From 96981a7f89d560d9abb0a672b88dcf66a9c02364 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 21 Sep 2026 13:34:25 -0600 Subject: [PATCH 1/5] feat: implement model catalog integration and context window management --- Directory.Packages.props | 1 + .../Llm/CompositeChatClientFactory.cs | 25 ++++++++-- .../Llm/ModelCatalogEntry.cs | 6 +++ .../Ports/IChatClientAdapter.cs | 12 ----- .../Ports/IModelCatalogPort.cs | 18 ++++++++ .../DependencyInjection/AgentCoreOptions.cs | 15 +++++- .../PublicAPI.Unshipped.txt | 1 + .../AgentCoreHostBuilderExtensions.cs | 7 ++- .../AgentCore.Infrastructure.csproj | 1 + .../Llm/ModelsDev/ModelsDevCatalogOptions.cs | 9 ++++ .../Llm/OpenAI/OpenAiContextWindows.cs | 46 ------------------- .../PublicAPI.Unshipped.txt | 22 ++++++++- 12 files changed, 98 insertions(+), 65 deletions(-) create mode 100644 src/AgentCore.Application/Llm/ModelCatalogEntry.cs create mode 100644 src/AgentCore.Application/Ports/IModelCatalogPort.cs create mode 100644 src/AgentCore.Infrastructure/Llm/ModelsDev/ModelsDevCatalogOptions.cs delete mode 100644 src/AgentCore.Infrastructure/Llm/OpenAI/OpenAiContextWindows.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 6a31555c..2a3cb6a7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,6 +15,7 @@ + diff --git a/src/AgentCore.Application/Llm/CompositeChatClientFactory.cs b/src/AgentCore.Application/Llm/CompositeChatClientFactory.cs index 9b6dd066..5c7a7d7f 100644 --- a/src/AgentCore.Application/Llm/CompositeChatClientFactory.cs +++ b/src/AgentCore.Application/Llm/CompositeChatClientFactory.cs @@ -14,11 +14,13 @@ namespace AgentCore.Application.Llm /// public sealed class CompositeChatClientFactory : IChatClientFactory, IDisposable { - private readonly Dictionary _entries = new(StringComparer.Ordinal); + private readonly Dictionary _entries = [with(StringComparer.Ordinal)]; - private readonly Dictionary _adapters = new(StringComparer.Ordinal); + private readonly Dictionary _adapters = [with(StringComparer.Ordinal)]; - private readonly Dictionary _vendor = new(StringComparer.Ordinal); + private readonly Dictionary _vendor = [with(StringComparer.Ordinal)]; + + private readonly Dictionary _contextWindows = [with(StringComparer.Ordinal)]; private readonly ConcurrentDictionary _shaped = new(StringComparer.Ordinal); @@ -34,6 +36,10 @@ private CompositeChatClientFactory() /// The loaded document. /// The chain each adapter resolves its credential through, or . /// The adapters the host registers, one for each vendor it supports. + /// + /// Looks up each entry's context window by its kind and model. A document with no + /// catalog answers every call with . + /// /// Cancels the build. /// The factory. /// @@ -44,6 +50,7 @@ public static async ValueTask CreateAsync( AgentCoreConfiguration configuration, ISecretResolverPort? secrets, IReadOnlyList adapters, + IModelCatalogPort? catalog = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(configuration); @@ -61,6 +68,7 @@ public static async ValueTask CreateAsync( $"providers.llm entry '{entry.As}'", ConfigurationError.AppendPointer(pointer, "kind"), "options.UseChatClients(...)"); + IChatClientAdapter adapter = VendorAdapterSelector.Select(entry.Kind, adapters, seam); if (!factory._entries.TryAdd(entry.As, entry)) @@ -76,6 +84,15 @@ public static async ValueTask CreateAsync( .CreateClientAsync(entry, secrets, cancellationToken) .ConfigureAwait(false); + if (catalog is not null) + { + ModelCatalogEntry? found = await catalog + .LookupAsync(entry.Kind, entry.Model, cancellationToken) + .ConfigureAwait(false); + + factory._contextWindows[entry.As] = found?.ContextWindow; + } + factory._default ??= entry; } @@ -134,7 +151,7 @@ public IChatClient GetChatClient(ModelReference? model) ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this); LlmProviderConfiguration entry = Resolve(model); - return _adapters.TryGetValue(entry.As, out IChatClientAdapter? adapter) ? adapter.GetContextWindow(entry) : null; + return _contextWindows.TryGetValue(entry.As, out int? tokens) ? tokens : null; } /// Releases every client the adapters built. diff --git a/src/AgentCore.Application/Llm/ModelCatalogEntry.cs b/src/AgentCore.Application/Llm/ModelCatalogEntry.cs new file mode 100644 index 00000000..1c506663 --- /dev/null +++ b/src/AgentCore.Application/Llm/ModelCatalogEntry.cs @@ -0,0 +1,6 @@ +namespace AgentCore.Application.Llm +{ + /// One model's facts, as reports them. + /// The context window, in tokens. + public sealed record ModelCatalogEntry(int ContextWindow); +} diff --git a/src/AgentCore.Application/Ports/IChatClientAdapter.cs b/src/AgentCore.Application/Ports/IChatClientAdapter.cs index b38f9165..b475adc4 100644 --- a/src/AgentCore.Application/Ports/IChatClientAdapter.cs +++ b/src/AgentCore.Application/Ports/IChatClientAdapter.cs @@ -26,17 +26,5 @@ ValueTask CreateClientAsync( { return null; } - - /// Reports the model's context window, in tokens. - /// The providers.llm[] entry this adapter serves. - /// - /// The window in tokens, or when this adapter does not know the model. - /// Compaction is always on, so the compiler refuses an agent whose model answers - /// . - /// - int? GetContextWindow(LlmProviderConfiguration entry) - { - return null; - } } } diff --git a/src/AgentCore.Application/Ports/IModelCatalogPort.cs b/src/AgentCore.Application/Ports/IModelCatalogPort.cs new file mode 100644 index 00000000..ebc9e3b9 --- /dev/null +++ b/src/AgentCore.Application/Ports/IModelCatalogPort.cs @@ -0,0 +1,18 @@ +using AgentCore.Application.Llm; + +namespace AgentCore.Application.Ports +{ + /// + /// Looks up one vendor's models by name: whether the name exists, and its context window. + /// + public interface IModelCatalogPort + { + /// Looks up one model under one provider. + /// The catalog's provider key, such as opencode-go. + /// The model id, exactly as the vendor's own API names it. + /// Cancels the lookup. + /// The entry, or when the provider knows no such model. + ValueTask LookupAsync( + string provider, string model, CancellationToken cancellationToken = default); + } +} diff --git a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs index e8ae944e..72330457 100644 --- a/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs +++ b/src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs @@ -45,6 +45,9 @@ public sealed class AgentCoreOptions /// Gets the seam that resolves a model reference, or . internal Func>? ChatClients { get; private set; } + /// Gets the catalog every chat adapter's context window is read from, or . + internal IModelCatalogPort? ModelCatalog { get; private set; } + /// Gets the embedding vendors the host registered, or . internal IReadOnlyList? Embeddings { get; private set; } @@ -117,6 +120,16 @@ public AgentCoreOptions UseConversationSessions(FuncBinds the catalog every chat adapter's context window is read from. + /// Looks up one entry's context window by its kind and model. + /// These options, so a host chains its conversations. + public AgentCoreOptions UseModelCatalog(IModelCatalogPort catalog) + { + ArgumentNullException.ThrowIfNull(catalog); + ModelCatalog = catalog; + return this; + } + /// Binds the vendor adapters, and the document picks one by each entry's kind. /// One adapter for each vendor this host supports. /// These options, so a host chains its conversations. @@ -124,7 +137,7 @@ public AgentCoreOptions UseChatClients(params IChatClientAdapter[] adapters) { ArgumentNullException.ThrowIfNull(adapters); return UseChatClients(async (startup, cancellationToken) => await CompositeChatClientFactory - .CreateAsync(startup.Configuration, SecretResolver, adapters, cancellationToken) + .CreateAsync(startup.Configuration, SecretResolver, adapters, ModelCatalog, cancellationToken) .ConfigureAwait(false)); } diff --git a/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt b/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt index 9464409f..f34457dc 100644 --- a/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt +++ b/src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt @@ -32,6 +32,7 @@ AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseConversationStores( AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseChatClients(System.Func! chatClients) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseChatClients(System.Func>! chatClients) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseChatClients(params AgentCore.Application.Ports.IChatClientAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! +AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseModelCatalog(AgentCore.Application.Ports.IModelCatalogPort! catalog) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseEmbeddings(params AgentCore.Application.Ports.IEmbeddingGeneratorAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseKnowledgeCitationFormatters(params AgentCore.Application.Knowledge.IKnowledgeCitationFormatter![]! formatters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseKnowledgePointMappers(params AgentCore.Application.Knowledge.IKnowledgePointMapper![]! mappers) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions! diff --git a/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs b/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs index 0b55aa01..85298416 100644 --- a/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs +++ b/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs @@ -8,7 +8,9 @@ using AgentCore.Infrastructure.Embeddings.OpenAI; using AgentCore.Infrastructure.Evaluation.OpenAiModeration; using AgentCore.Infrastructure.Knowledge.VectorData.Qdrant; +using AgentCore.Infrastructure.Llm.ModelsDev; using AgentCore.Infrastructure.Llm.OpenAI; +using AgentCore.Infrastructure.Llm.OpenCodeGo; using AgentCore.Infrastructure.Secrets; using AgentCore.Infrastructure.Telemetry.Grafana; using AgentCore.Infrastructure.Tools; @@ -98,8 +100,11 @@ private static void Configure( new ConfigurationSecretResolver(hostConfiguration), ]); + // Every vendor's models.dev lookup shares one fetch of the catalog. + _ = options.UseModelCatalog(ModelsDevCatalogPort.Create(httpClients)); + // providers.llm[].kind picks the adapter for each entry. - _ = options.UseChatClients(new OpenAiChatClientAdapter()); + _ = options.UseChatClients(new OpenAiChatClientAdapter(), new OpenCodeGoChatClientAdapter(httpClients)); // providers.embeddings.kind picks the adapter. _ = options.UseEmbeddings(new OpenAiEmbeddingGeneratorAdapter()); diff --git a/src/AgentCore.Infrastructure/AgentCore.Infrastructure.csproj b/src/AgentCore.Infrastructure/AgentCore.Infrastructure.csproj index 5a4ee52d..d13655c4 100644 --- a/src/AgentCore.Infrastructure/AgentCore.Infrastructure.csproj +++ b/src/AgentCore.Infrastructure/AgentCore.Infrastructure.csproj @@ -16,6 +16,7 @@ + diff --git a/src/AgentCore.Infrastructure/Llm/ModelsDev/ModelsDevCatalogOptions.cs b/src/AgentCore.Infrastructure/Llm/ModelsDev/ModelsDevCatalogOptions.cs new file mode 100644 index 00000000..862ac53b --- /dev/null +++ b/src/AgentCore.Infrastructure/Llm/ModelsDev/ModelsDevCatalogOptions.cs @@ -0,0 +1,9 @@ +namespace AgentCore.Infrastructure.Llm.ModelsDev +{ + /// Settings for . + public sealed class ModelsDevCatalogOptions + { + /// The one endpoint this port reads. No key, no documented rate limit. + public Uri CatalogEndpoint { get; set; } = new("https://models.dev/api.json", UriKind.Absolute); + } +} diff --git a/src/AgentCore.Infrastructure/Llm/OpenAI/OpenAiContextWindows.cs b/src/AgentCore.Infrastructure/Llm/OpenAI/OpenAiContextWindows.cs deleted file mode 100644 index 813147be..00000000 --- a/src/AgentCore.Infrastructure/Llm/OpenAI/OpenAiContextWindows.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace AgentCore.Infrastructure.Llm.OpenAI -{ - /// - /// A built-in table of OpenAI model context windows, keyed by model id prefix. - /// - internal static class OpenAiContextWindows - { - private static readonly (string Prefix, int Tokens)[] Table = - [ - ("gpt-6-astra", 1_050_000), - ("gpt-5.6-sol", 1_050_000), - ("gpt-5.6-terra", 1_050_000), - ("gpt-5.6-luna", 1_050_000), - ("gpt-5.6-cyber", 400_000), - ("gpt-daybreak-red-latest", 400_000), - ("gpt-daybreak-blue-latest", 1_050_000), - ("gpt-5.4-nano", 400_000), - ("gpt-5.4", 1_050_000), - ("gpt-4.1-mini", 1_047_576), - ("gpt-4.1-nano", 1_047_576), - ("gpt-4.1", 1_047_576), - ]; - - /// Looks up a model id's context window by its longest matching prefix. - /// The providers.llm[].model value, such as gpt-4.1-mini-2025-04-14. - /// The window in tokens, or when no entry's prefix matches. - internal static int? Lookup(string modelId) - { - ArgumentException.ThrowIfNullOrEmpty(modelId); - - int best = -1; - int? tokens = null; - - foreach ((string? prefix, int value) in Table) - { - if (prefix.Length > best && modelId.StartsWith(prefix, StringComparison.Ordinal)) - { - best = prefix.Length; - tokens = value; - } - } - - return tokens; - } - } -} diff --git a/src/AgentCore.Infrastructure/PublicAPI.Unshipped.txt b/src/AgentCore.Infrastructure/PublicAPI.Unshipped.txt index 2c9018af..79e6eb83 100644 --- a/src/AgentCore.Infrastructure/PublicAPI.Unshipped.txt +++ b/src/AgentCore.Infrastructure/PublicAPI.Unshipped.txt @@ -34,7 +34,6 @@ AgentCore.Infrastructure.Knowledge.VectorData.Qdrant.QdrantKnowledgeAdapter.UseA AgentCore.Infrastructure.Knowledge.VectorData.Qdrant.QdrantKnowledgeAdapter.UseMappers(params AgentCore.Application.Knowledge.IKnowledgePointMapper![]! mappers) -> AgentCore.Infrastructure.Knowledge.VectorData.Qdrant.QdrantKnowledgeAdapter! AgentCore.Infrastructure.Llm.OpenAI.OpenAiChatClientAdapter AgentCore.Infrastructure.Llm.OpenAI.OpenAiChatClientAdapter.CreateClientAsync(AgentCore.Application.Configuration.Schema.LlmProviderConfiguration! entry, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Infrastructure.Llm.OpenAI.OpenAiChatClientAdapter.GetContextWindow(AgentCore.Application.Configuration.Schema.LlmProviderConfiguration! entry) -> int? AgentCore.Infrastructure.Llm.OpenAI.OpenAiChatClientAdapter.Kind.get -> string! AgentCore.Infrastructure.Llm.OpenAI.OpenAiChatClientAdapter.OpenAiChatClientAdapter() -> void AgentCore.Infrastructure.Llm.OpenAI.OpenAiChatClientAdapter.ResolveHostedTool(Microsoft.Extensions.AI.AITool! marker, AgentCore.Application.Configuration.Schema.LlmProviderConfiguration! entry) -> Microsoft.Extensions.AI.AITool? @@ -102,3 +101,24 @@ const AgentCore.Infrastructure.Blobs.S3.S3BlobStoreAdapter.DefaultRegion = "us-e const AgentCore.Infrastructure.Blobs.S3.S3BlobStoreAdapter.ProviderKind = "s3" -> string! AgentCore.Infrastructure.Blobs.S3.S3BlobStore.LinkAsync(AgentCore.Application.Blobs.BlobRef! blob, System.TimeSpan lifetime, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Infrastructure.Blobs.S3.S3BlobStore.StatAsync(string! ownerId, string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort +AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort.ModelsDevCatalogPort(System.Net.Http.HttpClient! client, Microsoft.Extensions.Options.IOptions! options) -> void +AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort.LookupAsync(string! provider, string! model, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +const AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort.HttpClientName = "agentcore.models-dev" -> string! +static AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort.Create(System.Net.Http.IHttpClientFactory! httpClients) -> AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort! +AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter +AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.OpenCodeGoChatClientAdapter(System.Net.Http.IHttpMessageHandlerFactory! handlers) -> void +AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.Kind.get -> string! +AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.Dispose() -> void +AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.CreateClientAsync(AgentCore.Application.Configuration.Schema.LlmProviderConfiguration! entry, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +const AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.ProviderKind = "opencode-go" -> string! +const AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.ApiKeySecretName = "opencode-go-api-key" -> string! +const AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.ApiKeyVariableName = "OPENCODE_GO_API_KEY" -> string! +const AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.HttpClientName = "agentcore.opencode-go" -> string! +const AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.SessionHeaderName = "x-opencode-session" -> string! +const AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.UserAgentValue = "agentcore/1.0" -> string! +static readonly AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.ApiEndpoint -> System.Uri! +AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogOptions +AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogOptions.ModelsDevCatalogOptions() -> void +AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogOptions.CatalogEndpoint.get -> System.Uri! +AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogOptions.CatalogEndpoint.set -> void From 541355a3ca827ad8bc0795a868fcfe9cdaa97aec Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 21 Sep 2026 14:04:35 -0600 Subject: [PATCH 2/5] feat: add adaptor with opencode go --- .../Llm/CompositeChatClientFactory.cs | 8 +- .../PublicAPI.Unshipped.txt | 20 +- .../Secrets/KnownSecrets.cs | 9 + .../AgentCoreHostBuilderExtensions.cs | 6 +- .../Llm/ModelsDev/ModelsDevCatalogOptions.cs | 9 - .../Llm/ModelsDev/ModelsDevCatalogPort.cs | 106 +++++++++ .../Llm/OpenAI/OpenAiChatClientAdapter.cs | 8 - .../OpenCodeGo/OpenCodeGoChatClientAdapter.cs | 195 +++++++++++++++ .../PublicAPI.Unshipped.txt | 17 +- .../Fakes/RecordingChatClientAdapter.cs | 8 - .../Llm/CompositeChatClientFactoryTests.cs | 44 +++- .../DependencyInjection/AddAgentCoreTests.cs | 10 +- .../Fakes/FakeChatClientAdapter.cs | 5 - .../OpenAI/OpenAiChatClientAdapterTests.cs | 2 +- .../Llm/OpenAiContextWindowsTests.cs | 37 --- .../OpenCodeGoChatClientAdapterTests.cs | 223 ++++++++++++++++++ .../MapModelCatalogPort.cs | 29 +++ 17 files changed, 643 insertions(+), 93 deletions(-) delete mode 100644 src/AgentCore.Infrastructure/Llm/ModelsDev/ModelsDevCatalogOptions.cs create mode 100644 src/AgentCore.Infrastructure/Llm/ModelsDev/ModelsDevCatalogPort.cs create mode 100644 src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs delete mode 100644 tests/AgentCore.Infrastructure.Tests/Llm/OpenAiContextWindowsTests.cs create mode 100644 tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoChatClientAdapterTests.cs create mode 100644 tests/AgentCore.TestSupport/MapModelCatalogPort.cs diff --git a/src/AgentCore.Application/Llm/CompositeChatClientFactory.cs b/src/AgentCore.Application/Llm/CompositeChatClientFactory.cs index 5c7a7d7f..2305c055 100644 --- a/src/AgentCore.Application/Llm/CompositeChatClientFactory.cs +++ b/src/AgentCore.Application/Llm/CompositeChatClientFactory.cs @@ -14,13 +14,13 @@ namespace AgentCore.Application.Llm /// public sealed class CompositeChatClientFactory : IChatClientFactory, IDisposable { - private readonly Dictionary _entries = [with(StringComparer.Ordinal)]; + private readonly Dictionary _entries = new(StringComparer.Ordinal); - private readonly Dictionary _adapters = [with(StringComparer.Ordinal)]; + private readonly Dictionary _adapters = new(StringComparer.Ordinal); - private readonly Dictionary _vendor = [with(StringComparer.Ordinal)]; + private readonly Dictionary _vendor = new(StringComparer.Ordinal); - private readonly Dictionary _contextWindows = [with(StringComparer.Ordinal)]; + private readonly Dictionary _contextWindows = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _shaped = new(StringComparer.Ordinal); diff --git a/src/AgentCore.Application/PublicAPI.Unshipped.txt b/src/AgentCore.Application/PublicAPI.Unshipped.txt index a250e84d..357955ed 100644 --- a/src/AgentCore.Application/PublicAPI.Unshipped.txt +++ b/src/AgentCore.Application/PublicAPI.Unshipped.txt @@ -1089,7 +1089,6 @@ AgentCore.Application.Ports.IConversationTitler.GenerateAsync(string! conversati AgentCore.Application.Ports.IConversationTitler.GenerateFromAsync(string! conversationId, System.Collections.Generic.IReadOnlyList! messages, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! AgentCore.Application.Ports.IChatClientAdapter AgentCore.Application.Ports.IChatClientAdapter.CreateClientAsync(AgentCore.Application.Configuration.Schema.LlmProviderConfiguration! entry, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -AgentCore.Application.Ports.IChatClientAdapter.GetContextWindow(AgentCore.Application.Configuration.Schema.LlmProviderConfiguration! entry) -> int? AgentCore.Application.Ports.IChatClientAdapter.ResolveHostedTool(Microsoft.Extensions.AI.AITool! marker, AgentCore.Application.Configuration.Schema.LlmProviderConfiguration! entry) -> Microsoft.Extensions.AI.AITool? AgentCore.Application.Ports.IChatClientFactory AgentCore.Application.Ports.IChatClientFactory.GetChatClient(AgentCore.Application.Configuration.Schema.ModelReference? model) -> Microsoft.Extensions.AI.IChatClient! @@ -1959,7 +1958,7 @@ static AgentCore.Application.Knowledge.KnowledgeCitationFormatterFactory.Resolve static AgentCore.Application.Knowledge.KnowledgePoint.operator !=(AgentCore.Application.Knowledge.KnowledgePoint? left, AgentCore.Application.Knowledge.KnowledgePoint? right) -> bool static AgentCore.Application.Knowledge.KnowledgePoint.operator ==(AgentCore.Application.Knowledge.KnowledgePoint? left, AgentCore.Application.Knowledge.KnowledgePoint? right) -> bool static AgentCore.Application.Knowledge.KnowledgeRetrievalPortExtensions.GetService(this AgentCore.Application.Ports.IKnowledgeRetrievalPort! port, object? serviceKey = null) -> TService? -static AgentCore.Application.Llm.CompositeChatClientFactory.CreateAsync(AgentCore.Application.Configuration.Schema.AgentCoreConfiguration! configuration, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Collections.Generic.IReadOnlyList! adapters, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +static AgentCore.Application.Llm.CompositeChatClientFactory.CreateAsync(AgentCore.Application.Configuration.Schema.AgentCoreConfiguration! configuration, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Collections.Generic.IReadOnlyList! adapters, AgentCore.Application.Ports.IModelCatalogPort? catalog = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask static AgentCore.Application.Providers.VendorAdapterSelector.Select(string! kind, System.Collections.Generic.IReadOnlyList! adapters, AgentCore.Application.Providers.VendorSeam seam) -> TAdapter static AgentCore.Application.Providers.VendorSeam.operator !=(AgentCore.Application.Providers.VendorSeam left, AgentCore.Application.Providers.VendorSeam right) -> bool static AgentCore.Application.Providers.VendorSeam.operator ==(AgentCore.Application.Providers.VendorSeam left, AgentCore.Application.Providers.VendorSeam right) -> bool @@ -2337,3 +2336,20 @@ AgentCore.Application.Runtime.Compaction.CompactionContent.Outcome.get -> string AgentCore.Application.Runtime.Compaction.CompactionContent.Phase.get -> string! const AgentCore.Application.Runtime.Compaction.CompactionContent.EndPhase = "end" -> string! const AgentCore.Application.Runtime.Compaction.CompactionContent.StartPhase = "start" -> string! +AgentCore.Application.Llm.ModelCatalogEntry +AgentCore.Application.Llm.ModelCatalogEntry.$() -> AgentCore.Application.Llm.ModelCatalogEntry! +AgentCore.Application.Llm.ModelCatalogEntry.ModelCatalogEntry(int ContextWindow) -> void +AgentCore.Application.Llm.ModelCatalogEntry.ContextWindow.get -> int +AgentCore.Application.Llm.ModelCatalogEntry.ContextWindow.init -> void +AgentCore.Application.Llm.ModelCatalogEntry.Deconstruct(out int ContextWindow) -> void +AgentCore.Application.Llm.ModelCatalogEntry.Equals(AgentCore.Application.Llm.ModelCatalogEntry? other) -> bool +override AgentCore.Application.Llm.ModelCatalogEntry.Equals(object? obj) -> bool +override AgentCore.Application.Llm.ModelCatalogEntry.GetHashCode() -> int +override AgentCore.Application.Llm.ModelCatalogEntry.ToString() -> string! +static AgentCore.Application.Llm.ModelCatalogEntry.operator ==(AgentCore.Application.Llm.ModelCatalogEntry? left, AgentCore.Application.Llm.ModelCatalogEntry? right) -> bool +static AgentCore.Application.Llm.ModelCatalogEntry.operator !=(AgentCore.Application.Llm.ModelCatalogEntry? left, AgentCore.Application.Llm.ModelCatalogEntry? right) -> bool +AgentCore.Application.Ports.IModelCatalogPort +AgentCore.Application.Ports.IModelCatalogPort.LookupAsync(string! provider, string! model, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +const AgentCore.Application.Secrets.KnownSecrets.OpenCodeGoApiKeyName = "opencode-go-api-key" -> string! +const AgentCore.Application.Secrets.KnownSecrets.OpenCodeGoApiKeyVariable = "OPENCODE_GO_API_KEY" -> string! +static readonly AgentCore.Application.Secrets.KnownSecrets.OpenCodeGo -> AgentCore.Application.Secrets.SecretName! diff --git a/src/AgentCore.Application/Secrets/KnownSecrets.cs b/src/AgentCore.Application/Secrets/KnownSecrets.cs index 21d7bca5..6b152d8a 100644 --- a/src/AgentCore.Application/Secrets/KnownSecrets.cs +++ b/src/AgentCore.Application/Secrets/KnownSecrets.cs @@ -11,6 +11,12 @@ public static class KnownSecrets /// The standard OpenAI environment variable, read when the chain holds no name. public const string OpenAiApiKeyVariable = "OPENAI_API_KEY"; + /// The ${secret:name} name the OpenCode Go key resolves under. + public const string OpenCodeGoApiKeyName = "opencode-go-api-key"; + + /// The environment variable OpenCode Go's own docs name, read when the chain holds no name. + public const string OpenCodeGoApiKeyVariable = "OPENCODE_GO_API_KEY"; + /// The ${secret:name} name the Qdrant key resolves under. public const string QdrantApiKeyName = "qdrant-api-key"; @@ -50,6 +56,9 @@ public static class KnownSecrets /// The one OpenAI credential, which chat, embedding, and moderation all read. public static readonly SecretName OpenAi = new(OpenAiApiKeyName, OpenAiApiKeyVariable); + /// The OpenCode Go credential, which the chat adapter sends as its bearer token. + public static readonly SecretName OpenCodeGo = new(OpenCodeGoApiKeyName, OpenCodeGoApiKeyVariable); + /// The Qdrant API key the vector store sends on every conversation. public static readonly SecretName Qdrant = new(QdrantApiKeyName, QdrantApiKeyVariable); diff --git a/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs b/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs index 85298416..ba76ef86 100644 --- a/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs +++ b/src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs @@ -101,10 +101,12 @@ private static void Configure( ]); // Every vendor's models.dev lookup shares one fetch of the catalog. - _ = options.UseModelCatalog(ModelsDevCatalogPort.Create(httpClients)); + _ = options.UseModelCatalog(ModelsDevCatalogPort.CreateFromConfiguration(httpClients, hostConfiguration)); // providers.llm[].kind picks the adapter for each entry. - _ = options.UseChatClients(new OpenAiChatClientAdapter(), new OpenCodeGoChatClientAdapter(httpClients)); + _ = options.UseChatClients( + new OpenAiChatClientAdapter(), + OpenCodeGoChatClientAdapter.CreateFromConfiguration(httpClients, hostConfiguration)); // providers.embeddings.kind picks the adapter. _ = options.UseEmbeddings(new OpenAiEmbeddingGeneratorAdapter()); diff --git a/src/AgentCore.Infrastructure/Llm/ModelsDev/ModelsDevCatalogOptions.cs b/src/AgentCore.Infrastructure/Llm/ModelsDev/ModelsDevCatalogOptions.cs deleted file mode 100644 index 862ac53b..00000000 --- a/src/AgentCore.Infrastructure/Llm/ModelsDev/ModelsDevCatalogOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace AgentCore.Infrastructure.Llm.ModelsDev -{ - /// Settings for . - public sealed class ModelsDevCatalogOptions - { - /// The one endpoint this port reads. No key, no documented rate limit. - public Uri CatalogEndpoint { get; set; } = new("https://models.dev/api.json", UriKind.Absolute); - } -} diff --git a/src/AgentCore.Infrastructure/Llm/ModelsDev/ModelsDevCatalogPort.cs b/src/AgentCore.Infrastructure/Llm/ModelsDev/ModelsDevCatalogPort.cs new file mode 100644 index 00000000..38a32822 --- /dev/null +++ b/src/AgentCore.Infrastructure/Llm/ModelsDev/ModelsDevCatalogPort.cs @@ -0,0 +1,106 @@ +using System.Text.Json.Nodes; +using AgentCore.Application.Llm; +using AgentCore.Application.Ports; +using Microsoft.Extensions.Configuration; + +namespace AgentCore.Infrastructure.Llm.ModelsDev +{ + /// + /// Looks up models through the models.dev catalog, the open database OpenCode itself reads. + /// + public sealed class ModelsDevCatalogPort : IModelCatalogPort + { + /// The name this port opens its client under, on the pipeline of the host. + public const string HttpClientName = "agentcore.models-dev"; + + /// The configuration key a host names to override . + public const string CatalogEndpointConfigurationKey = "AgentCore:ModelsDev:CatalogEndpoint"; + + /// The endpoint this port reads, unless a host names its own. No key, no documented rate limit. + public static readonly Uri DefaultCatalogEndpoint = new("https://models.dev/api.json", UriKind.Absolute); + + private readonly HttpClient _client; + + private readonly Uri _catalogEndpoint; + + private JsonNode? _catalog; + + /// Creates the port over the outbound pipeline of the host. + /// The client this port reads the catalog on. The port never disposes it. + /// + /// The endpoint to read. Defaults to when omitted. + /// + public ModelsDevCatalogPort(HttpClient client, Uri? catalogEndpoint = null) + { + ArgumentNullException.ThrowIfNull(client); + + _client = client; + _catalogEndpoint = catalogEndpoint ?? DefaultCatalogEndpoint; + } + + /// Builds the port over the outbound pipeline of the host. + /// The pipeline. This port asks it for . + /// + /// The endpoint to read. Defaults to when omitted. + /// + /// The port. + public static ModelsDevCatalogPort Create(IHttpClientFactory httpClients, Uri? catalogEndpoint = null) + { + ArgumentNullException.ThrowIfNull(httpClients); + + return new ModelsDevCatalogPort(httpClients.CreateClient(HttpClientName), catalogEndpoint); + } + + /// Builds the port, reading off the host's own configuration. + /// The pipeline. This port asks it for . + /// The host's own configuration. + /// The port. + public static ModelsDevCatalogPort CreateFromConfiguration(IHttpClientFactory httpClients, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + Uri? catalogEndpoint = configuration[CatalogEndpointConfigurationKey] is { } value + ? new Uri(value, UriKind.Absolute) + : null; + + return Create(httpClients, catalogEndpoint); + } + + /// + public async ValueTask LookupAsync( + string provider, string model, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(provider); + ArgumentException.ThrowIfNullOrEmpty(model); + + JsonNode catalog = await LoadAsync(cancellationToken).ConfigureAwait(false); + + JsonNode? context = catalog[provider]?["models"]?[model]?["limit"]?["context"]; + return context?.GetValue() is { } tokens ? new ModelCatalogEntry(tokens) : null; + } + + /// Fetches the catalog once, and keeps every later call on the cached copy. + private async ValueTask LoadAsync(CancellationToken cancellationToken) + { + if (_catalog is { } cached) + { + return cached; + } + + using HttpResponseMessage response = await _client + .GetAsync(_catalogEndpoint, cancellationToken) + .ConfigureAwait(false); + + _ = response.EnsureSuccessStatusCode(); + + await using Stream body = await response.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + + _catalog = await JsonNode.ParseAsync(body, cancellationToken: cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("models.dev answered an empty body."); + + return _catalog; + } + } +} diff --git a/src/AgentCore.Infrastructure/Llm/OpenAI/OpenAiChatClientAdapter.cs b/src/AgentCore.Infrastructure/Llm/OpenAI/OpenAiChatClientAdapter.cs index 7d50bec4..d59647c7 100644 --- a/src/AgentCore.Infrastructure/Llm/OpenAI/OpenAiChatClientAdapter.cs +++ b/src/AgentCore.Infrastructure/Llm/OpenAI/OpenAiChatClientAdapter.cs @@ -65,14 +65,6 @@ await secrets }; } - /// - public int? GetContextWindow(LlmProviderConfiguration entry) - { - ArgumentNullException.ThrowIfNull(entry); - - return OpenAiContextWindows.Lookup(entry.Model); - } - /// Puts store, reasoning_effort and prompt_cache_key on every request this client sends. internal static IChatClient WithResponseDefaults(IChatClient client, string? effort) { diff --git a/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs b/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs new file mode 100644 index 00000000..3f2c2041 --- /dev/null +++ b/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs @@ -0,0 +1,195 @@ +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Concurrent; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Llm; +using AgentCore.Application.Ports; +using AgentCore.Application.Secrets; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using OpenAI; +using ChatClient = OpenAI.Chat.ChatClient; + +namespace AgentCore.Infrastructure.Llm.OpenCodeGo +{ + /// + /// The OpenCode Go adapter behind . + /// + public sealed class OpenCodeGoChatClientAdapter : IChatClientAdapter, IDisposable + { + /// The one providers.llm[].kind value this adapter serves. + public const string ProviderKind = "opencode-go"; + + /// The ${secret:name} name the resolver chain is asked for. + public const string ApiKeySecretName = KnownSecrets.OpenCodeGoApiKeyName; + + /// The environment variable OpenCode Go's own docs name. + public const string ApiKeyVariableName = KnownSecrets.OpenCodeGoApiKeyVariable; + + /// The name this adapter opens its client under, on the pipeline of the host. + public const string HttpClientName = "agentcore.opencode-go"; + + /// The header OpenCode Go's own docs ask every client to send. + public const string SessionHeaderName = "x-opencode-session"; + + /// The user agent OpenCode Go's own docs ask every client to identify itself with. + public const string UserAgentValue = "agentcore/1.0"; + + /// The configuration key a host names to override . + public const string ApiEndpointConfigurationKey = "AgentCore:OpenCodeGo:ApiEndpoint"; + + /// The endpoint this adapter posts to, unless a host names its own. + public static readonly Uri DefaultApiEndpoint = new("https://opencode.ai/zen/go/v1", UriKind.Absolute); + + /// One vendor client per (model, conversation) pair, built once and reused. + private readonly ConcurrentDictionary<(string Model, string SessionId), ChatClient> _clients = new(); + + private readonly IHttpMessageHandlerFactory _handlers; + + private readonly Uri _apiEndpoint; + + private HttpClient? _transport; + + private ApiKeyCredential? _credential; + + /// Creates the adapter over the outbound pipeline of the host. + /// The pipeline that holds the connection lifetime, the deadline, and the retry. + /// + /// The endpoint to post to. Defaults to when omitted. + /// + public OpenCodeGoChatClientAdapter(IHttpMessageHandlerFactory handlers, Uri? apiEndpoint = null) + { + ArgumentNullException.ThrowIfNull(handlers); + _handlers = handlers; + _apiEndpoint = apiEndpoint ?? DefaultApiEndpoint; + } + + /// Creates the adapter, reading off the host's own configuration. + /// The pipeline that holds the connection lifetime, the deadline, and the retry. + /// The host's own configuration. + /// The adapter. + public static OpenCodeGoChatClientAdapter CreateFromConfiguration(IHttpMessageHandlerFactory handlers, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + Uri? apiEndpoint = configuration[ApiEndpointConfigurationKey] is { } value + ? new Uri(value, UriKind.Absolute) + : null; + + return new OpenCodeGoChatClientAdapter(handlers, apiEndpoint); + } + + /// + public string Kind => ProviderKind; + + /// Releases the shared transport. The vendor clients built over it release nothing of their own. + public void Dispose() + { + _transport?.Dispose(); + } + + /// Builds the client of one entry, reading the key on the first build only. + public async ValueTask CreateClientAsync( + LlmProviderConfiguration entry, + ISecretResolverPort? secrets, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entry); + + if (_transport is null) + { + string apiKey = await secrets + .RequireAsync(KnownSecrets.OpenCodeGo, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + _transport = new HttpClient(_handlers.CreateHandler(HttpClientName), disposeHandler: false); + + _credential = new ApiKeyCredential(apiKey); + } + + return new ConversationRoutedChatClient(entry.Model, ClientFor); + } + + /// Gets the vendor client of one (model, conversation) pair, building it on the first ask. + private ChatClient ClientFor(string model, string sessionId) + { + return _clients.GetOrAdd((model, sessionId), key => + { + OpenAIClientOptions options = new() + { + Endpoint = _apiEndpoint, + Transport = new HttpClientPipelineTransport(_transport!), + }; + + options.AddPolicy(new OpenCodeGoHeaderPolicy(key.SessionId), PipelinePosition.BeforeTransport); + + return new OpenAIClient(_credential!, options).GetChatClient(key.Model); + }); + } + + /// Writes the two headers OpenCode Go's own docs ask for, last, so nothing after this undoes them. + private sealed class OpenCodeGoHeaderPolicy(string sessionId) : PipelinePolicy + { + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + Annotate(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + Annotate(message); + return ProcessNextAsync(message, pipeline, currentIndex); + } + + private void Annotate(PipelineMessage message) + { + message.Request.Headers.Set(SessionHeaderName, sessionId); + message.Request.Headers.Set("User-Agent", UserAgentValue); + } + } + + /// Routes each call to the vendor client of the conversation it belongs to. + private sealed class ConversationRoutedChatClient( + string model, + Func resolve) : IChatClient + { + public Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + return Route(options).GetResponseAsync(messages, options, cancellationToken); + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + return Route(options).GetStreamingResponseAsync(messages, options, cancellationToken); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + // The vendor clients this routes to share the adapter's one transport, which the + // adapter — not any conversation's view of it — owns. + } + + private IChatClient Route(ChatOptions? options) + { + if (options?.AdditionalProperties?.TryGetValue( + ChatRequestProperties.ConversationId, out string? conversationId) != true + || conversationId is not { Length: > 0 }) + { + throw new InvalidOperationException( + $"OpenCode Go requires a conversation id on every request. Set {ChatRequestProperties.ConversationId} in ChatOptions.AdditionalProperties."); + } + + return resolve(model, conversationId).AsIChatClient(); + } + } + } +} diff --git a/src/AgentCore.Infrastructure/PublicAPI.Unshipped.txt b/src/AgentCore.Infrastructure/PublicAPI.Unshipped.txt index 79e6eb83..748c76c3 100644 --- a/src/AgentCore.Infrastructure/PublicAPI.Unshipped.txt +++ b/src/AgentCore.Infrastructure/PublicAPI.Unshipped.txt @@ -102,12 +102,15 @@ const AgentCore.Infrastructure.Blobs.S3.S3BlobStoreAdapter.ProviderKind = "s3" - AgentCore.Infrastructure.Blobs.S3.S3BlobStore.LinkAsync(AgentCore.Application.Blobs.BlobRef! blob, System.TimeSpan lifetime, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Infrastructure.Blobs.S3.S3BlobStore.StatAsync(string! ownerId, string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort -AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort.ModelsDevCatalogPort(System.Net.Http.HttpClient! client, Microsoft.Extensions.Options.IOptions! options) -> void +AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort.ModelsDevCatalogPort(System.Net.Http.HttpClient! client, System.Uri? catalogEndpoint = null) -> void AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort.LookupAsync(string! provider, string! model, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask const AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort.HttpClientName = "agentcore.models-dev" -> string! -static AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort.Create(System.Net.Http.IHttpClientFactory! httpClients) -> AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort! +const AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort.CatalogEndpointConfigurationKey = "AgentCore:ModelsDev:CatalogEndpoint" -> string! +static AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort.Create(System.Net.Http.IHttpClientFactory! httpClients, System.Uri? catalogEndpoint = null) -> AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort! +static AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort.CreateFromConfiguration(System.Net.Http.IHttpClientFactory! httpClients, Microsoft.Extensions.Configuration.IConfiguration! configuration) -> AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort! +static readonly AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogPort.DefaultCatalogEndpoint -> System.Uri! AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter -AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.OpenCodeGoChatClientAdapter(System.Net.Http.IHttpMessageHandlerFactory! handlers) -> void +AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.OpenCodeGoChatClientAdapter(System.Net.Http.IHttpMessageHandlerFactory! handlers, System.Uri? apiEndpoint = null) -> void AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.Kind.get -> string! AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.Dispose() -> void AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.CreateClientAsync(AgentCore.Application.Configuration.Schema.LlmProviderConfiguration! entry, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -117,8 +120,6 @@ const AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.ApiKey const AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.HttpClientName = "agentcore.opencode-go" -> string! const AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.SessionHeaderName = "x-opencode-session" -> string! const AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.UserAgentValue = "agentcore/1.0" -> string! -static readonly AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.ApiEndpoint -> System.Uri! -AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogOptions -AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogOptions.ModelsDevCatalogOptions() -> void -AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogOptions.CatalogEndpoint.get -> System.Uri! -AgentCore.Infrastructure.Llm.ModelsDev.ModelsDevCatalogOptions.CatalogEndpoint.set -> void +const AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.ApiEndpointConfigurationKey = "AgentCore:OpenCodeGo:ApiEndpoint" -> string! +static readonly AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.DefaultApiEndpoint -> System.Uri! +static AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter.CreateFromConfiguration(System.Net.Http.IHttpMessageHandlerFactory! handlers, Microsoft.Extensions.Configuration.IConfiguration! configuration) -> AgentCore.Infrastructure.Llm.OpenCodeGo.OpenCodeGoChatClientAdapter! diff --git a/tests/AgentCore.Application.Tests/Fakes/RecordingChatClientAdapter.cs b/tests/AgentCore.Application.Tests/Fakes/RecordingChatClientAdapter.cs index bf18d0f0..55db81b3 100644 --- a/tests/AgentCore.Application.Tests/Fakes/RecordingChatClientAdapter.cs +++ b/tests/AgentCore.Application.Tests/Fakes/RecordingChatClientAdapter.cs @@ -15,9 +15,6 @@ internal sealed class RecordingChatClientAdapter(string kind) : IChatClientAdapt public string Kind { get; } = kind; - /// Gets or sets the value answers, or . - public int? ContextWindow { get; set; } - /// Gets the as names this adapter built a client for, in build order. public IReadOnlyList BuiltNames => _builtNames; @@ -50,11 +47,6 @@ public ValueTask CreateClientAsync( _clients[entry.As] = client; return ValueTask.FromResult(client); } - - public int? GetContextWindow(LlmProviderConfiguration entry) - { - return ContextWindow; - } } /// diff --git a/tests/AgentCore.Application.Tests/Llm/CompositeChatClientFactoryTests.cs b/tests/AgentCore.Application.Tests/Llm/CompositeChatClientFactoryTests.cs index 4daa3058..c5fbbc19 100644 --- a/tests/AgentCore.Application.Tests/Llm/CompositeChatClientFactoryTests.cs +++ b/tests/AgentCore.Application.Tests/Llm/CompositeChatClientFactoryTests.cs @@ -194,20 +194,39 @@ public async Task ADocumentWithNoModel_FailsAReferenceThatNamesNone() } // --------------------------------------------------------------------------------------------- - // GetContextWindow: forwards to the adapter of the entry the reference names. + // GetContextWindow: the factory resolves it from the catalog, once, while it builds. // --------------------------------------------------------------------------------------------- [Fact] - public async Task GetContextWindow_ANamedReference_ForwardsToItsOwnAdapter() + public async Task GetContextWindow_ANamedReference_ReadsItsEntrysCatalogAnswer() { - RecordingChatClientAdapter openai = new("openai") { ContextWindow = 128_000 }; - RecordingChatClientAdapter anthropic = new("anthropic") { ContextWindow = 200_000 }; + MapModelCatalogPort catalog = new MapModelCatalogPort() + .With("openai", "gpt-4.1-mini", 128_000) + .With("anthropic", "claude-sonnet-5", 200_000); - using CompositeChatClientFactory factory = await Create(TwoVendorsYaml, openai, anthropic); + using CompositeChatClientFactory factory = await Create( + TwoVendorsYaml, catalog, new RecordingChatClientAdapter("openai"), new RecordingChatClientAdapter("anthropic")); Assert.Equal(128_000, factory.GetContextWindow(new ModelReference { Ref = "reply" })); Assert.Equal(200_000, factory.GetContextWindow(new ModelReference { Ref = "fill" })); } + [Fact] + public async Task GetContextWindow_NoCatalogEntryForTheModel_AnswersNull() + { + using CompositeChatClientFactory factory = await Create( + OneVendorYaml, new MapModelCatalogPort(), new RecordingChatClientAdapter("openai")); + + Assert.Null(factory.GetContextWindow(new ModelReference { Ref = "reply" })); + } + + [Fact] + public async Task GetContextWindow_NoCatalogAtAll_AnswersNull() + { + using CompositeChatClientFactory factory = await Create(OneVendorYaml, new RecordingChatClientAdapter("openai")); + + Assert.Null(factory.GetContextWindow(new ModelReference { Ref = "reply" })); + } + // --------------------------------------------------------------------------------------------- // The temperature wrapper: the document keeps its setting, the vendor client stays one. // --------------------------------------------------------------------------------------------- @@ -372,7 +391,7 @@ public async Task TwoEntriesWithOneAsName_FailTheBuild() // --------------------------------------------------------------------------------------------- private static ValueTask Create(string yaml, params IChatClientAdapter[] adapters) { - return Create(yaml, null, adapters); + return Create(yaml, (ISecretResolverPort?)null, adapters); } private static ValueTask Create( @@ -384,6 +403,19 @@ private static ValueTask Create( ConfigurationLoader.LoadYaml(yaml), secrets, adapters, + cancellationToken: TestContext.Current.CancellationToken); + } + + private static ValueTask Create( + string yaml, + IModelCatalogPort catalog, + params IChatClientAdapter[] adapters) + { + return CompositeChatClientFactory.CreateAsync( + ConfigurationLoader.LoadYaml(yaml), + null, + adapters, + catalog, TestContext.Current.CancellationToken); } } diff --git a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreTests.cs b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreTests.cs index 1a28e8cb..dd35e061 100644 --- a/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/DependencyInjection/AddAgentCoreTests.cs @@ -720,9 +720,13 @@ public async Task TheAdapterOverload_LetsTheDocumentPickTheVendorByItsKind() { // 'kind: openai' selects the adapter registered under that kind. The host lists what it // supports, once, and the document decides which entry runs. - using StartedHost provider = await BuildAsync(OneAgentYaml, options => options.UseChatClients( - new FakeChatClientAdapter("openai", () => new FragmentingChatClient("routed")), - new FakeChatClientAdapter("anthropic", () => new FragmentingChatClient("wrong vendor")))); + MapModelCatalogPort catalog = new MapModelCatalogPort().With("openai", "gpt-4.1-mini", 128_000); + + using StartedHost provider = await BuildAsync(OneAgentYaml, options => options + .UseModelCatalog(catalog) + .UseChatClients( + new FakeChatClientAdapter("openai", () => new FragmentingChatClient("routed")), + new FakeChatClientAdapter("anthropic", () => new FragmentingChatClient("wrong vendor")))); ConversationSession session = provider.GetRequiredService().ForFactory("main").Create(); TurnResult turn = await session.RunTurnAsync("hello", TestContext.Current.CancellationToken); diff --git a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeChatClientAdapter.cs b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeChatClientAdapter.cs index 494e60cf..6e6e44c5 100644 --- a/tests/AgentCore.AspNetCore.Tests/Fakes/FakeChatClientAdapter.cs +++ b/tests/AgentCore.AspNetCore.Tests/Fakes/FakeChatClientAdapter.cs @@ -20,10 +20,5 @@ public ValueTask CreateClientAsync( { return ValueTask.FromResult(_client()); } - - public int? GetContextWindow(LlmProviderConfiguration entry) - { - return 128_000; - } } } diff --git a/tests/AgentCore.AspNetCore.Tests/Llm/OpenAI/OpenAiChatClientAdapterTests.cs b/tests/AgentCore.AspNetCore.Tests/Llm/OpenAI/OpenAiChatClientAdapterTests.cs index 5d447308..4ce55db3 100644 --- a/tests/AgentCore.AspNetCore.Tests/Llm/OpenAI/OpenAiChatClientAdapterTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Llm/OpenAI/OpenAiChatClientAdapterTests.cs @@ -108,7 +108,7 @@ public async Task TheComposite_ServesAnOpenAiDocumentThroughThisAdapter() Document(), resolver, [new OpenAiChatClientAdapter()], - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); IChatClient reply = factory.GetChatClient(new ModelReference { Ref = "reply" }); diff --git a/tests/AgentCore.Infrastructure.Tests/Llm/OpenAiContextWindowsTests.cs b/tests/AgentCore.Infrastructure.Tests/Llm/OpenAiContextWindowsTests.cs deleted file mode 100644 index 48704c95..00000000 --- a/tests/AgentCore.Infrastructure.Tests/Llm/OpenAiContextWindowsTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using AgentCore.Infrastructure.Llm.OpenAI; -using Xunit; - -namespace AgentCore.Infrastructure.Tests.Llm -{ - /// The built-in OpenAI context window table. - public sealed class OpenAiContextWindowsTests - { - [Fact] - public void Lookup_ExactId_ReturnsItsValue() - { - Assert.Equal(1_050_000, OpenAiContextWindows.Lookup("gpt-6-astra")); - } - - [Fact] - public void Lookup_DatedSnapshotId_ResolvesThroughTheBaseId() - { - Assert.Equal(1_047_576, OpenAiContextWindows.Lookup("gpt-4.1-mini-2025-04-14")); - } - - [Fact] - public void Lookup_LongerPrefixWins_OverAShorterPrefixThatAlsoMatches() - { - // "gpt-5.4" and "gpt-5.4-nano" are both entries, and both match "gpt-5.4-nano-2026-03-17". - // The two have different windows, so this proves the longer prefix wins rather than the - // first or shortest one. - Assert.NotEqual(OpenAiContextWindows.Lookup("gpt-5.4"), OpenAiContextWindows.Lookup("gpt-5.4-nano")); - Assert.Equal(400_000, OpenAiContextWindows.Lookup("gpt-5.4-nano-2026-03-17")); - } - - [Fact] - public void Lookup_UnknownId_ReturnsNull() - { - Assert.Null(OpenAiContextWindows.Lookup("gpt-does-not-exist")); - } - } -} diff --git a/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoChatClientAdapterTests.cs b/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoChatClientAdapterTests.cs new file mode 100644 index 00000000..8c9281f2 --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoChatClientAdapterTests.cs @@ -0,0 +1,223 @@ +using System.Net; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Llm; +using AgentCore.Application.Secrets; +using AgentCore.Infrastructure.Llm.OpenCodeGo; +using AgentCore.Infrastructure.Tests.Fakes; +using AgentCore.Infrastructure.Tests.Tools; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using Xunit; + +namespace AgentCore.Infrastructure.Tests.Llm.OpenCodeGo +{ + /// + /// The OpenCode Go adapter: an OpenAI-compatible endpoint that refuses any request missing its two + /// vendor-specific headers. + /// + /// + /// Every test here reaches the network zero times, the same way OpenAiChatClientAdapterTests + /// does: stands in for the outbound pipeline, and + /// stands in for opencode.ai. + /// + public sealed class OpenCodeGoChatClientAdapterTests + { + private const string ApiKey = "sk-test-not-a-real-key"; + + private const string Answer = + /*lang=json,strict*/ + """ + { + "id": "gen-test", + "object": "chat.completion", + "created": 1790012523, + "model": "kimi-k2.6", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": { "role": "assistant", "content": "OK" } + } + ] + } + """; + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Fact] + public void TheAdapter_ServesTheOpenCodeGoKind() + { + Assert.Equal("opencode-go", new OpenCodeGoChatClientAdapter(new StubHandlerFactory( + StubHttpMessageHandler.Answering(HttpStatusCode.OK, Answer))).Kind); + } + + [Fact] + public async Task CreateClientAsync_OpensTheNamedClientOnThePipeline() + { + StubHandlerFactory pipeline = new(StubHttpMessageHandler.Answering(HttpStatusCode.OK, Answer)); + + _ = await BuildClientAsync(pipeline); + + Assert.Equal(["agentcore.opencode-go"], pipeline.Names); + } + + [Fact] + public async Task NoApiKeyAnywhere_FailsAndSaysWhereToPutOne() + { + string? saved = Environment.GetEnvironmentVariable(OpenCodeGoChatClientAdapter.ApiKeyVariableName); + Environment.SetEnvironmentVariable(OpenCodeGoChatClientAdapter.ApiKeyVariableName, null); + + try + { + OpenCodeGoChatClientAdapter adapter = new(new StubHandlerFactory( + StubHttpMessageHandler.Answering(HttpStatusCode.OK, Answer))); + + SecretResolutionException failure = await Assert.ThrowsAsync( + async () => await adapter.CreateClientAsync(Entry(), new MapSecretResolver(), Token)); + + Assert.Contains(OpenCodeGoChatClientAdapter.ApiKeySecretName, failure.Message, StringComparison.Ordinal); + Assert.Contains(OpenCodeGoChatClientAdapter.ApiKeyVariableName, failure.Message, StringComparison.Ordinal); + } + finally + { + Environment.SetEnvironmentVariable(OpenCodeGoChatClientAdapter.ApiKeyVariableName, saved); + } + } + + [Fact] + public async Task TheApiKey_ResolvesOnceHoweverManyEntriesTheDocumentDeclares() + { + CountingSecretResolver resolver = new(); + OpenCodeGoChatClientAdapter adapter = new(new StubHandlerFactory( + StubHttpMessageHandler.Answering(HttpStatusCode.OK, Answer))); + + _ = await adapter.CreateClientAsync(Entry("reply", "kimi-k2.6"), resolver, Token); + _ = await adapter.CreateClientAsync(Entry("fill", "glm-5.3"), resolver, Token); + + Assert.Equal(1, resolver.Resolutions); + } + + [Fact] + public async Task EveryRequest_PostsToTheOpenCodeGoChatCompletionsRoute() + { + StubHttpMessageHandler endpoint = StubHttpMessageHandler.Answering(HttpStatusCode.OK, Answer); + IChatClient client = await BuildClientAsync(new StubHandlerFactory(endpoint)); + + _ = await client.GetResponseAsync("hi", Options("conv-1"), Token); + + HttpRequestMessage request = Assert.Single(endpoint.Requests); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://opencode.ai", request.RequestUri!.GetLeftPart(UriPartial.Authority)); + Assert.Equal("/zen/go/v1/chat/completions", request.RequestUri.AbsolutePath); + } + + [Fact] + public async Task EveryRequest_CarriesTheSessionHeaderAndTheUserAgentOpenCodeGoAsksFor() + { + StubHttpMessageHandler endpoint = StubHttpMessageHandler.Answering(HttpStatusCode.OK, Answer); + IChatClient client = await BuildClientAsync(new StubHandlerFactory(endpoint)); + + _ = await client.GetResponseAsync("hi", Options("conv-1"), Token); + + HttpRequestMessage request = Assert.Single(endpoint.Requests); + Assert.True(request.Headers.Contains(OpenCodeGoChatClientAdapter.SessionHeaderName)); + Assert.Equal( + OpenCodeGoChatClientAdapter.UserAgentValue, + request.Headers.UserAgent.ToString()); + } + + [Fact] + public async Task TwoRequests_CarryTheSameSessionId() + { + StubHttpMessageHandler endpoint = StubHttpMessageHandler.Answering(HttpStatusCode.OK, Answer); + IChatClient client = await BuildClientAsync(new StubHandlerFactory(endpoint)); + + _ = await client.GetResponseAsync("first", Options("conv-1"), Token); + _ = await client.GetResponseAsync("second", Options("conv-1"), Token); + + Assert.Equal(2, endpoint.Requests.Count); + IEnumerable first = endpoint.Requests[0].Headers.GetValues(OpenCodeGoChatClientAdapter.SessionHeaderName); + IEnumerable second = endpoint.Requests[1].Headers.GetValues(OpenCodeGoChatClientAdapter.SessionHeaderName); + Assert.Equal(first, second); + } + + [Fact] + public async Task TwoConversations_CarryDifferentSessionIds() + { + StubHttpMessageHandler endpoint = StubHttpMessageHandler.Answering(HttpStatusCode.OK, Answer); + IChatClient client = await BuildClientAsync(new StubHandlerFactory(endpoint)); + + _ = await client.GetResponseAsync("hi", Options("conv-1"), Token); + _ = await client.GetResponseAsync("hi", Options("conv-2"), Token); + + Assert.Equal(2, endpoint.Requests.Count); + IEnumerable first = endpoint.Requests[0].Headers.GetValues(OpenCodeGoChatClientAdapter.SessionHeaderName); + IEnumerable second = endpoint.Requests[1].Headers.GetValues(OpenCodeGoChatClientAdapter.SessionHeaderName); + Assert.NotEqual(first, second); + } + + [Fact] + public async Task ARequestOutsideAnyConversation_Throws() + { + StubHttpMessageHandler endpoint = StubHttpMessageHandler.Answering(HttpStatusCode.OK, Answer); + IChatClient client = await BuildClientAsync(new StubHandlerFactory(endpoint)); + + await Assert.ThrowsAsync( + async () => await client.GetResponseAsync("hi", options: null, Token)); + + Assert.Empty(endpoint.Requests); + } + + [Fact] + public async Task CreateClientAsync_OpensNoSocket() + { + StubHttpMessageHandler endpoint = StubHttpMessageHandler.Answering(HttpStatusCode.OK, Answer); + + _ = await BuildClientAsync(new StubHandlerFactory(endpoint)); + + Assert.Empty(endpoint.Requests); + } + + private static async Task BuildClientAsync(StubHandlerFactory pipeline) + { + OpenCodeGoChatClientAdapter adapter = new(pipeline); + MapSecretResolver resolver = new MapSecretResolver() + .With(OpenCodeGoChatClientAdapter.ApiKeySecretName, ApiKey); + + return await adapter.CreateClientAsync(Entry(), resolver, Token); + } + + private static ChatOptions Options(string conversationId) + { + return new ChatOptions + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [ChatRequestProperties.ConversationId] = conversationId, + }, + }; + } + + private static LlmProviderConfiguration Entry(string asName = "reply", string model = "kimi-k2.6") + { + return new() + { + Kind = OpenCodeGoChatClientAdapter.ProviderKind, + Model = model, + As = asName, + }; + } + + /// A resolver that counts its reads, so a test proves the key resolves once. + private sealed class CountingSecretResolver : Application.Ports.ISecretResolverPort + { + public int Resolutions { get; private set; } + + public ValueTask TryResolveAsync(string name, CancellationToken cancellationToken = default) + { + Resolutions++; + return ValueTask.FromResult(ApiKey); + } + } + } +} diff --git a/tests/AgentCore.TestSupport/MapModelCatalogPort.cs b/tests/AgentCore.TestSupport/MapModelCatalogPort.cs new file mode 100644 index 00000000..4f7626ee --- /dev/null +++ b/tests/AgentCore.TestSupport/MapModelCatalogPort.cs @@ -0,0 +1,29 @@ +using AgentCore.Application.Llm; +using AgentCore.Application.Ports; + +namespace AgentCore.TestSupport +{ + /// + /// A catalog that holds a map of provider and model pairs to their entry, and never leaves the + /// process to answer. + /// + public sealed class MapModelCatalogPort : IModelCatalogPort + { + private readonly Dictionary<(string Provider, string Model), ModelCatalogEntry> _values = new(); + + /// Adds one provider and model pair's entry, and returns this catalog. + public MapModelCatalogPort With(string provider, string model, int contextWindow) + { + _values[(provider, model)] = new ModelCatalogEntry(contextWindow); + return this; + } + + /// + public ValueTask LookupAsync( + string provider, string model, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult( + _values.TryGetValue((provider, model), out ModelCatalogEntry? entry) ? entry : null); + } + } +} From c13f83d8ca0b60d8bae3e6c1fd4aa0a55f4684b5 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 21 Sep 2026 14:44:59 -0600 Subject: [PATCH 3/5] feat: enhance OpenCodeGoChatClientAdapter with reasoning effort management and add corresponding tests --- .../OpenCodeGo/OpenCodeGoChatClientAdapter.cs | 83 ++++++++- .../OpenCodeGoReasoningEffortTests.cs | 157 ++++++++++++++++++ 2 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoReasoningEffortTests.cs diff --git a/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs b/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs index 3f2c2041..a2683adb 100644 --- a/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs +++ b/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs @@ -1,6 +1,12 @@ +// The reasoning-effort type is marked for evaluation by the SDK (OPENAI001). It is pinned at +// OpenAI 2.13.0 and covered by OpenCodeGoReasoningEffortTests, which fail loudly if a bump moves it. +#pragma warning disable OPENAI001 + using System.ClientModel; using System.ClientModel.Primitives; using System.Collections.Concurrent; +using System.Globalization; +using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Llm; using AgentCore.Application.Ports; @@ -9,6 +15,8 @@ using Microsoft.Extensions.Configuration; using OpenAI; using ChatClient = OpenAI.Chat.ChatClient; +using ChatCompletionOptions = OpenAI.Chat.ChatCompletionOptions; +using ChatReasoningEffortLevel = OpenAI.Chat.ChatReasoningEffortLevel; namespace AgentCore.Infrastructure.Llm.OpenCodeGo { @@ -42,7 +50,10 @@ public sealed class OpenCodeGoChatClientAdapter : IChatClientAdapter, IDisposabl public static readonly Uri DefaultApiEndpoint = new("https://opencode.ai/zen/go/v1", UriKind.Absolute); /// One vendor client per (model, conversation) pair, built once and reused. - private readonly ConcurrentDictionary<(string Model, string SessionId), ChatClient> _clients = new(); + private readonly ConcurrentDictionary<(string Model, string SessionId), IChatClient> _clients = new(); + + /// The reasoningEffort each model was declared with, read once per model. + private readonly ConcurrentDictionary _effortByModel = new(); private readonly IHttpMessageHandlerFactory _handlers; @@ -107,11 +118,13 @@ public async ValueTask CreateClientAsync( _credential = new ApiKeyCredential(apiKey); } + _effortByModel[entry.Model] = entry.ReasoningEffort; + return new ConversationRoutedChatClient(entry.Model, ClientFor); } /// Gets the vendor client of one (model, conversation) pair, building it on the first ask. - private ChatClient ClientFor(string model, string sessionId) + private IChatClient ClientFor(string model, string sessionId) { return _clients.GetOrAdd((model, sessionId), key => { @@ -123,10 +136,70 @@ private ChatClient ClientFor(string model, string sessionId) options.AddPolicy(new OpenCodeGoHeaderPolicy(key.SessionId), PipelinePosition.BeforeTransport); - return new OpenAIClient(_credential!, options).GetChatClient(key.Model); + ChatClient vendorClient = new OpenAIClient(_credential!, options).GetChatClient(key.Model); + string? effort = _effortByModel.GetValueOrDefault(key.Model); + + return WithReasoningEffort(vendorClient.AsIChatClient(), effort); }); } + /// Puts reasoning_effort on every request this client sends, unless the caller already set one. + internal static IChatClient WithReasoningEffort(IChatClient client, string? effort) + { + ChatReasoningEffortLevel? level = effort is { Length: > 0 } value ? Level(value) : (ChatReasoningEffortLevel?)null; + + if (level is not { } chosen) + { + return client; + } + + return client + .AsBuilder() + .ConfigureOptions(options => + { + Func? caller = options.RawRepresentationFactory; + + options.RawRepresentationFactory = inner => + { + if (caller?.Invoke(inner) is not ChatCompletionOptions raw) + { + raw = new ChatCompletionOptions(); + } + + raw.ReasoningEffortLevel ??= chosen; + + return raw; + }; + }) + .Build(); + } + + /// Reads one reasoningEffort value. + /// The value the document wrote. + /// The vendor level. + /// The value is not one this vendor knows. + private static ChatReasoningEffortLevel Level(string effort) + { + return effort.ToLowerInvariant() switch + { + "none" => ChatReasoningEffortLevel.None, + "minimal" => ChatReasoningEffortLevel.Minimal, + "low" => ChatReasoningEffortLevel.Low, + "medium" => ChatReasoningEffortLevel.Medium, + "high" => ChatReasoningEffortLevel.High, + _ => throw new ConfigurationLoadException(new ConfigurationError + { + Pointer = "/providers/llm", + Message = string.Format( + CultureInfo.InvariantCulture, + "reasoningEffort '{0}' is not one this vendor knows. Write none, minimal, low, " + + "medium or high.", + effort), + Check = ConfigurationCheck.ReferenceResolution, + }), + }; + } + /// Writes the two headers OpenCode Go's own docs ask for, last, so nothing after this undoes them. private sealed class OpenCodeGoHeaderPolicy(string sessionId) : PipelinePolicy { @@ -152,7 +225,7 @@ private void Annotate(PipelineMessage message) /// Routes each call to the vendor client of the conversation it belongs to. private sealed class ConversationRoutedChatClient( string model, - Func resolve) : IChatClient + Func resolve) : IChatClient { public Task GetResponseAsync( IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) @@ -188,7 +261,7 @@ private IChatClient Route(ChatOptions? options) $"OpenCode Go requires a conversation id on every request. Set {ChatRequestProperties.ConversationId} in ChatOptions.AdditionalProperties."); } - return resolve(model, conversationId).AsIChatClient(); + return resolve(model, conversationId); } } } diff --git a/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoReasoningEffortTests.cs b/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoReasoningEffortTests.cs new file mode 100644 index 00000000..3c91d674 --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoReasoningEffortTests.cs @@ -0,0 +1,157 @@ +#pragma warning disable OPENAI001 + +using System.Net; +using AgentCore.Application.Configuration.Parsing; +using AgentCore.Application.Configuration.Schema; +using AgentCore.Application.Secrets; +using AgentCore.Infrastructure.Llm.OpenCodeGo; +using AgentCore.Infrastructure.Tests.Fakes; +using AgentCore.Infrastructure.Tests.Tools; +using AgentCore.TestSupport; +using Microsoft.Extensions.AI; +using OpenAI.Chat; +using Xunit; + +namespace AgentCore.Infrastructure.Tests.Llm.OpenCodeGo +{ + /// + /// providers.llm[].reasoningEffort on the OpenCode Go adapter, and why a document ever + /// writes it. + /// + /// + /// OpenCode Go's own docs are silent on the wire field; a real request against the live + /// endpoint (2026-09-21) confirmed reasoning_effort on the chat-completions body, honoured + /// by at least one hosted model (reasoning token count moved with the level). These tests pin + /// that shape offline; none of them reach the network. + /// + public sealed class OpenCodeGoReasoningEffortTests + { + private const string ApiKey = "sk-test-not-a-real-key"; + + private const string Answer = + /*lang=json,strict*/ + """ + { + "id": "gen-test", + "object": "chat.completion", + "created": 1790012523, + "model": "kimi-k2.6", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": { "role": "assistant", "content": "OK" } + } + ] + } + """; + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Theory] + [InlineData("none")] + [InlineData("minimal")] + [InlineData("low")] + [InlineData("medium")] + [InlineData("high")] + public async Task EveryValueTheSchemaAllows_ReachesTheRequestTheVendorSees(string effort) + { + CapturingChatClient inner = new(); + + IChatClient client = OpenCodeGoChatClientAdapter.WithReasoningEffort(inner, effort); + _ = await client.GetResponseAsync("hi", cancellationToken: Token); + + ChatCompletionOptions raw = Assert.IsType( + inner.Seen!.RawRepresentationFactory!(inner)); + + Assert.Equal(effort, raw.ReasoningEffortLevel.ToString()); + } + + [Fact] + public async Task AnEntryThatNamesNoEffort_SendsNoReasoningValueAtAll() + { + CapturingChatClient inner = new(); + + IChatClient client = OpenCodeGoChatClientAdapter.WithReasoningEffort(inner, effort: null); + _ = await client.GetResponseAsync("hi", cancellationToken: Token); + + // No wrapper was applied at all: the caller's own client, unmodified, saw the request. + Assert.Same(inner, client); + Assert.Null(inner.Seen); + } + + [Fact] + public async Task RawOptionsTheCallerBuiltItself_KeepTheValueItAlreadyHolds() + { + CapturingChatClient inner = new(); + ChatCompletionOptions mine = new() + { + ReasoningEffortLevel = ChatReasoningEffortLevel.High, + MaxOutputTokenCount = 42, + }; + + IChatClient client = OpenCodeGoChatClientAdapter.WithReasoningEffort(inner, "low"); + _ = await client.GetResponseAsync( + "hi", + new ChatOptions { RawRepresentationFactory = _ => mine }, + Token); + + Assert.Same(mine, inner.Seen!.RawRepresentationFactory!(inner)); + Assert.Equal(ChatReasoningEffortLevel.High, mine.ReasoningEffortLevel); + Assert.Equal(42, mine.MaxOutputTokenCount); + } + + [Fact] + public async Task AnEntryWithAnEffort_PutsReasoningEffortOnTheRequestTheVendorSees() + { + string? body = null; + StubHttpMessageHandler endpoint = new(request => + { + body = request.Content!.ReadAsStringAsync(Token).GetAwaiter().GetResult(); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(Answer, System.Text.Encoding.UTF8, "application/json"), + }; + }); + OpenCodeGoChatClientAdapter adapter = new(new StubHandlerFactory(endpoint)); + MapSecretResolver secrets = new MapSecretResolver() + .With(OpenCodeGoChatClientAdapter.ApiKeySecretName, ApiKey); + + IChatClient client = await adapter.CreateClientAsync( + new LlmProviderConfiguration + { + Kind = OpenCodeGoChatClientAdapter.ProviderKind, + Model = "kimi-k2.6", + As = "reply", + ReasoningEffort = "high", + }, + secrets, + Token); + + _ = await client.GetResponseAsync( + "hi", + new ChatOptions + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [Application.Llm.ChatRequestProperties.ConversationId] = "conv-1", + }, + }, + Token); + + Assert.Single(endpoint.Requests); + Assert.Contains("\"reasoning_effort\":\"high\"", body, StringComparison.Ordinal); + } + + [Fact] + public void AValueThisVendorDoesNotKnow_FailsAndSaysWhatIsAllowed() + { + ConfigurationLoadException failure = Assert.Throws( + () => OpenCodeGoChatClientAdapter.WithReasoningEffort(new CapturingChatClient(), "exhaustive")); + + Assert.Contains(failure.Errors, error => error.Pointer == "/providers/llm"); + Assert.Contains("exhaustive", failure.Message, StringComparison.Ordinal); + Assert.Contains("none", failure.Message, StringComparison.Ordinal); + } + } +} From 5e978ea8c58e6b3e83dd2d5f93006c3ba12e1fee Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 21 Sep 2026 16:52:55 -0600 Subject: [PATCH 4/5] feat: refactor OpenCodeGoChatClientAdapter to use AsyncLocal for session management and simplify client creation --- .../OpenCodeGo/OpenCodeGoChatClientAdapter.cs | 81 +++++++++++-------- 1 file changed, 47 insertions(+), 34 deletions(-) diff --git a/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs b/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs index a2683adb..9bc43a20 100644 --- a/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs +++ b/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs @@ -4,8 +4,8 @@ using System.ClientModel; using System.ClientModel.Primitives; -using System.Collections.Concurrent; using System.Globalization; +using System.Runtime.CompilerServices; using AgentCore.Application.Configuration.Parsing; using AgentCore.Application.Configuration.Schema; using AgentCore.Application.Llm; @@ -14,7 +14,6 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using OpenAI; -using ChatClient = OpenAI.Chat.ChatClient; using ChatCompletionOptions = OpenAI.Chat.ChatCompletionOptions; using ChatReasoningEffortLevel = OpenAI.Chat.ChatReasoningEffortLevel; @@ -49,11 +48,8 @@ public sealed class OpenCodeGoChatClientAdapter : IChatClientAdapter, IDisposabl /// The endpoint this adapter posts to, unless a host names its own. public static readonly Uri DefaultApiEndpoint = new("https://opencode.ai/zen/go/v1", UriKind.Absolute); - /// One vendor client per (model, conversation) pair, built once and reused. - private readonly ConcurrentDictionary<(string Model, string SessionId), IChatClient> _clients = new(); - - /// The reasoningEffort each model was declared with, read once per model. - private readonly ConcurrentDictionary _effortByModel = new(); + /// The conversation id the header policy reads on send. + private readonly AsyncLocal _currentSessionId = new(); private readonly IHttpMessageHandlerFactory _handlers; @@ -63,6 +59,8 @@ public sealed class OpenCodeGoChatClientAdapter : IChatClientAdapter, IDisposabl private ApiKeyCredential? _credential; + private OpenAIClient? _vendorClient; + /// Creates the adapter over the outbound pipeline of the host. /// The pipeline that holds the connection lifetime, the deadline, and the retry. /// @@ -118,29 +116,30 @@ public async ValueTask CreateClientAsync( _credential = new ApiKeyCredential(apiKey); } - _effortByModel[entry.Model] = entry.ReasoningEffort; + return new ConversationRoutedChatClient( + () => BuildClientFor(entry.Model, entry.ReasoningEffort), _currentSessionId); + } - return new ConversationRoutedChatClient(entry.Model, ClientFor); + /// Gets the vendor client of one model, built fresh over the one shared vendor connection. + private IChatClient BuildClientFor(string model, string? effort) + { + _vendorClient ??= CreateVendorClient(); + + return WithReasoningEffort(_vendorClient.GetChatClient(model).AsIChatClient(), effort); } - /// Gets the vendor client of one (model, conversation) pair, building it on the first ask. - private IChatClient ClientFor(string model, string sessionId) + /// Builds the one vendor connection every model shares, with the session header wired to . + private OpenAIClient CreateVendorClient() { - return _clients.GetOrAdd((model, sessionId), key => + OpenAIClientOptions options = new() { - OpenAIClientOptions options = new() - { - Endpoint = _apiEndpoint, - Transport = new HttpClientPipelineTransport(_transport!), - }; - - options.AddPolicy(new OpenCodeGoHeaderPolicy(key.SessionId), PipelinePosition.BeforeTransport); + Endpoint = _apiEndpoint, + Transport = new HttpClientPipelineTransport(_transport!), + }; - ChatClient vendorClient = new OpenAIClient(_credential!, options).GetChatClient(key.Model); - string? effort = _effortByModel.GetValueOrDefault(key.Model); + options.AddPolicy(new OpenCodeGoHeaderPolicy(_currentSessionId), PipelinePosition.BeforeTransport); - return WithReasoningEffort(vendorClient.AsIChatClient(), effort); - }); + return new OpenAIClient(_credential!, options); } /// Puts reasoning_effort on every request this client sends, unless the caller already set one. @@ -201,7 +200,7 @@ private static ChatReasoningEffortLevel Level(string effort) } /// Writes the two headers OpenCode Go's own docs ask for, last, so nothing after this undoes them. - private sealed class OpenCodeGoHeaderPolicy(string sessionId) : PipelinePolicy + private sealed class OpenCodeGoHeaderPolicy(AsyncLocal currentSessionId) : PipelinePolicy { public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) { @@ -217,26 +216,40 @@ public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyListRoutes each call to the vendor client of the conversation it belongs to. + /// Routes each call to the vendor client of its model, after stamping the conversation id the header policy reads. private sealed class ConversationRoutedChatClient( - string model, - Func resolve) : IChatClient + Func resolve, + AsyncLocal currentSessionId) : IChatClient { public Task GetResponseAsync( IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { - return Route(options).GetResponseAsync(messages, options, cancellationToken); + currentSessionId.Value = RequireConversationId(options); + return resolve().GetResponseAsync(messages, options, cancellationToken); } - public IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) { - return Route(options).GetStreamingResponseAsync(messages, options, cancellationToken); + currentSessionId.Value = RequireConversationId(options); + + await foreach (ChatResponseUpdate update in resolve() + .GetStreamingResponseAsync(messages, options, cancellationToken) + .ConfigureAwait(false)) + { + yield return update; + } } public object? GetService(Type serviceType, object? serviceKey = null) @@ -247,11 +260,11 @@ public IAsyncEnumerable GetStreamingResponseAsync( public void Dispose() { - // The vendor clients this routes to share the adapter's one transport, which the + // The vendor client this routes to shares the adapter's one transport, which the // adapter — not any conversation's view of it — owns. } - private IChatClient Route(ChatOptions? options) + private static string RequireConversationId(ChatOptions? options) { if (options?.AdditionalProperties?.TryGetValue( ChatRequestProperties.ConversationId, out string? conversationId) != true @@ -261,7 +274,7 @@ private IChatClient Route(ChatOptions? options) $"OpenCode Go requires a conversation id on every request. Set {ChatRequestProperties.ConversationId} in ChatOptions.AdditionalProperties."); } - return resolve(model, conversationId); + return conversationId; } } } From feb26110637ee2a3e52021dd66602bacf8fd4633 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Mon, 21 Sep 2026 20:26:30 -0600 Subject: [PATCH 5/5] feat: update OpenCodeGoChatClientAdapter to use Responses API and adjust reasoning effort handling --- .../Conversation/ChatConversationTitler.cs | 13 +++- .../OpenCodeGo/OpenCodeGoChatClientAdapter.cs | 51 +++++++------ .../ChatConversationTitlerTests.cs | 22 ++++++ .../OpenCodeGoChatClientAdapterTests.cs | 37 +++++++--- .../OpenCodeGoReasoningEffortTests.cs | 72 ++++++++++++------- 5 files changed, 135 insertions(+), 60 deletions(-) diff --git a/src/AgentCore.Application/Conversation/ChatConversationTitler.cs b/src/AgentCore.Application/Conversation/ChatConversationTitler.cs index bcea874d..5dcbfa80 100644 --- a/src/AgentCore.Application/Conversation/ChatConversationTitler.cs +++ b/src/AgentCore.Application/Conversation/ChatConversationTitler.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Text; +using AgentCore.Application.Llm; using AgentCore.Application.Ports; using AgentCore.Application.Transcript; using Microsoft.Extensions.AI; @@ -99,10 +100,20 @@ private async IAsyncEnumerable NameAsync( new ChatMessage(ChatRole.User, Transcript(messages.Take(MaxMessages))), ]; + // The title runs outside a turn, so the stamp that tags turn requests never sees it. Adapters + // that route on the conversation id (OpenCode Go refuses a request without one) read this. + ChatOptions options = new() + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [ChatRequestProperties.ConversationId] = conversationId, + }, + }; + StringBuilder title = new(); await foreach (ChatResponseUpdate? update in client - .GetStreamingResponseAsync(prompt, options: null, cancellationToken) + .GetStreamingResponseAsync(prompt, options, cancellationToken) .ConfigureAwait(false)) { string piece = update.Text; diff --git a/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs b/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs index 9bc43a20..1469e399 100644 --- a/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs +++ b/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs @@ -14,8 +14,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using OpenAI; -using ChatCompletionOptions = OpenAI.Chat.ChatCompletionOptions; -using ChatReasoningEffortLevel = OpenAI.Chat.ChatReasoningEffortLevel; +using OpenAI.Responses; namespace AgentCore.Infrastructure.Llm.OpenCodeGo { @@ -120,12 +119,15 @@ public async ValueTask CreateClientAsync( () => BuildClientFor(entry.Model, entry.ReasoningEffort), _currentSessionId); } - /// Gets the vendor client of one model, built fresh over the one shared vendor connection. + /// + /// Gets the vendor client of one model, built fresh over the one shared vendor connection. + /// It speaks the Responses API: OpenCode Go serves some models on that route only. + /// private IChatClient BuildClientFor(string model, string? effort) { _vendorClient ??= CreateVendorClient(); - return WithReasoningEffort(_vendorClient.GetChatClient(model).AsIChatClient(), effort); + return WithResponseDefaults(_vendorClient.GetResponsesClient().AsIChatClient(model), effort); } /// Builds the one vendor connection every model shares, with the session header wired to . @@ -142,15 +144,14 @@ private OpenAIClient CreateVendorClient() return new OpenAIClient(_credential!, options); } - /// Puts reasoning_effort on every request this client sends, unless the caller already set one. - internal static IChatClient WithReasoningEffort(IChatClient client, string? effort) + /// + /// Puts store: false and reasoning.effort on every request this client sends, unless the + /// caller already set them. Without store: false the vendor client reads the response id as a + /// server-side conversation, which clashes with the history AgentCore keeps itself. + /// + internal static IChatClient WithResponseDefaults(IChatClient client, string? effort) { - ChatReasoningEffortLevel? level = effort is { Length: > 0 } value ? Level(value) : (ChatReasoningEffortLevel?)null; - - if (level is not { } chosen) - { - return client; - } + ResponseReasoningEffortLevel? level = effort is { Length: > 0 } value ? Level(value) : (ResponseReasoningEffortLevel?)null; return client .AsBuilder() @@ -160,12 +161,20 @@ internal static IChatClient WithReasoningEffort(IChatClient client, string? effo options.RawRepresentationFactory = inner => { - if (caller?.Invoke(inner) is not ChatCompletionOptions raw) + if (caller?.Invoke(inner) is not CreateResponseOptions raw) { - raw = new ChatCompletionOptions(); + raw = new CreateResponseOptions(); } - raw.ReasoningEffortLevel ??= chosen; + raw.StoredOutputEnabled ??= false; + + if (level is { } chosen) + { + raw.ReasoningOptions ??= new ResponseReasoningOptions + { + ReasoningEffortLevel = chosen, + }; + } return raw; }; @@ -177,15 +186,15 @@ internal static IChatClient WithReasoningEffort(IChatClient client, string? effo /// The value the document wrote. /// The vendor level. /// The value is not one this vendor knows. - private static ChatReasoningEffortLevel Level(string effort) + private static ResponseReasoningEffortLevel Level(string effort) { return effort.ToLowerInvariant() switch { - "none" => ChatReasoningEffortLevel.None, - "minimal" => ChatReasoningEffortLevel.Minimal, - "low" => ChatReasoningEffortLevel.Low, - "medium" => ChatReasoningEffortLevel.Medium, - "high" => ChatReasoningEffortLevel.High, + "none" => ResponseReasoningEffortLevel.None, + "minimal" => ResponseReasoningEffortLevel.Minimal, + "low" => ResponseReasoningEffortLevel.Low, + "medium" => ResponseReasoningEffortLevel.Medium, + "high" => ResponseReasoningEffortLevel.High, _ => throw new ConfigurationLoadException(new ConfigurationError { Pointer = "/providers/llm", diff --git a/tests/AgentCore.Application.Tests/Conversation/ChatConversationTitlerTests.cs b/tests/AgentCore.Application.Tests/Conversation/ChatConversationTitlerTests.cs index 0fd89809..bd7aebf1 100644 --- a/tests/AgentCore.Application.Tests/Conversation/ChatConversationTitlerTests.cs +++ b/tests/AgentCore.Application.Tests/Conversation/ChatConversationTitlerTests.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using AgentCore.Application.Conversation; using AgentCore.Application.Conversation.Memory; +using AgentCore.Application.Llm; using AgentCore.Application.Transcript; using Microsoft.Extensions.AI; using Xunit; @@ -196,6 +197,24 @@ public async Task GenerateFromAsync_Always_SendsTheInstructionAsTheSystemMessage }); } + [Fact] + public async Task EveryRequest_CarriesTheConversationIdAnAdapterRoutesOn() + { + // Arrange: the title runs outside a turn, so nothing else stamps the id on the request. + InMemoryConversationStore conversations = new(); + _ = await conversations.CreateAsync("c1", Token); + StubChatClient client = new("title"); + ChatConversationTitler titler = new(conversations, client); + + // Act + _ = await CollectAsync(titler.GenerateFromAsync("c1", Said("my belt squeaks"), Token)); + + // Assert + Assert.True(client.SeenOptions!.AdditionalProperties!.TryGetValue( + ChatRequestProperties.ConversationId, out string? conversationId)); + Assert.Equal("c1", conversationId); + } + private static List Said(string words) { return [new ChatMessage(ChatRole.User, words)]; @@ -229,6 +248,8 @@ private sealed class StubChatClient(params string[] pieces) : IChatClient { public List Seen { get; } = []; + public ChatOptions? SeenOptions { get; private set; } + public Task GetResponseAsync( IEnumerable messages, ChatOptions? options = null, @@ -243,6 +264,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) { Seen.AddRange(messages); + SeenOptions = options; foreach (string piece in pieces) { diff --git a/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoChatClientAdapterTests.cs b/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoChatClientAdapterTests.cs index 8c9281f2..091c2c47 100644 --- a/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoChatClientAdapterTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoChatClientAdapterTests.cs @@ -24,21 +24,36 @@ public sealed class OpenCodeGoChatClientAdapterTests { private const string ApiKey = "sk-test-not-a-real-key"; + /// A real Responses API body from opencode.ai (2026-09-21), trimmed to what the SDK reads. private const string Answer = /*lang=json,strict*/ """ { - "id": "gen-test", - "object": "chat.completion", - "created": 1790012523, - "model": "kimi-k2.6", - "choices": [ + "id": "resp_6ab1bca48e6d09672c4f4283", + "object": "response", + "created_at": 1790033060, + "status": "completed", + "model": "muse-spark-1.3-contributor", + "error": null, + "incomplete_details": null, + "output": [ { - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "OK" } + "id": "msg_5e6ea904-c9e3-4047-b4eb-9836a61b656b", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { "type": "output_text", "text": "OK", "annotations": [], "logprobs": [] } + ] } - ] + ], + "usage": { + "input_tokens": 12, + "output_tokens": 148, + "total_tokens": 160, + "input_tokens_details": { "cached_tokens": 0 }, + "output_tokens_details": { "reasoning_tokens": 137 } + } } """; @@ -98,7 +113,7 @@ public async Task TheApiKey_ResolvesOnceHoweverManyEntriesTheDocumentDeclares() } [Fact] - public async Task EveryRequest_PostsToTheOpenCodeGoChatCompletionsRoute() + public async Task EveryRequest_PostsToTheOpenCodeGoResponsesRoute() { StubHttpMessageHandler endpoint = StubHttpMessageHandler.Answering(HttpStatusCode.OK, Answer); IChatClient client = await BuildClientAsync(new StubHandlerFactory(endpoint)); @@ -108,7 +123,7 @@ public async Task EveryRequest_PostsToTheOpenCodeGoChatCompletionsRoute() HttpRequestMessage request = Assert.Single(endpoint.Requests); Assert.Equal(HttpMethod.Post, request.Method); Assert.Equal("https://opencode.ai", request.RequestUri!.GetLeftPart(UriPartial.Authority)); - Assert.Equal("/zen/go/v1/chat/completions", request.RequestUri.AbsolutePath); + Assert.Equal("/zen/go/v1/responses", request.RequestUri.AbsolutePath); } [Fact] diff --git a/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoReasoningEffortTests.cs b/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoReasoningEffortTests.cs index 3c91d674..7ac43c58 100644 --- a/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoReasoningEffortTests.cs +++ b/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoReasoningEffortTests.cs @@ -9,7 +9,7 @@ using AgentCore.Infrastructure.Tests.Tools; using AgentCore.TestSupport; using Microsoft.Extensions.AI; -using OpenAI.Chat; +using OpenAI.Responses; using Xunit; namespace AgentCore.Infrastructure.Tests.Llm.OpenCodeGo @@ -20,7 +20,7 @@ namespace AgentCore.Infrastructure.Tests.Llm.OpenCodeGo /// /// /// OpenCode Go's own docs are silent on the wire field; a real request against the live - /// endpoint (2026-09-21) confirmed reasoning_effort on the chat-completions body, honoured + /// endpoint (2026-09-21) confirmed reasoning.effort on the Responses body, honoured /// by at least one hosted model (reasoning token count moved with the level). These tests pin /// that shape offline; none of them reach the network. /// @@ -28,21 +28,36 @@ public sealed class OpenCodeGoReasoningEffortTests { private const string ApiKey = "sk-test-not-a-real-key"; + /// A real Responses API body from opencode.ai (2026-09-21), trimmed to what the SDK reads. private const string Answer = /*lang=json,strict*/ """ { - "id": "gen-test", - "object": "chat.completion", - "created": 1790012523, - "model": "kimi-k2.6", - "choices": [ + "id": "resp_6ab1bca48e6d09672c4f4283", + "object": "response", + "created_at": 1790033060, + "status": "completed", + "model": "muse-spark-1.3-contributor", + "error": null, + "incomplete_details": null, + "output": [ { - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "OK" } + "id": "msg_5e6ea904-c9e3-4047-b4eb-9836a61b656b", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { "type": "output_text", "text": "OK", "annotations": [], "logprobs": [] } + ] } - ] + ], + "usage": { + "input_tokens": 12, + "output_tokens": 148, + "total_tokens": 160, + "input_tokens_details": { "cached_tokens": 0 }, + "output_tokens_details": { "reasoning_tokens": 137 } + } } """; @@ -58,51 +73,53 @@ public async Task EveryValueTheSchemaAllows_ReachesTheRequestTheVendorSees(strin { CapturingChatClient inner = new(); - IChatClient client = OpenCodeGoChatClientAdapter.WithReasoningEffort(inner, effort); + IChatClient client = OpenCodeGoChatClientAdapter.WithResponseDefaults(inner, effort); _ = await client.GetResponseAsync("hi", cancellationToken: Token); - ChatCompletionOptions raw = Assert.IsType( + CreateResponseOptions raw = Assert.IsType( inner.Seen!.RawRepresentationFactory!(inner)); - Assert.Equal(effort, raw.ReasoningEffortLevel.ToString()); + Assert.Equal(effort, raw.ReasoningOptions!.ReasoningEffortLevel.ToString()); } [Fact] - public async Task AnEntryThatNamesNoEffort_SendsNoReasoningValueAtAll() + public async Task AnEntryThatNamesNoEffort_SendsNoReasoningValueButStillTurnsStoreOff() { CapturingChatClient inner = new(); - IChatClient client = OpenCodeGoChatClientAdapter.WithReasoningEffort(inner, effort: null); + IChatClient client = OpenCodeGoChatClientAdapter.WithResponseDefaults(inner, effort: null); _ = await client.GetResponseAsync("hi", cancellationToken: Token); - // No wrapper was applied at all: the caller's own client, unmodified, saw the request. - Assert.Same(inner, client); - Assert.Null(inner.Seen); + CreateResponseOptions raw = Assert.IsType( + inner.Seen!.RawRepresentationFactory!(inner)); + + Assert.Null(raw.ReasoningOptions); + Assert.False(raw.StoredOutputEnabled); } [Fact] public async Task RawOptionsTheCallerBuiltItself_KeepTheValueItAlreadyHolds() { CapturingChatClient inner = new(); - ChatCompletionOptions mine = new() + CreateResponseOptions mine = new() { - ReasoningEffortLevel = ChatReasoningEffortLevel.High, + ReasoningOptions = new ResponseReasoningOptions { ReasoningEffortLevel = ResponseReasoningEffortLevel.High }, MaxOutputTokenCount = 42, }; - IChatClient client = OpenCodeGoChatClientAdapter.WithReasoningEffort(inner, "low"); + IChatClient client = OpenCodeGoChatClientAdapter.WithResponseDefaults(inner, "low"); _ = await client.GetResponseAsync( "hi", new ChatOptions { RawRepresentationFactory = _ => mine }, Token); Assert.Same(mine, inner.Seen!.RawRepresentationFactory!(inner)); - Assert.Equal(ChatReasoningEffortLevel.High, mine.ReasoningEffortLevel); + Assert.Equal(ResponseReasoningEffortLevel.High, mine.ReasoningOptions.ReasoningEffortLevel); Assert.Equal(42, mine.MaxOutputTokenCount); } [Fact] - public async Task AnEntryWithAnEffort_PutsReasoningEffortOnTheRequestTheVendorSees() + public async Task AnEntryWithAnEffort_PutsReasoningEffortAndStoreFalseOnTheRequestTheVendorSees() { string? body = null; StubHttpMessageHandler endpoint = new(request => @@ -121,7 +138,7 @@ public async Task AnEntryWithAnEffort_PutsReasoningEffortOnTheRequestTheVendorSe new LlmProviderConfiguration { Kind = OpenCodeGoChatClientAdapter.ProviderKind, - Model = "kimi-k2.6", + Model = "muse-spark-1.3-contributor", As = "reply", ReasoningEffort = "high", }, @@ -140,14 +157,15 @@ public async Task AnEntryWithAnEffort_PutsReasoningEffortOnTheRequestTheVendorSe Token); Assert.Single(endpoint.Requests); - Assert.Contains("\"reasoning_effort\":\"high\"", body, StringComparison.Ordinal); + Assert.Contains("\"reasoning\":{\"effort\":\"high\"}", body, StringComparison.Ordinal); + Assert.Contains("\"store\":false", body, StringComparison.Ordinal); } [Fact] public void AValueThisVendorDoesNotKnow_FailsAndSaysWhatIsAllowed() { ConfigurationLoadException failure = Assert.Throws( - () => OpenCodeGoChatClientAdapter.WithReasoningEffort(new CapturingChatClient(), "exhaustive")); + () => OpenCodeGoChatClientAdapter.WithResponseDefaults(new CapturingChatClient(), "exhaustive")); Assert.Contains(failure.Errors, error => error.Pointer == "/providers/llm"); Assert.Contains("exhaustive", failure.Message, StringComparison.Ordinal);