Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,13 @@ jobs:
name_suffix: -gemma-e2b
models: mlx-community/gemma-4-e2b-it-4bit
test_model: mlx-community/gemma-4-e2b-it-4bit
# LFM2.5 used to be absent here and was fetched by the server mid-test, which
# is how it silently picked up a republished revision whose chat template was
# one brace short of valid and took main red. Prefetching it at a pinned
# revision is what makes this job depend on our code rather than on what
# upstream published that afternoon.
- modality: vision
models: mlx-community/Qwen2-VL-2B-Instruct-4bit
models: mlx-community/Qwen2-VL-2B-Instruct-4bit LiquidAI/LFM2.5-VL-450M-MLX-4bit@10ce3604e42cd595497c47aaf67b7890e1e2a3b4
- modality: audio
models: mlx-community/gemma-4-e4b-it-4bit
- modality: graph
Expand Down
137 changes: 108 additions & 29 deletions .github/workflows/update_dependencies.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
name: Dependency Automation

# Prepares a submodule bump when mlx-swift or mlx-swift-lm cuts a release, and stops
# at a pushed branch rather than opening a pull request.
#
# Opening the PR from a workflow needs a personal access token, because GitHub does
# not start workflow runs for events raised by GITHUB_TOKEN. A bot-opened PR would
# therefore arrive with no checks at all — permanently pending, never green — and this
# repository gates releases on CI concluding successfully (see release.yml). A branch
# is the honest stopping point: opening the PR yourself takes one click, and CI then
# runs normally because the event is yours.
#
# That a human sees the bump before it merges is a feature. Bumps here have needed a
# pointer check, an umbrella build and a smoke test to be trustworthy; the automation
# does the mechanical part and leaves the judgement.

on:
repository_dispatch:
types: [dependency_bump]

permissions:
contents: write
pull-requests: write

jobs:
bump-dependencies:
Expand All @@ -18,34 +31,100 @@ jobs:
submodules: recursive
fetch-depth: 0

- name: Update swift-mlx dependencies
if: ${{ github.event.client_payload.source_repo == 'mlx-swift' }}
# client_payload is attacker-controlled in principle — anything able to dispatch
# to this repository chooses these strings — and they end up in shell and in a
# ref name. Validate them here and pass them onward through the environment
# rather than interpolating ${{ }} into a run block, where a crafted tag would
# be executed rather than compared.
- name: Validate dispatch payload
env:
PAYLOAD_SOURCE_REPO: ${{ github.event.client_payload.source_repo }}
PAYLOAD_NEW_TAG: ${{ github.event.client_payload.new_tag }}
run: |
echo "Bumping mlx-swift dependency to ${{ github.event.client_payload.new_tag }}"
# In Package.swift we depend on branch main, but if we wanted to depend on a tag:
# SwiftPM resolves "main" to the latest commit automatically, but updating the SPM resolved file ensures deterministic builds:
swift package update mlx-swift
set -euo pipefail
case "$PAYLOAD_SOURCE_REPO" in
mlx-swift|mlx-swift-lm) ;;
*)
echo "::error::unexpected source_repo '$PAYLOAD_SOURCE_REPO' — expected mlx-swift or mlx-swift-lm"
exit 1
;;
esac
if ! printf '%s' "$PAYLOAD_NEW_TAG" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$'; then
echo "::error::refusing tag '$PAYLOAD_NEW_TAG' — not a plain tag name"
exit 1
fi
{
echo "SOURCE_REPO=$PAYLOAD_SOURCE_REPO"
echo "NEW_TAG=$PAYLOAD_NEW_TAG"
} >> "$GITHUB_ENV"

- name: Update swift-mlx-lm dependencies
if: ${{ github.event.client_payload.source_repo == 'mlx-swift-lm' }}
# Both dependencies are `.package(path: "./…")` in Package.swift, backed by git
# submodules, so bumping either one is a pointer move. `swift package update`
# does nothing for a path dependency — SwiftPM takes whatever is on disk — which
# is why the mlx-swift branch of the previous version of this workflow could only
# ever have produced an empty commit.
- name: Move submodule to the released tag
run: |
echo "Bumping local mlx-swift-lm submodule to ${{ github.event.client_payload.new_tag }}"
git submodule update --remote mlx-swift-lm
# Force the submodule onto the specific new release tag
cd mlx-swift-lm
git checkout ${{ github.event.client_payload.new_tag }}
cd ..
git add mlx-swift-lm

- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.SWIFTLM_PR_TOKEN }}
commit-message: "chore(deps): bump ${{ github.event.client_payload.source_repo }} to ${{ github.event.client_payload.new_tag }}"
title: "Update ${{ github.event.client_payload.source_repo }} Dependency to ${{ github.event.client_payload.new_tag }}"
body: |
Automated dependency update triggered by release `${{ github.event.client_payload.new_tag }}` in `SharpAI/${{ github.event.client_payload.source_repo }}`.

This PR ensures SwiftLM is tracking the latest validated architectural improvements.
branch: "auto-update/${{ github.event.client_payload.source_repo }}-${{ github.event.client_payload.new_tag }}"
base: main
set -euo pipefail
git -C "$SOURCE_REPO" fetch --tags --force origin
if ! git -C "$SOURCE_REPO" rev-parse -q --verify "refs/tags/${NEW_TAG}^{commit}" >/dev/null; then
echo "::error::tag $NEW_TAG does not exist in $SOURCE_REPO"
exit 1
fi
before=$(git rev-parse "HEAD:$SOURCE_REPO")
git -C "$SOURCE_REPO" checkout --detach "refs/tags/$NEW_TAG"
after=$(git -C "$SOURCE_REPO" rev-parse HEAD)
{
echo "BEFORE_SHA=$before"
echo "AFTER_SHA=$after"
} >> "$GITHUB_ENV"
if [ "$before" = "$after" ]; then
echo "ALREADY_CURRENT=1" >> "$GITHUB_ENV"
fi

- name: Report an already-current submodule and stop
if: env.ALREADY_CURRENT == '1'
run: |
{
echo "### Nothing to bump"
echo
echo "\`$SOURCE_REPO\` is already at \`$NEW_TAG\` (\`${AFTER_SHA:0:7}\`)."
} >> "$GITHUB_STEP_SUMMARY"

- name: Push the bump branch
if: env.ALREADY_CURRENT != '1'
run: |
set -euo pipefail
branch="auto-update/${SOURCE_REPO}-${NEW_TAG}"
git checkout -B "$branch"
git add "$SOURCE_REPO"
git \
-c user.name='github-actions[bot]' \
-c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
commit -m "chore(deps): bump $SOURCE_REPO to $NEW_TAG

Moves the $SOURCE_REPO submodule from ${BEFORE_SHA:0:7} to ${AFTER_SHA:0:7},
the commit tagged $NEW_TAG.

Prepared automatically; opened by hand so that CI runs against it."
# The auto-update/* namespace belongs to this workflow, so replacing a branch
# left by an earlier run for the same tag is safe and keeps re-runs idempotent.
git push --force origin "$branch"
echo "BUMP_BRANCH=$branch" >> "$GITHUB_ENV"

- name: Summarise, with a link that opens the pull request
if: env.ALREADY_CURRENT != '1'
run: |
{
echo "### \`$SOURCE_REPO\` → \`$NEW_TAG\` is ready"
echo
echo "Branch \`$BUMP_BRANCH\` pushed, moving the submodule from"
echo "\`${BEFORE_SHA:0:7}\` to \`${AFTER_SHA:0:7}\`."
echo
echo "**[Open the pull request](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/main...${BUMP_BRANCH}?expand=1)**"
echo
echo "No PR is opened here on purpose: GitHub does not start workflow runs"
echo "for events raised by \`GITHUB_TOKEN\`, so a bot-opened PR would never"
echo "get CI. Opening it yourself gets the checks this repository gates"
echo "releases on."
} >> "$GITHUB_STEP_SUMMARY"
85 changes: 77 additions & 8 deletions Sources/SwiftLM/Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,50 @@ private struct HubDownloader: Downloader, Sendable {
}

private struct TransformersTokenizerLoader: TokenizerLoader, Sendable {
/// Carried purely so template diagnostics can name the offending checkpoint; the
/// on-disk directory is a snapshot hash and means nothing to the person reading it.
let modelId: String
init(modelId: String = "this model") { self.modelId = modelId }

func load(from directory: URL) async throws -> any MLXLMCommon.Tokenizer {
let t = try await AutoTokenizer.from(modelFolder: directory)
return TransformersTokenizerBridge(t)
return TransformersTokenizerBridge(t, modelId: modelId)
}
}

/// A chat template that the Jinja parser rejected.
///
/// Worth its own type because the underlying error says only what the parser choked on
/// — `parser('Unexpected token type: closeExpression')` — with no hint that the subject
/// is a file shipped inside someone else's checkpoint. A model published with a
/// malformed template loads fine, starts fine, and then fails every request, which
/// reads as "the server is broken" rather than "this checkpoint is broken". That
/// happened for real: LiquidAI republished LFM2.5-VL-450M-MLX-4bit with
/// `{- bos_token -}}` (one brace short) and identifying it took a bisect across two
/// model revisions.
struct MalformedChatTemplate: Error, CustomStringConvertible {
let modelId: String
let underlying: String

var description: String {
"""
The chat template shipped with \(modelId) is not valid Jinja and could not be \
parsed: \(underlying). This is a defect in the model's chat_template.jinja (or \
the chat_template field of its tokenizer_config.json), not in the request — \
report it to whoever publishes the checkpoint. Pinning to an earlier revision \
of the model is the usual workaround.
"""
}
}

private struct TransformersTokenizerBridge: MLXLMCommon.Tokenizer, Sendable {
let upstream: any Tokenizers.Tokenizer
init(_ upstream: any Tokenizers.Tokenizer) { self.upstream = upstream }
/// Only used to name the model in template diagnostics.
let modelId: String
init(_ upstream: any Tokenizers.Tokenizer, modelId: String = "this model") {
self.upstream = upstream
self.modelId = modelId
}
func encode(text: String, addSpecialTokens: Bool) -> [Int] {
upstream.encode(text: text, addSpecialTokens: addSpecialTokens)
}
Expand All @@ -73,6 +108,12 @@ private struct TransformersTokenizerBridge: MLXLMCommon.Tokenizer, Sendable {
messages: messages, tools: tools, additionalContext: additionalContext)
} catch Tokenizers.TokenizerError.missingChatTemplate {
throw MLXLMCommon.TokenizerError.missingChatTemplate
} catch {
// A missing template is a legitimate state (base models have none) and keeps
// its own error above. Anything else here means the template exists but the
// parser would not take it, which is worth naming precisely.
throw MalformedChatTemplate(
modelId: modelId, underlying: String(describing: error))
}
}
}
Expand Down Expand Up @@ -598,7 +639,7 @@ struct MLXServer: AsyncParsableCommand {
print("[SwiftLM] Loading Omni-Language Model (Text + Vision + Audio)...")
container = try await OmniModelFactory.shared.loadContainer(
from: downloader,
using: TransformersTokenizerLoader(),
using: TransformersTokenizerLoader(modelId: resolvedModelId),
configuration: modelConfig
) { progress in
tracker.printProgress(progress)
Expand All @@ -607,7 +648,7 @@ struct MLXServer: AsyncParsableCommand {
print("[SwiftLM] Loading VLM (vision-language model)...")
container = try await VLMModelFactory.shared.loadContainer(
from: downloader,
using: TransformersTokenizerLoader(),
using: TransformersTokenizerLoader(modelId: resolvedModelId),
configuration: modelConfig
) { progress in
tracker.printProgress(progress)
Expand All @@ -618,7 +659,7 @@ struct MLXServer: AsyncParsableCommand {
// and the native prepareForMultimodal path extracts real mel features.
container = try await OmniModelFactory.shared.loadContainer(
from: downloader,
using: TransformersTokenizerLoader(),
using: TransformersTokenizerLoader(modelId: resolvedModelId),
configuration: modelConfig
) { progress in
tracker.printProgress(progress)
Expand All @@ -627,7 +668,7 @@ struct MLXServer: AsyncParsableCommand {
print("[SwiftLM] Loading LLM (large language model)...")
container = try await LLMModelFactory.shared.loadContainer(
from: downloader,
using: TransformersTokenizerLoader(),
using: TransformersTokenizerLoader(modelId: resolvedModelId),
configuration: modelConfig
) { progress in
tracker.printProgress(progress)
Expand Down Expand Up @@ -674,7 +715,7 @@ struct MLXServer: AsyncParsableCommand {
let draftDownloader = HubDownloader(hub: HubApi(downloadBase: cacheRoot))
let draftContainer = try await LLMModelFactory.shared.loadContainer(
from: draftDownloader,
using: TransformersTokenizerLoader(),
using: TransformersTokenizerLoader(modelId: resolvedModelId),
configuration: draftConfig
) { progress in
// Silent loading for draft model
Expand Down Expand Up @@ -709,7 +750,7 @@ struct MLXServer: AsyncParsableCommand {
let assistantDownloader = HubDownloader(hub: HubApi(downloadBase: cacheRoot))
let assistantContainer = try await LLMModelFactory.shared.loadContainer(
from: assistantDownloader,
using: TransformersTokenizerLoader(),
using: TransformersTokenizerLoader(modelId: resolvedModelId),
configuration: assistantConfig
) { _ in }
mtpAssistantModelRef = await assistantContainer.perform { assistantContext in
Expand Down Expand Up @@ -837,6 +878,34 @@ struct MLXServer: AsyncParsableCommand {
print("[SwiftLM] 🧠 Auto-calibration (Wisdom) bypassed for SSD Streaming")
}

// Render the chat template once before opening the port. A checkpoint whose
// template does not parse otherwise loads cleanly, reports ready, and then fails
// every single request — a shape that reads as a broken server rather than a
// broken model, and that survives restarts without ever explaining itself.
// Failing here instead costs one template render and makes the cause the first
// thing anybody sees.
//
// A model with no chat template at all is a different and legitimate case (base
// models ship without one, and /v1/completions does not need it), so that is
// passed over rather than treated as a defect.
do {
let probeTokenizer = await container.tokenizer
// Shaped like the simplest real request rather than a bare minimum: one user
// turn with a generation prompt is what every chat template is written to
// handle, so a failure here is the template's, not the probe's.
_ = try probeTokenizer.applyChatTemplate(
messages: [["role": "user", "content": "ping"]],
tools: nil,
additionalContext: ["add_generation_prompt": true]
)
} catch let error as MalformedChatTemplate {
print("[SwiftLM] ❌ \(error.description)")
throw error
} catch {
// Missing template, or a template that needs context this probe does not
// supply. Neither is a reason to refuse to start.
}

print("[SwiftLM] Model loaded. Starting HTTP server on \(host):\(port)")

// ── Capture CLI defaults into a shared config ──
Expand Down
20 changes: 17 additions & 3 deletions scripts/ci-download-models.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,26 @@ has_partial_files() {
}

download_one() {
local repo="$1"
local spec="$1"
# `repo@revision` pins to an immutable commit. Upstream repositories are mutable:
# LiquidAI republished LFM2.5-VL-450M-MLX-4bit with a malformed chat template
# (`{- bos_token -}}`, one brace short), which turned every request into an HTTP 500
# and took main red with no change on our side. A floating tag means CI results
# depend on what a third party did that afternoon.
local repo="${spec%@*}"
local revision=""
if [ "$spec" != "$repo" ]; then
revision="${spec##*@}"
fi
local dir="$HUB_DIR/models--${repo//\//--}"

for attempt in $(seq 1 "$ATTEMPTS"); do
echo "--- $repo (attempt $attempt/$ATTEMPTS, per-request timeout ${HF_HUB_DOWNLOAD_TIMEOUT}s)"
if hf download "$repo"; then
echo "--- $repo${revision:+ @ $revision} (attempt $attempt/$ATTEMPTS, per-request timeout ${HF_HUB_DOWNLOAD_TIMEOUT}s)"
# Spelled out rather than assembling an args array: the runners are macOS, which
# ships bash 3.2, where expanding an empty array under `set -u` is an unbound
# variable error rather than nothing at all. That fails every unpinned download.
if { [ -n "$revision" ] && hf download "$repo" --revision "$revision"; } \
|| { [ -z "$revision" ] && hf download "$repo"; }; then
if has_partial_files "$dir"; then
echo "::warning::$repo downloaded but .incomplete files remain; retrying"
else
Expand Down
Loading
Loading