diff --git a/README.md b/README.md index 3a7ea41..dacb91b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Generative AI library for .NET 9.0 with built-in OpenAI ChatGPT and Google Gemin - [ ] Text Embedding - [ ] Moderation - [ ] Response Streaming -- [ ] Multi-Modal Requests +- [x] Multi-Modal Requests ### Miscellaneous - [x] Dependency Injection - [x] Time Awareness @@ -174,6 +174,130 @@ byte[] audio = await File.ReadAllBytesAsync("speech.mp3"); string translation = await client.TranslateAsync(audio); ``` +## Multi-Modal Requests (Gemini) + +To send multi-modal requests with the Gemini client (e.g., text combined with uploaded files like PDFs, images, videos), you first need to upload the file using the `FileService` (exposed via `GeminiClient.Files`) and then reference it in your chat message. + +### 1. Accessing the File Service + +The `IFileService` is accessible via the `Files` property of your `GeminiClient` instance. + +**If using `GeminiClient` as a single instance:** + +```cs +using ChatAIze.GenerativeCS.Clients; +using ChatAIze.GenerativeCS.Models.Gemini; // For GeminiFile +using ChatAIze.GenerativeCS.Providers.Gemini; // For IFileService +using System.IO; + +var geminiClient = new GeminiClient(""); +IFileService fileService = geminiClient.Files; + +// Example usage: +// string filePath = "path/to/your/file.pdf"; +// string mimeType = "application/pdf"; // Adjust mime type accordingly +// GeminiFile? uploadedFile = await fileService.UploadFileAsync(filePath, mimeType, Path.GetFileName(filePath)); + +// if (uploadedFile != null) +// { +// Console.WriteLine($"File uploaded: {uploadedFile.Name}, URI: {uploadedFile.Uri}"); +// // Now use uploadedFile.Uri in a ChatMessage +// } +``` + +**If using Dependency Injection:** + +You register `GeminiClient` (which includes `IFileService` registration) during setup. You can then inject `GeminiClient` and access its `Files` property, or inject `IFileService` directly if you only need the file operations. + +```cs +// In your Startup.cs or Program.cs (service registration shown in previous DI examples) +// builder.Services.AddGeminiClient(""); + +// In your class, Option 1: Inject GeminiClient +// private readonly GeminiClient _geminiClient; +// private readonly IFileService _fileService; // Derived from GeminiClient +// public YourService(GeminiClient geminiClient) +// { +// _geminiClient = geminiClient; +// _fileService = geminiClient.Files; +// } + +// In your class, Option 2: Inject IFileService directly (if preferred for just file ops) +// private readonly IFileService _fileService; +// public YourService(IFileService fileService) // Assumes IFileService is registered as shown previously +// { +// _fileService = fileService; +// } + +// async Task ProcessFile() +// { +// string filePath = "path/to/your/file.pdf"; +// string mimeType = "application/pdf"; +// GeminiFile? uploadedFile = await _fileService.UploadFileAsync(filePath, mimeType, Path.GetFileName(filePath)); +// // ... +// } +``` + +### 2. Uploading a File + +Once you have an `IFileService` instance (e.g., from `geminiClient.Files`): + +```cs +using ChatAIze.GenerativeCS.Clients; // For GeminiClient +using ChatAIze.GenerativeCS.Models.Gemini; // For GeminiFile +using ChatAIze.GenerativeCS.Providers.Gemini; // For IFileService +using System.IO; + +// Assuming 'geminiClient' is an initialized GeminiClient instance +IFileService fileService = geminiClient.Files; + +string filePath = "path/to/your/document.pdf"; +string mimeType = "application/pdf"; // Change for other types e.g. "image/png", "video/mp4" +string displayName = Path.GetFileName(filePath); + +GeminiFile? uploadedFile = await fileService.UploadFileAsync(filePath, mimeType, displayName); + +if (uploadedFile != null) +{ + Console.WriteLine($"File uploaded. Name: {uploadedFile.Name}, URI: {uploadedFile.Uri}"); + // Store uploadedFile.Uri to use in a chat message +} +else +{ + Console.WriteLine("File upload failed."); +} +``` + +### 3. Sending a Chat Message with the File + +After uploading the file, you use its `Uri` (which typically starts with `files/your-file-id`) and `MimeType` in a `ChatMessage` by adding a `FileDataPart`. + +```cs +using ChatAIze.GenerativeCS.Clients; +using ChatAIze.GenerativeCS.Models; +using ChatAIze.Abstractions.Chat; // For ChatRole + +// Assuming 'geminiClient' is an initialized GeminiClient +// Assuming 'uploadedFile' is the GeminiFile object from the successful upload + +if (uploadedFile != null && uploadedFile.Uri != null && uploadedFile.MimeType != null) +{ + var chat = new Chat(); + var userMessage = new ChatMessage(); + userMessage.Role = ChatRole.User; + userMessage.Parts.Add(new TextPart("Please summarize this document.")); + userMessage.Parts.Add(new FileDataPart(new FileDataSource(uploadedFile.MimeType, uploadedFile.Uri))); + + chat.Messages.Add(userMessage); + + // Using the existing CompleteAsync method which now supports parts + string response = await geminiClient.CompleteAsync(chat); + Console.WriteLine(response); +} +``` + +Supported file types and their MIME types for Gemini include a wide range (PDF, common document formats, images, audio, video). Refer to the official Google Gemini API documentation for the most up-to-date list of supported MIME types. + ## Moderation ```cs using ChatAIze.GenerativeCS.Clients; diff --git a/src/ChatAIze.GenerativeCS.csproj b/src/ChatAIze.GenerativeCS.csproj index 42f135b..7f3be15 100644 --- a/src/ChatAIze.GenerativeCS.csproj +++ b/src/ChatAIze.GenerativeCS.csproj @@ -2,7 +2,7 @@ Generative CS Generative CS - 0.14.2 + 0.15.0 ChatAIze Marcel Kwiatkowski © ChatAIze 2025 @@ -25,9 +25,12 @@ true Generative AI library for .NET 9.0 with built-in OpenAI ChatGPT and Google Gemini API clients - and support for C# function calling via reflection. + and support for C# function calling via reflection. Now includes multimodal support for Gemini, + allowing text, PDF, DOC, video, audio, and image file processing. Features: - Chat Completion + - Gemini Multimodal Requests (text with files) + - Gemini File Management (upload, get, list, delete) - Response Streaming - Text Embedding - Text-to-Speech @@ -49,21 +52,21 @@ chatgpt-api client co co-pilot complete completion completion-generator completion-provider completions completions-generator completions-provider conversation conversational conversational-ai copilot cs csharp davinci dialog dotnet dotnet-core embedding - embedding-model embeddings embeddings-model function function-calling functional + embedding-model embeddings embeddings-model file-data file-upload file-uri function function-calling functional functional-gpt functions functions-calling gemini gemini-api gemini-api-client gemini-client gemini-pro gemini-pro-api gemini-pro-api-client gemini-pro-client generation generative-ai generative-cs generator google google-bard google-gemini google-gemini-pro google-gemini-pro-api google-gemini-pro-api-client google-gemini-pro-client gpt gpt-3 gpt-4 - gpt-function gpt-functions gpt3 gpt4 kernel language language-model learning library llama llm + gpt-function gpt-functions gpt3 gpt4 image kernel language language-model learning library llama llm machine machine-learning method method-calling methods methods-calling microsot ml model - moderation natural natural-language-processing nlp open-ai open-ai-api open-ai-client openai - openai-api openai-api-client openai-client pilot pro processing prompt provider reflection + moderation multi-modal multimodal natural natural-language-processing nlp open-ai open-ai-api open-ai-client openai + openai-api openai-api-client openai-client pdf pilot pro processing prompt provider reflection respond response response-completion response-generation rest rest-api restful restful-api robot sdk search semantic sound speech speech-to-text stream streaming synthesis text text-completion text-embedding text-embeddings text-generation text-synthesis text-to-speech token transcript transcription transformer transformer-model transformers transformers-model - translation translator tts turbo vector vector-embedding vector-embeddings vector-search - vertex virtual virtual-assistant voice whisper whisper-api wrapper + translation translator tts txt turbo vector vector-embedding vector-embeddings vector-search + vertex video virtual virtual-assistant voice whisper whisper-api wrapper diff --git a/src/Clients/GeminiClient.cs b/src/Clients/GeminiClient.cs index 498b0a6..e6ef492 100644 --- a/src/Clients/GeminiClient.cs +++ b/src/Clients/GeminiClient.cs @@ -15,6 +15,8 @@ public class GeminiClient where TFunctionResult : IFunctionResult, new() { private readonly HttpClient _httpClient = new(); + public string? ApiKey { get; set; } + public IFileService Files { get; } public GeminiClient() { @@ -22,6 +24,8 @@ public GeminiClient() { ApiKey = EnvironmentVariableManager.GetGeminiAPIKey(); } + var geminiOptions = new GeminiOptions { ApiKey = this.ApiKey }; + Files = new FileService(_httpClient, Microsoft.Extensions.Options.Options.Create(geminiOptions)); } public GeminiClient(string apiKey) @@ -32,6 +36,8 @@ public GeminiClient(string apiKey) { ApiKey = EnvironmentVariableManager.GetGeminiAPIKey(); } + var geminiOptions = new GeminiOptions { ApiKey = this.ApiKey }; + Files = new FileService(_httpClient, Microsoft.Extensions.Options.Options.Create(geminiOptions)); } public GeminiClient(GeminiClientOptions options) @@ -44,29 +50,41 @@ public GeminiClient(GeminiClientOptions> options) + public GeminiClient(HttpClient httpClient, IOptions> clientOptions) { _httpClient = httpClient; - ApiKey = options.Value.ApiKey; + ApiKey = clientOptions.Value.ApiKey; if (string.IsNullOrWhiteSpace(ApiKey)) { ApiKey = EnvironmentVariableManager.GetGeminiAPIKey(); } - DefaultCompletionOptions = options.Value.DefaultCompletionOptions; + DefaultCompletionOptions = clientOptions.Value.DefaultCompletionOptions; + + var fileServiceOptions = new GeminiOptions + { + ApiKey = clientOptions.Value.ApiKey + }; + Files = new FileService(_httpClient, Microsoft.Extensions.Options.Options.Create(fileServiceOptions)); } public GeminiClient(ChatCompletionOptions defaultCompletionOptions) { DefaultCompletionOptions = defaultCompletionOptions; + if (string.IsNullOrWhiteSpace(ApiKey)) + { + ApiKey = EnvironmentVariableManager.GetGeminiAPIKey(); + } + var geminiOptions = new GeminiOptions { ApiKey = this.ApiKey }; + Files = new FileService(_httpClient, Microsoft.Extensions.Options.Options.Create(geminiOptions)); } - public string? ApiKey { get; set; } - public ChatCompletionOptions DefaultCompletionOptions { get; set; } = new(); public async Task CompleteAsync(string prompt, ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) diff --git a/src/Extensions/GeminiClientExtension.cs b/src/Extensions/GeminiClientExtension.cs index c172b04..00d5c2b 100644 --- a/src/Extensions/GeminiClientExtension.cs +++ b/src/Extensions/GeminiClientExtension.cs @@ -2,6 +2,7 @@ using ChatAIze.GenerativeCS.Clients; using ChatAIze.GenerativeCS.Models; using ChatAIze.GenerativeCS.Options.Gemini; +using ChatAIze.GenerativeCS.Providers.Gemini; using Microsoft.Extensions.DependencyInjection; namespace ChatAIze.GenerativeCS.Extensions; @@ -29,6 +30,13 @@ public static IServiceCollection AddGeminiClient>(); _ = services.AddSingleton(); + // Register IFileService to be resolved from the GeminiClient's Files property + _ = services.AddSingleton(sp => + { + var client = sp.GetRequiredService>(); + return client.Files; + }); + return services; } diff --git a/src/Models/ChatContentPart.cs b/src/Models/ChatContentPart.cs new file mode 100644 index 0000000..8a33509 --- /dev/null +++ b/src/Models/ChatContentPart.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace ChatAIze.GenerativeCS.Models +{ + /// + /// Represents a part of a chat message content, which can be text, file data, etc. + /// + public interface IChatContentPart { } + + public class TextPart : IChatContentPart + { + [JsonPropertyName("text")] + public string Text { get; set; } + + public TextPart(string text) + { + Text = text; + } + } + + public class FileDataPart : IChatContentPart + { + [JsonPropertyName("file_data")] + public FileDataSource FileData { get; set; } + + public FileDataPart(FileDataSource fileData) + { + FileData = fileData; + } + } + + public class FileDataSource + { + [JsonPropertyName("mime_type")] + public string MimeType { get; set; } + + [JsonPropertyName("file_uri")] + public string FileUri { get; set; } + + public FileDataSource(string mimeType, string fileUri) + { + MimeType = mimeType; + FileUri = fileUri; + } + } +} \ No newline at end of file diff --git a/src/Models/ChatMessage.cs b/src/Models/ChatMessage.cs index 39ac4ac..a417761 100644 --- a/src/Models/ChatMessage.cs +++ b/src/Models/ChatMessage.cs @@ -1,4 +1,7 @@ using ChatAIze.Abstractions.Chat; +using System.Collections.Generic; +using System.Linq; +using System; namespace ChatAIze.GenerativeCS.Models; @@ -6,23 +9,26 @@ public record ChatMessage : IChatMessage(); + } public ChatMessage(ChatRole role, string content, PinLocation pinLocation = PinLocation.None, params ICollection imageUrls) { Role = role; - Content = content; PinLocation = pinLocation; ImageUrls = imageUrls; + Parts = new List { new TextPart(content) }; } public ChatMessage(ChatRole role, string userName, string content, PinLocation pinLocation = PinLocation.None, params ICollection imageUrls) { Role = role; UserName = userName; - Content = content; PinLocation = pinLocation; ImageUrls = imageUrls; + Parts = new List { new TextPart(content) }; } public ChatMessage(TFunctionCall functionCall, PinLocation pinLocation = PinLocation.None) @@ -30,6 +36,7 @@ public ChatMessage(TFunctionCall functionCall, PinLocation pinLocation = PinLoca Role = ChatRole.Chatbot; FunctionCalls = [functionCall]; PinLocation = pinLocation; + Parts = new List(); } public ChatMessage(ICollection functionCalls, PinLocation pinLocation = PinLocation.None) @@ -37,6 +44,7 @@ public ChatMessage(ICollection functionCalls, PinLocation pinLoca Role = ChatRole.Chatbot; FunctionCalls = functionCalls; PinLocation = pinLocation; + Parts = new List(); } public ChatMessage(TFunctionResult functionResult, PinLocation pinLocation = PinLocation.None) @@ -44,13 +52,33 @@ public ChatMessage(TFunctionResult functionResult, PinLocation pinLocation = Pin Role = ChatRole.Function; FunctionResult = functionResult; PinLocation = pinLocation; + Parts = new List(); } public ChatRole Role { get; set; } public string? UserName { get; set; } - public string? Content { get; set; } + [Obsolete("Use Parts to support multimodal content. This property interacts with the first TextPart among the Parts collection.")] + public string? Content + { + get => Parts?.OfType().FirstOrDefault()?.Text; + set + { + Parts ??= new List(); + Parts.RemoveAll(p => p is TextPart); + if (value != null) + { + Parts.Insert(0, new TextPart(value)); + } + } + } + + /// + /// Represents the collection of content parts for the message (e.g., text, image, file data). + /// For Gemini, this will be used to construct the 'parts' array. + /// + public List Parts { get; set; } public ICollection FunctionCalls { get; set; } = []; @@ -64,22 +92,22 @@ public ChatMessage(TFunctionResult functionResult, PinLocation pinLocation = Pin public static IChatMessage FromSystem(string content, PinLocation pinLocation = PinLocation.None) { - return new ChatMessage(ChatRole.System, content, pinLocation); + return new ChatMessage { Role = ChatRole.System, Parts = new List { new TextPart(content) }, PinLocation = pinLocation }; } public static IChatMessage FromUser(string content, PinLocation pinLocation = PinLocation.None) { - return new ChatMessage(ChatRole.User, content, pinLocation); + return new ChatMessage { Role = ChatRole.User, Parts = new List { new TextPart(content) }, PinLocation = pinLocation }; } public static IChatMessage FromUser(string userName, string content, PinLocation pinLocation = PinLocation.None) { - return new ChatMessage(ChatRole.User, userName, content, pinLocation); + return new ChatMessage { Role = ChatRole.User, UserName = userName, Parts = new List { new TextPart(content) }, PinLocation = pinLocation }; } public static IChatMessage FromChatbot(string userName, PinLocation pinLocation = PinLocation.None) { - return new ChatMessage(ChatRole.Chatbot, userName, pinLocation); + return new ChatMessage { Role = ChatRole.Chatbot, UserName = userName, PinLocation = pinLocation, Parts = new List() }; } public static IChatMessage FromChatbot(TFunctionCall functionCall, PinLocation pinLocation = PinLocation.None) @@ -102,9 +130,22 @@ public record ChatMessage : ChatMessage { public ChatMessage() : base() { } - public ChatMessage(ChatRole role, string content, PinLocation pinLocation = PinLocation.None) : base(role, content, pinLocation) { } + public ChatMessage(ChatRole role, string content, PinLocation pinLocation = PinLocation.None) + : base() + { + Role = role; + PinLocation = pinLocation; + Parts = new List { new TextPart(content) }; + } - public ChatMessage(ChatRole role, string userName, string content, PinLocation pinLocation = PinLocation.None) : base(role, userName, content, pinLocation) { } + public ChatMessage(ChatRole role, string userName, string content, PinLocation pinLocation = PinLocation.None) + : base() + { + Role = role; + UserName = userName; + PinLocation = pinLocation; + Parts = new List { new TextPart(content) }; + } public ChatMessage(FunctionCall functionCall, PinLocation pinLocation = PinLocation.None) : base(functionCall, pinLocation) { } diff --git a/src/Models/Gemini/GeminiFile.cs b/src/Models/Gemini/GeminiFile.cs new file mode 100644 index 0000000..7d0e9f4 --- /dev/null +++ b/src/Models/Gemini/GeminiFile.cs @@ -0,0 +1,38 @@ +using System; +using System.Text.Json.Serialization; + +namespace ChatAIze.GenerativeCS.Models.Gemini +{ + public class GeminiFile + { + [JsonPropertyName("name")] + required public string Name { get; set; } + + [JsonPropertyName("uri")] + required public string Uri { get; set; } + + [JsonPropertyName("mime_type")] + required public string MimeType { get; set; } + + [JsonPropertyName("size_bytes")] + public long? SizeBytes { get; set; } + + [JsonPropertyName("create_time")] + public DateTime? CreateTime { get; set; } + + [JsonPropertyName("update_time")] + public DateTime? UpdateTime { get; set; } + + [JsonPropertyName("expiration_time")] + public DateTime? ExpirationTime { get; set; } + + [JsonPropertyName("sha256_hash")] + public string? Sha256Hash { get; set; } + + [JsonPropertyName("display_name")] + public string? DisplayName { get; set; } + + [JsonPropertyName("state")] + public string? State { get; set; } // e.g., "ACTIVE", "PROCESSING" + } +} \ No newline at end of file diff --git a/src/Models/Gemini/GeminiFileUploadRequest.cs b/src/Models/Gemini/GeminiFileUploadRequest.cs new file mode 100644 index 0000000..13ee79e --- /dev/null +++ b/src/Models/Gemini/GeminiFileUploadRequest.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace ChatAIze.GenerativeCS.Models.Gemini +{ + public class FileMetadata + { + [JsonPropertyName("display_name")] + required public string DisplayName { get; set; } + } + + public class GeminiFileUploadRequest + { + [JsonPropertyName("file")] + required public FileMetadata File { get; set; } + } +} \ No newline at end of file diff --git a/src/Models/Gemini/GeminiListFilesResponse.cs b/src/Models/Gemini/GeminiListFilesResponse.cs new file mode 100644 index 0000000..17273d6 --- /dev/null +++ b/src/Models/Gemini/GeminiListFilesResponse.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace ChatAIze.GenerativeCS.Models.Gemini +{ + public class GeminiListFilesResponse + { + [JsonPropertyName("files")] + public List Files { get; set; } = new(); + + [JsonPropertyName("nextPageToken")] + public string? NextPageToken { get; set; } + } +} \ No newline at end of file diff --git a/src/Options/Gemini/GeminiOptions.cs b/src/Options/Gemini/GeminiOptions.cs new file mode 100644 index 0000000..560d9de --- /dev/null +++ b/src/Options/Gemini/GeminiOptions.cs @@ -0,0 +1,25 @@ +namespace ChatAIze.GenerativeCS.Options.Gemini +{ + public class GeminiOptions + { + public string? ApiKey { get; set; } + public string? ModelId { get; set; } // e.g., "gemini-1.5-flash" + + private string _fileApiBaseUrl = "https://generativelanguage.googleapis.com/upload/v1beta"; + public string FileApiBaseUrl + { + get => _fileApiBaseUrl; + set => _fileApiBaseUrl = value.TrimEnd('/'); + } + + private string _generativeApiBaseUrl = "https://generativelanguage.googleapis.com/v1beta"; + public string GenerativeApiBaseUrl + { + get => _generativeApiBaseUrl; + set => _generativeApiBaseUrl = value.TrimEnd('/'); + } + + // Default timeout for HTTP requests, in seconds + public int DefaultTimeoutSeconds { get; set; } = 100; + } +} \ No newline at end of file diff --git a/src/Providers/Gemini/ChatCompletion.cs b/src/Providers/Gemini/ChatCompletion.cs index 2dde669..e0f6609 100644 --- a/src/Providers/Gemini/ChatCompletion.cs +++ b/src/Providers/Gemini/ChatCompletion.cs @@ -211,8 +211,11 @@ private static JsonObject CreateChatCompletionRequest; + if (richMessage == null) continue; + + var partsArray = new JsonArray(); + var functionCall = richMessage.FunctionCalls.FirstOrDefault(); if (functionCall is not null) { @@ -225,38 +228,57 @@ private static JsonObject CreateChatCompletionRequest options) // Constructor name matches new class name + { + _httpClient = httpClient; + _options = options.Value; + } + + public async Task UploadFileAsync(string filePath, string mimeType, string? displayName = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_options.ApiKey)) + throw new InvalidOperationException("API key is not configured for Gemini."); + + displayName ??= Path.GetFileName(filePath); + using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); + return await UploadFileAsync(fileStream, Path.GetFileName(filePath), mimeType, displayName, cancellationToken); + } + + public async Task UploadFileAsync(Stream stream, string fileName, string mimeType, string? displayName = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_options.ApiKey)) + throw new InvalidOperationException("API key is not configured for Gemini."); + + displayName ??= fileName; + long fileSize = stream.Length; + + var initialRequestUrl = $"{_options.FileApiBaseUrl}/files?key={_options.ApiKey}"; + + var initialRequest = new HttpRequestMessage(HttpMethod.Post, initialRequestUrl); + initialRequest.Headers.Add("X-Goog-Upload-Protocol", "resumable"); + initialRequest.Headers.Add("X-Goog-Upload-Command", "start"); + initialRequest.Headers.Add("X-Goog-Upload-Header-Content-Length", fileSize.ToString()); + initialRequest.Headers.Add("X-Goog-Upload-Header-Content-Type", mimeType); + + var uploadRequestData = new GeminiFileUploadRequest { File = new Models.Gemini.FileMetadata { DisplayName = displayName } }; + initialRequest.Content = JsonContent.Create(uploadRequestData, options: new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + initialRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + HttpResponseMessage initialResponse = await _httpClient.SendAsync(initialRequest, cancellationToken); + if (!initialResponse.IsSuccessStatusCode) + { + var errorContent = await initialResponse.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException($"Failed to initiate file upload. Status: {initialResponse.StatusCode}. Response: {errorContent}"); + } + + if (!initialResponse.Headers.TryGetValues("X-Goog-Upload-URL", out var uploadUrlValues) || !Uri.TryCreate(uploadUrlValues.FirstOrDefault(), UriKind.Absolute, out var uploadUrl)) + { + throw new HttpRequestException("Failed to get upload URL from response headers."); + } + + var uploadContent = new StreamContent(stream); + uploadContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType); + + var uploadRequest = new HttpRequestMessage(HttpMethod.Post, uploadUrl); + uploadRequest.Headers.Add("X-Goog-Upload-Command", "upload, finalize"); + uploadRequest.Headers.Add("X-Goog-Upload-Offset", "0"); + uploadRequest.Content = uploadContent; + + HttpResponseMessage uploadResponse = await _httpClient.SendAsync(uploadRequest, cancellationToken); + + if (!uploadResponse.IsSuccessStatusCode) + { + var errorContent = await uploadResponse.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException($"Failed to upload file content. Status: {uploadResponse.StatusCode}. Response: {errorContent}"); + } + + var fileUploadWrapper = await uploadResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + return fileUploadWrapper?.File; + } + + public async Task GetFileAsync(string name, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_options.ApiKey)) + throw new InvalidOperationException("API key is not configured for Gemini."); + + var requestUrl = $"{_options.FileApiBaseUrl}/{name.TrimStart('/')}?key={_options.ApiKey}"; + var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + + HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException($"Failed to get file metadata. Status: {response.StatusCode}. Response: {errorContent}"); + } + var fileWrapper = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + return fileWrapper?.File; + } + + public async Task ListFilesAsync(int pageSize = 1000, string? pageToken = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_options.ApiKey)) + throw new InvalidOperationException("API key is not configured for Gemini."); + + var requestUrl = $"{_options.FileApiBaseUrl}/files?key={_options.ApiKey}&pageSize={pageSize}"; + if (!string.IsNullOrEmpty(pageToken)) + { + requestUrl += $"&pageToken={pageToken}"; + } + var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + + HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException($"Failed to list files. Status: {response.StatusCode}. Response: {errorContent}"); + } + return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + } + + public async Task DeleteFileAsync(string name, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_options.ApiKey)) + throw new InvalidOperationException("API key is not configured for Gemini."); + + var requestUrl = $"{_options.FileApiBaseUrl}/{name.TrimStart('/')}?key={_options.ApiKey}"; + var request = new HttpRequestMessage(HttpMethod.Delete, requestUrl); + + HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken); + if (response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.NoContent) + { + return true; + } + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return false; + + // var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); // Don't read if already returned true/false + return false; + } + + private class GeminiFileUploadResponseWrapper + { + [JsonPropertyName("file")] + required public GeminiFile File { get; set; } + } + } +} \ No newline at end of file diff --git a/src/Providers/Gemini/IFileService.cs b/src/Providers/Gemini/IFileService.cs new file mode 100644 index 0000000..1215af0 --- /dev/null +++ b/src/Providers/Gemini/IFileService.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; +using ChatAIze.GenerativeCS.Models.Gemini; + +namespace ChatAIze.GenerativeCS.Providers.Gemini +{ + public interface IFileService // Renamed from IGeminiFileServiceProvider + { + Task UploadFileAsync(string filePath, string mimeType, string? displayName = null, CancellationToken cancellationToken = default); + Task UploadFileAsync(System.IO.Stream stream, string fileName, string mimeType, string? displayName = null, CancellationToken cancellationToken = default); + Task GetFileAsync(string name, CancellationToken cancellationToken = default); + Task ListFilesAsync(int pageSize = 1000, string? pageToken = null, CancellationToken cancellationToken = default); + Task DeleteFileAsync(string name, CancellationToken cancellationToken = default); + } +} \ No newline at end of file