Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
b82e30d
cmake: reconfigure MLX external builds
pd95 Aug 27, 2026
dfd0050
responses: make namespace tool identity injective
pd95 Aug 15, 2026
15b572c
test(responses): cover prior namespace separators
pd95 Aug 27, 2026
81ee373
openai: adapt custom apply_patch responses tool
pd95 Aug 15, 2026
ba5d772
openai: support Codex custom apply patch
pd95 Aug 22, 2026
71494cb
openai: improve apply patch guidance for non-GPT-OSS models
pd95 Aug 22, 2026
fd443f3
fix(openai): align apply patch schema guidance
pd95 Aug 27, 2026
9979c21
fix(parser): reject unsafe Gemma4 tool calls
pd95 Aug 15, 2026
052e97b
fix(tokenizer): bound sparse tokenizer IDs
pd95 Aug 15, 2026
d1f3b61
tokenizer: bound added token IDs during load
pd95 Aug 26, 2026
5b3d114
test(tokenizer): clarify added token limit scope
pd95 Aug 27, 2026
25dfd22
create: plan GPT-OSS tensor imports
pd95 Aug 15, 2026
3550281
gptoss: add the MLX model configuration contract
pd95 Aug 15, 2026
f00bf63
mlxrunner: register the GPT-OSS model runtime
pd95 Aug 15, 2026
428cd93
create: recognize GPT-OSS safetensors checkpoints
pd95 Aug 15, 2026
2239da4
create: preserve native GPT-OSS MXFP4 tensors
pd95 Aug 15, 2026
c74120e
mlx: fuse GPT-OSS MXFP4 MoE decode
pd95 Aug 15, 2026
135d09f
tokenizer: preserve ordered GPT-OSS EOS tokens
pd95 Aug 15, 2026
47f285e
test: add GPT-OSS forward-reference parity
pd95 Aug 15, 2026
fc8dd07
mlx: carry attention sinks through shared SDPA
pd95 Aug 15, 2026
6baa1f2
gptoss: execute dense, expert, and cached MLX paths
pd95 Aug 15, 2026
0db9c95
test: cover GPT-OSS runtime and import contracts
pd95 Aug 15, 2026
950a0ed
create: bound GPT-OSS expert conversion memory
pd95 Aug 15, 2026
f7029e8
create: preserve native GPT-OSS MXFP4 snapshots
pd95 Aug 23, 2026
64f405f
create: restore GPT-OSS family metadata
pd95 Aug 23, 2026
111d0e0
mlx: avoid GPT-OSS MoE selector synchronization
pd95 Aug 26, 2026
0266785
create: canonicalize native GPT-OSS expert metadata
pd95 Aug 27, 2026
761375c
create: require GPT-OSS native expert biases
pd95 Aug 27, 2026
212f270
create: validate and share GPT-OSS expert transforms
pd95 Aug 27, 2026
b04ed5d
safetensors: use named integer limits
pd95 Aug 27, 2026
e49324c
mlx: add Apertus model runtime
pd95 Aug 15, 2026
05db221
model: add Apertus chat rendering and parsing
pd95 Aug 15, 2026
d260be7
create: infer Apertus metadata
pd95 Aug 15, 2026
4c6296a
model: enable Apertus thinking mode
pd95 Aug 15, 2026
55c4341
create: keep Apertus NVFP4 imports uniform
pd95 Aug 15, 2026
7dcd6a3
create: restore Apertus MXFP8 imports
pd95 Aug 23, 2026
2bfda88
apertus: accept packed MXFP8 import tensors
pd95 Aug 23, 2026
d4ad2d9
create: preserve Apertus family metadata
pd95 Aug 23, 2026
0d61ba2
create: correct Apertus 1.0 capabilities
pd95 Aug 23, 2026
2fd492f
server: preserve Apertus structured output content
pd95 Aug 27, 2026
a413b35
gemma4: add MLX image embedding path
pd95 Aug 15, 2026
84684e4
gemma4: expose MLX vision capability
pd95 Aug 15, 2026
8a71076
gemma4: gate MLX vision capability on tensors
pd95 Aug 15, 2026
05c5576
gemma4: harden MLX vision integration
pd95 Aug 15, 2026
931eb9a
gemma4: support unified MLX vision
pd95 Aug 15, 2026
8f3359b
gemma4: retain and gate MLX audio tensors
pd95 Aug 15, 2026
1784d0a
gemma4: add released MLX audio processor
pd95 Aug 16, 2026
c59f098
gemma4: execute native MLX audio embeddings
pd95 Aug 16, 2026
b96bd24
gemma4: add MLX audio forward parity test
pd95 Aug 16, 2026
e80351e
gemma4: harden MLX audio readiness and validation
pd95 Aug 16, 2026
19c8875
gemma4: align MLX audio capability with runtime
pd95 Aug 16, 2026
78c28c9
gemma4: test malformed audio degradation
pd95 Aug 16, 2026
5620ab8
gemma4: unify audio readiness parsing
pd95 Aug 16, 2026
163a747
gemma4: support unified MLX audio projection
pd95 Aug 14, 2026
4862964
gemma4: tighten MLX audio architecture validation
pd95 Aug 16, 2026
0e49cf3
gemma4: support ordered MLX media inputs
pd95 Aug 16, 2026
1deb636
gemma4: complete quantized shared media support
pd95 Aug 16, 2026
1a1e866
gemma4: hydrate unified vision manifest tensors
pd95 Aug 16, 2026
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
3 changes: 2 additions & 1 deletion cmake/local.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,8 @@ function(ollama_add_mlx_build name)
SOURCE_DIR ${CMAKE_SOURCE_DIR}/cmake/mlx
BINARY_DIR ${_build_dir}
CONFIGURE_COMMAND ${_configure_command}
BUILD_COMMAND ${OLLAMA_NATIVE_BUILD_TOOL_COMMAND}
BUILD_COMMAND ${_configure_command}
COMMAND ${OLLAMA_NATIVE_BUILD_TOOL_COMMAND}
${OLLAMA_NATIVE_CONFIG_ARG}
${OLLAMA_NATIVE_BUILD_TARGET_ARG} mlx
${OLLAMA_NATIVE_BUILD_TARGET_ARG} mlxc
Expand Down
44 changes: 44 additions & 0 deletions docs/third-party/mlx-vlm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# MLX-VLM

Portions of `x/models/gemma4/vision.go` are adapted from the Gemma 4
implementation in MLX-VLM:

- Repository: https://github.com/Blaizzy/mlx-vlm
- Revision: `61990c9054f2bc7bb8f32541e3238b4a58fe64e5`
- Source paths: `mlx_vlm/models/gemma4/gemma4.py`,
`mlx_vlm/models/gemma4/vision.py`, and
`mlx_vlm/models/gemma4_unified/gemma4_unified.py`

The native Gemma 4 audio encoder in `x/models/gemma4/audio.go` is adapted from
the same project's Gemma 4 Conformer implementation at revision
`84f43753380355c0455a2bafb291d4b7cbcf81d1` (MLX-VLM v0.6.5), source path
`mlx_vlm/models/gemma4/audio.py`. Its architecture and numerical behavior were
also cross-checked against Hugging Face Transformers revision
`dff4572dfa4bfa9f00cc8414e4b84877552fefe9`. No MLX-VLM or Transformers runtime
dependency is included.

The adapted implementation is distributed under the following license.

```text
MIT License

Copyright © 2025 Prince Canuma

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
239 changes: 239 additions & 0 deletions integration/audio_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,28 @@ import (
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"regexp"
"strings"
"testing"
"time"

"github.com/ollama/ollama/api"
)

var defaultAudioModels = []string{
"nemotron3:33b",
"gemma4:e2b",
"gemma4:e4b",
}

var catResponsePattern = regexp.MustCompile(`\bcats?\b`)

// decodeTestAudio returns the test audio clip ("Why is the sky blue?", 16kHz mono WAV).
func decodeTestAudio(t *testing.T) api.ImageData {
t.Helper()
Expand All @@ -28,6 +38,58 @@ func decodeTestAudio(t *testing.T) api.ImageData {
return data
}

func silentTestAudio(t *testing.T) api.ImageData {
t.Helper()
const (
sampleRate = 16_000
samples = sampleRate / 2
)
dataSize := samples * 2
var out bytes.Buffer
for _, value := range []any{
[]byte("RIFF"), uint32(36 + dataSize), []byte("WAVE"),
[]byte("fmt "), uint32(16), uint16(1), uint16(1),
uint32(sampleRate), uint32(sampleRate * 2), uint16(2), uint16(16),
[]byte("data"), uint32(dataSize), make([]byte, dataSize),
} {
if err := binary.Write(&out, binary.LittleEndian, value); err != nil {
t.Fatalf("encode silent WAV: %v", err)
}
}
return out.Bytes()
}

func requireResponseContains(t *testing.T, response string, words ...string) {
t.Helper()
lower := strings.ToLower(response)
for _, word := range words {
if word == "cat" {
if catResponsePattern.MatchString(lower) {
return
}
continue
}
if strings.Contains(lower, word) {
return
}
}
t.Fatalf("none of %v found in %q", words, response)
}

func requireLabeledImageOrder(t *testing.T, response string, firstWords, secondWords []string) {
t.Helper()
lower := strings.ToLower(response)
firstAt := strings.Index(lower, "first:")
secondAt := strings.Index(lower, "second:")
if firstAt < 0 || secondAt <= firstAt {
t.Fatalf("response does not contain ordered FIRST:/SECOND: labels: %q", response)
}
first := response[firstAt+len("first:") : secondAt]
second := response[secondAt+len("second:"):]
requireResponseContains(t, first, firstWords...)
requireResponseContains(t, second, secondWords...)
}

// setupAudioModel pulls the model, preloads it, and skips if it doesn't support audio.
func setupAudioModel(ctx context.Context, t *testing.T, client *api.Client, model string) {
t.Helper()
Expand Down Expand Up @@ -250,3 +312,180 @@ func runOpenAIChatWithAudio(t *testing.T, models []string) {
})
}
}

// TestGemma4MultipleMedia exercises ordered multi-audio, multi-image, mixed
// image/audio, OpenAI interleaving, and retained-history media through MLX.
func TestGemma4MultipleMedia(t *testing.T) {
models := testModels([]string{"gemma4:e2b"})
for _, model := range models {
t.Run(model, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
client, endpoint, cleanup := InitServerConnection(ctx, t)
defer cleanup()

setupAudioModel(ctx, t, client, model)
requireCapability(ctx, t, client, model, "vision")
speech := decodeTestAudio(t)
silence := silentTestAudio(t)
abbeyRoad, docs, _ := decodeTestImages(t)
noThink := &api.ThinkValue{Value: false}

for _, tc := range []struct {
name string
media []api.ImageData
}{
{name: "speech_then_silence", media: []api.ImageData{speech, silence}},
{name: "silence_then_speech", media: []api.ImageData{silence, speech}},
} {
t.Run(tc.name, func(t *testing.T) {
req := api.ChatRequest{
Model: model,
Think: noThink,
Messages: []api.Message{{
Role: "user",
Content: "Two audio clips are attached. Transcribe only the clip containing speech.",
Images: tc.media,
}},
Options: map[string]any{"temperature": 0, "seed": 123, "num_predict": 80},
}
response := DoChat(ctx, t, client, req, []string{"sky", "blue"}, 90*time.Second, 20*time.Second)
requireResponseContains(t, response.Content, "sky", "blue")
})
}

for _, tc := range []struct {
name string
media []api.ImageData
firstWords []string
secondWords []string
}{
{
name: "abbey_then_docs", media: []api.ImageData{abbeyRoad, docs},
firstWords: []string{"road", "street", "cross", "walk", "beatles", "stripe", "ollamas"},
secondWords: []string{"laptop", "book", "read", "sleep", "documentation", "document", "desk", "work", "study", "activity", "office"},
},
{
name: "docs_then_abbey", media: []api.ImageData{docs, abbeyRoad},
firstWords: []string{"laptop", "book", "read", "sleep", "documentation", "desk"},
secondWords: []string{"road", "street", "cross", "walk", "beatles", "stripe"},
},
} {
t.Run(tc.name, func(t *testing.T) {
req := api.ChatRequest{
Model: model,
Think: noThink,
Messages: []api.Message{{
Role: "user",
Content: "Describe both pictures in order. Reply with exactly two labeled lines: " +
"FIRST: the first picture. SECOND: the second picture.",
Images: tc.media,
}},
Options: map[string]any{"temperature": 0, "seed": 123, "num_predict": 120},
}
response := DoChat(ctx, t, client, req, append(tc.firstWords, tc.secondWords...), 120*time.Second, 20*time.Second)
requireLabeledImageOrder(t, response.Content, tc.firstWords, tc.secondWords)
})
}

t.Run("mixed_same_message", func(t *testing.T) {
req := api.ChatRequest{
Model: model,
Think: noThink,
Messages: []api.Message{{
Role: "user",
Content: "First [img] is a picture and second [img] is audio. Identify the picture subject and transcribe the spoken question.",
Images: []api.ImageData{docs, speech},
}},
Options: map[string]any{"temperature": 0, "seed": 123, "num_predict": 120},
}
response := DoChat(ctx, t, client, req, []string{"llama", "alpaca", "sky", "blue"}, 120*time.Second, 20*time.Second)
requireResponseContains(t, response.Content, "llama", "alpaca", "animal", "cartoon", "bear", "character", "cat")
requireResponseContains(t, response.Content, "sky", "blue")
})

t.Run("openai_mixed_same_message", func(t *testing.T) {
body, err := json.Marshal(map[string]any{
"model": model,
"messages": []any{map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "text", "text": "First "},
map[string]any{"type": "image_url", "image_url": map[string]any{
"url": "data:image/png;base64," + base64.StdEncoding.EncodeToString(docs),
}},
map[string]any{"type": "text", "text": " is a picture. Second "},
map[string]any{"type": "input_audio", "input_audio": map[string]any{
"data": base64.StdEncoding.EncodeToString(speech), "format": "wav",
}},
map[string]any{"type": "text", "text": " is audio. Identify the picture subject and transcribe the spoken question."},
},
}},
"temperature": 0,
"seed": 123,
"max_tokens": 200,
"reasoning_effort": "none",
})
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
fmt.Sprintf("http://%s/v1/chat/completions", endpoint), bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("OpenAI mixed-media request returned %s: %s", resp.Status, responseBody)
}
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
Reasoning string `json:"reasoning"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(responseBody, &result); err != nil {
t.Fatal(err)
}
if len(result.Choices) != 1 {
t.Fatalf("OpenAI mixed-media choices = %d, want 1", len(result.Choices))
}
text := result.Choices[0].Message.Content + " " + result.Choices[0].Message.Reasoning
requireResponseContains(t, text, "llama", "alpaca", "animal", "cartoon", "bear", "character", "cat")
requireResponseContains(t, text, "sky", "blue")
})

t.Run("mixed_across_history", func(t *testing.T) {
req := api.ChatRequest{
Model: model,
Think: noThink,
Messages: []api.Message{
{Role: "user", Content: "Remember this picture.", Images: []api.ImageData{docs}},
{Role: "assistant", Content: "I will retain the picture for the next instruction."},
{
Role: "user",
Content: "Use both media inputs. Reply with exactly two labeled lines: " +
"AUDIO: the exact spoken question. IMAGE: the picture subject.",
Images: []api.ImageData{speech},
},
},
Options: map[string]any{"temperature": 0, "seed": 123, "num_predict": 120},
}
response := DoChat(ctx, t, client, req, []string{"llama", "alpaca", "sky", "blue"}, 120*time.Second, 20*time.Second)
requireResponseContains(t, response.Content, "llama", "alpaca", "animal", "cartoon", "bear", "character", "cat")
requireResponseContains(t, response.Content, "sky", "blue")
})
})
}
}
16 changes: 3 additions & 13 deletions middleware/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -1018,7 +1018,7 @@ func (w *WebSearchResponsesWriter) writeWebSearchResponse(final api.ChatResponse
response := openai.ToResponse(w.inner.model, w.inner.responseID, w.inner.itemID, final, w.req)
completedAt := time.Now().Unix()
response.CompletedAt = &completedAt
response.Output = buildResponsesWebSearchOutput(response.Output, w.preSearchThinking, w.preSearchContent, calls, w.otherToolCalls)
response.Output = buildResponsesWebSearchOutput(w.req, response.Output, w.preSearchThinking, w.preSearchContent, calls, w.otherToolCalls)
if response.Usage != nil {
response.Usage.InputTokens = usage.PromptEvalCount
response.Usage.OutputTokens = usage.EvalCount
Expand All @@ -1032,7 +1032,7 @@ func (w *WebSearchResponsesWriter) writeWebSearchResponse(final api.ChatResponse
// buildResponsesWebSearchOutput assembles the final non-streaming output in
// model-leg order: pre-search reasoning/text, server and mixed tool calls, then
// the final model output.
func buildResponsesWebSearchOutput(output []openai.ResponsesOutputItem, preSearchThinking, preSearchContent string, searchCalls []openai.ResponsesWebSearchCall, otherToolCalls []api.ToolCall) []openai.ResponsesOutputItem {
func buildResponsesWebSearchOutput(request openai.ResponsesRequest, output []openai.ResponsesOutputItem, preSearchThinking, preSearchContent string, searchCalls []openai.ResponsesWebSearchCall, otherToolCalls []api.ToolCall) []openai.ResponsesOutputItem {
items := make([]openai.ResponsesOutputItem, 0, len(output)+len(searchCalls)+len(otherToolCalls)+2)
if preSearchThinking != "" {
items = append(items, openai.ResponsesOutputItem{
Expand Down Expand Up @@ -1061,17 +1061,7 @@ func buildResponsesWebSearchOutput(output []openai.ResponsesOutputItem, preSearc
items = append(items, openai.WebSearchCallOutputItem(call))
}
// function_call items from mixed responses
convertedCalls := openai.ToToolCalls(otherToolCalls)
for i, tc := range convertedCalls {
items = append(items, openai.ResponsesOutputItem{
ID: fmt.Sprintf("fc_mixed_%d", i),
Type: "function_call",
Status: "completed",
CallID: tc.ID,
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
})
}
items = append(items, openai.ResponsesFunctionCallOutputItems(request, "fc_mixed_", otherToolCalls)...)
// remaining items (final reasoning, message, or function calls)
items = append(items, output...)
return items
Expand Down
Loading