Skip to main content

Client Library

The idegym-client package provides a Python async client for interacting with the IdeGYM orchestrator. It handles authentication, client registration, server lifecycle management, and all tool operations.

Installation​

The client is part of the IdeGYM workspace. If you have the repository set up:

uv sync --all-packages --all-extras --all-groups

Or install from PyPI (once published):

uv add idegym-client

Quick Example​

import asyncio
from idegym.client.client import IdeGYMClient


async def main():
async with IdeGYMClient(
orchestrator_url="https://idegym.yourdomain.com",
name="my-training-run",
namespace="idegym",
) as client:
async with client.with_server(
image_tag="registry.example.com/my-env:latest",
server_name="my-server",
) as server:
result = await server.execute_bash(script="echo hello")
print(result.stdout) # → hello


asyncio.run(main())

IdeGYMClient​

from idegym.client.client import IdeGYMClient

The main entry point. Must be used as an async context manager — async with IdeGYMClient(...) as client — which registers the client with the orchestrator on entry and deregisters it on exit (stopping all associated servers). A failed deregistration leaks every pod the client owns, so it is never silent: it raises on exit, unless the body is already raising, in which case it is logged and the body's exception propagates.

Constructor​

IdeGYMClient(
orchestrator_url: str,
name: str,
namespace: str,
nodes_count: int = 0,
auth: Optional[BasicAuth] = None,
client_id: Optional[str] = None,
heartbeat_interval_in_seconds: int = 60,
request_timeout_in_seconds: int = 60,
otel_config: Optional[OTELConfig] = None,
transport: Optional[httpx.AsyncBaseTransport] = None,
limits: Optional[httpx.Limits] = None,
http_client: Optional[httpx.AsyncClient] = None,
)
ParameterDescription
orchestrator_urlURL of the orchestrator API (e.g., https://idegym.yourdomain.com)
nameClient name used for resource quota assignment
namespaceKubernetes namespace where servers are created
nodes_countNumber of nodes to reserve for this client (default: 0)
authBasicAuth(username, password). Defaults to IDEGYM_AUTH_USERNAME / IDEGYM_AUTH_PASSWORD environment variables
client_idIf provided, attaches to an existing client session without sending heartbeats
heartbeat_interval_in_secondsHow often to send liveness heartbeats to the orchestrator (default: 60)
request_timeout_in_secondsDefault HTTP request timeout (default: 60)
otel_configOpenTelemetry tracing configuration
transportTransport for the HTTP client IdeGYM builds — an alternative HTTP stack, a proxy, or a recording transport in tests
limitsConnection-pool limits for the HTTP client IdeGYM builds; omit for httpx's defaults (100 connections, 20 keep-alive)
http_clientA fully configured httpx.AsyncClient to use verbatim; orchestrator_url, auth, request_timeout_in_seconds and otel_config are then ignored

Configuring the HTTP stack:

import httpx

client = IdeGYMClient(
orchestrator_url="https://idegym.yourdomain.com",
name="my-training-run",
namespace="idegym",
limits=httpx.Limits(max_connections=64, max_keepalive_connections=16),
)

A supplied transport is used as-is, and httpx applies limits only to a transport it builds itself, so the two cannot be combined — passing both raises ValueError. Set the limits on the transport instead:

client = IdeGYMClient(
orchestrator_url="https://idegym.yourdomain.com",
name="my-training-run",
namespace="idegym",
transport=httpx.AsyncHTTPTransport(
limits=httpx.Limits(max_connections=64, max_keepalive_connections=16),
proxy="http://proxy.internal:3128",
),
)

http_client is the full escape hatch. It is used exactly as given — nothing about it is modified, not even to instrument it for tracing, so it must already carry base_url and any authentication — and it is not closed when the IdeGYMClient context exits, because it belongs to its caller. A client IdeGYM builds itself is closed on exit as before. With http_client, no credentials are required, and orchestrator_url, auth, request_timeout_in_seconds and otel_config are ignored: they only configure the client IdeGYM builds. Passing http_client together with transport or limits raises ValueError rather than ignoring them.

Authentication via environment variables:

export IDEGYM_AUTH_USERNAME=admin
export IDEGYM_AUTH_PASSWORD=your-password

with_server(...) — async context manager​

Start a server and yield an IdeGYMServer, then stop or finish it on exit.

async with client.with_server(
image_tag="registry.example.com/my-env:latest",
server_name="my-server",
namespace=None, # defaults to client namespace
runtime_class_name="gvisor",
run_as_root=False,
resources=None, # KubernetesResources
node_selector=None,
labels={"team": "research", "job": "swe-bench-run-42"},
annotations={"idegym.example.com/task-url": "https://tracker.example.com/TASK-1"},
server_start_wait_timeout_in_seconds=300,
reuse_strategy=ServerReuseStrategy.RESET,
close_action=ServerCloseAction.FINISH, # FINISH or STOP
) as server:
...
ParameterDescription
image_tagOCI image to run as the server
server_nameKubernetes name for the server deployment (must be a valid k8s object name)
runtime_class_nameRuntime class for the pod (e.g., "gvisor" for sandboxing)
run_as_rootRun the container as root (default: False)
resourcesKubernetes resource requests/limits
node_selectorNode affinity labels
labelsExtra labels for the server's Deployment, Pod, Service and PodDisruptionBudget
annotationsExtra annotations for the server pod
server_start_wait_timeout_in_secondsHow long to wait for the server pod to become ready (default: 300)
reuse_strategyWhether to take over an existing server: NONE (always create from scratch), RESTART (reuse and restart the pod), RESET (reuse and reset project state) — see Server reuse
close_actionFINISH — release the server but leave it running for the next client; STOP — stop and delete the server

The server is closed with finish_server or stop_server on the way out, whether the body succeeded or not. If the body raised and the cleanup fails as well, the cleanup error is logged rather than raised, so the body's exception — the one that says what went wrong first — is the one you see. A cleanup failure after a body that succeeded is raised.

Waiting for the server to be ready​

server_start_wait_timeout_in_seconds defaults to 300, which covers a cold image pull. A multi-gigabyte environment image takes minutes to pull onto a node that has never seen it, and the previous 60-second default expired long before that. Raise it further for larger images rather than retrying into the same wall — a retry re-pulls onto the same cold node.

The timeout is the orchestrator's: it is sent with the request, and the orchestrator waits that long for the pod. The client itself waits a grace period longer — the larger of 60 seconds and a tenth of the timeout, so 360 seconds in all by default — because the orchestrator's wait starts only after its own bookkeeping and the deploy, and ends with a look at the pod. That way the orchestrator's diagnosis, not a bare client-side timeout, is what reaches the caller. A 429 from a full quota is retried every retry_delay_in_seconds within the same client deadline. The client polls for the result with backoff, but never sleeps past its deadline: the last poll is made at the deadline itself, so a server that became ready shortly before it is still returned rather than left running unclaimed. restart_server uses the same deadline.

When the wait does expire, the error says what the pod was doing, so a slow pull is not mistaken for a broken health endpoint:

Pods with label 'app=srv-abc' were not ready within 300s
(not waiting on the scheduler; still pulling the image or creating the container (ContainerCreating))

The distinctions it draws are: still pulling or creating the container, the image could not be pulled, an init container that is still running or has failed (named, since the main container then waits with PodInitializing, which alone looks like a pull), the container is running but its readiness probe has not passed, a new pod that is ready but waiting for an old one to terminate, and no pod matched at all. Only the readiness probe case points at the image's health endpoint. With several pods it describes the first one that is not ready, prefixed with how many are (1/2 pods ready; ...).

Scheduling has its own budgets, separate from this timeout; see Clusters with slow node provisioning.

Tagging a sandbox​

labels and annotations are how a sandbox gets attributed to the job, team or task that created it, which makes the usual operator workflows apply:

kubectl get pods -n idegym -l job=swe-bench-run-42

They land on the Deployment, Pod, Service and PodDisruptionBudget. Use a label for anything you want to select or group by, and an annotation for metadata that is too long or too unstructured to be one — a Kubernetes label value is capped at 63 characters. Both are checked against the Kubernetes syntax when the request is built: a key is an optional lowercase DNS prefix (at most 253 characters) and a slash, then a name of at most 63 characters, and all annotation keys and values together may not exceed 256 KiB. A request that breaks these fails with a ValidationError instead of a server that fails to start.

Keys IdeGYM manages are rejected, not silently overwritten: app, anything under app.kubernetes.io/, and anything under idegym.jetbrains.com/. Those are what the Service selector, the PodDisruptionBudget and the watcher's pod queries match on, so taking one over would detach the sandbox from the machinery that manages it. The annotations IdeGYM sets are rejected the same way: cluster-autoscaler.kubernetes.io/safe-to-evict, anything under prometheus.io/, and anything under podsnapshot.gke.io/. The last one matters most, since it names the GKE pod snapshot a pod is restored from. Any other annotation key is accepted, including ones under the managed label prefixes, because annotations carry no selector weight.

Two things they deliberately do not affect. They are not part of the snapshot hash — they describe who asked for a sandbox, not what is in it, and a per-job label would otherwise make every snapshot a miss. And they are not part of reuse matching: a server reused through RESTART or RESET (the default) keeps the labels and annotations it was created with, and the new request's values are not applied to it. So kubectl get pods -l job=<new job> does not find a reused sandbox. Pass reuse_strategy=ServerReuseStrategy.NONE when the pod's metadata must match this request, and do not rely on labels to tell one episode from the next on a reused server.

start_server(...) / stop_server(...) / finish_server(...)​

Explicit lifecycle control without the context manager:

server = await client.start_server(image_tag=..., server_name=..., ...)

# Work with the server...

await client.finish_server(server) # release without stopping
# or
await client.stop_server(server) # stop and delete

stop_server, finish_server and restart_server raise when the operation fails, like the rest of the API — they do not report failure as a return value. There is no return value to check, so a failed delete cannot be mistaken for a successful one and leak the pod:

from idegym.client import IdeGYMHTTPError

try:
await client.stop_server(server)
except IdeGYMHTTPError as e:
... # the pod may still be running; the server is not stopped

stop_server and finish_server retry a failed call twice more with exponential backoff before raising. A permanent client error — a 4xx other than 408 and 429, such as the 404 or 410 for a server that was already reaped — is raised at once instead of being retried.

Server reuse​

Reuse is attempted only for RESTART and RESET. A candidate is taken over only if all of these match:

FieldComes from
client namethe name this client registered with
image_tagthe start request
runtime_class_namethe start request
run_as_rootthe start request
server_kindthe start request
server_namethe start request, when set
availability is FINISHEDhow the previous holder released it

server_name is one of seven filters, not the key — two requests with the same name but different images will not share a server.

labels and annotations are not among them, and a server that is taken over keeps the Kubernetes metadata it was created with — see Tagging a sandbox.

The last row is the one that catches people. A server becomes FINISHED only through finish_server (or close_action=FINISH, which with_server uses by default). A client that always calls stop_server leaves its servers STOPPED, and reuse can then never hit — the requests look identical and a new server is created every time.

Because that is invisible from the outside, the start response says what happened:

server = await client.start_server(..., reuse_strategy=ServerReuseStrategy.RESET)
if not server.reused:
... # a fresh server; the project is already in its initial state

list_servers(include_terminal=False) — what am I holding?​

for server in await client.list_servers():
print(server.server_id, server.availability, server.image_tag)

Scoped to this client's registration, newest first. Terminal servers (STOPPED, KILLED, CRASHED, …) are excluded unless include_terminal=True, so the default answers "what is still mine and running" — which is what makes cleaning up after a crashed run possible without cluster access.

Each row carries server_id, server_name, generated_name, namespace, availability, usable, image_tag, created_at, last_activity_at, keepalive_until and details. There are no pod fields: listing them would mean one Kubernetes call per server. Use status() for the pod view of one server.

build_and_push_images(path, timeout, poll_interval) — image builds​

Submit a YAML image definition for Kaniko build and wait for completion:

from pathlib import Path

summary = await client.build_and_push_images(
path=Path("image.yaml"),
timeout=600, # seconds; None = no timeout
poll_interval=10, # seconds between status polls
)

if summary.failed_jobs > 0:
raise RuntimeError(f"Build failed: {summary.jobs_results}")

image_tag = summary.jobs_results[0].tag

health_check()​

response = await client.health_check()
print(response.status) # → "healthy"

Threads and event loops​

IdeGYMClient is bound to the event loop that created it. It owns an httpx session and a heartbeat task, so every call has to be awaited on that same loop. One asyncio program with one loop needs to know nothing more than this.

It matters when a caller drives sandboxes from several loops — a synchronous facade with one loop per sandbox, for instance. Sharing one registration across them is not optional bookkeeping: deregistering a client terminates every server it owns, so two registrations in one process means either can tear down the other's sandboxes.

SharedIdeGYMClient owns a loop in a dedicated thread and marshals every call onto it, so any thread — including one already running its own loop — can share the single registration:

from idegym.client import SharedIdeGYMClient

with SharedIdeGYMClient(
orchestrator_url="https://idegym.yourdomain.com",
name="my-training-run",
namespace="idegym",
) as shared:
server = shared.run(lambda client: client.start_server(image_tag="registry.example.com/env:latest"))
result = shared.run(lambda _: server.execute_bash("echo hi"))
shared.run(lambda client: client.stop_server(server))

It takes the same arguments as IdeGYMClient and registers on entry, exactly as async with IdeGYMClient(...) would.

MethodDescription
run(call, timeout=None)Run call(client) on the owned loop and return its result; on timeout the call is cancelled, then TimeoutError is raised
submit(call)Schedule call(client) and return a concurrent.futures.Future
clientThe underlying IdeGYMClient; only touch it from inside a call

call takes the client and returns an awaitable, rather than being an awaitable itself, so the awaitable is created on the owning loop — nothing ever binds to the caller's.

On exit, work still in flight gets a few seconds to finish, and is then cancelled — so a thread still waiting on a submit future is released with CancelledError rather than left blocked. Once exit has begun, new run and submit calls raise RuntimeError.


IdeGYMServer​

Returned by client.with_server() or client.start_server(). Provides all operations on a running server environment.

execute_bash(script, ...)​

Run a bash script in the environment:

result = await server.execute_bash(
script="python -c 'print(1+1)'",
command_timeout=600.0, # seconds
graceful_termination_timeout=2.0, # seconds
request_timeout=None, # HTTP timeout (uses client default)
max_output_bytes=1048576, # retained bytes for each stream; None disables truncation
)

print(result.exit_code) # 0
print(result.stdout) # "2\n"
print(result.stderr) # ""

The default retains the beginning and end of stdout and stderr independently and inserts a marker when bytes are omitted. Use max_output_bytes=None when a caller must parse complete, trusted output. Output is returned verbatim within that window; pass strip_output=True if you want surrounding whitespace trimmed.

cwd, env and user set per-command context, so you do not have to build cd, export and runuser into the script yourself:

result = await server.execute_bash(
"python -m pytest -q",
cwd="tests", # relative paths resolve against the project directory
env={"PYTHONHASHSEED": "0"}, # merged over the server's cleaned environment
user="devuser", # needs a root server, or passwordless sudo as in the server image
)

Values passed through env never enter the command text, so they are not written to the command log — prefer it to an export line for anything sensitive. See Tools — Per-command context.

reset_project(...)​

Reset the project to its original state (re-extracts the project archive):

result = await server.reset_project(
reset_timeout=600.0,
graceful_termination_timeout=2.0,
)

create_file(file_path, content, ...)​

Create a new file at the given path:

result = await server.create_file(
file_path="/home/devuser/hello.py",
content='print("hello world")\n',
)

edit_file(file_path, start_line, end_line, new_content, ...)​

Replace a range of lines in an existing file (1-indexed, inclusive):

result = await server.edit_file(
file_path="/home/devuser/hello.py",
start_line=1,
end_line=1,
new_content='print("goodbye world")\n',
)

patch_file(file_path, patch, ...)​

Apply a unified diff patch to a file:

result = await server.patch_file(
file_path="/home/devuser/hello.py",
patch="--- a/hello.py\n+++ b/hello.py\n@@ -1 +1 @@\n-print...",
)

Binary file transfer​

create_file and patch_file carry text. To move bytes — an archive, a wheel, a compiled artifact, a database fixture — use the transfer methods, which chunk the content as base64 over the ordinary forwarding path. That path stores and replays a request as JSON text, so base64 is what makes the round trip lossless; raw bytes would not survive it.

# Upload a local file into the container, then read it back byte-for-byte.
size = await server.upload_file("./fixtures/repo.tar.gz", "/root/work/repo.tar.gz")
await server.download_file("/root/work/out/results.db", "./results.db")

# In-memory variants for smaller payloads.
await server.upload_bytes("/root/work/blob.bin", b"\x00\xff")
blob = await server.download_bytes("/root/work/blob.bin")
MethodReturnsDescription
upload_file(local_path, file_path, chunk_bytes=..., ...)intStream a local file into the container; returns the resulting size
upload_bytes(file_path, data, chunk_bytes=..., ...)intWrite bytes already in memory; returns the resulting size
download_file(file_path, local_path, chunk_bytes=..., ...)intStream a container file to disk; returns the byte count
download_bytes(file_path, chunk_bytes=..., ...)bytesRead a container file into memory

chunk_bytes is the number of raw bytes per request and defaults to 1 MiB; base64 inflates that to roughly 1.4 MiB on the wire. The file-based variants read and write one chunk at a time, so a transfer is bounded by disk rather than by client memory. Uploads are sequential and each chunk truncates the file at its own end, so an interrupted upload leaves a short file rather than a file with a stale tail. Downloading a path that does not exist fails with a 404.

Prefer these over base64 through the bash tool: they are not bounded by the size of a single shell script, and the payload never reaches the command log.

keepalive(minutes=15.0) — hold a server that is idle on purpose​

The watcher reaps servers on time since the last completed request. That is only a proxy for "somebody is using this", and a poor one: a sandbox is equally quiet while an agent thinks, a build runs, or a human reads a stack trace. Call keepalive while you still hold the server.

hold = await server.keepalive(minutes=30)
print(hold.keepalive_until) # epoch milliseconds

Calling again extends the window and never shortens it, so two holders of the same server cannot cut each other short — which also means keepalive(minutes=1) after keepalive(minutes=60) leaves the longer hold in place, and the response reports the window actually in effect rather than the one you asked for. The maximum window is 24 hours. The hold belongs to the client that took it: when a finished server is handed to another client for reuse, the hold is cleared, so the new owner starts without one.

A hold on a server that has already reached a terminal state is refused with 410 Gone, which the client raises as IdeGYMNotFoundError: keepalive keeps a live server alive, it does not revive a dead one. Catch it as "the sandbox is gone, start a new one". status() reports the hold as keepalive_until.

status() — is this server usable?​

status = await server.status()

if not status.usable:
print(status.availability, status.details) # e.g. "CRASHED", "OOMKilled"
FieldTypeDescription
server_idintNumeric server ID
server_name / generated_namestrLogical name from the start request, and the Kubernetes resource name
namespacestrNamespace the server runs in
availabilitystrOrchestrator status: ALIVE, REUSED, FINISHED, STOPPED, CRASHED, …
usableboolTrue when the server accepts requests (ALIVE or REUSED)
image_tagstr | NoneImage the server runs
created_at / last_activity_atintEpoch milliseconds
idle_secondsfloatSeconds since the last recorded activity
keepalive_untilint | NoneEpoch milliseconds until which an explicit keepalive holds the server
pod_phasestr | NoneKubernetes phase, or None when no pod matches or the pod was not checked
pod_readybool | NoneTrue when the pod is Running with all containers ready; None when the pod was not checked
detailsstr | NoneFailure reason recorded on a terminal status

Use this rather than an unrelated call such as list_capabilities as a liveness probe. It answers for a finished, stopped or crashed server instead of raising, and reading it does not count as activity — so a polling loop will not keep a server from being reaped.

The pod fields are best-effort: they are None for a server in a terminal state, which is not looked up in Kubernetes, and when the Kubernetes lookup fails. The rest of the response still comes from the orchestrator's record, so check usable and availability rather than pod_ready to decide whether the server is gone.

restart_server(...)​

Restart the server pod (preserves the same image and configuration):

response = await server.restart_server(
server_start_wait_timeout_in_seconds=300,
)

list_capabilities() — loaded plugin list​

Return the list of server plugins running in the container:

result = await server.list_capabilities()
print(result.plugins) # → ["tools", "rewards"]

This calls GET /api/idegym-servers/{id}/capabilities on the orchestrator, which proxies to GET /api/capabilities on the server. The response reflects the contents of /etc/idegym/plugins.json written at image build time.

result: CapabilitiesResponse = await server.list_capabilities()

forward(method, path, body, ...) — generic plugin endpoint call​

An escape hatch for calling plugin-provided endpoints that do not have a typed wrapper:

from idegym.api.tools.bash import BashCommandRequest

response = await server.forward(
method="POST",
path="tools/bash",
body=BashCommandRequest(command="echo hello", timeout=30.0),
)
# response is the parsed JSON dict
server.forward(
method: str,
path: str,
body: Optional[BaseModel] = None,
request_timeout: Optional[int] = None,
polling_config: Optional[PollingConfig] = None,
) -> dict[str, Any]

The path is relative to the server's API base (/api/). For typed access to plugin operations, prefer the attribute-style API described below.


Plugin-Provided Operations​

Installed plugins can extend IdeGYMServer with typed operation objects. Each plugin package registers an operations class via the idegym.plugins.client entry point group. When IdeGYMServer is constructed, it loads all installed client plugins and attaches each as an attribute under the entry point name.

PyCharm operations (server.pycharm)​

server.pycharm is attached automatically when idegym-plugins[pycharm] is installed in the local Python environment and its idegym.plugins.client entry point loads successfully. This is a purely local check — it is independent of whether the running server image was built with the PyCharm plugin.

inspect(...)​

Runs the JetBrains built-in inspect.sh script on the server and returns an InspectResponse:

result = await server.pycharm.inspect(
project_path="/root/work",
profile_path="/root/work/.idea/inspectionProfiles/Default.xml",
output_dir="/tmp/inspect-out",
timeout=300.0,
)
assert result.exit_code == 0
# Read result files from the container:
xml = await server.execute_bash("cat /tmp/inspect-out/*.xml")
ParameterTypeDefaultDescription
project_pathstr—Absolute path to the project inside the container
profile_pathstr—Absolute path to an inspection profile XML file
output_dirstr—Directory where result files will be written
changes_onlyboolFalseOnly inspect locally changed files (-changes)
directoryOptional[str]NoneLimit scope to a subdirectory (-d)
formatstr"xml"Output format: "xml" or "json"
verbosityint0Verbosity level 0–2
timeoutfloat600.0Maximum seconds for inspect.sh to run
request_timeoutOptional[int]NoneHTTP request timeout override (seconds)

Note: inspect.sh runs in batch/headless mode — no Xvfb or display server is required for inspection. Xvfb is only needed when PyCharm opens projects interactively (open_project=True).

IDEA operations (server.idea)​

server.idea is attached automatically when idegym-plugins[idea] is installed in the local Python environment. It provides the same inspect() interface as server.pycharm:

result = await server.idea.inspect(
project_path="/root/work",
profile_path="/root/work/.idea/inspectionProfiles/Default.xml",
output_dir="/tmp/inspect-out",
)

IntelliJ IDEA supports true headless mode (java.awt.headless=true) — no Xvfb is needed by default. Pass Idea(headless=False) to run the IDE against a virtual display (Xvfb on :99), the same way PyCharm runs, for workloads that need a real AWT toolkit.

Checking for a plugin at runtime​

Use server.list_capabilities() to get the definitive list of plugins loaded in the running container, then check membership before calling plugin-specific methods:

caps = await server.list_capabilities()
# → CapabilitiesResponse(plugins=["tools", "rewards", "pycharm"])

if "pycharm" in caps.plugins and hasattr(server, "pycharm"):
result = await server.pycharm.inspect(
project_path="/root/work",
profile_path="/root/work/.idea/inspectionProfiles/Default.xml",
output_dir="/tmp/inspect-out",
)

list_capabilities() calls GET /api/idegym-servers/{id}/capabilities on the orchestrator, which proxies to GET /api/capabilities on the server container and returns the contents of /etc/idegym/plugins.json — the file written at image build time that controls which plugins are loaded at startup.

hasattr(server, "pycharm") still guards against the local package not being installed, but caps.plugins is the authoritative runtime check for whether the server image supports a plugin.


See also: Plugin Architecture — full guide for writing plugins that extend the server and client with new endpoints and typed operations.


Reward Operations​

IdeGYMServer exposes reward signals used for RL training evaluation:

compilation_reward(compilation_script, ...)​

Run a compilation check and get a pass/fail reward:

result = await server.compilation_reward(
compilation_script="cd /home/devuser/project && python -m py_compile main.py",
compilation_timeout=600.0,
)
print(result.success) # True / False

setup_reward(setup_check_script, ...)​

Run a setup verification script:

result = await server.setup_reward(
setup_check_script="cd /project && pip check",
setup_timeout=600.0,
)
print(result.success)

test_reward(test_script, ...)​

Run a test suite and get a structured report:

result = await server.test_reward(
test_script="cd /project && python -m pytest --tb=short",
test_timeout=600.0,
)
print(result.passed) # number of passing tests
print(result.failed) # number of failing tests
print(result.output) # full test output

WebSocket / OpenEnv Access​

For environments that implement the OpenEnv protocol, use server.openenv_url to get the WebSocket base URL and connect with an OpenEnv client:

url = server.openenv_url
# → "https://idegym.yourdomain.com/api/ws-forward/<client_id>/<server_id>"

# Pass to an OpenEnv-compatible client:
env_client = MyOpenEnvClient(base_url=url)

Environment Variables​

VariableDescriptionDefault
IDEGYM_AUTH_USERNAMEOrchestrator username—
IDEGYM_AUTH_PASSWORDOrchestrator password—
IDEGYM_OTEL_SERVICE_NAMEOpenTelemetry service name for tracesauto-generated
IDEGYM_OTEL_TRACING_ENDPOINTOTLP trace export endpoint— (tracing off)
IDEGYM_OTEL_TRACING_TIMEOUTTrace export timeout in seconds10
IDEGYM_OTEL_TRACING_AUTH_USERNAMETrace export auth username—
IDEGYM_OTEL_TRACING_AUTH_PASSWORDTrace export auth password—

Tracing​

Tracing is off unless you give it an endpoint — there is no default collector, so a client constructed with no OpenTelemetry configuration exports nothing. Turn it on with the environment variable above, or with an explicit config:

from idegym.api.config import OTELConfig, TracingConfig

client = IdeGYMClient(
orchestrator_url="https://idegym.yourdomain.com",
name="my-training-run",
namespace="idegym",
otel_config=OTELConfig(
service_name="my-training-run",
tracing=TracingConfig(endpoint="https://collector.internal/v1/traces"),
),
)

An explicit otel_config is used as given; the environment variables are only consulted when none is passed.


Error Handling​

An HTTP failure raises a subclass of IdeGYMHTTPError, chosen by status code. The exception carries status_code, body, method and url as attributes, so a retry policy can branch on the failure instead of parsing the message.

from idegym.client import IdeGYMBusyError, IdeGYMHTTPError, IdeGYMNotFoundError

try:
async with client.with_server(image_tag="registry.example.com/my-env:latest") as server:
...
except IdeGYMNotFoundError:
... # the sandbox is gone — start a new one
except IdeGYMBusyError as e:
... # the control plane is rate-limiting — back off and retry
except IdeGYMHTTPError as e:
print(f"Failed with {e.status_code}: {e.body}")
ExceptionStatusesWhat it means for a retry
IdeGYMBadRequestError400, 422, other 4xxThe request is wrong; retrying it unchanged will not help
IdeGYMAuthError401, 403Credentials are missing, wrong, or insufficient
IdeGYMNotFoundError404, 410The client, server, or operation is gone — including a pod the orchestrator can no longer reach
IdeGYMTimeoutError408, 504, client-side timeoutSafe to retry if the operation is idempotent
IdeGYMConnectionErrornone — no responseThe connection failed or broke off, e.g. while the orchestrator restarts; retry with backoff
IdeGYMBusyError429, 503Rate-limited or out of capacity; retry with backoff
IdeGYMCancelledError499Cancelled before finishing, usually by a disconnect
IdeGYMServerError5xxThe orchestrator failed, or a sandbox call failed on its way through it
IdeGYMSandboxErrorany error the sandbox itself returnedThe sandbox is alive and answered a forwarded call (a tool, reward or file request) with an error; read status_code and body

All of them subclass IdeGYMHTTPError, which subclasses both IdeGYMException and RuntimeError, and the message text is unchanged from before the typed exceptions existed — so an existing except RuntimeError still catches everything it used to.

An IdeGYMTimeoutError with status_code is None is a client-side timeout: the request never reached a status. That is the case to distinguish from a 504, where the orchestrator answered. It covers every deadline the SDK enforces itself — a single request's request_timeout, the wait while an async operation is polled (a long tools/bash call, say), and the overall server-start wait. IdeGYMTimeoutError is also a builtin TimeoutError, which is what those deadlines raised before, so an existing except TimeoutError still catches them. The underlying httpx exception, when there is one, is chained as __cause__, so a ConnectTimeout can still be told apart from a ReadTimeout or a PoolTimeout.

A call forwarded to the sandbox — execute_bash, a file transfer, forward() — can fail in two places, and the type says which. When the orchestrator cannot reach the pod it reports 410, raised as IdeGYMNotFoundError: the sandbox really is gone. When the sandbox answers with an error of its own — 404 Path not found for a missing file, say — the status it sent is relayed as IdeGYMSandboxError, whatever it is, so a missing file never reads as a missing sandbox. Before IdeGYMSandboxError existed a relayed status went through the table above; code that caught, for instance, IdeGYMServerError for a sandbox-side 500 should catch IdeGYMSandboxError too.

A transport failure that produced no response at all — a refused or reset connection, a broken exchange — raises IdeGYMConnectionError, again with status_code is None and the httpx error as __cause__. Code that catches httpx.TimeoutException or httpx.HTTPError around an SDK call no longer sees these; catch IdeGYMTimeoutError, IdeGYMConnectionError or IdeGYMHTTPError instead.


Complete Example with Rewards​

import asyncio
from pathlib import Path
from idegym.client.client import IdeGYMClient
from idegym.image.builder import Image
from idegym.plugins.defaults.image import User, Project


async def train_step(client: IdeGYMClient, image_tag: str, patch: str) -> float:
"""Apply a patch and return a test-pass reward."""
async with client.with_server(
image_tag=image_tag,
server_name="train-server",
reuse_strategy="RESET", # reset project on each episode
server_start_wait_timeout_in_seconds=300,
) as server:
# Apply the model's proposed change
await server.patch_file(
file_path="/home/devuser/project/src/main.py",
patch=patch,
)

# Evaluate
result = await server.test_reward(
test_script="cd /home/devuser/project && python -m pytest -q",
test_timeout=120.0,
)

total = result.passed + result.failed
return result.passed / total if total > 0 else 0.0


async def main():
async with IdeGYMClient(
orchestrator_url="https://idegym.yourdomain.com",
name="rl-training",
namespace="idegym",
nodes_count=4, # reserve 4 nodes for parallel episodes
) as client:
image_tag = "registry.example.com/my-env:latest"
reward = await train_step(client, image_tag, patch="--- ...")
print(f"Reward: {reward:.2f}")


asyncio.run(main())