Talk to Claude as well as the OpenAI-shaped services

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) <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-04 18:54:19 +02:00
parent 576a07ae31
commit 65bc9ed0fc
5 changed files with 110 additions and 21 deletions

View File

@ -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<Choice> = 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<ChatMessage>,
@SerialName("max_tokens") val maxTokens: Int = 2048,
)
@Serializable
private data class ClaudeResponse(val content: List<ClaudeBlock> = 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<ClaudeResponse>(body)
.content.firstOrNull { it.type == "text" }?.text
} else {
json.decodeFromString<ChatResponse>(body)
.choices.firstOrNull()?.message?.content
}
return text?.trim().orEmpty().ifEmpty { throw IOException("empty reply") }
}
suspend fun ask(
settings: AssistantSettings,
system: String,
@ -154,8 +189,18 @@ object Assistant {
if (!settings.serverReady) {
return@withContext Result.failure(IllegalStateException("not configured"))
}
val url = settings.baseUrl.trimEnd('/') + "/chat/completions"
val body = json.encodeToString(
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)),
)
)
} else {
json.encodeToString(
ChatRequest(
model = settings.model,
messages = listOf(
@ -163,22 +208,28 @@ object Assistant {
ChatMessage("user", user),
),
)
).toRequestBody(media)
)
}.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<ChatResponse>(text)
.choices.firstOrNull()?.message?.content?.trim()
.orEmpty()
.ifEmpty { throw IOException("empty reply") }
reply(text, claude)
}
}
}

View File

@ -78,13 +78,13 @@
<string name="assistant_off">خاموش</string>
<string name="assistant_off_note">بدون دستیار. خلاصه‌های فارسی خود گنجور، اگر در تنظیمات خواندن روشن باشند، نمایش داده می‌شوند.</string>
<string name="assistant_server">سرور خودتان</string>
<string name="assistant_server_note">با هر چیزی که API گفت‌وگوی OpenAI را بفهمد کار می‌کند — Ollama، LM Studio، llama.cpp، LocalAI یا سرویسی پولی اگر ترجیح می‌دهید.</string>
<string name="assistant_server_note">با هر چیزی که API گفت‌وگوی OpenAI را بفهمد کار می‌کند — Ollama، LM Studio، llama.cpp، LocalAI — و با کلاد که API خودش را دارد.</string>
<string name="assistant_share">ارسال به یک برنامه</string>
<string name="assistant_share_note">متن را به هر دستیاری که روی گوشی دارید می‌سپارد. تا خودتان یکی را انتخاب نکنید چیزی از این برنامه بیرون نمی‌رود.</string>
<string name="assistant_language">پاسخ به زبان</string>
<string name="assistant_presets">آماده‌ها</string>
<string name="assistant_base_url">نشانی پایه</string>
<string name="assistant_base_url_note">برای نمونه http://127.0.0.1:11434/v1 برای Ollama روی همین دستگاه</string>
<string name="assistant_base_url_note">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</string>
<string name="assistant_model">مدل</string>
<string name="assistant_api_key">کلید API (اختیاری)</string>
<string name="assistant_api_key_note">سرور روی دستگاه خودتان معمولاً کلید نمی‌خواهد. بدون رمزگذاری روی همین دستگاه ذخیره می‌شود.</string>

View File

@ -78,13 +78,13 @@
<string name="assistant_off">بند</string>
<string name="assistant_off_note">کوئی معاون نہیں۔ گنجور کے اپنے فارسی خلاصے، اگر مطالعے کی ترتیبات میں آن ہوں، پھر بھی دکھائے جائیں گے۔</string>
<string name="assistant_server">آپ کا اپنا سرور</string>
<string name="assistant_server_note">ہر اس چیز سے بات کرتا ہے جو OpenAI چیٹ API سمجھتی ہو — Ollama، LM Studio، llama.cpp، LocalAI، یا کوئی ادائیگی والی سروس اگر آپ چاہیں۔</string>
<string name="assistant_server_note">ہر اس چیز سے بات کرتا ہے جو OpenAI چیٹ API سمجھتی ہو — Ollama، LM Studio، llama.cpp، LocalAI — اور Claude سے بھی، جس کا اپنا API ہے۔</string>
<string name="assistant_share">کسی ایپ کو بھیجیں</string>
<string name="assistant_share_note">متن آپ کے فون پر موجود کسی بھی معاون کو دے دیتا ہے۔ جب تک آپ خود انتخاب نہ کریں کچھ باہر نہیں جاتا۔</string>
<string name="assistant_language">جواب کی زبان</string>
<string name="assistant_presets">تیار ترتیبات</string>
<string name="assistant_base_url">بنیادی یو آر ایل</string>
<string name="assistant_base_url_note">مثلاً اسی آلے پر Ollama کے لیے http://127.0.0.1:11434/v1</string>
<string name="assistant_base_url_note">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</string>
<string name="assistant_model">ماڈل</string>
<string name="assistant_api_key">API کلید (اختیاری)</string>
<string name="assistant_api_key_note">آپ کی اپنی مشین کے سرور کو عموماً کلید کی ضرورت نہیں۔ اسی آلے پر بغیر خفیہ کاری محفوظ ہوتی ہے۔</string>

View File

@ -83,13 +83,13 @@
<string name="assistant_off">Off</string>
<string name="assistant_off_note">No assistant. Ganjoor&#8217;s own Persian summaries are still shown if you enable them in reading settings.</string>
<string name="assistant_server">Your own server</string>
<string name="assistant_server_note">Talks to anything speaking the OpenAI chat API — Ollama, LM Studio, llama.cpp, LocalAI, or a paid service if you prefer one.</string>
<string name="assistant_server_note">Talks to anything speaking the OpenAI chat API — Ollama, LM Studio, llama.cpp, LocalAI — and to Claude, which uses its own.</string>
<string name="assistant_share">Send to an app</string>
<string name="assistant_share_note">Hands the text to any assistant installed on your phone. Nothing leaves this app until you pick one.</string>
<string name="assistant_language">Answer in</string>
<string name="assistant_presets">Presets</string>
<string name="assistant_base_url">Base URL</string>
<string name="assistant_base_url_note">For example http://127.0.0.1:11434/v1 for Ollama on this device</string>
<string name="assistant_base_url_note">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</string>
<string name="assistant_model">Model</string>
<string name="assistant_api_key">API key (optional)</string>
<string name="assistant_api_key_note">A server on your own machine usually needs none. Stored unencrypted on this device.</string>

View File

@ -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) }
}
}