diff --git a/app/src/main/java/com/ganjoor/android/data/Assistant.kt b/app/src/main/java/com/ganjoor/android/data/Assistant.kt index 08f301a..a69c3a5 100644 --- a/app/src/main/java/com/ganjoor/android/data/Assistant.kt +++ b/app/src/main/java/com/ganjoor/android/data/Assistant.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.core.content.edit import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType @@ -129,6 +130,25 @@ private data class ChatResponse(val choices: List = emptyList()) @Serializable private data class Choice(val message: ChatMessage? = null) +/** + * Anthropic's own shape. Everything else worth talking to speaks the OpenAI one, so this is the + * single exception the client makes — a different path, a different header, and a reply that + * arrives as a list of content blocks rather than a message. + */ +@Serializable +private data class ClaudeRequest( + val model: String, + val system: String, + val messages: List, + @SerialName("max_tokens") val maxTokens: Int = 2048, +) + +@Serializable +private data class ClaudeResponse(val content: List = emptyList()) + +@Serializable +private data class ClaudeBlock(val type: String = "", val text: String = "") + /** * Talks to anything that speaks the OpenAI chat-completions shape, which Ollama, LM Studio, * llama.cpp and LocalAI all do. That one shape is why no vendor library is needed: it is an @@ -146,6 +166,21 @@ object Assistant { .build() } + /** Anthropic is the one service that does not speak the OpenAI shape. */ + internal fun isClaude(baseUrl: String) = baseUrl.contains("anthropic.com", ignoreCase = true) + + /** Pulls the reply out of whichever shape came back, so the callers never see the difference. */ + internal fun reply(body: String, claude: Boolean): String { + val text = if (claude) { + json.decodeFromString(body) + .content.firstOrNull { it.type == "text" }?.text + } else { + json.decodeFromString(body) + .choices.firstOrNull()?.message?.content + } + return text?.trim().orEmpty().ifEmpty { throw IOException("empty reply") } + } + suspend fun ask( settings: AssistantSettings, system: String, @@ -154,31 +189,47 @@ object Assistant { if (!settings.serverReady) { return@withContext Result.failure(IllegalStateException("not configured")) } - val url = settings.baseUrl.trimEnd('/') + "/chat/completions" - val body = json.encodeToString( - ChatRequest( - model = settings.model, - messages = listOf( - ChatMessage("system", system), - ChatMessage("user", user), - ), + val claude = isClaude(settings.baseUrl) + val base = settings.baseUrl.trimEnd('/') + val body = if (claude) { + json.encodeToString( + ClaudeRequest( + model = settings.model, + system = system, + messages = listOf(ChatMessage("user", user)), + ) ) - ).toRequestBody(media) + } else { + json.encodeToString( + ChatRequest( + model = settings.model, + messages = listOf( + ChatMessage("system", system), + ChatMessage("user", user), + ), + ) + ) + }.toRequestBody(media) val request = Request.Builder() - .url(url) + .url(if (claude) "$base/messages" else "$base/chat/completions") .post(body) - .apply { if (settings.apiKey.isNotBlank()) header("Authorization", "Bearer ${settings.apiKey}") } + .apply { + if (settings.apiKey.isBlank()) return@apply + if (claude) { + header("x-api-key", settings.apiKey) + header("anthropic-version", "2023-06-01") + } else { + header("Authorization", "Bearer ${settings.apiKey}") + } + } .build() runCatching { http.newCall(request).execute().use { response -> val text = response.body?.string().orEmpty() if (!response.isSuccessful) throw IOException("HTTP ${response.code}: ${text.take(200)}") - json.decodeFromString(text) - .choices.firstOrNull()?.message?.content?.trim() - .orEmpty() - .ifEmpty { throw IOException("empty reply") } + reply(text, claude) } } } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index e8b7de5..78de97b 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -78,13 +78,13 @@ خاموش بدون دستیار. خلاصه‌های فارسی خود گنجور، اگر در تنظیمات خواندن روشن باشند، نمایش داده می‌شوند. سرور خودتان - با هر چیزی که API گفت‌وگوی OpenAI را بفهمد کار می‌کند — Ollama، LM Studio، llama.cpp، LocalAI یا سرویسی پولی اگر ترجیح می‌دهید. + با هر چیزی که API گفت‌وگوی OpenAI را بفهمد کار می‌کند — Ollama، LM Studio، llama.cpp، LocalAI — و با کلاد که API خودش را دارد. ارسال به یک برنامه متن را به هر دستیاری که روی گوشی دارید می‌سپارد. تا خودتان یکی را انتخاب نکنید چیزی از این برنامه بیرون نمی‌رود. پاسخ به زبان آماده‌ها نشانی پایه - برای نمونه http://127.0.0.1:11434/v1 برای Ollama روی همین دستگاه + Ollama: http://127.0.0.1:11434/v1 · LM Studio: http://127.0.0.1:1234/v1 · OpenAI: https://api.openai.com/v1 · DeepSeek: https://api.deepseek.com/v1 · Gemini: https://generativelanguage.googleapis.com/v1beta/openai · Claude: https://api.anthropic.com/v1 مدل کلید API (اختیاری) سرور روی دستگاه خودتان معمولاً کلید نمی‌خواهد. بدون رمزگذاری روی همین دستگاه ذخیره می‌شود. diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index fa8700a..eefe67c 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -78,13 +78,13 @@ بند کوئی معاون نہیں۔ گنجور کے اپنے فارسی خلاصے، اگر مطالعے کی ترتیبات میں آن ہوں، پھر بھی دکھائے جائیں گے۔ آپ کا اپنا سرور - ہر اس چیز سے بات کرتا ہے جو OpenAI چیٹ API سمجھتی ہو — Ollama، LM Studio، llama.cpp، LocalAI، یا کوئی ادائیگی والی سروس اگر آپ چاہیں۔ + ہر اس چیز سے بات کرتا ہے جو OpenAI چیٹ API سمجھتی ہو — Ollama، LM Studio، llama.cpp، LocalAI — اور Claude سے بھی، جس کا اپنا API ہے۔ کسی ایپ کو بھیجیں متن آپ کے فون پر موجود کسی بھی معاون کو دے دیتا ہے۔ جب تک آپ خود انتخاب نہ کریں کچھ باہر نہیں جاتا۔ جواب کی زبان تیار ترتیبات بنیادی یو آر ایل - مثلاً اسی آلے پر Ollama کے لیے http://127.0.0.1:11434/v1 + Ollama: http://127.0.0.1:11434/v1 · LM Studio: http://127.0.0.1:1234/v1 · OpenAI: https://api.openai.com/v1 · DeepSeek: https://api.deepseek.com/v1 · Gemini: https://generativelanguage.googleapis.com/v1beta/openai · Claude: https://api.anthropic.com/v1 ماڈل API کلید (اختیاری) آپ کی اپنی مشین کے سرور کو عموماً کلید کی ضرورت نہیں۔ اسی آلے پر بغیر خفیہ کاری محفوظ ہوتی ہے۔ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 501e3c3..b6287d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,13 +83,13 @@ Off No assistant. Ganjoor’s own Persian summaries are still shown if you enable them in reading settings. Your own server - Talks to anything speaking the OpenAI chat API — Ollama, LM Studio, llama.cpp, LocalAI, or a paid service if you prefer one. + Talks to anything speaking the OpenAI chat API — Ollama, LM Studio, llama.cpp, LocalAI — and to Claude, which uses its own. Send to an app Hands the text to any assistant installed on your phone. Nothing leaves this app until you pick one. Answer in Presets Base URL - For example http://127.0.0.1:11434/v1 for Ollama on this device + Ollama: http://127.0.0.1:11434/v1 · LM Studio: http://127.0.0.1:1234/v1 · OpenAI: https://api.openai.com/v1 · DeepSeek: https://api.deepseek.com/v1 · Gemini: https://generativelanguage.googleapis.com/v1beta/openai · Claude: https://api.anthropic.com/v1 Model API key (optional) A server on your own machine usually needs none. Stored unencrypted on this device. diff --git a/app/src/test/java/com/ganjoor/android/AssistantTest.kt b/app/src/test/java/com/ganjoor/android/AssistantTest.kt new file mode 100644 index 0000000..8212628 --- /dev/null +++ b/app/src/test/java/com/ganjoor/android/AssistantTest.kt @@ -0,0 +1,38 @@ +package com.ganjoor.android + +import com.ganjoor.android.data.Assistant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +class AssistantTest { + + @Test + fun `recognises anthropic by its host`() { + assertTrue(Assistant.isClaude("https://api.anthropic.com/v1")) + assertFalse(Assistant.isClaude("https://api.openai.com/v1")) + assertFalse(Assistant.isClaude("http://127.0.0.1:11434/v1")) + } + + @Test + fun `reads an openai reply`() { + val body = """{"choices":[{"message":{"role":"assistant","content":" the rose "}}]}""" + assertEquals("the rose", Assistant.reply(body, claude = false)) + } + + /** Anthropic answers with content blocks, and the first is not always the text one. */ + @Test + fun `reads a claude reply past a non-text block`() { + val body = """{"content":[{"type":"thinking","text":"x"},{"type":"text","text":" the rose "}]}""" + assertEquals("the rose", Assistant.reply(body, claude = true)) + } + + @Test + fun `an empty reply is an error, not an empty answer`() { + assertThrows(IOException::class.java) { Assistant.reply("""{"choices":[]}""", false) } + assertThrows(IOException::class.java) { Assistant.reply("""{"content":[]}""", true) } + } +}