Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,5 +127,15 @@ let package = Package(
),
],
),
.testTarget(
name: "AnyLanguageModelMacrosTests",
dependencies: [
"AnyLanguageModelMacros",
.product(name: "SwiftParser", package: "swift-syntax"),
.product(name: "SwiftSyntax", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacroExpansion", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
]
),
]
)
11 changes: 5 additions & 6 deletions Sources/AnyLanguageModel/Extensions/HTTPClient+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,12 @@
// response to start unless it says otherwise. In nanoseconds, as far as they go: a
// timeout too long to count in them has no limit.
let (seconds, attoseconds) = (timeout ?? .seconds(180)).components
let (nanoseconds, overflowed) = seconds.multipliedReportingOverflow(by: 1_000_000_000)
let received = try await execute(
sent,
timeout: .nanoseconds(
overflowed ? .max : nanoseconds.addingReportingOverflow(attoseconds / 1_000_000_000).partialValue
)
let (wholeNanoseconds, multiplicationOverflowed) = seconds.multipliedReportingOverflow(by: 1_000_000_000)
let (nanoseconds, additionOverflowed) = wholeNanoseconds.addingReportingOverflow(
attoseconds / 1_000_000_000
)
let overflowed = multiplicationOverflowed || additionOverflowed
let received = try await execute(sent, timeout: .nanoseconds(overflowed ? .max : nanoseconds))
var fields = HTTPFields()
for (name, value) in received.headers {
guard let name = HTTPField.Name(name) else { continue }
Expand Down
9 changes: 7 additions & 2 deletions Sources/AnyLanguageModel/GenerationSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,14 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible
}
if type == "integer" {
// An integer at least 1.5 is at least 2: its bounds are the whole numbers within them.
// A whole-number bound is decoded as an `Int`, which a `Double` may not hold exactly.
// A bound no `Int` can hold, such as one of a `UInt64` range, is dropped.
let wholeMinimum = minimum.flatMap { Int(exactly: $0.rounded(.up)) }
let wholeMaximum = maximum.flatMap { Int(exactly: $0.rounded(.down)) }
let wholeMinimum =
(try? container.decodeIfPresent(Int.self, forKey: .minimum))
?? minimum.flatMap { Int(exactly: $0.rounded(.up)) }
let wholeMaximum =
(try? container.decodeIfPresent(Int.self, forKey: .maximum))
?? maximum.flatMap { Int(exactly: $0.rounded(.down)) }
guard let bounds = Bounds(minimum: wholeMinimum, maximum: wholeMaximum) else {
throw impossible(
"No whole number is within minimum \(minimum as Any) and maximum \(maximum as Any)."
Expand Down
2 changes: 2 additions & 0 deletions Sources/AnyLanguageModel/LanguageModelSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1687,6 +1687,8 @@ extension LanguageModelSession.ResponseStream: AsyncSequence {
}

if let fallbackSnapshot {
// A response made whole isn't returned to a task that was cancelled for it either.
try Task.checkCancellation()
let finalContent: Content
if let concrete = fallbackSnapshot.content as? Content {
finalContent = concrete
Expand Down
4 changes: 3 additions & 1 deletion Sources/AnyLanguageModel/Models/SystemLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,9 @@
@available(macOS 26.0, iOS 26.0, watchOS 27.0, tvOS 26.0, visionOS 26.0, *)
extension Tool {
fileprivate func callFunction(content: GeneratedContent) async throws -> any PromptRepresentable {
try await call(arguments: Self.Arguments(content))
let arguments = try Self.Arguments(content)
// What a tool asks of a model is its own: it isn't counted for the response that called it.
return try await RequestUsageChannel.outside { try await call(arguments: arguments) }
}
}

Expand Down
15 changes: 13 additions & 2 deletions Sources/AnyLanguageModelMacros/GenerableMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -266,10 +266,21 @@ public struct GenerableMacro: MemberMacro, ExtensionMacro {
{
constraints.maximumCount = value
}
// As in a schema built from the guides directly,
// a later bound replaces an earlier one of the other kind that it contradicts,
// so the generated guides never make a range out of order.
case "minimum":
constraints.minimum = parseNumericLiteral(firstArgument.expression)
guard let minimum = parseNumericLiteral(firstArgument.expression) else { return }
constraints.minimum = minimum
if let maximum = constraints.maximum, maximum < minimum {
constraints.maximum = nil
}
case "maximum":
constraints.maximum = parseNumericLiteral(firstArgument.expression)
guard let maximum = parseNumericLiteral(firstArgument.expression) else { return }
constraints.maximum = maximum
if let minimum = constraints.minimum, minimum > maximum {
constraints.minimum = nil
}
case "range":
if let rangeExpression = firstArgument.expression.as(SequenceExprSyntax.self) {
let (minimum, maximum) = parseClosedRangeDouble(rangeExpression)
Expand Down
72 changes: 72 additions & 0 deletions Tests/AnyLanguageModelMacrosTests/GenerableMacroGuideTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation
import SwiftParser
import SwiftSyntax
import SwiftSyntaxMacroExpansion
import SwiftSyntaxMacros
import Testing

@testable import AnyLanguageModelMacros

@Suite("Generable macro guides")
struct GenerableMacroGuideTests {
/// The guides that the `@Generable` expansion of `source` gives its one property.
private func expandedGuides(of source: String) throws -> String {
let file = Parser.parse(source: source)
let structDecl = try #require(file.statements.first?.item.as(StructDeclSyntax.self))
let attribute = try #require(structDecl.attributes.first?.as(AttributeSyntax.self))
let members = try GenerableMacro.expansion(
of: attribute,
providingMembersOf: structDecl,
conformingTo: [],
in: BasicMacroExpansionContext()
)
let expansion = members.map(\.description).joined(separator: "\n")
let line = try #require(
expansion.split(separator: "\n").first { $0.contains("guides:") }
)
return line.trimmingCharacters(in: .whitespaces)
}

/// A later bound replaces an earlier one that it contradicts,
/// as in a schema built from the guides directly.
@Test(arguments: [
("Int", ".minimum(10), .maximum(5)", "guides: [.maximum(5)]"),
("Int", ".maximum(5), .minimum(10)", "guides: [.minimum(10)]"),
("Double", ".minimum(1.0), .maximum(0.5)", "guides: [.maximum(0.5)]"),
("Double", ".maximum(0.5), .minimum(1.0)", "guides: [.minimum(1.0)]"),
("Int", ".range(1...9), .maximum(0)", "guides: [.maximum(0)]"),
("Int", ".range(1...9), .minimum(10)", "guides: [.minimum(10)]"),
])
func contradictingBoundsKeepTheLaterOne(type: String, guides: String, expected: String) throws {
let source = """
@Generable
struct Sample {
@Guide(\(guides))
var value: \(type)
}
"""
#expect(try expandedGuides(of: source) == expected)
}

/// Bounds in order still expand to the guides they expanded to before.
@Test(arguments: [
("Int", ".minimum(1), .maximum(9)", "guides: [.range(1...9)]"),
("Int", ".maximum(9), .minimum(1)", "guides: [.range(1...9)]"),
("Int", ".minimum(5), .maximum(5)", "guides: [.range(5...5)]"),
("Int", ".range(1...9)", "guides: [.range(1...9)]"),
("Int", ".minimum(-3)", "guides: [.minimum(-3)]"),
("Int", ".maximum(3)", "guides: [.maximum(3)]"),
("Double", ".minimum(0.5), .maximum(1.5)", "guides: [.range(0.5...1.5)]"),
("Double", ".range(0.0...1.0)", "guides: [.range(0.0...1.0)]"),
])
func boundsInOrderAreUnchanged(type: String, guides: String, expected: String) throws {
let source = """
@Generable
struct Sample {
@Guide(\(guides))
var value: \(type)
}
"""
#expect(try expandedGuides(of: source) == expected)
}
}
65 changes: 65 additions & 0 deletions Tests/AnyLanguageModelTests/BoundsTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import Testing

@testable import AnyLanguageModel
Expand Down Expand Up @@ -32,6 +33,36 @@ struct BoundsTests {
#expect(guide.maximum == .integer(Int.max))
}

/// Integer bounds survive encoding and decoding exactly, including those beyond what a `Double` holds exactly.
@Test(arguments: [
Bounds.closed(Int.max - 1 ... Int.max),
.closed(Int.min ... Int.min + 1),
.closed(-9_007_199_254_740_993 ... -9_007_199_254_740_991),
.from(9_007_199_254_740_993),
.through(-42),
.closed(-5 ... 5),
])
func integerBoundsRoundTrip(bounds: Bounds<Int>) throws {
let schema = GenerationSchema(root: .number(.integer(description: nil, bounds: bounds)), defs: [:])
let decoded = try JSONDecoder().decode(GenerationSchema.self, from: JSONEncoder().encode(schema))
guard case .number(let node) = decoded.root else {
Issue.record("Expected a number schema")
return
}
#expect(node == .integer(description: nil, bounds: bounds))
}

/// A fractional integer bound still rounds to the whole numbers within it.
@Test func fractionalIntegerBoundsRound() throws {
let json = #"{"type": "integer", "minimum": -2.5, "maximum": 7.5}"#
let decoded = try JSONDecoder().decode(GenerationSchema.self, from: Data(json.utf8))
guard case .number(let node) = decoded.root else {
Issue.record("Expected a number schema")
return
}
#expect(node == .integer(description: nil, bounds: .closed(-2 ... 7)))
}

@Test func replacingABoundDropsTheOneItContradicts() {
#expect(Bounds.closed(1 ... 9).replacing(minimum: 5) == .closed(5 ... 9))
#expect(Bounds.closed(1 ... 9).replacing(minimum: 10) == .from(10))
Expand All @@ -53,4 +84,38 @@ struct BoundsTests {
let share = GenerationSchema.Property(name: "share", type: Double.self, guides: [.minimum(1), .maximum(0.5)])
#expect(bounds(of: share) == .floatingPoint(description: nil, bounds: .through(0.5)))
}

@Generable
struct ContradictingGuides {
@Guide(.minimum(10), .maximum(5))
var count: Int

@Guide(.maximum(5), .minimum(10))
var level: Int

@Guide(.minimum(1), .maximum(0.5))
var share: Double

@Guide(.minimum(1), .maximum(9))
var rating: Int
}

/// `@Generable` gives contradicting guides the bounds that building the schema directly gives them,
/// instead of making a range out of order, which traps.
@Test func contradictingGuidesThroughTheMacroKeepTheLaterBound() throws {
let schema = ContradictingGuides.generationSchema
var root = schema.root
if case .ref(let name) = root, let definition = schema.defs[name] { root = definition }
guard case .object(let object) = root else {
Issue.record("Expected an object schema")
return
}
func bounds(of name: String) -> GenerationSchema.NumberNode? {
if case .number(let node)? = object.properties[name] { node } else { nil }
}
#expect(bounds(of: "count") == .integer(description: nil, bounds: .through(5)))
#expect(bounds(of: "level") == .integer(description: nil, bounds: .from(10)))
#expect(bounds(of: "share") == .floatingPoint(description: nil, bounds: .through(0.5)))
#expect(bounds(of: "rating") == .integer(description: nil, bounds: .closed(1 ... 9)))
}
}
13 changes: 13 additions & 0 deletions Tests/AnyLanguageModelTests/CollectCancellationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,17 @@ struct CollectCancellationTests {
await #expect(throws: CancellationError.self) { try await collecting.value }
continuation.finish()
}

/// A response made whole, as a model that doesn't stream makes it, isn't returned to a cancelled task either.
@Test func cancelledWithASingleSnapshot() async throws {
let collecting = Task {
withUnsafeCurrentTask { $0?.cancel() }
return try await LanguageModelSession.ResponseStream<String>(
content: "Done",
rawContent: GeneratedContent("Done")
)
.collect()
}
await #expect(throws: CancellationError.self) { try await collecting.value }
}
}
11 changes: 8 additions & 3 deletions Tests/AnyLanguageModelTests/HTTPTransportTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,19 @@ struct HTTPTransportTests {
}

#if canImport(AsyncHTTPClient)
/// A timeout too long to count in nanoseconds means no limit, rather than a trap.
@Test func aHugeTimeoutDoesntTrap() async throws {
/// A timeout too long to count in nanoseconds means no limit, rather than a trap
/// or a deadline that has already passed.
@Test(arguments: [
Duration.seconds(Int64.max),
.seconds(9_223_372_036) + .milliseconds(900),
])
func aHugeTimeoutHasNoLimit(timeout: Duration) async throws {
let server = try LoopbackServer(.partial(contentLength: 2, body: "{}"))
defer { server.stop() }
let body = try await HTTPClient.shared.send(
request: HTTPRequest(url: server.url),
body: nil,
timeout: .seconds(Int64.max)
timeout: timeout
) { _, body in try await body.collect() }
#expect(body == Data("{}".utf8))
}
Expand Down
47 changes: 47 additions & 0 deletions Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,53 @@ import Testing
}
}

/// What a tool asks of a model is its own, when Foundation Models calls it too:
/// a request the tool makes to another model isn't counted for the response that called it,
/// whether the session has a tool execution delegate or not. No system model is involved.
@available(macOS 26.0, iOS 26.0, tvOS 26.0, visionOS 26.0, *)
@Test(arguments: [false, true])
func whatASystemModelToolUsesIsntCountedForTheResponseThatCalledIt(withDelegate: Bool) async throws {
let tool = AskingOllama()
let session = LanguageModelSession(model: SystemLanguageModel.default, tools: [tool])
let delegate = ScriptedDelegate([.execute])
if withDelegate { session.toolExecutionDelegate = delegate }
let channel = RequestUsageChannel { _ in }
try await RequestUsageChannel.$current.withValue(channel) {
_ = try await callTool(tool, with: GeneratedContent(properties: ["term": "tea"]), in: session)
}
#expect(tool.transport.handled == 1)
#expect(channel.reportedUsage == .zero)
}

/// A tool that asks an Ollama model itself, which reports what the request used.
private struct AskingOllama: Tool {
let name = "lookup"
let description = "Looks something up."
let transport = FakeTransport(replies: [
.init(chunks: [
#"{"model":"m","created_at":"2026-10-22T10:00:00.000Z","message":{"role":"assistant","content":"Found"},"done":true,"prompt_eval_count":7,"eval_count":3}"#
])
])

@Generable struct Arguments { var term: String }

func call(arguments: Arguments) async throws -> String {
let model = OllamaLanguageModel(
baseURL: URL(string: "http://localhost:11434")!,
model: "m",
transport: transport
)
let response = try await model.respond(
within: LanguageModelSession(model: model),
to: Prompt(arguments.term),
generating: String.self,
includeSchemaInPrompt: false,
options: GenerationOptions()
)
return response.content
}
}

/// Decides each tool call in turn from `decisions`, then provides "Snowing" for the rest,
/// recording each call.
private actor ScriptedDelegate: ToolExecutionDelegate {
Expand Down
Loading