Skip to content

Commit 51e5ea3

Browse files
authored
Merge pull request #9 from clawdotnet/codex/provider-stream-failure-handling
[codex] Fix provider stream auth failures
2 parents 18de19f + 3c040f2 commit 51e5ea3

10 files changed

Lines changed: 498 additions & 47 deletions

File tree

src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Microsoft.Extensions.Options;
44
using SharpClaw.Code.Agents.Configuration;
55
using SharpClaw.Code.Agents.Models;
6+
using SharpClaw.Code.Infrastructure.Abstractions;
67
using SharpClaw.Code.Protocol.Events;
78
using SharpClaw.Code.Protocol.Models;
89
using SharpClaw.Code.Providers.Abstractions;
@@ -23,6 +24,7 @@ public sealed class ProviderBackedAgentKernel(
2324
IAuthFlowService authFlowService,
2425
ToolCallDispatcher toolCallDispatcher,
2526
IOptions<AgentLoopOptions> loopOptions,
27+
ISystemClock systemClock,
2628
ILogger<ProviderBackedAgentKernel> logger)
2729
{
2830
internal async Task<ProviderInvocationResult> ExecuteAsync(
@@ -78,17 +80,21 @@ internal async Task<ProviderInvocationResult> ExecuteAsync(
7880
exception);
7981
}
8082

81-
if (!authStatus.IsAuthenticated)
83+
var authExpired = ProviderStreamFailureClassifier.IsExpired(authStatus, systemClock.UtcNow);
84+
if (!authStatus.IsAuthenticated || authExpired)
8285
{
8386
logger.LogWarning(
84-
"Provider {ProviderName} is not authenticated for session {SessionId}.",
87+
"Provider {ProviderName} is not authenticated or its auth status expired for session {SessionId}.",
8588
resolvedProviderName,
8689
request.Context.SessionId);
90+
var message = authExpired
91+
? $"Provider '{resolvedProviderName}' authentication expired at {authStatus.ExpiresAtUtc:O}."
92+
: $"Provider '{resolvedProviderName}' is not authenticated.";
8793
throw new ProviderExecutionException(
8894
resolvedProviderName,
8995
requestedModel,
9096
ProviderFailureKind.AuthenticationUnavailable,
91-
$"Provider '{resolvedProviderName}' is not authenticated.");
97+
message);
9298
}
9399

94100
// --- Resolve provider ---
@@ -160,6 +166,17 @@ internal async Task<ProviderInvocationResult> ExecuteAsync(
160166
{
161167
allProviderEvents.Add(providerEvent);
162168

169+
if (providerEvent.IsTerminal
170+
&& string.Equals(providerEvent.Kind, "failed", StringComparison.OrdinalIgnoreCase))
171+
{
172+
var failureKind = ProviderStreamFailureClassifier.ClassifyFailedEvent(providerEvent);
173+
throw new ProviderExecutionException(
174+
resolvedProviderName,
175+
requestedModel,
176+
failureKind,
177+
CreateProviderFailedEventMessage(resolvedProviderName, providerEvent));
178+
}
179+
163180
if (!providerEvent.IsTerminal && !string.IsNullOrWhiteSpace(providerEvent.Content))
164181
{
165182
iterationTextSegments.Add(providerEvent.Content);
@@ -343,4 +360,12 @@ private static ProviderExecutionException CreateMissingProviderException(
343360
model,
344361
ProviderFailureKind.MissingProvider,
345362
$"No provider named '{providerName}' was registered during {stage}.");
363+
364+
private static string CreateProviderFailedEventMessage(string providerName, ProviderEvent providerEvent)
365+
{
366+
var detail = string.IsNullOrWhiteSpace(providerEvent.Content)
367+
? "The provider stream ended with a failure event."
368+
: providerEvent.Content;
369+
return $"Provider '{providerName}' stream failed: {detail}";
370+
}
346371
}

src/SharpClaw.Code.Cli/Rendering/JsonOutputRenderer.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@ namespace SharpClaw.Code.Cli.Rendering;
1212
/// <summary>
1313
/// Renders command and prompt results as JSON.
1414
/// </summary>
15-
public sealed class JsonOutputRenderer(ILogger<JsonOutputRenderer>? logger = null) : IOutputRenderer
15+
public sealed class JsonOutputRenderer(
16+
ILogger<JsonOutputRenderer>? logger = null,
17+
TextWriter? outputWriter = null) : IOutputRenderer
1618
{
1719
private readonly ILogger<JsonOutputRenderer> _logger = logger ?? NullLogger<JsonOutputRenderer>.Instance;
20+
private readonly TextWriter? _outputWriter = outputWriter;
21+
1822
/// <inheritdoc />
1923
public OutputFormat Format => OutputFormat.Json;
2024

@@ -47,15 +51,18 @@ public Task RenderCommandResultAsync(CommandResult result, CancellationToken can
4751
dataRaw),
4852
JsonOutputJsonContext.Default.JsonCommandEnvelope);
4953

50-
return Console.Out.WriteLineAsync(json.AsMemory(), cancellationToken);
54+
return WriteLineAsync(json, cancellationToken);
5155
}
5256

5357
/// <inheritdoc />
5458
public Task RenderTurnExecutionResultAsync(TurnExecutionResult result, CancellationToken cancellationToken)
5559
{
5660
var json = JsonSerializer.Serialize(result, ProtocolJsonContext.Default.TurnExecutionResult);
57-
return Console.Out.WriteLineAsync(json.AsMemory(), cancellationToken);
61+
return WriteLineAsync(json, cancellationToken);
5862
}
63+
64+
private Task WriteLineAsync(string json, CancellationToken cancellationToken)
65+
=> (_outputWriter ?? Console.Out).WriteLineAsync(json.AsMemory(), cancellationToken);
5966
}
6067

6168
internal sealed record JsonCommandEnvelope(

src/SharpClaw.Code.Providers/AnthropicProvider.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ public Task<AuthStatus> GetAuthStatusAsync(CancellationToken cancellationToken)
3434
hasAuthOptionalRuntime: false));
3535

3636
/// <inheritdoc />
37-
public async Task<ProviderStreamHandle> StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken)
37+
public Task<ProviderStreamHandle> StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken)
3838
{
39+
cancellationToken.ThrowIfCancellationRequested();
3940
var client = CreateClient();
4041
var modelId = Internal.ProviderHttpHelpers.ResolveModelOrDefault(request.Model, _options.DefaultModel);
4142

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

88+
logger.LogInformation("Starting Anthropic SDK stream for request {RequestId}.", request.Id);
89+
90+
IAsyncEnumerable<RawMessageStreamEvent> stream;
91+
try
92+
{
93+
stream = client.Messages.CreateStreaming(parameters, cancellationToken);
94+
}
95+
catch (OperationCanceledException)
96+
{
97+
throw;
98+
}
99+
catch (Exception exception) when (ProviderStreamFailureClassifier.IsAuthenticationFailure(exception))
100+
{
101+
throw new ProviderExecutionException(
102+
ProviderName,
103+
modelId,
104+
ProviderFailureKind.AuthenticationUnavailable,
105+
$"Provider '{ProviderName}' authentication failed while starting the stream.",
106+
exception);
107+
}
108+
87109
logger.LogInformation("Started Anthropic SDK stream for request {RequestId}.", request.Id);
88110

89-
var stream = client.Messages.CreateStreaming(parameters, cancellationToken);
90-
return new ProviderStreamHandle(request, AnthropicSdkStreamAdapter.AdaptAsync(stream, request.Id, systemClock, cancellationToken));
111+
return Task.FromResult(new ProviderStreamHandle(request, AnthropicSdkStreamAdapter.AdaptAsync(stream, request.Id, systemClock, cancellationToken)));
91112
}
92113

93114
private AnthropicClient CreateClient()

src/SharpClaw.Code.Providers/Internal/AnthropicSdkStreamAdapter.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Runtime.CompilerServices;
22
using Anthropic.Models.Messages;
33
using SharpClaw.Code.Infrastructure.Abstractions;
4+
using SharpClaw.Code.Providers.Models;
45
using SharpClaw.Code.Protocol.Models;
56

67
namespace SharpClaw.Code.Providers.Internal;
@@ -42,7 +43,8 @@ public static async IAsyncEnumerable<ProviderEvent> AdaptAsync(
4243
}
4344
catch (Exception ex)
4445
{
45-
streamError = ex.Message;
46+
cancellationToken.ThrowIfCancellationRequested();
47+
streamError = ProviderStreamFailureClassifier.Describe(ex);
4648
moved = false;
4749
}
4850

src/SharpClaw.Code.Providers/Internal/OpenAiMeaiStreamAdapter.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Runtime.CompilerServices;
22
using Microsoft.Extensions.AI;
33
using SharpClaw.Code.Infrastructure.Abstractions;
4+
using SharpClaw.Code.Providers.Models;
45
using SharpClaw.Code.Protocol.Models;
56

67
namespace SharpClaw.Code.Providers.Internal;
@@ -38,7 +39,8 @@ public static async IAsyncEnumerable<ProviderEvent> AdaptAsync(
3839
}
3940
catch (Exception ex)
4041
{
41-
streamError = ex.Message;
42+
cancellationToken.ThrowIfCancellationRequested();
43+
streamError = ProviderStreamFailureClassifier.Describe(ex);
4244
moved = false;
4345
}
4446

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
using System.ClientModel;
2+
using System.Globalization;
3+
using System.Net;
4+
using System.Reflection;
5+
using SharpClaw.Code.Protocol.Models;
6+
7+
namespace SharpClaw.Code.Providers.Models;
8+
9+
/// <summary>
10+
/// Classifies provider stream failures without changing the provider event contract.
11+
/// </summary>
12+
public static class ProviderStreamFailureClassifier
13+
{
14+
private static readonly string[] StatusPropertyNames = ["StatusCode", "Status"];
15+
16+
/// <summary>
17+
/// Returns whether an authentication status is already expired.
18+
/// </summary>
19+
public static bool IsExpired(AuthStatus authStatus, DateTimeOffset utcNow)
20+
=> authStatus.ExpiresAtUtc is { } expiresAt && expiresAt <= utcNow;
21+
22+
/// <summary>
23+
/// Converts an exception into stable failed-event content while preserving HTTP status detail when available.
24+
/// </summary>
25+
public static string Describe(Exception exception)
26+
{
27+
var message = string.IsNullOrWhiteSpace(exception.Message)
28+
? exception.GetType().Name
29+
: exception.Message;
30+
var statusCode = TryGetStatusCode(exception);
31+
return statusCode is null
32+
? message
33+
: $"HTTP {(int)statusCode.Value} {statusCode.Value}: {message}";
34+
}
35+
36+
/// <summary>
37+
/// Classifies a terminal provider failed event.
38+
/// </summary>
39+
public static ProviderFailureKind ClassifyFailedEvent(ProviderEvent providerEvent)
40+
=> LooksLikeAuthenticationFailure(providerEvent.Content)
41+
? ProviderFailureKind.AuthenticationUnavailable
42+
: ProviderFailureKind.StreamFailed;
43+
44+
/// <summary>
45+
/// Returns whether an exception represents provider authentication or authorization failure.
46+
/// </summary>
47+
public static bool IsAuthenticationFailure(Exception exception)
48+
{
49+
var statusCode = TryGetStatusCode(exception);
50+
if (statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
51+
{
52+
return true;
53+
}
54+
55+
var typeName = exception.GetType().Name;
56+
return typeName.Contains("Unauthorized", StringComparison.OrdinalIgnoreCase)
57+
|| typeName.Contains("Forbidden", StringComparison.OrdinalIgnoreCase)
58+
|| typeName.Contains("Authentication", StringComparison.OrdinalIgnoreCase)
59+
|| LooksLikeAuthenticationFailure(exception.Message)
60+
|| (exception.InnerException is not null && IsAuthenticationFailure(exception.InnerException));
61+
}
62+
63+
private static bool LooksLikeAuthenticationFailure(string? message)
64+
{
65+
if (string.IsNullOrWhiteSpace(message))
66+
{
67+
return false;
68+
}
69+
70+
var value = message.ToLowerInvariant();
71+
if (value.Contains("401", StringComparison.Ordinal)
72+
|| value.Contains("unauthorized", StringComparison.Ordinal)
73+
|| value.Contains("forbidden", StringComparison.Ordinal))
74+
{
75+
return true;
76+
}
77+
78+
if ((value.Contains("api key", StringComparison.Ordinal)
79+
|| value.Contains("token", StringComparison.Ordinal)
80+
|| value.Contains("credential", StringComparison.Ordinal)
81+
|| value.Contains("authentication", StringComparison.Ordinal))
82+
&& (value.Contains("expired", StringComparison.Ordinal)
83+
|| value.Contains("invalid", StringComparison.Ordinal)
84+
|| value.Contains("missing", StringComparison.Ordinal)
85+
|| value.Contains("failed", StringComparison.Ordinal)))
86+
{
87+
return true;
88+
}
89+
90+
return false;
91+
}
92+
93+
private static HttpStatusCode? TryGetStatusCode(Exception exception)
94+
{
95+
if (exception is HttpRequestException { StatusCode: { } httpStatusCode })
96+
{
97+
return httpStatusCode;
98+
}
99+
100+
if (exception is ClientResultException { Status: > 0 } clientResultException)
101+
{
102+
return (HttpStatusCode)clientResultException.Status;
103+
}
104+
105+
var reflected = TryGetReflectedStatusCode(exception);
106+
if (reflected is not null)
107+
{
108+
return reflected;
109+
}
110+
111+
return exception.InnerException is null ? null : TryGetStatusCode(exception.InnerException);
112+
}
113+
114+
private static HttpStatusCode? TryGetReflectedStatusCode(Exception exception)
115+
{
116+
foreach (var propertyName in StatusPropertyNames)
117+
{
118+
var property = exception.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public);
119+
if (property?.GetValue(exception) is not { } value)
120+
{
121+
continue;
122+
}
123+
124+
if (value is HttpStatusCode httpStatusCode)
125+
{
126+
return httpStatusCode;
127+
}
128+
129+
if (value is int intStatusCode and > 0)
130+
{
131+
return (HttpStatusCode)intStatusCode;
132+
}
133+
134+
if (value.GetType().IsEnum)
135+
{
136+
var enumStatusCode = Convert.ToInt32(value, CultureInfo.InvariantCulture);
137+
if (enumStatusCode > 0)
138+
{
139+
return (HttpStatusCode)enumStatusCode;
140+
}
141+
}
142+
}
143+
144+
return null;
145+
}
146+
}

src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public Task<AuthStatus> GetAuthStatusAsync(CancellationToken cancellationToken)
3939
/// <inheritdoc />
4040
public Task<ProviderStreamHandle> StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken)
4141
{
42+
cancellationToken.ThrowIfCancellationRequested();
4243
logger.LogInformation("Starting OpenAI-compatible MEAI stream for request {RequestId}.", request.Id);
4344
return Task.FromResult(new ProviderStreamHandle(request, StreamEventsAsync(request, cancellationToken)));
4445
}

0 commit comments

Comments
 (0)