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/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.Application/Llm/CompositeChatClientFactory.cs b/src/AgentCore.Application/Llm/CompositeChatClientFactory.cs index 9b6dd066..2305c055 100644 --- a/src/AgentCore.Application/Llm/CompositeChatClientFactory.cs +++ b/src/AgentCore.Application/Llm/CompositeChatClientFactory.cs @@ -20,6 +20,8 @@ public sealed class CompositeChatClientFactory : IChatClientFactory, IDisposable private readonly Dictionary _vendor = new(StringComparer.Ordinal); + private readonly Dictionary _contextWindows = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _shaped = new(StringComparer.Ordinal); private LlmProviderConfiguration? _default; @@ -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.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.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..ba76ef86 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,13 @@ private static void Configure( new ConfigurationSecretResolver(hostConfiguration), ]); + // Every vendor's models.dev lookup shares one fetch of the catalog. + _ = options.UseModelCatalog(ModelsDevCatalogPort.CreateFromConfiguration(httpClients, hostConfiguration)); + // providers.llm[].kind picks the adapter for each entry. - _ = options.UseChatClients(new OpenAiChatClientAdapter()); + _ = options.UseChatClients( + new OpenAiChatClientAdapter(), + OpenCodeGoChatClientAdapter.CreateFromConfiguration(httpClients, hostConfiguration)); // 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/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/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/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs b/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs new file mode 100644 index 00000000..1469e399 --- /dev/null +++ b/src/AgentCore.Infrastructure/Llm/OpenCodeGo/OpenCodeGoChatClientAdapter.cs @@ -0,0 +1,290 @@ +// 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.Globalization; +using System.Runtime.CompilerServices; +using AgentCore.Application.Configuration.Parsing; +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 OpenAI.Responses; + +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); + + /// The conversation id the header policy reads on send. + private readonly AsyncLocal _currentSessionId = new(); + + private readonly IHttpMessageHandlerFactory _handlers; + + private readonly Uri _apiEndpoint; + + private HttpClient? _transport; + + 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. + /// + /// 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( + () => BuildClientFor(entry.Model, entry.ReasoningEffort), _currentSessionId); + } + + /// + /// 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 WithResponseDefaults(_vendorClient.GetResponsesClient().AsIChatClient(model), effort); + } + + /// Builds the one vendor connection every model shares, with the session header wired to . + private OpenAIClient CreateVendorClient() + { + OpenAIClientOptions options = new() + { + Endpoint = _apiEndpoint, + Transport = new HttpClientPipelineTransport(_transport!), + }; + + options.AddPolicy(new OpenCodeGoHeaderPolicy(_currentSessionId), PipelinePosition.BeforeTransport); + + return new OpenAIClient(_credential!, options); + } + + /// + /// 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) + { + ResponseReasoningEffortLevel? level = effort is { Length: > 0 } value ? Level(value) : (ResponseReasoningEffortLevel?)null; + + return client + .AsBuilder() + .ConfigureOptions(options => + { + Func? caller = options.RawRepresentationFactory; + + options.RawRepresentationFactory = inner => + { + if (caller?.Invoke(inner) is not CreateResponseOptions raw) + { + raw = new CreateResponseOptions(); + } + + raw.StoredOutputEnabled ??= false; + + if (level is { } chosen) + { + raw.ReasoningOptions ??= new ResponseReasoningOptions + { + 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 ResponseReasoningEffortLevel Level(string effort) + { + return effort.ToLowerInvariant() switch + { + "none" => ResponseReasoningEffortLevel.None, + "minimal" => ResponseReasoningEffortLevel.Minimal, + "low" => ResponseReasoningEffortLevel.Low, + "medium" => ResponseReasoningEffortLevel.Medium, + "high" => ResponseReasoningEffortLevel.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(AsyncLocal currentSessionId) : 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) + { + string sessionId = currentSessionId.Value + ?? throw new InvalidOperationException( + "OpenCode Go requires a conversation id set on the current call before it reaches the transport."); + + message.Request.Headers.Set(SessionHeaderName, sessionId); + message.Request.Headers.Set("User-Agent", UserAgentValue); + } + } + + /// Routes each call to the vendor client of its model, after stamping the conversation id the header policy reads. + private sealed class ConversationRoutedChatClient( + Func resolve, + AsyncLocal currentSessionId) : IChatClient + { + public Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + currentSessionId.Value = RequireConversationId(options); + return resolve().GetResponseAsync(messages, options, cancellationToken); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + 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) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + public void Dispose() + { + // The vendor client this routes to shares the adapter's one transport, which the + // adapter — not any conversation's view of it — owns. + } + + private static string RequireConversationId(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 conversationId; + } + } + } +} diff --git a/src/AgentCore.Infrastructure/PublicAPI.Unshipped.txt b/src/AgentCore.Infrastructure/PublicAPI.Unshipped.txt index 2c9018af..748c76c3 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,25 @@ 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, 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! +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, 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 +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! +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/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.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..091c2c47 --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoChatClientAdapterTests.cs @@ -0,0 +1,238 @@ +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"; + + /// 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": "resp_6ab1bca48e6d09672c4f4283", + "object": "response", + "created_at": 1790033060, + "status": "completed", + "model": "muse-spark-1.3-contributor", + "error": null, + "incomplete_details": null, + "output": [ + { + "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 } + } + } + """; + + 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_PostsToTheOpenCodeGoResponsesRoute() + { + 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/responses", 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.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoReasoningEffortTests.cs b/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoReasoningEffortTests.cs new file mode 100644 index 00000000..7ac43c58 --- /dev/null +++ b/tests/AgentCore.Infrastructure.Tests/Llm/OpenCodeGo/OpenCodeGoReasoningEffortTests.cs @@ -0,0 +1,175 @@ +#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.Responses; +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 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. + /// + 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": "resp_6ab1bca48e6d09672c4f4283", + "object": "response", + "created_at": 1790033060, + "status": "completed", + "model": "muse-spark-1.3-contributor", + "error": null, + "incomplete_details": null, + "output": [ + { + "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 } + } + } + """; + + 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.WithResponseDefaults(inner, effort); + _ = await client.GetResponseAsync("hi", cancellationToken: Token); + + CreateResponseOptions raw = Assert.IsType( + inner.Seen!.RawRepresentationFactory!(inner)); + + Assert.Equal(effort, raw.ReasoningOptions!.ReasoningEffortLevel.ToString()); + } + + [Fact] + public async Task AnEntryThatNamesNoEffort_SendsNoReasoningValueButStillTurnsStoreOff() + { + CapturingChatClient inner = new(); + + IChatClient client = OpenCodeGoChatClientAdapter.WithResponseDefaults(inner, effort: null); + _ = await client.GetResponseAsync("hi", cancellationToken: Token); + + 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(); + CreateResponseOptions mine = new() + { + ReasoningOptions = new ResponseReasoningOptions { ReasoningEffortLevel = ResponseReasoningEffortLevel.High }, + MaxOutputTokenCount = 42, + }; + + IChatClient client = OpenCodeGoChatClientAdapter.WithResponseDefaults(inner, "low"); + _ = await client.GetResponseAsync( + "hi", + new ChatOptions { RawRepresentationFactory = _ => mine }, + Token); + + Assert.Same(mine, inner.Seen!.RawRepresentationFactory!(inner)); + Assert.Equal(ResponseReasoningEffortLevel.High, mine.ReasoningOptions.ReasoningEffortLevel); + Assert.Equal(42, mine.MaxOutputTokenCount); + } + + [Fact] + public async Task AnEntryWithAnEffort_PutsReasoningEffortAndStoreFalseOnTheRequestTheVendorSees() + { + 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 = "muse-spark-1.3-contributor", + 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); + Assert.Contains("\"store\":false", body, StringComparison.Ordinal); + } + + [Fact] + public void AValueThisVendorDoesNotKnow_FailsAndSaysWhatIsAllowed() + { + ConfigurationLoadException failure = Assert.Throws( + () => OpenCodeGoChatClientAdapter.WithResponseDefaults(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); + } + } +} 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); + } + } +}