diff --git a/convert.go b/convert.go index 6122f9b..403c731 100644 --- a/convert.go +++ b/convert.go @@ -125,8 +125,22 @@ func toDomainGenerateResponse(resp *GenerateResponse) *domain.GenerateResponse { return nil } return &domain.GenerateResponse{ - Content: resp.Content, - Usage: toDomainTokenUsage(resp.Usage), + Content: resp.Content, + Usage: toDomainTokenUsage(resp.Usage), + UsagePresence: toDomainTokenUsagePresence(resp.UsagePresence), + } +} + +func toDomainTokenUsagePresence(presence *TokenUsagePresence) *domain.TokenUsagePresence { + if presence == nil { + return nil + } + return &domain.TokenUsagePresence{ + PromptTokens: presence.PromptTokens, + CompletionTokens: presence.CompletionTokens, + TotalTokens: presence.TotalTokens, + CachedTokens: presence.CachedTokens, + CacheWriteTokens: presence.CacheWriteTokens, } } diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 434e177..bc6c968 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -234,6 +234,8 @@ Run `go test ./...`. Commit when mappings and existing tests pass. ## Stage 2 — Preserve Usage Presence Through The Adapter +**Completed:** 2026-09-23. + Read wire response decoding, domain/public response types, facade conversion, and JSON contract tests. Add the presence types and optional public response field above. Convert wire usage counts to pointer-aware decoding, retaining diff --git a/internal/domain/domain.go b/internal/domain/domain.go index a6a0410..22636e0 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -306,8 +306,18 @@ type StructuredOutputJSONSpec struct { // GenerateResponse is the response received from the LLM client. type GenerateResponse struct { - Content string - Usage TokenUsage + Content string + Usage TokenUsage + UsagePresence *TokenUsagePresence +} + +// TokenUsagePresence records which token fields a provider reported. +type TokenUsagePresence struct { + PromptTokens bool + CompletionTokens bool + TotalTokens bool + CachedTokens bool + CacheWriteTokens bool } // TokenUsage tracks token consumption. diff --git a/internal/llm/openai_compatible_client.go b/internal/llm/openai_compatible_client.go index 1892e48..a40e1e0 100644 --- a/internal/llm/openai_compatible_client.go +++ b/internal/llm/openai_compatible_client.go @@ -187,15 +187,29 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera return &domain.GenerateResponse{ Content: *content, Usage: domain.TokenUsage{ - PromptTokens: wireResp.Usage.PromptTokens, - CompletionTokens: wireResp.Usage.CompletionTokens, - TotalTokens: wireResp.Usage.TotalTokens, - CachedTokens: wireResp.Usage.PromptTokensDetails.CachedTokens, - CacheWriteTokens: wireResp.Usage.CacheWriteTokens, + PromptTokens: tokenCount(wireResp.Usage.PromptTokens), + CompletionTokens: tokenCount(wireResp.Usage.CompletionTokens), + TotalTokens: tokenCount(wireResp.Usage.TotalTokens), + CachedTokens: tokenCount(wireResp.Usage.PromptTokensDetails.CachedTokens), + CacheWriteTokens: tokenCount(wireResp.Usage.CacheWriteTokens), + }, + UsagePresence: &domain.TokenUsagePresence{ + PromptTokens: wireResp.Usage.PromptTokens != nil, + CompletionTokens: wireResp.Usage.CompletionTokens != nil, + TotalTokens: wireResp.Usage.TotalTokens != nil, + CachedTokens: wireResp.Usage.PromptTokensDetails.CachedTokens != nil, + CacheWriteTokens: wireResp.Usage.CacheWriteTokens != nil, }, }, nil } +func tokenCount(value *int) int { + if value == nil { + return 0 + } + return *value +} + func decodeOpenAIChatResponse(body io.Reader) (openAIChatResponse, error) { limited := &io.LimitedReader{ R: body, @@ -389,13 +403,13 @@ type openAIChatResponse struct { Message openAIChatResponseMessage `json:"message"` } `json:"choices"` Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` + PromptTokens *int `json:"prompt_tokens"` + CompletionTokens *int `json:"completion_tokens"` + TotalTokens *int `json:"total_tokens"` PromptTokensDetails struct { - CachedTokens int `json:"cached_tokens"` + CachedTokens *int `json:"cached_tokens"` } `json:"prompt_tokens_details"` - CacheWriteTokens int `json:"cache_write_tokens"` + CacheWriteTokens *int `json:"cache_write_tokens"` } `json:"usage"` } diff --git a/internal/llm/openai_compatible_client_test.go b/internal/llm/openai_compatible_client_test.go index a60959f..65310bf 100644 --- a/internal/llm/openai_compatible_client_test.go +++ b/internal/llm/openai_compatible_client_test.go @@ -881,6 +881,44 @@ func checkCacheUsageMapping(t *testing.T) { } } +func TestOpenAICompatibleUsagePresence(t *testing.T) { + for _, tc := range []struct { + name string + usage string + want domain.TokenUsage + presence domain.TokenUsagePresence + }{ + {name: "absent"}, + {name: "null", usage: `"usage":null,`}, + {name: "partial nested", usage: `"usage":{"prompt_tokens":7,"prompt_tokens_details":{"cached_tokens":0}},`, + want: domain.TokenUsage{PromptTokens: 7}, presence: domain.TokenUsagePresence{PromptTokens: true, CachedTokens: true}}, + {name: "explicit null fields", usage: `"usage":{"prompt_tokens":null,"completion_tokens":0,"prompt_tokens_details":{"cached_tokens":null}},`, + presence: domain.TokenUsagePresence{CompletionTokens: true}}, + {name: "all reported", usage: `"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3,"prompt_tokens_details":{"cached_tokens":4},"cache_write_tokens":5},`, + want: domain.TokenUsage{PromptTokens: 1, CompletionTokens: 2, TotalTokens: 3, CachedTokens: 4, CacheWriteTokens: 5}, + presence: domain.TokenUsagePresence{PromptTokens: true, CompletionTokens: true, TotalTokens: true, CachedTokens: true, CacheWriteTokens: true}}, + } { + t.Run(tc.name, func(t *testing.T) { + provider := newRecordingProvider(t) + provider.respond(http.StatusOK, `{`+tc.usage+`"choices":[{"message":{"content":"ok"}}]}`) + client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "model"}) + resp, err := client.Generate(context.Background(), ordinaryGenerateRequest()) + if err != nil { + t.Fatal(err) + } + if resp.Usage != tc.want || resp.UsagePresence == nil || *resp.UsagePresence != tc.presence { + t.Fatalf("usage=%+v, presence=%+v", resp.Usage, resp.UsagePresence) + } + }) + } + provider := newRecordingProvider(t) + provider.respond(http.StatusOK, `{"usage":{"prompt_tokens":"seven"},"choices":[{"message":{"content":"ok"}}]}`) + client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "model"}) + if resp, err := client.Generate(context.Background(), ordinaryGenerateRequest()); resp != nil || !errors.Is(err, ErrMalformedResponse) { + t.Fatalf("invalid numeric usage response=%+v, error=%v", resp, err) + } +} + func checkOptionalResponseFieldOmission(t *testing.T) { provider := newRecordingProvider(t) client := newProviderClient(t, provider, OpenAICompatibleConfig{}) diff --git a/json_contract_test.go b/json_contract_test.go index 515de94..9ae78d4 100644 --- a/json_contract_test.go +++ b/json_contract_test.go @@ -12,6 +12,36 @@ import ( "gitea.maximumdirect.net/eric/promptkit" ) +func TestGenerateResponseUsagePresenceJSON(t *testing.T) { + value := promptkit.GenerateResponse{ + Content: "ok", + Usage: promptkit.TokenUsage{TotalTokens: 5}, + UsagePresence: &promptkit.TokenUsagePresence{ + PromptTokens: true, CompletionTokens: true, TotalTokens: true, + CachedTokens: true, CacheWriteTokens: true, + }, + } + payload, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + object := decodeJSONObject(t, payload) + if _, ok := object["usage_presence"]; !ok { + t.Fatalf("missing usage_presence: %s", payload) + } + var decoded promptkit.GenerateResponse + if err := json.Unmarshal(payload, &decoded); err != nil || !reflect.DeepEqual(decoded, value) { + t.Fatalf("round trip = %+v, %v", decoded, err) + } + payload, err = json.Marshal(promptkit.GenerateResponse{}) + if err != nil { + t.Fatal(err) + } + if _, ok := decodeJSONObject(t, payload)["usage_presence"]; ok { + t.Fatalf("nil usage_presence was serialized: %s", payload) + } +} + func TestPreparedRunJSONContractRoundTripsAllFields(t *testing.T) { value := fullyPopulatedPreparedRun() payload, err := json.Marshal(value) diff --git a/llm_adapter_internal_test.go b/llm_adapter_internal_test.go index c81b8fb..0cf29a2 100644 --- a/llm_adapter_internal_test.go +++ b/llm_adapter_internal_test.go @@ -47,6 +47,30 @@ func TestPublicLLMClientAdapterGivesClientOwnedNestedValues(t *testing.T) { } } +func TestPublicLLMClientAdapterCopiesUsagePresence(t *testing.T) { + presence := &TokenUsagePresence{PromptTokens: true} + client := &usagePresenceClient{response: &GenerateResponse{ + Content: "ok", + Usage: TokenUsage{PromptTokens: 0, TotalTokens: 5}, + UsagePresence: presence, + }} + adapter := publicLLMClientAdapter{client: client} + got, err := adapter.Generate(context.Background(), domain.GenerateRequest{}) + if err != nil { + t.Fatal(err) + } + presence.PromptTokens = false + if got.UsagePresence == nil || !got.UsagePresence.PromptTokens || got.UsagePresence.TotalTokens || got.Usage.TotalTokens != 5 { + t.Fatalf("copied response = %+v", got) + } +} + +type usagePresenceClient struct{ response *GenerateResponse } + +func (c *usagePresenceClient) Generate(context.Context, GenerateRequest) (*GenerateResponse, error) { + return c.response, nil +} + type retainingMutatingLLMClient struct { retained GenerateRequest } diff --git a/types.go b/types.go index ae71504..2197716 100644 --- a/types.go +++ b/types.go @@ -709,8 +709,13 @@ type StructuredOutputJSONSpec struct { // // An arbitrary returned error makes Run or RunPrepared return ErrLLMGenerate // while preserving the client error through errors.Is rather than translating -// it. A nil response with a nil error also produces ErrLLMGenerate. Promptkit -// copies the non-nil response before returning from either method. +// it. A nil response with a nil error also produces ErrLLMGenerate and +// ErrMalformedResponse. Promptkit copies the non-nil response before returning +// from either method. When UsagePresence is nil, nonzero usage counts are +// treated as known and zero counts as unknown for observation only; the legacy +// interface cannot distinguish omitted zero from measured zero. An explicit +// UsagePresence can report known zero counts. Result usage sums the supplied +// Usage values independently of presence. type LLMClient interface { Generate(context.Context, GenerateRequest) (*GenerateResponse, error) } @@ -741,6 +746,19 @@ type GenerateResponse struct { Content string `json:"content"` // Usage is the client's token accounting. Usage TokenUsage `json:"usage"` + // UsagePresence states which usage fields were reported, including zeros. + // Nil uses the legacy observation fallback described by LLMClient. + UsagePresence *TokenUsagePresence `json:"usage_presence,omitempty"` +} + +// TokenUsagePresence identifies provider-reported token counts for observation. +// It does not change TokenUsage values or successful-result accounting. +type TokenUsagePresence struct { + PromptTokens bool `json:"prompt_tokens"` + CompletionTokens bool `json:"completion_tokens"` + TotalTokens bool `json:"total_tokens"` + CachedTokens bool `json:"cached_tokens"` + CacheWriteTokens bool `json:"cache_write_tokens"` } // File returns a file-backed artifact reference whose URI is path.