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
9 changes: 9 additions & 0 deletions app/src/main/java/com/zhousl/aether/data/ModelCapabilities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ internal enum class LlmCompatibilityFamily {
MiMo,
Moonshot,
OpenRouter,
Requesty,
}

internal enum class ReasoningDisableStyle {
Expand Down Expand Up @@ -40,6 +41,11 @@ internal object ModelCapabilitiesResolver {
reasoningDisableStyle = ReasoningDisableStyle.OpenRouterReasoningEffortNone,
)

isRequesty(host) -> ModelCapabilities(
family = LlmCompatibilityFamily.Requesty,
reasoningDisableStyle = ReasoningDisableStyle.OpenRouterReasoningEffortNone,
)

isDeepSeek(host, model) -> ModelCapabilities(
family = LlmCompatibilityFamily.DeepSeek,
reasoningDisableStyle = ReasoningDisableStyle.DeepSeekThinkingDisabled,
Expand Down Expand Up @@ -71,6 +77,9 @@ internal object ModelCapabilitiesResolver {
private fun isOpenRouter(host: String, model: String): Boolean =
"openrouter" in host || model.startsWith("openrouter/") || model.startsWith("openrouter:")

private fun isRequesty(host: String): Boolean =
"requesty" in host

private fun isDeepSeek(host: String, model: String): Boolean =
"deepseek" in host || "deepseek" in model

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,12 @@ object ProviderModelCatalogClient {
): FetchModelsResult = withContext(Dispatchers.IO) {
try {
val definition = PiProviderCatalog.resolve(config.piProviderId)
val providerModels = runCatching { fetchProviderModels(config) }.getOrElse { error ->
FetchModelsResult(emptyList(), error.message ?: "Unable to fetch models.")
val providerModels = if (definition.id == "requesty") {
fetchRequestyModels(config)
} else {
runCatching { fetchProviderModels(config) }.getOrElse { error ->
FetchModelsResult(emptyList(), error.message ?: "Unable to fetch models.")
}
}
if (providerModels.models.isNotEmpty()) return@withContext providerModels

Expand Down Expand Up @@ -189,14 +193,30 @@ object ProviderModelCatalogClient {
}
}

private fun fetchProviderModels(config: LlmProviderConfig): FetchModelsResult {
// Requesty lists its curated managed models first, followed by the full catalog.
private fun fetchRequestyModels(config: LlmProviderConfig): FetchModelsResult {
val managedModels = runCatching { fetchProviderModels(config, endpointSuffix = "/managed") }
.getOrElse { error -> FetchModelsResult(emptyList(), error.message ?: "Unable to fetch models.") }
val catalogModels = runCatching { fetchProviderModels(config) }
.getOrElse { error -> FetchModelsResult(emptyList(), error.message ?: "Unable to fetch models.") }
val models = (managedModels.models + catalogModels.models).distinctBy { it.lowercase() }
return FetchModelsResult(
models,
if (models.isEmpty()) catalogModels.error ?: managedModels.error else null,
)
}

private fun fetchProviderModels(
config: LlmProviderConfig,
endpointSuffix: String = "",
): FetchModelsResult {
val baseUrl = config.baseUrl.trim().trimEnd('/')
val modelsUrl = when {
baseUrl.endsWith("/responses") -> baseUrl.replace("/responses", "/models")
baseUrl.endsWith("/chat/completions") -> baseUrl.replace("/chat/completions", "/models")
baseUrl.endsWith("/v1") -> "$baseUrl/models"
else -> "$baseUrl/models"
}
} + endpointSuffix

val connection = URL(modelsUrl).openConnection() as HttpURLConnection
connection.requestMethod = "GET"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1968,6 +1968,7 @@ class SessionExecutionManager(
private fun AppSettings.supportsVisibleReasoningTrace(): Boolean {
return baseUrl.contains("deepseek", ignoreCase = true) ||
baseUrl.contains("openrouter", ignoreCase = true) ||
baseUrl.contains("requesty", ignoreCase = true) ||
modelId.contains("deepseek", ignoreCase = true) ||
modelId.contains("openrouter", ignoreCase = true)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,42 @@ class ProviderModelCatalogClientTest {
}
}

@Test
fun requestyListsManagedModelsBeforeFullCatalog() = runBlocking {
val server = MockWebServer()
server.enqueue(
MockResponse()
.addHeader("Content-Type", "application/json")
.setBody("""{"data":[{"id":"claude-sonnet-4-5"}]}""")
)
server.enqueue(
MockResponse()
.addHeader("Content-Type", "application/json")
.setBody("""{"data":[{"id":"openai/gpt-4o-mini"},{"id":"claude-sonnet-4-5"}]}""")
)
server.start()

try {
val result = ProviderModelCatalogClient.fetchModels(
LlmProviderConfig(
providerId = "requesty",
name = "Requesty",
piProviderId = "requesty",
apiKey = "test-key",
baseUrl = server.url("/v1").toString(),
modelId = "",
)
)

assertEquals(null, result.error)
assertEquals(listOf("claude-sonnet-4-5", "openai/gpt-4o-mini"), result.models)
assertEquals("/v1/models/managed", server.takeRequest().path)
assertEquals("/v1/models", server.takeRequest().path)
} finally {
server.shutdown()
}
}

@Test
fun failedProviderRequestFallsBackToModelsDev() = runBlocking {
val server = MockWebServer()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,24 @@ class PiProviderMapperTest {
assertFalse(builtIn.has("compatibility_mode"))
}

@Test
fun requestyMapsToCustomOpenAiCompletionsProvider() {
val config = AppSettings(
piProviderId = "requesty",
providerConfigId = "requesty",
apiKey = "requesty-key",
baseUrl = "https://router.requesty.ai/v1",
modelId = "openai/gpt-4o-mini",
).toPiModelConfig()

assertEquals("custom", config.providerType)
assertEquals("openai-completions", config.piApi)
assertTrue(config.piProviderId.startsWith("aether-"))
assertEquals("https://router.requesty.ai/v1", config.baseUrl)
assertEquals("openai/gpt-4o-mini", config.modelId)
assertEquals("requesty-key", config.apiKey)
}

@Test
fun legacyNoneReasoningEffortMigratesToPiOff() {
assertEquals("off", AppSettings(
Expand Down
16 changes: 13 additions & 3 deletions app/src/test/java/com/zhousl/aether/ui/ProviderConfigFormTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,27 @@ class ProviderConfigFormTest {
@Test
fun catalogIncludesEveryPiBuiltInProviderAndCustomEndpoint() {
assertEquals(35, PiProviderCatalog.builtInProviders.size)
assertEquals(36, PiProviderCatalog.providers.size)
assertEquals(37, PiProviderCatalog.providers.size)

val customProviders = PiProviderCatalog.providers.filterNot { it.isBuiltIn }
assertEquals(1, customProviders.size)
assertEquals("openai-compatible", customProviders.single().id)
assertEquals(listOf("openai-compatible", "requesty"), customProviders.map { it.id })
assertEquals(
listOf("openai", "openai-compatible"),
PiProviderCatalog.providers.take(2).map { it.id },
)
}

@Test
fun requestyPrefillsItsOpenAiCompatibleEndpoint() {
val state = ProviderFormState.fromConfig(null)

state.applyProviderDefaults(PiProviderCatalog.resolve("requesty"))

assertEquals("https://router.requesty.ai/v1", state.baseUrl)
assertTrue(state.selectedDefinition.requiresBaseUrl)
assertFalse(state.buildConfig().compatibilityMode)
}

@Test
fun compatibilityModeIsScopedToOpenAiCompatibleProvider() {
val state = ProviderFormState.fromConfig(null)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:fillColor="#FF000000"
android:fillType="evenOdd"
android:pathData="M3.5,1.5H8.75C11.0972,1.5 13,3.40279 13,5.75C13,7.4463 12.0058,8.91084 10.5686,9.59042L13.4,14.5H10.8L8.24,10H6V14.5H3.5V1.5ZM6,4V7.5H8.75C9.7165,7.5 10.5,6.7165 10.5,5.75C10.5,4.7835 9.7165,4 8.75,4H6Z" />
</vector>
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ object PiProviderCatalog {
builtin("huggingface", "Hugging Face", "https://router.huggingface.co/v1", "MiniMaxAI/MiniMax-M2.7", category = "Aggregators"),
builtin("together", "Together", "https://api.together.ai/v1", "Qwen/Qwen3.5-397B-A17B", category = "Aggregators"),
builtin("fireworks", "Fireworks", "https://api.fireworks.ai/inference", "accounts/fireworks/models/deepseek-v4-flash", category = "Aggregators"),
custom("requesty", "Requesty", "https://router.requesty.ai/v1", "openai/gpt-5.4", category = "Aggregators"),
builtin("opencode", "OpenCode Zen", "", "big-pickle", category = "Coding"),
builtin("opencode-go", "OpenCode Zen Go", "", "deepseek-v4-flash", category = "Coding"),
builtin("kimi-coding", "Kimi For Coding", "https://api.kimi.com/coding", "k2p7", category = "Coding"),
Expand Down Expand Up @@ -169,15 +170,18 @@ private fun builtin(
private fun custom(
id: String,
displayName: String,
defaultBaseUrl: String = DefaultCustomProviderBaseUrl,
defaultModelId: String = DefaultCustomModelId,
category: String = "Custom",
): PiProviderDefinition = PiProviderDefinition(
id = id,
displayName = displayName,
defaultBaseUrl = DefaultCustomProviderBaseUrl,
defaultModelId = DefaultCustomModelId,
defaultBaseUrl = defaultBaseUrl,
defaultModelId = defaultModelId,
supportsApiKey = true,
requiresBaseUrl = true,
isBuiltIn = false,
category = "Custom",
category = category,
)

private fun hostOf(baseUrl: String): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,11 @@ class SharedProviderModelCatalogClient(engine: HttpClientEngine? = null) {
config: LlmProviderConfig,
): SharedProviderModelsResult {
val definition = PiProviderCatalog.resolve(config.piProviderId)
val providerModels = fetchProviderModels(config)
val providerModels = if (definition.id == "requesty") {
fetchRequestyModels(config)
} else {
fetchProviderModels(config)
}
if (providerModels.models.isNotEmpty()) return providerModels

val publicModels = fetchPublicProviderModels(definition)
Expand Down Expand Up @@ -223,9 +227,23 @@ class SharedProviderModelCatalogClient(engine: HttpClientEngine? = null) {
}
}

private suspend fun fetchProviderModels(config: LlmProviderConfig): SharedProviderModelsResult {
// Requesty lists its curated managed models first, followed by the full catalog.
private suspend fun fetchRequestyModels(config: LlmProviderConfig): SharedProviderModelsResult {
val managedModels = fetchProviderModels(config, endpointSuffix = "/managed")
val catalogModels = fetchProviderModels(config)
val models = (managedModels.models + catalogModels.models).distinctBy(String::lowercase)
return SharedProviderModelsResult(
models,
if (models.isEmpty()) catalogModels.error ?: managedModels.error else null,
)
}

private suspend fun fetchProviderModels(
config: LlmProviderConfig,
endpointSuffix: String = "",
): SharedProviderModelsResult {
return runCatching {
val modelsUrl = modelsEndpoint(config.baseUrl)
val modelsUrl = modelsEndpoint(config.baseUrl) + endpointSuffix
val response = client.get(modelsUrl) {
headers {
append(HttpHeaders.Authorization, "Bearer ${config.apiKey.trim()}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ internal fun providerBrandIconRes(providerId: String): DrawableResource = when (
"opencode" -> Res.drawable.provider_opencode
"opencode-go" -> Res.drawable.provider_opencode_go
"openrouter" -> Res.drawable.provider_openrouter
"requesty" -> Res.drawable.provider_requesty
"together" -> Res.drawable.provider_together
"vercel-ai-gateway" -> Res.drawable.provider_vercel_ai_gateway
"xai" -> Res.drawable.provider_xai
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,33 @@ class SharedProviderModelCatalogClientTest {
assertNull(result.error)
}

@Test
fun requestyListsManagedModelsBeforeFullCatalog() = runTest {
val requestedUrls = mutableListOf<String>()
val engine = MockEngine { request ->
requestedUrls += request.url.toString()
assertEquals("Bearer secret", request.headers[HttpHeaders.Authorization])
val body = when (request.url.encodedPath) {
"/v1/models/managed" -> """{"data":[{"id":"claude-sonnet-4-5"}]}"""
else -> """{"data":[{"id":"openai/gpt-4o-mini"},{"id":"claude-sonnet-4-5"}]}"""
}
respond(body, headers = headersOf(HttpHeaders.ContentType, "application/json"))
}
val result = SharedProviderModelCatalogClient(engine).fetchModels(
customConfig(
piProviderId = "requesty",
baseUrl = "https://router.requesty.ai/v1",
),
)

assertEquals(
listOf("https://router.requesty.ai/v1/models/managed", "https://router.requesty.ai/v1/models"),
requestedUrls,
)
assertEquals(listOf("claude-sonnet-4-5", "openai/gpt-4o-mini"), result.models)
assertNull(result.error)
}

@Test
fun modelsDevProviderAliasesMatchAetherBuiltIns() {
assertEquals(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7954,6 +7954,7 @@ private fun List<SharedChatToolInvocation>.upsertSharedTool(
internal fun LlmProviderConfig.supportsSharedVisibleReasoningTrace(): Boolean =
baseUrl.contains("deepseek", ignoreCase = true) ||
baseUrl.contains("openrouter", ignoreCase = true) ||
baseUrl.contains("requesty", ignoreCase = true) ||
modelId.contains("deepseek", ignoreCase = true) ||
modelId.contains("openrouter", ignoreCase = true)

Expand Down