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 @@ -16,5 +16,7 @@ internal val CloudProvider.documentationUrl: String?
CloudProvider.AIHUBMIX -> "https://docs.aihubmix.com/cn"
CloudProvider.AI_302 -> "https://doc.302.ai/"
CloudProvider.SCNET -> "https://www.scnet.cn/ac/openapi/doc/2.0/moduleapi/overview.html"
CloudProvider.SILICONFLOW_CN -> "https://docs.siliconflow.cn/docs/api/models-get"
CloudProvider.SILICONFLOW -> "https://docs.siliconflow.com/en/api-reference/models/get-model-list"
CloudProvider.CUSTOM -> null
}
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@ enum class CloudProvider(
baseUrl = "https://api.scnet.cn/api/llm/v1/",
sortName = "SCNet",
),
SILICONFLOW_CN(
displayName = "SiliconFlow CN",
baseUrl = "https://api.siliconflow.cn/v1/",
),
SILICONFLOW(
displayName = "SiliconFlow",
baseUrl = "https://api.siliconflow.com/v1/",
),
CUSTOM(
displayName = "Custom",
baseUrl = "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1388,6 +1388,8 @@ private fun cloudProviderLabel(provider: CloudProvider): String = when (provider
CloudProvider.CUSTOM -> stringResource(R.string.onboarding_cloud_custom)
CloudProvider.MODELSCOPE -> stringResource(R.string.onboarding_cloud_provider_modelscope)
CloudProvider.SCNET -> stringResource(R.string.onboarding_cloud_provider_scnet)
CloudProvider.SILICONFLOW_CN -> stringResource(R.string.onboarding_cloud_provider_siliconflow_cn)
CloudProvider.SILICONFLOW -> stringResource(R.string.onboarding_cloud_provider_siliconflow)
else -> provider.displayName
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.gameocr.app.translate

import okhttp3.HttpUrl

/** SiliconFlow documents sub_type=chat for chat-capable models, including vision models.
* https://docs.siliconflow.cn/docs/api/models-get
* https://docs.siliconflow.com/en/api-reference/models/get-model-list
* Only translation calls this policy; the shared ASR model-list client stays unfiltered.
*/
internal fun chatModelListUrl(url: HttpUrl): HttpUrl = if (
url.scheme == "https" && url.port == 443 &&
url.host in setOf("api.siliconflow.cn", "api.siliconflow.com") &&
url.encodedPath == "/v1/models" && url.username.isEmpty() && url.password.isEmpty() &&
url.query == null && url.fragment == null
) {
url.newBuilder().addQueryParameter("sub_type", "chat").build()
} else {
url
}
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,7 @@ class OpenAiTranslator @Inject constructor(
val timedClient = client.withApiTimeout(
OpenAiRequestPolicy.remoteLlmTimeoutSeconds(settings.apiTimeoutSeconds),
)
val modelsUrl = (ensureSlash(settings.baseUrl) + "models").toHttpUrl()
val modelsUrl = chatModelListUrl((ensureSlash(settings.baseUrl) + "models").toHttpUrl())
val modelsResult = try {
modelListClient.fetch(
modelsUrl, settings.apiKey,
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values-zh-rCN/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2134,6 +2134,8 @@
<string name="onboarding_cloud_region_international">国际</string>
<string name="onboarding_cloud_provider_modelscope">魔搭 ModelScope</string>
<string name="onboarding_cloud_provider_scnet">国家超算互联网(SCNet)</string>
<string name="onboarding_cloud_provider_siliconflow_cn">硅基流动</string>
<string name="onboarding_cloud_provider_siliconflow">SiliconFlow</string>
<string name="onboarding_cloud_url">Base URL</string>
<string name="onboarding_cloud_api_key">API Key</string>
<string name="onboarding_cloud_model">模型名称</string>
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2136,6 +2136,8 @@
<string name="onboarding_cloud_region_international">International</string>
<string name="onboarding_cloud_provider_modelscope">ModelScope</string>
<string name="onboarding_cloud_provider_scnet">SCNet</string>
<string name="onboarding_cloud_provider_siliconflow_cn">SiliconFlow CN</string>
<string name="onboarding_cloud_provider_siliconflow">SiliconFlow</string>
<string name="onboarding_cloud_url">Base URL</string>
<string name="onboarding_cloud_api_key">API key</string>
<string name="onboarding_cloud_model">Model name</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class CloudConnectionUiContractTest {
CloudProvider.MODELSCOPE to "modelscope.cn", CloudProvider.OPENROUTER to "openrouter.ai",
CloudProvider.AIHUBMIX to "docs.aihubmix.com", CloudProvider.AI_302 to "doc.302.ai",
CloudProvider.SCNET to "www.scnet.cn",
CloudProvider.SILICONFLOW_CN to "docs.siliconflow.cn",
CloudProvider.SILICONFLOW to "docs.siliconflow.com",
)
assertEquals(CloudProvider.entries.toSet() - CloudProvider.CUSTOM, hosts.keys)
hosts.forEach { (provider, host) ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ class OnboardingCloudProviderTest {
CloudProvider.DEEPSEEK, CloudProvider.GEMINI, CloudProvider.GLM,
CloudProvider.KIMI, CloudProvider.MIMO, CloudProvider.MINIMAX,
CloudProvider.MODELSCOPE, CloudProvider.OPENAI, CloudProvider.OPENROUTER,
CloudProvider.SCNET, CloudProvider.CUSTOM,
CloudProvider.SCNET, CloudProvider.SILICONFLOW, CloudProvider.SILICONFLOW_CN,
CloudProvider.CUSTOM,
)
assertEquals(expected, CloudProvider.sortedChoices)
CloudProvider.sortedChoices.filterNot { it == CloudProvider.CUSTOM }.zipWithNext().forEach { (a, b) ->
Expand Down Expand Up @@ -189,7 +190,8 @@ class OnboardingCloudProviderTest {
@Test
fun aggregatorSetupSmoke_requiresUserModelThenUsesTheExistingOpenAiPipeline() {
listOf(CloudProvider.MODELSCOPE, CloudProvider.OPENROUTER, CloudProvider.AIHUBMIX,
CloudProvider.AI_302, CloudProvider.SCNET).forEach { provider ->
CloudProvider.AI_302, CloudProvider.SCNET, CloudProvider.SILICONFLOW_CN,
CloudProvider.SILICONFLOW).forEach { provider ->
val draft = OnboardingPolicy.selectCloudProvider(
OnboardingDraft(translationMethod = OnboardingTranslationMethod.CLOUD_LLM), provider,
).copy(cloudApiKey = "test-key")
Expand Down Expand Up @@ -234,6 +236,32 @@ class OnboardingCloudProviderTest {
assertTrue(source.contains("cloudProviderLabel(draft.cloudProvider)"))
}

@Test
fun siliconFlowSites_setupAndReopenPreserveUserChosenModelAndCredentials() {
listOf(CloudProvider.SILICONFLOW_CN, CloudProvider.SILICONFLOW).forEach { provider ->
val draft = OnboardingPolicy.selectCloudProvider(
OnboardingDraft(translationMethod = OnboardingTranslationMethod.CLOUD_LLM), provider,
)
assertEquals("", draft.cloudModel)
assertEquals("", draft.cloudApiKey)
val configured = draft.copy(cloudModel = "org/user-chosen-model", cloudApiKey = "${provider.name}-key")
val saved = OnboardingPolicy.apply(Settings(), configured)
assertEquals(TranslatorEngine.OPENAI, saved.translatorEngine)
val restored = OnboardingPolicy.fromSettings(saved)
assertEquals(provider, restored.cloudProvider)
assertEquals(provider.baseUrl, restored.cloudBaseUrl)
assertEquals(configured.cloudApiKey, restored.cloudApiKey)
assertEquals(configured.cloudModel, restored.cloudModel)
val other = if (provider == CloudProvider.SILICONFLOW_CN) CloudProvider.SILICONFLOW else CloudProvider.SILICONFLOW_CN
val switched = OnboardingPolicy.selectCloudProvider(restored, other)
assertEquals(other.baseUrl, switched.cloudBaseUrl)
assertEquals("", switched.cloudApiKey)
assertEquals("", switched.cloudModel)
assertEquals(configured.cloudApiKey, saved.apiKey)
assertEquals(provider.baseUrl, saved.baseUrl)
}
}

private fun sourceFile(path: String): File =
listOf(File(path), File("app", path)).firstOrNull { it.isFile } ?: error("Missing source: $path")
}
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,16 @@ class OnboardingPolicyTest {
"",
CloudApiProtocol.OPENAI,
),
Case(
CloudProvider.SILICONFLOW_CN,
"https://api.siliconflow.cn/v1/",
CloudApiProtocol.OPENAI,
),
Case(
CloudProvider.SILICONFLOW,
"https://api.siliconflow.com/v1/",
CloudApiProtocol.OPENAI,
),
)

assertEquals(CloudProvider.entries.size, cases.size)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.gameocr.app.translate

import com.gameocr.app.asr.OpenAiAsrProtocol
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.*
import org.junit.Test

class OpenAiModelListPolicyTest {
@Test fun officialSites_onlyRequestChatModels_withoutChangingTheHostOrCredential() = runBlocking {
listOf("api.siliconflow.cn", "api.siliconflow.com").forEach { host ->
var calls = 0
val client = OkHttpClient.Builder().addInterceptor { chain ->
calls++
val request = chain.request()
assertEquals("GET", request.method)
assertEquals(host, request.url.host)
assertEquals("/v1/models", request.url.encodedPath)
assertEquals("sub_type=chat", request.url.encodedQuery)
assertEquals("Bearer $host-key", request.header("Authorization"))
Response.Builder().request(request).protocol(Protocol.HTTP_1_1).code(200)
.message("test").body("""{"data":[{"id":"org/chat-model"},{"id":"org/vision-model"}]}""".toResponseBody()).build()
}.build()
try {
val models = OpenAiModelListClient(client, Json).fetch(
chatModelListUrl("https://$host/v1/models".toHttpUrl()), "$host-key", 30,
)
assertEquals(listOf("org/chat-model", "org/vision-model"), models)
assertEquals(1, calls)
} finally {
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
}
}

@Test fun customEndpointsAndExplicitQueries_arePreservedExactly() {
listOf(
"https://api.openai.com/v1/models",
"https://gateway.example/v1/models",
"https://api.siliconflow.cn.evil.example/v1/models",
"https://api.siliconflow.com/v2/models",
"https://api.siliconflow.cn/proxy/v1/models",
"http://api.siliconflow.cn/v1/models",
"https://api.siliconflow.cn:8443/v1/models",
"https://user@api.siliconflow.cn/v1/models",
"https://api.siliconflow.cn/v1/models?sub_type=speech-to-text",
"https://api.siliconflow.cn/v1/models#custom",
).forEach { raw ->
val url = raw.toHttpUrl()
assertSame(raw, url, chatModelListUrl(url))
}
assertEquals("chat", chatModelListUrl("https://API.SILICONFLOW.COM:443/v1/models".toHttpUrl())
.queryParameter("sub_type"))
}

@Test fun speechModelLists_neverUseTheChatFilter() {
listOf("cn", "com").forEach { site ->
val url = OpenAiAsrProtocol.modelsEndpoint("https://api.siliconflow.$site/v1")!!
assertEquals("https://api.siliconflow.$site/v1/models", url.toString())
assertNull(url.query)
}
}
}
Loading