mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-06 17:47:14 +00:00
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:
@@ -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 工作空间内的资源,连接个人账号前请确认模型的使用范围。
|
||||
@@ -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))
|
||||
|
||||
@@ -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')
|
||||
@@ -62,6 +62,7 @@ _ALEMBIC_TENANT_TABLES = {
|
||||
'binary_storages',
|
||||
'mcp_servers',
|
||||
'model_providers',
|
||||
'codex_credentials',
|
||||
'llm_models',
|
||||
'embedding_models',
|
||||
'rerank_models',
|
||||
|
||||
@@ -51,6 +51,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
|
||||
'binary_storages': 'workspace_uuid',
|
||||
'mcp_servers': 'workspace_uuid',
|
||||
'model_providers': 'workspace_uuid',
|
||||
'codex_credentials': 'workspace_uuid',
|
||||
'llm_models': 'workspace_uuid',
|
||||
'embedding_models': 'workspace_uuid',
|
||||
'rerank_models': 'workspace_uuid',
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Exercise Codex provider wiring through a real LangBot process.
|
||||
|
||||
The default run does not contact OpenAI. Set LANGBOT_TEST_CODEX_DEVICE_AUTH=1
|
||||
to also exercise live device start/pending/cancel, without account sign-in.
|
||||
OAuth exchange and inference behavior are covered by deterministic tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def test_codex_provider_disconnected_journey(e2e_client):
|
||||
credentials = {'user': 'codex-e2e@example.com', 'password': 'codex-local-test-password'}
|
||||
initialized = e2e_client.post('/api/v1/user/init', json=credentials)
|
||||
assert initialized.status_code == 200, initialized.text
|
||||
authenticated = e2e_client.post('/api/v1/user/auth', json=credentials)
|
||||
assert authenticated.status_code == 200, authenticated.text
|
||||
headers = {'Authorization': f'Bearer {authenticated.json()["data"]["token"]}'}
|
||||
bootstrap = e2e_client.get('/api/v1/workspaces/bootstrap', headers=headers)
|
||||
assert bootstrap.status_code == 200, bootstrap.text
|
||||
headers['X-Workspace-Id'] = bootstrap.json()['data']['workspaces'][0]['workspace']['uuid']
|
||||
|
||||
requesters = e2e_client.get('/api/v1/provider/requesters?type=llm', headers=headers)
|
||||
assert requesters.status_code == 200, requesters.text
|
||||
codex = next(item for item in requesters.json()['data']['requesters'] if item['name'] == 'openai-codex')
|
||||
assert codex['spec']['support_type'] == ['llm']
|
||||
icon = e2e_client.get('/api/v1/provider/requesters/openai-codex/icon')
|
||||
assert icon.status_code == 200
|
||||
assert 'image/' in icon.headers['content-type']
|
||||
|
||||
base = '/api/v1/provider/providers'
|
||||
created = e2e_client.post(
|
||||
base,
|
||||
headers=headers,
|
||||
json={'name': 'Codex E2E', 'requester': 'openai-codex', 'base_url': '', 'api_keys': []},
|
||||
)
|
||||
assert created.status_code == 200, created.text
|
||||
provider_path = f'{base}/{created.json()["data"]["uuid"]}'
|
||||
try:
|
||||
provider = e2e_client.get(provider_path, headers=headers)
|
||||
assert provider.status_code == 200, provider.text
|
||||
data = provider.json()['data']['provider']
|
||||
assert data['requester'] == 'openai-codex'
|
||||
assert data['api_keys'] == []
|
||||
assert data['base_url'] == 'https://chatgpt.com/backend-api/codex'
|
||||
assert not {'access_token', 'refresh_token', 'id_token'} & data.keys()
|
||||
|
||||
status = e2e_client.get(f'{provider_path}/codex/status', headers=headers)
|
||||
assert status.status_code == 200, status.text
|
||||
assert status.json()['data']['connected'] is False
|
||||
assert status.json()['data']['status'] == 'disconnected'
|
||||
|
||||
anonymous = e2e_client.post(f'{provider_path}/codex/device', json={})
|
||||
assert anonymous.status_code == 401
|
||||
invalid = e2e_client.put(provider_path, headers=headers, json={'base_url': 'https://example.com'})
|
||||
assert invalid.status_code == 400, invalid.text
|
||||
invalid_key = e2e_client.put(provider_path, headers=headers, json={'api_keys': ['not-a-codex-key']})
|
||||
assert invalid_key.status_code == 400, invalid_key.text
|
||||
|
||||
scanned = e2e_client.get(f'{provider_path}/scan-models?type=llm', headers=headers)
|
||||
assert scanned.status_code == 400, scanned.text
|
||||
assert 'sign in' in scanned.json()['msg'].lower()
|
||||
|
||||
renamed = e2e_client.put(provider_path, headers=headers, json={'name': 'Codex renamed'})
|
||||
assert renamed.status_code == 200, renamed.text
|
||||
reread = e2e_client.get(provider_path, headers=headers)
|
||||
assert reread.json()['data']['provider']['name'] == 'Codex renamed'
|
||||
disconnected = e2e_client.delete(f'{provider_path}/codex/auth', headers=headers)
|
||||
assert disconnected.status_code == 200, disconnected.text
|
||||
|
||||
# Opt-in smoke contacts real OpenAI device endpoints, but never completes
|
||||
# account sign-in or prints the one-time code/device credentials.
|
||||
if os.environ.get('LANGBOT_TEST_CODEX_DEVICE_AUTH') == '1':
|
||||
started = e2e_client.post(f'{provider_path}/codex/device', headers=headers, json={})
|
||||
assert started.status_code == 200, started.json().get('msg', 'Device start failed')
|
||||
attempt = started.json()['data']
|
||||
assert attempt['verification_uri'] == 'https://auth.openai.com/codex/device'
|
||||
assert isinstance(attempt['user_code'], str) and attempt['user_code']
|
||||
assert 0 < attempt['expires_at'] - time.time() <= 900
|
||||
assert not {'access_token', 'refresh_token', 'device_auth_id'} & attempt.keys()
|
||||
time.sleep(attempt['interval'])
|
||||
pending = e2e_client.post(
|
||||
f'{provider_path}/codex/device/poll',
|
||||
headers=headers,
|
||||
json={'authorization_id': attempt['authorization_id']},
|
||||
)
|
||||
assert pending.status_code == 200
|
||||
assert pending.json()['data']['status'] == 'pending'
|
||||
canceled = e2e_client.delete(f'{provider_path}/codex/device/{attempt["authorization_id"]}', headers=headers)
|
||||
assert canceled.status_code == 200
|
||||
expired = e2e_client.post(
|
||||
f'{provider_path}/codex/device/poll',
|
||||
headers=headers,
|
||||
json={'authorization_id': attempt['authorization_id']},
|
||||
)
|
||||
assert expired.json()['data']['status'] == 'expired'
|
||||
finally:
|
||||
deleted = e2e_client.delete(provider_path, headers=headers)
|
||||
assert deleted.status_code == 200, deleted.text
|
||||
assert e2e_client.get(provider_path, headers=headers).status_code == 404
|
||||
@@ -69,7 +69,7 @@ class LangBotProcess:
|
||||
# Use coverage.py to collect coverage data
|
||||
# Set COVERAGE_PROCESS_START to enable coverage in subprocess
|
||||
self._coverage_file = self.work_dir / '.coverage.e2e'
|
||||
env['COVERAGE_PROCESS_START'] = str(self.project_root / '.coveragerc')
|
||||
env['COVERAGE_PROCESS_START'] = str(self.work_dir / '.coveragerc')
|
||||
env['COVERAGE_FILE'] = str(self._coverage_file)
|
||||
|
||||
# Create .coveragerc for subprocess
|
||||
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { useCodexLogin } from './useCodexLogin';
|
||||
|
||||
export default function CodexAccountSection({
|
||||
login,
|
||||
providerId,
|
||||
}: {
|
||||
login: ReturnType<typeof useCodexLogin>;
|
||||
providerId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [confirmDisconnect, setConfirmDisconnect] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyFailed, setCopyFailed] = useState(false);
|
||||
const { phase, device } = login;
|
||||
const copyGeneration = useRef(0);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
const generation = copyGeneration;
|
||||
setCopied(false);
|
||||
setCopyFailed(false);
|
||||
return () => {
|
||||
generation.current++;
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
};
|
||||
}, [providerId, device?.authorization_id, device?.user_code, phase]);
|
||||
const handleCopy = async () => {
|
||||
if (!device) return;
|
||||
const generation = ++copyGeneration.current;
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await copyToClipboard(device.user_code);
|
||||
} catch {
|
||||
// Clipboard failures are recoverable; never log device codes.
|
||||
}
|
||||
if (generation !== copyGeneration.current) return;
|
||||
setCopied(ok);
|
||||
setCopyFailed(!ok);
|
||||
if (ok) {
|
||||
toast.success(t('common.copySuccess'));
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
toast.error(t('common.copyFailed'));
|
||||
}
|
||||
};
|
||||
const waiting = ['starting', 'loading', 'canceling'].includes(phase);
|
||||
return (
|
||||
<section
|
||||
data-testid="codex-account"
|
||||
aria-label={t('models.codex.account')}
|
||||
className="min-w-0 rounded-lg border p-3 space-y-3 text-sm"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium">{t('models.codex.account')}</h3>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{t('models.codex.description')}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
role={phase === 'error' ? 'alert' : 'status'}
|
||||
aria-live="polite"
|
||||
className={
|
||||
phase === 'error' ? 'text-destructive' : 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{t(`models.codex.${phase}`)}
|
||||
</p>
|
||||
{device && phase === 'pending' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground">
|
||||
{t('models.codex.instructions')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="select-all break-all rounded border bg-muted px-3 py-2 text-base font-semibold tracking-wider">
|
||||
{device.user_code}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{t(copied ? 'models.codex.copied' : 'models.codex.copyCode')}
|
||||
</Button>
|
||||
</div>
|
||||
{copyFailed && (
|
||||
<p role="status" className="text-muted-foreground">
|
||||
{t('models.codex.copyManually')}
|
||||
</p>
|
||||
)}
|
||||
<a
|
||||
href={device.verification_uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t('models.codex.continueAtOpenAI')}
|
||||
</a>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('models.codex.expiresAt', {
|
||||
time: new Date(device.expires_at * 1000).toLocaleTimeString(),
|
||||
})}
|
||||
</p>
|
||||
{login.retrying && (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
{t('models.codex.retrying')}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => providerId && void login.cancel(providerId)}
|
||||
>
|
||||
{t('models.codex.cancelSignIn')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{providerId && !waiting && phase !== 'pending' && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{phase !== 'connected' && (
|
||||
<Button type="submit" size="sm" variant="outline">
|
||||
{t(
|
||||
phase === 'error' || phase === 'expired'
|
||||
? 'models.codex.tryAgain'
|
||||
: 'models.codex.signIn',
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{phase === 'connected' && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setConfirmDisconnect(false);
|
||||
void login.start(providerId);
|
||||
}}
|
||||
>
|
||||
{t('models.codex.reconnect')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmDisconnect(true)}
|
||||
>
|
||||
{t('models.codex.disconnect')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{confirmDisconnect && phase === 'connected' && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<p>{t('models.codex.disconnectConfirm')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmDisconnect(false);
|
||||
if (providerId) void login.disconnect(providerId);
|
||||
}}
|
||||
>
|
||||
{t('models.codex.confirmDisconnect')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDisconnect(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+380
-145
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -16,12 +16,31 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { DialogFooter } from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '../../types';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { toast } from 'sonner';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, ChevronDown, Search } from 'lucide-react';
|
||||
import { providerPayload } from './codexPolicy';
|
||||
import { useCodexLogin } from './useCodexLogin';
|
||||
import CodexAccountSection from './CodexAccountSection';
|
||||
|
||||
const getFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
@@ -35,12 +54,14 @@ interface ProviderFormProps {
|
||||
providerId?: string;
|
||||
onFormSubmit: (providerUuid: string) => void | Promise<void>;
|
||||
onFormCancel: () => void;
|
||||
onProviderDeleted?: (providerUuid: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function ProviderForm({
|
||||
providerId,
|
||||
onFormSubmit,
|
||||
onFormCancel,
|
||||
onProviderDeleted,
|
||||
}: ProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const formSchema = getFormSchema(t);
|
||||
@@ -54,7 +75,31 @@ export default function ProviderForm({
|
||||
api_key: '',
|
||||
},
|
||||
});
|
||||
const { setValue } = form;
|
||||
const { reset } = form;
|
||||
const isCodex = form.watch('requester') === 'openai-codex';
|
||||
const [savedProviderId, setSavedProviderId] = useState(providerId);
|
||||
const savedId = useRef(providerId);
|
||||
const submitting = useRef(false);
|
||||
const deleting = useRef(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
const [mutableProviderLoaded, setMutableProviderLoaded] = useState(false);
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>(
|
||||
'loading',
|
||||
);
|
||||
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||
const mounted = useRef(true);
|
||||
const login = useCodexLogin(isCodex, providerId);
|
||||
const loginActive = ['starting', 'pending', 'canceling', 'loading'].includes(
|
||||
login.phase,
|
||||
);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [requesterList, setRequesterList] = useState<
|
||||
{
|
||||
@@ -68,72 +113,59 @@ export default function ProviderForm({
|
||||
>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadRequesters = useCallback(async () => {
|
||||
const resp = await httpClient.getProviderRequesters();
|
||||
setRequesterList(
|
||||
resp.requesters
|
||||
.filter((item) => item.name !== 'space-chat-completions')
|
||||
.map((item) => ({
|
||||
label: extractI18nObject(item.label),
|
||||
value: item.name,
|
||||
category: item.spec.provider_category || 'manufacturer',
|
||||
defaultUrl:
|
||||
item.spec.config
|
||||
.find((c) => c.name === 'base_url')
|
||||
?.default?.toString() || '',
|
||||
description: extractI18nObject(item.description),
|
||||
alias: item.spec.alias || '',
|
||||
})),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const loadProvider = useCallback(
|
||||
async (id: string) => {
|
||||
const resp = await httpClient.getModelProvider(id);
|
||||
const provider = resp.provider;
|
||||
|
||||
setValue('name', provider.name);
|
||||
setValue('requester', provider.requester);
|
||||
setValue('base_url', provider.base_url);
|
||||
setValue('api_key', provider.api_keys?.[0] || '');
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Ignore both success and failure from a closed form or superseded attempt.
|
||||
let canceled = false;
|
||||
setLoadState('loading');
|
||||
setMutableProviderLoaded(false);
|
||||
|
||||
async function init() {
|
||||
await loadRequesters();
|
||||
if (providerId) {
|
||||
await loadProvider(providerId);
|
||||
try {
|
||||
const [requesters, detail] = await Promise.all([
|
||||
httpClient.getProviderRequesters(),
|
||||
providerId ? httpClient.getModelProvider(providerId) : null,
|
||||
]);
|
||||
if (canceled) return;
|
||||
setRequesterList(
|
||||
requesters.requesters
|
||||
.filter((item) => item.name !== LANGBOT_MODELS_PROVIDER_REQUESTER)
|
||||
.map((item) => ({
|
||||
label: extractI18nObject(item.label),
|
||||
value: item.name,
|
||||
category: item.spec.provider_category || 'manufacturer',
|
||||
defaultUrl:
|
||||
item.spec.config
|
||||
.find((c) => c.name === 'base_url')
|
||||
?.default?.toString() || '',
|
||||
description: extractI18nObject(item.description),
|
||||
alias: item.spec.alias || '',
|
||||
})),
|
||||
);
|
||||
if (detail) {
|
||||
const provider = detail.provider;
|
||||
reset({
|
||||
name: provider.name,
|
||||
requester: provider.requester,
|
||||
base_url: provider.base_url,
|
||||
api_key: provider.api_keys?.[0] || '',
|
||||
});
|
||||
setMutableProviderLoaded(
|
||||
provider.uuid === providerId &&
|
||||
provider.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
);
|
||||
}
|
||||
setLoadState('ready');
|
||||
} catch {
|
||||
if (!canceled) setLoadState('error');
|
||||
}
|
||||
}
|
||||
init();
|
||||
}, [providerId, loadProvider, loadRequesters]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
setSearchQuery('');
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Focus search input when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
void init();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [providerId, reset, loadAttempt]);
|
||||
|
||||
// Filter requesters based on search query
|
||||
const filteredRequesters = requesterList.filter(
|
||||
@@ -163,29 +195,105 @@ export default function ProviderForm({
|
||||
};
|
||||
|
||||
async function handleFormSubmit(values: z.infer<typeof formSchema>) {
|
||||
const data = {
|
||||
name: values.name,
|
||||
requester: values.requester,
|
||||
base_url: values.base_url,
|
||||
api_keys: values.api_key ? [values.api_key] : [],
|
||||
};
|
||||
|
||||
if (
|
||||
loadState !== 'ready' ||
|
||||
submitting.current ||
|
||||
deleting.current ||
|
||||
(isCodex && loginActive)
|
||||
)
|
||||
return;
|
||||
submitting.current = true;
|
||||
const data = providerPayload(values);
|
||||
try {
|
||||
let savedProviderUuid = providerId;
|
||||
if (providerId) {
|
||||
await httpClient.updateModelProvider(providerId, data);
|
||||
toast.success(t('models.providerSaved'));
|
||||
if (savedId.current) {
|
||||
await httpClient.updateModelProvider(savedId.current, data);
|
||||
} else {
|
||||
const response = await httpClient.createModelProvider(data);
|
||||
savedProviderUuid = response.uuid;
|
||||
toast.success(t('models.providerCreated'));
|
||||
savedId.current = response.uuid;
|
||||
if (mounted.current) setSavedProviderId(response.uuid);
|
||||
}
|
||||
if (!mounted.current) return;
|
||||
if (isCodex && login.phase !== 'connected') {
|
||||
await login.start(savedId.current);
|
||||
} else {
|
||||
toast.success(t('models.providerSaved'));
|
||||
await onFormSubmit(savedId.current);
|
||||
}
|
||||
await onFormSubmit(savedProviderUuid as string);
|
||||
} catch (err) {
|
||||
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
|
||||
if (mounted.current)
|
||||
toast.error(
|
||||
t('models.providerSaveError') + (err as CustomApiError).msg,
|
||||
);
|
||||
} finally {
|
||||
submitting.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (
|
||||
loadState !== 'ready' ||
|
||||
!providerId ||
|
||||
!mutableProviderLoaded ||
|
||||
!onProviderDeleted ||
|
||||
deleting.current ||
|
||||
submitting.current ||
|
||||
(isCodex && loginActive)
|
||||
)
|
||||
return;
|
||||
deleting.current = true;
|
||||
setIsDeleting(true);
|
||||
setDeleteError('');
|
||||
try {
|
||||
await httpClient.deleteModelProvider(providerId, true);
|
||||
} catch (err) {
|
||||
const detail =
|
||||
(err as CustomApiError | null)?.msg ||
|
||||
(err instanceof Error ? err.message : '');
|
||||
setDeleteError(t('models.providerDeleteError') + detail);
|
||||
deleting.current = false;
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
toast.success(t('models.providerDeleted'));
|
||||
await onProviderDeleted(providerId);
|
||||
}
|
||||
|
||||
if (loadState !== 'ready') {
|
||||
return (
|
||||
<>
|
||||
{loadState === 'loading' ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t('common.loading')}
|
||||
className="flex justify-center py-8"
|
||||
>
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
) : (
|
||||
<p role="alert" className="py-8 text-sm text-destructive">
|
||||
{t('models.loadError')}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{loadState === 'error' && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoadState('loading');
|
||||
setLoadAttempt((attempt) => attempt + 1);
|
||||
}}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onFormCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -202,7 +310,12 @@ export default function ProviderForm({
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input
|
||||
{...field}
|
||||
disabled={
|
||||
form.formState.isSubmitting || (isCodex && loginActive)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -222,45 +335,65 @@ export default function ProviderForm({
|
||||
{t('models.requester')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) setSearchQuery('');
|
||||
}}
|
||||
>
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'ring-2 ring-ring ring-offset-2',
|
||||
)}
|
||||
>
|
||||
{selectedRequester ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getProviderRequesterIconURL(
|
||||
selectedRequester.value,
|
||||
)}
|
||||
alt={selectedRequester.label}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{selectedRequester.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t('models.selectRequester')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
form.formState.isSubmitting ||
|
||||
(isCodex && (!!savedProviderId || loginActive))
|
||||
}
|
||||
aria-expanded={isOpen}
|
||||
className={cn(
|
||||
'h-4 w-4 opacity-50 transition-transform',
|
||||
isOpen && 'rotate-180',
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'ring-2 ring-ring ring-offset-2',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
>
|
||||
{selectedRequester ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getProviderRequesterIconURL(
|
||||
selectedRequester.value,
|
||||
)}
|
||||
alt={selectedRequester.label}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{selectedRequester.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t('models.selectRequester')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 opacity-50 transition-transform',
|
||||
isOpen && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
{/* Dropdown */}
|
||||
{/* Unmount on close so an exiting layer cannot eat Dialog Escape. */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95">
|
||||
<PopoverContent
|
||||
align="start"
|
||||
collisionPadding={8}
|
||||
className="flex max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-16px)] flex-col overflow-hidden p-0"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center border-b px-3">
|
||||
<div className="flex shrink-0 items-center border-b px-3">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
@@ -275,7 +408,13 @@ export default function ProviderForm({
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<div className="max-h-[300px] overflow-y-auto p-1">
|
||||
<div
|
||||
className="min-h-0 max-h-[300px] overflow-y-auto overscroll-contain p-1"
|
||||
// The dialog's document-level scroll lock treats this portal as outside.
|
||||
// Keep native list scrolling without forwarding gestures to that lock.
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
onTouchMove={(event) => event.stopPropagation()}
|
||||
>
|
||||
{Object.entries(groupedRequesters).map(
|
||||
([category, items]) => {
|
||||
if (items.length === 0) return null;
|
||||
@@ -288,6 +427,11 @@ export default function ProviderForm({
|
||||
<button
|
||||
key={r.value}
|
||||
type="button"
|
||||
disabled={
|
||||
!!providerId &&
|
||||
r.value === 'openai-codex' &&
|
||||
!isCodex
|
||||
}
|
||||
onClick={() => {
|
||||
field.onChange(r.value);
|
||||
const req = requesterList.find(
|
||||
@@ -337,9 +481,9 @@ export default function ProviderForm({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
{selectedRequester?.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -351,40 +495,131 @@ export default function ProviderForm({
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.requestURL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isCodex ? (
|
||||
<CodexAccountSection login={login} providerId={savedProviderId} />
|
||||
) : (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.requestURL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={
|
||||
form.formState.isSubmitting || (isCodex && loginActive)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.apiKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.apiKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">{t('common.save')}</Button>
|
||||
<Button type="button" variant="outline" onClick={onFormCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<DialogFooter className="flex-row flex-wrap items-start justify-between sm:justify-between">
|
||||
{providerId && mutableProviderLoaded && onProviderDeleted && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={
|
||||
isDeleting ||
|
||||
form.formState.isSubmitting ||
|
||||
(isCodex && loginActive)
|
||||
}
|
||||
onClick={() => {
|
||||
setDeleteError('');
|
||||
setDeleteConfirmOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="ml-auto flex flex-col gap-2 sm:flex-row">
|
||||
{(!isCodex || !savedProviderId || login.phase === 'connected') && (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isDeleting ||
|
||||
form.formState.isSubmitting ||
|
||||
(isCodex && loginActive)
|
||||
}
|
||||
>
|
||||
{isCodex
|
||||
? t(
|
||||
login.phase === 'connected'
|
||||
? 'models.codex.done'
|
||||
: 'models.codex.saveAndSignIn',
|
||||
)
|
||||
: t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isDeleting}
|
||||
onClick={onFormCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
<AlertDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!deleting.current) setDeleteConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
{deleteConfirmOpen && (
|
||||
<AlertDialogContent className="max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-lg">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('common.delete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('models.deleteProviderCascadeConfirmation')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{deleteError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm text-destructive break-words"
|
||||
>
|
||||
{deleteError}
|
||||
</p>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>
|
||||
{t('common.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
aria-busy={isDeleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
)}
|
||||
</AlertDialog>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Subscription credentials are server-owned, never API-key form values. */
|
||||
export function providerPayload(values: {
|
||||
name: string;
|
||||
requester: string;
|
||||
base_url: string;
|
||||
api_key?: string;
|
||||
}) {
|
||||
const subscription = values.requester === 'openai-codex';
|
||||
return {
|
||||
name: values.name,
|
||||
requester: values.requester,
|
||||
base_url: subscription
|
||||
? 'https://chatgpt.com/backend-api/codex'
|
||||
: values.base_url,
|
||||
api_keys: subscription ? [] : values.api_key ? [values.api_key] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function pollDelay(interval: number, failures = 0): number {
|
||||
const seconds = Number.isFinite(interval) && interval > 0 ? interval : 5;
|
||||
return Math.max(seconds, Math.min(60, seconds * 2 ** failures)) * 1000;
|
||||
}
|
||||
|
||||
export function isCodexVerificationUri(uri: string): boolean {
|
||||
return uri === 'https://auth.openai.com/codex/device';
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import type { CodexDeviceAuthorization } from '@/app/infra/entities/codex';
|
||||
import { isCodexVerificationUri, pollDelay } from './codexPolicy';
|
||||
|
||||
type Phase =
|
||||
| 'disconnected'
|
||||
| 'loading'
|
||||
| 'starting'
|
||||
| 'pending'
|
||||
| 'connected'
|
||||
| 'expired'
|
||||
| 'error'
|
||||
| 'canceling';
|
||||
|
||||
/** One in-memory authorization, sequential polls, and stale-response fencing. */
|
||||
export function useCodexLogin(enabled: boolean, providerId?: string) {
|
||||
const [phase, setPhase] = useState<Phase>('disconnected');
|
||||
const [device, setDevice] = useState<CodexDeviceAuthorization | null>(null);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const generation = useRef(0);
|
||||
const busy = useRef(false);
|
||||
const attempt = useRef<{ uuid: string; authorizationId: string } | null>(
|
||||
null,
|
||||
);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const deadline = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const request = useRef<AbortController | null>(null);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
generation.current++;
|
||||
clearTimeout(timer.current);
|
||||
clearTimeout(deadline.current);
|
||||
request.current?.abort();
|
||||
busy.current = false;
|
||||
const pending = attempt.current;
|
||||
attempt.current = null;
|
||||
return pending;
|
||||
}, []);
|
||||
|
||||
const clearPending = useCallback(async () => {
|
||||
const pending = stop();
|
||||
if (pending)
|
||||
await httpClient.cancelCodexDeviceLogin(
|
||||
pending.uuid,
|
||||
pending.authorizationId,
|
||||
);
|
||||
}, [stop]);
|
||||
|
||||
const loadStatus = useCallback(async (uuid: string) => {
|
||||
const version = generation.current;
|
||||
request.current = new AbortController();
|
||||
setPhase('loading');
|
||||
try {
|
||||
const status = await httpClient.getCodexAuthStatus(
|
||||
uuid,
|
||||
request.current.signal,
|
||||
);
|
||||
if (version === generation.current) setPhase(status.status);
|
||||
} catch {
|
||||
if (version === generation.current) setPhase('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setDevice(null);
|
||||
setPhase('disconnected');
|
||||
if (enabled && providerId) void loadStatus(providerId);
|
||||
return () => {
|
||||
// Device creation is deliberately not aborted: its late response must be
|
||||
// canceled server-side even if this form has already unmounted.
|
||||
void clearPending().catch(() => {});
|
||||
};
|
||||
}, [enabled, providerId, loadStatus, clearPending]);
|
||||
|
||||
async function start(uuid: string) {
|
||||
if (busy.current) return;
|
||||
const old = stop();
|
||||
busy.current = true;
|
||||
const version = generation.current;
|
||||
setPhase('starting');
|
||||
setDevice(null);
|
||||
setRetrying(false);
|
||||
try {
|
||||
if (old)
|
||||
await httpClient.cancelCodexDeviceLogin(old.uuid, old.authorizationId);
|
||||
if (version !== generation.current) return;
|
||||
const authorization = await httpClient.startCodexDeviceLogin(uuid);
|
||||
if (version !== generation.current) {
|
||||
await httpClient.cancelCodexDeviceLogin(
|
||||
uuid,
|
||||
authorization.authorization_id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
attempt.current = {
|
||||
uuid,
|
||||
authorizationId: authorization.authorization_id,
|
||||
};
|
||||
if (
|
||||
!isCodexVerificationUri(authorization.verification_uri) ||
|
||||
!Number.isFinite(authorization.expires_at)
|
||||
) {
|
||||
await clearPending();
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
setDevice(authorization);
|
||||
setPhase('pending');
|
||||
let interval = authorization.interval;
|
||||
let failures = 0;
|
||||
request.current = new AbortController();
|
||||
const signal = request.current.signal;
|
||||
const expire = () => {
|
||||
if (version !== generation.current) return;
|
||||
void clearPending().catch(() => {});
|
||||
setDevice(null);
|
||||
setPhase('expired');
|
||||
};
|
||||
deadline.current = setTimeout(
|
||||
expire,
|
||||
Math.max(0, authorization.expires_at * 1000 - Date.now()),
|
||||
);
|
||||
const poll = async () => {
|
||||
if (version !== generation.current) return;
|
||||
if (Date.now() >= authorization.expires_at * 1000) {
|
||||
expire();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await httpClient.pollCodexDeviceLogin(
|
||||
uuid,
|
||||
authorization.authorization_id,
|
||||
signal,
|
||||
);
|
||||
if (version !== generation.current) return;
|
||||
if (result.status !== 'pending') {
|
||||
attempt.current = null;
|
||||
stop();
|
||||
setDevice(null);
|
||||
setPhase(result.status);
|
||||
return;
|
||||
}
|
||||
interval = result.interval ?? interval;
|
||||
failures = 0;
|
||||
setRetrying(false);
|
||||
} catch (error) {
|
||||
if (version !== generation.current) return;
|
||||
const code = (error as { code?: number }).code;
|
||||
if (
|
||||
(code === -1 || (code !== undefined && code >= 500)) &&
|
||||
failures < 3
|
||||
) {
|
||||
failures++;
|
||||
setRetrying(true);
|
||||
} else {
|
||||
void clearPending().catch(() => {});
|
||||
setDevice(null);
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
timer.current = setTimeout(poll, pollDelay(interval, failures));
|
||||
};
|
||||
timer.current = setTimeout(poll, pollDelay(interval));
|
||||
} catch {
|
||||
if (version === generation.current) {
|
||||
busy.current = false;
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(uuid: string) {
|
||||
setPhase('canceling');
|
||||
setDevice(null);
|
||||
const pending = clearPending();
|
||||
const version = generation.current;
|
||||
try {
|
||||
await pending;
|
||||
if (version === generation.current) await loadStatus(uuid);
|
||||
} catch {
|
||||
if (version === generation.current) setPhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect(uuid: string) {
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
setPhase('loading');
|
||||
const version = generation.current;
|
||||
try {
|
||||
await httpClient.disconnectCodex(uuid);
|
||||
if (version === generation.current) await loadStatus(uuid);
|
||||
} catch {
|
||||
if (version === generation.current) setPhase('error');
|
||||
} finally {
|
||||
busy.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { phase, device, retrying, start, cancel, disconnect, loadStatus };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Public device-login responses only. OAuth credentials stay on the server. */
|
||||
export interface CodexAuthStatus {
|
||||
status: 'connected' | 'disconnected' | 'expired';
|
||||
connected: boolean;
|
||||
expires_at: number | null;
|
||||
}
|
||||
|
||||
export interface CodexDeviceAuthorization {
|
||||
authorization_id: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
interval: number;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export interface CodexDevicePoll {
|
||||
status: 'pending' | 'connected' | 'expired';
|
||||
interval?: number;
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
|
||||
import 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(
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
/**
|
||||
* Copy text to clipboard with fallback support
|
||||
* Tries to use modern Clipboard API first, falls back to execCommand if not available
|
||||
*
|
||||
* @param text - The text to copy to clipboard
|
||||
* @returns Promise<boolean> - true if successful, false otherwise
|
||||
*/
|
||||
/** Copy text using the Clipboard API, with a focus-trap-safe legacy fallback. */
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// Try modern Clipboard API first
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
try {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Clipboard] Modern API failed, trying fallback:', err);
|
||||
// Fall through to legacy method
|
||||
}
|
||||
} catch {
|
||||
// Permission/security errors can include sensitive text; do not log them.
|
||||
}
|
||||
|
||||
// Fallback to legacy execCommand method
|
||||
const previousFocus = document.activeElement as HTMLElement | null;
|
||||
const textArea = document.createElement('textarea');
|
||||
try {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
// Radix modal focus scopes reject focus on elements appended to body.
|
||||
const container =
|
||||
previousFocus?.closest('[role="dialog"], [role="alertdialog"]') ??
|
||||
document.body;
|
||||
container.appendChild(textArea);
|
||||
textArea.focus({ preventScroll: true });
|
||||
textArea.select();
|
||||
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
return successful;
|
||||
} catch (err) {
|
||||
console.error('[Clipboard] Fallback method failed:', err);
|
||||
if (
|
||||
document.activeElement !== textArea ||
|
||||
textArea.selectionEnd !== text.length
|
||||
)
|
||||
return false;
|
||||
return document.execCommand('copy');
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
textArea.remove();
|
||||
if (previousFocus?.isConnected)
|
||||
previousFocus.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -182,6 +182,37 @@ const enUS = {
|
||||
help: 'Get Help',
|
||||
},
|
||||
models: {
|
||||
codex: {
|
||||
account: 'ChatGPT subscription',
|
||||
description:
|
||||
'Sign in with your ChatGPT account. Subscription access is separate from OpenAI API billing; model availability and usage limits depend on your plan.',
|
||||
disconnected: 'Not connected',
|
||||
loading: 'Checking connection…',
|
||||
starting: 'Starting sign-in…',
|
||||
pending: 'Waiting for authorization',
|
||||
connected: 'Connected',
|
||||
expired: 'Sign-in expired. Start again to get a new code.',
|
||||
error: 'Unable to sign in. Check your connection and try again.',
|
||||
canceling: 'Canceling sign-in…',
|
||||
saveAndSignIn: 'Save and sign in',
|
||||
done: 'Done',
|
||||
instructions:
|
||||
'Enter this code on the OpenAI page. Keep this dialog open until sign-in completes.',
|
||||
copyCode: 'Copy code',
|
||||
copied: 'Copied',
|
||||
copyManually: 'Select and copy the code manually.',
|
||||
continueAtOpenAI: 'Continue at OpenAI',
|
||||
expiresAt: 'Code expires at {{time}}.',
|
||||
retrying: 'Connection interrupted. Retrying automatically…',
|
||||
cancelSignIn: 'Cancel sign-in',
|
||||
tryAgain: 'Try again',
|
||||
signIn: 'Sign in',
|
||||
reconnect: 'Reconnect',
|
||||
disconnect: 'Disconnect',
|
||||
disconnectConfirm:
|
||||
'Disconnect this provider? Its models will stop working until you sign in again. This does not cancel your ChatGPT subscription.',
|
||||
confirmDisconnect: 'Confirm disconnect',
|
||||
},
|
||||
title: 'Models',
|
||||
description: 'Configure and manage models that can be used in pipelines',
|
||||
createModel: 'Create Model',
|
||||
@@ -316,6 +347,8 @@ const enUS = {
|
||||
providerSaveError: 'Failed to save provider: ',
|
||||
providerDeleted: 'Provider deleted',
|
||||
providerDeleteError: 'Failed to delete provider: ',
|
||||
deleteProviderCascadeConfirmation:
|
||||
'Delete this provider and ALL models it contains? This action is irreversible and cannot be undone.',
|
||||
deleteProviderConfirmation:
|
||||
'Are you sure you want to delete this provider?',
|
||||
loadError: 'Failed to load data',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'チャット',
|
||||
|
||||
@@ -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: 'Не удалось загрузить данные',
|
||||
|
||||
@@ -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: 'แชท',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '对话',
|
||||
|
||||
@@ -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: '對話',
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
// Isolated real React/Radix fixture. No backend or OAuth requests are made.
|
||||
async function mount(page: Page, mode: string) {
|
||||
page.on('pageerror', (error) => console.error(error.message));
|
||||
await page.route('**/copy-harness', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'text/html',
|
||||
body: `
|
||||
<div id="root"></div><script type="module">
|
||||
import RefreshRuntime from '/@react-refresh';
|
||||
RefreshRuntime.injectIntoGlobalHook(window);
|
||||
window.$RefreshReg$ = () => {};
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
</script><script type="module">
|
||||
import React from '/node_modules/.vite/deps/react.js';
|
||||
import ReactDOM from '/node_modules/.vite/deps/react-dom_client.js';
|
||||
const {createRoot} = ReactDOM;
|
||||
import i18n from '/node_modules/.vite/deps/i18next.js';
|
||||
import {initReactI18next} from '/node_modules/.vite/deps/react-i18next.js';
|
||||
import {Toaster} from '/src/components/ui/sonner.tsx';
|
||||
import '/src/app/global.css';
|
||||
import {Dialog, DialogContent, DialogTitle} from '/src/components/ui/dialog.tsx';
|
||||
import Section from '/src/app/home/components/models-dialog/component/provider-form/CodexAccountSection.tsx';
|
||||
await i18n.use(initReactI18next).init({lng:'en', resources:{en:{translation:{}}}, interpolation:{escapeValue:false}});
|
||||
const root=createRoot(document.getElementById('root'));
|
||||
window.renderCode=(code='FIXTURE-1234',attempt='attempt-1')=>root.render(React.createElement(Dialog,{open:true},
|
||||
React.createElement(DialogContent,{},React.createElement(DialogTitle,{},'Copy fixture'),React.createElement(Section,{providerId:'fixture',login:{phase:'pending',device:{user_code:code,authorization_id:attempt,verification_uri:'https://example.invalid',expires_at:9999999999}}})),React.createElement(Toaster)));
|
||||
window.renderCode();
|
||||
</script>`,
|
||||
}),
|
||||
);
|
||||
await page.addInitScript((mode) => {
|
||||
const w = window as any;
|
||||
w.copyEvents = [];
|
||||
document.addEventListener('copy', () => {
|
||||
const el = document.activeElement as HTMLTextAreaElement;
|
||||
w.copyEvents.push({
|
||||
tag: el.tagName,
|
||||
selected: el.value?.slice(el.selectionStart, el.selectionEnd),
|
||||
});
|
||||
});
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value:
|
||||
mode === 'unavailable'
|
||||
? undefined
|
||||
: {
|
||||
writeText: (text: string) => {
|
||||
if (mode === 'success') {
|
||||
w.written = text;
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (mode === 'delayed')
|
||||
return new Promise((resolve) => {
|
||||
w.resolveCopy = resolve;
|
||||
});
|
||||
return Promise.reject(new Error('denied'));
|
||||
},
|
||||
},
|
||||
});
|
||||
if (mode === 'false') document.execCommand = () => false;
|
||||
if (mode === 'throw')
|
||||
document.execCommand = () => {
|
||||
throw new Error('denied');
|
||||
};
|
||||
}, mode);
|
||||
await page.goto('/copy-harness');
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'models.codex.copyCode', exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
const copy = (page: Page) =>
|
||||
page.getByRole('button', { name: 'models.codex.copyCode', exact: true });
|
||||
const copied = (page: Page) =>
|
||||
page.getByRole('button', { name: 'models.codex.copied', exact: true });
|
||||
|
||||
test('Clipboard API success shows icon, toast and transient feedback', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mount(page, 'success');
|
||||
await expect(copy(page).locator('svg.lucide-copy')).toBeVisible();
|
||||
await copy(page).click();
|
||||
await expect(copied(page).locator('svg.lucide-check')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('common.copySuccess', { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(await page.evaluate(() => (window as any).written)).toBe(
|
||||
'FIXTURE-1234',
|
||||
);
|
||||
await expect(copy(page)).toBeVisible({ timeout: 4000 });
|
||||
});
|
||||
for (const mode of ['unavailable', 'rejected'])
|
||||
test(`${mode} API performs a real selected-text copy inside modal`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await mount(page, mode);
|
||||
await copy(page).click();
|
||||
await expect(copied(page)).toBeVisible();
|
||||
expect(await page.evaluate(() => (window as any).copyEvents)).toEqual([
|
||||
{ tag: 'TEXTAREA', selected: 'FIXTURE-1234' },
|
||||
]);
|
||||
await expect(copied(page)).toBeFocused();
|
||||
await expect(page.locator('textarea')).toHaveCount(0);
|
||||
});
|
||||
for (const mode of ['false', 'throw'])
|
||||
test(`${mode} fallback reports failure and manual guidance`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await mount(page, mode);
|
||||
await copy(page).click();
|
||||
await expect(
|
||||
page.getByText('common.copyFailed', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('models.codex.copyManually', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(copy(page)).toBeVisible();
|
||||
await expect(page.locator('textarea')).toHaveCount(0);
|
||||
await expect(copy(page)).toBeFocused();
|
||||
});
|
||||
test('new code or attempt clears copied feedback', async ({ page }) => {
|
||||
await mount(page, 'success');
|
||||
await copy(page).click();
|
||||
await expect(copied(page)).toBeVisible();
|
||||
await page.evaluate(() =>
|
||||
(window as any).renderCode('FIXTURE-5678', 'attempt-2'),
|
||||
);
|
||||
await expect(copy(page)).toBeVisible();
|
||||
await copy(page).click();
|
||||
await expect(copied(page)).toBeVisible();
|
||||
await page.evaluate(() =>
|
||||
(window as any).renderCode('FIXTURE-5678', 'attempt-3'),
|
||||
);
|
||||
await expect(copy(page)).toBeVisible();
|
||||
});
|
||||
test('completion from an old attempt cannot mark the new code copied', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mount(page, 'delayed');
|
||||
await copy(page).click();
|
||||
await page.evaluate(() =>
|
||||
(window as any).renderCode('FIXTURE-5678', 'attempt-2'),
|
||||
);
|
||||
await expect(page.getByText('FIXTURE-5678')).toBeVisible();
|
||||
await page.evaluate(() => (window as any).resolveCopy());
|
||||
await expect(copy(page)).toBeVisible();
|
||||
await expect(copied(page)).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
// All OAuth, provider and model responses here are explicit UI fixtures.
|
||||
// These tests never authenticate with OpenAI or use a real subscription.
|
||||
async function fixture(page: Page) {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const state = {
|
||||
providers: [] as Record<string, unknown>[],
|
||||
creates: 0,
|
||||
starts: 0,
|
||||
polls: 0,
|
||||
cancels: 0,
|
||||
disconnects: 0,
|
||||
connected: false,
|
||||
failStart: false,
|
||||
pollStatus: 'pending',
|
||||
interval: 1,
|
||||
expiresIn: 600,
|
||||
};
|
||||
const ok = (route: Route, data: unknown) =>
|
||||
route.fulfill({ json: { code: 0, data } });
|
||||
await page.route('**/api/v1/provider/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const path = url.pathname;
|
||||
const method = route.request().method();
|
||||
if (path.endsWith('/icon'))
|
||||
return route.fulfill({
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><circle cx="12" cy="12" r="10" fill="#555"/></svg>',
|
||||
});
|
||||
if (path.endsWith('/requesters'))
|
||||
return ok(route, {
|
||||
requesters: ['openai-codex', 'openai'].map((name) => ({
|
||||
name,
|
||||
label: {
|
||||
en_US: name === 'openai-codex' ? 'OpenAI Codex' : 'OpenAI API',
|
||||
},
|
||||
description: { en_US: '' },
|
||||
spec: {
|
||||
provider_category: 'manufacturer',
|
||||
support_type: ['llm'],
|
||||
config: [
|
||||
{ name: 'base_url', default: 'https://api.openai.com/v1' },
|
||||
],
|
||||
},
|
||||
})),
|
||||
});
|
||||
if (path.endsWith('/providers')) {
|
||||
if (method === 'POST') {
|
||||
state.creates++;
|
||||
const provider = {
|
||||
...route.request().postDataJSON(),
|
||||
uuid: `provider-${state.creates}`,
|
||||
};
|
||||
state.providers.push(provider);
|
||||
return ok(route, { uuid: provider.uuid });
|
||||
}
|
||||
return ok(route, { providers: state.providers });
|
||||
}
|
||||
if (path.endsWith('/codex/status'))
|
||||
return ok(route, {
|
||||
status: state.connected ? 'connected' : 'disconnected',
|
||||
connected: state.connected,
|
||||
expires_at: null,
|
||||
});
|
||||
if (path.endsWith('/codex/device') && method === 'POST') {
|
||||
state.starts++;
|
||||
if (state.failStart)
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: { code: 400, msg: 'Fixture start failure' },
|
||||
});
|
||||
return ok(route, {
|
||||
authorization_id: `attempt-${state.starts}`,
|
||||
user_code: 'TEST-1234',
|
||||
verification_uri: 'https://auth.openai.com/codex/device',
|
||||
interval: state.interval,
|
||||
expires_at: Date.now() / 1000 + state.expiresIn,
|
||||
});
|
||||
}
|
||||
if (path.endsWith('/codex/device/poll')) {
|
||||
state.polls++;
|
||||
expect(route.request().postDataJSON()).toEqual({
|
||||
authorization_id: `attempt-${state.starts}`,
|
||||
});
|
||||
if (state.pollStatus === 'connected') state.connected = true;
|
||||
return ok(route, { status: state.pollStatus, interval: state.interval });
|
||||
}
|
||||
if (path.includes('/codex/device/') && method === 'DELETE') {
|
||||
state.cancels++;
|
||||
return ok(route, {});
|
||||
}
|
||||
if (path.endsWith('/codex/auth') && method === 'DELETE') {
|
||||
state.disconnects++;
|
||||
state.connected = false;
|
||||
return ok(route, {});
|
||||
}
|
||||
if (/\/providers\/provider-\d+$/.test(path)) {
|
||||
const provider = state.providers.find((p) =>
|
||||
path.endsWith(String(p.uuid)),
|
||||
);
|
||||
if (method === 'PUT')
|
||||
Object.assign(provider!, route.request().postDataJSON());
|
||||
return ok(route, { provider });
|
||||
}
|
||||
if (path.includes('/models/')) return ok(route, { models: [] });
|
||||
return ok(route, {});
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
async function openModels(page: Page) {
|
||||
await page.goto('/home/bots');
|
||||
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
|
||||
}
|
||||
async function choose(page: Page, name: string) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Select Provider Type', exact: true })
|
||||
.click();
|
||||
await page.getByRole('button', { name: new RegExp(name) }).click();
|
||||
}
|
||||
|
||||
for (const width of [1280, 390, 320]) {
|
||||
test(`subscription sign-in in the existing provider dialog (${width}px, UI fixture)`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await openModels(page);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.locator('input[name="name"]').fill('My Codex');
|
||||
await choose(page, 'OpenAI Codex');
|
||||
await expect(page.locator('input[name="api_key"]')).toHaveCount(0);
|
||||
await expect(page.locator('input[name="base_url"]')).toHaveCount(0);
|
||||
await page
|
||||
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||
.click();
|
||||
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Copy code', exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Copied', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Copy Successfully', { exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
expect(state.creates).toBe(1);
|
||||
expect(state.providers[0]).toMatchObject({
|
||||
requester: 'openai-codex',
|
||||
api_keys: [],
|
||||
base_url: 'https://chatgpt.com/backend-api/codex',
|
||||
});
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Continue at OpenAI' }),
|
||||
).toHaveAttribute('href', 'https://auth.openai.com/codex/device');
|
||||
const geometry = await page.getByTestId('codex-account').evaluate((el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return {
|
||||
left: box.left,
|
||||
right: box.right,
|
||||
width: innerWidth,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
expect(geometry.left).toBeGreaterThanOrEqual(0);
|
||||
expect(geometry.right).toBeLessThanOrEqual(width);
|
||||
expect(geometry.documentWidth).toBeLessThanOrEqual(width);
|
||||
if (process.env.CODEX_EVIDENCE_DIR) {
|
||||
await page.locator('[data-sonner-toast]').evaluate(async (el) => {
|
||||
await Promise.all(
|
||||
el
|
||||
.getAnimations({ subtree: true })
|
||||
.map((animation) => animation.finished.catch(() => undefined)),
|
||||
);
|
||||
});
|
||||
const screenshot = `${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.png`;
|
||||
await page.screenshot({ path: screenshot, fullPage: true });
|
||||
writeFileSync(
|
||||
`${process.env.CODEX_EVIDENCE_DIR}/codex-${width}.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
evidence: 'UI fixture only; not live OpenAI sign-in',
|
||||
viewport: { width, height: 900 },
|
||||
geometry,
|
||||
screenshot,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
state.pollStatus = 'connected';
|
||||
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
await expect(page.getByText('My Codex', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Add Model', exact: true }),
|
||||
).toBeVisible();
|
||||
expect(state.creates).toBe(1);
|
||||
expect(
|
||||
await page.evaluate(() => JSON.stringify({ ...localStorage })),
|
||||
).not.toContain('attempt-');
|
||||
});
|
||||
}
|
||||
|
||||
test('failed start retries reuse saved provider; cancellation refreshes list', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
state.failStart = true;
|
||||
await openModels(page);
|
||||
await page.locator('input[name="name"]').fill('Retry Codex');
|
||||
await choose(page, 'OpenAI Codex');
|
||||
await page
|
||||
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole('alert')).toContainText('Unable to sign in');
|
||||
state.failStart = false;
|
||||
await page.getByRole('button', { name: 'Try again', exact: true }).click();
|
||||
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Cancel sign-in', exact: true })
|
||||
.click();
|
||||
await expect.poll(() => state.cancels).toBe(1);
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(page.getByText('Retry Codex', { exact: true })).toBeVisible();
|
||||
expect(state.creates).toBe(1);
|
||||
});
|
||||
|
||||
test('reconnect cancellation preserves connection and disconnect requires confirmation', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
state.pollStatus = 'connected';
|
||||
await openModels(page);
|
||||
await page.locator('input[name="name"]').fill('Managed Codex');
|
||||
await choose(page, 'OpenAI Codex');
|
||||
await page
|
||||
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||
.click();
|
||||
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
|
||||
state.pollStatus = 'pending';
|
||||
await page.getByRole('button', { name: 'Reconnect', exact: true }).click();
|
||||
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Cancel sign-in', exact: true })
|
||||
.click();
|
||||
await expect(page.getByText('Connected', { exact: true })).toBeVisible();
|
||||
expect(state.disconnects).toBe(0);
|
||||
await page.getByRole('button', { name: 'Disconnect', exact: true }).click();
|
||||
expect(state.disconnects).toBe(0);
|
||||
await page
|
||||
.getByRole('button', { name: 'Confirm disconnect', exact: true })
|
||||
.click();
|
||||
await expect(page.getByText('Not connected', { exact: true })).toBeVisible();
|
||||
expect(state.disconnects).toBe(1);
|
||||
expect(state.creates).toBe(1);
|
||||
});
|
||||
|
||||
test('expiration permits retry without duplicate provider and closing cancels pending login', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
state.expiresIn = 1;
|
||||
await openModels(page);
|
||||
await page.locator('input[name="name"]').fill('Expired Codex');
|
||||
await choose(page, 'OpenAI Codex');
|
||||
await page
|
||||
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText('Sign-in expired. Start again to get a new code.'),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => state.cancels).toBe(1);
|
||||
state.expiresIn = 600;
|
||||
await page.getByRole('button', { name: 'Try again', exact: true }).click();
|
||||
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect.poll(() => state.cancels).toBe(2);
|
||||
await expect(page.getByText('Expired Codex', { exact: true })).toBeVisible();
|
||||
expect(state.creates).toBe(1);
|
||||
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
|
||||
await page.locator('input[name="name"]').fill('Second Codex');
|
||||
await choose(page, 'OpenAI Codex');
|
||||
await page
|
||||
.getByRole('button', { name: 'Save and sign in', exact: true })
|
||||
.click();
|
||||
await expect(page.getByText('TEST-1234')).toBeVisible();
|
||||
expect(state.creates).toBe(2);
|
||||
expect(state.providers.map((provider) => provider.name)).toEqual([
|
||||
'Expired Codex',
|
||||
'Second Codex',
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect.poll(() => state.cancels).toBe(3);
|
||||
});
|
||||
|
||||
test('model test retains the connected provider identity', async ({ page }) => {
|
||||
const state = await fixture(page);
|
||||
state.connected = true;
|
||||
state.providers.push({
|
||||
uuid: 'provider-1',
|
||||
name: 'Connected Codex',
|
||||
requester: 'openai-codex',
|
||||
base_url: 'https://chatgpt.com/backend-api/codex',
|
||||
api_keys: [],
|
||||
});
|
||||
await page.goto('/home/bots');
|
||||
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Add Model', exact: true }).click();
|
||||
await page
|
||||
.getByPlaceholder('Model Name', { exact: true })
|
||||
.fill('fixture-codex-model');
|
||||
const requestPromise = page.waitForRequest('**/models/llm/_/test');
|
||||
await page.getByRole('button', { name: 'Test', exact: true }).click();
|
||||
const payload = (await requestPromise).postDataJSON();
|
||||
expect(payload.provider_uuid).toBe('provider-1');
|
||||
expect(payload.provider.uuid).toBe('provider-1');
|
||||
expect(payload.provider.api_keys).toEqual([]);
|
||||
});
|
||||
|
||||
test('ordinary API-key provider still saves and closes', async ({ page }) => {
|
||||
const state = await fixture(page);
|
||||
await openModels(page);
|
||||
await page.locator('input[name="name"]').fill('My API');
|
||||
await choose(page, 'OpenAI API');
|
||||
await page.locator('input[name="api_key"]').fill('fixture-api-key-not-real');
|
||||
await page
|
||||
.locator('input[name="base_url"]')
|
||||
.fill('https://api.example.test/v1');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(page.getByText('My API', { exact: true })).toBeVisible();
|
||||
expect(state.providers[0]).toMatchObject({
|
||||
requester: 'openai',
|
||||
api_keys: ['fixture-api-key-not-real'],
|
||||
base_url: 'https://api.example.test/v1',
|
||||
});
|
||||
expect(state.starts).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
// UI fixtures only: no real provider/model deletion or subscription authentication.
|
||||
async function fixture(page: Page, requester = 'openai', empty = false) {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const provider = {
|
||||
uuid: 'provider-delete-fixture',
|
||||
name: 'Delete fixture provider',
|
||||
requester,
|
||||
base_url: 'https://example.test/v1',
|
||||
api_keys: [],
|
||||
llm_count: empty ? 0 : 1,
|
||||
embedding_count: empty ? 0 : 1,
|
||||
rerank_count: empty ? 0 : 1,
|
||||
};
|
||||
const state = {
|
||||
deleted: false,
|
||||
fail: false,
|
||||
deletes: [] as string[],
|
||||
reads: [] as string[],
|
||||
release: undefined as (() => void) | undefined,
|
||||
hold: false,
|
||||
};
|
||||
const ok = (route: Route, data: unknown) =>
|
||||
route.fulfill({ json: { code: 0, data } });
|
||||
await page.route('**/api/v1/provider/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const path = url.pathname;
|
||||
const method = route.request().method();
|
||||
if (method === 'DELETE') {
|
||||
state.deletes.push(path + url.search);
|
||||
if (state.hold)
|
||||
await new Promise<void>((resolve) => {
|
||||
state.release = resolve;
|
||||
});
|
||||
if (state.fail)
|
||||
return route.fulfill({
|
||||
status: 409,
|
||||
json: { code: 409, msg: 'Fixture deletion blocked; try again.' },
|
||||
});
|
||||
state.deleted = true;
|
||||
return ok(route, {});
|
||||
}
|
||||
if (path.endsWith('/icon'))
|
||||
return route.fulfill({
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
});
|
||||
if (path.endsWith('/requesters'))
|
||||
return ok(route, {
|
||||
requesters: ['openai', 'openai-codex'].map((name) => ({
|
||||
name,
|
||||
label: { en_US: name },
|
||||
description: { en_US: '' },
|
||||
spec: {
|
||||
provider_category: 'manufacturer',
|
||||
support_type: ['llm', 'embedding', 'rerank'],
|
||||
config: [],
|
||||
},
|
||||
})),
|
||||
});
|
||||
if (method === 'GET') state.reads.push(path + url.search);
|
||||
if (path.endsWith('/providers'))
|
||||
return ok(route, { providers: state.deleted ? [] : [provider] });
|
||||
if (path.endsWith('/codex/status'))
|
||||
return ok(route, {
|
||||
status: 'connected',
|
||||
connected: true,
|
||||
expires_at: null,
|
||||
});
|
||||
if (path.includes('/models/')) {
|
||||
const type = path.split('/').pop();
|
||||
return ok(route, {
|
||||
models: state.deleted
|
||||
? []
|
||||
: [
|
||||
{
|
||||
uuid: `fixture-${type}`,
|
||||
name: `Fixture ${type} model`,
|
||||
provider_uuid: provider.uuid,
|
||||
provider,
|
||||
abilities: [],
|
||||
extra_args: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (path.endsWith(provider.uuid)) return ok(route, { provider });
|
||||
return ok(route, {});
|
||||
});
|
||||
await page.goto('/home/bots');
|
||||
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||
return state;
|
||||
}
|
||||
const editDialog = (page: Page) =>
|
||||
page.locator('[role="dialog"]').filter({
|
||||
has: page.locator('[data-slot="dialog-title"]', {
|
||||
hasText: /^Edit Provider$/,
|
||||
}),
|
||||
});
|
||||
async function edit(page: Page) {
|
||||
const card = page
|
||||
.locator('[data-slot="card"]')
|
||||
.filter({ hasText: 'Delete fixture provider' });
|
||||
await card.getByRole('button', { name: 'Expand', exact: true }).click();
|
||||
await expect(
|
||||
card.getByText('Fixture llm model', { exact: true }),
|
||||
).toBeVisible();
|
||||
await card
|
||||
.locator('button')
|
||||
.filter({ has: page.locator('svg.lucide-settings') })
|
||||
.click();
|
||||
await expect(editDialog(page).locator('input[name="name"]')).toHaveValue(
|
||||
'Delete fixture provider',
|
||||
);
|
||||
}
|
||||
|
||||
for (const width of [1280, 320]) {
|
||||
test(`confirmation stays centered throughout entry (${width}px)`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
await edit(page);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
// Trigger without Playwright's post-click wait so the browser animation is
|
||||
// still live. Sample its actual keyframes, not only the final screenshot.
|
||||
await editDialog(page)
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.evaluate((el) => (el as HTMLButtonElement).click());
|
||||
const confirmation = page.getByRole('alertdialog');
|
||||
for (const phase of ['entry']) {
|
||||
const samples = await confirmation.evaluate(async (el) => {
|
||||
const animations = el.getAnimations();
|
||||
if (!animations.length)
|
||||
throw new Error('Expected the real dialog animation');
|
||||
await Promise.all(animations.map((a) => a.ready));
|
||||
animations.forEach((a) => a.pause());
|
||||
const samples = [0, 0.25, 0.5, 0.75, 0.99].map((fraction) => {
|
||||
animations.forEach((a) => {
|
||||
a.currentTime = Number(a.effect!.getTiming().duration) * fraction;
|
||||
});
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
x: r.x + r.width / 2,
|
||||
y: r.y + r.height / 2,
|
||||
left: r.left,
|
||||
right: r.right,
|
||||
};
|
||||
});
|
||||
animations.forEach((a) => a.finish());
|
||||
return samples;
|
||||
});
|
||||
for (const sample of samples) {
|
||||
expect(
|
||||
Math.abs(sample.x - width / 2),
|
||||
`${phase} horizontal center`,
|
||||
).toBeLessThan(1);
|
||||
expect(
|
||||
Math.abs(sample.y - 450),
|
||||
`${phase} vertical center`,
|
||||
).toBeLessThan(1);
|
||||
expect(sample.left).toBeGreaterThanOrEqual(0);
|
||||
expect(sample.right).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
await confirmation
|
||||
.getByRole('button', { name: 'Cancel', exact: true })
|
||||
.click();
|
||||
await expect(confirmation).toHaveCount(0);
|
||||
expect(state.deletes).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const requester of ['openai', 'openai-codex']) {
|
||||
for (const width of [1280, 320]) {
|
||||
test(`footer deletion confirmation cancellation and geometry (${requester}, ${width}px)`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page, requester);
|
||||
await edit(page);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const dialog = editDialog(page);
|
||||
const footer = dialog.locator('[data-slot="dialog-footer"]');
|
||||
const remove = footer.getByRole('button', {
|
||||
name: 'Delete',
|
||||
exact: true,
|
||||
});
|
||||
await expect(remove).toBeVisible();
|
||||
for (const button of await footer.getByRole('button').all()) {
|
||||
await expect(button).toBeInViewport({ ratio: 1 });
|
||||
const box = await button.boundingBox();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
|
||||
}
|
||||
const left = await remove.boundingBox();
|
||||
const cancel = await footer
|
||||
.getByRole('button', { name: 'Cancel', exact: true })
|
||||
.boundingBox();
|
||||
expect(left!.x + left!.width).toBeLessThan(cancel!.x);
|
||||
await remove.click();
|
||||
const confirmation = page.getByRole('alertdialog');
|
||||
await expect(confirmation).toContainText('this provider and ALL models');
|
||||
await expect(confirmation).toContainText('cannot be undone');
|
||||
await expect(confirmation).toBeInViewport({ ratio: 1 });
|
||||
await confirmation.evaluate(async (element) => {
|
||||
await Promise.all(
|
||||
element.getAnimations().map((animation) => animation.finished),
|
||||
);
|
||||
});
|
||||
const box = await confirmation.boundingBox();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
|
||||
await confirmation
|
||||
.getByRole('button', { name: 'Cancel', exact: true })
|
||||
.click();
|
||||
await expect(confirmation).toHaveCount(0);
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(state.deletes).toEqual([]);
|
||||
});
|
||||
}
|
||||
test(`one awaited cascade request refreshes providers and clears models (${requester})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page, requester);
|
||||
await edit(page);
|
||||
state.hold = true;
|
||||
await editDialog(page)
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
const confirmation = page.getByRole('alertdialog');
|
||||
await confirmation
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
await expect.poll(() => state.deletes.length).toBe(1);
|
||||
await expect(
|
||||
confirmation.getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toBeDisabled();
|
||||
await expect(
|
||||
confirmation.getByRole('button', { name: 'Cancel', exact: true }),
|
||||
).toBeDisabled();
|
||||
await expect(
|
||||
editDialog(page).getByRole('button', {
|
||||
name: requester === 'openai' ? 'Save' : 'Done',
|
||||
exact: true,
|
||||
includeHidden: true,
|
||||
}),
|
||||
).toBeDisabled();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(confirmation).toBeVisible();
|
||||
state.reads = [];
|
||||
state.release!();
|
||||
await expect(editDialog(page)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText('Delete fixture provider', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText('Fixture llm model', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
expect(state.deletes).toEqual([
|
||||
'/api/v1/provider/providers/provider-delete-fixture?cascade=true',
|
||||
]);
|
||||
expect(state.reads).toContain('/api/v1/provider/providers');
|
||||
});
|
||||
}
|
||||
|
||||
test('failed cascade retains readable error and can retry', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
await edit(page);
|
||||
state.fail = true;
|
||||
await editDialog(page)
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
const confirmation = page.getByRole('alertdialog');
|
||||
await confirmation
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
await expect(confirmation.getByRole('alert')).toContainText(
|
||||
'Fixture deletion blocked; try again.',
|
||||
);
|
||||
await expect(
|
||||
confirmation.getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toBeEnabled();
|
||||
await expect(editDialog(page)).toBeVisible();
|
||||
state.fail = false;
|
||||
await confirmation
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click();
|
||||
await expect(editDialog(page)).toHaveCount(0);
|
||||
expect(state.deletes).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('new providers do not expose footer deletion', async ({ page }) => {
|
||||
const state = await fixture(page);
|
||||
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('dialog', { name: 'Add Provider', exact: true })
|
||||
.getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toHaveCount(0);
|
||||
expect(state.deletes).toEqual([]);
|
||||
});
|
||||
|
||||
test('system-managed provider has no edit or delete entry', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page, 'space-chat-completions');
|
||||
const card = page
|
||||
.locator('[data-slot="card"]')
|
||||
.filter({ hasText: 'Delete fixture provider' });
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.locator('svg.lucide-settings')).toHaveCount(0);
|
||||
await expect(card.locator('svg.lucide-trash-2')).toHaveCount(0);
|
||||
expect(state.deletes).toEqual([]);
|
||||
});
|
||||
|
||||
test('existing empty-provider card delete keeps its non-cascade request', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page, 'openai', true);
|
||||
const card = page
|
||||
.locator('[data-slot="card"]')
|
||||
.filter({ hasText: 'Delete fixture provider' });
|
||||
await card
|
||||
.locator('button')
|
||||
.filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText('Are you sure you want to delete this provider?', {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(card).toHaveCount(0);
|
||||
expect(state.deletes).toEqual([
|
||||
'/api/v1/provider/providers/provider-delete-fixture',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
// UI fixtures only: never authenticate or write a real provider.
|
||||
test.use({ hasTouch: true });
|
||||
for (const width of [1280, 390, 320]) {
|
||||
test(`provider dropdown bounded without dialog growth (${width}px)`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.route('**/api/v1/provider/**', async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path.endsWith('/icon'))
|
||||
return route.fulfill({
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
});
|
||||
const data = path.endsWith('/requesters')
|
||||
? {
|
||||
requesters: Array.from({ length: 30 }, (_, i) => ({
|
||||
name: i === 0 ? 'openai-codex' : `provider-${i}`,
|
||||
label: { en_US: i === 0 ? 'OpenAI Codex' : `Provider ${i}` },
|
||||
description: { en_US: '' },
|
||||
spec: {
|
||||
provider_category: 'manufacturer',
|
||||
config: [],
|
||||
support_type: ['llm'],
|
||||
},
|
||||
})),
|
||||
}
|
||||
: { providers: [], models: [] };
|
||||
await route.fulfill({ json: { code: 0, data } });
|
||||
});
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto('/home/bots');
|
||||
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Add Provider', exact: true })
|
||||
.click();
|
||||
await page.setViewportSize({ width, height: 720 });
|
||||
const trigger = page.getByRole('button', {
|
||||
name: 'Select Provider Type',
|
||||
exact: true,
|
||||
});
|
||||
const dialog = page
|
||||
.locator('[role="dialog"]')
|
||||
.filter({ has: page.locator('input[name="name"]') });
|
||||
await trigger.scrollIntoViewIfNeeded();
|
||||
const before = await dialog.evaluate((el) => ({
|
||||
height: el.clientHeight,
|
||||
scroll: el.scrollHeight,
|
||||
}));
|
||||
await trigger.click();
|
||||
const search = page.getByPlaceholder('Search providers...');
|
||||
await expect(search).toBeFocused();
|
||||
const menu = search.locator('../..');
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Provider 29', exact: false }),
|
||||
).toBeAttached();
|
||||
await menu.evaluate(async (el) => {
|
||||
await Promise.all(el.getAnimations().map((a) => a.finished));
|
||||
});
|
||||
const options = menu.locator(':scope > div').last();
|
||||
await options.hover();
|
||||
await page.mouse.wheel(0, 1200);
|
||||
await expect
|
||||
.poll(() => options.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
if (width < 1280) {
|
||||
await page.mouse.wheel(0, -1200);
|
||||
await expect.poll(() => options.evaluate((el) => el.scrollTop)).toBe(0);
|
||||
const box = (await options.boundingBox())!;
|
||||
const session = await page.context().newCDPSession(page);
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height - 30;
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: [{ x, y }],
|
||||
});
|
||||
for (let step = 1; step <= 10; step++) {
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{ x, y: y - step * 18 }],
|
||||
});
|
||||
}
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchEnd',
|
||||
touchPoints: [],
|
||||
});
|
||||
await session.detach();
|
||||
await expect
|
||||
.poll(() => options.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
const geometry = await menu.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const list = el.lastElementChild as HTMLElement;
|
||||
const clipped: string[] = [];
|
||||
for (
|
||||
let parent = el.parentElement;
|
||||
parent;
|
||||
parent = parent.parentElement
|
||||
) {
|
||||
const bounds = parent.getBoundingClientRect();
|
||||
if (
|
||||
/(auto|scroll|hidden|clip)/.test(
|
||||
getComputedStyle(parent).overflowY,
|
||||
) &&
|
||||
(rect.bottom > bounds.bottom + 1 || rect.top < bounds.top - 1)
|
||||
)
|
||||
clipped.push(parent.tagName);
|
||||
}
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
clipped,
|
||||
listHeight: list.clientHeight,
|
||||
listScroll: list.scrollHeight,
|
||||
scrollTop: list.scrollTop,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
const after = await dialog.evaluate((el) => ({
|
||||
height: el.clientHeight,
|
||||
scroll: el.scrollHeight,
|
||||
}));
|
||||
const dir = process.env.DROPDOWN_EVIDENCE_DIR || testInfo.outputDir;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: `${dir}/dropdown-${width}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
writeFileSync(
|
||||
`${dir}/dropdown-${width}.json`,
|
||||
JSON.stringify(
|
||||
{ evidence: 'UI fixture only', width, before, after, geometry },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
expect.soft(after).toEqual(before);
|
||||
expect.soft(geometry.clipped).toEqual([]);
|
||||
expect.soft(geometry.left).toBeGreaterThanOrEqual(0);
|
||||
expect.soft(geometry.right).toBeLessThanOrEqual(width);
|
||||
expect.soft(geometry.top).toBeGreaterThanOrEqual(0);
|
||||
expect.soft(geometry.bottom).toBeLessThanOrEqual(720);
|
||||
expect.soft(geometry.documentWidth).toBeLessThanOrEqual(width);
|
||||
expect(geometry.listScroll).toBeGreaterThan(geometry.listHeight);
|
||||
expect(geometry.scrollTop).toBeGreaterThan(0);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(search).toBeHidden();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(trigger).toBeFocused();
|
||||
await trigger.click();
|
||||
await search.fill('Provider 29');
|
||||
await page.locator('input[name="name"]').click();
|
||||
await expect(search).toBeHidden();
|
||||
await expect(page.locator('input[name="name"]')).toBeFocused();
|
||||
await trigger.click();
|
||||
await expect(search).toHaveValue('');
|
||||
await search.fill('Codex');
|
||||
await page
|
||||
.getByRole('button', { name: 'OpenAI Codex', exact: false })
|
||||
.click();
|
||||
await expect(search).toBeHidden();
|
||||
await expect(page.locator('input[name="api_key"]')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save and sign in', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'OpenAI Codex', exact: false }),
|
||||
).toBeFocused();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
// All API traffic is intercepted; no real provider secrets or mutations.
|
||||
async function fixture(page: Page, requester = 'openai') {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
const providers = ['alpha', 'beta'].map((id) => ({
|
||||
uuid: `loading-${id}`,
|
||||
name: `Loading fixture ${id}`,
|
||||
requester,
|
||||
base_url: `https://${id}.example.test/v1`,
|
||||
api_keys: [`fixture-key-${id}`],
|
||||
llm_count: 0,
|
||||
embedding_count: 0,
|
||||
rerank_count: 0,
|
||||
}));
|
||||
const state = {
|
||||
hold: '' as '' | 'detail' | 'requesters',
|
||||
fail: '' as '' | 'detail' | 'requesters',
|
||||
held: [] as { release: () => void; finished: Promise<void> }[],
|
||||
reads: [] as string[],
|
||||
mutations: [] as string[],
|
||||
errors: [] as string[],
|
||||
};
|
||||
page.on('pageerror', (error) => state.errors.push(error.message));
|
||||
const ok = (route: Route, data: unknown) =>
|
||||
route.fulfill({ json: { code: 0, data } });
|
||||
await page.route('**/api/v1/provider/**', async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (route.request().method() !== 'GET') {
|
||||
state.mutations.push(route.request().method() + ' ' + path);
|
||||
return ok(route, {});
|
||||
}
|
||||
if (path.endsWith('/icon'))
|
||||
return route.fulfill({
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
});
|
||||
state.reads.push(path);
|
||||
const provider = providers.find((p) => path.endsWith('/' + p.uuid));
|
||||
const dependency = path.endsWith('/requesters')
|
||||
? 'requesters'
|
||||
: provider
|
||||
? 'detail'
|
||||
: '';
|
||||
const fail = dependency && state.fail === dependency;
|
||||
let finish: (() => void) | undefined;
|
||||
if (dependency && state.hold === dependency) {
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
await new Promise<void>((release) =>
|
||||
state.held.push({ release, finished }),
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (fail)
|
||||
return await route.fulfill({
|
||||
status: 503,
|
||||
json: { code: 503, msg: `Fixture ${dependency} unavailable` },
|
||||
});
|
||||
if (dependency === 'requesters')
|
||||
return await ok(route, {
|
||||
requesters: [
|
||||
{
|
||||
name: requester,
|
||||
label: {
|
||||
en_US:
|
||||
requester === 'openai' ? 'OpenAI fixture' : 'Codex fixture',
|
||||
},
|
||||
description: { en_US: '' },
|
||||
spec: {
|
||||
provider_category: 'manufacturer',
|
||||
support_type: ['llm'],
|
||||
config: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
if (provider) return await ok(route, { provider });
|
||||
if (path.endsWith('/providers')) return await ok(route, { providers });
|
||||
if (path.endsWith('/codex/status'))
|
||||
return await ok(route, {
|
||||
status: 'connected',
|
||||
connected: true,
|
||||
expires_at: null,
|
||||
});
|
||||
return await ok(route, { models: [] });
|
||||
} finally {
|
||||
finish?.();
|
||||
}
|
||||
});
|
||||
await page.goto('/home/bots');
|
||||
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(providers[0].name, { exact: true }),
|
||||
).toBeVisible();
|
||||
// Let the panel's independent requester-support read finish before gating the form.
|
||||
await expect
|
||||
.poll(() => state.reads.filter((p) => p.endsWith('/requesters')).length)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
return state;
|
||||
}
|
||||
|
||||
const dialog = (page: Page) =>
|
||||
page.getByRole('dialog', { name: 'Edit Provider', exact: true });
|
||||
const editButton = (page: Page, id = 'alpha') =>
|
||||
page
|
||||
.locator('[data-slot="card"]')
|
||||
.filter({ hasText: `Loading fixture ${id}` })
|
||||
.locator('button')
|
||||
.filter({ has: page.locator('svg.lucide-settings') });
|
||||
|
||||
async function expectLoading(page: Page) {
|
||||
const form = dialog(page);
|
||||
await expect(form.getByRole('status')).toContainText('Loading...');
|
||||
await expect(
|
||||
form.getByRole('status').locator('svg.animate-spin'),
|
||||
).toBeVisible();
|
||||
await expect(form.locator('input')).toHaveCount(0);
|
||||
await expect(
|
||||
form.getByRole('button', { name: /^(Save|Done|Delete)$/ }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
form.getByRole('button', { name: 'Cancel', exact: true }),
|
||||
).toBeEnabled();
|
||||
}
|
||||
|
||||
async function expectReady(page: Page, id = 'alpha', requester = 'openai') {
|
||||
const form = dialog(page);
|
||||
await expect(form.locator('input[name="name"]')).toHaveValue(
|
||||
`Loading fixture ${id}`,
|
||||
);
|
||||
await expect(
|
||||
form.getByRole('status', { name: 'Loading...', exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
form.getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toBeEnabled();
|
||||
await expect(
|
||||
form.getByRole('button', {
|
||||
name: requester === 'openai' ? 'Save' : 'Done',
|
||||
exact: true,
|
||||
}),
|
||||
).toBeEnabled();
|
||||
if (requester === 'openai') {
|
||||
await expect(form.locator('input[name="base_url"]')).toHaveValue(
|
||||
`https://${id}.example.test/v1`,
|
||||
);
|
||||
await expect(form.locator('input[name="api_key"]')).toHaveValue(
|
||||
`fixture-key-${id}`,
|
||||
);
|
||||
await expect(
|
||||
form.getByRole('button', { name: /OpenAI fixture/ }),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(form.locator('input[name="api_key"]')).toHaveCount(0);
|
||||
await expect(
|
||||
form.getByRole('button', { name: /Codex fixture/ }),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
for (const requester of ['openai', 'openai-codex']) {
|
||||
for (const dependency of ['detail', 'requesters'] as const) {
|
||||
test(`edit waits for ${dependency} before showing populated ${requester} form`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page, requester);
|
||||
state.hold = dependency;
|
||||
await editButton(page).click();
|
||||
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
|
||||
await expectLoading(page);
|
||||
// Remain gated for the whole delay, not just the first render.
|
||||
await page.waitForTimeout(250);
|
||||
await expectLoading(page);
|
||||
state.hold = '';
|
||||
state.held.forEach((request) => request.release());
|
||||
await expectReady(page, 'alpha', requester);
|
||||
expect(state.mutations).toEqual([]);
|
||||
expect(state.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const dependency of ['detail', 'requesters'] as const) {
|
||||
test(`${dependency} load failure is recoverable with Retry or Cancel`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
state.fail = dependency;
|
||||
await editButton(page).click();
|
||||
const form = dialog(page);
|
||||
await expect(form.getByRole('alert')).toContainText('Failed to load data');
|
||||
await expect(form.locator('input')).toHaveCount(0);
|
||||
await expect(
|
||||
form.getByRole('button', { name: /^(Save|Done|Delete)$/ }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
form.getByRole('button', { name: 'Retry', exact: true }),
|
||||
).toBeEnabled();
|
||||
await expect(
|
||||
form.getByRole('button', { name: 'Cancel', exact: true }),
|
||||
).toBeEnabled();
|
||||
state.fail = '';
|
||||
state.hold = dependency;
|
||||
await form.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
|
||||
await expectLoading(page);
|
||||
state.hold = '';
|
||||
state.held.forEach((request) => request.release());
|
||||
await expectReady(page);
|
||||
await form.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(form).toHaveCount(0);
|
||||
state.fail = dependency;
|
||||
await editButton(page).click();
|
||||
await expect(form.getByRole('alert')).toBeVisible();
|
||||
await form.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(form).toHaveCount(0);
|
||||
expect(state.mutations).toEqual([]);
|
||||
expect(state.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const next of ['alpha', 'beta']) {
|
||||
for (const staleFailure of [false, true]) {
|
||||
test(`closed request ${staleFailure ? 'failure' : 'success'} cannot affect reopened ${next}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await fixture(page);
|
||||
state.hold = 'detail';
|
||||
state.fail = staleFailure ? 'detail' : '';
|
||||
await editButton(page).click();
|
||||
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
|
||||
await expectLoading(page);
|
||||
const staleRequests = state.held.splice(0);
|
||||
await dialog(page)
|
||||
.getByRole('button', { name: 'Cancel', exact: true })
|
||||
.click();
|
||||
state.fail = '';
|
||||
// Reopen during the closing animation, before Radix's retained content unmounts.
|
||||
await editButton(page, next).dispatchEvent('click');
|
||||
await expect.poll(() => state.held.length).toBeGreaterThanOrEqual(1);
|
||||
await expectLoading(page);
|
||||
state.hold = '';
|
||||
state.held.forEach((request) => request.release());
|
||||
await expectReady(page, next);
|
||||
await dialog(page)
|
||||
.locator('input[name="name"]')
|
||||
.fill('Unsaved fixture edit');
|
||||
staleRequests.forEach((request) => request.release());
|
||||
await Promise.all(staleRequests.map((request) => request.finished));
|
||||
await page.waitForTimeout(250);
|
||||
await expect(dialog(page).locator('input[name="name"]')).toHaveValue(
|
||||
'Unsaved fixture edit',
|
||||
);
|
||||
await expect(dialog(page).getByRole('alert')).toHaveCount(0);
|
||||
expect(state.mutations).toEqual([]);
|
||||
expect(state.errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
test('all locale catalogs cover Codex states and preserve the expiry placeholder', () => {
|
||||
const directory = new URL('../../src/i18n/locales/', import.meta.url);
|
||||
let expected;
|
||||
for (const file of fs.readdirSync(directory)) {
|
||||
const compiled = ts.transpileModule(
|
||||
fs.readFileSync(new URL(file, directory), 'utf8'),
|
||||
{
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
},
|
||||
).outputText;
|
||||
const module = { exports: {} };
|
||||
new Function('module', 'exports', compiled)(module, module.exports);
|
||||
const catalog = (module.exports.default || Object.values(module.exports)[0])
|
||||
.models.codex;
|
||||
const keys = Object.keys(catalog).sort();
|
||||
expected ??= keys;
|
||||
assert.deepEqual(keys, expected, file);
|
||||
assert.equal(keys.length, 26, file);
|
||||
assert.ok(catalog.expiresAt.includes('{{time}}'), file);
|
||||
}
|
||||
});
|
||||
|
||||
function policy() {
|
||||
const source = fs.readFileSync(
|
||||
new URL(
|
||||
'../../src/app/home/components/models-dialog/component/provider-form/codexPolicy.ts',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const module = { exports: {} };
|
||||
new Function('module', 'exports', compiled)(module, module.exports);
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
test('Codex payload discards previously entered API credentials and URL', () => {
|
||||
const { providerPayload } = policy();
|
||||
assert.deepEqual(
|
||||
providerPayload({
|
||||
name: 'Subscription',
|
||||
requester: 'openai-codex',
|
||||
base_url: 'https://proxy.invalid',
|
||||
api_key: 'fixture-only',
|
||||
}),
|
||||
{
|
||||
name: 'Subscription',
|
||||
requester: 'openai-codex',
|
||||
base_url: 'https://chatgpt.com/backend-api/codex',
|
||||
api_keys: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('ordinary providers preserve API key and base URL behavior', () => {
|
||||
assert.deepEqual(
|
||||
policy().providerPayload({
|
||||
name: 'API',
|
||||
requester: 'openai',
|
||||
base_url: 'https://api.example.test/v1',
|
||||
api_key: 'fixture-only',
|
||||
}),
|
||||
{
|
||||
name: 'API',
|
||||
requester: 'openai',
|
||||
base_url: 'https://api.example.test/v1',
|
||||
api_keys: ['fixture-only'],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('poll delay honors upstream minimum and transient backoff', () => {
|
||||
const { pollDelay } = policy();
|
||||
assert.equal(pollDelay(5, 0), 5000);
|
||||
assert.equal(pollDelay(10, 2), 40000);
|
||||
assert.equal(pollDelay(120, 3), 120000);
|
||||
assert.equal(pollDelay(NaN, 0), 5000);
|
||||
assert.equal(pollDelay(-1, 0), 5000);
|
||||
});
|
||||
|
||||
test('only the contracted OpenAI device authorization URL can be opened', () => {
|
||||
const { isCodexVerificationUri } = policy();
|
||||
assert.equal(
|
||||
isCodexVerificationUri('https://auth.openai.com/codex/device'),
|
||||
true,
|
||||
);
|
||||
for (const url of [
|
||||
'javascript:alert(1)',
|
||||
'https://auth.openai.com.evil.test/codex/device',
|
||||
'https://evil.test',
|
||||
'https://user@auth.openai.com/codex/device',
|
||||
]) {
|
||||
assert.equal(isCodexVerificationUri(url), false);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user