Add tool/function calling — enables use as an agent brain

OnDeviceModel gains a chat() method accepting the full message history and
a list of ToolDef entries. Returns ChatResult which is either a text reply or
a list of ToolCallData the model wants to invoke. Default impl flattens
messages to a prompt so Nano and MediaPipe backends work unchanged.

GeminiCloudModel overrides chat() with native Gemini function calling:
- Converts OpenAI tools to Gemini functionDeclarations
- Handles system messages via Gemini's systemInstruction field
- Converts multi-turn history including assistant tool_calls and tool results
  (role=tool → Gemini functionResponse with name resolved from prior turns)
- Parses functionCall parts in the response and returns ToolCallData list
- Falls back to text content when no function call is present

AIApiServer routes to chat() when tools are provided or the conversation
has more than one turn. Returns finish_reason=tool_calls and the tool_calls
array in the assistant message so OpenClaw / any OpenAI-compatible agent
client can execute tools and feed results back.

ApiModels updated: Message.content nullable, tool_calls and tool_call_id
added to Message, Tool/ToolFunction/ToolCall/FunctionCallDetail added,
tools and tool_choice added to ChatRequest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexpolo1
2026-02-28 21:45:31 +01:00
parent 375aaad0fa
commit 4a79542454
4 changed files with 453 additions and 97 deletions

View File

@@ -104,6 +104,37 @@ class GeminiCloudModel(private val apiKey: String) : OnDeviceModel {
}
}
override suspend fun chat(
messages: List<OnDeviceModel.ConvMessage>,
tools: List<OnDeviceModel.ToolDef>,
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<OnDeviceModel.ConvMessage>,
tools: List<OnDeviceModel.ToolDef>,
maxTokens: Int,
temperature: Float
): String {
// Build a map from tool_call_id → function name so we can label tool results
val toolCallIdToName = mutableMapOf<String, String>()
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<OnDeviceModel.ToolCallData>()
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 {

View File

@@ -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<ConvMessage>,
tools: List<ToolDef> = 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<ToolCallData>? = 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<ToolCallData>? = null,
val finishReason: String = if (toolCalls != null) "tool_calls" else "stop"
)
companion object {
private const val TAG = "OnDeviceModel"

View File

@@ -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://<phone-ip>: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)}...")
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)
)
)
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 = result.response),
thinking = result.thinking.ifEmpty { null }
)),
usage = buildUsage(result.response, 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(
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<Message>): String {
/** Flat prompt for simple / streaming calls (no tool use). */
private fun buildFlatPrompt(messages: List<Message>): 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", "*")

View File

@@ -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<Message> = 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<Tool>? = 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<ToolCall>? = 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<StreamChoice>
)
@@ -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<ModelInfo> = 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<String> = listOf(
"POST /v1/chat/completions",
"POST /v1/chat/completions (tools, streaming, thinking supported)",
"POST /v1/completions",
"GET /v1/models",
"GET /health",