Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ class BaiLianLLMModelParams(BaseForm):


class BaiLianLLMModelCredential(BaseForm, BaseModelCredential):
api_base = forms.TextInputField(_("API URL"), required=True)
api_base = forms.TextInputField(
_("API URL"), required=True, default_value="https://dashscope.aliyuncs.com/compatible-mode/v1"
)
api_key = forms.PasswordInputField(_("API Key"), required=True)

def is_valid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@


class AliyunBaiLianAsrSTTModelCredential(BaseForm, BaseModelCredential):
api_url = forms.TextInputField(_("API URL"), required=True)
api_url = forms.TextInputField(_("API URL"), required=True, default_value="https://dashscope.aliyuncs.com/api/v1")
api_key = forms.PasswordInputField(_("API Key"), required=True)

def is_valid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ class AliyunBaiLianDefaultSTTModelCredential(BaseForm, BaseModelCredential):
{"label": _("Real-time speech recognition - Fun-ASR/Paraformer"), "value": "other"},
],
)
api_url = forms.TextInputField(_("API URL"), required=True, relation_show_field_dict={"type": ["qwen", "omni"]})
api_url = forms.TextInputField(
_("API URL"),
required=True,
default_value="https://dashscope.aliyuncs.com/api/v1",
relation_show_field_dict={"type": ["qwen", "omni"]},
)
api_key = forms.PasswordInputField(_("API Key"), required=True)

def is_valid(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of Code Changes

The provided diff adds a default_value to an API URL field in what appears to be a credential form for Aliyun BaiLian models within a Dify-like framework. Here are the findings:

1. Potential Issues & Irregularities

  • Hardcoded Environment Dependency: Hardcoding "https://dashscope.aliyuncs.com/api/v1" assumes all users will access the public internet endpoint. This ignores:
    • Regional Endpoints: Users might need regional endpoints (e.g., https://dashscope-intl.aliyuncs.com/...).
    • Private VPC/Proxy Environments: Enterprise users often use internal mirrors or proxies. Forcing this specific URL as a default makes it less flexible and potentially confusing if their setup differs from the standard public endpoint.
    • Path Versioning: The path /api/v1 is hardcoded. If Alibaba changes the API version, this code becomes stale. While unlikely to break immediately, it is generally better to let the SDK/client library handle base URLs internally rather than exposing and hardcoding them in UI forms unless strictly necessary for multi-tenant proxy setups.
  • Redundancy/Confusion with relation_show_field_dict: The field is shown for types ["qwen", "omni"]. Ensure that for these types, the backend logic expects this exact URL format. If some types don't require custom URLs (and rely on SDK defaults), showing this field with a hardcoded default could mislead users into thinking it's mandatory configuration when it's not.

2. Optimization Suggestions

  • Consider Making It Optional or Context-Aware:
    • Instead of a hardcoded default_value, consider making required=False and letting the backend fall back to the standard SDK behavior if no URL is provided.
    • Alternatively, provide multiple common options in a dropdown (SelectField) instead of a free-text input for the API URL, such as "Public Internet," "International," "Custom."
  • Configuration Management: For production systems, hardcoded URLs should ideally be managed via environment variables or a central config file, not baked into the UI definition. If this UI allows overriding for testing/proxies, that’s acceptable, but the default should probably align with what the SDK uses by default to avoid maintenance burden.
  • String Constant Extraction: Move the URL string to a class constant or module-level variable for easier maintenance if it's referenced elsewhere or needs to be updated frequently.

3. Recommendation

If this change is to simplify setup for most users (who use the public dashscope), it is functionally acceptable but architecturally slightly risky. A better approach would be:

  1. Make the field required=False.
  2. Document that leaving it blank uses the default official endpoint.
  3. Remove the hardcoded default_value unless strictly required to populate existing user settings (migration scenario).

If you must keep the default, consider adding a comment explaining why this specific URL/path is chosen, especially regarding versioning (v1).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class AliyunBaiLianOmiSTTModelParams(BaseForm):


class AliyunBaiLianOmiSTTModelCredential(BaseForm, BaseModelCredential):
api_url = forms.TextInputField(_("API URL"), required=True)
api_url = forms.TextInputField(_("API URL"), required=True, default_value="https://dashscope.aliyuncs.com/api/v1")
api_key = forms.PasswordInputField(_("API Key"), required=True)

def is_valid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class AnthropicImageModelParams(BaseForm):


class AnthropicImageModelCredential(BaseForm, BaseModelCredential):
api_base = forms.TextInputField(_("API URL"), required=True)
api_base = forms.TextInputField(_("API URL"), required=True, default_value="https://api.anthropic.com/v1")
api_key = forms.PasswordInputField(_("API Key"), required=True)

def is_valid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def is_valid(
def encryption_dict(self, model: Dict[str, object]):
return {**model, "api_key": super().encryption(model.get("api_key", ""))}

api_base = forms.TextInputField(_("API URL"), required=True)
api_base = forms.TextInputField(_("API URL"), required=True, default_value="https://api.anthropic.com/v1")
api_key = forms.PasswordInputField(_("API Key"), required=True)

def get_model_params_setting_form(self, model_name):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def is_valid(
def encryption_dict(self, model: Dict[str, object]):
return {**model, "api_key": super().encryption(model.get("api_key", ""))}

api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://api.moonshot.cn/v1")
api_key = forms.PasswordInputField("API Key", required=True)

def get_model_params_setting_form(self, model_name):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,5 @@ def encryption_dict(self, model: Dict[str, object]):
def get_model_params_setting_form(self, model_name):
return OpenAIEmbeddingModelParams()

api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://api.openai.com/v1")
api_key = forms.PasswordInputField("API Key", required=True)
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class OpenAIImageModelParams(BaseForm):


class OpenAIImageModelCredential(BaseForm, BaseModelCredential):
api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://api.openai.com/v1")
api_key = forms.PasswordInputField("API Key", required=True)

def is_valid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def is_valid(
def encryption_dict(self, model: Dict[str, object]):
return {**model, "api_key": super().encryption(model.get("api_key", ""))}

api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://api.openai.com/v1")
api_key = forms.PasswordInputField("API Key", required=True)

def get_model_params_setting_form(self, model_name):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class OpenAISTTModelParams(BaseForm):


class OpenAISTTModelCredential(BaseForm, BaseModelCredential):
api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://api.openai.com/v1")
api_key = forms.PasswordInputField("API Key", required=True)

def is_valid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class OpenAITTIModelParams(BaseForm):


class OpenAITextToImageModelCredential(BaseForm, BaseModelCredential):
api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://api.openai.com/v1")
api_key = forms.PasswordInputField("API Key", required=True)

def is_valid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class OpenAITTSModelGeneralParams(BaseForm):


class OpenAITTSModelCredential(BaseForm, BaseModelCredential):
api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://api.openai.com/v1")
api_key = forms.PasswordInputField("API Key", required=True)

def is_valid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class QfRerankerModelParams(BaseForm):


class QfRerankerCredential(BaseForm, BaseModelCredential):
api_url = forms.TextInputField("API URL", required=True)
api_url = forms.TextInputField("API URL", required=True, default_value="https://qianfan.baidubce.com/v2")
api_key = forms.PasswordInputField("API Key", required=True)

def is_valid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,5 @@ def is_valid(
def encryption_dict(self, model: Dict[str, object]):
return {**model, "api_key": super().encryption(model.get("api_key", ""))}

api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://api.siliconflow.cn/v1")
api_key = forms.PasswordInputField("API Key", required=True)
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class SiliconCloudImageModelParams(BaseForm):


class SiliconCloudImageModelCredential(BaseForm, BaseModelCredential):
api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://api.siliconflow.cn/v1")
api_key = forms.PasswordInputField("API Key", required=True)

def is_valid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def is_valid(
def encryption_dict(self, model: Dict[str, object]):
return {**model, "api_key": super().encryption(model.get("api_key", ""))}

api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://api.siliconflow.cn/v1")
api_key = forms.PasswordInputField("API Key", required=True)

def get_model_params_setting_form(self, model_name):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def is_valid(
def encryption_dict(self, model: Dict[str, object]):
return {**model, "api_key": super().encryption(model.get("api_key", ""))}

api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://api.siliconflow.cn/v1")
api_key = forms.PasswordInputField("API Key", required=True)

def get_model_params_setting_form(self, model_name: str) -> SiliconCloudRerankerModelParams:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@


class SiliconCloudSTTModelCredential(BaseForm, BaseModelCredential):
api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://api.siliconflow.cn/v1")
api_key = forms.PasswordInputField("API Key", required=True)

def is_valid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class SiliconCloudTTIModelParams(BaseForm):


class SiliconCloudTextToImageModelCredential(BaseForm, BaseModelCredential):
api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://api.siliconflow.cn/v1")
api_key = forms.PasswordInputField("API Key", required=True)

def is_valid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class SiliconCloudTTSModelGeneralParams(BaseForm):


class SiliconCloudTTSModelCredential(BaseForm, BaseModelCredential):
api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://api.siliconflow.cn/v1")
api_key = forms.PasswordInputField("API Key", required=True)

def is_valid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,5 @@ def is_valid(
def encryption_dict(self, model: Dict[str, object]):
return {**model, "api_key": super().encryption(model.get("api_key", ""))}

api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://ark.cn-beijing.volces.com/api/v3")
api_key = forms.PasswordInputField("API Key", required=True)
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class VolcanicEngineImageModelParams(BaseForm):


class VolcanicEngineImageModelCredential(BaseForm, BaseModelCredential):
api_base = forms.TextInputField("API URL", required=True)
api_base = forms.TextInputField("API URL", required=True, default_value="https://ark.cn-beijing.volces.com/api/v3")
api_key = forms.PasswordInputField("API Key", required=True)

def is_valid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
@Date :5/13/24 7:47 AM
"""

import copy
import os

from common.utils.common import get_file_content
Expand Down Expand Up @@ -146,6 +147,15 @@ class VolcanicEngineModelProvider(IModelProvider):
def get_model_info_manage(self):
return model_info_manage

def get_model_credential(self, model_type, model_name):
model_credential = super().get_model_credential(model_type, model_name)
# 火山引擎 Ark 统一走 v3 地址;LLM 复用 OpenAI 凭证类,需为实例单独设置,
# 避免覆写共享凭证类的 default_value 影响到其它 provider。
if model_type == "LLM" and hasattr(model_credential, "api_base"):
model_credential.api_base = copy.copy(model_credential.api_base)
model_credential.api_base.default_value = "https://ark.cn-beijing.volces.com/api/v3"
return model_credential

def get_model_provide_info(self):
return ModelProvideInfo(
provider="model_volcanic_engine_provider",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

The change is small and the intent is reasonable (give Volcanic Ark LLMs the v3 base URL without poisoning a shared credential class), but the implementation relies on a fragile monkey-patch of a credential-field object. Below are the concerns, ordered by severity.


1. copy.copy on a field descriptor is risky (high)

model_credential.api_base is presumably a field descriptor (pydantic FieldInfo, dataclass field, or a custom descriptor). A shallow copy:

  • Shares any mutable internals (choices, validators, default_factory, metadata, nested config objects). Mutating anything other than the plain string default_value later will leak back into the original.
  • May not be a copy at all if api_base is implemented as a property / __get__ descriptor — you'd be copying a bound wrapper, not the field.
  • Breaks descriptor protocol semantics if the class relies on __set_name__, __set__, or identity checks between the field object and the class registry.

Fix: use copy.deepcopy, or better, construct a fresh field via the field's own constructor:

api_base_field = type(model_credential.api_base)(**{...})   # if the API allows it
# or: dataclasses.replace / model_credential.__class__.__fields__["api_base"].copy()

Then assign the URL through the normal field/default API so validators and __post_init__ still run.

2. Thread-safety / shared-state assumption (high)

The comment says this avoids overwriting the shared class's default_value, but that guarantee only holds if super().get_model_credential(...) returns a fresh instance per call. Confirm that:

  • If the base method caches/returns a singleton credential, model_credential.api_base = ... mutates shared state on the instance, and concurrent requests (LLM vs. another provider) can observe mixed values.
  • Even with a fresh instance, mutating a class-level descriptor's default_value on any reference is process-global. If copy.copy returns an object that still shares the class's __dict__ entry, the guard is illusory.

Add a comment/assertion, or make the field construction fully local.

3. Bypasses validation (medium)

Setting default_value directly after construction skips field validation and any __post_init__/validator hooks. If the field or credential validates URLs, this write may silently produce an invalid state.

4. Magic strings and hard-coded URL (medium)

  • "LLM" should be an enum/constant (e.g. ModelType.LLM) already defined elsewhere in the codebase.
  • The URL should be a module constant and ideally overridable via env/config so private/sovereign deployments and tests can point elsewhere:
VOLCANIC_ARK_LLM_BASE_URL = os.getenv(
    "VOLCANIC_ARK_LLM_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"
)

5. Silent no-op on missing api_base (low–medium)

hasattr(..., "api_base") silently returns the unmodified credential. If the upstream credential class ever renames the field, this degrades silently. Log a warning (or assert) so the misconfiguration is visible. Use getattr(model_credential, "api_base", None) for clarity; also handles model_credential is None.

6. Missing type hints / docstring (low)

Other methods in the class appear to follow a consistent style; add -> Any (or the concrete credential type) and a one-line docstring explaining why the copy is needed.

7. Missing regression test (low–medium)

The whole point of the copy is "don't affect other providers." Add a test asserting:

before = SomeOpenAICredential.api_base.default_value
provider.get_model_credential("LLM", "x")
assert SomeOpenAICredential.api_base.default_value == before
assert provider.get_model_credential("LLM", "x").api_base.value == VOLCANIC_ARK_LLM_BASE_URL

This test would have caught a shallow-copy leak.

8. Minor / style

  • import copy placement is fine (stdlib group), but if common.utils.common is a heavy import this file already pulls in, no concern.
  • Consider caching the modified field on the provider instance only if it is provably immutable and thread-safe — otherwise keep per-call copies (cheap enough).

Suggested shape

VOLCANIC_ARK_LLM_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"

def get_model_credential(self, model_type: str, model_name: str):
    """Return a credential whose api_base points at Ark v3.

    The base credential class is shared across providers, so we copy the
    api_base field instead of mutating the shared default.
    """
    credential = super().get_model_credential(model_type, model_name)
    if model_type != ModelType.LLM:
        return credential

    api_base = getattr(credential, "api_base", None)
    if api_base is None:
        logger.warning("Credential for %s has no api_base; skipping Ark URL override", model_name)
        return credential

    new_field = copy.deepcopy(api_base)          # avoid shared mutable internals
    new_field.default_value = VOLCANIC_ARK_LLM_BASE_URL
    credential.api_base = new_field
    return credential

Bottom line: the approach works today for a plain string field, but it is one refactor away from a cross-provider bug. Deep-copy (or reconstruct) the field, avoid magic strings, surface the missing-attribute case, and lock the behavior in with a regression test.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def is_valid(
def encryption_dict(self, model: Dict[str, object]):
return {**model, "spark_api_secret": super().encryption(model.get("spark_api_secret", ""))}

spark_api_url = forms.TextInputField("API URL", required=True)
spark_api_url = forms.TextInputField("API URL", required=True, default_value="wss://spark-api.xf-yun.com/v3.5/chat")
spark_app_id = forms.TextInputField("APP ID", required=True)
spark_api_key = forms.PasswordInputField("API Key", required=True)
spark_api_secret = forms.PasswordInputField("API Secret", required=True)
Expand Down
144 changes: 144 additions & 0 deletions apps/models_provider/tests/test_provider_default_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
from django.test import SimpleTestCase

from models_provider.constants.model_provider_constants import ModelProvideConstants


class ProviderDefaultUrlTests(SimpleTestCase):
"""供应商创建模型表单中的 API 地址应展示官方默认值。"""

def _url_default(self, provider_key, model_type, model_name, field):
provider = ModelProvideConstants[provider_key]
form_list = provider.get_model_credential(model_type, model_name).to_form_list()
fields = {item["field"]: item for item in form_list}
return fields[field].get("default_value")

def test_providers_expose_default_url(self):
cases = (
# OpenAI
("model_openai_provider", "LLM", "gpt-4o", "api_base", "https://api.openai.com/v1"),
("model_openai_provider", "STT", "whisper-1", "api_base", "https://api.openai.com/v1"),
("model_openai_provider", "TTS", "tts-1", "api_base", "https://api.openai.com/v1"),
("model_openai_provider", "EMBEDDING", "text-embedding-3-small", "api_base", "https://api.openai.com/v1"),
("model_openai_provider", "IMAGE", "gpt-4o", "api_base", "https://api.openai.com/v1"),
("model_openai_provider", "TTI", "dall-e-3", "api_base", "https://api.openai.com/v1"),
# Anthropic
(
"model_anthropic_provider",
"LLM",
"claude-3-5-sonnet-20241022",
"api_base",
"https://api.anthropic.com/v1",
),
(
"model_anthropic_provider",
"IMAGE",
"claude-3-5-sonnet-20241022",
"api_base",
"https://api.anthropic.com/v1",
),
# Kimi
("model_kimi_provider", "LLM", "moonshot-v1-128k", "api_base", "https://api.moonshot.cn/v1"),
# SiliconCloud
(
"model_siliconCloud_provider",
"LLM",
"deepseek-ai/DeepSeek-R1-Distill-Llama-8B",
"api_base",
"https://api.siliconflow.cn/v1",
),
(
"model_siliconCloud_provider",
"STT",
"FunAudioLLM/SenseVoiceSmall",
"api_base",
"https://api.siliconflow.cn/v1",
),
(
"model_siliconCloud_provider",
"EMBEDDING",
"BAAI/bge-large-zh-v1.5",
"api_base",
"https://api.siliconflow.cn/v1",
),
(
"model_siliconCloud_provider",
"TTI",
"stabilityai/stable-diffusion-xl-base-1.0",
"api_base",
"https://api.siliconflow.cn/v1",
),
(
"model_siliconCloud_provider",
"RERANKER",
"BAAI/bge-reranker-v2-m3",
"api_base",
"https://api.siliconflow.cn/v1",
),
(
"model_siliconCloud_provider",
"TTS",
"FunAudioLLM/CosyVoice2-0.5B",
"api_base",
"https://api.siliconflow.cn/v1",
),
(
"model_siliconCloud_provider",
"IMAGE",
"Qwen/Qwen3-VL-32B-Instruct",
"api_base",
"https://api.siliconflow.cn/v1",
),
# Aliyun BaiLian
(
"aliyun_bai_lian_model_provider",
"LLM",
"qwen3.7-plus",
"api_base",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
),
("aliyun_bai_lian_model_provider", "STT", "default", "api_url", "https://dashscope.aliyuncs.com/api/v1"),
(
"aliyun_bai_lian_model_provider",
"STT",
"qwen-omni-turbo",
"api_url",
"https://dashscope.aliyuncs.com/api/v1",
),
(
"aliyun_bai_lian_model_provider",
"STT",
"qwen-audio-asr",
"api_url",
"https://dashscope.aliyuncs.com/api/v1",
),
# Volcanic engine
(
"model_volcanic_engine_provider",
"LLM",
"ep-xxxxxxxxxx-yyyy",
"api_base",
"https://ark.cn-beijing.volces.com/api/v3",
),
(
"model_volcanic_engine_provider",
"IMAGE",
"ep-xxxxxxxxxx-yyyy",
"api_base",
"https://ark.cn-beijing.volces.com/api/v3",
),
(
"model_volcanic_engine_provider",
"EMBEDDING",
"ep-xxxxxxxxxx-yyyy",
"api_base",
"https://ark.cn-beijing.volces.com/api/v3",
),
# Xunfei
("model_xf_provider", "LLM", "general", "spark_api_url", "wss://spark-api.xf-yun.com/v3.5/chat"),
# Qianfan reranker
("model_qianfan_provider", "RERANKER", "bce-reranker-base", "api_url", "https://qianfan.baidubce.com/v2"),
)

for provider_key, model_type, model_name, field, expected in cases:
with self.subTest(provider=provider_key, model_type=model_type, model_name=model_name, field=field):
self.assertEqual(expected, self._url_default(provider_key, model_type, model_name, field))
Loading