diff --git a/.env.example b/.env.example index e9a6df142..565ac4da3 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 59a426e35..9fcb0c330 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -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 | diff --git a/docs/integrations/overview.md b/docs/integrations/overview.md index 8e1b0ba79..fa28a55b9 100644 --- a/docs/integrations/overview.md +++ b/docs/integrations/overview.md @@ -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`. diff --git a/src/components/Feedback.tsx b/src/components/Feedback.tsx index 41a433e89..6c2d63046 100644 --- a/src/components/Feedback.tsx +++ b/src/components/Feedback.tsx @@ -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 diff --git a/src/components/FeedbackSurvey/submitTranscriptShare.ts b/src/components/FeedbackSurvey/submitTranscriptShare.ts index c9f691105..03436c21c 100644 --- a/src/components/FeedbackSurvey/submitTranscriptShare.ts +++ b/src/components/FeedbackSurvey/submitTranscriptShare.ts @@ -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 { + if (!isFirstPartyAnthropicProvider()) { + return { success: false } + } try { logForDebugging('Collecting transcript for sharing', { level: 'info' }) diff --git a/src/components/LogoV2/LogoV2.tsx b/src/components/LogoV2/LogoV2.tsx index 4fb51255f..52b5d6dee 100644 --- a/src/components/LogoV2/LogoV2.tsx +++ b/src/components/LogoV2/LogoV2.tsx @@ -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 diff --git a/src/components/ProviderManager.test.tsx b/src/components/ProviderManager.test.tsx index dc045137e..be4bc69a2 100644 --- a/src/components/ProviderManager.test.tsx +++ b/src/components/ProviderManager.test.tsx @@ -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) diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index 7150d08e0..63326dd84 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -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 { {editingProfileId ? 'Edit provider profile' : 'Create provider profile'} - {currentStep.helpText} + {displayStep.helpText} Provider type:{' '} {getRouteProviderTypeLabel(resolveProfileRoute(draftProvider).routeId)} @@ -2171,7 +2201,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { ) : null} - Step {formStepIndex + 1} of {formSteps.length}: {currentStep.label} + Step {formStepIndex + 1} of {formSteps.length}: {displayStep.label} {currentStepKey === 'apiFormat' ? (