Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions responses_api_agents/claude_code_agent/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,13 +262,19 @@ def model_post_init(self, __context: Any) -> None:

def _resolve_base_url(self) -> str:
if self.config.model_server:
cfg = get_first_server_config_dict(
self.server_client.global_config_dict,
self.config.model_server.name,
)
return self.server_client._build_server_base_url(cfg)
return self._resolve_model_base_url().removesuffix("/v1")
return self.config.anthropic_base_url or ""

def _resolve_model_base_url(self) -> str:
if self.config.model_server is None:
return ""
config = get_first_server_config_dict(
self.server_client.global_config_dict,
self.config.model_server.name,
)
base_url = self.server_client._build_server_base_url(config).rstrip("/")
return base_url if base_url.endswith("/v1") else f"{base_url}/v1"

def _resolve_call_base_url(self, rollout_id: Optional[str]) -> str:
"""Base URL for the CLI's model calls, with the per-rollout capture prefix applied only when a
Gym model server is configured. A real Anthropic endpoint (``model_server`` unset) has no
Expand Down
5 changes: 5 additions & 0 deletions responses_api_agents/claude_code_agent/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,11 @@ def test_no_skills_ref_forwards_none(self) -> None:


class TestRunClaudeCode:
def test_model_server_uses_anthropic_root(self) -> None:
agent = _make_agent(model_server={"type": "responses_api_models", "name": "policy_model"})
with patch.object(agent, "_resolve_model_base_url", return_value="http://model/v1"):
assert agent._resolve_base_url() == "http://model"

def test_wires_command_env_and_cleans_up(self, tmp_path: Path) -> None:
agent = _make_agent(mcp_config="/path/to/mcp.json")
captured: dict = {}
Expand Down
4 changes: 3 additions & 1 deletion responses_api_agents/hermes_agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ hermes_agent:
model_server:
type: responses_api_models
name: policy_model
model: served-model-name
enabled_toolsets: [terminal, file, code_execution]
max_turns: 30
concurrency: 32
Expand All @@ -93,11 +94,12 @@ hermes_agent:
|-------|---------|-------------|
| `enabled_toolsets` | `null` (all) | forwarded to `AIAgent(enabled_toolsets=...)` |
| `disabled_toolsets` | `null` | forwarded to `AIAgent(disabled_toolsets=...)` |
| `model` | `null` | served model id; defaults to `model_server.name` for backward compatibility |
| `max_turns` | `30` | maps to `AIAgent.max_iterations` |
| `concurrency` | `32` | max simultaneous `run()` calls |
| `temperature` | `1.0` | sampling temperature passed to `AIAgent` |
| `terminal_backend` | `local` | sets `TERMINAL_ENV` (process-global); `local`, `docker`, `daytona`, `modal`, `ssh` |
| `terminal_timeout` | `60` | sets `TERMINAL_TIMEOUT` (process-global); per-command wall-clock seconds |
| `system_prompt` | `null` | passed as `system_message` to `run_conversation`; falls back to any system item in `body.input` |

The model-server url is resolved at request time and passed to `AIAgent(base_url=..., api_key="gym")`. <!-- pragma: allowlist secret -->
The model-server url is resolved at request time and passed to `AIAgent(base_url=..., api_key="gym")`. <!-- pragma: allowlist secret -->
8 changes: 6 additions & 2 deletions responses_api_agents/hermes_agent/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ def _split_input_to_user_and_history(input_items) -> tuple[str, list[dict], Opti
class HermesAgentConfig(BaseResponsesAPIAgentConfig):
resources_server: ResourcesServerRef
model_server: ModelServerRef
model: Optional[str] = None
concurrency: int = 32
max_turns: int = 90
enabled_toolsets: Optional[list[str]] = None
Expand Down Expand Up @@ -211,7 +212,7 @@ def _build_config(self) -> str:
import yaml

config: dict[str, Any] = {
"model": str(self.config.model_server.name),
"model": self._model_name(),
"provider": "auto",
"toolsets": ["hermes-cli"],
"agent": {"max_turns": self.config.max_turns},
Expand Down Expand Up @@ -251,6 +252,9 @@ def model_post_init(self, __context: Any) -> None:
_f.write(self._build_config())
os.environ["HERMES_HOME"] = hermes_home

def _model_name(self) -> str:
return self.config.model or str(self.config.model_server.name)

async def responses(
self,
request: Request,
Expand All @@ -268,7 +272,7 @@ async def responses(
# A prefixed self-call carries the rollout id into the model-server base URL.
rollout_id = request.path_params.get("rollout_id") if request is not None else None
base_url = self.resolve_model_base_url(self.config.model_server.name, rollout_id)
model_name = str(self.config.model_server.name)
model_name = self._model_name()

agent = AIAgent(
base_url=base_url,
Expand Down
8 changes: 8 additions & 0 deletions responses_api_agents/hermes_agent/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ def test_concurrency_semaphore_initialized(self) -> None:
agent = HermesAgent(config=_config(concurrency=4), server_client=MagicMock(spec=ServerClient))
assert agent.sem._value == 4

def test_model_defaults_to_server_name(self) -> None:
agent = HermesAgent(config=_config(), server_client=MagicMock(spec=ServerClient))
assert agent._model_name() == ""

def test_configured_model_overrides_server_name(self) -> None:
agent = HermesAgent(config=_config(model="Qwen3.6-35B-A3B"), server_client=MagicMock(spec=ServerClient))
assert agent._model_name() == "Qwen3.6-35B-A3B"


class _FakeAgent:
"""Stand-in for AIAgent — only needs .interrupt() for the SIGTERM dispatch path."""
Expand Down
9 changes: 8 additions & 1 deletion responses_api_agents/openclaw_agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,18 @@ openclaw_config:
- {id: nvidia/meta/llama-3.3-70b-instruct, name: nvidia/meta/llama-3.3-70b-instruct, api: openai-completions}
```

Alternatively, set `model_server` to a Gym model server and set `model` to its served model id. The
agent creates the OpenClaw provider entry automatically. Without `model_server`, the existing
provider configuration is unchanged.

## Config fields

- `concurrency`: max simultaneous `run()` calls
- `command`: the OpenClaw command, split on spaces so a multi-word launcher works (e.g. `npx openclaw`)
- `model`: `<provider>/<model-name>` (see Model id)
- `model_server`: optional Gym model server used to generate the provider entry
- `context_window`: context limit for a generated model entry
- `max_output_tokens`: output limit for a generated model entry
- `workspace_root`: where per-request workspaces are created and deleted
- `openclaw_agent_id`: passed to `--agent`
- `thinking`: passed to `--thinking` (off, low, medium, high, ...)
Expand All @@ -53,4 +60,4 @@ openclaw_config:
- `openclaw_config`: deep-merged into the generated `openclaw.json`
- `openclaw_version`: npm version to pin on install (null means latest)

See `configs/openclaw_agent.yaml`.
See `configs/openclaw_agent.yaml`.
42 changes: 40 additions & 2 deletions responses_api_agents/openclaw_agent/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
Body,
SimpleResponsesAPIAgent,
)
from nemo_gym.config_types import ResourcesServerRef
from nemo_gym.config_types import ModelServerRef, ResourcesServerRef
from nemo_gym.global_config import get_first_server_config_dict
from nemo_gym.openai_utils import (
NeMoGymEasyInputMessage,
NeMoGymFunctionCallOutput,
Expand Down Expand Up @@ -215,6 +216,7 @@ def _extract_instruction(body_input) -> tuple[str, Optional[str]]:

class OpenClawAgentConfig(BaseResponsesAPIAgentConfig):
resources_server: ResourcesServerRef
model_server: Optional[ModelServerRef] = None
concurrency: int = 32
command: str = "openclaw"
model: str = "nvinf/nvidia/meta/llama-3.3-70b-instruct"
Expand All @@ -229,6 +231,8 @@ class OpenClawAgentConfig(BaseResponsesAPIAgentConfig):
timeout: int = 900
extra_args: list[str] = []
openclaw_config: dict[str, Any] = Field(default_factory=dict)
context_window: int = 262144
max_output_tokens: int = 131072
openclaw_version: Optional[str] = None

@property
Expand Down Expand Up @@ -283,9 +287,43 @@ def _merge_headless_tool_denies(self, cfg: dict[str, Any]) -> None:
def _build_openclaw_config(self, base: dict[str, Any]) -> dict[str, Any]:
cfg = copy.deepcopy(base)
self._deep_merge(cfg, copy.deepcopy(self.config.openclaw_config))
if self.config.model_server:
providers = cfg.setdefault("models", {}).setdefault("providers", {})
nemo = providers.setdefault("nemo", {})
nemo.update(
{
"api": "openai-completions",
"baseUrl": self._resolve_model_base_url(),
"apiKey": "EMPTY", # pragma: allowlist secret
"models": [
{
"id": self.config.model,
"name": self.config.model,
"api": "openai-completions",
"reasoning": True,
"input": ["text"],
"contextWindow": self.config.context_window,
"maxTokens": self.config.max_output_tokens,
}
],
}
)
self._merge_headless_tool_denies(cfg)
return cfg

def _resolve_model_base_url(self) -> str:
if self.config.model_server is None:
return ""
config = get_first_server_config_dict(
self.server_client.global_config_dict,
self.config.model_server.name,
)
base_url = self.server_client._build_server_base_url(config).rstrip("/")
return base_url if base_url.endswith("/v1") else f"{base_url}/v1"

def _effective_model(self) -> str:
return f"nemo/{self.config.model}" if self.config.model_server else self.config.model

def _workspace_root(self) -> Path:
root = Path(self.config.workspace_root).expanduser() / f"openclaw_{uuid4().hex[:8]}"
if not root.is_absolute():
Expand Down Expand Up @@ -366,7 +404,7 @@ async def _run_openclaw(
"--thinking",
self.config.thinking,
"--model",
self.config.model,
self._effective_model(),
"--message",
prompt,
*self.config.extra_args,
Expand Down
15 changes: 15 additions & 0 deletions responses_api_agents/openclaw_agent/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import yaml

from nemo_gym.config_types import ModelServerRef
from nemo_gym.openai_utils import (
NeMoGymEasyInputMessage,
NeMoGymFunctionCallOutput,
Expand Down Expand Up @@ -238,6 +239,20 @@ def test_user_deny_cannot_drop_headless_deny(self) -> None:
assert "message" in cfg["tools"]["deny"]
assert "custom" in cfg["tools"]["deny"]

def test_model_server_builds_local_provider(self) -> None:
agent = _make_agent(
model="Qwen3.6-35B-A3B",
model_server=ModelServerRef(type="responses_api_models", name="policy_model"),
)
with patch.object(agent, "_resolve_model_base_url", return_value="http://model/v1"):
cfg = agent._build_openclaw_config({})

provider = cfg["models"]["providers"]["nemo"]
assert agent._effective_model() == "nemo/Qwen3.6-35B-A3B"
assert provider["baseUrl"] == "http://model/v1"
assert provider["models"][0]["id"] == "Qwen3.6-35B-A3B"
assert provider["models"][0]["maxTokens"] == 131072

def test_timeout_pads_empty_output(self) -> None:
agent = _make_agent()

Expand Down
7 changes: 7 additions & 0 deletions responses_api_agents/opencode_agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,18 @@ opencode_config:
nvidia/qwen/qwen3-next-80b-a3b-instruct: {}
```

Alternatively, set `model_server` to a Gym model server and set `model` to its served model id. The
agent creates the OpenCode provider entry automatically. Without `model_server`, the existing URL,
key, and provider configuration are unchanged.

## Config fields

- `concurrency`: max simultaneous `run()` calls
- `command`: the OpenCode command, split on spaces so a multi-word launcher works (e.g. `npx opencode`)
- `model`: `<provider>/<model-name>` (see Model id)
- `model_server`: optional Gym model server used to generate the provider entry
- `context_window`: context limit for a generated model entry
- `max_output_tokens`: output limit for a generated model entry
- `openai_api_key`: passed to the subprocess as `OPENAI_API_KEY`
- `openai_base_url`: passed to the subprocess as `OPENAI_BASE_URL`
- `env`: extra env vars for the subprocess
Expand Down
55 changes: 47 additions & 8 deletions responses_api_agents/opencode_agent/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
Body,
SimpleResponsesAPIAgent,
)
from nemo_gym.config_types import ResourcesServerRef
from nemo_gym.config_types import ModelServerRef, ResourcesServerRef
from nemo_gym.global_config import get_first_server_config_dict
from nemo_gym.openai_utils import (
NeMoGymEasyInputMessage,
NeMoGymFunctionCallOutput,
Expand Down Expand Up @@ -161,6 +162,7 @@ def _extract_instruction(body_input) -> tuple[str, Optional[str]]:

class OpenCodeAgentConfig(BaseResponsesAPIAgentConfig):
resources_server: ResourcesServerRef
model_server: Optional[ModelServerRef] = None
concurrency: int = 8
command: str = "opencode"
model: str = "openai/gpt-4o-mini"
Expand All @@ -176,6 +178,8 @@ class OpenCodeAgentConfig(BaseResponsesAPIAgentConfig):
timeout: int = 900
extra_args: list[str] = []
opencode_config: dict[str, Any] = Field(default_factory=dict)
context_window: int = 262144
max_output_tokens: int = 131072
opencode_version: Optional[str] = None

@property
Expand Down Expand Up @@ -233,18 +237,53 @@ def _repo_dir(self, fallback: Path) -> Path:
root.mkdir(parents=True, exist_ok=True)
return root

def _resolve_model_base_url(self) -> str:
if self.config.model_server is None:
return ""
config = get_first_server_config_dict(
self.server_client.global_config_dict,
self.config.model_server.name,
)
base_url = self.server_client._build_server_base_url(config).rstrip("/")
return base_url if base_url.endswith("/v1") else f"{base_url}/v1"

def _effective_model(self) -> str:
return f"nemo/{self.config.model}" if self.config.model_server else self.config.model

def _build_opencode_config(self) -> dict[str, Any]:
config = self._deep_merge({}, copy.deepcopy(self.config.opencode_config))
if self.config.model_server:
providers = config.setdefault("provider", {})
nemo = providers.setdefault("nemo", {"npm": "@ai-sdk/openai-compatible"})
nemo.setdefault("options", {}).update(
{"baseURL": self._resolve_model_base_url(), "apiKey": "EMPTY"} # pragma: allowlist secret
)
model = nemo.setdefault("models", {}).get(self.config.model, {})
self._deep_merge(
model,
{
"name": self.config.model,
"interleaved": {"field": "reasoning"},
"limit": {"context": self.config.context_window, "output": self.config.max_output_tokens},
},
)
nemo["models"] = {self.config.model: model}
return config

def _write_opencode_config(self, work_dir: Path) -> None:
if not self.config.opencode_config:
config = self._build_opencode_config()
if not config:
return
config = self._deep_merge({}, copy.deepcopy(self.config.opencode_config))
(work_dir / "opencode.json").write_text(json.dumps(config, indent=2))

def _env(self, data_home: str) -> dict[str, str]:
env = {**os.environ, "XDG_DATA_HOME": data_home}
if self.config.openai_base_url:
env["OPENAI_BASE_URL"] = self.config.openai_base_url
if self.config.openai_api_key:
env["OPENAI_API_KEY"] = self.config.openai_api_key
base_url = self._resolve_model_base_url() if self.config.model_server else self.config.openai_base_url
api_key = "EMPTY" if self.config.model_server else self.config.openai_api_key
if base_url:
env["OPENAI_BASE_URL"] = base_url
if api_key:
env["OPENAI_API_KEY"] = api_key
env.update({k: v for k, v in self.config.env.items() if v})
return env

Expand All @@ -260,7 +299,7 @@ async def _run_opencode(
self._write_opencode_config(project_dir)
env = self._env(str(data_home))

cmd = [*self.config.command_parts, "run", "-m", self.config.model, "--dir", str(project_dir)]
cmd = [*self.config.command_parts, "run", "-m", self._effective_model(), "--dir", str(project_dir)]
if self.config.thinking:
cmd.append("--thinking")
cmd.extend(self.config.extra_args)
Expand Down
17 changes: 16 additions & 1 deletion responses_api_agents/opencode_agent/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

import yaml

from nemo_gym.config_types import ResourcesServerRef
from nemo_gym.config_types import ModelServerRef, ResourcesServerRef
from nemo_gym.openai_utils import (
NeMoGymEasyInputMessage,
NeMoGymFunctionCallOutput,
Expand Down Expand Up @@ -180,6 +180,21 @@ def test_env_passthrough(self) -> None:
assert env["FOO"] == "bar"
assert "EMPTY" not in env

def test_model_server_builds_local_provider(self) -> None:
agent = _make_agent(
model="Qwen3.6-35B-A3B",
model_server=ModelServerRef(type="responses_api_models", name="policy_model"),
)
with patch.object(agent, "_resolve_model_base_url", return_value="http://model/v1"):
env = agent._env("/tmp/data")
config = agent._build_opencode_config()

provider = config["provider"]["nemo"]
assert agent._effective_model() == "nemo/Qwen3.6-35B-A3B"
assert env["OPENAI_BASE_URL"] == "http://model/v1"
assert provider["options"]["baseURL"] == "http://model/v1"
assert provider["models"]["Qwen3.6-35B-A3B"]["limit"]["output"] == 131072


class TestRepoDir:
def test_creates_configured_repo_dir(self, tmp_path: Path) -> None:
Expand Down
Loading
Loading