polyclaude 0.1.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.
@@ -0,0 +1,25 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.11", "3.12"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - name: Install
20
+ run: pip install -e .
21
+ - name: Import & CLI smoke test
22
+ run: |
23
+ python -c "import polyclaude, polyclaude.bridge, polyclaude.cli, polyclaude.providers"
24
+ polyclaude --list
25
+ polyclaude --version
@@ -0,0 +1,23 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ release:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write # required for PyPI Trusted Publishing (OIDC)
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.12"
18
+ - name: Build sdist and wheel
19
+ run: |
20
+ python -m pip install --upgrade build
21
+ python -m build
22
+ - name: Publish to PyPI
23
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,29 @@
1
+ # secrets — never commit
2
+ .env
3
+ .env.*
4
+ *.key
5
+ *.pem
6
+
7
+ # python
8
+ __pycache__/
9
+ *.py[cod]
10
+ *.egg-info/
11
+ build/
12
+ dist/
13
+ .venv/
14
+ venv/
15
+ .eggs/
16
+
17
+ # tooling
18
+ .mypy_cache/
19
+ .pytest_cache/
20
+ .ruff_cache/
21
+
22
+ # os / editor
23
+ .DS_Store
24
+ *.swp
25
+ .idea/
26
+ .vscode/
27
+
28
+ # runtime logs
29
+ /tmp/polyclaude*.log
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Devesh Pratap
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,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: polyclaude
3
+ Version: 0.1.0
4
+ Summary: Use OpenAI, Gemini, and any OpenAI-compatible model inside the Claude Code CLI.
5
+ Project-URL: Homepage, https://github.com/ibedevesh/polyclaude
6
+ Project-URL: Repository, https://github.com/ibedevesh/polyclaude
7
+ Project-URL: Issues, https://github.com/ibedevesh/polyclaude/issues
8
+ Author-email: Devesh Pratap <mail2deveshpratap@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: anthropic,claude,claude-code,cli,coding-agent,gemini,gpt,llm,openai,proxy
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development :: Build Tools
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: mitmproxy>=10
20
+ Description-Content-Type: text/markdown
21
+
22
+ # polyclaude
23
+
24
+ **Use OpenAI, Gemini, or any OpenAI-compatible model inside the Claude Code CLI.**
25
+
26
+ Claude Code is a great terminal coding agent — but it only talks to Anthropic's
27
+ models. `polyclaude` lets you keep the exact Claude Code experience (its UI,
28
+ tools, and agent loop) while the actual model behind it is **GPT, Gemini, a Groq
29
+ model, an OpenRouter model, or a local Ollama model**.
30
+
31
+ It works by running a tiny local proxy that speaks Claude Code's API on one side
32
+ and your provider's API on the other, translating between them on the fly.
33
+ Claude Code itself is never modified.
34
+
35
+ ```
36
+ Claude Code ⇄ Anthropic Messages API ⇄ [ polyclaude ] ⇄ OpenAI / Gemini / …
37
+ ```
38
+
39
+ ---
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pipx install polyclaude # recommended
45
+ # or: uv tool install polyclaude
46
+ # or: pip install polyclaude
47
+ ```
48
+
49
+ You'll also need [Claude Code](https://docs.claude.com/claude-code) installed
50
+ (`claude` on your PATH) and an API key for whichever provider you want to use.
51
+
52
+ ## Use
53
+
54
+ ```bash
55
+ export GEMINI_API_KEY=... # or OPENAI_API_KEY, GROQ_API_KEY, …
56
+ polyclaude --gemini # Claude Code, powered by Gemini
57
+ polyclaude --openai # …powered by GPT
58
+ polyclaude --openai --model gpt-4.1
59
+ polyclaude --gemini --model gemini-2.5-pro
60
+ ```
61
+
62
+ That's it — you're dropped into a normal Claude Code session running on the
63
+ model you chose. First run auto-configures the local proxy certificate; nothing
64
+ else to set up.
65
+
66
+ Pass arguments straight through to `claude` after `--`:
67
+
68
+ ```bash
69
+ polyclaude --gemini -- -p "explain this repo"
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Providers
75
+
76
+ | Provider | Flag | Key env | Default model |
77
+ |---|---|---|---|
78
+ | Google Gemini | `--gemini` | `GEMINI_API_KEY` | `gemini-2.5-pro` |
79
+ | OpenAI | `--openai` | `OPENAI_API_KEY` | `gpt-4.1` |
80
+ | Groq | `--groq` | `GROQ_API_KEY` | `llama-3.3-70b-versatile` |
81
+ | OpenRouter | `--openrouter` | `OPENROUTER_API_KEY` | `anthropic/claude-3.5-sonnet` |
82
+ | Ollama (local) | `--ollama` | — | `qwen2.5-coder` |
83
+
84
+ Any model your key can access works via `--model`, including the newest ones
85
+ (Gemini 3.x, GPT-5.x). polyclaude handles the provider-specific details so tool
86
+ calling and reasoning keep working:
87
+
88
+ - **Gemini 3.x** thinking models need their function-call *thought signatures*
89
+ round-tripped across turns — handled automatically.
90
+ - **GPT-5.5 / 5.6** can't use tools and reasoning together on the standard
91
+ endpoint; polyclaude routes them through OpenAI's `/responses` API so you get
92
+ **both** (tune depth with `--reasoning low|medium|high`).
93
+ - Reasoning models reject non-default sampling params — dropped automatically.
94
+ - Turns that hit the output-token cap are transparently continued.
95
+
96
+ Run `polyclaude --list` to see everything.
97
+
98
+ ---
99
+
100
+ ## Specialize the agent
101
+
102
+ Give the agent a persona/use-case with a profile — it's appended to the system
103
+ prompt for the main loop only, so Claude Code's own tooling keeps working:
104
+
105
+ ```bash
106
+ polyclaude --gemini --profile datascience
107
+ polyclaude --openai --profile reviewer
108
+ polyclaude --gemini --profile ./my-profile.md # your own file
109
+ ```
110
+
111
+ Bundled profiles: `datascience`, `reviewer`, `concise`. Or replace the whole
112
+ system prompt with `--system path/to/file.md`.
113
+
114
+ ---
115
+
116
+ ## How it works
117
+
118
+ `polyclaude` starts [mitmproxy](https://mitmproxy.org) locally with a small
119
+ addon and points Claude Code at it (via `HTTPS_PROXY` + `NODE_EXTRA_CA_CERTS`,
120
+ scoped to that one process). The addon intercepts requests to
121
+ `api.anthropic.com/v1/messages`, translates the Anthropic request into the
122
+ provider's format, calls the provider, and streams the reply back in Anthropic's
123
+ event format. Every other host is passed straight through untouched.
124
+
125
+ Only sessions you launch with `polyclaude` are affected — your normal `claude`
126
+ is unchanged and still uses Anthropic.
127
+
128
+ ---
129
+
130
+ ## FAQ
131
+
132
+ **Does this change my Claude Code install?** No. It only sets proxy environment
133
+ variables for the session it launches.
134
+
135
+ **Do my keys leave my machine?** Only to the provider you choose (Google,
136
+ OpenAI, etc.), exactly as if you called their API directly. polyclaude has no
137
+ servers.
138
+
139
+ **Quality feels off on some models.** Different models behave differently inside
140
+ Claude Code's harness (its prompt and tools are tuned for Claude). Larger/newer
141
+ models fare better; try `--reasoning high` on OpenAI and compare providers.
142
+
143
+ **Can I use my own endpoint?** Yes — anything OpenAI-compatible. Point at it and
144
+ go (OpenRouter, vLLM, LM Studio, etc.).
145
+
146
+ ---
147
+
148
+ ## Notes
149
+
150
+ For personal and development use, with your own API keys. Respect the terms of
151
+ service of Claude Code and of whichever model provider you use.
152
+
153
+ ## License
154
+
155
+ MIT © Devesh Pratap
@@ -0,0 +1,134 @@
1
+ # polyclaude
2
+
3
+ **Use OpenAI, Gemini, or any OpenAI-compatible model inside the Claude Code CLI.**
4
+
5
+ Claude Code is a great terminal coding agent — but it only talks to Anthropic's
6
+ models. `polyclaude` lets you keep the exact Claude Code experience (its UI,
7
+ tools, and agent loop) while the actual model behind it is **GPT, Gemini, a Groq
8
+ model, an OpenRouter model, or a local Ollama model**.
9
+
10
+ It works by running a tiny local proxy that speaks Claude Code's API on one side
11
+ and your provider's API on the other, translating between them on the fly.
12
+ Claude Code itself is never modified.
13
+
14
+ ```
15
+ Claude Code ⇄ Anthropic Messages API ⇄ [ polyclaude ] ⇄ OpenAI / Gemini / …
16
+ ```
17
+
18
+ ---
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pipx install polyclaude # recommended
24
+ # or: uv tool install polyclaude
25
+ # or: pip install polyclaude
26
+ ```
27
+
28
+ You'll also need [Claude Code](https://docs.claude.com/claude-code) installed
29
+ (`claude` on your PATH) and an API key for whichever provider you want to use.
30
+
31
+ ## Use
32
+
33
+ ```bash
34
+ export GEMINI_API_KEY=... # or OPENAI_API_KEY, GROQ_API_KEY, …
35
+ polyclaude --gemini # Claude Code, powered by Gemini
36
+ polyclaude --openai # …powered by GPT
37
+ polyclaude --openai --model gpt-4.1
38
+ polyclaude --gemini --model gemini-2.5-pro
39
+ ```
40
+
41
+ That's it — you're dropped into a normal Claude Code session running on the
42
+ model you chose. First run auto-configures the local proxy certificate; nothing
43
+ else to set up.
44
+
45
+ Pass arguments straight through to `claude` after `--`:
46
+
47
+ ```bash
48
+ polyclaude --gemini -- -p "explain this repo"
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Providers
54
+
55
+ | Provider | Flag | Key env | Default model |
56
+ |---|---|---|---|
57
+ | Google Gemini | `--gemini` | `GEMINI_API_KEY` | `gemini-2.5-pro` |
58
+ | OpenAI | `--openai` | `OPENAI_API_KEY` | `gpt-4.1` |
59
+ | Groq | `--groq` | `GROQ_API_KEY` | `llama-3.3-70b-versatile` |
60
+ | OpenRouter | `--openrouter` | `OPENROUTER_API_KEY` | `anthropic/claude-3.5-sonnet` |
61
+ | Ollama (local) | `--ollama` | — | `qwen2.5-coder` |
62
+
63
+ Any model your key can access works via `--model`, including the newest ones
64
+ (Gemini 3.x, GPT-5.x). polyclaude handles the provider-specific details so tool
65
+ calling and reasoning keep working:
66
+
67
+ - **Gemini 3.x** thinking models need their function-call *thought signatures*
68
+ round-tripped across turns — handled automatically.
69
+ - **GPT-5.5 / 5.6** can't use tools and reasoning together on the standard
70
+ endpoint; polyclaude routes them through OpenAI's `/responses` API so you get
71
+ **both** (tune depth with `--reasoning low|medium|high`).
72
+ - Reasoning models reject non-default sampling params — dropped automatically.
73
+ - Turns that hit the output-token cap are transparently continued.
74
+
75
+ Run `polyclaude --list` to see everything.
76
+
77
+ ---
78
+
79
+ ## Specialize the agent
80
+
81
+ Give the agent a persona/use-case with a profile — it's appended to the system
82
+ prompt for the main loop only, so Claude Code's own tooling keeps working:
83
+
84
+ ```bash
85
+ polyclaude --gemini --profile datascience
86
+ polyclaude --openai --profile reviewer
87
+ polyclaude --gemini --profile ./my-profile.md # your own file
88
+ ```
89
+
90
+ Bundled profiles: `datascience`, `reviewer`, `concise`. Or replace the whole
91
+ system prompt with `--system path/to/file.md`.
92
+
93
+ ---
94
+
95
+ ## How it works
96
+
97
+ `polyclaude` starts [mitmproxy](https://mitmproxy.org) locally with a small
98
+ addon and points Claude Code at it (via `HTTPS_PROXY` + `NODE_EXTRA_CA_CERTS`,
99
+ scoped to that one process). The addon intercepts requests to
100
+ `api.anthropic.com/v1/messages`, translates the Anthropic request into the
101
+ provider's format, calls the provider, and streams the reply back in Anthropic's
102
+ event format. Every other host is passed straight through untouched.
103
+
104
+ Only sessions you launch with `polyclaude` are affected — your normal `claude`
105
+ is unchanged and still uses Anthropic.
106
+
107
+ ---
108
+
109
+ ## FAQ
110
+
111
+ **Does this change my Claude Code install?** No. It only sets proxy environment
112
+ variables for the session it launches.
113
+
114
+ **Do my keys leave my machine?** Only to the provider you choose (Google,
115
+ OpenAI, etc.), exactly as if you called their API directly. polyclaude has no
116
+ servers.
117
+
118
+ **Quality feels off on some models.** Different models behave differently inside
119
+ Claude Code's harness (its prompt and tools are tuned for Claude). Larger/newer
120
+ models fare better; try `--reasoning high` on OpenAI and compare providers.
121
+
122
+ **Can I use my own endpoint?** Yes — anything OpenAI-compatible. Point at it and
123
+ go (OpenRouter, vLLM, LM Studio, etc.).
124
+
125
+ ---
126
+
127
+ ## Notes
128
+
129
+ For personal and development use, with your own API keys. Respect the terms of
130
+ service of Claude Code and of whichever model provider you use.
131
+
132
+ ## License
133
+
134
+ MIT © Devesh Pratap
@@ -0,0 +1,47 @@
1
+ # Specializing the system prompt
2
+
3
+ polyclaude can inject a persona / use-case into the agent's system prompt so the
4
+ same Claude Code becomes a specialized assistant — without editing anything in
5
+ Claude Code itself.
6
+
7
+ ## Profiles
8
+
9
+ ```bash
10
+ polyclaude --gemini --profile datascience
11
+ polyclaude --openai --profile reviewer
12
+ polyclaude --gemini --profile concise
13
+ ```
14
+
15
+ The profile text is **appended** to the system prompt of the **main agent
16
+ loop** only. The lightweight side-calls Claude Code makes (conversation titles,
17
+ summaries, etc.) are left alone, so nothing breaks.
18
+
19
+ Bundled profiles: `datascience`, `reviewer`, `concise`.
20
+
21
+ ## Your own profile
22
+
23
+ Point `--profile` at any Markdown file:
24
+
25
+ ```bash
26
+ polyclaude --gemini --profile ./profiles/backend.md
27
+ ```
28
+
29
+ A profile is just plain instructions, e.g.:
30
+
31
+ ```markdown
32
+ # Backend engineer
33
+ You work on a Python + FastAPI + Postgres service. Prefer async, type hints,
34
+ and small pure functions. Always add or update tests for changed behavior.
35
+ ```
36
+
37
+ ## Replace the whole system prompt
38
+
39
+ For full control:
40
+
41
+ ```bash
42
+ polyclaude --gemini --system ./my-system.md
43
+ ```
44
+
45
+ This replaces the main system prompt entirely. Note that Claude Code's built-in
46
+ prompt carries important tool-usage contracts — replacing it wholesale can
47
+ change behavior, so append (a profile) unless you know you want a full swap.
@@ -0,0 +1,35 @@
1
+ # Providers & models
2
+
3
+ polyclaude works with any OpenAI-compatible backend. Presets:
4
+
5
+ | Provider | Flag | Key env | Base URL | Default model |
6
+ |---|---|---|---|---|
7
+ | Gemini | `--gemini` | `GEMINI_API_KEY` / `GOOGLE_API_KEY` | `generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.5-pro` |
8
+ | OpenAI | `--openai` | `OPENAI_API_KEY` | `api.openai.com/v1` | `gpt-4.1` |
9
+ | Groq | `--groq` | `GROQ_API_KEY` | `api.groq.com/openai/v1` | `llama-3.3-70b-versatile` |
10
+ | OpenRouter | `--openrouter` | `OPENROUTER_API_KEY` | `openrouter.ai/api/v1` | `anthropic/claude-3.5-sonnet` |
11
+ | Ollama | `--ollama` | — | `127.0.0.1:11434/v1` | `qwen2.5-coder` |
12
+
13
+ Override the model with `--model NAME`.
14
+
15
+ ## Newer models
16
+
17
+ These work; polyclaude smooths over their API differences:
18
+
19
+ - **Gemini 3.x** (`gemini-3.1-pro-preview`, `gemini-pro-latest`, …) — thinking
20
+ models. Their function calls carry a *thought signature* that must be sent
21
+ back on the next turn or the API rejects it; polyclaude stores and replays it.
22
+ - **GPT-5.x / 5.5 / 5.6** — on `/v1/chat/completions` these refuse to use tools
23
+ and reasoning at the same time. polyclaude routes `gpt-5.5`/`gpt-5.6` through
24
+ OpenAI's `/v1/responses` API so you get tools **and** full reasoning; control
25
+ depth with `--reasoning low|medium|high` (default `high`). Reasoning items are
26
+ round-tripped across turns.
27
+ - **o-series / any reasoning model** — non-default `temperature`/`top_p` are
28
+ dropped (they'd be rejected).
29
+
30
+ ## Tips
31
+
32
+ - `--reasoning` only affects OpenAI reasoning models routed via `/responses`.
33
+ - Free Groq keys have low per-minute token limits; large contexts may 429.
34
+ - For a fully local, no-key setup: install Ollama, `ollama pull qwen2.5-coder`,
35
+ then `polyclaude --ollama`.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "polyclaude"
7
+ version = "0.1.0"
8
+ description = "Use OpenAI, Gemini, and any OpenAI-compatible model inside the Claude Code CLI."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Devesh Pratap", email = "mail2deveshpratap@gmail.com" }]
13
+ keywords = ["claude-code", "claude", "openai", "gemini", "gpt", "llm", "cli", "anthropic", "proxy", "coding-agent"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Build Tools",
21
+ ]
22
+ dependencies = ["mitmproxy>=10"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/ibedevesh/polyclaude"
26
+ Repository = "https://github.com/ibedevesh/polyclaude"
27
+ Issues = "https://github.com/ibedevesh/polyclaude/issues"
28
+
29
+ [project.scripts]
30
+ polyclaude = "polyclaude.cli:main"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/polyclaude"]
@@ -0,0 +1,3 @@
1
+ """polyclaude — use OpenAI, Gemini, and any OpenAI-compatible model in Claude Code."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,466 @@
1
+ """
2
+ polyclaude bridge — a mitmproxy addon that answers Claude Code's Anthropic
3
+ Messages API requests from an OpenAI-compatible backend (OpenAI, Gemini, Groq,
4
+ OpenRouter, Ollama, …), translating both directions on the wire.
5
+
6
+ Claude Code is unmodified. It sends its turn to api.anthropic.com/v1/messages;
7
+ this addon intercepts that request, translates it to the backend's format,
8
+ calls the backend, and translates the reply back into the exact Anthropic
9
+ streaming (SSE) grammar Claude Code expects.
10
+
11
+ Configured entirely via POLYCLAUDE_* environment variables (set by the CLI):
12
+ POLYCLAUDE_BASE backend base URL (…/v1)
13
+ POLYCLAUDE_MODEL main-loop model
14
+ POLYCLAUDE_SMALL model for the lightweight side-calls (titles etc.)
15
+ POLYCLAUDE_KEY API key for the backend
16
+ POLYCLAUDE_REASONING OpenAI reasoning effort: low|medium|high (default high)
17
+ POLYCLAUDE_AUTOCONTINUE 1 = stitch a turn truncated at the token cap
18
+ POLYCLAUDE_MAXTOK output cap (default 32768)
19
+ POLYCLAUDE_SYSTEM_APPEND_FILE append this file to the main system prompt
20
+ POLYCLAUDE_SYSTEM_REPLACE_FILE replace the main system prompt with this file
21
+ POLYCLAUDE_LOG optional path to a wire log
22
+ """
23
+ import json
24
+ import os
25
+ import ssl
26
+ import urllib.error
27
+ import urllib.request
28
+
29
+ from mitmproxy import http
30
+
31
+ BASE = os.environ.get("POLYCLAUDE_BASE", "").rstrip("/")
32
+ MODEL = os.environ.get("POLYCLAUDE_MODEL", "gpt-4.1")
33
+ SMALL = os.environ.get("POLYCLAUDE_SMALL", MODEL)
34
+ API_KEY = os.environ.get("POLYCLAUDE_KEY", "")
35
+ REASONING = os.environ.get("POLYCLAUDE_REASONING", "high")
36
+ AUTOCONT = os.environ.get("POLYCLAUDE_AUTOCONTINUE", "1") == "1"
37
+ MAX_TOK = int(os.environ.get("POLYCLAUDE_MAXTOK", "32768"))
38
+ MAXCONT = int(os.environ.get("POLYCLAUDE_MAXCONT", "4"))
39
+ LOG = os.environ.get("POLYCLAUDE_LOG", "")
40
+
41
+ _ssl_ctx = ssl.create_default_context()
42
+ try:
43
+ import certifi
44
+ _ssl_ctx = ssl.create_default_context(cafile=certifi.where())
45
+ except Exception:
46
+ pass
47
+
48
+
49
+ def _read_file(var):
50
+ p = os.environ.get(var, "")
51
+ if not p:
52
+ return ""
53
+ try:
54
+ with open(os.path.expanduser(p)) as f:
55
+ return f.read().strip()
56
+ except Exception:
57
+ return ""
58
+
59
+
60
+ SYS_APPEND = _read_file("POLYCLAUDE_SYSTEM_APPEND_FILE")
61
+ SYS_REPLACE = _read_file("POLYCLAUDE_SYSTEM_REPLACE_FILE")
62
+
63
+ # per-tool-call state that must round-trip across turns
64
+ _SIG = {} # Gemini 3.x thought_signature, keyed by tool-call id
65
+ _RSN = {} # OpenAI /responses reasoning items, keyed by tool-call id
66
+
67
+
68
+ def _log(m):
69
+ if LOG:
70
+ try:
71
+ with open(LOG, "a") as f:
72
+ f.write(m + "\n")
73
+ except Exception:
74
+ pass
75
+
76
+
77
+ def _is_messages(flow):
78
+ return (flow.request.pretty_host.endswith("api.anthropic.com")
79
+ and "/v1/messages" in flow.request.path
80
+ and not flow.request.path.endswith("count_tokens"))
81
+
82
+
83
+ def _is_count(flow):
84
+ return (flow.request.pretty_host.endswith("api.anthropic.com")
85
+ and flow.request.path.endswith("count_tokens"))
86
+
87
+
88
+ def _pick_model(anthropic_model):
89
+ return SMALL if "haiku" in (anthropic_model or "") else MODEL
90
+
91
+
92
+ def _is_main_loop(body):
93
+ m = body.get("model", "")
94
+ tools = {t.get("name") for t in body.get("tools", []) if isinstance(t, dict)}
95
+ return ("opus" in m) or ("sonnet" in m) or ("Bash" in tools) or ("Edit" in tools)
96
+
97
+
98
+ def _sys_to_str(system):
99
+ if isinstance(system, str):
100
+ return system
101
+ if isinstance(system, list):
102
+ return "\n\n".join(b.get("text", "") for b in system
103
+ if isinstance(b, dict) and b.get("type") == "text")
104
+ return ""
105
+
106
+
107
+ def _apply_prompt_override(body):
108
+ """Append/replace the main-loop system prompt from the configured files."""
109
+ if not (SYS_APPEND or SYS_REPLACE) or not _is_main_loop(body):
110
+ return
111
+ sysv = body.get("system")
112
+ if SYS_REPLACE:
113
+ body["system"] = SYS_REPLACE
114
+ elif SYS_APPEND:
115
+ if isinstance(sysv, str):
116
+ body["system"] = sysv + "\n\n" + SYS_APPEND
117
+ elif isinstance(sysv, list):
118
+ sysv.append({"type": "text", "text": "\n\n" + SYS_APPEND})
119
+ else:
120
+ body["system"] = SYS_APPEND
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Anthropic request -> OpenAI chat/completions request
125
+ # ---------------------------------------------------------------------------
126
+ def _to_openai(body):
127
+ msgs = []
128
+ system = _sys_to_str(body.get("system", ""))
129
+ if system:
130
+ msgs.append({"role": "system", "content": system})
131
+
132
+ for m in body.get("messages", []):
133
+ role = m.get("role")
134
+ content = m.get("content")
135
+ if isinstance(content, str):
136
+ msgs.append({"role": role, "content": content})
137
+ continue
138
+ text_parts, tool_calls, tool_results = [], [], []
139
+ for blk in content or []:
140
+ if not isinstance(blk, dict):
141
+ continue
142
+ bt = blk.get("type")
143
+ if bt == "text":
144
+ text_parts.append(blk.get("text", ""))
145
+ elif bt == "tool_use":
146
+ tc = {"id": blk.get("id"), "type": "function",
147
+ "function": {"name": blk.get("name"),
148
+ "arguments": json.dumps(blk.get("input", {}))}}
149
+ sig = _SIG.get(blk.get("id"))
150
+ if sig:
151
+ tc["extra_content"] = {"google": {"thought_signature": sig}}
152
+ tool_calls.append(tc)
153
+ elif bt == "tool_result":
154
+ rc = blk.get("content")
155
+ if isinstance(rc, list):
156
+ rc = "".join(x.get("text", "") for x in rc
157
+ if isinstance(x, dict))
158
+ elif not isinstance(rc, str):
159
+ rc = json.dumps(rc)
160
+ tool_results.append((blk.get("tool_use_id"), rc or ""))
161
+ elif bt == "image":
162
+ text_parts.append("[image omitted]")
163
+ if role == "assistant":
164
+ am = {"role": "assistant", "content": "\n".join(text_parts) or None}
165
+ if tool_calls:
166
+ am["tool_calls"] = tool_calls
167
+ msgs.append(am)
168
+ else:
169
+ for tid, rc in tool_results:
170
+ msgs.append({"role": "tool", "tool_call_id": tid, "content": rc})
171
+ if text_parts:
172
+ msgs.append({"role": "user", "content": "\n".join(text_parts)})
173
+
174
+ tgt = _pick_model(body.get("model", ""))
175
+ out = {"model": tgt, "messages": msgs,
176
+ "max_completion_tokens": min(int(body.get("max_tokens", MAX_TOK)),
177
+ MAX_TOK)}
178
+ reasoning = tgt.startswith(("gpt-5", "gpt-6", "o1", "o3", "o4"))
179
+ if not reasoning:
180
+ if body.get("temperature") is not None:
181
+ out["temperature"] = body["temperature"]
182
+ if body.get("top_p") is not None:
183
+ out["top_p"] = body["top_p"]
184
+ if body.get("stop_sequences"):
185
+ out["stop"] = body["stop_sequences"]
186
+
187
+ fn_tools = [{"type": "function",
188
+ "function": {"name": t.get("name"),
189
+ "description": t.get("description", ""),
190
+ "parameters": t.get("input_schema",
191
+ {"type": "object"})}}
192
+ for t in (body.get("tools") or []) if "input_schema" in t]
193
+ if fn_tools:
194
+ out["tools"] = fn_tools
195
+ tc = body.get("tool_choice") or {}
196
+ tt = tc.get("type")
197
+ out["tool_choice"] = ("required" if tt == "any"
198
+ else {"type": "function",
199
+ "function": {"name": tc["name"]}}
200
+ if tt == "tool" and tc.get("name") else "auto")
201
+ return out
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Anthropic request -> OpenAI /responses request (tools + full reasoning)
206
+ # ---------------------------------------------------------------------------
207
+ def _to_responses(body):
208
+ inp, seen = [], set()
209
+ for m in body.get("messages", []):
210
+ role = m.get("role")
211
+ content = m.get("content")
212
+ if isinstance(content, str):
213
+ inp.append({"role": role, "content": content})
214
+ continue
215
+ buf = []
216
+ if role == "assistant":
217
+ for blk in content or []:
218
+ bt = blk.get("type") if isinstance(blk, dict) else None
219
+ if bt == "text":
220
+ buf.append(blk.get("text", ""))
221
+ elif bt == "tool_use":
222
+ if buf:
223
+ inp.append({"role": "assistant", "content": "\n".join(buf)})
224
+ buf = []
225
+ for r in _RSN.get(blk.get("id"), []):
226
+ rid = r.get("id")
227
+ if rid and rid not in seen:
228
+ inp.append(r)
229
+ seen.add(rid)
230
+ inp.append({"type": "function_call",
231
+ "call_id": blk.get("id"), "name": blk.get("name"),
232
+ "arguments": json.dumps(blk.get("input", {}))})
233
+ if buf:
234
+ inp.append({"role": "assistant", "content": "\n".join(buf)})
235
+ else:
236
+ for blk in content or []:
237
+ bt = blk.get("type") if isinstance(blk, dict) else None
238
+ if bt == "text":
239
+ buf.append(blk.get("text", ""))
240
+ elif bt == "image":
241
+ buf.append("[image omitted]")
242
+ elif bt == "tool_result":
243
+ rc = blk.get("content")
244
+ if isinstance(rc, list):
245
+ rc = "".join(x.get("text", "") for x in rc
246
+ if isinstance(x, dict))
247
+ elif not isinstance(rc, str):
248
+ rc = json.dumps(rc)
249
+ inp.append({"type": "function_call_output",
250
+ "call_id": blk.get("tool_use_id"),
251
+ "output": rc or ""})
252
+ if buf:
253
+ inp.append({"role": "user", "content": "\n".join(buf)})
254
+
255
+ out = {"model": _pick_model(body.get("model", "")), "input": inp,
256
+ "store": False, "reasoning": {"effort": REASONING},
257
+ "include": ["reasoning.encrypted_content"],
258
+ "max_output_tokens": min(int(body.get("max_tokens", MAX_TOK)),
259
+ MAX_TOK)}
260
+ instr = _sys_to_str(body.get("system", ""))
261
+ if instr:
262
+ out["instructions"] = instr
263
+ fn = [{"type": "function", "name": t.get("name"),
264
+ "description": t.get("description", ""),
265
+ "parameters": t.get("input_schema", {"type": "object"})}
266
+ for t in (body.get("tools") or []) if "input_schema" in t]
267
+ if fn:
268
+ out["tools"] = fn
269
+ tc = body.get("tool_choice") or {}
270
+ tt = tc.get("type")
271
+ out["tool_choice"] = ("required" if tt == "any"
272
+ else {"type": "function", "name": tc["name"]}
273
+ if tt == "tool" and tc.get("name") else "auto")
274
+ return out
275
+
276
+
277
+ def _use_responses(tgt):
278
+ return "openai.com" in BASE and (
279
+ os.environ.get("POLYCLAUDE_RESPONSES_FORCE") == "1"
280
+ or tgt.startswith("gpt-5.6") or tgt.startswith("gpt-5.5"))
281
+
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # HTTP to the backend
285
+ # ---------------------------------------------------------------------------
286
+ def _post(path, payload, timeout):
287
+ req = urllib.request.Request(
288
+ f"{BASE}/{path}", data=json.dumps(payload).encode(),
289
+ headers={"Authorization": f"Bearer {API_KEY}",
290
+ "Content-Type": "application/json",
291
+ "Accept": "application/json",
292
+ "User-Agent": "polyclaude/0.1"},
293
+ method="POST")
294
+ with urllib.request.urlopen(req, timeout=timeout, context=_ssl_ctx) as r:
295
+ return json.loads(r.read())
296
+
297
+
298
+ def _chat(payload):
299
+ oai = _post("chat/completions", payload, 120)
300
+ if not AUTOCONT:
301
+ return oai
302
+ for _ in range(MAXCONT):
303
+ ch = (oai.get("choices") or [{}])[0]
304
+ msg = ch.get("message", {})
305
+ if ch.get("finish_reason") != "length" or msg.get("tool_calls"):
306
+ break
307
+ cont = dict(payload)
308
+ cont["messages"] = payload["messages"] + [
309
+ {"role": "assistant", "content": msg.get("content") or ""},
310
+ {"role": "user", "content": "continue"}]
311
+ nxt = _post("chat/completions", cont, 120)
312
+ nmsg = (nxt.get("choices") or [{}])[0].get("message", {})
313
+ oai["choices"][0]["message"]["content"] = \
314
+ (msg.get("content") or "") + (nmsg.get("content") or "")
315
+ oai["choices"][0]["finish_reason"] = \
316
+ (nxt.get("choices") or [{}])[0].get("finish_reason")
317
+ if nmsg.get("tool_calls"):
318
+ oai["choices"][0]["message"]["tool_calls"] = nmsg["tool_calls"]
319
+ payload = cont
320
+ return oai
321
+
322
+
323
+ def _responses_to_oai(resp):
324
+ text, tool_calls, pending = "", [], []
325
+ for it in resp.get("output", []):
326
+ t = it.get("type")
327
+ if t == "reasoning":
328
+ pending.append(it)
329
+ elif t == "message":
330
+ for c in it.get("content", []):
331
+ if c.get("type") == "output_text":
332
+ text += c.get("text", "")
333
+ pending = []
334
+ elif t == "function_call":
335
+ cid = it.get("call_id") or it.get("id")
336
+ tool_calls.append({"id": cid, "type": "function",
337
+ "function": {"name": it.get("name"),
338
+ "arguments": it.get("arguments") or "{}"}})
339
+ if pending:
340
+ _RSN[cid] = list(pending)
341
+ reason = (resp.get("incomplete_details") or {}).get("reason")
342
+ finish = ("tool_calls" if tool_calls
343
+ else "length" if reason == "max_output_tokens" else "stop")
344
+ u = resp.get("usage", {})
345
+ return {"id": resp.get("id", "msg"),
346
+ "choices": [{"message": {"content": text, "tool_calls": tool_calls},
347
+ "finish_reason": finish}],
348
+ "usage": {"prompt_tokens": u.get("input_tokens", 0),
349
+ "completion_tokens": u.get("output_tokens", 0)}}
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # OpenAI response -> Anthropic SSE
354
+ # ---------------------------------------------------------------------------
355
+ def _sse(event, data):
356
+ return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode()
357
+
358
+
359
+ _STOP = {"stop": "end_turn", "length": "max_tokens",
360
+ "tool_calls": "tool_use", "content_filter": "end_turn"}
361
+
362
+
363
+ def _to_sse(oai, anthropic_model):
364
+ ch = (oai.get("choices") or [{}])[0]
365
+ msg = ch.get("message", {})
366
+ text = msg.get("content") or ""
367
+ tool_calls = msg.get("tool_calls") or []
368
+ finish = ch.get("finish_reason", "stop")
369
+ usage = oai.get("usage", {})
370
+ out = b""
371
+ out += _sse("message_start", {"type": "message_start", "message": {
372
+ "id": oai.get("id", "msg"), "type": "message", "role": "assistant",
373
+ "model": anthropic_model, "content": [], "stop_reason": None,
374
+ "stop_sequence": None,
375
+ "usage": {"input_tokens": usage.get("prompt_tokens", 0),
376
+ "output_tokens": 0}}})
377
+ out += _sse("ping", {"type": "ping"})
378
+ idx = 0
379
+ if text:
380
+ out += _sse("content_block_start", {"type": "content_block_start",
381
+ "index": idx, "content_block": {"type": "text", "text": ""}})
382
+ out += _sse("content_block_delta", {"type": "content_block_delta",
383
+ "index": idx,
384
+ "delta": {"type": "text_delta", "text": text}})
385
+ out += _sse("content_block_stop",
386
+ {"type": "content_block_stop", "index": idx})
387
+ idx += 1
388
+ for tc in tool_calls:
389
+ fn = tc.get("function", {})
390
+ tid = tc.get("id", f"toolu_{idx}")
391
+ sig = (((tc.get("extra_content") or {}).get("google") or {})
392
+ .get("thought_signature"))
393
+ if sig:
394
+ _SIG[tid] = sig
395
+ out += _sse("content_block_start", {"type": "content_block_start",
396
+ "index": idx,
397
+ "content_block": {"type": "tool_use", "id": tid,
398
+ "name": fn.get("name"), "input": {}}})
399
+ out += _sse("content_block_delta", {"type": "content_block_delta",
400
+ "index": idx,
401
+ "delta": {"type": "input_json_delta",
402
+ "partial_json": fn.get("arguments") or "{}"}})
403
+ out += _sse("content_block_stop",
404
+ {"type": "content_block_stop", "index": idx})
405
+ idx += 1
406
+ stop = "tool_use" if tool_calls else _STOP.get(finish, "end_turn")
407
+ out += _sse("message_delta", {"type": "message_delta",
408
+ "delta": {"stop_reason": stop, "stop_sequence": None},
409
+ "usage": {"output_tokens": usage.get("completion_tokens", 0)}})
410
+ out += _sse("message_stop", {"type": "message_stop"})
411
+ return out
412
+
413
+
414
+ def _error_sse(model, message):
415
+ return _to_sse({"id": "err", "choices": [{"message": {
416
+ "content": f"[polyclaude error] {message}"},
417
+ "finish_reason": "stop"}], "usage": {}}, model)
418
+
419
+
420
+ # ---------------------------------------------------------------------------
421
+ # mitmproxy hook
422
+ # ---------------------------------------------------------------------------
423
+ class Bridge:
424
+ def request(self, flow: http.HTTPFlow):
425
+ if _is_count(flow):
426
+ try:
427
+ body = json.loads(flow.request.content or b"{}")
428
+ except Exception:
429
+ body = {}
430
+ flow.response = http.Response.make(
431
+ 200, json.dumps({"input_tokens": len(json.dumps(body)) // 4}
432
+ ).encode(), {"content-type": "application/json"})
433
+ return
434
+ if not _is_messages(flow):
435
+ return
436
+ try:
437
+ body = json.loads(flow.request.content or b"{}")
438
+ except Exception:
439
+ return
440
+ model = body.get("model", "claude-opus")
441
+ _apply_prompt_override(body)
442
+ tgt = _pick_model(model)
443
+ _log(f"\n=== {model} -> {tgt} tools={len(body.get('tools') or [])} "
444
+ f"msgs={len(body.get('messages', []))} ===")
445
+ try:
446
+ if _use_responses(tgt):
447
+ oai = _responses_to_oai(_post("responses", _to_responses(body), 300))
448
+ _log(f">>> via /responses (reasoning={REASONING})")
449
+ else:
450
+ oai = _chat(_to_openai(body))
451
+ ch = (oai.get("choices") or [{}])[0]
452
+ _log(f">>> finish={ch.get('finish_reason')} "
453
+ f"usage={oai.get('usage', {})}")
454
+ sse = _to_sse(oai, model)
455
+ except urllib.error.HTTPError as e:
456
+ detail = e.read().decode(errors="replace")[:400]
457
+ _log(f"!!! backend {e.code}: {detail}")
458
+ sse = _error_sse(model, f"backend {e.code}: {detail}")
459
+ except Exception as e:
460
+ _log(f"!!! {e!r}")
461
+ sse = _error_sse(model, repr(e))
462
+ flow.response = http.Response.make(
463
+ 200, sse, {"content-type": "text/event-stream; charset=utf-8"})
464
+
465
+
466
+ addons = [Bridge()]
@@ -0,0 +1,206 @@
1
+ """polyclaude CLI — start the bridge and launch Claude Code on a chosen model."""
2
+ import argparse
3
+ import atexit
4
+ import importlib.util
5
+ import os
6
+ import shutil
7
+ import socket
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from . import __version__
14
+ from .providers import PROVIDERS
15
+
16
+ CA = Path.home() / ".mitmproxy" / "mitmproxy-ca-cert.pem"
17
+
18
+
19
+ def _die(msg, code=1):
20
+ print(f"polyclaude: {msg}", file=sys.stderr)
21
+ sys.exit(code)
22
+
23
+
24
+ def _find(exe):
25
+ """Locate an executable, preferring the one next to our interpreter."""
26
+ local = Path(sys.executable).parent / exe
27
+ if local.exists():
28
+ return str(local)
29
+ return shutil.which(exe)
30
+
31
+
32
+ def _resolve_key(prov):
33
+ for var in prov["key_env"]:
34
+ v = os.environ.get(var)
35
+ if v:
36
+ return v
37
+ return None
38
+
39
+
40
+ def _profile_path(name):
41
+ # a path wins; otherwise look up a bundled profile by name
42
+ if os.path.sep in name or name.endswith(".md"):
43
+ p = Path(name).expanduser()
44
+ return str(p) if p.exists() else None
45
+ bundled = Path(__file__).parent / "profiles" / f"{name}.md"
46
+ return str(bundled) if bundled.exists() else None
47
+
48
+
49
+ def _free_port(start):
50
+ for p in range(start, start + 40):
51
+ with socket.socket() as s:
52
+ if s.connect_ex(("127.0.0.1", p)) != 0:
53
+ return p
54
+ return start
55
+
56
+
57
+ def _wait_listen(port, timeout=25):
58
+ end = time.time() + timeout
59
+ while time.time() < end:
60
+ with socket.socket() as s:
61
+ if s.connect_ex(("127.0.0.1", port)) == 0:
62
+ return True
63
+ time.sleep(0.2)
64
+ return False
65
+
66
+
67
+ def main(argv=None):
68
+ argv = list(sys.argv[1:] if argv is None else argv)
69
+ passthrough = []
70
+ if "--" in argv:
71
+ i = argv.index("--")
72
+ passthrough = argv[i + 1:]
73
+ argv = argv[:i]
74
+
75
+ ap = argparse.ArgumentParser(
76
+ prog="polyclaude",
77
+ description="Use OpenAI, Gemini, and any OpenAI-compatible model in Claude Code.",
78
+ epilog="Examples:\n"
79
+ " polyclaude --gemini\n"
80
+ " polyclaude --openai --model gpt-4.1\n"
81
+ " polyclaude --gemini --profile datascience\n"
82
+ " polyclaude --openai -- -p \"one-shot prompt\"",
83
+ formatter_class=argparse.RawDescriptionHelpFormatter)
84
+ g = ap.add_mutually_exclusive_group()
85
+ for name in PROVIDERS:
86
+ g.add_argument(f"--{name}", dest="provider", action="store_const",
87
+ const=name, help=f"use {name}")
88
+ ap.add_argument("--provider", dest="provider2",
89
+ help="provider name (alternative to the flags above)")
90
+ ap.add_argument("--model", help="model override")
91
+ ap.add_argument("--profile", help="specialize the system prompt "
92
+ "(bundled name or path to a .md file)")
93
+ ap.add_argument("--system", help="replace the whole main system prompt with this file")
94
+ ap.add_argument("--reasoning", choices=["low", "medium", "high"],
95
+ help="OpenAI reasoning depth (default high)")
96
+ ap.add_argument("--port", type=int, default=8118, help="proxy port (default 8118)")
97
+ ap.add_argument("--list", action="store_true", help="list providers and exit")
98
+ ap.add_argument("--verbose", action="store_true", help="print the wire log path")
99
+ ap.add_argument("--version", action="version",
100
+ version=f"polyclaude {__version__}")
101
+ args = ap.parse_args(argv)
102
+
103
+ if args.list:
104
+ for n, p in PROVIDERS.items():
105
+ key = (p["key_env"][0] if p["key_env"] else "(none)")
106
+ print(f" --{n:<11} default {p['model']:<28} key: {key}")
107
+ return
108
+
109
+ provider = args.provider or args.provider2 or "gemini"
110
+ if provider not in PROVIDERS:
111
+ _die(f"unknown provider '{provider}'. Try: {', '.join(PROVIDERS)}")
112
+ prov = PROVIDERS[provider]
113
+
114
+ key = _resolve_key(prov)
115
+ if prov["key_env"] and not key:
116
+ _die(f"no API key found. Set one of {prov['key_env']} in your "
117
+ f"environment.\n {prov['help']}")
118
+
119
+ claude = shutil.which("claude")
120
+ if not claude:
121
+ _die("the `claude` CLI is not installed or not on PATH.\n"
122
+ " Install: https://docs.claude.com/claude-code")
123
+ mitmdump = _find("mitmdump")
124
+ if not mitmdump:
125
+ _die("mitmproxy not found (it is a dependency; try reinstalling polyclaude).")
126
+
127
+ bridge = importlib.util.find_spec("polyclaude.bridge").origin
128
+ model = args.model or prov["model"]
129
+ port = _free_port(args.port)
130
+ log = "/tmp/polyclaude.log"
131
+
132
+ env = os.environ.copy()
133
+ env.update({
134
+ "POLYCLAUDE_BASE": prov["base"],
135
+ "POLYCLAUDE_MODEL": model,
136
+ "POLYCLAUDE_SMALL": prov["small"],
137
+ "POLYCLAUDE_KEY": key or "none",
138
+ "POLYCLAUDE_REASONING": args.reasoning or "high",
139
+ "POLYCLAUDE_AUTOCONTINUE": "1",
140
+ "POLYCLAUDE_LOG": log,
141
+ })
142
+ if args.system:
143
+ sp = Path(args.system).expanduser()
144
+ if not sp.exists():
145
+ _die(f"--system file not found: {args.system}")
146
+ env["POLYCLAUDE_SYSTEM_REPLACE_FILE"] = str(sp)
147
+ elif args.profile:
148
+ pp = _profile_path(args.profile)
149
+ if not pp:
150
+ avail = ", ".join(sorted(
151
+ p.stem for p in (Path(__file__).parent / "profiles").glob("*.md")))
152
+ _die(f"unknown profile '{args.profile}'. Bundled: {avail} "
153
+ f"(or pass a path to a .md file)")
154
+ env["POLYCLAUDE_SYSTEM_APPEND_FILE"] = pp
155
+
156
+ # a proxy env must NOT leak into the proxy's own upstream call
157
+ for v in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
158
+ env.pop(v, None)
159
+
160
+ print(f"polyclaude {__version__} · {provider} · {model}")
161
+ open(log, "w").close()
162
+ proxy = subprocess.Popen(
163
+ [mitmdump, "-q", "-s", bridge, "--listen-host", "127.0.0.1",
164
+ "--listen-port", str(port), "--allow-hosts", r"anthropic\.com"],
165
+ env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
166
+
167
+ def _cleanup():
168
+ if proxy.poll() is None:
169
+ proxy.terminate()
170
+ try:
171
+ proxy.wait(timeout=5)
172
+ except Exception:
173
+ proxy.kill()
174
+ atexit.register(_cleanup)
175
+
176
+ if not _wait_listen(port):
177
+ _cleanup()
178
+ _die("the bridge proxy did not start in time.")
179
+ # mitmproxy creates its CA on first start; wait for it
180
+ for _ in range(50):
181
+ if CA.exists():
182
+ break
183
+ time.sleep(0.1)
184
+ if not CA.exists():
185
+ _cleanup()
186
+ _die("mitmproxy CA not found; run `mitmdump` once to generate it, then retry.")
187
+
188
+ if args.verbose:
189
+ print(f" wire log: tail -f {log}")
190
+ print(f" launching Claude Code (Ctrl-C to exit)\n")
191
+
192
+ run_env = os.environ.copy()
193
+ run_env["HTTPS_PROXY"] = f"http://127.0.0.1:{port}"
194
+ run_env["NODE_EXTRA_CA_CERTS"] = str(CA)
195
+ run_env.setdefault("ANTHROPIC_API_KEY", "sk-polyclaude-bridge")
196
+ cmd = [claude, *passthrough]
197
+ try:
198
+ rc = subprocess.call(cmd, env=run_env)
199
+ except KeyboardInterrupt:
200
+ rc = 130
201
+ _cleanup()
202
+ sys.exit(rc)
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()
@@ -0,0 +1,6 @@
1
+ # Concise
2
+
3
+ Be terse. Lead with the answer or the change. No preamble, no restating the
4
+ question, no filler. Minimal explanation unless asked. Prefer diffs, commands,
5
+ and short bullet points over prose. If a one-line answer is correct, give exactly
6
+ one line.
@@ -0,0 +1,18 @@
1
+ # Data Science specialist
2
+
3
+ You are operating as a senior data scientist / ML engineer. Bias all work toward:
4
+
5
+ - **Stack**: pandas / polars, numpy, scikit-learn, statsmodels, PyTorch; notebooks
6
+ and reproducible scripts.
7
+ - **Inspect before you model**: check shapes, dtypes, missingness, duplicates,
8
+ class balance, and target leakage before touching a model — and say what you saw.
9
+ - **Reproducibility**: set random seeds, pin the split, log versions.
10
+ - **Sound statistics**: state assumptions; prefer effect sizes and confidence
11
+ intervals over bare p-values; avoid p-hacking and multiple-comparison traps.
12
+ - **Honest evaluation**: hold out data, cross-validate, pick the metric that
13
+ matches the goal, check calibration and error slices — never present train-set
14
+ performance as if it generalizes.
15
+ - **Performance**: vectorize; be memory-aware on large frames; prefer
16
+ chunking/streaming for big data.
17
+ - **Communication**: clear plots (labeled axes + units), short tradeoff notes,
18
+ runnable code. Never claim things about data you have not inspected.
@@ -0,0 +1,14 @@
1
+ # Code reviewer
2
+
3
+ You are a senior code reviewer. When reviewing or writing code:
4
+
5
+ - Prioritize correctness, then readability, then performance.
6
+ - Flag real defects with the exact file:line and a concrete failure scenario
7
+ (inputs → wrong output), not vague concerns.
8
+ - Watch for: off-by-one and boundary bugs, error/exception handling, resource
9
+ leaks, race conditions, input validation, and security-sensitive sinks.
10
+ - Prefer the smallest change that fixes the issue; call out unnecessary
11
+ complexity and suggest simplifications.
12
+ - Distinguish blocking issues from nits, and say which is which.
13
+ - When you change code, match the surrounding style and add tests where they
14
+ meaningfully reduce risk.
@@ -0,0 +1,44 @@
1
+ """Provider presets: base URL, default models, and which env var holds the key.
2
+
3
+ Defaults are broadly-available models. Override any with `--model NAME`, and
4
+ newer models (Gemini 3.x, GPT-5.x/5.6) work too — the bridge handles their
5
+ quirks automatically.
6
+ """
7
+
8
+ PROVIDERS = {
9
+ "gemini": {
10
+ "base": "https://generativelanguage.googleapis.com/v1beta/openai",
11
+ "model": "gemini-2.5-pro",
12
+ "small": "gemini-2.5-flash",
13
+ "key_env": ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
14
+ "help": "Get a key at https://aistudio.google.com/apikey",
15
+ },
16
+ "openai": {
17
+ "base": "https://api.openai.com/v1",
18
+ "model": "gpt-4.1",
19
+ "small": "gpt-4.1-mini",
20
+ "key_env": ["OPENAI_API_KEY"],
21
+ "help": "Get a key at https://platform.openai.com/api-keys",
22
+ },
23
+ "groq": {
24
+ "base": "https://api.groq.com/openai/v1",
25
+ "model": "llama-3.3-70b-versatile",
26
+ "small": "llama-3.1-8b-instant",
27
+ "key_env": ["GROQ_API_KEY"],
28
+ "help": "Get a free key at https://console.groq.com/keys",
29
+ },
30
+ "openrouter": {
31
+ "base": "https://openrouter.ai/api/v1",
32
+ "model": "anthropic/claude-3.5-sonnet",
33
+ "small": "anthropic/claude-3.5-haiku",
34
+ "key_env": ["OPENROUTER_API_KEY"],
35
+ "help": "Get a key at https://openrouter.ai/keys",
36
+ },
37
+ "ollama": {
38
+ "base": "http://127.0.0.1:11434/v1",
39
+ "model": "qwen2.5-coder",
40
+ "small": "qwen2.5-coder",
41
+ "key_env": [], # local, no key
42
+ "help": "Install from https://ollama.com and `ollama pull qwen2.5-coder`",
43
+ },
44
+ }