From 276134c9da91aafa4ff149ad693bd08982b2c26e Mon Sep 17 00:00:00 2001 From: jordanthejet Date: Wed, 25 Feb 2026 16:58:14 -0500 Subject: [PATCH] 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 --- .../kotlin/com/cellclaw/config/AppConfig.kt | 2 +- .../cellclaw/provider/AnthropicProvider.kt | 29 ++- .../com/cellclaw/provider/GeminiProvider.kt | 4 +- .../com/cellclaw/provider/OpenAIProvider.kt | 103 ++++++-- .../cellclaw/provider/OpenRouterProvider.kt | 239 ++++++++++++++++++ .../com/cellclaw/provider/ProviderManager.kt | 20 +- 6 files changed, 354 insertions(+), 43 deletions(-) create mode 100644 app/src/main/kotlin/com/cellclaw/provider/OpenRouterProvider.kt diff --git a/app/src/main/kotlin/com/cellclaw/config/AppConfig.kt b/app/src/main/kotlin/com/cellclaw/config/AppConfig.kt index e701917..d294442 100644 --- a/app/src/main/kotlin/com/cellclaw/config/AppConfig.kt +++ b/app/src/main/kotlin/com/cellclaw/config/AppConfig.kt @@ -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 diff --git a/app/src/main/kotlin/com/cellclaw/provider/AnthropicProvider.kt b/app/src/main/kotlin/com/cellclaw/provider/AnthropicProvider.kt index 135cae0..f1e4e84 100644 --- a/app/src/main/kotlin/com/cellclaw/provider/AnthropicProvider.kt +++ b/app/src/main/kotlin/com/cellclaw/provider/AnthropicProvider.kt @@ -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" } } diff --git a/app/src/main/kotlin/com/cellclaw/provider/GeminiProvider.kt b/app/src/main/kotlin/com/cellclaw/provider/GeminiProvider.kt index 6b62a9e..e4c7f44 100644 --- a/app/src/main/kotlin/com/cellclaw/provider/GeminiProvider.kt +++ b/app/src/main/kotlin/com/cellclaw/provider/GeminiProvider.kt @@ -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() diff --git a/app/src/main/kotlin/com/cellclaw/provider/OpenAIProvider.kt b/app/src/main/kotlin/com/cellclaw/provider/OpenAIProvider.kt index 436d9a9..b7e0e20 100644 --- a/app/src/main/kotlin/com/cellclaw/provider/OpenAIProvider.kt +++ b/app/src/main/kotlin/com/cellclaw/provider/OpenAIProvider.kt @@ -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() + 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() + val toolCalls = msg.content.filterIsInstance() + + 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() .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() + .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" } } diff --git a/app/src/main/kotlin/com/cellclaw/provider/OpenRouterProvider.kt b/app/src/main/kotlin/com/cellclaw/provider/OpenRouterProvider.kt new file mode 100644 index 0000000..234b98b --- /dev/null +++ b/app/src/main/kotlin/com/cellclaw/provider/OpenRouterProvider.kt @@ -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() + + 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 = 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() + val toolCalls = msg.content.filterIsInstance() + + 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() + .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() + .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() + + 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" + } +} diff --git a/app/src/main/kotlin/com/cellclaw/provider/ProviderManager.kt b/app/src/main/kotlin/com/cellclaw/provider/ProviderManager.kt index 73265fd..baaed73 100644 --- a/app/src/main/kotlin/com/cellclaw/provider/ProviderManager.kt +++ b/app/src/main/kotlin/com/cellclaw/provider/ProviderManager.kt @@ -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 = 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) } } }