Skip to content
Merged
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
18 changes: 15 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,12 @@ help: ## Show this help

# ── Workflows ────────────────────────────────────────

deploy: create-app grant-omnigent-host sync deploy-app ## Full deploy (create app, grant Omnigent host IAM, sync, deploy)
deploy: create-app configure-gateway-resources grant-omnigent-host sync deploy-app ## Full deploy (create app, attach Gateway models, grant Omnigent IAM, sync, deploy)
@echo ""
@echo "Deployment complete! App URL:"
@databricks apps get $(APP_NAME) --profile $(PROFILE) --output json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('url','(pending)'))"

redeploy: grant-omnigent-host sync deploy-app ## Redeploy: (re)grant Omnigent host IAM + sync + deploy
redeploy: configure-gateway-resources grant-omnigent-host sync deploy-app ## Redeploy: refresh Gateway models + Omnigent IAM, sync, deploy
@echo ""
@echo "Redeployment complete!"

Expand Down Expand Up @@ -206,7 +206,7 @@ configure-git-credential: ## Add a Git credential to the app SP for private repo
&& echo " Git credential added to SP $$sp_id for '$(APP_NAME)'." \
|| echo " git-credentials create failed (credential may already exist for $(GIT_PROVIDER))."

deploy-git: ## Deploy the app from the configured Git ref ($(GIT_REF_TYPE)=$(GIT_REF))
deploy-git: configure-gateway-resources ## Attach Gateway models, then deploy from configured Git ref ($(GIT_REF_TYPE)=$(GIT_REF))
@echo "==> Deploying '$(APP_NAME)' from Git $(GIT_REF_TYPE)='$(GIT_REF)'..."
@databricks apps deploy $(APP_NAME) --profile $(PROFILE) --no-wait \
--json '{"git_source":{"$(GIT_REF_TYPE)":"$(GIT_REF)"}}'
Expand All @@ -217,6 +217,18 @@ redeploy-git: grant-omnigent-host deploy-git ## (Re)grant Omnigent host IAM, the
@echo ""
@echo "Git redeployment complete!"

# ── AI Gateway model resources ─────────────────────

AUTO_CONFIGURE_GATEWAY ?= true

configure-gateway-resources: ## Grant app SP UC EXECUTE (v3) + CAN_QUERY (legacy) on READY chat models
@if [ "$(AUTO_CONFIGURE_GATEWAY)" = "true" ]; then \
echo "==> Configuring AI Gateway model resources for '$(APP_NAME)'..."; \
python3 configure_gateway_resources.py --profile $(PROFILE) --app $(APP_NAME); \
else \
echo "==> AI Gateway resource configuration disabled (AUTO_CONFIGURE_GATEWAY=$(AUTO_CONFIGURE_GATEWAY))."; \
fi

# ── Omnigent host resources ─────────────────────────
# The generic app.yaml resolves workspace-specific Omnigent values at runtime
# via valueFrom resource references. This target attaches those resources to
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,10 +261,10 @@ Beyond boot registration, the host can be driven at runtime:
1. Click [**Use this template**](https://github.com/datasciencemonkey/coding-agents-databricks-apps/generate) to create your own repo
2. Go to **Databricks → Apps → Create App**
3. Choose **Custom App** and connect your new repo
4. Deploy
5. Open the app paste a short-lived PAT when prompted on first terminal session
4. From your checkout, run `make configure-gateway-resources PROFILE=<profile> APP_NAME=<app>`
5. Deploy, then open the app and paste a short-lived PAT if prompted

That's it. No secrets to configure, no pre-deployment setup.
> **Required:** a plain Apps UI deployment cannot grant Unity AI Gateway v3 model-service privileges. The configuration target grants the app SP `USE CATALOG`, `USE SCHEMA`, and least-privilege `EXECUTE`; without it Pi and Claude report `404 '<model>' does not exist`. The repository's normal `make deploy`, `make redeploy`, and `make deploy-git` workflows run it automatically.

[→ Full deployment guide](docs/deployment.md) — environment variables, gateway config, and advanced options.

Expand Down
60 changes: 45 additions & 15 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
import app_state
import enterprise_config
from claude_otel import apply_claude_otel_env
from utils import add_1m_context_suffix, ensure_https, get_gateway_host
from utils import ensure_https
from token_helper import write_databricks_token_wrapper
from pat_rotator import PATRotator
from sp_token_broker import (
Expand Down Expand Up @@ -229,7 +229,21 @@ def _update_step(step_id, **kwargs):

def _get_setup_state_snapshot():
with setup_lock:
return copy.deepcopy(setup_state)
snapshot = copy.deepcopy(setup_state)
warnings = []
agents_need_gateway = any(
os.environ.get(name, "true").strip().lower() not in ("false", "0", "no")
for name in ("ENABLE_CLAUDE", "ENABLE_PI")
)
if agents_need_gateway and not os.environ.get("CODA_GATEWAY_MODEL_CATALOG", "").strip():
warnings.append(
"AI Gateway model access is not provisioned for this app. Deploy with "
"the repository Make targets, or run `make configure-gateway-resources "
"PROFILE=<profile> APP_NAME=<app>` before redeploying. App resources "
"alone do not grant Unity model-service EXECUTE."
)
snapshot["warnings"] = warnings
return snapshot


# Single-user security: only the token owner can access the terminal
Expand Down Expand Up @@ -301,9 +315,23 @@ def _run_step(step_id, command):
if result.returncode == 0:
if step_id == "dbcli" and os.environ.get(BROKER_URL_ENV):
_ensure_broker_cli_wrapper()
if step_id in {"claude", "pi"}:
safe_markers = (
"Using workspace AI Gateway:",
"Discovered ",
"Pi configured:",
"Claude configured:",
)
summary = [
line.strip()
for line in result.stdout.splitlines()
if line.strip().startswith(safe_markers)
]
logger.info("%s setup: %s", step_id, " | ".join(summary) or "complete")
_update_step(step_id, status="complete", completed_at=time.time())
else:
err = result.stderr.strip() or result.stdout.strip() or "Unknown error"
logger.error("%s setup failed (rc=%s): %s", step_id, result.returncode, err[-500:])
_update_step(step_id, status="error", completed_at=time.time(), error=err[:500])
except subprocess.TimeoutExpired:
_update_step(step_id, status="error", completed_at=time.time(), error="Timed out after 300s")
Expand Down Expand Up @@ -784,13 +812,9 @@ def _configure_all_cli_auth(token):
claude_dir = os.path.join(home, ".claude")
os.makedirs(claude_dir, exist_ok=True)

gateway_host = get_gateway_host()
databricks_host = ensure_https(os.environ.get("DATABRICKS_HOST", "").rstrip("/"))

if gateway_host:
anthropic_base_url = f"{gateway_host}/anthropic"
else:
anthropic_base_url = f"{databricks_host}/serving-endpoints/anthropic"
from gateway_models import pi_base_urls
anthropic_base_url = pi_base_urls(databricks_host)["claude"]

# Read-merge-write to preserve env vars from other setup scripts (e.g. setup_mlflow.py)
settings_path = os.path.join(claude_dir, "settings.json")
Expand All @@ -801,20 +825,19 @@ def _configure_all_cli_auth(token):
settings = {}

settings.setdefault("env", {})
settings["env"]["ANTHROPIC_MODEL"] = os.environ.get("ANTHROPIC_MODEL", "databricks-claude-opus-4-8")
settings["env"].pop("ANTHROPIC_MODEL", None)
settings["env"]["ANTHROPIC_BASE_URL"] = anthropic_base_url
settings["env"]["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
settings["env"]["CLAUDE_CODE_USE_GATEWAY"] = "1"
# Respect the spec-C apiKeyHelper: when it owns model auth (setup_claude.py
# installed the "apiKeyHelper" key), don't re-pin a static token here — the
# helper fetches its own per-TTL. Otherwise write the PAT as before.
if settings.get("apiKeyHelper"):
settings["env"].pop("ANTHROPIC_AUTH_TOKEN", None)
else:
settings["env"]["ANTHROPIC_AUTH_TOKEN"] = token
# [1m] suffix requests the 1M context window via the gateway (opus/sonnet
# only; see utils.add_1m_context_suffix). Keep in sync with setup_claude.py.
settings["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"] = add_1m_context_suffix("databricks-claude-opus-4-8")
settings["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"] = add_1m_context_suffix("databricks-claude-sonnet-4-6")
settings["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "databricks-claude-haiku-4-5"
# Model inventory and family defaults are owned by setup_claude.py. Token
# rotation must not replace them with stale static model ids.
settings["env"]["ANTHROPIC_CUSTOM_HEADERS"] = "x-databricks-use-coding-agent-mode: true"
settings["env"]["CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"] = "1"
if apply_claude_otel_env(settings, token, databricks_host):
Expand All @@ -834,7 +857,7 @@ def _configure_all_cli_auth(token):
# They are idempotent: detect CLI already installed, just write config files
env = {**os.environ, "DATABRICKS_TOKEN": token,
"CODA_VENV_PYTHON": _venv_python()}
for script in ["setup_pi.py", "setup_codex.py", "setup_opencode.py", "setup_gemini.py", "setup_hermes.py"]:
for script in ["setup_claude.py", "setup_pi.py", "setup_codex.py", "setup_opencode.py", "setup_gemini.py", "setup_hermes.py"]:
try:
result = subprocess.run(
[_venv_python(), script],
Expand Down Expand Up @@ -2438,6 +2461,13 @@ def initialize_app(local_dev=False):
"""One-time init: detect owner, start cleanup thread."""
global app_owner, _omnigent_sp_creds, _sp_token_broker_server

if not os.environ.get("CODA_GATEWAY_MODEL_CATALOG", "").strip():
logger.warning(
"AI Gateway model access is not provisioned: deploy with the repository "
"Make targets or run `make configure-gateway-resources PROFILE=<profile> "
"APP_NAME=<app>`; Apps YAML alone cannot grant model-service EXECUTE"
)

global _sp_token_broker_atexit_registered
if not _sp_token_broker_atexit_registered:
atexit.register(_shutdown_sp_token_broker)
Expand Down
13 changes: 7 additions & 6 deletions app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ command:
env:
- name: HOME
value: /app/python/source_code
- name: ANTHROPIC_MODEL
value: system.ai.claude-sonnet-5
# Pi routes to the same /anthropic gateway route as Claude Code; PI_MODEL is
# the serving-endpoint name it addresses (validated against in-geo models).
- name: PI_MODEL
value: system.ai.claude-sonnet-5
# The deploy-time Gateway configurator writes the exact CAN_QUERY inventory
# into this secret resource. Runtime unions it with live discovery because an
# app SP can invoke attached endpoints without UC model-services browse access.
- name: CODA_GATEWAY_MODEL_CATALOG
valueFrom: gateway-model-catalog
# Do not pin ANTHROPIC_MODEL or PI_MODEL here: a static value collapses
# Claude's picker and can select a model the app SP cannot query.
- name: GEMINI_MODEL
value: system.ai.gemini-3-flash
# The setup script discovers system.ai models served via the Responses API
Expand Down
153 changes: 153 additions & 0 deletions configure_gateway_resources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Attach available Foundation Model endpoints to a CoDA Databricks App.

This is the deployment-time half of CoDA's ucode-compatible Gateway setup.
The deploying identity discovers READY chat endpoints; the Apps resource API
then grants the app service principal only CAN_QUERY. Existing resources are
preserved, embeddings are excluded, and repeated runs are idempotent.
"""
from __future__ import annotations

import argparse
import hashlib
import json
from collections.abc import Iterable

from databricks.sdk import WorkspaceClient
from databricks.sdk.errors import ResourceAlreadyExists
from databricks.sdk.service.apps import App, AppResource
from databricks.sdk.service.catalog import PermissionsChange, Privilege

_RESOURCE_PREFIX = "coda-gw-"
_CATALOG_RESOURCE = "gateway-model-catalog"
_CATALOG_SCOPE = "coda-gateway"


def gateway_resource_name(endpoint_name: str) -> str:
"""Return a deterministic Apps resource name within the 30-char limit."""
slug = endpoint_name.removeprefix("databricks-").replace("_", "-")
digest = hashlib.sha256(endpoint_name.encode()).hexdigest()[:8]
return f"{_RESOURCE_PREFIX}{slug[:13]}-{digest}"


def discover_gateway_endpoint_names(endpoints: Iterable[object]) -> list[str]:
"""Return READY Foundation Model chat endpoint names."""
names: set[str] = set()
for endpoint in endpoints:
data = endpoint.as_dict() if hasattr(endpoint, "as_dict") else endpoint
if not isinstance(data, dict) or data.get("task") != "llm/v1/chat":
continue
state = data.get("state") or {}
if state.get("ready") != "READY":
continue
entities = ((data.get("config") or {}).get("served_entities") or [])
if not any(
isinstance(entity, dict)
and str(entity.get("entity_name") or "").startswith("system.ai.")
for entity in entities
):
continue
name = data.get("name")
if isinstance(name, str) and name.startswith("databricks-"):
names.add(name)
return sorted(names)


def endpoint_model_id(endpoint_name: str) -> str:
"""Map a Foundation Model endpoint name to its routable request model ID."""
return "system.ai." + endpoint_name.removeprefix("databricks-")


def merge_resources(
current: list[dict], endpoint_names: list[str], *, catalog_secret_key: str
) -> list[AppResource]:
"""Preserve non-Gateway resources and replace our managed endpoint set."""
by_name = {
resource["name"]: resource
for resource in current
if isinstance(resource, dict)
and isinstance(resource.get("name"), str)
and not resource["name"].startswith(_RESOURCE_PREFIX)
}
for endpoint_name in endpoint_names:
resource_name = gateway_resource_name(endpoint_name)
by_name[resource_name] = {
"name": resource_name,
"description": "ucode-compatible AI Gateway model access",
"serving_endpoint": {
"name": endpoint_name,
"permission": "CAN_QUERY",
},
}
by_name[_CATALOG_RESOURCE] = {
"name": _CATALOG_RESOURCE,
"description": "Gateway models granted to this CoDA app",
"secret": {
"scope": _CATALOG_SCOPE,
"key": catalog_secret_key,
"permission": "READ",
},
}
return [AppResource.from_dict(resource) for resource in by_name.values()]


def configure(profile: str, app_name: str) -> list[str]:
w = WorkspaceClient(profile=profile)
app = w.apps.get(app_name)
endpoint_names = discover_gateway_endpoint_names(w.serving_endpoints.list())
if not endpoint_names:
raise RuntimeError("no READY Foundation Model chat endpoints were discovered")
catalog_secret_key = f"{app_name}-model-catalog"
try:
w.secrets.create_scope(_CATALOG_SCOPE)
except ResourceAlreadyExists:
pass
model_ids = [endpoint_model_id(name) for name in endpoint_names]
principal = app.service_principal_client_id
if not principal:
raise RuntimeError(f"app {app_name} has no service principal client ID")
w.grants.update(
"catalog",
"system",
changes=[PermissionsChange(principal=principal, add=[Privilege.USE_CATALOG])],
)
w.grants.update(
"schema",
"system.ai",
changes=[PermissionsChange(principal=principal, add=[Privilege.USE_SCHEMA])],
)
for model_id in model_ids:
w.grants.update(
"model_service",
model_id,
changes=[PermissionsChange(principal=principal, add=[Privilege.EXECUTE])],
)
w.secrets.put_secret(
_CATALOG_SCOPE,
catalog_secret_key,
string_value=json.dumps(model_ids, separators=(",", ":")),
)
current = [resource.as_dict() for resource in (app.resources or [])]
resources = merge_resources(
current, endpoint_names, catalog_secret_key=catalog_secret_key
)
w.apps.create_update(app_name, "resources", app=App(name=app_name, resources=resources))
return endpoint_names


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--profile", required=True)
parser.add_argument("--app", required=True)
args = parser.parse_args()
endpoints = configure(args.profile, args.app)
print(
f"Configured {len(endpoints)} READY chat models for {args.app} "
"(UC EXECUTE + legacy CAN_QUERY):"
)
for endpoint in endpoints:
print(f" {endpoint}")


if __name__ == "__main__":
main()
6 changes: 3 additions & 3 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The app pulls the code directly from Git. To update later, just re-deploy — it

> **Note:** On first startup, the app automatically removes the template's `.git` history and reinitializes a clean, remote-free git repo. This prevents accidental pushes back to the template repo from the in-browser terminal.

> **Optional (Highly Recommended):** If you use [Databricks AI Gateway](https://docs.databricks.com/aws/en/ai-gateway/), also add `DATABRICKS_GATEWAY_HOST` as a secret or environment variable. Otherwise the app falls back to direct model serving endpoints.
> **Required AI Gateway setup:** deploy through this repository's `make deploy`, `make redeploy`, or `make deploy-git` targets. They grant the app SP `USE CATALOG` on `system`, `USE SCHEMA` on `system.ai`, and `EXECUTE` on each selected Unity AI Gateway v3 model service; they also attach legacy `CAN_QUERY` resources and publish the app-visible model inventory. A plain Apps UI deploy cannot express the v3 UC grants and leaves Pi/Claude returning a masked `404 '<model>' does not exist`. Set `AUTO_CONFIGURE_GATEWAY=false` only when equivalent UC grants are managed externally.

## Alternative: Deploy with CLI

Expand Down Expand Up @@ -75,8 +75,8 @@ make configure-git APP_NAME=coda-04 PROFILE=<profile>
gh auth token | make configure-git-credential APP_NAME=coda-04 PROFILE=<profile>

# 3. Deploy from a ref (branch | tag | commit)
make deploy-git APP_NAME=coda-04 PROFILE=<profile> GIT_REF=main
make redeploy-git APP_NAME=coda-04 PROFILE=<profile> GIT_REF=main # + (re)grant Omnigent IAM
make deploy-git APP_NAME=coda-04 PROFILE=<profile> GIT_REF=main # refresh Gateway CAN_QUERY resources
make redeploy-git APP_NAME=coda-04 PROFILE=<profile> GIT_REF=main # + (re)grant Omnigent IAM
```

Overridable vars: `GIT_URL`, `GIT_PROVIDER` (`gitHub`, `gitLab`, …), `GIT_REF`,
Expand Down
Loading