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:
jordanthejet
2026-02-25 16:58:14 -05:00
parent f0bdc0dea4
commit 276134c9da
6 changed files with 354 additions and 43 deletions

View File

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

View File

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

View File

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

View File

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

View 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"
}
}

View File

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