Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/modal-infra/src/images/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
CODE_SERVER_VERSION = "4.109.5"

# Cache buster - change this to force Modal image rebuild
# v42: code-server pin + GPT-5.4 codex allowlist
CACHE_BUSTER = "v42-code-server-gpt54"
# v43: copy=True on sandbox dir, version stamp, auth consolidation
CACHE_BUSTER = "v43"

# Base image with all development tools
base_image = (
Expand Down Expand Up @@ -100,7 +100,7 @@
# Install OpenCode CLI and plugin for custom tools
# CACHE_BUSTER is embedded in a no-op echo so Modal invalidates this layer on bump.
.run_commands(
f"echo 'cache: {CACHE_BUSTER}' > /dev/null",
f"echo 'cache: {CACHE_BUSTER}' > /tmp/.cache-buster",
"npm install -g opencode-ai@latest",
"opencode --version || echo 'OpenCode installed'",
# Install @opencode-ai/plugin globally for custom tools
Expand Down Expand Up @@ -143,10 +143,13 @@
}
)
# Add sandbox code to the image (includes plugin at /app/sandbox/inspect-plugin.js)
# copy=True bakes files into the image layer so run_commands can follow
.add_local_dir(
str(SANDBOX_DIR),
remote_path="/app/sandbox",
copy=True,
)
.run_commands(f"echo 'sandbox_version={CACHE_BUSTER}' > /app/sandbox/.version")
)

# Image variant optimized for Node.js/TypeScript projects
Expand Down
47 changes: 33 additions & 14 deletions packages/modal-infra/src/sandbox/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,12 @@ def __init__(self):
session_id=session_id,
)

self.log.info("session_config.loaded", keys=list(self.session_config.keys()))

@property
def base_branch(self) -> str:
"""The branch to clone/fetch — defaults to 'main'."""
return self.session_config.get("branch", "main")
return self.session_config.get("branch") or "main"

def _build_repo_url(self, authenticated: bool = True) -> str:
"""Build the HTTPS URL for the repository, optionally with clone credentials."""
Expand Down Expand Up @@ -291,25 +293,42 @@ def _install_tools(self, workdir: Path) -> None:
package_json.write_text('{"name": "opencode-tools", "type": "module"}')

def _setup_openai_oauth(self) -> None:
"""Write OpenCode auth.json for ChatGPT OAuth if refresh token is configured."""
refresh_token = os.environ.get("OPENAI_OAUTH_REFRESH_TOKEN")
if not refresh_token:
return
"""Write OpenCode auth.json for configured LLM providers.

try:
auth_dir = Path.home() / ".local" / "share" / "opencode"
auth_dir.mkdir(parents=True, exist_ok=True)
Builds a unified auth.json from environment variables:
- OPENAI_OAUTH_REFRESH_TOKEN → OpenAI OAuth entry
- OPENCODE_ZEN_API_KEY → OpenCode Zen (pay-as-you-go) entry
- OPENCODE_GO_API_KEY → OpenCode Go (subscription) entry
"""
auth_data: dict[str, dict] = {}

openai_entry = {
refresh_token = os.environ.get("OPENAI_OAUTH_REFRESH_TOKEN")
if refresh_token:
entry: dict = {
"type": "oauth",
"refresh": "managed-by-control-plane",
"access": "",
"expires": 0,
}

account_id = os.environ.get("OPENAI_OAUTH_ACCOUNT_ID")
if account_id:
openai_entry["accountId"] = account_id
entry["accountId"] = account_id
auth_data["openai"] = entry

zen_key = os.environ.get("OPENCODE_ZEN_API_KEY")
Copy link
Copy Markdown
Owner

@ColeMurray ColeMurray Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should already be addressed via the RepoSecrets functionality within the system, where the control-plane will inject the env vars into the sandbox and OpenCode will pick them up.

the OAI ones exist as a special case due to the need of refreshing the subscription.

https://github.com/anomalyco/opencode/blob/1b86c27fb8d5b96e09cab48ad33ce08f381652cb/packages/opencode/src/provider/provider.ts#L952-L962

set with env: ["OPENCODE_API_KEY"]

if zen_key:
auth_data["opencode"] = {"type": "api", "key": zen_key}

go_key = os.environ.get("OPENCODE_GO_API_KEY")
if go_key:
auth_data["opencode-go"] = {"type": "api", "key": go_key}

if not auth_data:
return

try:
auth_dir = Path.home() / ".local" / "share" / "opencode"
auth_dir.mkdir(parents=True, exist_ok=True)

auth_file = auth_dir / "auth.json"
tmp_file = auth_dir / ".auth.json.tmp"
Expand All @@ -318,12 +337,12 @@ def _setup_openai_oauth(self) -> None:
# atomically rename so the target is never world-readable.
fd = os.open(str(tmp_file), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.write(fd, json.dumps({"openai": openai_entry}).encode())
os.write(fd, json.dumps(auth_data).encode())
finally:
os.close(fd)
tmp_file.replace(auth_file)

self.log.info("openai_oauth.setup")
self.log.info("openai_oauth.setup", providers=list(auth_data.keys()))
except Exception as e:
self.log.warn("openai_oauth.setup_error", exc=e)

Expand Down Expand Up @@ -647,7 +666,7 @@ async def monitor_processes(self) -> None:

async def _report_fatal_error(self, message: str) -> None:
"""Report a fatal error to the control plane."""
self.log.error("supervisor.fatal", message=message)
self.log.error("supervisor.fatal", error_message=message)

if not self.control_plane_url:
return
Expand Down
58 changes: 56 additions & 2 deletions packages/modal-infra/tests/test_openai_oauth_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,71 @@ def test_includes_account_id_when_present(self, tmp_path):
data = json.loads(_auth_file(tmp_path).read_text())
assert data["openai"]["accountId"] == "acct_xyz"

def test_skips_when_no_refresh_token(self, tmp_path, monkeypatch):
def test_skips_when_no_providers_configured(self, tmp_path, monkeypatch):
sup = _make_supervisor()

# Explicitly remove the key so it is absent regardless of test ordering
# Explicitly remove all provider keys so none are present
monkeypatch.delenv("OPENAI_OAUTH_REFRESH_TOKEN", raising=False)
monkeypatch.delenv("OPENCODE_ZEN_API_KEY", raising=False)
monkeypatch.delenv("OPENCODE_GO_API_KEY", raising=False)

with patch("pathlib.Path.home", return_value=tmp_path):
sup._setup_openai_oauth()

assert not _auth_file(tmp_path).exists()

def test_writes_zen_only(self, tmp_path, monkeypatch):
sup = _make_supervisor()

monkeypatch.delenv("OPENAI_OAUTH_REFRESH_TOKEN", raising=False)
monkeypatch.delenv("OPENCODE_GO_API_KEY", raising=False)

with (
patch.dict("os.environ", {"OPENCODE_ZEN_API_KEY": "zen_key_123"}, clear=False),
patch("pathlib.Path.home", return_value=tmp_path),
):
sup._setup_openai_oauth()

data = json.loads(_auth_file(tmp_path).read_text())
assert data == {"opencode": {"type": "api", "key": "zen_key_123"}}

def test_writes_go_only(self, tmp_path, monkeypatch):
sup = _make_supervisor()

monkeypatch.delenv("OPENAI_OAUTH_REFRESH_TOKEN", raising=False)
monkeypatch.delenv("OPENCODE_ZEN_API_KEY", raising=False)

with (
patch.dict("os.environ", {"OPENCODE_GO_API_KEY": "go_key_456"}, clear=False),
patch("pathlib.Path.home", return_value=tmp_path),
):
sup._setup_openai_oauth()

data = json.loads(_auth_file(tmp_path).read_text())
assert data == {"opencode-go": {"type": "api", "key": "go_key_456"}}

def test_writes_all_providers_combined(self, tmp_path, monkeypatch):
sup = _make_supervisor()

with (
patch.dict(
"os.environ",
{
"OPENAI_OAUTH_REFRESH_TOKEN": "rt_abc",
"OPENCODE_ZEN_API_KEY": "zen_key",
"OPENCODE_GO_API_KEY": "go_key",
},
clear=False,
),
patch("pathlib.Path.home", return_value=tmp_path),
):
sup._setup_openai_oauth()

data = json.loads(_auth_file(tmp_path).read_text())
assert "openai" in data
assert data["opencode"] == {"type": "api", "key": "zen_key"}
assert data["opencode-go"] == {"type": "api", "key": "go_key"}

def test_sets_secure_permissions(self, tmp_path):
sup = _make_supervisor()

Expand Down
13 changes: 12 additions & 1 deletion packages/shared/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export const VALID_MODELS = [
"opencode/kimi-k2.5",
"opencode/minimax-m2.5",
"opencode/glm-5",
"opencode-go/kimi-k2.5",
"opencode-go/minimax-m2.5",
"opencode-go/glm-5",
] as const;

export type ValidModel = (typeof VALID_MODELS)[number];
Expand Down Expand Up @@ -130,11 +133,19 @@ export const MODEL_OPTIONS: ModelCategory[] = [
{ id: "opencode/glm-5", name: "GLM 5", description: "Z.ai 744B MoE" },
],
},
{
category: "OpenCode Go",
models: [
{ id: "opencode-go/kimi-k2.5", name: "Kimi K2.5", description: "Moonshot AI (Go)" },
{ id: "opencode-go/minimax-m2.5", name: "MiniMax M2.5", description: "MiniMax (Go)" },
{ id: "opencode-go/glm-5", name: "GLM 5", description: "Z.ai 744B MoE (Go)" },
],
},
];

/**
* Models enabled by default when no preferences are stored.
* Excludes zen models which must be opted into via settings.
* Excludes Zen and Go models which must be opted into via settings.
*/
export const DEFAULT_ENABLED_MODELS: ValidModel[] = [
"anthropic/claude-haiku-4-5",
Expand Down
Loading