fix(ai): inject Claude Code identity for Anthropic OAuth model access#1981
fix(ai): inject Claude Code identity for Anthropic OAuth model access#1981
Conversation
Anthropic's API requires the Claude Code identity system prompt as a separate first system block for OAuth tokens to access Opus/Sonnet models. Without it, consumer OAuth tokens are restricted to Haiku only (400 error). Adds a fetch interceptor for Anthropic OAuth providers that prepends the identity block to all API requests. The interceptor is idempotent and only activates for OAuth tokens (sk-ant-oa* prefix). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdded an Anthropic OAuth fetch interceptor that enforces an exact CLAUDE_CODE_IDENTITY system block in outgoing messages, and implemented profile deduplication in the Claude profile manager that removes duplicate profiles by Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client
participant Interceptor as OAuth Fetch Interceptor
participant Anthropic as Anthropic API
Client->>Interceptor: POST /v1/... with JSON body (messages)
activate Interceptor
Interceptor->>Interceptor: Attempt JSON parse
alt parse fails
Interceptor->>Anthropic: Forward original request
else parse succeeds
alt no system array
Interceptor->>Interceptor: Add system = [{type:'text', text: CLAUDE_CODE_IDENTITY}]
else system exists but first entry != CLAUDE_CODE_IDENTITY
Interceptor->>Interceptor: Prepend CLAUDE_CODE_IDENTITY block to system
else first system already CLAUDE_CODE_IDENTITY
Interceptor->>Interceptor: Leave messages unchanged
end
Interceptor->>Anthropic: Send modified request
end
deactivate Interceptor
Anthropic-->>Client: Response
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request resolves a critical issue preventing Anthropic OAuth token users from accessing advanced models like Opus and Sonnet. Anthropic's API now strictly requires a specific 'Claude Code' identity system prompt as the very first system block for OAuth requests. This change introduces an intelligent fetch interceptor that automatically injects this necessary prompt into outgoing Anthropic API requests when an OAuth token is detected, ensuring seamless access to the full range of models without manual intervention from the user. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request adds a fetch interceptor to inject the required Claude Code identity system prompt for Anthropic OAuth requests, which is a good solution to the problem described. The implementation is mostly correct, but I've found a potential bug where it doesn't handle the case where the system prompt is a string, which is valid according to the Anthropic API. I've suggested a refactoring to make the logic more robust and handle this case correctly.
| 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 (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) }; | ||
| } |
There was a problem hiding this comment.
The current implementation doesn't handle the case where body.system is a string, which is a valid type for the Anthropic API. If body.system is a string, the identity prompt will not be injected, and the API call may fail for OAuth users.
The logic can be simplified and made more robust by first normalizing the system prompt (whether it's a string or an array) into an array of blocks, and then performing the identity check and injection. This ensures all valid formats for the system prompt are handled correctly.
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) };
}
Coverage Report for apps/desktop
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/desktop/src/main/ai/providers/factory.ts`:
- Around line 67-84: The interceptor currently only handles body.system when
it's an array or undefined, so string system prompts are ignored; update the
logic around body.system handling to detect when body.system is a string and
convert it into an array that prepends the CLAUDE_CODE_IDENTITY block (e.g.,
replace string with [{ type: 'text', text: CLAUDE_CODE_IDENTITY }, { type:
'text', text: body.system }]) and then set init = { ...init, body:
JSON.stringify(body) } just like the other branches; ensure you reference and
update the same variables (body.system, CLAUDE_CODE_IDENTITY, init) and preserve
existing messages if present.
- Line 65: The current one-line parse of init.body can throw for
non-string/ArrayBuffer types; update the body parsing where const body =
JSON.parse(...) is assigned to first detect init.body's runtime type: if it's a
string parse directly; if it's an ArrayBuffer decode with TextDecoder; if it's a
Blob call blob.text(); if it's URLSearchParams call toString(); if it's FormData
convert via Object.fromEntries(formData) then JSON.stringify that result (or
extract entries into an object) and then JSON.parse; if it's a ReadableStream or
other unsupported type, read it to a string (e.g., via Response/stream handling)
or fall back to safe empty/object handling and log a warning. Ensure all
branches produce a string or object suitable for JSON.parse and keep references
to init.body and the const body assignment location so the fix targets that line
in factory.ts.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 41d93d8d-7824-4e88-9068-f053a2b5aab5
📒 Files selected for processing (1)
apps/desktop/src/main/ai/providers/factory.ts
| 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)); |
There was a problem hiding this comment.
Body type handling may throw on non-string/ArrayBuffer types.
init.body can be Blob, FormData, URLSearchParams, ReadableStream, or other types. Casting directly to ArrayBuffer will fail for these. While the Vercel AI SDK likely only sends strings, a defensive check avoids surprises.
🛡️ Proposed fix for safer body parsing
- const body = JSON.parse(typeof init.body === 'string' ? init.body : new TextDecoder().decode(init.body as ArrayBuffer));
+ let bodyStr: string;
+ if (typeof init.body === 'string') {
+ bodyStr = init.body;
+ } else if (init.body instanceof ArrayBuffer || ArrayBuffer.isView(init.body)) {
+ bodyStr = new TextDecoder().decode(init.body);
+ } else {
+ // Unsupported body type — pass through unchanged
+ return globalThis.fetch(input, init);
+ }
+ const body = JSON.parse(bodyStr);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/desktop/src/main/ai/providers/factory.ts` at line 65, The current
one-line parse of init.body can throw for non-string/ArrayBuffer types; update
the body parsing where const body = JSON.parse(...) is assigned to first detect
init.body's runtime type: if it's a string parse directly; if it's an
ArrayBuffer decode with TextDecoder; if it's a Blob call blob.text(); if it's
URLSearchParams call toString(); if it's FormData convert via
Object.fromEntries(formData) then JSON.stringify that result (or extract entries
into an object) and then JSON.parse; if it's a ReadableStream or other
unsupported type, read it to a string (e.g., via Response/stream handling) or
fall back to safe empty/object handling and log a warning. Ensure all branches
produce a string or object suitable for JSON.parse and keep references to
init.body and the const body assignment location so the fix targets that line in
factory.ts.
| 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 (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) }; | ||
| } |
There was a problem hiding this comment.
Missing handling for string system prompts.
The Anthropic API accepts system as either a string or an array of content blocks. This interceptor only handles the array case (line 67) and undefined case (line 80). If body.system is a string (e.g., "You are a helpful assistant"), neither branch triggers and the identity block won't be prepended—OAuth users would silently get 400 errors or be restricted to Haiku.
🐛 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
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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 (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) }; | |
| } | |
| 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) }; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/desktop/src/main/ai/providers/factory.ts` around lines 67 - 84, The
interceptor currently only handles body.system when it's an array or undefined,
so string system prompts are ignored; update the logic around body.system
handling to detect when body.system is a string and convert it into an array
that prepends the CLAUDE_CODE_IDENTITY block (e.g., replace string with [{ type:
'text', text: CLAUDE_CODE_IDENTITY }, { type: 'text', text: body.system }]) and
then set init = { ...init, body: JSON.stringify(body) } just like the other
branches; ensure you reference and update the same variables (body.system,
CLAUDE_CODE_IDENTITY, init) and preserve existing messages if present.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| // No system prompt at all — add identity block | ||
| body.system = [{ type: 'text', text: CLAUDE_CODE_IDENTITY }]; | ||
| init = { ...init, body: JSON.stringify(body) }; | ||
| } |
There was a problem hiding this comment.
String system prompt silently skips identity injection
Medium Severity
The Anthropic API accepts system as either a string or an array of content blocks. The conditional only handles body.system when it's an array (Array.isArray) or undefined, but when body.system is a string, it's truthy (so the first branch is skipped) and not === undefined (so the second branch is also skipped). The identity block is silently not injected, meaning OAuth users would still get 400 errors for any request where the SDK sends system as a plain string.
…startup saveProfile() matched by ID only, so when the UI sent a profile with a new generated ID but the same configDir, it was added as a duplicate. This caused the profile store to grow unboundedly (5 accounts → 17 entries). Two fixes: 1. saveProfile() now checks configDir for existing entries before adding. If a profile with the same configDir exists, it updates that entry instead of creating a duplicate. 2. deduplicateProfiles() runs on startup to clean any existing duplicates, keeping the first (oldest/default) entry per configDir. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| 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; | ||
|
|
There was a problem hiding this comment.
Bug: The identity injection logic does not handle cases where body.system is a string, causing the injection to be silently skipped.
Severity: MEDIUM
Suggested Fix
Add a new conditional branch or modify the existing one to handle the case where body.system is a string. Convert the string to an array containing that string, and then prepend the identity block as planned.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location: apps/desktop/src/main/ai/providers/factory.ts#L67-L72
Potential issue: The fetch interceptor logic for identity injection only handles cases
where `body.system` is an array or is `undefined`. If the request body contains a
`system` property that is a string, neither conditional branch is executed. This results
in the identity block not being prepended to the system prompt, silently bypassing the
intended functionality of the PR. While the Vercel AI SDK may typically send an array,
the code should defensively handle the string case to ensure robustness.
Did we get this right? 👍 / 👎 to inform future reviews.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/desktop/src/main/claude-profile-manager.ts`:
- Around line 186-189: The current duplicate-collapse behavior (the reverse-loop
that splices this.data.profiles at duplicateIndices and the saveProfile(...)
path) discards persisted fields like usage, rateLimitEvents, oauthToken,
tokenCreatedAt, subscription metadata, and lastUsedAt; replace the blind
deletion/overwrite with a deterministic merge routine (e.g., add a private
mergeDuplicateProfiles(existing: ClaudeProfile, incoming: ClaudeProfile)) and
call it from both the initialization duplicate-handling loop and saveProfile;
the merge should prefer incoming values when present but fall back to existing
for oauthToken/tokenCreatedAt, choose the most-recent usage by lastUpdated,
concatenate rateLimitEvents arrays, and preserve
subscriptionType/rateLimitTier/lastUsedAt when incoming is undefined so no
persisted state is lost.
- Around line 159-173: The deduplication uses raw configDir strings so paths
like ~/foo vs /home/user/foo, or differing separators/case on Windows, bypass
dedupe; in deduplicateProfiles() canonicalize each profile.configDir before
using it as the Map key (expand leading ~ to homedir, normalize separators with
path.normalize, resolve relative segments with path.resolve and, if available,
follow symlinks with fs.realpathSync), and on Windows compare case-insensitively
(toLowerCase the canonical path); apply the same canonicalization in
saveProfile() (the referenced block around lines 425-429) so both startup
cleanup and saveProfile() use identical normalized keys for deduplication.
- Around line 178-193: The dedupe logic only remaps activeProfileId but must
remap all references to removed profile IDs; create an idRemap (oldId ->
retainedId) when you detect duplicates (using the existing seen map and
duplicateIndices), then iterate and replace entries in
this.data.migratedProfileIds and this.data.accountPriorityOrder (and any other
arrays of profile IDs) by mapping removed IDs to the retained ID; update the
arrays in-place (or filter/map to new arrays) before splicing out duplicates and
calling this.save(); reference ClaudeProfileManager, the seen map,
duplicateIndices, removedIds, migratedProfileIds and accountPriorityOrder when
making the changes.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 912b0b03-7b81-4442-9323-532f546e0f9f
📒 Files selected for processing (1)
apps/desktop/src/main/claude-profile-manager.ts
| 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); | ||
| } |
There was a problem hiding this comment.
Canonicalize configDir before using it as the dedupe key.
These comparisons use raw strings. Mixed ~/expanded paths and Windows separator or casing differences can still resolve to the same directory, so the same profile can slip past both startup cleanup and saveProfile() deduplication.
Also applies to: 425-429
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/desktop/src/main/claude-profile-manager.ts` around lines 159 - 173, The
deduplication uses raw configDir strings so paths like ~/foo vs /home/user/foo,
or differing separators/case on Windows, bypass dedupe; in deduplicateProfiles()
canonicalize each profile.configDir before using it as the Map key (expand
leading ~ to homedir, normalize separators with path.normalize, resolve relative
segments with path.resolve and, if available, follow symlinks with
fs.realpathSync), and on Windows compare case-insensitively (toLowerCase the
canonical path); apply the same canonicalization in saveProfile() (the
referenced block around lines 425-429) so both startup cleanup and saveProfile()
use identical normalized keys for deduplication.
| // 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); | ||
| } | ||
|
|
||
| console.warn(`[ClaudeProfileManager] Deduplicated profiles: removed ${duplicateIndices.length} duplicates, ${this.data.profiles.length} remaining`); | ||
| this.save(); | ||
| } |
There was a problem hiding this comment.
Remap all profile-ID indexes, not just activeProfileId.
After a duplicate is removed, migratedProfileIds and accountPriorityOrder can still point at the deleted ID. apps/desktop/src/main/index.ts:569-593 compares remainingMigratedIds with activeProfile.id, so an active migrated profile will stop triggering re-auth once its duplicate ID is collapsed.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/desktop/src/main/claude-profile-manager.ts` around lines 178 - 193, The
dedupe logic only remaps activeProfileId but must remap all references to
removed profile IDs; create an idRemap (oldId -> retainedId) when you detect
duplicates (using the existing seen map and duplicateIndices), then iterate and
replace entries in this.data.migratedProfileIds and
this.data.accountPriorityOrder (and any other arrays of profile IDs) by mapping
removed IDs to the retained ID; update the arrays in-place (or filter/map to new
arrays) before splicing out duplicates and calling this.save(); reference
ClaudeProfileManager, the seen map, duplicateIndices, removedIds,
migratedProfileIds and accountPriorityOrder when making the changes.
| // Remove duplicates (iterate in reverse to preserve indices) | ||
| for (let i = duplicateIndices.length - 1; i >= 0; i--) { | ||
| this.data.profiles.splice(duplicateIndices[i], 1); | ||
| } |
There was a problem hiding this comment.
Both duplicate-collapse paths can discard persisted profile state.
Initialization deletes the later row outright, and saveProfile() overwrites the survivor with the incoming object while only preserving id and isDefault. That can drop usage, rateLimitEvents, oauthToken, subscription metadata, or lastUsedAt, which changes auth and availability scoring after restart.
💡 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
Verify each finding against the current code and only fix it if needed.
In `@apps/desktop/src/main/claude-profile-manager.ts` around lines 186 - 189, The
current duplicate-collapse behavior (the reverse-loop that splices
this.data.profiles at duplicateIndices and the saveProfile(...) path) discards
persisted fields like usage, rateLimitEvents, oauthToken, tokenCreatedAt,
subscription metadata, and lastUsedAt; replace the blind deletion/overwrite with
a deterministic merge routine (e.g., add a private
mergeDuplicateProfiles(existing: ClaudeProfile, incoming: ClaudeProfile)) and
call it from both the initialization duplicate-handling loop and saveProfile;
the merge should prefer incoming values when present but fall back to existing
for oauthToken/tokenCreatedAt, choose the most-recent usage by lastUpdated,
concatenate rateLimitEvents arrays, and preserve
subscriptionType/rateLimitTier/lastUsedAt when incoming is undefined so no
persisted state is lost.


Summary
Anthropic's API now requires a Claude Code identity system prompt as the first system block for OAuth tokens to access Opus/Sonnet models. Without it, consumer OAuth tokens (from Claude Max subscriptions) are restricted to Haiku only, returning a generic 400
invalid_request_error.Problem
Since ~January 2026, Anthropic enforces server-side validation on OAuth token requests. The API checks that the first system block is an exact match of
"You are Claude Code, Anthropic's official CLI for Claude."as a separate{type: "text"}entry. Combining it with other text in a single block is rejected.This caused all autonomous tasks (spec creation, planning, coding, QA) to fail with 400 errors when using OAuth accounts.
Solution
Adds a fetch interceptor (
createOAuthSystemPromptFetch()) to the Anthropic provider factory that:sk-ant-oa*prefix) — API key users are unaffectedChanges
apps/desktop/src/main/ai/providers/factory.ts— AddedcreateOAuthSystemPromptFetch()and wired it into the OAuth provider pathTesting
Summary by CodeRabbit