Skip to content
Draft
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 @@ -188,6 +188,7 @@ enum ProviderCatalog {
ProviderCatalogEntry(.google, authMethods: [.apiKey], placement: .apiKey),
ProviderCatalogEntry(.mistral, authMethods: [.apiKey], placement: .apiKey),
ProviderCatalogEntry(.minimax, authMethods: [.apiKey], placement: .apiKey),
ProviderCatalogEntry(.siliconflow, authMethods: [.apiKey], placement: .apiKey),
ProviderCatalogEntry(.venice, authMethods: [.apiKey], placement: .apiKey),
ProviderCatalogEntry(.ollama, authMethods: [.none], placement: .local),
ProviderCatalogEntry(.custom, authMethods: [.apiKey], placement: .custom),
Expand Down
26 changes: 26 additions & 0 deletions Packages/OsaurusCore/Models/Configuration/ProviderPresets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public enum ProviderPreset: String, CaseIterable, Identifiable, Sendable {
case deepseek
case mistral
case minimax
case siliconflow
case venice
case openrouter
case ollama
Expand All @@ -39,6 +40,7 @@ public enum ProviderPreset: String, CaseIterable, Identifiable, Sendable {
case .deepseek: return "DeepSeek"
case .mistral: return "Mistral"
case .minimax: return "MiniMax"
case .siliconflow: return "SiliconFlow"
case .venice: return "Venice AI"
case .openrouter: return "OpenRouter"
case .ollama: return "Ollama"
Expand All @@ -58,6 +60,7 @@ public enum ProviderPreset: String, CaseIterable, Identifiable, Sendable {
case .deepseek: return "deepseek-v4-pro / v4-flash"
case .mistral: return L("Mistral Small/Medium models")
case .minimax: return L("MiniMax M-series models")
case .siliconflow: return "DeepSeek, Qwen, GLM, Kimi"
case .venice: return L("Privacy-first AI")
case .openrouter: return L("Multi-provider")
case .ollama: return L("Run models locally via Ollama")
Expand All @@ -77,6 +80,7 @@ public enum ProviderPreset: String, CaseIterable, Identifiable, Sendable {
case .deepseek: return "cpu"
case .mistral: return "wind"
case .minimax: return "m.square.fill"
case .siliconflow: return "bolt.horizontal.circle.fill"
case .venice: return "lock.shield.fill"
case .openrouter: return "arrow.triangle.branch"
case .ollama: return "shippingbox.fill"
Expand All @@ -96,6 +100,7 @@ public enum ProviderPreset: String, CaseIterable, Identifiable, Sendable {
case .deepseek: return [Color(red: 0.18, green: 0.36, blue: 0.95), Color(red: 0.34, green: 0.52, blue: 0.98)]
case .mistral: return [Color(red: 0.98, green: 0.42, blue: 0.11), Color(red: 0.87, green: 0.24, blue: 0.09)]
case .minimax: return [Color(red: 0.93, green: 0.27, blue: 0.23), Color(red: 0.83, green: 0.15, blue: 0.18)]
case .siliconflow: return [Color(red: 0.08, green: 0.55, blue: 0.82), Color(red: 0.1, green: 0.72, blue: 0.62)]
case .venice: return [Color(red: 0.83, green: 0.66, blue: 0.33), Color(red: 0.72, green: 0.53, blue: 0.17)]
case .openrouter: return [Color(red: 0.95, green: 0.55, blue: 0.25), Color(red: 0.85, green: 0.4, blue: 0.2)]
case .ollama: return [Color(red: 0.36, green: 0.36, blue: 0.4), Color(red: 0.22, green: 0.22, blue: 0.26)]
Expand All @@ -115,6 +120,7 @@ public enum ProviderPreset: String, CaseIterable, Identifiable, Sendable {
case .deepseek: return "https://platform.deepseek.com/api_keys"
case .mistral: return "https://console.mistral.ai/api-keys"
case .minimax: return "https://platform.minimax.io/user-center/basic-information/interface-key"
case .siliconflow: return "https://cloud.siliconflow.com/account/ak"
case .venice: return "https://venice.ai/settings/api"
case .openrouter: return "https://openrouter.ai/keys"
case .ollama: return "https://ollama.com/download"
Expand All @@ -126,6 +132,7 @@ public enum ProviderPreset: String, CaseIterable, Identifiable, Sendable {
var badge: String? {
switch self {
case .azureOpenAI: return "Azure"
case .siliconflow: return "OpenAI-compatible"
case .venice: return L("Privacy")
case .ollama: return L("Local")
default: return nil
Expand All @@ -140,6 +147,7 @@ public enum ProviderPreset: String, CaseIterable, Identifiable, Sendable {
case .deepseek: return "https://api-docs.deepseek.com/"
case .mistral: return "https://docs.mistral.ai/api"
case .minimax: return "https://platform.minimax.io/docs/api-reference/api-overview"
case .siliconflow: return "https://docs.siliconflow.com/en/userguide/quickstart"
case .venice: return "https://docs.venice.ai"
case .ollama: return "https://github.com/ollama/ollama"
default: return nil
Expand Down Expand Up @@ -207,6 +215,14 @@ public enum ProviderPreset: String, CaseIterable, Identifiable, Sendable {
L("Create a new API key"),
L("Copy and paste it here"),
]
case .siliconflow:
return [
L("Go to the SiliconFlow API Keys page"),
L("Sign in or create an account"),
L("Create a new API key"),
L("Osaurus connects to https://api.siliconflow.com/v1 automatically"),
L("Copy and paste it here"),
]
case .ollama:
return [
L("Install Ollama from ollama.com"),
Expand Down Expand Up @@ -397,6 +413,16 @@ public enum ProviderPreset: String, CaseIterable, Identifiable, Sendable {
"MiniMax-M2",
]
)
case .siliconflow:
return ProviderPresetConfiguration(
name: "SiliconFlow",
host: "api.siliconflow.com",
providerProtocol: .https,
port: nil,
basePath: "/v1",
authType: .apiKey,
providerType: .openaiLegacy
)
case .venice:
return ProviderPresetConfiguration(
name: "Venice AI",
Expand Down
107 changes: 106 additions & 1 deletion Packages/OsaurusCore/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -38810,6 +38810,40 @@
}
}
},
"Create a key at cloud.siliconflow.com/account/ak." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Erstelle einen Schlüssel unter cloud.siliconflow.com/account/ak."
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "cloud.siliconflow.com/account/ak에서 키를 만드세요."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Создайте ключ на cloud.siliconflow.com/account/ak."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "在 cloud.siliconflow.com/account/ak 创建密钥。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "在 cloud.siliconflow.com/account/ak 創建密鑰。"
}
}
}
},
"Create a new secret key" : {
"localizations" : {
"de" : {
Expand Down Expand Up @@ -62251,6 +62285,40 @@
}
}
},
"Go to the SiliconFlow API Keys page" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Zur SiliconFlow-API-Schlüsselseite gehen"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "SiliconFlow API 키 페이지로 이동"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Перейдите на страницу API-ключей SiliconFlow."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "前往 SiliconFlow API 密钥页面"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "前往 SiliconFlow API 密鑰頁面"
}
}
}
},
"Go to the OpenAI Platform API keys page" : {
"localizations" : {
"de" : {
Expand Down Expand Up @@ -135925,6 +135993,9 @@
}
}
},
"SiliconFlow" : {
"shouldTranslate" : false
},
"Simple" : {
"comment" : "A label for the \"Simple\" segment in the privacy rule editor.",
"isCommentAutoGenerated" : true,
Expand Down Expand Up @@ -157568,6 +157639,40 @@
}
}
},
"Osaurus connects to https://api.siliconflow.com/v1 automatically" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Osaurus verbindet sich automatisch mit https://api.siliconflow.com/v1"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "Osaurus가 https://api.siliconflow.com/v1에 자동으로 연결합니다"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Osaurus автоматически подключается к https://api.siliconflow.com/v1"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Osaurus 会自动连接到 https://api.siliconflow.com/v1"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "Osaurus 會自動連接到 https://api.siliconflow.com/v1"
}
}
}
},
"Use the resource key from Azure Portal." : {
"comment" : "Label for an extra field in the credential instructions for Azure OpenAI, asking the user to provide the resource key.",
"isCommentAutoGenerated" : true,
Expand Down Expand Up @@ -165654,4 +165759,4 @@
}
},
"version" : "1.1"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,16 @@ public enum ProviderCredentialInstructionsCatalog {
storageAuthType: .apiKey,
presetId: preset.rawValue
)
case .siliconflow:
return ProviderCredentialInstructions(
providerType: providerType,
displayName: L("SiliconFlow"),
authMethod: .apiKey,
getKeyURL: getKeyURL,
keyFormatHint: L("Create a key at cloud.siliconflow.com/account/ak."),
storageAuthType: .apiKey,
presetId: preset.rawValue
)
case .xai:
return ProviderCredentialInstructions(
providerType: providerType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,49 @@ struct ProviderPresetsTests {
)
}

@Test func siliconFlowPreset_configurationMatchesOfficialAPI() throws {
let config = ProviderPreset.siliconflow.configuration

#expect(config.name == "SiliconFlow")
#expect(config.host == "api.siliconflow.com")
#expect(config.providerProtocol == .https)
#expect(config.port == nil)
#expect(config.basePath == "/v1")
#expect(config.authType == .apiKey)
#expect(config.providerType == .openaiLegacy)
}

@Test func siliconFlowPreset_isListedAsKnownPreset() throws {
#expect(ProviderPreset.knownPresets.contains(.siliconflow))
}

@Test func matching_providerWithSiliconFlowHost_resolvesToSiliconFlowPreset() throws {
let provider = RemoteProvider(
name: "My SiliconFlow",
host: "api.siliconflow.com",
basePath: "/v1",
authType: .apiKey,
providerType: .openaiLegacy
)

#expect(ProviderPreset.matching(provider: provider) == .siliconflow)
}

@Test func siliconFlowPreset_chatEndpointResolvesToChatCompletions() throws {
let provider = RemoteProvider(
name: "SiliconFlow",
host: ProviderPreset.siliconflow.configuration.host,
basePath: ProviderPreset.siliconflow.configuration.basePath,
authType: .apiKey,
providerType: ProviderPreset.siliconflow.configuration.providerType
)

#expect(
provider.url(for: provider.providerType.chatEndpoint)?.absoluteString
== "https://api.siliconflow.com/v1/chat/completions"
)
}

// MARK: - OAuth-first picker grouping

@Test func oauthProviders_areTheThreeOneClickProviders() throws {
Expand All @@ -177,6 +220,7 @@ struct ProviderPresetsTests {
#expect(keyVendors.contains(.openrouter))
#expect(keyVendors.contains(.anthropic))
#expect(keyVendors.contains(.azureOpenAI))
#expect(keyVendors.contains(.siliconflow))
// Ollama (local, no key) and Custom (its own section) stay out.
#expect(!keyVendors.contains(.ollama))
#expect(!keyVendors.contains(.custom))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,30 @@ struct ProviderPresetCredentialSheetTests {
#expect(entry.getKeyURL?.absoluteString == "https://www.atlascloud.ai/console/api-keys")
}

@Test
func siliconFlowPreset_usesApiKeyAndOpenAICompatibleHost() {
let request = ProviderCredentialRequest(
preset: .siliconflow,
providerName: "SiliconFlow",
mode: .addNew
)
#expect(request.preset == .siliconflow)
#expect(request.providerType == .openaiLegacy)
#expect(request.instructions.authMethod == .apiKey)
#expect(request.instructions.presetId == "siliconflow")
#expect(ProviderPreset.siliconflow.configuration.host == "api.siliconflow.com")
#expect(ProviderPreset.siliconflow.configuration.basePath == "/v1")
}

@Test
func siliconFlowCatalogEntry_usesApiKeyStorageAuth() {
let entry = ProviderCredentialInstructionsCatalog.entry(for: .siliconflow)
#expect(entry.storageAuthType == .apiKey)
#expect(entry.authMethod == .apiKey)
#expect(entry.getKeyURL?.absoluteString == "https://cloud.siliconflow.com/account/ak")
#expect(entry.keyFormatHint?.contains("cloud.siliconflow.com/account/ak") == true)
}

@Test
func customPreset_requiresHostExtraField() {
let request = ProviderCredentialRequest(
Expand Down Expand Up @@ -278,6 +302,15 @@ struct ProviderPresetCredentialSheetTests {
#expect(preset == .openrouter)
}

@Test
func resolver_resolvesSiliconFlowToSiliconFlowPreset() {
guard case .preset(let preset) = ProviderToolShared.resolve("siliconflow") else {
Issue.record("siliconflow must resolve to a preset")
return
}
#expect(preset == .siliconflow)
}

@Test
func resolver_legacyOpenaiCompatibleAliasResolvesToCustom() {
// The chat tool used to expose `openai_compatible` as a sibling
Expand Down
Loading
Loading