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:
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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", "*")
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user