mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-07 18:17:14 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 267232c24f | |||
| d6443b10bc | |||
| 0577689da4 | |||
| 0f216a0d4d |
@@ -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 工作空间内的资源,连接个人账号前请确认模型的使用范围。
|
||||||
@@ -88,6 +88,38 @@ already have a default pipeline.
|
|||||||
4. Use `list_*` tools to discover, then `get_*` / `create_*` / `update_*` /
|
4. Use `list_*` tools to discover, then `get_*` / `create_*` / `update_*` /
|
||||||
`delete_*` as needed.
|
`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)
|
## Implementation & maintenance (for LangBot developers)
|
||||||
|
|
||||||
- Server: `src/langbot/pkg/api/mcp/server.py` (FastMCP). Tools call the service
|
- 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 ....workspace.errors import WorkspaceNotFoundError
|
||||||
from ....cloud.entitlements import EntitlementUnavailableError
|
from ....cloud.entitlements import EntitlementUnavailableError
|
||||||
from ....core.errors import TaskCapacityError
|
from ....core.errors import TaskCapacityError
|
||||||
|
from ....provider.modelmgr.codex_errors import CodexProviderError
|
||||||
from ..authz import (
|
from ..authz import (
|
||||||
AuthenticationDeniedError,
|
AuthenticationDeniedError,
|
||||||
AuthorizationError,
|
AuthorizationError,
|
||||||
@@ -247,6 +248,8 @@ class RouterGroup(abc.ABC):
|
|||||||
return await f(*args, **kwargs)
|
return await f(*args, **kwargs)
|
||||||
|
|
||||||
except Exception as e: # 自动 500
|
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):
|
if isinstance(e, AuthorizationError):
|
||||||
return self.http_status(e.status_code, e.error_code, str(e))
|
return self.http_status(e.status_code, e.error_code, str(e))
|
||||||
if isinstance(e, WorkspaceNotFoundError):
|
if isinstance(e, WorkspaceNotFoundError):
|
||||||
|
|||||||
@@ -8,6 +8,80 @@ from ... import group
|
|||||||
@group.group_class('models/providers', '/api/v1/provider/providers')
|
@group.group_class('models/providers', '/api/v1/provider/providers')
|
||||||
class ModelProvidersRouterGroup(group.RouterGroup):
|
class ModelProvidersRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
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(
|
@self.route(
|
||||||
'',
|
'',
|
||||||
methods=['GET'],
|
methods=['GET'],
|
||||||
@@ -82,6 +156,14 @@ class ModelProvidersRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
async def _(provider_uuid: str, request_context: RequestContext) -> str:
|
async def _(provider_uuid: str, request_context: RequestContext) -> str:
|
||||||
try:
|
try:
|
||||||
|
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)
|
await self.ap.provider_service.delete_provider(request_context, provider_uuid)
|
||||||
return self.success()
|
return self.success()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import quart
|
|||||||
import argon2
|
import argon2
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
@@ -11,6 +13,33 @@ from ...context import RequestContext
|
|||||||
from .....cloud.launch import SpaceLaunchError
|
from .....cloud.launch import SpaceLaunchError
|
||||||
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
|
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')
|
@group.group_class('user', '/api/v1/user')
|
||||||
class UserRouterGroup(group.RouterGroup):
|
class UserRouterGroup(group.RouterGroup):
|
||||||
@@ -81,6 +110,12 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
|
@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||||
async def _() -> str:
|
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
|
json_data = await quart.request.json
|
||||||
|
|
||||||
user_email = json_data['user']
|
user_email = json_data['user']
|
||||||
@@ -98,7 +133,18 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
if user_obj is None:
|
if user_obj is None:
|
||||||
return self.http_status(400, -1, 'User not found')
|
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')
|
return self.http_status(403, -1, 'Invalid recovery key')
|
||||||
|
|
||||||
await self.ap.user_service.reset_password(user_email, new_password)
|
await self.ap.user_service.reset_password(user_email, new_password)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
@@ -7,8 +8,10 @@ import sqlalchemy
|
|||||||
|
|
||||||
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||||
from ....core import app
|
from ....core import app
|
||||||
|
from ....core.task_boundary import create_detached_task
|
||||||
from ....entity.persistence import model as persistence_model
|
from ....entity.persistence import model as persistence_model
|
||||||
from ....workspace.errors import WorkspaceNotFoundError
|
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 .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
|
||||||
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||||
|
|
||||||
@@ -20,6 +23,8 @@ class ModelProviderService:
|
|||||||
|
|
||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
self.codex_auth = CodexAuth(ap)
|
||||||
|
self._deletion_tasks: set[asyncio.Task[None]] = set()
|
||||||
|
|
||||||
def _is_cloud_runtime(self) -> bool:
|
def _is_cloud_runtime(self) -> bool:
|
||||||
mode = getattr(self.ap.persistence_mgr, 'mode', None)
|
mode = getattr(self.ap.persistence_mgr, 'mode', None)
|
||||||
@@ -116,11 +121,27 @@ class ModelProviderService:
|
|||||||
provider_data = provider_data.copy()
|
provider_data = provider_data.copy()
|
||||||
if self._system_requester_is_reserved(provider_data.get('requester')):
|
if self._system_requester_is_reserved(provider_data.get('requester')):
|
||||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
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['uuid'] = str(uuid.uuid4())
|
||||||
provider_data['workspace_uuid'] = require_workspace_uuid(context)
|
provider_data['workspace_uuid'] = require_workspace_uuid(context)
|
||||||
provider_data['api_keys'] = self._normalize_api_keys(
|
provider_data['api_keys'] = self._normalize_api_keys(
|
||||||
restore_secret_placeholders(provider_data.get('api_keys'), sensitive=True)
|
restore_secret_placeholders(provider_data.get('api_keys'), sensitive=True)
|
||||||
)
|
)
|
||||||
|
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(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
|
||||||
)
|
)
|
||||||
@@ -138,6 +159,17 @@ class ModelProviderService:
|
|||||||
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider')
|
||||||
provider_data.pop('uuid', None)
|
provider_data.pop('uuid', None)
|
||||||
provider_data.pop('workspace_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:
|
if 'api_keys' in provider_data:
|
||||||
submitted_keys = provider_data.get('api_keys')
|
submitted_keys = provider_data.get('api_keys')
|
||||||
if contains_secret_placeholder(submitted_keys, sensitive=True):
|
if contains_secret_placeholder(submitted_keys, sensitive=True):
|
||||||
@@ -163,48 +195,70 @@ class ModelProviderService:
|
|||||||
raise WorkspaceNotFoundError('Provider not found')
|
raise WorkspaceNotFoundError('Provider not found')
|
||||||
await self.ap.model_mgr.reload_provider(context, provider_uuid)
|
await self.ap.model_mgr.reload_provider(context, provider_uuid)
|
||||||
|
|
||||||
async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
|
async def delete_provider(self, context: TenantContext, provider_uuid: str, cascade: bool = False) -> None:
|
||||||
"""Delete a provider (only if no models reference it)"""
|
"""Delete a provider, optionally deleting all its Workspace-scoped models."""
|
||||||
await self._assert_provider_mutable(context, provider_uuid)
|
|
||||||
workspace_uuid = require_workspace_uuid(context)
|
workspace_uuid = require_workspace_uuid(context)
|
||||||
# Check if any models use this provider
|
persistence = self.ap.persistence_mgr
|
||||||
llm_result = await self.ap.persistence_mgr.execute_async(
|
model_types = (
|
||||||
|
(persistence_model.LLMModel, 'LLM', 'remove_llm_model'),
|
||||||
|
(persistence_model.EmbeddingModel, 'Embedding', 'remove_embedding_model'),
|
||||||
|
(persistence_model.RerankModel, 'Rerank', 'remove_rerank_model'),
|
||||||
|
)
|
||||||
|
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(
|
scope_statement(
|
||||||
sqlalchemy.select(persistence_model.LLMModel).where(
|
sqlalchemy.select(persistence_model.ModelProvider.requester)
|
||||||
persistence_model.LLMModel.provider_uuid == provider_uuid
|
.where(persistence_model.ModelProvider.uuid == provider_uuid)
|
||||||
),
|
.with_for_update(),
|
||||||
persistence_model.LLMModel,
|
persistence_model.ModelProvider,
|
||||||
workspace_uuid,
|
workspace_uuid,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if llm_result.first() is not None:
|
provider = provider_result.first()
|
||||||
raise ValueError('Cannot delete provider: LLM models still reference it')
|
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')
|
||||||
|
|
||||||
embedding_result = await self.ap.persistence_mgr.execute_async(
|
for model_type, label, remover in model_types:
|
||||||
|
result = await persistence.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.select(persistence_model.EmbeddingModel).where(
|
sqlalchemy.select(model_type.uuid).where(model_type.provider_uuid == provider_uuid),
|
||||||
persistence_model.EmbeddingModel.provider_uuid == provider_uuid
|
model_type,
|
||||||
),
|
|
||||||
persistence_model.EmbeddingModel,
|
|
||||||
workspace_uuid,
|
workspace_uuid,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if embedding_result.first() is not None:
|
model_uuids = list(result.scalars())
|
||||||
raise ValueError('Cannot delete provider: Embedding models still reference it')
|
if model_uuids and not cascade:
|
||||||
|
raise ValueError(f'Cannot delete provider: {label} models still reference it')
|
||||||
rerank_result = await self.ap.persistence_mgr.execute_async(
|
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(
|
scope_statement(
|
||||||
sqlalchemy.select(persistence_model.RerankModel).where(
|
sqlalchemy.delete(model_type).where(model_type.provider_uuid == provider_uuid),
|
||||||
persistence_model.RerankModel.provider_uuid == provider_uuid
|
model_type,
|
||||||
),
|
|
||||||
persistence_model.RerankModel,
|
|
||||||
workspace_uuid,
|
workspace_uuid,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if rerank_result.first() is not None:
|
deleted_models.append((remover, model_uuids))
|
||||||
raise ValueError('Cannot delete provider: Rerank models still reference it')
|
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
# 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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await persistence.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||||
persistence_model.ModelProvider.uuid == provider_uuid
|
persistence_model.ModelProvider.uuid == provider_uuid
|
||||||
@@ -213,11 +267,36 @@ class ModelProviderService:
|
|||||||
workspace_uuid,
|
workspace_uuid,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if getattr(result, 'rowcount', None) == 0:
|
if result.rowcount == 0:
|
||||||
raise WorkspaceNotFoundError('Provider not found')
|
raise WorkspaceNotFoundError('Provider not found')
|
||||||
|
|
||||||
|
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)
|
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:
|
async def get_provider_model_counts(self, context: TenantContext, provider_uuid: str) -> dict:
|
||||||
"""Get count of models using this provider"""
|
"""Get count of models using this provider"""
|
||||||
workspace_uuid = require_workspace_uuid(context)
|
workspace_uuid = require_workspace_uuid(context)
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from .. import stage, app
|
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')
|
@stage.stage_class('GenKeysStage')
|
||||||
class GenKeysStage(stage.BootingStage):
|
class GenKeysStage(stage.BootingStage):
|
||||||
@@ -20,5 +29,15 @@ class GenKeysStage(stage.BootingStage):
|
|||||||
ap.instance_config.data['system']['recovery_key'] = ''
|
ap.instance_config.data['system']['recovery_key'] = ''
|
||||||
|
|
||||||
if not 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()
|
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):
|
class LLMModel(Base):
|
||||||
"""LLM model"""
|
"""LLM model"""
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -62,6 +62,7 @@ _ALEMBIC_TENANT_TABLES = {
|
|||||||
'binary_storages',
|
'binary_storages',
|
||||||
'mcp_servers',
|
'mcp_servers',
|
||||||
'model_providers',
|
'model_providers',
|
||||||
|
'codex_credentials',
|
||||||
'llm_models',
|
'llm_models',
|
||||||
'embedding_models',
|
'embedding_models',
|
||||||
'rerank_models',
|
'rerank_models',
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
|
|||||||
'binary_storages': 'workspace_uuid',
|
'binary_storages': 'workspace_uuid',
|
||||||
'mcp_servers': 'workspace_uuid',
|
'mcp_servers': 'workspace_uuid',
|
||||||
'model_providers': 'workspace_uuid',
|
'model_providers': 'workspace_uuid',
|
||||||
|
'codex_credentials': 'workspace_uuid',
|
||||||
'llm_models': 'workspace_uuid',
|
'llm_models': 'workspace_uuid',
|
||||||
'embedding_models': 'workspace_uuid',
|
'embedding_models': 'workspace_uuid',
|
||||||
'rerank_models': 'workspace_uuid',
|
'rerank_models': 'workspace_uuid',
|
||||||
@@ -851,8 +852,31 @@ class TenantScopedAsyncSession(sqlalchemy_asyncio.AsyncSession):
|
|||||||
self._require_control_capability(capability, 'owned transaction commit')
|
self._require_control_capability(capability, 'owned transaction commit')
|
||||||
self._require_owner_task()
|
self._require_owner_task()
|
||||||
self._enter_internal_access()
|
self._enter_internal_access()
|
||||||
|
try:
|
||||||
|
# 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:
|
try:
|
||||||
await transaction.commit()
|
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:
|
finally:
|
||||||
self._exit_internal_access()
|
self._exit_internal_access()
|
||||||
|
|
||||||
@@ -1369,6 +1393,7 @@ class TenantUnitOfWork:
|
|||||||
state.mark_rollback_only(exc_value)
|
state.mark_rollback_only(exc_value)
|
||||||
rollback_only = state.rollback_only
|
rollback_only = state.rollback_only
|
||||||
committed = False
|
committed = False
|
||||||
|
transaction_error: BaseException | None = None
|
||||||
try:
|
try:
|
||||||
if exc_type is None and not rollback_only:
|
if exc_type is None and not rollback_only:
|
||||||
await typing.cast(TenantScopedAsyncSession, session)._commit_owned_transaction(
|
await typing.cast(TenantScopedAsyncSession, session)._commit_owned_transaction(
|
||||||
@@ -1381,6 +1406,9 @@ class TenantUnitOfWork:
|
|||||||
_UOW_SESSION_CONTROL_CAPABILITY,
|
_UOW_SESSION_CONTROL_CAPABILITY,
|
||||||
transaction,
|
transaction,
|
||||||
)
|
)
|
||||||
|
except BaseException as exc:
|
||||||
|
transaction_error = exc
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
if self._active_transaction is not None and self._context_token is not None:
|
if self._active_transaction is not None and self._context_token is not None:
|
||||||
@@ -1388,6 +1416,10 @@ class TenantUnitOfWork:
|
|||||||
await typing.cast(TenantScopedAsyncSession, session)._close_owned_session(
|
await typing.cast(TenantScopedAsyncSession, session)._close_owned_session(
|
||||||
_UOW_SESSION_CONTROL_CAPABILITY
|
_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:
|
finally:
|
||||||
if self._database_operation_token is not None:
|
if self._database_operation_token is not None:
|
||||||
_DATABASE_OPERATION_TRANSACTION.reset(self._database_operation_token)
|
_DATABASE_OPERATION_TRANSACTION.reset(self._database_operation_token)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.errors import provider as provider_errors
|
||||||
from ...entity.persistence import model as persistence_model
|
from ...entity.persistence import model as persistence_model
|
||||||
from ...workspace.entities import WorkspaceExecutionBinding
|
from ...workspace.entities import WorkspaceExecutionBinding
|
||||||
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError
|
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError, WorkspaceNotFoundError
|
||||||
from . import requester, token
|
from . import requester, token
|
||||||
|
|
||||||
|
|
||||||
@@ -638,10 +638,32 @@ class ModelManager:
|
|||||||
) -> requester.RuntimeLLMModel:
|
) -> requester.RuntimeLLMModel:
|
||||||
execution_context = await self.resolve_execution_context(context)
|
execution_context = await self.resolve_execution_context(context)
|
||||||
provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
|
provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
|
||||||
runtime_provider = await self._build_provider(
|
provider_uuid = model_info.get('provider_uuid') or provider_info.get('uuid')
|
||||||
execution_context,
|
inline_codex = provider_info.get('requester') == 'openai-codex'
|
||||||
persistence_model.ModelProvider(**provider_info),
|
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(
|
model_entity = persistence_model.LLMModel(
|
||||||
workspace_uuid=execution_context.workspace_uuid,
|
workspace_uuid=execution_context.workspace_uuid,
|
||||||
uuid=model_info.get('uuid', ''),
|
uuid=model_info.get('uuid', ''),
|
||||||
@@ -723,6 +745,10 @@ class ModelManager:
|
|||||||
'requester_name': provider_entity.requester,
|
'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:
|
if litellm_provider:
|
||||||
from .requesters import litellmchat
|
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
|
||||||
@@ -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
|
# Use coverage.py to collect coverage data
|
||||||
# Set COVERAGE_PROCESS_START to enable coverage in subprocess
|
# Set COVERAGE_PROCESS_START to enable coverage in subprocess
|
||||||
self._coverage_file = self.work_dir / '.coverage.e2e'
|
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)
|
env['COVERAGE_FILE'] = str(self._coverage_file)
|
||||||
|
|
||||||
# Create .coveragerc for subprocess
|
# Create .coveragerc for subprocess
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -108,7 +108,7 @@ class TestSQLiteMigrationUpgrade:
|
|||||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
await run_alembic_upgrade(sqlite_engine, 'head')
|
||||||
|
|
||||||
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
assert await get_alembic_current(sqlite_engine) == _get_script_head()
|
||||||
assert _get_script_head() == '0021_merge_reasoning_config'
|
assert _get_script_head() == '0022_codex_credentials'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine):
|
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_stamp(sqlite_engine, '0018_llm_reasoning_config')
|
||||||
await run_alembic_upgrade(sqlite_engine, 'head')
|
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) == '0022_codex_credentials'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
|
||||||
|
|||||||
@@ -549,10 +549,12 @@ class TestPostgreSQLWorkspaceMigration:
|
|||||||
await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
|
await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
|
||||||
)
|
)
|
||||||
assert 'workspaces' not in tables_before_migration
|
assert 'workspaces' not in tables_before_migration
|
||||||
|
assert 'codex_credentials' not in tables_before_migration
|
||||||
|
|
||||||
await manager._initialize_managed_schema()
|
await manager._initialize_managed_schema()
|
||||||
|
|
||||||
async with postgres_engine.connect() as conn:
|
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()
|
account = (await conn.execute(text('SELECT uuid, status, source FROM users'))).mappings().one()
|
||||||
workspace = (
|
workspace = (
|
||||||
(await conn.execute(text('SELECT * FROM workspaces WHERE source = :source'), {'source': 'local'}))
|
(await conn.execute(text('SELECT * FROM workspaces WHERE source = :source'), {'source': 'local'}))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -34,7 +35,7 @@ def _manifest_payloads(backup_directory) -> list[dict]:
|
|||||||
|
|
||||||
def _assert_verified_backup(payload: dict) -> None:
|
def _assert_verified_backup(payload: dict) -> None:
|
||||||
backup_path = pathlib.Path(payload['backup_path'])
|
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('PRAGMA quick_check').fetchall() == [('ok',)]
|
||||||
assert connection.execute('SELECT version_num FROM alembic_version').fetchone()[0] == payload['source_revision']
|
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())
|
await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
|
||||||
)
|
)
|
||||||
assert 'workspaces' not in tables_before_migration
|
assert 'workspaces' not in tables_before_migration
|
||||||
|
assert 'codex_credentials' not in tables_before_migration
|
||||||
|
|
||||||
await manager._run_alembic_migrations()
|
await manager._run_alembic_migrations()
|
||||||
|
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
|
assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
|
||||||
workspace = (
|
workspace = (
|
||||||
(await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
|
(await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.pipeline import LegacyPipeline
|
||||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||||
|
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
@@ -28,15 +29,10 @@ WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
|
|||||||
WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
|
WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
|
||||||
|
|
||||||
|
|
||||||
class _PersistenceManager:
|
class _PersistenceManager(PersistenceManager):
|
||||||
def __init__(self, engine):
|
def __init__(self, engine):
|
||||||
self.engine = engine
|
super().__init__(SimpleNamespace())
|
||||||
|
self.db = SimpleNamespace(get_engine=lambda: 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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def serialize_model(model, data, masked_columns=None):
|
def serialize_model(model, data, masked_columns=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
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from contextlib import nullcontext
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from langbot.pkg.api.http.service.provider import ModelProviderService
|
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
|
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||||
|
|
||||||
|
|
||||||
@@ -383,112 +384,35 @@ class TestModelProviderServiceUpdateProvider:
|
|||||||
|
|
||||||
|
|
||||||
class TestModelProviderServiceDeleteProvider:
|
class TestModelProviderServiceDeleteProvider:
|
||||||
"""Tests for delete_provider method."""
|
"""Fast guard coverage; real transaction/cache behavior is in test_provider_cascade."""
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
@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)
|
service = ModelProviderService(ap)
|
||||||
|
if label is not None:
|
||||||
# Execute & Verify
|
with pytest.raises(ValueError, match=f'Cannot delete provider: {label} models'):
|
||||||
with pytest.raises(ValueError, match='Cannot delete provider: LLM models'):
|
await service.delete_provider(WORKSPACE_UUID, 'provider')
|
||||||
await service.delete_provider(WORKSPACE_UUID, 'provider-with-llm')
|
ap.model_mgr.remove_provider.assert_not_awaited()
|
||||||
|
else:
|
||||||
async def test_delete_provider_with_embedding_models_raises_error(self):
|
await service.delete_provider(WORKSPACE_UUID, 'provider')
|
||||||
"""Raises ValueError when Embedding models reference provider."""
|
ap.model_mgr.remove_provider.assert_awaited_once_with(WORKSPACE_UUID, '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')
|
|
||||||
|
|
||||||
|
|
||||||
class TestModelProviderServiceGetProviderModelCounts:
|
class TestModelProviderServiceGetProviderModelCounts:
|
||||||
@@ -1045,15 +969,18 @@ class TestCloudManagedProviderProtection:
|
|||||||
|
|
||||||
async def test_cloud_rejects_update_and_delete_of_managed_provider(self):
|
async def test_cloud_rejects_update_and_delete_of_managed_provider(self):
|
||||||
service = self._service()
|
service = self._service()
|
||||||
service.get_provider = AsyncMock(
|
service.get_provider = AsyncMock(return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER})
|
||||||
return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER}
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='managed by Cloud'):
|
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||||
await service.update_provider(WORKSPACE_UUID, 'system-provider', {'name': 'Renamed'})
|
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'):
|
with pytest.raises(ValueError, match='managed by Cloud'):
|
||||||
await service.delete_provider(WORKSPACE_UUID, 'system-provider')
|
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):
|
async def test_oss_does_not_reserve_space_requester(self):
|
||||||
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='oss_compat')))
|
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]
|
||||||
@@ -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,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'
|
||||||
@@ -486,6 +486,7 @@ export default function ModelsPanel({
|
|||||||
// Get the provider info
|
// Get the provider info
|
||||||
const provider = providers.find((p) => p.uuid === providerUuid);
|
const provider = providers.find((p) => p.uuid === providerUuid);
|
||||||
const providerData = {
|
const providerData = {
|
||||||
|
uuid: providerUuid,
|
||||||
requester: provider?.requester || '',
|
requester: provider?.requester || '',
|
||||||
base_url: provider?.base_url || '',
|
base_url: provider?.base_url || '',
|
||||||
api_keys: provider?.api_keys || [],
|
api_keys: provider?.api_keys || [],
|
||||||
@@ -495,7 +496,7 @@ export default function ModelsPanel({
|
|||||||
await httpClient.testLLMModel('_', {
|
await httpClient.testLLMModel('_', {
|
||||||
uuid: '',
|
uuid: '',
|
||||||
name,
|
name,
|
||||||
provider_uuid: '',
|
provider_uuid: providerUuid,
|
||||||
provider: providerData,
|
provider: providerData,
|
||||||
abilities,
|
abilities,
|
||||||
reasoning_config: reasoningConfig,
|
reasoning_config: reasoningConfig,
|
||||||
@@ -505,7 +506,7 @@ export default function ModelsPanel({
|
|||||||
await httpClient.testEmbeddingModel('_', {
|
await httpClient.testEmbeddingModel('_', {
|
||||||
uuid: '',
|
uuid: '',
|
||||||
name,
|
name,
|
||||||
provider_uuid: '',
|
provider_uuid: providerUuid,
|
||||||
provider: providerData,
|
provider: providerData,
|
||||||
extra_args: extraArgsObj,
|
extra_args: extraArgsObj,
|
||||||
} as never);
|
} as never);
|
||||||
@@ -513,7 +514,7 @@ export default function ModelsPanel({
|
|||||||
await httpClient.testRerankModel('_', {
|
await httpClient.testRerankModel('_', {
|
||||||
uuid: '',
|
uuid: '',
|
||||||
name,
|
name,
|
||||||
provider_uuid: '',
|
provider_uuid: providerUuid,
|
||||||
provider: providerData,
|
provider: providerData,
|
||||||
extra_args: extraArgsObj,
|
extra_args: extraArgsObj,
|
||||||
} as never);
|
} as never);
|
||||||
@@ -536,6 +537,29 @@ export default function ModelsPanel({
|
|||||||
expandedProviders.forEach((uuid) => loadProviderModels(uuid));
|
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(
|
function renderProviderCard(
|
||||||
provider: ModelProvider,
|
provider: ModelProvider,
|
||||||
isLangBotModels: boolean = false,
|
isLangBotModels: boolean = false,
|
||||||
@@ -666,8 +690,14 @@ export default function ModelsPanel({
|
|||||||
)}
|
)}
|
||||||
</PanelBody>
|
</PanelBody>
|
||||||
|
|
||||||
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
|
<Dialog
|
||||||
<DialogContent className="w-full max-w-[calc(100%-2rem)] p-4 sm:max-w-[600px] sm:p-6">
|
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>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{editingProviderId
|
{editingProviderId
|
||||||
@@ -675,11 +705,15 @@ export default function ModelsPanel({
|
|||||||
: t('models.addProvider')}
|
: t('models.addProvider')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{providerFormOpen && (
|
||||||
<ProviderForm
|
<ProviderForm
|
||||||
|
key={editingProviderId || 'new'}
|
||||||
providerId={editingProviderId || undefined}
|
providerId={editingProviderId || undefined}
|
||||||
onFormSubmit={handleFormClose}
|
onFormSubmit={handleFormClose}
|
||||||
onFormCancel={() => setProviderFormOpen(false)}
|
onFormCancel={handleFormClose}
|
||||||
|
onProviderDeleted={canManage ? handleProviderDeleted : undefined}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+313
-78
@@ -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 { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -16,12 +16,31 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||||
import { DialogFooter } from '@/components/ui/dialog';
|
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 { toast } from 'sonner';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { CustomApiError } from '@/app/infra/entities/common';
|
import { CustomApiError } from '@/app/infra/entities/common';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Check, ChevronDown, Search } from 'lucide-react';
|
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) =>
|
const getFormSchema = (t: (key: string) => string) =>
|
||||||
z.object({
|
z.object({
|
||||||
@@ -35,12 +54,14 @@ interface ProviderFormProps {
|
|||||||
providerId?: string;
|
providerId?: string;
|
||||||
onFormSubmit: (providerUuid: string) => void | Promise<void>;
|
onFormSubmit: (providerUuid: string) => void | Promise<void>;
|
||||||
onFormCancel: () => void;
|
onFormCancel: () => void;
|
||||||
|
onProviderDeleted?: (providerUuid: string) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProviderForm({
|
export default function ProviderForm({
|
||||||
providerId,
|
providerId,
|
||||||
onFormSubmit,
|
onFormSubmit,
|
||||||
onFormCancel,
|
onFormCancel,
|
||||||
|
onProviderDeleted,
|
||||||
}: ProviderFormProps) {
|
}: ProviderFormProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const formSchema = getFormSchema(t);
|
const formSchema = getFormSchema(t);
|
||||||
@@ -54,7 +75,31 @@ export default function ProviderForm({
|
|||||||
api_key: '',
|
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<
|
const [requesterList, setRequesterList] = useState<
|
||||||
{
|
{
|
||||||
@@ -68,14 +113,24 @@ export default function ProviderForm({
|
|||||||
>([]);
|
>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const loadRequesters = useCallback(async () => {
|
useEffect(() => {
|
||||||
const resp = await httpClient.getProviderRequesters();
|
// Ignore both success and failure from a closed form or superseded attempt.
|
||||||
|
let canceled = false;
|
||||||
|
setLoadState('loading');
|
||||||
|
setMutableProviderLoaded(false);
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const [requesters, detail] = await Promise.all([
|
||||||
|
httpClient.getProviderRequesters(),
|
||||||
|
providerId ? httpClient.getModelProvider(providerId) : null,
|
||||||
|
]);
|
||||||
|
if (canceled) return;
|
||||||
setRequesterList(
|
setRequesterList(
|
||||||
resp.requesters
|
requesters.requesters
|
||||||
.filter((item) => item.name !== 'space-chat-completions')
|
.filter((item) => item.name !== LANGBOT_MODELS_PROVIDER_REQUESTER)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
label: extractI18nObject(item.label),
|
label: extractI18nObject(item.label),
|
||||||
value: item.name,
|
value: item.name,
|
||||||
@@ -88,52 +143,29 @@ export default function ProviderForm({
|
|||||||
alias: item.spec.alias || '',
|
alias: item.spec.alias || '',
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}, []);
|
if (detail) {
|
||||||
|
const provider = detail.provider;
|
||||||
const loadProvider = useCallback(
|
reset({
|
||||||
async (id: string) => {
|
name: provider.name,
|
||||||
const resp = await httpClient.getModelProvider(id);
|
requester: provider.requester,
|
||||||
const provider = resp.provider;
|
base_url: provider.base_url,
|
||||||
|
api_key: provider.api_keys?.[0] || '',
|
||||||
setValue('name', provider.name);
|
});
|
||||||
setValue('requester', provider.requester);
|
setMutableProviderLoaded(
|
||||||
setValue('base_url', provider.base_url);
|
provider.uuid === providerId &&
|
||||||
setValue('api_key', provider.api_keys?.[0] || '');
|
provider.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
},
|
|
||||||
[setValue],
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
useEffect(() => {
|
setLoadState('ready');
|
||||||
async function init() {
|
} catch {
|
||||||
await loadRequesters();
|
if (!canceled) setLoadState('error');
|
||||||
if (providerId) {
|
|
||||||
await loadProvider(providerId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
init();
|
void init();
|
||||||
}, [providerId, loadProvider, loadRequesters]);
|
return () => {
|
||||||
|
canceled = true;
|
||||||
// Close dropdown when clicking outside
|
};
|
||||||
useEffect(() => {
|
}, [providerId, reset, loadAttempt]);
|
||||||
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]);
|
|
||||||
|
|
||||||
// Filter requesters based on search query
|
// Filter requesters based on search query
|
||||||
const filteredRequesters = requesterList.filter(
|
const filteredRequesters = requesterList.filter(
|
||||||
@@ -163,29 +195,105 @@ export default function ProviderForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function handleFormSubmit(values: z.infer<typeof formSchema>) {
|
async function handleFormSubmit(values: z.infer<typeof formSchema>) {
|
||||||
const data = {
|
if (
|
||||||
name: values.name,
|
loadState !== 'ready' ||
|
||||||
requester: values.requester,
|
submitting.current ||
|
||||||
base_url: values.base_url,
|
deleting.current ||
|
||||||
api_keys: values.api_key ? [values.api_key] : [],
|
(isCodex && loginActive)
|
||||||
};
|
)
|
||||||
|
return;
|
||||||
|
submitting.current = true;
|
||||||
|
const data = providerPayload(values);
|
||||||
try {
|
try {
|
||||||
let savedProviderUuid = providerId;
|
if (savedId.current) {
|
||||||
if (providerId) {
|
await httpClient.updateModelProvider(savedId.current, data);
|
||||||
await httpClient.updateModelProvider(providerId, data);
|
|
||||||
toast.success(t('models.providerSaved'));
|
|
||||||
} else {
|
} else {
|
||||||
const response = await httpClient.createModelProvider(data);
|
const response = await httpClient.createModelProvider(data);
|
||||||
savedProviderUuid = response.uuid;
|
savedId.current = response.uuid;
|
||||||
toast.success(t('models.providerCreated'));
|
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) {
|
} 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 (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
@@ -202,7 +310,12 @@ export default function ProviderForm({
|
|||||||
<span className="text-red-500">*</span>
|
<span className="text-red-500">*</span>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input
|
||||||
|
{...field}
|
||||||
|
disabled={
|
||||||
|
form.formState.isSubmitting || (isCodex && loginActive)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -222,11 +335,22 @@ export default function ProviderForm({
|
|||||||
{t('models.requester')}
|
{t('models.requester')}
|
||||||
<span className="text-red-500">*</span>
|
<span className="text-red-500">*</span>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<div ref={dropdownRef} className="relative">
|
<Popover
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsOpen(open);
|
||||||
|
if (!open) setSearchQuery('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Trigger button */}
|
{/* Trigger button */}
|
||||||
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
disabled={
|
||||||
|
form.formState.isSubmitting ||
|
||||||
|
(isCodex && (!!savedProviderId || loginActive))
|
||||||
|
}
|
||||||
|
aria-expanded={isOpen}
|
||||||
className={cn(
|
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',
|
'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',
|
isOpen && 'ring-2 ring-ring ring-offset-2',
|
||||||
@@ -255,12 +379,21 @@ export default function ProviderForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
{/* Dropdown */}
|
{/* Unmount on close so an exiting layer cannot eat Dialog Escape. */}
|
||||||
{isOpen && (
|
{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 */}
|
{/* 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" />
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
@@ -275,7 +408,13 @@ export default function ProviderForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Options list */}
|
{/* 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(
|
{Object.entries(groupedRequesters).map(
|
||||||
([category, items]) => {
|
([category, items]) => {
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
@@ -288,6 +427,11 @@ export default function ProviderForm({
|
|||||||
<button
|
<button
|
||||||
key={r.value}
|
key={r.value}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={
|
||||||
|
!!providerId &&
|
||||||
|
r.value === 'openai-codex' &&
|
||||||
|
!isCodex
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
field.onChange(r.value);
|
field.onChange(r.value);
|
||||||
const req = requesterList.find(
|
const req = requesterList.find(
|
||||||
@@ -337,9 +481,9 @@ export default function ProviderForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PopoverContent>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Popover>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
{selectedRequester?.description && (
|
{selectedRequester?.description && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -351,6 +495,10 @@ export default function ProviderForm({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isCodex ? (
|
||||||
|
<CodexAccountSection login={login} providerId={savedProviderId} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="base_url"
|
name="base_url"
|
||||||
@@ -358,7 +506,12 @@ export default function ProviderForm({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('models.requestURL')}</FormLabel>
|
<FormLabel>{t('models.requestURL')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input
|
||||||
|
{...field}
|
||||||
|
disabled={
|
||||||
|
form.formState.isSubmitting || (isCodex && loginActive)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -378,13 +531,95 @@ export default function ProviderForm({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="flex-row flex-wrap items-start justify-between sm:justify-between">
|
||||||
<Button type="submit">{t('common.save')}</Button>
|
{providerId && mutableProviderLoaded && onProviderDeleted && (
|
||||||
<Button type="button" variant="outline" onClick={onFormCancel}>
|
<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')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</DialogFooter>
|
</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>
|
||||||
</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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
|
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
|
||||||
|
import type {
|
||||||
|
CodexAuthStatus,
|
||||||
|
CodexDeviceAuthorization,
|
||||||
|
CodexDevicePoll,
|
||||||
|
} from '@/app/infra/entities/codex';
|
||||||
import {
|
import {
|
||||||
ApiRespProviderRequesters,
|
ApiRespProviderRequesters,
|
||||||
ApiRespProviderRequester,
|
ApiRespProviderRequester,
|
||||||
@@ -126,8 +131,52 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.put(`/api/v1/provider/providers/${uuid}`, provider);
|
return this.put(`/api/v1/provider/providers/${uuid}`, provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
public deleteModelProvider(uuid: string): Promise<object> {
|
public deleteModelProvider(uuid: string, cascade = false): Promise<object> {
|
||||||
return this.delete(`/api/v1/provider/providers/${uuid}`);
|
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(
|
public scanProviderModels(
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import {
|
|
||||||
InputOTP,
|
|
||||||
InputOTPGroup,
|
|
||||||
InputOTPSlot,
|
|
||||||
InputOTPSeparator,
|
|
||||||
} from '@/components/ui/input-otp';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
@@ -28,14 +22,12 @@ import {
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||||
|
|
||||||
const REGEXP_ONLY_DIGITS_AND_CHARS = /^[0-9a-zA-Z]+$/;
|
|
||||||
|
|
||||||
const formSchema = (t: (key: string) => string) =>
|
const formSchema = (t: (key: string) => string) =>
|
||||||
z.object({
|
z.object({
|
||||||
email: z.string().email(t('common.invalidEmail')),
|
email: z.string().email(t('common.invalidEmail')),
|
||||||
@@ -136,28 +128,17 @@ export default function ResetPassword() {
|
|||||||
{t('resetPassword.recoveryKeyDescription')}
|
{t('resetPassword.recoveryKeyDescription')}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<InputOTP
|
{/* Recovery keys are case-sensitive base64url strings; send them verbatim */}
|
||||||
maxLength={6}
|
<div className="relative">
|
||||||
value={field.value}
|
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS.source}
|
<Input
|
||||||
onChange={(value) => {
|
placeholder={t('resetPassword.enterRecoveryKey')}
|
||||||
// 将输入的值转换为大写
|
className="pl-10 font-mono"
|
||||||
const upperValue = value.toUpperCase();
|
autoComplete="off"
|
||||||
field.onChange(upperValue);
|
spellCheck={false}
|
||||||
}}
|
{...field}
|
||||||
>
|
/>
|
||||||
<InputOTPGroup>
|
</div>
|
||||||
<InputOTPSlot index={0} />
|
|
||||||
<InputOTPSlot index={1} />
|
|
||||||
<InputOTPSlot index={2} />
|
|
||||||
</InputOTPGroup>
|
|
||||||
<InputOTPSeparator />
|
|
||||||
<InputOTPGroup>
|
|
||||||
<InputOTPSlot index={3} />
|
|
||||||
<InputOTPSlot index={4} />
|
|
||||||
<InputOTPSlot index={5} />
|
|
||||||
</InputOTPGroup>
|
|
||||||
</InputOTP>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -1,39 +1,39 @@
|
|||||||
/**
|
/** Copy text using the Clipboard API, with a focus-trap-safe legacy fallback. */
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
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);
|
await navigator.clipboard.writeText(text);
|
||||||
return true;
|
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;
|
||||||
try {
|
|
||||||
const textArea = document.createElement('textarea');
|
const textArea = document.createElement('textarea');
|
||||||
|
try {
|
||||||
textArea.value = text;
|
textArea.value = text;
|
||||||
textArea.style.position = 'fixed';
|
textArea.style.position = 'fixed';
|
||||||
textArea.style.left = '-999999px';
|
textArea.style.left = '-999999px';
|
||||||
textArea.style.top = '-999999px';
|
textArea.style.top = '-999999px';
|
||||||
document.body.appendChild(textArea);
|
// Radix modal focus scopes reject focus on elements appended to body.
|
||||||
textArea.focus();
|
const container =
|
||||||
|
previousFocus?.closest('[role="dialog"], [role="alertdialog"]') ??
|
||||||
|
document.body;
|
||||||
|
container.appendChild(textArea);
|
||||||
|
textArea.focus({ preventScroll: true });
|
||||||
textArea.select();
|
textArea.select();
|
||||||
|
if (
|
||||||
const successful = document.execCommand('copy');
|
document.activeElement !== textArea ||
|
||||||
document.body.removeChild(textArea);
|
textArea.selectionEnd !== text.length
|
||||||
|
)
|
||||||
return successful;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Clipboard] Fallback method failed:', err);
|
|
||||||
return false;
|
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
|
<AlertDialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -182,6 +182,37 @@ const enUS = {
|
|||||||
help: 'Get Help',
|
help: 'Get Help',
|
||||||
},
|
},
|
||||||
models: {
|
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',
|
title: 'Models',
|
||||||
description: 'Configure and manage models that can be used in pipelines',
|
description: 'Configure and manage models that can be used in pipelines',
|
||||||
createModel: 'Create Model',
|
createModel: 'Create Model',
|
||||||
@@ -316,6 +347,8 @@ const enUS = {
|
|||||||
providerSaveError: 'Failed to save provider: ',
|
providerSaveError: 'Failed to save provider: ',
|
||||||
providerDeleted: 'Provider deleted',
|
providerDeleted: 'Provider deleted',
|
||||||
providerDeleteError: 'Failed to delete provider: ',
|
providerDeleteError: 'Failed to delete provider: ',
|
||||||
|
deleteProviderCascadeConfirmation:
|
||||||
|
'Delete this provider and ALL models it contains? This action is irreversible and cannot be undone.',
|
||||||
deleteProviderConfirmation:
|
deleteProviderConfirmation:
|
||||||
'Are you sure you want to delete this provider?',
|
'Are you sure you want to delete this provider?',
|
||||||
loadError: 'Failed to load data',
|
loadError: 'Failed to load data',
|
||||||
|
|||||||
@@ -187,6 +187,38 @@ const esES = {
|
|||||||
help: 'Obtener ayuda',
|
help: 'Obtener ayuda',
|
||||||
},
|
},
|
||||||
models: {
|
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',
|
title: 'Modelos',
|
||||||
description:
|
description:
|
||||||
'Configura y gestiona los modelos que se pueden usar en los Pipelines',
|
'Configura y gestiona los modelos que se pueden usar en los Pipelines',
|
||||||
@@ -324,6 +356,8 @@ const esES = {
|
|||||||
providerSaveError: 'Error al guardar el proveedor: ',
|
providerSaveError: 'Error al guardar el proveedor: ',
|
||||||
providerDeleted: 'Proveedor eliminado',
|
providerDeleted: 'Proveedor eliminado',
|
||||||
providerDeleteError: 'Error al eliminar el proveedor: ',
|
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:
|
deleteProviderConfirmation:
|
||||||
'¿Estás seguro de que deseas eliminar este proveedor?',
|
'¿Estás seguro de que deseas eliminar este proveedor?',
|
||||||
loadError: 'Error al cargar datos',
|
loadError: 'Error al cargar datos',
|
||||||
|
|||||||
@@ -185,6 +185,38 @@ const jaJP = {
|
|||||||
help: 'ヘルプドキュメントを見る',
|
help: 'ヘルプドキュメントを見る',
|
||||||
},
|
},
|
||||||
models: {
|
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: 'モデル設定',
|
title: 'モデル設定',
|
||||||
description: 'パイプラインで使用できるモデルを設定・管理',
|
description: 'パイプラインで使用できるモデルを設定・管理',
|
||||||
createModel: 'モデルを作成',
|
createModel: 'モデルを作成',
|
||||||
@@ -322,6 +354,8 @@ const jaJP = {
|
|||||||
providerSaveError: 'プロバイダーの保存に失敗しました:',
|
providerSaveError: 'プロバイダーの保存に失敗しました:',
|
||||||
providerDeleted: 'プロバイダーを削除しました',
|
providerDeleted: 'プロバイダーを削除しました',
|
||||||
providerDeleteError: 'プロバイダーの削除に失敗しました:',
|
providerDeleteError: 'プロバイダーの削除に失敗しました:',
|
||||||
|
deleteProviderCascadeConfirmation:
|
||||||
|
'このプロバイダーと、その中のすべてのモデルを削除しますか?この操作は取り消せず、元に戻せません。',
|
||||||
deleteProviderConfirmation: 'このプロバイダーを削除してもよろしいですか?',
|
deleteProviderConfirmation: 'このプロバイダーを削除してもよろしいですか?',
|
||||||
loadError: 'データの読み込みに失敗しました',
|
loadError: 'データの読み込みに失敗しました',
|
||||||
chat: 'チャット',
|
chat: 'チャット',
|
||||||
|
|||||||
@@ -184,6 +184,37 @@ const ruRU = {
|
|||||||
help: 'Помощь',
|
help: 'Помощь',
|
||||||
},
|
},
|
||||||
models: {
|
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: 'Модели',
|
title: 'Модели',
|
||||||
description: 'Настройка и управление моделями, используемыми в конвейерах',
|
description: 'Настройка и управление моделями, используемыми в конвейерах',
|
||||||
createModel: 'Создать модель',
|
createModel: 'Создать модель',
|
||||||
@@ -322,6 +353,8 @@ const ruRU = {
|
|||||||
providerSaveError: 'Ошибка сохранения провайдера: ',
|
providerSaveError: 'Ошибка сохранения провайдера: ',
|
||||||
providerDeleted: 'Провайдер удалён',
|
providerDeleted: 'Провайдер удалён',
|
||||||
providerDeleteError: 'Ошибка удаления провайдера: ',
|
providerDeleteError: 'Ошибка удаления провайдера: ',
|
||||||
|
deleteProviderCascadeConfirmation:
|
||||||
|
'Удалить этого провайдера и ВСЕ содержащиеся в нём модели? Это действие необратимо, его нельзя отменить.',
|
||||||
deleteProviderConfirmation:
|
deleteProviderConfirmation:
|
||||||
'Вы уверены, что хотите удалить этого провайдера?',
|
'Вы уверены, что хотите удалить этого провайдера?',
|
||||||
loadError: 'Не удалось загрузить данные',
|
loadError: 'Не удалось загрузить данные',
|
||||||
|
|||||||
@@ -181,6 +181,37 @@ const thTH = {
|
|||||||
help: 'ขอความช่วยเหลือ',
|
help: 'ขอความช่วยเหลือ',
|
||||||
},
|
},
|
||||||
models: {
|
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: 'โมเดล',
|
title: 'โมเดล',
|
||||||
description: 'กำหนดค่าและจัดการโมเดลที่สามารถใช้ใน Pipeline',
|
description: 'กำหนดค่าและจัดการโมเดลที่สามารถใช้ใน Pipeline',
|
||||||
createModel: 'สร้างโมเดล',
|
createModel: 'สร้างโมเดล',
|
||||||
@@ -310,6 +341,8 @@ const thTH = {
|
|||||||
providerSaveError: 'บันทึกผู้ให้บริการล้มเหลว: ',
|
providerSaveError: 'บันทึกผู้ให้บริการล้มเหลว: ',
|
||||||
providerDeleted: 'ลบผู้ให้บริการแล้ว',
|
providerDeleted: 'ลบผู้ให้บริการแล้ว',
|
||||||
providerDeleteError: 'ลบผู้ให้บริการล้มเหลว: ',
|
providerDeleteError: 'ลบผู้ให้บริการล้มเหลว: ',
|
||||||
|
deleteProviderCascadeConfirmation:
|
||||||
|
'ลบผู้ให้บริการนี้และโมเดลทั้งหมดที่อยู่ภายในหรือไม่? การดำเนินการนี้ไม่สามารถย้อนกลับหรือยกเลิกได้',
|
||||||
deleteProviderConfirmation: 'คุณแน่ใจหรือไม่ว่าต้องการลบผู้ให้บริการนี้?',
|
deleteProviderConfirmation: 'คุณแน่ใจหรือไม่ว่าต้องการลบผู้ให้บริการนี้?',
|
||||||
loadError: 'โหลดข้อมูลล้มเหลว',
|
loadError: 'โหลดข้อมูลล้มเหลว',
|
||||||
chat: 'แชท',
|
chat: 'แชท',
|
||||||
|
|||||||
@@ -184,6 +184,37 @@ const viVN = {
|
|||||||
help: 'Trợ giúp',
|
help: 'Trợ giúp',
|
||||||
},
|
},
|
||||||
models: {
|
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',
|
title: 'Mô hình',
|
||||||
description:
|
description:
|
||||||
'Cấu hình và quản lý các mô hình có thể sử dụng trong Pipeline',
|
'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: ',
|
providerSaveError: 'Lưu nhà cung cấp thất bại: ',
|
||||||
providerDeleted: 'Đã xóa nhà cung cấp',
|
providerDeleted: 'Đã xóa nhà cung cấp',
|
||||||
providerDeleteError: 'Xóa nhà cung cấp thất bại: ',
|
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:
|
deleteProviderConfirmation:
|
||||||
'Bạn có chắc chắn muốn xóa nhà cung cấp này không?',
|
'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',
|
loadError: 'Tải dữ liệu thất bại',
|
||||||
|
|||||||
@@ -172,6 +172,37 @@ const zhHans = {
|
|||||||
help: '查看帮助文档',
|
help: '查看帮助文档',
|
||||||
},
|
},
|
||||||
models: {
|
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: '模型配置',
|
title: '模型配置',
|
||||||
description: '配置和管理可在流水线中使用的模型',
|
description: '配置和管理可在流水线中使用的模型',
|
||||||
createModel: '创建对话模型',
|
createModel: '创建对话模型',
|
||||||
@@ -302,6 +333,8 @@ const zhHans = {
|
|||||||
providerSaveError: '保存供应商失败:',
|
providerSaveError: '保存供应商失败:',
|
||||||
providerDeleted: '供应商已删除',
|
providerDeleted: '供应商已删除',
|
||||||
providerDeleteError: '删除供应商失败:',
|
providerDeleteError: '删除供应商失败:',
|
||||||
|
deleteProviderCascadeConfirmation:
|
||||||
|
'确定删除此供应商及其包含的所有模型吗?此操作不可逆,无法撤销。',
|
||||||
deleteProviderConfirmation: '你确定要删除这个供应商吗?',
|
deleteProviderConfirmation: '你确定要删除这个供应商吗?',
|
||||||
loadError: '加载数据失败',
|
loadError: '加载数据失败',
|
||||||
chat: '对话',
|
chat: '对话',
|
||||||
|
|||||||
@@ -173,6 +173,37 @@ const zhHant = {
|
|||||||
help: '查看說明文件',
|
help: '查看說明文件',
|
||||||
},
|
},
|
||||||
models: {
|
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: '模型設定',
|
title: '模型設定',
|
||||||
description: '設定和管理可在流程線中使用的模型',
|
description: '設定和管理可在流程線中使用的模型',
|
||||||
createModel: '建立模型',
|
createModel: '建立模型',
|
||||||
@@ -299,6 +330,8 @@ const zhHant = {
|
|||||||
providerSaveError: '儲存供應商失敗:',
|
providerSaveError: '儲存供應商失敗:',
|
||||||
providerDeleted: '供應商已刪除',
|
providerDeleted: '供應商已刪除',
|
||||||
providerDeleteError: '刪除供應商失敗:',
|
providerDeleteError: '刪除供應商失敗:',
|
||||||
|
deleteProviderCascadeConfirmation:
|
||||||
|
'確定刪除此供應商及其包含的所有模型嗎?此操作不可逆,無法復原。',
|
||||||
deleteProviderConfirmation: '您確定要刪除這個供應商嗎?',
|
deleteProviderConfirmation: '您確定要刪除這個供應商嗎?',
|
||||||
loadError: '載入資料失敗',
|
loadError: '載入資料失敗',
|
||||||
chat: '對話',
|
chat: '對話',
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
// Isolated real React/Radix fixture. No backend or OAuth requests are made.
|
||||||
|
async function mount(page: Page, mode: string) {
|
||||||
|
page.on('pageerror', (error) => console.error(error.message));
|
||||||
|
await page.route('**/copy-harness', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: 'text/html',
|
||||||
|
body: `
|
||||||
|
<div id="root"></div><script type="module">
|
||||||
|
import RefreshRuntime from '/@react-refresh';
|
||||||
|
RefreshRuntime.injectIntoGlobalHook(window);
|
||||||
|
window.$RefreshReg$ = () => {};
|
||||||
|
window.$RefreshSig$ = () => (type) => type;
|
||||||
|
window.__vite_plugin_react_preamble_installed__ = true;
|
||||||
|
</script><script type="module">
|
||||||
|
import React from '/node_modules/.vite/deps/react.js';
|
||||||
|
import ReactDOM from '/node_modules/.vite/deps/react-dom_client.js';
|
||||||
|
const {createRoot} = ReactDOM;
|
||||||
|
import i18n from '/node_modules/.vite/deps/i18next.js';
|
||||||
|
import {initReactI18next} from '/node_modules/.vite/deps/react-i18next.js';
|
||||||
|
import {Toaster} from '/src/components/ui/sonner.tsx';
|
||||||
|
import '/src/app/global.css';
|
||||||
|
import {Dialog, DialogContent, DialogTitle} from '/src/components/ui/dialog.tsx';
|
||||||
|
import Section from '/src/app/home/components/models-dialog/component/provider-form/CodexAccountSection.tsx';
|
||||||
|
await i18n.use(initReactI18next).init({lng:'en', resources:{en:{translation:{}}}, interpolation:{escapeValue:false}});
|
||||||
|
const root=createRoot(document.getElementById('root'));
|
||||||
|
window.renderCode=(code='FIXTURE-1234',attempt='attempt-1')=>root.render(React.createElement(Dialog,{open:true},
|
||||||
|
React.createElement(DialogContent,{},React.createElement(DialogTitle,{},'Copy fixture'),React.createElement(Section,{providerId:'fixture',login:{phase:'pending',device:{user_code:code,authorization_id:attempt,verification_uri:'https://example.invalid',expires_at:9999999999}}})),React.createElement(Toaster)));
|
||||||
|
window.renderCode();
|
||||||
|
</script>`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.addInitScript((mode) => {
|
||||||
|
const w = window as any;
|
||||||
|
w.copyEvents = [];
|
||||||
|
document.addEventListener('copy', () => {
|
||||||
|
const el = document.activeElement as HTMLTextAreaElement;
|
||||||
|
w.copyEvents.push({
|
||||||
|
tag: el.tagName,
|
||||||
|
selected: el.value?.slice(el.selectionStart, el.selectionEnd),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value:
|
||||||
|
mode === 'unavailable'
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
writeText: (text: string) => {
|
||||||
|
if (mode === 'success') {
|
||||||
|
w.written = text;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (mode === 'delayed')
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
w.resolveCopy = resolve;
|
||||||
|
});
|
||||||
|
return Promise.reject(new Error('denied'));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (mode === 'false') document.execCommand = () => false;
|
||||||
|
if (mode === 'throw')
|
||||||
|
document.execCommand = () => {
|
||||||
|
throw new Error('denied');
|
||||||
|
};
|
||||||
|
}, mode);
|
||||||
|
await page.goto('/copy-harness');
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'models.codex.copyCode', exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
const copy = (page: Page) =>
|
||||||
|
page.getByRole('button', { name: 'models.codex.copyCode', exact: true });
|
||||||
|
const copied = (page: Page) =>
|
||||||
|
page.getByRole('button', { name: 'models.codex.copied', exact: true });
|
||||||
|
|
||||||
|
test('Clipboard API success shows icon, toast and transient feedback', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mount(page, 'success');
|
||||||
|
await expect(copy(page).locator('svg.lucide-copy')).toBeVisible();
|
||||||
|
await copy(page).click();
|
||||||
|
await expect(copied(page).locator('svg.lucide-check')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText('common.copySuccess', { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(await page.evaluate(() => (window as any).written)).toBe(
|
||||||
|
'FIXTURE-1234',
|
||||||
|
);
|
||||||
|
await expect(copy(page)).toBeVisible({ timeout: 4000 });
|
||||||
|
});
|
||||||
|
for (const mode of ['unavailable', 'rejected'])
|
||||||
|
test(`${mode} API performs a real selected-text copy inside modal`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mount(page, mode);
|
||||||
|
await copy(page).click();
|
||||||
|
await expect(copied(page)).toBeVisible();
|
||||||
|
expect(await page.evaluate(() => (window as any).copyEvents)).toEqual([
|
||||||
|
{ tag: 'TEXTAREA', selected: 'FIXTURE-1234' },
|
||||||
|
]);
|
||||||
|
await expect(copied(page)).toBeFocused();
|
||||||
|
await expect(page.locator('textarea')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
for (const mode of ['false', 'throw'])
|
||||||
|
test(`${mode} fallback reports failure and manual guidance`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mount(page, mode);
|
||||||
|
await copy(page).click();
|
||||||
|
await expect(
|
||||||
|
page.getByText('common.copyFailed', { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText('models.codex.copyManually', { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(copy(page)).toBeVisible();
|
||||||
|
await expect(page.locator('textarea')).toHaveCount(0);
|
||||||
|
await expect(copy(page)).toBeFocused();
|
||||||
|
});
|
||||||
|
test('new code or attempt clears copied feedback', async ({ page }) => {
|
||||||
|
await mount(page, 'success');
|
||||||
|
await copy(page).click();
|
||||||
|
await expect(copied(page)).toBeVisible();
|
||||||
|
await page.evaluate(() =>
|
||||||
|
(window as any).renderCode('FIXTURE-5678', 'attempt-2'),
|
||||||
|
);
|
||||||
|
await expect(copy(page)).toBeVisible();
|
||||||
|
await copy(page).click();
|
||||||
|
await expect(copied(page)).toBeVisible();
|
||||||
|
await page.evaluate(() =>
|
||||||
|
(window as any).renderCode('FIXTURE-5678', 'attempt-3'),
|
||||||
|
);
|
||||||
|
await expect(copy(page)).toBeVisible();
|
||||||
|
});
|
||||||
|
test('completion from an old attempt cannot mark the new code copied', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mount(page, 'delayed');
|
||||||
|
await copy(page).click();
|
||||||
|
await page.evaluate(() =>
|
||||||
|
(window as any).renderCode('FIXTURE-5678', 'attempt-2'),
|
||||||
|
);
|
||||||
|
await expect(page.getByText('FIXTURE-5678')).toBeVisible();
|
||||||
|
await page.evaluate(() => (window as any).resolveCopy());
|
||||||
|
await expect(copy(page)).toBeVisible();
|
||||||
|
await expect(copied(page)).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||||
|
|
||||||
|
// All OAuth, provider and model responses here are explicit UI fixtures.
|
||||||
|
// These tests never authenticate with OpenAI or use a real subscription.
|
||||||
|
async function fixture(page: Page) {
|
||||||
|
await installLangBotApiMocks(page, { authenticated: true });
|
||||||
|
const state = {
|
||||||
|
providers: [] as Record<string, unknown>[],
|
||||||
|
creates: 0,
|
||||||
|
starts: 0,
|
||||||
|
polls: 0,
|
||||||
|
cancels: 0,
|
||||||
|
disconnects: 0,
|
||||||
|
connected: false,
|
||||||
|
failStart: false,
|
||||||
|
pollStatus: 'pending',
|
||||||
|
interval: 1,
|
||||||
|
expiresIn: 600,
|
||||||
|
};
|
||||||
|
const ok = (route: Route, data: unknown) =>
|
||||||
|
route.fulfill({ json: { code: 0, data } });
|
||||||
|
await page.route('**/api/v1/provider/**', async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const path = url.pathname;
|
||||||
|
const method = route.request().method();
|
||||||
|
if (path.endsWith('/icon'))
|
||||||
|
return route.fulfill({
|
||||||
|
contentType: 'image/svg+xml',
|
||||||
|
body: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><circle cx="12" cy="12" r="10" fill="#555"/></svg>',
|
||||||
|
});
|
||||||
|
if (path.endsWith('/requesters'))
|
||||||
|
return ok(route, {
|
||||||
|
requesters: ['openai-codex', 'openai'].map((name) => ({
|
||||||
|
name,
|
||||||
|
label: {
|
||||||
|
en_US: name === 'openai-codex' ? 'OpenAI Codex' : 'OpenAI API',
|
||||||
|
},
|
||||||
|
description: { en_US: '' },
|
||||||
|
spec: {
|
||||||
|
provider_category: 'manufacturer',
|
||||||
|
support_type: ['llm'],
|
||||||
|
config: [
|
||||||
|
{ name: 'base_url', default: 'https://api.openai.com/v1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
if (path.endsWith('/providers')) {
|
||||||
|
if (method === 'POST') {
|
||||||
|
state.creates++;
|
||||||
|
const provider = {
|
||||||
|
...route.request().postDataJSON(),
|
||||||
|
uuid: `provider-${state.creates}`,
|
||||||
|
};
|
||||||
|
state.providers.push(provider);
|
||||||
|
return ok(route, { uuid: provider.uuid });
|
||||||
|
}
|
||||||
|
return ok(route, { providers: state.providers });
|
||||||
|
}
|
||||||
|
if (path.endsWith('/codex/status'))
|
||||||
|
return ok(route, {
|
||||||
|
status: state.connected ? 'connected' : 'disconnected',
|
||||||
|
connected: state.connected,
|
||||||
|
expires_at: null,
|
||||||
|
});
|
||||||
|
if (path.endsWith('/codex/device') && method === 'POST') {
|
||||||
|
state.starts++;
|
||||||
|
if (state.failStart)
|
||||||
|
return route.fulfill({
|
||||||
|
status: 400,
|
||||||
|
json: { code: 400, msg: 'Fixture start failure' },
|
||||||
|
});
|
||||||
|
return ok(route, {
|
||||||
|
authorization_id: `attempt-${state.starts}`,
|
||||||
|
user_code: 'TEST-1234',
|
||||||
|
verification_uri: 'https://auth.openai.com/codex/device',
|
||||||
|
interval: state.interval,
|
||||||
|
expires_at: Date.now() / 1000 + state.expiresIn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path.endsWith('/codex/device/poll')) {
|
||||||
|
state.polls++;
|
||||||
|
expect(route.request().postDataJSON()).toEqual({
|
||||||
|
authorization_id: `attempt-${state.starts}`,
|
||||||
|
});
|
||||||
|
if (state.pollStatus === 'connected') state.connected = true;
|
||||||
|
return ok(route, { status: state.pollStatus, interval: state.interval });
|
||||||
|
}
|
||||||
|
if (path.includes('/codex/device/') && method === 'DELETE') {
|
||||||
|
state.cancels++;
|
||||||
|
return ok(route, {});
|
||||||
|
}
|
||||||
|
if (path.endsWith('/codex/auth') && method === 'DELETE') {
|
||||||
|
state.disconnects++;
|
||||||
|
state.connected = false;
|
||||||
|
return ok(route, {});
|
||||||
|
}
|
||||||
|
if (/\/providers\/provider-\d+$/.test(path)) {
|
||||||
|
const provider = state.providers.find((p) =>
|
||||||
|
path.endsWith(String(p.uuid)),
|
||||||
|
);
|
||||||
|
if (method === 'PUT')
|
||||||
|
Object.assign(provider!, route.request().postDataJSON());
|
||||||
|
return ok(route, { provider });
|
||||||
|
}
|
||||||
|
if (path.includes('/models/')) return ok(route, { models: [] });
|
||||||
|
return ok(route, {});
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openModels(page: Page) {
|
||||||
|
await page.goto('/home/bots');
|
||||||
|
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
|
||||||
|
}
|
||||||
|
async function choose(page: Page, name: string) {
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Select Provider Type', exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByRole('button', { name: new RegExp(name) }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const width of [1280, 390, 320]) {
|
||||||
|
test(`subscription sign-in in the existing provider dialog (${width}px, UI fixture)`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await fixture(page);
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await openModels(page);
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.locator('input[name="name"]').fill('My Codex');
|
||||||
|
await choose(page, 'OpenAI Codex');
|
||||||
|
await expect(page.locator('input[name="api_key"]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('input[name="base_url"]')).toHaveCount(0);
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Copy code', exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Copied', exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText('Copy Successfully', { exact: true }),
|
||||||
|
).toBeInViewport({ ratio: 1 });
|
||||||
|
expect(state.creates).toBe(1);
|
||||||
|
expect(state.providers[0]).toMatchObject({
|
||||||
|
requester: 'openai-codex',
|
||||||
|
api_keys: [],
|
||||||
|
base_url: 'https://chatgpt.com/backend-api/codex',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
page.getByRole('link', { name: 'Continue at OpenAI' }),
|
||||||
|
).toHaveAttribute('href', 'https://auth.openai.com/codex/device');
|
||||||
|
const geometry = await page.getByTestId('codex-account').evaluate((el) => {
|
||||||
|
const box = el.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
left: box.left,
|
||||||
|
right: box.right,
|
||||||
|
width: innerWidth,
|
||||||
|
documentWidth: document.documentElement.scrollWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(geometry.right).toBeLessThanOrEqual(width);
|
||||||
|
expect(geometry.documentWidth).toBeLessThanOrEqual(width);
|
||||||
|
if (process.env.CODEX_EVIDENCE_DIR) {
|
||||||
|
await page.locator('[data-sonner-toast]').evaluate(async (el) => {
|
||||||
|
await Promise.all(
|
||||||
|
el
|
||||||
|
.getAnimations({ subtree: true })
|
||||||
|
.map((animation) => animation.finished.catch(() => undefined)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const screenshot = `${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.png`;
|
||||||
|
await page.screenshot({ path: screenshot, fullPage: true });
|
||||||
|
writeFileSync(
|
||||||
|
`${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.json`,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
evidence: 'UI fixture only; not live OpenAI sign-in',
|
||||||
|
viewport: { width, height: 900 },
|
||||||
|
geometry,
|
||||||
|
screenshot,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state.pollStatus = 'connected';
|
||||||
|
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||||
|
await expect(page.getByText('My Codex', { exact: true })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Add Model', exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(state.creates).toBe(1);
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => JSON.stringify({ ...localStorage })),
|
||||||
|
).not.toContain('attempt-');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('failed start retries reuse saved provider; cancellation refreshes list', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await fixture(page);
|
||||||
|
state.failStart = true;
|
||||||
|
await openModels(page);
|
||||||
|
await page.locator('input[name="name"]').fill('Retry Codex');
|
||||||
|
await choose(page, 'OpenAI Codex');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByRole('alert')).toContainText('Unable to sign in');
|
||||||
|
state.failStart = false;
|
||||||
|
await page.getByRole('button', { name: 'Try again', exact: true }).click();
|
||||||
|
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Cancel sign-in', exact: true })
|
||||||
|
.click();
|
||||||
|
await expect.poll(() => state.cancels).toBe(1);
|
||||||
|
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
await expect(page.getByText('Retry Codex', { exact: true })).toBeVisible();
|
||||||
|
expect(state.creates).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconnect cancellation preserves connection and disconnect requires confirmation', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await fixture(page);
|
||||||
|
state.pollStatus = 'connected';
|
||||||
|
await openModels(page);
|
||||||
|
await page.locator('input[name="name"]').fill('Managed Codex');
|
||||||
|
await choose(page, 'OpenAI Codex');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
|
||||||
|
state.pollStatus = 'pending';
|
||||||
|
await page.getByRole('button', { name: 'Reconnect', exact: true }).click();
|
||||||
|
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Cancel sign-in', exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
|
||||||
|
expect(state.disconnects).toBe(0);
|
||||||
|
await page.getByRole('button', { name: 'Disconnect', exact: true }).click();
|
||||||
|
expect(state.disconnects).toBe(0);
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Confirm disconnect', exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByText('Not connected', { exact: true })).toBeVisible();
|
||||||
|
expect(state.disconnects).toBe(1);
|
||||||
|
expect(state.creates).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expiration permits retry without duplicate provider and closing cancels pending login', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await fixture(page);
|
||||||
|
state.expiresIn = 1;
|
||||||
|
await openModels(page);
|
||||||
|
await page.locator('input[name="name"]').fill('Expired Codex');
|
||||||
|
await choose(page, 'OpenAI Codex');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByText('Sign-in expired. Start again to get a new code.'),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect.poll(() => state.cancels).toBe(1);
|
||||||
|
state.expiresIn = 600;
|
||||||
|
await page.getByRole('button', { name: 'Try again', exact: true }).click();
|
||||||
|
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect.poll(() => state.cancels).toBe(2);
|
||||||
|
await expect(page.getByText('Expired Codex', { exact: true })).toBeVisible();
|
||||||
|
expect(state.creates).toBe(1);
|
||||||
|
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
|
||||||
|
await page.locator('input[name="name"]').fill('Second Codex');
|
||||||
|
await choose(page, 'OpenAI Codex');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||||
|
expect(state.creates).toBe(2);
|
||||||
|
expect(state.providers.map((provider) => provider.name)).toEqual([
|
||||||
|
'Expired Codex',
|
||||||
|
'Second Codex',
|
||||||
|
]);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect.poll(() => state.cancels).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('model test retains the connected provider identity', async ({ page }) => {
|
||||||
|
const state = await fixture(page);
|
||||||
|
state.connected = true;
|
||||||
|
state.providers.push({
|
||||||
|
uuid: 'provider-1',
|
||||||
|
name: 'Connected Codex',
|
||||||
|
requester: 'openai-codex',
|
||||||
|
base_url: 'https://chatgpt.com/backend-api/codex',
|
||||||
|
api_keys: [],
|
||||||
|
});
|
||||||
|
await page.goto('/home/bots');
|
||||||
|
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Add Model', exact: true }).click();
|
||||||
|
await page
|
||||||
|
.getByPlaceholder('Model Name', { exact: true })
|
||||||
|
.fill('fixture-codex-model');
|
||||||
|
const requestPromise = page.waitForRequest('**/models/llm/_/test');
|
||||||
|
await page.getByRole('button', { name: 'Test', exact: true }).click();
|
||||||
|
const payload = (await requestPromise).postDataJSON();
|
||||||
|
expect(payload.provider_uuid).toBe('provider-1');
|
||||||
|
expect(payload.provider.uuid).toBe('provider-1');
|
||||||
|
expect(payload.provider.api_keys).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary API-key provider still saves and closes', async ({ page }) => {
|
||||||
|
const state = await fixture(page);
|
||||||
|
await openModels(page);
|
||||||
|
await page.locator('input[name="name"]').fill('My API');
|
||||||
|
await choose(page, 'OpenAI API');
|
||||||
|
await page.locator('input[name="api_key"]').fill('fixture-api-key-not-real');
|
||||||
|
await page
|
||||||
|
.locator('input[name="base_url"]')
|
||||||
|
.fill('https://api.example.test/v1');
|
||||||
|
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||||
|
await expect(page.getByText('My API', { exact: true })).toBeVisible();
|
||||||
|
expect(state.providers[0]).toMatchObject({
|
||||||
|
requester: 'openai',
|
||||||
|
api_keys: ['fixture-api-key-not-real'],
|
||||||
|
base_url: 'https://api.example.test/v1',
|
||||||
|
});
|
||||||
|
expect(state.starts).toBe(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||||
|
|
||||||
|
// UI fixtures only: no real provider/model deletion or subscription authentication.
|
||||||
|
async function fixture(page: Page, requester = 'openai', empty = false) {
|
||||||
|
await installLangBotApiMocks(page, { authenticated: true });
|
||||||
|
const provider = {
|
||||||
|
uuid: 'provider-delete-fixture',
|
||||||
|
name: 'Delete fixture provider',
|
||||||
|
requester,
|
||||||
|
base_url: 'https://example.test/v1',
|
||||||
|
api_keys: [],
|
||||||
|
llm_count: empty ? 0 : 1,
|
||||||
|
embedding_count: empty ? 0 : 1,
|
||||||
|
rerank_count: empty ? 0 : 1,
|
||||||
|
};
|
||||||
|
const state = {
|
||||||
|
deleted: false,
|
||||||
|
fail: false,
|
||||||
|
deletes: [] as string[],
|
||||||
|
reads: [] as string[],
|
||||||
|
release: undefined as (() => void) | undefined,
|
||||||
|
hold: false,
|
||||||
|
};
|
||||||
|
const ok = (route: Route, data: unknown) =>
|
||||||
|
route.fulfill({ json: { code: 0, data } });
|
||||||
|
await page.route('**/api/v1/provider/**', async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const path = url.pathname;
|
||||||
|
const method = route.request().method();
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
state.deletes.push(path + url.search);
|
||||||
|
if (state.hold)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
state.release = resolve;
|
||||||
|
});
|
||||||
|
if (state.fail)
|
||||||
|
return route.fulfill({
|
||||||
|
status: 409,
|
||||||
|
json: { code: 409, msg: 'Fixture deletion blocked; try again.' },
|
||||||
|
});
|
||||||
|
state.deleted = true;
|
||||||
|
return ok(route, {});
|
||||||
|
}
|
||||||
|
if (path.endsWith('/icon'))
|
||||||
|
return route.fulfill({
|
||||||
|
contentType: 'image/svg+xml',
|
||||||
|
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||||
|
});
|
||||||
|
if (path.endsWith('/requesters'))
|
||||||
|
return ok(route, {
|
||||||
|
requesters: ['openai', 'openai-codex'].map((name) => ({
|
||||||
|
name,
|
||||||
|
label: { en_US: name },
|
||||||
|
description: { en_US: '' },
|
||||||
|
spec: {
|
||||||
|
provider_category: 'manufacturer',
|
||||||
|
support_type: ['llm', 'embedding', 'rerank'],
|
||||||
|
config: [],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
if (method === 'GET') state.reads.push(path + url.search);
|
||||||
|
if (path.endsWith('/providers'))
|
||||||
|
return ok(route, { providers: state.deleted ? [] : [provider] });
|
||||||
|
if (path.endsWith('/codex/status'))
|
||||||
|
return ok(route, {
|
||||||
|
status: 'connected',
|
||||||
|
connected: true,
|
||||||
|
expires_at: null,
|
||||||
|
});
|
||||||
|
if (path.includes('/models/')) {
|
||||||
|
const type = path.split('/').pop();
|
||||||
|
return ok(route, {
|
||||||
|
models: state.deleted
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
uuid: `fixture-${type}`,
|
||||||
|
name: `Fixture ${type} model`,
|
||||||
|
provider_uuid: provider.uuid,
|
||||||
|
provider,
|
||||||
|
abilities: [],
|
||||||
|
extra_args: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path.endsWith(provider.uuid)) return ok(route, { provider });
|
||||||
|
return ok(route, {});
|
||||||
|
});
|
||||||
|
await page.goto('/home/bots');
|
||||||
|
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const editDialog = (page: Page) =>
|
||||||
|
page.locator('[role="dialog"]').filter({
|
||||||
|
has: page.locator('[data-slot="dialog-title"]', {
|
||||||
|
hasText: /^Edit Provider$/,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
async function edit(page: Page) {
|
||||||
|
const card = page
|
||||||
|
.locator('[data-slot="card"]')
|
||||||
|
.filter({ hasText: 'Delete fixture provider' });
|
||||||
|
await card.getByRole('button', { name: 'Expand', exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
card.getByText('Fixture llm model', { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await card
|
||||||
|
.locator('button')
|
||||||
|
.filter({ has: page.locator('svg.lucide-settings') })
|
||||||
|
.click();
|
||||||
|
await expect(editDialog(page).locator('input[name="name"]')).toHaveValue(
|
||||||
|
'Delete fixture provider',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const width of [1280, 320]) {
|
||||||
|
test(`confirmation stays centered throughout entry (${width}px)`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await fixture(page);
|
||||||
|
await edit(page);
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
// Trigger without Playwright's post-click wait so the browser animation is
|
||||||
|
// still live. Sample its actual keyframes, not only the final screenshot.
|
||||||
|
await editDialog(page)
|
||||||
|
.getByRole('button', { name: 'Delete', exact: true })
|
||||||
|
.evaluate((el) => (el as HTMLButtonElement).click());
|
||||||
|
const confirmation = page.getByRole('alertdialog');
|
||||||
|
for (const phase of ['entry']) {
|
||||||
|
const samples = await confirmation.evaluate(async (el) => {
|
||||||
|
const animations = el.getAnimations();
|
||||||
|
if (!animations.length)
|
||||||
|
throw new Error('Expected the real dialog animation');
|
||||||
|
await Promise.all(animations.map((a) => a.ready));
|
||||||
|
animations.forEach((a) => a.pause());
|
||||||
|
const samples = [0, 0.25, 0.5, 0.75, 0.99].map((fraction) => {
|
||||||
|
animations.forEach((a) => {
|
||||||
|
a.currentTime = Number(a.effect!.getTiming().duration) * fraction;
|
||||||
|
});
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: r.x + r.width / 2,
|
||||||
|
y: r.y + r.height / 2,
|
||||||
|
left: r.left,
|
||||||
|
right: r.right,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
animations.forEach((a) => a.finish());
|
||||||
|
return samples;
|
||||||
|
});
|
||||||
|
for (const sample of samples) {
|
||||||
|
expect(
|
||||||
|
Math.abs(sample.x - width / 2),
|
||||||
|
`${phase} horizontal center`,
|
||||||
|
).toBeLessThan(1);
|
||||||
|
expect(
|
||||||
|
Math.abs(sample.y - 450),
|
||||||
|
`${phase} vertical center`,
|
||||||
|
).toBeLessThan(1);
|
||||||
|
expect(sample.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(sample.right).toBeLessThanOrEqual(width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await confirmation
|
||||||
|
.getByRole('button', { name: 'Cancel', exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(confirmation).toHaveCount(0);
|
||||||
|
expect(state.deletes).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const requester of ['openai', 'openai-codex']) {
|
||||||
|
for (const width of [1280, 320]) {
|
||||||
|
test(`footer deletion confirmation cancellation and geometry (${requester}, ${width}px)`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await fixture(page, requester);
|
||||||
|
await edit(page);
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
const dialog = editDialog(page);
|
||||||
|
const footer = dialog.locator('[data-slot="dialog-footer"]');
|
||||||
|
const remove = footer.getByRole('button', {
|
||||||
|
name: 'Delete',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(remove).toBeVisible();
|
||||||
|
for (const button of await footer.getByRole('button').all()) {
|
||||||
|
await expect(button).toBeInViewport({ ratio: 1 });
|
||||||
|
const box = await button.boundingBox();
|
||||||
|
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
|
||||||
|
}
|
||||||
|
const left = await remove.boundingBox();
|
||||||
|
const cancel = await footer
|
||||||
|
.getByRole('button', { name: 'Cancel', exact: true })
|
||||||
|
.boundingBox();
|
||||||
|
expect(left!.x + left!.width).toBeLessThan(cancel!.x);
|
||||||
|
await remove.click();
|
||||||
|
const confirmation = page.getByRole('alertdialog');
|
||||||
|
await expect(confirmation).toContainText('this provider and ALL models');
|
||||||
|
await expect(confirmation).toContainText('cannot be undone');
|
||||||
|
await expect(confirmation).toBeInViewport({ ratio: 1 });
|
||||||
|
await confirmation.evaluate(async (element) => {
|
||||||
|
await Promise.all(
|
||||||
|
element.getAnimations().map((animation) => animation.finished),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const box = await confirmation.boundingBox();
|
||||||
|
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
|
||||||
|
await confirmation
|
||||||
|
.getByRole('button', { name: 'Cancel', exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(confirmation).toHaveCount(0);
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
expect(state.deletes).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test(`one awaited cascade request refreshes providers and clears models (${requester})`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await fixture(page, requester);
|
||||||
|
await edit(page);
|
||||||
|
state.hold = true;
|
||||||
|
await editDialog(page)
|
||||||
|
.getByRole('button', { name: 'Delete', exact: true })
|
||||||
|
.click();
|
||||||
|
const confirmation = page.getByRole('alertdialog');
|
||||||
|
await confirmation
|
||||||
|
.getByRole('button', { name: 'Delete', exact: true })
|
||||||
|
.click();
|
||||||
|
await expect.poll(() => state.deletes.length).toBe(1);
|
||||||
|
await expect(
|
||||||
|
confirmation.getByRole('button', { name: 'Delete', exact: true }),
|
||||||
|
).toBeDisabled();
|
||||||
|
await expect(
|
||||||
|
confirmation.getByRole('button', { name: 'Cancel', exact: true }),
|
||||||
|
).toBeDisabled();
|
||||||
|
await expect(
|
||||||
|
editDialog(page).getByRole('button', {
|
||||||
|
name: requester === 'openai' ? 'Save' : 'Done',
|
||||||
|
exact: true,
|
||||||
|
includeHidden: true,
|
||||||
|
}),
|
||||||
|
).toBeDisabled();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(confirmation).toBeVisible();
|
||||||
|
state.reads = [];
|
||||||
|
state.release!();
|
||||||
|
await expect(editDialog(page)).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByText('Delete fixture provider', { exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByText('Fixture llm model', { exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
expect(state.deletes).toEqual([
|
||||||
|
'/api/v1/provider/providers/provider-delete-fixture?cascade=true',
|
||||||
|
]);
|
||||||
|
expect(state.reads).toContain('/api/v1/provider/providers');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('failed cascade retains readable error and can retry', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await fixture(page);
|
||||||
|
await edit(page);
|
||||||
|
state.fail = true;
|
||||||
|
await editDialog(page)
|
||||||
|
.getByRole('button', { name: 'Delete', exact: true })
|
||||||
|
.click();
|
||||||
|
const confirmation = page.getByRole('alertdialog');
|
||||||
|
await confirmation
|
||||||
|
.getByRole('button', { name: 'Delete', exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(confirmation.getByRole('alert')).toContainText(
|
||||||
|
'Fixture deletion blocked; try again.',
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
confirmation.getByRole('button', { name: 'Delete', exact: true }),
|
||||||
|
).toBeEnabled();
|
||||||
|
await expect(editDialog(page)).toBeVisible();
|
||||||
|
state.fail = false;
|
||||||
|
await confirmation
|
||||||
|
.getByRole('button', { name: 'Delete', exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(editDialog(page)).toHaveCount(0);
|
||||||
|
expect(state.deletes).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new providers do not expose footer deletion', async ({ page }) => {
|
||||||
|
const state = await fixture(page);
|
||||||
|
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole('dialog', { name: 'Add Provider', exact: true })
|
||||||
|
.getByRole('button', { name: 'Delete', exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
expect(state.deletes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('system-managed provider has no edit or delete entry', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await fixture(page, 'space-chat-completions');
|
||||||
|
const card = page
|
||||||
|
.locator('[data-slot="card"]')
|
||||||
|
.filter({ hasText: 'Delete fixture provider' });
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await expect(card.locator('svg.lucide-settings')).toHaveCount(0);
|
||||||
|
await expect(card.locator('svg.lucide-trash-2')).toHaveCount(0);
|
||||||
|
expect(state.deletes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('existing empty-provider card delete keeps its non-cascade request', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await fixture(page, 'openai', true);
|
||||||
|
const card = page
|
||||||
|
.locator('[data-slot="card"]')
|
||||||
|
.filter({ hasText: 'Delete fixture provider' });
|
||||||
|
await card
|
||||||
|
.locator('button')
|
||||||
|
.filter({ has: page.locator('svg.lucide-trash-2') })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByText('Are you sure you want to delete this provider?', {
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||||
|
await expect(card).toHaveCount(0);
|
||||||
|
expect(state.deletes).toEqual([
|
||||||
|
'/api/v1/provider/providers/provider-delete-fixture',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||||
|
|
||||||
|
// UI fixtures only: never authenticate or write a real provider.
|
||||||
|
test.use({ hasTouch: true });
|
||||||
|
for (const width of [1280, 390, 320]) {
|
||||||
|
test(`provider dropdown bounded without dialog growth (${width}px)`, async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await installLangBotApiMocks(page, { authenticated: true });
|
||||||
|
await page.route('**/api/v1/provider/**', async (route) => {
|
||||||
|
const path = new URL(route.request().url()).pathname;
|
||||||
|
if (path.endsWith('/icon'))
|
||||||
|
return route.fulfill({
|
||||||
|
contentType: 'image/svg+xml',
|
||||||
|
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||||
|
});
|
||||||
|
const data = path.endsWith('/requesters')
|
||||||
|
? {
|
||||||
|
requesters: Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
name: i === 0 ? 'openai-codex' : `provider-${i}`,
|
||||||
|
label: { en_US: i === 0 ? 'OpenAI Codex' : `Provider ${i}` },
|
||||||
|
description: { en_US: '' },
|
||||||
|
spec: {
|
||||||
|
provider_category: 'manufacturer',
|
||||||
|
config: [],
|
||||||
|
support_type: ['llm'],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: { providers: [], models: [] };
|
||||||
|
await route.fulfill({ json: { code: 0, data } });
|
||||||
|
});
|
||||||
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
|
await page.goto('/home/bots');
|
||||||
|
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Add Provider', exact: true })
|
||||||
|
.click();
|
||||||
|
await page.setViewportSize({ width, height: 720 });
|
||||||
|
const trigger = page.getByRole('button', {
|
||||||
|
name: 'Select Provider Type',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
const dialog = page
|
||||||
|
.locator('[role="dialog"]')
|
||||||
|
.filter({ has: page.locator('input[name="name"]') });
|
||||||
|
await trigger.scrollIntoViewIfNeeded();
|
||||||
|
const before = await dialog.evaluate((el) => ({
|
||||||
|
height: el.clientHeight,
|
||||||
|
scroll: el.scrollHeight,
|
||||||
|
}));
|
||||||
|
await trigger.click();
|
||||||
|
const search = page.getByPlaceholder('Search providers...');
|
||||||
|
await expect(search).toBeFocused();
|
||||||
|
const menu = search.locator('../..');
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Provider 29', exact: false }),
|
||||||
|
).toBeAttached();
|
||||||
|
await menu.evaluate(async (el) => {
|
||||||
|
await Promise.all(el.getAnimations().map((a) => a.finished));
|
||||||
|
});
|
||||||
|
const options = menu.locator(':scope > div').last();
|
||||||
|
await options.hover();
|
||||||
|
await page.mouse.wheel(0, 1200);
|
||||||
|
await expect
|
||||||
|
.poll(() => options.evaluate((el) => el.scrollTop))
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
if (width < 1280) {
|
||||||
|
await page.mouse.wheel(0, -1200);
|
||||||
|
await expect.poll(() => options.evaluate((el) => el.scrollTop)).toBe(0);
|
||||||
|
const box = (await options.boundingBox())!;
|
||||||
|
const session = await page.context().newCDPSession(page);
|
||||||
|
const x = box.x + box.width / 2;
|
||||||
|
const y = box.y + box.height - 30;
|
||||||
|
await session.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchStart',
|
||||||
|
touchPoints: [{ x, y }],
|
||||||
|
});
|
||||||
|
for (let step = 1; step <= 10; step++) {
|
||||||
|
await session.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchMove',
|
||||||
|
touchPoints: [{ x, y: y - step * 18 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await session.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchEnd',
|
||||||
|
touchPoints: [],
|
||||||
|
});
|
||||||
|
await session.detach();
|
||||||
|
await expect
|
||||||
|
.poll(() => options.evaluate((el) => el.scrollTop))
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
const geometry = await menu.evaluate((el) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const list = el.lastElementChild as HTMLElement;
|
||||||
|
const clipped: string[] = [];
|
||||||
|
for (
|
||||||
|
let parent = el.parentElement;
|
||||||
|
parent;
|
||||||
|
parent = parent.parentElement
|
||||||
|
) {
|
||||||
|
const bounds = parent.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
/(auto|scroll|hidden|clip)/.test(
|
||||||
|
getComputedStyle(parent).overflowY,
|
||||||
|
) &&
|
||||||
|
(rect.bottom > bounds.bottom + 1 || rect.top < bounds.top - 1)
|
||||||
|
)
|
||||||
|
clipped.push(parent.tagName);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
top: rect.top,
|
||||||
|
bottom: rect.bottom,
|
||||||
|
clipped,
|
||||||
|
listHeight: list.clientHeight,
|
||||||
|
listScroll: list.scrollHeight,
|
||||||
|
scrollTop: list.scrollTop,
|
||||||
|
documentWidth: document.documentElement.scrollWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const after = await dialog.evaluate((el) => ({
|
||||||
|
height: el.clientHeight,
|
||||||
|
scroll: el.scrollHeight,
|
||||||
|
}));
|
||||||
|
const dir = process.env.DROPDOWN_EVIDENCE_DIR || testInfo.outputDir;
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
await page.screenshot({
|
||||||
|
path: `${dir}/dropdown-${width}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
writeFileSync(
|
||||||
|
`${dir}/dropdown-${width}.json`,
|
||||||
|
JSON.stringify(
|
||||||
|
{ evidence: 'UI fixture only', width, before, after, geometry },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect.soft(after).toEqual(before);
|
||||||
|
expect.soft(geometry.clipped).toEqual([]);
|
||||||
|
expect.soft(geometry.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect.soft(geometry.right).toBeLessThanOrEqual(width);
|
||||||
|
expect.soft(geometry.top).toBeGreaterThanOrEqual(0);
|
||||||
|
expect.soft(geometry.bottom).toBeLessThanOrEqual(720);
|
||||||
|
expect.soft(geometry.documentWidth).toBeLessThanOrEqual(width);
|
||||||
|
expect(geometry.listScroll).toBeGreaterThan(geometry.listHeight);
|
||||||
|
expect(geometry.scrollTop).toBeGreaterThan(0);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(search).toBeHidden();
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(trigger).toBeFocused();
|
||||||
|
await trigger.click();
|
||||||
|
await search.fill('Provider 29');
|
||||||
|
await page.locator('input[name="name"]').click();
|
||||||
|
await expect(search).toBeHidden();
|
||||||
|
await expect(page.locator('input[name="name"]')).toBeFocused();
|
||||||
|
await trigger.click();
|
||||||
|
await expect(search).toHaveValue('');
|
||||||
|
await search.fill('Codex');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'OpenAI Codex', exact: false })
|
||||||
|
.click();
|
||||||
|
await expect(search).toBeHidden();
|
||||||
|
await expect(page.locator('input[name="api_key"]')).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Save and sign in', exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'OpenAI Codex', exact: false }),
|
||||||
|
).toBeFocused();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||||
|
|
||||||
|
// All API traffic is intercepted; no real provider secrets or mutations.
|
||||||
|
async function fixture(page: Page, requester = 'openai') {
|
||||||
|
await installLangBotApiMocks(page, { authenticated: true });
|
||||||
|
const providers = ['alpha', 'beta'].map((id) => ({
|
||||||
|
uuid: `loading-${id}`,
|
||||||
|
name: `Loading fixture ${id}`,
|
||||||
|
requester,
|
||||||
|
base_url: `https://${id}.example.test/v1`,
|
||||||
|
api_keys: [`fixture-key-${id}`],
|
||||||
|
llm_count: 0,
|
||||||
|
embedding_count: 0,
|
||||||
|
rerank_count: 0,
|
||||||
|
}));
|
||||||
|
const state = {
|
||||||
|
hold: '' as '' | 'detail' | 'requesters',
|
||||||
|
fail: '' as '' | 'detail' | 'requesters',
|
||||||
|
held: [] as { release: () => void; finished: Promise<void> }[],
|
||||||
|
reads: [] as string[],
|
||||||
|
mutations: [] as string[],
|
||||||
|
errors: [] as string[],
|
||||||
|
};
|
||||||
|
page.on('pageerror', (error) => state.errors.push(error.message));
|
||||||
|
const ok = (route: Route, data: unknown) =>
|
||||||
|
route.fulfill({ json: { code: 0, data } });
|
||||||
|
await page.route('**/api/v1/provider/**', async (route) => {
|
||||||
|
const path = new URL(route.request().url()).pathname;
|
||||||
|
if (route.request().method() !== 'GET') {
|
||||||
|
state.mutations.push(route.request().method() + ' ' + path);
|
||||||
|
return ok(route, {});
|
||||||
|
}
|
||||||
|
if (path.endsWith('/icon'))
|
||||||
|
return route.fulfill({
|
||||||
|
contentType: 'image/svg+xml',
|
||||||
|
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||||
|
});
|
||||||
|
state.reads.push(path);
|
||||||
|
const provider = providers.find((p) => path.endsWith('/' + p.uuid));
|
||||||
|
const dependency = path.endsWith('/requesters')
|
||||||
|
? 'requesters'
|
||||||
|
: provider
|
||||||
|
? 'detail'
|
||||||
|
: '';
|
||||||
|
const fail = dependency && state.fail === dependency;
|
||||||
|
let finish: (() => void) | undefined;
|
||||||
|
if (dependency && state.hold === dependency) {
|
||||||
|
const finished = new Promise<void>((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
});
|
||||||
|
await new Promise<void>((release) =>
|
||||||
|
state.held.push({ release, finished }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (fail)
|
||||||
|
return await route.fulfill({
|
||||||
|
status: 503,
|
||||||
|
json: { code: 503, msg: `Fixture ${dependency} unavailable` },
|
||||||
|
});
|
||||||
|
if (dependency === 'requesters')
|
||||||
|
return await ok(route, {
|
||||||
|
requesters: [
|
||||||
|
{
|
||||||
|
name: requester,
|
||||||
|
label: {
|
||||||
|
en_US:
|
||||||
|
requester === 'openai' ? 'OpenAI fixture' : 'Codex fixture',
|
||||||
|
},
|
||||||
|
description: { en_US: '' },
|
||||||
|
spec: {
|
||||||
|
provider_category: 'manufacturer',
|
||||||
|
support_type: ['llm'],
|
||||||
|
config: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (provider) return await ok(route, { provider });
|
||||||
|
if (path.endsWith('/providers')) return await ok(route, { providers });
|
||||||
|
if (path.endsWith('/codex/status'))
|
||||||
|
return await ok(route, {
|
||||||
|
status: 'connected',
|
||||||
|
connected: true,
|
||||||
|
expires_at: null,
|
||||||
|
});
|
||||||
|
return await ok(route, { models: [] });
|
||||||
|
} finally {
|
||||||
|
finish?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.goto('/home/bots');
|
||||||
|
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByText(providers[0].name, { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
// Let the panel's independent requester-support read finish before gating the form.
|
||||||
|
await expect
|
||||||
|
.poll(() => state.reads.filter((p) => p.endsWith('/requesters')).length)
|
||||||
|
.toBeGreaterThanOrEqual(1);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialog = (page: Page) =>
|
||||||
|
page.getByRole('dialog', { name: 'Edit Provider', exact: true });
|
||||||
|
const editButton = (page: Page, id = 'alpha') =>
|
||||||
|
page
|
||||||
|
.locator('[data-slot="card"]')
|
||||||
|
.filter({ hasText: `Loading fixture ${id}` })
|
||||||
|
.locator('button')
|
||||||
|
.filter({ has: page.locator('svg.lucide-settings') });
|
||||||
|
|
||||||
|
async function expectLoading(page: Page) {
|
||||||
|
const form = dialog(page);
|
||||||
|
await expect(form.getByRole('status')).toContainText('Loading...');
|
||||||
|
await expect(
|
||||||
|
form.getByRole('status').locator('svg.animate-spin'),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(form.locator('input')).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
form.getByRole('button', { name: /^(Save|Done|Delete)$/ }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
form.getByRole('button', { name: 'Cancel', exact: true }),
|
||||||
|
).toBeEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectReady(page: Page, id = 'alpha', requester = 'openai') {
|
||||||
|
const form = dialog(page);
|
||||||
|
await expect(form.locator('input[name="name"]')).toHaveValue(
|
||||||
|
`Loading fixture ${id}`,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
form.getByRole('status', { name: 'Loading...', exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
form.getByRole('button', { name: 'Delete', exact: true }),
|
||||||
|
).toBeEnabled();
|
||||||
|
await expect(
|
||||||
|
form.getByRole('button', {
|
||||||
|
name: requester === 'openai' ? 'Save' : 'Done',
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toBeEnabled();
|
||||||
|
if (requester === 'openai') {
|
||||||
|
await expect(form.locator('input[name="base_url"]')).toHaveValue(
|
||||||
|
`https://${id}.example.test/v1`,
|
||||||
|
);
|
||||||
|
await expect(form.locator('input[name="api_key"]')).toHaveValue(
|
||||||
|
`fixture-key-${id}`,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
form.getByRole('button', { name: /OpenAI fixture/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(form.locator('input[name="api_key"]')).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
form.getByRole('button', { name: /Codex fixture/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const requester of ['openai', 'openai-codex']) {
|
||||||
|
for (const dependency of ['detail', 'requesters'] as const) {
|
||||||
|
test(`edit waits for ${dependency} before showing populated ${requester} form`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await fixture(page, requester);
|
||||||
|
state.hold = dependency;
|
||||||
|
await editButton(page).click();
|
||||||
|
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
|
||||||
|
await expectLoading(page);
|
||||||
|
// Remain gated for the whole delay, not just the first render.
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
await expectLoading(page);
|
||||||
|
state.hold = '';
|
||||||
|
state.held.forEach((request) => request.release());
|
||||||
|
await expectReady(page, 'alpha', requester);
|
||||||
|
expect(state.mutations).toEqual([]);
|
||||||
|
expect(state.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dependency of ['detail', 'requesters'] as const) {
|
||||||
|
test(`${dependency} load failure is recoverable with Retry or Cancel`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await fixture(page);
|
||||||
|
state.fail = dependency;
|
||||||
|
await editButton(page).click();
|
||||||
|
const form = dialog(page);
|
||||||
|
await expect(form.getByRole('alert')).toContainText('Failed to load data');
|
||||||
|
await expect(form.locator('input')).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
form.getByRole('button', { name: /^(Save|Done|Delete)$/ }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
form.getByRole('button', { name: 'Retry', exact: true }),
|
||||||
|
).toBeEnabled();
|
||||||
|
await expect(
|
||||||
|
form.getByRole('button', { name: 'Cancel', exact: true }),
|
||||||
|
).toBeEnabled();
|
||||||
|
state.fail = '';
|
||||||
|
state.hold = dependency;
|
||||||
|
await form.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||||
|
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
|
||||||
|
await expectLoading(page);
|
||||||
|
state.hold = '';
|
||||||
|
state.held.forEach((request) => request.release());
|
||||||
|
await expectReady(page);
|
||||||
|
await form.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
await expect(form).toHaveCount(0);
|
||||||
|
state.fail = dependency;
|
||||||
|
await editButton(page).click();
|
||||||
|
await expect(form.getByRole('alert')).toBeVisible();
|
||||||
|
await form.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
await expect(form).toHaveCount(0);
|
||||||
|
expect(state.mutations).toEqual([]);
|
||||||
|
expect(state.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const next of ['alpha', 'beta']) {
|
||||||
|
for (const staleFailure of [false, true]) {
|
||||||
|
test(`closed request ${staleFailure ? 'failure' : 'success'} cannot affect reopened ${next}`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = await fixture(page);
|
||||||
|
state.hold = 'detail';
|
||||||
|
state.fail = staleFailure ? 'detail' : '';
|
||||||
|
await editButton(page).click();
|
||||||
|
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
|
||||||
|
await expectLoading(page);
|
||||||
|
const staleRequests = state.held.splice(0);
|
||||||
|
await dialog(page)
|
||||||
|
.getByRole('button', { name: 'Cancel', exact: true })
|
||||||
|
.click();
|
||||||
|
state.fail = '';
|
||||||
|
// Reopen during the closing animation, before Radix's retained content unmounts.
|
||||||
|
await editButton(page, next).dispatchEvent('click');
|
||||||
|
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
|
||||||
|
await expectLoading(page);
|
||||||
|
state.hold = '';
|
||||||
|
state.held.forEach((request) => request.release());
|
||||||
|
await expectReady(page, next);
|
||||||
|
await dialog(page)
|
||||||
|
.locator('input[name="name"]')
|
||||||
|
.fill('Unsaved fixture edit');
|
||||||
|
staleRequests.forEach((request) => request.release());
|
||||||
|
await Promise.all(staleRequests.map((request) => request.finished));
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
await expect(dialog(page).locator('input[name="name"]')).toHaveValue(
|
||||||
|
'Unsaved fixture edit',
|
||||||
|
);
|
||||||
|
await expect(dialog(page).getByRole('alert')).toHaveCount(0);
|
||||||
|
expect(state.mutations).toEqual([]);
|
||||||
|
expect(state.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||||
|
|
||||||
|
const resetEndpoint = '**/api/v1/user/reset-password';
|
||||||
|
const email = 'reset-password@example.com';
|
||||||
|
const newPassword = 'Regression-password-2026!';
|
||||||
|
const successMessage = 'Password reset successfully, please login';
|
||||||
|
const failureMessage =
|
||||||
|
'Password reset failed, please check your email and recovery key';
|
||||||
|
|
||||||
|
async function fillResetForm(page: Page, recoveryKey: string) {
|
||||||
|
await page.goto('/reset-password');
|
||||||
|
await page.getByPlaceholder('Enter email address').fill(email);
|
||||||
|
const recoveryInput = page.getByPlaceholder('Enter recovery key');
|
||||||
|
await recoveryInput.fill(recoveryKey);
|
||||||
|
await expect(recoveryInput).toHaveValue(recoveryKey);
|
||||||
|
await page.getByPlaceholder('Enter new password').fill(newPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await installLangBotApiMocks(page, { authenticated: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
const recoveryKeys = [
|
||||||
|
{ name: 'eight-character recovery code', value: '2A3B4C5D' },
|
||||||
|
{ name: 'six-character legacy recovery key', value: 'ABC123' },
|
||||||
|
{
|
||||||
|
name: '43-character mixed-case base64url recovery key',
|
||||||
|
value: 'aB-_'.repeat(10) + 'xYz',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, value } of recoveryKeys) {
|
||||||
|
test(`submits the ${name} verbatim and returns to login`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const requests: { method: string; body: unknown }[] = [];
|
||||||
|
await page.route(resetEndpoint, async (route) => {
|
||||||
|
requests.push({
|
||||||
|
method: route.request().method(),
|
||||||
|
body: route.request().postDataJSON(),
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
json: { code: 0, msg: 'ok', data: { user: email } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await fillResetForm(page, value);
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Reset Password', exact: true })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
await expect(page.getByText(successMessage, { exact: true })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: 'Login with password', exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(requests).toEqual([
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { user: email, recovery_key: value, new_password: newPassword },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await expect(page.getByText(failureMessage, { exact: true })).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('HTTP 429 shows failure, stays on reset-password, and reenables submission', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const recoveryKey = '2A3B4C5D';
|
||||||
|
const requests: { method: string; body: unknown }[] = [];
|
||||||
|
let releaseResponse!: () => void;
|
||||||
|
const responseGate = new Promise<void>((resolve) => {
|
||||||
|
releaseResponse = resolve;
|
||||||
|
});
|
||||||
|
await page.route(resetEndpoint, async (route) => {
|
||||||
|
requests.push({
|
||||||
|
method: route.request().method(),
|
||||||
|
body: route.request().postDataJSON(),
|
||||||
|
});
|
||||||
|
await responseGate;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 429,
|
||||||
|
json: { code: -1, msg: 'Too many attempts, try again later' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await fillResetForm(page, recoveryKey);
|
||||||
|
const submit = page.locator('button[type="submit"]');
|
||||||
|
await submit.click();
|
||||||
|
try {
|
||||||
|
await expect.poll(() => requests.length).toBe(1);
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
await expect(submit).toHaveText('Resetting...');
|
||||||
|
} finally {
|
||||||
|
releaseResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.getByText(failureMessage, { exact: true })).toBeVisible();
|
||||||
|
await expect(submit).toBeEnabled();
|
||||||
|
await expect(submit).toHaveText('Reset Password');
|
||||||
|
await expect(page).toHaveURL(/\/reset-password$/);
|
||||||
|
await expect(page.getByText(successMessage, { exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByPlaceholder('Enter recovery key')).toHaveValue(
|
||||||
|
recoveryKey,
|
||||||
|
);
|
||||||
|
expect(requests).toEqual([
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
user: email,
|
||||||
|
recovery_key: recoveryKey,
|
||||||
|
new_password: newPassword,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
|
test('all locale catalogs cover Codex states and preserve the expiry placeholder', () => {
|
||||||
|
const directory = new URL('../../src/i18n/locales/', import.meta.url);
|
||||||
|
let expected;
|
||||||
|
for (const file of fs.readdirSync(directory)) {
|
||||||
|
const compiled = ts.transpileModule(
|
||||||
|
fs.readFileSync(new URL(file, directory), 'utf8'),
|
||||||
|
{
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||||
|
},
|
||||||
|
).outputText;
|
||||||
|
const module = { exports: {} };
|
||||||
|
new Function('module', 'exports', compiled)(module, module.exports);
|
||||||
|
const catalog = (module.exports.default || Object.values(module.exports)[0])
|
||||||
|
.models.codex;
|
||||||
|
const keys = Object.keys(catalog).sort();
|
||||||
|
expected ??= keys;
|
||||||
|
assert.deepEqual(keys, expected, file);
|
||||||
|
assert.equal(keys.length, 26, file);
|
||||||
|
assert.ok(catalog.expiresAt.includes('{{time}}'), file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function policy() {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
new URL(
|
||||||
|
'../../src/app/home/components/models-dialog/component/provider-form/codexPolicy.ts',
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||||
|
}).outputText;
|
||||||
|
const module = { exports: {} };
|
||||||
|
new Function('module', 'exports', compiled)(module, module.exports);
|
||||||
|
return module.exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Codex payload discards previously entered API credentials and URL', () => {
|
||||||
|
const { providerPayload } = policy();
|
||||||
|
assert.deepEqual(
|
||||||
|
providerPayload({
|
||||||
|
name: 'Subscription',
|
||||||
|
requester: 'openai-codex',
|
||||||
|
base_url: 'https://proxy.invalid',
|
||||||
|
api_key: 'fixture-only',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'Subscription',
|
||||||
|
requester: 'openai-codex',
|
||||||
|
base_url: 'https://chatgpt.com/backend-api/codex',
|
||||||
|
api_keys: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary providers preserve API key and base URL behavior', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
policy().providerPayload({
|
||||||
|
name: 'API',
|
||||||
|
requester: 'openai',
|
||||||
|
base_url: 'https://api.example.test/v1',
|
||||||
|
api_key: 'fixture-only',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'API',
|
||||||
|
requester: 'openai',
|
||||||
|
base_url: 'https://api.example.test/v1',
|
||||||
|
api_keys: ['fixture-only'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('poll delay honors upstream minimum and transient backoff', () => {
|
||||||
|
const { pollDelay } = policy();
|
||||||
|
assert.equal(pollDelay(5, 0), 5000);
|
||||||
|
assert.equal(pollDelay(10, 2), 40000);
|
||||||
|
assert.equal(pollDelay(120, 3), 120000);
|
||||||
|
assert.equal(pollDelay(NaN, 0), 5000);
|
||||||
|
assert.equal(pollDelay(-1, 0), 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only the contracted OpenAI device authorization URL can be opened', () => {
|
||||||
|
const { isCodexVerificationUri } = policy();
|
||||||
|
assert.equal(
|
||||||
|
isCodexVerificationUri('https://auth.openai.com/codex/device'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
for (const url of [
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'https://auth.openai.com.evil.test/codex/device',
|
||||||
|
'https://evil.test',
|
||||||
|
'https://user@auth.openai.com/codex/device',
|
||||||
|
]) {
|
||||||
|
assert.equal(isCodexVerificationUri(url), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user