diff --git a/Package.resolved b/Package.resolved index 8d5643a..a533262 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,31 @@ { - "originHash" : "c8e9d241d5161b35df68a2356c124470cbcfc25dedec2bd2d5427bbe6b4fbe64", + "originHash" : "872b6229e6bd91a68cee931a98a03f360346ba4753b0d988ba6ffaf7edad82bd", "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client", + "state" : { + "revision" : "2fc4652fb4689eb24af10e55cabaa61d8ba774fd", + "version" : "1.32.0" + } + }, + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/EventSource.git", + "state" : { + "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e", + "version" : "1.4.1" + } + }, { "identity" : "mlx-swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/shareup/mlx-swift", + "location" : "https://github.com/ml-explore/mlx-swift", "state" : { - "revision" : "bb80abe26c04a7dd7207532d760ee319c5d84410", - "version" : "0.0.1" + "revision" : "6ba4827fb82c97d012eec9ab4b2de21f85c3b33d", + "version" : "0.30.6" } }, { @@ -15,8 +33,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/shareup/mlx-swift-lm", "state" : { - "revision" : "7d153df4018d3c325818b0edaac09d2b8983825f", - "version" : "0.0.4" + "revision" : "056832eff7ce48efde44236404703df462839884", + "version" : "0.0.11" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" } }, { @@ -24,8 +60,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms", "state" : { - "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", - "version" : "1.1.1" + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "24ccdeeeed4dfaae7955fcac9dbf5489ed4f1a25", + "version" : "1.18.0" } }, { @@ -33,8 +87,62 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" + "revision" : "8d9834a6189db730f6264db7556a7ffb751e99ee", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", + "version" : "4.2.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-huggingface", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-huggingface.git", + "state" : { + "revision" : "de01c0ab8fd537bbd8216cea7f774275178501a2", + "version" : "0.8.1" } }, { @@ -42,8 +150,62 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/huggingface/swift-jinja.git", "state" : { - "revision" : "06a511d5adab5a812852ff972e65702a24b8ce30", - "version" : "2.2.0" + "revision" : "f731f03bf746481d4fda07f817c3774390c4d5b9", + "version" : "2.3.2" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "b31565862a8f39866af50bc6676160d8dda7de35", + "version" : "2.96.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "3df009d563dc9f21a5c85b33d8c2e34d2e4f8c3b", + "version" : "1.32.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "b6571f3db40799df5a7fc0e92c399aa71c883edd", + "version" : "1.40.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "173cc69a058623525a58ae6710e2f5727c663793", + "version" : "2.36.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "60c3e187154421171721c1a38e800b390680fb5d", + "version" : "1.26.0" } }, { @@ -55,23 +217,68 @@ "version" : "1.1.1" } }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle", + "state" : { + "revision" : "89888196dd79c61c50bca9a103d8114f32e1e598", + "version" : "2.10.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, { "identity" : "swift-transformers", "kind" : "remoteSourceControl", - "location" : "https://github.com/huggingface/swift-transformers", + "location" : "https://github.com/shareup/swift-transformers", "state" : { - "revision" : "573e5c9036c2f136b3a8a071da8e8907322403d0", - "version" : "1.1.6" + "revision" : "531b8b0f94fcd69e0963e7d4f73788cd29471c2d", + "version" : "0.0.1" + } + }, + { + "identity" : "swift-xet", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/swift-xet.git", + "state" : { + "revision" : "341bfd4172f6a57119bfd49bafa11cf5d21fab75", + "version" : "0.2.3" } }, { "identity" : "synchronized", "kind" : "remoteSourceControl", - "location" : "https://github.com/shareup/synchronized.git", + "location" : "https://github.com/shareup/synchronized", "state" : { "revision" : "85653e23270ec88ae19f8d494157769487e34aed", "version" : "4.0.1" } + }, + { + "identity" : "yyjson", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ibireme/yyjson.git", + "state" : { + "revision" : "8b4a38dc994a110abaec8a400615567bd996105f", + "version" : "0.12.0" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index c18ff8e..13b5646 100644 --- a/Package.swift +++ b/Package.swift @@ -17,11 +17,11 @@ let package = Package( ), .package( url: "https://github.com/shareup/mlx-swift-lm", - from: "0.0.4" + from: "0.0.11" ), .package( - url: "https://github.com/huggingface/swift-transformers", - from: "1.0.0" + url: "https://github.com/shareup/swift-transformers", + from: "0.0.1" ), .package( url: "https://github.com/shareup/synchronized.git", @@ -58,8 +58,11 @@ let package = Package( // .copy("Resources/LFM2-8B-A1B-4bit"), // .copy("Resources/Llama-3.2-1B-Instruct-4bit"), // .copy("Resources/Llama-3.2-3B-Instruct-4bit"), +// .copy("Resources/Devstral-Small-2-24B-Instruct-2512-4bit"), +// .copy("Resources/Ministral-3-14B-Instruct-2512-6bit"), // .copy("Resources/Mistral-7B-Instruct-v0.3-4bit"), // .copy("Resources/Mistral-Nemo-Instruct-2407-4bit"), +// .copy("Resources/NVIDIA-Nemotron-3-Nano-30B-A3B-4bit"), // .copy("Resources/OpenELM-270M-Instruct"), // .copy("Resources/Phi-3.5-mini-instruct-4bit"), // .copy("Resources/Phi-3.5-MoE-instruct-4bit"), @@ -76,6 +79,10 @@ let package = Package( // .copy("Resources/Qwen3-VL-2B-Thinking-4bit"), // .copy("Resources/Qwen3-VL-4B-Instruct-4bit"), // .copy("Resources/Qwen3-VL-4B-Thinking-4bit"), +// .copy("Resources/Qwen3.5-2B-6bit"), +// .copy("Resources/Qwen3.5-9B-4bit"), +// .copy("Resources/Qwen3.5-27B-4bit"), +// .copy("Resources/Qwen3.5-35B-A3B-4bit"), // ], linkerSettings: [ .linkedFramework("CoreGraphics", .when(platforms: [.macOS])), diff --git a/Sources/SHLLM/LLM.swift b/Sources/SHLLM/LLM.swift index 34ca4b7..2b14128 100644 --- a/Sources/SHLLM/LLM.swift +++ b/Sources/SHLLM/LLM.swift @@ -414,6 +414,68 @@ extension LLM where Model == Qwen2Model { } } +// MARK: - Mistral 3 + +extension LLM where Model == Mistral3VLM { + public static func devstral2( + directory: URL, + input: UserInput, + tools: [any ToolProtocol] = [], + maxInputTokenCount: Int? = nil, + maxOutputTokenCount: Int? = nil + ) throws -> LLM { + try SHLLM.assertSupportedDevice + return .init( + directory: directory, + input: input, + tools: tools, + maxInputTokenCount: maxInputTokenCount, + maxOutputTokenCount: maxOutputTokenCount, + generateParameters: generateParameters, + responseParser: mistral3Parser + ) + } + + public static func ministral( + directory: URL, + input: UserInput, + tools: [any ToolProtocol] = [], + maxInputTokenCount: Int? = nil, + maxOutputTokenCount: Int? = nil + ) throws -> LLM { + try SHLLM.assertSupportedDevice + return .init( + directory: directory, + input: input, + tools: tools, + maxInputTokenCount: maxInputTokenCount, + maxOutputTokenCount: maxOutputTokenCount, + generateParameters: generateParameters, + responseParser: mistral3Parser + ) + } + + static var generateParameters: GenerateParameters { + GenerateParameters( + temperature: 0.15 + ) + } + + static var devstral2Small_24B: URL { + get throws { + let dir = "Devstral-Small-2-24B-Instruct-2512-4bit" + return try Bundle.shllm.directory(named: dir) + } + } + + static var ministral_3_14B: URL { + get throws { + let dir = "Ministral-3-14B-Instruct-2512-6bit" + return try Bundle.shllm.directory(named: dir) + } + } +} + // MARK: - Gemma extension LLM where Model == GemmaModel { @@ -759,6 +821,43 @@ extension LLM where Model == LlamaModel { } } +// MARK: - Nemotron + +extension LLM where Model == NemotronHModel { + public static func nemotron3Nano( + directory: URL, + input: UserInput, + tools: [any ToolProtocol] = [], + maxInputTokenCount: Int? = nil, + maxOutputTokenCount: Int? = nil + ) throws -> LLM { + try SHLLM.assertSupportedDevice + return .init( + directory: directory, + input: input, + tools: tools, + maxInputTokenCount: maxInputTokenCount, + maxOutputTokenCount: maxOutputTokenCount, + generateParameters: generateParameters, + responseParser: nemotronParser + ) + } + + static var generateParameters: GenerateParameters { + GenerateParameters( + temperature: 0.6, + topP: 0.95 + ) + } + + static var nemotron3Nano_30B_A3B: URL { + get throws { + let dir = "NVIDIA-Nemotron-3-Nano-30B-A3B-4bit" + return try Bundle.shllm.directory(named: dir) + } + } +} + // MARK: - OpenELM extension LLM where Model == OpenELMModel { @@ -1196,3 +1295,117 @@ extension LLM where Model == Qwen3VL { } } } + +// MARK: - Qwen3.5 + +extension LLM where Model == Qwen35 { + public static func qwen3_5( + directory: URL, + input: UserInput, + tools: [any ToolProtocol] = [], + maxInputTokenCount: Int? = nil, + maxOutputTokenCount: Int? = nil + ) throws -> LLM { + try SHLLM.assertSupportedDevice + return .init( + directory: directory, + input: input, + tools: tools, + maxInputTokenCount: maxInputTokenCount, + maxOutputTokenCount: maxOutputTokenCount, + generateParameters: generateParameters, + responseParser: qwen3_5Parser(for: input) + ) + } + + // https://huggingface.co/Qwen/Qwen3.5-35B-A3B#using-qwen35-via-the-chat-completions-api + // + // # Thinking mode for general tasks + // + // - temperature=1.0 + // - top_p=0.95 + // - top_k=20 + // - min_p=0.0 + // - presence_penalty=1.5 + // - repetition_penalty=1.0 + static var generateParameters: GenerateParameters { + GenerateParameters( + maxTokens: 32_768, + temperature: 1.0, + topP: 0.95, + presencePenalty: 1.5 + ) + } + + // NOTE: Qwen3.5-2B operates in non-thinking mode by default. + // https://huggingface.co/Qwen/Qwen3.5-2B#quickstart + static var qwen3_5__2B: URL { + get throws { + let dir = "Qwen3.5-2B-6bit" + return try Bundle.shllm.directory(named: dir) + } + } + + static var qwen3_5__9B: URL { + get throws { + let dir = "Qwen3.5-9B-4bit" + return try Bundle.shllm.directory(named: dir) + } + } + + static var qwen3_5__27B: URL { + get throws { + let dir = "Qwen3.5-27B-4bit" + return try Bundle.shllm.directory(named: dir) + } + } +} + +// MARK: - Qwen3.5 MoE + +extension LLM where Model == Qwen35MoE { + public static func qwen3_5MoE( + directory: URL, + input: UserInput, + tools: [any ToolProtocol] = [], + maxInputTokenCount: Int? = nil, + maxOutputTokenCount: Int? = nil + ) throws -> LLM { + try SHLLM.assertSupportedDevice + return .init( + directory: directory, + input: input, + tools: tools, + maxInputTokenCount: maxInputTokenCount, + maxOutputTokenCount: maxOutputTokenCount, + generateParameters: generateParameters, + responseParser: qwen3_5MoEParser(for: input) + ) + } + + // https://huggingface.co/Qwen/Qwen3.5-35B-A3B#using-qwen35-via-the-chat-completions-api + // + // # Thinking mode for general tasks + // + // - temperature=1.0 + // - top_p=0.95 + // - top_k=20 + // - min_p=0.0 + // - presence_penalty=1.5 + // - repetition_penalty=1.0 + static var generateParameters: GenerateParameters { + GenerateParameters( + maxTokens: 32_768, + temperature: 1.0, + topP: 0.95, + presencePenalty: 1.5 + ) + } + + static var qwen3_5MoE__35B_A3B: URL { + get throws { + let dir = "Qwen3.5-35B-A3B-4bit" + return try Bundle.shllm.directory(named: dir) + } + } +} diff --git a/Sources/SHLLM/ResponseParser.swift b/Sources/SHLLM/ResponseParser.swift index 52fadd7..4be1f89 100644 --- a/Sources/SHLLM/ResponseParser.swift +++ b/Sources/SHLLM/ResponseParser.swift @@ -1,9 +1,14 @@ import Foundation import class MLXLLM.GPTOSSModel +import class MLXLLM.NemotronHModel import class MLXLLM.Qwen2Model import class MLXLLM.Qwen3Model import class MLXLLM.Qwen3MoEModel import enum MLXLMCommon.Generation +import struct MLXLMCommon.ToolCall +import class MLXVLM.Mistral3VLM +import class MLXVLM.Qwen35 +import class MLXVLM.Qwen35MoE import class MLXVLM.Qwen3VL import Synchronized @@ -47,6 +52,35 @@ public extension LLM where Model == Qwen3VL { static var qwen3VLThinkingParser = defaultsToThinkingParser } +public extension LLM where Model == Qwen35 { + static func qwen3_5Parser(for input: UserInput) -> ResponseParser { + qwen35Parser(for: input) + } +} + +public extension LLM where Model == Qwen35MoE { + static func qwen3_5MoEParser(for input: UserInput) -> ResponseParser { + qwen35Parser(for: input) + } +} + +public extension LLM where Model == NemotronHModel { + static var nemotronParser: ResponseParser { + ThinkingTagProcessor.defaultsToThinking() + } +} + +public extension LLM where Model == Mistral3VLM { + static var mistral3Parser: ResponseParser { + ThinkingTagProcessor.hybrid( + startTags: ["[THINK]", "[THINK]\n"], + endTags: ["[/THINK]", "[/THINK]\n"] + ) + } + + static var devstral2Parser: ResponseParser { mistral3Parser } +} + public extension LLM where Model == GPTOSSModel { static var gptOSSParser: ResponseParser { let state = Locked(GPTOSSState()) @@ -195,4 +229,16 @@ private extension LLM { static var defaultsToThinkingParser: ResponseParser { ThinkingTagProcessor.defaultsToThinking() } + + static func qwen35Parser(for input: UserInput) -> ResponseParser { + let enableThinking = input.additionalContext?["enable_thinking"] as? Bool + // NOTE: Qwen3.5 models usually default to thinking mode. Only the 2B + // models default to non-thinking mode. So, if the `enable_thinking` + // flag is not set, we will default to thinking mode. + if enableThinking == false { + return hybridParser + } else { + return defaultsToThinkingParser + } + } } diff --git a/Sources/SHLLM/SHLLM.swift b/Sources/SHLLM/SHLLM.swift index e3176a6..cf0104f 100644 --- a/Sources/SHLLM/SHLLM.swift +++ b/Sources/SHLLM/SHLLM.swift @@ -99,6 +99,8 @@ extension Chat.Message: @retroactive @unchecked Sendable {} @_exported import class MLXLLM.LFM2MoEModel @_exported import class MLXLLM.LlamaModel @_exported import protocol MLXLLM.LLMModel +@_exported import class MLXLLM.Mistral3TextModel +@_exported import class MLXLLM.NemotronHModel @_exported import class MLXLLM.OpenELMModel @_exported import class MLXLLM.Phi3Model @_exported import class MLXLLM.PhiModel @@ -108,4 +110,7 @@ extension Chat.Message: @retroactive @unchecked Sendable {} @_exported import class MLXLLM.Qwen3MoEModel @_exported import class MLXVLM.Gemma3 +@_exported import class MLXVLM.Mistral3VLM +@_exported import class MLXVLM.Qwen35 +@_exported import class MLXVLM.Qwen35MoE @_exported import protocol MLXVLM.VLMModel diff --git a/Sources/SHLLM/ThinkingTagProcessor.swift b/Sources/SHLLM/ThinkingTagProcessor.swift index 10f4f06..a71ebea 100644 --- a/Sources/SHLLM/ThinkingTagProcessor.swift +++ b/Sources/SHLLM/ThinkingTagProcessor.swift @@ -3,6 +3,11 @@ import MLXLMCommon import Synchronized enum ThinkingTagProcessor { + /// Creates a `LLM.ResponseParser` that processes thinking tags + /// in the model's output. It assumes the model will output + /// start and end tags to delimit reasoning blocks. This means + /// the parser assumes arriving tokens are non-reasoning tokens + /// unless they are between start and end tags. static func hybrid( startTags: Set = ["", "\n"], endTags: Set = ["", "\n"] @@ -14,6 +19,10 @@ enum ThinkingTagProcessor { ) } + /// Creates a `LLM.ResponseParser` that processes thinking tags + /// in the model's output. It assumes the model starts in + /// "thinking" mode, meaning arriving tokens are assumed to + /// be reasoning tokens until an end tag is seen. static func defaultsToThinking( startTags: Set = ["", "\n"], endTags: Set = ["", "\n"] diff --git a/Sources/SHLLM/UserInput+SHLLM.swift b/Sources/SHLLM/UserInput+SHLLM.swift index 991f3ce..b5ea6c2 100644 --- a/Sources/SHLLM/UserInput+SHLLM.swift +++ b/Sources/SHLLM/UserInput+SHLLM.swift @@ -2,12 +2,35 @@ import Foundation import MLXLMCommon public extension UserInput { + mutating func appendAssistantToolCall(_ call: ToolCall) { + ensureMessagesForm() + let message: Message = [ + "role": "assistant", + "tool_calls": [[ + "type": "function", + "function": [ + "name": call.function.name, + "arguments": call.function.arguments + .mapValues { $0.anyValue }, + ] as [String: Any], + ]], + ] + appendMessage(message) + } + /// Append a generic tool-result message suitable for most models. mutating func appendToolResult(_ object: [String: Any]) { ensureMessagesForm() + let content: String = { + guard let data = try? JSONSerialization.data( + withJSONObject: object + ), let string = String(data: data, encoding: .utf8) + else { return "\(object)" } + return string + }() let message: Message = [ "role": "tool", - "content": object, + "content": content, ] appendMessage(message) } diff --git a/Tests/SHLLMTests/HarmonyTests.swift b/Tests/SHLLMTests/HarmonyTests.swift index ef542d6..df40f66 100644 --- a/Tests/SHLLMTests/HarmonyTests.swift +++ b/Tests/SHLLMTests/HarmonyTests.swift @@ -1,4 +1,5 @@ import Foundation +import MLXLMCommon @testable import SHLLM import Synchronized import Testing diff --git a/Tests/SHLLMTests/Helpers.swift b/Tests/SHLLMTests/Helpers.swift index 4d483fe..fa1c666 100644 --- a/Tests/SHLLMTests/Helpers.swift +++ b/Tests/SHLLMTests/Helpers.swift @@ -45,7 +45,7 @@ func imageInput( ) -> UserInput { UserInput(chat: [ .system( - "You are an image understanding model capable of describing the salient features of any image." + "You are an image-understanding model capable of describing the salient features of any image." ), .user(message, images: [.ciImage(.init(data: image)!)]), ]) @@ -57,7 +57,7 @@ func imageInput( ) -> UserInput { UserInput(chat: [ .system( - "You are an image understanding model capable of describing the salient features of any image." + "You are an image-understanding model capable of describing the salient features of any image." ), .user(message, images: [.url(image)]), ]) diff --git a/Tests/SHLLMTests/Models/Devstral2Small-24BTests.swift b/Tests/SHLLMTests/Models/Devstral2Small-24BTests.swift new file mode 100644 index 0000000..c1f601d --- /dev/null +++ b/Tests/SHLLMTests/Models/Devstral2Small-24BTests.swift @@ -0,0 +1,404 @@ +import Foundation +import MLXLMCommon +import MLXVLM +@testable import SHLLM +import Testing + +@Suite(.serialized) +struct Devstral2Small_24BTests { + @Test + func canStreamResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try devstral2(input) else { return } + + var reasoning = "" + var result = "" + for try await reply in llm { + switch reply { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + result.append(text) + case .toolCall: + Issue.record() + } + } + + if !reasoning.isEmpty { + Swift.print("[THINK]\n\(reasoning)\n[/THINK]") + } + + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canStreamTextResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try devstral2(input) else { return } + + var result = "" + for try await reply in llm.text { + result.append(reply) + } + + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canAwaitResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try devstral2(input) else { return } + + let (_, _text, toolCalls) = try await llm.result + + let text = try #require(_text) + Swift.print(text) + #expect(!text.isEmpty) + + #expect(toolCalls == nil) + } + + @Test + func canAwaitTextResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try devstral2(input) else { return } + + let result = try await llm.text.result + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canFetchTheWeather() async throws { + let input = UserInput(chat: [ + .system( + "You are a weather assistant who must use the get_current_weather tool to fetch weather data for any location the user asks about." + ), + .user("What is the weather in Paris, France?"), + ]) + + guard let llm = try devstral2( + input, + tools: [weatherTool] + ) else { return } + + var reasoning = "" + var reply = "" + var toolCallCount = 0 + var weatherLocationFound = false + + for try await response in llm { + switch response { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + reply.append(text) + case let .toolCall(toolCall): + toolCallCount += 1 + #expect(toolCall.function.name == "get_current_weather") + + if case let .string(location) = toolCall.function.arguments["location"] { + weatherLocationFound = location.lowercased().contains("paris") + } + } + } + + #expect(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + #expect(toolCallCount >= 1) + #expect(weatherLocationFound) + } + + @Test + func canChooseBetweenDifferentTools() async throws { + let input = UserInput(chat: [ + .system( + "You are a helpful assistant that can provide weather, stock prices, and news." + ), + .user("Get the latest news about Apple, sorted by popularity."), + ]) + + guard let llm = try devstral2( + input, + tools: [weatherTool, stockTool, newsTool] + ) else { return } + + var reasoning = "" + var reply = "" + var toolCallCount = 0 + var newsQueryFound = false + var newsSortByFound = false + + for try await response in llm { + switch response { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + reply.append(text) + case let .toolCall(toolCall): + toolCallCount += 1 + #expect(toolCall.function.name == "get_latest_news") + + if case let .string(query) = toolCall.function.arguments["query"] { + newsQueryFound = query.lowercased().contains("apple") + } + if case let .string(sortBy) = toolCall.function.arguments["sortBy"] { + newsSortByFound = sortBy.lowercased().contains("popularity") + } + } + } + + #expect(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + #expect(toolCallCount >= 1) + #expect(newsQueryFound) + #expect(newsSortByFound) + } + + @Test + func canUseStockToolAndRespond() async throws { + let chat: [Chat.Message] = [ + .system( + "You are a helpful assistant that can provide stock prices. When asked for a stock price, you must use the get_stock_price tool." + ), + .user("What is the price of AAPL?"), + ] + + var input = UserInput(chat: chat) + + guard let llm1 = try devstral2( + input, + tools: [stockTool] + ) else { return } + + let (_, text, toolCallsOpt) = try await llm1.result + let toolCall = try #require(toolCallsOpt?.first) + + #expect(text == nil) + #expect(toolCall.function.name == "get_stock_price") + #expect(toolCall.function.arguments["symbol"] == .string("AAPL")) + + input.appendAssistantToolCall(toolCall) + input.appendToolResult(["price": 123.45]) + + guard let llm2 = try devstral2( + input, + tools: [stockTool] + ) else { return } + + let result = try await llm2.text.result + Swift.print(result) + #expect(!result.isEmpty) + #expect(result.lowercased().contains("aapl")) + #expect(result.contains("123.45")) + } + + @Test + func canCompleteMultiToolWorkflowAndEmail() async throws { + let chat: [Chat.Message] = [ + .system(""" + You are a helpful assistant that must complete tasks by calling tools \ + in sequence. When asked to find information on the web and email it, \ + you must: + + 1) use web_search to find a relevant page + 2) use fetch_web_page to retrieve the page content + 3) use find_email_in_contacts to get the recipient's email + 4) use send_email to send the email with the requested information. + """ + ), + .user( + "Find the keynote date from the ACME Conference website and email it to Alex Example." + ), + ] + + var input = UserInput(chat: chat) + guard let llm = try devstral2(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + + let (_, _, toolCallsOutput1) = try await llm.result + let toolCall1 = try #require(toolCallsOutput1?.first) + #expect(toolCall1.function.name == "web_search") + + input.appendAssistantToolCall(toolCall1) + input.appendToolResult([ + "results": [[ + "title": "ACME Conference 2025 Keynote", + "url": "https://acme.test/conf", + ]], + ]) + + guard let llm2 = try devstral2(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (_, _, toolCallsOutput2) = try await llm2.result + let toolCall2 = try #require(toolCallsOutput2?.first) + #expect(toolCall2.function.name == "fetch_web_page") + + input.appendAssistantToolCall(toolCall2) + input.appendToolResult([ + "content": "Welcome to ACME Conf! Keynote date: November 5, 2025.", + ]) + + guard let llm3 = try devstral2(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (_, _, toolCallsOutput3) = try await llm3.result + #expect(toolCallsOutput3?.count == 1) + let toolCall3 = try #require(toolCallsOutput3?.first) + #expect(toolCall3.function.name == "find_email_in_contacts") + + input.appendAssistantToolCall(toolCall3) + input.appendToolResult([ + "email": "alex@example.com", + ]) + + guard let llm4 = try devstral2(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (reasoning, text, toolCalls4) = try await llm4.result + + guard let toolCall4 = toolCalls4?.first else { + Issue.record(""" + Did not call send_email: reasoning=\(String(describing: reasoning)), \ + text=\(String(describing: text)) + """ + ) + return + } + + #expect(toolCall4.function.name == "send_email") + let toArg = try #require(toolCall4.function.arguments["to"]) + let subjectArg = try #require(toolCall4.function.arguments["subject"]) + let bodyArg = try #require(toolCall4.function.arguments["body"]) + #expect((toArg.anyValue as? String) == "alex@example.com") + #expect((subjectArg.anyValue as? String)?.isEmpty == false) + #expect((bodyArg.anyValue as? String)?.isEmpty == false) + + input.appendAssistantToolCall(toolCall4) + input.appendToolResult(["status": "sent"]) + + guard let llm5 = try devstral2(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + + let response = try await llm5.text.result + Swift.print(response) + #expect(!response.isEmpty) + #expect(response.contains(oneOf: ["sent", "emailed"])) + #expect(response.lowercased().contains("alex")) + } + + @Test() + @MainActor + func canExtractTextFromImageData() async throws { + let data = try authenticationFactors + guard let llm = try devstral2(image: data) else { return } + + var response = "" + for try await token in llm.text { + response += token + } + + Swift.print(response) + let strings = [ + "authentication", + "Something you forgot", + "Something you left in the taxi", + "Something that can be chopped off", + ] + #expect(response.contains(oneOf: strings)) + } + + @Test() + @MainActor + func canExtractTextFromImageURL() async throws { + let url = try authenticationFactorsURL + guard let llm = try devstral2(image: url) else { return } + + var response = "" + for try await token in llm.text { + response += token + } + + Swift.print(response) + let expected = [ + "authentication", + "Something you forgot", + "Something you left in the taxi", + "Something that can be chopped off", + ] + #expect(response.contains(oneOf: expected)) + } +} + +private func devstral2( + _ input: UserInput, + tools: [any ToolProtocol] = [] +) throws -> LLM? { + try loadModel( + directory: LLM.devstral2Small_24B, + input: input, + tools: tools, + responseParser: LLM.mistral3Parser + ) +} + +private func devstral2( + image: Data +) throws -> LLM? { + try loadModel( + directory: LLM.devstral2Small_24B, + input: imageInput(image), + responseParser: LLM.mistral3Parser + ) +} + +private func devstral2( + image: URL +) throws -> LLM? { + try loadModel( + directory: LLM.devstral2Small_24B, + input: imageInput(image), + responseParser: LLM.mistral3Parser + ) +} + +private var authenticationFactorsURL: URL { + get throws { + guard let url = Bundle.module.url( + forResource: "3-authentication-factors", + withExtension: "png" + ) else { + throw NSError( + domain: NSURLErrorDomain, + code: NSURLErrorFileDoesNotExist, + userInfo: nil + ) + } + return url + } +} + +private var authenticationFactors: Data { + get throws { + try Data(contentsOf: authenticationFactorsURL) + } +} diff --git a/Tests/SHLLMTests/Models/LFM2-8B-A1BTests.swift b/Tests/SHLLMTests/Models/LFM2-8B-A1BTests.swift index 7f1fd92..f1a9805 100644 --- a/Tests/SHLLMTests/Models/LFM2-8B-A1BTests.swift +++ b/Tests/SHLLMTests/Models/LFM2-8B-A1BTests.swift @@ -149,6 +149,7 @@ struct LFM2_8B_A1BTests { #expect(toolCall.function.name == "get_stock_price") #expect(toolCall.function.arguments["symbol"] == .string("AAPL")) + input.appendAssistantToolCall(toolCall) input.appendToolResult(["price": 123.45]) guard let llm2 = try lfm2_8B_A1B( input, @@ -162,9 +163,7 @@ struct LFM2_8B_A1BTests { #expect(result.contains("123.45")) } - // NOTE: This test is disabled because LFM2-8B-A1B can't seem to handle - // multi-step tool workflows. - @Test(.disabled()) + @Test() func canCompleteMultiToolWorkflowAndEmail() async throws { let chat: [Chat.Message] = [ .system(""" @@ -197,6 +196,7 @@ struct LFM2_8B_A1BTests { .first { $0.function.name == "web_search" }) #expect(toolCall1.function.name == "web_search") + input.appendAssistantToolCall(toolCall1) input.appendToolResult([ "results": [[ "title": "ACME Conference 2025 Keynote", @@ -213,6 +213,7 @@ struct LFM2_8B_A1BTests { .first { $0.function.name == "fetch_web_page" }) #expect(toolCall2.function.name == "fetch_web_page") + input.appendAssistantToolCall(toolCall2) input.appendToolResult([ "content": "Welcome to ACME Conf! Keynote date: November 5, 2025.", ]) @@ -226,6 +227,7 @@ struct LFM2_8B_A1BTests { .first { $0.function.name == "find_email_in_contacts" }) #expect(toolCall3.function.name == "find_email_in_contacts") + input.appendAssistantToolCall(toolCall3) input.appendToolResult([ "email": "alex@example.com", ]) @@ -245,6 +247,7 @@ struct LFM2_8B_A1BTests { #expect((subjectArg.anyValue as? String)?.isEmpty == false) #expect((bodyArg.anyValue as? String)?.isEmpty == false) + input.appendAssistantToolCall(toolCall4) input.appendToolResult([ "status": "sent", ]) diff --git a/Tests/SHLLMTests/Models/Ministral-3-14BTests.swift b/Tests/SHLLMTests/Models/Ministral-3-14BTests.swift new file mode 100644 index 0000000..f754fee --- /dev/null +++ b/Tests/SHLLMTests/Models/Ministral-3-14BTests.swift @@ -0,0 +1,398 @@ +import Foundation +import MLXLMCommon +import MLXVLM +@testable import SHLLM +import Testing + +@Suite(.serialized) +struct Ministral_3_14BTests { + @Test + func canStreamResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try ministral(input) else { return } + + var reasoning = "" + var result = "" + for try await reply in llm { + switch reply { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + result.append(text) + case .toolCall: + Issue.record() + } + } + + if !reasoning.isEmpty { + Swift.print("[THINK]\n\(reasoning)\n[/THINK]") + } + + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canStreamTextResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try ministral(input) else { return } + + var result = "" + for try await reply in llm.text { + result.append(reply) + } + + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canAwaitResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try ministral(input) else { return } + + let (_, _text, toolCalls) = try await llm.result + + let text = try #require(_text) + Swift.print(text) + #expect(!text.isEmpty) + + #expect(toolCalls == nil) + } + + @Test + func canAwaitTextResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try ministral(input) else { return } + + let result = try await llm.text.result + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canFetchTheWeather() async throws { + let input = UserInput(chat: [ + .system( + "You are a weather assistant who must use the get_current_weather tool to fetch weather data for any location the user asks about." + ), + .user("What is the weather in Paris, France?"), + ]) + + guard let llm = try ministral( + input, + tools: [weatherTool] + ) else { return } + + var reasoning = "" + var reply = "" + var toolCallCount = 0 + var weatherLocationFound = false + + for try await response in llm { + switch response { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + reply.append(text) + case let .toolCall(toolCall): + toolCallCount += 1 + #expect(toolCall.function.name == "get_current_weather") + + if case let .string(location) = toolCall.function.arguments["location"] { + weatherLocationFound = location.lowercased().contains("paris") + } + } + } + + #expect(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + #expect(toolCallCount >= 1) + #expect(weatherLocationFound) + } + + @Test + func canChooseBetweenDifferentTools() async throws { + let input = UserInput(chat: [ + .system( + "You are a helpful assistant that can provide weather, stock prices, and news." + ), + .user("Get the latest news about Apple, sorted by popularity."), + ]) + + guard let llm = try ministral( + input, + tools: [weatherTool, stockTool, newsTool] + ) else { return } + + var reasoning = "" + var reply = "" + var toolCallCount = 0 + var newsQueryFound = false + var newsSortByFound = false + + for try await response in llm { + switch response { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + reply.append(text) + case let .toolCall(toolCall): + toolCallCount += 1 + #expect(toolCall.function.name == "get_latest_news") + + if case let .string(query) = toolCall.function.arguments["query"] { + newsQueryFound = query.lowercased().contains("apple") + } + if case let .string(sortBy) = toolCall.function.arguments["sortBy"] { + newsSortByFound = sortBy.lowercased().contains("popularity") + } + } + } + + #expect(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + #expect(toolCallCount >= 1) + #expect(newsQueryFound) + #expect(newsSortByFound) + } + + @Test + func canUseStockToolAndRespond() async throws { + let chat: [Chat.Message] = [ + .system( + "You are a helpful assistant that can provide stock prices. When asked for a stock price, you must use the get_stock_price tool." + ), + .user("What is the price of AAPL?"), + ] + + var input = UserInput(chat: chat) + + guard let llm1 = try ministral( + input, + tools: [stockTool] + ) else { return } + + let (_, text, toolCallsOpt) = try await llm1.result + let toolCall = try #require(toolCallsOpt?.first) + + #expect(text == nil) + #expect(toolCall.function.name == "get_stock_price") + #expect(toolCall.function.arguments["symbol"] == .string("AAPL")) + + input.appendAssistantToolCall(toolCall) + input.appendToolResult(["price": 123.45]) + + guard let llm2 = try ministral( + input, + tools: [stockTool] + ) else { return } + + let result = try await llm2.text.result + Swift.print(result) + #expect(!result.isEmpty) + #expect(result.lowercased().contains("aapl")) + #expect(result.contains("123.45")) + } + + @Test + func canCompleteMultiToolWorkflowAndEmail() async throws { + let chat: [Chat.Message] = [ + .system(""" + You are a helpful assistant that must complete tasks by calling tools \ + in sequence. When asked to find information on the web and email it, \ + you must: + + 1) use web_search to find a relevant page + 2) use fetch_web_page to retrieve the page content + 3) use find_email_in_contacts to get the recipient's email + 4) use send_email to send the email with the requested information. + """ + ), + .user( + "Find the keynote date from the ACME Conference website and email it to Alex Example." + ), + ] + + var input = UserInput(chat: chat) + guard let llm = try ministral(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + + let (_, _, toolCallsOutput1) = try await llm.result + let toolCall1 = try #require(toolCallsOutput1?.first) + #expect(toolCall1.function.name == "web_search") + + input.appendAssistantToolCall(toolCall1) + input.appendToolResult([ + "results": [[ + "title": "ACME Conference 2025 Keynote", + "url": "https://acme.test/conf", + ]], + ]) + + guard let llm2 = try ministral(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (_, _, toolCallsOutput2) = try await llm2.result + let toolCall2 = try #require(toolCallsOutput2?.first) + #expect(toolCall2.function.name == "fetch_web_page") + + input.appendAssistantToolCall(toolCall2) + input.appendToolResult([ + "content": "Welcome to ACME Conf! Keynote date: November 5, 2025.", + ]) + + guard let llm3 = try ministral(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (_, _, toolCallsOutput3) = try await llm3.result + #expect(toolCallsOutput3?.count == 1) + let toolCall3 = try #require(toolCallsOutput3?.first) + #expect(toolCall3.function.name == "find_email_in_contacts") + + input.appendAssistantToolCall(toolCall3) + input.appendToolResult([ + "email": "alex@example.com", + ]) + + guard let llm4 = try ministral(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (reasoning, text, toolCalls4) = try await llm4.result + + guard let toolCall4 = toolCalls4?.first else { + Issue.record(""" + Did not call send_email: reasoning=\(String(describing: reasoning)), \ + text=\(String(describing: text)) + """ + ) + return + } + + #expect(toolCall4.function.name == "send_email") + let toArg = try #require(toolCall4.function.arguments["to"]) + let subjectArg = try #require(toolCall4.function.arguments["subject"]) + let bodyArg = try #require(toolCall4.function.arguments["body"]) + #expect((toArg.anyValue as? String) == "alex@example.com") + #expect((subjectArg.anyValue as? String)?.isEmpty == false) + #expect((bodyArg.anyValue as? String)?.isEmpty == false) + + input.appendAssistantToolCall(toolCall4) + input.appendToolResult(["status": "sent"]) + + guard let llm5 = try ministral(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + + let response = try await llm5.text.result + Swift.print(response) + #expect(!response.isEmpty) + #expect(response.contains(oneOf: ["sent", "emailed"])) + #expect(response.lowercased().contains("alex")) + } + + @Test + @MainActor + func canExtractTextFromImageData() async throws { + let data = try authenticationFactors + guard let llm = try ministral(image: data) else { return } + + var response = "" + for try await token in llm.text { + response += token + } + + Swift.print(response) + #expect(response.contains("authentication")) + } + + @Test + @MainActor + func canExtractTextFromImageURL() async throws { + let url = try authenticationFactorsURL + guard let llm = try ministral(image: url) else { return } + + var response = "" + for try await token in llm.text { + response += token + } + + Swift.print(response) + let expected = [ + "authentication", + "Something you forgot", + "Something you left in the taxi", + "Something that can be chopped off", + ] + #expect(response.contains(oneOf: expected)) + } +} + +private func ministral( + _ input: UserInput, + tools: [any ToolProtocol] = [] +) throws -> LLM? { + try loadModel( + directory: LLM.ministral_3_14B, + input: input, + tools: tools, + responseParser: LLM.mistral3Parser + ) +} + +private func ministral( + image: Data +) throws -> LLM? { + try loadModel( + directory: LLM.ministral_3_14B, + input: imageInput(image), + responseParser: LLM.mistral3Parser + ) +} + +private func ministral( + image: URL +) throws -> LLM? { + try loadModel( + directory: LLM.ministral_3_14B, + input: imageInput(image), + responseParser: LLM.mistral3Parser + ) +} + +private var authenticationFactorsURL: URL { + get throws { + guard let url = Bundle.module.url( + forResource: "3-authentication-factors", + withExtension: "png" + ) else { + throw NSError( + domain: NSURLErrorDomain, + code: NSURLErrorFileDoesNotExist, + userInfo: nil + ) + } + return url + } +} + +private var authenticationFactors: Data { + get throws { + try Data(contentsOf: authenticationFactorsURL) + } +} diff --git a/Tests/SHLLMTests/Models/NemotronNano-30BTests.swift b/Tests/SHLLMTests/Models/NemotronNano-30BTests.swift new file mode 100644 index 0000000..de67b9b --- /dev/null +++ b/Tests/SHLLMTests/Models/NemotronNano-30BTests.swift @@ -0,0 +1,331 @@ +import Foundation +import MLXLLM +import MLXLMCommon +@testable import SHLLM +import Testing + +@Suite(.serialized) +struct NemotronNano_30BTests { + @Test + func canStreamResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try nemotronNano(input) else { return } + + var reasoning = "" + var result = "" + for try await reply in llm { + switch reply { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + result.append(text) + case .toolCall: + Issue.record() + } + } + + Swift.print("\n\(reasoning)\n") + #expect(!reasoning.isEmpty) + + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canStreamTextResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try nemotronNano(input) else { return } + + var result = "" + for try await reply in llm.text { + result.append(reply) + } + + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canAwaitResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try nemotronNano(input) else { return } + + let (_reasoning, _text, toolCalls) = try await llm.result + + let reasoning = try #require(_reasoning) + Swift.print("\n\(reasoning)\n") + #expect(!reasoning.isEmpty) + + let text = try #require(_text) + Swift.print(text) + #expect(!text.isEmpty) + + #expect(toolCalls == nil) + } + + @Test + func canAwaitTextResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try nemotronNano(input) else { return } + + let result = try await llm.text.result + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canFetchTheWeather() async throws { + let input = UserInput(chat: [ + .system( + "You are a weather assistant who must use the get_current_weather tool to fetch weather data for any location the user asks about." + ), + .user("What is the weather in Paris, France?"), + ]) + + guard let llm = try nemotronNano( + input, + tools: [weatherTool] + ) else { return } + + var reasoning = "" + var reply = "" + var toolCallCount = 0 + var weatherLocationFound = false + + for try await response in llm { + switch response { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + reply.append(text) + case let .toolCall(toolCall): + toolCallCount += 1 + #expect(toolCall.function.name == "get_current_weather") + + if case let .string(location) = toolCall.function.arguments["location"] { + weatherLocationFound = location.lowercased().contains("paris") + } + } + } + + #expect(!reasoning.isEmpty) + #expect(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + #expect(toolCallCount >= 1) + #expect(weatherLocationFound) + } + + @Test + func canChooseBetweenDifferentTools() async throws { + let input = UserInput(chat: [ + .system( + "You are a helpful assistant that can provide weather, stock prices, and news." + ), + .user("Get the latest news about Apple, sorted by popularity."), + ]) + + guard let llm = try nemotronNano( + input, + tools: [weatherTool, stockTool, newsTool] + ) else { return } + + var reasoning = "" + var reply = "" + var toolCallCount = 0 + var newsQueryFound = false + var newsSortByFound = false + + for try await response in llm { + switch response { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + reply.append(text) + case let .toolCall(toolCall): + toolCallCount += 1 + #expect(toolCall.function.name == "get_latest_news") + + if case let .string(query) = toolCall.function.arguments["query"] { + newsQueryFound = query.lowercased().contains("apple") + } + if case let .string(sortBy) = toolCall.function.arguments["sortBy"] { + newsSortByFound = sortBy.lowercased().contains("popularity") + } + } + } + + #expect(!reasoning.isEmpty) + #expect(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + #expect(toolCallCount >= 1) + #expect(newsQueryFound) + #expect(newsSortByFound) + } + + @Test + func canUseStockToolAndRespond() async throws { + let chat: [Chat.Message] = [ + .system( + "You are a helpful assistant that can provide stock prices. When asked for a stock price, you must use the get_stock_price tool." + ), + .user("What is the price of AAPL?"), + ] + + var input = UserInput(chat: chat) + + guard let llm1 = try nemotronNano( + input, + tools: [stockTool] + ) else { return } + + let (reasoning, text, toolCallsOpt) = try await llm1.result + let toolCall = try #require(toolCallsOpt?.first) + + #expect(reasoning != nil) + #expect(text == nil) + #expect(toolCall.function.name == "get_stock_price") + #expect(toolCall.function.arguments["symbol"] == .string("AAPL")) + + input.appendAssistantToolCall(toolCall) + input.appendToolResult(["price": 123.45]) + + guard let llm2 = try nemotronNano( + input, + tools: [stockTool] + ) else { return } + + let result = try await llm2.text.result + Swift.print(result) + #expect(!result.isEmpty) + #expect(result.lowercased().contains("aapl")) + #expect(result.contains("123.45")) + } + + @Test + func canCompleteMultiToolWorkflowAndEmail() async throws { + let chat: [Chat.Message] = [ + .system(""" + You are a helpful assistant that must complete tasks by calling tools \ + in sequence. When asked to find information on the web and email it, \ + you must: + + 1) use web_search to find a relevant page + 2) use fetch_web_page to retrieve the page content + 3) use find_email_in_contacts to get the recipient's email + 4) use send_email to send the email with the requested information. + """ + ), + .user( + "Find the keynote date from the ACME Conference website and email it to Alex Example." + ), + ] + + // web_search + var input = UserInput(chat: chat) + guard let llm = try nemotronNano(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + + let (_, _, toolCallsOutput1) = try await llm.result + let toolCall1 = try #require(toolCallsOutput1?.first) + #expect(toolCall1.function.name == "web_search") + + input.appendAssistantToolCall(toolCall1) + input.appendToolResult([ + "results": [[ + "title": "ACME Conference 2025 Keynote", + "url": "https://acme.test/conf", + ]], + ]) + + // fetch_web_page + guard let llm2 = try nemotronNano(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (_, _, toolCallsOutput2) = try await llm2.result + let toolCall2 = try #require(toolCallsOutput2?.first) + #expect(toolCall2.function.name == "fetch_web_page") + + input.appendAssistantToolCall(toolCall2) + input.appendToolResult([ + "content": "Welcome to ACME Conf! Keynote date: November 5, 2025.", + ]) + + // find_email_in_contacts + guard let llm3 = try nemotronNano(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (_, _, toolCallsOutput3) = try await llm3.result + #expect(toolCallsOutput3?.count == 1) + let toolCall3 = try #require(toolCallsOutput3?.first) + #expect(toolCall3.function.name == "find_email_in_contacts") + + input.appendAssistantToolCall(toolCall3) + input.appendToolResult([ + "email": "alex@example.com", + ]) + + // send_email + guard let llm4 = try nemotronNano(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (reasoning, text, toolCalls4) = try await llm4.result + + guard let toolCall4 = toolCalls4?.first else { + Issue.record(""" + Did not call send_email: reasoning=\(String(describing: reasoning)), \ + text=\(String(describing: text)) + """ + ) + return + } + + #expect(toolCall4.function.name == "send_email") + let toArg = try #require(toolCall4.function.arguments["to"]) + let subjectArg = try #require(toolCall4.function.arguments["subject"]) + let bodyArg = try #require(toolCall4.function.arguments["body"]) + #expect((toArg.anyValue as? String) == "alex@example.com") + #expect((subjectArg.anyValue as? String)?.isEmpty == false) + #expect((bodyArg.anyValue as? String)?.isEmpty == false) + + input.appendAssistantToolCall(toolCall4) + input.appendToolResult(["status": "sent"]) + + // assistant response + guard let llm5 = try nemotronNano(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + + let response = try await llm5.text.result + Swift.print(response) + #expect(!response.isEmpty) + #expect(response.contains(oneOf: ["sent", "emailed"])) + #expect(response.lowercased().contains("alex")) + } +} + +private func nemotronNano( + _ input: UserInput, + tools: [any ToolProtocol] = [] +) throws -> LLM? { + try loadModel( + directory: LLM.nemotron3Nano_30B_A3B, + input: input, + tools: tools, + responseParser: LLM.nemotronParser + ) +} diff --git a/Tests/SHLLMTests/Models/Orchestrator-8BTests.swift b/Tests/SHLLMTests/Models/Orchestrator-8BTests.swift index f8a313f..990e4d9 100644 --- a/Tests/SHLLMTests/Models/Orchestrator-8BTests.swift +++ b/Tests/SHLLMTests/Models/Orchestrator-8BTests.swift @@ -154,6 +154,7 @@ struct Orchestrator_8BTests { #expect(toolCall.function.name == "get_stock_price") #expect(toolCall.function.arguments["symbol"] == .string("AAPL")) + input.appendAssistantToolCall(toolCall) input.appendToolResult(["price": 123.45]) guard let llm2 = try orchestrator_8B( @@ -197,6 +198,7 @@ struct Orchestrator_8BTests { let toolCall1 = try #require(toolCallsOutput1?.first) #expect(toolCall1.function.name == "web_search") + input.appendAssistantToolCall(toolCall1) input.appendToolResult([ "results": [[ "title": "ACME Conference 2025 Keynote", @@ -212,6 +214,7 @@ struct Orchestrator_8BTests { let toolCall2 = try #require(toolCallsOutput2?.first) #expect(toolCall2.function.name == "fetch_web_page") + input.appendAssistantToolCall(toolCall2) input.appendToolResult([ "content": "Welcome to ACME Conf! Keynote date: November 5, 2025.", ]) @@ -225,6 +228,7 @@ struct Orchestrator_8BTests { let toolCall3 = try #require(toolCallsOutput3?.first) #expect(toolCall3.function.name == "find_email_in_contacts") + input.appendAssistantToolCall(toolCall3) input.appendToolResult([ "email": "alex@example.com", ]) @@ -252,6 +256,7 @@ struct Orchestrator_8BTests { #expect((subjectArg.anyValue as? String)?.isEmpty == false) #expect((bodyArg.anyValue as? String)?.isEmpty == false) + input.appendAssistantToolCall(toolCall4) input.appendToolResult(["status": "sent"]) // assistant response diff --git a/Tests/SHLLMTests/Models/Qwen3-30BTests.swift b/Tests/SHLLMTests/Models/Qwen3-30BTests.swift index fe67aee..44d2f7b 100644 --- a/Tests/SHLLMTests/Models/Qwen3-30BTests.swift +++ b/Tests/SHLLMTests/Models/Qwen3-30BTests.swift @@ -154,6 +154,7 @@ struct Qwen3_30BTests { #expect(toolCall.function.name == "get_stock_price") #expect(toolCall.function.arguments["symbol"] == .string("AAPL")) + input.appendAssistantToolCall(toolCall) input.appendToolResult(["price": 123.45]) guard let llm2 = try qwen3MoE( @@ -197,6 +198,7 @@ struct Qwen3_30BTests { let toolCall1 = try #require(toolCallsOutput1?.first) #expect(toolCall1.function.name == "web_search") + input.appendAssistantToolCall(toolCall1) input.appendToolResult([ "results": [[ "title": "ACME Conference 2025 Keynote", @@ -212,6 +214,7 @@ struct Qwen3_30BTests { let toolCall2 = try #require(toolCallsOutput2?.first) #expect(toolCall2.function.name == "fetch_web_page") + input.appendAssistantToolCall(toolCall2) input.appendToolResult([ "content": "Welcome to ACME Conf! Keynote date: November 5, 2025.", ]) @@ -225,6 +228,7 @@ struct Qwen3_30BTests { let toolCall3 = try #require(toolCallsOutput3?.first) #expect(toolCall3.function.name == "find_email_in_contacts") + input.appendAssistantToolCall(toolCall3) input.appendToolResult([ "email": "alex@example.com", ]) @@ -252,6 +256,7 @@ struct Qwen3_30BTests { #expect((subjectArg.anyValue as? String)?.isEmpty == false) #expect((bodyArg.anyValue as? String)?.isEmpty == false) + input.appendAssistantToolCall(toolCall4) input.appendToolResult(["status": "sent"]) // assistant response diff --git a/Tests/SHLLMTests/Models/Qwen3-4BTests.swift b/Tests/SHLLMTests/Models/Qwen3-4BTests.swift index dab67c7..8fb3458 100644 --- a/Tests/SHLLMTests/Models/Qwen3-4BTests.swift +++ b/Tests/SHLLMTests/Models/Qwen3-4BTests.swift @@ -154,6 +154,7 @@ struct Qwen3_4BTests { #expect(toolCall.function.name == "get_stock_price") #expect(toolCall.function.arguments["symbol"] == .string("AAPL")) + input.appendAssistantToolCall(toolCall) input.appendToolResult(["price": 123.45]) guard let llm2 = try qwen3_4B( @@ -197,6 +198,7 @@ struct Qwen3_4BTests { let toolCall1 = try #require(toolCallsOutput1?.first) #expect(toolCall1.function.name == "web_search") + input.appendAssistantToolCall(toolCall1) input.appendToolResult([ "results": [[ "title": "ACME Conference 2025 Keynote", @@ -212,6 +214,7 @@ struct Qwen3_4BTests { let toolCall2 = try #require(toolCallsOutput2?.first) #expect(toolCall2.function.name == "fetch_web_page") + input.appendAssistantToolCall(toolCall2) input.appendToolResult([ "content": "Welcome to ACME Conf! Keynote date: November 5, 2025.", ]) @@ -225,6 +228,7 @@ struct Qwen3_4BTests { let toolCall3 = try #require(toolCallsOutput3?.first) #expect(toolCall3.function.name == "find_email_in_contacts") + input.appendAssistantToolCall(toolCall3) input.appendToolResult([ "email": "alex@example.com", ]) @@ -252,6 +256,7 @@ struct Qwen3_4BTests { #expect((subjectArg.anyValue as? String)?.isEmpty == false) #expect((bodyArg.anyValue as? String)?.isEmpty == false) + input.appendAssistantToolCall(toolCall4) input.appendToolResult(["status": "sent"]) // assistant response diff --git a/Tests/SHLLMTests/Models/Qwen3-8BTests.swift b/Tests/SHLLMTests/Models/Qwen3-8BTests.swift index 1939c64..005eae6 100644 --- a/Tests/SHLLMTests/Models/Qwen3-8BTests.swift +++ b/Tests/SHLLMTests/Models/Qwen3-8BTests.swift @@ -203,6 +203,7 @@ struct Qwen3_8BTests { #expect(toolCall1.function.name == "get_stock_price") #expect(toolCall1.function.arguments["symbol"] == .string("AAPL")) + input.appendAssistantToolCall(toolCall1) input.appendToolResult(["price": 123.45]) guard let llm2 = try qwen3_8B( input, @@ -247,6 +248,7 @@ struct Qwen3_8BTests { let toolCall1 = try #require(toolCallsOutput1?.first) #expect(toolCall1.function.name == "web_search") + input.appendAssistantToolCall(toolCall1) input.appendToolResult([ "results": [[ "title": "ACME Conference 2025 Keynote", @@ -262,6 +264,7 @@ struct Qwen3_8BTests { let toolCall2 = try #require(toolCallsOutput2?.first) #expect(toolCall2.function.name == "fetch_web_page") + input.appendAssistantToolCall(toolCall2) input.appendToolResult([ "content": "Welcome to ACME Conf! Keynote date: November 5, 2025.", ]) @@ -274,6 +277,7 @@ struct Qwen3_8BTests { let toolCall3 = try #require(toolCallsOutput3?.first) #expect(toolCall3.function.name == "find_email_in_contacts") + input.appendAssistantToolCall(toolCall3) input.appendToolResult([ "email": "alex@example.com", ]) @@ -292,6 +296,7 @@ struct Qwen3_8BTests { #expect((subjectArg.anyValue as? String)?.isEmpty == false) #expect((bodyArg.anyValue as? String)?.isEmpty == false) + input.appendAssistantToolCall(toolCall4) input.appendToolResult([ "status": "sent", ]) diff --git a/Tests/SHLLMTests/Models/Qwen3_5-27BTests.swift b/Tests/SHLLMTests/Models/Qwen3_5-27BTests.swift new file mode 100644 index 0000000..983c6d9 --- /dev/null +++ b/Tests/SHLLMTests/Models/Qwen3_5-27BTests.swift @@ -0,0 +1,429 @@ +import Foundation +import MLXLMCommon +import MLXVLM +@testable import SHLLM +import Testing + +@Suite(.serialized) +struct Qwen3_5_27BTests { + @Test + func canStreamResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try qwen3_5__27B(input) else { return } + + var reasoning = "" + var result = "" + for try await reply in llm { + switch reply { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + result.append(text) + case .toolCall: + Issue.record() + } + } + + Swift.print("\n\(reasoning)\n") + #expect(!reasoning.isEmpty) + + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canStreamTextResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try qwen3_5__27B(input) else { return } + + var result = "" + for try await reply in llm.text { + result.append(reply) + } + + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canAwaitResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try qwen3_5__27B(input) else { return } + + let (_reasoning, _text, toolCalls) = try await llm.result + + let reasoning = try #require(_reasoning) + Swift.print("\n\(reasoning)\n") + #expect(!reasoning.isEmpty) + + let text = try #require(_text) + Swift.print(text) + #expect(!text.isEmpty) + + #expect(toolCalls == nil) + } + + @Test + func canAwaitTextResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try qwen3_5__27B(input) else { return } + + let result = try await llm.text.result + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canStreamResultWithoutThinking() async throws { + var input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + input.additionalContext = ["enable_thinking": false] + + guard let llm = try qwen3_5__27B(input) else { return } + + let (reasoning, _text, _) = try await llm.result + #expect(reasoning == nil) + + let text = try #require(_text) + Swift.print(text) + #expect(!text.isEmpty) + } + + @Test + func canFetchTheWeather() async throws { + let input = UserInput(chat: [ + .system( + "You are a weather assistant who must use the get_current_weather tool to fetch weather data for any location the user asks about." + ), + .user("What is the weather in Paris, France?"), + ]) + + guard let llm = try qwen3_5__27B( + input, + tools: [weatherTool] + ) else { return } + + var reasoning = "" + var reply = "" + var toolCallCount = 0 + var weatherLocationFound = false + + for try await response in llm { + switch response { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + reply.append(text) + case let .toolCall(toolCall): + toolCallCount += 1 + #expect(toolCall.function.name == "get_current_weather") + + if case let .string(location) = toolCall.function.arguments["location"] { + weatherLocationFound = location.lowercased().contains("paris") + } + } + } + + #expect(!reasoning.isEmpty) + #expect(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + #expect(toolCallCount >= 1) + #expect(weatherLocationFound) + } + + @Test + func canChooseBetweenDifferentTools() async throws { + let input = UserInput(chat: [ + .system( + "You are a helpful assistant that can provide weather, stock prices, and news." + ), + .user("Get the latest news about Apple, sorted by popularity."), + ]) + + guard let llm = try qwen3_5__27B( + input, + tools: [weatherTool, stockTool, newsTool] + ) else { return } + + var reasoning = "" + var reply = "" + var toolCallCount = 0 + var newsQueryFound = false + var newsSortByFound = false + + for try await response in llm { + switch response { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + reply.append(text) + case let .toolCall(toolCall): + toolCallCount += 1 + #expect(toolCall.function.name == "get_latest_news") + + if case let .string(query) = toolCall.function.arguments["query"] { + newsQueryFound = query.lowercased().contains("apple") + } + if case let .string(sortBy) = toolCall.function.arguments["sortBy"] { + newsSortByFound = sortBy.lowercased().contains("popularity") + } + } + } + + #expect(!reasoning.isEmpty) + #expect(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + #expect(toolCallCount >= 1) + #expect(newsQueryFound) + #expect(newsSortByFound) + } + + @Test + func canUseStockToolAndRespond() async throws { + let chat: [Chat.Message] = [ + .system( + "You are a helpful assistant that can provide stock prices. When asked for a stock price, you must use the get_stock_price tool." + ), + .user("What is the price of AAPL?"), + ] + + var input = UserInput(chat: chat) + + guard let llm1 = try qwen3_5__27B( + input, + tools: [stockTool] + ) else { return } + + let (reasoning1, text1, toolCallsOpt1) = try await llm1.result + #expect(reasoning1 != nil) + #expect(text1 == nil) + let toolCall1 = try #require(toolCallsOpt1?.first) + + #expect(toolCall1.function.name == "get_stock_price") + #expect(toolCall1.function.arguments["symbol"] == .string("AAPL")) + + input.appendAssistantToolCall(toolCall1) + input.appendToolResult(["price": 123.45]) + guard let llm2 = try qwen3_5__27B( + input, + tools: [stockTool] + ) else { return } + + let (reasoning2, text2, toolCallsOpt2) = try await llm2.result + Swift.print(text2 ?? "") + #expect(reasoning2 != nil) + #expect(text2?.isEmpty == false) + #expect(text2?.contains(oneOf: ["aapl"]) == true) + #expect(text2?.contains("123.45") == true) + #expect(toolCallsOpt2 == nil) + } + + @Test + func canCompleteMultiToolWorkflowAndEmail() async throws { + let chat: [Chat.Message] = [ + .system(""" + You are a helpful assistant that must complete tasks by calling tools \ + in sequence. When asked to find information on the web and email it, \ + you must: + + 1) use web_search to find a relevant page + 2) use fetch_web_page to retrieve the page content + 3) use find_email_in_contacts to get the recipient's email + 4) use send_email to send the email with the requested information. + """ + ), + .user( + "Find the keynote date from the ACME Conference website and email it to Alex Example." + ), + ] + + var input = UserInput(chat: chat) + guard let llm = try qwen3_5__27B(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + + let (_, _, toolCallsOutput1) = try await llm.result + let toolCall1 = try #require(toolCallsOutput1?.first) + #expect(toolCall1.function.name == "web_search") + + input.appendAssistantToolCall(toolCall1) + input.appendToolResult([ + "results": [[ + "title": "ACME Conference 2025 Keynote", + "url": "https://acme.test/conf", + ]], + ]) + + guard let llm2 = try qwen3_5__27B(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (_, _, toolCallsOutput2) = try await llm2.result + let toolCall2 = try #require(toolCallsOutput2?.first) + #expect(toolCall2.function.name == "fetch_web_page") + + input.appendAssistantToolCall(toolCall2) + input.appendToolResult([ + "content": "Welcome to ACME Conf! Keynote date: November 5, 2025.", + ]) + + guard let llm3 = try qwen3_5__27B(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (_, _, toolCallsOutput3) = try await llm3.result + let toolCall3 = try #require(toolCallsOutput3?.first) + #expect(toolCall3.function.name == "find_email_in_contacts") + + input.appendAssistantToolCall(toolCall3) + input.appendToolResult([ + "email": "alex@example.com", + ]) + + guard let llm4 = try qwen3_5__27B(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (reasoning, text, toolCalls4) = try await llm4.result + + guard let toolCall4 = toolCalls4?.first else { + Issue.record(""" + Did not call send_email: reasoning=\(String(describing: reasoning)), \ + text=\(String(describing: text)) + """) + return + } + + #expect(toolCall4.function.name == "send_email") + let toArg = try #require(toolCall4.function.arguments["to"]) + let subjectArg = try #require(toolCall4.function.arguments["subject"]) + let bodyArg = try #require(toolCall4.function.arguments["body"]) + #expect((toArg.anyValue as? String) == "alex@example.com") + #expect((subjectArg.anyValue as? String)?.isEmpty == false) + #expect((bodyArg.anyValue as? String)?.isEmpty == false) + + input.appendAssistantToolCall(toolCall4) + input.appendToolResult(["status": "sent"]) + + guard let llm5 = try qwen3_5__27B(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + + let response = try await llm5.text.result + Swift.print(response) + #expect(!response.isEmpty) + #expect(response.contains(oneOf: ["sent", "emailed"])) + #expect(response.lowercased().contains("alex")) + } + + @Test + @MainActor + func canExtractTextFromImageData() async throws { + let data = try authenticationFactors + guard let llm = try qwen3_5__27B(image: data) else { return } + + var response = "" + for try await token in llm.text { + response += token + } + + Swift.print(response) + let strings = [ + "authentication", + "Something you forgot", + "Something you left in the taxi", + "Something that can be chopped off", + ] + #expect(response.contains(oneOf: strings)) + } + + @Test + @MainActor + func canExtractTextFromImageURL() async throws { + let url = try authenticationFactorsURL + guard let llm = try qwen3_5__27B(image: url) else { return } + + var response = "" + for try await token in llm.text { + response += token + } + + Swift.print(response) + let expected = [ + "authentication", + "Something you forgot", + "Something you left in the taxi", + "Something that can be chopped off", + ] + #expect(response.contains(oneOf: expected)) + } +} + +private func qwen3_5__27B( + _ input: UserInput, + tools: [any ToolProtocol] = [] +) throws -> LLM? { + try loadModel( + directory: LLM.qwen3_5__27B, + input: input, + tools: tools, + responseParser: LLM.qwen3_5Parser(for: input) + ) +} + +private func qwen3_5__27B( + image: Data +) throws -> LLM? { + let input = imageInput(image) + return try loadModel( + directory: LLM.qwen3_5__27B, + input: input, + responseParser: LLM.qwen3_5Parser(for: input) + ) +} + +private func qwen3_5__27B( + image: URL +) throws -> LLM? { + let input = imageInput(image) + return try loadModel( + directory: LLM.qwen3_5__27B, + input: input, + responseParser: LLM.qwen3_5Parser(for: input) + ) +} + +private var authenticationFactorsURL: URL { + get throws { + guard let url = Bundle.module.url( + forResource: "3-authentication-factors", + withExtension: "png" + ) else { + throw NSError( + domain: NSURLErrorDomain, + code: NSURLErrorFileDoesNotExist, + userInfo: nil + ) + } + return url + } +} + +private var authenticationFactors: Data { + get throws { + try Data(contentsOf: authenticationFactorsURL) + } +} diff --git a/Tests/SHLLMTests/Models/Qwen3_5-2BTests.swift b/Tests/SHLLMTests/Models/Qwen3_5-2BTests.swift new file mode 100644 index 0000000..4cbfaed --- /dev/null +++ b/Tests/SHLLMTests/Models/Qwen3_5-2BTests.swift @@ -0,0 +1,444 @@ +import Foundation +import MLXLMCommon +import MLXVLM +@testable import SHLLM +import Testing + +@Suite(.serialized) +struct Qwen3_5_2BTests { + @Test + func canStreamResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ], additionalContext: ["enable_thinking": false]) + + guard let llm = try qwen3_5__2B(input) else { return } + + var reasoning = "" + var result = "" + for try await reply in llm { + switch reply { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + result.append(text) + case .toolCall: + Issue.record() + } + } + + #expect(reasoning.isEmpty) + + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canStreamTextResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ], additionalContext: ["enable_thinking": false]) + + guard let llm = try qwen3_5__2B(input) else { return } + + var result = "" + for try await reply in llm.text { + result.append(reply) + } + + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canAwaitResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ], additionalContext: ["enable_thinking": false]) + + guard let llm = try qwen3_5__2B(input) else { return } + + let (reasoning, _text, toolCalls) = try await llm.result + + #expect(reasoning == nil) + + let text = try #require(_text) + Swift.print(text) + #expect(!text.isEmpty) + + #expect(toolCalls == nil) + } + + @Test + func canAwaitTextResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ], additionalContext: ["enable_thinking": false]) + + guard let llm = try qwen3_5__2B(input) else { return } + + let result = try await llm.text.result + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test() + func canStreamResultWithThinking() async throws { + let input: UserInput = .init( + messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ], + additionalContext: ["enable_thinking": true] + ) + + guard let llm = try qwen3_5__2B(input) else { return } + + let (_reasoning, _text, _) = try await llm.result + + let reasoning = try #require(_reasoning) + Swift.print("\n\(reasoning)\n") + #expect(!reasoning.isEmpty) + + let text = try #require(_text) + Swift.print(text) + #expect(!text.isEmpty) + } + + @Test + func canFetchTheWeather() async throws { + let input = UserInput(chat: [ + .system( + "You are a weather assistant who must use the get_current_weather tool to fetch weather data for any location the user asks about." + ), + .user("What is the weather in Paris, France?"), + ], additionalContext: ["enable_thinking": false]) + + guard let llm = try qwen3_5__2B( + input, + tools: [weatherTool] + ) else { return } + + var reasoning = "" + var reply = "" + var toolCallCount = 0 + var weatherLocationFound = false + + for try await response in llm { + switch response { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + reply.append(text) + case let .toolCall(toolCall): + toolCallCount += 1 + #expect(toolCall.function.name == "get_current_weather") + + if case let .string(location) = toolCall.function.arguments["location"] { + weatherLocationFound = location.lowercased().contains("paris") + } + } + } + + #expect(reasoning.isEmpty) + #expect(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + #expect(toolCallCount >= 1) + #expect(weatherLocationFound) + } + + @Test + func canChooseBetweenDifferentTools() async throws { + let input = UserInput(chat: [ + .system( + "You are a helpful assistant that can provide weather, stock prices, and news." + ), + .user("Get the latest news about Apple, sorted by popularity."), + ], additionalContext: ["enable_thinking": false]) + + guard let llm = try qwen3_5__2B( + input, + tools: [weatherTool, stockTool, newsTool] + ) else { return } + + var reasoning = "" + var reply = "" + var toolCallCount = 0 + var newsQueryFound = false + var newsSortByFound = false + + for try await response in llm { + switch response { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + reply.append(text) + case let .toolCall(toolCall): + toolCallCount += 1 + #expect(toolCall.function.name == "get_latest_news") + + if case let .string(query) = toolCall.function.arguments["query"] { + newsQueryFound = query.lowercased().contains("apple") + } + if case let .string(sortBy) = toolCall.function.arguments["sortBy"] { + newsSortByFound = sortBy.lowercased().contains("popularity") + } + } + } + + #expect(reasoning.isEmpty) + #expect(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + #expect(toolCallCount >= 1) + #expect(newsQueryFound) + #expect(newsSortByFound) + } + + @Test + func canUseStockToolAndRespond() async throws { + let chat: [Chat.Message] = [ + .system( + "You are a helpful assistant that can provide stock prices. When asked for a stock price, you must use the get_stock_price tool." + ), + .user("What is the price of AAPL?"), + ] + + var input = UserInput( + chat: chat, + additionalContext: ["enable_thinking": false] + ) + + guard let llm1 = try qwen3_5__2B( + input, + tools: [stockTool] + ) else { return } + + let (reasoning1, text1, toolCallsOpt1) = try await llm1.result + #expect(reasoning1 == nil) + #expect(text1 == nil) + let toolCall1 = try #require(toolCallsOpt1?.first) + + #expect(toolCall1.function.name == "get_stock_price") + #expect(toolCall1.function.arguments["symbol"] == .string("AAPL")) + + input.appendAssistantToolCall(toolCall1) + input.appendToolResult(["price": 123.45]) + guard let llm2 = try qwen3_5__2B( + input, + tools: [stockTool] + ) else { return } + + let (reasoning2, text2, toolCallsOpt2) = try await llm2.result + Swift.print(text2 ?? "") + #expect(reasoning2 == nil) + #expect(text2?.isEmpty == false) + #expect(text2?.contains(oneOf: ["aapl"]) == true) + #expect(text2?.contains("123.45") == true) + #expect(toolCallsOpt2 == nil) + } + + @Test + func canCompleteMultiToolWorkflowAndEmail() async throws { + let chat: [Chat.Message] = [ + .system(""" + You are a helpful assistant that must complete tasks by calling tools \ + in sequence. When asked to find information on the web and email it, \ + you must: + + 1) use web_search to find a relevant page + 2) use fetch_web_page to retrieve the page content + 3) use find_email_in_contacts to get the recipient's email + 4) use send_email to send the email with the requested information. + """ + ), + .user( + "Find the keynote date from the ACME Conference website and email it to Alex Example." + ), + ] + + // web_search + var input = UserInput( + chat: chat, + additionalContext: ["enable_thinking": false] + ) + guard let llm = try qwen3_5__2B(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + + let (_, _, toolCallsOutput1) = try await llm.result + let toolCall1 = try #require(toolCallsOutput1?.first) + #expect(toolCall1.function.name == "web_search") + + input.appendAssistantToolCall(toolCall1) + input.appendToolResult([ + "results": [[ + "title": "ACME Conference 2025 Keynote", + "url": "https://acme.test/conf", + ]], + ]) + + // fetch_web_page + guard let llm2 = try qwen3_5__2B(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (_, _, toolCallsOutput2) = try await llm2.result + let toolCall2 = try #require(toolCallsOutput2?.first) + #expect(toolCall2.function.name == "fetch_web_page") + + input.appendAssistantToolCall(toolCall2) + input.appendToolResult([ + "content": "Welcome to ACME Conf! Keynote date: November 5, 2025.", + ]) + + // find_email_in_contacts + guard let llm3 = try qwen3_5__2B(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (_, _, toolCallsOutput3) = try await llm3.result + let toolCall3 = try #require(toolCallsOutput3?.first) + #expect(toolCall3.function.name == "find_email_in_contacts") + + input.appendAssistantToolCall(toolCall3) + input.appendToolResult([ + "email": "alex@example.com", + ]) + + // send_email + guard let llm4 = try qwen3_5__2B(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (reasoning, text, toolCalls4) = try await llm4.result + + guard let toolCall4 = toolCalls4?.first else { + Issue.record(""" + Did not call send_email: reasoning=\(String(describing: reasoning)), \ + text=\(String(describing: text)) + """) + return + } + + #expect(toolCall4.function.name == "send_email") + let toArg = try #require(toolCall4.function.arguments["to"]) + let subjectArg = try #require(toolCall4.function.arguments["subject"]) + let bodyArg = try #require(toolCall4.function.arguments["body"]) + #expect((toArg.anyValue as? String) == "alex@example.com") + #expect((subjectArg.anyValue as? String)?.isEmpty == false) + #expect((bodyArg.anyValue as? String)?.isEmpty == false) + + input.appendAssistantToolCall(toolCall4) + input.appendToolResult(["status": "sent"]) + + // assistant response + guard let llm5 = try qwen3_5__2B(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + + let response = try await llm5.text.result + Swift.print(response) + #expect(!response.isEmpty) + #expect(response.contains(oneOf: ["sent", "emailed"])) + #expect(response.lowercased().contains("alex")) + } + + @Test + @MainActor + func canExtractTextFromImageData() async throws { + let data = try authenticationFactors + guard let llm = try qwen3_5__2B(image: data) else { return } + + var response = "" + for try await token in llm.text { + response += token + } + + Swift.print(response) + let strings = [ + "authentication", + "Something you forgot", + "Something you left in the taxi", + "Something that can be chopped off", + ] + #expect(response.contains(oneOf: strings)) + } + + @Test + @MainActor + func canExtractTextFromImageURL() async throws { + let url = try authenticationFactorsURL + guard let llm = try qwen3_5__2B(image: url) else { return } + + var response = "" + for try await token in llm.text { + response += token + } + + Swift.print(response) + let expected = [ + "authentication", + "Something you forgot", + "Something you left in the taxi", + "Something that can be chopped off", + ] + #expect(response.contains(oneOf: expected)) + } +} + +private func qwen3_5__2B( + _ input: UserInput, + tools: [any ToolProtocol] = [] +) throws -> LLM? { + try loadModel( + directory: LLM.qwen3_5__2B, + input: input, + tools: tools, + responseParser: LLM.qwen3_5Parser(for: input) + ) +} + +private func qwen3_5__2B( + image: Data +) throws -> LLM? { + var input = imageInput(image) + input.additionalContext = ["enable_thinking": false] + return try loadModel( + directory: LLM.qwen3_5__2B, + input: input, + responseParser: LLM.qwen3_5Parser(for: input) + ) +} + +private func qwen3_5__2B( + image: URL +) throws -> LLM? { + var input = imageInput(image) + input.additionalContext = ["enable_thinking": false] + return try loadModel( + directory: LLM.qwen3_5__2B, + input: input, + responseParser: LLM.qwen3_5Parser(for: input) + ) +} + +private var authenticationFactorsURL: URL { + get throws { + guard let url = Bundle.module.url( + forResource: "3-authentication-factors", + withExtension: "png" + ) else { + throw NSError( + domain: NSURLErrorDomain, + code: NSURLErrorFileDoesNotExist, + userInfo: nil + ) + } + return url + } +} + +private var authenticationFactors: Data { + get throws { + try Data(contentsOf: authenticationFactorsURL) + } +} diff --git a/Tests/SHLLMTests/Models/Qwen3_5-35B-A3BTests.swift b/Tests/SHLLMTests/Models/Qwen3_5-35B-A3BTests.swift new file mode 100644 index 0000000..4f41b17 --- /dev/null +++ b/Tests/SHLLMTests/Models/Qwen3_5-35B-A3BTests.swift @@ -0,0 +1,429 @@ +import Foundation +import MLXLMCommon +import MLXVLM +@testable import SHLLM +import Testing + +@Suite(.serialized) +struct Qwen3_5_35B_A3BTests { + @Test + func canStreamResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try qwen3_5MoE(input) else { return } + + var reasoning = "" + var result = "" + for try await reply in llm { + switch reply { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + result.append(text) + case .toolCall: + Issue.record() + } + } + + Swift.print("\n\(reasoning)\n") + #expect(!reasoning.isEmpty) + + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canStreamTextResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try qwen3_5MoE(input) else { return } + + var result = "" + for try await reply in llm.text { + result.append(reply) + } + + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canAwaitResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try qwen3_5MoE(input) else { return } + + let (_reasoning, _text, toolCalls) = try await llm.result + + let reasoning = try #require(_reasoning) + Swift.print("\n\(reasoning)\n") + #expect(!reasoning.isEmpty) + + let text = try #require(_text) + Swift.print(text) + #expect(!text.isEmpty) + + #expect(toolCalls == nil) + } + + @Test + func canAwaitTextResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try qwen3_5MoE(input) else { return } + + let result = try await llm.text.result + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canStreamResultWithoutThinking() async throws { + var input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + input.additionalContext = ["enable_thinking": false] + + guard let llm = try qwen3_5MoE(input) else { return } + + let (reasoning, _text, _) = try await llm.result + #expect(reasoning == nil) + + let text = try #require(_text) + Swift.print(text) + #expect(!text.isEmpty) + } + + @Test + func canFetchTheWeather() async throws { + let input = UserInput(chat: [ + .system( + "You are a weather assistant who must use the get_current_weather tool to fetch weather data for any location the user asks about." + ), + .user("What is the weather in Paris, France?"), + ]) + + guard let llm = try qwen3_5MoE( + input, + tools: [weatherTool] + ) else { return } + + var reasoning = "" + var reply = "" + var toolCallCount = 0 + var weatherLocationFound = false + + for try await response in llm { + switch response { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + reply.append(text) + case let .toolCall(toolCall): + toolCallCount += 1 + #expect(toolCall.function.name == "get_current_weather") + + if case let .string(location) = toolCall.function.arguments["location"] { + weatherLocationFound = location.lowercased().contains("paris") + } + } + } + + #expect(!reasoning.isEmpty) + #expect(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + #expect(toolCallCount >= 1) + #expect(weatherLocationFound) + } + + @Test + func canChooseBetweenDifferentTools() async throws { + let input = UserInput(chat: [ + .system( + "You are a helpful assistant that can provide weather, stock prices, and news." + ), + .user("Get the latest news about Apple, sorted by popularity."), + ]) + + guard let llm = try qwen3_5MoE( + input, + tools: [weatherTool, stockTool, newsTool] + ) else { return } + + var reasoning = "" + var reply = "" + var toolCallCount = 0 + var newsQueryFound = false + var newsSortByFound = false + + for try await response in llm { + switch response { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + reply.append(text) + case let .toolCall(toolCall): + toolCallCount += 1 + #expect(toolCall.function.name == "get_latest_news") + + if case let .string(query) = toolCall.function.arguments["query"] { + newsQueryFound = query.lowercased().contains("apple") + } + if case let .string(sortBy) = toolCall.function.arguments["sortBy"] { + newsSortByFound = sortBy.lowercased().contains("popularity") + } + } + } + + #expect(!reasoning.isEmpty) + #expect(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + #expect(toolCallCount >= 1) + #expect(newsQueryFound) + #expect(newsSortByFound) + } + + @Test + func canUseStockToolAndRespond() async throws { + let chat: [Chat.Message] = [ + .system( + "You are a helpful assistant that can provide stock prices. When asked for a stock price, you must use the get_stock_price tool." + ), + .user("What is the price of AAPL?"), + ] + + var input = UserInput(chat: chat) + + guard let llm1 = try qwen3_5MoE( + input, + tools: [stockTool] + ) else { return } + + let (reasoning1, text1, toolCallsOpt1) = try await llm1.result + #expect(reasoning1 != nil) + #expect(text1 == nil) + let toolCall1 = try #require(toolCallsOpt1?.first) + + #expect(toolCall1.function.name == "get_stock_price") + #expect(toolCall1.function.arguments["symbol"] == .string("AAPL")) + + input.appendAssistantToolCall(toolCall1) + input.appendToolResult(["price": 123.45]) + guard let llm2 = try qwen3_5MoE( + input, + tools: [stockTool] + ) else { return } + + let (reasoning2, text2, toolCallsOpt2) = try await llm2.result + Swift.print(text2 ?? "") + #expect(reasoning2 != nil) + #expect(text2?.isEmpty == false) + #expect(text2?.contains(oneOf: ["aapl"]) == true) + #expect(text2?.contains("123.45") == true) + #expect(toolCallsOpt2 == nil) + } + + @Test + func canCompleteMultiToolWorkflowAndEmail() async throws { + let chat: [Chat.Message] = [ + .system(""" + You are a helpful assistant that must complete tasks by calling tools \ + in sequence. When asked to find information on the web and email it, \ + you must: + + 1) use web_search to find a relevant page + 2) use fetch_web_page to retrieve the page content + 3) use find_email_in_contacts to get the recipient's email + 4) use send_email to send the email with the requested information. + """ + ), + .user( + "Find the keynote date from the ACME Conference website and email it to Alex Example." + ), + ] + + var input = UserInput(chat: chat) + guard let llm = try qwen3_5MoE(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + + let (_, _, toolCallsOutput1) = try await llm.result + let toolCall1 = try #require(toolCallsOutput1?.first) + #expect(toolCall1.function.name == "web_search") + + input.appendAssistantToolCall(toolCall1) + input.appendToolResult([ + "results": [[ + "title": "ACME Conference 2025 Keynote", + "url": "https://acme.test/conf", + ]], + ]) + + guard let llm2 = try qwen3_5MoE(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (_, _, toolCallsOutput2) = try await llm2.result + let toolCall2 = try #require(toolCallsOutput2?.first) + #expect(toolCall2.function.name == "fetch_web_page") + + input.appendAssistantToolCall(toolCall2) + input.appendToolResult([ + "content": "Welcome to ACME Conf! Keynote date: November 5, 2025.", + ]) + + guard let llm3 = try qwen3_5MoE(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (_, _, toolCallsOutput3) = try await llm3.result + let toolCall3 = try #require(toolCallsOutput3?.first) + #expect(toolCall3.function.name == "find_email_in_contacts") + + input.appendAssistantToolCall(toolCall3) + input.appendToolResult([ + "email": "alex@example.com", + ]) + + guard let llm4 = try qwen3_5MoE(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (reasoning, text, toolCalls4) = try await llm4.result + + guard let toolCall4 = toolCalls4?.first else { + Issue.record(""" + Did not call send_email: reasoning=\(String(describing: reasoning)), \ + text=\(String(describing: text)) + """) + return + } + + #expect(toolCall4.function.name == "send_email") + let toArg = try #require(toolCall4.function.arguments["to"]) + let subjectArg = try #require(toolCall4.function.arguments["subject"]) + let bodyArg = try #require(toolCall4.function.arguments["body"]) + #expect((toArg.anyValue as? String) == "alex@example.com") + #expect((subjectArg.anyValue as? String)?.isEmpty == false) + #expect((bodyArg.anyValue as? String)?.isEmpty == false) + + input.appendAssistantToolCall(toolCall4) + input.appendToolResult(["status": "sent"]) + + guard let llm5 = try qwen3_5MoE(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + + let response = try await llm5.text.result + Swift.print(response) + #expect(!response.isEmpty) + #expect(response.contains(oneOf: ["sent", "emailed"])) + #expect(response.lowercased().contains("alex")) + } + + @Test + @MainActor + func canExtractTextFromImageData() async throws { + let data = try authenticationFactors + guard let llm = try qwen3_5MoE(image: data) else { return } + + var response = "" + for try await token in llm.text { + response += token + } + + Swift.print(response) + let strings = [ + "authentication", + "Something you forgot", + "Something you left in the taxi", + "Something that can be chopped off", + ] + #expect(response.contains(oneOf: strings)) + } + + @Test + @MainActor + func canExtractTextFromImageURL() async throws { + let url = try authenticationFactorsURL + guard let llm = try qwen3_5MoE(image: url) else { return } + + var response = "" + for try await token in llm.text { + response += token + } + + Swift.print(response) + let expected = [ + "authentication", + "Something you forgot", + "Something you left in the taxi", + "Something that can be chopped off", + ] + #expect(response.contains(oneOf: expected)) + } +} + +private func qwen3_5MoE( + _ input: UserInput, + tools: [any ToolProtocol] = [] +) throws -> LLM? { + try loadModel( + directory: LLM.qwen3_5MoE__35B_A3B, + input: input, + tools: tools, + responseParser: LLM.qwen3_5MoEParser(for: input) + ) +} + +private func qwen3_5MoE( + image: Data +) throws -> LLM? { + let input = imageInput(image) + return try loadModel( + directory: LLM.qwen3_5MoE__35B_A3B, + input: input, + responseParser: LLM.qwen3_5MoEParser(for: input) + ) +} + +private func qwen3_5MoE( + image: URL +) throws -> LLM? { + let input = imageInput(image) + return try loadModel( + directory: LLM.qwen3_5MoE__35B_A3B, + input: input, + responseParser: LLM.qwen3_5MoEParser(for: input) + ) +} + +private var authenticationFactorsURL: URL { + get throws { + guard let url = Bundle.module.url( + forResource: "3-authentication-factors", + withExtension: "png" + ) else { + throw NSError( + domain: NSURLErrorDomain, + code: NSURLErrorFileDoesNotExist, + userInfo: nil + ) + } + return url + } +} + +private var authenticationFactors: Data { + get throws { + try Data(contentsOf: authenticationFactorsURL) + } +} diff --git a/Tests/SHLLMTests/Models/Qwen3_5-9BTests.swift b/Tests/SHLLMTests/Models/Qwen3_5-9BTests.swift new file mode 100644 index 0000000..4cfb4b4 --- /dev/null +++ b/Tests/SHLLMTests/Models/Qwen3_5-9BTests.swift @@ -0,0 +1,429 @@ +import Foundation +import MLXLMCommon +import MLXVLM +@testable import SHLLM +import Testing + +@Suite(.serialized) +struct Qwen3_5_9BTests { + @Test + func canStreamResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try qwen3_5__9B(input) else { return } + + var reasoning = "" + var result = "" + for try await reply in llm { + switch reply { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + result.append(text) + case .toolCall: + Issue.record() + } + } + + Swift.print("\n\(reasoning)\n") + #expect(!reasoning.isEmpty) + + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canStreamTextResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try qwen3_5__9B(input) else { return } + + var result = "" + for try await reply in llm.text { + result.append(reply) + } + + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canAwaitResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try qwen3_5__9B(input) else { return } + + let (_reasoning, _text, toolCalls) = try await llm.result + + let reasoning = try #require(_reasoning) + Swift.print("\n\(reasoning)\n") + #expect(!reasoning.isEmpty) + + let text = try #require(_text) + Swift.print(text) + #expect(!text.isEmpty) + + #expect(toolCalls == nil) + } + + @Test + func canAwaitTextResult() async throws { + let input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + + guard let llm = try qwen3_5__9B(input) else { return } + + let result = try await llm.text.result + Swift.print(result) + #expect(!result.isEmpty) + } + + @Test + func canStreamResultWithoutThinking() async throws { + var input: UserInput = .init(messages: [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": "What is the meaning of life?"], + ]) + input.additionalContext = ["enable_thinking": false] + + guard let llm = try qwen3_5__9B(input) else { return } + + let (reasoning, _text, _) = try await llm.result + #expect(reasoning == nil) + + let text = try #require(_text) + Swift.print(text) + #expect(!text.isEmpty) + } + + @Test + func canFetchTheWeather() async throws { + let input = UserInput(chat: [ + .system( + "You are a weather assistant who must use the get_current_weather tool to fetch weather data for any location the user asks about." + ), + .user("What is the weather in Paris, France?"), + ]) + + guard let llm = try qwen3_5__9B( + input, + tools: [weatherTool] + ) else { return } + + var reasoning = "" + var reply = "" + var toolCallCount = 0 + var weatherLocationFound = false + + for try await response in llm { + switch response { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + reply.append(text) + case let .toolCall(toolCall): + toolCallCount += 1 + #expect(toolCall.function.name == "get_current_weather") + + if case let .string(location) = toolCall.function.arguments["location"] { + weatherLocationFound = location.lowercased().contains("paris") + } + } + } + + #expect(!reasoning.isEmpty) + #expect(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + #expect(toolCallCount >= 1) + #expect(weatherLocationFound) + } + + @Test + func canChooseBetweenDifferentTools() async throws { + let input = UserInput(chat: [ + .system( + "You are a helpful assistant that can provide weather, stock prices, and news." + ), + .user("Get the latest news about Apple, sorted by popularity."), + ]) + + guard let llm = try qwen3_5__9B( + input, + tools: [weatherTool, stockTool, newsTool] + ) else { return } + + var reasoning = "" + var reply = "" + var toolCallCount = 0 + var newsQueryFound = false + var newsSortByFound = false + + for try await response in llm { + switch response { + case let .reasoning(text): + reasoning.append(text) + case let .text(text): + reply.append(text) + case let .toolCall(toolCall): + toolCallCount += 1 + #expect(toolCall.function.name == "get_latest_news") + + if case let .string(query) = toolCall.function.arguments["query"] { + newsQueryFound = query.lowercased().contains("apple") + } + if case let .string(sortBy) = toolCall.function.arguments["sortBy"] { + newsSortByFound = sortBy.lowercased().contains("popularity") + } + } + } + + #expect(!reasoning.isEmpty) + #expect(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + #expect(toolCallCount >= 1) + #expect(newsQueryFound) + #expect(newsSortByFound) + } + + @Test + func canUseStockToolAndRespond() async throws { + let chat: [Chat.Message] = [ + .system( + "You are a helpful assistant that can provide stock prices. When asked for a stock price, you must use the get_stock_price tool." + ), + .user("What is the price of AAPL?"), + ] + + var input = UserInput(chat: chat) + + guard let llm1 = try qwen3_5__9B( + input, + tools: [stockTool] + ) else { return } + + let (reasoning1, text1, toolCallsOpt1) = try await llm1.result + #expect(reasoning1 != nil) + #expect(text1 == nil) + let toolCall1 = try #require(toolCallsOpt1?.first) + + #expect(toolCall1.function.name == "get_stock_price") + #expect(toolCall1.function.arguments["symbol"] == .string("AAPL")) + + input.appendAssistantToolCall(toolCall1) + input.appendToolResult(["price": 123.45]) + guard let llm2 = try qwen3_5__9B( + input, + tools: [stockTool] + ) else { return } + + let (reasoning2, text2, toolCallsOpt2) = try await llm2.result + Swift.print(text2 ?? "") + #expect(reasoning2 != nil) + #expect(text2?.isEmpty == false) + #expect(text2?.contains(oneOf: ["aapl"]) == true) + #expect(text2?.contains("123.45") == true) + #expect(toolCallsOpt2 == nil) + } + + @Test + func canCompleteMultiToolWorkflowAndEmail() async throws { + let chat: [Chat.Message] = [ + .system(""" + You are a helpful assistant that must complete tasks by calling tools \ + in sequence. When asked to find information on the web and email it, \ + you must: + + 1) use web_search to find a relevant page + 2) use fetch_web_page to retrieve the page content + 3) use find_email_in_contacts to get the recipient's email + 4) use send_email to send the email with the requested information. + """ + ), + .user( + "Find the keynote date from the ACME Conference website and email it to Alex Example." + ), + ] + + var input = UserInput(chat: chat) + guard let llm = try qwen3_5__9B(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + + let (_, _, toolCallsOutput1) = try await llm.result + let toolCall1 = try #require(toolCallsOutput1?.first) + #expect(toolCall1.function.name == "web_search") + + input.appendAssistantToolCall(toolCall1) + input.appendToolResult([ + "results": [[ + "title": "ACME Conference 2025 Keynote", + "url": "https://acme.test/conf", + ]], + ]) + + guard let llm2 = try qwen3_5__9B(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (_, _, toolCallsOutput2) = try await llm2.result + let toolCall2 = try #require(toolCallsOutput2?.first) + #expect(toolCall2.function.name == "fetch_web_page") + + input.appendAssistantToolCall(toolCall2) + input.appendToolResult([ + "content": "Welcome to ACME Conf! Keynote date: November 5, 2025.", + ]) + + guard let llm3 = try qwen3_5__9B(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (_, _, toolCallsOutput3) = try await llm3.result + let toolCall3 = try #require(toolCallsOutput3?.first) + #expect(toolCall3.function.name == "find_email_in_contacts") + + input.appendAssistantToolCall(toolCall3) + input.appendToolResult([ + "email": "alex@example.com", + ]) + + guard let llm4 = try qwen3_5__9B(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + let (reasoning, text, toolCalls4) = try await llm4.result + + guard let toolCall4 = toolCalls4?.first else { + Issue.record(""" + Did not call send_email: reasoning=\(String(describing: reasoning)), \ + text=\(String(describing: text)) + """) + return + } + + #expect(toolCall4.function.name == "send_email") + let toArg = try #require(toolCall4.function.arguments["to"]) + let subjectArg = try #require(toolCall4.function.arguments["subject"]) + let bodyArg = try #require(toolCall4.function.arguments["body"]) + #expect((toArg.anyValue as? String) == "alex@example.com") + #expect((subjectArg.anyValue as? String)?.isEmpty == false) + #expect((bodyArg.anyValue as? String)?.isEmpty == false) + + input.appendAssistantToolCall(toolCall4) + input.appendToolResult(["status": "sent"]) + + guard let llm5 = try qwen3_5__9B(input, tools: [ + webSearchTool, fetchPageTool, findEmailTool, sendEmailTool, + ]) else { return } + + let response = try await llm5.text.result + Swift.print(response) + #expect(!response.isEmpty) + #expect(response.contains(oneOf: ["sent", "emailed"])) + #expect(response.lowercased().contains("alex")) + } + + @Test + @MainActor + func canExtractTextFromImageData() async throws { + let data = try authenticationFactors + guard let llm = try qwen3_5__9B(image: data) else { return } + + var response = "" + for try await token in llm.text { + response += token + } + + Swift.print(response) + let strings = [ + "authentication", + "Something you forgot", + "Something you left in the taxi", + "Something that can be chopped off", + ] + #expect(response.contains(oneOf: strings)) + } + + @Test + @MainActor + func canExtractTextFromImageURL() async throws { + let url = try authenticationFactorsURL + guard let llm = try qwen3_5__9B(image: url) else { return } + + var response = "" + for try await token in llm.text { + response += token + } + + Swift.print(response) + let expected = [ + "authentication", + "Something you forgot", + "Something you left in the taxi", + "Something that can be chopped off", + ] + #expect(response.contains(oneOf: expected)) + } +} + +private func qwen3_5__9B( + _ input: UserInput, + tools: [any ToolProtocol] = [] +) throws -> LLM? { + try loadModel( + directory: LLM.qwen3_5__9B, + input: input, + tools: tools, + responseParser: LLM.qwen3_5Parser(for: input) + ) +} + +private func qwen3_5__9B( + image: Data +) throws -> LLM? { + let input = imageInput(image) + return try loadModel( + directory: LLM.qwen3_5__9B, + input: input, + responseParser: LLM.qwen3_5Parser(for: input) + ) +} + +private func qwen3_5__9B( + image: URL +) throws -> LLM? { + let input = imageInput(image) + return try loadModel( + directory: LLM.qwen3_5__9B, + input: input, + responseParser: LLM.qwen3_5Parser(for: input) + ) +} + +private var authenticationFactorsURL: URL { + get throws { + guard let url = Bundle.module.url( + forResource: "3-authentication-factors", + withExtension: "png" + ) else { + throw NSError( + domain: NSURLErrorDomain, + code: NSURLErrorFileDoesNotExist, + userInfo: nil + ) + } + return url + } +} + +private var authenticationFactors: Data { + get throws { + try Data(contentsOf: authenticationFactorsURL) + } +} diff --git a/bin/download.sh b/bin/download.sh index 61ff2bc..9a40076 100755 --- a/bin/download.sh +++ b/bin/download.sh @@ -5,6 +5,7 @@ set -eo pipefail ids=( "CodeLlama-13b-Instruct-hf-4bit-MLX" "DeepSeek-R1-Distill-Qwen-7B-4bit" + "Devstral-Small-2-24B-Instruct-2512-4bit" "gemma-2-2b-it-4bit" "gemma-2-9b-it-4bit" "gemma-3-12b-it-qat-3bit" @@ -21,8 +22,10 @@ ids=( "lmstudio-community/gpt-oss-20b-MLX-8bit" "Meta-Llama-3-8B-Instruct-4bit" "Meta-Llama-3.1-8B-Instruct-4bit" + "Ministral-3-14B-Instruct-2512-6bit" "Mistral-7B-Instruct-v0.3-4bit" "Mistral-Nemo-Instruct-2407-4bit" + "NVIDIA-Nemotron-3-Nano-30B-A3B-4bit" "OpenELM-270M-Instruct" "Orchestrator-8B-4bit" "phi-2-hf-4bit-mlx" @@ -41,6 +44,10 @@ ids=( "Qwen3-VL-2B-Thinking-4bit" "Qwen3-VL-4B-Instruct-4bit" "Qwen3-VL-4B-Thinking-4bit" + "Qwen3.5-2B-6bit" + "Qwen3.5-27B-4bit" + "Qwen3.5-35B-A3B-4bit" + "Qwen3.5-9B-4bit" "SmolLM-135M-Instruct-4bit" )