diff --git a/app/src/main/java/com/pixel10/ai/inference/GeminiCloudModel.kt b/app/src/main/java/com/pixel10/ai/inference/GeminiCloudModel.kt index 3829312..8c9322f 100644 --- a/app/src/main/java/com/pixel10/ai/inference/GeminiCloudModel.kt +++ b/app/src/main/java/com/pixel10/ai/inference/GeminiCloudModel.kt @@ -104,6 +104,37 @@ class GeminiCloudModel(private val apiKey: String) : OnDeviceModel { } } + override suspend fun chat( + messages: List, + tools: List, + maxTokens: Int, + temperature: Float + ): OnDeviceModel.ChatResult = withContext(Dispatchers.IO) { + val url = URL("$BASE_URL:generateContent?key=$apiKey") + val connection = url.openConnection() as HttpURLConnection + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.doOutput = true + connection.connectTimeout = 30_000 + connection.readTimeout = 120_000 + + val body = buildChatRequestBody(messages, tools, maxTokens, temperature) + connection.outputStream.use { it.write(body.toByteArray()) } + + val responseCode = connection.responseCode + if (responseCode != HttpURLConnection.HTTP_OK) { + val error = connection.errorStream?.bufferedReader()?.readText() ?: "Unknown error" + throw OnDeviceModel.InferenceException("Gemini chat API error $responseCode: $error") + } + + val responseText = connection.inputStream.bufferedReader().readText() + parseChatResponse(responseText) + } finally { + connection.disconnect() + } + } + override suspend fun generateWithThinking( prompt: String, maxTokens: Int, @@ -138,6 +169,174 @@ class GeminiCloudModel(private val apiKey: String) : OnDeviceModel { // No resources to clean up } + // ── Chat / Tool-calling helpers ─────────────────────────────────────────── + + private fun buildChatRequestBody( + messages: List, + tools: List, + maxTokens: Int, + temperature: Float + ): String { + // Build a map from tool_call_id → function name so we can label tool results + val toolCallIdToName = mutableMapOf() + for (msg in messages) { + msg.toolCalls?.forEach { tc -> toolCallIdToName[tc.id] = tc.name } + } + + return JSONObject().apply { + // System instruction (Gemini uses a dedicated field, not a role) + val systemMsg = messages.firstOrNull { it.role == "system" } + if (systemMsg?.content != null) { + put("systemInstruction", JSONObject().apply { + put("parts", JSONArray().apply { + put(JSONObject().apply { put("text", systemMsg.content) }) + }) + }) + } + + // Conversation turns (skip system — handled above) + put("contents", JSONArray().apply { + for (msg in messages) { + when (msg.role) { + "system" -> { /* handled via systemInstruction */ } + + "user" -> put(JSONObject().apply { + put("role", "user") + put("parts", JSONArray().apply { + put(JSONObject().apply { put("text", msg.content ?: "") }) + }) + }) + + "assistant" -> { + if (msg.toolCalls != null) { + // Model requested tool calls + put(JSONObject().apply { + put("role", "model") + put("parts", JSONArray().apply { + for (tc in msg.toolCalls) { + put(JSONObject().apply { + put("functionCall", JSONObject().apply { + put("name", tc.name) + put("args", safeJsonObject(tc.argsJson)) + }) + }) + } + }) + }) + } else { + put(JSONObject().apply { + put("role", "model") + put("parts", JSONArray().apply { + put(JSONObject().apply { put("text", msg.content ?: "") }) + }) + }) + } + } + + "tool" -> { + // Tool result — Gemini expects a user-role functionResponse + val fnName = msg.toolName + ?: toolCallIdToName[msg.toolCallId] + ?: "unknown" + put(JSONObject().apply { + put("role", "user") + put("parts", JSONArray().apply { + put(JSONObject().apply { + put("functionResponse", JSONObject().apply { + put("name", fnName) + put("response", JSONObject().apply { + put("result", msg.content ?: "") + }) + }) + }) + }) + }) + } + } + } + }) + + // Tool declarations + if (tools.isNotEmpty()) { + put("tools", JSONArray().apply { + put(JSONObject().apply { + put("functionDeclarations", JSONArray().apply { + for (tool in tools) { + put(JSONObject().apply { + put("name", tool.name) + put("description", tool.description) + if (tool.parametersJson != null) { + put("parameters", safeJsonObject(tool.parametersJson)) + } + }) + } + }) + }) + }) + put("toolConfig", JSONObject().apply { + put("functionCallingConfig", JSONObject().apply { + put("mode", "AUTO") + }) + }) + } + + put("generationConfig", JSONObject().apply { + put("maxOutputTokens", maxTokens) + put("temperature", temperature.toDouble()) + }) + }.toString() + } + + private fun parseChatResponse(json: String): OnDeviceModel.ChatResult { + return try { + val candidate = JSONObject(json) + .getJSONArray("candidates") + .getJSONObject(0) + val content = candidate.getJSONObject("content") + val parts = content.getJSONArray("parts") + val finishReason = candidate.optString("finishReason", "STOP") + + // Check if any part is a function call + val toolCalls = mutableListOf() + val textBuilder = StringBuilder() + + for (i in 0 until parts.length()) { + val part = parts.getJSONObject(i) + when { + part.has("functionCall") -> { + val fc = part.getJSONObject("functionCall") + toolCalls += OnDeviceModel.ToolCallData( + id = "call_${System.currentTimeMillis()}_$i", + name = fc.getString("name"), + argsJson = fc.optJSONObject("args")?.toString() ?: "{}" + ) + } + part.has("text") -> textBuilder.append(part.getString("text")) + } + } + + when { + toolCalls.isNotEmpty() -> OnDeviceModel.ChatResult( + toolCalls = toolCalls, + finishReason = "tool_calls" + ) + else -> OnDeviceModel.ChatResult( + content = textBuilder.toString(), + finishReason = if (finishReason == "MAX_TOKENS") "length" else "stop" + ) + } + } catch (e: Exception) { + throw OnDeviceModel.InferenceException("Failed to parse Gemini chat response: ${e.message}", e) + } + } + + /** Parse a JSON string into a JSONObject, returning an empty object on failure. */ + private fun safeJsonObject(json: String): JSONObject = try { + JSONObject(json) + } catch (_: Exception) { + JSONObject() + } + private fun buildThinkingRequestBody(prompt: String, maxTokens: Int, thinkingBudget: Int): String { return JSONObject().apply { put("contents", JSONArray().apply { diff --git a/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt b/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt index 2dc4d05..6b5b8c5 100644 --- a/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt +++ b/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt @@ -49,8 +49,42 @@ interface OnDeviceModel { thinkingBudget: Int = 8192 ): ThinkingResult = ThinkingResult(thinking = "", response = generate(prompt, maxTokens)) + /** + * Multi-turn conversation with optional tool/function calling. + * + * Accepts the full message history so the model can reference prior turns, + * and an optional list of tool definitions the model may invoke. + * + * Returns a [ChatResult] which is either: + * - A text reply ([ChatResult.content] set, [ChatResult.toolCalls] null) + * - A tool invocation ([ChatResult.toolCalls] set, [ChatResult.content] null) + * + * The default implementation flattens the conversation to a plain prompt + * and calls [generate], so backends without native tool support still work + * (they just won't invoke tools). + */ + suspend fun chat( + messages: List, + tools: List = emptyList(), + maxTokens: Int = 1024, + temperature: Float = 0.7f + ): ChatResult { + val prompt = messages.joinToString("\n") { msg -> + when (msg.role) { + "system" -> "System: ${msg.content.orEmpty()}" + "user" -> "User: ${msg.content.orEmpty()}" + "assistant" -> "Assistant: ${msg.content.orEmpty()}" + "tool" -> "Tool result: ${msg.content.orEmpty()}" + else -> "${msg.role}: ${msg.content.orEmpty()}" + } + } + "\nAssistant:" + return ChatResult(content = generate(prompt, maxTokens, temperature)) + } + fun close() + // ── Supporting types ────────────────────────────────────────────────────── + data class ThinkingResult( /** The model's internal reasoning trace (may be empty for non-thinking backends). */ val thinking: String, @@ -58,6 +92,41 @@ interface OnDeviceModel { val response: String ) + /** A single message in a multi-turn conversation passed to [chat]. */ + data class ConvMessage( + val role: String, + /** Text content — null when role=assistant and tool_calls is set. */ + val content: String?, + val toolCalls: List? = null, + /** For role=tool messages: the tool_call id being responded to. */ + val toolCallId: String? = null, + /** For role=tool messages: the function name (needed by Gemini). */ + val toolName: String? = null + ) + + /** A tool/function definition passed to [chat]. */ + data class ToolDef( + val name: String, + val description: String, + /** JSON Schema for the function parameters, as a raw JSON string. */ + val parametersJson: String? + ) + + /** A tool call the model wants to make. */ + data class ToolCallData( + val id: String, + val name: String, + /** Arguments as a JSON-encoded string. */ + val argsJson: String + ) + + /** Result from [chat]. Exactly one of content/toolCalls will be non-null. */ + data class ChatResult( + val content: String? = null, + val toolCalls: List? = null, + val finishReason: String = if (toolCalls != null) "tool_calls" else "stop" + ) + companion object { private const val TAG = "OnDeviceModel" diff --git a/app/src/main/java/com/pixel10/ai/server/AIApiServer.kt b/app/src/main/java/com/pixel10/ai/server/AIApiServer.kt index 85c89ff..60814be 100644 --- a/app/src/main/java/com/pixel10/ai/server/AIApiServer.kt +++ b/app/src/main/java/com/pixel10/ai/server/AIApiServer.kt @@ -13,7 +13,14 @@ import java.util.concurrent.atomic.AtomicLong * Embedded HTTP server that exposes the on-device AI model as a REST API. * * Provides OpenAI-compatible endpoints so existing tools (curl, Python openai - * library, etc.) can talk to this phone as if it were a cloud AI endpoint. + * library, OpenClaw, Open WebUI, etc.) can talk to this phone as if it were a + * cloud AI endpoint. + * + * Supported features: + * - Multi-turn chat (full message history forwarded to the model) + * - Tool / function calling (agents can invoke tools and receive results) + * - Streaming (SSE) + * - Thinking mode (extended reasoning via Gemini 2.5 Flash) * * Usage from any device on the same network: * curl http://:8080/v1/chat/completions \ @@ -39,7 +46,6 @@ class AIApiServer( log("[$count] ${method.name} $uri") return try { - // Add CORS headers to all responses when { method == Method.OPTIONS -> corsPreflightResponse() uri == "/" || uri == "/health" -> handleHealth() @@ -56,7 +62,7 @@ class AIApiServer( } } - // ── Endpoint Handlers ────────────────────────────────────────────── + // ── Endpoint Handlers ────────────────────────────────────────────────────── private fun handleHealth(): Response { val status = ServerStatus( @@ -81,62 +87,88 @@ class AIApiServer( return errorResponse(400, "messages array is required and must not be empty") } - val prompt = buildChatPrompt(request.messages) - val useThinking = request.thinking_budget > 0 || request.model.contains("think") - val budget = if (request.thinking_budget > 0) request.thinking_budget else 8192 - - log("Chat prompt (${request.messages.size} messages, ${prompt.length} chars, thinking=$useThinking)") - - if (request.stream && !useThinking) { - return handleStreamingResponse(prompt, request) - } - val id = "chatcmpl-${UUID.randomUUID().toString().take(8)}" + val useThinking = request.thinking_budget > 0 || request.model.contains("think") + val hasTools = !request.tools.isNullOrEmpty() + log("Chat: ${request.messages.size} messages, tools=${request.tools?.size ?: 0}, thinking=$useThinking, stream=${request.stream}") + + // ── Thinking mode ────────────────────────────────────────────────────── if (useThinking) { + val prompt = buildFlatPrompt(request.messages) + val budget = if (request.thinking_budget > 0) request.thinking_budget else 8192 val result = runBlocking { model.generateWithThinking(prompt, request.max_tokens, budget) } log("Thinking: ${result.thinking.take(80)}...") log("Response: ${result.response.take(80)}...") + return jsonResponse(200, gson.toJson(ChatResponse( + id = id, model = request.model, + choices = listOf(Choice( + message = Message(role = "assistant", content = result.response), + thinking = result.thinking.ifEmpty { null } + )), + usage = buildUsage(result.response, result.response) + ))) + } - val chatResponse = ChatResponse( - id = id, - model = request.model, - choices = listOf( - Choice( - message = Message(role = "assistant", content = result.response), - thinking = result.thinking.ifEmpty { null } - ) - ), - usage = Usage( - prompt_tokens = estimateTokens(prompt), - completion_tokens = estimateTokens(result.response), - total_tokens = estimateTokens(prompt) + estimateTokens(result.response) + // ── Tool calling / multi-turn chat ───────────────────────────────────── + if (hasTools || request.messages.size > 1 || request.messages.any { it.role == "system" }) { + val convMessages = request.messages.map { it.toConvMessage() } + val toolDefs = request.tools?.map { it.toToolDef() } ?: emptyList() + + val result = runBlocking { + model.chat(convMessages, toolDefs, request.max_tokens, request.temperature) + } + + if (result.toolCalls != null) { + // Model wants to call tools — return tool_calls in the assistant message + log("Tool calls: ${result.toolCalls.joinToString { it.name }}") + val assistantMsg = Message( + role = "assistant", + content = null, + tool_calls = result.toolCalls.map { tc -> + ToolCall( + id = tc.id, + function = FunctionCallDetail(name = tc.name, arguments = tc.argsJson) + ) + } ) - ) - return jsonResponse(200, gson.toJson(chatResponse)) + return jsonResponse(200, gson.toJson(ChatResponse( + id = id, model = request.model, + choices = listOf(Choice( + message = assistantMsg, + finish_reason = "tool_calls" + )), + usage = buildUsage("", "") + ))) + } + + val responseText = result.content ?: "" + log("Response: ${responseText.take(80)}...") + return jsonResponse(200, gson.toJson(ChatResponse( + id = id, model = request.model, + choices = listOf(Choice(message = Message(role = "assistant", content = responseText))), + usage = buildUsage(buildFlatPrompt(request.messages), responseText) + ))) + } + + // ── Simple single-turn (fast path) ───────────────────────────────────── + val prompt = buildFlatPrompt(request.messages) + + if (request.stream) { + return handleStreamingResponse(id, prompt, request) } - // Fast (non-streaming) generation val responseText = runBlocking { model.generate(prompt, request.max_tokens, request.temperature) } log("Response: ${responseText.take(80)}...") - - val chatResponse = ChatResponse( - id = id, - model = request.model, - choices = listOf( - Choice(message = Message(role = "assistant", content = responseText)) - ), - usage = Usage( - prompt_tokens = estimateTokens(prompt), - completion_tokens = estimateTokens(responseText), - total_tokens = estimateTokens(prompt) + estimateTokens(responseText) - ) - ) - return jsonResponse(200, gson.toJson(chatResponse)) + return jsonResponse(200, gson.toJson(ChatResponse( + id = id, model = request.model, + choices = listOf(Choice(message = Message(role = "assistant", content = responseText))), + usage = buildUsage(prompt, responseText) + ))) } private fun handleCompletions(session: IHTTPSession): Response { @@ -152,37 +184,21 @@ class AIApiServer( val responseText = runBlocking { model.generate(prompt, request.max_tokens, request.temperature) } - log("Response: ${responseText.take(80)}...") - val chatResponse = ChatResponse( + return jsonResponse(200, gson.toJson(ChatResponse( id = "cmpl-${UUID.randomUUID().toString().take(8)}", - choices = listOf( - Choice( - message = Message(role = "assistant", content = responseText) - ) - ), - usage = Usage( - prompt_tokens = estimateTokens(prompt), - completion_tokens = estimateTokens(responseText), - total_tokens = estimateTokens(prompt) + estimateTokens(responseText) - ) - ) - - return jsonResponse(200, gson.toJson(chatResponse)) + model = request.model, + choices = listOf(Choice(message = Message(role = "assistant", content = responseText))), + usage = buildUsage(prompt, responseText) + ))) } - private fun handleStreamingResponse(prompt: String, request: ChatRequest): Response { - val id = "chatcmpl-${UUID.randomUUID().toString().take(8)}" - - // For streaming, collect all tokens then return as SSE-formatted response. - // NanoHTTPD doesn't natively support chunked streaming in a clean way, - // so we buffer and return the full SSE payload. + private fun handleStreamingResponse(id: String, prompt: String, request: ChatRequest): Response { val sseBuilder = StringBuilder() - // Initial role chunk val roleChunk = StreamChunk( - id = id, + id = id, model = request.model, choices = listOf(StreamChoice(delta = Delta(role = "assistant"))) ) sseBuilder.append("data: ${gson.toJson(roleChunk)}\n\n") @@ -190,16 +206,15 @@ class AIApiServer( val fullResponse = runBlocking { model.generateStreaming(prompt) { token -> val chunk = StreamChunk( - id = id, + id = id, model = request.model, choices = listOf(StreamChoice(delta = Delta(content = token))) ) sseBuilder.append("data: ${gson.toJson(chunk)}\n\n") } } - // Final done chunk val doneChunk = StreamChunk( - id = id, + id = id, model = request.model, choices = listOf(StreamChoice(delta = Delta(), finish_reason = "stop")) ) sseBuilder.append("data: ${gson.toJson(doneChunk)}\n\n") @@ -214,21 +229,51 @@ class AIApiServer( ) } - // ── Helpers ───────────────────────────────────────────────────────── + // ── Conversion helpers ───────────────────────────────────────────────────── - private fun buildChatPrompt(messages: List): String { + /** Flat prompt for simple / streaming calls (no tool use). */ + private fun buildFlatPrompt(messages: List): String { val sb = StringBuilder() for (msg in messages) { when (msg.role) { - "system" -> sb.append("System: ${msg.content}\n\n") - "user" -> sb.append("User: ${msg.content}\n\n") - "assistant" -> sb.append("Assistant: ${msg.content}\n\n") + "system" -> sb.append("System: ${msg.content.orEmpty()}\n\n") + "user" -> sb.append("User: ${msg.content.orEmpty()}\n\n") + "assistant" -> sb.append("Assistant: ${msg.content.orEmpty()}\n\n") } } sb.append("Assistant: ") return sb.toString() } + private fun Message.toConvMessage(): OnDeviceModel.ConvMessage = + OnDeviceModel.ConvMessage( + role = role, + content = content, + toolCalls = tool_calls?.map { tc -> + OnDeviceModel.ToolCallData( + id = tc.id, + name = tc.function.name, + argsJson = tc.function.arguments + ) + }, + toolCallId = tool_call_id + ) + + private fun Tool.toToolDef(): OnDeviceModel.ToolDef = + OnDeviceModel.ToolDef( + name = function.name, + description = function.description, + parametersJson = function.parameters?.toString() + ) + + private fun buildUsage(prompt: String, response: String) = Usage( + prompt_tokens = estimateTokens(prompt), + completion_tokens = estimateTokens(response), + total_tokens = estimateTokens(prompt) + estimateTokens(response) + ) + + // ── Utilities ────────────────────────────────────────────────────────────── + private fun readBody(session: IHTTPSession): String { val contentLength = session.headers["content-length"]?.toIntOrNull() ?: 0 val buffer = ByteArray(contentLength) @@ -236,10 +281,7 @@ class AIApiServer( return String(buffer) } - private fun estimateTokens(text: String): Int { - // Rough estimate: ~4 characters per token - return (text.length / 4).coerceAtLeast(1) - } + private fun estimateTokens(text: String): Int = (text.length / 4).coerceAtLeast(1) private fun jsonResponse(statusCode: Int, json: String): Response { val status = when (statusCode) { @@ -251,16 +293,11 @@ class AIApiServer( return newFixedLengthResponse(status, "application/json", json) } - private fun errorResponse(statusCode: Int, message: String): Response { - val error = ErrorResponse( - ErrorDetail(message = message, code = statusCode) - ) - return jsonResponse(statusCode, gson.toJson(error)) - } + private fun errorResponse(statusCode: Int, message: String): Response = + jsonResponse(statusCode, gson.toJson(ErrorResponse(ErrorDetail(message = message, code = statusCode)))) - private fun corsPreflightResponse(): Response { - return newFixedLengthResponse(Response.Status.OK, MIME_PLAINTEXT, "") - } + private fun corsPreflightResponse(): Response = + newFixedLengthResponse(Response.Status.OK, MIME_PLAINTEXT, "") private fun addCorsHeaders(response: Response) { response.addHeader("Access-Control-Allow-Origin", "*") diff --git a/app/src/main/java/com/pixel10/ai/server/ApiModels.kt b/app/src/main/java/com/pixel10/ai/server/ApiModels.kt index cb32b42..b47995f 100644 --- a/app/src/main/java/com/pixel10/ai/server/ApiModels.kt +++ b/app/src/main/java/com/pixel10/ai/server/ApiModels.kt @@ -1,12 +1,19 @@ package com.pixel10.ai.server +import com.google.gson.JsonObject import com.google.gson.annotations.SerializedName /** * Request/response models for the AI API. - * Follows an OpenAI-compatible schema for easy integration. + * Follows the OpenAI Chat Completions schema for easy integration with + * any OpenAI-compatible client (OpenClaw, LM Studio, Open WebUI, etc.). + * + * Tool/function calling is fully supported so coding agents can invoke + * tools (read_file, run_shell, etc.) through the standard OpenAI tool-use flow. */ +// ── Requests ────────────────────────────────────────────────────────────────── + data class ChatRequest( val model: String = "pixel10-fast", val messages: List = emptyList(), @@ -15,14 +22,51 @@ data class ChatRequest( val temperature: Float = 0.7f, val stream: Boolean = false, /** Thinking token budget. 0 = fast (no thinking). >0 = thinking mode. */ - val thinking_budget: Int = 0 + val thinking_budget: Int = 0, + /** Tool/function definitions available to the model. */ + val tools: List? = null, + /** "auto" | "none" | "required" — defaults to "auto" when tools are provided. */ + val tool_choice: String? = null ) data class Message( val role: String = "user", - val content: String = "" + /** Text content. Null when role=assistant and the model is calling a tool. */ + val content: String? = null, + /** Set by the model when it wants to call one or more tools. */ + val tool_calls: List? = null, + /** Set on role=tool messages — references the tool_call.id being responded to. */ + val tool_call_id: String? = null ) +// ── Tool / Function Calling ─────────────────────────────────────────────────── + +data class Tool( + val type: String = "function", + val function: ToolFunction +) + +data class ToolFunction( + val name: String, + val description: String = "", + /** JSON Schema object describing the function parameters. */ + val parameters: JsonObject? = null +) + +data class ToolCall( + val id: String, + val type: String = "function", + val function: FunctionCallDetail +) + +data class FunctionCallDetail( + val name: String, + /** Arguments as a JSON-encoded string (matches OpenAI spec). */ + val arguments: String +) + +// ── Responses ───────────────────────────────────────────────────────────────── + data class ChatResponse( val id: String, @SerializedName("object") @@ -36,8 +80,9 @@ data class ChatResponse( data class Choice( val index: Int = 0, val message: Message, + /** "stop" | "tool_calls" | "length" */ val finish_reason: String = "stop", - /** Non-standard: reasoning/thinking trace, present only when thinking mode is used. */ + /** Non-standard: reasoning trace, present only in thinking mode. */ val thinking: String? = null ) @@ -47,12 +92,14 @@ data class Usage( val total_tokens: Int ) +// ── Streaming ───────────────────────────────────────────────────────────────── + data class StreamChunk( val id: String, @SerializedName("object") val objectType: String = "chat.completion.chunk", val created: Long = System.currentTimeMillis() / 1000, - val model: String = "pixel10-on-device", + val model: String = "pixel10-fast", val choices: List ) @@ -67,6 +114,8 @@ data class Delta( val content: String? = null ) +// ── Models List ─────────────────────────────────────────────────────────────── + data class ModelInfo( val id: String, @SerializedName("object") @@ -81,15 +130,17 @@ data class ModelList( val data: List = listOf( ModelInfo( id = "pixel10-fast", - description = "Fast inference — no reasoning trace" + description = "Fast inference via Gemini 2.0 Flash — low latency, tool calling supported" ), ModelInfo( id = "pixel10-thinking", - description = "Thinking mode — includes step-by-step reasoning before answering" + description = "Thinking mode via Gemini 2.5 Flash — step-by-step reasoning before answering" ) ) ) +// ── Health / Errors ─────────────────────────────────────────────────────────── + data class ErrorResponse( val error: ErrorDetail ) @@ -107,7 +158,7 @@ data class ServerStatus( val uptime_seconds: Long, val requests_served: Long, val endpoints: List = listOf( - "POST /v1/chat/completions", + "POST /v1/chat/completions (tools, streaming, thinking supported)", "POST /v1/completions", "GET /v1/models", "GET /health",