Spaces:
Paused
Paused
Chandra Kiran commited on
Let ROS 2 code use its own model (CODE_LLM_MODEL)
Browse filesLLM_MODEL stays the scene model; CODE_LLM_MODEL (default: the same) is what OpenCode
and the direct code backend use, through the same OpenRouter key. /health shows both.
- .env.example +2 -1
- README.md +2 -1
- api/codegen.py +1 -1
- api/config.py +3 -1
- api/llm.py +2 -2
- api/main.py +5 -2
- api/opencode_runner.py +3 -3
- tests/test_api.py +31 -0
.env.example
CHANGED
|
@@ -4,7 +4,8 @@
|
|
| 4 |
# Any OpenAI-compatible endpoint. Default: GLM-5.3 through OpenRouter (key from openrouter.ai/keys).
|
| 5 |
LLM_API_KEY=
|
| 6 |
LLM_BASE_URL=https://openrouter.ai/api/v1
|
| 7 |
-
LLM_MODEL=z-ai/glm-5.3
|
|
|
|
| 8 |
LLM_TIMEOUT=180
|
| 9 |
|
| 10 |
# --- Storage (defaults shown)
|
|
|
|
| 4 |
# Any OpenAI-compatible endpoint. Default: GLM-5.3 through OpenRouter (key from openrouter.ai/keys).
|
| 5 |
LLM_API_KEY=
|
| 6 |
LLM_BASE_URL=https://openrouter.ai/api/v1
|
| 7 |
+
LLM_MODEL=z-ai/glm-5.3 # scenes (/simulate/generate)
|
| 8 |
+
# CODE_LLM_MODEL=z-ai/glm-5.3 # ROS 2 code via OpenCode (default: same as LLM_MODEL); any OpenRouter model id
|
| 9 |
LLM_TIMEOUT=180
|
| 10 |
|
| 11 |
# --- Storage (defaults shown)
|
README.md
CHANGED
|
@@ -24,7 +24,8 @@ Menagerie). Generated ROS 2 code only ever runs inside the sandbox (no network,
|
|
| 24 |
|
| 25 |
The model is **GLM-5.3 through OpenRouter** by default (`z-ai/glm-5.3`, a fraction of the price of frontier
|
| 26 |
models, strong at code). Any OpenAI-compatible endpoint works: set `LLM_BASE_URL`, `LLM_MODEL` and `LLM_API_KEY`
|
| 27 |
-
(e.g. `deepseek/deepseek-v4-pro` on OpenRouter, or Z.ai's own API).
|
|
|
|
| 28 |
|
| 29 |
## How it works
|
| 30 |
|
|
|
|
| 24 |
|
| 25 |
The model is **GLM-5.3 through OpenRouter** by default (`z-ai/glm-5.3`, a fraction of the price of frontier
|
| 26 |
models, strong at code). Any OpenAI-compatible endpoint works: set `LLM_BASE_URL`, `LLM_MODEL` and `LLM_API_KEY`
|
| 27 |
+
(e.g. `deepseek/deepseek-v4-pro` on OpenRouter, or Z.ai's own API). `CODE_LLM_MODEL` sets a different
|
| 28 |
+
model for ROS 2 code (what OpenCode uses; defaults to `LLM_MODEL`). Everyone uses the same models and key.
|
| 29 |
|
| 30 |
## How it works
|
| 31 |
|
api/codegen.py
CHANGED
|
@@ -261,7 +261,7 @@ def generate_code(
|
|
| 261 |
attempts.append(CodeAttempt(dict(files), validation.to_dict(), transcript))
|
| 262 |
if validation.valid:
|
| 263 |
break
|
| 264 |
-
model = getattr(getattr(backend, "llm", None), "model", None) or settings.
|
| 265 |
return CodeResult(
|
| 266 |
valid=validation.valid,
|
| 267 |
files=files,
|
|
|
|
| 261 |
attempts.append(CodeAttempt(dict(files), validation.to_dict(), transcript))
|
| 262 |
if validation.valid:
|
| 263 |
break
|
| 264 |
+
model = getattr(getattr(backend, "llm", None), "model", None) or settings.code_llm_model
|
| 265 |
return CodeResult(
|
| 266 |
valid=validation.valid,
|
| 267 |
files=files,
|
api/config.py
CHANGED
|
@@ -20,7 +20,8 @@ class Settings:
|
|
| 20 |
# Model: one model for every tester in Stage 1, via any OpenAI-compatible endpoint (OpenRouter by default).
|
| 21 |
llm_api_key: str
|
| 22 |
llm_base_url: str
|
| 23 |
-
llm_model: str
|
|
|
|
| 24 |
llm_timeout: float
|
| 25 |
|
| 26 |
# Storage
|
|
@@ -89,6 +90,7 @@ def get_settings() -> Settings:
|
|
| 89 |
llm_api_key=_env("LLM_API_KEY", ""),
|
| 90 |
llm_base_url=_env("LLM_BASE_URL", "https://openrouter.ai/api/v1").rstrip("/"),
|
| 91 |
llm_model=_env("LLM_MODEL", "z-ai/glm-5.3"),
|
|
|
|
| 92 |
llm_timeout=float(_env("LLM_TIMEOUT", "180")),
|
| 93 |
data_dir=data_dir,
|
| 94 |
testers_file=Path(_env("ROSDIFF_TESTERS_FILE", str(REPO_ROOT / "testers.yaml"))),
|
|
|
|
| 20 |
# Model: one model for every tester in Stage 1, via any OpenAI-compatible endpoint (OpenRouter by default).
|
| 21 |
llm_api_key: str
|
| 22 |
llm_base_url: str
|
| 23 |
+
llm_model: str # scene generation (/simulate/generate)
|
| 24 |
+
code_llm_model: str # ROS 2 code generation: OpenCode's model, or the direct backend's
|
| 25 |
llm_timeout: float
|
| 26 |
|
| 27 |
# Storage
|
|
|
|
| 90 |
llm_api_key=_env("LLM_API_KEY", ""),
|
| 91 |
llm_base_url=_env("LLM_BASE_URL", "https://openrouter.ai/api/v1").rstrip("/"),
|
| 92 |
llm_model=_env("LLM_MODEL", "z-ai/glm-5.3"),
|
| 93 |
+
code_llm_model=_env("CODE_LLM_MODEL", _env("LLM_MODEL", "z-ai/glm-5.3")),
|
| 94 |
llm_timeout=float(_env("LLM_TIMEOUT", "180")),
|
| 95 |
data_dir=data_dir,
|
| 96 |
testers_file=Path(_env("ROSDIFF_TESTERS_FILE", str(REPO_ROOT / "testers.yaml"))),
|
api/llm.py
CHANGED
|
@@ -39,10 +39,10 @@ class LLM(Protocol):
|
|
| 39 |
class ChatClient:
|
| 40 |
"""POST {base_url}/chat/completions. The same key and endpoint are used for every tester."""
|
| 41 |
|
| 42 |
-
def __init__(self, settings: Settings, transport: httpx.BaseTransport | None = None):
|
| 43 |
if not settings.llm_api_key:
|
| 44 |
raise LLMError("LLM_API_KEY is not set")
|
| 45 |
-
self.model = settings.llm_model
|
| 46 |
self._client = httpx.Client(
|
| 47 |
base_url=settings.llm_base_url,
|
| 48 |
# X-Title labels the requests in the OpenRouter dashboard; other providers ignore it.
|
|
|
|
| 39 |
class ChatClient:
|
| 40 |
"""POST {base_url}/chat/completions. The same key and endpoint are used for every tester."""
|
| 41 |
|
| 42 |
+
def __init__(self, settings: Settings, transport: httpx.BaseTransport | None = None, model: str | None = None):
|
| 43 |
if not settings.llm_api_key:
|
| 44 |
raise LLMError("LLM_API_KEY is not set")
|
| 45 |
+
self.model = model or settings.llm_model
|
| 46 |
self._client = httpx.Client(
|
| 47 |
base_url=settings.llm_base_url,
|
| 48 |
# X-Title labels the requests in the OpenRouter dashboard; other providers ignore it.
|
api/main.py
CHANGED
|
@@ -49,9 +49,10 @@ class Services:
|
|
| 49 |
store: RagStore
|
| 50 |
logs: LogStore
|
| 51 |
allowlist: Allowlist
|
| 52 |
-
llm_factory: Callable[[], LLM]
|
| 53 |
backend: SimBackend | None = None # None: /simulate/run is disabled
|
| 54 |
storage: Storage | None = None
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
def default_services() -> Services:
|
|
@@ -65,6 +66,7 @@ def default_services() -> Services:
|
|
| 65 |
logs=LogStore(settings.log_db),
|
| 66 |
allowlist=Allowlist(settings.testers_file),
|
| 67 |
llm_factory=lambda: ChatClient(settings),
|
|
|
|
| 68 |
backend=make_backend(settings),
|
| 69 |
storage=R2Storage(r2) if r2 else None,
|
| 70 |
)
|
|
@@ -225,6 +227,7 @@ def create_app(services: Services | None = None) -> FastAPI:
|
|
| 225 |
return {
|
| 226 |
"status": "ok",
|
| 227 |
"model": s.settings.llm_model,
|
|
|
|
| 228 |
"llm_configured": bool(s.settings.llm_api_key),
|
| 229 |
"codegen_backend": s.settings.codegen_backend,
|
| 230 |
"opencode_found": shutil.which(s.settings.opencode_bin) is not None,
|
|
@@ -295,7 +298,7 @@ def create_app(services: Services | None = None) -> FastAPI:
|
|
| 295 |
req.request,
|
| 296 |
req.distro,
|
| 297 |
req.language,
|
| 298 |
-
DirectBackend(s.llm_factory()),
|
| 299 |
s.store,
|
| 300 |
s.settings,
|
| 301 |
req.package_name,
|
|
|
|
| 49 |
store: RagStore
|
| 50 |
logs: LogStore
|
| 51 |
allowlist: Allowlist
|
| 52 |
+
llm_factory: Callable[[], LLM] # scene generation
|
| 53 |
backend: SimBackend | None = None # None: /simulate/run is disabled
|
| 54 |
storage: Storage | None = None
|
| 55 |
+
code_llm_factory: Callable[[], LLM] | None = None # direct ROS 2 code backend; None: same as llm_factory
|
| 56 |
|
| 57 |
|
| 58 |
def default_services() -> Services:
|
|
|
|
| 66 |
logs=LogStore(settings.log_db),
|
| 67 |
allowlist=Allowlist(settings.testers_file),
|
| 68 |
llm_factory=lambda: ChatClient(settings),
|
| 69 |
+
code_llm_factory=lambda: ChatClient(settings, model=settings.code_llm_model),
|
| 70 |
backend=make_backend(settings),
|
| 71 |
storage=R2Storage(r2) if r2 else None,
|
| 72 |
)
|
|
|
|
| 227 |
return {
|
| 228 |
"status": "ok",
|
| 229 |
"model": s.settings.llm_model,
|
| 230 |
+
"code_model": s.settings.code_llm_model,
|
| 231 |
"llm_configured": bool(s.settings.llm_api_key),
|
| 232 |
"codegen_backend": s.settings.codegen_backend,
|
| 233 |
"opencode_found": shutil.which(s.settings.opencode_bin) is not None,
|
|
|
|
| 298 |
req.request,
|
| 299 |
req.distro,
|
| 300 |
req.language,
|
| 301 |
+
DirectBackend((s.code_llm_factory or s.llm_factory)()),
|
| 302 |
s.store,
|
| 303 |
s.settings,
|
| 304 |
req.package_name,
|
api/opencode_runner.py
CHANGED
|
@@ -52,13 +52,13 @@ class OpenCodeRun:
|
|
| 52 |
def opencode_config(settings: Settings) -> dict:
|
| 53 |
return {
|
| 54 |
"$schema": "https://opencode.ai/config.json",
|
| 55 |
-
"model": f"llm/{settings.
|
| 56 |
"provider": {
|
| 57 |
"llm": {
|
| 58 |
"npm": "@ai-sdk/openai-compatible",
|
| 59 |
"name": "RosDiff LLM",
|
| 60 |
"options": {"baseURL": settings.llm_base_url, "apiKey": "{env:LLM_API_KEY}"},
|
| 61 |
-
"models": {settings.
|
| 62 |
}
|
| 63 |
},
|
| 64 |
"permission": {
|
|
@@ -124,7 +124,7 @@ class OpenCodeWorkspace:
|
|
| 124 |
"--dir",
|
| 125 |
str(self.ws),
|
| 126 |
"--model",
|
| 127 |
-
f"llm/{self.settings.
|
| 128 |
"--format",
|
| 129 |
"json",
|
| 130 |
]
|
|
|
|
| 52 |
def opencode_config(settings: Settings) -> dict:
|
| 53 |
return {
|
| 54 |
"$schema": "https://opencode.ai/config.json",
|
| 55 |
+
"model": f"llm/{settings.code_llm_model}",
|
| 56 |
"provider": {
|
| 57 |
"llm": {
|
| 58 |
"npm": "@ai-sdk/openai-compatible",
|
| 59 |
"name": "RosDiff LLM",
|
| 60 |
"options": {"baseURL": settings.llm_base_url, "apiKey": "{env:LLM_API_KEY}"},
|
| 61 |
+
"models": {settings.code_llm_model: {"name": settings.code_llm_model}},
|
| 62 |
}
|
| 63 |
},
|
| 64 |
"permission": {
|
|
|
|
| 124 |
"--dir",
|
| 125 |
str(self.ws),
|
| 126 |
"--model",
|
| 127 |
+
f"llm/{self.settings.code_llm_model}",
|
| 128 |
"--format",
|
| 129 |
"json",
|
| 130 |
]
|
tests/test_api.py
CHANGED
|
@@ -217,3 +217,34 @@ def test_code_generate_rejects_bad_input(env):
|
|
| 217 |
).status_code
|
| 218 |
== 422
|
| 219 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
).status_code
|
| 218 |
== 422
|
| 219 |
)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def test_coding_model_is_separate(settings):
|
| 223 |
+
from dataclasses import replace
|
| 224 |
+
|
| 225 |
+
from api.opencode_runner import opencode_config
|
| 226 |
+
|
| 227 |
+
s = replace(settings, llm_model="z-ai/glm-5.3", code_llm_model="some/coder")
|
| 228 |
+
cfg = opencode_config(s)
|
| 229 |
+
assert cfg["model"] == "llm/some/coder" and list(cfg["provider"]["llm"]["models"]) == ["some/coder"]
|
| 230 |
+
assert cfg["provider"]["llm"]["options"]["baseURL"] == s.llm_base_url
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def test_code_direct_backend_uses_code_model(env):
|
| 234 |
+
from ros_fixtures import INTERFACES, as_file_blocks, python_package
|
| 235 |
+
|
| 236 |
+
from api.llm import ScriptedLLM
|
| 237 |
+
from api.ros_check import load_interfaces
|
| 238 |
+
|
| 239 |
+
client, key, llm, services = env
|
| 240 |
+
services.settings.data_dir.mkdir(parents=True, exist_ok=True)
|
| 241 |
+
services.settings.interfaces_file.write_text(json.dumps(INTERFACES))
|
| 242 |
+
load_interfaces.cache_clear()
|
| 243 |
+
coder = ScriptedLLM([as_file_blocks(python_package())], model="some/coder")
|
| 244 |
+
services.code_llm_factory = lambda: coder
|
| 245 |
+
r = client.post(
|
| 246 |
+
"/code/generate", headers={"X-API-Key": key}, json={"request": "Stop on LaserScan closer than 0.5 m"}
|
| 247 |
+
)
|
| 248 |
+
assert r.status_code == 200 and r.json()["model"] == "some/coder" and coder.calls and not llm.calls
|
| 249 |
+
health = client.get("/health").json()
|
| 250 |
+
assert health["model"] == services.settings.llm_model and "code_model" in health
|