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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 18:12:48 +08:00
parent 63ef7b1a5b
commit 8cce7ac8bb
11 changed files with 90 additions and 1 deletions

View File

@ -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 () => {

View File

@ -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')
})

View File

@ -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<string, string>
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()
})

View File

@ -620,6 +620,7 @@ describe('Models API', () => {
const managedSettings = await providerSvc.getManagedSettings()
expect(managedSettings.model).toBe('glm-5-turbo')
expect((managedSettings.env as Record<string, string>).CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0')
const globalSettings = await settingsSvc.getUserSettings()
expect(globalSettings.model).toBeUndefined()

View File

@ -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<Response> {
}
const { activeId } = await providerService.listProviders()
if (activeId) {
await providerService.updateManagedSettings(updates)
const currentManagedSettings = await providerService.getManagedSettings()
const currentEnv =
(currentManagedSettings.env as Record<string, string> | undefined) ?? {}
await providerService.updateManagedSettings({
...updates,
env: {
...currentEnv,
...attributionHeaderEnvForModel(baseId),
},
})
} else {
await settingsService.updateUserSettings(updates)
}

View File

@ -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<string, string> {
const normalizedModel = modelName?.trim()
if (!normalizedModel) return {}
return {
[ATTRIBUTION_HEADER_ENV_KEY]: isClaudeModelName(normalizedModel) ? '1' : '0',
}
}

View File

@ -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 {

View File

@ -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,
}
}

View File

@ -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),
}
}

View File

@ -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)
})
})

View File

@ -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',
])