Skip to content

fix(ai-providers): resolve an AI provider's request URL from the base the user configured - #3042

Merged
datlechin merged 1 commit into
mainfrom
fix/ai-endpoint-resolution
Sep 21, 2026
Merged

datlechin merged 1 commit into
mainfrom
fix/ai-endpoint-resolution

Conversation

@datlechin

Copy link
Copy Markdown
Member

Fixes #3040

The defect

AIProviderConfig carries one URL field, and five separate call sites each hard-coded the API version segment onto it:

Provider Built De-duplicated
String.openAIPath (OpenRouter, OpenCode Zen, llama.cpp, MLX, Custom) <base>/v1/chat/completions, <base>/v1/models only a base ending in the literal /v1
AnthropicProvider <base>/v1/messages, <base>/v1/models nothing
OpenAIResponsesProvider (OpenAI, xAI) <base>/v1/responses, <base>/v1/models nothing
GeminiProvider <base>/v1beta/models/... nothing

Z.ai serves its OpenAI-compatible API at https://api.z.ai/api/paas/v4/chat/completions. Typing that base produced /api/paas/v4/v1/chat/completions, and typing the full completions URL appended /v1/chat/completions again, so no value of the field reached the server.

The same class bites the other four providers from the opposite direction: every one of them is endpointConfigurable, so pasting a vendor's own documented base (https://api.openai.com/v1, https://api.anthropic.com/v1) yielded /v1/v1/responses and /v1/v1/messages.

The fix

One resolver, AIEndpoint, that every provider goes through. It follows the rule VS Code documents for its own custom providers: a URL whose path already names a resource is used as it stands, and the version segment is inserted only when the last path segment is not already one (v1, v4, v1beta).

https://api.openai.com             -> https://api.openai.com/v1/chat/completions      (unchanged)
https://openrouter.ai/api          -> https://openrouter.ai/api/v1/chat/completions   (unchanged)
https://opencode.ai/zen/v1         -> https://opencode.ai/zen/v1/chat/completions     (unchanged)
https://api.z.ai/api/paas/v4       -> https://api.z.ai/api/paas/v4/chat/completions   (fixed)
https://api.z.ai/api/paas/v4/chat/completions -> the same URL                          (fixed)
https://api.anthropic.com/v1       -> https://api.anthropic.com/v1/messages           (fixed)
api.z.ai/api/paas/v4               -> refused, with TablePro's own message             (fixed)

Every configuration that works today resolves to the byte-identical URL, so no stored setting is migrated, no default endpoint changes, and an older build syncing the same AISettings blob keeps working. The only values whose resolution changes are ones that 404 today.

Joining is URL.appending(path:), measured on this toolchain rather than assumed: URL(string:relativeTo:) drops v4 from https://api.z.ai/api/paas/v4 per RFC 3986's relative-reference rules, and appending(path:) preserves a query string already on the base, which an Azure-shaped deployment URL needs. Scheme, host and userinfo validation mirrors MCPServerConfiguration.validate, the pattern already used for a user-typed HTTP endpoint.

One accepted behaviour change

A gateway mounted at /v2 in front of an OpenAI-compatible backend used to get an implicit /v1 appended. It no longer does. The escape hatch is typing the full completions URL, which is now honoured as it stands.

What shipped alongside, and why the fix is incomplete without it

Each of these was found while investigating #3040 and verified against the code:

  • Test Connection reported success against a 404. OpenAICompatibleProvider.testConnection checked only for a 401 and returned true for any other status whose body was JSON, so the reporter's Spring Boot 404 page drew a green "Connection successful". It now accepts 200 and 400, throws on everything else, and still requires an API-shaped body so a proxy login page answering 200 with HTML is not mistaken for a working endpoint.
  • A scheme-less endpoint produced "unsupported URL". URL(string: "api.z.ai/api/paas/v4") is not nil; it is a relative reference with no host, so the invalidEndpoint guard never fired and URLSession reported -1002 instead of TablePro's own message.
  • Claude and Gemini masked a rejected model list. Both returned a hardcoded offline model list on the non-200 branch as well as the transport-error branch, so a wrong base or a revoked key filled the Model picker and showed no error at all. The offline list now covers the transport-error branch only.
  • A 404 was reported as "Model not found". That is what sent the reporter looking at their model ID. AIProviderError.notFound names the URL that was called and keeps the server's own message.
  • Editing a saved provider in Settings replaced the transport of a live conversation. AIProviderDetailSheet built its throwaway transport through AIProviderFactory.createProvider, which writes the per-id cache that resolve reads for running sessions, so a half-typed Base URL reached a session already streaming. The sheet now builds through makeUncachedProvider.

UI

Endpoint is now Base URL, the wording every comparable client uses, with the type's default as its placeholder, one line saying to include the version segment, and the URL a request resolves to shown underneath and selectable. Apple's HIG note that placeholder text disappears once typing starts is why the rule is stated in a label rather than only in the placeholder.

The field is hidden for xAI while it is running on a subscription sign-in, because XAIGrokProvider reaches its own proxy and the configured base has no effect in that mode.

Verification

Step Result
verify.sh build PASS
verify.sh test (24 suites) PASS, 212 cases, 209 passed
verify.sh test (9 suites, after review fixes) PASS, 70 cases, 70 passed
verify.sh lint (17 files) 0 violations
verify.sh docs PASS
Skill(security-review) no HIGH or MEDIUM findings

The three SSEEventStreamTests failures in the first run are the known live-network failures on this machine. I confirmed that rather than accepting the wrapper's mute: with the SSEEventStream.swift change reverted, the same three cases fail identically.

New tests: AIEndpointTests (the whole resolution table, per style, plus trailing slashes, a base query string, an escaped path separator, credentials in the endpoint, and every provider's default endpoint), OpenAICompatibleProviderConnectionTests (the status-code and body-shape contract), AIProviderModelFetchTests (Claude and Gemini surfacing an HTTP failure, and all three providers reaching the resolved base). StringAIEndpointTests is replaced.

No screenshots: the change is a relabelled text field and two caption lines inside the AI provider detail sheet, which no docs page pictures today.

Review

Codex reviewed the working tree and raised three, all fixed in this branch:

  • URLComponents.path decodes %2F, so writing the decoded value back would have turned one path segment into two and addressed a different route on a gateway mounted under an escaped separator. Measured, then fixed by working on percentEncodedPath.
  • Accepting 200 and 400 unconditionally reintroduced the false positive from the other side, for a proxy or SPA answering 200 with HTML.
  • The resolved-URL caption was wrong for xAI's subscription sign-in.

Skill(security-review) ran because this path is a trust boundary. It changes nothing about what a user or a paired client is allowed to do: the host was always user-supplied, only the path is rewritten, and the new validation is strictly tighter than what it replaces, refusing non-http(s) schemes and an endpoint carrying credentials, neither of which was checked before.

Not in this PR

Four verified defects in the same subsystem are independent of the URL fix and are reported separately rather than bundled here.

@mintlify

mintlify Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
TablePro 🟢 Ready View Preview Sep 21, 2026, 9:02 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@datlechin
datlechin merged commit 8423ba1 into main Sep 21, 2026
8 of 9 checks passed
@datlechin
datlechin deleted the fix/ai-endpoint-resolution branch September 21, 2026 21:07

This branch was successfully deployed

1 active deployment
staging - docs — 9619f3b9 Deployed Sep 21, 2026 by mintlify[bot]
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.

Custom OpenAI-compatible provider hardcodes /v1/chat/completions, incompatible with Z.ai (/v4/chat/completions)

1 participant