Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
bc84170
fix(proxy/ccr): dedup proactive expansion per session and guard tail …
nangsontay Jul 29, 2026
68bd294
fix(ccr): stop proactively expanding non-tool contexts
nangsontay Aug 1, 2026
ba62fad
fix(proxy): guard the exact index each append helper mutates
nangsontay Aug 11, 2026
c6f9948
fix(proxy/anthropic): run tool-search history repair after turn hooks
gglucass Aug 11, 2026
d7b25ae
fix(wrap/serena): install Serena from the serena-agent PyPI wheel, no…
abhay-codes07 Aug 11, 2026
702dbc5
fix(opencode): ship the transport hook-shim so wheel installs route N…
abhay-codes07 Aug 11, 2026
5e53b8a
fix(opencode): keep Claude models off OpenAI provider
SulimanAbdulrazzaq Aug 11, 2026
739fdef
fix(proxy): cancel periodic TOIN task on shutdown
abhinavkr26104 Aug 11, 2026
0ae948c
fix(cache): bound compression cache bookkeeping
Robert2547 Aug 11, 2026
620028f
fix(proxy): emit request log timestamps in UTC
SulimanAbdulrazzaq Aug 11, 2026
4bd8ecd
fix(memory): close MCP backend on shutdown
abhinavkr26104 Aug 11, 2026
99f07e7
fix(proxy): cache litellm model resolution to stop repeated Provider …
connectsudhindra-gif Aug 11, 2026
07d89a7
fix(litellm): close shared cloud client
abhinavkr26104 Aug 11, 2026
c85abf7
fix(oauth2): make repository lint checks pass
abhinavkr26104 Aug 11, 2026
e044139
fix(install): trust Docker bridge for dashboard metadata
SulimanAbdulrazzaq Aug 11, 2026
6596182
fix(memory): close DirectMem0 resources
abhinavkr26104 Aug 11, 2026
fd4628d
fix(memory): sync FTS5 and vector indexes on CLI delete/edit/prune/purge
gingeekrishna Aug 11, 2026
de9e052
fix(settings): accept documented HEADROOM_* env names as settings key…
axelray-dev Aug 11, 2026
7092b53
fix(cli/update): let install ownership win over bare /.dockerenv so v…
abhay-codes07 Aug 11, 2026
8cd1380
fix(toin): bound private query and pattern retention
chopratejas Aug 11, 2026
cde1513
fix(proxy): guard telemetry and TOIN endpoints
chopratejas Aug 11, 2026
d0c1f5b
fix(ccr): avoid injecting tool on chat streaming
chopratejas Aug 11, 2026
ae38486
fix(wrap/opencode): verify the opencode binary before mutating config
abhay-codes07 Aug 12, 2026
c093bf1
fix(wrap/claude): keep --1m effective when an explicit --model is pas…
abhay-codes07 Aug 12, 2026
def3d76
fix(cache): mirror client cache_control positions instead of single-m…
gglucass Aug 12, 2026
0d6866b
fix(backends/anyllm): convert Anthropic tools and tool_choice to Open…
abhay-codes07 Aug 12, 2026
e4904e2
fix(backends/anyllm): stream tool_use blocks and map finish_reason on…
abhay-codes07 Aug 12, 2026
d7bc1e2
fix(content-router): protect custom-tag blocks before mixed-content s…
gglucass Aug 12, 2026
0951663
fix(proxy): close the upstream stream when a streaming body is never …
abhay-codes07 Aug 12, 2026
12149f7
fix(proxy): include tool_search_deferral savings in the savings ledger
abhay-codes07 Aug 12, 2026
d41ab62
Merge remote-tracking branch 'upstream/main' into pr2706-conflict
nangsontay Aug 12, 2026
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
5 changes: 5 additions & 0 deletions .github/workflows/opencode-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ on:
paths:
- "plugins/opencode/**"
- "headroom/providers/opencode/_dist/**"
- "headroom/providers/opencode/hook-shim/**"
- ".github/workflows/opencode-plugin.yml"
push:
branches: [main]
paths:
- "plugins/opencode/**"
- "headroom/providers/opencode/_dist/**"
- "headroom/providers/opencode/hook-shim/**"
- ".github/workflows/opencode-plugin.yml"

permissions:
Expand Down Expand Up @@ -51,3 +53,6 @@ jobs:
cmp dist-standalone/entry.opencode.js \
../../headroom/providers/opencode/_dist/entry.opencode.js \
|| { echo "::error::headroom/providers/opencode/_dist/entry.opencode.js is stale - run 'npm run build:standalone' in plugins/opencode and commit the result"; exit 1; }
cmp dist-standalone/hook-shim/handler.js \
../../headroom/providers/opencode/hook-shim/handler.js \
|| { echo "::error::headroom/providers/opencode/hook-shim/handler.js is stale - run 'npm run build:standalone' in plugins/opencode and commit the result"; exit 1; }
179 changes: 153 additions & 26 deletions headroom/backends/anyllm.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,44 @@
AnyLLM = None # type: ignore


def _convert_anthropic_tool(tool: dict[str, Any]) -> dict[str, Any]:
"""Convert an Anthropic tool definition to the OpenAI function shape.

any-llm speaks OpenAI, so an Anthropic ``{name, description, input_schema}``
tool must become ``{type: function, function: {name, description,
parameters}}`` before it is forwarded, or the provider ignores/rejects the
tools array and the model never calls a tool. Mirrors the LiteLLM backend's
converter so both OpenAI-compatible backends send the same shape.
"""
func: dict[str, Any] = {"name": tool.get("name", "")}
if "description" in tool:
func["description"] = tool["description"]
if "input_schema" in tool:
func["parameters"] = tool["input_schema"]
return {"type": "function", "function": func}


def _convert_tool_choice(choice: Any) -> Any:
"""Convert an Anthropic ``tool_choice`` to the OpenAI shape (mirrors LiteLLM).

Anthropic: ``{"type": "auto"}``, ``{"type": "any"}``, ``{"type": "tool",
"name": ...}``. OpenAI: ``"auto"``, ``"required"``, ``{"type": "function",
"function": {"name": ...}}``. Passing the raw Anthropic dict through makes
the provider reject or ignore it.
"""
if isinstance(choice, str):
return choice
if isinstance(choice, dict):
choice_type = choice.get("type", "auto")
if choice_type == "auto":
return "auto"
if choice_type == "any":
return "required"
if choice_type == "tool":
return {"type": "function", "function": {"name": choice.get("name", "")}}
return "auto"


class AnyLLMBackend(Backend):
"""Backend using any-llm for multi-provider support."""

Expand Down Expand Up @@ -251,9 +289,9 @@ async def send_message(
if "stop_sequences" in body:
kwargs["stop"] = body["stop_sequences"]
if "tools" in body:
kwargs["tools"] = body["tools"]
kwargs["tools"] = [_convert_anthropic_tool(t) for t in body["tools"]]
if "tool_choice" in body:
kwargs["tool_choice"] = body["tool_choice"]
kwargs["tool_choice"] = _convert_tool_choice(body["tool_choice"])

logger.debug(f"any-llm request: provider={self.provider}, model={original_model}")

Expand Down Expand Up @@ -301,9 +339,9 @@ async def stream_message(
if "stop_sequences" in body:
kwargs["stop"] = body["stop_sequences"]
if "tools" in body:
kwargs["tools"] = body["tools"]
kwargs["tools"] = [_convert_anthropic_tool(t) for t in body["tools"]]
if "tool_choice" in body:
kwargs["tool_choice"] = body["tool_choice"]
kwargs["tool_choice"] = _convert_tool_choice(body["tool_choice"])

msg_id = f"msg_{uuid.uuid4().hex[:24]}"

Expand All @@ -324,42 +362,131 @@ async def stream_message(
},
)

yield StreamEvent(
event_type="content_block_start",
data={
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": ""},
},
)

stream_response = await self.llm.acompletion(**kwargs)
output_tokens = 0
# Stream text immediately in a single text block, but BUFFER tool
# calls and emit them as complete blocks at the end. OpenAI streams
# parallel tool calls interleaved by index (index 0 and 1 introduced
# together, then a fragment for 0, then for 1), while Anthropic
# requires each content block to be fully emitted — start, deltas,
# stop — before the next opens. Reassembling per index and flushing
# complete blocks keeps every delta inside its own block's start/stop
# for any interleaving. (The previous version pre-opened one text
# block and dropped tool calls entirely; a naive open-on-new-index
# instead mis-sequenced parallel calls, emitting a fragment for an
# already-stopped block.)
current_block_index = -1
text_block_open = False
# provider tool index -> {"id", "name", "arguments"}, first-seen order
tool_calls: dict[int, dict[str, Any]] = {}
tool_order: list[int] = []
stop_reason = "end_turn"

async for chunk in cast(AsyncIterator[Any], stream_response):
if hasattr(chunk, "choices") and chunk.choices:
delta = chunk.choices[0].delta
if hasattr(delta, "content") and delta.content:
if not (hasattr(chunk, "choices") and chunk.choices):
continue
choice = chunk.choices[0]
delta = choice.delta

# Map OpenAI finish_reason to the Anthropic stop_reason so a tool
# call or a length truncation is not reported as end_turn.
finish_reason = getattr(choice, "finish_reason", None)
if finish_reason == "tool_calls":
stop_reason = "tool_use"
elif finish_reason == "length":
stop_reason = "max_tokens"
elif finish_reason == "stop":
stop_reason = "end_turn"

if getattr(delta, "tool_calls", None):
for tc in delta.tool_calls:
idx = tc.index if getattr(tc, "index", None) is not None else 0
buf = tool_calls.get(idx)
if buf is None:
buf = {"id": None, "name": "", "arguments": ""}
tool_calls[idx] = buf
tool_order.append(idx)
if getattr(tc, "id", None):
buf["id"] = tc.id
func = getattr(tc, "function", None)
if func is not None:
if getattr(func, "name", None):
buf["name"] = func.name
if getattr(func, "arguments", None):
buf["arguments"] += func.arguments

elif getattr(delta, "content", None):
if not text_block_open:
current_block_index += 1
text_block_open = True
yield StreamEvent(
event_type="content_block_delta",
event_type="content_block_start",
data={
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": delta.content},
"type": "content_block_start",
"index": current_block_index,
"content_block": {"type": "text", "text": ""},
},
)
output_tokens += 1
yield StreamEvent(
event_type="content_block_delta",
data={
"type": "content_block_delta",
"index": current_block_index,
"delta": {"type": "text_delta", "text": delta.content},
},
)
output_tokens += 1

# Close the text block before any tool blocks (Anthropic orders
# content blocks sequentially, text then tool_use).
if text_block_open:
yield StreamEvent(
event_type="content_block_stop",
data={"type": "content_block_stop", "index": current_block_index},
)

yield StreamEvent(
event_type="content_block_stop",
data={"type": "content_block_stop", "index": 0},
)
# Flush each buffered tool call as a complete, self-contained block:
# start, one input_json_delta with the reassembled arguments, stop.
for idx in tool_order:
buf = tool_calls[idx]
current_block_index += 1
tool_id = buf["id"] or f"toolu_{uuid.uuid4().hex[:24]}"
yield StreamEvent(
event_type="content_block_start",
data={
"type": "content_block_start",
"index": current_block_index,
"content_block": {
"type": "tool_use",
"id": tool_id,
"name": buf["name"],
"input": {},
},
},
)
if buf["arguments"]:
yield StreamEvent(
event_type="content_block_delta",
data={
"type": "content_block_delta",
"index": current_block_index,
"delta": {
"type": "input_json_delta",
"partial_json": buf["arguments"],
},
},
)
output_tokens += 1
yield StreamEvent(
event_type="content_block_stop",
data={"type": "content_block_stop", "index": current_block_index},
)

yield StreamEvent(
event_type="message_delta",
data={
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
"usage": {"output_tokens": output_tokens},
},
)
Expand Down
47 changes: 41 additions & 6 deletions headroom/cache/compression_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,10 @@ def __init__(self, max_entries: int = 10000) -> None:
# `compute_frozen_count` (bounded above by the `min` clamp at
# `proxy/handlers/anthropic.py`) and `update_from_result`'s
# "unchanged content" tracking.
self._stable_hashes: set[str] = set()
self._first_seen: dict[str, float] = {}
# Ordered mappings preserve set/dict-style membership while allowing
# deterministic oldest-first eviction.
self._stable_hashes: OrderedDict[str, None] = OrderedDict()
self._first_seen: OrderedDict[str, float] = OrderedDict()
self._hits: int = 0
self._misses: int = 0
self._total_tokens_saved: int = 0
Expand Down Expand Up @@ -172,6 +174,34 @@ def store_compressed(self, hash: str, compressed: str, tokens_saved: int) -> Non
_, evicted = self._cache.popitem(last=False)
self._total_tokens_saved -= evicted.tokens_saved

def _mark_stable_locked(self, content_hash: str) -> None:
"""Record a stable hash while bounding retained bookkeeping."""
self._stable_hashes[content_hash] = None
self._stable_hashes.move_to_end(content_hash)

while len(self._stable_hashes) > self.max_entries:
self._stable_hashes.popitem(last=False)

def _record_first_seen_locked(self, content_hash: str, seen_at: float) -> None:
"""Record a first-seen timestamp while bounding retained bookkeeping."""
self._first_seen[content_hash] = seen_at
self._first_seen.move_to_end(content_hash)

while len(self._first_seen) > self.max_entries:
self._first_seen.popitem(last=False)

def _prune_expired_first_seen_locked(
self,
now: float,
ttl_seconds: float,
) -> None:
"""Remove first-seen entries whose cache timing window has expired."""
while self._first_seen:
_, oldest_seen_at = next(iter(self._first_seen.items()))
if now - oldest_seen_at < ttl_seconds:
break
self._first_seen.popitem(last=False)

def mark_stable(self, content_hash: str) -> None:
"""Mark a content hash as stable (unchanged, not compressed).

Expand All @@ -180,7 +210,7 @@ def mark_stable(self, content_hash: str) -> None:
even though no compressed version exists in the cache.
"""
with self._lock:
self._stable_hashes.add(content_hash)
self._mark_stable_locked(content_hash)

def mark_stable_from_messages(self, messages: list[dict], up_to: int) -> None:
"""Mark all tool_result hashes in messages[:up_to] as stable."""
Expand All @@ -189,7 +219,7 @@ def mark_stable_from_messages(self, messages: list[dict], up_to: int) -> None:
if _is_tool_result_message(msg):
content = _extract_tool_result_content(msg)
if content is not None:
self._stable_hashes.add(self.content_hash(content))
self._mark_stable_locked(self.content_hash(content))

def should_defer_compression(
self,
Expand All @@ -216,13 +246,18 @@ def should_defer_compression(
"""
with self._lock:
now = time.time()
self._prune_expired_first_seen_locked(now, ttl_seconds)

first_seen = self._first_seen.get(content_hash)
if first_seen is None:
self._first_seen[content_hash] = now
self._record_first_seen_locked(content_hash, now)
return False # First time — compress now (no cache entry to preserve)

age = now - first_seen
if age >= ttl_seconds - batch_window:
self._record_first_seen_locked(content_hash, now)
return False # Near TTL boundary — compress now (batch window)

return True # Seen recently within TTL — defer to preserve cache

def get_stats(self) -> dict:
Expand Down Expand Up @@ -335,7 +370,7 @@ def update_from_result(self, originals: list[dict], compressed: list[dict]) -> N
continue
if orig_content == comp_content:
# Content unchanged — mark as stable for frozen count walk
self._stable_hashes.add(self.content_hash(orig_content))
self._mark_stable_locked(self.content_hash(orig_content))
continue
h = self.content_hash(orig_content)
tokens_saved = len(orig_content) // 4 - len(comp_content) // 4
Expand Down
18 changes: 18 additions & 0 deletions headroom/cache/compression_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@
CCR_TTL_SECONDS_ENV = "HEADROOM_CCR_TTL_SECONDS"

_RETRIEVAL_LOG_PREVIEW_CHARS = 4096
# Previews carry verbatim tool-result content (post-redaction), which makes
# proxy.log too sensitive for users to share in bug reports. Set to
# 0/false/no/off to log byte counts only.
PAYLOAD_PREVIEW_ENV = "HEADROOM_LOG_PAYLOAD_PREVIEW"
_SECRET_KEY_VALUE_RE = re.compile(
r"(?i)\b([A-Z0-9_-]*(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_-]*)"
r"(\s*[:=]\s*)([\"']?)([^\"'\s,}]+)"
Expand Down Expand Up @@ -108,7 +112,21 @@ def _redact_retrieval_log_payload(payload: str) -> str:
return _API_KEY_VALUE_RE.sub("sk-[REDACTED]", redacted)


def _payload_preview_enabled() -> bool:
raw = os.environ.get(PAYLOAD_PREVIEW_ENV)
if raw is None:
return True
return raw.strip().lower() not in ("0", "false", "no", "off")


def _payload_for_retrieval_log(payload: str) -> dict[str, Any]:
if not _payload_preview_enabled():
return {
"payload_chars": len(payload),
"payload_preview_chars": 0,
"payload_truncated": len(payload) > 0,
"payload_preview": "",
}
redacted = _redact_retrieval_log_payload(payload)
preview = redacted[:_RETRIEVAL_LOG_PREVIEW_CHARS]
truncated = len(redacted) > len(preview)
Expand Down
Loading
Loading