🅰️ feat: Native Anthropic Provider for Custom Endpoints (#13748)

* 🅰️ 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.
This commit is contained in:
Danny Avila
2026-06-14 18:23:48 -04:00
committed by GitHub
parent 44c253d48a
commit 9efe4878e7
9 changed files with 340 additions and 11 deletions

View File

@@ -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}'

View File

@@ -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`,

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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,
);
});
});

View File

@@ -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,
};

View File

@@ -394,3 +394,93 @@ describe('initializeCustom token-config fetch header forwarding', () => {
});
});
});
describe('initializeCustom native Anthropic provider', () => {
beforeEach(() => {
jest.clearAllMocks();
});
function createAnthropicParams(
config: Record<string, unknown>,
model_parameters: Record<string, unknown> = { 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<string, string> } }
).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();
});
});

View File

@@ -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<TEndpoint>;
}): 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;

View File

@@ -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(),