From 0ba1d37a10ec4a0f6b40bd7857a92b53a5be5a8d Mon Sep 17 00:00:00 2001 From: Lynn Date: Thu, 9 Jul 2026 15:53:06 +0800 Subject: [PATCH] Feat: optional url v1 (#16774) --- conf/llm_factories.json | 36 ++++++++++++++++-------- rag/llm/chat_model.py | 15 ++++------ rag/llm/cv_model.py | 53 ++++++++++++++++++++--------------- rag/llm/embedding_model.py | 23 +++++++++------ rag/llm/sequence2txt_model.py | 24 ++++++++++------ rag/llm/tts_model.py | 5 ++-- rag/utils/url_utils.py | 50 +++++++++++++++++++++++++++++++++ 7 files changed, 142 insertions(+), 64 deletions(-) create mode 100644 rag/utils/url_utils.py diff --git a/conf/llm_factories.json b/conf/llm_factories.json index 3aeb9b6bf..c1734a13d 100644 --- a/conf/llm_factories.json +++ b/conf/llm_factories.json @@ -2008,7 +2008,8 @@ "max_tokens": 512, "model_type": "embedding" } - ] + ], + "url": "https://api.baichuan-ai.com/v1" }, { "name": "Jina", @@ -2641,7 +2642,8 @@ "model_type": "speech2text", "is_tools": false } - ] + ], + "url": "https://api.stepfun.com/v1" }, { "name": "NVIDIA", @@ -3586,7 +3588,8 @@ "max_tokens": 4000, "model_type": "embedding" } - ] + ], + "url": "https://api.upstage.ai/v1/solar" }, { "name": "NovitaAI", @@ -3713,7 +3716,8 @@ "max_tokens": 8192, "model_type": "embedding" } - ] + ], + "url": "https://api.novita.ai/v3/openai" }, { "name": "SILICONFLOW", @@ -4840,7 +4844,8 @@ "max_tokens": 32768, "model_type": "chat" } - ] + ], + "url": "https://api.ppinfra.com/v3/openai" }, { "name": "Replicate", @@ -5003,7 +5008,8 @@ "thinking" ] } - ] + ], + "url": "https://spark-api-open.xf-yun.com/v1" }, { "name": "BaiduYiyan", @@ -6199,7 +6205,8 @@ "model_type": "rerank", "is_tools": false } - ] + ], + "url": "https://api.302.ai/v1" }, { "name": "CometAPI", @@ -6493,7 +6500,8 @@ "model_type": "tts", "is_tools": false } - ] + ], + "url": "https://api.cometapi.com/v1" }, { "name": "LongCat", @@ -6810,7 +6818,8 @@ "model_type": "tts", "is_tools": false } - ] + ], + "url": "https://api.deerapi.com/v1" }, { "name": "Jiekou.AI", @@ -7464,7 +7473,8 @@ "max_tokens": 32768, "model_type": "rerank" } - ] + ], + "url": "https://api.jiekou.ai/openai" }, { "name": "MinerU", @@ -7996,14 +8006,16 @@ "model_type": "chat", "is_tools": true } - ] + ], + "url": "https://api.avian.io/v1" }, { "name": "RAGcon", "logo": "", "tags": "LLM,TEXT EMBEDDING,TTS,TEXT RE-RANK,SPEECH2TEXT,IMAGE2TEXT", "status": "1", - "llm": [] + "llm": [], + "url": "https://connect.ragcon.com/v1" }, { "name": "Xiaomi", diff --git a/rag/llm/chat_model.py b/rag/llm/chat_model.py index 418c568c5..55a81d414 100644 --- a/rag/llm/chat_model.py +++ b/rag/llm/chat_model.py @@ -38,6 +38,7 @@ from rag.llm import FACTORY_DEFAULT_BASE_URL, LITELLM_PROVIDER_PREFIX, Supported from rag.llm.key_utils import _normalize_replicate_key from rag.llm.tool_decorator import FunctionToolSession, is_tool from rag.nlp import is_chinese, is_english +from rag.utils.url_utils import ensure_v1 class LLMErrorCode(StrEnum): @@ -218,8 +219,9 @@ def _move_litellm_provider_body_fields(provider: SupportedLiteLLMProvider | str class Base(ABC): def __init__(self, key, model_name, base_url, **kwargs): timeout = int(os.environ.get("LLM_TIMEOUT_SECONDS", 600)) - self.client = OpenAI(api_key=key, base_url=base_url, timeout=timeout) - self.async_client = AsyncOpenAI(api_key=key, base_url=base_url, timeout=timeout) + self.base_url = ensure_v1(base_url) + self.client = OpenAI(api_key=key, base_url=self.base_url, timeout=timeout) + self.async_client = AsyncOpenAI(api_key=key, base_url=self.base_url, timeout=timeout) self.model_name = model_name # Configure retry parameters self.max_retries = kwargs.get("max_retries", int(os.environ.get("LLM_MAX_RETRIES", 5))) @@ -783,7 +785,6 @@ class XinferenceChat(Base): def __init__(self, key=None, model_name="", base_url="", **kwargs): if not base_url: raise ValueError("Local llm url cannot be None") - base_url = urljoin(base_url, "v1") super().__init__(key, model_name, base_url, **kwargs) @@ -793,7 +794,6 @@ class HuggingFaceChat(Base): def __init__(self, key=None, model_name="", base_url="", **kwargs): if not base_url: raise ValueError("Local llm url cannot be None") - base_url = urljoin(base_url, "v1") super().__init__(key, model_name.split("___")[0], base_url, **kwargs) @@ -803,7 +803,6 @@ class ModelScopeChat(Base): def __init__(self, key=None, model_name="", base_url="", **kwargs): if not base_url: raise ValueError("Local llm url cannot be None") - base_url = urljoin(base_url, "v1") super().__init__(key, model_name.split("___")[0], base_url, **kwargs) @@ -894,8 +893,7 @@ class LocalAIChat(Base): if not base_url: raise ValueError("Local llm url cannot be None") - base_url = urljoin(base_url, "v1") - self.client = OpenAI(api_key="empty", base_url=base_url) + self.client = OpenAI(api_key="empty", base_url=self.base_url) self.model_name = model_name.split("___")[0] @@ -1032,9 +1030,8 @@ class LmStudioChat(Base): def __init__(self, key, model_name, base_url, **kwargs): if not base_url: raise ValueError("Local llm url cannot be None") - base_url = urljoin(base_url, "v1") super().__init__(key, model_name, base_url, **kwargs) - self.client = OpenAI(api_key="lm-studio", base_url=base_url) + self.client = OpenAI(api_key="lm-studio", base_url=self.base_url) self.model_name = model_name diff --git a/rag/llm/cv_model.py b/rag/llm/cv_model.py index d423451c9..6482319a8 100644 --- a/rag/llm/cv_model.py +++ b/rag/llm/cv_model.py @@ -34,6 +34,7 @@ from openai.lib.azure import AzureOpenAI, AsyncAzureOpenAI from common.token_utils import num_tokens_from_string, total_token_count_from_response from rag.nlp import is_english from rag.prompts.generator import vision_llm_describe_prompt +from rag.utils.url_utils import ensure_v1 from common.misc_utils import thread_pool_exec @@ -267,9 +268,10 @@ class GptV4(Base): def __init__(self, key, model_name="gpt-4-vision-preview", lang="Chinese", base_url="https://api.openai.com/v1", **kwargs): if not base_url: base_url = "https://api.openai.com/v1" + self.base_url = ensure_v1(base_url) self.api_key = key - self.client = OpenAI(api_key=key, base_url=base_url) - self.async_client = AsyncOpenAI(api_key=key, base_url=base_url) + self.client = OpenAI(api_key=key, base_url=self.base_url) + self.async_client = AsyncOpenAI(api_key=key, base_url=self.base_url) self.model_name = model_name self.lang = lang super().__init__(**kwargs) @@ -309,8 +311,9 @@ class AzureGptV4(GptV4): def __init__(self, key, model_name, lang="Chinese", **kwargs): api_key, api_version = _resolve_azure_credentials(key) - self.client = AzureOpenAI(api_key=api_key, azure_endpoint=kwargs["base_url"], api_version=api_version) - self.async_client = AsyncAzureOpenAI(api_key=api_key, azure_endpoint=kwargs["base_url"], api_version=api_version) + self.base_url = ensure_v1(kwargs["base_url"]) + self.client = AzureOpenAI(api_key=api_key, azure_endpoint=self.base_url, api_version=api_version) + self.async_client = AsyncAzureOpenAI(api_key=api_key, azure_endpoint=self.base_url, api_version=api_version) self.model_name = model_name self.lang = lang Base.__init__(self, **kwargs) @@ -551,8 +554,9 @@ class StepFunCV(GptV4): def __init__(self, key, model_name="step-1v-8k", lang="Chinese", base_url="https://api.stepfun.com/v1", **kwargs): if not base_url: base_url = "https://api.stepfun.com/v1" - self.client = OpenAI(api_key=key, base_url=base_url) - self.async_client = AsyncOpenAI(api_key=key, base_url=base_url) + self.base_url = ensure_v1(base_url) + self.client = OpenAI(api_key=key, base_url=self.base_url) + self.async_client = AsyncOpenAI(api_key=key, base_url=self.base_url) self.model_name = model_name self.lang = lang Base.__init__(self, **kwargs) @@ -572,9 +576,9 @@ class VolcEngineCV(GptV4): except JSONDecodeError: api_key = key llm_name = model_name - - self.client = OpenAI(api_key=api_key, base_url=base_url) - self.async_client = AsyncOpenAI(api_key=api_key, base_url=base_url) + self.base_url = ensure_v1(base_url) + self.client = OpenAI(api_key=api_key, base_url=self.base_url) + self.async_client = AsyncOpenAI(api_key=api_key, base_url=self.base_url) self.model_name = llm_name self.lang = lang Base.__init__(self, **kwargs) @@ -586,9 +590,9 @@ class LmStudioCV(GptV4): def __init__(self, key, model_name, lang="Chinese", base_url="", **kwargs): if not base_url: raise ValueError("Local llm url cannot be None") - base_url = urljoin(base_url, "v1") - self.client = OpenAI(api_key="lm-studio", base_url=base_url) - self.async_client = AsyncOpenAI(api_key="lm-studio", base_url=base_url) + self.base_url = ensure_v1(base_url) + self.client = OpenAI(api_key="lm-studio", base_url=self.base_url) + self.async_client = AsyncOpenAI(api_key="lm-studio", base_url=self.base_url) self.model_name = model_name self.lang = lang Base.__init__(self, **kwargs) @@ -600,9 +604,9 @@ class OpenAI_APICV(GptV4): def __init__(self, key, model_name, lang="Chinese", base_url="", **kwargs): if not base_url: raise ValueError("url cannot be None") - base_url = urljoin(base_url, "v1") - self.client = OpenAI(api_key=key, base_url=base_url) - self.async_client = AsyncOpenAI(api_key=key, base_url=base_url) + self.base_url = ensure_v1(base_url) + self.client = OpenAI(api_key=key, base_url=self.base_url) + self.async_client = AsyncOpenAI(api_key=key, base_url=self.base_url) self.model_name = model_name.split("___")[0] self.lang = lang Base.__init__(self, **kwargs) @@ -647,8 +651,9 @@ class OpenRouterCV(GptV4): except JSONDecodeError: api_key = key provider_order = "" - self.client = OpenAI(api_key=api_key, base_url=base_url) - self.async_client = AsyncOpenAI(api_key=api_key, base_url=base_url) + self.base_url = ensure_v1(base_url) + self.client = OpenAI(api_key=api_key, base_url=self.base_url) + self.async_client = AsyncOpenAI(api_key=api_key, base_url=self.base_url) self.model_name = model_name self.lang = lang Base.__init__(self, **kwargs) @@ -677,7 +682,7 @@ class LocalAICV(GptV4): def __init__(self, key, model_name, base_url, lang="Chinese", **kwargs): if not base_url: raise ValueError("Local cv model url cannot be None") - base_url = urljoin(base_url, "v1") + base_url = ensure_v1(base_url) self.client = OpenAI(api_key="empty", base_url=base_url) self.async_client = AsyncOpenAI(api_key="empty", base_url=base_url) self.model_name = model_name.split("___")[0] @@ -689,7 +694,7 @@ class XinferenceCV(GptV4): _FACTORY_NAME = "Xinference" def __init__(self, key, model_name="", lang="Chinese", base_url="", **kwargs): - base_url = urljoin(base_url, "v1") + base_url = ensure_v1(base_url) self.client = OpenAI(api_key=key, base_url=base_url) self.async_client = AsyncOpenAI(api_key=key, base_url=base_url) self.model_name = model_name @@ -703,7 +708,7 @@ class GPUStackCV(GptV4): def __init__(self, key, model_name, lang="Chinese", base_url="", **kwargs): if not base_url: raise ValueError("Local llm url cannot be None") - base_url = urljoin(base_url, "v1") + base_url = ensure_v1(base_url) self.client = OpenAI(api_key=key, base_url=base_url) self.async_client = AsyncOpenAI(api_key=key, base_url=base_url) self.model_name = model_name @@ -727,7 +732,8 @@ class OllamaCV(Base): def __init__(self, key, model_name, lang="Chinese", **kwargs): from ollama import Client - self.client = Client(host=kwargs["base_url"]) + self.base_url = ensure_v1(kwargs["base_url"]) + self.client = Client(host=self.base_url) self.model_name = model_name self.lang = lang self.keep_alive = kwargs.get("ollama_keep_alive", int(os.environ.get("OLLAMA_KEEP_ALIVE", -1))) @@ -1359,8 +1365,9 @@ class RAGconCV(GptV4): base_url = "https://connect.ragcon.com/v1" # Initialize client - self.client = OpenAI(api_key=key, base_url=base_url) - self.async_client = AsyncOpenAI(api_key=key, base_url=base_url) + self.base_url = ensure_v1(base_url) + self.client = OpenAI(api_key=key, base_url=self.base_url) + self.async_client = AsyncOpenAI(api_key=key, base_url=self.base_url) self.model_name = model_name self.lang = lang diff --git a/rag/llm/embedding_model.py b/rag/llm/embedding_model.py index 64b752654..317d7e39c 100644 --- a/rag/llm/embedding_model.py +++ b/rag/llm/embedding_model.py @@ -32,6 +32,7 @@ from common import settings from common.exceptions import ModelException from common.token_utils import num_tokens_from_string, truncate, total_token_count_from_response from rag.llm.key_utils import _normalize_replicate_key +from rag.utils.url_utils import ensure_v1 import logging import base64 @@ -259,7 +260,8 @@ class OpenAIEmbed(Base): def __init__(self, key, model_name="text-embedding-ada-002", base_url="https://api.openai.com/v1"): if not base_url: base_url = "https://api.openai.com/v1" - self.client = OpenAI(api_key=key, base_url=base_url) + self.base_url = ensure_v1(base_url) + self.client = OpenAI(api_key=key, base_url=self.base_url) self.model_name = model_name def _call(self, batch): @@ -281,7 +283,7 @@ class LocalAIEmbed(Base): def __init__(self, key, model_name, base_url): if not base_url: raise ValueError("Local embedding model url cannot be None") - base_url = urljoin(base_url, "v1") + base_url = ensure_v1(base_url) self.client = OpenAI(api_key="empty", base_url=base_url) self.model_name = model_name.split("___")[0] @@ -320,7 +322,9 @@ class AzureEmbed(OpenAIEmbed): from openai.lib.azure import AzureOpenAI api_key, api_version = _resolve_azure_credentials(key) - self.client = AzureOpenAI(api_key=api_key, azure_endpoint=kwargs["base_url"], api_version=api_version) + self.base_url = ensure_v1(kwargs["base_url"]) + self.client = AzureOpenAI(api_key=api_key, azure_endpoint=self.base_url, api_version=api_version) + self.model_name = model_name @@ -465,7 +469,8 @@ class OllamaEmbed(Base): _special_tokens = ["<|endoftext|>"] def __init__(self, key, model_name, **kwargs): - self.client = Client(host=kwargs["base_url"]) if not key or key == "x" else Client(host=kwargs["base_url"], headers={"Authorization": f"Bearer {key}"}) + self.base_url = ensure_v1(kwargs["base_url"]) + self.client = Client(host=self.base_url) if not key or key == "x" else Client(host=self.base_url, headers={"Authorization": f"Bearer {key}"}) self.model_name = model_name self.keep_alive = kwargs.get("ollama_keep_alive", int(os.environ.get("OLLAMA_KEEP_ALIVE", -1))) @@ -502,7 +507,7 @@ class XinferenceEmbed(Base): _FACTORY_NAME = "Xinference" def __init__(self, key, model_name="", base_url=""): - base_url = urljoin(base_url, "v1") + base_url = ensure_v1(base_url) self.client = OpenAI(api_key=key, base_url=base_url) self.model_name = model_name @@ -805,7 +810,7 @@ class NvidiaEmbed(Base): if not base_url: base_url = "https://integrate.api.nvidia.com/v1/embeddings" self.api_key = key - self.base_url = base_url + self.base_url = ensure_v1(base_url) self.headers = { "accept": "application/json", "Content-Type": "application/json", @@ -844,7 +849,7 @@ class LmStudioEmbed(LocalAIEmbed): def __init__(self, key, model_name, base_url): if not base_url: raise ValueError("Local llm url cannot be None") - base_url = urljoin(base_url, "v1") + base_url = ensure_v1(base_url) self.client = OpenAI(api_key="lm-studio", base_url=base_url) self.model_name = model_name @@ -855,7 +860,7 @@ class OpenAI_APIEmbed(OpenAIEmbed): def __init__(self, key, model_name, base_url): if not base_url: raise ValueError("url cannot be None") - base_url = urljoin(base_url, "v1") + base_url = ensure_v1(base_url) self.client = OpenAI(api_key=key, base_url=base_url) self.model_name = model_name.split("___")[0] @@ -1147,7 +1152,7 @@ class GPUStackEmbed(OpenAIEmbed): def __init__(self, key, model_name, base_url): if not base_url: raise ValueError("url cannot be None") - base_url = urljoin(base_url, "v1") + base_url = ensure_v1(base_url) self.client = OpenAI(api_key=key, base_url=base_url) self.model_name = model_name diff --git a/rag/llm/sequence2txt_model.py b/rag/llm/sequence2txt_model.py index e41c48159..d21586a21 100644 --- a/rag/llm/sequence2txt_model.py +++ b/rag/llm/sequence2txt_model.py @@ -27,6 +27,7 @@ from openai import OpenAI from openai.lib.azure import AzureOpenAI from common.token_utils import num_tokens_from_string +from rag.utils.url_utils import ensure_v1 class Base(ABC): @@ -56,7 +57,8 @@ class GPTSeq2txt(Base): def __init__(self, key, model_name="whisper-1", base_url="https://api.openai.com/v1", **kwargs): if not base_url: base_url = "https://api.openai.com/v1" - self.client = OpenAI(api_key=key, base_url=base_url) + self.base_url = ensure_v1(base_url) + self.client = OpenAI(api_key=key, base_url=self.base_url) self.model_name = model_name @@ -134,7 +136,8 @@ class AzureSeq2txt(Base): _FACTORY_NAME = "Azure-OpenAI" def __init__(self, key, model_name, lang="Chinese", **kwargs): - self.client = AzureOpenAI(api_key=key, azure_endpoint=kwargs["base_url"], api_version="2024-02-01") + self.base_url = ensure_v1(kwargs["base_url"]) + self.client = AzureOpenAI(api_key=key, azure_endpoint=self.base_url, api_version="2024-02-01") self.model_name = model_name self.lang = lang @@ -143,7 +146,7 @@ class XinferenceSeq2txt(Base): _FACTORY_NAME = "Xinference" def __init__(self, key, model_name="whisper-small", **kwargs): - self.base_url = kwargs.get("base_url", None) + self.base_url = ensure_v1(kwargs["base_url"]) if kwargs.get("base_url") else None self.model_name = model_name self.key = key @@ -256,7 +259,8 @@ class GiteeSeq2txt(Base): def __init__(self, key, model_name="whisper-1", base_url="https://ai.gitee.com/v1/", **kwargs): if not base_url: base_url = "https://ai.gitee.com/v1/" - self.client = OpenAI(api_key=key, base_url=base_url) + self.base_url = ensure_v1(base_url) + self.client = OpenAI(api_key=key, base_url=self.base_url) self.model_name = model_name @@ -266,8 +270,8 @@ class DeepInfraSeq2txt(Base): def __init__(self, key, model_name, base_url="https://api.deepinfra.com/v1/openai", **kwargs): if not base_url: base_url = "https://api.deepinfra.com/v1/openai" - - self.client = OpenAI(api_key=key, base_url=base_url) + self.base_url = ensure_v1(base_url) + self.client = OpenAI(api_key=key, base_url=self.base_url) self.model_name = model_name @@ -277,7 +281,8 @@ class CometAPISeq2txt(Base): def __init__(self, key, model_name="whisper-1", base_url="https://api.cometapi.com/v1", **kwargs): if not base_url: base_url = "https://api.cometapi.com/v1" - self.client = OpenAI(api_key=key, base_url=base_url) + self.base_url = ensure_v1(base_url) + self.client = OpenAI(api_key=key, base_url=self.base_url) self.model_name = model_name @@ -287,7 +292,8 @@ class DeerAPISeq2txt(Base): def __init__(self, key, model_name="whisper-1", base_url="https://api.deerapi.com/v1", **kwargs): if not base_url: base_url = "https://api.deerapi.com/v1" - self.client = OpenAI(api_key=key, base_url=base_url) + self.base_url = ensure_v1(base_url) + self.client = OpenAI(api_key=key, base_url=self.base_url) self.model_name = model_name @@ -366,7 +372,7 @@ class RAGconSeq2txt(Base): if not base_url: base_url = "https://connect.ragcon.com/v1" - self.base_url = base_url + self.base_url = ensure_v1(base_url) self.model_name = model_name self.key = key self.lang = lang diff --git a/rag/llm/tts_model.py b/rag/llm/tts_model.py index 38ff8de54..197c21d28 100644 --- a/rag/llm/tts_model.py +++ b/rag/llm/tts_model.py @@ -40,6 +40,7 @@ from pydantic import BaseModel, conint from common.http_client import sync_request from common.token_utils import num_tokens_from_string +from rag.utils.url_utils import ensure_v1 class ServeReferenceAudio(BaseModel): @@ -88,7 +89,7 @@ class HTTPBasedTTS(Base): def __init__(self, key, model_name, base_url, **kwargs): self.model_name = model_name - self.base_url = base_url + self.base_url = ensure_v1(base_url) self.api_key = key self.headers = {"Content-Type": "application/json"} if key and key != "x": @@ -504,7 +505,7 @@ class RAGconTTS(Base): if not base_url: base_url = "https://connect.ragcon.com/v1" - self.base_url = base_url + self.base_url = ensure_v1(base_url) self.api_key = key self.model_name = model_name self.headers = {"accept": "application/json", "Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}"} diff --git a/rag/utils/url_utils.py b/rag/utils/url_utils.py new file mode 100644 index 000000000..8fd6c7138 --- /dev/null +++ b/rag/utils/url_utils.py @@ -0,0 +1,50 @@ +# +# Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import re +from urllib.parse import urlparse, urlunparse + + +def ensure_v1(url: str) -> str: + """Ensure the URL ends with a versioned path segment like ``/v1``. + + If the path already contains a segment matching ``v{digit}`` (e.g. ``/v1``, + ``/v2``, ``/v3``), the URL is returned unchanged. Otherwise the base host + is kept and ``/v1`` is appended. + + Examples:: + + >>> ensure_v1("https://api.example.com") + 'https://api.example.com/v1' + >>> ensure_v1("https://api.example.com/v1") + 'https://api.example.com/v1' + >>> ensure_v1("https://api.example.com/v2/chat") + 'https://api.example.com/v2/chat' + >>> ensure_v1("https://api.example.com/api/v3") + 'https://api.example.com/api/v3' + """ + if not url: + return url + + parsed = urlparse(url) + path = parsed.path.rstrip("/") + + # Check if any path segment matches v{digit}, e.g. /v1, /v2, /v3 + if re.search(r"/v\d+(?:/|$)", path + "/"): + return url + + # No versioned segment found – append /v1 + new_path = (path + "/v1") if path else "/v1" + return urlunparse((parsed.scheme, parsed.netloc, new_path, parsed.params, parsed.query, parsed.fragment))