diff --git a/app.yaml b/app.yaml index 35c6f64..7ce9dce 100644 --- a/app.yaml +++ b/app.yaml @@ -30,6 +30,18 @@ env: value: "true" - name: CLAUDE_CODE_DISABLE_AUTO_MEMORY value: 0 + # Firewalled network: install Claude Code from the npm registry instead of the + # claude.ai installer host. Same CLI (@anthropic-ai/claude-code); use when npm + # is allowlisted but claude.ai / its install CDN is blocked. Default (unset) + # keeps the curl claude.ai path. + - name: CLAUDE_INSTALL_METHOD + value: npm + # This workspace has no AI Gateway — force the legacy serving-endpoints path. + # Empty value skips the gateway probe and routes model calls to + # {DATABRICKS_HOST}/serving-endpoints. Do NOT set a non-empty value here unless + # the gateway actually exists (it's trusted with no reachability probe). + - name: DATABRICKS_GATEWAY_HOST + value: "" # Set CLAUDE_CODE_OTEL_ENABLED=true to send Claude Code OTEL signals to Unity # Catalog. Off by default; requires CLAUDE_CODE_OTEL_CATALOG_SCHEMA pointing at # a . your app SP can write to. diff --git a/setup_claude.py b/setup_claude.py index cf0a1c8..a03ef73 100644 --- a/setup_claude.py +++ b/setup_claude.py @@ -262,6 +262,52 @@ def main(): if os.environ.get("CODA_SKIP_CLAUDE_INSTALL", "").lower() == "true": print("Claude Code CLI install skipped") +elif os.environ.get("CLAUDE_INSTALL_METHOD", "").strip().lower() == "npm": + # npm install path for firewalled networks where the claude.ai installer + # host (or the CDN its install.sh pulls from) is blocked but the npm + # registry is reachable. @anthropic-ai/claude-code is the same CLI as the + # curl installer produces. Mirrors setup_pi.py's hardened pattern: + # version cooldown (get_npm_version), NPM_REGISTRY override (npm_env), + # retries, and loud stderr. Unlike setup_pi.py we do NOT pass + # --ignore-scripts: Claude Code's postinstall (node install.cjs) is what + # places the native binary, so skipping scripts yields no working `claude`. + from utils import get_npm_version + from enterprise_config import npm_env + + CLAUDE_PACKAGE = "@anthropic-ai/claude-code" + npm_prefix = str(home / ".local") + claude_version = get_npm_version(CLAUDE_PACKAGE) + claude_pkg = ( + f"{CLAUDE_PACKAGE}@{claude_version}" if claude_version + else f"{CLAUDE_PACKAGE}@latest" + ) + + MAX_RETRIES = 3 + RETRY_DELAY = 5 # seconds + for attempt in range(1, MAX_RETRIES + 1): + print(f"Installing {claude_pkg} via npm (attempt {attempt}/{MAX_RETRIES})...") + result = subprocess.run( + ["npm", "install", "-g", f"--prefix={npm_prefix}", claude_pkg], + capture_output=True, text=True, + env={**os.environ, "HOME": str(home), **npm_env()}, + ) + if result.returncode == 0 and claude_bin.exists(): + print(f"Claude Code CLI installed to {claude_bin}") + break + else: + stderr = result.stderr.strip() + print(f"Claude Code npm install failed (attempt {attempt}/{MAX_RETRIES}, rc={result.returncode})") + if stderr: + print(f" stderr: {stderr[:500]}") + if result.stdout.strip(): + print(f" stdout: {result.stdout.strip()[:500]}") + if attempt < MAX_RETRIES: + import time + print(f" Retrying in {RETRY_DELAY}s...") + time.sleep(RETRY_DELAY) + else: + print(f"ERROR: Claude Code npm install failed after {MAX_RETRIES} attempts. " + f"Run manually: npm install -g --prefix=$HOME/.local {CLAUDE_PACKAGE}") else: # Honour CLAUDE_INSTALLER_URL for enterprise environments where claude.ai is # firewalled — defaults to the public installer when unset. The URL is diff --git a/setup_hermes.py b/setup_hermes.py index 8270e78..6ee5ce1 100644 --- a/setup_hermes.py +++ b/setup_hermes.py @@ -130,14 +130,12 @@ def _run(cmd, **kwargs): model_catalog = [ "databricks-claude-opus-4-8", - "databricks-claude-opus-4-6", "databricks-claude-sonnet-4-6", "databricks-claude-haiku-4-5", "databricks-gpt-5-3-codex", "databricks-gpt-5-1-codex-max", "databricks-gemini-2-5-flash", "databricks-gemini-2-5-pro", - "databricks-gemini-2-5-pro", ] lines = [] diff --git a/setup_opencode.py b/setup_opencode.py index 929ffd5..2bfc088 100644 --- a/setup_opencode.py +++ b/setup_opencode.py @@ -15,6 +15,7 @@ from pathlib import Path from utils import ensure_https, get_gateway_host, get_npm_version +from enterprise_config import npm_env # Opt-out: allow operators to disable OpenCode bundling without removing the file. if os.environ.get("ENABLE_OPENCODE", "true").strip().lower() in ("false", "0", "no"): @@ -55,7 +56,7 @@ result = subprocess.run( ["npm", "install", "-g", f"--prefix={npm_prefix}", oc_pkg], capture_output=True, text=True, - env={**os.environ, "HOME": str(home)} + env={**os.environ, "HOME": str(home), **npm_env()} ) if result.returncode == 0 and opencode_bin.exists(): print(f"OpenCode CLI installed to {opencode_bin}") @@ -82,7 +83,7 @@ result = subprocess.run( ["npm", "install", "-g", f"--prefix={npm_prefix}", sdk_pkg], capture_output=True, text=True, - env={**os.environ, "HOME": str(home)} + env={**os.environ, "HOME": str(home), **npm_env()} ) if result.returncode == 0: print(f"@ai-sdk/openai@{sdk_version or 'latest'} installed (Responses API support)") @@ -150,17 +151,17 @@ "apiKey": "{env:DATABRICKS_TOKEN}" }, "models": { - "databricks-claude-opus-4-6": { - "name": "Claude Opus 4.6 (Databricks)", + "databricks-claude-opus-4-8": { + "name": "Claude Opus 4.8 (Databricks)", "limit": { - "context": 200000, + "context": 1000000, "output": 16384 } }, "databricks-claude-sonnet-4-6": { "name": "Claude Sonnet 4.6 (Databricks)", "limit": { - "context": 200000, + "context": 1000000, "output": 8192 } }, @@ -178,13 +179,6 @@ "output": 8192 } }, - "databricks-gemini-2-5-pro": { - "name": "Gemini 2.5 Pro (Databricks)", - "limit": { - "context": 1000000, - "output": 8192 - } - }, } }, "databricks-openai": { @@ -230,17 +224,17 @@ "apiKey": "{env:DATABRICKS_TOKEN}" }, "models": { - "databricks-claude-opus-4-6": { - "name": "Claude Opus 4.6 (Databricks)", + "databricks-claude-opus-4-8": { + "name": "Claude Opus 4.8 (Databricks)", "limit": { - "context": 200000, + "context": 1000000, "output": 16384 } }, "databricks-claude-sonnet-4-6": { "name": "Claude Sonnet 4.6 (Databricks)", "limit": { - "context": 200000, + "context": 1000000, "output": 8192 } }, @@ -258,13 +252,6 @@ "output": 8192 } }, - "databricks-gemini-2-5-pro": { - "name": "Gemini 2.5 Pro (Databricks)", - "limit": { - "context": 1000000, - "output": 8192 - } - }, } } }, diff --git a/setup_pi.py b/setup_pi.py index 2c35606..014fdce 100644 --- a/setup_pi.py +++ b/setup_pi.py @@ -149,7 +149,13 @@ "apiKey": auth_token, "authHeader": True, "compat": {"supportsEagerToolInputStreaming": False}, - "models": [{"id": active_model}], + # contextWindow is explicit: Pi defaults a custom provider's model to 131072 + # (128K) when absent. The Databricks FMAPI-served Claude model has a ~1.05M + # total-token context window (per Databricks Foundation Model APIs supported- + # models docs), so the 128K default badly under-uses it. Set the real window. + # NB: the FMAPI *rate* limits are separate (e.g. ~200k input tokens/minute on + # the default tier) — that's throughput, not context, and is not fixed here. + "models": [{"id": active_model, "contextWindow": 1000000}], } models_path.write_text(json.dumps(config, indent=2)) diff --git a/tests/test_gateway_discovery.py b/tests/test_gateway_discovery.py index e8667a5..c4b5c67 100644 --- a/tests/test_gateway_discovery.py +++ b/tests/test_gateway_discovery.py @@ -115,16 +115,25 @@ def test_empty_when_both_blank(self): @mock.patch("utils._probe_gateway", return_value=True) @mock.patch.dict(os.environ, {"DATABRICKS_GATEWAY_HOST": " ", "DATABRICKS_WORKSPACE_ID": "12345"}) - def test_whitespace_only_gateway_falls_through(self, mock_probe): - """Whitespace-only DATABRICKS_GATEWAY_HOST falls through to workspace ID.""" + def test_empty_gateway_host_forces_off(self, mock_probe): + """An explicitly-set-but-empty DATABRICKS_GATEWAY_HOST forces the gateway + OFF (returns "") — it does NOT fall through to workspace-ID derivation. + This is the operator kill-switch: setting it empty on an Azure host must + route model calls to serving-endpoints, not to a derived ai-gateway URL. + """ os.environ.pop("_GATEWAY_RESOLVED", None) - assert self._get_fn()() == "https://12345.ai-gateway.cloud.databricks.com" + assert self._get_fn()() == "" @mock.patch("utils._probe_gateway", return_value=True) - @mock.patch.dict(os.environ, {"DATABRICKS_GATEWAY_HOST": "", "DATABRICKS_WORKSPACE_ID": " 99999 "}) + @mock.patch.dict(os.environ, {"DATABRICKS_WORKSPACE_ID": " 99999 "}) def test_workspace_id_whitespace_stripped(self, mock_probe): - """Leading/trailing whitespace in workspace ID is stripped.""" + """Leading/trailing whitespace in workspace ID is stripped (tier 2). + + DATABRICKS_GATEWAY_HOST is left unset so derivation runs — setting it + empty would now force the gateway off before tier 2 is reached. + """ os.environ.pop("_GATEWAY_RESOLVED", None) + os.environ.pop("DATABRICKS_GATEWAY_HOST", None) assert self._get_fn()() == "https://99999.ai-gateway.cloud.databricks.com" diff --git a/utils.py b/utils.py index 63d4526..1f3ee7e 100644 --- a/utils.py +++ b/utils.py @@ -262,10 +262,17 @@ def get_gateway_host() -> str: if resolved is not None: return resolved - # Tier 1: explicit override (trusted, no probe) - explicit = os.environ.get("DATABRICKS_GATEWAY_HOST", "").strip().rstrip("/") - if explicit: - return ensure_https(explicit) + # Tier 1: explicit override. Presence of the env var is authoritative: + # - set to a URL -> trust it, no probe. + # - set but empty -> operator is explicitly disabling the gateway; return + # "" so callers use serving-endpoints. Do NOT fall through to tier 2, + # which would otherwise DERIVE a gateway URL from an Azure host + # (adb-{id}.…azuredatabricks.net) and probe it — reintroducing a gateway + # the operator meant to turn off. + # - unset entirely -> fall through to tier 2 auto-derivation. + if "DATABRICKS_GATEWAY_HOST" in os.environ: + explicit = os.environ["DATABRICKS_GATEWAY_HOST"].strip().rstrip("/") + return ensure_https(explicit) if explicit else "" # Tier 2: auto-construct from workspace ID and probe for reachability host = os.environ.get("DATABRICKS_HOST", "")