Files
oai-swift/oAI/Services/JarvisService.swift
T
rune 76b58e6fdc Rename app from oAI to Confab
"oAI" reads as easily confused with OpenAI, both visually and in
casual conversation. Renamed to "Confab" throughout: Xcode
target/scheme/bundle ID (com.oai.Confab), Info.plist and Help Book
identity, all user-facing UI text, internal Log subsystem and color
identifiers, localization catalogs (6 languages, including a proper
reworded/retranslated Intel-deprecation notice), Help Book HTML
content, and docs (README/DEVELOPMENT/PRIVACY/SECURITY).

Deliberately cosmetic-only: the on-disk data folder
(~/Library/Application Support/oAI/), database/backup filenames,
Keychain service identifiers, and EncryptionService's key-derivation
inputs are all left untouched so existing conversations, settings,
and stored API keys survive the update with zero migration and no
re-entering credentials. Verified live: a real signed build
successfully decrypted a stored API key and loaded an existing
conversation database after the bundle ID change.

Also includes a small already-completed, previously uncommitted
model-release-date feature (ModelInfo/OpenRouterModels/
OpenRouterProvider/ModelInfoView) that happened to share several
files with this rename.

Gitignored on this branch and updated on disk but not part of this
commit: CLAUDE.md, RELEASE_NOTES.md, and the build*.sh scripts.
2026-08-02 14:58:14 +02:00

157 lines
5.1 KiB
Swift

//
// JarvisService.swift
// Confab
//
// HTTP client for the Jarvis (oAI-Web) REST API.
// Auth: Authorization: Bearer <api-key>
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Foundation
import os
final class JarvisService: Sendable {
static let shared = JarvisService()
private init() {}
private let log = Logger(subsystem: Log.subsystem, category: "jarvis")
private var baseURL: String { SettingsService.shared.jarvisURL }
private var apiKey: String? { SettingsService.shared.jarvisAPIKey }
// MARK: - Agents
func listAgents() async throws -> [JarvisAgent] {
try await get("/api/agents")
}
func createAgent(_ input: JarvisAgentInput) async throws -> JarvisAgent {
try await post("/api/agents", body: input)
}
func updateAgent(id: String, _ input: JarvisAgentInput) async throws -> JarvisAgent {
try await put("/api/agents/\(id)", body: input)
}
func deleteAgent(id: String) async throws {
try await voidRequest("DELETE", path: "/api/agents/\(id)")
}
func toggleAgent(id: String) async throws -> JarvisAgent {
try await post("/api/agents/\(id)/toggle", body: Empty())
}
func runAgent(id: String) async throws {
try await voidRequest("POST", path: "/api/agents/\(id)/run")
}
func stopAgent(id: String) async throws {
try await voidRequest("POST", path: "/api/agents/\(id)/stop")
}
func agentRuns(id: String) async throws -> [JarvisAgentRun] {
try await get("/api/agents/\(id)/runs")
}
// MARK: - Usage
func usage() async throws -> [JarvisUsageStat] {
let data = try await rawData("GET", path: "/api/usage")
// API returns {summary:{...}, by_agent:[...], chat:{...}}
if let w = try? JSONDecoder().decode(JarvisUsageResponse.self, from: data) {
return w.byAgent ?? []
}
// Fallback: plain array
if let arr = try? JSONDecoder().decode([JarvisUsageStat].self, from: data) { return arr }
return []
}
func credits() async throws -> JarvisCreditsResponse {
try await get("/api/usage/openrouter-credits")
}
// MARK: - Control
func queueStatus() async throws -> JarvisQueueStatus {
try await get("/api/status")
}
func pauseAll() async throws {
try await voidRequest("POST", path: "/api/pause")
}
func resumeAll() async throws {
try await voidRequest("POST", path: "/api/resume")
}
// MARK: - Connection test
func testConnection() async -> Bool {
guard !baseURL.isEmpty, let key = apiKey, !key.isEmpty else { return false }
do {
let _: [JarvisAgent] = try await get("/api/agents")
return true
} catch {
log.warning("Jarvis connection test failed: \(error.localizedDescription)")
return false
}
}
// MARK: - HTTP core
private func get<T: Decodable>(_ path: String) async throws -> T {
let data = try await rawData("GET", path: path)
return try JSONDecoder().decode(T.self, from: data)
}
private func post<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
let data = try await rawData("POST", path: path, body: body)
return try JSONDecoder().decode(T.self, from: data)
}
private func put<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
let data = try await rawData("PUT", path: path, body: body)
return try JSONDecoder().decode(T.self, from: data)
}
private func voidRequest(_ method: String, path: String) async throws {
_ = try await rawData(method, path: path)
}
private func rawData<B: Encodable>(_ method: String, path: String, body: B? = nil as Empty?) async throws -> Data {
guard let url = URL(string: baseURL.trimmingCharacters(in: .whitespaces) + path) else {
throw JarvisError.invalidURL
}
guard let key = apiKey, !key.isEmpty else {
throw JarvisError.noAPIKey
}
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.timeoutInterval = 30
if let body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(body)
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw JarvisError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
let msg = (try? JSONDecoder().decode(JarvisErrBody.self, from: data))?.detail ?? "HTTP \(http.statusCode)"
log.error("Jarvis \(method) \(path)\(http.statusCode): \(msg)")
throw JarvisError.serverError(http.statusCode, msg)
}
return data
}
}
private struct Empty: Codable {}
private struct JarvisErrBody: Decodable { let detail: String? }