Skip to content

Commit a401997

Browse files
authored
Merge pull request #1 from robomotic/openrouter
implement support for openrouter. Closes #3
2 parents ccb2adc + d5c06ff commit a401997

9 files changed

Lines changed: 3820 additions & 18 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Copy this file to `.env` and set one or both API keys.
2+
OPENAI_API_KEY=
3+
OPENROUTER_API_KEY=

README.md

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,17 +81,58 @@ Robot models are detected using a heuristic. See the section below on how to con
8181

8282
## Prompting / Scene Generation Examples
8383

84-
You can conveniently generate a MuJoCo scene from a natural-language prompt (requires an OpenAI API key):
84+
You can conveniently generate a MuJoCo scene from a natural-language prompt using OpenAI or OpenRouter.
85+
86+
You can either export your keys in the shell or place them in a `.env` file in the working directory (or any parent directory). The CLI loads `.env` automatically on startup:
87+
88+
```bash
89+
cp .env.example .env
90+
# Then edit `.env` and set OPENAI_API_KEY=... and/or OPENROUTER_API_KEY=...
91+
```
92+
93+
Exported environment variables take precedence over values in `.env`.
94+
95+
### Using OpenAI
8596

8697
```bash
8798
# Set this to your API key
8899
export OPENAI_API_KEY=...
89100
# Generate a scene from a prompt string
90-
mjprompt
101+
mjprompt "A detailed kitchen."
102+
```
103+
104+
### Using OpenRouter
105+
106+
OpenRouter allows you to use various models beyond OpenAI, including many free models.
107+
108+
```bash
109+
# Set this to your OpenRouter API key
110+
export OPENROUTER_API_KEY=...
91111

92-
# Edit the generated scene.
93-
mjedit examples/prompt/scene_coffee_shop.xml
112+
# Generate a scene using a specific model via OpenRouter
113+
mjprompt --provider openrouter --model google/gemini-flash-1.5:free "A cozy living room with a sofa and coffee table."
114+
115+
# Verified working example with GPT-5 Codex via OpenRouter
116+
mjprompt --provider openrouter --model openai/gpt-5-codex "Output ONLY a valid MuJoCo XML document with root tag <mujoco> for a red cube on a gray floor."
117+
```
118+
119+
If both `OPENAI_API_KEY` and `OPENROUTER_API_KEY` are set, the editor defaults to OpenAI unless `--provider openrouter` is specified. If only `OPENROUTER_API_KEY` is set, it will automatically fallback to OpenRouter.
120+
121+
Tested OpenRouter models include `openrouter/free`, `openai/gpt-5-codex`, `qwen/qwen3.6-plus:free`, `nvidia/nemotron-3-super-120b-a12b:free`, `openai/gpt-oss-120b:free`, and `openai/gpt-oss-20b:free`. Availability may still vary with your OpenRouter account and privacy settings.
122+
123+
#### Troubleshooting OpenRouter
124+
125+
- If a model returns `404 Not Found`, check your OpenRouter privacy/data policy settings and confirm that the model is enabled for your account.
126+
- Some free models may occasionally return non-MuJoCo XML or malformed output; retrying with a stricter prompt or another tested model such as `openai/gpt-5-codex` usually helps.
127+
- If generation fails entirely, verify that `OPENROUTER_API_KEY` is set correctly in your shell or `.env` file.
128+
129+
To quickly validate your setup, this tested command runs the OpenRouter integration checks:
130+
131+
```bash
132+
set -a && . ./.env && set +a
133+
PYTHONPATH=src uv run pytest -q examples/test_openrouter.py -s
94134
```
135+
95136
Loading a generated scene might not work out of the box in all cases. Generated scenes can have inconsistencies in geometry, but can be easily edited.
96137

97138
### Examples

examples/test_openrouter.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import os
2+
import pytest
3+
import re
4+
import logging
5+
from mujoco_scene_editor.env import load_env_file
6+
from mujoco_scene_editor.utils.llm import OpenRouterClient, PromptBuilderWrapper
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
def test_load_env_file_reads_api_keys(tmp_path, monkeypatch):
12+
env_path = tmp_path / ".env"
13+
env_path.write_text(
14+
'OPENROUTER_API_KEY="router-from-dotenv"\nexport OPENAI_API_KEY=openai-from-dotenv\n',
15+
encoding="utf-8",
16+
)
17+
18+
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
19+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
20+
monkeypatch.chdir(tmp_path)
21+
22+
loaded_path = load_env_file()
23+
24+
assert loaded_path == env_path
25+
assert os.environ["OPENROUTER_API_KEY"] == "router-from-dotenv"
26+
assert os.environ["OPENAI_API_KEY"] == "openai-from-dotenv"
27+
28+
29+
@pytest.mark.skipif(not os.environ.get("OPENROUTER_API_KEY"), reason="OPENROUTER_API_KEY not set")
30+
def test_openrouter_query():
31+
client = OpenRouterClient()
32+
builder = PromptBuilderWrapper()
33+
builder.add_instruction(
34+
"Output ONLY a valid MuJoCo 3.3.7 XML document with root tag <mujoco> for a scene containing a red cube."
35+
)
36+
37+
# Use a verified model by default, but allow overrides for local testing.
38+
model = os.environ.get("OPENROUTER_TEST_MODEL", "openai/gpt-5-codex")
39+
response = client.query(builder, model=model)
40+
41+
content = response.choices[0].message.content
42+
assert "<mujoco" in content, f"Model {model} failed to generate a MuJoCo root tag: {content}"
43+
assert "</mujoco>" in content, f"Model {model} failed to close the MuJoCo root tag: {content}"
44+
45+
# Check for color/rgba (more lenient)
46+
assert any(x in content.lower() for x in ["rgba", "color", "red"]), f"Model {model} did not include color information: {content}"
47+
48+
@pytest.mark.skipif(not os.environ.get("OPENROUTER_API_KEY"), reason="OPENROUTER_API_KEY not set")
49+
def test_common_models():
50+
# Test free models as requested by the user
51+
models = [
52+
"qwen/qwen3.6-plus:free",
53+
"nvidia/nemotron-3-super-120b-a12b:free",
54+
"openai/gpt-oss-120b:free",
55+
"openai/gpt-oss-20b:free"
56+
]
57+
client = OpenRouterClient()
58+
59+
results = {}
60+
for model in models:
61+
logger.info(f"Testing model: {model}")
62+
builder = PromptBuilderWrapper()
63+
builder.add_instruction(f"Generate a minimal valid MuJoCo XML for a sphere using {model}. Output ONLY the XML.")
64+
try:
65+
response = client.query(builder, model=model)
66+
content = response.choices[0].message.content
67+
valid = any(tag in content for tag in ["<mujoco", "<model"])
68+
results[model] = "Passed" if valid else f"Failed (Invalid XML: {content[:50]}...)"
69+
except Exception as e:
70+
results[model] = f"Failed (Error: {e})"
71+
logger.error(f"Model {model} failed: {e}")
72+
73+
# Log results
74+
print("\nOpenRouter Model Test Results:")
75+
for model, status in results.items():
76+
print(f"{model}: {status}")
77+
78+
# We only fail if ALL models fail (some models might be down or restricted)
79+
assert any(s == "Passed" for s in results.values()), "All test models failed."

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dependencies = [
3737
"qpsolvers[quadprog]>=0.1.13",
3838
"openai>=2.15.0",
3939
"trimesh>=4.11.1",
40+
"pytest",
4041
"xacrodoc>=2.0.0",
4142
]
4243

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""MuJoCo Scene Editor package."""
2+
3+
from mujoco_scene_editor.env import load_env_file
4+
5+
load_env_file()

src/mujoco_scene_editor/cli/editor_cli.py

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from click_prompt import filepath_option
1717
from click_prompt import filepath_argument
1818
from click_prompt import input_text_argument
19+
from click_prompt import choice_option
1920

2021
from robits.sim.blueprints import blueprints_from_json
2122
from robits.sim.blueprints import Blueprint
@@ -35,10 +36,14 @@
3536

3637
from mujoco_scene_editor.constants import DEFAULT_ASSET_DIR
3738
from mujoco_scene_editor.constants import DEFAULT_EXPORT_TARGET
39+
from mujoco_scene_editor.env import load_env_file
40+
41+
from mujoco_scene_editor.utils.llm import OpenRouterClient, PromptBuilderWrapper
3842

3943
logger = logging.getLogger(__name__)
4044

4145
setup_cli(logging.INFO)
46+
load_env_file()
4247

4348

4449
def get_scene_editor(blueprints: Optional[List[Blueprint]] = None) -> SceneEditor:
@@ -148,36 +153,83 @@ def list_assets(root: str):
148153
click.echo(f"- {m.name}: {m.path}")
149154

150155

151-
def validate_has_openai_key(func):
156+
def validate_has_llm_key(func):
152157
@wraps(func)
153158
def _wrapper(*args, **kwargs):
154-
if not os.environ.get("OPENAI_API_KEY"):
155-
logger.error("Environment variable OPENAI_API_KEY is not set.")
159+
provider = kwargs.get("provider")
160+
if provider == "openrouter":
161+
if not os.environ.get("OPENROUTER_API_KEY"):
162+
logger.error("Environment variable OPENROUTER_API_KEY is not set.")
163+
raise RuntimeError(
164+
"Missing OPENROUTER_API_KEY. Set it in your shell or a `.env` file and retry."
165+
)
166+
elif provider == "openai":
167+
if not os.environ.get("OPENAI_API_KEY"):
168+
logger.error("Environment variable OPENAI_API_KEY is not set.")
169+
raise RuntimeError(
170+
"Missing OPENAI_API_KEY. Set it in your shell or a `.env` file and retry."
171+
)
172+
elif not os.environ.get("OPENAI_API_KEY") and not os.environ.get("OPENROUTER_API_KEY"):
173+
logger.error("Neither OPENAI_API_KEY nor OPENROUTER_API_KEY is set.")
156174
raise RuntimeError(
157-
"Missing OPENAI_API_KEY. Please export it with export OPENAI_API_KEY=... and retry."
175+
"Missing API key. Set OPENAI_API_KEY or OPENROUTER_API_KEY in your shell or a `.env` file and retry."
158176
)
159177
return func(*args, **kwargs)
160178

161179
return _wrapper
162180

163181

164-
# @validate_has_openai_key
182+
@validate_has_llm_key
165183
@cli.command()
184+
@choice_option(
185+
"--provider",
186+
type=click.Choice(["openai", "openrouter"]),
187+
default=None,
188+
help="LLM provider to use (default: openai, falls back to openrouter if OPENAI_API_KEY is missing)",
189+
)
190+
@click.option(
191+
"--model",
192+
default=None,
193+
help="LLM model to use (default: gpt-3.5-turbo for openai, google/gemini-flash-1.5-free for openrouter)",
194+
)
166195
@filepath_option(
167196
"--output-model-name",
168197
default=str(Path(DEFAULT_EXPORT_TARGET).with_name("scene_prompt.xml")),
169198
)
170199
@input_text_argument(
171-
"prompt", default="A detailed kitchen with a robot.", prompt="Describe your scene."
200+
"prompt", default="A detailed kitchen.", prompt="Describe your scene."
172201
)
173-
def prompt(output_model_name: str, prompt: str) -> None:
202+
def prompt(output_model_name: str, prompt: str, provider: Optional[str] = None, model: Optional[str] = None) -> None:
174203
"""
175-
Ask ChatGPT to generate a scene. Requires an OpenAI API key
204+
Ask an LLM to generate a scene. Supports OpenAI and OpenRouter.
176205
"""
177-
from robits.vlm.openai_vlm import PromptBuilder
178-
from robits.vlm.openai_vlm import ChatGPT
179206
import re
180207

208+
openai_key = os.environ.get("OPENAI_API_KEY")
209+
openrouter_key = os.environ.get("OPENROUTER_API_KEY")
210+
211+
if provider is None:
212+
if not openai_key and openrouter_key:
213+
provider = "openrouter"
214+
else:
215+
provider = "openai"
216+
217+
if provider == "openai":
218+
from robits.vlm.openai_vlm import PromptBuilder
219+
from robits.vlm.openai_vlm import ChatGPT
220+
llm = ChatGPT()
221+
builder = PromptBuilder()
222+
if model:
223+
# We assume ChatGPT class supports model override or we just use it as is
224+
# If robits doesn't support it, we might need a workaround.
225+
# For now, let's assume it uses default if model is None.
226+
pass
227+
else:
228+
llm = OpenRouterClient()
229+
builder = PromptBuilderWrapper()
230+
if model is None:
231+
model = "openrouter/free"
232+
181233
prefix = """
182234
Generate a MuJoCo 3.3.7 XML. Here are some guidelines:
183235
- Don't use global tags for colors or other elements
@@ -188,15 +240,15 @@ def prompt(output_model_name: str, prompt: str) -> None:
188240
- Avoid accelerometer and sensor tags
189241
Output the complete XML file. The scene is as follows:
190242
"""
191-
chatgpt = ChatGPT()
192-
193-
builder = PromptBuilder()
194243
builder.add_instruction(prefix)
195244
builder.add_instruction(prompt)
196245

197246
with Progress() as progress:
198247
progress.add_task("Querying.", total=None)
199-
response = chatgpt.query(builder)
248+
if provider == "openai":
249+
response = llm.query(builder)
250+
else:
251+
response = llm.query(builder, model=model)
200252

201253
# click.echo(response)
202254

src/mujoco_scene_editor/env.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import os
5+
from pathlib import Path
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
def find_env_file(start_dir: str | Path | None = None) -> Path | None:
11+
"""Find the nearest `.env` file from the working directory upwards."""
12+
base_dir = Path(start_dir or Path.cwd()).expanduser().resolve()
13+
for directory in (base_dir, *base_dir.parents):
14+
candidate = directory / ".env"
15+
if candidate.is_file():
16+
return candidate
17+
return None
18+
19+
20+
def _parse_env_line(line: str) -> tuple[str, str] | None:
21+
stripped = line.strip()
22+
if not stripped or stripped.startswith("#"):
23+
return None
24+
25+
if stripped.startswith("export "):
26+
stripped = stripped[len("export ") :].strip()
27+
28+
key, separator, value = stripped.partition("=")
29+
if not separator:
30+
return None
31+
32+
key = key.strip()
33+
if not key:
34+
return None
35+
36+
value = value.strip()
37+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"\"", "'"}:
38+
value = value[1:-1]
39+
40+
if " #" in value:
41+
value = value.split(" #", 1)[0].rstrip()
42+
43+
return key, value
44+
45+
46+
def load_env_file(env_path: str | Path | None = None) -> Path | None:
47+
"""Load environment variables from `.env` without overriding exported values."""
48+
path = Path(env_path).expanduser().resolve() if env_path else find_env_file()
49+
if path is None or not path.is_file():
50+
return None
51+
52+
for raw_line in path.read_text(encoding="utf-8").splitlines():
53+
parsed = _parse_env_line(raw_line)
54+
if parsed is None:
55+
continue
56+
57+
key, value = parsed
58+
os.environ.setdefault(key, value)
59+
60+
logger.debug("Loaded environment variables from %s", path)
61+
return path

0 commit comments

Comments
 (0)