fix: stop a pasted /v1 turning into a 404, and name the real failure - #9
Merged
Merged
Conversation
Two things made Wingman look broken against a gateway that was fine. A base URL was concatenated with the format's endpoint path without any normalising, so the shapes people actually paste — the full endpoint from a provider's docs, or a base ending in `/v1` — produced `/v1/v1/chat/completions` and a 404 that reads as a gateway fault. Less visibly, a base URL with stray whitespace threw a raw "Failed to parse URL" out of fetch, and a schemeless one (`localhost:3001`) was resolved as a *relative* URL against Wingman's own origin — quietly POSTing the user's API key to wingman.manifest.build. The second is diagnosis. When fetch rejects, the browser reports DNS failure, connection refused, a CORS rejection and an address-space block identically. Wingman resolved that ambiguity by always blaming CORS. That is wrong in the case people hit most: an HTTPS page calling a gateway on localhost is gated by Chrome's Local Network Access permission, which replaced Private Network Access and cannot be satisfied by any server header — so "add the origin to your allow-list" sends people to fix something that was never broken. - Add `normalizeBaseUrl`: trims, infers a scheme (http for loopback hosts, https otherwise), validates the host, and strips a duplicated endpoint path or trailing `/v1`. Provider bases that legitimately carry a path (Groq's `/openai`, OpenRouter's `/api`) are left alone. - Show the resolved `POST <url>` under the Base URL field, with a note when normalising changed something, so a doubled `/v1` is visible before Send. - Reject an unusable base URL before fetch sees it, instead of leaking the key to our own origin or surfacing a parser error. - Classify local-network and mixed-content failures separately and render the guidance. `errorKind` was computed and never displayed. - Health badge: report the URL it probed. It answers on the origin, not the base path, so it used to show a green "reachable" next to a base that would 404 on send. Wingman had no test runner; add vitest with 60 tests over the URL and diagnosis logic and run it in CI. One of them earns its keep already: Chrome accepts spaces in a host and percent-encodes them where Node and jsdom throw, so validating the host has to be ours to do rather than something `new URL()` is trusted with.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reported against the Wingman drawer inside a Manifest dev dashboard: a healthy gateway answering
404 Cannot POST /v1/v1/chat/completions. Reproduced, plus four more failures in the same area.What was wrong
The base URL was concatenated with the format's endpoint path, unnormalised. The shapes people actually paste all break:
…/v1…/v1/v1/chat/completions…/v1/chat/completions…/v1/chat/completions/v1/chat/completionshttp://hostFailed to parse URL, nothing sentlocalhost:3001https://wingman.manifest.build/localhost:3001/…That last one is the one to look at twice: a schemeless base URL isn't a host the browser completes, it's a relative URL, so it resolved against Wingman's origin and shipped the user's bearer token there.
The failure diagnosis was wrong in the most common case. When
fetchrejects, the browser reports DNS failure, connection refused, a CORS rejection and an address-space block identically. Wingman resolved that ambiguity by always blaming CORS. But an HTTPS page calling a gateway onlocalhostis gated by Chrome's Local Network Access permission — it replaced Private Network Access, and no server header satisfies it. Telling someone to add an origin to their allow-list sends them to fix something that was never broken.errorKindwas computed insend.tsand rendered nowhere, so the UI showed a bareFailed to fetch.The health badge went green next to a base URL that would 404. It resolved an absolute path against the origin, discarding the base path, so
…/v1probed the right health endpoint, reportedreachable · 5ms, and then the send 404'd. That mismatch is most of the "works sometimes" feeling.Changes
normalizeBaseUrl— trims, infers a scheme (http://for loopback hosts,https://otherwise), validates the host, strips a duplicated endpoint path or trailing/v1. Provider bases that legitimately carry a path (Groq's/openai, OpenRouter's/api, Fireworks'/inference) are left alone; a gateway at…/api/v1still resolves correctly.POST <url>under the Base URL field, with a note when normalising changed something — a doubled/v1is now visible before you hit Send.fetchsees it, with the reason shown.local-networkandmixed-contentfailures are classified separately from CORS, and the guidance is rendered.Tests
The repo had no test runner. Added vitest (60 tests) over the URL and diagnosis logic, wired into CI.
One test earns its keep immediately: Chrome accepts spaces in a host and percent-encodes them, where Node and jsdom throw. Relying on
new URL()to rejectnot a url at allpassed in jsdom and still let the request through in the real browser — caught by browser testing, now covered by an explicit host check.Verification
Driven through a real Chrome against a local Manifest gateway (
/serve), both standalone and inside the dashboard drawer:The drawer also needs mnfst/manifest#2579 — Chrome blocks the hosted HTTPS SPA from reaching a
localhostgateway regardless of what Wingman does. This PR makes that failure legible; that one makes the drawer work.