Files
markbaseengine/Sources/MarkBaseServer/SimpleServerApp.swift
T
MarkBase Admin 85dd87e28a
CI / build (push) Waiting to run
CI / unit-tests (push) Blocked by required conditions
CI / lint (push) Blocked by required conditions
v2: add embedding tests, multilingual embedding support
2026-07-06 08:01:52 +08:00

385 lines
18 KiB
Swift

import Foundation
import MarkBase
import Hummingbird
struct SimpleServerApp {
static func main() async throws {
// 支持命令行參數選擇模型
let args = CommandLine.arguments
let modelName = args.count > 1 ? args[1] : "E4B-MarkBase"
let port = args.count > 2 ? Int(args[2]) ?? 8080 : 8080
let modelPath = NSString(string: "~/MarkBaseEngine/models/\(modelName)").expandingTildeInPath
print("═══════════════════════════════════════════════════════════════════")
print(" MarkBaseEngine Server")
print("═══════════════════════════════════════════════════════════════════")
print(" Model: \(modelName)")
print(" Port: \(port)")
print(" Path: \(modelPath)")
print("")
let engine = try MarkBaseEngine(autoCompile: true)
let model = try E4BModel(modelDir: modelPath, engine: engine, maxContextLength: 512)
let tokenizer = try TokenizerFactory.load(modelDir: modelPath)
let generator = StreamingGenerator(model: model, tokenizer: tokenizer, engine: engine)
let embeddingModel = try TextEmbeddingModel(modelDir: modelPath, engine: engine, config: TextEmbeddingConfig())
print("✓ E4B loaded (\(model.numHiddenLayers) layers)")
let router = Router()
let layers = model.numHiddenLayers
@Sendable func helpJSON() -> String {
return """
{
"server": {
"name": "MarkBaseEngine",
"version": "1.0.0",
"model": "E4B (\(layers) layers)",
"framework": "Hummingbird 2.x + Metal GPU",
"platform": "Apple Silicon",
"base_url": "http://localhost:8080"
},
"endpoints": [
{
"method": "GET",
"path": "/",
"summary": "API help and documentation",
"content_type": "application/json",
"curl": "curl http://localhost:8080/"
},
{
"method": "GET",
"path": "/help",
"summary": "API help and documentation (alias)",
"content_type": "application/json",
"curl": "curl http://localhost:8080/help"
},
{
"method": "GET",
"path": "/health",
"summary": "Health check - returns server status and model information",
"responses": {
"200": {
"description": "Server is healthy",
"body": "OK"
}
},
"curl": "curl http://localhost:8080/health"
},
{
"method": "GET",
"path": "/v1/models",
"summary": "List available models (OpenAI-compatible)",
"responses": {
"200": {
"description": "Model list",
"body": {
"id": "e4b",
"object": "model",
"owned_by": "markbase"
}
}
},
"curl": "curl http://localhost:8080/v1/models"
},
{
"method": "POST",
"path": "/v1/chat/completions",
"summary": "Chat completion (OpenAI-compatible API)",
"description": "Generate chat responses using the E4B model. Supports text-only input via messages array.",
"request": {
"body": {
"messages": [
{"role": "user", "content": "Hello"}
],
"max_tokens": 100,
"temperature": 0.7,
"top_p": 0.95,
"top_k": 40,
"stream": false
}
},
"responses": {
"200": {
"description": "Successful completion",
"body": {
"id": "chatcmpl-xxx",
"object": "chat.completion",
"created": 1234567890,
"model": "e4b",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello! How can I help you?"
},
"finish_reason": "stop"
}
]
}
},
"400": {
"description": "Invalid request - missing or malformed messages field"
}
},
"parameters": [
{"name": "messages", "type": "array", "required": true, "description": "Array of message objects with 'role' (system/user/assistant/tool) and 'content' (string). Supports 'tool_calls' in assistant messages and 'tool_call_id'/'name' in tool messages."},
{"name": "max_tokens", "type": "integer", "required": false, "default": 100, "description": "Maximum number of tokens to generate (1-4096)"},
{"name": "temperature", "type": "float", "required": false, "default": 0.7, "description": "Sampling temperature (0.0-2.0)"},
{"name": "top_p", "type": "float", "required": false, "description": "Nucleus sampling threshold (0.0-1.0)"},
{"name": "top_k", "type": "integer", "required": false, "description": "Top-k sampling count"},
{"name": "tools", "type": "array", "required": false, "description": "Array of tool definitions (OpenAI format) for function calling. Each tool has 'type' (function) and 'function' with 'name', 'description', 'parameters'."},
{"name": "stream", "type": "boolean", "required": false, "default": false, "description": "Enable streaming response (not yet implemented)"}
],
"curl": "curl -X POST http://localhost:8080/v1/chat/completions -H 'Content-Type: application/json' -d '{\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}],\"max_tokens\":100}'",
"examples": [
{
"title": "Text completion",
"curl": "curl -X POST http://localhost:8080/v1/chat/completions -H 'Content-Type: application/json' -d '{\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}],\"max_tokens\":100}'"
},
{
"title": "Function calling",
"curl": "curl -X POST http://localhost:8080/v1/chat/completions -H 'Content-Type: application/json' -d '{\"messages\":[{\"role\":\"user\",\"content\":\"Search for cats\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"find_file\",\"description\":\"Search for files\",\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"}},\"required\":[\"query\"]}}}],\"max_tokens\":200}'"
}
]
}
],
"schema": {
"content_type": "application/json",
"error_format": {
"error": {
"message": "Error description",
"type": "error_type",
"code": 400
}
},
"error_codes": [
{"code": 400, "type": "invalid_request_error", "description": "Invalid request parameters"},
{"code": 404, "type": "not_found_error", "description": "Resource not found"},
{"code": 500, "type": "server_error", "description": "Internal server error"}
]
},
"documentation": {
"api_spec": "docs/API_SPEC.md",
"api_reference": "docs/API.md",
"deployment": "docs/DEPLOYMENT.md",
"performance": "docs/PERFORMANCE.md"
},
"notes": [
"All responses are in JSON format",
"Text generation only (multimodal not yet supported via API)",
"E4B model with 42 layers, ~4B parameters",
"For multimodal (vision/audio) support, use the MarkBase Swift library directly",
"Streaming support is planned but not yet implemented",
"Function calling uses native Gemma 4 special tokens",
"Messages can include tool_calls (assistant) and tool responses (tool role) for multi-turn function calling",
"Text embeddings available via /v1/embeddings endpoint (OpenAI-compatible)"
]
}
"""
}
@Sendable func healthResponse() -> String {
return "{\"status\":\"healthy\",\"model\":\"e4b\",\"layers\":\(layers)}"
}
router.get("/") { _, _ in
return helpJSON()
}
router.get("/help") { _, _ in
return helpJSON()
}
router.get("/health") { _, _ in
return healthResponse()
}
router.get("/v1/models") { _, _ in
return "{\"id\":\"e4b\",\"object\":\"model\",\"owned_by\":\"markbase\"}"
}
router.post("/v1/chat/completions") { request, _ in
let buffer = try await request.body.collect(upTo: .max)
let data = Data(buffer: buffer)
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let messages = json["messages"] as? [[String: Any]] else {
return "{\"error\":\"invalid request\",\"type\":\"invalid_request_error\",\"code\":400}"
}
let maxTokens = (json["max_tokens"] as? Int) ?? 100
let temperature = Float(json["temperature"] as? Double ?? 0.7)
let topK = json["top_k"] as? Int
let topP = (json["top_p"] as? Double).map { Float($0) }
let tools = json["tools"] as? [[String: Any]]
let prompt = Gemma4Format.buildChatPrompt(messages: messages, tools: tools)
let promptTokens = tokenizer.encode(text: prompt)
let config = GenerationConfig(
maxTokens: maxTokens,
temperature: temperature,
topK: topK,
topP: topP
)
let generatedTokens = try generator.generateTokens(promptTokens: promptTokens, config: config)
let id = UUID().uuidString
let ts = Int(Date().timeIntervalSince1970)
// Check for tool calls (token ID 48 = <|tool_call>)
if generatedTokens.contains(48) {
var results: [ToolCallResult] = []
var i = 0
while i < generatedTokens.count {
if generatedTokens[i] == 48 {
i += 1
var callTokens: [Int] = []
while i < generatedTokens.count && generatedTokens[i] != 49 {
callTokens.append(generatedTokens[i])
i += 1
}
if i < generatedTokens.count && generatedTokens[i] == 49 {
i += 1
}
let callText = tokenizer.decode(tokens: callTokens)
if let colonIdx = callText.firstIndex(of: ":"),
let braceIdx = callText.firstIndex(of: "{"),
let endBrace = callText.lastIndex(of: "}") {
let name = String(callText[callText.index(after: colonIdx)..<braceIdx]).trimmingCharacters(in: .whitespaces)
let rawArgs = String(callText[callText.index(after: braceIdx)..<endBrace])
let jsonArgs = Gemma4Format.gemma4ArgsToJSON(rawArgs)
results.append(ToolCallResult(
id: "call_\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(16))",
function: ToolCallFunction(name: name, arguments: jsonArgs)
))
}
} else {
i += 1
}
}
let encoder = JSONEncoder()
let callsData = try encoder.encode(results)
let callsStr = String(data: callsData, encoding: .utf8) ?? "[]"
return """
{"id":"chatcmpl-\(id)","object":"chat.completion","created":\(ts),"model":"e4b","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":\(callsStr)},"finish_reason":"tool_calls"}]}
"""
} else {
let response = tokenizer.decode(tokens: generatedTokens)
let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines)
let escaped = trimmed
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
.replacingOccurrences(of: "\t", with: "\\t")
return """
{"id":"chatcmpl-\(id)","object":"chat.completion","created":\(ts),"model":"e4b","choices":[{"index":0,"message":{"role":"assistant","content":"\(escaped)"},"finish_reason":"stop"}]}
"""
}
}
router.post("/v1/embeddings") { request, _ in
let buffer = try await request.body.collect(upTo: .max)
let data = Data(buffer: buffer)
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let input = json["input"] else {
return "{\"error\":\"invalid request\",\"type\":\"invalid_request_error\",\"code\":400,\"message\":\"missing 'input' field\"}"
}
let modelId = (json["model"] as? String) ?? "e4b"
let encodingFormat = (json["encoding_format"] as? String) ?? "float"
let inputs: [String]
if let str = input as? String {
inputs = [str]
} else if let arr = input as? [String] {
inputs = arr
} else {
return "{\"error\":\"invalid request\",\"type\":\"invalid_request_error\",\"code\":400,\"message\":\"'input' must be string or array of strings\"}"
}
var embeddings: [[String: Any]] = []
for (i, text) in inputs.enumerated() {
let t0 = Date()
let embedding = try embeddingModel.embed(text: text)
let duration = Date().timeIntervalSince(t0)
let embeddingData: [String: Any]
if encodingFormat == "base64" {
let base64 = embedding.withUnsafeBytes { Data($0).base64EncodedString() }
embeddingData = [
"object": "embedding",
"index": i,
"embedding": base64,
"usage_ms": Int(duration * 1000)
]
} else {
embeddingData = [
"object": "embedding",
"index": i,
"embedding": embedding,
"usage_ms": Int(duration * 1000)
]
}
embeddings.append(embeddingData)
}
let id = UUID().uuidString
let ts = Int(Date().timeIntervalSince1970)
let totalTokens = inputs.reduce(0) { $0 + tokenizer.encode(text: $1).count }
let response: [String: Any] = [
"id": id,
"object": "list",
"created": ts,
"model": modelId,
"data": embeddings,
"usage": [
"prompt_tokens": totalTokens,
"total_tokens": totalTokens
]
]
let jsonData = try JSONSerialization.data(withJSONObject: response)
return String(data: jsonData, encoding: .utf8) ?? "{}"
}
let app = Application(
router: router,
configuration: .init(address: .hostname("0.0.0.0", port: port))
)
print("Server starting on port \(port)...")
print("Endpoints:")
print(" GET / - API help")
print(" GET /help - API help")
print(" GET /health - Health check")
print(" GET /v1/models - Model list")
print(" POST /v1/chat/completions - Chat completion")
print(" POST /v1/embeddings - Text embeddings")
print("")
print("Model: \(modelName)")
if modelName.contains("E4B") {
print(" ⚠️ E4B is multimodal (Vision + Audio + Text)")
print(" For text-only, use: 12B-it-MLX-8bit or 31B-it-8bit")
} else {
print(" ✓ LLM for text generation")
}
print("")
try await app.run()
}
}