615 lines
25 KiB
C#
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;
|
|
}
|