From fdef4a1b4ce218ded4937ca83b30acce7c726472 Mon Sep 17 00:00:00 2001 From: Zartris Date: Mon, 20 Apr 2026 10:34:58 +0200 Subject: [PATCH] feat: native Anthropic API mode for Claude models on GitHub Copilot (#579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: native Anthropic API mode for Claude models on GitHub Copilot When using Claude models through GitHub Copilot, automatically switch from the OpenAI-compatible shim to Anthropic's native messages API format. The Copilot proxy (api.githubcopilot.com) supports Anthropic's native API for Claude models. This enables cache_control blocks to be sent and honoured, allowing explicit prompt caching control (as opposed to relying solely on server-side auto-caching). Changes: - Add isGithubNativeAnthropicMode() in providers.ts that auto-enables when the resolved model starts with "claude-" and the GitHub provider is active - Create a native Anthropic client in client.ts using the GitHub base URL and Bearer token authentication when native mode is detected - Enable prompt caching in claude.ts for native GitHub mode so cache_control blocks are sent (previously only allowed for firstParty/bedrock/vertex) - CLAUDE_CODE_GITHUB_ANTHROPIC_API=1 env var to force native mode for any model Benefits: - Proper Anthropic message format (no lossy OpenAI translation) - Explicit cache_control blocks for fine-grained caching control - Potentially better Claude model behaviour with native format Related: #515 * fix: scope force flag to Claude models and add isGithubNativeAnthropicMode tests - CLAUDE_CODE_GITHUB_ANTHROPIC_API=1 now returns false for non-Claude models (force flag still useful for aliases like 'github:copilot' with no model resolved yet, where it returns true when model is empty) - Add 7 focused tests covering mode detection: off without GitHub provider, auto-detect via OPENAI_MODEL and resolvedModel, non-Claude model rejection, and force-flag behaviour for claude/non-claude/no-model cases * fix: detect github:copilot:claude- compound format, remove force flag OPENAI_MODEL for GitHub Copilot uses the format 'github:copilot:MODEL' (e.g. 'github:copilot:claude-sonnet-4'), which does not start with 'claude-'. Auto-detection now handles both bare model names and the compound format. The CLAUDE_CODE_GITHUB_ANTHROPIC_API force flag is removed: with proper compound-format detection there is no remaining gap it could fill, and keeping a broad override flag without a concrete use case invites misuse. Tests updated to cover the compound format, generic alias (false), and non-Claude compound model (github:copilot:gpt-4o → false). * fix: use includes('claude-') for model detection, remove force flag Detection was broken for the standard GitHub Copilot compound format 'github:copilot:claude-sonnet-4' which does not start with 'claude-'. Using includes('claude-') handles bare names, compound names, and any future variants without needing updates. The CLAUDE_CODE_GITHUB_ANTHROPIC_API force flag is removed as it was a workaround for the broken detection, not a genuine use case. --------- Co-authored-by: Zartris <14197299+Zartris@users.noreply.github.com> --- src/services/api/claude.ts | 8 ++++- src/services/api/client.ts | 20 +++++++++++ src/utils/model/providers.test.ts | 57 +++++++++++++++++++++++++++++++ src/utils/model/providers.ts | 18 ++++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 053bdb6c..1e924193 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -23,6 +23,7 @@ import { randomUUID } from 'crypto' import { getAPIProvider, isFirstPartyAnthropicBaseUrl, + isGithubNativeAnthropicMode, } from 'src/utils/model/providers.js' import { getAttributionHeader, @@ -334,8 +335,13 @@ export function getPromptCachingEnabled(model: string): boolean { // Prompt caching is an Anthropic-specific feature. Third-party providers // do not understand cache_control blocks and strict backends (e.g. Azure // Foundry) reject or flag requests that contain them. + // + // Exception: when the GitHub provider is configured in native Anthropic API + // mode (CLAUDE_CODE_GITHUB_ANTHROPIC_API=1), requests are sent in Anthropic + // format, so cache_control blocks are supported. const provider = getAPIProvider() - if (provider !== 'firstParty' && provider !== 'bedrock' && provider !== 'vertex') { + const isNativeGithub = isGithubNativeAnthropicMode(model) + if (provider !== 'firstParty' && provider !== 'bedrock' && provider !== 'vertex' && !isNativeGithub) { return false } diff --git a/src/services/api/client.ts b/src/services/api/client.ts index dbeb8651..63d90e71 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -14,6 +14,7 @@ import { getSmallFastModel } from 'src/utils/model/model.js' import { getAPIProvider, isFirstPartyAnthropicBaseUrl, + isGithubNativeAnthropicMode, } from 'src/utils/model/providers.js' import { getProxyFetchOptions } from 'src/utils/proxy.js' import { @@ -174,6 +175,25 @@ export async function getAnthropicClient({ providerOverride, }) as unknown as Anthropic } + // GitHub provider in native Anthropic API mode: send requests in Anthropic + // format so cache_control blocks are honoured and prompt caching works. + // Requires the GitHub endpoint (OPENAI_BASE_URL) to support Anthropic's + // messages API — set CLAUDE_CODE_GITHUB_ANTHROPIC_API=1 to opt in. + if (isGithubNativeAnthropicMode(model)) { + const githubBaseUrl = + process.env.OPENAI_BASE_URL?.replace(/\/$/, '') ?? + 'https://api.githubcopilot.com' + const githubToken = + process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? '' + const nativeArgs: ConstructorParameters[0] = { + ...ARGS, + baseURL: githubBaseUrl, + authToken: githubToken, + // No apiKey — we authenticate via Bearer token (authToken) + apiKey: null, + } + return new Anthropic(nativeArgs) + } if ( isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) || isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) || diff --git a/src/utils/model/providers.test.ts b/src/utils/model/providers.test.ts index a8e84069..6e18c98d 100644 --- a/src/utils/model/providers.test.ts +++ b/src/utils/model/providers.test.ts @@ -107,3 +107,60 @@ test('official OpenAI base URLs now keep provider detection on openai for aliase const { getAPIProvider } = await importFreshProvidersModule() expect(getAPIProvider()).toBe('openai') }) + +// isGithubNativeAnthropicMode + +test('isGithubNativeAnthropicMode: false when CLAUDE_CODE_USE_GITHUB is not set', async () => { + clearProviderEnv() + process.env.OPENAI_MODEL = 'claude-sonnet-4-5' + const { isGithubNativeAnthropicMode } = await importFreshProvidersModule() + expect(isGithubNativeAnthropicMode()).toBe(false) +}) + +test('isGithubNativeAnthropicMode: true for bare claude- model via OPENAI_MODEL', async () => { + clearProviderEnv() + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_MODEL = 'claude-sonnet-4-5' + const { isGithubNativeAnthropicMode } = await importFreshProvidersModule() + expect(isGithubNativeAnthropicMode()).toBe(true) +}) + +test('isGithubNativeAnthropicMode: true for github:copilot:claude- compound format', async () => { + clearProviderEnv() + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_MODEL = 'github:copilot:claude-sonnet-4' + const { isGithubNativeAnthropicMode } = await importFreshProvidersModule() + expect(isGithubNativeAnthropicMode()).toBe(true) +}) + +test('isGithubNativeAnthropicMode: true when resolvedModel is a claude- model', async () => { + clearProviderEnv() + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_MODEL = 'github:copilot' + const { isGithubNativeAnthropicMode } = await importFreshProvidersModule() + expect(isGithubNativeAnthropicMode('claude-haiku-4-5')).toBe(true) +}) + +test('isGithubNativeAnthropicMode: false for generic github:copilot alias', async () => { + clearProviderEnv() + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_MODEL = 'github:copilot' + const { isGithubNativeAnthropicMode } = await importFreshProvidersModule() + expect(isGithubNativeAnthropicMode()).toBe(false) +}) + +test('isGithubNativeAnthropicMode: false for non-Claude model', async () => { + clearProviderEnv() + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_MODEL = 'gpt-4o' + const { isGithubNativeAnthropicMode } = await importFreshProvidersModule() + expect(isGithubNativeAnthropicMode()).toBe(false) +}) + +test('isGithubNativeAnthropicMode: false for github:copilot:gpt- model', async () => { + clearProviderEnv() + process.env.CLAUDE_CODE_USE_GITHUB = '1' + process.env.OPENAI_MODEL = 'github:copilot:gpt-4o' + const { isGithubNativeAnthropicMode } = await importFreshProvidersModule() + expect(isGithubNativeAnthropicMode()).toBe(false) +}) diff --git a/src/utils/model/providers.ts b/src/utils/model/providers.ts index 55675cd6..aed15e55 100644 --- a/src/utils/model/providers.ts +++ b/src/utils/model/providers.ts @@ -45,6 +45,24 @@ export function getAPIProvider(): APIProvider { export function usesAnthropicAccountFlow(): boolean { return getAPIProvider() === 'firstParty' } + +/** + * Returns true when the GitHub provider should use Anthropic's native API + * format instead of the OpenAI-compatible shim. + * + * Enabled when CLAUDE_CODE_USE_GITHUB=1 and the model string contains "claude-" + * anywhere (handles bare names like "claude-sonnet-4" and compound formats like + * "github:copilot:claude-sonnet-4" or any future provider-prefixed variants). + * + * api.githubcopilot.com supports Anthropic native format for Claude models, + * enabling prompt caching via cache_control blocks which significantly reduces + * per-turn token costs by caching the system prompt and tool definitions. + */ +export function isGithubNativeAnthropicMode(resolvedModel?: string): boolean { + if (!isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) return false + const model = resolvedModel?.trim() || process.env.OPENAI_MODEL?.trim() || '' + return model.toLowerCase().includes('claude-') +} function isCodexModel(): boolean { return shouldUseCodexTransport( process.env.OPENAI_MODEL || '',