From 0f216a0d4da0674e1c61da9fafa0a1c8633f5df5 Mon Sep 17 00:00:00 2001 From: Hyu Date: Sun, 6 Sep 2026 23:31:02 +0800 Subject: [PATCH] feat(provider): support Codex subscriptions with ChatGPT sign-in (#2513) * feat(provider): support Codex subscriptions with ChatGPT sign-in * style: format Codex live integration test * fix(provider): preserve Codex identity in temporary model tests * fix(web): portal provider selector without dialog overflow * fix(web): allow native scrolling in provider dropdown * fix(provider): surface safe Codex quota and upstream errors * fix(web): provide reliable Codex copy feedback in dialogs * feat(provider): confirm cascade deletion from edit dialog * fix(persistence): discard connections after failed commit * fix(web): polish provider loading and confirmation motion --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- docs/CODEX_SUBSCRIPTION.md | 65 +++ skills/skills/langbot-mcp-ops/SKILL.md | 32 ++ src/langbot/pkg/api/http/controller/group.py | 3 + .../controller/groups/provider/providers.py | 84 ++- src/langbot/pkg/api/http/service/provider.py | 179 ++++-- src/langbot/pkg/entity/persistence/model.py | 22 + .../versions/0022_codex_credentials.py | 48 ++ src/langbot/pkg/persistence/mgr.py | 1 + src/langbot/pkg/persistence/tenant_uow.py | 34 +- .../pkg/provider/modelmgr/codex_auth.py | 420 ++++++++++++++ .../pkg/provider/modelmgr/codex_errors.py | 10 + src/langbot/pkg/provider/modelmgr/modelmgr.py | 36 +- .../pkg/provider/modelmgr/requesters/codex.py | 423 ++++++++++++++ .../provider/modelmgr/requesters/codex.yaml | 27 + tests/e2e/test_codex_provider.py | 106 ++++ tests/e2e/utils/process_manager.py | 2 +- .../persistence/test_codex_auth.py | 445 +++++++++++++++ .../persistence/test_migrations.py | 4 +- .../persistence/test_migrations_postgres.py | 2 + .../test_sqlite_migration_backup.py | 3 +- .../persistence/test_workspace_migration.py | 2 + .../service/test_tenant_resource_isolation.py | 12 +- .../api/service/test_provider_cascade.py | 401 +++++++++++++ .../api/service/test_provider_service.py | 145 ++--- .../core/test_e2e_coverage_config.py | 20 + .../test_tenant_uow_failed_commit.py | 115 ++++ tests/unit_tests/provider/test_codex.py | 200 +++++++ .../unit_tests/provider/test_codex_errors.py | 100 ++++ .../provider/test_codex_temporary_model.py | 147 +++++ .../components/models-dialog/ModelsPanel.tsx | 54 +- .../provider-form/CodexAccountSection.tsx | 194 +++++++ .../component/provider-form/ProviderForm.tsx | 525 +++++++++++++----- .../component/provider-form/codexPolicy.ts | 26 + .../component/provider-form/useCodexLogin.ts | 203 +++++++ web/src/app/infra/entities/codex.ts | 19 + web/src/app/infra/http/BackendClient.ts | 53 +- web/src/app/utils/clipboard.ts | 48 +- web/src/components/ui/alert-dialog.tsx | 2 +- web/src/i18n/locales/en-US.ts | 33 ++ web/src/i18n/locales/es-ES.ts | 34 ++ web/src/i18n/locales/ja-JP.ts | 34 ++ web/src/i18n/locales/ru-RU.ts | 33 ++ web/src/i18n/locales/th-TH.ts | 33 ++ web/src/i18n/locales/vi-VN.ts | 33 ++ web/src/i18n/locales/zh-Hans.ts | 33 ++ web/src/i18n/locales/zh-Hant.ts | 33 ++ web/tests/e2e/codex-copy-feedback.spec.ts | 150 +++++ web/tests/e2e/codex-subscription.spec.ts | 341 ++++++++++++ web/tests/e2e/provider-delete-footer.spec.ts | 340 ++++++++++++ web/tests/e2e/provider-dropdown.spec.ts | 177 ++++++ web/tests/e2e/provider-edit-loading.spec.ts | 262 +++++++++ web/tests/unit/codex-subscription.test.mjs | 102 ++++ 52 files changed, 5490 insertions(+), 360 deletions(-) create mode 100644 docs/CODEX_SUBSCRIPTION.md create mode 100644 src/langbot/pkg/persistence/alembic/versions/0022_codex_credentials.py create mode 100644 src/langbot/pkg/provider/modelmgr/codex_auth.py create mode 100644 src/langbot/pkg/provider/modelmgr/codex_errors.py create mode 100644 src/langbot/pkg/provider/modelmgr/requesters/codex.py create mode 100644 src/langbot/pkg/provider/modelmgr/requesters/codex.yaml create mode 100644 tests/e2e/test_codex_provider.py create mode 100644 tests/integration/persistence/test_codex_auth.py create mode 100644 tests/unit_tests/api/service/test_provider_cascade.py create mode 100644 tests/unit_tests/core/test_e2e_coverage_config.py create mode 100644 tests/unit_tests/persistence/test_tenant_uow_failed_commit.py create mode 100644 tests/unit_tests/provider/test_codex.py create mode 100644 tests/unit_tests/provider/test_codex_errors.py create mode 100644 tests/unit_tests/provider/test_codex_temporary_model.py create mode 100644 web/src/app/home/components/models-dialog/component/provider-form/CodexAccountSection.tsx create mode 100644 web/src/app/home/components/models-dialog/component/provider-form/codexPolicy.ts create mode 100644 web/src/app/home/components/models-dialog/component/provider-form/useCodexLogin.ts create mode 100644 web/src/app/infra/entities/codex.ts create mode 100644 web/tests/e2e/codex-copy-feedback.spec.ts create mode 100644 web/tests/e2e/codex-subscription.spec.ts create mode 100644 web/tests/e2e/provider-delete-footer.spec.ts create mode 100644 web/tests/e2e/provider-dropdown.spec.ts create mode 100644 web/tests/e2e/provider-edit-loading.spec.ts create mode 100644 web/tests/unit/codex-subscription.test.mjs diff --git a/docs/CODEX_SUBSCRIPTION.md b/docs/CODEX_SUBSCRIPTION.md new file mode 100644 index 000000000..b7324e404 --- /dev/null +++ b/docs/CODEX_SUBSCRIPTION.md @@ -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 工作空间内的资源,连接个人账号前请确认模型的使用范围。 diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index 7480f2b1a..95d2d756a 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -86,6 +86,38 @@ already have a default pipeline. 4. Use `list_*` tools to discover, then `get_*` / `create_*` / `update_*` / `delete_*` as needed. +## ChatGPT / Codex subscription providers + +`list_model_providers` can return the `openai-codex` requester. Its OAuth +credentials are server-only and are not provider API keys. Never ask a user +to paste ChatGPT access tokens, refresh tokens, or a Codex auth cache into an +MCP tool or model configuration. + +A human connects or disconnects the subscription through **Models → provider +settings** in the LangBot web UI. The provider-scoped `/codex/*` authentication +routes deliberately require a browser-user session and are not exposed as MCP +tools or authorized by a LangBot API key. Once connected, models are managed +and selected through the normal provider/model workflow. A disconnected +provider must be reauthorized; do not silently replace it with API-key billing. + +See [ChatGPT / Codex subscription](../../../docs/CODEX_SUBSCRIPTION.md) for setup, +usage limits, and the personal-account versus shared-service boundary. + +## Provider deletion + +The curated MCP surface currently lists providers but has no provider-deletion +tool. In the web UI, **Edit Provider → Delete** asks for confirmation before +removing that provider and all its LLM, embedding, and rerank models. This is +irreversible; never interpret a request to edit a provider as authorization to +delete it. + +The equivalent HTTP operation is +`DELETE /api/v1/provider/providers/{uuid}?cascade=true`, requiring +`resource.manage` in the authenticated Workspace. Omitting `cascade` preserves +the existing refusal to delete providers that still have models. Cloud-managed +providers remain protected. Cascade deletion removes stored Codex authorization +state as well; it is not the same operation as disconnecting an account. + ## Implementation & maintenance (for LangBot developers) - Server: `src/langbot/pkg/api/mcp/server.py` (FastMCP). Tools call the service diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py index 6459ecec7..d3346de87 100644 --- a/src/langbot/pkg/api/http/controller/group.py +++ b/src/langbot/pkg/api/http/controller/group.py @@ -15,6 +15,7 @@ from ....workspace.collaboration import MembershipPermissionError, WorkspaceColl from ....workspace.errors import WorkspaceNotFoundError from ....cloud.entitlements import EntitlementUnavailableError from ....core.errors import TaskCapacityError +from ....provider.modelmgr.codex_errors import CodexProviderError from ..authz import ( AuthenticationDeniedError, AuthorizationError, @@ -247,6 +248,8 @@ class RouterGroup(abc.ABC): return await f(*args, **kwargs) except Exception as e: # 自动 500 + if isinstance(e, CodexProviderError): + return self.http_status(e.status_code, e.error_code, str(e)) if isinstance(e, AuthorizationError): return self.http_status(e.status_code, e.error_code, str(e)) if isinstance(e, WorkspaceNotFoundError): diff --git a/src/langbot/pkg/api/http/controller/groups/provider/providers.py b/src/langbot/pkg/api/http/controller/groups/provider/providers.py index bf8a195ae..a7097745c 100644 --- a/src/langbot/pkg/api/http/controller/groups/provider/providers.py +++ b/src/langbot/pkg/api/http/controller/groups/provider/providers.py @@ -8,6 +8,80 @@ from ... import group @group.group_class('models/providers', '/api/v1/provider/providers') class ModelProvidersRouterGroup(group.RouterGroup): async def initialize(self) -> None: + # Subscription authorization is an interactive, browser-user-only surface. + @self.route( + '//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( + '//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( + '//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( + '//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( + '//codex/device/', + methods=['DELETE'], + auth_type=group.AuthType.USER_TOKEN, + permission=Permission.PROVIDER_SECRET_MANAGE, + ) + async def codex_cancel(provider_uuid: str, authorization_id: str, request_context: RequestContext): + try: + await self.ap.provider_service.codex_auth.cancel(request_context, provider_uuid, authorization_id) + return self.success() + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + @self.route( '', methods=['GET'], @@ -82,7 +156,15 @@ class ModelProvidersRouterGroup(group.RouterGroup): ) async def _(provider_uuid: str, request_context: RequestContext) -> str: try: - await self.ap.provider_service.delete_provider(request_context, provider_uuid) + cascade_values = quart.request.args.getlist('cascade') + if cascade_values: + if len(cascade_values) != 1 or cascade_values[0] not in ('true', 'false'): + return self.http_status(400, -1, 'cascade must be a single true or false value') + await self.ap.provider_service.delete_provider( + request_context, provider_uuid, cascade=cascade_values[0] == 'true' + ) + else: + await self.ap.provider_service.delete_provider(request_context, provider_uuid) return self.success() except ValueError as e: return self.http_status(400, -1, str(e)) diff --git a/src/langbot/pkg/api/http/service/provider.py b/src/langbot/pkg/api/http/service/provider.py index dc29d3858..b5909b404 100644 --- a/src/langbot/pkg/api/http/service/provider.py +++ b/src/langbot/pkg/api/http/service/provider.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import uuid import traceback @@ -7,8 +8,10 @@ import sqlalchemy from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER from ....core import app +from ....core.task_boundary import create_detached_task from ....entity.persistence import model as persistence_model from ....workspace.errors import WorkspaceNotFoundError +from ....provider.modelmgr.codex_auth import CodexAuth, REQUESTER as CODEX_REQUESTER, validate_config from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders from .tenant import TenantContext, require_workspace_uuid, scope_statement @@ -20,6 +23,8 @@ class ModelProviderService: def __init__(self, ap: app.Application) -> None: self.ap = ap + self.codex_auth = CodexAuth(ap) + self._deletion_tasks: set[asyncio.Task[None]] = set() def _is_cloud_runtime(self) -> bool: mode = getattr(self.ap.persistence_mgr, 'mode', None) @@ -116,14 +121,30 @@ class ModelProviderService: provider_data = provider_data.copy() if self._system_requester_is_reserved(provider_data.get('requester')): raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider') + validate_config(provider_data) provider_data['uuid'] = str(uuid.uuid4()) provider_data['workspace_uuid'] = require_workspace_uuid(context) provider_data['api_keys'] = self._normalize_api_keys( restore_secret_placeholders(provider_data.get('api_keys'), sensitive=True) ) - await self.ap.persistence_mgr.execute_async( - sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data) - ) + if provider_data.get('requester') == CODEX_REQUESTER: + async with self.ap.persistence_mgr.tenant_uow(provider_data['workspace_uuid']): + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data) + ) + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_model.CodexCredential).values( + workspace_uuid=provider_data['workspace_uuid'], + provider_uuid=provider_data['uuid'], + payload={}, + version=0, + lease_until=0, + ) + ) + else: + await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data) + ) # load to runtime runtime_provider = await self.ap.model_mgr.load_provider(context, provider_data) @@ -138,6 +159,17 @@ class ModelProviderService: raise ValueError('space-chat-completions is reserved for the Cloud-managed LangBot Models provider') provider_data.pop('uuid', None) provider_data.pop('workspace_uuid', None) + if {'requester', 'base_url', 'api_keys'} & provider_data.keys(): + current = await self.get_provider(context, provider_uuid, include_secret=True) + if current is None: + raise WorkspaceNotFoundError('Provider not found') + if CODEX_REQUESTER in (current.get('requester'), provider_data.get('requester')): + if provider_data.get('requester', current.get('requester')) != current.get('requester'): + raise ValueError('Create a separate provider to change the ChatGPT authentication type') + merged = {**current, **provider_data} + validate_config(merged) + provider_data['base_url'] = merged['base_url'] + provider_data['api_keys'] = [] if 'api_keys' in provider_data: submitted_keys = provider_data.get('api_keys') if contains_secret_placeholder(submitted_keys, sensitive=True): @@ -163,60 +195,107 @@ class ModelProviderService: raise WorkspaceNotFoundError('Provider not found') await self.ap.model_mgr.reload_provider(context, provider_uuid) - async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None: - """Delete a provider (only if no models reference it)""" - await self._assert_provider_mutable(context, provider_uuid) + async def delete_provider(self, context: TenantContext, provider_uuid: str, cascade: bool = False) -> None: + """Delete a provider, optionally deleting all its Workspace-scoped models.""" workspace_uuid = require_workspace_uuid(context) - # Check if any models use this provider - llm_result = await self.ap.persistence_mgr.execute_async( - scope_statement( - sqlalchemy.select(persistence_model.LLMModel).where( - persistence_model.LLMModel.provider_uuid == provider_uuid - ), - persistence_model.LLMModel, - workspace_uuid, - ) + persistence = self.ap.persistence_mgr + model_types = ( + (persistence_model.LLMModel, 'LLM', 'remove_llm_model'), + (persistence_model.EmbeddingModel, 'Embedding', 'remove_embedding_model'), + (persistence_model.RerankModel, 'Rerank', 'remove_rerank_model'), ) - if llm_result.first() is not None: - raise ValueError('Cannot delete provider: LLM models still reference it') - - embedding_result = await self.ap.persistence_mgr.execute_async( - scope_statement( - sqlalchemy.select(persistence_model.EmbeddingModel).where( - persistence_model.EmbeddingModel.provider_uuid == provider_uuid - ), - persistence_model.EmbeddingModel, - workspace_uuid, + deleted_models: list[tuple[str, list[str]]] = [] + async with persistence.tenant_uow(workspace_uuid): + # Check ownership before touching children. Lock the provider on PostgreSQL + # so concurrent model inserts cannot race the reference check/deletion. + provider_result = await persistence.execute_async( + scope_statement( + sqlalchemy.select(persistence_model.ModelProvider.requester) + .where(persistence_model.ModelProvider.uuid == provider_uuid) + .with_for_update(), + persistence_model.ModelProvider, + workspace_uuid, + ) ) - ) - if embedding_result.first() is not None: - raise ValueError('Cannot delete provider: Embedding models still reference it') + provider = provider_result.first() + if provider is None: + raise WorkspaceNotFoundError('Provider not found') + if self._system_requester_is_reserved(provider.requester): + raise ValueError('LangBot Models is managed by Cloud and cannot be modified') - rerank_result = await self.ap.persistence_mgr.execute_async( - scope_statement( - sqlalchemy.select(persistence_model.RerankModel).where( - persistence_model.RerankModel.provider_uuid == provider_uuid - ), - persistence_model.RerankModel, - workspace_uuid, + for model_type, label, remover in model_types: + result = await persistence.execute_async( + scope_statement( + sqlalchemy.select(model_type.uuid).where(model_type.provider_uuid == provider_uuid), + model_type, + workspace_uuid, + ) + ) + model_uuids = list(result.scalars()) + if model_uuids and not cascade: + raise ValueError(f'Cannot delete provider: {label} models still reference it') + if model_uuids: + # Model services have no pipeline/KB deletion side effects: they + # delete the scoped row and evict its runtime cache. Defer eviction + # here rather than calling those services before our commit. + await persistence.execute_async( + scope_statement( + sqlalchemy.delete(model_type).where(model_type.provider_uuid == provider_uuid), + model_type, + workspace_uuid, + ) + ) + deleted_models.append((remover, model_uuids)) + + # Explicit cleanup also works on legacy SQLite connections without FK + # enforcement; never load or serialize the private credential payload. + await persistence.execute_async( + scope_statement( + sqlalchemy.delete(persistence_model.CodexCredential).where( + persistence_model.CodexCredential.provider_uuid == provider_uuid + ), + persistence_model.CodexCredential, + workspace_uuid, + ) ) - ) - if rerank_result.first() is not None: - raise ValueError('Cannot delete provider: Rerank models still reference it') - - result = await self.ap.persistence_mgr.execute_async( - scope_statement( - sqlalchemy.delete(persistence_model.ModelProvider).where( - persistence_model.ModelProvider.uuid == provider_uuid - ), - persistence_model.ModelProvider, - workspace_uuid, + result = await persistence.execute_async( + scope_statement( + sqlalchemy.delete(persistence_model.ModelProvider).where( + persistence_model.ModelProvider.uuid == provider_uuid + ), + persistence_model.ModelProvider, + workspace_uuid, + ) ) - ) - if getattr(result, 'rowcount', None) == 0: - raise WorkspaceNotFoundError('Provider not found') + if result.rowcount == 0: + raise WorkspaceNotFoundError('Provider not found') - await self.ap.model_mgr.remove_provider(context, provider_uuid) + async def remove_runtime() -> None: + async with persistence.tenant_scope(workspace_uuid): + for remover, model_uuids in deleted_models: + for model_uuid in model_uuids: + await getattr(self.ap.model_mgr, remover)(context, model_uuid) + # This also closes the requester's HTTP client; models go first. + await self.ap.model_mgr.remove_provider(context, provider_uuid) + + if persistence.current_session() is None: + await remove_runtime() + else: + # A nested UoW has not committed yet. Reuse the rollback-cancelled gate + # and detached context boundary instead of evicting uncommitted data. + task = create_detached_task( + remove_runtime(), + after_commit_manager=persistence, + workspace_uuid=workspace_uuid, + ) + self._deletion_tasks.add(task) + + def completed(task: asyncio.Task[None]) -> None: + self._deletion_tasks.discard(task) + if not task.cancelled() and task.exception() is not None: + self.ap.logger.error('Failed to remove deleted provider runtime', exc_info=task.exception()) + + task.add_done_callback(completed) async def get_provider_model_counts(self, context: TenantContext, provider_uuid: str) -> dict: """Get count of models using this provider""" diff --git a/src/langbot/pkg/entity/persistence/model.py b/src/langbot/pkg/entity/persistence/model.py index 13cc178bb..1e133475a 100644 --- a/src/langbot/pkg/entity/persistence/model.py +++ b/src/langbot/pkg/entity/persistence/model.py @@ -33,6 +33,28 @@ class ModelProvider(Base): ) +class CodexCredential(Base): + """Server-only OAuth state. Never joined into provider/model serialization.""" + + __tablename__ = 'codex_credentials' + + provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True) + workspace_uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=False) + payload = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=dict) + version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0) + lease_owner = sqlalchemy.Column(sqlalchemy.String(64), nullable=True) + lease_until = sqlalchemy.Column(sqlalchemy.Float, nullable=False, default=0) + __table_args__ = ( + sqlalchemy.ForeignKeyConstraint( + ['workspace_uuid', 'provider_uuid'], + ['model_providers.workspace_uuid', 'model_providers.uuid'], + name='fk_codex_credentials_workspace_provider', + ondelete='CASCADE', + ), + sqlalchemy.Index('ix_codex_credentials_workspace', 'workspace_uuid'), + ) + + class LLMModel(Base): """LLM model""" diff --git a/src/langbot/pkg/persistence/alembic/versions/0022_codex_credentials.py b/src/langbot/pkg/persistence/alembic/versions/0022_codex_credentials.py new file mode 100644 index 000000000..9d22988cd --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0022_codex_credentials.py @@ -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') diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py index b29d6b44e..5d226675b 100644 --- a/src/langbot/pkg/persistence/mgr.py +++ b/src/langbot/pkg/persistence/mgr.py @@ -62,6 +62,7 @@ _ALEMBIC_TENANT_TABLES = { 'binary_storages', 'mcp_servers', 'model_providers', + 'codex_credentials', 'llm_models', 'embedding_models', 'rerank_models', diff --git a/src/langbot/pkg/persistence/tenant_uow.py b/src/langbot/pkg/persistence/tenant_uow.py index 0d21560ab..b361b1067 100644 --- a/src/langbot/pkg/persistence/tenant_uow.py +++ b/src/langbot/pkg/persistence/tenant_uow.py @@ -51,6 +51,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = { 'binary_storages': 'workspace_uuid', 'mcp_servers': 'workspace_uuid', 'model_providers': 'workspace_uuid', + 'codex_credentials': 'workspace_uuid', 'llm_models': 'workspace_uuid', 'embedding_models': 'workspace_uuid', 'rerank_models': 'workspace_uuid', @@ -852,7 +853,30 @@ class TenantScopedAsyncSession(sqlalchemy_asyncio.AsyncSession): self._require_owner_task() self._enter_internal_access() try: - await transaction.commit() + # Retain the actual connection before COMMIT: after a failed SQLite + # COMMIT the logical transaction is inactive, but the DBAPI writer + # can still hold PENDING/RESERVED locks. Session.close()/rollback() + # alone can then return that poisoned connection to the pool. + connection = await super().connection() + try: + await transaction.commit() + except BaseException as exc: + cleanup = asyncio.create_task(connection.invalidate()) + # Invalidation does not access the task-owned Session. Shield + # physical cleanup, including against repeated cancellation, + # before the owner closes the Session and releases its scope. + while not cleanup.done(): + try: + await asyncio.shield(cleanup) + except asyncio.CancelledError: + continue + except BaseException: + break + try: + cleanup.result() + except BaseException as cleanup_error: + exc.add_note(f'Failed to invalidate transaction connection: {cleanup_error!r}') + raise finally: self._exit_internal_access() @@ -1369,6 +1393,7 @@ class TenantUnitOfWork: state.mark_rollback_only(exc_value) rollback_only = state.rollback_only committed = False + transaction_error: BaseException | None = None try: if exc_type is None and not rollback_only: await typing.cast(TenantScopedAsyncSession, session)._commit_owned_transaction( @@ -1381,6 +1406,9 @@ class TenantUnitOfWork: _UOW_SESSION_CONTROL_CAPABILITY, transaction, ) + except BaseException as exc: + transaction_error = exc + raise finally: try: if self._active_transaction is not None and self._context_token is not None: @@ -1388,6 +1416,10 @@ class TenantUnitOfWork: await typing.cast(TenantScopedAsyncSession, session)._close_owned_session( _UOW_SESSION_CONTROL_CAPABILITY ) + except BaseException as cleanup_error: + if transaction_error is None: + raise + transaction_error.add_note(f'Failed to close transaction Session: {cleanup_error!r}') finally: if self._database_operation_token is not None: _DATABASE_OPERATION_TRANSACTION.reset(self._database_operation_token) diff --git a/src/langbot/pkg/provider/modelmgr/codex_auth.py b/src/langbot/pkg/provider/modelmgr/codex_auth.py new file mode 100644 index 000000000..571a67b40 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/codex_auth.py @@ -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 diff --git a/src/langbot/pkg/provider/modelmgr/codex_errors.py b/src/langbot/pkg/provider/modelmgr/codex_errors.py new file mode 100644 index 000000000..a7ceb0301 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/codex_errors.py @@ -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 diff --git a/src/langbot/pkg/provider/modelmgr/modelmgr.py b/src/langbot/pkg/provider/modelmgr/modelmgr.py index 16441cf18..e86f1761b 100644 --- a/src/langbot/pkg/provider/modelmgr/modelmgr.py +++ b/src/langbot/pkg/provider/modelmgr/modelmgr.py @@ -18,7 +18,7 @@ from ...discover import engine from ...entity.errors import provider as provider_errors from ...entity.persistence import model as persistence_model from ...workspace.entities import WorkspaceExecutionBinding -from ...workspace.errors import WorkspaceError, WorkspaceInvariantError +from ...workspace.errors import WorkspaceError, WorkspaceInvariantError, WorkspaceNotFoundError from . import requester, token @@ -638,10 +638,32 @@ class ModelManager: ) -> requester.RuntimeLLMModel: execution_context = await self.resolve_execution_context(context) provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid} - runtime_provider = await self._build_provider( - execution_context, - persistence_model.ModelProvider(**provider_info), - ) + provider_uuid = model_info.get('provider_uuid') or provider_info.get('uuid') + inline_codex = provider_info.get('requester') == 'openai-codex' + provider_entity = persistence_model.ModelProvider(**provider_info) + if provider_uuid: + if provider_info.get('uuid') and provider_info['uuid'] != provider_uuid: + raise ValueError('Conflicting provider identities') + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_model.ModelProvider).where( + persistence_model.ModelProvider.workspace_uuid == execution_context.workspace_uuid, + persistence_model.ModelProvider.uuid == provider_uuid, + ) + ) + saved_provider = result.first() + if saved_provider is None: + if inline_codex or model_info.get('provider_uuid'): + raise WorkspaceNotFoundError('Provider not found') + else: + saved_provider = self._coerce_provider(saved_provider, execution_context) + if saved_provider.requester == 'openai-codex': + # OAuth identity and transport configuration are server-owned. + provider_entity = saved_provider + elif inline_codex: + raise ValueError('This provider does not use ChatGPT sign-in') + elif inline_codex: + raise WorkspaceNotFoundError('Provider not found') + runtime_provider = await self._build_provider(execution_context, provider_entity) model_entity = persistence_model.LLMModel( workspace_uuid=execution_context.workspace_uuid, uuid=model_info.get('uuid', ''), @@ -723,6 +745,10 @@ class ModelManager: 'requester_name': provider_entity.requester, } + if provider_entity.requester == 'openai-codex': + config['provider_uuid'] = provider_entity.uuid + config['workspace_uuid'] = context.workspace_uuid + if litellm_provider: from .requesters import litellmchat diff --git a/src/langbot/pkg/provider/modelmgr/requesters/codex.py b/src/langbot/pkg/provider/modelmgr/requesters/codex.py new file mode 100644 index 000000000..0d19928b8 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/codex.py @@ -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 diff --git a/src/langbot/pkg/provider/modelmgr/requesters/codex.yaml b/src/langbot/pkg/provider/modelmgr/requesters/codex.yaml new file mode 100644 index 000000000..dd705c430 --- /dev/null +++ b/src/langbot/pkg/provider/modelmgr/requesters/codex.yaml @@ -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 diff --git a/tests/e2e/test_codex_provider.py b/tests/e2e/test_codex_provider.py new file mode 100644 index 000000000..59a57d7ea --- /dev/null +++ b/tests/e2e/test_codex_provider.py @@ -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 diff --git a/tests/e2e/utils/process_manager.py b/tests/e2e/utils/process_manager.py index 44c6719e5..c5052926a 100644 --- a/tests/e2e/utils/process_manager.py +++ b/tests/e2e/utils/process_manager.py @@ -69,7 +69,7 @@ class LangBotProcess: # Use coverage.py to collect coverage data # Set COVERAGE_PROCESS_START to enable coverage in subprocess self._coverage_file = self.work_dir / '.coverage.e2e' - env['COVERAGE_PROCESS_START'] = str(self.project_root / '.coveragerc') + env['COVERAGE_PROCESS_START'] = str(self.work_dir / '.coveragerc') env['COVERAGE_FILE'] = str(self._coverage_file) # Create .coveragerc for subprocess diff --git a/tests/integration/persistence/test_codex_auth.py b/tests/integration/persistence/test_codex_auth.py new file mode 100644 index 000000000..f0aae879a --- /dev/null +++ b/tests/integration/persistence/test_codex_auth.py @@ -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 diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py index 729683f9f..1c1674a4b 100644 --- a/tests/integration/persistence/test_migrations.py +++ b/tests/integration/persistence/test_migrations.py @@ -108,7 +108,7 @@ class TestSQLiteMigrationUpgrade: await run_alembic_upgrade(sqlite_engine, 'head') assert await get_alembic_current(sqlite_engine) == _get_script_head() - assert _get_script_head() == '0021_merge_reasoning_config' + assert _get_script_head() == '0022_codex_credentials' @pytest.mark.asyncio async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine): @@ -119,7 +119,7 @@ class TestSQLiteMigrationUpgrade: await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config') await run_alembic_upgrade(sqlite_engine, 'head') - assert await get_alembic_current(sqlite_engine) == '0021_merge_reasoning_config' + assert await get_alembic_current(sqlite_engine) == '0022_codex_credentials' @pytest.mark.asyncio async def test_upgrade_from_baseline_to_head(self, sqlite_engine): diff --git a/tests/integration/persistence/test_migrations_postgres.py b/tests/integration/persistence/test_migrations_postgres.py index 5b0502e1f..11af89c59 100644 --- a/tests/integration/persistence/test_migrations_postgres.py +++ b/tests/integration/persistence/test_migrations_postgres.py @@ -549,10 +549,12 @@ class TestPostgreSQLWorkspaceMigration: await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names()) ) assert 'workspaces' not in tables_before_migration + assert 'codex_credentials' not in tables_before_migration await manager._initialize_managed_schema() async with postgres_engine.connect() as conn: + assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names()) account = (await conn.execute(text('SELECT uuid, status, source FROM users'))).mappings().one() workspace = ( (await conn.execute(text('SELECT * FROM workspaces WHERE source = :source'), {'source': 'local'})) diff --git a/tests/integration/persistence/test_sqlite_migration_backup.py b/tests/integration/persistence/test_sqlite_migration_backup.py index 89fffdc89..5bf367553 100644 --- a/tests/integration/persistence/test_sqlite_migration_backup.py +++ b/tests/integration/persistence/test_sqlite_migration_backup.py @@ -5,6 +5,7 @@ import logging import os import pathlib import sqlite3 +from contextlib import closing import pytest import sqlalchemy as sa @@ -34,7 +35,7 @@ def _manifest_payloads(backup_directory) -> list[dict]: def _assert_verified_backup(payload: dict) -> None: backup_path = pathlib.Path(payload['backup_path']) - with sqlite3.connect(f'{backup_path.as_uri()}?mode=ro', uri=True) as connection: + with closing(sqlite3.connect(f'{backup_path.as_uri()}?mode=ro', uri=True)) as connection: assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)] assert connection.execute('SELECT version_num FROM alembic_version').fetchone()[0] == payload['source_revision'] diff --git a/tests/integration/persistence/test_workspace_migration.py b/tests/integration/persistence/test_workspace_migration.py index 2ebb0f1e8..454cf93a5 100644 --- a/tests/integration/persistence/test_workspace_migration.py +++ b/tests/integration/persistence/test_workspace_migration.py @@ -403,10 +403,12 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names()) ) assert 'workspaces' not in tables_before_migration + assert 'codex_credentials' not in tables_before_migration await manager._run_alembic_migrations() async with engine.connect() as conn: + assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names()) workspace = ( (await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one() ) diff --git a/tests/unit_tests/api/http/service/test_tenant_resource_isolation.py b/tests/unit_tests/api/http/service/test_tenant_resource_isolation.py index 0dc7bb5e2..7243d7a02 100644 --- a/tests/unit_tests/api/http/service/test_tenant_resource_isolation.py +++ b/tests/unit_tests/api/http/service/test_tenant_resource_isolation.py @@ -20,6 +20,7 @@ from langbot.pkg.entity.persistence.model import LLMModel, ModelProvider from langbot.pkg.entity.persistence.pipeline import LegacyPipeline from langbot.pkg.entity.persistence.workspace import Workspace from langbot.pkg.workspace.errors import WorkspaceNotFoundError +from langbot.pkg.persistence.mgr import PersistenceManager pytestmark = pytest.mark.asyncio @@ -28,15 +29,10 @@ WORKSPACE_A = '00000000-0000-0000-0000-00000000000a' WORKSPACE_B = '00000000-0000-0000-0000-00000000000b' -class _PersistenceManager: +class _PersistenceManager(PersistenceManager): def __init__(self, engine): - self.engine = engine - - async def execute_async(self, *args, **kwargs): - async with self.engine.connect() as connection: - result = await connection.execute(*args, **kwargs) - await connection.commit() - return result + super().__init__(SimpleNamespace()) + self.db = SimpleNamespace(get_engine=lambda: engine) @staticmethod def serialize_model(model, data, masked_columns=None): diff --git a/tests/unit_tests/api/service/test_provider_cascade.py b/tests/unit_tests/api/service/test_provider_cascade.py new file mode 100644 index 000000000..1fd58e98d --- /dev/null +++ b/tests/unit_tests/api/service/test_provider_cascade.py @@ -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) diff --git a/tests/unit_tests/api/service/test_provider_service.py b/tests/unit_tests/api/service/test_provider_service.py index fdf8b01bc..b99509e5b 100644 --- a/tests/unit_tests/api/service/test_provider_service.py +++ b/tests/unit_tests/api/service/test_provider_service.py @@ -14,11 +14,12 @@ Source: src/langbot/pkg/api/http/service/provider.py from __future__ import annotations import pytest +from contextlib import nullcontext from unittest.mock import AsyncMock, Mock from types import SimpleNamespace from langbot.pkg.api.http.service.provider import ModelProviderService -from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel, EmbeddingModel, RerankModel +from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel from langbot.pkg.workspace.errors import WorkspaceNotFoundError @@ -383,112 +384,35 @@ class TestModelProviderServiceUpdateProvider: class TestModelProviderServiceDeleteProvider: - """Tests for delete_provider method.""" - - async def test_delete_provider_with_llm_models_raises_error(self): - """Raises ValueError when LLM models reference provider.""" - # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - - # Mock LLM model exists - only return LLM result since that's first check - llm_result = _create_mock_result([], first_item=_create_mock_llm_model()) - - ap.persistence_mgr.execute_async = AsyncMock(return_value=llm_result) + """Fast guard coverage; real transaction/cache behavior is in test_provider_cascade.""" + @pytest.mark.parametrize('label', ['LLM', 'Embedding', 'Rerank', None]) + async def test_delete_provider_requires_no_references(self, label): + provider_result = Mock() + provider_result.first.return_value = SimpleNamespace(requester='openai') + results = [provider_result] + for model_label in ('LLM', 'Embedding', 'Rerank'): + result = Mock() + result.scalars.return_value = ['model'] if label == model_label else [] + results.append(result) + results.extend([Mock(rowcount=1), Mock(rowcount=1)]) + ap = SimpleNamespace( + persistence_mgr=SimpleNamespace( + execute_async=AsyncMock(side_effect=results), + tenant_uow=lambda _: nullcontext(), + tenant_scope=lambda _: nullcontext(), + current_session=lambda: None, + ), + model_mgr=SimpleNamespace(remove_provider=AsyncMock()), + ) service = ModelProviderService(ap) - - # Execute & Verify - with pytest.raises(ValueError, match='Cannot delete provider: LLM models'): - await service.delete_provider(WORKSPACE_UUID, 'provider-with-llm') - - async def test_delete_provider_with_embedding_models_raises_error(self): - """Raises ValueError when Embedding models reference provider.""" - # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - - # Create results for each check type - llm_result = Mock() - llm_result.first = Mock(return_value=None) # No LLM models - embedding_result = Mock() - embedding_result.first = Mock(return_value=Mock(spec=EmbeddingModel)) # Has embedding model - rerank_result = Mock() - rerank_result.first = Mock(return_value=None) - - call_count = 0 - - async def mock_execute(query): - nonlocal call_count - call_count += 1 - if call_count == 1: - return llm_result - elif call_count == 2: - return embedding_result - return rerank_result - - ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) - - service = ModelProviderService(ap) - - # Execute & Verify - should raise embedding error (LLM check passes, embedding check fails) - with pytest.raises(ValueError, match='Cannot delete provider: Embedding models'): - await service.delete_provider(WORKSPACE_UUID, 'provider-with-embedding') - - async def test_delete_provider_with_rerank_models_raises_error(self): - """Raises ValueError when Rerank models reference provider.""" - # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - - # Create results for each check type - llm_result = Mock() - llm_result.first = Mock(return_value=None) # No LLM models - embedding_result = Mock() - embedding_result.first = Mock(return_value=None) # No embedding models - rerank_result = Mock() - rerank_result.first = Mock(return_value=Mock(spec=RerankModel)) # Has rerank model - - call_count = 0 - - async def mock_execute(query): - nonlocal call_count - call_count += 1 - if call_count == 1: - return llm_result - elif call_count == 2: - return embedding_result - return rerank_result - - ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) - - service = ModelProviderService(ap) - - # Execute & Verify - should raise rerank error (LLM and embedding checks pass, rerank check fails) - with pytest.raises(ValueError, match='Cannot delete provider: Rerank models'): - await service.delete_provider(WORKSPACE_UUID, 'provider-with-rerank') - - async def test_delete_provider_no_models_success(self): - """Deletes provider when no models reference it.""" - # Setup - ap = SimpleNamespace() - ap.persistence_mgr = SimpleNamespace() - ap.model_mgr = SimpleNamespace() - ap.model_mgr.remove_provider = AsyncMock() - - # Mock no models reference provider - empty_result = Mock() - empty_result.first = Mock(return_value=None) - - ap.persistence_mgr.execute_async = AsyncMock(return_value=empty_result) - - service = ModelProviderService(ap) - - # Execute - await service.delete_provider(WORKSPACE_UUID, 'provider-no-models') - - # Verify - delete and remove called - ap.model_mgr.remove_provider.assert_called_once_with(WORKSPACE_UUID, 'provider-no-models') + if label is not None: + with pytest.raises(ValueError, match=f'Cannot delete provider: {label} models'): + await service.delete_provider(WORKSPACE_UUID, 'provider') + ap.model_mgr.remove_provider.assert_not_awaited() + else: + await service.delete_provider(WORKSPACE_UUID, 'provider') + ap.model_mgr.remove_provider.assert_awaited_once_with(WORKSPACE_UUID, 'provider') class TestModelProviderServiceGetProviderModelCounts: @@ -1045,15 +969,18 @@ class TestCloudManagedProviderProtection: async def test_cloud_rejects_update_and_delete_of_managed_provider(self): service = self._service() - service.get_provider = AsyncMock( - return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER} - ) + service.get_provider = AsyncMock(return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER}) with pytest.raises(ValueError, match='managed by Cloud'): await service.update_provider(WORKSPACE_UUID, 'system-provider', {'name': 'Renamed'}) + service.ap.persistence_mgr.execute_async.assert_not_awaited() + service.ap.persistence_mgr.tenant_uow = lambda _: nullcontext() + result = Mock() + result.first.return_value = SimpleNamespace(requester=SYSTEM_REQUESTER) + service.ap.persistence_mgr.execute_async.return_value = result with pytest.raises(ValueError, match='managed by Cloud'): await service.delete_provider(WORKSPACE_UUID, 'system-provider') - service.ap.persistence_mgr.execute_async.assert_not_awaited() + assert service.ap.persistence_mgr.execute_async.await_count == 1 async def test_oss_does_not_reserve_space_requester(self): ap = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='oss_compat'))) diff --git a/tests/unit_tests/core/test_e2e_coverage_config.py b/tests/unit_tests/core/test_e2e_coverage_config.py new file mode 100644 index 000000000..142c4b4b4 --- /dev/null +++ b/tests/unit_tests/core/test_e2e_coverage_config.py @@ -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] diff --git a/tests/unit_tests/persistence/test_tenant_uow_failed_commit.py b/tests/unit_tests/persistence/test_tenant_uow_failed_commit.py new file mode 100644 index 000000000..957892024 --- /dev/null +++ b/tests/unit_tests/persistence/test_tenant_uow_failed_commit.py @@ -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() diff --git a/tests/unit_tests/provider/test_codex.py b/tests/unit_tests/provider/test_codex.py new file mode 100644 index 000000000..14dbd6832 --- /dev/null +++ b/tests/unit_tests/provider/test_codex.py @@ -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) diff --git a/tests/unit_tests/provider/test_codex_errors.py b/tests/unit_tests/provider/test_codex_errors.py new file mode 100644 index 000000000..63ebd2ec7 --- /dev/null +++ b/tests/unit_tests/provider/test_codex_errors.py @@ -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) diff --git a/tests/unit_tests/provider/test_codex_temporary_model.py b/tests/unit_tests/provider/test_codex_temporary_model.py new file mode 100644 index 000000000..aaa7f3be3 --- /dev/null +++ b/tests/unit_tests/provider/test_codex_temporary_model.py @@ -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' diff --git a/web/src/app/home/components/models-dialog/ModelsPanel.tsx b/web/src/app/home/components/models-dialog/ModelsPanel.tsx index c8d8cfdda..0580cae3c 100644 --- a/web/src/app/home/components/models-dialog/ModelsPanel.tsx +++ b/web/src/app/home/components/models-dialog/ModelsPanel.tsx @@ -486,6 +486,7 @@ export default function ModelsPanel({ // Get the provider info const provider = providers.find((p) => p.uuid === providerUuid); const providerData = { + uuid: providerUuid, requester: provider?.requester || '', base_url: provider?.base_url || '', api_keys: provider?.api_keys || [], @@ -495,7 +496,7 @@ export default function ModelsPanel({ await httpClient.testLLMModel('_', { uuid: '', name, - provider_uuid: '', + provider_uuid: providerUuid, provider: providerData, abilities, reasoning_config: reasoningConfig, @@ -505,7 +506,7 @@ export default function ModelsPanel({ await httpClient.testEmbeddingModel('_', { uuid: '', name, - provider_uuid: '', + provider_uuid: providerUuid, provider: providerData, extra_args: extraArgsObj, } as never); @@ -513,7 +514,7 @@ export default function ModelsPanel({ await httpClient.testRerankModel('_', { uuid: '', name, - provider_uuid: '', + provider_uuid: providerUuid, provider: providerData, extra_args: extraArgsObj, } as never); @@ -536,6 +537,29 @@ export default function ModelsPanel({ expandedProviders.forEach((uuid) => loadProviderModels(uuid)); } + async function handleProviderDeleted(providerUuid: string) { + setProviders((prev) => + prev.filter((provider) => provider.uuid !== providerUuid), + ); + setProviderModels((prev) => { + const next = { ...prev }; + delete next[providerUuid]; + return next; + }); + setExpandedProviders((prev) => { + const next = new Set(prev); + next.delete(providerUuid); + return next; + }); + await Promise.all([ + loadProviders(), + ...Array.from(expandedProviders) + .filter((uuid) => uuid !== providerUuid) + .map((uuid) => loadProviderModels(uuid)), + ]); + setProviderFormOpen(false); + } + function renderProviderCard( provider: ModelProvider, isLangBotModels: boolean = false, @@ -666,8 +690,14 @@ export default function ModelsPanel({ )} - - + { + if (!open) handleFormClose(); + else setProviderFormOpen(true); + }} + > + {editingProviderId @@ -675,11 +705,15 @@ export default function ModelsPanel({ : t('models.addProvider')} - setProviderFormOpen(false)} - /> + {providerFormOpen && ( + + )} diff --git a/web/src/app/home/components/models-dialog/component/provider-form/CodexAccountSection.tsx b/web/src/app/home/components/models-dialog/component/provider-form/CodexAccountSection.tsx new file mode 100644 index 000000000..3857b5323 --- /dev/null +++ b/web/src/app/home/components/models-dialog/component/provider-form/CodexAccountSection.tsx @@ -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; + 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 | 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 ( +
+
+

{t('models.codex.account')}

+

+ {t('models.codex.description')} +

+
+

+ {t(`models.codex.${phase}`)} +

+ {device && phase === 'pending' && ( +
+

+ {t('models.codex.instructions')} +

+
+ + {device.user_code} + + +
+ {copyFailed && ( +

+ {t('models.codex.copyManually')} +

+ )} + + {t('models.codex.continueAtOpenAI')} + +

+ {t('models.codex.expiresAt', { + time: new Date(device.expires_at * 1000).toLocaleTimeString(), + })} +

+ {login.retrying && ( +

+ {t('models.codex.retrying')} +

+ )} + +
+ )} + {providerId && !waiting && phase !== 'pending' && ( +
+ {phase !== 'connected' && ( + + )} + {phase === 'connected' && ( + <> + + + + )} +
+ )} + {confirmDisconnect && phase === 'connected' && ( +
+

{t('models.codex.disconnectConfirm')}

+
+ + +
+
+ )} +
+ ); +} diff --git a/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx b/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx index 96816db83..0b07a6966 100644 --- a/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx +++ b/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef, useCallback } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -16,12 +16,31 @@ import { FormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; +import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { DialogFooter } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '../../types'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; import { toast } from 'sonner'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { CustomApiError } from '@/app/infra/entities/common'; import { cn } from '@/lib/utils'; import { Check, ChevronDown, Search } from 'lucide-react'; +import { providerPayload } from './codexPolicy'; +import { useCodexLogin } from './useCodexLogin'; +import CodexAccountSection from './CodexAccountSection'; const getFormSchema = (t: (key: string) => string) => z.object({ @@ -35,12 +54,14 @@ interface ProviderFormProps { providerId?: string; onFormSubmit: (providerUuid: string) => void | Promise; onFormCancel: () => void; + onProviderDeleted?: (providerUuid: string) => void | Promise; } export default function ProviderForm({ providerId, onFormSubmit, onFormCancel, + onProviderDeleted, }: ProviderFormProps) { const { t } = useTranslation(); const formSchema = getFormSchema(t); @@ -54,7 +75,31 @@ export default function ProviderForm({ api_key: '', }, }); - const { setValue } = form; + const { reset } = form; + const isCodex = form.watch('requester') === 'openai-codex'; + const [savedProviderId, setSavedProviderId] = useState(providerId); + const savedId = useRef(providerId); + const submitting = useRef(false); + const deleting = useRef(false); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [deleteError, setDeleteError] = useState(''); + const [mutableProviderLoaded, setMutableProviderLoaded] = useState(false); + const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>( + 'loading', + ); + const [loadAttempt, setLoadAttempt] = useState(0); + const mounted = useRef(true); + const login = useCodexLogin(isCodex, providerId); + const loginActive = ['starting', 'pending', 'canceling', 'loading'].includes( + login.phase, + ); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); const [requesterList, setRequesterList] = useState< { @@ -68,72 +113,59 @@ export default function ProviderForm({ >([]); const [searchQuery, setSearchQuery] = useState(''); const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); const searchInputRef = useRef(null); - const loadRequesters = useCallback(async () => { - const resp = await httpClient.getProviderRequesters(); - setRequesterList( - resp.requesters - .filter((item) => item.name !== 'space-chat-completions') - .map((item) => ({ - label: extractI18nObject(item.label), - value: item.name, - category: item.spec.provider_category || 'manufacturer', - defaultUrl: - item.spec.config - .find((c) => c.name === 'base_url') - ?.default?.toString() || '', - description: extractI18nObject(item.description), - alias: item.spec.alias || '', - })), - ); - }, []); - - const loadProvider = useCallback( - async (id: string) => { - const resp = await httpClient.getModelProvider(id); - const provider = resp.provider; - - setValue('name', provider.name); - setValue('requester', provider.requester); - setValue('base_url', provider.base_url); - setValue('api_key', provider.api_keys?.[0] || ''); - }, - [setValue], - ); - useEffect(() => { + // Ignore both success and failure from a closed form or superseded attempt. + let canceled = false; + setLoadState('loading'); + setMutableProviderLoaded(false); + async function init() { - await loadRequesters(); - if (providerId) { - await loadProvider(providerId); + try { + const [requesters, detail] = await Promise.all([ + httpClient.getProviderRequesters(), + providerId ? httpClient.getModelProvider(providerId) : null, + ]); + if (canceled) return; + setRequesterList( + requesters.requesters + .filter((item) => item.name !== LANGBOT_MODELS_PROVIDER_REQUESTER) + .map((item) => ({ + label: extractI18nObject(item.label), + value: item.name, + category: item.spec.provider_category || 'manufacturer', + defaultUrl: + item.spec.config + .find((c) => c.name === 'base_url') + ?.default?.toString() || '', + description: extractI18nObject(item.description), + alias: item.spec.alias || '', + })), + ); + if (detail) { + const provider = detail.provider; + reset({ + name: provider.name, + requester: provider.requester, + base_url: provider.base_url, + api_key: provider.api_keys?.[0] || '', + }); + setMutableProviderLoaded( + provider.uuid === providerId && + provider.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER, + ); + } + setLoadState('ready'); + } catch { + if (!canceled) setLoadState('error'); } } - init(); - }, [providerId, loadProvider, loadRequesters]); - - // Close dropdown when clicking outside - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - setSearchQuery(''); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - // Focus search input when dropdown opens - useEffect(() => { - if (isOpen && searchInputRef.current) { - searchInputRef.current.focus(); - } - }, [isOpen]); + void init(); + return () => { + canceled = true; + }; + }, [providerId, reset, loadAttempt]); // Filter requesters based on search query const filteredRequesters = requesterList.filter( @@ -163,29 +195,105 @@ export default function ProviderForm({ }; async function handleFormSubmit(values: z.infer) { - const data = { - name: values.name, - requester: values.requester, - base_url: values.base_url, - api_keys: values.api_key ? [values.api_key] : [], - }; - + if ( + loadState !== 'ready' || + submitting.current || + deleting.current || + (isCodex && loginActive) + ) + return; + submitting.current = true; + const data = providerPayload(values); try { - let savedProviderUuid = providerId; - if (providerId) { - await httpClient.updateModelProvider(providerId, data); - toast.success(t('models.providerSaved')); + if (savedId.current) { + await httpClient.updateModelProvider(savedId.current, data); } else { const response = await httpClient.createModelProvider(data); - savedProviderUuid = response.uuid; - toast.success(t('models.providerCreated')); + savedId.current = response.uuid; + if (mounted.current) setSavedProviderId(response.uuid); + } + if (!mounted.current) return; + if (isCodex && login.phase !== 'connected') { + await login.start(savedId.current); + } else { + toast.success(t('models.providerSaved')); + await onFormSubmit(savedId.current); } - await onFormSubmit(savedProviderUuid as string); } catch (err) { - toast.error(t('models.providerSaveError') + (err as CustomApiError).msg); + if (mounted.current) + toast.error( + t('models.providerSaveError') + (err as CustomApiError).msg, + ); + } finally { + submitting.current = false; } } + async function handleDelete() { + if ( + loadState !== 'ready' || + !providerId || + !mutableProviderLoaded || + !onProviderDeleted || + deleting.current || + submitting.current || + (isCodex && loginActive) + ) + return; + deleting.current = true; + setIsDeleting(true); + setDeleteError(''); + try { + await httpClient.deleteModelProvider(providerId, true); + } catch (err) { + const detail = + (err as CustomApiError | null)?.msg || + (err instanceof Error ? err.message : ''); + setDeleteError(t('models.providerDeleteError') + detail); + deleting.current = false; + setIsDeleting(false); + return; + } + toast.success(t('models.providerDeleted')); + await onProviderDeleted(providerId); + } + + if (loadState !== 'ready') { + return ( + <> + {loadState === 'loading' ? ( +
+ +
+ ) : ( +

+ {t('models.loadError')} +

+ )} + + {loadState === 'error' && ( + + )} + + + + ); + } + return (
* - + @@ -222,45 +335,65 @@ export default function ProviderForm({ {t('models.requester')} * -
+ { + setIsOpen(open); + if (!open) setSearchQuery(''); + }} + > {/* Trigger button */} - + > + {selectedRequester ? ( +
+ {selectedRequester.label} + {selectedRequester.label} +
+ ) : ( + + {t('models.selectRequester')} + + )} + + + - {/* Dropdown */} + {/* Unmount on close so an exiting layer cannot eat Dialog Escape. */} {isOpen && ( -
+ { + event.preventDefault(); + searchInputRef.current?.focus(); + }} + > {/* Search input */} -
+
{/* Options list */} -
+
event.stopPropagation()} + onTouchMove={(event) => event.stopPropagation()} + > {Object.entries(groupedRequesters).map( ([category, items]) => { if (items.length === 0) return null; @@ -288,6 +427,11 @@ export default function ProviderForm({
)}
-
+ )} -
+ {selectedRequester?.description && (

@@ -351,40 +495,131 @@ export default function ProviderForm({ }} /> - ( - - {t('models.requestURL')} - - - - - - )} - /> + {isCodex ? ( + + ) : ( + <> + ( + + {t('models.requestURL')} + + + + + + )} + /> - ( - - {t('models.apiKey')} - - - - - - )} - /> + ( + + {t('models.apiKey')} + + + + + + )} + /> + + )} - - - + + {providerId && mutableProviderLoaded && onProviderDeleted && ( + + )} +

+ {(!isCodex || !savedProviderId || login.phase === 'connected') && ( + + )} + +
+ { + if (!deleting.current) setDeleteConfirmOpen(open); + }} + > + {deleteConfirmOpen && ( + + + {t('common.delete')} + + {t('models.deleteProviderCascadeConfirmation')} + + + {deleteError && ( +

+ {deleteError} +

+ )} + + + {t('common.cancel')} + + + +
+ )} +
); diff --git a/web/src/app/home/components/models-dialog/component/provider-form/codexPolicy.ts b/web/src/app/home/components/models-dialog/component/provider-form/codexPolicy.ts new file mode 100644 index 000000000..579a5b3e8 --- /dev/null +++ b/web/src/app/home/components/models-dialog/component/provider-form/codexPolicy.ts @@ -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'; +} diff --git a/web/src/app/home/components/models-dialog/component/provider-form/useCodexLogin.ts b/web/src/app/home/components/models-dialog/component/provider-form/useCodexLogin.ts new file mode 100644 index 000000000..99175a526 --- /dev/null +++ b/web/src/app/home/components/models-dialog/component/provider-form/useCodexLogin.ts @@ -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('disconnected'); + const [device, setDevice] = useState(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 | undefined>(undefined); + const deadline = useRef | undefined>(undefined); + const request = useRef(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 }; +} diff --git a/web/src/app/infra/entities/codex.ts b/web/src/app/infra/entities/codex.ts new file mode 100644 index 000000000..92cf2e410 --- /dev/null +++ b/web/src/app/infra/entities/codex.ts @@ -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; +} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index c9d742c4c..4e742259d 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1,4 +1,9 @@ import { BaseHttpClient, type RequestConfig } from './BaseHttpClient'; +import type { + CodexAuthStatus, + CodexDeviceAuthorization, + CodexDevicePoll, +} from '@/app/infra/entities/codex'; import { ApiRespProviderRequesters, ApiRespProviderRequester, @@ -126,8 +131,52 @@ export class BackendClient extends BaseHttpClient { return this.put(`/api/v1/provider/providers/${uuid}`, provider); } - public deleteModelProvider(uuid: string): Promise { - return this.delete(`/api/v1/provider/providers/${uuid}`); + public deleteModelProvider(uuid: string, cascade = false): Promise { + return this.delete( + `/api/v1/provider/providers/${uuid}${cascade ? '?cascade=true' : ''}`, + ); + } + + public getCodexAuthStatus( + uuid: string, + signal?: AbortSignal, + ): Promise { + return this.get( + `/api/v1/provider/providers/${uuid}/codex/status`, + undefined, + { signal }, + ); + } + + public startCodexDeviceLogin( + uuid: string, + ): Promise { + return this.post(`/api/v1/provider/providers/${uuid}/codex/device`, {}); + } + + public pollCodexDeviceLogin( + uuid: string, + authorizationId: string, + signal?: AbortSignal, + ): Promise { + return this.post( + `/api/v1/provider/providers/${uuid}/codex/device/poll`, + { authorization_id: authorizationId }, + { signal }, + ); + } + + public cancelCodexDeviceLogin( + uuid: string, + authorizationId: string, + ): Promise { + return this.delete( + `/api/v1/provider/providers/${uuid}/codex/device/${encodeURIComponent(authorizationId)}`, + ); + } + + public disconnectCodex(uuid: string): Promise { + return this.delete(`/api/v1/provider/providers/${uuid}/codex/auth`); } public scanProviderModels( diff --git a/web/src/app/utils/clipboard.ts b/web/src/app/utils/clipboard.ts index 61fb7dfa2..11c3f5a9a 100644 --- a/web/src/app/utils/clipboard.ts +++ b/web/src/app/utils/clipboard.ts @@ -1,39 +1,39 @@ -/** - * Copy text to clipboard with fallback support - * Tries to use modern Clipboard API first, falls back to execCommand if not available - * - * @param text - The text to copy to clipboard - * @returns Promise - true if successful, false otherwise - */ +/** Copy text using the Clipboard API, with a focus-trap-safe legacy fallback. */ export async function copyToClipboard(text: string): Promise { - // Try modern Clipboard API first - if (navigator.clipboard && navigator.clipboard.writeText) { - try { + try { + if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; - } catch (err) { - console.error('[Clipboard] Modern API failed, trying fallback:', err); - // Fall through to legacy method } + } catch { + // Permission/security errors can include sensitive text; do not log them. } - // Fallback to legacy execCommand method + const previousFocus = document.activeElement as HTMLElement | null; + const textArea = document.createElement('textarea'); try { - const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; textArea.style.top = '-999999px'; - document.body.appendChild(textArea); - textArea.focus(); + // Radix modal focus scopes reject focus on elements appended to body. + const container = + previousFocus?.closest('[role="dialog"], [role="alertdialog"]') ?? + document.body; + container.appendChild(textArea); + textArea.focus({ preventScroll: true }); textArea.select(); - - const successful = document.execCommand('copy'); - document.body.removeChild(textArea); - - return successful; - } catch (err) { - console.error('[Clipboard] Fallback method failed:', err); + if ( + document.activeElement !== textArea || + textArea.selectionEnd !== text.length + ) + return false; + return document.execCommand('copy'); + } catch { return false; + } finally { + textArea.remove(); + if (previousFocus?.isConnected) + previousFocus.focus({ preventScroll: true }); } } diff --git a/web/src/components/ui/alert-dialog.tsx b/web/src/components/ui/alert-dialog.tsx index 82ed0ab9b..57a41a929 100644 --- a/web/src/components/ui/alert-dialog.tsx +++ b/web/src/components/ui/alert-dialog.tsx @@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef< console.error(error.message)); + await page.route('**/copy-harness', (route) => + route.fulfill({ + contentType: 'text/html', + body: ` +
`, + }), + ); + 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); +}); diff --git a/web/tests/e2e/codex-subscription.spec.ts b/web/tests/e2e/codex-subscription.spec.ts new file mode 100644 index 000000000..5a67867f9 --- /dev/null +++ b/web/tests/e2e/codex-subscription.spec.ts @@ -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[], + 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: '', + }); + 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); +}); diff --git a/web/tests/e2e/provider-delete-footer.spec.ts b/web/tests/e2e/provider-delete-footer.spec.ts new file mode 100644 index 000000000..49d696a9e --- /dev/null +++ b/web/tests/e2e/provider-delete-footer.spec.ts @@ -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((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: '', + }); + 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', + ]); +}); diff --git a/web/tests/e2e/provider-dropdown.spec.ts b/web/tests/e2e/provider-dropdown.spec.ts new file mode 100644 index 000000000..9424cc713 --- /dev/null +++ b/web/tests/e2e/provider-dropdown.spec.ts @@ -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: '', + }); + 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(); + }); +} diff --git a/web/tests/e2e/provider-edit-loading.spec.ts b/web/tests/e2e/provider-edit-loading.spec.ts new file mode 100644 index 000000000..e73a59196 --- /dev/null +++ b/web/tests/e2e/provider-edit-loading.spec.ts @@ -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 }[], + 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: '', + }); + 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((resolve) => { + finish = resolve; + }); + await new Promise((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([]); + }); + } +} diff --git a/web/tests/unit/codex-subscription.test.mjs b/web/tests/unit/codex-subscription.test.mjs new file mode 100644 index 000000000..6d8d9bbdf --- /dev/null +++ b/web/tests/unit/codex-subscription.test.mjs @@ -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); + } +});