-
[OPIK-4551] [BE] [FE] [DOCS] feat: support Azure OpenAI REST API via Custom LLM provider (#6474)
发布于
2026-04-27 10:18:16 +00:00 - [OPIK-4551] [BE] test: add WireMock baseline for Custom LLM URL + error behavior
Adds 7 integration tests documenting current Custom LLM provider behavior
as a regression baseline for OPIK-4551:- legacyCustomProviderBehavesAsTodayWhenNoNewKeysSet — backward-compat
contract lock: no URL/header mutation when no new config keys are set. - openAiStyleErrorBodyIsPropagated / azureStyleErrorBodyIsPropagated —
current error-body propagation; Azure-shape falls through to 500 today. - baseUrlPathSuffixIsPreserved / queryParamInBaseUrlIsMangled — URL
construction; proves a dedicated url_query_params config is needed. - structuredContentIsPreservedForVisionModels /
structuredContentIsFlattenedForNonVisionModels — AC #5 holds for
gpt-4o family; documents pre-existing flattenContent bug for non-vision
models (separate ticket).
No production code changed.
- [OPIK-4551] [BE] feat: support custom query params for Custom LLM providers
Introduces a thin HTTP-client decorator over LangChain4j's JdkHttpClient that
mutates outgoing Custom LLM requests based on optional keys in the existing
provider configuration map. The first configurable key is:- configuration["url_query_params"]: JSON-encoded Map<String,String> whose
entries are URL-encoded and appended to every outbound request URL. This
unblocks Azure OpenAI APIM gateways that require the mandatory
api-version query parameter, which cannot be smuggled via base_url
because LangChain4j's string concatenation of /chat/completions would
corrupt the query string.
The decorator is a pure no-op when no configuration keys it understands are
set, preserving the existing Custom LLM contract byte-for-byte for Ollama,
vLLM, and every other OpenAI-compatible provider in use today. Guarded by
the legacyCustomProviderBehavesAsTodayWhenNoNewKeysSet test introduced in
the baseline commit.Adds urlQueryParamsAreAppendedToUpstreamRequest integration test that
verifies both api-version and a second arbitrary query parameter reach the
upstream cleanly via WireMock verify assertions.- [OPIK-4551] [BE] feat: support custom auth header and optional Bearer suppression
Adds two new optional configuration keys for Custom LLM providers:
- configuration["auth_header_name"]: when set, appends a custom auth header
{name}: {apiKey} alongside LangChain4j's default Authorization: Bearer.
Used by Azure APIM gateways that expect api-key (or a tenant-specific
header name) rather than Bearer tokens. - configuration["suppress_default_auth"]: when "true", removes the default
Authorization: Bearer header for gateways whose policy rejects it.
Defaults to false to preserve today's behavior for every existing
Custom LLM provider.
Plumbs the decrypted apiKey through InterceptingHttpClientBuilder so the
decorator can use it as the custom header value. The apiKey is already
decrypted by LlmProviderFactoryImpl.buildConfig at call time (line 74);
the decorator receives the same plaintext LangChain4j would have used for
Bearer — no new secret-handling code path.Adds two integration tests:
- authHeaderNameAddsCustomHeaderAlongsideBearer verifies both headers
reach the upstream when suppress_default_auth is off. - suppressDefaultAuthDropsBearer verifies only the custom header is sent
when suppression is on.
The backward-compat tripwire (legacyCustomProviderBehavesAsTodayWhenNoNewKeysSet)
continues to pass, confirming the decorator is a no-op for providers
without any of the new configuration keys.- [OPIK-4551] [BE] feat: substitute {model} placeholder in Custom LLM base URL
Lets a single Custom LLM provider entry serve many deployments on gateways
that bake the deployment name into the path. When the configured base_url
contains the literal '{model}' placeholder, InterceptingHttpClient parses
the outgoing request body, reads the model field (already stripped of the
'custom-llm/<provider_name>/' prefix by CustomLlmProvider.cleanModelName
and OpikOpenAiChatModel respectively), and substitutes it into the URL.Use case: Azure APIM gateways expect paths like
/openai/deployments/{deployment}/chat/completions
Operators can configure a single provider entry with
base_url = https://gw/openai/deployments/{model}
and list 20+ deployments in the Models field. End users pick any
deployment from the single dropdown; the decorator routes each request to
the matching upstream path at request time.If the URL contains no placeholder, this code path is entirely skipped —
Ollama, vLLM, bare OpenAI-compat setups remain unaffected (validated by
the legacyCustomProviderBehavesAsTodayWhenNoNewKeysSet tripwire).Adds modelPlaceholderIsSubstitutedInUrl integration test that reuses one
provider entry to fire two requests against two deployments and verifies
each lands on the correct upstream path, with no literal '{model}' reaching
the gateway.- [OPIK-4551] [BE] feat: surface Azure-shape error responses cleanly
Extends CustomLlmErrorMessage to understand OpenAI's and Azure's nested
object error shape (for example {"error": {"code": "InvalidAPIVersion",
"message": "..."}}) alongside the historical OpenAI-compat text shape
({"error": "message"}). Before this change, object-shaped error bodies
could not be parsed into CustomLlmErrorMessage's String error field, so
ChatCompletionService fell through to the generic 500 path with the raw
upstream JSON embedded inside "Unexpected error calling LLM provider: ...".
Now the nested message surfaces as the user-visible text.The record field is promoted to JsonNode so Jackson can accept either
shape. toErrorMessage mirrors OpenAiErrorMessage.getCode's status-code
mapping for OpenAI-compat codes (invalid_api_key to 401, rate_limit_exceeded
to 429, etc.) so Custom LLM providers fronting a real OpenAI-compat
backend behave identically to the native OpenAI provider. InvalidAPIVersion
is listed explicitly since it is the one Azure-specific code we have seen
in the ticket. Unrecognized codes fall back to 400 rather than 500 because
a provider that returned an error body at all is almost always signalling
a client-side fault rather than a server-side blow-up. The flip from
OpenAiErrorMessage's 500 default is deliberate.Flips azureStyleErrorBodyIsPropagated from the previous "documents-the-bug"
shape (asserting 500 with raw body embedded) to the fixed expectation
(400 with the clean message text). openAiStyleErrorBodyIsPropagated and
legacyCustomProviderBehavesAsTodayWhenNoNewKeysSet remain green, proving
the string-error path and no-config-key path are unchanged.- [OPIK-4551] [FE] feat: expose query params, auth header name, and suppress Bearer in Custom Provider form
Extends the v2 Custom Provider configuration dialog with three optional
fields that round-trip to the new backend configuration keys introduced in
this ticket:- Query parameters: reuses the CustomHeadersField key/value pattern
(generalized here with name/label/placeholder props). Serialized into
configuration.url_query_params as a JSON Map<String,String> on save and
parsed back on load. The empty case is collapsed to undefined so the
configuration key is omitted for providers that don't need it. - Auth header name: single text input. Sent alongside the default
Authorization: Bearer unless suppression is on. - Suppress default Authorization header: Switch toggle, default off so
existing custom providers are unchanged.
Adds a helper hint under the URL field explaining the {model} placeholder
for multi-deployment gateways (Azure APIM-style) and extends
ProviderKeyConfiguration in types/providers.ts with the three new optional
fields.Extracts the form <-> API serialization helpers into customProviderConfig.ts
and covers them with 15 Vitest unit tests (15/15 green) in
customProviderConfig.test.ts, including round-trip, blank-key handling,
and the empty-array-while-editing behavior convertHeadersForAPI already
has. v1 form is untouched per repo convention.- [OPIK-4551] [DOCS] docs: document query params, auth header, and {model} placeholder
Extends the AI Providers admin documentation with the new Custom Provider
configuration options introduced in OPIK-4551:- Query parameters section explaining how to add key/value pairs that
are appended to every outbound URL (covers the api-version case). - Auth header name and suppress-default-Authorization section. Makes
clear that suppression is opt-in and default-off matches today's
behavior for every existing provider. - URL templates with {model} for multi-deployment gateways (Azure APIM
style), plus a worked example. - End-to-end Azure OpenAI via APIM subsection that ties all the new
options together with realistic enterprise gateway URL shapes.
Also notes that the plaintext API key remains encrypted at rest using
the same mechanism as every other provider, so users understand the new
fields don't change the secret-handling contract.- [OPIK-4551] [BE] [FE] refactor: address PR review feedback on OPIK-4551 decorator
Addresses five reviewer findings on #6474:
Backend (InterceptingHttpClient):
- Normalize null
configurationto Map.of() in the constructor so
applyAuthHeaders/applyQueryParamscan safely callconfiguration.get
when{model}-only providers reachmutate()without any new config keys.
Fixes the potential NullPointerException flagged on the review. - Narrow the broad
catch (RuntimeException)around JSON parsing to
catch (UncheckedIOException)— the concrete type JsonUtils throws — so
only config-parse / body-parse failures are swallowed while other
unchecked bugs propagate. - Log the offending
url_query_paramsraw value when the map fails to
parse, to aid debugging misconfigured providers. - Use
Boolean.TRUE.toString()instead of a bare"true"string literal.
Backend (CustomLlmClientGenerator):
- Extract a shared
newInterceptingHttpClientBuilder(config)helper so the
HTTP/1.1-pinned JdkHttpClientBuilder + decorator wiring is defined once and
reused by bothnewCustomLlmClientandnewCustomProviderChatLanguageModel. - Only call
OpenAiClient.builder().httpClientBuilder(...)when the provider
actually needs request mutation — i.e. when it declares one of the
OPIK-4551 configuration keys (url_query_params,auth_header_name,
suppress_default_auth) or itsbase_urlcontains a{model}placeholder.
Legacy providers (Ollama, vLLM, bare OpenAI-compat) skip the decorator
entirely and keep LangChain4j's default HTTP client path, so their request
behaviour is byte-identical to pre-OPIK-4551.
Backend (new test):
- Adds InterceptingHttpClientTest with three unit tests locking in the
behaviours that are awkward to reach through the WireMock integration
suite: null-configuration with a{model}placeholder (regression guard
for the NPE fix above), empty-configuration-and-no-placeholder pure
pass-through, and malformed JSON body with a placeholder forwarding the
URL unchanged.
Frontend (CustomHeadersField):
- Replace the manual
field.onChange([...entries])array handling with
React Hook Form'suseFieldArray, per the project forms guideline. React
keys now come from RHF's auto-managedfield.id; text inputs are wired
via innerFormFieldwith indexed paths so RHF tracks value/onChange
automatically. Array data shape ({key, value, id}) unchanged, so
serialization helpers / Vitest round-trip tests keep passing unchanged.
- [OPIK-4551] [FE] polish: generalize Azure-specific help text in Custom Provider form
Addresses review feedback on UI copy:
- Drop Azure-specific phrasing from the "Auth header name" helper (the
custom provider isn't Azure-only). The what-it-does sentence is kept,
the "Used by Azure APIM gateways that expect…" tail is removed. - Similarly generalize the URL
{model}-placeholder helper (drop
"(e.g. Azure APIM)") and the "Query parameters" helper ("Azure OpenAI
gateways" → "Some gateways"). - Drop "Default off, matching today's behavior for all existing custom
providers." from the "Suppress default Authorization header" helper —
reads as noise to someone configuring their first custom provider. - Fix the pre-existing "Models list" example that read as slash-separated
(meta-llama/Meta-Llama-3.1-70B,mistralai/Mistral-7B) even though the
text said "comma separated". Switched togpt-4o, gpt-4o-mini, llama-3.1-70bso the commas are the obvious separator.
- [OPIK-4551] [DOCS] polish: generalize Azure-specific framing in Custom Provider docs
Mirrors the UI-copy cleanup in the previous commit. The generic "Query
Parameters", "Auth Header Name", and "URL Templates with {model}"
sub-sections now describe the feature in terms of any gateway — Azure is
no longer singled out in the intros.The "Azure OpenAI via API Management gateway" worked example later in the
page is kept as-is; its whole purpose is to show how to apply these
generic building blocks to a specific Azure APIM deployment. Pre-existing
"Azure OpenAI" section (direct-endpoint setup) is also untouched.Also drops "Leave off for every existing provider — the default preserves
today's behavior." from the suppress-default-auth bullet since, like its
UI counterpart, the "today's behavior" wording is noise for a first-time
custom provider reader.The
{model}placeholder example URL was switched from
my-gateway.azure-api.net/openai/deployments/{model}to
my-gateway.example.com/deployments/{model}so the abstract pattern
doesn't look like it requires an Azure-shaped host.- [OPIK-4551] [BE] fix: always pass the JdkHttpClientBuilder to LangChain4j, only wrap with decorator conditionally
The previous revision of this commit dropped the .httpClientBuilder(...)
call entirely for legacy providers, which also dropped the HTTP/1.1
pinning the original CustomLlmClientGenerator code put in place for
vLLM / FastAPI servers (a pre-OPIK-4551 behaviour that must be
preserved).The correct split is:
- .httpClientBuilder(...) is always called, passing a JdkHttpClientBuilder
that still forces HTTP/1.1 — same object LC4j received before OPIK-4551. - Only the InterceptingHttpClientBuilder wrapper is conditional. It is
applied via requiresInterceptingBuilder(config) when the provider
actually uses an OPIK-4551 feature (url_query_params, auth_header_name,
suppress_default_auth, or a {model} placeholder in the base URL).
Legacy providers (Ollama, vLLM, bare OpenAI-compat) therefore see a plain
JdkHttpClientBuilder — byte-identical to the pre-OPIK-4551 setup — with
no decorator in the way. OPIK-4551-enabled providers see the same
JdkHttpClientBuilder wrapped by our decorator so URL / header mutation
can take effect.All 14 unit and integration tests still green.
- [OPIK-4551] [FE] refactor: extract shared KeyValueFieldArray for headers + query-param editors
Addresses PR review: CustomHeadersField and WebhookHeaders.tsx both
implemented their own key/value field editor with useFieldArray, which
would have meant every future header/query-param tweak (styling,
validation, accessibility, etc.) had to land in two places.Introduces a generic shared component at
src/shared/KeyValueFieldArray/KeyValueFieldArray.tsxthat:- Is parameterised over the form type
T extends FieldValuesand takes
name: ArrayPath<T>, so any form with a{key, value}-shaped array
field can plug in without schema coupling. - Accepts a
newItemfactory so callers control the exact shape of
freshly-appended rows. Custom LLM providers need{key, value, id}
to satisfy their Zod contract; Webhook Alert headers only need
{key, value}. Both consumers pass the right shape in one place. - Exposes configurable label / description / placeholders / add-button
label, plus an optionalshowColumnHeaderstoggle that matches the
existing WebhookHeaders layout.
Both consumers are migrated:
CustomHeadersField.tsxbecomes a thin wrapper that preserves its
current prop surface (used twice fromCustomProviderDetails— once
for custom headers, once for query parameters) soCustomProviderDetails
is unchanged.v2/pages/AlertsPage/AddEditAlertPage/WebhookHeaders.tsxnow renders
the shared component withshowColumnHeadersand its existing label /
description strings. Visually the only drift is the remove icon (Trash2
instead of X) and slightly less vertical padding — net cleaner.
v1
WebhookHeaders.tsxis intentionally untouched per the v1/v2
separation rule.Frontend gates still green:
tsc --noEmit,eslint --max-warnings=0,
15/15 Vitest unit tests incustomProviderConfig.test.ts.- docs(ai-providers): apply reviewer wording polish on Query Parameters and Auth header sections
- Reword the Query Parameters intro with a concrete Azure api-version example
- Tweak bullet "Add multiple parameters as needed" -> "Add as many parameters as needed"
- Drop redundant "the default" qualifier before Authorization: Bearer header
- chore(custom-llm): drop customer names from comments
Scrub customer references from Javadoc on CustomLlmErrorMessage.getStatusCode
and from a test comment in ChatCompletionsResourceTest. Customer names
should not ship in the public repo; OPIK-4551 remains the traceable reference.- refactor(custom-llm): use URIBuilder for safer query-param append
Replace hand-rolled "?"/"&" logic with Apache HttpComponents URIBuilder
so malformed, empty, or fragment-bearing URLs are handled safely. If the
URL fails to parse, log a warning and forward unchanged instead of
producing a corrupt output.Adds tests covering the fragment case (query goes before "#") and the
malformed-URL fallback.- fix(custom-llm): guard against unauthenticated upstream + nested error paths in KeyValueFieldArray
- InterceptingHttpClient: ignore
suppress_default_authwhen no
auth_header_nameis configured, keep the default Authorization
header and log a warning. Previously this combination dropped the
header without replacing it, silently sending an unauthenticated
request upstream. - KeyValueFieldArray: split dotted
name(ArrayPath) into path
segments before lodashget, so nested RHF error trees like
errors.provider.headers[0].keyare read correctly when the
component is used with a dotted path. - Add unit test covering the suppress-auth fallback.
- refactor(custom-llm): address PR review — centralize status mapping, javadoc, redact URL log, drop prints
- Extract OpenAiCompatStatusCodes as a shared utility so OpenAiErrorMessage
and CustomLlmErrorMessage no longer duplicate the OpenAI-compat code -> HTTP
status switch. Azure-specific codes (InvalidAPIVersion) layer on top in
CustomLlmErrorMessage. - Convert
///comment blocks to proper/** */javadoc on public /
package-private types, constructors, and helper methods that this PR
introduced, so the javadoc tool picks them up on Java 21. - Stop logging the user-controlled URL in the URIBuilder failure warn path;
log only the exception. Base URLs can contain secrets in their query
string or userinfo segment. - Remove diagnostic System.out.printf calls from ChatCompletionsResourceTest.
- refactor(custom-llm): warn on slashed model names, simplify error switch, drop stale comment
- InterceptingHttpClient.applyModelPlaceholder now logs a warning when the
substituted model name contains '/'. The substitution stays raw (some
gateways expect literal slashed segments) so the request still goes
through, but operators get visibility when a HuggingFace-style name
silently turns into extra path segments. - CustomLlmErrorMessage.getStatusCode collapses the InvalidAPIVersion
branch into the default since both already returned 400. The intent is
preserved as a comment. - ChatCompletionsResourceTest comment drops the stale
:10line ref —
class name alone is enough. - FE form help text and ai_providers.mdx now call out the alphanumeric /
hyphen guideline for {model} substitution.
- refactor(custom-llm): use @UtilityClass; apply reviewer wording polish on ai_providers docs
- OpenAiCompatStatusCodes is now annotated with Lombok's @UtilityClass; the
explicit private constructor andstaticmodifier onfromCodeare removed
since the annotation contributes both. - ai_providers.mdx wording polish across the Query Parameters, Auth Header,
URL Templates and Azure APIM subsections (Juan's review): "key-value",
"automatically URL-encoded", "differently named", "Enable this", "using
{model} as a placeholder. This avoids creating...", split slashed-name
warning into two sentences, "instead of Authorization: Bearer", and
"full gateway path, including the deployment placeholder".
下载附件