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:
parent
576a07ae31
commit
65bc9ed0fc
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -83,13 +83,13 @@
|
||||
<string name="assistant_off">Off</string>
|
||||
<string name="assistant_off_note">No assistant. Ganjoor’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>
|
||||
|
||||
38
app/src/test/java/com/ganjoor/android/AssistantTest.kt
Normal file
38
app/src/test/java/com/ganjoor/android/AssistantTest.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user