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.
- copro_mcp-0.4.0/LICENSE +21 -0
- copro_mcp-0.4.0/PKG-INFO +478 -0
- copro_mcp-0.4.0/README.md +443 -0
- copro_mcp-0.4.0/pyproject.toml +74 -0
- copro_mcp-0.4.0/setup.cfg +4 -0
- copro_mcp-0.4.0/src/copro/__init__.py +3 -0
- copro_mcp-0.4.0/src/copro/__main__.py +3 -0
- copro_mcp-0.4.0/src/copro/_legacy.py +112 -0
- copro_mcp-0.4.0/src/copro/application/__init__.py +0 -0
- copro_mcp-0.4.0/src/copro/application/conversation_store.py +95 -0
- copro_mcp-0.4.0/src/copro/application/cross_check_orchestrator.py +360 -0
- copro_mcp-0.4.0/src/copro/application/delegate_task_use_case.py +730 -0
- copro_mcp-0.4.0/src/copro/application/dto.py +89 -0
- copro_mcp-0.4.0/src/copro/application/job_manager.py +79 -0
- copro_mcp-0.4.0/src/copro/application/map_reduce_orchestrator.py +126 -0
- copro_mcp-0.4.0/src/copro/application/progress.py +129 -0
- copro_mcp-0.4.0/src/copro/cli.py +183 -0
- copro_mcp-0.4.0/src/copro/commands.py +67 -0
- copro_mcp-0.4.0/src/copro/config/__init__.py +0 -0
- copro_mcp-0.4.0/src/copro/config/budgets.yaml +97 -0
- copro_mcp-0.4.0/src/copro/config/default.yaml +336 -0
- copro_mcp-0.4.0/src/copro/config/routing_from_eval.yaml +26 -0
- copro_mcp-0.4.0/src/copro/config/settings.py +394 -0
- copro_mcp-0.4.0/src/copro/config/task_profiles.yaml +111 -0
- copro_mcp-0.4.0/src/copro/domain/__init__.py +0 -0
- copro_mcp-0.4.0/src/copro/domain/chunker.py +32 -0
- copro_mcp-0.4.0/src/copro/domain/exceptions.py +50 -0
- copro_mcp-0.4.0/src/copro/domain/file_context.py +356 -0
- copro_mcp-0.4.0/src/copro/domain/file_selection.py +194 -0
- copro_mcp-0.4.0/src/copro/domain/finding_merge.py +115 -0
- copro_mcp-0.4.0/src/copro/domain/guardrails.py +132 -0
- copro_mcp-0.4.0/src/copro/domain/interfaces.py +65 -0
- copro_mcp-0.4.0/src/copro/domain/models.py +119 -0
- copro_mcp-0.4.0/src/copro/domain/output_parser.py +64 -0
- copro_mcp-0.4.0/src/copro/domain/prompt_builder.py +90 -0
- copro_mcp-0.4.0/src/copro/domain/rag_indexer.py +73 -0
- copro_mcp-0.4.0/src/copro/domain/token_estimate.py +38 -0
- copro_mcp-0.4.0/src/copro/domain/validators.py +223 -0
- copro_mcp-0.4.0/src/copro/evals/__init__.py +1 -0
- copro_mcp-0.4.0/src/copro/evals/dataset.py +62 -0
- copro_mcp-0.4.0/src/copro/evals/judge.py +194 -0
- copro_mcp-0.4.0/src/copro/evals/routing.py +223 -0
- copro_mcp-0.4.0/src/copro/evals/runner.py +492 -0
- copro_mcp-0.4.0/src/copro/evals/scoring.py +151 -0
- copro_mcp-0.4.0/src/copro/infra/__init__.py +0 -0
- copro_mcp-0.4.0/src/copro/infra/cache.py +41 -0
- copro_mcp-0.4.0/src/copro/infra/circuit_breaker.py +201 -0
- copro_mcp-0.4.0/src/copro/infra/event_log.py +119 -0
- copro_mcp-0.4.0/src/copro/infra/logging.py +188 -0
- copro_mcp-0.4.0/src/copro/infra/quota_tracker.py +407 -0
- copro_mcp-0.4.0/src/copro/infra/retry.py +56 -0
- copro_mcp-0.4.0/src/copro/infra/shared_key_state.py +249 -0
- copro_mcp-0.4.0/src/copro/infra/stats.py +212 -0
- copro_mcp-0.4.0/src/copro/infra/url_fetcher.py +306 -0
- copro_mcp-0.4.0/src/copro/infra/vector_store.py +43 -0
- copro_mcp-0.4.0/src/copro/metrics_exporter.py +268 -0
- copro_mcp-0.4.0/src/copro/providers/__init__.py +0 -0
- copro_mcp-0.4.0/src/copro/providers/caching_provider.py +71 -0
- copro_mcp-0.4.0/src/copro/providers/concurrency_provider.py +50 -0
- copro_mcp-0.4.0/src/copro/providers/ollama_embedding_provider.py +57 -0
- copro_mcp-0.4.0/src/copro/providers/ollama_provider.py +133 -0
- copro_mcp-0.4.0/src/copro/providers/openai_compatible_provider.py +371 -0
- copro_mcp-0.4.0/src/copro/providers/registry.py +204 -0
- copro_mcp-0.4.0/src/copro/server.py +382 -0
- copro_mcp-0.4.0/src/copro_mcp.egg-info/PKG-INFO +478 -0
- copro_mcp-0.4.0/src/copro_mcp.egg-info/SOURCES.txt +68 -0
- copro_mcp-0.4.0/src/copro_mcp.egg-info/dependency_links.txt +1 -0
- copro_mcp-0.4.0/src/copro_mcp.egg-info/entry_points.txt +7 -0
- copro_mcp-0.4.0/src/copro_mcp.egg-info/requires.txt +24 -0
- copro_mcp-0.4.0/src/copro_mcp.egg-info/top_level.txt +1 -0
copro_mcp-0.4.0/LICENSE
ADDED
|
@@ -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.
|
copro_mcp-0.4.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/huynguyenkhac17/copro-mcp/actions/workflows/ci.yml)
|
|
39
|
+
|
|
40
|
+
[](https://www.python.org/downloads/)
|
|
41
|
+
[](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!
|