diff --git a/README.md b/README.md index ec6e2f7..f298496 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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 @@ -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 diff --git a/internal/launch/launch.go b/internal/launch/launch.go index 9ee6b99..98c06b5 100644 --- a/internal/launch/launch.go +++ b/internal/launch/launch.go @@ -23,12 +23,27 @@ 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 { @@ -36,7 +51,8 @@ func BuildChildEnv(environ []string, baseURL, sessionToken string) []string { 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 { @@ -44,6 +60,9 @@ func BuildChildEnv(environ []string, baseURL, sessionToken string) []string { } out = append(out, kv) } + if setEntrypoint { + out = append(out, entrypointEnv+"="+entrypoint) + } if !hasTTL { out = append(out, promptCacheTTLEnv+"=1h") } @@ -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 +} diff --git a/internal/launch/launch_test.go b/internal/launch/launch_test.go index 87f71c5..6a216fd 100644 --- a/internal/launch/launch_test.go +++ b/internal/launch/launch_test.go @@ -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 +}