diff --git a/.github/workflows/intelligence-docs.yml b/.github/workflows/intelligence-docs.yml index 849945f02b91..7d62a25ef82d 100644 --- a/.github/workflows/intelligence-docs.yml +++ b/.github/workflows/intelligence-docs.yml @@ -23,20 +23,28 @@ env: jobs: build_and_deploy: - runs-on: ubuntu-22.04 + runs-on: macos-14 name: Build and deploy steps: - uses: actions/checkout@v4 - name: Bootstrap uses: ./.github/actions/bootstrap - name: Install pandoc - run: sudo apt install pandoc + run: brew install pandoc - name: Install Flower dependencies (mandatory only) run: python -m poetry install --extras "simulation" + - uses: swift-actions/setup-swift@v2 + with: + swift-version: "6.0.0" + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "16.2" - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '22.14.0' + - name: Install SourceDocs + run: brew install sourcedocs - name: Install pnpm run: | npm install -g pnpm diff --git a/.github/workflows/intelligence-swift.yml b/.github/workflows/intelligence-swift.yml new file mode 100644 index 000000000000..5d85a478db85 --- /dev/null +++ b/.github/workflows/intelligence-swift.yml @@ -0,0 +1,55 @@ +name: Intelligence Swift + +on: + push: + branches: + - main + paths: + - 'intelligence/swift/**/*' + - '.github/workflows/intelligence-swift.yml' + pull_request: + branches: + - main + paths: + - 'intelligence/swift/**/*' + - '.github/workflows/intelligence-swift.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + FLWR_TELEMETRY_ENABLED: 0 + +jobs: + fi_swift_format_lint: + name: Format and Lint Check + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install SwiftFormat + run: brew install swift-format + + - name: Check formatting and lint + working-directory: intelligence/swift + run: swift-format lint --recursive . + + fi_swift_tests: + name: Tests + runs-on: macos-14 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - uses: swift-actions/setup-swift@v2 + with: + swift-version: "6.0.0" + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "16.2" + + - name: Run tests + run: xcodebuild test -scheme flower -destination 'platform=macOS' diff --git a/Package.swift b/Package.swift index f3e245583857..4694fd452f45 100644 --- a/Package.swift +++ b/Package.swift @@ -38,6 +38,12 @@ let package = Package( .product(name: "MLXLLM", package: "mlx-swift-examples"), .product(name: "Crypto", package: "swift-crypto") ], - path: "intelligence/swift/src"), + path: "intelligence/swift/src" + ), + .testTarget( + name: "FlowerIntelligenceTests", + dependencies: ["FlowerIntelligence"], + path: "intelligence/swift/tests" + ) ] ) diff --git a/intelligence/dev/build-docs.sh b/intelligence/dev/build-docs.sh index 986256bcde65..9c7f43bd1c6f 100755 --- a/intelligence/dev/build-docs.sh +++ b/intelligence/dev/build-docs.sh @@ -2,11 +2,21 @@ set -e cd "$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"/../ +# Build TS docs cd ts && \ pnpm build:docs --readme none --name "TypeScript API" && \ cd .. -cd docs +# Build Swift docs +cd .. && \ + sourcedocs generate --module-name FlowerIntelligence -- -scheme flower -destination 'platform=macOS' && \ + mkdir -p intelligence/docs/source/swift-api-ref && \ + cp -r Documentation/Reference/* intelligence/docs/source/swift-api-ref/ && \ + cd intelligence/docs && \ + mv source/swift-api-ref/README.md source/swift-api-ref/index.md && \ + sed -i.bak '1 s/^# .*/# Swift API/' source/swift-api-ref/index.md && \ + sed -i.bak '/^This file was generated by/d' source/swift-api-ref/index.md && \ + rm -f source/swift-api-ref/index.md.bak { echo '' @@ -17,6 +27,6 @@ cd docs echo '' echo '*/*' echo '```' -} | tee -a source/ts-api-ref/index.md +} | tee -a source/ts-api-ref/index.md source/swift-api-ref/index.md make html diff --git a/intelligence/swift/tests/FlowerIntelligenceTests.swift b/intelligence/swift/tests/FlowerIntelligenceTests.swift new file mode 100644 index 000000000000..f802739876ed --- /dev/null +++ b/intelligence/swift/tests/FlowerIntelligenceTests.swift @@ -0,0 +1,137 @@ +import Testing +@testable import FlowerIntelligence + +class MockMlxEngine: Engine { + var lastMessages: [Message]? + var lastModel: String? + var lastTemperature: Float? + var lastMaxCompletionTokens: Int? + var lastStream: Bool? + var lastTools: [Tool]? + + func chat( + _ messages: [Message], + model: String?, + temperature: Float?, + maxCompletionTokens: Int?, + stream: Bool, + onStreamEvent: ((StreamEvent) -> Void)?, + tools: [Tool]? + ) async throws -> Message { + lastMessages = messages + return Message(role: "assistant", content: "Mock Local Engine Response") + } +} + +class MockRemoteEngine: RemoteEngineProtocol { + var apiKey: String = "" + var lastMessages: [Message]? + var lastModel: String? + var lastTemperature: Float? + var lastMaxCompletionTokens: Int? + var lastStream: Bool? + var lastTools: [Tool]? + + func chat( + _ messages: [Message], + model: String?, + temperature: Float?, + maxCompletionTokens: Int?, + stream: Bool, + onStreamEvent: ((StreamEvent) -> Void)?, + tools: [Tool]? + ) async throws -> Message { + lastMessages = messages + return Message(role: "assistant", content: "Mock Remote Engine Response") + } +} + +class FlowerIntelligenceTests { + var flowerIntelligence: FlowerIntelligence + var mockMlxEngine: MockMlxEngine + var mockRemoteEngine: MockRemoteEngine + + init() { + mockMlxEngine = MockMlxEngine() + mockRemoteEngine = MockRemoteEngine() + flowerIntelligence = FlowerIntelligence(mlxEngine: mockMlxEngine, remoteEngine: mockRemoteEngine) + } + + @Test + func testUsesCorrectMlxEngine() async throws { + flowerIntelligence.remoteHandoff = false + _ = await flowerIntelligence.chat("Hello") + try #require(mockMlxEngine.lastMessages != nil) + try #require(mockRemoteEngine.lastMessages == nil) + } + + @Test + func testUsesCorrectRemoteEngine() async throws { + flowerIntelligence.remoteHandoff = true + _ = await flowerIntelligence.chat("Hello") + try #require(mockRemoteEngine.lastMessages != nil) + try #require(mockMlxEngine.lastMessages == nil) + } + + @Test + func testForceRemoteOverridesRemoteHandoff() async throws { + flowerIntelligence.remoteHandoff = false + _ = await flowerIntelligence.chat("Hello", maybeOptions: ChatOptions(forceRemote: true)) + try #require(mockRemoteEngine.lastMessages != nil) + try #require(mockMlxEngine.lastMessages == nil) + } + + @Test + func testForceLocalOverridesRemoteHandoff() async throws { + flowerIntelligence.remoteHandoff = true + _ = await flowerIntelligence.chat("Hello", maybeOptions: ChatOptions(forceLocal: true)) + try #require(mockMlxEngine.lastMessages != nil) + try #require(mockRemoteEngine.lastMessages == nil) + } + + @Test + func testForceRemoteAndForceLocalReturnFailure() async throws { + let result = await flowerIntelligence.chat("Hello", maybeOptions: ChatOptions(forceRemote: true, forceLocal: true)) + switch result { + case .failure(let error): + try #require(error.message == "Cannot set both forceRemote and forceLocal to true") + default: + Issue.record("Expected failure but got success") + } + } + + @Test + func testChatFunctionWithSingleMessage() async throws { + let result = await flowerIntelligence.chat("Test message") + switch result { + case .success(let response): + let messages = try #require(mockMlxEngine.lastMessages) + try #require(messages.first?.content == "Test message") + try #require(response.role == "assistant") + try #require(response.content == "Mock Local Engine Response") + default: + Issue.record("Expected success but got failure") + } + } + + @Test + func testChatFunctionWithArrayOfMessages() async throws { + let messages = [ + Message(role: "system", content: "You are an AI"), + Message(role: "user", content: "What is Swift?") + ] + let options = ChatOptions(model: "meta/llama3.2-1b") + let result = await flowerIntelligence.chat(options: (messages, options)) + + switch result { + case .success(let response): + let messages = try #require(mockMlxEngine.lastMessages) + try #require(messages.count == 2) + try #require(messages.last?.content == "What is Swift?") + try #require(response.role == "assistant") + try #require(response.content == "Mock Local Engine Response") + default: + Issue.record("Expected success but got failure") + } + } +}