Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions ArcChat.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<Project Path="packages/net-core/ArcChat.Net.Tests/ArcChat.Net.Tests.csproj" />
<Project Path="packages/model-providers/ArcChat.ModelProviders.Core/ArcChat.ModelProviders.Core.csproj" />
<Project Path="packages/model-providers/ArcChat.ModelProviders.Core.Tests/ArcChat.ModelProviders.Core.Tests.csproj" />
<Project Path="packages/model-providers/ArcChat.ModelProviders.Core.ContractTestKit/ArcChat.ModelProviders.Core.ContractTestKit.csproj" />
<Project Path="packages/model-providers/ArcChat.ModelProviders.Tokenizer/ArcChat.ModelProviders.Tokenizer.csproj" />
<Project Path="packages/model-providers/ArcChat.ModelProviders.Tokenizer.Tests/ArcChat.ModelProviders.Tokenizer.Tests.csproj" />
<Project Path="packages/model-providers/ArcChat.ModelProviders.OpenAi/ArcChat.ModelProviders.OpenAi.csproj" />
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
<!-- Net: NC02 transport and provider foundations. -->
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
<PackageVersion Include="Polly.Core" Version="8.6.6" />
<PackageVersion Include="SharpToken" Version="2.0.6" />
<PackageVersion Include="System.IO.Pipelines" Version="10.0.8" />
<PackageVersion Include="System.Threading.Channels" Version="10.0.8" />
<PackageVersion Include="System.Threading.RateLimiting" Version="10.0.8" />
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/ArcChat.Desktop.UiTests/NewChatViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,23 @@ public static async Task NewChatMaskAppliesContextAndModelOverrides()
_ = conversation.Mask.ModelConfig.MaxTokens.Should().Be(2000);
}

[Fact]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "Async disposal belongs to the test scope.")]
public static async Task NewChatProviderPickerIncludesReferenceProviders()
{
await using ArcChatDatabase database = await CreateDatabaseAsync().ConfigureAwait(true);
AppNavigator navigator = new AppNavigator();
NewChatViewModel newChat = CreateLoadedNewChat(database, navigator);

_ = newChat.Providers.Select(provider => provider.ProviderName).Should().Equal(
"OpenAI",
"Anthropic",
"Google",
"GenericOpenAI");
_ = newChat.Providers.Should().OnlyContain(provider => provider.Models.Length > 0);
_ = newChat.SelectedProvider!.ProviderName.Should().Be("OpenAI");
}

private static NewChatViewModel CreateLoadedNewChat(ArcChatDatabase database, IAppNavigator navigator)
{
NewChatViewModel viewModel = new NewChatViewModel(
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/ArcChat.Desktop/ArcChat.Desktop.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@
<ProjectReference Include="..\..\..\packages\agent-core\ArcChat.Agent\ArcChat.Agent.csproj" />
<ProjectReference Include="..\..\..\packages\local-persistence\ArcChat.LocalPersistence\ArcChat.LocalPersistence.csproj" />
<ProjectReference Include="..\..\..\packages\local-services\ArcChat.LocalServices\ArcChat.LocalServices.csproj" />
<ProjectReference Include="..\..\..\packages\model-providers\ArcChat.ModelProviders.Anthropic\ArcChat.ModelProviders.Anthropic.csproj" />
<ProjectReference Include="..\..\..\packages\model-providers\ArcChat.ModelProviders.Core\ArcChat.ModelProviders.Core.csproj" />
<ProjectReference Include="..\..\..\packages\model-providers\ArcChat.ModelProviders.GenericOpenAi\ArcChat.ModelProviders.GenericOpenAi.csproj" />
<ProjectReference Include="..\..\..\packages\model-providers\ArcChat.ModelProviders.Google\ArcChat.ModelProviders.Google.csproj" />
<ProjectReference Include="..\..\..\packages\model-providers\ArcChat.ModelProviders.OpenAi\ArcChat.ModelProviders.OpenAi.csproj" />
<ProjectReference Include="..\..\..\packages\net-core\ArcChat.Net\ArcChat.Net.csproj" />
<ProjectReference Include="..\..\..\tools\ArcChat.IconCodegen\ArcChat.IconCodegen.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" PrivateAssets="all" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
using ArcChat.Desktop.ViewModels;
using ArcChat.LocalPersistence;
using ArcChat.LocalPersistence.Repositories;
using ArcChat.ModelProviders.Anthropic;
using ArcChat.ModelProviders.Core;
using ArcChat.ModelProviders.GenericOpenAi;
using ArcChat.ModelProviders.Google;
using ArcChat.ModelProviders.OpenAi;
using ArcChat.Net.Factory;
using Microsoft.Extensions.DependencyInjection;
using ObservableSettingsRepository = ArcChat.LocalServices.Settings.SettingsRepository;
Expand All @@ -37,7 +41,8 @@ private static void AddCoreServices(IServiceCollection services)
_ = services.AddSingleton<IConversationRepository>(provider => provider.GetRequiredService<ArcChatDatabase>().Conversations);
_ = services.AddSingleton<IMessageRepository>(provider => provider.GetRequiredService<ArcChatDatabase>().Messages);
_ = services.AddSingleton<PersistenceSettingsRepository>(provider => provider.GetRequiredService<ArcChatDatabase>().Settings);
_ = services.AddSingleton<IChatProviderRegistry>(_ => ModelProviderCoreDefaults.CreateRegistry());
AddChatProviders(services);
_ = services.AddArcChatProviders();
_ = services.AddSingleton<IAgentRuntime>(provider => new AgentRuntime(provider.GetRequiredService<IChatProviderRegistry>()));
_ = services.AddSingleton<IConversationTitler, ConversationTitler>();
_ = services.AddSingleton<IContextSummarizer, ContextSummarizer>();
Expand All @@ -53,6 +58,18 @@ private static void AddCoreServices(IServiceCollection services)
_ = services.AddSingleton<ILocaleService>(_ => LocaleService.FromDirectory(CreateLocaleDirectory(), CultureInfo.CurrentUICulture.Name));
}

private static void AddChatProviders(IServiceCollection services)
{
_ = services.AddSingleton<IChatProvider>(provider =>
new OpenAiProvider(provider.GetRequiredService<INetCoreFactory>().GetClient(NetClientProfileNames.Streaming)));
_ = services.AddSingleton<IChatProvider>(provider =>
new AnthropicProvider(provider.GetRequiredService<INetCoreFactory>().GetClient(NetClientProfileNames.Streaming)));
_ = services.AddSingleton<IChatProvider>(provider =>
new GoogleProvider(provider.GetRequiredService<INetCoreFactory>().GetClient(NetClientProfileNames.Streaming)));
_ = services.AddSingleton<IChatProvider>(provider =>
new GenericOpenAiProvider(provider.GetRequiredService<INetCoreFactory>().GetClient(NetClientProfileNames.Streaming)));
}

private static void AddFeatureViewModels(IServiceCollection services)
{
_ = services.AddTransient(provider => new SettingsViewModel(
Expand Down
45 changes: 45 additions & 0 deletions docs/coverage/providers-live-demo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# NC05 Provider Live Demo Evidence

NC05 lands the chat-provider SPI and the four reference chat providers required before later provider waves:
OpenAI, Anthropic, Google Gemini, and GenericOpenAI-compatible endpoints.

## Offline Contract Evidence

The provider contract suites stream recorded fixtures without network access:

| Provider | Fixture | Test evidence |
| --- | --- | --- |
| OpenAI | `packages/model-providers/ArcChat.ModelProviders.OpenAi.Tests/Resources/openai-stream-tools-vision.ndjson` | `OpenAiProviderTests.StreamsOpenAiFixtureWithVisionToolsAndReasoning` |
| Anthropic | `packages/model-providers/ArcChat.ModelProviders.Anthropic.Tests/Resources/anthropic-messages-tools-vision.ndjson` | `AnthropicProviderTests.StreamsAnthropicFixtureWithVisionToolsAndThinking` |
| Google | `packages/model-providers/ArcChat.ModelProviders.Google.Tests/Resources/google-stream-tools-vision.ndjson` | `GoogleProviderTests.StreamsGoogleFixtureWithVisionToolsAndSafetySettings` |
| GenericOpenAI | `packages/model-providers/ArcChat.ModelProviders.GenericOpenAi.Tests/Resources/vllm-stream-tools-vision.ndjson`; `packages/model-providers/ArcChat.ModelProviders.GenericOpenAi.Tests/Resources/lmstudio-chat-stream.ndjson` | `GenericOpenAiProviderTests.StreamsVllmCompatibleFixtureWithConfiguredEndpointVisionAndTools`; `GenericOpenAiProviderTests.StreamsLmStudioFixtureAgainstBaseUriWithoutVersionSegment` |

## Desktop Selection Evidence

`SettingsDefaults.Create()` now seeds selectable provider configs for OpenAI, Anthropic, Google, and GenericOpenAI. `ArcChat.Desktop` registers all four providers with the streaming HTTP profile, so `AgentRuntime` resolves the selected `ModelConfig.ProviderName` directly from the desktop composition root.

Automated coverage:

```text
dotnet test apps/desktop/ArcChat.Desktop.UiTests/ArcChat.Desktop.UiTests.csproj -m:1 /p:TreatWarningsAsErrors=true
```

Result: passed, 46 tests. `NewChatViewModelTests.NewChatProviderPickerIncludesReferenceProviders` verifies the provider picker contains OpenAI, Anthropic, Google, and GenericOpenAI with at least one selectable model each.

## Live Demo Procedure

Live provider streaming is enabled through provider-specific environment variables until NC15 moves API keys into the OS keychain:

```powershell
$env:OPENAI_API_KEY = "<developer key>"
$env:ANTHROPIC_API_KEY = "<developer key>"
$env:GOOGLE_API_KEY = "<developer key>"
$env:GENERIC_OPENAI_BASE_URL = "http://localhost:8000"
$env:GENERIC_OPENAI_API_KEY = "<optional endpoint token>"
$env:MSBUILDDISABLENODEREUSE = "1"
dotnet run --project apps/desktop/ArcChat.Desktop/ArcChat.Desktop.csproj
```

Manual path: New Chat -> provider picker -> choose OpenAI, Anthropic, Google, or GenericOpenAI -> send a prompt -> verify streamed assistant response.

Live execution status for this automated run: not executed because no developer provider key was supplied in the environment. The desktop wiring and stream behavior are covered by the offline contract and UI tests above.
4 changes: 4 additions & 0 deletions docs/coverage/settings-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ NC02 lands the shared protocol DTOs, the local SQLite v1 schema, and repository
| Sync store | `SyncSnapshot`, `SyncProviderConfig` | `SyncMeta`, `SyncBackup`, and generic JSON table store | `ProtocolRoundTripTests.ProviderSettingsAndSyncDtosPreserveOpaqueExtraFields`; `MigrationAndSchemaTests.JsonBackedTablesRoundTripEveryAuxiliaryTable` |
| Mask/plugin/MCP/artifact seeds | `Mask`, `Plugin`, `PluginManifest`, `McpConfigData`, `McpRequestMessage`, `McpResponseMessage`, `McpTool`, `HtmlArtifactPreview`, `ArcTool` | `Mask`, `Plugin`, `McpServer`, `HtmlArtifactPreview`, `ToolCall`, `PromptSeed`, `KeychainRef` tables | `ProtocolRoundTripTests.MaskPluginMcpAndArtifactDtosRoundTrip`; `MigrationAndSchemaTests.JsonBackedTablesRoundTripEveryAuxiliaryTable` |

## NC05 Provider Settings Evidence

NC05 extends `SettingsDefaults.Create()` with selectable provider configs for OpenAI, Anthropic, Google, and GenericOpenAI. The seeded `ProviderConfig` rows include endpoint base URLs and model descriptors. `NewChatViewModelTests.NewChatProviderPickerIncludesReferenceProviders` verifies the desktop provider picker exposes all four NC05 providers and at least one model for each provider.

## Store Metadata

| Source | Store key | Version | Migrations found | Owner |
Expand Down
39 changes: 27 additions & 12 deletions packages/agent-core/ArcChat.Agent.Tests/AgentRuntimeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,12 @@ public SequenceProvider(params ChatEvent[] events)
this.events = events;
}

public string Id => "OpenAI";
public ProviderId Id => new ProviderId("OpenAI");

public bool SupportsVision => false;
public ChatProviderCapabilities Capabilities => ChatProviderCapabilities.Streaming;

public async IAsyncEnumerable<ChatEvent> StreamAsync(
ChatProviderRequest request,
ChatRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
foreach (ChatEvent chatEvent in this.events)
Expand All @@ -139,18 +139,23 @@ public async IAsyncEnumerable<ChatEvent> StreamAsync(
yield return chatEvent;
}
}

public Task<ImmutableArray<ModelDescriptor>> ListModelsAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(ImmutableArray<ModelDescriptor>.Empty);
}
}

private sealed class RetryProvider : IChatProvider
{
public int Attempts { get; private set; }

public string Id => "OpenAI";
public ProviderId Id => new ProviderId("OpenAI");

public bool SupportsVision => false;
public ChatProviderCapabilities Capabilities => ChatProviderCapabilities.Streaming;

public async IAsyncEnumerable<ChatEvent> StreamAsync(
ChatProviderRequest request,
ChatRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
this.Attempts++;
Expand All @@ -161,23 +166,33 @@ public async IAsyncEnumerable<ChatEvent> StreamAsync(
}

cancellationToken.ThrowIfCancellationRequested();
yield return new MessageDelta(request.ConversationId, request.MessageId, "ok");
yield return new ChatFinished(request.ConversationId, request.MessageId, "stop");
yield return new MessageDelta(request.Extra.ConversationId, request.Extra.MessageId, "ok");
yield return new ChatFinished(request.Extra.ConversationId, request.Extra.MessageId, "stop");
}

public Task<ImmutableArray<ModelDescriptor>> ListModelsAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(ImmutableArray<ModelDescriptor>.Empty);
}
}

private sealed class SlowProvider : IChatProvider
{
public string Id => "OpenAI";
public ProviderId Id => new ProviderId("OpenAI");

public bool SupportsVision => false;
public ChatProviderCapabilities Capabilities => ChatProviderCapabilities.Streaming;

public async IAsyncEnumerable<ChatEvent> StreamAsync(
ChatProviderRequest request,
ChatRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
yield return new MessageDelta(request.ConversationId, request.MessageId, "late");
yield return new MessageDelta(request.Extra.ConversationId, request.Extra.MessageId, "late");
}

public Task<ImmutableArray<ModelDescriptor>> ListModelsAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(ImmutableArray<ModelDescriptor>.Empty);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ public async Task ConversationTitlerUsesProviderOutput()
string title = await titler.GenerateTitleAsync(conversation, CancellationToken.None).ConfigureAwait(true);

_ = title.Should().Be("Neural roadmap");
ChatProviderRequest request = provider.Requests.Should().ContainSingle().Subject;
Message lastMessage = request.Messages[request.Messages.Count - 1];
ChatRequest request = provider.Requests.Should().ContainSingle().Subject;
Message lastMessage = request.History[request.History.Length - 1];
_ = lastMessage.Role.Should().Be(MessageRole.User);
_ = ConversationPromptRunner.ExtractText(lastMessage).Should().Be(ConversationTitler.TopicPrompt);
}
Expand All @@ -57,8 +57,8 @@ public async Task ContextSummarizerCollapsesLongHistoryToBoundedMemoryPrompt()

_ = Encoding.UTF8.GetByteCount(summarized.MemoryPrompt).Should().BeLessThanOrEqualTo(ContextSummarizer.MaxSummaryUtf8Bytes);
_ = summarized.LastSummarizeIndex.Should().Be(500);
ChatProviderRequest request = provider.Requests.Should().ContainSingle().Subject;
Message lastMessage = request.Messages[request.Messages.Count - 1];
ChatRequest request = provider.Requests.Should().ContainSingle().Subject;
Message lastMessage = request.History[request.History.Length - 1];
_ = lastMessage.Role.Should().Be(MessageRole.System);
_ = ConversationPromptRunner.ExtractText(lastMessage).Should().Be(ContextSummarizer.SummaryPrompt);
}
Expand All @@ -74,22 +74,22 @@ private static Message[] CreateMessages(int count)
.ToArray();
}

private static Func<ChatProviderRequest, CancellationToken, IAsyncEnumerable<ChatEvent>> CompletedText(string text)
private static Func<ChatRequest, CancellationToken, IAsyncEnumerable<ChatEvent>> CompletedText(string text)
{
return (request, cancellationToken) => StreamCompletedText(request, text, cancellationToken);
}

private static async IAsyncEnumerable<ChatEvent> StreamCompletedText(
ChatProviderRequest request,
ChatRequest request,
string text,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Delay(1, cancellationToken).ConfigureAwait(true);
yield return new MessageCompleted(
request.ConversationId,
request.MessageId,
Message.Text(request.MessageId, MessageRole.Assistant, text, "0"));
yield return new ChatFinished(request.ConversationId, request.MessageId, "stop");
request.Extra.ConversationId,
request.Extra.MessageId,
Message.Text(request.Extra.MessageId, MessageRole.Assistant, text, "0"));
yield return new ChatFinished(request.Extra.ConversationId, request.Extra.MessageId, "stop");
}

[SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:PrefixLocalCallsWithThis", Justification = "Record with-expressions use member assignment syntax.")]
Expand Down Expand Up @@ -120,23 +120,28 @@ private static Conversation CreateConversation(string topic, params Message[] me

private sealed class ScriptedProvider : IChatProvider
{
private readonly Func<ChatProviderRequest, CancellationToken, IAsyncEnumerable<ChatEvent>> streamFactory;
private readonly Func<ChatRequest, CancellationToken, IAsyncEnumerable<ChatEvent>> streamFactory;

public ScriptedProvider(Func<ChatProviderRequest, CancellationToken, IAsyncEnumerable<ChatEvent>> streamFactory)
public ScriptedProvider(Func<ChatRequest, CancellationToken, IAsyncEnumerable<ChatEvent>> streamFactory)
{
this.streamFactory = streamFactory;
}

public List<ChatProviderRequest> Requests { get; } = new List<ChatProviderRequest>();
public List<ChatRequest> Requests { get; } = new List<ChatRequest>();

public string Id => "OpenAI";
public ProviderId Id => new ProviderId("OpenAI");

public bool SupportsVision => false;
public ChatProviderCapabilities Capabilities => ChatProviderCapabilities.Streaming;

public IAsyncEnumerable<ChatEvent> StreamAsync(ChatProviderRequest request, CancellationToken cancellationToken = default)
public IAsyncEnumerable<ChatEvent> StreamAsync(ChatRequest request, CancellationToken cancellationToken = default)
{
this.Requests.Add(request);
return this.streamFactory(request, cancellationToken);
}

public Task<ImmutableArray<ModelDescriptor>> ListModelsAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(ImmutableArray<ModelDescriptor>.Empty);
}
}
}
Loading
Loading