Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ with a flag go to claude, so `cproxy run --pool cvp -- --resume x` works
like cswap's run. Ambient Anthropic credentials and `CPROXY_*` secrets are
stripped from the child's environment, and `CLAUDE_CODE_PROMPT_CACHE_TTL=1h`
is set unless you set it yourself (see [Prompt cache TTL](#prompt-cache-ttl)).
`CLAUDE_CODE_ENTRYPOINT` is left as the environment has it — stock behaviour
— unless `CPROXY_ENTRYPOINT` opts the launch into the custom-label workaround
for the faster-draining `sdk-*` rate (see
[The 5-hour meter and the entrypoint label](#the-5-hour-meter-and-the-entrypoint-label)).
The child shares cproxy's process
group (Ctrl+C reaches it directly), and cproxy exits with the child's exit
code. `--isolated` runs a private proxy for one launch instead.
Expand Down Expand Up @@ -221,6 +225,7 @@ orbs, CI, or anywhere you don't want a credentials file:
| `CPROXY_CLIENT_TOKENS` | several client tokens in one variable: `name:token[:scopes]`, scopes `+`-separated |
| `CPROXY_UPSTREAM` | upstream base URL (default `https://api.anthropic.com`) |
| `CPROXY_HOME` | config directory (default `~/.cproxy`) |
| `CPROXY_ENTRYPOINT` | custom `CLAUDE_CODE_ENTRYPOINT` label for the launch (unset, `off` or `0` leaves the variable untouched) |

One variable per account is the easiest to manage as secrets: adding,
rotating or removing an account touches only its own variable.
Expand Down Expand Up @@ -312,6 +317,42 @@ set it yourself (`5m`, or `1h`) to override. Clients of a shared
subscriber, Claude Code then keeps 1h even after the account is past its plan
limits.

## The 5-hour meter and the entrypoint label

Anthropic's plan meters currently charge headless Claude Code — `claude -p`
and Agent SDK apps like T3 Code — about 1.7× to 2.9× more of the 5-hour
window per token than the same tokens from the interactive CLI, because those
sessions label their requests `sdk-cli`/`sdk-ts` and that label is what is
weighted ([anthropics/claude-code#97074](https://github.com/anthropics/claude-code/issues/97074),
[pingdotgg/t3code#24649](https://github.com/pingdotgg/t3code/issues/24649)).
Weekly limits are not affected. Every request carries the label, as
`cc_entrypoint` in its billing block and in the User-Agent, and it comes from
`CLAUDE_CODE_ENTRYPOINT` in Claude Code's environment.

cproxy can apply the same workaround: `cproxy run` and the shim launch claude
with `CLAUDE_CODE_ENTRYPOINT=$CPROXY_ENTRYPOINT` (any custom name, e.g.
`acme-agent`), so its requests carry a custom client label — the same
workaround T3 Code and Paseo ship — and skip the `sdk-*` weighting. The name
must be custom: headless claude rewrites the literal `cli` (the interactive
TUI's default) to `sdk-cli`, so `cli` and `sdk-*` values are treated like
unset. With the variable unset — or `off` or `0` — cproxy leaves
`CLAUDE_CODE_ENTRYPOINT` exactly as the environment has it, including
absent: stock claude behaviour. An interactive TUI launched through cproxy
needs no label either way; claude defaults it to `cli` itself.

Clients of a shared `cproxy serve` set it in their own environment, like the
prompt cache TTL:

```sh
export CLAUDE_CODE_ENTRYPOINT=acme-agent # any custom name
```

This is a workaround for an Anthropic-side bug, not an Anthropic-confirmed
fix: it only changes how usage is attributed (custom labels are the same
mechanism official surfaces like the VS Code extension use). It is opt-in —
no launch is labeled unless `CPROXY_ENTRYPOINT` is set — so there is nothing
to undo once the metering is fixed upstream.

## Hosting a shared cproxy

Run one `cproxy serve` that holds the pool, and hand each person (or
Expand All @@ -328,10 +369,16 @@ claude
```

Also set `export CLAUDE_CODE_PROMPT_CACHE_TTL=1h` to keep the 1-hour prompt
cache; see [Prompt cache TTL](#prompt-cache-ttl).
cache; see [Prompt cache TTL](#prompt-cache-ttl). And set
`export CLAUDE_CODE_ENTRYPOINT=acme-agent` (any custom name) so headless use
is not charged the `sdk-*` rate; see
[The 5-hour meter and the entrypoint label](#the-5-hour-meter-and-the-entrypoint-label).
This one has to be the environment variable, not the settings `env` block:
cproxy is not launching claude there, and claude applies that block only to
processes it spawns.

If the cproxy shim is installed on that machine it sees `ANTHROPIC_BASE_URL`
and stays out of the way. In Amp, set those two variables (the token as a
and stays out of the way. In Amp, set those variables (the token as a
secret) in your personal or project env instead of `CPROXY_TOKEN_*`.

### On the server
Expand Down
65 changes: 63 additions & 2 deletions internal/launch/launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,46 @@ var strippedEnv = []string{
// although every pool account is a subscription.
const promptCacheTTLEnv = "CLAUDE_CODE_PROMPT_CACHE_TTL"

// Headless Claude Code (claude -p, the Agent SDK) labels its requests
// sdk-cli or sdk-ts, and Anthropic's plan meters currently charge those
// labels roughly 1.7x-2.9x more of the 5-hour window per token than the
// same tokens under any other label (anthropics/claude-code#97074,
// pingdotgg/t3code#24649). A custom label sidesteps the weighting — the
// same workaround T3 Code and Paseo ship — and $CPROXY_ENTRYPOINT opts a
// launch into it. The name must be custom: headless claude rewrites the
// literal "cli" (the interactive TUI's default) to sdk-cli.
const (
entrypointEnv = "CLAUDE_CODE_ENTRYPOINT"
entrypointLabelFlag = "CPROXY_ENTRYPOINT"
)

// BuildChildEnv returns environ rewritten so the child talks to cproxy: the
// base URL and auth token point at the local proxy, competing credentials
// are removed, and every CPROXY_* variable (which may hold secrets) is
// stripped. The prompt cache TTL defaults to 1h, as it would for a signed-in
// subscriber, unless the environment already sets it.
// subscriber, unless the environment already sets it, and the entrypoint
// label is picked as entrypointFor describes.
func BuildChildEnv(environ []string, baseURL, sessionToken string) []string {
entrypoint, setEntrypoint := entrypointFor(environ)
out := make([]string, 0, len(environ)+3)
hasTTL := false
for _, kv := range environ {
k, _, ok := strings.Cut(kv, "=")
if !ok {
continue
}
if slices.Contains(strippedEnv, k) || strings.HasPrefix(k, "CPROXY_") {
if slices.Contains(strippedEnv, k) || strings.HasPrefix(k, "CPROXY_") ||
(k == entrypointEnv && setEntrypoint) {
continue
}
if k == promptCacheTTLEnv {
hasTTL = true
}
out = append(out, kv)
}
if setEntrypoint {
out = append(out, entrypointEnv+"="+entrypoint)
}
if !hasTTL {
out = append(out, promptCacheTTLEnv+"=1h")
}
Expand All @@ -53,3 +72,45 @@ func BuildChildEnv(environ []string, baseURL, sessionToken string) []string {
)
return out
}

// entrypointFor picks the CLAUDE_CODE_ENTRYPOINT the child runs with. The
// second return is false when the variable should be left exactly as the
// environment has it, including absent — normal behaviour. That is the case
// for $CPROXY_ENTRYPOINT unset, off, 0, or naming a label that cannot work
// (cli, which headless claude rewrites to sdk-cli, and the sdk-* spellings).
// Any other value names the label outright, replacing whatever the
// environment has.
func entrypointFor(environ []string) (string, bool) {
label, ok := envValue(environ, entrypointLabelFlag)
if !ok {
return "", false
}
switch strings.ToLower(strings.TrimSpace(label)) {
case "off", "0":
return "", false
}
if !usableEntrypoint(label) {
return "", false
}
return strings.TrimSpace(label), true
}

// usableEntrypoint reports whether value can serve as the label: the sdk-*
// values are the over-charged category, and headless claude rewrites the
// literal cli to sdk-cli, so those spellings are rejected; any other
// non-empty value works.
func usableEntrypoint(v string) bool {
v = strings.TrimSpace(v)
return v != "" && v != "cli" && !strings.HasPrefix(v, "sdk-")
}

// envValue returns the value of name in environ; empty values count as
// unset.
func envValue(environ []string, name string) (string, bool) {
for _, kv := range environ {
if k, v, ok := strings.Cut(kv, "="); ok && k == name && v != "" {
return v, true
}
}
return "", false
}
43 changes: 43 additions & 0 deletions internal/launch/launch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,46 @@ func TestBuildChildEnvKeepsPromptCacheTTL(t *testing.T) {
t.Fatalf("prompt cache TTL set %d times, want 1", n)
}
}

func TestBuildChildEnvEntrypoint(t *testing.T) {
for _, tc := range []struct {
name string
env []string
want []string // CLAUDE_CODE_ENTRYPOINT values the child env must hold
}{
{name: "no flag sets nothing", env: nil, want: nil},
{name: "no flag leaves inherited custom alone", env: []string{"CLAUDE_CODE_ENTRYPOINT=my-tool"}, want: []string{"my-tool"}},
{name: "no flag leaves inherited sdk-cli alone", env: []string{"CLAUDE_CODE_ENTRYPOINT=sdk-cli"}, want: []string{"sdk-cli"}},
{name: "no flag leaves inherited sdk-ts alone", env: []string{"CLAUDE_CODE_ENTRYPOINT=sdk-ts"}, want: []string{"sdk-ts"}},
{name: "no flag leaves inherited cli alone", env: []string{"CLAUDE_CODE_ENTRYPOINT=cli"}, want: []string{"cli"}},
{name: "no flag leaves inherited empty alone", env: []string{"CLAUDE_CODE_ENTRYPOINT="}, want: []string{""}},
{name: "flag names label", env: []string{"CPROXY_ENTRYPOINT=paseo"}, want: []string{"paseo"}},
{name: "flag trims label", env: []string{"CPROXY_ENTRYPOINT= paseo "}, want: []string{"paseo"}},
{name: "flag replaces inherited sdk-ts", env: []string{"CPROXY_ENTRYPOINT=paseo", "CLAUDE_CODE_ENTRYPOINT=sdk-ts"}, want: []string{"paseo"}},
{name: "flag replaces inherited custom", env: []string{"CPROXY_ENTRYPOINT=paseo", "CLAUDE_CODE_ENTRYPOINT=my-tool"}, want: []string{"paseo"}},
{name: "flag cli touches nothing", env: []string{"CPROXY_ENTRYPOINT=cli"}, want: nil},
{name: "flag cli leaves inherited alone", env: []string{"CPROXY_ENTRYPOINT=cli", "CLAUDE_CODE_ENTRYPOINT=cli"}, want: []string{"cli"}},
{name: "flag sdk-ts touches nothing", env: []string{"CPROXY_ENTRYPOINT=sdk-ts"}, want: nil},
{name: "flag off sets nothing", env: []string{"CPROXY_ENTRYPOINT=off"}, want: nil},
{name: "flag off leaves inherited alone", env: []string{"CPROXY_ENTRYPOINT=off", "CLAUDE_CODE_ENTRYPOINT=sdk-cli"}, want: []string{"sdk-cli"}},
{name: "flag zero sets nothing", env: []string{"CPROXY_ENTRYPOINT=0"}, want: nil},
{name: "flag empty sets nothing", env: []string{"CPROXY_ENTRYPOINT="}, want: nil},
} {
t.Run(tc.name, func(t *testing.T) {
got := entrypointValues(BuildChildEnv(tc.env, "http://127.0.0.1:4123", "cp_sess"))
if !slices.Equal(got, tc.want) {
t.Fatalf("CLAUDE_CODE_ENTRYPOINT = %v, want %v", got, tc.want)
}
})
}
}

func entrypointValues(env []string) []string {
var got []string
for _, kv := range env {
if k, v, ok := cut(kv); ok && k == "CLAUDE_CODE_ENTRYPOINT" {
got = append(got, v)
}
}
return got
}
Loading