Files
Termind/Frameworks/TermindApplication/ModelProviderServiceAccount.swift
T

125 lines
5.2 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import TermindDomain
/// Validated transient input. Never put this value in a configuration snapshot or sync payload.
public nonisolated struct ModelProviderServiceAccount: Sendable {
public static let maximumBytes = 1_048_576
public let json: String
public let clientEmail: String
public let projectID: String
public init(json: String) throws {
guard json.utf8.count <= Self.maximumBytes,
let object = try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any],
object["type"] as? String == "service_account",
let tokenURI = object["token_uri"] as? String,
["https://oauth2.googleapis.com/token", "https://accounts.google.com/o/oauth2/token"].contains(tokenURI),
let key = object["private_key"] as? String,
key.hasPrefix("-----BEGIN PRIVATE KEY-----"),
key.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix("-----END PRIVATE KEY-----"),
let der = Data(
base64Encoded: key.replacingOccurrences(of: "-----BEGIN PRIVATE KEY-----", with: "")
.replacingOccurrences(of: "-----END PRIVATE KEY-----", with: "")
.components(separatedBy: .whitespacesAndNewlines).joined()),
der.count > 256, der.first == 0x30,
let email = GoogleServiceAccountAuth.clientEmail(in: json), email.contains("@"),
let project = GoogleServiceAccountAuth.projectID(in: json), !project.isEmpty
else { throw ValidationError.invalid }
self.json = json
clientEmail = email
projectID = project
}
public enum ValidationError: LocalizedError {
case invalid
public var errorDescription: String? {
String(localized: "Choose a valid Google service account JSON file smaller than 1 MiB.")
}
}
}
public nonisolated enum ModelProviderCredentialKind: Sendable {
case apiKey, serviceAccount
public func reference(in provider: ModelProvider) -> String {
switch self {
case .apiKey: provider.credentialRef
case .serviceAccount: provider.vertex.credentialRef
}
}
public func setReference(_ reference: String, in provider: inout ModelProvider) {
switch self {
case .apiKey: provider.credentialRef = reference
case .serviceAccount: provider.vertex.credentialRef = reference
}
}
}
/// A compare-and-swap edit held only for the duration of an editor transaction.
public nonisolated struct ModelProviderCredentialEdit {
public let kind: ModelProviderCredentialKind
public let reference: String
public let previous: String?
public let replacement: String?
public init(kind: ModelProviderCredentialKind, reference: String, previous: String?, replacement: String?) {
self.kind = kind
self.reference = reference
self.previous = previous
self.replacement = replacement
}
}
extension AIConfigurationModel {
/// Writes both secrets before metadata, rolling every changed secret back if any step fails.
public func saveProvider(
_ draft: ModelProvider, endpoint: ProviderEndpoint, credentials: [ModelProviderCredentialEdit]
) throws {
var value = draft
let current = modelProvider(draft.id)
for edit in credentials {
guard edit.kind.reference(in: current ?? draft) == edit.reference else {
throw SettingsDraftMerge.Conflict.changed
}
let previous = secret(for: edit.reference)
if let error = secretStorageError, !edit.reference.isEmpty { throw CredentialError.message(error) }
guard previous == edit.previous else { throw SettingsDraftMerge.Conflict.changed }
}
var rollback: [(String, String?)] = []
do {
for edit in credentials {
if edit.reference.isEmpty && edit.replacement == nil { continue }
let reference = edit.reference.isEmpty ? Self.makeCredentialRef("provider") : edit.reference
rollback.append((reference, edit.previous))
setSecret(edit.replacement, for: reference)
if let error = secretStorageError { throw CredentialError.message(error) }
edit.kind.setReference(edit.replacement == nil ? "" : reference, in: &value)
}
guard saveProvider(value, endpoint: endpoint) else {
throw CredentialError.message(storageError ?? String(localized: "Unable to Save"))
}
} catch {
var message = error.localizedDescription
for (reference, previous) in rollback.reversed() {
setSecret(previous, for: reference)
if let failure = secretStorageError { message += "\n" + failure }
}
throw CredentialError.message(message)
}
}
public enum CredentialError: LocalizedError {
case message(String)
public var errorDescription: String? {
switch self {
case .message(let message): message
}
}
}
}