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 @@ -297,4 +297,13 @@ interface DuckChatFeature {
*/
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
fun sendDuckAiSessionWideEvent(): Toggle

/**
* @return `true` when the updated Duck.ai model and reasoning pickers are enabled: models grouped
* by availability, backend-driven ordering and sublines, and a gated section header that follows the
* user's tier.
* If the remote feature is not present defaults to `false`.
*/
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
fun updatedPickers(): Toggle
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,19 @@ import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.duckchat.api.DuckAiHostProvider
import com.duckduckgo.duckchat.impl.feature.DuckChatFeature
import com.duckduckgo.duckchat.impl.store.DuckChatDataStore
import com.duckduckgo.duckchat.impl.store.SelectedModel
import com.duckduckgo.subscriptions.api.Product
import com.duckduckgo.subscriptions.api.Subscriptions
import com.squareup.anvil.annotations.ContributesBinding
import dagger.Lazy
import dagger.SingleInstanceIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -86,6 +89,7 @@ class RealDuckAiModelManager @Inject constructor(
private val dataStore: DuckChatDataStore,
private val subscriptions: Subscriptions,
private val duckAiHostProvider: DuckAiHostProvider,
private val duckChatFeature: Lazy<DuckChatFeature>,
private val dispatcherProvider: DispatcherProvider,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : DuckAiModelManager {
Expand Down Expand Up @@ -113,10 +117,15 @@ class RealDuckAiModelManager @Inject constructor(
} catch (e: Exception) {
logcat { "Duck.ai Model Manager: failed to restore cached selection: ${e.message}" }
}
subscriptions.getEntitlements()
// Status as well as entitlements: signing in or out changes whether the response carries
// model labels, and entitlements stay empty either way when there is no subscription.
combine(
subscriptions.getEntitlements(),
subscriptions.getSubscriptionStatusFlow(),
) { entitlements, status -> entitlements to status }
.distinctUntilChanged()
.collect {
logcat { "Duck.ai Model Manager: entitlements changed, re-fetching models" }
logcat { "Duck.ai Model Manager: subscription state changed, re-fetching models" }
fetchModels()
}
}
Expand Down Expand Up @@ -205,7 +214,19 @@ class RealDuckAiModelManager @Inject constructor(

private suspend fun fetchModelsResponse(): AIChatModelsResponse {
val url = DuckAiModelsService.modelsUrl(duckAiHostProvider.getHost())
return modelsService.getModels(url)
return modelsService.getModels(url, authorizationHeader())
}

// The picker sublines (model `label`) are only returned on an authenticated request, so the
// token rides along only while the updated pickers are the ones consuming it.
private suspend fun authorizationHeader(): String? {
if (!duckChatFeature.get().updatedPickers().isEnabled()) return null
return runCatching {
subscriptions.getAccessToken()?.takeUnless { it.isBlank() }?.let { "Bearer $it" }
}.getOrElse {
logcat { "Duck.ai Model Manager: failed to resolve access token, fetching models unauthenticated: ${it.message}" }
null
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@ package com.duckduckgo.duckchat.impl.models
import com.duckduckgo.anvil.annotations.ContributesServiceApi
import com.duckduckgo.di.scopes.AppScope
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Url

@ContributesServiceApi(AppScope::class)
interface DuckAiModelsService {
@GET
suspend fun getModels(@Url url: String): AIChatModelsResponse
suspend fun getModels(
@Url url: String,
@Header("Authorization") authorization: String?,
): AIChatModelsResponse

companion object {
private const val MODELS_PATH = "/duckchat/v1/models"
Expand Down
Loading
Loading