Skip to content

fix: stop a pasted /v1 turning into a 404, and name the real failure - #9

Merged
brunobuddy merged 1 commit into
mainfrom
fix/base-url-normalization-and-diagnostics
Jul 27, 2026
Merged

fix: stop a pasted /v1 turning into a 404, and name the real failure#9
brunobuddy merged 1 commit into
mainfrom
fix/base-url-normalization-and-diagnostics

Conversation

@brunobuddy

@brunobuddy brunobuddy commented Jul 27, 2026

Copy link
Copy Markdown
Member

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:

typed base URL POSTed to result
…/v1 …/v1/v1/chat/completions 404
…/v1/chat/completions …/v1/chat/completions/v1/chat/completions 404
http://host fetch threw Failed to parse URL, nothing sent
localhost:3001 https://wingman.manifest.build/localhost:3001/… API key POSTed to our own origin

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 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. But an HTTPS page calling a gateway on localhost is 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. errorKind was computed in send.ts and rendered nowhere, so the UI showed a bare Failed 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 …/v1 probed the right health endpoint, reported reachable · 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/v1 still resolves correctly.
  • The connection panel shows the resolved POST <url> under the Base URL field, with a note when normalising changed something — a doubled /v1 is now visible before you hit Send.
  • An unusable base URL is rejected before fetch sees it, with the reason shown.
  • local-network and mixed-content failures are classified separately from CORS, and the guidance is rendered.
  • The health badge names the URL it probed, so a green badge can't be read as more than it checked.

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 reject not a url at all passed 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:

  • 16-case base-URL matrix: 7/16 correct before → 16/16 after (the two non-2xx are genuinely unreachable hosts, now correctly diagnosed rather than blamed on CORS).
  • 9-case drawer matrix — streaming, all three wire formats, and each previously-404ing URL shape: 9/9, zero console errors. The displayed POST URL matched the actual POST URL in every case.

The drawer also needs mnfst/manifest#2579 — Chrome blocks the hosted HTTPS SPA from reaching a localhost gateway regardless of what Wingman does. This PR makes that failure legible; that one makes the drawer work.

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.
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
wingman Ready Ready Preview Jul 27, 2026 11:14am

Request Review

@brunobuddy
brunobuddy merged commit f9e1205 into main Jul 27, 2026
2 checks passed
@brunobuddy
brunobuddy deleted the fix/base-url-normalization-and-diagnostics branch July 27, 2026 11:35
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.

1 participant