mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Merge branch 'main' into docs/fix-stale-and-incorrect-docs
This commit is contained in:
commit
3599de8e7a
46 changed files with 2361 additions and 1537 deletions
54
.github/ISSUE_TEMPLATE/copilot-subscription-test-report.md
vendored
Normal file
54
.github/ISSUE_TEMPLATE/copilot-subscription-test-report.md
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
---
|
||||
name: Copilot Subscription Test Report
|
||||
about: Report results of testing `headroom wrap copilot --subscription` on Linux/Windows/macOS
|
||||
title: '[COPILOT-SUB] <OS> test report'
|
||||
labels: copilot-subscription, testing
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
<!--
|
||||
Thanks for helping verify Copilot subscription mode across platforms!
|
||||
See TESTING-copilot-subscription.md for the step-by-step flows.
|
||||
Redact your actual token everywhere.
|
||||
-->
|
||||
|
||||
## Environment
|
||||
|
||||
- **OS + version**: (e.g., Ubuntu 24.04, Windows 11 23H2, macOS 14.5)
|
||||
- **Architecture**: (x86_64 / arm64)
|
||||
- **How you installed headroom**: (pipx/pip `--pre` wheel · Docker install.sh/ps1 · built from source)
|
||||
- **headroom version**: (`headroom --version`)
|
||||
- **Copilot CLI version**: (`copilot --version`)
|
||||
- **Was plain `copilot` logged in before the test?**: yes / no
|
||||
|
||||
## Result
|
||||
|
||||
- **Command run**:
|
||||
```
|
||||
headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with exactly: HEADROOM_OK"
|
||||
```
|
||||
- **Did it print `HEADROOM_OK`?**: yes / no
|
||||
- **Worked WITHOUT `GITHUB_COPILOT_TOKEN` (auto-discovery)?**: yes / no / didn't try
|
||||
- **Worked WITH `GITHUB_COPILOT_TOKEN` set?**: yes / no / didn't try
|
||||
|
||||
## Error output (if any)
|
||||
|
||||
```
|
||||
paste any error here
|
||||
```
|
||||
|
||||
## Token storage schema (only if auto-discovery failed)
|
||||
|
||||
Helps us fix auto-discovery. **Redact the secret value.**
|
||||
|
||||
- Linux: `secret-tool search --all 2>/dev/null | sed -E 's/^secret = .*/secret = <redacted>/'`
|
||||
- Windows: `cmd /c "cmdkey /list"` (paste the Copilot-related `Target:` line)
|
||||
- macOS (reference): service `copilot-cli`
|
||||
|
||||
```
|
||||
paste the attribute / Target lines here (secret redacted)
|
||||
```
|
||||
|
||||
## Anything else
|
||||
|
||||
(logs from `~/.headroom/logs/proxy.log`, surprises, etc.)
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
"draft": false,
|
||||
"prerelease": false,
|
||||
"separate-pull-requests": false,
|
||||
"pull-request-title-pattern": "chore: release ${version}",
|
||||
"packages": {
|
||||
".": {
|
||||
"package-name": "headroom-ai",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
".": "0.22.4"
|
||||
".": "0.23.0"
|
||||
}
|
||||
|
|
|
|||
61
CHANGELOG.md
61
CHANGELOG.md
|
|
@ -5,29 +5,48 @@ All notable changes to Headroom will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Documentation
|
||||
## [0.23.0](https://github.com/chopratejas/headroom/compare/v0.22.4...v0.23.0) (2026-06-04)
|
||||
|
||||
- Fix stale API references: removed `IntelligentContextConfig`, `ScoringWeights`, and `RollingWindowConfig` from all docs (these classes no longer exist); context management is now automatic.
|
||||
- Fix `SmartCrusherConfig` field names: removed fabricated fields (`keep_first`, `keep_last`, `relevance_threshold`, `anomaly_std_threshold`, `preserve_errors`, `relevance_tier`); replaced with real fields from source (`variance_threshold`, `uniqueness_threshold`, `similarity_threshold`, `preserve_change_points`, `first_fraction`, `last_fraction`, `dedup_identical_items`, `use_feedback_hints`).
|
||||
- Fix `EmbeddingScorer` constructor: `model=` -> `model_name=`, default model updated to `BAAI/bge-small-en-v1.5`.
|
||||
- Fix `memory.add()` examples: remove non-existent `category=` parameter.
|
||||
- Fix `headroom learn` CLI reference: remove non-existent `--claude-dir PATH`; add actual `--agent`, `--model`, `--workers` flags.
|
||||
- Fix import path: `from headroom.compression import compress` -> `from headroom import compress`; mark `UniversalCompressor` examples as Advanced/Internal API.
|
||||
- Mark "Message-level CCR via IntelligentContext" section as retired in `ccr.mdx`.
|
||||
- Fix CHANGELOG footer links: `headroom-sdk/headroom` -> `chopratejas/headroom`.
|
||||
- Note `pip install "headroom-ai[llmlingua]"` extra was removed in 0.9.x.
|
||||
- Fix proxy command: `python -m headroom.proxy.server --port 8787` -> `headroom proxy --port 8787`.
|
||||
- Add "opt-in; disabled by default" caveat to source code 40-70% savings claims.
|
||||
- Add missing extras to installation docs: `memory`, `relevance`, `image`, `reports`, `otel`, `voice`.
|
||||
- Update `README.md` extras mention to include `[code]`, `[memory]`, `[relevance]`, `[image]`.
|
||||
- Fix telemetry description in `llms.txt`: "No telemetry by default" -> "Anonymous telemetry enabled by default; set `HEADROOM_TELEMETRY=off` to disable".
|
||||
- Update `EmbedderBackend.LOCAL` recommendation to `EmbedderBackend.ONNX` with note that LOCAL requires PyTorch (~2 GB).
|
||||
- Fix CCR acronym: "Cross-Conversation Retrieval" -> "Compress-Cache-Retrieve" in `api-reference.mdx` (including `ccrHashes` field description).
|
||||
- Remove `IntelligentContext` from pipeline diagram in `how-compression-works.mdx`; update to 2-stage pipeline: `CacheAligner` -> `ContentRouter`.
|
||||
- Remove retired `rolling_window_config` field from `HeadroomClient` Python constructor table in `api-reference.mdx`; add note that `rolling_window` was removed in 0.9.x.
|
||||
- docs: fix stale API references, retired class imports, wrong field names, and incomplete extras table
|
||||
### Features
|
||||
|
||||
* **copilot:** GitHub Copilot subscription mode through Headroom ([f4dff9b](https://github.com/chopratejas/headroom/commit/f4dff9b4885b5c62d79396bbb0847ae3e39a9bd9))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ccr:** scope proactive expansion by workspace (cross-project leak) ([197601b](https://github.com/chopratejas/headroom/commit/197601bc64ee72e786bf6b94cd90efcac4269bcf))
|
||||
* **ccr:** scope proactive expansion by workspace (cross-project leak) ([1bc163f](https://github.com/chopratejas/headroom/commit/1bc163f5bc1a8422f9ad659061e1fdd8cfeb077b))
|
||||
* **codex:** keep init model_provider at config root ([#260](https://github.com/chopratejas/headroom/issues/260)) ([304dcc7](https://github.com/chopratejas/headroom/commit/304dcc78047bc744fc2f7656b484ec54dc271354))
|
||||
* **codex:** keep init model_provider at config root ([#260](https://github.com/chopratejas/headroom/issues/260)) ([849b46d](https://github.com/chopratejas/headroom/commit/849b46de5934a88369af2fd7f7d52e9af0536a7e))
|
||||
* **copilot:** deterministic subscription token handoff to the proxy ([72da461](https://github.com/chopratejas/headroom/commit/72da46121726074515e0c1eb9745498457a1a8d5))
|
||||
* **copilot:** support subscription auth through Headroom ([ff4a0c6](https://github.com/chopratejas/headroom/commit/ff4a0c6bc64e5e68ab76c38047a36a3c7a6aaacf))
|
||||
* correct tiktoken encoding for unknown gpt-4 model snapshots ([#552](https://github.com/chopratejas/headroom/issues/552)) ([0e551de](https://github.com/chopratejas/headroom/commit/0e551de9d81021bb7f0dde1857a2341408606969))
|
||||
* decode/encode owned config, state and template assets as UTF-8 ([2f1538a](https://github.com/chopratejas/headroom/commit/2f1538a641dd0e60a7be3de85646a70c4bf7e287))
|
||||
* decode/encode owned config, state and template assets as UTF-8 (fixes [#533](https://github.com/chopratejas/headroom/issues/533)) ([92075b9](https://github.com/chopratejas/headroom/commit/92075b95af799951c90a305a08ec4e958473967a))
|
||||
* **docker:** upgrade base images to Python 3.13 / debian13 ([e6bf7a0](https://github.com/chopratejas/headroom/commit/e6bf7a03fef8a9f2e4802d63afdafb40627c7ad9))
|
||||
* **docker:** upgrade base images to Python 3.13 / debian13, drop digest pinning ([08a2197](https://github.com/chopratejas/headroom/commit/08a219708c97dcdc678483a0e6891306624a1fad))
|
||||
* **docs:** bump next.js to 16.2.6 for GHSA-h64f-5h5j-jqjh (CVE-2026-44577) ([a6a09e6](https://github.com/chopratejas/headroom/commit/a6a09e6cfbe6962a70a6fb2e4bebeee80756e304))
|
||||
* **docs:** mkdocs configuration to build with correct folder ([#543](https://github.com/chopratejas/headroom/issues/543)) ([5557944](https://github.com/chopratejas/headroom/commit/55579445f84c363219f45dc5358599a04d4263ed))
|
||||
* **docs:** update brace-expansion to 5.0.6 to remediate GHSA-jxxr-4gwj-5jf2 (CVE-2026-45149) ([6eb6fb5](https://github.com/chopratejas/headroom/commit/6eb6fb5941adfbd056daa1689c3fa0c3755fd298))
|
||||
* **docs:** update bun.lock to next 16.2.6 for GHSA-h64f-5h5j-jqjh (CVE-2026-44577) ([91e0937](https://github.com/chopratejas/headroom/commit/91e0937243c801fa5f1021b4c47debef2444650c))
|
||||
* ignore brackets inside JSON strings when splitting mixed content ([#553](https://github.com/chopratejas/headroom/issues/553)) ([bdcfc32](https://github.com/chopratejas/headroom/commit/bdcfc322da0c4cde69931d641cfa18c76ddb138b))
|
||||
* **learn:** decode Unix home dirs whose username contains '.', '-' or '_' ([211daae](https://github.com/chopratejas/headroom/commit/211daae25687901d1f893714d877b25606d0ef69))
|
||||
* **learn:** decode Unix home dirs whose username contains '.', '-' or '_' ([491a8b3](https://github.com/chopratejas/headroom/commit/491a8b3a1b260f42f503b3553a04c578c18e1cc0))
|
||||
* **learn:** finish gemini-flash-latest default model sweep ([982d01b](https://github.com/chopratejas/headroom/commit/982d01b9c996fd5fe26154dc2f94d567192f6ff6))
|
||||
* **learn:** finish gemini-flash-latest default model sweep ([#532](https://github.com/chopratejas/headroom/issues/532)) ([d797366](https://github.com/chopratejas/headroom/commit/d7973665f4e2f40f2b3acadd0ec584609fb33c6c))
|
||||
* **memory:** READ-ONLY framing + fail-closed unresolved-project fallback ([a178249](https://github.com/chopratejas/headroom/commit/a178249fc0af4a1b6f212decb4f6d2793d57fae8))
|
||||
* **memory:** READ-ONLY framing + fail-closed unresolved-project fallback ([482f80e](https://github.com/chopratejas/headroom/commit/482f80e735f124ee6860f6854255c77170b862e7))
|
||||
* update dashboard doc link ([#544](https://github.com/chopratejas/headroom/issues/544)) ([378d77e](https://github.com/chopratejas/headroom/commit/378d77e79d0020ca7fba3de8df7aaf910056ad2a))
|
||||
* Update Next.js to 16.2.4 in docs/bun.lock to address GHSA-gx5p-jg67-6x7h (CVE-2026-44580) ([0b9f11a](https://github.com/chopratejas/headroom/commit/0b9f11a223bb6e6a6c1660ff1dfc1df6d67dfa84))
|
||||
* Update Next.js to 16.2.6 in docs/package.json and package-lock.json to address GHSA-h64f-5h5j-jqjh (CVE-2026-44577) ([db5d15f](https://github.com/chopratejas/headroom/commit/db5d15f99e71b69a369eb9c161e04dbffb9b5d4a))
|
||||
* Upgrade litellm to 1.86.2 to remediate CVE-2026-42271 ([07581b9](https://github.com/chopratejas/headroom/commit/07581b9e8075b833a6b543149008547260fe9dc0))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **cli:** factor shared wrap-subcommand scaffolding ([8eeb926](https://github.com/chopratejas/headroom/commit/8eeb9261680dd071654a87204521ccd3703ef77d))
|
||||
* **cli:** factor shared wrap-subcommand scaffolding ([c74ad11](https://github.com/chopratejas/headroom/commit/c74ad113a4ced9968e45cad1077e6a020dc6a401))
|
||||
|
||||
## [0.22.4](https://github.com/chopratejas/headroom/compare/v0.22.3...v0.22.4) (2026-05-26)
|
||||
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -34,6 +34,7 @@
|
|||
</sub></p>
|
||||
|
||||
---
|
||||
<a href="https://trendshift.io/repositories/20881" target="_blank"><img src="https://trendshift.io/api/badge/repositories/20881" alt="chopratejas%2Fheadroom | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
> Headroom compresses everything your AI agent reads — tool outputs, logs, RAG chunks, files, and conversation history — before it reaches the LLM. Same answers, fraction of the tokens.
|
||||
|
||||
|
|
@ -94,7 +95,7 @@ headroom proxy --port 8787 # drop-in proxy, zero code changes
|
|||
# or: from headroom import compress # inline library
|
||||
|
||||
# 3 — See the savings
|
||||
headroom stats
|
||||
headroom perf
|
||||
```
|
||||
|
||||
Granular extras: `[proxy]`, `[mcp]`, `[ml]`, `[code]`, `[memory]`, `[relevance]`, `[image]`, `[agno]`, `[langchain]`, `[evals]`. Requires **Python 3.10+**.
|
||||
|
|
@ -134,6 +135,18 @@ Reproduce: `python -m headroom.evals suite --tier 1` · [Full benchmarks & metho
|
|||
|
||||
Any OpenAI-compatible client works via `headroom proxy`. MCP-native: `headroom mcp install`.
|
||||
|
||||
### GitHub Copilot CLI subscription mode
|
||||
|
||||
Headroom can route GitHub Copilot CLI subscription traffic through the local proxy:
|
||||
|
||||
```bash
|
||||
headroom wrap copilot --subscription -- --model gpt-4o
|
||||
```
|
||||
|
||||
This lets Headroom intercept OpenAI-compatible Copilot CLI requests and apply the same proxy compression pipeline before forwarding to GitHub Copilot's hosted API. The wrapper resolves the account-specific Copilot API endpoint and prints it as `COPILOT_PROVIDER_API_URL=...` during launch.
|
||||
|
||||
Platform support note: macOS auth reuse via Copilot CLI Keychain storage has been smoke-tested. Windows Credential Manager, Linux Secret Service / `secret-tool`, and Docker/CI token-injection paths are implemented or planned as auth-discovery paths, but still need real OS validation before they should be considered fully vetted. For Docker and CI, prefer passing an explicit `GITHUB_COPILOT_TOKEN` or `GITHUB_COPILOT_GITHUB_TOKEN` rather than relying on host keychain access.
|
||||
|
||||
## When to use · When to skip
|
||||
|
||||
**Great fit if you…**
|
||||
|
|
@ -262,7 +275,6 @@ Devcontainers in `.devcontainer/` (default + `memory-stack` with Qdrant & Neo4j)
|
|||
|
||||
## Community
|
||||
|
||||
- **[Live leaderboard](https://headroomlabs.ai/dashboard)** — 60B+ tokens saved and counting.
|
||||
- **[Discord](https://discord.gg/yRmaUNpsPJ)** — questions, feedback, war stories.
|
||||
- **[Kompress-base on HuggingFace](https://huggingface.co/chopratejas/kompress-base)** — the model behind our text compression.
|
||||
|
||||
|
|
|
|||
122
TESTING-copilot-subscription.md
Normal file
122
TESTING-copilot-subscription.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
# Testing: GitHub Copilot subscription mode (`headroom wrap copilot --subscription`)
|
||||
|
||||
This is an **experimental** feature and we need help verifying it on **Linux and
|
||||
Windows**. It already works on macOS; the cross-platform gap is small and
|
||||
specific (see [Status](#status)). If you have a GitHub Copilot subscription and
|
||||
10 minutes, please run one of the flows below and
|
||||
[file a report](https://github.com/chopratejas/headroom/issues/new?template=copilot-subscription-test-report.md).
|
||||
|
||||
> ⚠️ This is experimental, and it reads your Copilot login token + routes your
|
||||
> Copilot CLI traffic through a local Headroom proxy. Only run it if you're
|
||||
> comfortable with that. The branch is open for inspection.
|
||||
|
||||
## What it does (and what "subscription" means here)
|
||||
|
||||
Normally `headroom wrap copilot` is **BYOK** — you bring an Anthropic/OpenAI API
|
||||
key and pay that vendor. `--subscription` is different: it lets you use the
|
||||
**Copilot seat you already pay GitHub for**, with **no separate API key**, while
|
||||
still routing through Headroom so your context gets compressed.
|
||||
|
||||
Mechanically: the Copilot CLI's only interposition hook is its provider-override
|
||||
(the "BYOK transport"), so Headroom uses that knob but supplies **your
|
||||
subscription token** and points back at **GitHub's own Copilot API**. So the CLI
|
||||
may print "BYOK" and require an explicit `--model`, but you are **not** paying a
|
||||
third party — it's your subscription, just compressed. (Proof it's working: the
|
||||
proxy forwards to `https://api.*.githubcopilot.com` with your token.)
|
||||
|
||||
## Status
|
||||
|
||||
| Platform | Mechanism (compress + forward) | Token **auto-discovery** from the OS secret store |
|
||||
|----------|:---:|:---:|
|
||||
| macOS (Keychain) | ✅ verified | ✅ verified (`copilot-cli`) |
|
||||
| Linux (`secret-tool`/libsecret) | ✅ expected | ❓ **needs testing** |
|
||||
| Windows (Credential Manager) | ✅ expected | ❓ **needs testing** |
|
||||
| Any OS via `GITHUB_COPILOT_TOKEN` env var | ✅ verified by tests | n/a (bypasses discovery) |
|
||||
|
||||
The two things we want to learn:
|
||||
1. **Does it work end to end on your OS?**
|
||||
2. **Does it find your Copilot token automatically**, or do you have to set
|
||||
`GITHUB_COPILOT_TOKEN`? If it can't find it, we need the **storage schema**
|
||||
(see each flow) so we can fix auto-discovery.
|
||||
|
||||
## Prerequisites (all platforms)
|
||||
|
||||
1. A **GitHub Copilot subscription**.
|
||||
2. The **GitHub Copilot CLI**: `npm install -g @github/copilot`
|
||||
3. **Log in once**: run `copilot`, complete the device-code login in your
|
||||
browser, then type `/exit`.
|
||||
|
||||
---
|
||||
|
||||
## Linux — the flow we most need (tests auto-discovery)
|
||||
|
||||
Auto-discovery only works with a **host-native** install (a container can't read
|
||||
your host secret store). Linux has prebuilt wheels, so:
|
||||
|
||||
```bash
|
||||
pipx install --pip-args='--pre' headroom-ai # or: pip install --pre headroom-ai
|
||||
# (no separate API key needed — that's the point)
|
||||
headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with exactly: HEADROOM_OK"
|
||||
```
|
||||
|
||||
- **If it prints `HEADROOM_OK`** → auto-discovery works on your Linux. 🎉 Report success.
|
||||
- **If it errors with "no reusable bearer token"** → discovery missed your token. Please grab the **schema** so we can fix it (redact the secret), then confirm the mechanism works via the env var:
|
||||
```bash
|
||||
secret-tool search --all 2>/dev/null | sed -E 's/^secret = .*/secret = <redacted>/'
|
||||
# then retry, supplying the token explicitly:
|
||||
GITHUB_COPILOT_TOKEN='<your-token>' headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with: HEADROOM_OK"
|
||||
```
|
||||
Report the `attribute.*` lines from `secret-tool` and whether the env-var retry worked.
|
||||
|
||||
---
|
||||
|
||||
## Windows
|
||||
|
||||
There is **no native Windows wheel yet**, so pick one:
|
||||
|
||||
**A. Mechanism test (easiest — Docker Desktop or WSL2):**
|
||||
```powershell
|
||||
$env:HEADROOM_DOCKER_IMAGE = "ghcr.io/chopratejas/headroom:<branch-tag>" # ask the maintainer for the tag
|
||||
# run the Docker-native installer (scripts/install.ps1), then:
|
||||
$env:GITHUB_COPILOT_TOKEN = "<your-token>"
|
||||
headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with: HEADROOM_OK"
|
||||
```
|
||||
Report whether it prints `HEADROOM_OK`.
|
||||
|
||||
**B. Native auto-discovery schema (even without a working install):** after
|
||||
`copilot` login, tell us where Windows stored the token:
|
||||
```cmd
|
||||
cmd /c "cmdkey /list"
|
||||
```
|
||||
Report the `Target:` line that looks Copilot-related (it shows the target name,
|
||||
not the secret). That single fact lets us make native Windows discovery work.
|
||||
|
||||
> Native Windows auto-discovery becomes fully testable once we add a Windows
|
||||
> wheel to the build matrix — tracked separately.
|
||||
|
||||
---
|
||||
|
||||
## macOS (already proven — a second data point still helps)
|
||||
|
||||
```bash
|
||||
pipx install --pip-args='--pre' headroom-ai
|
||||
headroom wrap copilot --subscription -- --model gpt-4o -p "Reply with exactly: HEADROOM_OK"
|
||||
```
|
||||
Schema, for reference: Keychain generic password, service `copilot-cli`
|
||||
(`security find-generic-password -s copilot-cli -w`).
|
||||
|
||||
---
|
||||
|
||||
## What to report
|
||||
|
||||
Please open a
|
||||
[Copilot subscription test report](https://github.com/chopratejas/headroom/issues/new?template=copilot-subscription-test-report.md)
|
||||
with:
|
||||
|
||||
- **OS + version** and **how you installed** (pipx/pip wheel, Docker, source).
|
||||
- Was plain `copilot` logged in?
|
||||
- Did `wrap copilot --subscription` print **`HEADROOM_OK`**? Paste any error.
|
||||
- Did it work **without** setting `GITHUB_COPILOT_TOKEN` (auto-discovery), or
|
||||
only **with** it?
|
||||
- The **storage schema** if discovery failed (`secret-tool search --all` /
|
||||
`cmdkey /list`), with the secret redacted.
|
||||
115
docs/bun.lock
115
docs/bun.lock
|
|
@ -13,9 +13,10 @@
|
|||
"fumadocs-ui": "16.7.10",
|
||||
"headroom-ai": "file:../sdk/typescript",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.2.2",
|
||||
"next": "16.2.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -28,7 +29,7 @@
|
|||
"@types/react-dom": "^19.2.3",
|
||||
"ai": "^6.0.149",
|
||||
"openai": "^6.33.0",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss": "^8.5.10",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
|
|
@ -181,23 +182,23 @@
|
|||
|
||||
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
|
||||
|
||||
"@next/env": ["@next/env@16.2.2", "", {}, "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ=="],
|
||||
"@next/env": ["@next/env@16.2.4", "", {}, "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg=="],
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw=="],
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q=="],
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg=="],
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g=="],
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg=="],
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g=="],
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA=="],
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
|
|
@ -271,6 +272,8 @@
|
|||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
|
||||
|
|
@ -345,6 +348,8 @@
|
|||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
|
||||
|
|
@ -385,6 +390,24 @@
|
|||
|
||||
"@turf/invariant": ["@turf/invariant@7.3.4", "", { "dependencies": { "@turf/helpers": "7.3.4", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" } }, "sha512-88Eo4va4rce9sNZs6XiMJowWkikM3cS2TBhaCKlU+GFHdNf8PFEpiU42VDU8q5tOF6/fu21Rvlke5odgOGW4AQ=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||
|
||||
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||
|
||||
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
||||
|
||||
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||
|
||||
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||
|
||||
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
||||
|
||||
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
|
||||
|
||||
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||
|
||||
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
|
@ -411,6 +434,8 @@
|
|||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
|
@ -511,8 +536,32 @@
|
|||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
|
||||
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||
|
||||
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||
|
||||
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
|
||||
|
||||
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||
|
||||
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
||||
|
||||
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
||||
|
||||
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
||||
|
||||
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
||||
|
||||
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
|
|
@ -547,6 +596,8 @@
|
|||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"es-toolkit": ["es-toolkit@1.47.0", "", {}, "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw=="],
|
||||
|
||||
"esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="],
|
||||
|
||||
"esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="],
|
||||
|
|
@ -573,6 +624,8 @@
|
|||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
|
@ -651,8 +704,12 @@
|
|||
|
||||
"image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="],
|
||||
|
||||
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||
|
||||
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||
|
|
@ -839,11 +896,11 @@
|
|||
|
||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"next": ["next@16.2.2", "", { "dependencies": { "@next/env": "16.2.2", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.2", "@next/swc-darwin-x64": "16.2.2", "@next/swc-linux-arm64-gnu": "16.2.2", "@next/swc-linux-arm64-musl": "16.2.2", "@next/swc-linux-x64-gnu": "16.2.2", "@next/swc-linux-x64-musl": "16.2.2", "@next/swc-win32-arm64-msvc": "16.2.2", "@next/swc-win32-x64-msvc": "16.2.2", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A=="],
|
||||
"next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
|
|
@ -883,7 +940,7 @@
|
|||
|
||||
"point-in-polygon-hao": ["point-in-polygon-hao@1.2.4", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
|
||||
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
|
||||
|
||||
|
|
@ -897,8 +954,12 @@
|
|||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"react-is": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="],
|
||||
|
||||
"react-medium-image-zoom": ["react-medium-image-zoom@5.4.3", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cDIwdn35fRUPsGnnj/cG6Pacll+z+Mfv6EWU2wDO5ngbZjg5uLRb2ZhEnh92ufbXCJDFvXHekb8G3+oKqUcv5g=="],
|
||||
|
||||
"react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
|
@ -907,6 +968,8 @@
|
|||
|
||||
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
|
||||
|
||||
"recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="],
|
||||
|
||||
"recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="],
|
||||
|
|
@ -915,6 +978,10 @@
|
|||
|
||||
"recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="],
|
||||
|
||||
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
|
||||
|
||||
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||
|
||||
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
|
||||
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
|
||||
|
|
@ -937,6 +1004,8 @@
|
|||
|
||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||
|
||||
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||
|
||||
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
|
||||
|
||||
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
|
||||
|
|
@ -985,6 +1054,8 @@
|
|||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
|
||||
|
|
@ -1045,6 +1116,8 @@
|
|||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
|
@ -1053,6 +1126,8 @@
|
|||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="],
|
||||
|
|
@ -1083,6 +1158,8 @@
|
|||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@reduxjs/toolkit/immer": ["immer@11.1.8", "", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
|
|
@ -1095,6 +1172,8 @@
|
|||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tailwindcss/postcss/postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"headroom-ai/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
|
||||
|
||||
"headroom-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="],
|
||||
|
|
@ -1115,12 +1194,18 @@
|
|||
|
||||
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"vite/postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"@tailwindcss/postcss/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"headroom-ai/@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"headroom-ai/openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"tsup/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
|
@ -1169,6 +1254,8 @@
|
|||
|
||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"vite/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"headroom-ai/@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"headroom-ai/openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
|
|
|||
|
|
@ -249,7 +249,13 @@ OPENROUTER_API_KEY=sk-or-... headroom proxy --backend openrouter
|
|||
export HEADROOM_HOST=0.0.0.0
|
||||
export HEADROOM_PORT=8787
|
||||
export HEADROOM_BUDGET=100.0
|
||||
|
||||
# Route OpenAI passthrough requests to a custom endpoint
|
||||
export OPENAI_TARGET_API_URL=https://custom.openai.endpoint.com
|
||||
|
||||
# Route Anthropic passthrough requests to a custom endpoint
|
||||
export ANTHROPIC_TARGET_API_URL=https://litellm.company.internal
|
||||
|
||||
headroom proxy
|
||||
```
|
||||
|
||||
|
|
|
|||
1474
docs/package-lock.json
generated
1474
docs/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -18,7 +18,7 @@
|
|||
"fumadocs-ui": "16.7.10",
|
||||
"headroom-ai": "file:../sdk/typescript",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.2.4",
|
||||
"next": "16.2.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"recharts": "^3.8.1",
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ Use 'auto' (default) to scan all detected agents."""
|
|||
"--model",
|
||||
type=str,
|
||||
default=None,
|
||||
help="LLM model for analysis (e.g., claude-sonnet-4-6, gpt-4o, gemini/gemini-2.0-flash). "
|
||||
help="LLM model for analysis (e.g., claude-sonnet-4-6, gpt-4o, gemini/gemini-flash-latest). "
|
||||
"Auto-detected from API keys if not specified.",
|
||||
)
|
||||
@click.option(
|
||||
|
|
|
|||
|
|
@ -37,8 +37,12 @@ if sys.platform == "win32" and hasattr(sys.stdout, "buffer"):
|
|||
import click
|
||||
|
||||
from headroom._version import __version__ as _HEADROOM_VERSION
|
||||
from headroom.copilot_auth import DEFAULT_API_URL as COPILOT_API_URL
|
||||
from headroom.copilot_auth import has_oauth_auth, resolve_client_bearer_token
|
||||
from headroom.copilot_auth import (
|
||||
has_oauth_auth,
|
||||
resolve_client_bearer_token,
|
||||
resolve_copilot_api_url,
|
||||
resolve_subscription_bearer_token,
|
||||
)
|
||||
from headroom.providers.aider import build_launch_env as _build_aider_launch_env
|
||||
from headroom.providers.claude import proxy_base_url as _claude_proxy_base_url
|
||||
from headroom.providers.codex import build_launch_env as _build_codex_launch_env
|
||||
|
|
@ -157,6 +161,7 @@ def _start_proxy(
|
|||
anyllm_provider: str | None = None,
|
||||
region: str | None = None,
|
||||
openai_api_url: str | None = None,
|
||||
copilot_api_token: str | None = None,
|
||||
) -> subprocess.Popen:
|
||||
"""Start Headroom proxy as a background subprocess.
|
||||
|
||||
|
|
@ -210,6 +215,14 @@ def _start_proxy(
|
|||
if agent_type != "unknown":
|
||||
proxy_env["HEADROOM_AGENT_TYPE"] = agent_type
|
||||
proxy_env.setdefault("HEADROOM_STACK", f"wrap_{agent_type}")
|
||||
if openai_api_url:
|
||||
proxy_env["OPENAI_TARGET_API_URL"] = openai_api_url
|
||||
# Pin the wrapper-validated Copilot token for this proxy instance only.
|
||||
# Injected into the subprocess env here (not the parent's os.environ) so it
|
||||
# never leaks into shared state. The proxy's CopilotTokenProvider honours
|
||||
# GITHUB_COPILOT_API_TOKEN directly, making upstream auth deterministic.
|
||||
if copilot_api_token:
|
||||
proxy_env["GITHUB_COPILOT_API_TOKEN"] = copilot_api_token
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
|
|
@ -1343,6 +1356,16 @@ def _proxy_active_session_count(payload: dict[str, Any] | None) -> int:
|
|||
return max(counts, default=0)
|
||||
|
||||
|
||||
def _normalize_proxy_api_url(url: object) -> str | None:
|
||||
"""Normalize configured upstream URLs for running-proxy comparisons."""
|
||||
if not isinstance(url, str):
|
||||
return None
|
||||
normalized = url.strip().rstrip("/")
|
||||
if normalized.endswith("/v1"):
|
||||
normalized = normalized[:-3]
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _proxy_version(payload: dict[str, Any] | None) -> str | None:
|
||||
"""Return the running proxy version when it exposes one."""
|
||||
if payload is None:
|
||||
|
|
@ -1554,8 +1577,11 @@ def _should_use_copilot_oauth(
|
|||
backend: str | None,
|
||||
provider_type: str,
|
||||
env: dict[str, str],
|
||||
force_subscription: bool = False,
|
||||
) -> bool:
|
||||
"""Prefer a reusable Copilot OAuth session when the requested routing supports it."""
|
||||
if force_subscription:
|
||||
return True
|
||||
if env.get("COPILOT_PROVIDER_API_KEY") or env.get("COPILOT_PROVIDER_BEARER_TOKEN"):
|
||||
return False
|
||||
if provider_type == "anthropic":
|
||||
|
|
@ -1580,6 +1606,7 @@ def _ensure_proxy(
|
|||
anyllm_provider: str | None = None,
|
||||
region: str | None = None,
|
||||
openai_api_url: str | None = None,
|
||||
copilot_api_token: str | None = None,
|
||||
) -> subprocess.Popen | None:
|
||||
"""Start or verify proxy. Returns process handle if we started it."""
|
||||
helpers = _live_wrap_module()
|
||||
|
|
@ -1669,10 +1696,19 @@ def _ensure_proxy(
|
|||
missing.append("learn")
|
||||
if code_graph and not running_config.get("code_graph"):
|
||||
missing.append("code_graph")
|
||||
if openai_api_url:
|
||||
running_openai_url = _normalize_proxy_api_url(
|
||||
running_config.get("openai_api_url")
|
||||
)
|
||||
requested_openai_url = _normalize_proxy_api_url(openai_api_url)
|
||||
if running_openai_url != requested_openai_url:
|
||||
missing.append("openai-api-url")
|
||||
|
||||
if missing:
|
||||
needs_restart = True
|
||||
flags_str = ", ".join(f"--{f.replace('_', '-')}" for f in missing)
|
||||
flags_str = ", ".join(
|
||||
f if f.startswith("--") else f"--{f.replace('_', '-')}" for f in missing
|
||||
)
|
||||
click.echo(f" Proxy on port {port} is missing: {flags_str}")
|
||||
click.echo(" Restarting proxy with upgraded configuration...")
|
||||
|
||||
|
|
@ -1718,6 +1754,7 @@ def _ensure_proxy(
|
|||
anyllm_provider=anyllm_provider,
|
||||
region=region,
|
||||
openai_api_url=openai_api_url,
|
||||
copilot_api_token=copilot_api_token,
|
||||
),
|
||||
)
|
||||
click.echo(f" Proxy ready on http://127.0.0.1:{port}")
|
||||
|
|
@ -1793,6 +1830,7 @@ def _launch_tool(
|
|||
anyllm_provider: str | None = None,
|
||||
region: str | None = None,
|
||||
openai_api_url: str | None = None,
|
||||
copilot_api_token: str | None = None,
|
||||
) -> None:
|
||||
"""Common logic: start proxy, launch tool, clean up."""
|
||||
proxy_holder: list[subprocess.Popen | None] = [None]
|
||||
|
|
@ -1819,6 +1857,7 @@ def _launch_tool(
|
|||
anyllm_provider=anyllm_provider,
|
||||
region=region,
|
||||
openai_api_url=openai_api_url,
|
||||
copilot_api_token=copilot_api_token,
|
||||
)
|
||||
|
||||
if code_graph:
|
||||
|
|
@ -2360,6 +2399,14 @@ def unwrap_claude(
|
|||
default=None,
|
||||
help="OpenAI-compatible Copilot wire API. Defaults to 'completions' when provider-type resolves to openai.",
|
||||
)
|
||||
@click.option(
|
||||
"--subscription",
|
||||
is_flag=True,
|
||||
help=(
|
||||
"Experimental: route GitHub-authenticated Copilot CLI traffic through Headroom "
|
||||
"without requiring a provider API key."
|
||||
),
|
||||
)
|
||||
@click.option("--memory", is_flag=True, help="Enable persistent cross-session memory")
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
|
||||
@click.argument("copilot_args", nargs=-1, type=click.UNPROCESSED)
|
||||
|
|
@ -2372,6 +2419,7 @@ def copilot(
|
|||
region: str | None,
|
||||
provider_type: str,
|
||||
wire_api: str | None,
|
||||
subscription: bool,
|
||||
memory: bool,
|
||||
verbose: bool,
|
||||
copilot_args: tuple[str, ...],
|
||||
|
|
@ -2389,6 +2437,7 @@ def copilot(
|
|||
headroom wrap copilot -- --model claude-sonnet-4-20250514
|
||||
headroom wrap copilot --backend anyllm --anyllm-provider groq -- --model gpt-4o
|
||||
headroom wrap copilot --provider-type openai --wire-api responses -- --model gpt-5.4
|
||||
headroom wrap copilot --subscription -- --model gpt-4.1
|
||||
headroom wrap copilot --no-context-tool -- --prompt "explain this file"
|
||||
"""
|
||||
copilot_bin = shutil.which("copilot")
|
||||
|
|
@ -2416,6 +2465,17 @@ def copilot(
|
|||
wire_api=wire_api,
|
||||
backend=effective_backend,
|
||||
)
|
||||
if subscription:
|
||||
if effective_backend not in (None, "", "anthropic"):
|
||||
raise click.ClickException(
|
||||
"--subscription routes to GitHub Copilot's hosted API and cannot be combined "
|
||||
"with translated backends such as anyllm or litellm-*."
|
||||
)
|
||||
if provider_type == "anthropic":
|
||||
raise click.ClickException(
|
||||
"--subscription uses Copilot's OpenAI-compatible hosted API path; "
|
||||
"do not combine it with --provider-type anthropic."
|
||||
)
|
||||
|
||||
if not no_rtk:
|
||||
if _selected_context_tool() == _CONTEXT_TOOL_LEAN_CTX:
|
||||
|
|
@ -2430,15 +2490,21 @@ def copilot(
|
|||
|
||||
env = os.environ.copy()
|
||||
openai_api_url: str | None = None
|
||||
copilot_proxy_token: str | None = None
|
||||
if _should_use_copilot_oauth(
|
||||
backend=effective_backend,
|
||||
provider_type=provider_type,
|
||||
env=env,
|
||||
force_subscription=subscription,
|
||||
):
|
||||
client_bearer = resolve_client_bearer_token()
|
||||
client_bearer = (
|
||||
resolve_subscription_bearer_token() if subscription else resolve_client_bearer_token()
|
||||
)
|
||||
if not client_bearer:
|
||||
raise click.ClickException(
|
||||
"GitHub Copilot auth was detected but no reusable bearer token could be resolved."
|
||||
"GitHub Copilot subscription mode requires a reusable GitHub/Copilot bearer "
|
||||
"token, but none could be resolved. Run `copilot auth login` first, or set "
|
||||
"GITHUB_COPILOT_TOKEN / GITHUB_COPILOT_GITHUB_TOKEN."
|
||||
)
|
||||
|
||||
effective_wire_api = wire_api or "completions"
|
||||
|
|
@ -2446,14 +2512,32 @@ def copilot(
|
|||
env["COPILOT_PROVIDER_BASE_URL"] = f"http://127.0.0.1:{port}/v1"
|
||||
env["COPILOT_PROVIDER_WIRE_API"] = effective_wire_api
|
||||
env["COPILOT_PROVIDER_BEARER_TOKEN"] = client_bearer
|
||||
env["GITHUB_COPILOT_USE_TOKEN_EXCHANGE"] = "false"
|
||||
env.pop("COPILOT_PROVIDER_API_KEY", None)
|
||||
# Hand the exact token we resolved (and, for --subscription, validated
|
||||
# against GitHub) to the proxy explicitly via copilot_proxy_token below.
|
||||
# The proxy pins it as GITHUB_COPILOT_API_TOKEN, so upstream auth is
|
||||
# deterministic instead of the proxy re-running unvalidated discovery
|
||||
# (read_cached_oauth_token returns the *first* candidate, which may not
|
||||
# be the one the wrapper approved → environment-dependent 401s). Passing
|
||||
# it as a launch argument — rather than mutating this process's global
|
||||
# os.environ — keeps the token off shared state and out of unrelated
|
||||
# code paths.
|
||||
copilot_proxy_token = client_bearer
|
||||
env_vars_display = [
|
||||
"COPILOT_PROVIDER_TYPE=openai",
|
||||
f"COPILOT_PROVIDER_BASE_URL=http://127.0.0.1:{port}/v1",
|
||||
f"COPILOT_PROVIDER_WIRE_API={effective_wire_api}",
|
||||
"COPILOT_AUTH_MODE=github-oauth",
|
||||
(
|
||||
"COPILOT_AUTH_MODE=github-subscription-experimental"
|
||||
if subscription
|
||||
else "COPILOT_AUTH_MODE=github-oauth"
|
||||
),
|
||||
]
|
||||
openai_api_url = COPILOT_API_URL
|
||||
openai_api_url = resolve_copilot_api_url(client_bearer)
|
||||
env["GITHUB_COPILOT_API_URL"] = openai_api_url
|
||||
env["OPENAI_TARGET_API_URL"] = openai_api_url
|
||||
env_vars_display.append(f"COPILOT_PROVIDER_API_URL={openai_api_url}")
|
||||
else:
|
||||
env, env_vars_display = _build_copilot_launch_env(
|
||||
port=port,
|
||||
|
|
@ -2475,7 +2559,7 @@ def copilot(
|
|||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
if not _copilot_model_configured(copilot_args, env):
|
||||
if not subscription and not _copilot_model_configured(copilot_args, env):
|
||||
click.echo(
|
||||
" Note: Copilot BYOK requires a model. Pass `--model <name>` "
|
||||
"or set `COPILOT_MODEL` / `COPILOT_PROVIDER_MODEL_ID`."
|
||||
|
|
@ -2496,6 +2580,7 @@ def copilot(
|
|||
anyllm_provider=anyllm_provider,
|
||||
region=region,
|
||||
openai_api_url=openai_api_url,
|
||||
copilot_api_token=copilot_proxy_token,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,10 +18,14 @@ from urllib import error as urllib_error
|
|||
from urllib import request as urllib_request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from headroom.copilot_linux_secret import read_copilot_oauth_token as read_linux_secret_token
|
||||
from headroom.copilot_macos_keychain import read_copilot_oauth_token as read_macos_keychain_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_API_URL = "https://api.githubcopilot.com"
|
||||
DEFAULT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token"
|
||||
DEFAULT_USER_INFO_URL = "https://api.github.com/copilot_internal/user"
|
||||
DEFAULT_GITHUB_HOST = "github.com"
|
||||
_TOKEN_EXPIRY_BUFFER_S = 60
|
||||
_DEFAULT_EDITOR_VERSION = "vscode/1.104.1"
|
||||
|
|
@ -31,12 +35,15 @@ _API_TOKEN_ENV_VARS = (
|
|||
"GITHUB_COPILOT_API_TOKEN",
|
||||
"COPILOT_PROVIDER_BEARER_TOKEN",
|
||||
)
|
||||
_OAUTH_TOKEN_ENV_VARS = (
|
||||
_COPILOT_OAUTH_TOKEN_ENV_VARS = (
|
||||
"GITHUB_COPILOT_GITHUB_TOKEN",
|
||||
"GITHUB_COPILOT_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"COPILOT_GITHUB_TOKEN",
|
||||
)
|
||||
_GENERIC_GITHUB_TOKEN_ENV_VARS = (
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
)
|
||||
_OAUTH_TOKEN_KEYS = (
|
||||
"oauth_token",
|
||||
"oauthToken",
|
||||
|
|
@ -62,6 +69,16 @@ class CopilotAPIToken:
|
|||
return time.time() < (self.expires_at - _TOKEN_EXPIRY_BUFFER_S)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CopilotTokenCandidate:
|
||||
"""A discovered reusable token plus enough metadata to reason about trust."""
|
||||
|
||||
token: str
|
||||
source: str
|
||||
confidence: str
|
||||
validate_for_subscription: bool = True
|
||||
|
||||
|
||||
def _github_host() -> str:
|
||||
return (os.environ.get("GITHUB_COPILOT_HOST") or DEFAULT_GITHUB_HOST).strip().lower()
|
||||
|
||||
|
|
@ -70,6 +87,10 @@ def _token_exchange_url() -> str:
|
|||
return os.environ.get("GITHUB_COPILOT_TOKEN_EXCHANGE_URL", DEFAULT_TOKEN_EXCHANGE_URL).strip()
|
||||
|
||||
|
||||
def _user_info_url() -> str:
|
||||
return os.environ.get("GITHUB_COPILOT_USER_INFO_URL", DEFAULT_USER_INFO_URL).strip()
|
||||
|
||||
|
||||
def _should_exchange_oauth_token() -> bool:
|
||||
raw = os.environ.get("GITHUB_COPILOT_USE_TOKEN_EXCHANGE", "").strip().lower()
|
||||
return raw in {"1", "true", "yes", "on"}
|
||||
|
|
@ -119,6 +140,18 @@ def _read_gh_cli_oauth_token() -> str | None:
|
|||
return token or None
|
||||
|
||||
|
||||
def _read_macos_keychain_oauth_token() -> str | None:
|
||||
"""Best-effort Copilot CLI token lookup from macOS Keychain."""
|
||||
|
||||
return read_macos_keychain_token(host=_github_host())
|
||||
|
||||
|
||||
def _read_linux_secret_oauth_token() -> str | None:
|
||||
"""Best-effort Copilot CLI token lookup from Linux Secret Service."""
|
||||
|
||||
return read_linux_secret_token(host=_github_host())
|
||||
|
||||
|
||||
def _read_windows_copilot_cli_oauth_token() -> str | None:
|
||||
if os.name != "nt":
|
||||
return None
|
||||
|
|
@ -262,19 +295,87 @@ def _iter_file_entries(payload: Any) -> list[tuple[str, dict[str, Any]]]:
|
|||
def read_cached_oauth_token() -> str | None:
|
||||
"""Return a GitHub OAuth token for Copilot, if one is available."""
|
||||
|
||||
for env_var in _OAUTH_TOKEN_ENV_VARS:
|
||||
for candidate in iter_oauth_token_candidates():
|
||||
return candidate.token
|
||||
return None
|
||||
|
||||
|
||||
def iter_oauth_token_candidates() -> list[CopilotTokenCandidate]:
|
||||
"""Return reusable token candidates in safest-first discovery order."""
|
||||
|
||||
candidates: list[CopilotTokenCandidate] = []
|
||||
|
||||
for env_var in _COPILOT_OAUTH_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(env_var, "").strip()
|
||||
if token:
|
||||
return token
|
||||
candidates.append(
|
||||
CopilotTokenCandidate(
|
||||
token=token,
|
||||
source=f"env:{env_var}",
|
||||
confidence="explicit",
|
||||
)
|
||||
)
|
||||
|
||||
windows_copilot_token = _read_windows_copilot_cli_oauth_token()
|
||||
if windows_copilot_token:
|
||||
return windows_copilot_token
|
||||
candidates.append(
|
||||
CopilotTokenCandidate(
|
||||
token=windows_copilot_token,
|
||||
source="windows-credential-manager:copilot-cli",
|
||||
confidence="high",
|
||||
)
|
||||
)
|
||||
|
||||
macos_copilot_token = _read_macos_keychain_oauth_token()
|
||||
if macos_copilot_token:
|
||||
candidates.append(
|
||||
CopilotTokenCandidate(
|
||||
token=macos_copilot_token,
|
||||
source="macos-keychain:copilot-cli",
|
||||
confidence="high",
|
||||
)
|
||||
)
|
||||
|
||||
linux_copilot_token = _read_linux_secret_oauth_token()
|
||||
if linux_copilot_token:
|
||||
candidates.append(
|
||||
CopilotTokenCandidate(
|
||||
token=linux_copilot_token,
|
||||
source="linux-secret-service:copilot-cli",
|
||||
confidence="high",
|
||||
)
|
||||
)
|
||||
|
||||
candidates.extend(_read_file_oauth_token_candidates())
|
||||
|
||||
for env_var in _GENERIC_GITHUB_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(env_var, "").strip()
|
||||
if token:
|
||||
candidates.append(
|
||||
CopilotTokenCandidate(
|
||||
token=token,
|
||||
source=f"env:{env_var}",
|
||||
confidence="generic-github",
|
||||
)
|
||||
)
|
||||
|
||||
gh_token = _read_gh_cli_oauth_token()
|
||||
if gh_token:
|
||||
return gh_token
|
||||
candidates.append(
|
||||
CopilotTokenCandidate(
|
||||
token=gh_token,
|
||||
source="gh-cli",
|
||||
confidence="generic-github",
|
||||
)
|
||||
)
|
||||
|
||||
return _dedupe_token_candidates(candidates)
|
||||
|
||||
|
||||
def _read_file_oauth_token_candidates() -> list[CopilotTokenCandidate]:
|
||||
"""Return token candidates from Copilot/GitHub credential files."""
|
||||
|
||||
candidates: list[CopilotTokenCandidate] = []
|
||||
host = _github_host()
|
||||
for path in _resolve_token_file_paths():
|
||||
try:
|
||||
|
|
@ -290,9 +391,28 @@ def read_cached_oauth_token() -> str | None:
|
|||
continue
|
||||
cached_token = _extract_oauth_token(entry)
|
||||
if cached_token:
|
||||
return cached_token
|
||||
candidates.append(
|
||||
CopilotTokenCandidate(
|
||||
token=cached_token,
|
||||
source=f"file:{path}",
|
||||
confidence="medium",
|
||||
)
|
||||
)
|
||||
|
||||
return None
|
||||
return candidates
|
||||
|
||||
|
||||
def _dedupe_token_candidates(
|
||||
candidates: list[CopilotTokenCandidate],
|
||||
) -> list[CopilotTokenCandidate]:
|
||||
seen: set[str] = set()
|
||||
deduped: list[CopilotTokenCandidate] = []
|
||||
for candidate in candidates:
|
||||
if candidate.token in seen:
|
||||
continue
|
||||
seen.add(candidate.token)
|
||||
deduped.append(candidate)
|
||||
return deduped
|
||||
|
||||
|
||||
def resolve_client_bearer_token() -> str | None:
|
||||
|
|
@ -305,6 +425,28 @@ def resolve_client_bearer_token() -> str | None:
|
|||
return read_cached_oauth_token()
|
||||
|
||||
|
||||
def resolve_subscription_bearer_token() -> str | None:
|
||||
"""Return the first discovered token that GitHub accepts for Copilot subscription APIs."""
|
||||
|
||||
for env_var in _API_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(env_var, "").strip()
|
||||
if token and _fetch_copilot_user_info(token) is not None:
|
||||
return token
|
||||
|
||||
for candidate in iter_oauth_token_candidates():
|
||||
if not candidate.validate_for_subscription:
|
||||
continue
|
||||
if _fetch_copilot_user_info(candidate.token) is not None:
|
||||
logger.debug(
|
||||
"Using Copilot subscription token from %s (%s)",
|
||||
candidate.source,
|
||||
candidate.confidence,
|
||||
)
|
||||
return candidate.token
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def has_oauth_auth() -> bool:
|
||||
"""Return True when existing Copilot auth can be reused."""
|
||||
|
||||
|
|
@ -331,6 +473,46 @@ def build_copilot_upstream_url(base_url: str, path: str) -> str:
|
|||
return f"{normalized_base}{normalized_path}"
|
||||
|
||||
|
||||
def resolve_copilot_api_url(oauth_token: str | None = None) -> str:
|
||||
"""Return the Copilot API endpoint advertised for the current OAuth token."""
|
||||
|
||||
token = (oauth_token or read_cached_oauth_token() or "").strip()
|
||||
if not token:
|
||||
return os.environ.get("GITHUB_COPILOT_API_URL", DEFAULT_API_URL).strip() or DEFAULT_API_URL
|
||||
|
||||
payload = _fetch_copilot_user_info(token)
|
||||
if payload is None:
|
||||
return os.environ.get("GITHUB_COPILOT_API_URL", DEFAULT_API_URL).strip() or DEFAULT_API_URL
|
||||
|
||||
endpoints = payload.get("endpoints") if isinstance(payload, dict) else None
|
||||
api_url = endpoints.get("api") if isinstance(endpoints, dict) else None
|
||||
if isinstance(api_url, str) and api_url.strip():
|
||||
return api_url.strip()
|
||||
return os.environ.get("GITHUB_COPILOT_API_URL", DEFAULT_API_URL).strip() or DEFAULT_API_URL
|
||||
|
||||
|
||||
def _fetch_copilot_user_info(token: str) -> dict[str, Any] | None:
|
||||
"""Fetch Copilot account metadata for a reusable OAuth-style token."""
|
||||
|
||||
token = token.strip()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
request = urllib_request.Request(_user_info_url(), headers=headers, method="GET")
|
||||
try:
|
||||
with urllib_request.urlopen(request, timeout=10.0) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except Exception as exc:
|
||||
logger.debug("Unable to resolve Copilot API URL from user info: %s", exc)
|
||||
return None
|
||||
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
class CopilotTokenProvider:
|
||||
"""Resolve and cache short-lived Copilot API tokens."""
|
||||
|
||||
|
|
@ -377,7 +559,7 @@ class CopilotTokenProvider:
|
|||
|
||||
async def _exchange_token(self, oauth_token: str) -> CopilotAPIToken:
|
||||
headers = {
|
||||
"Authorization": f"token {oauth_token}",
|
||||
"Authorization": f"Bearer {oauth_token}",
|
||||
"Accept": "application/json",
|
||||
"Editor-Version": os.environ.get(
|
||||
"GITHUB_COPILOT_EDITOR_VERSION", _DEFAULT_EDITOR_VERSION
|
||||
|
|
|
|||
106
headroom/copilot_linux_secret.py
Normal file
106
headroom/copilot_linux_secret.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""Linux secret-service lookup helpers for GitHub Copilot CLI auth."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def read_copilot_oauth_token(*, host: str = "github.com") -> str | None:
|
||||
"""Return a Copilot CLI OAuth token from Linux Secret Service, if available."""
|
||||
|
||||
if not sys.platform.startswith("linux"):
|
||||
return None
|
||||
|
||||
secret_tool = os.environ.get("GITHUB_COPILOT_SECRET_TOOL", "secret-tool").strip()
|
||||
if not secret_tool:
|
||||
return None
|
||||
|
||||
normalized_host = host.strip().lower() or "github.com"
|
||||
login = _read_copilot_config_login()
|
||||
for command in _candidate_secret_tool_commands(secret_tool, normalized_host, login):
|
||||
token = _run_secret_tool_lookup(command)
|
||||
if token:
|
||||
return token
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _read_copilot_config_login() -> str | None:
|
||||
path = Path(os.environ.get("COPILOT_HOME", str(Path.home() / ".copilot"))) / "config.json"
|
||||
try:
|
||||
lines = [
|
||||
line
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if not line.lstrip().startswith("//")
|
||||
]
|
||||
payload = json.loads("\n".join(lines))
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
user = payload.get("lastLoggedInUser")
|
||||
if not isinstance(user, dict):
|
||||
return None
|
||||
login = user.get("login")
|
||||
return login.strip() if isinstance(login, str) and login.strip() else None
|
||||
|
||||
|
||||
def _candidate_secret_tool_commands(
|
||||
secret_tool: str,
|
||||
host: str,
|
||||
login: str | None,
|
||||
) -> list[list[str]]:
|
||||
commands: list[list[str]] = []
|
||||
accounts = [
|
||||
value
|
||||
for value in (
|
||||
f"https://{host}:{login}" if login else None,
|
||||
f"{host}:{login}" if login else None,
|
||||
login,
|
||||
f"https://{host}",
|
||||
host,
|
||||
)
|
||||
if value
|
||||
]
|
||||
service_names = ("copilot-cli", "GitHub Copilot CLI", "github-copilot", "copilot")
|
||||
|
||||
for service in service_names:
|
||||
commands.append([secret_tool, "lookup", "service", service])
|
||||
commands.append([secret_tool, "lookup", "application", service])
|
||||
for account in accounts:
|
||||
commands.append([secret_tool, "lookup", "service", service, "account", account])
|
||||
commands.append([secret_tool, "lookup", "application", service, "account", account])
|
||||
commands.append([secret_tool, "lookup", "service", service, "username", account])
|
||||
|
||||
return commands
|
||||
|
||||
|
||||
def _run_secret_tool_lookup(command: list[str]) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
timeout=5,
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.debug("Unable to invoke secret-tool for Copilot auth discovery: %s", exc)
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.debug("secret-tool lookup timed out for Copilot auth")
|
||||
return None
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
token = result.stdout.strip()
|
||||
return token or None
|
||||
124
headroom/copilot_macos_keychain.py
Normal file
124
headroom/copilot_macos_keychain.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""macOS Keychain lookup helpers for GitHub Copilot CLI auth."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def read_copilot_oauth_token(*, host: str = "github.com") -> str | None:
|
||||
"""Return a Copilot CLI OAuth token from macOS Keychain, if available."""
|
||||
|
||||
if sys.platform != "darwin":
|
||||
return None
|
||||
|
||||
normalized_host = host.strip().lower() or "github.com"
|
||||
login = _read_copilot_config_login()
|
||||
services = _split_env_list("GITHUB_COPILOT_KEYCHAIN_SERVICE") or [
|
||||
"GitHub Copilot",
|
||||
"GitHub Copilot CLI",
|
||||
"github-copilot",
|
||||
"copilot",
|
||||
"copilot-cli",
|
||||
"GitHub CLI",
|
||||
"github.com",
|
||||
f"https://{normalized_host}",
|
||||
normalized_host,
|
||||
]
|
||||
accounts = _split_env_list("GITHUB_COPILOT_KEYCHAIN_ACCOUNT") or [
|
||||
value
|
||||
for value in (
|
||||
f"https://{normalized_host}:{login}" if login else None,
|
||||
f"{normalized_host}:{login}" if login else None,
|
||||
login,
|
||||
os.environ.get("USER"),
|
||||
os.environ.get("USERNAME"),
|
||||
normalized_host,
|
||||
f"https://{normalized_host}",
|
||||
)
|
||||
if value
|
||||
]
|
||||
|
||||
for command in _candidate_security_commands(normalized_host, services, accounts):
|
||||
token = _run_security_lookup(command)
|
||||
if token:
|
||||
return token
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _read_copilot_config_login() -> str | None:
|
||||
"""Return the last logged-in Copilot CLI username from ~/.copilot/config.json."""
|
||||
|
||||
path = Path(os.environ.get("COPILOT_HOME", str(Path.home() / ".copilot"))) / "config.json"
|
||||
try:
|
||||
lines = [
|
||||
line
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if not line.lstrip().startswith("//")
|
||||
]
|
||||
payload = json.loads("\n".join(lines))
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
user = payload.get("lastLoggedInUser")
|
||||
if not isinstance(user, dict):
|
||||
return None
|
||||
login = user.get("login")
|
||||
return login.strip() if isinstance(login, str) and login.strip() else None
|
||||
|
||||
|
||||
def _split_env_list(name: str) -> list[str]:
|
||||
return [part.strip() for part in os.environ.get(name, "").split(",") if part.strip()]
|
||||
|
||||
|
||||
def _candidate_security_commands(
|
||||
host: str,
|
||||
services: list[str],
|
||||
accounts: list[str],
|
||||
) -> list[list[str]]:
|
||||
commands: list[list[str]] = []
|
||||
for service in services:
|
||||
commands.append(["security", "find-generic-password", "-s", service, "-w"])
|
||||
for account in accounts:
|
||||
commands.append(
|
||||
["security", "find-generic-password", "-s", service, "-a", account, "-w"]
|
||||
)
|
||||
for server in (host, f"https://{host}"):
|
||||
commands.append(["security", "find-internet-password", "-s", server, "-w"])
|
||||
for account in accounts:
|
||||
commands.append(
|
||||
["security", "find-internet-password", "-s", server, "-a", account, "-w"]
|
||||
)
|
||||
return commands
|
||||
|
||||
|
||||
def _run_security_lookup(command: list[str]) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
timeout=5,
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.debug("Unable to invoke macOS Keychain lookup for Copilot auth: %s", exc)
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.debug("macOS Keychain lookup timed out for Copilot auth")
|
||||
return None
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
token = result.stdout.strip()
|
||||
return token or None
|
||||
|
|
@ -9,4 +9,4 @@ TEMPLATES_DIR = DASHBOARD_DIR / "templates"
|
|||
def get_dashboard_html() -> str:
|
||||
"""Load the dashboard HTML template."""
|
||||
template_path = TEMPLATES_DIR / "dashboard.html"
|
||||
return template_path.read_text()
|
||||
return template_path.read_text(encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -1238,7 +1238,7 @@
|
|||
Press <kbd class="px-1.5 py-0.5 bg-border rounded text-gray-400">R</kbd> to refresh
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://chopratejas.github.io/headroom/" target="_blank" class="hover:text-gray-300 transition-colors">Documentation</a>
|
||||
<a href="https://headroom-docs.vercel.app/docs" target="_blank" class="hover:text-gray-300 transition-colors">Documentation</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ def save_manifest(manifest: DeploymentManifest) -> None:
|
|||
root.mkdir(parents=True, exist_ok=True)
|
||||
manifest.updated_at = iso_utc_now()
|
||||
path = manifest_path(manifest.profile)
|
||||
path.write_text(json.dumps(asdict(manifest), indent=2) + "\n")
|
||||
path.write_text(json.dumps(asdict(manifest), indent=2) + "\n", encoding="utf-8")
|
||||
except OSError as e:
|
||||
logger.warning("Cannot save deployment manifest: %s — continuing without persistence", e)
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ def load_manifest(profile: str = "default") -> DeploymentManifest | None:
|
|||
path = manifest_path(profile)
|
||||
if not path.exists():
|
||||
return None
|
||||
payload = json.loads(path.read_text())
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
payload["mutations"] = [ManagedMutation(**item) for item in payload.get("mutations", [])]
|
||||
payload["artifacts"] = [ArtifactRecord(**item) for item in payload.get("artifacts", [])]
|
||||
return DeploymentManifest(**payload)
|
||||
|
|
@ -51,7 +51,7 @@ def list_manifests() -> list[DeploymentManifest]:
|
|||
manifests: list[DeploymentManifest] = []
|
||||
for candidate in sorted(root.glob("*/manifest.json")):
|
||||
try:
|
||||
payload = json.loads(candidate.read_text())
|
||||
payload = json.loads(candidate.read_text(encoding="utf-8"))
|
||||
payload["mutations"] = [
|
||||
ManagedMutation(**item) for item in payload.get("mutations", [])
|
||||
]
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
|
|||
_MODEL_DEFAULTS: list[tuple[str, str]] = [
|
||||
("ANTHROPIC_API_KEY", "claude-sonnet-4-6"),
|
||||
("OPENAI_API_KEY", "gpt-4o"),
|
||||
("GEMINI_API_KEY", "gemini/gemini-2.0-flash"),
|
||||
("GEMINI_API_KEY", "gemini/gemini-flash-latest"),
|
||||
]
|
||||
|
||||
_MAX_DIGEST_TOKENS = 80_000 # Budget for the digest (leave room for prompt + output)
|
||||
|
|
@ -94,7 +94,7 @@ def _detect_default_model() -> str:
|
|||
"No LLM API key found. headroom learn needs one of:\n"
|
||||
" export ANTHROPIC_API_KEY=sk-ant-... → uses claude-sonnet-4-6\n"
|
||||
" export OPENAI_API_KEY=sk-... → uses gpt-4o\n"
|
||||
" export GEMINI_API_KEY=... → uses gemini-2.0-flash\n"
|
||||
" export GEMINI_API_KEY=... → uses gemini-flash-latest\n"
|
||||
"Or set HEADROOM_LEARN_CLI to a coding agent CLI (claude, gemini, codex).\n"
|
||||
"Or install one of those CLIs for auto-detection.\n"
|
||||
"Or specify a model directly: headroom learn --model <litellm-model-name>"
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ def _load_sync_state(state_path: Path) -> dict[str, Any]:
|
|||
"""Load sync state from disk."""
|
||||
if state_path.exists():
|
||||
try:
|
||||
result: dict[str, Any] = json.loads(state_path.read_text())
|
||||
result: dict[str, Any] = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
return result
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
|
@ -134,7 +134,7 @@ def _load_sync_state(state_path: Path) -> dict[str, Any]:
|
|||
def _save_sync_state(state_path: Path, state: dict[str, Any]) -> None:
|
||||
"""Save sync state to disk."""
|
||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
state_path.write_text(json.dumps(state, indent=2))
|
||||
state_path.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _db_fingerprint(memories: list[Any]) -> str:
|
||||
|
|
|
|||
|
|
@ -26,14 +26,14 @@ def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation | None
|
|||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload: dict[str, object] = {}
|
||||
if path.exists():
|
||||
payload = json.loads(path.read_text())
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
env = payload.get("env")
|
||||
env_map = dict(env) if isinstance(env, dict) else {}
|
||||
values = manifest.tool_envs.get(ToolTarget.CLAUDE.value, {})
|
||||
previous = {name: env_map.get(name) for name in values}
|
||||
env_map.update(values)
|
||||
payload["env"] = env_map
|
||||
path.write_text(json.dumps(payload, indent=2) + "\n")
|
||||
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
return ManagedMutation(
|
||||
target=ToolTarget.CLAUDE.value,
|
||||
kind="json-env",
|
||||
|
|
@ -49,7 +49,7 @@ def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifes
|
|||
path = Path(mutation.path)
|
||||
if not path.exists():
|
||||
return
|
||||
payload = json.loads(path.read_text())
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
env = payload.get("env")
|
||||
env_map = dict(env) if isinstance(env, dict) else {}
|
||||
previous: dict[str, object] = mutation.data.get("previous", {})
|
||||
|
|
@ -60,4 +60,4 @@ def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifes
|
|||
else:
|
||||
env_map[name] = previous[name]
|
||||
payload["env"] = env_map
|
||||
path.write_text(json.dumps(payload, indent=2) + "\n")
|
||||
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -80,14 +80,14 @@ def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation | None
|
|||
+ f"{_CODEX_MARKER_END}\n"
|
||||
)
|
||||
if path.exists():
|
||||
existing = path.read_text()
|
||||
existing = path.read_text(encoding="utf-8")
|
||||
if _CODEX_MARKER_START in existing:
|
||||
merged = _CODEX_PATTERN.sub(section, existing)
|
||||
else:
|
||||
merged = existing.rstrip() + "\n\n" + section + "\n"
|
||||
else:
|
||||
merged = section + "\n"
|
||||
path.write_text(merged)
|
||||
path.write_text(merged, encoding="utf-8")
|
||||
return ManagedMutation(target=ToolTarget.CODEX.value, kind="toml-block", path=str(path))
|
||||
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifes
|
|||
path = Path(mutation.path)
|
||||
if not path.exists():
|
||||
return
|
||||
content = path.read_text()
|
||||
content = path.read_text(encoding="utf-8")
|
||||
# Remove the managed marker block.
|
||||
if _CODEX_MARKER_START in content:
|
||||
content = _CODEX_PATTERN.sub("", content)
|
||||
|
|
@ -108,4 +108,4 @@ def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifes
|
|||
content = _ORPHAN_MODEL_PROVIDER.sub("", content)
|
||||
content = _ORPHAN_OPENAI_BASE_URL.sub("", content)
|
||||
content = _ORPHAN_HEADROOM_TABLE.sub("", content)
|
||||
path.write_text(content.strip() + "\n")
|
||||
path.write_text(content.strip() + "\n", encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -13,8 +13,12 @@ import hashlib
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
|
@ -46,6 +50,72 @@ from headroom.proxy.outcome import RequestOutcome
|
|||
|
||||
logger = logging.getLogger("headroom.proxy")
|
||||
|
||||
_OPENAI_RESPONSES_UNIT_CACHE_MAX_ENTRIES = 10_000
|
||||
_OPENAI_RESPONSES_UNIT_CACHE_VERSION = "openai_responses_unit_v1"
|
||||
_OPENAI_RESPONSES_UNIT_PARALLELISM_ENV = "HEADROOM_TOOL_OUTPUT_COMPRESSION_PARALLELISM"
|
||||
_OPENAI_RESPONSES_UNIT_PARALLELISM_DEFAULT = 4
|
||||
_OPENAI_RESPONSES_UNIT_PARALLELISM_MAX = 16
|
||||
_OPENAI_RESPONSES_UNIT_CACHE_INIT_LOCK = threading.RLock()
|
||||
_OPENAI_RESPONSES_UNIT_EXECUTOR_LOCK = threading.RLock()
|
||||
_OPENAI_RESPONSES_UNIT_EXECUTOR: ThreadPoolExecutor | None = None
|
||||
|
||||
|
||||
def _openai_responses_unit_parallelism() -> int:
|
||||
raw = os.getenv(_OPENAI_RESPONSES_UNIT_PARALLELISM_ENV)
|
||||
if raw is None or raw.strip() == "":
|
||||
return _OPENAI_RESPONSES_UNIT_PARALLELISM_DEFAULT
|
||||
try:
|
||||
requested = int(raw)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Invalid %s=%r; using default %d",
|
||||
_OPENAI_RESPONSES_UNIT_PARALLELISM_ENV,
|
||||
raw,
|
||||
_OPENAI_RESPONSES_UNIT_PARALLELISM_DEFAULT,
|
||||
)
|
||||
return _OPENAI_RESPONSES_UNIT_PARALLELISM_DEFAULT
|
||||
return max(1, min(_OPENAI_RESPONSES_UNIT_PARALLELISM_MAX, requested))
|
||||
|
||||
|
||||
def _openai_responses_unit_executor() -> ThreadPoolExecutor:
|
||||
global _OPENAI_RESPONSES_UNIT_EXECUTOR
|
||||
with _OPENAI_RESPONSES_UNIT_EXECUTOR_LOCK:
|
||||
if _OPENAI_RESPONSES_UNIT_EXECUTOR is None:
|
||||
_OPENAI_RESPONSES_UNIT_EXECUTOR = ThreadPoolExecutor(
|
||||
max_workers=_OPENAI_RESPONSES_UNIT_PARALLELISM_MAX,
|
||||
thread_name_prefix="headroom-openai-unit",
|
||||
)
|
||||
return _OPENAI_RESPONSES_UNIT_EXECUTOR
|
||||
|
||||
|
||||
def _openai_responses_unit_cache_key(unit: Any, *, model: str) -> str:
|
||||
text_hash = hashlib.sha256(unit.text.encode("utf-8", errors="replace")).hexdigest()
|
||||
key_payload = {
|
||||
"version": _OPENAI_RESPONSES_UNIT_CACHE_VERSION,
|
||||
"model": model,
|
||||
"provider": unit.provider,
|
||||
"endpoint": unit.endpoint,
|
||||
"role": unit.role,
|
||||
"item_type": unit.item_type,
|
||||
"cache_zone": unit.cache_zone,
|
||||
"mutable": unit.mutable,
|
||||
"min_bytes": unit.min_bytes,
|
||||
"context": unit.context,
|
||||
"question": unit.question,
|
||||
"bias": unit.bias,
|
||||
"metadata": unit.metadata,
|
||||
"text_sha256": text_hash,
|
||||
}
|
||||
serialized = json.dumps(key_payload, sort_keys=True, separators=(",", ":"), default=str)
|
||||
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _openai_responses_result_with_cache_hit(result: Any) -> Any:
|
||||
router_result = getattr(result, "router_result", None)
|
||||
if router_result is None:
|
||||
return result
|
||||
return replace(result, router_result=replace(router_result, cache_hit=True))
|
||||
|
||||
|
||||
def _codex_ws_text_shape(text: str) -> str:
|
||||
stripped = text.strip()
|
||||
|
|
@ -366,6 +436,35 @@ class OpenAIHandlerMixin:
|
|||
"apply_patch_call_output",
|
||||
}
|
||||
|
||||
def _openai_responses_unit_cache(self) -> tuple[Any, OrderedDict[str, Any]]:
|
||||
with _OPENAI_RESPONSES_UNIT_CACHE_INIT_LOCK:
|
||||
lock = getattr(self, "_openai_responses_unit_cache_lock", None)
|
||||
if lock is None:
|
||||
lock = threading.RLock()
|
||||
self._openai_responses_unit_cache_lock = lock
|
||||
cache = getattr(self, "_openai_responses_unit_result_cache", None)
|
||||
if cache is None:
|
||||
cache = OrderedDict()
|
||||
self._openai_responses_unit_result_cache = cache
|
||||
return lock, cache
|
||||
|
||||
def _get_openai_responses_cached_unit(self, key: str) -> Any | None:
|
||||
lock, cache = self._openai_responses_unit_cache()
|
||||
with lock:
|
||||
result = cache.get(key)
|
||||
if result is None:
|
||||
return None
|
||||
cache.move_to_end(key)
|
||||
return _openai_responses_result_with_cache_hit(result)
|
||||
|
||||
def _store_openai_responses_cached_unit(self, key: str, result: Any) -> None:
|
||||
lock, cache = self._openai_responses_unit_cache()
|
||||
with lock:
|
||||
cache[key] = result
|
||||
cache.move_to_end(key)
|
||||
while len(cache) > _OPENAI_RESPONSES_UNIT_CACHE_MAX_ENTRIES:
|
||||
cache.popitem(last=False)
|
||||
|
||||
@staticmethod
|
||||
def _headroom_bypass_enabled(headers: Any) -> bool:
|
||||
"""Return True when inbound headers request full passthrough."""
|
||||
|
|
@ -698,18 +797,65 @@ class OpenAIHandlerMixin:
|
|||
elapsed_ms = (time.perf_counter() - unit_started) * 1000.0
|
||||
return routed.slot, result, elapsed_ms
|
||||
|
||||
# Units run serially within the frame-level worker thread. Frame-
|
||||
# level parallelism is already provided by
|
||||
# ``self._compression_executor`` (32 workers, sized
|
||||
# ``min(32, cpu*4)``), which `_run_compression_in_executor`
|
||||
# dispatches each frame onto. The prior per-call
|
||||
# ``ThreadPoolExecutor`` + module-global
|
||||
# ``threading.BoundedSemaphore(10)`` caused production cascades
|
||||
# under ≥10 concurrent Codex sessions; both are deleted.
|
||||
router_total_started = time.perf_counter()
|
||||
routed_results = [_compress_routed_unit(routed) for routed in routed_units]
|
||||
routed_results: list[tuple[object, Any, float] | None] = [None] * len(routed_units)
|
||||
cache_misses: list[tuple[int, str, RoutedCompressionUnit]] = []
|
||||
cache_miss_followers: dict[str, list[int]] = {}
|
||||
for unit_idx, routed in enumerate(routed_units):
|
||||
cache_key = _openai_responses_unit_cache_key(routed.unit, model=model)
|
||||
cached = self._get_openai_responses_cached_unit(cache_key)
|
||||
if cached is not None:
|
||||
routed_results[unit_idx] = (routed.slot, cached, 0.0)
|
||||
continue
|
||||
if cache_key in cache_miss_followers:
|
||||
cache_miss_followers[cache_key].append(unit_idx)
|
||||
continue
|
||||
cache_miss_followers[cache_key] = []
|
||||
cache_misses.append((unit_idx, cache_key, routed))
|
||||
|
||||
for _, result, elapsed_ms in routed_results:
|
||||
def _compress_and_store(
|
||||
unit_idx: int,
|
||||
cache_key: str,
|
||||
routed: RoutedCompressionUnit,
|
||||
) -> tuple[int, str, tuple[object, Any, float]]:
|
||||
slot, result, elapsed_ms = _compress_routed_unit(routed)
|
||||
self._store_openai_responses_cached_unit(cache_key, result)
|
||||
return unit_idx, cache_key, (slot, result, elapsed_ms)
|
||||
|
||||
def _record_routed_result(
|
||||
unit_idx: int,
|
||||
cache_key: str,
|
||||
routed_result: tuple[object, Any, float],
|
||||
) -> None:
|
||||
routed_results[unit_idx] = routed_result
|
||||
_slot, result, _elapsed_ms = routed_result
|
||||
for follower_idx in cache_miss_followers.get(cache_key, []):
|
||||
routed_results[follower_idx] = (
|
||||
routed_units[follower_idx].slot,
|
||||
_openai_responses_result_with_cache_hit(result),
|
||||
0.0,
|
||||
)
|
||||
|
||||
parallelism = _openai_responses_unit_parallelism()
|
||||
if len(cache_misses) > 1 and parallelism > 1:
|
||||
executor = _openai_responses_unit_executor()
|
||||
for start in range(0, len(cache_misses), parallelism):
|
||||
batch = cache_misses[start : start + parallelism]
|
||||
futures = [executor.submit(_compress_and_store, *item) for item in batch]
|
||||
for future in as_completed(futures):
|
||||
unit_idx, cache_key, routed_result = future.result()
|
||||
_record_routed_result(unit_idx, cache_key, routed_result)
|
||||
else:
|
||||
for unit_idx, cache_key, routed in cache_misses:
|
||||
_record_routed_result(
|
||||
unit_idx,
|
||||
cache_key,
|
||||
_compress_and_store(unit_idx, cache_key, routed)[2],
|
||||
)
|
||||
|
||||
ordered_routed_results = [result for result in routed_results if result is not None]
|
||||
|
||||
for _, result, elapsed_ms in ordered_routed_results:
|
||||
router_chain = list(result.router_result.strategy_chain) if result.router_result else []
|
||||
router_content_type = (
|
||||
result.router_result.routing_log[0].content_type.value
|
||||
|
|
@ -766,7 +912,7 @@ class OpenAIHandlerMixin:
|
|||
_add_timing("compression_units_router_loop", router_total_started)
|
||||
|
||||
apply_started = time.perf_counter()
|
||||
for slot, result, _elapsed_ms in routed_results:
|
||||
for slot, result, _elapsed_ms in ordered_routed_results:
|
||||
item_idx, slot_ref = slot
|
||||
router_chain = list(result.router_result.strategy_chain) if result.router_result else []
|
||||
for s in router_chain:
|
||||
|
|
|
|||
|
|
@ -1666,6 +1666,10 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
|
|||
"memory": config.memory_enabled,
|
||||
"learn": config.traffic_learning_enabled,
|
||||
"code_graph": config.code_graph_watcher,
|
||||
"anthropic_api_url": config.anthropic_api_url,
|
||||
"openai_api_url": config.openai_api_url,
|
||||
"gemini_api_url": config.gemini_api_url,
|
||||
"cloudcode_api_url": config.cloudcode_api_url,
|
||||
"pid": os.getpid(),
|
||||
}
|
||||
return payload
|
||||
|
|
|
|||
|
|
@ -357,7 +357,9 @@ class UsageReporter:
|
|||
return
|
||||
try:
|
||||
self._cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._cache_path.write_text(json.dumps(self._license_info.to_dict(), indent=2))
|
||||
self._cache_path.write_text(
|
||||
json.dumps(self._license_info.to_dict(), indent=2), encoding="utf-8"
|
||||
)
|
||||
except OSError:
|
||||
logger.warning("Could not save license cache to %s", self._cache_path)
|
||||
|
||||
|
|
@ -365,7 +367,7 @@ class UsageReporter:
|
|||
"""Load cached license info, or return a default if expired/missing."""
|
||||
try:
|
||||
if self._cache_path.exists():
|
||||
data = json.loads(self._cache_path.read_text())
|
||||
data = json.loads(self._cache_path.read_text(encoding="utf-8"))
|
||||
cached = LicenseInfo.from_dict(data)
|
||||
age = (datetime.now(timezone.utc) - cached.validated_at).total_seconds()
|
||||
if age < GRACE_PERIOD_SECONDS:
|
||||
|
|
|
|||
|
|
@ -97,13 +97,22 @@ def get_encoding_for_model(model: str) -> str:
|
|||
if model in MODEL_TO_ENCODING:
|
||||
return MODEL_TO_ENCODING[model]
|
||||
|
||||
# Try prefix matching for versioned models
|
||||
for prefix in ["gpt-4o", "gpt-4-turbo", "gpt-4", "gpt-3.5", "o1", "o3"]:
|
||||
# Try prefix matching for versioned models. Ordered most-specific first
|
||||
# so that, e.g., "gpt-4o-*" resolves before "gpt-4-*". Each prefix maps
|
||||
# directly to its encoding: scanning MODEL_TO_ENCODING for the first key
|
||||
# that merely starts with the prefix is order-dependent and wrong — the
|
||||
# "gpt-4" prefix would match the "gpt-4o" dict entry first and return
|
||||
# o200k_base instead of cl100k_base for unknown gpt-4 snapshots.
|
||||
for prefix, encoding in (
|
||||
("gpt-4o", "o200k_base"),
|
||||
("gpt-4-turbo", "cl100k_base"),
|
||||
("gpt-4", "cl100k_base"),
|
||||
("gpt-3.5", "cl100k_base"),
|
||||
("o1", "o200k_base"),
|
||||
("o3", "o200k_base"),
|
||||
):
|
||||
if model.startswith(prefix):
|
||||
# Find any model with this prefix
|
||||
for known_model, encoding in MODEL_TO_ENCODING.items():
|
||||
if known_model.startswith(prefix):
|
||||
return encoding
|
||||
return encoding
|
||||
|
||||
return DEFAULT_ENCODING
|
||||
|
||||
|
|
|
|||
|
|
@ -658,13 +658,38 @@ def _extract_json_block(lines: list[str], start: int) -> tuple[str | None, int]:
|
|||
bracket_count = 0
|
||||
brace_count = 0
|
||||
json_lines = []
|
||||
in_string = False
|
||||
escaped = False
|
||||
|
||||
for i in range(start, len(lines)):
|
||||
line = lines[i]
|
||||
json_lines.append(line)
|
||||
|
||||
bracket_count += line.count("[") - line.count("]")
|
||||
brace_count += line.count("{") - line.count("}")
|
||||
# Count brackets/braces, but ignore any that appear inside a JSON
|
||||
# string literal — a naive line.count() treats e.g. the "]" in
|
||||
# {"path": "a]b"} as a closing bracket and terminates the block
|
||||
# early, splitting one array across multiple sections.
|
||||
for ch in line:
|
||||
if escaped:
|
||||
escaped = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
if in_string:
|
||||
escaped = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == "[":
|
||||
bracket_count += 1
|
||||
elif ch == "]":
|
||||
bracket_count -= 1
|
||||
elif ch == "{":
|
||||
brace_count += 1
|
||||
elif ch == "}":
|
||||
brace_count -= 1
|
||||
|
||||
if bracket_count <= 0 and brace_count <= 0 and json_lines:
|
||||
return "\n".join(json_lines), i
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
site_name: Headroom
|
||||
docs_dir: wiki
|
||||
site_description: "The Context Optimization Layer for LLM Applications — compress everything your AI agent reads."
|
||||
site_url: https://chopratejas.github.io/headroom
|
||||
repo_url: https://github.com/chopratejas/headroom
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "headroom-openclaw",
|
||||
"version": "0.22.4",
|
||||
"version": "0.23.0",
|
||||
"description": "Headroom context compression plugin for OpenClaw — 70-90% token savings with zero LLM calls",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "maturin"
|
|||
|
||||
[project]
|
||||
name = "headroom-ai"
|
||||
version = "0.22.4"
|
||||
version = "0.23.0"
|
||||
description = "The Context Optimization Layer for LLM Applications - Cut costs by 50-90%"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "headroom-ai",
|
||||
"version": "0.22.4",
|
||||
"version": "0.23.0",
|
||||
"description": "Compress LLM context. Save tokens. Fit more into every request.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
|
@ -212,8 +213,145 @@ def test_wrap_copilot_prefers_existing_oauth_session(
|
|||
assert env["COPILOT_PROVIDER_BASE_URL"] == "http://127.0.0.1:8787/v1"
|
||||
assert env["COPILOT_PROVIDER_WIRE_API"] == "completions"
|
||||
assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "gho-existing"
|
||||
assert env["GITHUB_COPILOT_API_URL"] == DEFAULT_API_URL
|
||||
assert env["OPENAI_TARGET_API_URL"] == DEFAULT_API_URL
|
||||
assert "COPILOT_PROVIDER_API_KEY" not in env
|
||||
assert captured["openai_api_url"] == DEFAULT_API_URL
|
||||
assert f"COPILOT_PROVIDER_API_URL={DEFAULT_API_URL}" in captured["env_vars_display"]
|
||||
|
||||
|
||||
def test_wrap_copilot_subscription_uses_github_auth_without_provider_key(
|
||||
runner: CliRunner,
|
||||
wrap_modules: tuple[types.ModuleType, click.Group],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_wrap_cli, main = wrap_modules
|
||||
for var in ("COPILOT_PROVIDER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_launch_tool(**kwargs): # noqa: ANN003
|
||||
captured.update(kwargs)
|
||||
|
||||
with (
|
||||
patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
|
||||
patch("headroom.cli.wrap.resolve_subscription_bearer_token", return_value="gho-existing"),
|
||||
patch("headroom.cli.wrap.has_oauth_auth", return_value=False),
|
||||
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
|
||||
):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["wrap", "copilot", "--subscription", "--no-rtk"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Copilot BYOK requires a model" not in result.output
|
||||
env = captured["env"]
|
||||
assert isinstance(env, dict)
|
||||
assert env["COPILOT_PROVIDER_TYPE"] == "openai"
|
||||
assert env["COPILOT_PROVIDER_BASE_URL"] == "http://127.0.0.1:8787/v1"
|
||||
assert env["COPILOT_PROVIDER_WIRE_API"] == "completions"
|
||||
assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "gho-existing"
|
||||
assert "COPILOT_PROVIDER_API_KEY" not in env
|
||||
assert captured["openai_api_url"] == DEFAULT_API_URL
|
||||
|
||||
|
||||
def test_wrap_copilot_subscription_pins_validated_token_for_proxy(
|
||||
runner: CliRunner,
|
||||
wrap_modules: tuple[types.ModuleType, click.Group],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`--subscription` must hand the *validated* token to the proxy.
|
||||
|
||||
The proxy honours ``GITHUB_COPILOT_API_TOKEN``; the wrapper passes the
|
||||
resolved token as the ``copilot_api_token`` launch argument so the proxy
|
||||
pins exactly it (rather than re-discovering a possibly different,
|
||||
unvalidated token). The token rides the launch arg, never the child env or
|
||||
the parent's global ``os.environ``. This guards the deterministic handoff.
|
||||
"""
|
||||
_wrap_cli, main = wrap_modules
|
||||
for var in ("COPILOT_PROVIDER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
business_api = "https://api.business.githubcopilot.com"
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_launch_tool(**kwargs: object) -> None:
|
||||
captured.update(kwargs)
|
||||
|
||||
with (
|
||||
patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
|
||||
patch(
|
||||
"headroom.cli.wrap.resolve_subscription_bearer_token",
|
||||
return_value="gho-validated",
|
||||
),
|
||||
patch("headroom.cli.wrap.resolve_copilot_api_url", return_value=business_api),
|
||||
patch("headroom.cli.wrap.has_oauth_auth", return_value=False),
|
||||
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
|
||||
):
|
||||
result = runner.invoke(main, ["wrap", "copilot", "--subscription", "--no-rtk"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
env = captured["env"]
|
||||
assert isinstance(env, dict)
|
||||
# The validated token is handed to the proxy as an explicit launch
|
||||
# argument — not via the child env, not via the parent's os.environ.
|
||||
assert captured["copilot_api_token"] == "gho-validated"
|
||||
assert "GITHUB_COPILOT_API_TOKEN" not in env
|
||||
assert os.environ.get("GITHUB_COPILOT_API_TOKEN") is None
|
||||
assert env["COPILOT_PROVIDER_TYPE"] == "openai"
|
||||
assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "gho-validated"
|
||||
assert env["GITHUB_COPILOT_USE_TOKEN_EXCHANGE"] == "false"
|
||||
assert env["OPENAI_TARGET_API_URL"] == business_api
|
||||
assert captured["openai_api_url"] == business_api
|
||||
assert "COPILOT_PROVIDER_API_KEY" not in env
|
||||
# The secret must never be echoed to the terminal.
|
||||
assert "gho-validated" not in result.output
|
||||
|
||||
|
||||
def test_wrap_copilot_subscription_requires_reusable_auth(
|
||||
runner: CliRunner,
|
||||
wrap_modules: tuple[types.ModuleType, click.Group],
|
||||
) -> None:
|
||||
_wrap_cli, main = wrap_modules
|
||||
with (
|
||||
patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
|
||||
patch("headroom.cli.wrap.resolve_subscription_bearer_token", return_value=None),
|
||||
):
|
||||
result = runner.invoke(main, ["wrap", "copilot", "--subscription", "--no-rtk"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "subscription mode requires a reusable GitHub/Copilot bearer token" in result.output
|
||||
|
||||
|
||||
def test_wrap_copilot_subscription_rejects_translated_backend(
|
||||
runner: CliRunner,
|
||||
wrap_modules: tuple[types.ModuleType, click.Group],
|
||||
) -> None:
|
||||
_wrap_cli, main = wrap_modules
|
||||
with patch("headroom.cli.wrap.shutil.which", return_value="copilot"):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["wrap", "copilot", "--subscription", "--backend", "anyllm", "--no-rtk"],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "cannot be combined with translated backends" in result.output
|
||||
|
||||
|
||||
def test_wrap_copilot_subscription_rejects_anthropic_provider_type(
|
||||
runner: CliRunner,
|
||||
wrap_modules: tuple[types.ModuleType, click.Group],
|
||||
) -> None:
|
||||
_wrap_cli, main = wrap_modules
|
||||
with patch("headroom.cli.wrap.shutil.which", return_value="copilot"):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["wrap", "copilot", "--subscription", "--provider-type", "anthropic", "--no-rtk"],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "do not combine it with --provider-type anthropic" in result.output
|
||||
|
||||
|
||||
def test_wrap_copilot_translated_backend_still_requires_byok(
|
||||
|
|
|
|||
|
|
@ -206,6 +206,46 @@ def test_ensure_proxy_restarts_idle_stale_ephemeral_proxy(monkeypatch) -> None:
|
|||
assert calls[1][0] == "start"
|
||||
|
||||
|
||||
def test_ensure_proxy_restarts_ephemeral_proxy_for_openai_api_url_mismatch(monkeypatch) -> None:
|
||||
calls: list[object] = []
|
||||
health = {
|
||||
"version": wrap_cli._HEADROOM_VERSION,
|
||||
"runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}},
|
||||
"config": {
|
||||
"pid": "12345",
|
||||
"memory": False,
|
||||
"learn": False,
|
||||
"code_graph": False,
|
||||
"openai_api_url": "https://api.githubcopilot.com",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
|
||||
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: len(calls) == 0)
|
||||
monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health)
|
||||
monkeypatch.setattr(
|
||||
wrap_cli,
|
||||
"_kill_proxy_by_pid",
|
||||
lambda pid, port: calls.append(("kill", pid, port)) or True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wrap_cli,
|
||||
"_start_proxy",
|
||||
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
|
||||
)
|
||||
|
||||
result = wrap_cli._ensure_proxy(
|
||||
8787,
|
||||
False,
|
||||
openai_api_url="https://api.individual.githubcopilot.com",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert calls[0] == ("kill", 12345, 8787)
|
||||
assert calls[1][0] == "start"
|
||||
assert calls[1][2]["openai_api_url"] == "https://api.individual.githubcopilot.com"
|
||||
|
||||
|
||||
def test_ensure_proxy_leaves_active_stale_ephemeral_proxy_running(monkeypatch) -> None:
|
||||
health = {
|
||||
"version": "0.0.1",
|
||||
|
|
|
|||
|
|
@ -245,7 +245,9 @@ def test_concurrent_compression_has_no_semaphore_tail() -> None:
|
|||
(≈27×) or (b) OS-level scheduler noise (≈2–3×). 4× sits
|
||||
comfortably between the two — catches the bug, tolerates
|
||||
hardware. (First iteration tried 5× with mixed sizes, which
|
||||
let size-variance push CI ratios to 7.4×.)
|
||||
let size-variance push CI ratios to 7.4×.) The ratio is only
|
||||
enforced once p99 clears a scheduler-noise floor — on very fast
|
||||
runners p50 rounds to 0ms and the ratio becomes pure jitter.
|
||||
|
||||
Marked ``slow`` so a normal ``pytest`` run can skip it via
|
||||
``-m 'not slow'``. CI matrix runs all marks.
|
||||
|
|
@ -300,9 +302,20 @@ def test_concurrent_compression_has_no_semaphore_tail() -> None:
|
|||
|
||||
assert not errors, f"Got {len(errors)} errors; first: {errors[0].error}"
|
||||
ratio = p99 / max(p50, 1)
|
||||
assert ratio < 4.0, (
|
||||
# The p99/p50 ratio only signals contention when the tail is also
|
||||
# *absolutely* large. On a fast/quiet runner p50 rounds toward 0ms, so the
|
||||
# ratio collapses to "p99 in ms" and a few milliseconds of ordinary
|
||||
# scheduler jitter reads as a spurious multiple (e.g. p50=0ms, p99=5ms →
|
||||
# ~5×) that has nothing to do with the semaphore. The deleted semaphore
|
||||
# produced a tail of *tens* of milliseconds (and ~27×); a healthy run keeps
|
||||
# p99 in the single-digit-ms range regardless of ratio. So only treat a high
|
||||
# ratio as a regression once p99 clears a scheduler-noise floor.
|
||||
SEMAPHORE_TAIL_FLOOR_MS = 25.0
|
||||
assert ratio < 4.0 or p99 < SEMAPHORE_TAIL_FLOOR_MS, (
|
||||
f"p99/p50 ratio is {ratio:.1f}× (p50={p50:.0f}ms, p99={p99:.0f}ms). "
|
||||
f"Expected < 4× on uniform-size workload — a higher ratio means "
|
||||
f"the semaphore-induced contention tail is back. Pre-fix baseline "
|
||||
f"ratio on this same workload shape was ~27× regardless of CPU speed."
|
||||
f"Expected < 4× on uniform-size workload once p99 clears the "
|
||||
f"{SEMAPHORE_TAIL_FLOOR_MS:.0f}ms noise floor — a high ratio with a large "
|
||||
f"absolute tail means the semaphore-induced contention tail is back. "
|
||||
f"Pre-fix baseline ratio on this same workload shape was ~27× regardless "
|
||||
f"of CPU speed."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,74 @@ def test_read_cached_oauth_token_prefers_env(monkeypatch: pytest.MonkeyPatch) ->
|
|||
assert copilot_auth.read_cached_oauth_token() == "gho-env"
|
||||
|
||||
|
||||
def test_read_cached_oauth_token_prefers_copilot_cli_before_generic_github_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
||||
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp-generic")
|
||||
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
||||
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: "gho-keychain")
|
||||
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
|
||||
|
||||
assert copilot_auth.read_cached_oauth_token() == "gho-keychain"
|
||||
|
||||
|
||||
def test_iter_oauth_token_candidates_preserves_sources(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
||||
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp-generic")
|
||||
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
||||
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: "gho-keychain")
|
||||
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
|
||||
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
|
||||
|
||||
candidates = copilot_auth.iter_oauth_token_candidates()
|
||||
|
||||
assert [(candidate.source, candidate.token) for candidate in candidates] == [
|
||||
("macos-keychain:copilot-cli", "gho-keychain"),
|
||||
("env:GITHUB_TOKEN", "ghp-generic"),
|
||||
]
|
||||
|
||||
|
||||
def test_resolve_subscription_bearer_token_skips_invalid_generic_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("GITHUB_COPILOT_API_TOKEN", raising=False)
|
||||
monkeypatch.delenv("COPILOT_PROVIDER_BEARER_TOKEN", raising=False)
|
||||
monkeypatch.setattr(
|
||||
copilot_auth,
|
||||
"iter_oauth_token_candidates",
|
||||
lambda: [
|
||||
copilot_auth.CopilotTokenCandidate(
|
||||
token="ghp-generic",
|
||||
source="env:GITHUB_TOKEN",
|
||||
confidence="generic-github",
|
||||
),
|
||||
copilot_auth.CopilotTokenCandidate(
|
||||
token="gho-copilot",
|
||||
source="macos-keychain:copilot-cli",
|
||||
confidence="high",
|
||||
),
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
copilot_auth,
|
||||
"_fetch_copilot_user_info",
|
||||
lambda token: (
|
||||
{"endpoints": {"api": "https://api.individual.githubcopilot.com"}}
|
||||
if token == "gho-copilot"
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
assert copilot_auth.resolve_subscription_bearer_token() == "gho-copilot"
|
||||
|
||||
|
||||
def test_should_exchange_oauth_token_supports_truthy_values(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
@ -59,6 +127,7 @@ def test_read_cached_oauth_token_falls_back_to_gh_cli(monkeypatch: pytest.Monkey
|
|||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
||||
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: None)
|
||||
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: "gho-gh-cli")
|
||||
|
||||
assert copilot_auth.read_cached_oauth_token() == "gho-gh-cli"
|
||||
|
|
@ -79,6 +148,35 @@ def test_read_cached_oauth_token_prefers_copilot_cli_windows_token(
|
|||
assert copilot_auth.read_cached_oauth_token() == "gho-copilot"
|
||||
|
||||
|
||||
def test_read_cached_oauth_token_prefers_macos_keychain_before_gh(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
||||
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
||||
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: "gho-keychain")
|
||||
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: "gho-gh-cli")
|
||||
|
||||
assert copilot_auth.read_cached_oauth_token() == "gho-keychain"
|
||||
|
||||
|
||||
def test_read_macos_keychain_oauth_token_uses_security(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_read(*, host: str) -> str:
|
||||
calls.append(host)
|
||||
return "gho-keychain"
|
||||
|
||||
monkeypatch.setattr(copilot_auth, "read_macos_keychain_token", fake_read)
|
||||
assert copilot_auth._read_macos_keychain_oauth_token() == "gho-keychain"
|
||||
assert calls == ["github.com"]
|
||||
|
||||
|
||||
def test_read_cached_oauth_token_reads_hosts_file(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
|
|
@ -97,6 +195,7 @@ def test_read_cached_oauth_token_reads_hosts_file(
|
|||
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_FILE", str(hosts))
|
||||
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
||||
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: None)
|
||||
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
|
||||
|
||||
assert copilot_auth.read_cached_oauth_token() == "gho-file"
|
||||
|
|
@ -112,6 +211,7 @@ def test_read_cached_oauth_token_skips_expired_entries(
|
|||
)
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_FILE", str(hosts))
|
||||
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
||||
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: None)
|
||||
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
|
||||
|
||||
assert copilot_auth.read_cached_oauth_token() is None
|
||||
|
|
|
|||
68
tests/test_copilot_linux_secret.py
Normal file
68
tests/test_copilot_linux_secret.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from headroom import copilot_linux_secret
|
||||
|
||||
|
||||
def test_read_copilot_oauth_token_uses_secret_tool(monkeypatch) -> None:
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake_run(command, **kwargs): # noqa: ANN001, ANN003
|
||||
calls.append(command)
|
||||
assert kwargs["capture_output"] is True
|
||||
return SimpleNamespace(returncode=0, stdout="gho-secret\n")
|
||||
|
||||
monkeypatch.setattr(copilot_linux_secret.sys, "platform", "linux")
|
||||
monkeypatch.setattr(copilot_linux_secret, "_read_copilot_config_login", lambda: "octo")
|
||||
monkeypatch.setattr(copilot_linux_secret.subprocess, "run", fake_run)
|
||||
|
||||
assert copilot_linux_secret.read_copilot_oauth_token(host="github.com") == "gho-secret"
|
||||
assert calls[0] == ["secret-tool", "lookup", "service", "copilot-cli"]
|
||||
|
||||
|
||||
def test_read_copilot_oauth_token_returns_none_off_linux(monkeypatch) -> None:
|
||||
monkeypatch.setattr(copilot_linux_secret.sys, "platform", "darwin")
|
||||
|
||||
assert copilot_linux_secret.read_copilot_oauth_token() is None
|
||||
|
||||
|
||||
def test_candidate_secret_tool_commands_include_login_specific_lookup() -> None:
|
||||
commands = copilot_linux_secret._candidate_secret_tool_commands(
|
||||
"secret-tool",
|
||||
"github.com",
|
||||
"octo",
|
||||
)
|
||||
|
||||
assert [
|
||||
"secret-tool",
|
||||
"lookup",
|
||||
"service",
|
||||
"copilot-cli",
|
||||
"account",
|
||||
"https://github.com:octo",
|
||||
] in commands
|
||||
|
||||
|
||||
def test_read_copilot_oauth_token_tries_until_match(monkeypatch) -> None:
|
||||
expected = [
|
||||
"secret-tool",
|
||||
"lookup",
|
||||
"service",
|
||||
"copilot-cli",
|
||||
"account",
|
||||
"https://github.com:octo",
|
||||
]
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake_run(command, **kwargs): # noqa: ANN001, ANN003
|
||||
calls.append(command)
|
||||
stdout = "gho-secret\n" if command == expected else ""
|
||||
return SimpleNamespace(returncode=0, stdout=stdout)
|
||||
|
||||
monkeypatch.setattr(copilot_linux_secret.sys, "platform", "linux")
|
||||
monkeypatch.setattr(copilot_linux_secret, "_read_copilot_config_login", lambda: "octo")
|
||||
monkeypatch.setattr(copilot_linux_secret.subprocess, "run", fake_run)
|
||||
|
||||
assert copilot_linux_secret.read_copilot_oauth_token(host="github.com") == "gho-secret"
|
||||
assert expected in calls
|
||||
90
tests/test_copilot_macos_keychain.py
Normal file
90
tests/test_copilot_macos_keychain.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from headroom import copilot_macos_keychain
|
||||
|
||||
|
||||
def test_read_copilot_oauth_token_uses_security(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake_run(command: list[str], **kwargs: object) -> SimpleNamespace:
|
||||
calls.append(command)
|
||||
assert kwargs["capture_output"] is True
|
||||
assert kwargs["timeout"] == 5
|
||||
return SimpleNamespace(returncode=0, stdout="gho-keychain\n")
|
||||
|
||||
monkeypatch.setattr(copilot_macos_keychain.sys, "platform", "darwin")
|
||||
monkeypatch.setenv("GITHUB_COPILOT_KEYCHAIN_SERVICE", "GitHub Copilot")
|
||||
monkeypatch.setenv("GITHUB_COPILOT_KEYCHAIN_ACCOUNT", "chopratejas")
|
||||
monkeypatch.setattr(copilot_macos_keychain.subprocess, "run", fake_run)
|
||||
|
||||
assert copilot_macos_keychain.read_copilot_oauth_token(host="github.com") == "gho-keychain"
|
||||
assert calls[0] == ["security", "find-generic-password", "-s", "GitHub Copilot", "-w"]
|
||||
|
||||
|
||||
def test_read_copilot_oauth_token_returns_none_off_macos(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(copilot_macos_keychain.sys, "platform", "linux")
|
||||
|
||||
assert copilot_macos_keychain.read_copilot_oauth_token() is None
|
||||
|
||||
|
||||
def test_candidate_security_commands_include_account_specific_lookup() -> None:
|
||||
commands = copilot_macos_keychain._candidate_security_commands(
|
||||
"github.com",
|
||||
["GitHub Copilot"],
|
||||
["chopratejas"],
|
||||
)
|
||||
|
||||
assert ["security", "find-generic-password", "-s", "GitHub Copilot", "-w"] in commands
|
||||
assert [
|
||||
"security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
"GitHub Copilot",
|
||||
"-a",
|
||||
"chopratejas",
|
||||
"-w",
|
||||
] in commands
|
||||
|
||||
|
||||
def test_read_copilot_oauth_token_tries_copilot_cli_host_login_account(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
calls: list[list[str]] = []
|
||||
copilot_home = tmp_path / ".copilot"
|
||||
copilot_home.mkdir()
|
||||
(copilot_home / "config.json").write_text(
|
||||
'{"lastLoggedInUser":{"host":"https://github.com","login":"chopratejas"}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fake_run(command: list[str], **kwargs: object) -> object:
|
||||
calls.append(command)
|
||||
stdout = "gho-keychain\n" if command == expected else ""
|
||||
return type("CompletedProcess", (), {"returncode": 0 if stdout else 44, "stdout": stdout})()
|
||||
|
||||
expected = [
|
||||
"security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
"copilot-cli",
|
||||
"-a",
|
||||
"https://github.com:chopratejas",
|
||||
"-w",
|
||||
]
|
||||
monkeypatch.setattr(copilot_macos_keychain.sys, "platform", "darwin")
|
||||
monkeypatch.setenv("COPILOT_HOME", str(copilot_home))
|
||||
monkeypatch.delenv("GITHUB_COPILOT_KEYCHAIN_SERVICE", raising=False)
|
||||
monkeypatch.delenv("GITHUB_COPILOT_KEYCHAIN_ACCOUNT", raising=False)
|
||||
monkeypatch.setattr(copilot_macos_keychain.subprocess, "run", fake_run)
|
||||
|
||||
assert copilot_macos_keychain.read_copilot_oauth_token(host="github.com") == "gho-keychain"
|
||||
assert expected in calls
|
||||
199
tests/test_copilot_subscription_smoke.py
Normal file
199
tests/test_copilot_subscription_smoke.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
"""Cross-platform smoke test for GitHub Copilot subscription routing.
|
||||
|
||||
The subscription flow has to behave identically on macOS, Linux, and Windows
|
||||
(and in headless Docker/CI), but the only OS-specific part — reading the
|
||||
Copilot CLI token from the platform secret store — is impossible to exercise
|
||||
portably. This suite proves the *portable* contract instead:
|
||||
|
||||
1. With an explicit token in the environment, resolution + API-URL discovery
|
||||
succeed on every platform without touching any secret store. This is the
|
||||
universal escape hatch (``GITHUB_COPILOT_TOKEN`` etc.) that makes the
|
||||
feature work anywhere, including headless CI.
|
||||
2. Each OS-specific secret reader is inert on a foreign platform — so on any
|
||||
given OS only that OS's reader can fire, and a missing/foreign secret store
|
||||
degrades to ``None`` rather than crashing.
|
||||
3. The proxy injects exactly the token the wrapper validated (the
|
||||
deterministic-handoff fix), never a different discoverable one.
|
||||
4. The full wrapper→proxy chain carries one consistent token end to end.
|
||||
|
||||
Everything here is hermetic: no Keychain, no ``secret-tool``, no Credential
|
||||
Manager, no network. It runs the same on every OS.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from headroom import copilot_auth, copilot_linux_secret, copilot_macos_keychain
|
||||
|
||||
BUSINESS_API = "https://api.business.githubcopilot.com"
|
||||
|
||||
|
||||
def _stub_all_secret_stores(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Simulate 'no OS secret store / not logged in' on every platform."""
|
||||
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
||||
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: None)
|
||||
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: None)
|
||||
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
|
||||
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
|
||||
|
||||
|
||||
def _clear_token_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for var in (
|
||||
*copilot_auth._COPILOT_OAUTH_TOKEN_ENV_VARS,
|
||||
*copilot_auth._GENERIC_GITHUB_TOKEN_ENV_VARS,
|
||||
*copilot_auth._API_TOKEN_ENV_VARS,
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The env-var path resolves on any platform with no secret store.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_env_token_resolves_subscription_without_secret_store(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_stub_all_secret_stores(monkeypatch)
|
||||
_clear_token_env(monkeypatch)
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-env-universal")
|
||||
monkeypatch.setattr(
|
||||
copilot_auth,
|
||||
"_fetch_copilot_user_info",
|
||||
lambda token: (
|
||||
{"endpoints": {"api": BUSINESS_API}} if token == "gho-env-universal" else None
|
||||
),
|
||||
)
|
||||
|
||||
assert copilot_auth.resolve_subscription_bearer_token() == "gho-env-universal"
|
||||
assert copilot_auth.resolve_copilot_api_url("gho-env-universal") == BUSINESS_API
|
||||
|
||||
|
||||
def test_api_url_falls_back_to_default_when_user_info_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_clear_token_env(monkeypatch)
|
||||
monkeypatch.delenv("GITHUB_COPILOT_API_URL", raising=False)
|
||||
monkeypatch.setattr(copilot_auth, "_fetch_copilot_user_info", lambda token: None)
|
||||
|
||||
# No network / no endpoints advertised → safe default, never a crash.
|
||||
assert copilot_auth.resolve_copilot_api_url("gho-anything") == copilot_auth.DEFAULT_API_URL
|
||||
|
||||
|
||||
def test_subscription_rejects_token_github_does_not_accept(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_stub_all_secret_stores(monkeypatch)
|
||||
_clear_token_env(monkeypatch)
|
||||
# A generic GitHub token is present but GitHub's Copilot API rejects it;
|
||||
# a valid Copilot token is discoverable behind it.
|
||||
monkeypatch.setattr(
|
||||
copilot_auth,
|
||||
"iter_oauth_token_candidates",
|
||||
lambda: [
|
||||
copilot_auth.CopilotTokenCandidate(
|
||||
token="ghp-generic-pat", source="env:GITHUB_TOKEN", confidence="generic-github"
|
||||
),
|
||||
copilot_auth.CopilotTokenCandidate(
|
||||
token="gho-real-copilot",
|
||||
source="macos-keychain:copilot-cli",
|
||||
confidence="high",
|
||||
),
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
copilot_auth,
|
||||
"_fetch_copilot_user_info",
|
||||
lambda token: {"endpoints": {"api": BUSINESS_API}} if token == "gho-real-copilot" else None,
|
||||
)
|
||||
|
||||
assert copilot_auth.resolve_subscription_bearer_token() == "gho-real-copilot"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Each OS reader is inert on a foreign platform.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize("foreign_platform", ["linux", "win32"])
|
||||
def test_macos_reader_noop_off_darwin(
|
||||
monkeypatch: pytest.MonkeyPatch, foreign_platform: str
|
||||
) -> None:
|
||||
monkeypatch.setattr(copilot_macos_keychain.sys, "platform", foreign_platform)
|
||||
assert copilot_macos_keychain.read_copilot_oauth_token(host="github.com") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("foreign_platform", ["darwin", "win32"])
|
||||
def test_linux_reader_noop_off_linux(
|
||||
monkeypatch: pytest.MonkeyPatch, foreign_platform: str
|
||||
) -> None:
|
||||
monkeypatch.setattr(copilot_linux_secret.sys, "platform", foreign_platform)
|
||||
assert copilot_linux_secret.read_copilot_oauth_token(host="github.com") is None
|
||||
|
||||
|
||||
def test_windows_reader_noop_off_windows(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(copilot_auth.os, "name", "posix")
|
||||
assert copilot_auth._read_windows_copilot_cli_oauth_token() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. The proxy injects exactly the wrapper-validated token (determinism).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_proxy_injects_explicit_token_over_discovered_one(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Reset the cached module-level provider so this test is self-contained.
|
||||
monkeypatch.setattr(copilot_auth, "_provider", None)
|
||||
# What `wrap copilot --subscription` exports for the proxy:
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_TOKEN", "gho-validated")
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_URL", BUSINESS_API)
|
||||
monkeypatch.setenv("GITHUB_COPILOT_USE_TOKEN_EXCHANGE", "false")
|
||||
# A *different* token is discoverable — it must be ignored entirely.
|
||||
monkeypatch.setattr(
|
||||
copilot_auth, "read_cached_oauth_token", lambda: "gho-WRONG-should-not-be-used"
|
||||
)
|
||||
|
||||
headers = asyncio.run(
|
||||
copilot_auth.apply_copilot_api_auth(
|
||||
{"authorization": "Bearer placeholder"},
|
||||
url=f"{BUSINESS_API}/v1/chat/completions",
|
||||
)
|
||||
)
|
||||
|
||||
assert headers["Authorization"] == "Bearer gho-validated"
|
||||
assert "authorization" not in headers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Full wrapper→proxy chain carries one consistent token, any account host.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_end_to_end_subscription_chain(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(copilot_auth, "_provider", None)
|
||||
|
||||
# (a) wrapper side: resolve + validate the subscription token, then
|
||||
# discover the account-specific API endpoint.
|
||||
_stub_all_secret_stores(monkeypatch)
|
||||
_clear_token_env(monkeypatch)
|
||||
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-seat-token")
|
||||
monkeypatch.setattr(
|
||||
copilot_auth,
|
||||
"_fetch_copilot_user_info",
|
||||
lambda token: {"endpoints": {"api": BUSINESS_API}} if token == "gho-seat-token" else None,
|
||||
)
|
||||
resolved_token = copilot_auth.resolve_subscription_bearer_token()
|
||||
resolved_url = copilot_auth.resolve_copilot_api_url(resolved_token)
|
||||
assert resolved_token == "gho-seat-token"
|
||||
assert resolved_url == BUSINESS_API
|
||||
|
||||
# (b) hand-off: the wrapper exports exactly these for the proxy.
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_TOKEN", resolved_token)
|
||||
monkeypatch.setenv("GITHUB_COPILOT_API_URL", resolved_url)
|
||||
|
||||
# (c) proxy side: build the upstream URL (Copilot has no /v1 prefix) and
|
||||
# inject the same token onto the outbound request.
|
||||
upstream = copilot_auth.build_copilot_upstream_url(resolved_url, "/v1/chat/completions")
|
||||
assert upstream == "https://api.business.githubcopilot.com/chat/completions"
|
||||
|
||||
headers = asyncio.run(
|
||||
copilot_auth.apply_copilot_api_auth({"authorization": "Bearer placeholder"}, url=upstream)
|
||||
)
|
||||
assert headers["Authorization"] == f"Bearer {resolved_token}"
|
||||
|
|
@ -466,7 +466,7 @@ class TestDetectDefaultModel:
|
|||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test")
|
||||
assert _detect_default_model() == "gemini/gemini-2.0-flash"
|
||||
assert _detect_default_model() == "gemini/gemini-flash-latest"
|
||||
|
||||
def test_anthropic_preferred_over_openai(self, monkeypatch):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from types import MethodType, SimpleNamespace
|
||||
|
||||
from headroom.proxy.handlers import openai as openai_handler
|
||||
from headroom.proxy.handlers.openai import OpenAIHandlerMixin
|
||||
from headroom.transforms.compression_units import UnitCompressionResult
|
||||
from headroom.transforms.content_router import (
|
||||
CompressionStrategy,
|
||||
ContentRouter,
|
||||
|
|
@ -24,6 +27,69 @@ def _handler_with_router(router: ContentRouter) -> OpenAIHandlerMixin:
|
|||
return handler
|
||||
|
||||
|
||||
def test_openai_responses_unit_parallelism_env_defaults_and_clamps(monkeypatch):
|
||||
monkeypatch.delenv("HEADROOM_TOOL_OUTPUT_COMPRESSION_PARALLELISM", raising=False)
|
||||
assert openai_handler._openai_responses_unit_parallelism() == 4
|
||||
|
||||
monkeypatch.setenv("HEADROOM_TOOL_OUTPUT_COMPRESSION_PARALLELISM", "bad")
|
||||
assert openai_handler._openai_responses_unit_parallelism() == 4
|
||||
|
||||
monkeypatch.setenv("HEADROOM_TOOL_OUTPUT_COMPRESSION_PARALLELISM", "0")
|
||||
assert openai_handler._openai_responses_unit_parallelism() == 1
|
||||
|
||||
monkeypatch.setenv("HEADROOM_TOOL_OUTPUT_COMPRESSION_PARALLELISM", "999")
|
||||
assert openai_handler._openai_responses_unit_parallelism() == 16
|
||||
|
||||
|
||||
def test_openai_responses_cached_unit_handles_results_without_router_result():
|
||||
result = UnitCompressionResult(
|
||||
original="original",
|
||||
compressed="compressed",
|
||||
modified=True,
|
||||
tokens_before=2,
|
||||
tokens_after=1,
|
||||
tokens_saved=1,
|
||||
transforms_applied=[],
|
||||
strategy="none",
|
||||
router_result=None,
|
||||
)
|
||||
|
||||
assert openai_handler._openai_responses_result_with_cache_hit(result) is result
|
||||
|
||||
|
||||
def test_openai_responses_unit_cache_evicts_oldest_entry(monkeypatch):
|
||||
monkeypatch.setattr(openai_handler, "_OPENAI_RESPONSES_UNIT_CACHE_MAX_ENTRIES", 1)
|
||||
handler = OpenAIHandlerMixin()
|
||||
first = UnitCompressionResult(
|
||||
original="first",
|
||||
compressed="first compressed",
|
||||
modified=True,
|
||||
tokens_before=2,
|
||||
tokens_after=1,
|
||||
tokens_saved=1,
|
||||
transforms_applied=[],
|
||||
strategy="none",
|
||||
router_result=None,
|
||||
)
|
||||
second = UnitCompressionResult(
|
||||
original="second",
|
||||
compressed="second compressed",
|
||||
modified=True,
|
||||
tokens_before=2,
|
||||
tokens_after=1,
|
||||
tokens_saved=1,
|
||||
transforms_applied=[],
|
||||
strategy="none",
|
||||
router_result=None,
|
||||
)
|
||||
|
||||
handler._store_openai_responses_cached_unit("first", first)
|
||||
handler._store_openai_responses_cached_unit("second", second)
|
||||
|
||||
assert handler._get_openai_responses_cached_unit("first") is None
|
||||
assert handler._get_openai_responses_cached_unit("second") is second
|
||||
|
||||
|
||||
def test_openai_responses_adapter_compresses_only_live_text_slots():
|
||||
router = ContentRouter()
|
||||
|
||||
|
|
@ -110,6 +176,162 @@ def test_openai_responses_adapter_compresses_custom_tool_call_output():
|
|||
assert strategy_chain == []
|
||||
|
||||
|
||||
def test_openai_responses_adapter_reuses_exact_tool_output_cache():
|
||||
router = ContentRouter()
|
||||
calls = {"count": 0}
|
||||
|
||||
def compress(self, content: str, **_kwargs):
|
||||
calls["count"] += 1
|
||||
return RouterCompressionResult(
|
||||
compressed="cached output summary",
|
||||
original=content,
|
||||
strategy_used=CompressionStrategy.KOMPRESS,
|
||||
)
|
||||
|
||||
router.compress = MethodType(compress, router)
|
||||
handler = _handler_with_router(router)
|
||||
long_text = " ".join(f"word{i}" for i in range(180))
|
||||
|
||||
payload_one = {
|
||||
"model": "gpt-5",
|
||||
"input": [
|
||||
{"type": "local_shell_call_output", "call_id": "c1", "output": long_text},
|
||||
],
|
||||
}
|
||||
payload_two = {
|
||||
"model": "gpt-5",
|
||||
"input": [
|
||||
{"type": "message", "role": "user", "content": "changed envelope"},
|
||||
{"type": "local_shell_call_output", "call_id": "c2", "output": long_text},
|
||||
],
|
||||
}
|
||||
|
||||
new_payload_one, modified_one, saved_one, *_ = (
|
||||
handler._compress_openai_responses_live_text_units_with_router(
|
||||
payload_one,
|
||||
model="gpt-5",
|
||||
request_id="req_cache_one",
|
||||
)
|
||||
)
|
||||
new_payload_two, modified_two, saved_two, *_ = (
|
||||
handler._compress_openai_responses_live_text_units_with_router(
|
||||
payload_two,
|
||||
model="gpt-5",
|
||||
request_id="req_cache_two",
|
||||
)
|
||||
)
|
||||
|
||||
assert calls["count"] == 1
|
||||
assert modified_one is True
|
||||
assert modified_two is True
|
||||
assert saved_one > 0
|
||||
assert saved_two == saved_one
|
||||
assert new_payload_one["input"][0]["output"] == "cached output summary"
|
||||
assert new_payload_two["input"][1]["output"] == "cached output summary"
|
||||
|
||||
|
||||
def test_openai_responses_adapter_reuses_identical_tool_output_in_same_request():
|
||||
router = ContentRouter()
|
||||
calls = {"count": 0}
|
||||
|
||||
def compress(self, content: str, **_kwargs):
|
||||
calls["count"] += 1
|
||||
return RouterCompressionResult(
|
||||
compressed="same request cached summary",
|
||||
original=content,
|
||||
strategy_used=CompressionStrategy.KOMPRESS,
|
||||
)
|
||||
|
||||
router.compress = MethodType(compress, router)
|
||||
handler = _handler_with_router(router)
|
||||
long_text = " ".join(f"word{i}" for i in range(180))
|
||||
payload = {
|
||||
"model": "gpt-5",
|
||||
"input": [
|
||||
{"type": "function_call_output", "call_id": "c1", "output": long_text},
|
||||
{"type": "function_call_output", "call_id": "c2", "output": long_text},
|
||||
],
|
||||
}
|
||||
|
||||
new_payload, modified, saved, *_ = (
|
||||
handler._compress_openai_responses_live_text_units_with_router(
|
||||
payload,
|
||||
model="gpt-5",
|
||||
request_id="req_same_request_cache",
|
||||
)
|
||||
)
|
||||
|
||||
assert calls["count"] == 1
|
||||
assert modified is True
|
||||
assert saved > 0
|
||||
assert [item["output"] for item in new_payload["input"]] == [
|
||||
"same request cached summary",
|
||||
"same request cached summary",
|
||||
]
|
||||
|
||||
|
||||
def test_openai_responses_adapter_parallelizes_cache_misses_preserving_order(monkeypatch):
|
||||
monkeypatch.setenv("HEADROOM_TOOL_OUTPUT_COMPRESSION_PARALLELISM", "4")
|
||||
router = ContentRouter()
|
||||
lock = threading.Lock()
|
||||
release = threading.Event()
|
||||
active = {"count": 0, "max": 0}
|
||||
|
||||
def compress(self, content: str, **_kwargs):
|
||||
with lock:
|
||||
active["count"] += 1
|
||||
active["max"] = max(active["max"], active["count"])
|
||||
if active["count"] >= 2:
|
||||
release.set()
|
||||
release.wait(0.05)
|
||||
try:
|
||||
marker = content.rsplit(" marker", 1)[1]
|
||||
return RouterCompressionResult(
|
||||
compressed=f"summary marker{marker}",
|
||||
original=content,
|
||||
strategy_used=CompressionStrategy.KOMPRESS,
|
||||
)
|
||||
finally:
|
||||
with lock:
|
||||
active["count"] -= 1
|
||||
|
||||
router.compress = MethodType(compress, router)
|
||||
handler = _handler_with_router(router)
|
||||
|
||||
def long_text(index: int) -> str:
|
||||
return " ".join(f"word{index}_{j}" for j in range(180)) + f" marker{index}"
|
||||
|
||||
payload = {
|
||||
"model": "gpt-5",
|
||||
"input": [
|
||||
{
|
||||
"type": "local_shell_call_output",
|
||||
"call_id": f"c{i}",
|
||||
"output": long_text(i),
|
||||
}
|
||||
for i in range(4)
|
||||
],
|
||||
}
|
||||
|
||||
new_payload, modified, saved, *_ = (
|
||||
handler._compress_openai_responses_live_text_units_with_router(
|
||||
payload,
|
||||
model="gpt-5",
|
||||
request_id="req_parallel",
|
||||
)
|
||||
)
|
||||
|
||||
assert active["max"] >= 2
|
||||
assert modified is True
|
||||
assert saved > 0
|
||||
assert [item["output"] for item in new_payload["input"]] == [
|
||||
"summary marker0",
|
||||
"summary marker1",
|
||||
"summary marker2",
|
||||
"summary marker3",
|
||||
]
|
||||
|
||||
|
||||
def test_openai_responses_adapter_accepts_empty_input_list():
|
||||
router = ContentRouter()
|
||||
handler = _handler_with_router(router)
|
||||
|
|
|
|||
216
tests/test_openai_responses_t3_replay_regression.py
Normal file
216
tests/test_openai_responses_t3_replay_regression.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from types import MethodType, SimpleNamespace
|
||||
|
||||
from headroom.proxy.handlers.openai import OpenAIHandlerMixin
|
||||
from headroom.transforms.content_router import (
|
||||
CompressionStrategy,
|
||||
ContentRouter,
|
||||
RouterCompressionResult,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class T3FailureCase:
|
||||
provider_log: str
|
||||
turn_id: str
|
||||
request_bytes: int
|
||||
unit_count: int
|
||||
|
||||
|
||||
# T3 provider logs keep the Headroom 413 metadata, not the raw /v1/responses
|
||||
# body. These cases recreate the failing byte scale and Responses item shape.
|
||||
T3_FAILED_CASES = (
|
||||
T3FailureCase(
|
||||
provider_log="2b38b84f-b6b0-4d92-8ff0-42f83b59dd70.log",
|
||||
turn_id="019e8c3f-91d9-73b3-a6f8-4e6ae312f91b",
|
||||
request_bytes=674_436,
|
||||
unit_count=8,
|
||||
),
|
||||
T3FailureCase(
|
||||
provider_log="cc084653-feba-4241-a8fd-6655c0dfa799.log",
|
||||
turn_id="019e8bdd-ffb3-7f31-9182-51b2bdb96f52",
|
||||
request_bytes=1_288_876,
|
||||
unit_count=12,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TokenCounter:
|
||||
def count_text(self, text: str) -> int:
|
||||
return max(1, len(text) // 4)
|
||||
|
||||
|
||||
def _handler_with_router(router: ContentRouter) -> OpenAIHandlerMixin:
|
||||
handler = OpenAIHandlerMixin()
|
||||
handler.openai_pipeline = SimpleNamespace(transforms=[router])
|
||||
handler.openai_provider = SimpleNamespace(
|
||||
get_token_counter=lambda _model: TokenCounter(),
|
||||
)
|
||||
return handler
|
||||
|
||||
|
||||
def _tool_output(case: T3FailureCase, index: int, target_bytes: int) -> str:
|
||||
line = (
|
||||
f"{case.turn_id} {case.provider_log} "
|
||||
f"tool={index} path=/tmp/t3-live-output-{index}.txt status=ok "
|
||||
"alpha beta gamma delta epsilon zeta eta theta iota kappa\n"
|
||||
)
|
||||
return (line * ((target_bytes // len(line)) + 1))[:target_bytes]
|
||||
|
||||
|
||||
def _payload_for_case(case: T3FailureCase) -> dict:
|
||||
envelope_budget = 2_500
|
||||
per_unit_bytes = max(2_048, (case.request_bytes - envelope_budget) // case.unit_count)
|
||||
return {
|
||||
"model": "gpt-5.4-mini",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": "continue after tool output",
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call-shell",
|
||||
"name": "shell",
|
||||
"arguments": "{}",
|
||||
},
|
||||
*[
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": f"call-shell-{index}",
|
||||
"output": _tool_output(case, index, per_unit_bytes),
|
||||
}
|
||||
for index in range(case.unit_count)
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _json_bytes(value: object) -> int:
|
||||
return len(json.dumps(value, separators=(",", ":"), default=str).encode("utf-8"))
|
||||
|
||||
|
||||
def test_t3_failed_size_responses_payload_parallelizes_uncached_tool_outputs(monkeypatch):
|
||||
monkeypatch.setenv("HEADROOM_TOOL_OUTPUT_COMPRESSION_PARALLELISM", "4")
|
||||
case = T3_FAILED_CASES[0]
|
||||
router = ContentRouter()
|
||||
lock = threading.Lock()
|
||||
release = threading.Event()
|
||||
active = {"count": 0, "max": 0, "calls": 0}
|
||||
|
||||
def compress(self, content: str, **_kwargs):
|
||||
with lock:
|
||||
active["count"] += 1
|
||||
active["calls"] += 1
|
||||
active["max"] = max(active["max"], active["count"])
|
||||
if active["count"] >= 2:
|
||||
release.set()
|
||||
release.wait(0.05)
|
||||
try:
|
||||
marker = content.split(" tool=", 1)[1].split(" ", 1)[0]
|
||||
return RouterCompressionResult(
|
||||
compressed=f"summary for tool={marker}",
|
||||
original=content,
|
||||
strategy_used=CompressionStrategy.KOMPRESS,
|
||||
)
|
||||
finally:
|
||||
with lock:
|
||||
active["count"] -= 1
|
||||
|
||||
router.compress = MethodType(compress, router)
|
||||
handler = _handler_with_router(router)
|
||||
payload = _payload_for_case(case)
|
||||
|
||||
new_payload, modified, saved, transforms, units_by_category, _strategy_chain, attempted = (
|
||||
handler._compress_openai_responses_live_text_units_with_router(
|
||||
payload,
|
||||
model="gpt-5.4-mini",
|
||||
request_id=f"t3_replay_{case.turn_id}",
|
||||
)
|
||||
)
|
||||
|
||||
assert _json_bytes(payload) >= case.request_bytes * 0.95
|
||||
assert attempted > 0
|
||||
assert modified is True
|
||||
assert saved > 0
|
||||
assert active["calls"] == case.unit_count
|
||||
assert active["max"] >= 2
|
||||
assert units_by_category == {"applied": case.unit_count}
|
||||
assert "router:openai:responses:function_call_output:kompress" in transforms
|
||||
outputs = [
|
||||
item["output"]
|
||||
for item in new_payload["input"]
|
||||
if item.get("type") == "function_call_output"
|
||||
]
|
||||
assert outputs == [f"summary for tool={index}" for index in range(case.unit_count)]
|
||||
|
||||
|
||||
def test_t3_failed_size_exact_tool_output_cache_survives_history_changes():
|
||||
case = T3_FAILED_CASES[1]
|
||||
router = ContentRouter()
|
||||
calls = {"count": 0}
|
||||
|
||||
def compress(self, content: str, **_kwargs):
|
||||
calls["count"] += 1
|
||||
marker = content.split(" tool=", 1)[1].split(" ", 1)[0]
|
||||
return RouterCompressionResult(
|
||||
compressed=f"cached summary for tool={marker}",
|
||||
original=content,
|
||||
strategy_used=CompressionStrategy.KOMPRESS,
|
||||
)
|
||||
|
||||
router.compress = MethodType(compress, router)
|
||||
handler = _handler_with_router(router)
|
||||
first_payload = _payload_for_case(case)
|
||||
second_payload = {
|
||||
"model": "gpt-5.4-mini",
|
||||
"input": [
|
||||
# Simulate a harness that changed/trimmed the ancient envelope.
|
||||
{"type": "message", "role": "user", "content": "history compacted"},
|
||||
*first_payload["input"][2:],
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call-shell-new",
|
||||
"output": _tool_output(case, case.unit_count, 32_000),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
first_new_payload, first_modified, first_saved, *_ = (
|
||||
handler._compress_openai_responses_live_text_units_with_router(
|
||||
first_payload,
|
||||
model="gpt-5.4-mini",
|
||||
request_id=f"t3_replay_cache_first_{case.turn_id}",
|
||||
)
|
||||
)
|
||||
second_new_payload, second_modified, second_saved, *_ = (
|
||||
handler._compress_openai_responses_live_text_units_with_router(
|
||||
second_payload,
|
||||
model="gpt-5.4-mini",
|
||||
request_id=f"t3_replay_cache_second_{case.turn_id}",
|
||||
)
|
||||
)
|
||||
|
||||
assert first_modified is True
|
||||
assert second_modified is True
|
||||
assert first_saved > 0
|
||||
assert second_saved > 0
|
||||
assert calls["count"] == case.unit_count + 1
|
||||
assert [
|
||||
item["output"]
|
||||
for item in first_new_payload["input"]
|
||||
if item.get("type") == "function_call_output"
|
||||
] == [f"cached summary for tool={index}" for index in range(case.unit_count)]
|
||||
assert [
|
||||
item["output"]
|
||||
for item in second_new_payload["input"]
|
||||
if item.get("type") == "function_call_output"
|
||||
] == [
|
||||
*[f"cached summary for tool={index}" for index in range(case.unit_count)],
|
||||
f"cached summary for tool={case.unit_count}",
|
||||
]
|
||||
59
tests/test_owned_asset_encoding.py
Normal file
59
tests/test_owned_asset_encoding.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Regression tests for UTF-8 decoding/encoding of headroom-owned assets.
|
||||
|
||||
These guard against ``UnicodeDecodeError`` on systems whose default text
|
||||
encoding is not UTF-8 (e.g. Windows ``cp949``/``cp1252`` locales). Headroom
|
||||
ships and writes its own templates, JSON state and config files as UTF-8, so
|
||||
they must be read and written with an explicit ``encoding="utf-8"`` rather than
|
||||
relying on the platform default codec. See issue #533.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from headroom.dashboard import TEMPLATES_DIR, get_dashboard_html
|
||||
from headroom.memory.sync import _load_sync_state, _save_sync_state
|
||||
|
||||
|
||||
def test_dashboard_template_contains_non_ascii() -> None:
|
||||
"""The bundled template has non-ASCII bytes, so the bug is reproducible."""
|
||||
raw = (TEMPLATES_DIR / "dashboard.html").read_bytes()
|
||||
assert any(byte > 0x7F for byte in raw), "template expected to contain non-ASCII bytes"
|
||||
|
||||
|
||||
def test_get_dashboard_html_reads_as_utf8(monkeypatch) -> None:
|
||||
"""get_dashboard_html must decode the template as UTF-8, not the OS default.
|
||||
|
||||
Before the fix, ``read_text()`` used the platform default codec and raised
|
||||
``UnicodeDecodeError`` on non-UTF-8 locales. We assert the explicit encoding
|
||||
is passed so the regression cannot silently return (a utf-8 CI host would
|
||||
otherwise mask it).
|
||||
"""
|
||||
captured: dict[str, object] = {}
|
||||
original = Path.read_text
|
||||
|
||||
def _spy(self: Path, *args: object, **kwargs: object) -> str:
|
||||
captured["encoding"] = kwargs.get("encoding")
|
||||
return original(self, *args, **kwargs) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", _spy)
|
||||
|
||||
html = get_dashboard_html()
|
||||
|
||||
assert captured["encoding"] == "utf-8"
|
||||
assert html # non-empty
|
||||
# Content must equal an explicit UTF-8 decode of the raw template.
|
||||
expected = (TEMPLATES_DIR / "dashboard.html").read_bytes().decode("utf-8")
|
||||
assert html == expected
|
||||
|
||||
|
||||
def test_sync_state_round_trips_non_ascii(tmp_path) -> None:
|
||||
"""JSON sync state with non-ASCII values must survive a save/load round-trip."""
|
||||
state_path = tmp_path / "nested" / "sync_state.json"
|
||||
state = {"agent": "café", "note": "한국어 메모", "emoji": "🚀"}
|
||||
|
||||
_save_sync_state(state_path, state)
|
||||
|
||||
# Persisted bytes must be valid UTF-8 regardless of the platform default.
|
||||
assert state_path.read_bytes().decode("utf-8")
|
||||
assert _load_sync_state(state_path) == state
|
||||
|
|
@ -34,6 +34,23 @@ class TestTiktokenCounter:
|
|||
assert counter.model == "gpt-4"
|
||||
assert counter.encoding_name == "cl100k_base"
|
||||
|
||||
def test_unknown_gpt4_snapshot_uses_cl100k(self):
|
||||
"""Unknown gpt-4 (non-o, non-turbo) snapshots must use cl100k_base.
|
||||
|
||||
Regression: the prefix matcher scanned MODEL_TO_ENCODING for the
|
||||
first key starting with the prefix. For prefix "gpt-4" that matched
|
||||
the "gpt-4o" entry first and wrongly returned o200k_base for any
|
||||
gpt-4 snapshot not in the table (e.g. a future dated build).
|
||||
"""
|
||||
from headroom.tokenizers.tiktoken_counter import get_encoding_for_model
|
||||
|
||||
assert get_encoding_for_model("gpt-4-2025-01-01") == "cl100k_base"
|
||||
assert get_encoding_for_model("gpt-4-future") == "cl100k_base"
|
||||
# gpt-4o snapshots still resolve to o200k_base (most-specific first).
|
||||
assert get_encoding_for_model("gpt-4o-2099-12-31") == "o200k_base"
|
||||
# gpt-4-turbo snapshots use cl100k_base.
|
||||
assert get_encoding_for_model("gpt-4-turbo-2099") == "cl100k_base"
|
||||
|
||||
def test_count_text_empty(self):
|
||||
"""Test counting empty text."""
|
||||
counter = TiktokenCounter()
|
||||
|
|
|
|||
|
|
@ -171,6 +171,66 @@ def test_mixed_content_section_splitting_and_json_extraction() -> None:
|
|||
assert _extract_json_block(["{", '"a": 1'], 0) == (None, 0)
|
||||
|
||||
|
||||
def test_extract_json_block_ignores_brackets_inside_strings() -> None:
|
||||
"""Brackets/braces inside JSON string values must not end the block early.
|
||||
|
||||
Regression: counting raw ``[``/``]``/``{``/``}`` per line treated the
|
||||
``]`` inside ``{"path": "a]b"}`` as a closing bracket, so the array was
|
||||
truncated mid-way and the remaining rows leaked into later sections.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
lines = [
|
||||
"[",
|
||||
' {"path": "a]b"},',
|
||||
' {"path": "c"}',
|
||||
"]",
|
||||
]
|
||||
block, end_idx = _extract_json_block(lines, 0)
|
||||
assert end_idx == 3
|
||||
assert block is not None
|
||||
parsed = _json.loads(block)
|
||||
assert parsed == [{"path": "a]b"}, {"path": "c"}]
|
||||
|
||||
# Braces inside a string value must likewise be ignored.
|
||||
obj_lines = [
|
||||
"{",
|
||||
' "msg": "use {curly} and [square]",',
|
||||
' "n": 1',
|
||||
"}",
|
||||
]
|
||||
obj_block, obj_end = _extract_json_block(obj_lines, 0)
|
||||
assert obj_end == 3
|
||||
assert obj_block is not None
|
||||
assert _json.loads(obj_block) == {"msg": "use {curly} and [square]", "n": 1}
|
||||
|
||||
|
||||
def test_split_into_sections_keeps_json_array_with_bracket_in_string() -> None:
|
||||
"""A JSON array embedded in prose stays one JSON section, not fragments.
|
||||
|
||||
With the bracket-in-string bug, the array below split into a truncated
|
||||
JSON section plus a stray ``]`` glued onto the trailing prose.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
content = "\n".join(
|
||||
[
|
||||
"prose line here that is long enough to matter",
|
||||
"[",
|
||||
' {"path": "a]b"},',
|
||||
' {"path": "c"}',
|
||||
"]",
|
||||
"trailing prose",
|
||||
]
|
||||
)
|
||||
|
||||
sections = split_into_sections(content)
|
||||
json_sections = [s for s in sections if s.content_type == ContentType.JSON_ARRAY]
|
||||
assert len(json_sections) == 1
|
||||
parsed = _json.loads(json_sections[0].content)
|
||||
assert parsed == [{"path": "a]b"}, {"path": "c"}]
|
||||
|
||||
|
||||
def test_content_router_strategy_and_compress_paths(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
router = ContentRouter(ContentRouterConfig(prefer_code_aware_for_code=False))
|
||||
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -1200,7 +1200,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "gitpython"
|
||||
version = "3.1.46"
|
||||
version = "3.1.47"
|
||||
source = { registry = "https://pypi.org/simple/" }
|
||||
dependencies = [
|
||||
{ name = "gitdb" },
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ When configured, Headroom emits OTLP traces for the shared compression pipeline
|
|||
| `--budget` | None | Daily budget limit in USD |
|
||||
| `--code-aware` | true | Enable AST-based code compression (env: HEADROOM_CODE_AWARE_ENABLED) |
|
||||
| `--no-code-aware` | false | Disable code-aware compression |
|
||||
| `--anthropic-api-url` | `https://api.anthropic.com` | Custom Anthropic API URL endpoint |
|
||||
| `--openai-api-url` | `https://api.openai.com` | Custom OpenAI API URL endpoint |
|
||||
|
||||
### Run Modes
|
||||
|
|
@ -379,7 +380,13 @@ headroom_latency_ms_sum
|
|||
export HEADROOM_HOST=0.0.0.0
|
||||
export HEADROOM_PORT=8787
|
||||
export HEADROOM_BUDGET=100.0
|
||||
|
||||
# Route OpenAI passthrough requests to a custom endpoint
|
||||
export OPENAI_TARGET_API_URL=https://custom.openai.endpoint.com
|
||||
|
||||
# Route Anthropic passthrough requests to a custom endpoint
|
||||
export ANTHROPIC_TARGET_API_URL=https://litellm.company.internal
|
||||
|
||||
headroom proxy
|
||||
```
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue