using System.Text.Json;
using AIStudio.Provider;
using AIStudio.Provider.OpenAI;
namespace AIStudio.Tests.Provider;
///
/// Checks that what OpenAI says a Responses API call cost is read off the completed event, and only
/// believed when it describes the request.
///
///
/// The completed event states the input of the whole response. That is the request as it was sent,
/// unless OpenAI ran a hosted tool along the way: what such a tool found is charged as input of the
/// same response, and no later request carries it. Believe the number then, and a single web search
/// makes the chat look several times as large as it is.
///
[TestFixture]
public sealed class ResponsesUsageTests
{
private const string COMPLETED_PREFIX = """{"type":"response.completed","sequence_number":42,"response":{"id":"resp_1","object":"response","status":"completed","model":"gpt-5","output":[""";
private const string MESSAGE_ITEM = """{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hi there!","annotations":[]}]}""";
private const string USAGE_SUFFIX = ""","usage":{"input_tokens":2006,"input_tokens_details":{"cached_tokens":1920},"output_tokens":300,"output_tokens_details":{"reasoning_tokens":120},"total_tokens":2306}}}""";
[Test]
public void TheCompletedEventStatesWhatTheRequestCarried()
{
//
// The cached tokens are a share of the input tokens, not an addition to them: the request
// carried 2,006 tokens, 1,920 of which came out of the cache.
//
var line = Read(COMPLETED_PREFIX + MESSAGE_ITEM + "]" + USAGE_SUFFIX);
Assert.Multiple(() =>
{
Assert.That(line.GetUsage().IsKnown, Is.True);
Assert.That(line.GetUsage().PromptTokens, Is.EqualTo(2006));
});
}
[Test]
public void ReasoningAndFunctionCallsLeaveTheInputAlone()
{
//
// Both are output the model wrote itself. Neither makes OpenAI add anything to the input,
// and the function call's result comes back in the next request, which states its own.
//
var line = Read(COMPLETED_PREFIX +
"""{"type":"reasoning","id":"rs_1","summary":[],"encrypted_content":"gAAAAAB0aXRs"},""" +
"""{"type":"function_call","call_id":"call_1","name":"web_search","arguments":"{\"query\":\"weather\"}"}""" +
"]" + USAGE_SUFFIX);
Assert.That(line.GetUsage().PromptTokens, Is.EqualTo(2006));
}
[Test]
public void AHostedWebSearchStatesNothing()
{
//
// OpenAI searched on its own, and what it found is part of the input tokens. The next
// request carries none of it.
//
var line = Read(COMPLETED_PREFIX +
"""{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"weather"}},""" +
MESSAGE_ITEM +
"]" + USAGE_SUFFIX);
Assert.That(line.GetUsage().IsKnown, Is.False);
}
[Test]
public void AnOutputItemNobodyKnowsStatesNothing()
{
//
// A hosted tool OpenAI adds later is treated like the web search until somebody checked
// what it does to the input.
//
var line = Read(COMPLETED_PREFIX +
"""{"type":"future_tool_call","id":"ft_1","status":"completed"},""" +
MESSAGE_ITEM +
"]" + USAGE_SUFFIX);
Assert.That(line.GetUsage().IsKnown, Is.False);
}
[Test]
public void ACompletedEventWithoutAUsageStatesNothing()
{
var line = Read(COMPLETED_PREFIX + MESSAGE_ITEM + "]}}");
Assert.That(line.GetUsage().IsKnown, Is.False);
}
private static ResponsesCompletedStreamLine Read(string data) => JsonSerializer.Deserialize(data, ProviderJsonOptions.OPTIONS)!;
}