From cfcb4e4475bca5aeb95cf8b9aec3aa2c023205a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 03:35:41 +0000 Subject: [PATCH 1/5] Initial plan From 2139a95c06e71f8e96484e65a5c9f660be3ac767 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 03:40:41 +0000 Subject: [PATCH 2/5] Add Gemini model support - core implementation Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com> --- Sources/Agent/chat/chat.swift | 3 + Sources/Agent/chat/geminiChat.swift | 213 ++++++++++++++++++++++++++ Sources/Agent/chat/geminiClient.swift | 188 +++++++++++++++++++++++ Sources/Agent/models.swift | 6 + 4 files changed, 410 insertions(+) create mode 100644 Sources/Agent/chat/geminiChat.swift create mode 100644 Sources/Agent/chat/geminiClient.swift diff --git a/Sources/Agent/chat/chat.swift b/Sources/Agent/chat/chat.swift index 77fe60a..b068a88 100644 --- a/Sources/Agent/chat/chat.swift +++ b/Sources/Agent/chat/chat.swift @@ -25,11 +25,14 @@ public enum ChatStatus { public enum Message: Identifiable, Hashable { case openai(OpenAIMessage) + case gemini(GeminiMessage) public var id: String { switch self { case .openai(let openAIMessage): return String(openAIMessage.hashValue) + case .gemini(let geminiMessage): + return String(geminiMessage.hashValue) } } } diff --git a/Sources/Agent/chat/geminiChat.swift b/Sources/Agent/chat/geminiChat.swift new file mode 100644 index 0000000..5c06b54 --- /dev/null +++ b/Sources/Agent/chat/geminiChat.swift @@ -0,0 +1,213 @@ +import Foundation +import JSONSchema + +public enum GeminiRole: String, Codable, Sendable { + case user + case model +} + +public enum GeminiContentType: String, Codable, Sendable { + case text + case inlineData = "inline_data" + case functionCall = "function_call" + case functionResponse = "function_response" +} + +public struct GeminiTextPart: Hashable, Codable, Sendable { + public var text: String + + public init(text: String) { + self.text = text + } +} + +public struct GeminiInlineData: Hashable, Codable, Sendable { + public let mimeType: String + public let data: String + + public init(mimeType: String, data: String) { + self.mimeType = mimeType + self.data = data + } +} + +public struct GeminiFunctionCall: Hashable, Codable, Sendable { + public let name: String + public let args: [String: String] + + public init(name: String, args: [String: String]) { + self.name = name + self.args = args + } +} + +public struct GeminiFunctionResponse: Hashable, Codable, Sendable { + public let name: String + public let response: [String: String] + + public init(name: String, response: [String: String]) { + self.name = name + self.response = response + } +} + +public enum GeminiPart: Hashable, Codable, Sendable { + case text(GeminiTextPart) + case inlineData(GeminiInlineData) + case functionCall(GeminiFunctionCall) + case functionResponse(GeminiFunctionResponse) + + private enum CodingKeys: String, CodingKey { + case text + case inlineData + case functionCall + case functionResponse + } + + 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(GeminiTextPart(text: text)) + } else if let inlineData = try? container.decode(GeminiInlineData.self, forKey: .inlineData) { + self = .inlineData(inlineData) + } else if let functionCall = try? container.decode(GeminiFunctionCall.self, forKey: .functionCall) { + self = .functionCall(functionCall) + } else if let functionResponse = try? container.decode(GeminiFunctionResponse.self, forKey: .functionResponse) { + self = .functionResponse(functionResponse) + } else { + throw DecodingError.dataCorruptedError( + forKey: CodingKeys.text, + in: container, + debugDescription: "Unable to decode GeminiPart" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .text(let textPart): + try container.encode(textPart.text, forKey: .text) + case .inlineData(let inlineData): + try container.encode(inlineData, forKey: .inlineData) + case .functionCall(let functionCall): + try container.encode(functionCall, forKey: .functionCall) + case .functionResponse(let functionResponse): + try container.encode(functionResponse, forKey: .functionResponse) + } + } +} + +public struct GeminiContent: Hashable, Codable, Sendable { + public let role: GeminiRole? + public let parts: [GeminiPart] + + public init(role: GeminiRole?, parts: [GeminiPart]) { + self.role = role + self.parts = parts + } +} + +public struct GeminiUserMessage: Hashable, Codable, Sendable { + public var role: GeminiRole = .user + public var parts: [GeminiPart] + public var createdAt: Date + + public init(parts: [GeminiPart], createdAt: Date = Date()) { + self.parts = parts + self.createdAt = createdAt + } + + public init(text: String, createdAt: Date = Date()) { + self.parts = [.text(GeminiTextPart(text: text))] + self.createdAt = createdAt + } +} + +public struct GeminiModelMessage: Hashable, Codable, Sendable { + private enum CodingKeys: String, CodingKey { + case role + case parts + } + + public var role: GeminiRole = .model + public var parts: [GeminiPart] + + public init(parts: [GeminiPart]) { + self.parts = parts + } + + public init(text: String) { + self.parts = [.text(GeminiTextPart(text: text))] + } +} + +public enum GeminiMessage: Hashable, Codable, Sendable { + case user(GeminiUserMessage) + case model(GeminiModelMessage) + + private enum CodingKeys: String, CodingKey { + case role + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let role = try container.decode(GeminiRole.self, forKey: .role) + switch role { + case .user: + let message = try GeminiUserMessage(from: decoder) + self = .user(message) + case .model: + let message = try GeminiModelMessage(from: decoder) + self = .model(message) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .user(let message): + try message.encode(to: encoder) + case .model(let message): + try message.encode(to: encoder) + } + } + + public var role: GeminiRole { + switch self { + case .user: + return .user + case .model: + return .model + } + } + + public var parts: [GeminiPart] { + switch self { + case .user(let message): + return message.parts + case .model(let message): + return message.parts + } + } +} + +public struct GeminiTool: Codable, Sendable { + public struct FunctionDeclaration: Codable, Sendable { + public let name: String + public let description: String + public let parameters: JSONSchema? + + public init(name: String, description: String, parameters: JSONSchema?) { + self.name = name + self.description = description + self.parameters = parameters + } + } + + public let functionDeclarations: [FunctionDeclaration] + + public init(functionDeclarations: [FunctionDeclaration]) { + self.functionDeclarations = functionDeclarations + } +} diff --git a/Sources/Agent/chat/geminiClient.swift b/Sources/Agent/chat/geminiClient.swift new file mode 100644 index 0000000..a39e3bb --- /dev/null +++ b/Sources/Agent/chat/geminiClient.swift @@ -0,0 +1,188 @@ +// +// geminiClient.swift +// AgentKit +// +// Created by Copilot +// + +import Foundation + +enum GeminiError: LocalizedError { + case invalidURL + case requestFailed(Error) + case invalidResponse(url: URL, textResponse: String) + case decodingError + case missingAPIKey + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid URL." + case .requestFailed(let error): + return "Request failed: \(error.localizedDescription)" + case .invalidResponse(let url, let textResponse): + return "Invalid response from server.\n URL: \(url)\n Response: \(textResponse)" + case .decodingError: + return "Failed to decode response." + case .missingAPIKey: + return "API key is required for Gemini API." + } + } +} + +struct GeminiRequest: Codable { + let contents: [GeminiContent] + let tools: [GeminiTool]? + let systemInstruction: GeminiContent? + + enum CodingKeys: String, CodingKey { + case contents + case tools + case systemInstruction + } + + init(contents: [GeminiContent], tools: [GeminiTool]? = nil, systemInstruction: GeminiContent? = nil) { + self.contents = contents + self.tools = tools + self.systemInstruction = systemInstruction + } +} + +struct GeminiResponse: Codable { + struct Candidate: Codable { + let content: GeminiContent + let finishReason: String? + + enum CodingKeys: String, CodingKey { + case content + case finishReason + } + } + + let candidates: [Candidate] +} + +actor GeminiClient { + private let apiKey: String + private let baseURL: URL + + init(baseURL: URL, apiKey: String) { + self.apiKey = apiKey + self.baseURL = baseURL + } + + func makeRequest( + model: String, + body: GeminiRequest + ) async throws -> URLSession.AsyncBytes { + // Gemini API endpoint format: {baseURL}/v1beta/models/{model}:streamGenerateContent?key={apiKey} + let endpoint = baseURL + .appendingPathComponent("v1beta/models/\(model):streamGenerateContent") + + guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: true) else { + throw GeminiError.invalidURL + } + + components.queryItems = [URLQueryItem(name: "key", value: apiKey)] + + guard let url = components.url else { + throw GeminiError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + request.httpBody = try JSONEncoder().encode(body) + + let (responseStream, response) = try await URLSession.shared.bytes(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) + else { + let textResponse = response.description + throw GeminiError.invalidResponse(url: url, textResponse: textResponse) + } + + return responseStream + } + + func generateStreamResponse( + systemText: String?, + message: GeminiUserMessage, + model: OpenAICompatibleModel, + tools: [GeminiTool] = [], + history: [GeminiMessage] = [] + ) + -> (stream: AsyncThrowingStream, cancellable: Cancellable) + { + let task = Task {} + let stream = AsyncThrowingStream { continuation in + Task { + do { + var contents: [GeminiContent] = [] + + // Add history (excluding system messages as they go in systemInstruction) + for msg in history { + contents.append(GeminiContent(role: msg.role, parts: msg.parts)) + } + + // Add current user message + contents.append(GeminiContent(role: .user, parts: message.parts)) + + // Create system instruction if provided + let systemInstruction: GeminiContent? = systemText.map { + GeminiContent(role: nil, parts: [.text(GeminiTextPart(text: $0))]) + } + + let requestBody = GeminiRequest( + contents: contents, + tools: tools.isEmpty ? nil : tools, + systemInstruction: systemInstruction + ) + + let responseStream = try await makeRequest(model: model.id, body: requestBody) + var totalText = "" + var totalParts: [GeminiPart] = [] + + for try await line in responseStream.lines { + if task.isCancelled { + continuation.finish() + break + } + + // Skip empty lines + guard !line.isEmpty else { continue } + + // Gemini streaming response comes as JSON objects separated by newlines + if let data = line.data(using: .utf8), + let response = try? JSONDecoder().decode(GeminiResponse.self, from: data), + let candidate = response.candidates.first + { + let content = candidate.content + + // Accumulate text parts + for part in content.parts { + if case .text(let textPart) = part { + totalText += textPart.text + totalParts.append(.text(GeminiTextPart(text: totalText))) + } else { + totalParts.append(part) + } + } + + // Yield the accumulated message + let message = GeminiModelMessage(parts: totalParts) + continuation.yield(.model(message)) + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + + return (stream, Cancellable { task.cancel() }) + } +} diff --git a/Sources/Agent/models.swift b/Sources/Agent/models.swift index 2d2e68c..5e1e51d 100644 --- a/Sources/Agent/models.swift +++ b/Sources/Agent/models.swift @@ -4,6 +4,8 @@ import Foundation public enum ApiType: String { /// Any openai compatible api should use this type case openAI = "openai" + /// Google Gemini API + case gemini = "gemini" } /// Represents the architecture of an AI model, including its input/output modalities and tokenizer. @@ -89,6 +91,7 @@ public struct CustomModel: Identifiable, Hashable { public enum Provider: Identifiable, Hashable { case openAI case openRouter + case gemini case custom(String) public var id: String { @@ -103,6 +106,8 @@ public enum Provider: Identifiable, Hashable { return "openai" case .openRouter: return "openrouter" + case .gemini: + return "gemini" } } @@ -118,6 +123,7 @@ public enum Provider: Identifiable, Hashable { [ "openai", "openrouter", + "gemini", "custom", ] } From cc77dd79768e70b09f3467fd6932e792e2280aa2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 03:42:32 +0000 Subject: [PATCH 3/5] Add comprehensive tests for Gemini implementation Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com> --- .../AgentTests/ParseGeminiMessageTests.swift | 180 +++++++++++++++ .../AgentTests/gemini/GeminiClientTests.swift | 213 ++++++++++++++++++ .../gemini/geminiChatController.swift | 64 ++++++ 3 files changed, 457 insertions(+) create mode 100644 Tests/AgentTests/ParseGeminiMessageTests.swift create mode 100644 Tests/AgentTests/gemini/GeminiClientTests.swift create mode 100644 Tests/AgentTests/gemini/geminiChatController.swift diff --git a/Tests/AgentTests/ParseGeminiMessageTests.swift b/Tests/AgentTests/ParseGeminiMessageTests.swift new file mode 100644 index 0000000..b080b0c --- /dev/null +++ b/Tests/AgentTests/ParseGeminiMessageTests.swift @@ -0,0 +1,180 @@ +import XCTest + +@testable import Agent + +class ParseGeminiMessageTests: XCTestCase { + func testParseGeminiModelMessage() { + let data = """ + { + "role": "model", + "parts": [ + { + "text": "Hello! How can I help you today?" + } + ] + } + """ + + let message = try! JSONDecoder().decode(GeminiMessage.self, from: data.data(using: .utf8)!) + if case .model(let modelMessage) = message { + XCTAssertEqual(modelMessage.parts.count, 1) + if case .text(let textPart) = modelMessage.parts[0] { + XCTAssertEqual(textPart.text, "Hello! How can I help you today?") + } else { + XCTFail("Part is not a text part") + } + } else { + XCTFail("Message is not a model message") + } + } + + func testParseGeminiUserMessage() { + let data = """ + { + "role": "user", + "parts": [ + { + "text": "What is the weather?" + } + ] + } + """ + + let message = try! JSONDecoder().decode(GeminiMessage.self, from: data.data(using: .utf8)!) + if case .user(let userMessage) = message { + XCTAssertEqual(userMessage.parts.count, 1) + if case .text(let textPart) = userMessage.parts[0] { + XCTAssertEqual(textPart.text, "What is the weather?") + } else { + XCTFail("Part is not a text part") + } + } else { + XCTFail("Message is not a user message") + } + } + + func testParseGeminiFunctionCallMessage() { + let data = """ + { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "getWeather", + "args": { + "city": "San Francisco" + } + } + } + ] + } + """ + + let message = try! JSONDecoder().decode(GeminiMessage.self, from: data.data(using: .utf8)!) + if case .model(let modelMessage) = message { + XCTAssertEqual(modelMessage.parts.count, 1) + if case .functionCall(let functionCall) = modelMessage.parts[0] { + XCTAssertEqual(functionCall.name, "getWeather") + XCTAssertEqual(functionCall.args["city"], "San Francisco") + } else { + XCTFail("Part is not a function call part") + } + } else { + XCTFail("Message is not a model message") + } + } + + func testParseGeminiInlineDataMessage() { + let data = """ + { + "role": "user", + "parts": [ + { + "inlineData": { + "mimeType": "image/jpeg", + "data": "base64encodeddata" + } + } + ] + } + """ + + let message = try! JSONDecoder().decode(GeminiMessage.self, from: data.data(using: .utf8)!) + if case .user(let userMessage) = message { + XCTAssertEqual(userMessage.parts.count, 1) + if case .inlineData(let inlineData) = userMessage.parts[0] { + XCTAssertEqual(inlineData.mimeType, "image/jpeg") + XCTAssertEqual(inlineData.data, "base64encodeddata") + } else { + XCTFail("Part is not an inline data part") + } + } else { + XCTFail("Message is not a user message") + } + } + + func testSerializeGeminiMessages() { + let userMessage = GeminiUserMessage(text: "Hello") + let modelMessage = GeminiModelMessage(text: "Hi there!") + + let messages: [GeminiMessage] = [ + .user(userMessage), + .model(modelMessage) + ] + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = try! encoder.encode(messages) + let jsonString = String(data: data, encoding: .utf8) + XCTAssertNotNil(jsonString) + } + + func testSerializeGeminiRequest() { + let contents = [ + GeminiContent( + role: .user, + parts: [.text(GeminiTextPart(text: "Hello"))] + ) + ] + + let systemInstruction = GeminiContent( + role: nil, + parts: [.text(GeminiTextPart(text: "You are a helpful assistant."))] + ) + + let tools = [ + GeminiTool(functionDeclarations: [ + GeminiTool.FunctionDeclaration( + name: "getWeather", + description: "Get weather by city", + parameters: .object( + title: "weather", + properties: [ + "city": .string(description: "name of the city") + ], + required: ["city"] + ) + ) + ]) + ] + + let request = GeminiRequest( + contents: contents, + tools: tools, + systemInstruction: systemInstruction + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = try! encoder.encode(request) + let jsonString = String(data: data, encoding: .utf8) + XCTAssertNotNil(jsonString) + + // Verify we can decode it back + let decoder = JSONDecoder() + let decodedRequest = try! decoder.decode(GeminiRequest.self, from: data) + XCTAssertEqual(decodedRequest.contents.count, 1) + XCTAssertNotNil(decodedRequest.systemInstruction) + XCTAssertEqual(decodedRequest.tools?.count, 1) + } +} diff --git a/Tests/AgentTests/gemini/GeminiClientTests.swift b/Tests/AgentTests/gemini/GeminiClientTests.swift new file mode 100644 index 0000000..68444e3 --- /dev/null +++ b/Tests/AgentTests/gemini/GeminiClientTests.swift @@ -0,0 +1,213 @@ +// +// GeminiClientTests.swift +// AgentKit +// +// Created by Copilot +// + +import Foundation +import Vapor +import XCTest + +@testable import Agent + +final class GeminiClientTests: XCTestCase { + var app: Application! + var controller: GeminiChatController! + var client: GeminiClient! + + override func setUp() async throws { + // Set up Vapor application for testing + app = try await Application.make(.testing) + + // Configure the mock server + controller = await GeminiChatController() + await controller.registerRoutes(on: app) + + // Find a free port for testing + let port = 8124 // Different port from OpenAI tests + app.http.server.configuration.port = port + + // Start the server + try await app.startup() + + // Initialize the client with the testing server URL + let baseURL = URL(string: "http://localhost:\(port)")! + client = GeminiClient(baseURL: baseURL, apiKey: "test-api-key") + } + + override func tearDown() async throws { + // Shut down the server + try await app.asyncShutdown() + app = nil + controller = nil + client = nil + } + + @MainActor + func testStreamingResponseWithTextContent() async throws { + // Set up the mock response + let mockResponse = GeminiModelMessage( + parts: [ + .text(GeminiTextPart(text: "This is a test response from the mock Gemini server.")) + ] + ) + controller.mockChatResponse([mockResponse]) + + // Create a user message for testing + let userMessage = GeminiUserMessage(text: "Hello") + + // Use a test model + let testModel = OpenAICompatibleModel(id: "gemini-pro") + + // Collect all responses + var receivedText = "" + var responseCount = 0 + + // Call the client with streaming response + let stream = await client.generateStreamResponse( + systemText: "You are a helpful assistant.", + message: userMessage, + model: testModel, + tools: [ + GeminiTool(functionDeclarations: [ + GeminiTool.FunctionDeclaration( + name: "getWeather", + description: "Get weather by city", + parameters: .object( + title: "weather", + properties: [ + "city": .string(description: "name of the city") + ], + required: ["city"] + ) + ) + ]) + ] + ) + + // Process the stream + for try await message in stream.stream { + if case .model(let modelMessage) = message { + responseCount += 1 + + for part in modelMessage.parts { + if case .text(let textPart) = part { + receivedText = textPart.text + } + } + } + } + + // Assert the expected results + XCTAssertGreaterThan(responseCount, 0, "Should receive at least one streaming response") + XCTAssertEqual(receivedText, "This is a test response from the mock Gemini server.") + } + + @MainActor + func testStreamingResponseCancellation() async throws { + // Set up the mock response with multiple chunks + let mockResponses = [ + GeminiModelMessage(parts: [.text(GeminiTextPart(text: "This "))]), + GeminiModelMessage(parts: [.text(GeminiTextPart(text: "is "))]), + GeminiModelMessage(parts: [.text(GeminiTextPart(text: "a "))]), + GeminiModelMessage(parts: [.text(GeminiTextPart(text: "test."))]), + ] + controller.mockChatResponse(mockResponses) + + // Create a user message for testing + let userMessage = GeminiUserMessage(text: "Hello") + + // Use a test model + let testModel = OpenAICompatibleModel(id: "gemini-pro") + + // Call the client with streaming response + let (stream, cancellable) = await client.generateStreamResponse( + systemText: "You are a helpful assistant.", + message: userMessage, + model: testModel + ) + + // Cancel immediately + await cancellable.cancel() + + // Capture received messages + var receivedMessages = 0 + + do { + for try await _ in stream { + receivedMessages += 1 + } + } catch { + XCTFail("Stream should not throw an error when cancelled: \(error)") + } + + // Assert that we received no messages or very few after cancellation + XCTAssertLessThanOrEqual(receivedMessages, 1, "Should receive very few or no messages after cancellation") + } + + @MainActor + func testStreamingResponseWithFunctionCall() async throws { + // Set up the mock response with a function call + let mockResponse = GeminiModelMessage( + parts: [ + .functionCall(GeminiFunctionCall( + name: "getWeather", + args: ["city": "San Francisco"] + )) + ] + ) + controller.mockChatResponse([mockResponse]) + + // Create a user message for testing + let userMessage = GeminiUserMessage(text: "What's the weather in San Francisco?") + + // Use a test model + let testModel = OpenAICompatibleModel(id: "gemini-pro") + + // Collect all responses + var receivedFunctionCalls: [GeminiFunctionCall] = [] + var responseCount = 0 + + // Call the client with streaming response + let stream = await client.generateStreamResponse( + systemText: "You are a helpful assistant.", + message: userMessage, + model: testModel, + tools: [ + GeminiTool(functionDeclarations: [ + GeminiTool.FunctionDeclaration( + name: "getWeather", + description: "Get weather by city", + parameters: .object( + title: "weather", + properties: [ + "city": .string(description: "name of the city") + ], + required: ["city"] + ) + ) + ]) + ] + ) + + // Process the stream + for try await message in stream.stream { + if case .model(let modelMessage) = message { + responseCount += 1 + + for part in modelMessage.parts { + if case .functionCall(let functionCall) = part { + receivedFunctionCalls.append(functionCall) + } + } + } + } + + // Assert the expected results + XCTAssertGreaterThan(responseCount, 0, "Should receive at least one streaming response") + XCTAssertEqual(receivedFunctionCalls.count, 1, "Should receive one function call") + XCTAssertEqual(receivedFunctionCalls.first?.name, "getWeather") + XCTAssertEqual(receivedFunctionCalls.first?.args["city"], "San Francisco") + } +} diff --git a/Tests/AgentTests/gemini/geminiChatController.swift b/Tests/AgentTests/gemini/geminiChatController.swift new file mode 100644 index 0000000..8997f3c --- /dev/null +++ b/Tests/AgentTests/gemini/geminiChatController.swift @@ -0,0 +1,64 @@ +// +// geminiChatController.swift +// AgentKit +// +// Created by Copilot +// + +import Foundation +import Vapor + +@testable import Agent + +/// A controller that mocks Google Gemini API responses +@MainActor +class GeminiChatController { + private var mockResponses: [GeminiModelMessage] + + init() { + self.mockResponses = [] + } + + /// Set mock responses to be returned in the streaming API + /// - Parameter responses: List of model messages to be returned as chunks + func mockChatResponse(_ responses: [GeminiModelMessage]) { + mockResponses = responses + } + + /// Register routes for this controller on a Vapor router + /// - Parameter routes: The router to register routes on + func registerRoutes(on routes: RoutesBuilder) { + // Register the endpoint that matches Gemini's API + // Pattern: /v1beta/models/{model}:streamGenerateContent + let v1Routes = routes.grouped("v1beta") + let modelsRoutes = v1Routes.grouped("models") + modelsRoutes.post(":model", "streamGenerateContent", use: handleStreamGeneration) + } + + private func handleStreamGeneration(request: Request) async throws -> Response { + let body = Response.Body(stream: { writer in + Task { + let mockResponses = await self.mockResponses + for response in mockResponses { + let geminiResponse = GeminiResponse( + candidates: [ + GeminiResponse.Candidate( + content: GeminiContent(role: .model, parts: response.parts), + finishReason: nil + ) + ] + ) + let jsonData = try JSONEncoder().encode(geminiResponse) + let jsonString = String(data: jsonData, encoding: .utf8) ?? "" + _ = writer.write(.buffer(ByteBuffer(string: "\(jsonString)\n"))) + } + + _ = writer.write(.end) + } + }) + + let response = Response(status: .ok, body: body) + response.headers.replaceOrAdd(name: .contentType, value: "application/json") + return response + } +} From 1d257dff5c89b72cd274a8427262aca6b21e7173 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 03:42:57 +0000 Subject: [PATCH 4/5] Update README to document Gemini support Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com> --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index bd67b07..f7e4e5f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ A Swift implementation of LLM agents with multi-agent support. - Multiple agent support - SwiftUI chat layout implementation - Multiple LLM model integration + - OpenAI and OpenAI-compatible APIs + - Google Gemini API - Clean, intuitive API design ## Installation @@ -21,6 +23,14 @@ dependencies: [ ] ``` +## Supported Models + +### OpenAI and Compatible APIs +AgentKit supports OpenAI and any OpenAI-compatible API (such as OpenRouter). Use `ApiType.openAI` for these providers. + +### Google Gemini +AgentKit supports Google Gemini models through the official Gemini API (https://ai.google.dev/api). Use `ApiType.gemini` for Gemini models. + ## License AgentKit is available under the MIT license. See the LICENSE file for more info. From ae4422dce1c45fd792019877ffb80f4b142fdfa1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 03:43:49 +0000 Subject: [PATCH 5/5] Fix GeminiClient streaming accumulation logic Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com> --- Sources/Agent/chat/geminiClient.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/Agent/chat/geminiClient.swift b/Sources/Agent/chat/geminiClient.swift index a39e3bb..d6a84dd 100644 --- a/Sources/Agent/chat/geminiClient.swift +++ b/Sources/Agent/chat/geminiClient.swift @@ -161,18 +161,24 @@ actor GeminiClient { { let content = candidate.content - // Accumulate text parts + // Build the complete parts list + var newParts: [GeminiPart] = [] for part in content.parts { if case .text(let textPart) = part { totalText += textPart.text - totalParts.append(.text(GeminiTextPart(text: totalText))) } else { totalParts.append(part) } } + // Create the parts list with accumulated text and other parts + if !totalText.isEmpty { + newParts.append(.text(GeminiTextPart(text: totalText))) + } + newParts.append(contentsOf: totalParts) + // Yield the accumulated message - let message = GeminiModelMessage(parts: totalParts) + let message = GeminiModelMessage(parts: newParts) continuation.yield(.model(message)) } }