mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description Fixes #1602. OpenCode Zen custom-base requests can reach Headroom through the generic passthrough path, but that route was not supplying endpoint/provider metadata for Zen chat completions. This made forwarded Zen traffic invisible in dashboard provider, usage, and token telemetry. Closes #1602 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Added a narrow OpenCode Zen custom-base classifier for `POST /zen/v1/chat/completions` on `opencode.ai` and `www.opencode.ai`. - Passed `endpoint_name="chat/completions"` and `provider="zen"` into catch-all passthrough telemetry for matching Zen traffic. - Attributed normalized OpenCode transport traffic (`/v1/chat/completions` with `x-headroom-original-path: /zen/v1/chat/completions`) to `zen` for request outcomes while keeping the OpenAI parser path unchanged. - Added coverage for direct catch-all routing, normalized original-path routing, token usage outcome recording, and false-positive paths like `/mcp/v1/chat/completions`, `/npm/v1/chat/completions`, and `/context7/v1/chat/completions`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ rtk pytest tests/test_custom_base_passthrough_telemetry.py -q Pytest: 4 passed $ rtk uvx --from ruff==0.15.17 ruff check headroom/proxy/handlers/openai.py headroom/providers/proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_provider_proxy_routes.py tests/test_proxy/test_openai_transport_path_prefix.py All checks passed! $ rtk uvx --from ruff==0.15.17 ruff format --check headroom/proxy/handlers/openai.py headroom/providers/proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_provider_proxy_routes.py tests/test_proxy/test_openai_transport_path_prefix.py 5 files already formatted $ rtk /Library/Frameworks/Python.framework/Versions/3.13/bin/python3 -m py_compile headroom/proxy/handlers/openai.py headroom/providers/proxy_routes.py tests/test_custom_base_passthrough_telemetry.py tests/test_provider_proxy_routes.py tests/test_proxy/test_openai_transport_path_prefix.py # passed $ rtk git diff --check # passed ``` GitHub Actions also passed after the final push, including CI, Docker native/wrap/init E2E, security, lint, and PR governance. ## Real Behavior Proof - Environment: local worktree on macOS plus GitHub Actions for PR #1648. - Exact command / steps: ran focused pytest coverage for Zen passthrough telemetry, Ruff check/format validation on touched files, Python compile validation, `git diff --check`, and waited for the full GitHub Actions rollup. - Observed result: Zen custom-base chat completions now record request outcomes as provider `zen` with endpoint `chat/completions`; false-positive OpenCode paths remain unattributed to Zen; GitHub checks are green. - Not tested: full local test suite did not collect in this worktree because the native `headroom._core` extension is not installed. `rtk npm --prefix plugins/opencode test` is also blocked locally because `vitest` is not installed in `plugins/opencode/node_modules`. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A ## Additional Notes The documentation and CHANGELOG checklist items are not applicable for this narrow telemetry bug fix. No new comments were added because the code path is covered by narrowly named helper/test cases.
212 lines
6.4 KiB
Python
212 lines
6.4 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import FastAPI
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.testclient import TestClient
|
|
|
|
from headroom.providers.proxy_routes import register_provider_routes
|
|
from headroom.proxy.handlers.openai import OpenAIHandlerMixin
|
|
|
|
|
|
class _Runtime:
|
|
@staticmethod
|
|
def api_target(provider: str) -> str:
|
|
return f"https://{provider}.example.test"
|
|
|
|
@staticmethod
|
|
def model_metadata_provider(headers: dict[str, str]) -> str:
|
|
return "anthropic"
|
|
|
|
|
|
class _Proxy:
|
|
ANTHROPIC_API_URL = "https://anthropic.example.test"
|
|
OPENAI_API_URL = "https://openai.example.test"
|
|
GEMINI_API_URL = "https://gemini.example.test"
|
|
CLOUDCODE_API_URL = "https://cloudcode.example.test"
|
|
VERTEX_API_URL = "https://vertex.example.test"
|
|
|
|
def __init__(self) -> None:
|
|
self.config = SimpleNamespace(bedrock_api_url=None)
|
|
self.provider_runtime = _Runtime()
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
async def handle_passthrough(
|
|
self,
|
|
request: Any,
|
|
base_url: str,
|
|
endpoint_name: str = "",
|
|
provider: str = "",
|
|
) -> JSONResponse:
|
|
self.calls.append(
|
|
{
|
|
"path": request.url.path,
|
|
"base_url": base_url,
|
|
"endpoint_name": endpoint_name,
|
|
"provider": provider,
|
|
}
|
|
)
|
|
return JSONResponse(self.calls[-1])
|
|
|
|
|
|
class _ChatCompletionsRequest:
|
|
method = "POST"
|
|
headers = {}
|
|
url = SimpleNamespace(path="/zen/v1/chat/completions", query="")
|
|
|
|
async def body(self) -> bytes:
|
|
return b'{"model":"zen"}'
|
|
|
|
|
|
class _OpenAIUsageClient:
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
async def request(self, **kwargs: Any) -> httpx.Response:
|
|
self.calls.append(kwargs)
|
|
request = httpx.Request(kwargs["method"], kwargs["url"])
|
|
return httpx.Response(
|
|
200,
|
|
request=request,
|
|
headers={"content-type": "application/json"},
|
|
json={
|
|
"usage": {
|
|
"prompt_tokens": 21,
|
|
"completion_tokens": 8,
|
|
"prompt_tokens_details": {"cached_tokens": 5},
|
|
}
|
|
},
|
|
)
|
|
|
|
|
|
def test_custom_base_provider_prefixed_chat_completions_gets_telemetry() -> None:
|
|
app = FastAPI()
|
|
proxy = _Proxy()
|
|
register_provider_routes(app, proxy)
|
|
|
|
with TestClient(app) as client:
|
|
for base_url, expected_base_url in (
|
|
("https://opencode.ai/", "https://opencode.ai"),
|
|
("https://www.opencode.ai/", "https://www.opencode.ai"),
|
|
):
|
|
response = client.post(
|
|
"/zen/v1/chat/completions",
|
|
headers={"x-headroom-base-url": base_url},
|
|
json={"model": "zen"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"path": "/zen/v1/chat/completions",
|
|
"base_url": expected_base_url,
|
|
"endpoint_name": "chat/completions",
|
|
"provider": "zen",
|
|
}
|
|
|
|
|
|
def test_custom_base_unrelated_passthrough_paths_stay_unclassified() -> None:
|
|
app = FastAPI()
|
|
proxy = _Proxy()
|
|
register_provider_routes(app, proxy)
|
|
|
|
with TestClient(app) as client:
|
|
for path in (
|
|
"/mcp",
|
|
"/mcp/v1/chat/completions",
|
|
"/npm/v1/chat/completions",
|
|
"/context7/v1/chat/completions",
|
|
):
|
|
response = client.post(
|
|
path,
|
|
headers={"x-headroom-base-url": "https://opencode.ai/"},
|
|
json={},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"path": path,
|
|
"base_url": "https://opencode.ai",
|
|
"endpoint_name": "",
|
|
"provider": "",
|
|
}
|
|
|
|
|
|
def test_custom_base_chat_completions_telemetry_is_post_and_opencode_zen_only() -> None:
|
|
app = FastAPI()
|
|
proxy = _Proxy()
|
|
register_provider_routes(app, proxy)
|
|
|
|
with TestClient(app) as client:
|
|
get_response = client.get(
|
|
"/zen/v1/chat/completions",
|
|
headers={"x-headroom-base-url": "https://opencode.ai/"},
|
|
)
|
|
other_host_response = client.post(
|
|
"/zen/v1/chat/completions",
|
|
headers={"x-headroom-base-url": "https://custom.example/"},
|
|
json={"model": "zen"},
|
|
)
|
|
double_slash_response = client.post(
|
|
"/zen//v1/chat/completions",
|
|
headers={"x-headroom-base-url": "https://opencode.ai/"},
|
|
json={"model": "zen"},
|
|
)
|
|
trailing_slash_response = client.post(
|
|
"/zen/v1/chat/completions/",
|
|
headers={"x-headroom-base-url": "https://opencode.ai/"},
|
|
json={"model": "zen"},
|
|
)
|
|
|
|
for response in (
|
|
get_response,
|
|
other_host_response,
|
|
double_slash_response,
|
|
trailing_slash_response,
|
|
):
|
|
assert response.status_code == 200
|
|
assert response.json()["endpoint_name"] == ""
|
|
assert response.json()["provider"] == ""
|
|
|
|
|
|
def test_classified_custom_base_passthrough_records_telemetry_usage() -> None:
|
|
handler = object.__new__(OpenAIHandlerMixin)
|
|
handler.http_client = _OpenAIUsageClient()
|
|
outcomes = []
|
|
|
|
async def next_request_id() -> str:
|
|
return "req_zen"
|
|
|
|
async def record(outcome: Any) -> None:
|
|
outcomes.append(outcome)
|
|
|
|
handler._next_request_id = next_request_id
|
|
handler._record_request_outcome = record
|
|
|
|
response = asyncio.run(
|
|
handler.handle_passthrough(
|
|
_ChatCompletionsRequest(),
|
|
"https://opencode.ai",
|
|
"chat/completions",
|
|
"zen",
|
|
)
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert json.loads(response.body) == {
|
|
"usage": {
|
|
"prompt_tokens": 21,
|
|
"completion_tokens": 8,
|
|
"prompt_tokens_details": {"cached_tokens": 5},
|
|
}
|
|
}
|
|
assert handler.http_client.calls[0]["url"] == ("https://opencode.ai/zen/v1/chat/completions")
|
|
assert len(outcomes) == 1
|
|
outcome = outcomes[0]
|
|
assert outcome.provider == "zen"
|
|
assert outcome.model == "passthrough:chat/completions"
|
|
assert outcome.optimized_tokens == 21
|
|
assert outcome.output_tokens == 8
|
|
assert outcome.cache_read_tokens == 5
|