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);
+ }
+ }
+}