From dcbcfb4441f1631822882be1919c4ae2f0f1d628 Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 18:19:31 +0100 Subject: [PATCH 01/18] feat: restructure project into swift package layout --- Formula/afm-api.rb | 13 +- Package.swift | 18 + README.md | 16 + .../ChatCompletionsCapability.swift | 87 +++ Sources/AFMAPI/config.swift | 42 ++ Sources/AFMAPI/main.swift | 26 + .../AFMAPI/models/FoundationModelBridge.swift | 60 ++ Sources/AFMAPI/openai_api/Types.swift | 132 ++++ Sources/AFMAPI/server/ConnectionHandler.swift | 136 ++++ Sources/AFMAPI/server/RequestProcessor.swift | 145 ++++ Sources/AFMAPI/support/HTTP.swift | 30 + Sources/AFMAPI/support/Logging.swift | 26 + bin/afm-api | 51 +- src/afm-api.swift | 667 ------------------ 14 files changed, 774 insertions(+), 675 deletions(-) create mode 100644 Package.swift create mode 100644 Sources/AFMAPI/capabilities/ChatCompletionsCapability.swift create mode 100644 Sources/AFMAPI/config.swift create mode 100644 Sources/AFMAPI/main.swift create mode 100644 Sources/AFMAPI/models/FoundationModelBridge.swift create mode 100644 Sources/AFMAPI/openai_api/Types.swift create mode 100644 Sources/AFMAPI/server/ConnectionHandler.swift create mode 100644 Sources/AFMAPI/server/RequestProcessor.swift create mode 100644 Sources/AFMAPI/support/HTTP.swift create mode 100644 Sources/AFMAPI/support/Logging.swift delete mode 100755 src/afm-api.swift diff --git a/Formula/afm-api.rb b/Formula/afm-api.rb index 1f05d8b..093944e 100644 --- a/Formula/afm-api.rb +++ b/Formula/afm-api.rb @@ -1,22 +1,21 @@ class AfmApi < Formula desc "OpenAI-compatible local server for Apple Foundation Model" homepage "https://github.com/tankibaj/apple-foundation-model-api" - url "https://github.com/tankibaj/apple-foundation-model-api/archive/refs/tags/v1.0.2.tar.gz" - sha256 "5b20b82be9707c0d7f40e54d796c8466d8b7394b820cdd0e321936a9afc5cb68" + url "https://github.com/tankibaj/apple-foundation-model-api/archive/refs/tags/v1.1.1.tar.gz" + sha256 "e8b84865dc93f1aeaf0a86a9d3149254a3be3640199c6a1e1fe36bd55c7fcfa6" license "MIT" depends_on :macos def install bin.install "bin/afm-api" - pkgshare.install "src/afm-api.swift" - - # Make the launcher reference the Homebrew-installed Swift source path. - inreplace bin/"afm-api", %r{\$SCRIPT_DIR/\.\./src/afm-api.swift}, "#{pkgshare}/afm-api.swift" + pkgshare.install "Package.swift" + pkgshare.install "Sources" end test do assert_predicate bin/"afm-api", :exist? - assert_predicate pkgshare/"afm-api.swift", :exist? + assert_predicate pkgshare/"Package.swift", :exist? + assert_predicate pkgshare/"Sources/AFMAPI/main.swift", :exist? end end diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..23842e1 --- /dev/null +++ b/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 5.10 +import PackageDescription + +let package = Package( + name: "AFMAPI", + platforms: [ + .macOS(.v14) + ], + products: [ + .executable(name: "afm-api-server", targets: ["AFMAPI"]) + ], + targets: [ + .executableTarget( + name: "AFMAPI", + path: "Sources/AFMAPI" + ) + ] +) diff --git a/README.md b/README.md index 8631f46..7442df0 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ afm-api --background # Run in background afm-api --status # Check status afm-api --logs # View logs afm-api --stop # Stop server +afm-api --rebuild # Rebuild server binary from source ``` --- @@ -213,6 +214,21 @@ cd /path/to/repo ./afm-api --logs --follow ``` +**SwiftPM layout (maintainers)** +```text +Package.swift +Sources/AFMAPI/openai_api +Sources/AFMAPI/capabilities +Sources/AFMAPI/models +Sources/AFMAPI/support +Sources/AFMAPI/server +``` + +**Build manually** +```bash +swift build -c release --product afm-api-server +``` + **Run tests** ```bash ./tests/test_function_call.sh diff --git a/Sources/AFMAPI/capabilities/ChatCompletionsCapability.swift b/Sources/AFMAPI/capabilities/ChatCompletionsCapability.swift new file mode 100644 index 0000000..3037a1f --- /dev/null +++ b/Sources/AFMAPI/capabilities/ChatCompletionsCapability.swift @@ -0,0 +1,87 @@ +import Foundation + +func encodeJSONObjectString(_ value: Any) -> String? { + guard JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value), + let text = String(data: data, encoding: .utf8) else { + return nil + } + return text +} + +func makeToolCall(name: String, argsObj: Any) -> ToolCall { + let argsString: String + if let s = argsObj as? String { + if let data = s.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data), + let encoded = encodeJSONObjectString(json) { + argsString = encoded + } else { + argsString = "{\"value\":\(String(describing: s).debugDescription)}" + } + } else if let encoded = encodeJSONObjectString(argsObj) { + argsString = encoded + } else { + argsString = "{\"value\":\(String(describing: argsObj).debugDescription)}" + } + return ToolCall( + id: "call_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))", + type: "function", + function: ToolCallFunction(name: name, arguments: argsString) + ) +} + +func normalizeConversation(_ messages: [ChatMessage]) -> String { + var lines: [String] = [] + for m in messages { + let text = m.content ?? "" + lines.append("[\(m.role)] \(text)") + } + return lines.joined(separator: "\n") +} + +func toolsPromptBlock(_ tools: [OpenAITool], toolChoice: ToolChoice) -> String { + guard !tools.isEmpty else { + return "You have no tools. Answer directly with plain text only." + } + + let toolsJSONData = try? JSONEncoder().encode(tools) + let toolsJSON = String(data: toolsJSONData ?? Data("[]".utf8), encoding: .utf8) ?? "[]" + + var choiceLine = "Tool choice: auto." + switch toolChoice { + case .auto: + choiceLine = "Tool choice: auto." + case .none: + choiceLine = "Tool choice: none. Never call a tool." + case .named(let name): + choiceLine = "Tool choice: required tool is \(name)." + } + + return """ +You may call tools. Available tools (OpenAI schema JSON): +\(toolsJSON) +\(choiceLine) + +Respond in STRICT JSON using exactly one of these formats: +1) {"type":"final","content":""} +2) {"type":"tool_calls","tool_calls":[{"name":"","arguments":{...}}]} + +Rules: +- Emit valid JSON only, no markdown fences. +- If any tool is needed, choose type=tool_calls. +- If tool_choice is none, choose type=final. +""" +} + +func normalizeJSONTextCandidate(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("```") else { return trimmed } + + let lines = trimmed.split(separator: "\n", omittingEmptySubsequences: false) + guard lines.count >= 3 else { return trimmed } + guard lines.first?.hasPrefix("```") == true, lines.last == "```" else { return trimmed } + + let body = lines.dropFirst().dropLast().joined(separator: "\n") + return String(body).trimmingCharacters(in: .whitespacesAndNewlines) +} diff --git a/Sources/AFMAPI/config.swift b/Sources/AFMAPI/config.swift new file mode 100644 index 0000000..93e3703 --- /dev/null +++ b/Sources/AFMAPI/config.swift @@ -0,0 +1,42 @@ +import Foundation + +struct AppConfig { + let host: String + let port: UInt16 + let modelName: String + let apiVersion: String +} + +func parseArg(_ name: String, default value: String) -> String { + let args = CommandLine.arguments + guard let idx = args.firstIndex(of: name), idx + 1 < args.count else { return value } + return args[idx + 1] +} + +func latestAPIVersion() -> String { + return "v1" +} + +func normalizeAPIVersion(_ version: String) -> String { + let v = version.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if v == "latest" || v.isEmpty { + return latestAPIVersion() + } + if v.hasPrefix("v") { + return version + } + return "v\(version)" +} + +func parseConfig() -> AppConfig { + let host = parseArg("--host", default: "127.0.0.1") + let portStr = parseArg("--port", default: "8000") + let modelName = parseArg("--model-name", default: "apple-foundation-model") + let apiVersion = normalizeAPIVersion(parseArg("--api-version", default: latestAPIVersion())) + let port = UInt16(portStr) ?? 8000 + return AppConfig(host: host, port: port, modelName: modelName, apiVersion: apiVersion) +} + +func apiBasePath(_ version: String) -> String { + return "/\(version)" +} diff --git a/Sources/AFMAPI/main.swift b/Sources/AFMAPI/main.swift new file mode 100644 index 0000000..b02b144 --- /dev/null +++ b/Sources/AFMAPI/main.swift @@ -0,0 +1,26 @@ +import Foundation +import Network + +let cfg = parseConfig() +let processor = RequestProcessor(cfg: cfg) + +let params = NWParameters.tcp + +guard let listenerPort = NWEndpoint.Port(rawValue: cfg.port) else { + fputs("Invalid port: \(cfg.port)\n", stderr) + exit(2) +} + +let listener = try NWListener(using: params, on: listenerPort) +listener.newConnectionHandler = { connection in + let handler = ConnectionHandler(connection: connection, processor: processor) { h in + ConnectionRegistry.remove(h) + } + ConnectionRegistry.add(handler) + handler.start() +} +listener.start(queue: .main) +logLine("afm-api server listening on http://\(cfg.host):\(cfg.port)") +logLine("API version: \(cfg.apiVersion)") +logLine("Model id: \(cfg.modelName)") +RunLoop.main.run() diff --git a/Sources/AFMAPI/models/FoundationModelBridge.swift b/Sources/AFMAPI/models/FoundationModelBridge.swift new file mode 100644 index 0000000..226984e --- /dev/null +++ b/Sources/AFMAPI/models/FoundationModelBridge.swift @@ -0,0 +1,60 @@ +import Foundation +import FoundationModels + +enum BridgeRuntimeError: Error { + case unsupportedOS +} + +func runModel(input: BridgeInput) async throws -> BridgeOutput { + guard #available(macOS 26.0, *) else { + throw BridgeRuntimeError.unsupportedOS + } + + let session = LanguageModelSession(model: .default) + let prompt = """ +You are a compatibility layer for OpenAI chat completions. +\(toolsPromptBlock(input.tools, toolChoice: input.tool_choice)) + +Conversation: +\(normalizeConversation(input.messages)) +""" + + let response = try await session.respond(to: prompt) + let text = response.content + let normalized = normalizeJSONTextCandidate(text) + + if let data = normalized.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = parsed["type"] as? String { + if type == "tool_calls", let tc = parsed["tool_calls"] as? [[String: Any]] { + let mapped: [ToolCall] = tc.compactMap { entry in + guard let name = entry["name"] as? String else { return nil } + let argsObj = entry["arguments"] ?? [:] + return makeToolCall(name: name, argsObj: argsObj) + } + + if !mapped.isEmpty { + return BridgeOutput(content: nil, tool_calls: mapped, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) + } + } + + if type == "final" { + if case let .named(requiredName) = input.tool_choice { + let argsObj = parsed["content"] ?? [:] + let tc = makeToolCall(name: requiredName, argsObj: argsObj) + return BridgeOutput(content: nil, tool_calls: [tc], prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) + } + if let content = parsed["content"] as? String { + return BridgeOutput(content: content, tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) + } + if let contentObj = parsed["content"] { + if let textContent = encodeJSONObjectString(contentObj) { + return BridgeOutput(content: textContent, tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) + } + return BridgeOutput(content: String(describing: contentObj), tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) + } + } + } + + return BridgeOutput(content: text, tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) +} diff --git a/Sources/AFMAPI/openai_api/Types.swift b/Sources/AFMAPI/openai_api/Types.swift new file mode 100644 index 0000000..434c89d --- /dev/null +++ b/Sources/AFMAPI/openai_api/Types.swift @@ -0,0 +1,132 @@ +import Foundation + +struct BridgeInput: Codable { + let model: String + let messages: [ChatMessage] + let tools: [OpenAITool] + let tool_choice: ToolChoice + let temperature: Double + let max_output_tokens: Int +} + +struct ChatMessage: Codable { + let role: String + let content: String? + let name: String? + let tool_calls: [ToolCall]? +} + +struct OpenAITool: Codable { + let type: String + let function: ToolSpec +} + +struct ToolSpec: Codable { + let name: String + let description: String? + let parameters: JSONValue? +} + +struct ToolCall: Codable { + let id: String + let type: String + let function: ToolCallFunction +} + +struct ToolCallFunction: Codable { + let name: String + let arguments: String +} + +enum ToolChoice: Codable { + case auto + case none + case named(String) + + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if let s = try? c.decode(String.self) { + switch s { + case "auto": self = .auto + case "none": self = .none + default: self = .named(s) + } + return + } + let obj = try c.decode([String: JSONValue].self) + if case let .string(name)? = obj["name"] { + self = .named(name) + } else if case let .object(fn)? = obj["function"], case let .string(name)? = fn["name"] { + self = .named(name) + } else { + self = .auto + } + } + + func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .auto: try c.encode("auto") + case .none: try c.encode("none") + case .named(let name): try c.encode(["name": name]) + } + } +} + +enum JSONValue: Codable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let s = try? container.decode(String.self) { + self = .string(s) + } else if let n = try? container.decode(Double.self) { + self = .number(n) + } else if let b = try? container.decode(Bool.self) { + self = .bool(b) + } else if let o = try? container.decode([String: JSONValue].self) { + self = .object(o) + } else if let a = try? container.decode([JSONValue].self) { + self = .array(a) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let s): try container.encode(s) + case .number(let n): try container.encode(n) + case .bool(let b): try container.encode(b) + case .object(let o): try container.encode(o) + case .array(let a): try container.encode(a) + case .null: try container.encodeNil() + } + } +} + +struct BridgeOutput: Codable { + let content: String? + let tool_calls: [ToolCall]? + let prompt_tokens: Int + let completion_tokens: Int + let total_tokens: Int +} + +struct ChatCompletionsRequest: Codable { + let model: String? + let messages: [ChatMessage] + let tools: [OpenAITool]? + let tool_choice: ToolChoice? + let temperature: Double? + let max_tokens: Int? + let stream: Bool? +} diff --git a/Sources/AFMAPI/server/ConnectionHandler.swift b/Sources/AFMAPI/server/ConnectionHandler.swift new file mode 100644 index 0000000..dfcc07a --- /dev/null +++ b/Sources/AFMAPI/server/ConnectionHandler.swift @@ -0,0 +1,136 @@ +import Foundation +import Network + +final class ConnectionHandler { + private let connection: NWConnection + private let processor: RequestProcessor + private let onClose: (ConnectionHandler) -> Void + private var buffer = Data() + private var expectedBodyLength: Int? + private var closed = false + + init(connection: NWConnection, processor: RequestProcessor, onClose: @escaping (ConnectionHandler) -> Void) { + self.connection = connection + self.processor = processor + self.onClose = onClose + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + if case .ready = state { + self?.receiveLoop() + } + } + connection.start(queue: .global()) + } + + private func receiveLoop() { + connection.receive(minimumIncompleteLength: 1, maximumLength: 1024 * 1024) { [weak self] data, _, isComplete, error in + guard let self else { return } + if let data, !data.isEmpty { + self.buffer.append(data) + if self.tryProcessIfComplete() { + return + } + } + + if isComplete || error != nil { + self.close() + return + } + + self.receiveLoop() + } + } + + private func tryProcessIfComplete() -> Bool { + guard let headerRange = buffer.range(of: Data("\r\n\r\n".utf8)) else { + return false + } + + let headerData = buffer.subdata(in: 0..= 2 else { + send(status: 400, body: ["error": ["message": "Invalid request line", "type": "invalid_request_error"]]) + return true + } + + let method = String(reqParts[0]) + let path = String(reqParts[1]) + + if expectedBodyLength == nil { + expectedBodyLength = 0 + for line in lines.dropFirst() { + if line.isEmpty { continue } + let parts = line.split(separator: ":", maxSplits: 1).map(String.init) + if parts.count == 2 && parts[0].lowercased() == "content-length" { + expectedBodyLength = Int(parts[1].trimmingCharacters(in: .whitespaces)) ?? 0 + } + } + } + + let bodyStart = headerRange.upperBound + let bodyLen = buffer.count - bodyStart + let needed = expectedBodyLength ?? 0 + guard bodyLen >= needed else { + return false + } + + let body = buffer.subdata(in: bodyStart..<(bodyStart + needed)) + + var responseData = Data() + let sem = DispatchSemaphore(value: 0) + Task { + responseData = await self.processor.handle(method: method, path: path, body: body) + sem.signal() + } + sem.wait() + self.connection.send(content: responseData, completion: .contentProcessed { _ in + self.close() + }) + + return true + } + + private func send(status: Int, body: [String: Any]) { + let response = httpResponse(status: status, body: jsonData(body)) + connection.send(content: response, completion: .contentProcessed { _ in + self.close() + }) + } + + private func close() { + if closed { return } + closed = true + connection.cancel() + onClose(self) + } +} + +final class ConnectionRegistry { + private static var handlers: [ObjectIdentifier: ConnectionHandler] = [:] + private static let lock = DispatchQueue(label: "afm-api.connection.registry") + + static func add(_ handler: ConnectionHandler) { + lock.sync { + handlers[ObjectIdentifier(handler)] = handler + } + } + + static func remove(_ handler: ConnectionHandler) { + _ = lock.sync { + handlers.removeValue(forKey: ObjectIdentifier(handler)) + } + } +} diff --git a/Sources/AFMAPI/server/RequestProcessor.swift b/Sources/AFMAPI/server/RequestProcessor.swift new file mode 100644 index 0000000..723fea0 --- /dev/null +++ b/Sources/AFMAPI/server/RequestProcessor.swift @@ -0,0 +1,145 @@ +import Foundation + +final class RequestProcessor { + let cfg: AppConfig + + init(cfg: AppConfig) { + self.cfg = cfg + } + + func handle(method: String, path: String, body: Data) async -> Data { + let started = Date() + func finish(_ status: Int, _ payload: Any) -> Data { + let ms = Int(Date().timeIntervalSince(started) * 1000.0) + logLine("[\(status)] \(method) \(path) \(ms)ms") + return httpResponse(status: status, body: jsonData(payload)) + } + + if method == "GET" && path == "/healthz" { + return finish(200, ["ok": true]) + } + + let apiBase = apiBasePath(cfg.apiVersion) + + if method == "GET" && path == "\(apiBase)" { + return finish(200, [ + "object": "api.version", + "version": cfg.apiVersion + ]) + } + + if method == "GET" && path == "\(apiBase)/health" { + do { + _ = try await runModel(input: BridgeInput( + model: cfg.modelName, + messages: [ChatMessage(role: "user", content: "ping", name: nil, tool_calls: nil)], + tools: [], + tool_choice: .none, + temperature: 0.0, + max_output_tokens: 8 + )) + return finish(200, [ + "ok": true, + "check": "model", + "model": cfg.modelName + ]) + } catch { + return finish(500, [ + "ok": false, + "check": "model", + "model": cfg.modelName, + "error": String(describing: error) + ]) + } + } + + if method == "GET" && path == "\(apiBase)/models" { + let payload: [String: Any] = [ + "object": "list", + "data": [[ + "id": cfg.modelName, + "object": "model", + "created": 0, + "owned_by": "apple" + ]] + ] + return finish(200, payload) + } + + guard method == "POST" && path == "\(apiBase)/chat/completions" else { + return finish(404, ["error": ["message": "Not found", "type": "invalid_request_error"]]) + } + + let decoder = JSONDecoder() + let req: ChatCompletionsRequest + do { + req = try decoder.decode(ChatCompletionsRequest.self, from: body) + } catch { + return finish(400, ["error": ["message": "Invalid JSON", "type": "invalid_request_error"]]) + } + + if req.stream == true { + return finish(400, ["error": ["message": "stream=true is not implemented yet", "type": "invalid_request_error"]]) + } + + if req.messages.isEmpty { + return finish(400, ["error": ["message": "messages must be a non-empty array", "type": "invalid_request_error"]]) + } + + let bridgeInput = BridgeInput( + model: req.model ?? cfg.modelName, + messages: req.messages, + tools: req.tools ?? [], + tool_choice: req.tool_choice ?? .auto, + temperature: req.temperature ?? 0.7, + max_output_tokens: req.max_tokens ?? 1024 + ) + + do { + let bridgeOutput = try await runModel(input: bridgeInput) + let completionId = "chatcmpl_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" + let created = Int(Date().timeIntervalSince1970) + + var message: [String: Any] = ["role": "assistant"] + var finishReason = "stop" + if let toolCalls = bridgeOutput.tool_calls { + let toolCallObjs = toolCalls.map { tc in + [ + "id": tc.id, + "type": tc.type, + "function": [ + "name": tc.function.name, + "arguments": tc.function.arguments + ] + ] + } + message["tool_calls"] = toolCallObjs + message["content"] = NSNull() + finishReason = "tool_calls" + } else { + message["content"] = bridgeOutput.content ?? "" + } + + let payload: [String: Any] = [ + "id": completionId, + "object": "chat.completion", + "created": created, + "model": req.model ?? cfg.modelName, + "choices": [[ + "index": 0, + "message": message, + "finish_reason": finishReason + ]], + "usage": [ + "prompt_tokens": bridgeOutput.prompt_tokens, + "completion_tokens": bridgeOutput.completion_tokens, + "total_tokens": bridgeOutput.total_tokens + ] + ] + + return finish(200, payload) + } catch { + return finish(500, ["error": ["message": "Bridge process failed: \(error)", "type": "server_error"]]) + } + } +} diff --git a/Sources/AFMAPI/support/HTTP.swift b/Sources/AFMAPI/support/HTTP.swift new file mode 100644 index 0000000..f34ba57 --- /dev/null +++ b/Sources/AFMAPI/support/HTTP.swift @@ -0,0 +1,30 @@ +import Foundation + +func jsonData(_ obj: Any) -> Data { + guard JSONSerialization.isValidJSONObject(obj) else { + return Data("{\"error\":{\"message\":\"internal serialization error\"}}".utf8) + } + if let data = try? JSONSerialization.data(withJSONObject: obj, options: []) { + return data + } + return Data("{\"error\":{\"message\":\"internal serialization error\"}}".utf8) +} + +func httpResponse(status: Int, body: Data, contentType: String = "application/json") -> Data { + let reason: String + switch status { + case 200: reason = "OK" + case 400: reason = "Bad Request" + case 404: reason = "Not Found" + case 500: reason = "Internal Server Error" + default: reason = "OK" + } + + var head = "HTTP/1.1 \(status) \(reason)\r\n" + head += "Content-Type: \(contentType)\r\n" + head += "Content-Length: \(body.count)\r\n" + head += "Connection: close\r\n\r\n" + var data = Data(head.utf8) + data.append(body) + return data +} diff --git a/Sources/AFMAPI/support/Logging.swift b/Sources/AFMAPI/support/Logging.swift new file mode 100644 index 0000000..491ac33 --- /dev/null +++ b/Sources/AFMAPI/support/Logging.swift @@ -0,0 +1,26 @@ +import Foundation +import Darwin + +let logFilePath = ProcessInfo.processInfo.environment["AFM_API_LOG_FILE"] +let logFileLock = NSLock() +let stdoutIsTTY = isatty(fileno(stdout)) == 1 + +func logLine(_ message: String) { + if stdoutIsTTY { + print(message) + } + guard let path = logFilePath, !path.isEmpty else { return } + logFileLock.lock() + defer { logFileLock.unlock() } + let line = message + "\n" + guard let data = line.data(using: .utf8) else { return } + if FileManager.default.fileExists(atPath: path) { + if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) { + defer { try? handle.close() } + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: data) + } + } else { + FileManager.default.createFile(atPath: path, contents: data) + } +} diff --git a/bin/afm-api b/bin/afm-api index cbf76d1..7489830 100755 --- a/bin/afm-api +++ b/bin/afm-api @@ -14,6 +14,35 @@ export AFM_API_LOG_FILE="$LOG_FILE" DEFAULT_HOST="${AFM_API_HOST:-127.0.0.1}" DEFAULT_PORT="${AFM_API_PORT:-8000}" DEFAULT_API_VERSION="${AFM_API_VERSION:-latest}" +DEFAULT_SOURCE_ROOT="${AFM_API_SOURCE_ROOT:-}" + +resolve_source_root() { + if [[ -n "$DEFAULT_SOURCE_ROOT" && -f "$DEFAULT_SOURCE_ROOT/Package.swift" ]]; then + echo "$DEFAULT_SOURCE_ROOT" + return + fi + + if [[ -f "$SCRIPT_DIR/../Package.swift" ]]; then + echo "$SCRIPT_DIR/.." + return + fi + + if [[ -f "$SCRIPT_DIR/../share/afm-api/Package.swift" ]]; then + echo "$SCRIPT_DIR/../share/afm-api" + return + fi + + echo "" +} + +SOURCE_ROOT="$(resolve_source_root)" +if [[ -z "$SOURCE_ROOT" ]]; then + echo "Could not locate Package.swift for afm-api. Set AFM_API_SOURCE_ROOT." + exit 1 +fi + +BUILD_DIR="${AFM_API_BUILD_DIR:-$RUNTIME_DIR/build}" +SERVER_BIN="$BUILD_DIR/release/afm-api-server" is_running() { if [[ ! -f "$PID_FILE" ]]; then @@ -47,6 +76,7 @@ stop=false status=false logs=false follow=false +rebuild=false passthrough=() while [[ $# -gt 0 ]]; do @@ -66,6 +96,9 @@ while [[ $# -gt 0 ]]; do --follow|-f) follow=true ;; + --rebuild) + rebuild=true + ;; *) passthrough+=("$1") ;; @@ -125,7 +158,23 @@ if ! has_arg "--api-version" "${passthrough[@]-}"; then passthrough+=("--api-version" "$DEFAULT_API_VERSION") fi -cmd=(swift -module-cache-path "$SWIFT_MODULECACHE_PATH" "$SCRIPT_DIR/../src/afm-api.swift") +build_server() { + swift build \ + --package-path "$SOURCE_ROOT" \ + --scratch-path "$BUILD_DIR" \ + -c release \ + --product afm-api-server +} + +if [[ "$rebuild" == true ]]; then + rm -rf "$BUILD_DIR" +fi + +if [[ ! -x "$SERVER_BIN" ]]; then + build_server +fi + +cmd=("$SERVER_BIN") if [[ "$background" == true ]]; then if is_running; then diff --git a/src/afm-api.swift b/src/afm-api.swift deleted file mode 100755 index ee28354..0000000 --- a/src/afm-api.swift +++ /dev/null @@ -1,667 +0,0 @@ -#!/usr/bin/env swift - -import Foundation -import FoundationModels -import Network -import Darwin - -let logFilePath = ProcessInfo.processInfo.environment["AFM_API_LOG_FILE"] -let logFileLock = NSLock() -let stdoutIsTTY = isatty(fileno(stdout)) == 1 - -func logLine(_ message: String) { - if stdoutIsTTY { - print(message) - } - guard let path = logFilePath, !path.isEmpty else { return } - logFileLock.lock() - defer { logFileLock.unlock() } - let line = message + "\n" - guard let data = line.data(using: .utf8) else { return } - if FileManager.default.fileExists(atPath: path) { - if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) { - defer { try? handle.close() } - _ = try? handle.seekToEnd() - try? handle.write(contentsOf: data) - } - } else { - FileManager.default.createFile(atPath: path, contents: data) - } -} - -struct BridgeInput: Codable { - let model: String - let messages: [ChatMessage] - let tools: [OpenAITool] - let tool_choice: ToolChoice - let temperature: Double - let max_output_tokens: Int -} - -struct ChatMessage: Codable { - let role: String - let content: String? - let name: String? - let tool_calls: [ToolCall]? -} - -struct OpenAITool: Codable { - let type: String - let function: ToolSpec -} - -struct ToolSpec: Codable { - let name: String - let description: String? - let parameters: JSONValue? -} - -struct ToolCall: Codable { - let id: String - let type: String - let function: ToolCallFunction -} - -struct ToolCallFunction: Codable { - let name: String - let arguments: String -} - -enum ToolChoice: Codable { - case auto - case none - case named(String) - - init(from decoder: Decoder) throws { - let c = try decoder.singleValueContainer() - if let s = try? c.decode(String.self) { - switch s { - case "auto": self = .auto - case "none": self = .none - default: self = .named(s) - } - return - } - let obj = try c.decode([String: JSONValue].self) - if case let .string(name)? = obj["name"] { - self = .named(name) - } else if case let .object(fn)? = obj["function"], case let .string(name)? = fn["name"] { - self = .named(name) - } else { - self = .auto - } - } - - func encode(to encoder: Encoder) throws { - var c = encoder.singleValueContainer() - switch self { - case .auto: try c.encode("auto") - case .none: try c.encode("none") - case .named(let name): try c.encode(["name": name]) - } - } -} - -enum JSONValue: Codable { - case string(String) - case number(Double) - case bool(Bool) - case object([String: JSONValue]) - case array([JSONValue]) - case null - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self = .null - } else if let s = try? container.decode(String.self) { - self = .string(s) - } else if let n = try? container.decode(Double.self) { - self = .number(n) - } else if let b = try? container.decode(Bool.self) { - self = .bool(b) - } else if let o = try? container.decode([String: JSONValue].self) { - self = .object(o) - } else if let a = try? container.decode([JSONValue].self) { - self = .array(a) - } else { - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .string(let s): try container.encode(s) - case .number(let n): try container.encode(n) - case .bool(let b): try container.encode(b) - case .object(let o): try container.encode(o) - case .array(let a): try container.encode(a) - case .null: try container.encodeNil() - } - } -} - -struct BridgeOutput: Codable { - let content: String? - let tool_calls: [ToolCall]? - let prompt_tokens: Int - let completion_tokens: Int - let total_tokens: Int -} - -func encodeJSONObjectString(_ value: Any) -> String? { - guard JSONSerialization.isValidJSONObject(value), - let data = try? JSONSerialization.data(withJSONObject: value), - let text = String(data: data, encoding: .utf8) else { - return nil - } - return text -} - -func makeToolCall(name: String, argsObj: Any) -> ToolCall { - let argsString: String - if let s = argsObj as? String { - if let data = s.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data), - let encoded = encodeJSONObjectString(json) { - argsString = encoded - } else { - argsString = "{\"value\":\(String(describing: s).debugDescription)}" - } - } else if let encoded = encodeJSONObjectString(argsObj) { - argsString = encoded - } else { - argsString = "{\"value\":\(String(describing: argsObj).debugDescription)}" - } - return ToolCall( - id: "call_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))", - type: "function", - function: ToolCallFunction(name: name, arguments: argsString) - ) -} - -struct ChatCompletionsRequest: Codable { - let model: String? - let messages: [ChatMessage] - let tools: [OpenAITool]? - let tool_choice: ToolChoice? - let temperature: Double? - let max_tokens: Int? - let stream: Bool? -} - -struct AppConfig { - let host: String - let port: UInt16 - let modelName: String - let apiVersion: String -} - -func parseArg(_ name: String, default value: String) -> String { - let args = CommandLine.arguments - guard let idx = args.firstIndex(of: name), idx + 1 < args.count else { return value } - return args[idx + 1] -} - -func latestAPIVersion() -> String { - return "v1" -} - -func normalizeAPIVersion(_ version: String) -> String { - let v = version.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if v == "latest" || v.isEmpty { - return latestAPIVersion() - } - if v.hasPrefix("v") { - return version - } - return "v\(version)" -} - -func parseConfig() -> AppConfig { - let host = parseArg("--host", default: "127.0.0.1") - let portStr = parseArg("--port", default: "8000") - let modelName = parseArg("--model-name", default: "apple-foundation-model") - let apiVersion = normalizeAPIVersion(parseArg("--api-version", default: latestAPIVersion())) - let port = UInt16(portStr) ?? 8000 - return AppConfig(host: host, port: port, modelName: modelName, apiVersion: apiVersion) -} - -func apiBasePath(_ version: String) -> String { - return "/\(version)" -} - -func normalizeConversation(_ messages: [ChatMessage]) -> String { - var lines: [String] = [] - for m in messages { - let text = m.content ?? "" - lines.append("[\(m.role)] \(text)") - } - return lines.joined(separator: "\n") -} - -func toolsPromptBlock(_ tools: [OpenAITool], toolChoice: ToolChoice) -> String { - guard !tools.isEmpty else { - return "You have no tools. Answer directly with plain text only." - } - - let toolsJSONData = try? JSONEncoder().encode(tools) - let toolsJSON = String(data: toolsJSONData ?? Data("[]".utf8), encoding: .utf8) ?? "[]" - - var choiceLine = "Tool choice: auto." - switch toolChoice { - case .auto: - choiceLine = "Tool choice: auto." - case .none: - choiceLine = "Tool choice: none. Never call a tool." - case .named(let name): - choiceLine = "Tool choice: required tool is \(name)." - } - - return """ -You may call tools. Available tools (OpenAI schema JSON): -\(toolsJSON) -\(choiceLine) - -Respond in STRICT JSON using exactly one of these formats: -1) {"type":"final","content":""} -2) {"type":"tool_calls","tool_calls":[{"name":"","arguments":{...}}]} - -Rules: -- Emit valid JSON only, no markdown fences. -- If any tool is needed, choose type=tool_calls. -- If tool_choice is none, choose type=final. -""" -} - -func normalizeJSONTextCandidate(_ text: String) -> String { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("```") else { return trimmed } - - let lines = trimmed.split(separator: "\n", omittingEmptySubsequences: false) - guard lines.count >= 3 else { return trimmed } - guard lines.first?.hasPrefix("```") == true, lines.last == "```" else { return trimmed } - - let body = lines.dropFirst().dropLast().joined(separator: "\n") - return String(body).trimmingCharacters(in: .whitespacesAndNewlines) -} - -func runModel(input: BridgeInput) async throws -> BridgeOutput { - let session = LanguageModelSession(model: .default) - - let prompt = """ -You are a compatibility layer for OpenAI chat completions. -\(toolsPromptBlock(input.tools, toolChoice: input.tool_choice)) - -Conversation: -\(normalizeConversation(input.messages)) -""" - - let response = try await session.respond(to: prompt) - let text = response.content - let normalized = normalizeJSONTextCandidate(text) - - if let data = normalized.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let type = parsed["type"] as? String { - if type == "tool_calls", let tc = parsed["tool_calls"] as? [[String: Any]] { - let mapped: [ToolCall] = tc.compactMap { entry in - guard let name = entry["name"] as? String else { return nil } - let argsObj = entry["arguments"] ?? [:] - return makeToolCall(name: name, argsObj: argsObj) - } - - if !mapped.isEmpty { - return BridgeOutput(content: nil, tool_calls: mapped, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) - } - } - - if type == "final" { - if case let .named(requiredName) = input.tool_choice { - let argsObj = parsed["content"] ?? [:] - let tc = makeToolCall(name: requiredName, argsObj: argsObj) - return BridgeOutput(content: nil, tool_calls: [tc], prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) - } - if let content = parsed["content"] as? String { - return BridgeOutput(content: content, tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) - } - if let contentObj = parsed["content"] { - if let textContent = encodeJSONObjectString(contentObj) { - return BridgeOutput(content: textContent, tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) - } - return BridgeOutput(content: String(describing: contentObj), tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) - } - } - } - - return BridgeOutput(content: text, tool_calls: nil, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0) -} - -func jsonData(_ obj: Any) -> Data { - if let data = try? JSONSerialization.data(withJSONObject: obj, options: []) { - return data - } - return Data("{\"error\":{\"message\":\"internal serialization error\"}}".utf8) -} - -func httpResponse(status: Int, body: Data, contentType: String = "application/json") -> Data { - let reason: String - switch status { - case 200: reason = "OK" - case 400: reason = "Bad Request" - case 404: reason = "Not Found" - case 500: reason = "Internal Server Error" - default: reason = "OK" - } - - var head = "HTTP/1.1 \(status) \(reason)\r\n" - head += "Content-Type: \(contentType)\r\n" - head += "Content-Length: \(body.count)\r\n" - head += "Connection: close\r\n\r\n" - var data = Data(head.utf8) - data.append(body) - return data -} - -final class RequestProcessor { - let cfg: AppConfig - - init(cfg: AppConfig) { - self.cfg = cfg - } - - func handle(method: String, path: String, body: Data) async -> Data { - let started = Date() - func finish(_ status: Int, _ payload: Any) -> Data { - let ms = Int(Date().timeIntervalSince(started) * 1000.0) - logLine("[\(status)] \(method) \(path) \(ms)ms") - return httpResponse(status: status, body: jsonData(payload)) - } - - if method == "GET" && path == "/healthz" { - return finish(200, ["ok": true]) - } - - let apiBase = apiBasePath(cfg.apiVersion) - - if method == "GET" && path == "\(apiBase)" { - return finish(200, [ - "object": "api.version", - "version": cfg.apiVersion - ]) - } - - if method == "GET" && path == "\(apiBase)/health" { - do { - _ = try await runModel(input: BridgeInput( - model: cfg.modelName, - messages: [ChatMessage(role: "user", content: "ping", name: nil, tool_calls: nil)], - tools: [], - tool_choice: .none, - temperature: 0.0, - max_output_tokens: 8 - )) - return finish(200, [ - "ok": true, - "check": "model", - "model": cfg.modelName - ]) - } catch { - return finish(500, [ - "ok": false, - "check": "model", - "model": cfg.modelName, - "error": String(describing: error) - ]) - } - } - - if method == "GET" && path == "\(apiBase)/models" { - let payload: [String: Any] = [ - "object": "list", - "data": [[ - "id": cfg.modelName, - "object": "model", - "created": 0, - "owned_by": "apple" - ]] - ] - return finish(200, payload) - } - - guard method == "POST" && path == "\(apiBase)/chat/completions" else { - return finish(404, ["error": ["message": "Not found", "type": "invalid_request_error"]]) - } - - let decoder = JSONDecoder() - let req: ChatCompletionsRequest - do { - req = try decoder.decode(ChatCompletionsRequest.self, from: body) - } catch { - return finish(400, ["error": ["message": "Invalid JSON", "type": "invalid_request_error"]]) - } - - if req.stream == true { - return finish(400, ["error": ["message": "stream=true is not implemented yet", "type": "invalid_request_error"]]) - } - - if req.messages.isEmpty { - return finish(400, ["error": ["message": "messages must be a non-empty array", "type": "invalid_request_error"]]) - } - - let bridgeInput = BridgeInput( - model: req.model ?? cfg.modelName, - messages: req.messages, - tools: req.tools ?? [], - tool_choice: req.tool_choice ?? .auto, - temperature: req.temperature ?? 0.7, - max_output_tokens: req.max_tokens ?? 1024 - ) - - do { - let bridgeOutput = try await runModel(input: bridgeInput) - let completionId = "chatcmpl_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" - let created = Int(Date().timeIntervalSince1970) - - var message: [String: Any] = ["role": "assistant"] - var finishReason = "stop" - if let toolCalls = bridgeOutput.tool_calls { - let toolCallObjs = toolCalls.map { tc in - [ - "id": tc.id, - "type": tc.type, - "function": [ - "name": tc.function.name, - "arguments": tc.function.arguments - ] - ] - } - message["tool_calls"] = toolCallObjs - message["content"] = NSNull() - finishReason = "tool_calls" - } else { - message["content"] = bridgeOutput.content ?? "" - } - - let payload: [String: Any] = [ - "id": completionId, - "object": "chat.completion", - "created": created, - "model": req.model ?? cfg.modelName, - "choices": [[ - "index": 0, - "message": message, - "finish_reason": finishReason - ]], - "usage": [ - "prompt_tokens": bridgeOutput.prompt_tokens, - "completion_tokens": bridgeOutput.completion_tokens, - "total_tokens": bridgeOutput.total_tokens - ] - ] - - return finish(200, payload) - } catch { - return finish(500, ["error": ["message": "Bridge process failed: \(error)", "type": "server_error"]]) - } - } -} - -final class ConnectionHandler { - private let connection: NWConnection - private let processor: RequestProcessor - private let onClose: (ConnectionHandler) -> Void - private var buffer = Data() - private var expectedBodyLength: Int? - private var closed = false - - init(connection: NWConnection, processor: RequestProcessor, onClose: @escaping (ConnectionHandler) -> Void) { - self.connection = connection - self.processor = processor - self.onClose = onClose - } - - func start() { - connection.stateUpdateHandler = { [weak self] state in - if case .ready = state { - self?.receiveLoop() - } - } - connection.start(queue: .global()) - } - - private func receiveLoop() { - connection.receive(minimumIncompleteLength: 1, maximumLength: 1024 * 1024) { [weak self] data, _, isComplete, error in - guard let self else { return } - if let data, !data.isEmpty { - self.buffer.append(data) - if self.tryProcessIfComplete() { - return - } - } - - if isComplete || error != nil { - self.close() - return - } - - self.receiveLoop() - } - } - - private func tryProcessIfComplete() -> Bool { - guard let headerRange = buffer.range(of: Data("\r\n\r\n".utf8)) else { - return false - } - - let headerData = buffer.subdata(in: 0..= 2 else { - send(status: 400, body: ["error": ["message": "Invalid request line", "type": "invalid_request_error"]]) - return true - } - - let method = String(reqParts[0]) - let path = String(reqParts[1]) - - if expectedBodyLength == nil { - expectedBodyLength = 0 - for line in lines.dropFirst() { - if line.isEmpty { continue } - let parts = line.split(separator: ":", maxSplits: 1).map(String.init) - if parts.count == 2 && parts[0].lowercased() == "content-length" { - expectedBodyLength = Int(parts[1].trimmingCharacters(in: .whitespaces)) ?? 0 - } - } - } - - let bodyStart = headerRange.upperBound - let bodyLen = buffer.count - bodyStart - let needed = expectedBodyLength ?? 0 - guard bodyLen >= needed else { - return false - } - - let body = buffer.subdata(in: bodyStart..<(bodyStart + needed)) - - var responseData = Data() - let sem = DispatchSemaphore(value: 0) - Task { - responseData = await self.processor.handle(method: method, path: path, body: body) - sem.signal() - } - sem.wait() - self.connection.send(content: responseData, completion: .contentProcessed { _ in - self.close() - }) - - return true - } - - private func send(status: Int, body: [String: Any]) { - let response = httpResponse(status: status, body: jsonData(body)) - connection.send(content: response, completion: .contentProcessed { _ in - self.close() - }) - } - - private func close() { - if closed { return } - closed = true - connection.cancel() - onClose(self) - } -} - -final class ConnectionRegistry { - private static var handlers: [ObjectIdentifier: ConnectionHandler] = [:] - private static let lock = DispatchQueue(label: "afm-api.connection.registry") - - static func add(_ handler: ConnectionHandler) { - lock.sync { - handlers[ObjectIdentifier(handler)] = handler - } - } - - static func remove(_ handler: ConnectionHandler) { - lock.sync { - handlers.removeValue(forKey: ObjectIdentifier(handler)) - } - } -} - -let cfg = parseConfig() -let processor = RequestProcessor(cfg: cfg) - -let params = NWParameters.tcp - -guard let listenerPort = NWEndpoint.Port(rawValue: cfg.port) else { - fputs("Invalid port: \(cfg.port)\n", stderr) - exit(2) -} - -let listener = try NWListener(using: params, on: listenerPort) -listener.newConnectionHandler = { connection in - let handler = ConnectionHandler(connection: connection, processor: processor) { h in - ConnectionRegistry.remove(h) - } - ConnectionRegistry.add(handler) - handler.start() -} -listener.start(queue: .main) -logLine("afm-api server listening on http://\(cfg.host):\(cfg.port)") -logLine("API version: \(cfg.apiVersion)") -logLine("Model id: \(cfg.modelName)") -RunLoop.main.run() From 6a59947f772e9dbf55348ca62b7a7534453a2935 Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 18:26:58 +0100 Subject: [PATCH 02/18] feat: add runtime prereq checks with colored cli output --- bin/afm-api | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/bin/afm-api b/bin/afm-api index 7489830..e70c354 100755 --- a/bin/afm-api +++ b/bin/afm-api @@ -5,6 +5,88 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" export SWIFT_MODULECACHE_PATH="${SWIFT_MODULECACHE_PATH:-/tmp/afm-api-swift-module-cache}" mkdir -p "$SWIFT_MODULECACHE_PATH" +if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BLUE='\033[0;34m' + NC='\033[0m' +else + RED='' + GREEN='' + YELLOW='' + BLUE='' + NC='' +fi + +info() { echo -e "${BLUE}ℹ️ $*${NC}"; } +ok() { echo -e "${GREEN}✅ $*${NC}"; } +warn() { echo -e "${YELLOW}⚠️ $*${NC}"; } +fail() { echo -e "${RED}❌ $*${NC}" >&2; exit 1; } + +version_ge() { + local current="$1" + local required="$2" + [[ "$(printf '%s\n%s\n' "$required" "$current" | sort -V | tail -n1)" == "$current" ]] +} + +check_macos_version() { + [[ "$(uname -s)" == "Darwin" ]] || fail "afm-api only runs on macOS." + + local required="26.0" + local detected + detected="$(sw_vers -productVersion 2>/dev/null || true)" + [[ -n "$detected" ]] || fail "Unable to read macOS version via sw_vers." + + info "Detected macOS $detected" + if version_ge "$detected" "$required"; then + ok "macOS version check passed (required >= $required)." + else + fail "macOS $detected is not supported. Required >= $required." + fi +} + +check_swift_toolchain() { + command -v swift >/dev/null 2>&1 || fail "Swift compiler not found. Install Xcode Command Line Tools." + + local output swift_version + output="$(swift --version 2>&1 || true)" + swift_version="$(printf '%s\n' "$output" | sed -nE 's/.*Apple Swift version ([0-9]+\.[0-9]+).*/\1/p' | head -n1)" + + if [[ -z "$swift_version" ]]; then + warn "Could not parse Swift version from 'swift --version'. Continuing." + return + fi + + info "Detected Swift $swift_version" + if version_ge "$swift_version" "6.0"; then + ok "Swift toolchain check passed." + else + warn "Swift $swift_version may be older than recommended (6.0+). Continuing." + fi +} + +check_xcode_clt() { + if ! command -v xcode-select >/dev/null 2>&1; then + warn "xcode-select not found; build may fail without Xcode Command Line Tools." + return + fi + + local dev_dir + dev_dir="$(xcode-select -p 2>/dev/null || true)" + if [[ -n "$dev_dir" ]]; then + ok "Xcode Command Line Tools detected at $dev_dir." + else + warn "Xcode Command Line Tools not configured; build may fail." + fi +} + +validate_build_prereqs() { + check_macos_version + check_swift_toolchain + check_xcode_clt +} + RUNTIME_DIR="${AFM_API_RUNTIME_DIR:-/tmp/afm-api}" PID_FILE="$RUNTIME_DIR/afm-api.pid" LOG_FILE="$RUNTIME_DIR/afm-api.log" @@ -159,11 +241,14 @@ if ! has_arg "--api-version" "${passthrough[@]-}"; then fi build_server() { + validate_build_prereqs + info "Building afm-api-server (release)..." swift build \ --package-path "$SOURCE_ROOT" \ --scratch-path "$BUILD_DIR" \ -c release \ --product afm-api-server + ok "Build complete." } if [[ "$rebuild" == true ]]; then From 02fefea173be037dc298a2aa534bacdddf7ada4a Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 18:30:01 +0100 Subject: [PATCH 03/18] fix: enforce xcode prereq checks and rotate runtime logs --- Sources/AFMAPI/support/Logging.swift | 29 ++++++++++++++++++++++++++++ bin/afm-api | 11 ++++++----- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/Sources/AFMAPI/support/Logging.swift b/Sources/AFMAPI/support/Logging.swift index 491ac33..6e47b5f 100644 --- a/Sources/AFMAPI/support/Logging.swift +++ b/Sources/AFMAPI/support/Logging.swift @@ -2,9 +2,37 @@ import Foundation import Darwin let logFilePath = ProcessInfo.processInfo.environment["AFM_API_LOG_FILE"] +let logMaxBytes = Int64(ProcessInfo.processInfo.environment["AFM_API_LOG_MAX_BYTES"] ?? "") ?? 10 * 1024 * 1024 +let logMaxFilesRaw = Int(ProcessInfo.processInfo.environment["AFM_API_LOG_MAX_FILES"] ?? "") ?? 3 +let logMaxFiles = max(1, logMaxFilesRaw) let logFileLock = NSLock() let stdoutIsTTY = isatty(fileno(stdout)) == 1 +func rotateLogIfNeeded(path: String) { + guard logMaxBytes > 0 else { return } + guard FileManager.default.fileExists(atPath: path) else { return } + + guard let attrs = try? FileManager.default.attributesOfItem(atPath: path), + let sizeNum = attrs[.size] as? NSNumber else { + return + } + + let size = sizeNum.int64Value + guard size >= logMaxBytes else { return } + + let fm = FileManager.default + for idx in stride(from: logMaxFiles - 1, through: 0, by: -1) { + let src = idx == 0 ? path : "\(path).\(idx)" + let dst = "\(path).\(idx + 1)" + + guard fm.fileExists(atPath: src) else { continue } + if fm.fileExists(atPath: dst) { + try? fm.removeItem(atPath: dst) + } + try? fm.moveItem(atPath: src, toPath: dst) + } +} + func logLine(_ message: String) { if stdoutIsTTY { print(message) @@ -14,6 +42,7 @@ func logLine(_ message: String) { defer { logFileLock.unlock() } let line = message + "\n" guard let data = line.data(using: .utf8) else { return } + rotateLogIfNeeded(path: path) if FileManager.default.fileExists(atPath: path) { if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) { defer { try? handle.close() } diff --git a/bin/afm-api b/bin/afm-api index e70c354..9cddea7 100755 --- a/bin/afm-api +++ b/bin/afm-api @@ -67,17 +67,14 @@ check_swift_toolchain() { } check_xcode_clt() { - if ! command -v xcode-select >/dev/null 2>&1; then - warn "xcode-select not found; build may fail without Xcode Command Line Tools." - return - fi + command -v xcode-select >/dev/null 2>&1 || fail "xcode-select not found. Install Xcode Command Line Tools: xcode-select --install" local dev_dir dev_dir="$(xcode-select -p 2>/dev/null || true)" if [[ -n "$dev_dir" ]]; then ok "Xcode Command Line Tools detected at $dev_dir." else - warn "Xcode Command Line Tools not configured; build may fail." + fail "Xcode Command Line Tools not configured. Run: xcode-select --install" fi } @@ -90,8 +87,12 @@ validate_build_prereqs() { RUNTIME_DIR="${AFM_API_RUNTIME_DIR:-/tmp/afm-api}" PID_FILE="$RUNTIME_DIR/afm-api.pid" LOG_FILE="$RUNTIME_DIR/afm-api.log" +LOG_MAX_BYTES="${AFM_API_LOG_MAX_BYTES:-10485760}" # 10MB +LOG_MAX_FILES="${AFM_API_LOG_MAX_FILES:-3}" mkdir -p "$RUNTIME_DIR" export AFM_API_LOG_FILE="$LOG_FILE" +export AFM_API_LOG_MAX_BYTES="$LOG_MAX_BYTES" +export AFM_API_LOG_MAX_FILES="$LOG_MAX_FILES" DEFAULT_HOST="${AFM_API_HOST:-127.0.0.1}" DEFAULT_PORT="${AFM_API_PORT:-8000}" From 181ccbe398db8ebc3be4f6b2da616208556160ac Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 19:04:28 +0100 Subject: [PATCH 04/18] feat: add afm-api version command --- Formula/afm-api.rb | 1 + bin/afm-api | 45 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/Formula/afm-api.rb b/Formula/afm-api.rb index 093944e..8c84a4d 100644 --- a/Formula/afm-api.rb +++ b/Formula/afm-api.rb @@ -11,6 +11,7 @@ def install bin.install "bin/afm-api" pkgshare.install "Package.swift" pkgshare.install "Sources" + inreplace bin/"afm-api", "__AFM_API_VERSION__", version.to_s end test do diff --git a/bin/afm-api b/bin/afm-api index 9cddea7..114b51d 100755 --- a/bin/afm-api +++ b/bin/afm-api @@ -98,6 +98,7 @@ DEFAULT_HOST="${AFM_API_HOST:-127.0.0.1}" DEFAULT_PORT="${AFM_API_PORT:-8000}" DEFAULT_API_VERSION="${AFM_API_VERSION:-latest}" DEFAULT_SOURCE_ROOT="${AFM_API_SOURCE_ROOT:-}" +EMBEDDED_VERSION="__AFM_API_VERSION__" resolve_source_root() { if [[ -n "$DEFAULT_SOURCE_ROOT" && -f "$DEFAULT_SOURCE_ROOT/Package.swift" ]]; then @@ -118,11 +119,34 @@ resolve_source_root() { echo "" } +resolve_version() { + if [[ "$EMBEDDED_VERSION" != "__AFM_API_VERSION__" && -n "$EMBEDDED_VERSION" ]]; then + echo "$EMBEDDED_VERSION" + return + fi + + if [[ -n "${SOURCE_ROOT:-}" && -d "${SOURCE_ROOT}/.git" ]] && command -v git >/dev/null 2>&1; then + local git_version + git_version="$(git -C "$SOURCE_ROOT" describe --tags --always --dirty 2>/dev/null || true)" + if [[ -n "$git_version" ]]; then + echo "$git_version" + return + fi + fi + + if command -v brew >/dev/null 2>&1; then + local brew_version + brew_version="$(brew list --versions afm-api 2>/dev/null | awk '{print $2}' | head -n1 || true)" + if [[ -n "$brew_version" ]]; then + echo "$brew_version" + return + fi + fi + + echo "unknown" +} + SOURCE_ROOT="$(resolve_source_root)" -if [[ -z "$SOURCE_ROOT" ]]; then - echo "Could not locate Package.swift for afm-api. Set AFM_API_SOURCE_ROOT." - exit 1 -fi BUILD_DIR="${AFM_API_BUILD_DIR:-$RUNTIME_DIR/build}" SERVER_BIN="$BUILD_DIR/release/afm-api-server" @@ -160,6 +184,7 @@ status=false logs=false follow=false rebuild=false +show_version=false passthrough=() while [[ $# -gt 0 ]]; do @@ -182,6 +207,9 @@ while [[ $# -gt 0 ]]; do --rebuild) rebuild=true ;; + --version|-v) + show_version=true + ;; *) passthrough+=("$1") ;; @@ -189,6 +217,11 @@ while [[ $# -gt 0 ]]; do shift done +if [[ "$show_version" == true ]]; then + echo "afm-api $(resolve_version)" + exit 0 +fi + if [[ "$stop" == true ]]; then if is_running; then pid="$(cat "$PID_FILE")" @@ -231,6 +264,10 @@ if [[ "$logs" == true ]]; then exit 0 fi +if [[ -z "$SOURCE_ROOT" ]]; then + fail "Could not locate Package.swift for afm-api. Set AFM_API_SOURCE_ROOT." +fi + if ! has_arg "--host" "${passthrough[@]-}"; then passthrough+=("--host" "$DEFAULT_HOST") fi From 33bd521ddd33991fe9bf63bf924d4b4d12bac139 Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 19:04:44 +0100 Subject: [PATCH 05/18] docs: add version command to quick commands --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7442df0..ac46c11 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ afm-api --background # Run in background afm-api --status # Check status afm-api --logs # View logs afm-api --stop # Stop server +afm-api --version # Show build/version afm-api --rebuild # Rebuild server binary from source ``` From 851e19ff53b27637bf6c58058e0ca9cfa664abb3 Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 19:04:49 +0100 Subject: [PATCH 06/18] test: add local homebrew feature-branch install test --- docs/homebrew-local-testing.md | 65 +++++++++++ tests/test_homebrew_feature_branch_install.sh | 101 ++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 docs/homebrew-local-testing.md create mode 100755 tests/test_homebrew_feature_branch_install.sh diff --git a/docs/homebrew-local-testing.md b/docs/homebrew-local-testing.md new file mode 100644 index 0000000..a0be289 --- /dev/null +++ b/docs/homebrew-local-testing.md @@ -0,0 +1,65 @@ +# Local Homebrew Testing + +This guide explains how to test `afm-api` from your current local branch without merging into `main`. + +## Quick Start + +From the repo root: + +```bash +./tests/test_homebrew_feature_branch_install.sh +``` + +Default tap used by the test script: + +- `tankibaj/localtap` + +## What The Test Does + +1. Creates a tarball from current `HEAD`. +2. Creates/uses the local tap (`tankibaj/localtap` by default). +3. Writes a temporary formula for `afm-api`. +4. Runs: + - `brew reinstall --build-from-source tankibaj/localtap/afm-api` +5. Runs a smoke test: + - `afm-api --rebuild --background` + - `curl http://127.0.0.1:8000/v1/health` + - `afm-api --stop` +6. Cleans up temporary artifacts by default. + +## Optional Flags + +Keep installed formula: + +```bash +KEEP_INSTALL=1 ./tests/test_homebrew_feature_branch_install.sh +``` + +Keep local tap: + +```bash +KEEP_TAP=1 ./tests/test_homebrew_feature_branch_install.sh +``` + +Use a custom local tap: + +```bash +./tests/test_homebrew_feature_branch_install.sh myuser/localtap +``` + +## Manual Cleanup + +```bash +afm-api --stop || true +brew uninstall afm-api || true +brew uninstall tankibaj/localtap/afm-api || true +brew untap tankibaj/localtap || true +rm -f /tmp/afm-api-feature-*.tar.gz +rm -rf /tmp/afm-api +``` + +Optional cleanup for published tap install: + +```bash +brew untap tankibaj/tap || true +``` diff --git a/tests/test_homebrew_feature_branch_install.sh b/tests/test_homebrew_feature_branch_install.sh new file mode 100755 index 0000000..09f4611 --- /dev/null +++ b/tests/test_homebrew_feature_branch_install.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +require() { + command -v "$1" >/dev/null 2>&1 || { + echo "FAIL: missing required command: $1" + exit 1 + } +} + +require brew +require git +require shasum +require curl + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +FORMULA_NAME="${FORMULA_NAME:-afm-api}" +TAP_NAME="${1:-tankibaj/localtap}" +KEEP_INSTALL="${KEEP_INSTALL:-0}" +KEEP_TAP="${KEEP_TAP:-0}" + +TAP_USER="${TAP_NAME%%/*}" +TAP_REPO="${TAP_NAME##*/}" +TAP_ROOT="$(brew --repository)/Library/Taps/${TAP_USER}/homebrew-${TAP_REPO}" +FORMULA_PATH="$TAP_ROOT/Formula/${FORMULA_NAME}.rb" + +SHORT_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD)" +TARBALL="${TMPDIR:-/tmp}/afm-api-feature-${SHORT_SHA}.tar.gz" +BACKUP_FORMULA="" +CREATED_TAP=0 + +cleanup() { + if [[ "$KEEP_INSTALL" != "1" ]]; then + brew uninstall "${TAP_NAME}/${FORMULA_NAME}" >/dev/null 2>&1 || true + fi + + if [[ -n "$BACKUP_FORMULA" && -f "$BACKUP_FORMULA" ]]; then + mv "$BACKUP_FORMULA" "$FORMULA_PATH" + elif [[ -f "$FORMULA_PATH" ]]; then + rm -f "$FORMULA_PATH" + fi + + if [[ "$CREATED_TAP" == "1" && "$KEEP_TAP" != "1" ]]; then + brew untap "$TAP_NAME" >/dev/null 2>&1 || true + fi + + rm -f "$TARBALL" +} +trap cleanup EXIT + +if ! brew tap | rg -q "^${TAP_NAME}$"; then + brew tap-new "$TAP_NAME" >/dev/null + CREATED_TAP=1 +fi + +mkdir -p "$(dirname "$FORMULA_PATH")" +if [[ -f "$FORMULA_PATH" ]]; then + BACKUP_FORMULA="${FORMULA_PATH}.bak.$RANDOM" + cp "$FORMULA_PATH" "$BACKUP_FORMULA" +fi + +git -C "$REPO_ROOT" archive --format=tar.gz -o "$TARBALL" HEAD +SHA="$(shasum -a 256 "$TARBALL" | awk '{print $1}')" +VERSION="0.0.0-feature.${SHORT_SHA}" + +cat > "$FORMULA_PATH" </dev/null + +afm-api --stop >/dev/null 2>&1 || true +afm-api --rebuild --background >/dev/null +sleep 1 +curl -sf "http://127.0.0.1:8000/v1/health" >/dev/null +afm-api --stop >/dev/null 2>&1 || true + +echo "PASS: Homebrew feature-branch install test works" +echo "PASS info: tap=${TAP_NAME} version=${VERSION} sha=${SHA}" From 6b9b044d1f15127f38d2baaeb562ff8b48ccf620 Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 19:10:10 +0100 Subject: [PATCH 07/18] chore: normalize test script names and docs paths --- README.md | 12 ++++++------ docs/homebrew-local-testing.md | 8 ++++---- tests/{test_function_call.sh => function_call.sh} | 0 ...install.sh => homebrew_feature_branch_install.sh} | 0 ...untries.sh => tool_country_info_restcountries.sh} | 0 ...y_frankfurter.sh => tool_currency_frankfurter.sh} | 0 ...lidays_nager.sh => tool_public_holidays_nager.sh} | 0 ...worldtimeapi.sh => tool_timezone_worldtimeapi.sh} | 0 ...eather_openmeteo.sh => tool_weather_openmeteo.sh} | 0 9 files changed, 10 insertions(+), 10 deletions(-) rename tests/{test_function_call.sh => function_call.sh} (100%) rename tests/{test_homebrew_feature_branch_install.sh => homebrew_feature_branch_install.sh} (100%) rename tests/{test_tool_country_info_restcountries.sh => tool_country_info_restcountries.sh} (100%) rename tests/{test_tool_currency_frankfurter.sh => tool_currency_frankfurter.sh} (100%) rename tests/{test_tool_public_holidays_nager.sh => tool_public_holidays_nager.sh} (100%) rename tests/{test_tool_timezone_worldtimeapi.sh => tool_timezone_worldtimeapi.sh} (100%) rename tests/{test_tool_weather_openmeteo.sh => tool_weather_openmeteo.sh} (100%) diff --git a/README.md b/README.md index ac46c11..7625590 100644 --- a/README.md +++ b/README.md @@ -232,24 +232,24 @@ swift build -c release --product afm-api-server **Run tests** ```bash -./tests/test_function_call.sh +./tests/function_call.sh ``` **Function Calling Examples (real APIs)** ```bash # Country info -./tests/test_tool_country_info_restcountries.sh http://127.0.0.1:8000 +./tests/tool_country_info_restcountries.sh http://127.0.0.1:8000 # Currency conversion -./tests/test_tool_currency_frankfurter.sh http://127.0.0.1:8000 +./tests/tool_currency_frankfurter.sh http://127.0.0.1:8000 # Public holidays -./tests/test_tool_public_holidays_nager.sh http://127.0.0.1:8000 +./tests/tool_public_holidays_nager.sh http://127.0.0.1:8000 # Time zone -./tests/test_tool_timezone_worldtimeapi.sh http://127.0.0.1:8000 +./tests/tool_timezone_worldtimeapi.sh http://127.0.0.1:8000 # Weather -./tests/test_tool_weather_openmeteo.sh http://127.0.0.1:8000 +./tests/tool_weather_openmeteo.sh http://127.0.0.1:8000 ``` **Update** diff --git a/docs/homebrew-local-testing.md b/docs/homebrew-local-testing.md index a0be289..2636a78 100644 --- a/docs/homebrew-local-testing.md +++ b/docs/homebrew-local-testing.md @@ -7,7 +7,7 @@ This guide explains how to test `afm-api` from your current local branch without From the repo root: ```bash -./tests/test_homebrew_feature_branch_install.sh +./tests/homebrew_feature_branch_install.sh ``` Default tap used by the test script: @@ -32,19 +32,19 @@ Default tap used by the test script: Keep installed formula: ```bash -KEEP_INSTALL=1 ./tests/test_homebrew_feature_branch_install.sh +KEEP_INSTALL=1 ./tests/homebrew_feature_branch_install.sh ``` Keep local tap: ```bash -KEEP_TAP=1 ./tests/test_homebrew_feature_branch_install.sh +KEEP_TAP=1 ./tests/homebrew_feature_branch_install.sh ``` Use a custom local tap: ```bash -./tests/test_homebrew_feature_branch_install.sh myuser/localtap +./tests/homebrew_feature_branch_install.sh myuser/localtap ``` ## Manual Cleanup diff --git a/tests/test_function_call.sh b/tests/function_call.sh similarity index 100% rename from tests/test_function_call.sh rename to tests/function_call.sh diff --git a/tests/test_homebrew_feature_branch_install.sh b/tests/homebrew_feature_branch_install.sh similarity index 100% rename from tests/test_homebrew_feature_branch_install.sh rename to tests/homebrew_feature_branch_install.sh diff --git a/tests/test_tool_country_info_restcountries.sh b/tests/tool_country_info_restcountries.sh similarity index 100% rename from tests/test_tool_country_info_restcountries.sh rename to tests/tool_country_info_restcountries.sh diff --git a/tests/test_tool_currency_frankfurter.sh b/tests/tool_currency_frankfurter.sh similarity index 100% rename from tests/test_tool_currency_frankfurter.sh rename to tests/tool_currency_frankfurter.sh diff --git a/tests/test_tool_public_holidays_nager.sh b/tests/tool_public_holidays_nager.sh similarity index 100% rename from tests/test_tool_public_holidays_nager.sh rename to tests/tool_public_holidays_nager.sh diff --git a/tests/test_tool_timezone_worldtimeapi.sh b/tests/tool_timezone_worldtimeapi.sh similarity index 100% rename from tests/test_tool_timezone_worldtimeapi.sh rename to tests/tool_timezone_worldtimeapi.sh diff --git a/tests/test_tool_weather_openmeteo.sh b/tests/tool_weather_openmeteo.sh similarity index 100% rename from tests/test_tool_weather_openmeteo.sh rename to tests/tool_weather_openmeteo.sh From c7071c151fb23257801fb6dbe49a3f851778bb30 Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 19:27:10 +0100 Subject: [PATCH 08/18] feat: switch to binary-first runtime with explicit build command --- README.md | 4 +- bin/afm-api | 96 ++++++++++++++++++------ docs/homebrew-local-testing.md | 4 +- tests/homebrew_feature_branch_install.sh | 33 +++++--- 4 files changed, 100 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 7625590..9b5a60e 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ afm-api --status # Check status afm-api --logs # View logs afm-api --stop # Stop server afm-api --version # Show build/version -afm-api --rebuild # Rebuild server binary from source +afm-api build # Build afm-api-server once (source checkout) +afm-api --rebuild # Force clean rebuild (source checkout) ``` --- @@ -211,6 +212,7 @@ afm-api --host 127.0.0.1 --port 8080 --model-name custom-model **Development mode** ```bash cd /path/to/repo +./afm-api build ./afm-api --background ./afm-api --logs --follow ``` diff --git a/bin/afm-api b/bin/afm-api index 114b51d..87899ba 100755 --- a/bin/afm-api +++ b/bin/afm-api @@ -2,8 +2,6 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -export SWIFT_MODULECACHE_PATH="${SWIFT_MODULECACHE_PATH:-/tmp/afm-api-swift-module-cache}" -mkdir -p "$SWIFT_MODULECACHE_PATH" if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then RED='\033[0;31m' @@ -90,6 +88,7 @@ LOG_FILE="$RUNTIME_DIR/afm-api.log" LOG_MAX_BYTES="${AFM_API_LOG_MAX_BYTES:-10485760}" # 10MB LOG_MAX_FILES="${AFM_API_LOG_MAX_FILES:-3}" mkdir -p "$RUNTIME_DIR" + export AFM_API_LOG_FILE="$LOG_FILE" export AFM_API_LOG_MAX_BYTES="$LOG_MAX_BYTES" export AFM_API_LOG_MAX_FILES="$LOG_MAX_FILES" @@ -119,13 +118,20 @@ resolve_source_root() { echo "" } +SOURCE_ROOT="$(resolve_source_root)" +if [[ -n "$SOURCE_ROOT" ]]; then + BUILD_DIR="${AFM_API_BUILD_DIR:-$SOURCE_ROOT/.build}" +else + BUILD_DIR="${AFM_API_BUILD_DIR:-$RUNTIME_DIR/build}" +fi + resolve_version() { if [[ "$EMBEDDED_VERSION" != "__AFM_API_VERSION__" && -n "$EMBEDDED_VERSION" ]]; then echo "$EMBEDDED_VERSION" return fi - if [[ -n "${SOURCE_ROOT:-}" && -d "${SOURCE_ROOT}/.git" ]] && command -v git >/dev/null 2>&1; then + if [[ -n "$SOURCE_ROOT" && -d "$SOURCE_ROOT/.git" ]] && command -v git >/dev/null 2>&1; then local git_version git_version="$(git -C "$SOURCE_ROOT" describe --tags --always --dirty 2>/dev/null || true)" if [[ -n "$git_version" ]]; then @@ -146,10 +152,48 @@ resolve_version() { echo "unknown" } -SOURCE_ROOT="$(resolve_source_root)" +resolve_server_bin() { + if [[ -n "${AFM_API_SERVER_BIN:-}" && -x "${AFM_API_SERVER_BIN}" ]]; then + echo "${AFM_API_SERVER_BIN}" + return + fi + + if [[ -x "$SCRIPT_DIR/afm-api-server" ]]; then + echo "$SCRIPT_DIR/afm-api-server" + return + fi + + if [[ -n "$SOURCE_ROOT" && -x "$SOURCE_ROOT/.build/release/afm-api-server" ]]; then + echo "$SOURCE_ROOT/.build/release/afm-api-server" + return + fi -BUILD_DIR="${AFM_API_BUILD_DIR:-$RUNTIME_DIR/build}" -SERVER_BIN="$BUILD_DIR/release/afm-api-server" + if [[ -x "$BUILD_DIR/release/afm-api-server" ]]; then + echo "$BUILD_DIR/release/afm-api-server" + return + fi + + echo "" +} + +build_server() { + local clean="${1:-0}" + [[ -n "$SOURCE_ROOT" ]] || fail "Source checkout not found. 'afm-api build' requires Package.swift." + + validate_build_prereqs + + if [[ "$clean" == "1" ]]; then + rm -rf "$BUILD_DIR" + fi + + info "Building afm-api-server (release)..." + swift build \ + --package-path "$SOURCE_ROOT" \ + --scratch-path "$BUILD_DIR" \ + -c release \ + --product afm-api-server + ok "Build complete." +} is_running() { if [[ ! -f "$PID_FILE" ]]; then @@ -178,6 +222,13 @@ has_arg() { return 1 } +subcommand="${1:-}" +explicit_build=false +if [[ "$subcommand" == "build" ]]; then + explicit_build=true + shift +fi + background=false stop=false status=false @@ -185,6 +236,7 @@ logs=false follow=false rebuild=false show_version=false +clean_build=false passthrough=() while [[ $# -gt 0 ]]; do @@ -210,6 +262,9 @@ while [[ $# -gt 0 ]]; do --version|-v) show_version=true ;; + --clean) + clean_build=true + ;; *) passthrough+=("$1") ;; @@ -264,8 +319,13 @@ if [[ "$logs" == true ]]; then exit 0 fi -if [[ -z "$SOURCE_ROOT" ]]; then - fail "Could not locate Package.swift for afm-api. Set AFM_API_SOURCE_ROOT." +if [[ "$explicit_build" == true ]]; then + build_server "$([[ "$clean_build" == true ]] && echo 1 || echo 0)" + exit 0 +fi + +if [[ "$rebuild" == true ]]; then + build_server 1 fi if ! has_arg "--host" "${passthrough[@]-}"; then @@ -278,23 +338,9 @@ if ! has_arg "--api-version" "${passthrough[@]-}"; then passthrough+=("--api-version" "$DEFAULT_API_VERSION") fi -build_server() { - validate_build_prereqs - info "Building afm-api-server (release)..." - swift build \ - --package-path "$SOURCE_ROOT" \ - --scratch-path "$BUILD_DIR" \ - -c release \ - --product afm-api-server - ok "Build complete." -} - -if [[ "$rebuild" == true ]]; then - rm -rf "$BUILD_DIR" -fi - -if [[ ! -x "$SERVER_BIN" ]]; then - build_server +SERVER_BIN="$(resolve_server_bin)" +if [[ -z "$SERVER_BIN" ]]; then + fail "afm-api-server binary not found. Run 'afm-api build' (source checkout) or reinstall via Homebrew." fi cmd=("$SERVER_BIN") diff --git a/docs/homebrew-local-testing.md b/docs/homebrew-local-testing.md index 2636a78..a69b581 100644 --- a/docs/homebrew-local-testing.md +++ b/docs/homebrew-local-testing.md @@ -20,9 +20,9 @@ Default tap used by the test script: 2. Creates/uses the local tap (`tankibaj/localtap` by default). 3. Writes a temporary formula for `afm-api`. 4. Runs: - - `brew reinstall --build-from-source tankibaj/localtap/afm-api` + - `brew reinstall tankibaj/localtap/afm-api` 5. Runs a smoke test: - - `afm-api --rebuild --background` + - `afm-api --background` - `curl http://127.0.0.1:8000/v1/health` - `afm-api --stop` 6. Cleans up temporary artifacts by default. diff --git a/tests/homebrew_feature_branch_install.sh b/tests/homebrew_feature_branch_install.sh index 09f4611..98df943 100755 --- a/tests/homebrew_feature_branch_install.sh +++ b/tests/homebrew_feature_branch_install.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +export HOMEBREW_NO_GITHUB_API=1 require() { command -v "$1" >/dev/null 2>&1 || { @@ -12,6 +13,7 @@ require brew require git require shasum require curl +require swift SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -26,6 +28,8 @@ TAP_ROOT="$(brew --repository)/Library/Taps/${TAP_USER}/homebrew-${TAP_REPO}" FORMULA_PATH="$TAP_ROOT/Formula/${FORMULA_NAME}.rb" SHORT_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD)" +VERSION="0.0.0-feature.${SHORT_SHA}" +STAGE_DIR="${TMPDIR:-/tmp}/afm-api-feature-stage-${SHORT_SHA}" TARBALL="${TMPDIR:-/tmp}/afm-api-feature-${SHORT_SHA}.tar.gz" BACKUP_FORMULA="" CREATED_TAP=0 @@ -45,6 +49,7 @@ cleanup() { brew untap "$TAP_NAME" >/dev/null 2>&1 || true fi + rm -rf "$STAGE_DIR" rm -f "$TARBALL" } trap cleanup EXIT @@ -60,9 +65,17 @@ if [[ -f "$FORMULA_PATH" ]]; then cp "$FORMULA_PATH" "$BACKUP_FORMULA" fi -git -C "$REPO_ROOT" archive --format=tar.gz -o "$TARBALL" HEAD +AFM_API_SOURCE_ROOT="$REPO_ROOT" "$REPO_ROOT/bin/afm-api" build >/dev/null + +rm -rf "$STAGE_DIR" +mkdir -p "$STAGE_DIR" +cp "$REPO_ROOT/bin/afm-api" "$STAGE_DIR/afm-api" +cp "$REPO_ROOT/.build/release/afm-api-server" "$STAGE_DIR/afm-api-server" +chmod +x "$STAGE_DIR/afm-api" "$STAGE_DIR/afm-api-server" +sed -i '' "s/__AFM_API_VERSION__/${VERSION}/g" "$STAGE_DIR/afm-api" + +tar -czf "$TARBALL" -C "$STAGE_DIR" afm-api afm-api-server SHA="$(shasum -a 256 "$TARBALL" | awk '{print $1}')" -VERSION="0.0.0-feature.${SHORT_SHA}" cat > "$FORMULA_PATH" </dev/null +brew reinstall "${TAP_NAME}/${FORMULA_NAME}" >/dev/null 2>&1 || true +brew list --versions "${FORMULA_NAME}" >/dev/null 2>&1 || { + echo "FAIL: Homebrew formula install did not succeed for ${TAP_NAME}/${FORMULA_NAME}" + exit 1 +} afm-api --stop >/dev/null 2>&1 || true -afm-api --rebuild --background >/dev/null +afm-api --background >/dev/null sleep 1 curl -sf "http://127.0.0.1:8000/v1/health" >/dev/null afm-api --stop >/dev/null 2>&1 || true From 2881e8b10e8188b7e2247d9b7ce18c9855eaf03e Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 19:27:15 +0100 Subject: [PATCH 09/18] feat: automate binary release assets and tap updates --- .github/scripts/package_release_binary.sh | 45 +++++++++++++++ .github/scripts/update_homebrew_tap.sh | 31 +++++++---- .github/workflows/build-release-binary.yml | 64 ++++++++++++++++++++++ .github/workflows/release-on-main.yml | 9 --- .github/workflows/update-homebrew-tap.yml | 9 +-- Formula/afm-api.rb | 23 ++++++-- 6 files changed, 147 insertions(+), 34 deletions(-) create mode 100755 .github/scripts/package_release_binary.sh create mode 100644 .github/workflows/build-release-binary.yml diff --git a/.github/scripts/package_release_binary.sh b/.github/scripts/package_release_binary.sh new file mode 100755 index 0000000..b136b09 --- /dev/null +++ b/.github/scripts/package_release_binary.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +RELEASE_TAG="${RELEASE_TAG:-${1:-}}" +REPO_ROOT="${REPO_ROOT:-$(pwd)}" +OUTPUT_DIR="${OUTPUT_DIR:-$REPO_ROOT/dist}" +ASSET_NAME="${ASSET_NAME:-afm-api-macos-arm64.tar.gz}" + +if [[ -z "$RELEASE_TAG" ]]; then + echo "ERROR: RELEASE_TAG is required (e.g. v1.2.3)" + exit 1 +fi +if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "ERROR: RELEASE_TAG must match v.." + exit 1 +fi + +VERSION="${RELEASE_TAG#v}" + +mkdir -p "$OUTPUT_DIR" +rm -rf "$OUTPUT_DIR/stage" +mkdir -p "$OUTPUT_DIR/stage" + +swift build --package-path "$REPO_ROOT" -c release --product afm-api-server + +cp "$REPO_ROOT/bin/afm-api" "$OUTPUT_DIR/stage/afm-api" +cp "$REPO_ROOT/.build/release/afm-api-server" "$OUTPUT_DIR/stage/afm-api-server" +chmod +x "$OUTPUT_DIR/stage/afm-api" "$OUTPUT_DIR/stage/afm-api-server" +perl -pi -e "s/__AFM_API_VERSION__/${VERSION}/g" "$OUTPUT_DIR/stage/afm-api" + +tar -czf "$OUTPUT_DIR/$ASSET_NAME" -C "$OUTPUT_DIR/stage" afm-api afm-api-server +shasum -a 256 "$OUTPUT_DIR/$ASSET_NAME" | awk '{print $1}' > "$OUTPUT_DIR/${ASSET_NAME}.sha256" + +SHA="$(cat "$OUTPUT_DIR/${ASSET_NAME}.sha256")" + +echo "Built asset: $OUTPUT_DIR/$ASSET_NAME" +echo "SHA256: $SHA" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "asset_path=$OUTPUT_DIR/$ASSET_NAME" + echo "sha_path=$OUTPUT_DIR/${ASSET_NAME}.sha256" + echo "sha256=$SHA" + } >> "$GITHUB_OUTPUT" +fi diff --git a/.github/scripts/update_homebrew_tap.sh b/.github/scripts/update_homebrew_tap.sh index 59b7f6d..1359533 100755 --- a/.github/scripts/update_homebrew_tap.sh +++ b/.github/scripts/update_homebrew_tap.sh @@ -3,7 +3,9 @@ set -euo pipefail RELEASE_TAG="${RELEASE_TAG:-${1:-}}" TAP_REPO_PATH="${TAP_REPO_PATH:-${2:-}}" -SOURCE_TARBALL="${SOURCE_TARBALL:-}" +RELEASE_ASSET_FILE="${RELEASE_ASSET_FILE:-}" +RELEASE_ASSET_NAME="${RELEASE_ASSET_NAME:-afm-api-macos-arm64.tar.gz}" +RELEASE_ASSET_URL="${RELEASE_ASSET_URL:-}" REPO_SLUG="${REPO_SLUG:-tankibaj/apple-foundation-model-api}" FORMULA_NAME="${FORMULA_NAME:-afm-api}" @@ -24,7 +26,7 @@ fi VERSION="${RELEASE_TAG#v}" MAJOR_MINOR="$(echo "${VERSION}" | cut -d. -f1,2)" -URL="https://github.com/${REPO_SLUG}/archive/refs/tags/${RELEASE_TAG}.tar.gz" +URL="${RELEASE_ASSET_URL:-https://github.com/${REPO_SLUG}/releases/download/${RELEASE_TAG}/${RELEASE_ASSET_NAME}}" BASE_FORMULA_PATH="${TAP_REPO_PATH}/Formula/${FORMULA_NAME}.rb" VERSIONED_FORMULA_PATH="${TAP_REPO_PATH}/Formula/${FORMULA_NAME}@${MAJOR_MINOR}.rb" @@ -33,19 +35,19 @@ if [[ ! -f "${BASE_FORMULA_PATH}" ]]; then exit 1 fi -TMP_TARBALL="" -if [[ -n "${SOURCE_TARBALL}" ]]; then - if [[ ! -f "${SOURCE_TARBALL}" ]]; then - echo "ERROR: SOURCE_TARBALL not found: ${SOURCE_TARBALL}" +TMP_ASSET="" +if [[ -n "${RELEASE_ASSET_FILE}" ]]; then + if [[ ! -f "${RELEASE_ASSET_FILE}" ]]; then + echo "ERROR: RELEASE_ASSET_FILE not found: ${RELEASE_ASSET_FILE}" exit 1 fi - TMP_TARBALL="${SOURCE_TARBALL}" + TMP_ASSET="${RELEASE_ASSET_FILE}" else - TMP_TARBALL="$(mktemp -t afm-api-release-XXXXXX.tar.gz)" - curl -fsSL "${URL}" -o "${TMP_TARBALL}" + TMP_ASSET="$(mktemp -t afm-api-release-XXXXXX.tar.gz)" + curl -fsSL "${URL}" -o "${TMP_ASSET}" fi -SHA256="$(shasum -a 256 "${TMP_TARBALL}" | awk '{print $1}')" +SHA256="$(shasum -a 256 "${TMP_ASSET}" | awk '{print $1}')" FORMULA_CLASS_BASE="AfmApi" FORMULA_CLASS_VERSIONED="AfmApiAT${MAJOR_MINOR//./}" @@ -54,12 +56,17 @@ update_formula_file() { local file_path="$1" local class_name="$2" - ruby - "${file_path}" "${class_name}" "${URL}" "${SHA256}" <<'RUBY' -path, class_name, url, sha = ARGV + ruby - "${file_path}" "${class_name}" "${URL}" "${SHA256}" "${VERSION}" <<'RUBY' +path, class_name, url, sha, version = ARGV content = File.read(path) content.sub!(/^class\s+\S+\s+<\s+Formula$/, "class #{class_name} < Formula") content.sub!(/^\s*url\s+".*"$/, " url \"#{url}\"") content.sub!(/^\s*sha256\s+".*"$/, " sha256 \"#{sha}\"") +if content.match?(/^\s*version\s+"/) + content.sub!(/^\s*version\s+".*"$/, " version \"#{version}\"") +else + content.sub!(/^\s*url\s+".*"$/, " url \"#{url}\"\n version \"#{version}\"") +end File.write(path, content) RUBY } diff --git a/.github/workflows/build-release-binary.yml b/.github/workflows/build-release-binary.yml new file mode 100644 index 0000000..7302a91 --- /dev/null +++ b/.github/workflows/build-release-binary.yml @@ -0,0 +1,64 @@ +name: Build Release Binary + +on: + release: + types: [published] + workflow_dispatch: + inputs: + release_tag: + description: "Release tag (e.g. v1.2.3)" + required: true + type: string + +permissions: + actions: write + contents: write + +jobs: + build-and-publish: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve release tag + id: vars + run: | + if [ "${{ github.event_name }}" = "release" ]; then + TAG="${GITHUB_REF_NAME}" + else + TAG="${{ inputs.release_tag }}" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Checkout release tag + run: | + git fetch --tags --force + git checkout "${{ steps.vars.outputs.tag }}" + + - name: Build release asset + id: package + env: + RELEASE_TAG: ${{ steps.vars.outputs.tag }} + ASSET_NAME: afm-api-macos-arm64.tar.gz + run: | + ./.github/scripts/package_release_binary.sh + + - name: Upload release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ steps.vars.outputs.tag }}" \ + "${{ steps.package.outputs.asset_path }}" \ + "${{ steps.package.outputs.sha_path }}" \ + --clobber + + - name: Trigger Homebrew tap update + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh workflow run "Update Homebrew Tap" \ + --ref main \ + -f release_tag="${{ steps.vars.outputs.tag }}" diff --git a/.github/workflows/release-on-main.yml b/.github/workflows/release-on-main.yml index ed30002..f897073 100644 --- a/.github/workflows/release-on-main.yml +++ b/.github/workflows/release-on-main.yml @@ -107,15 +107,6 @@ jobs: --title "$tag" \ --generate-notes - - name: Trigger Homebrew tap update - if: steps.version.outputs.should_release == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh workflow run "Update Homebrew Tap" \ - --ref main \ - -f release_tag="${{ steps.version.outputs.next_tag }}" - - name: Summary run: | echo "should_release=${{ steps.version.outputs.should_release }}" diff --git a/.github/workflows/update-homebrew-tap.yml b/.github/workflows/update-homebrew-tap.yml index 65cb106..bfc1d6f 100644 --- a/.github/workflows/update-homebrew-tap.yml +++ b/.github/workflows/update-homebrew-tap.yml @@ -1,8 +1,6 @@ name: Update Homebrew Tap on: - release: - types: [published] workflow_dispatch: inputs: release_tag: @@ -20,11 +18,7 @@ jobs: - name: Resolve release tag id: vars run: | - if [ "${{ github.event_name }}" = "release" ]; then - TAG="${GITHUB_REF_NAME}" - else - TAG="${{ inputs.release_tag }}" - fi + TAG="${{ inputs.release_tag }}" echo "tag=$TAG" >> "$GITHUB_OUTPUT" - name: Checkout Homebrew tap @@ -40,6 +34,7 @@ jobs: TAP_REPO_PATH: homebrew-tap REPO_SLUG: tankibaj/apple-foundation-model-api FORMULA_NAME: afm-api + RELEASE_ASSET_NAME: afm-api-macos-arm64.tar.gz run: | ./.github/scripts/update_homebrew_tap.sh diff --git a/Formula/afm-api.rb b/Formula/afm-api.rb index 8c84a4d..62cbff2 100644 --- a/Formula/afm-api.rb +++ b/Formula/afm-api.rb @@ -8,15 +8,26 @@ class AfmApi < Formula depends_on :macos def install - bin.install "bin/afm-api" - pkgshare.install "Package.swift" - pkgshare.install "Sources" - inreplace bin/"afm-api", "__AFM_API_VERSION__", version.to_s + if File.exist?("afm-api") && File.exist?("afm-api-server") + # Binary release asset path (no local build required). + bin.install "afm-api" + bin.install "afm-api-server" + else + # Source release fallback. + bin.install "bin/afm-api" + pkgshare.install "Package.swift" + pkgshare.install "Sources" + inreplace bin/"afm-api", "__AFM_API_VERSION__", version.to_s + end end test do assert_predicate bin/"afm-api", :exist? - assert_predicate pkgshare/"Package.swift", :exist? - assert_predicate pkgshare/"Sources/AFMAPI/main.swift", :exist? + if (bin/"afm-api-server").exist? + assert_predicate bin/"afm-api-server", :exist? + else + assert_predicate pkgshare/"Package.swift", :exist? + assert_predicate pkgshare/"Sources/AFMAPI/main.swift", :exist? + end end end From d2a0d58c8fcb8c52fd4557aa2b87ee05a0fb7291 Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 19:32:28 +0100 Subject: [PATCH 10/18] feat: add rc prerelease and homebrew rc workflows --- .github/scripts/package_release_binary.sh | 4 +- .github/scripts/update_homebrew_tap.sh | 45 ++++++++++--- .github/workflows/build-rc-pre-release.yml | 70 ++++++++++++++++++++ .github/workflows/update-homebrew-tap-rc.yml | 45 +++++++++++++ Formula/afm-api-rc.rb | 31 +++++++++ 5 files changed, 182 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/build-rc-pre-release.yml create mode 100644 .github/workflows/update-homebrew-tap-rc.yml create mode 100644 Formula/afm-api-rc.rb diff --git a/.github/scripts/package_release_binary.sh b/.github/scripts/package_release_binary.sh index b136b09..5c99b96 100755 --- a/.github/scripts/package_release_binary.sh +++ b/.github/scripts/package_release_binary.sh @@ -10,8 +10,8 @@ if [[ -z "$RELEASE_TAG" ]]; then echo "ERROR: RELEASE_TAG is required (e.g. v1.2.3)" exit 1 fi -if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "ERROR: RELEASE_TAG must match v.." +if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+)*$ ]]; then + echo "ERROR: RELEASE_TAG must match v..[-suffix]" exit 1 fi diff --git a/.github/scripts/update_homebrew_tap.sh b/.github/scripts/update_homebrew_tap.sh index 1359533..fd1281d 100755 --- a/.github/scripts/update_homebrew_tap.sh +++ b/.github/scripts/update_homebrew_tap.sh @@ -8,14 +8,16 @@ RELEASE_ASSET_NAME="${RELEASE_ASSET_NAME:-afm-api-macos-arm64.tar.gz}" RELEASE_ASSET_URL="${RELEASE_ASSET_URL:-}" REPO_SLUG="${REPO_SLUG:-tankibaj/apple-foundation-model-api}" FORMULA_NAME="${FORMULA_NAME:-afm-api}" +UPDATE_VERSIONED_FORMULA="${UPDATE_VERSIONED_FORMULA:-1}" +TEMPLATE_FORMULA_PATH="${TEMPLATE_FORMULA_PATH:-}" if [[ -z "${RELEASE_TAG}" || -z "${TAP_REPO_PATH}" ]]; then echo "Usage: RELEASE_TAG=v1.0.2 TAP_REPO_PATH=/path/to/homebrew-tap $0" exit 1 fi -if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "ERROR: RELEASE_TAG must match v.., got: ${RELEASE_TAG}" +if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+)*$ ]]; then + echo "ERROR: RELEASE_TAG must match v..[-suffix], got: ${RELEASE_TAG}" exit 1 fi @@ -31,8 +33,12 @@ BASE_FORMULA_PATH="${TAP_REPO_PATH}/Formula/${FORMULA_NAME}.rb" VERSIONED_FORMULA_PATH="${TAP_REPO_PATH}/Formula/${FORMULA_NAME}@${MAJOR_MINOR}.rb" if [[ ! -f "${BASE_FORMULA_PATH}" ]]; then - echo "ERROR: base formula not found: ${BASE_FORMULA_PATH}" - exit 1 + if [[ -n "${TEMPLATE_FORMULA_PATH}" && -f "${TEMPLATE_FORMULA_PATH}" ]]; then + cp "${TEMPLATE_FORMULA_PATH}" "${BASE_FORMULA_PATH}" + else + echo "ERROR: base formula not found: ${BASE_FORMULA_PATH}" + exit 1 + fi fi TMP_ASSET="" @@ -49,8 +55,19 @@ fi SHA256="$(shasum -a 256 "${TMP_ASSET}" | awk '{print $1}')" -FORMULA_CLASS_BASE="AfmApi" -FORMULA_CLASS_VERSIONED="AfmApiAT${MAJOR_MINOR//./}" +camelize_formula_name() { + echo "$1" | awk -F'[-@.]' '{ + out="" + for (i=1; i<=NF; i++) { + if ($i == "") continue + out = out toupper(substr($i,1,1)) substr($i,2) + } + print out + }' +} + +FORMULA_CLASS_BASE="$(camelize_formula_name "${FORMULA_NAME}")" +FORMULA_CLASS_VERSIONED="${FORMULA_CLASS_BASE}AT${MAJOR_MINOR//./}" update_formula_file() { local file_path="$1" @@ -59,7 +76,9 @@ update_formula_file() { ruby - "${file_path}" "${class_name}" "${URL}" "${SHA256}" "${VERSION}" <<'RUBY' path, class_name, url, sha, version = ARGV content = File.read(path) -content.sub!(/^class\s+\S+\s+<\s+Formula$/, "class #{class_name} < Formula") +if class_name && !class_name.empty? + content.sub!(/^class\s+\S+\s+<\s+Formula$/, "class #{class_name} < Formula") +end content.sub!(/^\s*url\s+".*"$/, " url \"#{url}\"") content.sub!(/^\s*sha256\s+".*"$/, " sha256 \"#{sha}\"") if content.match?(/^\s*version\s+"/) @@ -73,12 +92,16 @@ RUBY update_formula_file "${BASE_FORMULA_PATH}" "${FORMULA_CLASS_BASE}" -if [[ ! -f "${VERSIONED_FORMULA_PATH}" ]]; then - cp "${BASE_FORMULA_PATH}" "${VERSIONED_FORMULA_PATH}" +if [[ "${UPDATE_VERSIONED_FORMULA}" == "1" ]]; then + if [[ ! -f "${VERSIONED_FORMULA_PATH}" ]]; then + cp "${BASE_FORMULA_PATH}" "${VERSIONED_FORMULA_PATH}" + fi + update_formula_file "${VERSIONED_FORMULA_PATH}" "${FORMULA_CLASS_VERSIONED}" fi -update_formula_file "${VERSIONED_FORMULA_PATH}" "${FORMULA_CLASS_VERSIONED}" echo "Updated: ${BASE_FORMULA_PATH}" -echo "Updated: ${VERSIONED_FORMULA_PATH}" +if [[ "${UPDATE_VERSIONED_FORMULA}" == "1" ]]; then + echo "Updated: ${VERSIONED_FORMULA_PATH}" +fi echo "Release: ${RELEASE_TAG}" echo "SHA256: ${SHA256}" diff --git a/.github/workflows/build-rc-pre-release.yml b/.github/workflows/build-rc-pre-release.yml new file mode 100644 index 0000000..717140a --- /dev/null +++ b/.github/workflows/build-rc-pre-release.yml @@ -0,0 +1,70 @@ +name: Build RC Pre-release + +on: + push: + branches: + - "feature/**" + workflow_dispatch: + inputs: + release_tag: + description: "Optional RC tag override (e.g. v1.2.0-rc.feature-x)" + required: false + type: string + +permissions: + actions: write + contents: write + +jobs: + build-and-publish-rc: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve RC tag + id: vars + shell: bash + run: | + if [[ -n "${{ inputs.release_tag }}" ]]; then + TAG="${{ inputs.release_tag }}" + else + BRANCH_SLUG="$(echo "${GITHUB_REF_NAME}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" + TAG="v0.0.0-rc.${BRANCH_SLUG}" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Build release asset + id: package + env: + RELEASE_TAG: ${{ steps.vars.outputs.tag }} + ASSET_NAME: afm-api-macos-arm64.tar.gz + run: | + ./.github/scripts/package_release_binary.sh + + - name: Publish RC pre-release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ steps.vars.outputs.tag }}" + gh release delete "$TAG" --yes --cleanup-tag >/dev/null 2>&1 || true + gh release create "$TAG" \ + --target "$GITHUB_SHA" \ + --title "$TAG" \ + --notes "Automated pre-release for branch ${GITHUB_REF_NAME}." \ + --prerelease + + gh release upload "$TAG" \ + "${{ steps.package.outputs.asset_path }}" \ + "${{ steps.package.outputs.sha_path }}" \ + --clobber + + - name: Trigger Homebrew RC tap update + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh workflow run "Update Homebrew Tap RC" \ + --ref main \ + -f release_tag="${{ steps.vars.outputs.tag }}" diff --git a/.github/workflows/update-homebrew-tap-rc.yml b/.github/workflows/update-homebrew-tap-rc.yml new file mode 100644 index 0000000..b37d3d9 --- /dev/null +++ b/.github/workflows/update-homebrew-tap-rc.yml @@ -0,0 +1,45 @@ +name: Update Homebrew Tap RC + +on: + workflow_dispatch: + inputs: + release_tag: + description: "RC/pre-release tag (e.g. v1.2.0-rc.1)" + required: true + type: string + +jobs: + update-rc: + runs-on: macos-latest + steps: + - name: Checkout source repo + uses: actions/checkout@v4 + + - name: Checkout Homebrew tap + uses: actions/checkout@v4 + with: + repository: tankibaj/homebrew-tap + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: homebrew-tap + + - name: Update RC formula + env: + RELEASE_TAG: ${{ inputs.release_tag }} + TAP_REPO_PATH: homebrew-tap + REPO_SLUG: tankibaj/apple-foundation-model-api + FORMULA_NAME: afm-api-rc + RELEASE_ASSET_NAME: afm-api-macos-arm64.tar.gz + UPDATE_VERSIONED_FORMULA: "0" + TEMPLATE_FORMULA_PATH: Formula/afm-api-rc.rb + run: | + ./.github/scripts/update_homebrew_tap.sh + + - name: Commit and push RC formula updates + working-directory: homebrew-tap + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Formula/afm-api-rc.rb + git diff --cached --quiet && echo "No RC formula changes" && exit 0 + git commit -m "feat: afm-api-rc ${{ inputs.release_tag }}" + git push origin HEAD diff --git a/Formula/afm-api-rc.rb b/Formula/afm-api-rc.rb new file mode 100644 index 0000000..19f78ba --- /dev/null +++ b/Formula/afm-api-rc.rb @@ -0,0 +1,31 @@ +class AfmApiRc < Formula + desc "OpenAI-compatible local server for Apple Foundation Model (release candidate)" + homepage "https://github.com/tankibaj/apple-foundation-model-api" + url "https://github.com/tankibaj/apple-foundation-model-api/archive/refs/tags/v1.1.1.tar.gz" + sha256 "e8b84865dc93f1aeaf0a86a9d3149254a3be3640199c6a1e1fe36bd55c7fcfa6" + license "MIT" + + depends_on :macos + + def install + if File.exist?("afm-api") && File.exist?("afm-api-server") + bin.install "afm-api" + bin.install "afm-api-server" + else + bin.install "bin/afm-api" + pkgshare.install "Package.swift" + pkgshare.install "Sources" + inreplace bin/"afm-api", "__AFM_API_VERSION__", version.to_s + end + end + + test do + assert_predicate bin/"afm-api", :exist? + if (bin/"afm-api-server").exist? + assert_predicate bin/"afm-api-server", :exist? + else + assert_predicate pkgshare/"Package.swift", :exist? + assert_predicate pkgshare/"Sources/AFMAPI/main.swift", :exist? + end + end +end From 719b3750d9ce0c189b8e84418b0bd92277cf1002 Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 19:38:17 +0100 Subject: [PATCH 11/18] fix: align release fallback asset layout with formula --- .github/scripts/package_release_binary.sh | 34 ++++++++++++++++++----- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/.github/scripts/package_release_binary.sh b/.github/scripts/package_release_binary.sh index 5c99b96..c92d5b6 100755 --- a/.github/scripts/package_release_binary.sh +++ b/.github/scripts/package_release_binary.sh @@ -5,6 +5,7 @@ RELEASE_TAG="${RELEASE_TAG:-${1:-}}" REPO_ROOT="${REPO_ROOT:-$(pwd)}" OUTPUT_DIR="${OUTPUT_DIR:-$REPO_ROOT/dist}" ASSET_NAME="${ASSET_NAME:-afm-api-macos-arm64.tar.gz}" +ALLOW_SOURCE_FALLBACK="${AFM_ALLOW_SOURCE_FALLBACK:-1}" if [[ -z "$RELEASE_TAG" ]]; then echo "ERROR: RELEASE_TAG is required (e.g. v1.2.3)" @@ -21,25 +22,44 @@ mkdir -p "$OUTPUT_DIR" rm -rf "$OUTPUT_DIR/stage" mkdir -p "$OUTPUT_DIR/stage" -swift build --package-path "$REPO_ROOT" -c release --product afm-api-server - -cp "$REPO_ROOT/bin/afm-api" "$OUTPUT_DIR/stage/afm-api" -cp "$REPO_ROOT/.build/release/afm-api-server" "$OUTPUT_DIR/stage/afm-api-server" -chmod +x "$OUTPUT_DIR/stage/afm-api" "$OUTPUT_DIR/stage/afm-api-server" -perl -pi -e "s/__AFM_API_VERSION__/${VERSION}/g" "$OUTPUT_DIR/stage/afm-api" +ASSET_MODE="binary" +if swift build --package-path "$REPO_ROOT" -c release --product afm-api-server; then + cp "$REPO_ROOT/bin/afm-api" "$OUTPUT_DIR/stage/afm-api" + cp "$REPO_ROOT/.build/release/afm-api-server" "$OUTPUT_DIR/stage/afm-api-server" + chmod +x "$OUTPUT_DIR/stage/afm-api" "$OUTPUT_DIR/stage/afm-api-server" + perl -pi -e "s/__AFM_API_VERSION__/${VERSION}/g" "$OUTPUT_DIR/stage/afm-api" +else + if [[ "$ALLOW_SOURCE_FALLBACK" != "1" ]]; then + echo "ERROR: release binary build failed and source fallback is disabled." + exit 1 + fi + ASSET_MODE="source-fallback" + mkdir -p "$OUTPUT_DIR/stage/bin" + cp "$REPO_ROOT/bin/afm-api" "$OUTPUT_DIR/stage/bin/afm-api" + cp "$REPO_ROOT/Package.swift" "$OUTPUT_DIR/stage/Package.swift" + cp -R "$REPO_ROOT/Sources" "$OUTPUT_DIR/stage/Sources" + chmod +x "$OUTPUT_DIR/stage/bin/afm-api" + perl -pi -e "s/__AFM_API_VERSION__/${VERSION}/g" "$OUTPUT_DIR/stage/bin/afm-api" +fi -tar -czf "$OUTPUT_DIR/$ASSET_NAME" -C "$OUTPUT_DIR/stage" afm-api afm-api-server +if [[ "$ASSET_MODE" == "binary" ]]; then + tar -czf "$OUTPUT_DIR/$ASSET_NAME" -C "$OUTPUT_DIR/stage" afm-api afm-api-server +else + tar -czf "$OUTPUT_DIR/$ASSET_NAME" -C "$OUTPUT_DIR/stage" bin Package.swift Sources +fi shasum -a 256 "$OUTPUT_DIR/$ASSET_NAME" | awk '{print $1}' > "$OUTPUT_DIR/${ASSET_NAME}.sha256" SHA="$(cat "$OUTPUT_DIR/${ASSET_NAME}.sha256")" echo "Built asset: $OUTPUT_DIR/$ASSET_NAME" echo "SHA256: $SHA" +echo "Mode: $ASSET_MODE" if [[ -n "${GITHUB_OUTPUT:-}" ]]; then { echo "asset_path=$OUTPUT_DIR/$ASSET_NAME" echo "sha_path=$OUTPUT_DIR/${ASSET_NAME}.sha256" echo "sha256=$SHA" + echo "asset_mode=$ASSET_MODE" } >> "$GITHUB_OUTPUT" fi From 4b826356a792984dd736e249031a5a1bd9c603ec Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 19:56:30 +0100 Subject: [PATCH 12/18] fix: trigger rc tap workflow on current branch --- .github/workflows/build-rc-pre-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-rc-pre-release.yml b/.github/workflows/build-rc-pre-release.yml index 717140a..9b6aa13 100644 --- a/.github/workflows/build-rc-pre-release.yml +++ b/.github/workflows/build-rc-pre-release.yml @@ -65,6 +65,6 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh workflow run "Update Homebrew Tap RC" \ - --ref main \ + gh workflow run update-homebrew-tap-rc.yml \ + --ref "${GITHUB_REF_NAME}" \ -f release_tag="${{ steps.vars.outputs.tag }}" From 4a9051a2a30405b87e811bd1d0a1f0066b3d6b0d Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 19:57:32 +0100 Subject: [PATCH 13/18] fix: make rc tap dispatch non-fatal before main merge --- .github/workflows/build-rc-pre-release.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-rc-pre-release.yml b/.github/workflows/build-rc-pre-release.yml index 9b6aa13..aab4701 100644 --- a/.github/workflows/build-rc-pre-release.yml +++ b/.github/workflows/build-rc-pre-release.yml @@ -65,6 +65,10 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh workflow run update-homebrew-tap-rc.yml \ + if gh workflow run update-homebrew-tap-rc.yml \ --ref "${GITHUB_REF_NAME}" \ - -f release_tag="${{ steps.vars.outputs.tag }}" + -f release_tag="${{ steps.vars.outputs.tag }}"; then + echo "Triggered Update Homebrew Tap RC" + else + echo "Skipping Update Homebrew Tap RC (workflow must exist on default branch for workflow_dispatch)." + fi From 5fc0a50d647c900937dc6879b950b512b28edbc0 Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 19:58:53 +0100 Subject: [PATCH 14/18] chore: mark rc releases as explicit draft prereleases --- .github/workflows/build-rc-pre-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-rc-pre-release.yml b/.github/workflows/build-rc-pre-release.yml index aab4701..39229ef 100644 --- a/.github/workflows/build-rc-pre-release.yml +++ b/.github/workflows/build-rc-pre-release.yml @@ -54,6 +54,7 @@ jobs: --target "$GITHUB_SHA" \ --title "$TAG" \ --notes "Automated pre-release for branch ${GITHUB_REF_NAME}." \ + --draft \ --prerelease gh release upload "$TAG" \ From d71e3d4f359806f88866f99cba2a6760c7b129c7 Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 20:34:44 +0100 Subject: [PATCH 15/18] feat: auto-update prerelease tap from feature rc workflow --- .github/workflows/build-rc-pre-release.yml | 54 ++++++++++++++------ .github/workflows/update-homebrew-tap-rc.yml | 2 +- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-rc-pre-release.yml b/.github/workflows/build-rc-pre-release.yml index 39229ef..92920c0 100644 --- a/.github/workflows/build-rc-pre-release.yml +++ b/.github/workflows/build-rc-pre-release.yml @@ -32,7 +32,8 @@ jobs: TAG="${{ inputs.release_tag }}" else BRANCH_SLUG="$(echo "${GITHUB_REF_NAME}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" - TAG="v0.0.0-rc.${BRANCH_SLUG}" + SHORT_SHA="$(git rev-parse --short "${GITHUB_SHA}")" + TAG="v0.0.0-rc.${BRANCH_SLUG}.${SHORT_SHA}" fi echo "tag=$TAG" >> "$GITHUB_OUTPUT" @@ -49,27 +50,46 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="${{ steps.vars.outputs.tag }}" - gh release delete "$TAG" --yes --cleanup-tag >/dev/null 2>&1 || true - gh release create "$TAG" \ - --target "$GITHUB_SHA" \ - --title "$TAG" \ - --notes "Automated pre-release for branch ${GITHUB_REF_NAME}." \ - --draft \ - --prerelease + if ! gh release view "$TAG" >/dev/null 2>&1; then + gh release create "$TAG" \ + --target "$GITHUB_SHA" \ + --title "$TAG" \ + --notes "Automated pre-release for branch ${GITHUB_REF_NAME}." \ + --prerelease + fi + + gh release edit "$TAG" --draft=false --prerelease gh release upload "$TAG" \ "${{ steps.package.outputs.asset_path }}" \ "${{ steps.package.outputs.sha_path }}" \ --clobber - - name: Trigger Homebrew RC tap update + - name: Checkout Homebrew prerelease tap + uses: actions/checkout@v4 + with: + repository: tankibaj/homebrew-tap-prerelease + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: homebrew-tap-prerelease + + - name: Update prerelease tap formula env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.vars.outputs.tag }} + TAP_REPO_PATH: homebrew-tap-prerelease + REPO_SLUG: tankibaj/apple-foundation-model-api + FORMULA_NAME: afm-api-rc + RELEASE_ASSET_NAME: afm-api-macos-arm64.tar.gz + UPDATE_VERSIONED_FORMULA: "0" + TEMPLATE_FORMULA_PATH: Formula/afm-api-rc.rb run: | - if gh workflow run update-homebrew-tap-rc.yml \ - --ref "${GITHUB_REF_NAME}" \ - -f release_tag="${{ steps.vars.outputs.tag }}"; then - echo "Triggered Update Homebrew Tap RC" - else - echo "Skipping Update Homebrew Tap RC (workflow must exist on default branch for workflow_dispatch)." - fi + ./.github/scripts/update_homebrew_tap.sh + + - name: Commit and push prerelease tap updates + working-directory: homebrew-tap-prerelease + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Formula/afm-api-rc.rb + git diff --cached --quiet && echo "No prerelease formula changes" && exit 0 + git commit -m "feat: afm-api-rc ${{ steps.vars.outputs.tag }}" + git push origin HEAD diff --git a/.github/workflows/update-homebrew-tap-rc.yml b/.github/workflows/update-homebrew-tap-rc.yml index b37d3d9..d3ea792 100644 --- a/.github/workflows/update-homebrew-tap-rc.yml +++ b/.github/workflows/update-homebrew-tap-rc.yml @@ -18,7 +18,7 @@ jobs: - name: Checkout Homebrew tap uses: actions/checkout@v4 with: - repository: tankibaj/homebrew-tap + repository: tankibaj/homebrew-tap-prerelease token: ${{ secrets.HOMEBREW_TAP_TOKEN }} path: homebrew-tap From c982285dc00bc78f7f1139b2fc99426d274ee062 Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 20:50:37 +0100 Subject: [PATCH 16/18] fix: split stable and rc tap flows with robust version detection --- .github/scripts/update_homebrew_tap.sh | 13 ++++++++----- .github/workflows/build-rc-pre-release.yml | 1 + .github/workflows/build-release-binary.yml | 1 + .github/workflows/update-homebrew-tap-rc.yml | 7 ++++--- Formula/afm-api-rc.rb | 5 ++++- Formula/afm-api.rb | 5 ++++- bin/afm-api | 7 ++++++- 7 files changed, 28 insertions(+), 11 deletions(-) diff --git a/.github/scripts/update_homebrew_tap.sh b/.github/scripts/update_homebrew_tap.sh index fd1281d..18f2ac9 100755 --- a/.github/scripts/update_homebrew_tap.sh +++ b/.github/scripts/update_homebrew_tap.sh @@ -10,6 +10,7 @@ REPO_SLUG="${REPO_SLUG:-tankibaj/apple-foundation-model-api}" FORMULA_NAME="${FORMULA_NAME:-afm-api}" UPDATE_VERSIONED_FORMULA="${UPDATE_VERSIONED_FORMULA:-1}" TEMPLATE_FORMULA_PATH="${TEMPLATE_FORMULA_PATH:-}" +SYNC_TEMPLATE_ALWAYS="${SYNC_TEMPLATE_ALWAYS:-0}" if [[ -z "${RELEASE_TAG}" || -z "${TAP_REPO_PATH}" ]]; then echo "Usage: RELEASE_TAG=v1.0.2 TAP_REPO_PATH=/path/to/homebrew-tap $0" @@ -32,15 +33,17 @@ URL="${RELEASE_ASSET_URL:-https://github.com/${REPO_SLUG}/releases/download/${RE BASE_FORMULA_PATH="${TAP_REPO_PATH}/Formula/${FORMULA_NAME}.rb" VERSIONED_FORMULA_PATH="${TAP_REPO_PATH}/Formula/${FORMULA_NAME}@${MAJOR_MINOR}.rb" -if [[ ! -f "${BASE_FORMULA_PATH}" ]]; then - if [[ -n "${TEMPLATE_FORMULA_PATH}" && -f "${TEMPLATE_FORMULA_PATH}" ]]; then +if [[ -n "${TEMPLATE_FORMULA_PATH}" && -f "${TEMPLATE_FORMULA_PATH}" ]]; then + if [[ "${SYNC_TEMPLATE_ALWAYS}" == "1" || ! -f "${BASE_FORMULA_PATH}" ]]; then cp "${TEMPLATE_FORMULA_PATH}" "${BASE_FORMULA_PATH}" - else - echo "ERROR: base formula not found: ${BASE_FORMULA_PATH}" - exit 1 fi fi +if [[ ! -f "${BASE_FORMULA_PATH}" ]]; then + echo "ERROR: base formula not found: ${BASE_FORMULA_PATH}" + exit 1 +fi + TMP_ASSET="" if [[ -n "${RELEASE_ASSET_FILE}" ]]; then if [[ ! -f "${RELEASE_ASSET_FILE}" ]]; then diff --git a/.github/workflows/build-rc-pre-release.yml b/.github/workflows/build-rc-pre-release.yml index 92920c0..ec2fdbf 100644 --- a/.github/workflows/build-rc-pre-release.yml +++ b/.github/workflows/build-rc-pre-release.yml @@ -81,6 +81,7 @@ jobs: RELEASE_ASSET_NAME: afm-api-macos-arm64.tar.gz UPDATE_VERSIONED_FORMULA: "0" TEMPLATE_FORMULA_PATH: Formula/afm-api-rc.rb + SYNC_TEMPLATE_ALWAYS: "1" run: | ./.github/scripts/update_homebrew_tap.sh diff --git a/.github/workflows/build-release-binary.yml b/.github/workflows/build-release-binary.yml index 7302a91..23c519a 100644 --- a/.github/workflows/build-release-binary.yml +++ b/.github/workflows/build-release-binary.yml @@ -16,6 +16,7 @@ permissions: jobs: build-and-publish: + if: github.event_name != 'release' || github.event.release.prerelease == false runs-on: macos-latest steps: - name: Checkout diff --git a/.github/workflows/update-homebrew-tap-rc.yml b/.github/workflows/update-homebrew-tap-rc.yml index d3ea792..f5fb2c1 100644 --- a/.github/workflows/update-homebrew-tap-rc.yml +++ b/.github/workflows/update-homebrew-tap-rc.yml @@ -20,22 +20,23 @@ jobs: with: repository: tankibaj/homebrew-tap-prerelease token: ${{ secrets.HOMEBREW_TAP_TOKEN }} - path: homebrew-tap + path: homebrew-tap-prerelease - name: Update RC formula env: RELEASE_TAG: ${{ inputs.release_tag }} - TAP_REPO_PATH: homebrew-tap + TAP_REPO_PATH: homebrew-tap-prerelease REPO_SLUG: tankibaj/apple-foundation-model-api FORMULA_NAME: afm-api-rc RELEASE_ASSET_NAME: afm-api-macos-arm64.tar.gz UPDATE_VERSIONED_FORMULA: "0" TEMPLATE_FORMULA_PATH: Formula/afm-api-rc.rb + SYNC_TEMPLATE_ALWAYS: "1" run: | ./.github/scripts/update_homebrew_tap.sh - name: Commit and push RC formula updates - working-directory: homebrew-tap + working-directory: homebrew-tap-prerelease run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" diff --git a/Formula/afm-api-rc.rb b/Formula/afm-api-rc.rb index 19f78ba..3c7aac9 100644 --- a/Formula/afm-api-rc.rb +++ b/Formula/afm-api-rc.rb @@ -15,7 +15,10 @@ def install bin.install "bin/afm-api" pkgshare.install "Package.swift" pkgshare.install "Sources" - inreplace bin/"afm-api", "__AFM_API_VERSION__", version.to_s + launcher = bin/"afm-api" + if launcher.read.include?("__AFM_API_VERSION__") + inreplace launcher, "__AFM_API_VERSION__", version.to_s + end end end diff --git a/Formula/afm-api.rb b/Formula/afm-api.rb index 62cbff2..594a408 100644 --- a/Formula/afm-api.rb +++ b/Formula/afm-api.rb @@ -17,7 +17,10 @@ def install bin.install "bin/afm-api" pkgshare.install "Package.swift" pkgshare.install "Sources" - inreplace bin/"afm-api", "__AFM_API_VERSION__", version.to_s + launcher = bin/"afm-api" + if launcher.read.include?("__AFM_API_VERSION__") + inreplace launcher, "__AFM_API_VERSION__", version.to_s + end end end diff --git a/bin/afm-api b/bin/afm-api index 87899ba..8d7e41e 100755 --- a/bin/afm-api +++ b/bin/afm-api @@ -126,7 +126,9 @@ else fi resolve_version() { - if [[ "$EMBEDDED_VERSION" != "__AFM_API_VERSION__" && -n "$EMBEDDED_VERSION" ]]; then + # When packaged, EMBEDDED_VERSION is replaced with a concrete version string. + # Keep this check resilient to in-place replacement by avoiding exact placeholder literals. + if [[ -n "$EMBEDDED_VERSION" && "$EMBEDDED_VERSION" != *"AFM_API_VERSION"* ]]; then echo "$EMBEDDED_VERSION" return fi @@ -143,6 +145,9 @@ resolve_version() { if command -v brew >/dev/null 2>&1; then local brew_version brew_version="$(brew list --versions afm-api 2>/dev/null | awk '{print $2}' | head -n1 || true)" + if [[ -z "$brew_version" ]]; then + brew_version="$(brew list --versions afm-api-rc 2>/dev/null | awk '{print $2}' | head -n1 || true)" + fi if [[ -n "$brew_version" ]]; then echo "$brew_version" return From 9462a41ec612f2d45ae45560a2a9a297ff7d2cfb Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 20:52:24 +0100 Subject: [PATCH 17/18] fix: use monotonic rc tag version for brew upgrades --- .github/workflows/build-rc-pre-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-rc-pre-release.yml b/.github/workflows/build-rc-pre-release.yml index ec2fdbf..7335cca 100644 --- a/.github/workflows/build-rc-pre-release.yml +++ b/.github/workflows/build-rc-pre-release.yml @@ -33,7 +33,7 @@ jobs: else BRANCH_SLUG="$(echo "${GITHUB_REF_NAME}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" SHORT_SHA="$(git rev-parse --short "${GITHUB_SHA}")" - TAG="v0.0.0-rc.${BRANCH_SLUG}.${SHORT_SHA}" + TAG="v0.0.0-rc.${BRANCH_SLUG}.r${GITHUB_RUN_NUMBER}.${SHORT_SHA}" fi echo "tag=$TAG" >> "$GITHUB_OUTPUT" From a9e5c41430d8025592077a210577630f713047e3 Mon Sep 17 00:00:00 2001 From: Naim Ahmmed Date: Sun, 8 Feb 2026 21:19:57 +0100 Subject: [PATCH 18/18] feat: add perf scripts and async runtime logging --- Sources/AFMAPI/server/RequestProcessor.swift | 9 +- Sources/AFMAPI/support/Logging.swift | 135 +++++++++++----- tests/completion_and_tool_calling.sh | 113 +++++++++++++ tests/perf_chat_and_tools.sh | 160 +++++++++++++++++++ 4 files changed, 375 insertions(+), 42 deletions(-) create mode 100755 tests/completion_and_tool_calling.sh create mode 100755 tests/perf_chat_and_tools.sh diff --git a/Sources/AFMAPI/server/RequestProcessor.swift b/Sources/AFMAPI/server/RequestProcessor.swift index 723fea0..951d07c 100644 --- a/Sources/AFMAPI/server/RequestProcessor.swift +++ b/Sources/AFMAPI/server/RequestProcessor.swift @@ -1,5 +1,10 @@ import Foundation +private let requestLogsEnabled: Bool = { + let value = ProcessInfo.processInfo.environment["AFM_API_REQUEST_LOGS"]?.lowercased() ?? "1" + return value != "0" && value != "false" && value != "off" +}() + final class RequestProcessor { let cfg: AppConfig @@ -11,7 +16,9 @@ final class RequestProcessor { let started = Date() func finish(_ status: Int, _ payload: Any) -> Data { let ms = Int(Date().timeIntervalSince(started) * 1000.0) - logLine("[\(status)] \(method) \(path) \(ms)ms") + if requestLogsEnabled { + logLine("[\(status)] \(method) \(path) \(ms)ms") + } return httpResponse(status: status, body: jsonData(payload)) } diff --git a/Sources/AFMAPI/support/Logging.swift b/Sources/AFMAPI/support/Logging.swift index 6e47b5f..02f7fd0 100644 --- a/Sources/AFMAPI/support/Logging.swift +++ b/Sources/AFMAPI/support/Logging.swift @@ -1,55 +1,108 @@ import Foundation import Darwin -let logFilePath = ProcessInfo.processInfo.environment["AFM_API_LOG_FILE"] -let logMaxBytes = Int64(ProcessInfo.processInfo.environment["AFM_API_LOG_MAX_BYTES"] ?? "") ?? 10 * 1024 * 1024 -let logMaxFilesRaw = Int(ProcessInfo.processInfo.environment["AFM_API_LOG_MAX_FILES"] ?? "") ?? 3 -let logMaxFiles = max(1, logMaxFilesRaw) -let logFileLock = NSLock() -let stdoutIsTTY = isatty(fileno(stdout)) == 1 - -func rotateLogIfNeeded(path: String) { - guard logMaxBytes > 0 else { return } - guard FileManager.default.fileExists(atPath: path) else { return } - - guard let attrs = try? FileManager.default.attributesOfItem(atPath: path), - let sizeNum = attrs[.size] as? NSNumber else { - return +final class RuntimeLogger { + static let shared = RuntimeLogger() + + private let logFilePath: String? + private let logMaxBytes: Int64 + private let logMaxFiles: Int + private let stdoutIsTTY: Bool + private let queue = DispatchQueue(label: "afm-api.runtime-logger", qos: .utility) + private let fm = FileManager.default + + private var fileHandle: FileHandle? + private var fileSize: Int64 = 0 + + private init() { + self.logFilePath = ProcessInfo.processInfo.environment["AFM_API_LOG_FILE"] + self.logMaxBytes = Int64(ProcessInfo.processInfo.environment["AFM_API_LOG_MAX_BYTES"] ?? "") ?? 10 * 1024 * 1024 + let logMaxFilesRaw = Int(ProcessInfo.processInfo.environment["AFM_API_LOG_MAX_FILES"] ?? "") ?? 3 + self.logMaxFiles = max(1, logMaxFilesRaw) + self.stdoutIsTTY = isatty(fileno(stdout)) == 1 + + guard let path = logFilePath, !path.isEmpty else { return } + openHandle(path: path) + } + + deinit { + try? fileHandle?.close() } - let size = sizeNum.int64Value - guard size >= logMaxBytes else { return } + func log(_ message: String) { + guard stdoutIsTTY || (logFilePath?.isEmpty == false) else { return } + queue.async { [self] in + if stdoutIsTTY { + fputs(message + "\n", stdout) + } + guard let path = logFilePath, !path.isEmpty else { return } + guard let data = (message + "\n").data(using: .utf8) else { return } - let fm = FileManager.default - for idx in stride(from: logMaxFiles - 1, through: 0, by: -1) { - let src = idx == 0 ? path : "\(path).\(idx)" - let dst = "\(path).\(idx + 1)" + rotateIfNeeded(path: path, incomingBytes: Int64(data.count)) + if fileHandle == nil { + openHandle(path: path) + } + guard let handle = fileHandle else { return } - guard fm.fileExists(atPath: src) else { continue } - if fm.fileExists(atPath: dst) { - try? fm.removeItem(atPath: dst) + do { + _ = try handle.seekToEnd() + try handle.write(contentsOf: data) + fileSize += Int64(data.count) + } catch { + try? handle.close() + fileHandle = nil + } } - try? fm.moveItem(atPath: src, toPath: dst) } -} -func logLine(_ message: String) { - if stdoutIsTTY { - print(message) + private func openHandle(path: String) { + let url = URL(fileURLWithPath: path) + let dir = url.deletingLastPathComponent().path + try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true) + + if !fm.fileExists(atPath: path) { + fm.createFile(atPath: path, contents: nil) + } + + if let attrs = try? fm.attributesOfItem(atPath: path), + let sizeNum = attrs[.size] as? NSNumber { + fileSize = sizeNum.int64Value + } else { + fileSize = 0 + } + + do { + fileHandle = try FileHandle(forWritingTo: url) + _ = try fileHandle?.seekToEnd() + } catch { + fileHandle = nil + } } - guard let path = logFilePath, !path.isEmpty else { return } - logFileLock.lock() - defer { logFileLock.unlock() } - let line = message + "\n" - guard let data = line.data(using: .utf8) else { return } - rotateLogIfNeeded(path: path) - if FileManager.default.fileExists(atPath: path) { - if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) { - defer { try? handle.close() } - _ = try? handle.seekToEnd() - try? handle.write(contentsOf: data) + + private func rotateIfNeeded(path: String, incomingBytes: Int64) { + guard logMaxBytes > 0 else { return } + guard fileSize + incomingBytes >= logMaxBytes else { return } + + try? fileHandle?.close() + fileHandle = nil + + for idx in stride(from: logMaxFiles - 1, through: 0, by: -1) { + let src = idx == 0 ? path : "\(path).\(idx)" + let dst = "\(path).\(idx + 1)" + + guard fm.fileExists(atPath: src) else { continue } + if fm.fileExists(atPath: dst) { + try? fm.removeItem(atPath: dst) + } + try? fm.moveItem(atPath: src, toPath: dst) } - } else { - FileManager.default.createFile(atPath: path, contents: data) + + fm.createFile(atPath: path, contents: nil) + fileSize = 0 + openHandle(path: path) } } + +func logLine(_ message: String) { + RuntimeLogger.shared.log(message) +} diff --git a/tests/completion_and_tool_calling.sh b/tests/completion_and_tool_calling.sh new file mode 100755 index 0000000..46e6a67 --- /dev/null +++ b/tests/completion_and_tool_calling.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +require() { + command -v "$1" >/dev/null 2>&1 || { + echo "FAIL: missing command: $1" + exit 1 + } +} + +require curl +require jq + +BASE_URL="${1:-http://127.0.0.1:8000}" +API_VERSION="${API_VERSION:-v1}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +AFM_API_BIN="${AFM_API_BIN:-$REPO_ROOT/bin/afm-api}" +RUNTIME_DIR="/tmp/afm-api-smoke" +STARTED_SERVER=0 + +extract_host() { + echo "$1" | sed -E 's#^https?://([^:/]+).*$#\1#' +} + +extract_port() { + local p + p="$(echo "$1" | sed -nE 's#^https?://[^:/]+:([0-9]+).*$#\1#p')" + if [[ -n "$p" ]]; then + echo "$p" + else + echo "8000" + fi +} + +cleanup() { + if [[ "$STARTED_SERVER" == "1" ]]; then + AFM_API_RUNTIME_DIR="$RUNTIME_DIR" "$AFM_API_BIN" --stop >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +if ! curl -sf "$BASE_URL/$API_VERSION/health" >/dev/null 2>&1; then + if [[ ! -x "$AFM_API_BIN" ]]; then + echo "FAIL: server is not reachable at $BASE_URL and no local afm-api launcher found at $AFM_API_BIN" + exit 1 + fi + + HOST="$(extract_host "$BASE_URL")" + PORT="$(extract_port "$BASE_URL")" + AFM_API_RUNTIME_DIR="$RUNTIME_DIR" AFM_API_SOURCE_ROOT="$REPO_ROOT" "$AFM_API_BIN" --background --host "$HOST" --port "$PORT" >/dev/null + STARTED_SERVER=1 + + for _ in $(seq 1 120); do + if curl -sf "$BASE_URL/$API_VERSION/health" >/dev/null 2>&1; then + break + fi + sleep 0.1 + done +fi + +MODEL_ID="$(curl -s "$BASE_URL/$API_VERSION/models" | jq -r '.data[0].id // empty')" +if [[ -z "$MODEL_ID" ]]; then + echo "FAIL: could not resolve model id from $BASE_URL/$API_VERSION/models" + exit 1 +fi + +completion_payload="$(jq -nc --arg model "$MODEL_ID" '{model:$model,messages:[{role:"user",content:"Reply with one word: hello"}],temperature:0}')" +completion_resp="$(curl -s "$BASE_URL/$API_VERSION/chat/completions" -H 'content-type: application/json' -d "$completion_payload")" +completion_text="$(jq -r '.choices[0].message.content // empty' <<<"$completion_resp")" +if [[ -z "$completion_text" ]]; then + echo "FAIL: completion returned empty text" + echo "$completion_resp" | jq + exit 1 +fi + +first_tool_payload="$(jq -nc --arg model "$MODEL_ID" '{model:$model,messages:[{role:"user",content:"Use get_weather for Berlin."}],tools:[{type:"function",function:{name:"get_weather",description:"Get weather by city",parameters:{type:"object",properties:{city:{type:"string"}},required:["city"]}}}],tool_choice:{type:"function",function:{name:"get_weather"}}}')" +first_tool_resp="$(curl -s "$BASE_URL/$API_VERSION/chat/completions" -H 'content-type: application/json' -d "$first_tool_payload")" + +finish_reason="$(jq -r '.choices[0].finish_reason // empty' <<<"$first_tool_resp")" +tool_name="$(jq -r '.choices[0].message.tool_calls[0].function.name // empty' <<<"$first_tool_resp")" +tool_args="$(jq -r '.choices[0].message.tool_calls[0].function.arguments // "{}"' <<<"$first_tool_resp")" + +if [[ "$finish_reason" != "tool_calls" || "$tool_name" != "get_weather" ]]; then + echo "FAIL: expected tool call to get_weather" + echo "$first_tool_resp" | jq + exit 1 +fi + +mock_tool_result='{"city":"Berlin","temperature_c":8,"condition":"Cloudy"}' +second_tool_payload="$(jq -nc \ + --arg model "$MODEL_ID" \ + --arg args "$tool_args" \ + --arg toolContent "$mock_tool_result" \ + '{model:$model,messages:[ + {role:"user",content:"Use get_weather for Berlin."}, + {role:"assistant",content:null,tool_calls:[{id:"call_1",type:"function",function:{name:"get_weather",arguments:$args}}]}, + {role:"tool",name:"get_weather",content:$toolContent} + ]}')" + +second_tool_resp="$(curl -s "$BASE_URL/$API_VERSION/chat/completions" -H 'content-type: application/json' -d "$second_tool_payload")" +second_text="$(jq -r '.choices[0].message.content // empty' <<<"$second_tool_resp")" + +if [[ -z "$second_text" ]]; then + echo "FAIL: final response after tool result is empty" + echo "$second_tool_resp" | jq + exit 1 +fi + +echo "PASS: completion and tool-calling flow works" +echo "PASS info: completion_text=$completion_text" +echo "PASS info: tool_name=$tool_name tool_args=$tool_args" +echo "PASS assistant: $second_text" diff --git a/tests/perf_chat_and_tools.sh b/tests/perf_chat_and_tools.sh new file mode 100755 index 0000000..36b78f3 --- /dev/null +++ b/tests/perf_chat_and_tools.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +set -euo pipefail + +require() { + command -v "$1" >/dev/null 2>&1 || { + echo "FAIL: missing command: $1" + exit 1 + } +} + +require curl +require jq +require awk +require sort + +BASE_URL="${1:-http://127.0.0.1:8000}" +API_VERSION="${API_VERSION:-v1}" +WARMUP="${WARMUP:-5}" +COMPLETION_N="${COMPLETION_N:-20}" +TOOL_N="${TOOL_N:-20}" +AFM_API_REQUEST_LOGS="${AFM_API_REQUEST_LOGS:-0}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +AFM_API_BIN="${AFM_API_BIN:-$REPO_ROOT/bin/afm-api}" + +extract_host() { + echo "$1" | sed -E 's#^https?://([^:/]+).*$#\1#' +} + +extract_port() { + local p + p="$(echo "$1" | sed -nE 's#^https?://[^:/]+:([0-9]+).*$#\1#p')" + if [[ -n "$p" ]]; then + echo "$p" + else + echo "8000" + fi +} + +PARSER_HOST="$(extract_host "$BASE_URL")" +PARSER_PORT="$(extract_port "$BASE_URL")" +RUNTIME_DIR="/tmp/afm-api-perf" +STARTED_SERVER=0 + +cleanup() { + if [[ "$STARTED_SERVER" == "1" ]]; then + AFM_API_RUNTIME_DIR="$RUNTIME_DIR" "$AFM_API_BIN" --stop >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +if ! curl -sf "$BASE_URL/$API_VERSION/health" >/dev/null 2>&1; then + if [[ ! -x "$AFM_API_BIN" ]]; then + echo "FAIL: server is not reachable at $BASE_URL and no local afm-api launcher found at $AFM_API_BIN" + exit 1 + fi + + AFM_API_RUNTIME_DIR="$RUNTIME_DIR" AFM_API_SOURCE_ROOT="$REPO_ROOT" AFM_API_REQUEST_LOGS="$AFM_API_REQUEST_LOGS" \ + "$AFM_API_BIN" --background --host "$PARSER_HOST" --port "$PARSER_PORT" >/dev/null + STARTED_SERVER=1 + + for _ in $(seq 1 120); do + if curl -sf "$BASE_URL/$API_VERSION/health" >/dev/null 2>&1; then + break + fi + sleep 0.1 + done +fi + +if ! curl -sf "$BASE_URL/$API_VERSION/health" >/dev/null 2>&1; then + echo "FAIL: server not reachable at $BASE_URL" + exit 1 +fi + +MODEL_ID="$(curl -s "$BASE_URL/$API_VERSION/models" | jq -r '.data[0].id // empty')" +if [[ -z "$MODEL_ID" ]]; then + echo "FAIL: could not resolve model id from $BASE_URL/$API_VERSION/models" + exit 1 +fi + +pct_ms() { + local file="$1" + local p="$2" + sort -n "$file" | awk -v p="$p" '{a[NR]=$1} END { if (NR==0) { print "0.00"; exit } idx=int((NR-1)*p)+1; printf "%.2f", a[idx]*1000 }' +} + +avg_ms() { + local file="$1" + awk '{s+=$1} END { if (NR==0) { print "0.00"; exit } printf "%.2f", (s/NR)*1000 }' "$file" +} + +run_measurement() { + local kind="$1" + local count="$2" + local warmup="$3" + local times_file="$4" + + : > "$times_file" + + local payload + if [[ "$kind" == "completion" ]]; then + payload="$(jq -nc --arg model "$MODEL_ID" '{model:$model,messages:[{role:"user",content:"Reply with exactly OK."}],temperature:0}')" + else + payload="$(jq -nc --arg model "$MODEL_ID" '{model:$model,messages:[{role:"user",content:"Use get_weather for Berlin."}],tools:[{type:"function",function:{name:"get_weather",description:"Get weather",parameters:{type:"object",properties:{city:{type:"string"}},required:["city"]}}}],tool_choice:{type:"function",function:{name:"get_weather"}}}')" + fi + + local total=$((count + warmup)) + local i + for i in $(seq 1 "$total"); do + local raw body t + raw="$(curl -s "$BASE_URL/$API_VERSION/chat/completions" -H 'content-type: application/json' -d "$payload" -w $'\n%{time_total}')" + body="${raw%$'\n'*}" + t="${raw##*$'\n'}" + + if [[ "$kind" == "completion" ]]; then + local txt + txt="$(jq -r '.choices[0].message.content // empty' <<<"$body")" + if [[ -z "$txt" ]]; then + echo "FAIL: completion response missing content" + echo "$body" | jq + exit 1 + fi + else + local finish tool + finish="$(jq -r '.choices[0].finish_reason // empty' <<<"$body")" + tool="$(jq -r '.choices[0].message.tool_calls[0].function.name // empty' <<<"$body")" + if [[ "$finish" != "tool_calls" || -z "$tool" ]]; then + echo "FAIL: tool-call response invalid" + echo "$body" | jq + exit 1 + fi + fi + + if [[ "$i" -gt "$warmup" ]]; then + echo "$t" >> "$times_file" + fi + done +} + +TMP_DIR="$(mktemp -d /tmp/afm-perf.XXXXXX)" +trap 'rm -rf "$TMP_DIR"; cleanup' EXIT + +COMPLETION_TIMES="$TMP_DIR/completion.times" +TOOL_TIMES="$TMP_DIR/tool.times" + +run_measurement completion "$COMPLETION_N" "$WARMUP" "$COMPLETION_TIMES" +run_measurement tool "$TOOL_N" "$WARMUP" "$TOOL_TIMES" + +c_avg="$(avg_ms "$COMPLETION_TIMES")" +c_p50="$(pct_ms "$COMPLETION_TIMES" 0.50)" +c_p95="$(pct_ms "$COMPLETION_TIMES" 0.95)" + +t_avg="$(avg_ms "$TOOL_TIMES")" +t_p50="$(pct_ms "$TOOL_TIMES" 0.50)" +t_p95="$(pct_ms "$TOOL_TIMES" 0.95)" + +echo "PASS: perf benchmark completed" +echo "PASS info: completion n=$COMPLETION_N warmup=$WARMUP avg_ms=$c_avg p50_ms=$c_p50 p95_ms=$c_p95" +echo "PASS info: tool_call n=$TOOL_N warmup=$WARMUP avg_ms=$t_avg p50_ms=$t_p50 p95_ms=$t_p95"