feat(provider): add SSYCloud chat completion provider (#9659)

This commit is contained in:
xxshu26
2026-08-14 00:30:08 +08:00
committed by GitHub
parent 604392d9a8
commit 27689db58c
11 changed files with 204 additions and 5 deletions
+12
View File
@@ -1377,6 +1377,18 @@ CONFIG_METADATA_2 = {
"proxy": "",
"custom_headers": {},
},
"SSYCloud(胜算云)": {
"id": "ssycloud",
"provider": "ssycloud",
"type": "ssycloud_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://router.shengsuanyun.com/api/v1",
"proxy": "",
"custom_headers": {"X-Title": "AstrBot"},
},
"NVIDIA": {
"id": "nvidia",
"provider": "nvidia",
+4
View File
@@ -443,6 +443,10 @@ class ProviderManager:
from .sources.openrouter_source import (
ProviderOpenRouter as ProviderOpenRouter,
)
case "ssycloud_chat_completion":
from .sources.ssycloud_source import (
ProviderSSYCloud as ProviderSSYCloud,
)
case "anthropic_chat_completion":
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
@@ -0,0 +1,59 @@
from openai._exceptions import NotFoundError
from ..register import register_provider_adapter
from .openai_source import ProviderOpenAIOfficial
from .request_retry import retry_provider_request
@register_provider_adapter(
"ssycloud_chat_completion",
"SSYCloud Chat Completion Provider Adapter",
)
class ProviderSSYCloud(ProviderOpenAIOfficial):
"""SSYCloud provider using its OpenAI-compatible Chat Completions API."""
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
"""Initialize the SSYCloud client with provider defaults.
Args:
provider_config: AstrBot provider source configuration.
provider_settings: Global provider settings.
"""
if not provider_config.get("api_base"):
provider_config["api_base"] = "https://router.shengsuanyun.com/api/v1"
custom_headers = provider_config.get("custom_headers")
if not isinstance(custom_headers, dict):
custom_headers = {}
provider_config["custom_headers"] = custom_headers
custom_headers.setdefault("X-Title", "AstrBot")
super().__init__(provider_config, provider_settings)
async def get_models(self) -> list[str]:
"""Return models compatible with the Chat Completions API.
Returns:
Sorted model IDs. Models without ``support_apis`` metadata are kept
for compatibility with older SSYCloud responses.
Raises:
Exception: If the SSYCloud model catalog endpoint is unavailable.
"""
try:
response = await retry_provider_request(
"SSYCloud",
lambda: self.client.models.list(),
)
model_ids: list[str] = []
for model in response.data:
support_apis = getattr(model, "support_apis", None)
if support_apis is None:
model_extra = getattr(model, "model_extra", None)
if isinstance(model_extra, dict):
support_apis = model_extra.get("support_apis")
if not isinstance(support_apis, list) or (
"/v1/chat/completions" in support_apis
):
model_ids.append(model.id)
return sorted(model_ids)
except NotFoundError as exc:
raise Exception(f"Failed to fetch SSYCloud model list: {exc}") from exc
@@ -24,7 +24,7 @@
<div v-if="selectedProviderSource" class="provider-config-shell">
<div class="provider-config-header">
<div class="provider-config-headline">
<div class="provider-config-title">{{ selectedProviderSource.id }}</div>
<div class="provider-config-title">{{ getSourceDisplayName(selectedProviderSource) }}</div>
<div class="provider-config-subtitle">
{{ selectedProviderSource.api_base || 'N/A' }}
</div>
@@ -56,6 +56,7 @@
v-if="basicSourceConfig"
:iterable="basicSourceConfig"
:metadata="providerSourceSchema"
:field-links="providerSourceFieldLinks"
metadataKey="provider"
:is-editing="true"
/>
@@ -184,7 +185,7 @@
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useModuleI18n } from '@/i18n/composables'
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue'
import ProviderModelsPanel from '@/components/provider/ProviderModelsPanel.vue'
@@ -253,6 +254,17 @@ const {
showMessage
})
const providerSourceFieldLinks = computed(() => (
selectedProviderSource.value?.provider === 'ssycloud'
? {
key: {
label: tm('providerSources.getApiKey'),
href: 'https://www.shengsuanyun.com/?from=CH_T70U2X9L'
}
}
: {}
))
const showManualModelDialog = ref(false)
const {
@@ -41,6 +41,10 @@ const props = defineProps({
enableDefaultReset: {
type: Boolean,
default: false
},
fieldLinks: {
type: Object,
default: () => ({})
}
})
@@ -274,9 +278,19 @@ function hasVisibleItemsAfter(items, currentIndex) {
</v-list-item-title>
<v-list-item-subtitle class="property-hint">
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && getItemHint(key, metadata[metadataKey].items[key])"
class="important-hint"></span>
{{ resolveConfigText(getItemPath(key), 'hint', getItemHint(key, metadata[metadataKey].items[key])) }}
<span :class="{ 'property-hint__content--linked': fieldLinks[key] }">
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && getItemHint(key, metadata[metadataKey].items[key])"
class="important-hint"></span>
<span>{{ resolveConfigText(getItemPath(key), 'hint', getItemHint(key, metadata[metadataKey].items[key])) }}</span>
<a
v-if="fieldLinks[key]"
class="property-link"
:href="fieldLinks[key].href"
target="_blank"
rel="noopener noreferrer"
@click.stop
>{{ fieldLinks[key].label }}</a>
</span>
</v-list-item-subtitle>
</v-list-item>
</v-col>
@@ -459,12 +473,31 @@ function hasVisibleItemsAfter(items, currentIndex) {
color: var(--v-theme-primaryText);
}
.property-link {
color: rgb(var(--v-theme-primary));
font-size: 0.75rem;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
}
.property-link:hover {
text-decoration: underline;
}
.property-hint {
font-size: 0.75rem;
color: var(--v-theme-secondaryText);
margin-top: 2px;
}
.property-hint__content--linked {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.type-indicator {
display: flex;
justify-content: center;
@@ -299,6 +299,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
function getSourceDisplayName(source: any) {
if (!source) return ''
if (source.isPlaceholder) return source.templateKey || source.id || ''
if (source.id === 'ssycloud') return 'ssycloud(胜算云)'
return source.id
}
@@ -100,6 +100,7 @@
"save": "Save Configuration",
"saveAndFetchModels": "Save and Fetch Models",
"fetchModels": "Fetch Model List",
"getApiKey": "Get API Key",
"saveSuccess": "Provider source saved successfully",
"saveError": "Failed to save provider source",
"deleteConfirm": "Are you sure you want to delete provider source {id}? This will also delete all associated model configurations.",
@@ -101,6 +101,7 @@
"save": "Сохранить конфиг",
"saveAndFetchModels": "Сохранить и загрузить модели",
"fetchModels": "Загрузить список моделей",
"getApiKey": "Получить API-ключ",
"saveSuccess": "Источник успешно сохранен",
"saveError": "Ошибка сохранения источника",
"deleteConfirm": "Вы уверены, что хотите удалить источник «{id}»? Все связанные конфигурации моделей будут удалены.",
@@ -101,6 +101,7 @@
"save": "保存配置",
"saveAndFetchModels": "保存并获取模型",
"fetchModels": "获取模型列表",
"getApiKey": "获取 API Key",
"saveSuccess": "提供商源保存成功",
"saveError": "提供商源保存失败",
"deleteConfirm": "确定要删除提供商源 {id} 吗?这将同时删除关联的所有模型配置。",
+1
View File
@@ -43,6 +43,7 @@ export function getProviderIcon(type) {
'groq': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/groq.svg',
'aihubmix': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/aihubmix-color.svg',
'openrouter': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/openrouter.svg',
'ssycloud': 'https://admin.shengsuanyun.com/assets/logo-BoujJhP-.png',
"tokenpony": "https://tokenpony.cn/tokenpony-web/logo.png",
"compshare": "https://compshare.cn/favicon.ico",
"xinference": "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/xinference-color.svg",
+74
View File
@@ -0,0 +1,74 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from astrbot.core.config.default import CONFIG_METADATA_2
from astrbot.core.provider.sources.ssycloud_source import ProviderSSYCloud
def _make_provider(overrides: dict | None = None) -> ProviderSSYCloud:
config = {
"id": "ssycloud-test",
"provider": "ssycloud",
"type": "ssycloud_chat_completion",
"model": "test-model",
"key": ["test-key"],
}
if overrides:
config.update(overrides)
return ProviderSSYCloud(config, {})
def test_ssycloud_template_uses_expected_defaults():
templates = CONFIG_METADATA_2["provider_group"]["metadata"]["provider"][
"config_template"
]
template = templates["SSYCloud(胜算云)"]
assert template["type"] == "ssycloud_chat_completion"
assert template["api_base"] == "https://router.shengsuanyun.com/api/v1"
assert template["custom_headers"] == {"X-Title": "AstrBot"}
def test_ssycloud_provider_sets_endpoint_and_attribution_header():
provider = _make_provider()
assert str(provider.client.base_url) == "https://router.shengsuanyun.com/api/v1/"
assert provider.client._custom_headers["X-Title"] == "AstrBot"
def test_ssycloud_provider_preserves_custom_attribution_header():
provider = _make_provider({"custom_headers": {"X-Title": "Custom Client"}})
assert provider.client._custom_headers["X-Title"] == "Custom Client"
@pytest.mark.asyncio
async def test_ssycloud_model_list_keeps_chat_completion_models():
provider = _make_provider()
provider.client.models.list = AsyncMock(
return_value=SimpleNamespace(
data=[
SimpleNamespace(
id="chat-model",
support_apis=["/v1/chat/completions", "/v1/messages"],
),
SimpleNamespace(
id="responses-model",
support_apis=["/v1/responses"],
),
SimpleNamespace(
id="extra-chat-model",
model_extra={"support_apis": ["/v1/chat/completions"]},
),
SimpleNamespace(id="legacy-model"),
]
)
)
assert await provider.get_models() == [
"chat-model",
"extra-chat-model",
"legacy-model",
]