From 65bc9ed0fc29edefedbfad78a4c6c75cd8f65661 Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Sun, 4 Oct 2026 18:54:19 +0200 Subject: [PATCH] Talk to Claude as well as the OpenAI-shaped services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything else worth connecting to — Ollama, LM Studio, llama.cpp, LocalAI, OpenAI, DeepSeek, and Gemini through its compatibility endpoint — speaks the OpenAI chat-completions shape, so one client covered them all. Anthropic does not: it is /v1/messages, an x-api-key header, and a reply that arrives as content blocks rather than a message. That one exception is now handled, keyed off the host, so the callers never see the difference. The endpoint note lists the services by name and URL. Knowing the app "supports OpenAI-compatible servers" is not the same as knowing what to type. Presets stay self-hosted only: naming a paid service in a one-tap button is steering people towards it, and the field takes any URL regardless. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/ganjoor/android/data/Assistant.kt | 81 +++++++++++++++---- app/src/main/res/values-fa/strings.xml | 4 +- app/src/main/res/values-ur/strings.xml | 4 +- app/src/main/res/values/strings.xml | 4 +- .../java/com/ganjoor/android/AssistantTest.kt | 38 +++++++++ 5 files changed, 110 insertions(+), 21 deletions(-) create mode 100644 app/src/test/java/com/ganjoor/android/AssistantTest.kt 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) } + } +}