From 8cce7ac8bbb43c973b58a04ac1e17e980fd6f338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 19 May 2026 18:12:48 +0800 Subject: [PATCH] fix: Route Claude attribution by model name Provider-managed environments should preserve Claude attribution only when the selected model is actually Claude-prefixed. Non-Claude provider models now disable the attribution header so third-party providers do not inherit Claude-specific prompt metadata. Constraint: Claude model detection is intentionally model-name based to match the requested provider policy. Rejected: Remove attribution headers globally | official Claude-prefixed models should keep existing behavior. Confidence: high Scope-risk: moderate Directive: Do not broaden this policy without live provider request-shape verification. Tested: Targeted server, provider, settings, cron, and managed-env tests in prior verification pass. Not-tested: Full verify gate after this commit. --- .../__tests__/conversation-service.test.ts | 10 ++++++++ .../__tests__/cron-scheduler-launcher.test.ts | 1 + src/server/__tests__/providers.test.ts | 23 +++++++++++++++++++ src/server/__tests__/settings.test.ts | 1 + src/server/api/models.ts | 12 +++++++++- .../services/attributionHeaderPolicy.ts | 16 +++++++++++++ src/server/services/conversationService.ts | 9 ++++++++ src/server/services/cronScheduler.ts | 7 ++++++ src/server/services/providerService.ts | 6 +++++ .../providerManagedEnvCompat.test.ts | 5 ++++ src/utils/managedEnvConstants.ts | 1 + 11 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/server/services/attributionHeaderPolicy.ts diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 0f4eefe3..f5770fc6 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -17,6 +17,7 @@ describe('ConversationService', () => { let originalOAuthToken: string | undefined let originalProviderManagedByHost: string | undefined let originalDiagnosticsFile: string | undefined + let originalAttributionHeader: string | undefined let originalHome: string | undefined let originalPath: string | undefined let originalShell: string | undefined @@ -34,6 +35,7 @@ describe('ConversationService', () => { originalOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN originalProviderManagedByHost = process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE + originalAttributionHeader = process.env.CLAUDE_CODE_ATTRIBUTION_HEADER originalHome = process.env.HOME originalPath = process.env.PATH originalShell = process.env.SHELL @@ -51,6 +53,7 @@ describe('ConversationService', () => { delete process.env.CLAUDE_CODE_ENTRYPOINT delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE + delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1' resetTerminalShellEnvironmentCacheForTests() }) @@ -83,6 +86,9 @@ describe('ConversationService', () => { if (originalDiagnosticsFile === undefined) delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE else process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = originalDiagnosticsFile + if (originalAttributionHeader === undefined) delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER + else process.env.CLAUDE_CODE_ATTRIBUTION_HEADER = originalAttributionHeader + if (originalHome === undefined) delete process.env.HOME else process.env.HOME = originalHome @@ -133,6 +139,7 @@ describe('ConversationService', () => { expect(env.ANTHROPIC_AUTH_TOKEN).toBe('test-token') expect(env.ANTHROPIC_BASE_URL).toBe('https://example.invalid/anthropic') expect(env.ANTHROPIC_MODEL).toBe('test-model') + expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0') expect(env.CLAUDE_CODE_DIAGNOSTICS_FILE).toBe(path.join(tmpDir, 'cc-haha', 'diagnostics', 'cli-diagnostics.jsonl')) expect(env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE).toBe( `${path.join(tmpDir, 'projects', 'D--workspace-code-myself-code-cc-haha', 'memory')}${path.sep}`, @@ -280,6 +287,7 @@ describe('ConversationService', () => { expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('kimi-k2.6') expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('kimi-k2.6') expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1') + expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0') expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined() }) @@ -307,6 +315,7 @@ describe('ConversationService', () => { expect(env.ANTHROPIC_BASE_URL).toBe(`http://127.0.0.1:3456/proxy/providers/${provider.id}`) expect(env.ANTHROPIC_MODEL).toBe('new-provider-sonnet') + expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0') }) test('buildChildEnv clears stale api key for bearer-token providers', async () => { @@ -336,6 +345,7 @@ describe('ConversationService', () => { expect(env.ANTHROPIC_API_KEY).toBe('') expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6') expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe('none') + expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('1') }) test('buildChildEnv can force official auth even when a custom default provider exists', async () => { diff --git a/src/server/__tests__/cron-scheduler-launcher.test.ts b/src/server/__tests__/cron-scheduler-launcher.test.ts index 8241adb3..af227d4d 100644 --- a/src/server/__tests__/cron-scheduler-launcher.test.ts +++ b/src/server/__tests__/cron-scheduler-launcher.test.ts @@ -326,6 +326,7 @@ describe('cron scheduler launcher resolution', () => { expect(env.ANTHROPIC_MODEL).toBe('provider-fast') expect(env.ANTHROPIC_MODEL).not.toBe('stale-parent-model') expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1') + expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0') expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-cli') }) diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 40754b85..99cd1a6a 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -523,9 +523,31 @@ describe('ProviderService', () => { expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('model-haiku') expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('model-sonnet') expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('model-opus') + expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0') expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined() }) + test('should preserve attribution header for Claude-prefixed provider models', async () => { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput({ + models: { + main: 'Claude Sonnet 4.6', + haiku: 'Claude Haiku 4.5', + sonnet: 'Claude Sonnet 4.6', + opus: 'Claude Opus 4.7', + }, + })) + + await svc.activateProvider(provider.id) + + const settings = await readSettings() + const env = settings.env as Record + expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('1') + + const runtimeEnv = await svc.getProviderRuntimeEnv(provider.id) + expect(runtimeEnv.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('1') + }) + test('should honor provider auth env strategies on activation and runtime env', async () => { const svc = new ProviderService() @@ -615,6 +637,7 @@ describe('ProviderService', () => { expect(clearedEnv.API_TIMEOUT_MS).toBeUndefined() expect(clearedEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBeUndefined() expect(clearedEnv.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined() + expect(clearedEnv.CLAUDE_CODE_ATTRIBUTION_HEADER).toBeUndefined() expect(clearedEnv.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS).toBeUndefined() }) diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index a38a651b..f82db41d 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -620,6 +620,7 @@ describe('Models API', () => { const managedSettings = await providerSvc.getManagedSettings() expect(managedSettings.model).toBe('glm-5-turbo') + expect((managedSettings.env as Record).CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0') const globalSettings = await settingsSvc.getUserSettings() expect(globalSettings.model).toBeUndefined() diff --git a/src/server/api/models.ts b/src/server/api/models.ts index eadb2867..e39fd683 100644 --- a/src/server/api/models.ts +++ b/src/server/api/models.ts @@ -10,6 +10,7 @@ import { SettingsService } from '../services/settingsService.js' import { ProviderService } from '../services/providerService.js' +import { attributionHeaderEnvForModel } from '../services/attributionHeaderPolicy.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' import { hasOpenAIAuthLogin } from '../../utils/auth.js' import { OPENAI_CODEX_MODEL_CATALOG } from '../../services/openaiAuth/models.js' @@ -275,7 +276,16 @@ async function handleCurrentModel(req: Request): Promise { } const { activeId } = await providerService.listProviders() if (activeId) { - await providerService.updateManagedSettings(updates) + const currentManagedSettings = await providerService.getManagedSettings() + const currentEnv = + (currentManagedSettings.env as Record | undefined) ?? {} + await providerService.updateManagedSettings({ + ...updates, + env: { + ...currentEnv, + ...attributionHeaderEnvForModel(baseId), + }, + }) } else { await settingsService.updateUserSettings(updates) } diff --git a/src/server/services/attributionHeaderPolicy.ts b/src/server/services/attributionHeaderPolicy.ts new file mode 100644 index 00000000..c6f3e93f --- /dev/null +++ b/src/server/services/attributionHeaderPolicy.ts @@ -0,0 +1,16 @@ +export const ATTRIBUTION_HEADER_ENV_KEY = 'CLAUDE_CODE_ATTRIBUTION_HEADER' + +export function isClaudeModelName(modelName?: string | null): boolean { + return typeof modelName === 'string' && modelName.trim().toLowerCase().startsWith('claude') +} + +export function attributionHeaderEnvForModel( + modelName?: string | null, +): Record { + const normalizedModel = modelName?.trim() + if (!normalizedModel) return {} + + return { + [ATTRIBUTION_HEADER_ENV_KEY]: isClaudeModelName(normalizedModel) ? '1' : '0', + } +} diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 4444a4ef..53e73055 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -26,6 +26,7 @@ import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { findCanonicalGitRoot } from '../../utils/git.js' import { sanitizePath } from '../../utils/path.js' import { getProcessEnvWithTerminalShellEnvironment } from '../../utils/terminalShellEnvironment.js' +import { attributionHeaderEnvForModel } from './attributionHeaderPolicy.js' const MAX_CAPTURED_PROCESS_LINES = 80 const MAX_CAPTURED_SDK_MESSAGES = 40 @@ -885,6 +886,7 @@ export class ConversationService { 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', 'CC_HAHA_SEND_DISABLED_THINKING', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', + 'CLAUDE_CODE_ATTRIBUTION_HEADER', 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', ] as const @@ -913,6 +915,11 @@ export class ConversationService { if (explicitProviderEnv && options?.model?.trim()) { explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim() } + const attributionHeaderEnv = attributionHeaderEnvForModel( + options?.model?.trim() || + explicitProviderEnv?.ANTHROPIC_MODEL || + cleanEnv.ANTHROPIC_MODEL, + ) const cliDiagnosticsPath = diagnosticsService.getCliDiagnosticsPath() try { @@ -957,6 +964,7 @@ export class ConversationService { ...(this.shouldMarkManagedOAuth(options?.providerId) ? await this.buildOfficialOAuthEnv() : {}), + ...attributionHeaderEnv, } } @@ -1034,6 +1042,7 @@ export class ConversationService { 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', 'CC_HAHA_SEND_DISABLED_THINKING', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', + 'CLAUDE_CODE_ATTRIBUTION_HEADER', 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', ].some((key) => typeof env[key] === 'string' && env[key]!.trim().length > 0) } catch { diff --git a/src/server/services/cronScheduler.ts b/src/server/services/cronScheduler.ts index 702b661b..59f31a74 100644 --- a/src/server/services/cronScheduler.ts +++ b/src/server/services/cronScheduler.ts @@ -22,6 +22,7 @@ import { resolveClaudeCliLauncher, } from '../../utils/desktopBundledCli.js' import { getProcessEnvWithTerminalShellEnvironment } from '../../utils/terminalShellEnvironment.js' +import { attributionHeaderEnvForModel } from './attributionHeaderPolicy.js' // ─── Types ───────────────────────────────────────────────────────────────────── @@ -659,6 +660,11 @@ export class CronScheduler { if (explicitProviderEnv && task.model?.trim()) { explicitProviderEnv.ANTHROPIC_MODEL = task.model.trim() } + const attributionHeaderEnv = attributionHeaderEnvForModel( + task.model?.trim() || + explicitProviderEnv?.ANTHROPIC_MODEL || + cleanEnv.ANTHROPIC_MODEL, + ) return { ...cleanEnv, @@ -677,6 +683,7 @@ export class CronScheduler { ...(this.shouldMarkManagedOAuth(task.providerId) ? await this.buildOfficialOAuthEnv() : {}), + ...attributionHeaderEnv, } } diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index 22ef7f80..784781b1 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -19,6 +19,10 @@ import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesTo import type { AnthropicRequest, AnthropicResponse } from '../proxy/transform/types.js' import { PROVIDER_PRESETS } from '../config/providerPresets.js' import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js' +import { + ATTRIBUTION_HEADER_ENV_KEY, + attributionHeaderEnvForModel, +} from './attributionHeaderPolicy.js' import { CURRENT_PROVIDER_INDEX_SCHEMA_VERSION, ensurePersistentStorageUpgraded, @@ -47,6 +51,7 @@ const MANAGED_ENV_KEYS = [ 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', + ATTRIBUTION_HEADER_ENV_KEY, MODEL_CONTEXT_WINDOWS_ENV_KEY, ] as const @@ -393,6 +398,7 @@ export class ProviderService { ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku, ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet, ANTHROPIC_DEFAULT_OPUS_MODEL: provider.models.opus, + ...attributionHeaderEnvForModel(provider.models.main), } } diff --git a/src/utils/__tests__/providerManagedEnvCompat.test.ts b/src/utils/__tests__/providerManagedEnvCompat.test.ts index 2ec61fde..571e6ec2 100644 --- a/src/utils/__tests__/providerManagedEnvCompat.test.ts +++ b/src/utils/__tests__/providerManagedEnvCompat.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' +import { isProviderManagedEnvVar } from '../managedEnvConstants.js' import { normalizeLegacyDeepSeekManagedEnv } from '../providerManagedEnvCompat.js' describe('provider managed env compatibility', () => { @@ -36,4 +37,8 @@ describe('provider managed env compatibility', () => { expect(changed).toBe(false) expect(env).toBe(input) }) + + test('treats attribution header routing as provider-managed env', () => { + expect(isProviderManagedEnvVar('CLAUDE_CODE_ATTRIBUTION_HEADER')).toBe(true) + }) }) diff --git a/src/utils/managedEnvConstants.ts b/src/utils/managedEnvConstants.ts index 16cf0045..61ab8e12 100644 --- a/src/utils/managedEnvConstants.ts +++ b/src/utils/managedEnvConstants.ts @@ -59,6 +59,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION', 'CLAUDE_CODE_SUBAGENT_MODEL', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', + 'CLAUDE_CODE_ATTRIBUTION_HEADER', 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', ])