-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
fix(ai): inject Claude Code identity for Anthropic OAuth model access #1981
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -38,6 +38,59 @@ function isOAuthToken(token: string | undefined): boolean { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return token.startsWith('sk-ant-oa') || token.startsWith('sk-ant-ort'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ============================================================================= | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Anthropic OAuth System Prompt Injection | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ============================================================================= | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * The Claude Code identity string that Anthropic's API requires as the first | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * system block for OAuth tokens to access Opus/Sonnet models. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Must be a SEPARATE system block (not combined with other text). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Creates a fetch interceptor that injects the Claude Code identity as a | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * separate first system block in Anthropic API requests. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Anthropic's API validates that OAuth tokens include this identity as an | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * exact-match first system block to access Opus/Sonnet models. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * The identity must be a separate `{type: "text", text: "..."}` entry — | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * combining it with other text in a single block is rejected. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function createOAuthSystemPromptFetch(): typeof globalThis.fetch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (init?.method?.toUpperCase() === 'POST' && init.body) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const body = JSON.parse(typeof init.body === 'string' ? init.body : new TextDecoder().decode(init.body as ArrayBuffer)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (body.system && Array.isArray(body.system)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Check if identity block already present | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const hasIdentity = body.system.length > 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| && body.system[0]?.type === 'text' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| && body.system[0]?.text === CLAUDE_CODE_IDENTITY; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+67
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The identity injection logic does not handle cases where Suggested FixAdd a new conditional branch or modify the existing one to handle the case where Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!hasIdentity) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| body.system = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { type: 'text', text: CLAUDE_CODE_IDENTITY }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ...body.system, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| init = { ...init, body: JSON.stringify(body) }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else if (body.system === undefined && body.messages) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // No system prompt at all — add identity block | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| body.system = [{ type: 'text', text: CLAUDE_CODE_IDENTITY }]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| init = { ...init, body: JSON.stringify(body) }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+67
to
+84
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation doesn't handle the case where The logic can be simplified and made more robust by first normalizing the if (body.system) {
const systemBlocks = Array.isArray(body.system)
? body.system
: [{ type: 'text', text: body.system }];
const hasIdentity = systemBlocks.length > 0 &&
systemBlocks[0]?.type === 'text' &&
systemBlocks[0]?.text === CLAUDE_CODE_IDENTITY;
if (!hasIdentity) {
body.system = [
{ type: 'text', text: CLAUDE_CODE_IDENTITY },
...systemBlocks,
];
init = { ...init, body: JSON.stringify(body) };
}
} else if (body.messages) {
// No system prompt at all — add identity block
body.system = [{ type: 'text', text: CLAUDE_CODE_IDENTITY }];
init = { ...init, body: JSON.stringify(body) };
}
Comment on lines
+67
to
+84
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing handling for string system prompts. The Anthropic API accepts 🐛 Proposed fix to handle string system prompts if (body.system && Array.isArray(body.system)) {
// Check if identity block already present
const hasIdentity = body.system.length > 0
&& body.system[0]?.type === 'text'
&& body.system[0]?.text === CLAUDE_CODE_IDENTITY;
if (!hasIdentity) {
body.system = [
{ type: 'text', text: CLAUDE_CODE_IDENTITY },
...body.system,
];
init = { ...init, body: JSON.stringify(body) };
}
+ } else if (typeof body.system === 'string') {
+ // Convert string system to array with identity prepended
+ body.system = [
+ { type: 'text', text: CLAUDE_CODE_IDENTITY },
+ { type: 'text', text: body.system },
+ ];
+ init = { ...init, body: JSON.stringify(body) };
} else if (body.system === undefined && body.messages) {
// No system prompt at all — add identity block
body.system = [{ type: 'text', text: CLAUDE_CODE_IDENTITY }];
init = { ...init, body: JSON.stringify(body) };
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. String system prompt silently skips identity injectionMedium Severity The Anthropic API accepts |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // JSON parse failed — pass through unchanged | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return globalThis.fetch(input, init); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ============================================================================= | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Provider Instance Creators | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ============================================================================= | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -53,6 +106,7 @@ function createProviderInstance(config: ProviderConfig) { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case SupportedProvider.Anthropic: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // OAuth tokens use authToken (Authorization: Bearer) + required beta header | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // API keys use apiKey (x-api-key header) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Custom fetch injects Claude Code identity system block for model access | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isOAuthToken(apiKey)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return createAnthropic({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| authToken: apiKey, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -61,6 +115,7 @@ function createProviderInstance(config: ProviderConfig) { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ...headers, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fetch: createOAuthSystemPromptFetch(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return createAnthropic({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -105,6 +105,10 @@ export class ClaudeProfileManager { | |
| // This repairs emails that were truncated due to ANSI escape codes in terminal output | ||
| this.migrateCorruptedEmails(); | ||
|
|
||
| // Remove duplicate profiles sharing the same configDir | ||
| // Keeps the first (oldest/default) entry per configDir | ||
| this.deduplicateProfiles(); | ||
|
|
||
| // Populate missing subscription metadata for existing profiles | ||
| // This reads subscriptionType and rateLimitTier from Keychain credentials | ||
| this.populateSubscriptionMetadata(); | ||
|
|
@@ -147,6 +151,47 @@ export class ClaudeProfileManager { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Remove duplicate profiles that share the same configDir. | ||
| * Keeps the first entry per configDir (which is typically the oldest or the default). | ||
| * Remaps activeProfileId if the active profile was a duplicate that got removed. | ||
| */ | ||
| private deduplicateProfiles(): void { | ||
| const seen = new Map<string, number>(); // configDir -> index of first occurrence | ||
| const duplicateIndices: number[] = []; | ||
| const removedIds: string[] = []; | ||
|
|
||
| for (let i = 0; i < this.data.profiles.length; i++) { | ||
| const configDir = this.data.profiles[i].configDir; | ||
| if (!configDir) continue; | ||
|
|
||
| if (seen.has(configDir)) { | ||
| duplicateIndices.push(i); | ||
| removedIds.push(this.data.profiles[i].id); | ||
| } else { | ||
| seen.set(configDir, i); | ||
| } | ||
|
Comment on lines
+159
to
+173
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Canonicalize These comparisons use raw strings. Mixed Also applies to: 425-429 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| if (duplicateIndices.length === 0) return; | ||
|
|
||
| // Remap activeProfileId if it points to a duplicate being removed | ||
| if (this.data.activeProfileId && removedIds.includes(this.data.activeProfileId)) { | ||
| const activeConfigDir = this.data.profiles.find(p => p.id === this.data.activeProfileId)?.configDir; | ||
| if (activeConfigDir && seen.has(activeConfigDir)) { | ||
| this.data.activeProfileId = this.data.profiles[seen.get(activeConfigDir)!].id; | ||
| } | ||
| } | ||
|
|
||
| // Remove duplicates (iterate in reverse to preserve indices) | ||
| for (let i = duplicateIndices.length - 1; i >= 0; i--) { | ||
| this.data.profiles.splice(duplicateIndices[i], 1); | ||
| } | ||
|
Comment on lines
+186
to
+189
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both duplicate-collapse paths can discard persisted profile state. Initialization deletes the later row outright, and 💡 Suggested direction- for (let i = duplicateIndices.length - 1; i >= 0; i--) {
- this.data.profiles.splice(duplicateIndices[i], 1);
- }
+ for (let i = duplicateIndices.length - 1; i >= 0; i--) {
+ const duplicateIndex = duplicateIndices[i];
+ const duplicate = this.data.profiles[duplicateIndex];
+ const keptIndex = seen.get(duplicate.configDir!)!;
+ this.data.profiles[keptIndex] = this.mergeDuplicateProfiles(
+ this.data.profiles[keptIndex],
+ duplicate
+ );
+ this.data.profiles.splice(duplicateIndex, 1);
+ }
@@
- profile.id = existingId;
- profile.isDefault = existingIsDefault || profile.isDefault;
- this.data.profiles[indexByConfigDir] = profile;
+ this.data.profiles[indexByConfigDir] = this.mergeDuplicateProfiles(
+ this.data.profiles[indexByConfigDir],
+ profile
+ );
+ this.data.profiles[indexByConfigDir].id = existingId;
+ this.data.profiles[indexByConfigDir].isDefault = existingIsDefault || profile.isDefault;
this.save();
- return profile;
+ return this.data.profiles[indexByConfigDir];private mergeDuplicateProfiles(existing: ClaudeProfile, incoming: ClaudeProfile): ClaudeProfile {
return {
...existing,
...incoming,
oauthToken: incoming.oauthToken ?? existing.oauthToken,
tokenCreatedAt: incoming.tokenCreatedAt ?? existing.tokenCreatedAt,
usage:
!existing.usage ||
(incoming.usage?.lastUpdated && incoming.usage.lastUpdated > existing.usage.lastUpdated)
? incoming.usage ?? existing.usage
: existing.usage,
rateLimitEvents: [
...(existing.rateLimitEvents ?? []),
...(incoming.rateLimitEvents ?? []),
],
subscriptionType: incoming.subscriptionType ?? existing.subscriptionType,
rateLimitTier: incoming.rateLimitTier ?? existing.rateLimitTier,
lastUsedAt: incoming.lastUsedAt ?? existing.lastUsedAt,
};
}Also applies to: 431-444 🤖 Prompt for AI Agents |
||
|
|
||
| console.warn(`[ClaudeProfileManager] Deduplicated profiles: removed ${duplicateIndices.length} duplicates, ${this.data.profiles.length} remaining`); | ||
| this.save(); | ||
| } | ||
|
Comment on lines
+178
to
+193
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remap all profile-ID indexes, not just After a duplicate is removed, 🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * Populate missing subscription metadata (subscriptionType, rateLimitTier) for existing profiles. | ||
| * | ||
|
|
@@ -354,24 +399,55 @@ export class ClaudeProfileManager { | |
| } | ||
|
|
||
| /** | ||
| * Save or update a profile | ||
| * Save or update a profile. | ||
| * | ||
| * Deduplication: If a profile with the same configDir already exists (but | ||
| * a different ID), we update the existing entry instead of creating a | ||
| * duplicate. This prevents the profile store from growing unboundedly | ||
| * when the UI generates new IDs for the same underlying account. | ||
| */ | ||
| saveProfile(profile: ClaudeProfile): ClaudeProfile { | ||
| // Expand ~ in configDir path | ||
| if (profile.configDir) { | ||
| profile.configDir = expandHomePath(profile.configDir); | ||
| } | ||
|
|
||
| const index = this.data.profiles.findIndex(p => p.id === profile.id); | ||
| // First, try exact ID match (normal update path) | ||
| const indexById = this.data.profiles.findIndex(p => p.id === profile.id); | ||
|
|
||
| if (index >= 0) { | ||
| // Update existing | ||
| this.data.profiles[index] = profile; | ||
| } else { | ||
| // Add new | ||
| this.data.profiles.push(profile); | ||
| if (indexById >= 0) { | ||
| // Update existing profile by ID | ||
| this.data.profiles[indexById] = profile; | ||
| this.save(); | ||
| return profile; | ||
| } | ||
|
|
||
| // No ID match — check for configDir duplicate before adding | ||
| if (profile.configDir) { | ||
| const indexByConfigDir = this.data.profiles.findIndex( | ||
| p => p.configDir === profile.configDir | ||
| ); | ||
|
|
||
| if (indexByConfigDir >= 0) { | ||
| // Same configDir exists with a different ID — update the existing entry | ||
| // instead of creating a duplicate. Preserve the existing ID. | ||
| const existingId = this.data.profiles[indexByConfigDir].id; | ||
| const existingIsDefault = this.data.profiles[indexByConfigDir].isDefault; | ||
| debugLog('[ClaudeProfileManager] Dedup: profile with configDir already exists, updating instead of adding', { | ||
| existingId, | ||
| incomingId: profile.id, | ||
| configDir: profile.configDir, | ||
| }); | ||
| profile.id = existingId; | ||
| profile.isDefault = existingIsDefault || profile.isDefault; | ||
| this.data.profiles[indexByConfigDir] = profile; | ||
| this.save(); | ||
| return profile; | ||
| } | ||
| } | ||
|
|
||
| // Genuinely new profile — add it | ||
| this.data.profiles.push(profile); | ||
| this.save(); | ||
| return profile; | ||
| } | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Body type handling may throw on non-string/ArrayBuffer types.
init.bodycan beBlob,FormData,URLSearchParams,ReadableStream, or other types. Casting directly toArrayBufferwill fail for these. While the Vercel AI SDK likely only sends strings, a defensive check avoids surprises.🛡️ Proposed fix for safer body parsing
🤖 Prompt for AI Agents