diff --git a/core/llm/llms/Anthropic.ts b/core/llm/llms/Anthropic.ts index a1fdfbe359..35e9845edd 100644 --- a/core/llm/llms/Anthropic.ts +++ b/core/llm/llms/Anthropic.ts @@ -426,6 +426,7 @@ class Anthropic extends BaseLLM { const headers = getAnthropicHeaders( this.apiKey, shouldCacheSystemMessage || shouldCachePrompt, + this.apiBase, ); const body: MessageCreateParams = { diff --git a/packages/openai-adapters/src/apis/Anthropic.ts b/packages/openai-adapters/src/apis/Anthropic.ts index 66656957a1..6b601b424d 100644 --- a/packages/openai-adapters/src/apis/Anthropic.ts +++ b/packages/openai-adapters/src/apis/Anthropic.ts @@ -456,7 +456,7 @@ export class AnthropicApi implements BaseLlmApi { private getHeaders(): Record { const enableCaching = this.config?.cachingStrategy !== "none"; - return getAnthropicHeaders(this.config.apiKey, enableCaching); + return getAnthropicHeaders(this.config.apiKey, enableCaching, this.apiBase); } async completionNonStream( diff --git a/packages/openai-adapters/src/apis/AnthropicUtils.test.ts b/packages/openai-adapters/src/apis/AnthropicUtils.test.ts new file mode 100644 index 0000000000..7ed572c805 --- /dev/null +++ b/packages/openai-adapters/src/apis/AnthropicUtils.test.ts @@ -0,0 +1,193 @@ +import { vi } from "vitest"; +import { + getAnthropicHeaders, + isAzureAnthropicEndpoint, +} from "./AnthropicUtils.js"; + +describe("isAzureAnthropicEndpoint", () => { + describe("should return true for Azure endpoints", () => { + it("detects Azure AI Foundry endpoint", () => { + expect( + isAzureAnthropicEndpoint( + "https://my-resource.services.ai.azure.com/anthropic", + ), + ).toBe(true); + }); + + it("detects Azure Cognitive Services endpoint", () => { + expect( + isAzureAnthropicEndpoint( + "https://my-resource.cognitiveservices.azure.com/anthropic", + ), + ).toBe(true); + }); + + it("handles case insensitivity", () => { + expect( + isAzureAnthropicEndpoint( + "https://my-resource.SERVICES.AI.AZURE.COM/anthropic", + ), + ).toBe(true); + }); + + it("handles mixed case", () => { + expect( + isAzureAnthropicEndpoint( + "https://My-Resource.Services.AI.Azure.Com/anthropic/v1/messages", + ), + ).toBe(true); + }); + }); + + describe("should return false for non-Azure endpoints", () => { + it("returns false for standard Anthropic endpoint", () => { + expect(isAzureAnthropicEndpoint("https://api.anthropic.com")).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isAzureAnthropicEndpoint(undefined)).toBe(false); + }); + + it("returns false for empty string", () => { + expect(isAzureAnthropicEndpoint("")).toBe(false); + }); + + it("returns false for invalid URL", () => { + expect(isAzureAnthropicEndpoint("not-a-valid-url")).toBe(false); + }); + + it("returns false for other providers", () => { + expect(isAzureAnthropicEndpoint("https://api.openai.com/v1")).toBe(false); + }); + }); + + describe("URL parsing security", () => { + it("rejects Azure pattern in path (not hostname)", () => { + // This should NOT match - Azure domain is in path, not hostname + expect( + isAzureAnthropicEndpoint( + "https://evil.com/services.ai.azure.com/anthropic", + ), + ).toBe(false); + }); + + it("rejects Azure pattern as subdomain of non-Azure domain", () => { + expect( + isAzureAnthropicEndpoint( + "https://services.ai.azure.com.evil.com/anthropic", + ), + ).toBe(false); + }); + }); +}); + +describe("getAnthropicHeaders", () => { + describe("authentication header", () => { + it("uses x-api-key for standard Anthropic endpoint", () => { + const headers = getAnthropicHeaders( + "test-key", + false, + "https://api.anthropic.com", + ); + expect(headers["x-api-key"]).toBe("test-key"); + expect(headers["api-key"]).toBeUndefined(); + }); + + it("uses api-key for Azure AI Foundry endpoint", () => { + const headers = getAnthropicHeaders( + "azure-key", + false, + "https://my-resource.services.ai.azure.com/anthropic", + ); + expect(headers["api-key"]).toBe("azure-key"); + expect(headers["x-api-key"]).toBeUndefined(); + }); + + it("uses api-key for Azure Cognitive Services endpoint", () => { + const headers = getAnthropicHeaders( + "azure-key", + false, + "https://my-resource.cognitiveservices.azure.com/anthropic", + ); + expect(headers["api-key"]).toBe("azure-key"); + expect(headers["x-api-key"]).toBeUndefined(); + }); + + it("uses x-api-key when apiBase is undefined", () => { + const headers = getAnthropicHeaders("test-key", false, undefined); + expect(headers["x-api-key"]).toBe("test-key"); + expect(headers["api-key"]).toBeUndefined(); + }); + }); + + describe("caching headers", () => { + it("includes anthropic-beta header when caching is enabled", () => { + const headers = getAnthropicHeaders("test-key", true); + expect(headers["anthropic-beta"]).toBe("prompt-caching-2024-07-31"); + }); + + it("does not include anthropic-beta header when caching is disabled", () => { + const headers = getAnthropicHeaders("test-key", false); + expect(headers["anthropic-beta"]).toBeUndefined(); + }); + + it("includes caching header for Azure endpoints too", () => { + const headers = getAnthropicHeaders( + "azure-key", + true, + "https://my-resource.services.ai.azure.com/anthropic", + ); + expect(headers["anthropic-beta"]).toBe("prompt-caching-2024-07-31"); + }); + }); + + describe("standard headers", () => { + it("always includes anthropic-version header", () => { + const headers = getAnthropicHeaders("test-key", false); + expect(headers["anthropic-version"]).toBe("2023-06-01"); + }); + + it("always includes Content-Type and Accept headers", () => { + const headers = getAnthropicHeaders("test-key", false); + expect(headers["Content-Type"]).toBe("application/json"); + expect(headers["Accept"]).toBe("application/json"); + }); + }); + + describe("key/endpoint mismatch warning", () => { + it("warns when Azure endpoint is used with Anthropic-style key", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + getAnthropicHeaders( + "sk-ant-test-key", + false, + "https://my-resource.services.ai.azure.com/anthropic", + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Azure endpoint detected"), + ); + warnSpy.mockRestore(); + }); + + it("does not warn when Azure endpoint is used with Azure key", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + getAnthropicHeaders( + "azure-api-key-12345", + false, + "https://my-resource.services.ai.azure.com/anthropic", + ); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("does not warn when standard Anthropic endpoint is used with Anthropic key", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + getAnthropicHeaders( + "sk-ant-test-key", + false, + "https://api.anthropic.com", + ); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); +}); diff --git a/packages/openai-adapters/src/apis/AnthropicUtils.ts b/packages/openai-adapters/src/apis/AnthropicUtils.ts index cc0df5ee01..46b28e5785 100644 --- a/packages/openai-adapters/src/apis/AnthropicUtils.ts +++ b/packages/openai-adapters/src/apis/AnthropicUtils.ts @@ -34,15 +34,57 @@ export function getAnthropicErrorMessage(response: ErrorResponse): string { } } +/** + * Detects if the given API base URL is an Azure-hosted Anthropic endpoint. + * Azure AI Foundry hosts Anthropic models but requires a different auth header. + * + * Supported Azure endpoint patterns: + * - *.services.ai.azure.com (Azure AI Foundry) + * - *.cognitiveservices.azure.com (Azure Cognitive Services) + * + * @param apiBase - The API base URL to check + * @returns true if the endpoint is an Azure-hosted Anthropic endpoint + */ +export function isAzureAnthropicEndpoint(apiBase?: string): boolean { + if (!apiBase) { + return false; + } + + try { + const url = new URL(apiBase); + const hostname = url.hostname.toLowerCase(); + return ( + hostname.endsWith(".services.ai.azure.com") || + hostname.endsWith(".cognitiveservices.azure.com") + ); + } catch { + // Invalid URL - fall back to false + return false; + } +} + export function getAnthropicHeaders( apiKey: string, enableCaching: boolean, + apiBase?: string, ): Record { + const isAzure = isAzureAnthropicEndpoint(apiBase); + + // Warn if Azure endpoint is used with Anthropic-style key + if (isAzure && apiKey.startsWith("sk-ant-")) { + console.warn( + "[Continue] Azure endpoint detected but API key appears to be a standard Anthropic key (sk-ant-*). " + + "Azure endpoints require Azure API keys from your AI Foundry resource.", + ); + } + + const authHeaderName = isAzure ? "api-key" : "x-api-key"; + const headers: Record = { "Content-Type": "application/json", Accept: "application/json", "anthropic-version": "2023-06-01", - "x-api-key": apiKey, + [authHeaderName]: apiKey, }; if (enableCaching) {