claude-autorouter 0.4.0 → 0.5.0
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.
- package/.env.example +2 -1
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +21 -1
- package/README.md +12 -8
- package/SECURITY.md +23 -0
- package/SUPPORT.md +18 -0
- package/docs/reference.md +22 -6
- package/docs/releasing.md +5 -3
- package/docs/subscription-integration.md +27 -0
- package/package.json +10 -2
- package/src/cli-help.mjs +10 -7
- package/src/config-command.mjs +25 -8
- package/src/config.mjs +1 -1
- package/src/keychain.mjs +58 -0
- package/src/onboarding.mjs +54 -9
- package/src/prompt-state.mjs +22 -7
- package/src/redaction.mjs +97 -0
- package/src/router.mjs +1 -1
- package/src/session-history.mjs +2 -1
- package/src/telemetry-event.mjs +4 -0
- package/src/user-config.mjs +67 -6
package/.env.example
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Copy to .env and load with: node --env-file=.env bin/autorouter.mjs claude
|
|
2
2
|
AUTOROUTER_AUTH_MODE=subscription
|
|
3
3
|
AUTOROUTER_CLIENT_PROFILE=compatible
|
|
4
|
-
#
|
|
4
|
+
# Local Ollama is the default evaluator (experimental; see settings below).
|
|
5
|
+
# Set jev to use TypeSafe's hosted evaluator, which needs TYPESAFE_API_KEY.
|
|
5
6
|
AUTOROUTER_EVALUATOR=jev
|
|
6
7
|
# The launcher enables the router status line for this session. Set 0 to keep your own.
|
|
7
8
|
AUTOROUTER_STATUSLINE=1
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Code of conduct
|
|
2
|
+
|
|
3
|
+
Be respectful and constructive in issues, pull requests, reviews and other project spaces. Welcome people with different backgrounds and experience levels, focus criticism on ideas and code, and respect requests to stop unwanted interaction.
|
|
4
|
+
|
|
5
|
+
Harassment, discriminatory or demeaning remarks, sexualized conduct, threats, personal attacks, and publishing someone's private information without consent are not acceptable. The same expectations apply when representing the project outside its repository.
|
|
6
|
+
|
|
7
|
+
Report concerns privately to maintainer Fabio Rapposelli at [fabio@rapposelli.org](mailto:fabio@rapposelli.org), with the subject `AutoRouter conduct report`. Include relevant links and enough context to investigate; avoid publishing the report or unrelated private information. Reports will be handled with discretion, sharing information only as needed to investigate and respond.
|
|
8
|
+
|
|
9
|
+
The maintainer may request changes, remove content, issue warnings, or temporarily or permanently restrict participation according to the severity and pattern of behavior. Requests to review a decision can be sent to the same address. This policy is maintained on a best-effort basis and does not promise a response deadline.
|
package/CONTRIBUTING.md
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
# Contributing to AutoRouter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Bug reports, documentation improvements, reproducible routing cases and focused fixes are welcome. Read the [support guide](SUPPORT.md) before opening an issue, and use the [private security reporting process](SECURITY.md) for vulnerabilities. Participation follows the [code of conduct](CODE_OF_CONDUCT.md).
|
|
4
|
+
|
|
5
|
+
For a substantial behavior change, open an issue describing the problem and proposed scope before implementing it. Fabio Rapposelli ([@frapposelli](https://github.com/frapposelli)) maintains the project and reviews design and release decisions. Review is best effort; there is no guaranteed response time.
|
|
6
|
+
|
|
7
|
+
Coding agents working in a source checkout should follow [AGENTS.md](https://github.com/frapposelli/claude-autorouter/blob/main/AGENTS.md). [CLAUDE.md](https://github.com/frapposelli/claude-autorouter/blob/main/CLAUDE.md) imports the same guidance for Claude Code.
|
|
8
|
+
|
|
9
|
+
## Submit a change
|
|
10
|
+
|
|
11
|
+
Fork the repository, clone your fork and create a branch. While the repository is private, this requires access and permission to fork; existing collaborators can use a branch in their authorized checkout.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
git clone https://github.com/YOUR-USERNAME/claude-autorouter.git
|
|
15
|
+
cd claude-autorouter
|
|
16
|
+
git switch -c describe-your-change
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Use Node.js 22+ and macOS or Linux (including WSL). Install pinned development tools, then run the local checks:
|
|
4
20
|
|
|
5
21
|
```sh
|
|
6
22
|
npm ci --ignore-scripts --no-audit --no-fund
|
|
@@ -11,6 +27,10 @@ npm run test:package
|
|
|
11
27
|
|
|
12
28
|
Tests use synthetic local services and credentials. They require loopback binding, but make no paid provider calls, downloads or user-config changes. The package check installs and exercises the exact distributable archive. Opt-in provider/model canaries are described in [development and validation](docs/development.md).
|
|
13
29
|
|
|
30
|
+
Open a pull request against `main`. Describe the problem, resulting behavior and relevant validation, linking an issue when one exists. Keep the change focused, include meaningful regressions for behavior changes, and update affected help or documentation. Report any checks you could not run. Real provider calls and model downloads are not required for ordinary contributions; label their results separately if deliberately run.
|
|
31
|
+
|
|
32
|
+
Use synthetic fixtures. Do not commit credentials, personal configuration, private prompts, transcripts or session logs. Metadata-only logs can still contain identifying information; inspect any material before sharing it. Contributions are accepted under the project's [Apache-2.0 license](LICENSE); submit only work you have the right to contribute. No CLA or sign-off workflow is required.
|
|
33
|
+
|
|
14
34
|
## Changing behavior
|
|
15
35
|
|
|
16
36
|
Follow the [request lifecycle](docs/development.md#request-lifecycle-and-model-continuity). Keep authentication and permission decisions owned by Claude. Preserve provider bytes, signed history and unfamiliar extensions. Routing must check compatibility in every profile; new human tasks remain eligible to switch models. Active task state is separate from disposable classification caches, and only clean, successfully forwarded completion evidence can establish confirmed continuation state.
|
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
#
|
|
1
|
+
# AutoRouter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An independent local model-routing gateway for Claude Code. AutoRouter is not affiliated with, endorsed by, or sponsored by Anthropic. The existing npm package and command remain `claude-autorouter`.
|
|
4
|
+
|
|
5
|
+
Use Haiku, Sonnet and Opus in one Claude Code session. AutoRouter evaluates each coding request, checks model compatibility and context capacity, and forwards it through a local gateway. Native Ollama `/v1/systemone` models are the default, experimental local evaluator, so task excerpts stay on your machine; [TypeSafe Jev](https://typesafe.ai/blog/introducing-system-one-models-and-jev) is an optional hosted evaluator (`setup --evaluator jev`). Claude owns authentication, tool permissions and safety review.
|
|
4
6
|
|
|
5
7
|
Requires Node.js 22+, macOS or Linux (including WSL), an installed `claude` command, and a Claude subscription login or Anthropic API key. The default evaluator also needs a [TypeSafe API key](https://console.typesafe.ai). The installed CLI has no runtime dependencies.
|
|
6
8
|
|
|
@@ -16,9 +18,11 @@ cd /path/to/project
|
|
|
16
18
|
claude-autorouter claude
|
|
17
19
|
```
|
|
18
20
|
|
|
19
|
-
Setup defaults to your Claude subscription and prompts privately for
|
|
21
|
+
Setup defaults to your Claude subscription and the local Ollama evaluator (Ollama 0.35+ with the default model; add `--pull` to download it), so it asks for no evaluator key. To use hosted Jev instead, run `claude-autorouter setup --evaluator jev`, which prompts privately for its key. Run `claude auth login` if needed. Jev has separate credentials and billing; subscription mode needs no Anthropic API key. For API billing, use `setup --auth-mode api-key`.
|
|
22
|
+
|
|
23
|
+
AutoRouter launches your installed, unmodified official Claude Code executable. Each user supplies their own login or API credentials. Subscription forwarding is a technical integration, not a claim of provider approval; review the [integration boundaries and current provider-policy notes](docs/subscription-integration.md) for your deployment.
|
|
20
24
|
|
|
21
|
-
Configuration is saved privately at `~/.config/claude-autorouter/config.json`. Environment variables override it; project `.env` files are not loaded automatically. `setup --force` updates an existing configuration while preserving other settings. Use focused commands for later edits:
|
|
25
|
+
Configuration is saved privately at `~/.config/claude-autorouter/config.json`. On macOS, new setups keep keys in the login Keychain; for an existing plaintext configuration, run `claude-autorouter config set AUTOROUTER_SECRET_STORE keychain` to move them. Environment variables override it; project `.env` files are not loaded automatically. `setup --force` updates an existing configuration while preserving other settings. Use focused commands for later edits:
|
|
22
26
|
|
|
23
27
|
```sh
|
|
24
28
|
claude-autorouter config show
|
|
@@ -64,7 +68,7 @@ Savings are **API-equivalent estimates using the recorded Opus baseline and toke
|
|
|
64
68
|
|
|
65
69
|
## Local Ollama evaluator
|
|
66
70
|
|
|
67
|
-
Start Ollama 0.35+ with a model supporting its native decision endpoint, then
|
|
71
|
+
Ollama is the default evaluator. Start Ollama 0.35+ with a model supporting its native decision endpoint, then choose a model:
|
|
68
72
|
|
|
69
73
|
```sh
|
|
70
74
|
claude-autorouter setup --evaluator ollama --ollama-model tev1:4b-q4_K_M --pull --force
|
|
@@ -72,7 +76,7 @@ claude-autorouter doctor --evaluate-local
|
|
|
72
76
|
claude-autorouter claude
|
|
73
77
|
```
|
|
74
78
|
|
|
75
|
-
`--pull` authorizes downloading the chosen model if missing. Setup keeps existing models and settings; ordinary launches download nothing. The default local model is `nimble:9b-q4_K_M`; `tev1:0.8b` is smaller and requires checking its accuracy on your tasks. Local classification needs no Jev key
|
|
79
|
+
`--pull` authorizes downloading the chosen model if missing. Setup keeps existing models and settings; ordinary launches download nothing. The default local model is `nimble:9b-q4_K_M`; `tev1:0.8b` is smaller and requires checking its accuracy on your tasks. Local classification needs no Jev key; Jev remains available with `setup --evaluator jev`. Claude still answers through Anthropic. [Model choices, deadlines and historical measurements](docs/reference.md#ollama-evaluator).
|
|
76
80
|
|
|
77
81
|
To allow a slower local model to finish without AutoRouter's runtime deadline:
|
|
78
82
|
|
|
@@ -94,6 +98,6 @@ claude-autorouter doctor
|
|
|
94
98
|
|
|
95
99
|
Historical integration observations cover Claude Code 2.1.284–2.1.285. The versioned synthetic protocol fixtures test reviewed request/response contracts; they do not certify the current checkout against a live Claude version. Real-provider checks remain explicitly invoked. [Troubleshooting](docs/reference.md#troubleshooting) covers context use, blocked goals and logging. Run ordinary `claude` to bypass routing.
|
|
96
100
|
|
|
97
|
-
The evaluator receives bounded task/history excerpts that may contain code and tool results: TypeSafe for Jev, or your loopback Ollama service. Anthropic receives the complete request. Model switching can reduce cache reuse. [Data flow and authentication](docs/reference.md#data-flow-and-authentication).
|
|
101
|
+
The evaluator receives bounded task/history excerpts that may contain code and tool results: TypeSafe for Jev, or your loopback Ollama service. Recognizable credentials and personal identifiers are redacted from those excerpts first. Anthropic receives the complete request. Model switching can reduce cache reuse. [Data flow and authentication](docs/reference.md#data-flow-and-authentication).
|
|
98
102
|
|
|
99
|
-
[Reference](docs/reference.md) · [Contributing](CONTRIBUTING.md) · [Development](docs/development.md) · [Releases](docs/releasing.md) · [Apache-2.0](LICENSE)
|
|
103
|
+
[Reference](docs/reference.md) · [Integration and provider policy](docs/subscription-integration.md) · [Contributing](CONTRIBUTING.md) · [Development](docs/development.md) · [Releases](docs/releasing.md) · [Apache-2.0](LICENSE)
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Report privately
|
|
4
|
+
|
|
5
|
+
Use GitHub's private [Report a vulnerability](https://github.com/frapposelli/claude-autorouter/security/advisories/new) form. Private vulnerability reporting is enabled for this repository. If the form is unavailable, email maintainer Fabio Rapposelli at [fabio@rapposelli.org](mailto:fabio@rapposelli.org) with the subject `AutoRouter security report`.
|
|
6
|
+
|
|
7
|
+
Do not open a public issue or pull request with exploit details, credentials or sensitive request data. Include the affected AutoRouter and Claude Code versions, operating system, evaluator/client profile, expected security boundary, observed impact and a minimal synthetic reproduction where possible. Do not include real API keys, OAuth tokens, private source code, prompts or transcripts. The maintainer can coordinate any additional evidence privately.
|
|
8
|
+
|
|
9
|
+
Reports are handled on a best-effort basis; there is no guaranteed response time or bounty program. We will coordinate investigation, remediation and disclosure with the reporter before publishing details.
|
|
10
|
+
|
|
11
|
+
## Supported versions and scope
|
|
12
|
+
|
|
13
|
+
Security fixes target the latest stable release. Older releases are not maintained as separate security branches; users may need to upgrade. Reports against `main` are also welcome.
|
|
14
|
+
|
|
15
|
+
Relevant reports include credential exposure, unauthorized access to the local gateway or saved configuration, unintended disclosure through logs, and routing or request changes that weaken Claude's authentication or permission boundaries. Provider accounts, billing and vulnerabilities in Claude Code, TypeSafe or Ollama should also be reported to the responsible provider when applicable.
|
|
16
|
+
|
|
17
|
+
## Handling diagnostic data
|
|
18
|
+
|
|
19
|
+
AutoRouter's default evaluator is local Ollama, which keeps classification on loopback. The optional hosted Jev evaluator (`--evaluator jev`) receives bounded task/history excerpts, which may contain private code or tool results. Recognizable credentials and personal identifiers are redacted from those excerpts first; this pattern-based filter reduces, but does not eliminate, disclosure. Anthropic still receives the full inference request. See [data flow and authentication](docs/reference.md#data-flow-and-authentication).
|
|
20
|
+
|
|
21
|
+
New macOS setups keep saved keys in the login Keychain. Existing and non-macOS configurations keep plaintext keys in the private configuration file until you run `claude-autorouter config set AUTOROUTER_SECRET_STORE keychain` (macOS only). See [credential storage](docs/reference.md#credential-storage).
|
|
22
|
+
|
|
23
|
+
Session logging is optional. Enabling only a log directory uses the default `prompts` mode, which includes bounded human-task excerpts with recognizable credentials and personal identifiers redacted (pattern-based, so not exhaustive). Select `AUTOROUTER_SESSION_LOG_MODE=metadata` before enabling a directory to omit those excerpts. Inspect even metadata-only output before sharing it; identifiers, paths or environment details can still be sensitive. Saved logs have no automatic deletion policy. See [history and privacy](docs/reference.md#session-decision-logs).
|
package/SUPPORT.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Support
|
|
2
|
+
|
|
3
|
+
AutoRouter is an independent, community-maintained project. It does not provide official support for Anthropic, TypeSafe or Ollama, and has no response-time guarantee.
|
|
4
|
+
|
|
5
|
+
For setup and usage, start with the [README](README.md) and [troubleshooting reference](docs/reference.md#troubleshooting). Run `claude-autorouter --version`, `claude --version` and `claude-autorouter doctor` to identify the installation and configuration involved. Ordinary `doctor` performs configuration and local service checks; it does not verify paid-provider access.
|
|
6
|
+
|
|
7
|
+
Use [GitHub issues](https://github.com/frapposelli/claude-autorouter/issues) for reproducible bugs, feature proposals and questions not answered by the documentation. Repository access is required while the repository is private; if you cannot access it, contact [fabio@rapposelli.org](mailto:fabio@rapposelli.org). For vulnerabilities, follow [SECURITY.md](SECURITY.md) instead of opening an issue. Community conduct reports follow the [code of conduct](CODE_OF_CONDUCT.md).
|
|
8
|
+
|
|
9
|
+
## Make a report useful
|
|
10
|
+
|
|
11
|
+
- Include AutoRouter, Claude Code, Node.js and operating-system versions; include the Ollama version and model tag for local evaluation.
|
|
12
|
+
- Identify the authentication mode, evaluator and client profile without sharing credentials or full environment/configuration dumps.
|
|
13
|
+
- Describe expected and actual behavior, and provide minimal steps using a synthetic prompt or fixture when possible. Distinguish the selected model from the provider-confirmed serving model.
|
|
14
|
+
- Share only the relevant, inspected diagnostic excerpt. Remove secrets, private prompts, responses, source code, personal paths and organization identifiers. Screenshots can disclose this information too.
|
|
15
|
+
|
|
16
|
+
Logging is disabled by default. When enabling optional session history for a reproduction, explicitly choose `AUTOROUTER_SESSION_LOG_MODE=metadata`; the default `prompts` mode includes task excerpts. Metadata-only output still needs review before sharing. Debug stderr can also contain Claude's own diagnostics. See [session logs](docs/reference.md#session-decision-logs) and [troubleshooting](docs/reference.md#troubleshooting).
|
|
17
|
+
|
|
18
|
+
Account access, subscription/model eligibility, billing and provider outages belong with the responsible provider. AutoRouter reports can investigate how the gateway handles those failures, but cannot change a provider's account policies. Local-model accuracy and performance depend on the workload and hardware; include those conditions when reporting unexpected classifications.
|
package/docs/reference.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Reference
|
|
2
2
|
|
|
3
|
+
AutoRouter is an independent gateway for Claude Code. Its existing `claude-autorouter` package, command, configuration paths, and repository identity remain unchanged. See [integration boundaries and provider policy](subscription-integration.md) for authentication ownership and the distinction between technical operation and provider authorization.
|
|
4
|
+
|
|
3
5
|
## Commands
|
|
4
6
|
|
|
5
7
|
| Command | Purpose |
|
|
@@ -29,7 +31,7 @@ The launcher binds an ephemeral port on `127.0.0.1`, creates a temporary local c
|
|
|
29
31
|
|
|
30
32
|
## Configuration
|
|
31
33
|
|
|
32
|
-
First setup defaults to subscription mode unless `--auth-mode` or `AUTOROUTER_AUTH_MODE` selects another mode.
|
|
34
|
+
First setup defaults to subscription mode unless `--auth-mode` or `AUTOROUTER_AUTH_MODE` selects another mode. Local Ollama is the default evaluator (changed from Jev in 0.5.0; configurations created by `setup` always record their evaluator, so existing ones are unchanged, but an environment-only launch with no `AUTOROUTER_EVALUATOR` now selects Ollama); `--evaluator jev` selects TypeSafe's hosted evaluator. An existing configuration updated with `--force` keeps its saved choices unless a command-line flag changes them; unrelated environment overrides remain temporary. Setup prompts for required secrets without echoing them and writes a private JSON file. On macOS, new and `--replace` setups keep keys in the login Keychain by default (see [credential storage](#credential-storage)); elsewhere, and in existing configurations, stored keys are plaintext in that file, so keep it private and out of source control. Supply keys through the environment when interactive input is unavailable. Subscription mode with Ollama requires no API keys. API-key authentication always requires `ANTHROPIC_API_KEY`, regardless of evaluator.
|
|
33
35
|
|
|
34
36
|
The config path is selected in this order:
|
|
35
37
|
|
|
@@ -43,6 +45,17 @@ The JSON file uses flat environment-style string keys, such as `AUTOROUTER_AUTH_
|
|
|
43
45
|
node --env-file=.env bin/autorouter.mjs claude
|
|
44
46
|
```
|
|
45
47
|
|
|
48
|
+
### Credential storage
|
|
49
|
+
|
|
50
|
+
`AUTOROUTER_SECRET_STORE` selects where saved `ANTHROPIC_API_KEY`, `TYPESAFE_API_KEY` and `AUTOROUTER_TOKEN` values live. `file` stores them in the private JSON file; it is used on Linux and by configurations created before this option existed. `keychain`, available on macOS and chosen by default there for new setups, stores each one as a generic password in the login Keychain (service `claude-autorouter`, scoped to the configuration file path); the JSON file then contains only settings. Changing the setting moves saved keys:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
claude-autorouter config set AUTOROUTER_SECRET_STORE keychain # file → Keychain
|
|
54
|
+
claude-autorouter config set AUTOROUTER_SECRET_STORE file # Keychain → file
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
If the Keychain cannot be used during a default setup (locked or headless), setup says so and saves keys to the file instead; an explicit `--secret-store keychain` fails rather than falling back. An existing plaintext configuration is never moved implicitly: `setup --force` and `doctor` flag plaintext keys on macOS and print the command above. Keys are written to the Keychain before the file is rewritten, so an interrupted move leaves a copy in both places rather than neither. Items are removed only after the file is saved. Values pass to the system `security` tool on standard input, never as process arguments, and each write is read back to confirm it. Keychain values must be printable single-line ASCII. Environment variables still take precedence: when the Keychain is locked (for example over SSH), a key supplied in the environment is used instead, but moving keys between stores waits until every saved key can be read. `sessions` never reads the Keychain.
|
|
58
|
+
|
|
46
59
|
For an environment-only subscription launch, set `AUTOROUTER_AUTH_MODE=subscription` and either supply `TYPESAFE_API_KEY` or select `AUTOROUTER_EVALUATOR=ollama` with a running local model. For API-key mode, also supply `ANTHROPIC_API_KEY`. The shell variables are read by the router; Jev's key is removed from the Claude child environment.
|
|
47
60
|
|
|
48
61
|
| Variable | Default | Purpose |
|
|
@@ -51,7 +64,8 @@ For an environment-only subscription launch, set `AUTOROUTER_AUTH_MODE=subscript
|
|
|
51
64
|
| `ANTHROPIC_API_KEY` | required in API-key mode | Upstream Anthropic credential |
|
|
52
65
|
| `AUTOROUTER_CONFIG` | see path order above | Explicit user config path |
|
|
53
66
|
| `AUTOROUTER_AUTH_MODE` | `api-key` without saved config; setup selects `subscription` | Authentication mode |
|
|
54
|
-
| `AUTOROUTER_EVALUATOR` | `
|
|
67
|
+
| `AUTOROUTER_EVALUATOR` | `ollama` | local `ollama` (default) or hosted `jev` classification |
|
|
68
|
+
| `AUTOROUTER_SECRET_STORE` | `keychain` for new macOS setups; otherwise `file` | Saved-config setting: `keychain` keeps saved keys in the macOS login Keychain; the environment cannot redirect it |
|
|
55
69
|
| `AUTOROUTER_CLIENT_PROFILE` | `compatible` | `native` retains client model/thinking settings; `auto` starts with Sonnet when no explicit model is set and excludes Haiku from task routing |
|
|
56
70
|
| `AUTOROUTER_STATUSLINE` | enabled | `0` retains your existing status line |
|
|
57
71
|
| `AUTOROUTER_DEBUG` | off | `1` enables launcher metadata logs on stderr |
|
|
@@ -93,7 +107,7 @@ Normal startup validates the selected evaluator; stale settings for the inactive
|
|
|
93
107
|
|
|
94
108
|
## Ollama evaluator
|
|
95
109
|
|
|
96
|
-
The local configuration documented here requires AutoRouter 0.3.2 or newer and remains experimental. It uses Ollama's native `/v1/systemone` decision endpoint for every model, replacing the chat backend from 0.2.0. Jev
|
|
110
|
+
The local configuration documented here requires AutoRouter 0.3.2 or newer and remains experimental. It uses Ollama's native `/v1/systemone` decision endpoint for every model, replacing the chat backend from 0.2.0. Jev is the optional hosted evaluator, using TypeSafe's `/v1/systemone` endpoint and a TypeSafe API key. Selecting Ollama never silently switches back to Jev. Haiku, Sonnet, or Opus still completes the task through Anthropic.
|
|
97
111
|
|
|
98
112
|
Version 0.3.2 excludes Claude's executor system instructions from local excerpts, uses model-specific runtime deadlines, and accepts `0` to disable that deadline. Setup, doctor, and startup show the effective model and deadline; setup accepts `--ollama-timeout-ms`. Jev is unchanged.
|
|
99
113
|
|
|
@@ -186,15 +200,17 @@ Claude Code → authenticated local gateway → Jev or local Ollama classificati
|
|
|
186
200
|
→ selected Claude model → streamed response
|
|
187
201
|
```
|
|
188
202
|
|
|
189
|
-
AutoRouter uses Claude Code's [gateway integration](https://code.claude.com/docs/en/llm-gateway-protocol), so it sees inference requests and tool continuations. It does not rely on a user-prompt hook.
|
|
203
|
+
AutoRouter launches the user's installed official Claude Code binary without patching it and uses Claude Code's [gateway integration](https://code.claude.com/docs/en/llm-gateway-protocol), so it sees inference requests and tool continuations. It does not rely on a user-prompt hook. Each user uses their own provider credentials; AutoRouter does not provide a Claude sign-in service or a shared provider account.
|
|
190
204
|
|
|
191
|
-
The selected evaluator receives a bounded state containing the latest human request and excerpts of the original task and recent messages: up to 12,000 serialized characters sent to TypeSafe for Jev, or 3,000 UTF-8 bytes sent to the local Ollama service. Jev also receives system-text excerpts. The local path excludes Claude's top-level executor system instructions. These excerpts can include private source code and tool results. Images, document payloads, and signed thinking are omitted. Full tool schemas and full conversation history are not sent to either classifier. Anthropic receives the complete request, including its tools and attachments. Large or multimodal requests may also go to Anthropic's token-count endpoint before inference, including when classification is local.
|
|
205
|
+
The selected evaluator receives a bounded state containing the latest human request and excerpts of the original task and recent messages: up to 12,000 serialized characters sent to TypeSafe for Jev, or 3,000 UTF-8 bytes sent to the local Ollama service. Jev also receives system-text excerpts. The local path excludes Claude's top-level executor system instructions. These excerpts can include private source code and tool results. Before excerpting, recognizable sensitive values are replaced (the same filter applies to opt-in session-log prompt excerpts, including when old logs are read back) with markers such as `[REDACTED:secret]`: private keys, common provider token formats (Anthropic, OpenAI-style `sk-`, AWS, GitHub, GitLab, Slack, Google, Stripe, npm), JWTs, authorization headers, URL credentials, values assigned to password/secret/token/key-like names, email addresses, and checksum-valid IBANs and payment card numbers. Setting names stay visible. Redaction is pattern-based: unrecognized formats can remain, code resembling an assignment can be over-redacted, and it does not make arbitrary private source code safe to share. Images, document payloads, and signed thinking are omitted. Full tool schemas and full conversation history are not sent to either classifier. Anthropic receives the complete request, including its tools and attachments. Large or multimodal requests may also go to Anthropic's token-count endpoint before inference, including when classification is local.
|
|
192
206
|
|
|
193
207
|
In subscription mode, Claude Code owns login and OAuth refresh. AutoRouter forwards the current request's authorization and beta headers to Anthropic. It does not read keychain or saved login files, persist subscription tokens, or send them to Jev. A separate temporary `X-Autorouter-Token` authenticates the local connection and is stripped upstream. Subscription forwarding is restricted to `https://api.anthropic.com`. See [subscriptions and gateways](https://code.claude.com/docs/en/llm-gateway#subscriptions-and-gateways).
|
|
194
208
|
|
|
195
209
|
In API-key mode, the upstream key stays in the proxy and Claude receives a temporary local credential. Requests are billed to the supplied API key. Subscription requests remain subject to the subscription's model access and usage limits. AutoRouter never falls back from subscription authentication to API billing.
|
|
196
210
|
|
|
197
|
-
|
|
211
|
+
The proxy processes authenticated requests in memory, including their authorization headers. Preserving Claude's login flow does not by itself establish that every deployment is permitted. The [provider-policy note](subscription-integration.md#provider-guidance-and-unresolved-scope) records the current documentation and the unresolved scope of model-rewriting subscription forwarding. Jev requires its own TypeSafe credentials and billing, separate from Anthropic authentication.
|
|
212
|
+
|
|
213
|
+
Routine diagnostic logs contain route, model, timing, usage, and error-category metadata, not prompts, raw responses, or credentials. Opt-in session history is separate and includes task excerpts in its default `prompts` mode. Status snapshots contain routing metadata and token counts in a private temporary directory and are deleted on normal launcher exit. Classification, turn, and token-count caches are held in memory. Claude Code and the external providers have their own storage and logging behavior.
|
|
198
214
|
|
|
199
215
|
## Routing policy
|
|
200
216
|
|
package/docs/releasing.md
CHANGED
|
@@ -18,7 +18,9 @@ Version `0.3.7` enables automatic Sonnet/Opus switching for compatible Auto-mode
|
|
|
18
18
|
|
|
19
19
|
Version `0.4.0` completes the routing, configuration, history and performance improvement plan. Shared compatibility checks and durable task state preserve valid request features and confirmed tool/goal continuity across evaluator cache expiry, provider fallback and concurrent requests. New `config show/set/unset`, `sessions list/show` and `doctor --evaluate-local` commands support focused configuration edits, private metadata-only history and explicit local diagnostics. `setup --force` now merges saved settings; use `--replace` for deliberate replacement. Optional logging remains disabled by default; new schema-2 decision/outcome records separate selected and observed models, while the reader still accepts schema-1 files. Consumers parsing JSONL directly should account for both event kinds and the new schema. Identical concurrent evaluations are coalesced with independent cancellation, responses are bounded, and status persistence is asynchronous. Releases retain the tested archive and verify public npm availability and installation after submission. Actual 16 GiB/64 GiB Ollama results retain failed quality gates and comparison limits; Jev remains the default. See the [configuration/history reference](reference.md) and [hardware comparison](hardware-comparison.md).
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
Version `0.5.0` makes local Ollama the default evaluator and TypeSafe Jev an explicit option (`setup --evaluator jev`), so evaluator excerpts stay on the machine unless the user opts in. **Breaking for environment-only launches** that relied on the implicit Jev default; configurations created by `setup` record their evaluator and are unchanged. Evaluator excerpts and opt-in session-log prompt excerpts are now redacted for recognizable credentials and personal identifiers (pattern-based, not exhaustive). On macOS, new `setup` runs keep saved keys in the login Keychain by default; existing plaintext configurations are not moved implicitly, and `doctor` prints the `config set AUTOROUTER_SECRET_STORE keychain` command to move them. See [credential storage](reference.md#credential-storage) and the [data flow](reference.md#data-flow-and-authentication).
|
|
22
|
+
|
|
23
|
+
The GitHub repository became public on October 6, 2026, after preparation PR #1 merged. That launch created no release tag and published no new npm version. npm publication remains a separate release operation. Its tarball includes runtime source, README, configuration example, license, and shipped documentation; model weights, user configuration, credentials, transcripts, session logs, local artifacts, and test fixtures are excluded. Review each release archive, especially when the package allowlist changes. The public Git repository also exposes history, development scripts and tests; the npm archive allowlist does not govern that material.
|
|
22
24
|
|
|
23
25
|
## What runs automatically
|
|
24
26
|
|
|
@@ -35,7 +37,7 @@ Only the publishing job has `id-token: write`; there is no `NPM_TOKEN` or requir
|
|
|
35
37
|
|
|
36
38
|
Verification polls uncached version metadata and the package document, checks the downloaded tarball against the tested archive, and installs the exact version from the public registry into a temporary prefix with an empty npm cache/config. It compares the installed file set and bytes with the canonical archive before invoking that executable’s `--version` and `--help` from an unrelated directory, with lifecycle scripts disabled and no evaluator credentials. Only a successful public install and matching artifact produce `verified`.
|
|
37
39
|
|
|
38
|
-
The
|
|
40
|
+
The installed CLI has no runtime dependencies. Contributor tooling uses pinned development dependencies and a committed `package-lock.json`; CI installs them with `npm ci --ignore-scripts --no-audit --no-fund` before checks. Live Claude/Jev calls, Ollama downloads, and private repository probes are not CI checks.
|
|
39
41
|
|
|
40
42
|
## 1. Publish the first version interactively
|
|
41
43
|
|
|
@@ -107,7 +109,7 @@ On npmjs.com, open the `claude-autorouter` package's **Settings → Trusted Publ
|
|
|
107
109
|
|
|
108
110
|
Use the filename only, not `.github/workflows/publish.yml`. No GitHub environment or npm token secret needs to be created. The owner, repository, and workflow must match exactly. New trust configurations default to permitting staged publication; **enable direct `npm publish`** for this workflow. See [npm trusted publishers](https://docs.npmjs.com/trusted-publishers/) and [staged publishing](https://docs.npmjs.com/staged-publishing/).
|
|
109
111
|
|
|
110
|
-
The workflow
|
|
112
|
+
The publishing workflow selects provenance from repository visibility. With the source now public, the workflow requests provenance on future publication; it would disable provenance for a private source repository. OIDC authentication works independently of provenance. The package preparation job's dry run always disables provenance because it has no OIDC publishing permission. Before the first public-source release, review repository metadata and npm trust settings, then verify the resulting provenance statement. The public launch itself created no attestation, and changing visibility does not add attestations to historical releases. See [npm provenance requirements](https://docs.npmjs.com/generating-provenance-statements/).
|
|
111
113
|
|
|
112
114
|
After a successful trusted release, npm recommends the optional **Publishing access → Require two-factor authentication and disallow tokens** setting. It does not disable OIDC publishing. See [restricting token access](https://docs.npmjs.com/trusted-publishers/#recommended-restrict-token-access-when-using-trusted-publishers).
|
|
113
115
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Claude Code integration and provider policy
|
|
2
|
+
|
|
3
|
+
AutoRouter is an independent, user-operated local gateway for Claude Code. Anthropic does not sponsor or endorse this project. The project name is **AutoRouter**; references to Claude Code describe the software it runs. Existing npm, command, configuration, and repository identifiers remain `claude-autorouter` for compatibility. Those identifiers do not represent provider approval or trademark clearance. Anthropic's names and marks remain subject to its [trademark guidelines](https://www.anthropic.com/legal/trademark-guidelines).
|
|
4
|
+
|
|
5
|
+
## What the integration does
|
|
6
|
+
|
|
7
|
+
The launcher starts the official `claude` executable already installed on the user's machine. AutoRouter neither bundles nor patches that binary. It sets a local gateway address and temporary session settings, evaluates eligible inference requests, selects a compatible Claude model, and forwards the provider's response stream. Model-specific request adjustments and continuity checks are described in the [routing reference](reference.md#routing-policy). It does not change Claude's permission verdicts or grant access to unavailable models.
|
|
8
|
+
|
|
9
|
+
The gateway listens on `127.0.0.1` and authenticates local requests. Standalone `serve` retains that local boundary; it is not a hosted account-sharing service. This project does not supply a shared Anthropic account, resell inference, or pay provider charges on a user's behalf.
|
|
10
|
+
|
|
11
|
+
## Authentication and data ownership
|
|
12
|
+
|
|
13
|
+
- **Subscription mode:** each user signs into their own account through Claude Code's official login flow. Claude owns OAuth refresh. AutoRouter receives the authorization header with each proxied request and forwards it to `https://api.anthropic.com`, together with the OAuth capability header. It does not extract login files or keychain entries, persist subscription tokens, or send those tokens to an evaluator. A separate temporary local token is removed before forwarding.
|
|
14
|
+
- **API-key mode:** the user supplies their own Anthropic API key. The proxy forwards that key upstream and gives Claude a separate local credential. API usage is billed to the key owner's account; the router does not switch subscription traffic to API billing after an error.
|
|
15
|
+
- **Evaluation:** the default Ollama evaluator receives bounded, redacted task/history excerpts through its loopback service without Claude or Jev credentials. The optional Jev evaluator uses a separate TypeSafe key and separate billing, and receives the same bounded, redacted excerpts, which can still include private code. Anthropic still receives the complete inference request. See [data flow and authentication](reference.md#data-flow-and-authentication).
|
|
16
|
+
|
|
17
|
+
Claude Code, Anthropic, TypeSafe, and any downloaded local model have their own terms and data handling. Optional AutoRouter history persists locally when enabled; prompt mode can retain text entered by the user. See [history and privacy](reference.md#session-decision-logs).
|
|
18
|
+
|
|
19
|
+
## Provider guidance and unresolved scope
|
|
20
|
+
|
|
21
|
+
Documentation reviewed **October 6, 2026**:
|
|
22
|
+
|
|
23
|
+
Anthropic's [Claude Code legal guidance](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use) allows end users to sign into an unmodified Claude Code binary with their own credentials. It also restricts third-party credential collection or intermediation and certain subscription routing. Its [gateway documentation](https://code.claude.com/docs/en/llm-gateway#subscriptions-and-gateways) describes retaining a saved subscription login when configuring a gateway address without replacing authentication, including forwarding the OAuth capability header.
|
|
24
|
+
|
|
25
|
+
These statements are relevant technical and policy context; they do not specifically approve AutoRouter's model-rewriting OAuth proxy. This project has not established that every use of subscription forwarding meets the applicable restrictions. Successful authentication or a passing test establishes technical behavior, not provider authorization. Enterprise access does not establish a blanket exception.
|
|
26
|
+
|
|
27
|
+
Review your account agreement and organization requirements before deployment, and seek clarification from Anthropic about this integration when needed. Anthropic identifies [Commercial Terms](https://www.anthropic.com/legal/commercial-terms) for Team, Enterprise, and API use and [Consumer Terms](https://www.anthropic.com/legal/consumer-terms) for consumer plans in its [license guidance](https://code.claude.com/docs/en/legal-and-compliance#license). API-key mode is available with separate billing and remains subject to its applicable terms. No policy-compliance guarantee is made by this project.
|
package/package.json
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-autorouter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "
|
|
6
|
+
"description": "AutoRouter: a local model-routing gateway for Claude Code with Jev and Ollama System One evaluators",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/frapposelli/claude-autorouter.git"
|
|
10
10
|
},
|
|
11
|
+
"homepage": "https://github.com/frapposelli/claude-autorouter#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/frapposelli/claude-autorouter/issues"
|
|
14
|
+
},
|
|
11
15
|
"bin": {
|
|
12
16
|
"claude-autorouter": "bin/autorouter.mjs"
|
|
13
17
|
},
|
|
@@ -39,6 +43,10 @@
|
|
|
39
43
|
".env.example",
|
|
40
44
|
"LICENSE",
|
|
41
45
|
"CONTRIBUTING.md",
|
|
46
|
+
"SECURITY.md",
|
|
47
|
+
"SUPPORT.md",
|
|
48
|
+
"CODE_OF_CONDUCT.md",
|
|
49
|
+
"docs/subscription-integration.md",
|
|
42
50
|
"docs/router-performance.md",
|
|
43
51
|
"docs/router-performance.json",
|
|
44
52
|
"docs/status-performance.md",
|
package/src/cli-help.mjs
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
const commands = {
|
|
2
2
|
setup: `Usage: claude-autorouter setup [options]
|
|
3
3
|
|
|
4
|
-
Configure
|
|
4
|
+
Configure the local Ollama evaluator (default) or TypeSafe Jev.
|
|
5
5
|
--auth-mode subscription|api-key Default: subscription
|
|
6
6
|
--client-profile compatible|native|auto
|
|
7
|
-
--evaluator jev
|
|
7
|
+
--evaluator ollama|jev Default: ollama (local); jev sends excerpts to TypeSafe
|
|
8
8
|
--ollama-model TAG Select a /v1/systemone model
|
|
9
9
|
--ollama-timeout-ms N 0 disables the routing deadline
|
|
10
10
|
--pull Download the selected missing Ollama model
|
|
11
11
|
--stop-hook-block-cap N Optional Claude Stop-hook retry limit
|
|
12
12
|
--session-log-dir DIR Opt in to private logs with prompt excerpts
|
|
13
13
|
--session-log-mode metadata|prompts Choose whether excerpts are included
|
|
14
|
-
--
|
|
14
|
+
--secret-store file|keychain default on macOS for new setups; file is plaintext
|
|
15
|
+
--force Update an existing configuration
|
|
15
16
|
--replace Explicitly rebuild the saved configuration
|
|
16
17
|
|
|
17
18
|
First setup reads environment settings and keys, or prompts for missing keys.
|
|
18
19
|
--force retains saved defaults and applies explicit options; unrelated runtime
|
|
19
20
|
overrides stay temporary. Explicit evaluator/auth selection accepts its supplied key.
|
|
20
|
-
|
|
21
|
+
Examples: claude-autorouter setup --pull
|
|
22
|
+
claude-autorouter setup --evaluator jev`,
|
|
21
23
|
doctor: `Usage: claude-autorouter doctor [--evaluate-local] [--json]
|
|
22
24
|
|
|
23
25
|
Check configuration, installed Claude, and local model availability.
|
|
@@ -35,6 +37,7 @@ Show effective settings and their source; secrets are always redacted.
|
|
|
35
37
|
--check-all also validates settings for the inactive evaluator.
|
|
36
38
|
Set/unset changes only the named saved setting. Environment values still win.
|
|
37
39
|
Secret keys require --stdin or a hidden prompt, never a command-line value.
|
|
40
|
+
Setting AUTOROUTER_SECRET_STORE to keychain or file moves saved keys (macOS).
|
|
38
41
|
|
|
39
42
|
Example: claude-autorouter config set AUTOROUTER_OLLAMA_TIMEOUT_MS 0`,
|
|
40
43
|
serve: `Usage: claude-autorouter serve
|
|
@@ -61,7 +64,7 @@ Claude owns permission checks and subscription authentication.
|
|
|
61
64
|
};
|
|
62
65
|
|
|
63
66
|
export function helpText(command = 'help') {
|
|
64
|
-
return commands[command] ?? `
|
|
67
|
+
return commands[command] ?? `AutoRouter — automatic model routing for Claude Code
|
|
65
68
|
|
|
66
69
|
Usage: claude-autorouter <command> [options]
|
|
67
70
|
|
|
@@ -78,8 +81,8 @@ Start: claude-autorouter setup
|
|
|
78
81
|
claude-autorouter claude --permission-mode auto
|
|
79
82
|
|
|
80
83
|
Run claude-autorouter help <command> for options and examples.
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
Local Ollama is the default evaluator and uses only the local /v1/systemone
|
|
85
|
+
endpoint. Jev is optional; it receives bounded, redacted prompt excerpts. Complete requests go to Anthropic.
|
|
83
86
|
Session logging is off unless AUTOROUTER_SESSION_LOG_DIR is configured.
|
|
84
87
|
Environment variables override ~/.config/claude-autorouter/config.json.
|
|
85
88
|
AUTOROUTER_CONFIG selects another file. Project .env files are not auto-loaded.
|
package/src/config-command.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readConfig, parseSessionLogDir, parseStopHookBlockCap } from './config.mjs';
|
|
2
|
-
import { CONFIG_KEYS, SECRET_CONFIG_KEYS, loadUserConfig, saveUserConfig } from './user-config.mjs';
|
|
2
|
+
import { CONFIG_KEYS, SECRET_CONFIG_KEYS, SECRET_STORES, keychainRemovals, loadUserConfig, saveUserConfig } from './user-config.mjs';
|
|
3
3
|
import { modelCapabilities } from './model-catalog.mjs';
|
|
4
4
|
import { askSecret } from './onboarding.mjs';
|
|
5
5
|
|
|
@@ -30,16 +30,20 @@ function effectiveValues(config, env) {
|
|
|
30
30
|
/** Redacted effective configuration, independent of Claude launch arguments. */
|
|
31
31
|
export function configReport(loaded, env, { checkAll = false } = {}) {
|
|
32
32
|
const config = readConfig(loaded.env);
|
|
33
|
-
const values = effectiveValues(config, loaded.env);
|
|
33
|
+
const values = { ...effectiveValues(config, loaded.env), AUTOROUTER_SECRET_STORE: loaded.secretStore ?? 'file' };
|
|
34
34
|
const settings = {};
|
|
35
35
|
for (const key of CONFIG_KEYS) {
|
|
36
|
-
const
|
|
36
|
+
const saved = loaded.keychainSecrets?.includes(key) ? 'keychain' : 'file';
|
|
37
|
+
// The saved store setting governs saved secrets; the environment cannot redirect it.
|
|
38
|
+
const source = env[key] !== undefined && key !== 'AUTOROUTER_SECRET_STORE' ? 'environment'
|
|
39
|
+
: Object.hasOwn(loaded.values, key) ? saved : 'default';
|
|
37
40
|
const provider = providerFor(key);
|
|
38
41
|
const active = (!provider || provider === config.evaluator)
|
|
39
42
|
&& (key !== 'ANTHROPIC_API_KEY' || config.authMode === 'api-key');
|
|
40
43
|
const secret = secretKey(key);
|
|
41
44
|
settings[key] = { source, active,
|
|
42
45
|
...(source === 'environment' && Object.hasOwn(loaded.values, key) ? { overrides_file: true } : {}),
|
|
46
|
+
...(source === 'environment' && loaded.unavailableSecrets?.includes(key) ? { keychain_unavailable: true } : {}),
|
|
43
47
|
...(secret ? { secret: true, present: typeof loaded.env[key] === 'string' && Boolean(loaded.env[key].trim()) }
|
|
44
48
|
: { value: active ? values[key] ?? null : null }),
|
|
45
49
|
};
|
|
@@ -84,19 +88,21 @@ function normalizedValue(key, value) {
|
|
|
84
88
|
}
|
|
85
89
|
if (key === 'AUTOROUTER_SESSION_LOG_DIR') return parseSessionLogDir(value) ?? '';
|
|
86
90
|
if (key === 'CLAUDE_CODE_STOP_HOOK_BLOCK_CAP') return String(parseStopHookBlockCap(value));
|
|
91
|
+
if (key === 'AUTOROUTER_SECRET_STORE' && !SECRET_STORES.includes(value)) throw new Error('AUTOROUTER_SECRET_STORE must be file or keychain.');
|
|
87
92
|
if (/[\r\n\0\u001b]/.test(value)) throw new Error(`${key} must be a single-line setting.`);
|
|
88
93
|
return value;
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
export async function configCommand(args, {
|
|
92
|
-
env = process.env, write = console.log, input = process.stdin, promptSecret = askSecret,
|
|
97
|
+
env = process.env, write = console.log, input = process.stdin, promptSecret = askSecret, keychain,
|
|
93
98
|
} = {}) {
|
|
99
|
+
const store = keychain ? { keychain } : {};
|
|
94
100
|
const [operation, ...rest] = args;
|
|
95
101
|
if (operation === 'show') {
|
|
96
102
|
if (rest.some(arg => !['--json', '--check-all'].includes(arg))) throw new Error('Usage: claude-autorouter config show [--json] [--check-all]');
|
|
97
103
|
let loaded, report;
|
|
98
104
|
try {
|
|
99
|
-
loaded = loadUserConfig(env, { allowMissing: true });
|
|
105
|
+
loaded = loadUserConfig(env, { allowMissing: true, ...store });
|
|
100
106
|
report = configReport(loaded, env, { checkAll: rest.includes('--check-all') });
|
|
101
107
|
}
|
|
102
108
|
catch (error) {
|
|
@@ -124,7 +130,7 @@ export async function configCommand(args, {
|
|
|
124
130
|
throw new Error('Secret values are not accepted as command arguments. Use --stdin or the hidden prompt.');
|
|
125
131
|
}
|
|
126
132
|
if (operation === 'set' && !secretKey(key) && (value === undefined || value === '--stdin')) throw new Error('Nonsecret settings require a value argument.');
|
|
127
|
-
const loaded = loadUserConfig(env, { allowMissing: true });
|
|
133
|
+
const loaded = loadUserConfig(env, { allowMissing: true, ...store });
|
|
128
134
|
const next = { ...loaded.values };
|
|
129
135
|
if (operation === 'unset') delete next[key];
|
|
130
136
|
else next[key] = normalizedValue(key, secretKey(key)
|
|
@@ -134,8 +140,19 @@ export async function configCommand(args, {
|
|
|
134
140
|
// mask it. An explicitly edited inactive provider is checked too.
|
|
135
141
|
const provider = operation === 'set' ? providerFor(key) : undefined;
|
|
136
142
|
readConfig({ ...next, ...(provider ? { AUTOROUTER_EVALUATOR: provider } : {}) });
|
|
137
|
-
|
|
138
|
-
|
|
143
|
+
const nextStore = next.AUTOROUTER_SECRET_STORE ?? 'file';
|
|
144
|
+
// Changing the store moves saved secrets; unsetting a secret deletes its item.
|
|
145
|
+
const removeSecrets = key === 'AUTOROUTER_SECRET_STORE' ? keychainRemovals(loaded, nextStore)
|
|
146
|
+
: operation === 'unset' && secretKey(key) && loaded.secretStore === 'keychain' ? [key] : [];
|
|
147
|
+
saveUserConfig(next, { env, overwrite: loaded.exists, expectedRevision: loaded.revision, removeSecrets, ...store });
|
|
148
|
+
if (key === 'AUTOROUTER_SECRET_STORE') {
|
|
149
|
+
const moved = SECRET_CONFIG_KEYS.filter(name => Object.hasOwn(next, name)).length;
|
|
150
|
+
const where = nextStore === 'keychain' ? 'macOS Keychain' : 'configuration file';
|
|
151
|
+
write(nextStore === loaded.secretStore || !moved ? `Saved ${key}. Saved secrets are stored in the ${where}.`
|
|
152
|
+
: `Saved ${key}. Moved ${moved} saved secret${moved === 1 ? '' : 's'} to the ${where}.`);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
write(`${operation === 'unset' ? 'Removed saved' : 'Saved'} ${key}${secretKey(key) && nextStore === 'keychain' ? ' in the macOS Keychain' : ''}. Other saved settings are unchanged.`);
|
|
139
156
|
if (env[key] !== undefined) write(`The current environment still overrides ${key}; unset that environment variable to use the saved/default value.`);
|
|
140
157
|
return true;
|
|
141
158
|
}
|
package/src/config.mjs
CHANGED
|
@@ -63,7 +63,7 @@ export function readConfig(env = process.env, { validateAll = false } = {}) {
|
|
|
63
63
|
for (const key of ['AUTOROUTER_STATUSLINE', 'AUTOROUTER_DEBUG']) {
|
|
64
64
|
if (env[key] !== undefined && !['0', '1'].includes(env[key])) throw new Error(`${key} must be 0 or 1`);
|
|
65
65
|
}
|
|
66
|
-
const evaluator = env.AUTOROUTER_EVALUATOR ?? '
|
|
66
|
+
const evaluator = env.AUTOROUTER_EVALUATOR ?? 'ollama';
|
|
67
67
|
if (evaluator !== 'jev' && evaluator !== 'ollama') throw new Error('AUTOROUTER_EVALUATOR must be jev or ollama');
|
|
68
68
|
// A stale inactive backend must not stop the selected evaluator. Config
|
|
69
69
|
// inspection can request validation of both providers explicitly.
|
package/src/keychain.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// macOS Keychain access through the system `security` tool. Secret values are
|
|
4
|
+
// passed on stdin to its interactive mode, never as process arguments, and are
|
|
5
|
+
// read back from stdout. Tool output can describe items, so failures report
|
|
6
|
+
// only a generic category.
|
|
7
|
+
const SECURITY = '/usr/bin/security';
|
|
8
|
+
const SERVICE = 'claude-autorouter';
|
|
9
|
+
const NOT_FOUND = 44;
|
|
10
|
+
const PRINTABLE = /^[\x20-\x7e]+$/;
|
|
11
|
+
|
|
12
|
+
function keychainError(message) {
|
|
13
|
+
const error = new Error(message);
|
|
14
|
+
error.code = 'AUTOROUTER_CONFIG_ERROR';
|
|
15
|
+
return error;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Quote for the `security -i` command parser, which accepts backslash escapes
|
|
19
|
+
// inside double quotes. Values are validated as printable single-line ASCII.
|
|
20
|
+
const quote = value => `"${value.replace(/[\\"]/g, '\\$&')}"`;
|
|
21
|
+
|
|
22
|
+
export function createKeychain({ run = spawnSync, platform = process.platform } = {}) {
|
|
23
|
+
const available = platform === 'darwin';
|
|
24
|
+
const call = (args, input) => {
|
|
25
|
+
if (!available) throw keychainError('The macOS Keychain secret store is available only on macOS.');
|
|
26
|
+
const result = run(SECURITY, args, {
|
|
27
|
+
input, encoding: 'utf8', timeout: 15000, maxBuffer: 64 * 1024, stdio: ['pipe', 'pipe', 'pipe'],
|
|
28
|
+
});
|
|
29
|
+
if (result.error) throw keychainError('Could not run the macOS Keychain tool.');
|
|
30
|
+
return result;
|
|
31
|
+
};
|
|
32
|
+
const read = account => {
|
|
33
|
+
const result = call(['find-generic-password', '-s', SERVICE, '-a', account, '-w']);
|
|
34
|
+
if (result.status === NOT_FOUND) return undefined;
|
|
35
|
+
if (result.status !== 0) {
|
|
36
|
+
throw keychainError('Could not read an AutoRouter secret from the macOS Keychain. Unlock the login keychain, or set the key in the environment.');
|
|
37
|
+
}
|
|
38
|
+
return String(result.stdout).replace(/\n$/, '');
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
available,
|
|
42
|
+
read,
|
|
43
|
+
write(account, value, label) {
|
|
44
|
+
if (typeof value !== 'string' || !PRINTABLE.test(value)) {
|
|
45
|
+
throw keychainError('Keychain secrets must be printable single-line ASCII.');
|
|
46
|
+
}
|
|
47
|
+
call(['-i'], `add-generic-password -U -s ${quote(SERVICE)} -a ${quote(account)} -l ${quote(label)} -w ${quote(value)}\n`);
|
|
48
|
+
// Interactive mode exits successfully even when a command fails.
|
|
49
|
+
if (read(account) !== value) throw keychainError('Could not save an AutoRouter secret to the macOS Keychain.');
|
|
50
|
+
},
|
|
51
|
+
remove(account) {
|
|
52
|
+
const result = call(['delete-generic-password', '-s', SERVICE, '-a', account]);
|
|
53
|
+
if (result.status !== 0 && result.status !== NOT_FOUND) {
|
|
54
|
+
throw keychainError('Could not remove an AutoRouter secret from the macOS Keychain.');
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/onboarding.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { Writable } from 'node:stream';
|
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { CLIENT_PROFILES, readConfig, requireKeys, parseStopHookBlockCap, parseSessionLogDir } from './config.mjs';
|
|
6
6
|
import { buildClaudeEnv, conflictingProviders, LOCAL_AUTH_HEADER } from './auth.mjs';
|
|
7
|
-
import { CONFIG_KEYS, SECRET_CONFIG_KEYS, loadUserConfig, saveUserConfig } from './user-config.mjs';
|
|
7
|
+
import { CONFIG_KEYS, SECRET_CONFIG_KEYS, SECRET_STORES, keychainRemovals, loadUserConfig, saveUserConfig } from './user-config.mjs';
|
|
8
8
|
import { DEFAULT_OLLAMA_MODEL, validateOllamaModel } from './ollama-models.mjs';
|
|
9
9
|
import { inspectOllama, setupOllama } from './ollama-setup.mjs';
|
|
10
10
|
|
|
@@ -37,10 +37,12 @@ export async function askSecret(label, { input = process.stdin, output = process
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export async function setup(args, {
|
|
40
|
-
env = process.env, write = console.log, prompt = askSecret, fetchImpl = fetch, signal,
|
|
40
|
+
env = process.env, write = console.log, prompt = askSecret, fetchImpl = fetch, signal, keychain,
|
|
41
|
+
platform = process.platform,
|
|
41
42
|
} = {}) {
|
|
42
43
|
if (signal?.aborted) throw new Error('Setup cancelled');
|
|
43
|
-
const
|
|
44
|
+
const store = keychain ? { keychain } : {};
|
|
45
|
+
const loaded = loadUserConfig(env, { allowMissing: true, ...store });
|
|
44
46
|
const replace = args.includes('--replace');
|
|
45
47
|
const mergeExisting = loaded.exists && !replace;
|
|
46
48
|
// Updating one preference must not turn unrelated runtime overrides into
|
|
@@ -50,12 +52,13 @@ export async function setup(args, {
|
|
|
50
52
|
let explicitEvaluator = false, explicitAuthMode = false;
|
|
51
53
|
let authMode = effectiveEnv.AUTOROUTER_AUTH_MODE ?? 'subscription';
|
|
52
54
|
let clientProfile = effectiveEnv.AUTOROUTER_CLIENT_PROFILE ?? 'compatible';
|
|
53
|
-
let evaluator = effectiveEnv.AUTOROUTER_EVALUATOR ?? '
|
|
55
|
+
let evaluator = effectiveEnv.AUTOROUTER_EVALUATOR ?? 'ollama';
|
|
54
56
|
let model;
|
|
55
57
|
let ollamaTimeoutMs;
|
|
56
58
|
let stopHookBlockCap = effectiveEnv.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP;
|
|
57
59
|
let sessionLogDir = effectiveEnv.AUTOROUTER_SESSION_LOG_DIR;
|
|
58
60
|
let sessionLogMode = effectiveEnv.AUTOROUTER_SESSION_LOG_MODE;
|
|
61
|
+
let secretStore;
|
|
59
62
|
let pull = false;
|
|
60
63
|
let overwrite = replace;
|
|
61
64
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -82,10 +85,14 @@ export async function setup(args, {
|
|
|
82
85
|
sessionLogMode = args[++i];
|
|
83
86
|
if (!['metadata', 'prompts'].includes(sessionLogMode)) throw new Error('--session-log-mode must be metadata or prompts');
|
|
84
87
|
}
|
|
88
|
+
else if (args[i] === '--secret-store') {
|
|
89
|
+
secretStore = args[++i];
|
|
90
|
+
if (!SECRET_STORES.includes(secretStore)) throw new Error('--secret-store must be file or keychain');
|
|
91
|
+
}
|
|
85
92
|
else if (args[i] === '--pull') pull = true;
|
|
86
93
|
else if (args[i] === '--force') overwrite = true;
|
|
87
94
|
else if (args[i] === '--replace') continue;
|
|
88
|
-
else throw new Error('Usage: claude-autorouter setup [--auth-mode subscription|api-key] [--client-profile compatible|native|auto] [--evaluator jev|ollama] [--ollama-model TAG] [--ollama-timeout-ms N] [--stop-hook-block-cap N] [--session-log-dir DIR] [--session-log-mode metadata|prompts] [--pull] [--force|--replace]');
|
|
95
|
+
else throw new Error('Usage: claude-autorouter setup [--auth-mode subscription|api-key] [--client-profile compatible|native|auto] [--evaluator jev|ollama] [--ollama-model TAG] [--ollama-timeout-ms N] [--stop-hook-block-cap N] [--session-log-dir DIR] [--session-log-mode metadata|prompts] [--secret-store file|keychain] [--pull] [--force|--replace]');
|
|
89
96
|
}
|
|
90
97
|
if (!['subscription', 'api-key'].includes(authMode)) throw new Error('--auth-mode must be subscription or api-key');
|
|
91
98
|
if (!CLIENT_PROFILES.includes(clientProfile)) throw new Error('--client-profile must be compatible, native or auto');
|
|
@@ -108,6 +115,11 @@ export async function setup(args, {
|
|
|
108
115
|
if (stopHookBlockCap !== undefined) values.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP = String(stopHookBlockCap);
|
|
109
116
|
if (sessionLogDir !== undefined) values.AUTOROUTER_SESSION_LOG_DIR = sessionLogDir;
|
|
110
117
|
if (sessionLogMode !== undefined) values.AUTOROUTER_SESSION_LOG_MODE = sessionLogMode;
|
|
118
|
+
if (secretStore !== undefined) values.AUTOROUTER_SECRET_STORE = secretStore;
|
|
119
|
+
// New and rebuilt configurations keep keys in the macOS Keychain. An existing
|
|
120
|
+
// configuration keeps its store until the user moves it deliberately.
|
|
121
|
+
const defaultedStore = values.AUTOROUTER_SECRET_STORE === undefined && platform === 'darwin' && (!loaded.exists || replace);
|
|
122
|
+
if (defaultedStore) values.AUTOROUTER_SECRET_STORE = 'keychain';
|
|
111
123
|
if (evaluator === 'ollama') {
|
|
112
124
|
values.AUTOROUTER_OLLAMA_MODEL = validateOllamaModel(model
|
|
113
125
|
?? (explicitEvaluator ? env.AUTOROUTER_OLLAMA_MODEL : undefined) ?? effectiveEnv.AUTOROUTER_OLLAMA_MODEL ?? DEFAULT_OLLAMA_MODEL);
|
|
@@ -119,6 +131,9 @@ export async function setup(args, {
|
|
|
119
131
|
const keys = [...(evaluator === 'jev' ? ['TYPESAFE_API_KEY'] : []), ...(authMode === 'api-key' ? ['ANTHROPIC_API_KEY'] : [])];
|
|
120
132
|
// Reject invalid settings before inviting secret input or making local calls.
|
|
121
133
|
readConfig(values);
|
|
134
|
+
if (values.AUTOROUTER_SECRET_STORE === 'keychain' && platform !== 'darwin') {
|
|
135
|
+
throw new Error('--secret-store keychain is available only on macOS');
|
|
136
|
+
}
|
|
122
137
|
for (const key of keys) {
|
|
123
138
|
if (signal?.aborted) throw new Error('Setup cancelled');
|
|
124
139
|
const explicitlySelected = !mergeExisting || (key === 'TYPESAFE_API_KEY' ? explicitEvaluator : explicitAuthMode);
|
|
@@ -140,17 +155,43 @@ export async function setup(args, {
|
|
|
140
155
|
const cancel = () => controller.abort();
|
|
141
156
|
if (!signal) for (const name of ['SIGINT', 'SIGTERM']) process.once(name, cancel);
|
|
142
157
|
try { await setupOllama(config, { pull, warm: true, write, fetchImpl, signal: signal ?? controller.signal }); }
|
|
158
|
+
catch (error) {
|
|
159
|
+
// Local evaluation is the default, not a choice the user made, so a
|
|
160
|
+
// missing or stopped Ollama should say how to pick another evaluator.
|
|
161
|
+
if (!explicitEvaluator && !mergeExisting && !signal?.aborted) {
|
|
162
|
+
error.message = `${error.message} Local Ollama is the default evaluator; add --pull to download its model, or use TypeSafe Jev instead: claude-autorouter setup --evaluator jev`;
|
|
163
|
+
}
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
143
166
|
finally { if (!signal) for (const name of ['SIGINT', 'SIGTERM']) process.removeListener(name, cancel); }
|
|
144
167
|
}
|
|
145
168
|
if (signal?.aborted) throw new Error('Setup cancelled');
|
|
146
|
-
|
|
169
|
+
let savedStore = values.AUTOROUTER_SECRET_STORE ?? 'file';
|
|
170
|
+
try {
|
|
171
|
+
saveUserConfig(values, { env, overwrite, expectedRevision: loaded.revision,
|
|
172
|
+
removeSecrets: keychainRemovals(loaded, savedStore, { replace, values }), ...store });
|
|
173
|
+
} catch (error) {
|
|
174
|
+
// The default must not make setup impossible where the Keychain cannot be
|
|
175
|
+
// used (locked, headless). An explicit request still fails.
|
|
176
|
+
if (!defaultedStore || !keys.length || error?.code !== 'AUTOROUTER_CONFIG_ERROR' || !/Keychain/.test(error.message)) throw error;
|
|
177
|
+
write('The macOS Keychain is unavailable; saving keys in the private configuration file instead. Move them later with: claude-autorouter config set AUTOROUTER_SECRET_STORE keychain');
|
|
178
|
+
delete values.AUTOROUTER_SECRET_STORE;
|
|
179
|
+
savedStore = 'file';
|
|
180
|
+
saveUserConfig(values, { env, overwrite, expectedRevision: loaded.revision,
|
|
181
|
+
removeSecrets: keychainRemovals(loaded, savedStore, { replace, values }), ...store });
|
|
182
|
+
}
|
|
147
183
|
write(`Saved ${authMode} configuration to ${path}`);
|
|
148
|
-
|
|
184
|
+
if (keys.length && savedStore === 'keychain') {
|
|
185
|
+
write('Keys are stored in the macOS Keychain; settings are stored in this file with owner-only permissions. Environment variables take precedence.');
|
|
186
|
+
} else {
|
|
187
|
+
write(`${keys.length ? 'Keys and settings are' : 'Settings are'} stored locally in this file with owner-only permissions. Environment variables take precedence.`);
|
|
188
|
+
if (keys.length && platform === 'darwin' && savedStore === 'file' && !defaultedStore) write('Keys are plaintext in this file. Move them into the macOS Keychain: claude-autorouter config set AUTOROUTER_SECRET_STORE keychain');
|
|
189
|
+
}
|
|
149
190
|
write('Next: claude-autorouter doctor, then claude-autorouter claude from your project.');
|
|
150
191
|
}
|
|
151
192
|
|
|
152
193
|
export async function doctor({ env = process.env, write = console.log, run = execute, fetchImpl = fetch, signal,
|
|
153
|
-
evaluateLocal = false, json = false, diagnosticRunner } = {}) {
|
|
194
|
+
evaluateLocal = false, json = false, diagnosticRunner, keychain, platform = process.platform } = {}) {
|
|
154
195
|
if (evaluateLocal) {
|
|
155
196
|
try {
|
|
156
197
|
const config = readConfig(loadUserConfig(env).env);
|
|
@@ -180,9 +221,13 @@ export async function doctor({ env = process.env, write = console.log, run = exe
|
|
|
180
221
|
let config;
|
|
181
222
|
let effectiveEnv = env;
|
|
182
223
|
try {
|
|
183
|
-
const loaded = loadUserConfig(env);
|
|
224
|
+
const loaded = loadUserConfig(env, keychain ? { keychain } : {});
|
|
184
225
|
effectiveEnv = loaded.env;
|
|
185
226
|
write(`Config: ${loaded.path}${loaded.exists ? '' : ' (absent; using environment)'}`);
|
|
227
|
+
if (loaded.secretStore === 'keychain') write(`Saved secrets: macOS Keychain (${loaded.keychainSecrets.length} found).`);
|
|
228
|
+
else if (platform === 'darwin' && SECRET_CONFIG_KEYS.some(key => Object.hasOwn(loaded.values, key))) {
|
|
229
|
+
write('WARN Saved keys are plaintext in the configuration file. Move them into the macOS Keychain: claude-autorouter config set AUTOROUTER_SECRET_STORE keychain');
|
|
230
|
+
}
|
|
186
231
|
config = readConfig(effectiveEnv);
|
|
187
232
|
requireKeys(config);
|
|
188
233
|
report(true, `Configuration and required keys present (${config.authMode})`);
|
package/src/prompt-state.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { redactSensitive } from './redaction.mjs';
|
|
2
|
+
|
|
1
3
|
// Claude Code prepends these as separate text blocks. Ignore only complete
|
|
2
4
|
// wrapper blocks; a user's text that mentions a tag or mixes it with a task
|
|
3
5
|
// must remain part of the classifier's input.
|
|
@@ -51,6 +53,16 @@ function excerpt(text, length) {
|
|
|
51
53
|
|
|
52
54
|
const textCost = text => JSON.stringify(text).length - 2;
|
|
53
55
|
|
|
56
|
+
// Redact before excerpting so a value cannot be split into an unrecognizable
|
|
57
|
+
// fragment. Very long text keeps only end windows wider than any retained
|
|
58
|
+
// excerpt; the margin also keeps a value cut at a window edge out of the result.
|
|
59
|
+
const REDACTION_MARGIN = 4096;
|
|
60
|
+
function classifierText(text, limit) {
|
|
61
|
+
const span = limit + REDACTION_MARGIN;
|
|
62
|
+
if (text.length > 2 * span) text = `${text.slice(0, span)}\n[... omitted ...]\n${text.slice(-span)}`;
|
|
63
|
+
return redactSensitive(text);
|
|
64
|
+
}
|
|
65
|
+
|
|
54
66
|
// Budget serialized characters, including JSON escaping, rather than just
|
|
55
67
|
// raw text length. The two ends keep both an initial instruction and a final
|
|
56
68
|
// question visible when one long text block must be shortened.
|
|
@@ -145,6 +157,9 @@ export function promptExcerpt(body, maxChars = 500) {
|
|
|
145
157
|
const blocks = typeof content === 'string' ? [{ type: 'text', text: content }]
|
|
146
158
|
: Array.isArray(content) ? content : [];
|
|
147
159
|
const characters = [];
|
|
160
|
+
// Collect past the retained length: redaction must see a value that
|
|
161
|
+
// straddles the final boundary, and may shorten the text before the cut.
|
|
162
|
+
const window = maxChars + REDACTION_MARGIN;
|
|
148
163
|
let nonText = false;
|
|
149
164
|
for (const block of blocks) {
|
|
150
165
|
if (block?.type !== 'text' || typeof block.text !== 'string') {
|
|
@@ -157,12 +172,12 @@ export function promptExcerpt(body, maxChars = 500) {
|
|
|
157
172
|
if (!/\S/.test(value) || isReminderBlock(value)) continue;
|
|
158
173
|
if (characters.length) characters.push('\n');
|
|
159
174
|
for (const character of value) {
|
|
160
|
-
if (characters.length >=
|
|
175
|
+
if (characters.length >= window) break;
|
|
161
176
|
characters.push(character);
|
|
162
177
|
}
|
|
163
|
-
if (characters.length >=
|
|
178
|
+
if (characters.length >= window) break;
|
|
164
179
|
}
|
|
165
|
-
if (characters.length) return characters.join('').toWellFormed();
|
|
180
|
+
if (characters.length) return [...redactSensitive(characters.join('').toWellFormed())].slice(0, maxChars).join('').toWellFormed();
|
|
166
181
|
// An image/document-only human turn is a new task with no safe excerpt;
|
|
167
182
|
// do not incorrectly label it with the preceding human task's text.
|
|
168
183
|
if (nonText) return '';
|
|
@@ -196,9 +211,9 @@ export function buildState(body, limit = 12000) {
|
|
|
196
211
|
const remaining = () => Math.max(0, limit - JSON.stringify(state).length);
|
|
197
212
|
// Reserve more than half of the budget for the actual latest human task
|
|
198
213
|
// before considering reminders, original instructions, or tool results.
|
|
199
|
-
state.current_task = fitText(currentTask, Math.min(remaining(), Math.floor(limit * 0.55)));
|
|
200
|
-
state.original_task = fitText(firstTask, Math.min(2000, Math.floor(remaining() * 0.3)));
|
|
201
|
-
state.system = fitText(contentText(body.system, true), Math.min(1000, Math.floor(remaining() * 0.3)));
|
|
214
|
+
state.current_task = fitText(classifierText(currentTask, limit), Math.min(remaining(), Math.floor(limit * 0.55)));
|
|
215
|
+
state.original_task = fitText(classifierText(firstTask, limit), Math.min(2000, Math.floor(remaining() * 0.3)));
|
|
216
|
+
state.system = fitText(classifierText(contentText(body.system, true), limit), Math.min(1000, Math.floor(remaining() * 0.3)));
|
|
202
217
|
|
|
203
218
|
for (let index = messages.length - 1; index >= 0 && state.recent_messages.length < 8; index--) {
|
|
204
219
|
// current_task already contains this message; leave room for actual
|
|
@@ -210,7 +225,7 @@ export function buildState(body, limit = 12000) {
|
|
|
210
225
|
const overhead = JSON.stringify(entry).length + (state.recent_messages.length ? 1 : 0);
|
|
211
226
|
const budget = Math.min(3000, remaining() - overhead);
|
|
212
227
|
if (budget < 1) break;
|
|
213
|
-
entry.content = fitText(text, budget);
|
|
228
|
+
entry.content = fitText(classifierText(text, limit), budget);
|
|
214
229
|
state.recent_messages.unshift(entry);
|
|
215
230
|
}
|
|
216
231
|
return state;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Pattern-based redaction for evaluator excerpts. Classification needs the
|
|
3
|
+
// shape of a task, not credentials or personal identifiers, so recognizable
|
|
4
|
+
// values are replaced before any excerpt leaves the inference path. This is a
|
|
5
|
+
// best-effort filter: unrecognized formats can remain, and code that merely
|
|
6
|
+
// resembles an assignment can be over-redacted. Every quantifier is bounded or
|
|
7
|
+
// excludes its delimiter, so scanning stays linear in the input length.
|
|
8
|
+
|
|
9
|
+
const marker = kind => `[REDACTED:${kind}]`;
|
|
10
|
+
|
|
11
|
+
function ibanValid(value) {
|
|
12
|
+
const compact = value.replace(/ /g, '');
|
|
13
|
+
if (compact.length < 15 || compact.length > 34) return false;
|
|
14
|
+
const rearranged = compact.slice(4) + compact.slice(0, 4);
|
|
15
|
+
let remainder = 0;
|
|
16
|
+
for (const character of rearranged) {
|
|
17
|
+
const digits = /[0-9]/.test(character) ? character : String(character.charCodeAt(0) - 55);
|
|
18
|
+
for (const digit of digits) remainder = (remainder * 10 + Number(digit)) % 97;
|
|
19
|
+
}
|
|
20
|
+
return remainder === 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function luhnValid(value) {
|
|
24
|
+
const digits = value.replace(/[ -]/g, '');
|
|
25
|
+
if (digits.length < 13 || digits.length > 19) return false;
|
|
26
|
+
let sum = 0;
|
|
27
|
+
for (let i = 0; i < digits.length; i++) {
|
|
28
|
+
let digit = Number(digits[digits.length - 1 - i]);
|
|
29
|
+
if (i % 2 === 1) { digit *= 2; if (digit > 9) digit -= 9; }
|
|
30
|
+
sum += digit;
|
|
31
|
+
}
|
|
32
|
+
return sum % 10 === 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const base64 = code => (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
|
|
36
|
+
|| code === 43 || code === 47 || code === 61;
|
|
37
|
+
|
|
38
|
+
// Key material whose BEGIN line was cut off before the excerpt: walk back from
|
|
39
|
+
// each END line over whole base64 lines. A regex line repetition would
|
|
40
|
+
// backtrack quadratically on a long base64 run that is not followed by END.
|
|
41
|
+
function redactKeyTails(text) {
|
|
42
|
+
const end = /-----END [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----/g;
|
|
43
|
+
let output = '', copied = 0;
|
|
44
|
+
for (let match; (match = end.exec(text));) {
|
|
45
|
+
let start = match.index;
|
|
46
|
+
while (start > copied && text[start - 1] === '\n') {
|
|
47
|
+
let lineEnd = start - 1;
|
|
48
|
+
if (lineEnd > copied && text[lineEnd - 1] === '\r') lineEnd--;
|
|
49
|
+
let lineStart = lineEnd;
|
|
50
|
+
while (lineStart > copied && base64(text.charCodeAt(lineStart - 1))) lineStart--;
|
|
51
|
+
if (lineEnd - lineStart < 16 || (lineStart > copied && text[lineStart - 1] !== '\n')) break;
|
|
52
|
+
start = lineStart;
|
|
53
|
+
}
|
|
54
|
+
if (start === match.index) continue;
|
|
55
|
+
output += text.slice(copied, start) + marker('private_key');
|
|
56
|
+
copied = end.lastIndex;
|
|
57
|
+
}
|
|
58
|
+
return copied ? output + text.slice(copied) : text;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @type {ReadonlyArray<readonly [RegExp, string | ((...match: string[]) => string)] | ((text: string) => string)>} */
|
|
62
|
+
const RULES = Object.freeze([
|
|
63
|
+
// A block cut off by excerpting is still redacted through the end of text.
|
|
64
|
+
[/-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----|$)/g, marker('private_key')],
|
|
65
|
+
redactKeyTails,
|
|
66
|
+
[/\b([a-z][a-z0-9+.-]{1,20}:\/\/)[^\s:@/]{1,256}:[^\s@/]{1,256}@/gi, (_, scheme) => `${scheme}${marker('credentials')}@`],
|
|
67
|
+
[/\bsk-[A-Za-z0-9_-]{20,}/g, marker('secret')],
|
|
68
|
+
[/\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}/g, marker('secret')],
|
|
69
|
+
[/\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/g, marker('secret')],
|
|
70
|
+
[/\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/g, marker('secret')],
|
|
71
|
+
[/\bglpat-[A-Za-z0-9_-]{20,}/g, marker('secret')],
|
|
72
|
+
[/\bxox[abposr]-[A-Za-z0-9-]{10,}/g, marker('secret')],
|
|
73
|
+
[/https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/]{8,}/g, marker('secret')],
|
|
74
|
+
[/\bAIza[0-9A-Za-z_-]{35}/g, marker('secret')],
|
|
75
|
+
[/\bnpm_[A-Za-z0-9]{36}/g, marker('secret')],
|
|
76
|
+
[/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, marker('secret')],
|
|
77
|
+
[/\b((?:proxy-)?authorization["']?[ \t]{0,8}[:=][ \t]{0,8}["']?)(?:(Bearer|Basic|Token|Digest)[ \t]+)?[^\s"',;]{4,}/gi,
|
|
78
|
+
(_, prefix, scheme) => `${prefix}${scheme ? `${scheme} ` : ''}${marker('secret')}`],
|
|
79
|
+
[/\b(Bearer[ \t]+)[A-Za-z0-9._~+/-]{16,}=*/g, (_, prefix) => `${prefix}${marker('secret')}`],
|
|
80
|
+
// Keep the setting name: it tells the classifier what kind of work this is.
|
|
81
|
+
[/\b([A-Za-z0-9_.-]{0,40}(?:passw(?:or)?d|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|credentials?)[A-Za-z0-9_.-]{0,40})(["']?[ \t]{0,8}[:=][ \t]{0,8})(["']?)[^\s"',;]{4,}/gi,
|
|
82
|
+
(_, name, separator, quote) => `${name}${separator}${quote}${marker('secret')}`],
|
|
83
|
+
[/\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,253}\.[A-Za-z]{2,24}\b/g, marker('email')],
|
|
84
|
+
[/\b[A-Z]{2}[0-9]{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,4})?\b/g, match => ibanValid(match) ? marker('iban') : match],
|
|
85
|
+
// Major card networks only, so millisecond timestamps and IDs survive.
|
|
86
|
+
[/\b(?:4|5[1-5]|2[2-7]|3[47]|6)(?:[0-9][ -]?){11,17}[0-9]\b/g, match => luhnValid(match) ? marker('card') : match],
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
/** @param {string} text */
|
|
90
|
+
export function redactSensitive(text) {
|
|
91
|
+
let result = text;
|
|
92
|
+
for (const rule of RULES) {
|
|
93
|
+
// @ts-ignore String.replace accepts either replacement form.
|
|
94
|
+
result = typeof rule === 'function' ? rule(result) : result.replace(rule[0], rule[1]);
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
}
|
package/src/router.mjs
CHANGED
|
@@ -191,7 +191,7 @@ export class Router {
|
|
|
191
191
|
// Include live configuration/rubric facts so configuration changes cannot
|
|
192
192
|
// reuse cached decisions from another evaluator, account or confidence rule.
|
|
193
193
|
const c = this.config;
|
|
194
|
-
const evaluator = c.evaluator ?? '
|
|
194
|
+
const evaluator = c.evaluator ?? 'ollama';
|
|
195
195
|
const settings = evaluator === 'ollama'
|
|
196
196
|
? { evaluator, ollamaEndpoint: c.ollamaEndpoint, ollamaModel: c.ollamaModel, ollamaTimeoutMs: c.ollamaTimeoutMs,
|
|
197
197
|
ollamaStateChars: c.ollamaStateChars, ollamaKeepAlive: c.ollamaKeepAlive }
|
package/src/session-history.mjs
CHANGED
|
@@ -231,7 +231,8 @@ export async function sessionsCommand(args, { env = process.env, write = console
|
|
|
231
231
|
|| (operation === 'list' ? positional.length !== 0 : positional.length !== 1)) {
|
|
232
232
|
throw new Error('Usage: claude-autorouter sessions list [--json] | sessions show ID [--json]');
|
|
233
233
|
}
|
|
234
|
-
|
|
234
|
+
// History needs only the log directory; never prompt for keychain access.
|
|
235
|
+
const loaded = loadUserConfig(env, { allowMissing: true, readSecrets: false });
|
|
235
236
|
const directory = parseSessionLogDir(loaded.env.AUTOROUTER_SESSION_LOG_DIR);
|
|
236
237
|
if (!directory) {
|
|
237
238
|
const report = { schema_version: 1, type: 'session_history', logging_enabled: false, sessions: [],
|
package/src/telemetry-event.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
import { redactSensitive } from './redaction.mjs';
|
|
2
3
|
// Shared, bounded telemetry contract. Never copy request bodies, headers, raw
|
|
3
4
|
// errors or arbitrary provider fields into status snapshots or saved history.
|
|
4
5
|
export const TELEMETRY_SCHEMA_VERSION = 2;
|
|
@@ -161,6 +162,9 @@ export function normalizeTelemetryEvent(entry) {
|
|
|
161
162
|
function excerpt(value) {
|
|
162
163
|
let text = '', length = 0;
|
|
163
164
|
if (typeof value !== 'string') return { text, truncated: false };
|
|
165
|
+
// Defense in depth, and it also covers records written before redaction
|
|
166
|
+
// existed when they are read back. Already-redacted text is unchanged.
|
|
167
|
+
value = redactSensitive(value);
|
|
164
168
|
for (const character of value) {
|
|
165
169
|
if (length++ === 500) return { text: text.toWellFormed(), truncated: true };
|
|
166
170
|
text += character;
|
package/src/user-config.mjs
CHANGED
|
@@ -5,9 +5,10 @@ import {
|
|
|
5
5
|
import { createHash, randomBytes } from 'node:crypto';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
8
|
+
import { createKeychain } from './keychain.mjs';
|
|
8
9
|
|
|
9
10
|
export const CONFIG_KEYS = Object.freeze([
|
|
10
|
-
'AUTOROUTER_AUTH_MODE', 'AUTOROUTER_CLIENT_PROFILE',
|
|
11
|
+
'AUTOROUTER_AUTH_MODE', 'AUTOROUTER_CLIENT_PROFILE', 'AUTOROUTER_SECRET_STORE',
|
|
11
12
|
'ANTHROPIC_API_KEY', 'TYPESAFE_API_KEY', 'AUTOROUTER_TOKEN',
|
|
12
13
|
'AUTOROUTER_UPSTREAM_URL', 'AUTOROUTER_JEV_URL', 'AUTOROUTER_JEV_MODEL',
|
|
13
14
|
'AUTOROUTER_HAIKU_MODEL', 'AUTOROUTER_SONNET_MODEL', 'AUTOROUTER_OPUS_MODEL',
|
|
@@ -19,7 +20,13 @@ export const CONFIG_KEYS = Object.freeze([
|
|
|
19
20
|
'AUTOROUTER_OLLAMA_TIMEOUT_MS', 'AUTOROUTER_OLLAMA_KEEP_ALIVE',
|
|
20
21
|
]);
|
|
21
22
|
export const SECRET_CONFIG_KEYS = Object.freeze(['ANTHROPIC_API_KEY', 'TYPESAFE_API_KEY', 'AUTOROUTER_TOKEN']);
|
|
23
|
+
export const SECRET_STORES = Object.freeze(['file', 'keychain']);
|
|
22
24
|
const allowedKeys = new Set(CONFIG_KEYS);
|
|
25
|
+
const defaultKeychain = createKeychain();
|
|
26
|
+
// Items are scoped to one configuration file, so separate configurations
|
|
27
|
+
// (including test fixtures) never share or overwrite each other's secrets.
|
|
28
|
+
const keychainAccount = (path, key) => `${key}:${createHash('sha256').update(path).digest('hex').slice(0, 16)}`;
|
|
29
|
+
const keychainLabel = (path, key) => `AutoRouter ${key} (${path})`;
|
|
23
30
|
const revision = content => createHash('sha256').update(content).digest('hex');
|
|
24
31
|
const SAFE_FS_CODES = new Set([
|
|
25
32
|
'EACCES', 'EPERM', 'ENOENT', 'ENOTDIR', 'EISDIR', 'ENOSPC', 'EROFS',
|
|
@@ -57,6 +64,9 @@ function validate(values) {
|
|
|
57
64
|
}
|
|
58
65
|
validated[key] = descriptor.value;
|
|
59
66
|
}
|
|
67
|
+
if (validated.AUTOROUTER_SECRET_STORE !== undefined && !SECRET_STORES.includes(validated.AUTOROUTER_SECRET_STORE)) {
|
|
68
|
+
throw configError('AUTOROUTER_SECRET_STORE must be file or keychain.');
|
|
69
|
+
}
|
|
60
70
|
return validated;
|
|
61
71
|
}
|
|
62
72
|
|
|
@@ -74,14 +84,20 @@ export function getConfigPath(env = process.env) {
|
|
|
74
84
|
return join(xdg || join(homedir(), '.config'), 'claude-autorouter', 'config.json');
|
|
75
85
|
}
|
|
76
86
|
|
|
77
|
-
|
|
87
|
+
// The saved store setting decides where saved secrets live; the environment
|
|
88
|
+
// only overrides values. With the keychain store, `values` includes secrets
|
|
89
|
+
// read from the keychain so callers can update settings without losing them.
|
|
90
|
+
export function loadUserConfig(env = process.env, { allowMissing = false, readSecrets = true, keychain = defaultKeychain } = {}) {
|
|
78
91
|
const path = getConfigPath(env);
|
|
79
92
|
let content;
|
|
80
93
|
try {
|
|
81
94
|
content = readFileSync(path, 'utf8');
|
|
82
95
|
} catch (error) {
|
|
83
96
|
if (error.code === 'ENOENT') {
|
|
84
|
-
if (env.AUTOROUTER_CONFIG === undefined || allowMissing)
|
|
97
|
+
if (env.AUTOROUTER_CONFIG === undefined || allowMissing) {
|
|
98
|
+
return { env: { ...env }, values: {}, path, exists: false, revision: null,
|
|
99
|
+
secretStore: 'file', keychainSecrets: [], unavailableSecrets: [] };
|
|
100
|
+
}
|
|
85
101
|
throw configError('AUTOROUTER_CONFIG points to a missing configuration file.');
|
|
86
102
|
}
|
|
87
103
|
throw filesystemError(error, 'read');
|
|
@@ -90,7 +106,36 @@ export function loadUserConfig(env = process.env, { allowMissing = false } = {})
|
|
|
90
106
|
try { parsed = JSON.parse(content); }
|
|
91
107
|
catch { throw configError('AutoRouter configuration must contain valid JSON.'); }
|
|
92
108
|
const values = validate(parsed);
|
|
93
|
-
|
|
109
|
+
const secretStore = values.AUTOROUTER_SECRET_STORE ?? 'file';
|
|
110
|
+
const keychainSecrets = [], unavailableSecrets = [];
|
|
111
|
+
if (secretStore === 'keychain' && readSecrets) {
|
|
112
|
+
for (const key of SECRET_CONFIG_KEYS) {
|
|
113
|
+
if (Object.hasOwn(values, key)) continue;
|
|
114
|
+
let value;
|
|
115
|
+
try { value = keychain.read(keychainAccount(path, key)); }
|
|
116
|
+
catch (error) {
|
|
117
|
+
// An environment value can stand in for a locked keychain, such as
|
|
118
|
+
// over SSH. Only a missing required value stops the caller.
|
|
119
|
+
if (env[key] !== undefined) { unavailableSecrets.push(key); continue; }
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
if (value !== undefined) { values[key] = value; keychainSecrets.push(key); }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { env: { ...values, ...env }, values, path, exists: true, revision: revision(content),
|
|
126
|
+
secretStore, keychainSecrets, unavailableSecrets };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Keychain items to delete when saving `nextStore`, given what was loaded.
|
|
130
|
+
// Moving between stores needs every saved secret, so refuse while one could
|
|
131
|
+
// not be read; deleting it unseen would lose it.
|
|
132
|
+
export function keychainRemovals(loaded, nextStore, { replace = false, values = {} } = {}) {
|
|
133
|
+
if (loaded.secretStore !== 'keychain') return [];
|
|
134
|
+
if (nextStore !== 'keychain' && loaded.unavailableSecrets?.length) {
|
|
135
|
+
throw configError('Could not read every saved secret from the macOS Keychain. Unlock the login keychain and retry.');
|
|
136
|
+
}
|
|
137
|
+
if (nextStore !== 'keychain') return [...loaded.keychainSecrets];
|
|
138
|
+
return replace ? loaded.keychainSecrets.filter(key => !Object.hasOwn(values, key)) : [];
|
|
94
139
|
}
|
|
95
140
|
|
|
96
141
|
function existingFile(path) {
|
|
@@ -102,8 +147,17 @@ function existingFile(path) {
|
|
|
102
147
|
return stat;
|
|
103
148
|
}
|
|
104
149
|
|
|
105
|
-
|
|
150
|
+
// With the keychain store, secrets are written to the keychain before the file
|
|
151
|
+
// (an interrupted move leaves both copies, never neither) and stale items are
|
|
152
|
+
// removed only after the file is saved.
|
|
153
|
+
export function saveUserConfig(values, {
|
|
154
|
+
env = process.env, overwrite = false, expectedRevision, removeSecrets = [], keychain = defaultKeychain,
|
|
155
|
+
} = {}) {
|
|
106
156
|
const validated = validate(values);
|
|
157
|
+
const store = validated.AUTOROUTER_SECRET_STORE ?? 'file';
|
|
158
|
+
const keychainKeys = store === 'keychain' ? SECRET_CONFIG_KEYS.filter(key => Object.hasOwn(validated, key)) : [];
|
|
159
|
+
const fileValues = { ...validated };
|
|
160
|
+
for (const key of keychainKeys) delete fileValues[key];
|
|
107
161
|
const path = getConfigPath(env);
|
|
108
162
|
const parent = dirname(path);
|
|
109
163
|
let temporary;
|
|
@@ -118,13 +172,17 @@ export function saveUserConfig(values, { env = process.env, overwrite = false, e
|
|
|
118
172
|
throw configError('AutoRouter configuration already exists; use overwrite to replace it.');
|
|
119
173
|
}
|
|
120
174
|
checkRevision();
|
|
175
|
+
if (store === 'keychain' && !keychain.available) {
|
|
176
|
+
throw configError('The macOS Keychain secret store is available only on macOS.');
|
|
177
|
+
}
|
|
178
|
+
for (const key of keychainKeys) keychain.write(keychainAccount(path, key), validated[key], keychainLabel(path, key));
|
|
121
179
|
// mkdir leaves existing directory permissions unchanged. Only directories
|
|
122
180
|
// created for this configuration receive the private creation mode.
|
|
123
181
|
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
124
182
|
temporary = join(parent, `.${basename(path)}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`);
|
|
125
183
|
descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
126
184
|
fchmodSync(descriptor, 0o600);
|
|
127
|
-
writeFileSync(descriptor, `${JSON.stringify(
|
|
185
|
+
writeFileSync(descriptor, `${JSON.stringify(fileValues, null, 2)}\n`, 'utf8');
|
|
128
186
|
fsyncSync(descriptor);
|
|
129
187
|
closeSync(descriptor);
|
|
130
188
|
descriptor = undefined;
|
|
@@ -143,6 +201,9 @@ export function saveUserConfig(values, { env = process.env, overwrite = false, e
|
|
|
143
201
|
// symlink can never be replaced by the default save operation.
|
|
144
202
|
linkSync(temporary, path);
|
|
145
203
|
}
|
|
204
|
+
for (const key of removeSecrets) {
|
|
205
|
+
if (SECRET_CONFIG_KEYS.includes(key) && !keychainKeys.includes(key)) keychain.remove(keychainAccount(path, key));
|
|
206
|
+
}
|
|
146
207
|
return path;
|
|
147
208
|
} catch (error) {
|
|
148
209
|
if (error?.code === 'EEXIST') {
|