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
29 changes: 28 additions & 1 deletion app/.package.resolved
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"originHash" : "b249855f804deae42e940172e4709aa3266c4019f646e9063d8be08e0746ce8c",
"originHash" : "f02263b61f37b5e295a5032b43f64716c46db240ad8d18119a9acfb3d8a45d95",
"pins" : [
{
"identity" : "alerttoast",
Expand Down Expand Up @@ -115,6 +115,15 @@
"version" : "1.3.0"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5fa253428866f2360c3754e88537f700ed2656b5",
"version" : "1.4.1"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
Expand Down Expand Up @@ -151,6 +160,15 @@
"version" : "26.0.0-rc.1"
}
},
{
"identity" : "swiftui-math",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swiftui-math",
"state" : {
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
"version" : "0.1.0"
}
},
{
"identity" : "swiftui-webview",
"kind" : "remoteSourceControl",
Expand All @@ -169,6 +187,15 @@
"version" : "0.2.4"
}
},
{
"identity" : "textual",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/textual",
"state" : {
"revision" : "01b51875a5406eefc95f52a058cb059e7bc94dc4",
"version" : "0.5.0"
}
},
{
"identity" : "whatsnewkit",
"kind" : "remoteSourceControl",
Expand Down
2 changes: 2 additions & 0 deletions app/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ let project = Project(
.remote(url: "https://github.com/joshbirnholz/WhatsNewKit", requirement: .revision("f509ee14716567e2155eae2f0910184a19f08428")),
.remote(url: "https://github.com/apple/swift-collections", requirement: .exact("1.3.0")),
.remote(url: "https://github.com/Chronos2500/CustomNavigationTitle", requirement: .revision("37269a4478a9f7596ea8382ce3bf006530b1ad12")),
.remote(url: "https://github.com/gonzalezreal/textual", requirement: .exact("0.5.0")),
],
targets: [
// iOS App Target
Expand Down Expand Up @@ -63,6 +64,7 @@ let project = Project(
.package(product: "WhatsNewKit"),
.package(product: "Collections"),
.package(product: "CustomNavigationTitle"),
.package(product: "Textual"),
.xcframework(path: "../out/logic-ios.xcframework"),
],
settings: .settings(
Expand Down
70 changes: 70 additions & 0 deletions app/Shared/ChatSDK/ChatConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//
// ChatConfiguration.swift
// MNGA
//

import Foundation

struct ChatAPIConfiguration: Equatable {
static let defaultBaseURL = "https://api.openai.com/v1"
static let defaultModel = "gpt-5.6-luna"

let baseURL: URL
let apiKey: String
let model: String

init(baseURL: String, apiKey: String, model: String) throws {
self.baseURL = try Self.validate(baseURL: baseURL)

let apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !apiKey.isEmpty else { throw ChatClientError.missingAPIKey }
self.apiKey = apiKey

let model = model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !model.isEmpty else { throw ChatClientError.missingModel }
self.model = model
}

static func validate(baseURL rawValue: String) throws -> URL {
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard var components = URLComponents(string: value),
let scheme = components.scheme?.lowercased(),
let host = components.host,
!host.isEmpty,
components.query == nil,
components.fragment == nil
else {
throw ChatClientError.invalidBaseURL
}

let isLoopback = host == "localhost" || host == "127.0.0.1" || host == "::1"
guard scheme == "https" || (scheme == "http" && isLoopback) else {
throw ChatClientError.insecureBaseURL
}

while components.path.count > 1, components.path.hasSuffix("/") {
components.path.removeLast()
}
guard let url = components.url else { throw ChatClientError.invalidBaseURL }
return url
}

var chatCompletionsURL: URL {
if baseURL.path.hasSuffix("/chat/completions") {
return baseURL
}
if baseURL.path.hasSuffix("/v1") || baseURL.path.hasSuffix("/beta") {
return baseURL.appending(path: "chat/completions")
}
return baseURL.appending(path: "v1/chat/completions")
}

var isDeepSeek: Bool {
guard let host = baseURL.host?.lowercased() else { return false }
return host == "deepseek.com" || host.hasSuffix(".deepseek.com")
}

var supportsStrictTools: Bool {
!isDeepSeek || baseURL.path.split(separator: "/").contains("beta")
}
}
92 changes: 92 additions & 0 deletions app/Shared/ChatSDK/ChatConfigurationStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//
// ChatConfigurationStore.swift
// MNGA
//

import Combine
import CryptoKit
import Foundation

final class ChatConfigurationStore: ObservableObject {
static let shared = ChatConfigurationStore()

private enum Key {
static let baseURL = "chatAPIBaseURL"
static let model = "chatAPIModel"
static let verifiedConfigurationFingerprint = "chatAPIVerifiedConfigurationFingerprint"
}

@Published private(set) var baseURL: String
@Published private(set) var model: String
@Published private(set) var apiKey: String
@Published private(set) var verifiedConfigurationFingerprint: String?

private let defaults: UserDefaults
private let credentialStore: KeychainCredentialStore

init(
defaults: UserDefaults = .standard,
credentialStore: KeychainCredentialStore = .init(),
) {
self.defaults = defaults
self.credentialStore = credentialStore
baseURL = defaults.string(forKey: Key.baseURL) ?? ChatAPIConfiguration.defaultBaseURL
model = defaults.string(forKey: Key.model) ?? ChatAPIConfiguration.defaultModel
apiKey = (try? credentialStore.load()) ?? ""
verifiedConfigurationFingerprint = defaults.string(forKey: Key.verifiedConfigurationFingerprint)
}

var isConfigured: Bool {
!apiKey.isEmpty && !model.isEmpty && (try? ChatAPIConfiguration.validate(baseURL: baseURL)) != nil
}

var isAIEnabled: Bool {
guard let configuration = try? configuration() else { return false }
return isConnectionVerified(for: configuration)
}

func save(baseURL: String, apiKey: String, model: String) throws {
let normalizedBaseURL = try ChatAPIConfiguration.validate(baseURL: baseURL).absoluteString
let normalizedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedModel.isEmpty else { throw ChatClientError.missingModel }

try credentialStore.save(apiKey)
defaults.set(normalizedBaseURL, forKey: Key.baseURL)
defaults.set(normalizedModel, forKey: Key.model)

self.baseURL = normalizedBaseURL
self.model = normalizedModel
self.apiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
}

func configuration() throws -> ChatAPIConfiguration {
try ChatAPIConfiguration(baseURL: baseURL, apiKey: apiKey, model: model)
}

func isConnectionVerified(for configuration: ChatAPIConfiguration) -> Bool {
verifiedConfigurationFingerprint == Self.verificationFingerprint(for: configuration)
}

func recordSuccessfulConnectionTest(for configuration: ChatAPIConfiguration) {
let fingerprint = Self.verificationFingerprint(for: configuration)
defaults.set(fingerprint, forKey: Key.verifiedConfigurationFingerprint)
verifiedConfigurationFingerprint = fingerprint
}

func recordFailedConnectionTest(for configuration: ChatAPIConfiguration) {
guard isConnectionVerified(for: configuration) else { return }
defaults.removeObject(forKey: Key.verifiedConfigurationFingerprint)
verifiedConfigurationFingerprint = nil
}

private static func verificationFingerprint(for configuration: ChatAPIConfiguration) -> String {
let value = [
configuration.baseURL.absoluteString,
configuration.model,
configuration.apiKey,
].joined(separator: "\u{1F}")
return SHA256.hash(data: Data(value.utf8))
.map { String(format: "%02x", $0) }
.joined()
}
}
128 changes: 128 additions & 0 deletions app/Shared/ChatSDK/ChatConnectionTester.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//
// ChatConnectionTester.swift
// MNGA
//

import Foundation

struct ChatConnectionTestReport {
let statusCode: Int
let model: String
let responsePreview: String
let inputTokens: Int?
let outputTokens: Int?
}

final class ChatConnectionTester {
private static let maximumErrorCharacters = 2_000
private static let maximumPreviewCharacters = 300

private let urlSession: URLSession

init(urlSession: URLSession = .shared) {
self.urlSession = urlSession
}

func test(configuration: ChatAPIConfiguration) async throws -> ChatConnectionTestReport {
var request = URLRequest(url: configuration.chatCompletionsURL)
request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue("Bearer \(configuration.apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try requestBody(configuration: configuration)

let (data, response) = try await urlSession.data(for: request)
try Task.checkCancellation()
guard let httpResponse = response as? HTTPURLResponse else {
throw ChatClientError.invalidResponse
}

guard (200 ..< 300).contains(httpResponse.statusCode) else {
let apiError = try? JSONDecoder().decode(ConnectionTestAPIErrorEnvelope.self, from: data)
let rawMessage = apiError?.error.message ?? String(decoding: data, as: UTF8.self)
let message = redact(
String(rawMessage.prefix(Self.maximumErrorCharacters)),
secret: configuration.apiKey,
)
throw ChatClientError.httpStatus(
code: httpResponse.statusCode,
message: message,
body: message,
)
}

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
guard let response = try? decoder.decode(ConnectionTestAPIResponse.self, from: data),
let content = response.choices.first?.message.content?
.trimmingCharacters(in: .whitespacesAndNewlines),
!content.isEmpty
else {
throw ChatClientError.invalidResponse
}

return ChatConnectionTestReport(
statusCode: httpResponse.statusCode,
model: response.model ?? configuration.model,
responsePreview: String(content.prefix(Self.maximumPreviewCharacters)),
inputTokens: response.usage?.resolvedInputTokens,
outputTokens: response.usage?.resolvedOutputTokens,
)
}

private func requestBody(configuration: ChatAPIConfiguration) throws -> Data {
var body: [String: Any] = [
"model": configuration.model,
"messages": [[
"role": "user",
"content": "This is a connection test. Reply with exactly OK.",
]],
"stream": false,
]
if configuration.isDeepSeek {
body["thinking"] = ["type": "disabled"]
}
return try JSONSerialization.data(
withJSONObject: body,
options: [.sortedKeys, .withoutEscapingSlashes],
)
}

private func redact(_ value: String, secret: String) -> String {
guard !secret.isEmpty else { return value }
return value.replacingOccurrences(of: secret, with: "<redacted>")
}
}

private struct ConnectionTestAPIErrorEnvelope: Decodable {
struct APIError: Decodable {
let message: String
}

let error: APIError
}

private struct ConnectionTestAPIResponse: Decodable {
struct Choice: Decodable {
struct Message: Decodable {
let content: String?
}

let message: Message
}

struct Usage: Decodable {
let promptTokens: Int?
let inputTokens: Int?
let completionTokens: Int?
let outputTokens: Int?

var resolvedInputTokens: Int? { promptTokens ?? inputTokens }
var resolvedOutputTokens: Int? { completionTokens ?? outputTokens }
}

let model: String?
let choices: [Choice]
let usage: Usage?
}
Loading