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
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
id: bugfix-985
title: consult-m-claude-bills-metered
protocol: bugfix
phase: pr
plan_phases: []
current_plan_phase: null
gates:
pr:
status: approved
requested_at: '2026-06-04T04:22:57.960Z'
approved_at: '2026-06-04T05:13:18.384Z'
iteration: 1
build_complete: false
history: []
started_at: '2026-06-04T04:10:42.581Z'
updated_at: '2026-06-04T05:13:18.385Z'
pr_ready_for_human: false
13 changes: 13 additions & 0 deletions codev/resources/commands/consult.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,19 @@ Configure API keys:
- Codex: `OPENAI_API_KEY`
- Gemini: `GOOGLE_API_KEY` or `GEMINI_API_KEY`

### Claude auth: subscription vs. metered API

`consult -m claude` runs on the Claude Agent SDK. When `CLAUDE_CODE_OAUTH_TOKEN`
(a Claude subscription/OAuth token) is present, consult strips `ANTHROPIC_API_KEY`
and `ANTHROPIC_AUTH_TOKEN` from the SDK subprocess env so the consultation
authenticates against the **subscription** rather than the **metered Opus API**.
The Agent SDK otherwise prioritizes `ANTHROPIC_API_KEY`, which silently routes
CMAP/review traffic to the metered API (issue #985). When no OAuth token is set,
the API key is used as before so CI / key-only environments keep working.

> **Caveat:** dedicated Agent-SDK subscription credit starts **2026-06-15**.
> Before that date, subscription auth draws from the interactive Max quota.

## The Consultant Role

The consultant role (`codev/roles/consultant.md`) defines behavior:
Expand Down
52 changes: 52 additions & 0 deletions codev/state/bugfix-985_thread.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# bugfix-985 thread

## Issue
`consult -m claude` bills the metered Opus API instead of the Claude subscription because it
forwards `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` into the Agent SDK env, which shadows
`CLAUDE_CODE_OAUTH_TOKEN` in the SDK's auth priority. Reported by an external adopter
(~$150/day on a heavy dev day).

## Investigate (done)
Root cause confirmed in source on this branch:
- `packages/codev/src/commands/consult/index.ts`, `runClaudeConsultation()` lines 504-509
copy ALL of `process.env` into the `env` object passed to `claudeQuery`. That includes
`ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` when present, which the Agent SDK prioritizes
over `CLAUDE_CODE_OAUTH_TOKEN`.
- No existing test exercises this env construction. Function is not exported.
- Established testability pattern in this file: internal helpers re-exported with `_` prefix
(see export block ~line 1627). Test dir: `src/commands/consult/__tests__/` (vitest).
- Adjacent (noted, not in scope): line 519 hardcodes `model: 'claude-opus-4-6'`.

## Fix plan
- Extract env-building into a pure exported helper `buildClaudeConsultEnv(processEnv)` so it's
unit-testable. When `CLAUDE_CODE_OAUTH_TOKEN` is set, delete `ANTHROPIC_API_KEY` and
`ANTHROPIC_AUTH_TOKEN` from the LOCAL env object (not global process.env). When OAuth token
is absent, preserve the API key (CI/key-only envs keep working).
- Regression test in `__tests__/claude-auth-env.test.ts`.
- Doc note in consult docs re: subscription-credit caveat (2026-06-15).

## Fix (done)
- `consult/index.ts`: extracted env-building into exported pure helper
`buildClaudeConsultEnv(processEnv)`. Strips `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`
from the local copy only when `CLAUDE_CODE_OAUTH_TOKEN` is set; preserves the key
otherwise. `runClaudeConsultation` now calls the helper.
- Regression test: `__tests__/claude-auth-env.test.ts` (4 cases: strip-on-oauth,
preserve-without-oauth, no-mutation-of-source, drops-undefined). All pass.
- Doc note added to `codev/resources/commands/consult.md`.
- Verified: consult suite 79/79 pass; `tsc --noEmit` clean (after building codev-core
in the fresh worktree). Net diff well under 300 LOC.

BUGFIX protocol phases: Investigate → Fix → Create PR.

## Create PR (done)
- PR #986 opened against main. "Fixes #985".
- CMAP (3-way) on the PR — all APPROVE, HIGH confidence, zero blocking issues:
- Codex: APPROVE/HIGH — correct, tightly scoped, solid regression coverage.
- Gemini: APPROVE/HIGH — correct root-cause fix, thorough branch coverage.
- Claude: APPROVE/HIGH — clean minimal fix; flagged (non-blocking) external-adopter
scrubbing convention + the `_`-prefix export style note (kept direct export — it's a
legitimate public utility, not a test-only helper).
- Applied scrub: removed external-adopter workspace name from PR body + this thread
(kept the ~$150/day signal). Issue body still names the adopter — upstream of the
builder; flagged to architect.
- Awaiting architect approval to merge (BUGFIX pr gate).
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import { buildClaudeConsultEnv } from '../index.js';

/**
* Regression test for issue #985.
*
* `consult -m claude` must authenticate the Agent SDK with the Claude
* subscription (`CLAUDE_CODE_OAUTH_TOKEN`) rather than the metered Opus API.
* The SDK prioritizes `ANTHROPIC_API_KEY` over `CLAUDE_CODE_OAUTH_TOKEN`, so
* when an OAuth token is present the API/auth tokens must be stripped from the
* subprocess env. When no OAuth token is set, the API key must be preserved so
* CI / key-only environments still authenticate.
*/
describe('buildClaudeConsultEnv (issue #985)', () => {
it('strips ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN when OAuth token is set', () => {
const env = buildClaudeConsultEnv({
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-sub-token',
ANTHROPIC_API_KEY: 'sk-metered-key',
ANTHROPIC_AUTH_TOKEN: 'auth-token',
PATH: '/usr/bin',
});

expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
// OAuth token and unrelated vars are preserved.
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-sub-token');
expect(env.PATH).toBe('/usr/bin');
});

it('preserves ANTHROPIC_API_KEY when no OAuth token is set (CI / key-only)', () => {
const env = buildClaudeConsultEnv({
ANTHROPIC_API_KEY: 'sk-metered-key',
ANTHROPIC_AUTH_TOKEN: 'auth-token',
PATH: '/usr/bin',
});

expect(env.ANTHROPIC_API_KEY).toBe('sk-metered-key');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('auth-token');
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
});

it('does not mutate the source process.env object', () => {
const source: NodeJS.ProcessEnv = {
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-sub-token',
ANTHROPIC_API_KEY: 'sk-metered-key',
ANTHROPIC_AUTH_TOKEN: 'auth-token',
};

buildClaudeConsultEnv(source);

// The deletion must be scoped to the returned copy, never the global env.
expect(source.ANTHROPIC_API_KEY).toBe('sk-metered-key');
expect(source.ANTHROPIC_AUTH_TOKEN).toBe('auth-token');
});

it('drops undefined values while copying', () => {
const env = buildClaudeConsultEnv({
DEFINED: 'yes',
UNDEFINED: undefined,
});

expect(env.DEFINED).toBe('yes');
expect('UNDEFINED' in env).toBe(false);
});
});
41 changes: 35 additions & 6 deletions packages/codev/src/commands/consult/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,40 @@ export async function runCodexConsultation(
}
}

/**
* Build the env passed to the Claude Agent SDK subprocess for a consultation.
*
* Copies the given environment, but when a Claude subscription/OAuth token
* (`CLAUDE_CODE_OAUTH_TOKEN`) is present, strips `ANTHROPIC_API_KEY` and
* `ANTHROPIC_AUTH_TOKEN` from the *copy* (never the global `process.env`).
* The Agent SDK prioritizes the API key over the OAuth token, so leaving the
* key in would silently route CMAP/review traffic to the metered Opus API
* instead of the Claude subscription (issue #985).
*
* When no OAuth token is set, the API key is preserved so CI / key-only
* environments continue to authenticate.
*
* The deletion is scoped to this subprocess env only — other callers that need
* the API key (persona, dev:local) are unaffected.
*/
export function buildClaudeConsultEnv(
processEnv: NodeJS.ProcessEnv,
): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(processEnv)) {
if (value !== undefined) {
env[key] = value;
}
}

if (env.CLAUDE_CODE_OAUTH_TOKEN) {
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_AUTH_TOKEN;
}

return env;
}

/**
* Run Claude consultation via Agent SDK.
* Uses the SDK's query() function instead of CLI subprocess.
Expand All @@ -501,12 +535,7 @@ async function runClaudeConsultation(
const savedClaudeCode = process.env.CLAUDECODE;
delete process.env.CLAUDECODE;

const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) {
env[key] = value;
}
}
const env = buildClaudeConsultEnv(process.env);

try {
const session = claudeQuery({
Expand Down
Loading