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
31 changes: 28 additions & 3 deletions src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.Extensions.Options;
using SharpClaw.Code.Agents.Configuration;
using SharpClaw.Code.Agents.Models;
using SharpClaw.Code.Infrastructure.Abstractions;
using SharpClaw.Code.Protocol.Events;
using SharpClaw.Code.Protocol.Models;
using SharpClaw.Code.Providers.Abstractions;
Expand All @@ -23,6 +24,7 @@ public sealed class ProviderBackedAgentKernel(
IAuthFlowService authFlowService,
ToolCallDispatcher toolCallDispatcher,
IOptions<AgentLoopOptions> loopOptions,
ISystemClock systemClock,
ILogger<ProviderBackedAgentKernel> logger)
{
internal async Task<ProviderInvocationResult> ExecuteAsync(
Expand Down Expand Up @@ -78,17 +80,21 @@ internal async Task<ProviderInvocationResult> ExecuteAsync(
exception);
}

if (!authStatus.IsAuthenticated)
var authExpired = ProviderStreamFailureClassifier.IsExpired(authStatus, systemClock.UtcNow);
if (!authStatus.IsAuthenticated || authExpired)
{
logger.LogWarning(
"Provider {ProviderName} is not authenticated for session {SessionId}.",
"Provider {ProviderName} is not authenticated or its auth status expired for session {SessionId}.",
resolvedProviderName,
request.Context.SessionId);
var message = authExpired
? $"Provider '{resolvedProviderName}' authentication expired at {authStatus.ExpiresAtUtc:O}."
: $"Provider '{resolvedProviderName}' is not authenticated.";
throw new ProviderExecutionException(
resolvedProviderName,
requestedModel,
ProviderFailureKind.AuthenticationUnavailable,
$"Provider '{resolvedProviderName}' is not authenticated.");
message);
}

// --- Resolve provider ---
Expand Down Expand Up @@ -160,6 +166,17 @@ internal async Task<ProviderInvocationResult> ExecuteAsync(
{
allProviderEvents.Add(providerEvent);

if (providerEvent.IsTerminal
&& string.Equals(providerEvent.Kind, "failed", StringComparison.OrdinalIgnoreCase))
{
var failureKind = ProviderStreamFailureClassifier.ClassifyFailedEvent(providerEvent);
throw new ProviderExecutionException(
resolvedProviderName,
requestedModel,
failureKind,
CreateProviderFailedEventMessage(resolvedProviderName, providerEvent));
}

if (!providerEvent.IsTerminal && !string.IsNullOrWhiteSpace(providerEvent.Content))
{
iterationTextSegments.Add(providerEvent.Content);
Expand Down Expand Up @@ -343,4 +360,12 @@ private static ProviderExecutionException CreateMissingProviderException(
model,
ProviderFailureKind.MissingProvider,
$"No provider named '{providerName}' was registered during {stage}.");

private static string CreateProviderFailedEventMessage(string providerName, ProviderEvent providerEvent)
{
var detail = string.IsNullOrWhiteSpace(providerEvent.Content)
? "The provider stream ended with a failure event."
: providerEvent.Content;
return $"Provider '{providerName}' stream failed: {detail}";
}
}
13 changes: 10 additions & 3 deletions src/SharpClaw.Code.Cli/Rendering/JsonOutputRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@ namespace SharpClaw.Code.Cli.Rendering;
/// <summary>
/// Renders command and prompt results as JSON.
/// </summary>
public sealed class JsonOutputRenderer(ILogger<JsonOutputRenderer>? logger = null) : IOutputRenderer
public sealed class JsonOutputRenderer(
ILogger<JsonOutputRenderer>? logger = null,
TextWriter? outputWriter = null) : IOutputRenderer
{
private readonly ILogger<JsonOutputRenderer> _logger = logger ?? NullLogger<JsonOutputRenderer>.Instance;
private readonly TextWriter? _outputWriter = outputWriter;

/// <inheritdoc />
public OutputFormat Format => OutputFormat.Json;

Expand Down Expand Up @@ -47,15 +51,18 @@ public Task RenderCommandResultAsync(CommandResult result, CancellationToken can
dataRaw),
JsonOutputJsonContext.Default.JsonCommandEnvelope);

return Console.Out.WriteLineAsync(json.AsMemory(), cancellationToken);
return WriteLineAsync(json, cancellationToken);
}

/// <inheritdoc />
public Task RenderTurnExecutionResultAsync(TurnExecutionResult result, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(result, ProtocolJsonContext.Default.TurnExecutionResult);
return Console.Out.WriteLineAsync(json.AsMemory(), cancellationToken);
return WriteLineAsync(json, cancellationToken);
}

private Task WriteLineAsync(string json, CancellationToken cancellationToken)
=> (_outputWriter ?? Console.Out).WriteLineAsync(json.AsMemory(), cancellationToken);
}

internal sealed record JsonCommandEnvelope(
Expand Down
27 changes: 24 additions & 3 deletions src/SharpClaw.Code.Providers/AnthropicProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ public Task<AuthStatus> GetAuthStatusAsync(CancellationToken cancellationToken)
hasAuthOptionalRuntime: false));

/// <inheritdoc />
public async Task<ProviderStreamHandle> StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken)
public Task<ProviderStreamHandle> StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var client = CreateClient();
var modelId = Internal.ProviderHttpHelpers.ResolveModelOrDefault(request.Model, _options.DefaultModel);

Expand Down Expand Up @@ -84,10 +85,30 @@ public async Task<ProviderStreamHandle> StartStreamAsync(ProviderRequest request
parameters = parameters with { System = systemPrompt };
}

logger.LogInformation("Starting Anthropic SDK stream for request {RequestId}.", request.Id);

IAsyncEnumerable<RawMessageStreamEvent> stream;
try
{
stream = client.Messages.CreateStreaming(parameters, cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception) when (ProviderStreamFailureClassifier.IsAuthenticationFailure(exception))
{
throw new ProviderExecutionException(
ProviderName,
modelId,
ProviderFailureKind.AuthenticationUnavailable,
$"Provider '{ProviderName}' authentication failed while starting the stream.",
exception);
}

logger.LogInformation("Started Anthropic SDK stream for request {RequestId}.", request.Id);

var stream = client.Messages.CreateStreaming(parameters, cancellationToken);
return new ProviderStreamHandle(request, AnthropicSdkStreamAdapter.AdaptAsync(stream, request.Id, systemClock, cancellationToken));
return Task.FromResult(new ProviderStreamHandle(request, AnthropicSdkStreamAdapter.AdaptAsync(stream, request.Id, systemClock, cancellationToken)));
}

private AnthropicClient CreateClient()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Runtime.CompilerServices;
using Anthropic.Models.Messages;
using SharpClaw.Code.Infrastructure.Abstractions;
using SharpClaw.Code.Providers.Models;
using SharpClaw.Code.Protocol.Models;

namespace SharpClaw.Code.Providers.Internal;
Expand Down Expand Up @@ -42,7 +43,8 @@ public static async IAsyncEnumerable<ProviderEvent> AdaptAsync(
}
catch (Exception ex)
{
streamError = ex.Message;
cancellationToken.ThrowIfCancellationRequested();
streamError = ProviderStreamFailureClassifier.Describe(ex);
moved = false;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.AI;
using SharpClaw.Code.Infrastructure.Abstractions;
using SharpClaw.Code.Providers.Models;
using SharpClaw.Code.Protocol.Models;

namespace SharpClaw.Code.Providers.Internal;
Expand Down Expand Up @@ -38,7 +39,8 @@ public static async IAsyncEnumerable<ProviderEvent> AdaptAsync(
}
catch (Exception ex)
{
streamError = ex.Message;
cancellationToken.ThrowIfCancellationRequested();
streamError = ProviderStreamFailureClassifier.Describe(ex);
moved = false;
}

Expand Down
146 changes: 146 additions & 0 deletions src/SharpClaw.Code.Providers/Models/ProviderStreamFailureClassifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
using System.ClientModel;
using System.Globalization;
using System.Net;
using System.Reflection;
using SharpClaw.Code.Protocol.Models;

namespace SharpClaw.Code.Providers.Models;

/// <summary>
/// Classifies provider stream failures without changing the provider event contract.
/// </summary>
public static class ProviderStreamFailureClassifier
{
private static readonly string[] StatusPropertyNames = ["StatusCode", "Status"];

/// <summary>
/// Returns whether an authentication status is already expired.
/// </summary>
public static bool IsExpired(AuthStatus authStatus, DateTimeOffset utcNow)
=> authStatus.ExpiresAtUtc is { } expiresAt && expiresAt <= utcNow;

/// <summary>
/// Converts an exception into stable failed-event content while preserving HTTP status detail when available.
/// </summary>
public static string Describe(Exception exception)
{
var message = string.IsNullOrWhiteSpace(exception.Message)
? exception.GetType().Name
: exception.Message;
var statusCode = TryGetStatusCode(exception);
return statusCode is null
? message
: $"HTTP {(int)statusCode.Value} {statusCode.Value}: {message}";
}

/// <summary>
/// Classifies a terminal provider failed event.
/// </summary>
public static ProviderFailureKind ClassifyFailedEvent(ProviderEvent providerEvent)
=> LooksLikeAuthenticationFailure(providerEvent.Content)
? ProviderFailureKind.AuthenticationUnavailable
: ProviderFailureKind.StreamFailed;

/// <summary>
/// Returns whether an exception represents provider authentication or authorization failure.
/// </summary>
public static bool IsAuthenticationFailure(Exception exception)
{
var statusCode = TryGetStatusCode(exception);
if (statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
return true;
}

var typeName = exception.GetType().Name;
return typeName.Contains("Unauthorized", StringComparison.OrdinalIgnoreCase)
|| typeName.Contains("Forbidden", StringComparison.OrdinalIgnoreCase)
|| typeName.Contains("Authentication", StringComparison.OrdinalIgnoreCase)
|| LooksLikeAuthenticationFailure(exception.Message)
|| (exception.InnerException is not null && IsAuthenticationFailure(exception.InnerException));
}

private static bool LooksLikeAuthenticationFailure(string? message)
{
if (string.IsNullOrWhiteSpace(message))
{
return false;
}

var value = message.ToLowerInvariant();
if (value.Contains("401", StringComparison.Ordinal)
|| value.Contains("unauthorized", StringComparison.Ordinal)
|| value.Contains("forbidden", StringComparison.Ordinal))
{
return true;
}

if ((value.Contains("api key", StringComparison.Ordinal)
|| value.Contains("token", StringComparison.Ordinal)
|| value.Contains("credential", StringComparison.Ordinal)
|| value.Contains("authentication", StringComparison.Ordinal))
&& (value.Contains("expired", StringComparison.Ordinal)
|| value.Contains("invalid", StringComparison.Ordinal)
|| value.Contains("missing", StringComparison.Ordinal)
|| value.Contains("failed", StringComparison.Ordinal)))
{
return true;
}

return false;
}

private static HttpStatusCode? TryGetStatusCode(Exception exception)
{
if (exception is HttpRequestException { StatusCode: { } httpStatusCode })
{
return httpStatusCode;
}

if (exception is ClientResultException { Status: > 0 } clientResultException)
{
return (HttpStatusCode)clientResultException.Status;
}

var reflected = TryGetReflectedStatusCode(exception);
if (reflected is not null)
{
return reflected;
}

return exception.InnerException is null ? null : TryGetStatusCode(exception.InnerException);
}

private static HttpStatusCode? TryGetReflectedStatusCode(Exception exception)
{
foreach (var propertyName in StatusPropertyNames)
{
var property = exception.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public);
if (property?.GetValue(exception) is not { } value)
{
continue;
}

if (value is HttpStatusCode httpStatusCode)
{
return httpStatusCode;
}

if (value is int intStatusCode and > 0)
{
return (HttpStatusCode)intStatusCode;
}

if (value.GetType().IsEnum)
{
var enumStatusCode = Convert.ToInt32(value, CultureInfo.InvariantCulture);
if (enumStatusCode > 0)
{
return (HttpStatusCode)enumStatusCode;
}
}
}

return null;
}
}
1 change: 1 addition & 0 deletions src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public Task<AuthStatus> GetAuthStatusAsync(CancellationToken cancellationToken)
/// <inheritdoc />
public Task<ProviderStreamHandle> StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
logger.LogInformation("Starting OpenAI-compatible MEAI stream for request {RequestId}.", request.Id);
return Task.FromResult(new ProviderStreamHandle(request, StreamEventsAsync(request, cancellationToken)));
}
Expand Down
Loading
Loading