mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-08-31 01:40:25 +08:00
feat(provider): add SSYCloud chat completion provider (#9659)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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} 吗?这将同时删除关联的所有模型配置。",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user