copro-mcp 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. copro_mcp-0.4.0/LICENSE +21 -0
  2. copro_mcp-0.4.0/PKG-INFO +478 -0
  3. copro_mcp-0.4.0/README.md +443 -0
  4. copro_mcp-0.4.0/pyproject.toml +74 -0
  5. copro_mcp-0.4.0/setup.cfg +4 -0
  6. copro_mcp-0.4.0/src/copro/__init__.py +3 -0
  7. copro_mcp-0.4.0/src/copro/__main__.py +3 -0
  8. copro_mcp-0.4.0/src/copro/_legacy.py +112 -0
  9. copro_mcp-0.4.0/src/copro/application/__init__.py +0 -0
  10. copro_mcp-0.4.0/src/copro/application/conversation_store.py +95 -0
  11. copro_mcp-0.4.0/src/copro/application/cross_check_orchestrator.py +360 -0
  12. copro_mcp-0.4.0/src/copro/application/delegate_task_use_case.py +730 -0
  13. copro_mcp-0.4.0/src/copro/application/dto.py +89 -0
  14. copro_mcp-0.4.0/src/copro/application/job_manager.py +79 -0
  15. copro_mcp-0.4.0/src/copro/application/map_reduce_orchestrator.py +126 -0
  16. copro_mcp-0.4.0/src/copro/application/progress.py +129 -0
  17. copro_mcp-0.4.0/src/copro/cli.py +183 -0
  18. copro_mcp-0.4.0/src/copro/commands.py +67 -0
  19. copro_mcp-0.4.0/src/copro/config/__init__.py +0 -0
  20. copro_mcp-0.4.0/src/copro/config/budgets.yaml +97 -0
  21. copro_mcp-0.4.0/src/copro/config/default.yaml +336 -0
  22. copro_mcp-0.4.0/src/copro/config/routing_from_eval.yaml +26 -0
  23. copro_mcp-0.4.0/src/copro/config/settings.py +394 -0
  24. copro_mcp-0.4.0/src/copro/config/task_profiles.yaml +111 -0
  25. copro_mcp-0.4.0/src/copro/domain/__init__.py +0 -0
  26. copro_mcp-0.4.0/src/copro/domain/chunker.py +32 -0
  27. copro_mcp-0.4.0/src/copro/domain/exceptions.py +50 -0
  28. copro_mcp-0.4.0/src/copro/domain/file_context.py +356 -0
  29. copro_mcp-0.4.0/src/copro/domain/file_selection.py +194 -0
  30. copro_mcp-0.4.0/src/copro/domain/finding_merge.py +115 -0
  31. copro_mcp-0.4.0/src/copro/domain/guardrails.py +132 -0
  32. copro_mcp-0.4.0/src/copro/domain/interfaces.py +65 -0
  33. copro_mcp-0.4.0/src/copro/domain/models.py +119 -0
  34. copro_mcp-0.4.0/src/copro/domain/output_parser.py +64 -0
  35. copro_mcp-0.4.0/src/copro/domain/prompt_builder.py +90 -0
  36. copro_mcp-0.4.0/src/copro/domain/rag_indexer.py +73 -0
  37. copro_mcp-0.4.0/src/copro/domain/token_estimate.py +38 -0
  38. copro_mcp-0.4.0/src/copro/domain/validators.py +223 -0
  39. copro_mcp-0.4.0/src/copro/evals/__init__.py +1 -0
  40. copro_mcp-0.4.0/src/copro/evals/dataset.py +62 -0
  41. copro_mcp-0.4.0/src/copro/evals/judge.py +194 -0
  42. copro_mcp-0.4.0/src/copro/evals/routing.py +223 -0
  43. copro_mcp-0.4.0/src/copro/evals/runner.py +492 -0
  44. copro_mcp-0.4.0/src/copro/evals/scoring.py +151 -0
  45. copro_mcp-0.4.0/src/copro/infra/__init__.py +0 -0
  46. copro_mcp-0.4.0/src/copro/infra/cache.py +41 -0
  47. copro_mcp-0.4.0/src/copro/infra/circuit_breaker.py +201 -0
  48. copro_mcp-0.4.0/src/copro/infra/event_log.py +119 -0
  49. copro_mcp-0.4.0/src/copro/infra/logging.py +188 -0
  50. copro_mcp-0.4.0/src/copro/infra/quota_tracker.py +407 -0
  51. copro_mcp-0.4.0/src/copro/infra/retry.py +56 -0
  52. copro_mcp-0.4.0/src/copro/infra/shared_key_state.py +249 -0
  53. copro_mcp-0.4.0/src/copro/infra/stats.py +212 -0
  54. copro_mcp-0.4.0/src/copro/infra/url_fetcher.py +306 -0
  55. copro_mcp-0.4.0/src/copro/infra/vector_store.py +43 -0
  56. copro_mcp-0.4.0/src/copro/metrics_exporter.py +268 -0
  57. copro_mcp-0.4.0/src/copro/providers/__init__.py +0 -0
  58. copro_mcp-0.4.0/src/copro/providers/caching_provider.py +71 -0
  59. copro_mcp-0.4.0/src/copro/providers/concurrency_provider.py +50 -0
  60. copro_mcp-0.4.0/src/copro/providers/ollama_embedding_provider.py +57 -0
  61. copro_mcp-0.4.0/src/copro/providers/ollama_provider.py +133 -0
  62. copro_mcp-0.4.0/src/copro/providers/openai_compatible_provider.py +371 -0
  63. copro_mcp-0.4.0/src/copro/providers/registry.py +204 -0
  64. copro_mcp-0.4.0/src/copro/server.py +382 -0
  65. copro_mcp-0.4.0/src/copro_mcp.egg-info/PKG-INFO +478 -0
  66. copro_mcp-0.4.0/src/copro_mcp.egg-info/SOURCES.txt +68 -0
  67. copro_mcp-0.4.0/src/copro_mcp.egg-info/dependency_links.txt +1 -0
  68. copro_mcp-0.4.0/src/copro_mcp.egg-info/entry_points.txt +7 -0
  69. copro_mcp-0.4.0/src/copro_mcp.egg-info/requires.txt +24 -0
  70. copro_mcp-0.4.0/src/copro_mcp.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 huynguyenkhac17
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,478 @@
1
+ Metadata-Version: 2.4
2
+ Name: copro-mcp
3
+ Version: 0.4.0
4
+ Summary: MCP server that delegates side tasks from Claude Code to a pool of cloud/local LLM workers (Groq, Gemini, Mistral, DeepSeek, OpenRouter, Ollama) with routing, fallback, quota awareness and guardrails
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/huynguyenkhac17/Agentic_Routing
7
+ Project-URL: Changelog, https://github.com/huynguyenkhac17/Agentic_Routing/blob/master/CHANGELOG.md
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Topic :: Software Development
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: mcp>=1.2.0
15
+ Requires-Dist: httpx>=0.27
16
+ Requires-Dist: pydantic>=2.7
17
+ Requires-Dist: pydantic-settings>=2.4
18
+ Requires-Dist: python-dotenv>=1.0
19
+ Requires-Dist: pyyaml>=6.0
20
+ Requires-Dist: jsonschema>=4.20
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8.0; extra == "dev"
23
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
24
+ Requires-Dist: prometheus_client>=0.20; extra == "dev"
25
+ Provides-Extra: rag
26
+ Requires-Dist: numpy>=1.26; extra == "rag"
27
+ Requires-Dist: faiss-cpu>=1.8; extra == "rag"
28
+ Provides-Extra: metrics
29
+ Requires-Dist: prometheus_client>=0.20; extra == "metrics"
30
+ Provides-Extra: eval
31
+ Requires-Dist: ragas>=0.3; extra == "eval"
32
+ Requires-Dist: openai>=1.40; extra == "eval"
33
+ Requires-Dist: langchain-community<0.4; extra == "eval"
34
+ Dynamic: license-file
35
+
36
+ # Copro
37
+
38
+ [![CI](https://github.com/huynguyenkhac17/copro-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/huynguyenkhac17/copro-mcp/actions/workflows/ci.yml)
39
+
40
+ [![Python](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
41
+ [![MCP](https://img.shields.io/badge/MCP-1.2.0+-green.svg)](https://modelcontextprotocol.io/)
42
+
43
+ An MCP (Model Context Protocol) server acting as a middleware between Claude Code and a pool of local/cloud LLM workers.
44
+
45
+ This project is **not a chatbot**. Claude Code remains the ultimate decision-making supervisor; the local models act as specialized "workers" used to compress context (reading multiple files, summarizing, finding bug candidates, initial planning) or handle low/medium effort tasks. This significantly saves Claude's context window and API token usage.
46
+
47
+ The server exposes three MCP tools: `delegate_task` does the work, `check_job_status` collects the result of an `async_mode` call, and `get_gateway_stats` reports usage. Claude does not need to know which model runs a task or with which generation parameters: it picks a budget (`L1`-`L3`) and, optionally, a `task_type`; routing, fallback, quotas and retries happen behind that interface.
48
+
49
+ ## Architecture
50
+
51
+ ```
52
+ Claude Code (supervisor)
53
+ │ delegate_task(task, budget, task_type, context, file_paths, urls, output_format, json_schema)
54
+ ▼
55
+ MCP layer server.py — registers the tool, maps inputs/exceptions
56
+ ▼
57
+ Application layer DelegateTaskUseCase — orchestrates the main flow
58
+ ▼
59
+ Domain layer interfaces / models / PromptBuilder / OutputParser
60
+ ▼
61
+ Provider layer ProviderFactory → OllamaProvider / OpenAICompatibleProvider
62
+ ```
63
+
64
+ **Core Principles:**
65
+ - The domain layer has zero external dependencies.
66
+ - The application layer only interacts with the domain via the `LLMProvider` interface.
67
+ - The MCP layer handles wiring and input validation with no business logic.
68
+ - Adding a new provider or budget requires **no existing code changes** (see [Extensibility](#extensibility)).
69
+
70
+ ## Directory Structure
71
+
72
+ ```
73
+ mcp/
74
+ ├── pyproject.toml
75
+ ├── .env.example
76
+ ├── src/copro/
77
+ │ ├── server.py # MCP entrypoint, registers delegate_task tool
78
+ │ ├── application/ # Orchestration layer (DTOs, Use Cases, Map-Reduce, Jobs)
79
+ │ ├── domain/ # Core business logic (Interfaces, Prompts, Parsers, RAG)
80
+ │ ├── providers/ # LLM integrations (Ollama, OpenAI-compatible, Cache)
81
+ │ ├── config/ # Pydantic Settings + the shipped YAML defaults:
82
+ │ │ # default.yaml, budgets.yaml, task_profiles.yaml
83
+ │ ├── evals/ # copro eval
84
+ │ └── infra/ # Logging, Retries, Cache, Vector Store, Stats
85
+ ├── evals/ # eval datasets and reports
86
+ └── tests/
87
+ ├── unit/ # Uses mocked LLMProvider
88
+ └── integration/ # Uses real Ollama (auto-skips if unavailable)
89
+ ```
90
+
91
+ ## Prerequisites
92
+
93
+ - [uv](https://docs.astral.sh/uv/) (or Python >= 3.11 with pip).
94
+ - At least one cloud API key. With the shipped config, L1-L3 requests go to Groq
95
+ first and fall back through the other vendors:
96
+
97
+ | Provider | Key variable | Cost | Notes |
98
+ |---|---|---|---|
99
+ | `groq` | `GROQ_API_KEY` | free tier | [console.groq.com](https://console.groq.com/keys); 8K tokens/minute per organization, prompts over ~6K tokens skip it |
100
+ | `gemini-lite`, `gemini` | `GEMINI_API_KEY` | free tier | [Google AI Studio](https://aistudio.google.com/apikey); quota is per Google Cloud project |
101
+ | `mistral` *(experimental)* | `MISTRAL_API_KEY` | free tier | [console.mistral.ai](https://console.mistral.ai/); low request rate on the free plan |
102
+ | `deepseek` *(experimental)* | `DEEPSEEK_API_KEY` | paid, cheap | [platform.deepseek.com](https://platform.deepseek.com/); 1M context, strong on code; placed after the free tiers |
103
+ | `openrouter` *(experimental)* | `OPENROUTER_API_KEY` | free models | [openrouter.ai](https://openrouter.ai/keys); 50 requests/day (1000 after buying 10 credits) |
104
+
105
+ *Experimental* providers are configured from their vendors' documentation and covered
106
+ by unit tests of the request they send, but have not yet been run against the live
107
+ API or measured with `copro eval`. Reports of what works and what does not are welcome.
108
+
109
+ A provider whose key is missing is skipped with a warning and dropped from the
110
+ chains; the others keep working. A call routed to it explicitly fails with
111
+ `Provider '<name>' is configured but was skipped at startup: ...`, naming the
112
+ environment variables it looks for. More keys for the same vendor go in `_2`,
113
+ `_3`... variables (see `api_key_envs` in `default.yaml`).
114
+ - *(Optional)* [Ollama](https://ollama.com) for `provider="ollama"` (content that must
115
+ not leave the machine) and for RAG embeddings (`ollama pull nomic-embed-text`).
116
+
117
+ ## Installation
118
+
119
+ Install the server as a uv tool, straight from the repository:
120
+
121
+ ```bash
122
+ uv tool install git+https://github.com/huynguyenkhac17/copro-mcp
123
+ ```
124
+
125
+ This puts two commands on your PATH: `copro-mcp`, the MCP server, and `copro`, with
126
+ `copro serve` (the same server), `copro stats`, `copro metrics` and `copro eval`.
127
+ `uv tool upgrade copro-mcp` updates it later. Without installing anything,
128
+ `uvx --from git+https://github.com/huynguyenkhac17/copro-mcp copro-mcp` runs it from
129
+ uv's cache.
130
+
131
+ Then register it with Claude Code, passing the keys and the directories the server may
132
+ read (`file_paths` is disabled until you name at least one):
133
+
134
+ ```bash
135
+ claude mcp add -s user copro \
136
+ -e GROQ_API_KEY=gsk_... \
137
+ -e GEMINI_API_KEY=AIza... \
138
+ -e 'COPRO_FILE_ACCESS__ALLOWED_ROOTS=["/home/me/projects"]' \
139
+ -- copro-mcp
140
+ ```
141
+
142
+ Instead of `-e`, the same variables can go in `~/.copro/.env`, which the
143
+ server reads at startup (see `.env.example`).
144
+
145
+ **From a source checkout** (development):
146
+
147
+ ```bash
148
+ git clone https://github.com/huynguyenkhac17/copro-mcp && cd copro-mcp
149
+ uv venv && uv pip install -e ".[dev]"
150
+ cp .env.example .env # keys and allowed_roots for this checkout
151
+ claude mcp add -s user copro -- "$PWD/.venv/bin/copro-mcp"
152
+ ```
153
+
154
+ On Windows the script is `.venv\Scripts\copro-mcp.exe`. Code changes in `src/`
155
+ take effect when the server restarts (`/mcp` in Claude Code reconnects it).
156
+
157
+ ### Upgrading from llm-worker-pool (before 0.4.0)
158
+
159
+ The project was called `llm-worker-pool`. Until 0.4.x ends the old names still work,
160
+ each with a note on stderr:
161
+
162
+ - `LLM_WORKER_POOL_*` variables are read as `COPRO_*` when the new name is not set.
163
+ - `~/.llm_worker_pool/` is copied to `~/.copro/` on the first start (the old one is left
164
+ in place and no longer read).
165
+ - `llm-worker-pool`, `worker-stats`, `worker-metrics` and `worker-eval` run
166
+ `copro-mcp`, `copro stats`, `copro metrics` and `copro eval`.
167
+
168
+ Re-register the server under its new name (`claude mcp remove llm-worker-pool`, then
169
+ the `claude mcp add` above); its tools become `mcp__copro__*`.
170
+
171
+ ## Configuration
172
+
173
+ Everything is configuration-driven. **No code modifications are required.**
174
+
175
+ - `src/copro/config/default.yaml`: providers, timeouts, retries, concurrency,
176
+ logging, file access, caching, background jobs and RAG.
177
+ - `src/copro/config/budgets.yaml`: execution budgets. Add a budget by adding
178
+ an entry.
179
+ - `src/copro/config/task_profiles.yaml`: kinds of work (`task_type`).
180
+
181
+ These ship inside the package. To change them for an installed tool, copy one and
182
+ point the server at the copy with `COPRO_CONFIG`,
183
+ `COPRO_BUDGETS_CONFIG` or `COPRO_TASK_PROFILES_CONFIG`. Single
184
+ settings are overridden with environment variables (prefix `COPRO_`, `__` for
185
+ nested fields, e.g. `COPRO_FILE_ACCESS__ALLOWED_ROOTS`). Priority (highest
186
+ to lowest):
187
+ `init kwargs > real environment variables > .env files > default.yaml`
188
+
189
+ The `.env` files are the checkout's own `<repo>/.env` (when running from a source
190
+ checkout) and then `~/.copro/.env`.
191
+
192
+ > Note: provider API key variables (`api_key_env`/`api_key_envs`, e.g. `GEMINI_API_KEY`) follow the same priority but are a special case: their variable *names* are arbitrary (whatever you write in `default.yaml`), not `COPRO_`-prefixed settings. The `.env` files are loaded via `python-dotenv` directly into the process environment at startup, so setting them there works exactly like exporting real OS environment variables. A real OS env var of the same name still wins if both are set.
193
+
194
+ ### Providers
195
+
196
+ `default.yaml` declares multiple providers simultaneously. Each provider has a name and a `type` (the implementation class in `providers/registry.py`):
197
+
198
+ ```yaml
199
+ default_provider: gemini-lite
200
+ providers:
201
+ ollama:
202
+ type: ollama
203
+ endpoint: http://localhost:11434
204
+ model: qwen2.5-coder:7b
205
+ gemini:
206
+ type: openai_compatible # Compatible with Gemini/OpenAI/Groq
207
+ endpoint: https://generativelanguage.googleapis.com/v1beta/openai
208
+ model: gemini-flash-latest
209
+ api_key_env: GEMINI_API_KEY # The ENV VAR name containing the key
210
+ # api_key_envs: [GEMINI_API_KEY_1, GEMINI_API_KEY_2] # optional: round-robin across multiple keys
211
+ # concurrency: 10 # optional: max parallel requests for this provider
212
+ ```
213
+
214
+ The `delegate_task` tool accepts a `provider: str | None` parameter to select the backend per invocation (defaults to `default_provider`). Routing is decided by **confidentiality, not by whether data is on local disk**: send only genuinely sensitive content (secrets, credentials, proprietary data that must not leave the machine) to `ollama`, and route **everything else** — including summarizing ordinary local project code — to a cloud provider (the default). Note that `file_paths` reads files server-side and works with *any* provider, so summarizing a local codebase on the cloud is fully supported; cloud is in fact preferred for multi-file work because it runs many workers in parallel (concurrency + multi-key round-robin), whereas `ollama` is serial by default. For large local codebases, combine `file_paths` with `map_reduce=true` on a cloud provider.
215
+
216
+ Each provider runs behind its own concurrency limit (`ConcurrencyProvider`), so a slow/serial local model never blocks parallel calls to a cloud provider (e.g. during Map-Reduce). If `concurrency` is omitted, it defaults by provider `type`: `ollama` -> 1 (VRAM-safe), everything else -> 5. When `api_key_envs` lists multiple keys, requests round-robin across them to spread load/rate limits.
217
+
218
+ ### Budget Profiles
219
+
220
+ Tuned for the cloud default provider. The earlier values were sized for a local 9B on
221
+ limited VRAM, where a small `num_predict` was the cheapest guard against runaway
222
+ generation; on a cloud model that same ceiling became the main cause of truncated answers.
223
+
224
+ | Budget | Purpose | reasoning_effort | num_ctx | num_predict | timeout |
225
+ |--------|---------|------------------|---------|-------------|---------|
226
+ | `L1` | Fast, deterministic — categorization, extraction, short answers | low | 131072 | 4096 | 45s |
227
+ | `L2` | Balanced — summarizing, moderate refactoring, code explanation | low | 262144 | 16384 | 120s |
228
+ | `L3` | Highest quality — architecture analysis, bug finding, reading multiple files | medium | 524288 | 32768 | 300s |
229
+
230
+ **Thinking shares the output budget.** Reasoning models bill internal thought against
231
+ `num_predict`, so a low ceiling starves the visible answer. Measured on
232
+ `gemini-flash-latest` at `max_tokens=1024` with no reasoning cap: 979 tokens went to
233
+ thinking, 41 to the answer, and it still stopped at `finish_reason=length`.
234
+
235
+ Two fields control this and each backend reads only its own, declared via the
236
+ provider's `thinking_option`:
237
+
238
+ | Field | Backend | Values |
239
+ |-------|---------|--------|
240
+ | `think` | Ollama | `true` / `false` |
241
+ | `reasoning_effort` | OpenAI-compatible (Gemini, ...) | `low` / `medium` / `high` — `none` is rejected with HTTP 400, so `low` is the floor |
242
+
243
+ Setting `think: false` does nothing on a cloud provider: that field does not exist in
244
+ the OpenAI-compatible wire format and is never sent.
245
+
246
+ Every response reports `prompt_tokens`, `completion_tokens`, `thinking_tokens`
247
+ (derived as total − prompt − completion, because providers bill thinking inside the
248
+ total while reporting it in neither part) and `truncated`. An answer cut off at the
249
+ ceiling carries an explicit `output_truncated_at_max_tokens` warning instead of
250
+ passing as a clean success.
251
+
252
+ ### Direct File Access (`file_paths`)
253
+
254
+ Instead of Claude reading files and pasting them into the prompt (wasting tokens), the server can read files directly via absolute paths. This is **disabled by default** for safety: the shipped `allowed_roots` is empty. Name the directories per machine, as a JSON array:
255
+
256
+ ```bash
257
+ COPRO_FILE_ACCESS__ALLOWED_ROOTS=["/home/me/projects", "/srv/repo"]
258
+ ```
259
+
260
+ `max_file_bytes` (200 KB per file) and `max_total_bytes` (800 KB per call) cap what is read. Keep the roots narrow: the deny-list below blocks `.env*`, keys and `.git`, but not every credential file a home directory can hold (`~/.aws/credentials`, `~/.netrc`, `~/.git-credentials`...).
261
+
262
+ An item may be a file, a directory (walked recursively) or a glob such as `D:/repo/src/**/*.py`. While walking, `.git`, `node_modules`, `.venv`, build output and lockfiles are skipped, and symlinked or junction directories are never entered. Files are taken most useful first (README, source, tests, docs, then the rest), so when the byte budget runs out the noise is what gets dropped. `max_dir_files`, `max_dir_depth` and `max_dir_entries` cap each item.
263
+
264
+ Errors (path traversal, file not found, too large, unreadable) are gracefully handled and appended to `DelegateTaskResult.warnings`. Files skipped inside a directory or glob are reported as one count per item. When `file_paths` is the request's only material and none of it can be read, the call fails before any model is asked (`success: false`, error `None of file_paths could be read: ...`, the same warnings attached) instead of returning the model's answer to the bare task.
265
+
266
+ ### Follow-ups (`continuation_id`)
267
+
268
+ Every answer carries a `continuation_id`. Pass it back with a new `task` to follow up without resending the context. This is useful after `output_truncated_at_max_tokens` (`task="continue"`), or to ask a second question about the same files. The server replays the conversation's context and earlier turns as a transcript, and older turns are dropped first when they do not fit `num_ctx`. Provider, budget, task_type, output_format and json_schema are inherited unless you set them to another value (leaving one out, or passing null, keeps the inherited value; start a new conversation to drop a task_type). New `context`/`file_paths`/`urls` are added to the conversation.
269
+
270
+ Conversations live in server memory (`conversations:` in `default.yaml`: 1 h TTL, 50 conversations, 20 turns). After expiry or a restart the call fails with `continuation_not_found`. A follow-up always runs on one provider, and it cannot be combined with `map_reduce`.
271
+
272
+ ### Guardrails
273
+
274
+ `allowed_roots` can be a whole drive, so two more checks sit on top of it (`guardrails` in `default.yaml`):
275
+
276
+ - **Deny-list.** Files matching `.env*`, `*.pem`, `*.key`, `*id_rsa*`, `*id_ed25519*`, `credentials*.json` or `**/.git/**` are never read, even when a directory is listed or a symlink points at them. The warning is `file_denied_sensitive:<path>`. Use `extra_deny_globs` to add patterns, or `deny_globs` to replace the list.
277
+ - **Secret redaction.** If **any** tier in the fallback chain is a cloud provider, secret-looking literals in the task and context are replaced with `[REDACTED:<kind>]` before the call (`secret_redacted:<kind> xN`). This covers `AIza…`, `gsk_…`, `sk-…`, `ghp_…`, PEM private keys, `password="…"`, `SECRET_TOKEN=…` and `user:pass@` in URLs. Nothing is redacted when the whole chain is local (ollama). Answers are scanned too, and a secret found there is flagged as `secret_in_response:<kind> xN`.
278
+ - **Untrusted context.** File, URL and context text is wrapped in `<untrusted_context>…</untrusted_context>`, and a grounding rule tells the worker to treat it as data, not instructions. This guards against prompt injection hidden in a README or web page.
279
+
280
+ `DelegateTaskResult.guardrail_actions` lists what was done on each call. These checks match patterns, so they catch the common cases but are not a guarantee. For genuinely confidential data, use `provider='ollama'`.
281
+
282
+ ### Task Types (`task_type`)
283
+
284
+ `task_profiles.yaml` defines kinds of work: `review`, `summarize`, `extract`, `classify`, `explain`, `translate` and `generate`. Each profile can set:
285
+
286
+ - `system_addon`: an output contract added to the system prompt.
287
+ - `default_budget`, `output_format` and `json_schema`: used only when the caller does not set them.
288
+ - `validators`:
289
+ - `json_repair`: if the JSON fails to parse or fails the schema, the same tier gets one more try with its own output and the errors (`json_repaired` on success).
290
+ - `citations`: every `path:line` or `{file, line, evidence_quote}` in the answer is checked against the files that were in the context. The results go to `result.verification` (`ok` / `file_not_in_context` / `line_out_of_range` / `quote_not_found`), and each failed one gets a `citation_unverified:<file>:<line>` warning.
291
+
292
+ For example, `review` returns `findings: [{file, line, severity, claim, evidence_quote}]` at L3. Leaving out `task_type` gives exactly the old behaviour. To add a type, add a YAML entry; no code change is needed.
293
+
294
+ ### Local RAG
295
+
296
+ RAG is off by default. It needs the `[rag]` extra (faiss, numpy: `uv tool install "copro-mcp[rag] @ git+..."` or `pip install -e ".[rag]"`) and a local Ollama; turn it on with `COPRO_RAG__ENABLED=true`. Without the extra the server starts with RAG off and logs why.
297
+
298
+ If `file_paths` includes a directory or glob, `rag.enabled` is `true`, and the files' full text would not fit the budget's context window (`num_ctx` minus the 25% reserve), the server embeds them with `rag.embedding_model` via the Ollama at `rag.embedding_endpoint`. A local FAISS store then picks the `top_k` chunks most relevant to the `task`. Below that size the model gets every file verbatim, which is what review needs. If Ollama is unreachable, the call reports `rag_indexing_failed` and falls back to the full text.
299
+
300
+ ### Response Caching
301
+
302
+ Responses are cached based on a hash of the final built prompt, generation options, and model. This avoids repeated inference costs for identical queries. The cache is in-memory and configurable in `default.yaml`:
303
+
304
+ ```yaml
305
+ cache:
306
+ enabled: true
307
+ max_entries: 256
308
+ ttl_seconds: 3600
309
+ ```
310
+
311
+ ### Background Tasks (`async_mode` & `check_job_status`)
312
+
313
+ Large tasks (Budget L3 or Map-Reduce) may exceed Claude's timeout limit. Calling `delegate_task(..., async_mode=true)` immediately returns a `job_id`. Claude can then periodically poll the `check_job_status(job_id)` tool to retrieve the results once completed.
314
+
315
+ ### Map-Reduce for Large Contexts
316
+
317
+ A tier whose `max_prompt_tokens` the prompt exceeds is dropped from the fallback chain before any call (warning `prompt_cap_skip:<provider> (~N tokens > max_prompt_tokens=M)`), instead of failing locally and showing up as `fallback_used` plus a logged failure. The next tier keeps the reasoning effort of its configured position (a second tier is still curbed by `fallback_reasoning_effort`). If every tier is over its cap, nothing is dropped and the refusal reaches the caller.
318
+
319
+ A review with the findings contract (`task_type="review"`) that runs on **one** provider (`cross_check=false`, only one cross-check provider up, or a provider named in the request) is batched instead: when its files exceed that provider's `max_prompt_tokens` (groq: 6000), they go out in batches of whole files that fit, each batch with its own fallback chain, and the findings are concatenated and re-verified against all the files (warning `review_batched:<n>`, failed batches `review_batch_failed:<i>/<n>:...`). There is no LLM reduce step, so file/line/quote evidence survives. A single file over the cap is not split; its batch goes to the next tier like any oversized prompt. On `review-large-l3` this took routed recall from 0.33-0.50 to 1.00 (3 runs each), with precision 0.60-1.00 and more groq rate limiting.
320
+
321
+ `delegate_task(..., map_reduce=true)` automatically splits massive contexts (or large sets of files) into chunks that fit the budget's `num_ctx`. It processes each chunk concurrently (Map phase, bounded by the target provider's `concurrency`), then synthesizes the results (Reduce phase). This requires multiple LLM calls and is **opt-in only**.
322
+
323
+ ## Registering with Claude Code
324
+
325
+ See [Installation](#installation). After changing code or config, `/mcp` in Claude Code
326
+ reconnects the server, which reloads both.
327
+
328
+ ## Running Tests
329
+
330
+ ```bash
331
+ # In the source checkout's venv (installed with ".[dev]", see Installation)
332
+ # Unit tests (mocks LLM, does not require Ollama)
333
+ python -m pytest tests/unit
334
+
335
+ # Integration tests (requires Ollama running - auto-skips if unavailable)
336
+ python -m pytest tests/integration
337
+ ```
338
+
339
+ ## Releasing
340
+
341
+ Bump `version` in `pyproject.toml` and `copro/__init__.py`, add the CHANGELOG entry,
342
+ then push a matching tag:
343
+
344
+ ```bash
345
+ git tag v0.4.0 && git push origin v0.4.0
346
+ ```
347
+
348
+ `.github/workflows/publish.yml` checks that the tag matches the version, runs the tests,
349
+ builds, smoke-tests the wheel over MCP stdio and uploads to PyPI through trusted
350
+ publishing (no API token is stored).
351
+
352
+ ## Usage Stats
353
+
354
+ The server keeps counters in memory and exposes them through the `get_gateway_stats`
355
+ MCP tool — but reading them that way costs a model round-trip just to look at numbers.
356
+ Every update is therefore also mirrored, atomically, to `~/.copro/stats.json`,
357
+ which anything can read directly:
358
+
359
+ ```powershell
360
+ copro stats # human-readable table
361
+ copro stats --json # raw snapshot, for piping into jq
362
+ ```
363
+
364
+ Tracked: requests, failures, cache hits, **tokens actually spent** split into
365
+ prompt / completion / thinking, and the count of answers cut off at the output ceiling.
366
+ Cache hits are excluded from spend and counted separately as tokens saved.
367
+
368
+ Counters reset when the server restarts, since they live in the server process.
369
+
370
+ ### Key state shared between sessions
371
+
372
+ Every Claude Code session starts its own server process. What one of them learns about a
373
+ key — blocked by a 429 (Retry-After / retryDelay / per-day quota), a token window drained
374
+ per Groq's `x-ratelimit-*` headers, a model that keeps hanging, a breaker quarantine —
375
+ goes to `~/.copro/key_state.json`, and the others skip that key or tier too
376
+ instead of collecting their own 429. A skipped tier says why in `fallback_used`:
377
+ `quota_exhausted_locally` (no key has quota left), `keys_quarantined_locally` (every key
378
+ quarantined by the breaker after repeated failures — connection errors, 5xx) or
379
+ `model_unresponsive_locally` (repeated timeouts). Keys are stored as a truncated SHA-256, times as
380
+ wall-clock. In-flight reservations and the counters above stay per process;
381
+ `continuation_id` conversations are never written to disk. Turn it off with
382
+ `quota.shared_state: false`.
383
+
384
+ ### Progress
385
+
386
+ Long calls report progress: over MCP `notifications/progress` for a synchronous call (the
387
+ tier being tried, `map i/N done`, each cross-check arm's calls, `review batch i/N done`),
388
+ and in the `progress` field `check_job_status` returns while an async job runs.
389
+
390
+ ### Retries
391
+
392
+ A 429 that says how long the key is locked is retried after exactly that long (or at once
393
+ on another free key), and given up immediately when the wait would outlast the tier's
394
+ deadline, so the fallback chain moves on.
395
+
396
+ ### Prometheus metrics (`copro metrics`)
397
+
398
+ Each server instance appends one JSONL line per call (and one per failed tier) to `~/.copro/events.jsonl`. Every line is a single `O_APPEND` write, so several instances (Claude Code, Gemini CLI…) can share the file safely. It rotates to `events.jsonl.1` at `metrics.max_bytes`. Task and response text are never logged unless `logging.debug_log_content` is on. `stats.json` and `/worker` work the same as before.
399
+
400
+ A separate exporter process turns the log into metrics. It reads only the new bytes on each scrape:
401
+
402
+ ```bash
403
+ uv pip install -e ".[metrics]"
404
+ copro metrics # http://127.0.0.1:9464/metrics
405
+ curl -s 127.0.0.1:9464/metrics | grep copro_
406
+ ```
407
+
408
+ It is a separate process because MCP runs over stdio and often as several instances at once. An HTTP endpoint inside each instance would fight over the port, and each would only see its own part of the traffic.
409
+
410
+ | metric | labels |
411
+ |---|---|
412
+ | `copro_requests_total` | provider, model, budget, task_type, outcome |
413
+ | `copro_latency_seconds` (histogram) | provider, budget, outcome |
414
+ | `copro_tokens_total` | provider, kind (prompt/completion/thinking) |
415
+ | `copro_fallback_total` | requested, answered |
416
+ | `copro_errors_total` | provider, code (timeout, http_503, prompt_too_large…) |
417
+ | `copro_warnings_total` | code (fallback_used, output_truncated_at_max_tokens, json_parse_failed, secret_redacted, citation_unverified…) |
418
+ | `copro_guardrail_actions_total` | action |
419
+ | `copro_eval_score` (gauge) | metric, provider, budget, task_type (from `eval_latest.json`) |
420
+
421
+ `InvocationLogger(sinks=[...])` is a hook for a future tracer such as a self-hosted Langfuse. Nothing is installed for it yet.
422
+
423
+ ## Evaluation (`copro eval`)
424
+
425
+ ragas pulls in `scikit-network`, which has no Python 3.14 wheel yet. So the eval gets its own venv on Python 3.12, and the server's `.venv` stays as it is. Without ragas, `--no-judge` still runs the deterministic metrics.
426
+
427
+ ```bash
428
+ uv venv .venv-eval --python 3.12
429
+ uv pip install --python .venv-eval\Scripts\python.exe -e ".[eval,dev]"
430
+ .venv-eval\Scripts\copro eval --arm groq --arm gemini-lite --harness on # judged run
431
+ copro eval --arm groq --arm gemini-lite --arm gemini --label baseline # harness off
432
+ copro eval --arm groq --arm gemini-lite --harness on --compare evals/reports/<baseline>.json
433
+ copro eval --compare A.json B.json # diff only
434
+ copro eval --arm groq --arm gemini-lite --harness on --emit-routing # measured routing
435
+ copro eval --arm groq --arm gemini-lite --harness on --repeat 3 --emit-routing # with run-to-run spread
436
+ copro eval --from-report NEW.json --gate BASELINE.json # regression gate
437
+ ```
438
+
439
+ - **Dataset.** Cases live in `evals/datasets/*.jsonl` (fixtures in `evals/datasets/fixtures/`). Fields: `id, task, task_type, budget, file_paths|context, output_format, json_schema, reference, expected_findings, expected_fields, must_contain, must_not_contain, expect_file_denied`.
440
+ - **Arms.** Each `--arm` forces one provider with fallbacks off, so the scores belong to that model. `--arm routed` uses the real routing instead. With `--harness on`, each case's `task_type` is passed to the worker.
441
+ - **LLM-judged metrics (RAGAS).**
442
+ - `faithfulness`: not computed for review/generate, where the claims do not come from the context.
443
+ - `factual_correctness`: needs a `reference`.
444
+ - `context_precision` / `context_recall`: only for cases whose `file_paths` is a directory.
445
+ - `answer_relevancy`: needs Ollama `nomic-embed-text` at `eval.embedding_endpoint`, and is skipped when that is unreachable.
446
+ - **Judge.** The judge is `eval.judge_provider` (gemini-lite by default, since gemini-flash was too overloaded to judge on 2026-10-30; override with `--judge-provider`). It is called through the openai client with key rotation, a circuit breaker and a bounded timeout. If the judge's model is also one of the arms, the report flags the self-preference risk.
447
+ - **Deterministic metrics.** `bug_recall` (cited file plus a line within ±3), `citation_valid_rate`, `json_valid`/`json_schema_valid`, `field_accuracy`, `must_contain`, `no_leak`, `file_denied`, fallback/truncation rates, latency p50/p95 and tokens.
448
+ - **Failed calls** (429, timeout) count only towards `success`; quality metrics are averaged over answered cases, so a quota storm does not read as a quality drop.
449
+ - **Routing from eval.** `--emit-routing` writes `src/copro/config/routing_from_eval.yaml`. For each task type it picks the provider with the best primary metric (review: `bug_recall`, extract/classify: `field_accuracy`, summarize/explain: `faithfulness` or `must_contain`, translate: `factual_correctness`). Only providers that answered at least `--min-success` (0.8) of their cases qualify, and they are compared on the cases all of them answered (so a provider cannot win by failing the hard case), then on success, then on latency. Each pick carries its evidence as a comment. A task type moves away from the provider callers get today (hand-written `preferred_provider`, else the budget's provider) only when the winner leads it by more than `--switch-margin` (0.05) and by more than either provider's run-to-run spread; otherwise the line stays commented out. `--repeat N` runs every case N times per arm: a repeated case counts once at the mean of its answered runs, and the spread is how far each provider's mean moved between runs. The file fills `preferred_provider` in the task profiles, and a value written in `task_profiles.yaml` wins. Review the file before committing it.
450
+ - **Regression gate.** `--gate BASELINE.json` exits 1 when an arm's primary metric for a task type drops by more than `--gate-tolerance` (0.05). A drop in `success` is only printed as a note. `--from-report` applies `--emit-routing`/`--gate` to an existing report without calling any model.
451
+ - **Output.** Reports go to `evals/reports/<timestamp>[-label].{json,md}`. Scores are also merged into `~/.copro/eval_latest.json` for the exporter, so a `--case`/`--task-type` subset run only replaces what it measured.
452
+
453
+ ### `/worker` slash command in Claude Code
454
+
455
+ `~/.claude/commands/worker.md` runs the CLI through bash injection, so the table is
456
+ produced by the command itself rather than generated by the model:
457
+
458
+ ```markdown
459
+ ---
460
+ description: Show Copro usage
461
+ allowed-tools: Bash(copro stats:*)
462
+ ---
463
+ !`copro stats`
464
+ ```
465
+
466
+ Typing `/worker` prints the current stats. The model only adds a comment when a number
467
+ warrants it — truncated answers, thinking above 40% of spend, or a provider erroring out.
468
+
469
+ ## Extensibility
470
+
471
+ To **add a new provider type** (e.g., vLLM, llama.cpp):
472
+ *(Note: OpenAI, Gemini, Groq are already supported via the `openai_compatible` type)*
473
+ 1. Create a class implementing the `LLMProvider` interface in `providers/`.
474
+ 2. Create a builder function and register it in `providers/registry.py`.
475
+ 3. Add the corresponding block to `default.yaml`.
476
+
477
+ To **add a new budget profile**: simply add an entry to `budgets.yaml`.
478
+ No code changes are required for either!