AI-Studio/app/Tests/Provider/ResponsesUsageTests.cs
Thorsten Sommer 4baf21656a
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Added the exact token count for Anthropic and OpenAI (#1004)
2026-09-24 13:36:06 +02:00

95 lines
3.9 KiB
C#

using System.Text.Json;
using AIStudio.Provider;
using AIStudio.Provider.OpenAI;
namespace AIStudio.Tests.Provider;
/// <summary>
/// Checks that what OpenAI says a Responses API call cost is read off the completed event, and only
/// believed when it describes the request.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<ResponsesCompletedStreamLine>(data, ProviderJsonOptions.OPTIONS)!;
}