Chandra Kiran commited on
Commit
4508afe
·
unverified ·
1 Parent(s): 115459a

Let ROS 2 code use its own model (CODE_LLM_MODEL)

Browse files

LLM_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.

Files changed (8) hide show
  1. .env.example +2 -1
  2. README.md +2 -1
  3. api/codegen.py +1 -1
  4. api/config.py +3 -1
  5. api/llm.py +2 -2
  6. api/main.py +5 -2
  7. api/opencode_runner.py +3 -3
  8. 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). Everyone uses the same model and key.
 
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.llm_model
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.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.llm_model: {"name": settings.llm_model}},
62
  }
63
  },
64
  "permission": {
@@ -124,7 +124,7 @@ class OpenCodeWorkspace:
124
  "--dir",
125
  str(self.ws),
126
  "--model",
127
- f"llm/{self.settings.llm_model}",
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