From 2e1772aa2883d04cef7a1a7f802308bcd072ac01 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Mon, 23 Mar 2026 15:34:30 +0100 Subject: [PATCH 1/2] fix(ai): inject Claude Code identity for Anthropic OAuth model access 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) --- apps/desktop/src/main/ai/providers/factory.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/apps/desktop/src/main/ai/providers/factory.ts b/apps/desktop/src/main/ai/providers/factory.ts index f4fc6e9ee4..90367a4f53 100644 --- a/apps/desktop/src/main/ai/providers/factory.ts +++ b/apps/desktop/src/main/ai/providers/factory.ts @@ -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 => { + 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; + + 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) }; + } + } 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({ From a81f600c5faa8f1887854194a2c9b89634f9e4af Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Mon, 23 Mar 2026 20:47:53 +0100 Subject: [PATCH 2/2] fix(profiles): prevent duplicate profile creation and deduplicate on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/main/claude-profile-manager.ts | 92 +++++++++++++++++-- 1 file changed, 84 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/claude-profile-manager.ts b/apps/desktop/src/main/claude-profile-manager.ts index 95f813e73a..1adeba9730 100644 --- a/apps/desktop/src/main/claude-profile-manager.ts +++ b/apps/desktop/src/main/claude-profile-manager.ts @@ -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(); // 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); + } + } + + 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); + } + + console.warn(`[ClaudeProfileManager] Deduplicated profiles: removed ${duplicateIndices.length} duplicates, ${this.data.profiles.length} remaining`); + this.save(); + } + /** * Populate missing subscription metadata (subscriptionType, rateLimitTier) for existing profiles. * @@ -354,7 +399,12 @@ 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 @@ -362,16 +412,42 @@ export class ClaudeProfileManager { 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; }