mirror of
https://github.com/frido22/cellclaw
synced 2026-05-10 22:43:50 +00:00
Fix OpenAI/OpenRouter tool serialization and update all provider models
OpenAI & OpenRouter: - Fix tool call serialization: assistant messages now include tool_calls array, tool results sent as role:"tool" with tool_call_id - Sanitize dotted tool names (sms.read -> sms_read) for OpenAI compliance, reverse-map on response parsing Model updates: - Anthropic: claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5 - OpenAI: gpt-5.2 (default), gpt-5.2-chat-latest, gpt-5-mini - Gemini: add gemini-3.1-pro-preview, drop deprecated gemini-2.0-flash - OpenRouter: add anthropic/claude-sonnet-4.6, openai/gpt-5.2 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ class AppConfig @Inject constructor(
|
||||
set(value) = prefs.edit().putString("provider_type", value).apply()
|
||||
|
||||
var model: String
|
||||
get() = prefs.getString("model", "claude-sonnet-4-20250514") ?: "claude-sonnet-4-20250514"
|
||||
get() = prefs.getString("model", "claude-sonnet-4-6") ?: "claude-sonnet-4-6"
|
||||
set(value) = prefs.edit().putString("model", value).apply()
|
||||
|
||||
var maxTokens: Int
|
||||
|
||||
@@ -194,6 +194,12 @@ class AnthropicProvider @Inject constructor() : Provider {
|
||||
put("messages", messagesArray)
|
||||
if (stream) put("stream", true)
|
||||
|
||||
// Enable extended thinking
|
||||
putJsonObject("thinking") {
|
||||
put("type", "enabled")
|
||||
put("budget_tokens", request.maxTokens.coerceAtMost(8000))
|
||||
}
|
||||
|
||||
if (request.tools.isNotEmpty()) {
|
||||
putJsonArray("tools") {
|
||||
for (tool in request.tools) {
|
||||
@@ -207,9 +213,17 @@ class AnthropicProvider @Inject constructor() : Provider {
|
||||
}
|
||||
|
||||
private fun contentBlockToJson(block: ContentBlock): JsonObject = when (block) {
|
||||
is ContentBlock.Text -> buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", block.text)
|
||||
is ContentBlock.Text -> if (block.thought) {
|
||||
buildJsonObject {
|
||||
put("type", "thinking")
|
||||
put("thinking", block.text)
|
||||
block.thoughtSignature?.let { put("signature", it) }
|
||||
}
|
||||
} else {
|
||||
buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", block.text)
|
||||
}
|
||||
}
|
||||
is ContentBlock.ToolUse -> buildJsonObject {
|
||||
put("type", "tool_use")
|
||||
@@ -272,6 +286,11 @@ class AnthropicProvider @Inject constructor() : Provider {
|
||||
val blocks = contentArray.map { element ->
|
||||
val block = element.jsonObject
|
||||
when (block["type"]?.jsonPrimitive?.content) {
|
||||
"thinking" -> ContentBlock.Text(
|
||||
text = block["thinking"]?.jsonPrimitive?.content ?: "",
|
||||
thoughtSignature = block["signature"]?.jsonPrimitive?.content,
|
||||
thought = true
|
||||
)
|
||||
"text" -> ContentBlock.Text(
|
||||
block["text"]?.jsonPrimitive?.content ?: ""
|
||||
)
|
||||
@@ -303,8 +322,8 @@ class AnthropicProvider @Inject constructor() : Provider {
|
||||
|
||||
companion object {
|
||||
const val API_URL = "https://api.anthropic.com/v1/messages"
|
||||
const val API_VERSION = "2023-06-01"
|
||||
const val DEFAULT_MODEL = "claude-sonnet-4-20250514"
|
||||
const val API_VERSION = "2025-04-14"
|
||||
const val DEFAULT_MODEL = "claude-sonnet-4-6"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@ class GeminiProvider @Inject constructor() : Provider {
|
||||
/** Fallback models to try when the primary model returns 404 */
|
||||
private val fallbackModels = listOf(
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.0-flash"
|
||||
"gemini-2.5-pro"
|
||||
)
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
|
||||
@@ -32,6 +32,9 @@ class OpenAIProvider @Inject constructor() : Provider {
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
// Maps sanitized names (dots replaced with underscores) back to original tool names
|
||||
private val toolNameMap = mutableMapOf<String, String>()
|
||||
|
||||
fun configure(apiKey: String, model: String = DEFAULT_MODEL) {
|
||||
this.apiKey = apiKey
|
||||
this.model = model
|
||||
@@ -78,6 +81,7 @@ class OpenAIProvider @Inject constructor() : Provider {
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
private fun buildRequestBody(request: CompletionRequest): String {
|
||||
toolNameMap.clear()
|
||||
val messages = buildJsonArray {
|
||||
// System message
|
||||
add(buildJsonObject {
|
||||
@@ -86,33 +90,72 @@ class OpenAIProvider @Inject constructor() : Provider {
|
||||
})
|
||||
// Conversation messages
|
||||
for (msg in request.messages) {
|
||||
add(buildJsonObject {
|
||||
put("role", msg.role.name.lowercase())
|
||||
val hasImages = msg.content.any { it is ContentBlock.Image }
|
||||
if (hasImages) {
|
||||
putJsonArray("content") {
|
||||
for (block in msg.content) {
|
||||
when (block) {
|
||||
is ContentBlock.Text -> add(buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", block.text)
|
||||
})
|
||||
is ContentBlock.Image -> add(buildJsonObject {
|
||||
put("type", "image_url")
|
||||
putJsonObject("image_url") {
|
||||
put("url", "data:${block.mediaType};base64,${block.base64Data}")
|
||||
}
|
||||
})
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val toolResults = msg.content.filterIsInstance<ContentBlock.ToolResult>()
|
||||
val toolCalls = msg.content.filterIsInstance<ContentBlock.ToolUse>()
|
||||
|
||||
if (toolResults.isNotEmpty()) {
|
||||
// OpenAI expects each tool result as a separate message with role: "tool"
|
||||
for (result in toolResults) {
|
||||
add(buildJsonObject {
|
||||
put("role", "tool")
|
||||
put("tool_call_id", result.toolUseId)
|
||||
put("content", result.content)
|
||||
})
|
||||
}
|
||||
} else if (toolCalls.isNotEmpty()) {
|
||||
// Assistant message with tool calls
|
||||
add(buildJsonObject {
|
||||
put("role", "assistant")
|
||||
val textContent = msg.content.filterIsInstance<ContentBlock.Text>()
|
||||
.joinToString("") { it.text }
|
||||
put("content", textContent)
|
||||
}
|
||||
})
|
||||
if (textContent.isNotBlank()) {
|
||||
put("content", textContent)
|
||||
} else {
|
||||
put("content", JsonNull)
|
||||
}
|
||||
putJsonArray("tool_calls") {
|
||||
for (call in toolCalls) {
|
||||
add(buildJsonObject {
|
||||
put("id", call.id)
|
||||
put("type", "function")
|
||||
putJsonObject("function") {
|
||||
put("name", call.name.replace(".", "_"))
|
||||
put("arguments", json.encodeToString(JsonObject.serializer(), call.input))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Regular user or assistant message
|
||||
add(buildJsonObject {
|
||||
put("role", msg.role.name.lowercase())
|
||||
val hasImages = msg.content.any { it is ContentBlock.Image }
|
||||
if (hasImages) {
|
||||
putJsonArray("content") {
|
||||
for (block in msg.content) {
|
||||
when (block) {
|
||||
is ContentBlock.Text -> add(buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", block.text)
|
||||
})
|
||||
is ContentBlock.Image -> add(buildJsonObject {
|
||||
put("type", "image_url")
|
||||
putJsonObject("image_url") {
|
||||
put("url", "data:${block.mediaType};base64,${block.base64Data}")
|
||||
}
|
||||
})
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val textContent = msg.content.filterIsInstance<ContentBlock.Text>()
|
||||
.joinToString("") { it.text }
|
||||
put("content", textContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +169,9 @@ class OpenAIProvider @Inject constructor() : Provider {
|
||||
add(buildJsonObject {
|
||||
put("type", "function")
|
||||
putJsonObject("function") {
|
||||
put("name", tool.name)
|
||||
val sanitized = tool.name.replace(".", "_")
|
||||
toolNameMap[sanitized] = tool.name
|
||||
put("name", sanitized)
|
||||
put("description", tool.description)
|
||||
putJsonObject("parameters") {
|
||||
put("type", tool.inputSchema.type)
|
||||
@@ -174,9 +219,11 @@ class OpenAIProvider @Inject constructor() : Provider {
|
||||
JsonObject(emptyMap())
|
||||
}
|
||||
|
||||
val rawName = function["name"]?.jsonPrimitive?.content ?: ""
|
||||
val toolName = toolNameMap[rawName] ?: rawName.replace("_", ".")
|
||||
blocks.add(ContentBlock.ToolUse(
|
||||
id = toolCall["id"]?.jsonPrimitive?.content ?: "",
|
||||
name = function["name"]?.jsonPrimitive?.content ?: "",
|
||||
name = toolName,
|
||||
input = input
|
||||
))
|
||||
}
|
||||
@@ -193,6 +240,6 @@ class OpenAIProvider @Inject constructor() : Provider {
|
||||
|
||||
companion object {
|
||||
const val API_URL = "https://api.openai.com/v1/chat/completions"
|
||||
const val DEFAULT_MODEL = "gpt-4o"
|
||||
const val DEFAULT_MODEL = "gpt-5.2"
|
||||
}
|
||||
}
|
||||
|
||||
239
app/src/main/kotlin/com/cellclaw/provider/OpenRouterProvider.kt
Normal file
239
app/src/main/kotlin/com/cellclaw/provider/OpenRouterProvider.kt
Normal file
@@ -0,0 +1,239 @@
|
||||
package com.cellclaw.provider
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
class OpenRouterProvider @Inject constructor() : Provider {
|
||||
|
||||
override val name = "openrouter"
|
||||
|
||||
private var apiKey: String = ""
|
||||
private var model: String = DEFAULT_MODEL
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = false
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
// Maps sanitized names (dots replaced with underscores) back to original tool names
|
||||
private val toolNameMap = mutableMapOf<String, String>()
|
||||
|
||||
fun configure(apiKey: String, model: String = DEFAULT_MODEL) {
|
||||
this.apiKey = apiKey
|
||||
this.model = model
|
||||
}
|
||||
|
||||
override suspend fun complete(request: CompletionRequest): CompletionResponse =
|
||||
withContext(Dispatchers.IO) {
|
||||
val body = buildRequestBody(request)
|
||||
val httpRequest = Request.Builder()
|
||||
.url(API_URL)
|
||||
.addHeader("Authorization", "Bearer $apiKey")
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(body.toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
val response = client.newCall(httpRequest).execute()
|
||||
val responseBody = response.body?.string()
|
||||
?: throw ProviderException("Empty response body")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw ProviderException("OpenRouter API error ${response.code}: $responseBody")
|
||||
}
|
||||
|
||||
parseResponse(json.parseToJsonElement(responseBody).jsonObject)
|
||||
}
|
||||
|
||||
override fun stream(request: CompletionRequest): Flow<StreamEvent> = flow {
|
||||
try {
|
||||
val response = complete(request)
|
||||
for (block in response.content) {
|
||||
when (block) {
|
||||
is ContentBlock.Text -> emit(StreamEvent.TextDelta(block.text))
|
||||
is ContentBlock.ToolUse -> {
|
||||
emit(StreamEvent.ToolUseStart(block.id, block.name))
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
emit(StreamEvent.Complete(response))
|
||||
} catch (e: Exception) {
|
||||
emit(StreamEvent.Error(e.message ?: "Unknown error"))
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
private fun buildRequestBody(request: CompletionRequest): String {
|
||||
toolNameMap.clear()
|
||||
val messages = buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("role", "system")
|
||||
put("content", request.systemPrompt)
|
||||
})
|
||||
for (msg in request.messages) {
|
||||
val toolResults = msg.content.filterIsInstance<ContentBlock.ToolResult>()
|
||||
val toolCalls = msg.content.filterIsInstance<ContentBlock.ToolUse>()
|
||||
|
||||
if (toolResults.isNotEmpty()) {
|
||||
// OpenAI format: each tool result is a separate message with role: "tool"
|
||||
for (result in toolResults) {
|
||||
add(buildJsonObject {
|
||||
put("role", "tool")
|
||||
put("tool_call_id", result.toolUseId)
|
||||
put("content", result.content)
|
||||
})
|
||||
}
|
||||
} else if (toolCalls.isNotEmpty()) {
|
||||
// Assistant message with tool calls
|
||||
add(buildJsonObject {
|
||||
put("role", "assistant")
|
||||
val textContent = msg.content.filterIsInstance<ContentBlock.Text>()
|
||||
.joinToString("") { it.text }
|
||||
if (textContent.isNotBlank()) {
|
||||
put("content", textContent)
|
||||
} else {
|
||||
put("content", JsonNull)
|
||||
}
|
||||
putJsonArray("tool_calls") {
|
||||
for (call in toolCalls) {
|
||||
add(buildJsonObject {
|
||||
put("id", call.id)
|
||||
put("type", "function")
|
||||
putJsonObject("function") {
|
||||
put("name", call.name.replace(".", "_"))
|
||||
put("arguments", json.encodeToString(JsonObject.serializer(), call.input))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Regular user or assistant message
|
||||
add(buildJsonObject {
|
||||
put("role", msg.role.name.lowercase())
|
||||
val hasImages = msg.content.any { it is ContentBlock.Image }
|
||||
if (hasImages) {
|
||||
putJsonArray("content") {
|
||||
for (block in msg.content) {
|
||||
when (block) {
|
||||
is ContentBlock.Text -> add(buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", block.text)
|
||||
})
|
||||
is ContentBlock.Image -> add(buildJsonObject {
|
||||
put("type", "image_url")
|
||||
putJsonObject("image_url") {
|
||||
put("url", "data:${block.mediaType};base64,${block.base64Data}")
|
||||
}
|
||||
})
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val textContent = msg.content.filterIsInstance<ContentBlock.Text>()
|
||||
.joinToString("") { it.text }
|
||||
put("content", textContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json.encodeToString(JsonObject.serializer(), buildJsonObject {
|
||||
put("model", model)
|
||||
put("messages", messages)
|
||||
put("max_tokens", request.maxTokens)
|
||||
if (request.tools.isNotEmpty()) {
|
||||
putJsonArray("tools") {
|
||||
for (tool in request.tools) {
|
||||
add(buildJsonObject {
|
||||
put("type", "function")
|
||||
putJsonObject("function") {
|
||||
val sanitized = tool.name.replace(".", "_")
|
||||
toolNameMap[sanitized] = tool.name
|
||||
put("name", sanitized)
|
||||
put("description", tool.description)
|
||||
putJsonObject("parameters") {
|
||||
put("type", tool.inputSchema.type)
|
||||
putJsonObject("properties") {
|
||||
for ((key, prop) in tool.inputSchema.properties) {
|
||||
putJsonObject(key) {
|
||||
put("type", prop.type)
|
||||
put("description", prop.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tool.inputSchema.required.isNotEmpty()) {
|
||||
putJsonArray("required") {
|
||||
tool.inputSchema.required.forEach { add(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun parseResponse(obj: JsonObject): CompletionResponse {
|
||||
val choices = obj["choices"]?.jsonArray ?: return CompletionResponse(emptyList(), StopReason.ERROR)
|
||||
val choice = choices.firstOrNull()?.jsonObject ?: return CompletionResponse(emptyList(), StopReason.ERROR)
|
||||
val message = choice["message"]?.jsonObject ?: return CompletionResponse(emptyList(), StopReason.ERROR)
|
||||
|
||||
val blocks = mutableListOf<ContentBlock>()
|
||||
|
||||
message["content"]?.jsonPrimitive?.contentOrNull?.let {
|
||||
if (it.isNotBlank()) blocks.add(ContentBlock.Text(it))
|
||||
}
|
||||
|
||||
message["tool_calls"]?.jsonArray?.forEach { toolCallElement ->
|
||||
val toolCall = toolCallElement.jsonObject
|
||||
val function = toolCall["function"]?.jsonObject ?: return@forEach
|
||||
val input = try {
|
||||
json.parseToJsonElement(function["arguments"]?.jsonPrimitive?.content ?: "{}").jsonObject
|
||||
} catch (_: Exception) {
|
||||
JsonObject(emptyMap())
|
||||
}
|
||||
|
||||
val rawName = function["name"]?.jsonPrimitive?.content ?: ""
|
||||
val toolName = toolNameMap[rawName] ?: rawName.replace("_", ".")
|
||||
blocks.add(ContentBlock.ToolUse(
|
||||
id = toolCall["id"]?.jsonPrimitive?.content ?: "",
|
||||
name = toolName,
|
||||
input = input
|
||||
))
|
||||
}
|
||||
|
||||
val finishReason = choice["finish_reason"]?.jsonPrimitive?.content
|
||||
val stopReason = when (finishReason) {
|
||||
"tool_calls" -> StopReason.TOOL_USE
|
||||
"length" -> StopReason.MAX_TOKENS
|
||||
else -> StopReason.END_TURN
|
||||
}
|
||||
|
||||
return CompletionResponse(blocks, stopReason)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
const val DEFAULT_MODEL = "google/gemini-2.5-flash"
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,14 @@ class ProviderManager @Inject constructor(
|
||||
private val secureKeyStore: SecureKeyStore,
|
||||
private val anthropicProvider: AnthropicProvider,
|
||||
private val openAIProvider: OpenAIProvider,
|
||||
private val geminiProvider: GeminiProvider
|
||||
private val geminiProvider: GeminiProvider,
|
||||
private val openRouterProvider: OpenRouterProvider
|
||||
) {
|
||||
private val providers = mapOf(
|
||||
"anthropic" to anthropicProvider,
|
||||
"openai" to openAIProvider,
|
||||
"gemini" to geminiProvider
|
||||
"gemini" to geminiProvider,
|
||||
"openrouter" to openRouterProvider
|
||||
)
|
||||
|
||||
/** Get the currently active provider, configured with its API key */
|
||||
@@ -44,15 +46,18 @@ class ProviderManager @Inject constructor(
|
||||
|
||||
/** Get all available provider types */
|
||||
fun availableProviders(): List<ProviderInfo> = listOf(
|
||||
ProviderInfo("anthropic", "Anthropic (Claude)", "claude-sonnet-4-20250514",
|
||||
ProviderInfo("anthropic", "Anthropic (Claude)", "claude-sonnet-4-6",
|
||||
secureKeyStore.hasApiKey("anthropic"),
|
||||
models = listOf("claude-sonnet-4-20250514", "claude-haiku-4-20250514", "claude-opus-4-20250514")),
|
||||
ProviderInfo("openai", "OpenAI (GPT)", "gpt-4o",
|
||||
models = listOf("claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5")),
|
||||
ProviderInfo("openai", "OpenAI (GPT)", "gpt-5.2",
|
||||
secureKeyStore.hasApiKey("openai"),
|
||||
models = listOf("gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini")),
|
||||
models = listOf("gpt-5.2", "gpt-5.2-chat-latest", "gpt-5-mini", "gpt-4.1", "gpt-4.1-mini")),
|
||||
ProviderInfo("gemini", "Google (Gemini)", "gemini-3-flash-preview",
|
||||
secureKeyStore.hasApiKey("gemini"),
|
||||
models = listOf("gemini-3-flash-preview", "gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"))
|
||||
models = listOf("gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-2.5-flash", "gemini-2.5-pro")),
|
||||
ProviderInfo("openrouter", "OpenRouter", "google/gemini-2.5-flash",
|
||||
secureKeyStore.hasApiKey("openrouter"),
|
||||
models = listOf("google/gemini-2.5-flash", "google/gemini-2.5-pro", "anthropic/claude-sonnet-4.6", "openai/gpt-5.2"))
|
||||
)
|
||||
|
||||
/** Check if a provider has an API key configured */
|
||||
@@ -75,6 +80,7 @@ class ProviderManager @Inject constructor(
|
||||
is AnthropicProvider -> provider.configure(apiKey, model)
|
||||
is OpenAIProvider -> provider.configure(apiKey, model)
|
||||
is GeminiProvider -> provider.configure(apiKey, model)
|
||||
is OpenRouterProvider -> provider.configure(apiKey, model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user