Skip to content

Re-land Custom model endpoint provider with the provider-coverage gaps closed - #1437

Merged
Paul Lizer (paullizer) merged 11 commits into
Developmentfrom
paullizer-custom-model-endpoints-v2
Sep 5, 2026
Merged

Re-land Custom model endpoint provider with the provider-coverage gaps closed#1437
Paul Lizer (paullizer) merged 11 commits into
Developmentfrom
paullizer-custom-model-endpoints-v2

Conversation

@paullizer

Copy link
Copy Markdown
Collaborator

What this is

PR #1228 was merged prematurely and reverted by #1431. This restores that work and closes the gaps that made it not-ready, so Custom endpoints actually support the provider shapes people need rather than only the three they were built against.

The first commit is a plain revert-of-revert, so the restore can be reviewed separately from the seven fixes stacked on top.

Commit
0a359d6a Restore #1228 onto Development (revert-of-revert, tree identical to the original PR head)
dd18f415 Resolve model capabilities from the catalog instead of the model name
88e52242 Make Custom endpoint API types a registry, and add Google Gemini
17e3862a Make Custom model endpoint failures diagnosable
3bbbfcdb Stop rewriting Custom endpoint URLs into 404s
934a5577 Deliver completed responses as a real stream, not one blob
26c3344b Make on-premises Custom model endpoints usable
51178c36 Add bearer, OAuth2, and mTLS authentication to Custom endpoints

The gaps, and what changed

Capabilities were guessed from the model's name. functions_model_capabilities.py never opened model_capabilities.json — it was pure regex. "Is this Claude?" was "claude" in name; "is this a reasoning model?" was startswith("gpt-5"). A customer's on-premises model named corp-llm-v2 therefore got the wrong answer to every capability question. Capabilities now resolve through per-model override → endpoint override → catalog → the original heuristic, so unknown models behave exactly as before and administrators can describe one without waiting on a catalog release.

Two matching bugs surfaced while building it and are fixed: family was letting phi-4 inherit phi-4-multimodal-instruct's vision capability, and gpt-5.3 was prefix-matching gpt-5 because normalization collapses . and -. The catalog gained supportsStreaming, reasoning, 10 Google Gemini records — a provider it did not cover at all — and a JSON schema so a malformed record fails a test rather than degrading answers at runtime.

Adding a provider meant editing five places. The api-type allowlist, the request-model resolver, the protocol inference chain, the admin template's option list, and the admin JavaScript. An API type is now one declarative registry entry carrying its wire protocol, model identifier field, URL policy, auth types, and version field. Google Gemini is registered, reached through its OpenAI-compatible surface so it keeps running on the validated-DNS pinned transport rather than needing a vendor SDK with its own.

URLs were rewritten into 404s. /v1 was appended unconditionally, so https://apim.example.com/inference/chat/completions was called at …/inference/v1/, and any base carrying its own version segment broke the same way. It is now appended only when the URL does not already name the API surface, with a "use this URL exactly as entered" escape hatch for gateways at an unguessable path. Test Connection reports the URL actually called, because that rewrite was previously invisible.

On-premises was unreachable. The gate named "allow private Custom endpoint hosts" did not permit IP literals or short host names — the two forms on-premises deployments use — and refused both with a message claiming the URL was an IP address. Even once an address passed, the transport trusted only certifi's public roots and ignored SSL_CERT_FILE, so an internally issued certificate could never validate. Both are fixed, with a CA bundle setting and a separate loud plaintext-HTTP gate. Loopback, link-local, and cloud metadata stay blocked with every gate enabled.

Every failure said the same thing. Seven paths did raise RuntimeError("Custom model request failed.") from None, discarding the cause before it could reach the log. Sanitizing what reaches the browser is right; destroying it before the server log is not. The browser message stays generic and gains a reference id; the log gets API type, resolved URL, status code, and upstream body with credentials redacted.

Auth was API-key-only. Header name and value prefix are now configurable, which is what lets one scheme cover Authorization: Bearer, Anthropic's x-api-key, Google's x-goog-api-key, and bespoke gateway headers. Bearer tokens and the OAuth2 client-credentials grant are supported, with token caching and refresh ahead of expiry.

Tool-calling responses did not really stream. Tool calling cannot stream, so the completed answer was pushed through the streaming interface as a single chunk — the user saw nothing, then everything. Agents and plugins rely on tool calling, so this was a common path. Answers are now chunked losslessly, with tool calls and usage metadata on the final chunk only. stream_options was also being stripped from every OpenAI-compatible request, silently suppressing usage reporting; it is now dropped only for surfaces that reject it.

Two decisions worth reviewer attention

Egress proxies are refused deliberately, not overlooked. With an HTTP proxy the client issues CONNECT host:443 and the proxy resolves DNS, so connection-time address pinning would protect nothing. Supporting a proxy means replacing that control with an administrator-managed host allowlist, which is a security decision rather than plumbing, so the proxy is refused instead of silently unprotected.

mTLS certificates are referenced by path, never by value. A private key pasted into a settings field would be written to Cosmos and replicated wherever that database goes. The key is mounted into the deployment and its path named instead.

Out of scope

Embeddings remain Azure-only and are untouched. functions_content.generate_embedding still constructs AzureOpenAI directly, so a pure-OpenAI, pure-Anthropic, or air-gapped deployment still needs an Azure OpenAI resource for RAG. That is a separate piece of work.

Testing

Seven suites, 49 assertions, all passing at 0.261.017:

Suite
test_model_capability_catalog_resolution.py 7/7
test_model_endpoint_provider_registry.py 8/8
test_custom_model_endpoint_diagnostics.py 6/6
test_custom_model_endpoint_synthetic_streaming.py 6/6
test_custom_model_endpoint_on_prem.py 7/7
test_custom_model_endpoint_auth.py 8/8
test_custom_model_endpoint_provider.py 7/7

Also green: the workspace, admin-persistence, cloud-environment, Claude, Foundry-streaming, workflow-capabilities, tabular, docs-integrity, and route-policy suites.

Eleven other *endpoint* tests fail, along with eight relative docs links and the exact-version assertions in the tabular export tests. Each was baselined at the restore commit and fails identically there, so none are introduced here.

Documentation: five feature pages, one fix page, and release-notes entries for every version in the stack.

Refs #1228, #1431

Re-applies the changes from PR #1228, which was merged prematurely and
reverted by PR #1431. This restores the feature onto current Development
as a working base so the remaining provider-coverage gaps can be closed
before it is proposed for merge again.

Refs #1228

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
SimpleChat decided what a model could do by pattern-matching its name, so any
model the shipped catalog did not know about -- an on-premises or customer
supplied model reached through a Custom endpoint -- silently got the wrong
answer for vision, tool calling, streaming, and reasoning. A model named
"corp-llm-v2" was treated as having no capabilities at all.

Capability answers now resolve through per-model override, endpoint override,
catalog entry, then the original name heuristic, so the catalog can be
incomplete without blocking an administrator and unknown models behave exactly
as they did before.

Two matching rules keep this honest. "family" is never used to match, because
members of one family disagree on capabilities: the phi-4 family holds both the
multimodal and the text-only Phi models, and each gpt-5.x family holds a
non-vision "-chat" member. And a longer identifier prefix wins, with a
digit-leading remainder rejected as a version continuation rather than a
variant, so gpt-5.1-chat-v2 resolves to gpt-5.1-chat and gpt-5.3 no longer
resolves to gpt-5.

The catalog gains supportsStreaming and reasoning for all 65 existing models and
10 Google Gemini records, a provider it did not cover at all. Claude, Llama 4,
and Phi-4 multimodal are now correctly recognised as vision-capable, and the
gpt-5.x "-chat" variants correctly as not. A published JSON schema plus a
coverage test means a malformed record fails a test rather than degrading
capability answers at runtime.

Refs #1228

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Custom endpoints supported exactly three API types, each hard-coded in five
places: the allowlist, the request-model resolver, the protocol inference
chain, the admin template option list, and the admin JavaScript. Adding a
provider meant editing all five, and missing one failed silently.

An API type is now one declarative record carrying its wire protocol, model
identifier field, URL policy, accepted auth types, and version field. The admin
option list, model identifier label, version fields, validation, and protocol
inference all derive from it. The three existing API types are unchanged and an
unregistered api_type is still refused.

Google Gemini is registered, reached through its OpenAI-compatible surface so it
keeps running on the validated-DNS pinned transport rather than needing a vendor
SDK with its own transport.

This also fixes URL construction for any surface that already carries a version
segment. SimpleChat appended /v1 unconditionally, turning Gemini's
.../v1beta/openai base into .../v1beta/openai/v1 and a 404. The URL policy is
now per provider, so /v1 is appended only where it belongs.

The registry reaches the browser as server-rendered inline JSON, through the
modal's data-api-types attribute and a window helper in base.html for scripts
that build model lists without the endpoint editor present. No hard-coded
api_type comparison remains in the admin or workspace scripts, which the
existing UI contract test now asserts.

Refs #1228

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Every Custom endpoint failure produced the same sentence, "Custom model request
failed.", with the cause discarded by "from None" before it could reach the log.
A wrong path, a wrong key, a wrong model name, a TLS failure, and a blocked
address were indistinguishable, and nothing anywhere explained which had
happened. Seven failure paths did this.

Sanitizing what reaches the browser is right, because an upstream error body can
echo back a URL, a header, or an API key. Destroying it before it reaches the
server log is not.

The browser message stays sanitized and now carries a short reference id. The
real cause is recorded server-side with the API type, the resolved request URL,
the upstream status code, and the upstream body, stamped with the same id so an
administrator can join a reported message to the entry that explains it.

The resolved request URL is included deliberately: URL normalization rewrites
what the administrator typed, and that rewrite was previously invisible, so a
404 caused by a rewritten path could not be diagnosed at all.

Credentials are redacted before anything is logged, covering api_key fields,
Authorization and bearer tokens, x-api-key, x-goog-api-key, key query
parameters, and sk- style keys. Detail is truncated so a large upstream body
cannot flood the log, and a logging failure is swallowed so it can never replace
the model error.

The existing provider test asserted the message was exactly "Custom model
request failed."; it now asserts the security property, that no provider detail
leaks, plus the presence of a reference id.

Refs #1228

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
SimpleChat appended /v1 to every Custom OpenAI-compatible endpoint, even when
the configured URL already said where the API lived. A gateway at
https://apim.example.com/inference/chat/completions was called at
https://apim.example.com/inference/v1/, and any base carrying its own version
segment was broken the same way. The rewrite was also invisible, so a
misdirected request looked identical to a correct one.

/v1 is now appended only when the URL does not already name the API surface. A
last path segment matching a version, such as v1, v2, or v1beta, is left alone,
and a full operation URL is treated as stating the base exactly, so its suffix
is stripped and the remainder used as given.

Some gateways mount the API at a path that cannot be inferred, where both the
given path and a /v1 child are plausible. Rather than guess, the endpoint editor
gains a "Use this URL exactly as entered" option, stored as connection.url_mode
and honoured for any API type.

Test Connection now reports the URL that was actually called, so the rewrite is
always verifiable instead of silent.

Refs #1228

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
SimpleChat supports one response mode, streaming, but some responses cannot be
streamed by the model. Tool calling is the clearest case: a tool call has to
arrive complete, so the request is made without streaming.

That path already pushed the completed answer through the streaming interface,
but as a single chunk, so the user saw nothing and then everything at once,
which reads as a hang. Agents and plugins rely on tool calling, so this was a
common path rather than an exotic one.

A completed answer is now split at word boundaries and delivered progressively.
Chunking is lossless, which matters because the frontend accumulates chunks by
concatenation: the tokenizer preserves every character, including runs of
whitespace and newlines, so the reassembled text is identical to the original.

Function calls cannot be partially delivered, so non-text items ride on the
final message alongside the finish reason and usage metadata. Emitting metadata
exactly once keeps token usage from being multiplied by the chunk count.

This also restores usage reporting on streaming responses. stream_options is how
an OpenAI-compatible stream reports token usage, and it was stripped from every
request, including from endpoints that accept it. Support is now declared per
provider and the option is dropped only for surfaces that reject it.

Refs #1228

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The gate named "allow private Custom endpoint hosts" did not permit the two
address forms on-premises deployments actually use. An IP address and a short
single-label host name were both rejected even with the gate enabled, and both
were refused with a message claiming the URL was an IP address, which was simply
wrong for the short host name and named no setting that would allow it.

Even once an address was accepted, the connection could not complete. The
outbound transport trusts only certifi public roots and deliberately ignores
SSL_CERT_FILE, so a gateway presenting an internally issued certificate could
never be validated. On-premises inference was unreachable in practice.

With the gate enabled, IP addresses, short host names, and private-range hosts
are now accepted, and administrators can name a CA bundle to trust an internal
issuer. Ambient environment variables are still ignored, so widening trust stays
a recorded configuration decision, and a bundle that cannot be loaded fails the
request rather than silently falling back to public roots.

Plaintext HTTP gets its own second gate for isolated networks, labelled with its
consequence, and requires the private-hosts gate as well.

None of this weakens the outbound protections. Loopback, link-local, and cloud
metadata addresses stay blocked with every gate enabled, and addresses are still
revalidated at connection time.

Saving an endpoint no longer requires the host to resolve from the application
tier, so configuration can be seeded or restored ahead of connectivity. Only
resolution is tolerated; policy violations are still refused at save time, and
are reported as policy violations rather than resolution failures.

Egress proxies remain unsupported on purpose: with a proxy the CONNECT target is
resolved by the proxy, so connection-time pinning would protect nothing, and
replacing it with a host allowlist is a security decision rather than plumbing.

Refs #1228

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Custom endpoints accepted one scheme: an API key sent in whichever header the
built-in providers happened to use. That covers OpenAI and Anthropic and nothing
else, so a gateway reading the key from a different header, a corporate gateway
issuing short-lived tokens, and an appliance requiring a client certificate were
all unreachable.

The API key header name and value prefix are now configurable, which is what lets
one scheme cover Authorization: Bearer, Anthropic's x-api-key, Google's
x-goog-api-key, and bespoke gateway headers. Each provider supplies its own
default so the common case needs no configuration.

Static bearer tokens are supported, as is the OAuth2 client credentials grant.
Tokens are cached per token URL, client, and scope, and refreshed ahead of expiry
so one cannot lapse mid-request.

Two things about the token endpoint matter. It is a different host from the
inference endpoint, so it is validated against the same outbound policy and
cannot become an unchecked request target: a token URL pointing at cloud metadata
is refused exactly as an inference endpoint would be. And its error body
routinely echoes the client id or secret, so failures are sanitized to the
browser and recorded server-side with credentials redacted.

mTLS is modelled as a transport concern rather than an auth type, because a
client certificate combines with any scheme. Certificates are referenced by file
path, never by value, so a private key is mounted into the deployment instead of
being written to the configuration database and replicated with it.

Refs #1228

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread application/single_app/functions_model_endpoint_auth.py Fixed
Comment thread application/single_app/model_endpoint_clients.py Fixed
Comment thread functional_tests/test_custom_model_endpoint_synthetic_streaming.py Fixed
Comment thread functional_tests/test_custom_model_endpoint_diagnostics.py Fixed
Comment thread functional_tests/test_custom_model_endpoint_auth.py Fixed
Comment thread application/single_app/functions_model_endpoint_runtime.py Fixed
Comment thread application/single_app/functions_model_endpoint_runtime.py Dismissed
Comment thread application/single_app/functions_model_endpoint_diagnostics.py Dismissed
Comment thread application/single_app/functions_model_endpoint_auth.py Fixed
Comment thread application/single_app/functions_documents.py Dismissed
Validate the OAuth2 token endpoint when the token is fetched, not only when the
endpoint is saved. Checking at save time leaves the request itself unguarded,
because settings can be written by another path, restored from backup, or
changed after validation, so code scanning was right to call this a server-side
request forgery. The token URL is now revalidated against the same outbound
policy as the inference endpoint and the request runs on the same pinned
transport, so its addresses are validated at connection time.

That transport refuses redirects, which the previous code avoided on the stated
grounds that token endpoints commonly redirect. That reasoning was wrong:
redirects belong to the browser-based authorization-code flow, while a
client-credentials token endpoint answers a server-to-server POST with a JSON
body. A regression test now asserts a blocked token URL is refused before any
HTTP client is constructed.

Remove backtracking from the version-segment pattern. Its optional suffix could
begin with a digit, making it ambiguous with the preceding digits and quadratic
on a long run of them: 8,000 characters took about 0.19s to reject and now takes
0.0003s. Requiring the suffix to begin with a letter matches exactly the same
segments.

Move the auth-type constants and their normalizer to the provider registry so
validation no longer imports the authentication module. That removes the real
coupling behind several cyclic-import findings and lets the token fetch import
validation normally rather than through a deferred import whose comment would
otherwise have become untrue.

Drop genuinely dead imports, and declare the deliberate re-exports in
functions_model_endpoint_types with __all__, since functions_settings and the
provider tests import those constants through it. Close file handles in tests
and stop importing the same module both ways.

The remaining cyclic-import findings are pre-existing: the same single
nine-module cycle is present on Development, and none of the modules in this
change participate in an import-time cycle.

Refs #1228

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ease

Development published v0.261.010 and v0.261.011 for the Azure Managed Redis work
while this branch was using the same numbers, so both release-notes sections and
config.py collided.

Development's sections are authoritative and are kept exactly as published. This
branch's nine sections move above them, to v0.261.012 through v0.261.020, and
config.py becomes 0.261.020. The restored PR #1228 entry is renumbered too: its
original v0.261.010 claim was vacated when that PR was reverted, so it re-lands
alongside the fixes stacked on it.

Version references in this branch's tests and feature documentation are updated
to match, so each still names the version its behaviour ships in.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread application/single_app/functions_model_endpoint_auth.py Dismissed
Comment thread application/single_app/functions_model_endpoint_auth.py Dismissed
…ease

Development published v0.261.012 for the Yamcs basic authentication work while
this branch was using that number for the restored PR #1228 entry, so the
release notes and config.py collided again.

Development's sections are kept exactly as published. This branch's nine
sections shift up one, to v0.261.013 through v0.261.021, and config.py becomes
0.261.021. Version references in this branch's tests and feature documentation
move with them.

The sections belonging to this branch are now identified by content rather than
by a hardcoded list of numbers, so a further collision while this PR is open is
resolved the same way without guessing which sections are whose.

The generated release-notes pages are rebuilt from the merged source rather than
hand-resolved, since they are build output.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@paullizer
Paul Lizer (paullizer) merged commit 9962817 into Development Sep 5, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants