Skip to content

fix(usage): fold repair-round usage into refusal responses on macOS 27 (#516) - #518

Draft
Arthur-Ficial wants to merge 4 commits into
mainfrom
claude/zealous-mayer-vkpanu
Draft

Arthur-Ficial wants to merge 4 commits into
mainfrom
claude/zealous-mayer-vkpanu

Conversation

@Arthur-Ficial

Copy link
Copy Markdown
Owner

Draft PR - needs @franzenzenhofer review and local test run before merging.

Fixes #516 (item 1).

Summary

Root cause

On macOS 27, when a tool-policy repair round completes and then the subsequent model call throws a refusal, the repair round's runtime-reported usage (stored in usageRounds) was silently dropped by all five refusal paths. Those paths used promptTokens.resolve() + promptAdjustment for prompt tokens and counted only the refusal text for completion tokens. On macOS 26 this is correct because promptAdjustment accounts for the discarded round's overhead - but on macOS 27, promptAdjustment stays 0 (the runtime reports usage instead of counting), so the refusal's usage object under-reports both prompt and completion tokens.

The fix

Added TokenUsage.refusalBase(rounds:countedPromptTokens:) to ApfelCore: when usageRounds is non-empty (macOS 27), it returns the sum's prompt and completion tokens; otherwise it falls back to the counted prompt figure with zero prior completion tokens (macOS 26 path, unchanged). All five refusal sites now call this helper:

  • Handlers.swift: tool-call handler non-streaming refusal, tool-call handler streaming refusal, non-streaming handler refusal, streaming handler inline refusal (also includes lastRoundUsage like the truncated-content path does)
  • ResponsesHandlers.swift: /v1/responses refusal

The refusalNonStreamingResponse and refusalStreamingResponse helpers gain a priorCompletionTokens: Int = 0 parameter so they add the repair round's completion tokens to the counted refusal text.

Test coverage

  • Added 4 tests in Tests/apfelTests/TokenUsageTests.swift:
    • refusalBase with no rounds falls back to counted prompt tokens (macOS 26 path)
    • refusalBase with one round uses reported prompt and completion tokens (macOS 27, one round before refusal)
    • refusalBase with repair round sums both rounds (macOS 27, initial + repair before refusal)
    • refusalBase ignores countedPromptTokens when rounds are present
  • Existing TokenUsage.sum tests still cover the underlying summing.

Test plan

  • swift build -c release
  • swift run apfel-tests
  • make install and manual smoke test

What I verified (static only)

  • Read all five refusal code paths and traced usageRounds scope in each handler.
  • Confirmed that the truncated-content path in the streaming handler (line 889) already uses usageRounds + (lastRoundUsage.map { [$0] } ?? []) - the streaming refusal now matches that pattern.
  • Verified call sites without repair rounds (structured non-streaming at line 1049, structured streaming at line 1219) are not touched - they have no usageRounds to fold.
  • Swift 6 concurrency: no data races introduced - refusalBase is a pure static function on a Sendable struct.
  • Diff limited to Sources/Core/TokenUsage.swift, Sources/Handlers.swift, Sources/ResponsesHandlers.swift, Tests/apfelTests/TokenUsageTests.swift, and CHANGELOG.md - no touches to release infrastructure, guardrails, CI, or dependencies.
  • Default parameter priorCompletionTokens: Int = 0 ensures the two call sites without repair rounds (structured handlers) remain unchanged if ever converted to use the helpers.

What I did NOT verify

  • Functional correctness. This needs make test on a Mac with Apple Intelligence. I cannot run FoundationModels code. If the fix does not actually resolve the reported behaviour, that is on me to refine - ping me in a review comment.

Item #2 (counted-path unit test for #176 guard)

Item #2 from the issue (explicit counted-path unit test for TokenCounter.count(entries:) including toolDefinitions) requires FoundationModels types (Transcript.Entry, Transcript.ToolDefinition) which are unavailable in the pure test runner and cannot be written from this cloud environment. This needs to be authored on a Mac. The fallbackCount(entries:) method at Sources/TokenCounter.swift:187 is private and tied to the FoundationModels Transcript type, so the test belongs in the integration suite or a new macOS-only unit test target.

Anything that seemed suspicious

Nothing - straightforward follow-up from the native-usage review. Issue filed by the repo owner.


cc @franzenzenhofer - draft stays draft until you review, test locally, and mark ready.

Generated by the apfel bug-solver routine.

https://claude.ai/code/session_01WjhuKcS1iPkKapmj84Zhah


Generated by Claude Code

#516)

On macOS 27, when a repair round completes before the model throws a
refusal, the repair round's runtime-reported usage was stored in
usageRounds but all five refusal paths ignored it, under-reporting
prompt and completion tokens. This adds TokenUsage.refusalBase() to
select the runtime sum (macOS 27) or the counted fallback (macOS 26)
and wires it into every refusal site in Handlers.swift and
ResponsesHandlers.swift.

Drafted by the apfel bug-solver routine. Needs review + local test run
by @franzenzenhofer on a Mac with Apple Intelligence before merging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjhuKcS1iPkKapmj84Zhah
claude added 3 commits October 5, 2026 14:52
The v1.14.0 release on main stamped the [Unreleased] section while this
branch was in flight. Git's auto-merge placed the new Fixed entry inside
[1.14.0] instead of [Unreleased]. Moved it back so changelog-gate passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjhuKcS1iPkKapmj84Zhah
Swift does not allow `await` on the right side of a non-assignment
operator. Split `priorCompletionTokens + await TokenCounter...count()`
into a separate let binding in all four affected sites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjhuKcS1iPkKapmj84Zhah

Copy link
Copy Markdown
Owner Author

The build-and-test CI failure is not this PR's. The errors are in Sources/UsageAccounting.swift (macOS 27 APIs: LanguageModelSession.Usage, Response.usage, Snapshot.usage) which was introduced by the v1.14.0 release. The macOS 26 CI runner's SDK does not have these types. Main's own CI (run 717, commit ef364fb) fails with the same errors.

This PR's code changes compile cleanly - the earlier await operator error was fixed in 3bd6baa. The changelog-gate job should now pass (verified locally).

I don't have the means to re-run CI, and this failure needs the CI workflow or UsageAccounting.swift to be fixed on main first - that's outside this PR's scope (CI workflow changes are forbidden for routine-drafted PRs).

Cheers, Arthur

cc @franzenzenhofer


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

usage on macOS 27: include repair-round usage in refusals; #176 guard needs a counted-path unit test

2 participants