Skip to content

Commit a5174f8

Browse files
kivo360claude
andcommitted
feat(spawner): render opencode.json + omo config from credential aliases
Closes the smoke gap `opencode_config` (spec §14): without these files the agent boots with no provider surface and cannot route any task. New module `services/opencode_config_renderer.py`: - Maps each credential alias (anthropic / openrouter / openai / etc.) to its canonical `@ai-sdk/...` provider config. - Picks a default model in preference order (anthropic > openrouter > openai > google > groq > xai). - Emits a minimal `oh-my-openagent.jsonc` with a single default route. Both spawners now inject `OMOIOS_OPENCODE_CONFIG` and `OMOIOS_OMO_CONFIG` whenever an env_version with credentials is bound. Modal-specific: bootstrap.sh never runs on Modal (sandboxes start with `sleep infinity`), so the spawner now writes the two files directly via `sandbox.filesystem.write_bytes` after creation. Makes the wiring observable in `opencode_config` regardless of bootstrap timing. Smoke harness: `_allocate_via_modal` passes a sample config through `extra_env` so the file-write path is exercised even without an env_version. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9c38796 commit a5174f8

5 files changed

Lines changed: 263 additions & 10 deletions

File tree

‎.secrets.baseline‎

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎backend/omoi_os/services/daytona_spawner.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -341,15 +341,26 @@ async def spawn_for_task(
341341
if env_version and env_version.credentials:
342342
env_vars["SESSION_TOKEN"] = sandbox_session_token
343343
env_vars["BROKER_URL"] = f"{base_url}/broker"
344-
env_vars["OMOIOS_CREDENTIAL_ALIASES"] = ",".join(
345-
env_version.credentials.keys()
344+
aliases = list(env_version.credentials.keys())
345+
env_vars["OMOIOS_CREDENTIAL_ALIASES"] = ",".join(aliases)
346+
347+
# Render opencode.json + oh-my-openagent.jsonc so the agent
348+
# has a provider surface (spec §14). Bootstrap reads these
349+
# env vars and writes the files into ~/.config/opencode/.
350+
from omoi_os.services.opencode_config_renderer import (
351+
render_omo_config,
352+
render_opencode_config,
346353
)
354+
355+
env_vars["OMOIOS_OPENCODE_CONFIG"] = render_opencode_config(aliases)
356+
env_vars["OMOIOS_OMO_CONFIG"] = render_omo_config(aliases)
357+
347358
logger.info(
348359
"[SPAWNER] Broker env vars injected",
349360
extra={
350361
"task_id": task_id,
351362
"broker_url": f"{base_url}/broker",
352-
"credential_aliases": list(env_version.credentials.keys()),
363+
"credential_aliases": aliases,
353364
},
354365
)
355366

‎backend/omoi_os/services/modal_spawner.py‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,18 @@ def _build_env(
181181
if sandbox_session_token:
182182
env["SESSION_TOKEN"] = sandbox_session_token
183183
env["BROKER_URL"] = f"{base_url}/broker"
184-
env["OMOIOS_CREDENTIAL_ALIASES"] = ",".join(env_version.credentials.keys())
184+
aliases = list(env_version.credentials.keys())
185+
env["OMOIOS_CREDENTIAL_ALIASES"] = ",".join(aliases)
186+
# Render opencode.json + oh-my-openagent.jsonc — bootstrap
187+
# writes them into ~/.config/opencode/ so the agent has a
188+
# provider surface (spec §14, smoke phase opencode_config).
189+
from omoi_os.services.opencode_config_renderer import (
190+
render_omo_config,
191+
render_opencode_config,
192+
)
193+
194+
env["OMOIOS_OPENCODE_CONFIG"] = render_opencode_config(aliases)
195+
env["OMOIOS_OMO_CONFIG"] = render_omo_config(aliases)
185196
if extra_env:
186197
env.update(extra_env)
187198
return env
@@ -299,6 +310,50 @@ async def spawn_for_task(
299310
info.status = "running"
300311
info.started_at = utc_now()
301312

313+
# Render opencode.json + oh-my-openagent.jsonc directly to the
314+
# sandbox filesystem. Modal's container starts with `sleep
315+
# infinity` and never executes our bootstrap.sh, so we can't rely
316+
# on the env-var contract bootstrap reads — write the files now
317+
# while we have the sandbox handle. Skipped silently when no
318+
# provider config was rendered (e.g. bare smoke sandboxes).
319+
opencode_body = env.get("OMOIOS_OPENCODE_CONFIG")
320+
omo_body = env.get("OMOIOS_OMO_CONFIG")
321+
if opencode_body or omo_body:
322+
try:
323+
# Ensure the config dir exists. Modal `Image.run_commands`
324+
# bakes /root/.config/opencode at image-build time, but
325+
# mkdir -p is idempotent and cheap.
326+
await _run_sync(
327+
lambda: sandbox.exec("mkdir", "-p", "/root/.config/opencode")
328+
)
329+
if opencode_body:
330+
await _run_sync(
331+
lambda: sandbox.filesystem.write_bytes(
332+
opencode_body.encode("utf-8"),
333+
"/root/.config/opencode/opencode.json",
334+
)
335+
)
336+
if omo_body:
337+
await _run_sync(
338+
lambda: sandbox.filesystem.write_bytes(
339+
omo_body.encode("utf-8"),
340+
"/root/.config/opencode/oh-my-openagent.jsonc",
341+
)
342+
)
343+
logger.info(
344+
"[MODAL_SPAWNER] opencode config files written",
345+
extra={
346+
"sandbox_id": sandbox_id,
347+
"wrote_opencode_json": bool(opencode_body),
348+
"wrote_omo_jsonc": bool(omo_body),
349+
},
350+
)
351+
except Exception as exc: # noqa: BLE001 — best-effort
352+
logger.warning(
353+
"[MODAL_SPAWNER] opencode config write failed",
354+
extra={"sandbox_id": sandbox_id, "error": str(exc)},
355+
)
356+
302357
# Tunnel URLs for any exposed_ports. Modal returns these
303358
# synchronously after `create` completes.
304359
if exposed_ports:
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""Render OpenCode + OmO config from an EnvironmentVersion's credentials.
2+
3+
Closes the smoke-test gap `opencode_config`: bootstrap.sh expects
4+
`OMOIOS_OPENCODE_CONFIG` and `OMOIOS_OMO_CONFIG` env vars to contain the
5+
JSON / JSONC bodies it should write into `~/.config/opencode/`. Without
6+
them the agent boots with no provider surface and cannot route tasks.
7+
8+
Spec §14: OmO reads `opencode.json` for provider/model wiring and
9+
`oh-my-openagent.jsonc` for layered agent/category routing. We render
10+
both from the credential alias map so each environment version
11+
auto-derives a usable agent runtime — no per-tenant config templates.
12+
13+
Design choices:
14+
15+
- Provider npm package names mirror OpenCode's conventions (`@ai-sdk/...`).
16+
- API keys reference `{env:UPPERCASE_ALIAS_API_KEY}`. The bootstrap also
17+
writes `auth.json` from the broker; OpenCode prefers `auth.json`
18+
when both are set, so the env-var indirection is just a polite
19+
fallback for callers running OpenCode directly.
20+
- Default model: prefer `anthropic` (best Claude 4 family for codegen),
21+
then fall back to whatever else the environment has.
22+
- oh-my-openagent.jsonc gets a single `default` route; tenants who want
23+
multiple categories can override at the env-version level.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import json
29+
from typing import Any, Optional
30+
31+
32+
# Mapping: credential alias → OpenCode provider definition fragment.
33+
# When an alias is recognised here, we plug in the canonical npm
34+
# package + key reference. Unknown aliases get a passthrough entry so
35+
# the agent can still see the credential exists; OpenCode's provider
36+
# resolver tolerates extra keys.
37+
_KNOWN_PROVIDERS: dict[str, dict[str, Any]] = {
38+
"anthropic": {
39+
"npm": "@ai-sdk/anthropic",
40+
"name": "Anthropic",
41+
"options": {"apiKey": "{env:ANTHROPIC_API_KEY}"},
42+
},
43+
"openai": {
44+
"npm": "@ai-sdk/openai",
45+
"name": "OpenAI",
46+
"options": {"apiKey": "{env:OPENAI_API_KEY}"},
47+
},
48+
"openrouter": {
49+
"npm": "@openrouter/ai-sdk-provider",
50+
"name": "OpenRouter",
51+
"options": {"apiKey": "{env:OPENROUTER_API_KEY}"},
52+
},
53+
"google": {
54+
"npm": "@ai-sdk/google",
55+
"name": "Google",
56+
"options": {"apiKey": "{env:GOOGLE_API_KEY}"},
57+
},
58+
"groq": {
59+
"npm": "@ai-sdk/groq",
60+
"name": "Groq",
61+
"options": {"apiKey": "{env:GROQ_API_KEY}"},
62+
},
63+
"xai": {
64+
"npm": "@ai-sdk/xai",
65+
"name": "xAI",
66+
"options": {"apiKey": "{env:XAI_API_KEY}"},
67+
},
68+
}
69+
70+
# Default model per provider — used when picking the top-level `model`
71+
# field. Tenants can override by editing the environment version.
72+
_DEFAULT_MODELS: dict[str, str] = {
73+
"anthropic": "anthropic/claude-sonnet-4-5",
74+
"openai": "openai/gpt-5",
75+
"openrouter": "openrouter/anthropic/claude-sonnet-4-5",
76+
"google": "google/gemini-2-pro",
77+
"groq": "groq/llama-3.3-70b-versatile",
78+
"xai": "xai/grok-4",
79+
}
80+
81+
# Preference order when no alias is "anthropic" but multiple are set.
82+
_PREFERENCE_ORDER = ["anthropic", "openrouter", "openai", "google", "groq", "xai"]
83+
84+
85+
def render_opencode_config(
86+
credential_aliases: list[str],
87+
*,
88+
default_model: Optional[str] = None,
89+
) -> str:
90+
"""Build an opencode.json body for a sandbox.
91+
92+
Args:
93+
credential_aliases: alias names from `env_version.credentials.keys()`.
94+
These are the broker aliases the agent can fetch at runtime.
95+
default_model: override the auto-selected default model. Useful
96+
for tenants pinning a specific model per environment.
97+
98+
Returns:
99+
JSON string ready to be written to `~/.config/opencode/opencode.json`
100+
by `sandbox/bootstrap.sh` when `OMOIOS_OPENCODE_CONFIG` is set.
101+
"""
102+
# Build the provider block. Known aliases get the canonical
103+
# definition; unknown aliases get a stub so the agent at least sees
104+
# the alias exists — OpenCode treats extra providers as opt-in.
105+
providers: dict[str, dict[str, Any]] = {}
106+
for alias in credential_aliases:
107+
if alias in _KNOWN_PROVIDERS:
108+
providers[alias] = _KNOWN_PROVIDERS[alias]
109+
else:
110+
providers[alias] = {
111+
"name": alias,
112+
"options": {
113+
"apiKey": f"{{env:{alias.upper()}_API_KEY}}",
114+
},
115+
}
116+
117+
# Pick a default model. Caller override > anthropic > preference order.
118+
chosen_model = default_model
119+
if chosen_model is None:
120+
for pref in _PREFERENCE_ORDER:
121+
if pref in credential_aliases and pref in _DEFAULT_MODELS:
122+
chosen_model = _DEFAULT_MODELS[pref]
123+
break
124+
if chosen_model is None and credential_aliases:
125+
# Fallback: take the first alias and assume it follows
126+
# `provider/model` form. Better than no model at all.
127+
first = credential_aliases[0]
128+
chosen_model = f"{first}/default"
129+
130+
config: dict[str, Any] = {
131+
"$schema": "https://opencode.ai/config.json",
132+
}
133+
if providers:
134+
config["provider"] = providers
135+
if chosen_model:
136+
config["model"] = chosen_model
137+
138+
return json.dumps(config, indent=2)
139+
140+
141+
def render_omo_config(
142+
credential_aliases: list[str],
143+
*,
144+
default_model: Optional[str] = None,
145+
) -> str:
146+
"""Build a minimal oh-my-openagent.jsonc body.
147+
148+
Just defines a single `default` route. Tenants who want
149+
category-aware routing should override the environment version's
150+
config templates rather than relying on the auto-generated default.
151+
"""
152+
chosen_model = default_model
153+
if chosen_model is None:
154+
for pref in _PREFERENCE_ORDER:
155+
if pref in credential_aliases and pref in _DEFAULT_MODELS:
156+
chosen_model = _DEFAULT_MODELS[pref]
157+
break
158+
159+
if chosen_model is None:
160+
# Absent any creds we still emit a valid (if useless) doc — the
161+
# smoke phase tolerates this; OmO will pick the first available
162+
# provider at runtime.
163+
chosen_model = "anthropic/claude-sonnet-4-5"
164+
165+
body = {
166+
"$schema": "https://oh-my-openagent.dev/config.json",
167+
"default": {"model": chosen_model},
168+
}
169+
# Wrap in JSONC-friendly preamble so editors highlight the file
170+
# correctly. JSON parsers tolerate the leading comment block.
171+
return (
172+
"// oh-my-openagent.jsonc — auto-generated from environment "
173+
"credentials\n" + json.dumps(body, indent=2)
174+
)

‎scripts/smoke_agent_platform.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -594,11 +594,24 @@ async def _allocate_via_modal(ctx: Context) -> PhaseResult:
594594
return PhaseResult(
595595
"daytona_allocation", Verdict.FAIL,
596596
detail=f"factory returned {type(provider).__name__}; expected ModalProvider")
597+
# Render a sample opencode.json + oh-my-openagent.jsonc so the
598+
# spawner's file-write step fires and `opencode_config` can
599+
# verify the wiring without needing a real env_version (which
600+
# the smoke can't easily synthesize against prod).
601+
from omoi_os.services.opencode_config_renderer import (
602+
render_omo_config,
603+
render_opencode_config,
604+
)
605+
smoke_aliases = ["anthropic"]
597606
result = await provider.spawn_for_task(
598607
task_id=f"smoke-{uuid.uuid4().hex[:8]}",
599608
agent_id="smoke-agent",
600609
phase_id="PHASE_SMOKE",
601-
env_vars={"SMOKE_TEST": "1"},
610+
env_vars={
611+
"SMOKE_TEST": "1",
612+
"OMOIOS_OPENCODE_CONFIG": render_opencode_config(smoke_aliases),
613+
"OMOIOS_OMO_CONFIG": render_omo_config(smoke_aliases),
614+
},
602615
runtime="claude",
603616
execution_mode="implementation",
604617
)

0 commit comments

Comments
 (0)