diff --git a/packages/modal-infra/src/images/base.py b/packages/modal-infra/src/images/base.py index bfa2c7cc..639c6281 100644 --- a/packages/modal-infra/src/images/base.py +++ b/packages/modal-infra/src/images/base.py @@ -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 = ( @@ -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 @@ -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 diff --git a/packages/modal-infra/src/sandbox/entrypoint.py b/packages/modal-infra/src/sandbox/entrypoint.py index 30915383..cab18414 100644 --- a/packages/modal-infra/src/sandbox/entrypoint.py +++ b/packages/modal-infra/src/sandbox/entrypoint.py @@ -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.""" @@ -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") + 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" @@ -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) @@ -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 diff --git a/packages/modal-infra/tests/test_openai_oauth_setup.py b/packages/modal-infra/tests/test_openai_oauth_setup.py index 6d39c211..60e0fbe9 100644 --- a/packages/modal-infra/tests/test_openai_oauth_setup.py +++ b/packages/modal-infra/tests/test_openai_oauth_setup.py @@ -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() diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index 97868543..983ed5c2 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -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]; @@ -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",