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>
This commit is contained in:
Hyu
2026-09-06 23:31:02 +08:00
committed by GitHub
parent ec63978ecf
commit 0f216a0d4d
52 changed files with 5490 additions and 360 deletions
+65
View File
@@ -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 工作空间内的资源,连接个人账号前请确认模型的使用范围。
+32
View File
@@ -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
@@ -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):
@@ -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(
'/<provider_uuid>/codex/status',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_status(provider_uuid: str, request_context: RequestContext):
try:
return self.success(
data=await self.ap.provider_service.codex_auth.status(request_context, provider_uuid)
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/device',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_device(provider_uuid: str, request_context: RequestContext):
try:
return self.success(
data=await self.ap.provider_service.codex_auth.start(request_context, provider_uuid)
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/device/poll',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_poll(provider_uuid: str, request_context: RequestContext):
body = await quart.request.get_json()
if not isinstance(body, dict):
return self.http_status(400, -1, 'JSON object required')
try:
return self.success(
data=await self.ap.provider_service.codex_auth.poll(
request_context, provider_uuid, body.get('authorization_id')
)
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/auth',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_disconnect(provider_uuid: str, request_context: RequestContext):
try:
await self.ap.provider_service.codex_auth.disconnect(request_context, provider_uuid)
return self.success()
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'/<provider_uuid>/codex/device/<authorization_id>',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.PROVIDER_SECRET_MANAGE,
)
async def codex_cancel(provider_uuid: str, authorization_id: str, request_context: RequestContext):
try:
await self.ap.provider_service.codex_auth.cancel(request_context, provider_uuid, authorization_id)
return self.success()
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route(
'',
methods=['GET'],
@@ -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))
+129 -50
View File
@@ -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"""
@@ -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"""
@@ -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')
+1
View File
@@ -62,6 +62,7 @@ _ALEMBIC_TENANT_TABLES = {
'binary_storages',
'mcp_servers',
'model_providers',
'codex_credentials',
'llm_models',
'embedding_models',
'rerank_models',
+33 -1
View File
@@ -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)
@@ -0,0 +1,420 @@
"""ChatGPT device auth with server-only credentials and cross-process refresh leases.
Network I/O never holds a DB transaction. A persisted CAS lease serializes refresh
and poll; cancel fences device exchanges but waits for existing-token refreshes.
"""
from __future__ import annotations
import asyncio
import base64
import json
import math
import secrets
import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
import httpx
import sqlalchemy as sa
from ...entity.persistence.model import CodexCredential, ModelProvider
from ...api.http.context import PrincipalType, RequestContext
from ...api.http.authz import Permission, has_permission
from ...api.http.service.tenant import require_workspace_uuid
from ...workspace.errors import WorkspaceNotFoundError
REQUESTER = 'openai-codex'
BASE_URL = 'https://chatgpt.com/backend-api/codex'
ISSUER = 'https://auth.openai.com'
CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
LOGIN_REQUIRED = 'ChatGPT sign-in required. Open this provider and sign in again.'
LEASE_SECONDS = 90
def validate_config(data: dict) -> None:
if data.get('requester') != REQUESTER:
return
if data.get('base_url') not in (None, '', BASE_URL):
raise ValueError('Codex uses the fixed ChatGPT endpoint; custom base URLs are not supported')
if data.get('api_keys') not in (None, [], ''):
raise ValueError('Codex uses ChatGPT sign-in, not API keys')
data['base_url'] = BASE_URL
data['api_keys'] = []
def _claims(token: str) -> dict:
"""Read routing metadata, NOT trusted LangBot identity, from issuer tokens."""
try:
part = token.split('.')[1]
value = json.loads(base64.urlsafe_b64decode(part + '=' * (-len(part) % 4)))
return value if isinstance(value, dict) else {}
except (ValueError, IndexError, TypeError):
return {}
def _tokens(data: dict, previous: dict | None = None) -> dict:
previous = previous or {}
access = data.get('access_token')
refresh = data.get('refresh_token') or previous.get('refresh_token')
account = None
for token in (access, data.get('id_token')):
namespace = _claims(token or '').get('https://api.openai.com/auth', {})
if isinstance(namespace, dict) and isinstance(namespace.get('chatgpt_account_id'), str):
account = namespace['chatgpt_account_id']
break
account = account or previous.get('account_id')
try:
expires_at = (
time.time() + float(data['expires_in'])
if data.get('expires_in') is not None
else float(_claims(access or '').get('exp', 0))
)
except (TypeError, ValueError):
expires_at = 0
if (
not all(isinstance(v, str) and v for v in (access, refresh, account))
or not math.isfinite(expires_at)
or expires_at <= time.time()
):
raise ValueError('ChatGPT returned an incomplete authorization. Please sign in again.')
return {
'access_token': access,
'refresh_token': refresh,
'account_id': account,
'expires_at': expires_at,
'connection_id': previous.get('connection_id') or secrets.token_urlsafe(24),
}
class CodexAuth:
def __init__(self, ap):
self.ap = ap
def _where(self, workspace: str, provider: str):
return (CodexCredential.workspace_uuid == workspace, CodexCredential.provider_uuid == provider)
async def _execute(self, statement):
# SQLAlchemy/driver/serialization errors may embed the entire secret payload.
try:
return await self.ap.persistence_mgr.execute_async(statement)
except Exception:
raise ValueError('ChatGPT credential storage failed. Please retry.') from None
async def _read(self, workspace: str, provider: str) -> dict | None:
result = await self._execute(sa.select(CodexCredential).where(*self._where(workspace, provider)))
try:
row = result.first()
return dict(row._mapping) if row is not None else None
except Exception:
raise ValueError('ChatGPT credential storage failed. Please retry.') from None
async def _provider(self, context, provider: str, *, user: bool = False) -> str:
workspace = require_workspace_uuid(context)
if user and (
not isinstance(context, RequestContext)
or context.principal.principal_type != PrincipalType.ACCOUNT
or not context.account_uuid
or not has_permission(context, Permission.PROVIDER_SECRET_MANAGE)
):
raise ValueError('ChatGPT authorization requires an authorized workspace user')
result = await self._execute(
sa.select(ModelProvider.requester).where(
ModelProvider.workspace_uuid == workspace, ModelProvider.uuid == provider
)
)
kind = result.scalar()
if kind is None:
raise WorkspaceNotFoundError('Provider not found')
if kind != REQUESTER:
raise ValueError('This provider does not use ChatGPT sign-in')
return workspace
@asynccontextmanager
async def _lease(self, workspace: str, provider: str, *, refresh: bool = False):
owner = ('refresh:' if refresh else 'device:') + secrets.token_urlsafe(32)
deadline = time.monotonic() + 65
while True:
now = time.time()
result = await self._execute(
sa.update(CodexCredential)
.where(
*self._where(workspace, provider),
sa.or_(CodexCredential.lease_owner.is_(None), CodexCredential.lease_until < now),
)
.values(lease_owner=owner, lease_until=now + LEASE_SECONDS)
)
if result.rowcount == 1:
break
if await self._read(workspace, provider) is None:
raise ValueError(LOGIN_REQUIRED)
if time.monotonic() >= deadline:
raise ValueError('ChatGPT authorization is busy. Please retry shortly.')
await asyncio.sleep(0.1)
try:
yield owner
finally:
await self._execute(
sa.update(CodexCredential)
.where(*self._where(workspace, provider), CodexCredential.lease_owner == owner)
.values(lease_owner=None, lease_until=0)
)
async def _save(self, workspace: str, provider: str, owner: str, payload: dict) -> None:
result = await self._execute(
sa.update(CodexCredential)
.where(
*self._where(workspace, provider),
CodexCredential.lease_owner == owner,
CodexCredential.lease_until > time.time(),
)
.values(payload=payload, version=CodexCredential.version + 1)
)
if result.rowcount != 1:
raise ValueError('ChatGPT authorization was cancelled or replaced. Please retry.')
async def _post(self, path: str, *, data=None, json_body=None) -> httpx.Response:
try:
async with httpx.AsyncClient(timeout=20, follow_redirects=False) as client:
return await asyncio.wait_for(
client.post(
ISSUER + path,
data=data,
json=json_body,
headers={'Accept': 'application/json', 'User-Agent': 'LangBot'},
),
25,
)
except (httpx.HTTPError, TimeoutError):
raise ValueError('ChatGPT authorization network error. Please retry.') from None
@staticmethod
def _json(response: httpx.Response) -> dict:
try:
value = response.json()
if not isinstance(value, dict):
raise ValueError
return value
except ValueError:
raise ValueError('ChatGPT returned an invalid authorization response') from None
async def status(self, context, provider: str) -> dict:
workspace = await self._provider(context, provider, user=True)
row = await self._read(workspace, provider)
payload = row['payload'] if row else {}
tokens = payload.get('tokens')
connected = bool(tokens and not payload.get('invalid'))
return {
'status': 'connected' if connected else 'expired' if payload.get('invalid') else 'disconnected',
'connected': connected,
'expires_at': tokens.get('expires_at') if tokens else None,
}
async def start(self, context, provider: str) -> dict:
workspace = await self._provider(context, provider, user=True)
async with self._lease(workspace, provider) as owner:
response = await self._post('/api/accounts/deviceauth/usercode', json_body={'client_id': CLIENT_ID})
if response.status_code != 200:
raise ValueError('Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.')
data = self._json(response)
try:
code = data.get('user_code') or data['usercode']
device = data['device_auth_id']
interval = max(5, min(60, int(data.get('interval') or 5)))
if not isinstance(code, str) or not isinstance(device, str) or not code or not device:
raise ValueError
except (KeyError, ValueError, TypeError):
raise ValueError('ChatGPT returned an invalid device code') from None
now = time.time()
try:
expiry = data.get('expires_at')
if expiry is None:
expiry = now + float(data.get('expires_in', 900))
try:
expires_at = float(expiry)
except ValueError:
parsed = datetime.fromisoformat(expiry.replace('Z', '+00:00'))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
expires_at = parsed.timestamp()
if not math.isfinite(expires_at) or expires_at <= now:
raise ValueError
expires_at = min(now + 900, expires_at)
except (ValueError, TypeError):
raise ValueError('ChatGPT returned an invalid device code expiry') from None
pending = {
'authorization_id': secrets.token_urlsafe(32),
'user_code': code,
'device_auth_id': device,
'account_uuid': context.account_uuid,
'interval': interval,
'expires_at': expires_at,
'next_poll_at': now + interval,
}
row = await self._read(workspace, provider)
payload = dict(row['payload'])
payload['pending'] = pending
await self._save(workspace, provider, owner, payload)
return {k: pending[k] for k in ('authorization_id', 'user_code', 'interval', 'expires_at')} | {
'verification_uri': ISSUER + '/codex/device'
}
@staticmethod
def _attempt(payload: dict, context, authorization_id: str) -> dict | None:
pending = payload.get('pending')
if not pending or pending.get('authorization_id') != authorization_id:
return None
if pending.get('account_uuid') != context.account_uuid:
raise WorkspaceNotFoundError('Authorization not found')
return pending
async def poll(self, context, provider: str, authorization_id: str) -> dict:
workspace = await self._provider(context, provider, user=True)
if not isinstance(authorization_id, str) or not authorization_id:
raise ValueError('authorization_id is required')
async with self._lease(workspace, provider) as owner:
row = await self._read(workspace, provider)
payload = dict(row['payload'])
pending = self._attempt(payload, context, authorization_id)
if pending is None:
completed = payload.get('completed', {})
if (
completed.get('authorization_id') == authorization_id
and completed.get('account_uuid') == context.account_uuid
):
return {'status': 'connected'}
return {'status': 'expired'}
now = time.time()
if pending['expires_at'] <= now or pending.get('consumed'):
payload.pop('pending', None)
await self._save(workspace, provider, owner, payload)
return {'status': 'expired'}
if pending['next_poll_at'] > now:
return {'status': 'pending', 'interval': pending['interval']}
pending['next_poll_at'] = now + pending['interval']
await self._save(workspace, provider, owner, payload)
response = await self._post(
'/api/accounts/deviceauth/token',
json_body={'device_auth_id': pending['device_auth_id'], 'user_code': pending['user_code']},
)
if response.status_code in (403, 404, 429):
if response.status_code == 429:
pending['interval'] = min(60, pending['interval'] + 5)
pending['next_poll_at'] = time.time() + pending['interval']
await self._save(workspace, provider, owner, payload)
return {'status': 'pending', 'interval': pending['interval']}
if response.status_code != 200:
payload.pop('pending', None)
await self._save(workspace, provider, owner, payload)
raise ValueError('ChatGPT device authorization failed. Please start again.')
data = self._json(response)
if not data.get('authorization_code') or not data.get('code_verifier'):
payload.pop('pending', None)
await self._save(workspace, provider, owner, payload)
raise ValueError('ChatGPT returned an incomplete device authorization')
# Keep an attempt tombstone so cancel can preempt exchange, but never replay a code.
pending['consumed'] = True
await self._save(workspace, provider, owner, payload)
response = await self._post(
'/oauth/token',
data={
'grant_type': 'authorization_code',
'client_id': CLIENT_ID,
'code': data['authorization_code'],
'code_verifier': data['code_verifier'],
'redirect_uri': ISSUER + '/deviceauth/callback',
},
)
if response.status_code != 200:
raise ValueError('ChatGPT token exchange failed. Please start sign-in again.')
tokens = _tokens(self._json(response))
await self._save(
workspace,
provider,
owner,
{
'tokens': tokens,
'completed': {'authorization_id': authorization_id, 'account_uuid': context.account_uuid},
},
)
return {'status': 'connected'}
async def disconnect(self, context, provider: str) -> None:
workspace = await self._provider(context, provider, user=True)
await self._execute(
sa.update(CodexCredential)
.where(*self._where(workspace, provider))
.values(payload={}, lease_owner=None, lease_until=0, version=CodexCredential.version + 1)
)
async def cancel(self, context, provider: str, authorization_id: str) -> None:
workspace = await self._provider(context, provider, user=True)
deadline = time.monotonic() + 65
while time.monotonic() < deadline:
row = await self._read(workspace, provider)
if row is None:
return
old = row['payload']
if self._attempt(old, context, authorization_id) is None:
return
lease_owner = row['lease_owner']
if lease_owner and lease_owner.startswith('refresh:') and row['lease_until'] > time.time():
# A rotated refresh token must be committed before removing the attempt.
await asyncio.sleep(0.1)
continue
payload = dict(old)
payload.pop('pending', None)
result = await self._execute(
sa.update(CodexCredential)
.where(
*self._where(workspace, provider),
CodexCredential.version == row['version'],
# Lease acquisition does not change version; fence that race too.
CodexCredential.lease_owner == lease_owner,
)
.values(payload=payload, lease_owner=None, lease_until=0, version=CodexCredential.version + 1)
)
if result.rowcount == 1:
return
raise ValueError('Authorization changed concurrently. Please retry cancellation.')
async def access(self, context, provider: str, *, rejected_token: str | None = None) -> dict:
workspace = await self._provider(context, provider)
row = await self._read(workspace, provider)
payload = row['payload'] if row else {}
tokens = payload.get('tokens')
if not tokens or payload.get('invalid'):
raise ValueError(LOGIN_REQUIRED)
if tokens['expires_at'] > time.time() + 120 and tokens['access_token'] != rejected_token:
return tokens
async with self._lease(workspace, provider, refresh=True) as owner:
row = await self._read(workspace, provider)
payload = dict(row['payload'])
tokens = payload.get('tokens')
if not tokens or payload.get('invalid'):
raise ValueError(LOGIN_REQUIRED)
if tokens['expires_at'] > time.time() + 120 and tokens['access_token'] != rejected_token:
return tokens
response = await self._post(
'/oauth/token',
data={'grant_type': 'refresh_token', 'client_id': CLIENT_ID, 'refresh_token': tokens['refresh_token']},
)
error = self._json(response).get('error') if response.status_code in (400, 401, 403) else None
error_code = error.get('code') if isinstance(error, dict) else error
if error_code in (
'invalid_grant',
'refresh_token_reused',
'refresh_token_expired',
'refresh_token_revoked',
):
payload['invalid'] = True
payload.pop('tokens', None)
payload.pop('completed', None)
await self._save(workspace, provider, owner, payload)
raise ValueError(LOGIN_REQUIRED)
if response.status_code != 200:
raise ValueError('ChatGPT token refresh temporarily failed. Please retry.')
refreshed = _tokens(self._json(response), tokens)
payload['tokens'] = refreshed
await self._save(workspace, provider, owner, payload)
return refreshed
@@ -0,0 +1,10 @@
"""Explicitly safe Codex failures; never construct messages from upstream bodies."""
class CodexProviderError(ValueError):
"""A known provider failure safe to expose at the HTTP boundary."""
def __init__(self, message: str, status_code: int = 502, error_code: str = 'codex_upstream_failure'):
super().__init__(message)
self.status_code = status_code
self.error_code = error_code
+31 -5
View File
@@ -18,7 +18,7 @@ from ...discover import engine
from ...entity.errors import provider as provider_errors
from ...entity.persistence import model as persistence_model
from ...workspace.entities import WorkspaceExecutionBinding
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError
from ...workspace.errors import WorkspaceError, WorkspaceInvariantError, WorkspaceNotFoundError
from . import requester, token
@@ -638,10 +638,32 @@ class ModelManager:
) -> requester.RuntimeLLMModel:
execution_context = await self.resolve_execution_context(context)
provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
runtime_provider = await self._build_provider(
execution_context,
persistence_model.ModelProvider(**provider_info),
)
provider_uuid = model_info.get('provider_uuid') or provider_info.get('uuid')
inline_codex = provider_info.get('requester') == 'openai-codex'
provider_entity = persistence_model.ModelProvider(**provider_info)
if provider_uuid:
if provider_info.get('uuid') and provider_info['uuid'] != provider_uuid:
raise ValueError('Conflicting provider identities')
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.workspace_uuid == execution_context.workspace_uuid,
persistence_model.ModelProvider.uuid == provider_uuid,
)
)
saved_provider = result.first()
if saved_provider is None:
if inline_codex or model_info.get('provider_uuid'):
raise WorkspaceNotFoundError('Provider not found')
else:
saved_provider = self._coerce_provider(saved_provider, execution_context)
if saved_provider.requester == 'openai-codex':
# OAuth identity and transport configuration are server-owned.
provider_entity = saved_provider
elif inline_codex:
raise ValueError('This provider does not use ChatGPT sign-in')
elif inline_codex:
raise WorkspaceNotFoundError('Provider not found')
runtime_provider = await self._build_provider(execution_context, provider_entity)
model_entity = persistence_model.LLMModel(
workspace_uuid=execution_context.workspace_uuid,
uuid=model_info.get('uuid', ''),
@@ -723,6 +745,10 @@ class ModelManager:
'requester_name': provider_entity.requester,
}
if provider_entity.requester == 'openai-codex':
config['provider_uuid'] = provider_entity.uuid
config['workspace_uuid'] = context.workspace_uuid
if litellm_provider:
from .requesters import litellmchat
@@ -0,0 +1,423 @@
"""Native ChatGPT Codex Responses/SSE requester (never Chat Completions)."""
from __future__ import annotations
import asyncio
import json
import secrets
import time
from collections import OrderedDict
import httpx
import langbot
import langbot_plugin.api.entities.builtin.provider.message as pm
from .. import requester, reasoning
from ..codex_auth import BASE_URL, CodexAuth, LOGIN_REQUIRED
from ..codex_errors import CodexProviderError
async def sse_events(response):
"""Decode SSE records, including CRLF, comments, and multiline data."""
data = []
size = 0
async for line in response.aiter_lines():
if not line:
if data:
text = '\n'.join(data)
if text == '[DONE]':
return
try:
event = json.loads(text)
if not isinstance(event, dict):
raise ValueError
except ValueError:
raise ValueError('Codex returned an invalid stream event') from None
yield event
data, size = [], 0
elif line.startswith('data:'):
value = line[5:]
if value.startswith(' '):
value = value[1:]
size += len(value)
if size > 4 * 1024 * 1024:
raise ValueError('Codex stream event exceeds the size limit')
data.append(value)
# SSE requires the blank separator; unterminated records cannot prove completion.
def _content(message):
content = message.content
if isinstance(content, str):
return [{'type': 'output_text' if message.role == 'assistant' else 'input_text', 'text': content}]
result = []
for part in content or []:
if part.type == 'text':
result.append(
{'type': 'output_text' if message.role == 'assistant' else 'input_text', 'text': part.text or ''}
)
elif part.type == 'image_url' and part.image_url is not None:
result.append({'type': 'input_image', 'image_url': part.image_url.url})
elif part.type == 'image_base64' and part.image_base64:
value = part.image_base64
result.append(
{
'type': 'input_image',
'image_url': value if value.startswith('data:') else 'data:image/png;base64,' + value,
}
)
else:
raise ValueError('Codex supports text and images only; this message contains unsupported content')
return result
def _tool(item):
try:
return pm.ToolCall(
id=item['call_id'],
type='function',
function=pm.FunctionCall(name=item['name'], arguments=item.get('arguments') or ''),
)
except (KeyError, ValueError, TypeError):
raise ValueError('Codex returned an invalid function call') from None
def _usage(response):
usage = response.get('usage') or {}
return {
'prompt_tokens': usage.get('input_tokens', 0),
'completion_tokens': usage.get('output_tokens', 0),
'total_tokens': usage.get('total_tokens', usage.get('input_tokens', 0) + usage.get('output_tokens', 0)),
'prompt_tokens_details': usage.get('input_tokens_details', {}),
'completion_tokens_details': usage.get('output_tokens_details', {}),
}
class CodexRequester(requester.ProviderAPIRequester):
async def initialize(self):
self.auth = CodexAuth(self.ap)
self.workspace = self.requester_cfg['workspace_uuid']
self.provider = self.requester_cfg['provider_uuid']
# Opaque replay data stays server-side; handles are scoped to the same query,
# model and OAuth connection. No token or encrypted reasoning enters messages.
self._replay = OrderedDict()
async def aclose(self):
self._replay.clear()
def get_reasoning_capabilities(self, model):
return {
'supported': True,
'levels': ['provider_default', 'low', 'medium', 'high', 'xhigh'],
'source': 'provider',
}
@staticmethod
def _headers(tokens, *, stream=False):
return {
'Authorization': 'Bearer ' + tokens['access_token'],
'ChatGPT-Account-ID': tokens['account_id'],
'User-Agent': 'LangBot/' + langbot.__version__,
'originator': 'langbot',
'OpenAI-Beta': 'responses=experimental',
'Accept': 'text/event-stream' if stream else 'application/json',
}
@staticmethod
def _http_error(status):
if status == 401:
# Upstream authentication is not LangBot authentication: HTTP401 would
# make the browser discard its own valid user session.
return CodexProviderError(LOGIN_REQUIRED, 400, 'codex_reauthentication_required')
if status == 429:
return CodexProviderError(
'ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.',
429,
'codex_rate_limited',
)
if status == 403:
return CodexProviderError(
'ChatGPT denied this request. Check subscription and workspace permissions.',
403,
'codex_access_denied',
)
if status == 400:
return CodexProviderError(
'ChatGPT rejected the model or request. Check the selected model and request settings.',
400,
'codex_invalid_request',
)
return CodexProviderError('ChatGPT Codex upstream request failed. Please retry later.')
async def _response_error(self, response):
# Inspect only a bounded 429 error record and an allowlisted machine code.
# Never expose upstream prose, reset metadata, headers or credentials.
if response.status_code == 429:
payload = bytearray()
async for chunk in response.aiter_bytes():
if len(payload) + len(chunk) > 8192:
return self._http_error(429)
payload.extend(chunk)
try:
data = json.loads(payload)
error = data.get('error') if isinstance(data, dict) else None
if isinstance(error, dict) and (
error.get('type') == 'usage_limit_reached' or error.get('code') == 'usage_limit_reached'
):
return CodexProviderError(
'ChatGPT subscription usage limit reached. Please retry later or check your plan.',
429,
'codex_usage_limit_reached',
)
except (ValueError, UnicodeError):
pass
return self._http_error(response.status_code)
def _scope(self, query, model, tokens):
return (
id(query),
getattr(query, 'query_id', None),
model.model_entity.name,
tokens.get('connection_id'),
tokens['account_id'],
)
def _body(self, query, model, messages, funcs, extra_args, tokens):
args = {**(model.model_entity.extra_args or {}), **(extra_args or {})}
# Never permit credentials, transport overrides, store/history or arbitrary
# SDK kwargs to be smuggled through model advanced parameters.
allowed = {'reasoning', 'text', 'parallel_tool_calls', 'tool_choice'}
unknown = set(args) - allowed
if unknown:
raise ValueError('Unsupported Codex advanced parameters: ' + ', '.join(sorted(unknown)))
instructions = []
items = []
scope = self._scope(query, model, tokens)
for message in messages:
if message.role in ('system', 'developer'):
instructions.append('\n'.join(p['text'] for p in _content(message) if 'text' in p))
continue
if message.role == 'tool':
if not message.tool_call_id:
raise ValueError('Codex tool results require a tool_call_id')
output = (
message.content
if isinstance(message.content, str)
else json.dumps([p.model_dump(exclude_none=True) for p in message.content or []])
)
items.append({'type': 'function_call_output', 'call_id': message.tool_call_id, 'output': output or ''})
continue
if message.role not in ('assistant', 'user'):
raise ValueError('Unsupported Codex message role')
handle = (message.provider_specific_fields or {}).get('codex_replay_id')
cached = self._replay.get(handle) if isinstance(handle, str) else None
if query is not None and cached and cached[0] == scope and cached[1] > time.time():
items.extend(cached[2])
continue
content = _content(message)
if content:
items.append({'type': 'message', 'role': message.role, 'content': content})
for call in message.tool_calls or []:
items.append(
{
'type': 'function_call',
'call_id': call.id,
'name': call.function.name,
'arguments': call.function.arguments,
}
)
body = {
**args,
'model': model.model_entity.name,
'instructions': '\n\n'.join(instructions),
'input': items,
'store': False,
'stream': True,
'include': ['reasoning.encrypted_content'],
}
level = reasoning.normalize_reasoning_config(getattr(model.model_entity, 'reasoning_config', None))['level']
if level != 'provider_default':
reasoning.validate_reasoning_capabilities(
{'level': level}, self.get_reasoning_capabilities(model), model.model_entity.name
)
body['reasoning'] = {'effort': level, 'summary': 'auto'}
if funcs:
body['tools'] = [
{
'type': 'function',
'name': f.name,
'description': f.description,
'parameters': f.parameters,
'strict': False,
}
for f in funcs
]
return body
async def _events(self, query, model, messages, funcs, extra_args):
tokens = await self.auth.access(self.workspace, self.provider)
try:
async with asyncio.timeout(300), httpx.AsyncClient(timeout=120, follow_redirects=False) as client:
for attempt in range(2):
body = self._body(query, model, messages, funcs, extra_args, tokens)
async with client.stream(
'POST', BASE_URL + '/responses', json=body, headers=self._headers(tokens, stream=True)
) as response:
if response.status_code == 401 and attempt == 0:
tokens = await self.auth.access(
self.workspace, self.provider, rejected_token=tokens['access_token']
)
continue
if response.status_code != 200:
raise await self._response_error(response)
async for event in sse_events(response):
yield event, tokens
return
except (httpx.HTTPError, TimeoutError):
raise ValueError('ChatGPT Codex network error or timeout. Please retry.') from None
async def _chunks(self, query, model, messages, funcs, extra_args, remove_think, usage_out):
text = ''
seen_calls = set()
output_items = {}
response_id = None
async for event, tokens in self._events(query, model, messages, funcs, extra_args):
kind = event.get('type')
response = event.get('response') or {}
response_id = response.get('id') or response_id
if kind in ('error', 'response.failed', 'response.incomplete'):
raise CodexProviderError('ChatGPT Codex response failed or was incomplete. Please retry.')
if kind == 'response.output_text.delta':
delta = event.get('delta', '')
text += delta
yield pm.MessageChunk(role='assistant', content=delta, resp_message_id=response_id)
elif kind in ('response.reasoning_summary_text.delta', 'response.reasoning_text.delta'):
if not remove_think:
yield pm.MessageChunk(
role='assistant',
content='',
provider_specific_fields={'reasoning_content': event.get('delta', '')},
)
elif kind == 'response.output_item.done':
item = event.get('item') or {}
output_items[event.get('output_index', len(output_items))] = item
if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls:
seen_calls.add(item.get('call_id'))
yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)])
elif kind in ('response.completed', 'response.done'):
if response.get('status') not in (None, 'completed'):
raise CodexProviderError('ChatGPT Codex response was not completed')
output = response.get('output') or [output_items[k] for k in sorted(output_items)]
for item in output:
if item.get('type') == 'function_call' and item.get('call_id') not in seen_calls:
seen_calls.add(item.get('call_id'))
yield pm.MessageChunk(role='assistant', content='', tool_calls=[_tool(item)])
# Some servers send only the terminal output, without text deltas.
final_text = ''.join(
p.get('text', '')
for item in output
if item.get('type') == 'message'
for p in item.get('content', [])
if p.get('type') == 'output_text'
)
if not text and final_text:
text = final_text
yield pm.MessageChunk(role='assistant', content=text, resp_message_id=response_id)
usage_out.update(_usage(response))
if query is not None:
if query.variables is None:
query.variables = {}
query.variables[requester.STREAM_USAGE_QUERY_VARIABLE] = dict(usage_out)
fields = None
if query is not None and output:
handle = secrets.token_urlsafe(24)
self._replay[handle] = (self._scope(query, model, tokens), time.time() + 3600, output)
while len(self._replay) > 64:
self._replay.popitem(last=False)
fields = {'codex_replay_id': handle}
yield pm.MessageChunk(
role='assistant',
content='',
all_content=text,
is_final=True,
resp_message_id=response_id,
provider_specific_fields=fields,
)
return
raise CodexProviderError('ChatGPT Codex stream ended before completion. Please retry.')
async def invoke_llm_stream(self, query, model, messages, funcs=None, extra_args=None, remove_think=False):
async for chunk in self._chunks(query, model, messages, funcs, extra_args, remove_think, {}):
yield chunk
async def invoke_llm(self, query, model, messages, funcs=None, extra_args=None, remove_think=False):
usage = {}
text = ''
calls = []
fields = {}
response_id = None
async for chunk in self._chunks(query, model, messages, funcs, extra_args, remove_think, usage):
text += chunk.content or ''
calls.extend(chunk.tool_calls or [])
response_id = chunk.resp_message_id or response_id
for key, value in (chunk.provider_specific_fields or {}).items():
fields[key] = fields.get(key, '') + value if key == 'reasoning_content' else value
return pm.Message(
role='assistant',
content=text,
tool_calls=calls or None,
resp_message_id=response_id,
provider_specific_fields=fields or None,
), usage
async def scan_models(self, api_key=None):
tokens = await self.auth.access(self.workspace, self.provider)
try:
async with asyncio.timeout(90), httpx.AsyncClient(timeout=30, follow_redirects=False) as client:
for attempt in range(2):
response = await client.get(
BASE_URL + '/models',
params={'client_version': langbot.__version__},
headers=self._headers(tokens),
)
if response.status_code == 401 and attempt == 0:
tokens = await self.auth.access(
self.workspace, self.provider, rejected_token=tokens['access_token']
)
continue
if response.status_code != 200:
raise await self._response_error(response)
data = response.json()
if not isinstance(data, dict) or not isinstance(data.get('models'), list):
raise ValueError('ChatGPT returned an invalid model catalog')
result = {}
for item in data['models']:
name = item.get('slug') or item.get('id')
if not isinstance(name, str) or not name or item.get('visibility') == 'hide':
continue
modalities = item.get('input_modalities') or ['text']
abilities = ['func_call']
if 'image' in modalities:
abilities.append('vision')
if item.get('supported_reasoning_levels'):
abilities.append('reasoning')
result[name] = {
'id': name,
'name': name,
'type': 'llm',
'abilities': abilities,
'display_name': item.get('display_name'),
'description': item.get('description'),
'context_length': item.get('context_window'),
'input_modalities': modalities,
'output_modalities': ['text'],
'owned_by': 'openai',
}
return {'models': list(result.values()), 'debug': None}
except (httpx.HTTPError, TimeoutError):
raise ValueError('ChatGPT model discovery network error. Please retry.') from None
except (ValueError, TypeError, KeyError, AttributeError) as exc:
# Never echo upstream response bodies (which may contain credentials).
if isinstance(exc, ValueError) and str(exc).startswith(('ChatGPT', 'Codex')):
raise
raise ValueError('ChatGPT returned an invalid model catalog') from None
@@ -0,0 +1,27 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: openai-codex
label:
en_US: OpenAI Codex
zh_Hans: OpenAI Codex
ja_JP: OpenAI Codex
icon: openai.svg
spec:
config:
- name: base_url
label:
en_US: ChatGPT endpoint
zh_Hans: ChatGPT 服务地址
ja_JP: ChatGPT エンドポイント
type: string
required: false
default: https://chatgpt.com/backend-api/codex
alias: "openai codex ChatGPT subscription OAuth 订阅"
support_type:
- llm
provider_category: manufacturer
execution:
python:
path: ./codex.py
attr: CodexRequester
+106
View File
@@ -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
+1 -1
View File
@@ -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
@@ -0,0 +1,445 @@
"""Deterministic OAuth tests using real SQLite CAS writes, never live credentials."""
import asyncio
import base64
import json
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
import pytest_asyncio
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.api.http.authz import Permission
from langbot.pkg.api.http.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
from langbot.pkg.entity.persistence.model import CodexCredential
from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade
from langbot.pkg.provider.modelmgr.codex_auth import CodexAuth, _tokens, validate_config
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
def context(workspace='w', user='u', principal=PrincipalType.ACCOUNT, permitted=True):
return RequestContext(
'i',
0,
'r',
'user_token',
PrincipalContext(principal, account_uuid=user),
WorkspaceContext(
workspace, 'm', 'owner', frozenset({Permission.PROVIDER_SECRET_MANAGE} if permitted else set())
),
)
def jwt(**claims):
return 'test.' + base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip('=') + '.test'
def token_response(**extra):
return {
'access_token': jwt(**{'https://api.openai.com/auth': {'chatgpt_account_id': 'account'}}),
'refresh_token': 'refresh-secret',
'expires_in': 3600,
**extra,
}
@pytest_asyncio.fixture
async def auth(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "codex.db"}')
@sa.event.listens_for(engine.sync_engine, 'connect')
def foreign_keys(connection, _):
connection.execute('PRAGMA foreign_keys=ON')
async with engine.begin() as conn:
await conn.execute(
sa.text(
'CREATE TABLE model_providers (uuid VARCHAR(255) PRIMARY KEY, workspace_uuid VARCHAR(36) NOT NULL, requester TEXT, UNIQUE(workspace_uuid, uuid))'
)
)
await conn.execute(
sa.text(
"INSERT INTO model_providers VALUES ('p','w','openai-codex'), ('other','other','openai-codex'), ('api','w','openai-chat-completions')"
)
)
await run_alembic_stamp(engine, '0021_merge_reasoning_config')
await run_alembic_upgrade(engine, '0022_codex_credentials')
async def execute(statement):
async with engine.begin() as conn:
return await conn.execute(statement)
service = CodexAuth(SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=execute)))
service.engine = engine
await execute(
sa.insert(CodexCredential).values(provider_uuid='p', workspace_uuid='w', payload={}, version=0, lease_until=0)
)
try:
yield service
finally:
await engine.dispose()
async def seed(auth, payload):
await auth.ap.persistence_mgr.execute_async(sa.update(CodexCredential).values(payload=payload))
@pytest.mark.asyncio
async def test_migration_upgrade_repeat_fk_cascade(auth):
await run_alembic_upgrade(auth.engine, '0022_codex_credentials')
await run_alembic_stamp(auth.engine, '0021_merge_reasoning_config')
await run_alembic_upgrade(auth.engine, '0022_codex_credentials')
with pytest.raises(sa.exc.IntegrityError):
await auth.ap.persistence_mgr.execute_async(
sa.insert(CodexCredential).values(
provider_uuid='other', workspace_uuid='w', payload={}, version=0, lease_until=0
)
)
await auth.ap.persistence_mgr.execute_async(sa.text("DELETE FROM model_providers WHERE uuid='p'"))
assert await auth._read('w', 'p') is None
from langbot.pkg.persistence.alembic_runner import run_alembic_downgrade
await run_alembic_downgrade(auth.engine, '0021_merge_reasoning_config')
async with auth.engine.connect() as conn:
assert 'codex_credentials' not in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
await run_alembic_upgrade(auth.engine, '0022_codex_credentials')
async with auth.engine.connect() as conn:
assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
@pytest.mark.asyncio
async def test_device_pacing_exchange_secrecy_and_user_binding(auth):
auth._post = AsyncMock(
side_effect=[
httpx.Response(200, json={'device_auth_id': 'device-secret', 'usercode': 'CODE', 'interval': '5'}),
httpx.Response(200, json={'authorization_code': 'code-secret', 'code_verifier': 'verifier-secret'}),
httpx.Response(200, json=token_response()),
]
)
start = await auth.start(context(), 'p')
assert set(start) == {'authorization_id', 'user_code', 'interval', 'expires_at', 'verification_uri'}
assert 'device-secret' not in json.dumps(start)
attempt = start['authorization_id']
with pytest.raises(WorkspaceNotFoundError):
await auth.poll(context(user='attacker'), 'p', attempt)
assert (await auth.poll(context(), 'p', attempt))['status'] == 'pending'
assert auth._post.await_count == 1
row = await auth._read('w', 'p')
row['payload']['pending']['next_poll_at'] = 0
await seed(auth, row['payload'])
assert await auth.poll(context(), 'p', attempt) == {'status': 'connected'}
assert await auth.poll(context(), 'p', attempt) == {'status': 'connected'}
exchange = auth._post.call_args.kwargs['data']
assert exchange['grant_type'] == 'authorization_code'
assert exchange['redirect_uri'] == 'https://auth.openai.com/deviceauth/callback'
assert exchange['code_verifier'] == 'verifier-secret'
status = await auth.status(context(), 'p')
assert set(status) == {'status', 'connected', 'expires_at'}
assert 'secret' not in json.dumps(status)
await auth.disconnect(context(), 'p')
assert (await auth._read('w', 'p'))['payload'] == {}
@pytest.mark.asyncio
@pytest.mark.parametrize(
'ctx,provider,error',
[
(context('other'), 'p', WorkspaceNotFoundError),
(context(principal=PrincipalType.API_KEY), 'p', ValueError),
(context(permitted=False), 'p', ValueError),
(context(), 'api', ValueError),
],
)
async def test_auth_tenant_principal_permission_guards(auth, ctx, provider, error):
auth._post = AsyncMock()
with pytest.raises(error):
await auth.start(ctx, provider)
auth._post.assert_not_called()
@pytest.mark.asyncio
async def test_refresh_cross_instance_single_flight_and_rotation(auth):
old = _tokens(token_response())
old['expires_at'] = 0
await seed(auth, {'tokens': old})
entered, release = asyncio.Event(), asyncio.Event()
async def refresh(*args, **kwargs):
entered.set()
await release.wait()
return httpx.Response(200, json=token_response(refresh_token='rotated-secret'))
auth._post = AsyncMock(side_effect=refresh)
other = CodexAuth(auth.ap)
other._post = auth._post
first = asyncio.create_task(auth.access('w', 'p'))
await entered.wait()
second = asyncio.create_task(other.access('w', 'p'))
release.set()
a, b = await asyncio.gather(first, second)
assert a == b
assert a['refresh_token'] == 'rotated-secret'
assert auth._post.await_count == 1
assert (await auth._read('w', 'p'))['payload']['tokens'] == a
@pytest.mark.asyncio
@pytest.mark.parametrize(
'status,error,invalid',
[
(400, 'invalid_grant', True),
(401, 'refresh_token_reused', True),
(429, 'limited', False),
(500, 'secret-upstream-body', False),
(403, 'permission_denied', False),
],
)
async def test_refresh_errors_are_safe_and_transient_preserves_tokens(auth, status, error, invalid):
old = _tokens(token_response())
old['expires_at'] = 0
await seed(auth, {'tokens': old})
auth._post = AsyncMock(
return_value=httpx.Response(status, json={'error': error, 'access_token': 'secret-upstream-body'})
)
with pytest.raises(ValueError) as caught:
await auth.access('w', 'p')
assert 'secret' not in str(caught.value)
payload = (await auth._read('w', 'p'))['payload']
assert bool(payload.get('invalid')) == invalid
assert ('tokens' not in payload) if invalid else payload['tokens'] == old
@pytest.mark.asyncio
@pytest.mark.parametrize('cancel', [False, True])
async def test_disconnect_or_cancel_fences_inflight_exchange(auth, cancel):
old = _tokens(token_response())
await seed(
auth,
{
'tokens': old,
'pending': {
'authorization_id': 'attempt',
'account_uuid': 'u',
'expires_at': time.time() + 100,
'next_poll_at': 0,
'interval': 5,
'device_auth_id': 'device',
'user_code': 'code',
},
},
)
entered, release = asyncio.Event(), asyncio.Event()
async def post(path, **kwargs):
if path.endswith('/token') and path != '/oauth/token':
return httpx.Response(200, json={'authorization_code': 'code', 'code_verifier': 'verifier'})
entered.set()
await release.wait()
return httpx.Response(200, json=token_response())
auth._post = post
task = asyncio.create_task(auth.poll(context(), 'p', 'attempt'))
await entered.wait()
if cancel:
await auth.cancel(context(), 'p', 'attempt')
else:
await auth.disconnect(context(), 'p')
release.set()
with pytest.raises(ValueError, match='cancelled or replaced'):
await task
payload = (await auth._read('w', 'p'))['payload']
assert payload == ({'tokens': old} if cancel else {})
@pytest.mark.asyncio
@pytest.mark.parametrize('status,interval', [(403, 5), (404, 5), (429, 10)])
async def test_device_pending_and_backoff(auth, status, interval):
await seed(
auth,
{
'pending': {
'authorization_id': 'attempt',
'account_uuid': 'u',
'expires_at': time.time() + 100,
'next_poll_at': 0,
'interval': 5,
'device_auth_id': 'device',
'user_code': 'code',
}
},
)
auth._post = AsyncMock(return_value=httpx.Response(status))
assert await auth.poll(context(), 'p', 'attempt') == {'status': 'pending', 'interval': interval}
assert await auth.poll(context(), 'p', 'attempt') == {'status': 'pending', 'interval': interval}
assert auth._post.await_count == 1
@pytest.mark.asyncio
async def test_device_replacement_expiry_and_idempotent_cancel(auth):
auth._post = AsyncMock(return_value=httpx.Response(200, json={'device_auth_id': 'device', 'user_code': 'CODE'}))
first = await auth.start(context(), 'p')
second = await auth.start(context(), 'p')
assert first['authorization_id'] != second['authorization_id']
assert await auth.poll(context(), 'p', first['authorization_id']) == {'status': 'expired'}
await auth.cancel(context(), 'p', first['authorization_id'])
payload = (await auth._read('w', 'p'))['payload']
assert payload['pending']['authorization_id'] == second['authorization_id']
payload['pending']['expires_at'] = 0
await seed(auth, payload)
assert await auth.poll(context(), 'p', second['authorization_id']) == {'status': 'expired'}
assert 'pending' not in (await auth._read('w', 'p'))['payload']
@pytest.mark.asyncio
async def test_device_accepts_issuer_iso_expiry(auth):
from datetime import datetime, timezone
expires = datetime.fromtimestamp(time.time() + 600, timezone.utc).isoformat().replace('+00:00', 'Z')
auth._post = AsyncMock(
return_value=httpx.Response(200, json={'device_auth_id': 'device', 'user_code': 'CODE', 'expires_at': expires})
)
result = await auth.start(context(), 'p')
assert time.time() < result['expires_at'] < time.time() + 900
@pytest.mark.asyncio
async def test_device_expires_in_fallback(auth):
auth._post = AsyncMock(
return_value=httpx.Response(200, json={'device_auth_id': 'device', 'user_code': 'CODE', 'expires_in': 60})
)
result = await auth.start(context(), 'p')
assert time.time() < result['expires_at'] <= time.time() + 60
@pytest.mark.asyncio
@pytest.mark.parametrize('cancel_reads_before_refresh', [False, True])
async def test_cancel_pending_relogin_waits_for_existing_refresh(auth, cancel_reads_before_refresh):
old = _tokens(token_response())
old['expires_at'] = 0
await seed(auth, {'tokens': old, 'pending': {'authorization_id': 'attempt', 'account_uuid': 'u'}})
entered, release, cancel_read = asyncio.Event(), asyncio.Event(), asyncio.Event()
async def refresh(*args, **kwargs):
entered.set()
await release.wait()
return httpx.Response(200, json=token_response(refresh_token='rotated-secret'))
other = CodexAuth(auth.ap)
original_read = other._read
async def read(workspace, provider):
row = await original_read(workspace, provider)
cancel_read.set()
if cancel_reads_before_refresh:
await entered.wait()
return row
other._read = read
auth._post = refresh
if cancel_reads_before_refresh:
cancelling = asyncio.create_task(other.cancel(context(), 'p', 'attempt'))
await cancel_read.wait()
refreshing = asyncio.create_task(auth.access('w', 'p'))
await entered.wait()
if not cancel_reads_before_refresh:
cancelling = asyncio.create_task(other.cancel(context(), 'p', 'attempt'))
await cancel_read.wait()
await asyncio.sleep(0.05)
try:
assert not cancelling.done(), 'Cancellation must not revoke the refresh lease'
finally:
release.set()
results = await asyncio.gather(refreshing, cancelling, return_exceptions=True)
assert not any(isinstance(result, Exception) for result in results)
payload = (await auth._read('w', 'p'))['payload']
assert payload['tokens']['refresh_token'] == 'rotated-secret'
assert 'pending' not in payload
@pytest.mark.asyncio
@pytest.mark.parametrize('operation', ['save', 'cancel', 'disconnect', 'acquire', 'release', 'read'])
async def test_credential_database_errors_never_expose_secrets(auth, operation):
import traceback
markers = ['ACCESS-MARKER', 'REFRESH-MARKER', 'DEVICE-MARKER', 'VERIFIER-MARKER']
payload = {
'tokens': {'access_token': markers[0], 'refresh_token': markers[1]},
'pending': {
'authorization_id': 'attempt',
'account_uuid': 'u',
'device_auth_id': markers[2],
'code_verifier': markers[3],
},
}
await seed(auth, payload)
if operation == 'read':
auth.ap.persistence_mgr.execute_async = AsyncMock(
side_effect=sa.exc.StatementError(
'failure', 'SELECT credentials', {'payload': payload}, RuntimeError(markers[0])
)
)
else:
column = 'payload' if operation in ('save', 'cancel', 'disconnect') else 'lease_owner'
condition = ' WHEN NEW.lease_owner IS NULL' if operation == 'release' else ''
# Trigger errors can themselves contain secrets, even for parameter-free writes.
await auth.ap.persistence_mgr.execute_async(
sa.text(
f'CREATE TRIGGER reject_write BEFORE UPDATE OF {column} ON codex_credentials{condition} '
f"BEGIN SELECT RAISE(ABORT, '{' '.join(markers)}'); END"
)
)
with pytest.raises(ValueError, match='credential storage') as caught:
if operation == 'save':
async with auth._lease('w', 'p') as owner:
await auth._save('w', 'p', owner, payload)
elif operation == 'cancel':
await auth.cancel(context(), 'p', 'attempt')
elif operation == 'disconnect':
await auth.disconnect(context(), 'p')
elif operation == 'read':
await auth._read('w', 'p')
else:
async with auth._lease('w', 'p'):
pass
rendered = ''.join(traceback.format_exception(caught.value))
assert all(marker not in rendered for marker in markers)
assert caught.value.__suppress_context__
@pytest.mark.asyncio
async def test_credential_serialization_failure_is_sanitized(auth):
import traceback
class Secret:
def __repr__(self):
return 'SERIALIZATION-SECRET'
with pytest.raises(ValueError, match='credential storage') as caught:
async with auth._lease('w', 'p') as owner:
await auth._save('w', 'p', owner, {'tokens': {'refresh_token': Secret()}})
assert 'SERIALIZATION-SECRET' not in ''.join(traceback.format_exception(caught.value))
assert caught.value.__suppress_context__
def test_token_refresh_fallback_and_config_validation():
old = _tokens(token_response())
refreshed = _tokens({'access_token': 'opaque-access', 'expires_in': 3600}, old)
assert refreshed['refresh_token'] == old['refresh_token']
assert refreshed['connection_id'] == old['connection_id']
for expiry in [float('nan'), float('inf'), -1, 'bad']:
with pytest.raises(ValueError):
_tokens(token_response(expires_in=expiry))
data = {'requester': 'openai-codex'}
validate_config(data)
assert data['api_keys'] == []
for update in [{'base_url': 'https://evil.invalid'}, {'api_keys': ['secret']}]:
with pytest.raises(ValueError):
validate_config({**data, **update})
ordinary = {'requester': 'openai-chat-completions', 'api_keys': ['key'], 'base_url': 'https://custom.invalid'}
before = dict(ordinary)
validate_config(ordinary)
assert ordinary == before
@@ -108,7 +108,7 @@ class TestSQLiteMigrationUpgrade:
await run_alembic_upgrade(sqlite_engine, 'head')
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):
@@ -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'}))
@@ -5,6 +5,7 @@ import logging
import os
import pathlib
import sqlite3
from contextlib import closing
import pytest
import sqlalchemy as sa
@@ -34,7 +35,7 @@ def _manifest_payloads(backup_directory) -> list[dict]:
def _assert_verified_backup(payload: dict) -> None:
backup_path = pathlib.Path(payload['backup_path'])
with sqlite3.connect(f'{backup_path.as_uri()}?mode=ro', uri=True) as connection:
with closing(sqlite3.connect(f'{backup_path.as_uri()}?mode=ro', uri=True)) as connection:
assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)]
assert connection.execute('SELECT version_num FROM alembic_version').fetchone()[0] == payload['source_revision']
@@ -403,10 +403,12 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
)
assert 'workspaces' not in tables_before_migration
assert 'codex_credentials' not in tables_before_migration
await manager._run_alembic_migrations()
async with engine.connect() as conn:
assert 'codex_credentials' in await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
workspace = (
(await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
)
@@ -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):
@@ -0,0 +1,401 @@
"""Provider deletion uses real SQLite transactions and real runtime cache cleanup."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import pytest_asyncio
import quart
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.api.http.controller.groups.provider.providers import ModelProvidersRouterGroup
from langbot.pkg.api.http.service.provider import ModelProviderService
from langbot.pkg.entity.persistence.model import CodexCredential, EmbeddingModel, LLMModel, ModelProvider, RerankModel
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.entity.persistence.workspace import Workspace
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.asyncio
MODEL_TYPES = (LLMModel, EmbeddingModel, RerankModel)
TABLES = (*MODEL_TYPES, CodexCredential, ModelProvider)
@pytest_asyncio.fixture
async def deletion(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "cascade.db"}')
@sa.event.listens_for(engine.sync_engine, 'connect')
def enable_foreign_keys(connection, _record):
connection.execute('PRAGMA foreign_keys=ON')
ap = SimpleNamespace(logger=Mock())
pm = ap.persistence_mgr = PersistenceManager(ap)
pm.db = SimpleNamespace(get_engine=lambda: engine)
manager = ap.model_mgr = ModelManager(ap)
contexts = {workspace: ExecutionContext('instance', workspace, 1) for workspace in ('a', 'b')}
# Only execution binding discovery is stubbed; cache indexing/removal/close is real.
manager.resolve_execution_context = AsyncMock(side_effect=lambda context: contexts[context])
service = ModelProviderService(ap)
closed = []
async def snapshot():
async with engine.connect() as conn:
return {
table.__tablename__: [dict(row) for row in (await conn.execute(sa.select(table))).mappings()]
for table in TABLES
}
async with engine.begin() as conn:
for table in (User, Workspace, ModelProvider, CodexCredential, *MODEL_TYPES):
await conn.run_sync(table.__table__.create)
for workspace in contexts:
await conn.execute(
sa.insert(Workspace).values(
uuid=workspace,
instance_uuid='instance',
name=workspace,
slug=workspace,
source='cloud_projection',
)
)
for provider, workspace in (('target', 'a'), ('neighbor', 'a'), ('foreign', 'b'), ('empty', 'a')):
await conn.execute(
sa.insert(ModelProvider).values(
uuid=provider,
workspace_uuid=workspace,
name=provider,
requester='openai-codex',
base_url='https://chatgpt.com/backend-api/codex',
api_keys=[],
)
)
await conn.execute(
sa.insert(CodexCredential).values(
provider_uuid=provider,
workspace_uuid=workspace,
payload={'synthetic': provider},
)
)
async def close(provider=provider):
# A separate connection must observe the durable deletion before close runs.
state = await snapshot()
assert all(row['uuid'] != provider for row in state['model_providers'])
assert pm.current_session() is None
closed.append(provider)
runtime = SimpleNamespace(requester=SimpleNamespace(aclose=AsyncMock(side_effect=close)))
manager._cache_set(manager.provider_dict, manager._cache_key(contexts[workspace], provider), runtime)
if provider == 'empty':
continue
for model_type, cache in zip(
MODEL_TYPES,
(
manager.llm_model_dict,
manager.embedding_model_dict,
manager.rerank_model_dict,
),
):
for index in range(2):
uuid = f'{provider}-{model_type.__tablename__}-{index}'
await conn.execute(
sa.insert(model_type).values(
uuid=uuid,
workspace_uuid=workspace,
provider_uuid=provider,
name=uuid,
)
)
manager._cache_set(cache, manager._cache_key(contexts[workspace], uuid), object())
initial = await snapshot()
initial_caches = [
dict(cache)
for cache in (
manager.provider_dict,
manager.llm_model_dict,
manager.embedding_model_dict,
manager.rerank_model_dict,
)
]
try:
yield SimpleNamespace(
ap=ap,
pm=pm,
engine=engine,
service=service,
manager=manager,
snapshot=snapshot,
initial=initial,
initial_caches=initial_caches,
closed=closed,
)
finally:
await engine.dispose()
def assert_caches_unchanged(deletion):
assert deletion.closed == []
assert deletion.initial_caches == [
dict(cache)
for cache in (
deletion.manager.provider_dict,
deletion.manager.llm_model_dict,
deletion.manager.embedding_model_dict,
deletion.manager.rerank_model_dict,
)
]
@pytest.mark.parametrize('mode', [PersistenceMode.OSS_COMPAT, PersistenceMode.CLOUD_RUNTIME])
async def test_cascade_deletes_all_model_types_and_credentials_after_commit(deletion, mode):
deletion.pm.mode = mode
await deletion.service.delete_provider('a', 'target', cascade=True)
state = await deletion.snapshot()
for table, rows in deletion.initial.items():
identity = 'uuid' if table == 'model_providers' else 'provider_uuid'
assert state[table] == [row for row in rows if row[identity] != 'target']
assert deletion.closed == ['target']
deletion.ap.logger.warning.assert_not_called()
for cache in (
deletion.manager.provider_dict,
deletion.manager.llm_model_dict,
deletion.manager.embedding_model_dict,
deletion.manager.rerank_model_dict,
):
assert all(not key[-1].startswith('target') for key in cache)
assert any(key[1] == 'b' for key in cache)
assert any(key[-1].startswith('neighbor') for key in cache)
@pytest.mark.parametrize('model_type', MODEL_TYPES)
@pytest.mark.parametrize('kwargs', [{}, {'cascade': False}])
async def test_default_guard_preserves_each_model_type(deletion, model_type, kwargs):
async with deletion.engine.begin() as conn:
for other in MODEL_TYPES:
if other is not model_type:
await conn.execute(sa.delete(other).where(other.provider_uuid == 'target'))
before = await deletion.snapshot()
with pytest.raises(ValueError, match='models still reference it'):
await deletion.service.delete_provider('a', 'target', **kwargs)
assert await deletion.snapshot() == before
assert_caches_unchanged(deletion)
@pytest.mark.parametrize('kwargs', [{}, {'cascade': False}, {'cascade': True}])
async def test_empty_provider_deletes_credentials_with_or_without_cascade(deletion, kwargs):
await deletion.service.delete_provider('a', 'empty', **kwargs)
state = await deletion.snapshot()
assert all(row['uuid'] != 'empty' for row in state['model_providers'])
assert all(row['provider_uuid'] != 'empty' for row in state['codex_credentials'])
assert deletion.closed == ['empty']
@pytest.mark.parametrize('provider', ['foreign', 'missing'])
@pytest.mark.parametrize('cascade', [False, True])
async def test_foreign_and_missing_provider_are_non_enumerating(deletion, provider, cascade):
with pytest.raises(WorkspaceNotFoundError, match='Provider not found'):
await deletion.service.delete_provider('a', provider, cascade=cascade)
assert await deletion.snapshot() == deletion.initial
assert_caches_unchanged(deletion)
@pytest.mark.parametrize('cascade', [False, True])
async def test_cloud_managed_provider_cannot_be_deleted(deletion, cascade):
async with deletion.engine.begin() as conn:
await conn.execute(
sa.update(ModelProvider)
.where(ModelProvider.uuid == 'target')
.values(
requester='space-chat-completions',
)
)
before = await deletion.snapshot()
deletion.pm.mode = PersistenceMode.CLOUD_RUNTIME
with pytest.raises(ValueError, match='managed by Cloud'):
await deletion.service.delete_provider('a', 'target', cascade=cascade)
assert await deletion.snapshot() == before
assert_caches_unchanged(deletion)
@pytest.mark.parametrize('failure_table', ['embedding_models', 'codex_credentials', 'model_providers'])
async def test_database_failure_rolls_back_all_rows_without_runtime_cleanup(deletion, failure_table):
async with deletion.engine.begin() as conn:
await conn.exec_driver_sql(
f'CREATE TRIGGER fail_delete BEFORE DELETE ON {failure_table} '
"BEGIN SELECT RAISE(ABORT, 'injected delete failure'); END"
)
with pytest.raises(sa.exc.IntegrityError, match='injected delete failure'):
await deletion.service.delete_provider('a', 'target', cascade=True)
assert await deletion.snapshot() == deletion.initial
assert_caches_unchanged(deletion)
@pytest.mark.parametrize('rollback', [False, True])
async def test_nested_transaction_defers_cleanup_until_outer_commit(deletion, rollback):
class Abort(Exception):
pass
try:
async with deletion.pm.tenant_uow('a'):
await deletion.service.delete_provider('a', 'target', cascade=True)
assert_caches_unchanged(deletion)
if rollback:
raise Abort
except Abort:
pass
tasks = tuple(deletion.service._deletion_tasks)
await asyncio.gather(*tasks, return_exceptions=True)
if rollback:
assert await deletion.snapshot() == deletion.initial
assert_caches_unchanged(deletion)
else:
assert deletion.closed == ['target']
deletion.ap.logger.warning.assert_not_called()
async def test_cascade_ignores_foreign_workspace_references_even_without_foreign_keys(deletion):
async with deletion.engine.connect() as conn:
await conn.exec_driver_sql('PRAGMA foreign_keys=OFF')
for model_type in MODEL_TYPES:
await conn.execute(
sa.update(model_type)
.where(model_type.workspace_uuid == 'b')
.values(
provider_uuid='target',
)
)
await conn.commit()
await deletion.service.delete_provider('a', 'target', cascade=True)
state = await deletion.snapshot()
for model_type in MODEL_TYPES:
assert len([row for row in state[model_type.__tablename__] if row['workspace_uuid'] == 'b']) == 2
assert all(row['provider_uuid'] != 'target' for row in state['codex_credentials'])
assert deletion.closed == ['target']
@pytest_asyncio.fixture
async def route_app(deletion):
ap = deletion.ap
ap.user_service = SimpleNamespace(
get_authenticated_account=AsyncMock(
return_value=SimpleNamespace(uuid='account', user='owner@example.invalid'),
)
)
membership = SimpleNamespace(uuid='membership', role='owner', projection_revision=0)
ap.workspace_collaboration_service = SimpleNamespace(
resolve_account_workspace=AsyncMock(
return_value=SimpleNamespace(
workspace=SimpleNamespace(uuid='a'),
membership=membership,
execution=SimpleNamespace(instance_uuid='instance', placement_generation=1),
),
)
)
ap.provider_service = SimpleNamespace(delete_provider=AsyncMock())
app = quart.Quart(__name__)
await ModelProvidersRouterGroup(ap, app).initialize()
return app.test_client(), ap.provider_service.delete_provider, membership
@pytest.mark.parametrize('query, expected', [('', None), ('?cascade=true', True), ('?cascade=false', False)])
async def test_route_passes_explicit_cascade_and_trusted_workspace(route_app, query, expected):
client, delete, _ = route_app
response = await client.delete(
'/api/v1/provider/providers/target' + query,
headers={
'Authorization': 'Bearer token',
'X-Workspace-Id': 'a',
},
)
assert response.status_code == 200
assert delete.await_count == 1
assert delete.await_args.args[0].workspace_uuid == 'a'
assert delete.await_args.args[1] == 'target'
assert delete.await_args.kwargs == ({} if expected is None else {'cascade': expected})
@pytest.mark.parametrize(
'query',
[
'?cascade=',
'?cascade',
'?cascade=TRUE',
'?cascade=1',
'?cascade=yes',
'?cascade=null',
'?cascade=%20true',
'?cascade=true&cascade=false',
'?cascade=true&cascade=true',
],
)
async def test_route_rejects_invalid_or_duplicate_cascade_before_deletion(route_app, query):
client, delete, _ = route_app
response = await client.delete(
'/api/v1/provider/providers/target' + query,
headers={
'Authorization': 'Bearer token',
'X-Workspace-Id': 'a',
},
)
assert response.status_code == 400
delete.assert_not_awaited()
@pytest.mark.parametrize('role', ['viewer', 'operator'])
async def test_cascade_requires_workspace_resource_manage_permission(route_app, role):
client, delete, membership = route_app
membership.role = role
response = await client.delete(
'/api/v1/provider/providers/target?cascade=true',
headers={
'Authorization': 'Bearer token',
'X-Workspace-Id': 'a',
},
)
assert response.status_code == 403
delete.assert_not_awaited()
@pytest.mark.parametrize(
'provider, query, status',
[
('target', '', 400),
('target', '?cascade=false', 400),
('target', '?cascade=true', 200),
('foreign', '?cascade=true', 404),
('missing', '?cascade=true', 404),
],
)
async def test_route_to_real_sqlite_service(deletion, route_app, provider, query, status):
client, _, _ = route_app
deletion.ap.provider_service = deletion.service
# The route forwards RequestContext, unlike the string-context service tests.
deletion.manager.resolve_execution_context = AsyncMock(
side_effect=lambda context: ExecutionContext(
context.instance_uuid,
context.workspace_uuid,
context.placement_generation,
)
)
response = await client.delete(
'/api/v1/provider/providers/' + provider + query,
headers={
'Authorization': 'Bearer token',
'X-Workspace-Id': 'a',
},
)
assert response.status_code == status
if status == 200:
assert deletion.closed == ['target']
for model_type in MODEL_TYPES:
assert all(
row['provider_uuid'] != 'target' for row in (await deletion.snapshot())[model_type.__tablename__]
)
else:
assert await deletion.snapshot() == deletion.initial
assert_caches_unchanged(deletion)
@@ -14,11 +14,12 @@ Source: src/langbot/pkg/api/http/service/provider.py
from __future__ import annotations
import pytest
from contextlib import nullcontext
from unittest.mock import AsyncMock, Mock
from types import SimpleNamespace
from langbot.pkg.api.http.service.provider import ModelProviderService
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel, EmbeddingModel, RerankModel
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
@@ -383,112 +384,35 @@ class TestModelProviderServiceUpdateProvider:
class TestModelProviderServiceDeleteProvider:
"""Tests for delete_provider method."""
async def test_delete_provider_with_llm_models_raises_error(self):
"""Raises ValueError when LLM models reference provider."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Mock LLM model exists - only return LLM result since that's first check
llm_result = _create_mock_result([], first_item=_create_mock_llm_model())
ap.persistence_mgr.execute_async = AsyncMock(return_value=llm_result)
"""Fast guard coverage; real transaction/cache behavior is in test_provider_cascade."""
@pytest.mark.parametrize('label', ['LLM', 'Embedding', 'Rerank', None])
async def test_delete_provider_requires_no_references(self, label):
provider_result = Mock()
provider_result.first.return_value = SimpleNamespace(requester='openai')
results = [provider_result]
for model_label in ('LLM', 'Embedding', 'Rerank'):
result = Mock()
result.scalars.return_value = ['model'] if label == model_label else []
results.append(result)
results.extend([Mock(rowcount=1), Mock(rowcount=1)])
ap = SimpleNamespace(
persistence_mgr=SimpleNamespace(
execute_async=AsyncMock(side_effect=results),
tenant_uow=lambda _: nullcontext(),
tenant_scope=lambda _: nullcontext(),
current_session=lambda: None,
),
model_mgr=SimpleNamespace(remove_provider=AsyncMock()),
)
service = ModelProviderService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='Cannot delete provider: LLM models'):
await service.delete_provider(WORKSPACE_UUID, 'provider-with-llm')
async def test_delete_provider_with_embedding_models_raises_error(self):
"""Raises ValueError when Embedding models reference provider."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Create results for each check type
llm_result = Mock()
llm_result.first = Mock(return_value=None) # No LLM models
embedding_result = Mock()
embedding_result.first = Mock(return_value=Mock(spec=EmbeddingModel)) # Has embedding model
rerank_result = Mock()
rerank_result.first = Mock(return_value=None)
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return llm_result
elif call_count == 2:
return embedding_result
return rerank_result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
service = ModelProviderService(ap)
# Execute & Verify - should raise embedding error (LLM check passes, embedding check fails)
with pytest.raises(ValueError, match='Cannot delete provider: Embedding models'):
await service.delete_provider(WORKSPACE_UUID, 'provider-with-embedding')
async def test_delete_provider_with_rerank_models_raises_error(self):
"""Raises ValueError when Rerank models reference provider."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
# Create results for each check type
llm_result = Mock()
llm_result.first = Mock(return_value=None) # No LLM models
embedding_result = Mock()
embedding_result.first = Mock(return_value=None) # No embedding models
rerank_result = Mock()
rerank_result.first = Mock(return_value=Mock(spec=RerankModel)) # Has rerank model
call_count = 0
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return llm_result
elif call_count == 2:
return embedding_result
return rerank_result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
service = ModelProviderService(ap)
# Execute & Verify - should raise rerank error (LLM and embedding checks pass, rerank check fails)
with pytest.raises(ValueError, match='Cannot delete provider: Rerank models'):
await service.delete_provider(WORKSPACE_UUID, 'provider-with-rerank')
async def test_delete_provider_no_models_success(self):
"""Deletes provider when no models reference it."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.model_mgr.remove_provider = AsyncMock()
# Mock no models reference provider
empty_result = Mock()
empty_result.first = Mock(return_value=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=empty_result)
service = ModelProviderService(ap)
# Execute
await service.delete_provider(WORKSPACE_UUID, 'provider-no-models')
# Verify - delete and remove called
ap.model_mgr.remove_provider.assert_called_once_with(WORKSPACE_UUID, 'provider-no-models')
if label is not None:
with pytest.raises(ValueError, match=f'Cannot delete provider: {label} models'):
await service.delete_provider(WORKSPACE_UUID, 'provider')
ap.model_mgr.remove_provider.assert_not_awaited()
else:
await service.delete_provider(WORKSPACE_UUID, 'provider')
ap.model_mgr.remove_provider.assert_awaited_once_with(WORKSPACE_UUID, 'provider')
class TestModelProviderServiceGetProviderModelCounts:
@@ -1045,15 +969,18 @@ class TestCloudManagedProviderProtection:
async def test_cloud_rejects_update_and_delete_of_managed_provider(self):
service = self._service()
service.get_provider = AsyncMock(
return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER}
)
service.get_provider = AsyncMock(return_value={'uuid': 'system-provider', 'requester': SYSTEM_REQUESTER})
with pytest.raises(ValueError, match='managed by Cloud'):
await service.update_provider(WORKSPACE_UUID, 'system-provider', {'name': 'Renamed'})
service.ap.persistence_mgr.execute_async.assert_not_awaited()
service.ap.persistence_mgr.tenant_uow = lambda _: nullcontext()
result = Mock()
result.first.return_value = SimpleNamespace(requester=SYSTEM_REQUESTER)
service.ap.persistence_mgr.execute_async.return_value = result
with pytest.raises(ValueError, match='managed by Cloud'):
await service.delete_provider(WORKSPACE_UUID, 'system-provider')
service.ap.persistence_mgr.execute_async.assert_not_awaited()
assert service.ap.persistence_mgr.execute_async.await_count == 1
async def test_oss_does_not_reserve_space_requester(self):
ap = SimpleNamespace(persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='oss_compat')))
@@ -0,0 +1,20 @@
"""Keep subprocess coverage pointed at the generated E2E configuration."""
from pathlib import Path
from unittest.mock import Mock, patch
from tests.e2e.utils.process_manager import LangBotProcess
def test_e2e_coverage_environment_uses_generated_config(tmp_path):
process = Mock()
process.poll.return_value = None
project = tmp_path / 'project'
project.mkdir()
manager = LangBotProcess(project, tmp_path, collect_coverage=True)
with patch('subprocess.Popen', return_value=process) as popen, patch('httpx.get') as get:
get.return_value.status_code = 200
assert manager.start()
config = Path(popen.call_args.kwargs['env']['COVERAGE_PROCESS_START'])
assert config.is_file()
assert f'--rcfile={config}' in popen.call_args.args[0]
@@ -0,0 +1,115 @@
"""Real rollback-journal contention must not poison the pooled writer."""
import asyncio
import contextlib
import sqlite3
from types import SimpleNamespace
import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSessionTransaction, create_async_engine
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
from langbot.pkg.persistence.tenant_uow import TenantScopedAsyncSession
@pytest.mark.asyncio
@pytest.mark.parametrize('cancel_commit', [False, True])
@pytest.mark.parametrize('close_fails', [False, True])
@pytest.mark.parametrize('cancel_cleanup', [False, True])
async def test_failed_commit_releases_sqlite_writer_and_scope(
tmp_path, monkeypatch, cancel_commit, close_fails, cancel_cleanup
):
path = tmp_path / 'failed-commit.db'
engine = create_async_engine(
f'sqlite+aiosqlite:///{path}', connect_args={'timeout': 0.05}, pool_size=1, max_overflow=0
)
table = sa.Table('rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
original_commit = AsyncSessionTransaction.commit
original_error = None
invalidation_finished = False
owner = asyncio.current_task()
original_invalidate = AsyncConnection.invalidate
async def delayed_invalidate(connection, exception=None):
nonlocal invalidation_finished
if cancel_cleanup:
owner.cancel()
await asyncio.sleep(0)
owner.cancel()
await asyncio.sleep(0)
await original_invalidate(connection, exception)
invalidation_finished = True
monkeypatch.setattr(AsyncConnection, 'invalidate', delayed_invalidate)
async def failing_commit(transaction):
nonlocal original_error
try:
await original_commit(transaction)
except sa.exc.OperationalError as exc:
original_error = asyncio.CancelledError('commit cancelled') if cancel_commit else exc
raise original_error
monkeypatch.setattr(AsyncSessionTransaction, 'commit', failing_commit)
original_close = TenantScopedAsyncSession._close_owned_session
async def failing_close(session, capability):
await original_close(session, capability)
if original_error is not None:
raise RuntimeError('secondary close failure')
if close_fails:
monkeypatch.setattr(TenantScopedAsyncSession, '_close_owned_session', failing_close)
blocker = sqlite3.connect(path, timeout=0.05)
try:
async with engine.begin() as connection:
await connection.run_sync(table.metadata.create_all)
await connection.execute(sa.insert(table).values(id=1))
blocker.execute('BEGIN')
blocker.execute('SELECT * FROM rows').fetchall()
error_type = asyncio.CancelledError if cancel_commit else sa.exc.OperationalError
with pytest.raises(error_type) as caught:
async with manager.tenant_uow('workspace-a') as outer:
gate = manager.create_after_commit_gate()
state = outer._active_state
async with manager.tenant_uow('workspace-a') as inner:
assert inner.session is outer.session
await manager.execute_async(sa.insert(table).values(id=2))
assert not gate.done()
assert state.depth == 1
assert caught.value is original_error
assert invalidation_finished
if cancel_cleanup:
# Do not leak the synthetic cancellation count into pytest.
owner.uncancel()
owner.uncancel()
monkeypatch.setattr(TenantScopedAsyncSession, '_close_owned_session', original_close)
if close_fails:
assert any('secondary close failure' in note for note in caught.value.__notes__)
assert gate.cancelled()
assert state.depth == 0
assert manager.current_session() is None
with pytest.raises(RuntimeError, match='not active'):
_ = outer.session
# The original SHARED lock remains. New reads and RESERVED writes
# must work; COMMIT of another write must wait for its release.
assert blocker.in_transaction
with contextlib.closing(sqlite3.connect(path, timeout=0.05)) as probe:
assert probe.execute('SELECT id FROM rows').fetchall() == [(1,)]
probe.execute('INSERT INTO rows VALUES (3)')
probe.rollback()
async with manager.tenant_uow('workspace-b'):
assert (await manager.execute_async(sa.select(table.c.id))).scalars().all() == [1]
blocker.rollback()
async with manager.tenant_uow('workspace-b'):
await manager.execute_async(sa.insert(table).values(id=4))
async with engine.connect() as connection:
assert (await connection.execute(sa.select(table.c.id).order_by(table.c.id))).scalars().all() == [1, 4]
assert engine.pool.checkedout() == 0
finally:
blocker.close()
await engine.dispose()
+200
View File
@@ -0,0 +1,200 @@
"""Replay synthetic HTTP/SSE traffic through the real Codex requester."""
import json
from collections import OrderedDict
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
import langbot_plugin.api.entities.builtin.provider.message as pm
from langbot.pkg.provider.modelmgr.requesters.codex import CodexRequester, sse_events
TOKENS = {'access_token': 'access-secret', 'account_id': 'account', 'connection_id': 'connection'}
MODEL = SimpleNamespace(model_entity=SimpleNamespace(name='codex-test', extra_args={}, reasoning_config=None))
def requester(monkeypatch, handler):
real_client = httpx.AsyncClient
monkeypatch.setattr(
httpx, 'AsyncClient', lambda **kwargs: real_client(transport=httpx.MockTransport(handler), **kwargs)
)
obj = object.__new__(CodexRequester)
obj.workspace, obj.provider = 'w', 'p'
obj._replay = OrderedDict()
obj.auth = SimpleNamespace(access=AsyncMock(return_value=TOKENS))
return obj
def stream(events):
return httpx.Response(200, content=''.join('data: ' + json.dumps(event) + '\r\n\r\n' for event in events))
@pytest.mark.asyncio
async def test_text_tools_usage_and_scoped_opaque_replay(monkeypatch):
call = {'type': 'function_call', 'call_id': 'call_1', 'name': 'lookup', 'arguments': '{"q":"test"}'}
output = [
{'type': 'reasoning', 'encrypted_content': 'opaque-secret'},
call,
{'type': 'message', 'role': 'assistant', 'content': [{'type': 'output_text', 'text': 'Hello'}]},
]
requests = []
def handler(request):
requests.append(request)
return stream(
[
{'type': 'response.created', 'response': {'id': 'resp_1'}},
{'type': 'response.output_text.delta', 'delta': 'Hel'},
{'type': 'response.output_text.delta', 'delta': 'lo'},
{'type': 'response.function_call_arguments.delta', 'delta': '{broken'},
{'type': 'response.output_item.done', 'item': call, 'output_index': 1},
{
'type': 'response.completed',
'response': {
'id': 'resp_1',
'status': 'completed',
'output': output,
'usage': {'input_tokens': 4, 'output_tokens': 3, 'input_tokens_details': {'cached_tokens': 2}},
},
},
]
)
obj = requester(monkeypatch, handler)
query = SimpleNamespace(query_id='q', variables=None)
messages = [pm.Message(role='system', content='Be brief'), pm.Message(role='user', content='Hi')]
message, usage = await obj.invoke_llm(query, MODEL, messages)
assert message.content == 'Hello'
assert len(message.tool_calls) == 1
assert message.tool_calls[0].function.arguments == '{"q":"test"}'
assert usage['total_tokens'] == 7
assert query.variables['_stream_usage'] == usage
assert 'opaque-secret' not in message.model_dump_json()
body = json.loads(requests[0].content)
assert body['store'] is False and body['stream'] is True
assert body['instructions'] == 'Be brief'
assert requests[0].url.path.endswith('/codex/responses')
assert requests[0].headers['authorization'] == 'Bearer access-secret'
assert requests[0].headers['originator'] == 'langbot'
same = obj._body(query, MODEL, [message], None, None, TOKENS)
assert same['input'] == output
other = obj._body(SimpleNamespace(query_id='q'), MODEL, [message], None, None, TOKENS)
assert 'opaque-secret' not in json.dumps(other)
rotated = obj._body(query, MODEL, [message], None, None, {**TOKENS, 'connection_id': 'new'})
assert 'opaque-secret' not in json.dumps(rotated)
@pytest.mark.asyncio
@pytest.mark.parametrize(
'events',
[
[{'type': 'response.failed', 'error': 'access-secret'}],
[{'type': 'response.incomplete'}],
[{'type': 'error'}],
[{'type': 'response.output_text.delta', 'delta': 'partial'}],
[],
],
)
async def test_failure_and_truncated_stream_never_succeed(monkeypatch, events):
obj = requester(monkeypatch, lambda request: stream(events))
with pytest.raises(ValueError) as caught:
await obj.invoke_llm(None, MODEL, [])
assert 'access-secret' not in str(caught.value)
@pytest.mark.asyncio
async def test_terminal_only_text_stream_and_usage(monkeypatch):
obj = requester(
monkeypatch,
lambda request: stream(
[
{
'type': 'response.done',
'response': {
'output': [{'type': 'message', 'content': [{'type': 'output_text', 'text': 'done'}]}],
'usage': {'input_tokens': 2, 'output_tokens': 1},
},
}
]
),
)
query = SimpleNamespace(query_id='q', variables={})
chunks = [chunk async for chunk in obj.invoke_llm_stream(query, MODEL, [])]
assert ''.join(chunk.content or '' for chunk in chunks) == 'done'
assert chunks[-1].is_final
assert query.variables['_stream_usage']['total_tokens'] == 3
@pytest.mark.asyncio
@pytest.mark.parametrize('status', [401, 403, 429, 500])
async def test_http_error_secrecy_and_bounded_401_retry(monkeypatch, status):
requests = []
def handler(request):
requests.append(request)
return httpx.Response(status, text='access-secret refresh-secret')
obj = requester(monkeypatch, handler)
with pytest.raises(ValueError) as caught:
await obj.invoke_llm(None, MODEL, [])
assert 'secret' not in str(caught.value)
assert len(requests) == (2 if status == 401 else 1)
if status == 401:
assert obj.auth.access.call_args.kwargs == {'rejected_token': 'access-secret'}
@pytest.mark.asyncio
async def test_catalog_mapping_filtering_and_deduplication(monkeypatch):
requests = []
def handler(request):
requests.append(request)
return httpx.Response(
200,
json={
'models': [
{'slug': 'a', 'input_modalities': ['text', 'image'], 'supported_reasoning_levels': ['low']},
{'slug': 'hidden', 'visibility': 'hide'},
{'slug': 'a', 'input_modalities': ['text', 'image'], 'supported_reasoning_levels': ['low']},
]
},
)
obj = requester(monkeypatch, handler)
catalog = await obj.scan_models()
assert len(catalog['models']) == 1
assert catalog['models'][0]['abilities'] == ['func_call', 'vision', 'reasoning']
assert catalog['debug'] is None
assert requests[0].url.path.endswith('/codex/models')
assert 'client_version' in requests[0].url.params
@pytest.mark.asyncio
@pytest.mark.parametrize('payload', [{'models': ['secret']}, [], {'models': None}])
async def test_catalog_malformed_safe(monkeypatch, payload):
obj = requester(monkeypatch, lambda request: httpx.Response(200, json=payload))
with pytest.raises(ValueError, match='invalid model catalog'):
await obj.scan_models()
@pytest.mark.asyncio
async def test_sse_multiline_crlf_comments_and_chunk_boundaries():
class Bytes(httpx.AsyncByteStream):
async def __aiter__(self):
for value in b': comment\r\nevent: test\r\ndata: {"type":\r\ndata: "test"}\r\n\r\ndata: [DONE]\r\n\r\n':
yield bytes([value])
response = httpx.Response(200, stream=Bytes())
assert [event async for event in sse_events(response)] == [{'type': 'test'}]
@pytest.mark.parametrize(
'key', ['base_url', 'headers', 'api_key', 'store', 'stream', 'previous_response_id', 'temperature']
)
def test_advanced_parameters_cannot_override_transport(monkeypatch, key):
obj = requester(monkeypatch, lambda request: httpx.Response(200))
with pytest.raises(ValueError, match='Unsupported Codex advanced'):
obj._body(None, MODEL, [], None, {key: 'secret'}, TOKENS)
@@ -0,0 +1,100 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
from quart import Quart
from sqlalchemy.exc import SQLAlchemyError
from langbot.pkg.api.http.controller.groups.provider.models import LLMModelsRouterGroup
from langbot.pkg.api.http.authz import Permission
from langbot.pkg.provider.modelmgr.requesters.codex import CodexRequester
from tests.unit_tests.provider.test_codex import requester, MODEL, stream
CASES = [
(400, 400, 'codex_invalid_request'),
(401, 400, 'codex_reauthentication_required'),
(403, 403, 'codex_access_denied'),
(429, 429, 'codex_rate_limited'),
(500, 502, 'codex_upstream_failure'),
]
@pytest.mark.asyncio
@pytest.mark.parametrize('upstream,status,code', CASES)
async def test_requester_safe_error(monkeypatch, upstream, status, code):
obj = requester(monkeypatch, lambda request: httpx.Response(upstream, text='credential-secret'))
with pytest.raises(Exception) as caught:
await obj.invoke_llm(None, MODEL, [])
error = caught.value
assert getattr(error, 'status_code', None) == status
assert error.error_code == code
assert 'secret' not in str(error)
if upstream == 429:
assert 'rate limit' in str(error).lower()
assert 'usage limit reached' not in str(error).lower()
@pytest.mark.asyncio
@pytest.mark.parametrize('events', [[{'type': 'response.failed', 'error': 'credential-secret'}], []])
async def test_stream_safe_error(monkeypatch, events):
obj = requester(monkeypatch, lambda request: stream(events))
with pytest.raises(Exception) as caught:
await obj.invoke_llm(None, MODEL, [])
assert getattr(caught.value, 'status_code', None) == 502
assert caught.value.error_code == 'codex_upstream_failure'
assert 'secret' not in str(caught.value)
@pytest.mark.asyncio
@pytest.mark.parametrize(
'kind,code', [('usage_limit_reached', 'codex_usage_limit_reached'), ('unknown', 'codex_rate_limited')]
)
async def test_allowlisted_usage_error(monkeypatch, kind, code):
obj = requester(
monkeypatch,
lambda request: httpx.Response(
429, json={'error': {'type': kind, 'message': 'credential-secret', 'resets_at': 1789043289}}
),
)
with pytest.raises(Exception) as caught:
await obj.invoke_llm(None, MODEL, [])
assert caught.value.error_code == code
assert 'secret' not in str(caught.value)
async def client_for(error):
app = Quart(__name__)
ap = SimpleNamespace(logger=Mock(), llm_model_service=SimpleNamespace(test_llm_model=AsyncMock(side_effect=error)))
router = LLMModelsRouterGroup(ap, app)
router._authenticate_api_key = AsyncMock(
return_value=SimpleNamespace(
workspace_uuid='w',
workspace=SimpleNamespace(permissions=frozenset({Permission.PROVIDER_SECRET_MANAGE.value})),
)
)
await router.initialize()
return app.test_client(), ap
@pytest.mark.asyncio
@pytest.mark.parametrize('upstream,status,code', CASES)
async def test_real_model_test_route_safe_error(upstream, status, code):
error = CodexRequester._http_error(upstream)
client, ap = await client_for(error)
response = await client.post('/api/v1/provider/models/llm/model/test', json={}, headers={'X-API-Key': 'synthetic'})
body = await response.get_json()
assert response.status_code == status
assert body['code'] == code
assert body['msg'] == str(error)
ap.logger.error.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('error', [ValueError('private-value-secret'), SQLAlchemyError('private-sql-secret')])
async def test_real_model_test_route_unexpected_errors_hidden(error):
client, _ = await client_for(error)
response = await client.post('/api/v1/provider/models/llm/model/test', json={}, headers={'X-API-Key': 'synthetic'})
assert response.status_code == 500
assert 'secret' not in await response.get_data(as_text=True)
@@ -0,0 +1,147 @@
"""Temporary Codex models use saved, tenant-scoped providers and synthetic SQLite credentials."""
import time
from types import SimpleNamespace
import pytest
import pytest_asyncio
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.model import CodexCredential, ModelProvider
from langbot.pkg.provider.modelmgr.codex_auth import BASE_URL
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
from langbot.pkg.provider.modelmgr.requesters.codex import CodexRequester
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
from tests.unit_tests.provider.conftest import (
TEST_EXECUTION_CONTEXT,
TEST_WORKSPACE_UUID,
FakeProviderAPIRequester,
)
@pytest_asyncio.fixture
async def manager(tmp_path, mock_app_for_modelmgr):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "temporary-codex.db"}')
async with engine.begin() as conn:
await conn.run_sync(ModelProvider.__table__.create)
await conn.run_sync(CodexCredential.__table__.create)
for uuid, workspace, kind in (
('saved', TEST_WORKSPACE_UUID, 'openai-codex'),
('foreign', 'another-workspace', 'openai-codex'),
('api', TEST_WORKSPACE_UUID, 'fake-requester'),
):
await conn.execute(
sa.insert(ModelProvider).values(
uuid=uuid,
workspace_uuid=workspace,
name='Saved provider',
requester=kind,
base_url=BASE_URL,
api_keys=[],
)
)
await conn.execute(
sa.insert(CodexCredential).values(
provider_uuid='saved',
workspace_uuid=TEST_WORKSPACE_UUID,
payload={
'tokens': {
'access_token': 'synthetic-access',
'refresh_token': 'synthetic-refresh',
'account_id': 'synthetic-account',
'expires_at': time.time() + 3600,
}
},
)
)
async def execute(statement):
async with engine.begin() as conn:
return await conn.execute(statement)
mock_app_for_modelmgr.persistence_mgr = SimpleNamespace(execute_async=execute)
mgr = ModelManager(mock_app_for_modelmgr)
mgr.requester_dict = {'openai-codex': CodexRequester, 'fake-requester': FakeProviderAPIRequester}
try:
yield mgr
finally:
await engine.dispose()
def info(provider_uuid='saved', **inline):
result = {'name': 'codex-test', 'provider': {'requester': 'openai-codex', **inline}}
if provider_uuid is not None:
result['provider_uuid'] = provider_uuid
return result
@pytest.mark.asyncio
@pytest.mark.parametrize(
'inline',
[
{},
{'uuid': 'saved'},
{
'requester': 'fake-requester',
'api_keys': ['untrusted'],
'base_url': 'https://untrusted.invalid',
'workspace_uuid': 'another-workspace',
},
],
)
async def test_codex_temporary_model_resolves_saved_provider_and_real_credentials(manager, inline):
model = await manager.init_temporary_runtime_llm_model(TEST_EXECUTION_CONTEXT, info(**inline))
provider = model.provider
assert provider.provider_entity.uuid == 'saved'
assert provider.provider_entity.requester == 'openai-codex'
assert provider.provider_entity.api_keys == []
assert provider.provider_entity.base_url == BASE_URL
assert isinstance(provider.requester, CodexRequester)
tokens = await provider.requester.auth.access(provider.requester.workspace, provider.requester.provider)
assert tokens['access_token'] == 'synthetic-access'
assert model.model_entity.provider_uuid == 'saved'
@pytest.mark.asyncio
async def test_codex_temporary_model_accepts_inline_saved_identity(manager):
model = await manager.init_temporary_runtime_llm_model(TEST_EXECUTION_CONTEXT, info(None, uuid='saved'))
assert model.provider.provider_entity.name == 'Saved provider'
@pytest.mark.asyncio
@pytest.mark.parametrize('identity', ['missing', 'foreign', None])
async def test_codex_temporary_model_rejects_unavailable_identity(manager, identity):
with pytest.raises(WorkspaceNotFoundError):
await manager.init_temporary_runtime_llm_model(
TEST_EXECUTION_CONTEXT, info(identity, workspace_uuid='another-workspace')
)
@pytest.mark.asyncio
async def test_codex_temporary_model_rejects_non_codex_saved_provider(manager):
with pytest.raises(ValueError):
await manager.init_temporary_runtime_llm_model(TEST_EXECUTION_CONTEXT, info('api'))
@pytest.mark.asyncio
async def test_codex_temporary_model_rejects_conflicting_identities(manager):
with pytest.raises(ValueError):
await manager.init_temporary_runtime_llm_model(TEST_EXECUTION_CONTEXT, info('saved', uuid='foreign'))
@pytest.mark.asyncio
async def test_api_key_temporary_model_preserves_inline_configuration(manager):
model = await manager.init_temporary_runtime_llm_model(
TEST_EXECUTION_CONTEXT,
{
'name': 'api-model',
'provider': {
'requester': 'fake-requester',
'api_keys': ['synthetic-key'],
'base_url': 'https://api.example.invalid',
},
},
)
assert model.provider.provider_entity.api_keys == ['synthetic-key']
assert model.provider.provider_entity.base_url == 'https://api.example.invalid'
@@ -486,6 +486,7 @@ export default function ModelsPanel({
// Get the provider info
const provider = providers.find((p) => p.uuid === providerUuid);
const providerData = {
uuid: providerUuid,
requester: provider?.requester || '',
base_url: provider?.base_url || '',
api_keys: provider?.api_keys || [],
@@ -495,7 +496,7 @@ export default function ModelsPanel({
await httpClient.testLLMModel('_', {
uuid: '',
name,
provider_uuid: '',
provider_uuid: providerUuid,
provider: providerData,
abilities,
reasoning_config: reasoningConfig,
@@ -505,7 +506,7 @@ export default function ModelsPanel({
await httpClient.testEmbeddingModel('_', {
uuid: '',
name,
provider_uuid: '',
provider_uuid: providerUuid,
provider: providerData,
extra_args: extraArgsObj,
} as never);
@@ -513,7 +514,7 @@ export default function ModelsPanel({
await httpClient.testRerankModel('_', {
uuid: '',
name,
provider_uuid: '',
provider_uuid: providerUuid,
provider: providerData,
extra_args: extraArgsObj,
} as never);
@@ -536,6 +537,29 @@ export default function ModelsPanel({
expandedProviders.forEach((uuid) => loadProviderModels(uuid));
}
async function handleProviderDeleted(providerUuid: string) {
setProviders((prev) =>
prev.filter((provider) => provider.uuid !== providerUuid),
);
setProviderModels((prev) => {
const next = { ...prev };
delete next[providerUuid];
return next;
});
setExpandedProviders((prev) => {
const next = new Set(prev);
next.delete(providerUuid);
return next;
});
await Promise.all([
loadProviders(),
...Array.from(expandedProviders)
.filter((uuid) => uuid !== providerUuid)
.map((uuid) => loadProviderModels(uuid)),
]);
setProviderFormOpen(false);
}
function renderProviderCard(
provider: ModelProvider,
isLangBotModels: boolean = false,
@@ -666,8 +690,14 @@ export default function ModelsPanel({
)}
</PanelBody>
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
<DialogContent className="w-full max-w-[calc(100%-2rem)] p-4 sm:max-w-[600px] sm:p-6">
<Dialog
open={providerFormOpen}
onOpenChange={(open) => {
if (!open) handleFormClose();
else setProviderFormOpen(true);
}}
>
<DialogContent className="w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto p-4 sm:max-w-[600px] sm:p-6">
<DialogHeader>
<DialogTitle>
{editingProviderId
@@ -675,11 +705,15 @@ export default function ModelsPanel({
: t('models.addProvider')}
</DialogTitle>
</DialogHeader>
<ProviderForm
providerId={editingProviderId || undefined}
onFormSubmit={handleFormClose}
onFormCancel={() => setProviderFormOpen(false)}
/>
{providerFormOpen && (
<ProviderForm
key={editingProviderId || 'new'}
providerId={editingProviderId || undefined}
onFormSubmit={handleFormClose}
onFormCancel={handleFormClose}
onProviderDeleted={canManage ? handleProviderDeleted : undefined}
/>
)}
</DialogContent>
</Dialog>
</>
@@ -0,0 +1,194 @@
import { useEffect, useRef, useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { toast } from 'sonner';
import { copyToClipboard } from '@/app/utils/clipboard';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import type { useCodexLogin } from './useCodexLogin';
export default function CodexAccountSection({
login,
providerId,
}: {
login: ReturnType<typeof useCodexLogin>;
providerId?: string;
}) {
const { t } = useTranslation();
const [confirmDisconnect, setConfirmDisconnect] = useState(false);
const [copied, setCopied] = useState(false);
const [copyFailed, setCopyFailed] = useState(false);
const { phase, device } = login;
const copyGeneration = useRef(0);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const generation = copyGeneration;
setCopied(false);
setCopyFailed(false);
return () => {
generation.current++;
if (copyTimer.current) clearTimeout(copyTimer.current);
};
}, [providerId, device?.authorization_id, device?.user_code, phase]);
const handleCopy = async () => {
if (!device) return;
const generation = ++copyGeneration.current;
if (copyTimer.current) clearTimeout(copyTimer.current);
let ok = false;
try {
ok = await copyToClipboard(device.user_code);
} catch {
// Clipboard failures are recoverable; never log device codes.
}
if (generation !== copyGeneration.current) return;
setCopied(ok);
setCopyFailed(!ok);
if (ok) {
toast.success(t('common.copySuccess'));
copyTimer.current = setTimeout(() => setCopied(false), 2000);
} else {
toast.error(t('common.copyFailed'));
}
};
const waiting = ['starting', 'loading', 'canceling'].includes(phase);
return (
<section
data-testid="codex-account"
aria-label={t('models.codex.account')}
className="min-w-0 rounded-lg border p-3 space-y-3 text-sm"
>
<div>
<h3 className="font-medium">{t('models.codex.account')}</h3>
<p className="mt-1 text-muted-foreground">
{t('models.codex.description')}
</p>
</div>
<p
role={phase === 'error' ? 'alert' : 'status'}
aria-live="polite"
className={
phase === 'error' ? 'text-destructive' : 'text-muted-foreground'
}
>
{t(`models.codex.${phase}`)}
</p>
{device && phase === 'pending' && (
<div className="space-y-3">
<p className="text-muted-foreground">
{t('models.codex.instructions')}
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="select-all break-all rounded border bg-muted px-3 py-2 text-base font-semibold tracking-wider">
{device.user_code}
</code>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopy}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
{t(copied ? 'models.codex.copied' : 'models.codex.copyCode')}
</Button>
</div>
{copyFailed && (
<p role="status" className="text-muted-foreground">
{t('models.codex.copyManually')}
</p>
)}
<a
href={device.verification_uri}
target="_blank"
rel="noopener noreferrer"
className="inline-flex text-sm font-medium underline underline-offset-4"
>
{t('models.codex.continueAtOpenAI')}
</a>
<p className="text-xs text-muted-foreground">
{t('models.codex.expiresAt', {
time: new Date(device.expires_at * 1000).toLocaleTimeString(),
})}
</p>
{login.retrying && (
<p role="status" className="text-xs text-muted-foreground">
{t('models.codex.retrying')}
</p>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => providerId && void login.cancel(providerId)}
>
{t('models.codex.cancelSignIn')}
</Button>
</div>
)}
{providerId && !waiting && phase !== 'pending' && (
<div className="flex flex-wrap gap-2">
{phase !== 'connected' && (
<Button type="submit" size="sm" variant="outline">
{t(
phase === 'error' || phase === 'expired'
? 'models.codex.tryAgain'
: 'models.codex.signIn',
)}
</Button>
)}
{phase === 'connected' && (
<>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setConfirmDisconnect(false);
void login.start(providerId);
}}
>
{t('models.codex.reconnect')}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setConfirmDisconnect(true)}
>
{t('models.codex.disconnect')}
</Button>
</>
)}
</div>
)}
{confirmDisconnect && phase === 'connected' && (
<div className="space-y-2 border-t pt-3">
<p>{t('models.codex.disconnectConfirm')}</p>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => {
setConfirmDisconnect(false);
if (providerId) void login.disconnect(providerId);
}}
>
{t('models.codex.confirmDisconnect')}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setConfirmDisconnect(false)}
>
{t('common.cancel')}
</Button>
</div>
</div>
)}
</section>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useEffect, useState, useRef } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -16,12 +16,31 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { DialogFooter } from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '../../types';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { toast } from 'sonner';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { CustomApiError } from '@/app/infra/entities/common';
import { cn } from '@/lib/utils';
import { Check, ChevronDown, Search } from 'lucide-react';
import { providerPayload } from './codexPolicy';
import { useCodexLogin } from './useCodexLogin';
import CodexAccountSection from './CodexAccountSection';
const getFormSchema = (t: (key: string) => string) =>
z.object({
@@ -35,12 +54,14 @@ interface ProviderFormProps {
providerId?: string;
onFormSubmit: (providerUuid: string) => void | Promise<void>;
onFormCancel: () => void;
onProviderDeleted?: (providerUuid: string) => void | Promise<void>;
}
export default function ProviderForm({
providerId,
onFormSubmit,
onFormCancel,
onProviderDeleted,
}: ProviderFormProps) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
@@ -54,7 +75,31 @@ export default function ProviderForm({
api_key: '',
},
});
const { setValue } = form;
const { reset } = form;
const isCodex = form.watch('requester') === 'openai-codex';
const [savedProviderId, setSavedProviderId] = useState(providerId);
const savedId = useRef(providerId);
const submitting = useRef(false);
const deleting = useRef(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteError, setDeleteError] = useState('');
const [mutableProviderLoaded, setMutableProviderLoaded] = useState(false);
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>(
'loading',
);
const [loadAttempt, setLoadAttempt] = useState(0);
const mounted = useRef(true);
const login = useCodexLogin(isCodex, providerId);
const loginActive = ['starting', 'pending', 'canceling', 'loading'].includes(
login.phase,
);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const [requesterList, setRequesterList] = useState<
{
@@ -68,72 +113,59 @@ export default function ProviderForm({
>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const loadRequesters = useCallback(async () => {
const resp = await httpClient.getProviderRequesters();
setRequesterList(
resp.requesters
.filter((item) => item.name !== 'space-chat-completions')
.map((item) => ({
label: extractI18nObject(item.label),
value: item.name,
category: item.spec.provider_category || 'manufacturer',
defaultUrl:
item.spec.config
.find((c) => c.name === 'base_url')
?.default?.toString() || '',
description: extractI18nObject(item.description),
alias: item.spec.alias || '',
})),
);
}, []);
const loadProvider = useCallback(
async (id: string) => {
const resp = await httpClient.getModelProvider(id);
const provider = resp.provider;
setValue('name', provider.name);
setValue('requester', provider.requester);
setValue('base_url', provider.base_url);
setValue('api_key', provider.api_keys?.[0] || '');
},
[setValue],
);
useEffect(() => {
// Ignore both success and failure from a closed form or superseded attempt.
let canceled = false;
setLoadState('loading');
setMutableProviderLoaded(false);
async function init() {
await loadRequesters();
if (providerId) {
await loadProvider(providerId);
try {
const [requesters, detail] = await Promise.all([
httpClient.getProviderRequesters(),
providerId ? httpClient.getModelProvider(providerId) : null,
]);
if (canceled) return;
setRequesterList(
requesters.requesters
.filter((item) => item.name !== LANGBOT_MODELS_PROVIDER_REQUESTER)
.map((item) => ({
label: extractI18nObject(item.label),
value: item.name,
category: item.spec.provider_category || 'manufacturer',
defaultUrl:
item.spec.config
.find((c) => c.name === 'base_url')
?.default?.toString() || '',
description: extractI18nObject(item.description),
alias: item.spec.alias || '',
})),
);
if (detail) {
const provider = detail.provider;
reset({
name: provider.name,
requester: provider.requester,
base_url: provider.base_url,
api_key: provider.api_keys?.[0] || '',
});
setMutableProviderLoaded(
provider.uuid === providerId &&
provider.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
);
}
setLoadState('ready');
} catch {
if (!canceled) setLoadState('error');
}
}
init();
}, [providerId, loadProvider, loadRequesters]);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearchQuery('');
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Focus search input when dropdown opens
useEffect(() => {
if (isOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isOpen]);
void init();
return () => {
canceled = true;
};
}, [providerId, reset, loadAttempt]);
// Filter requesters based on search query
const filteredRequesters = requesterList.filter(
@@ -163,29 +195,105 @@ export default function ProviderForm({
};
async function handleFormSubmit(values: z.infer<typeof formSchema>) {
const data = {
name: values.name,
requester: values.requester,
base_url: values.base_url,
api_keys: values.api_key ? [values.api_key] : [],
};
if (
loadState !== 'ready' ||
submitting.current ||
deleting.current ||
(isCodex && loginActive)
)
return;
submitting.current = true;
const data = providerPayload(values);
try {
let savedProviderUuid = providerId;
if (providerId) {
await httpClient.updateModelProvider(providerId, data);
toast.success(t('models.providerSaved'));
if (savedId.current) {
await httpClient.updateModelProvider(savedId.current, data);
} else {
const response = await httpClient.createModelProvider(data);
savedProviderUuid = response.uuid;
toast.success(t('models.providerCreated'));
savedId.current = response.uuid;
if (mounted.current) setSavedProviderId(response.uuid);
}
if (!mounted.current) return;
if (isCodex && login.phase !== 'connected') {
await login.start(savedId.current);
} else {
toast.success(t('models.providerSaved'));
await onFormSubmit(savedId.current);
}
await onFormSubmit(savedProviderUuid as string);
} catch (err) {
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
if (mounted.current)
toast.error(
t('models.providerSaveError') + (err as CustomApiError).msg,
);
} finally {
submitting.current = false;
}
}
async function handleDelete() {
if (
loadState !== 'ready' ||
!providerId ||
!mutableProviderLoaded ||
!onProviderDeleted ||
deleting.current ||
submitting.current ||
(isCodex && loginActive)
)
return;
deleting.current = true;
setIsDeleting(true);
setDeleteError('');
try {
await httpClient.deleteModelProvider(providerId, true);
} catch (err) {
const detail =
(err as CustomApiError | null)?.msg ||
(err instanceof Error ? err.message : '');
setDeleteError(t('models.providerDeleteError') + detail);
deleting.current = false;
setIsDeleting(false);
return;
}
toast.success(t('models.providerDeleted'));
await onProviderDeleted(providerId);
}
if (loadState !== 'ready') {
return (
<>
{loadState === 'loading' ? (
<div
role="status"
aria-label={t('common.loading')}
className="flex justify-center py-8"
>
<LoadingSpinner text={t('common.loading')} />
</div>
) : (
<p role="alert" className="py-8 text-sm text-destructive">
{t('models.loadError')}
</p>
)}
<DialogFooter>
{loadState === 'error' && (
<Button
type="button"
onClick={() => {
setLoadState('loading');
setLoadAttempt((attempt) => attempt + 1);
}}
>
{t('common.retry')}
</Button>
)}
<Button type="button" variant="outline" onClick={onFormCancel}>
{t('common.cancel')}
</Button>
</DialogFooter>
</>
);
}
return (
<Form {...form}>
<form
@@ -202,7 +310,12 @@ export default function ProviderForm({
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
<Input
{...field}
disabled={
form.formState.isSubmitting || (isCodex && loginActive)
}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -222,45 +335,65 @@ export default function ProviderForm({
{t('models.requester')}
<span className="text-red-500">*</span>
</FormLabel>
<div ref={dropdownRef} className="relative">
<Popover
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) setSearchQuery('');
}}
>
{/* Trigger button */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'ring-2 ring-ring ring-offset-2',
)}
>
{selectedRequester ? (
<div className="flex items-center gap-2">
<img
src={httpClient.getProviderRequesterIconURL(
selectedRequester.value,
)}
alt={selectedRequester.label}
className="h-5 w-5 rounded"
/>
<span>{selectedRequester.label}</span>
</div>
) : (
<span className="text-muted-foreground">
{t('models.selectRequester')}
</span>
)}
<ChevronDown
<PopoverTrigger asChild>
<button
type="button"
disabled={
form.formState.isSubmitting ||
(isCodex && (!!savedProviderId || loginActive))
}
aria-expanded={isOpen}
className={cn(
'h-4 w-4 opacity-50 transition-transform',
isOpen && 'rotate-180',
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'ring-2 ring-ring ring-offset-2',
)}
/>
</button>
>
{selectedRequester ? (
<div className="flex items-center gap-2">
<img
src={httpClient.getProviderRequesterIconURL(
selectedRequester.value,
)}
alt={selectedRequester.label}
className="h-5 w-5 rounded"
/>
<span>{selectedRequester.label}</span>
</div>
) : (
<span className="text-muted-foreground">
{t('models.selectRequester')}
</span>
)}
<ChevronDown
className={cn(
'h-4 w-4 opacity-50 transition-transform',
isOpen && 'rotate-180',
)}
/>
</button>
</PopoverTrigger>
{/* Dropdown */}
{/* Unmount on close so an exiting layer cannot eat Dialog Escape. */}
{isOpen && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95">
<PopoverContent
align="start"
collisionPadding={8}
className="flex max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-16px)] flex-col overflow-hidden p-0"
onOpenAutoFocus={(event) => {
event.preventDefault();
searchInputRef.current?.focus();
}}
>
{/* Search input */}
<div className="flex items-center border-b px-3">
<div className="flex shrink-0 items-center border-b px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
ref={searchInputRef}
@@ -275,7 +408,13 @@ export default function ProviderForm({
</div>
{/* Options list */}
<div className="max-h-[300px] overflow-y-auto p-1">
<div
className="min-h-0 max-h-[300px] overflow-y-auto overscroll-contain p-1"
// The dialog's document-level scroll lock treats this portal as outside.
// Keep native list scrolling without forwarding gestures to that lock.
onWheel={(event) => event.stopPropagation()}
onTouchMove={(event) => event.stopPropagation()}
>
{Object.entries(groupedRequesters).map(
([category, items]) => {
if (items.length === 0) return null;
@@ -288,6 +427,11 @@ export default function ProviderForm({
<button
key={r.value}
type="button"
disabled={
!!providerId &&
r.value === 'openai-codex' &&
!isCodex
}
onClick={() => {
field.onChange(r.value);
const req = requesterList.find(
@@ -337,9 +481,9 @@ export default function ProviderForm({
</div>
)}
</div>
</div>
</PopoverContent>
)}
</div>
</Popover>
<FormMessage />
{selectedRequester?.description && (
<p className="text-sm text-muted-foreground">
@@ -351,40 +495,131 @@ export default function ProviderForm({
}}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.requestURL')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isCodex ? (
<CodexAccountSection login={login} providerId={savedProviderId} />
) : (
<>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.requestURL')}</FormLabel>
<FormControl>
<Input
{...field}
disabled={
form.formState.isSubmitting || (isCodex && loginActive)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.apiKey')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{t('models.apiKey')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<DialogFooter>
<Button type="submit">{t('common.save')}</Button>
<Button type="button" variant="outline" onClick={onFormCancel}>
{t('common.cancel')}
</Button>
<DialogFooter className="flex-row flex-wrap items-start justify-between sm:justify-between">
{providerId && mutableProviderLoaded && onProviderDeleted && (
<Button
type="button"
variant="destructive"
disabled={
isDeleting ||
form.formState.isSubmitting ||
(isCodex && loginActive)
}
onClick={() => {
setDeleteError('');
setDeleteConfirmOpen(true);
}}
>
{t('common.delete')}
</Button>
)}
<div className="ml-auto flex flex-col gap-2 sm:flex-row">
{(!isCodex || !savedProviderId || login.phase === 'connected') && (
<Button
type="submit"
disabled={
isDeleting ||
form.formState.isSubmitting ||
(isCodex && loginActive)
}
>
{isCodex
? t(
login.phase === 'connected'
? 'models.codex.done'
: 'models.codex.saveAndSignIn',
)
: t('common.save')}
</Button>
)}
<Button
type="button"
variant="outline"
disabled={isDeleting}
onClick={onFormCancel}
>
{t('common.cancel')}
</Button>
</div>
</DialogFooter>
<AlertDialog
open={deleteConfirmOpen}
onOpenChange={(open) => {
if (!deleting.current) setDeleteConfirmOpen(open);
}}
>
{deleteConfirmOpen && (
<AlertDialogContent className="max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-lg">
<AlertDialogHeader>
<AlertDialogTitle>{t('common.delete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('models.deleteProviderCascadeConfirmation')}
</AlertDialogDescription>
</AlertDialogHeader>
{deleteError && (
<p
role="alert"
className="text-sm text-destructive break-words"
>
{deleteError}
</p>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
{t('common.cancel')}
</AlertDialogCancel>
<Button
type="button"
variant="destructive"
disabled={isDeleting}
aria-busy={isDeleting}
onClick={handleDelete}
>
{t('common.delete')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
)}
</AlertDialog>
</form>
</Form>
);
@@ -0,0 +1,26 @@
/** Subscription credentials are server-owned, never API-key form values. */
export function providerPayload(values: {
name: string;
requester: string;
base_url: string;
api_key?: string;
}) {
const subscription = values.requester === 'openai-codex';
return {
name: values.name,
requester: values.requester,
base_url: subscription
? 'https://chatgpt.com/backend-api/codex'
: values.base_url,
api_keys: subscription ? [] : values.api_key ? [values.api_key] : [],
};
}
export function pollDelay(interval: number, failures = 0): number {
const seconds = Number.isFinite(interval) && interval > 0 ? interval : 5;
return Math.max(seconds, Math.min(60, seconds * 2 ** failures)) * 1000;
}
export function isCodexVerificationUri(uri: string): boolean {
return uri === 'https://auth.openai.com/codex/device';
}
@@ -0,0 +1,203 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import type { CodexDeviceAuthorization } from '@/app/infra/entities/codex';
import { isCodexVerificationUri, pollDelay } from './codexPolicy';
type Phase =
| 'disconnected'
| 'loading'
| 'starting'
| 'pending'
| 'connected'
| 'expired'
| 'error'
| 'canceling';
/** One in-memory authorization, sequential polls, and stale-response fencing. */
export function useCodexLogin(enabled: boolean, providerId?: string) {
const [phase, setPhase] = useState<Phase>('disconnected');
const [device, setDevice] = useState<CodexDeviceAuthorization | null>(null);
const [retrying, setRetrying] = useState(false);
const generation = useRef(0);
const busy = useRef(false);
const attempt = useRef<{ uuid: string; authorizationId: string } | null>(
null,
);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const deadline = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const request = useRef<AbortController | null>(null);
const stop = useCallback(() => {
generation.current++;
clearTimeout(timer.current);
clearTimeout(deadline.current);
request.current?.abort();
busy.current = false;
const pending = attempt.current;
attempt.current = null;
return pending;
}, []);
const clearPending = useCallback(async () => {
const pending = stop();
if (pending)
await httpClient.cancelCodexDeviceLogin(
pending.uuid,
pending.authorizationId,
);
}, [stop]);
const loadStatus = useCallback(async (uuid: string) => {
const version = generation.current;
request.current = new AbortController();
setPhase('loading');
try {
const status = await httpClient.getCodexAuthStatus(
uuid,
request.current.signal,
);
if (version === generation.current) setPhase(status.status);
} catch {
if (version === generation.current) setPhase('error');
}
}, []);
useEffect(() => {
setDevice(null);
setPhase('disconnected');
if (enabled && providerId) void loadStatus(providerId);
return () => {
// Device creation is deliberately not aborted: its late response must be
// canceled server-side even if this form has already unmounted.
void clearPending().catch(() => {});
};
}, [enabled, providerId, loadStatus, clearPending]);
async function start(uuid: string) {
if (busy.current) return;
const old = stop();
busy.current = true;
const version = generation.current;
setPhase('starting');
setDevice(null);
setRetrying(false);
try {
if (old)
await httpClient.cancelCodexDeviceLogin(old.uuid, old.authorizationId);
if (version !== generation.current) return;
const authorization = await httpClient.startCodexDeviceLogin(uuid);
if (version !== generation.current) {
await httpClient.cancelCodexDeviceLogin(
uuid,
authorization.authorization_id,
);
return;
}
attempt.current = {
uuid,
authorizationId: authorization.authorization_id,
};
if (
!isCodexVerificationUri(authorization.verification_uri) ||
!Number.isFinite(authorization.expires_at)
) {
await clearPending();
setPhase('error');
return;
}
setDevice(authorization);
setPhase('pending');
let interval = authorization.interval;
let failures = 0;
request.current = new AbortController();
const signal = request.current.signal;
const expire = () => {
if (version !== generation.current) return;
void clearPending().catch(() => {});
setDevice(null);
setPhase('expired');
};
deadline.current = setTimeout(
expire,
Math.max(0, authorization.expires_at * 1000 - Date.now()),
);
const poll = async () => {
if (version !== generation.current) return;
if (Date.now() >= authorization.expires_at * 1000) {
expire();
return;
}
try {
const result = await httpClient.pollCodexDeviceLogin(
uuid,
authorization.authorization_id,
signal,
);
if (version !== generation.current) return;
if (result.status !== 'pending') {
attempt.current = null;
stop();
setDevice(null);
setPhase(result.status);
return;
}
interval = result.interval ?? interval;
failures = 0;
setRetrying(false);
} catch (error) {
if (version !== generation.current) return;
const code = (error as { code?: number }).code;
if (
(code === -1 || (code !== undefined && code >= 500)) &&
failures < 3
) {
failures++;
setRetrying(true);
} else {
void clearPending().catch(() => {});
setDevice(null);
setPhase('error');
return;
}
}
timer.current = setTimeout(poll, pollDelay(interval, failures));
};
timer.current = setTimeout(poll, pollDelay(interval));
} catch {
if (version === generation.current) {
busy.current = false;
setPhase('error');
}
}
}
async function cancel(uuid: string) {
setPhase('canceling');
setDevice(null);
const pending = clearPending();
const version = generation.current;
try {
await pending;
if (version === generation.current) await loadStatus(uuid);
} catch {
if (version === generation.current) setPhase('error');
}
}
async function disconnect(uuid: string) {
if (busy.current) return;
busy.current = true;
setPhase('loading');
const version = generation.current;
try {
await httpClient.disconnectCodex(uuid);
if (version === generation.current) await loadStatus(uuid);
} catch {
if (version === generation.current) setPhase('error');
} finally {
busy.current = false;
}
}
return { phase, device, retrying, start, cancel, disconnect, loadStatus };
}
+19
View File
@@ -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;
}
+51 -2
View File
@@ -1,4 +1,9 @@
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
import type {
CodexAuthStatus,
CodexDeviceAuthorization,
CodexDevicePoll,
} from '@/app/infra/entities/codex';
import {
ApiRespProviderRequesters,
ApiRespProviderRequester,
@@ -126,8 +131,52 @@ export class BackendClient extends BaseHttpClient {
return this.put(`/api/v1/provider/providers/${uuid}`, provider);
}
public deleteModelProvider(uuid: string): Promise<object> {
return this.delete(`/api/v1/provider/providers/${uuid}`);
public deleteModelProvider(uuid: string, cascade = false): Promise<object> {
return this.delete(
`/api/v1/provider/providers/${uuid}${cascade ? '?cascade=true' : ''}`,
);
}
public getCodexAuthStatus(
uuid: string,
signal?: AbortSignal,
): Promise<CodexAuthStatus> {
return this.get(
`/api/v1/provider/providers/${uuid}/codex/status`,
undefined,
{ signal },
);
}
public startCodexDeviceLogin(
uuid: string,
): Promise<CodexDeviceAuthorization> {
return this.post(`/api/v1/provider/providers/${uuid}/codex/device`, {});
}
public pollCodexDeviceLogin(
uuid: string,
authorizationId: string,
signal?: AbortSignal,
): Promise<CodexDevicePoll> {
return this.post(
`/api/v1/provider/providers/${uuid}/codex/device/poll`,
{ authorization_id: authorizationId },
{ signal },
);
}
public cancelCodexDeviceLogin(
uuid: string,
authorizationId: string,
): Promise<object> {
return this.delete(
`/api/v1/provider/providers/${uuid}/codex/device/${encodeURIComponent(authorizationId)}`,
);
}
public disconnectCodex(uuid: string): Promise<object> {
return this.delete(`/api/v1/provider/providers/${uuid}/codex/auth`);
}
public scanProviderModels(
+24 -24
View File
@@ -1,39 +1,39 @@
/**
* Copy text to clipboard with fallback support
* Tries to use modern Clipboard API first, falls back to execCommand if not available
*
* @param text - The text to copy to clipboard
* @returns Promise<boolean> - true if successful, false otherwise
*/
/** Copy text using the Clipboard API, with a focus-trap-safe legacy fallback. */
export async function copyToClipboard(text: string): Promise<boolean> {
// Try modern Clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('[Clipboard] Modern API failed, trying fallback:', err);
// Fall through to legacy method
}
} catch {
// Permission/security errors can include sensitive text; do not log them.
}
// Fallback to legacy execCommand method
const previousFocus = document.activeElement as HTMLElement | null;
const textArea = document.createElement('textarea');
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
// Radix modal focus scopes reject focus on elements appended to body.
const container =
previousFocus?.closest('[role="dialog"], [role="alertdialog"]') ??
document.body;
container.appendChild(textArea);
textArea.focus({ preventScroll: true });
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
return successful;
} catch (err) {
console.error('[Clipboard] Fallback method failed:', err);
if (
document.activeElement !== textArea ||
textArea.selectionEnd !== text.length
)
return false;
return document.execCommand('copy');
} catch {
return false;
} finally {
textArea.remove();
if (previousFocus?.isConnected)
previousFocus.focus({ preventScroll: true });
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
className,
)}
{...props}
+33
View File
@@ -182,6 +182,37 @@ const enUS = {
help: 'Get Help',
},
models: {
codex: {
account: 'ChatGPT subscription',
description:
'Sign in with your ChatGPT account. Subscription access is separate from OpenAI API billing; model availability and usage limits depend on your plan.',
disconnected: 'Not connected',
loading: 'Checking connection…',
starting: 'Starting sign-in…',
pending: 'Waiting for authorization',
connected: 'Connected',
expired: 'Sign-in expired. Start again to get a new code.',
error: 'Unable to sign in. Check your connection and try again.',
canceling: 'Canceling sign-in…',
saveAndSignIn: 'Save and sign in',
done: 'Done',
instructions:
'Enter this code on the OpenAI page. Keep this dialog open until sign-in completes.',
copyCode: 'Copy code',
copied: 'Copied',
copyManually: 'Select and copy the code manually.',
continueAtOpenAI: 'Continue at OpenAI',
expiresAt: 'Code expires at {{time}}.',
retrying: 'Connection interrupted. Retrying automatically…',
cancelSignIn: 'Cancel sign-in',
tryAgain: 'Try again',
signIn: 'Sign in',
reconnect: 'Reconnect',
disconnect: 'Disconnect',
disconnectConfirm:
'Disconnect this provider? Its models will stop working until you sign in again. This does not cancel your ChatGPT subscription.',
confirmDisconnect: 'Confirm disconnect',
},
title: 'Models',
description: 'Configure and manage models that can be used in pipelines',
createModel: 'Create Model',
@@ -316,6 +347,8 @@ const enUS = {
providerSaveError: 'Failed to save provider: ',
providerDeleted: 'Provider deleted',
providerDeleteError: 'Failed to delete provider: ',
deleteProviderCascadeConfirmation:
'Delete this provider and ALL models it contains? This action is irreversible and cannot be undone.',
deleteProviderConfirmation:
'Are you sure you want to delete this provider?',
loadError: 'Failed to load data',
+34
View File
@@ -187,6 +187,38 @@ const esES = {
help: 'Obtener ayuda',
},
models: {
codex: {
account: 'Suscripción de ChatGPT',
description:
'Inicia sesión con tu cuenta de ChatGPT. La suscripción es independiente de la facturación de la API de OpenAI; los modelos y límites dependen de tu plan.',
disconnected: 'Sin conexión',
loading: 'Comprobando conexión…',
starting: 'Iniciando sesión…',
pending: 'Esperando autorización',
connected: 'Conectado',
expired: 'El inicio de sesión ha caducado. Solicita un nuevo código.',
error:
'No se pudo iniciar sesión. Comprueba la conexión e inténtalo de nuevo.',
canceling: 'Cancelando inicio de sesión…',
saveAndSignIn: 'Guardar e iniciar sesión',
done: 'Listo',
instructions:
'Introduce este código en la página de OpenAI. Mantén este diálogo abierto hasta completar el inicio de sesión.',
copyCode: 'Copiar código',
copied: 'Copiado',
copyManually: 'Selecciona y copia el código manualmente.',
continueAtOpenAI: 'Continuar en OpenAI',
expiresAt: 'El código caduca a las {{time}}.',
retrying: 'Conexión interrumpida. Reintentando automáticamente…',
cancelSignIn: 'Cancelar inicio de sesión',
tryAgain: 'Reintentar',
signIn: 'Iniciar sesión',
reconnect: 'Reconectar',
disconnect: 'Desconectar',
disconnectConfirm:
'¿Desconectar este proveedor? Sus modelos dejarán de funcionar hasta que vuelvas a iniciar sesión. Esto no cancela tu suscripción de ChatGPT.',
confirmDisconnect: 'Confirmar desconexión',
},
title: 'Modelos',
description:
'Configura y gestiona los modelos que se pueden usar en los Pipelines',
@@ -324,6 +356,8 @@ const esES = {
providerSaveError: 'Error al guardar el proveedor: ',
providerDeleted: 'Proveedor eliminado',
providerDeleteError: 'Error al eliminar el proveedor: ',
deleteProviderCascadeConfirmation:
'¿Eliminar este proveedor y TODOS los modelos que contiene? Esta acción es irreversible y no se puede deshacer.',
deleteProviderConfirmation:
'¿Estás seguro de que deseas eliminar este proveedor?',
loadError: 'Error al cargar datos',
+34
View File
@@ -185,6 +185,38 @@ const jaJP = {
help: 'ヘルプドキュメントを見る',
},
models: {
codex: {
account: 'ChatGPT サブスクリプション',
description:
'ChatGPT アカウントでログインします。サブスクリプションと OpenAI API の課金は別です。利用可能なモデルと使用制限はプランによって異なります。',
disconnected: '未接続',
loading: '接続を確認中…',
starting: 'ログインを開始中…',
pending: '認証を待機中',
connected: '接続済み',
expired:
'ログインの有効期限が切れました。新しいコードを取得してください。',
error: 'ログインできません。接続を確認して再試行してください。',
canceling: 'ログインをキャンセル中…',
saveAndSignIn: '保存してログイン',
done: '完了',
instructions:
'OpenAI のページでこのコードを入力してください。ログインが完了するまでこの画面を開いたままにしてください。',
copyCode: 'コードをコピー',
copied: 'コピー済み',
copyManually: 'コードを選択して手動でコピーしてください。',
continueAtOpenAI: 'OpenAI で続行',
expiresAt: 'コードの有効期限: {{time}}',
retrying: '接続が切れました。自動的に再試行しています…',
cancelSignIn: 'ログインをキャンセル',
tryAgain: '再試行',
signIn: 'ログイン',
reconnect: '再接続',
disconnect: '切断',
disconnectConfirm:
'このプロバイダーを切断しますか?再ログインするまでモデルは使用できません。ChatGPT のサブスクリプションは解約されません。',
confirmDisconnect: '切断を確認',
},
title: 'モデル設定',
description: 'パイプラインで使用できるモデルを設定・管理',
createModel: 'モデルを作成',
@@ -322,6 +354,8 @@ const jaJP = {
providerSaveError: 'プロバイダーの保存に失敗しました:',
providerDeleted: 'プロバイダーを削除しました',
providerDeleteError: 'プロバイダーの削除に失敗しました:',
deleteProviderCascadeConfirmation:
'このプロバイダーと、その中のすべてのモデルを削除しますか?この操作は取り消せず、元に戻せません。',
deleteProviderConfirmation: 'このプロバイダーを削除してもよろしいですか?',
loadError: 'データの読み込みに失敗しました',
chat: 'チャット',
+33
View File
@@ -184,6 +184,37 @@ const ruRU = {
help: 'Помощь',
},
models: {
codex: {
account: 'Подписка ChatGPT',
description:
'Войдите в аккаунт ChatGPT. Подписка не связана с оплатой API OpenAI; доступные модели и лимиты зависят от тарифа.',
disconnected: 'Не подключено',
loading: 'Проверка подключения…',
starting: 'Начало входа…',
pending: 'Ожидание авторизации',
connected: 'Подключено',
expired: 'Срок входа истёк. Получите новый код.',
error: 'Не удалось войти. Проверьте подключение и повторите попытку.',
canceling: 'Отмена входа…',
saveAndSignIn: 'Сохранить и войти',
done: 'Готово',
instructions:
'Введите этот код на странице OpenAI. Не закрывайте это окно до завершения входа.',
copyCode: 'Копировать код',
copied: 'Скопировано',
copyManually: 'Выделите и скопируйте код вручную.',
continueAtOpenAI: 'Продолжить в OpenAI',
expiresAt: 'Код действителен до {{time}}.',
retrying: 'Соединение прервано. Автоматическая повторная попытка…',
cancelSignIn: 'Отменить вход',
tryAgain: 'Повторить',
signIn: 'Войти',
reconnect: 'Переподключить',
disconnect: 'Отключить',
disconnectConfirm:
'Отключить этого провайдера? Его модели перестанут работать до повторного входа. Подписка ChatGPT не будет отменена.',
confirmDisconnect: 'Подтвердить отключение',
},
title: 'Модели',
description: 'Настройка и управление моделями, используемыми в конвейерах',
createModel: 'Создать модель',
@@ -322,6 +353,8 @@ const ruRU = {
providerSaveError: 'Ошибка сохранения провайдера: ',
providerDeleted: 'Провайдер удалён',
providerDeleteError: 'Ошибка удаления провайдера: ',
deleteProviderCascadeConfirmation:
'Удалить этого провайдера и ВСЕ содержащиеся в нём модели? Это действие необратимо, его нельзя отменить.',
deleteProviderConfirmation:
'Вы уверены, что хотите удалить этого провайдера?',
loadError: 'Не удалось загрузить данные',
+33
View File
@@ -181,6 +181,37 @@ const thTH = {
help: 'ขอความช่วยเหลือ',
},
models: {
codex: {
account: 'การสมัครสมาชิก ChatGPT',
description:
'ลงชื่อเข้าใช้ด้วยบัญชี ChatGPT การใช้งานผ่านการสมัครสมาชิกแยกจากการเรียกเก็บเงิน OpenAI API รุ่นโมเดลและขีดจำกัดการใช้งานขึ้นอยู่กับแพ็กเกจของคุณ',
disconnected: 'ยังไม่ได้เชื่อมต่อ',
loading: 'กำลังตรวจสอบการเชื่อมต่อ…',
starting: 'กำลังเริ่มลงชื่อเข้าใช้…',
pending: 'กำลังรอการอนุญาต',
connected: 'เชื่อมต่อแล้ว',
expired: 'การลงชื่อเข้าใช้หมดอายุ เริ่มใหม่เพื่อรับรหัสใหม่',
error: 'ไม่สามารถลงชื่อเข้าใช้ได้ ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง',
canceling: 'กำลังยกเลิกการลงชื่อเข้าใช้…',
saveAndSignIn: 'บันทึกและลงชื่อเข้าใช้',
done: 'เสร็จสิ้น',
instructions:
'ป้อนรหัสนี้บนหน้า OpenAI เปิดกล่องโต้ตอบนี้ไว้จนกว่าจะลงชื่อเข้าใช้เสร็จ',
copyCode: 'คัดลอกรหัส',
copied: 'คัดลอกแล้ว',
copyManually: 'เลือกรหัสและคัดลอกด้วยตนเอง',
continueAtOpenAI: 'ดำเนินการต่อที่ OpenAI',
expiresAt: 'รหัสหมดอายุเวลา {{time}}',
retrying: 'การเชื่อมต่อขัดข้อง กำลังลองใหม่โดยอัตโนมัติ…',
cancelSignIn: 'ยกเลิกการลงชื่อเข้าใช้',
tryAgain: 'ลองอีกครั้ง',
signIn: 'ลงชื่อเข้าใช้',
reconnect: 'เชื่อมต่อใหม่',
disconnect: 'ยกเลิกการเชื่อมต่อ',
disconnectConfirm:
'ยกเลิกการเชื่อมต่อผู้ให้บริการนี้หรือไม่? โมเดลจะหยุดทำงานจนกว่าคุณจะลงชื่อเข้าใช้อีกครั้ง การดำเนินการนี้ไม่ได้ยกเลิกการสมัครสมาชิก ChatGPT',
confirmDisconnect: 'ยืนยันการยกเลิกการเชื่อมต่อ',
},
title: 'โมเดล',
description: 'กำหนดค่าและจัดการโมเดลที่สามารถใช้ใน Pipeline',
createModel: 'สร้างโมเดล',
@@ -310,6 +341,8 @@ const thTH = {
providerSaveError: 'บันทึกผู้ให้บริการล้มเหลว: ',
providerDeleted: 'ลบผู้ให้บริการแล้ว',
providerDeleteError: 'ลบผู้ให้บริการล้มเหลว: ',
deleteProviderCascadeConfirmation:
'ลบผู้ให้บริการนี้และโมเดลทั้งหมดที่อยู่ภายในหรือไม่? การดำเนินการนี้ไม่สามารถย้อนกลับหรือยกเลิกได้',
deleteProviderConfirmation: 'คุณแน่ใจหรือไม่ว่าต้องการลบผู้ให้บริการนี้?',
loadError: 'โหลดข้อมูลล้มเหลว',
chat: 'แชท',
+33
View File
@@ -184,6 +184,37 @@ const viVN = {
help: 'Trợ giúp',
},
models: {
codex: {
account: 'Gói đăng ký ChatGPT',
description:
'Đăng nhập bằng tài khoản ChatGPT. Gói đăng ký độc lập với thanh toán API OpenAI; mô hình và giới hạn sử dụng tùy thuộc vào gói của bạn.',
disconnected: 'Chưa kết nối',
loading: 'Đang kiểm tra kết nối…',
starting: 'Đang bắt đầu đăng nhập…',
pending: 'Đang chờ cấp quyền',
connected: 'Đã kết nối',
expired: 'Phiên đăng nhập đã hết hạn. Hãy lấy mã mới.',
error: 'Không thể đăng nhập. Kiểm tra kết nối và thử lại.',
canceling: 'Đang hủy đăng nhập…',
saveAndSignIn: 'Lưu và đăng nhập',
done: 'Xong',
instructions:
'Nhập mã này trên trang OpenAI. Giữ hộp thoại này mở cho đến khi đăng nhập hoàn tất.',
copyCode: 'Sao chép mã',
copied: 'Đã sao chép',
copyManually: 'Chọn và sao chép mã thủ công.',
continueAtOpenAI: 'Tiếp tục tại OpenAI',
expiresAt: 'Mã hết hạn lúc {{time}}.',
retrying: 'Kết nối bị gián đoạn. Đang tự động thử lại…',
cancelSignIn: 'Hủy đăng nhập',
tryAgain: 'Thử lại',
signIn: 'Đăng nhập',
reconnect: 'Kết nối lại',
disconnect: 'Ngắt kết nối',
disconnectConfirm:
'Ngắt kết nối nhà cung cấp này? Các mô hình sẽ ngừng hoạt động cho đến khi bạn đăng nhập lại. Thao tác này không hủy gói ChatGPT của bạn.',
confirmDisconnect: 'Xác nhận ngắt kết nối',
},
title: 'Mô hình',
description:
'Cấu hình và quản lý các mô hình có thể sử dụng trong Pipeline',
@@ -318,6 +349,8 @@ const viVN = {
providerSaveError: 'Lưu nhà cung cấp thất bại: ',
providerDeleted: 'Đã xóa nhà cung cấp',
providerDeleteError: 'Xóa nhà cung cấp thất bại: ',
deleteProviderCascadeConfirmation:
'Xóa nhà cung cấp này và TẤT CẢ mô hình bên trong? Hành động này không thể đảo ngược hoặc hoàn tác.',
deleteProviderConfirmation:
'Bạn có chắc chắn muốn xóa nhà cung cấp này không?',
loadError: 'Tải dữ liệu thất bại',
+33
View File
@@ -172,6 +172,37 @@ const zhHans = {
help: '查看帮助文档',
},
models: {
codex: {
account: 'ChatGPT 订阅',
description:
'使用 ChatGPT 账号登录。订阅权限与 OpenAI API 计费相互独立,可用模型和使用额度取决于你的订阅方案。',
disconnected: '未连接',
loading: '正在检查连接…',
starting: '正在开始登录…',
pending: '等待授权',
connected: '已连接',
expired: '登录已过期,请重试以获取新验证码。',
error: '无法登录,请检查网络连接后重试。',
canceling: '正在取消登录…',
saveAndSignIn: '保存并登录',
done: '完成',
instructions:
'在 OpenAI 页面输入此验证码,登录完成前请保持此对话框打开。',
copyCode: '复制验证码',
copied: '已复制',
copyManually: '请选中并手动复制验证码。',
continueAtOpenAI: '前往 OpenAI 继续',
expiresAt: '验证码将于 {{time}} 过期。',
retrying: '连接中断,正在自动重试…',
cancelSignIn: '取消登录',
tryAgain: '重试',
signIn: '登录',
reconnect: '重新连接',
disconnect: '断开连接',
disconnectConfirm:
'断开此供应商的连接?重新登录前,其模型将无法使用。此操作不会取消你的 ChatGPT 订阅。',
confirmDisconnect: '确认断开',
},
title: '模型配置',
description: '配置和管理可在流水线中使用的模型',
createModel: '创建对话模型',
@@ -302,6 +333,8 @@ const zhHans = {
providerSaveError: '保存供应商失败:',
providerDeleted: '供应商已删除',
providerDeleteError: '删除供应商失败:',
deleteProviderCascadeConfirmation:
'确定删除此供应商及其包含的所有模型吗?此操作不可逆,无法撤销。',
deleteProviderConfirmation: '你确定要删除这个供应商吗?',
loadError: '加载数据失败',
chat: '对话',
+33
View File
@@ -173,6 +173,37 @@ const zhHant = {
help: '查看說明文件',
},
models: {
codex: {
account: 'ChatGPT 訂閱',
description:
'使用 ChatGPT 帳號登入。訂閱權限與 OpenAI API 計費相互獨立,可用模型和使用額度取決於你的訂閱方案。',
disconnected: '未連線',
loading: '正在檢查連線…',
starting: '正在開始登入…',
pending: '等待授權',
connected: '已連線',
expired: '登入已過期,請重試以取得新驗證碼。',
error: '無法登入,請檢查網路連線後重試。',
canceling: '正在取消登入…',
saveAndSignIn: '儲存並登入',
done: '完成',
instructions:
'在 OpenAI 頁面輸入此驗證碼,登入完成前請保持此對話框開啟。',
copyCode: '複製驗證碼',
copied: '已複製',
copyManually: '請選取並手動複製驗證碼。',
continueAtOpenAI: '前往 OpenAI 繼續',
expiresAt: '驗證碼將於 {{time}} 過期。',
retrying: '連線中斷,正在自動重試…',
cancelSignIn: '取消登入',
tryAgain: '重試',
signIn: '登入',
reconnect: '重新連線',
disconnect: '中斷連線',
disconnectConfirm:
'中斷此供應商的連線?重新登入前,其模型將無法使用。此操作不會取消你的 ChatGPT 訂閱。',
confirmDisconnect: '確認中斷',
},
title: '模型設定',
description: '設定和管理可在流程線中使用的模型',
createModel: '建立模型',
@@ -299,6 +330,8 @@ const zhHant = {
providerSaveError: '儲存供應商失敗:',
providerDeleted: '供應商已刪除',
providerDeleteError: '刪除供應商失敗:',
deleteProviderCascadeConfirmation:
'確定刪除此供應商及其包含的所有模型嗎?此操作不可逆,無法復原。',
deleteProviderConfirmation: '您確定要刪除這個供應商嗎?',
loadError: '載入資料失敗',
chat: '對話',
+150
View File
@@ -0,0 +1,150 @@
import { expect, test, type Page } from '@playwright/test';
// Isolated real React/Radix fixture. No backend or OAuth requests are made.
async function mount(page: Page, mode: string) {
page.on('pageerror', (error) => console.error(error.message));
await page.route('**/copy-harness', (route) =>
route.fulfill({
contentType: 'text/html',
body: `
<div id="root"></div><script type="module">
import RefreshRuntime from '/@react-refresh';
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
</script><script type="module">
import React from '/node_modules/.vite/deps/react.js';
import ReactDOM from '/node_modules/.vite/deps/react-dom_client.js';
const {createRoot} = ReactDOM;
import i18n from '/node_modules/.vite/deps/i18next.js';
import {initReactI18next} from '/node_modules/.vite/deps/react-i18next.js';
import {Toaster} from '/src/components/ui/sonner.tsx';
import '/src/app/global.css';
import {Dialog, DialogContent, DialogTitle} from '/src/components/ui/dialog.tsx';
import Section from '/src/app/home/components/models-dialog/component/provider-form/CodexAccountSection.tsx';
await i18n.use(initReactI18next).init({lng:'en', resources:{en:{translation:{}}}, interpolation:{escapeValue:false}});
const root=createRoot(document.getElementById('root'));
window.renderCode=(code='FIXTURE-1234',attempt='attempt-1')=>root.render(React.createElement(Dialog,{open:true},
React.createElement(DialogContent,{},React.createElement(DialogTitle,{},'Copy fixture'),React.createElement(Section,{providerId:'fixture',login:{phase:'pending',device:{user_code:code,authorization_id:attempt,verification_uri:'https://example.invalid',expires_at:9999999999}}})),React.createElement(Toaster)));
window.renderCode();
</script>`,
}),
);
await page.addInitScript((mode) => {
const w = window as any;
w.copyEvents = [];
document.addEventListener('copy', () => {
const el = document.activeElement as HTMLTextAreaElement;
w.copyEvents.push({
tag: el.tagName,
selected: el.value?.slice(el.selectionStart, el.selectionEnd),
});
});
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value:
mode === 'unavailable'
? undefined
: {
writeText: (text: string) => {
if (mode === 'success') {
w.written = text;
return Promise.resolve();
}
if (mode === 'delayed')
return new Promise((resolve) => {
w.resolveCopy = resolve;
});
return Promise.reject(new Error('denied'));
},
},
});
if (mode === 'false') document.execCommand = () => false;
if (mode === 'throw')
document.execCommand = () => {
throw new Error('denied');
};
}, mode);
await page.goto('/copy-harness');
await expect(
page.getByRole('button', { name: 'models.codex.copyCode', exact: true }),
).toBeVisible();
}
const copy = (page: Page) =>
page.getByRole('button', { name: 'models.codex.copyCode', exact: true });
const copied = (page: Page) =>
page.getByRole('button', { name: 'models.codex.copied', exact: true });
test('Clipboard API success shows icon, toast and transient feedback', async ({
page,
}) => {
await mount(page, 'success');
await expect(copy(page).locator('svg.lucide-copy')).toBeVisible();
await copy(page).click();
await expect(copied(page).locator('svg.lucide-check')).toBeVisible();
await expect(
page.getByText('common.copySuccess', { exact: true }),
).toBeVisible();
expect(await page.evaluate(() => (window as any).written)).toBe(
'FIXTURE-1234',
);
await expect(copy(page)).toBeVisible({ timeout: 4000 });
});
for (const mode of ['unavailable', 'rejected'])
test(`${mode} API performs a real selected-text copy inside modal`, async ({
page,
}) => {
await mount(page, mode);
await copy(page).click();
await expect(copied(page)).toBeVisible();
expect(await page.evaluate(() => (window as any).copyEvents)).toEqual([
{ tag: 'TEXTAREA', selected: 'FIXTURE-1234' },
]);
await expect(copied(page)).toBeFocused();
await expect(page.locator('textarea')).toHaveCount(0);
});
for (const mode of ['false', 'throw'])
test(`${mode} fallback reports failure and manual guidance`, async ({
page,
}) => {
await mount(page, mode);
await copy(page).click();
await expect(
page.getByText('common.copyFailed', { exact: true }),
).toBeVisible();
await expect(
page.getByText('models.codex.copyManually', { exact: true }),
).toBeVisible();
await expect(copy(page)).toBeVisible();
await expect(page.locator('textarea')).toHaveCount(0);
await expect(copy(page)).toBeFocused();
});
test('new code or attempt clears copied feedback', async ({ page }) => {
await mount(page, 'success');
await copy(page).click();
await expect(copied(page)).toBeVisible();
await page.evaluate(() =>
(window as any).renderCode('FIXTURE-5678', 'attempt-2'),
);
await expect(copy(page)).toBeVisible();
await copy(page).click();
await expect(copied(page)).toBeVisible();
await page.evaluate(() =>
(window as any).renderCode('FIXTURE-5678', 'attempt-3'),
);
await expect(copy(page)).toBeVisible();
});
test('completion from an old attempt cannot mark the new code copied', async ({
page,
}) => {
await mount(page, 'delayed');
await copy(page).click();
await page.evaluate(() =>
(window as any).renderCode('FIXTURE-5678', 'attempt-2'),
);
await expect(page.getByText('FIXTURE-5678')).toBeVisible();
await page.evaluate(() => (window as any).resolveCopy());
await expect(copy(page)).toBeVisible();
await expect(copied(page)).toHaveCount(0);
});
+341
View File
@@ -0,0 +1,341 @@
import { writeFileSync } from 'node:fs';
import { expect, test, type Page, type Route } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
// All OAuth, provider and model responses here are explicit UI fixtures.
// These tests never authenticate with OpenAI or use a real subscription.
async function fixture(page: Page) {
await installLangBotApiMocks(page, { authenticated: true });
const state = {
providers: [] as Record<string, unknown>[],
creates: 0,
starts: 0,
polls: 0,
cancels: 0,
disconnects: 0,
connected: false,
failStart: false,
pollStatus: 'pending',
interval: 1,
expiresIn: 600,
};
const ok = (route: Route, data: unknown) =>
route.fulfill({ json: { code: 0, data } });
await page.route('**/api/v1/provider/**', async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
const method = route.request().method();
if (path.endsWith('/icon'))
return route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><circle cx="12" cy="12" r="10" fill="#555"/></svg>',
});
if (path.endsWith('/requesters'))
return ok(route, {
requesters: ['openai-codex', 'openai'].map((name) => ({
name,
label: {
en_US: name === 'openai-codex' ? 'OpenAI Codex' : 'OpenAI API',
},
description: { en_US: '' },
spec: {
provider_category: 'manufacturer',
support_type: ['llm'],
config: [
{ name: 'base_url', default: 'https://api.openai.com/v1' },
],
},
})),
});
if (path.endsWith('/providers')) {
if (method === 'POST') {
state.creates++;
const provider = {
...route.request().postDataJSON(),
uuid: `provider-${state.creates}`,
};
state.providers.push(provider);
return ok(route, { uuid: provider.uuid });
}
return ok(route, { providers: state.providers });
}
if (path.endsWith('/codex/status'))
return ok(route, {
status: state.connected ? 'connected' : 'disconnected',
connected: state.connected,
expires_at: null,
});
if (path.endsWith('/codex/device') && method === 'POST') {
state.starts++;
if (state.failStart)
return route.fulfill({
status: 400,
json: { code: 400, msg: 'Fixture start failure' },
});
return ok(route, {
authorization_id: `attempt-${state.starts}`,
user_code: 'TEST-1234',
verification_uri: 'https://auth.openai.com/codex/device',
interval: state.interval,
expires_at: Date.now() / 1000 + state.expiresIn,
});
}
if (path.endsWith('/codex/device/poll')) {
state.polls++;
expect(route.request().postDataJSON()).toEqual({
authorization_id: `attempt-${state.starts}`,
});
if (state.pollStatus === 'connected') state.connected = true;
return ok(route, { status: state.pollStatus, interval: state.interval });
}
if (path.includes('/codex/device/') && method === 'DELETE') {
state.cancels++;
return ok(route, {});
}
if (path.endsWith('/codex/auth') && method === 'DELETE') {
state.disconnects++;
state.connected = false;
return ok(route, {});
}
if (/\/providers\/provider-\d+$/.test(path)) {
const provider = state.providers.find((p) =>
path.endsWith(String(p.uuid)),
);
if (method === 'PUT')
Object.assign(provider!, route.request().postDataJSON());
return ok(route, { provider });
}
if (path.includes('/models/')) return ok(route, { models: [] });
return ok(route, {});
});
return state;
}
async function openModels(page: Page) {
await page.goto('/home/bots');
await page.getByRole('button', { name: 'Models', exact: true }).click();
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
}
async function choose(page: Page, name: string) {
await page
.getByRole('button', { name: 'Select Provider Type', exact: true })
.click();
await page.getByRole('button', { name: new RegExp(name) }).click();
}
for (const width of [1280, 390, 320]) {
test(`subscription sign-in in the existing provider dialog (${width}px, UI fixture)`, async ({
page,
}) => {
const state = await fixture(page);
await page.setViewportSize({ width: 1280, height: 900 });
await openModels(page);
await page.setViewportSize({ width, height: 900 });
await page.locator('input[name="name"]').fill('My Codex');
await choose(page, 'OpenAI Codex');
await expect(page.locator('input[name="api_key"]')).toHaveCount(0);
await expect(page.locator('input[name="base_url"]')).toHaveCount(0);
await page
.getByRole('button', { name: 'Save and sign in', exact: true })
.click();
await expect(page.getByText('TEST-1234')).toBeVisible();
await page.getByRole('button', { name: 'Copy code', exact: true }).click();
await expect(
page.getByRole('button', { name: 'Copied', exact: true }),
).toBeVisible();
await expect(
page.getByText('Copy Successfully', { exact: true }),
).toBeInViewport({ ratio: 1 });
expect(state.creates).toBe(1);
expect(state.providers[0]).toMatchObject({
requester: 'openai-codex',
api_keys: [],
base_url: 'https://chatgpt.com/backend-api/codex',
});
await expect(
page.getByRole('link', { name: 'Continue at OpenAI' }),
).toHaveAttribute('href', 'https://auth.openai.com/codex/device');
const geometry = await page.getByTestId('codex-account').evaluate((el) => {
const box = el.getBoundingClientRect();
return {
left: box.left,
right: box.right,
width: innerWidth,
documentWidth: document.documentElement.scrollWidth,
};
});
expect(geometry.left).toBeGreaterThanOrEqual(0);
expect(geometry.right).toBeLessThanOrEqual(width);
expect(geometry.documentWidth).toBeLessThanOrEqual(width);
if (process.env.CODEX_EVIDENCE_DIR) {
await page.locator('[data-sonner-toast]').evaluate(async (el) => {
await Promise.all(
el
.getAnimations({ subtree: true })
.map((animation) => animation.finished.catch(() => undefined)),
);
});
const screenshot = `${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.png`;
await page.screenshot({ path: screenshot, fullPage: true });
writeFileSync(
`${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.json`,
JSON.stringify(
{
evidence: 'UI fixture only; not live OpenAI sign-in',
viewport: { width, height: 900 },
geometry,
screenshot,
},
null,
2,
),
);
}
state.pollStatus = 'connected';
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Done', exact: true }).click();
await expect(page.getByText('My Codex', { exact: true })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Add Model', exact: true }),
).toBeVisible();
expect(state.creates).toBe(1);
expect(
await page.evaluate(() => JSON.stringify({ ...localStorage })),
).not.toContain('attempt-');
});
}
test('failed start retries reuse saved provider; cancellation refreshes list', async ({
page,
}) => {
const state = await fixture(page);
state.failStart = true;
await openModels(page);
await page.locator('input[name="name"]').fill('Retry Codex');
await choose(page, 'OpenAI Codex');
await page
.getByRole('button', { name: 'Save and sign in', exact: true })
.click();
await expect(page.getByRole('alert')).toContainText('Unable to sign in');
state.failStart = false;
await page.getByRole('button', { name: 'Try again', exact: true }).click();
await expect(page.getByText('TEST-1234')).toBeVisible();
await page
.getByRole('button', { name: 'Cancel sign-in', exact: true })
.click();
await expect.poll(() => state.cancels).toBe(1);
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(page.getByText('Retry Codex', { exact: true })).toBeVisible();
expect(state.creates).toBe(1);
});
test('reconnect cancellation preserves connection and disconnect requires confirmation', async ({
page,
}) => {
const state = await fixture(page);
state.pollStatus = 'connected';
await openModels(page);
await page.locator('input[name="name"]').fill('Managed Codex');
await choose(page, 'OpenAI Codex');
await page
.getByRole('button', { name: 'Save and sign in', exact: true })
.click();
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
state.pollStatus = 'pending';
await page.getByRole('button', { name: 'Reconnect', exact: true }).click();
await expect(page.getByText('TEST-1234')).toBeVisible();
await page
.getByRole('button', { name: 'Cancel sign-in', exact: true })
.click();
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
expect(state.disconnects).toBe(0);
await page.getByRole('button', { name: 'Disconnect', exact: true }).click();
expect(state.disconnects).toBe(0);
await page
.getByRole('button', { name: 'Confirm disconnect', exact: true })
.click();
await expect(page.getByText('Not connected', { exact: true })).toBeVisible();
expect(state.disconnects).toBe(1);
expect(state.creates).toBe(1);
});
test('expiration permits retry without duplicate provider and closing cancels pending login', async ({
page,
}) => {
const state = await fixture(page);
state.expiresIn = 1;
await openModels(page);
await page.locator('input[name="name"]').fill('Expired Codex');
await choose(page, 'OpenAI Codex');
await page
.getByRole('button', { name: 'Save and sign in', exact: true })
.click();
await expect(
page.getByText('Sign-in expired. Start again to get a new code.'),
).toBeVisible();
await expect.poll(() => state.cancels).toBe(1);
state.expiresIn = 600;
await page.getByRole('button', { name: 'Try again', exact: true }).click();
await expect(page.getByText('TEST-1234')).toBeVisible();
await page.keyboard.press('Escape');
await expect.poll(() => state.cancels).toBe(2);
await expect(page.getByText('Expired Codex', { exact: true })).toBeVisible();
expect(state.creates).toBe(1);
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
await page.locator('input[name="name"]').fill('Second Codex');
await choose(page, 'OpenAI Codex');
await page
.getByRole('button', { name: 'Save and sign in', exact: true })
.click();
await expect(page.getByText('TEST-1234')).toBeVisible();
expect(state.creates).toBe(2);
expect(state.providers.map((provider) => provider.name)).toEqual([
'Expired Codex',
'Second Codex',
]);
await page.keyboard.press('Escape');
await expect.poll(() => state.cancels).toBe(3);
});
test('model test retains the connected provider identity', async ({ page }) => {
const state = await fixture(page);
state.connected = true;
state.providers.push({
uuid: 'provider-1',
name: 'Connected Codex',
requester: 'openai-codex',
base_url: 'https://chatgpt.com/backend-api/codex',
api_keys: [],
});
await page.goto('/home/bots');
await page.getByRole('button', { name: 'Models', exact: true }).click();
await page.getByRole('button', { name: 'Add Model', exact: true }).click();
await page
.getByPlaceholder('Model Name', { exact: true })
.fill('fixture-codex-model');
const requestPromise = page.waitForRequest('**/models/llm/_/test');
await page.getByRole('button', { name: 'Test', exact: true }).click();
const payload = (await requestPromise).postDataJSON();
expect(payload.provider_uuid).toBe('provider-1');
expect(payload.provider.uuid).toBe('provider-1');
expect(payload.provider.api_keys).toEqual([]);
});
test('ordinary API-key provider still saves and closes', async ({ page }) => {
const state = await fixture(page);
await openModels(page);
await page.locator('input[name="name"]').fill('My API');
await choose(page, 'OpenAI API');
await page.locator('input[name="api_key"]').fill('fixture-api-key-not-real');
await page
.locator('input[name="base_url"]')
.fill('https://api.example.test/v1');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('My API', { exact: true })).toBeVisible();
expect(state.providers[0]).toMatchObject({
requester: 'openai',
api_keys: ['fixture-api-key-not-real'],
base_url: 'https://api.example.test/v1',
});
expect(state.starts).toBe(0);
});
@@ -0,0 +1,340 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
// UI fixtures only: no real provider/model deletion or subscription authentication.
async function fixture(page: Page, requester = 'openai', empty = false) {
await installLangBotApiMocks(page, { authenticated: true });
const provider = {
uuid: 'provider-delete-fixture',
name: 'Delete fixture provider',
requester,
base_url: 'https://example.test/v1',
api_keys: [],
llm_count: empty ? 0 : 1,
embedding_count: empty ? 0 : 1,
rerank_count: empty ? 0 : 1,
};
const state = {
deleted: false,
fail: false,
deletes: [] as string[],
reads: [] as string[],
release: undefined as (() => void) | undefined,
hold: false,
};
const ok = (route: Route, data: unknown) =>
route.fulfill({ json: { code: 0, data } });
await page.route('**/api/v1/provider/**', async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
const method = route.request().method();
if (method === 'DELETE') {
state.deletes.push(path + url.search);
if (state.hold)
await new Promise<void>((resolve) => {
state.release = resolve;
});
if (state.fail)
return route.fulfill({
status: 409,
json: { code: 409, msg: 'Fixture deletion blocked; try again.' },
});
state.deleted = true;
return ok(route, {});
}
if (path.endsWith('/icon'))
return route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
});
if (path.endsWith('/requesters'))
return ok(route, {
requesters: ['openai', 'openai-codex'].map((name) => ({
name,
label: { en_US: name },
description: { en_US: '' },
spec: {
provider_category: 'manufacturer',
support_type: ['llm', 'embedding', 'rerank'],
config: [],
},
})),
});
if (method === 'GET') state.reads.push(path + url.search);
if (path.endsWith('/providers'))
return ok(route, { providers: state.deleted ? [] : [provider] });
if (path.endsWith('/codex/status'))
return ok(route, {
status: 'connected',
connected: true,
expires_at: null,
});
if (path.includes('/models/')) {
const type = path.split('/').pop();
return ok(route, {
models: state.deleted
? []
: [
{
uuid: `fixture-${type}`,
name: `Fixture ${type} model`,
provider_uuid: provider.uuid,
provider,
abilities: [],
extra_args: {},
},
],
});
}
if (path.endsWith(provider.uuid)) return ok(route, { provider });
return ok(route, {});
});
await page.goto('/home/bots');
await page.getByRole('button', { name: 'Models', exact: true }).click();
return state;
}
const editDialog = (page: Page) =>
page.locator('[role="dialog"]').filter({
has: page.locator('[data-slot="dialog-title"]', {
hasText: /^Edit Provider$/,
}),
});
async function edit(page: Page) {
const card = page
.locator('[data-slot="card"]')
.filter({ hasText: 'Delete fixture provider' });
await card.getByRole('button', { name: 'Expand', exact: true }).click();
await expect(
card.getByText('Fixture llm model', { exact: true }),
).toBeVisible();
await card
.locator('button')
.filter({ has: page.locator('svg.lucide-settings') })
.click();
await expect(editDialog(page).locator('input[name="name"]')).toHaveValue(
'Delete fixture provider',
);
}
for (const width of [1280, 320]) {
test(`confirmation stays centered throughout entry (${width}px)`, async ({
page,
}) => {
const state = await fixture(page);
await edit(page);
await page.setViewportSize({ width, height: 900 });
// Trigger without Playwright's post-click wait so the browser animation is
// still live. Sample its actual keyframes, not only the final screenshot.
await editDialog(page)
.getByRole('button', { name: 'Delete', exact: true })
.evaluate((el) => (el as HTMLButtonElement).click());
const confirmation = page.getByRole('alertdialog');
for (const phase of ['entry']) {
const samples = await confirmation.evaluate(async (el) => {
const animations = el.getAnimations();
if (!animations.length)
throw new Error('Expected the real dialog animation');
await Promise.all(animations.map((a) => a.ready));
animations.forEach((a) => a.pause());
const samples = [0, 0.25, 0.5, 0.75, 0.99].map((fraction) => {
animations.forEach((a) => {
a.currentTime = Number(a.effect!.getTiming().duration) * fraction;
});
const r = el.getBoundingClientRect();
return {
x: r.x + r.width / 2,
y: r.y + r.height / 2,
left: r.left,
right: r.right,
};
});
animations.forEach((a) => a.finish());
return samples;
});
for (const sample of samples) {
expect(
Math.abs(sample.x - width / 2),
`${phase} horizontal center`,
).toBeLessThan(1);
expect(
Math.abs(sample.y - 450),
`${phase} vertical center`,
).toBeLessThan(1);
expect(sample.left).toBeGreaterThanOrEqual(0);
expect(sample.right).toBeLessThanOrEqual(width);
}
}
await confirmation
.getByRole('button', { name: 'Cancel', exact: true })
.click();
await expect(confirmation).toHaveCount(0);
expect(state.deletes).toEqual([]);
});
}
for (const requester of ['openai', 'openai-codex']) {
for (const width of [1280, 320]) {
test(`footer deletion confirmation cancellation and geometry (${requester}, ${width}px)`, async ({
page,
}) => {
const state = await fixture(page, requester);
await edit(page);
await page.setViewportSize({ width, height: 900 });
const dialog = editDialog(page);
const footer = dialog.locator('[data-slot="dialog-footer"]');
const remove = footer.getByRole('button', {
name: 'Delete',
exact: true,
});
await expect(remove).toBeVisible();
for (const button of await footer.getByRole('button').all()) {
await expect(button).toBeInViewport({ ratio: 1 });
const box = await button.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
}
const left = await remove.boundingBox();
const cancel = await footer
.getByRole('button', { name: 'Cancel', exact: true })
.boundingBox();
expect(left!.x + left!.width).toBeLessThan(cancel!.x);
await remove.click();
const confirmation = page.getByRole('alertdialog');
await expect(confirmation).toContainText('this provider and ALL models');
await expect(confirmation).toContainText('cannot be undone');
await expect(confirmation).toBeInViewport({ ratio: 1 });
await confirmation.evaluate(async (element) => {
await Promise.all(
element.getAnimations().map((animation) => animation.finished),
);
});
const box = await confirmation.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
await confirmation
.getByRole('button', { name: 'Cancel', exact: true })
.click();
await expect(confirmation).toHaveCount(0);
await expect(dialog).toBeVisible();
expect(state.deletes).toEqual([]);
});
}
test(`one awaited cascade request refreshes providers and clears models (${requester})`, async ({
page,
}) => {
const state = await fixture(page, requester);
await edit(page);
state.hold = true;
await editDialog(page)
.getByRole('button', { name: 'Delete', exact: true })
.click();
const confirmation = page.getByRole('alertdialog');
await confirmation
.getByRole('button', { name: 'Delete', exact: true })
.click();
await expect.poll(() => state.deletes.length).toBe(1);
await expect(
confirmation.getByRole('button', { name: 'Delete', exact: true }),
).toBeDisabled();
await expect(
confirmation.getByRole('button', { name: 'Cancel', exact: true }),
).toBeDisabled();
await expect(
editDialog(page).getByRole('button', {
name: requester === 'openai' ? 'Save' : 'Done',
exact: true,
includeHidden: true,
}),
).toBeDisabled();
await page.keyboard.press('Escape');
await expect(confirmation).toBeVisible();
state.reads = [];
state.release!();
await expect(editDialog(page)).toHaveCount(0);
await expect(
page.getByText('Delete fixture provider', { exact: true }),
).toHaveCount(0);
await expect(
page.getByText('Fixture llm model', { exact: true }),
).toHaveCount(0);
expect(state.deletes).toEqual([
'/api/v1/provider/providers/provider-delete-fixture?cascade=true',
]);
expect(state.reads).toContain('/api/v1/provider/providers');
});
}
test('failed cascade retains readable error and can retry', async ({
page,
}) => {
const state = await fixture(page);
await edit(page);
state.fail = true;
await editDialog(page)
.getByRole('button', { name: 'Delete', exact: true })
.click();
const confirmation = page.getByRole('alertdialog');
await confirmation
.getByRole('button', { name: 'Delete', exact: true })
.click();
await expect(confirmation.getByRole('alert')).toContainText(
'Fixture deletion blocked; try again.',
);
await expect(
confirmation.getByRole('button', { name: 'Delete', exact: true }),
).toBeEnabled();
await expect(editDialog(page)).toBeVisible();
state.fail = false;
await confirmation
.getByRole('button', { name: 'Delete', exact: true })
.click();
await expect(editDialog(page)).toHaveCount(0);
expect(state.deletes).toHaveLength(2);
});
test('new providers do not expose footer deletion', async ({ page }) => {
const state = await fixture(page);
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
await expect(
page
.getByRole('dialog', { name: 'Add Provider', exact: true })
.getByRole('button', { name: 'Delete', exact: true }),
).toHaveCount(0);
expect(state.deletes).toEqual([]);
});
test('system-managed provider has no edit or delete entry', async ({
page,
}) => {
const state = await fixture(page, 'space-chat-completions');
const card = page
.locator('[data-slot="card"]')
.filter({ hasText: 'Delete fixture provider' });
await expect(card).toBeVisible();
await expect(card.locator('svg.lucide-settings')).toHaveCount(0);
await expect(card.locator('svg.lucide-trash-2')).toHaveCount(0);
expect(state.deletes).toEqual([]);
});
test('existing empty-provider card delete keeps its non-cascade request', async ({
page,
}) => {
const state = await fixture(page, 'openai', true);
const card = page
.locator('[data-slot="card"]')
.filter({ hasText: 'Delete fixture provider' });
await card
.locator('button')
.filter({ has: page.locator('svg.lucide-trash-2') })
.click();
await expect(
page.getByText('Are you sure you want to delete this provider?', {
exact: true,
}),
).toBeVisible();
await page.getByRole('button', { name: 'Delete', exact: true }).click();
await expect(card).toHaveCount(0);
expect(state.deletes).toEqual([
'/api/v1/provider/providers/provider-delete-fixture',
]);
});
+177
View File
@@ -0,0 +1,177 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
// UI fixtures only: never authenticate or write a real provider.
test.use({ hasTouch: true });
for (const width of [1280, 390, 320]) {
test(`provider dropdown bounded without dialog growth (${width}px)`, async ({
page,
}, testInfo) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/provider/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path.endsWith('/icon'))
return route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
});
const data = path.endsWith('/requesters')
? {
requesters: Array.from({ length: 30 }, (_, i) => ({
name: i === 0 ? 'openai-codex' : `provider-${i}`,
label: { en_US: i === 0 ? 'OpenAI Codex' : `Provider ${i}` },
description: { en_US: '' },
spec: {
provider_category: 'manufacturer',
config: [],
support_type: ['llm'],
},
})),
}
: { providers: [], models: [] };
await route.fulfill({ json: { code: 0, data } });
});
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto('/home/bots');
await page.getByRole('button', { name: 'Models', exact: true }).click();
await page
.getByRole('button', { name: 'Add Provider', exact: true })
.click();
await page.setViewportSize({ width, height: 720 });
const trigger = page.getByRole('button', {
name: 'Select Provider Type',
exact: true,
});
const dialog = page
.locator('[role="dialog"]')
.filter({ has: page.locator('input[name="name"]') });
await trigger.scrollIntoViewIfNeeded();
const before = await dialog.evaluate((el) => ({
height: el.clientHeight,
scroll: el.scrollHeight,
}));
await trigger.click();
const search = page.getByPlaceholder('Search providers...');
await expect(search).toBeFocused();
const menu = search.locator('../..');
await expect(
page.getByRole('button', { name: 'Provider 29', exact: false }),
).toBeAttached();
await menu.evaluate(async (el) => {
await Promise.all(el.getAnimations().map((a) => a.finished));
});
const options = menu.locator(':scope > div').last();
await options.hover();
await page.mouse.wheel(0, 1200);
await expect
.poll(() => options.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
if (width < 1280) {
await page.mouse.wheel(0, -1200);
await expect.poll(() => options.evaluate((el) => el.scrollTop)).toBe(0);
const box = (await options.boundingBox())!;
const session = await page.context().newCDPSession(page);
const x = box.x + box.width / 2;
const y = box.y + box.height - 30;
await session.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x, y }],
});
for (let step = 1; step <= 10; step++) {
await session.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x, y: y - step * 18 }],
});
}
await session.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
});
await session.detach();
await expect
.poll(() => options.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
}
const geometry = await menu.evaluate((el) => {
const rect = el.getBoundingClientRect();
const list = el.lastElementChild as HTMLElement;
const clipped: string[] = [];
for (
let parent = el.parentElement;
parent;
parent = parent.parentElement
) {
const bounds = parent.getBoundingClientRect();
if (
/(auto|scroll|hidden|clip)/.test(
getComputedStyle(parent).overflowY,
) &&
(rect.bottom > bounds.bottom + 1 || rect.top < bounds.top - 1)
)
clipped.push(parent.tagName);
}
return {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
clipped,
listHeight: list.clientHeight,
listScroll: list.scrollHeight,
scrollTop: list.scrollTop,
documentWidth: document.documentElement.scrollWidth,
};
});
const after = await dialog.evaluate((el) => ({
height: el.clientHeight,
scroll: el.scrollHeight,
}));
const dir = process.env.DROPDOWN_EVIDENCE_DIR || testInfo.outputDir;
mkdirSync(dir, { recursive: true });
await page.screenshot({
path: `${dir}/dropdown-${width}.png`,
fullPage: true,
});
writeFileSync(
`${dir}/dropdown-${width}.json`,
JSON.stringify(
{ evidence: 'UI fixture only', width, before, after, geometry },
null,
2,
),
);
expect.soft(after).toEqual(before);
expect.soft(geometry.clipped).toEqual([]);
expect.soft(geometry.left).toBeGreaterThanOrEqual(0);
expect.soft(geometry.right).toBeLessThanOrEqual(width);
expect.soft(geometry.top).toBeGreaterThanOrEqual(0);
expect.soft(geometry.bottom).toBeLessThanOrEqual(720);
expect.soft(geometry.documentWidth).toBeLessThanOrEqual(width);
expect(geometry.listScroll).toBeGreaterThan(geometry.listHeight);
expect(geometry.scrollTop).toBeGreaterThan(0);
await page.keyboard.press('Escape');
await expect(search).toBeHidden();
await expect(dialog).toBeVisible();
await expect(trigger).toBeFocused();
await trigger.click();
await search.fill('Provider 29');
await page.locator('input[name="name"]').click();
await expect(search).toBeHidden();
await expect(page.locator('input[name="name"]')).toBeFocused();
await trigger.click();
await expect(search).toHaveValue('');
await search.fill('Codex');
await page
.getByRole('button', { name: 'OpenAI Codex', exact: false })
.click();
await expect(search).toBeHidden();
await expect(page.locator('input[name="api_key"]')).toHaveCount(0);
await expect(
page.getByRole('button', { name: 'Save and sign in', exact: true }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'OpenAI Codex', exact: false }),
).toBeFocused();
});
}
+262
View File
@@ -0,0 +1,262 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
// All API traffic is intercepted; no real provider secrets or mutations.
async function fixture(page: Page, requester = 'openai') {
await installLangBotApiMocks(page, { authenticated: true });
const providers = ['alpha', 'beta'].map((id) => ({
uuid: `loading-${id}`,
name: `Loading fixture ${id}`,
requester,
base_url: `https://${id}.example.test/v1`,
api_keys: [`fixture-key-${id}`],
llm_count: 0,
embedding_count: 0,
rerank_count: 0,
}));
const state = {
hold: '' as '' | 'detail' | 'requesters',
fail: '' as '' | 'detail' | 'requesters',
held: [] as { release: () => void; finished: Promise<void> }[],
reads: [] as string[],
mutations: [] as string[],
errors: [] as string[],
};
page.on('pageerror', (error) => state.errors.push(error.message));
const ok = (route: Route, data: unknown) =>
route.fulfill({ json: { code: 0, data } });
await page.route('**/api/v1/provider/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (route.request().method() !== 'GET') {
state.mutations.push(route.request().method() + ' ' + path);
return ok(route, {});
}
if (path.endsWith('/icon'))
return route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
});
state.reads.push(path);
const provider = providers.find((p) => path.endsWith('/' + p.uuid));
const dependency = path.endsWith('/requesters')
? 'requesters'
: provider
? 'detail'
: '';
const fail = dependency && state.fail === dependency;
let finish: (() => void) | undefined;
if (dependency && state.hold === dependency) {
const finished = new Promise<void>((resolve) => {
finish = resolve;
});
await new Promise<void>((release) =>
state.held.push({ release, finished }),
);
}
try {
if (fail)
return await route.fulfill({
status: 503,
json: { code: 503, msg: `Fixture ${dependency} unavailable` },
});
if (dependency === 'requesters')
return await ok(route, {
requesters: [
{
name: requester,
label: {
en_US:
requester === 'openai' ? 'OpenAI fixture' : 'Codex fixture',
},
description: { en_US: '' },
spec: {
provider_category: 'manufacturer',
support_type: ['llm'],
config: [],
},
},
],
});
if (provider) return await ok(route, { provider });
if (path.endsWith('/providers')) return await ok(route, { providers });
if (path.endsWith('/codex/status'))
return await ok(route, {
status: 'connected',
connected: true,
expires_at: null,
});
return await ok(route, { models: [] });
} finally {
finish?.();
}
});
await page.goto('/home/bots');
await page.getByRole('button', { name: 'Models', exact: true }).click();
await expect(
page.getByText(providers[0].name, { exact: true }),
).toBeVisible();
// Let the panel's independent requester-support read finish before gating the form.
await expect
.poll(() => state.reads.filter((p) => p.endsWith('/requesters')).length)
.toBeGreaterThanOrEqual(1);
return state;
}
const dialog = (page: Page) =>
page.getByRole('dialog', { name: 'Edit Provider', exact: true });
const editButton = (page: Page, id = 'alpha') =>
page
.locator('[data-slot="card"]')
.filter({ hasText: `Loading fixture ${id}` })
.locator('button')
.filter({ has: page.locator('svg.lucide-settings') });
async function expectLoading(page: Page) {
const form = dialog(page);
await expect(form.getByRole('status')).toContainText('Loading...');
await expect(
form.getByRole('status').locator('svg.animate-spin'),
).toBeVisible();
await expect(form.locator('input')).toHaveCount(0);
await expect(
form.getByRole('button', { name: /^(Save|Done|Delete)$/ }),
).toHaveCount(0);
await expect(
form.getByRole('button', { name: 'Cancel', exact: true }),
).toBeEnabled();
}
async function expectReady(page: Page, id = 'alpha', requester = 'openai') {
const form = dialog(page);
await expect(form.locator('input[name="name"]')).toHaveValue(
`Loading fixture ${id}`,
);
await expect(
form.getByRole('status', { name: 'Loading...', exact: true }),
).toHaveCount(0);
await expect(
form.getByRole('button', { name: 'Delete', exact: true }),
).toBeEnabled();
await expect(
form.getByRole('button', {
name: requester === 'openai' ? 'Save' : 'Done',
exact: true,
}),
).toBeEnabled();
if (requester === 'openai') {
await expect(form.locator('input[name="base_url"]')).toHaveValue(
`https://${id}.example.test/v1`,
);
await expect(form.locator('input[name="api_key"]')).toHaveValue(
`fixture-key-${id}`,
);
await expect(
form.getByRole('button', { name: /OpenAI fixture/ }),
).toBeVisible();
} else {
await expect(form.locator('input[name="api_key"]')).toHaveCount(0);
await expect(
form.getByRole('button', { name: /Codex fixture/ }),
).toBeVisible();
}
}
for (const requester of ['openai', 'openai-codex']) {
for (const dependency of ['detail', 'requesters'] as const) {
test(`edit waits for ${dependency} before showing populated ${requester} form`, async ({
page,
}) => {
const state = await fixture(page, requester);
state.hold = dependency;
await editButton(page).click();
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
await expectLoading(page);
// Remain gated for the whole delay, not just the first render.
await page.waitForTimeout(250);
await expectLoading(page);
state.hold = '';
state.held.forEach((request) => request.release());
await expectReady(page, 'alpha', requester);
expect(state.mutations).toEqual([]);
expect(state.errors).toEqual([]);
});
}
}
for (const dependency of ['detail', 'requesters'] as const) {
test(`${dependency} load failure is recoverable with Retry or Cancel`, async ({
page,
}) => {
const state = await fixture(page);
state.fail = dependency;
await editButton(page).click();
const form = dialog(page);
await expect(form.getByRole('alert')).toContainText('Failed to load data');
await expect(form.locator('input')).toHaveCount(0);
await expect(
form.getByRole('button', { name: /^(Save|Done|Delete)$/ }),
).toHaveCount(0);
await expect(
form.getByRole('button', { name: 'Retry', exact: true }),
).toBeEnabled();
await expect(
form.getByRole('button', { name: 'Cancel', exact: true }),
).toBeEnabled();
state.fail = '';
state.hold = dependency;
await form.getByRole('button', { name: 'Retry', exact: true }).click();
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
await expectLoading(page);
state.hold = '';
state.held.forEach((request) => request.release());
await expectReady(page);
await form.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(form).toHaveCount(0);
state.fail = dependency;
await editButton(page).click();
await expect(form.getByRole('alert')).toBeVisible();
await form.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(form).toHaveCount(0);
expect(state.mutations).toEqual([]);
expect(state.errors).toEqual([]);
});
}
for (const next of ['alpha', 'beta']) {
for (const staleFailure of [false, true]) {
test(`closed request ${staleFailure ? 'failure' : 'success'} cannot affect reopened ${next}`, async ({
page,
}) => {
const state = await fixture(page);
state.hold = 'detail';
state.fail = staleFailure ? 'detail' : '';
await editButton(page).click();
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
await expectLoading(page);
const staleRequests = state.held.splice(0);
await dialog(page)
.getByRole('button', { name: 'Cancel', exact: true })
.click();
state.fail = '';
// Reopen during the closing animation, before Radix's retained content unmounts.
await editButton(page, next).dispatchEvent('click');
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
await expectLoading(page);
state.hold = '';
state.held.forEach((request) => request.release());
await expectReady(page, next);
await dialog(page)
.locator('input[name="name"]')
.fill('Unsaved fixture edit');
staleRequests.forEach((request) => request.release());
await Promise.all(staleRequests.map((request) => request.finished));
await page.waitForTimeout(250);
await expect(dialog(page).locator('input[name="name"]')).toHaveValue(
'Unsaved fixture edit',
);
await expect(dialog(page).getByRole('alert')).toHaveCount(0);
expect(state.mutations).toEqual([]);
expect(state.errors).toEqual([]);
});
}
}
+102
View File
@@ -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);
}
});