Garm Claude Opus 4.7 (1M context) commited on
Commit
bc11844
·
1 Parent(s): a35ad8d

feat(telemetry): add headroom_stack and install_mode identity fields

Browse files

Adds 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 CHANGED
@@ -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,
headroom/proxy/prometheus_metrics.py CHANGED
@@ -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,
headroom/proxy/server.py CHANGED
@@ -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,
headroom/telemetry/beacon.py CHANGED
@@ -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", {})
headroom/telemetry/context.py ADDED
@@ -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"
sdk/typescript/src/adapters/anthropic.ts CHANGED
@@ -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, { ...options, model });
 
 
 
 
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)
sdk/typescript/src/adapters/gemini.ts CHANGED
@@ -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)
sdk/typescript/src/adapters/openai.ts CHANGED
@@ -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, { ...options, model });
 
 
 
 
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,
sdk/typescript/src/adapters/vercel-ai.ts CHANGED
@@ -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, { ...options, model });
 
 
 
 
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, options);
 
 
 
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 {
sdk/typescript/src/client.ts CHANGED
@@ -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 {
sdk/typescript/src/types.ts CHANGED
@@ -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 {
sql/create_proxy_telemetry_v2.sql CHANGED
@@ -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,
sql/upgrade_telemetry_stack_context.sql ADDED
@@ -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;
tests/test_telemetry_context.py ADDED
@@ -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"