diff --git a/Package.resolved b/Package.resolved index afbf00de..44c6cdec 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c84aa4446fdd21cbb193bc4ba0b1cdc79c7ec1f15c14460832a38ff9323b7098", + "originHash" : "094840915419b625ed8a43083bdf164ab8d3f6bbb7fda2dcec07cb5e55a2b736", "pins" : [ { "identity" : "corepersistence", @@ -16,7 +16,7 @@ "location" : "https://github.com/vmanot/Merge.git", "state" : { "branch" : "master", - "revision" : "aca0b0c7a48d91934b098bab9b5c4a9f343369f2" + "revision" : "4bc71ce650b79b3dbe1a26acf7e54b29d750e0b6" } }, { @@ -34,7 +34,7 @@ "location" : "https://github.com/vmanot/Swallow.git", "state" : { "branch" : "master", - "revision" : "890a767eacad123a5310cdcc8074d16378c18cbe" + "revision" : "4c05166cf644846199fb734bbc47d74f87610945" } }, { @@ -49,9 +49,9 @@ { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-precompiled/swift-syntax", + "location" : "https://github.com/apple/swift-syntax.git", "state" : { - "revision" : "85ba62b876bb023acdd828d738703f1c08eaa058", + "revision" : "0687f71944021d616d34d922343dcef086855920", "version" : "600.0.1" } }, diff --git a/Package.swift b/Package.swift index 59b95785..80944321 100644 --- a/Package.swift +++ b/Package.swift @@ -20,25 +20,39 @@ let package = Package( "Anthropic", "Cohere", "ElevenLabs", - "_Gemini", "Groq", "HuggingFace", "Jina", "Mistral", "Ollama", "OpenAI", - "Perplexity", - "TogetherAI", - "VoyageAI", "AI", ] ), + .library( + name: "_Gemini", + targets: [ + "_Gemini" + ] + ), .library( name: "Anthropic", targets: [ "Anthropic" ] ), + .library( + name: "HumeAI", + targets: [ + "HumeAI" + ] + ), + .library( + name: "NeetsAI", + targets: [ + "NeetsAI" + ] + ), .library( name: "OpenAI", targets: [ @@ -50,7 +64,31 @@ let package = Package( targets: [ "Perplexity" ] - ) + ), + .library( + name: "PlayHT", + targets: [ + "PlayHT" + ] + ), + .library( + name: "Rime", + targets: [ + "Rime" + ] + ), + .library( + name: "TogetherAI", + targets: [ + "TogetherAI" + ] + ), + .library( + name: "VoyageAI", + targets: [ + "VoyageAI" + ] + ), ], dependencies: [ .package(url: "https://github.com/vmanot/CorePersistence.git", branch: "main"), @@ -340,30 +378,33 @@ let package = Package( dependencies: [ "CoreMI", "LargeLanguageModels", - "_Gemini", "Anthropic", "Cohere", "ElevenLabs", "Groq", "HuggingFace", - "HumeAI", "Jina", - "NeetsAI", "Mistral", "Ollama", "OpenAI", - "Perplexity", - "PlayHT", - "Rime", "Swallow", - "TogetherAI", - "VoyageAI", ], path: "Sources/AI", swiftSettings: [ .enableExperimentalFeature("AccessLevelOnImport") ] ), + .testTarget( + name: "_GeminiTests", + dependencies: [ + "AI", + "Swallow" + ], + path: "Tests/_Gemini", + swiftSettings: [ + .enableExperimentalFeature("AccessLevelOnImport") + ] + ), .testTarget( name: "LargeLanguageModelsTests", dependencies: [ diff --git a/Sources/LargeLanguageModels/Intramodular/LLMs/AbstractLLM.Capability.swift b/Sources/LargeLanguageModels/Intramodular/LLMs/AbstractLLM.Capability.swift deleted file mode 100644 index 3ccc3696..00000000 --- a/Sources/LargeLanguageModels/Intramodular/LLMs/AbstractLLM.Capability.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Copyright (c) Vatsal Manot -// - -import SwiftUIX - -extension AbstractLLM { - public enum Capability { - case functionCalling - case vision - } -} diff --git a/Sources/LargeLanguageModels/Intramodular/LLMs/AbstractLLM.ChatOrTextCompletion.swift b/Sources/LargeLanguageModels/Intramodular/LLMs/AbstractLLM.ChatOrTextCompletion.swift index 0fd61a26..be5b0abd 100644 --- a/Sources/LargeLanguageModels/Intramodular/LLMs/AbstractLLM.ChatOrTextCompletion.swift +++ b/Sources/LargeLanguageModels/Intramodular/LLMs/AbstractLLM.ChatOrTextCompletion.swift @@ -7,7 +7,7 @@ import Swallow extension AbstractLLM { public protocol Completion: Codable, CustomDebugStringConvertible, Hashable, Sendable { - static var _completionType: AbstractLLM.CompletionType? { get } + static var knownCompletionType: AbstractLLM.CompletionType? { get } } } diff --git a/Sources/LargeLanguageModels/Intramodular/LLMs/AbstractLLM.ChatOrTextPrompt.swift b/Sources/LargeLanguageModels/Intramodular/LLMs/AbstractLLM.ChatOrTextPrompt.swift index 3b3732a6..2467cf79 100644 --- a/Sources/LargeLanguageModels/Intramodular/LLMs/AbstractLLM.ChatOrTextPrompt.swift +++ b/Sources/LargeLanguageModels/Intramodular/LLMs/AbstractLLM.ChatOrTextPrompt.swift @@ -16,7 +16,7 @@ extension AbstractLLM { associatedtype CompletionParameters: AbstractLLM.CompletionParameters associatedtype Completion: Partializable - static var completionType: AbstractLLM.CompletionType? { get } + static var knownCompletionType: AbstractLLM.CompletionType? { get } var context: PromptContextValues { get set } } @@ -24,10 +24,10 @@ extension AbstractLLM { extension AbstractLLM { public enum ChatOrTextPrompt: Prompt { - public typealias CompletionParameters = AbstractLLM.ChatOrTextCompletionParameters + public typealias CompletionParameters = ChatOrTextCompletionParameters public typealias Completion = AbstractLLM.ChatOrTextCompletion - public static var completionType: AbstractLLM.CompletionType? { + public static var knownCompletionType: AbstractLLM.CompletionType? { nil } diff --git a/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/AbstractLLM.ChatCompletion.swift b/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/AbstractLLM.ChatCompletion.swift index 17c9c934..626e98f6 100644 --- a/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/AbstractLLM.ChatCompletion.swift +++ b/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/AbstractLLM.ChatCompletion.swift @@ -9,7 +9,7 @@ import Swallow extension AbstractLLM { public struct ChatCompletion: Completion { - public static var _completionType: AbstractLLM.CompletionType? { + public static var knownCompletionType: AbstractLLM.CompletionType? { .chat } diff --git a/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/AbstractLLM.ChatPrompt.swift b/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/AbstractLLM.ChatPrompt.swift index 60654d65..21d5e30e 100644 --- a/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/AbstractLLM.ChatPrompt.swift +++ b/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/AbstractLLM.ChatPrompt.swift @@ -12,7 +12,7 @@ extension AbstractLLM { public typealias CompletionParameters = AbstractLLM.ChatCompletionParameters public typealias Completion = AbstractLLM.ChatCompletion - public static var completionType: AbstractLLM.CompletionType? { + public static var knownCompletionType: AbstractLLM.CompletionType? { .chat } diff --git a/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/AbstractLLM.ChatRole.swift b/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/AbstractLLM.ChatRole.swift index 93c1bccb..584ddc52 100644 --- a/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/AbstractLLM.ChatRole.swift +++ b/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/AbstractLLM.ChatRole.swift @@ -5,6 +5,7 @@ import Diagnostics import Foundation import Swallow +import CreateMLComponents public protocol __AbstractLLM_ChatRole_Initiable { init(from role: AbstractLLM.ChatRole) throws diff --git a/Sources/LargeLanguageModels/Intramodular/LLMs/LLMRequestHandling+Chat.swift b/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/LLMRequestHandling+Chat.swift similarity index 100% rename from Sources/LargeLanguageModels/Intramodular/LLMs/LLMRequestHandling+Chat.swift rename to Sources/LargeLanguageModels/Intramodular/LLMs/Chat/LLMRequestHandling+Chat.swift diff --git a/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/Function Calling/Abstract.LLM.ChatFunctionCall.swift b/Sources/LargeLanguageModels/Intramodular/LLMs/Function Calling/Abstract.LLM.ChatFunctionCall.swift similarity index 100% rename from Sources/LargeLanguageModels/Intramodular/LLMs/Chat/Function Calling/Abstract.LLM.ChatFunctionCall.swift rename to Sources/LargeLanguageModels/Intramodular/LLMs/Function Calling/Abstract.LLM.ChatFunctionCall.swift diff --git a/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/Function Calling/AbstractLLM.ChatFunction.swift b/Sources/LargeLanguageModels/Intramodular/LLMs/Function Calling/AbstractLLM.ChatFunction.swift similarity index 100% rename from Sources/LargeLanguageModels/Intramodular/LLMs/Chat/Function Calling/AbstractLLM.ChatFunction.swift rename to Sources/LargeLanguageModels/Intramodular/LLMs/Function Calling/AbstractLLM.ChatFunction.swift diff --git a/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/Function Calling/AbstractLLM.ChatFunctionDefinition.swift b/Sources/LargeLanguageModels/Intramodular/LLMs/Function Calling/AbstractLLM.ChatFunctionDefinition.swift similarity index 100% rename from Sources/LargeLanguageModels/Intramodular/LLMs/Chat/Function Calling/AbstractLLM.ChatFunctionDefinition.swift rename to Sources/LargeLanguageModels/Intramodular/LLMs/Function Calling/AbstractLLM.ChatFunctionDefinition.swift diff --git a/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/Function Calling/AbstractLLM.ChatFunctionExecuting.swift b/Sources/LargeLanguageModels/Intramodular/LLMs/Function Calling/AbstractLLM.ChatFunctionExecuting.swift similarity index 100% rename from Sources/LargeLanguageModels/Intramodular/LLMs/Chat/Function Calling/AbstractLLM.ChatFunctionExecuting.swift rename to Sources/LargeLanguageModels/Intramodular/LLMs/Function Calling/AbstractLLM.ChatFunctionExecuting.swift diff --git a/Sources/LargeLanguageModels/Intramodular/LLMs/Chat/Function Calling/AbstractLLM.ResultOfFunctionCall.swift b/Sources/LargeLanguageModels/Intramodular/LLMs/Function Calling/AbstractLLM.ResultOfFunctionCall.swift similarity index 100% rename from Sources/LargeLanguageModels/Intramodular/LLMs/Chat/Function Calling/AbstractLLM.ResultOfFunctionCall.swift rename to Sources/LargeLanguageModels/Intramodular/LLMs/Function Calling/AbstractLLM.ResultOfFunctionCall.swift diff --git a/Sources/LargeLanguageModels/Intramodular/LLMs/Text/AbstractLLM.TextCompletion.swift b/Sources/LargeLanguageModels/Intramodular/LLMs/Text/AbstractLLM.TextCompletion.swift index 1bf76a54..92b563ba 100644 --- a/Sources/LargeLanguageModels/Intramodular/LLMs/Text/AbstractLLM.TextCompletion.swift +++ b/Sources/LargeLanguageModels/Intramodular/LLMs/Text/AbstractLLM.TextCompletion.swift @@ -8,7 +8,7 @@ import Swallow extension AbstractLLM { public struct TextCompletion: Completion { - public static var _completionType: AbstractLLM.CompletionType? { + public static var knownCompletionType: AbstractLLM.CompletionType? { .text } diff --git a/Sources/LargeLanguageModels/Intramodular/LLMs/Text/AbstractLLM.TextPrompt.swift b/Sources/LargeLanguageModels/Intramodular/LLMs/Text/AbstractLLM.TextPrompt.swift index 3bf8570f..f303ff49 100644 --- a/Sources/LargeLanguageModels/Intramodular/LLMs/Text/AbstractLLM.TextPrompt.swift +++ b/Sources/LargeLanguageModels/Intramodular/LLMs/Text/AbstractLLM.TextPrompt.swift @@ -11,7 +11,7 @@ extension AbstractLLM { public typealias CompletionParameters = AbstractLLM.TextCompletionParameters public typealias Completion = AbstractLLM.TextCompletion - public static var completionType: AbstractLLM.CompletionType? { + public static var knownCompletionType: AbstractLLM.CompletionType? { .text } diff --git a/Sources/OpenAI/Intramodular/Models/OpenAI.ChatFunctionDefinition.swift b/Sources/OpenAI/Intramodular/Models/OpenAI.ChatFunctionDefinition.swift index 86abcbf1..0522972f 100644 --- a/Sources/OpenAI/Intramodular/Models/OpenAI.ChatFunctionDefinition.swift +++ b/Sources/OpenAI/Intramodular/Models/OpenAI.ChatFunctionDefinition.swift @@ -8,13 +8,29 @@ import LargeLanguageModels import Swallow extension OpenAI { + public struct ToolName: Codable, Hashable, RawRepresentable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(from decoder: any Decoder) throws { + rawValue = try String(from: decoder) + } + + public func encode(to encoder: any Encoder) throws { + try rawValue.encode(to: encoder) + } + } + public struct ChatFunctionDefinition: Codable, Hashable, Sendable { - public let name: String + public let name: OpenAI.ToolName public let description: String public let parameters: JSONSchema public init( - name: String, + name: OpenAI.ToolName, description: String, parameters: JSONSchema ) { @@ -22,5 +38,19 @@ extension OpenAI { self.description = description self.parameters = parameters } + + public init( + name: String, + description: String, + parameters: JSONSchema + ) { + self.init( + name: OpenAI.ToolName( + rawValue: name + ), + description: description, + parameters: parameters + ) + } } } diff --git a/Sources/OpenAI/Intramodular/Models/OpenAI.ToolChoice.swift b/Sources/OpenAI/Intramodular/Models/OpenAI.ToolChoice.swift index ae534e97..ad4e943a 100644 --- a/Sources/OpenAI/Intramodular/Models/OpenAI.ToolChoice.swift +++ b/Sources/OpenAI/Intramodular/Models/OpenAI.ToolChoice.swift @@ -6,6 +6,7 @@ import NetworkKit import Swift extension OpenAI { + /// https://platform.openai.com/docs/api-reference/runs/createRun#runs-createrun-tool_choice public enum ToolChoice: Codable { case none case auto @@ -20,15 +21,7 @@ extension OpenAI { } public enum ToolValue: Codable { - case function(String) - - public struct FunctionDetails: Codable { - var name: String - - public init(name: String) { - self.name = name - } - } + case function(ToolName) } } } @@ -83,6 +76,14 @@ extension OpenAI.ToolChoice.ToolValue { case function } + private struct FunctionDetails: Codable { + var name: OpenAI.ToolName + + public init(name: OpenAI.ToolName) { + self.name = name + } + } + public init( from decoder: Decoder ) throws { diff --git a/Sources/_Gemini/Intramodular/API/_Gemini.APISpecification.RequestBodies.swift b/Sources/_Gemini/Intramodular/API/_Gemini.APISpecification.RequestBodies.swift new file mode 100644 index 00000000..8d840785 --- /dev/null +++ b/Sources/_Gemini/Intramodular/API/_Gemini.APISpecification.RequestBodies.swift @@ -0,0 +1,263 @@ +// +// Copyright (c) Vatsal Manot +// + +import NetworkKit +import SwiftAPI +import Foundation + +extension _Gemini.APISpecification { + public enum RequestBodies { + public struct CompleteUploadInput: Codable { + public let uploadURL: URL + public let fileData: Data + public let offset: Int + + public init( + uploadURL: URL, + fileData: Data, + offset: Int = 0 + ) { + self.uploadURL = uploadURL + self.fileData = fileData + self.offset = offset + } + } + + public struct GenerateContentInput: Codable { + public let model: String + public let requestBody: ContentBody + + public init( + model: _Gemini.Model, + requestBody: ContentBody + ) { + self.model = model.rawValue + self.requestBody = requestBody + } + } + + public struct ContentBody: Codable { + public let contents: [Content] + public let cachedContent: String? + public let generationConfig: _Gemini.GenerationConfiguration? + public let tools: [_Gemini.Tool]? + public let toolConfiguration: _Gemini.ToolConfiguration? + public let systemInstruction: Content? + + private enum CodingKeys: String, CodingKey { + case contents + case cachedContent + case generationConfig + case tools + case toolConfiguration = "tool_config" + case systemInstruction = "system_instruction" + } + + public init( + contents: [Content], + cachedContent: String? = nil, + generationConfig: _Gemini.GenerationConfiguration? = nil, + tools: [_Gemini.Tool]? = nil, + toolConfiguration: _Gemini.ToolConfiguration? = nil, + systemInstruction: Content? = nil + ) { + self.contents = contents + self.cachedContent = cachedContent + self.generationConfig = generationConfig + self.tools = tools + self.toolConfiguration = toolConfiguration + self.systemInstruction = systemInstruction + } + } + + public struct Content: Codable { + public let role: String + public let parts: [Part] + + public init(role: String, parts: [Part]) { + self.role = role + self.parts = parts + } + + public enum Part: Codable { + case text(String) + case inline(data: Data, mimeType: String) + case file(url: URL, mimeType: String) + + private enum CodingKeys: String, CodingKey { + case text + case inlineData + case fileData + } + + private enum FileDataKeys: String, CodingKey { + case fileUri + case mimeType + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .text(let text): + try container.encode(text, forKey: .text) + + case .inline(data: let data, mimeType: let mimeType): + var nested = container.nestedContainer(keyedBy: FileDataKeys.self, forKey: .inlineData) + try nested.encode(data.base64EncodedString(), forKey: .fileUri) + try nested.encode(mimeType, forKey: .mimeType) + + case .file(url: let url, mimeType: let mimeType): + var nested = container.nestedContainer(keyedBy: FileDataKeys.self, forKey: .fileData) + try nested.encode(url.absoluteString, forKey: .fileUri) + try nested.encode(mimeType, forKey: .mimeType) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let text = try? container.decode(String.self, forKey: .text) { + self = .text(text) + return + } + + if let nested = try? container.nestedContainer(keyedBy: FileDataKeys.self, forKey: .fileData) { + let uri = try nested.decode(String.self, forKey: .fileUri) + let mimeType = try nested.decode(String.self, forKey: .mimeType) + if let url = URL(string: uri) { + self = .file(url: url, mimeType: mimeType) + return + } + } + + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Could not decode Part" + ) + ) + } + } + } + + public struct FileUploadInput: Codable, HTTPRequest.Multipart.ContentConvertible { + public let fileData: Data + public let mimeType: String + public let displayName: String + + public init( + fileData: Data, + mimeType: String, + displayName: String + ) { + self.fileData = fileData + self.mimeType = mimeType + self.displayName = displayName + } + + public func __conversion() throws -> HTTPRequest.Multipart.Content { + var result = HTTPRequest.Multipart.Content() + + // TODO: - Add this to `HTTPMediaType` @jared @vmanot + let fileExtension: String = { + guard let subtype = mimeType.split(separator: "/").last else { + return "bin" + } + + switch subtype { + case "quicktime": + return "mov" + case "x-m4a": + return "m4a" + case "mp4": + return "mp4" + case "jpeg", "jpg": + return "jpg" + case "png": + return "png" + case "gif": + return "gif" + case "webp": + return "webp" + case "pdf": + return "pdf" + default: + return String(subtype) + } + }() + + result.append( + .file( + named: "file", + data: fileData, + filename: "\(displayName).\(fileExtension)", + contentType: .init(rawValue: mimeType) + ) + ) + + return result + } + } + + public struct DeleteFileInput: Codable { + public let fileURL: URL + + public init( + fileURL: URL + ) { + self.fileURL = fileURL + } + } + + public struct FileStatusInput: Codable { + public let name: _Gemini.File.Name + } + + // Fine Tuning + + public struct CreateTunedModel: Codable { + public let requestBody: _Gemini.TuningConfig + + public init( + requestBody: _Gemini.TuningConfig + ) { + self.requestBody = requestBody + } + } + + public struct GetOperation: Codable { + public let operationName: String + + public init( + operationName: String + ) { + self.operationName = operationName + } + } + + public struct GetTunedModel: Codable { + public let modelName: String + + public init( + modelName: String + ) { + self.modelName = modelName + } + } + + public struct EmbeddingInput: Codable { + public let model: String + public let content: Content + + public init( + model: _Gemini.Model, + content: Content + ) { + self.model = model.rawValue + self.content = content + } + } + } +} diff --git a/Sources/_Gemini/Intramodular/API/_Gemini.APISpecification.ResponseBodies.swift b/Sources/_Gemini/Intramodular/API/_Gemini.APISpecification.ResponseBodies.swift new file mode 100644 index 00000000..2dc63d14 --- /dev/null +++ b/Sources/_Gemini/Intramodular/API/_Gemini.APISpecification.ResponseBodies.swift @@ -0,0 +1,179 @@ +// +// _Gemini.APISpecification.ResponseBodies.swift +// AI +// +// Created by Jared Davidson on 12/11/24. +// + +import Foundation + +extension _Gemini.APISpecification { + public enum ResponseBodies { + public struct GenerateContent: Decodable { + public let candidates: [Candidate]? + public let usageMetadata: UsageMetadata? + } + + public struct Candidate: Decodable { + public let content: Content? + public let finishReason: String? + public let index: Int? + public let safetyRatings: [SafetyRating]? + public let functionCall: _Gemini.FunctionCall? + public let groundingMetadata: GroundingMetadata? + + public struct Content: Decodable { + public let parts: [Part]? + public let role: String? + + public enum Part: Decodable { + case text(String) + case functionCall(_Gemini.FunctionCall) + case executableCode(language: String, code: String) + case codeExecutionResult(outcome: String, output: String) + + private enum CodingKeys: String, CodingKey { + case text + case functionCall + case executableCode = "executableCode" + case codeExecutionResult = "codeExecutionResult" + } + + private enum ExecutableCodeKeys: String, CodingKey { + case language + case code + } + + private enum CodeExecutionResultKeys: String, CodingKey { + case outcome + case output + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let text = try? container.decode(String.self, forKey: .text) { + self = .text(text) + return + } + + if let functionCall = try? container.decode(_Gemini.FunctionCall.self, forKey: .functionCall) { + self = .functionCall(functionCall) + return + } + + if let executableContainer = try? container.nestedContainer(keyedBy: ExecutableCodeKeys.self, forKey: .executableCode) { + let language = try executableContainer.decode(String.self, forKey: .language) + let code = try executableContainer.decode(String.self, forKey: .code) + self = .executableCode(language: language, code: code) + return + } + + if let resultContainer = try? container.nestedContainer(keyedBy: CodeExecutionResultKeys.self, forKey: .codeExecutionResult) { + let outcome = try resultContainer.decode(String.self, forKey: .outcome) + let output = try resultContainer.decode(String.self, forKey: .output) + self = .codeExecutionResult(outcome: outcome, output: output) + return + } + + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Could not decode Part" + ) + ) + } + } + } + + public struct SafetyRating: Decodable { + public let blocked: Bool? + public let category: String? + public let probability: String? + } + + public struct GroundingMetadata: Decodable { + public let webSearchQueries: [String]? + public let searchEntryPoint: SearchEntryPoint? + public let groundingChunks: [WebSource]? + public let groundingSupports: [GroundingSupport]? + + public struct SearchEntryPoint: Decodable { + public let renderedContent: String + } + + public struct WebSource: Decodable { + public let web: WebInfo + + public struct WebInfo: Decodable { + public let uri: String + public let title: String + } + } + + public struct GroundingSupport: Decodable { + public let segment: Segment + public let groundingChunkIndices: [Int] + public let confidenceScores: [Double] + + public struct Segment: Decodable { + public let startIndex: Int? + public let endIndex: Int + public let text: String + } + } + } + } + + public struct UsageMetadata: Decodable { + public let cachedContentTokenCount: Int? + public let candidatesTokenCount: Int? + public let promptTokenCount: Int? + public let totalTokenCount: Int? + } + + public struct FileUpload: Codable { + public let file: _Gemini.File + } + + public struct UploadInitiation: Decodable { + public let uploadURL: URL + } + + public struct TunedGenerateContent: Decodable { + public let candidates: [Candidate]? + public let usageMetadata: UsageMetadata? + public let modelVersion: String? + + private enum CodingKeys: String, CodingKey { + case candidates + case usageMetadata + case modelVersion + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.candidates = try container.decodeIfPresent([Candidate].self, forKey: .candidates) + self.usageMetadata = try container.decodeIfPresent(UsageMetadata.self, forKey: .usageMetadata) + self.modelVersion = try container.decodeIfPresent(String.self, forKey: .modelVersion) + + // Validate that we have either candidates or usage metadata + guard candidates != nil || usageMetadata != nil else { + throw DecodingError.dataCorruptedError( + forKey: .candidates, + in: container, + debugDescription: "Response must contain either candidates or usage metadata" + ) + } + } + } + + public struct EmbeddingResponse: Decodable { + public let embedding: Embedding + + public struct Embedding: Decodable { + public let values: [Double] + } + } + } +} diff --git a/Sources/_Gemini/Intramodular/API/_Gemini.APISpecification.swift b/Sources/_Gemini/Intramodular/API/_Gemini.APISpecification.swift new file mode 100644 index 00000000..58e1fec2 --- /dev/null +++ b/Sources/_Gemini/Intramodular/API/_Gemini.APISpecification.swift @@ -0,0 +1,165 @@ +// +// _Gemini.APISpecification.swift +// AI +// +// Created by Jared Davidson on 12/11/24. +// + +import CorePersistence +import Diagnostics +import NetworkKit +import Swift +import SwiftAPI + +extension _Gemini { + public enum APIError: APIErrorProtocol { + public typealias API = _Gemini.APISpecification + + case apiKeyMissing + case incorrectAPIKeyProvided + case rateLimitExceeded + case invalidContentType + case badRequest(request: API.Request?, error: API.Request.Error) + case unknown(message: String) + case runtime(AnyError) + + public var traits: ErrorTraits { + [.domain(.networking)] + } + } + + public struct APISpecification: RESTAPISpecification { + public typealias Error = APIError + + public struct Configuration: Codable, Hashable { + public var host: URL + public var apiKey: String? + public var serviceURL: String? + public var clientID: String? + + public init( + host: URL = URL(string: "https://generativelanguage.googleapis.com")!, + apiKey: String? = nil, + serviceURL: String? = nil, + clientID: String? = nil + ) { + self.host = host + self.apiKey = apiKey + self.serviceURL = serviceURL + self.clientID = clientID + } + } + + public let configuration: Configuration + + public var host: URL { + configuration.host + } + + public var id: some Hashable { + configuration + } + + public init(configuration: Configuration) { + self.configuration = configuration + } + + // Generate Content endpoint + @POST + @Path({ context -> String in + "/v1beta/models/\(context.input.model):generateContent" + }) + @Body(json: \.requestBody) + var generateContent = Endpoint() + + // Initial Upload Request endpoint + @POST + @Path("/upload/v1beta/files") + @Header([ + "X-Goog-Upload-Command": "start, upload, finalize" + ]) + @Body(multipart: .input) + var uploadFile = Endpoint() + + // File Status endpoint + @GET + @Path({ context -> String in + "/v1beta/\(context.input.name)" + }) + var getFile = Endpoint() + + // Delete File endpoint + @DELETE + @Path({ context -> String in + "/\(context.input.fileURL.path)" + }) + var deleteFile = Endpoint() + + //Fine Tuning + @POST + @Path("/v1beta/tunedModels") + @Body(json: \.requestBody) + var createTunedModel = Endpoint() + + @GET + @Path({ context -> String in + "/v1/\(context.input.operationName)" + }) + var getTuningOperation = Endpoint() + + @GET + @Path({ context -> String in + "/v1beta/\(context.input.modelName)" + }) + var getTunedModel = Endpoint() + + @POST + @Path({ context -> String in + "/v1beta/\(context.input.model):generateContent" // Use the model name directly + }) + @Body(json: \.requestBody) + var generateTunedContent = Endpoint() + + @POST + @Path({ context -> String in + "/v1beta/models/\(context.input.model):embedContent" + }) + @Body(json: \.input) + var generateEmbedding = Endpoint() + } +} + +extension _Gemini.APISpecification { + public final class Endpoint: BaseHTTPEndpoint<_Gemini.APISpecification, Input, Output, Options> { + override public func buildRequestBase( + from input: Input, + context: BuildRequestContext + ) throws -> Request { + var request = try super.buildRequestBase( + from: input, + context: context + ) + + if let apiKey = context.root.configuration.apiKey { + request = request.query([.init(name: "key", value: apiKey)]) + } + + return request + } + + override public func decodeOutputBase( + from response: Request.Response, + context: DecodeOutputContext + ) throws -> Output { + + print(response) + + try response.validate() + + return try response.decode( + Output.self, + keyDecodingStrategy: .convertFromSnakeCase + ) + } + } +} diff --git a/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.FineTuningExample.swift b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.FineTuningExample.swift new file mode 100644 index 00000000..e9fe2f8f --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.FineTuningExample.swift @@ -0,0 +1,24 @@ +// +// _Gemini.FineTuningExample.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + + +extension _Gemini { + public struct FineTuningExample: Codable { + public let textInput: String + public let output: String + + private enum CodingKeys: String, CodingKey { + case textInput = "text_input" + case output + } + + public init(textInput: String, output: String) { + self.textInput = textInput + self.output = output + } + } +} diff --git a/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.FineTuningExamples.swift b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.FineTuningExamples.swift new file mode 100644 index 00000000..968a50cc --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.FineTuningExamples.swift @@ -0,0 +1,16 @@ +// +// _Gemini.FineTuningExamples.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + +extension _Gemini { + public struct FineTuningExamples: Codable { + public let examples: [FineTuningExample] + + public init(examples: [FineTuningExample]) { + self.examples = examples + } + } +} diff --git a/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.Hyperparameters.swift b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.Hyperparameters.swift new file mode 100644 index 00000000..dcdd7e61 --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.Hyperparameters.swift @@ -0,0 +1,31 @@ +// +// _Gemini.Hyperparameters.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + + +extension _Gemini { + public struct Hyperparameters: Codable { + public let batchSize: Int + public let learningRate: Double + public let epochCount: Int + + private enum CodingKeys: String, CodingKey { + case batchSize = "batch_size" + case learningRate = "learning_rate" + case epochCount = "epoch_count" + } + + public init( + batchSize: Int = 2, + learningRate: Double = 0.001, + epochCount: Int = 5 + ) { + self.batchSize = batchSize + self.learningRate = learningRate + self.epochCount = epochCount + } + } +} diff --git a/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.TrainingData.swift b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.TrainingData.swift new file mode 100644 index 00000000..fc4e77c7 --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.TrainingData.swift @@ -0,0 +1,16 @@ +// +// _Gemini.TrainingData.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + +extension _Gemini { + public struct TrainingData: Codable { + public let examples: FineTuningExamples + + public init(examples: FineTuningExamples) { + self.examples = examples + } + } +} diff --git a/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.TunedModel.swift b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.TunedModel.swift new file mode 100644 index 00000000..863336db --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.TunedModel.swift @@ -0,0 +1,26 @@ +// +// _Gemini.TuningModel.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + +// FIXME: - Break Apart + +extension _Gemini { + public struct TunedModel: Codable { + public let name: String + public let displayName: String + public let baseModel: String + public let state: State + public let createTime: String + public let updateTime: String + + public enum State: String, Codable { + case stateUnspecified = "STATE_UNSPECIFIED" + case creating = "CREATING" + case active = "ACTIVE" + case failed = "FAILED" + } + } +} diff --git a/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.TuningConfig.swift b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.TuningConfig.swift new file mode 100644 index 00000000..60d1c21f --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.TuningConfig.swift @@ -0,0 +1,26 @@ +// +// _Gemini.TuningConfig.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + +import Foundation + +extension _Gemini { + public struct TuningConfig: Codable { + public let displayName: String + public let baseModel: String + public let tuningTask: TuningTask + + public init( + displayName: String, + baseModel: _Gemini.Model, + tuningTask: TuningTask + ) { + self.displayName = displayName + self.baseModel = "models/" + baseModel.rawValue + "-001-tuning" + self.tuningTask = tuningTask + } + } +} diff --git a/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.TuningOperation.swift b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.TuningOperation.swift new file mode 100644 index 00000000..446bed42 --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.TuningOperation.swift @@ -0,0 +1,54 @@ +// +// _Gemini.TuningOperation.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + +import Foundation + +extension _Gemini { + public struct TuningOperation: Codable { + public let name: String + public let metadata: TuningMetadata? + public let error: TuningError? + + // Computed property for done state since it's not in initial response + public var done: Bool { + // Operation is done if we have a tunedModel in metadata + return metadata?.tunedModel != nil + } + + public struct TuningMetadata: Codable { + public let totalSteps: Int + public let tunedModel: String? + + private enum CodingKeys: String, CodingKey { + case totalSteps + case tunedModel + case type = "@type" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.totalSteps = try container.decode(Int.self, forKey: .totalSteps) + self.tunedModel = try container.decodeIfPresent(String.self, forKey: .tunedModel) + // Ignore the @type field as we don't need it + _ = try container.decodeIfPresent(String.self, forKey: .type) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(totalSteps, forKey: .totalSteps) + try container.encodeIfPresent(tunedModel, forKey: .tunedModel) + try container.encode("type.googleapis.com/google.ai.generativelanguage.v1beta.CreateTunedModelMetadata", forKey: .type) + } + } + + public struct TuningError: Codable { + public let code: Int + public let message: String + public let details: [String: String] + } + } +} diff --git a/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.TuningTask.swift b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.TuningTask.swift new file mode 100644 index 00000000..01c376e7 --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/Fine Tuning/_Gemini.TuningTask.swift @@ -0,0 +1,23 @@ +// +// _Gemini.TuningTask.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + +import Foundation + +extension _Gemini { + public struct TuningTask: Codable { + public let hyperparameters: Hyperparameters + public let trainingData: TrainingData + + public init( + hyperparameters: Hyperparameters, + trainingData: TrainingData + ) { + self.hyperparameters = hyperparameters + self.trainingData = trainingData + } + } +} diff --git a/Sources/_Gemini/Intramodular/Models/_Gemini.Content.swift b/Sources/_Gemini/Intramodular/Models/_Gemini.Content.swift new file mode 100644 index 00000000..a424a1e2 --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/_Gemini.Content.swift @@ -0,0 +1,283 @@ +// +// Copyright (c) Vatsal Manot +// + +import Foundation +import LargeLanguageModels +import Swallow + +extension _Gemini { + public struct Content: Decodable { + public let text: String + public let finishReason: FinishReason? + public let safetyRatings: [SafetyRating] + public let tokenUsage: TokenUsage? + public let role: String? = nil + public let parts: [Part] + public let groundingMetadata: GroundingMetadata? + + public enum Part { + case text(String) + case functionCall(_Gemini.FunctionCall) + case executableCode(language: String, code: String) + case codeExecutionResult(outcome: String, output: String) + } + + 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 + } + + public struct GroundingMetadata: Decodable { + public let searchEntryPoint: SearchEntryPoint? + public let groundingChunks: [WebSource] + public let groundingSupports: [GroundingSupport] + public let webSearchQueries: [String] + + public struct SearchEntryPoint: Decodable { + public let renderedContent: String + } + + public struct WebSource: Decodable { + public let web: WebInfo + + public struct WebInfo: Decodable { + public let uri: String + public let title: String + } + } + + public struct GroundingSupport: Decodable { + public let segment: Segment + public let groundingChunkIndices: [Int] + public let confidenceScores: [Double] + + public struct Segment: Decodable { + public let startIndex: Int? + public let endIndex: Int + public let text: String + } + } + } + + public init(from decoder: Decoder) throws { + self.text = "" + self.finishReason = nil + self.safetyRatings = [] + self.tokenUsage = nil + self.parts = [] + self.groundingMetadata = nil + + throw _Gemini.APIError.unknown(message: "Direct decoding not supported") + } + } +} + +extension _Gemini.Content { + init( + apiResponse response: _Gemini.APISpecification.ResponseBodies.GenerateContent + ) throws { + guard let candidate = response.candidates?.first, + let content = candidate.content, + let responseParts = content.parts else { + throw _Gemini.APIError.unknown(message: "Invalid response format") + } + + var parts: [Part] = [] + var textParts: [String] = [] + + for part in responseParts { + switch part { + case .text(let text): + parts.append(.text(text)) + textParts.append(text) + case .executableCode(let language, let code): + parts.append(.executableCode(language: language, code: code)) + textParts.append("```\(language.lowercased())\n\(code)\n```") + case .codeExecutionResult(let outcome, let output): + parts.append(.codeExecutionResult(outcome: outcome, output: output)) + textParts.append("Execution Result (\(outcome)):\n\(output)") + case .functionCall(let call): + parts.append(.functionCall(call)) + textParts.append("Function Call: \(call.name) with args: \(call.args)") + } + } + + self.parts = parts + self.text = textParts.filter { !$0.isEmpty }.joined(separator: "\n\n") + + if let finishReasonStr = candidate.finishReason { + self.finishReason = FinishReason(rawValue: finishReasonStr) + } else { + self.finishReason = nil + } + + 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 + ) + } + + if let usage = response.usageMetadata { + self.tokenUsage = TokenUsage( + prompt: usage.promptTokenCount ?? 0, + response: usage.candidatesTokenCount ?? 0, + total: usage.totalTokenCount ?? 0 + ) + } else { + self.tokenUsage = nil + } + + if let metadata = candidate.groundingMetadata { + let searchEntryPoint = metadata.searchEntryPoint.map { + GroundingMetadata.SearchEntryPoint(renderedContent: $0.renderedContent) + } + + let groundingChunks = (metadata.groundingChunks ?? []).map { + GroundingMetadata.WebSource( + web: .init( + uri: $0.web.uri, + title: $0.web.title + ) + ) + } + + let groundingSupports = (metadata.groundingSupports ?? []).map { + GroundingMetadata.GroundingSupport( + segment: .init( + startIndex: $0.segment.startIndex, + endIndex: $0.segment.endIndex, + text: $0.segment.text + ), + groundingChunkIndices: $0.groundingChunkIndices, + confidenceScores: $0.confidenceScores + ) + } + + self.groundingMetadata = GroundingMetadata( + searchEntryPoint: searchEntryPoint, + groundingChunks: groundingChunks, + groundingSupports: groundingSupports, + webSearchQueries: metadata.webSearchQueries ?? [] + ) + } else { + self.groundingMetadata = nil + } + } + + init( + apiResponse response: _Gemini.APISpecification.ResponseBodies.TunedGenerateContent + ) throws { + if response.candidates == nil || response.candidates?.isEmpty == true { + guard let usage = response.usageMetadata else { + throw _Gemini.APIError.unknown(message: "Response missing both candidates and usage metadata") + } + + self.text = "" + self.finishReason = .other // Set a default finish reason + self.safetyRatings = [] + self.parts = [] + self.groundingMetadata = nil + self.tokenUsage = TokenUsage( + prompt: usage.promptTokenCount ?? 0, + response: usage.candidatesTokenCount ?? 0, + total: usage.totalTokenCount ?? 0 + ) + return + } + + guard let candidate = response.candidates?.first, + let content = candidate.content else { + throw _Gemini.APIError.unknown(message: "Invalid candidate format") + } + + var parts: [Part] = [] + var textParts: [String] = [] + + if let responseParts = content.parts { + for part in responseParts { + switch part { + case .text(let text): + let normalizedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + parts.append(.text(normalizedText)) + textParts.append(normalizedText) + case .functionCall(let call): + parts.append(.functionCall(call)) + textParts.append("Function Call: \(call.name)") + case .executableCode(let language, let code): + parts.append(.executableCode(language: language, code: code)) + textParts.append("```\(language)\n\(code)\n```") + case .codeExecutionResult(let outcome, let output): + parts.append(.codeExecutionResult(outcome: outcome, output: output)) + textParts.append("\(outcome): \(output)") + } + } + } + + self.parts = parts + self.text = textParts.joined(separator: "\n\n") + + // Handle finish reason with a default value if not present + self.finishReason = candidate.finishReason + .flatMap { FinishReason(rawValue: $0) } ?? .other + + 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 + ) + } + + self.tokenUsage = response.usageMetadata.map { + TokenUsage( + prompt: $0.promptTokenCount ?? 0, + response: $0.candidatesTokenCount ?? 0, + total: $0.totalTokenCount ?? 0 + ) + } + self.groundingMetadata = nil + } +} diff --git a/Sources/_Gemini/Intramodular/Models/_Gemini.File.swift b/Sources/_Gemini/Intramodular/Models/_Gemini.File.swift new file mode 100644 index 00000000..4fd7c452 --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/_Gemini.File.swift @@ -0,0 +1,60 @@ +// +// _Gemini.File.swift +// AI +// +// Created by Jared Davidson on 12/11/24. +// + +import Foundation + +extension _Gemini { + public struct File: Codable { + public let createTime: String? + public let expirationTime: String? + public let mimeType: String? + public let name: _Gemini.File.Name? + public let sha256Hash: String? + public let sizeBytes: String? + public let state: State + public let updateTime: String? + public let uri: URL + public let videoMetadata: VideoMetadata? + + public enum State: String, Codable { + case processing = "PROCESSING" + case active = "ACTIVE" + } + + public struct VideoMetadata: Codable { + public let videoDuration: String + } + } +} + +extension _Gemini.File { + public struct Name: Codable, RawRepresentable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(from decoder: any Decoder) throws { + rawValue = try String(from: decoder) + } + + public func encode(to encoder: any Encoder) throws { + try rawValue.encode(to: encoder) + } + } +} + +// MARK: - Supplementary + +extension _Gemini { + public enum FileSource { + case localFile(URL) + case remoteURL(URL) + case uploadedFile(_Gemini.File) + } +} diff --git a/Sources/_Gemini/Intramodular/Models/_Gemini.FunctionCall.swift b/Sources/_Gemini/Intramodular/Models/_Gemini.FunctionCall.swift new file mode 100644 index 00000000..cb8ad501 --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/_Gemini.FunctionCall.swift @@ -0,0 +1,78 @@ +// +// _Gemini.FunctionCall.swift +// AI +// +// Created by Jared Davidson on 12/13/24. +// + +import Foundation + +extension _Gemini { + public struct FunctionCall: Codable, Equatable { + public let name: String + public let args: [String: String] + + public init(name: String, args: [String: String]) { + self.name = name + self.args = args + } + } + + public struct FunctionDefinition: Codable, Equatable { + public let name: String + public let description: String + public let parameters: ParameterSchema? + + public init(name: String, description: String, parameters: ParameterSchema? = nil) { + self.name = name + self.description = description + self.parameters = parameters + } + } + + public struct ParameterSchema: Codable, Equatable { + public let type: String + public let description: String? + public let properties: [String: ParameterSchema]? + public let required: [String]? + + public init( + type: String, + description: String? = nil, + properties: [String: ParameterSchema]? = nil, + required: [String]? = nil + ) { + self.type = type.uppercased() + self.description = description + self.properties = properties + self.required = required + } + } + + public struct FunctionCallingConfiguration: Codable, Equatable { + public enum Mode: String, Codable { + case auto = "AUTO" + case any = "ANY" + case none = "NONE" + } + + public let mode: Mode + public let allowedFunctionNames: [String]? + + public init( + mode: Mode, + allowedFunctionNames: [String]? = nil + ) { + self.mode = mode + self.allowedFunctionNames = allowedFunctionNames + } + } + + public struct ToolConfiguration: Codable, Equatable { + public let functionCallingConfig: FunctionCallingConfiguration? + + public init(functionCallingConfig: FunctionCallingConfiguration? = nil) { + self.functionCallingConfig = functionCallingConfig + } + } +} diff --git a/Sources/_Gemini/Intramodular/Models/_Gemini.GenerationConfig.swift b/Sources/_Gemini/Intramodular/Models/_Gemini.GenerationConfig.swift new file mode 100644 index 00000000..93d054fa --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/_Gemini.GenerationConfig.swift @@ -0,0 +1,116 @@ +// +// _Gemini.GenerationConfig.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + +import Foundation + +extension _Gemini { + public struct GenerationConfiguration: Codable { + public let maxOutputTokens: Int? + public let temperature: Double? + public let topP: Double? + public let topK: Int? + public let presencePenalty: Double? + public let frequencyPenalty: Double? + public let responseMimeType: String? + public let responseSchema: SchemaObject? + + public init( + maxOutputTokens: Int? = nil, + temperature: Double? = nil, + topP: Double? = nil, + topK: Int? = nil, + presencePenalty: Double? = nil, + frequencyPenalty: Double? = nil, + responseMimeType: String? = nil, + responseSchema: SchemaObject? = nil + ) { + self.maxOutputTokens = maxOutputTokens + self.temperature = temperature + self.topP = topP + self.topK = topK + self.presencePenalty = presencePenalty + self.frequencyPenalty = frequencyPenalty + self.responseMimeType = responseMimeType + self.responseSchema = responseSchema + } + } + + public indirect enum SchemaObject { + case object(properties: [String: SchemaObject]) + case array(items: SchemaObject) + case string + case number + case boolean + + public var type: SchemaType { + switch self { + case .object: + return .object + case .array: + return .array + case .string: + return .string + case .number: + return .number + case .boolean: + return .boolean + } + } + } + + public enum SchemaType: String, Codable { + case array = "ARRAY" + case object = "OBJECT" + case string = "STRING" + case number = "NUMBER" + case boolean = "BOOLEAN" + } +} + +// MARK: - Conformances + +extension _Gemini.SchemaObject: Codable { + private enum CodingKeys: String, CodingKey { + case type + case properties + case items + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + + switch self { + case .object(let properties): + try container.encode(properties, forKey: .properties) + case .array(let items): + try container.encode(items, forKey: .items) + case .string, .number, .boolean: + break + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(_Gemini.SchemaType.self, forKey: .type) + + switch type { + case .object: + let properties = try container.decode([String: _Gemini.SchemaObject].self, forKey: .properties) + self = .object(properties: properties) + case .array: + let items = try container.decode(_Gemini.SchemaObject.self, forKey: .items) + self = .array(items: items) + case .string: + self = .string + case .number: + self = .number + case .boolean: + self = .boolean + } + } +} diff --git a/Sources/_Gemini/Intramodular/Models/_Gemini.GoogleSearchRetrieval.swift b/Sources/_Gemini/Intramodular/Models/_Gemini.GoogleSearchRetrieval.swift new file mode 100644 index 00000000..775464d9 --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/_Gemini.GoogleSearchRetrieval.swift @@ -0,0 +1,40 @@ +// +// _Gemini.GoogleSearchRetrieval.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + +import Foundation + +extension _Gemini { + public struct GoogleSearchRetrieval: Codable { + private enum CodingKeys: String, CodingKey { + case dynamicRetrievalConfiguration = "dynamic_retrieval_config" + } + + public let dynamicRetrievalConfiguration: DynamicRetrievalConfiguration + + public init(dynamicRetrievalConfiguration: DynamicRetrievalConfiguration) { + self.dynamicRetrievalConfiguration = dynamicRetrievalConfiguration + } + } + + public struct DynamicRetrievalConfiguration: Codable { + private enum CodingKeys: String, CodingKey { + case mode + case dynamicThreshold = "dynamic_threshold" + } + + public let mode: String + public let dynamicThreshold: Double + + public init( + mode: String = "MODE_DYNAMIC", + dynamicThreshold: Double + ) { + self.mode = mode + self.dynamicThreshold = dynamicThreshold + } + } +} diff --git a/Sources/_Gemini/Intramodular/Models/_Gemini.Message.swift b/Sources/_Gemini/Intramodular/Models/_Gemini.Message.swift new file mode 100644 index 00000000..7fb4891c --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/_Gemini.Message.swift @@ -0,0 +1,29 @@ +// +// _Gemini.Message.swift +// AI +// +// Created by Jared Davidson on 12/13/24. +// + +import Foundation + +extension _Gemini { + public struct Message: Codable { + public let role: Role + public let content: String + + public init(role: Role, content: String) { + self.role = role + self.content = content + } + internal func toRequestContent() -> _Gemini.APISpecification.RequestBodies.Content { + .init(role: role.rawValue, parts: [.text(content)]) + } + } + + public enum Role: String, Codable { + case user = "user" + case system = "system" + case assistant = "assistant" + } +} diff --git a/Sources/_Gemini/Intramodular/Models/_Gemini.Tool.swift b/Sources/_Gemini/Intramodular/Models/_Gemini.Tool.swift new file mode 100644 index 00000000..f5940de5 --- /dev/null +++ b/Sources/_Gemini/Intramodular/Models/_Gemini.Tool.swift @@ -0,0 +1,56 @@ +// +// _Gemini.Tool.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + +import Foundation + +extension _Gemini { + public struct Tool: Codable { + private enum CodingKeys: String, CodingKey { + case functionDeclarations = "function_declarations" + case codeExecution = "code_execution" + case googleSearchRetrieval = "google_search_retrieval" + } + + public let functionDeclarations: [_Gemini.FunctionDefinition]? + public let codeExecutionEnabled: Bool + public let googleSearchRetrieval: _Gemini.GoogleSearchRetrieval? + + public init(functionDeclarations: [_Gemini.FunctionDefinition]? = nil) { + self.functionDeclarations = functionDeclarations + self.codeExecutionEnabled = false + self.googleSearchRetrieval = nil + } + + public init(codeExecutionEnabled: Bool = true) { + self.functionDeclarations = nil + self.codeExecutionEnabled = codeExecutionEnabled + self.googleSearchRetrieval = nil + } + + public init(googleSearchRetrieval: _Gemini.GoogleSearchRetrieval) { + self.functionDeclarations = nil + self.codeExecutionEnabled = false + self.googleSearchRetrieval = googleSearchRetrieval + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(functionDeclarations, forKey: .functionDeclarations) + if codeExecutionEnabled { + try container.encode([String: String](), forKey: .codeExecution) + } + try container.encodeIfPresent(googleSearchRetrieval, forKey: .googleSearchRetrieval) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.functionDeclarations = try container.decodeIfPresent([_Gemini.FunctionDefinition].self, forKey: .functionDeclarations) + self.codeExecutionEnabled = false + self.googleSearchRetrieval = try container.decodeIfPresent(_Gemini.GoogleSearchRetrieval.self, forKey: .googleSearchRetrieval) + } + } +} diff --git a/Sources/_Gemini/Intramodular/_Gemini.Client+CodeExecution.swift b/Sources/_Gemini/Intramodular/_Gemini.Client+CodeExecution.swift new file mode 100644 index 00000000..2ae235f2 --- /dev/null +++ b/Sources/_Gemini/Intramodular/_Gemini.Client+CodeExecution.swift @@ -0,0 +1,47 @@ +// +// Copyright (c) Vatsal Manot +// + +import Foundation +import Swallow + +extension _Gemini.Client { + public func generateContentWithCodeExecution( + messages: [_Gemini.Message], + model: _Gemini.Model, + toolConfig: _Gemini.ToolConfiguration? = nil, + configuration: _Gemini.GenerationConfiguration? = nil + ) async throws -> _Gemini.Content { + let contents = messages.filter { $0.role != .system }.map { message in + _Gemini.APISpecification.RequestBodies.Content( + role: message.role.rawValue, + parts: [.text(message.content)] + ) + } + + let systemInstruction = messages.first { $0.role == .system }.map { message in + _Gemini.APISpecification.RequestBodies.Content( + role: message.role.rawValue, + parts: [.text(message.content)] + ) + } + + let tool = _Gemini.Tool(codeExecutionEnabled: true) + let input = _Gemini.APISpecification.RequestBodies.GenerateContentInput( + model: model, + requestBody: .init( + contents: contents, + generationConfig: configuration, + tools: [tool], + toolConfiguration: toolConfig, + systemInstruction: systemInstruction + ) + ) + + let response = try await run(\.generateContent, with: input) + + let content = try _Gemini.Content(apiResponse: response) + + return content + } +} diff --git a/Sources/_Gemini/Intramodular/_Gemini.Client+ContentGeneration.swift b/Sources/_Gemini/Intramodular/_Gemini.Client+ContentGeneration.swift new file mode 100644 index 00000000..06ed2205 --- /dev/null +++ b/Sources/_Gemini/Intramodular/_Gemini.Client+ContentGeneration.swift @@ -0,0 +1,114 @@ +// +// _Gemini.Client+ContentGeneration.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + +import CorePersistence +import Diagnostics +import NetworkKit +import Foundation +import SwiftAPI +import Merge +import FoundationX +import Swallow + +extension _Gemini.Client { + static public let configDefault: _Gemini.GenerationConfiguration = .init( + maxOutputTokens: 8192, + temperature: 1, + topP: 0.95, + topK: 40, + responseMimeType: "text/plain" + ) + + public func generateContent( + messages: [_Gemini.Message] = [], + fileSource: _Gemini.FileSource? = nil, + mimeType: HTTPMediaType? = nil, + model: _Gemini.Model, + configuration: _Gemini.GenerationConfiguration = configDefault + ) async throws -> _Gemini.Content { + let file: _Gemini.File? + + if let fileSource = fileSource { + file = try await _processedFile(from: fileSource, mimeType: mimeType) + } else { + file = nil + } + + let systemInstruction = extractSystemInstruction(from: messages) + let messages: [_Gemini.Message] = messages.filter({ $0.role != .system }) + var contents: [_Gemini.APISpecification.RequestBodies.Content] = [] + + if let file { + guard let mimeType = file.mimeType else { + throw _Gemini.APIError.unknown(message: "Invalid MIME type") + } + + contents.append( + _Gemini.APISpecification.RequestBodies.Content( + role: "user", + parts: [.file(url: file.uri, mimeType: mimeType)] + ) + ) + } + + contents.append(contentsOf: messages.map { message in + _Gemini.APISpecification.RequestBodies.Content( + role: message.role.rawValue, + parts: [.text(message.content)] + ) + }) + + return try await generateContent( + contents: contents, + systemInstruction: systemInstruction, + model: model, + configuration: configuration + ) + } + + internal func generateContent( + contents: [_Gemini.APISpecification.RequestBodies.Content], + systemInstruction: _Gemini.APISpecification.RequestBodies.Content?, + model: _Gemini.Model, + configuration: _Gemini.GenerationConfiguration + ) async throws -> _Gemini.Content { + let input = _Gemini.APISpecification.RequestBodies.GenerateContentInput( + model: model, + requestBody: .init( + contents: contents, + generationConfig: configuration, + systemInstruction: systemInstruction + ) + ) + + let response = try await run(\.generateContent, with: input) + + return try _Gemini.Content(apiResponse: response) + } + + internal func extractSystemInstruction( + from messages: [_Gemini.Message] + ) -> _Gemini.APISpecification.RequestBodies.Content? { + messages.first { $0.role == .system }.map { message in + _Gemini.APISpecification.RequestBodies.Content( + role: message.role.rawValue, + parts: [.text(message.content)] + ) + } + } +} + +// MARK: - Error Handling + +extension _Gemini.Client { + fileprivate enum ContentGenerationError: Error { + case invalidFileName + case processingTimeout(fileName: String) + case invalidFileState(state: String) + case fileNotFound(name: String) + } +} diff --git a/Sources/_Gemini/Intramodular/_Gemini.Client+Embeddings.swift b/Sources/_Gemini/Intramodular/_Gemini.Client+Embeddings.swift new file mode 100644 index 00000000..deeaa709 --- /dev/null +++ b/Sources/_Gemini/Intramodular/_Gemini.Client+Embeddings.swift @@ -0,0 +1,27 @@ +// +// _Gemini..swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + +extension _Gemini.Client { + public func generateEmbedding( + text: String, + model: _Gemini.Model = .text_embedding_004 + ) async throws -> [Double] { + let content = _Gemini.APISpecification.RequestBodies.Content( + role: "user", + parts: [.text(text)] + ) + + let input = _Gemini.APISpecification.RequestBodies.EmbeddingInput( + model: model, + content: content + ) + + let response = try await run(\.generateEmbedding, with: input) + + return response.embedding.values + } +} diff --git a/Sources/_Gemini/Intramodular/_Gemini.Client+Files.swift b/Sources/_Gemini/Intramodular/_Gemini.Client+Files.swift new file mode 100644 index 00000000..ff454ba7 --- /dev/null +++ b/Sources/_Gemini/Intramodular/_Gemini.Client+Files.swift @@ -0,0 +1,177 @@ +// +// Copyright (c) Vatsal Manot +// + +import Dispatch +import Foundation +import Merge +import NetworkKit +import Swallow + +extension _Gemini.Client { + public func uploadFile( + fileData: Data, + mimeType: HTTPMediaType, + displayName: String + ) async throws -> _Gemini.File { + guard !displayName.isEmpty else { + throw FileProcessingError.invalidFileName + } + + do { + let input = _Gemini.APISpecification.RequestBodies.FileUploadInput( + fileData: fileData, + mimeType: mimeType.rawValue, + displayName: displayName + ) + + let response = try await run(\.uploadFile, with: input) + + return response.file + } catch { + throw _Gemini.APIError.unknown(message: "File upload failed: \(error.localizedDescription)") + } + } + + public func getFile( + name: _Gemini.File.Name + ) async throws -> _Gemini.File { + guard !name.rawValue.isEmpty else { + throw FileProcessingError.invalidFileName + } + + do { + let input = _Gemini.APISpecification.RequestBodies.FileStatusInput(name: name) + return try await run(\.getFile, with: input) + } catch { + throw _Gemini.APIError.unknown(message: "Failed to get file status: \(error.localizedDescription)") + } + } + + public func deleteFile( + fileURL: URL + ) async throws { + do { + let input = _Gemini.APISpecification.RequestBodies.DeleteFileInput(fileURL: fileURL) + try await run(\.deleteFile, with: input) + } catch { + throw _Gemini.APIError.unknown(message: "Failed to delete file: \(error.localizedDescription)") + } + } + + public func pollFileUntilActive( + name: _Gemini.File.Name, + maxRetryCount: Int? = nil, + retryDelay: DispatchTimeInterval = .seconds(1) + ) async throws -> _Gemini.File { + guard !name.rawValue.isEmpty else { + throw FileProcessingError.invalidFileName + } + + let result = try await Task.retrying( + priority: nil, + maxRetryCount: maxRetryCount ?? Int.max, + retryDelay: retryDelay + ) { + let file: _Gemini.File = try await self.getFile(name: name) + + switch file.state { + case .active: + return file + case .processing: + throw FileProcessingError.fileStillProcessing + } + }.value + + return result + } + + internal func processLocalFile( + fileURL: URL, + mimeType: HTTPMediaType? + ) async throws -> _Gemini.File { + guard let mimeType = mimeType else { + throw _Gemini.APIError.unknown(message: "MIME type is required when using fileURL") + } + + do { + let data = try Data(contentsOf: fileURL) + let file = try await uploadFile( + fileData: data, + mimeType: mimeType, + displayName: UUID().uuidString + ) + return file + } catch let error as NSError where error.domain == NSCocoaErrorDomain { + throw _Gemini.APIError.unknown(message: "Failed to read file: \(error.localizedDescription)") + } + } + + internal func processRemoteURL( + url: URL, + mimeType: HTTPMediaType? + ) async throws -> _Gemini.File { + guard let mimeType = mimeType else { + throw _Gemini.APIError.unknown(message: "MIME type is required when using remote URL") + } + + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw _Gemini.APIError.unknown(message: "Failed to download file from URL") + } + + let file = try await uploadFile( + fileData: data, + mimeType: mimeType, + displayName: UUID().uuidString + ) + return file + } + + func _processedFile( + from fileSource: _Gemini.FileSource, + mimeType: HTTPMediaType? + ) async throws -> _Gemini.File { + enum FileGenerationError: Swift.Error { + case missingFileName + } + + let initialFile: _Gemini.File + + switch fileSource { + case .localFile(let fileURL): + initialFile = try await processLocalFile( + fileURL: fileURL, + mimeType: mimeType + ) + + case .remoteURL(let url): + initialFile = try await processRemoteURL( + url: url, + mimeType: mimeType + ) + + case .uploadedFile(let file): + initialFile = file + } + + guard let name: _Gemini.File.Name = initialFile.name else { + throw FileGenerationError.missingFileName + } + + let result = try await pollFileUntilActive(name: name) + + return result + } +} + +// MARK: - Error Handling + +fileprivate enum FileProcessingError: Error { + case invalidFileName + case fileStillProcessing + case invalidFileState(state: String) + case fileNotFound(name: String) +} diff --git a/Sources/_Gemini/Intramodular/_Gemini.Client+FineTuning.swift b/Sources/_Gemini/Intramodular/_Gemini.Client+FineTuning.swift new file mode 100644 index 00000000..9ce58bfd --- /dev/null +++ b/Sources/_Gemini/Intramodular/_Gemini.Client+FineTuning.swift @@ -0,0 +1,104 @@ +// +// _Gemini.Client+FineTuning.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + +import Foundation + +extension _Gemini.Client { + public func createTunedModel( + config: _Gemini.TuningConfig + ) async throws -> _Gemini.TuningOperation { + let input = _Gemini.APISpecification.RequestBodies.CreateTunedModel( + requestBody: config + ) + return try await run(\.createTunedModel, with: input) + } + + public func getTuningOperation( + operationName: String + ) async throws -> _Gemini.TuningOperation { + let input = _Gemini.APISpecification.RequestBodies.GetOperation( + operationName: operationName + ) + return try await run(\.getTuningOperation, with: input) + } + + public func getTunedModel( + modelName: String + ) async throws -> _Gemini.TunedModel { + let input = _Gemini.APISpecification.RequestBodies.GetTunedModel( + modelName: modelName + ) + return try await run(\.getTunedModel, with: input) + } + + public func generateWithTunedModel( + modelName: String, + input: String, + config: _Gemini.GenerationConfiguration = configDefault + ) async throws -> _Gemini.Content { + let messages = [ + _Gemini.Message(role: .user, content: input) + ] + + let contents = messages.filter { $0.role != .system }.map { message in + _Gemini.APISpecification.RequestBodies.Content( + role: message.role.rawValue, + parts: [.text(message.content)] + ) + } + + let input = _Gemini.APISpecification.RequestBodies.GenerateContentInput( + model: .tunedModel(modelName), + requestBody: .init( + contents: contents, + generationConfig: config + ) + ) + + let response = try await run(\.generateTunedContent, with: input) + return try _Gemini.Content(apiResponse: response) + } + + public func waitForTuningCompletion( + operation: _Gemini.TuningOperation, + pollingInterval: TimeInterval = 5.0, + timeout: TimeInterval = 3600.0 + ) async throws -> _Gemini.TunedModel { + let startTime = Date() + var currentOperation = operation + + while true { + if Date().timeIntervalSince(startTime) > timeout { + throw _Gemini.APIError.unknown(message: "Tuning operation timed out") + } + + if let tunedModelName = currentOperation.metadata?.tunedModel { + // Get the model status + let model = try await getTunedModel(modelName: tunedModelName) + + switch model.state { + case .active: + return model + case .failed: + throw _Gemini.APIError.unknown(message: "Model tuning failed") + case .creating: + // Continue polling + try await Task.sleep(nanoseconds: UInt64(pollingInterval * 1_000_000_000)) + case .stateUnspecified: + throw _Gemini.APIError.unknown(message: "Invalid model state") + } + } + + if let error = currentOperation.error { + throw _Gemini.APIError.unknown(message: "Tuning failed: \(error.message)") + } + + try await Task.sleep(nanoseconds: UInt64(pollingInterval * 1_000_000_000)) + currentOperation = try await getTuningOperation(operationName: currentOperation.name) + } + } +} diff --git a/Sources/_Gemini/Intramodular/_Gemini.Client+FunctionCalling.swift b/Sources/_Gemini/Intramodular/_Gemini.Client+FunctionCalling.swift new file mode 100644 index 00000000..368710cf --- /dev/null +++ b/Sources/_Gemini/Intramodular/_Gemini.Client+FunctionCalling.swift @@ -0,0 +1,44 @@ +// +// _Gemini.Client+FunctionCalling.swift +// AI +// +// Created by Jared Davidson on 12/13/24. +// + +extension _Gemini.Client { + public func generateContentWithFunctions( + messages: [_Gemini.Message], + functions: [_Gemini.FunctionDefinition], + model: _Gemini.Model, + functionConfig: _Gemini.FunctionCallingConfiguration = .init(mode: .auto) + ) async throws -> _Gemini.Content { + let contents = messages.filter { $0.role != .system }.map { message in + _Gemini.APISpecification.RequestBodies.Content( + role: message.role.rawValue, + parts: [.text(message.content)] + ) + } + + let systemInstruction = messages.first { $0.role == .system }.map { message in + _Gemini.APISpecification.RequestBodies.Content( + role: message.role.rawValue, + parts: [.text(message.content)] + ) + } + + let tool = _Gemini.Tool(functionDeclarations: functions) + let input = _Gemini.APISpecification.RequestBodies.GenerateContentInput( + model: model, + requestBody: .init( + contents: contents, + tools: [tool], + toolConfiguration: _Gemini.ToolConfiguration(functionCallingConfig: functionConfig), + systemInstruction: systemInstruction + ) + ) + + let response = try await run(\.generateContent, with: input) + + return try _Gemini.Content(apiResponse: response) + } +} diff --git a/Sources/_Gemini/Intramodular/_Gemini.Client+Grounding.swift b/Sources/_Gemini/Intramodular/_Gemini.Client+Grounding.swift new file mode 100644 index 00000000..377379e3 --- /dev/null +++ b/Sources/_Gemini/Intramodular/_Gemini.Client+Grounding.swift @@ -0,0 +1,51 @@ +// +// _Gemini.Client+Grounding.swift +// AI +// +// Created by Jared Davidson on 12/13/24. +// + +extension _Gemini.Client { + public func generateContentWithGrounding( + messages: [_Gemini.Message], + model: _Gemini.Model, + dynamicThreshold: Double = 0.3 + ) async throws -> _Gemini.Content { + let contents = messages.filter { $0.role != .system }.map { message in + _Gemini.APISpecification.RequestBodies.Content( + role: message.role.rawValue, + parts: [.text(message.content)] + ) + } + + let systemInstruction = messages.first { $0.role == .system }.map { message in + _Gemini.APISpecification.RequestBodies.Content( + role: message.role.rawValue, + parts: [.text(message.content)] + ) + } + + let tool = _Gemini.Tool( + googleSearchRetrieval: .init( + dynamicRetrievalConfiguration: .init( + mode: "MODE_DYNAMIC", + dynamicThreshold: dynamicThreshold + ) + ) + ) + + let input = _Gemini.APISpecification.RequestBodies.GenerateContentInput( + model: model, + requestBody: .init( + contents: contents, + tools: [tool], + toolConfiguration: nil, + systemInstruction: systemInstruction + ) + ) + + let response = try await run(\.generateContent, with: input) + + return try _Gemini.Content(apiResponse: response) + } +} diff --git a/Sources/_Gemini/Intramodular/_Gemini.Client.swift b/Sources/_Gemini/Intramodular/_Gemini.Client.swift new file mode 100644 index 00000000..89cb515a --- /dev/null +++ b/Sources/_Gemini/Intramodular/_Gemini.Client.swift @@ -0,0 +1,39 @@ +// +// _Gemini.CLient.swift +// AI +// +// Created by Jared Davidson on 12/11/24. +// + +import Diagnostics +import NetworkKit +import Foundation +import Merge +import FoundationX +import Swallow + +extension _Gemini { + @RuntimeDiscoverable + public final class Client: HTTPClient, _StaticSwift.Namespace { + public typealias API = _Gemini.APISpecification + public typealias Session = HTTPSession + + public let interface: API + public let session: Session + public var sessionCache: EmptyKeyedCache + + public required init(configuration: API.Configuration) { + self.interface = API(configuration: configuration) + self.session = HTTPSession.shared + self.sessionCache = .init() + } + + public convenience init(apiKey: String?) { + self.init(configuration: .init(apiKey: apiKey)) + } + } +} + +extension _Gemini.Client { + +} diff --git a/Sources/_Gemini/Intramodular/_Gemini.Model.swift b/Sources/_Gemini/Intramodular/_Gemini.Model.swift index 7169e784..672dc5e0 100644 --- a/Sources/_Gemini/Intramodular/_Gemini.Model.swift +++ b/Sources/_Gemini/Intramodular/_Gemini.Model.swift @@ -6,27 +6,49 @@ import CoreMI import Swift extension _Gemini { - public enum Model: String, CaseIterable, Codable, Hashable, Sendable { - case gemini_1_5_pro = "gemini-1.5-pro" - case gemini_1_5_pro_latest = "gemini-1.5-pro-latest" - case gemini_1_5_flash = "gemini-1.5-flash" - case gemini_1_5_flash_latest = "gemini-1.5-flash-latest" - case gemini_1_0_pro = "gemini-1.0-pro" + public struct Model: RawRepresentable, Codable, Hashable, Sendable, CaseIterable { + public static var allCases: [Model] = [ + .gemini_2_0_flash_exp, + .gemini_1_5_pro, + .gemini_1_5_pro_latest, + .gemini_1_5_flash, + .gemini_1_5_flash_latest, + .gemini_1_0_pro, + .text_embedding_004 + ] + + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public static let gemini_2_0_flash_exp = Model(rawValue: "gemini-2.0-flash-exp") + public static let gemini_1_5_pro = Model(rawValue: "gemini-1.5-pro") + public static let gemini_1_5_pro_latest = Model(rawValue: "gemini-1.5-pro-latest") + public static let gemini_1_5_flash = Model(rawValue: "gemini-1.5-flash") + public static let gemini_1_5_flash_latest = Model(rawValue: "gemini-1.5-flash-latest") + public static let gemini_1_0_pro = Model(rawValue: "gemini-1.0-pro") + public static let text_embedding_004 = Model(rawValue: "text-embedding-004") public var maximumContextLength: Int { switch self { - case .gemini_1_5_pro: + case .gemini_2_0_flash_exp: return 1048576 - case .gemini_1_5_pro_latest: + case .gemini_1_5_pro, .gemini_1_5_pro_latest: return 1048576 - case .gemini_1_5_flash: - return 1048576 - case .gemini_1_5_flash_latest: + case .gemini_1_5_flash, .gemini_1_5_flash_latest: return 1048576 case .gemini_1_0_pro: return 30720 + default: + return 30720 } } + + public static func tunedModel(_ name: String) -> Model { + Model(rawValue: name) + } } } @@ -48,7 +70,7 @@ extension _Gemini.Model: ModelIdentifierRepresentable { throw _DecodingError.invalidModelProvider } - self = try Self(rawValue: model.name).unwrap() + self = Self(rawValue: model.name) } public func __conversion() -> ModelIdentifier { diff --git a/Tests/ElevenLabs/module.swift b/Tests/ElevenLabs/module.swift index 8435762d..795e2958 100644 --- a/Tests/ElevenLabs/module.swift +++ b/Tests/ElevenLabs/module.swift @@ -5,7 +5,7 @@ import ElevenLabs public var ELEVENLABS_API_KEY: String { - "0dea648f8b5c9497b647902ae00e6903" + "" } public var client: ElevenLabs.Client { diff --git a/Tests/HumeAI/module.swift b/Tests/HumeAI/module.swift index a0cddc5f..22b6ac94 100644 --- a/Tests/HumeAI/module.swift +++ b/Tests/HumeAI/module.swift @@ -7,7 +7,7 @@ import HumeAI -let HUMEAI_API_KEY = "Ei8s58Zp0JWqH9g00N8LdOFnpu03H4uj1Nr300OAh5dsdiGr" +let HUMEAI_API_KEY = "" var client = HumeAI.Client( apiKey: HUMEAI_API_KEY diff --git a/Tests/NeetsAI/module.swift b/Tests/NeetsAI/module.swift index 147ed769..e3616823 100644 --- a/Tests/NeetsAI/module.swift +++ b/Tests/NeetsAI/module.swift @@ -8,7 +8,7 @@ import NeetsAI public var NEETSAI_API_KEY: String { - "59fd70d014324dfe9100c8d3daefd84c" + "" } public var client: NeetsAI.Client { diff --git a/Tests/PlayHT/module.swift b/Tests/PlayHT/module.swift index 182cfdab..4bb29191 100644 --- a/Tests/PlayHT/module.swift +++ b/Tests/PlayHT/module.swift @@ -5,11 +5,11 @@ import PlayHT public var PLAYHT_API_KEY: String { - "fcfc923b8bd44fc383c9d23e409d52b1" + "" } public var PLAYHT_USER_ID: String { - "gze0b6x9kbXPVPOINZTAB09TsZ63" + "" } public var client: PlayHT.Client { diff --git a/Tests/_Gemini/Intramodular/_GeminiTests+CodeExecution.swift b/Tests/_Gemini/Intramodular/_GeminiTests+CodeExecution.swift new file mode 100644 index 00000000..3f302bf3 --- /dev/null +++ b/Tests/_Gemini/Intramodular/_GeminiTests+CodeExecution.swift @@ -0,0 +1,56 @@ +// +// _GeminiTests+CodeExecution.swift +// AI +// +// Created by Jared Davidson on 12/13/24. +// + +import Testing +import Foundation +import _Gemini +import AI + +@Suite struct _GeminiCodeExecutionTests { + @Test func testCodeExecution() async throws { + let messages = [ + _Gemini.Message( + role: .user, + content: "What is the sum of the first 50 prime numbers? Generate and run code for the calculation, and make sure you get all 50." + ) + ] + + let response = try await client.generateContentWithCodeExecution( + messages: messages, + model: .gemini_1_5_pro_latest + ) + + print("Response:", response) + + // Basic response validation + #expect(!response.text.isEmpty, "Response should not be empty") + + // Content structure validation + let responseText = response.text + + // Check for Python code + let codeBlockExists = responseText.contains("```python") && responseText.contains("```") + #expect(codeBlockExists, "Response should contain a Python code block") + + // Check for the correct result + let containsCorrectResult = responseText.contains("5117") + #expect(containsCorrectResult, "Response should contain the correct sum (5117)") + + // Check token usage is present and valid + let tokenUsage = response.tokenUsage + #expect(tokenUsage != nil, "Token usage should be present") + if let usage = tokenUsage { + #expect(usage.prompt > 0, "Prompt tokens should be greater than 0") + #expect(usage.response > 0, "Response tokens should be greater than 0") + #expect(usage.total > 0, "Total tokens should be greater than 0") + #expect(usage.total == usage.prompt + usage.response, "Total tokens should equal prompt + response") + } + + // Check finish reason + #expect(response.finishReason == .stop, "Response should have completed normally") + } +} diff --git a/Tests/_Gemini/Intramodular/_GeminiTests+Embeddings.swift b/Tests/_Gemini/Intramodular/_GeminiTests+Embeddings.swift new file mode 100644 index 00000000..8cd84af7 --- /dev/null +++ b/Tests/_Gemini/Intramodular/_GeminiTests+Embeddings.swift @@ -0,0 +1,36 @@ +// +// _GeminiTests+Embeddings.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + +import Testing +import Foundation +import _Gemini +import AI + +@Suite struct _GeminiEmbeddingTests { + @Test func testGenerateEmbedding() async throws { + let text = "What is the meaning of life?" + + let embedding = try await client.generateEmbedding(text: text) + + // Basic validation checks + #expect(!embedding.isEmpty, "Embedding should not be empty") + #expect(embedding.count > 0, "Embedding should have multiple dimensions") + + // Validate embedding values are within expected range + for value in embedding { + #expect(value >= -1.0 && value <= 1.0, "Embedding values should be normalized between -1 and 1") + } + + // Print some basic statistics + let sum = embedding.reduce(0, +) + let average = sum / Double(embedding.count) + print("Embedding statistics:") + print("- Dimensions:", embedding.count) + print("- Average value:", average) + print("- First few values:", Array(embedding.prefix(5))) + } +} diff --git a/Tests/_Gemini/Intramodular/_GeminiTests+FineTuning.swift b/Tests/_Gemini/Intramodular/_GeminiTests+FineTuning.swift new file mode 100644 index 00000000..f02ba1c2 --- /dev/null +++ b/Tests/_Gemini/Intramodular/_GeminiTests+FineTuning.swift @@ -0,0 +1,159 @@ +// +// _GeminiTests+FineTuning.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + +import Testing +import Foundation +import _Gemini +import AI + +@Suite struct _GeminiModelTuningTests { + // Test case structure + struct TestCase { + let input: String + let expectedOutput: String + } + + // Keep existing examples and config + static let examples = [ + // Numeric inputs + _Gemini.FineTuningExample(textInput: "1", output: "2"), + _Gemini.FineTuningExample(textInput: "2", output: "3"), + _Gemini.FineTuningExample(textInput: "3", output: "4"), + _Gemini.FineTuningExample(textInput: "4", output: "5"), + _Gemini.FineTuningExample(textInput: "9", output: "10"), + _Gemini.FineTuningExample(textInput: "10", output: "11"), + + // Text inputs + _Gemini.FineTuningExample(textInput: "one", output: "two"), + _Gemini.FineTuningExample(textInput: "two", output: "three"), + _Gemini.FineTuningExample(textInput: "three", output: "four"), + _Gemini.FineTuningExample(textInput: "nine", output: "ten"), + _Gemini.FineTuningExample(textInput: "ten", output: "eleven"), + ] + + static let tuningConfig = _Gemini.TuningConfig( + displayName: "number increment model", + baseModel: .gemini_1_5_flash, + tuningTask: .init( + hyperparameters: .init( + batchSize: 4, + learningRate: 0.001, + epochCount: 10 + ), + trainingData: .init( + examples: .init(examples: examples) + ) + ) + ) + + @Test func testCreateTunedModel() async throws { + print("\nStarting model tuning...") + let operation = try await client.createTunedModel(config: Self.tuningConfig) + + print("\nInitial operation response:", + String(data: try JSONEncoder().encode(operation), encoding: .utf8) ?? "") + + #expect(!operation.name.isEmpty) + #expect(operation.metadata != nil) + + if let totalSteps = operation.metadata?.totalSteps { + print("\nTotal tuning steps:", totalSteps) + } + + print("\nWaiting for model to become active...") + let completedModel = try await client.waitForTuningCompletion( + operation: operation + ) + + print("\nFinal model state:", completedModel.state.rawValue) + print("Model details:", + String(data: try JSONEncoder().encode(completedModel), encoding: .utf8) ?? "") + + #expect(completedModel.state == .active) + + UserDefaults.standard.set(completedModel.name, forKey: "lastTunedModelName") + } + + @Test func testGenerateWithTunedModel() async throws { + guard let modelName = UserDefaults.standard.string(forKey: "lastTunedModelName") else { + throw TestError.noModelAvailable + } + + print("\nUsing model:", modelName) + + let testCases = [ + // Test numeric inputs + TestCase(input: "5", expectedOutput: "6"), + TestCase(input: "10", expectedOutput: "11"), + TestCase(input: "99", expectedOutput: "100"), + TestCase(input: "-3", expectedOutput: "-2"), + + // Test text inputs + TestCase(input: "ten", expectedOutput: "eleven"), + TestCase(input: "twenty", expectedOutput: "twenty one"), + TestCase(input: "thirty", expectedOutput: "thirty one") + ] + + let config = _Gemini.GenerationConfiguration( + maxOutputTokens: 100, + temperature: 0.0, // Use 0 temperature for deterministic outputs + topP: 1.0, + topK: 1 + ) + + var successCount = 0 + var failureCount = 0 + + for testCase in testCases { + print("\n=== Testing input:", testCase.input, "===") + do { + let response = try await client.generateWithTunedModel( + modelName: modelName, + input: testCase.input, + config: config + ) + + let output = response.text.trimmingCharacters(in: .whitespacesAndNewlines) + print("Model output:", output) + print("Expected output:", testCase.expectedOutput) + + if let usage = response.tokenUsage { + print("Token usage - Prompt:", usage.prompt, + "Response:", usage.response, + "Total:", usage.total) + } + + #expect(!output.isEmpty, "Output should not be empty") + + if output == testCase.expectedOutput { + print("✅ Output matches expected") + successCount += 1 + } else { + print("⚠️ Output differs from expected:") + print(" Actual:", output) + print(" Expected:", testCase.expectedOutput) + failureCount += 1 + } + } catch { + print("❌ Error testing input '\(testCase.input)':", error) + failureCount += 1 + } + } + + print("\nTest Summary:") + print("Successes:", successCount) + print("Failures:", failureCount) + print("Total cases:", testCases.count) + + #expect(successCount > 0, "At least one test should pass") + } + + private enum TestError: Error { + case missingAPIKey + case noModelAvailable + } +} diff --git a/Tests/_Gemini/Intramodular/_GeminiTests+FunctionCalling.swift b/Tests/_Gemini/Intramodular/_GeminiTests+FunctionCalling.swift new file mode 100644 index 00000000..a5bd13f7 --- /dev/null +++ b/Tests/_Gemini/Intramodular/_GeminiTests+FunctionCalling.swift @@ -0,0 +1,107 @@ +// +// _GeminiFunctionTests.swift +// AI +// +// Created by Jared Davidson on 12/13/24. +// + + +import Testing +import Foundation +import _Gemini +import AI + +@Suite struct _GeminiFunctionTests { + @Test func testLightingSystem() async throws { + let messages = [ + _Gemini.Message( + role: .system, + content: "You are a helpful lighting system bot. You can turn lights on and off, and you can set the color. Do not perform any other tasks." + ), + _Gemini.Message( + role: .user, + content: "Turn on the lights and set them to red." + ) + ] + + let functions = [ + _Gemini.FunctionDefinition( + name: "enable_lights", + description: "Turn on the lighting system.", + parameters: _Gemini.ParameterSchema( + type: "object", + properties: [ + "dummy": _Gemini.ParameterSchema( + type: "string", + description: "Placeholder parameter" + ) + ] + ) + ), + _Gemini.FunctionDefinition( + name: "set_light_color", + description: "Set the light color. Lights must be enabled for this to work.", + parameters: _Gemini.ParameterSchema( + type: "object", + properties: [ + "rgb_hex": _Gemini.ParameterSchema( + type: "string", + description: "The light color as a 6-digit hex string, e.g. ff0000 for red." + ) + ], + required: ["rgb_hex"] + ) + ), + _Gemini.FunctionDefinition( + name: "stop_lights", + description: "Turn off the lighting system.", + parameters: _Gemini.ParameterSchema( + type: "object", + properties: [ + "dummy": _Gemini.ParameterSchema( + type: "string", + description: "Placeholder parameter" + ) + ] + ) + ) + ] + + let response = try await client.generateContentWithFunctions( + messages: messages, + functions: functions, + model: .gemini_1_5_pro_latest + ) + + for part in response.parts { + switch part { + case .text(_): + break + case .functionCall(let functionCall): + do { + let data = try functionCall.args.toJSONData() + if let jsonObject = try? JSONSerialization.jsonObject(with: data) { + let result = try JSONSerialization.data(withJSONObject: jsonObject) + .decode(LightingCommandParameters.self) + + if result.rgbHex != nil { + #expect(true) + } + } else { + print("Invalid JSON format") + } + } catch { + print("Error:", error) + } + case .executableCode(_, _): + break + case .codeExecutionResult(_, _): + break + } + } + + struct LightingCommandParameters: Codable { + let rgbHex: String? + } + } +} diff --git a/Tests/_Gemini/Intramodular/_GeminiTests+Grounding.swift b/Tests/_Gemini/Intramodular/_GeminiTests+Grounding.swift new file mode 100644 index 00000000..dbeb9024 --- /dev/null +++ b/Tests/_Gemini/Intramodular/_GeminiTests+Grounding.swift @@ -0,0 +1,60 @@ +// +// _GeminiTests+Grounding.swift +// AI +// +// Created by Jared Davidson on 12/13/24. +// + +import Testing +import Foundation +import _Gemini +import AI + +@Suite struct _GeminiGroundingTests { + @Test func testGroundingWithGoogleSearch() async throws { + let messages = [ + _Gemini.Message( + role: .user, + content: "What are the latest developments in quantum computing?" + ) + ] + + let response = try await client.generateContentWithGrounding( + messages: messages, + model: .gemini_1_5_pro_latest + ) + + print("Response:", response) + + // Basic response validation + #expect(!response.text.isEmpty, "Response should not be empty") + + // Check if grounding metadata exists + #expect(response.groundingMetadata != nil, "Grounding metadata should be present") + + if let metadata = response.groundingMetadata { + // Validate search entry point + #expect(metadata.searchEntryPoint?.renderedContent != nil, "Search entry point should be present") + + // Validate grounding chunks + #expect(!metadata.groundingChunks.isEmpty, "Grounding chunks should not be empty") + + // Validate grounding supports + #expect(!metadata.groundingSupports.isEmpty, "Grounding supports should not be empty") + + // Validate web search queries + #expect(!metadata.webSearchQueries.isEmpty, "Web search queries should not be empty") + } + + // Check token usage is present and valid + if let usage = response.tokenUsage { + #expect(usage.prompt > 0, "Prompt tokens should be greater than 0") + #expect(usage.response > 0, "Response tokens should be greater than 0") + #expect(usage.total > 0, "Total tokens should be greater than 0") + #expect(usage.total == usage.prompt + usage.response, "Total tokens should equal prompt + response") + } + + // Check finish reason + #expect(response.finishReason == .stop, "Response should have completed normally") + } +} diff --git a/Tests/_Gemini/Intramodular/_GeminiTests+StructuredOutput.swift b/Tests/_Gemini/Intramodular/_GeminiTests+StructuredOutput.swift new file mode 100644 index 00000000..a1b26c64 --- /dev/null +++ b/Tests/_Gemini/Intramodular/_GeminiTests+StructuredOutput.swift @@ -0,0 +1,92 @@ +// +// _GeminiTests+StructuredOutput.swift +// AI +// +// Created by Jared Davidson on 12/18/24. +// + +import Testing +import Foundation +import _Gemini +import AI + +@Suite struct _GeminiStructuredOutputTests { + @Test func testStructuredMovieReview() async throws { + let reviewSchema = _Gemini.SchemaObject.object(properties: [ + "title": .string, + "rating": .number, + "genres": .array(items: .string), + "review": .string + ]) + + let config = _Gemini.GenerationConfiguration( + temperature: 0.7, + responseMimeType: "application/json", + responseSchema: .object(properties: [ + "review": reviewSchema + ]) + ) + + let messages = [ + _Gemini.Message( + role: .user, + content: "Write a review for the movie 'Inception' with a rating from 1-10. Return it as a JSON object." + ) + ] + + let response = try await client.generateContent( + messages: messages, + model: .gemini_1_5_pro_latest, + configuration: configuration + ) + + dump(response) + + // Validate the response + #expect(!response.text.isEmpty, "Response should not be empty") + + // Attempt to parse the response as JSON + if let jsonData = response.text.data(using: String.Encoding.utf8) { + do { + let wrapper = try JSONDecoder().decode(MovieReviewWrapper.self, from: jsonData) + let review = wrapper.review + + // Validate the structured output + #expect(!review.title.isEmpty, "Movie title should not be empty") + #expect(review.rating >= 1 && review.rating <= 10, "Rating should be between 1 and 10") + #expect(!review.genres.isEmpty, "Genres array should not be empty") + #expect(!review.review.isEmpty, "Review text should not be empty") + + print("Parsed review:", review) + } catch { + print("JSON parsing error:", error) + print("Response text:", response.text) + #expect(false, "Failed to parse JSON response: \(error)") + } + } else { + #expect(false, "Failed to convert response to data") + } + + // Check token usage + if let usage = response.tokenUsage { + #expect(usage.prompt > 0, "Prompt tokens should be greater than 0") + #expect(usage.response > 0, "Response tokens should be greater than 0") + #expect(usage.total == usage.prompt + usage.response, "Total tokens should equal prompt + response") + } + + // Check finish reason + #expect(response.finishReason == .stop, "Response should have completed normally") + } +} + +// Response structures +private struct MovieReviewWrapper: Codable { + let review: MovieReview +} + +private struct MovieReview: Codable { + let title: String + let rating: Double + let genres: [String] + let review: String +} diff --git a/Tests/_Gemini/Intramodular/_GeminiTests.swift b/Tests/_Gemini/Intramodular/_GeminiTests.swift new file mode 100644 index 00000000..0d17fbc5 --- /dev/null +++ b/Tests/_Gemini/Intramodular/_GeminiTests.swift @@ -0,0 +1,115 @@ +// +// Untitled.swift +// AI +// +// Created by Jared Davidson on 12/11/24. +// + +import Testing +import SwiftUIX +import Foundation +import _Gemini + +@Suite struct GeminiTests { + @Test func testVideoContentGeneration() async throws { + do { + guard let url = URL(string: "https://file-examples.com/storage/fefaeec240676402c9bdb74/2017/04/file_example_MP4_640_3MG.mp4") else { + throw GeminiTestError.invalidURL("https://file-examples.com/storage/fefaeec240676402c9bdb74/2017/04/file_example_MP4_640_3MG.mp4") + } + + let messages = [_Gemini.Message(role: .user, content: "What is happening in this video?")] + + let content = try await client.generateContent( + messages: messages, + fileSource: .remoteURL(url), + mimeType: .custom("video/mp4"), + model: .gemini_1_5_flash + ) + + #expect(!content.text.isEmpty) + + 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)") + #expect(Bool(false), "Video content generation failed: \(error)") + } catch { + throw GeminiTestError.videoProcessingError(error) + } + } + + @Test func testAudioContentGeneration() async throws { + do { + guard let url = URL(string: "https://file-examples.com/storage/fefaeec240676402c9bdb74/2017/11/file_example_WAV_10MG.wav") else { + throw GeminiTestError.invalidURL("https://file-examples.com/storage/fefaeec240676402c9bdb74/2017/11/file_example_WAV_10MG.wav") + } + + let messages = [_Gemini.Message(role: .user, content: "What is being said in this audio?")] + + let content = try await client.generateContent( + messages: messages, + fileSource: .remoteURL(url), + mimeType: .wav, + model: .gemini_1_5_flash + ) + + print("Generated content: \(content)") + + #expect(!content.text.isEmpty) + } catch let error as GeminiTestError { + print("Detailed error: \(error.localizedDescription)") + #expect(Bool(false), "Audio content generation failed: \(error)") + } catch { + throw GeminiTestError.audioProcessingError(error) + } + } + + @Test func testImageContentGeneration() async throws { + do { + guard let url = URL(string: "https://file-examples.com/storage/fefaeec240676402c9bdb74/2017/10/file_example_PNG_2100kB.png") else { + throw GeminiTestError.invalidURL("https://file-examples.com/storage/fefaeec240676402c9bdb74/2017/10/file_example_PNG_2100kB.png") + } + + let messages = [_Gemini.Message(role: .user, content: "What is in this image?")] + + let content = try await client.generateContent( + messages: messages, + fileSource: .remoteURL(url), + mimeType: .custom("image/png"), + model: .gemini_1_5_flash + ) + + print("Generated content: \(content)") + + #expect(!content.text.isEmpty) + } catch let error as GeminiTestError { + print("Detailed error: \(error.localizedDescription)") + #expect(Bool(false), "Image content generation failed: \(error)") + } catch { + throw GeminiTestError.imageProcessingError(error) + } + } +} + +// Error Handling +fileprivate enum GeminiTestError: LocalizedError { + case invalidURL(String) + case videoProcessingError(Error) + case audioProcessingError(Error) + case imageProcessingError(Error) + + var errorDescription: String? { + switch self { + case .invalidURL(let url): + return "Invalid URL provided: \(url)" + case .videoProcessingError(let error): + 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)" + } + } +} diff --git a/Tests/_Gemini/module.swift b/Tests/_Gemini/module.swift new file mode 100644 index 00000000..204240bb --- /dev/null +++ b/Tests/_Gemini/module.swift @@ -0,0 +1,18 @@ +// +// module.swift +// AI +// +// Created by Jared Davidson on 12/11/24. +// + +import AI +@testable import _Gemini + +public var GEMINI_API_KEY: String { + // Add your API key here or load from environment + "" +} + +public var client: _Gemini.Client { + _Gemini.Client(apiKey: GEMINI_API_KEY) +}