Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions Perspective Server/AppLogStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import Combine
import Foundation
import OSLog

enum AppLogSeverity: String, CaseIterable, Sendable {
case debug
case info
case warning
case error

var label: String {
rawValue.uppercased()
}

var osLogType: OSLogType {
switch self {
case .debug:
return .debug
case .info:
return .info
case .warning:
return .default
case .error:
return .error
}
}
}

struct AppLogEntry: Identifiable, Sendable {
let id = UUID()
let timestamp: Date
let severity: AppLogSeverity
let source: String
let message: String
}

@MainActor
final class AppLogStore: ObservableObject {
static let shared = AppLogStore()

@Published private(set) var entries: [AppLogEntry] = []

private let maxEntries = 500
private let copyDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()

private init() {}

func append(_ message: String, severity: AppLogSeverity, source: String) {
let entry = AppLogEntry(timestamp: Date(), severity: severity, source: source, message: message)
entries.append(entry)
if entries.count > maxEntries {
entries.removeFirst(entries.count - maxEntries)
}
}

func clear() {
entries.removeAll()
}

func exportText() -> String {
entries.map { entry in
let stamp = copyDateFormatter.string(from: entry.timestamp)
return "[\(stamp)] [\(entry.severity.label)] [\(entry.source)] \(entry.message)"
}
.joined(separator: "\n")
}
}

enum AppLog {
private static let logger = Logger(subsystem: "com.example.PerspectiveServer", category: "AppLog")

static func debug(_ message: String, source: String = "app") {
write(message, severity: .debug, source: source)
}

static func info(_ message: String, source: String = "app") {
write(message, severity: .info, source: source)
}

static func warning(_ message: String, source: String = "app") {
write(message, severity: .warning, source: source)
}

static func error(_ message: String, source: String = "app") {
write(message, severity: .error, source: source)
}

private static func write(_ message: String, severity: AppLogSeverity, source: String) {
switch severity {
case .debug:
logger.debug("[\(source, privacy: .public)] \(message, privacy: .public)")
case .info:
logger.info("[\(source, privacy: .public)] \(message, privacy: .public)")
case .warning:
logger.notice("[\(source, privacy: .public)] \(message, privacy: .public)")
case .error:
logger.error("[\(source, privacy: .public)] \(message, privacy: .public)")
}

Task { @MainActor in
AppLogStore.shared.append(message, severity: severity, source: source)
}
}
}
15 changes: 14 additions & 1 deletion Perspective Server/FoundationModelsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -408,10 +408,12 @@ nonisolated final class FoundationModelsService: @unchecked Sendable {
// Build a context-aware prompt that fits within the model's context by summarizing older content when needed.
let prompt = await prepareChatPrompt(messages: request.messages, model: request.model, temperature: request.temperature, maxTokens: request.max_tokens)
logger.log("[chat] model=\(request.model, privacy: .public) messages=\(request.messages.count) promptLen=\(prompt.count)")
AppLog.info("Inference started for model \(request.model)", source: "inference")

// Call into Foundation Models.
let output = try await generateText(model: request.model, prompt: prompt, temperature: request.temperature, maxTokens: request.max_tokens)
logger.log("[chat] outputLen=\(output.count)")
AppLog.info("Inference completed (\(output.count) chars)", source: "inference")

let elapsed = ContinuousClock.now - inferenceStart
let ttft = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
Expand Down Expand Up @@ -509,6 +511,7 @@ nonisolated final class FoundationModelsService: @unchecked Sendable {
break
case .unavailable(let reason):
logger.error("[fm-stream] Model unavailable: \(String(describing: reason))")
AppLog.error("Streaming unavailable: \(String(describing: reason))", source: "inference")
throw NSError(
domain: "FoundationModelsService", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Model unavailable: \(String(describing: reason))"]
Expand Down Expand Up @@ -547,6 +550,7 @@ nonisolated final class FoundationModelsService: @unchecked Sendable {
let prompt = userMessage

logger.log("[fm-stream] starting stream, prompt len=\(prompt.count), cached=\(isExistingSession)")
AppLog.info("Streaming inference started (cached session: \(isExistingSession))", source: "inference")

do {
let stream = session.streamResponse(to: prompt)
Expand Down Expand Up @@ -580,6 +584,7 @@ nonisolated final class FoundationModelsService: @unchecked Sendable {
if isSoftRefusal {
// Evict the poisoned session so the next message gets a fresh one
logger.warning("[fm-stream] Soft refusal detected for session \(sessionID, privacy: .public) — evicting to prevent refusal spiral")
AppLog.warning("Soft refusal detected; session reset", source: "guardrail")
await sessionManager.store(sessionID, session: LanguageModelSession(instructions: instructions))
} else {
// Cache the healthy session for reuse
Expand All @@ -588,6 +593,7 @@ nonisolated final class FoundationModelsService: @unchecked Sendable {

let cachedCount = await sessionManager.count
logger.log("[fm-stream] stream complete, total len=\(lastContent.count), refusal=\(isSoftRefusal), session=\(sessionID, privacy: .public), cached sessions=\(cachedCount)")
AppLog.info("Streaming inference complete (\(lastContent.count) chars)", source: "inference")
} catch {
// Handle ALL errors gracefully to prevent the "Unable to stream" fallback.
// Always evict the session on error — a poisoned transcript causes refusal spirals.
Expand All @@ -596,8 +602,10 @@ nonisolated final class FoundationModelsService: @unchecked Sendable {

if isGuardrail {
logger.warning("[fm-stream] Guardrail/refusal for session \(sessionID, privacy: .public) — evicting session: \(errorDesc.prefix(120), privacy: .public)")
AppLog.warning("Guardrail/refusal triggered during streaming; returned friendly fallback", source: "guardrail")
} else {
logger.error("[fm-stream] Stream error for session \(sessionID, privacy: .public) — evicting session: \(errorDesc.prefix(200), privacy: .public)")
AppLog.error("Streaming inference error: \(String(errorDesc.prefix(160)))", source: "inference")
}

// Always evict — any error during streaming may have corrupted the session transcript
Expand Down Expand Up @@ -1122,6 +1130,7 @@ nonisolated final class FoundationModelsService: @unchecked Sendable {
private func generateText(model: String, prompt: String, temperature: Double?, maxTokens: Int?) async throws -> String {
// Prefer Apple Intelligence on supported platforms; otherwise return a graceful fallback
logger.log("Generating text (FoundationModels if available, else fallback)")
AppLog.debug("Generating text request received for model \(model)", source: "inference")

#if canImport(FoundationModels)
logger.log("[fm] FoundationModels framework is available at compile time")
Expand All @@ -1131,13 +1140,16 @@ nonisolated final class FoundationModelsService: @unchecked Sendable {
return try await generateWithFoundationModels(model: model, prompt: prompt, temperature: temperature)
} catch {
logger.error("FoundationModels failed: \(String(describing: error))")
AppLog.error("FoundationModels failed, using fallback response: \(error.localizedDescription)", source: "fallback")
// Fall through to fallback message below without truncating the prompt
}
} else {
logger.warning("[fm] Runtime availability check FAILED - macOS 26.0+ required. Current OS version does not meet requirements.")
AppLog.warning("FoundationModels runtime unavailable; using fallback response", source: "fallback")
}
#else
logger.warning("[fm] FoundationModels framework NOT available at compile time")
AppLog.warning("FoundationModels not compiled in; using fallback response", source: "fallback")
#endif

// Fallback path when FoundationModels is not available on this platform/SDK.
Expand Down Expand Up @@ -1177,11 +1189,13 @@ nonisolated final class FoundationModelsService: @unchecked Sendable {
do {
let response = try await session.respond(to: prompt)
logger.log("[fm] got response len=\(response.content.count)")
AppLog.info("Non-stream inference completed (\(response.content.count) chars)", source: "inference")
return response.content
} catch {
let errorDesc = String(reflecting: error).lowercased()
if errorDesc.contains("guardrailviolation") || errorDesc.contains("refusal") {
logger.warning("[fm] Guardrail/refusal hit — returning friendly message")
AppLog.warning("Guardrail/refusal triggered; returned friendly fallback", source: "guardrail")
return "I'm not able to help with that particular request. Could you try rephrasing or asking something different?"
}
throw error
Expand Down Expand Up @@ -1535,4 +1549,3 @@ nonisolated private final class ToolsRegistry: @unchecked Sendable {
return nil
}
}

21 changes: 19 additions & 2 deletions Perspective Server/LocalHTTPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ actor LocalHTTPServer {
private func generatePairingCode() {
pairingCode = String(format: "%06d", Int.random(in: 0...999999))
logger.log("Pairing code generated: \(self.pairingCode, privacy: .public)")
AppLog.info("Generated new pairing code", source: "server")
}

private init() {}
Expand Down Expand Up @@ -108,6 +109,7 @@ actor LocalHTTPServer {
func start() async {
guard !isRunning else {
logger.log("Server already running, ignoring start request")
AppLog.warning("Start requested while already running", source: "server")
return
}
lastError = nil
Expand Down Expand Up @@ -143,16 +145,19 @@ actor LocalHTTPServer {
}
listener?.start(queue: DispatchQueue.global())
logger.log("Server starting on port \(targetPort)...")
AppLog.info("Attempting to bind localhost:\(targetPort)", source: "server")
} catch {
lastError = "Failed to create listener on port \(targetPort): \(error.localizedDescription)"
logger.error("Failed to start listener: \(String(describing: error))")
AppLog.error("Failed to create listener on port \(targetPort): \(error.localizedDescription)", source: "server")
// Try next port
currentPortIndex += 1
await tryStartOnNextPort()
}
}

func stop() async {
AppLog.info("Shutting down listener and active connections", source: "server")
listener?.cancel()
listener = nil
connections.forEach { $0.cancel() }
Expand Down Expand Up @@ -181,6 +186,7 @@ actor LocalHTTPServer {
let validatedCorsOrigin = Self.isAllowedOrigin(origin) ? (origin ?? "") : ""
if !Self.isAllowedHost(host, serverPort: serverPort) {
logger.warning("[req:\(rid, privacy: .public)] Blocked: invalid Host header '\(host ?? "nil", privacy: .public)'")
AppLog.warning("Blocked request with invalid Host header (\(host ?? "nil"))", source: "auth")
let msg = ["error": ["message": "Forbidden: invalid Host header"]]
let data = (try? JSONSerialization.data(withJSONObject: msg, options: [])) ?? Data()
return .normal(HTTPResponse(status: 403, headers: Self.jsonHeaders(corsOrigin: validatedCorsOrigin), body: data))
Expand All @@ -189,6 +195,7 @@ actor LocalHTTPServer {
// 2. Validate Origin header (cross-origin protection)
if !Self.isAllowedOrigin(origin) {
logger.warning("[req:\(rid, privacy: .public)] Blocked: disallowed Origin '\(origin ?? "nil", privacy: .public)'")
AppLog.warning("Blocked request with disallowed Origin (\(origin ?? "nil"))", source: "auth")
let msg = ["error": ["message": "Forbidden: origin not allowed"]]
let data = (try? JSONSerialization.data(withJSONObject: msg, options: [])) ?? Data()
return .normal(HTTPResponse(status: 403, headers: Self.jsonHeaders(), body: data))
Expand Down Expand Up @@ -242,6 +249,7 @@ actor LocalHTTPServer {
let data = (try? JSONSerialization.data(withJSONObject: obj, options: [])) ?? Data()
return .normal(HTTPResponse(status: 200, headers: Self.jsonHeaders(corsOrigin: corsOrigin), body: data))
} else {
AppLog.warning("Pairing verification failed: invalid code", source: "auth")
let msg = ["error": ["message": "Invalid pairing code"]]
let data = (try? JSONSerialization.data(withJSONObject: msg, options: [])) ?? Data()
return .normal(HTTPResponse(status: 403, headers: Self.jsonHeaders(corsOrigin: corsOrigin), body: data))
Expand All @@ -259,9 +267,10 @@ actor LocalHTTPServer {
}()

// Basic request logging for troubleshooting
let contentType = request.headers["content-type"] ?? request.headers["Content-Type"] ?? ""
let contentLength = request.headers["content-length"] ?? request.headers["Content-Length"] ?? ""
let contentType = request.headers["content-type"] ?? request.headers["Content-Type"] ?? ""
let contentLength = request.headers["content-length"] ?? request.headers["Content-Length"] ?? ""
logger.log("[req:\(rid, privacy: .public)] HTTP \(request.method, privacy: .public) \(path, privacy: .public) ct=\(contentType, privacy: .public) cl=\(contentLength, privacy: .public)")
AppLog.debug("HTTP \(request.method) \(path)", source: "request")
if request.method == "POST" {
logger.log("[req:\(rid, privacy: .public)] body: \(Self.truncateBodyForLog(request.bodyData), privacy: .public)")
}
Expand Down Expand Up @@ -757,6 +766,7 @@ actor LocalHTTPServer {
}
} catch {
logger.error("[req:\(rid, privacy: .public)] /v1/chat/completions error: \(String(describing: error), privacy: .public) body=\(Self.truncateBodyForLog(request.bodyData), privacy: .public)")
AppLog.error("Inference request failed at /v1/chat/completions: \(error.localizedDescription)", source: "inference")
let msg = ["error": ["message": error.localizedDescription]]
let data = try? JSONSerialization.data(withJSONObject: msg, options: [])
return .normal(HTTPResponse(status: 400, headers: [
Expand All @@ -767,6 +777,7 @@ actor LocalHTTPServer {
}
// Not Found
logger.error("[req:\(rid, privacy: .public)] 404 Not Found \(path, privacy: .public)")
AppLog.warning("Request returned 404 for path \(path)", source: "request")
let body = Data("Not Found".utf8)
return .normal(HTTPResponse(status: 404, headers: [
"Content-Type": "text/plain",
Expand All @@ -780,6 +791,7 @@ actor LocalHTTPServer {
switch state {
case .ready:
logger.log("HTTP server listening on localhost:\(currentPort) (loopback only)")
AppLog.info("Server listening on localhost:\(currentPort)", source: "server")
isRunning = true
lastError = nil
case .failed(let error):
Expand All @@ -793,6 +805,7 @@ actor LocalHTTPServer {
}

logger.error("Listener failed on port \(currentPort): \(String(describing: error))")
AppLog.error("Listener failed on port \(currentPort): \(error.localizedDescription)", source: "server")
listener?.cancel()
listener = nil
isRunning = false
Expand All @@ -802,16 +815,20 @@ actor LocalHTTPServer {
currentPortIndex += 1
if currentPortIndex < fallbackPorts.count {
logger.log("Port \(currentPort) in use, trying next port...")
AppLog.warning("Port \(currentPort) already in use, trying fallback", source: "server")
await tryStartOnNextPort()
} else {
lastError = "All ports in use. Tried: \(fallbackPorts.map(String.init).joined(separator: ", "))"
logger.error("\(self.lastError ?? "")")
AppLog.error(lastError ?? "All ports in use", source: "server")
}
} else {
lastError = "Server failed: \(error.localizedDescription)"
AppLog.error(lastError ?? "Server failed", source: "server")
}
case .cancelled:
logger.log("Listener cancelled")
AppLog.info("Server listener cancelled", source: "server")
isRunning = false
default:
break
Expand Down
1 change: 1 addition & 0 deletions Perspective Server/Perspective_ServerApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate, SPUStand
func applicationDidFinishLaunching(_ notification: Notification) {
// Disable window state restoration to prevent previously opened windows from appearing
UserDefaults.standard.set(false, forKey: "NSQuitAlwaysKeepsWindows")
AppLog.info("Application launched", source: "app")
}

func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
Expand Down
5 changes: 5 additions & 0 deletions Perspective Server/RelayClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ actor RelayClient {
func connect() async {
guard !isEnabled else {
logger.log("RelayClient.connect() skipped — already enabled")
AppLog.debug("Connect skipped because relay is already enabled", source: "relay")
return
}
isEnabled = true
Expand Down Expand Up @@ -114,6 +115,7 @@ actor RelayClient {
let delay = reconnectDelay
reconnectDelay = min(reconnectDelay * 2, maxReconnectDelay)
logger.log("Scheduling reconnect in \(delay, privacy: .public)s")
AppLog.warning("Relay reconnect scheduled in \(Int(delay))s", source: "relay")
updateStatus(.disconnected)

reconnectTask = Task { [weak self] in
Expand Down Expand Up @@ -207,10 +209,12 @@ actor RelayClient {
case "error":
let msg = json["message"] as? String ?? "Unknown relay error"
logger.error("Relay error: \(msg, privacy: .public)")
AppLog.error("Relay error: \(msg)", source: "relay")
// If the relay token was rejected, clear it so next reconnect uses pairing code
if msg.contains("relay token") {
UserDefaults.standard.removeObject(forKey: relayTokenKey)
logger.log("Cleared invalid relay token")
AppLog.warning("Cleared invalid relay token after auth failure", source: "relay")
}
updateStatus(.error(msg))

Expand All @@ -234,6 +238,7 @@ actor RelayClient {
let code = await LocalHTTPServer.shared.pairingCode
guard !code.isEmpty else {
logger.error("No pairing code available, cannot authenticate")
AppLog.error("Relay auth failed: no pairing code available", source: "relay")
updateStatus(.error("No pairing code"))
return
}
Expand Down
Loading