diff --git a/app/src/main/java/com/zhousl/aether/data/ModelCapabilities.kt b/app/src/main/java/com/zhousl/aether/data/ModelCapabilities.kt index 314c6fde..2f823337 100644 --- a/app/src/main/java/com/zhousl/aether/data/ModelCapabilities.kt +++ b/app/src/main/java/com/zhousl/aether/data/ModelCapabilities.kt @@ -10,6 +10,7 @@ internal enum class LlmCompatibilityFamily { MiMo, Moonshot, OpenRouter, + Requesty, } internal enum class ReasoningDisableStyle { @@ -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, @@ -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 diff --git a/app/src/main/java/com/zhousl/aether/data/ProviderModelCatalogClient.kt b/app/src/main/java/com/zhousl/aether/data/ProviderModelCatalogClient.kt index ea50150f..f0d25865 100644 --- a/app/src/main/java/com/zhousl/aether/data/ProviderModelCatalogClient.kt +++ b/app/src/main/java/com/zhousl/aether/data/ProviderModelCatalogClient.kt @@ -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 @@ -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" diff --git a/app/src/main/java/com/zhousl/aether/data/SessionExecutionManager.kt b/app/src/main/java/com/zhousl/aether/data/SessionExecutionManager.kt index 12776fda..6fca44cb 100644 --- a/app/src/main/java/com/zhousl/aether/data/SessionExecutionManager.kt +++ b/app/src/main/java/com/zhousl/aether/data/SessionExecutionManager.kt @@ -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) } diff --git a/app/src/test/java/com/zhousl/aether/data/ProviderModelCatalogClientTest.kt b/app/src/test/java/com/zhousl/aether/data/ProviderModelCatalogClientTest.kt index 441ddef7..1f4c48b0 100644 --- a/app/src/test/java/com/zhousl/aether/data/ProviderModelCatalogClientTest.kt +++ b/app/src/test/java/com/zhousl/aether/data/ProviderModelCatalogClientTest.kt @@ -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() diff --git a/app/src/test/java/com/zhousl/aether/data/pi/PiProviderMapperTest.kt b/app/src/test/java/com/zhousl/aether/data/pi/PiProviderMapperTest.kt index 270a6431..8bd3cd25 100644 --- a/app/src/test/java/com/zhousl/aether/data/pi/PiProviderMapperTest.kt +++ b/app/src/test/java/com/zhousl/aether/data/pi/PiProviderMapperTest.kt @@ -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( diff --git a/app/src/test/java/com/zhousl/aether/ui/ProviderConfigFormTest.kt b/app/src/test/java/com/zhousl/aether/ui/ProviderConfigFormTest.kt index 1030ac7b..5ef368f4 100644 --- a/app/src/test/java/com/zhousl/aether/ui/ProviderConfigFormTest.kt +++ b/app/src/test/java/com/zhousl/aether/ui/ProviderConfigFormTest.kt @@ -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) diff --git a/shared/src/commonMain/composeResources/drawable/provider_requesty.xml b/shared/src/commonMain/composeResources/drawable/provider_requesty.xml new file mode 100644 index 00000000..6e81ccf3 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/provider_requesty.xml @@ -0,0 +1,11 @@ + + + + diff --git a/shared/src/commonMain/kotlin/com/zhousl/aether/data/PiProviderCatalog.kt b/shared/src/commonMain/kotlin/com/zhousl/aether/data/PiProviderCatalog.kt index 7427eda0..2659d1cf 100644 --- a/shared/src/commonMain/kotlin/com/zhousl/aether/data/PiProviderCatalog.kt +++ b/shared/src/commonMain/kotlin/com/zhousl/aether/data/PiProviderCatalog.kt @@ -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"), @@ -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 { diff --git a/shared/src/commonMain/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClient.kt b/shared/src/commonMain/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClient.kt index 2076e2db..5c651fc9 100644 --- a/shared/src/commonMain/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClient.kt +++ b/shared/src/commonMain/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClient.kt @@ -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) @@ -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()}") diff --git a/shared/src/commonMain/kotlin/com/zhousl/aether/ui/ProviderBrandIcon.kt b/shared/src/commonMain/kotlin/com/zhousl/aether/ui/ProviderBrandIcon.kt index e16cb245..46a6a71a 100644 --- a/shared/src/commonMain/kotlin/com/zhousl/aether/ui/ProviderBrandIcon.kt +++ b/shared/src/commonMain/kotlin/com/zhousl/aether/ui/ProviderBrandIcon.kt @@ -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 diff --git a/shared/src/commonTest/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClientTest.kt b/shared/src/commonTest/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClientTest.kt index 1c6c2f45..5dcc5257 100644 --- a/shared/src/commonTest/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClientTest.kt +++ b/shared/src/commonTest/kotlin/com/zhousl/aether/data/SharedProviderModelCatalogClientTest.kt @@ -55,6 +55,33 @@ class SharedProviderModelCatalogClientTest { assertNull(result.error) } + @Test + fun requestyListsManagedModelsBeforeFullCatalog() = runTest { + val requestedUrls = mutableListOf() + 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( diff --git a/shared/src/iosMain/kotlin/com/zhousl/aether/ui/IosComposeApp.kt b/shared/src/iosMain/kotlin/com/zhousl/aether/ui/IosComposeApp.kt index 3f9cd0c3..b093d4fd 100644 --- a/shared/src/iosMain/kotlin/com/zhousl/aether/ui/IosComposeApp.kt +++ b/shared/src/iosMain/kotlin/com/zhousl/aether/ui/IosComposeApp.kt @@ -7954,6 +7954,7 @@ private fun List.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)