From 9efe4878e7251d3b66d0ece7135706fdacf5a334 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 14 Jun 2026 18:23:48 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=85=B0=EF=B8=8F=20feat:=20Native=20Anthro?= =?UTF-8?q?pic=20Provider=20for=20Custom=20Endpoints=20(#13748)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🅰️ feat: Native Anthropic provider for Custom Endpoints Let a custom endpoint declare `provider: anthropic` to use the native Anthropic `/v1/messages` client (the agents SDK's ChatAnthropic) against its own `baseURL`/`apiKey`/`headers`, instead of being forced through the OpenAI-compatible client. Enables Anthropic itself and Anthropic-compatible gateways (AI gateways, OpenCode Zen, etc.) as custom endpoints — including for agents and role-scoped model access. Closes #10655 (Option 1: explicit provider). - Schema: add optional `provider` (currently `anthropic`) to the custom `endpointSchema` in data-provider. - Routing: `getProviderConfig` maps a custom endpoint with `provider: anthropic` to `Providers.ANTHROPIC` (was always `Providers.OPENAI`). - Config: `initializeCustom` builds the native Anthropic config via the Anthropic `getLLMConfig` (custom baseURL/apiKey/headers) and returns `provider: anthropic`; `useLegacyContent` is left unset to match the built-in Anthropic endpoint. The OpenAI-compatible path is unchanged for endpoints without `provider`. - Summarization: `resolveSummarizationProvider` builds an Anthropic config for a cross-endpoint native-Anthropic summarization target (self-summarize already reuses the agent's client options). Title generation already resolves via `agent.endpoint`, and provider-specific handling (tool conflicts, content/PDF validation, token counting, streamUsage) already branches on `Providers.ANTHROPIC`, so it applies automatically. Note: model auto-fetch (`models.fetch`) uses the OpenAI `/models` convention and is not used for this provider — list models explicitly under `models.default`. * 🅰️ fix: Anthropic custom-endpoint param parity (Codex review) Address Codex P2 findings — the native Anthropic path must match the OpenAI-compatible path's parameter handling: - UI param set: `loadCustomEndpointsConfig` now surfaces `provider` as the client `customParams.defaultParamsEndpoint`, so the Agents model panel shows Anthropic fields (`maxOutputTokens`/`thinking`) instead of OpenAI `max_tokens` (which the native initializer ignored). An explicit non-default `defaultParamsEndpoint` still wins. - Provider override: `getProviderConfig` re-applies `provider: anthropic` after all `customEndpointConfig` resolution, so it also wins when the endpoint name collides with a known custom provider (e.g. `openrouter`) — fixing the token/context budget derived from `overrideProvider`. - Default params: the native path (and cross-endpoint Anthropic summarization) now apply `customParams.paramDefinitions` defaults via `extractDefaultParams`, matching what `getOpenAIConfig` does for the OpenAI-compatible path. Adds tests for each. --- librechat.example.yaml | 23 +++++ packages/api/src/agents/run.ts | 34 +++++++ .../src/endpoints/config/providers.spec.ts | 47 +++++++++- .../api/src/endpoints/config/providers.ts | 12 +++ .../api/src/endpoints/custom/config.spec.ts | 44 +++++++++ packages/api/src/endpoints/custom/config.ts | 16 +++- .../src/endpoints/custom/initialize.spec.ts | 90 +++++++++++++++++++ .../api/src/endpoints/custom/initialize.ts | 77 ++++++++++++++-- packages/data-provider/src/config.ts | 8 ++ 9 files changed, 340 insertions(+), 11 deletions(-) create mode 100644 packages/api/src/endpoints/custom/config.spec.ts diff --git a/librechat.example.yaml b/librechat.example.yaml index 45997869f8..fc27ef8c76 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -508,6 +508,29 @@ endpoints: # # deploymentName: claude-3-5-haiku@20241022 # Override for this model custom: + # Anthropic-compatible Example (native `/v1/messages` API) + # Set `provider: anthropic` to use the native Anthropic client instead of the + # default OpenAI-compatible one — for Anthropic itself or Anthropic-compatible + # gateways (e.g. AI gateways, OpenCode Zen). The `baseURL` must be the API root + # the Anthropic SDK appends `/v1/messages` to. List models explicitly: model + # auto-fetch uses the OpenAI `/models` convention and is not used for this provider. + - name: 'Claude-Compatible' + provider: 'anthropic' + apiKey: '${ANTHROPIC_API_KEY}' + baseURL: 'https://api.anthropic.com' + # (optional) headers forwarded on every request (e.g. for a reverse proxy); + # values support the same placeholders as built-in endpoints. + headers: + anthropic-version: '2023-06-01' + models: + default: + - 'claude-sonnet-4-5' + - 'claude-opus-4-5' + fetch: false + titleConvo: true + titleModel: 'claude-sonnet-4-5' + modelDisplayLabel: 'Claude (Compatible)' + # Groq Example - name: 'groq' apiKey: '${GROQ_API_KEY}' diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index ebdf04e385..833213a86c 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -2,6 +2,7 @@ import { logger } from '@librechat/data-schemas'; import { Run, Providers, Constants } from '@librechat/agents'; import { KnownEndpoints, + EModelEndpoint, MAX_SUBAGENT_DEPTH, MAX_SUBAGENT_RUN_CONFIGS, extractEnvVariable, @@ -33,7 +34,9 @@ import type { BaseMessage } from '@librechat/agents/langchain/messages'; import type { AppConfig, IUser } from '@librechat/data-schemas'; import type { SubagentUsageEvent } from '~/agents/usage'; import type * as t from '~/types'; +import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm'; import { getProviderConfig } from '~/endpoints/config/providers'; +import { extractDefaultParams } from '~/endpoints/openai/llm'; import { resolveHeaders, createSafeUser } from '~/utils/env'; import { getOpenAIConfig } from '~/endpoints/openai/config'; import { resolveConfigHeaders } from '~/utils/headers'; @@ -457,6 +460,37 @@ function resolveSummarizationProvider( body: headerContext.requestBody, }) : undefined; + /** + * Native Anthropic custom endpoints must build their config with the + * Anthropic client (`/v1/messages`), not `getOpenAIConfig` (which would emit + * OpenAI-shaped requests). The self-summarize case is handled earlier by + * `isSameEndpointAsAgent`; this covers summarizing against a *different* + * Anthropic-native custom endpoint. + */ + if (customEndpointConfig.provider === EModelEndpoint.anthropic) { + const { llmConfig } = getAnthropicLLMConfig(apiKey, { + modelOptions: {}, + proxy: process.env.PROXY ?? undefined, + reverseProxyUrl: baseURL, + headers: resolvedHeaders, + addParams: customEndpointConfig.addParams, + dropParams: customEndpointConfig.dropParams, + defaultParams: extractDefaultParams(customEndpointConfig.customParams?.paramDefinitions), + }); + const { apiKey: resolvedApiKey, ...llmConfigOverrides } = llmConfig as Record< + string, + unknown + >; + const clientOverrides: SummarizationClientOverrides = { ...llmConfigOverrides }; + if (typeof resolvedApiKey === 'string') { + clientOverrides.apiKey = resolvedApiKey; + } + /** Strip the default model so the user-supplied `summarization.model` wins. */ + delete clientOverrides.model; + delete clientOverrides.modelName; + return { provider: Providers.ANTHROPIC, clientOverrides }; + } + /** * Run the endpoint config through `getOpenAIConfig` so summarization * inherits the same `headers`, `defaultQuery`, `addParams`/`dropParams`, diff --git a/packages/api/src/endpoints/config/providers.spec.ts b/packages/api/src/endpoints/config/providers.spec.ts index 6045cde218..ecd3bfa76a 100644 --- a/packages/api/src/endpoints/config/providers.spec.ts +++ b/packages/api/src/endpoints/config/providers.spec.ts @@ -4,7 +4,7 @@ import type { AppConfig } from '@librechat/data-schemas'; import { getProviderConfig, providerConfigMap, resolveTitleTiming } from './providers'; const buildAppConfig = ( - customEndpoints: Array<{ name: string; baseURL?: string; apiKey?: string }>, + customEndpoints: Array<{ name: string; baseURL?: string; apiKey?: string; provider?: string }>, ): AppConfig => ({ endpoints: { @@ -96,6 +96,51 @@ describe('getProviderConfig', () => { getProviderConfig({ provider: 'openrouter', appConfig: buildAppConfig([]) }), ).toThrow('Provider openrouter not supported'); }); + + it('routes a custom endpoint with provider:anthropic to the Anthropic client', () => { + const appConfig = buildAppConfig([ + { + name: 'Claude-Compatible', + baseURL: 'https://gateway.example.com', + apiKey: 'sk-ant', + provider: EModelEndpoint.anthropic, + }, + ]); + + const result = getProviderConfig({ provider: 'Claude-Compatible', appConfig }); + + expect(result.overrideProvider).toBe(Providers.ANTHROPIC); + expect(result.customEndpointConfig?.provider).toBe(EModelEndpoint.anthropic); + }); + + it('defaults a custom endpoint without provider to the OpenAI-compatible client', () => { + const appConfig = buildAppConfig([ + { name: 'My-LLM', baseURL: 'https://api.example.com/v1', apiKey: 'sk-test' }, + ]); + + const result = getProviderConfig({ provider: 'My-LLM', appConfig }); + + expect(result.overrideProvider).toBe(Providers.OPENAI); + expect(result.customEndpointConfig?.name).toBe('My-LLM'); + }); + + it('applies provider:anthropic even when the endpoint name collides with a known custom provider', () => { + // `openrouter` resolves via `providerConfigMap` first (skipping the generic + // custom branch); the override must still be re-applied from the config so + // overrideProvider-derived values (token/context budget) use the Anthropic map. + const appConfig = buildAppConfig([ + { + name: 'openrouter', + baseURL: 'https://gateway.example.com', + apiKey: 'sk-ant', + provider: EModelEndpoint.anthropic, + }, + ]); + + const result = getProviderConfig({ provider: 'openrouter', appConfig }); + + expect(result.overrideProvider).toBe(Providers.ANTHROPIC); + }); }); describe('resolveTitleTiming', () => { diff --git a/packages/api/src/endpoints/config/providers.ts b/packages/api/src/endpoints/config/providers.ts index deab8071d6..b0d34abfe7 100644 --- a/packages/api/src/endpoints/config/providers.ts +++ b/packages/api/src/endpoints/config/providers.ts @@ -198,6 +198,18 @@ export function getProviderConfig({ } } + /** + * Custom endpoints default to the OpenAI-compatible client. An explicit + * `provider: anthropic` routes them through the native Anthropic `/v1/messages` + * client (`initializeCustom` builds the right config). Applied here — after all + * `customEndpointConfig` resolution — so it also wins when the endpoint name + * collides with a known custom-provider (e.g. `openrouter`), ensuring + * `overrideProvider`-derived values (token/context budget) use the Anthropic map. + */ + if (customEndpointConfig?.provider === EModelEndpoint.anthropic) { + overrideProvider = Providers.ANTHROPIC; + } + return { getOptions, overrideProvider, diff --git a/packages/api/src/endpoints/custom/config.spec.ts b/packages/api/src/endpoints/custom/config.spec.ts new file mode 100644 index 0000000000..0b1b1eb20a --- /dev/null +++ b/packages/api/src/endpoints/custom/config.spec.ts @@ -0,0 +1,44 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TCustomEndpoints } from 'librechat-data-provider'; +import { loadCustomEndpointsConfig } from './config'; + +const baseEndpoint = { + apiKey: 'sk-test', + baseURL: 'https://gateway.example.com', + models: { default: ['claude-sonnet-4-5'] }, +}; + +describe('loadCustomEndpointsConfig – native provider param set', () => { + it('synthesizes defaultParamsEndpoint from provider so the UI shows the right params', () => { + const config = loadCustomEndpointsConfig([ + { ...baseEndpoint, name: 'Claude-Compatible', provider: EModelEndpoint.anthropic }, + ] as unknown as TCustomEndpoints); + + expect(config?.['Claude-Compatible']?.customParams?.defaultParamsEndpoint).toBe( + EModelEndpoint.anthropic, + ); + }); + + it('does not set defaultParamsEndpoint for endpoints without a provider', () => { + const config = loadCustomEndpointsConfig([ + { ...baseEndpoint, name: 'My-LLM' }, + ] as unknown as TCustomEndpoints); + + expect(config?.['My-LLM']?.customParams).toBeUndefined(); + }); + + it('respects an explicit non-default defaultParamsEndpoint over the provider', () => { + const config = loadCustomEndpointsConfig([ + { + ...baseEndpoint, + name: 'Claude-Compatible', + provider: EModelEndpoint.anthropic, + customParams: { defaultParamsEndpoint: EModelEndpoint.google }, + }, + ] as unknown as TCustomEndpoints); + + expect(config?.['Claude-Compatible']?.customParams?.defaultParamsEndpoint).toBe( + EModelEndpoint.google, + ); + }); +}); diff --git a/packages/api/src/endpoints/custom/config.ts b/packages/api/src/endpoints/custom/config.ts index 5b3bd88ce2..f8d2de47e5 100644 --- a/packages/api/src/endpoints/custom/config.ts +++ b/packages/api/src/endpoints/custom/config.ts @@ -35,17 +35,31 @@ export function loadCustomEndpointsConfig( iconURL, modelDisplayLabel, customParams, + provider, } = endpoint; const name = normalizeEndpointName(configName); const resolvedApiKey = extractEnvVariable(apiKey ?? ''); const resolvedBaseURL = extractEnvVariable(baseURL ?? ''); + /** + * A native `provider` (e.g. anthropic) implies its parameter set. Surface it + * as `defaultParamsEndpoint` so the client param panel shows the right fields + * (e.g. `maxOutputTokens`/`thinking` for Anthropic, not OpenAI `max_tokens`), + * unless an admin explicitly chose a non-default `defaultParamsEndpoint`. + */ + const resolvedCustomParams = + provider != null && + (customParams?.defaultParamsEndpoint == null || + customParams.defaultParamsEndpoint === EModelEndpoint.custom) + ? { ...customParams, defaultParamsEndpoint: provider } + : customParams; + customEndpointsConfig[name] = { type: EModelEndpoint.custom, userProvide: isUserProvided(resolvedApiKey), userProvideURL: isUserProvided(resolvedBaseURL), - customParams, + customParams: resolvedCustomParams, modelDisplayLabel, iconURL, }; diff --git a/packages/api/src/endpoints/custom/initialize.spec.ts b/packages/api/src/endpoints/custom/initialize.spec.ts index c9d05a3001..e2266f936e 100644 --- a/packages/api/src/endpoints/custom/initialize.spec.ts +++ b/packages/api/src/endpoints/custom/initialize.spec.ts @@ -394,3 +394,93 @@ describe('initializeCustom – token-config fetch header forwarding', () => { }); }); }); + +describe('initializeCustom – native Anthropic provider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + function createAnthropicParams( + config: Record, + model_parameters: Record = { model: 'claude-sonnet-4-5' }, + ): BaseInitializeParams { + mockGetCustomEndpointConfig.mockReturnValue(config); + return { + req: { + user: { id: 'user-1' }, + body: { conversationId: 'convo-1' }, + config: {}, + } as unknown as BaseInitializeParams['req'], + endpoint: 'Claude-Compatible', + model_parameters, + db: { + getUserKeyValues: jest.fn(), + getUserKey: jest.fn(), + } as unknown as BaseInitializeParams['db'], + }; + } + + it('builds a native Anthropic config pointed at the custom baseURL/apiKey', async () => { + const params = createAnthropicParams({ + provider: 'anthropic', + apiKey: 'sk-ant-custom', + baseURL: 'https://gateway.example.com', + headers: { 'anthropic-version': '2023-06-01' }, + models: { default: ['claude-sonnet-4-5'] }, + }); + + const options = await initializeCustom(params); + + /** Routed to the native Anthropic client, not the OpenAI-compatible one */ + expect(mockGetOpenAIConfig).not.toHaveBeenCalled(); + expect(options.provider).toBe('anthropic'); + /** Custom baseURL/key wired into the native Anthropic config */ + expect(options.llmConfig).toHaveProperty('anthropicApiUrl', 'https://gateway.example.com'); + expect(options.llmConfig).toHaveProperty('apiKey', 'sk-ant-custom'); + /** Configured header attached (kept unresolved for request-time resolution) */ + const defaultHeaders = ( + options.llmConfig as { clientOptions?: { defaultHeaders?: Record } } + ).clientOptions?.defaultHeaders; + expect(defaultHeaders?.['anthropic-version']).toBe('2023-06-01'); + /** Native Anthropic path must NOT use OpenAI legacy content formatting */ + expect(options.useLegacyContent).toBeUndefined(); + }); + + it('applies customParams.paramDefinitions defaults on the native path', async () => { + const params = createAnthropicParams({ + provider: 'anthropic', + apiKey: 'sk-ant-custom', + baseURL: 'https://gateway.example.com', + models: { default: ['claude-sonnet-4-5'] }, + customParams: { + defaultParamsEndpoint: 'anthropic', + paramDefinitions: [{ key: 'web_search', default: true }], + }, + }); + + const options = await initializeCustom(params); + + /** `web_search: true` default flows through to the Anthropic web_search tool */ + expect(options.tools).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'web_search' })]), + ); + }); + + it('still uses the OpenAI-compatible client when no provider is set', async () => { + const params = createAnthropicParams({ + apiKey: 'sk-test', + baseURL: 'https://api.example.com/v1', + models: { default: ['gpt-4o'] }, + }); + + const options = await initializeCustom(params); + + expect(mockGetOpenAIConfig).toHaveBeenCalledWith( + 'sk-test', + expect.any(Object), + 'Claude-Compatible', + ); + expect(options.useLegacyContent).toBe(true); + expect(options.provider).toBeUndefined(); + }); +}); diff --git a/packages/api/src/endpoints/custom/initialize.ts b/packages/api/src/endpoints/custom/initialize.ts index 0f83794920..59f3aacf7e 100644 --- a/packages/api/src/endpoints/custom/initialize.ts +++ b/packages/api/src/endpoints/custom/initialize.ts @@ -1,12 +1,21 @@ +import { Providers } from '@librechat/agents'; import { ErrorTypes, envVarRegex, + EModelEndpoint, FetchTokenConfig, extractEnvVariable, } from 'librechat-data-provider'; import type { TEndpoint } from 'librechat-data-provider'; import type { AppConfig } from '@librechat/data-schemas'; -import type { BaseInitializeParams, InitializeResultBase, EndpointTokenConfig } from '~/types'; +import type { + BaseInitializeParams, + InitializeResultBase, + EndpointTokenConfig, + AnthropicModelOptions, +} from '~/types'; +import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm'; +import { extractDefaultParams } from '~/endpoints/openai/llm'; import { isUserProvided, checkUserKeyExpiry } from '~/utils'; import { getOpenAIConfig } from '~/endpoints/openai/config'; import { getCustomEndpointConfig } from '~/app/config'; @@ -90,6 +99,42 @@ function buildCustomOptions( return customOptions; } +/** + * Builds a native Anthropic (`/v1/messages`) config for a custom endpoint that + * declares `provider: anthropic`, pointing the Anthropic client at the custom + * `baseURL`/`apiKey`. Returns `provider: anthropic` so the agent uses the native + * Anthropic client instead of the OpenAI-compatible one. Headers stay unresolved + * here and resolve at request time via `resolveConfigHeaders`. + */ +function buildAnthropicCustomConfig({ + apiKey, + baseURL, + modelOptions, + endpointConfig, +}: { + apiKey: string; + baseURL: string; + modelOptions: AnthropicModelOptions; + endpointConfig: Partial; +}): InitializeResultBase { + const result = getAnthropicLLMConfig(apiKey, { + modelOptions, + proxy: PROXY ?? undefined, + reverseProxyUrl: baseURL, + headers: endpointConfig.headers, + addParams: endpointConfig.addParams, + dropParams: endpointConfig.dropParams, + /** Apply admin `customParams.paramDefinitions` defaults (e.g. promptCache, + * web_search, thinking) the OpenAI-compatible path gets via `getOpenAIConfig`. */ + defaultParams: extractDefaultParams(endpointConfig.customParams?.paramDefinitions), + }); + return { + llmConfig: result.llmConfig as InitializeResultBase['llmConfig'], + tools: result.tools, + provider: Providers.ANTHROPIC, + }; +} + /** * Initializes a custom endpoint client configuration. * This function handles custom endpoints defined in librechat.yaml, including @@ -233,15 +278,29 @@ export async function initializeCustom({ }; const modelOptions = { ...(model_parameters ?? {}), user: userId }; - const finalClientOptions = { - modelOptions, - ...clientOptions, - }; - const options = getOpenAIConfig(apiKey, finalClientOptions, endpoint); - if (options != null) { - (options as InitializeResultBase).useLegacyContent = true; - (options as InitializeResultBase).endpointTokenConfig = endpointTokenConfig; + let options: InitializeResultBase; + if (endpointConfig.provider === EModelEndpoint.anthropic) { + /** Native Anthropic `/v1/messages` client against the custom baseURL/apiKey. + * `useLegacyContent` is intentionally left unset (matches the built-in + * Anthropic endpoint, which uses native content formatting). */ + options = buildAnthropicCustomConfig({ + apiKey, + baseURL, + modelOptions: modelOptions as AnthropicModelOptions, + endpointConfig, + }); + options.endpointTokenConfig = endpointTokenConfig; + } else { + const finalClientOptions = { + modelOptions, + ...clientOptions, + }; + options = getOpenAIConfig(apiKey, finalClientOptions, endpoint); + if (options != null) { + options.useLegacyContent = true; + options.endpointTokenConfig = endpointTokenConfig; + } } const streamRate = clientOptions.streamRate as number | undefined; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 50da11b7b2..8edbd8d8fe 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -841,6 +841,14 @@ export const endpointSchema = baseEndpointSchema.merge( }), iconURL: z.string().optional(), modelDisplayLabel: z.string().optional(), + /** + * Forces the endpoint to use a provider's native client / request format + * instead of the default OpenAI-compatible client. Currently supports + * `anthropic`, for endpoints that speak the Anthropic `/v1/messages` API + * (Anthropic itself or Anthropic-compatible gateways). Omit for + * OpenAI-compatible endpoints. + */ + provider: z.literal(EModelEndpoint.anthropic).optional(), headers: z.record(z.string()).optional(), addParams: addParamsSchema.optional(), dropParams: z.array(z.string()).optional(),