Files

615 lines
25 KiB
C#

using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
namespace Volcanic.AgenticCore.Extensions.Anthropic;
/// <summary>
/// Streams <c>POST /v1/messages</c> (Anthropic Messages API, SSE) and maps the wire events to
/// <see cref="AssistantMessageEvent"/>s. Never throws from <see cref="StreamAsync"/>: every failure is an
/// <see cref="AssistantMessageEvent.Error"/>.
/// </summary>
/// <param name="http">
/// Optional client. When omitted a shared client with an infinite <see cref="HttpClient.Timeout"/> is used;
/// a caller-supplied client keeps its own timeout, which also bounds the streamed body.
/// </param>
public sealed class AnthropicApi(HttpClient? http = null)
{
public const string ApiVersion = "2023-06-01";
/// <summary>Headroom added to <c>max_tokens</c> when the thinking budget would not fit under it.</summary>
private const int ThinkingHeadroom = 4_096;
private const int ErrorExcerptLength = 500;
private static readonly Lazy<HttpClient> SharedClient = new(() => new HttpClient { Timeout = Timeout.InfiniteTimeSpan });
private static readonly IReadOnlyDictionary<ThinkingLevel, int> DefaultBudgets = new Dictionary<ThinkingLevel, int>
{
[ThinkingLevel.Minimal] = 1_024,
[ThinkingLevel.Low] = 2_048,
[ThinkingLevel.Medium] = 8_192,
[ThinkingLevel.High] = 16_384,
[ThinkingLevel.XHigh] = 32_768,
[ThinkingLevel.Max] = 32_768,
};
private static readonly HashSet<string> EffortNames = new(StringComparer.OrdinalIgnoreCase) { "low", "medium", "high", "xhigh", "max" };
private readonly HttpClient _http = http ?? SharedClient.Value;
public async IAsyncEnumerable<AssistantMessageEvent> StreamAsync(
Model model, LlmContext context, StreamOptions options, [EnumeratorCancellation] CancellationToken ct)
{
var builder = new AssistantMessageBuilder(model);
if (ct.IsCancellationRequested)
{
yield return builder.Fail("aborted", aborted: true);
yield break;
}
CancellationTokenSource? timeoutCts = null;
if (options.Timeout is { } timeout)
{
timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeout);
}
var token = timeoutCts?.Token ?? ct;
HttpResponseMessage? response = null;
Stream? body = null;
string? failure = null;
var aborted = false;
try
{
try
{
JsonNode payload = BuildRequest(model, context, options);
if (options.OnPayload is { } onPayload) payload = await onPayload(payload).ConfigureAwait(false) ?? payload;
using var request = CreateRequest(model, options, payload);
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false);
if (options.OnResponse is { } onResponse) await onResponse(Describe(response)).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
body = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
}
else
{
var text = await response.Content.ReadAsStringAsync(token).ConfigureAwait(false);
failure = FormatHttpError((int)response.StatusCode, text);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { aborted = true; }
catch (OperationCanceledException) { failure = TimeoutMessage(options); }
catch (Exception ex) { failure = ex.Message; }
if (aborted)
{
yield return builder.Fail("aborted", aborted: true);
yield break;
}
if (failure is not null || body is null)
{
yield return builder.Fail(failure ?? "No response body.");
yield break;
}
yield return builder.Start();
var state = new StreamState(model, builder, options);
var events = new List<AssistantMessageEvent>();
await using var items = SseParser.Create(body).EnumerateAsync(token).GetAsyncEnumerator(token);
while (!state.Finished)
{
events.Clear();
try
{
if (!await items.MoveNextAsync().ConfigureAwait(false)) break;
await state.HandleAsync(items.Current, events).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { events.Add(state.Abort()); }
catch (OperationCanceledException) { events.Add(state.Fail(TimeoutMessage(options))); }
catch (Exception ex) { events.Add(state.Fail(ex.Message)); }
foreach (var e in events) yield return e;
}
if (!state.Finished) yield return state.EndOfStream();
}
finally
{
response?.Dispose();
timeoutCts?.Dispose();
}
}
// ---- request building -------------------------------------------------------------------------------------
/// <summary>Builds the Messages API request body. Exposed for tests and payload hooks.</summary>
public static JsonObject BuildRequest(Model model, LlmContext context, StreamOptions options)
{
var retention = options.CacheRetention;
var body = new JsonObject
{
["model"] = model.Id,
["max_tokens"] = options.MaxTokens ?? model.MaxTokens,
["stream"] = true,
};
var thinking = ResolveThinking(model, options);
if (thinking is null && options.Temperature is { } temperature) body["temperature"] = temperature;
if (!string.IsNullOrWhiteSpace(context.SystemPrompt))
{
var system = new JsonObject { ["type"] = "text", ["text"] = context.SystemPrompt };
if (retention != CacheRetention.None) system["cache_control"] = CacheControl(retention);
body["system"] = new JsonArray(system);
}
body["messages"] = BuildMessages(context.Messages, retention);
if (context.Tools.Count > 0) body["tools"] = BuildTools(context.Tools);
if (thinking is not null)
{
body["thinking"] = thinking.Node;
if (thinking.Budget is { } budget && budget >= body["max_tokens"]!.GetValue<int>())
body["max_tokens"] = budget + ThinkingHeadroom;
if (thinking.Effort is not null)
body["output_config"] = new JsonObject { ["effort"] = thinking.Effort };
}
return body;
}
private static JsonArray BuildMessages(IReadOnlyList<Message> messages, CacheRetention retention)
{
var result = new JsonArray();
string? lastRole = null;
JsonArray? lastContent = null;
void Append(string role, List<JsonObject> blocks)
{
if (blocks.Count == 0) return;
if (role != lastRole || lastContent is null)
{
lastContent = new JsonArray();
lastRole = role;
result.Add(new JsonObject { ["role"] = role, ["content"] = lastContent });
}
foreach (var block in blocks) lastContent.Add(block);
}
foreach (var message in messages)
{
switch (message)
{
case UserMessage user: Append("user", UserBlocks(user.Content)); break;
case CustomMessage custom: Append("user", UserBlocks(custom.Content)); break;
case ToolResultMessage toolResult: Append("user", [ToolResultBlock(toolResult)]); break;
case AssistantMessage assistant: Append("assistant", AssistantBlocks(assistant.Content)); break;
}
}
if (retention != CacheRetention.None)
{
for (var i = result.Count - 1; i >= 0; i--)
{
var entry = result[i]!.AsObject();
if (entry["role"]!.GetValue<string>() != "user") continue;
var content = entry["content"]!.AsArray();
if (content.Count > 0) content[content.Count - 1]!.AsObject()["cache_control"] = CacheControl(retention);
break;
}
}
return result;
}
private static List<JsonObject> UserBlocks(IReadOnlyList<ContentBlock> content)
{
var blocks = new List<JsonObject>();
foreach (var block in content)
{
switch (block)
{
case TextContent text when !string.IsNullOrWhiteSpace(text.Text):
blocks.Add(TextBlock(text.Text));
break;
case ImageContent image:
blocks.Add(ImageBlock(image));
break;
}
}
return blocks;
}
private static List<JsonObject> AssistantBlocks(IReadOnlyList<ContentBlock> content)
{
var blocks = new List<JsonObject>();
foreach (var block in content)
{
switch (block)
{
case TextContent text when !string.IsNullOrWhiteSpace(text.Text):
blocks.Add(TextBlock(text.Text));
break;
case ThinkingContent { Redacted: true, Signature: { } data }:
blocks.Add(new JsonObject { ["type"] = "redacted_thinking", ["data"] = data });
break;
case ThinkingContent { Redacted: false, Signature: { } signature } thinking:
blocks.Add(new JsonObject { ["type"] = "thinking", ["thinking"] = thinking.Thinking, ["signature"] = signature });
break;
case ToolCallContent call:
blocks.Add(new JsonObject
{
["type"] = "tool_use",
["id"] = call.Id,
["name"] = call.Name,
["input"] = call.Arguments.ValueKind == JsonValueKind.Object ? JsonNode.Parse(call.Arguments.GetRawText()) : new JsonObject(),
});
break;
}
}
return blocks;
}
private static JsonObject ToolResultBlock(ToolResultMessage result)
{
var block = new JsonObject { ["type"] = "tool_result", ["tool_use_id"] = result.ToolCallId };
var content = UserBlocks(result.Content);
if (content.Count > 0) block["content"] = new JsonArray(content.ToArray<JsonNode?>());
block["is_error"] = result.IsError;
return block;
}
private static JsonArray BuildTools(IReadOnlyList<ToolDescriptor> tools)
{
var array = new JsonArray();
foreach (var tool in tools)
{
array.Add(new JsonObject
{
["name"] = tool.Name,
["description"] = tool.Description,
["input_schema"] = tool.Parameters.ValueKind == JsonValueKind.Object
? JsonNode.Parse(tool.Parameters.GetRawText())
: new JsonObject { ["type"] = "object", ["properties"] = new JsonObject() },
});
}
return array;
}
private sealed record ThinkingPlan(JsonObject Node, int? Budget, string? Effort);
/// <summary>
/// Resolves the <c>thinking</c> parameter. A numeric <see cref="Model.ThinkingLevelMap"/> value (or the default
/// table) becomes <c>{type:"enabled", budget_tokens}</c>; a non-numeric value (for example <c>adaptive</c> or an
/// effort name) becomes <c>{type:"adaptive"}</c> for models that reject <c>budget_tokens</c>. A <c>null</c> entry
/// marks the level unsupported and the next lower level is used.
/// </summary>
private static ThinkingPlan? ResolveThinking(Model model, StreamOptions options)
{
if (!model.Reasoning || options.Reasoning is null or ThinkingLevel.Off) return null;
for (var level = options.Reasoning.Value; level >= ThinkingLevel.Minimal; level--)
{
string? mapped;
if (model.ThinkingLevelMap is null || !model.ThinkingLevelMap.TryGetValue(level, out mapped))
mapped = DefaultBudgets[level].ToString();
if (mapped is null) continue;
if (int.TryParse(mapped, out var budget))
return new ThinkingPlan(new JsonObject { ["type"] = "enabled", ["budget_tokens"] = budget }, budget, null);
var effort = EffortNames.Contains(mapped) ? mapped.ToLowerInvariant() : null;
// Newer models omit thinking text by default; ask for the readable summary so UIs can show it.
return new ThinkingPlan(new JsonObject { ["type"] = "adaptive", ["display"] = "summarized" }, null, effort);
}
return null;
}
private static JsonObject CacheControl(CacheRetention retention)
{
var node = new JsonObject { ["type"] = "ephemeral" };
if (retention == CacheRetention.Long) node["ttl"] = "1h";
return node;
}
private static JsonObject TextBlock(string text) => new() { ["type"] = "text", ["text"] = text };
private static JsonObject ImageBlock(ImageContent image) => new()
{
["type"] = "image",
["source"] = new JsonObject { ["type"] = "base64", ["media_type"] = image.MimeType, ["data"] = image.Data },
};
// ---- HTTP -------------------------------------------------------------------------------------------------
private static HttpRequestMessage CreateRequest(Model model, StreamOptions options, JsonNode payload)
{
var baseUrl = (model.BaseUrl ?? AnthropicModels.DefaultBaseUrl).TrimEnd('/');
var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/v1/messages")
{
Content = new StringContent(payload.ToJsonString(VolcanicJson.Options), Encoding.UTF8, "application/json"),
};
var headers = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["x-api-key"] = options.ApiKey,
["anthropic-version"] = ApiVersion,
["accept"] = "text/event-stream",
};
if (model.Headers is not null) foreach (var (name, value) in model.Headers) headers[name] = value;
if (options.Headers is not null) foreach (var (name, value) in options.Headers) headers[name] = value;
foreach (var (name, value) in headers) ApplyHeader(request, name, value);
return request;
}
private static void ApplyHeader(HttpRequestMessage request, string name, string? value)
{
var isContentHeader = name.StartsWith("content-", StringComparison.OrdinalIgnoreCase);
var target = isContentHeader ? request.Content!.Headers : (System.Net.Http.Headers.HttpHeaders)request.Headers;
target.Remove(name);
if (value is not null) target.TryAddWithoutValidation(name, value);
}
private static ProviderResponseInfo Describe(HttpResponseMessage response)
{
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (name, values) in response.Headers) headers[name] = string.Join(", ", values);
foreach (var (name, values) in response.Content.Headers) headers[name] = string.Join(", ", values);
return new ProviderResponseInfo((int)response.StatusCode, headers);
}
private static string FormatHttpError(int status, string body)
{
string? message = null;
string? type = null;
try
{
using var doc = JsonDocument.Parse(body);
if (TryGet(doc.RootElement, "error", out var error))
{
message = GetString(error, "message");
type = GetString(error, "type");
}
}
catch (JsonException) { }
var detail = message ?? Truncate(body.Trim(), ErrorExcerptLength);
if (string.IsNullOrEmpty(detail)) return $"HTTP {status}";
return type is null ? $"HTTP {status}: {detail}" : $"HTTP {status}: {detail} ({type})";
}
private static string TimeoutMessage(StreamOptions options) =>
options.Timeout is { } t ? $"Request timed out after {t.TotalSeconds:0.#}s." : "Request timed out.";
private static string Truncate(string text, int max) => text.Length <= max ? text : text[..max] + "…";
// ---- SSE mapping ------------------------------------------------------------------------------------------
/// <summary>Per-stream mapping state: which block kind is open and the stop reason seen so far.</summary>
private sealed class StreamState(Model model, AssistantMessageBuilder builder, StreamOptions options)
{
private enum OpenKind { None, Text, Thinking, ToolCall, Other }
private OpenKind _open = OpenKind.None;
private string? _stopReason;
public bool Finished { get; private set; }
public async ValueTask HandleAsync(SseItem<string> item, List<AssistantMessageEvent> events)
{
if (string.IsNullOrWhiteSpace(item.Data)) return;
var root = VolcanicJson.Parse(item.Data);
if (options.OnProviderStreamEvent is { } hook) await hook(root).ConfigureAwait(false);
var type = GetString(root, "type") ?? item.EventType;
switch (type)
{
case "message_start":
if (TryGet(root, "message", out var message))
{
builder.SetResponseId(GetString(message, "id"));
builder.SetResponseModel(GetString(message, "model"));
if (TryGet(message, "usage", out var usage)) builder.SetUsage(ReadUsage(usage, builder.Usage));
}
break;
case "content_block_start":
if (TryGet(root, "content_block", out var block)) StartBlock(block, events);
break;
case "content_block_delta":
if (TryGet(root, "delta", out var delta)) ApplyDelta(delta, events);
break;
case "content_block_stop":
CloseOpen(events);
break;
case "message_delta":
if (TryGet(root, "delta", out var messageDelta)) _stopReason = GetString(messageDelta, "stop_reason") ?? _stopReason;
if (TryGet(root, "usage", out var deltaUsage)) builder.SetUsage(ReadUsage(deltaUsage, builder.Usage));
break;
case "message_stop":
Finish(events);
break;
case "error":
var error = TryGet(root, "error", out var err) ? GetString(err, "message") ?? GetString(err, "type") : null;
events.Add(Fail(error ?? "Unknown stream error."));
break;
// "ping" and anything unknown are ignored.
}
}
public AssistantMessageEvent EndOfStream()
{
var events = new List<AssistantMessageEvent>(1);
if (_stopReason is not null) Finish(events);
else events.Add(Fail("Stream ended before message_stop."));
return events[^1];
}
public AssistantMessageEvent Fail(string message)
{
Finished = true;
ApplyCost();
return builder.Fail(message);
}
public AssistantMessageEvent Abort()
{
Finished = true;
ApplyCost();
return builder.Fail("aborted", aborted: true);
}
private void StartBlock(JsonElement block, List<AssistantMessageEvent> events)
{
CloseOpen(events);
switch (GetString(block, "type"))
{
case "text":
_open = OpenKind.Text;
events.Add(builder.StartText());
if (GetString(block, "text") is { Length: > 0 } text) events.Add(builder.AppendText(text));
break;
case "thinking":
_open = OpenKind.Thinking;
events.Add(builder.StartThinking());
if (GetString(block, "thinking") is { Length: > 0 } thinking) events.Add(builder.AppendThinking(thinking));
if (GetString(block, "signature") is { Length: > 0 } signature) builder.AppendThinkingSignature(signature);
break;
case "redacted_thinking":
_open = OpenKind.Thinking;
events.Add(builder.StartThinking(redacted: true));
if (GetString(block, "data") is { Length: > 0 } data) builder.AppendThinkingSignature(data);
break;
case "tool_use":
_open = OpenKind.ToolCall;
events.Add(builder.StartToolCall(GetString(block, "id") ?? "", GetString(block, "name") ?? ""));
break;
default:
_open = OpenKind.Other;
break;
}
}
private void ApplyDelta(JsonElement delta, List<AssistantMessageEvent> events)
{
switch (GetString(delta, "type"))
{
case "text_delta":
if (GetString(delta, "text") is { } text)
{
EnsureOpen(OpenKind.Text, events);
events.Add(builder.AppendText(text));
}
break;
case "thinking_delta":
if (GetString(delta, "thinking") is { } thinking)
{
EnsureOpen(OpenKind.Thinking, events);
events.Add(builder.AppendThinking(thinking));
}
break;
case "signature_delta":
if (GetString(delta, "signature") is { } signature)
{
EnsureOpen(OpenKind.Thinking, events);
builder.AppendThinkingSignature(signature);
}
break;
case "input_json_delta":
if (_open == OpenKind.ToolCall && GetString(delta, "partial_json") is { } json)
events.Add(builder.AppendToolCallDelta(json));
break;
}
}
/// <summary>Opens <paramref name="kind"/> (emitting its start event) if a different kind is open, so events stay balanced.</summary>
private void EnsureOpen(OpenKind kind, List<AssistantMessageEvent> events)
{
if (_open == kind) return;
CloseOpen(events);
_open = kind;
events.Add(kind == OpenKind.Text ? builder.StartText() : builder.StartThinking());
}
private void CloseOpen(List<AssistantMessageEvent> events)
{
if (builder.HasOpenBlock)
{
switch (_open)
{
case OpenKind.Text: events.Add(builder.EndText()); break;
case OpenKind.Thinking: events.Add(builder.EndThinking()); break;
case OpenKind.ToolCall: events.Add(builder.EndToolCall()); break;
}
}
_open = OpenKind.None;
}
private void Finish(List<AssistantMessageEvent> events)
{
CloseOpen(events);
Finished = true;
ApplyCost();
events.Add(_stopReason switch
{
"max_tokens" => builder.Done(StopReason.Length),
"tool_use" => builder.Done(StopReason.ToolUse),
"refusal" => builder.Fail("refusal"),
// end_turn, stop_sequence, pause_turn, null and anything unknown.
_ => builder.Done(StopReason.Stop),
});
}
private void ApplyCost()
{
var usage = builder.Usage;
builder.SetUsage(usage with { Cost = model.CalculateCost(usage) });
}
private static Usage ReadUsage(JsonElement usage, Usage current) => current with
{
Input = GetInt(usage, "input_tokens") ?? current.Input,
Output = GetInt(usage, "output_tokens") ?? current.Output,
CacheRead = GetInt(usage, "cache_read_input_tokens") ?? current.CacheRead,
CacheWrite = GetInt(usage, "cache_creation_input_tokens") ?? current.CacheWrite,
};
}
// ---- JSON helpers -----------------------------------------------------------------------------------------
private static bool TryGet(JsonElement element, string name, out JsonElement value)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out value)) return true;
value = default;
return false;
}
private static string? GetString(JsonElement element, string name) =>
TryGet(element, name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
private static int? GetInt(JsonElement element, string name) =>
TryGet(element, name, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n) ? n : null;
}