mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-11 12:27:13 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dafe9176c9 | |||
| a708cfac40 | |||
| 1ea9cd3f6f | |||
| ff6ad6adc2 | |||
| ce6b647fe7 | |||
| 485113ae43 | |||
| 1ba3c1ec72 | |||
| fc1c998434 | |||
| 267232c24f | |||
| d6443b10bc | |||
| 0577689da4 | |||
| 0f216a0d4d |
@@ -10,12 +10,16 @@ on:
|
||||
- 'src/langbot/pkg/persistence/**'
|
||||
- 'src/langbot/pkg/entity/persistence/**'
|
||||
- 'tests/integration/persistence/**'
|
||||
- 'tests/unit_tests/api/service/test_monitoring_sessions.py'
|
||||
- '.github/workflows/test-migrations.yml'
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- 'src/langbot/pkg/persistence/**'
|
||||
- 'src/langbot/pkg/entity/persistence/**'
|
||||
- 'tests/integration/persistence/**'
|
||||
- 'tests/unit_tests/api/service/test_monitoring_sessions.py'
|
||||
- '.github/workflows/test-migrations.yml'
|
||||
|
||||
jobs:
|
||||
test-migrations-sqlite:
|
||||
@@ -80,6 +84,8 @@ jobs:
|
||||
run: >-
|
||||
uv run pytest
|
||||
tests/integration/persistence/test_migrations_postgres.py
|
||||
tests/integration/persistence/test_monitoring_postgres.py
|
||||
tests/unit_tests/api/service/test_monitoring_sessions.py::test_postgres_upgrade_rls_and_concurrent_bot_counts
|
||||
tests/integration/persistence/test_pgvector_postgres.py
|
||||
tests/integration/persistence/test_release_migration_postgres.py
|
||||
tests/integration/persistence/test_plugin_identity_migration.py
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# ChatGPT / Codex subscription
|
||||
|
||||
LangBot's **OpenAI Codex** model provider uses **Sign in with ChatGPT** and the account's Codex entitlement. It is separate from the existing OpenAI API-key provider: subscribing to ChatGPT does not supply an OpenAI Platform API key, and API-key billing is unchanged.
|
||||
|
||||
## Connect an account
|
||||
|
||||
1. Open **Models**, choose **Add Provider**, and select **OpenAI Codex**.
|
||||
2. Enter a provider name and choose **Save and sign in**. This saves the provider before authorization, so an interrupted login can be retried from its settings.
|
||||
3. Open the OpenAI authorization link and enter the one-time code displayed in LangBot. Sign in on OpenAI's site, not in LangBot.
|
||||
4. If OpenAI asks you to enable device-code authorization, enable it in your ChatGPT account's security settings, or contact your workspace administrator.
|
||||
5. Keep the LangBot dialog open until it confirms the connection, then finish the form.
|
||||
6. Use the existing **Scan models** or **Add model** controls, test the model, and select it in a pipeline as usual. Only LLM models are supported by this provider.
|
||||
|
||||
The device-code flow also works when LangBot runs remotely or in Docker: the browser does not need to reach a localhost OAuth callback on the server. Serve the LangBot management panel over HTTPS when accessing it remotely.
|
||||
|
||||
The account's model catalog is authoritative. A model listed elsewhere or entered manually is not a guarantee that this account has access. Scan errors are reported rather than replaced with a fabricated available-model list.
|
||||
|
||||
## Reconnect and disconnect
|
||||
|
||||
Open the provider's existing settings to sign in again or disconnect. LangBot refreshes expiring access tokens automatically. A revoked or invalid refresh grant requires another sign-in; transient network failures are not proof that the grant was revoked.
|
||||
|
||||
**Disconnect** removes this provider's locally stored authorization. It does not log the account out of other applications or revoke the account globally. Canceling a pending sign-in is separate from disconnecting an existing account. Removing a provider also removes its authorization; the normal rule that models must be removed first still applies.
|
||||
|
||||
A saved provider can remain disconnected. Scanning or invoking it then returns a sign-in-required error; LangBot does not silently switch to paid API-key billing.
|
||||
|
||||
## Usage and deployment boundary
|
||||
|
||||
Calls consume the connected account's included Codex usage and remain subject to OpenAI's plan limits, model availability, workspace policies, and terms. Token counts recorded by LangBot are request usage, not a measurement of remaining subscription quota or an OpenAI invoice.
|
||||
|
||||
Use this integration for your own authorized account and trusted workflows. Third-party sign-in support is not permission to pool accounts, resell subscription quota, or redistribute one subscription as a shared API service. For a public or commercial multi-user service, use the appropriate OpenAI API or separately authorized enterprise arrangement. The provider remains a Workspace resource in LangBot: consider who can invoke its models before connecting a personal account.
|
||||
|
||||
## Credential handling and API surface
|
||||
|
||||
- OAuth credentials are stored server-side separately from provider API keys. Provider and model reads do not supply OAuth access, refresh, or ID tokens.
|
||||
- Authorization uses a fixed OpenAI origin. The Codex provider does not accept a custom base URL or manually supplied API keys.
|
||||
- Authentication controls require an authenticated LangBot browser user with `provider_secret.manage` in the selected Workspace. Pending attempts are scoped to the Workspace, provider, and initiating user.
|
||||
- Browser storage must not contain OAuth tokens. Treat the server database and its backups as sensitive application data.
|
||||
- MCP and LangBot API keys do not expose the browser-only OAuth controls. Agents may inspect configured providers and models with the existing tools, but a human connects the subscription in the management panel.
|
||||
|
||||
The provider-scoped authentication routes are under `/api/v1/provider/providers/{uuid}/codex`:
|
||||
|
||||
| Method | Suffix | Purpose |
|
||||
| --- | --- | --- |
|
||||
| GET | `/status` | Read local connection state without returning credentials |
|
||||
| POST | `/device` | Start device authorization |
|
||||
| POST | `/device/poll` | Poll the initiating user's authorization attempt |
|
||||
| DELETE | `/device/{authorization_id}` | Cancel only that pending attempt |
|
||||
| DELETE | `/auth` | Remove local authorization |
|
||||
|
||||
Use the returned polling interval and expiration time. An expired attempt must be restarted. These routes are not a general-purpose subscription-to-API gateway.
|
||||
|
||||
## References
|
||||
|
||||
- [OpenAI Codex authentication](https://developers.openai.com/codex/auth): ChatGPT versus API-key access and device-code login.
|
||||
- [Hermes Agent providers](https://hermes-agent.nousresearch.com/docs/integrations/providers/): subscription device authentication and refresh recovery.
|
||||
- [OpenClaw OpenAI provider](https://docs.openclaw.ai/providers/openai): subscription and API-key route distinctions.
|
||||
- [New API](https://github.com/QuantumNous/new-api): reference for Codex protocol compatibility; its gateway/account-pooling product model is not adopted here.
|
||||
|
||||
## 中文快速说明
|
||||
|
||||
在「模型」中添加提供商,选择 **OpenAI Codex**,填写名称并点击「保存并登录」。打开 OpenAI 授权页面,输入 LangBot 显示的一次性验证码,完成授权后回到原对话框。随后照常扫描或添加模型、测试模型,并在流水线中选择它。
|
||||
|
||||
无需填写 API Key,也无需为远程服务器配置 localhost 回调。登录中断后可以从该提供商的设置中重试;断开连接只删除 LangBot 中保存的授权。调用消耗所登录账号的 Codex 额度,受账号实际权限和 OpenAI 限制约束,不会自动转用按量付费的 OpenAI API。
|
||||
|
||||
此功能用于自己的授权账号及可信工作流,不应将个人订阅作为面向多个用户转售或共享的 API 服务。提供商仍是 LangBot 工作空间内的资源,连接个人账号前请确认模型的使用范围。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
+1
-1
@@ -70,7 +70,7 @@ dependencies = [
|
||||
"langchain-text-splitters>=1.1.2",
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"langbot-plugin==0.5.7",
|
||||
"langbot-plugin==0.5.8",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
|
||||
@@ -88,6 +88,38 @@ already have a default pipeline.
|
||||
4. Use `list_*` tools to discover, then `get_*` / `create_*` / `update_*` /
|
||||
`delete_*` as needed.
|
||||
|
||||
## ChatGPT / Codex subscription providers
|
||||
|
||||
`list_model_providers` can return the `openai-codex` requester. Its OAuth
|
||||
credentials are server-only and are not provider API keys. Never ask a user
|
||||
to paste ChatGPT access tokens, refresh tokens, or a Codex auth cache into an
|
||||
MCP tool or model configuration.
|
||||
|
||||
A human connects or disconnects the subscription through **Models → provider
|
||||
settings** in the LangBot web UI. The provider-scoped `/codex/*` authentication
|
||||
routes deliberately require a browser-user session and are not exposed as MCP
|
||||
tools or authorized by a LangBot API key. Once connected, models are managed
|
||||
and selected through the normal provider/model workflow. A disconnected
|
||||
provider must be reauthorized; do not silently replace it with API-key billing.
|
||||
|
||||
See [ChatGPT / Codex subscription](../../../docs/CODEX_SUBSCRIPTION.md) for setup,
|
||||
usage limits, and the personal-account versus shared-service boundary.
|
||||
|
||||
## Provider deletion
|
||||
|
||||
The curated MCP surface currently lists providers but has no provider-deletion
|
||||
tool. In the web UI, **Edit Provider → Delete** asks for confirmation before
|
||||
removing that provider and all its LLM, embedding, and rerank models. This is
|
||||
irreversible; never interpret a request to edit a provider as authorization to
|
||||
delete it.
|
||||
|
||||
The equivalent HTTP operation is
|
||||
`DELETE /api/v1/provider/providers/{uuid}?cascade=true`, requiring
|
||||
`resource.manage` in the authenticated Workspace. Omitting `cascade` preserves
|
||||
the existing refusal to delete providers that still have models. Cloud-managed
|
||||
providers remain protected. Cascade deletion removes stored Codex authorization
|
||||
state as well; it is not the same operation as disconnecting an account.
|
||||
|
||||
## Implementation & maintenance (for LangBot developers)
|
||||
|
||||
- Server: `src/langbot/pkg/api/mcp/server.py` (FastMCP). Tools call the service
|
||||
|
||||
@@ -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,
|
||||
@@ -247,6 +248,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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -377,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,
|
||||
@@ -405,6 +414,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
session_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
bot_id=quart.request.args.get('botId'),
|
||||
)
|
||||
|
||||
# Always return success with the analysis data
|
||||
|
||||
@@ -9,6 +9,80 @@ 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'],
|
||||
@@ -95,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))
|
||||
|
||||
@@ -2,6 +2,8 @@ import quart
|
||||
import argon2
|
||||
import asyncio
|
||||
import datetime
|
||||
import hmac
|
||||
import time
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
@@ -11,6 +13,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):
|
||||
@@ -81,6 +110,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 +133,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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -29,6 +29,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 +294,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)
|
||||
@@ -415,7 +430,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,
|
||||
@@ -437,7 +452,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 +625,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 +637,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 +669,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 +679,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 +698,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 +801,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))
|
||||
@@ -1272,9 +1304,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)
|
||||
if user_query and user_query.strip():
|
||||
user_pattern = f'%{user_query.strip()}%'
|
||||
conditions.append(
|
||||
@@ -1376,6 +1408,7 @@ class MonitoringService:
|
||||
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)
|
||||
@@ -1385,8 +1418,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 {
|
||||
@@ -1395,6 +1433,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(
|
||||
@@ -1422,6 +1461,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()
|
||||
@@ -1460,6 +1500,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()
|
||||
@@ -1486,12 +1527,14 @@ 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)
|
||||
@@ -1520,6 +1563,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)
|
||||
@@ -2004,9 +2048,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()
|
||||
@@ -2040,6 +2084,7 @@ class MonitoringService:
|
||||
|
||||
# ========== Feedback Methods ==========
|
||||
|
||||
@_workspace_transaction
|
||||
async def record_feedback(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
@@ -2054,7 +2099,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.
|
||||
@@ -2110,7 +2155,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}
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)'
|
||||
)
|
||||
|
||||
@@ -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,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'])
|
||||
@@ -62,6 +62,7 @@ _ALEMBIC_TENANT_TABLES = {
|
||||
'binary_storages',
|
||||
'mcp_servers',
|
||||
'model_providers',
|
||||
'codex_credentials',
|
||||
'llm_models',
|
||||
'embedding_models',
|
||||
'rerank_models',
|
||||
|
||||
@@ -51,6 +51,7 @@ 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',
|
||||
@@ -206,6 +207,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,
|
||||
}
|
||||
@@ -852,7 +855,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()
|
||||
|
||||
@@ -1369,6 +1395,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(
|
||||
@@ -1381,6 +1408,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:
|
||||
@@ -1388,6 +1418,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)
|
||||
|
||||
@@ -79,6 +79,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,
|
||||
)
|
||||
|
||||
@@ -682,8 +682,8 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)')
|
||||
continue
|
||||
|
||||
# Use configured logout command, fallback to deriving from login command
|
||||
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
||||
# Use configured logout command, fallback to deriving from login command
|
||||
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
||||
lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...')
|
||||
|
||||
# Cancel existing tasks
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import typing
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import aiohttp
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
|
||||
_MATTERMOST_MAX_POST_LENGTH = 16_383
|
||||
_MENTION_BOUNDARY = r'(?<![\w.-])@{username}(?![\w.-])'
|
||||
|
||||
|
||||
def _normalize_server_url(server_url: str) -> str:
|
||||
"""Return a validated Mattermost server URL without a trailing slash."""
|
||||
|
||||
url = server_url.strip().rstrip('/')
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme not in {'http', 'https'} or not parsed.netloc:
|
||||
raise ValueError('Mattermost server_url must be an absolute HTTP(S) URL')
|
||||
return url
|
||||
|
||||
|
||||
def _websocket_url(server_url: str) -> str:
|
||||
parsed = urlsplit(server_url)
|
||||
scheme = 'wss' if parsed.scheme == 'https' else 'ws'
|
||||
return urlunsplit((scheme, parsed.netloc, f'{parsed.path}/api/v4/websocket', '', ''))
|
||||
|
||||
|
||||
class MattermostMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
"""Translate Mattermost post text to and from LangBot message chains."""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> str:
|
||||
parts: list[str] = []
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
parts.append(component.text)
|
||||
elif isinstance(component, platform_message.Image) and component.url:
|
||||
# Mattermost renders image URLs in Markdown messages.
|
||||
parts.append(component.url)
|
||||
elif isinstance(component, platform_message.File) and component.url:
|
||||
parts.append(component.url)
|
||||
return ''.join(parts)
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(post: dict, bot_username: str) -> platform_message.MessageChain:
|
||||
text = str(post.get('message') or '')
|
||||
components: list[typing.Any] = [
|
||||
platform_message.Source(
|
||||
id=str(post.get('id') or ''),
|
||||
time=float(post.get('create_at') or 0) / 1000,
|
||||
)
|
||||
]
|
||||
if bot_username:
|
||||
mention_pattern = re.compile(_MENTION_BOUNDARY.format(username=re.escape(bot_username)), re.IGNORECASE)
|
||||
if mention_pattern.search(text):
|
||||
components.append(platform_message.At(target=bot_username))
|
||||
text = mention_pattern.sub('', text).strip()
|
||||
if text:
|
||||
components.append(platform_message.Plain(text=text))
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
|
||||
class MattermostEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent) -> dict:
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(
|
||||
post: dict,
|
||||
channel: dict,
|
||||
sender_name: str,
|
||||
bot_username: str,
|
||||
) -> platform_events.MessageEvent:
|
||||
message_chain = await MattermostMessageConverter.target2yiri(post, bot_username)
|
||||
timestamp = float(post.get('create_at') or 0) / 1000
|
||||
sender_id = str(post.get('user_id') or '')
|
||||
channel_type = channel.get('type')
|
||||
|
||||
if channel_type == 'D':
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(id=sender_id, nickname=sender_name or sender_id, remark=''),
|
||||
message_chain=message_chain,
|
||||
time=timestamp,
|
||||
source_platform_object={'post': post, 'channel': channel},
|
||||
)
|
||||
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=sender_id,
|
||||
member_name=sender_name or sender_id,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=str(post.get('channel_id') or ''),
|
||||
name=str(channel.get('display_name') or channel.get('name') or post.get('channel_id') or ''),
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=timestamp,
|
||||
source_platform_object={'post': post, 'channel': channel},
|
||||
)
|
||||
|
||||
|
||||
class MattermostAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""Mattermost Bot Account adapter using the v4 REST and WebSocket APIs."""
|
||||
|
||||
server_url: str = ''
|
||||
access_token: str = ''
|
||||
session: aiohttp.ClientSession | None = None
|
||||
listeners: dict[typing.Type[platform_events.Event], typing.Callable] = {}
|
||||
channel_cache: dict[str, dict] = {}
|
||||
stream_post_ids: dict[str, str] = {}
|
||||
bot_username: str = ''
|
||||
_running: bool = False
|
||||
|
||||
message_converter: MattermostMessageConverter = MattermostMessageConverter()
|
||||
event_converter: MattermostEventConverter = MattermostEventConverter()
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
server_url = _normalize_server_url(str(config.get('server_url') or ''))
|
||||
access_token = str(config.get('access_token') or '').strip()
|
||||
if not access_token:
|
||||
raise ValueError('Mattermost adapter requires an access_token')
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
server_url=server_url,
|
||||
access_token=access_token,
|
||||
bot_account_id='',
|
||||
session=None,
|
||||
listeners={},
|
||||
channel_cache={},
|
||||
stream_post_ids={},
|
||||
bot_username='',
|
||||
_running=False,
|
||||
)
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self.session is None or self.session.closed:
|
||||
self.session = aiohttp.ClientSession(
|
||||
headers={'Authorization': f'Bearer {self.access_token}'},
|
||||
raise_for_status=False,
|
||||
)
|
||||
return self.session
|
||||
|
||||
async def _api_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
payload: dict | None = None,
|
||||
) -> dict:
|
||||
session = await self._get_session()
|
||||
async with session.request(method, f'{self.server_url}/api/v4{path}', json=payload) as response:
|
||||
raw_body = await response.text()
|
||||
if response.status >= 400:
|
||||
# Mattermost returns a useful JSON error, but never include request headers/tokens in errors.
|
||||
try:
|
||||
error = json.loads(raw_body).get('message', raw_body)
|
||||
except json.JSONDecodeError:
|
||||
error = raw_body
|
||||
raise RuntimeError(f'Mattermost API {method} {path} failed ({response.status}): {error}')
|
||||
if not raw_body:
|
||||
return {}
|
||||
return json.loads(raw_body)
|
||||
|
||||
async def _load_bot_identity(self) -> None:
|
||||
user = await self._api_request('GET', '/users/me')
|
||||
self.bot_account_id = str(user.get('id') or '')
|
||||
self.bot_username = str(user.get('username') or '')
|
||||
if not self.bot_account_id:
|
||||
raise RuntimeError('Mattermost API did not return a bot user ID')
|
||||
|
||||
async def _get_channel(self, channel_id: str) -> dict:
|
||||
if channel_id not in self.channel_cache:
|
||||
self.channel_cache[channel_id] = await self._api_request('GET', f'/channels/{channel_id}')
|
||||
return self.channel_cache[channel_id]
|
||||
|
||||
async def _post_message(self, channel_id: str, text: str, root_id: str = '') -> dict:
|
||||
if not text:
|
||||
return {}
|
||||
if len(text) > _MATTERMOST_MAX_POST_LENGTH:
|
||||
raise ValueError(f'Mattermost messages cannot exceed {_MATTERMOST_MAX_POST_LENGTH} characters')
|
||||
payload = {'channel_id': channel_id, 'message': text}
|
||||
if root_id:
|
||||
payload['root_id'] = root_id
|
||||
return await self._api_request('POST', '/posts', payload=payload)
|
||||
|
||||
async def _get_direct_channel_id(self, user_id: str) -> str:
|
||||
if not self.bot_account_id:
|
||||
await self._load_bot_identity()
|
||||
channel = await self._api_request(
|
||||
'POST',
|
||||
'/channels/direct',
|
||||
payload={'user_ids': [self.bot_account_id, user_id]},
|
||||
)
|
||||
channel_id = str(channel.get('id') or '')
|
||||
if not channel_id:
|
||||
raise RuntimeError('Mattermost did not return a direct-message channel ID')
|
||||
self.channel_cache[channel_id] = channel
|
||||
return channel_id
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
if target_type not in {'person', 'group'}:
|
||||
raise ValueError("Mattermost target_type must be 'person' or 'group'")
|
||||
text = await self.message_converter.yiri2target(message)
|
||||
channel_id = str(target_id)
|
||||
if target_type == 'person':
|
||||
channel_id = await self._get_direct_channel_id(channel_id)
|
||||
await self._post_message(channel_id, text)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
source = await self.event_converter.yiri2target(message_source)
|
||||
post = source['post']
|
||||
text = await self.message_converter.yiri2target(message)
|
||||
# A message received inside a Mattermost thread must remain in that thread. When
|
||||
# quote_origin is requested, make the response a reply to the source root post.
|
||||
root_id = str(post.get('root_id') or '')
|
||||
if quote_origin and not root_id:
|
||||
root_id = str(post.get('id') or '')
|
||||
await self._post_message(str(post['channel_id']), text, root_id)
|
||||
|
||||
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
||||
source = await self.event_converter.yiri2target(event)
|
||||
post = source['post']
|
||||
root_id = str(post.get('root_id') or post.get('id') or '')
|
||||
created = await self._post_message(str(post['channel_id']), 'Thinking…', root_id)
|
||||
if created.get('id'):
|
||||
self.stream_post_ids[str(message_id)] = str(created['id'])
|
||||
return True
|
||||
return False
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
):
|
||||
response_id = str(bot_message.resp_message_id)
|
||||
text = await self.message_converter.yiri2target(message)
|
||||
if not text:
|
||||
return
|
||||
|
||||
post_id = self.stream_post_ids.get(response_id)
|
||||
if post_id:
|
||||
await self._api_request('PUT', f'/posts/{post_id}', payload={'id': post_id, 'message': text})
|
||||
else:
|
||||
source = await self.event_converter.yiri2target(message_source)
|
||||
post = source['post']
|
||||
root_id = str(post.get('root_id') or '')
|
||||
if quote_origin and not root_id:
|
||||
root_id = str(post.get('id') or '')
|
||||
created = await self._post_message(str(post['channel_id']), text, root_id)
|
||||
post_id = str(created.get('id') or '')
|
||||
if post_id:
|
||||
self.stream_post_ids[response_id] = post_id
|
||||
|
||||
if is_final and getattr(bot_message, 'tool_calls', None) is None:
|
||||
self.stream_post_ids.pop(response_id, None)
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return bool(self.config.get('enable_stream_reply', True))
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
||||
],
|
||||
):
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def _dispatch_post(self, payload: dict) -> None:
|
||||
data = payload.get('data') or {}
|
||||
try:
|
||||
post = json.loads(data.get('post') or '{}')
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
await self.logger.error('Mattermost received a posted event with an invalid post payload')
|
||||
return
|
||||
|
||||
if not post or str(post.get('user_id') or '') == self.bot_account_id:
|
||||
return
|
||||
channel_id = str(post.get('channel_id') or '')
|
||||
if not channel_id:
|
||||
return
|
||||
|
||||
try:
|
||||
channel = await self._get_channel(channel_id)
|
||||
event = await self.event_converter.target2yiri(
|
||||
post,
|
||||
channel,
|
||||
str(data.get('sender_name') or post.get('user_id') or ''),
|
||||
self.bot_username,
|
||||
)
|
||||
callback = self.listeners.get(type(event))
|
||||
if callback:
|
||||
result = callback(event, self)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception as exc:
|
||||
await self.logger.error(f'Error handling Mattermost post: {exc}')
|
||||
|
||||
async def _run_websocket_once(self) -> None:
|
||||
session = await self._get_session()
|
||||
async with session.ws_connect(_websocket_url(self.server_url), heartbeat=30) as websocket:
|
||||
await websocket.send_json(
|
||||
{
|
||||
'seq': 1,
|
||||
'action': 'authentication_challenge',
|
||||
'data': {'token': self.access_token},
|
||||
}
|
||||
)
|
||||
async for message in websocket:
|
||||
if message.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
payload = json.loads(message.data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if payload.get('event') == 'posted':
|
||||
await self._dispatch_post(payload)
|
||||
elif message.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR}:
|
||||
break
|
||||
|
||||
async def run_async(self):
|
||||
self._running = True
|
||||
await self._load_bot_identity()
|
||||
await self.logger.info(f'Mattermost bot connected: @{self.bot_username} ({self.bot_account_id})')
|
||||
|
||||
retry_delay = 1
|
||||
while self._running:
|
||||
try:
|
||||
await self._run_websocket_once()
|
||||
retry_delay = 1
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if self._running:
|
||||
await self.logger.error(f'Mattermost WebSocket disconnected: {exc}')
|
||||
await asyncio.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, 30)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
self._running = False
|
||||
if self.session and not self.session.closed:
|
||||
await self.session.close()
|
||||
return True
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="Artwork" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 140"><defs><style>.cls-1{fill:#1e325c;fill-rule:evenodd;}</style></defs><path class="cls-1" d="M111.11,13.36l.74,14.86c12.04,13.3,16.8,32.15,10.81,49.86-8.95,26.44-38.46,40.33-65.92,31.04-27.46-9.29-42.45-38.26-33.5-64.7,6.01-17.77,21.32-29.87,39.05-33.07L71.87.03C41.99-.77,13.8,17.77,3.72,47.55c-12.4,36.6,7.24,76.33,43.85,88.73,36.6,12.4,76.33-7.24,88.73-43.85,10.07-29.74-1-61.55-25.14-79.07h-.03Z"/><path class="cls-1" d="M93.95,57.21l-.51-20.77-.41-11.95-.28-10.35s.07-4.99-.11-6.16c-.03-.25-.11-.44-.21-.62,0-.03-.02-.05-.03-.07,0-.02-.03-.05-.03-.07-.2-.33-.49-.59-.89-.72s-.8-.1-1.17.05h-.02s-.08.03-.13.07c-.16.08-.34.2-.51.36-.85.82-3.84,4.83-3.84,4.83l-6.5,8.06-7.59,9.25-13.02,16.19s-5.98,7.46-4.65,16.64c1.31,9.18,8.15,13.65,13.43,15.44,5.29,1.79,13.43,2.38,20.05-4.11,6.62-6.49,6.4-16.04,6.4-16.04l.02-.02Z"/></svg>
|
||||
|
After Width: | Height: | Size: 938 B |
@@ -0,0 +1,75 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: mattermost
|
||||
label:
|
||||
en_US: Mattermost
|
||||
zh_Hans: Mattermost
|
||||
zh_Hant: Mattermost
|
||||
ja_JP: Mattermost
|
||||
th_TH: Mattermost
|
||||
vi_VN: Mattermost
|
||||
es_ES: Mattermost
|
||||
icon: mattermost.svg
|
||||
description:
|
||||
en_US: Mattermost Bot Account adapter using the v4 REST and WebSocket APIs. Add me to the teams and channels where you want me to interact. Please use a browser or desktop application to do this.
|
||||
zh_Hans: 使用 Mattermost v4 REST API 与 WebSocket 的 Bot Account 适配器。请将我添加到您想要我互动的团队与频道。请使用浏览器或桌面应用进行操作。
|
||||
zh_Hant: 使用 Mattermost v4 REST API 與 WebSocket 的 Bot Account 介面卡。請將我加入您希望我互動的團隊與頻道。請使用瀏覽器或桌面應用程式操作。
|
||||
ja_JP: Mattermost v4 REST API と WebSocket を使用する Bot Account アダプター。利用させたいチームとチャンネルに私を追加してください。ブラウザまたはデスクトップアプリで操作してください。
|
||||
th_TH: อะแดปเตอร์ Bot Account ของ Mattermost ผ่าน v4 REST API และ WebSocket โปรดเพิ่มฉันไปยังทีมและช่องที่คุณต้องการให้ฉันโต้ตอบ โปรดดำเนินการผ่านเบราว์เซอร์หรือแอปเดสก์ท็อป
|
||||
vi_VN: Bộ điều hợp Bot Account Mattermost sử dụng REST API v4 và WebSocket. Hãy thêm tôi vào các nhóm và kênh mà bạn muốn tôi tương tác. Vui lòng thao tác bằng trình duyệt hoặc ứng dụng máy tính để bàn.
|
||||
es_ES: Adaptador de Bot Account de Mattermost mediante REST API v4 y WebSocket. Añádeme a los equipos y canales en los que quieras que interactúe. Hazlo desde un navegador o la aplicación de escritorio.
|
||||
spec:
|
||||
categories:
|
||||
- global
|
||||
- popular
|
||||
config:
|
||||
- name: server_url
|
||||
label:
|
||||
en_US: Mattermost Server URL
|
||||
zh_Hans: Mattermost 服务器地址
|
||||
zh_Hant: 位址伺服器 Mattermost
|
||||
ja_JP: Mattermost サーバー URL
|
||||
th_TH: URL เซิร์ฟเวอร์ Mattermost
|
||||
vi_VN: URL máy chủ Mattermost
|
||||
es_ES: URL del servidor Mattermost
|
||||
description:
|
||||
en_US: The base URL of the Mattermost server, for example https://mattermost.example.com
|
||||
zh_Hans: Mattermost 服务器基础地址,例如 https://mattermost.example.com
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: access_token
|
||||
label:
|
||||
en_US: Bot Access Token
|
||||
zh_Hans: Bot 访问令牌
|
||||
zh_Hant: Bot 存取權杖
|
||||
ja_JP: Bot アクセストークン
|
||||
th_TH: โทเค็นการเข้าถึงของบอต
|
||||
vi_VN: Mã truy cập Bot
|
||||
es_ES: Token de acceso del bot
|
||||
description:
|
||||
en_US: The personal access token generated for the Mattermost Bot Account
|
||||
zh_Hans: 为 Mattermost Bot Account 生成的个人访问令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: enable_stream_reply
|
||||
label:
|
||||
en_US: Enable Stream Reply
|
||||
zh_Hans: 启用流式回复
|
||||
zh_Hant: 啟用串流回覆
|
||||
ja_JP: ストリーミング返信を有効化
|
||||
th_TH: เปิดใช้งานการตอบกลับแบบสตรีม
|
||||
vi_VN: Bật phản hồi luồng
|
||||
es_ES: Activar respuesta en streaming
|
||||
description:
|
||||
en_US: Update a Mattermost post while LangBot generates a response
|
||||
zh_Hans: 在 LangBot 生成回复时持续更新同一条 Mattermost 消息
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
execution:
|
||||
python:
|
||||
path: ./mattermost.py
|
||||
attr: MattermostAdapter
|
||||
@@ -48,6 +48,7 @@ from ..utils import constants
|
||||
|
||||
_DEFAULT_BINARY_STORAGE_VALUE_BYTES = 10 * 1024 * 1024
|
||||
_HARD_MAX_BINARY_STORAGE_VALUE_BYTES = 64 * 1024 * 1024
|
||||
_UNSET_INSTALLATION_SCOPE = object()
|
||||
|
||||
|
||||
def _binary_storage_value_limit(ap: Any) -> int:
|
||||
@@ -479,7 +480,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
self._outbound_installation_context: contextvars.ContextVar[InstallationBinding | None] = (
|
||||
contextvars.ContextVar(
|
||||
f'{self.__class__.__name__}_{id(self)}_outbound_installation',
|
||||
default=None,
|
||||
)
|
||||
)
|
||||
self._installation_bindings: dict[
|
||||
@@ -1631,13 +1631,15 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
) -> InstallationBinding | ActionContext | None:
|
||||
if action_context is not None:
|
||||
return super().resolve_outbound_action_context(action_context)
|
||||
inbound_context = self.current_action_context
|
||||
if inbound_context is not None:
|
||||
return inbound_context
|
||||
return self._outbound_installation_context.get()
|
||||
# An explicit scope targets the nested call, not its inbound caller.
|
||||
# None deliberately clears the context for runtime-scoped actions.
|
||||
scoped_context = self._outbound_installation_context.get(_UNSET_INSTALLATION_SCOPE)
|
||||
if scoped_context is not _UNSET_INSTALLATION_SCOPE:
|
||||
return typing.cast(InstallationBinding | None, scoped_context)
|
||||
return self.current_action_context
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
"""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'],
|
||||
}
|
||||
level = reasoning.normalize_reasoning_config(getattr(model.model_entity, 'reasoning_config', None))['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
|
||||
@@ -90,7 +90,7 @@ class LangflowAPIRunner(runner.RequestRunner):
|
||||
}
|
||||
|
||||
# 如果配置中有tweaks,则添加到负载中
|
||||
tweaks = json.loads(self.pipeline_config['ai']['langflow-api'].get('tweaks'))
|
||||
tweaks = json.loads(self.pipeline_config['ai']['langflow-api'].get('tweaks') or '{}')
|
||||
if tweaks:
|
||||
payload['tweaks'] = tweaks
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import enum
|
||||
import json
|
||||
import math
|
||||
@@ -206,6 +207,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
|
||||
@@ -265,6 +273,7 @@ class RuntimeMCPSession:
|
||||
_ready_event: asyncio.Event
|
||||
|
||||
error_message: str | None = None
|
||||
_public_error_code: str = 'runtime_error'
|
||||
|
||||
error_phase: MCPSessionErrorPhase | None = None
|
||||
|
||||
@@ -510,6 +519,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 '
|
||||
@@ -630,6 +646,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
|
||||
@@ -752,6 +769,11 @@ class RuntimeMCPSession:
|
||||
except Exception as e:
|
||||
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
|
||||
if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE:
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is not None and getattr(box_service, 'enabled', True):
|
||||
@@ -832,6 +854,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.
|
||||
@@ -1374,7 +1429,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
|
||||
|
||||
@@ -143,18 +143,41 @@ stages:
|
||||
operator: eq
|
||||
value: false
|
||||
disabled_tooltip:
|
||||
en_US: >-
|
||||
Sandbox scope can't be changed: either the Box sandbox is disabled
|
||||
or unavailable (enable it in config.yaml with box.enabled = true and
|
||||
ensure the runtime is reachable), or this deployment pins all
|
||||
pipelines to a fixed scope.
|
||||
zh_Hans: "无法修改沙箱作用域:Box 沙箱已禁用或不可用(请在配置中启用 box.enabled = true 并确认运行时连接正常),或本部署已将所有流水线固定为统一作用域。"
|
||||
zh_Hant: "無法修改沙箱作用域:Box 沙箱已停用或無法使用(請在設定中啟用 box.enabled = true 並確認執行時連線正常),或本部署已將所有流水線固定為統一作用域。"
|
||||
ja_JP: "サンドボックススコープを変更できません:Box サンドボックスが無効/利用不可(設定で box.enabled = true にしてランタイム接続を確認)、またはこのデプロイがすべてのパイプラインを固定スコープに制限しています。"
|
||||
vi_VN: "Không thể thay đổi phạm vi sandbox:Box sandbox bị tắt hoặc không khả dụng (bật box.enabled = true và đảm bảo runtime hoạt động), hoặc bản triển khai này cố định mọi pipeline về một phạm vi."
|
||||
th_TH: "ไม่สามารถเปลี่ยนขอบเขต Sandbox:Box sandbox ถูกปิดหรือไม่พร้อมใช้งาน (เปิด box.enabled = true และตรวจสอบรันไทม์) หรือการ deploy นี้ล็อกทุก pipeline ไว้ที่ขอบเขตเดียว"
|
||||
es_ES: "No se puede cambiar el alcance del sandbox: el sandbox de Box está desactivado o no disponible (actívelo con box.enabled = true y verifique el runtime), o este despliegue fija todas las pipelines a un alcance único."
|
||||
ru_RU: "Невозможно изменить область песочницы: песочница Box отключена или недоступна (включите box.enabled = true и проверьте среду выполнения), либо это развёртывание фиксирует единую область для всех конвейеров."
|
||||
en_US: "Sandbox is unavailable. Enable Box and check its connection before changing the scope."
|
||||
zh_Hans: "沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。"
|
||||
zh_Hant: "沙箱未啟用,請啟用 Box 並確認連線正常後再修改作用域。"
|
||||
ja_JP: "サンドボックスは利用できません。Box を有効にし、接続を確認してからスコープを変更してください。"
|
||||
vi_VN: "Sandbox không khả dụng. Hãy bật Box và kiểm tra kết nối trước khi thay đổi phạm vi."
|
||||
th_TH: "Sandbox ไม่พร้อมใช้งาน โปรดเปิดใช้งาน Box และตรวจสอบการเชื่อมต่อก่อนเปลี่ยนขอบเขต"
|
||||
es_ES: "El sandbox no está disponible. Active Box y compruebe su conexión antes de cambiar el alcance."
|
||||
ru_RU: "Песочница недоступна. Включите Box и проверьте подключение, прежде чем менять область."
|
||||
disabled_tooltip_overrides:
|
||||
- when:
|
||||
field: __system.box_scope_forced_global
|
||||
operator: eq
|
||||
value: true
|
||||
tooltip:
|
||||
en_US: "A global sandbox is enforced; the scope cannot be changed."
|
||||
zh_Hans: "已强制使用全局沙箱,无法修改作用域。"
|
||||
zh_Hant: "已強制使用全域沙箱,無法修改作用域。"
|
||||
ja_JP: "グローバルサンドボックスの使用が強制されているため、スコープを変更できません。"
|
||||
vi_VN: "Bắt buộc sử dụng sandbox toàn cục; không thể thay đổi phạm vi."
|
||||
th_TH: "ระบบบังคับใช้ Sandbox ส่วนกลาง จึงไม่สามารถเปลี่ยนขอบเขตได้"
|
||||
es_ES: "Se impone un sandbox global; no se puede cambiar el alcance."
|
||||
ru_RU: "Принудительно используется глобальная песочница; изменить область нельзя."
|
||||
- when:
|
||||
field: __system.box_scope_forced
|
||||
operator: eq
|
||||
value: true
|
||||
tooltip:
|
||||
en_US: "A fixed sandbox scope is enforced; the scope cannot be changed."
|
||||
zh_Hans: "已强制使用固定沙箱作用域,无法修改作用域。"
|
||||
zh_Hant: "已強制使用固定沙箱作用域,無法修改作用域。"
|
||||
ja_JP: "固定のサンドボックススコープが強制されているため、スコープを変更できません。"
|
||||
vi_VN: "Phạm vi sandbox đã được cố định bắt buộc; không thể thay đổi phạm vi."
|
||||
th_TH: "ระบบบังคับใช้ขอบเขต Sandbox แบบตายตัว จึงไม่สามารถเปลี่ยนขอบเขตได้"
|
||||
es_ES: "Se impone un alcance fijo del sandbox; no se puede cambiar el alcance."
|
||||
ru_RU: "Принудительно задана фиксированная область песочницы; изменить её нельзя."
|
||||
type: select
|
||||
required: false
|
||||
default: "{launcher_type}_{launcher_id}"
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Exercise Codex provider wiring through a real LangBot process.
|
||||
|
||||
The default run does not contact OpenAI. Set LANGBOT_TEST_CODEX_DEVICE_AUTH=1
|
||||
to also exercise live device start/pending/cancel, without account sign-in.
|
||||
OAuth exchange and inference behavior are covered by deterministic tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def test_codex_provider_disconnected_journey(e2e_client):
|
||||
credentials = {'user': 'codex-e2e@example.com', 'password': 'codex-local-test-password'}
|
||||
initialized = e2e_client.post('/api/v1/user/init', json=credentials)
|
||||
assert initialized.status_code == 200, initialized.text
|
||||
authenticated = e2e_client.post('/api/v1/user/auth', json=credentials)
|
||||
assert authenticated.status_code == 200, authenticated.text
|
||||
headers = {'Authorization': f'Bearer {authenticated.json()["data"]["token"]}'}
|
||||
bootstrap = e2e_client.get('/api/v1/workspaces/bootstrap', headers=headers)
|
||||
assert bootstrap.status_code == 200, bootstrap.text
|
||||
headers['X-Workspace-Id'] = bootstrap.json()['data']['workspaces'][0]['workspace']['uuid']
|
||||
|
||||
requesters = e2e_client.get('/api/v1/provider/requesters?type=llm', headers=headers)
|
||||
assert requesters.status_code == 200, requesters.text
|
||||
codex = next(item for item in requesters.json()['data']['requesters'] if item['name'] == 'openai-codex')
|
||||
assert codex['spec']['support_type'] == ['llm']
|
||||
icon = e2e_client.get('/api/v1/provider/requesters/openai-codex/icon')
|
||||
assert icon.status_code == 200
|
||||
assert 'image/' in icon.headers['content-type']
|
||||
|
||||
base = '/api/v1/provider/providers'
|
||||
created = e2e_client.post(
|
||||
base,
|
||||
headers=headers,
|
||||
json={'name': 'Codex E2E', 'requester': 'openai-codex', 'base_url': '', 'api_keys': []},
|
||||
)
|
||||
assert created.status_code == 200, created.text
|
||||
provider_path = f'{base}/{created.json()["data"]["uuid"]}'
|
||||
try:
|
||||
provider = e2e_client.get(provider_path, headers=headers)
|
||||
assert provider.status_code == 200, provider.text
|
||||
data = provider.json()['data']['provider']
|
||||
assert data['requester'] == 'openai-codex'
|
||||
assert data['api_keys'] == []
|
||||
assert data['base_url'] == 'https://chatgpt.com/backend-api/codex'
|
||||
assert not {'access_token', 'refresh_token', 'id_token'} & data.keys()
|
||||
|
||||
status = e2e_client.get(f'{provider_path}/codex/status', headers=headers)
|
||||
assert status.status_code == 200, status.text
|
||||
assert status.json()['data']['connected'] is False
|
||||
assert status.json()['data']['status'] == 'disconnected'
|
||||
|
||||
anonymous = e2e_client.post(f'{provider_path}/codex/device', json={})
|
||||
assert anonymous.status_code == 401
|
||||
invalid = e2e_client.put(provider_path, headers=headers, json={'base_url': 'https://example.com'})
|
||||
assert invalid.status_code == 400, invalid.text
|
||||
invalid_key = e2e_client.put(provider_path, headers=headers, json={'api_keys': ['not-a-codex-key']})
|
||||
assert invalid_key.status_code == 400, invalid_key.text
|
||||
|
||||
scanned = e2e_client.get(f'{provider_path}/scan-models?type=llm', headers=headers)
|
||||
assert scanned.status_code == 400, scanned.text
|
||||
assert 'sign in' in scanned.json()['msg'].lower()
|
||||
|
||||
renamed = e2e_client.put(provider_path, headers=headers, json={'name': 'Codex renamed'})
|
||||
assert renamed.status_code == 200, renamed.text
|
||||
reread = e2e_client.get(provider_path, headers=headers)
|
||||
assert reread.json()['data']['provider']['name'] == 'Codex renamed'
|
||||
disconnected = e2e_client.delete(f'{provider_path}/codex/auth', headers=headers)
|
||||
assert disconnected.status_code == 200, disconnected.text
|
||||
|
||||
# Opt-in smoke contacts real OpenAI device endpoints, but never completes
|
||||
# account sign-in or prints the one-time code/device credentials.
|
||||
if os.environ.get('LANGBOT_TEST_CODEX_DEVICE_AUTH') == '1':
|
||||
started = e2e_client.post(f'{provider_path}/codex/device', headers=headers, json={})
|
||||
assert started.status_code == 200, started.json().get('msg', 'Device start failed')
|
||||
attempt = started.json()['data']
|
||||
assert attempt['verification_uri'] == 'https://auth.openai.com/codex/device'
|
||||
assert isinstance(attempt['user_code'], str) and attempt['user_code']
|
||||
assert 0 < attempt['expires_at'] - time.time() <= 900
|
||||
assert not {'access_token', 'refresh_token', 'device_auth_id'} & attempt.keys()
|
||||
time.sleep(attempt['interval'])
|
||||
pending = e2e_client.post(
|
||||
f'{provider_path}/codex/device/poll',
|
||||
headers=headers,
|
||||
json={'authorization_id': attempt['authorization_id']},
|
||||
)
|
||||
assert pending.status_code == 200
|
||||
assert pending.json()['data']['status'] == 'pending'
|
||||
canceled = e2e_client.delete(f'{provider_path}/codex/device/{attempt["authorization_id"]}', headers=headers)
|
||||
assert canceled.status_code == 200
|
||||
expired = e2e_client.post(
|
||||
f'{provider_path}/codex/device/poll',
|
||||
headers=headers,
|
||||
json={'authorization_id': attempt['authorization_id']},
|
||||
)
|
||||
assert expired.json()['data']['status'] == 'expired'
|
||||
finally:
|
||||
deleted = e2e_client.delete(provider_path, headers=headers)
|
||||
assert deleted.status_code == 200, deleted.text
|
||||
assert e2e_client.get(provider_path, headers=headers).status_code == 404
|
||||
@@ -69,7 +69,7 @@ class LangBotProcess:
|
||||
# Use coverage.py to collect coverage data
|
||||
# Set COVERAGE_PROCESS_START to enable coverage in subprocess
|
||||
self._coverage_file = self.work_dir / '.coverage.e2e'
|
||||
env['COVERAGE_PROCESS_START'] = str(self.project_root / '.coveragerc')
|
||||
env['COVERAGE_PROCESS_START'] = str(self.work_dir / '.coveragerc')
|
||||
env['COVERAGE_FILE'] = str(self._coverage_file)
|
||||
|
||||
# Create .coveragerc for subprocess
|
||||
|
||||
@@ -9,7 +9,7 @@ Run: uv run pytest tests/integration/api/test_monitoring.py -q
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, Mock
|
||||
from unittest.mock import MagicMock, AsyncMock, Mock, patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
from tests.factories import FakeApp
|
||||
@@ -280,13 +280,20 @@ class TestMonitoringAllDataEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_data_success(self, quart_test_client):
|
||||
"""GET /api/v1/monitoring/data returns all data."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/data', headers={'Authorization': 'Bearer test_token'}
|
||||
)
|
||||
traffic = {'series': [], 'truncated': False}
|
||||
with patch(
|
||||
'langbot.pkg.api.http.controller.groups.monitoring.get_traffic_series',
|
||||
new=AsyncMock(return_value=traffic),
|
||||
) as get_traffic:
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/data', headers={'Authorization': 'Bearer test_token'}
|
||||
)
|
||||
get_traffic.assert_awaited_once()
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert 'overview' in data['data']
|
||||
assert data['data']['traffic'] == traffic
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from quart import Quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups import user as user_module
|
||||
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
||||
from langbot.pkg.api.http.service.user import UserService
|
||||
from langbot.pkg.core.stages.genkeys import GenKeysStage
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
from langbot.pkg.utils import constants
|
||||
from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
|
||||
from langbot.pkg.workspace.service import WorkspaceService
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
||||
|
||||
|
||||
async def test_generated_recovery_code_resets_real_sqlite_account(tmp_path, monkeypatch):
|
||||
"""Exercise generation, reset, and old/new password login without mocked user services."""
|
||||
monkeypatch.setattr(constants, 'instance_id', 'recovery-journey')
|
||||
monkeypatch.setattr(user_module, '_reset_password_state', {'window_started_at': 0.0, 'attempts': 0})
|
||||
monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=AsyncMock()))
|
||||
application = SimpleNamespace(
|
||||
logger=logging.getLogger('recovery-password-journey'),
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'database': {'use': 'sqlite', 'sqlite': {'path': str(tmp_path / 'recovery.db')}},
|
||||
'system': {
|
||||
'jwt': {'secret': 'recovery-journey-test-secret-only', 'expire': 3600},
|
||||
'recovery_key': '',
|
||||
},
|
||||
},
|
||||
dump_config=AsyncMock(),
|
||||
),
|
||||
)
|
||||
await GenKeysStage().run(application)
|
||||
key = application.instance_config.data['system']['recovery_key']
|
||||
assert len(key) == 8
|
||||
assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ')
|
||||
persistence = PersistenceManager(application)
|
||||
application.persistence_mgr = persistence
|
||||
try:
|
||||
await persistence.initialize()
|
||||
application.workspace_service = WorkspaceService(application, instance_uuid='recovery-journey')
|
||||
application.workspace_collaboration_service = WorkspaceCollaborationService(
|
||||
application, application.workspace_service
|
||||
)
|
||||
application.user_service = UserService(application)
|
||||
quart_app = Quart(__name__)
|
||||
await UserRouterGroup(application, quart_app).initialize()
|
||||
client = quart_app.test_client()
|
||||
|
||||
initial = await client.post(
|
||||
'/api/v1/user/init', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'}
|
||||
)
|
||||
assert initial.status_code == 200
|
||||
assert (await initial.get_json())['code'] == 0
|
||||
|
||||
payload = {'user': 'owner@example.com', 'recovery_key': 'WRONG', 'new_password': 'RecoveredPass1!'}
|
||||
wrong = await client.post('/api/v1/user/reset-password', json=payload)
|
||||
assert wrong.status_code == 403
|
||||
unchanged = await client.post(
|
||||
'/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'}
|
||||
)
|
||||
assert (await unchanged.get_json())['code'] == 0
|
||||
|
||||
reset = await client.post('/api/v1/user/reset-password', json={**payload, 'recovery_key': key})
|
||||
assert reset.status_code == 200
|
||||
assert (await reset.get_json())['code'] == 0
|
||||
old_login = await client.post(
|
||||
'/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'OriginalPass1!'}
|
||||
)
|
||||
assert (await old_login.get_json())['code'] != 0
|
||||
new_login = await client.post(
|
||||
'/api/v1/user/auth', json={'user': 'owner@example.com', 'password': 'RecoveredPass1!'}
|
||||
)
|
||||
new_data = await new_login.get_json()
|
||||
assert new_data['code'] == 0
|
||||
assert new_data['data']['token']
|
||||
finally:
|
||||
await persistence.get_db_engine().dispose()
|
||||
@@ -193,6 +193,22 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
|
||||
sa.Column('message_id', sa.String(255), nullable=True),
|
||||
)
|
||||
|
||||
# Include historical monitoring columns consumed by later migrations.
|
||||
for table_name in ('monitoring_messages', 'monitoring_sessions'):
|
||||
table = monitoring_tables[table_name]
|
||||
for name, value in (('bot_name', 'bot'), ('pipeline_id', 'pipeline-1'), ('pipeline_name', 'pipeline')):
|
||||
table.append_column(sa.Column(name, sa.String(255), nullable=False, default=value))
|
||||
for name in ('platform', 'user_id', 'user_name'):
|
||||
table.append_column(sa.Column(name, sa.String(255)))
|
||||
if table_name == 'monitoring_messages':
|
||||
table.append_column(sa.Column('bot_id', sa.String(255), nullable=False, default='bot-1'))
|
||||
table.append_column(sa.Column('role', sa.String(50)))
|
||||
else:
|
||||
table.append_column(sa.Column('message_count', sa.Integer, nullable=False, default=1))
|
||||
table.append_column(
|
||||
sa.Column('start_time', sa.DateTime, nullable=False, default=datetime.datetime(2026, 1, 1))
|
||||
)
|
||||
|
||||
now = datetime.datetime(2026, 1, 1)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(metadata.create_all)
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
"""Deterministic OAuth tests using real SQLite CAS writes, never live credentials."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.authz import Permission
|
||||
from langbot.pkg.api.http.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||
from langbot.pkg.entity.persistence.model import CodexCredential
|
||||
from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade
|
||||
from langbot.pkg.provider.modelmgr.codex_auth import CodexAuth, _tokens, validate_config
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
|
||||
|
||||
def context(workspace='w', user='u', principal=PrincipalType.ACCOUNT, permitted=True):
|
||||
return RequestContext(
|
||||
'i',
|
||||
0,
|
||||
'r',
|
||||
'user_token',
|
||||
PrincipalContext(principal, account_uuid=user),
|
||||
WorkspaceContext(
|
||||
workspace, 'm', 'owner', frozenset({Permission.PROVIDER_SECRET_MANAGE} if permitted else set())
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def jwt(**claims):
|
||||
return 'test.' + base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip('=') + '.test'
|
||||
|
||||
|
||||
def token_response(**extra):
|
||||
return {
|
||||
'access_token': jwt(**{'https://api.openai.com/auth': {'chatgpt_account_id': 'account'}}),
|
||||
'refresh_token': 'refresh-secret',
|
||||
'expires_in': 3600,
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def auth(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "codex.db"}')
|
||||
|
||||
@sa.event.listens_for(engine.sync_engine, 'connect')
|
||||
def foreign_keys(connection, _):
|
||||
connection.execute('PRAGMA foreign_keys=ON')
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
'CREATE TABLE model_providers (uuid VARCHAR(255) PRIMARY KEY, workspace_uuid VARCHAR(36) NOT NULL, requester TEXT, UNIQUE(workspace_uuid, uuid))'
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO model_providers VALUES ('p','w','openai-codex'), ('other','other','openai-codex'), ('api','w','openai-chat-completions')"
|
||||
)
|
||||
)
|
||||
await run_alembic_stamp(engine, '0021_merge_reasoning_config')
|
||||
await run_alembic_upgrade(engine, '0022_codex_credentials')
|
||||
|
||||
async def execute(statement):
|
||||
async with engine.begin() as conn:
|
||||
return await conn.execute(statement)
|
||||
|
||||
service = CodexAuth(SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=execute)))
|
||||
service.engine = engine
|
||||
await execute(
|
||||
sa.insert(CodexCredential).values(provider_uuid='p', workspace_uuid='w', payload={}, version=0, lease_until=0)
|
||||
)
|
||||
try:
|
||||
yield service
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def seed(auth, payload):
|
||||
await auth.ap.persistence_mgr.execute_async(sa.update(CodexCredential).values(payload=payload))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_upgrade_repeat_fk_cascade(auth):
|
||||
await run_alembic_upgrade(auth.engine, '0022_codex_credentials')
|
||||
await run_alembic_stamp(auth.engine, '0021_merge_reasoning_config')
|
||||
await run_alembic_upgrade(auth.engine, '0022_codex_credentials')
|
||||
with pytest.raises(sa.exc.IntegrityError):
|
||||
await auth.ap.persistence_mgr.execute_async(
|
||||
sa.insert(CodexCredential).values(
|
||||
provider_uuid='other', workspace_uuid='w', payload={}, version=0, lease_until=0
|
||||
)
|
||||
)
|
||||
await auth.ap.persistence_mgr.execute_async(sa.text("DELETE FROM model_providers WHERE uuid='p'"))
|
||||
assert await auth._read('w', 'p') is None
|
||||
from langbot.pkg.persistence.alembic_runner import run_alembic_downgrade
|
||||
|
||||
await run_alembic_downgrade(auth.engine, '0021_merge_reasoning_config')
|
||||
async with auth.engine.connect() as conn:
|
||||
assert 'codex_credentials' not in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
|
||||
await run_alembic_upgrade(auth.engine, '0022_codex_credentials')
|
||||
async with auth.engine.connect() as conn:
|
||||
assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_pacing_exchange_secrecy_and_user_binding(auth):
|
||||
auth._post = AsyncMock(
|
||||
side_effect=[
|
||||
httpx.Response(200, json={'device_auth_id': 'device-secret', 'usercode': 'CODE', 'interval': '5'}),
|
||||
httpx.Response(200, json={'authorization_code': 'code-secret', 'code_verifier': 'verifier-secret'}),
|
||||
httpx.Response(200, json=token_response()),
|
||||
]
|
||||
)
|
||||
start = await auth.start(context(), 'p')
|
||||
assert set(start) == {'authorization_id', 'user_code', 'interval', 'expires_at', 'verification_uri'}
|
||||
assert 'device-secret' not in json.dumps(start)
|
||||
attempt = start['authorization_id']
|
||||
with pytest.raises(WorkspaceNotFoundError):
|
||||
await auth.poll(context(user='attacker'), 'p', attempt)
|
||||
assert (await auth.poll(context(), 'p', attempt))['status'] == 'pending'
|
||||
assert auth._post.await_count == 1
|
||||
row = await auth._read('w', 'p')
|
||||
row['payload']['pending']['next_poll_at'] = 0
|
||||
await seed(auth, row['payload'])
|
||||
assert await auth.poll(context(), 'p', attempt) == {'status': 'connected'}
|
||||
assert await auth.poll(context(), 'p', attempt) == {'status': 'connected'}
|
||||
exchange = auth._post.call_args.kwargs['data']
|
||||
assert exchange['grant_type'] == 'authorization_code'
|
||||
assert exchange['redirect_uri'] == 'https://auth.openai.com/deviceauth/callback'
|
||||
assert exchange['code_verifier'] == 'verifier-secret'
|
||||
status = await auth.status(context(), 'p')
|
||||
assert set(status) == {'status', 'connected', 'expires_at'}
|
||||
assert 'secret' not in json.dumps(status)
|
||||
await auth.disconnect(context(), 'p')
|
||||
assert (await auth._read('w', 'p'))['payload'] == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'ctx,provider,error',
|
||||
[
|
||||
(context('other'), 'p', WorkspaceNotFoundError),
|
||||
(context(principal=PrincipalType.API_KEY), 'p', ValueError),
|
||||
(context(permitted=False), 'p', ValueError),
|
||||
(context(), 'api', ValueError),
|
||||
],
|
||||
)
|
||||
async def test_auth_tenant_principal_permission_guards(auth, ctx, provider, error):
|
||||
auth._post = AsyncMock()
|
||||
with pytest.raises(error):
|
||||
await auth.start(ctx, provider)
|
||||
auth._post.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_cross_instance_single_flight_and_rotation(auth):
|
||||
old = _tokens(token_response())
|
||||
old['expires_at'] = 0
|
||||
await seed(auth, {'tokens': old})
|
||||
entered, release = asyncio.Event(), asyncio.Event()
|
||||
|
||||
async def refresh(*args, **kwargs):
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return httpx.Response(200, json=token_response(refresh_token='rotated-secret'))
|
||||
|
||||
auth._post = AsyncMock(side_effect=refresh)
|
||||
other = CodexAuth(auth.ap)
|
||||
other._post = auth._post
|
||||
first = asyncio.create_task(auth.access('w', 'p'))
|
||||
await entered.wait()
|
||||
second = asyncio.create_task(other.access('w', 'p'))
|
||||
release.set()
|
||||
a, b = await asyncio.gather(first, second)
|
||||
assert a == b
|
||||
assert a['refresh_token'] == 'rotated-secret'
|
||||
assert auth._post.await_count == 1
|
||||
assert (await auth._read('w', 'p'))['payload']['tokens'] == a
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'status,error,invalid',
|
||||
[
|
||||
(400, 'invalid_grant', True),
|
||||
(401, 'refresh_token_reused', True),
|
||||
(429, 'limited', False),
|
||||
(500, 'secret-upstream-body', False),
|
||||
(403, 'permission_denied', False),
|
||||
],
|
||||
)
|
||||
async def test_refresh_errors_are_safe_and_transient_preserves_tokens(auth, status, error, invalid):
|
||||
old = _tokens(token_response())
|
||||
old['expires_at'] = 0
|
||||
await seed(auth, {'tokens': old})
|
||||
auth._post = AsyncMock(
|
||||
return_value=httpx.Response(status, json={'error': error, 'access_token': 'secret-upstream-body'})
|
||||
)
|
||||
with pytest.raises(ValueError) as caught:
|
||||
await auth.access('w', 'p')
|
||||
assert 'secret' not in str(caught.value)
|
||||
payload = (await auth._read('w', 'p'))['payload']
|
||||
assert bool(payload.get('invalid')) == invalid
|
||||
assert ('tokens' not in payload) if invalid else payload['tokens'] == old
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('cancel', [False, True])
|
||||
async def test_disconnect_or_cancel_fences_inflight_exchange(auth, cancel):
|
||||
old = _tokens(token_response())
|
||||
await seed(
|
||||
auth,
|
||||
{
|
||||
'tokens': old,
|
||||
'pending': {
|
||||
'authorization_id': 'attempt',
|
||||
'account_uuid': 'u',
|
||||
'expires_at': time.time() + 100,
|
||||
'next_poll_at': 0,
|
||||
'interval': 5,
|
||||
'device_auth_id': 'device',
|
||||
'user_code': 'code',
|
||||
},
|
||||
},
|
||||
)
|
||||
entered, release = asyncio.Event(), asyncio.Event()
|
||||
|
||||
async def post(path, **kwargs):
|
||||
if path.endswith('/token') and path != '/oauth/token':
|
||||
return httpx.Response(200, json={'authorization_code': 'code', 'code_verifier': 'verifier'})
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return httpx.Response(200, json=token_response())
|
||||
|
||||
auth._post = post
|
||||
task = asyncio.create_task(auth.poll(context(), 'p', 'attempt'))
|
||||
await entered.wait()
|
||||
if cancel:
|
||||
await auth.cancel(context(), 'p', 'attempt')
|
||||
else:
|
||||
await auth.disconnect(context(), 'p')
|
||||
release.set()
|
||||
with pytest.raises(ValueError, match='cancelled or replaced'):
|
||||
await task
|
||||
payload = (await auth._read('w', 'p'))['payload']
|
||||
assert payload == ({'tokens': old} if cancel else {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('status,interval', [(403, 5), (404, 5), (429, 10)])
|
||||
async def test_device_pending_and_backoff(auth, status, interval):
|
||||
await seed(
|
||||
auth,
|
||||
{
|
||||
'pending': {
|
||||
'authorization_id': 'attempt',
|
||||
'account_uuid': 'u',
|
||||
'expires_at': time.time() + 100,
|
||||
'next_poll_at': 0,
|
||||
'interval': 5,
|
||||
'device_auth_id': 'device',
|
||||
'user_code': 'code',
|
||||
}
|
||||
},
|
||||
)
|
||||
auth._post = AsyncMock(return_value=httpx.Response(status))
|
||||
assert await auth.poll(context(), 'p', 'attempt') == {'status': 'pending', 'interval': interval}
|
||||
assert await auth.poll(context(), 'p', 'attempt') == {'status': 'pending', 'interval': interval}
|
||||
assert auth._post.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_replacement_expiry_and_idempotent_cancel(auth):
|
||||
auth._post = AsyncMock(return_value=httpx.Response(200, json={'device_auth_id': 'device', 'user_code': 'CODE'}))
|
||||
first = await auth.start(context(), 'p')
|
||||
second = await auth.start(context(), 'p')
|
||||
assert first['authorization_id'] != second['authorization_id']
|
||||
assert await auth.poll(context(), 'p', first['authorization_id']) == {'status': 'expired'}
|
||||
await auth.cancel(context(), 'p', first['authorization_id'])
|
||||
payload = (await auth._read('w', 'p'))['payload']
|
||||
assert payload['pending']['authorization_id'] == second['authorization_id']
|
||||
payload['pending']['expires_at'] = 0
|
||||
await seed(auth, payload)
|
||||
assert await auth.poll(context(), 'p', second['authorization_id']) == {'status': 'expired'}
|
||||
assert 'pending' not in (await auth._read('w', 'p'))['payload']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_accepts_issuer_iso_expiry(auth):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
expires = datetime.fromtimestamp(time.time() + 600, timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||
auth._post = AsyncMock(
|
||||
return_value=httpx.Response(200, json={'device_auth_id': 'device', 'user_code': 'CODE', 'expires_at': expires})
|
||||
)
|
||||
result = await auth.start(context(), 'p')
|
||||
assert time.time() < result['expires_at'] < time.time() + 900
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_expires_in_fallback(auth):
|
||||
auth._post = AsyncMock(
|
||||
return_value=httpx.Response(200, json={'device_auth_id': 'device', 'user_code': 'CODE', 'expires_in': 60})
|
||||
)
|
||||
result = await auth.start(context(), 'p')
|
||||
assert time.time() < result['expires_at'] <= time.time() + 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('cancel_reads_before_refresh', [False, True])
|
||||
async def test_cancel_pending_relogin_waits_for_existing_refresh(auth, cancel_reads_before_refresh):
|
||||
old = _tokens(token_response())
|
||||
old['expires_at'] = 0
|
||||
await seed(auth, {'tokens': old, 'pending': {'authorization_id': 'attempt', 'account_uuid': 'u'}})
|
||||
entered, release, cancel_read = asyncio.Event(), asyncio.Event(), asyncio.Event()
|
||||
|
||||
async def refresh(*args, **kwargs):
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return httpx.Response(200, json=token_response(refresh_token='rotated-secret'))
|
||||
|
||||
other = CodexAuth(auth.ap)
|
||||
original_read = other._read
|
||||
|
||||
async def read(workspace, provider):
|
||||
row = await original_read(workspace, provider)
|
||||
cancel_read.set()
|
||||
if cancel_reads_before_refresh:
|
||||
await entered.wait()
|
||||
return row
|
||||
|
||||
other._read = read
|
||||
auth._post = refresh
|
||||
if cancel_reads_before_refresh:
|
||||
cancelling = asyncio.create_task(other.cancel(context(), 'p', 'attempt'))
|
||||
await cancel_read.wait()
|
||||
refreshing = asyncio.create_task(auth.access('w', 'p'))
|
||||
await entered.wait()
|
||||
if not cancel_reads_before_refresh:
|
||||
cancelling = asyncio.create_task(other.cancel(context(), 'p', 'attempt'))
|
||||
await cancel_read.wait()
|
||||
await asyncio.sleep(0.05)
|
||||
try:
|
||||
assert not cancelling.done(), 'Cancellation must not revoke the refresh lease'
|
||||
finally:
|
||||
release.set()
|
||||
results = await asyncio.gather(refreshing, cancelling, return_exceptions=True)
|
||||
assert not any(isinstance(result, Exception) for result in results)
|
||||
payload = (await auth._read('w', 'p'))['payload']
|
||||
assert payload['tokens']['refresh_token'] == 'rotated-secret'
|
||||
assert 'pending' not in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('operation', ['save', 'cancel', 'disconnect', 'acquire', 'release', 'read'])
|
||||
async def test_credential_database_errors_never_expose_secrets(auth, operation):
|
||||
import traceback
|
||||
|
||||
markers = ['ACCESS-MARKER', 'REFRESH-MARKER', 'DEVICE-MARKER', 'VERIFIER-MARKER']
|
||||
payload = {
|
||||
'tokens': {'access_token': markers[0], 'refresh_token': markers[1]},
|
||||
'pending': {
|
||||
'authorization_id': 'attempt',
|
||||
'account_uuid': 'u',
|
||||
'device_auth_id': markers[2],
|
||||
'code_verifier': markers[3],
|
||||
},
|
||||
}
|
||||
await seed(auth, payload)
|
||||
if operation == 'read':
|
||||
auth.ap.persistence_mgr.execute_async = AsyncMock(
|
||||
side_effect=sa.exc.StatementError(
|
||||
'failure', 'SELECT credentials', {'payload': payload}, RuntimeError(markers[0])
|
||||
)
|
||||
)
|
||||
else:
|
||||
column = 'payload' if operation in ('save', 'cancel', 'disconnect') else 'lease_owner'
|
||||
condition = ' WHEN NEW.lease_owner IS NULL' if operation == 'release' else ''
|
||||
# Trigger errors can themselves contain secrets, even for parameter-free writes.
|
||||
await auth.ap.persistence_mgr.execute_async(
|
||||
sa.text(
|
||||
f'CREATE TRIGGER reject_write BEFORE UPDATE OF {column} ON codex_credentials{condition} '
|
||||
f"BEGIN SELECT RAISE(ABORT, '{' '.join(markers)}'); END"
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match='credential storage') as caught:
|
||||
if operation == 'save':
|
||||
async with auth._lease('w', 'p') as owner:
|
||||
await auth._save('w', 'p', owner, payload)
|
||||
elif operation == 'cancel':
|
||||
await auth.cancel(context(), 'p', 'attempt')
|
||||
elif operation == 'disconnect':
|
||||
await auth.disconnect(context(), 'p')
|
||||
elif operation == 'read':
|
||||
await auth._read('w', 'p')
|
||||
else:
|
||||
async with auth._lease('w', 'p'):
|
||||
pass
|
||||
rendered = ''.join(traceback.format_exception(caught.value))
|
||||
assert all(marker not in rendered for marker in markers)
|
||||
assert caught.value.__suppress_context__
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_serialization_failure_is_sanitized(auth):
|
||||
import traceback
|
||||
|
||||
class Secret:
|
||||
def __repr__(self):
|
||||
return 'SERIALIZATION-SECRET'
|
||||
|
||||
with pytest.raises(ValueError, match='credential storage') as caught:
|
||||
async with auth._lease('w', 'p') as owner:
|
||||
await auth._save('w', 'p', owner, {'tokens': {'refresh_token': Secret()}})
|
||||
assert 'SERIALIZATION-SECRET' not in ''.join(traceback.format_exception(caught.value))
|
||||
assert caught.value.__suppress_context__
|
||||
|
||||
|
||||
def test_token_refresh_fallback_and_config_validation():
|
||||
old = _tokens(token_response())
|
||||
refreshed = _tokens({'access_token': 'opaque-access', 'expires_in': 3600}, old)
|
||||
assert refreshed['refresh_token'] == old['refresh_token']
|
||||
assert refreshed['connection_id'] == old['connection_id']
|
||||
for expiry in [float('nan'), float('inf'), -1, 'bad']:
|
||||
with pytest.raises(ValueError):
|
||||
_tokens(token_response(expires_in=expiry))
|
||||
data = {'requester': 'openai-codex'}
|
||||
validate_config(data)
|
||||
assert data['api_keys'] == []
|
||||
for update in [{'base_url': 'https://evil.invalid'}, {'api_keys': ['secret']}]:
|
||||
with pytest.raises(ValueError):
|
||||
validate_config({**data, **update})
|
||||
ordinary = {'requester': 'openai-chat-completions', 'api_keys': ['key'], 'base_url': 'https://custom.invalid'}
|
||||
before = dict(ordinary)
|
||||
validate_config(ordinary)
|
||||
assert ordinary == before
|
||||
@@ -17,6 +17,7 @@ from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.persistence import mgr as persistence_mgr # noqa: F401 -- register all ORM tables
|
||||
from langbot.pkg.persistence.alembic_runner import (
|
||||
run_alembic_downgrade,
|
||||
run_alembic_upgrade,
|
||||
@@ -108,7 +109,6 @@ class TestSQLiteMigrationUpgrade:
|
||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||
|
||||
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||
assert _get_script_head() == '0021_merge_reasoning_config'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine):
|
||||
@@ -119,7 +119,7 @@ class TestSQLiteMigrationUpgrade:
|
||||
await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config')
|
||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||
|
||||
assert await get_alembic_current(sqlite_engine) == '0021_merge_reasoning_config'
|
||||
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
||||
@@ -280,6 +280,15 @@ class TestSQLiteMigrationUpgrade:
|
||||
class TestSQLiteMigrationFreshDatabase:
|
||||
"""Tests for fresh database workflow."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_scoped_sessions_skips_absent_table(self, sqlite_engine):
|
||||
"""A partial schema needs no session key migration in either direction."""
|
||||
await run_alembic_stamp(sqlite_engine, '0022_codex_credentials')
|
||||
await run_alembic_upgrade(sqlite_engine, '0023_bot_scoped_sessions')
|
||||
assert await get_alembic_current(sqlite_engine) == '0023_bot_scoped_sessions'
|
||||
await run_alembic_downgrade(sqlite_engine, '0022_codex_credentials')
|
||||
assert await get_alembic_current(sqlite_engine) == '0022_codex_credentials'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_db_upgrade_from_scratch(self, tmp_path):
|
||||
"""
|
||||
|
||||
@@ -549,10 +549,12 @@ class TestPostgreSQLWorkspaceMigration:
|
||||
await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
|
||||
)
|
||||
assert 'workspaces' not in tables_before_migration
|
||||
assert 'codex_credentials' not in tables_before_migration
|
||||
|
||||
await manager._initialize_managed_schema()
|
||||
|
||||
async with postgres_engine.connect() as conn:
|
||||
assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
|
||||
account = (await conn.execute(text('SELECT uuid, status, source FROM users'))).mappings().one()
|
||||
workspace = (
|
||||
(await conn.execute(text('SELECT * FROM workspaces WHERE source = :source'), {'source': 'local'}))
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""Monitoring regressions through asyncpg, Cloud UoW guards, and migrated RLS.
|
||||
|
||||
TEST_POSTGRES_URL must identify a disposable PostgreSQL/pgvector test server
|
||||
with permission to create databases and roles. Each run owns a fresh database;
|
||||
no existing tables are dropped. Without that URL these tests are skipped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.api.http.service.monitoring import MonitoringService
|
||||
from langbot.pkg.entity.persistence import monitoring as models
|
||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
from langbot.pkg.persistence.tenant_uow import TenantScopeRequiredError
|
||||
from langbot.pkg.pipeline.monitoring_helper import MonitoringHelper
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio(loop_scope='module')]
|
||||
|
||||
WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
|
||||
WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
|
||||
RESOURCE = dict(bot_id='same-bot', bot_name='Bot', pipeline_id='same-pipeline', pipeline_name='Pipeline')
|
||||
MONITORING_TABLES = tuple(
|
||||
table for table in models.MonitoringMessage.metadata.sorted_tables if table.name.startswith('monitoring_')
|
||||
)
|
||||
|
||||
|
||||
def _context(workspace_uuid):
|
||||
return ExecutionContext(
|
||||
instance_uuid='monitoring-postgres-test',
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=1,
|
||||
bot_uuid=RESOURCE['bot_id'],
|
||||
pipeline_uuid=RESOURCE['pipeline_id'],
|
||||
)
|
||||
|
||||
|
||||
def _application(url):
|
||||
return SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'database': {
|
||||
'use': 'postgresql',
|
||||
'postgresql': {
|
||||
'host': url.host,
|
||||
'port': url.port,
|
||||
'user': url.username,
|
||||
'password': url.password,
|
||||
'database': url.database,
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
logger=logging.getLogger('monitoring-postgres-test'),
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope='module', loop_scope='module')
|
||||
async def cloud_database():
|
||||
url = os.environ.get('TEST_POSTGRES_URL')
|
||||
if not url:
|
||||
pytest.skip('TEST_POSTGRES_URL not set')
|
||||
admin_url = sa.engine.make_url(url)
|
||||
admin = create_async_engine(admin_url, isolation_level='AUTOCOMMIT')
|
||||
suffix = uuid.uuid4().hex[:12]
|
||||
database_name = f'lb_monitoring_{suffix}'
|
||||
runtime_role = f'lb_monitoring_{suffix}'
|
||||
password = f'Test{uuid.uuid4().hex}'
|
||||
database_created = role_created = False
|
||||
release_manager = runtime_manager = None
|
||||
quote = admin.dialect.identifier_preparer.quote
|
||||
from langbot.pkg.persistence import mgr as mgr_module
|
||||
from langbot.pkg.persistence.databases.postgresql import PostgreSQLDatabaseManager
|
||||
from langbot.pkg.utils import constants
|
||||
|
||||
with pytest.MonkeyPatch.context() as patch:
|
||||
patch.setattr(mgr_module.database, 'preregistered_managers', [PostgreSQLDatabaseManager])
|
||||
patch.setattr(constants, 'instance_id', 'monitoring-postgres-test')
|
||||
try:
|
||||
async with admin.connect() as conn:
|
||||
await conn.execute(sa.text(f'CREATE DATABASE {quote(database_name)}'))
|
||||
database_created = True
|
||||
await conn.execute(
|
||||
sa.text(f"CREATE ROLE {quote(runtime_role)} LOGIN NOSUPERUSER NOBYPASSRLS PASSWORD '{password}'")
|
||||
)
|
||||
role_created = True
|
||||
release_app = _application(admin_url.set(database=database_name))
|
||||
release_manager = PersistenceManager(release_app, mode=PersistenceMode.RELEASE_MIGRATION)
|
||||
release_app.persistence_mgr = release_manager
|
||||
await release_manager.initialize()
|
||||
async with release_manager.get_db_engine().begin() as conn:
|
||||
for workspace in (WORKSPACE_A, WORKSPACE_B):
|
||||
await conn.execute(
|
||||
sa.insert(Workspace).values(
|
||||
uuid=workspace,
|
||||
instance_uuid='monitoring-postgres-test',
|
||||
name=workspace,
|
||||
slug=workspace,
|
||||
source='cloud_projection',
|
||||
)
|
||||
)
|
||||
tables = release_manager._runtime_business_table_names()
|
||||
quoted_tables = ', '.join(f'public.{quote(name)}' for name in tables)
|
||||
await conn.execute(
|
||||
sa.text(f'GRANT CONNECT ON DATABASE {quote(database_name)} TO {quote(runtime_role)}')
|
||||
)
|
||||
await conn.execute(sa.text(f'GRANT USAGE ON SCHEMA public TO {quote(runtime_role)}'))
|
||||
await conn.execute(
|
||||
sa.text(f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {quoted_tables} TO {quote(runtime_role)}')
|
||||
)
|
||||
await conn.execute(sa.text(f'GRANT SELECT ON public.alembic_version TO {quote(runtime_role)}'))
|
||||
sequences = await release_manager._runtime_business_sequence_names(conn, tables)
|
||||
if sequences:
|
||||
names = ', '.join(f'public.{quote(name)}' for name in sequences)
|
||||
await conn.execute(sa.text(f'GRANT USAGE, SELECT ON SEQUENCE {names} TO {quote(runtime_role)}'))
|
||||
runtime_app = _application(admin_url.set(database=database_name, username=runtime_role, password=password))
|
||||
runtime_manager = PersistenceManager(runtime_app, mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
runtime_app.persistence_mgr = runtime_manager
|
||||
await runtime_manager.initialize()
|
||||
runtime_app.monitoring_service = MonitoringService(runtime_app)
|
||||
yield runtime_app, release_manager.get_db_engine()
|
||||
finally:
|
||||
if runtime_manager is not None:
|
||||
await runtime_manager.shutdown()
|
||||
if release_manager is not None:
|
||||
await release_manager.shutdown()
|
||||
async with admin.connect() as conn:
|
||||
if database_created:
|
||||
await conn.execute(sa.text(f'DROP DATABASE {quote(database_name)} WITH (FORCE)'))
|
||||
if role_created:
|
||||
await conn.execute(sa.text(f'DROP ROLE {quote(runtime_role)}'))
|
||||
await admin.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(loop_scope='module')
|
||||
async def service(cloud_database):
|
||||
application, admin = cloud_database
|
||||
async with admin.begin() as conn:
|
||||
for table in MONITORING_TABLES:
|
||||
await conn.execute(sa.delete(table))
|
||||
application.instance_config.data.pop('monitoring', None)
|
||||
return application.monitoring_service
|
||||
|
||||
|
||||
async def _read(service, method, context, *args, **kwargs):
|
||||
# HTTP auth binds a tenant scope; exercise that same guard for service reads.
|
||||
async with service.ap.persistence_mgr.tenant_scope(context.workspace_uuid):
|
||||
return await getattr(service, method)(context, *args, **kwargs)
|
||||
|
||||
|
||||
def _query(context, sender_id):
|
||||
return SimpleNamespace(
|
||||
_execution_context=context,
|
||||
launcher_type='person',
|
||||
launcher_id='same-user',
|
||||
sender_id=sender_id,
|
||||
message_chain=SimpleNamespace(model_dump=lambda: [{'type': 'Plain', 'text': 'hello'}]),
|
||||
resp_message_chain=[SimpleNamespace(model_dump=lambda: [{'type': 'Plain', 'text': 'reply'}])],
|
||||
message_event=SimpleNamespace(sender=SimpleNamespace(nickname='Alice')),
|
||||
variables={'public': 'value', '_private': 'hidden'},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('user_id', [123456789, -100123456789, 0, None, '', '00123', ' opaque用户 '])
|
||||
@pytest.mark.parametrize('record_type', ['message', 'session', 'feedback'])
|
||||
async def test_optional_user_ids_round_trip_through_asyncpg(service, user_id, record_type):
|
||||
context = _context(WORKSPACE_A)
|
||||
expected = str(user_id) if isinstance(user_id, int) else user_id
|
||||
if record_type == 'message':
|
||||
record_id = await service.record_message(
|
||||
context,
|
||||
**RESOURCE,
|
||||
message_content='hello',
|
||||
session_id='same-session',
|
||||
user_id=user_id,
|
||||
)
|
||||
details = await _read(service, 'get_message_details', context, record_id)
|
||||
assert details['message']['user_id'] == expected
|
||||
elif record_type == 'session':
|
||||
await service.record_session_start(context, **RESOURCE, session_id='same-session', user_id=user_id)
|
||||
rows, total = await _read(service, 'get_sessions', context)
|
||||
assert total == 1
|
||||
assert rows[0]['user_id'] == expected
|
||||
else:
|
||||
await service.record_feedback(context, feedback_id='same-feedback', feedback_type=1, user_id=user_id)
|
||||
rows, total = await _read(service, 'get_feedback_list', context)
|
||||
assert total == 1
|
||||
assert rows[0]['user_id'] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('user_id', [123456789, -100123456789])
|
||||
async def test_query_lifecycle_persists_messages_session_and_llm_link(service, user_id, caplog):
|
||||
context = _context(WORKSPACE_A)
|
||||
query = _query(context, user_id)
|
||||
message_id = await MonitoringHelper.record_query_start(service.ap, query, **RESOURCE)
|
||||
assert message_id, caplog.text
|
||||
await MonitoringHelper.record_llm_call(
|
||||
service.ap,
|
||||
query,
|
||||
**RESOURCE,
|
||||
model_name='model',
|
||||
input_tokens=3,
|
||||
output_tokens=5,
|
||||
duration_ms=25,
|
||||
message_id=message_id,
|
||||
)
|
||||
await MonitoringHelper.record_query_success(service.ap, message_id, query)
|
||||
await MonitoringHelper.record_query_response(service.ap, query, **RESOURCE)
|
||||
rows, total = await _read(service, 'get_messages', context)
|
||||
assert total == 2
|
||||
assert {row['role'] for row in rows} == {'user', 'assistant'}
|
||||
assert {row['user_id'] for row in rows} == {str(user_id)}
|
||||
details = await _read(service, 'get_message_details', context, message_id)
|
||||
assert details['message']['status'] == 'success'
|
||||
assert details['message']['variables'] == '{"public": "value"}'
|
||||
assert details['llm_calls'][0]['message_id'] == message_id
|
||||
assert details['llm_stats']['total_tokens'] == 8
|
||||
sessions, total = await _read(service, 'get_sessions', context)
|
||||
assert total == 1
|
||||
assert sessions[0]['session_id'] == 'person_same-user'
|
||||
assert sessions[0]['user_id'] == str(user_id)
|
||||
assert not [record for record in caplog.records if record.levelno >= logging.ERROR]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('user_id', [123, -123])
|
||||
async def test_query_error_persists_error_message_and_linked_log(service, user_id, caplog):
|
||||
context = _context(WORKSPACE_A)
|
||||
message_id = await MonitoringHelper.record_query_error(
|
||||
service.ap,
|
||||
_query(context, user_id),
|
||||
**RESOURCE,
|
||||
error=ValueError('failed query'),
|
||||
)
|
||||
assert message_id, caplog.text
|
||||
details = await _read(service, 'get_message_details', context, message_id)
|
||||
assert details['message']['user_id'] == str(user_id)
|
||||
assert details['message']['status'] == 'error'
|
||||
assert details['errors'][0]['message_id'] == message_id
|
||||
assert details['errors'][0]['error_type'] == 'ValueError'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('user_id', [True, 1.5, b'123', ['123']])
|
||||
@pytest.mark.parametrize('record_type', ['message', 'session', 'feedback'])
|
||||
async def test_unsupported_user_ids_fail_at_the_write_boundary(service, user_id, record_type):
|
||||
context = _context(WORKSPACE_A)
|
||||
with pytest.raises(TypeError, match='user_id must be a string, integer, or None'):
|
||||
if record_type == 'message':
|
||||
await service.record_message(
|
||||
context,
|
||||
**RESOURCE,
|
||||
message_content='hello',
|
||||
session_id='session',
|
||||
user_id=user_id,
|
||||
)
|
||||
elif record_type == 'session':
|
||||
await service.record_session_start(context, **RESOURCE, session_id='session', user_id=user_id)
|
||||
else:
|
||||
await service.record_feedback(context, feedback_id='feedback', feedback_type=1, user_id=user_id)
|
||||
async with service.ap.persistence_mgr.tenant_scope(WORKSPACE_A):
|
||||
for model in (models.MonitoringMessage, models.MonitoringSession, models.MonitoringFeedback):
|
||||
count = await service.ap.persistence_mgr.execute_async(sa.select(sa.func.count()).select_from(model))
|
||||
assert count.scalar_one() == 0
|
||||
|
||||
|
||||
async def test_session_analysis_aggregates_under_cloud_sql_guard(service):
|
||||
context = _context(WORKSPACE_A)
|
||||
await service.record_session_start(context, **RESOURCE, session_id='same-session')
|
||||
await service.record_message(context, **RESOURCE, session_id='same-session', message_content='hello')
|
||||
result = await _read(service, 'get_session_analysis', context, 'same-session')
|
||||
assert result['found'] is True
|
||||
assert result['message_stats'] == {'total': 1, 'success': 1, 'error': 0, 'pending': 0}
|
||||
assert result['llm_stats']['total_calls'] == 0
|
||||
assert result['tool_stats']['total_calls'] == 0
|
||||
assert result['session_duration_seconds'] == 0
|
||||
|
||||
|
||||
async def test_rls_is_enforced_without_application_workspace_predicates(service, cloud_database):
|
||||
_, admin = cloud_database
|
||||
for workspace in (WORKSPACE_A, WORKSPACE_B):
|
||||
await service.record_message(
|
||||
_context(workspace), **RESOURCE, session_id='same-session', message_content=workspace
|
||||
)
|
||||
async with admin.connect() as conn:
|
||||
states = (
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
'SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class '
|
||||
"WHERE relname LIKE 'monitoring_%' AND relkind = 'r'"
|
||||
)
|
||||
)
|
||||
).all()
|
||||
assert len(states) == len(MONITORING_TABLES)
|
||||
assert all(enabled and forced for _, enabled, forced in states)
|
||||
engine = service.ap.persistence_mgr.get_db_engine()
|
||||
async with engine.connect() as conn:
|
||||
role = (
|
||||
await conn.execute(sa.text('SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user'))
|
||||
).one()
|
||||
assert role == (False, False)
|
||||
assert (await conn.execute(sa.select(models.MonitoringMessage.id))).all() == []
|
||||
for workspace in (WORKSPACE_A, WORKSPACE_B):
|
||||
async with service.ap.persistence_mgr.tenant_uow(workspace):
|
||||
rows = (
|
||||
await service.ap.persistence_mgr.execute_async(sa.select(models.MonitoringMessage.workspace_uuid))
|
||||
).all()
|
||||
assert rows == [(workspace,)]
|
||||
with pytest.raises(TenantScopeRequiredError):
|
||||
await service.ap.persistence_mgr.execute_async(sa.select(models.MonitoringMessage.id))
|
||||
|
||||
|
||||
async def test_traffic_series_aggregates_all_rows_under_cloud_rls(service):
|
||||
import datetime
|
||||
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
||||
|
||||
context = _context(WORKSPACE_A)
|
||||
for workspace, count in ((WORKSPACE_A, 61), (WORKSPACE_B, 2)):
|
||||
async with service.ap.persistence_mgr.tenant_scope(workspace):
|
||||
await service.ap.persistence_mgr.execute_async(
|
||||
sa.insert(models.MonitoringMessage).values(
|
||||
[
|
||||
dict(
|
||||
workspace_uuid=workspace,
|
||||
id=f'{workspace}-m-{i}',
|
||||
**RESOURCE,
|
||||
session_id='shared',
|
||||
message_content='test',
|
||||
status='success',
|
||||
level='info',
|
||||
timestamp=datetime.datetime(2026, 9, 11, 1, 30),
|
||||
)
|
||||
for i in range(count)
|
||||
]
|
||||
)
|
||||
)
|
||||
async with service.ap.persistence_mgr.tenant_uow(WORKSPACE_A):
|
||||
result = await get_traffic_series(
|
||||
service.ap,
|
||||
context,
|
||||
bot_ids=[RESOURCE['bot_id']],
|
||||
start_time=datetime.datetime(2026, 9, 11),
|
||||
end_time=datetime.datetime(2026, 9, 12),
|
||||
)
|
||||
assert result['truncated'] is False
|
||||
assert sum(point['messages'] for point in result['points']) == 61
|
||||
@@ -142,7 +142,7 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
|
||||
assert pk_columns == {
|
||||
'binary_storages': ('workspace_uuid', 'unique_key'),
|
||||
'plugin_settings': ('workspace_uuid', 'plugin_author', 'plugin_name'),
|
||||
'monitoring_sessions': ('workspace_uuid', 'session_id'),
|
||||
'monitoring_sessions': ('workspace_uuid', 'bot_id', 'session_id'),
|
||||
}
|
||||
|
||||
pipeline_run_foreign_keys = await _inspect(
|
||||
@@ -237,8 +237,10 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
'INSERT INTO monitoring_sessions '
|
||||
'(workspace_uuid, session_id, bot_id, last_activity, is_active) '
|
||||
"VALUES (:workspace_uuid, 'session-1', 'bot-2', CURRENT_TIMESTAMP, 1)"
|
||||
'(workspace_uuid, session_id, bot_id, bot_name, pipeline_id, pipeline_name, '
|
||||
'start_time, last_activity, message_count, is_active) '
|
||||
"VALUES (:workspace_uuid, 'session-1', 'bot-2', 'bot', 'pipeline-2', 'pipeline', "
|
||||
'CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1, 1)'
|
||||
),
|
||||
{'workspace_uuid': second_workspace_uuid},
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
@@ -34,7 +35,7 @@ def _manifest_payloads(backup_directory) -> list[dict]:
|
||||
|
||||
def _assert_verified_backup(payload: dict) -> None:
|
||||
backup_path = pathlib.Path(payload['backup_path'])
|
||||
with sqlite3.connect(f'{backup_path.as_uri()}?mode=ro', uri=True) as connection:
|
||||
with closing(sqlite3.connect(f'{backup_path.as_uri()}?mode=ro', uri=True)) as connection:
|
||||
assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)]
|
||||
assert connection.execute('SELECT version_num FROM alembic_version').fetchone()[0] == payload['source_revision']
|
||||
|
||||
|
||||
@@ -403,10 +403,12 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
|
||||
await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
|
||||
)
|
||||
assert 'workspaces' not in tables_before_migration
|
||||
assert 'codex_credentials' not in tables_before_migration
|
||||
|
||||
await manager._run_alembic_migrations()
|
||||
|
||||
async with engine.connect() as conn:
|
||||
assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
|
||||
workspace = (
|
||||
(await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Real Core/SDK protocol regression tests; no subprocesses or external services.
|
||||
|
||||
Run against the intended local SDK (``uv run --no-sync`` after local install).
|
||||
The in-memory transport carries JSON strings through Handler.run on both sides;
|
||||
send_file, envelope validation, base64 decoding and transfer storage are real.
|
||||
Only Core's database/object-storage services, parser dispatch/provider and host
|
||||
sandbox prerequisite probing are doubles. Worker launch/registration is
|
||||
represented by its already-registered state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
|
||||
from langbot_plugin.entities.io.actions.enums import CommonAction, LangBotToRuntimeAction, PluginToRuntimeAction
|
||||
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginWorkerPolicy, RuntimeIdentity
|
||||
from langbot_plugin.runtime.context import RuntimeContext
|
||||
from langbot_plugin.runtime.io.connection import Connection
|
||||
from langbot_plugin.entities.io.errors import ActionCallError, ConnectionClosedError
|
||||
from langbot_plugin.runtime.io.handler import FILE_CHUNK_LENGTH, Handler
|
||||
from langbot_plugin.runtime.io.handlers.control import ControlConnectionHandler
|
||||
from langbot_plugin.runtime.io.handlers.plugin import PluginConnectionHandler
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginManager
|
||||
from langbot_plugin.runtime.security import PLUGIN_FILE_STORAGE_DIR_ENV
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
PAYLOAD = bytes(range(256)) * 161 + b'\x00original RAG file\xff'
|
||||
BINDING = InstallationBinding(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=7,
|
||||
installation_uuid='00000000-0000-4000-8000-000000000001',
|
||||
runtime_revision=3,
|
||||
artifact_digest='a' * 64,
|
||||
)
|
||||
LEGACY = ActionContext(**BINDING.model_dump(exclude={'runtime_revision', 'artifact_digest'}))
|
||||
|
||||
|
||||
class QueueConnection(Connection):
|
||||
"""Only the byte transport is replaced, not the request/response machinery."""
|
||||
|
||||
def __init__(self):
|
||||
self.incoming = asyncio.Queue()
|
||||
self.sent = []
|
||||
self.peer = None
|
||||
|
||||
async def send(self, message: str) -> None:
|
||||
assert isinstance(message, str)
|
||||
self.sent.append(json.loads(message))
|
||||
await self.peer.incoming.put(message)
|
||||
|
||||
async def receive(self) -> str:
|
||||
message = await self.incoming.get()
|
||||
if message is None:
|
||||
raise ConnectionClosedError('test transport closed')
|
||||
return message
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.incoming.put(None)
|
||||
await self.peer.incoming.put(None)
|
||||
|
||||
|
||||
def connection_pair():
|
||||
left, right = QueueConnection(), QueueConnection()
|
||||
left.peer, right.peer = right, left
|
||||
return left, right
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def protocol_stack(tmp_path, monkeypatch, profile='oss_dev', binding=LEGACY):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
stored = tmp_path / 'original.bin'
|
||||
stored.write_bytes(PAYLOAD)
|
||||
storage_calls = []
|
||||
|
||||
async def get_file_stream(execution_context, storage_path):
|
||||
storage_calls.append((execution_context, storage_path))
|
||||
assert execution_context.workspace_uuid == BINDING.workspace_uuid
|
||||
assert storage_path == 'knowledge/original.bin'
|
||||
return stored.read_bytes()
|
||||
|
||||
async def get_execution_binding(workspace_uuid, expected_generation):
|
||||
assert workspace_uuid == BINDING.workspace_uuid
|
||||
assert expected_generation == BINDING.placement_generation
|
||||
return BINDING
|
||||
|
||||
setting = SimpleNamespace(
|
||||
plugin_author='tester',
|
||||
plugin_name='engine',
|
||||
installation_uuid=BINDING.installation_uuid,
|
||||
runtime_revision=BINDING.runtime_revision,
|
||||
artifact_digest=BINDING.artifact_digest,
|
||||
)
|
||||
app = SimpleNamespace(
|
||||
deployment=SimpleNamespace(mode='oss' if profile == 'oss_dev' else 'cloud'),
|
||||
logger=logging.getLogger(__name__),
|
||||
persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=SimpleNamespace(first=lambda: setting))),
|
||||
workspace_service=SimpleNamespace(get_execution_binding=get_execution_binding),
|
||||
rag_runtime_service=SimpleNamespace(get_file_stream=get_file_stream),
|
||||
)
|
||||
core_conn, control_conn = connection_pair()
|
||||
monkeypatch.setenv(PLUGIN_FILE_STORAGE_DIR_ENV, str(tmp_path / 'core-transfer'))
|
||||
core = RuntimeConnectionHandler(core_conn, AsyncMock(return_value=False), app)
|
||||
core.register_installation_binding(BINDING, plugin_author='tester', plugin_name='engine')
|
||||
runtime = RuntimeContext()
|
||||
runtime.plugin_mgr = PluginManager(runtime)
|
||||
# No worker is launched: omit only host nsjail/cgroup prerequisite probing.
|
||||
monkeypatch.setattr(runtime.plugin_mgr.worker_launcher, 'configure', lambda policy, profile: None)
|
||||
monkeypatch.setenv(PLUGIN_FILE_STORAGE_DIR_ENV, str(tmp_path / 'runtime-transfer'))
|
||||
control = ControlConnectionHandler(control_conn, runtime)
|
||||
runtime.activate_control_handler(control)
|
||||
bridge_conn, plugin_conn = connection_pair()
|
||||
bridge = PluginConnectionHandler(bridge_conn, runtime, file_storage_dir=str(tmp_path / 'bridge-transfer'))
|
||||
plugin = Handler(plugin_conn, file_storage_dir=str(tmp_path / 'plugin-transfer'))
|
||||
# Trusted state left by registration, not plugin-supplied action data.
|
||||
bridge.bind_action_context(binding)
|
||||
runtime.plugin_mgr.plugin_handlers.append(bridge)
|
||||
runtime.plugin_mgr.plugins.append(SimpleNamespace(_runtime_plugin_handler=bridge))
|
||||
handlers = [core, control, bridge, plugin]
|
||||
tasks = [asyncio.create_task(handler.run()) for handler in handlers]
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
core.set_runtime_config(
|
||||
runtime_identity=RuntimeIdentity(instance_uuid='instance-a', runtime_id='test-runtime'),
|
||||
worker_policy=PluginWorkerPolicy(
|
||||
max_cpus=1,
|
||||
max_memory_mb=128,
|
||||
max_pids=32,
|
||||
max_open_files=64,
|
||||
max_file_size_mb=8,
|
||||
require_hard_limits=False,
|
||||
),
|
||||
runtime_profile=profile,
|
||||
cloud_service_url=None,
|
||||
),
|
||||
5,
|
||||
)
|
||||
if isinstance(binding, InstallationBinding):
|
||||
runtime.activate_installation_binding(binding)
|
||||
else:
|
||||
runtime.bind_workspace(binding)
|
||||
yield SimpleNamespace(
|
||||
core=core,
|
||||
control=control,
|
||||
runtime=runtime,
|
||||
bridge=bridge,
|
||||
plugin=plugin,
|
||||
core_conn=core_conn,
|
||||
control_conn=control_conn,
|
||||
bridge_conn=bridge_conn,
|
||||
plugin_conn=plugin_conn,
|
||||
app=app,
|
||||
storage_calls=storage_calls,
|
||||
)
|
||||
finally:
|
||||
for handler in handlers:
|
||||
await handler.close()
|
||||
await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), 5)
|
||||
|
||||
|
||||
def assert_chunks(connection, binding, payload=PAYLOAD):
|
||||
chunks = [message for message in connection.sent if message.get('action') == CommonAction.FILE_CHUNK.value]
|
||||
expected = (len(payload) + FILE_CHUNK_LENGTH - 1) // FILE_CHUNK_LENGTH
|
||||
assert expected > 1
|
||||
assert len(chunks) == expected
|
||||
assert [chunk['data']['chunk_index'] for chunk in chunks] == list(range(expected))
|
||||
assert {chunk['data']['chunk_amount'] for chunk in chunks} == {expected}
|
||||
assert all(chunk['context'] == binding.model_dump() for chunk in chunks)
|
||||
assert len({chunk['data']['file_key'] for chunk in chunks}) == 1
|
||||
return chunks[0]['data']['file_key']
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'profile,binding',
|
||||
[('oss_dev', LEGACY), ('oss_dev', BINDING), ('shared', BINDING)],
|
||||
ids=['legacy-oss', 'managed-oss', 'managed-shared'],
|
||||
)
|
||||
async def test_knowledge_file_roundtrip_reaches_plugin_original_bytes(tmp_path, monkeypatch, profile, binding):
|
||||
async with protocol_stack(tmp_path, monkeypatch, profile, binding) as stack:
|
||||
# Legacy plugin API sends no authority; Runtime supplies its trusted binding.
|
||||
result = await asyncio.wait_for(
|
||||
stack.plugin.call_action(
|
||||
PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM,
|
||||
{'storage_path': 'knowledge/original.bin'},
|
||||
),
|
||||
5,
|
||||
)
|
||||
assert await stack.plugin.read_local_file(result['file_key']) == PAYLOAD
|
||||
assert len(stack.storage_calls) == 1
|
||||
core_key = assert_chunks(stack.core_conn, binding)
|
||||
plugin_key = assert_chunks(stack.bridge_conn, binding)
|
||||
assert result['file_key'] == plugin_key != core_key
|
||||
assert not (Path(stack.control.file_storage_dir) / core_key).exists()
|
||||
assert not stack.control._owned_transfer_files
|
||||
callbacks = [
|
||||
message
|
||||
for message in stack.control_conn.sent
|
||||
if message.get('action') == PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM.value
|
||||
]
|
||||
assert len(callbacks) == 1
|
||||
assert callbacks[0]['context'] == binding.model_dump()
|
||||
assert callbacks[0]['data'] == {'storage_path': 'knowledge/original.bin'}
|
||||
|
||||
|
||||
async def test_shared_control_rejects_legacy_chunks_before_storage(tmp_path, monkeypatch):
|
||||
async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack:
|
||||
with stack.core.installation_scope(LEGACY):
|
||||
with pytest.raises(ActionCallError, match='InstallationBinding|Legacy FILE_CHUNK'):
|
||||
await asyncio.wait_for(stack.core.send_file(PAYLOAD, ''), 5)
|
||||
assert not list(Path(stack.control.file_storage_dir).iterdir())
|
||||
assert not stack.control._owned_transfer_files
|
||||
|
||||
|
||||
async def test_candidate_artifact_pretransfer_does_not_require_active_installation(tmp_path, monkeypatch):
|
||||
async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack:
|
||||
candidate = BINDING.model_copy(
|
||||
update={'installation_uuid': 'candidate-installation', 'runtime_revision': 1, 'artifact_digest': 'c' * 64}
|
||||
)
|
||||
assert not stack.runtime.is_current_installation_binding(candidate)
|
||||
with stack.core.installation_scope(candidate):
|
||||
key = await asyncio.wait_for(stack.core.send_file(PAYLOAD, 'lbp'), 5)
|
||||
assert_chunks(stack.core_conn, candidate)
|
||||
assert await stack.control.read_local_file(key) == PAYLOAD
|
||||
assert not stack.runtime.is_current_installation_binding(candidate)
|
||||
|
||||
|
||||
async def test_nested_parser_target_owns_file_and_action_envelopes(tmp_path, monkeypatch):
|
||||
async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack:
|
||||
target = BINDING.model_copy(
|
||||
update={
|
||||
'installation_uuid': 'parser-installation',
|
||||
'runtime_revision': 2,
|
||||
'artifact_digest': 'b' * 64,
|
||||
}
|
||||
)
|
||||
stack.runtime.activate_installation_binding(target)
|
||||
parser_calls = []
|
||||
restored = []
|
||||
|
||||
async def parse_document(author, name, context_data, file_bytes):
|
||||
parser_calls.append((stack.control.current_action_context, author, name, context_data, file_bytes))
|
||||
return {'documents': [{'text': 'parsed'}]}
|
||||
|
||||
stack.runtime.plugin_mgr.parse_document = parse_document
|
||||
|
||||
class ParserConnector:
|
||||
async def require_workspace_context(self, context):
|
||||
assert context.workspace_uuid == BINDING.workspace_uuid
|
||||
|
||||
async def call_parser(self, plugin_name, context_data, file_bytes):
|
||||
assert plugin_name == 'tester/parser'
|
||||
assert stack.core.current_action_context == BINDING
|
||||
with stack.core.installation_scope(target):
|
||||
result = await stack.core.parse_document('tester', 'parser', context_data, file_bytes)
|
||||
restored.append(stack.core.resolve_outbound_action_context(None))
|
||||
return result
|
||||
|
||||
stack.app.plugin_connector = ParserConnector()
|
||||
result = await asyncio.wait_for(
|
||||
stack.plugin.call_action(
|
||||
PluginToRuntimeAction.INVOKE_PARSER,
|
||||
{
|
||||
'plugin_author': 'tester',
|
||||
'plugin_name': 'parser',
|
||||
'storage_path': 'knowledge/original.bin',
|
||||
'filename': 'original.bin',
|
||||
},
|
||||
),
|
||||
5,
|
||||
)
|
||||
assert result == {'documents': [{'text': 'parsed'}]}
|
||||
key = assert_chunks(stack.core_conn, target)
|
||||
parse_requests = [
|
||||
message
|
||||
for message in stack.core_conn.sent
|
||||
if message.get('action') == LangBotToRuntimeAction.PARSE_DOCUMENT.value
|
||||
]
|
||||
assert len(parse_requests) == 1
|
||||
assert parse_requests[0]['context'] == target.model_dump()
|
||||
assert parse_requests[0]['data']['context']['file_key'] == key
|
||||
assert parser_calls == [
|
||||
(
|
||||
target,
|
||||
'tester',
|
||||
'parser',
|
||||
{
|
||||
'mime_type': 'application/octet-stream',
|
||||
'filename': 'original.bin',
|
||||
'metadata': {},
|
||||
},
|
||||
PAYLOAD,
|
||||
)
|
||||
]
|
||||
assert restored == [BINDING]
|
||||
assert stack.core.current_action_context is None
|
||||
assert stack.core.resolve_outbound_action_context(None) is None
|
||||
assert not (Path(stack.control.file_storage_dir) / key).exists()
|
||||
@@ -20,6 +20,7 @@ from langbot.pkg.entity.persistence.model import LLMModel, ModelProvider
|
||||
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
|
||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
@@ -28,15 +29,10 @@ WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
|
||||
WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
|
||||
|
||||
|
||||
class _PersistenceManager:
|
||||
class _PersistenceManager(PersistenceManager):
|
||||
def __init__(self, engine):
|
||||
self.engine = engine
|
||||
|
||||
async def execute_async(self, *args, **kwargs):
|
||||
async with self.engine.connect() as connection:
|
||||
result = await connection.execute(*args, **kwargs)
|
||||
await connection.commit()
|
||||
return result
|
||||
super().__init__(SimpleNamespace())
|
||||
self.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
|
||||
@staticmethod
|
||||
def serialize_model(model, data, masked_columns=None):
|
||||
|
||||
@@ -1009,6 +1009,37 @@ class TestMCPServiceTestMCPServer:
|
||||
# Verify - returns task ID
|
||||
assert task_id == 123
|
||||
|
||||
@pytest.mark.parametrize('refresh_first', [False, True])
|
||||
async def test_persisted_test_preserves_failure_details(self, refresh_first):
|
||||
from langbot.pkg.provider.tools.loaders.mcp import MCPSessionStatus
|
||||
|
||||
runtime_info = {'status': 'error', 'error_message': 'HTTP 403: access denied'}
|
||||
session = SimpleNamespace(
|
||||
status=MCPSessionStatus.CONNECTED if refresh_first else MCPSessionStatus.ERROR,
|
||||
session=object(),
|
||||
refresh=AsyncMock(side_effect=RuntimeError('refresh failed')),
|
||||
start=AsyncMock(side_effect=RuntimeError('Connection failed, please check URL')),
|
||||
get_runtime_info_dict=Mock(return_value=runtime_info),
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def create_user_task(coroutine, **kwargs):
|
||||
captured.update(coroutine=coroutine, context=kwargs['context'])
|
||||
return SimpleNamespace(id=123)
|
||||
|
||||
ap = SimpleNamespace(
|
||||
tool_mgr=SimpleNamespace(mcp_tool_loader=SimpleNamespace(get_session=Mock(return_value=session))),
|
||||
task_mgr=SimpleNamespace(create_user_task=Mock(side_effect=create_user_task)),
|
||||
)
|
||||
service = _service(ap)
|
||||
service._require_server = AsyncMock(return_value=(_CONTEXT, {'name': 'existing-server'}))
|
||||
await service.test_mcp_server(_CONTEXT, 'existing-server', {})
|
||||
with pytest.raises(RuntimeError, match='Connection failed'):
|
||||
await captured['coroutine']
|
||||
assert captured['context'].metadata['runtime_info'] == runtime_info
|
||||
session.start.assert_awaited_once()
|
||||
assert session.refresh.await_count == int(refresh_first)
|
||||
|
||||
async def test_test_mcp_server_not_found_raises(self):
|
||||
"""Raises ValueError when server not found."""
|
||||
# Setup
|
||||
@@ -1052,6 +1083,45 @@ class TestMCPServiceTestMCPServer:
|
||||
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once()
|
||||
assert task_id == 456
|
||||
|
||||
async def test_transient_test_preserves_runtime_info_after_connection_failure(self):
|
||||
runtime_info = {
|
||||
'status': 'error',
|
||||
'error_phase': 'oauth_required',
|
||||
'retry_count': 1,
|
||||
}
|
||||
mock_session = SimpleNamespace(
|
||||
server_name='oauth-server',
|
||||
start=AsyncMock(side_effect=RuntimeError('connection failed')),
|
||||
get_runtime_info_dict=Mock(return_value=runtime_info),
|
||||
shutdown=AsyncMock(),
|
||||
)
|
||||
ap = SimpleNamespace(
|
||||
tool_mgr=SimpleNamespace(
|
||||
mcp_tool_loader=SimpleNamespace(load_mcp_server=AsyncMock(return_value=mock_session))
|
||||
)
|
||||
)
|
||||
captured: dict = {}
|
||||
|
||||
def create_user_task(coroutine, **kwargs):
|
||||
captured['coroutine'] = coroutine
|
||||
captured['context'] = kwargs['context']
|
||||
return SimpleNamespace(id=457)
|
||||
|
||||
ap.task_mgr = SimpleNamespace(create_user_task=Mock(side_effect=create_user_task))
|
||||
service = _service(ap)
|
||||
|
||||
task_id = await service.test_mcp_server(
|
||||
_CONTEXT,
|
||||
'_',
|
||||
{'name': 'OAuth server', 'mode': 'remote', 'enable': True, 'extra_args': {}},
|
||||
)
|
||||
|
||||
assert task_id == 457
|
||||
with pytest.raises(RuntimeError, match='connection failed'):
|
||||
await captured['coroutine']
|
||||
assert captured['context'].metadata['runtime_info'] == runtime_info
|
||||
mock_session.shutdown.assert_awaited_once_with()
|
||||
|
||||
async def test_rejected_transient_test_session_is_shut_down(self):
|
||||
ap = SimpleNamespace()
|
||||
mock_session = MagicMock()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Identifier normalization must not rely on SQLite's permissive codecs."""
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.api.http.service import monitoring
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('value', 'expected'),
|
||||
[(None, None), ('', ''), ('00123', '00123'), (' 用户 ', ' 用户 '), (123, '123'), (-123, '-123'), (0, '0')],
|
||||
)
|
||||
def test_normalize_user_id_preserves_opaque_strings(value, expected):
|
||||
assert monitoring._normalize_user_id(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', [True, False, 1.5, b'123', ['123'], {'id': 123}])
|
||||
def test_normalize_user_id_rejects_unsupported_types(value):
|
||||
with pytest.raises(TypeError, match='user_id must be a string, integer, or None'):
|
||||
monitoring._normalize_user_id(value)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Bot-scoped session regressions exercised against real SQL databases."""
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.api.http.service.monitoring import MonitoringService
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence import monitoring as models
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
from langbot.pkg.pipeline.monitoring_helper import MonitoringHelper
|
||||
|
||||
from tests.integration.persistence.test_monitoring_postgres import cloud_database # noqa: F401
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope='module')
|
||||
async def test_postgres_upgrade_rls_and_concurrent_bot_counts(cloud_database): # noqa: F811
|
||||
import asyncio
|
||||
import importlib
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from tests.integration.persistence.test_monitoring_postgres import WORKSPACE_A, _context, _read
|
||||
|
||||
ap, admin = cloud_database
|
||||
service = ap.monitoring_service
|
||||
ctx = _context(WORKSPACE_A)
|
||||
await service.record_session_start(ctx, session_id='person_42', **resource('a'))
|
||||
for bot in ['a', 'b']:
|
||||
await service.record_message(ctx, session_id='person_42', message_content=bot, **resource(bot))
|
||||
async with admin.begin() as conn:
|
||||
|
||||
def migrate(connection):
|
||||
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0023_bot_scoped_sessions')
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.downgrade()
|
||||
migration.upgrade()
|
||||
rls = connection.execute(
|
||||
sa.text("SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname='monitoring_sessions'")
|
||||
).one()
|
||||
assert tuple(rls) == (True, True)
|
||||
assert (
|
||||
connection.execute(
|
||||
sa.text("SELECT count(*) FROM pg_policies WHERE tablename='monitoring_sessions'")
|
||||
).scalar_one()
|
||||
== 1
|
||||
)
|
||||
|
||||
await conn.run_sync(migrate)
|
||||
rows, total = await _read(service, 'get_sessions', ctx)
|
||||
assert total == 2
|
||||
assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 1, 'b': 1}
|
||||
await asyncio.gather(*[service.record_session_start(ctx, session_id='race', **resource('a')) for _ in range(10)])
|
||||
result = await _read(service, 'get_session_analysis', ctx, 'race', bot_id='a')
|
||||
assert result['session']['message_count'] == 10
|
||||
assert not (await _read(service, 'get_session_analysis', ctx, 'person_42'))['found']
|
||||
assert (await _read(service, 'get_session_analysis', ctx, 'person_42', bot_id='b'))['message_stats']['total'] == 1
|
||||
|
||||
|
||||
async def test_migration_reconstructs_collisions_and_preserves_indexes(service):
|
||||
import importlib
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
engine = service.ap.persistence_mgr.get_db_engine()
|
||||
async with engine.begin() as conn:
|
||||
|
||||
def upgrade(connection):
|
||||
table = models.MonitoringSession.__table__
|
||||
table.drop(connection)
|
||||
metadata = sa.MetaData()
|
||||
legacy = table.to_metadata(metadata)
|
||||
legacy.primary_key._columns.remove(legacy.c.bot_id)
|
||||
legacy.c.bot_id.primary_key = False
|
||||
# Resolve the unchanged Workspace FK in copied metadata.
|
||||
Base.metadata.tables['workspaces'].to_metadata(metadata)
|
||||
legacy.create(connection)
|
||||
now = dt.datetime(2026, 1, 1)
|
||||
connection.execute(
|
||||
sa.insert(legacy).values(
|
||||
workspace_uuid='workspace',
|
||||
session_id='person_42',
|
||||
**resource('a'),
|
||||
message_count=99,
|
||||
start_time=now,
|
||||
last_activity=now,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
for bot in ['a', 'b']:
|
||||
connection.execute(
|
||||
sa.insert(models.MonitoringMessage).values(
|
||||
id=bot,
|
||||
workspace_uuid='workspace',
|
||||
timestamp=now,
|
||||
**resource(bot),
|
||||
session_id='person_42',
|
||||
message_content=bot,
|
||||
role='user',
|
||||
status='success',
|
||||
level='info',
|
||||
)
|
||||
)
|
||||
indexes = {i['name'] for i in sa.inspect(connection).get_indexes('monitoring_sessions')}
|
||||
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0023_bot_scoped_sessions')
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
migration.upgrade()
|
||||
migration.upgrade() # Fresh/already-upgraded schema is safe.
|
||||
assert sa.inspect(connection).get_pk_constraint('monitoring_sessions')['constrained_columns'] == [
|
||||
'workspace_uuid',
|
||||
'bot_id',
|
||||
'session_id',
|
||||
]
|
||||
assert indexes <= {i['name'] for i in sa.inspect(connection).get_indexes('monitoring_sessions')}
|
||||
|
||||
await conn.run_sync(upgrade)
|
||||
rows, total = await service.get_sessions(context())
|
||||
assert total == 2
|
||||
assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 1, 'b': 1}
|
||||
assert {r['pipeline_id'] for r in rows} == {'a', 'b'}
|
||||
|
||||
|
||||
def context(bot=None):
|
||||
return ExecutionContext(instance_uuid='test', workspace_uuid='workspace', placement_generation=1, bot_uuid=bot)
|
||||
|
||||
|
||||
def resource(bot):
|
||||
return dict(bot_id=bot, bot_name=bot, pipeline_id=bot, pipeline_name=bot)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def service():
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
class Persistence:
|
||||
serialize_model = PersistenceManager.serialize_model
|
||||
|
||||
def get_db_engine(self):
|
||||
return engine
|
||||
|
||||
async def execute_async(self, stmt):
|
||||
async with engine.begin() as conn:
|
||||
return await conn.execute(stmt)
|
||||
|
||||
ap = SimpleNamespace(persistence_mgr=Persistence(), logger=logging.getLogger(__name__))
|
||||
ap.monitoring_service = MonitoringService(ap)
|
||||
yield ap.monitoring_service
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_helper_first_message_count_and_two_bot_isolation(service):
|
||||
for bot in ['a', 'b', 'a']:
|
||||
query = SimpleNamespace(
|
||||
_execution_context=context(bot),
|
||||
launcher_type='person',
|
||||
launcher_id=42,
|
||||
sender_id=42,
|
||||
message_chain=SimpleNamespace(model_dump=lambda: []),
|
||||
)
|
||||
assert await MonitoringHelper.record_query_start(service.ap, query, **resource(bot))
|
||||
rows, total = await service.get_sessions(context())
|
||||
assert total == 2
|
||||
assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 2, 'b': 1}
|
||||
assert {r['pipeline_id'] for r in rows} == {'a', 'b'}
|
||||
assert {r['session_id'] for r in rows} == {'person_42'}
|
||||
|
||||
|
||||
async def test_analysis_fails_closed_and_scopes_statistics(service):
|
||||
for bot in ['a', 'b']:
|
||||
await service.record_session_start(context(bot), session_id='person_42', **resource(bot))
|
||||
await service.record_message(context(bot), session_id='person_42', message_content=bot, **resource(bot))
|
||||
assert (await service.get_session_analysis(context(), 'person_42'))['found'] is False
|
||||
result = await service.get_session_analysis(context(), 'person_42', bot_id='b')
|
||||
assert result['message_stats']['total'] == 1
|
||||
assert result['session']['bot_id'] == 'b'
|
||||
|
||||
|
||||
async def test_activity_requires_bot_and_upsert_counts_racing_first_queries(service):
|
||||
for _ in range(2):
|
||||
await service.record_session_start(context('a'), session_id='person_42', **resource('a'))
|
||||
with pytest.raises(ValueError, match='bot'):
|
||||
await service.update_session_activity(context(), 'person_42')
|
||||
assert await service.update_session_activity(context('a'), 'person_42')
|
||||
assert not await service.update_session_activity(context('b'), 'person_42')
|
||||
rows, _ = await service.get_sessions(context())
|
||||
assert rows[0]['message_count'] == 3
|
||||
|
||||
|
||||
async def test_old_active_sessions_are_listed_exported_and_not_cleaned(service):
|
||||
for bot in ['a', 'b']:
|
||||
await service.record_session_start(context(bot), session_id='person_42', **resource(bot))
|
||||
old = dt.datetime(2000, 1, 1)
|
||||
await service.ap.persistence_mgr.execute_async(sa.update(models.MonitoringSession).values(start_time=old))
|
||||
await service.ap.persistence_mgr.execute_async(
|
||||
sa.update(models.MonitoringSession).where(models.MonitoringSession.bot_id == 'a').values(last_activity=old)
|
||||
)
|
||||
since = dt.datetime.now(dt.timezone.utc).replace(tzinfo=None) - dt.timedelta(days=1)
|
||||
rows, total = await service.get_sessions(context(), start_time=since)
|
||||
assert total == 1 and rows[0]['bot_id'] == 'b'
|
||||
assert len(await service.export_sessions(context(), start_time=since)) == 1
|
||||
count = await service._delete_expired_in_batches(
|
||||
context(),
|
||||
models.MonitoringSession,
|
||||
models.MonitoringSession.last_activity,
|
||||
models.MonitoringSession.session_id,
|
||||
since,
|
||||
1,
|
||||
2,
|
||||
)
|
||||
assert count == 1
|
||||
rows, total = await service.get_sessions(context())
|
||||
assert total == 1 and rows[0]['bot_id'] == 'b'
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence.monitoring import MonitoringLLMCall, MonitoringMessage
|
||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
A = '00000000-0000-0000-0000-00000000000a'
|
||||
B = '00000000-0000-0000-0000-00000000000b'
|
||||
START = datetime.datetime(2026, 1, 1)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def traffic_app():
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(Workspace),
|
||||
[
|
||||
{'uuid': wid, 'instance_uuid': 'instance', 'name': wid, 'slug': wid, 'source': 'cloud_projection'}
|
||||
for wid in (A, B)
|
||||
],
|
||||
)
|
||||
for wid, bot, count in [(A, 'bot-a', 60), (A, 'bot-b', 7), (B, 'bot-a', 9)]:
|
||||
common = {
|
||||
'workspace_uuid': wid,
|
||||
'timestamp': START,
|
||||
'bot_id': bot,
|
||||
'bot_name': bot,
|
||||
'pipeline_id': 'pipeline',
|
||||
'pipeline_name': 'Pipeline',
|
||||
'session_id': 'person_42',
|
||||
'status': 'success',
|
||||
}
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(MonitoringMessage),
|
||||
[
|
||||
dict(common, id=f'{wid}-{bot}-{i}', message_content='test fixture', level='info', role='user')
|
||||
for i in range(count)
|
||||
],
|
||||
)
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(MonitoringLLMCall),
|
||||
[
|
||||
dict(
|
||||
common,
|
||||
id=f'{wid}-{bot}-{i}',
|
||||
model_name='fixture-model',
|
||||
input_tokens=1,
|
||||
output_tokens=1,
|
||||
total_tokens=2,
|
||||
duration=1,
|
||||
)
|
||||
for i in range(count)
|
||||
],
|
||||
)
|
||||
|
||||
class Persistence:
|
||||
def get_db_engine(self):
|
||||
return engine
|
||||
|
||||
async def execute_async(self, statement):
|
||||
async with engine.connect() as connection:
|
||||
return await connection.execute(statement)
|
||||
|
||||
yield SimpleNamespace(persistence_mgr=Persistence())
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_traffic_counts_all_rows_not_just_latest_page(traffic_app):
|
||||
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
||||
|
||||
context = ExecutionContext(instance_uuid='instance', workspace_uuid=A, placement_generation=1)
|
||||
result = await get_traffic_series(
|
||||
traffic_app, context, bot_ids=['bot-a'], start_time=START, end_time=START + datetime.timedelta(hours=2)
|
||||
)
|
||||
assert result['bucket'] == 'hour'
|
||||
assert result['truncated'] is False
|
||||
assert sum(point['messages'] for point in result['points']) == 60
|
||||
assert sum(point['llm_calls'] for point in result['points']) == 60
|
||||
assert len(result['points']) == 3
|
||||
assert result['points'][1]['messages'] == result['points'][1]['llm_calls'] == 0
|
||||
assert result['points'][0]['timestamp'] == '2026-01-01T00:00:00Z'
|
||||
|
||||
|
||||
async def test_traffic_workspace_pipeline_and_empty_filters(traffic_app):
|
||||
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
||||
|
||||
context = ExecutionContext(instance_uuid='instance', workspace_uuid=B, placement_generation=1)
|
||||
kwargs = dict(start_time=START, end_time=START + datetime.timedelta(hours=2))
|
||||
result = await get_traffic_series(traffic_app, context, **kwargs)
|
||||
assert sum(point['messages'] for point in result['points']) == 9
|
||||
empty = await get_traffic_series(traffic_app, context, pipeline_ids=['missing'], **kwargs)
|
||||
assert sum(point['messages'] for point in empty['points']) == 0
|
||||
assert sum(point['llm_calls'] for point in empty['points']) == 0
|
||||
|
||||
|
||||
async def test_traffic_bounds_large_ranges_and_marks_truncation(traffic_app):
|
||||
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
||||
|
||||
context = ExecutionContext(instance_uuid='instance', workspace_uuid=A, placement_generation=1)
|
||||
result = await get_traffic_series(
|
||||
traffic_app, context, start_time=START, end_time=START + datetime.timedelta(days=5000)
|
||||
)
|
||||
assert result['bucket'] == 'day'
|
||||
assert result['truncated'] is True
|
||||
assert len(result['points']) == 1000
|
||||
|
||||
|
||||
async def test_traffic_fails_closed_without_workspace(traffic_app):
|
||||
from langbot.pkg.api.http.authz import WorkspaceRequiredError
|
||||
from langbot.pkg.api.http.service.monitoring_traffic import get_traffic_series
|
||||
|
||||
with pytest.raises(WorkspaceRequiredError):
|
||||
await get_traffic_series(traffic_app, None)
|
||||
@@ -0,0 +1,401 @@
|
||||
"""Provider deletion uses real SQLite transactions and real runtime cache cleanup."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import quart
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.api.http.controller.groups.provider.providers import ModelProvidersRouterGroup
|
||||
from langbot.pkg.api.http.service.provider import ModelProviderService
|
||||
from langbot.pkg.entity.persistence.model import CodexCredential, EmbeddingModel, LLMModel, ModelProvider, RerankModel
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
MODEL_TYPES = (LLMModel, EmbeddingModel, RerankModel)
|
||||
TABLES = (*MODEL_TYPES, CodexCredential, ModelProvider)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def deletion(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "cascade.db"}')
|
||||
|
||||
@sa.event.listens_for(engine.sync_engine, 'connect')
|
||||
def enable_foreign_keys(connection, _record):
|
||||
connection.execute('PRAGMA foreign_keys=ON')
|
||||
|
||||
ap = SimpleNamespace(logger=Mock())
|
||||
pm = ap.persistence_mgr = PersistenceManager(ap)
|
||||
pm.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
manager = ap.model_mgr = ModelManager(ap)
|
||||
contexts = {workspace: ExecutionContext('instance', workspace, 1) for workspace in ('a', 'b')}
|
||||
# Only execution binding discovery is stubbed; cache indexing/removal/close is real.
|
||||
manager.resolve_execution_context = AsyncMock(side_effect=lambda context: contexts[context])
|
||||
service = ModelProviderService(ap)
|
||||
closed = []
|
||||
|
||||
async def snapshot():
|
||||
async with engine.connect() as conn:
|
||||
return {
|
||||
table.__tablename__: [dict(row) for row in (await conn.execute(sa.select(table))).mappings()]
|
||||
for table in TABLES
|
||||
}
|
||||
|
||||
async with engine.begin() as conn:
|
||||
for table in (User, Workspace, ModelProvider, CodexCredential, *MODEL_TYPES):
|
||||
await conn.run_sync(table.__table__.create)
|
||||
for workspace in contexts:
|
||||
await conn.execute(
|
||||
sa.insert(Workspace).values(
|
||||
uuid=workspace,
|
||||
instance_uuid='instance',
|
||||
name=workspace,
|
||||
slug=workspace,
|
||||
source='cloud_projection',
|
||||
)
|
||||
)
|
||||
for provider, workspace in (('target', 'a'), ('neighbor', 'a'), ('foreign', 'b'), ('empty', 'a')):
|
||||
await conn.execute(
|
||||
sa.insert(ModelProvider).values(
|
||||
uuid=provider,
|
||||
workspace_uuid=workspace,
|
||||
name=provider,
|
||||
requester='openai-codex',
|
||||
base_url='https://chatgpt.com/backend-api/codex',
|
||||
api_keys=[],
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
sa.insert(CodexCredential).values(
|
||||
provider_uuid=provider,
|
||||
workspace_uuid=workspace,
|
||||
payload={'synthetic': provider},
|
||||
)
|
||||
)
|
||||
|
||||
async def close(provider=provider):
|
||||
# A separate connection must observe the durable deletion before close runs.
|
||||
state = await snapshot()
|
||||
assert all(row['uuid'] != provider for row in state['model_providers'])
|
||||
assert pm.current_session() is None
|
||||
closed.append(provider)
|
||||
|
||||
runtime = SimpleNamespace(requester=SimpleNamespace(aclose=AsyncMock(side_effect=close)))
|
||||
manager._cache_set(manager.provider_dict, manager._cache_key(contexts[workspace], provider), runtime)
|
||||
if provider == 'empty':
|
||||
continue
|
||||
for model_type, cache in zip(
|
||||
MODEL_TYPES,
|
||||
(
|
||||
manager.llm_model_dict,
|
||||
manager.embedding_model_dict,
|
||||
manager.rerank_model_dict,
|
||||
),
|
||||
):
|
||||
for index in range(2):
|
||||
uuid = f'{provider}-{model_type.__tablename__}-{index}'
|
||||
await conn.execute(
|
||||
sa.insert(model_type).values(
|
||||
uuid=uuid,
|
||||
workspace_uuid=workspace,
|
||||
provider_uuid=provider,
|
||||
name=uuid,
|
||||
)
|
||||
)
|
||||
manager._cache_set(cache, manager._cache_key(contexts[workspace], uuid), object())
|
||||
initial = await snapshot()
|
||||
initial_caches = [
|
||||
dict(cache)
|
||||
for cache in (
|
||||
manager.provider_dict,
|
||||
manager.llm_model_dict,
|
||||
manager.embedding_model_dict,
|
||||
manager.rerank_model_dict,
|
||||
)
|
||||
]
|
||||
try:
|
||||
yield SimpleNamespace(
|
||||
ap=ap,
|
||||
pm=pm,
|
||||
engine=engine,
|
||||
service=service,
|
||||
manager=manager,
|
||||
snapshot=snapshot,
|
||||
initial=initial,
|
||||
initial_caches=initial_caches,
|
||||
closed=closed,
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def assert_caches_unchanged(deletion):
|
||||
assert deletion.closed == []
|
||||
assert deletion.initial_caches == [
|
||||
dict(cache)
|
||||
for cache in (
|
||||
deletion.manager.provider_dict,
|
||||
deletion.manager.llm_model_dict,
|
||||
deletion.manager.embedding_model_dict,
|
||||
deletion.manager.rerank_model_dict,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode', [PersistenceMode.OSS_COMPAT, PersistenceMode.CLOUD_RUNTIME])
|
||||
async def test_cascade_deletes_all_model_types_and_credentials_after_commit(deletion, mode):
|
||||
deletion.pm.mode = mode
|
||||
await deletion.service.delete_provider('a', 'target', cascade=True)
|
||||
state = await deletion.snapshot()
|
||||
for table, rows in deletion.initial.items():
|
||||
identity = 'uuid' if table == 'model_providers' else 'provider_uuid'
|
||||
assert state[table] == [row for row in rows if row[identity] != 'target']
|
||||
assert deletion.closed == ['target']
|
||||
deletion.ap.logger.warning.assert_not_called()
|
||||
for cache in (
|
||||
deletion.manager.provider_dict,
|
||||
deletion.manager.llm_model_dict,
|
||||
deletion.manager.embedding_model_dict,
|
||||
deletion.manager.rerank_model_dict,
|
||||
):
|
||||
assert all(not key[-1].startswith('target') for key in cache)
|
||||
assert any(key[1] == 'b' for key in cache)
|
||||
assert any(key[-1].startswith('neighbor') for key in cache)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('model_type', MODEL_TYPES)
|
||||
@pytest.mark.parametrize('kwargs', [{}, {'cascade': False}])
|
||||
async def test_default_guard_preserves_each_model_type(deletion, model_type, kwargs):
|
||||
async with deletion.engine.begin() as conn:
|
||||
for other in MODEL_TYPES:
|
||||
if other is not model_type:
|
||||
await conn.execute(sa.delete(other).where(other.provider_uuid == 'target'))
|
||||
before = await deletion.snapshot()
|
||||
with pytest.raises(ValueError, match='models still reference it'):
|
||||
await deletion.service.delete_provider('a', 'target', **kwargs)
|
||||
assert await deletion.snapshot() == before
|
||||
assert_caches_unchanged(deletion)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kwargs', [{}, {'cascade': False}, {'cascade': True}])
|
||||
async def test_empty_provider_deletes_credentials_with_or_without_cascade(deletion, kwargs):
|
||||
await deletion.service.delete_provider('a', 'empty', **kwargs)
|
||||
state = await deletion.snapshot()
|
||||
assert all(row['uuid'] != 'empty' for row in state['model_providers'])
|
||||
assert all(row['provider_uuid'] != 'empty' for row in state['codex_credentials'])
|
||||
assert deletion.closed == ['empty']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('provider', ['foreign', 'missing'])
|
||||
@pytest.mark.parametrize('cascade', [False, True])
|
||||
async def test_foreign_and_missing_provider_are_non_enumerating(deletion, provider, cascade):
|
||||
with pytest.raises(WorkspaceNotFoundError, match='Provider not found'):
|
||||
await deletion.service.delete_provider('a', provider, cascade=cascade)
|
||||
assert await deletion.snapshot() == deletion.initial
|
||||
assert_caches_unchanged(deletion)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('cascade', [False, True])
|
||||
async def test_cloud_managed_provider_cannot_be_deleted(deletion, cascade):
|
||||
async with deletion.engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sa.update(ModelProvider)
|
||||
.where(ModelProvider.uuid == 'target')
|
||||
.values(
|
||||
requester='space-chat-completions',
|
||||
)
|
||||
)
|
||||
before = await deletion.snapshot()
|
||||
deletion.pm.mode = PersistenceMode.CLOUD_RUNTIME
|
||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||
await deletion.service.delete_provider('a', 'target', cascade=cascade)
|
||||
assert await deletion.snapshot() == before
|
||||
assert_caches_unchanged(deletion)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure_table', ['embedding_models', 'codex_credentials', 'model_providers'])
|
||||
async def test_database_failure_rolls_back_all_rows_without_runtime_cleanup(deletion, failure_table):
|
||||
async with deletion.engine.begin() as conn:
|
||||
await conn.exec_driver_sql(
|
||||
f'CREATE TRIGGER fail_delete BEFORE DELETE ON {failure_table} '
|
||||
"BEGIN SELECT RAISE(ABORT, 'injected delete failure'); END"
|
||||
)
|
||||
with pytest.raises(sa.exc.IntegrityError, match='injected delete failure'):
|
||||
await deletion.service.delete_provider('a', 'target', cascade=True)
|
||||
assert await deletion.snapshot() == deletion.initial
|
||||
assert_caches_unchanged(deletion)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('rollback', [False, True])
|
||||
async def test_nested_transaction_defers_cleanup_until_outer_commit(deletion, rollback):
|
||||
class Abort(Exception):
|
||||
pass
|
||||
|
||||
try:
|
||||
async with deletion.pm.tenant_uow('a'):
|
||||
await deletion.service.delete_provider('a', 'target', cascade=True)
|
||||
assert_caches_unchanged(deletion)
|
||||
if rollback:
|
||||
raise Abort
|
||||
except Abort:
|
||||
pass
|
||||
tasks = tuple(deletion.service._deletion_tasks)
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
if rollback:
|
||||
assert await deletion.snapshot() == deletion.initial
|
||||
assert_caches_unchanged(deletion)
|
||||
else:
|
||||
assert deletion.closed == ['target']
|
||||
deletion.ap.logger.warning.assert_not_called()
|
||||
|
||||
|
||||
async def test_cascade_ignores_foreign_workspace_references_even_without_foreign_keys(deletion):
|
||||
async with deletion.engine.connect() as conn:
|
||||
await conn.exec_driver_sql('PRAGMA foreign_keys=OFF')
|
||||
for model_type in MODEL_TYPES:
|
||||
await conn.execute(
|
||||
sa.update(model_type)
|
||||
.where(model_type.workspace_uuid == 'b')
|
||||
.values(
|
||||
provider_uuid='target',
|
||||
)
|
||||
)
|
||||
await conn.commit()
|
||||
await deletion.service.delete_provider('a', 'target', cascade=True)
|
||||
state = await deletion.snapshot()
|
||||
for model_type in MODEL_TYPES:
|
||||
assert len([row for row in state[model_type.__tablename__] if row['workspace_uuid'] == 'b']) == 2
|
||||
assert all(row['provider_uuid'] != 'target' for row in state['codex_credentials'])
|
||||
assert deletion.closed == ['target']
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def route_app(deletion):
|
||||
ap = deletion.ap
|
||||
ap.user_service = SimpleNamespace(
|
||||
get_authenticated_account=AsyncMock(
|
||||
return_value=SimpleNamespace(uuid='account', user='owner@example.invalid'),
|
||||
)
|
||||
)
|
||||
membership = SimpleNamespace(uuid='membership', role='owner', projection_revision=0)
|
||||
ap.workspace_collaboration_service = SimpleNamespace(
|
||||
resolve_account_workspace=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
workspace=SimpleNamespace(uuid='a'),
|
||||
membership=membership,
|
||||
execution=SimpleNamespace(instance_uuid='instance', placement_generation=1),
|
||||
),
|
||||
)
|
||||
)
|
||||
ap.provider_service = SimpleNamespace(delete_provider=AsyncMock())
|
||||
app = quart.Quart(__name__)
|
||||
await ModelProvidersRouterGroup(ap, app).initialize()
|
||||
return app.test_client(), ap.provider_service.delete_provider, membership
|
||||
|
||||
|
||||
@pytest.mark.parametrize('query, expected', [('', None), ('?cascade=true', True), ('?cascade=false', False)])
|
||||
async def test_route_passes_explicit_cascade_and_trusted_workspace(route_app, query, expected):
|
||||
client, delete, _ = route_app
|
||||
response = await client.delete(
|
||||
'/api/v1/provider/providers/target' + query,
|
||||
headers={
|
||||
'Authorization': 'Bearer token',
|
||||
'X-Workspace-Id': 'a',
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert delete.await_count == 1
|
||||
assert delete.await_args.args[0].workspace_uuid == 'a'
|
||||
assert delete.await_args.args[1] == 'target'
|
||||
assert delete.await_args.kwargs == ({} if expected is None else {'cascade': expected})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'query',
|
||||
[
|
||||
'?cascade=',
|
||||
'?cascade',
|
||||
'?cascade=TRUE',
|
||||
'?cascade=1',
|
||||
'?cascade=yes',
|
||||
'?cascade=null',
|
||||
'?cascade=%20true',
|
||||
'?cascade=true&cascade=false',
|
||||
'?cascade=true&cascade=true',
|
||||
],
|
||||
)
|
||||
async def test_route_rejects_invalid_or_duplicate_cascade_before_deletion(route_app, query):
|
||||
client, delete, _ = route_app
|
||||
response = await client.delete(
|
||||
'/api/v1/provider/providers/target' + query,
|
||||
headers={
|
||||
'Authorization': 'Bearer token',
|
||||
'X-Workspace-Id': 'a',
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
delete.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('role', ['viewer', 'operator'])
|
||||
async def test_cascade_requires_workspace_resource_manage_permission(route_app, role):
|
||||
client, delete, membership = route_app
|
||||
membership.role = role
|
||||
response = await client.delete(
|
||||
'/api/v1/provider/providers/target?cascade=true',
|
||||
headers={
|
||||
'Authorization': 'Bearer token',
|
||||
'X-Workspace-Id': 'a',
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
delete.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'provider, query, status',
|
||||
[
|
||||
('target', '', 400),
|
||||
('target', '?cascade=false', 400),
|
||||
('target', '?cascade=true', 200),
|
||||
('foreign', '?cascade=true', 404),
|
||||
('missing', '?cascade=true', 404),
|
||||
],
|
||||
)
|
||||
async def test_route_to_real_sqlite_service(deletion, route_app, provider, query, status):
|
||||
client, _, _ = route_app
|
||||
deletion.ap.provider_service = deletion.service
|
||||
# The route forwards RequestContext, unlike the string-context service tests.
|
||||
deletion.manager.resolve_execution_context = AsyncMock(
|
||||
side_effect=lambda context: ExecutionContext(
|
||||
context.instance_uuid,
|
||||
context.workspace_uuid,
|
||||
context.placement_generation,
|
||||
)
|
||||
)
|
||||
response = await client.delete(
|
||||
'/api/v1/provider/providers/' + provider + query,
|
||||
headers={
|
||||
'Authorization': 'Bearer token',
|
||||
'X-Workspace-Id': 'a',
|
||||
},
|
||||
)
|
||||
assert response.status_code == status
|
||||
if status == 200:
|
||||
assert deletion.closed == ['target']
|
||||
for model_type in MODEL_TYPES:
|
||||
assert all(
|
||||
row['provider_uuid'] != 'target' for row in (await deletion.snapshot())[model_type.__tablename__]
|
||||
)
|
||||
else:
|
||||
assert await deletion.snapshot() == deletion.initial
|
||||
assert_caches_unchanged(deletion)
|
||||
@@ -14,11 +14,12 @@ Source: src/langbot/pkg/api/http/service/provider.py
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from types import SimpleNamespace
|
||||
|
||||
from langbot.pkg.api.http.service.provider import ModelProviderService
|
||||
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel, EmbeddingModel, RerankModel
|
||||
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
|
||||
|
||||
@@ -383,112 +384,35 @@ class TestModelProviderServiceUpdateProvider:
|
||||
|
||||
|
||||
class TestModelProviderServiceDeleteProvider:
|
||||
"""Tests for delete_provider method."""
|
||||
|
||||
async def test_delete_provider_with_llm_models_raises_error(self):
|
||||
"""Raises ValueError when LLM models reference provider."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
# Mock LLM model exists - only return LLM result since that's first check
|
||||
llm_result = _create_mock_result([], first_item=_create_mock_llm_model())
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=llm_result)
|
||||
"""Fast guard coverage; real transaction/cache behavior is in test_provider_cascade."""
|
||||
|
||||
@pytest.mark.parametrize('label', ['LLM', 'Embedding', 'Rerank', None])
|
||||
async def test_delete_provider_requires_no_references(self, label):
|
||||
provider_result = Mock()
|
||||
provider_result.first.return_value = SimpleNamespace(requester='openai')
|
||||
results = [provider_result]
|
||||
for model_label in ('LLM', 'Embedding', 'Rerank'):
|
||||
result = Mock()
|
||||
result.scalars.return_value = ['model'] if label == model_label else []
|
||||
results.append(result)
|
||||
results.extend([Mock(rowcount=1), Mock(rowcount=1)])
|
||||
ap = SimpleNamespace(
|
||||
persistence_mgr=SimpleNamespace(
|
||||
execute_async=AsyncMock(side_effect=results),
|
||||
tenant_uow=lambda _: nullcontext(),
|
||||
tenant_scope=lambda _: nullcontext(),
|
||||
current_session=lambda: None,
|
||||
),
|
||||
model_mgr=SimpleNamespace(remove_provider=AsyncMock()),
|
||||
)
|
||||
service = ModelProviderService(ap)
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(ValueError, match='Cannot delete provider: LLM models'):
|
||||
await service.delete_provider(WORKSPACE_UUID, 'provider-with-llm')
|
||||
|
||||
async def test_delete_provider_with_embedding_models_raises_error(self):
|
||||
"""Raises ValueError when Embedding models reference provider."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
# Create results for each check type
|
||||
llm_result = Mock()
|
||||
llm_result.first = Mock(return_value=None) # No LLM models
|
||||
embedding_result = Mock()
|
||||
embedding_result.first = Mock(return_value=Mock(spec=EmbeddingModel)) # Has embedding model
|
||||
rerank_result = Mock()
|
||||
rerank_result.first = Mock(return_value=None)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return llm_result
|
||||
elif call_count == 2:
|
||||
return embedding_result
|
||||
return rerank_result
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
|
||||
service = ModelProviderService(ap)
|
||||
|
||||
# Execute & Verify - should raise embedding error (LLM check passes, embedding check fails)
|
||||
with pytest.raises(ValueError, match='Cannot delete provider: Embedding models'):
|
||||
await service.delete_provider(WORKSPACE_UUID, 'provider-with-embedding')
|
||||
|
||||
async def test_delete_provider_with_rerank_models_raises_error(self):
|
||||
"""Raises ValueError when Rerank models reference provider."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
|
||||
# Create results for each check type
|
||||
llm_result = Mock()
|
||||
llm_result.first = Mock(return_value=None) # No LLM models
|
||||
embedding_result = Mock()
|
||||
embedding_result.first = Mock(return_value=None) # No embedding models
|
||||
rerank_result = Mock()
|
||||
rerank_result.first = Mock(return_value=Mock(spec=RerankModel)) # Has rerank model
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return llm_result
|
||||
elif call_count == 2:
|
||||
return embedding_result
|
||||
return rerank_result
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
|
||||
service = ModelProviderService(ap)
|
||||
|
||||
# Execute & Verify - should raise rerank error (LLM and embedding checks pass, rerank check fails)
|
||||
with pytest.raises(ValueError, match='Cannot delete provider: Rerank models'):
|
||||
await service.delete_provider(WORKSPACE_UUID, 'provider-with-rerank')
|
||||
|
||||
async def test_delete_provider_no_models_success(self):
|
||||
"""Deletes provider when no models reference it."""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.model_mgr = SimpleNamespace()
|
||||
ap.model_mgr.remove_provider = AsyncMock()
|
||||
|
||||
# Mock no models reference provider
|
||||
empty_result = Mock()
|
||||
empty_result.first = Mock(return_value=None)
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=empty_result)
|
||||
|
||||
service = ModelProviderService(ap)
|
||||
|
||||
# Execute
|
||||
await service.delete_provider(WORKSPACE_UUID, 'provider-no-models')
|
||||
|
||||
# Verify - delete and remove called
|
||||
ap.model_mgr.remove_provider.assert_called_once_with(WORKSPACE_UUID, 'provider-no-models')
|
||||
if label is not None:
|
||||
with pytest.raises(ValueError, match=f'Cannot delete provider: {label} models'):
|
||||
await service.delete_provider(WORKSPACE_UUID, 'provider')
|
||||
ap.model_mgr.remove_provider.assert_not_awaited()
|
||||
else:
|
||||
await service.delete_provider(WORKSPACE_UUID, 'provider')
|
||||
ap.model_mgr.remove_provider.assert_awaited_once_with(WORKSPACE_UUID, 'provider')
|
||||
|
||||
|
||||
class TestModelProviderServiceGetProviderModelCounts:
|
||||
@@ -1045,15 +969,18 @@ class TestCloudManagedProviderProtection:
|
||||
|
||||
async def test_cloud_rejects_update_and_delete_of_managed_provider(self):
|
||||
service = self._service()
|
||||
service.get_provider = AsyncMock(
|
||||
return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER}
|
||||
)
|
||||
service.get_provider = AsyncMock(return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER})
|
||||
|
||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||
await service.update_provider(WORKSPACE_UUID, 'system-provider', {'name': 'Renamed'})
|
||||
service.ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
service.ap.persistence_mgr.tenant_uow = lambda _: nullcontext()
|
||||
result = Mock()
|
||||
result.first.return_value = SimpleNamespace(requester=SYSTEM_REQUESTER)
|
||||
service.ap.persistence_mgr.execute_async.return_value = result
|
||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||
await service.delete_provider(WORKSPACE_UUID, 'system-provider')
|
||||
service.ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
assert service.ap.persistence_mgr.execute_async.await_count == 1
|
||||
|
||||
async def test_oss_does_not_reserve_space_requester(self):
|
||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='oss_compat')))
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Regression tests for recovery-key hardening (#2392).
|
||||
|
||||
Covers two attack surfaces reported in GHSA-4xcp-6758-rxqv:
|
||||
|
||||
1. ``genkeys.py`` generated ``system.recovery_key`` with only 24 bits of
|
||||
entropy (``secrets.token_hex(3)``), making the whole keyspace brute-forceable.
|
||||
2. ``POST /api/v1/user/reset-password`` (unauthenticated) checked its failure
|
||||
counter across ``await`` points, so concurrent guesses all passed the gate
|
||||
before any accounting happened; admission is now a synchronous fixed-window
|
||||
quota consumed at entry, plus constant-time key comparison.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups import user as user_module
|
||||
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
||||
from langbot.pkg.core.stages.genkeys import GenKeysStage
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
STORED_KEY = 'ABCD2345'
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_quota_state():
|
||||
"""Reset the module-level admission-quota state before each test."""
|
||||
user_module._reset_password_state['window_started_at'] = 0.0
|
||||
user_module._reset_password_state['attempts'] = 0
|
||||
yield
|
||||
user_module._reset_password_state['window_started_at'] = 0.0
|
||||
user_module._reset_password_state['attempts'] = 0
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fast_sleep(monkeypatch):
|
||||
"""Neutralize the fixed 3s delay so tests run instantly."""
|
||||
monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=AsyncMock()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# genkeys.py: recovery-key generation and compatibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_genkeys_ap(existing_key: str) -> SimpleNamespace:
|
||||
"""Build a minimal Application mock for GenKeysStage.
|
||||
|
||||
Mirrors the real boot order: no ``logger`` attribute is set because
|
||||
GenKeysStage runs before SetupLoggerStage.
|
||||
"""
|
||||
return SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={'system': {'jwt': {'secret': 'jwt-secret'}, 'recovery_key': existing_key}},
|
||||
dump_config=AsyncMock(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_recovery_key_generation_is_short_and_unambiguous():
|
||||
"""Eight random base32 characters balance manual entry and online throttling."""
|
||||
ap = _make_genkeys_ap(existing_key='')
|
||||
|
||||
await GenKeysStage().run(ap)
|
||||
|
||||
key = ap.instance_config.data['system']['recovery_key']
|
||||
assert len(key) == 8
|
||||
assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ')
|
||||
assert ap.instance_config.dump_config.called
|
||||
|
||||
|
||||
async def test_legacy_low_entropy_key_preserved_with_warning(caplog):
|
||||
"""A legacy 6-char key must keep working but emit a warning, without ap.logger."""
|
||||
ap = _make_genkeys_ap(existing_key='ABC123')
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'):
|
||||
await GenKeysStage().run(ap)
|
||||
|
||||
assert ap.instance_config.data['system']['recovery_key'] == 'ABC123'
|
||||
assert any('Low-entropy' in record.message for record in caplog.records)
|
||||
assert not ap.instance_config.dump_config.called
|
||||
|
||||
|
||||
@pytest.mark.parametrize('existing_key', ['ABC123', 'ABCD2345', 'aB-_' * 10 + 'xYz', '自定义恢复密钥'])
|
||||
async def test_recovery_key_generation_preserves_existing_key(existing_key):
|
||||
"""An explicitly configured recovery key must not be regenerated on boot."""
|
||||
ap = _make_genkeys_ap(existing_key=existing_key)
|
||||
|
||||
await GenKeysStage().run(ap)
|
||||
|
||||
assert ap.instance_config.data['system']['recovery_key'] == existing_key
|
||||
assert not ap.instance_config.dump_config.called
|
||||
|
||||
|
||||
async def test_generated_key_is_preserved_without_legacy_warning(caplog):
|
||||
"""A restart must not warn about or replace the new eight-character key."""
|
||||
ap = _make_genkeys_ap(existing_key='')
|
||||
await GenKeysStage().run(ap)
|
||||
key = ap.instance_config.data['system']['recovery_key']
|
||||
assert len(key) == 8
|
||||
ap.instance_config.dump_config.reset_mock()
|
||||
with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'):
|
||||
await GenKeysStage().run(ap)
|
||||
assert ap.instance_config.data['system']['recovery_key'] == key
|
||||
assert not caplog.records
|
||||
ap.instance_config.dump_config.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_eight_character_key_does_not_trigger_legacy_warning(caplog):
|
||||
ap = _make_genkeys_ap(existing_key='ABCD2345')
|
||||
with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'):
|
||||
await GenKeysStage().run(ap)
|
||||
assert not caplog.records
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/v1/user/reset-password: admission quota + constant-time compare
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _create_client(stored_key: str = STORED_KEY):
|
||||
"""Create a Quart test client with a mocked Application."""
|
||||
quart_app = quart.Quart(__name__)
|
||||
|
||||
user_obj = SimpleNamespace(uuid='user-uuid', user='admin@example.com')
|
||||
reset_password = AsyncMock()
|
||||
get_user_by_email = AsyncMock(return_value=user_obj)
|
||||
|
||||
ap = SimpleNamespace(
|
||||
user_service=SimpleNamespace(
|
||||
is_initialized=AsyncMock(return_value=True),
|
||||
get_user_by_email=get_user_by_email,
|
||||
reset_password=reset_password,
|
||||
),
|
||||
instance_config=SimpleNamespace(
|
||||
data={'system': {'recovery_key': stored_key}},
|
||||
),
|
||||
)
|
||||
|
||||
router = UserRouterGroup(ap, quart_app)
|
||||
await router.initialize()
|
||||
|
||||
client = quart_app.test_client()
|
||||
return client, reset_password, get_user_by_email
|
||||
|
||||
|
||||
def _payload(key: str = STORED_KEY) -> dict:
|
||||
return {'user': 'admin@example.com', 'recovery_key': key, 'new_password': 'NewPass1!'}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', [STORED_KEY, 'ABC123', 'aB-_' * 10 + 'xYz', '自定义恢复密钥'])
|
||||
async def test_correct_key_resets_password(key):
|
||||
"""New, legacy and explicitly configured keys all remain usable verbatim."""
|
||||
client, reset_password, _ = await _create_client(stored_key=key)
|
||||
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload(key))
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert (await resp.get_json())['code'] == 0
|
||||
reset_password.assert_awaited_once_with('admin@example.com', 'NewPass1!')
|
||||
|
||||
|
||||
async def test_wrong_key_rejected_without_reset():
|
||||
"""A wrong recovery key returns 403 and never touches the password."""
|
||||
client, reset_password, _ = await _create_client()
|
||||
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG'))
|
||||
|
||||
assert resp.status_code == 403
|
||||
reset_password.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_non_string_recovery_key_does_not_crash():
|
||||
"""Malformed recovery-key payloads must be rejected, not raise a 500.
|
||||
|
||||
Constant-time comparison via hmac.compare_digest on bytes requires the
|
||||
input to be a str; other JSON types must fail closed.
|
||||
"""
|
||||
client, reset_password, _ = await _create_client()
|
||||
|
||||
resp = await client.post(
|
||||
'/api/v1/user/reset-password',
|
||||
json={'user': 'admin@example.com', 'recovery_key': 12345, 'new_password': 'NewPass1!'},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
reset_password.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', ['奇数密钥不是ASCII', '\ud800', '\udfff'])
|
||||
async def test_non_ascii_recovery_key_does_not_crash(key):
|
||||
"""Non-ASCII keys must compare safely (encode-based constant-time compare)."""
|
||||
client, _, _ = await _create_client()
|
||||
|
||||
resp = await client.post(
|
||||
'/api/v1/user/reset-password',
|
||||
json={'user': 'admin@example.com', 'recovery_key': key, 'new_password': 'NewPass1!'},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_quota_exhausted_after_max_attempts():
|
||||
"""After MAX admitted attempts even a correct key must be rejected with 429 (#2392).
|
||||
|
||||
Every admission consumes quota regardless of outcome; the legacy endpoint
|
||||
accepted every guess independently, exhausting the 24-bit keyspace via bursts.
|
||||
"""
|
||||
client, reset_password, _ = await _create_client()
|
||||
|
||||
for _ in range(user_module._MAX_RESET_ATTEMPTS_PER_WINDOW):
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG'))
|
||||
assert resp.status_code == 403
|
||||
|
||||
# The very next request carries the CORRECT key but has no quota left.
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload())
|
||||
assert resp.status_code == 429
|
||||
reset_password.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_quota_rejects_before_touching_user_lookup():
|
||||
"""An exhausted quota must reject early, before the sleep and any service calls."""
|
||||
client, _, get_user_by_email = await _create_client()
|
||||
|
||||
user_module._reset_password_state['attempts'] = user_module._MAX_RESET_ATTEMPTS_PER_WINDOW
|
||||
user_module._reset_password_state['window_started_at'] = time.monotonic()
|
||||
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload())
|
||||
|
||||
assert resp.status_code == 429
|
||||
get_user_by_email.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_window_rolls_over_and_admits_again():
|
||||
"""Once the fixed window elapses, the quota resets and a correct key works again."""
|
||||
client, reset_password, _ = await _create_client()
|
||||
|
||||
user_module._reset_password_state['attempts'] = user_module._MAX_RESET_ATTEMPTS_PER_WINDOW
|
||||
user_module._reset_password_state['window_started_at'] = time.monotonic() - user_module._RESET_WINDOW_SECONDS - 1
|
||||
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload())
|
||||
|
||||
assert resp.status_code == 200
|
||||
reset_password.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_success_does_not_restore_quota():
|
||||
"""A successful reset does NOT restore quota: brute-force budget survives wins (#2392).
|
||||
|
||||
The legacy clear-on-success let attackers interleave correct-looking states;
|
||||
success only proves knowledge of the key once, it must not refill attempts.
|
||||
"""
|
||||
client, _, _ = await _create_client()
|
||||
|
||||
for _ in range(user_module._MAX_RESET_ATTEMPTS_PER_WINDOW - 1):
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG'))
|
||||
assert resp.status_code == 403
|
||||
|
||||
# Last slot is spent on the genuine reset.
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload())
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Quota is exhausted; even a correct key waits for the next window.
|
||||
resp = await client.post('/api/v1/user/reset-password', json=_payload())
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
async def test_concurrent_burst_cannot_bypass_quota(monkeypatch):
|
||||
"""A 20-request burst yields exactly {403: 5, 429: 15} (#2392 regression).
|
||||
|
||||
The vulnerable version accounted failures after several awaits, letting all
|
||||
concurrent requests pass the gate ({403: 20}). Admission is now synchronous
|
||||
and await-free, so total admissions are capped regardless of scheduling.
|
||||
"""
|
||||
|
||||
# Swap the AsyncMock sleep for a real cooperative yield so tasks actually
|
||||
# interleave mid-handler like they do under production load.
|
||||
async def _yield_sleep(_seconds):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=_yield_sleep))
|
||||
|
||||
client, reset_password, _ = await _create_client()
|
||||
|
||||
responses = await asyncio.gather(
|
||||
*(client.post('/api/v1/user/reset-password', json=_payload(key='WRONG')) for _ in range(20))
|
||||
)
|
||||
|
||||
status_counts: dict[int, int] = {}
|
||||
for resp in responses:
|
||||
status_counts[resp.status_code] = status_counts.get(resp.status_code, 0) + 1
|
||||
|
||||
assert status_counts == {403: 5, 429: 15}
|
||||
reset_password.assert_not_awaited()
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Keep subprocess coverage pointed at the generated E2E configuration."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from tests.e2e.utils.process_manager import LangBotProcess
|
||||
|
||||
|
||||
def test_e2e_coverage_environment_uses_generated_config(tmp_path):
|
||||
process = Mock()
|
||||
process.poll.return_value = None
|
||||
project = tmp_path / 'project'
|
||||
project.mkdir()
|
||||
manager = LangBotProcess(project, tmp_path, collect_coverage=True)
|
||||
with patch('subprocess.Popen', return_value=process) as popen, patch('httpx.get') as get:
|
||||
get.return_value.status_code = 200
|
||||
assert manager.start()
|
||||
config = Path(popen.call_args.kwargs['env']['COVERAGE_PROCESS_START'])
|
||||
assert config.is_file()
|
||||
assert f'--rcfile={config}' in popen.call_args.args[0]
|
||||
@@ -958,6 +958,8 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql(
|
||||
[
|
||||
sa.select(sa.literal('set_config(')),
|
||||
sa.select(sa.func.count()),
|
||||
sa.select(sa.func.min(sa.column('timestamp'))),
|
||||
sa.select(sa.func.max(sa.column('timestamp'))),
|
||||
sa.select(sa.func.coalesce(sa.func.sum(sa.literal(1)), sa.literal(0))),
|
||||
sa.select(
|
||||
sa.func.now(),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Real rollback-journal contention must not poison the pooled writer."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import sqlite3
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSessionTransaction, create_async_engine
|
||||
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
||||
from langbot.pkg.persistence.tenant_uow import TenantScopedAsyncSession
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('cancel_commit', [False, True])
|
||||
@pytest.mark.parametrize('close_fails', [False, True])
|
||||
@pytest.mark.parametrize('cancel_cleanup', [False, True])
|
||||
async def test_failed_commit_releases_sqlite_writer_and_scope(
|
||||
tmp_path, monkeypatch, cancel_commit, close_fails, cancel_cleanup
|
||||
):
|
||||
path = tmp_path / 'failed-commit.db'
|
||||
engine = create_async_engine(
|
||||
f'sqlite+aiosqlite:///{path}', connect_args={'timeout': 0.05}, pool_size=1, max_overflow=0
|
||||
)
|
||||
table = sa.Table('rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
|
||||
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
||||
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
original_commit = AsyncSessionTransaction.commit
|
||||
original_error = None
|
||||
invalidation_finished = False
|
||||
owner = asyncio.current_task()
|
||||
original_invalidate = AsyncConnection.invalidate
|
||||
|
||||
async def delayed_invalidate(connection, exception=None):
|
||||
nonlocal invalidation_finished
|
||||
if cancel_cleanup:
|
||||
owner.cancel()
|
||||
await asyncio.sleep(0)
|
||||
owner.cancel()
|
||||
await asyncio.sleep(0)
|
||||
await original_invalidate(connection, exception)
|
||||
invalidation_finished = True
|
||||
|
||||
monkeypatch.setattr(AsyncConnection, 'invalidate', delayed_invalidate)
|
||||
|
||||
async def failing_commit(transaction):
|
||||
nonlocal original_error
|
||||
try:
|
||||
await original_commit(transaction)
|
||||
except sa.exc.OperationalError as exc:
|
||||
original_error = asyncio.CancelledError('commit cancelled') if cancel_commit else exc
|
||||
raise original_error
|
||||
|
||||
monkeypatch.setattr(AsyncSessionTransaction, 'commit', failing_commit)
|
||||
original_close = TenantScopedAsyncSession._close_owned_session
|
||||
|
||||
async def failing_close(session, capability):
|
||||
await original_close(session, capability)
|
||||
if original_error is not None:
|
||||
raise RuntimeError('secondary close failure')
|
||||
|
||||
if close_fails:
|
||||
monkeypatch.setattr(TenantScopedAsyncSession, '_close_owned_session', failing_close)
|
||||
blocker = sqlite3.connect(path, timeout=0.05)
|
||||
try:
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(table.metadata.create_all)
|
||||
await connection.execute(sa.insert(table).values(id=1))
|
||||
blocker.execute('BEGIN')
|
||||
blocker.execute('SELECT * FROM rows').fetchall()
|
||||
error_type = asyncio.CancelledError if cancel_commit else sa.exc.OperationalError
|
||||
with pytest.raises(error_type) as caught:
|
||||
async with manager.tenant_uow('workspace-a') as outer:
|
||||
gate = manager.create_after_commit_gate()
|
||||
state = outer._active_state
|
||||
async with manager.tenant_uow('workspace-a') as inner:
|
||||
assert inner.session is outer.session
|
||||
await manager.execute_async(sa.insert(table).values(id=2))
|
||||
assert not gate.done()
|
||||
assert state.depth == 1
|
||||
assert caught.value is original_error
|
||||
assert invalidation_finished
|
||||
if cancel_cleanup:
|
||||
# Do not leak the synthetic cancellation count into pytest.
|
||||
owner.uncancel()
|
||||
owner.uncancel()
|
||||
monkeypatch.setattr(TenantScopedAsyncSession, '_close_owned_session', original_close)
|
||||
if close_fails:
|
||||
assert any('secondary close failure' in note for note in caught.value.__notes__)
|
||||
assert gate.cancelled()
|
||||
assert state.depth == 0
|
||||
assert manager.current_session() is None
|
||||
with pytest.raises(RuntimeError, match='not active'):
|
||||
_ = outer.session
|
||||
|
||||
# The original SHARED lock remains. New reads and RESERVED writes
|
||||
# must work; COMMIT of another write must wait for its release.
|
||||
assert blocker.in_transaction
|
||||
with contextlib.closing(sqlite3.connect(path, timeout=0.05)) as probe:
|
||||
assert probe.execute('SELECT id FROM rows').fetchall() == [(1,)]
|
||||
probe.execute('INSERT INTO rows VALUES (3)')
|
||||
probe.rollback()
|
||||
async with manager.tenant_uow('workspace-b'):
|
||||
assert (await manager.execute_async(sa.select(table.c.id))).scalars().all() == [1]
|
||||
blocker.rollback()
|
||||
async with manager.tenant_uow('workspace-b'):
|
||||
await manager.execute_async(sa.insert(table).values(id=4))
|
||||
async with engine.connect() as connection:
|
||||
assert (await connection.execute(sa.select(table.c.id).order_by(table.c.id))).scalars().all() == [1, 4]
|
||||
assert engine.pool.checkedout() == 0
|
||||
finally:
|
||||
blocker.close()
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,169 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.platform.sources.mattermost import (
|
||||
MattermostAdapter,
|
||||
MattermostEventConverter,
|
||||
MattermostMessageConverter,
|
||||
_normalize_server_url,
|
||||
_websocket_url,
|
||||
)
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
|
||||
class StubLogger:
|
||||
async def info(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
async def error(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def _adapter() -> MattermostAdapter:
|
||||
return MattermostAdapter.model_construct(
|
||||
config={'enable_stream_reply': True},
|
||||
logger=StubLogger(),
|
||||
server_url='https://mattermost.example.com',
|
||||
access_token='secret',
|
||||
bot_account_id='bot-id',
|
||||
bot_username='langbot',
|
||||
session=None,
|
||||
listeners={},
|
||||
channel_cache={},
|
||||
stream_post_ids={},
|
||||
_running=False,
|
||||
)
|
||||
|
||||
|
||||
def test_server_and_websocket_urls_preserve_subpath():
|
||||
server_url = _normalize_server_url('https://example.com/chat/')
|
||||
assert server_url == 'https://example.com/chat'
|
||||
assert _websocket_url(server_url) == 'wss://example.com/chat/api/v4/websocket'
|
||||
|
||||
with pytest.raises(ValueError, match='absolute HTTP'):
|
||||
_normalize_server_url('mattermost.example.com')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_converter_marks_and_removes_bot_mention():
|
||||
chain = await MattermostMessageConverter.target2yiri(
|
||||
{'id': 'post-1', 'create_at': 1_000, 'message': '@langbot hello'},
|
||||
'langbot',
|
||||
)
|
||||
|
||||
assert any(isinstance(item, platform_message.At) for item in chain)
|
||||
assert any(isinstance(item, platform_message.Plain) and item.text == 'hello' for item in chain)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_converter_distinguishes_direct_and_group_channels():
|
||||
post = {'id': 'post-1', 'channel_id': 'channel-1', 'user_id': 'user-1', 'message': 'hello', 'create_at': 1_000}
|
||||
direct = await MattermostEventConverter.target2yiri(post, {'type': 'D'}, 'alice', 'langbot')
|
||||
group = await MattermostEventConverter.target2yiri(
|
||||
post,
|
||||
{'type': 'O', 'display_name': 'General'},
|
||||
'alice',
|
||||
'langbot',
|
||||
)
|
||||
|
||||
assert isinstance(direct, platform_events.FriendMessage)
|
||||
assert isinstance(group, platform_events.GroupMessage)
|
||||
assert group.sender.group.name == 'General'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_to_person_creates_or_reuses_direct_channel(monkeypatch):
|
||||
adapter = _adapter()
|
||||
requests = []
|
||||
posted = []
|
||||
|
||||
async def api_request(method, path, *, payload=None):
|
||||
requests.append((method, path, payload))
|
||||
return {'id': 'direct-channel', 'type': 'D'}
|
||||
|
||||
async def post_message(channel_id, text, root_id=''):
|
||||
posted.append((channel_id, text, root_id))
|
||||
return {'id': 'post-1'}
|
||||
|
||||
monkeypatch.setattr(adapter, '_api_request', api_request)
|
||||
monkeypatch.setattr(adapter, '_post_message', post_message)
|
||||
|
||||
await adapter.send_message('person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hello')]))
|
||||
|
||||
assert requests == [('POST', '/channels/direct', {'user_ids': ['bot-id', 'user-1']})]
|
||||
assert posted == [('direct-channel', 'hello', '')]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_keeps_existing_thread(monkeypatch):
|
||||
adapter = _adapter()
|
||||
posted = []
|
||||
|
||||
async def post_message(channel_id, text, root_id=''):
|
||||
posted.append((channel_id, text, root_id))
|
||||
return {'id': 'reply'}
|
||||
|
||||
monkeypatch.setattr(adapter, '_post_message', post_message)
|
||||
event = platform_events.GroupMessage.model_construct(
|
||||
source_platform_object={
|
||||
'post': {'id': 'post-1', 'channel_id': 'channel-1', 'root_id': 'thread-root'},
|
||||
'channel': {'type': 'O'},
|
||||
}
|
||||
)
|
||||
|
||||
await adapter.reply_message(event, platform_message.MessageChain([platform_message.Plain(text='reply')]))
|
||||
|
||||
assert posted == [('channel-1', 'reply', 'thread-root')]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_reply_updates_existing_post(monkeypatch):
|
||||
adapter = _adapter()
|
||||
adapter.stream_post_ids['response-1'] = 'post-1'
|
||||
requests = []
|
||||
|
||||
async def api_request(method, path, *, payload=None):
|
||||
requests.append((method, path, payload))
|
||||
return {'id': 'post-1'}
|
||||
|
||||
monkeypatch.setattr(adapter, '_api_request', api_request)
|
||||
message = SimpleNamespace(resp_message_id='response-1', tool_calls=None)
|
||||
|
||||
await adapter.reply_message_chunk(
|
||||
SimpleNamespace(),
|
||||
message,
|
||||
platform_message.MessageChain([platform_message.Plain(text='complete')]),
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
assert requests == [('PUT', '/posts/post-1', {'id': 'post-1', 'message': 'complete'})]
|
||||
assert 'response-1' not in adapter.stream_post_ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_posted_event_dispatches_listener(monkeypatch):
|
||||
adapter = _adapter()
|
||||
received = []
|
||||
|
||||
async def get_channel(_channel_id):
|
||||
return {'type': 'D'}
|
||||
|
||||
async def listener(event, _adapter):
|
||||
received.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, '_get_channel', get_channel)
|
||||
adapter.register_listener(platform_events.FriendMessage, listener)
|
||||
|
||||
await adapter._dispatch_post(
|
||||
{
|
||||
'data': {
|
||||
'sender_name': 'alice',
|
||||
'post': '{"id":"post-1","channel_id":"channel-1","user_id":"user-1","message":"hello","create_at":1000}',
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].sender.nickname == 'alice'
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Exercise nested installation routing through real Core/SDK wire envelopes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.entities.io.actions.enums import CommonAction, LangBotToRuntimeAction, PluginToRuntimeAction
|
||||
from langbot_plugin.entities.io.req import ActionRequest
|
||||
from langbot_plugin.entities.io.resp import ActionResponse
|
||||
from langbot_plugin.runtime.io import handler as sdk_handler
|
||||
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||
from tests.unit_tests.plugin.test_handler_tenancy import RecordingConnection, make_handler, workspace_context
|
||||
|
||||
|
||||
class ReplyingConnection(RecordingConnection):
|
||||
"""Replace only the transport, retaining serialization and response routing."""
|
||||
|
||||
async def send(self, message: str) -> None:
|
||||
await super().send(message)
|
||||
request = json.loads(message)
|
||||
if 'action' in request:
|
||||
response = ActionResponse.success({'elements': []})
|
||||
response.seq_id = request['seq_id']
|
||||
await self.handler._route_response(response.seq_id, response.model_dump())
|
||||
|
||||
@property
|
||||
def requests(self):
|
||||
return [request for message in self.sent if 'action' in (request := json.loads(message))]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bridge(monkeypatch):
|
||||
runtime_handler, app, binding_a = make_handler()
|
||||
connection = ReplyingConnection()
|
||||
connection.handler = runtime_handler
|
||||
runtime_handler.conn = connection
|
||||
monkeypatch.setattr(sdk_handler, 'FILE_CHUNK_LENGTH', 4)
|
||||
binding_b = binding_a.model_copy(
|
||||
update={
|
||||
'installation_uuid': '00000000-0000-4000-8000-000000000002',
|
||||
'runtime_revision': 2,
|
||||
'artifact_digest': 'b' * 64,
|
||||
}
|
||||
)
|
||||
return runtime_handler, app, connection, binding_a, binding_b
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('mode', ['managed', 'legacy'])
|
||||
async def test_nested_invoke_parser_uses_target_for_every_chunk_and_parse(bridge, mode):
|
||||
runtime_handler, app, connection, binding_a, binding_b = bridge
|
||||
app.instance_config = SimpleNamespace(data={'plugin': {'enable': True}})
|
||||
app.deployment.mode = 'cloud' if mode == 'managed' else 'oss'
|
||||
connector = PluginRuntimeConnector(app, AsyncMock())
|
||||
connector.handler = runtime_handler
|
||||
app.plugin_connector = connector
|
||||
execution_context = runtime_handler._execution_context(binding_a)
|
||||
setting_b = SimpleNamespace(
|
||||
installation_uuid=binding_b.installation_uuid,
|
||||
runtime_revision=binding_b.runtime_revision,
|
||||
artifact_digest=binding_b.artifact_digest,
|
||||
install_info={'_artifact_storage': 'tenant_binary_storage_v1'} if mode == 'managed' else {},
|
||||
)
|
||||
connector._setting_for_plugin = AsyncMock(return_value=(execution_context, setting_b))
|
||||
connector.require_workspace_context = AsyncMock(return_value=execution_context)
|
||||
file_bytes = b'parser document'
|
||||
app.rag_runtime_service = SimpleNamespace(get_file_stream=AsyncMock(return_value=file_bytes))
|
||||
inbound_context = binding_a
|
||||
if mode == 'legacy':
|
||||
inbound_context = workspace_context().for_installation(binding_a.installation_uuid)
|
||||
setting_a = SimpleNamespace(
|
||||
plugin_author='author-a',
|
||||
plugin_name='plugin-a',
|
||||
installation_uuid=binding_a.installation_uuid,
|
||||
runtime_revision=binding_a.runtime_revision,
|
||||
artifact_digest=binding_a.artifact_digest,
|
||||
)
|
||||
app.persistence_mgr.execute_async.return_value = SimpleNamespace(first=lambda: setting_a)
|
||||
expected = binding_b if mode == 'managed' else connector._legacy_oss_bridge_binding(execution_context)
|
||||
request = ActionRequest.make_request(
|
||||
101,
|
||||
PluginToRuntimeAction.INVOKE_PARSER.value,
|
||||
{'plugin_author': 'author-b', 'plugin_name': 'parser-b', 'storage_path': 'file-a'},
|
||||
inbound_context,
|
||||
)
|
||||
|
||||
await runtime_handler._handle_action(request.model_dump())
|
||||
|
||||
response = json.loads(connection.sent[-1])
|
||||
assert response['code'] == 0, response
|
||||
chunks = connection.requests[:-1]
|
||||
parse = connection.requests[-1]
|
||||
assert len(chunks) == 4
|
||||
assert all(chunk['action'] == CommonAction.FILE_CHUNK.value for chunk in chunks)
|
||||
assert parse['action'] == LangBotToRuntimeAction.PARSE_DOCUMENT.value
|
||||
assert all(request['context'] == expected.model_dump() for request in connection.requests)
|
||||
assert b''.join(base64.b64decode(chunk['data']['chunk_base64']) for chunk in chunks) == file_bytes
|
||||
assert {chunk['data']['file_key'] for chunk in chunks} == {parse['data']['context']['file_key']}
|
||||
connector._setting_for_plugin.assert_awaited_once_with('author-b', 'parser-b', require_enabled=True)
|
||||
assert runtime_handler.current_action_context is None
|
||||
assert runtime_handler.resolve_outbound_action_context(None) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_argument_overrides_scope_and_inbound_falls_back(bridge):
|
||||
runtime_handler, _, connection, binding_a, binding_b = bridge
|
||||
token = runtime_handler._current_action_context.set(binding_a)
|
||||
try:
|
||||
with runtime_handler.installation_scope(binding_b):
|
||||
await runtime_handler.call_action(
|
||||
LangBotToRuntimeAction.LIST_PARSERS, {}, action_context=binding_a.model_dump()
|
||||
)
|
||||
await runtime_handler.list_parsers()
|
||||
finally:
|
||||
runtime_handler._current_action_context.reset(token)
|
||||
assert [request['context'] for request in connection.requests] == [binding_a.model_dump()] * 2
|
||||
assert runtime_handler.resolve_outbound_action_context(None) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_none_scope_clears_inbound_and_restores_outer_scope(bridge):
|
||||
runtime_handler, _, connection, binding_a, binding_b = bridge
|
||||
token = runtime_handler._current_action_context.set(binding_a)
|
||||
try:
|
||||
with runtime_handler.installation_scope(binding_b):
|
||||
await runtime_handler.ping()
|
||||
await runtime_handler.list_parsers()
|
||||
await runtime_handler.list_parsers()
|
||||
finally:
|
||||
runtime_handler._current_action_context.reset(token)
|
||||
assert [request.get('context') for request in connection.requests] == [
|
||||
None,
|
||||
binding_b.model_dump(),
|
||||
binding_a.model_dump(),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('failure', [RuntimeError, asyncio.CancelledError])
|
||||
async def test_scope_restores_after_exception_or_cancellation(bridge, failure):
|
||||
runtime_handler, _, connection, binding_a, binding_b = bridge
|
||||
with runtime_handler.installation_scope(binding_a):
|
||||
with pytest.raises(failure):
|
||||
with runtime_handler.installation_scope(binding_b):
|
||||
await runtime_handler.list_parsers()
|
||||
raise failure()
|
||||
await runtime_handler.list_parsers()
|
||||
await runtime_handler.list_parsers()
|
||||
assert [request.get('context') for request in connection.requests] == [
|
||||
binding_b.model_dump(),
|
||||
binding_a.model_dump(),
|
||||
None,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_nested_scopes_do_not_leak_on_task_cancellation(bridge):
|
||||
runtime_handler, _, connection, binding_a, binding_b = bridge
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def cancelled_invocation():
|
||||
with runtime_handler.installation_scope(binding_b):
|
||||
await runtime_handler.list_parsers()
|
||||
entered.set()
|
||||
await release.wait()
|
||||
|
||||
token = runtime_handler._current_action_context.set(binding_a)
|
||||
task = asyncio.create_task(cancelled_invocation())
|
||||
try:
|
||||
await asyncio.wait_for(entered.wait(), timeout=2)
|
||||
with runtime_handler.installation_scope(None):
|
||||
await runtime_handler.list_parsers()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
await runtime_handler.list_parsers()
|
||||
finally:
|
||||
runtime_handler._current_action_context.reset(token)
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
assert [request.get('context') for request in connection.requests] == [
|
||||
binding_b.model_dump(),
|
||||
None,
|
||||
binding_a.model_dump(),
|
||||
]
|
||||
assert runtime_handler.resolve_outbound_action_context(None) is None
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Replay synthetic HTTP/SSE traffic through the real Codex requester."""
|
||||
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import langbot_plugin.api.entities.builtin.provider.message as pm
|
||||
|
||||
from langbot.pkg.provider.modelmgr.requesters.codex import CodexRequester, sse_events
|
||||
|
||||
|
||||
TOKENS = {'access_token': 'access-secret', 'account_id': 'account', 'connection_id': 'connection'}
|
||||
MODEL = SimpleNamespace(model_entity=SimpleNamespace(name='codex-test', extra_args={}, reasoning_config=None))
|
||||
|
||||
|
||||
def requester(monkeypatch, handler):
|
||||
real_client = httpx.AsyncClient
|
||||
monkeypatch.setattr(
|
||||
httpx, 'AsyncClient', lambda **kwargs: real_client(transport=httpx.MockTransport(handler), **kwargs)
|
||||
)
|
||||
obj = object.__new__(CodexRequester)
|
||||
obj.workspace, obj.provider = 'w', 'p'
|
||||
obj._replay = OrderedDict()
|
||||
obj.auth = SimpleNamespace(access=AsyncMock(return_value=TOKENS))
|
||||
return obj
|
||||
|
||||
|
||||
def stream(events):
|
||||
return httpx.Response(200, content=''.join('data: ' + json.dumps(event) + '\r\n\r\n' for event in events))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_tools_usage_and_scoped_opaque_replay(monkeypatch):
|
||||
call = {'type': 'function_call', 'call_id': 'call_1', 'name': 'lookup', 'arguments': '{"q":"test"}'}
|
||||
output = [
|
||||
{'type': 'reasoning', 'encrypted_content': 'opaque-secret'},
|
||||
call,
|
||||
{'type': 'message', 'role': 'assistant', 'content': [{'type': 'output_text', 'text': 'Hello'}]},
|
||||
]
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
return stream(
|
||||
[
|
||||
{'type': 'response.created', 'response': {'id': 'resp_1'}},
|
||||
{'type': 'response.output_text.delta', 'delta': 'Hel'},
|
||||
{'type': 'response.output_text.delta', 'delta': 'lo'},
|
||||
{'type': 'response.function_call_arguments.delta', 'delta': '{broken'},
|
||||
{'type': 'response.output_item.done', 'item': call, 'output_index': 1},
|
||||
{
|
||||
'type': 'response.completed',
|
||||
'response': {
|
||||
'id': 'resp_1',
|
||||
'status': 'completed',
|
||||
'output': output,
|
||||
'usage': {'input_tokens': 4, 'output_tokens': 3, 'input_tokens_details': {'cached_tokens': 2}},
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
obj = requester(monkeypatch, handler)
|
||||
query = SimpleNamespace(query_id='q', variables=None)
|
||||
messages = [pm.Message(role='system', content='Be brief'), pm.Message(role='user', content='Hi')]
|
||||
message, usage = await obj.invoke_llm(query, MODEL, messages)
|
||||
assert message.content == 'Hello'
|
||||
assert len(message.tool_calls) == 1
|
||||
assert message.tool_calls[0].function.arguments == '{"q":"test"}'
|
||||
assert usage['total_tokens'] == 7
|
||||
assert query.variables['_stream_usage'] == usage
|
||||
assert 'opaque-secret' not in message.model_dump_json()
|
||||
body = json.loads(requests[0].content)
|
||||
assert body['store'] is False and body['stream'] is True
|
||||
assert body['instructions'] == 'Be brief'
|
||||
assert requests[0].url.path.endswith('/codex/responses')
|
||||
assert requests[0].headers['authorization'] == 'Bearer access-secret'
|
||||
assert requests[0].headers['originator'] == 'langbot'
|
||||
same = obj._body(query, MODEL, [message], None, None, TOKENS)
|
||||
assert same['input'] == output
|
||||
other = obj._body(SimpleNamespace(query_id='q'), MODEL, [message], None, None, TOKENS)
|
||||
assert 'opaque-secret' not in json.dumps(other)
|
||||
rotated = obj._body(query, MODEL, [message], None, None, {**TOKENS, 'connection_id': 'new'})
|
||||
assert 'opaque-secret' not in json.dumps(rotated)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'events',
|
||||
[
|
||||
[{'type': 'response.failed', 'error': 'access-secret'}],
|
||||
[{'type': 'response.incomplete'}],
|
||||
[{'type': 'error'}],
|
||||
[{'type': 'response.output_text.delta', 'delta': 'partial'}],
|
||||
[],
|
||||
],
|
||||
)
|
||||
async def test_failure_and_truncated_stream_never_succeed(monkeypatch, events):
|
||||
obj = requester(monkeypatch, lambda request: stream(events))
|
||||
with pytest.raises(ValueError) as caught:
|
||||
await obj.invoke_llm(None, MODEL, [])
|
||||
assert 'access-secret' not in str(caught.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_only_text_stream_and_usage(monkeypatch):
|
||||
obj = requester(
|
||||
monkeypatch,
|
||||
lambda request: stream(
|
||||
[
|
||||
{
|
||||
'type': 'response.done',
|
||||
'response': {
|
||||
'output': [{'type': 'message', 'content': [{'type': 'output_text', 'text': 'done'}]}],
|
||||
'usage': {'input_tokens': 2, 'output_tokens': 1},
|
||||
},
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
query = SimpleNamespace(query_id='q', variables={})
|
||||
chunks = [chunk async for chunk in obj.invoke_llm_stream(query, MODEL, [])]
|
||||
assert ''.join(chunk.content or '' for chunk in chunks) == 'done'
|
||||
assert chunks[-1].is_final
|
||||
assert query.variables['_stream_usage']['total_tokens'] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('status', [401, 403, 429, 500])
|
||||
async def test_http_error_secrecy_and_bounded_401_retry(monkeypatch, status):
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(status, text='access-secret refresh-secret')
|
||||
|
||||
obj = requester(monkeypatch, handler)
|
||||
with pytest.raises(ValueError) as caught:
|
||||
await obj.invoke_llm(None, MODEL, [])
|
||||
assert 'secret' not in str(caught.value)
|
||||
assert len(requests) == (2 if status == 401 else 1)
|
||||
if status == 401:
|
||||
assert obj.auth.access.call_args.kwargs == {'rejected_token': 'access-secret'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_mapping_filtering_and_deduplication(monkeypatch):
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
'models': [
|
||||
{'slug': 'a', 'input_modalities': ['text', 'image'], 'supported_reasoning_levels': ['low']},
|
||||
{'slug': 'hidden', 'visibility': 'hide'},
|
||||
{'slug': 'a', 'input_modalities': ['text', 'image'], 'supported_reasoning_levels': ['low']},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
obj = requester(monkeypatch, handler)
|
||||
catalog = await obj.scan_models()
|
||||
assert len(catalog['models']) == 1
|
||||
assert catalog['models'][0]['abilities'] == ['func_call', 'vision', 'reasoning']
|
||||
assert catalog['debug'] is None
|
||||
assert requests[0].url.path.endswith('/codex/models')
|
||||
assert 'client_version' in requests[0].url.params
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('payload', [{'models': ['secret']}, [], {'models': None}])
|
||||
async def test_catalog_malformed_safe(monkeypatch, payload):
|
||||
obj = requester(monkeypatch, lambda request: httpx.Response(200, json=payload))
|
||||
with pytest.raises(ValueError, match='invalid model catalog'):
|
||||
await obj.scan_models()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_multiline_crlf_comments_and_chunk_boundaries():
|
||||
class Bytes(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
for value in b': comment\r\nevent: test\r\ndata: {"type":\r\ndata: "test"}\r\n\r\ndata: [DONE]\r\n\r\n':
|
||||
yield bytes([value])
|
||||
|
||||
response = httpx.Response(200, stream=Bytes())
|
||||
assert [event async for event in sse_events(response)] == [{'type': 'test'}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'key', ['base_url', 'headers', 'api_key', 'store', 'stream', 'previous_response_id', 'temperature']
|
||||
)
|
||||
def test_advanced_parameters_cannot_override_transport(monkeypatch, key):
|
||||
obj = requester(monkeypatch, lambda request: httpx.Response(200))
|
||||
with pytest.raises(ValueError, match='Unsupported Codex advanced'):
|
||||
obj._body(None, MODEL, [], None, {key: 'secret'}, TOKENS)
|
||||
@@ -0,0 +1,100 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from quart import Quart
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from langbot.pkg.api.http.controller.groups.provider.models import LLMModelsRouterGroup
|
||||
from langbot.pkg.api.http.authz import Permission
|
||||
from langbot.pkg.provider.modelmgr.requesters.codex import CodexRequester
|
||||
from tests.unit_tests.provider.test_codex import requester, MODEL, stream
|
||||
|
||||
|
||||
CASES = [
|
||||
(400, 400, 'codex_invalid_request'),
|
||||
(401, 400, 'codex_reauthentication_required'),
|
||||
(403, 403, 'codex_access_denied'),
|
||||
(429, 429, 'codex_rate_limited'),
|
||||
(500, 502, 'codex_upstream_failure'),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('upstream,status,code', CASES)
|
||||
async def test_requester_safe_error(monkeypatch, upstream, status, code):
|
||||
obj = requester(monkeypatch, lambda request: httpx.Response(upstream, text='credential-secret'))
|
||||
with pytest.raises(Exception) as caught:
|
||||
await obj.invoke_llm(None, MODEL, [])
|
||||
error = caught.value
|
||||
assert getattr(error, 'status_code', None) == status
|
||||
assert error.error_code == code
|
||||
assert 'secret' not in str(error)
|
||||
if upstream == 429:
|
||||
assert 'rate limit' in str(error).lower()
|
||||
assert 'usage limit reached' not in str(error).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('events', [[{'type': 'response.failed', 'error': 'credential-secret'}], []])
|
||||
async def test_stream_safe_error(monkeypatch, events):
|
||||
obj = requester(monkeypatch, lambda request: stream(events))
|
||||
with pytest.raises(Exception) as caught:
|
||||
await obj.invoke_llm(None, MODEL, [])
|
||||
assert getattr(caught.value, 'status_code', None) == 502
|
||||
assert caught.value.error_code == 'codex_upstream_failure'
|
||||
assert 'secret' not in str(caught.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'kind,code', [('usage_limit_reached', 'codex_usage_limit_reached'), ('unknown', 'codex_rate_limited')]
|
||||
)
|
||||
async def test_allowlisted_usage_error(monkeypatch, kind, code):
|
||||
obj = requester(
|
||||
monkeypatch,
|
||||
lambda request: httpx.Response(
|
||||
429, json={'error': {'type': kind, 'message': 'credential-secret', 'resets_at': 1789043289}}
|
||||
),
|
||||
)
|
||||
with pytest.raises(Exception) as caught:
|
||||
await obj.invoke_llm(None, MODEL, [])
|
||||
assert caught.value.error_code == code
|
||||
assert 'secret' not in str(caught.value)
|
||||
|
||||
|
||||
async def client_for(error):
|
||||
app = Quart(__name__)
|
||||
ap = SimpleNamespace(logger=Mock(), llm_model_service=SimpleNamespace(test_llm_model=AsyncMock(side_effect=error)))
|
||||
router = LLMModelsRouterGroup(ap, app)
|
||||
router._authenticate_api_key = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
workspace_uuid='w',
|
||||
workspace=SimpleNamespace(permissions=frozenset({Permission.PROVIDER_SECRET_MANAGE.value})),
|
||||
)
|
||||
)
|
||||
await router.initialize()
|
||||
return app.test_client(), ap
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('upstream,status,code', CASES)
|
||||
async def test_real_model_test_route_safe_error(upstream, status, code):
|
||||
error = CodexRequester._http_error(upstream)
|
||||
client, ap = await client_for(error)
|
||||
response = await client.post('/api/v1/provider/models/llm/model/test', json={}, headers={'X-API-Key': 'synthetic'})
|
||||
body = await response.get_json()
|
||||
assert response.status_code == status
|
||||
assert body['code'] == code
|
||||
assert body['msg'] == str(error)
|
||||
ap.logger.error.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('error', [ValueError('private-value-secret'), SQLAlchemyError('private-sql-secret')])
|
||||
async def test_real_model_test_route_unexpected_errors_hidden(error):
|
||||
client, _ = await client_for(error)
|
||||
response = await client.post('/api/v1/provider/models/llm/model/test', json={}, headers={'X-API-Key': 'synthetic'})
|
||||
assert response.status_code == 500
|
||||
assert 'secret' not in await response.get_data(as_text=True)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Temporary Codex models use saved, tenant-scoped providers and synthetic SQLite credentials."""
|
||||
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.entity.persistence.model import CodexCredential, ModelProvider
|
||||
from langbot.pkg.provider.modelmgr.codex_auth import BASE_URL
|
||||
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
|
||||
from langbot.pkg.provider.modelmgr.requesters.codex import CodexRequester
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
from tests.unit_tests.provider.conftest import (
|
||||
TEST_EXECUTION_CONTEXT,
|
||||
TEST_WORKSPACE_UUID,
|
||||
FakeProviderAPIRequester,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def manager(tmp_path, mock_app_for_modelmgr):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "temporary-codex.db"}')
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(ModelProvider.__table__.create)
|
||||
await conn.run_sync(CodexCredential.__table__.create)
|
||||
for uuid, workspace, kind in (
|
||||
('saved', TEST_WORKSPACE_UUID, 'openai-codex'),
|
||||
('foreign', 'another-workspace', 'openai-codex'),
|
||||
('api', TEST_WORKSPACE_UUID, 'fake-requester'),
|
||||
):
|
||||
await conn.execute(
|
||||
sa.insert(ModelProvider).values(
|
||||
uuid=uuid,
|
||||
workspace_uuid=workspace,
|
||||
name='Saved provider',
|
||||
requester=kind,
|
||||
base_url=BASE_URL,
|
||||
api_keys=[],
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
sa.insert(CodexCredential).values(
|
||||
provider_uuid='saved',
|
||||
workspace_uuid=TEST_WORKSPACE_UUID,
|
||||
payload={
|
||||
'tokens': {
|
||||
'access_token': 'synthetic-access',
|
||||
'refresh_token': 'synthetic-refresh',
|
||||
'account_id': 'synthetic-account',
|
||||
'expires_at': time.time() + 3600,
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def execute(statement):
|
||||
async with engine.begin() as conn:
|
||||
return await conn.execute(statement)
|
||||
|
||||
mock_app_for_modelmgr.persistence_mgr = SimpleNamespace(execute_async=execute)
|
||||
mgr = ModelManager(mock_app_for_modelmgr)
|
||||
mgr.requester_dict = {'openai-codex': CodexRequester, 'fake-requester': FakeProviderAPIRequester}
|
||||
try:
|
||||
yield mgr
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def info(provider_uuid='saved', **inline):
|
||||
result = {'name': 'codex-test', 'provider': {'requester': 'openai-codex', **inline}}
|
||||
if provider_uuid is not None:
|
||||
result['provider_uuid'] = provider_uuid
|
||||
return result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'inline',
|
||||
[
|
||||
{},
|
||||
{'uuid': 'saved'},
|
||||
{
|
||||
'requester': 'fake-requester',
|
||||
'api_keys': ['untrusted'],
|
||||
'base_url': 'https://untrusted.invalid',
|
||||
'workspace_uuid': 'another-workspace',
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_codex_temporary_model_resolves_saved_provider_and_real_credentials(manager, inline):
|
||||
model = await manager.init_temporary_runtime_llm_model(TEST_EXECUTION_CONTEXT, info(**inline))
|
||||
provider = model.provider
|
||||
assert provider.provider_entity.uuid == 'saved'
|
||||
assert provider.provider_entity.requester == 'openai-codex'
|
||||
assert provider.provider_entity.api_keys == []
|
||||
assert provider.provider_entity.base_url == BASE_URL
|
||||
assert isinstance(provider.requester, CodexRequester)
|
||||
tokens = await provider.requester.auth.access(provider.requester.workspace, provider.requester.provider)
|
||||
assert tokens['access_token'] == 'synthetic-access'
|
||||
assert model.model_entity.provider_uuid == 'saved'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_temporary_model_accepts_inline_saved_identity(manager):
|
||||
model = await manager.init_temporary_runtime_llm_model(TEST_EXECUTION_CONTEXT, info(None, uuid='saved'))
|
||||
assert model.provider.provider_entity.name == 'Saved provider'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('identity', ['missing', 'foreign', None])
|
||||
async def test_codex_temporary_model_rejects_unavailable_identity(manager, identity):
|
||||
with pytest.raises(WorkspaceNotFoundError):
|
||||
await manager.init_temporary_runtime_llm_model(
|
||||
TEST_EXECUTION_CONTEXT, info(identity, workspace_uuid='another-workspace')
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_temporary_model_rejects_non_codex_saved_provider(manager):
|
||||
with pytest.raises(ValueError):
|
||||
await manager.init_temporary_runtime_llm_model(TEST_EXECUTION_CONTEXT, info('api'))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_temporary_model_rejects_conflicting_identities(manager):
|
||||
with pytest.raises(ValueError):
|
||||
await manager.init_temporary_runtime_llm_model(TEST_EXECUTION_CONTEXT, info('saved', uuid='foreign'))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_temporary_model_preserves_inline_configuration(manager):
|
||||
model = await manager.init_temporary_runtime_llm_model(
|
||||
TEST_EXECUTION_CONTEXT,
|
||||
{
|
||||
'name': 'api-model',
|
||||
'provider': {
|
||||
'requester': 'fake-requester',
|
||||
'api_keys': ['synthetic-key'],
|
||||
'base_url': 'https://api.example.invalid',
|
||||
},
|
||||
},
|
||||
)
|
||||
assert model.provider.provider_entity.api_keys == ['synthetic-key']
|
||||
assert model.provider.provider_entity.base_url == 'https://api.example.invalid'
|
||||
@@ -13,7 +13,15 @@ from aiohttp import web
|
||||
from mcp import types as mcp_types
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.provider.tools.loaders.mcp import MCPToolCallTimeoutError, RuntimeMCPSession
|
||||
from langbot.pkg.provider.tools.loaders.mcp import MCPSessionStatus, MCPToolCallTimeoutError, RuntimeMCPSession
|
||||
from langbot.pkg.provider.tools.loaders.mcp_stdio import MCPSessionErrorPhase
|
||||
|
||||
|
||||
TEST_EXECUTION_CONTEXT = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
|
||||
|
||||
TEST_EXECUTION_CONTEXT = ExecutionContext(
|
||||
@@ -24,8 +32,9 @@ TEST_EXECUTION_CONTEXT = ExecutionContext(
|
||||
|
||||
|
||||
class _TransportProbe:
|
||||
def __init__(self, streamable_status: int | None) -> None:
|
||||
def __init__(self, streamable_status: int | None, streamable_headers: dict[str, str] | None = None) -> None:
|
||||
self.streamable_status = streamable_status
|
||||
self.streamable_headers = streamable_headers or {}
|
||||
self.streamable_posts = 0
|
||||
self.streamable_messages: list[str] = []
|
||||
self.sse_gets = 0
|
||||
@@ -93,7 +102,7 @@ class _TransportProbe:
|
||||
}
|
||||
)
|
||||
return web.Response(status=202)
|
||||
return web.Response(status=self.streamable_status)
|
||||
return web.Response(status=self.streamable_status, headers=self.streamable_headers)
|
||||
|
||||
self.sse_gets += 1
|
||||
response = web.StreamResponse(
|
||||
@@ -136,8 +145,8 @@ class _TransportProbe:
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _transport_server(streamable_status: int | None):
|
||||
probe = _TransportProbe(streamable_status)
|
||||
async def _transport_server(streamable_status: int | None, streamable_headers: dict[str, str] | None = None):
|
||||
probe = _TransportProbe(streamable_status, streamable_headers)
|
||||
application = web.Application()
|
||||
application.router.add_route('*', '/mcp', probe.handle_mcp_endpoint)
|
||||
application.router.add_post('/messages', probe.handle_sse_message)
|
||||
@@ -265,6 +274,45 @@ async def test_remote_transport_real_non_compatibility_error_does_not_fallback(s
|
||||
await _close_session(session)
|
||||
|
||||
|
||||
def test_remote_transport_extracts_oauth_resource_metadata_from_bearer_challenge():
|
||||
request = httpx.Request('POST', 'https://mcp.example/mcp')
|
||||
response = httpx.Response(
|
||||
401,
|
||||
headers={
|
||||
'WWW-Authenticate': (
|
||||
'Basic realm="MCP", Bearer resource_metadata="https://mcp.example/.well-known/oauth-protected-resource"'
|
||||
)
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||
response.raise_for_status()
|
||||
|
||||
challenge = RuntimeMCPSession._extract_oauth_challenge(exc_info.value)
|
||||
|
||||
assert challenge is not None
|
||||
assert challenge.resource_metadata_url == 'https://mcp.example/.well-known/oauth-protected-resource'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_transport_oauth_challenge_sets_non_retryable_authorization_state():
|
||||
headers = {
|
||||
'WWW-Authenticate': 'Bearer resource_metadata="https://mcp.example/.well-known/oauth-protected-resource"'
|
||||
}
|
||||
async with _transport_server(401, headers) as (probe, url):
|
||||
session = _session(url)
|
||||
|
||||
await session._lifecycle_loop_with_retry()
|
||||
|
||||
assert session.status == MCPSessionStatus.ERROR
|
||||
assert session.error_phase == MCPSessionErrorPhase.OAUTH_REQUIRED
|
||||
assert session.retry_count == 1
|
||||
assert session._ready_event.is_set()
|
||||
assert probe.streamable_posts == 1
|
||||
assert probe.sse_gets == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_transport_real_timeout_does_not_fallback():
|
||||
async with _transport_server(None) as (probe, url):
|
||||
@@ -313,3 +361,25 @@ async def test_remote_transport_external_cancellation_is_not_converted_to_sse_fa
|
||||
finally:
|
||||
probe.release_streamable_request.set()
|
||||
await _close_session(session)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('error', 'expected'),
|
||||
[
|
||||
(httpx.ConnectError('secret host'), 'connection_unreachable'),
|
||||
(httpx.ReadTimeout('secret URL'), 'connection_timeout'),
|
||||
(TimeoutError('secret command'), 'connection_timeout'),
|
||||
(RuntimeError('secret environment'), 'runtime_error'),
|
||||
(
|
||||
httpx.HTTPStatusError(
|
||||
'secret response',
|
||||
request=httpx.Request('POST', 'https://example.test/?token=secret'),
|
||||
response=httpx.Response(403),
|
||||
),
|
||||
'http_403',
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_public_error_category_does_not_expose_exception_details(error, expected):
|
||||
grouped = ExceptionGroup('secret outer exception', [error])
|
||||
assert RuntimeMCPSession._classify_public_error(grouped) == expected
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Regression test for the Matrix ``!relogin`` command (``_handle_relogin_command``).
|
||||
|
||||
The old code placed ``logout_cmd = ...`` on a line after an unconditional
|
||||
``continue`` inside the ``if not bridge.login_command or not bridge.dm_room_id``
|
||||
branch. Because the ``continue`` always fired, ``logout_cmd`` was never assigned
|
||||
on the configured path, so any bridge with both ``login_command`` and
|
||||
``dm_room_id`` raised ``UnboundLocalError``. The fix moves the assignment above
|
||||
the ``if`` so it runs for configured bridges.
|
||||
|
||||
These tests replicate the loop's control flow with a minimal fake bridge so they
|
||||
run without the Matrix SDK / langbot_plugin dependency.
|
||||
"""
|
||||
|
||||
|
||||
class FakeBridge:
|
||||
def __init__(self, user_id: str, login_command: str, logout_command: str = '', dm_room_id: str | None = None):
|
||||
self.user_id = user_id
|
||||
self.login_command = login_command
|
||||
self.logout_command = logout_command
|
||||
self.dm_room_id = dm_room_id
|
||||
|
||||
|
||||
def _relogin_commands(bridges: list[FakeBridge]) -> list[str]:
|
||||
"""Return the logout commands the fixed loop would send for each bridge."""
|
||||
commands: list[str] = []
|
||||
for bridge in bridges:
|
||||
if not bridge.login_command or not bridge.dm_room_id:
|
||||
continue
|
||||
# Use configured logout command, fallback to deriving from login command.
|
||||
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
||||
commands.append(logout_cmd)
|
||||
return commands
|
||||
|
||||
|
||||
def test_configured_bridge_produces_logout_command_without_error() -> None:
|
||||
bridges = [FakeBridge('@u:example.org', 'login', dm_room_id='!room:example.org')]
|
||||
# Old code raised UnboundLocalError here; the fix must return the derived
|
||||
# logout command (no configured logout_command -> derive from login).
|
||||
assert _relogin_commands(bridges) == ['logout']
|
||||
|
||||
|
||||
def test_configured_logout_command_is_used_verbatim() -> None:
|
||||
bridges = [FakeBridge('@u:example.org', 'login', logout_command='leave', dm_room_id='!room:example.org')]
|
||||
assert _relogin_commands(bridges) == ['leave']
|
||||
|
||||
|
||||
def test_skipped_bridge_is_ignored() -> None:
|
||||
# Missing dm_room_id -> skipped, no command emitted.
|
||||
bridges = [FakeBridge('@u:example.org', 'login', dm_room_id=None)]
|
||||
assert _relogin_commands(bridges) == []
|
||||
|
||||
|
||||
def test_relogin_never_raises_for_mixed_configurations() -> None:
|
||||
bridges = [
|
||||
FakeBridge('@skip:example.org', '', dm_room_id='!room:example.org'), # no login_command
|
||||
FakeBridge('@ok:example.org', 'login', dm_room_id='!room:example.org'),
|
||||
FakeBridge('@skip2:example.org', 'login', dm_room_id=None), # no dm_room_id
|
||||
]
|
||||
assert _relogin_commands(bridges) == ['logout']
|
||||
@@ -2129,7 +2129,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", specifier = "==0.5.7" },
|
||||
{ name = "langbot-plugin", specifier = "==0.5.8" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -2196,7 +2196,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.5.7"
|
||||
version = "0.5.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2217,9 +2217,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d2/7d/b024770f1f52c9dc71ddcab79fc07dfb6147ce8e645f0fed170d758e49cb/langbot_plugin-0.5.7.tar.gz", hash = "sha256:faecd566b7ff57dc5f3a5b1be01e2165d25924031c0a65a829c83b51c65255ee", size = 480635, upload-time = "2026-09-04T13:39:22.505Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/ab/8d8bd6b8355c5b30b4aab2b5322fd28d8f36158f36d6b4ee33f4df4bc861/langbot_plugin-0.5.8.tar.gz", hash = "sha256:46fbdf948f4a2d110607738ab35633c9ab22a30784edce3a4e684cd19bab84ff", size = 487972, upload-time = "2026-09-11T09:27:58.304Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/25/416745039cacace6a0ca3f719a2eff41dc74cdb30ef7ffaec1de0142bd2e/langbot_plugin-0.5.7-py3-none-any.whl", hash = "sha256:b1a20bcb6a2d482019eafbfe0ac628c106b8e915c7afe89df057b4d8e2015f05", size = 310463, upload-time = "2026-09-04T13:39:21.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/13/4939205e2f7922ec09113e390e35f9355ce6d93e1b380a4b3c49441130f5/langbot_plugin-0.5.8-py3-none-any.whl", hash = "sha256:4fbbcfa55f1dcb9af8392b48de8b7877ea79c880dfd268d651404702614d182e", size = 311552, upload-time = "2026-09-11T09:27:57.082Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -17,7 +17,7 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm exec vite --host 127.0.0.1 --port 4173',
|
||||
command: 'corepack pnpm@8.9.2 exec vite --host 127.0.0.1 --port 4173',
|
||||
url: 'http://127.0.0.1:4173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
|
||||
@@ -157,6 +157,9 @@ const BotSessionMonitor = forwardRef<
|
||||
const [messagePage, setMessagePage] = useState(0);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [sessionError, setSessionError] = useState(false);
|
||||
const [messageError, setMessageError] = useState(false);
|
||||
const [analysisError, setAnalysisError] = useState(false);
|
||||
const [copiedUserId, setCopiedUserId] = useState(false);
|
||||
const [feedbackMap, setFeedbackMap] = useState<
|
||||
Record<string, SessionFeedback>
|
||||
@@ -236,6 +239,8 @@ const BotSessionMonitor = forwardRef<
|
||||
const loadSessions = useCallback(async () => {
|
||||
const requestId = ++sessionRequestIdRef.current;
|
||||
setLoadingSessions(true);
|
||||
setSessionError(false);
|
||||
setSessions([]);
|
||||
try {
|
||||
const response = await httpClient.getBotSessions(botId, {
|
||||
limit: SESSION_PAGE_SIZE,
|
||||
@@ -254,6 +259,7 @@ const BotSessionMonitor = forwardRef<
|
||||
} catch (error) {
|
||||
if (requestId === sessionRequestIdRef.current) {
|
||||
console.error('Failed to load sessions:', error);
|
||||
setSessionError(true);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === sessionRequestIdRef.current) {
|
||||
@@ -274,12 +280,18 @@ const BotSessionMonitor = forwardRef<
|
||||
async (sessionId: string, page: number) => {
|
||||
const requestId = ++messageRequestIdRef.current;
|
||||
setLoadingMessages(true);
|
||||
setMessageError(false);
|
||||
setAnalysisError(false);
|
||||
setMessages([]);
|
||||
setToolCalls([]);
|
||||
setFeedbackMap({});
|
||||
setExpandedToolCallIds({});
|
||||
try {
|
||||
const messagesRes = await httpClient.getSessionMessages(
|
||||
sessionId,
|
||||
MESSAGE_PAGE_SIZE,
|
||||
page * MESSAGE_PAGE_SIZE,
|
||||
botId,
|
||||
);
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
const sorted = (messagesRes.messages ?? []).sort(
|
||||
@@ -290,22 +302,19 @@ const BotSessionMonitor = forwardRef<
|
||||
setMessageTotal(messagesRes.total ?? 0);
|
||||
|
||||
try {
|
||||
const analysisParams = new URLSearchParams();
|
||||
if (sorted.length > 0) {
|
||||
analysisParams.set('startTime', sorted[0].timestamp);
|
||||
analysisParams.set('endTime', sorted[sorted.length - 1].timestamp);
|
||||
}
|
||||
const analysisRes = await httpClient.get<{
|
||||
const analysisRes = await httpClient.getSessionAnalysis<{
|
||||
tool_calls?: SessionToolCall[];
|
||||
}>(
|
||||
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${analysisParams.toString()}`,
|
||||
);
|
||||
}>(sessionId, botId, {
|
||||
startTime: sorted[0]?.timestamp,
|
||||
endTime: sorted[sorted.length - 1]?.timestamp,
|
||||
});
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
setToolCalls(analysisRes?.tool_calls ?? []);
|
||||
} catch (analysisError) {
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
console.error('Failed to load session tool calls:', analysisError);
|
||||
setToolCalls([]);
|
||||
setAnalysisError(true);
|
||||
}
|
||||
|
||||
// Collect user message IDs for feedback matching
|
||||
@@ -337,6 +346,7 @@ const BotSessionMonitor = forwardRef<
|
||||
} catch (error) {
|
||||
if (requestId === messageRequestIdRef.current) {
|
||||
console.error('Failed to load session messages:', error);
|
||||
setMessageError(true);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === messageRequestIdRef.current) {
|
||||
@@ -349,6 +359,9 @@ const BotSessionMonitor = forwardRef<
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
return () => {
|
||||
sessionRequestIdRef.current += 1;
|
||||
};
|
||||
}, [loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -362,12 +375,17 @@ const BotSessionMonitor = forwardRef<
|
||||
} else {
|
||||
messageRequestIdRef.current += 1;
|
||||
setLoadingMessages(false);
|
||||
setMessageError(false);
|
||||
setAnalysisError(false);
|
||||
setMessages([]);
|
||||
setMessageTotal(0);
|
||||
setToolCalls([]);
|
||||
setExpandedToolCallIds({});
|
||||
setFeedbackMap({});
|
||||
}
|
||||
return () => {
|
||||
messageRequestIdRef.current += 1;
|
||||
};
|
||||
}, [selectedSessionId, messagePage, loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -728,6 +746,20 @@ const BotSessionMonitor = forwardRef<
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : sessionError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="p-3 space-y-2 text-sm text-destructive"
|
||||
>
|
||||
<p>{t('monitoring.loadError')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadSessions}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.noSessions')}
|
||||
@@ -898,10 +930,46 @@ const BotSessionMonitor = forwardRef<
|
||||
className="flex-1 px-4 py-4 overflow-y-auto min-h-0"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{analysisError && !loadingMessages && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-sm text-destructive space-y-2"
|
||||
>
|
||||
<p>
|
||||
{t('monitoring.toolCalls.title')}:{' '}
|
||||
{t('monitoring.loadError')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
loadMessages(selectedSessionId, messagePage)
|
||||
}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loadingMessages ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : messageError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-sm text-destructive space-y-2"
|
||||
>
|
||||
<p>{t('monitoring.loadError')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
loadMessages(selectedSessionId, messagePage)
|
||||
}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : timelineItems.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.noMessages')}
|
||||
|
||||
@@ -46,30 +46,10 @@ import {
|
||||
} from '@/components/ui/tooltip';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||
|
||||
/**
|
||||
* Resolve the value referenced by a `show_if.field` string.
|
||||
*
|
||||
* Fields prefixed with `__system.` are looked up in the caller-supplied
|
||||
* `systemContext` dictionary (e.g. `__system.is_wizard` → `systemContext.is_wizard`).
|
||||
* All other field names are resolved from the live form values first, then
|
||||
* fall back to `externalDependentValues`.
|
||||
*/
|
||||
function resolveShowIfValue(
|
||||
field: string,
|
||||
watchedValues: Record<string, unknown>,
|
||||
externalDependentValues?: Record<string, unknown>,
|
||||
systemContext?: Record<string, unknown>,
|
||||
): unknown {
|
||||
if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
|
||||
const key = field.slice(SYSTEM_FIELD_PREFIX.length);
|
||||
return systemContext?.[key];
|
||||
}
|
||||
if (watchedValues[field] !== undefined) {
|
||||
return watchedValues[field];
|
||||
}
|
||||
return externalDependentValues?.[field];
|
||||
}
|
||||
import {
|
||||
resolveDisabledState,
|
||||
resolveShowIfValue,
|
||||
} from './DynamicFormConditions';
|
||||
|
||||
type DynamicFormValueSpec = Pick<
|
||||
IDynamicFormItemSchema,
|
||||
@@ -675,40 +655,19 @@ export default function DynamicFormComponent({
|
||||
}
|
||||
}
|
||||
|
||||
// ``disable_if`` mirrors ``show_if``'s evaluator but instead of
|
||||
// hiding the field, leaves it visible and inert. Use it when the
|
||||
// operator needs to see that the field exists yet cannot edit it
|
||||
// under the current runtime state (e.g. sandbox-bound fields when
|
||||
// Box is disabled).
|
||||
let isDisabledByCondition = false;
|
||||
if (config.disable_if) {
|
||||
const dependValue = resolveShowIfValue(
|
||||
config.disable_if.field,
|
||||
// Keep locked fields visible and resolve only the applicable reason.
|
||||
const { isDisabledByCondition, disabledTooltip: tooltip } =
|
||||
resolveDisabledState(
|
||||
config,
|
||||
watchedValues as Record<string, unknown>,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
const cond = config.disable_if;
|
||||
if (cond.operator === 'eq' && dependValue === cond.value) {
|
||||
isDisabledByCondition = true;
|
||||
} else if (cond.operator === 'neq' && dependValue !== cond.value) {
|
||||
isDisabledByCondition = true;
|
||||
} else if (
|
||||
cond.operator === 'in' &&
|
||||
Array.isArray(cond.value) &&
|
||||
cond.value.includes(dependValue)
|
||||
) {
|
||||
isDisabledByCondition = true;
|
||||
}
|
||||
}
|
||||
|
||||
// All fields are disabled when editing (creation_settings are
|
||||
// immutable) or when ``disable_if`` matches.
|
||||
const isFieldDisabled = !!isEditing || isDisabledByCondition;
|
||||
const disabledTooltip =
|
||||
isDisabledByCondition && config.disabled_tooltip
|
||||
? extractI18nObject(config.disabled_tooltip)
|
||||
: '';
|
||||
const disabledTooltip = tooltip ? extractI18nObject(tooltip) : '';
|
||||
const renderDisabledTooltipIcon = () =>
|
||||
disabledTooltip ? (
|
||||
<DisabledTooltipIcon text={disabledTooltip} />
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
SYSTEM_FIELD_PREFIX,
|
||||
type IDynamicFormItemSchema,
|
||||
type IShowIfCondition,
|
||||
} from '@/app/infra/entities/form/dynamic';
|
||||
|
||||
/** System references use caller context; other fields prefer live form values. */
|
||||
export function resolveShowIfValue(
|
||||
field: string,
|
||||
watchedValues: Record<string, unknown>,
|
||||
externalDependentValues?: Record<string, unknown>,
|
||||
systemContext?: Record<string, unknown>,
|
||||
): unknown {
|
||||
if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
|
||||
return systemContext?.[field.slice(SYSTEM_FIELD_PREFIX.length)];
|
||||
}
|
||||
if (watchedValues[field] !== undefined) {
|
||||
return watchedValues[field];
|
||||
}
|
||||
return externalDependentValues?.[field];
|
||||
}
|
||||
|
||||
export function matchesFormCondition(
|
||||
condition: IShowIfCondition,
|
||||
watchedValues: Record<string, unknown>,
|
||||
externalDependentValues?: Record<string, unknown>,
|
||||
systemContext?: Record<string, unknown>,
|
||||
): boolean {
|
||||
const value = resolveShowIfValue(
|
||||
condition.field,
|
||||
watchedValues,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
switch (condition.operator) {
|
||||
case 'eq':
|
||||
return value === condition.value;
|
||||
case 'neq':
|
||||
return value !== condition.value;
|
||||
case 'in':
|
||||
return Array.isArray(condition.value) && condition.value.includes(value);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDisabledState(
|
||||
config: Pick<
|
||||
IDynamicFormItemSchema,
|
||||
'disable_if' | 'disabled_tooltip' | 'disabled_tooltip_overrides'
|
||||
>,
|
||||
watchedValues: Record<string, unknown>,
|
||||
externalDependentValues?: Record<string, unknown>,
|
||||
systemContext?: Record<string, unknown>,
|
||||
) {
|
||||
const matches = (condition: IShowIfCondition) =>
|
||||
matchesFormCondition(
|
||||
condition,
|
||||
watchedValues,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
const isDisabledByCondition =
|
||||
!!config.disable_if && matches(config.disable_if);
|
||||
const disabledTooltip = isDisabledByCondition
|
||||
? (config.disabled_tooltip_overrides?.find((override) =>
|
||||
matches(override.when),
|
||||
)?.tooltip ?? config.disabled_tooltip)
|
||||
: undefined;
|
||||
return { isDisabledByCondition, disabledTooltip };
|
||||
}
|
||||
@@ -486,6 +486,7 @@ export default function ModelsPanel({
|
||||
// Get the provider info
|
||||
const provider = providers.find((p) => p.uuid === providerUuid);
|
||||
const providerData = {
|
||||
uuid: providerUuid,
|
||||
requester: provider?.requester || '',
|
||||
base_url: provider?.base_url || '',
|
||||
api_keys: provider?.api_keys || [],
|
||||
@@ -495,7 +496,7 @@ export default function ModelsPanel({
|
||||
await httpClient.testLLMModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider_uuid: providerUuid,
|
||||
provider: providerData,
|
||||
abilities,
|
||||
reasoning_config: reasoningConfig,
|
||||
@@ -505,7 +506,7 @@ export default function ModelsPanel({
|
||||
await httpClient.testEmbeddingModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider_uuid: providerUuid,
|
||||
provider: providerData,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
@@ -513,7 +514,7 @@ export default function ModelsPanel({
|
||||
await httpClient.testRerankModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider_uuid: providerUuid,
|
||||
provider: providerData,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
@@ -536,6 +537,29 @@ export default function ModelsPanel({
|
||||
expandedProviders.forEach((uuid) => loadProviderModels(uuid));
|
||||
}
|
||||
|
||||
async function handleProviderDeleted(providerUuid: string) {
|
||||
setProviders((prev) =>
|
||||
prev.filter((provider) => provider.uuid !== providerUuid),
|
||||
);
|
||||
setProviderModels((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[providerUuid];
|
||||
return next;
|
||||
});
|
||||
setExpandedProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(providerUuid);
|
||||
return next;
|
||||
});
|
||||
await Promise.all([
|
||||
loadProviders(),
|
||||
...Array.from(expandedProviders)
|
||||
.filter((uuid) => uuid !== providerUuid)
|
||||
.map((uuid) => loadProviderModels(uuid)),
|
||||
]);
|
||||
setProviderFormOpen(false);
|
||||
}
|
||||
|
||||
function renderProviderCard(
|
||||
provider: ModelProvider,
|
||||
isLangBotModels: boolean = false,
|
||||
@@ -666,8 +690,14 @@ export default function ModelsPanel({
|
||||
)}
|
||||
</PanelBody>
|
||||
|
||||
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
|
||||
<DialogContent className="w-full max-w-[calc(100%-2rem)] p-4 sm:max-w-[600px] sm:p-6">
|
||||
<Dialog
|
||||
open={providerFormOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleFormClose();
|
||||
else setProviderFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto p-4 sm:max-w-[600px] sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingProviderId
|
||||
@@ -675,11 +705,15 @@ export default function ModelsPanel({
|
||||
: t('models.addProvider')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ProviderForm
|
||||
providerId={editingProviderId || undefined}
|
||||
onFormSubmit={handleFormClose}
|
||||
onFormCancel={() => setProviderFormOpen(false)}
|
||||
/>
|
||||
{providerFormOpen && (
|
||||
<ProviderForm
|
||||
key={editingProviderId || 'new'}
|
||||
providerId={editingProviderId || undefined}
|
||||
onFormSubmit={handleFormClose}
|
||||
onFormCancel={handleFormClose}
|
||||
onProviderDeleted={canManage ? handleProviderDeleted : undefined}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { useCodexLogin } from './useCodexLogin';
|
||||
|
||||
export default function CodexAccountSection({
|
||||
login,
|
||||
providerId,
|
||||
}: {
|
||||
login: ReturnType<typeof useCodexLogin>;
|
||||
providerId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [confirmDisconnect, setConfirmDisconnect] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyFailed, setCopyFailed] = useState(false);
|
||||
const { phase, device } = login;
|
||||
const copyGeneration = useRef(0);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
const generation = copyGeneration;
|
||||
setCopied(false);
|
||||
setCopyFailed(false);
|
||||
return () => {
|
||||
generation.current++;
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
};
|
||||
}, [providerId, device?.authorization_id, device?.user_code, phase]);
|
||||
const handleCopy = async () => {
|
||||
if (!device) return;
|
||||
const generation = ++copyGeneration.current;
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await copyToClipboard(device.user_code);
|
||||
} catch {
|
||||
// Clipboard failures are recoverable; never log device codes.
|
||||
}
|
||||
if (generation !== copyGeneration.current) return;
|
||||
setCopied(ok);
|
||||
setCopyFailed(!ok);
|
||||
if (ok) {
|
||||
toast.success(t('common.copySuccess'));
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
toast.error(t('common.copyFailed'));
|
||||
}
|
||||
};
|
||||
const waiting = ['starting', 'loading', 'canceling'].includes(phase);
|
||||
return (
|
||||
<section
|
||||
data-testid="codex-account"
|
||||
aria-label={t('models.codex.account')}
|
||||
className="min-w-0 rounded-lg border p-3 space-y-3 text-sm"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium">{t('models.codex.account')}</h3>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{t('models.codex.description')}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
role={phase === 'error' ? 'alert' : 'status'}
|
||||
aria-live="polite"
|
||||
className={
|
||||
phase === 'error' ? 'text-destructive' : 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{t(`models.codex.${phase}`)}
|
||||
</p>
|
||||
{device && phase === 'pending' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground">
|
||||
{t('models.codex.instructions')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="select-all break-all rounded border bg-muted px-3 py-2 text-base font-semibold tracking-wider">
|
||||
{device.user_code}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{t(copied ? 'models.codex.copied' : 'models.codex.copyCode')}
|
||||
</Button>
|
||||
</div>
|
||||
{copyFailed && (
|
||||
<p role="status" className="text-muted-foreground">
|
||||
{t('models.codex.copyManually')}
|
||||
</p>
|
||||
)}
|
||||
<a
|
||||
href={device.verification_uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t('models.codex.continueAtOpenAI')}
|
||||
</a>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('models.codex.expiresAt', {
|
||||
time: new Date(device.expires_at * 1000).toLocaleTimeString(),
|
||||
})}
|
||||
</p>
|
||||
{login.retrying && (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
{t('models.codex.retrying')}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => providerId && void login.cancel(providerId)}
|
||||
>
|
||||
{t('models.codex.cancelSignIn')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{providerId && !waiting && phase !== 'pending' && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{phase !== 'connected' && (
|
||||
<Button type="submit" size="sm" variant="outline">
|
||||
{t(
|
||||
phase === 'error' || phase === 'expired'
|
||||
? 'models.codex.tryAgain'
|
||||
: 'models.codex.signIn',
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{phase === 'connected' && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setConfirmDisconnect(false);
|
||||
void login.start(providerId);
|
||||
}}
|
||||
>
|
||||
{t('models.codex.reconnect')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmDisconnect(true)}
|
||||
>
|
||||
{t('models.codex.disconnect')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{confirmDisconnect && phase === 'connected' && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<p>{t('models.codex.disconnectConfirm')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmDisconnect(false);
|
||||
if (providerId) void login.disconnect(providerId);
|
||||
}}
|
||||
>
|
||||
{t('models.codex.confirmDisconnect')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDisconnect(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+380
-145
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -16,12 +16,31 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { DialogFooter } from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '../../types';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { toast } from 'sonner';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, ChevronDown, Search } from 'lucide-react';
|
||||
import { providerPayload } from './codexPolicy';
|
||||
import { useCodexLogin } from './useCodexLogin';
|
||||
import CodexAccountSection from './CodexAccountSection';
|
||||
|
||||
const getFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
@@ -35,12 +54,14 @@ interface ProviderFormProps {
|
||||
providerId?: string;
|
||||
onFormSubmit: (providerUuid: string) => void | Promise<void>;
|
||||
onFormCancel: () => void;
|
||||
onProviderDeleted?: (providerUuid: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function ProviderForm({
|
||||
providerId,
|
||||
onFormSubmit,
|
||||
onFormCancel,
|
||||
onProviderDeleted,
|
||||
}: ProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const formSchema = getFormSchema(t);
|
||||
@@ -54,7 +75,31 @@ export default function ProviderForm({
|
||||
api_key: '',
|
||||
},
|
||||
});
|
||||
const { setValue } = form;
|
||||
const { reset } = form;
|
||||
const isCodex = form.watch('requester') === 'openai-codex';
|
||||
const [savedProviderId, setSavedProviderId] = useState(providerId);
|
||||
const savedId = useRef(providerId);
|
||||
const submitting = useRef(false);
|
||||
const deleting = useRef(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
const [mutableProviderLoaded, setMutableProviderLoaded] = useState(false);
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>(
|
||||
'loading',
|
||||
);
|
||||
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||
const mounted = useRef(true);
|
||||
const login = useCodexLogin(isCodex, providerId);
|
||||
const loginActive = ['starting', 'pending', 'canceling', 'loading'].includes(
|
||||
login.phase,
|
||||
);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [requesterList, setRequesterList] = useState<
|
||||
{
|
||||
@@ -68,72 +113,59 @@ export default function ProviderForm({
|
||||
>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadRequesters = useCallback(async () => {
|
||||
const resp = await httpClient.getProviderRequesters();
|
||||
setRequesterList(
|
||||
resp.requesters
|
||||
.filter((item) => item.name !== 'space-chat-completions')
|
||||
.map((item) => ({
|
||||
label: extractI18nObject(item.label),
|
||||
value: item.name,
|
||||
category: item.spec.provider_category || 'manufacturer',
|
||||
defaultUrl:
|
||||
item.spec.config
|
||||
.find((c) => c.name === 'base_url')
|
||||
?.default?.toString() || '',
|
||||
description: extractI18nObject(item.description),
|
||||
alias: item.spec.alias || '',
|
||||
})),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const loadProvider = useCallback(
|
||||
async (id: string) => {
|
||||
const resp = await httpClient.getModelProvider(id);
|
||||
const provider = resp.provider;
|
||||
|
||||
setValue('name', provider.name);
|
||||
setValue('requester', provider.requester);
|
||||
setValue('base_url', provider.base_url);
|
||||
setValue('api_key', provider.api_keys?.[0] || '');
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Ignore both success and failure from a closed form or superseded attempt.
|
||||
let canceled = false;
|
||||
setLoadState('loading');
|
||||
setMutableProviderLoaded(false);
|
||||
|
||||
async function init() {
|
||||
await loadRequesters();
|
||||
if (providerId) {
|
||||
await loadProvider(providerId);
|
||||
try {
|
||||
const [requesters, detail] = await Promise.all([
|
||||
httpClient.getProviderRequesters(),
|
||||
providerId ? httpClient.getModelProvider(providerId) : null,
|
||||
]);
|
||||
if (canceled) return;
|
||||
setRequesterList(
|
||||
requesters.requesters
|
||||
.filter((item) => item.name !== LANGBOT_MODELS_PROVIDER_REQUESTER)
|
||||
.map((item) => ({
|
||||
label: extractI18nObject(item.label),
|
||||
value: item.name,
|
||||
category: item.spec.provider_category || 'manufacturer',
|
||||
defaultUrl:
|
||||
item.spec.config
|
||||
.find((c) => c.name === 'base_url')
|
||||
?.default?.toString() || '',
|
||||
description: extractI18nObject(item.description),
|
||||
alias: item.spec.alias || '',
|
||||
})),
|
||||
);
|
||||
if (detail) {
|
||||
const provider = detail.provider;
|
||||
reset({
|
||||
name: provider.name,
|
||||
requester: provider.requester,
|
||||
base_url: provider.base_url,
|
||||
api_key: provider.api_keys?.[0] || '',
|
||||
});
|
||||
setMutableProviderLoaded(
|
||||
provider.uuid === providerId &&
|
||||
provider.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
);
|
||||
}
|
||||
setLoadState('ready');
|
||||
} catch {
|
||||
if (!canceled) setLoadState('error');
|
||||
}
|
||||
}
|
||||
init();
|
||||
}, [providerId, loadProvider, loadRequesters]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
setSearchQuery('');
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Focus search input when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
void init();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [providerId, reset, loadAttempt]);
|
||||
|
||||
// Filter requesters based on search query
|
||||
const filteredRequesters = requesterList.filter(
|
||||
@@ -163,29 +195,105 @@ export default function ProviderForm({
|
||||
};
|
||||
|
||||
async function handleFormSubmit(values: z.infer<typeof formSchema>) {
|
||||
const data = {
|
||||
name: values.name,
|
||||
requester: values.requester,
|
||||
base_url: values.base_url,
|
||||
api_keys: values.api_key ? [values.api_key] : [],
|
||||
};
|
||||
|
||||
if (
|
||||
loadState !== 'ready' ||
|
||||
submitting.current ||
|
||||
deleting.current ||
|
||||
(isCodex && loginActive)
|
||||
)
|
||||
return;
|
||||
submitting.current = true;
|
||||
const data = providerPayload(values);
|
||||
try {
|
||||
let savedProviderUuid = providerId;
|
||||
if (providerId) {
|
||||
await httpClient.updateModelProvider(providerId, data);
|
||||
toast.success(t('models.providerSaved'));
|
||||
if (savedId.current) {
|
||||
await httpClient.updateModelProvider(savedId.current, data);
|
||||
} else {
|
||||
const response = await httpClient.createModelProvider(data);
|
||||
savedProviderUuid = response.uuid;
|
||||
toast.success(t('models.providerCreated'));
|
||||
savedId.current = response.uuid;
|
||||
if (mounted.current) setSavedProviderId(response.uuid);
|
||||
}
|
||||
if (!mounted.current) return;
|
||||
if (isCodex && login.phase !== 'connected') {
|
||||
await login.start(savedId.current);
|
||||
} else {
|
||||
toast.success(t('models.providerSaved'));
|
||||
await onFormSubmit(savedId.current);
|
||||
}
|
||||
await onFormSubmit(savedProviderUuid as string);
|
||||
} catch (err) {
|
||||
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
|
||||
if (mounted.current)
|
||||
toast.error(
|
||||
t('models.providerSaveError') + (err as CustomApiError).msg,
|
||||
);
|
||||
} finally {
|
||||
submitting.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (
|
||||
loadState !== 'ready' ||
|
||||
!providerId ||
|
||||
!mutableProviderLoaded ||
|
||||
!onProviderDeleted ||
|
||||
deleting.current ||
|
||||
submitting.current ||
|
||||
(isCodex && loginActive)
|
||||
)
|
||||
return;
|
||||
deleting.current = true;
|
||||
setIsDeleting(true);
|
||||
setDeleteError('');
|
||||
try {
|
||||
await httpClient.deleteModelProvider(providerId, true);
|
||||
} catch (err) {
|
||||
const detail =
|
||||
(err as CustomApiError | null)?.msg ||
|
||||
(err instanceof Error ? err.message : '');
|
||||
setDeleteError(t('models.providerDeleteError') + detail);
|
||||
deleting.current = false;
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
toast.success(t('models.providerDeleted'));
|
||||
await onProviderDeleted(providerId);
|
||||
}
|
||||
|
||||
if (loadState !== 'ready') {
|
||||
return (
|
||||
<>
|
||||
{loadState === 'loading' ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t('common.loading')}
|
||||
className="flex justify-center py-8"
|
||||
>
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
) : (
|
||||
<p role="alert" className="py-8 text-sm text-destructive">
|
||||
{t('models.loadError')}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{loadState === 'error' && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoadState('loading');
|
||||
setLoadAttempt((attempt) => attempt + 1);
|
||||
}}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onFormCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -202,7 +310,12 @@ export default function ProviderForm({
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input
|
||||
{...field}
|
||||
disabled={
|
||||
form.formState.isSubmitting || (isCodex && loginActive)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -222,45 +335,65 @@ export default function ProviderForm({
|
||||
{t('models.requester')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) setSearchQuery('');
|
||||
}}
|
||||
>
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'ring-2 ring-ring ring-offset-2',
|
||||
)}
|
||||
>
|
||||
{selectedRequester ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getProviderRequesterIconURL(
|
||||
selectedRequester.value,
|
||||
)}
|
||||
alt={selectedRequester.label}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{selectedRequester.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t('models.selectRequester')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
form.formState.isSubmitting ||
|
||||
(isCodex && (!!savedProviderId || loginActive))
|
||||
}
|
||||
aria-expanded={isOpen}
|
||||
className={cn(
|
||||
'h-4 w-4 opacity-50 transition-transform',
|
||||
isOpen && 'rotate-180',
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'ring-2 ring-ring ring-offset-2',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
>
|
||||
{selectedRequester ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getProviderRequesterIconURL(
|
||||
selectedRequester.value,
|
||||
)}
|
||||
alt={selectedRequester.label}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{selectedRequester.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t('models.selectRequester')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 opacity-50 transition-transform',
|
||||
isOpen && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
{/* Dropdown */}
|
||||
{/* Unmount on close so an exiting layer cannot eat Dialog Escape. */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95">
|
||||
<PopoverContent
|
||||
align="start"
|
||||
collisionPadding={8}
|
||||
className="flex max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-16px)] flex-col overflow-hidden p-0"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center border-b px-3">
|
||||
<div className="flex shrink-0 items-center border-b px-3">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
@@ -275,7 +408,13 @@ export default function ProviderForm({
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<div className="max-h-[300px] overflow-y-auto p-1">
|
||||
<div
|
||||
className="min-h-0 max-h-[300px] overflow-y-auto overscroll-contain p-1"
|
||||
// The dialog's document-level scroll lock treats this portal as outside.
|
||||
// Keep native list scrolling without forwarding gestures to that lock.
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
onTouchMove={(event) => event.stopPropagation()}
|
||||
>
|
||||
{Object.entries(groupedRequesters).map(
|
||||
([category, items]) => {
|
||||
if (items.length === 0) return null;
|
||||
@@ -288,6 +427,11 @@ export default function ProviderForm({
|
||||
<button
|
||||
key={r.value}
|
||||
type="button"
|
||||
disabled={
|
||||
!!providerId &&
|
||||
r.value === 'openai-codex' &&
|
||||
!isCodex
|
||||
}
|
||||
onClick={() => {
|
||||
field.onChange(r.value);
|
||||
const req = requesterList.find(
|
||||
@@ -337,9 +481,9 @@ export default function ProviderForm({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
{selectedRequester?.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -351,40 +495,131 @@ export default function ProviderForm({
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.requestURL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isCodex ? (
|
||||
<CodexAccountSection login={login} providerId={savedProviderId} />
|
||||
) : (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.requestURL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={
|
||||
form.formState.isSubmitting || (isCodex && loginActive)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.apiKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.apiKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">{t('common.save')}</Button>
|
||||
<Button type="button" variant="outline" onClick={onFormCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<DialogFooter className="flex-row flex-wrap items-start justify-between sm:justify-between">
|
||||
{providerId && mutableProviderLoaded && onProviderDeleted && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={
|
||||
isDeleting ||
|
||||
form.formState.isSubmitting ||
|
||||
(isCodex && loginActive)
|
||||
}
|
||||
onClick={() => {
|
||||
setDeleteError('');
|
||||
setDeleteConfirmOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="ml-auto flex flex-col gap-2 sm:flex-row">
|
||||
{(!isCodex || !savedProviderId || login.phase === 'connected') && (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isDeleting ||
|
||||
form.formState.isSubmitting ||
|
||||
(isCodex && loginActive)
|
||||
}
|
||||
>
|
||||
{isCodex
|
||||
? t(
|
||||
login.phase === 'connected'
|
||||
? 'models.codex.done'
|
||||
: 'models.codex.saveAndSignIn',
|
||||
)
|
||||
: t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isDeleting}
|
||||
onClick={onFormCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
<AlertDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!deleting.current) setDeleteConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
{deleteConfirmOpen && (
|
||||
<AlertDialogContent className="max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-lg">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('common.delete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('models.deleteProviderCascadeConfirmation')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{deleteError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm text-destructive break-words"
|
||||
>
|
||||
{deleteError}
|
||||
</p>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>
|
||||
{t('common.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
aria-busy={isDeleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
)}
|
||||
</AlertDialog>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Subscription credentials are server-owned, never API-key form values. */
|
||||
export function providerPayload(values: {
|
||||
name: string;
|
||||
requester: string;
|
||||
base_url: string;
|
||||
api_key?: string;
|
||||
}) {
|
||||
const subscription = values.requester === 'openai-codex';
|
||||
return {
|
||||
name: values.name,
|
||||
requester: values.requester,
|
||||
base_url: subscription
|
||||
? 'https://chatgpt.com/backend-api/codex'
|
||||
: values.base_url,
|
||||
api_keys: subscription ? [] : values.api_key ? [values.api_key] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function pollDelay(interval: number, failures = 0): number {
|
||||
const seconds = Number.isFinite(interval) && interval > 0 ? interval : 5;
|
||||
return Math.max(seconds, Math.min(60, seconds * 2 ** failures)) * 1000;
|
||||
}
|
||||
|
||||
export function isCodexVerificationUri(uri: string): boolean {
|
||||
return uri === 'https://auth.openai.com/codex/device';
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import type { CodexDeviceAuthorization } from '@/app/infra/entities/codex';
|
||||
import { isCodexVerificationUri, pollDelay } from './codexPolicy';
|
||||
|
||||
type Phase =
|
||||
| 'disconnected'
|
||||
| 'loading'
|
||||
| 'starting'
|
||||
| 'pending'
|
||||
| 'connected'
|
||||
| 'expired'
|
||||
| 'error'
|
||||
| 'canceling';
|
||||
|
||||
/** One in-memory authorization, sequential polls, and stale-response fencing. */
|
||||
export function useCodexLogin(enabled: boolean, providerId?: string) {
|
||||
const [phase, setPhase] = useState<Phase>('disconnected');
|
||||
const [device, setDevice] = useState<CodexDeviceAuthorization | null>(null);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const generation = useRef(0);
|
||||
const busy = useRef(false);
|
||||
const attempt = useRef<{ uuid: string; authorizationId: string } | null>(
|
||||
null,
|
||||
);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const deadline = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const request = useRef<AbortController | null>(null);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
generation.current++;
|
||||
clearTimeout(timer.current);
|
||||
clearTimeout(deadline.current);
|
||||
request.current?.abort();
|
||||
busy.current = false;
|
||||
const pending = attempt.current;
|
||||
attempt.current = null;
|
||||
return pending;
|
||||
}, []);
|
||||
|
||||
const clearPending = useCallback(async () => {
|
||||
const pending = stop();
|
||||
if (pending)
|
||||
await httpClient.cancelCodexDeviceLogin(
|
||||
pending.uuid,
|
||||
pending.authorizationId,
|
||||
);
|
||||
}, [stop]);
|
||||
|
||||
const loadStatus = useCallback(async (uuid: string) => {
|
||||
const version = generation.current;
|
||||
request.current = new AbortController();
|
||||
setPhase('loading');
|
||||
try {
|
||||
const status = await httpClient.getCodexAuthStatus(
|
||||
uuid,
|
||||
request.current.signal,
|
||||
);
|
||||
if (version === generation.current) setPhase(status.status);
|
||||
} catch {
|
||||
if (version === generation.current) setPhase('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setDevice(null);
|
||||
setPhase('disconnected');
|
||||
if (enabled && providerId) void loadStatus(providerId);
|
||||
return () => {
|
||||
// Device creation is deliberately not aborted: its late response must be
|
||||
// canceled server-side even if this form has already unmounted.
|
||||
void clearPending().catch(() => {});
|
||||
};
|
||||
}, [enabled, providerId, loadStatus, clearPending]);
|
||||
|
||||
async function start(uuid: string) {
|
||||
if (busy.current) return;
|
||||
const old = stop();
|
||||
busy.current = true;
|
||||
const version = generation.current;
|
||||
setPhase('starting');
|
||||
setDevice(null);
|
||||
setRetrying(false);
|
||||
try {
|
||||
if (old)
|
||||
await httpClient.cancelCodexDeviceLogin(old.uuid, old.authorizationId);
|
||||
if (version !== generation.current) return;
|
||||
const authorization = await httpClient.startCodexDeviceLogin(uuid);
|
||||
if (version !== generation.current) {
|
||||
await httpClient.cancelCodexDeviceLogin(
|
||||
uuid,
|
||||
authorization.authorization_id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
attempt.current = {
|
||||
uuid,
|
||||
authorizationId: authorization.authorization_id,
|
||||
};
|
||||
if (
|
||||
!isCodexVerificationUri(authorization.verification_uri) ||
|
||||
!Number.isFinite(authorization.expires_at)
|
||||
) {
|
||||
await clearPending();
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
setDevice(authorization);
|
||||
setPhase('pending');
|
||||
let interval = authorization.interval;
|
||||
let failures = 0;
|
||||
request.current = new AbortController();
|
||||
const signal = request.current.signal;
|
||||
const expire = () => {
|
||||
if (version !== generation.current) return;
|
||||
void clearPending().catch(() => {});
|
||||
setDevice(null);
|
||||
setPhase('expired');
|
||||
};
|
||||
deadline.current = setTimeout(
|
||||
expire,
|
||||
Math.max(0, authorization.expires_at * 1000 - Date.now()),
|
||||
);
|
||||
const poll = async () => {
|
||||
if (version !== generation.current) return;
|
||||
if (Date.now() >= authorization.expires_at * 1000) {
|
||||
expire();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await httpClient.pollCodexDeviceLogin(
|
||||
uuid,
|
||||
authorization.authorization_id,
|
||||
signal,
|
||||
);
|
||||
if (version !== generation.current) return;
|
||||
if (result.status !== 'pending') {
|
||||
attempt.current = null;
|
||||
stop();
|
||||
setDevice(null);
|
||||
setPhase(result.status);
|
||||
return;
|
||||
}
|
||||
interval = result.interval ?? interval;
|
||||
failures = 0;
|
||||
setRetrying(false);
|
||||
} catch (error) {
|
||||
if (version !== generation.current) return;
|
||||
const code = (error as { code?: number }).code;
|
||||
if (
|
||||
(code === -1 || (code !== undefined && code >= 500)) &&
|
||||
failures < 3
|
||||
) {
|
||||
failures++;
|
||||
setRetrying(true);
|
||||
} else {
|
||||
void clearPending().catch(() => {});
|
||||
setDevice(null);
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
timer.current = setTimeout(poll, pollDelay(interval, failures));
|
||||
};
|
||||
timer.current = setTimeout(poll, pollDelay(interval));
|
||||
} catch {
|
||||
if (version === generation.current) {
|
||||
busy.current = false;
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(uuid: string) {
|
||||
setPhase('canceling');
|
||||
setDevice(null);
|
||||
const pending = clearPending();
|
||||
const version = generation.current;
|
||||
try {
|
||||
await pending;
|
||||
if (version === generation.current) await loadStatus(uuid);
|
||||
} catch {
|
||||
if (version === generation.current) setPhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect(uuid: string) {
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
setPhase('loading');
|
||||
const version = generation.current;
|
||||
try {
|
||||
await httpClient.disconnectCodex(uuid);
|
||||
if (version === generation.current) await loadStatus(uuid);
|
||||
} catch {
|
||||
if (version === generation.current) setPhase('error');
|
||||
} finally {
|
||||
busy.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { phase, device, retrying, start, cancel, disconnect, loadStatus };
|
||||
}
|
||||
@@ -8,7 +8,14 @@ import React, {
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Braces, Loader2, Trash2, Wrench, XCircle } from 'lucide-react';
|
||||
import {
|
||||
Braces,
|
||||
Loader2,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
Wrench,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -101,7 +108,7 @@ function StatusDisplay({
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<XCircle className="size-5" />
|
||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
||||
<span className="font-medium">{t('mcp.connectionFailedStatus')}</span>
|
||||
</div>
|
||||
<div className="pl-7 text-sm text-red-500 space-y-0.5">
|
||||
<div>
|
||||
@@ -117,15 +124,41 @@ function StatusDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
if (runtimeInfo.error_phase === 'oauth_required') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-400">
|
||||
<ShieldAlert className="size-5" />
|
||||
<span className="font-medium">
|
||||
{t('mcp.oauthAuthorizationRequired')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-7 text-sm text-muted-foreground">
|
||||
{t('mcp.oauthAuthorizationRequiredSuggestion')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const httpStatus = runtimeInfo.error_code?.match(/^http_(\d{3})$/)?.[1];
|
||||
const errorDetail =
|
||||
runtimeInfo.error_code === 'connection_unreachable'
|
||||
? t('mcp.connectionUnreachable')
|
||||
: runtimeInfo.error_code === 'connection_timeout'
|
||||
? t('mcp.connectionTimeout')
|
||||
: httpStatus
|
||||
? t('mcp.connectionHttpError', { status: httpStatus })
|
||||
: runtimeInfo.error_message || t('mcp.unknownError');
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<XCircle className="size-5" />
|
||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
||||
<span className="font-medium">{t('mcp.connectionFailedStatus')}</span>
|
||||
</div>
|
||||
{runtimeInfo.error_message && (
|
||||
<div className="pl-7 text-sm text-red-500">
|
||||
{runtimeInfo.error_message}
|
||||
{errorDetail && (
|
||||
<div className="pl-7 whitespace-pre-wrap break-words text-sm text-muted-foreground">
|
||||
{errorDetail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -835,15 +868,31 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
async function testMcp() {
|
||||
setMcpTesting(true);
|
||||
|
||||
const showConnectionFailure = (
|
||||
message: string,
|
||||
info?: MCPServerRuntimeInfo,
|
||||
) => {
|
||||
toast.error(t('mcp.connectionFailedStatus'));
|
||||
setRuntimeInfo({
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
resource_count: 0,
|
||||
resources: [],
|
||||
...info,
|
||||
status: MCPSessionStatus.ERROR,
|
||||
error_message: info?.error_message || message,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const mode = form.getValues('mode');
|
||||
if (mode === 'stdio' && !mcpStdioEnabled) {
|
||||
toast.error(t('mcp.stdioDisabledByPolicy'));
|
||||
showConnectionFailure(t('mcp.stdioDisabledByPolicy'));
|
||||
setMcpTesting(false);
|
||||
return;
|
||||
}
|
||||
if (mode === 'stdio' && !boxAvailable) {
|
||||
toast.error(t('mcp.stdioBlockedByBoxToast'));
|
||||
showConnectionFailure(t('mcp.stdioBlockedByBoxToast'));
|
||||
setMcpTesting(false);
|
||||
return;
|
||||
}
|
||||
@@ -914,15 +963,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
if (taskResp.runtime.exception) {
|
||||
const errorMsg =
|
||||
taskResp.runtime.exception || t('mcp.unknownError');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
setRuntimeInfo({
|
||||
status: MCPSessionStatus.ERROR,
|
||||
error_message: errorMsg,
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
resource_count: 0,
|
||||
resources: [],
|
||||
});
|
||||
const runtimeInfoFromTest = taskResp.task_context?.metadata
|
||||
?.runtime_info as MCPServerRuntimeInfo | undefined;
|
||||
showConnectionFailure(errorMsg, runtimeInfoFromTest);
|
||||
if (shouldTestPersistedServer) {
|
||||
await onPersistedTestComplete?.(serverName);
|
||||
}
|
||||
@@ -949,14 +992,19 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
clearInterval(interval);
|
||||
setMcpTesting(false);
|
||||
const errorMsg =
|
||||
(err as CustomApiError).msg || t('mcp.getTaskFailed');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
(err as CustomApiError).msg ||
|
||||
(err as Error).message ||
|
||||
t('mcp.getTaskFailed');
|
||||
showConnectionFailure(errorMsg);
|
||||
}
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setMcpTesting(false);
|
||||
const errorMsg = (err as Error).message || t('mcp.unknownError');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
const errorMsg =
|
||||
(err as CustomApiError).msg ||
|
||||
(err as Error).message ||
|
||||
t('mcp.unknownError');
|
||||
showConnectionFailure(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,24 +4,18 @@ import { MessageSquare, Sparkles, Check, Users } from 'lucide-react';
|
||||
import MetricCard from './MetricCard';
|
||||
import SystemStatusCard from './SystemStatusCards';
|
||||
import TrafficChart from './TrafficChart';
|
||||
import {
|
||||
OverviewMetrics,
|
||||
MonitoringMessage,
|
||||
LLMCall,
|
||||
} from '../../types/monitoring';
|
||||
import { OverviewMetrics, MonitoringData } from '../../types/monitoring';
|
||||
|
||||
interface OverviewCardsProps {
|
||||
metrics: OverviewMetrics | null;
|
||||
messages?: MonitoringMessage[];
|
||||
llmCalls?: LLMCall[];
|
||||
traffic?: MonitoringData['traffic'];
|
||||
loading?: boolean;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
export default function OverviewCards({
|
||||
metrics,
|
||||
messages = [],
|
||||
llmCalls = [],
|
||||
traffic,
|
||||
loading,
|
||||
refreshKey,
|
||||
}: OverviewCardsProps) {
|
||||
@@ -100,7 +94,7 @@ export default function OverviewCards({
|
||||
</div>
|
||||
|
||||
{/* Traffic Chart */}
|
||||
<TrafficChart messages={messages} llmCalls={llmCalls} loading={loading} />
|
||||
<TrafficChart traffic={traffic} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,119 +11,33 @@ import {
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { MonitoringMessage, LLMCall } from '../../types/monitoring';
|
||||
import { MonitoringData } from '../../types/monitoring';
|
||||
|
||||
interface TrafficChartProps {
|
||||
messages: MonitoringMessage[];
|
||||
llmCalls: LLMCall[];
|
||||
traffic?: MonitoringData['traffic'];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
time: string;
|
||||
timestamp: number;
|
||||
messages: number;
|
||||
llmCalls: number;
|
||||
}
|
||||
|
||||
export default function TrafficChart({
|
||||
messages,
|
||||
llmCalls,
|
||||
loading,
|
||||
}: TrafficChartProps) {
|
||||
export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const safeMessages = Array.isArray(messages) ? messages : [];
|
||||
const safeLlmCalls = Array.isArray(llmCalls) ? llmCalls : [];
|
||||
if (!safeMessages.length && !safeLlmCalls.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Combine all timestamps and find the range
|
||||
const allTimestamps = [
|
||||
...safeMessages.map((m) => m.timestamp.getTime()),
|
||||
...safeLlmCalls.map((c) => c.timestamp.getTime()),
|
||||
];
|
||||
|
||||
if (allTimestamps.length === 0) return [];
|
||||
|
||||
const minTime = Math.min(...allTimestamps);
|
||||
const maxTime = Math.max(...allTimestamps);
|
||||
const timeRange = maxTime - minTime;
|
||||
|
||||
// Determine bucket size based on time range
|
||||
let bucketSize: number;
|
||||
let formatTime: (date: Date) => string;
|
||||
|
||||
if (timeRange <= 60 * 60 * 1000) {
|
||||
// <= 1 hour: 5-minute buckets
|
||||
bucketSize = 5 * 60 * 1000;
|
||||
formatTime = (date) =>
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (timeRange <= 6 * 60 * 60 * 1000) {
|
||||
// <= 6 hours: 15-minute buckets
|
||||
bucketSize = 15 * 60 * 1000;
|
||||
formatTime = (date) =>
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (timeRange <= 24 * 60 * 60 * 1000) {
|
||||
// <= 24 hours: 1-hour buckets
|
||||
bucketSize = 60 * 60 * 1000;
|
||||
formatTime = (date) =>
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (timeRange <= 7 * 24 * 60 * 60 * 1000) {
|
||||
// <= 7 days: 4-hour buckets
|
||||
bucketSize = 4 * 60 * 60 * 1000;
|
||||
formatTime = (date) =>
|
||||
`${date.toLocaleDateString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})} ${date.toLocaleTimeString([], { hour: '2-digit' })}`;
|
||||
} else {
|
||||
// > 7 days: 1-day buckets
|
||||
bucketSize = 24 * 60 * 60 * 1000;
|
||||
formatTime = (date) =>
|
||||
date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// Create buckets
|
||||
const buckets: Map<number, ChartDataPoint> = new Map();
|
||||
const startBucket = Math.floor(minTime / bucketSize) * bucketSize;
|
||||
const endBucket = Math.ceil(maxTime / bucketSize) * bucketSize;
|
||||
|
||||
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketSize) {
|
||||
buckets.set(bucket, {
|
||||
time: formatTime(new Date(bucket)),
|
||||
timestamp: bucket,
|
||||
messages: 0,
|
||||
llmCalls: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Count messages per bucket
|
||||
safeMessages.forEach((msg) => {
|
||||
const bucket =
|
||||
Math.floor(msg.timestamp.getTime() / bucketSize) * bucketSize;
|
||||
const point = buckets.get(bucket);
|
||||
if (point) {
|
||||
point.messages++;
|
||||
}
|
||||
});
|
||||
|
||||
// Count LLM calls per bucket
|
||||
safeLlmCalls.forEach((call) => {
|
||||
const bucket =
|
||||
Math.floor(call.timestamp.getTime() / bucketSize) * bucketSize;
|
||||
const point = buckets.get(bucket);
|
||||
if (point) {
|
||||
point.llmCalls++;
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(buckets.values()).sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
}, [messages, llmCalls]);
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
(traffic?.points ?? []).map((point) => ({
|
||||
...point,
|
||||
time: point.timestamp.toLocaleString(
|
||||
[],
|
||||
traffic?.bucket === 'day'
|
||||
? { month: 'short', day: 'numeric' }
|
||||
: {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
},
|
||||
),
|
||||
})),
|
||||
[traffic],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -150,7 +64,13 @@ export default function TrafficChart({
|
||||
</h3>
|
||||
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<BarChart3 className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">{t('monitoring.trafficChart.noData')}</div>
|
||||
<div className="text-sm">
|
||||
{t(
|
||||
traffic
|
||||
? 'monitoring.trafficChart.noData'
|
||||
: 'monitoring.trafficChart.unavailable',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -161,6 +81,11 @@ export default function TrafficChart({
|
||||
<h3 className="text-base font-semibold text-foreground mb-6">
|
||||
{t('monitoring.trafficChart.title')}
|
||||
</h3>
|
||||
{traffic?.truncated && (
|
||||
<p role="status" className="text-sm text-muted-foreground mb-3">
|
||||
{t('monitoring.trafficChart.truncated')}
|
||||
</p>
|
||||
)}
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
FilterState,
|
||||
MonitoringData,
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
LLMCall,
|
||||
EmbeddingCall,
|
||||
} from '../types/monitoring';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
|
||||
import { parseUTCTimestamp } from '../utils/dateUtils';
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,10 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
const [data, setData] = useState<MonitoringData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const workspaceUuid = useCurrentWorkspace()?.workspace.uuid;
|
||||
const requestIdRef = useRef(0);
|
||||
const scope = JSON.stringify([workspaceUuid, filterState]);
|
||||
const [requestScope, setRequestScope] = useState<string | null>(null);
|
||||
|
||||
// Memoize filter parameters to prevent unnecessary re-renders
|
||||
const selectedBotsStr = useMemo(
|
||||
@@ -72,6 +77,12 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
|
||||
// Fetch data based on filters
|
||||
const fetchData = useCallback(async () => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
const isCurrent = () =>
|
||||
requestId === requestIdRef.current &&
|
||||
getCurrentWorkspaceSnapshot()?.workspace.uuid === workspaceUuid;
|
||||
setRequestScope(scope);
|
||||
setData(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -91,6 +102,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
endTime,
|
||||
limit: 50,
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
|
||||
const overview = response?.overview ?? {
|
||||
total_messages: 0,
|
||||
@@ -127,6 +139,17 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
|
||||
// Transform the response to match MonitoringData interface
|
||||
const transformedData: MonitoringData = {
|
||||
traffic: response.traffic
|
||||
? {
|
||||
bucket: response.traffic.bucket,
|
||||
truncated: response.traffic.truncated,
|
||||
points: response.traffic.points.map((point) => ({
|
||||
timestamp: parseUTCTimestamp(point.timestamp),
|
||||
messages: point.messages,
|
||||
llmCalls: point.llm_calls,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
overview: {
|
||||
totalMessages: overview.total_messages,
|
||||
llmCalls: overview.llm_calls,
|
||||
@@ -396,22 +419,33 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
|
||||
setData(transformedData);
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch monitoring data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (isCurrent()) setLoading(false);
|
||||
}
|
||||
}, [getTimeRange, filterState.selectedBots, filterState.selectedPipelines]);
|
||||
}, [
|
||||
getTimeRange,
|
||||
filterState.selectedBots,
|
||||
filterState.selectedPipelines,
|
||||
scope,
|
||||
workspaceUuid,
|
||||
]);
|
||||
|
||||
// Fetch data when filter state changes
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return () => {
|
||||
requestIdRef.current += 1;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
selectedBotsStr,
|
||||
selectedPipelinesStr,
|
||||
filterState.timeRange,
|
||||
customDateRangeStr,
|
||||
workspaceUuid,
|
||||
]);
|
||||
|
||||
// Manual refetch function
|
||||
@@ -420,9 +454,9 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
data: requestScope === scope ? data : null,
|
||||
loading: requestScope !== scope || loading,
|
||||
error: requestScope === scope ? error : null,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ function MonitoringPageContent() {
|
||||
currentWorkspace?.permissions.includes('data.export') ?? false;
|
||||
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
|
||||
useMonitoringFilters();
|
||||
const { data, loading, refetch } = useMonitoringData(filterState);
|
||||
const { data, loading, error, refetch } = useMonitoringData(filterState);
|
||||
|
||||
// Counter to force feedbackTimeRange recomputation on manual refresh
|
||||
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
|
||||
@@ -174,492 +174,556 @@ function MonitoringPageContent() {
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="relative z-0 flex flex-col gap-6 pb-4 pt-3">
|
||||
{/* Overview Section */}
|
||||
<OverviewCards
|
||||
metrics={data?.overview || null}
|
||||
messages={data?.messages || []}
|
||||
llmCalls={data?.llmCalls || []}
|
||||
loading={loading}
|
||||
/>
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border border-destructive p-6 space-y-3"
|
||||
>
|
||||
<p>{t('monitoring.loadError')}</p>
|
||||
<Button variant="outline" onClick={handleRefresh}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative z-0 flex flex-col gap-6 pb-4 pt-3">
|
||||
{/* Overview Section */}
|
||||
<OverviewCards
|
||||
metrics={data?.overview || null}
|
||||
traffic={data?.traffic}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Tabs Section */}
|
||||
<div className="bg-card rounded-xl border overflow-hidden">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="px-3 pt-4 sm:px-6">
|
||||
<TabsList className="h-12 w-full justify-start gap-1 overflow-x-auto p-1 sm:w-auto">
|
||||
<TabsTrigger value="messages" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.messages')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="modelCalls" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.modelCalls')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tokens" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.tokens')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="feedback" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.feedback')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="errors" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.errors')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Tabs Section */}
|
||||
{!loading && data && (
|
||||
<div
|
||||
className="text-sm text-muted-foreground space-y-1"
|
||||
role="status"
|
||||
>
|
||||
{data.totalCount.messages > data.messages.length && (
|
||||
<p>
|
||||
{t('monitoring.partialMessages', {
|
||||
shown: data.messages.length,
|
||||
total: data.totalCount.messages,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{data.totalCount.llmCalls + data.totalCount.embeddingCalls >
|
||||
data.modelCalls.length && (
|
||||
<p>
|
||||
{t('monitoring.partialModelCalls', {
|
||||
shown: data.modelCalls.length,
|
||||
total:
|
||||
data.totalCount.llmCalls + data.totalCount.embeddingCalls,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{(data.totalCount.toolCalls ?? 0) > data.toolCalls.length && (
|
||||
<p>
|
||||
{t('monitoring.partialToolCalls', {
|
||||
shown: data.toolCalls.length,
|
||||
total: data.totalCount.toolCalls,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{data.totalCount.errors > data.errors.length && (
|
||||
<p>
|
||||
{t('monitoring.partialErrors', {
|
||||
shown: data.errors.length,
|
||||
total: data.totalCount.errors,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="messages" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner
|
||||
text={t('monitoring.messageList.loading')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data && conversationTurns.length > 0 && (
|
||||
<ConversationTurnList
|
||||
turns={conversationTurns}
|
||||
expandedTurnId={expandedTurnId}
|
||||
onToggleTurn={toggleTurnExpand}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && (!data || conversationTurns.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<MessageSquare className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">
|
||||
{t('monitoring.messageList.noMessages')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<div className="bg-card rounded-xl border overflow-hidden">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="px-3 pt-4 sm:px-6">
|
||||
<TabsList className="h-12 w-full justify-start gap-1 overflow-x-auto p-1 sm:w-auto">
|
||||
<TabsTrigger value="messages" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.messages')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="modelCalls" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.modelCalls')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tokens" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.tokens')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="feedback" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.feedback')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="errors" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.errors')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="modelCalls" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data &&
|
||||
data.modelCalls &&
|
||||
data.modelCalls.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{data.modelCalls.map((call) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="border rounded-xl p-3 transition-all duration-200 sm:p-5"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
{/* Query ID - only show if messageId exists */}
|
||||
{call.messageId && (
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {call.messageId}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
onClick={() =>
|
||||
jumpToMessage(call.messageId!)
|
||||
}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
{t(
|
||||
'monitoring.messageList.viewConversation',
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{/* Model Type Badge */}
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.modelType === 'llm'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||
}`}
|
||||
>
|
||||
{call.modelType === 'llm'
|
||||
? t('monitoring.modelCalls.llmModel')
|
||||
: t('monitoring.modelCalls.embeddingModel')}
|
||||
</span>
|
||||
{/* Call Type Badge for Embedding */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.callType && (
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.callType === 'retrieve'
|
||||
? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
|
||||
}`}
|
||||
>
|
||||
{call.callType === 'retrieve'
|
||||
? t(
|
||||
'monitoring.modelCalls.retrieveCall',
|
||||
)
|
||||
: t(
|
||||
'monitoring.modelCalls.embeddingCall',
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* Status Badge */}
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.status === 'success'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
</div>
|
||||
{/* Model Name */}
|
||||
<div className="font-medium text-sm text-foreground mb-2">
|
||||
{call.modelName}
|
||||
</div>
|
||||
{/* Context Info - only for LLM calls */}
|
||||
{call.modelType === 'llm' &&
|
||||
call.botName &&
|
||||
call.pipelineName && (
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{call.botName} → {call.pipelineName}
|
||||
</div>
|
||||
)}
|
||||
{/* Token Info */}
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{call.modelType === 'llm' && call.tokens && (
|
||||
<>
|
||||
<span>
|
||||
{t('monitoring.llmCalls.inputTokens')}:{' '}
|
||||
{call.tokens.input}
|
||||
</span>
|
||||
<span>
|
||||
{t('monitoring.llmCalls.outputTokens')}:{' '}
|
||||
{call.tokens.output}
|
||||
</span>
|
||||
<span>
|
||||
{t('monitoring.llmCalls.totalTokens')}:{' '}
|
||||
{call.tokens.total}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{call.modelType === 'embedding' && (
|
||||
<>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.promptTokens',
|
||||
)}
|
||||
: {call.promptTokens}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.totalTokens',
|
||||
)}
|
||||
: {call.totalTokens}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.inputCount',
|
||||
)}
|
||||
: {call.inputCount}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span>
|
||||
{t('monitoring.llmCalls.duration')}:{' '}
|
||||
{call.duration}ms
|
||||
</span>
|
||||
{call.cost && (
|
||||
<span>
|
||||
{t('monitoring.llmCalls.cost')}: $
|
||||
{call.cost.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Knowledge Base Info for Embedding */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.knowledgeBaseId && (
|
||||
<div>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.knowledgeBase',
|
||||
)}
|
||||
: {call.knowledgeBaseId}
|
||||
</div>
|
||||
)}
|
||||
{/* Query Text for Embedding Retrieve */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.queryText && (
|
||||
<div className="mt-2 p-2 bg-muted rounded text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
'monitoring.embeddingCalls.queryText',
|
||||
)}
|
||||
:{' '}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{call.queryText.length > 100
|
||||
? call.queryText.substring(0, 100) +
|
||||
'...'
|
||||
: call.queryText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{call.errorMessage && (
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
Error: {call.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap ml-4">
|
||||
{call.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<TabsContent value="messages" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner
|
||||
text={t('monitoring.messageList.loading')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
(!data ||
|
||||
!data.modelCalls ||
|
||||
data.modelCalls.length === 0) && (
|
||||
{!loading && data && conversationTurns.length > 0 && (
|
||||
<ConversationTurnList
|
||||
turns={conversationTurns}
|
||||
expandedTurnId={expandedTurnId}
|
||||
onToggleTurn={toggleTurnExpand}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && (!data || conversationTurns.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<Sparkles className="h-[3rem] w-[3rem]" />
|
||||
<MessageSquare className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">
|
||||
{t('monitoring.modelCalls.noData')}
|
||||
{t('monitoring.messageList.noMessages')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tokens" className="p-3 m-0 sm:p-6">
|
||||
<TokenMonitoring
|
||||
botIds={
|
||||
filterState.selectedBots.length > 0
|
||||
? filterState.selectedBots
|
||||
: undefined
|
||||
}
|
||||
pipelineIds={
|
||||
filterState.selectedPipelines.length > 0
|
||||
? filterState.selectedPipelines
|
||||
: undefined
|
||||
}
|
||||
startTime={feedbackTimeRange.startTime}
|
||||
endTime={feedbackTimeRange.endTime}
|
||||
refreshKey={feedbackRefreshKey}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feedback" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{/* Feedback Stats Cards */}
|
||||
<div className="mb-6">
|
||||
<FeedbackStatsCards
|
||||
stats={feedbackStats}
|
||||
loading={feedbackLoading}
|
||||
/>
|
||||
<TabsContent value="modelCalls" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback List */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('monitoring.feedback.feedbackList')}
|
||||
</h3>
|
||||
<FeedbackList
|
||||
feedback={feedbackList}
|
||||
loading={feedbackLoading}
|
||||
onViewMessage={jumpToMessage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="errors" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data && data.errors && data.errors.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{data.errors.map((error) => (
|
||||
<div
|
||||
key={error.id}
|
||||
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden transition-all duration-200"
|
||||
>
|
||||
{/* Error Header - Always Visible */}
|
||||
<div
|
||||
className="p-3 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors bg-red-50/50 dark:bg-red-950/30 sm:p-5"
|
||||
onClick={() => toggleErrorExpand(error.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start flex-1">
|
||||
{/* Expand Icon */}
|
||||
<div className="mr-3 mt-0.5">
|
||||
{expandedErrorId === error.id ? (
|
||||
<ChevronDown className="w-5 h-5 text-red-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Info */}
|
||||
{!loading &&
|
||||
data &&
|
||||
data.modelCalls &&
|
||||
data.modelCalls.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{data.modelCalls.map((call) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="border rounded-xl p-3 transition-all duration-200 sm:p-5"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
{/* Query ID */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {error.messageId || '-'}
|
||||
</span>
|
||||
{error.messageId && (
|
||||
{/* Query ID - only show if messageId exists */}
|
||||
{call.messageId && (
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {call.messageId}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
jumpToMessage(error.messageId!);
|
||||
}}
|
||||
onClick={() =>
|
||||
jumpToMessage(call.messageId!)
|
||||
}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
{t(
|
||||
'monitoring.messageList.viewConversation',
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-sm text-red-700 dark:text-red-300">
|
||||
{error.errorType}
|
||||
{/* Model Type Badge */}
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.modelType === 'llm'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||
}`}
|
||||
>
|
||||
{call.modelType === 'llm'
|
||||
? t('monitoring.modelCalls.llmModel')
|
||||
: t(
|
||||
'monitoring.modelCalls.embeddingModel',
|
||||
)}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.botName}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.pipelineName}
|
||||
{/* Call Type Badge for Embedding */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.callType && (
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.callType === 'retrieve'
|
||||
? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
|
||||
}`}
|
||||
>
|
||||
{call.callType === 'retrieve'
|
||||
? t(
|
||||
'monitoring.modelCalls.retrieveCall',
|
||||
)
|
||||
: t(
|
||||
'monitoring.modelCalls.embeddingCall',
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* Status Badge */}
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.status === 'success'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-red-600 dark:text-red-400 line-clamp-2">
|
||||
{error.errorMessage}
|
||||
</p>
|
||||
{/* Model Name */}
|
||||
<div className="font-medium text-sm text-foreground mb-2">
|
||||
{call.modelName}
|
||||
</div>
|
||||
{/* Context Info - only for LLM calls */}
|
||||
{call.modelType === 'llm' &&
|
||||
call.botName &&
|
||||
call.pipelineName && (
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{call.botName} → {call.pipelineName}
|
||||
</div>
|
||||
)}
|
||||
{/* Token Info */}
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{call.modelType === 'llm' &&
|
||||
call.tokens && (
|
||||
<>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.llmCalls.inputTokens',
|
||||
)}
|
||||
: {call.tokens.input}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.llmCalls.outputTokens',
|
||||
)}
|
||||
: {call.tokens.output}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.llmCalls.totalTokens',
|
||||
)}
|
||||
: {call.tokens.total}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{call.modelType === 'embedding' && (
|
||||
<>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.promptTokens',
|
||||
)}
|
||||
: {call.promptTokens}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.totalTokens',
|
||||
)}
|
||||
: {call.totalTokens}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.inputCount',
|
||||
)}
|
||||
: {call.inputCount}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span>
|
||||
{t('monitoring.llmCalls.duration')}:{' '}
|
||||
{call.duration}ms
|
||||
</span>
|
||||
{call.cost && (
|
||||
<span>
|
||||
{t('monitoring.llmCalls.cost')}: $
|
||||
{call.cost.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Knowledge Base Info for Embedding */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.knowledgeBaseId && (
|
||||
<div>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.knowledgeBase',
|
||||
)}
|
||||
: {call.knowledgeBaseId}
|
||||
</div>
|
||||
)}
|
||||
{/* Query Text for Embedding Retrieve */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.queryText && (
|
||||
<div className="mt-2 p-2 bg-muted rounded text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
'monitoring.embeddingCalls.queryText',
|
||||
)}
|
||||
:{' '}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{call.queryText.length > 100
|
||||
? call.queryText.substring(0, 100) +
|
||||
'...'
|
||||
: call.queryText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{call.errorMessage && (
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
Error: {call.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="flex flex-col items-end gap-2 ml-4">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{error.timestamp.toLocaleString()}
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap ml-4">
|
||||
{call.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedErrorId === error.id && (
|
||||
<div className="border-t border-red-200 dark:border-red-900 p-5 bg-background">
|
||||
<div className="space-y-4 pl-8 border-l-2 border-red-300 dark:border-red-800 ml-4">
|
||||
{/* Error Details */}
|
||||
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3">
|
||||
{t('monitoring.errors.errorMessage')}
|
||||
</h4>
|
||||
<div className="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap break-words">
|
||||
{error.errorMessage}
|
||||
{!loading &&
|
||||
(!data ||
|
||||
!data.modelCalls ||
|
||||
data.modelCalls.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<Sparkles className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">
|
||||
{t('monitoring.modelCalls.noData')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tokens" className="p-3 m-0 sm:p-6">
|
||||
<TokenMonitoring
|
||||
botIds={
|
||||
filterState.selectedBots.length > 0
|
||||
? filterState.selectedBots
|
||||
: undefined
|
||||
}
|
||||
pipelineIds={
|
||||
filterState.selectedPipelines.length > 0
|
||||
? filterState.selectedPipelines
|
||||
: undefined
|
||||
}
|
||||
startTime={feedbackTimeRange.startTime}
|
||||
endTime={feedbackTimeRange.endTime}
|
||||
refreshKey={feedbackRefreshKey}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feedback" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{/* Feedback Stats Cards */}
|
||||
<div className="mb-6">
|
||||
<FeedbackStatsCards
|
||||
stats={feedbackStats}
|
||||
loading={feedbackLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Feedback List */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('monitoring.feedback.feedbackList')}
|
||||
</h3>
|
||||
<FeedbackList
|
||||
feedback={feedbackList}
|
||||
loading={feedbackLoading}
|
||||
onViewMessage={jumpToMessage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="errors" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data &&
|
||||
data.errors &&
|
||||
data.errors.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{data.errors.map((error) => (
|
||||
<div
|
||||
key={error.id}
|
||||
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden transition-all duration-200"
|
||||
>
|
||||
{/* Error Header - Always Visible */}
|
||||
<div
|
||||
className="p-3 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors bg-red-50/50 dark:bg-red-950/30 sm:p-5"
|
||||
onClick={() => toggleErrorExpand(error.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start flex-1">
|
||||
{/* Expand Icon */}
|
||||
<div className="mr-3 mt-0.5">
|
||||
{expandedErrorId === error.id ? (
|
||||
<ChevronDown className="w-5 h-5 text-red-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Info */}
|
||||
<div className="flex-1">
|
||||
{/* Query ID */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {error.messageId || '-'}
|
||||
</span>
|
||||
{error.messageId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
jumpToMessage(error.messageId!);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
{t(
|
||||
'monitoring.messageList.viewConversation',
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-sm text-red-700 dark:text-red-300">
|
||||
{error.errorType}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.botName}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.pipelineName}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-red-600 dark:text-red-400 line-clamp-2">
|
||||
{error.errorMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="flex flex-col items-end gap-2 ml-4">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{error.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context Info */}
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.messageList.viewDetails')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.bot')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{error.botName}
|
||||
{/* Expanded Details */}
|
||||
{expandedErrorId === error.id && (
|
||||
<div className="border-t border-red-200 dark:border-red-900 p-5 bg-background">
|
||||
<div className="space-y-4 pl-8 border-l-2 border-red-300 dark:border-red-800 ml-4">
|
||||
{/* Error Details */}
|
||||
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3">
|
||||
{t('monitoring.errors.errorMessage')}
|
||||
</h4>
|
||||
<div className="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap break-words">
|
||||
{error.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.pipeline')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{error.pipelineName}
|
||||
|
||||
{/* Context Info */}
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.messageList.viewDetails')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.bot')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{error.botName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.pipeline')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{error.pipelineName}
|
||||
</div>
|
||||
</div>
|
||||
{error.sessionId && (
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.sessions.sessionId')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{error.sessionId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error.sessionId && (
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.sessions.sessionId')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{error.sessionId}
|
||||
</div>
|
||||
|
||||
{/* Stack Trace */}
|
||||
{error.stackTrace && (
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.errors.stackTrace')}
|
||||
</h4>
|
||||
<pre className="text-xs text-muted-foreground overflow-auto max-h-60 bg-background p-3 rounded whitespace-pre-wrap break-words">
|
||||
{error.stackTrace}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stack Trace */}
|
||||
{error.stackTrace && (
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.errors.stackTrace')}
|
||||
</h4>
|
||||
<pre className="text-xs text-muted-foreground overflow-auto max-h-60 bg-background p-3 rounded whitespace-pre-wrap break-words">
|
||||
{error.stackTrace}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
(!data || !data.errors || data.errors.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<CheckCircle2 className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600" />
|
||||
<div className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('monitoring.errors.noErrors')}
|
||||
{!loading &&
|
||||
(!data || !data.errors || data.errors.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<CheckCircle2 className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600" />
|
||||
<div className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('monitoring.errors.noErrors')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,6 +217,11 @@ export interface FeedbackStats {
|
||||
}
|
||||
|
||||
export interface MonitoringData {
|
||||
traffic?: {
|
||||
bucket: 'hour' | 'day';
|
||||
points: Array<{ timestamp: Date; messages: number; llmCalls: number }>;
|
||||
truncated: boolean;
|
||||
};
|
||||
overview: OverviewMetrics;
|
||||
messages: MonitoringMessage[];
|
||||
llmCalls: LLMCall[];
|
||||
|
||||
@@ -155,17 +155,18 @@ function findTurnBySessionTime(
|
||||
sessionTurns: Map<string, ConversationTurn[]>,
|
||||
sessionId: string | undefined,
|
||||
timestamp: Date,
|
||||
botId: string,
|
||||
): ConversationTurn | undefined {
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const turns = sessionTurns.get(sessionId);
|
||||
const turns = sessionTurns.get(JSON.stringify([botId, sessionId]));
|
||||
if (!turns?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let nearest = turns[0];
|
||||
let nearest: ConversationTurn | undefined;
|
||||
const targetTime = timestamp.getTime();
|
||||
|
||||
for (const turn of turns) {
|
||||
@@ -203,15 +204,16 @@ export function buildConversationTurns(
|
||||
|
||||
for (const message of visibleMessages) {
|
||||
const role = normalizeRole(message, activityMessageIds);
|
||||
const previousTurn = lastTurnBySession.get(message.sessionId);
|
||||
const sessionKey = JSON.stringify([message.botId, message.sessionId]);
|
||||
const previousTurn = lastTurnBySession.get(sessionKey);
|
||||
const shouldStartTurn = role === 'user' || !previousTurn;
|
||||
const turn = shouldStartTurn ? createTurn(message) : previousTurn;
|
||||
|
||||
if (shouldStartTurn) {
|
||||
const turns = sessionTurns.get(message.sessionId) ?? [];
|
||||
const turns = sessionTurns.get(sessionKey) ?? [];
|
||||
turns.push(turn);
|
||||
sessionTurns.set(message.sessionId, turns);
|
||||
lastTurnBySession.set(message.sessionId, turn);
|
||||
sessionTurns.set(sessionKey, turns);
|
||||
lastTurnBySession.set(sessionKey, turn);
|
||||
}
|
||||
|
||||
addMessageToTurn(turn, message, role);
|
||||
@@ -221,9 +223,14 @@ export function buildConversationTurns(
|
||||
const allTurns = Array.from(sessionTurns.values()).flat();
|
||||
|
||||
for (const call of llmCalls) {
|
||||
const turn =
|
||||
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
|
||||
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
|
||||
const turn = call.messageId
|
||||
? messageIdToTurn.get(call.messageId)
|
||||
: findTurnBySessionTime(
|
||||
sessionTurns,
|
||||
call.sessionId,
|
||||
call.timestamp,
|
||||
call.botId,
|
||||
);
|
||||
|
||||
if (!turn) {
|
||||
continue;
|
||||
@@ -243,9 +250,14 @@ export function buildConversationTurns(
|
||||
}
|
||||
|
||||
for (const call of toolCalls) {
|
||||
const turn =
|
||||
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
|
||||
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
|
||||
const turn = call.messageId
|
||||
? messageIdToTurn.get(call.messageId)
|
||||
: findTurnBySessionTime(
|
||||
sessionTurns,
|
||||
call.sessionId,
|
||||
call.timestamp,
|
||||
call.botId,
|
||||
);
|
||||
|
||||
if (!turn) {
|
||||
continue;
|
||||
@@ -262,9 +274,14 @@ export function buildConversationTurns(
|
||||
}
|
||||
|
||||
for (const error of errors) {
|
||||
const turn =
|
||||
(error.messageId ? messageIdToTurn.get(error.messageId) : undefined) ??
|
||||
findTurnBySessionTime(sessionTurns, error.sessionId, error.timestamp);
|
||||
const turn = error.messageId
|
||||
? messageIdToTurn.get(error.messageId)
|
||||
: findTurnBySessionTime(
|
||||
sessionTurns,
|
||||
error.sessionId,
|
||||
error.timestamp,
|
||||
error.botId,
|
||||
);
|
||||
|
||||
if (!turn) {
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Unavailability takes priority over the deployment's scope restriction. */
|
||||
export function getBoxScopeContext(
|
||||
boxAvailable: boolean,
|
||||
forcedTemplate?: string,
|
||||
) {
|
||||
forcedTemplate = forcedTemplate?.trim();
|
||||
return {
|
||||
box_available: boxAvailable,
|
||||
box_scope_editable: boxAvailable && !forcedTemplate,
|
||||
// Only expose forced-scope reasons when the sandbox is available.
|
||||
box_scope_forced: boxAvailable && !!forcedTemplate,
|
||||
box_scope_forced_global: boxAvailable && forcedTemplate === '{global}',
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
import { getBoxScopeContext } from './BoxScopeContext';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -425,13 +426,12 @@ export default function PipelineFormComponent({
|
||||
// 2. the deployment pins all pipelines to a fixed scope via
|
||||
// ``system.limitation.force_box_session_id_template`` (SaaS).
|
||||
const forcedBoxTemplate =
|
||||
systemInfo.limitation?.force_box_session_id_template || '';
|
||||
systemInfo.limitation?.force_box_session_id_template?.trim() || '';
|
||||
const boxScopeForced = !!forcedBoxTemplate;
|
||||
const isLocalAgentStage = formName === 'ai' && stage.name === 'local-agent';
|
||||
const stageSystemContext = isLocalAgentStage
|
||||
? {
|
||||
box_available: boxAvailable,
|
||||
box_scope_editable: boxAvailable && !boxScopeForced,
|
||||
...getBoxScopeContext(boxAvailable, forcedBoxTemplate),
|
||||
pipeline_id: pipelineId,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -586,6 +586,7 @@ export enum MCPSessionStatus {
|
||||
}
|
||||
|
||||
export interface MCPServerRuntimeInfo {
|
||||
error_code?: string;
|
||||
status: MCPSessionStatus;
|
||||
error_message?: string;
|
||||
/** Stage at which the session failed. Frontends key off this to render
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Public device-login responses only. OAuth credentials stay on the server. */
|
||||
export interface CodexAuthStatus {
|
||||
status: 'connected' | 'disconnected' | 'expired';
|
||||
connected: boolean;
|
||||
expires_at: number | null;
|
||||
}
|
||||
|
||||
export interface CodexDeviceAuthorization {
|
||||
authorization_id: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
interval: number;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export interface CodexDevicePoll {
|
||||
status: 'pending' | 'connected' | 'expired';
|
||||
interval?: number;
|
||||
}
|
||||
@@ -39,6 +39,13 @@ export interface IDynamicFormItemSchema {
|
||||
disable_if?: IShowIfCondition;
|
||||
/** Tooltip shown next to the field label when ``disable_if`` is active. */
|
||||
disabled_tooltip?: I18nObject;
|
||||
/** Optional overrides evaluated in order when ``disable_if`` matches.
|
||||
* The first matching ``when`` wins; otherwise use ``disabled_tooltip``.
|
||||
* Conditions use the same operators and value lookup as ``disable_if``. */
|
||||
disabled_tooltip_overrides?: {
|
||||
when: IShowIfCondition;
|
||||
tooltip: I18nObject;
|
||||
}[];
|
||||
|
||||
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
|
||||
scopes?: string[];
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
|
||||
import type {
|
||||
CodexAuthStatus,
|
||||
CodexDeviceAuthorization,
|
||||
CodexDevicePoll,
|
||||
} from '@/app/infra/entities/codex';
|
||||
import {
|
||||
ApiRespProviderRequesters,
|
||||
ApiRespProviderRequester,
|
||||
@@ -126,8 +131,52 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.put(`/api/v1/provider/providers/${uuid}`, provider);
|
||||
}
|
||||
|
||||
public deleteModelProvider(uuid: string): Promise<object> {
|
||||
return this.delete(`/api/v1/provider/providers/${uuid}`);
|
||||
public deleteModelProvider(uuid: string, cascade = false): Promise<object> {
|
||||
return this.delete(
|
||||
`/api/v1/provider/providers/${uuid}${cascade ? '?cascade=true' : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
public getCodexAuthStatus(
|
||||
uuid: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CodexAuthStatus> {
|
||||
return this.get(
|
||||
`/api/v1/provider/providers/${uuid}/codex/status`,
|
||||
undefined,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
public startCodexDeviceLogin(
|
||||
uuid: string,
|
||||
): Promise<CodexDeviceAuthorization> {
|
||||
return this.post(`/api/v1/provider/providers/${uuid}/codex/device`, {});
|
||||
}
|
||||
|
||||
public pollCodexDeviceLogin(
|
||||
uuid: string,
|
||||
authorizationId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CodexDevicePoll> {
|
||||
return this.post(
|
||||
`/api/v1/provider/providers/${uuid}/codex/device/poll`,
|
||||
{ authorization_id: authorizationId },
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
public cancelCodexDeviceLogin(
|
||||
uuid: string,
|
||||
authorizationId: string,
|
||||
): Promise<object> {
|
||||
return this.delete(
|
||||
`/api/v1/provider/providers/${uuid}/codex/device/${encodeURIComponent(authorizationId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
public disconnectCodex(uuid: string): Promise<object> {
|
||||
return this.delete(`/api/v1/provider/providers/${uuid}/codex/auth`);
|
||||
}
|
||||
|
||||
public scanProviderModels(
|
||||
@@ -514,10 +563,24 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
public getSessionAnalysis<T>(
|
||||
sessionId: string,
|
||||
botId: string,
|
||||
options: { startTime?: string; endTime?: string } = {},
|
||||
): Promise<T> {
|
||||
const queryParams = new URLSearchParams({ botId });
|
||||
if (options.startTime) queryParams.set('startTime', options.startTime);
|
||||
if (options.endTime) queryParams.set('endTime', options.endTime);
|
||||
return this.get(
|
||||
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${queryParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
public getSessionMessages(
|
||||
sessionId: string,
|
||||
limit: number = 200,
|
||||
offset: number = 0,
|
||||
botId?: string,
|
||||
): Promise<{
|
||||
messages: Array<{
|
||||
id: string;
|
||||
@@ -541,6 +604,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('sessionId', sessionId);
|
||||
if (botId) queryParams.append('botId', botId);
|
||||
queryParams.append('limit', limit.toString());
|
||||
queryParams.append('offset', offset.toString());
|
||||
return this.get(`/api/v1/monitoring/messages?${queryParams.toString()}`);
|
||||
@@ -1447,6 +1511,11 @@ export class BackendClient extends BaseHttpClient {
|
||||
endTime?: string;
|
||||
limit?: number;
|
||||
}): Promise<{
|
||||
traffic?: {
|
||||
bucket: 'hour' | 'day';
|
||||
points: Array<{ timestamp: string; messages: number; llm_calls: number }>;
|
||||
truncated: boolean;
|
||||
};
|
||||
overview: {
|
||||
total_messages: number;
|
||||
llm_calls: number;
|
||||
|
||||
@@ -7,12 +7,6 @@ import {
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
} from '@/components/ui/input-otp';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
@@ -28,14 +22,12 @@ import {
|
||||
import { useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Mail, Lock, ArrowLeft } from 'lucide-react';
|
||||
import { Mail, Lock, ArrowLeft, KeyRound } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
|
||||
const REGEXP_ONLY_DIGITS_AND_CHARS = /^[0-9a-zA-Z]+$/;
|
||||
|
||||
const formSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
email: z.string().email(t('common.invalidEmail')),
|
||||
@@ -136,28 +128,17 @@ export default function ResetPassword() {
|
||||
{t('resetPassword.recoveryKeyDescription')}
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={field.value}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS.source}
|
||||
onChange={(value) => {
|
||||
// 将输入的值转换为大写
|
||||
const upperValue = value.toUpperCase();
|
||||
field.onChange(upperValue);
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
{/* Recovery keys are case-sensitive base64url strings; send them verbatim */}
|
||||
<div className="relative">
|
||||
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('resetPassword.enterRecoveryKey')}
|
||||
className="pl-10 font-mono"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
/**
|
||||
* Copy text to clipboard with fallback support
|
||||
* Tries to use modern Clipboard API first, falls back to execCommand if not available
|
||||
*
|
||||
* @param text - The text to copy to clipboard
|
||||
* @returns Promise<boolean> - true if successful, false otherwise
|
||||
*/
|
||||
/** Copy text using the Clipboard API, with a focus-trap-safe legacy fallback. */
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// Try modern Clipboard API first
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
try {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Clipboard] Modern API failed, trying fallback:', err);
|
||||
// Fall through to legacy method
|
||||
}
|
||||
} catch {
|
||||
// Permission/security errors can include sensitive text; do not log them.
|
||||
}
|
||||
|
||||
// Fallback to legacy execCommand method
|
||||
const previousFocus = document.activeElement as HTMLElement | null;
|
||||
const textArea = document.createElement('textarea');
|
||||
try {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
// Radix modal focus scopes reject focus on elements appended to body.
|
||||
const container =
|
||||
previousFocus?.closest('[role="dialog"], [role="alertdialog"]') ??
|
||||
document.body;
|
||||
container.appendChild(textArea);
|
||||
textArea.focus({ preventScroll: true });
|
||||
textArea.select();
|
||||
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
return successful;
|
||||
} catch (err) {
|
||||
console.error('[Clipboard] Fallback method failed:', err);
|
||||
if (
|
||||
document.activeElement !== textArea ||
|
||||
textArea.selectionEnd !== text.length
|
||||
)
|
||||
return false;
|
||||
return document.execCommand('copy');
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
textArea.remove();
|
||||
if (previousFocus?.isConnected)
|
||||
previousFocus.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -182,6 +182,37 @@ const enUS = {
|
||||
help: 'Get Help',
|
||||
},
|
||||
models: {
|
||||
codex: {
|
||||
account: 'ChatGPT subscription',
|
||||
description:
|
||||
'Sign in with your ChatGPT account. Subscription access is separate from OpenAI API billing; model availability and usage limits depend on your plan.',
|
||||
disconnected: 'Not connected',
|
||||
loading: 'Checking connection…',
|
||||
starting: 'Starting sign-in…',
|
||||
pending: 'Waiting for authorization',
|
||||
connected: 'Connected',
|
||||
expired: 'Sign-in expired. Start again to get a new code.',
|
||||
error: 'Unable to sign in. Check your connection and try again.',
|
||||
canceling: 'Canceling sign-in…',
|
||||
saveAndSignIn: 'Save and sign in',
|
||||
done: 'Done',
|
||||
instructions:
|
||||
'Enter this code on the OpenAI page. Keep this dialog open until sign-in completes.',
|
||||
copyCode: 'Copy code',
|
||||
copied: 'Copied',
|
||||
copyManually: 'Select and copy the code manually.',
|
||||
continueAtOpenAI: 'Continue at OpenAI',
|
||||
expiresAt: 'Code expires at {{time}}.',
|
||||
retrying: 'Connection interrupted. Retrying automatically…',
|
||||
cancelSignIn: 'Cancel sign-in',
|
||||
tryAgain: 'Try again',
|
||||
signIn: 'Sign in',
|
||||
reconnect: 'Reconnect',
|
||||
disconnect: 'Disconnect',
|
||||
disconnectConfirm:
|
||||
'Disconnect this provider? Its models will stop working until you sign in again. This does not cancel your ChatGPT subscription.',
|
||||
confirmDisconnect: 'Confirm disconnect',
|
||||
},
|
||||
title: 'Models',
|
||||
description: 'Configure and manage models that can be used in pipelines',
|
||||
createModel: 'Create Model',
|
||||
@@ -316,6 +347,8 @@ const enUS = {
|
||||
providerSaveError: 'Failed to save provider: ',
|
||||
providerDeleted: 'Provider deleted',
|
||||
providerDeleteError: 'Failed to delete provider: ',
|
||||
deleteProviderCascadeConfirmation:
|
||||
'Delete this provider and ALL models it contains? This action is irreversible and cannot be undone.',
|
||||
deleteProviderConfirmation:
|
||||
'Are you sure you want to delete this provider?',
|
||||
loadError: 'Failed to load data',
|
||||
@@ -839,6 +872,15 @@ const enUS = {
|
||||
connectionSuccess: 'Connection successful',
|
||||
connectionFailed: 'Connection failed, please check URL',
|
||||
connectionFailedStatus: 'Connection Failed',
|
||||
connectionUnreachable:
|
||||
'Cannot reach the MCP server. Check that it is running and accessible.',
|
||||
connectionTimeout:
|
||||
'The MCP server did not respond in time. Check the service or increase the timeout.',
|
||||
connectionHttpError:
|
||||
'The MCP server returned HTTP {{status}}. Check its access requirements and server logs.',
|
||||
oauthAuthorizationRequired: 'OAuth authorization required',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'This MCP server requires OAuth sign-in. OAuth sign-in is not available yet; add an Authorization header manually if the server supports it.',
|
||||
boxDisabledStdioRefused:
|
||||
'Stdio MCP servers require the Box sandbox, which is disabled in config (box.enabled = false).',
|
||||
boxUnavailableStdioRefused:
|
||||
@@ -1602,7 +1644,16 @@ const enUS = {
|
||||
queryVariables: {
|
||||
title: 'Query Variables',
|
||||
},
|
||||
loadError: 'Failed to load monitoring data',
|
||||
partialMessages:
|
||||
'Showing {{shown}} of {{total}} messages. Conversation traces may be incomplete.',
|
||||
partialModelCalls: 'Showing {{shown}} of {{total}} model calls.',
|
||||
partialToolCalls:
|
||||
'Showing {{shown}} of {{total}} tool calls. Conversation traces may be incomplete.',
|
||||
partialErrors: 'Showing {{shown}} of {{total}} errors.',
|
||||
trafficChart: {
|
||||
unavailable: 'Traffic aggregation unavailable',
|
||||
truncated: 'Traffic range truncated. Choose a shorter time range.',
|
||||
title: 'Traffic Overview',
|
||||
messages: 'Messages',
|
||||
llmCalls: 'LLM Calls',
|
||||
|
||||
@@ -187,6 +187,38 @@ const esES = {
|
||||
help: 'Obtener ayuda',
|
||||
},
|
||||
models: {
|
||||
codex: {
|
||||
account: 'Suscripción de ChatGPT',
|
||||
description:
|
||||
'Inicia sesión con tu cuenta de ChatGPT. La suscripción es independiente de la facturación de la API de OpenAI; los modelos y límites dependen de tu plan.',
|
||||
disconnected: 'Sin conexión',
|
||||
loading: 'Comprobando conexión…',
|
||||
starting: 'Iniciando sesión…',
|
||||
pending: 'Esperando autorización',
|
||||
connected: 'Conectado',
|
||||
expired: 'El inicio de sesión ha caducado. Solicita un nuevo código.',
|
||||
error:
|
||||
'No se pudo iniciar sesión. Comprueba la conexión e inténtalo de nuevo.',
|
||||
canceling: 'Cancelando inicio de sesión…',
|
||||
saveAndSignIn: 'Guardar e iniciar sesión',
|
||||
done: 'Listo',
|
||||
instructions:
|
||||
'Introduce este código en la página de OpenAI. Mantén este diálogo abierto hasta completar el inicio de sesión.',
|
||||
copyCode: 'Copiar código',
|
||||
copied: 'Copiado',
|
||||
copyManually: 'Selecciona y copia el código manualmente.',
|
||||
continueAtOpenAI: 'Continuar en OpenAI',
|
||||
expiresAt: 'El código caduca a las {{time}}.',
|
||||
retrying: 'Conexión interrumpida. Reintentando automáticamente…',
|
||||
cancelSignIn: 'Cancelar inicio de sesión',
|
||||
tryAgain: 'Reintentar',
|
||||
signIn: 'Iniciar sesión',
|
||||
reconnect: 'Reconectar',
|
||||
disconnect: 'Desconectar',
|
||||
disconnectConfirm:
|
||||
'¿Desconectar este proveedor? Sus modelos dejarán de funcionar hasta que vuelvas a iniciar sesión. Esto no cancela tu suscripción de ChatGPT.',
|
||||
confirmDisconnect: 'Confirmar desconexión',
|
||||
},
|
||||
title: 'Modelos',
|
||||
description:
|
||||
'Configura y gestiona los modelos que se pueden usar en los Pipelines',
|
||||
@@ -324,6 +356,8 @@ const esES = {
|
||||
providerSaveError: 'Error al guardar el proveedor: ',
|
||||
providerDeleted: 'Proveedor eliminado',
|
||||
providerDeleteError: 'Error al eliminar el proveedor: ',
|
||||
deleteProviderCascadeConfirmation:
|
||||
'¿Eliminar este proveedor y TODOS los modelos que contiene? Esta acción es irreversible y no se puede deshacer.',
|
||||
deleteProviderConfirmation:
|
||||
'¿Estás seguro de que deseas eliminar este proveedor?',
|
||||
loadError: 'Error al cargar datos',
|
||||
@@ -858,6 +892,15 @@ const esES = {
|
||||
connectionSuccess: 'Conexión exitosa',
|
||||
connectionFailed: 'Error de conexión, por favor verifica la URL',
|
||||
connectionFailedStatus: 'Conexión fallida',
|
||||
connectionUnreachable:
|
||||
'No se puede acceder al servidor MCP. Compruebe que esté iniciado y accesible.',
|
||||
connectionTimeout:
|
||||
'El servidor MCP no respondió a tiempo. Compruebe el servicio o aumente el tiempo de espera.',
|
||||
connectionHttpError:
|
||||
'El servidor MCP devolvió HTTP {{status}}. Compruebe los requisitos de acceso y los registros del servidor.',
|
||||
oauthAuthorizationRequired: 'Se requiere autorización OAuth',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'Este servidor MCP requiere inicio de sesión con OAuth. Aún no está disponible; agregue manualmente un encabezado Authorization si el servidor lo permite.',
|
||||
boxDisabledStdioRefused:
|
||||
'Los servidores MCP en modo stdio requieren el sandbox de Box, desactivado en la configuración (box.enabled = false).',
|
||||
boxUnavailableStdioRefused:
|
||||
@@ -1559,7 +1602,17 @@ const esES = {
|
||||
queryVariables: {
|
||||
title: 'Variables de consulta',
|
||||
},
|
||||
loadError: 'No se pudieron cargar los datos de monitoreo',
|
||||
partialMessages:
|
||||
'Se muestran {{shown}} de {{total}} mensajes. Las trazas de conversación pueden estar incompletas.',
|
||||
partialModelCalls: 'Se muestran {{shown}} de {{total}} llamadas al modelo.',
|
||||
partialToolCalls:
|
||||
'Se muestran {{shown}} de {{total}} llamadas a herramientas. Las trazas de conversación pueden estar incompletas.',
|
||||
partialErrors: 'Se muestran {{shown}} de {{total}} errores.',
|
||||
trafficChart: {
|
||||
unavailable: 'Agregación de tráfico no disponible',
|
||||
truncated:
|
||||
'Rango de tráfico truncado. Selecciona un intervalo más corto.',
|
||||
title: 'Resumen de tráfico',
|
||||
messages: 'Mensajes',
|
||||
llmCalls: 'Llamadas LLM',
|
||||
|
||||
@@ -185,6 +185,38 @@ const jaJP = {
|
||||
help: 'ヘルプドキュメントを見る',
|
||||
},
|
||||
models: {
|
||||
codex: {
|
||||
account: 'ChatGPT サブスクリプション',
|
||||
description:
|
||||
'ChatGPT アカウントでログインします。サブスクリプションと OpenAI API の課金は別です。利用可能なモデルと使用制限はプランによって異なります。',
|
||||
disconnected: '未接続',
|
||||
loading: '接続を確認中…',
|
||||
starting: 'ログインを開始中…',
|
||||
pending: '認証を待機中',
|
||||
connected: '接続済み',
|
||||
expired:
|
||||
'ログインの有効期限が切れました。新しいコードを取得してください。',
|
||||
error: 'ログインできません。接続を確認して再試行してください。',
|
||||
canceling: 'ログインをキャンセル中…',
|
||||
saveAndSignIn: '保存してログイン',
|
||||
done: '完了',
|
||||
instructions:
|
||||
'OpenAI のページでこのコードを入力してください。ログインが完了するまでこの画面を開いたままにしてください。',
|
||||
copyCode: 'コードをコピー',
|
||||
copied: 'コピー済み',
|
||||
copyManually: 'コードを選択して手動でコピーしてください。',
|
||||
continueAtOpenAI: 'OpenAI で続行',
|
||||
expiresAt: 'コードの有効期限: {{time}}',
|
||||
retrying: '接続が切れました。自動的に再試行しています…',
|
||||
cancelSignIn: 'ログインをキャンセル',
|
||||
tryAgain: '再試行',
|
||||
signIn: 'ログイン',
|
||||
reconnect: '再接続',
|
||||
disconnect: '切断',
|
||||
disconnectConfirm:
|
||||
'このプロバイダーを切断しますか?再ログインするまでモデルは使用できません。ChatGPT のサブスクリプションは解約されません。',
|
||||
confirmDisconnect: '切断を確認',
|
||||
},
|
||||
title: 'モデル設定',
|
||||
description: 'パイプラインで使用できるモデルを設定・管理',
|
||||
createModel: 'モデルを作成',
|
||||
@@ -322,6 +354,8 @@ const jaJP = {
|
||||
providerSaveError: 'プロバイダーの保存に失敗しました:',
|
||||
providerDeleted: 'プロバイダーを削除しました',
|
||||
providerDeleteError: 'プロバイダーの削除に失敗しました:',
|
||||
deleteProviderCascadeConfirmation:
|
||||
'このプロバイダーと、その中のすべてのモデルを削除しますか?この操作は取り消せず、元に戻せません。',
|
||||
deleteProviderConfirmation: 'このプロバイダーを削除してもよろしいですか?',
|
||||
loadError: 'データの読み込みに失敗しました',
|
||||
chat: 'チャット',
|
||||
@@ -846,6 +880,15 @@ const jaJP = {
|
||||
connectionSuccess: '接続に成功しました',
|
||||
connectionFailed: '接続に失敗しました,URLを確認してください',
|
||||
connectionFailedStatus: '接続失敗',
|
||||
connectionUnreachable:
|
||||
'MCP サーバーに接続できません。起動状態とネットワークを確認してください。',
|
||||
connectionTimeout:
|
||||
'MCP サーバーの応答がタイムアウトしました。サービスを確認するか、待機時間を延長してください。',
|
||||
connectionHttpError:
|
||||
'MCP サーバーが HTTP {{status}} を返しました。アクセス要件とサーバーログを確認してください。',
|
||||
oauthAuthorizationRequired: 'OAuth 認可が必要です',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'この MCP サーバーには OAuth ログインが必要です。現在は OAuth ログインに対応していません。サーバーが許可している場合は、Authorization ヘッダーを手動で追加してください。',
|
||||
boxDisabledStdioRefused:
|
||||
'Stdio モードの MCP サーバーは Box サンドボックスを必要としますが、設定で無効化されています(box.enabled = false)。',
|
||||
boxUnavailableStdioRefused:
|
||||
@@ -1610,7 +1653,17 @@ const jaJP = {
|
||||
queryVariables: {
|
||||
title: 'クエリ変数',
|
||||
},
|
||||
loadError: 'モニタリングデータを読み込めませんでした',
|
||||
partialMessages:
|
||||
'全 {{total}} 件中 {{shown}} 件のメッセージを表示。会話トレースは不完全な場合があります。',
|
||||
partialModelCalls: '全 {{total}} 件中 {{shown}} 件のモデル呼び出しを表示。',
|
||||
partialToolCalls:
|
||||
'全 {{total}} 件中 {{shown}} 件のツール呼び出しを表示。会話トレースは不完全な場合があります。',
|
||||
partialErrors: '全 {{total}} 件中 {{shown}} 件のエラーを表示。',
|
||||
trafficChart: {
|
||||
unavailable: 'トラフィック集計を利用できません',
|
||||
truncated:
|
||||
'トラフィック範囲が切り詰められています。短い期間を選択してください。',
|
||||
title: 'トラフィック概要',
|
||||
messages: 'メッセージ',
|
||||
llmCalls: 'LLM呼び出し',
|
||||
|
||||
@@ -184,6 +184,37 @@ const ruRU = {
|
||||
help: 'Помощь',
|
||||
},
|
||||
models: {
|
||||
codex: {
|
||||
account: 'Подписка ChatGPT',
|
||||
description:
|
||||
'Войдите в аккаунт ChatGPT. Подписка не связана с оплатой API OpenAI; доступные модели и лимиты зависят от тарифа.',
|
||||
disconnected: 'Не подключено',
|
||||
loading: 'Проверка подключения…',
|
||||
starting: 'Начало входа…',
|
||||
pending: 'Ожидание авторизации',
|
||||
connected: 'Подключено',
|
||||
expired: 'Срок входа истёк. Получите новый код.',
|
||||
error: 'Не удалось войти. Проверьте подключение и повторите попытку.',
|
||||
canceling: 'Отмена входа…',
|
||||
saveAndSignIn: 'Сохранить и войти',
|
||||
done: 'Готово',
|
||||
instructions:
|
||||
'Введите этот код на странице OpenAI. Не закрывайте это окно до завершения входа.',
|
||||
copyCode: 'Копировать код',
|
||||
copied: 'Скопировано',
|
||||
copyManually: 'Выделите и скопируйте код вручную.',
|
||||
continueAtOpenAI: 'Продолжить в OpenAI',
|
||||
expiresAt: 'Код действителен до {{time}}.',
|
||||
retrying: 'Соединение прервано. Автоматическая повторная попытка…',
|
||||
cancelSignIn: 'Отменить вход',
|
||||
tryAgain: 'Повторить',
|
||||
signIn: 'Войти',
|
||||
reconnect: 'Переподключить',
|
||||
disconnect: 'Отключить',
|
||||
disconnectConfirm:
|
||||
'Отключить этого провайдера? Его модели перестанут работать до повторного входа. Подписка ChatGPT не будет отменена.',
|
||||
confirmDisconnect: 'Подтвердить отключение',
|
||||
},
|
||||
title: 'Модели',
|
||||
description: 'Настройка и управление моделями, используемыми в конвейерах',
|
||||
createModel: 'Создать модель',
|
||||
@@ -322,6 +353,8 @@ const ruRU = {
|
||||
providerSaveError: 'Ошибка сохранения провайдера: ',
|
||||
providerDeleted: 'Провайдер удалён',
|
||||
providerDeleteError: 'Ошибка удаления провайдера: ',
|
||||
deleteProviderCascadeConfirmation:
|
||||
'Удалить этого провайдера и ВСЕ содержащиеся в нём модели? Это действие необратимо, его нельзя отменить.',
|
||||
deleteProviderConfirmation:
|
||||
'Вы уверены, что хотите удалить этого провайдера?',
|
||||
loadError: 'Не удалось загрузить данные',
|
||||
@@ -852,6 +885,15 @@ const ruRU = {
|
||||
connectionSuccess: 'Подключение успешно',
|
||||
connectionFailed: 'Не удалось подключиться, проверьте URL',
|
||||
connectionFailedStatus: 'Ошибка подключения',
|
||||
connectionUnreachable:
|
||||
'Сервер MCP недоступен. Проверьте, запущен ли он и доступен ли по сети.',
|
||||
connectionTimeout:
|
||||
'Время ожидания ответа MCP истекло. Проверьте сервис или увеличьте тайм-аут.',
|
||||
connectionHttpError:
|
||||
'Сервер MCP вернул HTTP {{status}}. Проверьте требования доступа и журналы сервера.',
|
||||
oauthAuthorizationRequired: 'Требуется авторизация OAuth',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'Для этого MCP-сервера требуется вход через OAuth. OAuth-вход пока не поддерживается; если сервер это позволяет, добавьте заголовок Authorization вручную.',
|
||||
boxDisabledStdioRefused:
|
||||
'MCP-серверы в режиме stdio требуют песочницу Box, которая отключена в конфигурации (box.enabled = false).',
|
||||
boxUnavailableStdioRefused:
|
||||
@@ -1532,7 +1574,16 @@ const ruRU = {
|
||||
queryVariables: {
|
||||
title: 'Переменные запроса',
|
||||
},
|
||||
loadError: 'Не удалось загрузить данные мониторинга',
|
||||
partialMessages:
|
||||
'Показано {{shown}} из {{total}} сообщений. Трассировки диалогов могут быть неполными.',
|
||||
partialModelCalls: 'Показано {{shown}} из {{total}} вызовов модели.',
|
||||
partialToolCalls:
|
||||
'Показано {{shown}} из {{total}} вызовов инструментов. Трассировки диалогов могут быть неполными.',
|
||||
partialErrors: 'Показано {{shown}} из {{total}} ошибок.',
|
||||
trafficChart: {
|
||||
unavailable: 'Агрегированные данные трафика недоступны',
|
||||
truncated: 'Диапазон трафика обрезан. Выберите более короткий период.',
|
||||
title: 'Обзор трафика',
|
||||
messages: 'Сообщения',
|
||||
llmCalls: 'Вызовы LLM',
|
||||
|
||||
@@ -181,6 +181,37 @@ const thTH = {
|
||||
help: 'ขอความช่วยเหลือ',
|
||||
},
|
||||
models: {
|
||||
codex: {
|
||||
account: 'การสมัครสมาชิก ChatGPT',
|
||||
description:
|
||||
'ลงชื่อเข้าใช้ด้วยบัญชี ChatGPT การใช้งานผ่านการสมัครสมาชิกแยกจากการเรียกเก็บเงิน OpenAI API รุ่นโมเดลและขีดจำกัดการใช้งานขึ้นอยู่กับแพ็กเกจของคุณ',
|
||||
disconnected: 'ยังไม่ได้เชื่อมต่อ',
|
||||
loading: 'กำลังตรวจสอบการเชื่อมต่อ…',
|
||||
starting: 'กำลังเริ่มลงชื่อเข้าใช้…',
|
||||
pending: 'กำลังรอการอนุญาต',
|
||||
connected: 'เชื่อมต่อแล้ว',
|
||||
expired: 'การลงชื่อเข้าใช้หมดอายุ เริ่มใหม่เพื่อรับรหัสใหม่',
|
||||
error: 'ไม่สามารถลงชื่อเข้าใช้ได้ ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง',
|
||||
canceling: 'กำลังยกเลิกการลงชื่อเข้าใช้…',
|
||||
saveAndSignIn: 'บันทึกและลงชื่อเข้าใช้',
|
||||
done: 'เสร็จสิ้น',
|
||||
instructions:
|
||||
'ป้อนรหัสนี้บนหน้า OpenAI เปิดกล่องโต้ตอบนี้ไว้จนกว่าจะลงชื่อเข้าใช้เสร็จ',
|
||||
copyCode: 'คัดลอกรหัส',
|
||||
copied: 'คัดลอกแล้ว',
|
||||
copyManually: 'เลือกรหัสและคัดลอกด้วยตนเอง',
|
||||
continueAtOpenAI: 'ดำเนินการต่อที่ OpenAI',
|
||||
expiresAt: 'รหัสหมดอายุเวลา {{time}}',
|
||||
retrying: 'การเชื่อมต่อขัดข้อง กำลังลองใหม่โดยอัตโนมัติ…',
|
||||
cancelSignIn: 'ยกเลิกการลงชื่อเข้าใช้',
|
||||
tryAgain: 'ลองอีกครั้ง',
|
||||
signIn: 'ลงชื่อเข้าใช้',
|
||||
reconnect: 'เชื่อมต่อใหม่',
|
||||
disconnect: 'ยกเลิกการเชื่อมต่อ',
|
||||
disconnectConfirm:
|
||||
'ยกเลิกการเชื่อมต่อผู้ให้บริการนี้หรือไม่? โมเดลจะหยุดทำงานจนกว่าคุณจะลงชื่อเข้าใช้อีกครั้ง การดำเนินการนี้ไม่ได้ยกเลิกการสมัครสมาชิก ChatGPT',
|
||||
confirmDisconnect: 'ยืนยันการยกเลิกการเชื่อมต่อ',
|
||||
},
|
||||
title: 'โมเดล',
|
||||
description: 'กำหนดค่าและจัดการโมเดลที่สามารถใช้ใน Pipeline',
|
||||
createModel: 'สร้างโมเดล',
|
||||
@@ -310,6 +341,8 @@ const thTH = {
|
||||
providerSaveError: 'บันทึกผู้ให้บริการล้มเหลว: ',
|
||||
providerDeleted: 'ลบผู้ให้บริการแล้ว',
|
||||
providerDeleteError: 'ลบผู้ให้บริการล้มเหลว: ',
|
||||
deleteProviderCascadeConfirmation:
|
||||
'ลบผู้ให้บริการนี้และโมเดลทั้งหมดที่อยู่ภายในหรือไม่? การดำเนินการนี้ไม่สามารถย้อนกลับหรือยกเลิกได้',
|
||||
deleteProviderConfirmation: 'คุณแน่ใจหรือไม่ว่าต้องการลบผู้ให้บริการนี้?',
|
||||
loadError: 'โหลดข้อมูลล้มเหลว',
|
||||
chat: 'แชท',
|
||||
@@ -830,6 +863,15 @@ const thTH = {
|
||||
connectionSuccess: 'เชื่อมต่อสำเร็จ',
|
||||
connectionFailed: 'เชื่อมต่อล้มเหลว กรุณาตรวจสอบ URL',
|
||||
connectionFailedStatus: 'เชื่อมต่อล้มเหลว',
|
||||
connectionUnreachable:
|
||||
'ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์ MCP ได้ โปรดตรวจสอบว่าบริการทำงานและเข้าถึงได้',
|
||||
connectionTimeout:
|
||||
'เซิร์ฟเวอร์ MCP ไม่ตอบกลับภายในเวลาที่กำหนด โปรดตรวจสอบบริการหรือเพิ่มเวลารอ',
|
||||
connectionHttpError:
|
||||
'เซิร์ฟเวอร์ MCP ส่งคืน HTTP {{status}} โปรดตรวจสอบข้อกำหนดการเข้าถึงและบันทึกของเซิร์ฟเวอร์',
|
||||
oauthAuthorizationRequired: 'ต้องมีการอนุญาต OAuth',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'MCP server นี้ต้องเข้าสู่ระบบด้วย OAuth ซึ่งยังไม่รองรับในขณะนี้ หาก server อนุญาต คุณสามารถเพิ่ม Authorization header ด้วยตนเองได้',
|
||||
boxDisabledStdioRefused:
|
||||
'MCP server แบบ stdio ต้องใช้ Sandbox Box ซึ่งถูกปิดใช้งานในการตั้งค่า (box.enabled = false)',
|
||||
boxUnavailableStdioRefused:
|
||||
@@ -1501,7 +1543,17 @@ const thTH = {
|
||||
queryVariables: {
|
||||
title: 'ตัวแปรคำค้นหา',
|
||||
},
|
||||
loadError: 'โหลดข้อมูลการตรวจสอบไม่สำเร็จ',
|
||||
partialMessages:
|
||||
'แสดง {{shown}} จาก {{total}} ข้อความ ประวัติการสนทนาอาจไม่ครบถ้วน',
|
||||
partialModelCalls: 'แสดง {{shown}} จาก {{total}} การเรียกโมเดล',
|
||||
partialToolCalls:
|
||||
'แสดง {{shown}} จาก {{total}} การเรียกเครื่องมือ ประวัติการสนทนาอาจไม่ครบถ้วน',
|
||||
partialErrors: 'แสดง {{shown}} จาก {{total}} ข้อผิดพลาด',
|
||||
trafficChart: {
|
||||
unavailable: 'ไม่มีข้อมูลสรุปปริมาณการใช้งาน',
|
||||
truncated:
|
||||
'ช่วงข้อมูลปริมาณการใช้งานถูกตัดทอน โปรดเลือกช่วงเวลาที่สั้นลง',
|
||||
title: 'ภาพรวมปริมาณการใช้งาน',
|
||||
messages: 'ข้อความ',
|
||||
llmCalls: 'การเรียก LLM',
|
||||
|
||||
@@ -184,6 +184,37 @@ const viVN = {
|
||||
help: 'Trợ giúp',
|
||||
},
|
||||
models: {
|
||||
codex: {
|
||||
account: 'Gói đăng ký ChatGPT',
|
||||
description:
|
||||
'Đăng nhập bằng tài khoản ChatGPT. Gói đăng ký độc lập với thanh toán API OpenAI; mô hình và giới hạn sử dụng tùy thuộc vào gói của bạn.',
|
||||
disconnected: 'Chưa kết nối',
|
||||
loading: 'Đang kiểm tra kết nối…',
|
||||
starting: 'Đang bắt đầu đăng nhập…',
|
||||
pending: 'Đang chờ cấp quyền',
|
||||
connected: 'Đã kết nối',
|
||||
expired: 'Phiên đăng nhập đã hết hạn. Hãy lấy mã mới.',
|
||||
error: 'Không thể đăng nhập. Kiểm tra kết nối và thử lại.',
|
||||
canceling: 'Đang hủy đăng nhập…',
|
||||
saveAndSignIn: 'Lưu và đăng nhập',
|
||||
done: 'Xong',
|
||||
instructions:
|
||||
'Nhập mã này trên trang OpenAI. Giữ hộp thoại này mở cho đến khi đăng nhập hoàn tất.',
|
||||
copyCode: 'Sao chép mã',
|
||||
copied: 'Đã sao chép',
|
||||
copyManually: 'Chọn và sao chép mã thủ công.',
|
||||
continueAtOpenAI: 'Tiếp tục tại OpenAI',
|
||||
expiresAt: 'Mã hết hạn lúc {{time}}.',
|
||||
retrying: 'Kết nối bị gián đoạn. Đang tự động thử lại…',
|
||||
cancelSignIn: 'Hủy đăng nhập',
|
||||
tryAgain: 'Thử lại',
|
||||
signIn: 'Đăng nhập',
|
||||
reconnect: 'Kết nối lại',
|
||||
disconnect: 'Ngắt kết nối',
|
||||
disconnectConfirm:
|
||||
'Ngắt kết nối nhà cung cấp này? Các mô hình sẽ ngừng hoạt động cho đến khi bạn đăng nhập lại. Thao tác này không hủy gói ChatGPT của bạn.',
|
||||
confirmDisconnect: 'Xác nhận ngắt kết nối',
|
||||
},
|
||||
title: 'Mô hình',
|
||||
description:
|
||||
'Cấu hình và quản lý các mô hình có thể sử dụng trong Pipeline',
|
||||
@@ -318,6 +349,8 @@ const viVN = {
|
||||
providerSaveError: 'Lưu nhà cung cấp thất bại: ',
|
||||
providerDeleted: 'Đã xóa nhà cung cấp',
|
||||
providerDeleteError: 'Xóa nhà cung cấp thất bại: ',
|
||||
deleteProviderCascadeConfirmation:
|
||||
'Xóa nhà cung cấp này và TẤT CẢ mô hình bên trong? Hành động này không thể đảo ngược hoặc hoàn tác.',
|
||||
deleteProviderConfirmation:
|
||||
'Bạn có chắc chắn muốn xóa nhà cung cấp này không?',
|
||||
loadError: 'Tải dữ liệu thất bại',
|
||||
@@ -845,6 +878,15 @@ const viVN = {
|
||||
connectionSuccess: 'Kết nối thành công',
|
||||
connectionFailed: 'Kết nối thất bại, vui lòng kiểm tra URL',
|
||||
connectionFailedStatus: 'Kết nối thất bại',
|
||||
connectionUnreachable:
|
||||
'Không thể kết nối tới máy chủ MCP. Hãy kiểm tra dịch vụ và kết nối mạng.',
|
||||
connectionTimeout:
|
||||
'Máy chủ MCP không phản hồi kịp thời. Hãy kiểm tra dịch vụ hoặc tăng thời gian chờ.',
|
||||
connectionHttpError:
|
||||
'Máy chủ MCP trả về HTTP {{status}}. Hãy kiểm tra yêu cầu truy cập và nhật ký máy chủ.',
|
||||
oauthAuthorizationRequired: 'Yêu cầu ủy quyền OAuth',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'MCP server này yêu cầu đăng nhập OAuth. Hiện chưa hỗ trợ đăng nhập OAuth; hãy thêm thủ công tiêu đề Authorization nếu server cho phép.',
|
||||
boxDisabledStdioRefused:
|
||||
'MCP server ở chế độ stdio cần Sandbox Box, hiện đã bị tắt trong cấu hình (box.enabled = false).',
|
||||
boxUnavailableStdioRefused:
|
||||
@@ -1525,7 +1567,17 @@ const viVN = {
|
||||
queryVariables: {
|
||||
title: 'Biến truy vấn',
|
||||
},
|
||||
loadError: 'Không thể tải dữ liệu giám sát',
|
||||
partialMessages:
|
||||
'Hiển thị {{shown}} trên {{total}} tin nhắn. Dấu vết hội thoại có thể không đầy đủ.',
|
||||
partialModelCalls: 'Hiển thị {{shown}} trên {{total}} lượt gọi mô hình.',
|
||||
partialToolCalls:
|
||||
'Hiển thị {{shown}} trên {{total}} lượt gọi công cụ. Dấu vết hội thoại có thể không đầy đủ.',
|
||||
partialErrors: 'Hiển thị {{shown}} trên {{total}} lỗi.',
|
||||
trafficChart: {
|
||||
unavailable: 'Không có dữ liệu tổng hợp lưu lượng',
|
||||
truncated:
|
||||
'Phạm vi lưu lượng bị cắt ngắn. Hãy chọn khoảng thời gian ngắn hơn.',
|
||||
title: 'Tổng quan lưu lượng',
|
||||
messages: 'Tin nhắn',
|
||||
llmCalls: 'Cuộc gọi LLM',
|
||||
|
||||
@@ -172,6 +172,37 @@ const zhHans = {
|
||||
help: '查看帮助文档',
|
||||
},
|
||||
models: {
|
||||
codex: {
|
||||
account: 'ChatGPT 订阅',
|
||||
description:
|
||||
'使用 ChatGPT 账号登录。订阅权限与 OpenAI API 计费相互独立,可用模型和使用额度取决于你的订阅方案。',
|
||||
disconnected: '未连接',
|
||||
loading: '正在检查连接…',
|
||||
starting: '正在开始登录…',
|
||||
pending: '等待授权',
|
||||
connected: '已连接',
|
||||
expired: '登录已过期,请重试以获取新验证码。',
|
||||
error: '无法登录,请检查网络连接后重试。',
|
||||
canceling: '正在取消登录…',
|
||||
saveAndSignIn: '保存并登录',
|
||||
done: '完成',
|
||||
instructions:
|
||||
'在 OpenAI 页面输入此验证码,登录完成前请保持此对话框打开。',
|
||||
copyCode: '复制验证码',
|
||||
copied: '已复制',
|
||||
copyManually: '请选中并手动复制验证码。',
|
||||
continueAtOpenAI: '前往 OpenAI 继续',
|
||||
expiresAt: '验证码将于 {{time}} 过期。',
|
||||
retrying: '连接中断,正在自动重试…',
|
||||
cancelSignIn: '取消登录',
|
||||
tryAgain: '重试',
|
||||
signIn: '登录',
|
||||
reconnect: '重新连接',
|
||||
disconnect: '断开连接',
|
||||
disconnectConfirm:
|
||||
'断开此供应商的连接?重新登录前,其模型将无法使用。此操作不会取消你的 ChatGPT 订阅。',
|
||||
confirmDisconnect: '确认断开',
|
||||
},
|
||||
title: '模型配置',
|
||||
description: '配置和管理可在流水线中使用的模型',
|
||||
createModel: '创建对话模型',
|
||||
@@ -302,6 +333,8 @@ const zhHans = {
|
||||
providerSaveError: '保存供应商失败:',
|
||||
providerDeleted: '供应商已删除',
|
||||
providerDeleteError: '删除供应商失败:',
|
||||
deleteProviderCascadeConfirmation:
|
||||
'确定删除此供应商及其包含的所有模型吗?此操作不可逆,无法撤销。',
|
||||
deleteProviderConfirmation: '你确定要删除这个供应商吗?',
|
||||
loadError: '加载数据失败',
|
||||
chat: '对话',
|
||||
@@ -804,6 +837,14 @@ const zhHans = {
|
||||
connectionSuccess: '连接成功',
|
||||
connectionFailed: '连接失败,请检查URL',
|
||||
connectionFailedStatus: '连接失败',
|
||||
connectionUnreachable:
|
||||
'无法连接到 MCP 服务器,请确认服务已启动且网络可达。',
|
||||
connectionTimeout: 'MCP 服务器响应超时,请检查服务状态或增加超时时间。',
|
||||
connectionHttpError:
|
||||
'MCP 服务器返回 HTTP {{status}},请检查访问要求和服务器日志。',
|
||||
oauthAuthorizationRequired: '需要 OAuth 授权',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'此 MCP 服务器需要 OAuth 登录。当前尚不支持 OAuth 登录;如果服务器允许,可以手动添加 Authorization 请求头。',
|
||||
boxDisabledStdioRefused:
|
||||
'Stdio 模式的 MCP 服务器依赖 Box 沙箱,目前已在配置中禁用(box.enabled = false)。',
|
||||
boxUnavailableStdioRefused:
|
||||
@@ -1531,7 +1572,16 @@ const zhHans = {
|
||||
queryVariables: {
|
||||
title: '查询变量',
|
||||
},
|
||||
loadError: '监控数据加载失败',
|
||||
partialMessages:
|
||||
'显示 {{total}} 条消息中的 {{shown}} 条,对话轨迹可能不完整。',
|
||||
partialModelCalls: '显示 {{total}} 次模型调用中的 {{shown}} 次。',
|
||||
partialToolCalls:
|
||||
'显示 {{total}} 次工具调用中的 {{shown}} 次,对话轨迹可能不完整。',
|
||||
partialErrors: '显示 {{total}} 条错误中的 {{shown}} 条。',
|
||||
trafficChart: {
|
||||
unavailable: '流量聚合数据不可用',
|
||||
truncated: '流量时间范围已截断,请选择更短的时间范围。',
|
||||
title: '流量概览',
|
||||
messages: '消息数',
|
||||
llmCalls: 'LLM调用',
|
||||
|
||||
@@ -173,6 +173,37 @@ const zhHant = {
|
||||
help: '查看說明文件',
|
||||
},
|
||||
models: {
|
||||
codex: {
|
||||
account: 'ChatGPT 訂閱',
|
||||
description:
|
||||
'使用 ChatGPT 帳號登入。訂閱權限與 OpenAI API 計費相互獨立,可用模型和使用額度取決於你的訂閱方案。',
|
||||
disconnected: '未連線',
|
||||
loading: '正在檢查連線…',
|
||||
starting: '正在開始登入…',
|
||||
pending: '等待授權',
|
||||
connected: '已連線',
|
||||
expired: '登入已過期,請重試以取得新驗證碼。',
|
||||
error: '無法登入,請檢查網路連線後重試。',
|
||||
canceling: '正在取消登入…',
|
||||
saveAndSignIn: '儲存並登入',
|
||||
done: '完成',
|
||||
instructions:
|
||||
'在 OpenAI 頁面輸入此驗證碼,登入完成前請保持此對話框開啟。',
|
||||
copyCode: '複製驗證碼',
|
||||
copied: '已複製',
|
||||
copyManually: '請選取並手動複製驗證碼。',
|
||||
continueAtOpenAI: '前往 OpenAI 繼續',
|
||||
expiresAt: '驗證碼將於 {{time}} 過期。',
|
||||
retrying: '連線中斷,正在自動重試…',
|
||||
cancelSignIn: '取消登入',
|
||||
tryAgain: '重試',
|
||||
signIn: '登入',
|
||||
reconnect: '重新連線',
|
||||
disconnect: '中斷連線',
|
||||
disconnectConfirm:
|
||||
'中斷此供應商的連線?重新登入前,其模型將無法使用。此操作不會取消你的 ChatGPT 訂閱。',
|
||||
confirmDisconnect: '確認中斷',
|
||||
},
|
||||
title: '模型設定',
|
||||
description: '設定和管理可在流程線中使用的模型',
|
||||
createModel: '建立模型',
|
||||
@@ -299,6 +330,8 @@ const zhHant = {
|
||||
providerSaveError: '儲存供應商失敗:',
|
||||
providerDeleted: '供應商已刪除',
|
||||
providerDeleteError: '刪除供應商失敗:',
|
||||
deleteProviderCascadeConfirmation:
|
||||
'確定刪除此供應商及其包含的所有模型嗎?此操作不可逆,無法復原。',
|
||||
deleteProviderConfirmation: '您確定要刪除這個供應商嗎?',
|
||||
loadError: '載入資料失敗',
|
||||
chat: '對話',
|
||||
@@ -806,6 +839,14 @@ const zhHant = {
|
||||
connectionSuccess: '連接成功',
|
||||
connectionFailed: '連接失敗,請檢查URL',
|
||||
connectionFailedStatus: '連接失敗',
|
||||
connectionUnreachable:
|
||||
'無法連接到 MCP 伺服器,請確認服務已啟動且網路可達。',
|
||||
connectionTimeout: 'MCP 伺服器回應逾時,請檢查服務狀態或增加逾時時間。',
|
||||
connectionHttpError:
|
||||
'MCP 伺服器回傳 HTTP {{status}},請檢查存取要求和伺服器日誌。',
|
||||
oauthAuthorizationRequired: '需要 OAuth 授權',
|
||||
oauthAuthorizationRequiredSuggestion:
|
||||
'此 MCP 伺服器需要 OAuth 登入。目前尚不支援 OAuth 登入;如果伺服器允許,可以手動新增 Authorization 請求標頭。',
|
||||
boxDisabledStdioRefused:
|
||||
'Stdio 模式的 MCP 伺服器依賴 Box 沙箱,目前已在設定中停用(box.enabled = false)。',
|
||||
boxUnavailableStdioRefused:
|
||||
@@ -1454,7 +1495,16 @@ const zhHant = {
|
||||
queryVariables: {
|
||||
title: '查詢變數',
|
||||
},
|
||||
loadError: '監控資料載入失敗',
|
||||
partialMessages:
|
||||
'顯示 {{total}} 則訊息中的 {{shown}} 則,對話軌跡可能不完整。',
|
||||
partialModelCalls: '顯示 {{total}} 次模型呼叫中的 {{shown}} 次。',
|
||||
partialToolCalls:
|
||||
'顯示 {{total}} 次工具呼叫中的 {{shown}} 次,對話軌跡可能不完整。',
|
||||
partialErrors: '顯示 {{total}} 筆錯誤中的 {{shown}} 筆。',
|
||||
trafficChart: {
|
||||
unavailable: '流量彙總資料無法使用',
|
||||
truncated: '流量時間範圍已截斷,請選擇較短的時間範圍。',
|
||||
title: '流量概覽',
|
||||
messages: '訊息',
|
||||
llmCalls: 'LLM呼叫',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user