Skip to content
Open
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
137 changes: 135 additions & 2 deletions backend/app/services/llm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1896,6 +1896,28 @@ async def close(self) -> None:
# Factory and Utilities
# ============================================================================

@dataclass(frozen=True)
class ProviderModelSpec:
"""Model metadata exposed through the provider manifest."""

model_id: str
context_window: int | None = None
pricing_usd_per_million_tokens: dict[str, float | None] = field(default_factory=dict)
pricing_tiers_usd_per_million_tokens: tuple[dict[str, Any], ...] = ()
input_modalities: tuple[str, ...] = ()
thinking: tuple[str, ...] = ()


@dataclass(frozen=True)
class ProviderEndpoint:
"""Regional protocol endpoints exposed through the provider manifest."""

region: str
openai_base_url: str
anthropic_base_url: str
docs_root: str | None = None


@dataclass(frozen=True)
class ProviderSpec:
"""Provider registry entry."""
Expand All @@ -1907,6 +1929,9 @@ class ProviderSpec:
supports_tool_choice: bool = True
default_max_tokens: int = 4096
model_max_tokens: dict[str, int] = field(default_factory=dict)
default_model_id: str | None = None
models: tuple[ProviderModelSpec, ...] = ()
endpoints: tuple[ProviderEndpoint, ...] = ()


# Provider aliases accepted for compatibility
Expand Down Expand Up @@ -1971,8 +1996,83 @@ class ProviderSpec:
provider="minimax",
display_name="MiniMax",
protocol="openai_compatible",
default_base_url="https://api.minimaxi.com/v1",
default_base_url="https://api.minimax.io/v1",
default_max_tokens=16384,
default_model_id="MiniMax-M3",
models=(
ProviderModelSpec(
model_id="MiniMax-M3",
context_window=1000000,
pricing_usd_per_million_tokens={
"input": 0.3,
"output": 1.2,
"cache_read": 0.06,
"cache_write": None,
},
pricing_tiers_usd_per_million_tokens=(
{
"service_tier": "standard",
"input_tokens_lte": 512000,
"input": 0.3,
"output": 1.2,
"cache_read": 0.06,
"cache_write": None,
},
{
"service_tier": "standard",
"input_tokens_gt": 512000,
"input": 0.6,
"output": 2.4,
"cache_read": 0.12,
"cache_write": None,
},
{
"service_tier": "priority",
"input_tokens_lte": 512000,
"input": 0.45,
"output": 1.8,
"cache_read": 0.09,
"cache_write": None,
},
{
"service_tier": "priority",
"input_tokens_gt": 512000,
"input": 0.9,
"output": 3.6,
"cache_read": 0.18,
"cache_write": None,
},
),
input_modalities=("text", "image", "video"),
thinking=("adaptive", "disabled"),
),
ProviderModelSpec(
model_id="MiniMax-M2.7",
context_window=204800,
pricing_usd_per_million_tokens={
"input": 0.3,
"output": 1.2,
"cache_read": 0.06,
"cache_write": 0.375,
},
input_modalities=("text",),
thinking=("always_on",),
),
),
endpoints=(
ProviderEndpoint(
region="global_en",
openai_base_url="https://api.minimax.io/v1",
anthropic_base_url="https://api.minimax.io/anthropic",
docs_root="https://platform.minimax.io/docs",
),
ProviderEndpoint(
region="cn_zh",
openai_base_url="https://api.minimaxi.com/v1",
anthropic_base_url="https://api.minimaxi.com/anthropic",
docs_root="https://platform.minimaxi.com/docs",
),
),
),
"openrouter": ProviderSpec(
provider="openrouter",
Expand Down Expand Up @@ -2065,6 +2165,28 @@ def get_provider_manifest() -> list[dict[str, Any]]:
"default_max_tokens": spec.default_max_tokens,
"model_max_tokens": spec.model_max_tokens,
"aliases": [k for k, v in PROVIDER_ALIASES.items() if v == spec.provider],
"default_model_id": spec.default_model_id,
"model_ids": [model.model_id for model in spec.models],
"models": [
{
"model_id": model.model_id,
"context_window": model.context_window,
"pricing_usd_per_million_tokens": model.pricing_usd_per_million_tokens,
"pricing_tiers_usd_per_million_tokens": list(model.pricing_tiers_usd_per_million_tokens),
"input_modalities": list(model.input_modalities),
"thinking": list(model.thinking),
}
for model in spec.models
],
"endpoints": [
{
"region": endpoint.region,
"openai_base_url": endpoint.openai_base_url,
"anthropic_base_url": endpoint.anthropic_base_url,
"docs_root": endpoint.docs_root,
}
for endpoint in spec.endpoints
],
})
return out

Expand Down Expand Up @@ -2146,6 +2268,17 @@ def get_max_tokens(provider: str, model: str | None = None, max_output_tokens: i
return MAX_TOKENS_BY_PROVIDER.get(normalize_provider(provider), 4096)


def _uses_anthropic_endpoint(spec: ProviderSpec | None, base_url: str | None) -> bool:
"""Detect a configured native Anthropic-compatible endpoint for a provider."""
if not spec or not base_url:
return False
normalized_base_url = base_url.rstrip("/")
return any(
normalized_base_url == endpoint.anthropic_base_url.rstrip("/")
for endpoint in spec.endpoints
Comment on lines +2275 to +2278

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize MiniMax Anthropic URLs before client selection

When an admin enters the full MiniMax Anthropic endpoint ending in /v1/messages (or /v1), this exact comparison returns false even though AnthropicClient._normalize_base_url() already supports those forms. The model then falls through to OpenAICompatibleClient, which appends /chat/completions and sends the wrong protocol to a URL like .../anthropic/v1/messages/chat/completions; normalize the candidate URL the same way before matching, or accept the documented full endpoint variants.

Useful? React with 👍 / 👎.

)


def create_llm_client(
provider: str,
api_key: str,
Expand Down Expand Up @@ -2175,7 +2308,7 @@ def create_llm_client(
final_base_url = get_provider_base_url(normalized_provider, base_url)

# Create appropriate client
if spec and spec.protocol == "anthropic":
if spec and (spec.protocol == "anthropic" or _uses_anthropic_endpoint(spec, final_base_url)):
return AnthropicClient(
api_key=api_key,
base_url=final_base_url,
Expand Down
56 changes: 56 additions & 0 deletions backend/tests/test_llm_provider_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from app.services.llm.client import (
AnthropicClient,
OpenAICompatibleClient,
PROVIDER_REGISTRY,
create_llm_client,
get_provider_manifest,
)


def test_minimax_registry_contains_target_models_and_endpoints():
spec = PROVIDER_REGISTRY["minimax"]

assert spec.default_model_id == "MiniMax-M3"
assert [model.model_id for model in spec.models] == ["MiniMax-M3", "MiniMax-M2.7"]
assert {endpoint.region for endpoint in spec.endpoints} == {"global_en", "cn_zh"}
assert {
(endpoint.openai_base_url, endpoint.anthropic_base_url)
for endpoint in spec.endpoints
} == {
("https://api.minimax.io/v1", "https://api.minimax.io/anthropic"),
("https://api.minimaxi.com/v1", "https://api.minimaxi.com/anthropic"),
}
assert all(endpoint.anthropic_base_url.endswith("/anthropic") for endpoint in spec.endpoints)


def test_minimax_manifest_exposes_model_metadata_and_endpoint_choices():
manifest = next(item for item in get_provider_manifest() if item["provider"] == "minimax")

assert manifest["model_ids"] == ["MiniMax-M3", "MiniMax-M2.7"]
assert manifest["models"][0]["context_window"] == 1000000
assert manifest["models"][0]["input_modalities"] == ["text", "image", "video"]
assert manifest["models"][1]["thinking"] == ["always_on"]
assert {endpoint["region"] for endpoint in manifest["endpoints"]} == {"global_en", "cn_zh"}


def test_minimax_anthropic_base_uses_anthropic_client():
client = create_llm_client(
provider="minimax",
api_key="test-key",
model="MiniMax-M3",
base_url="https://api.minimax.io/anthropic",
)

assert isinstance(client, AnthropicClient)
assert client._normalize_base_url() == "https://api.minimax.io/anthropic"


def test_minimax_openai_base_uses_openai_compatible_client():
client = create_llm_client(
provider="minimax",
api_key="test-key",
model="MiniMax-M3",
base_url="https://api.minimaxi.com/v1",
)

assert isinstance(client, OpenAICompatibleClient)
69 changes: 63 additions & 6 deletions frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,47 @@ interface LLMProviderSpec {
default_base_url?: string | null;
supports_tool_choice: boolean;
default_max_tokens: number;
default_model_id?: string | null;
model_ids?: string[];
endpoints?: LLMProviderEndpoint[];
}

interface LLMProviderEndpoint {
region: string;
openai_base_url: string;
anthropic_base_url: string;
docs_root?: string | null;
}

const FALLBACK_LLM_PROVIDERS: LLMProviderSpec[] = [
{ provider: 'anthropic', display_name: 'Anthropic', protocol: 'anthropic', default_base_url: 'https://api.anthropic.com', supports_tool_choice: false, default_max_tokens: 8192 },
{ provider: 'openai', display_name: 'OpenAI', protocol: 'openai_compatible', default_base_url: 'https://api.openai.com/v1', supports_tool_choice: true, default_max_tokens: 16384 },
{ provider: 'azure', display_name: 'Azure OpenAI', protocol: 'openai_compatible', default_base_url: '', supports_tool_choice: true, default_max_tokens: 16384 },
{ provider: 'deepseek', display_name: 'DeepSeek', protocol: 'openai_compatible', default_base_url: 'https://api.deepseek.com/v1', supports_tool_choice: true, default_max_tokens: 8192 },
{ provider: 'minimax', display_name: 'MiniMax', protocol: 'openai_compatible', default_base_url: 'https://api.minimaxi.com/v1', supports_tool_choice: true, default_max_tokens: 16384 },
{
provider: 'minimax',
display_name: 'MiniMax',
protocol: 'openai_compatible',
default_base_url: 'https://api.minimax.io/v1',
supports_tool_choice: true,
default_max_tokens: 16384,
default_model_id: 'MiniMax-M3',
model_ids: ['MiniMax-M3', 'MiniMax-M2.7'],
endpoints: [
{
region: 'global_en',
openai_base_url: 'https://api.minimax.io/v1',
anthropic_base_url: 'https://api.minimax.io/anthropic',
docs_root: 'https://platform.minimax.io/docs',
},
{
region: 'cn_zh',
openai_base_url: 'https://api.minimaxi.com/v1',
anthropic_base_url: 'https://api.minimaxi.com/anthropic',
docs_root: 'https://platform.minimaxi.com/docs',
},
],
},
{ provider: 'qwen', display_name: 'Qwen (DashScope)', protocol: 'openai_compatible', default_base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', supports_tool_choice: true, default_max_tokens: 8192 },
{ provider: 'zhipu', display_name: 'Zhipu', protocol: 'openai_compatible', default_base_url: 'https://open.bigmodel.cn/api/paas/v4', supports_tool_choice: true, default_max_tokens: 8192 },
{ provider: 'baidu', display_name: 'Baidu (Qianfan)', protocol: 'openai_compatible', default_base_url: 'https://qianfan.baidubce.com/v2', supports_tool_choice: false, default_max_tokens: 4096 },
Expand Down Expand Up @@ -91,6 +124,14 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) {
queryFn: () => fetchJson<LLMProviderSpec[]>('/enterprise/llm-providers'),
});
const providerOptions = providerSpecs.length > 0 ? providerSpecs : FALLBACK_LLM_PROVIDERS;
const activeProviderSpec = providerOptions.find((p) => p.provider === modelForm.provider);
const modelOptions = activeProviderSpec?.model_ids || [];
const endpointOptions = Array.from(new Set(
(activeProviderSpec?.endpoints || []).flatMap((endpoint) => [
endpoint.openai_base_url,
endpoint.anthropic_base_url,
]),
));

const addModel = useMutation({
mutationFn: (data: any) => fetchJson(`/enterprise/llm-models${selectedTenantId ? `?tenant_id=${selectedTenantId}` : ''}`, { method: 'POST', body: JSON.stringify(data) }),
Expand Down Expand Up @@ -248,7 +289,11 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) {
const updates: any = { provider: newProvider };
updates.base_url = spec?.default_base_url || '';
if (spec) updates.max_output_tokens = String(spec.default_max_tokens);
setModelForm(f => ({ ...f, ...updates }));
setModelForm(f => ({
...f,
...updates,
model: f.model || spec?.default_model_id || '',
}));
}}>
{providerOptions.map((p) => (
<option key={p.provider} value={p.provider}>{p.display_name}</option>
Expand All @@ -257,15 +302,21 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) {
</div>
<div className="form-group">
<label className="form-label">{t('enterprise.llm.model')}</label>
<input className="form-input" placeholder={t('enterprise.llm.modelPlaceholder', 'e.g. claude-sonnet-4-20250514')} value={modelForm.model} onChange={e => setModelForm({ ...modelForm, model: e.target.value })} />
<input className="form-input" list="llm-model-options" placeholder={t('enterprise.llm.modelPlaceholder', 'e.g. claude-sonnet-4-20250514')} value={modelForm.model} onChange={e => setModelForm({ ...modelForm, model: e.target.value })} />
<datalist id="llm-model-options">
{modelOptions.map((modelId) => <option key={modelId} value={modelId} />)}
</datalist>
</div>
<div className="form-group">
<label className="form-label">{t('enterprise.llm.label')}</label>
<input className="form-input" placeholder={t('enterprise.llm.labelPlaceholder')} value={modelForm.label} onChange={e => setModelForm({ ...modelForm, label: e.target.value })} />
</div>
<div className="form-group">
<label className="form-label">{t('enterprise.llm.baseUrl')}</label>
<input className="form-input" placeholder={t('enterprise.llm.baseUrlPlaceholder')} value={modelForm.base_url} onChange={e => setModelForm({ ...modelForm, base_url: e.target.value })} />
<input className="form-input" list="llm-endpoint-options" placeholder={t('enterprise.llm.baseUrlPlaceholder')} value={modelForm.base_url} onChange={e => setModelForm({ ...modelForm, base_url: e.target.value })} />
<datalist id="llm-endpoint-options">
{endpointOptions.map((endpoint) => <option key={endpoint} value={endpoint} />)}
</datalist>
</div>
<div className="form-group" style={{ gridColumn: 'span 2' }}>
<label className="form-label">{t('enterprise.llm.apiKey')}</label>
Expand Down Expand Up @@ -334,15 +385,21 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) {
</div>
<div className="form-group">
<label className="form-label">{t('enterprise.llm.model')}</label>
<input className="form-input" placeholder={t('enterprise.llm.modelPlaceholder', 'e.g. claude-sonnet-4-20250514')} value={modelForm.model} onChange={e => setModelForm({ ...modelForm, model: e.target.value })} />
<input className="form-input" list="llm-model-options" placeholder={t('enterprise.llm.modelPlaceholder', 'e.g. claude-sonnet-4-20250514')} value={modelForm.model} onChange={e => setModelForm({ ...modelForm, model: e.target.value })} />
<datalist id="llm-model-options">
{modelOptions.map((modelId) => <option key={modelId} value={modelId} />)}
</datalist>
</div>
<div className="form-group">
<label className="form-label">{t('enterprise.llm.label')}</label>
<input className="form-input" placeholder={t('enterprise.llm.labelPlaceholder')} value={modelForm.label} onChange={e => setModelForm({ ...modelForm, label: e.target.value })} />
</div>
<div className="form-group">
<label className="form-label">{t('enterprise.llm.baseUrl')}</label>
<input className="form-input" placeholder={t('enterprise.llm.baseUrlPlaceholder')} value={modelForm.base_url} onChange={e => setModelForm({ ...modelForm, base_url: e.target.value })} />
<input className="form-input" list="llm-endpoint-options" placeholder={t('enterprise.llm.baseUrlPlaceholder')} value={modelForm.base_url} onChange={e => setModelForm({ ...modelForm, base_url: e.target.value })} />
<datalist id="llm-endpoint-options">
{endpointOptions.map((endpoint) => <option key={endpoint} value={endpoint} />)}
</datalist>
</div>
<div className="form-group" style={{ gridColumn: 'span 2' }}>
<label className="form-label">{t('enterprise.llm.apiKey')}</label>
Expand Down