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
Expand Up @@ -104,12 +104,14 @@ describe('AnthropicContentGenerator', () => {
vi.restoreAllMocks();
});

it('uses claude-cli identity (User-Agent + x-app + Bearer auth) for non-Anthropic baseURLs', async () => {
it('uses claude-cli identity (User-Agent + x-app + Bearer auth + x-api-key) for non-Anthropic baseURLs', async () => {
// Non-Anthropic-native baseURL → IdeaLab-style proxy path:
// - User-Agent presents as `claude-cli/<version> (external, cli)`
// - `x-app: cli` is sent
// - SDK is constructed with `authToken` (sends `Authorization: Bearer`)
// rather than `apiKey` (`x-api-key`), avoiding dual-header conflicts.
// - `x-api-key` is *also* added via defaultHeaders so standards-compliant
// Anthropic-compatible servers (OpenCode-Go, Claude proxies — #4323)
// that only read the canonical `x-api-key` header authenticate too.
const { AnthropicContentGenerator } = await importGenerator();
void new AnthropicContentGenerator(
{
Expand All @@ -129,10 +131,91 @@ describe('AnthropicContentGenerator', () => {
expect(headers['User-Agent']).toContain('claude-cli/1.2.3');
expect(headers['User-Agent']).toContain('(external, cli)');
expect(headers['x-app']).toBe('cli');
expect(headers['x-api-key']).toBe('test-key');
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The code comment in the source explicitly states "customHeaders can't override it" but no test verifies this invariant. Consider adding a test that constructs with customHeaders: { 'x-api-key': 'user-override' } on a proxy baseURL and asserts headers['x-api-key'] equals the canonical key — this guards against a future refactor accidentally moving the x-api-key assignment before the customHeaders merge.

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a24bc7e — added customHeaders cannot override x-api-key on the proxy branch (post-buildHeaders ordering invariant). Constructs with customHeaders: { 'x-api-key': 'user-override' } and apiKey: 'canonical-key', asserts the canonical key wins. A refactor that moves the injection above the customHeaders merge inside buildHeaders would now flip this test. Good catch — the comment promised the invariant but nothing pinned it.

expect(anthropicState.constructorOptions?.['authToken']).toBe('test-key');
expect(anthropicState.constructorOptions?.['apiKey']).toBeNull();
});

it('does NOT add x-api-key for Anthropic-native baseURLs (SDK apiKey path already supplies it)', async () => {
// On the native branch the SDK is constructed with `apiKey` and emits
// `x-api-key` itself. Adding the same header via defaultHeaders would
// duplicate it on the wire (and a stale defaultHeaders entry could
// override a future SDK rotation). Keep the native path SDK-driven.
const { AnthropicContentGenerator } = await importGenerator();
void new AnthropicContentGenerator(
{
model: 'claude-opus-4-7',
apiKey: 'test-key',
baseUrl: 'https://api.anthropic.com',
timeout: 10_000,
maxRetries: 2,
samplingParams: {},
schemaCompliance: 'auto',
},
mockConfig,
);

const headers = (anthropicState.constructorOptions?.['defaultHeaders'] ||
{}) as Record<string, string>;
expect(headers['x-api-key']).toBeUndefined();
});

it('does NOT add x-api-key when apiKey is falsy on the proxy branch', async () => {
// Guard branch on the `useProxyIdentity && apiKey` predicate: an
// empty / undefined apiKey would otherwise ship `x-api-key:` (empty)
// — a meaningless header that could confuse server-side debugging
// or trip strict input validation. Pin the guard so a future
// refactor that drops the truthy check fails this test, not prod.
const { AnthropicContentGenerator } = await importGenerator();
void new AnthropicContentGenerator(
{
model: 'claude-test',
apiKey: '',
baseUrl: 'https://example.invalid',
timeout: 10_000,
maxRetries: 2,
samplingParams: {},
schemaCompliance: 'auto',
},
mockConfig,
);

const headers = (anthropicState.constructorOptions?.['defaultHeaders'] ||
{}) as Record<string, string>;
expect(headers['x-api-key']).toBeUndefined();
});

it('customHeaders cannot override x-api-key on the proxy branch (post-buildHeaders ordering invariant)', async () => {
// The injection lives AFTER `buildHeaders` (which merges customHeaders
// into defaultHeaders), so a user-supplied
// `customHeaders: { 'x-api-key': … }` is overwritten by the canonical
// value. This is a security-relevant invariant: a refactor that
// accidentally moved the injection above the customHeaders merge
// would let user config swap the auth header for an arbitrary value
// — defeating the dual-auth contract. Pin the ordering here so any
// such regression flips this test before review.
const { AnthropicContentGenerator } = await importGenerator();
void new AnthropicContentGenerator(
{
model: 'claude-test',
apiKey: 'canonical-key',
baseUrl: 'https://example.invalid',
timeout: 10_000,
maxRetries: 2,
samplingParams: {},
schemaCompliance: 'auto',
customHeaders: {
'x-api-key': 'user-override',
},
},
mockConfig,
);

const headers = (anthropicState.constructorOptions?.['defaultHeaders'] ||
{}) as Record<string, string>;
expect(headers['x-api-key']).toBe('canonical-key');
});

it('uses QwenCode identity + apiKey auth when baseURL is api.anthropic.com', async () => {
// Anthropic-native baseURL: keep the SDK-default `x-api-key` auth and
// a truthful `QwenCode` User-Agent (no `x-app` header) so usage isn't
Expand Down Expand Up @@ -230,6 +313,7 @@ describe('AnthropicContentGenerator', () => {
{}) as Record<string, string>;
expect(headers['User-Agent']).toContain('claude-cli/1.2.3');
expect(headers['x-app']).toBe('cli');
expect(headers['x-api-key']).toBe('test-key');
expect(anthropicState.constructorOptions?.['authToken']).toBe('test-key');
expect(anthropicState.constructorOptions?.['apiKey']).toBeNull();
});
Expand Down Expand Up @@ -260,6 +344,7 @@ describe('AnthropicContentGenerator', () => {
{}) as Record<string, string>;
expect(headers['User-Agent']).toContain('claude-cli/1.2.3');
expect(headers['x-app']).toBe('cli');
expect(headers['x-api-key']).toBe('test-key');
expect(anthropicState.constructorOptions?.['authToken']).toBe('test-key');
expect(anthropicState.constructorOptions?.['apiKey']).toBeNull();
});
Expand Down Expand Up @@ -315,6 +400,7 @@ describe('AnthropicContentGenerator', () => {
{}) as Record<string, string>;
expect(headers['User-Agent']).toContain('claude-cli/1.2.3');
expect(headers['x-app']).toBe('cli');
expect(headers['x-api-key']).toBe('test-key');
expect(anthropicState.constructorOptions?.['authToken']).toBe('test-key');
expect(anthropicState.constructorOptions?.['apiKey']).toBeNull();
});
Expand Down Expand Up @@ -426,6 +512,7 @@ describe('AnthropicContentGenerator', () => {
{}) as Record<string, string>;
expect(headers['User-Agent']).toContain('claude-cli/1.2.3');
expect(headers['x-app']).toBe('cli');
expect(headers['x-api-key']).toBe('idealab-token');
expect(anthropicState.constructorOptions?.['authToken']).toBe(
'idealab-token',
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,22 @@ export class AnthropicContentGenerator implements ContentGenerator {
// half of the bundle without the other.
const useProxyIdentity = !isAnthropicNativeBaseUrl(contentGeneratorConfig);
const defaultHeaders = this.buildHeaders(useProxyIdentity);
// On the proxy branch the SDK is constructed with `authToken` so it
// emits `Authorization: Bearer <key>` natively, but some
// Anthropic-compatible servers (OpenCode-Go, Claude proxy products —
// see #4323) authenticate only on the canonical `x-api-key` header.
// Ship both shapes side-by-side so either family accepts us. We add
// `x-api-key` here (post-buildHeaders) so customHeaders can't override
// it and the SDK-level env back-fill suppression (apiKey: null on the
// SDK side, suppressing the SDK's own ANTHROPIC_API_KEY destructuring
// default) is preserved. `contentGeneratorConfig.apiKey` itself may
// have been env-resolved upstream by `resolveCredentialField`, but
// that's the same value already shipped as `Authorization: Bearer`
// via `authToken` on this very request — adding it as `x-api-key`
// doesn't widen the #4020 leak surface.
if (useProxyIdentity && contentGeneratorConfig.apiKey) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No test covers the falsy-apiKey branch on the proxy path. All proxy-branch tests supply a truthy apiKey. Consider adding a test that verifies x-api-key is NOT injected when apiKey is undefined or '' on a proxy baseURL — a future refactor could break the truthy guard without any test catching it.

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a24bc7e — added does NOT add x-api-key when apiKey is falsy on the proxy branch. Constructs with apiKey: '' on a proxy baseURL, asserts defaultHeaders['x-api-key'] is undefined. A future refactor that loosens apiKey?: string to a required field (re-enabling the empty-string ship) would now flip this test.

defaultHeaders['x-api-key'] = contentGeneratorConfig.apiKey;
}
const baseURL = contentGeneratorConfig.baseUrl;
// Configure fetch options for proxy support and timeout handling.
// With proxy, dispatcher timeouts are disabled so SDK timeout controls the
Expand Down
Loading