mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
fix(provider): support custom Anthropic bearer auth (#1929)
* fix(provider): support custom Anthropic bearer auth * feat(provider): add custom Anthropic profile flow * fix(provider): restore custom Anthropic tokens on startup * fix(provider): preserve custom Anthropic env setup * fix(provider): clear stale custom Anthropic tokens * fix(provider): preserve custom Anthropic API-key env setup * fix(provider): cover custom Anthropic auth routing * test(api): isolate custom Anthropic client routing * test(api): cache-bust client provider imports * feat(provider): clarify custom provider presets * fix(provider): address custom Anthropic review feedback * fix(provider): preserve custom Anthropic headers * fix(provider): require custom Anthropic token * fix(provider): isolate custom Anthropic credentials * fix(provider): guard custom Anthropic setup * fix(provider): complete custom Anthropic integration * fix(provider): classify custom Anthropic proxies * fix(provider): gate proxy cache extensions * fix(provider): preserve custom Anthropic isolation * fix(provider): retain direct proxy model option * fix(provider): honor custom endpoint boundaries * fix(provider): keep proxy credentials local * fix(provider): disable proxy fast mode * fix(provider): preserve first-party route identity * fix(provider): isolate custom Anthropic endpoints * fix(provider): gate remaining first-party features * fix(provider): isolate custom Anthropic proxy features * test(web-search): make Brave timeout mock abort-aware * fix(provider): address custom Anthropic review feedback * test(provider): cover first-party beta gates * fix(provider): complete custom Anthropic isolation * fix(provider): complete custom Anthropic routing * fix(provider): address custom Anthropic review followups * fix(provider): close custom Anthropic review gaps * test(provider): keep custom Anthropic mock helpers isolated * test(provider): isolate model options gateway mocks * fix(provider): stabilize custom Anthropic model option display * fix(provider): address remaining review threads * fix(provider): synchronize active profile persistence * fix(provider): preserve custom Anthropic API key auth * fix(provider): avoid forwarding inherited Anthropic keys * fix(provider): guard custom auth selection * fix(provider): require first-party Anthropic port * test(web-search): avoid duplicate shared lock * fix(provider): resolve remaining review findings * fix(provider): harden custom Anthropic routing * fix(provider): simplify Anthropic thinking gate * fix(provider): preserve custom proxy routing and secret permissions * fix(mcp): isolate Claude.ai config cache by provider * fix(model): keep custom endpoints out of first-party UX * fix(provider): scope Opus off switch to Anthropic * fix(provider): disable tool search for custom proxies * fix(provider): close custom Anthropic review gaps * fix(provider): reject Anthropic staging custom profiles * fix(webfetch): classify custom Anthropic endpoints * fix(provider): block bearer auth at Anthropic origin * fix(provider): keep custom auth off staging OAuth * test(provider): strengthen auth regression coverage
This commit is contained in:
@@ -33,6 +33,12 @@
|
||||
# ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||
# ANTHROPIC_MODEL=claude-sonnet-4-5 (optional)
|
||||
# ANTHROPIC_BASE_URL=https://api.anthropic.com (optional)
|
||||
# ANTHROPIC_AUTH_TOKEN=your-provider-token (custom Bearer endpoints)
|
||||
#
|
||||
# Option 1b — Custom Anthropic-compatible API (Bearer auth):
|
||||
# ANTHROPIC_BASE_URL=https://your-provider.example
|
||||
# ANTHROPIC_AUTH_TOKEN=your-provider-token
|
||||
# ANTHROPIC_MODEL=your-model-name
|
||||
#
|
||||
# Option 2 — OpenAI:
|
||||
# CLAUDE_CODE_USE_OPENAI=1
|
||||
|
||||
@@ -409,6 +409,26 @@ already present as dev dependencies, so source/dev builds need no extra steps.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Custom (Anthropic-compatible) APIs
|
||||
|
||||
For an endpoint that accepts Anthropic's native Messages API, set its base URL,
|
||||
Bearer token, and model directly. Do not set `CLAUDE_CODE_USE_OPENAI`; that
|
||||
selects the OpenAI-compatible transport instead.
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL=https://anthropic-proxy.example
|
||||
export ANTHROPIC_AUTH_TOKEN=your-provider-token
|
||||
export ANTHROPIC_MODEL=your-model-name
|
||||
openclaude
|
||||
```
|
||||
|
||||
`ANTHROPIC_AUTH_TOKEN` is sent as `Authorization: Bearer ...`. The
|
||||
`/provider` → `Add provider` menu uses that Bearer-token setup as **Custom
|
||||
(Anthropic-compatible)**, including optional extra request headers. For a
|
||||
directly configured endpoint that instead requires Anthropic's native
|
||||
`x-api-key` authentication, set `ANTHROPIC_API_KEY` in place of the Bearer
|
||||
token; do not set both credentials.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `CLAUDE_CODE_USE_OPENAI` | OpenAI-compatible only | Set to `1` to enable the OpenAI-compatible provider path |
|
||||
|
||||
@@ -198,6 +198,6 @@ Important compatibility surfaces include:
|
||||
Contributor docs should describe these as compatibility bridges, not as the
|
||||
primary architecture.
|
||||
|
||||
Preset ordering pins `anthropic` first, derives the middle entries from preset
|
||||
descriptions with standard alphanumeric sorting, and pins `custom` last
|
||||
automatically.
|
||||
Preset ordering pins `gitlawb-opengateway` first, derives the middle entries from preset
|
||||
descriptions with standard alphanumeric sorting, and pins the custom presets
|
||||
last: `custom` followed by `custom-anthropic`.
|
||||
|
||||
@@ -19,7 +19,10 @@ import { env } from '../utils/env.js';
|
||||
import { type GitRepoState, getGitState, getIsGit } from '../utils/git.js';
|
||||
import { getAuthHeaders, getUserAgent } from '../utils/http.js';
|
||||
import { getInMemoryErrors, logError } from '../utils/log.js';
|
||||
import { getAPIProvider } from '../utils/model/providers.js';
|
||||
import {
|
||||
getAPIProvider,
|
||||
isFirstPartyAnthropicBaseUrl,
|
||||
} from '../utils/model/providers.js';
|
||||
import { isEssentialTrafficOnly } from '../utils/privacyLevel.js';
|
||||
import { jsonRedactor, redactJsonLines, redactSensitiveInfo } from '../utils/redaction.js';
|
||||
import { extractTeammateTranscriptsFromTasks, getTranscriptPath, loadAllSubagentTranscriptsFromDisk, MAX_TRANSCRIPT_READ_BYTES } from '../utils/sessionStorage.js';
|
||||
@@ -488,7 +491,7 @@ async function submitFeedback(data: FeedbackData, signal?: AbortSignal): Promise
|
||||
try {
|
||||
// Third-party providers should not post feedback to Anthropic, but they
|
||||
// should still reach the done state so users can open a GitHub issue draft.
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
if (getAPIProvider() !== 'firstParty' || !isFirstPartyAnthropicBaseUrl()) {
|
||||
return {
|
||||
success: true,
|
||||
issueDraftOnly: true
|
||||
|
||||
@@ -5,6 +5,7 @@ import { checkAndRefreshOAuthTokenIfNeeded } from '../../utils/auth.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { getAuthHeaders, getUserAgent } from '../../utils/http.js'
|
||||
import { isFirstPartyAnthropicProvider } from '../../utils/model/providers.js'
|
||||
import { normalizeMessagesForAPI } from '../../utils/messages.js'
|
||||
import {
|
||||
extractAgentIdsFromMessages,
|
||||
@@ -31,6 +32,9 @@ export async function submitTranscriptShare(
|
||||
trigger: TranscriptShareTrigger,
|
||||
appearanceId: string,
|
||||
): Promise<TranscriptShareResult> {
|
||||
if (!isFirstPartyAnthropicProvider()) {
|
||||
return { success: false }
|
||||
}
|
||||
try {
|
||||
logForDebugging('Collecting transcript for sharing', { level: 'info' })
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ import { useShowOverageCreditUpsell, incrementOverageCreditUpsellSeenCount, crea
|
||||
import { plural } from '../../utils/stringUtils.js';
|
||||
import { useAppState } from '../../state/AppState.js';
|
||||
import { getEffortSuffix } from '../../utils/effort.js';
|
||||
import { getAPIProvider } from '../../utils/model/providers.js';
|
||||
import { isFirstPartyAnthropicProvider } from '../../utils/model/providers.js';
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
||||
import { renderModelSetting } from '../../utils/model/model.js';
|
||||
// Stubs: internal-only startup notices not included in this open snapshot
|
||||
@@ -58,7 +58,7 @@ const LEFT_PANEL_MAX_WIDTH = 50;
|
||||
export function LogoV2() {
|
||||
const $ = _c(94);
|
||||
const activities = getRecentActivitySync();
|
||||
const showAccountIdentity = getAPIProvider() === 'firstParty';
|
||||
const showAccountIdentity = isFirstPartyAnthropicProvider();
|
||||
const username = showAccountIdentity ? getGlobalConfig().oauthAccount?.displayName ?? "" : "";
|
||||
const {
|
||||
columns
|
||||
|
||||
@@ -111,7 +111,7 @@ async function waitForCondition(
|
||||
|
||||
// Provider list is sorted from generated preset metadata by description, with
|
||||
// Gitlawb Opengateway pinned first, Anthropic second, Codex OAuth injected
|
||||
// after DeepSeek, and Custom always pinned last. Keep the target-by-label
|
||||
// after DeepSeek, and the custom endpoints always pinned last. Keep the target-by-label
|
||||
// indirection here so
|
||||
// these tests survive future list edits without hardcoding raw key counts.
|
||||
//
|
||||
@@ -154,7 +154,8 @@ const PRESET_ORDER = [
|
||||
'Xiaomi MiMo',
|
||||
'Xiaomi MiMo (Token Plan)',
|
||||
'Z.AI - GLM Coding Plan',
|
||||
'Custom',
|
||||
'Custom (OpenAI-compatible)',
|
||||
'Custom (Anthropic-compatible)',
|
||||
] as const
|
||||
|
||||
async function navigateToPreset(
|
||||
@@ -226,6 +227,17 @@ function mockProviderProfilesModule(options?: {
|
||||
}
|
||||
}
|
||||
|
||||
if (preset === 'custom-anthropic') {
|
||||
return {
|
||||
provider: 'custom-anthropic',
|
||||
name: 'Custom (Anthropic-compatible)',
|
||||
baseUrl: 'https://anthropic-proxy.example',
|
||||
model: 'claude-sonnet-4-6',
|
||||
apiKey: '',
|
||||
requiresApiKey: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (preset === 'azure-openai') {
|
||||
return {
|
||||
provider: 'azure-openai',
|
||||
@@ -663,7 +675,7 @@ test('ProviderManager shows API mode picker for custom OpenAI-compatible provide
|
||||
frame.includes('Choose provider preset'),
|
||||
)
|
||||
|
||||
await navigateToPreset(mounted.stdin, 'Custom')
|
||||
await navigateToPreset(mounted.stdin, 'Custom (OpenAI-compatible)')
|
||||
mounted.stdin.write('\r')
|
||||
await waitForFrameOutput(mounted.getOutput, frame =>
|
||||
frame.includes('Create provider profile') &&
|
||||
@@ -689,6 +701,57 @@ test('ProviderManager shows API mode picker for custom OpenAI-compatible provide
|
||||
}
|
||||
})
|
||||
|
||||
test('ProviderManager offers a token field for custom Anthropic-compatible providers', async () => {
|
||||
mockProviderManagerDependencies(() => undefined, async () => undefined)
|
||||
|
||||
const nonce = `${Date.now()}-${Math.random()}`
|
||||
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
||||
const mounted = await mountProviderManager(ProviderManager)
|
||||
|
||||
try {
|
||||
await waitForFrameOutput(mounted.getOutput, frame =>
|
||||
frame.includes('Provider manager'),
|
||||
)
|
||||
mounted.stdin.write('\r')
|
||||
await waitForFrameOutput(mounted.getOutput, frame =>
|
||||
frame.includes('Choose provider preset'),
|
||||
)
|
||||
await navigateToPreset(mounted.stdin, 'Custom (Anthropic-compatible)')
|
||||
mounted.stdin.write('\r')
|
||||
await waitForFrameOutput(mounted.getOutput, frame => frame.includes('Provider name'))
|
||||
mounted.stdin.write('\r')
|
||||
await waitForFrameOutput(mounted.getOutput, frame => frame.includes('Base URL'))
|
||||
mounted.stdin.write('\r')
|
||||
await waitForFrameOutput(mounted.getOutput, frame => frame.includes('Default model'))
|
||||
mounted.stdin.write('\r')
|
||||
|
||||
const output = await waitForFrameOutput(mounted.getOutput, frame =>
|
||||
frame.includes('Credential') && frame.includes('Anthropic-compatible API'),
|
||||
)
|
||||
expect(output).not.toContain('API mode')
|
||||
mounted.stdin.write('\r')
|
||||
const requiredOutput = await waitForFrameOutput(mounted.getOutput, frame =>
|
||||
frame.includes('Credential is required.'),
|
||||
)
|
||||
expect(requiredOutput).toContain('Credential is required.')
|
||||
mounted.stdin.write('proxy-token')
|
||||
mounted.stdin.write('\r')
|
||||
const headersOutput = await waitForFrameOutput(mounted.getOutput, frame =>
|
||||
frame.includes('Custom headers'),
|
||||
)
|
||||
expect(headersOutput).toContain('Extra non-auth request headers')
|
||||
mounted.stdin.write('\r')
|
||||
const placeholderError = await waitForFrameOutput(mounted.getOutput, frame =>
|
||||
frame.includes('Base URL must be a real Anthropic-compatible endpoint.'),
|
||||
)
|
||||
expect(placeholderError).toContain(
|
||||
'Base URL must be a real Anthropic-compatible endpoint.',
|
||||
)
|
||||
} finally {
|
||||
await mounted.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('ProviderManager keeps full setup flow for presets with placeholder endpoint defaults', async () => {
|
||||
mockProviderManagerDependencies(() => undefined, async () => undefined)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
readCodexCredentialsAsync,
|
||||
} from '../utils/codexCredentials.js'
|
||||
import { isBareMode, isEnvTruthy } from '../utils/envUtils.js'
|
||||
import { isFirstPartyAnthropicBaseUrlForEnv } from '../utils/anthropicBaseUrl.js'
|
||||
import {
|
||||
parseProfileCustomHeadersInput,
|
||||
serializeProfileCustomHeaders,
|
||||
@@ -293,7 +294,11 @@ function presetToDraft(preset: ProviderPreset): ProviderDraft {
|
||||
}
|
||||
|
||||
function isSetupPlaceholder(value: string): boolean {
|
||||
return /\bYOUR[-_\s]/i.test(value) || /<[^>]+>/.test(value)
|
||||
return (
|
||||
/\bYOUR[-_\s]/i.test(value) ||
|
||||
/<[^>]+>/.test(value) ||
|
||||
/:\/\/[^/]+\.example(?:\/|$)/i.test(value)
|
||||
)
|
||||
}
|
||||
|
||||
function canUseStreamlinedPresetFlow(draft: ProviderDraft): boolean {
|
||||
@@ -905,6 +910,16 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
const currentStep = formSteps[formStepIndex] ?? formSteps[0] ?? FORM_STEPS[0]
|
||||
const currentStepKey = currentStep.key
|
||||
const currentValue = draft[currentStepKey]
|
||||
const displayStep =
|
||||
draftProvider === 'custom-anthropic' && currentStepKey === 'apiKey'
|
||||
? {
|
||||
...currentStep,
|
||||
label: 'Credential',
|
||||
placeholder: 'Credential for this endpoint',
|
||||
helpText: 'The custom profile stores this as an Authorization Bearer token.',
|
||||
optional: false,
|
||||
}
|
||||
: currentStep
|
||||
|
||||
// Memoize menu options to prevent unnecessary re-renders when navigating
|
||||
// the select menu. Without this, each arrow key press creates a new options
|
||||
@@ -1598,7 +1613,11 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
return
|
||||
}
|
||||
|
||||
if (preset === 'custom' || !canUseStreamlinedPresetFlow(nextDraft)) {
|
||||
if (
|
||||
preset === 'custom' ||
|
||||
preset === 'custom-anthropic' ||
|
||||
!canUseStreamlinedPresetFlow(nextDraft)
|
||||
) {
|
||||
setScreen('form')
|
||||
return
|
||||
}
|
||||
@@ -1629,6 +1648,17 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
provider: ProviderProfile['provider'] = draftProvider,
|
||||
profileId: string | null = editingProfileId,
|
||||
): void {
|
||||
if (
|
||||
provider === 'custom-anthropic' &&
|
||||
(isSetupPlaceholder(nextDraft.baseUrl) ||
|
||||
isFirstPartyAnthropicBaseUrlForEnv({
|
||||
ANTHROPIC_BASE_URL: nextDraft.baseUrl,
|
||||
USER_TYPE: process.env.USER_TYPE,
|
||||
}))
|
||||
) {
|
||||
setErrorMessage('Base URL must be a real Anthropic-compatible endpoint.')
|
||||
return
|
||||
}
|
||||
const routeId = resolveProviderEditorRouteId(provider, nextDraft.baseUrl)
|
||||
const supportsApiFormat = routeSupportsApiFormatSelection(routeId)
|
||||
const showsAuthHeader = routeShowsAuthHeader(routeId)
|
||||
@@ -1908,8 +1938,8 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
function handleFormSubmit(value: string): void {
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (!currentStep.optional && trimmed.length === 0) {
|
||||
setErrorMessage(`${currentStep.label} is required.`)
|
||||
if (!displayStep.optional && trimmed.length === 0) {
|
||||
setErrorMessage(`${displayStep.label} is required.`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2159,7 +2189,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
<Text color="remember" bold>
|
||||
{editingProfileId ? 'Edit provider profile' : 'Create provider profile'}
|
||||
</Text>
|
||||
<Text dimColor>{currentStep.helpText}</Text>
|
||||
<Text dimColor>{displayStep.helpText}</Text>
|
||||
<Text dimColor>
|
||||
Provider type:{' '}
|
||||
{getRouteProviderTypeLabel(resolveProfileRoute(draftProvider).routeId)}
|
||||
@@ -2171,7 +2201,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
</Text>
|
||||
) : null}
|
||||
<Text dimColor>
|
||||
Step {formStepIndex + 1} of {formSteps.length}: {currentStep.label}
|
||||
Step {formStepIndex + 1} of {formSteps.length}: {displayStep.label}
|
||||
</Text>
|
||||
{currentStepKey === 'apiFormat' ? (
|
||||
<Select
|
||||
@@ -2216,7 +2246,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
onSubmit={handleFormSubmit}
|
||||
focus={true}
|
||||
showCursor={true}
|
||||
placeholder={`${currentStep.placeholder}${figures.ellipsis}`}
|
||||
placeholder={`${displayStep.placeholder}${figures.ellipsis}`}
|
||||
mask={
|
||||
currentStepKey === 'apiKey' ||
|
||||
currentStepKey === 'authHeaderValue'
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineAnthropicProxy } from '../define.js'
|
||||
|
||||
// A generic user-configured Anthropic Messages API endpoint. Unlike the
|
||||
// first-party Anthropic preset, this route can use a provider-issued Bearer
|
||||
// token or native x-api-key authentication.
|
||||
export default defineAnthropicProxy({
|
||||
id: 'custom-anthropic',
|
||||
label: 'Custom (Anthropic-compatible)',
|
||||
classification: 'anthropic-proxy',
|
||||
defaultBaseUrl: 'https://anthropic-proxy.example',
|
||||
defaultModel: 'claude-sonnet-4-6',
|
||||
setup: {
|
||||
requiresAuth: true,
|
||||
authMode: 'token',
|
||||
credentialEnvVars: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'],
|
||||
setupPrompt: 'Paste the credential for your Anthropic-compatible endpoint.',
|
||||
},
|
||||
envVarConfig: {
|
||||
authTokenEnvVar: 'ANTHROPIC_AUTH_TOKEN',
|
||||
baseUrlEnvVar: 'ANTHROPIC_BASE_URL',
|
||||
modelEnvVar: 'ANTHROPIC_MODEL',
|
||||
},
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsFunctionCalling: true,
|
||||
supportsVision: true,
|
||||
},
|
||||
transportConfig: {
|
||||
kind: 'anthropic-proxy',
|
||||
anthropicProxy: { supportsCustomHeaders: true },
|
||||
},
|
||||
usage: { supported: false },
|
||||
preset: {
|
||||
id: 'custom-anthropic',
|
||||
description: 'Any Anthropic Messages API-compatible provider',
|
||||
label: 'Custom (Anthropic-compatible)',
|
||||
name: 'Custom (Anthropic-compatible)',
|
||||
vendorId: 'anthropic',
|
||||
apiKeyEnvVars: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'],
|
||||
baseUrlEnvVars: ['ANTHROPIC_BASE_URL'],
|
||||
modelEnvVars: ['ANTHROPIC_MODEL'],
|
||||
fallbackBaseUrl: 'https://anthropic-proxy.example',
|
||||
fallbackModel: 'claude-sonnet-4-6',
|
||||
},
|
||||
})
|
||||
@@ -250,13 +250,18 @@ function compareProviderPresetEntries(
|
||||
return 1
|
||||
}
|
||||
|
||||
if (leftPreset === 'custom') {
|
||||
// Keep the generic custom endpoints together at the end of the picker,
|
||||
// with the Anthropic-native option after the OpenAI-compatible one.
|
||||
if (leftPreset === 'custom-anthropic') {
|
||||
return 1
|
||||
}
|
||||
if (rightPreset === 'custom') {
|
||||
if (rightPreset === 'custom-anthropic') {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (leftPreset === 'custom') return 1
|
||||
if (rightPreset === 'custom') return -1
|
||||
|
||||
const descriptionDelta = PRESET_DESCRIPTION_COLLATOR.compare(
|
||||
String(left.description),
|
||||
String(right.description),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getGateway,
|
||||
getModelsForGateway,
|
||||
getVendor,
|
||||
ORDERED_PROVIDER_PRESETS,
|
||||
} from './index.js'
|
||||
import {
|
||||
PRESET_VENDOR_MAP,
|
||||
@@ -35,6 +36,7 @@ const EXPECTED_PRESETS = [
|
||||
'dashscope-cn',
|
||||
'dashscope-intl',
|
||||
'custom',
|
||||
'custom-anthropic',
|
||||
'nvidia-nim',
|
||||
'minimax',
|
||||
'xai',
|
||||
@@ -76,7 +78,9 @@ describe('compatibility mappings', () => {
|
||||
|
||||
expect(route.vendorId).toBe(vendorId)
|
||||
expect(route.gatewayId).toBe(gatewayId)
|
||||
expect(route.routeId).toBe(gatewayId ?? vendorId)
|
||||
expect(route.routeId).toBe(
|
||||
preset === 'custom-anthropic' ? 'custom-anthropic' : gatewayId ?? vendorId,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -95,6 +99,24 @@ describe('compatibility mappings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Custom Anthropic is modeled as an Anthropic proxy', () => {
|
||||
expect(routeForPreset('custom-anthropic')).toEqual({
|
||||
vendorId: 'anthropic',
|
||||
routeId: 'custom-anthropic',
|
||||
})
|
||||
expect(resolveProfileRoute('custom-anthropic')).toEqual({
|
||||
vendorId: 'anthropic',
|
||||
routeId: 'custom-anthropic',
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps custom provider presets at the bottom of the add-provider list', () => {
|
||||
expect(ORDERED_PROVIDER_PRESETS.slice(-2)).toEqual([
|
||||
'custom',
|
||||
'custom-anthropic',
|
||||
])
|
||||
})
|
||||
|
||||
test('Atlas Cloud gateway models do not resolve to NearAI-scoped descriptors', () => {
|
||||
const atlasModels = getModelsForGateway('atlas-cloud')
|
||||
expect(atlasModels.length).toBeGreaterThan(0)
|
||||
|
||||
@@ -85,6 +85,9 @@ export interface TransportConfig {
|
||||
kind: TransportKind
|
||||
headers?: Record<string, string>
|
||||
openaiShim?: OpenAIShimTransportConfig
|
||||
anthropicProxy?: {
|
||||
supportsCustomHeaders?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface CatalogTransportOverrides {
|
||||
|
||||
@@ -24,7 +24,7 @@ function getModelInfo(raw: unknown): Record<string, unknown> | undefined {
|
||||
|
||||
export default defineGateway({
|
||||
id: 'custom',
|
||||
label: 'Custom OpenAI-compatible',
|
||||
label: 'Custom (OpenAI-compatible)',
|
||||
category: 'hosted',
|
||||
defaultModel: 'llama3.1:8b',
|
||||
supportsModelRouting: true,
|
||||
@@ -42,8 +42,8 @@ export default defineGateway({
|
||||
preset: {
|
||||
id: 'custom',
|
||||
description: 'Any OpenAI-compatible provider',
|
||||
label: 'Custom',
|
||||
name: 'Custom OpenAI-compatible',
|
||||
label: 'Custom (OpenAI-compatible)',
|
||||
name: 'Custom (OpenAI-compatible)',
|
||||
apiKeyEnvVars: ['OPENAI_API_KEYS', 'OPENAI_API_KEY'],
|
||||
baseUrlEnvVars: ['OPENAI_BASE_URL', 'OPENAI_API_BASE'],
|
||||
modelEnvVars: ['OPENAI_MODEL'],
|
||||
|
||||
@@ -44,6 +44,7 @@ import gatewayOpenrouter from '../gateways/openrouter.js'
|
||||
import gatewayTogether from '../gateways/together.js'
|
||||
import gatewayVertex from '../gateways/vertex.js'
|
||||
import gatewayXiaomiMimoToken from '../gateways/xiaomi-mimo-token.js'
|
||||
import anthropicproxyCustom from '../anthropicProxies/custom.js'
|
||||
import brandClaude from '../brands/claude.js'
|
||||
import brandDeepseek from '../brands/deepseek.js'
|
||||
import brandFireworks from '../brands/fireworks.js'
|
||||
@@ -82,7 +83,7 @@ import modelXiaomiMimo from '../models/xiaomi-mimo.js'
|
||||
|
||||
export const VENDOR_DESCRIPTORS = [vendorAnthropic, vendorBankr, vendorDeepseek, vendorFireworks, vendorGemini, vendorMinimax, vendorMoonshot, vendorNearai, vendorOpenai, vendorVenice, vendorXai, vendorXiaomiMimo, vendorZai] as const satisfies readonly VendorDescriptor[]
|
||||
export const GATEWAY_DESCRIPTORS = [gatewayAimlapi, gatewayAtlasCloud, gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayClinepass, gatewayCloudflare, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithubEnterprise, gatewayGithub, gatewayGitlawbOpengateway, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpencodeGo, gatewayOpencode, gatewayOpenrouter, gatewayTogether, gatewayVertex, gatewayXiaomiMimoToken] as const satisfies readonly GatewayDescriptor[]
|
||||
export const ANTHROPIC_PROXY_DESCRIPTORS = [] as const satisfies readonly AnthropicProxyDescriptor[]
|
||||
export const ANTHROPIC_PROXY_DESCRIPTORS = [anthropicproxyCustom] as const satisfies readonly AnthropicProxyDescriptor[]
|
||||
export const BRAND_DESCRIPTORS = [brandClaude, brandDeepseek, brandFireworks, brandGemini, brandGlm, brandGpt, brandKimi, brandLlama, brandMinimax, brandMistral, brandNearai, brandNemotron, brandOpenaiCompatibleAlias, brandQwen, brandTencent, brandXai, brandXiaomiMimo] as const satisfies readonly BrandDescriptor[]
|
||||
export const MODEL_DESCRIPTOR_GROUPS = [modelClaude, modelDeepseek, modelFireworksMerged, modelGemini, modelGlm, modelGpt, modelKimi, modelLlama, modelMinimax, modelMistral, modelNearai, modelNemotron, modelOpenaiCompatibleAlias, modelOpencode, modelQwen, modelTencent, modelXai, modelXiaomiMimo] as const satisfies readonly (readonly ModelDescriptor[])[]
|
||||
export const MODEL_DESCRIPTORS = MODEL_DESCRIPTOR_GROUPS.flat() satisfies readonly ModelDescriptor[]
|
||||
|
||||
@@ -468,8 +468,8 @@ export const PROVIDER_PRESET_MANIFEST = [
|
||||
"vendorId": "openai",
|
||||
"gatewayId": "custom",
|
||||
"description": "Any OpenAI-compatible provider",
|
||||
"label": "Custom",
|
||||
"name": "Custom OpenAI-compatible",
|
||||
"label": "Custom (OpenAI-compatible)",
|
||||
"name": "Custom (OpenAI-compatible)",
|
||||
"apiKeyEnvVars": [
|
||||
"OPENAI_API_KEYS",
|
||||
"OPENAI_API_KEY"
|
||||
@@ -482,6 +482,27 @@ export const PROVIDER_PRESET_MANIFEST = [
|
||||
"OPENAI_MODEL"
|
||||
],
|
||||
"fallbackBaseUrl": "http://localhost:11434/v1"
|
||||
},
|
||||
{
|
||||
"preset": "custom-anthropic",
|
||||
"routeKind": "anthropic-proxy",
|
||||
"routeId": "custom-anthropic",
|
||||
"vendorId": "anthropic",
|
||||
"description": "Any Anthropic Messages API-compatible provider",
|
||||
"label": "Custom (Anthropic-compatible)",
|
||||
"name": "Custom (Anthropic-compatible)",
|
||||
"apiKeyEnvVars": [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY"
|
||||
],
|
||||
"baseUrlEnvVars": [
|
||||
"ANTHROPIC_BASE_URL"
|
||||
],
|
||||
"modelEnvVars": [
|
||||
"ANTHROPIC_MODEL"
|
||||
],
|
||||
"fallbackBaseUrl": "https://anthropic-proxy.example",
|
||||
"fallbackModel": "claude-sonnet-4-6"
|
||||
}
|
||||
] as const satisfies readonly ProviderPresetManifestEntry[]
|
||||
export type ProviderPreset = (typeof PROVIDER_PRESET_MANIFEST)[number]['preset']
|
||||
@@ -520,5 +541,6 @@ export const ORDERED_PROVIDER_PRESETS = [
|
||||
"xiaomi-mimo",
|
||||
"xiaomi-mimo-token",
|
||||
"zai",
|
||||
"custom"
|
||||
"custom",
|
||||
"custom-anthropic"
|
||||
] as const
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('loaded registry validation', () => {
|
||||
expect(routeSupportsApiFormatSelection('minimax')).toBe(false)
|
||||
expect(routeSupportsAuthHeaders('minimax')).toBe(false)
|
||||
expect(routeSupportsCustomHeaders('minimax')).toBe(false)
|
||||
expect(routeSupportsCustomHeaders('custom-anthropic')).toBe(true)
|
||||
})
|
||||
|
||||
test('route catalogs do not duplicate defaultModel with catalog default flags', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Resolves a stored profile.provider string to a descriptor-backed route.
|
||||
// This bridges legacy preset names, vendor ids, gateway ids, and custom strings.
|
||||
|
||||
import { getGateway, getVendor } from './registry.js'
|
||||
import { getAnthropicProxy, getGateway, getVendor } from './registry.js'
|
||||
import { isProviderPreset, routeForPreset } from './compatibility.js'
|
||||
|
||||
export type ResolvedProfileRoute = {
|
||||
@@ -42,7 +42,13 @@ export function resolveProfileRoute(provider: string): ResolvedProfileRoute {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Safe fallback — OpenAI-compatible so the user can still interact,
|
||||
// 4. Try Anthropic-native proxy id.
|
||||
const anthropicProxy = getAnthropicProxy(provider)
|
||||
if (anthropicProxy) {
|
||||
return { vendorId: 'anthropic', routeId: anthropicProxy.id }
|
||||
}
|
||||
|
||||
// 5. Safe fallback — OpenAI-compatible so the user can still interact,
|
||||
// but the routeId makes it clear this is unrecognised.
|
||||
return { vendorId: 'openai', routeId: 'unknown-fallback' }
|
||||
}
|
||||
|
||||
@@ -125,6 +125,64 @@ test('getRouteCredentialEnvVars keeps descriptor env vars and openai fallback fo
|
||||
])
|
||||
})
|
||||
|
||||
test('custom Anthropic credentials stay native and resolve to their proxy route', () => {
|
||||
expect(getRouteCredentialEnvVars('custom-anthropic')).toEqual([
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_API_KEY',
|
||||
])
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
ANTHROPIC_BASE_URL: 'https://tenant.example/v1',
|
||||
ANTHROPIC_MODEL: 'tenant-model',
|
||||
ANTHROPIC_AUTH_TOKEN: 'tenant-token',
|
||||
}),
|
||||
).toBe('custom-anthropic')
|
||||
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
ANTHROPIC_BASE_URL: 'https://api.anthropic.com',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_AUTH_TOKEN: 'first-party-token',
|
||||
}),
|
||||
).toBe('anthropic')
|
||||
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||
OPENAI_API_KEY: 'openai-key',
|
||||
ANTHROPIC_BASE_URL: 'https://tenant.example/v1',
|
||||
ANTHROPIC_MODEL: 'tenant-model',
|
||||
ANTHROPIC_AUTH_TOKEN: 'tenant-token',
|
||||
}),
|
||||
).toBe('openai')
|
||||
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
ANTHROPIC_BASE_URL: 'https://tenant.example/v1',
|
||||
ANTHROPIC_MODEL: 'tenant-model',
|
||||
ANTHROPIC_API_KEY: 'tenant-key',
|
||||
}),
|
||||
).toBe('custom-anthropic')
|
||||
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
ANTHROPIC_BASE_URL: 'https://tenant.example/v1',
|
||||
ANTHROPIC_MODEL: 'tenant-model',
|
||||
ANTHROPIC_API_KEY: 'tenant-key',
|
||||
MINIMAX_API_KEY: 'ambient-minimax-key',
|
||||
}),
|
||||
).toBe('custom-anthropic')
|
||||
|
||||
expect(
|
||||
resolveActiveRouteIdFromEnv({
|
||||
ANTHROPIC_BASE_URL: 'https://api.minimax.io/anthropic',
|
||||
ANTHROPIC_MODEL: 'tenant-model',
|
||||
ANTHROPIC_AUTH_TOKEN: 'tenant-token',
|
||||
}),
|
||||
).toBe('custom-anthropic')
|
||||
})
|
||||
|
||||
test('getRouteCredentialEnvVars omits the openai fallback for dedicatedCredentialsOnly routes', () => {
|
||||
expect(getRouteCredentialEnvVars('atlas-cloud')).toEqual([
|
||||
'ATLAS_CLOUD_API_KEY',
|
||||
|
||||
@@ -6,16 +6,19 @@ import type {
|
||||
} from './descriptors.js'
|
||||
import {
|
||||
ensureIntegrationsLoaded,
|
||||
getAllAnthropicProxies,
|
||||
getAllGateways,
|
||||
getAllVendors,
|
||||
getGateway,
|
||||
getAnthropicProxy,
|
||||
getVendor,
|
||||
resolveProfileRoute,
|
||||
} from './index.js'
|
||||
import { hasUsableOpenAICredential } from '../services/api/credentialPool.js'
|
||||
import { isEnvTruthy } from '../utils/envUtils.js'
|
||||
import { isFirstPartyAnthropicBaseUrlForEnv } from '../utils/anthropicBaseUrl.js'
|
||||
|
||||
export type RouteDescriptor = GatewayDescriptor | VendorDescriptor
|
||||
export type RouteDescriptor = GatewayDescriptor | VendorDescriptor | import('./descriptors.js').AnthropicProxyDescriptor
|
||||
|
||||
const TRANSPORT_KIND_PROVIDER_TYPE_LABELS: Partial<
|
||||
Record<TransportKind, string>
|
||||
@@ -93,7 +96,7 @@ function normalizeHost(
|
||||
|
||||
function getAllRoutes(): RouteDescriptor[] {
|
||||
ensureIntegrationsLoaded()
|
||||
return [...getAllGateways(), ...getAllVendors()]
|
||||
return [...getAllGateways(), ...getAllVendors(), ...getAllAnthropicProxies()]
|
||||
}
|
||||
|
||||
function resolveKnownLocalRouteIdFromBaseUrl(baseUrl?: string): string | null {
|
||||
@@ -129,7 +132,7 @@ export function getRouteDescriptor(
|
||||
routeId: string,
|
||||
): RouteDescriptor | null {
|
||||
ensureIntegrationsLoaded()
|
||||
return getGateway(routeId) ?? getVendor(routeId) ?? null
|
||||
return getGateway(routeId) ?? getVendor(routeId) ?? getAnthropicProxy(routeId) ?? null
|
||||
}
|
||||
|
||||
export function getRouteLabel(
|
||||
@@ -829,7 +832,10 @@ export function routeSupportsCustomHeaders(
|
||||
return false
|
||||
}
|
||||
|
||||
return descriptor.transportConfig.openaiShim?.supportsAuthHeaders === true
|
||||
return (
|
||||
descriptor.transportConfig.openaiShim?.supportsAuthHeaders === true ||
|
||||
descriptor.transportConfig.anthropicProxy?.supportsCustomHeaders === true
|
||||
)
|
||||
}
|
||||
|
||||
export function routeShowsAuthHeaderValue(routeId: string): boolean {
|
||||
@@ -992,6 +998,26 @@ export function resolveActiveRouteIdFromEnv(
|
||||
return 'vertex'
|
||||
}
|
||||
|
||||
// A Bearer token explicitly selects the custom Anthropic proxy contract,
|
||||
// even if the host also belongs to a known OpenAI-compatible route. Keep
|
||||
// native x-api-key configurations on those known routes for compatibility.
|
||||
const knownAnthropicRoute = resolveRouteIdFromBaseUrl(
|
||||
processEnv.ANTHROPIC_BASE_URL,
|
||||
)
|
||||
if (
|
||||
!isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI) &&
|
||||
hasNonEmptyEnvValue(processEnv.ANTHROPIC_BASE_URL) &&
|
||||
hasNonEmptyEnvValue(processEnv.ANTHROPIC_MODEL) &&
|
||||
(hasNonEmptyEnvValue(processEnv.ANTHROPIC_AUTH_TOKEN) ||
|
||||
hasNonEmptyEnvValue(processEnv.ANTHROPIC_API_KEY)) &&
|
||||
!isFirstPartyAnthropicBaseUrlForEnv(processEnv) &&
|
||||
(hasNonEmptyEnvValue(processEnv.ANTHROPIC_AUTH_TOKEN) ||
|
||||
knownAnthropicRoute === 'custom-anthropic' ||
|
||||
!knownAnthropicRoute)
|
||||
) {
|
||||
return 'custom-anthropic'
|
||||
}
|
||||
|
||||
const envOnlyRouteId = resolveEnvOnlyProviderRouteId(processEnv)
|
||||
if (envOnlyRouteId) return envOnlyRouteId
|
||||
|
||||
|
||||
+6
-3
@@ -58,6 +58,7 @@ import { clampUltracodeEffort, getInitialEffortSetting, parseEffortValue } from
|
||||
import { getInitialFastModeSetting, isFastModeEnabled, prefetchFastModeStatus, resolveFastModeStatusFromCache } from './utils/fastMode.js';
|
||||
import { applyConfigEnvironmentVariables } from './utils/managedEnv.js';
|
||||
import { createSystemMessage, createUserMessage } from './utils/messages.js';
|
||||
import { isFirstPartyAnthropicBaseUrl } from './utils/model/providers.js';
|
||||
import { getPlatform } from './utils/platform.js';
|
||||
import { getBaseRenderOptions } from './utils/renderOptions.js';
|
||||
import { getSessionIngressAuthToken } from './utils/sessionIngressAuth.js';
|
||||
@@ -1286,10 +1287,12 @@ async function run(): Promise<CommanderCommand> {
|
||||
const fileSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID || getSessionId();
|
||||
const files = parseFileSpecs(fileSpecs);
|
||||
if (files.length > 0) {
|
||||
// Use ANTHROPIC_BASE_URL if set (by EnvManager), otherwise use OAuth config
|
||||
// This ensures consistency with session ingress API in all environments
|
||||
// Session ingress credentials are only valid for the first-party Files API.
|
||||
// A custom Anthropic endpoint must never receive this bearer token.
|
||||
const config: FilesApiConfig = {
|
||||
baseUrl: process.env.ANTHROPIC_BASE_URL || getOauthConfig().BASE_API_URL,
|
||||
baseUrl: isFirstPartyAnthropicBaseUrl()
|
||||
? process.env.ANTHROPIC_BASE_URL || getOauthConfig().BASE_API_URL
|
||||
: getOauthConfig().BASE_API_URL,
|
||||
oauthToken: sessionToken,
|
||||
sessionId: fileSessionId
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from '../services/analytics/index.js'
|
||||
import { saveGlobalConfig } from '../utils/config.js'
|
||||
import { isLegacyModelRemapEnabled } from '../utils/model/model.js'
|
||||
import { getAPIProvider } from '../utils/model/providers.js'
|
||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from '../utils/model/providers.js'
|
||||
import {
|
||||
getSettingsForSource,
|
||||
updateSettingsForSource,
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
* project.
|
||||
*/
|
||||
export function migrateLegacyOpusToCurrent(): void {
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
if (getAPIProvider() !== 'firstParty' || !isFirstPartyAnthropicBaseUrl()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
setMainLoopModelOverride,
|
||||
} from '../bootstrap/state.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
|
||||
import { getAPIProvider } from '../utils/model/providers.js'
|
||||
import {
|
||||
isFirstPartyAnthropicProvider,
|
||||
} from '../utils/model/providers.js'
|
||||
import {
|
||||
getSettingsForSource,
|
||||
updateSettingsForSource,
|
||||
@@ -24,7 +26,7 @@ import {
|
||||
* tracked by a completion flag in global config.
|
||||
*/
|
||||
export function migrateSonnet1mToSonnet45(): void {
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
if (!isFirstPartyAnthropicProvider()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
isTeamPremiumSubscriber,
|
||||
} from '../utils/auth.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
|
||||
import { getAPIProvider } from '../utils/model/providers.js'
|
||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from '../utils/model/providers.js'
|
||||
import {
|
||||
getSettingsForSource,
|
||||
updateSettingsForSource,
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
* Idempotent: only writes if userSettings.model matches a Sonnet 4.5 string.
|
||||
*/
|
||||
export function migrateSonnet45ToSonnet46(): void {
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
if (getAPIProvider() !== 'firstParty' || !isFirstPartyAnthropicBaseUrl()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { isProSubscriber } from '../utils/auth.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
|
||||
import { getAPIProvider } from '../utils/model/providers.js'
|
||||
import {
|
||||
getAPIProvider,
|
||||
isFirstPartyAnthropicBaseUrl,
|
||||
} from '../utils/model/providers.js'
|
||||
import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
|
||||
|
||||
export function resetProToOpusDefault(): void {
|
||||
@@ -23,6 +26,15 @@ export function resetProToOpusDefault(): void {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isFirstPartyAnthropicBaseUrl()) {
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
opusProMigrationComplete: true,
|
||||
}))
|
||||
logEvent('tengu_reset_pro_to_opus_default', { skipped: true })
|
||||
return
|
||||
}
|
||||
|
||||
const settings = getSettings_DEPRECATED()
|
||||
|
||||
// Only show notification if user was on default (no custom model setting)
|
||||
|
||||
@@ -27,3 +27,22 @@ export function shouldUseFirstPartyAnthropicAuth(
|
||||
isFirstPartyBaseUrl: isFirstPartyAnthropicBaseUrl(),
|
||||
})
|
||||
}
|
||||
|
||||
export function shouldUseCustomAnthropicBearerAuth({
|
||||
providerOverride,
|
||||
apiProvider,
|
||||
isFirstPartyBaseUrl,
|
||||
authToken,
|
||||
}: {
|
||||
providerOverride?: ProviderOverride
|
||||
apiProvider: APIProvider
|
||||
isFirstPartyBaseUrl: boolean
|
||||
authToken?: string
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
!providerOverride &&
|
||||
authToken?.trim() &&
|
||||
apiProvider === 'firstParty' &&
|
||||
!isFirstPartyBaseUrl,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ import { logForDebugging } from '../../utils/debug.js'
|
||||
import { withOAuth401Retry } from '../../utils/http.js'
|
||||
import { lazySchema } from '../../utils/lazySchema.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { getAPIProvider } from '../../utils/model/providers.js'
|
||||
import {
|
||||
getAPIProvider,
|
||||
isFirstPartyAnthropicBaseUrl,
|
||||
} from '../../utils/model/providers.js'
|
||||
import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js'
|
||||
import type { ModelOption } from '../../utils/model/modelOptions.js'
|
||||
import {
|
||||
@@ -133,7 +136,7 @@ async function fetchBootstrapAPI(): Promise<BootstrapResponse | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
if (getAPIProvider() !== 'firstParty' || !isFirstPartyAnthropicBaseUrl()) {
|
||||
logForDebugging('[Bootstrap] Skipped: 3P provider')
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -352,7 +352,12 @@ export function getPromptCachingEnabled(model: string): boolean {
|
||||
// format, so cache_control blocks are supported.
|
||||
const provider = getAPIProvider()
|
||||
const isNativeGithub = isGithubNativeAnthropicMode(model)
|
||||
if (provider !== 'firstParty' && provider !== 'bedrock' && provider !== 'vertex' && !isNativeGithub) {
|
||||
if (
|
||||
(provider !== 'firstParty' || !isFirstPartyAnthropicBaseUrl()) &&
|
||||
provider !== 'bedrock' &&
|
||||
provider !== 'vertex' &&
|
||||
!isNativeGithub
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1113,6 +1118,7 @@ async function* queryModel(
|
||||
// init (~10ms). For non-Opus models (haiku, sonnet) this skips the await
|
||||
// entirely. Subscribers don't hit this path at all.
|
||||
if (
|
||||
isFirstPartyAnthropicBaseUrl() &&
|
||||
!isClaudeAISubscriber() &&
|
||||
isNonCustomOpusModel(options.model) &&
|
||||
(
|
||||
@@ -1544,6 +1550,7 @@ async function* queryModel(
|
||||
!cacheEditingHeaderLatched &&
|
||||
cachedMCEnabled &&
|
||||
getAPIProvider() === 'firstParty' &&
|
||||
isFirstPartyAnthropicBaseUrl() &&
|
||||
options.querySource === 'repl_main_thread'
|
||||
) {
|
||||
cacheEditingHeaderLatched = true
|
||||
@@ -1783,10 +1790,12 @@ async function* queryModel(
|
||||
const useCachedMC =
|
||||
cachedMCEnabled &&
|
||||
getAPIProvider() === 'firstParty' &&
|
||||
isFirstPartyAnthropicBaseUrl() &&
|
||||
options.querySource === 'repl_main_thread'
|
||||
if (
|
||||
cacheEditingHeaderLatched &&
|
||||
getAPIProvider() === 'firstParty' &&
|
||||
isFirstPartyAnthropicBaseUrl() &&
|
||||
options.querySource === 'repl_main_thread' &&
|
||||
!betasParams.includes(cacheEditingBetaHeader)
|
||||
) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { shouldUseFirstPartyAnthropicAuthForProvider } from './authRouting.js'
|
||||
import {
|
||||
shouldUseCustomAnthropicBearerAuth,
|
||||
shouldUseFirstPartyAnthropicAuthForProvider,
|
||||
} from './authRouting.js'
|
||||
|
||||
const providerOverride = {
|
||||
model: 'gpt-4o',
|
||||
@@ -43,3 +46,34 @@ test('custom Anthropic base URLs do not use first-party Anthropic auth', () => {
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('custom Anthropic bearer tokens use native custom authentication', () => {
|
||||
expect(
|
||||
shouldUseCustomAnthropicBearerAuth({
|
||||
apiProvider: 'firstParty',
|
||||
isFirstPartyBaseUrl: false,
|
||||
authToken: 'custom-token',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('providerOverride routing does not use custom Anthropic bearer auth', () => {
|
||||
expect(
|
||||
shouldUseCustomAnthropicBearerAuth({
|
||||
providerOverride,
|
||||
apiProvider: 'firstParty',
|
||||
isFirstPartyBaseUrl: false,
|
||||
authToken: 'custom-token',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('custom Anthropic bearer tokens are never forwarded to shim routes', () => {
|
||||
expect(
|
||||
shouldUseCustomAnthropicBearerAuth({
|
||||
apiProvider: 'gemini',
|
||||
isFirstPartyBaseUrl: false,
|
||||
authToken: 'custom-token',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
|
||||
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js'
|
||||
import {
|
||||
_clearRegistryForTesting,
|
||||
@@ -6,7 +6,25 @@ import {
|
||||
registerGateway,
|
||||
} from '../../integrations/index.js'
|
||||
import { publicBuildVersion } from '../../utils/version.js'
|
||||
import { getAnthropicClient } from './client.js'
|
||||
|
||||
// bun:test keeps mock.module() registrations process-global across test files.
|
||||
// Load and re-register the real module before importing the client so a prior
|
||||
// provider mock cannot make this suite validate the wrong route in CI.
|
||||
const _realProvidersModule = await import(
|
||||
`../../utils/model/providers.js?real=${Date.now()}-${Math.random()}`,
|
||||
)
|
||||
const realProviders = {
|
||||
getAPIProvider: _realProvidersModule.getAPIProvider,
|
||||
usesAnthropicAccountFlow: _realProvidersModule.usesAnthropicAccountFlow,
|
||||
isGithubNativeAnthropicMode: _realProvidersModule.isGithubNativeAnthropicMode,
|
||||
getAPIProviderForStatsig: _realProvidersModule.getAPIProviderForStatsig,
|
||||
isFirstPartyAnthropicBaseUrl: _realProvidersModule.isFirstPartyAnthropicBaseUrl,
|
||||
}
|
||||
mock.module('../../utils/model/providers.js', () => realProviders)
|
||||
mock.module('src/utils/model/providers.js', () => realProviders)
|
||||
const { getAnthropicClient } = await import(
|
||||
`./client.js?real=${Date.now()}-${Math.random()}`,
|
||||
)
|
||||
|
||||
type FetchType = typeof globalThis.fetch
|
||||
|
||||
@@ -55,6 +73,8 @@ const originalEnv = {
|
||||
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
|
||||
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
|
||||
ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS,
|
||||
USER_TYPE: process.env.USER_TYPE,
|
||||
USE_STAGING_OAUTH: process.env.USE_STAGING_OAUTH,
|
||||
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED:
|
||||
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED,
|
||||
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID:
|
||||
@@ -100,11 +120,13 @@ function clearEnvForMiniMaxOnlyTest(): void {
|
||||
delete process.env.AIMLAPI_API_KEY
|
||||
delete process.env.NVIDIA_NIM
|
||||
delete process.env.NVIDIA_API_KEY
|
||||
delete process.env.ANTHROPIC_API_KEY
|
||||
process.env.ANTHROPIC_API_KEY = 'must-not-forward'
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN
|
||||
delete process.env.ANTHROPIC_BASE_URL
|
||||
delete process.env.ANTHROPIC_MODEL
|
||||
delete process.env.ANTHROPIC_CUSTOM_HEADERS
|
||||
delete process.env.USER_TYPE
|
||||
delete process.env.USE_STAGING_OAUTH
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -140,7 +162,7 @@ beforeEach(async () => {
|
||||
delete process.env.OPENAI_AUTH_HEADER_VALUE
|
||||
delete process.env.NVIDIA_NIM
|
||||
delete process.env.NVIDIA_API_KEY
|
||||
delete process.env.ANTHROPIC_API_KEY
|
||||
process.env.ANTHROPIC_API_KEY = 'must-not-forward'
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN
|
||||
delete process.env.ANTHROPIC_BASE_URL
|
||||
delete process.env.ANTHROPIC_MODEL
|
||||
@@ -189,6 +211,8 @@ afterEach(() => {
|
||||
restoreEnv('ANTHROPIC_BASE_URL', originalEnv.ANTHROPIC_BASE_URL)
|
||||
restoreEnv('ANTHROPIC_MODEL', originalEnv.ANTHROPIC_MODEL)
|
||||
restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS)
|
||||
restoreEnv('USER_TYPE', originalEnv.USER_TYPE)
|
||||
restoreEnv('USE_STAGING_OAUTH', originalEnv.USE_STAGING_OAUTH)
|
||||
restoreEnv(
|
||||
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED',
|
||||
originalEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED,
|
||||
@@ -226,7 +250,7 @@ test('first-party Anthropic requests execute the configured fetch wrapper withou
|
||||
delete process.env.XAI_API_KEY
|
||||
delete process.env.MIMO_API_KEY
|
||||
delete process.env.VENICE_API_KEY
|
||||
delete process.env.ANTHROPIC_API_KEY
|
||||
process.env.ANTHROPIC_API_KEY = 'must-not-forward'
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN
|
||||
delete process.env.ANTHROPIC_BASE_URL
|
||||
delete process.env.ANTHROPIC_MODEL
|
||||
@@ -278,11 +302,150 @@ test('first-party Anthropic requests execute the configured fetch wrapper withou
|
||||
expect(capturedHeaders).toBeDefined()
|
||||
})
|
||||
|
||||
test('routes a custom Anthropic endpoint with ANTHROPIC_AUTH_TOKEN without requiring an API key', async () => {
|
||||
let capturedUrl: string | undefined
|
||||
let capturedHeaders: Headers | undefined
|
||||
|
||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.CLAUDE_CODE_USE_MISTRAL
|
||||
process.env.ANTHROPIC_API_KEY = 'must-not-forward'
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'custom-anthropic-token'
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://anthropic.example/api/v1'
|
||||
process.env.USER_TYPE = 'ant'
|
||||
process.env.USE_STAGING_OAUTH = '1'
|
||||
process.env.ANTHROPIC_CUSTOM_HEADERS = 'X-Tenant: tenant-a\nauthorization: stale-value'
|
||||
|
||||
const fetchOverride = (async (input, init) => {
|
||||
capturedUrl =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url
|
||||
capturedHeaders = new Headers(init?.headers)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'msg_custom_anthropic',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-sonnet-4-6',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
stop_reason: 'end_turn',
|
||||
stop_sequence: null,
|
||||
container: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}) as FetchType
|
||||
|
||||
const client = await getAnthropicClient({
|
||||
maxRetries: 0,
|
||||
model: 'claude-sonnet-4-6',
|
||||
fetchOverride,
|
||||
})
|
||||
|
||||
await client.messages.create({
|
||||
model: 'claude-sonnet-4-6',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
max_tokens: 64,
|
||||
})
|
||||
|
||||
expect(capturedUrl).toBe('https://anthropic.example/api/v1/messages')
|
||||
expect(capturedHeaders?.get('authorization')).toBe('Bearer custom-anthropic-token')
|
||||
expect(capturedHeaders?.get('x-api-key')).toBeNull()
|
||||
expect(capturedHeaders?.get('x-tenant')).toBe('tenant-a')
|
||||
})
|
||||
|
||||
test('does not forward a custom bearer token to the first-party Anthropic endpoint', async () => {
|
||||
let capturedHeaders: Headers | undefined
|
||||
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.GEMINI_API_KEY
|
||||
delete process.env.GEMINI_MODEL
|
||||
delete process.env.GEMINI_BASE_URL
|
||||
delete process.env.GEMINI_AUTH_MODE
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'custom-anthropic-token'
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com'
|
||||
|
||||
const fetchOverride = (async (_input, init) => {
|
||||
capturedHeaders = new Headers(init?.headers)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'msg_first_party_key', type: 'message', role: 'assistant',
|
||||
model: 'claude-sonnet-4-6', content: [{ type: 'text', text: 'ok' }],
|
||||
stop_reason: 'end_turn', stop_sequence: null, container: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}) as FetchType
|
||||
|
||||
const client = await getAnthropicClient({
|
||||
apiKey: 'first-party-api-key',
|
||||
maxRetries: 0,
|
||||
model: 'claude-sonnet-4-6',
|
||||
fetchOverride,
|
||||
})
|
||||
await client.messages.create({
|
||||
model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: 'hello' }], max_tokens: 64,
|
||||
})
|
||||
|
||||
expect(capturedHeaders?.get('authorization')).toBeNull()
|
||||
expect(capturedHeaders?.get('x-api-key')).toBe('first-party-api-key')
|
||||
})
|
||||
|
||||
test('routes a custom Anthropic endpoint with native x-api-key authentication', async () => {
|
||||
let capturedHeaders: Headers | undefined
|
||||
|
||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.CLAUDE_CODE_USE_MISTRAL
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN
|
||||
process.env.ANTHROPIC_API_KEY = 'custom-anthropic-api-key'
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://anthropic.example/api'
|
||||
process.env.ANTHROPIC_CUSTOM_HEADERS =
|
||||
'X-Tenant: tenant-a\nauthorization: stale-value\nx-api-key: stale-key'
|
||||
|
||||
const fetchOverride = (async (_input, init) => {
|
||||
capturedHeaders = new Headers(init?.headers)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'msg_custom_anthropic_key', type: 'message', role: 'assistant',
|
||||
model: 'claude-sonnet-4-6', content: [{ type: 'text', text: 'ok' }],
|
||||
stop_reason: 'end_turn', stop_sequence: null, container: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}) as FetchType
|
||||
|
||||
const client = await getAnthropicClient({ maxRetries: 0, model: 'claude-sonnet-4-6', fetchOverride })
|
||||
await client.messages.create({
|
||||
model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: 'hello' }], max_tokens: 64,
|
||||
})
|
||||
|
||||
expect(capturedHeaders?.get('x-api-key')).toBe('custom-anthropic-api-key')
|
||||
expect(capturedHeaders?.get('authorization')).toBeNull()
|
||||
expect(capturedHeaders?.get('x-tenant')).toBe('tenant-a')
|
||||
})
|
||||
|
||||
test('routes Gemini provider requests through the OpenAI-compatible shim', async () => {
|
||||
let capturedUrl: string | undefined
|
||||
let capturedHeaders: Headers | undefined
|
||||
let capturedBody: Record<string, unknown> | undefined
|
||||
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'must-not-reach-gemini'
|
||||
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
capturedUrl =
|
||||
typeof input === 'string'
|
||||
|
||||
+68
-14
@@ -49,7 +49,8 @@ import {
|
||||
} from '../../integrations/routeMetadata.js'
|
||||
import { resolveOpenAIShimRuntimeContext } from '../../integrations/runtimeMetadata.js'
|
||||
import {
|
||||
shouldUseFirstPartyAnthropicAuth,
|
||||
shouldUseCustomAnthropicBearerAuth,
|
||||
shouldUseFirstPartyAnthropicAuthForProvider,
|
||||
type ProviderOverride,
|
||||
} from './authRouting.js'
|
||||
import { AnthropicVertex } from './vertexClient.js'
|
||||
@@ -465,8 +466,13 @@ export async function getAnthropicClient({
|
||||
applyAimlapiEnvOnlyDefaults()
|
||||
}
|
||||
|
||||
const shouldUseFirstPartyAuth =
|
||||
shouldUseFirstPartyAnthropicAuth(providerOverride)
|
||||
const apiProvider = getAPIProvider()
|
||||
const isFirstPartyBaseUrl = isFirstPartyAnthropicBaseUrl()
|
||||
const shouldUseFirstPartyAuth = shouldUseFirstPartyAnthropicAuthForProvider({
|
||||
providerOverride,
|
||||
apiProvider,
|
||||
isFirstPartyBaseUrl,
|
||||
})
|
||||
const useMiniMaxNativeProvider =
|
||||
useMiniMaxEnvOnlyProvider ||
|
||||
(getAPIProvider() === 'minimax' &&
|
||||
@@ -480,9 +486,29 @@ export async function getAnthropicClient({
|
||||
|
||||
const isClaudeAiSubscriber =
|
||||
shouldUseFirstPartyAuth && isClaudeAISubscriber()
|
||||
const anthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN?.trim()
|
||||
const usesCustomAnthropicAuthToken = shouldUseCustomAnthropicBearerAuth({
|
||||
providerOverride,
|
||||
apiProvider,
|
||||
isFirstPartyBaseUrl,
|
||||
authToken: anthropicAuthToken,
|
||||
})
|
||||
|
||||
if (shouldUseFirstPartyAuth && !isClaudeAiSubscriber) {
|
||||
await configureApiKeyHeaders(defaultHeaders, getIsNonInteractiveSession())
|
||||
if (
|
||||
(shouldUseFirstPartyAuth && !isClaudeAiSubscriber) ||
|
||||
usesCustomAnthropicAuthToken
|
||||
) {
|
||||
await configureApiKeyHeaders(
|
||||
defaultHeaders,
|
||||
getIsNonInteractiveSession(),
|
||||
usesCustomAnthropicAuthToken ? anthropicAuthToken : undefined,
|
||||
)
|
||||
} else if (apiProvider === 'firstParty' && !isFirstPartyBaseUrl) {
|
||||
removeManagedAnthropicAuthHeaders(defaultHeaders)
|
||||
const anthropicApiKey = process.env.ANTHROPIC_API_KEY?.trim()
|
||||
if (anthropicApiKey) {
|
||||
defaultHeaders['X-Api-Key'] = anthropicApiKey
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedFetch = buildFetch(fetchOverride, source)
|
||||
@@ -733,20 +759,28 @@ export async function getAnthropicClient({
|
||||
|
||||
// Determine authentication method based on available tokens
|
||||
const clientConfig: ConstructorParameters<typeof Anthropic>[0] = {
|
||||
apiKey: isClaudeAiSubscriber
|
||||
apiKey: isClaudeAiSubscriber || usesCustomAnthropicAuthToken
|
||||
? null
|
||||
: useMiniMaxNativeProvider
|
||||
? process.env.MINIMAX_API_KEY || process.env.ANTHROPIC_API_KEY
|
||||
: apiKey || getAnthropicApiKey(),
|
||||
: apiKey ||
|
||||
(!isFirstPartyBaseUrl
|
||||
? process.env.ANTHROPIC_API_KEY?.trim()
|
||||
: getAnthropicApiKey()),
|
||||
// Pass an explicit null for non-Bearer routes so the SDK cannot fall back
|
||||
// to ANTHROPIC_AUTH_TOKEN from its own environment lookup.
|
||||
authToken: isClaudeAiSubscriber
|
||||
? getClaudeAIOAuthTokens()?.accessToken
|
||||
: undefined,
|
||||
: usesCustomAnthropicAuthToken
|
||||
? anthropicAuthToken
|
||||
: null,
|
||||
// Set baseURL from OAuth config when using staging OAuth
|
||||
...(process.env.USER_TYPE === 'ant' &&
|
||||
...(shouldUseFirstPartyAuth &&
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
isEnvTruthy(process.env.USE_STAGING_OAUTH)
|
||||
? { baseURL: getOauthConfig().BASE_API_URL }
|
||||
: process.env.ANTHROPIC_BASE_URL
|
||||
? { baseURL: process.env.ANTHROPIC_BASE_URL }
|
||||
? { baseURL: process.env.ANTHROPIC_BASE_URL.replace(/\/v1\/?$/i, '') }
|
||||
: {}),
|
||||
...ARGS,
|
||||
...(isDebugToStdErr() && { logger: createStderrLogger() }),
|
||||
@@ -758,15 +792,29 @@ export async function getAnthropicClient({
|
||||
async function configureApiKeyHeaders(
|
||||
headers: Record<string, string>,
|
||||
isNonInteractiveSession: boolean,
|
||||
authToken?: string,
|
||||
): Promise<void> {
|
||||
const token =
|
||||
process.env.ANTHROPIC_AUTH_TOKEN ||
|
||||
(await getApiKeyFromApiKeyHelper(isNonInteractiveSession))
|
||||
const token = authToken || (await getApiKeyFromApiKeyHelper(isNonInteractiveSession))
|
||||
if (token) {
|
||||
removeManagedAnthropicAuthHeaders(headers)
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
function removeManagedAnthropicAuthHeaders(headers: Record<string, string>): void {
|
||||
for (const name of Object.keys(headers)) {
|
||||
const lower = name.toLowerCase()
|
||||
if (lower === 'authorization' || lower === 'x-api-key' || lower === 'api-key') {
|
||||
delete headers[name]
|
||||
}
|
||||
}
|
||||
// The Anthropic SDK also reads ANTHROPIC_CUSTOM_HEADERS. Null sentinels clear
|
||||
// those env-parsed managed auth headers before the supported credential wins.
|
||||
headers.Authorization = null as unknown as string
|
||||
headers['X-Api-Key'] = null as unknown as string
|
||||
headers['api-key'] = null as unknown as string
|
||||
}
|
||||
|
||||
function getCustomHeaders(): Record<string, string> {
|
||||
const customHeaders: Record<string, string> = {}
|
||||
const customHeadersEnv = process.env.ANTHROPIC_CUSTOM_HEADERS
|
||||
@@ -784,7 +832,13 @@ function getCustomHeaders(): Record<string, string> {
|
||||
if (colonIdx === -1) continue
|
||||
const name = headerString.slice(0, colonIdx).trim()
|
||||
const value = headerString.slice(colonIdx + 1).trim()
|
||||
if (name) {
|
||||
const lowerName = name.toLowerCase()
|
||||
if (
|
||||
name &&
|
||||
lowerName !== 'authorization' &&
|
||||
lowerName !== 'x-api-key' &&
|
||||
lowerName !== 'api-key'
|
||||
) {
|
||||
customHeaders[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,11 @@ import {
|
||||
isNonCustomOpusModel,
|
||||
} from 'src/utils/model/model.js'
|
||||
import { getModelStrings } from 'src/utils/model/modelStrings.js'
|
||||
import { getAPIProvider } from 'src/utils/model/providers.js'
|
||||
import {
|
||||
getAPIProvider,
|
||||
isFirstPartyAnthropicBaseUrl,
|
||||
isFirstPartyAnthropicProvider,
|
||||
} from 'src/utils/model/providers.js'
|
||||
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
|
||||
import {
|
||||
API_PDF_MAX_PAGES,
|
||||
@@ -373,7 +377,7 @@ export const CCR_AUTH_ERROR_MESSAGE =
|
||||
'Authentication error · This may be a temporary network issue, please try again'
|
||||
export const REPEATED_529_ERROR_MESSAGE = 'Repeated 529 Overloaded errors'
|
||||
export function getCustomOffSwitchMessage(): string {
|
||||
return getAPIProvider() === 'firstParty'
|
||||
return isFirstPartyAnthropicProvider()
|
||||
? 'Opus is experiencing high load, please use /model to switch to Sonnet'
|
||||
: 'The API is experiencing high load, please try again shortly or use /model to switch models'
|
||||
}
|
||||
@@ -1200,7 +1204,7 @@ export function getAssistantMessageFromError(
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.toLowerCase().includes('x-api-key') &&
|
||||
getAPIProvider() === 'firstParty'
|
||||
isFirstPartyAnthropicProvider()
|
||||
) {
|
||||
// In CCR mode, auth is via JWTs - this is likely a transient network issue
|
||||
if (isCCRMode()) {
|
||||
@@ -1266,7 +1270,9 @@ export function getAssistantMessageFromError(
|
||||
error: 'authentication_failed',
|
||||
content: getIsNonInteractiveSession()
|
||||
? `Failed to authenticate. ${API_ERROR_MESSAGE_PREFIX}: Authentication failed (status ${error.status}). Check your API key configuration.`
|
||||
: `Please run /login · ${API_ERROR_MESSAGE_PREFIX}: Authentication failed (status ${error.status}). Check your API key configuration.`,
|
||||
: isFirstPartyAnthropicProvider()
|
||||
? `Please run /login · ${API_ERROR_MESSAGE_PREFIX}: Authentication failed (status ${error.status}). Check your API key configuration.`
|
||||
: `${API_ERROR_MESSAGE_PREFIX}: Authentication failed (status ${error.status}). Check your provider credential configuration.`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1351,7 +1357,7 @@ export function getAssistantMessageFromError(
|
||||
* Returns a model name suggestion, or undefined if no suggestion is applicable.
|
||||
*/
|
||||
function get3PModelFallbackSuggestion(model: string): string | undefined {
|
||||
if (getAPIProvider() === 'firstParty') {
|
||||
if (isFirstPartyAnthropicProvider()) {
|
||||
return undefined
|
||||
}
|
||||
// @[MODEL LAUNCH]: Add a fallback suggestion chain for the new model → previous version for 3P
|
||||
@@ -1618,7 +1624,7 @@ export function getErrorMessageIfRefusal(
|
||||
logEvent('tengu_refusal_api_response', {})
|
||||
|
||||
const usagePolicyUrl =
|
||||
getAPIProvider() === 'firstParty'
|
||||
isFirstPartyAnthropicProvider()
|
||||
? 'https://www.anthropic.com/legal/aup'
|
||||
: "your provider's acceptable use policy"
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../utils/http.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { getClaudeCodeUserAgent } from '../../utils/userAgent.js'
|
||||
import { isFirstPartyAnthropicProvider } from '../../utils/model/providers.js'
|
||||
|
||||
// Cache expiration: 24 hours
|
||||
const GROVE_CACHE_EXPIRATION_MS = 24 * 60 * 60 * 1000
|
||||
@@ -41,6 +42,13 @@ export type GroveConfig = {
|
||||
*/
|
||||
export type ApiResult<T> = { success: true; data: T } | { success: false }
|
||||
|
||||
function getGroveCacheKey(): string {
|
||||
// Provider selection can change during a process lifetime. Keep first-party
|
||||
// responses separate from the custom-provider no-op result so switching
|
||||
// endpoints cannot surface stale account settings or disable Grove later.
|
||||
return `${isFirstPartyAnthropicProvider()}:${process.env.ANTHROPIC_BASE_URL ?? ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Grove settings for the user account.
|
||||
* Returns ApiResult to distinguish between API failure and success.
|
||||
@@ -51,6 +59,9 @@ export type ApiResult<T> = { success: true; data: T } | { success: false }
|
||||
*/
|
||||
export const getGroveSettings = memoize(
|
||||
async (): Promise<ApiResult<AccountSettings>> => {
|
||||
if (!isFirstPartyAnthropicProvider()) {
|
||||
return { success: false }
|
||||
}
|
||||
// Grove is a notification feature; during an outage, skipping it is correct.
|
||||
if (isEssentialTrafficOnly()) {
|
||||
return { success: false }
|
||||
@@ -82,12 +93,14 @@ export const getGroveSettings = memoize(
|
||||
return { success: false }
|
||||
}
|
||||
},
|
||||
getGroveCacheKey,
|
||||
)
|
||||
|
||||
/**
|
||||
* Mark that the Grove notice has been viewed by the user
|
||||
*/
|
||||
export async function markGroveNoticeViewed(): Promise<void> {
|
||||
if (!isFirstPartyAnthropicProvider()) return
|
||||
try {
|
||||
await withOAuth401Retry(() => {
|
||||
const authHeaders = getAuthHeaders()
|
||||
@@ -120,6 +133,7 @@ export async function markGroveNoticeViewed(): Promise<void> {
|
||||
export async function updateGroveSettings(
|
||||
groveEnabled: boolean,
|
||||
): Promise<void> {
|
||||
if (!isFirstPartyAnthropicProvider()) return
|
||||
try {
|
||||
await withOAuth401Retry(() => {
|
||||
const authHeaders = getAuthHeaders()
|
||||
@@ -155,6 +169,9 @@ export async function updateGroveSettings(
|
||||
* false and the Grove dialog won't show until the next session.
|
||||
*/
|
||||
export async function isQualifiedForGrove(): Promise<boolean> {
|
||||
if (!isFirstPartyAnthropicProvider()) {
|
||||
return false
|
||||
}
|
||||
if (!isConsumerSubscriber()) {
|
||||
return false
|
||||
}
|
||||
@@ -232,6 +249,9 @@ async function fetchAndStoreGroveConfig(accountId: string): Promise<void> {
|
||||
*/
|
||||
export const getGroveNoticeConfig = memoize(
|
||||
async (): Promise<ApiResult<GroveConfig>> => {
|
||||
if (!isFirstPartyAnthropicProvider()) {
|
||||
return { success: false }
|
||||
}
|
||||
// Grove is a notification feature; during an outage, skipping it is correct.
|
||||
if (isEssentialTrafficOnly()) {
|
||||
return { success: false }
|
||||
@@ -276,6 +296,7 @@ export const getGroveNoticeConfig = memoize(
|
||||
return { success: false }
|
||||
}
|
||||
},
|
||||
getGroveCacheKey,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js'
|
||||
import { isFirstPartyAnthropicProvider } from '../../utils/model/providers.js'
|
||||
import { getOAuthHeaders, prepareApiRequest } from '../../utils/teleport/api.js'
|
||||
import type {
|
||||
ReferralCampaign,
|
||||
@@ -70,6 +71,7 @@ export async function fetchReferralRedemptions(
|
||||
*/
|
||||
function shouldCheckForPasses(): boolean {
|
||||
return !!(
|
||||
isFirstPartyAnthropicProvider() &&
|
||||
getOauthAccountInfo()?.organizationUuid &&
|
||||
isClaudeAISubscriber() &&
|
||||
getSubscriptionType() === 'max'
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../../utils/auth.js'
|
||||
import { getAuthHeaders } from '../../utils/http.js'
|
||||
import { getClaudeCodeUserAgent } from '../../utils/userAgent.js'
|
||||
import { isFirstPartyAnthropicProvider } from '../../utils/model/providers.js'
|
||||
import { isOAuthTokenExpired } from '../oauth/client.js'
|
||||
|
||||
export type RateLimit = {
|
||||
@@ -31,6 +32,9 @@ export type Utilization = {
|
||||
}
|
||||
|
||||
export async function fetchUtilization(): Promise<Utilization | null> {
|
||||
if (!isFirstPartyAnthropicProvider()) {
|
||||
return {}
|
||||
}
|
||||
if (!isClaudeAISubscriber() || !hasProfileScope()) {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getModelBetas } from '../utils/betas.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
import { getSmallFastModel } from '../utils/model/model.js'
|
||||
import { getAPIProvider } from '../utils/model/providers.js'
|
||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from '../utils/model/providers.js'
|
||||
import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'
|
||||
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from './analytics/index.js'
|
||||
import { logEvent } from './analytics/index.js'
|
||||
@@ -224,7 +224,7 @@ export async function checkQuotaStatus(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
if (getAPIProvider() !== 'firstParty' || !isFirstPartyAnthropicBaseUrl()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@ import { getClaudeAIOAuthTokens } from 'src/utils/auth.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import { isEnvDefinedFalsy } from 'src/utils/envUtils.js'
|
||||
import { getAPIProvider } from 'src/utils/model/providers.js'
|
||||
import {
|
||||
getAPIProvider,
|
||||
isFirstPartyAnthropicBaseUrl,
|
||||
} from 'src/utils/model/providers.js'
|
||||
import { clearMcpAuthCache } from './client.js'
|
||||
import { normalizeNameForMCP } from './normalization.js'
|
||||
import type { ScopedMcpServerConfig } from './types.js'
|
||||
@@ -31,6 +34,13 @@ type ClaudeAIMcpServersResponse = {
|
||||
const FETCH_TIMEOUT_MS = 5000
|
||||
const MCP_SERVERS_BETA_HEADER = 'mcp-servers-2025-12-04'
|
||||
|
||||
function getClaudeAIMcpConfigsCacheKey(): string {
|
||||
// Provider selection can change during a process lifetime. Keep the
|
||||
// first-party result separate from a custom-endpoint no-op so an in-process
|
||||
// switch cannot expose stale organization MCP configuration.
|
||||
return `${getAPIProvider()}:${process.env.ANTHROPIC_BASE_URL ?? ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches MCP server configurations from Claude.ai org configs.
|
||||
* These servers are managed by the organization via Claude.ai.
|
||||
@@ -48,6 +58,14 @@ export const fetchClaudeAIMcpConfigsIfEligible = memoize(
|
||||
})
|
||||
return {}
|
||||
}
|
||||
if (!isFirstPartyAnthropicBaseUrl()) {
|
||||
logForDebugging('[claudeai-mcp] Skipped: non-first-party base URL')
|
||||
logEvent('tengu_claudeai_mcp_eligibility', {
|
||||
state:
|
||||
'non_first_party_base_url' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
return {}
|
||||
}
|
||||
|
||||
if (isEnvDefinedFalsy(process.env.ENABLE_CLAUDEAI_MCP_SERVERS)) {
|
||||
logForDebugging('[claudeai-mcp] Disabled via env var')
|
||||
@@ -141,6 +159,7 @@ export const fetchClaudeAIMcpConfigsIfEligible = memoize(
|
||||
return {}
|
||||
}
|
||||
},
|
||||
getClaudeAIMcpConfigsCacheKey,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import axios from 'axios'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { getAPIProvider } from '../../utils/model/providers.js'
|
||||
import {
|
||||
getAPIProvider,
|
||||
isFirstPartyAnthropicBaseUrl,
|
||||
} from '../../utils/model/providers.js'
|
||||
|
||||
type RegistryServer = {
|
||||
server: {
|
||||
@@ -37,7 +40,8 @@ export async function prefetchOfficialMcpUrls(): Promise<void> {
|
||||
}
|
||||
|
||||
// The official first-party MCP registry is only relevant for first-party mode.
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
if (getAPIProvider() !== 'firstParty' || !isFirstPartyAnthropicBaseUrl()) {
|
||||
officialUrls = undefined
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,7 +74,11 @@ export async function prefetchOfficialMcpUrls(): Promise<void> {
|
||||
* URL is in the official MCP registry. Undefined registry → false (fail-closed).
|
||||
*/
|
||||
export function isOfficialMcpUrl(normalizedUrl: string): boolean {
|
||||
return officialUrls?.has(normalizedUrl) ?? false
|
||||
return (
|
||||
getAPIProvider() === 'firstParty' &&
|
||||
isFirstPartyAnthropicBaseUrl() &&
|
||||
(officialUrls?.has(normalizedUrl) ?? false)
|
||||
)
|
||||
}
|
||||
|
||||
export function resetOfficialMcpUrlsForTesting(): void {
|
||||
|
||||
@@ -8,7 +8,9 @@ import { queryHaiku } from '../../services/api/claude.js'
|
||||
import { AbortError } from '../../utils/errors.js'
|
||||
import { getWebFetchUserAgent } from '../../utils/http.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { getAPIProvider } from '../../utils/model/providers.js'
|
||||
import {
|
||||
isFirstPartyAnthropicProvider,
|
||||
} from '../../utils/model/providers.js'
|
||||
import {
|
||||
isBinaryContentType,
|
||||
persistBinaryContent,
|
||||
@@ -179,7 +181,7 @@ export async function checkDomainBlocklist(
|
||||
domain: string,
|
||||
): Promise<DomainCheckResult> {
|
||||
// Third-party providers should not consult the first-party domain policy.
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
if (!isFirstPartyAnthropicProvider()) {
|
||||
return { status: 'allowed' }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
BetaWebSearchTool20250305,
|
||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import { PRODUCT_DISPLAY_NAME } from 'src/constants/product.js'
|
||||
import { getAPIProvider } from 'src/utils/model/providers.js'
|
||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from 'src/utils/model/providers.js'
|
||||
import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js'
|
||||
|
||||
import { z } from 'zod/v4'
|
||||
@@ -549,7 +549,7 @@ function shouldUseAdapterProvider(): boolean {
|
||||
// Auto mode: native/first-party/Codex take precedence over adapter
|
||||
if (isCodexResponsesWebSearchEnabled()) return false
|
||||
const provider = getAPIProvider()
|
||||
if (provider === 'firstParty' || provider === 'vertex' || provider === 'foundry') {
|
||||
if ((provider === 'firstParty' && isFirstPartyAnthropicBaseUrl()) || provider === 'vertex' || provider === 'foundry') {
|
||||
return false
|
||||
}
|
||||
// No native path available — fall back to adapter
|
||||
@@ -566,7 +566,7 @@ function shouldUseAdapterProvider(): boolean {
|
||||
function hasNativeSearchFallback(): boolean {
|
||||
if (isCodexResponsesWebSearchEnabled()) return true
|
||||
const provider = getAPIProvider()
|
||||
return provider === 'firstParty' || provider === 'vertex' || provider === 'foundry'
|
||||
return (provider === 'firstParty' && isFirstPartyAnthropicBaseUrl()) || provider === 'vertex' || provider === 'foundry'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -605,7 +605,7 @@ export const WebSearchTool = buildTool({
|
||||
const model = getMainLoopModel()
|
||||
|
||||
// Enable for firstParty
|
||||
if (provider === 'firstParty') {
|
||||
if (provider === 'firstParty' && isFirstPartyAnthropicBaseUrl()) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,9 @@ function stalledJsonResponse(status = 200): Response {
|
||||
})
|
||||
}
|
||||
|
||||
function expectSignalAbort(signal: AbortSignal | undefined): Promise<void> {
|
||||
expect(signal).toBeInstanceOf(AbortSignal)
|
||||
if (signal?.aborted) return Promise.resolve()
|
||||
|
||||
return new Promise(resolve => {
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true })
|
||||
function pendingResponseUntilAbort(signal: AbortSignal | undefined): Promise<Response> {
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => reject(signal.reason), { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,24 +116,15 @@ describe('braveProvider search', () => {
|
||||
await expect(braveProvider.search({ query: 'q' })).rejects.toThrow(/429/)
|
||||
})
|
||||
|
||||
test(
|
||||
'rejects when the provider-level timeout elapses',
|
||||
async () => {
|
||||
process.env.WEB_SEARCH_TIMEOUT_SEC = '1'
|
||||
test('rejects when the provider-level timeout elapses', async () => {
|
||||
process.env.WEB_SEARCH_TIMEOUT_SEC = '1'
|
||||
globalThis.fetch = ((_: any, init: any) =>
|
||||
pendingResponseUntilAbort(init?.signal as AbortSignal | undefined)) as typeof fetch
|
||||
|
||||
let signalAborted: Promise<void> | undefined
|
||||
globalThis.fetch = (async (_input: any, init: any) => {
|
||||
signalAborted = expectSignalAbort(init?.signal as AbortSignal | undefined)
|
||||
return new Promise<Response>(() => undefined)
|
||||
}) as typeof fetch
|
||||
|
||||
await expect(braveProvider.search({ query: 'q' })).rejects.toThrow(
|
||||
/Brave search timed out/,
|
||||
)
|
||||
await expect(signalAborted).resolves.toBeUndefined()
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
await expect(braveProvider.search({ query: 'q' })).rejects.toThrow(
|
||||
/Brave search timed out/,
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects when the response body stalls after headers arrive', async () => {
|
||||
process.env.WEB_SEARCH_TIMEOUT_SEC = '1'
|
||||
@@ -152,17 +140,11 @@ describe('braveProvider search', () => {
|
||||
|
||||
test('rejects when a non-2xx error body stalls after headers arrive', async () => {
|
||||
process.env.WEB_SEARCH_TIMEOUT_SEC = '1'
|
||||
|
||||
let signalAborted: Promise<void> | undefined
|
||||
globalThis.fetch = (async (_input: any, init: any) => {
|
||||
signalAborted = expectSignalAbort(init?.signal as AbortSignal | undefined)
|
||||
return stalledJsonResponse(500)
|
||||
}) as typeof fetch
|
||||
globalThis.fetch = (async (_input: any, _init: any) => stalledJsonResponse(500)) as typeof fetch
|
||||
|
||||
await expect(braveProvider.search({ query: 'q' })).rejects.toThrow(
|
||||
/Brave search timed out/,
|
||||
)
|
||||
await expect(signalAborted).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns empty hits when web.results is missing', async () => {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { isFirstPartyAnthropicBaseUrlForEnv } from './anthropicBaseUrl.js'
|
||||
|
||||
describe('isFirstPartyAnthropicBaseUrlForEnv', () => {
|
||||
test('accepts the canonical HTTPS endpoint with its explicit default port', () => {
|
||||
expect(
|
||||
isFirstPartyAnthropicBaseUrlForEnv({
|
||||
ANTHROPIC_BASE_URL: 'https://api.anthropic.com:443',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects a non-default port on an Anthropic host', () => {
|
||||
expect(
|
||||
isFirstPartyAnthropicBaseUrlForEnv({
|
||||
ANTHROPIC_BASE_URL: 'https://api.anthropic.com:444',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('defaults to true when ANTHROPIC_BASE_URL is unset', () => {
|
||||
expect(isFirstPartyAnthropicBaseUrlForEnv({})).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects non-HTTPS URLs and lookalike hosts', () => {
|
||||
expect(
|
||||
isFirstPartyAnthropicBaseUrlForEnv({
|
||||
ANTHROPIC_BASE_URL: 'http://api.anthropic.com',
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isFirstPartyAnthropicBaseUrlForEnv({
|
||||
ANTHROPIC_BASE_URL: 'https://api.anthropic.com.evil.example',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('only accepts the staging host for ant users', () => {
|
||||
expect(
|
||||
isFirstPartyAnthropicBaseUrlForEnv({
|
||||
ANTHROPIC_BASE_URL: 'https://api-staging.anthropic.com',
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isFirstPartyAnthropicBaseUrlForEnv({
|
||||
ANTHROPIC_BASE_URL: 'https://api-staging.anthropic.com',
|
||||
USER_TYPE: 'ant',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('fails closed for malformed URLs', () => {
|
||||
expect(
|
||||
isFirstPartyAnthropicBaseUrlForEnv({ ANTHROPIC_BASE_URL: 'not a URL' }),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
export function isFirstPartyAnthropicBaseUrlForEnv(
|
||||
processEnv: NodeJS.ProcessEnv,
|
||||
): boolean {
|
||||
const baseUrl = processEnv.ANTHROPIC_BASE_URL
|
||||
if (!baseUrl) return true
|
||||
|
||||
try {
|
||||
const allowedHosts = ['api.anthropic.com']
|
||||
if (processEnv.USER_TYPE === 'ant') {
|
||||
allowedHosts.push('api-staging.anthropic.com')
|
||||
}
|
||||
const url = new URL(baseUrl)
|
||||
return (
|
||||
url.protocol === 'https:' &&
|
||||
allowedHosts.includes(url.hostname) &&
|
||||
(url.port === '' || url.port === '443')
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
releaseSharedMutationLock,
|
||||
} from '../test/sharedMutationLock.js'
|
||||
import { asMockFetch } from '../test/typedMocks.js'
|
||||
import * as actualProviders from './model/providers.js'
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
const originalFetch = globalThis.fetch
|
||||
@@ -18,6 +17,9 @@ function getMockApiProvider() {
|
||||
|
||||
async function importFreshModule() {
|
||||
mock.restore()
|
||||
const actualProviders = await import(
|
||||
`./model/providers.ts?actual=${Date.now()}-${Math.random()}`,
|
||||
)
|
||||
mock.module('./model/providers.js', () => ({
|
||||
...actualProviders,
|
||||
getAPIProvider: getMockApiProvider,
|
||||
@@ -35,7 +37,6 @@ afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
globalThis.fetch = originalFetch
|
||||
mock.restore()
|
||||
mock.module('./model/providers.js', () => actualProviders)
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
@@ -103,10 +104,6 @@ describe('preconnectAnthropicApi', () => {
|
||||
delete process.env.CLAUDE_CODE_CLIENT_CERT
|
||||
delete process.env.CLAUDE_CODE_CLIENT_KEY
|
||||
|
||||
mock.module('./model/providers.js', () => ({
|
||||
...actualProviders,
|
||||
getAPIProvider: () => 'firstParty',
|
||||
}))
|
||||
const fetchMock = mock(() => Promise.resolve(new Response(null, { status: 200 })))
|
||||
globalThis.fetch = asMockFetch(fetchMock)
|
||||
|
||||
@@ -116,6 +113,17 @@ describe('preconnectAnthropicApi', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('does not preconnect to a custom Anthropic endpoint', async () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://tenant.example'
|
||||
const fetchMock = mock(() => Promise.resolve(new Response(null, { status: 200 })))
|
||||
globalThis.fetch = asMockFetch(fetchMock)
|
||||
|
||||
const { preconnectAnthropicApi } = await importFreshModule()
|
||||
preconnectAnthropicApi('firstParty')
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('keeps non-mocked provider exports available to neighboring imports', async () => {
|
||||
await importFreshModule()
|
||||
|
||||
|
||||
@@ -26,7 +26,11 @@
|
||||
import { getOauthConfig } from '../constants/oauth.js'
|
||||
import { createCombinedAbortSignal } from './combinedAbortSignal.js'
|
||||
import { isEnvTruthy } from './envUtils.js'
|
||||
import { type APIProvider, getAPIProvider } from './model/providers.js'
|
||||
import {
|
||||
type APIProvider,
|
||||
getAPIProvider,
|
||||
isFirstPartyAnthropicBaseUrl,
|
||||
} from './model/providers.js'
|
||||
|
||||
let fired = false
|
||||
|
||||
@@ -37,7 +41,7 @@ export function preconnectAnthropicApi(
|
||||
fired = true
|
||||
|
||||
// Third-party providers should not warm a connection to Anthropic.
|
||||
if (apiProvider !== 'firstParty') {
|
||||
if (apiProvider !== 'firstParty' || !isFirstPartyAnthropicBaseUrl()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ import {
|
||||
} from 'src/types/textInputTypes.js'
|
||||
import { randomUUID, type UUID } from 'crypto'
|
||||
import { getSettings_DEPRECATED } from './settings/settings.js'
|
||||
import { getAPIProvider } from './model/providers.js'
|
||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './model/providers.js'
|
||||
import { getEffortEnvOverride, modelSupportsXHighEffort } from './effort.js'
|
||||
import { getSnippetForTwoFileDiff } from 'src/tools/FileEditTool/utils.js'
|
||||
import type {
|
||||
@@ -1540,6 +1540,7 @@ export function getUltracodePermissionAttachment(toolUseContext: ToolUseContext)
|
||||
if (
|
||||
!isUltracode ||
|
||||
getAPIProvider() !== 'firstParty' ||
|
||||
!isFirstPartyAnthropicBaseUrl() ||
|
||||
// A per-agent providerOverride routes the actual request through a
|
||||
// third-party shim (runAgent.ts sets it; query.ts forwards it to
|
||||
// getAnthropicClient()), so the process-wide first-party check above is not
|
||||
|
||||
+5
-2
@@ -10,7 +10,10 @@ import {
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { getModelStrings } from 'src/utils/model/modelStrings.js'
|
||||
import { getAPIProvider } from 'src/utils/model/providers.js'
|
||||
import {
|
||||
getAPIProvider,
|
||||
isFirstPartyAnthropicBaseUrl,
|
||||
} from 'src/utils/model/providers.js'
|
||||
import {
|
||||
getIsNonInteractiveSession,
|
||||
preferThirdPartyAuthentication,
|
||||
@@ -1918,7 +1921,7 @@ export type UserAccountInfo = {
|
||||
export function getAccountInformation() {
|
||||
const apiProvider = getAPIProvider()
|
||||
// Only provide account info for first-party Anthropic API
|
||||
if (apiProvider !== 'firstParty') {
|
||||
if (apiProvider !== 'firstParty' || !isFirstPartyAnthropicBaseUrl()) {
|
||||
return undefined
|
||||
}
|
||||
const { source: authTokenSource } = getAuthTokenSource()
|
||||
|
||||
@@ -45,6 +45,7 @@ const PROVIDER_ENV_KEYS = [
|
||||
'FIREWORKS_API_KEY',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_BETAS',
|
||||
'OPENAI_BASE_URL',
|
||||
@@ -208,6 +209,55 @@ test('isAnthropicProvider is true for firstParty', async () => {
|
||||
expect(isAnthropicProvider()).toBe(true)
|
||||
})
|
||||
|
||||
test('custom Anthropic proxy endpoints do not receive first-party beta headers', async () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://tenant.example'
|
||||
process.env.ANTHROPIC_MODEL = 'tenant-model'
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'tenant-token'
|
||||
process.env.ANTHROPIC_BETAS = 'tenant-beta-2026-01-01'
|
||||
|
||||
const {
|
||||
getMergedBetas,
|
||||
getModelBetas,
|
||||
isAnthropicProvider,
|
||||
modelSupportsAutoMode,
|
||||
modelSupportsContextManagement,
|
||||
modelSupportsISP,
|
||||
modelSupportsStructuredOutputs,
|
||||
shouldIncludeFirstPartyOnlyBetas,
|
||||
shouldUseGlobalCacheScope,
|
||||
} = await importFreshBetas()
|
||||
expect(isAnthropicProvider()).toBe(false)
|
||||
expect(getMergedBetas('tenant-model')).toEqual([])
|
||||
expect(getModelBetas('claude-sonnet-4-6')).toEqual([])
|
||||
expect(modelSupportsISP('claude-sonnet-4-5')).toBe(true)
|
||||
expect(modelSupportsContextManagement('claude-sonnet-4-5')).toBe(true)
|
||||
expect(modelSupportsStructuredOutputs('claude-sonnet-4-5')).toBe(false)
|
||||
expect(modelSupportsAutoMode('claude-sonnet-4-5')).toBe(false)
|
||||
expect(shouldIncludeFirstPartyOnlyBetas()).toBe(false)
|
||||
expect(shouldUseGlobalCacheScope()).toBe(false)
|
||||
})
|
||||
|
||||
test('switching from first-party Anthropic to a custom proxy clears beta headers', async () => {
|
||||
const { getModelBetas } = await importFreshBetas()
|
||||
expect(getModelBetas('claude-sonnet-4-6')).not.toEqual([])
|
||||
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://tenant.example'
|
||||
process.env.ANTHROPIC_MODEL = 'claude-sonnet-4-6'
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'tenant-token'
|
||||
process.env.ANTHROPIC_BETAS = 'tenant-beta-2026-01-01'
|
||||
|
||||
expect(getModelBetas('claude-sonnet-4-6')).toEqual([])
|
||||
})
|
||||
|
||||
test('first-party Anthropic retains the beta gates excluded for custom proxies', async () => {
|
||||
const {
|
||||
shouldIncludeFirstPartyOnlyBetas,
|
||||
shouldUseGlobalCacheScope,
|
||||
} = await importFreshBetas()
|
||||
expect(shouldIncludeFirstPartyOnlyBetas()).toBe(true)
|
||||
expect(shouldUseGlobalCacheScope()).toBe(true)
|
||||
})
|
||||
|
||||
test('isAnthropicProvider is true for bedrock', async () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = '1'
|
||||
const { isAnthropicProvider } = await importFreshBetas()
|
||||
|
||||
+50
-11
@@ -27,7 +27,11 @@ import { has1mContext } from './context.js'
|
||||
import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js'
|
||||
import { getCanonicalName } from './model/model.js'
|
||||
import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js'
|
||||
import { getAPIProvider, isGithubNativeAnthropicMode } from './model/providers.js'
|
||||
import {
|
||||
getAPIProvider,
|
||||
isFirstPartyAnthropicBaseUrl,
|
||||
isGithubNativeAnthropicMode,
|
||||
} from './model/providers.js'
|
||||
import { getInitialSettings } from './settings/settings.js'
|
||||
|
||||
/**
|
||||
@@ -103,7 +107,7 @@ export function modelSupportsISP(model: string): boolean {
|
||||
if (provider === 'foundry') {
|
||||
return true
|
||||
}
|
||||
if (provider === 'firstParty') {
|
||||
if (provider === 'firstParty' && isFirstPartyAnthropicBaseUrl()) {
|
||||
return !canonical.includes('claude-3-')
|
||||
}
|
||||
return (
|
||||
@@ -128,7 +132,7 @@ export function modelSupportsContextManagement(model: string): boolean {
|
||||
if (provider === 'foundry') {
|
||||
return true
|
||||
}
|
||||
if (provider === 'firstParty') {
|
||||
if (provider === 'firstParty' && isFirstPartyAnthropicBaseUrl()) {
|
||||
return !canonical.includes('claude-3-')
|
||||
}
|
||||
return (
|
||||
@@ -143,7 +147,10 @@ export function modelSupportsStructuredOutputs(model: string): boolean {
|
||||
const canonical = getCanonicalName(model)
|
||||
const provider = getAPIProvider()
|
||||
// Structured outputs only supported on firstParty and Foundry (not Bedrock/Vertex yet)
|
||||
if (provider !== 'firstParty' && provider !== 'foundry') {
|
||||
if (
|
||||
(provider !== 'firstParty' || !isFirstPartyAnthropicBaseUrl()) &&
|
||||
provider !== 'foundry'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
@@ -165,7 +172,10 @@ export function modelSupportsAutoMode(model: string): boolean {
|
||||
// External: firstParty-only at launch (PI probes not wired for
|
||||
// Bedrock/Vertex/Foundry yet). Checked before allowModels so the GB
|
||||
// override can't enable auto mode on unsupported providers.
|
||||
if (process.env.USER_TYPE !== 'ant' && getAPIProvider() !== 'firstParty') {
|
||||
if (
|
||||
getAPIProvider() !== 'firstParty' ||
|
||||
!isFirstPartyAnthropicBaseUrl()
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// GrowthBook override: tengu_auto_mode_config.allowModels force-enables
|
||||
@@ -216,7 +226,8 @@ export function getToolSearchBetaHeader(): string {
|
||||
*/
|
||||
export function shouldIncludeFirstPartyOnlyBetas(): boolean {
|
||||
return (
|
||||
(getAPIProvider() === 'firstParty' || getAPIProvider() === 'foundry') &&
|
||||
((getAPIProvider() === 'firstParty' && isFirstPartyAnthropicBaseUrl()) ||
|
||||
getAPIProvider() === 'foundry') &&
|
||||
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)
|
||||
)
|
||||
}
|
||||
@@ -229,10 +240,21 @@ export function shouldIncludeFirstPartyOnlyBetas(): boolean {
|
||||
export function shouldUseGlobalCacheScope(): boolean {
|
||||
return (
|
||||
getAPIProvider() === 'firstParty' &&
|
||||
isFirstPartyAnthropicBaseUrl() &&
|
||||
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)
|
||||
)
|
||||
}
|
||||
|
||||
function getBetaCacheKey(model: string): string {
|
||||
return [
|
||||
model.toLowerCase(),
|
||||
getAPIProvider(),
|
||||
process.env.ANTHROPIC_BASE_URL ?? '',
|
||||
process.env.USER_TYPE ?? '',
|
||||
process.env.CLAUDE_CODE_USE_GITHUB ?? '',
|
||||
].join('\0')
|
||||
}
|
||||
|
||||
export const getAllModelBetas = memoize((model: string): string[] => {
|
||||
const betaHeaders: string[] = []
|
||||
const isHaiku = getCanonicalName(model).includes('haiku')
|
||||
@@ -368,21 +390,25 @@ export const getAllModelBetas = memoize((model: string): string[] => {
|
||||
)
|
||||
}
|
||||
return betaHeaders
|
||||
})
|
||||
}, getBetaCacheKey)
|
||||
|
||||
export const getModelBetas = memoize((model: string): string[] => {
|
||||
if (!shouldUseAnthropicBetaHeaders(model)) {
|
||||
return []
|
||||
}
|
||||
const modelBetas = getAllModelBetas(model)
|
||||
if (getAPIProvider() === 'bedrock') {
|
||||
return modelBetas.filter(b => !BEDROCK_EXTRA_PARAMS_HEADERS.has(b))
|
||||
}
|
||||
return modelBetas
|
||||
})
|
||||
}, getBetaCacheKey)
|
||||
|
||||
export const getBedrockExtraBodyParamsBetas = memoize(
|
||||
(model: string): string[] => {
|
||||
const modelBetas = getAllModelBetas(model)
|
||||
return modelBetas.filter(b => BEDROCK_EXTRA_PARAMS_HEADERS.has(b))
|
||||
},
|
||||
getBetaCacheKey,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -398,7 +424,12 @@ export const getBedrockExtraBodyParamsBetas = memoize(
|
||||
*/
|
||||
export function isAnthropicProvider(): boolean {
|
||||
const provider = getAPIProvider()
|
||||
return provider === 'firstParty' || provider === 'bedrock' || provider === 'vertex' || provider === 'foundry'
|
||||
return (
|
||||
(provider === 'firstParty' && isFirstPartyAnthropicBaseUrl()) ||
|
||||
provider === 'bedrock' ||
|
||||
provider === 'vertex' ||
|
||||
provider === 'foundry'
|
||||
)
|
||||
}
|
||||
|
||||
export function getMergedBetas(
|
||||
@@ -407,8 +438,9 @@ export function getMergedBetas(
|
||||
): string[] {
|
||||
// Beta headers are Anthropic-specific. Non-Anthropic providers (OpenAI,
|
||||
// Gemini, Codex, etc.) do not understand them and may reject requests
|
||||
// containing unknown headers. GitHub Native Anthropic mode is an exception.
|
||||
if (!isAnthropicProvider() && !isGithubNativeAnthropicMode(model)) {
|
||||
// containing unknown headers. Custom Anthropic proxies and GitHub Native
|
||||
// Anthropic mode are exceptions because both use Anthropic wire format.
|
||||
if (!shouldUseAnthropicBetaHeaders(model)) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -441,6 +473,13 @@ export function getMergedBetas(
|
||||
return [...baseBetas, ...sdkBetas.filter(b => !baseBetas.includes(b))]
|
||||
}
|
||||
|
||||
function shouldUseAnthropicBetaHeaders(model: string): boolean {
|
||||
return (
|
||||
isAnthropicProvider() ||
|
||||
isGithubNativeAnthropicMode(model)
|
||||
)
|
||||
}
|
||||
|
||||
export function clearBetasCaches(): void {
|
||||
getAllModelBetas.cache?.clear?.()
|
||||
getModelBetas.cache?.clear?.()
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type ModelSetting,
|
||||
parseUserSpecifiedModel,
|
||||
} from './model/model.js'
|
||||
import { getAPIProvider } from './model/providers.js'
|
||||
import { isFirstPartyAnthropicProvider } from './model/providers.js'
|
||||
import { isEssentialTrafficOnly } from './privacyLevel.js'
|
||||
import {
|
||||
getInitialSettings,
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
import { createSignal } from './signal.js'
|
||||
|
||||
export function isFastModeEnabled(): boolean {
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
if (!isFirstPartyAnthropicProvider()) {
|
||||
return false
|
||||
}
|
||||
return !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FAST_MODE)
|
||||
@@ -73,7 +73,7 @@ function getDisabledReasonMessage(
|
||||
}
|
||||
|
||||
export function getFastModeUnavailableReason(): string | null {
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
if (!isFirstPartyAnthropicProvider()) {
|
||||
return 'Fast mode is not available on third-party providers'
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
|
||||
import { getWebFetchUserAgent } from './http.js'
|
||||
|
||||
const ROUTING_ENV_KEYS = [
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'CLAUDE_CODE_USE_OPENAI',
|
||||
'CLAUDE_CODE_USE_FOUNDRY',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLAUDE_CODE_USE_GEMINI',
|
||||
'CLAUDE_CODE_USE_GITHUB',
|
||||
'CLAUDE_CODE_USE_MISTRAL',
|
||||
]
|
||||
|
||||
const originalEnv = new Map(
|
||||
ROUTING_ENV_KEYS.map(key => [key, process.env[key]]),
|
||||
)
|
||||
const originalMacro = (globalThis as Record<string, unknown>).MACRO
|
||||
|
||||
beforeEach(() => {
|
||||
;(globalThis as Record<string, unknown>).MACRO = { VERSION: 'test-version' }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ROUTING_ENV_KEYS) {
|
||||
const value = originalEnv.get(key)
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
;(globalThis as Record<string, unknown>).MACRO = originalMacro
|
||||
})
|
||||
|
||||
test('WebFetch identifies a custom Anthropic endpoint as third-party', () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://proxy.example/v1'
|
||||
|
||||
expect(getWebFetchUserAgent()).toContain(
|
||||
'+https://github.com/Gitlawb/openclaude',
|
||||
)
|
||||
})
|
||||
|
||||
test('WebFetch preserves the Anthropic support URL for first-party endpoints', () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com'
|
||||
|
||||
expect(getWebFetchUserAgent()).toContain('+https://support.anthropic.com/')
|
||||
})
|
||||
+2
-2
@@ -10,7 +10,7 @@ import {
|
||||
handleOAuth401Error,
|
||||
isClaudeAISubscriber,
|
||||
} from './auth.js'
|
||||
import { getAPIProvider } from './model/providers.js'
|
||||
import { isFirstPartyAnthropicProvider } from './model/providers.js'
|
||||
import { getClaudeCodeUserAgent } from './userAgent.js'
|
||||
import { getWorkload } from './workloadContext.js'
|
||||
|
||||
@@ -56,7 +56,7 @@ export function getMCPUserAgent(): string {
|
||||
// local CLI traffic from claude.ai server-side fetches.
|
||||
export function getWebFetchUserAgent(): string {
|
||||
const supportUrl =
|
||||
getAPIProvider() === 'firstParty'
|
||||
isFirstPartyAnthropicProvider()
|
||||
? 'https://support.anthropic.com/'
|
||||
: 'https://github.com/Gitlawb/openclaude'
|
||||
return `Claude-User (${getClaudeCodeUserAgent()}; +${supportUrl})`
|
||||
|
||||
@@ -16,26 +16,32 @@ import {
|
||||
} from '../settings/settingsCache.js'
|
||||
async function importFreshModelModule() {
|
||||
mock.restore()
|
||||
const getAPIProvider = () => {
|
||||
if (process.env.NVIDIA_NIM) return 'nvidia-nim'
|
||||
if (process.env.MINIMAX_API_KEY) return 'minimax'
|
||||
if (process.env.MIMO_API_KEY) return 'xiaomi-mimo'
|
||||
if (process.env.CLAUDE_CODE_USE_GEMINI) return 'gemini'
|
||||
if (process.env.CLAUDE_CODE_USE_MISTRAL) return 'mistral'
|
||||
if (process.env.CLAUDE_CODE_USE_GITHUB) return 'github'
|
||||
if (process.env.CLAUDE_CODE_USE_OPENAI) {
|
||||
const baseUrl = process.env.OPENAI_BASE_URL ?? ''
|
||||
const model = process.env.OPENAI_MODEL ?? ''
|
||||
return baseUrl.includes('/backend-api/codex') || model.startsWith('codex')
|
||||
? 'codex'
|
||||
: 'openai'
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_USE_BEDROCK) return 'bedrock'
|
||||
if (process.env.CLAUDE_CODE_USE_VERTEX) return 'vertex'
|
||||
if (process.env.CLAUDE_CODE_USE_FOUNDRY) return 'foundry'
|
||||
return 'firstParty'
|
||||
}
|
||||
mock.module('./providers.js', () => ({
|
||||
getAPIProvider: () => {
|
||||
if (process.env.NVIDIA_NIM) return 'nvidia-nim'
|
||||
if (process.env.MINIMAX_API_KEY) return 'minimax'
|
||||
if (process.env.MIMO_API_KEY) return 'xiaomi-mimo'
|
||||
if (process.env.CLAUDE_CODE_USE_GEMINI) return 'gemini'
|
||||
if (process.env.CLAUDE_CODE_USE_MISTRAL) return 'mistral'
|
||||
if (process.env.CLAUDE_CODE_USE_GITHUB) return 'github'
|
||||
if (process.env.CLAUDE_CODE_USE_OPENAI) {
|
||||
const baseUrl = process.env.OPENAI_BASE_URL ?? ''
|
||||
const model = process.env.OPENAI_MODEL ?? ''
|
||||
return baseUrl.includes('/backend-api/codex') || model.startsWith('codex')
|
||||
? 'codex'
|
||||
: 'openai'
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_USE_BEDROCK) return 'bedrock'
|
||||
if (process.env.CLAUDE_CODE_USE_VERTEX) return 'vertex'
|
||||
if (process.env.CLAUDE_CODE_USE_FOUNDRY) return 'foundry'
|
||||
return 'firstParty'
|
||||
},
|
||||
getAPIProvider,
|
||||
isFirstPartyAnthropicBaseUrl: () => !process.env.ANTHROPIC_BASE_URL,
|
||||
isFirstPartyAnthropicProvider: () =>
|
||||
getAPIProvider() === 'firstParty' && !process.env.ANTHROPIC_BASE_URL,
|
||||
isCustomAnthropicProvider: () =>
|
||||
getAPIProvider() === 'firstParty' && !!process.env.ANTHROPIC_BASE_URL,
|
||||
}))
|
||||
mock.module('./modelAllowlist.js', () => ({
|
||||
isModelAllowed: () => true,
|
||||
@@ -67,6 +73,7 @@ const SAVED_ENV = {
|
||||
NVIDIA_NIM: process.env.NVIDIA_NIM,
|
||||
MINIMAX_API_KEY: process.env.MINIMAX_API_KEY,
|
||||
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
|
||||
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
|
||||
MIMO_API_KEY: process.env.MIMO_API_KEY,
|
||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||
@@ -385,6 +392,34 @@ test('getDefaultHaikuModel returns OPENAI_MODEL for MiniMax', async () => {
|
||||
expect(getDefaultHaikuModel()).toBe('MiniMax-M2.5-highspeed')
|
||||
})
|
||||
|
||||
test('getDefaultMainLoopModelSetting keeps the configured custom Anthropic model', async () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://tenant.example'
|
||||
process.env.ANTHROPIC_MODEL = 'tenant-model'
|
||||
|
||||
const { getDefaultMainLoopModelSetting } = await importFreshModelModule()
|
||||
expect(getDefaultMainLoopModelSetting()).toBe('tenant-model')
|
||||
})
|
||||
|
||||
test('modelDisplayString uses the configured custom Anthropic default', async () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://tenant.example'
|
||||
process.env.ANTHROPIC_MODEL = 'tenant-model'
|
||||
|
||||
const { modelDisplayString } = await importFreshModelModule()
|
||||
expect(modelDisplayString(null)).toBe('Default (tenant-model)')
|
||||
})
|
||||
|
||||
test('custom Anthropic endpoints retain their configured model and conservative defaults', async () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://tenant.example'
|
||||
process.env.ANTHROPIC_MODEL = 'tenant-model'
|
||||
|
||||
const { getDefaultOpusModel, getDefaultSonnetModel, getSmallFastModel } =
|
||||
await importFreshModelModule()
|
||||
|
||||
expect(getSmallFastModel()).toBe('tenant-model')
|
||||
expect(getDefaultOpusModel()).toBe('claude-opus-4-7')
|
||||
expect(getDefaultSonnetModel()).toBe('claude-sonnet-4-5-20250929')
|
||||
})
|
||||
|
||||
test('default helpers do not leak claude-* names to shim providers', async () => {
|
||||
// Umbrella guard: for each OpenAI-shim provider, none of the default-model
|
||||
// helpers may return an Anthropic-branded model name. That was the source
|
||||
|
||||
@@ -23,7 +23,12 @@ import { getModelStrings, resolveOverriddenModel } from './modelStrings.js'
|
||||
import { formatModelPricing, getOpus46CostTier } from '../modelCost.js'
|
||||
import { getSettings_DEPRECATED } from '../settings/settings.js'
|
||||
import type { PermissionMode } from '../permissions/PermissionMode.js'
|
||||
import { getAPIProvider } from './providers.js'
|
||||
import {
|
||||
getAPIProvider,
|
||||
isFirstPartyAnthropicBaseUrl,
|
||||
isFirstPartyAnthropicProvider,
|
||||
isCustomAnthropicProvider,
|
||||
} from './providers.js'
|
||||
import { LIGHTNING_BOLT } from '../../constants/figures.js'
|
||||
import { isModelAllowed } from './modelAllowlist.js'
|
||||
import { type ModelAlias, isModelAlias } from './aliases.js'
|
||||
@@ -48,6 +53,9 @@ function normalizeModelSetting(value: unknown): ModelName | ModelAlias | undefin
|
||||
|
||||
export function getSmallFastModel(): ModelName {
|
||||
if (process.env.ANTHROPIC_SMALL_FAST_MODEL) return process.env.ANTHROPIC_SMALL_FAST_MODEL
|
||||
if (isCustomAnthropicProvider()) {
|
||||
return process.env.ANTHROPIC_MODEL || getDefaultHaikuModel()
|
||||
}
|
||||
// For Gemini provider, use a fast model
|
||||
if (getAPIProvider() === 'gemini') {
|
||||
return process.env.GEMINI_MODEL || 'gemini-2.0-flash-lite'
|
||||
@@ -226,7 +234,7 @@ export function getDefaultOpusModel(): ModelName {
|
||||
// 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch
|
||||
// since 3P availability lags firstParty and these will diverge again at
|
||||
// the next model launch. Keep 3P on Opus 4.7 until they roll out 4.8.
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
if (!isFirstPartyAnthropicProvider()) {
|
||||
return getModelStrings().opus47
|
||||
}
|
||||
return getModelStrings().opus48
|
||||
@@ -274,7 +282,7 @@ export function getDefaultSonnetModel(): ModelName {
|
||||
return process.env.OPENAI_MODEL || 'grok-4.3'
|
||||
}
|
||||
// Default to Sonnet 4.5 for 3P since they may not have 4.6 yet
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
if (!isFirstPartyAnthropicProvider()) {
|
||||
return getModelStrings().sonnet45
|
||||
}
|
||||
return getModelStrings().sonnet46
|
||||
@@ -365,6 +373,12 @@ export function getRuntimeMainLoopModel(params: {
|
||||
* @returns The default model setting to use
|
||||
*/
|
||||
export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias {
|
||||
// Custom Anthropic-compatible endpoints intentionally retain the legacy
|
||||
// firstParty provider category, so prefer their explicitly configured model
|
||||
// before the subscription and PAYG defaults below.
|
||||
if (isCustomAnthropicProvider()) {
|
||||
return process.env.ANTHROPIC_MODEL || getDefaultSonnetModel()
|
||||
}
|
||||
// GitHub Copilot provider: check settings.model first, then env, then default
|
||||
if (getAPIProvider() === 'github') {
|
||||
const settings = getSettings_DEPRECATED() || {}
|
||||
@@ -549,7 +563,7 @@ export function renderDefaultModelSetting(
|
||||
}
|
||||
|
||||
export function getOpus46PricingSuffix(fastMode: boolean): string {
|
||||
if (getAPIProvider() !== 'firstParty') return ''
|
||||
if (!isFirstPartyAnthropicProvider()) return ''
|
||||
const pricing = formatModelPricing(getOpus46CostTier(fastMode))
|
||||
const fastModeIndicator = fastMode ? ` (${LIGHTNING_BOLT})` : ''
|
||||
return ` ·${fastModeIndicator} ${pricing}`
|
||||
@@ -559,7 +573,7 @@ export function isOpus1mMergeEnabled(): boolean {
|
||||
if (
|
||||
is1mContextDisabled() ||
|
||||
isProSubscriber() ||
|
||||
getAPIProvider() !== 'firstParty'
|
||||
!isFirstPartyAnthropicProvider()
|
||||
) {
|
||||
return false
|
||||
}
|
||||
@@ -825,6 +839,7 @@ export function parseUserSpecifiedModel(
|
||||
// 3P providers may not yet have 4.6 capacity, so pass through unchanged.
|
||||
if (
|
||||
getAPIProvider() === 'firstParty' &&
|
||||
isFirstPartyAnthropicBaseUrl() &&
|
||||
isLegacyOpusFirstParty(modelString) &&
|
||||
isLegacyModelRemapEnabled()
|
||||
) {
|
||||
@@ -909,7 +924,7 @@ export function isLegacyModelRemapEnabled(): boolean {
|
||||
|
||||
export function modelDisplayString(model: ModelSetting): string {
|
||||
if (model === null) {
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
if (!isFirstPartyAnthropicProvider()) {
|
||||
return `Default (${getDefaultMainLoopModel()})`
|
||||
}
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
|
||||
@@ -8,16 +8,25 @@ import {
|
||||
setSessionSettingsCache,
|
||||
} from '../settings/settingsCache.js'
|
||||
|
||||
async function importFreshModelOptionsModule() {
|
||||
async function importFreshModelOptionsModule(
|
||||
provider = 'openai',
|
||||
isFirstPartyAnthropicBaseUrl = false,
|
||||
) {
|
||||
mock.restore()
|
||||
mock.module('./providers.js', () => ({
|
||||
getAPIProvider: () => 'openai',
|
||||
getAPIProviderForStatsig: () => 'openai',
|
||||
isFirstPartyAnthropicBaseUrl: () => false,
|
||||
getAPIProvider: () => provider,
|
||||
getAPIProviderForStatsig: () => provider,
|
||||
isFirstPartyAnthropicBaseUrl: () => isFirstPartyAnthropicBaseUrl,
|
||||
isFirstPartyAnthropicProvider: () =>
|
||||
provider === 'firstParty' && isFirstPartyAnthropicBaseUrl,
|
||||
isCustomAnthropicProvider: () =>
|
||||
provider === 'firstParty' && !isFirstPartyAnthropicBaseUrl,
|
||||
isGithubNativeAnthropicMode: () => false,
|
||||
usesAnthropicAccountFlow: () => false,
|
||||
}))
|
||||
const nonce = `${Date.now()}-${Math.random()}`
|
||||
const modelModule = await import(`./model.js?modelOptionsTest=${nonce}`)
|
||||
mock.module('./model.js', () => modelModule)
|
||||
return import(`./modelOptions.js?ts=${nonce}`)
|
||||
}
|
||||
|
||||
@@ -32,6 +41,9 @@ const originalEnv = {
|
||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
|
||||
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
|
||||
ATLAS_CLOUD_API_KEY: process.env.ATLAS_CLOUD_API_KEY,
|
||||
CODEX_API_KEY: process.env.CODEX_API_KEY,
|
||||
@@ -93,6 +105,18 @@ test('OpenRouter keeps static catalog entries and the active custom model', asyn
|
||||
expect(values).toContain('deepseek/deepseek-chat')
|
||||
})
|
||||
|
||||
test('custom Anthropic endpoints use the third-party default description', async () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://proxy.example/v1'
|
||||
process.env.ANTHROPIC_MODEL = 'proxy-model'
|
||||
process.env.ANTHROPIC_API_KEY = 'proxy-key'
|
||||
|
||||
const { getModelOptions } = await importFreshModelOptionsModule('firstParty')
|
||||
const defaultOption = getModelOptions().find(option => option.value === null)
|
||||
|
||||
expect(defaultOption?.description).toContain('currently proxy-model')
|
||||
expect(defaultOption?.description).not.toContain('$')
|
||||
})
|
||||
|
||||
test('OpenRouter active profile cache merges with the static route catalog', async () => {
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1'
|
||||
@@ -143,4 +167,4 @@ test('Atlas Cloud canonicalizes static catalog aliases without hiding the catalo
|
||||
expect(values).not.toContain('claude-opus-4-8')
|
||||
expect(values).not.toContain('grok-code-fast-1')
|
||||
expect(values).not.toContain('grok-4')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||
import { getInitialMainLoopModel } from '../../bootstrap/state.js'
|
||||
import { getCatalogEntriesForRoute } from '../../integrations/index.js'
|
||||
import { resolveRouteIdFromBaseUrl } from '../../integrations/routeMetadata.js'
|
||||
import {
|
||||
getTransportKindForRoute,
|
||||
resolveActiveRouteIdFromEnv,
|
||||
resolveRouteIdFromBaseUrl,
|
||||
} from '../../integrations/routeMetadata.js'
|
||||
import {
|
||||
getAdditionalModelOptionsCacheScope,
|
||||
resolveProviderRequest,
|
||||
@@ -20,7 +24,12 @@ import {
|
||||
} from '../modelCost.js'
|
||||
import { getSettings_DEPRECATED } from '../settings/settings.js'
|
||||
import { checkOpus1mAccess, checkSonnet1mAccess } from './check1mAccess.js'
|
||||
import { getAPIProvider } from './providers.js'
|
||||
import {
|
||||
getAPIProvider,
|
||||
isCustomAnthropicProvider,
|
||||
isFirstPartyAnthropicBaseUrl,
|
||||
isFirstPartyAnthropicProvider,
|
||||
} from './providers.js'
|
||||
import { isModelAllowed } from './modelAllowlist.js'
|
||||
import {
|
||||
getCanonicalName,
|
||||
@@ -141,12 +150,14 @@ function getScopedAdditionalModelOptions(): ModelOption[] {
|
||||
}
|
||||
|
||||
export function getDefaultOptionForUser(fastMode = false): ModelOption {
|
||||
const is3P = getAPIProvider() !== 'firstParty'
|
||||
const is3P = !isFirstPartyAnthropicProvider()
|
||||
const currentDefaultModel =
|
||||
isCustomAnthropicProvider() && process.env.ANTHROPIC_MODEL
|
||||
? process.env.ANTHROPIC_MODEL
|
||||
: getDefaultMainLoopModelSetting()
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
const currentModel = renderDefaultModelSetting(
|
||||
getDefaultMainLoopModelSetting(),
|
||||
)
|
||||
if (process.env.USER_TYPE === 'ant' && !is3P) {
|
||||
const currentModel = renderDefaultModelSetting(currentDefaultModel)
|
||||
return {
|
||||
value: null,
|
||||
label: 'Default (recommended)',
|
||||
@@ -159,7 +170,7 @@ export function getDefaultOptionForUser(fastMode = false): ModelOption {
|
||||
return {
|
||||
value: null,
|
||||
label: 'Default (recommended)',
|
||||
description: `Use the default model (currently ${renderDefaultModelSetting(getDefaultMainLoopModelSetting())})`,
|
||||
description: `Use the default model (currently ${renderDefaultModelSetting(currentDefaultModel)})`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +187,7 @@ export function getDefaultOptionForUser(fastMode = false): ModelOption {
|
||||
return {
|
||||
value: null,
|
||||
label: 'Default (recommended)',
|
||||
description: `Use the default model (currently ${renderDefaultModelSetting(getDefaultMainLoopModelSetting())})${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`,
|
||||
description: `Use the default model (currently ${renderDefaultModelSetting(currentDefaultModel)})${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,6 +583,28 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
||||
return [defaultOption, ...inactiveProfileOptions]
|
||||
}
|
||||
|
||||
const activeProfile = getActiveProviderProfile()
|
||||
const activeRouteId = resolveActiveRouteIdFromEnv(process.env, {
|
||||
activeProfileProvider: activeProfile?.provider,
|
||||
activeProfileBaseUrl: activeProfile?.baseUrl,
|
||||
})
|
||||
if (getTransportKindForRoute(activeRouteId ?? '') === 'anthropic-proxy') {
|
||||
const directEnvOption =
|
||||
profileModelOptions.length === 0 && process.env.ANTHROPIC_MODEL
|
||||
? [{
|
||||
value: process.env.ANTHROPIC_MODEL,
|
||||
label: process.env.ANTHROPIC_MODEL,
|
||||
description: 'Custom Anthropic-compatible endpoint',
|
||||
}]
|
||||
: []
|
||||
return [
|
||||
getDefaultOptionForUser(fastMode),
|
||||
...profileModelOptions,
|
||||
...directEnvOption,
|
||||
...inactiveProfileOptions,
|
||||
]
|
||||
}
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
// Build options from antModels config
|
||||
const antModelOptions: ModelOption[] = getAntModels().map(m => ({
|
||||
@@ -634,7 +667,6 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
||||
// other configured profile while a local/route profile is active (#1119).
|
||||
const activeRouteCatalogOptions = getActiveOpenAIRouteCatalogOptions()
|
||||
const openAIModelOptionsScope = getAdditionalModelOptionsCacheScope()
|
||||
const activeProfile = getActiveProviderProfile()
|
||||
if (
|
||||
activeRouteCatalogOptions.length > 0 ||
|
||||
openAIModelOptionsScope?.startsWith('openai:')
|
||||
@@ -659,7 +691,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
||||
}
|
||||
|
||||
// PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.8 + Opus 4.7 + Opus 4.6 + Opus 1M + Haiku
|
||||
if (getAPIProvider() === 'firstParty') {
|
||||
if (getAPIProvider() === 'firstParty' && isFirstPartyAnthropicBaseUrl()) {
|
||||
const payg1POptions = [getDefaultOptionForUser(fastMode)]
|
||||
if (checkSonnet1mAccess()) {
|
||||
payg1POptions.push(getSonnet46_1MOption())
|
||||
@@ -1007,12 +1039,12 @@ export function getModelOptions(fastMode = false): ModelOption[] {
|
||||
return filterModelOptionsByAllowlist([...options, getCodexPlanOption()])
|
||||
} else if (customModel === 'gpt-5.3-codex-spark') {
|
||||
return filterModelOptionsByAllowlist([...options, getCodexSparkOption()])
|
||||
} else if (customModel === 'opus' && getAPIProvider() === 'firstParty') {
|
||||
} else if (customModel === 'opus' && getAPIProvider() === 'firstParty' && isFirstPartyAnthropicBaseUrl()) {
|
||||
return filterModelOptionsByAllowlist([
|
||||
...options,
|
||||
getMaxOpusOption(fastMode),
|
||||
])
|
||||
} else if (customModel === 'opus[1m]' && getAPIProvider() === 'firstParty') {
|
||||
} else if (customModel === 'opus[1m]' && getAPIProvider() === 'firstParty' && isFirstPartyAnthropicBaseUrl()) {
|
||||
return filterModelOptionsByAllowlist([
|
||||
...options,
|
||||
getMergedOpus1MOption(fastMode),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { getAPIProvider } from './providers.js'
|
||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './providers.js'
|
||||
|
||||
export type ModelCapabilityOverride =
|
||||
| 'effort'
|
||||
@@ -37,6 +37,8 @@ function buildCapabilityOverrideCacheKey(
|
||||
model.toLowerCase(),
|
||||
capability,
|
||||
getAPIProvider(),
|
||||
process.env.ANTHROPIC_BASE_URL ?? '',
|
||||
process.env.USER_TYPE ?? '',
|
||||
...envParts,
|
||||
].join('\0')
|
||||
}
|
||||
@@ -47,7 +49,10 @@ function buildCapabilityOverrideCacheKey(
|
||||
*/
|
||||
export const get3PModelCapabilityOverride = memoize(
|
||||
(model: string, capability: ModelCapabilityOverride): boolean | undefined => {
|
||||
if (getAPIProvider() === 'firstParty') {
|
||||
if (
|
||||
getAPIProvider() === 'firstParty' &&
|
||||
isFirstPartyAnthropicBaseUrl()
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const m = model.toLowerCase()
|
||||
|
||||
@@ -16,6 +16,7 @@ const originalEnv = {
|
||||
MINIMAX_API_KEY: process.env.MINIMAX_API_KEY,
|
||||
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
|
||||
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
|
||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
|
||||
@@ -63,6 +64,7 @@ function clearProviderEnv(): void {
|
||||
delete process.env.MINIMAX_API_KEY
|
||||
delete process.env.ANTHROPIC_BASE_URL
|
||||
delete process.env.ANTHROPIC_API_KEY
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN
|
||||
delete process.env.ANTHROPIC_MODEL
|
||||
delete process.env.OPENAI_BASE_URL
|
||||
delete process.env.OPENAI_API_BASE
|
||||
@@ -83,6 +85,26 @@ test('first-party provider keeps Anthropic account setup flow enabled', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('custom Anthropic endpoints do not start the first-party account flow', async () => {
|
||||
clearProviderEnv()
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://tenant.example'
|
||||
process.env.ANTHROPIC_MODEL = 'tenant-model'
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'tenant-token'
|
||||
|
||||
const { usesAnthropicAccountFlow } = await importFreshProvidersModule()
|
||||
expect(usesAnthropicAccountFlow()).toBe(false)
|
||||
})
|
||||
|
||||
test('HTTP Anthropic URLs do not enable the first-party account flow', async () => {
|
||||
clearProviderEnv()
|
||||
process.env.ANTHROPIC_BASE_URL = 'http://api.anthropic.com'
|
||||
|
||||
const { isFirstPartyAnthropicBaseUrl, usesAnthropicAccountFlow } =
|
||||
await importFreshProvidersModule()
|
||||
expect(isFirstPartyAnthropicBaseUrl()).toBe(false)
|
||||
expect(usesAnthropicAccountFlow()).toBe(false)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['CLAUDE_CODE_USE_OPENAI', 'openai'],
|
||||
['CLAUDE_CODE_USE_GITHUB', 'github'],
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
resolveActiveRouteIdFromEnv,
|
||||
} from '../../integrations/routeMetadata.js'
|
||||
import { isEnvTruthy } from '../envUtils.js'
|
||||
import { isFirstPartyAnthropicBaseUrlForEnv } from '../anthropicBaseUrl.js'
|
||||
|
||||
// Legacy provider categories that older model/status/runtime callers still
|
||||
// consume. Descriptor route ids are the newer source of truth, but we keep
|
||||
@@ -82,7 +83,15 @@ export function getAPIProvider(): LegacyAPIProvider {
|
||||
}
|
||||
|
||||
export function usesAnthropicAccountFlow(): boolean {
|
||||
return getAPIProvider() === 'firstParty'
|
||||
return isFirstPartyAnthropicProvider()
|
||||
}
|
||||
|
||||
export function isFirstPartyAnthropicProvider(): boolean {
|
||||
return getAPIProvider() === 'firstParty' && isFirstPartyAnthropicBaseUrl()
|
||||
}
|
||||
|
||||
export function isCustomAnthropicProvider(): boolean {
|
||||
return getAPIProvider() === 'firstParty' && !isFirstPartyAnthropicBaseUrl()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,18 +128,5 @@ export function getAPIProviderForStatsig(): AnalyticsMetadata_I_VERIFIED_THIS_IS
|
||||
* (or api-staging.anthropic.com for ant users).
|
||||
*/
|
||||
export function isFirstPartyAnthropicBaseUrl(): boolean {
|
||||
const baseUrl = process.env.ANTHROPIC_BASE_URL
|
||||
if (!baseUrl) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const host = new URL(baseUrl).host
|
||||
const allowedHosts = ['api.anthropic.com']
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
allowedHosts.push('api-staging.anthropic.com')
|
||||
}
|
||||
return allowedHosts.includes(host)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return isFirstPartyAnthropicBaseUrlForEnv(process.env)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered
|
||||
import { MODEL_ALIASES } from './aliases.js'
|
||||
import { isModelAllowed } from './modelAllowlist.js'
|
||||
import { getAPIProvider } from './providers.js'
|
||||
import { getAPIProvider, isFirstPartyAnthropicProvider } from './providers.js'
|
||||
import { sideQuery } from '../sideQuery.js'
|
||||
import {
|
||||
NotFoundError,
|
||||
@@ -218,7 +218,7 @@ function handleValidationError(
|
||||
* Suggest a fallback model for 3P users when the selected model is unavailable.
|
||||
*/
|
||||
function get3PFallbackSuggestion(model: string): string | undefined {
|
||||
if (getAPIProvider() === 'firstParty') {
|
||||
if (isFirstPartyAnthropicProvider()) {
|
||||
return undefined
|
||||
}
|
||||
const lowerModel = model.toLowerCase()
|
||||
|
||||
@@ -21,10 +21,15 @@ const ENV_KEYS = [
|
||||
'CLAUDE_CODE_USE_MISTRAL',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLAUDE_CODE_USE_FOUNDRY',
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENAI_API_BASE',
|
||||
'OPENAI_API_KEY',
|
||||
'OPENAI_MODEL',
|
||||
'OPENAI_API_FORMAT',
|
||||
'OPENAI_AUTH_HEADER',
|
||||
'OPENAI_AUTH_SCHEME',
|
||||
'OPENAI_AUTH_HEADER_VALUE',
|
||||
'GEMINI_MODEL',
|
||||
'NVIDIA_API_KEY',
|
||||
'NVIDIA_NIM',
|
||||
@@ -39,6 +44,11 @@ const ENV_KEYS = [
|
||||
'CLOUDFLARE_API_TOKEN',
|
||||
'MISTRAL_MODEL',
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_CUSTOM_HEADERS',
|
||||
'USER_TYPE',
|
||||
]
|
||||
|
||||
const originalEnv: Record<string, string | undefined> = {}
|
||||
@@ -58,10 +68,15 @@ const RESET_KEYS = [
|
||||
'CLAUDE_CODE_USE_MISTRAL',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLAUDE_CODE_USE_FOUNDRY',
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENAI_API_BASE',
|
||||
'OPENAI_API_KEY',
|
||||
'OPENAI_MODEL',
|
||||
'OPENAI_API_FORMAT',
|
||||
'OPENAI_AUTH_HEADER',
|
||||
'OPENAI_AUTH_SCHEME',
|
||||
'OPENAI_AUTH_HEADER_VALUE',
|
||||
'GEMINI_MODEL',
|
||||
'NVIDIA_API_KEY',
|
||||
'NVIDIA_NIM',
|
||||
@@ -76,6 +91,11 @@ const RESET_KEYS = [
|
||||
'CLOUDFLARE_API_TOKEN',
|
||||
'MISTRAL_MODEL',
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_CUSTOM_HEADERS',
|
||||
'USER_TYPE',
|
||||
] as const
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -137,6 +157,102 @@ describe('applyProviderFlag - anthropic', () => {
|
||||
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
|
||||
expect(process.env.CLAUDE_CODE_USE_GEMINI).toBeUndefined()
|
||||
})
|
||||
|
||||
test('clears a previously selected custom Anthropic endpoint', () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://proxy.example/v1'
|
||||
process.env.ANTHROPIC_MODEL = 'proxy-model'
|
||||
process.env.ANTHROPIC_API_KEY = 'proxy-api-key'
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'proxy-token'
|
||||
process.env.ANTHROPIC_CUSTOM_HEADERS = 'x-tenant: example'
|
||||
|
||||
const result = applyProviderFlag('anthropic', [])
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(process.env.ANTHROPIC_BASE_URL).toBeUndefined()
|
||||
expect(process.env.ANTHROPIC_MODEL).toBeUndefined()
|
||||
expect(process.env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(process.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
||||
expect(process.env.ANTHROPIC_CUSTOM_HEADERS).toBeUndefined()
|
||||
})
|
||||
|
||||
test('preserves a first-party Anthropic API key', () => {
|
||||
process.env.ANTHROPIC_API_KEY = 'first-party-key'
|
||||
|
||||
const result = applyProviderFlag('anthropic', [])
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(process.env.ANTHROPIC_API_KEY).toBe('first-party-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyProviderFlag - custom Anthropic-compatible', () => {
|
||||
test('requires a custom endpoint instead of sending its credential to Anthropic', () => {
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'proxy-token'
|
||||
|
||||
const result = applyProviderFlag('custom-anthropic', [])
|
||||
|
||||
expect(result.error).toContain('ANTHROPIC_BASE_URL')
|
||||
})
|
||||
|
||||
test('rejects the first-party Anthropic endpoint instead of forwarding a custom credential', () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com'
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'proxy-token'
|
||||
|
||||
const result = applyProviderFlag('custom-anthropic', [])
|
||||
|
||||
expect(result.error).toContain('non-Anthropic ANTHROPIC_BASE_URL')
|
||||
})
|
||||
|
||||
test('rejects the internal first-party Anthropic staging endpoint', () => {
|
||||
process.env.USER_TYPE = 'ant'
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://api-staging.anthropic.com'
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'proxy-token'
|
||||
|
||||
const result = applyProviderFlag('custom-anthropic', [])
|
||||
|
||||
expect(result.error).toContain('non-Anthropic ANTHROPIC_BASE_URL')
|
||||
})
|
||||
|
||||
test('keeps native Anthropic routing and applies --model', () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://proxy.example/v1'
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'proxy-token'
|
||||
process.env.ANTHROPIC_API_KEY = 'stale-anthropic-key'
|
||||
process.env.CLAUDE_CODE_USE_FOUNDRY = '1'
|
||||
process.env.OPENAI_BASE_URL = 'https://stale.example/v1'
|
||||
process.env.OPENAI_API_BASE = 'https://stale.example/v1'
|
||||
process.env.OPENAI_API_FORMAT = 'responses'
|
||||
process.env.OPENAI_AUTH_HEADER = 'Authorization'
|
||||
process.env.OPENAI_AUTH_SCHEME = 'bearer'
|
||||
process.env.OPENAI_AUTH_HEADER_VALUE = 'stale-token'
|
||||
|
||||
const result = applyProviderFlag('custom-anthropic', ['--model', 'proxy-model'])
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
|
||||
expect(process.env.CLAUDE_CODE_USE_FOUNDRY).toBeUndefined()
|
||||
expect(process.env.OPENAI_MODEL).toBeUndefined()
|
||||
expect(process.env.OPENAI_BASE_URL).toBeUndefined()
|
||||
expect(process.env.OPENAI_API_BASE).toBeUndefined()
|
||||
expect(process.env.OPENAI_API_FORMAT).toBeUndefined()
|
||||
expect(process.env.OPENAI_AUTH_HEADER).toBeUndefined()
|
||||
expect(process.env.OPENAI_AUTH_SCHEME).toBeUndefined()
|
||||
expect(process.env.OPENAI_AUTH_HEADER_VALUE).toBeUndefined()
|
||||
expect(process.env.ANTHROPIC_BASE_URL).toBe('https://proxy.example/v1')
|
||||
expect(process.env.ANTHROPIC_AUTH_TOKEN).toBe('proxy-token')
|
||||
expect(process.env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(process.env.ANTHROPIC_MODEL).toBe('proxy-model')
|
||||
})
|
||||
|
||||
test('accepts native x-api-key authentication', () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://proxy.example/v1'
|
||||
process.env.ANTHROPIC_API_KEY = 'stale-first-party-key'
|
||||
|
||||
const result = applyProviderFlag('custom-anthropic', [])
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(process.env.ANTHROPIC_API_KEY).toBe('stale-first-party-key')
|
||||
expect(process.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('VALID_PROVIDERS', () => {
|
||||
@@ -149,6 +265,7 @@ describe('VALID_PROVIDERS', () => {
|
||||
expect(VALID_PROVIDERS).toContain('venice')
|
||||
expect(VALID_PROVIDERS).toContain('xiaomi-mimo')
|
||||
expect(VALID_PROVIDERS).toContain('xiaomi-mimo-token')
|
||||
expect(VALID_PROVIDERS).toContain('custom-anthropic')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
import '../integrations/index.js'
|
||||
import {
|
||||
ensureIntegrationsLoaded,
|
||||
getAnthropicProxy,
|
||||
getAllAnthropicProxies,
|
||||
getAllGateways,
|
||||
getAllVendors,
|
||||
getGateway,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
resolveRouteIdFromBaseUrl,
|
||||
} from '../integrations/index.js'
|
||||
import { PRESET_VENDOR_MAP } from '../integrations/compatibility.js'
|
||||
import { isFirstPartyAnthropicBaseUrlForEnv } from './anthropicBaseUrl.js'
|
||||
|
||||
const PREFERRED_PROVIDER_ORDER = [
|
||||
'anthropic',
|
||||
@@ -53,6 +56,7 @@ function buildValidProviders(): string[] {
|
||||
...PRESET_VENDOR_MAP.map(mapping => mapping.preset),
|
||||
...getAllVendors().map(vendor => vendor.id),
|
||||
...getAllGateways().map(gateway => gateway.id),
|
||||
...getAllAnthropicProxies().map(proxy => proxy.id),
|
||||
])
|
||||
|
||||
const preferred = PREFERRED_PROVIDER_ORDER.filter(provider =>
|
||||
@@ -150,11 +154,12 @@ function getRouteDefaults(provider: string): {
|
||||
const gateway =
|
||||
(route.gatewayId ? getGateway(route.gatewayId) : undefined) ??
|
||||
getGateway(route.routeId)
|
||||
const anthropicProxy = getAnthropicProxy(route.routeId)
|
||||
|
||||
const defaultModel = gateway?.defaultModel ?? vendor?.defaultModel
|
||||
const defaultModel = gateway?.defaultModel ?? vendor?.defaultModel ?? anthropicProxy?.defaultModel
|
||||
|
||||
return {
|
||||
defaultBaseUrl: gateway?.defaultBaseUrl ?? vendor?.defaultBaseUrl,
|
||||
defaultBaseUrl: gateway?.defaultBaseUrl ?? vendor?.defaultBaseUrl ?? anthropicProxy?.defaultBaseUrl,
|
||||
defaultModel,
|
||||
}
|
||||
}
|
||||
@@ -330,6 +335,7 @@ export function applyProviderFlag(
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
delete process.env.NVIDIA_NIM
|
||||
if (copiedOpenAIKeyProvider && provider !== copiedOpenAIKeyProvider) {
|
||||
delete process.env.OPENAI_API_KEY
|
||||
@@ -339,8 +345,55 @@ export function applyProviderFlag(
|
||||
const { defaultBaseUrl, defaultModel } = getRouteDefaults(provider)
|
||||
|
||||
switch (provider) {
|
||||
case 'anthropic':
|
||||
// Default — no env vars needed
|
||||
case 'anthropic': {
|
||||
// Default — clear any custom native proxy contract so this explicit
|
||||
// provider flag cannot keep routing requests to a prior endpoint.
|
||||
// Preserve a first-party API key: it is the normal credential for this
|
||||
// provider and may have been supplied directly through the environment.
|
||||
const hadCustomAnthropicEndpoint =
|
||||
!isFirstPartyAnthropicBaseUrlForEnv(process.env)
|
||||
delete process.env.ANTHROPIC_BASE_URL
|
||||
delete process.env.ANTHROPIC_MODEL
|
||||
if (hadCustomAnthropicEndpoint) {
|
||||
delete process.env.ANTHROPIC_API_KEY
|
||||
}
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN
|
||||
delete process.env.ANTHROPIC_CUSTOM_HEADERS
|
||||
break
|
||||
}
|
||||
|
||||
case 'custom-anthropic':
|
||||
if (!process.env.ANTHROPIC_BASE_URL?.trim()) {
|
||||
return {
|
||||
error: 'Custom Anthropic-compatible provider requires ANTHROPIC_BASE_URL.',
|
||||
}
|
||||
}
|
||||
if (isFirstPartyAnthropicBaseUrlForEnv(process.env)) {
|
||||
return {
|
||||
error: 'Custom Anthropic-compatible provider requires a non-Anthropic ANTHROPIC_BASE_URL.',
|
||||
}
|
||||
}
|
||||
const hasAuthToken = Boolean(process.env.ANTHROPIC_AUTH_TOKEN?.trim())
|
||||
const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY?.trim())
|
||||
if (!hasAuthToken && !hasApiKey) {
|
||||
return {
|
||||
error: 'Custom Anthropic-compatible provider requires ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY.',
|
||||
}
|
||||
}
|
||||
if (hasAuthToken) {
|
||||
delete process.env.ANTHROPIC_API_KEY
|
||||
} else {
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN
|
||||
}
|
||||
delete process.env.OPENAI_BASE_URL
|
||||
delete process.env.OPENAI_API_BASE
|
||||
delete process.env.OPENAI_MODEL
|
||||
delete process.env.OPENAI_API_FORMAT
|
||||
delete process.env.OPENAI_AUTH_HEADER
|
||||
delete process.env.OPENAI_AUTH_SCHEME
|
||||
delete process.env.OPENAI_AUTH_HEADER_VALUE
|
||||
process.env.ANTHROPIC_MODEL ??= defaultModel
|
||||
if (model) process.env.ANTHROPIC_MODEL = model
|
||||
break
|
||||
|
||||
case 'openai':
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test, { afterEach, beforeEach } from 'node:test'
|
||||
@@ -508,7 +508,7 @@ test('buildStartupEnvFromProfile preserves explicit OpenAI-compatible env withou
|
||||
})
|
||||
|
||||
test('buildStartupEnvFromProfile preserves concrete env-only NIM setup over stale profile', async () => {
|
||||
const processEnv = {
|
||||
const processEnv: NodeJS.ProcessEnv = {
|
||||
OPENAI_BASE_URL: 'https://integrate.api.nvidia.com/v1',
|
||||
OPENAI_MODEL: 'qwen/qwen3.5-397b-a17b',
|
||||
NVIDIA_API_KEY: 'nvapi-live',
|
||||
@@ -1111,6 +1111,29 @@ test('saveProfileFile writes a profile that loadProfileFile can read back', () =
|
||||
}
|
||||
})
|
||||
|
||||
test('saveProfileFile restricts permissions when overwriting an existing profile', () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'openclaude-profile-mode-'))
|
||||
|
||||
try {
|
||||
const filePath = join(cwd, PROFILE_FILE_NAME)
|
||||
writeFileSync(filePath, '{}', { encoding: 'utf8', mode: 0o644 })
|
||||
chmodSync(filePath, 0o644)
|
||||
|
||||
saveProfileFile(
|
||||
createProfileFile('anthropic', {
|
||||
ANTHROPIC_AUTH_TOKEN: 'custom-bearer-token',
|
||||
}),
|
||||
{ cwd },
|
||||
)
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
assert.equal(statSync(filePath).mode & 0o777, 0o600)
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('saveProfileFile defaults to user config instead of the working directory', async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'openclaude-workspace-profile-'))
|
||||
const configRoot = mkdtempSync(join(tmpdir(), 'openclaude-config-profile-'))
|
||||
@@ -1438,6 +1461,64 @@ test('buildStartupEnvFromProfile applies persisted gemini settings when no provi
|
||||
assert.equal(env.GEMINI_MODEL, 'gemini-2.5-flash')
|
||||
})
|
||||
|
||||
test('buildStartupEnvFromProfile restores a persisted custom Anthropic Bearer token', async () => {
|
||||
const env = await buildStartupEnvFromProfile({
|
||||
persisted: profile('anthropic', {
|
||||
ANTHROPIC_BASE_URL: 'https://anthropic-proxy.example/v1',
|
||||
ANTHROPIC_MODEL: 'claude-proxy-model',
|
||||
ANTHROPIC_AUTH_TOKEN: 'persisted-proxy-token',
|
||||
ANTHROPIC_CUSTOM_HEADERS: 'X-Tenant: example',
|
||||
}),
|
||||
processEnv: {},
|
||||
})
|
||||
|
||||
assert.equal(env.ANTHROPIC_BASE_URL, 'https://anthropic-proxy.example/v1')
|
||||
assert.equal(env.ANTHROPIC_MODEL, 'claude-proxy-model')
|
||||
assert.equal(env.ANTHROPIC_AUTH_TOKEN, 'persisted-proxy-token')
|
||||
assert.equal(env.ANTHROPIC_API_KEY, undefined)
|
||||
assert.equal(env.ANTHROPIC_CUSTOM_HEADERS, 'X-Tenant: example')
|
||||
})
|
||||
|
||||
test('buildStartupEnvFromProfile does not leak a stray API key into a persisted custom Anthropic Bearer profile', async () => {
|
||||
const env = await buildStartupEnvFromProfile({
|
||||
persisted: profile('anthropic', {
|
||||
ANTHROPIC_BASE_URL: 'https://anthropic-proxy.example/v1',
|
||||
ANTHROPIC_MODEL: 'claude-proxy-model',
|
||||
ANTHROPIC_AUTH_TOKEN: 'persisted-proxy-token',
|
||||
}),
|
||||
processEnv: { ANTHROPIC_API_KEY: 'sk-ant-stray-shell-key' },
|
||||
})
|
||||
|
||||
assert.equal(env.ANTHROPIC_AUTH_TOKEN, 'persisted-proxy-token')
|
||||
assert.equal(env.ANTHROPIC_API_KEY, undefined)
|
||||
})
|
||||
|
||||
test('buildStartupEnvFromProfile preserves explicit custom Anthropic environment setup', async () => {
|
||||
const processEnv: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_BASE_URL: 'https://anthropic-proxy.example/v1',
|
||||
ANTHROPIC_MODEL: 'claude-proxy-model',
|
||||
ANTHROPIC_AUTH_TOKEN: 'env-proxy-token',
|
||||
}
|
||||
const env = await buildStartupEnvFromProfile({ persisted: null, processEnv })
|
||||
|
||||
assert.equal(env, processEnv)
|
||||
assert.equal(env.CLAUDE_CODE_USE_OPENAI, undefined)
|
||||
assert.equal(env.ANTHROPIC_AUTH_TOKEN, 'env-proxy-token')
|
||||
})
|
||||
|
||||
test('buildStartupEnvFromProfile preserves custom Anthropic x-api-key setup', async () => {
|
||||
const processEnv: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_BASE_URL: 'https://anthropic-proxy.example/v1',
|
||||
ANTHROPIC_MODEL: 'claude-proxy-model',
|
||||
ANTHROPIC_API_KEY: 'env-proxy-key',
|
||||
}
|
||||
const env = await buildStartupEnvFromProfile({ persisted: null, processEnv })
|
||||
|
||||
assert.equal(env, processEnv)
|
||||
assert.equal(env.CLAUDE_CODE_USE_OPENAI, undefined)
|
||||
assert.equal(env.ANTHROPIC_API_KEY, 'env-proxy-key')
|
||||
})
|
||||
|
||||
test('buildStartupEnvFromProfile rehydrates stored Gemini access token for access-token profile mode', async () => {
|
||||
const env = await buildStartupEnvFromProfile({
|
||||
persisted: profile('gemini', {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import {
|
||||
DEFAULT_CODEX_BASE_URL,
|
||||
@@ -64,6 +64,7 @@ const PROFILE_ENV_KEYS = [
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_CUSTOM_HEADERS',
|
||||
'ANTHROPIC_BEDROCK_BASE_URL',
|
||||
'ANTHROPIC_VERTEX_BASE_URL',
|
||||
@@ -148,6 +149,7 @@ export type ProfileEnv = {
|
||||
ANTHROPIC_BASE_URL?: string
|
||||
ANTHROPIC_MODEL?: string
|
||||
ANTHROPIC_API_KEY?: string
|
||||
ANTHROPIC_AUTH_TOKEN?: string
|
||||
ANTHROPIC_CUSTOM_HEADERS?: string
|
||||
ANTHROPIC_BEDROCK_BASE_URL?: string
|
||||
ANTHROPIC_VERTEX_BASE_URL?: string
|
||||
@@ -1207,6 +1209,7 @@ export function saveProfileFile(
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
})
|
||||
chmodSync(filePath, 0o600)
|
||||
return filePath
|
||||
}
|
||||
|
||||
@@ -1318,6 +1321,21 @@ function hasConcreteProviderSelection(
|
||||
return true
|
||||
}
|
||||
|
||||
// Anthropic-native proxies are selected by their own endpoint, model, and
|
||||
// Bearer token rather than a CLAUDE_CODE_USE_* flag. Treat that complete
|
||||
// contract as explicit so fresh-install fallback cannot replace it with the
|
||||
// default OpenAI-compatible provider.
|
||||
if (
|
||||
sanitizeProviderConfigValue(processEnv.ANTHROPIC_BASE_URL) !== undefined &&
|
||||
normalizeProfileModel(
|
||||
sanitizeProviderConfigValue(processEnv.ANTHROPIC_MODEL),
|
||||
) !== undefined &&
|
||||
(sanitizeApiKey(processEnv.ANTHROPIC_AUTH_TOKEN) !== undefined ||
|
||||
sanitizeApiKey(processEnv.ANTHROPIC_API_KEY) !== undefined)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Env-only provider setups — no CLAUDE_CODE_USE_* flag needed
|
||||
return (
|
||||
sanitizeApiKey(processEnv.FIREWORKS_API_KEY) !== undefined ||
|
||||
@@ -1492,9 +1510,13 @@ export async function buildLaunchEnv(options: {
|
||||
const anthropicBaseUrl =
|
||||
sanitizeProviderConfigValue(processEnv.ANTHROPIC_BASE_URL) ||
|
||||
sanitizeProviderConfigValue(persistedEnv.ANTHROPIC_BASE_URL)
|
||||
const anthropicApiKey =
|
||||
sanitizeApiKey(processEnv.ANTHROPIC_API_KEY) ||
|
||||
sanitizeApiKey(persistedEnv.ANTHROPIC_API_KEY)
|
||||
const anthropicAuthToken =
|
||||
sanitizeApiKey(processEnv.ANTHROPIC_AUTH_TOKEN) ||
|
||||
sanitizeApiKey(persistedEnv.ANTHROPIC_AUTH_TOKEN)
|
||||
const anthropicApiKey = anthropicAuthToken
|
||||
? undefined
|
||||
: sanitizeApiKey(processEnv.ANTHROPIC_API_KEY) ||
|
||||
sanitizeApiKey(persistedEnv.ANTHROPIC_API_KEY)
|
||||
|
||||
return buildCompatibilityProcessEnv({
|
||||
processEnv,
|
||||
@@ -1514,6 +1536,15 @@ export async function buildLaunchEnv(options: {
|
||||
...(anthropicApiKey
|
||||
? { ANTHROPIC_API_KEY: anthropicApiKey }
|
||||
: {}),
|
||||
...(anthropicAuthToken
|
||||
? { ANTHROPIC_AUTH_TOKEN: anthropicAuthToken }
|
||||
: {}),
|
||||
...(shellCustomHeaders || persistedCustomHeaders
|
||||
? {
|
||||
ANTHROPIC_CUSTOM_HEADERS:
|
||||
shellCustomHeaders || persistedCustomHeaders,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const RESTORED_KEYS = [
|
||||
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID',
|
||||
'CLAUDE_CODE_PROVIDER_ROUTE_ID',
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
'OPENCLAUDE_CONFIG_DIR',
|
||||
'CLAUDE_CODE_USE_OPENAI',
|
||||
'CLAUDE_CODE_USE_GEMINI',
|
||||
'CLAUDE_CODE_USE_MISTRAL',
|
||||
@@ -45,6 +46,7 @@ const RESTORED_KEYS = [
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_CUSTOM_HEADERS',
|
||||
'ANTHROPIC_VERTEX_BASE_URL',
|
||||
'GEMINI_BASE_URL',
|
||||
@@ -109,6 +111,7 @@ beforeEach(async () => {
|
||||
}
|
||||
testConfigDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = testConfigDir
|
||||
process.env.OPENCLAUDE_CONFIG_DIR = testConfigDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -2254,6 +2257,20 @@ describe('getProviderPresetDefaults', () => {
|
||||
|
||||
expect(defaults.apiKey).toBe('key-a,key-b')
|
||||
})
|
||||
|
||||
test('custom Anthropic preserves direct endpoint settings but only hydrates a Bearer token', async () => {
|
||||
const { getProviderPresetDefaults } = await importFreshProviderProfileModules()
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://tenant.example/v1'
|
||||
process.env.ANTHROPIC_MODEL = 'tenant-model'
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'bearer-token'
|
||||
process.env.ANTHROPIC_API_KEY = 'native-api-key'
|
||||
|
||||
const defaults = getProviderPresetDefaults('custom-anthropic')
|
||||
|
||||
expect(defaults.baseUrl).toBe('https://tenant.example/v1')
|
||||
expect(defaults.model).toBe('tenant-model')
|
||||
expect(defaults.apiKey).toBe('bearer-token')
|
||||
})
|
||||
test('ollama preset defaults to a local Ollama model', async () => {
|
||||
const { getProviderPresetDefaults } = await importFreshProviderProfileModules()
|
||||
delete process.env.OPENAI_MODEL
|
||||
@@ -3236,6 +3253,44 @@ describe('setActiveProviderProfile', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('persists custom Anthropic-compatible profiles with Bearer token auth', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-'))
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-config-'))
|
||||
process.chdir(tempDir)
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
|
||||
try {
|
||||
const { setActiveProviderProfile } = await importFreshProviderProfileModules()
|
||||
const profile = buildProfile({
|
||||
id: 'custom_anthropic_prof',
|
||||
name: 'Custom Anthropic',
|
||||
provider: 'custom-anthropic',
|
||||
baseUrl: 'https://anthropic-proxy.example',
|
||||
model: 'claude-proxy-model',
|
||||
apiKey: 'proxy-token',
|
||||
})
|
||||
saveMockGlobalConfig(current => ({ ...current, providerProfiles: [profile] }))
|
||||
|
||||
const result = setActiveProviderProfile('custom_anthropic_prof', { configDir })
|
||||
const persisted = JSON.parse(readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'))
|
||||
|
||||
expect(result?.id).toBe('custom_anthropic_prof')
|
||||
expect(process.env.ANTHROPIC_BASE_URL).toBe('https://anthropic-proxy.example')
|
||||
expect(process.env.ANTHROPIC_MODEL).toBe('claude-proxy-model')
|
||||
expect(process.env.ANTHROPIC_AUTH_TOKEN).toBe('proxy-token')
|
||||
expect(process.env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(persisted.env).toEqual({
|
||||
ANTHROPIC_BASE_URL: 'https://anthropic-proxy.example',
|
||||
ANTHROPIC_MODEL: 'claude-proxy-model',
|
||||
ANTHROPIC_AUTH_TOKEN: 'proxy-token',
|
||||
})
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
rmSync(configDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('sets ANTHROPIC_MODEL env var when switching to an anthropic-type provider', async () => {
|
||||
const { setActiveProviderProfile } =
|
||||
await importFreshProviderProfileModules()
|
||||
@@ -3443,6 +3498,101 @@ describe('deleteProviderProfile', () => {
|
||||
expect(process.env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
})
|
||||
|
||||
test('deleting the active custom Anthropic profile removes its startup mirror', async () => {
|
||||
const {
|
||||
deleteProviderProfile,
|
||||
setActiveProviderProfile,
|
||||
} = await importFreshProviderProfileModules()
|
||||
const profile = buildProfile({
|
||||
id: 'custom_anthropic_profile',
|
||||
provider: 'custom-anthropic',
|
||||
baseUrl: 'https://proxy.example',
|
||||
model: 'proxy-model',
|
||||
apiKey: 'bearer-token',
|
||||
})
|
||||
saveMockGlobalConfig(current => ({
|
||||
...current,
|
||||
providerProfiles: [profile],
|
||||
activeProviderProfileId: profile.id,
|
||||
}))
|
||||
|
||||
setActiveProviderProfile(profile.id, { configDir: testConfigDir ?? undefined })
|
||||
const profilePath = join(testConfigDir!, '.openclaude-profile.json')
|
||||
expect(existsSync(profilePath)).toBe(true)
|
||||
|
||||
deleteProviderProfile(profile.id)
|
||||
|
||||
expect(existsSync(profilePath)).toBe(false)
|
||||
})
|
||||
|
||||
test('updating the active custom Anthropic profile synchronizes its startup mirror', async () => {
|
||||
const { setActiveProviderProfile, updateProviderProfile } =
|
||||
await importFreshProviderProfileModules()
|
||||
const profile = buildProfile({
|
||||
id: 'custom_anthropic_profile',
|
||||
provider: 'custom-anthropic',
|
||||
baseUrl: 'https://proxy.example',
|
||||
model: 'proxy-model',
|
||||
apiKey: 'old-token',
|
||||
})
|
||||
saveMockGlobalConfig(current => ({
|
||||
...current,
|
||||
providerProfiles: [profile],
|
||||
activeProviderProfileId: profile.id,
|
||||
}))
|
||||
|
||||
setActiveProviderProfile(profile.id, { configDir: testConfigDir ?? undefined })
|
||||
updateProviderProfile(profile.id, {
|
||||
...profile,
|
||||
baseUrl: 'https://new-proxy.example',
|
||||
model: 'new-proxy-model',
|
||||
apiKey: 'new-token',
|
||||
})
|
||||
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(testConfigDir!, '.openclaude-profile.json'), 'utf8'),
|
||||
)
|
||||
expect(persisted.env.ANTHROPIC_BASE_URL).toBe('https://new-proxy.example')
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('new-proxy-model')
|
||||
expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBe('new-token')
|
||||
})
|
||||
|
||||
test('deleting an active custom Anthropic profile persists its replacement', async () => {
|
||||
const { deleteProviderProfile, setActiveProviderProfile } =
|
||||
await importFreshProviderProfileModules()
|
||||
const activeProfile = buildProfile({
|
||||
id: 'custom_anthropic_profile',
|
||||
provider: 'custom-anthropic',
|
||||
baseUrl: 'https://proxy.example',
|
||||
model: 'proxy-model',
|
||||
apiKey: 'bearer-token',
|
||||
})
|
||||
const replacement = buildProfile({
|
||||
id: 'replacement_profile',
|
||||
baseUrl: 'https://replacement.example/v1',
|
||||
model: 'replacement-model',
|
||||
apiKey: 'replacement-token',
|
||||
})
|
||||
saveMockGlobalConfig(current => ({
|
||||
...current,
|
||||
providerProfiles: [activeProfile, replacement],
|
||||
activeProviderProfileId: activeProfile.id,
|
||||
}))
|
||||
|
||||
setActiveProviderProfile(activeProfile.id, {
|
||||
configDir: testConfigDir ?? undefined,
|
||||
})
|
||||
deleteProviderProfile(activeProfile.id)
|
||||
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(testConfigDir!, '.openclaude-profile.json'), 'utf8'),
|
||||
)
|
||||
expect(persisted.profile).toBe('openai')
|
||||
expect(persisted.env.OPENAI_BASE_URL).toBe('https://replacement.example/v1')
|
||||
expect(persisted.env.OPENAI_MODEL).toBe('replacement-model')
|
||||
expect(persisted.env.OPENAI_API_KEY).toBe('replacement-token')
|
||||
})
|
||||
|
||||
test('deleting final profile preserves explicit startup provider env', async () => {
|
||||
const { deleteProviderProfile } = await importFreshProviderProfileModules()
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
|
||||
@@ -212,13 +212,16 @@ function resolveProfileCapabilityRouteId(
|
||||
provider: string,
|
||||
baseUrl?: string,
|
||||
): string {
|
||||
const providerRouteId = resolveProfileRoute(provider).routeId
|
||||
if (providerRouteId === 'custom-anthropic') {
|
||||
return providerRouteId
|
||||
}
|
||||
|
||||
const routeIdFromBaseUrl = resolveRouteIdFromBaseUrl(baseUrl)
|
||||
if (routeIdFromBaseUrl) {
|
||||
return routeIdFromBaseUrl
|
||||
}
|
||||
|
||||
const providerRouteId = resolveProfileRoute(provider).routeId
|
||||
|
||||
// A cloudflare profile retargeted away from the real Workers AI endpoint
|
||||
// (e.g. to gateway.ai.cloudflare.com or another OpenAI-compatible host) is
|
||||
// run generically at runtime — resolveActiveRouteIdFromEnv no longer resolves
|
||||
@@ -405,6 +408,16 @@ function applySupportedProfileCustomHeaders(
|
||||
return customHeaders ? { ...env, ANTHROPIC_CUSTOM_HEADERS: customHeaders } : env
|
||||
}
|
||||
|
||||
function buildAnthropicCredentialEnv(
|
||||
provider: ProviderProfile['provider'],
|
||||
apiKey: string | undefined,
|
||||
): ProfileEnv {
|
||||
if (!apiKey) return {}
|
||||
return provider === 'custom-anthropic'
|
||||
? { ANTHROPIC_AUTH_TOKEN: apiKey }
|
||||
: { ANTHROPIC_API_KEY: apiKey }
|
||||
}
|
||||
|
||||
function getModelCacheByProfile(
|
||||
profileId: string,
|
||||
config = getGlobalConfig(),
|
||||
@@ -444,7 +457,7 @@ export function getProviderPresetDefaults(
|
||||
// Keep preset-pinned endpoints/models even when generic OpenAI env values
|
||||
// are present, but still read provider-specific credential env vars above.
|
||||
const routeDefaults =
|
||||
preset === 'custom'
|
||||
preset === 'custom' || preset === 'custom-anthropic'
|
||||
? metadata
|
||||
: getProviderPresetUiMetadata(preset, {})
|
||||
return {
|
||||
@@ -452,7 +465,13 @@ export function getProviderPresetDefaults(
|
||||
name: metadata.name,
|
||||
baseUrl: routeDefaults.baseUrl,
|
||||
model: routeDefaults.model,
|
||||
apiKey: metadata.apiKey,
|
||||
// The /provider custom Anthropic flow always saves a Bearer token. Keep
|
||||
// direct ANTHROPIC_API_KEY/x-api-key setups out of that field so opening
|
||||
// the preset cannot silently change their authentication scheme.
|
||||
apiKey:
|
||||
preset === 'custom-anthropic'
|
||||
? process.env.ANTHROPIC_AUTH_TOKEN?.trim() || undefined
|
||||
: metadata.apiKey,
|
||||
requiresApiKey: metadata.requiresApiKey,
|
||||
}
|
||||
}
|
||||
@@ -499,6 +518,14 @@ function hasCompleteProviderSelection(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
if (resolveEnvOnlyProviderRouteId(processEnv) !== null) return true
|
||||
if (
|
||||
trimOrUndefined(processEnv.ANTHROPIC_BASE_URL) !== undefined &&
|
||||
trimOrUndefined(processEnv.ANTHROPIC_MODEL) !== undefined &&
|
||||
(trimOrUndefined(processEnv.ANTHROPIC_AUTH_TOKEN) !== undefined ||
|
||||
trimOrUndefined(processEnv.ANTHROPIC_API_KEY) !== undefined)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (!hasProviderSelectionFlags(processEnv)) return false
|
||||
if (processEnv.CLAUDE_CODE_USE_OPENAI !== undefined) {
|
||||
return (
|
||||
@@ -591,7 +618,9 @@ function isProcessEnvAlignedWithProfile(
|
||||
sameOptionalEnvValue(processEnv.ANTHROPIC_BASE_URL, profile.baseUrl) &&
|
||||
sameOptionalEnvValue(processEnv.ANTHROPIC_MODEL, primaryModel) &&
|
||||
(!includeApiKey ||
|
||||
sameOptionalEnvValue(processEnv.ANTHROPIC_API_KEY, profile.apiKey))
|
||||
(profile.provider === 'custom-anthropic'
|
||||
? sameOptionalEnvValue(processEnv.ANTHROPIC_AUTH_TOKEN, profile.apiKey)
|
||||
: sameOptionalEnvValue(processEnv.ANTHROPIC_API_KEY, profile.apiKey)))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -838,7 +867,7 @@ export function applyProviderProfileToProcessEnv(
|
||||
profileEnv = {
|
||||
ANTHROPIC_BASE_URL: profile.baseUrl,
|
||||
ANTHROPIC_MODEL: primaryModel,
|
||||
...(profile.apiKey ? { ANTHROPIC_API_KEY: profile.apiKey } : {}),
|
||||
...buildAnthropicCredentialEnv(profile.provider, profile.apiKey),
|
||||
}
|
||||
}
|
||||
} else if (compatibilityMode === 'mistral') {
|
||||
@@ -1185,7 +1214,7 @@ export function updateProviderProfile(
|
||||
}
|
||||
|
||||
if (shouldApply) {
|
||||
applyProviderProfileToProcessEnv(updatedProfile)
|
||||
setActiveProviderProfile(profileId)
|
||||
}
|
||||
|
||||
return updatedProfile
|
||||
@@ -1398,9 +1427,10 @@ function buildStartupProfileFromActiveProfile(
|
||||
env: applySupportedProfileCustomHeaders(activeProfile, {
|
||||
ANTHROPIC_BASE_URL: activeProfile.baseUrl,
|
||||
ANTHROPIC_MODEL: getPrimaryModel(activeProfile.model),
|
||||
...(activeProfile.apiKey
|
||||
? { ANTHROPIC_API_KEY: activeProfile.apiKey }
|
||||
: {}),
|
||||
...buildAnthropicCredentialEnv(
|
||||
activeProfile.provider,
|
||||
activeProfile.apiKey,
|
||||
),
|
||||
}),
|
||||
}
|
||||
case 'gemini': {
|
||||
@@ -1626,6 +1656,7 @@ export function deleteProviderProfile(profileId: string): {
|
||||
let removed = false
|
||||
let deletedProfile: ProviderProfile | undefined
|
||||
let nextActiveProfile: ProviderProfile | undefined
|
||||
let activeProfileWasDeleted = false
|
||||
|
||||
saveGlobalConfig(current => {
|
||||
const currentProfiles = getProviderProfiles(current)
|
||||
@@ -1648,6 +1679,7 @@ export function deleteProviderProfile(profileId: string): {
|
||||
currentActive === profileId ||
|
||||
(currentActive !== ANTHROPIC_DEFAULT_PROFILE_ID &&
|
||||
!nextProfiles.some(profile => profile.id === currentActive))
|
||||
activeProfileWasDeleted = activeWasDeleted
|
||||
|
||||
const nextActiveId = activeWasDeleted ? nextProfiles[0]?.id : currentActive
|
||||
|
||||
@@ -1682,14 +1714,16 @@ export function deleteProviderProfile(profileId: string): {
|
||||
})
|
||||
|
||||
if (nextActiveProfile) {
|
||||
applyProviderProfileToProcessEnv(nextActiveProfile)
|
||||
} else if (
|
||||
deletedProfile &&
|
||||
isProcessEnvAlignedWithProfile(process.env, deletedProfile, {
|
||||
includeApiKey: false,
|
||||
})
|
||||
) {
|
||||
clearProviderProfileEnvFromProcessEnv()
|
||||
setActiveProviderProfile(nextActiveProfile.id)
|
||||
} else if (deletedProfile && activeProfileWasDeleted) {
|
||||
if (
|
||||
isProcessEnvAlignedWithProfile(process.env, deletedProfile, {
|
||||
includeApiKey: false,
|
||||
})
|
||||
) {
|
||||
clearProviderProfileEnvFromProcessEnv()
|
||||
}
|
||||
deleteProfileFile()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('clearStartupProviderOverrides', () => {
|
||||
OPENAI_API_KEY: 'single-key',
|
||||
MINIMAX_API_KEY: 'sk-minimax',
|
||||
VENICE_API_KEY: 'sk-venice',
|
||||
ANTHROPIC_AUTH_TOKEN: 'stale-proxy-token',
|
||||
KEEP_ME: '1',
|
||||
},
|
||||
}),
|
||||
@@ -39,6 +40,7 @@ describe('clearStartupProviderOverrides', () => {
|
||||
OPENAI_API_KEY: undefined,
|
||||
MINIMAX_API_KEY: undefined,
|
||||
VENICE_API_KEY: undefined,
|
||||
ANTHROPIC_AUTH_TOKEN: undefined,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ export const STARTUP_PROVIDER_OVERRIDE_ENV_KEYS = [
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_CUSTOM_HEADERS',
|
||||
'GEMINI_API_KEY',
|
||||
'GOOGLE_API_KEY',
|
||||
|
||||
@@ -15,7 +15,7 @@ import { isJetBrainsPluginInstalledCachedSync } from './jetbrains.js';
|
||||
import type { LocalModelContextWarning } from './statusNoticeLocalModel.js';
|
||||
import type { PermissionMode } from './permissions/PermissionMode.js';
|
||||
import { modelSupportsAutoMode } from './betas.js';
|
||||
import { getAPIProvider } from './model/providers.js';
|
||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './model/providers.js';
|
||||
import { logForDebugging } from './debug.js';
|
||||
|
||||
// Types
|
||||
@@ -269,7 +269,7 @@ const thirdPartyPermissiveModeNotice: StatusNoticeDefinition = {
|
||||
if (ctx.mainLoopModel && modelSupportsAutoMode(ctx.mainLoopModel)) {
|
||||
return false;
|
||||
}
|
||||
return getAPIProvider() !== 'firstParty';
|
||||
return getAPIProvider() !== 'firstParty' || !isFirstPartyAnthropicBaseUrl();
|
||||
},
|
||||
render: ctx => {
|
||||
const mode = ctx.permissionMode;
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
import { getCanonicalName } from './model/model.js'
|
||||
import { resolveAntModel } from './model/antModels.js'
|
||||
import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js'
|
||||
import { getAPIProvider } from './model/providers.js'
|
||||
import {
|
||||
getAPIProvider,
|
||||
} from './model/providers.js'
|
||||
import { getSettingsWithErrors } from './settings/settings.js'
|
||||
import { isEnvTruthy } from './envUtils.js'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user