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
7 changes: 4 additions & 3 deletions Sources/AnyLanguageModel/GenerationSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
Copilot marked this conversation as resolved.

// Check userInfo to see if additionalProperties should be omitted
let shouldOmit = encoder.userInfo[GenerationSchema.omitAdditionalPropertiesKey] as? Bool ?? false
Expand Down Expand Up @@ -614,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)!)
}
}
Expand Down
12 changes: 10 additions & 2 deletions Sources/AnyLanguageModel/Models/OllamaLanguageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ChatResponse, any Error> = httpSession.fetchStream(
.post,
url: url,
Expand Down Expand Up @@ -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)
Comment on lines +526 to +531
}

private func convertSchemaToOllamaFormat(_ schema: GenerationSchema) throws -> JSONSchema {
try schema.inlinedJSONSchema()
}
Expand Down
123 changes: 123 additions & 0 deletions Tests/AnyLanguageModelTests/DynamicGenerationSchemaTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Key: CodingKey>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> {
KeyedEncodingContainer(Container<Key>(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<Key: CodingKey>: 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<T: Encodable>(_ 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<NestedKey: CodingKey>(
keyedBy type: NestedKey.Type,
forKey key: Key
) -> KeyedEncodingContainer<NestedKey> {
record(key)
return KeyedEncodingContainer(Container<NestedKey>(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 }
}
}
56 changes: 56 additions & 0 deletions Tests/AnyLanguageModelTests/ProviderSchemaResponseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading