Spaces:
Build error
feat(telemetry): add headroom_stack and install_mode identity fields
Browse filesAdds two orthogonal identity fields to the anonymous telemetry beacon so we
can segment usage by integration surface and deployment shape:
- headroom_stack: how Headroom is invoked (proxy, wrap_claude, wrap_codex,
adapter_ts_openai, adapter_ts_anthropic, etc.). Resolved from HEADROOM_STACK
env, HEADROOM_AGENT_TYPE fallback, or aggregated request-header counts.
- install_mode: how the proxy is deployed (wrapped / persistent / on_demand).
Detected from HEADROOM_AGENT_TYPE plus DeploymentManifest lookup.
TS SDK adapters now tag every request with X-Headroom-Stack; a FastAPI
middleware buckets the counts and surfaces them via /stats so the beacon can
report requests_by_stack for mixed-integration sessions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- headroom/cli/wrap.py +1 -0
- headroom/proxy/prometheus_metrics.py +17 -0
- headroom/proxy/server.py +14 -0
- headroom/telemetry/beacon.py +13 -0
- headroom/telemetry/context.py +108 -0
- sdk/typescript/src/adapters/anthropic.ts +5 -1
- sdk/typescript/src/adapters/gemini.ts +2 -2
- sdk/typescript/src/adapters/openai.ts +5 -1
- sdk/typescript/src/adapters/vercel-ai.ts +9 -2
- sdk/typescript/src/client.ts +8 -0
- sdk/typescript/src/types.ts +4 -0
- sql/create_proxy_telemetry_v2.sql +3 -0
- sql/upgrade_telemetry_stack_context.sql +15 -0
- tests/test_telemetry_context.py +102 -0
|
@@ -134,6 +134,7 @@ def _start_proxy(
|
|
| 134 |
# Tell the proxy which agent is being wrapped (for traffic learning output)
|
| 135 |
if agent_type != "unknown":
|
| 136 |
proxy_env["HEADROOM_AGENT_TYPE"] = agent_type
|
|
|
|
| 137 |
|
| 138 |
proc = subprocess.Popen(
|
| 139 |
cmd,
|
|
|
|
| 134 |
# Tell the proxy which agent is being wrapped (for traffic learning output)
|
| 135 |
if agent_type != "unknown":
|
| 136 |
proxy_env["HEADROOM_AGENT_TYPE"] = agent_type
|
| 137 |
+
proxy_env.setdefault("HEADROOM_STACK", f"wrap_{agent_type}")
|
| 138 |
|
| 139 |
proc = subprocess.Popen(
|
| 140 |
cmd,
|
|
@@ -70,6 +70,8 @@ class PrometheusMetrics:
|
|
| 70 |
self.requests_total = 0
|
| 71 |
self.requests_by_provider: dict[str, int] = defaultdict(int)
|
| 72 |
self.requests_by_model: dict[str, int] = defaultdict(int)
|
|
|
|
|
|
|
| 73 |
self.requests_cached = 0
|
| 74 |
self.requests_rate_limited = 0
|
| 75 |
self.requests_failed = 0
|
|
@@ -192,6 +194,21 @@ class PrometheusMetrics:
|
|
| 192 |
|
| 193 |
return total_input_tokens, total_input_cost_usd
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
async def record_request(
|
| 196 |
self,
|
| 197 |
provider: str,
|
|
|
|
| 70 |
self.requests_total = 0
|
| 71 |
self.requests_by_provider: dict[str, int] = defaultdict(int)
|
| 72 |
self.requests_by_model: dict[str, int] = defaultdict(int)
|
| 73 |
+
# Populated via X-Headroom-Stack header (TS SDK adapters, etc.)
|
| 74 |
+
self.requests_by_stack: dict[str, int] = defaultdict(int)
|
| 75 |
self.requests_cached = 0
|
| 76 |
self.requests_rate_limited = 0
|
| 77 |
self.requests_failed = 0
|
|
|
|
| 194 |
|
| 195 |
return total_input_tokens, total_input_cost_usd
|
| 196 |
|
| 197 |
+
def record_stack(self, stack: str | None) -> None:
|
| 198 |
+
"""Increment the per-stack request counter.
|
| 199 |
+
|
| 200 |
+
``stack`` is the ``X-Headroom-Stack`` header value (e.g.
|
| 201 |
+
``adapter_ts_openai``). Called once per inbound request from the
|
| 202 |
+
proxy's stack middleware; a no-op when the header is absent.
|
| 203 |
+
"""
|
| 204 |
+
|
| 205 |
+
if not stack:
|
| 206 |
+
return
|
| 207 |
+
slug = stack.strip().lower()
|
| 208 |
+
if not slug or len(slug) > 64:
|
| 209 |
+
return
|
| 210 |
+
self.requests_by_stack[slug] += 1
|
| 211 |
+
|
| 212 |
async def record_request(
|
| 213 |
self,
|
| 214 |
provider: str,
|
|
@@ -1188,6 +1188,19 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
|
|
| 1188 |
allow_headers=["*"],
|
| 1189 |
)
|
| 1190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1191 |
# Health & Metrics
|
| 1192 |
@app.get("/livez")
|
| 1193 |
async def livez():
|
|
@@ -1356,6 +1369,7 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
|
|
| 1356 |
"failed": m.requests_failed,
|
| 1357 |
"by_provider": dict(m.requests_by_provider),
|
| 1358 |
"by_model": dict(m.requests_by_model),
|
|
|
|
| 1359 |
},
|
| 1360 |
"tokens": {
|
| 1361 |
"input": m.tokens_input_total,
|
|
|
|
| 1188 |
allow_headers=["*"],
|
| 1189 |
)
|
| 1190 |
|
| 1191 |
+
# X-Headroom-Stack: SDK adapters (TS openai/anthropic/etc.) tag their
|
| 1192 |
+
# requests so telemetry can segment by integration surface.
|
| 1193 |
+
@app.middleware("http")
|
| 1194 |
+
async def _record_headroom_stack(request, call_next):
|
| 1195 |
+
if request.url.path.startswith("/v1/"):
|
| 1196 |
+
stack = request.headers.get("x-headroom-stack")
|
| 1197 |
+
if stack:
|
| 1198 |
+
try:
|
| 1199 |
+
proxy.metrics.record_stack(stack)
|
| 1200 |
+
except Exception:
|
| 1201 |
+
logger.debug("record_stack failed", exc_info=True)
|
| 1202 |
+
return await call_next(request)
|
| 1203 |
+
|
| 1204 |
# Health & Metrics
|
| 1205 |
@app.get("/livez")
|
| 1206 |
async def livez():
|
|
|
|
| 1369 |
"failed": m.requests_failed,
|
| 1370 |
"by_provider": dict(m.requests_by_provider),
|
| 1371 |
"by_model": dict(m.requests_by_model),
|
| 1372 |
+
"by_stack": dict(m.requests_by_stack),
|
| 1373 |
},
|
| 1374 |
"tokens": {
|
| 1375 |
"input": m.tokens_input_total,
|
|
@@ -19,6 +19,8 @@ import sys
|
|
| 19 |
import time
|
| 20 |
import uuid
|
| 21 |
|
|
|
|
|
|
|
| 22 |
logger = logging.getLogger(__name__)
|
| 23 |
|
| 24 |
# Supabase endpoint for anonymous aggregate telemetry.
|
|
@@ -89,6 +91,8 @@ class TelemetryBeacon:
|
|
| 89 |
self._session_id = uuid.uuid4().hex
|
| 90 |
# Stable across restarts — anonymous machine fingerprint (SHA256 of hostname)
|
| 91 |
self._instance_id = hashlib.sha256(platform.node().encode()).hexdigest()[:16]
|
|
|
|
|
|
|
| 92 |
|
| 93 |
async def start(self) -> None:
|
| 94 |
"""Start the periodic beacon. Call from proxy startup."""
|
|
@@ -177,8 +181,17 @@ class TelemetryBeacon:
|
|
| 177 |
"sdk": self._sdk,
|
| 178 |
"backend": self._backend,
|
| 179 |
"session_minutes": session_minutes,
|
|
|
|
|
|
|
| 180 |
}
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
# --- Effectiveness metrics ---
|
| 183 |
try:
|
| 184 |
tokens = stats.get("tokens", {})
|
|
|
|
| 19 |
import time
|
| 20 |
import uuid
|
| 21 |
|
| 22 |
+
from headroom.telemetry.context import detect_install_mode, detect_stack
|
| 23 |
+
|
| 24 |
logger = logging.getLogger(__name__)
|
| 25 |
|
| 26 |
# Supabase endpoint for anonymous aggregate telemetry.
|
|
|
|
| 91 |
self._session_id = uuid.uuid4().hex
|
| 92 |
# Stable across restarts — anonymous machine fingerprint (SHA256 of hostname)
|
| 93 |
self._instance_id = hashlib.sha256(platform.node().encode()).hexdigest()[:16]
|
| 94 |
+
# Deployment shape is determined once at startup (wrapped / persistent / on_demand)
|
| 95 |
+
self._install_mode = detect_install_mode(port)
|
| 96 |
|
| 97 |
async def start(self) -> None:
|
| 98 |
"""Start the periodic beacon. Call from proxy startup."""
|
|
|
|
| 181 |
"sdk": self._sdk,
|
| 182 |
"backend": self._backend,
|
| 183 |
"session_minutes": session_minutes,
|
| 184 |
+
"install_mode": self._install_mode,
|
| 185 |
+
"headroom_stack": detect_stack(stats),
|
| 186 |
}
|
| 187 |
|
| 188 |
+
try:
|
| 189 |
+
by_stack = (stats.get("requests") or {}).get("by_stack") or {}
|
| 190 |
+
if by_stack:
|
| 191 |
+
payload["requests_by_stack"] = dict(by_stack)
|
| 192 |
+
except Exception:
|
| 193 |
+
logger.debug("Beacon: failed to extract requests_by_stack", exc_info=True)
|
| 194 |
+
|
| 195 |
# --- Effectiveness metrics ---
|
| 196 |
try:
|
| 197 |
tokens = stats.get("tokens", {})
|
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deployment context detection for telemetry.
|
| 2 |
+
|
| 3 |
+
Derives two orthogonal identity fields the beacon reports:
|
| 4 |
+
|
| 5 |
+
* ``install_mode`` — how the proxy process is deployed
|
| 6 |
+
(``persistent`` / ``on_demand`` / ``wrapped`` / ``unknown``).
|
| 7 |
+
* ``headroom_stack`` — how Headroom is being invoked
|
| 8 |
+
(``proxy``, ``wrap_claude``, ``adapter_ts_openai``, ...).
|
| 9 |
+
|
| 10 |
+
Both helpers are best-effort and never raise: telemetry is fire-and-forget and
|
| 11 |
+
must not break the proxy.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
import os
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
_KNOWN_WRAP_AGENTS = frozenset(
|
| 24 |
+
{"claude", "copilot", "codex", "aider", "cursor", "openclaw"}
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _slug_from_agent_type(agent_type: str) -> str:
|
| 29 |
+
"""Return ``wrap_<agent>`` for known agents, otherwise ``unknown``."""
|
| 30 |
+
|
| 31 |
+
agent_type = agent_type.strip().lower()
|
| 32 |
+
if agent_type and agent_type in _KNOWN_WRAP_AGENTS:
|
| 33 |
+
return f"wrap_{agent_type}"
|
| 34 |
+
return "unknown"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def detect_install_mode(port: int) -> str:
|
| 38 |
+
"""Classify how the proxy is deployed.
|
| 39 |
+
|
| 40 |
+
Resolution order:
|
| 41 |
+
|
| 42 |
+
1. ``HEADROOM_AGENT_TYPE`` env var set → ``wrapped`` (spawned by ``headroom wrap``).
|
| 43 |
+
2. A ``DeploymentManifest`` on disk whose port matches ``port`` → ``persistent``.
|
| 44 |
+
3. Otherwise → ``on_demand``.
|
| 45 |
+
|
| 46 |
+
Any failure falls back to ``unknown`` so a broken install subsystem
|
| 47 |
+
doesn't silence telemetry.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
if os.environ.get("HEADROOM_AGENT_TYPE"):
|
| 52 |
+
return "wrapped"
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
from headroom.install.state import list_manifests
|
| 56 |
+
|
| 57 |
+
for manifest in list_manifests():
|
| 58 |
+
if getattr(manifest, "port", None) == port:
|
| 59 |
+
return "persistent"
|
| 60 |
+
except Exception:
|
| 61 |
+
logger.debug(
|
| 62 |
+
"Beacon: manifest lookup failed during install_mode detection",
|
| 63 |
+
exc_info=True,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
return "on_demand"
|
| 67 |
+
except Exception:
|
| 68 |
+
logger.debug("Beacon: detect_install_mode crashed", exc_info=True)
|
| 69 |
+
return "unknown"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def detect_stack(stats: dict[str, Any] | None = None) -> str:
|
| 73 |
+
"""Classify how Headroom is being invoked.
|
| 74 |
+
|
| 75 |
+
Resolution order:
|
| 76 |
+
|
| 77 |
+
1. ``HEADROOM_STACK`` env var set → use that slug verbatim.
|
| 78 |
+
2. ``HEADROOM_AGENT_TYPE`` env var set → ``wrap_<agent>``.
|
| 79 |
+
3. ``stats['requests']['by_stack']`` dict populated →
|
| 80 |
+
pick the stack with >80% of requests, else ``mixed``.
|
| 81 |
+
4. Otherwise → ``proxy``.
|
| 82 |
+
|
| 83 |
+
Any failure falls back to ``unknown``.
|
| 84 |
+
"""
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
explicit = os.environ.get("HEADROOM_STACK")
|
| 88 |
+
if explicit:
|
| 89 |
+
return explicit.strip().lower()
|
| 90 |
+
|
| 91 |
+
agent_type = os.environ.get("HEADROOM_AGENT_TYPE")
|
| 92 |
+
if agent_type:
|
| 93 |
+
return _slug_from_agent_type(agent_type)
|
| 94 |
+
|
| 95 |
+
if stats:
|
| 96 |
+
by_stack = (stats.get("requests") or {}).get("by_stack") or {}
|
| 97 |
+
if by_stack:
|
| 98 |
+
total = sum(by_stack.values())
|
| 99 |
+
if total > 0:
|
| 100 |
+
dominant, count = max(by_stack.items(), key=lambda kv: kv[1])
|
| 101 |
+
if count / total >= 0.8:
|
| 102 |
+
return dominant
|
| 103 |
+
return "mixed"
|
| 104 |
+
|
| 105 |
+
return "proxy"
|
| 106 |
+
except Exception:
|
| 107 |
+
logger.debug("Beacon: detect_stack crashed", exc_info=True)
|
| 108 |
+
return "unknown"
|
|
@@ -186,7 +186,11 @@ export function withHeadroom<T extends AnthropicLike>(
|
|
| 186 |
options.model ?? params.model ?? "claude-sonnet-4-5-20250929";
|
| 187 |
|
| 188 |
const openaiMessages = anthropicToOpenAI(messages);
|
| 189 |
-
const result = await compress(openaiMessages, {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
|
| 191 |
const anthropicMessages = result.compressed
|
| 192 |
? openAIToAnthropic(result.messages)
|
|
|
|
| 186 |
options.model ?? params.model ?? "claude-sonnet-4-5-20250929";
|
| 187 |
|
| 188 |
const openaiMessages = anthropicToOpenAI(messages);
|
| 189 |
+
const result = await compress(openaiMessages, {
|
| 190 |
+
stack: "adapter_ts_anthropic",
|
| 191 |
+
...options,
|
| 192 |
+
model,
|
| 193 |
+
});
|
| 194 |
|
| 195 |
const anthropicMessages = result.compressed
|
| 196 |
? openAIToAnthropic(result.messages)
|
|
@@ -44,7 +44,7 @@ export function withHeadroom<T extends GeminiModelLike>(
|
|
| 44 |
// compress() auto-detects Gemini format
|
| 45 |
const result = await compress(
|
| 46 |
Array.isArray(contents) ? contents : [contents],
|
| 47 |
-
{ ...options, model: modelName },
|
| 48 |
);
|
| 49 |
|
| 50 |
const newParams = Array.isArray(params)
|
|
@@ -61,7 +61,7 @@ export function withHeadroom<T extends GeminiModelLike>(
|
|
| 61 |
|
| 62 |
const result = await compress(
|
| 63 |
Array.isArray(contents) ? contents : [contents],
|
| 64 |
-
{ ...options, model: modelName },
|
| 65 |
);
|
| 66 |
|
| 67 |
const newParams = Array.isArray(params)
|
|
|
|
| 44 |
// compress() auto-detects Gemini format
|
| 45 |
const result = await compress(
|
| 46 |
Array.isArray(contents) ? contents : [contents],
|
| 47 |
+
{ stack: "adapter_ts_gemini", ...options, model: modelName },
|
| 48 |
);
|
| 49 |
|
| 50 |
const newParams = Array.isArray(params)
|
|
|
|
| 61 |
|
| 62 |
const result = await compress(
|
| 63 |
Array.isArray(contents) ? contents : [contents],
|
| 64 |
+
{ stack: "adapter_ts_gemini", ...options, model: modelName },
|
| 65 |
);
|
| 66 |
|
| 67 |
const newParams = Array.isArray(params)
|
|
@@ -42,7 +42,11 @@ export function withHeadroom<T extends OpenAILike>(
|
|
| 42 |
const messages: OpenAIMessage[] = params.messages;
|
| 43 |
const model = options.model ?? params.model ?? "gpt-4o";
|
| 44 |
|
| 45 |
-
const result = await compress(messages, {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
return originalCreate({
|
| 48 |
...params,
|
|
|
|
| 42 |
const messages: OpenAIMessage[] = params.messages;
|
| 43 |
const model = options.model ?? params.model ?? "gpt-4o";
|
| 44 |
|
| 45 |
+
const result = await compress(messages, {
|
| 46 |
+
stack: "adapter_ts_openai",
|
| 47 |
+
...options,
|
| 48 |
+
model,
|
| 49 |
+
});
|
| 50 |
|
| 51 |
return originalCreate({
|
| 52 |
...params,
|
|
@@ -54,7 +54,11 @@ export function headroomMiddleware(options: CompressOptions = {}) {
|
|
| 54 |
const openaiMessages = vercelToOpenAI(prompt);
|
| 55 |
|
| 56 |
// Compress via Headroom
|
| 57 |
-
const result = await compress(openaiMessages, {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
if (!result.compressed) return params;
|
| 60 |
|
|
@@ -75,7 +79,10 @@ export async function compressVercelMessages(
|
|
| 75 |
options: CompressOptions = {},
|
| 76 |
): Promise<CompressResult & { messages: VercelMessage[] }> {
|
| 77 |
const openaiMessages = vercelToOpenAI(messages);
|
| 78 |
-
const result = await compress(openaiMessages,
|
|
|
|
|
|
|
|
|
|
| 79 |
const vercelMessages = openAIToVercel(result.messages);
|
| 80 |
|
| 81 |
return {
|
|
|
|
| 54 |
const openaiMessages = vercelToOpenAI(prompt);
|
| 55 |
|
| 56 |
// Compress via Headroom
|
| 57 |
+
const result = await compress(openaiMessages, {
|
| 58 |
+
stack: "adapter_ts_vercel_ai",
|
| 59 |
+
...options,
|
| 60 |
+
model,
|
| 61 |
+
});
|
| 62 |
|
| 63 |
if (!result.compressed) return params;
|
| 64 |
|
|
|
|
| 79 |
options: CompressOptions = {},
|
| 80 |
): Promise<CompressResult & { messages: VercelMessage[] }> {
|
| 81 |
const openaiMessages = vercelToOpenAI(messages);
|
| 82 |
+
const result = await compress(openaiMessages, {
|
| 83 |
+
stack: "adapter_ts_vercel_ai",
|
| 84 |
+
...options,
|
| 85 |
+
});
|
| 86 |
const vercelMessages = openAIToVercel(result.messages);
|
| 87 |
|
| 88 |
return {
|
|
@@ -201,6 +201,7 @@ export class HeadroomClient implements HeadroomClientInterface {
|
|
| 201 |
private fallback: boolean;
|
| 202 |
private retries: number;
|
| 203 |
private config: HeadroomConfig | undefined;
|
|
|
|
| 204 |
|
| 205 |
/** @internal */ providerApiKey: string | undefined;
|
| 206 |
|
|
@@ -221,6 +222,7 @@ export class HeadroomClient implements HeadroomClientInterface {
|
|
| 221 |
this.retries = options.retries ?? DEFAULT_RETRIES;
|
| 222 |
this.providerApiKey = options.providerApiKey;
|
| 223 |
this.config = options.config;
|
|
|
|
| 224 |
|
| 225 |
this.chat = { completions: new ChatCompletions(this) };
|
| 226 |
this.messages = new Messages(this);
|
|
@@ -513,6 +515,9 @@ export class HeadroomClient implements HeadroomClientInterface {
|
|
| 513 |
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
| 514 |
}
|
| 515 |
}
|
|
|
|
|
|
|
|
|
|
| 516 |
|
| 517 |
let response: Response;
|
| 518 |
try {
|
|
@@ -558,6 +563,9 @@ export class HeadroomClient implements HeadroomClientInterface {
|
|
| 558 |
if (this.apiKey) {
|
| 559 |
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
| 560 |
}
|
|
|
|
|
|
|
|
|
|
| 561 |
|
| 562 |
let response: Response;
|
| 563 |
try {
|
|
|
|
| 201 |
private fallback: boolean;
|
| 202 |
private retries: number;
|
| 203 |
private config: HeadroomConfig | undefined;
|
| 204 |
+
private stack: string | undefined;
|
| 205 |
|
| 206 |
/** @internal */ providerApiKey: string | undefined;
|
| 207 |
|
|
|
|
| 222 |
this.retries = options.retries ?? DEFAULT_RETRIES;
|
| 223 |
this.providerApiKey = options.providerApiKey;
|
| 224 |
this.config = options.config;
|
| 225 |
+
this.stack = options.stack;
|
| 226 |
|
| 227 |
this.chat = { completions: new ChatCompletions(this) };
|
| 228 |
this.messages = new Messages(this);
|
|
|
|
| 515 |
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
| 516 |
}
|
| 517 |
}
|
| 518 |
+
if (this.stack && !headers["X-Headroom-Stack"]) {
|
| 519 |
+
headers["X-Headroom-Stack"] = this.stack;
|
| 520 |
+
}
|
| 521 |
|
| 522 |
let response: Response;
|
| 523 |
try {
|
|
|
|
| 563 |
if (this.apiKey) {
|
| 564 |
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
| 565 |
}
|
| 566 |
+
if (this.stack && !headers["X-Headroom-Stack"]) {
|
| 567 |
+
headers["X-Headroom-Stack"] = this.stack;
|
| 568 |
+
}
|
| 569 |
|
| 570 |
let response: Response;
|
| 571 |
try {
|
|
@@ -67,6 +67,8 @@ export interface CompressOptions {
|
|
| 67 |
tokenBudget?: number;
|
| 68 |
/** Compression hooks for pre/post processing. */
|
| 69 |
hooks?: CompressionHooks;
|
|
|
|
|
|
|
| 70 |
}
|
| 71 |
|
| 72 |
export interface CompressResult {
|
|
@@ -89,6 +91,8 @@ export interface HeadroomClientOptions {
|
|
| 89 |
timeout?: number;
|
| 90 |
fallback?: boolean;
|
| 91 |
retries?: number;
|
|
|
|
|
|
|
| 92 |
}
|
| 93 |
|
| 94 |
export interface HeadroomClientInterface {
|
|
|
|
| 67 |
tokenBudget?: number;
|
| 68 |
/** Compression hooks for pre/post processing. */
|
| 69 |
hooks?: CompressionHooks;
|
| 70 |
+
/** Integration slug sent as X-Headroom-Stack (e.g. "adapter_ts_openai"). */
|
| 71 |
+
stack?: string;
|
| 72 |
}
|
| 73 |
|
| 74 |
export interface CompressResult {
|
|
|
|
| 91 |
timeout?: number;
|
| 92 |
fallback?: boolean;
|
| 93 |
retries?: number;
|
| 94 |
+
/** Integration slug sent as X-Headroom-Stack on every request. */
|
| 95 |
+
stack?: string;
|
| 96 |
}
|
| 97 |
|
| 98 |
export interface HeadroomClientInterface {
|
|
@@ -15,6 +15,9 @@ CREATE TABLE IF NOT EXISTS proxy_telemetry_v2 (
|
|
| 15 |
sdk text,
|
| 16 |
backend text,
|
| 17 |
session_minutes integer,
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-- Effectiveness metrics
|
| 20 |
tokens_saved bigint,
|
|
|
|
| 15 |
sdk text,
|
| 16 |
backend text,
|
| 17 |
session_minutes integer,
|
| 18 |
+
headroom_stack text,
|
| 19 |
+
install_mode text,
|
| 20 |
+
requests_by_stack jsonb,
|
| 21 |
|
| 22 |
-- Effectiveness metrics
|
| 23 |
tokens_saved bigint,
|
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Add deployment-context columns to proxy_telemetry_v2
|
| 2 |
+
-- Run in Supabase SQL Editor.
|
| 3 |
+
--
|
| 4 |
+
-- headroom_stack: how Headroom is being invoked — e.g. "proxy",
|
| 5 |
+
-- "wrap_claude", "adapter_ts_openai", "mixed", "unknown".
|
| 6 |
+
-- install_mode: how the proxy process is deployed — one of
|
| 7 |
+
-- "wrapped", "persistent", "on_demand", "unknown".
|
| 8 |
+
-- requests_by_stack: JSONB dict {stack_slug: count} for sessions that see
|
| 9 |
+
-- multiple integration surfaces (e.g. a persistent proxy
|
| 10 |
+
-- serving both wrap_claude and TS adapter callers).
|
| 11 |
+
|
| 12 |
+
ALTER TABLE proxy_telemetry_v2
|
| 13 |
+
ADD COLUMN IF NOT EXISTS headroom_stack text,
|
| 14 |
+
ADD COLUMN IF NOT EXISTS install_mode text,
|
| 15 |
+
ADD COLUMN IF NOT EXISTS requests_by_stack jsonb;
|
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for headroom.telemetry.context (install_mode + headroom_stack detection)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from types import SimpleNamespace
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from headroom.telemetry.context import detect_install_mode, detect_stack
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture(autouse=True)
|
| 13 |
+
def _clean_env(monkeypatch):
|
| 14 |
+
"""Every test starts without our env vars set."""
|
| 15 |
+
|
| 16 |
+
monkeypatch.delenv("HEADROOM_STACK", raising=False)
|
| 17 |
+
monkeypatch.delenv("HEADROOM_AGENT_TYPE", raising=False)
|
| 18 |
+
yield
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class TestDetectInstallMode:
|
| 22 |
+
def test_wrapped_when_agent_type_set(self, monkeypatch):
|
| 23 |
+
monkeypatch.setenv("HEADROOM_AGENT_TYPE", "claude")
|
| 24 |
+
assert detect_install_mode(8787) == "wrapped"
|
| 25 |
+
|
| 26 |
+
def test_on_demand_when_no_env_and_no_manifest(self, monkeypatch):
|
| 27 |
+
monkeypatch.setattr(
|
| 28 |
+
"headroom.install.state.list_manifests", lambda: []
|
| 29 |
+
)
|
| 30 |
+
assert detect_install_mode(8787) == "on_demand"
|
| 31 |
+
|
| 32 |
+
def test_persistent_when_manifest_matches_port(self, monkeypatch):
|
| 33 |
+
manifest = SimpleNamespace(port=8787, profile="default")
|
| 34 |
+
monkeypatch.setattr(
|
| 35 |
+
"headroom.install.state.list_manifests", lambda: [manifest]
|
| 36 |
+
)
|
| 37 |
+
assert detect_install_mode(8787) == "persistent"
|
| 38 |
+
|
| 39 |
+
def test_on_demand_when_manifest_port_mismatches(self, monkeypatch):
|
| 40 |
+
manifest = SimpleNamespace(port=9000, profile="other")
|
| 41 |
+
monkeypatch.setattr(
|
| 42 |
+
"headroom.install.state.list_manifests", lambda: [manifest]
|
| 43 |
+
)
|
| 44 |
+
assert detect_install_mode(8787) == "on_demand"
|
| 45 |
+
|
| 46 |
+
def test_wrapped_takes_precedence_over_manifest(self, monkeypatch):
|
| 47 |
+
monkeypatch.setenv("HEADROOM_AGENT_TYPE", "codex")
|
| 48 |
+
manifest = SimpleNamespace(port=8787, profile="default")
|
| 49 |
+
monkeypatch.setattr(
|
| 50 |
+
"headroom.install.state.list_manifests", lambda: [manifest]
|
| 51 |
+
)
|
| 52 |
+
assert detect_install_mode(8787) == "wrapped"
|
| 53 |
+
|
| 54 |
+
def test_manifest_crash_falls_back_to_on_demand(self, monkeypatch):
|
| 55 |
+
def _boom():
|
| 56 |
+
raise RuntimeError("disk gone")
|
| 57 |
+
|
| 58 |
+
monkeypatch.setattr("headroom.install.state.list_manifests", _boom)
|
| 59 |
+
# install_mode should not raise; graceful fallback
|
| 60 |
+
assert detect_install_mode(8787) == "on_demand"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class TestDetectStack:
|
| 64 |
+
def test_explicit_env_wins(self, monkeypatch):
|
| 65 |
+
monkeypatch.setenv("HEADROOM_STACK", "custom_slug")
|
| 66 |
+
assert detect_stack() == "custom_slug"
|
| 67 |
+
|
| 68 |
+
def test_explicit_env_overrides_agent_type(self, monkeypatch):
|
| 69 |
+
monkeypatch.setenv("HEADROOM_STACK", "proxy")
|
| 70 |
+
monkeypatch.setenv("HEADROOM_AGENT_TYPE", "claude")
|
| 71 |
+
assert detect_stack() == "proxy"
|
| 72 |
+
|
| 73 |
+
def test_wrap_slug_from_agent_type(self, monkeypatch):
|
| 74 |
+
monkeypatch.setenv("HEADROOM_AGENT_TYPE", "claude")
|
| 75 |
+
assert detect_stack() == "wrap_claude"
|
| 76 |
+
|
| 77 |
+
def test_unknown_agent_type_rejected(self, monkeypatch):
|
| 78 |
+
monkeypatch.setenv("HEADROOM_AGENT_TYPE", "somebespoke")
|
| 79 |
+
assert detect_stack() == "unknown"
|
| 80 |
+
|
| 81 |
+
def test_default_is_proxy(self):
|
| 82 |
+
assert detect_stack() == "proxy"
|
| 83 |
+
|
| 84 |
+
def test_default_is_proxy_with_empty_stats(self):
|
| 85 |
+
assert detect_stack({"requests": {"by_stack": {}}}) == "proxy"
|
| 86 |
+
|
| 87 |
+
def test_dominant_stack_from_stats(self):
|
| 88 |
+
stats = {"requests": {"by_stack": {"adapter_ts_openai": 90, "adapter_ts_anthropic": 10}}}
|
| 89 |
+
assert detect_stack(stats) == "adapter_ts_openai"
|
| 90 |
+
|
| 91 |
+
def test_mixed_when_no_dominant_stack(self):
|
| 92 |
+
stats = {"requests": {"by_stack": {"adapter_ts_openai": 40, "adapter_ts_anthropic": 60}}}
|
| 93 |
+
assert detect_stack(stats) == "mixed"
|
| 94 |
+
|
| 95 |
+
def test_single_stack_is_dominant(self):
|
| 96 |
+
stats = {"requests": {"by_stack": {"adapter_ts_openai": 3}}}
|
| 97 |
+
assert detect_stack(stats) == "adapter_ts_openai"
|
| 98 |
+
|
| 99 |
+
def test_env_beats_stats(self, monkeypatch):
|
| 100 |
+
monkeypatch.setenv("HEADROOM_STACK", "wrap_claude")
|
| 101 |
+
stats = {"requests": {"by_stack": {"adapter_ts_openai": 100}}}
|
| 102 |
+
assert detect_stack(stats) == "wrap_claude"
|