Skip to content

Commit 4651c7b

Browse files
Arthur-Ficialclaude
andcommitted
fix(review): mid-stream overflow truncates on macOS 27 again, preflight asserts the binary SDK stamp, docs name the stamp condition (#510, #205)
Pre-release review findings on the items 3-5 commit: - StreamErrorResolver treated only .contextOverflow as truncation; the typed .contextWindowExceeded that macOS 27 now produces fell through to .fatal, so a context overflow in the middle of a streamed answer threw away the partial text (exit 4 / HTTP 400 instead of finish_reason length with the content). Tests first (2 new), then the one-line arm. Verified live on 27: a 3312-token prompt now returns 1908 bytes with exit 0 on the CLI and HTTP 200 finish_reason=length on the server. - The macOS 27 runtime hands typed LanguageModelError only to binaries whose LC_BUILD_VERSION sdk stamp is >= 27.0 (proven with vtool restamps); plain `swift build` stamps 26.0. release-preflight now asserts minos 26.0 and sdk >= 27.0 on the release binary; CHANGELOG names the condition; docs/install.md states that source builds need the macOS 27 SDK since 1.15.0 (binary still runs on macOS 26). - Follow-ups filed: #520 (26-SDK source builds), #521 (stamp / build-system deprecation / brew-core divergence), #522 (withRetry and typed errors). - Header banners of the two new files use hyphens. Unit suite 1396 green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent e1a94b5 commit 4651c7b

7 files changed

Lines changed: 45 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ and this project adheres to [https://semver.org/](https://semver.org/).
1010
### Added
1111

1212
- `tool_choice` reaches the macOS 27 runtime (#510 item 3, #197). On macOS 27 apfel sets `GenerationOptions.ToolCallingMode` for the request: `auto` (and an omitted `tool_choice` with tools in scope) maps to `.allowed`, `none` to `.disallowed`, on both `/v1/chat/completions` and `/v1/responses`; the debug trace names the applied mode (`runtime_mode=`). The SDK's `.required` mode is deliberately NOT used: with apfel's out-of-band tool calling (#119 - transcript tool definitions, no registered `FoundationModels.Tool`) the runtime rejected every `.required` request with `LanguageModelError` "An unsupported generation guide was used" (0/10 seeds survived on macOS 27.0.1/M2), while apfel's existing prompt steering + `ToolPolicy` enforcement satisfied `tool_choice: "required"` on 10/10 of the same requests - so `required` and named functions keep the steering + enforcement path on every OS (there is also no per-tool mode in the SDK). Measured before/after on the bundled calculator's `multiply` schema, 10 seeds each: `required` on a greeting prompt 10/10 -> 10/10 tool calls, `auto` on the same prompt 0/10 -> 0/10, `auto` on a multiplication prompt 10/10 -> 10/10, `none` on the multiplication prompt 0/10 -> 0/10 - behavior preserved, with the runtime now told the same contract. macOS 26 generation options are byte-identical to before. New `ApfelCore` surface (additive): `ToolCallingDirective` (#510)
13-
- Typed `LanguageModelError` classification on macOS 27 (#510 item 4, #197). Every main-target catch site now classifies via the typed cases before falling back to the mirror/keyword classifier (which remains the macOS 26 path and the fallback, #181/#193 tests untouched). Context overflow names the real numbers on macOS 27 - CLI: `[context overflow] Input exceeds the model's context window: the input is 4362 tokens, the window is 4096 tokens.` (exit 4), server: the same message on the 400 `context_length_exceeded`, from the SDK payload when the model throws and from apfel's own counted numbers when the pre-generation budget check fires; macOS 26 keeps the generic wording byte-identical (verified on macOS 26.1). A typed `rateLimited` with a `resetDate` becomes a `Retry-After` header on the 429. `timeout`, `unsupportedCapability` and `unsupportedTranscriptContent` have no dedicated `ApfelError` home and map to `.unknown` with the SDK message preserved. New `ApfelCore` surface (additive): `ApfelError.contextWindowExceeded(tokenCount:contextSize:)`, `ApfelError.rateLimitedUntil(retryAfterSeconds:)`, `ApfelError.retryAfterSeconds` property and `ApfelError.retryAfterSeconds(until:now:)` (#510)
13+
- Typed `LanguageModelError` classification on macOS 27 (#510 item 4, #197). Every main-target catch site now classifies via the typed cases before falling back to the mirror/keyword classifier (which remains the macOS 26 path and the fallback, #181/#193 tests untouched). Context overflow names the real numbers on macOS 27 - CLI: `[context overflow] Input exceeds the model's context window: the input is 4362 tokens, the window is 4096 tokens.` (exit 4), server: the same message on the 400 `context_length_exceeded`, from the SDK payload when the model throws and from apfel's own counted numbers when the pre-generation budget check fires; macOS 26 keeps the generic wording byte-identical (verified on macOS 26.1). A typed `rateLimited` with a `resetDate` becomes a `Retry-After` header on the 429. `timeout`, `unsupportedCapability` and `unsupportedTranscriptContent` have no dedicated `ApfelError` home and map to `.unknown` with the SDK message preserved. New `ApfelCore` surface (additive): `ApfelError.contextWindowExceeded(tokenCount:contextSize:)`, `ApfelError.rateLimitedUntil(retryAfterSeconds:)`, `ApfelError.retryAfterSeconds` property and `ApfelError.retryAfterSeconds(until:now:)` (#510) Applies to binaries linked against the macOS 27 SDK (the GitHub release tarball and the Homebrew tap; `make preflight` now asserts the stamp). A binary built from source with a plain `swift build` or an older SDK is stamped 26.0 and keeps the generic messages.
1414

1515
### Changed
1616

1717
- `GenerationOptions` are built with the macOS 27 SDK's `init(samplingMode:...)` instead of the deprecated `init(sampling:...)` (#510 item 5). The new initializer is back-deployed by the SDK to macOS 26 and forwards to the old one, so the sampling mapping (temperature 0 greedy, top-p nucleus, seeded top-k) is identical on both OS versions - verified via the seeded-determinism integration tests on macOS 27.0.1 and a seeded `--temperature 0` double-run on macOS 26.1. This removes the only deprecation warning in a macOS 27 SDK build (#510)
1818

19+
### Fixed
20+
21+
- A context overflow in the middle of a streamed answer on macOS 27 truncates gracefully again (`finish_reason: length`, partial content kept, exit 0) instead of discarding the streamed text with exit 4 / HTTP 400: the typed `contextWindowExceeded` case is now treated like `contextOverflow` by the stream resolver. macOS 26 was never affected (#510).
22+
1923
## [1.15.0] - 2026-10-05
2024

2125
### Added

‎Sources/Core/Chat/StreamOutcome.swift‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ public enum StreamErrorResolver {
5252
/// `prev` is non-empty. Everything else is fatal.
5353
public static func resolve(prev: String, error: ApfelError) -> StreamErrorResolution {
5454
switch error {
55-
case .contextOverflow where !prev.isEmpty:
55+
case .contextOverflow where !prev.isEmpty,
56+
.contextWindowExceeded where !prev.isEmpty:
5657
return .truncated(prev)
5758
default:
5859
return .fatal(error)

‎Sources/ToolCallingSupport.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// ============================================================================
2-
// ToolCallingSupport.swift — macOS 27 GenerationOptions.ToolCallingMode (#510)
2+
// ToolCallingSupport.swift - macOS 27 GenerationOptions.ToolCallingMode (#510)
33
//
44
// macOS 27's generation options can tell the runtime whether the model may
55
// (.allowed) or must not (.disallowed) call the tools presented through

‎Sources/TypedErrorSupport.swift‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
// ============================================================================
2-
// TypedErrorSupport.swift — macOS 27 typed LanguageModelError classification
2+
// TypedErrorSupport.swift - macOS 27 typed LanguageModelError classification
33
// (#510 item 4, #197).
44
//
55
// A binary linked against the macOS 27 SDK receives FoundationModels'
66
// typed `LanguageModelError` from the runtime on macOS 27. macOS 26 keeps
77
// throwing `GenerationError`, and a 26-SDK-linked binary running on 27
8-
// receives a case-less `GenerationError` — both stay classified by
8+
// receives a case-less `GenerationError` - both stay classified by
99
// ApfelCore's pure `ApfelError.classify` (mirror + keyword fallback,
1010
// #181/#193), which also remains the fallback here for anything that is
1111
// not a `LanguageModelError`. The availability decision is made HERE, once

‎Tests/apfelTests/StreamErrorResolverTests.swift‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ import ApfelCore
1313
func runStreamErrorResolverTests() {
1414
// MARK: - Output-side overflow (the only graceful path)
1515

16+
// macOS 27 typed classification yields .contextWindowExceeded for the same
17+
// mid-stream overflow; it must truncate like .contextOverflow, or streamed
18+
// content is thrown away (found in the v1.16.0 pre-release review).
19+
test("contextWindowExceeded + non-empty prev -> truncated") {
20+
let outcome = StreamErrorResolver.resolve(prev: "hello world", error: .contextWindowExceeded(tokenCount: 4098, contextSize: 4096))
21+
switch outcome {
22+
case .truncated(let content): try assertEqual(content, "hello world")
23+
case .fatal: throw TestFailure("expected .truncated, got .fatal")
24+
}
25+
}
26+
test("contextWindowExceeded + empty prev -> fatal") {
27+
let outcome = StreamErrorResolver.resolve(prev: "", error: .contextWindowExceeded(tokenCount: 4098, contextSize: 4096))
28+
guard case .fatal = outcome else { throw TestFailure("expected .fatal") }
29+
}
1630
test("contextOverflow + non-empty prev -> truncated") {
1731
let outcome = StreamErrorResolver.resolve(prev: "hello world", error: .contextOverflow)
1832
switch outcome {

‎docs/install.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Attribute name is `apfel-llm` because nixpkgs already has an unrelated `apfel` p
3434

3535
## Option 3: Build from source
3636

37-
Requires Swift 6.3+ with developer tools that include the **macOS 26.4 SDK**. Command Line Tools are enough - `make install` handles the SwiftPM 6.4 build-system workaround automatically. If you run `swift build` directly (outside the Makefile), add `--build-system native` - see the troubleshooting section below.
37+
Requires Swift 6.3+ with developer tools that include the **macOS 27 SDK** (Command Line Tools 27 or Xcode 27; since apfel 1.15.0 the sources use macOS 27 FoundationModels API behind runtime availability checks, so a 26.x SDK no longer compiles them - the binary still runs on macOS 26). Command Line Tools are enough - `make install` handles the SwiftPM 6.4 build-system workaround automatically. If you run `swift build` directly (outside the Makefile), add `--build-system native` - see the troubleshooting section below.
3838

3939
```bash
4040
git clone https://github.com/Arthur-Ficial/apfel.git

‎scripts/release-preflight.sh‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,26 @@ else
5858
fail "release build"
5959
fi
6060

61+
# --- 4b. Binary stamp: floor macOS 26.0, linked against SDK >= 27.0 ---
62+
# The macOS 27 runtime hands typed LanguageModelError (and other 27 behaviour)
63+
# only to binaries whose LC_BUILD_VERSION sdk stamp is >= 27.0; `swift build`
64+
# without `--build-system native` stamps 26.0 even on a 27 SDK. The release
65+
# tarball must never ship with the wrong stamp (#510, #205).
66+
step "Binary stamp"
67+
stamp=$(vtool -show-build .build/release/apfel 2>/dev/null || true)
68+
minos=$(echo "$stamp" | awk '/minos/ {print $2}')
69+
sdk=$(echo "$stamp" | awk '/sdk/ {print $2}')
70+
if [ "$minos" = "26.0" ]; then
71+
pass "binary minos is 26.0 (macOS 26 floor intact)"
72+
else
73+
fail "binary minos is '$minos', expected 26.0 - the macOS 26 floor moved"
74+
fi
75+
if [ "${sdk%%.*}" -ge 27 ] 2>/dev/null; then
76+
pass "binary linked against SDK $sdk (>= 27.0)"
77+
else
78+
fail "binary sdk stamp is '$sdk' - build with the macOS 27 SDK and --build-system native"
79+
fi
80+
6181
# --- 4b. Man page exists and lints cleanly ---
6282
step "Man page"
6383
if [ -f .build/release/apfel.1 ]; then

0 commit comments

Comments
 (0)