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
2 changes: 2 additions & 0 deletions .github/workflows/pr-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ jobs:

- name: Set up Android SDK
uses: android-actions/setup-android@v4
with:
packages: platform-tools

- name: Validate
run: ./gradlew :app:testDebugUnitTest :app:lintDebug
2 changes: 2 additions & 0 deletions .github/workflows/release-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jobs:

- name: Setup Android SDK
uses: android-actions/setup-android@v4
with:
packages: platform-tools

- name: Install Latest Build Tools
run: |
Expand Down
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ A user-defined provider profile (name, credentials, model, sampling config) that
_Avoid_: Provider, service, API (when referring to the profile)

**ClientType**:
The protocol family a Platform speaks (OpenAI, Anthropic, Google, Groq, OpenRouter, Ollama, Custom, and on-device LiteRT-LM). Determines which adapter handles the chat.
The named API compatibility contract a Platform uses, including vendor-specific defaults and wire behavior (OpenAI, Mistral, Anthropic, Google, Groq, OpenRouter, Ollama, Custom, and on-device LiteRT-LM). Different ClientTypes can share the same protocol.

**Local Platform**:
A Platform whose ClientType is LiteRT-LM: it answers chats by running a Local Model on-device instead of calling a remote API.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ object ModelConstants {
// LinkedHashSet should be used to guarantee item order
const val OPENAI_DEFAULT_MODEL = "gpt-5.6"
const val ANTHROPIC_DEFAULT_MODEL = "claude-sonnet-5"
const val MISTRAL_DEFAULT_MODEL = "mistral-large-latest"
const val GOOGLE_DEFAULT_MODEL = "gemini-3.7-flash"
const val GROQ_DEFAULT_MODEL = "openai/gpt-oss-120b"
const val OLLAMA_DEFAULT_MODEL = "gpt-oss"
Expand All @@ -22,6 +23,7 @@ object ModelConstants {

const val OPENAI_API_URL = "https://api.openai.com/v1/"
const val ANTHROPIC_API_URL = "https://api.anthropic.com/v1/"
const val MISTRAL_API_URL = "https://api.mistral.ai/v1/"
const val GOOGLE_API_URL = "https://generativelanguage.googleapis.com/"
const val GROQ_API_URL = "https://api.groq.com/openai/v1/"
const val OPENROUTER_API_URL = "https://openrouter.ai/api/v1/"
Expand All @@ -48,6 +50,7 @@ object ModelConstants {
fun defaultPlatformName(clientType: ClientType): String = when (clientType) {
ClientType.OPENAI -> "OpenAI"
ClientType.ANTHROPIC -> "Anthropic"
ClientType.MISTRAL -> "Mistral"
ClientType.GOOGLE -> "Google"
ClientType.GROQ -> "Groq"
ClientType.OLLAMA -> "Ollama"
Expand All @@ -59,6 +62,7 @@ object ModelConstants {
fun defaultApiUrl(clientType: ClientType): String = when (clientType) {
ClientType.OPENAI -> OPENAI_API_URL
ClientType.ANTHROPIC -> ANTHROPIC_API_URL
ClientType.MISTRAL -> MISTRAL_API_URL
ClientType.GOOGLE -> GOOGLE_API_URL
ClientType.GROQ -> GROQ_API_URL
ClientType.OLLAMA -> OLLAMA_API_URL
Expand All @@ -70,6 +74,7 @@ object ModelConstants {
fun defaultModel(clientType: ClientType): String = when (clientType) {
ClientType.OPENAI -> OPENAI_DEFAULT_MODEL
ClientType.ANTHROPIC -> ANTHROPIC_DEFAULT_MODEL
ClientType.MISTRAL -> MISTRAL_DEFAULT_MODEL
ClientType.GOOGLE -> GOOGLE_DEFAULT_MODEL
ClientType.GROQ -> GROQ_DEFAULT_MODEL
ClientType.OLLAMA -> OLLAMA_DEFAULT_MODEL
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package dev.chungjungsoo.gptmobile.data.agent.provider

import dev.chungjungsoo.gptmobile.data.agent.AgentProviderSession
import dev.chungjungsoo.gptmobile.data.context.ConversationTurn
import dev.chungjungsoo.gptmobile.data.database.entity.PlatformV2
import dev.chungjungsoo.gptmobile.data.network.OpenAIAPI
import javax.inject.Inject

class MistralAdapter @Inject constructor(
private val openAIAPI: OpenAIAPI,
private val attachmentEncoder: ProviderAttachmentEncoder
) {
suspend fun openSession(
turns: List<ConversationTurn>,
platform: PlatformV2
): AgentProviderSession = openChatCompletionsSession(
openAIAPI,
attachmentEncoder.openAIChatMessages(turns, platform.systemPrompt),
platform
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -154,17 +154,17 @@ class OpenAICompatibleAdapter @Inject constructor(
) {
suspend fun openSession(turns: List<ConversationTurn>, platform: PlatformV2): AgentProviderSession {
val initialMessages = attachmentEncoder.openAIChatMessages(turns, platform.systemPrompt)
val config = ProviderRequestConfig(platform.apiUrl, platform.token)
return object : AgentProviderSession {
override fun streamRound(
tools: List<AgentToolDefinition>,
exchanges: List<AgentToolExchange>
): Flow<ProviderEvent> = flow {
val messages = initialMessages + exchanges.flatMap { it.toChatMessages() }
val requestTools = tools.takeIf { it.isNotEmpty() }?.map { definition ->
ChatFunctionTool(definition.name, definition.description, definition.inputSchema)
}
if (platform.compatibleType == ClientType.GROQ) {
if (platform.compatibleType == ClientType.GROQ) {
val config = ProviderRequestConfig(platform.apiUrl, platform.token)
return object : AgentProviderSession {
override fun streamRound(
tools: List<AgentToolDefinition>,
exchanges: List<AgentToolExchange>
): Flow<ProviderEvent> = flow {
val messages = initialMessages + exchanges.flatMap { it.toChatMessages() }
val requestTools = tools.takeIf { it.isNotEmpty() }?.map { definition ->
ChatFunctionTool(definition.name, definition.description, definition.inputSchema)
}
val request = createGroqChatCompletionRequest(messages, platform).copy(tools = requestTools)
val assembler = ChatCompletionsEventAssembler()
val reasoningParser = GroqReasoningParser()
Expand Down Expand Up @@ -204,42 +204,59 @@ class OpenAICompatibleAdapter @Inject constructor(
usage?.let { emit(it) }
emit(ProviderEvent.Completed)
}
return@flow
}
}
}
return openChatCompletionsSession(openAIAPI, initialMessages, platform)
}
}

val request = ChatCompletionRequest(
model = platform.model,
messages = messages,
stream = platform.stream,
temperature = platform.temperature,
topP = platform.topP,
maxTokens = resolvedOutputTokenCap(platform, tools.isNotEmpty()),
tools = requestTools
)
val assembler = ChatCompletionsEventAssembler()
var failed = false
var usage: ProviderEvent.Usage? = null
openAIAPI.streamChatCompletion(request, platform.timeout, config).collect { chunk ->
chunk.error?.let { error ->
failed = true
emit(ProviderEvent.Failed(error.message))
} ?: run {
chunk.usage.toProviderUsage()?.let { usage = it }
chunk.choices.orEmpty().forEach { choice ->
assembler.accept(
content = choice.delta.content,
reasoning = choice.delta.reasoning,
toolCalls = choice.delta.toolCalls,
finishReason = choice.finishReason
).forEach { emit(it) }
}
}
internal fun openChatCompletionsSession(
api: OpenAIAPI,
initialMessages: List<ChatMessage>,
platform: PlatformV2
): AgentProviderSession {
val config = ProviderRequestConfig(platform.apiUrl, platform.token)
return object : AgentProviderSession {
override fun streamRound(
tools: List<AgentToolDefinition>,
exchanges: List<AgentToolExchange>
): Flow<ProviderEvent> = flow {
val messages = initialMessages + exchanges.flatMap { it.toChatMessages() }
val request = ChatCompletionRequest(
model = platform.model,
messages = messages,
stream = platform.stream,
temperature = platform.temperature,
topP = platform.topP,
maxTokens = resolvedOutputTokenCap(platform, tools.isNotEmpty()),
tools = tools.takeIf { it.isNotEmpty() }?.map { definition ->
ChatFunctionTool(definition.name, definition.description, definition.inputSchema)
}
if (!failed) {
usage?.let { emit(it) }
emit(ProviderEvent.Completed)
)
val assembler = ChatCompletionsEventAssembler()
var failed = false
var usage: ProviderEvent.Usage? = null
api.streamChatCompletion(request, platform.timeout, config).collect { chunk ->
chunk.error?.let { error ->
failed = true
emit(ProviderEvent.Failed(error.message))
} ?: run {
chunk.usage.toProviderUsage()?.let { usage = it }
chunk.choices.orEmpty().forEach { choice ->
assembler.accept(
content = choice.delta.content,
reasoning = choice.delta.reasoning,
toolCalls = choice.delta.toolCalls,
finishReason = choice.finishReason
).forEach { emit(it) }
}
}
}
if (!failed) {
usage?.let { emit(it) }
emit(ProviderEvent.Completed)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class HttpRemoteContextWindowLookup @Inject constructor(
ClientType.OLLAMA -> fetchOllama(platform)
ClientType.GROQ -> fetchGroq(platform)
ClientType.ANTHROPIC -> fetchAnthropic(platform)
ClientType.MISTRAL -> fetchMistral(platform)
else -> null
}
} catch (error: CancellationException) {
Expand Down Expand Up @@ -79,6 +80,13 @@ class HttpRemoteContextWindowLookup @Inject constructor(
return RemoteContextWindowParser.anthropicMaxInputTokens(body, platform.model)
}

private suspend fun fetchMistral(platform: PlatformV2): Int? {
val body = get(modelsUrl(platform, platform.model)) { builder ->
platform.token?.let { builder.bearerAuth(it) }
} ?: return null
return RemoteContextWindowParser.mistralMaxContextLength(body, platform.model)
}

private suspend fun fetchOllama(platform: PlatformV2): Int? {
val root = platform.apiUrl.trimEnd('/').removeSuffix("/v1")
val endpoint = root + "/api/show"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ data class ProviderContextPolicy(
private const val INLINE_ATTACHMENT_LIMIT_BYTES = 12L * 1024 * 1024

fun forClientType(clientType: ClientType): ProviderContextPolicy = when (clientType) {
ClientType.GROQ, ClientType.OLLAMA, ClientType.OPENROUTER, ClientType.CUSTOM ->
ClientType.GROQ, ClientType.OLLAMA, ClientType.OPENROUTER, ClientType.CUSTOM, ClientType.MISTRAL ->
ProviderContextPolicy(maxInlineAttachmentBytes = INLINE_ATTACHMENT_LIMIT_BYTES)

else -> ProviderContextPolicy()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ object RemoteContextWindowParser {
return positiveInt(match["max_input_tokens"])
}

fun mistralMaxContextLength(body: String, model: String): Int? {
val match = matchingModel(body, model) ?: return null
return positiveInt(match["max_context_length"])
}

fun ollamaContextLength(body: String): Int? {
val root = parseObject(body) ?: return null
val modelInfo = root["model_info"] as? JsonObject
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package dev.chungjungsoo.gptmobile.data.model
enum class ClientType {
OPENAI,
ANTHROPIC,
MISTRAL,
GOOGLE,
GROQ,
OPENROUTER,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import dev.chungjungsoo.gptmobile.data.agent.ToolResultContent
import dev.chungjungsoo.gptmobile.data.agent.provider.AnthropicMessagesAdapter
import dev.chungjungsoo.gptmobile.data.agent.provider.GeminiAdapter
import dev.chungjungsoo.gptmobile.data.agent.provider.LiteRtLmAdapter
import dev.chungjungsoo.gptmobile.data.agent.provider.MistralAdapter
import dev.chungjungsoo.gptmobile.data.agent.provider.OpenAICompatibleAdapter
import dev.chungjungsoo.gptmobile.data.agent.provider.OpenAIResponsesAdapter
import dev.chungjungsoo.gptmobile.data.agent.provider.ProviderAttachmentEncoder
Expand Down Expand Up @@ -108,6 +109,7 @@ class ChatRepositoryImpl @Inject constructor(
private val providerAttachmentEncoder = ProviderAttachmentEncoder(context)
private val openAIResponsesAdapter = OpenAIResponsesAdapter(openAIAPI, providerAttachmentEncoder)
private val openAICompatibleAdapter = OpenAICompatibleAdapter(openAIAPI, groqAPI, providerAttachmentEncoder)
private val mistralAdapter = MistralAdapter(openAIAPI, providerAttachmentEncoder)
private val anthropicMessagesAdapter = AnthropicMessagesAdapter(anthropicAPI, providerAttachmentEncoder)
private val geminiAdapter = GeminiAdapter(googleAPI, providerAttachmentEncoder)
private val liteRtLmAdapter = LiteRtLmAdapter(
Expand Down Expand Up @@ -739,6 +741,8 @@ class ChatRepositoryImpl @Inject constructor(
ClientType.GROQ, ClientType.OLLAMA, ClientType.OPENROUTER, ClientType.CUSTOM ->
openAICompatibleAdapter.openSession(preparedTurns, platform)

ClientType.MISTRAL -> mistralAdapter.openSession(preparedTurns, platform)

ClientType.ANTHROPIC -> anthropicMessagesAdapter.openSession(preparedTurns, platform, prepared)

ClientType.GOOGLE -> geminiAdapter.openSession(preparedTurns, platform)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package dev.chungjungsoo.gptmobile.presentation.common

import dev.chungjungsoo.gptmobile.data.model.ClientType
import java.net.URI

internal fun isPlatformApiUrlValid(
clientType: ClientType?,
apiUrl: String
): Boolean = when (clientType) {
ClientType.LITERT_LM -> true

ClientType.MISTRAL -> runCatching { URI(apiUrl.trim()) }.getOrNull()?.let { url ->
url.scheme.equals("https", ignoreCase = true) &&
!url.host.isNullOrBlank() &&
url.rawPath.endsWith("/v1/") &&
url.rawQuery == null &&
url.rawFragment == null
} ?: false

else -> apiUrl.isNotBlank()
}

internal fun isPlatformApiKeyValid(
clientType: ClientType?,
apiKey: String
): Boolean = clientType != ClientType.MISTRAL || apiKey.isNotBlank()
Loading
Loading