From e728e40b6864e5c49dcb93dc956d292ab7368e6a Mon Sep 17 00:00:00 2001 From: Jaleel Akbashev Date: Sun, 27 Sep 2026 11:51:16 +0200 Subject: [PATCH 1/2] Stabilize generated schema ordering --- Sources/AnyLanguageModel/GenerationSchema.swift | 5 +++-- .../Models/OllamaLanguageModel.swift | 12 ++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Sources/AnyLanguageModel/GenerationSchema.swift b/Sources/AnyLanguageModel/GenerationSchema.swift index 8d80644e..95902da5 100644 --- a/Sources/AnyLanguageModel/GenerationSchema.swift +++ b/Sources/AnyLanguageModel/GenerationSchema.swift @@ -79,10 +79,11 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible keyedBy: GenerationSchema.DynamicCodingKey.self, forKey: .properties ) - for (name, node) in obj.properties { + for name in obj.properties.keys.sorted() { + guard let node = obj.properties[name] else { continue } try propsContainer.encode(node, forKey: GenerationSchema.DynamicCodingKey(stringValue: name)!) } - try container.encode(Array(obj.required), forKey: .required) + try container.encode(obj.required.sorted(), forKey: .required) // Check userInfo to see if additionalProperties should be omitted let shouldOmit = encoder.userInfo[GenerationSchema.omitAdditionalPropertiesKey] as? Bool ?? false diff --git a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift index fcbb7f11..9a5fd206 100644 --- a/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift +++ b/Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift @@ -148,7 +148,7 @@ public struct OllamaLanguageModel: LanguageModel { ) let url = baseURL.appendingPathComponent("api/chat") - let body = try JSONEncoder().encode(params) + let body = try encodeChatParams(params) let chatResponse: ChatResponse = try await httpSession.fetch( .post, url: url, @@ -268,7 +268,7 @@ public struct OllamaLanguageModel: LanguageModel { format: format, parameters: extractTopLevelChatParameters(options) ) - let body = try JSONEncoder().encode(params) + let body = try encodeChatParams(params) let chunks: AsyncThrowingStream = httpSession.fetchStream( .post, url: url, @@ -523,6 +523,14 @@ private func convertToolToOllamaFormat(_ tool: any Tool) throws -> [String: JSON ] } +private func encodeChatParams(_ params: [String: JSONValue]) throws -> Data { + let encoder = JSONEncoder() + // Ollama reuses prompt prefixes only when their serialized bytes match. + // Dictionary iteration order must not vary between equivalent requests. + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(params) +} + private func convertSchemaToOllamaFormat(_ schema: GenerationSchema) throws -> JSONSchema { try schema.inlinedJSONSchema() } From 1f4c413c5ce3cb78c868a2ddc55ec74d01a4aaa1 Mon Sep 17 00:00:00 2001 From: Jaleel Akbashev Date: Sun, 27 Sep 2026 18:12:45 +0200 Subject: [PATCH 2/2] Sort schema definitions and test deterministic request encoding --- .../AnyLanguageModel/GenerationSchema.swift | 2 +- .../DynamicGenerationSchemaTests.swift | 123 ++++++++++++++++++ .../ProviderSchemaResponseTests.swift | 56 ++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) diff --git a/Sources/AnyLanguageModel/GenerationSchema.swift b/Sources/AnyLanguageModel/GenerationSchema.swift index 95902da5..729d04ef 100644 --- a/Sources/AnyLanguageModel/GenerationSchema.swift +++ b/Sources/AnyLanguageModel/GenerationSchema.swift @@ -615,7 +615,7 @@ public struct GenerationSchema: Equatable, Codable, CustomDebugStringConvertible if !defs.isEmpty { var defsContainer = container.nestedContainer(keyedBy: DynamicCodingKey.self, forKey: .defs) - for (name, node) in defs { + for (name, node) in defs.sorted(by: { $0.key < $1.key }) { try defsContainer.encode(node, forKey: DynamicCodingKey(stringValue: name)!) } } diff --git a/Tests/AnyLanguageModelTests/DynamicGenerationSchemaTests.swift b/Tests/AnyLanguageModelTests/DynamicGenerationSchemaTests.swift index eeb03977..5a4956da 100644 --- a/Tests/AnyLanguageModelTests/DynamicGenerationSchemaTests.swift +++ b/Tests/AnyLanguageModelTests/DynamicGenerationSchemaTests.swift @@ -109,4 +109,127 @@ struct DynamicGenerationSchemaTests { _ = try GenerationSchema(root: root, dependencies: []) } } + + @Test func encodingIsStableAcrossPropertyAndDefinitionInsertionOrders() throws { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + for _ in 0 ..< 16 { + let schemas = try Self.schemasInDifferentInsertionOrders() + #expect(try encoder.encode(schemas[0]) == encoder.encode(schemas[1])) + for schema in schemas { + let recorder = SchemaKeyOrderEncoder() + try schema.encode(to: recorder) + #expect(recorder.keys["$defs"] == ["Alpha", "Beta", "Root"]) + #expect(recorder.keys["$defs.Root.properties"] == ["alpha", "beta"]) + #expect(recorder.keys["$defs.Alpha.properties"] == ["count", "name"]) + #expect(recorder.keys["$defs.Beta.properties"] == ["enabled", "score"]) + } + } + } + + static func schemasInDifferentInsertionOrders() throws -> [GenerationSchema] { + let alpha = DynamicGenerationSchema( + name: "Alpha", + properties: [ + .init(name: "name", schema: .init(type: String.self)), + .init(name: "count", schema: .init(type: Int.self)), + ] + ) + let alphaReversed = DynamicGenerationSchema( + name: "Alpha", + properties: [ + .init(name: "count", schema: .init(type: Int.self)), + .init(name: "name", schema: .init(type: String.self)), + ] + ) + let beta = DynamicGenerationSchema( + name: "Beta", + properties: [ + .init(name: "enabled", schema: .init(type: Bool.self)), + .init(name: "score", schema: .init(type: Double.self)), + ] + ) + let betaReversed = DynamicGenerationSchema( + name: "Beta", + properties: [ + .init(name: "score", schema: .init(type: Double.self)), + .init(name: "enabled", schema: .init(type: Bool.self)), + ] + ) + let alphaFirstRoot = DynamicGenerationSchema( + name: "Root", + properties: [ + .init(name: "alpha", schema: .init(referenceTo: "Alpha")), + .init(name: "beta", schema: .init(referenceTo: "Beta")), + ] + ) + let betaFirstRoot = DynamicGenerationSchema( + name: "Root", + properties: [ + .init(name: "beta", schema: .init(referenceTo: "Beta")), + .init(name: "alpha", schema: .init(referenceTo: "Alpha")), + ] + ) + + let alphaFirst = try GenerationSchema(root: alphaFirstRoot, dependencies: [alpha, beta]) + let betaFirst = try GenerationSchema(root: betaFirstRoot, dependencies: [betaReversed, alphaReversed]) + + return [alphaFirst, betaFirst] + } +} + +/// Records schema key visitation before a concrete encoder can rearrange the keys. +private final class SchemaKeyOrderEncoder: Encoder { + var codingPath: [any CodingKey] = [] + var userInfo: [CodingUserInfoKey: Any] = [:] + var keys: [String: [String]] = [:] + + func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { + KeyedEncodingContainer(Container(encoder: self, codingPath: codingPath)) + } + + func unkeyedContainer() -> any UnkeyedEncodingContainer { + fatalError("This fixture has no array nodes") + } + + func singleValueContainer() -> any SingleValueEncodingContainer { + fatalError("Schema nodes use keyed containers") + } + + private struct Container: KeyedEncodingContainerProtocol { + let encoder: SchemaKeyOrderEncoder + var codingPath: [any CodingKey] + + func record(_ key: Key) { + let path = codingPath.map(\.stringValue).joined(separator: ".") + encoder.keys[path, default: []].append(key.stringValue) + } + + mutating func encode(_ value: T, forKey key: Key) throws { + record(key) + if let node = value as? GenerationSchema.Node { + let previousPath = encoder.codingPath + encoder.codingPath = codingPath + [key] + defer { encoder.codingPath = previousPath } + try node.encode(to: encoder) + } + } + + mutating func encodeNil(forKey key: Key) throws { record(key) } + + mutating func nestedContainer( + keyedBy type: NestedKey.Type, + forKey key: Key + ) -> KeyedEncodingContainer { + record(key) + return KeyedEncodingContainer(Container(encoder: encoder, codingPath: codingPath + [key])) + } + + mutating func nestedUnkeyedContainer(forKey key: Key) -> any UnkeyedEncodingContainer { + fatalError("This fixture has no nested unkeyed containers") + } + + mutating func superEncoder() -> any Encoder { encoder } + mutating func superEncoder(forKey key: Key) -> any Encoder { encoder } + } } diff --git a/Tests/AnyLanguageModelTests/ProviderSchemaResponseTests.swift b/Tests/AnyLanguageModelTests/ProviderSchemaResponseTests.swift index dca67b74..5bacf1f0 100644 --- a/Tests/AnyLanguageModelTests/ProviderSchemaResponseTests.swift +++ b/Tests/AnyLanguageModelTests/ProviderSchemaResponseTests.swift @@ -115,6 +115,62 @@ import Testing } } + @Test(arguments: [false, true]) + func ollamaRequestBytesAreStable(_ streaming: Bool) async throws { + SchemaURLProtocol.reset() + defer { SchemaURLProtocol.reset() } + let content = #"{"alpha":{"count":1,"name":"a"},"beta":{"enabled":true,"score":2}}"# + let optionEntries: [(String, JSONValue)] = [ + ("seed", 42), ("temperature", 0.5), ("stop", ["END", "STOP"]), + ("think", false), ("keep_alive", .null), + ] + for (index, schema) in try DynamicGenerationSchemaTests.schemasInDifferentInsertionOrders().enumerated() { + var customOptions: [String: JSONValue] = [:] + for (key, value) in index == 0 ? optionEntries : Array(optionEntries.reversed()) { + customOptions[key] = value + } + var options = GenerationOptions() + options[custom: OllamaLanguageModel.self] = customOptions + SchemaURLProtocol.enqueue( + json: try streaming + ? Provider.ollama.stream(text: content) : json(Provider.ollama.response(text: content)) + ) + let session = Provider.ollama.session() + if streaming { + _ = try await session.streamResponse( + to: "Return a result", + schema: schema, + includeSchemaInPrompt: false, + options: options + ) + .collect() + } else { + _ = try await session.respond( + to: "Return a result", + schema: schema, + includeSchemaInPrompt: false, + options: options + ) + } + } + let bodies = SchemaURLProtocol.recordedBodies + #expect(bodies.count == 2) + let first = try #require(bodies.first) + #expect(bodies.last == first) + + // Check the actual request encoder's sorted-key setting, including nested objects. + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + for body in bodies { + let params = try JSONDecoder().decode([String: JSONValue].self, from: body) + #expect(try encoder.encode(params) == body) + #expect(params["stream"] == .bool(streaming)) + #expect(params["think"] == .bool(false)) + #expect(params["keep_alive"] == .null) + #expect(params["options"] == .object(["seed": 42, "temperature": 0.5, "stop": ["END", "STOP"]])) + } + } + @Test(arguments: Provider.allCases, [false, true]) func callerSchemaReachesRequest(_ provider: Provider, _ streaming: Bool) async throws { SchemaURLProtocol.reset()