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
12 changes: 12 additions & 0 deletions app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <catalog>.<schema> your app SP can write to.
Expand Down
46 changes: 46 additions & 0 deletions setup_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions setup_hermes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
35 changes: 11 additions & 24 deletions setup_opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down Expand Up @@ -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}")
Expand All @@ -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)")
Expand Down Expand Up @@ -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
}
},
Expand All @@ -178,13 +179,6 @@
"output": 8192
}
},
"databricks-gemini-2-5-pro": {
"name": "Gemini 2.5 Pro (Databricks)",
"limit": {
"context": 1000000,
"output": 8192
}
},
}
},
"databricks-openai": {
Expand Down Expand Up @@ -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
}
},
Expand All @@ -258,13 +252,6 @@
"output": 8192
}
},
"databricks-gemini-2-5-pro": {
"name": "Gemini 2.5 Pro (Databricks)",
"limit": {
"context": 1000000,
"output": 8192
}
},
}
}
},
Expand Down
8 changes: 7 additions & 1 deletion setup_pi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
19 changes: 14 additions & 5 deletions tests/test_gateway_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
15 changes: 11 additions & 4 deletions utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand Down