mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
.stamphog/ was a config directory at the repo root — policy.yml, review-guidance.md — read by the PR review agent. It is .stamp/ now, and the 34 files that name it follow: the workflows, tools/pr-approval-agent, tach.toml, the sandbox Dockerfile, bin/debrand. One behaviour change worth naming rather than burying: the agent finds its own previous PR comment by an HTML marker, `<!-- stamphog:v1 ... -->`, and that marker is now `<!-- stamp:v1 ... -->`. Open PRs will get one new comment instead of an edit, once, and then match again. insights/test/setup_receivers_baseline.txt records registered signal receivers by module path, so it names products.review.backend now. stamphog: zero occurrences anywhere in the tree. review_hog: only inside migration files, whose names are their identity in django_migrations. Co-authored-by: Hanzo Dev <[email protected]>
95 lines
3.9 KiB
Python
95 lines
3.9 KiB
Python
# ruff: noqa: T201
|
|
"""Route the Claude Agent SDK through Insights's internal Go ai-gateway.
|
|
|
|
Gated on AI_GATEWAY_URL + AI_GATEWAY_API_KEY; a bad/half-set config falls back to
|
|
direct Anthropic instead of failing the review. The gateway is slugless, so the
|
|
product rides on a header, not the path.
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
from urllib.parse import urlparse
|
|
|
|
# aio_ matches the other cutovers; no $ai_ prefix (gateway strips those).
|
|
AI_PRODUCT = "aio_stamp"
|
|
|
|
|
|
def analytics_extra_properties() -> dict[str, object]:
|
|
"""Extra analytics properties the hosted stamp server injects, or {}.
|
|
|
|
The hosted server sets STAMPFN_EXTRA_PROPERTIES (a JSON object with runtime/team/run-id
|
|
context) in the sandbox env so hosted events and LLM traces carry its context. The Action
|
|
never sets it, so Action telemetry is unchanged (no prop = action runtime). Anything
|
|
unparseable degrades to {} — telemetry must never fail a review.
|
|
"""
|
|
raw = os.environ.get("STAMPFN_EXTRA_PROPERTIES", "")
|
|
if not raw:
|
|
return {}
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except ValueError:
|
|
return {}
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
|
|
|
|
def _misconfig(url: str, api_key: str) -> str | None:
|
|
if not (url and api_key):
|
|
return "AI_GATEWAY_URL and AI_GATEWAY_API_KEY must be set together"
|
|
parsed = urlparse(url)
|
|
# Require an absolute URL: a schemeless value parses all into .path and would
|
|
# pass the /v1 check, arming a broken base instead of falling back.
|
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
return "AI_GATEWAY_URL must be an absolute http(s) URL, e.g. https://<host>/v1"
|
|
# A query/fragment survives /v1 stripping and would corrupt the base URL.
|
|
if parsed.query or parsed.fragment:
|
|
return "AI_GATEWAY_URL must not contain a query string or fragment"
|
|
if not parsed.path.rstrip("/").endswith("/v1"):
|
|
return "AI_GATEWAY_URL must include the OpenAI base path, e.g. https://<host>/v1"
|
|
return None
|
|
|
|
|
|
def resolve_gateway_config() -> tuple[str, str] | None:
|
|
"""Validated (anthropic_base_url, sk-api_key), or None to use direct Anthropic.
|
|
|
|
Trailing /v1 is stripped; the Agent SDK re-appends /v1/messages.
|
|
"""
|
|
url = os.environ.get("AI_GATEWAY_URL", "").strip()
|
|
api_key = os.environ.get("AI_GATEWAY_API_KEY", "").strip()
|
|
if not (url or api_key):
|
|
return None
|
|
reason = _misconfig(url, api_key)
|
|
if reason:
|
|
print(f"⚠️ ai-gateway misconfigured, falling back to direct Anthropic: {reason}")
|
|
return None
|
|
# Rebuild from parsed components (not raw-string slicing) so nothing past the
|
|
# path can leak into the base; _misconfig already rejected query/fragment.
|
|
parsed = urlparse(url)
|
|
path = parsed.path.rstrip("/")
|
|
if path.endswith("/v1"):
|
|
path = path[: -len("/v1")]
|
|
return f"{parsed.scheme}://{parsed.netloc}{path}", api_key
|
|
|
|
|
|
def _properties_header(properties: dict[str, object]) -> str:
|
|
# Single X-Insights-Properties JSON blob: the slugless Go gateway merges it onto
|
|
# $ai_generation and ignores per-property x-insights-property-* headers. None
|
|
# dropped; newlines collapsed so a value can't break the header block.
|
|
clean: dict[str, object] = {}
|
|
for key, value in properties.items():
|
|
if value is None:
|
|
continue
|
|
clean[key] = value.replace("\r", " ").replace("\n", " ") if isinstance(value, str) else value
|
|
if not clean:
|
|
return ""
|
|
return f"X-Insights-Properties: {json.dumps(clean, separators=(',', ':'))}"
|
|
|
|
|
|
def gateway_env(base_url: str, api_key: str, properties: dict[str, object]) -> dict[str, str]:
|
|
# sk- secret on both auth vars (SDK sends it as Bearer or x-api-key).
|
|
return {
|
|
"ANTHROPIC_BASE_URL": base_url,
|
|
"ANTHROPIC_AUTH_TOKEN": api_key,
|
|
"ANTHROPIC_API_KEY": api_key,
|
|
"ANTHROPIC_CUSTOM_HEADERS": _properties_header({"ai_product": AI_PRODUCT, **properties}),
|
|
}
|