Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ Llm__Providers__OpenAI__Model=gpt-5.1
Llm__Providers__Claude__Model=claude-3-5-haiku-latest
Llm__Providers__Claude__Version=2023-06-01
Llm__Providers__Gemini__Model=gemini-3.1-flash-lite
# LM Studio does not require auth by default; a harmless placeholder such as
# "lmstudio" is sufficient for the saved provider key when auth is off.
# If the backend runs in Docker, point this at a host-reachable LM Studio server,
# for example http://host.docker.internal:1234/v1.
Llm__Providers__LmStudio__BaseUrl=http://localhost:1234/v1
Llm__Providers__LmStudio__Model=local-model

# Built-in guest preview keys used once per logged-out browser session.
# These should be low-quota or dedicated guest credentials.
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
- � **Note Generation** — synthesise notes from any combination of nodes
- � **Export** — download notes as PDF or plain text
- 🔒 **Secure Authentication** — JWT-based auth with BCrypt password hashing
- 🔑 **BYOK Provider Settings** — save and manage personal OpenAI, Claude, and Gemini keys
- 🔑 **BYOK Provider Settings** — save and manage personal OpenAI, Claude, Gemini, and LM Studio keys
- 💬 **Canvas Workspace** — the primary frontend experience now lives in the unified canvas workspace on `/`

---
Expand Down Expand Up @@ -56,7 +56,7 @@ Coliee/
- [Docker](https://docs.docker.com/get-docker/) & Docker Compose
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) *(for backend development outside Docker)*
- [Node.js 18+](https://nodejs.org/) *(for frontend development outside Docker)*
- A provider API key for OpenAI, Claude, or Gemini if you want to use the BYOK chat flow
- A provider API key for OpenAI, Claude, Gemini, or LM Studio if you want to use the BYOK chat flow
- Optional built-in guest preview API keys for OpenAI, Claude, and Gemini if you want logged-out users to get one real AI action before sign-in

### 1. Clone the repository
Expand Down Expand Up @@ -88,6 +88,12 @@ Llm__GuestPreview__Claude__ApiKey=
Llm__GuestPreview__Claude__Model=claude-sonnet-4-20250514
Llm__GuestPreview__Gemini__ApiKey=
Llm__GuestPreview__Gemini__Model=gemini-2.5-flash
# LM Studio does not require auth by default; a placeholder such as
# "lmstudio" is enough for the saved provider key when auth is off.
# If the backend runs in Docker, point LM Studio at a host-reachable URL such as
# http://host.docker.internal:1234/v1.
Llm__Providers__LmStudio__BaseUrl=http://localhost:1234/v1
Llm__Providers__LmStudio__Model=local-model

# Backend API URL (used by frontend)
VITE_API_URL=http://localhost:5000
Expand Down Expand Up @@ -169,7 +175,7 @@ The backend follows a **layered structure**:
Controller → Service → Data Access (ADO.NET) → MySQL
```

All LLM providers are abstracted behind an interface, and the current BYOK flow supports OpenAI, Claude, and Gemini using user-managed keys stored encrypted at rest. Logged-out guest canvas actions can also use dedicated built-in preview keys for a single real AI action before sign-in is required.
All LLM providers are abstracted behind an interface, and the current BYOK flow supports OpenAI, Claude, Gemini, and LM Studio using user-managed keys stored encrypted at rest. Logged-out guest canvas actions can also use dedicated built-in preview keys for a single real AI action before sign-in is required.

---

Expand Down
68 changes: 68 additions & 0 deletions backend/Coliee.API.Tests/Services/LlmProviderClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text;
using Coliee.API.Services;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
Expand Down Expand Up @@ -93,6 +94,40 @@ await client.SendStructuredJsonAsync(
requestBody.Should().NotContain("responseSchema");
}

[Fact]
public async Task LmStudioClient_ParsesAssistantReply()
{
var client = new LmStudioProviderClient(
new HttpClient(new StubHttpMessageHandler(_ =>
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"choices":[{"message":{"content":"LM Studio says hi"}}]}""", Encoding.UTF8, "application/json")
})),
CreateConfiguration(),
NullLogger<LmStudioProviderClient>.Instance);

var response = await client.SendChatAsync("lmstudio", [new LlmProviderChatMessage { Role = "user", Content = "Hello" }]);

response.Content.Should().Be("LM Studio says hi");
}

[Fact]
public async Task LmStudioClient_GetModels_ReturnsAvailableModels()
{
var client = new LmStudioProviderClient(
new HttpClient(new StubHttpMessageHandler(_ =>
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[{"id":"local-model"},{"id":"qwen/qwen3-4b-2507"}]}""", Encoding.UTF8, "application/json")
})),
CreateConfiguration(),
NullLogger<LmStudioProviderClient>.Instance);

var models = await client.GetModelsAsync("lmstudio");

models.Select(item => item.Id).Should().ContainInOrder("local-model", "qwen/qwen3-4b-2507");
}

[Fact]
public async Task GeminiClient_InvalidArgument_DoesNotMapToInvalidKey()
{
Expand Down Expand Up @@ -124,6 +159,37 @@ public async Task GeminiClient_InvalidArgument_DoesNotMapToInvalidKey()
exception.Which.Message.Should().Be("Request contains an invalid argument.");
}

[Fact]
public async Task GeminiClient_ServiceUnavailable_Preserves503StatusCode()
{
var client = new GeminiProviderClient(
new HttpClient(new StubHttpMessageHandler(_ =>
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
{
Content = new StringContent(
"""{"error":{"code":503,"message":"This model is currently experiencing high demand.","status":"UNAVAILABLE"}}""",
Encoding.UTF8,
"application/json")
})),
CreateConfiguration(),
NullLogger<GeminiProviderClient>.Instance);

var action = async () => await client.SendStructuredJsonAsync(
"AIza-test",
new LlmProviderStructuredRequest
{
Instructions = "Return JSON.",
Messages = [new LlmProviderChatMessage { Role = "user", Content = "Hello" }],
SchemaName = "branch_plan",
SchemaJson = """{"type":"object","properties":{"branches":{"type":"array"}}}""",
MaxOutputTokens = 256
});

var exception = await action.Should().ThrowAsync<LlmChatException>();
exception.Which.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
exception.Which.Code.Should().Be(LlmProviderErrorCodes.ProviderError);
}

[Fact]
public async Task OpenAiClient_InvalidKey_MapsToLlmChatException()
{
Expand All @@ -150,6 +216,8 @@ private static IConfiguration CreateConfiguration()
["Llm:Providers:OpenAI:Model"] = "gpt-4.1-mini",
["Llm:Providers:Claude:Model"] = "claude-3-5-haiku-latest",
["Llm:Providers:Gemini:Model"] = "gemini-1.5-flash",
["Llm:Providers:LmStudio:BaseUrl"] = "http://localhost:1234/v1",
["Llm:Providers:LmStudio:Model"] = "local-model",
})
.Build();
}
Expand Down
35 changes: 35 additions & 0 deletions backend/Coliee.API.Tests/Services/LlmServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ public async Task DeleteProviderKey_DefaultProviderPromotesNextConfiguredProvide
response.DefaultProvider.Should().Be("claude");
}

[Fact]
public async Task DeleteProviderKey_DefaultProviderPromotesLmStudioWhenItIsTheOnlyRemainingProvider()
{
var repository = new InMemoryDataStore();
var service = CreateService(
repository,
new StubProviderClient(LlmProviderNames.OpenAi),
new StubProviderClient(LlmProviderNames.LmStudio));

await service.UpsertProviderKeyAsync(1, "openai", "sk-openai-1234");
await service.UpsertProviderKeyAsync(1, "lmstudio", "lmstudio");

var response = await service.DeleteProviderKeyAsync(1, "openai");

response.DefaultProvider.Should().Be("lmstudio");
}

[Fact]
public async Task GetProviderModels_ConfiguredProvider_ReturnsDiscoveredModels()
{
Expand All @@ -58,6 +75,24 @@ public async Task GetProviderModels_ConfiguredProvider_ReturnsDiscoveredModels()
response.Models.Select(item => item.Id).Should().ContainInOrder("gpt-4.1-mini", "gpt-4.1");
}

[Fact]
public async Task GetProviders_IncludesLmStudioInConfiguredOrder()
{
var repository = new InMemoryDataStore();
var service = CreateService(
repository,
new StubProviderClient(LlmProviderNames.OpenAi),
new StubProviderClient(LlmProviderNames.LmStudio));

await service.UpsertProviderKeyAsync(1, "lmstudio", "lmstudio");
await service.UpsertProviderKeyAsync(1, "openai", "sk-openai-1234");

var response = await service.GetProvidersAsync(1);

response.Providers.Select(item => item.Provider).Should().ContainInOrder("openai", "claude", "gemini", "lmstudio");
response.Providers.Single(item => item.Provider == "lmstudio").Configured.Should().BeTrue();
}

[Fact]
public async Task GetProviderModels_WithoutConfiguredProvider_ThrowsMissingKey()
{
Expand Down
2 changes: 1 addition & 1 deletion backend/Coliee.API/Data/LlmRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public async Task<IReadOnlyList<LlmProviderKeyRecord>> GetProviderKeysAsync(int
SELECT user_id, provider, encrypted_api_key, masked_last4, is_default, last_tested_at, created_at, updated_at
FROM llm_provider_keys
WHERE user_id = @userId
ORDER BY FIELD(provider, 'openai', 'claude', 'gemini')
ORDER BY FIELD(provider, 'openai', 'claude', 'gemini', 'lmstudio')
""",
conn);
cmd.Parameters.AddWithValue("@userId", userId);
Expand Down
2 changes: 2 additions & 0 deletions backend/Coliee.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
builder.Services.AddScoped<ILlmProviderClient>(sp => sp.GetRequiredService<ClaudeProviderClient>());
builder.Services.AddHttpClient<GeminiProviderClient>();
builder.Services.AddScoped<ILlmProviderClient>(sp => sp.GetRequiredService<GeminiProviderClient>());
builder.Services.AddHttpClient<LmStudioProviderClient>();
builder.Services.AddScoped<ILlmProviderClient>(sp => sp.GetRequiredService<LmStudioProviderClient>());
builder.Services.AddSingleton(TimeProvider.System);

// Rate limiting configuration
Expand Down
4 changes: 4 additions & 0 deletions backend/Coliee.API/Services/ChatSessionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,7 @@ private async Task<LlmProviderChatResult> SendMultimodalConversationAsync(
return provider switch
{
LlmProviderNames.OpenAi => await SendOpenAiMultimodalAsync(apiKey, messages, modelOverride, systemPrompt),
LlmProviderNames.LmStudio => await SendOpenAiMultimodalAsync(apiKey, messages, modelOverride, systemPrompt),
LlmProviderNames.Gemini => await SendGeminiMultimodalAsync(apiKey, messages, modelOverride, systemPrompt),
LlmProviderNames.Claude => await SendClaudeMultimodalAsync(apiKey, messages, modelOverride, systemPrompt),
_ => throw new LlmChatException(
Expand Down Expand Up @@ -1638,6 +1639,8 @@ private static string NormalizeGuestActionType(string? actionType)
private static string InferProviderFromModel(string model)
{
var normalized = model.Trim().ToLowerInvariant();
if (normalized == "local-model" || normalized.StartsWith("lmstudio", StringComparison.Ordinal))
return LlmProviderNames.LmStudio;
if (normalized.Contains("claude", StringComparison.Ordinal))
return LlmProviderNames.Claude;
if (normalized.StartsWith("gpt", StringComparison.Ordinal) || normalized.Contains("gpt-", StringComparison.Ordinal) || normalized.StartsWith("o1", StringComparison.Ordinal) || normalized.StartsWith("o3", StringComparison.Ordinal))
Expand All @@ -1651,6 +1654,7 @@ private static string GetDefaultGuestModel(string provider)
{
LlmProviderNames.OpenAi => "gpt-5.2",
LlmProviderNames.Claude => "claude-sonnet-4-20250514",
LlmProviderNames.LmStudio => "local-model",
_ => "gemini-3.1-flash-lite-preview"
};
}
Expand Down
1 change: 1 addition & 0 deletions backend/Coliee.API/Services/ClaudeProviderClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ private static int GetStatusCode(HttpStatusCode statusCode)
{
HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => StatusCodes.Status400BadRequest,
HttpStatusCode.TooManyRequests => StatusCodes.Status429TooManyRequests,
HttpStatusCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status502BadGateway
};
}
Expand Down
1 change: 1 addition & 0 deletions backend/Coliee.API/Services/GeminiProviderClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ private static int GetStatusCode(HttpStatusCode statusCode)
{
HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => StatusCodes.Status400BadRequest,
HttpStatusCode.TooManyRequests => StatusCodes.Status429TooManyRequests,
HttpStatusCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status502BadGateway
};
}
Expand Down
3 changes: 2 additions & 1 deletion backend/Coliee.API/Services/LlmProviderNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ public static class LlmProviderNames
public const string OpenAi = "openai";
public const string Claude = "claude";
public const string Gemini = "gemini";
public const string LmStudio = "lmstudio";

public static readonly IReadOnlyList<string> Ordered = [OpenAi, Claude, Gemini];
public static readonly IReadOnlyList<string> Ordered = [OpenAi, Claude, Gemini, LmStudio];

public static string Normalize(string provider) => provider.Trim().ToLowerInvariant();

Expand Down
Loading
Loading