diff --git a/Package.swift b/Package.swift index a5d2cdd3..a4c7f912 100644 --- a/Package.swift +++ b/Package.swift @@ -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"), + ] + ), ] ) diff --git a/Sources/AnyLanguageModel/Extensions/HTTPClient+Extensions.swift b/Sources/AnyLanguageModel/Extensions/HTTPClient+Extensions.swift index 9dc5c08b..a0bbf804 100644 --- a/Sources/AnyLanguageModel/Extensions/HTTPClient+Extensions.swift +++ b/Sources/AnyLanguageModel/Extensions/HTTPClient+Extensions.swift @@ -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 } diff --git a/Sources/AnyLanguageModel/GenerationSchema.swift b/Sources/AnyLanguageModel/GenerationSchema.swift index fba3656d..389b0900 100644 --- a/Sources/AnyLanguageModel/GenerationSchema.swift +++ b/Sources/AnyLanguageModel/GenerationSchema.swift @@ -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)." diff --git a/Sources/AnyLanguageModel/LanguageModelSession.swift b/Sources/AnyLanguageModel/LanguageModelSession.swift index dbf9d944..4b57d92d 100644 --- a/Sources/AnyLanguageModel/LanguageModelSession.swift +++ b/Sources/AnyLanguageModel/LanguageModelSession.swift @@ -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 diff --git a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift index 620358f3..634bbcef 100644 --- a/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/SystemLanguageModel.swift @@ -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) } } } diff --git a/Sources/AnyLanguageModelMacros/GenerableMacro.swift b/Sources/AnyLanguageModelMacros/GenerableMacro.swift index 4f676272..2c194aa5 100644 --- a/Sources/AnyLanguageModelMacros/GenerableMacro.swift +++ b/Sources/AnyLanguageModelMacros/GenerableMacro.swift @@ -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) diff --git a/Tests/AnyLanguageModelMacrosTests/GenerableMacroGuideTests.swift b/Tests/AnyLanguageModelMacrosTests/GenerableMacroGuideTests.swift new file mode 100644 index 00000000..ae3aeecc --- /dev/null +++ b/Tests/AnyLanguageModelMacrosTests/GenerableMacroGuideTests.swift @@ -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) + } +} diff --git a/Tests/AnyLanguageModelTests/BoundsTests.swift b/Tests/AnyLanguageModelTests/BoundsTests.swift index c0aefb7b..39b31dbd 100644 --- a/Tests/AnyLanguageModelTests/BoundsTests.swift +++ b/Tests/AnyLanguageModelTests/BoundsTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import AnyLanguageModel @@ -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) 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)) @@ -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))) + } } diff --git a/Tests/AnyLanguageModelTests/CollectCancellationTests.swift b/Tests/AnyLanguageModelTests/CollectCancellationTests.swift index c2c437ca..d790879a 100644 --- a/Tests/AnyLanguageModelTests/CollectCancellationTests.swift +++ b/Tests/AnyLanguageModelTests/CollectCancellationTests.swift @@ -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( + content: "Done", + rawContent: GeneratedContent("Done") + ) + .collect() + } + await #expect(throws: CancellationError.self) { try await collecting.value } + } } diff --git a/Tests/AnyLanguageModelTests/HTTPTransportTests.swift b/Tests/AnyLanguageModelTests/HTTPTransportTests.swift index 85f32745..9538868c 100644 --- a/Tests/AnyLanguageModelTests/HTTPTransportTests.swift +++ b/Tests/AnyLanguageModelTests/HTTPTransportTests.swift @@ -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)) } diff --git a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift index dbee954d..31b62a62 100644 --- a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift @@ -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 {