Run Claude Code entirely offline using Gemma 4 26B and oMLX on Apple Silicon Macs.
- Claude Code running against a local Gemma 4 model — no API keys, no cloud, no data leaving your machine
- Native Anthropic API support — oMLX speaks Claude's protocol directly, no translation proxy needed
- Tiered KV caching (RAM + SSD) for fast prompt processing across long sessions
- Thinking/reasoning mode with proper streaming to Claude Code's UI
- Tool calling (file edits, bash, grep, etc.) working out of the box
- Auto-start on boot via macOS LaunchAgent
Claude Code CLI
│
│ Anthropic /v1/messages API (port 8082)
▼
Sampling Proxy (~80 lines Python, recommended)
│ - Temperature: 0.5 tools / 1.5 normal
│ - Repetition penalty: 1.0 (disabled)
│ - System prompt injection for local model tool guidance
│ - Request/response logging
│
│ Anthropic /v1/messages API (port 8080)
▼
oMLX Server (native Anthropic + OpenAI API)
│
▼
Gemma 4 26B-A4B MoE (8-bit, MLX on Apple Silicon GPU)
Why oMLX? It speaks the Anthropic API natively. Earlier approaches required an 800+ line proxy to translate between Anthropic and OpenAI API formats, handle tool call parsing edge cases, strip thinking token leaks, and manage streaming state. oMLX handles all of this internally with dedicated Gemma 4 support — tool call parsing, thinking token routing, context scaling, and tiered KV caching.
Why the sampling proxy? Local models like Gemma 4 need different sampling parameters than cloud Claude, and they benefit from system prompt injection that reminds them about tool usage patterns (read before edit, use Edit not Write, etc.). The proxy handles all of this transparently — no oMLX configuration changes needed.
Why Gemma 4 26B-A4B (MoE)?
- Only 3.8B active parameters per token despite 26B total — fast inference
- 8-bit quantization fits in 64 GB unified memory (~27 GB model)
- Strong tool-calling with native
call:name{args}format - Thinking mode via chat template with separated reasoning/content tokens
- ~25-35 tok/s generation on Apple Silicon (varies by chip)
Important: Use 8-bit quantization. The 4-bit variant has known bugs with MLX's
scaled_dot_product_attentionthat cause repetition loops under batch inference (ml-explore/mlx#3384). Stick with 8-bit unless you're very memory constrained.
| Component | 64 GB (M1/M2/M3/M4) | 128 GB (M4 Max/Ultra) |
|---|---|---|
| Model (8-bit) | ~27 GB | ~27 GB |
| Hot KV cache | 8 GB | 24 GB |
| SSD page cache | 20+ GB disk | 50+ GB disk |
| Context window | 128k tokens | 256k tokens |
| Max concurrent agents | 2 | 8 |
| Headroom for OS/apps | ~25 GB | ~70 GB |
64 GB note: The 8-bit model fits but leaves less room for caching. You'll see ~20-30 tok/s on M1 Max, faster on newer chips. If memory is very tight, use a 4-bit variant (~14 GB) — see Model Variants.
- macOS 14.0+ (Sonoma or later)
- Homebrew package manager
- Node.js 18+ (for Claude Code CLI)
- ~15 GB free disk for the model download
- ~20 GB free disk for SSD KV cache
npm install -g @anthropic-ai/claude-codeOption A: Homebrew (recommended — macOS Tahoe / 26.0+)
brew install jundot/omlx/omlxThat's it. Homebrew handles Python 3.11, the virtual environment, and all dependencies.
Note: The brew formula compiles the
tokenizersRust crate from source, which requires Xcode CLI Tools 26.3+. This ships with macOS Tahoe. On older macOS versions (Sequoia and earlier), the build will fail with a linker error. Use Option B instead.
Option B: pip (macOS Sonoma / Sequoia / pre-Tahoe)
If you're on an older macOS or the brew install fails, install from source into a virtual environment:
# Create a dedicated venv (Python 3.11 recommended)
python3.11 -m venv ~/.omlx-venv
source ~/.omlx-venv/bin/activate
# Install oMLX with MCP support
pip install "omlx[mcp] @ git+https://github.com/jundot/omlx.git"
deactivateTroubleshooting: If
pip installfails building thetokenizerswheel, install it separately first withpip install tokenizers(uses a pre-built binary wheel), then retry the oMLX install.
# Download Gemma 4 26B 8-bit (~14 GB download, ~27 GB on disk)
omlx pull mlx-community/gemma-4-26b-a4b-it-8bit
# Create the model directory and symlink
mkdir -p ~/.omlx/models ~/.omlx/cache
ln -s $(omlx model-path mlx-community/gemma-4-26b-a4b-it-8bit) \
~/.omlx/models/gemma-4-26b-8bitIf
omlx pullisn't available (or you used the pip install), download manually:pip install huggingface-hub # if not already installed huggingface-cli download mlx-community/gemma-4-26b-a4b-it-8bit ln -s $(python3 -c "from huggingface_hub import snapshot_download; print(snapshot_download('mlx-community/gemma-4-26b-a4b-it-8bit', local_files_only=True))") \ ~/.omlx/models/gemma-4-26b-8bit
Verify the model is linked:
ls ~/.omlx/models/gemma-4-26b-8bit/
# Should list model files: config.json, model*.safetensors, tokenizer.json, etc.Create ~/.omlx/settings.json:
{
"version": "1.0",
"server": {
"host": "127.0.0.1",
"port": 8080,
"log_level": "info",
"cors_origins": ["*"]
},
"model": {
"model_dirs": ["~/.omlx/models"],
"model_dir": "~/.omlx/models",
"max_model_memory": "auto",
"model_fallback": false
},
"memory": {
"max_process_memory": "auto",
"prefill_memory_guard": true
},
"scheduler": {
"max_concurrent_requests": 2
},
"cache": {
"enabled": true,
"ssd_cache_dir": "~/.omlx/cache",
"ssd_cache_max_size": "auto",
"hot_cache_max_size": "8GB",
"initial_cache_blocks": 128
},
"sampling": {
"max_context_window": 131072,
"max_tokens": 8192,
"temperature": 1.0,
"top_p": 0.95,
"top_k": 64,
"repetition_penalty": 1.0
},
"claude_code": {
"context_scaling_enabled": true,
"target_context_size": 120000,
"mode": "local",
"opus_model": "gemma-4-26b-8bit",
"sonnet_model": "gemma-4-26b-8bit",
"haiku_model": "gemma-4-26b-8bit"
},
"auth": {
"api_key": null,
"skip_api_key_verification": false
},
"ui": {
"language": "en"
}
}Note: If oMLX doesn't expand
~, replace~/.omlxwith the full path/Users/YOUR_USERNAME/.omlxthroughout.
If you have 128 GB unified memory, change these values for better performance:
| Setting | 64 GB | 128 GB |
|---|---|---|
scheduler.max_concurrent_requests |
2 | 8 |
cache.hot_cache_max_size |
"8GB" |
"24GB" |
cache.initial_cache_blocks |
128 | 256 |
sampling.max_context_window |
131072 | 262144 |
claude_code.target_context_size |
120000 | 200000 |
If you installed via Homebrew (Option A):
# Start oMLX now and auto-start on login
brew services start omlxOther service commands:
brew services stop omlx # Stop the server
brew services restart omlx # Restart (picks up settings.json changes)
brew services info omlx # Check statusIf you installed via pip (Option B):
Create a LaunchAgent so oMLX starts automatically on login. Create ~/Library/LaunchAgents/com.omlx.server.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.omlx.server</string>
<key>ProgramArguments</key>
<array>
<string>/Users/YOUR_USERNAME/.omlx-venv/bin/omlx</string>
<string>serve</string>
<string>--model-dir</string>
<string>/Users/YOUR_USERNAME/.omlx/models</string>
<string>--port</string>
<string>8080</string>
<string>--host</string>
<string>127.0.0.1</string>
<string>--paged-ssd-cache-dir</string>
<string>/Users/YOUR_USERNAME/.omlx/cache</string>
<string>--hot-cache-max-size</string>
<string>8GB</string>
<string>--log-level</string>
<string>info</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/omlx-server.log</string>
<key>StandardErrorPath</key>
<string>/tmp/omlx-server.log</string>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>Replace
YOUR_USERNAMEwith your macOS username. For 128 GB machines, change8GBto24GB.
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.omlx.server.plistVerify it's running (either method):
curl -s http://127.0.0.1:8080/v1/models
# Expected: {"object":"list","data":[{"id":"gemma-4-26b-8bit","object":"model",...}]}First launch: The model loads into memory on the first request (~30-60 seconds). Subsequent requests benefit from caching.
Add to your ~/.zshrc or ~/.bashrc:
# Run Claude Code against a local Gemma 4 model via oMLX
# Usage: claude-local [any claude args...]
claude-local() {
local model="gemma-4-26b-8bit"
# Check that oMLX is running
if ! curl -sf http://127.0.0.1:8080/v1/models > /dev/null 2>&1; then
echo "oMLX not running."
echo " Check: brew services info omlx"
echo " Logs: tail -20 /tmp/omlx-server.log"
echo " Start: brew services start omlx"
return 1
fi
# Clear any existing Anthropic credentials so they don't interfere
unset ANTHROPIC_AUTH_TOKEN
unset ANTHROPIC_API_KEY
# Start sampling proxy if not running (adjusts temp, injects system prompt)
if ! curl -sf http://127.0.0.1:8082/v1/models > /dev/null 2>&1; then
echo "Starting sampling proxy (8082 → 8080)..."
nohup python3 ~/.config/omlx-temp-proxy/proxy.py --port 8082 --backend-port 8080 \
> /tmp/omlx-temp-proxy.log 2>&1 &
sleep 1
fi
# Point Claude Code at sampling proxy → oMLX
ANTHROPIC_BASE_URL="http://127.0.0.1:8082" \
ANTHROPIC_AUTH_TOKEN="none" \
ANTHROPIC_MODEL="$model" \
ANTHROPIC_CUSTOM_MODEL_OPTION="$model" \
ANTHROPIC_CUSTOM_MODEL_OPTION_NAME="Gemma 4 26B (oMLX Local)" \
ANTHROPIC_DEFAULT_OPUS_MODEL="$model" \
ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES="thinking,interleaved_thinking" \
ANTHROPIC_DEFAULT_SONNET_MODEL="$model" \
ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES="thinking,interleaved_thinking" \
ANTHROPIC_DEFAULT_HAIKU_MODEL="$model" \
ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES="thinking,interleaved_thinking" \
CLAUDE_CODE_SUBAGENT_MODEL="$model" \
CLAUDE_CODE_AUTO_COMPACT_WINDOW="131072" \
CLAUDE_AUTOCOMPACT_PCT_OVERRIDE="75" \
CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY="2" \
CLAUDE_CODE_MAX_OUTPUT_TOKENS="8192" \
CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS="8000" \
CLAUDE_CODE_MAX_RETRIES="3" \
API_TIMEOUT_MS="30000000" \
BASH_DEFAULT_TIMEOUT_MS="2400000" \
BASH_MAX_TIMEOUT_MS="2500000" \
TASK_MAX_OUTPUT_LENGTH="32768" \
CLAUDE_CODE_DISABLE_1M_CONTEXT="1" \
CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING="1" \
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1" \
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS="1" \
CLAUDE_CODE_ATTRIBUTION_HEADER="0" \
DISABLE_PROMPT_CACHING="1" \
DISABLE_COST_WARNINGS="1" \
DISABLE_TELEMETRY="1" \
DISABLE_ERROR_REPORTING="1" \
DISABLE_EXTRA_USAGE_COMMAND="1" \
DISABLE_LOGIN_COMMAND="1" \
DISABLE_LOGOUT_COMMAND="1" \
CLAUDE_ENABLE_STREAM_WATCHDOG="1" \
CLAUDE_STREAM_IDLE_TIMEOUT_MS="600000" \
MAX_STRUCTURED_OUTPUT_RETRIES="10" \
claude "$@"
}For 128 GB machines, change these values in the function above:
CLAUDE_CODE_AUTO_COMPACT_WINDOW="262144"
CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY="8"source ~/.zshrc
claude-localYou should see Claude Code start with "Gemma 4 26B (oMLX Local)" as the model name.
# Test a direct API request
curl -s http://127.0.0.1:8080/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: none" \
-d '{
"model": "gemma-4-26b-8bit",
"max_tokens": 50,
"stream": false,
"messages": [{"role": "user", "content": "Hello! Say hi in one sentence."}]
}' | python3 -m json.toolYou should see a JSON response with the model's reply in content[0].text.
These are all the env vars set in claude-local() and what each does:
| Variable | Value | Purpose |
|---|---|---|
ANTHROPIC_BASE_URL |
http://127.0.0.1:8080 |
Redirects all API calls to local oMLX |
ANTHROPIC_AUTH_TOKEN |
none |
Satisfies auth check without real credentials |
ANTHROPIC_MODEL |
Model ID | Primary model for Claude Code |
ANTHROPIC_CUSTOM_MODEL_OPTION |
Model ID | Model identifier for Claude Code UI |
ANTHROPIC_CUSTOM_MODEL_OPTION_NAME |
Display name | Human-readable name shown in Claude Code |
ANTHROPIC_DEFAULT_*_MODEL |
Model ID | Routes all model tiers (opus/sonnet/haiku) to local |
CLAUDE_CODE_SUBAGENT_MODEL |
Model ID | Ensures subagents also use local model |
| Variable | Value | Purpose |
|---|---|---|
ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES |
thinking,interleaved_thinking |
Tells Claude Code opus tier supports thinking |
ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES |
thinking,interleaved_thinking |
Same for sonnet tier |
ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES |
thinking,interleaved_thinking |
Same for haiku tier |
These make Claude Code send thinking: { type: 'enabled', budget_tokens: N } in requests. oMLX handles the thinking protocol natively with Gemma 4's chat template. Thinking output appears in Claude Code's UI (toggle with Ctrl+O).
| Variable | Value | Purpose |
|---|---|---|
CLAUDE_CODE_AUTO_COMPACT_WINDOW |
131072 / 262144 |
Context window size for auto-compaction trigger |
CLAUDE_AUTOCOMPACT_PCT_OVERRIDE |
75 |
Compact at 75% context usage |
CLAUDE_CODE_MAX_OUTPUT_TOKENS |
8192 |
Max tokens per response |
CLAUDE_CODE_FILE_READ_MAX_OUTPUT_TOKENS |
8000 |
Max tokens for file reads |
TASK_MAX_OUTPUT_LENGTH |
32768 |
Max output for task/subagent results |
MAX_STRUCTURED_OUTPUT_RETRIES |
10 |
Retries for structured output parsing failures |
| Variable | Value | Purpose |
|---|---|---|
API_TIMEOUT_MS |
30000000 (~8 hrs) |
Prevents API timeout on long local generations |
BASH_DEFAULT_TIMEOUT_MS |
2400000 (40 min) |
Default timeout for shell commands |
BASH_MAX_TIMEOUT_MS |
2500000 (42 min) |
Max timeout for shell commands |
CLAUDE_STREAM_IDLE_TIMEOUT_MS |
600000 (10 min) |
Stream watchdog timeout — Gemma's thinking phase can be long and silent |
CLAUDE_ENABLE_STREAM_WATCHDOG |
1 |
Enables stream watchdog (kills dead connections) |
| Variable | Value | Purpose |
|---|---|---|
CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY |
2 / 8 |
Max parallel tool calls / agent requests |
These features require a connection to Anthropic's servers and don't work with local inference:
| Variable | Value | Purpose |
|---|---|---|
CLAUDE_CODE_DISABLE_1M_CONTEXT |
1 |
Disables 1M context (Anthropic-only) |
CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING |
1 |
Disables adaptive thinking (Anthropic-only) |
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC |
1 |
No background pings to Anthropic |
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS |
1 |
Disables beta features requiring Anthropic |
CLAUDE_CODE_ATTRIBUTION_HEADER |
0 |
Disables attribution header |
DISABLE_PROMPT_CACHING |
1 |
Disables Anthropic's server-side prompt caching (oMLX has its own) |
DISABLE_COST_WARNINGS |
1 |
No cost warnings (it's free locally) |
DISABLE_TELEMETRY |
1 |
No telemetry sent anywhere |
DISABLE_ERROR_REPORTING |
1 |
No error reporting to Anthropic |
DISABLE_LOGIN_COMMAND |
1 |
Hides login command (no auth needed) |
DISABLE_LOGOUT_COMMAND |
1 |
Hides logout command |
| Parameter | Value | Why |
|---|---|---|
temperature |
1.0 |
Google's official recommendation. The proxy overrides to 1.5 normal / 0.5 tools. |
top_p |
0.95 |
Nucleus sampling per Gemma 4 model card |
top_k |
64 |
Top-k sampling per Gemma 4 model card |
repetition_penalty |
1.0 |
Disabled. Token-level penalty is ineffective against paragraph-level loops (google-deepmind/gemma#622). The proxy enforces 1.0. |
max_tokens |
8192 |
Max generation length per request. Caps worst-case loops. |
max_context_window |
131072 / 262144 |
Model's context capacity. Gemma 4 supports up to 256k. |
| Parameter | Value | Why |
|---|---|---|
hot_cache_max_size |
8GB / 24GB |
In-memory KV cache. More = faster prompt processing. |
ssd_cache_dir |
~/.omlx/cache |
Where overflow KV cache is paged to disk |
ssd_cache_max_size |
auto |
oMLX manages SSD cache size automatically |
initial_cache_blocks |
128 / 256 |
Pre-allocated cache blocks. More = fewer reallocations. |
| Parameter | Value | Why |
|---|---|---|
context_scaling_enabled |
true |
Reports scaled token counts to Claude Code so auto-compact triggers at the right time relative to the real context limit |
target_context_size |
120000 / 200000 |
What oMLX reports as the context size to Claude Code |
A lightweight proxy (~80 lines) that sits between Claude Code and oMLX. Strongly recommended — it fixes several issues with running local models:
- Dynamic temperature: 0.5 for tool calls (tight structured JSON), 1.5 for normal text (higher entropy prevents Gemma 4 repetition loops)
- Repetition penalty override: Forces
repetition_penalty: 1.0(disabled) on every request — token-level penalties are proven ineffective against Gemma 4's paragraph-level loops - System prompt injection: Appends tool usage rules (read before edit, use Edit not Write, parameter names) that local models need but cloud Claude handles natively
- Request/response logging: Logs to
/tmp/omlx-proxy.logfor debugging tool calls, streaming health, and agent team coordination
# Install dependencies
pip install fastapi uvicorn httpx
# Create proxy directory
mkdir -p ~/.config/omlx-temp-proxyCreate ~/.config/omlx-temp-proxy/proxy.py:
"""
Lightweight sampling proxy for oMLX.
Request-body overrides (applied to /v1/messages POST):
- temperature: 0.5 with tools, 1.5 without
- repetition_penalty: 1.0 (disabled — ineffective per gemma#622)
- system prompt: appends local-model guidance (read before edit, etc.)
Logging:
- Requests and responses logged to /tmp/omlx-proxy.log
- tail -f /tmp/omlx-proxy.log to watch in real time
Usage:
python proxy.py # default: listen 8082 → oMLX 8080
python proxy.py --port 8082 --backend-port 8080
"""
import argparse
import json
import logging
import time
from logging.handlers import RotatingFileHandler
import httpx
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse, Response
LOG_FILE = "/tmp/omlx-proxy.log"
log = logging.getLogger("omlx-temp-proxy")
log.setLevel(logging.DEBUG)
_fh = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=3)
_fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
log.addHandler(_fh)
app = FastAPI()
BACKEND_URL = "http://127.0.0.1:8080"
TEMP_WITH_TOOLS = 0.5
TEMP_WITHOUT_TOOLS = 1.5
# Extra system instructions appended to every request.
# Compensates for local model quirks that cloud models handle natively.
SYSTEM_SUFFIX = """
## MANDATORY Tool Rules — violations cause failures
### Read Before Edit — THIS IS THE MOST IMPORTANT RULE
EVERY call to Edit REQUIRES a prior call to Read on the SAME file in the SAME conversation.
The Edit tool checks whether you have read the file. If you have not, it WILL reject your edit.
CORRECT workflow:
1. Call Read with the file_path
2. Find the exact text you want to change in the Read output
3. Copy that exact text into old_string (character-for-character, including whitespace)
4. Call Edit with file_path, old_string, new_string
WRONG (will fail):
- Calling Edit without calling Read first
- Guessing what old_string should be without reading the file
- Reading file A then editing file B (you must Read each file you Edit)
- Using Edit with old_string that doesn't match the file exactly
If you are about to call Edit, stop and ask yourself: "Did I Read this file?" If no, Read it first.
### Small Edits, Not Whole Files
- Use Edit for changes to existing files. Do NOT use Write to replace entire files when only a few lines change.
- Only use Write for NEW files that don't exist yet, or when truly every line must change.
### Always Use Tools
- NEVER output code as text instead of using tools to write/edit files.
- NEVER suggest the user run commands — execute them yourself with Bash.
- Take action immediately.
### Tool Parameter Names (snake_case, not camelCase)
- Edit: `file_path`, `old_string`, `new_string`, `replace_all`
- Read: `file_path`, `offset`, `limit`
- Write: `file_path`, `content`
- Grep: `pattern`, `path`, `glob`, `output_mode`
- Glob: `pattern`, `path`
- Bash: `command`, `description` (both required)
### Avoid These Mistakes
- Do NOT call tools that do not exist. Use Bash as a fallback.
- Do NOT pass arrays or objects as strings — use proper JSON types.
- Make independent tool calls in parallel, not sequentially.
""".strip()
def _summarize_messages(messages: list) -> str:
"""One-line-per-message summary for the log."""
lines = []
for msg in messages[-5:]:
role = msg.get("role", "?")
content = msg.get("content", "")
if isinstance(content, str):
preview = content[:120].replace("\n", " ")
elif isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict):
if block.get("type") == "text":
parts.append(block.get("text", "")[:80])
elif block.get("type") == "tool_use":
parts.append(f"[tool_use:{block.get('name','')}]")
elif block.get("type") == "tool_result":
parts.append(f"[tool_result:{block.get('tool_use_id','')[:12]}]")
else:
parts.append(f"[{block.get('type', '?')}]")
preview = " | ".join(parts)[:120]
else:
preview = str(content)[:120]
lines.append(f" {role}: {preview}")
total = len(messages)
if total > 5:
lines.insert(0, f" ... ({total - 5} earlier messages omitted)")
return "\n".join(lines)
def _summarize_tools(tools: list) -> str:
"""List tool names provided in the request."""
return ", ".join(t.get("name", "?") for t in tools)
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
async def proxy(request: Request, path: str):
body = await request.body()
is_streaming = False
t_start = time.perf_counter()
if request.method == "POST" and "messages" in path:
data = json.loads(body)
has_tools = bool(data.get("tools"))
data["temperature"] = TEMP_WITH_TOOLS if has_tools else TEMP_WITHOUT_TOOLS
data["repetition_penalty"] = 1.0
# Append local-model guidance to system prompt
system = data.get("system", "")
if isinstance(system, str):
data["system"] = f"{system}\n\n{SYSTEM_SUFFIX}" if system else SYSTEM_SUFFIX
elif isinstance(system, list):
data["system"].append({"type": "text", "text": SYSTEM_SUFFIX})
is_streaming = data.get("stream", False)
tools_summary = _summarize_tools(data["tools"]) if has_tools else "(none)"
log.info("=" * 60)
log.info("REQUEST %s /%s", request.method, path)
log.info(" model: %s", data.get("model", "?"))
log.info(" temp: %s rep_penalty: 1.0 stream: %s", data["temperature"], is_streaming)
log.info(" tools: %s", tools_summary)
log.info(" max_tok: %s", data.get("max_tokens", "?"))
log.info(" messages:\n%s", _summarize_messages(data.get("messages", [])))
body = json.dumps(data).encode()
else:
log.debug("PASSTHROUGH %s /%s", request.method, path)
headers = {
k: v for k, v in request.headers.items()
if k.lower() not in ("host", "content-length")
}
url = f"{BACKEND_URL}/{path}"
if is_streaming:
# IMPORTANT: Client lifecycle is managed by the generator, NOT the handler.
# Using `async with` here would close the client when the handler returns,
# before FastAPI starts iterating the StreamingResponse generator — resulting
# in an empty stream. Instead, we create the client here and close it in the
# generator's `finally` block.
client = httpx.AsyncClient(timeout=httpx.Timeout(300.0))
req = client.build_request(
request.method, url, content=body, headers=headers
)
resp = await client.send(req, stream=True)
async def stream():
chunks_seen = 0
text_accum = []
tool_calls = []
try:
async for chunk in resp.aiter_bytes():
chunks_seen += 1
try:
for line in chunk.decode("utf-8").split("\n"):
if not line.startswith("data: "):
continue
evt = json.loads(line[6:])
evt_type = evt.get("type", "")
if evt_type == "content_block_delta":
delta = evt.get("delta", {})
if delta.get("type") == "text_delta":
text_accum.append(delta.get("text", ""))
elif evt_type == "content_block_start":
block = evt.get("content_block", {})
if block.get("type") == "tool_use":
tool_calls.append(block.get("name", "?"))
except (json.JSONDecodeError, UnicodeDecodeError):
pass
yield chunk
except httpx.ReadError:
pass
finally:
await resp.aclose()
await client.aclose()
elapsed = time.perf_counter() - t_start
output_text = "".join(text_accum)
preview = output_text[:300].replace("\n", " ")
log.info("RESPONSE stream %d chunks %.1fs", chunks_seen, elapsed)
if tool_calls:
log.info(" tool_use: %s", ", ".join(tool_calls))
if preview:
log.info(" text: %s%s", preview, "..." if len(output_text) > 300 else "")
return StreamingResponse(
stream(),
status_code=resp.status_code,
headers=dict(resp.headers),
)
else:
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client:
resp = await client.request(
request.method, url, content=body, headers=headers
)
elapsed = time.perf_counter() - t_start
log.info("RESPONSE %d %.1fs %d bytes", resp.status_code, elapsed, len(resp.content))
return Response(
content=resp.content,
status_code=resp.status_code,
headers=dict(resp.headers),
)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8082)
parser.add_argument("--backend-port", type=int, default=8080)
args = parser.parse_args()
BACKEND_URL = f"http://127.0.0.1:{args.backend_port}"
uvicorn.run(app, host="127.0.0.1", port=args.port)Critical streaming note: The streaming path creates
httpx.AsyncClientoutside ofasync withand closes it in the generator'sfinallyblock. This is intentional — if you useasync with, the client closes when the handler returns (before FastAPI starts iterating the generator), resulting in an empty stream. This is a common FastAPI streaming proxy pitfall.
# Start the proxy (runs in background)
nohup python3 ~/.config/omlx-temp-proxy/proxy.py --port 8082 --backend-port 8080 \
> /tmp/omlx-temp-proxy.log 2>&1 &
# Watch logs in real time
tail -f /tmp/omlx-proxy.logAdd this block to your claude-local function before the ANTHROPIC_BASE_URL line:
# Start sampling proxy if not running
if ! curl -sf http://127.0.0.1:8082/v1/models > /dev/null 2>&1; then
echo "Starting sampling proxy (8082 → 8080)..."
nohup python3 ~/.config/omlx-temp-proxy/proxy.py --port 8082 --backend-port 8080 \
> /tmp/omlx-temp-proxy.log 2>&1 &
sleep 1
fiThen set ANTHROPIC_BASE_URL="http://127.0.0.1:8082" in the function.
Edit the SYSTEM_SUFFIX variable in proxy.py to add or modify rules. Common additions:
- Project-specific conventions ("always use Effect-TS patterns")
- Additional tool guidance based on observed failures
- Output style preferences
Changes take effect after restarting the proxy (pkill -f omlx-temp-proxy; nohup python3 ...).
All models are from the mlx-community HuggingFace organization (pre-quantized for MLX):
| Model | Disk/Memory | Active Params | Context | Best for |
|---|---|---|---|---|
gemma-4-26b-a4b-it-8bit |
~27 GB | 3.8B (MoE) | 256k | Best quality. Recommended for 64-128 GB. |
gemma-4-26b-a4b-it-4bit |
~14 GB | 3.8B (MoE) | 256k | Tight memory. Good quality, smaller footprint. |
gemma-4-4b-it-8bit |
~8 GB | 4B (Dense) | 128k | Fastest. Good for simple tasks or very constrained memory. |
To switch models:
- Download:
huggingface-cli download mlx-community/MODEL_NAME - Symlink into
~/.omlx/models/with a short name - Update model names in
~/.omlx/settings.jsonunderclaude_code - Update the
modelvariable in yourclaude-localfunction - Restart oMLX:
brew services restart omlx
Gemma 4 has a known model-level tendency toward repetition that is a regression from Gemma 3. This is the most common issue you'll encounter. The model generates a paragraph of reasoning, then generates the same paragraph again, over and over.
Root causes (layered):
- Model-level: Gemma 4 has inherent token-doubling behavior. Tested with identical seeds, Gemma 4 MoE fails 5/10 while Gemma 3 shows 0/10 failures (google-deepmind/gemma#622).
- Quantization amplifier: 4-bit quantization makes it dramatically worse via SDPA numerical drift in MLX (ml-explore/mlx#3384). Use 8-bit.
- Low temperature: Lower temperature pushes the model toward deterministic token choices, making loops more likely. Google recommends temp 1.0; the community reports 1.5 works better for coding.
- Grammar constraints: JSON schema output triggers loops more aggressively (10/10 failure rate in testing for long JSON).
What does NOT work:
repetition_penalty— tested at 1.0, 1.15, and 1.5 with identical results on identical seeds. Token-level penalties cannot catch paragraph-level loops because the repeating unit contains enough token diversity. Google doesn't recommend it. Keep at 1.0.- DRY sampling — catches sequence-level repetition but is only available in llama.cpp, not MLX/oMLX.
What helps (in order of impact):
- Use 8-bit, not 4-bit — avoids the MLX SDPA mask divergence bug that traps the sampler in cyclic repetition.
- Higher temperature (1.5) — gives the model more entropy to escape repetition attractors. The sampling proxy handles this automatically.
max_tokens: 8192— caps worst-case loop to ~4 minutes at 30 tok/s. Lower to 4096 if loops are frequent.- Sampling proxy with system prompt injection — the proxy's tool usage rules keep the model focused on concrete actions rather than open-ended reasoning where loops are most common.
Relevant issues:
- google-deepmind/gemma#610 — Deterministic loop at 14th list item
- google-deepmind/gemma#622 — Token repetition collapse, repetition_penalty proven ineffective
- ml-explore/mlx#3384 — SDPA mask divergence under 4-bit quantization
- jundot/omlx#726 — Thinking loop auto-detection feature request
If you see slowdowns, system swap usage, or memory_pressure warnings:
- Reduce hot cache:
"hot_cache_max_size": "4GB"— SSD cache compensates, just slower - Reduce context window:
"max_context_window": 65536(64k) — fewer KV cache entries - Lower concurrent requests:
"max_concurrent_requests": 1— less parallel memory usage - Use 4-bit model:
gemma-4-26b-a4b-it-4bitsaves ~13 GB of memory - Close memory-hungry apps — browsers (especially Chrome), Docker, Xcode, Simulator
- SSD cache is your friend — oMLX pages KV cache to SSD when RAM is tight. Make sure you have 20+ GB free disk space. NVMe SSDs on Apple Silicon are fast enough that SSD-cached prompts still process quickly.
- First request is slow — the model loads into GPU memory on first use (~30-60s). Subsequent requests are fast.
- Context scaling — oMLX's
context_scaling_enabledreports scaled token counts so Claude Code's auto-compact triggers at the right time. Don't disable this. - Prompt caching — oMLX caches prompt prefixes. In agent workflows with repeated system prompts, cache hit rates can exceed 90%, dramatically speeding up prefill.
Measured with Gemma 4 26B-A4B 8-bit on M4 Max 128 GB, 24 GB hot cache:
| Metric | 1 Request | 2 Parallel | 4 Parallel |
|---|---|---|---|
| Generation speed | ~35 tok/s | ~33 tok/s each | ~25 tok/s each |
| Total throughput | ~35 tok/s | ~66 tok/s (1.88x) | ~100 tok/s (~2.8x) |
| Model RSS | ~27 GB | ~27 GB | ~27 GB |
Key observations:
- Memory does not increase with concurrency. Model weights (~27 GB) are shared. KV caches for concurrent requests add minimal overhead.
- Throughput scales sublinearly — diminishing returns because memory bandwidth (not compute) is the bottleneck on Apple Silicon.
- 64 GB machines will see ~20-30 tok/s for single requests. Parallel throughput is limited by the lower hot cache and concurrent request cap.
If you see certificate verify failed when downloading models, your corporate proxy (Zscaler, Netskope, etc.) is doing SSL inspection with its own root CA that Python doesn't trust.
Step 1: Export the corporate root CA from macOS Keychain
# Find the certificate name (usually "Zscaler Root CA" or similar)
security find-certificate -a -c "Zscaler" /Library/Keychains/System.keychain
# Export it as PEM
security find-certificate -a -c "Zscaler" -p /Library/Keychains/System.keychain \
> ~/zscaler-root-ca.pemIf you don't know the certificate name, open Keychain Access, select System keychain, filter by "Certificates", and look for your proxy vendor's root CA. Right-click → Export as .pem.
Step 2: Create a combined CA bundle
# Find Python's default CA bundle
python3 -c "import certifi; print(certifi.where())"
# e.g., /opt/homebrew/lib/python3.11/site-packages/certifi/cacert.pem
# Create a combined bundle (system CAs + corporate CA)
cat "$(python3 -c 'import certifi; print(certifi.where())')" ~/zscaler-root-ca.pem \
> ~/.ssl/corporate-ca-bundle.pem
mkdir -p ~/.sslStep 3: Set environment variables
Add these to your ~/.zshrc or ~/.bashrc:
export REQUESTS_CA_BUNDLE="$HOME/.ssl/corporate-ca-bundle.pem"
export SSL_CERT_FILE="$HOME/.ssl/corporate-ca-bundle.pem"
export CURL_CA_BUNDLE="$HOME/.ssl/corporate-ca-bundle.pem"
export NODE_EXTRA_CA_CERTS="$HOME/.ssl/corporate-ca-bundle.pem"REQUESTS_CA_BUNDLE covers Python's requests library (used by huggingface-hub).
SSL_CERT_FILE covers Python's httpx and ssl module (used by hf-xet downloads).
CURL_CA_BUNDLE covers curl and tools that use libcurl.
NODE_EXTRA_CA_CERTS covers Node.js (used by Claude Code itself).
After sourcing your shell config, huggingface-cli download and omlx pull should work behind the corporate proxy.
Claude Code's web search uses Anthropic's server-side search API, which requires a valid API key. With ANTHROPIC_AUTH_TOKEN="none", searches fail silently. No workaround — this is a fundamental limitation of local inference.
Gemma 4 can generate thousands of thinking tokens before producing visible content. At ~30 tok/s, a 4,000-token thinking chain takes ~2 minutes of apparent silence in the UI. The stream watchdog timeout is set to 10 minutes (CLAUDE_STREAM_IDLE_TIMEOUT_MS=600000) to accommodate this. If you still see timeouts, increase the value.
If you see "Wait, I should..." or "Actually, let me..." deliberation text in the regular (non-thinking) output, Gemma is emitting thinking tokens in the wrong channel. This can happen with older oMLX versions. Update oMLX:
# Homebrew install:
brew upgrade omlx
brew services restart omlx
# pip install:
source ~/.omlx-venv/bin/activate
pip install --upgrade "omlx[mcp] @ git+https://github.com/jundot/omlx.git"
deactivate
launchctl kickstart -k gui/$(id -u)/com.omlx.serverRotatingKVCache offset 44032 may be stale (buffer seq_len=1024, max_size=1024)
These appear at high context lengths when the KV cache's rotating buffer hits its boundary. They're warnings, not errors, but excessive spam can indicate the model is at the edge of its cache capacity. Reduce max_context_window or increase hot_cache_max_size if you have memory headroom.
ERROR: [Errno 48] error while attempting to bind on address ('127.0.0.1', 8080)
Something else is using port 8080. Find and stop it:
lsof -i :8080
# Then either stop that process or change oMLX's port in settings.json and the plistClaude Code's experimental agent teams feature has a known bug where the team-lead's mailbox polling reads inboxes/team-lead.json but teammates write to inboxes/team-lead-TEAMNAME.json. This is a Claude Code bug, not a local inference issue — it happens with the Anthropic API too.
Homebrew install:
brew services start omlx # Start and enable auto-start
brew services stop omlx # Stop the server
brew services restart omlx # Restart (picks up settings.json changes)
brew services info omlx # Check statuspip install:
# Start (via LaunchAgent)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.omlx.server.plist
# Restart
launchctl kickstart -k gui/$(id -u)/com.omlx.server
# Stop
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.omlx.server.plistEither method:
# Stop the temperature proxy (if using)
pkill -f "omlx-temp-proxy/proxy.py"# Check if oMLX is running and which model is loaded
curl -s http://127.0.0.1:8080/v1/models | python3 -m json.tool
# Memory and CPU usage
ps aux | grep omlx | grep -v grep | awk '{printf "PID: %s | CPU: %s%% | MEM: %s%% | RSS: %dMB\n", $2, $3, $4, $6/1024}'
# Watch server logs live
tail -f /tmp/omlx-server.log
# Watch proxy logs live (request/response details, tool calls, timing)
tail -f /tmp/omlx-proxy.log
# Check active connections to oMLX
lsof -i :8080 -P | grep ESTABLISHED | wc -lWhen things are working, oMLX logs look like:
omlx.server - INFO - Anthropic message: 512 tokens in 6.84s (74.8 tok/s)
omlx.server - INFO - Anthropic message: 256 tokens in 3.96s (64.7 tok/s)
omlx.scheduler - INFO - Using boundary cache snapshot for ...: storing 60416/60447 tokens
| Log | Location |
|---|---|
| oMLX server | /tmp/omlx-server.log |
| Temperature proxy | /tmp/omlx-temp-proxy.log |
# Homebrew:
brew upgrade omlx
brew services restart omlx
# pip:
source ~/.omlx-venv/bin/activate
pip install --upgrade "omlx[mcp] @ git+https://github.com/jundot/omlx.git"
deactivate
launchctl kickstart -k gui/$(id -u)/com.omlx.server# Start Claude Code locally
claude-local
# Check oMLX status
curl -s http://127.0.0.1:8080/v1/models
# Restart oMLX (brew)
brew services restart omlx
# Restart oMLX (pip / LaunchAgent)
launchctl kickstart -k gui/$(id -u)/com.omlx.server
# View logs
tail -f /tmp/omlx-server.log
# Test a direct API call
curl -s http://127.0.0.1:8080/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: none" \
-d '{"model":"gemma-4-26b-8bit","max_tokens":50,"stream":false,"messages":[{"role":"user","content":"Hello!"}]}'