Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added Perplexity AI support (need to set custom URL) #433

Merged
merged 2 commits into from
Nov 26, 2024
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 @@ -2,6 +2,7 @@ package com.nononsenseapps.feeder.di

import com.nononsenseapps.feeder.archmodel.FeedItemStore
import com.nononsenseapps.feeder.archmodel.FeedStore
import com.nononsenseapps.feeder.archmodel.OpenAISettings
import com.nononsenseapps.feeder.archmodel.Repository
import com.nononsenseapps.feeder.archmodel.SessionStore
import com.nononsenseapps.feeder.archmodel.SettingsStore
Expand All @@ -11,6 +12,8 @@ import com.nononsenseapps.feeder.base.bindWithComposableViewModelScope
import com.nononsenseapps.feeder.model.OPMLParserHandler
import com.nononsenseapps.feeder.model.opml.OPMLImporter
import com.nononsenseapps.feeder.openai.OpenAIApi
import com.nononsenseapps.feeder.openai.OpenAIClient
import com.nononsenseapps.feeder.openai.OpenAIClientDefault
import com.nononsenseapps.feeder.ui.CommonActivityViewModel
import com.nononsenseapps.feeder.ui.MainActivityViewModel
import com.nononsenseapps.feeder.ui.NavigationDeepLinkViewModel
Expand All @@ -23,7 +26,8 @@ import com.nononsenseapps.feeder.ui.compose.searchfeed.SearchFeedViewModel
import com.nononsenseapps.feeder.ui.compose.settings.SettingsViewModel
import org.kodein.di.DI
import org.kodein.di.bind
import org.kodein.di.compose.instance
import org.kodein.di.bindFactory
import org.kodein.di.factory
import org.kodein.di.instance
import org.kodein.di.singleton
import java.util.Locale
Expand All @@ -37,7 +41,8 @@ val archModelModule =
bind<FeedItemStore>() with singleton { FeedItemStore(di) }
bind<SyncRemoteStore>() with singleton { SyncRemoteStore(di) }
bind<OPMLParserHandler>() with singleton { OPMLImporter(di) }
bind<OpenAIApi>() with singleton { OpenAIApi(instance(), appLang = Locale.getDefault().getISO3Language()) }
bindFactory<OpenAISettings, OpenAIClient> { settings -> OpenAIClientDefault(settings) }
anod marked this conversation as resolved.
Show resolved Hide resolved
bind<OpenAIApi>() with singleton { OpenAIApi(instance(), appLang = Locale.getDefault().getISO3Language(), factory()) }

bindWithActivityViewModelScope<MainActivityViewModel>()
bindWithActivityViewModelScope<OpenLinkInDefaultActivityViewModel>()
Expand Down
75 changes: 29 additions & 46 deletions app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,54 +5,19 @@ import com.aallam.openai.api.chat.ChatMessage
import com.aallam.openai.api.chat.ChatResponseFormat
import com.aallam.openai.api.chat.ChatRole
import com.aallam.openai.api.chat.TextContent
import com.aallam.openai.api.logging.LogLevel
import com.aallam.openai.api.model.ModelId
import com.aallam.openai.client.LoggingConfig
import com.aallam.openai.client.OpenAI
import com.aallam.openai.client.OpenAIConfig
import com.aallam.openai.client.OpenAIHost
import com.nononsenseapps.feeder.BuildConfig
import com.nononsenseapps.feeder.archmodel.OpenAISettings
import com.nononsenseapps.feeder.archmodel.Repository
import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.plugin
import io.ktor.client.request.url
import io.ktor.http.URLBuilder
import io.ktor.http.appendPathSegments
import io.ktor.http.takeFrom
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

private fun OpenAISettings.toOpenAIConfig(): OpenAIConfig =
OpenAIConfig(
token = key,
logging = LoggingConfig(logLevel = LogLevel.Headers, sanitize = !BuildConfig.DEBUG),
host = toOpenAIHost(withAzureDeploymentId = false),
httpClientConfig = {
if (isAzure) {
install(HttpSend)
install("azure-interceptor") {
plugin(HttpSend).intercept { request ->
request.headers.remove("Authorization")
request.headers.append("api-key", key)
// models path doesn't include azureDeploymentId
val path = request.url.pathSegments.takeLastWhile { it != "openai" || it.isEmpty() }
val url =
toOpenAIHost(withAzureDeploymentId = path.last() != "models")
.toUrl()
.appendPathSegments(path)
.build()
request.url(url)
execute(request)
}
}
}
},
)

class OpenAIApi(
private val repository: Repository,
private val appLang: String,
private val openAIClientFactory: (OpenAISettings) -> OpenAIClient,
) {
@Serializable
data class SummaryResponse(val lang: String, val content: String)
Expand Down Expand Up @@ -86,16 +51,23 @@ class OpenAIApi(
data class Error(val message: String?) : ModelsResult
}

companion object {
private val LANG_REGEX = Regex("^Lang: \"?([a-zA-Z]+)\"?$")
}

private val openAISettings: OpenAISettings
get() = repository.openAISettings.value

private val openAI: OpenAI
get() = OpenAI(config = openAISettings.toOpenAIConfig())
private val openAI: OpenAIClient
get() = openAIClientFactory(openAISettings)

suspend fun listModelIds(settings: OpenAISettings): ModelsResult {
if (settings.key.isEmpty()) {
return ModelsResult.MissingToken
}
if (settings.isPerplexity) {
return ModelsResult.Success(ids = emptyList())
}
if (settings.isAzure) {
if (settings.azureApiVersion.isBlank()) {
return ModelsResult.AzureApiVersionRequired
Expand All @@ -105,9 +77,9 @@ class OpenAIApi(
}
}
return try {
OpenAI(config = settings.toOpenAIConfig()).models()
openAIClientFactory(settings).models()
.sortedByDescending { it.created }
.map { it.id.id }.let { ModelsResult.Success(it) }
.map { it.id.id }.let { ModelsResult.Success(ids = it) }
} catch (e: Exception) {
ModelsResult.Error(message = e.message ?: e.cause?.message)
}
Expand All @@ -121,10 +93,9 @@ class OpenAIApi(
requestOptions = null,
)
val summaryResponse: SummaryResponse =
response.choices.firstOrNull()?.message?.content?.let { text ->
Json.decodeFromString(text)
} ?: throw IllegalStateException("Response content is null")

parseSummaryResponse(
response.choices.firstOrNull()?.message?.content ?: throw IllegalStateException("Response content is null"),
)
return SummaryResult.Success(
id = response.id,
model = response.model.id,
Expand All @@ -140,6 +111,15 @@ class OpenAIApi(
}
}

private fun parseSummaryResponse(content: String): SummaryResponse {
val firstLine = content.lineSequence().firstOrNull() ?: ""
val result = LANG_REGEX.find(firstLine)
return SummaryResponse(
lang = result?.groupValues?.getOrNull(1) ?: "",
content = content.replaceFirst(firstLine, "").trim(),
)
}

private fun summaryRequest(content: String): ChatCompletionRequest {
return ChatCompletionRequest(
model = ModelId(id = openAISettings.modelId),
Expand All @@ -153,7 +133,7 @@ class OpenAIApi(
"You are an assistant in an RSS reader app, summarizing article content.",
"The app language is '$appLang'.",
"Provide summaries in the article's language if 99% recognizable; otherwise, use the app language.",
"Format response as JSON: { \"lang\": \"ISO code\", \"content\": \"summary\" }.",
"First line must be: 'Lang: \"ISO code\"'",
"Keep summaries up to 100 words, 3 paragraphs, with up to 3 bullet points per paragraph.",
"For readability use bullet points, titles, quotes and new lines using plain text only.",
"Use only single language.",
Expand All @@ -166,14 +146,17 @@ class OpenAIApi(
messageContent = TextContent("Summarize:\n\n$content"),
),
),
responseFormat = ChatResponseFormat.JsonObject,
responseFormat = ChatResponseFormat.Text,
)
}
}

val OpenAISettings.isAzure: Boolean
get() = baseUrl.contains("openai.azure.com", ignoreCase = true)

val OpenAISettings.isPerplexity: Boolean
get() = baseUrl.contains("api.perplexity.ai", ignoreCase = true)

val OpenAISettings.isValid: Boolean
get() =
modelId.isNotEmpty() &&
Expand Down
63 changes: 63 additions & 0 deletions app/src/main/java/com/nononsenseapps/feeder/openai/OpenAIClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.nononsenseapps.feeder.openai

import com.aallam.openai.api.chat.ChatCompletion
import com.aallam.openai.api.chat.ChatCompletionRequest
import com.aallam.openai.api.core.RequestOptions
import com.aallam.openai.api.logging.LogLevel
import com.aallam.openai.api.model.Model
import com.aallam.openai.client.LoggingConfig
import com.aallam.openai.client.OpenAI
import com.aallam.openai.client.OpenAIConfig
import com.nononsenseapps.feeder.BuildConfig
import com.nononsenseapps.feeder.archmodel.OpenAISettings
import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.plugin
import io.ktor.client.request.url
import io.ktor.http.appendPathSegments

interface OpenAIClient {
suspend fun models(requestOptions: RequestOptions? = null): List<Model>

suspend fun chatCompletion(
request: ChatCompletionRequest,
requestOptions: RequestOptions?,
): ChatCompletion
}

class OpenAIClientDefault(settings: OpenAISettings) : OpenAIClient {
private val client = OpenAI(config = settings.toOpenAIConfig())

override suspend fun models(requestOptions: RequestOptions?): List<Model> = client.models(requestOptions)

override suspend fun chatCompletion(
request: ChatCompletionRequest,
requestOptions: RequestOptions?,
): ChatCompletion = client.chatCompletion(request, requestOptions)
}

private fun OpenAISettings.toOpenAIConfig(): OpenAIConfig =
OpenAIConfig(
token = key,
logging = LoggingConfig(logLevel = LogLevel.Headers, sanitize = !BuildConfig.DEBUG),
host = toOpenAIHost(withAzureDeploymentId = false),
httpClientConfig = {
if (isAzure) {
install(HttpSend)
install("azure-interceptor") {
plugin(HttpSend).intercept { request ->
request.headers.remove("Authorization")
request.headers.append("api-key", key)
// models path doesn't include azureDeploymentId
val path = request.url.pathSegments.takeLastWhile { it != "openai" || it.isEmpty() }
val url =
toOpenAIHost(withAzureDeploymentId = path.last() != "models")
.toUrl()
.appendPathSegments(path)
.build()
request.url(url)
execute(request)
}
}
}
},
)
Loading
Loading