Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/CrestApps.Core.Docs/docs/changelog/2.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -864,3 +864,19 @@ microphone, desk speakers):
docs version from patch tags only when that major/minor docs version does not
already exist, and skips prerelease tags successfully after logging why no docs
version PR was needed
- **Fixed: one of two AI provider connections whose names differ only by case disappeared without a
trace.** A connection's identifier hashes its lowercased client and connection name, so `Shared-Azure`
declared under `CrestApps:AI:Connections` and `shared-azure` declared under
`CrestApps:AI:Providers:Azure:Connections` resolved to the same identifier, and the second silently
replaced the first — taking its endpoint and its credentials with it. Every deployment naming that
connection then reached whichever resource happened to be read last, which surfaces as an unexplained
`DeploymentNotFound` when the model is deployed only on the other one. The first definition now wins,
matching how configured deployments are read, and the dropped entry is logged as a warning that names
the entry it collided with. A site that has such a pair today will switch to the earlier definition;
the warning names both, so the duplicate can be renamed.
- **A failed Azure OpenAI completion now names the deployment and endpoint that served it.** The
provider's own exception names neither, so a misrouted request — a deployment bound to the wrong
connection, or a model name that does not exist on the resource that connection points at — read as a
bare HTTP error with nothing to say where the request went. Both the streaming and non-streaming paths
now log the deployment name, the model name that forms the request URL, the connection name, and the
endpoint host.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using CrestApps.Core.AI.OpenAI.Azure.Models;
using CrestApps.Core.AI.Services;
using CrestApps.Core.Extensions;
using CrestApps.Core.Infrastructure;
using CrestApps.Core.Templates.Services;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -177,7 +178,7 @@ public string ClientName
}
catch (Exception ex)
{
_logger.LogError(ex, "Unable to get chat completion result from Azure OpenAI.");
LogCompletionFailure(ex, deployment, connectionProperties);
}

return null;
Expand Down Expand Up @@ -251,8 +252,36 @@ public string ClientName
while (iterations <= _defaultOptions.MaximumIterationsPerRequest)
{
var hasToolCalls = false;
await foreach (var update in chatClient.CompleteChatStreamingAsync(prompts, chatOptions, cancellationToken))

// Enumerated by hand rather than with `await foreach` so the call into the provider sits in a
// try block: a yield cannot appear inside one, and without it a failed request leaves no record
// of which deployment and endpoint produced it.
await using var updates = chatClient
.CompleteChatStreamingAsync(prompts, chatOptions, cancellationToken)
.GetAsyncEnumerator(cancellationToken);

while (true)
{
try
{
if (!await updates.MoveNextAsync())
{
break;
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
LogCompletionFailure(ex, deployment, connection);

throw;
}

var update = updates.Current;

// Accumulate tool call updates as they arrive.
foreach (var toolCallUpdate in update.ToolCallUpdates)
{
Expand Down Expand Up @@ -474,6 +503,30 @@ private AzureOpenAIClient GetChatClient(AIProviderConnectionEntry connection)
return AzureOpenAIClientFactory.Create(connection, _loggerFactory, _azureClientOptions);
}

/// <summary>
/// Logs a failed completion together with the deployment and endpoint that served it.
/// </summary>
/// <remarks>
/// The provider's own exception names neither. A misrouted request -- a deployment bound to the wrong
/// connection, or a model name that does not exist on the resource that connection points at -- reads
/// as a bare <c>DeploymentNotFound</c> without them, which says nothing about where the request went.
/// The model name is the one that forms the request URL, so it is reported even when it matches the
/// deployment name.
/// </remarks>
/// <param name="exception">The exception the request failed with.</param>
/// <param name="deployment">The deployment that served the request.</param>
/// <param name="connection">The connection that served the request.</param>
private void LogCompletionFailure(Exception exception, AIDeployment deployment, AIProviderConnectionEntry connection)
{
_logger.LogError(
exception,
"Unable to get chat completion result from Azure OpenAI using deployment '{DeploymentName}' (model '{ModelName}') on connection '{ConnectionName}' at '{Endpoint}'.",
deployment.Name,
deployment.ModelName,
deployment.ConnectionName,
connection.GetEndpoint(throwException: false)?.Host);
}

private static async ValueTask<IReadOnlyList<Microsoft.Extensions.AI.AIFunction>> ConfigureOptionsAsync(ChatCompletionOptions chatOptions, AICompletionContext context, List<ChatMessage> prompts)
{
var optionsContext = new AzureOpenAIChatOptionsContext(chatOptions, context, prompts);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,21 @@ private void AddConfiguredConnection(
return;
}

// The identifier hashes the lowercased provider and connection name, so two entries whose names
// differ only by case land on the same one. The first definition wins, matching how configured
// deployments are read; without this the later entry replaced the earlier one in silence and an
// entire connection -- its endpoint and its credentials -- disappeared with no diagnostic.
if (connections.TryGetValue(connection.ItemId, out var existingConnection))
{
_logger.LogWarning(
"Skipping AI connection '{ConnectionName}' from '{SourceDescription}' because '{ExistingConnectionName}' resolves to the same identifier. Connection names are case-insensitive; rename one of them so both are read.",
connection.Name,
sourceDescription,
existingConnection.Name);

return;
}

if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Adding AI connection '{ConnectionName}' (ClientName: '{ClientName}', ItemId: '{ItemId}') from '{SourceDescription}'.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,30 @@ public async Task ConfigurationAIProviderConnectionStore_ShouldSkipConfiguredCon
Assert.Equal("ui-connection", connections.Single().ItemId);
}

[Fact]
public async Task ConfigurationAIProviderConnectionStore_WhenTwoConfiguredNamesDifferOnlyByCase_ShouldKeepTheFirstOne()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string>
{
["CrestApps:AI:Connections:0:Name"] = "Shared-Azure",
["CrestApps:AI:Connections:0:ClientName"] = "Azure",
["CrestApps:AI:Connections:0:Endpoint"] = "https://first.openai.azure.com/",
["CrestApps:AI:Providers:Azure:Connections:shared-azure:Endpoint"] = "https://second.openai.azure.com/",
})
.Build();

var store = CreateConnectionStore(configuration);

var connections = await store.GetAllAsync(TestContext.Current.CancellationToken);

// Both entries hash to the same identifier, so only one survives. The later one used to replace
// the earlier in silence, taking its endpoint and its credentials with it.
var connection = Assert.Single(connections);
Assert.Equal("Shared-Azure", connection.Name);
Assert.Equal("https://first.openai.azure.com/", connection.Properties["Endpoint"]?.ToString());
}

private static DefaultAIDeploymentCapabilityService CreateCapabilityService()
{
return new DefaultAIDeploymentCapabilityService(Options.Create(new AIDeploymentCapabilityOptions()), Mock.Of<IAIDeploymentStore>());
Expand Down
Loading