From 29168fd48cb01a770fbbba5a5c820f8e764109fc Mon Sep 17 00:00:00 2001 From: "Jared Davidson (Archetapp)" Date: Thu, 12 Dec 2024 13:22:02 -0700 Subject: [PATCH] Cleanup --- .../Intramodular/Models/_Gemini.Content.swift | 108 ++++++++++++++++++ .../Models/_Gemini.MediaType.swift | 7 -- .../Models/_Gemini.SafetySetting.swift | 36 ------ .../Models/_Gemini.SystemInstruction.swift | 20 ---- .../Intramodular/Models/_Gemini.Tool.swift | 59 ---------- .../Models/_Gemini.ToolConfig.swift | 34 ------ .../_Gemini/Intramodular/_Gemini.Client.swift | 9 +- Tests/_Gemini/Intramodular/_GeminiTests.swift | 63 +++++----- 8 files changed, 149 insertions(+), 187 deletions(-) create mode 100644 Sources/_Gemini/Intramodular/Models/_Gemini.Content.swift delete mode 100644 Sources/_Gemini/Intramodular/Models/_Gemini.MediaType.swift delete mode 100644 Sources/_Gemini/Intramodular/Models/_Gemini.SafetySetting.swift delete mode 100644 Sources/_Gemini/Intramodular/Models/_Gemini.SystemInstruction.swift delete mode 100644 Sources/_Gemini/Intramodular/Models/_Gemini.Tool.swift delete mode 100644 Sources/_Gemini/Intramodular/Models/_Gemini.ToolConfig.swift diff --git a/Sources/_Gemini/Intramodular/Models/_Gemini.Content.swift b/Sources/_Gemini/Intramodular/Models/_Gemini.Content.swift new file mode 100644 index 0000000..8a7efff --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/_Gemini.Content.swift @@ -0,0 +1,108 @@ +// +// _Gemini.Content.swift +// AI +// +// Created by Jared Davidson on 12/12/24. +// + +import Foundation + +extension _Gemini { + public struct Content: Decodable { + public let text: String + public let finishReason: FinishReason? + public let safetyRatings: [SafetyRating] + public let tokenUsage: TokenUsage? + + public enum FinishReason: String, Decodable { + case maxTokens = "MAX_TOKENS" + case stop = "STOP" + case safety = "SAFETY" + case recitation = "RECITATION" + case other = "OTHER" + } + + public struct SafetyRating: Decodable { + public let category: Category + public let probability: Probability + public let blocked: Bool + + public enum Category: String, Decodable { + case harassment = "HARM_CATEGORY_HARASSMENT" + case hateSpeech = "HARM_CATEGORY_HATE_SPEECH" + case sexuallyExplicit = "HARM_CATEGORY_SEXUALLY_EXPLICIT" + case dangerousContent = "HARM_CATEGORY_DANGEROUS_CONTENT" + case civicIntegrity = "HARM_CATEGORY_CIVIC_INTEGRITY" + } + + public enum Probability: String, Decodable { + case negligible = "NEGLIGIBLE" + case low = "LOW" + case medium = "MEDIUM" + case high = "HIGH" + } + } + + public struct TokenUsage: Decodable { + public let prompt: Int + public let response: Int + public let total: Int + } + + // Custom initializer for API response + init(apiResponse response: _Gemini.APISpecification.ResponseBodies.GenerateContent) throws { + guard let candidate = response.candidates?.first, + let content = candidate.content, + let parts = content.parts else { + throw _Gemini.APIError.unknown(message: "Invalid response format") + } + + // Combine all text parts + self.text = parts.compactMap { part -> String? in + if case .text(let text) = part { + return text + } + return nil + }.joined(separator: " ") + + // Map finish reason + if let finishReasonStr = candidate.finishReason { + self.finishReason = FinishReason(rawValue: finishReasonStr) + } else { + self.finishReason = nil + } + + // Map safety ratings + self.safetyRatings = (candidate.safetyRatings ?? []).compactMap { rating -> SafetyRating? in + guard let category = rating.category, + let probability = rating.probability else { + return nil + } + + return SafetyRating( + category: SafetyRating.Category(rawValue: category) ?? .dangerousContent, + probability: SafetyRating.Probability(rawValue: probability) ?? .negligible, + blocked: rating.blocked ?? false + ) + } + + // Map token usage + if let usage = response.usageMetadata { + self.tokenUsage = TokenUsage( + prompt: usage.promptTokenCount ?? 0, + response: usage.candidatesTokenCount ?? 0, + total: usage.totalTokenCount ?? 0 + ) + } else { + self.tokenUsage = nil + } + } + + // Required Decodable implementation + public init(from decoder: Decoder) throws { + // This would be implemented if we need to decode Content directly from JSON + // For now, we'll throw an error as we expect to create Content from our API response + throw _Gemini.APIError.unknown(message: "Direct decoding not supported") + } + } +} diff --git a/Sources/_Gemini/Intramodular/Models/_Gemini.MediaType.swift b/Sources/_Gemini/Intramodular/Models/_Gemini.MediaType.swift deleted file mode 100644 index 8d6999b..0000000 --- a/Sources/_Gemini/Intramodular/Models/_Gemini.MediaType.swift +++ /dev/null @@ -1,7 +0,0 @@ -// -// _Gemini.MediaType.swift -// AI -// -// Created by Jared Davidson on 12/11/24. -// - diff --git a/Sources/_Gemini/Intramodular/Models/_Gemini.SafetySetting.swift b/Sources/_Gemini/Intramodular/Models/_Gemini.SafetySetting.swift deleted file mode 100644 index 1bd5516..0000000 --- a/Sources/_Gemini/Intramodular/Models/_Gemini.SafetySetting.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// _Gemini.SafetySettings.swift -// AI -// -// Created by Jared Davidson on 12/11/24. -// - -import Foundation - -extension _Gemini { - public struct SafetySetting: Codable { - public enum Category: String, Codable { - case harassment = "HARM_CATEGORY_HARASSMENT" - case hateSpeech = "HARM_CATEGORY_HATE_SPEECH" - case sexuallyExplicit = "HARM_CATEGORY_SEXUALLY_EXPLICIT" - case dangerousContent = "HARM_CATEGORY_DANGEROUS_CONTENT" - case civicIntegrity = "HARM_CATEGORY_CIVIC_INTEGRITY" - } - - public enum HarmBlockThreshold: String, Codable { - case none = "BLOCK_NONE" - case high = "BLOCK_ONLY_HIGH" - case mediumAndAbove = "BLOCK_MEDIUM_AND_ABOVE" - case lowAndAbove = "BLOCK_LOW_AND_ABOVE" - case unspecified = "HARM_BLOCK_THRESHOLD_UNSPECIFIED" - } - - public let category: Category - public let threshold: HarmBlockThreshold - - public init(category: Category, threshold: HarmBlockThreshold) { - self.category = category - self.threshold = threshold - } - } -} diff --git a/Sources/_Gemini/Intramodular/Models/_Gemini.SystemInstruction.swift b/Sources/_Gemini/Intramodular/Models/_Gemini.SystemInstruction.swift deleted file mode 100644 index 7004257..0000000 --- a/Sources/_Gemini/Intramodular/Models/_Gemini.SystemInstruction.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// _Gemini.SystemInstruction.swift -// AI -// -// Created by Jared Davidson on 12/11/24. -// - -import Foundation - -extension _Gemini { - public struct SystemInstruction: Codable { - public let role: String? - public let parts: [APISpecification.RequestBodies.Content.Part] - - public init(role: String? = nil, parts: [APISpecification.RequestBodies.Content.Part]) { - self.role = role - self.parts = parts - } - } -} diff --git a/Sources/_Gemini/Intramodular/Models/_Gemini.Tool.swift b/Sources/_Gemini/Intramodular/Models/_Gemini.Tool.swift deleted file mode 100644 index df180ef..0000000 --- a/Sources/_Gemini/Intramodular/Models/_Gemini.Tool.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// _Gemini.Tool.swift -// AI -// -// Created by Jared Davidson on 12/11/24. -// - -import Foundation - -extension _Gemini { - public struct Tool: Codable { - public let functionDeclarations: [FunctionDeclaration]? - public let codeExecution: CodeExecution? - - public init( - functionDeclarations: [FunctionDeclaration]? = nil, - codeExecution: CodeExecution? = nil - ) { - self.functionDeclarations = functionDeclarations - self.codeExecution = codeExecution - } - - public struct FunctionDeclaration: Codable { - public let name: String - public let description: String - public let parameters: [String: Schema]? - - public init( - name: String, - description: String, - parameters: [String: Schema]? = nil - ) { - self.name = name - self.description = description - self.parameters = parameters - } - - public struct Schema: Codable { - public let type: String - public let format: String? - - public init(type: String, format: String? = nil) { - self.type = type - self.format = format - } - } - } - - public struct CodeExecution: Codable { - public let language: String - public let code: String - - public init(language: String, code: String) { - self.language = language - self.code = code - } - } - } -} diff --git a/Sources/_Gemini/Intramodular/Models/_Gemini.ToolConfig.swift b/Sources/_Gemini/Intramodular/Models/_Gemini.ToolConfig.swift deleted file mode 100644 index 5126055..0000000 --- a/Sources/_Gemini/Intramodular/Models/_Gemini.ToolConfig.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// _Gemini.ToolConfig.swift -// AI -// -// Created by Jared Davidson on 12/11/24. -// - -import Foundation - -extension _Gemini { - public struct ToolConfig: Codable { - public struct FunctionCallingConfig: Codable { - public enum Mode: String, Codable { - case auto = "AUTO" - case none = "NONE" - case any = "ANY" - } - - public let mode: Mode? - public let allowedFunctionNames: [String]? - - public init(mode: Mode? = nil, allowedFunctionNames: [String]? = nil) { - self.mode = mode - self.allowedFunctionNames = allowedFunctionNames - } - } - - public let functionCallingConfig: FunctionCallingConfig? - - public init(functionCallingConfig: FunctionCallingConfig? = nil) { - self.functionCallingConfig = functionCallingConfig - } - } -} diff --git a/Sources/_Gemini/Intramodular/_Gemini.Client.swift b/Sources/_Gemini/Intramodular/_Gemini.Client.swift index 2fcf020..21d0e03 100644 --- a/Sources/_Gemini/Intramodular/_Gemini.Client.swift +++ b/Sources/_Gemini/Intramodular/_Gemini.Client.swift @@ -79,7 +79,7 @@ extension _Gemini.Client { type: HTTPMediaType, prompt: String, model: _Gemini.Model - ) async throws -> _Gemini.APISpecification.ResponseBodies.GenerateContent { + ) async throws -> _Gemini.Content { do { let data = try Data(contentsOf: url) @@ -105,7 +105,7 @@ extension _Gemini.Client { file: _Gemini.File, prompt: String, model: _Gemini.Model - ) async throws -> _Gemini.APISpecification.ResponseBodies.GenerateContent { + ) async throws -> _Gemini.Content { guard let fileName = file.name else { throw FileProcessingError.invalidFileName } @@ -151,7 +151,10 @@ extension _Gemini.Client { print(input) - return try await run(\.generateContent, with: input) + let response = try await run(\.generateContent, with: input) + + return try _Gemini.Content.init(apiResponse: response) + } catch let error as FileProcessingError { throw error } catch { diff --git a/Tests/_Gemini/Intramodular/_GeminiTests.swift b/Tests/_Gemini/Intramodular/_GeminiTests.swift index 0f2f721..ba87e95 100644 --- a/Tests/_Gemini/Intramodular/_GeminiTests.swift +++ b/Tests/_Gemini/Intramodular/_GeminiTests.swift @@ -10,8 +10,6 @@ import SwiftUIX import Foundation import _Gemini -private final class BundleHelper {} - @Suite struct GeminiTests { func loadTestFileURL(named filename: String, fileExtension: String) throws -> URL { let sourceFile = #file @@ -46,17 +44,19 @@ private final class BundleHelper {} do { let file = try await createFile(type: .video) - let response = try await client.generateContent( + let content = try await client.generateContent( file: file, prompt: "What is happening in this video?", model: .gemini_1_5_flash ) - #expect(response.candidates != nil) - #expect(!response.candidates!.isEmpty) + #expect(!content.text.isEmpty) + #expect(content.finishReason != nil) + #expect(!content.safetyRatings.isEmpty) - if let textContent = response.candidates?.first?.content?.parts?.first { - print("Response: \(textContent)") + print("Response text: \(content.text)") + if let tokenUsage = content.tokenUsage { + print("Token usage - Total: \(tokenUsage.total)") } } catch let error as GeminiTestError { print("Detailed error: \(error.localizedDescription)") @@ -70,16 +70,17 @@ private final class BundleHelper {} do { let file = try await createFile(type: .audio) - let response = try await client.generateContent( + let content = try await client.generateContent( file: file, prompt: "What is being said in this audio?", model: .gemini_1_5_flash ) - print(response) + print("Generated content: \(content)") - #expect(response.candidates != nil) - #expect(!response.candidates!.isEmpty) + #expect(!content.text.isEmpty) + #expect(content.finishReason != nil) + #expect(!content.safetyRatings.isEmpty) } catch let error as GeminiTestError { print("Detailed error: \(error.localizedDescription)") #expect(false, "Audio content generation failed: \(error)") @@ -92,41 +93,41 @@ private final class BundleHelper {} do { let file = try await createFile(type: .image) - let response = try await client.generateContent( + let content = try await client.generateContent( file: file, prompt: "What is this the shape of this image?", model: .gemini_1_5_flash ) - print(response) + print("Generated content: \(content)") - #expect(response.candidates != nil) - #expect(!response.candidates!.isEmpty) + #expect(!content.text.isEmpty) + #expect(content.finishReason != nil) + #expect(!content.safetyRatings.isEmpty) } catch let error as GeminiTestError { print("Detailed error: \(error.localizedDescription)") - #expect(false, "Audio content generation failed: \(error)") + #expect(false, "Image content generation failed: \(error)") } catch { - throw GeminiTestError.audioProcessingError(error) + throw GeminiTestError.imageProcessingError(error) } } - @Test func testaImageContentGenerationWithURL() async throws { + @Test func testImageContentGenerationWithURL() async throws { do { let url = try loadTestFileURL(named: "LintMySwift2", fileExtension: "m4a") - let response = try await client.generateContent( + let content = try await client.generateContent( url: url, type: .custom("audio/x-m4a"), prompt: "What does this audio say?", model: .gemini_1_5_flash ) - print(response) + print("Generated content: \(content)") - #expect(response.candidates != nil) - #expect(!response.candidates!.isEmpty) + #expect(!content.text.isEmpty) } catch let error as GeminiTestError { print("Detailed error: \(error.localizedDescription)") - #expect(false, "Audio content generation failed: \(error)") + #expect(false, "URL content generation failed: \(error)") } catch { throw GeminiTestError.audioProcessingError(error) } @@ -135,7 +136,9 @@ private final class BundleHelper {} @Test func testFileUpload() async throws { do { let file = try await createFile(type: .audio) - #expect(true) + #expect(file.name != nil) + #expect(file.mimeType != nil) + #expect(file.state == .active || file.state == .processing) } catch let error as GeminiTestError { print("Detailed error: \(error.localizedDescription)") #expect(false, "File upload failed: \(error)") @@ -147,8 +150,10 @@ private final class BundleHelper {} @Test func testGetFile() async throws { do { let file = try await createFile(type: .audio) - let _ = try await client.getFile(name: file.name ?? "") - #expect(true) + let retrievedFile = try await client.getFile(name: file.name ?? "") + #expect(retrievedFile.name == file.name) + #expect(retrievedFile.mimeType == file.mimeType) + #expect(retrievedFile.uri == file.uri) } catch let error as GeminiTestError { print("Detailed error: \(error.localizedDescription)") #expect(false, "File retrieval failed: \(error)") @@ -198,7 +203,6 @@ private final class BundleHelper {} mimeType: .custom("image/png"), displayName: "Test" ) - } } catch let error as GeminiTestError { throw error @@ -213,14 +217,15 @@ private final class BundleHelper {} case image } } -// Error Handling +// Error Handling fileprivate enum GeminiTestError: LocalizedError { case fileNotFound(String) case invalidFileURL(String) case fileLoadError(Error) case videoProcessingError(Error) case audioProcessingError(Error) + case imageProcessingError(Error) case fileUploadError(Error) case fileDeleteError(Error) case fileRetrievalError(Error) @@ -237,6 +242,8 @@ fileprivate enum GeminiTestError: LocalizedError { return "Failed to process video content: \(error.localizedDescription)" case .audioProcessingError(let error): return "Failed to process audio content: \(error.localizedDescription)" + case .imageProcessingError(let error): + return "Failed to process image content: \(error.localizedDescription)" case .fileUploadError(let error): return "Failed to upload file: \(error.localizedDescription)" case .fileDeleteError(let error):