diff --git a/responses_api_agents/hermes_agent/README.md b/responses_api_agents/hermes_agent/README.md index 951b3bfa72..76c6de5090 100644 --- a/responses_api_agents/hermes_agent/README.md +++ b/responses_api_agents/hermes_agent/README.md @@ -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 @@ -93,6 +94,7 @@ 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` | @@ -100,4 +102,4 @@ hermes_agent: | `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")`. \ No newline at end of file +The model-server url is resolved at request time and passed to `AIAgent(base_url=..., api_key="gym")`. diff --git a/responses_api_agents/hermes_agent/app.py b/responses_api_agents/hermes_agent/app.py index b828a52de4..900f482cf6 100644 --- a/responses_api_agents/hermes_agent/app.py +++ b/responses_api_agents/hermes_agent/app.py @@ -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 @@ -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}, @@ -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, @@ -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, diff --git a/responses_api_agents/hermes_agent/tests/test_app.py b/responses_api_agents/hermes_agent/tests/test_app.py index c59a702090..3be899956d 100644 --- a/responses_api_agents/hermes_agent/tests/test_app.py +++ b/responses_api_agents/hermes_agent/tests/test_app.py @@ -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.""" diff --git a/responses_api_agents/openclaw_agent/README.md b/responses_api_agents/openclaw_agent/README.md index c86022f767..55f6739832 100644 --- a/responses_api_agents/openclaw_agent/README.md +++ b/responses_api_agents/openclaw_agent/README.md @@ -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`: `/` (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, ...) @@ -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`. \ No newline at end of file +See `configs/openclaw_agent.yaml`. diff --git a/responses_api_agents/openclaw_agent/app.py b/responses_api_agents/openclaw_agent/app.py index 266db68727..4044a7bb40 100644 --- a/responses_api_agents/openclaw_agent/app.py +++ b/responses_api_agents/openclaw_agent/app.py @@ -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, @@ -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" @@ -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 # required: every config must pin an explicit version so runs are reproducible and cannot silently drift openclaw_version: str @@ -284,9 +288,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(): @@ -367,7 +405,7 @@ async def _run_openclaw( "--thinking", self.config.thinking, "--model", - self.config.model, + self._effective_model(), "--message", prompt, *self.config.extra_args, diff --git a/responses_api_agents/openclaw_agent/tests/test_app.py b/responses_api_agents/openclaw_agent/tests/test_app.py index b0322c5728..f85b54e7bd 100644 --- a/responses_api_agents/openclaw_agent/tests/test_app.py +++ b/responses_api_agents/openclaw_agent/tests/test_app.py @@ -20,6 +20,7 @@ import yaml +from nemo_gym.config_types import ModelServerRef from nemo_gym.openai_utils import ( NeMoGymEasyInputMessage, NeMoGymFunctionCallOutput, @@ -239,6 +240,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() diff --git a/responses_api_agents/opencode_agent/README.md b/responses_api_agents/opencode_agent/README.md index 9ad2d45547..5ce33d5155 100644 --- a/responses_api_agents/opencode_agent/README.md +++ b/responses_api_agents/opencode_agent/README.md @@ -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`: `/` (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 diff --git a/responses_api_agents/opencode_agent/app.py b/responses_api_agents/opencode_agent/app.py index 5eee9e948b..e628ac32bc 100644 --- a/responses_api_agents/opencode_agent/app.py +++ b/responses_api_agents/opencode_agent/app.py @@ -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, @@ -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" @@ -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 @@ -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 # pragma: allowlist secret + 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 @@ -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) diff --git a/responses_api_agents/opencode_agent/tests/test_app.py b/responses_api_agents/opencode_agent/tests/test_app.py index eab5ac99bf..1d61ad726a 100644 --- a/responses_api_agents/opencode_agent/tests/test_app.py +++ b/responses_api_agents/opencode_agent/tests/test_app.py @@ -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, @@ -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: diff --git a/responses_api_agents/pi_agent/README.md b/responses_api_agents/pi_agent/README.md index cc7bb915f2..63b6404cfe 100644 --- a/responses_api_agents/pi_agent/README.md +++ b/responses_api_agents/pi_agent/README.md @@ -42,11 +42,18 @@ models_config: reasoning: false ``` +Alternatively, set `model_server` to a Gym model server and set `model` to its served model id. The +agent creates the Pi provider entry automatically. Without `model_server`, the existing provider +configuration is unchanged. + ## Config fields - `concurrency`: max simultaneous `run()` calls - `command`: the pi command, split on spaces so a multi-word launcher works - `model`: `/` (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 - `env`: extra env vars for the subprocess (e.g. provider API keys) - `workspace_root`: where per-request HOMEs are created and deleted - `thinking`: passed to `--thinking` (off, minimal, low, medium, high, xhigh) @@ -56,4 +63,4 @@ models_config: - `models_config`: written to `~/.pi/agent/models.json` - `pi_version`: npm version to pin on install (null means latest) -See `configs/pi_agent.yaml`. \ No newline at end of file +See `configs/pi_agent.yaml`. diff --git a/responses_api_agents/pi_agent/app.py b/responses_api_agents/pi_agent/app.py index 4ee551b059..a85f784f3b 100644 --- a/responses_api_agents/pi_agent/app.py +++ b/responses_api_agents/pi_agent/app.py @@ -14,6 +14,7 @@ # limitations under the License. import asyncio +import copy import json import logging import os @@ -34,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, @@ -158,6 +160,7 @@ def _extract_instruction(body_input) -> tuple[str, Optional[str]]: class PiAgentConfig(BaseResponsesAPIAgentConfig): resources_server: ResourcesServerRef + model_server: Optional[ModelServerRef] = None concurrency: int = 8 command: str = "pi" model: str = "nvinf/nvidia/qwen/qwen3-next-80b-a3b-instruct" @@ -168,6 +171,8 @@ class PiAgentConfig(BaseResponsesAPIAgentConfig): timeout: int = 900 extra_args: list[str] = [] models_config: dict[str, Any] = Field(default_factory=dict) + context_window: int = 262144 + max_output_tokens: int = 131072 pi_version: Optional[str] = None @property @@ -211,13 +216,50 @@ def _env(self, home: Path) -> dict[str, str]: env.update({k: v for k, v in self.config.env.items() if v}) return env + 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_models_config(self) -> dict[str, Any]: + config = copy.deepcopy(self.config.models_config) + if self.config.model_server is None: + return config + providers = config.setdefault("providers", {}) + providers["nemo"] = { + "baseUrl": self._resolve_model_base_url(), + "api": "openai-completions", + "apiKey": "EMPTY", # pragma: allowlist secret + "compat": {"supportsDeveloperRole": False, "supportsReasoningEffort": False}, + "models": [ + { + "id": self.config.model, + "reasoning": True, + "input": ["text"], + "contextWindow": self.config.context_window, + "maxTokens": self.config.max_output_tokens, + } + ], + } + return config + async def _run_pi(self, instruction: str, system_prompt: Optional[str]) -> tuple[list[Any], dict[str, int], str]: - provider, _, model_id = self.config.model.partition("/") + effective_model = self._effective_model() + provider, _, model_id = effective_model.partition("/") work_dir = self._workspace_root() home = work_dir / ".pi-home" (home / ".pi" / "agent").mkdir(parents=True, exist_ok=True) - if self.config.models_config: - (home / ".pi" / "agent" / "models.json").write_text(json.dumps(self.config.models_config, indent=2)) + models_config = self._build_models_config() + if models_config: + (home / ".pi" / "agent" / "models.json").write_text(json.dumps(models_config, indent=2)) env = self._env(home) cmd = [*self.config.command_parts, "--print", "--mode", "json", "--no-session"] diff --git a/responses_api_agents/pi_agent/tests/test_app.py b/responses_api_agents/pi_agent/tests/test_app.py index 39f97afb27..86c32b46cf 100644 --- a/responses_api_agents/pi_agent/tests/test_app.py +++ b/responses_api_agents/pi_agent/tests/test_app.py @@ -20,6 +20,7 @@ import yaml +from nemo_gym.config_types import ModelServerRef from nemo_gym.openai_utils import ( NeMoGymEasyInputMessage, NeMoGymFunctionCallOutput, @@ -152,6 +153,28 @@ def test_env_passthrough(self) -> None: assert "EMPTY" not in env +class TestModelServer: + def test_builds_pi_provider_config(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"): + config = agent._build_models_config() + + provider = config["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_preserves_explicit_provider_without_model_server(self) -> None: + config = {"providers": {"custom": {"baseUrl": "https://example.test"}}} + agent = _make_agent(models_config=config) + assert agent._effective_model() == agent.config.model + assert agent._build_models_config() == config + + class TestConfigYaml: def test_module_parses(self) -> None: app_path = Path(__file__).resolve().parent.parent / "app.py"