mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-10 22:18:59 +00:00
fix(deps): depend on renamed tenki package instead of tenki-sandbox (#5087)
* fix(deps): depend on renamed tenki package instead of tenki-sandbox tenki-sandbox has been removed from PyPI and republished as tenki. Its old wheel URL still resolves, so existing lockfiles keep installing and the breakage is invisible to anyone with a warm lock; any fresh resolution fails with 'tenki-sandbox was not found in the package registry'. tenki 1.0.2 still ships the tenki_sandbox module, so the imports in community/tenki/provider.py and sandbox.py are unchanged. Fixes #5081 * fix(tenki): point install guidance at the renamed distribution The rename to `tenki` left the user-facing remediation still naming the removed package. `_import_client` raised "pip install tenki-sandbox" on the missing-extra path — the exact instruction this change proves now 404s on PyPI, handed to the user at the exact moment they need it to work. Update that message and the remaining `tenki-sandbox` references in the provider, sandbox adapter, README, sandbox AGENTS.md and the test docstring. The imported module stays `tenki_sandbox`, so the distribution and module names now differ; each mention says so rather than just swapping the string. No behavior change beyond the error text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tenki): migrate the provider to the 1.x workspace-only API Renaming the dependency was not enough. tenki 1.0.2 keeps the tenki_sandbox module name but not its contract: Client.create dropped project_id and has no **kwargs to absorb it, and IdentityWorkspace no longer carries `projects` (the attribute is gone from the package entirely). Both configuration paths therefore failed before a sandbox could be created — explicit project scope raised TypeError, and automatic scope raised AttributeError walking workspace.projects. Scope is now the workspace alone. _resolve_scope returns a single workspace id, auto-selecting when the account has exactly one, and project_id is gone from create_kwargs and from the documented config surface. A stale project_id in config.yaml warns rather than fails. SandboxConfig is extra="allow", so simply not reading the key would leave it scoping nothing with no signal; it also used to short-circuit the identity lookup, so operators with more than one workspace need to know they must now set workspace_id. The suite passed against the broken provider because the fake client took **kwargs and swallowed the project_id the real SDK rejects. The double now mirrors 1.0.2 — keyword-only, no **kwargs — so an unexpected argument is a TypeError in tests exactly as it is against the SDK. Reintroducing the old create call fails 20 tests; before this change it failed none. Verified against the exact locked wheels: every other kwarg the provider passes (name, workspace_id, sticky, wait, max_duration, image, cpu_cores, memory_mb, env) and every SDK surface it touches (who_am_i, Identity.workspaces, wait_ready, exec, close, the fs API, the four terminal exception classes) is unchanged in 1.0.2. Reported by willem-bd in review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(config): drop sandbox.project_id from the Tenki example The canonical example still documented project_id as a supported optional key after the provider stopped honouring it, so an operator following it could set the key, get no scope from it, and hit a workspace-resolution failure with nothing in the example to explain why. Replaced with a migration note rather than a silent deletion: someone upgrading already has the key in their config.yaml and needs to know it is inert now and that workspace_id is what scopes a sandbox on Tenki 1.x. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Aniket Wagh <aniketwaghh@users.noreply.github.com>
This commit is contained in:
parent
bb75f8d736
commit
8a830f6354
@ -13,7 +13,6 @@ sandbox:
|
||||
api_key: $TENKI_API_KEY # falls back to TENKI_API_KEY / TENKI_AUTH_TOKEN env var
|
||||
base_url: https://tenki.cloud # optional; SDK default when omitted
|
||||
image: my-base-image # optional; Tenki account default base image when omitted
|
||||
project_id: proj_... # optional; auto-selected if the account has exactly one
|
||||
workspace_id: ws_... # optional; auto-selected if the account has exactly one
|
||||
cpu_cores: 2 # optional per-sandbox vCPUs
|
||||
memory_mb: 2048 # optional per-sandbox memory
|
||||
@ -32,8 +31,9 @@ Install the optional SDK before selecting this provider:
|
||||
pip install "deerflow-harness[tenki]"
|
||||
```
|
||||
|
||||
The `tenki-sandbox` package is an optional DeerFlow harness extra, not part of
|
||||
the default install. Get an API key from <https://tenki.cloud/docs/sandbox/sdk>.
|
||||
The `tenki` package (which provides the `tenki_sandbox` module) is an optional
|
||||
DeerFlow harness extra, not part of the default install. Get an API key from
|
||||
<https://tenki.cloud/docs/sandbox/sdk>.
|
||||
|
||||
## Design
|
||||
|
||||
@ -92,6 +92,6 @@ sandboxes left by a previous gateway process) and a preview-URL surface.
|
||||
Verified end-to-end against live Tenki sandboxes: provider resolution →
|
||||
`execute_command` → full file-op surface (`read`/`write`/`update`/`download`,
|
||||
`list_dir`/`glob`/`grep`) → warm-pool reclaim → terminate, plus the
|
||||
`/mnt/user-data` sudo symlink. Unit tests run in CI without `tenki-sandbox`
|
||||
`/mnt/user-data` sudo symlink. Unit tests run in CI without `tenki`
|
||||
installed; `test_integration_real_sandbox` exercises a real microVM when
|
||||
`TENKI_API_KEY` is set.
|
||||
|
||||
@ -16,7 +16,6 @@ Configuration example (``config.yaml``)::
|
||||
api_key: $TENKI_API_KEY # falls back to TENKI_API_KEY / TENKI_AUTH_TOKEN env var
|
||||
base_url: https://tenki.cloud # optional; SDK default when omitted
|
||||
image: my-base-image # optional; Tenki account default base image when omitted
|
||||
project_id: proj_... # optional; auto-selected if the account has exactly one
|
||||
workspace_id: ws_... # optional; auto-selected if the account has exactly one
|
||||
cpu_cores: 2 # optional per-sandbox vCPUs
|
||||
memory_mb: 2048 # optional per-sandbox memory
|
||||
|
||||
@ -11,7 +11,7 @@ appear under ``sandbox:`` in ``config.yaml`` even though they are not declared o
|
||||
the model — see this package's ``__init__`` docstring for the full set.
|
||||
|
||||
The Tenki SDK is imported lazily (``_import_client``) so the harness — and every
|
||||
other provider — installs without ``tenki-sandbox``; the dependency is only
|
||||
other provider — installs without ``tenki``; the dependency is only
|
||||
needed once this provider is selected.
|
||||
"""
|
||||
|
||||
@ -91,7 +91,7 @@ def _import_client() -> type[Client]:
|
||||
try:
|
||||
from tenki_sandbox import Client
|
||||
except ImportError as e: # pragma: no cover - depends on the optional dependency
|
||||
raise ImportError("TenkiSandboxProvider requires the optional 'tenki-sandbox' dependency. Install it with: pip install 'deerflow-harness[tenki]' or pip install tenki-sandbox.") from e
|
||||
raise ImportError("TenkiSandboxProvider requires the optional 'tenki' dependency (it provides the tenki_sandbox module). Install it with: pip install 'deerflow-harness[tenki]' or pip install tenki.") from e
|
||||
return Client
|
||||
|
||||
|
||||
@ -147,6 +147,13 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider
|
||||
# config env is merged into every command and would otherwise only surface
|
||||
# as a confusing SDK error at create/exec time.
|
||||
_validate_extra_env(environment)
|
||||
# Tenki 1.x removed projects: ``Client.create`` no longer takes project_id
|
||||
# and IdentityWorkspace no longer carries ``projects``. Scope is the
|
||||
# workspace alone. Warn rather than fail so an existing config.yaml keeps
|
||||
# booting — SandboxConfig is extra="allow", so a stale key would otherwise
|
||||
# be read by nobody and silently change how scope resolves.
|
||||
if _opt("project_id") is not None:
|
||||
logger.warning("sandbox.project_id is ignored: Tenki 1.x removed projects. Scope is resolved by workspace alone — set sandbox.workspace_id if the account has more than one.")
|
||||
return {
|
||||
"max_duration": float(max_duration if max_duration is not None else DEFAULT_MAX_DURATION),
|
||||
# Off by default (the SDK default). Warm-pool sandboxes stay running
|
||||
@ -157,7 +164,6 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider
|
||||
"base_url": _opt("base_url"),
|
||||
"image": _opt("image"), # None → Tenki account default base image
|
||||
"home_dir": _opt("home_dir") or DEFAULT_TENKI_HOME_DIR,
|
||||
"project_id": _opt("project_id"),
|
||||
"workspace_id": _opt("workspace_id"),
|
||||
"cpu_cores": _opt("cpu_cores"),
|
||||
"memory_mb": _opt("memory_mb"),
|
||||
@ -177,25 +183,23 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider
|
||||
self._client = client
|
||||
return self._client
|
||||
|
||||
def _resolve_scope(self) -> tuple[str | None, str | None]:
|
||||
"""Return (project_id, workspace_id), auto-selecting when unambiguous.
|
||||
def _resolve_scope(self) -> str | None:
|
||||
"""Return the workspace id to create in, auto-selecting when unambiguous.
|
||||
|
||||
Tenki's ``create`` needs a project scope. When the caller didn't set one
|
||||
in config, pick it if the account has exactly one workspace and project;
|
||||
otherwise raise with the choices so the operator can set ``project_id``.
|
||||
Tenki 1.x scopes a sandbox by workspace; the project layer that 0.4.0
|
||||
required is gone from both ``Client.create`` and ``IdentityWorkspace``.
|
||||
When the caller didn't set ``workspace_id`` in config, pick it if the
|
||||
account has exactly one workspace; otherwise raise with the choices so
|
||||
the operator can set it.
|
||||
"""
|
||||
project_id = self._config["project_id"]
|
||||
workspace_id = self._config["workspace_id"]
|
||||
if project_id is not None:
|
||||
return project_id, workspace_id
|
||||
if workspace_id is not None:
|
||||
return workspace_id
|
||||
|
||||
identity = self._get_client().who_am_i()
|
||||
workspaces = list(identity.workspaces or [])
|
||||
if workspace_id is not None:
|
||||
workspaces = [w for w in workspaces if w.id == workspace_id]
|
||||
workspace = self._require_single(workspaces, "workspace", "workspace_id")
|
||||
project = self._require_single(list(workspace.projects or []), "project", "project_id")
|
||||
return project.id, workspace.id
|
||||
return workspace.id
|
||||
|
||||
@staticmethod
|
||||
def _require_single(items: list[Any], kind: str, param: str) -> Any:
|
||||
@ -293,10 +297,9 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider
|
||||
self._log_replicas_soft_cap(replicas, sandbox_id, evicted)
|
||||
|
||||
client = self._get_client()
|
||||
project_id, workspace_id = self._resolve_scope()
|
||||
workspace_id = self._resolve_scope()
|
||||
create_kwargs: dict[str, Any] = {
|
||||
"name": self._sandbox_name(sandbox_id),
|
||||
"project_id": project_id,
|
||||
"workspace_id": workspace_id,
|
||||
"sticky": self._config["sticky"],
|
||||
# Wait for readiness ourselves (below) instead of inside create():
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""``TenkiSandbox`` — DeerFlow :class:`Sandbox` backed by a Tenki cloud sandbox.
|
||||
|
||||
Tenki's Python SDK (``tenki-sandbox``) is synchronous, so — unlike
|
||||
Tenki's Python SDK (the ``tenki`` distribution, which ships the
|
||||
``tenki_sandbox`` module) is synchronous, so — unlike
|
||||
``community/boxlite`` — this adapter calls the SDK directly with no event-loop
|
||||
bridge. File transport uses Tenki's native ``sandbox.fs`` API (``read_text`` /
|
||||
``read_stream`` / ``write_stream`` / ``mkdir`` / ``stat``), which is binary-safe
|
||||
@ -13,7 +14,7 @@ Tenki base image works.
|
||||
|
||||
The Tenki SDK is not imported at module load (only its exception *class names*
|
||||
are matched, as strings), so importing this package never requires
|
||||
``tenki-sandbox`` to be installed — it is needed only once the provider is
|
||||
``tenki`` to be installed — it is needed only once the provider is
|
||||
selected and a sandbox is actually created.
|
||||
"""
|
||||
|
||||
@ -52,7 +53,7 @@ DEFAULT_TENKI_HOME_DIR = "/home/tenki"
|
||||
_STREAM_CHUNK = 1024 * 1024
|
||||
|
||||
# Tenki SDK exception *class names* that mean the remote session is gone for
|
||||
# good — matched as strings so this module imports without ``tenki-sandbox``.
|
||||
# good — matched as strings so this module imports without ``tenki``.
|
||||
# A terminated/not-found/closed session is unrecoverable; the provider drops it
|
||||
# and rebuilds on the next call. This is only the named-error half of the rule:
|
||||
# _is_terminal_failure ALSO treats the builtin ConnectionError / BrokenPipeError
|
||||
|
||||
@ -66,7 +66,7 @@
|
||||
- `BoxliteProvider` (`packages/harness/deerflow/community/boxlite/`) - BoxLite micro-VM isolation. The `boxlite` runtime is optional (`deerflow-harness[boxlite]`) and lazy-imported only when this provider is selected. The provider owns one private asyncio event loop on a daemon thread because BoxLite handles are loop-affine; sync `Sandbox` calls marshal onto that loop with `run_coroutine_threadsafe`.
|
||||
Boxes are named deterministically from `user_id:thread_id`, released into an in-process warm pool after each agent turn, and reclaimed only by the same user/thread. Warm-pool health checks use a short explicit timeout and forward that timeout through both BoxLite `exec(timeout=...)` and the private-loop `.result(timeout)` bridge so a hung VM cannot pin the per-thread acquire lock indefinitely.
|
||||
`sandbox.replicas` caps active + warm VMs per gateway process; if capacity is exhausted, only warm-pool VMs are evicted. `sandbox.idle_timeout` stops idle warm VMs after the configured seconds. `reset()` is intentionally a lightweight registry clear for `reset_sandbox_provider()` and does not close boxes, stop the idle reaper, or close the private loop; full teardown remains `shutdown()`.
|
||||
- `TenkiSandboxProvider` (`packages/harness/deerflow/community/tenki/`) - Tenki cloud microVM isolation. The `tenki-sandbox` SDK is optional (`deerflow-harness[tenki]`) and lazy-imported (`_import_client`) only when this provider is selected. Unlike Boxlite, the SDK is synchronous, so the adapter calls it directly with no event-loop bridge. File transport uses Tenki's native `sandbox.fs` API (`read_text`/`read_stream`/`write_stream`/`mkdir`/`stat`) — binary-safe and streaming, no base64/shell hop; only directory/content *search* (`list_dir`/`glob`/`grep`) shells out to busybox-portable `find`/`grep`, parsed with the shared `deerflow.sandbox.search` helpers like `community/e2b_sandbox`. Sandboxes run as the unprivileged `tenki` user, so DeerFlow's `/mnt/user-data` prefix is remapped under a writable HOME (`_resolve_path`) and best-effort `sudo`-symlinked at bootstrap. Boxes are named deterministically from `sha256(user_id:thread_id)[:16]` (64-bit, matching E2B; the warm pool is keyed by this id alone with no full-seed fallback), released into an in-process warm pool, and reclaimed only by the same user/thread after a liveness check. A terminal session error (named SDK errors plus builtin `ConnectionError`/`BrokenPipeError`/`EOFError`) routes through `_invalidate_sandbox` to evict the dead microVM. Cross-process orphan reconciliation is a follow-up (single-process warm pool today).
|
||||
- `TenkiSandboxProvider` (`packages/harness/deerflow/community/tenki/`) - Tenki cloud microVM isolation. The `tenki` SDK is optional (`deerflow-harness[tenki]`, importable as `tenki_sandbox`) and lazy-imported (`_import_client`) only when this provider is selected. Unlike Boxlite, the SDK is synchronous, so the adapter calls it directly with no event-loop bridge. File transport uses Tenki's native `sandbox.fs` API (`read_text`/`read_stream`/`write_stream`/`mkdir`/`stat`) — binary-safe and streaming, no base64/shell hop; only directory/content *search* (`list_dir`/`glob`/`grep`) shells out to busybox-portable `find`/`grep`, parsed with the shared `deerflow.sandbox.search` helpers like `community/e2b_sandbox`. Sandboxes run as the unprivileged `tenki` user, so DeerFlow's `/mnt/user-data` prefix is remapped under a writable HOME (`_resolve_path`) and best-effort `sudo`-symlinked at bootstrap. Boxes are named deterministically from `sha256(user_id:thread_id)[:16]` (64-bit, matching E2B; the warm pool is keyed by this id alone with no full-seed fallback), released into an in-process warm pool, and reclaimed only by the same user/thread after a liveness check. A terminal session error (named SDK errors plus builtin `ConnectionError`/`BrokenPipeError`/`EOFError`) routes through `_invalidate_sandbox` to evict the dead microVM. Cross-process orphan reconciliation is a follow-up (single-process warm pool today).
|
||||
|
||||
|
||||
**Shared warm-pool lifecycle:** community sandbox providers that keep released sandboxes alive for fast reuse share `deerflow.community.warm_pool_lifecycle.WarmPoolLifecycleMixin`. The mixin owns the common `DEFAULT_IDLE_TIMEOUT=600`, `IDLE_CHECK_INTERVAL=60`, `DEFAULT_REPLICAS=3`, idle-checker loop, warm-pool expiry, oldest-warm eviction, replica counting, and soft-cap logging. Providers remain responsible for their own active registries, creation/discovery, health checks, and destroy hook (`_destroy_warm_entry`): AIO destroys `SandboxInfo` through its backend; Boxlite closes loop-affine `BoxliteBox` handles; Tenki closes the microVM session (`TenkiSandbox.close`, which terminates the remote sandbox). AIO keeps active-idle cleanup outside the mixin and delegates only warm-pool expiry to the shared helper.
|
||||
|
||||
@ -78,7 +78,9 @@ pymupdf = ["pymupdf4llm>=0.0.17"]
|
||||
boxlite = ["boxlite>=0.9.7"]
|
||||
# Tenki cloud sandbox provider (deerflow.community.tenki). Optional so a default
|
||||
# install stays free of the Tenki SDK; only pulled in when the provider is used.
|
||||
tenki = ["tenki-sandbox>=0.4.0"]
|
||||
# The distribution is ``tenki``; it still ships the ``tenki_sandbox`` module that
|
||||
# provider.py and sandbox.py import.
|
||||
tenki = ["tenki>=1.0.0"]
|
||||
# OpenSandbox remote sandbox provider (deerflow.community.opensandbox). The
|
||||
# sync SDK is loaded only when this provider is selected.
|
||||
opensandbox = ["opensandbox>=0.1.15,<0.2.0"]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"""Unit tests for the Tenki community sandbox provider.
|
||||
|
||||
These run in CI without ``tenki-sandbox`` installed: they cover the lazy-import
|
||||
These run in CI without ``tenki`` installed: they cover the lazy-import
|
||||
error path, provider lifecycle, path-safety guards, the native ``fs`` file
|
||||
round-trip, warm-pool mechanics, and scope resolution — none of which need a live
|
||||
sandbox. A single opt-in integration test (``test_integration_real_sandbox``)
|
||||
@ -10,6 +10,7 @@ exercises a real Tenki microVM end to end when ``TENKI_API_KEY`` is set.
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
@ -175,19 +176,14 @@ class _FakeSandbox:
|
||||
raise self.close_error
|
||||
|
||||
|
||||
class _FakeProject:
|
||||
class _FakeWorkspace:
|
||||
"""Mirrors tenki 1.0.2 ``IdentityWorkspace``: id and name, no ``projects``."""
|
||||
|
||||
def __init__(self, id: str, name: str) -> None:
|
||||
self.id = id
|
||||
self.name = name
|
||||
|
||||
|
||||
class _FakeWorkspace:
|
||||
def __init__(self, id: str, name: str, projects: list[_FakeProject]) -> None:
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.projects = projects
|
||||
|
||||
|
||||
class _FakeIdentity:
|
||||
def __init__(self, workspaces: list[_FakeWorkspace]) -> None:
|
||||
self.workspaces = workspaces
|
||||
@ -200,12 +196,44 @@ class _FakeClient:
|
||||
self._sandbox_factory = sandbox_factory or (lambda: _FakeSandbox())
|
||||
self.last_sandbox: _FakeSandbox | None = None
|
||||
self._by_id: dict[str, _FakeSandbox] = {}
|
||||
self._workspaces = workspaces if workspaces is not None else [_FakeWorkspace("ws1", "Workspace", [_FakeProject("proj1", "Project")])]
|
||||
self._workspaces = workspaces if workspaces is not None else [_FakeWorkspace("ws1", "Workspace")]
|
||||
|
||||
def who_am_i(self):
|
||||
return _FakeIdentity(self._workspaces)
|
||||
|
||||
def create(self, **kwargs):
|
||||
# Keyword-only and deliberately WITHOUT **kwargs, mirroring tenki 1.0.2's
|
||||
# Client.create. The 0.4.0-era double accepted **kwargs, so it swallowed the
|
||||
# project_id the real 1.x rejects and the suite passed against a provider
|
||||
# that could not create a sandbox. An unexpected kwarg must be a TypeError
|
||||
# here exactly as it is against the real SDK.
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
name=None,
|
||||
workspace_id=None,
|
||||
sticky=False,
|
||||
wait=True,
|
||||
max_duration=None,
|
||||
image=None,
|
||||
cpu_cores=None,
|
||||
memory_mb=None,
|
||||
env=None,
|
||||
):
|
||||
kwargs = {
|
||||
"name": name,
|
||||
"workspace_id": workspace_id,
|
||||
"sticky": sticky,
|
||||
"wait": wait,
|
||||
}
|
||||
for key, value in (
|
||||
("max_duration", max_duration),
|
||||
("image", image),
|
||||
("cpu_cores", cpu_cores),
|
||||
("memory_mb", memory_mb),
|
||||
("env", env),
|
||||
):
|
||||
if value is not None:
|
||||
kwargs[key] = value
|
||||
self.create_count += 1
|
||||
self.create_kwargs.append(kwargs)
|
||||
sandbox = self._sandbox_factory()
|
||||
@ -648,8 +676,9 @@ def test_create_passes_prefixed_name_and_scope(monkeypatch):
|
||||
assert sid in provider._sandboxes
|
||||
kwargs = client.create_kwargs[0]
|
||||
assert kwargs["name"].startswith("deer-flow-tenki-")
|
||||
assert kwargs["project_id"] == "proj1"
|
||||
assert kwargs["workspace_id"] == "ws1"
|
||||
# Tenki 1.x has no project layer; passing one is a TypeError against the SDK.
|
||||
assert "project_id" not in kwargs
|
||||
provider.shutdown()
|
||||
|
||||
|
||||
@ -897,31 +926,59 @@ def test_shutdown_destroys_all_and_stops_reaper(monkeypatch):
|
||||
# ── Provider: scope resolution ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_scope_auto_resolves_single(monkeypatch):
|
||||
def test_scope_auto_resolves_single_workspace(monkeypatch):
|
||||
client = _FakeClient()
|
||||
provider = _install(monkeypatch, client=client)
|
||||
provider.acquire("thread-1", user_id="u1")
|
||||
assert client.create_kwargs[0]["project_id"] == "proj1"
|
||||
assert client.create_kwargs[0]["workspace_id"] == "ws1"
|
||||
provider.shutdown()
|
||||
|
||||
|
||||
def test_explicit_project_id_skips_lookup(monkeypatch):
|
||||
def test_explicit_workspace_id_skips_lookup(monkeypatch):
|
||||
client = _FakeClient()
|
||||
provider = _install(monkeypatch, client=client, config_attrs={"project_id": "explicit"})
|
||||
provider = _install(monkeypatch, client=client, config_attrs={"workspace_id": "explicit"})
|
||||
provider.acquire("thread-1", user_id="u1")
|
||||
assert client.create_kwargs[0]["project_id"] == "explicit"
|
||||
assert client.create_kwargs[0]["workspace_id"] == "explicit"
|
||||
provider.shutdown()
|
||||
|
||||
|
||||
def test_ambiguous_project_raises(monkeypatch):
|
||||
client = _FakeClient(workspaces=[_FakeWorkspace("ws1", "W", [_FakeProject("p1", "A"), _FakeProject("p2", "B")])])
|
||||
def test_ambiguous_workspace_raises(monkeypatch):
|
||||
client = _FakeClient(workspaces=[_FakeWorkspace("ws1", "A"), _FakeWorkspace("ws2", "B")])
|
||||
provider = _install(monkeypatch, client=client)
|
||||
with pytest.raises(ValueError, match="project_id"):
|
||||
with pytest.raises(ValueError, match="workspace_id"):
|
||||
provider.acquire("thread-1", user_id="u1")
|
||||
provider.shutdown()
|
||||
|
||||
|
||||
def test_stale_project_id_is_ignored_with_a_warning(monkeypatch, caplog):
|
||||
"""A 0.4.0-era config keeps booting; the dead key says so instead of going quiet.
|
||||
|
||||
SandboxConfig is ``extra="allow"``, so an unread project_id would sit in
|
||||
config.yaml scoping nothing. It also used to short-circuit the identity
|
||||
lookup, so silently dropping it changes how scope resolves.
|
||||
"""
|
||||
client = _FakeClient()
|
||||
with caplog.at_level(logging.WARNING, logger="deerflow.community.tenki.provider"):
|
||||
provider = _install(monkeypatch, client=client, config_attrs={"project_id": "proj_legacy"})
|
||||
assert "sandbox.project_id is ignored" in caplog.text
|
||||
provider.acquire("thread-1", user_id="u1")
|
||||
assert "project_id" not in client.create_kwargs[0]
|
||||
assert client.create_kwargs[0]["workspace_id"] == "ws1"
|
||||
provider.shutdown()
|
||||
|
||||
|
||||
def test_create_rejects_project_id_like_the_real_sdk(monkeypatch):
|
||||
"""Guards the hole that let the 1.x break through: the old double took **kwargs.
|
||||
|
||||
tenki 1.0.2's Client.create is keyword-only with no **kwargs, so a stray
|
||||
project_id raises TypeError. The double must do the same or a provider that
|
||||
cannot create a sandbox goes on passing its tests.
|
||||
"""
|
||||
client = _FakeClient()
|
||||
with pytest.raises(TypeError, match="project_id"):
|
||||
client.create(name="n", workspace_id="ws1", project_id="proj1")
|
||||
|
||||
|
||||
# ── Live integration (opt-in) ──────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
16
backend/uv.lock
generated
16
backend/uv.lock
generated
@ -992,7 +992,7 @@ redis = [
|
||||
{ name = "redis" },
|
||||
]
|
||||
tenki = [
|
||||
{ name = "tenki-sandbox" },
|
||||
{ name = "tenki" },
|
||||
]
|
||||
tui = [
|
||||
{ name = "textual" },
|
||||
@ -1049,7 +1049,7 @@ requires-dist = [
|
||||
{ name = "redis", marker = "extra == 'redis'", specifier = ">=5.0.0" },
|
||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0,<3.0" },
|
||||
{ name = "tavily-python", specifier = ">=0.7.17" },
|
||||
{ name = "tenki-sandbox", marker = "extra == 'tenki'", specifier = ">=0.4.0" },
|
||||
{ name = "tenki", marker = "extra == 'tenki'", specifier = ">=1.0.0" },
|
||||
{ name = "textual", marker = "extra == 'tui'", specifier = ">=0.80" },
|
||||
{ name = "tiktoken", specifier = ">=0.8.0" },
|
||||
]
|
||||
@ -4540,8 +4540,8 @@ name = "standard-aifc"
|
||||
version = "3.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "standard-chunk", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "audioop-lts" },
|
||||
{ name = "standard-chunk" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" }
|
||||
wheels = [
|
||||
@ -4624,17 +4624,17 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tenki-sandbox"
|
||||
version = "0.4.0"
|
||||
name = "tenki"
|
||||
version = "1.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "grpcio" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "websocket-client" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/58/f6527c63b2e4c94fd00a48ba4bd93ef52de22dbc72e247110949a17511f6/tenki_sandbox-0.4.0.tar.gz", hash = "sha256:2836e6e7100dfc81715b4d93ecfe80e3f055867498cb9f34be921a02969bb15f", size = 179392, upload-time = "2026-07-17T22:18:09.869Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/d9/1669b9ffe9b729a854985918ec84f39abf1ba032764a00bd105459e7e97d/tenki-1.0.2.tar.gz", hash = "sha256:131947e54758674f814a66699d3b65518adc9f093a62ea6dddbd4394cbc771c4", size = 240024, upload-time = "2026-08-27T19:40:32.85Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/f7/3c02da98793dc0e56230c520064d7d233c6e0edab13b410c031e1cfe7fd9/tenki_sandbox-0.4.0-py3-none-any.whl", hash = "sha256:76d5ece2e1607b43705587d86277e983ef43601a86993077e379be8033a40b3e", size = 155246, upload-time = "2026-07-17T22:18:08.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/ef/06486b129744c3f7293f9b1f1ea60055104db4d4ad03483c6857c57c0bbb/tenki-1.0.2-py3-none-any.whl", hash = "sha256:6259651ed243e071e88d789fa86a7c570954ec5f0372a40bcccc2d4b9ae2e342", size = 164788, upload-time = "2026-08-27T19:40:30.915Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -1482,8 +1482,10 @@ sandbox:
|
||||
# # api_key: $TENKI_API_KEY # falls back to TENKI_API_KEY / TENKI_AUTH_TOKEN env var
|
||||
# # base_url: https://tenki.cloud # optional; SDK default when omitted
|
||||
# # image: my-base-image # optional; Tenki account default base image when omitted
|
||||
# # project_id: proj_... # optional; auto-selected if the account has exactly one
|
||||
# # workspace_id: ws_... # optional; auto-selected if the account has exactly one
|
||||
# # Migration: sandbox.project_id is gone — Tenki 1.x removed projects, so scope
|
||||
# # is the workspace alone. Set workspace_id if the account has more than one.
|
||||
# # A leftover project_id is ignored and logs a warning at startup.
|
||||
# # cpu_cores: 2 # optional per-sandbox vCPUs
|
||||
# # memory_mb: 2048 # optional per-sandbox memory
|
||||
# # replicas: 3 # active + warm microVM cap per gateway process
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user