155 lines
7.5 KiB
Go
155 lines
7.5 KiB
Go
package llm
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// serveUsage answers one OpenAI-compat completion with the given usage block verbatim, so a test can
|
|
// say exactly which fields the PROVIDER sent — including the difference between a field carrying 0 and
|
|
// a field that is not there at all.
|
|
func serveUsage(t *testing.T, usage string) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
openAIOK(t, w, `{"id":"r1","choices":[{"message":{"content":"x"},"finish_reason":"stop"}],"usage":`+usage+`}`)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func completeWith(t *testing.T, srv *httptest.Server, sem ReasoningSemantics) *LLMResponse {
|
|
t.Helper()
|
|
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "p", BaseURL: srv.URL, Profile: fastProfile(), Reasoning: sem}, nil)
|
|
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// TestSubsetReasoningIsRecordedWithoutBeingBilled is the thinking share of an already-paid completion:
|
|
// on a subset-billing provider the number the provider reports must arrive as ReasoningInCompletion and
|
|
// must NOT arrive as ReasoningTokens, which ledger.CostUSD adds to the completion.
|
|
//
|
|
// The measured shape this stands on: DeepSeek fills completion_tokens_details on every reply, and the
|
|
// identity total == prompt + completion holds on all of it — the thinking is already paid for inside
|
|
// completion_tokens. Surfacing it as ReasoningTokens would bill it a second time.
|
|
func TestSubsetReasoningIsRecordedWithoutBeingBilled(t *testing.T) {
|
|
srv := serveUsage(t, `{"prompt_tokens":10,"completion_tokens":8496,"total_tokens":8506,
|
|
"completion_tokens_details":{"reasoning_tokens":8496}}`)
|
|
resp := completeWith(t, srv, ReasoningSubset)
|
|
|
|
if resp.Usage.ReasoningInCompletion == nil {
|
|
t.Fatal("a reported thinking count must be recorded, not dropped: ReasoningInCompletion is nil")
|
|
}
|
|
if got := *resp.Usage.ReasoningInCompletion; got != 8496 {
|
|
t.Fatalf("ReasoningInCompletion = %d, want the provider's 8496", got)
|
|
}
|
|
if resp.Usage.ReasoningTokens != 0 {
|
|
t.Fatalf("a subset provider's thinking is already inside completion_tokens; billing it again would double-charge it: ReasoningTokens = %d", resp.Usage.ReasoningTokens)
|
|
}
|
|
if resp.Usage.CompletionTokens != 8496 {
|
|
t.Fatalf("CompletionTokens = %d, want the provider's 8496 untouched", resp.Usage.CompletionTokens)
|
|
}
|
|
}
|
|
|
|
// TestAReportedZeroIsNotTheSameAsNoAnswer is the whole point of the pointer. A subset provider returns
|
|
// a genuine 0 for a call that did not think, and a provider that reports no such field returns nothing
|
|
// at all; spelling both as 0 is what made `reasoning_tokens` read as a measured number for the life of
|
|
// the project while nobody had asked the question.
|
|
func TestAReportedZeroIsNotTheSameAsNoAnswer(t *testing.T) {
|
|
reportedZero := completeWith(t, serveUsage(t,
|
|
`{"prompt_tokens":10,"completion_tokens":1617,"total_tokens":1627,"completion_tokens_details":{"reasoning_tokens":0}}`), ReasoningSubset)
|
|
if reportedZero.Usage.ReasoningInCompletion == nil {
|
|
t.Fatal("a provider that REPORTED zero thinking has answered the question; that answer must not read as silence")
|
|
}
|
|
if got := *reportedZero.Usage.ReasoningInCompletion; got != 0 {
|
|
t.Fatalf("a reported zero must stay zero, got %d", got)
|
|
}
|
|
|
|
noField := completeWith(t, serveUsage(t,
|
|
`{"prompt_tokens":10,"completion_tokens":1617,"total_tokens":1627}`), ReasoningSubset)
|
|
if noField.Usage.ReasoningInCompletion != nil {
|
|
t.Fatalf("a provider that reported nothing must leave the question unanswered, got %d", *noField.Usage.ReasoningInCompletion)
|
|
}
|
|
|
|
emptyDetails := completeWith(t, serveUsage(t,
|
|
`{"prompt_tokens":10,"completion_tokens":1617,"total_tokens":1627,"completion_tokens_details":{}}`), ReasoningSubset)
|
|
if emptyDetails.Usage.ReasoningInCompletion != nil {
|
|
t.Fatalf("a details block without the key is still no answer, got %d", *emptyDetails.Usage.ReasoningInCompletion)
|
|
}
|
|
}
|
|
|
|
// TestAdditiveBillingLeavesTheSubsetFieldEmpty keeps the two questions apart from the other side: where
|
|
// thinking bills ON TOP, the count is money and belongs to ReasoningTokens alone. A number in both
|
|
// fields would be one call's thinking described twice, and the next reader summing them would overcount
|
|
// exactly the share this pack exists to make visible.
|
|
func TestAdditiveBillingLeavesTheSubsetFieldEmpty(t *testing.T) {
|
|
body := `{"prompt_tokens":10,"completion_tokens":5,"total_tokens":65,"completion_tokens_details":{"reasoning_tokens":50}}`
|
|
|
|
additive := completeWith(t, serveUsage(t, body), ReasoningAdditive)
|
|
if additive.Usage.ReasoningTokens != 50 {
|
|
t.Fatalf("additive billing must surface the count as money, got %d", additive.Usage.ReasoningTokens)
|
|
}
|
|
if additive.Usage.ReasoningInCompletion != nil {
|
|
t.Fatalf("additive thinking is NOT inside the completion; the subset field must stay empty, got %d", *additive.Usage.ReasoningInCompletion)
|
|
}
|
|
|
|
total := completeWith(t, serveUsage(t,
|
|
`{"prompt_tokens":22,"completion_tokens":2,"total_tokens":847}`), ReasoningAdditiveTotal)
|
|
if total.Usage.ReasoningTokens != 823 {
|
|
t.Fatalf("additive_total must derive 823 from the total, got %d", total.Usage.ReasoningTokens)
|
|
}
|
|
if total.Usage.ReasoningInCompletion != nil {
|
|
t.Fatalf("additive_total thinking is outside completion_tokens; the subset field must stay empty, got %d", *total.Usage.ReasoningInCompletion)
|
|
}
|
|
}
|
|
|
|
// TestAnUnansweredThinkingShareAddsNoBytes pins the marshalling half of the field's contract. Usage is
|
|
// serialised verbatim into every checkpoint's usage_json, so a field that emitted `"…":null` on every
|
|
// call would change the bytes of every checkpoint a provider that reports nothing ever writes — the
|
|
// byte-identity discipline Capability.MinMaxTokens and SystemMessages carry two files away, and which
|
|
// the comment on this field claims for itself.
|
|
//
|
|
// Measured before this test existed: deleting the tag survived all four packages, the golden included.
|
|
// Nothing HASHES usage_json, which is exactly why nothing went red — and exactly why the claim needed a
|
|
// pin of its own rather than a neighbour's.
|
|
func TestAnUnansweredThinkingShareAddsNoBytes(t *testing.T) {
|
|
silent, err := json.Marshal(Usage{PromptTokens: 10, CompletionTokens: 8496})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(silent), "ReasoningInCompletion") {
|
|
t.Fatalf("a provider that reported nothing must add no bytes at all, got %s", silent)
|
|
}
|
|
|
|
// A MEASURED zero is an answer and must survive to disk; `omitempty` omits a nil pointer, never a
|
|
// pointer to zero, and this is the assertion that says so out loud.
|
|
zero := 0
|
|
measured, err := json.Marshal(Usage{PromptTokens: 10, CompletionTokens: 1617, ReasoningInCompletion: &zero})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(measured), `"ReasoningInCompletion":0`) {
|
|
t.Fatalf("a measured zero must reach the checkpoint as a zero, got %s", measured)
|
|
}
|
|
|
|
// And it round-trips: an old checkpoint decodes to nil, a new one to the number it carried.
|
|
var back Usage
|
|
if err := json.Unmarshal(silent, &back); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if back.ReasoningInCompletion != nil {
|
|
t.Fatalf("a checkpoint written without the field must decode as unanswered, got %d", *back.ReasoningInCompletion)
|
|
}
|
|
if err := json.Unmarshal(measured, &back); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if back.ReasoningInCompletion == nil || *back.ReasoningInCompletion != 0 {
|
|
t.Fatalf("a measured zero must decode as a measured zero, got %v", back.ReasoningInCompletion)
|
|
}
|
|
}
|