diff --git a/providerroutes/stitch.go b/providerroutes/stitch.go index 6abdba1..59015d1 100644 --- a/providerroutes/stitch.go +++ b/providerroutes/stitch.go @@ -144,6 +144,42 @@ func stitchRequestURL(req *http.Request, target *url.URL, basePath, version stri default: reqPath = trimLeadingVersionSegment(reqPath) } + } else if dup := trailingVersionSegment(basePath); dup != "" { + // 🔴 2026-08-15: the ONE unambiguous case on the degraded path. + // + // The block above deliberately leaves rowKnown=false alone, and that + // stays true: for an unknown vendor we cannot tell whether a stored + // path is a mount prefix (https://gw.example/proxy serving + // /proxy/v1/chat/completions) or a complete API root, so we must not + // decide its shape. This branch decides nothing about shape. + // + // It only refuses to emit the SAME version segment twice. When the + // stored base_url already ends in the exact segment the client is + // sending, literal-prepend produced: + // https://www.cun.ai/v1 + /v1/chat/completions + // -> https://www.cun.ai/v1/v1/chat/completions + // which is wrong under every reading of the vendor's shape — there is + // no gateway for which /v1/v1 is the intended path. Nothing is + // swallowed: the segment survives, it is contributed by basePath + // instead of by reqPath, so the bare-host case the note above protects + // (https://gw.example + /v1/chat/completions, basePath "") is + // untouched — there is no trailing segment to duplicate. + // + // Why this matters in practice: most relay vendors DOCUMENT their + // endpoint with the version on it ("base_url: https://xxx/v1"), so the + // user who pastes the documented URL got the broken shape and the one + // who happened to trim it got the working one. Same /v1/v1 poison the + // OAuth path was fixed for (stitchOAuthRequestURL) and known rows were + // fixed for on 2026-08-03; the unknown-host join was the last one left. + // + // Uses the same digits-only strictness as trimLeadingVersionSegment, so + // "/v1beta" or "/v1abc" is never treated as a duplicate of "/v1". + switch { + case strings.HasPrefix(reqPath, dup+"/"): + reqPath = strings.TrimPrefix(reqPath, dup) + case reqPath == dup: + reqPath = "" + } } stitched := basePath + version + reqPath @@ -188,6 +224,32 @@ func trimLeadingVersionSegment(p string) string { return p[len(seg)+1:] } +// trailingVersionSegment returns the leading-slash version segment a path ENDS +// with ("/v1", "/v3"), or "" when it ends with anything else. It is the mirror +// of trimLeadingVersionSegment and deliberately shares its strictness: "v" +// followed by DIGITS ONLY, so "/v1beta" and "/v1abc" return "" and can never be +// mistaken for a duplicate of a client's "/v1". +// +// Only used for the degraded-path duplicate-join guard in stitchRequestURL; it +// answers "does basePath already end in this exact version segment?", never +// "what version does this vendor use?". +func trailingVersionSegment(p string) string { + i := strings.LastIndexByte(p, '/') + if i < 0 { + return "" + } + seg := p[i:] // includes the leading '/' + if trimLeadingVersionSegment(seg) != "" { + // trimLeadingVersionSegment returns the REMAINDER; a bare version + // segment leaves "" behind. Anything else means this is not one. + return "" + } + if !strings.HasPrefix(seg, "/v") || len(seg) < 3 { + return "" + } + return seg +} + // PathDiscarded reports whether resolving storedBaseURL through this table // would THROW AWAY path information the stored URL carries — and returns the // row that swallowed it. diff --git a/providerroutes/stitch_unknown_host_dedup_test.go b/providerroutes/stitch_unknown_host_dedup_test.go new file mode 100644 index 0000000..fc3a09e --- /dev/null +++ b/providerroutes/stitch_unknown_host_dedup_test.go @@ -0,0 +1,145 @@ +package providerroutes + +import ( + "net/http" + "testing" +) + +// The degraded (unknown-host) stitch must never emit the same version segment +// twice — and must change nothing else (2026-08-15). +// +// Context: a host absent from provider_routes takes the literal-prepend branch +// (stitchRequestURL with rowKnown=false). That branch is deliberately left +// shape-agnostic: for a vendor the table does not know we cannot tell a mount +// prefix (https://gw.example/proxy serving /proxy/v1/chat/completions) from a +// complete API root, so we must not decide. See the long 🚫 note in +// stitchRequestURL — that decision stands. +// +// What was still wrong: when the STORED base_url already ended in the exact +// segment the client sends, literal-prepend produced /v1/v1/…, which is wrong +// under every reading of the vendor's shape. Most relays document their +// endpoint WITH the version ("base_url: https://xxx/v1"), so the user who +// pasted the documented URL got a broken route and the one who trimmed it got a +// working one. Same /v1/v1 poison already fixed on the OAuth path +// (stitchOAuthRequestURL) and for known rows (2026-08-03). +// +// 能红: delete the `else if dup := trailingVersionSegment(basePath)` branch in +// stitchRequestURL — the duplicate cases below go back to /v1/v1. + +func stitched(t *testing.T, base, clientPath string) string { + t.Helper() + req, err := http.NewRequest("POST", "http://ignored"+clientPath, nil) + if err != nil { + t.Fatalf("build request: %v", err) + } + if err := Default().Stitch(req, base); err != nil { + t.Fatalf("stitch %s: %v", base, err) + } + return req.URL.Host + req.URL.Path +} + +func TestUnknownHostStitch_DoesNotDuplicateVersionSegment(t *testing.T) { + cases := []struct{ name, base, client, want string }{ + { + // The documented-URL shape. This is the bug. + name: "stored base already carries the client's version", + base: "https://www.cun.ai/v1", client: "/v1/chat/completions", + want: "www.cun.ai/v1/chat/completions", + }, + { + name: "trailing slash on the stored version", + base: "https://www.cun.ai/v1/", client: "/v1/chat/completions", + want: "www.cun.ai/v1/chat/completions", + }, + { + name: "non-/v1 version, still a duplicate", + base: "https://relay.example/api/v3", client: "/v3/chat/completions", + want: "relay.example/api/v3/chat/completions", + }, + { + name: "client sends the bare version and nothing else", + base: "https://relay.example/v1", client: "/v1", + want: "relay.example/v1", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := stitched(t, c.base, c.client); got != c.want { + t.Errorf("got %s, want %s\nA stored base_url that already ends in the "+ + "client's version segment must not emit it twice — /v1/v1/... is "+ + "wrong for every gateway.", got, c.want) + } + }) + } +} + +// The other half of the fence, and the more important one: everything the +// degraded path did before MUST still happen. These are the shapes the +// stitchRequestURL note protects — where the vendor's shape is genuinely +// unknowable and we must not decide for it. +func TestUnknownHostStitch_LeavesNonDuplicateShapesAlone(t *testing.T) { + cases := []struct{ name, base, client, want string }{ + { + // The note's own counter-example: swallowing this /v1 would break a + // private gateway that serves /v1/chat/completions off a bare host. + name: "bare host keeps the client's version", + base: "https://gw.example", client: "/v1/chat/completions", + want: "gw.example/v1/chat/completions", + }, + { + name: "mount prefix keeps the client's version", + base: "https://relay.example/proxy", client: "/v1/chat/completions", + want: "relay.example/proxy/v1/chat/completions", + }, + { + // Different version segments are NOT duplicates — we do not know + // whether /api/v3 is a root or a prefix, so nothing is decided. + name: "different version segment is not a duplicate", + base: "https://relay.example/api/v3", client: "/v1/chat/completions", + want: "relay.example/api/v3/v1/chat/completions", + }, + { + // Strictness shared with trimLeadingVersionSegment: digits only. + name: "v1beta is not a duplicate of v1", + base: "https://gw.example/v1beta", client: "/v1/models", + want: "gw.example/v1beta/v1/models", + }, + { + name: "v1abc is a real path segment", + base: "https://gw.example/v1abc", client: "/v1abc/x", + want: "gw.example/v1abc/v1abc/x", + }, + { + name: "the exact shape the user runs today stays byte-identical", + base: "https://www.cun.ai", client: "/v1/chat/completions", + want: "www.cun.ai/v1/chat/completions", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := stitched(t, c.base, c.client); got != c.want { + t.Errorf("got %s, want %s\nThe degraded path must stay shape-agnostic "+ + "for unknown vendors: only an exact duplicate of the client's own "+ + "version segment may be collapsed.", got, c.want) + } + }) + } +} + +func TestTrailingVersionSegment(t *testing.T) { + cases := []struct{ in, want string }{ + {"/v1", "/v1"}, + {"/api/v3", "/v3"}, + {"", ""}, + {"/proxy", ""}, + {"/v1beta", ""}, // not digits-only + {"/v1abc", ""}, + {"/v", ""}, + {"/version", ""}, + } + for _, c := range cases { + if got := trailingVersionSegment(c.in); got != c.want { + t.Errorf("trailingVersionSegment(%q) = %q, want %q", c.in, got, c.want) + } + } +}