Compare commits

...
Author SHA1 Message Date
Eva Ho 955112e502 update poolside doc 2026-04-27 19:26:08 -07:00
Eva Ho 62e83eb18f add poolside integration 2026-04-27 19:26:03 -07:00
Daniel Hiltgen 0f8675780c ggml: implement nemotron 3 nano omni 2026-04-27 19:25:17 -07:00
Daniel Hiltgen 02376e20b2 review comments - perf improvements 2026-04-27 19:25:17 -07:00
Daniel Hiltgen 80a780ad26 server: preserve generate logprobs with builtin parsers
Generate requests were dropping logprob-only chunks whenever a builtin parser buffered visible content. Chat already handled this case, but generate only forwarded chunks with visible response, thinking, or tool-call output.

Keep generate chunks that carry logprobs even when the builtin parser has not flushed visible content yet, and add a regression test that exercises the behavior with a generic thinking parser.
2026-04-27 19:25:17 -07:00
Daniel Hiltgen 8b062e4c7e ggml: add laguna model support 2026-04-27 19:25:17 -07:00
Daniel Hiltgen 69c8fdd755 convert: support fp8 safetensors import
Decode HF F8_E4M3 safetensors with block scale companions into GGUF-supported tensor types, and record which output tensors came from FP8 source weights.

Use that source-precision metadata during create quantization: default FP8-sourced GGUFs to Q8_0, keep non-FP8 tensors at their original precision for Q8_0, and promote non-FP8 quantizable tensors to Q8_0 for Q4_K requests.
2026-04-27 19:25:17 -07:00
Daniel Hiltgen 081c1bab72 mlx: add laguna model support 2026-04-27 19:25:17 -07:00
Daniel Hiltgen 03aee88186 mlx: Support NVIDIA TensorRT Model Optimizer import (#15566)
* mlx: Support NVIDIA TensorRT Model Optimizer import

* x/create: support FP8 safetensors import

Decode HF F8_E4M3 safetensors with block scale companions into MLX-importable tensor blobs, including compressed-tensors weight_scale metadata, packed NVFP4 layouts, and mixed-precision tensor headers.

Use that source-precision metadata during create quantization: default FP8-sourced imports to mxfp8, allow source FP8 to target MLX low-bit formats, preserve source-quantized NVFP4 layouts, selectively keep or promote tensors based on their source precision, and detect quantized dtype from mixed-precision safetensors manifests.

* review comments
2026-04-27 18:28:10 -07:00
Daniel Hiltgen ec9b4e9e47 tokenizer: fix multi-regex BPE offset handling (#15844)
Use the current fragment offset when emitting unmatched spans during multi-regex BPE splitting. This avoids duplicating earlier prompt text and inflating token counts for multi-stage BPE tokenizers.
2026-04-27 14:14:27 -07:00
Jesse Gross 4656a07e56 mlxrunner: batch the sampler across multiple sequences
Register sequences with Add/Remove; each Sample call takes any subset of
registered slots and samples one token per row, appending to each slot's
ring-buffer history. When all slots share Options and penalty rings are
full, one fused transform pass runs over the whole batch via a persistent
pooled history tensor; otherwise calls fall back to per-slot serial
processing indexed against the same pool.

Performance is unchanged for a single sequence, which is all that is
exposed for now.
2026-04-25 09:53:53 -07:00
Jesse Gross 30f86cb9dd mlxrunner: track sampler history in a fixed-size ring buffer
AppendToken used to concatenate the new token onto the history tensor
and slice it back to RepeatLastN every decode step, churning the graph
shape and reallocating a fresh tensor each call. The stateful penalties
don't care about order within the window, so a fixed-capacity ring with
one SliceUpdate per append keeps the tensor shape constant across
steps.
2026-04-25 09:53:53 -07:00
Parth Sareen ea01af6f76 openai: map responses reasoning effort to think (#15789) 2026-04-24 02:49:36 -07:00
Parth Sareen c2ebb4d57c api: accept "max" as a think value (#15787) 2026-04-24 01:49:39 -07:00
Parth Sareen 590109c835 launch: harden OpenClaw onboarding flow (#15777) 2026-04-23 16:47:20 -07:00
Eva H b4442c6d17 launch: resave managed integration config when live config drifts (#15776) 2026-04-23 19:32:36 -04:00
Eva H 85ff8e4a21 launch: keep launch recommended models in a fixed canonical order (#15750) 2026-04-23 16:33:00 -04:00
Parth Sareen 160660e572 launch: use bundled OpenClaw ollama web search (#15757) 2026-04-22 16:34:19 -07:00
madflowandParth Sareen 3b43b9bc4b docs: update structured outputs doc for cloud (#15733)
---------

Co-authored-by: Parth Sareen <parth.sareen@ollama.com>
2026-04-22 00:42:39 -07:00
Parth Sareen 21883571b7 launch: replace kimi-k2.5 with k2.6 as top recommended model (#15737) 2026-04-21 15:13:20 -07:00
Jesse Gross ce99f24731 mlxrunner: tokenize prompts in request handler goroutines
Move tokenization out of the single GPU processing goroutine and
into each request's HTTP handler goroutine. This allows the next
request's prompt to be tokenized on the CPU while the current
request is executing on the GPU.
2026-04-21 14:38:49 -07:00
Jesse Gross 04f5f0cdb4 mlx: improve thread safety of array management
Use atomic.Int32 for Array.pinned and a sync.Mutex for the global
arrays slice so MLX arrays can be created and pinned from multiple
goroutines without racing on those structures. Convert Array value
receivers to pointer receivers and struct fields from Array to
*Array to avoid copying the atomic.

This does not fully achieve thread safety even when building
completely independent graphs. The tracing flag and traceScratch
slice in compile.go are unprotected, so concurrent Compile calls
will race. MLX itself is not fully thread-safe either although
it is working to improve.
2026-04-21 14:38:49 -07:00
Matteo Celani fb36a01ffe app/ui: fix model picker showing stale model after switching chats (#15280)
* app/ui: fix model picker showing stale model after switching chats

Optimistic messages created during streaming were storing the full
Model object instead of the model name string. When switching back
to a chat with cached streaming data, the restore effect read an
object where it expected a string, causing the model picker to fail
matching and remain stuck on the previous chat's model.

* app/ui: fix two more instances of Model object passed as model name

Fix the same bug at lines 523 and 536 in the assistant_with_tools
event handler, where selectedModel (object) was used instead of
selectedModel.model (string).
2026-04-21 15:08:06 -04:00
Michael Verrilli 0c65ed33bc cmd: populate model capabilities in launchInteractiveModel (#15712)
launchInteractiveModel was introduced in PR #14609 without the
client.Show() capability-detection block that RunHandler uses.
This left opts.MultiModal always false in the TUI path, causing
image/audio file paths to always be treated as unknown commands
instead of being loaded as multimodal attachments.

Mirror the Show() call, pull-on-404 fallback, cloud auth handling,
and MultiModal/Think population from RunHandler into
launchInteractiveModel.

Fixes #15711
2026-04-21 14:37:36 -04:00
Jesse Gross 22d6c817f8 mlxrunner: fuse top-P and top-K into a single sort pass
When both filters are active, avoid paying for a full sort in top-P
and a partial sort in top-K. Single-filter paths are unchanged.
Improves generation throughput on gemma4:e4b by 1.5%.
2026-04-20 17:43:00 -07:00
Jesse Gross ca01373b28 mlxrunner: use MaxAxis in the min-P sampler
One reduction op instead of Argmax + TakeAlongAxis.
2026-04-20 17:43:00 -07:00
Jesse Gross 24e038d56a mlxrunner: add logprobs support
Match the ollamarunner and OpenAI semantics: raw, full-vocab log-softmax
with the top-K ranked by probability. Skipped on the GPU when the request
doesn't ask for logprobs so decode doesn't pay for it otherwise.
2026-04-20 17:43:00 -07:00
Parth Sareen 5d1021603a server: apply format when think=false for gemma4 (#15678) 2026-04-20 17:42:29 -07:00
Parth Sareen 8e05d734b9 launch: add kimi cli integration with installer flow (#15723) 2026-04-20 15:33:32 -07:00
Jesse Gross 05e0f21bec mlx: fuse sigmoid router head in glm4_moe_lite
DeepSeek-V2-style aux-loss-free routing computes sigmoid(gates) once but
needs it twice: the raw sigmoid output is gathered after top-k, while the
post-bias negation is the argpartition key. Fuse into a single multi-output
Compiled kernel returning both, saving two launches on the routing path
per token. Exposed as a general SigmoidRouter since the same pattern is
shared across DeepSeek-V2 descendants.

Improves glm4.7 generation performance by approximately 1%.
2026-04-20 15:02:14 -07:00
Daniel Hiltgen ff23dd343f mlx: apply repeat penalties in sampler (#15631) 2026-04-18 07:49:38 -07:00
Parth Sareen 123b300af6 docs: update hermes (#15655) 2026-04-17 14:20:59 -07:00
Parth Sareen 57653b8e42 cmd/launch: show WSL guidance on Windows instead of handing off (#15637) 2026-04-16 17:18:04 -07:00
Parth Sareen a50ce61c54 launch: skip unchanged managed-single rewrite (#15633) 2026-04-16 16:20:42 -07:00
Daniel Hiltgen 2bb7ea00d2 create: avoid gc race with create (#15628)
If you have a long running create, and start another ollama server with the
same model dir, the GC algorithm deletes the pending blobs and breaks the
create.  This adds a 1h grace period to avoid deleting in-flight creation
operations.
2026-04-16 13:29:16 -07:00
Daniel Hiltgen 55fa80d07a mlx: additional gemma4 cache fixes (#15607)
Harden additional corner cases
2026-04-16 13:07:19 -07:00
Daniel Hiltgen b9cb535407 mlx: fix gemma4 cache to use logical view (#15617) 2026-04-16 11:54:30 -07:00
Daniel Hiltgen 031baef094 mlx: fix imagegen lookup (#15588)
* mlx: fix imagegen lookup

Fixes #15533 - imagegen had fallen out of sync with the new layout
for multiple mlx libraries on Metal.

* review comments
2026-04-16 10:39:00 -07:00
7d271e6dc9 cmd/launch: add Copilot CLI integration (#15583)
---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: ParthSareen <parth.sareen@ollama.com>
2026-04-15 17:22:53 -07:00
Devon Rifkin c88dae2d6b Merge pull request #15612 from ollama/drifkin/gemma4-split-templates
gemma4: render differently based on model size
2026-04-15 17:15:35 -07:00
Devon Rifkin 9e3618d663 make empty block conditional 2026-04-15 15:35:25 -07:00
Devon Rifkin e585ecd11f gemma4: render differently based on model size
Following up on #15560, this change now has e2b/e4b render differently
from 26b/31b.

For backwards compatibility, we take the existing renderer name `gemma4`
and make it do dynamic resolution based on the model name/size, but the
intended use is for the models to be republished with the renderer
variant specified explicitly: `gemma4-small` or `gemma4-large`.
2026-04-15 14:37:16 -07:00
129 changed files with 17458 additions and 2265 deletions

No files matched your search

+2 -2
View File
@@ -55,7 +55,7 @@ The official [Ollama Docker image](https://hub.docker.com/r/ollama/ollama) `olla
ollama
```
You'll be prompted to run a model or connect Ollama to your existing agents or applications such as `claude`, `codex`, `openclaw` and more.
You'll be prompted to run a model or connect Ollama to your existing agents or applications such as `Claude Code`, `OpenClaw`, `OpenCode` , `Codex`, `Copilot`, and more.
### Coding
@@ -65,7 +65,7 @@ To launch a specific integration:
ollama launch claude
```
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
### AI assistant
+7 -7
View File
@@ -1080,7 +1080,7 @@ func DefaultOptions() Options {
}
}
// ThinkValue represents a value that can be a boolean or a string ("high", "medium", "low")
// ThinkValue represents a value that can be a boolean or a string ("high", "medium", "low", "max")
type ThinkValue struct {
// Value can be a bool or string
Value interface{}
@@ -1096,7 +1096,7 @@ func (t *ThinkValue) IsValid() bool {
case bool:
return true
case string:
return v == "high" || v == "medium" || v == "low"
return v == "high" || v == "medium" || v == "low" || v == "max"
default:
return false
}
@@ -1130,8 +1130,8 @@ func (t *ThinkValue) Bool() bool {
case bool:
return v
case string:
// Any string value ("high", "medium", "low") means thinking is enabled
return v == "high" || v == "medium" || v == "low"
// Any string value ("high", "medium", "low", "max") means thinking is enabled
return v == "high" || v == "medium" || v == "low" || v == "max"
default:
return false
}
@@ -1169,14 +1169,14 @@ func (t *ThinkValue) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
// Validate string values
if s != "high" && s != "medium" && s != "low" {
return fmt.Errorf("invalid think value: %q (must be \"high\", \"medium\", \"low\", true, or false)", s)
if s != "high" && s != "medium" && s != "low" && s != "max" {
return fmt.Errorf("invalid think value: %q (must be \"high\", \"medium\", \"low\", \"max\", true, or false)", s)
}
t.Value = s
return nil
}
return fmt.Errorf("think must be a boolean or string (\"high\", \"medium\", \"low\", true, or false)")
return fmt.Errorf("think must be a boolean or string (\"high\", \"medium\", \"low\", \"max\", true, or false)")
}
// MarshalJSON implements json.Marshaler
+5
View File
@@ -495,6 +495,11 @@ func TestThinking_UnmarshalJSON(t *testing.T) {
input: `{ "think": "low" }`,
expectedThinking: &ThinkValue{Value: "low"},
},
{
name: "string_max",
input: `{ "think": "max" }`,
expectedThinking: &ThinkValue{Value: "max"},
},
{
name: "invalid_string",
input: `{ "think": "invalid" }`,
+5 -5
View File
@@ -381,7 +381,7 @@ export const useSendMessage = (chatId: string) => {
role: "assistant",
content: "",
thinking: "",
model: effectiveModel,
model: effectiveModel.model,
}),
);
lastMessage = newMessages[newMessages.length - 1];
@@ -433,7 +433,7 @@ export const useSendMessage = (chatId: string) => {
role: "assistant",
content: "",
thinking: "",
model: effectiveModel,
model: effectiveModel.model,
}),
);
lastMessage = newMessages[newMessages.length - 1];
@@ -520,7 +520,7 @@ export const useSendMessage = (chatId: string) => {
thinkingTimeStart:
lastMessage.thinkingTimeStart || event.thinkingTimeStart,
thinkingTimeEnd: event.thinkingTimeEnd,
model: selectedModel,
model: selectedModel.model,
});
newMessages[newMessages.length - 1] = updatedMessage;
} else {
@@ -533,7 +533,7 @@ export const useSendMessage = (chatId: string) => {
tool_calls: event.toolCalls,
thinkingTimeStart: event.thinkingTimeStart,
thinkingTimeEnd: event.thinkingTimeEnd,
model: selectedModel,
model: selectedModel.model,
}),
);
}
@@ -699,7 +699,7 @@ export const useSendMessage = (chatId: string) => {
queryClient.setQueryData(["chat", newId], {
chat: new Chat({
id: newId,
model: effectiveModel,
model: effectiveModel.model,
messages: [
new Message({
role: "user",
+57 -4
View File
@@ -582,10 +582,10 @@ func RunHandler(cmd *cobra.Command, args []string) error {
opts.Think = &api.ThinkValue{Value: true}
case "false":
opts.Think = &api.ThinkValue{Value: false}
case "high", "medium", "low":
case "high", "medium", "low", "max":
opts.Think = &api.ThinkValue{Value: thinkStr}
default:
return fmt.Errorf("invalid value for --think: %q (must be true, false, high, medium, or low)", thinkStr)
return fmt.Errorf("invalid value for --think: %q (must be true, false, high, medium, low, or max)", thinkStr)
}
} else {
opts.Think = nil
@@ -1975,8 +1975,61 @@ func launchInteractiveModel(cmd *cobra.Command, modelName string) error {
Options: map[string]any{},
ShowConnect: true,
}
// loadOrUnloadModel is cloud-safe here: remote/cloud models skip local preload
// and only validate auth/connectivity before interactive chat starts.
client, err := api.ClientFromEnvironment()
if err != nil {
return err
}
requestedCloud := modelref.HasExplicitCloudSource(modelName)
info, err := func() (*api.ShowResponse, error) {
showReq := &api.ShowRequest{Name: modelName}
info, err := client.Show(cmd.Context(), showReq)
var se api.StatusError
if errors.As(err, &se) && se.StatusCode == http.StatusNotFound {
if requestedCloud {
return nil, err
}
if err := PullHandler(cmd, []string{modelName}); err != nil {
return nil, err
}
return client.Show(cmd.Context(), &api.ShowRequest{Name: modelName})
}
return info, err
}()
if err != nil {
if handleCloudAuthorizationError(err) {
return nil
}
return err
}
ensureCloudStub(cmd.Context(), client, modelName)
opts.Think, err = inferThinkingOption(&info.Capabilities, &opts, false)
if err != nil {
return err
}
audioCapable := slices.Contains(info.Capabilities, model.CapabilityAudio)
opts.MultiModal = slices.Contains(info.Capabilities, model.CapabilityVision) || audioCapable
// TODO: remove the projector info and vision info checks below,
// these are left in for backwards compatibility with older servers
// that don't have the capabilities field in the model info
if len(info.ProjectorInfo) != 0 {
opts.MultiModal = true
}
for k := range info.ModelInfo {
if strings.Contains(k, ".vision.") {
opts.MultiModal = true
break
}
}
applyShowResponseToRunOptions(&opts, info)
if err := loadOrUnloadModel(cmd, &opts); err != nil {
return fmt.Errorf("error loading model: %w", err)
}
+3
View File
@@ -61,6 +61,9 @@ func TestLaunchCmd(t *testing.T) {
if !strings.Contains(cmd.Long, "hermes") {
t.Error("Long description should mention hermes")
}
if !strings.Contains(cmd.Long, "kimi") {
t.Error("Long description should mention kimi")
}
})
t.Run("flags exist", func(t *testing.T) {
+76
View File
@@ -0,0 +1,76 @@
package launch
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"github.com/ollama/ollama/envconfig"
)
// Copilot implements Runner for GitHub Copilot CLI integration.
type Copilot struct{}
func (c *Copilot) String() string { return "Copilot CLI" }
func (c *Copilot) args(model string, extra []string) []string {
var args []string
if model != "" {
args = append(args, "--model", model)
}
args = append(args, extra...)
return args
}
func (c *Copilot) findPath() (string, error) {
if p, err := exec.LookPath("copilot"); err == nil {
return p, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
name := "copilot"
if runtime.GOOS == "windows" {
name = "copilot.exe"
}
fallback := filepath.Join(home, ".local", "bin", name)
if _, err := os.Stat(fallback); err != nil {
return "", err
}
return fallback, nil
}
func (c *Copilot) Run(model string, args []string) error {
copilotPath, err := c.findPath()
if err != nil {
return fmt.Errorf("copilot is not installed, install from https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli")
}
cmd := exec.Command(copilotPath, c.args(model, args)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), c.envVars(model)...)
return cmd.Run()
}
// envVars returns the environment variables that configure Copilot CLI
// to use Ollama as its model provider.
func (c *Copilot) envVars(model string) []string {
env := []string{
"COPILOT_PROVIDER_BASE_URL=" + envconfig.Host().String() + "/v1",
"COPILOT_PROVIDER_API_KEY=",
"COPILOT_PROVIDER_WIRE_API=responses",
}
if model != "" {
env = append(env, "COPILOT_MODEL="+model)
}
return env
}
+161
View File
@@ -0,0 +1,161 @@
package launch
import (
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
)
func TestCopilotIntegration(t *testing.T) {
c := &Copilot{}
t.Run("String", func(t *testing.T) {
if got := c.String(); got != "Copilot CLI" {
t.Errorf("String() = %q, want %q", got, "Copilot CLI")
}
})
t.Run("implements Runner", func(t *testing.T) {
var _ Runner = c
})
}
func TestCopilotFindPath(t *testing.T) {
c := &Copilot{}
t.Run("finds copilot in PATH", func(t *testing.T) {
tmpDir := t.TempDir()
name := "copilot"
if runtime.GOOS == "windows" {
name = "copilot.exe"
}
fakeBin := filepath.Join(tmpDir, name)
os.WriteFile(fakeBin, []byte("#!/bin/sh\n"), 0o755)
t.Setenv("PATH", tmpDir)
got, err := c.findPath()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != fakeBin {
t.Errorf("findPath() = %q, want %q", got, fakeBin)
}
})
t.Run("returns error when not in PATH", func(t *testing.T) {
t.Setenv("PATH", t.TempDir()) // empty dir, no copilot binary
_, err := c.findPath()
if err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("falls back to ~/.local/bin/copilot", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", t.TempDir()) // empty dir, no copilot binary
name := "copilot"
if runtime.GOOS == "windows" {
name = "copilot.exe"
}
fallback := filepath.Join(tmpDir, ".local", "bin", name)
os.MkdirAll(filepath.Dir(fallback), 0o755)
os.WriteFile(fallback, []byte("#!/bin/sh\n"), 0o755)
got, err := c.findPath()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != fallback {
t.Errorf("findPath() = %q, want %q", got, fallback)
}
})
t.Run("returns error when neither PATH nor fallback exists", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", t.TempDir()) // empty dir, no copilot binary
_, err := c.findPath()
if err == nil {
t.Fatal("expected error, got nil")
}
})
}
func TestCopilotArgs(t *testing.T) {
c := &Copilot{}
tests := []struct {
name string
model string
args []string
want []string
}{
{"with model", "llama3.2", nil, []string{"--model", "llama3.2"}},
{"empty model", "", nil, nil},
{"with model and extra", "llama3.2", []string{"--verbose"}, []string{"--model", "llama3.2", "--verbose"}},
{"empty model with help", "", []string{"--help"}, []string{"--help"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := c.args(tt.model, tt.args)
if !slices.Equal(got, tt.want) {
t.Errorf("args(%q, %v) = %v, want %v", tt.model, tt.args, got, tt.want)
}
})
}
}
func TestCopilotEnvVars(t *testing.T) {
c := &Copilot{}
envMap := func(envs []string) map[string]string {
m := make(map[string]string)
for _, e := range envs {
k, v, _ := strings.Cut(e, "=")
m[k] = v
}
return m
}
t.Run("sets required provider env vars with model", func(t *testing.T) {
got := envMap(c.envVars("llama3.2"))
if got["COPILOT_PROVIDER_BASE_URL"] == "" {
t.Error("COPILOT_PROVIDER_BASE_URL should be set")
}
if !strings.HasSuffix(got["COPILOT_PROVIDER_BASE_URL"], "/v1") {
t.Errorf("COPILOT_PROVIDER_BASE_URL = %q, want /v1 suffix", got["COPILOT_PROVIDER_BASE_URL"])
}
if _, ok := got["COPILOT_PROVIDER_API_KEY"]; !ok {
t.Error("COPILOT_PROVIDER_API_KEY should be set (empty)")
}
if got["COPILOT_PROVIDER_WIRE_API"] != "responses" {
t.Errorf("COPILOT_PROVIDER_WIRE_API = %q, want %q", got["COPILOT_PROVIDER_WIRE_API"], "responses")
}
if got["COPILOT_MODEL"] != "llama3.2" {
t.Errorf("COPILOT_MODEL = %q, want %q", got["COPILOT_MODEL"], "llama3.2")
}
})
t.Run("omits COPILOT_MODEL when model is empty", func(t *testing.T) {
got := envMap(c.envVars(""))
if _, ok := got["COPILOT_MODEL"]; ok {
t.Errorf("COPILOT_MODEL should not be set for empty model, got %q", got["COPILOT_MODEL"])
}
})
t.Run("uses custom OLLAMA_HOST", func(t *testing.T) {
t.Setenv("OLLAMA_HOST", "http://myhost:9999")
got := envMap(c.envVars("test"))
if !strings.Contains(got["COPILOT_PROVIDER_BASE_URL"], "myhost:9999") {
t.Errorf("COPILOT_PROVIDER_BASE_URL = %q, want custom host", got["COPILOT_PROVIDER_BASE_URL"])
}
})
}
+33 -316
View File
@@ -4,18 +4,15 @@ import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
pathpkg "path"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
@@ -66,23 +63,13 @@ var hermesMessagingEnvGroups = [][]string{
// switching UX after startup.
type Hermes struct{}
type hermesConfigBackend struct {
displayPath string
read func() ([]byte, error)
write func([]byte) error
}
func (h *Hermes) String() string { return "Hermes Agent" }
func (h *Hermes) Run(_ string, args []string) error {
// Hermes reads its primary model from config.yaml. launch configures that
// default model ahead of time so we can keep runtime invocation simple and
// still let Hermes discover additional models later via its own UX.
if hermesGOOS == "windows" {
return h.runWindows(args)
}
bin, err := h.findUnixBinary()
bin, err := h.binary()
if err != nil {
return err
}
@@ -95,21 +82,21 @@ func (h *Hermes) Run(_ string, args []string) error {
}
func (h *Hermes) Paths() []string {
backend, err := h.configBackend()
configPath, err := hermesConfigPath()
if err != nil {
return nil
}
return []string{backend.displayPath}
return []string{configPath}
}
func (h *Hermes) Configure(model string) error {
backend, err := h.configBackend()
configPath, err := hermesConfigPath()
if err != nil {
return err
}
cfg := map[string]any{}
if data, err := backend.read(); err == nil {
if data, err := os.ReadFile(configPath); err == nil {
if err := yaml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parse hermes config: %w", err)
}
@@ -142,15 +129,18 @@ func (h *Hermes) Configure(model string) error {
if err != nil {
return err
}
return backend.write(data)
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return err
}
return fileutil.WriteWithBackup(configPath, data)
}
func (h *Hermes) CurrentModel() string {
backend, err := h.configBackend()
configPath, err := hermesConfigPath()
if err != nil {
return ""
}
data, err := backend.read()
data, err := os.ReadFile(configPath)
if err != nil {
return ""
}
@@ -188,14 +178,7 @@ func (h *Hermes) RefreshRuntimeAfterConfigure() error {
}
func (h *Hermes) installed() bool {
if hermesGOOS == "windows" {
if _, err := hermesLookPath("hermes"); err == nil {
return true
}
return h.wslHasHermes()
}
_, err := h.findUnixBinary()
_, err := h.binary()
return err == nil
}
@@ -205,7 +188,7 @@ func (h *Hermes) ensureInstalled() error {
}
if hermesGOOS == "windows" {
return h.ensureInstalledWindows()
return hermesWindowsHint()
}
var missing []string
@@ -239,42 +222,6 @@ func (h *Hermes) ensureInstalled() error {
return nil
}
func (h *Hermes) ensureInstalledWindows() error {
// Hermes upstream support is WSL-oriented, so Windows launch uses a hybrid
// WSL handoff that stays on the same install path as upstream Hermes.
if _, err := hermesLookPath("hermes"); err == nil {
return nil
}
if !h.wslAvailable() {
return hermesWindowsHint(fmt.Errorf("hermes is not installed"))
}
if h.wslHasHermes() {
return nil
}
ok, err := ConfirmPromptWithOptions("Hermes runs through WSL2 on Windows. Install it in WSL now?", ConfirmOptions{
YesLabel: "Use WSL",
NoLabel: "Show manual steps",
})
if err != nil {
return err
}
if !ok {
return hermesWindowsHint(fmt.Errorf("hermes is not installed"))
}
fmt.Fprintf(os.Stderr, "\nInstalling Hermes in WSL...\n")
if err := h.runWSL("bash", "-lc", hermesInstallScript); err != nil {
return hermesWindowsHint(fmt.Errorf("failed to install hermes in WSL: %w", err))
}
if !h.wslHasHermes() {
return hermesWindowsHint(fmt.Errorf("hermes install finished but the WSL binary was not found"))
}
fmt.Fprintf(os.Stderr, "%sHermes installed successfully in WSL%s\n\n", ansiGreen, ansiReset)
return nil
}
func (h *Hermes) listModels(defaultModel string) []string {
client := hermesOllamaClient()
resp, err := client.List(context.Background())
@@ -306,11 +253,15 @@ func (h *Hermes) listModels(defaultModel string) []string {
return models
}
func (h *Hermes) findUnixBinary() (string, error) {
func (h *Hermes) binary() (string, error) {
if path, err := hermesLookPath("hermes"); err == nil {
return path, nil
}
if hermesGOOS == "windows" {
return "", hermesWindowsHint()
}
home, err := hermesUserHome()
if err != nil {
return "", err
@@ -323,70 +274,6 @@ func (h *Hermes) findUnixBinary() (string, error) {
return "", fmt.Errorf("hermes is not installed")
}
func (h *Hermes) runWindows(args []string) error {
if path, err := hermesLookPath("hermes"); err == nil {
if err := h.runGatewaySetupPreflight(args, func() error {
return hermesAttachedCommand(path, "gateway", "setup").Run()
}); err != nil {
return err
}
return hermesAttachedCommand(path, args...).Run()
}
if !h.wslAvailable() {
return hermesWindowsHint(fmt.Errorf("hermes is not installed"))
}
if err := h.runGatewaySetupPreflight(args, func() error {
return h.runWSL("hermes", "gateway", "setup")
}); err != nil {
return err
}
if err := h.runWSL(append([]string{"hermes"}, args...)...); err != nil {
return hermesWindowsHint(err)
}
return nil
}
func (h *Hermes) runWSL(args ...string) error {
if !h.wslAvailable() {
return fmt.Errorf("wsl.exe is not available")
}
return hermesAttachedCommand("wsl.exe", "bash", "-lc", shellQuoteArgs(args)).Run()
}
func (h *Hermes) runWSLCombinedOutput(args ...string) ([]byte, error) {
if !h.wslAvailable() {
return nil, fmt.Errorf("wsl.exe is not available")
}
return hermesCommand("wsl.exe", "bash", "-lc", shellQuoteArgs(args)).CombinedOutput()
}
func (h *Hermes) wslAvailable() bool {
_, err := hermesLookPath("wsl.exe")
return err == nil
}
func (h *Hermes) wslHasHermes() bool {
if !h.wslAvailable() {
return false
}
cmd := hermesCommand("wsl.exe", "bash", "-lc", "command -v hermes >/dev/null 2>&1")
return cmd.Run() == nil
}
func (h *Hermes) configBackend() (*hermesConfigBackend, error) {
if hermesGOOS == "windows" {
if _, err := hermesLookPath("hermes"); err == nil {
return hermesLocalConfigBackend()
}
if h.wslAvailable() {
return h.wslConfigBackend()
}
}
return hermesLocalConfigBackend()
}
func hermesConfigPath() (string, error) {
home, err := hermesUserHome()
if err != nil {
@@ -395,110 +282,6 @@ func hermesConfigPath() (string, error) {
return filepath.Join(home, ".hermes", "config.yaml"), nil
}
func hermesLocalConfigBackend() (*hermesConfigBackend, error) {
configPath, err := hermesConfigPath()
if err != nil {
return nil, err
}
return &hermesConfigBackend{
displayPath: configPath,
read: func() ([]byte, error) {
return os.ReadFile(configPath)
},
write: func(data []byte) error {
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return err
}
return fileutil.WriteWithBackup(configPath, data)
},
}, nil
}
func (h *Hermes) wslConfigBackend() (*hermesConfigBackend, error) {
home, err := h.wslHome()
if err != nil {
return nil, err
}
configPath := pathpkg.Join(home, ".hermes", "config.yaml")
return &hermesConfigBackend{
displayPath: configPath,
read: func() ([]byte, error) {
return h.readWSLFile(configPath)
},
write: func(data []byte) error {
return h.writeWSLConfig(configPath, data)
},
}, nil
}
func (h *Hermes) wslHome() (string, error) {
if !h.wslAvailable() {
return "", fmt.Errorf("wsl.exe is not available")
}
cmd := hermesCommand("wsl.exe", "bash", "-lc", `printf %s "$HOME"`)
out, err := cmd.Output()
if err != nil {
return "", err
}
home := strings.TrimSpace(string(out))
if home == "" {
return "", fmt.Errorf("could not resolve WSL home directory")
}
return home, nil
}
func (h *Hermes) readWSLFile(path string) ([]byte, error) {
pathArg := shellQuoteArgs([]string{path})
cmd := hermesCommand("wsl.exe", "bash", "-lc", fmt.Sprintf("if [ -f %s ]; then cat %s; else exit 42; fi", pathArg, pathArg))
out, err := cmd.Output()
if err == nil {
return out, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 42 {
return nil, os.ErrNotExist
}
return nil, err
}
func (h *Hermes) writeWSLConfig(path string, data []byte) error {
if existing, err := h.readWSLFile(path); err == nil {
if !bytes.Equal(existing, data) {
if err := hermesBackupData(path, existing); err != nil {
return fmt.Errorf("backup failed: %w", err)
}
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("read existing file: %w", err)
}
dir := pathpkg.Dir(path)
dirArg := shellQuoteArgs([]string{dir})
pathArg := shellQuoteArgs([]string{path})
script := fmt.Sprintf(
"dir=%s; path=%s; mkdir -p \"$dir\" && tmp=$(mktemp \"$dir/.tmp-XXXXXX\") && cat > \"$tmp\" && mv \"$tmp\" \"$path\"",
dirArg,
pathArg,
)
cmd := hermesCommand("wsl.exe", "bash", "-lc", script)
cmd.Stdin = bytes.NewReader(data)
if out, err := cmd.CombinedOutput(); err != nil {
if msg := strings.TrimSpace(string(out)); msg != "" {
return fmt.Errorf("%w: %s", err, msg)
}
return err
}
return nil
}
func hermesBackupData(path string, data []byte) error {
if err := os.MkdirAll(fileutil.BackupDir(), 0o755); err != nil {
return err
}
backupPath := filepath.Join(fileutil.BackupDir(), fmt.Sprintf("%s.%d", filepath.Base(path), time.Now().Unix()))
return os.WriteFile(backupPath, data, 0o644)
}
func hermesBaseURL() string {
return strings.TrimRight(hermesOllamaURL().String(), "/") + "/v1"
}
@@ -554,8 +337,11 @@ func (h *Hermes) messagingConfigured() bool {
func (h *Hermes) gatewayEnvVars() (map[string]string, error) {
envVars := make(map[string]string)
data, err := h.readGatewayEnvFile()
switch {
envFilePath, err := hermesEnvPath()
if err != nil {
return nil, err
}
switch data, err := os.ReadFile(envFilePath); {
case err == nil:
for key, value := range hermesParseEnvFile(data) {
envVars[key] = value
@@ -566,12 +352,10 @@ func (h *Hermes) gatewayEnvVars() (map[string]string, error) {
return nil, err
}
if h.usesLocalRuntimeEnv() {
for _, group := range hermesMessagingEnvGroups {
for _, key := range group {
if value, ok := os.LookupEnv(key); ok {
envVars[key] = value
}
for _, group := range hermesMessagingEnvGroups {
for _, key := range group {
if value, ok := os.LookupEnv(key); ok {
envVars[key] = value
}
}
}
@@ -579,39 +363,6 @@ func (h *Hermes) gatewayEnvVars() (map[string]string, error) {
return envVars, nil
}
func (h *Hermes) readGatewayEnvFile() ([]byte, error) {
if hermesGOOS == "windows" {
if _, err := hermesLookPath("hermes"); err == nil {
path, err := hermesEnvPath()
if err != nil {
return nil, err
}
return os.ReadFile(path)
}
if h.wslAvailable() {
home, err := h.wslHome()
if err != nil {
return nil, err
}
return h.readWSLFile(pathpkg.Join(home, ".hermes", ".env"))
}
}
path, err := hermesEnvPath()
if err != nil {
return nil, err
}
return os.ReadFile(path)
}
func (h *Hermes) usesLocalRuntimeEnv() bool {
if hermesGOOS != "windows" {
return true
}
_, err := hermesLookPath("hermes")
return err == nil
}
func (h *Hermes) gatewayRunning() (bool, error) {
status, err := h.gatewayStatusOutput()
if err != nil {
@@ -621,19 +372,7 @@ func (h *Hermes) gatewayRunning() (bool, error) {
}
func (h *Hermes) gatewayStatusOutput() (string, error) {
if hermesGOOS == "windows" {
if path, err := hermesLookPath("hermes"); err == nil {
out, err := hermesCommand(path, "gateway", "status").CombinedOutput()
return string(out), err
}
if !h.wslAvailable() {
return "", hermesWindowsHint(fmt.Errorf("hermes is not installed"))
}
out, err := h.runWSLCombinedOutput("hermes", "gateway", "status")
return string(out), err
}
bin, err := h.findUnixBinary()
bin, err := h.binary()
if err != nil {
return "", err
}
@@ -642,20 +381,7 @@ func (h *Hermes) gatewayStatusOutput() (string, error) {
}
func (h *Hermes) restartGateway() error {
if hermesGOOS == "windows" {
if path, err := hermesLookPath("hermes"); err == nil {
return hermesAttachedCommand(path, "gateway", "restart").Run()
}
if !h.wslAvailable() {
return hermesWindowsHint(fmt.Errorf("hermes is not installed"))
}
if err := h.runWSL("hermes", "gateway", "restart"); err != nil {
return hermesWindowsHint(err)
}
return nil
}
bin, err := h.findUnixBinary()
bin, err := h.binary()
if err != nil {
return err
}
@@ -938,14 +664,6 @@ func mergeHermesToolsets(current any) any {
}
}
func shellQuoteArgs(args []string) string {
quoted := make([]string, 0, len(args))
for _, arg := range args {
quoted = append(quoted, "'"+strings.ReplaceAll(arg, "'", `'\''`)+"'")
}
return strings.Join(quoted, " ")
}
func hermesAttachedCommand(name string, args ...string) *exec.Cmd {
cmd := hermesCommand(name, args...)
cmd.Stdin = os.Stdin
@@ -954,9 +672,8 @@ func hermesAttachedCommand(name string, args ...string) *exec.Cmd {
return cmd
}
func hermesWindowsHint(err error) error {
if hermesGOOS != "windows" {
return err
}
return fmt.Errorf("%w\n\nHermes runs on Windows through WSL2.\nQuick setup: wsl --install\nInstaller docs: https://hermes-agent.nousresearch.com/docs/getting-started/installation/", err)
func hermesWindowsHint() error {
return fmt.Errorf("Hermes on Windows requires WSL2. Install WSL with: wsl --install\n" +
"Then run 'ollama launch hermes' from inside your WSL shell.\n" +
"Docs: https://hermes-agent.nousresearch.com/docs/getting-started/installation/")
}
+11 -137
View File
@@ -896,64 +896,6 @@ fi
}
}
func TestHermesRefreshRuntimeAfterConfigure_WindowsWSLRestartsRunningGateway(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell test binaries to simulate WSL")
}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withHermesPlatform(t, "windows")
t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
wslPath := filepath.Join(tmpDir, "wsl.exe")
wslScript := `#!/bin/sh
printf '[%s]\n' "$*" >> "$HOME/wsl-invocations.log"
exec /bin/sh -lc "$3"
`
if err := os.WriteFile(wslPath, []byte(wslScript), 0o755); err != nil {
t.Fatal(err)
}
hermesBin := filepath.Join(tmpDir, "hermes")
hermesScript := `#!/bin/sh
printf '[%s]\n' "$*" >> "$HOME/hermes-invocations.log"
if [ "$1" = "gateway" ] && [ "$2" = "status" ]; then
printf '✓ Gateway is running (PID: 321)\n'
fi
`
if err := os.WriteFile(hermesBin, []byte(hermesScript), 0o755); err != nil {
t.Fatal(err)
}
withHermesLookPath(t, func(file string) (string, error) {
if file == "wsl.exe" {
return wslPath, nil
}
return "", os.ErrNotExist
})
h := &Hermes{}
if err := h.RefreshRuntimeAfterConfigure(); err != nil {
t.Fatalf("RefreshRuntimeAfterConfigure returned error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "hermes-invocations.log"))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) != 2 {
t.Fatalf("expected WSL status then restart invocations, got %v", lines)
}
if lines[0] != "[gateway status]" {
t.Fatalf("expected WSL gateway status first, got %q", lines[0])
}
if lines[1] != "[gateway restart]" {
t.Fatalf("expected WSL gateway restart second, got %q", lines[1])
}
}
func TestHermesMessagingConfiguredRecognizesSupportedGatewayVars(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -1002,82 +944,7 @@ func TestHermesMessagingConfiguredRecognizesSupportedGatewayVars(t *testing.T) {
}
}
func TestHermesRunWindowsWSL_UsesGatewaySetupPreflight(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell test binaries to simulate WSL")
}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withLauncherHooks(t)
withInteractiveSession(t, true)
withHermesPlatform(t, "windows")
clearHermesMessagingEnvVars(t)
t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
wslPath := filepath.Join(tmpDir, "wsl.exe")
wslScript := `#!/bin/sh
printf '[%s]\n' "$*" >> "$HOME/wsl-invocations.log"
exec /bin/sh -lc "$3"
`
if err := os.WriteFile(wslPath, []byte(wslScript), 0o755); err != nil {
t.Fatal(err)
}
hermesBin := filepath.Join(tmpDir, "hermes")
hermesScript := `#!/bin/sh
printf '[%s]\n' "$*" >> "$HOME/hermes-invocations.log"
if [ "$1" = "gateway" ] && [ "$2" = "setup" ]; then
/bin/mkdir -p "$HOME/.hermes"
printf 'TELEGRAM_BOT_TOKEN=configured\n' > "$HOME/.hermes/.env"
fi
`
if err := os.WriteFile(hermesBin, []byte(hermesScript), 0o755); err != nil {
t.Fatal(err)
}
withHermesLookPath(t, func(file string) (string, error) {
if file == "wsl.exe" {
return wslPath, nil
}
return "", os.ErrNotExist
})
promptCount := 0
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
promptCount++
if prompt != hermesGatewaySetupTitle {
t.Fatalf("unexpected prompt %q", prompt)
}
return true, nil
}
h := &Hermes{}
if err := h.Run("", nil); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if promptCount != 1 {
t.Fatalf("expected one messaging prompt, got %d", promptCount)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "hermes-invocations.log"))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) != 2 {
t.Fatalf("expected WSL hermes to run setup then launch, got %v", lines)
}
if lines[0] != "[gateway setup]" {
t.Fatalf("expected WSL gateway setup first, got %q", lines[0])
}
if lines[1] != "[]" {
t.Fatalf("expected WSL default hermes launch second, got %q", lines[1])
}
}
func TestHermesEnsureInstalledWindowsWithoutWSLGivesGuidance(t *testing.T) {
func TestHermesEnsureInstalledWindowsShowsWSLGuidance(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withHermesPlatform(t, "windows")
@@ -1086,10 +953,17 @@ func TestHermesEnsureInstalledWindowsWithoutWSLGivesGuidance(t *testing.T) {
h := &Hermes{}
err := h.ensureInstalled()
if err == nil {
t.Fatal("expected missing WSL guidance error")
t.Fatal("expected WSL guidance error")
}
if !strings.Contains(err.Error(), "wsl --install") {
t.Fatalf("expected WSL guidance, got %v", err)
msg := err.Error()
if !strings.Contains(msg, "wsl --install") {
t.Fatalf("expected install command in guidance, got %v", err)
}
if !strings.Contains(msg, "hermes-agent.nousresearch.com") {
t.Fatalf("expected docs link in guidance, got %v", err)
}
if strings.Contains(msg, "hermes is not installed") {
t.Fatalf("guidance should not lead with 'hermes is not installed', got %v", err)
}
}
+88 -25
View File
@@ -54,6 +54,7 @@ func TestIntegrationLookup(t *testing.T) {
{"claude uppercase", "CLAUDE", true, "Claude Code"},
{"claude mixed case", "Claude", true, "Claude Code"},
{"codex", "codex", true, "Codex"},
{"kimi", "kimi", true, "Kimi Code CLI"},
{"droid", "droid", true, "Droid"},
{"opencode", "opencode", true, "OpenCode"},
{"unknown integration", "unknown", false, ""},
@@ -74,8 +75,7 @@ func TestIntegrationLookup(t *testing.T) {
}
func TestIntegrationRegistry(t *testing.T) {
expectedIntegrations := []string{"claude", "codex", "droid", "opencode", "hermes"}
expectedIntegrations := []string{"claude", "codex", "kimi", "droid", "opencode", "hermes", "pool"}
for _, name := range expectedIntegrations {
t.Run(name, func(t *testing.T) {
r, ok := integrations[name]
@@ -89,6 +89,15 @@ func TestIntegrationRegistry(t *testing.T) {
}
}
func TestHiddenIntegrationsExcludedFromVisibleLists(t *testing.T) {
for _, info := range ListIntegrationInfos() {
switch info.Name {
case "cline", "vscode", "kimi":
t.Fatalf("hidden integration %q should not appear in ListIntegrationInfos", info.Name)
}
}
}
func TestHasLocalModel(t *testing.T) {
tests := []struct {
name string
@@ -291,7 +300,7 @@ func TestParseArgs(t *testing.T) {
func TestIsCloudModel(t *testing.T) {
// isCloudModel now only uses Show API, so nil client always returns false
t.Run("nil client returns false", func(t *testing.T) {
models := []string{"glm-5.1:cloud", "kimi-k2.5:cloud", "local-model"}
models := []string{"glm-5.1:cloud", "kimi-k2.6:cloud", "local-model"}
for _, model := range models {
if isCloudModel(context.Background(), nil, model) {
t.Errorf("isCloudModel(%q) with nil client should return false", model)
@@ -308,10 +317,18 @@ func names(items []ModelItem) []string {
return out
}
func recommendedNames(extra ...string) []string {
out := make([]string, 0, len(recommendedModels)+len(extra))
for _, item := range recommendedModels {
out = append(out, item.Name)
}
return append(out, extra...)
}
func TestBuildModelList_NoExistingModels(t *testing.T) {
items, _, _, _ := buildModelList(nil, nil, "")
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5"}
want := recommendedNames()
if diff := cmp.Diff(want, names(items)); diff != "" {
t.Errorf("with no existing models, items should be recommended in order (-want +got):\n%s", diff)
}
@@ -340,7 +357,7 @@ func TestBuildModelList_OnlyLocalModels_CloudRecsStillFirst(t *testing.T) {
// Cloud recs always come first among recommended, regardless of installed inventory.
// Cloud disablement is handled upstream in loadSelectableModels via filterCloudItems.
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2", "qwen2.5"}
want := recommendedNames("llama3.2", "qwen2.5")
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("cloud recs pinned first even when no cloud models installed (-want +got):\n%s", diff)
}
@@ -356,13 +373,13 @@ func TestBuildModelList_BothCloudAndLocal_RegularSort(t *testing.T) {
got := names(items)
// All recs pinned at top (cloud before local in mixed case), then non-recs
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2"}
want := recommendedNames("llama3.2")
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("recs pinned at top, cloud recs first in mixed case (-want +got):\n%s", diff)
}
}
func TestBuildModelList_PreCheckedFirst(t *testing.T) {
func TestBuildModelList_PreCheckedNonRecommendedFirstInMore(t *testing.T) {
existing := []modelInfo{
{Name: "llama3.2:latest", Remote: false},
{Name: "glm-5.1:cloud", Remote: true},
@@ -371,8 +388,9 @@ func TestBuildModelList_PreCheckedFirst(t *testing.T) {
items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "")
got := names(items)
if got[0] != "llama3.2" {
t.Errorf("pre-checked model should be first, got %v", got)
want := recommendedNames("llama3.2")
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("recommended block should stay fixed while checked non-recommended models lead More (-want +got):\n%s", diff)
}
}
@@ -427,7 +445,7 @@ func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) {
if !strings.HasSuffix(item.Description, "(not downloaded)") {
t.Errorf("non-installed recommended %q should have '(not downloaded)' suffix, got %q", item.Name, item.Description)
}
case "minimax-m2.7:cloud", "kimi-k2.5:cloud", "qwen3.5:cloud":
case "minimax-m2.7:cloud", "kimi-k2.6:cloud", "qwen3.5:cloud":
if strings.HasSuffix(item.Description, "(not downloaded)") {
t.Errorf("cloud model %q should not have '(not downloaded)' suffix, got %q", item.Name, item.Description)
}
@@ -445,9 +463,9 @@ func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) {
got := names(items)
// gemma4 and glm-5.1:cloud are installed so they sort normally;
// kimi-k2.5:cloud, qwen3.5:cloud, and qwen3.5 are not installed so they go to the bottom
// qwen3.5:cloud and qwen3.5 are not installed so they go to the bottom
// All recs: cloud first in mixed case, then local, in rec order within each
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5"}
want := recommendedNames()
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("all recs, cloud first in mixed case (-want +got):\n%s", diff)
}
@@ -456,23 +474,23 @@ func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) {
func TestBuildModelList_HasRecommendedCloudModel_OnlyNonInstalledAtBottom(t *testing.T) {
existing := []modelInfo{
{Name: "llama3.2:latest", Remote: false},
{Name: "kimi-k2.5:cloud", Remote: true},
{Name: "kimi-k2.6:cloud", Remote: true},
}
items, _, _, _ := buildModelList(existing, nil, "")
got := names(items)
// kimi-k2.5:cloud is installed so it sorts normally;
// kimi-k2.6:cloud is installed so it sorts normally;
// the rest of the recommendations are not installed so they go to the bottom
// All recs pinned at top (cloud first in mixed case), then non-recs
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2"}
want := recommendedNames("llama3.2")
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("recs pinned at top, cloud first in mixed case (-want +got):\n%s", diff)
}
for _, item := range items {
isCloud := strings.HasSuffix(item.Name, ":cloud")
isInstalled := slices.Contains([]string{"kimi-k2.5:cloud", "llama3.2"}, item.Name)
isInstalled := slices.Contains([]string{"kimi-k2.6:cloud", "llama3.2"}, item.Name)
if isInstalled || isCloud {
if strings.HasSuffix(item.Description, "(not downloaded)") {
t.Errorf("installed or cloud model %q should not have '(not downloaded)' suffix, got %q", item.Name, item.Description)
@@ -539,8 +557,8 @@ func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) {
if !cloudModels["glm-5.1:cloud"] {
t.Error("glm-5.1:cloud should be in cloudModels")
}
if !cloudModels["kimi-k2.5:cloud"] {
t.Error("kimi-k2.5:cloud should be in cloudModels (recommended cloud)")
if !cloudModels["kimi-k2.6:cloud"] {
t.Error("kimi-k2.6:cloud should be in cloudModels (recommended cloud)")
}
if !cloudModels["qwen3.5:cloud"] {
t.Error("qwen3.5:cloud should be in cloudModels (recommended cloud)")
@@ -560,7 +578,7 @@ func TestBuildModelList_RecommendedFieldSet(t *testing.T) {
for _, item := range items {
switch item.Name {
case "gemma4", "qwen3.5", "glm-5.1:cloud", "kimi-k2.5:cloud", "qwen3.5:cloud":
case "gemma4", "qwen3.5", "glm-5.1:cloud", "kimi-k2.6:cloud", "qwen3.5:cloud":
if !item.Recommended {
t.Errorf("%q should have Recommended=true", item.Name)
}
@@ -618,7 +636,7 @@ func TestBuildModelList_RecsAboveNonRecs(t *testing.T) {
lastRecIdx := -1
firstNonRecIdx := len(got)
for i, name := range got {
isRec := name == "gemma4" || name == "qwen3.5" || name == "minimax-m2.7:cloud" || name == "glm-5.1:cloud" || name == "kimi-k2.5:cloud" || name == "qwen3.5:cloud"
isRec := name == "gemma4" || name == "qwen3.5" || name == "minimax-m2.7:cloud" || name == "glm-5.1:cloud" || name == "kimi-k2.6:cloud" || name == "qwen3.5:cloud"
if isRec && i > lastRecIdx {
lastRecIdx = i
}
@@ -631,17 +649,32 @@ func TestBuildModelList_RecsAboveNonRecs(t *testing.T) {
}
}
func TestBuildModelList_CheckedBeforeRecs(t *testing.T) {
func TestBuildModelList_CheckedRecommendedDoesNotReshuffleRecommendedOrder(t *testing.T) {
existing := []modelInfo{
{Name: "llama3.2:latest", Remote: false},
{Name: "glm-5.1:cloud", Remote: true},
}
items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "")
items, _, _, _ := buildModelList(existing, []string{"qwen3.5:cloud", "glm-5.1:cloud"}, "")
got := names(items)
if got[0] != "llama3.2" {
t.Errorf("checked model should be first even before recs, got %v", got)
want := recommendedNames("llama3.2")
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("checked recommended models should not reshuffle the fixed recommended order (-want +got):\n%s", diff)
}
}
func TestBuildModelList_StaleSavedKimiK25DoesNotReshuffleRecommendedOrder(t *testing.T) {
existing := []modelInfo{
{Name: "kimi-k2.5:cloud", Remote: true},
}
items, _, _, _ := buildModelList(existing, []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud"}, "kimi-k2.5:cloud")
got := names(items)
want := recommendedNames("kimi-k2.5:cloud")
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("stale saved kimi-k2.5 should stay in More without reshuffling the fixed recommended order (-want +got):\n%s", diff)
}
}
@@ -1460,6 +1493,11 @@ func TestIntegration_InstallHint(t *testing.T) {
input: "openclaw",
wantURL: "https://docs.openclaw.ai",
},
{
name: "pool has hint",
input: "pool",
wantURL: "https://github.com/poolsideai/pool",
},
{
name: "unknown has no hint",
input: "unknown",
@@ -1532,7 +1570,7 @@ func TestListIntegrationInfos(t *testing.T) {
})
t.Run("includes known integrations", func(t *testing.T) {
known := map[string]bool{"claude": false, "codex": false, "opencode": false}
known := map[string]bool{"claude": false, "codex": false, "opencode": false, "pool": false}
for _, info := range infos {
if _, ok := known[info.Name]; ok {
known[info.Name] = true
@@ -1567,6 +1605,17 @@ func TestListIntegrationInfos(t *testing.T) {
}
})
}
func TestListIntegrationInfos_HidesPoolsideOnWindows(t *testing.T) {
prev := poolsideGOOS
poolsideGOOS = "windows"
t.Cleanup(func() { poolsideGOOS = prev })
for _, info := range ListIntegrationInfos() {
if info.Name == "pool" {
t.Fatal("expected pool to be hidden on Windows")
}
}
}
func TestBuildModelList_Descriptions(t *testing.T) {
t.Run("installed recommended has base description", func(t *testing.T) {
@@ -1673,6 +1722,20 @@ func TestIntegration_AutoInstallable(t *testing.T) {
}
}
func TestEnsureIntegrationInstalled_PoolsideUnsupportedOnWindows(t *testing.T) {
prev := poolsideGOOS
poolsideGOOS = "windows"
t.Cleanup(func() { poolsideGOOS = prev })
err := EnsureIntegrationInstalled("pool", &Poolside{})
if err == nil {
t.Fatal("expected Windows unsupported error")
}
if !strings.Contains(err.Error(), "not currently supported on Windows") {
t.Fatalf("expected Windows warning, got %v", err)
}
}
func TestIntegrationModels(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
+315
View File
@@ -0,0 +1,315 @@
package launch
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/envconfig"
)
// Kimi implements Runner for Kimi Code CLI integration.
type Kimi struct{}
const (
kimiDefaultModelAlias = "ollama"
kimiDefaultMaxContextSize = 32768
)
var (
kimiGOOS = runtime.GOOS
kimiModelShowTimeout = 5 * time.Second
)
func (k *Kimi) String() string { return "Kimi Code CLI" }
func (k *Kimi) args(config string, extra []string) []string {
args := []string{"--config", config}
args = append(args, extra...)
return args
}
func (k *Kimi) Run(model string, args []string) error {
if strings.TrimSpace(model) == "" {
return fmt.Errorf("model is required")
}
if err := validateKimiPassthroughArgs(args); err != nil {
return err
}
config, err := buildKimiInlineConfig(model, resolveKimiMaxContextSize(model))
if err != nil {
return fmt.Errorf("failed to build kimi config: %w", err)
}
bin, err := ensureKimiInstalled()
if err != nil {
return err
}
cmd := exec.Command(bin, k.args(config, args)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func findKimiBinary() (string, error) {
if path, err := exec.LookPath("kimi"); err == nil {
return path, nil
}
home, _ := os.UserHomeDir()
var candidates []string
switch kimiGOOS {
case "windows":
candidates = appendWindowsKimiCandidates(candidates, filepath.Join(home, ".local", "bin"))
candidates = appendWindowsKimiCandidates(candidates, filepath.Join(home, "bin"))
if appData := strings.TrimSpace(os.Getenv("APPDATA")); appData != "" {
candidates = appendWindowsKimiCandidates(candidates, filepath.Join(appData, "uv", "bin"))
}
if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" {
candidates = appendWindowsKimiCandidates(candidates, filepath.Join(localAppData, "uv", "bin"))
}
default:
candidates = append(candidates,
filepath.Join(home, ".local", "bin", "kimi"),
filepath.Join(home, "bin", "kimi"),
filepath.Join(home, ".local", "share", "uv", "tools", "kimi-cli", "bin", "kimi"),
filepath.Join(home, ".local", "share", "uv", "tools", "kimi", "bin", "kimi"),
)
if xdgDataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); xdgDataHome != "" {
candidates = append(candidates,
filepath.Join(xdgDataHome, "uv", "tools", "kimi-cli", "bin", "kimi"),
filepath.Join(xdgDataHome, "uv", "tools", "kimi", "bin", "kimi"),
)
}
// WSL users can inherit Windows env vars while launching from Linux shells.
if profile := windowsPathToWSL(os.Getenv("USERPROFILE")); profile != "" {
candidates = appendWindowsKimiCandidates(candidates, filepath.Join(profile, ".local", "bin"))
}
if appData := windowsPathToWSL(os.Getenv("APPDATA")); appData != "" {
candidates = appendWindowsKimiCandidates(candidates, filepath.Join(appData, "uv", "bin"))
}
if localAppData := windowsPathToWSL(os.Getenv("LOCALAPPDATA")); localAppData != "" {
candidates = appendWindowsKimiCandidates(candidates, filepath.Join(localAppData, "uv", "bin"))
}
}
for _, candidate := range candidates {
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
}
return "", fmt.Errorf("kimi binary not found")
}
func appendWindowsKimiCandidates(candidates []string, dir string) []string {
if strings.TrimSpace(dir) == "" {
return candidates
}
return append(candidates,
filepath.Join(dir, "kimi.exe"),
filepath.Join(dir, "kimi.cmd"),
filepath.Join(dir, "kimi.bat"),
)
}
func windowsPathToWSL(path string) string {
trimmed := strings.TrimSpace(path)
if len(trimmed) < 3 || trimmed[1] != ':' {
return ""
}
drive := strings.ToLower(string(trimmed[0]))
rest := strings.ReplaceAll(trimmed[2:], "\\", "/")
rest = strings.TrimPrefix(rest, "/")
if rest == "" {
return filepath.Join("/mnt", drive)
}
return filepath.Join("/mnt", drive, rest)
}
func validateKimiPassthroughArgs(args []string) error {
for _, arg := range args {
switch {
case arg == "--config", strings.HasPrefix(arg, "--config="):
return fmt.Errorf("conflicting extra argument %q: ollama launch kimi manages --config", arg)
case arg == "--config-file", strings.HasPrefix(arg, "--config-file="):
return fmt.Errorf("conflicting extra argument %q: ollama launch kimi manages --config-file", arg)
case arg == "--model", strings.HasPrefix(arg, "--model="):
return fmt.Errorf("conflicting extra argument %q: ollama launch kimi manages --model", arg)
case arg == "-m", strings.HasPrefix(arg, "-m="):
return fmt.Errorf("conflicting extra argument %q: ollama launch kimi manages -m/--model", arg)
}
}
return nil
}
func buildKimiInlineConfig(model string, maxContextSize int) (string, error) {
cfg := map[string]any{
"default_model": kimiDefaultModelAlias,
"providers": map[string]any{
kimiDefaultModelAlias: map[string]any{
"type": "openai_legacy",
"base_url": envconfig.ConnectableHost().String() + "/v1",
"api_key": "ollama",
},
},
"models": map[string]any{
kimiDefaultModelAlias: map[string]any{
"provider": kimiDefaultModelAlias,
"model": model,
"max_context_size": maxContextSize,
},
},
}
data, err := json.Marshal(cfg)
if err != nil {
return "", err
}
return string(data), nil
}
func resolveKimiMaxContextSize(model string) int {
if l, ok := lookupCloudModelLimit(model); ok {
return l.Context
}
client, err := api.ClientFromEnvironment()
if err != nil {
return kimiDefaultMaxContextSize
}
ctx, cancel := context.WithTimeout(context.Background(), kimiModelShowTimeout)
defer cancel()
resp, err := client.Show(ctx, &api.ShowRequest{Model: model})
if err != nil {
return kimiDefaultMaxContextSize
}
if n, ok := modelInfoContextLength(resp.ModelInfo); ok {
return n
}
return kimiDefaultMaxContextSize
}
func modelInfoContextLength(modelInfo map[string]any) (int, bool) {
for key, val := range modelInfo {
if !strings.HasSuffix(key, ".context_length") {
continue
}
switch v := val.(type) {
case float64:
if v > 0 {
return int(v), true
}
case int:
if v > 0 {
return v, true
}
case int64:
if v > 0 {
return int(v), true
}
}
}
return 0, false
}
func ensureKimiInstalled() (string, error) {
if path, err := findKimiBinary(); err == nil {
return path, nil
}
if err := checkKimiInstallerDependencies(); err != nil {
return "", err
}
ok, err := ConfirmPrompt("Kimi is not installed. Install now?")
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("kimi installation cancelled")
}
bin, args, err := kimiInstallerCommand(kimiGOOS)
if err != nil {
return "", err
}
fmt.Fprintf(os.Stderr, "\nInstalling Kimi...\n")
cmd := exec.Command(bin, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to install kimi: %w", err)
}
path, err := findKimiBinary()
if err != nil {
return "", fmt.Errorf("kimi was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
}
fmt.Fprintf(os.Stderr, "%sKimi installed successfully%s\n\n", ansiGreen, ansiReset)
return path, nil
}
func checkKimiInstallerDependencies() error {
switch kimiGOOS {
case "windows":
if _, err := exec.LookPath("powershell"); err != nil {
return fmt.Errorf("kimi is not installed and required dependencies are missing\n\nInstall the following first:\n PowerShell: https://learn.microsoft.com/powershell/\n\nThen re-run:\n ollama launch kimi")
}
default:
var missing []string
if _, err := exec.LookPath("curl"); err != nil {
missing = append(missing, "curl: https://curl.se/")
}
if _, err := exec.LookPath("bash"); err != nil {
missing = append(missing, "bash: https://www.gnu.org/software/bash/")
}
if len(missing) > 0 {
return fmt.Errorf("kimi is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch kimi", strings.Join(missing, "\n "))
}
}
return nil
}
func kimiInstallerCommand(goos string) (string, []string, error) {
switch goos {
case "windows":
return "powershell", []string{
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
"Invoke-RestMethod https://code.kimi.com/install.ps1 | Invoke-Expression",
}, nil
case "darwin", "linux":
return "bash", []string{
"-c",
"curl -LsSf https://code.kimi.com/install.sh | bash",
}, nil
default:
return "", nil, fmt.Errorf("unsupported platform for kimi install: %s", goos)
}
}
+636
View File
@@ -0,0 +1,636 @@
package launch
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
)
func assertKimiBinPath(t *testing.T, bin string) {
t.Helper()
base := strings.ToLower(filepath.Base(bin))
if !strings.HasPrefix(base, "kimi") {
t.Fatalf("bin = %q, want path to kimi executable", bin)
}
}
func TestKimiIntegration(t *testing.T) {
k := &Kimi{}
t.Run("String", func(t *testing.T) {
if got := k.String(); got != "Kimi Code CLI" {
t.Errorf("String() = %q, want %q", got, "Kimi Code CLI")
}
})
t.Run("implements Runner", func(t *testing.T) {
var _ Runner = k
})
}
func TestKimiArgs(t *testing.T) {
k := &Kimi{}
got := k.args(`{"foo":"bar"}`, []string{"--quiet", "--print"})
want := []string{"--config", `{"foo":"bar"}`, "--quiet", "--print"}
if !slices.Equal(got, want) {
t.Fatalf("args() = %v, want %v", got, want)
}
}
func TestWindowsPathToWSL(t *testing.T) {
tests := []struct {
name string
in string
want string
valid bool
}{
{
name: "user profile path",
in: `C:\Users\parth`,
want: filepath.Join("/mnt", "c", "Users", "parth"),
valid: true,
},
{
name: "path with trailing slash",
in: `D:\tools\bin\`,
want: filepath.Join("/mnt", "d", "tools", "bin"),
valid: true,
},
{
name: "non windows path",
in: "/home/parth",
valid: false,
},
{
name: "empty",
in: "",
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := windowsPathToWSL(tt.in)
if !tt.valid {
if got != "" {
t.Fatalf("windowsPathToWSL(%q) = %q, want empty", tt.in, got)
}
return
}
if got != tt.want {
t.Fatalf("windowsPathToWSL(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestFindKimiBinaryFallbacks(t *testing.T) {
oldGOOS := kimiGOOS
t.Cleanup(func() { kimiGOOS = oldGOOS })
t.Run("linux/ubuntu uv tool path", func(t *testing.T) {
homeDir := t.TempDir()
setTestHome(t, homeDir)
t.Setenv("PATH", t.TempDir())
kimiGOOS = "linux"
target := filepath.Join(homeDir, ".local", "share", "uv", "tools", "kimi-cli", "bin", "kimi")
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatalf("failed to create candidate dir: %v", err)
}
if err := os.WriteFile(target, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("failed to write kimi candidate: %v", err)
}
got, err := findKimiBinary()
if err != nil {
t.Fatalf("findKimiBinary() error = %v", err)
}
if got != target {
t.Fatalf("findKimiBinary() = %q, want %q", got, target)
}
})
t.Run("windows appdata uv bin", func(t *testing.T) {
setTestHome(t, t.TempDir())
t.Setenv("PATH", t.TempDir())
kimiGOOS = "windows"
appDataDir := t.TempDir()
t.Setenv("APPDATA", appDataDir)
t.Setenv("LOCALAPPDATA", "")
target := filepath.Join(appDataDir, "uv", "bin", "kimi.cmd")
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatalf("failed to create candidate dir: %v", err)
}
if err := os.WriteFile(target, []byte("@echo off\r\nexit /b 0\r\n"), 0o755); err != nil {
t.Fatalf("failed to write kimi candidate: %v", err)
}
got, err := findKimiBinary()
if err != nil {
t.Fatalf("findKimiBinary() error = %v", err)
}
if got != target {
t.Fatalf("findKimiBinary() = %q, want %q", got, target)
}
})
}
func TestValidateKimiPassthroughArgs_RejectsConflicts(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "--config", args: []string{"--config", "{}"}, want: "--config"},
{name: "--config=", args: []string{"--config={}"}, want: "--config={"},
{name: "--config-file", args: []string{"--config-file", "x.toml"}, want: "--config-file"},
{name: "--config-file=", args: []string{"--config-file=x.toml"}, want: "--config-file=x.toml"},
{name: "--model", args: []string{"--model", "foo"}, want: "--model"},
{name: "--model=", args: []string{"--model=foo"}, want: "--model=foo"},
{name: "-m", args: []string{"-m", "foo"}, want: "-m"},
{name: "-m=", args: []string{"-m=foo"}, want: "-m=foo"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateKimiPassthroughArgs(tt.args)
if err == nil {
t.Fatalf("expected error for args %v", tt.args)
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error %q does not contain %q", err.Error(), tt.want)
}
})
}
}
func TestBuildKimiInlineConfig(t *testing.T) {
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
cfg, err := buildKimiInlineConfig("llama3.2", 65536)
if err != nil {
t.Fatalf("buildKimiInlineConfig() error = %v", err)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(cfg), &parsed); err != nil {
t.Fatalf("config is not valid JSON: %v", err)
}
if parsed["default_model"] != "ollama" {
t.Fatalf("default_model = %v, want ollama", parsed["default_model"])
}
providers, ok := parsed["providers"].(map[string]any)
if !ok {
t.Fatalf("providers missing or wrong type: %T", parsed["providers"])
}
ollamaProvider, ok := providers["ollama"].(map[string]any)
if !ok {
t.Fatalf("providers.ollama missing or wrong type: %T", providers["ollama"])
}
if ollamaProvider["type"] != "openai_legacy" {
t.Fatalf("provider type = %v, want openai_legacy", ollamaProvider["type"])
}
if ollamaProvider["base_url"] != "http://127.0.0.1:11434/v1" {
t.Fatalf("provider base_url = %v, want http://127.0.0.1:11434/v1", ollamaProvider["base_url"])
}
if ollamaProvider["api_key"] != "ollama" {
t.Fatalf("provider api_key = %v, want ollama", ollamaProvider["api_key"])
}
models, ok := parsed["models"].(map[string]any)
if !ok {
t.Fatalf("models missing or wrong type: %T", parsed["models"])
}
ollamaModel, ok := models["ollama"].(map[string]any)
if !ok {
t.Fatalf("models.ollama missing or wrong type: %T", models["ollama"])
}
if ollamaModel["provider"] != "ollama" {
t.Fatalf("model provider = %v, want ollama", ollamaModel["provider"])
}
if ollamaModel["model"] != "llama3.2" {
t.Fatalf("model model = %v, want llama3.2", ollamaModel["model"])
}
if ollamaModel["max_context_size"] != float64(65536) {
t.Fatalf("model max_context_size = %v, want 65536", ollamaModel["max_context_size"])
}
}
func TestBuildKimiInlineConfig_UsesConnectableHostForUnspecifiedBind(t *testing.T) {
t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434")
cfg, err := buildKimiInlineConfig("llama3.2", 65536)
if err != nil {
t.Fatalf("buildKimiInlineConfig() error = %v", err)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(cfg), &parsed); err != nil {
t.Fatalf("config is not valid JSON: %v", err)
}
providers, ok := parsed["providers"].(map[string]any)
if !ok {
t.Fatalf("providers missing or wrong type: %T", parsed["providers"])
}
ollamaProvider, ok := providers["ollama"].(map[string]any)
if !ok {
t.Fatalf("providers.ollama missing or wrong type: %T", providers["ollama"])
}
if got, _ := ollamaProvider["base_url"].(string); got != "http://127.0.0.1:11434/v1" {
t.Fatalf("provider base_url = %q, want %q", got, "http://127.0.0.1:11434/v1")
}
}
func TestResolveKimiMaxContextSize(t *testing.T) {
t.Run("uses cloud limit when known", func(t *testing.T) {
got := resolveKimiMaxContextSize("kimi-k2.5:cloud")
if got != 262_144 {
t.Fatalf("resolveKimiMaxContextSize() = %d, want 262144", got)
}
})
t.Run("uses model show context length for local models", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/show" {
http.NotFound(w, r)
return
}
fmt.Fprint(w, `{"model_info":{"llama.context_length":131072}}`)
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
got := resolveKimiMaxContextSize("llama3.2")
if got != 131_072 {
t.Fatalf("resolveKimiMaxContextSize() = %d, want 131072", got)
}
})
t.Run("falls back to default when show fails", func(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
oldTimeout := kimiModelShowTimeout
kimiModelShowTimeout = 100 * 1000 * 1000 // 100ms
t.Cleanup(func() { kimiModelShowTimeout = oldTimeout })
got := resolveKimiMaxContextSize("llama3.2")
if got != kimiDefaultMaxContextSize {
t.Fatalf("resolveKimiMaxContextSize() = %d, want %d", got, kimiDefaultMaxContextSize)
}
})
}
func TestKimiRun_RejectsConflictingArgsBeforeInstall(t *testing.T) {
k := &Kimi{}
oldConfirm := DefaultConfirmPrompt
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
t.Fatalf("did not expect install prompt, got %q", prompt)
return false, nil
}
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
err := k.Run("llama3.2", []string{"--model", "other"})
if err == nil || !strings.Contains(err.Error(), "--model") {
t.Fatalf("expected conflict error mentioning --model, got %v", err)
}
}
func TestKimiRun_PassesInlineConfigAndExtraArgs(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binary")
}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
logPath := filepath.Join(tmpDir, "kimi-args.log")
script := fmt.Sprintf(`#!/bin/sh
for arg in "$@"; do
printf "%%s\n" "$arg" >> %q
done
exit 0
`, logPath)
if err := os.WriteFile(filepath.Join(tmpDir, "kimi"), []byte(script), 0o755); err != nil {
t.Fatalf("failed to write fake kimi: %v", err)
}
t.Setenv("PATH", tmpDir)
srv := httptest.NewServer(http.NotFoundHandler())
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
k := &Kimi{}
if err := k.Run("llama3.2", []string{"--quiet", "--print"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("failed to read args log: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) < 4 {
t.Fatalf("expected at least 4 args, got %v", lines)
}
if lines[0] != "--config" {
t.Fatalf("first arg = %q, want --config", lines[0])
}
var cfg map[string]any
if err := json.Unmarshal([]byte(lines[1]), &cfg); err != nil {
t.Fatalf("config arg is not valid JSON: %v", err)
}
providers := cfg["providers"].(map[string]any)
ollamaProvider := providers["ollama"].(map[string]any)
if ollamaProvider["type"] != "openai_legacy" {
t.Fatalf("provider type = %v, want openai_legacy", ollamaProvider["type"])
}
if lines[2] != "--quiet" || lines[3] != "--print" {
t.Fatalf("extra args = %v, want [--quiet --print]", lines[2:])
}
}
func TestEnsureKimiInstalled(t *testing.T) {
oldGOOS := kimiGOOS
t.Cleanup(func() { kimiGOOS = oldGOOS })
withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) {
t.Helper()
oldConfirm := DefaultConfirmPrompt
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
return fn(prompt)
}
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
}
t.Run("already installed", func(t *testing.T) {
setTestHome(t, t.TempDir())
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
writeFakeBinary(t, tmpDir, "kimi")
kimiGOOS = runtime.GOOS
withConfirm(t, func(prompt string) (bool, error) {
t.Fatalf("did not expect prompt, got %q", prompt)
return false, nil
})
bin, err := ensureKimiInstalled()
if err != nil {
t.Fatalf("ensureKimiInstalled() error = %v", err)
}
assertKimiBinPath(t, bin)
})
t.Run("missing dependencies", func(t *testing.T) {
setTestHome(t, t.TempDir())
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
kimiGOOS = "linux"
withConfirm(t, func(prompt string) (bool, error) {
t.Fatalf("did not expect prompt, got %q", prompt)
return false, nil
})
_, err := ensureKimiInstalled()
if err == nil || !strings.Contains(err.Error(), "required dependencies are missing") {
t.Fatalf("expected missing dependency error, got %v", err)
}
})
t.Run("missing and user declines install", func(t *testing.T) {
setTestHome(t, t.TempDir())
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
writeFakeBinary(t, tmpDir, "curl")
writeFakeBinary(t, tmpDir, "bash")
kimiGOOS = "linux"
withConfirm(t, func(prompt string) (bool, error) {
if !strings.Contains(prompt, "Kimi is not installed.") {
t.Fatalf("unexpected prompt: %q", prompt)
}
return false, nil
})
_, err := ensureKimiInstalled()
if err == nil || !strings.Contains(err.Error(), "installation cancelled") {
t.Fatalf("expected cancellation error, got %v", err)
}
})
t.Run("missing and user confirms install succeeds", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binaries")
}
setTestHome(t, t.TempDir())
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
kimiGOOS = "linux"
writeFakeBinary(t, tmpDir, "curl")
installLog := filepath.Join(tmpDir, "bash.log")
kimiPath := filepath.Join(tmpDir, "kimi")
bashScript := fmt.Sprintf(`#!/bin/sh
echo "$@" >> %q
if [ "$1" = "-c" ]; then
/bin/cat > %q <<'EOS'
#!/bin/sh
exit 0
EOS
/bin/chmod +x %q
fi
exit 0
`, installLog, kimiPath, kimiPath)
if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte(bashScript), 0o755); err != nil {
t.Fatalf("failed to write fake bash: %v", err)
}
withConfirm(t, func(prompt string) (bool, error) {
return true, nil
})
bin, err := ensureKimiInstalled()
if err != nil {
t.Fatalf("ensureKimiInstalled() error = %v", err)
}
assertKimiBinPath(t, bin)
logData, err := os.ReadFile(installLog)
if err != nil {
t.Fatalf("failed to read install log: %v", err)
}
if !strings.Contains(string(logData), "https://code.kimi.com/install.sh") {
t.Fatalf("expected install.sh command in log, got:\n%s", string(logData))
}
})
t.Run("install succeeds and kimi is in home local bin without PATH update", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binaries")
}
homeDir := t.TempDir()
setTestHome(t, homeDir)
tmpBin := t.TempDir()
t.Setenv("PATH", tmpBin)
kimiGOOS = "linux"
writeFakeBinary(t, tmpBin, "curl")
installedKimi := filepath.Join(homeDir, ".local", "bin", "kimi")
bashScript := fmt.Sprintf(`#!/bin/sh
if [ "$1" = "-c" ]; then
/bin/mkdir -p %q
/bin/cat > %q <<'EOS'
#!/bin/sh
exit 0
EOS
/bin/chmod +x %q
fi
exit 0
`, filepath.Dir(installedKimi), installedKimi, installedKimi)
if err := os.WriteFile(filepath.Join(tmpBin, "bash"), []byte(bashScript), 0o755); err != nil {
t.Fatalf("failed to write fake bash: %v", err)
}
withConfirm(t, func(prompt string) (bool, error) {
return true, nil
})
bin, err := ensureKimiInstalled()
if err != nil {
t.Fatalf("ensureKimiInstalled() error = %v", err)
}
if bin != installedKimi {
t.Fatalf("bin = %q, want %q", bin, installedKimi)
}
})
t.Run("install command fails", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binaries")
}
setTestHome(t, t.TempDir())
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
kimiGOOS = "linux"
writeFakeBinary(t, tmpDir, "curl")
if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
t.Fatalf("failed to write fake bash: %v", err)
}
withConfirm(t, func(prompt string) (bool, error) {
return true, nil
})
_, err := ensureKimiInstalled()
if err == nil || !strings.Contains(err.Error(), "failed to install kimi") {
t.Fatalf("expected install failure error, got %v", err)
}
})
t.Run("install succeeds but binary missing on PATH", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binaries")
}
setTestHome(t, t.TempDir())
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
kimiGOOS = "linux"
writeFakeBinary(t, tmpDir, "curl")
if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("failed to write fake bash: %v", err)
}
withConfirm(t, func(prompt string) (bool, error) {
return true, nil
})
_, err := ensureKimiInstalled()
if err == nil || !strings.Contains(err.Error(), "binary was not found on PATH") {
t.Fatalf("expected PATH guidance error, got %v", err)
}
})
}
func TestKimiInstallerCommand(t *testing.T) {
tests := []struct {
name string
goos string
wantBin string
wantParts []string
wantErr bool
}{
{
name: "linux",
goos: "linux",
wantBin: "bash",
wantParts: []string{"-c", "install.sh"},
},
{
name: "darwin",
goos: "darwin",
wantBin: "bash",
wantParts: []string{"-c", "install.sh"},
},
{
name: "windows",
goos: "windows",
wantBin: "powershell",
wantParts: []string{"-Command", "install.ps1"},
},
{
name: "unsupported",
goos: "freebsd",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bin, args, err := kimiInstallerCommand(tt.goos)
if tt.wantErr {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil {
t.Fatalf("kimiInstallerCommand() error = %v", err)
}
if bin != tt.wantBin {
t.Fatalf("bin = %q, want %q", bin, tt.wantBin)
}
joined := strings.Join(args, " ")
for _, part := range tt.wantParts {
if !strings.Contains(joined, part) {
t.Fatalf("args %q missing %q", joined, part)
}
}
})
}
}
+4 -1
View File
@@ -206,11 +206,14 @@ Supported integrations:
claude Claude Code
cline Cline
codex Codex
copilot Copilot CLI (aliases: copilot-cli)
droid Droid
hermes Hermes Agent
kimi Kimi Code CLI
opencode OpenCode
openclaw OpenClaw (aliases: clawdbot, moltbot)
pi Pi
pool Poolside
vscode    VS Code (aliases: code)
Examples:
@@ -586,7 +589,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
return nil
}
if current == "" || needsConfigure || req.ModelOverride != "" || target != current {
if needsConfigure || req.ModelOverride != "" || (current != "" && target != current) || !savedMatchesModels(saved, []string{target}) {
if err := prepareManagedSingleIntegration(name, runner, managed, target); err != nil {
return err
}
+86 -22
View File
@@ -13,6 +13,7 @@ import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/cmd/config"
)
@@ -409,7 +410,7 @@ func TestLaunchIntegration_ManagedSingleIntegrationConfigOnlySkipsFinalRun(t *te
}
}
func TestLaunchIntegration_ManagedSingleIntegrationRepairsMissingLiveConfigUsingSavedModel(t *testing.T) {
func TestLaunchIntegration_ManagedSingleIntegrationSkipsRewriteWhenSavedMatches(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withInteractiveSession(t, true)
@@ -436,29 +437,30 @@ func TestLaunchIntegration_ManagedSingleIntegrationRepairsMissingLiveConfigUsing
withIntegrationOverride(t, "stubmanaged", runner)
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
t.Fatal("selector should not be called when saved model is reused for repair")
t.Fatal("selector should not be called when saved model matches target")
return "", nil
}
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
return true, nil
t.Fatal("confirm prompt should not run when saved model matches target")
return false, nil
}
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
t.Fatalf("expected missing live config to be rewritten from saved model: %s", diff)
if len(runner.configured) != 0 {
t.Fatalf("expected Configure to be skipped when saved matches, got %v", runner.configured)
}
if runner.refreshCalls != 1 {
t.Fatalf("expected repaired config to refresh runtime once, got %d", runner.refreshCalls)
if runner.refreshCalls != 0 {
t.Fatalf("expected no runtime refresh when config is unchanged, got %d", runner.refreshCalls)
}
if runner.ranModel != "gemma4" {
t.Fatalf("expected launch to use repaired saved model, got %q", runner.ranModel)
t.Fatalf("expected launch to run saved model, got %q", runner.ranModel)
}
}
func TestLaunchIntegration_ManagedSingleIntegrationConfigureOnlyRepairsMissingLiveConfig(t *testing.T) {
func TestLaunchIntegration_ManagedSingleIntegrationRewritesWhenSavedDiffers(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withInteractiveSession(t, true)
@@ -466,6 +468,60 @@ func TestLaunchIntegration_ManagedSingleIntegrationConfigureOnlyRepairsMissingLi
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"gemma4"}]}`)
case "/api/show":
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := config.SaveIntegration("stubmanaged", []string{"old-model"}); err != nil {
t.Fatalf("failed to save managed integration config: %v", err)
}
runner := &launcherManagedRunner{}
withIntegrationOverride(t, "stubmanaged", runner)
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
t.Fatal("selector should not be called when model override is provided")
return "", nil
}
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
return true, nil
}
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "stubmanaged",
ModelOverride: "gemma4",
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
t.Fatalf("expected Configure to run when saved differs from target: %s", diff)
}
if runner.refreshCalls != 1 {
t.Fatalf("expected runtime refresh once after configure, got %d", runner.refreshCalls)
}
if runner.ranModel != "gemma4" {
t.Fatalf("expected launch to run configured model, got %q", runner.ranModel)
}
}
func TestLaunchIntegration_ManagedSingleIntegrationRewritesWhenLiveConfigDrifts(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withInteractiveSession(t, true)
withLauncherHooks(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3:8b"}]}`)
case "/api/show":
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
default:
@@ -479,32 +535,39 @@ func TestLaunchIntegration_ManagedSingleIntegrationConfigureOnlyRepairsMissingLi
t.Fatalf("failed to save managed integration config: %v", err)
}
runner := &launcherManagedRunner{}
runner := &launcherManagedRunner{
currentModel: "qwen3:8b",
}
withIntegrationOverride(t, "stubmanaged", runner)
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
t.Fatal("selector should not be called when saved model is reused for repair")
t.Fatal("selector should not be called when live config already provides the target")
return "", nil
}
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
return true, nil
}
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "stubmanaged",
ConfigureOnly: true,
}); err != nil {
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
t.Fatalf("expected configure-only flow to rewrite missing live config: %s", diff)
if diff := compareStrings(runner.configured, []string{"qwen3:8b"}); diff != "" {
t.Fatalf("expected Configure to reconcile stale saved config to live target: %s", diff)
}
if runner.refreshCalls != 1 {
t.Fatalf("expected configure-only repair to refresh runtime once, got %d", runner.refreshCalls)
t.Fatalf("expected runtime refresh once after drift reconciliation, got %d", runner.refreshCalls)
}
if runner.ranModel != "" {
t.Fatalf("expected configure-only flow to skip final launch, got %q", runner.ranModel)
if runner.ranModel != "qwen3:8b" {
t.Fatalf("expected launch to run live configured model, got %q", runner.ranModel)
}
saved, err := config.LoadIntegration("stubmanaged")
if err != nil {
t.Fatalf("failed to reload managed integration config: %v", err)
}
if diff := compareStrings(saved.Models, []string{"qwen3:8b"}); diff != "" {
t.Fatalf("saved models mismatch after drift reconciliation: %s", diff)
}
}
@@ -1216,8 +1279,9 @@ func TestLaunchIntegration_EditorForceConfigure_FloatsCheckedModelsInPicker(t *t
if len(gotItems) == 0 {
t.Fatal("expected multi selector to receive items")
}
if gotItems[0] != "qwen3.5:cloud" {
t.Fatalf("expected checked models floated to top with qwen3.5:cloud first, got %v", gotItems)
wantItems := recommendedNames()
if diff := cmp.Diff(wantItems, gotItems); diff != "" {
t.Fatalf("expected fixed recommended order in selector items (-want +got):\n%s", diff)
}
if len(gotPreChecked) < 2 {
t.Fatalf("expected prechecked models to be preserved, got %v", gotPreChecked)
+10 -15
View File
@@ -21,7 +21,7 @@ import (
)
var recommendedModels = []ModelItem{
{Name: "kimi-k2.5:cloud", Description: "Multimodal reasoning with subagents", Recommended: true},
{Name: "kimi-k2.6:cloud", Description: "State-of-the-art coding, long-horizon execution, and multimodal agent swarm capability", Recommended: true},
{Name: "qwen3.5:cloud", Description: "Reasoning, coding, and agentic tool use with vision", Recommended: true},
{Name: "glm-5.1:cloud", Description: "Reasoning and code generation", Recommended: true},
{Name: "minimax-m2.7:cloud", Description: "Fast, efficient coding and real-world productivity", Recommended: true},
@@ -56,6 +56,7 @@ var cloudModelLimits = map[string]cloudModelLimit{
"gpt-oss:20b": {Context: 131_072, Output: 131_072},
"kimi-k2:1t": {Context: 262_144, Output: 262_144},
"kimi-k2.5": {Context: 262_144, Output: 262_144},
"kimi-k2.6": {Context: 262_144, Output: 262_144},
"kimi-k2-thinking": {Context: 262_144, Output: 262_144},
"nemotron-3-nano:30b": {Context: 1_048_576, Output: 131_072},
"qwen3-coder:480b": {Context: 262_144, Output: 65_536},
@@ -360,18 +361,12 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) (
}
if hasLocalModel || hasCloudModel {
// Keep the Recommended section pinned to recommendedModels order. Checked
// and default-model priority only apply within the More section.
slices.SortStableFunc(items, func(a, b ModelItem) int {
ac, bc := checked[a.Name], checked[b.Name]
aNew, bNew := notInstalled[a.Name], notInstalled[b.Name]
aRec, bRec := recRank[a.Name] > 0, recRank[b.Name] > 0
aCloud, bCloud := cloudModels[a.Name], cloudModels[b.Name]
if ac != bc {
if ac {
return -1
}
return 1
}
if aRec != bRec {
if aRec {
return -1
@@ -379,14 +374,14 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) (
return 1
}
if aRec && bRec {
if aCloud != bCloud {
if aCloud {
return -1
}
return 1
}
return recRank[a.Name] - recRank[b.Name]
}
if ac != bc {
if ac {
return -1
}
return 1
}
// Among checked non-recommended items - put the default first
if ac && !aRec && current != "" {
aCurrent := a.Name == current
+202 -182
View File
@@ -14,8 +14,6 @@ import (
"strings"
"time"
"golang.org/x/mod/semver"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
@@ -30,6 +28,8 @@ var openclawModelShowTimeout = 5 * time.Second
// openclawFreshInstall is set to true when ensureOpenclawInstalled performs an install
var openclawFreshInstall bool
var openclawCanInstallDaemon = canInstallDaemon
type Openclaw struct{}
func (c *Openclaw) String() string { return "OpenClaw" }
@@ -60,6 +60,7 @@ func (c *Openclaw) Run(model string, args []string) error {
// the newest wizard flags (e.g. --auth-choice ollama).
if !openclawFreshInstall {
update := exec.Command(bin, "update")
update.Env = openclawInstallEnv()
update.Stdout = os.Stdout
update.Stderr = os.Stderr
_ = update.Run() // best-effort; continue even if update fails
@@ -75,19 +76,18 @@ func (c *Openclaw) Run(model string, args []string) error {
"--auth-choice", "ollama",
"--custom-base-url", envconfig.Host().String(),
"--custom-model-id", model,
// Launch owns the first real gateway startup immediately after onboarding,
// so don't let OpenClaw fail the whole first-run flow on a transient
// daemon health probe.
"--skip-health",
"--skip-channels",
"--skip-skills",
}
if canInstallDaemon() {
if openclawCanInstallDaemon() {
onboardArgs = append(onboardArgs, "--install-daemon")
} else {
// When we can't install a daemon (e.g. no systemd, sudo dropped
// XDG_RUNTIME_DIR, or container environment), skip the gateway
// health check so non-interactive onboarding completes. The
// gateway is started as a foreground child process after onboarding.
onboardArgs = append(onboardArgs, "--skip-health")
}
cmd := exec.Command(bin, onboardArgs...)
cmd.Env = openclawInstallEnv()
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -98,13 +98,23 @@ func (c *Openclaw) Run(model string, args []string) error {
patchDeviceScopes()
}
if ensureWebSearchPlugin() {
registerWebSearchPlugin()
}
configureOllamaWebSearch()
// When extra args are passed through, run exactly what the user asked for
// after setup and skip the built-in gateway+TUI convenience flow.
if len(args) > 0 {
cleanup := func() {}
if shouldEnsureGatewayForArgs(args) {
cleanupFn, _, _, err := c.ensureGatewayReady(bin)
if err != nil {
return windowsHint(err)
}
if cleanupFn != nil {
cleanup = cleanupFn
}
}
defer cleanup()
cmd := exec.Command(bin, args...)
cmd.Env = openclawEnv()
cmd.Stdin = os.Stdin
@@ -125,41 +135,11 @@ func (c *Openclaw) Run(model string, args []string) error {
fmt.Fprintf(os.Stderr, "\n%sStarting your assistant — this may take a moment...%s\n\n", ansiGray, ansiReset)
token, port := c.gatewayInfo()
addr := fmt.Sprintf("localhost:%d", port)
// If the gateway is already running (e.g. via the daemon), restart it
// so it picks up any config changes (model, provider, etc.).
if portOpen(addr) {
restart := exec.Command(bin, "daemon", "restart")
restart.Env = openclawEnv()
if err := restart.Run(); err != nil {
fmt.Fprintf(os.Stderr, "%s Warning: daemon restart failed: %v%s\n", ansiYellow, err, ansiReset)
}
if !waitForPort(addr, 10*time.Second) {
fmt.Fprintf(os.Stderr, "%s Warning: gateway did not come back after restart%s\n", ansiYellow, ansiReset)
}
}
// If the gateway isn't running, start it as a background child process.
if !portOpen(addr) {
gw := exec.Command(bin, "gateway", "run", "--force")
gw.Env = openclawEnv()
if err := gw.Start(); err != nil {
return windowsHint(fmt.Errorf("failed to start gateway: %w", err))
}
defer func() {
if gw.Process != nil {
_ = gw.Process.Kill()
_ = gw.Wait()
}
}()
}
fmt.Fprintf(os.Stderr, "%sStarting gateway...%s\n", ansiGray, ansiReset)
if !waitForPort(addr, 30*time.Second) {
return windowsHint(fmt.Errorf("gateway did not start on %s", addr))
cleanup, token, port, err := c.ensureGatewayReady(bin)
if err != nil {
return windowsHint(err)
}
defer cleanup()
printOpenclawReady(bin, token, port, firstLaunch)
@@ -179,6 +159,66 @@ func (c *Openclaw) Run(model string, args []string) error {
return nil
}
func shouldEnsureGatewayForArgs(args []string) bool {
return len(args) > 0 && args[0] == "tui"
}
func (c *Openclaw) ensureGatewayReady(bin string) (func(), string, int, error) {
token, port := c.gatewayInfo()
addr := fmt.Sprintf("localhost:%d", port)
// If the gateway is already running (e.g. via the daemon), restart it
// so it picks up any config changes (model, provider, etc.).
if portOpen(addr) {
restart := exec.Command(bin, "daemon", "restart")
restart.Env = openclawEnv()
if err := restart.Run(); err != nil {
fmt.Fprintf(os.Stderr, "%s Warning: daemon restart failed: %v%s\n", ansiYellow, err, ansiReset)
}
if !waitForPort(addr, 10*time.Second) {
fmt.Fprintf(os.Stderr, "%s Warning: gateway did not come back after restart%s\n", ansiYellow, ansiReset)
}
}
// If the daemon is installed but not currently listening, try to bring it
// up before falling back to a foreground child process.
if openclawCanInstallDaemon() && !portOpen(addr) {
start := exec.Command(bin, "daemon", "start")
start.Env = openclawEnv()
if err := start.Run(); err != nil {
fmt.Fprintf(os.Stderr, "%s Warning: daemon start failed: %v%s\n", ansiYellow, err, ansiReset)
} else if waitForPort(addr, 10*time.Second) {
fmt.Fprintf(os.Stderr, "%sStarting gateway...%s\n", ansiGray, ansiReset)
return func() {}, token, port, nil
}
}
cleanup := func() {}
// If the gateway still isn't running, start it as a background child process.
if !portOpen(addr) {
gw := exec.Command(bin, "gateway", "run", "--force")
gw.Env = openclawEnv()
if err := gw.Start(); err != nil {
return nil, "", 0, fmt.Errorf("failed to start gateway: %w", err)
}
cleanup = func() {
if gw.Process != nil {
_ = gw.Process.Kill()
_ = gw.Wait()
}
}
}
fmt.Fprintf(os.Stderr, "%sStarting gateway...%s\n", ansiGray, ansiReset)
if !waitForPort(addr, 30*time.Second) {
cleanup()
return nil, "", 0, fmt.Errorf("gateway did not start on %s", addr)
}
return cleanup, token, port, nil
}
// runChannelSetupPreflight prompts users to connect a messaging channel before
// starting the built-in gateway+TUI flow. In interactive sessions, it loops
// until a channel is configured, unless the user chooses "Set up later".
@@ -339,9 +379,30 @@ func openclawEnv() []string {
env = append(env, e)
}
}
if _, ok := os.LookupEnv("OPENCLAW_PLUGIN_STAGE_DIR"); !ok {
if dir := openclawPluginStageDir(); dir != "" {
env = append(env, "OPENCLAW_PLUGIN_STAGE_DIR="+dir)
}
}
return env
}
func openclawInstallEnv() []string {
env := openclawEnv()
if _, ok := os.LookupEnv("OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"); !ok {
env = append(env, "OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS=1")
}
return env
}
func openclawPluginStageDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".openclaw", "plugin-runtime-deps")
}
// portOpen checks if a TCP port is currently accepting connections.
func portOpen(addr string) bool {
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
@@ -565,6 +626,7 @@ func ensureOpenclawInstalled() (string, error) {
fmt.Fprintf(os.Stderr, "\nInstalling OpenClaw...\n")
cmd := exec.Command("npm", "install", "-g", "openclaw@latest")
cmd.Env = openclawInstallEnv()
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -738,89 +800,13 @@ func clearSessionModelOverride(primary string) {
_ = os.WriteFile(path, out, 0o600)
}
const (
webSearchNpmPackage = "@ollama/openclaw-web-search"
webSearchMinVersion = "0.2.1"
)
// ensureWebSearchPlugin installs the openclaw-web-search extension into the
// user-level extensions directory (~/.openclaw/extensions/) if it isn't already
// present, or re-installs if the installed version is older than webSearchMinVersion.
// Returns true if the extension is available.
func ensureWebSearchPlugin() bool {
home, err := os.UserHomeDir()
if err != nil {
return false
}
pluginDir := filepath.Join(home, ".openclaw", "extensions", "openclaw-web-search")
if webSearchPluginUpToDate(pluginDir) {
return true
}
npmBin, err := exec.LookPath("npm")
if err != nil {
return false
}
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
return false
}
// Download the tarball via `npm pack`, extract it flat into the plugin dir.
pack := exec.Command(npmBin, "pack", webSearchNpmPackage, "--pack-destination", pluginDir)
out, err := pack.Output()
if err != nil {
fmt.Fprintf(os.Stderr, "%s Warning: could not download web search plugin: %v%s\n", ansiYellow, err, ansiReset)
return false
}
tgzName := strings.TrimSpace(string(out))
tgzPath := filepath.Join(pluginDir, tgzName)
defer os.Remove(tgzPath)
tar := exec.Command("tar", "xzf", tgzPath, "--strip-components=1", "-C", pluginDir)
if err := tar.Run(); err != nil {
fmt.Fprintf(os.Stderr, "%s Warning: could not extract web search plugin: %v%s\n", ansiYellow, err, ansiReset)
return false
}
fmt.Fprintf(os.Stderr, "%s ✓ Installed Ollama web search %s\n", ansiGreen, ansiReset)
return true
}
// webSearchPluginUpToDate returns true if the plugin is installed and its
// package.json version is >= webSearchMinVersion.
func webSearchPluginUpToDate(pluginDir string) bool {
data, err := os.ReadFile(filepath.Join(pluginDir, "package.json"))
if err != nil {
return false
}
var pkg struct {
Version string `json:"version"`
}
if json.Unmarshal(data, &pkg) != nil || pkg.Version == "" {
return false
}
return !versionLessThan(pkg.Version, webSearchMinVersion)
}
// versionLessThan compares two semver version strings (major.minor.patch).
// Inputs may omit the "v" prefix; it is added automatically for semver.Compare.
func versionLessThan(a, b string) bool {
if !strings.HasPrefix(a, "v") {
a = "v" + a
}
if !strings.HasPrefix(b, "v") {
b = "v" + b
}
return semver.Compare(a, b) < 0
}
// registerWebSearchPlugin adds plugins.entries.openclaw-web-search to the OpenClaw
// config so the gateway activates it on next start. Best-effort; silently returns
// on any error.
func registerWebSearchPlugin() {
// configureOllamaWebSearch keeps launch-managed OpenClaw installs on the
// bundled Ollama web_search provider. Older launch builds installed an
// external openclaw-web-search plugin that added custom ollama_web_search and
// ollama_web_fetch tools. Current OpenClaw versions ship Ollama web_search as
// the bundled "ollama" plugin instead, so we migrate stale config and ensure
// fresh installs select the bundled provider.
func configureOllamaWebSearch() {
home, err := os.UserHomeDir()
if err != nil {
return
@@ -835,6 +821,8 @@ func registerWebSearchPlugin() {
return
}
stalePluginConfigured := false
plugins, _ := config["plugins"].(map[string]any)
if plugins == nil {
plugins = make(map[string]any)
@@ -843,68 +831,100 @@ func registerWebSearchPlugin() {
if entries == nil {
entries = make(map[string]any)
}
entries["openclaw-web-search"] = map[string]any{"enabled": true}
plugins["entries"] = entries
// Pin trust so the gateway doesn't warn about untracked plugins.
allow, _ := plugins["allow"].([]any)
hasAllow := false
for _, v := range allow {
if s, ok := v.(string); ok && s == "openclaw-web-search" {
hasAllow = true
break
}
}
if !hasAllow {
allow = append(allow, "openclaw-web-search")
}
plugins["allow"] = allow
// Record install provenance so the loader can verify the plugin origin.
installs, _ := plugins["installs"].(map[string]any)
if installs == nil {
installs = make(map[string]any)
}
pluginDir := filepath.Join(home, ".openclaw", "extensions", "openclaw-web-search")
installs["openclaw-web-search"] = map[string]any{
"source": "npm",
"spec": webSearchNpmPackage,
"installPath": pluginDir,
}
plugins["installs"] = installs
config["plugins"] = plugins
// Add plugin tools to tools.alsoAllow so they survive the coding profile's
// policy pipeline (which has an explicit allow list of core tools only).
tools, _ := config["tools"].(map[string]any)
if tools == nil {
tools = make(map[string]any)
}
alsoAllow, _ := tools["alsoAllow"].([]any)
needed := []string{"ollama_web_search", "ollama_web_fetch"}
have := make(map[string]bool, len(alsoAllow))
for _, v := range alsoAllow {
if s, ok := v.(string); ok {
have[s] = true
}
}
for _, name := range needed {
if !have[name] {
alsoAllow = append(alsoAllow, name)
}
}
tools["alsoAllow"] = alsoAllow
// Disable built-in web search/fetch since our plugin replaces them.
web, _ := tools["web"].(map[string]any)
if web == nil {
web = make(map[string]any)
}
web["search"] = map[string]any{"enabled": false}
web["fetch"] = map[string]any{"enabled": false}
search, _ := web["search"].(map[string]any)
if search == nil {
search = make(map[string]any)
}
fetch, _ := web["fetch"].(map[string]any)
if fetch == nil {
fetch = make(map[string]any)
}
alsoAllow, _ := tools["alsoAllow"].([]any)
var filteredAlsoAllow []any
for _, v := range alsoAllow {
s, ok := v.(string)
if !ok {
filteredAlsoAllow = append(filteredAlsoAllow, v)
continue
}
if s == "ollama_web_search" || s == "ollama_web_fetch" {
stalePluginConfigured = true
continue
}
filteredAlsoAllow = append(filteredAlsoAllow, v)
}
if len(filteredAlsoAllow) > 0 {
tools["alsoAllow"] = filteredAlsoAllow
} else {
delete(tools, "alsoAllow")
}
if _, ok := entries["openclaw-web-search"]; ok {
delete(entries, "openclaw-web-search")
stalePluginConfigured = true
}
ollamaEntry, _ := entries["ollama"].(map[string]any)
if ollamaEntry == nil {
ollamaEntry = make(map[string]any)
}
ollamaEntry["enabled"] = true
entries["ollama"] = ollamaEntry
plugins["entries"] = entries
if allow, ok := plugins["allow"].([]any); ok {
var nextAllow []any
hasOllama := false
for _, v := range allow {
s, ok := v.(string)
if ok && s == "openclaw-web-search" {
stalePluginConfigured = true
continue
}
if ok && s == "ollama" {
hasOllama = true
}
nextAllow = append(nextAllow, v)
}
if !hasOllama {
nextAllow = append(nextAllow, "ollama")
}
plugins["allow"] = nextAllow
}
if installs, ok := plugins["installs"].(map[string]any); ok {
if _, exists := installs["openclaw-web-search"]; exists {
delete(installs, "openclaw-web-search")
stalePluginConfigured = true
}
if len(installs) > 0 {
plugins["installs"] = installs
} else {
delete(plugins, "installs")
}
}
if stalePluginConfigured || search["provider"] == nil {
search["provider"] = "ollama"
}
if stalePluginConfigured {
fetch["enabled"] = true
}
search["enabled"] = true
web["search"] = search
if len(fetch) > 0 {
web["fetch"] = fetch
}
tools["web"] = web
config["plugins"] = plugins
config["tools"] = tools
out, err := json.MarshalIndent(config, "", " ")
+443 -134
View File
@@ -251,6 +251,359 @@ func TestOpenclawRun_SetupLaterContinuesToGatewayAndTUI(t *testing.T) {
}
}
func TestOpenclawRun_FirstLaunchOnboardUsesLaunchManagedHealthFlow(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
bin := filepath.Join(tmpDir, "openclaw")
script := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$*" >> "$HOME/invocations.log"
if [ "$1" = "onboard" ]; then
/usr/bin/env | /usr/bin/sort > "$HOME/onboard-env.log"
/bin/mkdir -p "$HOME/.openclaw"
/bin/cat > "$HOME/.openclaw/openclaw.json" <<'EOF'
{"wizard":{"lastRunAt":"2026-01-01T00:00:00Z"},"gateway":{"port":18789,"mode":"local"}}
EOF
fi
exit 0
`)
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
oldConfirmPrompt := DefaultConfirmPrompt
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
if prompt != "I understand the risks. Continue?" {
t.Fatalf("unexpected prompt: %q", prompt)
}
return true, nil
}
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
c := &Openclaw{}
if err := c.Run("llama3.2", []string{"status"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log"))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) < 2 {
t.Fatalf("expected onboard + passthrough invocations, got %v", lines)
}
onboardInvocation := ""
for _, line := range lines {
if strings.HasPrefix(line, "onboard ") {
onboardInvocation = line
break
}
}
if onboardInvocation == "" {
t.Fatalf("expected onboard invocation, got %v", lines)
}
if !strings.Contains(onboardInvocation, "--skip-health") {
t.Fatalf("expected onboard invocation to include --skip-health, got %q", onboardInvocation)
}
envData, err := os.ReadFile(filepath.Join(tmpDir, "onboard-env.log"))
if err != nil {
t.Fatal(err)
}
env := envSliceToMap(strings.Split(strings.TrimSpace(string(envData)), "\n"))
if env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"] != "1" {
t.Fatalf("OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS = %q, want %q", env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"], "1")
}
if env["OPENCLAW_PLUGIN_STAGE_DIR"] != filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps") {
t.Fatalf("OPENCLAW_PLUGIN_STAGE_DIR = %q, want %q", env["OPENCLAW_PLUGIN_STAGE_DIR"], filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps"))
}
}
func TestOpenclawRun_FirstLaunchTUIArgsEnsureGatewayBeforePassthrough(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
bin := filepath.Join(tmpDir, "openclaw")
script := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$*" >> "$HOME/invocations.log"
if [ "$1" = "onboard" ]; then
/bin/mkdir -p "$HOME/.openclaw"
/bin/cat > "$HOME/.openclaw/openclaw.json" <<'EOF'
{"wizard":{"lastRunAt":"2026-01-01T00:00:00Z"},"gateway":{"port":%d,"mode":"local"}}
EOF
fi
exit 0
`, port)
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
oldConfirmPrompt := DefaultConfirmPrompt
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
if prompt != "I understand the risks. Continue?" {
t.Fatalf("unexpected prompt: %q", prompt)
}
return true, nil
}
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
c := &Openclaw{}
if err := c.Run("llama3.2", []string{"tui"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log"))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) < 3 {
t.Fatalf("expected at least 3 invocations (update, onboard, daemon restart, tui), got %v", lines)
}
onboardIdx, daemonRestartIdx, tuiIdx := -1, -1, -1
for i, line := range lines {
if onboardIdx == -1 && strings.HasPrefix(line, "onboard ") {
onboardIdx = i
}
if daemonRestartIdx == -1 && line == "daemon restart" {
daemonRestartIdx = i
}
if tuiIdx == -1 && line == "tui" {
tuiIdx = i
}
}
if onboardIdx == -1 {
t.Fatalf("expected an onboarding invocation, got %v", lines)
}
if daemonRestartIdx == -1 {
t.Fatalf("expected a daemon restart before tui, got %v", lines)
}
if tuiIdx == -1 {
t.Fatalf("expected a tui invocation, got %v", lines)
}
if !(onboardIdx < daemonRestartIdx && daemonRestartIdx < tuiIdx) {
t.Fatalf("expected onboarding, then daemon restart, then tui; got %v", lines)
}
}
func TestOpenclawEnsureGatewayReady_UsesDaemonStartFallback(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
portProbe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := portProbe.Addr().(*net.TCPAddr).Port
_ = portProbe.Close()
configDir := filepath.Join(tmpDir, ".openclaw")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configDir, "openclaw.json"), []byte(fmt.Sprintf(`{
"wizard": {"lastRunAt": "2026-01-01T00:00:00Z"},
"gateway": {"port": %d, "mode": "local"}
}`, port)), 0o644); err != nil {
t.Fatal(err)
}
bin := filepath.Join(tmpDir, "openclaw")
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$HOME/invocations.log\"\n"), 0o755); err != nil {
t.Fatal(err)
}
oldCanInstallDaemon := openclawCanInstallDaemon
openclawCanInstallDaemon = func() bool { return true }
defer func() { openclawCanInstallDaemon = oldCanInstallDaemon }()
triggeredBy := make(chan string, 1)
listenerReady := make(chan net.Listener, 1)
go func() {
invocationsPath := filepath.Join(tmpDir, "invocations.log")
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
data, err := os.ReadFile(invocationsPath)
if err == nil {
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
for _, line := range lines {
if line != "daemon start" && line != "gateway run --force" {
continue
}
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
return
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
_ = conn.Close()
}
}()
triggeredBy <- line
listenerReady <- ln
return
}
}
time.Sleep(10 * time.Millisecond)
}
}()
c := &Openclaw{}
cleanup, _, gotPort, err := c.ensureGatewayReady(bin)
if err != nil {
t.Fatalf("ensureGatewayReady() error = %v", err)
}
defer cleanup()
if gotPort != port {
t.Fatalf("ensureGatewayReady() port = %d, want %d", gotPort, port)
}
var ln net.Listener
select {
case which := <-triggeredBy:
if which != "daemon start" {
t.Fatalf("expected daemon start fallback, got %q", which)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for gateway startup trigger")
}
select {
case ln = <-listenerReady:
defer ln.Close()
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for test listener")
}
data, err := os.ReadFile(filepath.Join(tmpDir, "invocations.log"))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) == 0 || lines[0] != "daemon start" {
t.Fatalf("expected daemon start invocation, got %v", lines)
}
for _, line := range lines {
if line == "gateway run --force" {
t.Fatalf("did not expect gateway run fallback when daemon start succeeds, got %v", lines)
}
}
}
func TestOpenclawEnv_StagesBundledPluginRuntimeDeps(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("OPENAI_API_KEY", "should-be-cleared")
env := envSliceToMap(openclawEnv())
if env["OPENCLAW_PLUGIN_STAGE_DIR"] != filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps") {
t.Fatalf("OPENCLAW_PLUGIN_STAGE_DIR = %q, want %q", env["OPENCLAW_PLUGIN_STAGE_DIR"], filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps"))
}
if _, ok := env["OPENAI_API_KEY"]; ok {
t.Fatal("expected OPENAI_API_KEY to be cleared from openclaw environment")
}
}
func TestOpenclawInstallEnv_PreservesExplicitStageDirAndAddsEagerDeps(t *testing.T) {
t.Setenv("OPENCLAW_PLUGIN_STAGE_DIR", "/tmp/custom-stage")
env := envSliceToMap(openclawInstallEnv())
if env["OPENCLAW_PLUGIN_STAGE_DIR"] != "/tmp/custom-stage" {
t.Fatalf("OPENCLAW_PLUGIN_STAGE_DIR = %q, want %q", env["OPENCLAW_PLUGIN_STAGE_DIR"], "/tmp/custom-stage")
}
if env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"] != "1" {
t.Fatalf("OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS = %q, want %q", env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"], "1")
}
}
func TestEnsureOpenclawInstalled_UsesBundledPluginInstallEnv(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
writeScript := func(path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatal(err)
}
}
openclawPath := filepath.Join(tmpDir, "openclaw")
npmScript := fmt.Sprintf(`#!/bin/sh
/usr/bin/env | /usr/bin/sort > "$HOME/npm-env.log"
/bin/cat > %q <<'EOF'
#!/bin/sh
exit 0
EOF
/bin/chmod +x %q
exit 0
`, openclawPath, openclawPath)
writeScript(filepath.Join(tmpDir, "npm"), npmScript)
writeScript(filepath.Join(tmpDir, "git"), "#!/bin/sh\nexit 0\n")
oldConfirmPrompt := DefaultConfirmPrompt
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
if prompt != "OpenClaw is not installed. Install with npm?" {
t.Fatalf("unexpected prompt: %q", prompt)
}
return true, nil
}
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
openclawFreshInstall = false
bin, err := ensureOpenclawInstalled()
if err != nil {
t.Fatalf("ensureOpenclawInstalled() error = %v", err)
}
if bin != "openclaw" {
t.Fatalf("ensureOpenclawInstalled() bin = %q, want %q", bin, "openclaw")
}
envData, err := os.ReadFile(filepath.Join(tmpDir, "npm-env.log"))
if err != nil {
t.Fatal(err)
}
env := envSliceToMap(strings.Split(strings.TrimSpace(string(envData)), "\n"))
if env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"] != "1" {
t.Fatalf("OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS = %q, want %q", env["OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"], "1")
}
if env["OPENCLAW_PLUGIN_STAGE_DIR"] != filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps") {
t.Fatalf("OPENCLAW_PLUGIN_STAGE_DIR = %q, want %q", env["OPENCLAW_PLUGIN_STAGE_DIR"], filepath.Join(tmpDir, ".openclaw", "plugin-runtime-deps"))
}
}
func TestOpenclawEdit(t *testing.T) {
c := &Openclaw{}
tmpDir := t.TempDir()
@@ -1227,6 +1580,18 @@ func TestOpenclawChannelsConfigured(t *testing.T) {
})
}
func envSliceToMap(entries []string) map[string]string {
env := make(map[string]string, len(entries))
for _, entry := range entries {
key, value, ok := strings.Cut(entry, "=")
if !ok {
continue
}
env[key] = value
}
return env
}
func TestOpenclawChannelSetupPreflight(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
@@ -2242,95 +2607,7 @@ func TestIntegrationOnboarded(t *testing.T) {
})
}
func TestVersionLessThan(t *testing.T) {
tests := []struct {
a, b string
want bool
}{
{"0.1.7", "0.2.1", true},
{"0.2.0", "0.2.1", true},
{"0.2.1", "0.2.1", false},
{"0.2.2", "0.2.1", false},
{"1.0.0", "0.2.1", false},
{"0.2.1", "1.0.0", true},
{"v0.1.7", "0.2.1", true},
{"0.2.1", "v0.2.1", false},
}
for _, tt := range tests {
t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) {
if got := versionLessThan(tt.a, tt.b); got != tt.want {
t.Errorf("versionLessThan(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestWebSearchPluginUpToDate(t *testing.T) {
t.Run("missing directory", func(t *testing.T) {
if webSearchPluginUpToDate(filepath.Join(t.TempDir(), "nonexistent")) {
t.Error("expected false for missing directory")
}
})
t.Run("missing package.json", func(t *testing.T) {
dir := t.TempDir()
if webSearchPluginUpToDate(dir) {
t.Error("expected false for missing package.json")
}
})
t.Run("old version", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"version":"0.1.7"}`), 0o644); err != nil {
t.Fatal(err)
}
if webSearchPluginUpToDate(dir) {
t.Error("expected false for old version 0.1.7")
}
})
t.Run("exact minimum version", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"version":"0.2.1"}`), 0o644); err != nil {
t.Fatal(err)
}
if !webSearchPluginUpToDate(dir) {
t.Error("expected true for exact minimum version 0.2.1")
}
})
t.Run("newer version", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644); err != nil {
t.Fatal(err)
}
if !webSearchPluginUpToDate(dir) {
t.Error("expected true for newer version 1.0.0")
}
})
t.Run("invalid json", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`not json`), 0o644); err != nil {
t.Fatal(err)
}
if webSearchPluginUpToDate(dir) {
t.Error("expected false for invalid json")
}
})
t.Run("empty version", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"version":""}`), 0o644); err != nil {
t.Fatal(err)
}
if webSearchPluginUpToDate(dir) {
t.Error("expected false for empty version")
}
})
}
func TestRegisterWebSearchPlugin(t *testing.T) {
func TestConfigureOllamaWebSearch(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
@@ -2345,7 +2622,7 @@ func TestRegisterWebSearchPlugin(t *testing.T) {
t.Fatal(err)
}
registerWebSearchPlugin()
configureOllamaWebSearch()
data, err := os.ReadFile(configPath)
if err != nil {
@@ -2361,40 +2638,30 @@ func TestRegisterWebSearchPlugin(t *testing.T) {
t.Fatal("plugins section missing")
}
// Check entries
entries, _ := plugins["entries"].(map[string]any)
entry, _ := entries["openclaw-web-search"].(map[string]any)
entry, _ := entries["ollama"].(map[string]any)
if enabled, _ := entry["enabled"].(bool); !enabled {
t.Error("expected entries.openclaw-web-search.enabled = true")
t.Error("expected entries.ollama.enabled = true")
}
if _, ok := entries["openclaw-web-search"]; ok {
t.Error("expected stale openclaw-web-search entry to be absent")
}
// Check allow list
allow, _ := plugins["allow"].([]any)
found := false
for _, v := range allow {
if s, ok := v.(string); ok && s == "openclaw-web-search" {
found = true
}
if _, ok := plugins["allow"]; ok {
t.Error("did not expect plugins.allow to be created when no allowlist exists")
}
if !found {
t.Error("expected plugins.allow to contain openclaw-web-search")
if _, ok := plugins["installs"]; ok {
t.Error("did not expect plugins.installs to be created")
}
// Check install provenance
installs, _ := plugins["installs"].(map[string]any)
record, _ := installs["openclaw-web-search"].(map[string]any)
if record == nil {
t.Fatal("expected plugins.installs.openclaw-web-search")
tools, _ := config["tools"].(map[string]any)
web, _ := tools["web"].(map[string]any)
search, _ := web["search"].(map[string]any)
if got, _ := search["provider"].(string); got != "ollama" {
t.Errorf("search provider = %q, want %q", got, "ollama")
}
if source, _ := record["source"].(string); source != "npm" {
t.Errorf("install source = %q, want %q", source, "npm")
}
if spec, _ := record["spec"].(string); spec != webSearchNpmPackage {
t.Errorf("install spec = %q, want %q", spec, webSearchNpmPackage)
}
expectedPath := filepath.Join(home, ".openclaw", "extensions", "openclaw-web-search")
if installPath, _ := record["installPath"].(string); installPath != expectedPath {
t.Errorf("installPath = %q, want %q", installPath, expectedPath)
if enabled, _ := search["enabled"].(bool); !enabled {
t.Error("expected tools.web.search.enabled = true")
}
})
@@ -2403,8 +2670,8 @@ func TestRegisterWebSearchPlugin(t *testing.T) {
t.Fatal(err)
}
registerWebSearchPlugin()
registerWebSearchPlugin()
configureOllamaWebSearch()
configureOllamaWebSearch()
data, err := os.ReadFile(configPath)
if err != nil {
@@ -2416,30 +2683,39 @@ func TestRegisterWebSearchPlugin(t *testing.T) {
}
plugins, _ := config["plugins"].(map[string]any)
allow, _ := plugins["allow"].([]any)
count := 0
for _, v := range allow {
if s, ok := v.(string); ok && s == "openclaw-web-search" {
count++
}
entries, _ := plugins["entries"].(map[string]any)
if len(entries) != 1 {
t.Fatalf("expected only bundled ollama entry, got %v", entries)
}
if count != 1 {
t.Errorf("expected exactly 1 openclaw-web-search in allow, got %d", count)
if _, ok := entries["ollama"]; !ok {
t.Fatalf("expected entries.ollama to exist, got %v", entries)
}
})
t.Run("preserves existing config", func(t *testing.T) {
t.Run("migrates stale plugin config and preserves unrelated settings", func(t *testing.T) {
initial := map[string]any{
"plugins": map[string]any{
"allow": []any{"some-other-plugin"},
"allow": []any{"some-other-plugin", "openclaw-web-search"},
"entries": map[string]any{
"some-other-plugin": map[string]any{"enabled": true},
"some-other-plugin": map[string]any{"enabled": true},
"openclaw-web-search": map[string]any{"enabled": true},
},
"installs": map[string]any{
"some-other-plugin": map[string]any{
"source": "npm",
"installPath": "/some/path",
},
"openclaw-web-search": map[string]any{
"source": "npm",
"installPath": "/old/path",
},
},
},
"tools": map[string]any{
"alsoAllow": []any{"ollama_web_search", "ollama_web_fetch", "browser"},
"web": map[string]any{
"search": map[string]any{"enabled": false},
"fetch": map[string]any{"enabled": false},
},
},
"customField": "preserved",
@@ -2449,7 +2725,7 @@ func TestRegisterWebSearchPlugin(t *testing.T) {
t.Fatal(err)
}
registerWebSearchPlugin()
configureOllamaWebSearch()
out, err := os.ReadFile(configPath)
if err != nil {
@@ -2469,28 +2745,61 @@ func TestRegisterWebSearchPlugin(t *testing.T) {
if entries["some-other-plugin"] == nil {
t.Error("existing plugin entry was lost")
}
if entries["openclaw-web-search"] != nil {
t.Error("stale openclaw-web-search entry should be removed")
}
if ollamaEntry, _ := entries["ollama"].(map[string]any); ollamaEntry == nil {
t.Fatal("expected bundled ollama entry to be enabled")
}
installs, _ := plugins["installs"].(map[string]any)
if installs["some-other-plugin"] == nil {
t.Error("existing install record was lost")
}
if installs["openclaw-web-search"] != nil {
t.Error("stale openclaw-web-search install record should be removed")
}
allow, _ := plugins["allow"].([]any)
hasOther, hasWebSearch := false, false
hasOther, hasStalePlugin, hasOllama := false, false, false
for _, v := range allow {
s, _ := v.(string)
if s == "some-other-plugin" {
hasOther = true
}
if s == "openclaw-web-search" {
hasWebSearch = true
hasStalePlugin = true
}
if s == "ollama" {
hasOllama = true
}
}
if !hasOther {
t.Error("existing allow entry was lost")
}
if !hasWebSearch {
t.Error("openclaw-web-search not added to allow")
if hasStalePlugin {
t.Error("stale openclaw-web-search allow entry should be removed")
}
if !hasOllama {
t.Error("expected plugins.allow to contain bundled ollama plugin")
}
tools, _ := config["tools"].(map[string]any)
alsoAllow, _ := tools["alsoAllow"].([]any)
if len(alsoAllow) != 1 || alsoAllow[0] != "browser" {
t.Errorf("expected stale custom web tools to be removed, got %v", alsoAllow)
}
web, _ := tools["web"].(map[string]any)
search, _ := web["search"].(map[string]any)
fetch, _ := web["fetch"].(map[string]any)
if got, _ := search["provider"].(string); got != "ollama" {
t.Errorf("search provider = %q, want %q", got, "ollama")
}
if enabled, _ := search["enabled"].(bool); !enabled {
t.Error("expected migrated tools.web.search.enabled = true")
}
if enabled, _ := fetch["enabled"].(bool); !enabled {
t.Error("expected migrated tools.web.fetch.enabled = true")
}
})
}
+51
View File
@@ -0,0 +1,51 @@
package launch
import (
"fmt"
"os"
"os/exec"
"runtime"
"github.com/ollama/ollama/envconfig"
)
// Poolside implements Runner for Poolside's CLI.
type Poolside struct{}
var poolsideGOOS = runtime.GOOS
func (p *Poolside) String() string { return "Poolside" }
func poolsideUnsupportedError() error {
return fmt.Errorf("Warning: Poolside is not currently supported on Windows")
}
func (p *Poolside) args(model string, extra []string) []string {
var args []string
if model != "" {
args = append(args, "-m", model)
}
args = append(args, extra...)
return args
}
func (p *Poolside) Run(model string, args []string) error {
if poolsideGOOS == "windows" {
return poolsideUnsupportedError()
}
bin, err := exec.LookPath("pool")
if err != nil {
return fmt.Errorf("pool is not installed")
}
cmd := exec.Command(bin, p.args(model, args)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(),
"POOLSIDE_STANDALONE_BASE_URL="+envconfig.Host().String()+"/v1",
"POOLSIDE_API_KEY=ollama",
)
return cmd.Run()
}
+88
View File
@@ -0,0 +1,88 @@
package launch
import (
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
)
func TestPoolsideArgs(t *testing.T) {
p := &Poolside{}
tests := []struct {
name string
model string
extra []string
want []string
}{
{name: "with model", model: "qwen3.5", want: []string{"-m", "qwen3.5"}},
{name: "without model", extra: []string{"session"}, want: []string{"session"}},
{name: "with model and extra args", model: "llama3.2", extra: []string{"--foo", "bar"}, want: []string{"-m", "llama3.2", "--foo", "bar"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := p.args(tt.model, tt.extra)
if !slices.Equal(got, tt.want) {
t.Fatalf("args(%q, %v) = %v, want %v", tt.model, tt.extra, got, tt.want)
}
})
}
}
func TestPoolsideRunSetsOllamaEnv(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binary")
}
tmpDir := t.TempDir()
logPath := filepath.Join(tmpDir, "pool.log")
poolPath := filepath.Join(tmpDir, "pool")
script := "#!/bin/sh\n" +
"printf 'base=%s\\nkey=%s\\nargs=%s\\n' \"$POOLSIDE_STANDALONE_BASE_URL\" \"$POOLSIDE_API_KEY\" \"$*\" > \"" + logPath + "\"\n"
if err := os.WriteFile(poolPath, []byte(script), 0o755); err != nil {
t.Fatalf("failed to write fake pool binary: %v", err)
}
t.Setenv("PATH", tmpDir)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
p := &Poolside{}
if err := p.Run("qwen3.5", []string{"session"}); err != nil {
t.Fatalf("Run returned error: %v", err)
}
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("failed to read pool log: %v", err)
}
got := string(data)
if !strings.Contains(got, "base=http://127.0.0.1:11434/v1") {
t.Fatalf("expected Poolside base URL override in log, got:\n%s", got)
}
if !strings.Contains(got, "key=ollama") {
t.Fatalf("expected Poolside API key override in log, got:\n%s", got)
}
if !strings.Contains(got, "args=-m qwen3.5 session") {
t.Fatalf("expected model and extra args in log, got:\n%s", got)
}
}
func TestPoolsideRunWindowsUnsupported(t *testing.T) {
prev := poolsideGOOS
poolsideGOOS = "windows"
t.Cleanup(func() { poolsideGOOS = prev })
p := &Poolside{}
err := p.Run("kimi-k2.6:cloud", nil)
if err == nil {
t.Fatal("expected Windows unsupported error")
}
if !strings.Contains(err.Error(), "not currently supported on Windows") {
t.Fatalf("expected Windows warning, got %v", err)
}
}
+50 -1
View File
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
Description string
}
var launcherIntegrationOrder = []string{"openclaw", "claude", "opencode", "hermes", "codex", "droid", "pi"}
var launcherIntegrationOrder = []string{"openclaw", "claude", "opencode", "hermes", "codex", "copilot", "droid", "pi", "pool"}
var integrationSpecs = []*IntegrationSpec{
{
@@ -74,6 +74,36 @@ var integrationSpecs = []*IntegrationSpec{
Command: []string{"npm", "install", "-g", "@openai/codex"},
},
},
{
Name: "kimi",
Runner: &Kimi{},
Description: "Moonshot's coding agent for terminal and IDEs",
Hidden: true,
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
_, err := exec.LookPath("kimi")
return err == nil
},
EnsureInstalled: func() error {
_, err := ensureKimiInstalled()
return err
},
URL: "https://moonshotai.github.io/kimi-cli/en/guides/getting-started.html",
},
},
{
Name: "copilot",
Runner: &Copilot{},
Aliases: []string{"copilot-cli"},
Description: "GitHub's AI coding agent for the terminal",
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
_, err := (&Copilot{}).findPath()
return err == nil
},
URL: "https://github.com/features/copilot/cli/",
},
},
{
Name: "droid",
Runner: &Droid{},
@@ -136,6 +166,18 @@ var integrationSpecs = []*IntegrationSpec{
Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent@latest"},
},
},
{
Name: "pool",
Runner: &Poolside{},
Description: "Poolside's software agent for enterprise development",
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
_, err := exec.LookPath("pool")
return err == nil
},
URL: "https://github.com/poolsideai/pool",
},
},
{
Name: "hermes",
Runner: &Hermes{},
@@ -255,6 +297,9 @@ func ListVisibleIntegrationSpecs() []IntegrationSpec {
if spec.Hidden {
continue
}
if spec.Name == "pool" && poolsideGOOS == "windows" {
continue
}
visible = append(visible, *spec)
}
@@ -369,6 +414,10 @@ func EnsureIntegrationInstalled(name string, runner Runner) error {
return fmt.Errorf("%s is not installed", runner)
}
if integration.spec.Name == "pool" && poolsideGOOS == "windows" {
return poolsideUnsupportedError()
}
if integration.installed {
return nil
}
+20
View File
@@ -45,6 +45,22 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
return filepath.Join(home, ".pi", "agent", "models.json")
},
},
{
name: "pool",
binary: "pool",
runner: &Poolside{},
checkPath: func(home string) string {
return filepath.Join(home, ".poolside", "config")
},
},
{
name: "kimi",
binary: "kimi",
runner: &Kimi{},
checkPath: func(home string) string {
return filepath.Join(home, ".kimi", "config.toml")
},
},
}
for _, tt := range tests {
@@ -57,6 +73,10 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
if tt.name == "pi" {
writeFakeBinary(t, binDir, "npm")
}
if tt.name == "kimi" {
writeFakeBinary(t, binDir, "curl")
writeFakeBinary(t, binDir, "bash")
}
t.Setenv("PATH", binDir)
configPath := tt.checkPath(home)
+8
View File
@@ -316,6 +316,8 @@ func LoadModelMetadata(fsys fs.FS) (ModelKV, *Tokenizer, error) {
conv = &deepseek2Model{}
case "Glm4MoeLiteForCausalLM":
conv = &glm4MoeLiteModel{}
case "LagunaForCausalLM":
conv = &lagunaModel{}
case "GlmOcrForConditionalGeneration":
conv = &glmOcrModel{}
case "Lfm2ForCausalLM", "Lfm2MoeForCausalLM":
@@ -324,6 +326,8 @@ func LoadModelMetadata(fsys fs.FS) (ModelKV, *Tokenizer, error) {
conv = &lfm2VLTextModel{}
case "Qwen3NextForCausalLM", "Qwen3_5ForConditionalGeneration", "Qwen3_5MoeForConditionalGeneration":
conv = &qwen3NextModel{}
case "NemotronH_Nano_VL_V2", "NemotronH_Nano_Omni_Reasoning_V3":
conv = &nemotronHNanoVLModel{}
case "NemotronHForCausalLM":
conv = &nemotronHModel{}
default:
@@ -387,6 +391,10 @@ func ConvertModel(fsys fs.FS, f *os.File) error {
}
func writeFile(f *os.File, kv KV, ts []*ggml.Tensor) error {
for k, v := range sourceTensorKV(ts) {
kv[k] = v
}
for i := range ts {
ts[i].Shape = slices.Clone(ts[i].Shape)
slices.Reverse(ts[i].Shape)
+604
View File
@@ -0,0 +1,604 @@
package convert
import (
"cmp"
"encoding/json"
"fmt"
iofs "io/fs"
"math"
"strings"
"github.com/ollama/ollama/fs/ggml"
)
type lagunaModel struct {
ModelParameters
NumHiddenLayers uint32 `json:"num_hidden_layers"`
HiddenSize uint32 `json:"hidden_size"`
IntermediateSize uint32 `json:"intermediate_size"`
NumAttentionHeads uint32 `json:"num_attention_heads"`
NumKeyValueHeads uint32 `json:"num_key_value_heads"`
HeadDim uint32 `json:"head_dim"`
RMSNormEPS float32 `json:"rms_norm_eps"`
MaxPositionEmbeddings uint32 `json:"max_position_embeddings"`
SlidingWindow uint32 `json:"sliding_window"`
PartialRotaryFactor float32 `json:"partial_rotary_factor"`
Gating lagunaGatingMode `json:"gating"`
QKNormType string `json:"qk_norm_type"`
LayerTypes []string `json:"layer_types"`
NumAttentionHeadsPerLayer []uint32 `json:"num_attention_heads_per_layer"`
NumExperts uint32 `json:"num_experts"`
NumExpertsPerTok uint32 `json:"num_experts_per_tok"`
MoEIntermediateSize uint32 `json:"moe_intermediate_size"`
SharedExpertIntermediateSize uint32 `json:"shared_expert_intermediate_size"`
NormTopKProb bool `json:"norm_topk_prob"`
MoeRoutedScalingFactor float32 `json:"moe_routed_scaling_factor"`
MoERouterUseSigmoid bool `json:"moe_router_use_sigmoid"`
MoEApplyRouterWeightOnInput bool `json:"moe_apply_router_weight_on_input"`
DecoderSparseStep uint32 `json:"decoder_sparse_step"`
MLPOnlyLayers []uint32 `json:"mlp_only_layers"`
MLPLayerTypes []string `json:"mlp_layer_types"`
RopeParameters lagunaRopeParameters `json:"rope_parameters"`
SwaRopeParameters lagunaRopeParameters `json:"swa_rope_parameters"`
SwaAttentionSinkEnabled bool `json:"swa_attention_sink_enabled"`
}
type lagunaGatingMode string
type lagunaRopeParameters struct {
RopeTheta float32 `json:"rope_theta"`
RopeType string `json:"rope_type"`
Type string `json:"type"`
Factor float32 `json:"factor"`
OriginalMaxPositionEmbeddings uint32 `json:"original_max_position_embeddings"`
BetaSlow float32 `json:"beta_slow"`
BetaFast float32 `json:"beta_fast"`
AttentionFactor float32 `json:"attention_factor"`
PartialRotaryFactor float32 `json:"partial_rotary_factor"`
}
type lagunaRopeConfig struct {
flat lagunaRopeParameters
full lagunaRopeParameters
sliding lagunaRopeParameters
nested bool
}
func (g *lagunaGatingMode) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err == nil {
*g = lagunaGatingMode(s)
return nil
}
var enabled bool
if err := json.Unmarshal(b, &enabled); err == nil {
if enabled {
*g = "true"
} else {
*g = "false"
}
return nil
}
if string(b) == "null" {
return nil
}
return fmt.Errorf("unsupported Laguna gating JSON value %s", string(b))
}
func (g lagunaGatingMode) perHead() bool {
return strings.EqualFold(string(g), "per-head") || strings.EqualFold(string(g), "true")
}
func (r *lagunaRopeConfig) UnmarshalJSON(b []byte) error {
if string(b) == "null" {
return nil
}
var probe map[string]json.RawMessage
if err := json.Unmarshal(b, &probe); err != nil {
return err
}
if len(probe) == 0 {
return nil
}
if raw, ok := probe["full_attention"]; ok {
r.nested = true
if err := json.Unmarshal(raw, &r.full); err != nil {
return err
}
if raw = probe["sliding_attention"]; raw != nil {
if err := json.Unmarshal(raw, &r.sliding); err != nil {
return err
}
}
return nil
}
if raw, ok := probe["global_attention"]; ok {
r.nested = true
if err := json.Unmarshal(raw, &r.full); err != nil {
return err
}
if raw = probe["sliding_attention"]; raw != nil {
if err := json.Unmarshal(raw, &r.sliding); err != nil {
return err
}
}
return nil
}
return json.Unmarshal(b, &r.flat)
}
func (r lagunaRopeConfig) fullParams() lagunaRopeParameters {
if r.nested {
return r.full
}
return r.flat
}
func (r lagunaRopeConfig) slidingParams() (lagunaRopeParameters, bool) {
if !r.nested {
return lagunaRopeParameters{}, false
}
return r.sliding, true
}
func (r lagunaRopeParameters) ropeType() string {
return cmp.Or(r.RopeType, r.Type)
}
func (r lagunaRopeParameters) withDefaultPartialRotaryFactor(v float32) lagunaRopeParameters {
if r.PartialRotaryFactor == 0 {
r.PartialRotaryFactor = v
}
return r
}
func (r lagunaRopeParameters) empty() bool {
return r == (lagunaRopeParameters{})
}
type rawLagunaModel struct {
ModelParameters
NumHiddenLayers uint32 `json:"num_hidden_layers"`
HiddenSize uint32 `json:"hidden_size"`
IntermediateSize uint32 `json:"intermediate_size"`
NumAttentionHeads uint32 `json:"num_attention_heads"`
NumKeyValueHeads uint32 `json:"num_key_value_heads"`
HeadDim uint32 `json:"head_dim"`
RMSNormEPS float32 `json:"rms_norm_eps"`
MaxPositionEmbeddings uint32 `json:"max_position_embeddings"`
SlidingWindow uint32 `json:"sliding_window"`
PartialRotaryFactor float32 `json:"partial_rotary_factor"`
Gating lagunaGatingMode `json:"gating"`
QKNormType string `json:"qk_norm_type"`
LayerTypes []string `json:"layer_types"`
NumAttentionHeadsPerLayer []uint32 `json:"num_attention_heads_per_layer"`
NumExperts uint32 `json:"num_experts"`
NumExpertsPerTok uint32 `json:"num_experts_per_tok"`
MoEIntermediateSize uint32 `json:"moe_intermediate_size"`
SharedExpertIntermediateSize uint32 `json:"shared_expert_intermediate_size"`
NormTopKProb *bool `json:"norm_topk_prob"`
MoeRoutedScalingFactor float32 `json:"moe_routed_scaling_factor"`
MoERouterUseSigmoid *bool `json:"moe_router_use_sigmoid"`
MoEApplyRouterWeightOnInput bool `json:"moe_apply_router_weight_on_input"`
DecoderSparseStep uint32 `json:"decoder_sparse_step"`
MLPOnlyLayers []uint32 `json:"mlp_only_layers"`
MLPLayerTypes []string `json:"mlp_layer_types"`
RopeParameters lagunaRopeConfig `json:"rope_parameters"`
SwaRopeParameters lagunaRopeParameters `json:"swa_rope_parameters"`
SwaAttentionSinkEnabled bool `json:"swa_attention_sink_enabled"`
}
func (p *lagunaModel) UnmarshalJSON(b []byte) error {
var raw rawLagunaModel
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
mlpOnlyLayers, err := lagunaDenseLayers(raw.MLPOnlyLayers, raw.MLPLayerTypes)
if err != nil {
return err
}
fullRope := raw.RopeParameters.fullParams().withDefaultPartialRotaryFactor(cmp.Or(raw.PartialRotaryFactor, float32(1)))
swaRope := raw.SwaRopeParameters
if nestedSwa, ok := raw.RopeParameters.slidingParams(); ok && !nestedSwa.empty() {
swaRope = nestedSwa
}
swaRope = swaRope.withDefaultPartialRotaryFactor(cmp.Or(fullRope.PartialRotaryFactor, float32(1)))
*p = lagunaModel{
ModelParameters: raw.ModelParameters,
NumHiddenLayers: raw.NumHiddenLayers,
HiddenSize: raw.HiddenSize,
IntermediateSize: raw.IntermediateSize,
NumAttentionHeads: raw.NumAttentionHeads,
NumKeyValueHeads: raw.NumKeyValueHeads,
HeadDim: raw.HeadDim,
RMSNormEPS: raw.RMSNormEPS,
MaxPositionEmbeddings: raw.MaxPositionEmbeddings,
SlidingWindow: raw.SlidingWindow,
PartialRotaryFactor: cmp.Or(raw.PartialRotaryFactor, fullRope.PartialRotaryFactor),
Gating: raw.Gating,
QKNormType: cmp.Or(raw.QKNormType, "rmsnorm"),
LayerTypes: raw.LayerTypes,
NumAttentionHeadsPerLayer: raw.NumAttentionHeadsPerLayer,
NumExperts: raw.NumExperts,
NumExpertsPerTok: raw.NumExpertsPerTok,
MoEIntermediateSize: raw.MoEIntermediateSize,
SharedExpertIntermediateSize: raw.SharedExpertIntermediateSize,
NormTopKProb: defaultBool(raw.NormTopKProb, true),
MoeRoutedScalingFactor: raw.MoeRoutedScalingFactor,
MoERouterUseSigmoid: defaultBool(raw.MoERouterUseSigmoid, true),
MoEApplyRouterWeightOnInput: raw.MoEApplyRouterWeightOnInput,
DecoderSparseStep: raw.DecoderSparseStep,
MLPOnlyLayers: mlpOnlyLayers,
MLPLayerTypes: raw.MLPLayerTypes,
RopeParameters: fullRope,
SwaRopeParameters: swaRope,
SwaAttentionSinkEnabled: raw.SwaAttentionSinkEnabled,
}
return nil
}
func defaultBool(v *bool, fallback bool) bool {
if v == nil {
return fallback
}
return *v
}
const (
lagunaGatingFuncSoftmax uint32 = 1
lagunaGatingFuncSigmoid uint32 = 2
lagunaLayerTypeGlobal uint32 = 0
lagunaLayerTypeSliding uint32 = 1
)
func (p *lagunaModel) KV(t *Tokenizer) KV {
kv := p.ModelParameters.KV(t)
kv["general.architecture"] = "laguna"
// Laguna's chat template and built-in renderer both emit the leading
// special token explicitly. Auto-prepending BOS here would duplicate it.
kv["tokenizer.ggml.add_bos_token"] = false
kv["tokenizer.ggml.pre"] = "laguna"
// Laguna does not need tokenizer.chat_template at runtime: Ollama create
// sets the Laguna renderer/parser from the architecture, and the renderer
// owns prompt formatting.
delete(kv, "tokenizer.chat_template")
kv["laguna.block_count"] = p.NumHiddenLayers
kv["laguna.context_length"] = p.MaxPositionEmbeddings
kv["laguna.embedding_length"] = p.HiddenSize
kv["laguna.feed_forward_length"] = p.IntermediateSize
if len(p.NumAttentionHeadsPerLayer) == int(p.NumHiddenLayers) {
kv["laguna.attention.head_count"] = p.NumAttentionHeadsPerLayer
} else {
kv["laguna.attention.head_count"] = p.NumAttentionHeads
}
kv["laguna.attention.head_count_kv"] = p.NumKeyValueHeads
kv["laguna.attention.key_length"] = p.HeadDim
kv["laguna.attention.value_length"] = p.HeadDim
kv["laguna.attention.layer_norm_rms_epsilon"] = p.RMSNormEPS
kv["laguna.attention.sliding_window"] = p.SlidingWindow
kv["laguna.attention.sink_enabled"] = p.SwaAttentionSinkEnabled
if len(p.LayerTypes) > 0 {
encoded := make([]uint32, len(p.LayerTypes))
slidingPattern := make([]bool, len(p.LayerTypes))
for i, layerType := range p.LayerTypes {
if lagunaLayerIsSliding(layerType) {
encoded[i] = lagunaLayerTypeSliding
slidingPattern[i] = true
} else {
encoded[i] = lagunaLayerTypeGlobal
}
}
kv["laguna.attention.layer_types"] = encoded
kv["laguna.attention.sliding_window_pattern"] = slidingPattern
}
if p.Gating.perHead() {
kv["laguna.attention.gating_type"] = uint32(1)
} else {
kv["laguna.attention.gating_type"] = uint32(0)
}
kv["laguna.attention.qk_norm"] = p.QKNormType == "rmsnorm"
kv["laguna.expert_count"] = p.NumExperts
kv["laguna.expert_used_count"] = p.NumExpertsPerTok
kv["laguna.expert_feed_forward_length"] = p.MoEIntermediateSize
kv["laguna.expert_shared_feed_forward_length"] = p.SharedExpertIntermediateSize
kv["laguna.expert_shared_count"] = uint32(1)
kv["laguna.expert_weights_norm"] = p.NormTopKProb
kv["laguna.expert_weights_scale"] = p.MoeRoutedScalingFactor
kv["laguna.expert_gating_func"] = lagunaMoeGatingFunc(p.MoERouterUseSigmoid)
kv["laguna.decoder_sparse_step"] = cmp.Or(p.DecoderSparseStep, uint32(1))
if leading, ok := lagunaLeadingDensePrefix(p.MLPOnlyLayers); ok {
kv["laguna.leading_dense_block_count"] = leading
}
if len(p.MLPOnlyLayers) > 0 {
kv["laguna.dense_layers"] = p.MLPOnlyLayers
}
ropeType := p.RopeParameters.ropeType()
kv["laguna.rope.freq_base"] = cmp.Or(p.RopeParameters.RopeTheta, float32(10000))
kv["laguna.rope.scaling.type"] = ropeType
ropeFactor := cmp.Or(p.RopeParameters.Factor, float32(1))
kv["laguna.rope.scaling.factor"] = ropeFactor
kv["laguna.rope.scaling.original_context_length"] = p.RopeParameters.OriginalMaxPositionEmbeddings
kv["laguna.rope.scaling.beta_fast"] = p.RopeParameters.BetaFast
kv["laguna.rope.scaling.beta_slow"] = p.RopeParameters.BetaSlow
kv["laguna.rope.scaling.attn_factor"] = lagunaAttentionFactor(ropeType, ropeFactor, p.RopeParameters.AttentionFactor)
kv["laguna.rope.partial_rotary_factor"] = cmp.Or(p.PartialRotaryFactor, float32(1))
swaRopeType := p.SwaRopeParameters.ropeType()
kv["laguna.rope.swa.freq_base"] = cmp.Or(p.SwaRopeParameters.RopeTheta, float32(10000))
kv["laguna.rope.swa.scaling.type"] = cmp.Or(swaRopeType, "linear")
kv["laguna.rope.swa.scaling.factor"] = cmp.Or(p.SwaRopeParameters.Factor, float32(1))
kv["laguna.rope.swa.partial_rotary_factor"] = cmp.Or(p.SwaRopeParameters.PartialRotaryFactor, float32(1))
headDim := p.HeadDim
if headDim == 0 && p.NumAttentionHeads > 0 {
headDim = p.HiddenSize / p.NumAttentionHeads
}
kv["laguna.rope.dimension_count"] = lagunaRopeDim(headDim, cmp.Or(p.PartialRotaryFactor, float32(1)))
kv["laguna.rope.swa.dimension_count"] = lagunaRopeDim(headDim, cmp.Or(p.SwaRopeParameters.PartialRotaryFactor, float32(1)))
return kv
}
func (p *lagunaModel) parseMore(_ iofs.FS) error {
return p.validate()
}
func (p *lagunaModel) validate() error {
if p.NumHiddenLayers == 0 {
return fmt.Errorf("laguna: num_hidden_layers must be set")
}
if p.HiddenSize == 0 {
return fmt.Errorf("laguna: hidden_size must be set")
}
if p.HeadDim == 0 {
return fmt.Errorf("laguna: head_dim must be set")
}
if p.NumKeyValueHeads == 0 {
return fmt.Errorf("laguna: num_key_value_heads must be set")
}
if p.SwaAttentionSinkEnabled {
return fmt.Errorf("laguna: unsupported swa_attention_sink_enabled=true")
}
if !p.Gating.perHead() {
return fmt.Errorf("laguna: unsupported attention gating %q: only gating=\"per-head\" is supported", p.Gating)
}
if p.QKNormType != "rmsnorm" {
return fmt.Errorf("laguna: unsupported qk_norm_type %q: only rmsnorm is supported", p.QKNormType)
}
if !p.MoERouterUseSigmoid {
return fmt.Errorf("laguna: unsupported moe_router_use_sigmoid=false")
}
if p.MoEApplyRouterWeightOnInput {
return fmt.Errorf("laguna: unsupported moe_apply_router_weight_on_input=true")
}
if p.DecoderSparseStep != 0 && p.DecoderSparseStep != 1 {
return fmt.Errorf("laguna: unsupported decoder_sparse_step=%d: only 1 is supported", p.DecoderSparseStep)
}
if len(p.MLPOnlyLayers) != 1 || p.MLPOnlyLayers[0] != 0 {
return fmt.Errorf("laguna: unsupported mlp_only_layers=%v: only [0] is supported", p.MLPOnlyLayers)
}
if p.NumExperts == 0 {
return fmt.Errorf("laguna: num_experts must be set")
}
if p.NumExpertsPerTok == 0 {
return fmt.Errorf("laguna: num_experts_per_tok must be set")
}
if p.MoEIntermediateSize == 0 {
return fmt.Errorf("laguna: moe_intermediate_size must be set")
}
if p.SharedExpertIntermediateSize == 0 {
return fmt.Errorf("laguna: shared_expert_intermediate_size must be set")
}
if len(p.LayerTypes) > 0 && len(p.LayerTypes) != int(p.NumHiddenLayers) {
return fmt.Errorf("laguna: layer_types has %d entries, expected %d", len(p.LayerTypes), p.NumHiddenLayers)
}
for i, layerType := range p.LayerTypes {
if !lagunaLayerIsGlobal(layerType) && !lagunaLayerIsSliding(layerType) {
return fmt.Errorf("laguna: unsupported layer_types[%d]=%q", i, layerType)
}
}
if len(p.NumAttentionHeadsPerLayer) > 0 && len(p.NumAttentionHeadsPerLayer) != int(p.NumHiddenLayers) {
return fmt.Errorf("laguna: num_attention_heads_per_layer has %d entries, expected %d", len(p.NumAttentionHeadsPerLayer), p.NumHiddenLayers)
}
if len(p.NumAttentionHeadsPerLayer) == 0 && p.NumAttentionHeads == 0 {
return fmt.Errorf("laguna: num_attention_heads or num_attention_heads_per_layer must be set")
}
for i, heads := range p.NumAttentionHeadsPerLayer {
if heads == 0 {
return fmt.Errorf("laguna: num_attention_heads_per_layer[%d] must be non-zero", i)
}
}
return nil
}
func (p *lagunaModel) numHeadsForLayer(layer uint32) uint32 {
if len(p.NumAttentionHeadsPerLayer) > int(layer) && p.NumAttentionHeadsPerLayer[layer] > 0 {
return p.NumAttentionHeadsPerLayer[layer]
}
return p.NumAttentionHeads
}
func (p *lagunaModel) layerUsesMoE(layer uint32) bool {
for _, denseLayer := range p.MLPOnlyLayers {
if denseLayer == layer {
return false
}
}
step := cmp.Or(p.DecoderSparseStep, uint32(1))
return p.NumExperts > 0 && (layer+1)%step == 0
}
func (p *lagunaModel) Replacements() []string {
return []string{
"lm_head", "output",
"model.embed_tokens", "token_embd",
"model.norm", "output_norm",
"model.layers", "blk",
"input_layernorm", "attn_norm",
"post_attention_layernorm", "ffn_norm",
"self_attn.q_proj", "attn_q",
"self_attn.k_proj", "attn_k",
"self_attn.v_proj", "attn_v",
"self_attn.o_proj", "attn_output",
"self_attn.g_proj", "attn_g",
"self_attn.q_norm", "attn_q_norm",
"self_attn.k_norm", "attn_k_norm",
"mlp.gate_proj", "ffn_gate",
"mlp.up_proj", "ffn_up",
"mlp.down_proj", "ffn_down",
"mlp.gate.weight", "ffn_gate_inp.weight",
"mlp.experts.e_score_correction_bias", "exp_probs_b.bias",
"mlp.shared_expert.gate_proj", "ffn_gate_shexp",
"mlp.shared_expert.up_proj", "ffn_up_shexp",
"mlp.shared_expert.down_proj", "ffn_down_shexp",
"mlp.experts.*.gate_proj", "ffn_gate_exps",
"mlp.experts.*.up_proj", "ffn_up_exps",
"mlp.experts.*.down_proj", "ffn_down_exps",
}
}
func (p *lagunaModel) Tensors(ts []Tensor) []*ggml.Tensor {
// Current Laguna drops store routed MoE experts as separate per-expert
// tensors. GGUF stores each projection as one stacked tensor. If future
// drops change expert naming or layout, update these patterns with a
// focused conversion test using the new tensor names.
merges := make([]merge, 0, p.NumHiddenLayers*3)
for i := range p.NumHiddenLayers {
merges = append(merges,
merge{
fmt.Sprintf("blk.%d.mlp.experts.*.gate_proj.weight", i),
fmt.Sprintf("blk.%d.ffn_gate_exps.weight", i),
},
merge{
fmt.Sprintf("blk.%d.mlp.experts.*.up_proj.weight", i),
fmt.Sprintf("blk.%d.ffn_up_exps.weight", i),
},
merge{
fmt.Sprintf("blk.%d.mlp.experts.*.down_proj.weight", i),
fmt.Sprintf("blk.%d.ffn_down_exps.weight", i),
},
)
}
out, rest := mergeTensors(ts, merges...)
for _, t := range rest {
out = append(out, &ggml.Tensor{
Name: t.Name(),
Kind: t.Kind(),
Shape: t.Shape(),
WriterTo: t,
})
}
return out
}
func (p *lagunaModel) specialTokenTypes() []string {
return []string{"bos", "eos", "pad", "unk"}
}
func lagunaLayerIsSliding(layerType string) bool {
return strings.EqualFold(layerType, "sliding_attention")
}
func lagunaLayerIsGlobal(layerType string) bool {
return strings.EqualFold(layerType, "full_attention") || strings.EqualFold(layerType, "global_attention")
}
func lagunaLeadingDensePrefix(layers []uint32) (uint32, bool) {
for i, v := range layers {
if v != uint32(i) {
return 0, false
}
}
return uint32(len(layers)), true
}
func lagunaDenseLayers(mlpOnlyLayers []uint32, mlpLayerTypes []string) ([]uint32, error) {
if len(mlpOnlyLayers) > 0 {
return mlpOnlyLayers, nil
}
if len(mlpLayerTypes) == 0 {
return nil, nil
}
denseLayers := make([]uint32, 0, len(mlpLayerTypes))
for i, layerType := range mlpLayerTypes {
switch {
case strings.EqualFold(layerType, "dense"):
denseLayers = append(denseLayers, uint32(i))
case strings.EqualFold(layerType, "sparse"):
default:
return nil, fmt.Errorf("laguna: unsupported mlp_layer_types[%d]=%q", i, layerType)
}
}
return denseLayers, nil
}
func lagunaMoeGatingFunc(useSigmoid bool) uint32 {
if useSigmoid {
return lagunaGatingFuncSigmoid
}
return lagunaGatingFuncSoftmax
}
func lagunaAttentionFactor(ropeType string, scaleFactor, attentionFactor float32) float32 {
if attentionFactor != 0 {
return attentionFactor
}
if strings.EqualFold(ropeType, "yarn") && scaleFactor > 1 {
return float32(0.1*math.Log(float64(scaleFactor)) + 1)
}
return 1
}
func lagunaRopeDim(headDim uint32, partialRotaryFactor float32) uint32 {
if headDim == 0 {
return 0
}
dim := uint32(float32(headDim) * partialRotaryFactor)
if dim == 0 || dim > headDim {
dim = headDim
}
if dim%2 != 0 {
dim--
}
if dim == 0 {
return headDim
}
return dim
}
var (
_ ModelConverter = (*lagunaModel)(nil)
_ moreParser = (*lagunaModel)(nil)
)
+450
View File
@@ -0,0 +1,450 @@
package convert
import (
"encoding/json"
"fmt"
"io"
"math"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/fs/ggml"
)
type lagunaTestTensor struct {
tensorBase
}
func newLagunaTestTensor(name string, shape ...uint64) Tensor {
return &lagunaTestTensor{tensorBase: tensorBase{name: name, shape: shape}}
}
func (t *lagunaTestTensor) WriteTo(io.Writer) (int64, error) {
return 0, nil
}
func (t *lagunaTestTensor) Clone() Tensor {
return &lagunaTestTensor{tensorBase: tensorBase{
name: t.name,
shape: append([]uint64(nil), t.shape...),
}}
}
func TestLagunaReplacements(t *testing.T) {
p := lagunaModel{}
r := strings.NewReplacer(p.Replacements()...)
tests := []struct {
name string
in string
want string
}{
{"embed", "model.embed_tokens.weight", "token_embd.weight"},
{"final_norm", "model.norm.weight", "output_norm.weight"},
{"lm_head", "lm_head.weight", "output.weight"},
{"block prefix", "model.layers.7.input_layernorm.weight", "blk.7.attn_norm.weight"},
{"q", "model.layers.3.self_attn.q_proj.weight", "blk.3.attn_q.weight"},
{"k", "model.layers.3.self_attn.k_proj.weight", "blk.3.attn_k.weight"},
{"v", "model.layers.3.self_attn.v_proj.weight", "blk.3.attn_v.weight"},
{"o", "model.layers.3.self_attn.o_proj.weight", "blk.3.attn_output.weight"},
{"g", "model.layers.3.self_attn.g_proj.weight", "blk.3.attn_g.weight"},
{"q_norm", "model.layers.3.self_attn.q_norm.weight", "blk.3.attn_q_norm.weight"},
{"k_norm", "model.layers.3.self_attn.k_norm.weight", "blk.3.attn_k_norm.weight"},
{"post_attn_norm", "model.layers.3.post_attention_layernorm.weight", "blk.3.ffn_norm.weight"},
{"dense gate", "model.layers.0.mlp.gate_proj.weight", "blk.0.ffn_gate.weight"},
{"dense up", "model.layers.0.mlp.up_proj.weight", "blk.0.ffn_up.weight"},
{"dense down", "model.layers.0.mlp.down_proj.weight", "blk.0.ffn_down.weight"},
{"shexp gate", "model.layers.5.mlp.shared_expert.gate_proj.weight", "blk.5.ffn_gate_shexp.weight"},
{"shexp up", "model.layers.5.mlp.shared_expert.up_proj.weight", "blk.5.ffn_up_shexp.weight"},
{"shexp down", "model.layers.5.mlp.shared_expert.down_proj.weight", "blk.5.ffn_down_shexp.weight"},
{"router", "model.layers.5.mlp.gate.weight", "blk.5.ffn_gate_inp.weight"},
{"score bias", "model.layers.5.mlp.experts.e_score_correction_bias", "blk.5.exp_probs_b.bias"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := r.Replace(tc.in); got != tc.want {
t.Errorf("Replace(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestLagunaValidateRejectsUnsupportedVariants(t *testing.T) {
base := validLagunaTestModel()
tests := []struct {
name string
edit func(*lagunaModel)
want string
}{
{
name: "per-element gating",
edit: func(m *lagunaModel) {
m.Gating = "per-element"
},
want: "unsupported attention gating",
},
{
name: "attention sinks",
edit: func(m *lagunaModel) {
m.SwaAttentionSinkEnabled = true
},
want: "swa_attention_sink_enabled=true",
},
{
name: "qk norm disabled",
edit: func(m *lagunaModel) {
m.QKNormType = "none"
},
want: "unsupported qk_norm_type",
},
{
name: "softmax moe",
edit: func(m *lagunaModel) {
m.MoERouterUseSigmoid = false
},
want: "moe_router_use_sigmoid=false",
},
{
name: "router weight on input",
edit: func(m *lagunaModel) {
m.MoEApplyRouterWeightOnInput = true
},
want: "moe_apply_router_weight_on_input=true",
},
{
name: "unknown layer type",
edit: func(m *lagunaModel) {
m.LayerTypes[1] = "local_attention"
},
want: "unsupported layer_types[1]",
},
{
name: "nonstandard dense layout",
edit: func(m *lagunaModel) {
m.MLPOnlyLayers = []uint32{0, 3}
},
want: "unsupported mlp_only_layers",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m := base
m.LayerTypes = append([]string(nil), base.LayerTypes...)
m.NumAttentionHeadsPerLayer = append([]uint32(nil), base.NumAttentionHeadsPerLayer...)
m.MLPOnlyLayers = append([]uint32(nil), base.MLPOnlyLayers...)
tc.edit(&m)
err := m.validate()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("validate() error = %v, want substring %q", err, tc.want)
}
})
}
}
func TestLagunaGAConfigNormalizesBoolGatingAndNestedRope(t *testing.T) {
var m lagunaModel
if err := json.Unmarshal([]byte(`{
"architectures": ["LagunaForCausalLM"],
"num_hidden_layers": 1,
"hidden_size": 8,
"num_attention_heads": 2,
"num_key_value_heads": 1,
"head_dim": 4,
"gating": true,
"num_experts": 2,
"num_experts_per_tok": 1,
"moe_intermediate_size": 4,
"shared_expert_intermediate_size": 4,
"decoder_sparse_step": 1,
"mlp_layer_types": ["dense"],
"rope_parameters": {
"full_attention": {
"rope_theta": 500000,
"rope_type": "yarn",
"factor": 32,
"original_max_position_embeddings": 4096,
"beta_fast": 64,
"beta_slow": 1,
"attention_factor": 1,
"partial_rotary_factor": 0.5
},
"sliding_attention": {
"rope_theta": 10000,
"rope_type": "default",
"partial_rotary_factor": 1
}
}
}`), &m); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if err := m.validate(); err != nil {
t.Fatalf("validate() error = %v", err)
}
if m.Gating != "true" {
t.Fatalf("Gating = %q, want raw true marker", m.Gating)
}
if !m.Gating.perHead() {
t.Fatal("expected bool gating to normalize as per-head support")
}
if m.QKNormType != "rmsnorm" {
t.Fatalf("QKNormType = %q, want rmsnorm default", m.QKNormType)
}
if !m.MoERouterUseSigmoid {
t.Fatal("MoERouterUseSigmoid should default true")
}
if !m.NormTopKProb {
t.Fatal("NormTopKProb should default true")
}
if diff := cmp.Diff(m.MLPOnlyLayers, []uint32{0}); diff != "" {
t.Fatalf("MLPOnlyLayers mismatch (-got +want):\n%s", diff)
}
if m.RopeParameters.RopeTheta != 500000 || m.RopeParameters.PartialRotaryFactor != 0.5 {
t.Fatalf("full rope = %#v, want theta=500000 partial=0.5", m.RopeParameters)
}
if m.SwaRopeParameters.RopeTheta != 10000 || m.SwaRopeParameters.PartialRotaryFactor != 1 {
t.Fatalf("swa rope = %#v, want theta=10000 partial=1", m.SwaRopeParameters)
}
}
func validLagunaTestModel() lagunaModel {
return lagunaModel{
ModelParameters: ModelParameters{
VocabSize: 32,
},
NumHiddenLayers: 2,
HiddenSize: 8,
IntermediateSize: 16,
NumAttentionHeads: 2,
NumKeyValueHeads: 1,
HeadDim: 4,
RMSNormEPS: 1e-6,
MaxPositionEmbeddings: 4096,
SlidingWindow: 512,
Gating: "per-head",
QKNormType: "rmsnorm",
LayerTypes: []string{"global_attention", "sliding_attention"},
NumAttentionHeadsPerLayer: []uint32{2, 2},
NumExperts: 2,
NumExpertsPerTok: 1,
MoEIntermediateSize: 4,
SharedExpertIntermediateSize: 4,
NormTopKProb: true,
MoeRoutedScalingFactor: 2.5,
MoERouterUseSigmoid: true,
DecoderSparseStep: 1,
MLPOnlyLayers: []uint32{0},
}
}
func validLagunaTestTensors(m lagunaModel) []Tensor {
ts := []Tensor{
newLagunaTestTensor("token_embd.weight", uint64(m.VocabSize), uint64(m.HiddenSize)),
newLagunaTestTensor("output_norm.weight", uint64(m.HiddenSize)),
}
for layer := range m.NumHiddenLayers {
prefix := fmt.Sprintf("blk.%d", layer)
heads := uint64(m.numHeadsForLayer(layer))
attnWidth := heads * uint64(m.HeadDim)
kvWidth := uint64(m.NumKeyValueHeads * m.HeadDim)
ts = append(ts,
newLagunaTestTensor(prefix+".attn_norm.weight", uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".ffn_norm.weight", uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".attn_q.weight", attnWidth, uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".attn_k.weight", kvWidth, uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".attn_v.weight", kvWidth, uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".attn_output.weight", uint64(m.HiddenSize), attnWidth),
newLagunaTestTensor(prefix+".attn_g.weight", heads, uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".attn_q_norm.weight", uint64(m.HeadDim)),
newLagunaTestTensor(prefix+".attn_k_norm.weight", uint64(m.HeadDim)),
)
if m.layerUsesMoE(layer) {
ts = append(ts,
newLagunaTestTensor(prefix+".ffn_gate_inp.weight", uint64(m.NumExperts), uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".exp_probs_b.bias", uint64(m.NumExperts)),
newLagunaTestTensor(prefix+".ffn_gate_shexp.weight", uint64(m.SharedExpertIntermediateSize), uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".ffn_up_shexp.weight", uint64(m.SharedExpertIntermediateSize), uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".ffn_down_shexp.weight", uint64(m.HiddenSize), uint64(m.SharedExpertIntermediateSize)),
)
for expert := range m.NumExperts {
ts = append(ts,
newLagunaTestTensor(fmt.Sprintf("%s.mlp.experts.%d.gate_proj.weight", prefix, expert), uint64(m.MoEIntermediateSize), uint64(m.HiddenSize)),
newLagunaTestTensor(fmt.Sprintf("%s.mlp.experts.%d.up_proj.weight", prefix, expert), uint64(m.MoEIntermediateSize), uint64(m.HiddenSize)),
newLagunaTestTensor(fmt.Sprintf("%s.mlp.experts.%d.down_proj.weight", prefix, expert), uint64(m.HiddenSize), uint64(m.MoEIntermediateSize)),
)
}
} else {
ts = append(ts,
newLagunaTestTensor(prefix+".ffn_gate.weight", uint64(m.IntermediateSize), uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".ffn_up.weight", uint64(m.IntermediateSize), uint64(m.HiddenSize)),
newLagunaTestTensor(prefix+".ffn_down.weight", uint64(m.HiddenSize), uint64(m.IntermediateSize)),
)
}
}
return ts
}
func TestLagunaTensorsMergeRoutedExperts(t *testing.T) {
m := validLagunaTestModel()
out := m.Tensors(validLagunaTestTensors(m))
tensors := make(map[string]*ggml.Tensor, len(out))
for _, t := range out {
tensors[t.Name] = t
}
tests := map[string][]uint64{
"blk.1.ffn_gate_exps.weight": {uint64(m.NumExperts), uint64(m.MoEIntermediateSize), uint64(m.HiddenSize)},
"blk.1.ffn_up_exps.weight": {uint64(m.NumExperts), uint64(m.MoEIntermediateSize), uint64(m.HiddenSize)},
"blk.1.ffn_down_exps.weight": {uint64(m.NumExperts), uint64(m.HiddenSize), uint64(m.MoEIntermediateSize)},
}
for name, wantShape := range tests {
tensor, ok := tensors[name]
if !ok {
t.Fatalf("missing merged tensor %q", name)
}
if diff := cmp.Diff(wantShape, tensor.Shape); diff != "" {
t.Fatalf("%s shape mismatch (-want +got):\n%s", name, diff)
}
}
for expert := range m.NumExperts {
name := fmt.Sprintf("blk.1.mlp.experts.%d.gate_proj.weight", expert)
if _, ok := tensors[name]; ok {
t.Fatalf("unexpected unmerged expert tensor %q", name)
}
}
}
func TestLagunaKVShape(t *testing.T) {
m := lagunaModel{
NumHiddenLayers: 4,
HiddenSize: 128,
IntermediateSize: 256,
NumAttentionHeads: 8,
NumKeyValueHeads: 4,
HeadDim: 16,
RMSNormEPS: 1e-6,
MaxPositionEmbeddings: 4096,
SlidingWindow: 512,
PartialRotaryFactor: 0.5,
Gating: "per-head",
QKNormType: "rmsnorm",
LayerTypes: []string{"full_attention", "sliding_attention", "sliding_attention", "sliding_attention"},
NumAttentionHeadsPerLayer: []uint32{8, 16, 16, 16},
NumExperts: 32,
NumExpertsPerTok: 4,
MoEIntermediateSize: 64,
SharedExpertIntermediateSize: 64,
NormTopKProb: true,
MoeRoutedScalingFactor: 2.5,
MoERouterUseSigmoid: true,
DecoderSparseStep: 1,
MLPOnlyLayers: []uint32{0},
}
m.RopeParameters.RopeTheta = 500000
m.RopeParameters.RopeType = "yarn"
m.RopeParameters.Factor = 32
m.RopeParameters.OriginalMaxPositionEmbeddings = 4096
m.RopeParameters.BetaFast = 64
m.RopeParameters.BetaSlow = 1
m.SwaRopeParameters.RopeTheta = 10000
m.SwaRopeParameters.RopeType = "linear"
m.SwaRopeParameters.Factor = 1
m.SwaRopeParameters.PartialRotaryFactor = 1
kv := m.KV(&Tokenizer{Vocabulary: &Vocabulary{}, Template: "{% include 'chat_template.jinja' %}"})
required := []string{
"general.architecture",
"tokenizer.ggml.pre",
"laguna.block_count",
"laguna.context_length",
"laguna.embedding_length",
"laguna.feed_forward_length",
"laguna.attention.head_count",
"laguna.attention.head_count_kv",
"laguna.attention.key_length",
"laguna.attention.value_length",
"laguna.attention.layer_norm_rms_epsilon",
"laguna.attention.sliding_window",
"laguna.attention.layer_types",
"laguna.attention.sliding_window_pattern",
"laguna.attention.gating_type",
"laguna.attention.qk_norm",
"laguna.expert_count",
"laguna.expert_used_count",
"laguna.expert_feed_forward_length",
"laguna.expert_shared_feed_forward_length",
"laguna.expert_shared_count",
"laguna.expert_weights_norm",
"laguna.expert_weights_scale",
"laguna.expert_gating_func",
"laguna.leading_dense_block_count",
"laguna.dense_layers",
"laguna.rope.freq_base",
"laguna.rope.scaling.type",
"laguna.rope.scaling.factor",
"laguna.rope.partial_rotary_factor",
"laguna.rope.swa.freq_base",
"laguna.rope.swa.scaling.type",
"laguna.rope.dimension_count",
"laguna.rope.swa.dimension_count",
}
for _, k := range required {
if _, ok := kv[k]; !ok {
t.Errorf("missing required KV: %s", k)
}
}
if got := kv["general.architecture"]; got != "laguna" {
t.Errorf("architecture = %v, want laguna", got)
}
if got := kv["tokenizer.ggml.add_bos_token"]; got != false {
t.Errorf("tokenizer.ggml.add_bos_token = %v, want false", got)
}
if _, ok := kv["tokenizer.chat_template"]; ok {
t.Fatal("tokenizer.chat_template should be omitted for Laguna")
}
if got := kv["laguna.expert_gating_func"]; got != lagunaGatingFuncSigmoid {
t.Errorf("expert_gating_func = %v, want sigmoid(%d)", got, lagunaGatingFuncSigmoid)
}
if got := kv["laguna.leading_dense_block_count"]; got != uint32(1) {
t.Errorf("leading_dense_block_count = %v, want 1", got)
}
if got := kv["laguna.rope.dimension_count"]; got != uint32(8) {
t.Errorf("rope.dimension_count = %v, want 8", got)
}
if got := kv["laguna.rope.swa.dimension_count"]; got != uint32(16) {
t.Errorf("rope.swa.dimension_count = %v, want 16", got)
}
if got, ok := kv["laguna.attention.layer_types"].([]uint32); !ok || len(got) != 4 || got[0] != 0 || got[1] != 1 || got[2] != 1 || got[3] != 1 {
t.Fatalf("layer_types = %#v, want [0 1 1 1]", kv["laguna.attention.layer_types"])
}
if got, ok := kv["laguna.attention.sliding_window_pattern"].([]bool); !ok || len(got) != 4 || got[0] || !got[1] || !got[2] || !got[3] {
t.Fatalf("sliding_window_pattern = %#v, want [false true true true]", kv["laguna.attention.sliding_window_pattern"])
}
}
func TestLagunaKVYarnAttentionFactorFallback(t *testing.T) {
m := validLagunaTestModel()
m.RopeParameters.RopeType = "yarn"
m.RopeParameters.Factor = 32
kv := m.KV(&Tokenizer{Vocabulary: &Vocabulary{}})
got, ok := kv["laguna.rope.scaling.attn_factor"].(float32)
if !ok {
t.Fatalf("attn_factor type = %T, want float32", kv["laguna.rope.scaling.attn_factor"])
}
want := float32(0.1*math.Log(32) + 1)
if diff := math.Abs(float64(got - want)); diff > 1e-6 {
t.Fatalf("attn_factor = %v, want %v", got, want)
}
}
+409
View File
@@ -3,6 +3,7 @@ package convert
import (
"cmp"
"encoding/json"
"errors"
"fmt"
"io/fs"
"math"
@@ -69,7 +70,415 @@ type nemotronHModel struct {
ExpertGroupUsedCount uint32 `json:"topk_group"`
}
type nemotronHNanoVLModel struct {
ModelParameters
MaxSequenceLength uint32 `json:"max_sequence_length"`
ForceImageSize uint32 `json:"force_image_size"`
DownsampleRatio float32 `json:"downsample_ratio"`
PatchSize uint32 `json:"patch_size"`
UseThumbnail *bool `json:"use_thumbnail"`
ImgContextTokenID uint32 `json:"img_context_token_id"`
ImgContextToken string `json:"img_context_token"`
ImgStartToken string `json:"img_start_token"`
ImgEndToken string `json:"img_end_token"`
VitHiddenSize uint32 `json:"vit_hidden_size"`
ProjectorHidden uint32 `json:"projector_hidden_size"`
SoundContextTokenID uint32 `json:"sound_context_token_id"`
SoundContextToken string `json:"sound_context_token"`
NormMean []float32 `json:"norm_mean"`
NormStd []float32 `json:"norm_std"`
VisionConfig radioConfig `json:"vision_config"`
SoundConfig soundConfig `json:"sound_config"`
LLMConfig nemotronHModel `json:"llm_config"`
Preprocessor struct {
ImageSize uint32 `json:"image_size"`
PatchSize uint32 `json:"patch_size"`
DownsampleRatio float32 `json:"downsample_ratio"`
MaxNumTiles uint32 `json:"max_num_tiles"`
UseThumbnail *bool `json:"use_thumbnail"`
NormMean []float32 `json:"norm_mean"`
NormStd []float32 `json:"norm_std"`
}
}
type soundConfig struct {
ModelType string `json:"model_type"`
HiddenSize uint32 `json:"hidden_size"`
NumAttentionHeads uint32 `json:"num_attention_heads"`
NumHiddenLayers uint32 `json:"num_hidden_layers"`
IntermediateSize uint32 `json:"intermediate_size"`
ConvKernelSize uint32 `json:"conv_kernel_size"`
SubsamplingConvChannels uint32 `json:"subsampling_conv_channels"`
SubsamplingConvKernelSize uint32 `json:"subsampling_conv_kernel_size"`
SubsamplingConvStride uint32 `json:"subsampling_conv_stride"`
SubsamplingFactor uint32 `json:"subsampling_factor"`
NumMelBins uint32 `json:"num_mel_bins"`
ProjectionHiddenSize uint32 `json:"projection_hidden_size"`
SamplingRate uint32 `json:"sampling_rate"`
ScaleInput bool `json:"scale_input"`
}
type radioConfig struct {
Version string `json:"version"`
PatchSize uint32 `json:"patch_size"`
MaxResolution uint32 `json:"max_resolution"`
MinNumPatches uint32 `json:"min_num_patches"`
MaxNumPatches uint32 `json:"max_num_patches"`
SeparateVideoEmbedder bool `json:"separate_video_embedder"`
Args struct {
MinNumPatches uint32 `json:"min_num_patches"`
MaxNumPatches uint32 `json:"max_num_patches"`
} `json:"args"`
}
var _ ModelConverter = (*nemotronHModel)(nil)
var _ ModelConverter = (*nemotronHNanoVLModel)(nil)
func (n *nemotronHNanoVLModel) parseMore(fsys fs.FS) error {
if n.MaxSequenceLength > 0 {
n.LLMConfig.MaxPositionEmbeddings = n.MaxSequenceLength
}
if err := n.LLMConfig.parseMore(fsys); err != nil {
return err
}
if bts, err := fs.ReadFile(fsys, "preprocessor_config.json"); err == nil {
if err := json.Unmarshal(bts, &n.Preprocessor); err != nil {
return fmt.Errorf("nemotron_h_omni: parse preprocessor_config.json: %w", err)
}
} else if !errors.Is(err, fs.ErrNotExist) {
return err
}
if version := strings.TrimSpace(n.VisionConfig.Version); version != "" && version != "radio_v2.5-h" {
return fmt.Errorf("nemotron_h_omni: unsupported RADIO version %q", version)
}
if patchSize := n.visionPatchSize(); patchSize != 16 {
return fmt.Errorf("nemotron_h_omni: unsupported vision patch_size=%d", patchSize)
}
if scale := n.visionProjectorScaleFactor(); scale != 2 {
return fmt.Errorf("nemotron_h_omni: unsupported vision projector scale factor=%d", scale)
}
if n.SoundConfig.NumHiddenLayers > 0 {
if modelType := strings.TrimSpace(n.SoundConfig.ModelType); modelType != "" && modelType != "parakeet" {
return fmt.Errorf("nemotron_h_omni: unsupported sound model_type %q", modelType)
}
if n.soundHiddenSize() == 0 {
return fmt.Errorf("nemotron_h_omni: sound hidden_size must be set")
}
if n.soundAttentionHeads() == 0 {
return fmt.Errorf("nemotron_h_omni: sound num_attention_heads must be set")
}
if n.soundSubsamplingFactor() != 8 {
return fmt.Errorf("nemotron_h_omni: unsupported sound subsampling_factor=%d", n.soundSubsamplingFactor())
}
if n.soundMelBins() != 128 {
return fmt.Errorf("nemotron_h_omni: unsupported sound num_mel_bins=%d", n.soundMelBins())
}
}
return nil
}
func (n *nemotronHNanoVLModel) KV(t *Tokenizer) KV {
kv := n.LLMConfig.KV(t)
kv["general.architecture"] = "nemotron_h_omni"
kv["vision.block_count"] = n.visionBlockCount()
kv["vision.embedding_length"] = n.visionEmbeddingLength()
kv["vision.feed_forward_length"] = n.visionFeedForwardLength()
kv["vision.attention.head_count"] = n.visionAttentionHeads()
kv["vision.attention.layer_norm_epsilon"] = float32(1e-6)
kv["vision.patch_size"] = n.visionPatchSize()
kv["vision.image_size"] = n.visionImageSize()
kv["vision.max_tiles"] = n.visionMaxTiles()
kv["vision.use_thumbnail"] = n.visionUseThumbnail()
if minPatches := n.visionMinNumPatches(); minPatches > 0 {
kv["vision.min_num_patches"] = minPatches
}
if maxPatches := n.visionMaxNumPatches(); maxPatches > 0 {
kv["vision.max_num_patches"] = maxPatches
}
kv["vision.num_channels"] = uint32(3)
kv["vision.image_mean"] = slices.Clone(defaultFloat32Slice(n.visionMean(), imageNetStandardMean))
kv["vision.image_std"] = slices.Clone(defaultFloat32Slice(n.visionStd(), imageNetStandardSTD))
kv["vision.projector.scale_factor"] = n.visionProjectorScaleFactor()
setTokenID := func(key string, explicit uint32, token string) {
if explicit > 0 {
kv[key] = explicit
return
}
if t == nil || t.Vocabulary == nil {
return
}
for i, v := range t.Vocabulary.Tokens {
if v == token {
kv[key] = uint32(i)
return
}
}
}
setTokenID("vision.image_token_id", n.ImgContextTokenID, cmp.Or(n.ImgContextToken, "<image>"))
setTokenID("vision.image_start_token_id", 0, cmp.Or(n.ImgStartToken, "<img>"))
setTokenID("vision.image_end_token_id", 0, cmp.Or(n.ImgEndToken, "</img>"))
if n.SoundConfig.NumHiddenLayers > 0 {
kv["audio.block_count"] = n.SoundConfig.NumHiddenLayers
kv["audio.embedding_length"] = n.soundHiddenSize()
kv["audio.feed_forward_length"] = n.soundFeedForwardLength()
kv["audio.attention.head_count"] = n.soundAttentionHeads()
kv["audio.attention.layer_norm_epsilon"] = float32(1e-5)
kv["audio.conv_kernel_size"] = n.soundConvKernelSize()
kv["audio.num_mel_bins"] = n.soundMelBins()
kv["audio.sample_rate"] = n.soundSampleRate()
kv["audio.subsampling_factor"] = n.soundSubsamplingFactor()
kv["audio.subsampling_conv_channels"] = n.soundSubsamplingConvChannels()
kv["audio.subsampling_conv_kernel_size"] = n.soundSubsamplingConvKernelSize()
kv["audio.subsampling_conv_stride"] = n.soundSubsamplingConvStride()
kv["audio.projection_hidden_size"] = n.soundProjectionHiddenSize()
kv["audio.scale_input"] = n.SoundConfig.ScaleInput
setTokenID("audio.sound_token_id", n.SoundContextTokenID, cmp.Or(n.SoundContextToken, "<so_embedding>"))
}
return kv
}
func (n *nemotronHNanoVLModel) Tensors(ts []Tensor) []*ggml.Tensor {
var textTensors []Tensor
var out []*ggml.Tensor
for _, t := range ts {
switch {
case isNemotronHNanoVLOmittedTensor(t.Name()):
continue
case strings.Contains(t.Name(), ".attn_qkv"):
out = append(out, slices.Collect(splitDim(t, 0,
split{Replacer: strings.NewReplacer("attn_qkv", "attn_q")},
split{Replacer: strings.NewReplacer("attn_qkv", "attn_k")},
split{Replacer: strings.NewReplacer("attn_qkv", "attn_v")},
))...)
case t.Name() == "v.position_embd":
shape := t.Shape()
if len(shape) == 3 && shape[0] == 1 {
shape = shape[1:]
}
out = append(out, &ggml.Tensor{
Name: t.Name(),
Kind: t.Kind(),
Shape: shape,
WriterTo: t,
})
case strings.HasPrefix(t.Name(), "a.") || strings.HasPrefix(t.Name(), "v.") || strings.HasPrefix(t.Name(), "mm."):
name := t.Name()
shape := slices.Clone(t.Shape())
if strings.HasPrefix(name, "a.blk.") && strings.Contains(name, ".conv_dw.") && strings.HasSuffix(name, ".weight") && len(shape) == 3 {
t.SetRepacker(squeezeMiddleDim)
shape = []uint64{shape[0], shape[2]}
}
if strings.HasPrefix(name, "a.blk.") && (strings.Contains(name, ".conv_pw1.") || strings.Contains(name, ".conv_pw2.")) && strings.HasSuffix(name, ".weight") && len(shape) == 3 && shape[2] == 1 {
t.SetRepacker(squeezeLastDim)
shape = shape[:2]
}
out = append(out, &ggml.Tensor{
Name: name,
Kind: t.Kind(),
Shape: shape,
WriterTo: t,
})
default:
textTensors = append(textTensors, t)
}
}
return append(n.LLMConfig.Tensors(textTensors), out...)
}
func (n *nemotronHNanoVLModel) Replacements() []string {
return append([]string{
"language_model.", "",
"vision_model.radio_model.model.patch_generator.embedder", "v.patch_embd",
"vision_model.radio_model.model.patch_generator.pos_embed", "v.position_embd",
"vision_model.radio_model.model.patch_generator.cls_token.token", "v.cls_embd",
"vision_model.radio_model.model.blocks", "v.blk",
"attn.qkv", "attn_qkv",
"attn.proj", "attn_out",
"mlp.fc1", "ffn_up",
"mlp.fc2", "ffn_down",
"norm1", "ln1",
"norm2", "ln2",
"mlp1.0", "mm.norm",
"mlp1.1", "mm.1",
"mlp1.3", "mm.2",
"sound_encoder.encoder.feature_extractor.featurizer.fb", "a.feature_extractor.fb",
"sound_encoder.encoder.feature_extractor.featurizer.window", "a.feature_extractor.window",
"sound_encoder.encoder.subsampling.layers.0", "a.subsampling.conv0",
"sound_encoder.encoder.subsampling.layers.2", "a.subsampling.dw1",
"sound_encoder.encoder.subsampling.layers.3", "a.subsampling.pw1",
"sound_encoder.encoder.subsampling.layers.5", "a.subsampling.dw2",
"sound_encoder.encoder.subsampling.layers.6", "a.subsampling.pw2",
"sound_encoder.encoder.subsampling.linear", "a.subsampling.linear",
"sound_encoder.encoder.layers", "a.blk",
"feed_forward1.linear1", "ffn1_up",
"feed_forward1.linear2", "ffn1_down",
"feed_forward2.linear1", "ffn2_up",
"feed_forward2.linear2", "ffn2_down",
"norm_feed_forward1", "ffn1_norm",
"norm_feed_forward2", "ffn2_norm",
"norm_self_att", "attn_norm",
"norm_conv", "conv_norm",
"norm_out", "out_norm",
"self_attn.q_proj", "attn_q",
"self_attn.k_proj", "attn_k",
"self_attn.v_proj", "attn_v",
"self_attn.o_proj", "attn_out",
"self_attn.relative_k_proj", "attn_rel_k",
"self_attn.bias_u", "attn_bias_u",
"self_attn.bias_v", "attn_bias_v",
"conv.pointwise_conv1", "conv_pw1",
"conv.pointwise_conv2", "conv_pw2",
"conv.depthwise_conv", "conv_dw",
"conv.norm", "conv_bn",
"sound_projection.norm", "mm.a.norm",
"sound_projection.linear1", "mm.a.1",
"sound_projection.linear2", "mm.a.2",
}, n.LLMConfig.Replacements()...)
}
func (n *nemotronHNanoVLModel) specialTokenTypes() []string {
return n.LLMConfig.specialTokenTypes()
}
func isNemotronHNanoVLOmittedTensor(name string) bool {
return strings.HasSuffix(name, ".conv_bn.num_batches_tracked") ||
strings.HasPrefix(name, "vision_model.radio_model.input_conditioner.") ||
strings.HasPrefix(name, "vision_model.radio_model.model.patch_generator.video_embedder")
}
func squeezeLastDim(_ string, data []float32, _ []uint64) ([]float32, error) {
return data, nil
}
func (n *nemotronHNanoVLModel) visionImageSize() uint32 {
return cmp.Or(n.ForceImageSize, n.Preprocessor.ImageSize, uint32(512))
}
func (n *nemotronHNanoVLModel) visionPatchSize() uint32 {
return cmp.Or(n.PatchSize, n.Preprocessor.PatchSize, n.VisionConfig.PatchSize, uint32(16))
}
func (n *nemotronHNanoVLModel) visionProjectorScaleFactor() uint32 {
ratio := cmp.Or(n.DownsampleRatio, n.Preprocessor.DownsampleRatio, float32(0.5))
if ratio <= 0 {
return 2
}
return max(uint32(1), uint32(math.Round(1.0/float64(ratio))))
}
func (n *nemotronHNanoVLModel) visionBlockCount() uint32 {
return 32
}
func (n *nemotronHNanoVLModel) visionEmbeddingLength() uint32 {
return cmp.Or(n.VitHiddenSize, uint32(1280))
}
func (n *nemotronHNanoVLModel) visionAttentionHeads() uint32 {
return 16
}
func (n *nemotronHNanoVLModel) visionFeedForwardLength() uint32 {
return 4 * n.visionEmbeddingLength()
}
func (n *nemotronHNanoVLModel) visionMaxTiles() uint32 {
return cmp.Or(n.Preprocessor.MaxNumTiles, uint32(12))
}
func (n *nemotronHNanoVLModel) visionMinNumPatches() uint32 {
return cmp.Or(n.VisionConfig.MinNumPatches, n.VisionConfig.Args.MinNumPatches)
}
func (n *nemotronHNanoVLModel) visionMaxNumPatches() uint32 {
return cmp.Or(n.VisionConfig.MaxNumPatches, n.VisionConfig.Args.MaxNumPatches)
}
func (n *nemotronHNanoVLModel) visionUseThumbnail() bool {
for _, v := range []*bool{n.UseThumbnail, n.Preprocessor.UseThumbnail} {
if v != nil {
return *v
}
}
return true
}
func (n *nemotronHNanoVLModel) visionMean() []float32 {
if len(n.NormMean) > 0 {
return n.NormMean
}
return n.Preprocessor.NormMean
}
func (n *nemotronHNanoVLModel) visionStd() []float32 {
if len(n.NormStd) > 0 {
return n.NormStd
}
return n.Preprocessor.NormStd
}
func (n *nemotronHNanoVLModel) soundHiddenSize() uint32 {
return cmp.Or(n.SoundConfig.HiddenSize, uint32(1024))
}
func (n *nemotronHNanoVLModel) soundAttentionHeads() uint32 {
return cmp.Or(n.SoundConfig.NumAttentionHeads, uint32(8))
}
func (n *nemotronHNanoVLModel) soundFeedForwardLength() uint32 {
return cmp.Or(n.SoundConfig.IntermediateSize, 4*n.soundHiddenSize())
}
func (n *nemotronHNanoVLModel) soundConvKernelSize() uint32 {
return cmp.Or(n.SoundConfig.ConvKernelSize, uint32(9))
}
func (n *nemotronHNanoVLModel) soundMelBins() uint32 {
return cmp.Or(n.SoundConfig.NumMelBins, uint32(128))
}
func (n *nemotronHNanoVLModel) soundSampleRate() uint32 {
return cmp.Or(n.SoundConfig.SamplingRate, uint32(16000))
}
func (n *nemotronHNanoVLModel) soundSubsamplingFactor() uint32 {
return cmp.Or(n.SoundConfig.SubsamplingFactor, uint32(8))
}
func (n *nemotronHNanoVLModel) soundSubsamplingConvChannels() uint32 {
return cmp.Or(n.SoundConfig.SubsamplingConvChannels, uint32(256))
}
func (n *nemotronHNanoVLModel) soundSubsamplingConvKernelSize() uint32 {
return cmp.Or(n.SoundConfig.SubsamplingConvKernelSize, uint32(3))
}
func (n *nemotronHNanoVLModel) soundSubsamplingConvStride() uint32 {
return cmp.Or(n.SoundConfig.SubsamplingConvStride, uint32(2))
}
func (n *nemotronHNanoVLModel) soundProjectionHiddenSize() uint32 {
return cmp.Or(n.SoundConfig.ProjectionHiddenSize, uint32(4096))
}
var (
imageNetStandardMean = []float32{0.48145466, 0.4578275, 0.40821073}
imageNetStandardSTD = []float32{0.26862954, 0.26130258, 0.27577711}
)
func (n *nemotronHModel) parseMore(_ fs.FS) error {
if n.NumHiddenLayers == 0 {
+310
View File
@@ -217,6 +217,316 @@ func TestNemotronHLoadModelMetadata(t *testing.T) {
}
}
func TestNemotronHNanoVLLoadModelMetadata(t *testing.T) {
tempDir := t.TempDir()
config := `{
"architectures": ["NemotronH_Nano_VL_V2"],
"model_type": "NemotronH_Nano_VL_V2",
"max_sequence_length": 131072,
"force_image_size": 512,
"downsample_ratio": 0.5,
"patch_size": 16,
"use_thumbnail": true,
"img_context_token_id": 18,
"img_context_token": "<image>",
"img_start_token": "<img>",
"img_end_token": "</img>",
"sound_context_token_id": 27,
"sound_context_token": "<so_embedding>",
"vit_hidden_size": 1280,
"projector_hidden_size": 20480,
"norm_mean": [0.48145466, 0.4578275, 0.40821073],
"norm_std": [0.26862954, 0.26130258, 0.27577711],
"vision_config": {
"version": "radio_v2.5-h",
"patch_size": 16,
"max_resolution": 2048,
"separate_video_embedder": true
},
"sound_config": {
"model_type": "parakeet",
"hidden_size": 1024,
"num_attention_heads": 8,
"num_hidden_layers": 24,
"intermediate_size": 4096,
"conv_kernel_size": 9,
"subsampling_conv_channels": 256,
"subsampling_conv_kernel_size": 3,
"subsampling_conv_stride": 2,
"subsampling_factor": 8,
"num_mel_bins": 128,
"projection_hidden_size": 4096,
"sampling_rate": 16000
},
"llm_config": {
"architectures": ["NemotronHForCausalLM"],
"model_type": "nemotron_h",
"num_hidden_layers": 4,
"hidden_size": 512,
"max_position_embeddings": 262144,
"num_attention_heads": 8,
"num_key_value_heads": 2,
"head_dim": 64,
"layer_norm_epsilon": 1e-5,
"conv_kernel": 4,
"ssm_state_size": 128,
"mamba_num_heads": 16,
"mamba_head_dim": 32,
"n_groups": 8,
"hybrid_override_pattern": "ME*M",
"n_routed_experts": 16,
"num_experts_per_tok": 4,
"moe_intermediate_size": 256
}
}`
if err := os.WriteFile(filepath.Join(tempDir, "config.json"), []byte(config), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tempDir, "preprocessor_config.json"), []byte(`{
"image_size": 512,
"patch_size": 16,
"downsample_ratio": 0.5,
"max_num_tiles": 12,
"use_thumbnail": true,
"norm_mean": [0.48145466, 0.4578275, 0.40821073],
"norm_std": [0.26862954, 0.26130258, 0.27577711]
}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tempDir, "tokenizer.json"), []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
}
conv, tokenizer, err := LoadModelMetadata(os.DirFS(tempDir))
if err != nil {
t.Fatal(err)
}
if _, ok := conv.(*nemotronHNanoVLModel); !ok {
t.Fatalf("unexpected converter type: %T", conv)
}
kv := conv.KV(tokenizer)
if got, want := kv["general.architecture"], "nemotron_h_omni"; got != want {
t.Fatalf("unexpected architecture: got %v want %v", got, want)
}
if got, want := kv["context_length"], uint32(131072); got != want {
t.Fatalf("unexpected context length: got %v want %v", got, want)
}
if got, want := kv["vision.block_count"], uint32(32); got != want {
t.Fatalf("unexpected vision block count: got %v want %v", got, want)
}
if got, want := kv["vision.image_size"], uint32(512); got != want {
t.Fatalf("unexpected vision image size: got %v want %v", got, want)
}
if got, want := kv["vision.projector.scale_factor"], uint32(2); got != want {
t.Fatalf("unexpected projector scale factor: got %v want %v", got, want)
}
if got, want := kv["audio.block_count"], uint32(24); got != want {
t.Fatalf("unexpected audio block count: got %v want %v", got, want)
}
if got, want := kv["audio.sound_token_id"], uint32(27); got != want {
t.Fatalf("unexpected audio token id: got %v want %v", got, want)
}
if got, want := kv["audio.subsampling_factor"], uint32(8); got != want {
t.Fatalf("unexpected audio subsampling factor: got %v want %v", got, want)
}
}
func TestNemotronHNanoOmniReasoningV3LoadModelMetadata(t *testing.T) {
tempDir := t.TempDir()
config := `{
"architectures": ["NemotronH_Nano_Omni_Reasoning_V3"],
"model_type": "NemotronH_Nano_Omni_Reasoning_V3",
"max_sequence_length": 131072,
"force_image_size": 512,
"downsample_ratio": 0.5,
"patch_size": 16,
"img_context_token_id": 18,
"img_context_token": "<image>",
"img_start_token": "<img>",
"img_end_token": "</img>",
"sound_context_token_id": 27,
"sound_context_token": "<so_embedding>",
"vit_hidden_size": 1280,
"projector_hidden_size": 4096,
"vision_config": {
"version": "radio_v2.5-h",
"patch_size": 16,
"min_num_patches": 1024,
"max_num_patches": 13312,
"args": {
"min_num_patches": 1024,
"max_num_patches": 13312
}
},
"sound_config": {
"model_type": "parakeet",
"hidden_size": 1024,
"num_attention_heads": 8,
"num_hidden_layers": 24,
"intermediate_size": 4096,
"conv_kernel_size": 9,
"subsampling_conv_channels": 256,
"subsampling_conv_kernel_size": 3,
"subsampling_conv_stride": 2,
"subsampling_factor": 8,
"num_mel_bins": 128,
"projection_hidden_size": 4096,
"sampling_rate": 16000
},
"llm_config": {
"architectures": ["NemotronHForCausalLM"],
"model_type": "nemotron_h",
"num_hidden_layers": 4,
"hidden_size": 512,
"max_position_embeddings": 262144,
"num_attention_heads": 8,
"num_key_value_heads": 2,
"head_dim": 64,
"layer_norm_epsilon": 1e-5,
"conv_kernel": 4,
"ssm_state_size": 128,
"mamba_num_heads": 16,
"mamba_head_dim": 32,
"n_groups": 8,
"hybrid_override_pattern": "ME*M",
"n_routed_experts": 16,
"num_experts_per_tok": 4,
"moe_intermediate_size": 256
}
}`
if err := os.WriteFile(filepath.Join(tempDir, "config.json"), []byte(config), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tempDir, "tokenizer.json"), []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
}
conv, tokenizer, err := LoadModelMetadata(os.DirFS(tempDir))
if err != nil {
t.Fatal(err)
}
if _, ok := conv.(*nemotronHNanoVLModel); !ok {
t.Fatalf("unexpected converter type: %T", conv)
}
kv := conv.KV(tokenizer)
if got, want := kv["general.architecture"], "nemotron_h_omni"; got != want {
t.Fatalf("unexpected architecture: got %v want %v", got, want)
}
if got, want := kv["vision.block_count"], uint32(32); got != want {
t.Fatalf("unexpected vision block count: got %v want %v", got, want)
}
if got, want := kv["vision.min_num_patches"], uint32(1024); got != want {
t.Fatalf("unexpected vision min patches: got %v want %v", got, want)
}
if got, want := kv["vision.max_num_patches"], uint32(13312); got != want {
t.Fatalf("unexpected vision max patches: got %v want %v", got, want)
}
if got, want := kv["audio.block_count"], uint32(24); got != want {
t.Fatalf("unexpected audio block count: got %v want %v", got, want)
}
if got, want := kv["audio.sound_token_id"], uint32(27); got != want {
t.Fatalf("unexpected audio token id: got %v want %v", got, want)
}
}
func TestNemotronHNanoVLTensorsRetainVisionAndAudio(t *testing.T) {
m := &nemotronHNanoVLModel{
LLMConfig: nemotronHModel{NGroups: 8},
}
in := []Tensor{
&fakeTensor{
name: "blk.0.ssm_a",
shape: []uint64{4},
data: []float32{0, 1, 2, 3},
},
&fakeTensor{name: "v.blk.0.attn_qkv.weight", shape: []uint64{3840, 1280}},
&fakeTensor{name: "v.position_embd", shape: []uint64{1, 16384, 1280}},
&fakeTensor{name: "v.cls_embd", shape: []uint64{10, 1280}},
&fakeTensor{name: "mm.norm.weight", shape: []uint64{5120}},
&fakeTensor{name: "a.feature_extractor.fb", shape: []uint64{1, 128, 257}},
&fakeTensor{name: "a.subsampling.dw1.weight", shape: []uint64{256, 1, 3, 3}},
&fakeTensor{name: "a.blk.0.conv_dw.weight", shape: []uint64{1024, 1, 9}},
&fakeTensor{name: "a.blk.0.conv_pw1.weight", shape: []uint64{2048, 1024, 1}},
&fakeTensor{name: "a.blk.0.conv_bn.num_batches_tracked", shape: []uint64{1}},
&fakeTensor{name: "mm.a.1.weight", shape: []uint64{4096, 1024}},
}
out := m.Tensors(in)
got := map[string][]uint64{}
for _, tns := range out {
got[tns.Name] = tns.Shape
}
for _, name := range []string{
"blk.0.ssm_a",
"v.blk.0.attn_q.weight",
"v.blk.0.attn_k.weight",
"v.blk.0.attn_v.weight",
"v.position_embd",
"v.cls_embd",
"mm.norm.weight",
"a.feature_extractor.fb",
"a.subsampling.dw1.weight",
"a.blk.0.conv_dw.weight",
"a.blk.0.conv_pw1.weight",
"mm.a.1.weight",
} {
if _, ok := got[name]; !ok {
t.Fatalf("expected tensor %q in output", name)
}
}
if gotShape, want := got["blk.0.ssm_a"], []uint64{4, 1}; !slices.Equal(gotShape, want) {
t.Fatalf("unexpected ssm_a shape: got %v want %v", gotShape, want)
}
if gotShape, want := got["v.position_embd"], []uint64{16384, 1280}; !slices.Equal(gotShape, want) {
t.Fatalf("unexpected position embedding shape: got %v want %v", gotShape, want)
}
if gotShape, want := got["a.blk.0.conv_dw.weight"], []uint64{1024, 9}; !slices.Equal(gotShape, want) {
t.Fatalf("unexpected audio conv_dw shape: got %v want %v", gotShape, want)
}
if gotShape, want := got["a.blk.0.conv_pw1.weight"], []uint64{2048, 1024}; !slices.Equal(gotShape, want) {
t.Fatalf("unexpected audio conv_pw1 shape: got %v want %v", gotShape, want)
}
if _, ok := got["a.blk.0.conv_bn.num_batches_tracked"]; ok {
t.Fatal("audio batchnorm num_batches_tracked should be omitted")
}
}
func TestNemotronHNanoVLReplacements(t *testing.T) {
m := &nemotronHNanoVLModel{}
r := strings.NewReplacer(m.Replacements()...)
if got, want := r.Replace("language_model.backbone.layers.1.mixer.fc1_latent_proj.weight"), "blk.1.ffn_latent_in.weight"; got != want {
t.Fatalf("unexpected fc1 replacement: got %q want %q", got, want)
}
if got, want := r.Replace("language_model.lm_head.weight"), "output.weight"; got != want {
t.Fatalf("unexpected lm_head replacement: got %q want %q", got, want)
}
if got, want := r.Replace("vision_model.radio_model.model.blocks.0.attn.qkv.weight"), "v.blk.0.attn_qkv.weight"; got != want {
t.Fatalf("unexpected vision replacement: got %q want %q", got, want)
}
if got, want := r.Replace("mlp1.1.weight"), "mm.1.weight"; got != want {
t.Fatalf("unexpected projector replacement: got %q want %q", got, want)
}
if got, want := r.Replace("sound_encoder.encoder.layers.0.self_attn.q_proj.weight"), "a.blk.0.attn_q.weight"; got != want {
t.Fatalf("unexpected audio q_proj replacement: got %q want %q", got, want)
}
if got, want := r.Replace("sound_encoder.encoder.layers.0.conv.pointwise_conv1.weight"), "a.blk.0.conv_pw1.weight"; got != want {
t.Fatalf("unexpected audio conv replacement: got %q want %q", got, want)
}
if got, want := r.Replace("sound_projection.linear2.weight"), "mm.a.2.weight"; got != want {
t.Fatalf("unexpected audio projector replacement: got %q want %q", got, want)
}
}
func TestNemotronHReplacementsLatentProjections(t *testing.T) {
m := &nemotronHModel{}
r := strings.NewReplacer(m.Replacements()...)
+4 -2
View File
@@ -42,8 +42,10 @@ func (t tensorBase) Kind() uint32 {
strings.HasSuffix(t.name, ".bias") ||
strings.HasSuffix(t.name, ".shortconv.conv.weight") ||
strings.HasSuffix(t.name, ".ssm_conv1d.weight") || // SSM conv kernel must be F32 for Metal
strings.HasPrefix(t.name, "a.conv1d.") || // audio SSCP conv weights must be F32 for im2col
strings.Contains(t.name, ".conv_dw.") || // audio depthwise conv weights must be F32
strings.HasPrefix(t.name, "a.feature_extractor.") || // audio feature-extractor constants are read with BackendGet and must be real F32 values
strings.HasPrefix(t.name, "a.conv1d.") || // audio SSCP conv weights are kept F32 for im2col; this likely slows audio and should be revisited
strings.HasPrefix(t.name, "a.subsampling.") || // audio Parakeet subsampling weights are kept F32 for conv/linear stability; this likely slows audio and should be revisited
strings.Contains(t.name, ".conv_dw.") || // audio depthwise conv weights are kept F32; this likely slows audio and should be revisited
t.name == "token_types.weight" ||
t.name == "v.positional_embedding_vlm" ||
t.name == "v.position_embd.weight" ||
+416 -13
View File
@@ -5,10 +5,12 @@ import (
"bytes"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"maps"
"math"
"slices"
"strings"
@@ -23,6 +25,11 @@ type safetensorMetadata struct {
}
func parseSafetensors(fsys fs.FS, replacer *strings.Replacer, ps ...string) ([]Tensor, error) {
fp8Block, err := safetensorsFP8BlockSize(fsys)
if err != nil {
return nil, err
}
var ts []Tensor
for _, p := range ps {
f, err := fsys.Open(p)
@@ -50,24 +57,47 @@ func parseSafetensors(fsys fs.FS, replacer *strings.Replacer, ps ...string) ([]T
names := make(map[string]struct{}, len(keys))
fp8Scales, err := collectSafetensorsFP8Scales(n, headers)
if err != nil {
return nil, err
}
for _, key := range keys {
if value := headers[key]; value.Type != "" {
if _, ok := fp8Scales.consumed[key]; ok {
continue
}
// Scalar tensors (e.g. clipped linear min/max) are 0-dim in safetensors.
// Promote them to 1-dim so they can be stored in GGUF.
if len(value.Shape) == 0 {
value.Shape = []uint64{1}
}
var scale *safetensorScale
if value.Type == "F8_E4M3" {
if !fp8Block.ok {
return nil, fmt.Errorf("missing fp8 block size metadata for tensor %q", key)
}
scale = fp8Scales.byWeight[key]
if scale == nil {
return nil, fmt.Errorf("missing fp8 scale companion for tensor %q", key)
}
}
ggufName := replacer.Replace(key)
if _, ok := names[ggufName]; ok {
return nil, fmt.Errorf("duplicate tensor name '%s' was found for this model", ggufName)
}
names[ggufName] = struct{}{}
ts = append(ts, safetensor{
fs: fsys,
path: p,
dtype: value.Type,
offset: safetensorsPad(n, value.Offsets[0]),
size: safetensorsPad(n, value.Offsets[1]) - safetensorsPad(n, value.Offsets[0]),
fs: fsys,
path: p,
dtype: value.Type,
offset: safetensorsPad(n, value.Offsets[0]),
size: safetensorsPad(n, value.Offsets[1]) - safetensorsPad(n, value.Offsets[0]),
scale: scale,
fp8Block: fp8Block,
tensorBase: &tensorBase{
name: ggufName,
shape: value.Shape,
@@ -85,12 +115,22 @@ func safetensorsPad(n, offset int64) int64 {
return 8 + n + offset
}
type safetensor struct {
fs fs.FS
path string
type safetensorScale struct {
name string
dtype string
shape []uint64
offset int64
size int64
}
type safetensor struct {
fs fs.FS
path string
dtype string
offset int64
size int64
scale *safetensorScale
fp8Block safetensorFP8BlockSize
*tensorBase
}
@@ -104,17 +144,26 @@ func (st safetensor) Kind() uint32 {
kind != tensorKindFP32 {
kind = tensorKindBF16
}
if st.dtype == "F8_E4M3" && kind != tensorKindFP32 {
kind = tensorKindBF16
}
return kind
}
func (st safetensor) SourceDType() string {
return st.dtype
}
func (st safetensor) Clone() Tensor {
return &safetensor{
fs: st.fs,
path: st.path,
dtype: st.dtype,
offset: st.offset,
size: st.size,
fs: st.fs,
path: st.path,
dtype: st.dtype,
offset: st.offset,
size: st.size,
scale: st.scale.Clone(),
fp8Block: st.fp8Block,
tensorBase: &tensorBase{
name: st.name,
repacker: st.repacker,
@@ -123,6 +172,19 @@ func (st safetensor) Clone() Tensor {
}
}
func (ss *safetensorScale) Clone() *safetensorScale {
if ss == nil {
return nil
}
return &safetensorScale{
name: ss.name,
dtype: ss.dtype,
shape: slices.Clone(ss.shape),
offset: ss.offset,
size: ss.size,
}
}
func (st safetensor) WriteTo(w io.Writer) (int64, error) {
f, err := st.fs.Open(st.path)
if err != nil {
@@ -180,6 +242,16 @@ func (st safetensor) WriteTo(w io.Writer) (int64, error) {
}
f32s = bfloat16.DecodeFloat32(u8s)
case "F8_E4M3":
u8s := make([]uint8, st.size)
if err = binary.Read(br, binary.LittleEndian, u8s); err != nil {
return 0, err
}
f32s, err = st.decodeFP8E4M3(u8s)
if err != nil {
return 0, err
}
default:
return 0, fmt.Errorf("unknown data type: %s", st.dtype)
}
@@ -208,3 +280,334 @@ func (st safetensor) WriteTo(w io.Writer) (int64, error) {
return 0, fmt.Errorf("unknown storage type: %d", st.Kind())
}
}
type safetensorsFP8Scales struct {
byWeight map[string]*safetensorScale
consumed map[string]struct{}
}
func collectSafetensorsFP8Scales(n int64, headers map[string]safetensorMetadata) (safetensorsFP8Scales, error) {
scales := safetensorsFP8Scales{
byWeight: make(map[string]*safetensorScale),
consumed: make(map[string]struct{}),
}
for key, value := range headers {
if value.Type != "F8_E4M3" {
continue
}
scaleKey, scaleValue, ok, err := safetensorsFP8Scale(key, headers)
if err != nil {
return safetensorsFP8Scales{}, err
}
if !ok {
continue
}
if _, ok := scales.consumed[scaleKey]; ok {
return safetensorsFP8Scales{}, fmt.Errorf("fp8 scale companion %q is used by multiple tensors", scaleKey)
}
scales.byWeight[key] = &safetensorScale{
name: scaleKey,
dtype: scaleValue.Type,
shape: slices.Clone(scaleValue.Shape),
offset: safetensorsPad(n, scaleValue.Offsets[0]),
size: safetensorsPad(n, scaleValue.Offsets[1]) - safetensorsPad(n, scaleValue.Offsets[0]),
}
scales.consumed[scaleKey] = struct{}{}
}
return scales, nil
}
func safetensorsFP8Scale(key string, headers map[string]safetensorMetadata) (string, safetensorMetadata, bool, error) {
candidates := safetensorsFP8ScaleCandidates(key)
var scaleKey string
var scaleValue safetensorMetadata
if strings.HasSuffix(key, ".weight") {
// Keep support for compressed-tensors exports that place the scale name
// between the module path and weight suffix.
base := strings.TrimSuffix(key, ".weight")
candidates = appendUnique(candidates, base+".weight_scale")
candidates = appendUnique(candidates, base+".weight_scale_inv")
}
for _, candidate := range candidates {
if value, ok := headers[candidate]; ok && value.Type != "" {
if scaleKey != "" {
return "", safetensorMetadata{}, false, fmt.Errorf("multiple fp8 scale companions for tensor %q: %q and %q", key, scaleKey, candidate)
}
scaleKey = candidate
scaleValue = value
}
}
if scaleKey == "" {
return "", safetensorMetadata{}, false, nil
}
return scaleKey, scaleValue, true, nil
}
func safetensorsFP8ScaleCandidates(key string) []string {
var candidates []string
candidates = appendUnique(candidates, key+"_scale")
candidates = appendUnique(candidates, key+"_scale_inv")
candidates = appendUnique(candidates, key+".scale")
candidates = appendUnique(candidates, key+".scale_inv")
return candidates
}
func appendUnique(values []string, value string) []string {
if !slices.Contains(values, value) {
values = append(values, value)
}
return values
}
type safetensorFP8BlockSize struct {
rows int
cols int
ok bool
}
type safetensorsSourceQuantization struct {
QuantMethod string `json:"quant_method"`
Format string `json:"format"`
WeightBlockSize []int `json:"weight_block_size"`
ConfigGroups map[string]struct {
Format string `json:"format"`
Weights struct {
BlockStructure []int `json:"block_structure"`
NumBits int `json:"num_bits"`
Type string `json:"type"`
} `json:"weights"`
} `json:"config_groups"`
}
type safetensorsModelConfig struct {
Quantization safetensorsSourceQuantization `json:"quantization"`
QuantizationConfig safetensorsSourceQuantization `json:"quantization_config"`
CompressionConfig safetensorsSourceQuantization `json:"compression_config"`
TextConfig struct {
Quantization safetensorsSourceQuantization `json:"quantization"`
QuantizationConfig safetensorsSourceQuantization `json:"quantization_config"`
CompressionConfig safetensorsSourceQuantization `json:"compression_config"`
} `json:"text_config"`
}
func safetensorsFP8BlockSize(fsys fs.FS) (safetensorFP8BlockSize, error) {
bts, err := fs.ReadFile(fsys, "config.json")
if errors.Is(err, fs.ErrNotExist) {
return safetensorFP8BlockSize{}, nil
}
if err != nil {
return safetensorFP8BlockSize{}, err
}
bts = sanitizeNonFiniteJSON(bts)
var cfg safetensorsModelConfig
if err := json.Unmarshal(bts, &cfg); err != nil {
return safetensorFP8BlockSize{}, fmt.Errorf("parse config.json fp8 metadata: %w", err)
}
var blocks []safetensorFP8BlockSize
for _, q := range []safetensorsSourceQuantization{
cfg.Quantization,
cfg.QuantizationConfig,
cfg.CompressionConfig,
cfg.TextConfig.Quantization,
cfg.TextConfig.QuantizationConfig,
cfg.TextConfig.CompressionConfig,
} {
if strings.EqualFold(q.QuantMethod, "fp8") && len(q.WeightBlockSize) == 2 {
block, err := newSafetensorFP8BlockSize(q.WeightBlockSize[0], q.WeightBlockSize[1])
if err != nil {
return safetensorFP8BlockSize{}, err
}
blocks = append(blocks, block)
}
if !strings.EqualFold(q.QuantMethod, "compressed-tensors") && !strings.EqualFold(q.Format, "float-quantized") {
continue
}
for _, group := range q.ConfigGroups {
if !strings.EqualFold(group.Format, "float-quantized") ||
group.Weights.NumBits != 8 ||
!strings.EqualFold(group.Weights.Type, "float") ||
len(group.Weights.BlockStructure) != 2 {
continue
}
block, err := newSafetensorFP8BlockSize(group.Weights.BlockStructure[0], group.Weights.BlockStructure[1])
if err != nil {
return safetensorFP8BlockSize{}, err
}
blocks = append(blocks, block)
}
}
if len(blocks) == 0 {
return safetensorFP8BlockSize{}, nil
}
block := blocks[0]
for _, other := range blocks[1:] {
if other.rows != block.rows || other.cols != block.cols {
return safetensorFP8BlockSize{}, fmt.Errorf("multiple fp8 block sizes in config.json: %dx%d and %dx%d", block.rows, block.cols, other.rows, other.cols)
}
}
return block, nil
}
func newSafetensorFP8BlockSize(rows, cols int) (safetensorFP8BlockSize, error) {
if rows <= 0 || cols <= 0 {
return safetensorFP8BlockSize{}, fmt.Errorf("invalid fp8 block size %dx%d", rows, cols)
}
return safetensorFP8BlockSize{rows: rows, cols: cols, ok: true}, nil
}
func (st safetensor) decodeFP8E4M3(data []byte) ([]float32, error) {
if st.scale == nil {
return nil, fmt.Errorf("missing fp8 scale companion for tensor %q", st.name)
}
if !st.fp8Block.ok {
return nil, fmt.Errorf("missing fp8 block size metadata for tensor %q", st.name)
}
if len(st.shape) != 2 {
return nil, fmt.Errorf("expected 2D fp8 tensor %q, got shape %v", st.name, st.shape)
}
rows, cols := int(st.shape[0]), int(st.shape[1])
if rows < 0 || cols < 0 || rows*cols != len(data) {
return nil, fmt.Errorf("fp8 tensor %q shape %v does not match %d bytes", st.name, st.shape, len(data))
}
scale, err := st.readScale()
if err != nil {
return nil, err
}
if len(st.scale.shape) != 2 {
return nil, fmt.Errorf("expected 2D fp8 scale tensor %q, got shape %v", st.scale.name, st.scale.shape)
}
blockRows := st.fp8Block.rows
blockCols := st.fp8Block.cols
scaleRows, scaleCols := int(st.scale.shape[0]), int(st.scale.shape[1])
expectedRows := (rows + blockRows - 1) / blockRows
expectedCols := (cols + blockCols - 1) / blockCols
if scaleRows != expectedRows || scaleCols != expectedCols {
return nil, fmt.Errorf("unexpected fp8 scale shape %v for tensor %q shape %v; want [%d %d]", st.scale.shape, st.name, st.shape, expectedRows, expectedCols)
}
if len(scale) != scaleRows*scaleCols {
return nil, fmt.Errorf("fp8 scale tensor %q shape %v does not match decoded length %d", st.scale.name, st.scale.shape, len(scale))
}
f32s := make([]float32, len(data))
for r := range rows {
scaleRow := r / blockRows
rowOffset := r * cols
for c := range cols {
f32s[rowOffset+c] = decodeFloat8E4M3FN(data[rowOffset+c]) * scale[scaleRow*scaleCols+c/blockCols]
}
}
return f32s, nil
}
func (st safetensor) readScale() ([]float32, error) {
r, err := st.sectionReader(st.scale.offset, st.scale.size)
if err != nil {
return nil, fmt.Errorf("failed to read fp8 scale tensor %q: %w", st.scale.name, err)
}
if closer, ok := r.(io.Closer); ok {
defer closer.Close()
}
br := bufio.NewReaderSize(r, min(32<<10, int(st.scale.size)))
switch st.scale.dtype {
case "F32":
f32s := make([]float32, st.scale.size/4)
if err := binary.Read(br, binary.LittleEndian, f32s); err != nil {
return nil, err
}
return f32s, nil
case "F16":
u16s := make([]uint16, st.scale.size/2)
if err := binary.Read(br, binary.LittleEndian, u16s); err != nil {
return nil, err
}
f32s := make([]float32, len(u16s))
for i := range u16s {
f32s[i] = float16.Frombits(u16s[i]).Float32()
}
return f32s, nil
case "BF16":
u8s := make([]uint8, st.scale.size)
if err := binary.Read(br, binary.LittleEndian, u8s); err != nil {
return nil, err
}
return bfloat16.DecodeFloat32(u8s), nil
default:
return nil, fmt.Errorf("unsupported fp8 scale dtype %q for tensor %q", st.scale.dtype, st.scale.name)
}
}
func (st safetensor) sectionReader(offset, size int64) (io.Reader, error) {
f, err := st.fs.Open(st.path)
if err != nil {
return nil, err
}
if readerAt, ok := f.(io.ReaderAt); ok {
return &readCloserReader{
Reader: io.NewSectionReader(readerAt, offset, size),
Closer: f,
}, nil
}
if seeker, ok := f.(io.Seeker); ok {
if _, err := seeker.Seek(offset, io.SeekStart); err != nil {
f.Close()
return nil, err
}
return &readCloserReader{
Reader: io.LimitReader(f, size),
Closer: f,
}, nil
}
if _, err := io.CopyN(io.Discard, f, offset); err != nil {
f.Close()
return nil, err
}
return &readCloserReader{
Reader: io.LimitReader(f, size),
Closer: f,
}, nil
}
type readCloserReader struct {
io.Reader
io.Closer
}
func decodeFloat8E4M3FN(v byte) float32 {
sign := float32(1)
if v&0x80 != 0 {
sign = -1
}
exp := int((v >> 3) & 0x0f)
mant := int(v & 0x07)
if exp == 0 {
if mant == 0 {
return 0 * sign
}
return sign * float32(math.Ldexp(float64(mant)/8, -6))
}
if exp == 0x0f && mant == 0x07 {
return float32(math.NaN())
}
return sign * float32(math.Ldexp(1+float64(mant)/8, exp-7))
}
+229
View File
@@ -3,8 +3,10 @@ package convert
import (
"bytes"
"encoding/binary"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/d4l3k/go-bfloat16"
@@ -231,6 +233,222 @@ func TestSafetensors(t *testing.T) {
}
}
func TestSafetensorWriteToFP8E4M3(t *testing.T) {
root, err := os.OpenRoot(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer root.Close()
path := filepath.Base(t.Name())
f, err := root.Create(path)
if err != nil {
t.Fatal(err)
}
// E4M3FN encodings for 1.0, 2.0, 0.5, and -1.0.
if _, err := f.Write([]byte{0x38, 0x40, 0x30, 0xb8}); err != nil {
t.Fatal(err)
}
if _, err := f.Write(bfloat16.EncodeFloat32([]float32{2})); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
st := safetensor{
fs: root.FS(),
path: path,
dtype: "F8_E4M3",
offset: 0,
size: 4,
fp8Block: safetensorFP8BlockSize{rows: 128, cols: 128, ok: true},
scale: &safetensorScale{
name: "linear.weight_scale",
dtype: "BF16",
shape: []uint64{1, 1},
offset: 4,
size: 2,
},
tensorBase: &tensorBase{
name: "linear.weight",
shape: []uint64{2, 2},
},
}
var b bytes.Buffer
if _, err := st.WriteTo(&b); err != nil {
t.Fatal(err)
}
want := bfloat16.EncodeFloat32([]float32{2, 4, 1, -2})
if diff := cmp.Diff(want, b.Bytes()); diff != "" {
t.Errorf("safetensor.WriteTo() mismatch (-want +got):\n%s", diff)
}
}
func TestSafetensorWriteToFP8E4M3UsesConfiguredBlockSize(t *testing.T) {
root, err := os.OpenRoot(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer root.Close()
path := filepath.Base(t.Name())
f, err := root.Create(path)
if err != nil {
t.Fatal(err)
}
if _, err := f.Write(bytes.Repeat([]byte{0x38}, 12)); err != nil {
t.Fatal(err)
}
if _, err := f.Write(bfloat16.EncodeFloat32([]float32{1, 2, 3, 4})); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
st := safetensor{
fs: root.FS(),
path: path,
dtype: "F8_E4M3",
offset: 0,
size: 12,
fp8Block: safetensorFP8BlockSize{rows: 2, cols: 3, ok: true},
scale: &safetensorScale{
name: "linear.weight_scale",
dtype: "BF16",
shape: []uint64{2, 2},
offset: 12,
size: 8,
},
tensorBase: &tensorBase{
name: "linear.weight",
shape: []uint64{3, 4},
},
}
var b bytes.Buffer
if _, err := st.WriteTo(&b); err != nil {
t.Fatal(err)
}
want := bfloat16.EncodeFloat32([]float32{
1, 1, 1, 2,
1, 1, 1, 2,
3, 3, 3, 4,
})
if diff := cmp.Diff(want, b.Bytes()); diff != "" {
t.Errorf("safetensor.WriteTo() mismatch (-want +got):\n%s", diff)
}
}
func TestParseSafetensorsConsumesFP8ScaleCompanion(t *testing.T) {
tempDir := t.TempDir()
generateSafetensorTestData(t, tempDir, map[string]*tensorData{
"linear.weight": {
Offsets: []int{0, 4},
Type: "F8_E4M3",
Shape: []int{2, 2},
},
"linear.weight_scale": {
Offsets: []int{4, 6},
Type: "BF16",
Shape: []int{1, 1},
},
})
writeFP8BlockConfig(t, tempDir, 128, 128)
tensors, err := parseSafetensors(os.DirFS(tempDir), strings.NewReplacer(), "model-00001-of-00001.safetensors")
if err != nil {
t.Fatal(err)
}
if len(tensors) != 1 {
t.Fatalf("expected one tensor, got %d", len(tensors))
}
if got := tensors[0].Name(); got != "linear.weight" {
t.Fatalf("unexpected tensor name %q", got)
}
if got := tensors[0].Kind(); got != tensorKindBF16 {
t.Fatalf("unexpected fp8 converted kind %d, want %d", got, tensorKindBF16)
}
}
func TestParseSafetensorsRejectsFP8WithoutBlockMetadata(t *testing.T) {
tempDir := t.TempDir()
generateSafetensorTestData(t, tempDir, map[string]*tensorData{
"linear.weight": {
Offsets: []int{0, 4},
Type: "F8_E4M3",
Shape: []int{2, 2},
},
"linear.weight_scale": {
Offsets: []int{4, 6},
Type: "BF16",
Shape: []int{1, 1},
},
})
_, err := parseSafetensors(os.DirFS(tempDir), strings.NewReplacer(), "model-00001-of-00001.safetensors")
if err == nil || !strings.Contains(err.Error(), "missing fp8 block size metadata") {
t.Fatalf("expected missing fp8 block size metadata error, got %v", err)
}
}
func TestParseSafetensorsRejectsAmbiguousFP8ScaleCompanion(t *testing.T) {
tempDir := t.TempDir()
generateSafetensorTestData(t, tempDir, map[string]*tensorData{
"linear.weight": {
Offsets: []int{0, 4},
Type: "F8_E4M3",
Shape: []int{2, 2},
},
"linear.weight_scale": {
Offsets: []int{4, 6},
Type: "BF16",
Shape: []int{1, 1},
},
"linear.weight.scale": {
Offsets: []int{6, 8},
Type: "BF16",
Shape: []int{1, 1},
},
})
writeFP8BlockConfig(t, tempDir, 128, 128)
_, err := parseSafetensors(os.DirFS(tempDir), strings.NewReplacer(), "model-00001-of-00001.safetensors")
if err == nil || !strings.Contains(err.Error(), "multiple fp8 scale companions") {
t.Fatalf("expected ambiguous fp8 scale companion error, got %v", err)
}
}
func writeFP8BlockConfig(t *testing.T, dir string, rows, cols int) {
t.Helper()
config := fmt.Sprintf(`{
"architectures": ["GenericForCausalLM"],
"compression_config": {
"format": "float-quantized",
"config_groups": {
"group_0": {
"format": "float-quantized",
"weights": {
"type": "float",
"num_bits": 8,
"block_structure": [%d, %d]
}
}
}
}
}`, rows, cols)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(config), 0o644); err != nil {
t.Fatal(err)
}
}
func TestSafetensorKind(t *testing.T) {
tests := []struct {
name string
@@ -259,6 +477,17 @@ func TestSafetensorKind(t *testing.T) {
},
expected: tensorKindFP16,
},
{
name: "BF16 audio feature extractor constants should return FP32",
st: safetensor{
tensorBase: &tensorBase{
name: "a.feature_extractor.fb",
shape: []uint64{1, 128, 257},
},
dtype: "BF16",
},
expected: tensorKindFP32,
},
{
name: "BF16 dtype with FP32 base kind should return FP32",
st: safetensor{
+52
View File
@@ -5,6 +5,7 @@ import (
"errors"
"io"
"iter"
"maps"
"path"
"slices"
"strconv"
@@ -153,3 +154,54 @@ func (g mergeGroup) WriteTo(w io.Writer) (int64, error) {
return 0, nil
}
func sourceTensorKV(ts []*ggml.Tensor) KV {
sourceFP8 := make(map[string]struct{})
for _, t := range ts {
if writerSourceDType(t.WriterTo) == "F8_E4M3" {
sourceFP8[t.Name] = struct{}{}
}
}
if len(sourceFP8) == 0 {
return nil
}
return KV{
"source_quantization": "hf_fp8",
"source_fp8_tensors": slices.Sorted(maps.Keys(sourceFP8)),
}
}
type sourceDTypeTensor interface {
SourceDType() string
}
func writerSourceDType(w io.WriterTo) string {
switch w := w.(type) {
case sourceDTypeTensor:
return w.SourceDType()
case mergeGroup:
if len(w) == 0 {
return ""
}
dtype := sourceDType(w[0])
if dtype == "" {
return ""
}
for _, t := range w[1:] {
if sourceDType(t) != dtype {
return ""
}
}
return dtype
default:
return ""
}
}
func sourceDType(t Tensor) string {
if t, ok := t.(sourceDTypeTensor); ok {
return t.SourceDType()
}
return ""
}
+51 -5
View File
@@ -21,7 +21,8 @@ type fakeTensor struct {
shape []uint64
data []float32
repacker Repacker
sourceDType string
repacker Repacker
}
func (f fakeTensor) Name() string {
@@ -36,16 +37,21 @@ func (f fakeTensor) Kind() uint32 {
return 0
}
func (f fakeTensor) SourceDType() string {
return f.sourceDType
}
func (f *fakeTensor) SetRepacker(fn Repacker) {
f.repacker = fn
}
func (f fakeTensor) Clone() Tensor {
return &fakeTensor{
name: f.name,
shape: slices.Clone(f.shape),
data: slices.Clone(f.data),
repacker: f.repacker,
name: f.name,
shape: slices.Clone(f.shape),
data: slices.Clone(f.data),
sourceDType: f.sourceDType,
repacker: f.repacker,
}
}
@@ -995,3 +1001,43 @@ func TestMergeOrder(t *testing.T) {
})
}
}
func TestSourceTensorKVRecordsFP8OutputTensors(t *testing.T) {
fp8 := &fakeTensor{name: "linear.weight", shape: []uint64{2, 2}, sourceDType: "F8_E4M3"}
bf16 := &fakeTensor{name: "other.weight", shape: []uint64{2, 2}, sourceDType: "BF16"}
kv := sourceTensorKV([]*ggml.Tensor{
{Name: "blk.0.linear.weight", WriterTo: fp8},
{Name: "blk.0.other.weight", WriterTo: bf16},
})
if got := kv["source_quantization"]; got != "hf_fp8" {
t.Fatalf("source_quantization = %v, want hf_fp8", got)
}
got, ok := kv["source_fp8_tensors"].([]string)
if !ok {
t.Fatalf("source_fp8_tensors = %#v, want []string", kv["source_fp8_tensors"])
}
if diff := cmp.Diff([]string{"blk.0.linear.weight"}, got); diff != "" {
t.Fatalf("source_fp8_tensors mismatch (-want +got):\n%s", diff)
}
}
func TestSourceTensorKVRecordsMergedFP8OutputTensors(t *testing.T) {
fp8A := &fakeTensor{name: "expert.0.weight", shape: []uint64{2, 2}, sourceDType: "F8_E4M3"}
fp8B := &fakeTensor{name: "expert.1.weight", shape: []uint64{2, 2}, sourceDType: "F8_E4M3"}
bf16 := &fakeTensor{name: "expert.2.weight", shape: []uint64{2, 2}, sourceDType: "BF16"}
kv := sourceTensorKV([]*ggml.Tensor{
{Name: "ffn_exps.weight", WriterTo: mergeGroup{fp8A, fp8B}},
{Name: "mixed_exps.weight", WriterTo: mergeGroup{fp8A, bf16}},
})
got, ok := kv["source_fp8_tensors"].([]string)
if !ok {
t.Fatalf("source_fp8_tensors = %#v, want []string", kv["source_fp8_tensors"])
}
if diff := cmp.Diff([]string{"ffn_exps.weight"}, got); diff != "" {
t.Fatalf("source_fp8_tensors mismatch (-want +got):\n%s", diff)
}
}
+2
View File
@@ -103,6 +103,8 @@ func parseTokenizer(fsys fs.FS, specialTokenTypes []string) (*Tokenizer, error)
t.Pre = "qwen2"
case "00431aed57e696b747435f734d1e3b9b1bfd931a121fb5cac7129e97c181e9ba":
t.Pre = "qwen35"
case "b92c0824a58e1d8dc3221cf3e12c433c3a86f57e46d57229993489f0798e7702":
t.Pre = "laguna"
case "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855":
// noop, empty pretokenizer
default:
+4
View File
@@ -2,6 +2,10 @@
title: Structured Outputs
---
<Note>
Ollama's Cloud currently does not support structured outputs.
</Note>
Structured outputs let you enforce a JSON schema on model responses so you can reliably extract structured data, describe images, or keep every reply consistent.
## Generating structured JSON
+3 -1
View File
@@ -120,10 +120,12 @@
"pages": [
"/integrations/claude-code",
"/integrations/codex",
"/integrations/copilot-cli",
"/integrations/opencode",
"/integrations/droid",
"/integrations/goose",
"/integrations/pi"
"/integrations/pi",
"/integrations/poolside"
]
},
{
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+93
View File
@@ -0,0 +1,93 @@
---
title: Copilot CLI
---
GitHub Copilot CLI is GitHub's AI coding agent for the terminal. It can understand your codebase, make edits, run commands, and help you build software faster.
Open models can be used with Copilot CLI through Ollama, enabling you to use models such as `qwen3.5`, `glm-5.1:cloud`, `kimi-k2.5:cloud`.
## Install
Install [Copilot CLI](https://github.com/features/copilot/cli/):
<CodeGroup>
```shell macOS / Linux (Homebrew)
brew install copilot-cli
```
```shell npm (all platforms)
npm install -g @github/copilot
```
```shell macOS / Linux (script)
curl -fsSL https://gh.io/copilot-install | bash
```
```powershell Windows (WinGet)
winget install GitHub.Copilot
```
</CodeGroup>
## Usage with Ollama
### Quick setup
```shell
ollama launch copilot
```
### Run directly with a model
```shell
ollama launch copilot --model kimi-k2.5:cloud
```
## Recommended Models
- `kimi-k2.5:cloud`
- `glm-5:cloud`
- `minimax-m2.7:cloud`
- `qwen3.5:cloud`
- `glm-4.7-flash`
- `qwen3.5`
Cloud models are also available at [ollama.com/search?c=cloud](https://ollama.com/search?c=cloud).
## Non-interactive (headless) mode
Run Copilot CLI without interaction for use in Docker, CI/CD, or scripts:
```shell
ollama launch copilot --model kimi-k2.5:cloud --yes -- -p "how does this repository work?"
```
The `--yes` flag auto-pulls the model, skips selectors, and requires `--model` to be specified. Arguments after `--` are passed directly to Copilot CLI.
## Manual setup
Copilot CLI connects to Ollama using the OpenAI-compatible API via environment variables.
1. Set the environment variables:
```shell
export COPILOT_PROVIDER_BASE_URL=http://localhost:11434/v1
export COPILOT_PROVIDER_API_KEY=
export COPILOT_PROVIDER_WIRE_API=responses
export COPILOT_MODEL=qwen3.5
```
1. Run Copilot CLI:
```shell
copilot
```
Or run with environment variables inline:
```shell
COPILOT_PROVIDER_BASE_URL=http://localhost:11434/v1 COPILOT_PROVIDER_API_KEY= COPILOT_PROVIDER_WIRE_API=responses COPILOT_MODEL=glm-5:cloud copilot
```
**Note:** Copilot requires a large context window. We recommend at least 64k tokens. See the [context length documentation](/context-length) for how to adjust context length in Ollama.
+42 -38
View File
@@ -2,7 +2,9 @@
title: Hermes Agent
---
Hermes Agent is a self-improving AI agent built by Nous Research. It features automatic skill creation, cross-session memory, and connects messaging platforms (Telegram, Discord, Slack, WhatsApp, Signal, Email) to models through a unified gateway.
Hermes Agent is a self-improving AI agent built by Nous Research. It features automatic skill creation, cross-session memory, and 70+ skills that it ships with by default.
![Hermes Agent with Ollama](/images/hermes.png)
## Quick start
@@ -10,25 +12,56 @@ Hermes Agent is a self-improving AI agent built by Nous Research. It features au
ollama launch hermes
```
### Pull a model
Ollama handles everything automatically:
Before running the setup wizard, make sure you have a model available. Hermes will auto-detect models downloaded through Ollama.
1. **Install** — If Hermes isn't installed, Ollama prompts to install it via the Nous Research install script
2. **Model** — Pick a model from the selector (local or cloud)
3. **Onboarding** — Ollama configures the Ollama provider, points Hermes at `http://127.0.0.1:11434/v1`, and sets your model as the primary
4. **Gateway** — Optionally connects a messaging platform (Telegram, Discord, Slack, WhatsApp, Signal, Email) and launches the Hermes chat
<Note>Hermes on Windows requires WSL2. Install it with `wsl --install` and re-run from inside the WSL shell.</Note>
## Recommended models
**Cloud models**:
- `kimi-k2.5:cloud` — Multimodal reasoning with subagents
- `glm-5.1:cloud` — Reasoning and code generation
- `qwen3.5:cloud` — Reasoning, coding, and agentic tool use with vision
- `minimax-m2.7:cloud` — Fast, efficient coding and real-world productivity
**Local models:**
- `gemma4` — Reasoning and code generation locally (~16 GB VRAM)
- `qwen3.6` — Reasoning, coding, and visual understanding locally (~24 GB VRAM)
More models at [ollama.com/search](https://ollama.com/search?c=cloud).
## Connect messaging apps
Link Telegram, Discord, Slack, WhatsApp, Signal, or Email to chat with your models from anywhere:
```bash
ollama pull kimi-k2.5:cloud
hermes gateway setup
```
See [Recommended models](#recommended-models) for more options.
## Reconfigure
### Install
Re-run the full setup wizard at any time:
```bash
hermes setup
```
## Manual setup
If you'd rather drive Hermes's own wizard instead of `ollama launch hermes`, install it directly:
```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
```
### Set up
After installation, Hermes launches the setup wizard automatically. Choose **Quick setup**:
Hermes launches the setup wizard automatically. Choose **Quick setup**:
```
How would you like to set up Hermes?
@@ -84,32 +117,3 @@ Connect a messaging platform? (Telegram, Discord, etc.)
Launch hermes chat now? [Y/n]: Y
```
## Recommended models
**Cloud models**:
- `kimi-k2.5:cloud` — Multimodal reasoning with subagents
- `qwen3.5:cloud` — Reasoning, coding, and agentic tool use with vision
- `glm-5.1:cloud` — Reasoning and code generation
- `minimax-m2.7:cloud` — Fast, efficient coding and real-world productivity
**Local models:**
- `gemma4` — Reasoning and code generation locally (~16 GB VRAM)
- `qwen3.5` — Reasoning, coding, and visual understanding locally (~11 GB VRAM)
More models at [ollama.com/search](https://ollama.com/models).
## Configure later
Re-run the setup wizard at any time:
```bash
hermes setup
```
To configure just messaging:
```bash
hermes setup gateway
```
+2
View File
@@ -10,10 +10,12 @@ Coding assistants that can read, modify, and execute code in your projects.
- [Claude Code](/integrations/claude-code)
- [Codex](/integrations/codex)
- [Copilot CLI](/integrations/copilot-cli)
- [OpenCode](/integrations/opencode)
- [Droid](/integrations/droid)
- [Goose](/integrations/goose)
- [Pi](/integrations/pi)
- [Poolside](/integrations/poolside)
## Assistants
+5 -6
View File
@@ -15,7 +15,7 @@ Ollama handles everything automatically:
1. **Install** — If OpenClaw isn't installed, Ollama prompts to install it via npm
2. **Security** — On the first launch, a security notice explains the risks of tool access
3. **Model** — Pick a model from the selector (local or cloud)
4. **Onboarding** — Ollama configures the provider, installs the gateway daemon, sets your model as the primary, and installs the web search and fetch plugin
4. **Onboarding** — Ollama configures the provider, installs the gateway daemon, sets your model as the primary, and enables OpenClaw's bundled Ollama web search
5. **Gateway** — Starts in the background and opens the OpenClaw TUI
<Note>OpenClaw requires a larger context window. It is recommended to use a context window of at least 64k tokens if using local models. See [Context length](/context-length) for more information.</Note>
@@ -24,19 +24,19 @@ Ollama handles everything automatically:
## Web search and fetch
OpenClaw ships with a web search and fetch plugin that gives local or cloud models the ability to search the web and extract readable page content.
OpenClaw ships with a bundled Ollama `web_search` provider that lets local or cloud-backed Ollama setups search the web through the configured Ollama host.
```bash
ollama launch openclaw
```
Web search and fetch is enabled automatically when launching OpenClaw through Ollama. To install the plugin directly:
Ollama web search is enabled automatically when launching OpenClaw through Ollama. To configure it manually:
```bash
openclaw plugins install @ollama/openclaw-web-search
openclaw configure --section web
```
<Note>Web search for local models requires `ollama signin`.</Note>
<Note>Ollama web search for local models requires `ollama signin`.</Note>
## Configure without launching
@@ -93,4 +93,3 @@ Link WhatsApp, Telegram, Slack, Discord, or iMessage to chat with your local mod
```bash
openclaw gateway stop
```
+54
View File
@@ -0,0 +1,54 @@
---
title: Poolside
---
Poolside is Poolside's software agent for the terminal, built for enterprise development workflows.
## Install
Install [Poolside](https://github.com/poolsideai/pool):
## Usage with Ollama
### Quick setup
```shell
ollama launch pool
```
### Run directly with a model
```shell
ollama launch pool --model kimi-k2.6:cloud
```
### Pass arguments through to Poolside
Arguments after `--` are passed directly to Poolside:
```shell
ollama launch pool -- --help
```
## Manual setup
Poolside connects to Ollama using the OpenAI-compatible API via environment variables.
1. Set the environment variables:
```shell
export POOLSIDE_STANDALONE_BASE_URL=http://localhost:11434/v1
export POOLSIDE_API_KEY=ollama
```
2. Run Poolside with an Ollama model:
```shell
pool -m kimi-k2.6:cloud
```
Or run with environment variables inline:
```shell
POOLSIDE_STANDALONE_BASE_URL=http://localhost:11434/v1 POOLSIDE_API_KEY=ollama pool -m kimi-k2.6:cloud
```
+3 -2
View File
@@ -283,10 +283,11 @@ func (kv KV) OllamaEngineRequired() bool {
"gemma3n",
"gemma4",
"gptoss", "gpt-oss",
"laguna",
"llama4",
"mistral3",
"mllama",
"nemotron_h", "nemotron_h_moe",
"nemotron_h", "nemotron_h_moe", "nemotron_h_omni",
"nomic-bert",
"olmo3",
"qwen25vl",
@@ -897,7 +898,7 @@ func (f GGML) FlashAttention() bool {
"lfm2",
"lfm2moe",
"mistral3",
"nemotron_h", "nemotron_h_moe",
"nemotron_h", "nemotron_h_moe", "nemotron_h_omni",
"olmo3",
"qwen3", "qwen3moe",
"qwen35", "qwen35moe",
-8
View File
@@ -406,10 +406,6 @@ func TestAPIShowModel(t *testing.T) {
}
func TestAPIGenerateLogprobs(t *testing.T) {
if testModel != "" {
// Logprobs requires runner support (e.g. llama.cpp has it, MLX does not).
t.Skip("logprobs not supported by all runners")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
@@ -523,10 +519,6 @@ func TestAPIGenerateLogprobs(t *testing.T) {
}
func TestAPIChatLogprobs(t *testing.T) {
if testModel != "" {
// Logprobs requires runner support (e.g. llama.cpp has it, MLX does not).
t.Skip("logprobs not supported by all runners")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
+12
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
"time"
)
type Layer struct {
@@ -60,6 +61,9 @@ func NewLayer(r io.Reader, mediatype string) (Layer, error) {
return Layer{}, err
}
}
if err := touchLayer(blob); err != nil {
return Layer{}, err
}
return Layer{
MediaType: mediatype,
@@ -83,6 +87,9 @@ func NewLayerFromLayer(digest, mediatype, from string) (Layer, error) {
if err != nil {
return Layer{}, err
}
if err := touchLayer(blob); err != nil {
return Layer{}, err
}
return Layer{
MediaType: mediatype,
@@ -93,6 +100,11 @@ func NewLayerFromLayer(digest, mediatype, from string) (Layer, error) {
}, nil
}
func touchLayer(path string) error {
now := time.Now()
return os.Chtimes(path, now, now)
}
func (l *Layer) Open() (io.ReadSeekCloser, error) {
if l.Digest == "" {
return nil, errors.New("opening layer with empty digest")
+444
View File
@@ -0,0 +1,444 @@
package laguna
import (
"fmt"
"math"
"github.com/ollama/ollama/fs"
"github.com/ollama/ollama/kvcache"
"github.com/ollama/ollama/ml"
"github.com/ollama/ollama/ml/nn"
"github.com/ollama/ollama/ml/nn/rope"
"github.com/ollama/ollama/model"
"github.com/ollama/ollama/model/input"
"github.com/ollama/ollama/tokenizer"
)
const (
cacheTypeSWA = iota
cacheTypeCausal
)
type Options struct {
hiddenSize int
headDim int
numHeads []int
numKVHeads int
eps float32
slidingWindow int
slidingWindowPattern []bool
fullRopeDim int
fullRopeBase, fullRopeScale float32
fullRopeOriginalContextLength int
fullRopeAttentionFactor float32
fullRopeBetaFast float32
fullRopeBetaSlow float32
swaRopeDim int
swaRopeBase, swaRopeScale float32
numExperts, numExpertsUsed int
normTopKProb bool
routedScalingFactor float32
decoderSparseStep int
denseLayers map[int]bool
}
func (o *Options) numHeadsForLayer(layer int) int {
if layer < len(o.numHeads) && o.numHeads[layer] > 0 {
return o.numHeads[layer]
}
if len(o.numHeads) > 0 && o.numHeads[0] > 0 {
return o.numHeads[0]
}
return 1
}
func (o *Options) layerIsSliding(layer int) bool {
return layer < len(o.slidingWindowPattern) && o.slidingWindowPattern[layer]
}
func (o *Options) layerUsesMoE(layer int) bool {
if o.numExperts == 0 || o.denseLayers[layer] {
return false
}
step := o.decoderSparseStep
if step <= 0 {
step = 1
}
return (layer+1)%step == 0
}
func (o *Options) applyRotaryPositionEmbeddings(ctx ml.Context, layer int, states, positions ml.Tensor) ml.Tensor {
opts := []func(*rope.Options){rope.WithTypeNeoX()}
if o.layerIsSliding(layer) {
return nn.RoPE(ctx, states, positions, o.swaRopeDim, o.swaRopeBase, 1./o.swaRopeScale, opts...)
}
opts = append(opts,
rope.WithOriginalContextLength(o.fullRopeOriginalContextLength),
rope.WithExtrapolationFactor(1),
rope.WithAttentionFactor(o.fullRopeAttentionFactor),
rope.WithBetaFast(o.fullRopeBetaFast),
rope.WithBetaSlow(o.fullRopeBetaSlow),
)
return nn.RoPE(ctx, states, positions, o.fullRopeDim, o.fullRopeBase, 1./o.fullRopeScale, opts...)
}
type Attention struct {
Query *nn.Linear `gguf:"attn_q"`
QueryNorm *nn.RMSNorm `gguf:"attn_q_norm"`
Key *nn.Linear `gguf:"attn_k"`
KeyNorm *nn.RMSNorm `gguf:"attn_k_norm"`
Value *nn.Linear `gguf:"attn_v"`
Gate *nn.Linear `gguf:"attn_g"`
Output *nn.Linear `gguf:"attn_output"`
}
func (sa *Attention) Forward(ctx ml.Context, layer int, hiddenStates, positions ml.Tensor, cache kvcache.Cache, opts *Options) ml.Tensor {
batchSize := hiddenStates.Dim(1)
numHeads := opts.numHeadsForLayer(layer)
query := sa.Query.Forward(ctx, hiddenStates)
key := sa.Key.Forward(ctx, hiddenStates)
value := sa.Value.Forward(ctx, hiddenStates)
gate := sa.Gate.Forward(ctx, hiddenStates)
query = query.Reshape(ctx, opts.headDim, numHeads, batchSize)
key = key.Reshape(ctx, opts.headDim, opts.numKVHeads, batchSize)
value = value.Reshape(ctx, opts.headDim, opts.numKVHeads, batchSize)
query = sa.QueryNorm.Forward(ctx, query, opts.eps)
key = sa.KeyNorm.Forward(ctx, key, opts.eps)
query = opts.applyRotaryPositionEmbeddings(ctx, layer, query, positions)
key = opts.applyRotaryPositionEmbeddings(ctx, layer, key, positions)
attention := nn.Attention(ctx, query, key, value, 1./math.Sqrt(float64(opts.headDim)), cache)
// Laguna applies the per-head gate softplus in float32, then casts back.
gate = gate.Cast(ctx, ml.DTypeF32).Softplus(ctx).Cast(ctx, attention.DType())
attention = attention.Mul(ctx, gate.Reshape(ctx, 1, numHeads, batchSize))
attention = attention.Reshape(ctx, opts.headDim*numHeads, batchSize)
return sa.Output.Forward(ctx, attention)
}
type MLP interface {
Forward(ml.Context, ml.Tensor, *Options) ml.Tensor
}
type dense struct {
Gate *nn.Linear `gguf:"ffn_gate"`
Up *nn.Linear `gguf:"ffn_up"`
Down *nn.Linear `gguf:"ffn_down"`
}
func (mlp *dense) Forward(ctx ml.Context, hiddenStates ml.Tensor, _ *Options) ml.Tensor {
hiddenStates = mlp.Gate.Forward(ctx, hiddenStates).SILU(ctx, mlp.Up.Forward(ctx, hiddenStates))
return mlp.Down.Forward(ctx, hiddenStates)
}
type sparse struct {
Router *nn.Linear `gguf:"ffn_gate_inp"`
Gate *nn.LinearBatch `gguf:"ffn_gate_exps"`
Up *nn.LinearBatch `gguf:"ffn_up_exps"`
Down *nn.LinearBatch `gguf:"ffn_down_exps"`
SharedExpert *dense `gguf:",suf:_shexp"`
ExpProbsBias ml.Tensor `gguf:"exp_probs_b.bias,alt:exp_probs_b"`
}
func (moe *sparse) topKIndices(ctx ml.Context, scores ml.Tensor, opts *Options) ml.Tensor {
if moe.ExpProbsBias != nil {
scores = scores.Add(ctx, moe.ExpProbsBias)
}
return scores.TopK(ctx, opts.numExpertsUsed)
}
func (moe *sparse) Forward(ctx ml.Context, hiddenStates ml.Tensor, opts *Options) ml.Tensor {
residual := hiddenStates
scores := moe.Router.Forward(ctx, hiddenStates).Cast(ctx, ml.DTypeF32).Sigmoid(ctx)
selectedExperts := moe.topKIndices(ctx, scores, opts)
routingWeights := scores.Reshape(ctx, 1, opts.numExperts, hiddenStates.Dim(1)).Rows(ctx, selectedExperts)
if opts.normTopKProb {
routingWeights = routingWeights.Reshape(ctx, opts.numExpertsUsed, hiddenStates.Dim(1))
routingWeights = routingWeights.Div(ctx, routingWeights.SumRows(ctx))
routingWeights = routingWeights.Reshape(ctx, 1, opts.numExpertsUsed, hiddenStates.Dim(1))
}
routingWeights = routingWeights.Scale(ctx, float64(opts.routedScalingFactor))
hiddenStates = hiddenStates.Reshape(ctx, hiddenStates.Dim(0), 1, hiddenStates.Dim(1))
upStates := moe.Up.Forward(ctx, hiddenStates, selectedExperts)
hiddenStates = moe.Gate.Forward(ctx, hiddenStates, selectedExperts).SILU(ctx, upStates)
experts := moe.Down.Forward(ctx, hiddenStates, selectedExperts)
experts = experts.Mul(ctx, routingWeights)
nextStates := experts.View(ctx, 0, experts.Dim(0), experts.Stride(2), experts.Dim(2))
for i := 1; i < opts.numExpertsUsed; i++ {
nextStates = nextStates.Add(ctx, experts.View(ctx, i*experts.Stride(1), experts.Dim(0), experts.Stride(2), experts.Dim(2)))
}
return nextStates.Add(ctx, moe.SharedExpert.Forward(ctx, residual, opts))
}
type Layer struct {
AttentionNorm *nn.RMSNorm `gguf:"attn_norm"`
*Attention
MLPNorm *nn.RMSNorm `gguf:"ffn_norm"`
MLP
}
func (l *Layer) Forward(ctx ml.Context, layer int, hiddenStates, positions, outputs ml.Tensor, cache kvcache.Cache, opts *Options) ml.Tensor {
residual := hiddenStates
hiddenStates = l.AttentionNorm.Forward(ctx, hiddenStates, opts.eps)
hiddenStates = l.Attention.Forward(ctx, layer, hiddenStates, positions, cache, opts)
if outputs != nil {
hiddenStates = hiddenStates.Rows(ctx, outputs)
residual = residual.Rows(ctx, outputs)
}
hiddenStates = hiddenStates.Add(ctx, residual)
residual = hiddenStates
hiddenStates = l.MLPNorm.Forward(ctx, hiddenStates, opts.eps)
hiddenStates = l.MLP.Forward(ctx, hiddenStates, opts)
return hiddenStates.Add(ctx, residual)
}
type Model struct {
model.Base
tokenizer.Tokenizer
TokenEmbedding *nn.Embedding `gguf:"token_embd"`
Layers []Layer `gguf:"blk"`
OutputNorm *nn.RMSNorm `gguf:"output_norm"`
Output *nn.Linear `gguf:"output,alt:token_embd"`
*Options
}
func New(c fs.Config) (model.Model, error) {
if c.Bool("attention.sink_enabled") {
return nil, fmt.Errorf("laguna: SWA attention sinks are not supported")
}
if c.Uint("attention.gating_type") != 1 {
return nil, fmt.Errorf("laguna: unsupported attention gating type %d", c.Uint("attention.gating_type"))
}
if !c.Bool("attention.qk_norm") {
return nil, fmt.Errorf("laguna: Q/K RMSNorm is required")
}
if gating := c.Uint("expert_gating_func"); gating != 2 {
return nil, fmt.Errorf("laguna: unsupported expert gating function %d", gating)
}
numLayers := int(c.Uint("block_count"))
opts := newOptions(c, numLayers)
layers := make([]Layer, numLayers)
for i := range layers {
if opts.layerUsesMoE(i) {
layers[i].MLP = &sparse{}
} else {
layers[i].MLP = &dense{}
}
}
var pre []string
switch c.String("tokenizer.ggml.pre") {
case "laguna":
pre = []string{
`(?:\r?\n)+(?!\r?\n)`,
`(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+`,
}
default:
return nil, model.ErrUnsupportedTokenizer
}
m := Model{
Tokenizer: tokenizer.NewBytePairEncoding(
&tokenizer.Vocabulary{
Values: c.Strings("tokenizer.ggml.tokens"),
Types: c.Ints("tokenizer.ggml.token_type"),
Merges: c.Strings("tokenizer.ggml.merges"),
AddBOS: c.Bool("tokenizer.ggml.add_bos_token", true),
BOS: []int32{int32(c.Uint("tokenizer.ggml.bos_token_id"))},
AddEOS: c.Bool("tokenizer.ggml.add_eos_token", false),
EOS: append(
[]int32{int32(c.Uint("tokenizer.ggml.eos_token_id"))},
c.Ints("tokenizer.ggml.eos_token_ids")...,
),
},
pre...,
),
Layers: layers,
Options: opts,
}
m.Cache = kvcache.NewWrapperCache(
kvcache.NewSWACache(int32(opts.slidingWindow), m.Shift),
kvcache.NewCausalCache(m.Shift),
)
return &m, nil
}
func newOptions(c fs.Config, numLayers int) *Options {
denseLayers := make(map[int]bool)
for _, layer := range configUints(c, "dense_layers") {
denseLayers[int(layer)] = true
}
for i := range c.Uint("leading_dense_block_count") {
denseLayers[int(i)] = true
}
fullRopeScale := c.Float("rope.scaling.factor", 1)
if fullRopeScale == 0 {
fullRopeScale = 1
}
swaRopeScale := c.Float("rope.swa.scaling.factor", 1)
if swaRopeScale == 0 {
swaRopeScale = 1
}
fullRopeType := c.String("rope.scaling.type")
fullRopeAttentionFactor := lagunaAttentionFactor(fullRopeType, fullRopeScale, c.Float("rope.scaling.attn_factor"))
return &Options{
hiddenSize: int(c.Uint("embedding_length")),
headDim: int(c.Uint("attention.key_length")),
numHeads: expandIntArray(configUints(c, "attention.head_count"), numLayers, c.Uint("attention.head_count", 1)),
numKVHeads: int(c.Uint("attention.head_count_kv")),
eps: c.Float("attention.layer_norm_rms_epsilon", 1e-6),
slidingWindow: int(c.Uint("attention.sliding_window", 512)),
slidingWindowPattern: slidingWindowPattern(c, numLayers),
fullRopeDim: int(c.Uint("rope.dimension_count", c.Uint("attention.key_length"))),
fullRopeBase: c.Float("rope.freq_base", 500000),
fullRopeScale: fullRopeScale,
fullRopeOriginalContextLength: int(c.Uint("rope.scaling.original_context_length", 4096)),
fullRopeAttentionFactor: fullRopeAttentionFactor,
fullRopeBetaFast: c.Float("rope.scaling.beta_fast", 64),
fullRopeBetaSlow: c.Float("rope.scaling.beta_slow", 1),
swaRopeDim: int(c.Uint("rope.swa.dimension_count", c.Uint("attention.key_length"))),
swaRopeBase: c.Float("rope.swa.freq_base", 10000),
swaRopeScale: swaRopeScale,
numExperts: int(c.Uint("expert_count")),
numExpertsUsed: int(c.Uint("expert_used_count")),
normTopKProb: c.Bool("expert_weights_norm", true),
routedScalingFactor: c.Float("expert_weights_scale", 1),
decoderSparseStep: int(c.Uint("decoder_sparse_step", 1)),
denseLayers: denseLayers,
}
}
func lagunaAttentionFactor(ropeType string, scaleFactor, attentionFactor float32) float32 {
if attentionFactor != 0 {
return attentionFactor
}
if ropeType == "yarn" && scaleFactor > 1 {
return float32(0.1*math.Log(float64(scaleFactor)) + 1)
}
return 1
}
func slidingWindowPattern(c fs.Config, numLayers int) []bool {
pattern := c.Bools("attention.sliding_window_pattern")
if len(pattern) == numLayers {
return pattern
}
layerTypes := configUints(c, "attention.layer_types")
if len(layerTypes) == numLayers {
pattern = make([]bool, numLayers)
for i, layerType := range layerTypes {
pattern[i] = layerType == 1
}
return pattern
}
return make([]bool, numLayers)
}
func configUints(c fs.Config, key string) []uint32 {
keyExists := c.Value(c.Architecture()+"."+key) != nil || c.Value(key) != nil
if cc, ok := c.(interface {
Uints(string, ...[]uint32) []uint32
}); ok {
if values := cc.Uints(key); len(values) > 0 && (keyExists || !(len(values) == 1 && values[0] == 0)) {
return values
}
}
ints := c.Ints(key)
if len(ints) > 0 && (keyExists || !(len(ints) == 1 && ints[0] == 0)) {
values := make([]uint32, len(ints))
for i, v := range ints {
values[i] = uint32(v)
}
return values
}
if scalar := c.Uint(key); scalar != 0 {
return []uint32{scalar}
}
return nil
}
func expandIntArray(values []uint32, n int, fallback uint32) []int {
if len(values) == 0 {
values = []uint32{fallback}
}
defaultValue := values[0]
if len(values) == 1 {
defaultValue = values[0]
}
out := make([]int, n)
for i := range out {
if i < len(values) {
out[i] = int(values[i])
} else {
out[i] = int(defaultValue)
}
}
return out
}
func (m *Model) Shift(ctx ml.Context, layer int, key, shift ml.Tensor) (ml.Tensor, error) {
return m.Options.applyRotaryPositionEmbeddings(ctx, layer, key, shift), nil
}
func (m *Model) Forward(ctx ml.Context, batch input.Batch) (ml.Tensor, error) {
positions := ctx.Input().FromInts(batch.Positions, len(batch.Positions))
hiddenStates := m.TokenEmbedding.Forward(ctx, batch.Inputs)
for i, layer := range m.Layers {
if m.Cache != nil {
m.Cache.SetLayer(i)
if wrapper, ok := m.Cache.(*kvcache.WrapperCache); ok {
cacheType := cacheTypeCausal
if m.Options.layerIsSliding(i) {
cacheType = cacheTypeSWA
}
wrapper.SetLayerType(cacheType)
}
}
var outputs ml.Tensor
if i == len(m.Layers)-1 {
outputs = batch.Outputs
}
hiddenStates = layer.Forward(ctx, i, hiddenStates, positions, outputs, m.Cache, m.Options)
}
hiddenStates = m.OutputNorm.Forward(ctx, hiddenStates, m.eps)
return m.Output.Forward(ctx, hiddenStates), nil
}
func init() {
model.Register("laguna", New)
}
var _ model.Model = (*Model)(nil)
+237
View File
@@ -0,0 +1,237 @@
package laguna
import (
"iter"
"math"
"testing"
)
type testConfig map[string]any
func (c testConfig) Architecture() string { return "laguna" }
func (c testConfig) key(key string) string {
switch {
case len(key) >= len("tokenizer.") && key[:len("tokenizer.")] == "tokenizer.":
return key
case len(key) >= len("general.") && key[:len("general.")] == "general.":
return key
default:
return "laguna." + key
}
}
func (c testConfig) String(key string, defaultValue ...string) string {
if v, ok := c[c.key(key)].(string); ok {
return v
}
if len(defaultValue) > 0 {
return defaultValue[0]
}
return ""
}
func (c testConfig) Uint(key string, defaultValue ...uint32) uint32 {
switch v := c[c.key(key)].(type) {
case uint32:
return v
case int:
return uint32(v)
}
if len(defaultValue) > 0 {
return defaultValue[0]
}
return 0
}
func (c testConfig) Float(key string, defaultValue ...float32) float32 {
if v, ok := c[c.key(key)].(float32); ok {
return v
}
if len(defaultValue) > 0 {
return defaultValue[0]
}
return 0
}
func (c testConfig) Bool(key string, defaultValue ...bool) bool {
if v, ok := c[c.key(key)].(bool); ok {
return v
}
if len(defaultValue) > 0 {
return defaultValue[0]
}
return false
}
func (c testConfig) Strings(key string, defaultValue ...[]string) []string {
if v, ok := c[c.key(key)].([]string); ok {
return v
}
if len(defaultValue) > 0 {
return defaultValue[0]
}
return nil
}
func (c testConfig) Ints(key string, defaultValue ...[]int32) []int32 {
if v, ok := c[c.key(key)].([]int32); ok {
return v
}
if len(defaultValue) > 0 {
return defaultValue[0]
}
return nil
}
func (c testConfig) Uints(key string, defaultValue ...[]uint32) []uint32 {
if v, ok := c[c.key(key)].([]uint32); ok {
return v
}
if len(defaultValue) > 0 {
return defaultValue[0]
}
return nil
}
func (c testConfig) Floats(key string, defaultValue ...[]float32) []float32 {
if v, ok := c[c.key(key)].([]float32); ok {
return v
}
if len(defaultValue) > 0 {
return defaultValue[0]
}
return nil
}
func (c testConfig) Bools(key string, defaultValue ...[]bool) []bool {
if v, ok := c[c.key(key)].([]bool); ok {
return v
}
if len(defaultValue) > 0 {
return defaultValue[0]
}
return nil
}
func (c testConfig) Len() int { return len(c) }
func (c testConfig) Keys() iter.Seq[string] {
return func(yield func(string) bool) {
for key := range c {
if !yield(key) {
return
}
}
}
}
func (c testConfig) Value(key string) any { return c[key] }
func TestNewOptionsLayerConfig(t *testing.T) {
cfg := testConfig{
"laguna.block_count": uint32(4),
"laguna.embedding_length": uint32(128),
"laguna.attention.key_length": uint32(16),
"laguna.attention.head_count": []uint32{8, 16, 16, 16},
"laguna.attention.head_count_kv": uint32(4),
"laguna.attention.layer_norm_rms_epsilon": float32(1e-6),
"laguna.attention.sliding_window": uint32(512),
"laguna.attention.sliding_window_pattern": []bool{false, true, true, true},
"laguna.rope.dimension_count": uint32(8),
"laguna.rope.freq_base": float32(500000),
"laguna.rope.scaling.factor": float32(32),
"laguna.rope.scaling.original_context_length": uint32(4096),
"laguna.rope.swa.dimension_count": uint32(16),
"laguna.rope.swa.freq_base": float32(10000),
"laguna.expert_count": uint32(32),
"laguna.expert_used_count": uint32(4),
"laguna.expert_weights_norm": true,
"laguna.expert_weights_scale": float32(2.5),
"laguna.decoder_sparse_step": uint32(1),
"laguna.dense_layers": []uint32{0},
}
opts := newOptions(cfg, 4)
if got := opts.numHeadsForLayer(0); got != 8 {
t.Fatalf("layer 0 heads = %d, want 8", got)
}
if got := opts.numHeadsForLayer(1); got != 16 {
t.Fatalf("layer 1 heads = %d, want 16", got)
}
if opts.layerIsSliding(0) {
t.Fatal("layer 0 should be full attention")
}
if !opts.layerIsSliding(1) {
t.Fatal("layer 1 should be sliding attention")
}
if opts.layerUsesMoE(0) {
t.Fatal("layer 0 should be dense")
}
if !opts.layerUsesMoE(1) {
t.Fatal("layer 1 should use MoE")
}
if opts.fullRopeDim != 8 || opts.swaRopeDim != 16 {
t.Fatalf("rope dims = full %d swa %d, want 8/16", opts.fullRopeDim, opts.swaRopeDim)
}
}
func TestNewOptionsYarnAttentionFactorFallback(t *testing.T) {
cfg := testConfig{
"laguna.block_count": uint32(1),
"laguna.embedding_length": uint32(128),
"laguna.attention.key_length": uint32(16),
"laguna.attention.head_count": uint32(8),
"laguna.attention.head_count_kv": uint32(4),
"laguna.rope.scaling.type": "yarn",
"laguna.rope.scaling.factor": float32(32),
}
opts := newOptions(cfg, 1)
want := float32(0.1*math.Log(32) + 1)
if got := opts.fullRopeAttentionFactor; math.Abs(float64(got-want)) > 1e-6 {
t.Fatalf("fullRopeAttentionFactor = %v, want %v", got, want)
}
}
func TestNewRejectsUnsupportedLagunaVariants(t *testing.T) {
tests := []struct {
name string
cfg testConfig
}{
{
name: "attention sinks",
cfg: testConfig{
"laguna.attention.sink_enabled": true,
},
},
{
name: "non per-head gate",
cfg: testConfig{
"laguna.attention.gating_type": uint32(0),
},
},
{
name: "missing qk norm",
cfg: testConfig{
"laguna.attention.gating_type": uint32(1),
},
},
{
name: "non sigmoid experts",
cfg: testConfig{
"laguna.attention.gating_type": uint32(1),
"laguna.attention.qk_norm": true,
"laguna.expert_gating_func": uint32(1),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := New(tt.cfg); err == nil {
t.Fatal("expected unsupported variant error")
}
})
}
}
+1
View File
@@ -11,6 +11,7 @@ import (
_ "github.com/ollama/ollama/model/models/glm4moelite"
_ "github.com/ollama/ollama/model/models/glmocr"
_ "github.com/ollama/ollama/model/models/gptoss"
_ "github.com/ollama/ollama/model/models/laguna"
_ "github.com/ollama/ollama/model/models/lfm2"
_ "github.com/ollama/ollama/model/models/llama"
_ "github.com/ollama/ollama/model/models/llama4"
+355
View File
@@ -0,0 +1,355 @@
package nemotronh
import (
"errors"
"image"
"math"
"slices"
"github.com/ollama/ollama/fs"
"github.com/ollama/ollama/model/imageproc"
)
type ImageProcessor struct {
imageSize int
patchSize int
numChannels int
maxTiles int
minNumPatches int
maxNumPatches int
useThumbnail bool
projectorScale int
imageMean [3]float32
imageStd [3]float32
}
type processedVisionTile struct {
data []float32
size image.Point
}
func newImageProcessor(c fs.Config) ImageProcessor {
mean := c.Floats("vision.image_mean")
std := c.Floats("vision.image_std")
processor := ImageProcessor{
imageSize: int(c.Uint("vision.image_size", 512)),
patchSize: int(c.Uint("vision.patch_size", 16)),
numChannels: int(c.Uint("vision.num_channels", 3)),
maxTiles: int(c.Uint("vision.max_tiles", 12)),
minNumPatches: int(c.Uint("vision.min_num_patches")),
maxNumPatches: int(c.Uint("vision.max_num_patches")),
useThumbnail: c.Bool("vision.use_thumbnail", true),
projectorScale: int(c.Uint("vision.projector.scale_factor", 2)),
imageMean: imageproc.ClipDefaultMean,
imageStd: imageproc.ClipDefaultSTD,
}
if len(mean) >= 3 {
processor.imageMean = [3]float32{mean[0], mean[1], mean[2]}
}
if len(std) >= 3 {
processor.imageStd = [3]float32{std[0], std[1], std[2]}
}
if processor.imageSize <= 0 {
processor.imageSize = 512
}
if processor.patchSize <= 0 {
processor.patchSize = 16
}
if processor.numChannels <= 0 {
processor.numChannels = 3
}
if processor.maxTiles <= 0 {
processor.maxTiles = 12
}
if processor.projectorScale <= 0 {
processor.projectorScale = 2
}
return processor
}
func (p ImageProcessor) ProcessImage(img image.Image) ([]processedVisionTile, error) {
img = imageproc.Composite(img)
if p.useDynamicResolution() {
return p.processDynamicImage(img)
}
return p.processTiledImage(img), nil
}
func (p ImageProcessor) useDynamicResolution() bool {
return p.minNumPatches > 0 || p.maxNumPatches > 0
}
func (p ImageProcessor) processTiledImage(img image.Image) []processedVisionTile {
bounds := img.Bounds()
origWidth := bounds.Dx()
origHeight := bounds.Dy()
targetRatios := nemotronTargetRatios(p.maxTiles)
gridWidth, gridHeight := findClosestAspectRatio(float64(origWidth)/float64(origHeight), targetRatios, origWidth, origHeight, p.imageSize)
targetWidth := p.imageSize * gridWidth
targetHeight := p.imageSize * gridHeight
resized := resizeImageBicubicCHW(img, targetWidth, targetHeight)
tiles := make([]processedVisionTile, 0, gridWidth*gridHeight+1)
for row := range gridHeight {
for col := range gridWidth {
tile := cropCHWRegion(
resized,
targetWidth,
targetHeight,
p.numChannels,
col*p.imageSize,
row*p.imageSize,
p.imageSize,
p.imageSize,
)
tiles = append(tiles, processedVisionTile{
data: normalizeVisionCHW(tile, p.imageMean, p.imageStd),
size: image.Point{X: p.imageSize, Y: p.imageSize},
})
}
}
if p.useThumbnail && len(tiles) > 1 {
thumbnail := resizeImageBicubicCHW(img, p.imageSize, p.imageSize)
tiles = append(tiles, processedVisionTile{
data: normalizeVisionCHW(thumbnail, p.imageMean, p.imageStd),
size: image.Point{X: p.imageSize, Y: p.imageSize},
})
}
return tiles
}
func (p ImageProcessor) processDynamicImage(img image.Image) ([]processedVisionTile, error) {
bounds := img.Bounds()
origWidth := bounds.Dx()
origHeight := bounds.Dy()
patchesWidth, patchesHeight := p.dynamicPatchGrid(origWidth, origHeight)
if patchesWidth <= 0 || patchesHeight <= 0 {
return nil, errors.New("nemotron_h_omni: invalid dynamic image patch grid")
}
targetWidth := patchesWidth * p.patchSize
targetHeight := patchesHeight * p.patchSize
resized := resizeImageBicubicCHW(img, targetWidth, targetHeight)
return []processedVisionTile{{
data: normalizeVisionCHW(resized, p.imageMean, p.imageStd),
size: image.Point{X: targetWidth, Y: targetHeight},
}}, nil
}
func (p ImageProcessor) dynamicPatchGrid(origWidth, origHeight int) (int, int) {
patchesHeight := max(1, int(math.Round(float64(origHeight)/float64(p.patchSize)+0.5)))
patchesWidth := max(1, int(math.Round(float64(origWidth)/float64(p.patchSize)+0.5)))
patches := patchesHeight * patchesWidth
currentNumPatchesAvailable := p.maxNumPatches
if currentNumPatchesAvailable <= 0 {
currentNumPatchesAvailable = max(patches, p.minNumPatches)
}
factor := math.Min(math.Sqrt(float64(currentNumPatchesAvailable)/float64(patches)), 1.0)
targetPatchesHeight := max(1, int(math.Floor(factor*float64(patchesHeight))))
targetPatchesWidth := max(1, int(math.Floor(factor*float64(patchesWidth))))
if currentNumPatchesAvailable > p.minNumPatches && targetPatchesHeight*targetPatchesWidth < p.minNumPatches {
upFactor := math.Sqrt(float64(p.minNumPatches) / float64(targetPatchesHeight*targetPatchesWidth))
targetPatchesHeight = int(math.Ceil(upFactor * float64(targetPatchesHeight)))
targetPatchesWidth = int(math.Ceil(upFactor * float64(targetPatchesWidth)))
}
targetPatchesHeight = roundPatchGridForPixelShuffle(targetPatchesHeight, targetPatchesWidth, currentNumPatchesAvailable, p.projectorScale)
targetPatchesWidth = roundPatchGridForPixelShuffle(targetPatchesWidth, targetPatchesHeight, currentNumPatchesAvailable, p.projectorScale)
return targetPatchesWidth, targetPatchesHeight
}
func roundPatchGridForPixelShuffle(v, other, maxPatches, divisor int) int {
if divisor <= 1 {
return v
}
rem := v % divisor
if rem == 0 {
return v
}
inc := divisor - rem
if (v+inc)*other <= maxPatches {
return v + inc
}
return max(divisor, v-rem)
}
type nemotronImageRatio struct {
width int
height int
}
func nemotronTargetRatios(maxTiles int) []nemotronImageRatio {
targetRatios := make([]nemotronImageRatio, 0, maxTiles*maxTiles)
for n := 1; n <= maxTiles; n++ {
for w := 1; w <= n; w++ {
for h := 1; h <= n; h++ {
if w*h > maxTiles {
continue
}
targetRatios = append(targetRatios, nemotronImageRatio{width: w, height: h})
}
}
}
unique := targetRatios[:0]
for _, ratio := range targetRatios {
if slices.Contains(unique, ratio) {
continue
}
unique = append(unique, ratio)
}
slices.SortFunc(unique, func(a, b nemotronImageRatio) int {
return a.width*a.height - b.width*b.height
})
return unique
}
func findClosestAspectRatio(aspectRatio float64, targetRatios []nemotronImageRatio, width, height, imageSize int) (int, int) {
bestRatio := nemotronImageRatio{width: 1, height: 1}
bestRatioDiff := math.MaxFloat64
area := width * height
for _, ratio := range targetRatios {
targetAspectRatio := float64(ratio.width) / float64(ratio.height)
ratioDiff := math.Abs(aspectRatio - targetAspectRatio)
if ratioDiff < bestRatioDiff {
bestRatioDiff = ratioDiff
bestRatio = ratio
continue
}
if ratioDiff == bestRatioDiff && area > int(0.5*float64(imageSize*imageSize*ratio.width*ratio.height)) {
bestRatio = ratio
}
}
return bestRatio.width, bestRatio.height
}
func resizeImageBicubicCHW(img image.Image, outW, outH int) []float32 {
bounds := img.Bounds()
inW := bounds.Dx()
inH := bounds.Dy()
src := make([]float32, 3*inW*inH)
for y := range inH {
for x := range inW {
r, g, b, _ := img.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
src[y*inW+x] = float32(r>>8) / 255.0
src[inW*inH+y*inW+x] = float32(g>>8) / 255.0
src[2*inW*inH+y*inW+x] = float32(b>>8) / 255.0
}
}
dst := make([]float32, 3*outW*outH)
scaleX := float64(inW) / float64(outW)
scaleY := float64(inH) / float64(outH)
for oy := range outH {
srcY := scaleY*(float64(oy)+0.5) - 0.5
yBase := int(math.Floor(srcY))
yFrac := clampUnit(srcY - float64(yBase))
wy := torchBicubicWeights(yFrac)
for ox := range outW {
srcX := scaleX*(float64(ox)+0.5) - 0.5
xBase := int(math.Floor(srcX))
xFrac := clampUnit(srcX - float64(xBase))
wx := torchBicubicWeights(xFrac)
for c := range 3 {
var sum float64
channelOffset := c * inW * inH
for ky := range 4 {
iy := clampIndex(yBase-1+ky, 0, inH-1)
for kx := range 4 {
ix := clampIndex(xBase-1+kx, 0, inW-1)
sum += float64(src[channelOffset+iy*inW+ix]) * wy[ky] * wx[kx]
}
}
dst[c*outW*outH+oy*outW+ox] = float32(sum)
}
}
}
return dst
}
func cropCHWRegion(values []float32, width, height, channels, left, top, cropW, cropH int) []float32 {
out := make([]float32, channels*cropW*cropH)
channelSize := width * height
cropSize := cropW * cropH
for c := range channels {
srcBase := c * channelSize
dstBase := c * cropSize
for y := range cropH {
copy(out[dstBase+y*cropW:dstBase+(y+1)*cropW], values[srcBase+(top+y)*width+left:srcBase+(top+y)*width+left+cropW])
}
}
return out
}
func normalizeVisionCHW(values []float32, mean, std [3]float32) []float32 {
out := make([]float32, len(values))
channelSize := len(values) / 3
for c := range 3 {
base := c * channelSize
for i := range channelSize {
out[base+i] = (values[base+i] - mean[c]) / std[c]
}
}
return out
}
func torchBicubicWeights(t float64) [4]float64 {
const a = -0.75
return [4]float64{
bicubicConvolution2(t+1.0, a),
bicubicConvolution1(t, a),
bicubicConvolution1(1.0-t, a),
bicubicConvolution2(2.0-t, a),
}
}
func bicubicConvolution1(x, a float64) float64 {
return ((a+2)*x-(a+3))*x*x + 1
}
func bicubicConvolution2(x, a float64) float64 {
return ((a*x-5*a)*x+8*a)*x - 4*a
}
func clampUnit(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func clampIndex(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
+20 -5
View File
@@ -117,9 +117,7 @@ func Shift(ctx ml.Context, layer int, key, shift ml.Tensor) (ml.Tensor, error) {
return key, nil
}
func (m *Model) Forward(ctx ml.Context, batch input.Batch) (ml.Tensor, error) {
hiddenStates := m.TokenEmbedding.Forward(ctx, batch.Inputs)
func (m *Model) forwardHiddenStates(ctx ml.Context, batch input.Batch, hiddenStates ml.Tensor) (ml.Tensor, error) {
cache := m.Cache.(*HybridCache)
for i, layer := range m.Layers {
@@ -137,11 +135,24 @@ func (m *Model) Forward(ctx ml.Context, batch input.Batch) (ml.Tensor, error) {
}
}
hiddenStates = m.OutputNorm.Forward(ctx, hiddenStates, m.eps)
return m.OutputNorm.Forward(ctx, hiddenStates, m.eps), nil
}
func (m *Model) forwardLogits(ctx ml.Context, batch input.Batch, hiddenStates ml.Tensor) (ml.Tensor, error) {
hiddenStates, err := m.forwardHiddenStates(ctx, batch, hiddenStates)
if err != nil {
return nil, err
}
return m.Output.Forward(ctx, hiddenStates), nil
}
func New(c fs.Config) (model.Model, error) {
func (m *Model) Forward(ctx ml.Context, batch input.Batch) (ml.Tensor, error) {
hiddenStates := m.TokenEmbedding.Forward(ctx, batch.Inputs)
return m.forwardLogits(ctx, batch, hiddenStates)
}
func newTextModel(c fs.Config) (*Model, error) {
numLayers := int(c.Uint("block_count"))
layers := make([]Layer, numLayers)
@@ -306,6 +317,10 @@ func New(c fs.Config) (model.Model, error) {
return &m, nil
}
func New(c fs.Config) (model.Model, error) {
return newTextModel(c)
}
func init() {
model.Register("nemotron_h", New)
model.Register("nemotron_h_moe", New)
+511
View File
@@ -0,0 +1,511 @@
package nemotronh
import (
"math"
"sync"
"github.com/ollama/ollama/fs"
"github.com/ollama/ollama/ml"
"github.com/ollama/ollama/ml/nn"
)
type AudioOptions struct {
hiddenSize int
numHeads int
headDim int
intermediateSize int
convKernelSize int
melBins int
sampleRate int
subsamplingKernel int
subsamplingStride int
scaleInput bool
eps float32
}
type AudioFeatureExtractor struct {
FB ml.Tensor `gguf:"fb"`
Window ml.Tensor `gguf:"window"`
mu sync.Mutex
fb []float32
window []float32
fbShape [2]int
}
func (f *AudioFeatureExtractor) windowAndFilters(melBins, freqBins, sampleRate int) ([]float32, []float32) {
if f == nil {
return defaultParakeetWindow(), buildSlaneyMelFilterBank(freqBins, melBins, sampleRate)
}
f.mu.Lock()
defer f.mu.Unlock()
if f.window == nil {
if f.Window != nil {
if values := f.Window.BackendGet(); len(values) == parakeetWinLength {
f.window = values
}
}
if f.window == nil {
f.window = defaultParakeetWindow()
}
}
if f.fb == nil {
if f.FB != nil {
if values := f.FB.BackendGet(); len(values) == melBins*freqBins {
f.fb = values
f.fbShape = [2]int{melBins, freqBins}
}
}
if f.fb == nil {
f.fb = buildSlaneyMelFilterBank(freqBins, melBins, sampleRate)
f.fbShape = [2]int{melBins, freqBins}
}
}
return f.window, f.fb
}
type AudioSubsampling struct {
Conv0 *nn.Conv2D `gguf:"conv0"`
DW1 *AudioDepthwiseConv2D `gguf:"dw1"`
PW1 *nn.Conv2D `gguf:"pw1"`
DW2 *AudioDepthwiseConv2D `gguf:"dw2"`
PW2 *nn.Conv2D `gguf:"pw2"`
Linear *nn.Linear `gguf:"linear"`
}
type AudioDepthwiseConv2D struct {
Weight ml.Tensor `gguf:"weight"`
Bias ml.Tensor `gguf:"bias"`
}
type AudioFeedForward struct {
Up *nn.Linear `gguf:"up"`
Down *nn.Linear `gguf:"down"`
}
type AudioSelfAttention struct {
Query *nn.Linear `gguf:"attn_q"`
Key *nn.Linear `gguf:"attn_k"`
Value *nn.Linear `gguf:"attn_v"`
Output *nn.Linear `gguf:"attn_out"`
RelativeKey *nn.Linear `gguf:"attn_rel_k"`
BiasU ml.Tensor `gguf:"attn_bias_u"`
BiasV ml.Tensor `gguf:"attn_bias_v"`
}
type AudioConvolutionModule struct {
Pointwise1 *nn.Linear `gguf:"conv_pw1"`
Depthwise ml.Tensor `gguf:"conv_dw.weight"`
BatchNorm *AudioBatchNorm1D `gguf:"conv_bn"`
Pointwise2 *nn.Linear `gguf:"conv_pw2"`
}
type AudioBatchNorm1D struct {
Weight ml.Tensor `gguf:"weight"`
Bias ml.Tensor `gguf:"bias"`
RunningMean ml.Tensor `gguf:"running_mean"`
RunningVar ml.Tensor `gguf:"running_var"`
}
type AudioLayer struct {
FFN1Norm *nn.LayerNorm `gguf:"ffn1_norm"`
FFN1Up *nn.Linear `gguf:"ffn1_up"`
FFN1Down *nn.Linear `gguf:"ffn1_down"`
AttentionNorm *nn.LayerNorm `gguf:"attn_norm"`
Attention *AudioSelfAttention
ConvNorm *nn.LayerNorm `gguf:"conv_norm"`
Conv *AudioConvolutionModule
FFN2Norm *nn.LayerNorm `gguf:"ffn2_norm"`
FFN2Up *nn.Linear `gguf:"ffn2_up"`
FFN2Down *nn.Linear `gguf:"ffn2_down"`
OutputNorm *nn.LayerNorm `gguf:"out_norm"`
}
type AudioModel struct {
FeatureExtractor *AudioFeatureExtractor `gguf:"feature_extractor"`
Subsampling *AudioSubsampling `gguf:"subsampling"`
Layers []AudioLayer `gguf:"blk"`
*AudioOptions
}
type AudioProjector struct {
Norm *nn.RMSNorm `gguf:"norm"`
Linear1 *nn.Linear `gguf:"1"`
Linear2 *nn.Linear `gguf:"2"`
}
func (p *AudioProjector) Forward(ctx ml.Context, x ml.Tensor, eps float32) ml.Tensor {
x = p.Norm.Forward(ctx, x, eps)
x = audioF32(ctx, p.Linear1.Forward(ctx, x))
x = x.RELU(ctx)
x = x.Mul(ctx, x)
return audioF32(ctx, p.Linear2.Forward(ctx, x))
}
func (m *AudioModel) ForwardAudio(ctx ml.Context, melFeatures ml.Tensor, validFrames int, projector *AudioProjector) ml.Tensor {
x := melFeatures.Reshape(ctx, melFeatures.Dim(0), melFeatures.Dim(1), 1, 1)
validLen := validFrames
x = forwardAudioConv2D(ctx, m.Subsampling.Conv0, x, m.subsamplingStride, m.subsamplingStride, audioConvPadding(m.subsamplingKernel), audioConvPadding(m.subsamplingKernel), 1, 1)
x = x.RELU(ctx)
validLen = convOutputLength(validLen, m.subsamplingKernel, m.subsamplingStride, audioConvPadding(m.subsamplingKernel))
x = applyAudioTimeMask(ctx, x, validLen)
x = forwardAudioDepthwiseConv2D(ctx, m.Subsampling.DW1, x, m.subsamplingStride, m.subsamplingStride, audioConvPadding(m.subsamplingKernel), audioConvPadding(m.subsamplingKernel), 1, 1)
x = forwardAudioConv2D(ctx, m.Subsampling.PW1, x, 1, 1, 0, 0, 1, 1)
x = x.RELU(ctx)
validLen = convOutputLength(validLen, m.subsamplingKernel, m.subsamplingStride, audioConvPadding(m.subsamplingKernel))
x = applyAudioTimeMask(ctx, x, validLen)
x = forwardAudioDepthwiseConv2D(ctx, m.Subsampling.DW2, x, m.subsamplingStride, m.subsamplingStride, audioConvPadding(m.subsamplingKernel), audioConvPadding(m.subsamplingKernel), 1, 1)
x = forwardAudioConv2D(ctx, m.Subsampling.PW2, x, 1, 1, 0, 0, 1, 1)
x = x.RELU(ctx)
validLen = convOutputLength(validLen, m.subsamplingKernel, m.subsamplingStride, audioConvPadding(m.subsamplingKernel))
x = applyAudioTimeMask(ctx, x, validLen)
x = flattenAudioSubsamplingOutput(ctx, x)
x = m.Subsampling.Linear.Forward(ctx, x)
if m.scaleInput {
x = x.Scale(ctx, math.Sqrt(float64(m.hiddenSize)))
}
if validLen > 0 && validLen < x.Dim(1) {
x = x.Slice(ctx, 1, 0, validLen, 1).Contiguous(ctx)
}
for i := range m.Layers {
x = m.Layers[i].Forward(ctx, x, validLen, m.AudioOptions)
}
if projector != nil {
x = projector.Forward(ctx, x, m.eps)
}
return x
}
func flattenAudioSubsamplingOutput(ctx ml.Context, x ml.Tensor) ml.Tensor {
fOut, tOut, cOut := x.Dim(0), x.Dim(1), x.Dim(2)
// PyTorch flattens the subsampling output after [B, C, T, F] ->
// [B, T, C, F], so F must remain the fastest dimension inside each
// channel block before the linear projection.
x = x.Permute(ctx, 0, 2, 1, 3).Contiguous(ctx)
return x.Reshape(ctx, cOut*fOut, tOut)
}
func (l *AudioLayer) Forward(ctx ml.Context, x ml.Tensor, validLen int, opts *AudioOptions) ml.Tensor {
residual := x
x = audioFeedForward(ctx, l.FFN1Up, l.FFN1Down, l.FFN1Norm.Forward(ctx, x, opts.eps)).Scale(ctx, 0.5)
x = residual.Add(ctx, x)
residual = x
x = l.Attention.Forward(ctx, l.AttentionNorm.Forward(ctx, x, opts.eps), validLen, opts)
x = residual.Add(ctx, x)
residual = x
x = l.Conv.Forward(ctx, l.ConvNorm.Forward(ctx, x, opts.eps), opts)
x = residual.Add(ctx, x)
residual = x
x = audioFeedForward(ctx, l.FFN2Up, l.FFN2Down, l.FFN2Norm.Forward(ctx, x, opts.eps)).Scale(ctx, 0.5)
x = residual.Add(ctx, x)
return l.OutputNorm.Forward(ctx, x, opts.eps)
}
func audioFeedForward(ctx ml.Context, up, down *nn.Linear, x ml.Tensor) ml.Tensor {
x = audioF32(ctx, up.Forward(ctx, x))
x = x.SILU(ctx)
return audioF32(ctx, down.Forward(ctx, x))
}
func (a *AudioSelfAttention) Forward(ctx ml.Context, x ml.Tensor, validLen int, opts *AudioOptions) ml.Tensor {
seqLen := x.Dim(1)
headDim := opts.headDim
numHeads := opts.numHeads
q := audioF32(ctx, a.Query.Forward(ctx, x)).Reshape(ctx, headDim, numHeads, seqLen)
k := audioF32(ctx, a.Key.Forward(ctx, x)).Reshape(ctx, headDim, numHeads, seqLen)
v := audioF32(ctx, a.Value.Forward(ctx, x)).Reshape(ctx, headDim, numHeads, seqLen)
qU := q
if a.BiasU != nil {
qU = qU.Add(ctx, audioF32(ctx, a.BiasU).Reshape(ctx, headDim, numHeads, 1))
}
qV := q
if a.BiasV != nil {
qV = qV.Add(ctx, audioF32(ctx, a.BiasV).Reshape(ctx, headDim, numHeads, 1))
}
qP := qU.Permute(ctx, 0, 2, 1, 3)
kP := k.Permute(ctx, 0, 2, 1, 3)
logits := kP.MulmatFullPrec(ctx, qP)
positionEmbeddings := parakeetPositionEmbeddings(ctx, seqLen, opts.hiddenSize)
relKey := audioF32(ctx, a.RelativeKey.Forward(ctx, positionEmbeddings)).Reshape(ctx, headDim, numHeads, 2*seqLen-1)
pP := relKey.Permute(ctx, 0, 2, 1, 3)
qVP := qV.Permute(ctx, 0, 2, 1, 3)
relLogits := pP.MulmatFullPrec(ctx, qVP)
relLogits = relativeShiftParakeet(ctx, relLogits, seqLen, numHeads)
logits = logits.Add(ctx, relLogits)
logits = logits.Scale(ctx, math.Pow(float64(headDim), -0.5))
if validLen > 0 && validLen < seqLen {
logits = logits.Add(ctx, audioAttentionMask(ctx, seqLen, validLen))
}
logits = logits.Softmax(ctx)
vP := v.Permute(ctx, 0, 2, 1, 3)
vPT := vP.Permute(ctx, 1, 0, 2, 3).Contiguous(ctx)
out := vPT.Mulmat(ctx, logits)
out = out.Permute(ctx, 0, 2, 1, 3).Contiguous(ctx)
out = out.Reshape(ctx, opts.hiddenSize, seqLen)
return audioF32(ctx, a.Output.Forward(ctx, out))
}
func (c *AudioConvolutionModule) Forward(ctx ml.Context, x ml.Tensor, opts *AudioOptions) ml.Tensor {
x = audioF32(ctx, c.Pointwise1.Forward(ctx, x))
hidden := x.Dim(0) / 2
value := x.Slice(ctx, 0, 0, hidden, 1).Contiguous(ctx)
gate := x.Slice(ctx, 0, hidden, 2*hidden, 1).Contiguous(ctx).Sigmoid(ctx)
x = value.Mul(ctx, gate)
x = audioDepthwiseConv1DSame(ctx, x, c.Depthwise, audioConvPadding(opts.convKernelSize))
x = c.BatchNorm.Forward(ctx, x, opts.eps)
x = x.SILU(ctx)
return audioF32(ctx, c.Pointwise2.Forward(ctx, x))
}
func audioF32(ctx ml.Context, x ml.Tensor) ml.Tensor {
if x.DType() == ml.DTypeF32 {
return x
}
// Metal binary kernels used by the audio graph require F32 operands here.
// This likely slows audio and should be revisited once the precision vs.
// speed tradeoff is validated against BF16-native elementwise paths.
return x.Cast(ctx, ml.DTypeF32)
}
func (b *AudioBatchNorm1D) Forward(ctx ml.Context, x ml.Tensor, eps float32) ml.Tensor {
if b == nil || b.RunningMean == nil || b.RunningVar == nil {
return x
}
hidden := x.Dim(0)
epsValues := make([]float32, hidden)
for i := range epsValues {
epsValues[i] = eps
}
variance := b.RunningVar.Add(ctx, ctx.Input().FromFloats(epsValues, hidden))
x = x.Sub(ctx, b.RunningMean)
x = x.Div(ctx, variance.Sqrt(ctx))
if b.Weight != nil {
x = x.Mul(ctx, b.Weight)
}
if b.Bias != nil {
x = x.Add(ctx, b.Bias)
}
return x
}
func forwardAudioConv2D(ctx ml.Context, conv *nn.Conv2D, x ml.Tensor, s0, s1, p0, p1, d0, d1 int) ml.Tensor {
weight := conv.Weight.Contiguous(ctx)
x = weight.Conv2D(ctx, x, s0, s1, p0, p1, d0, d1)
if conv.Bias != nil {
x = x.Add(ctx, conv.Bias.Reshape(ctx, 1, 1, -1))
}
return x
}
func forwardAudioDepthwiseConv2D(ctx ml.Context, conv *AudioDepthwiseConv2D, x ml.Tensor, s0, s1, p0, p1, d0, d1 int) ml.Tensor {
x = audioDepthwiseConv2D(ctx, x, conv.Weight, s0, s1, p0, p1, d0, d1)
if conv.Bias != nil {
x = x.Add(ctx, conv.Bias.Reshape(ctx, 1, 1, -1))
}
return x
}
func applyAudioTimeMask(ctx ml.Context, x ml.Tensor, validLen int) ml.Tensor {
if validLen <= 0 || validLen >= x.Dim(1) {
return x
}
mask := make([]float32, x.Dim(1))
for i := range validLen {
mask[i] = 1
}
return x.Mul(ctx, ctx.Input().FromFloats(mask, 1, x.Dim(1), 1, 1))
}
func audioDepthwiseConv1DSame(ctx ml.Context, x, kernel ml.Tensor, padding int) ml.Tensor {
kernelSize := kernel.Dim(0)
seqLen := x.Dim(1)
kernelT := kernel.Permute(ctx, 1, 0, 2, 3).Contiguous(ctx)
var out ml.Tensor
for k := range kernelSize {
offset := k - padding
shifted := x
switch {
case offset > 0:
shifted = x.Slice(ctx, 1, offset, seqLen, 1).Contiguous(ctx)
shifted = shifted.PadExt(ctx, 0, 0, 0, offset, 0, 0, 0, 0)
case offset < 0:
shift := -offset
shifted = x.Slice(ctx, 1, 0, seqLen-shift, 1).Contiguous(ctx)
shifted = shifted.PadExt(ctx, 0, 0, shift, 0, 0, 0, 0, 0)
}
wk := kernelT.Slice(ctx, 1, k, k+1, 1).Contiguous(ctx)
term := shifted.Mul(ctx, wk)
if out == nil {
out = term
} else {
out = out.Add(ctx, term)
}
}
return out
}
func audioDepthwiseConv2D(ctx ml.Context, x, kernel ml.Tensor, s0, s1, p0, p1, d0, d1 int) ml.Tensor {
if d0 != 1 || d1 != 1 {
panic("audio depthwise conv2d only supports dilation 1")
}
kernel = kernel.Contiguous(ctx)
kernelW, kernelH := kernel.Dim(0), kernel.Dim(1)
outW := convOutputLength(x.Dim(0), kernelW, s0, p0)
outH := convOutputLength(x.Dim(1), kernelH, s1, p1)
padded := x.PadExt(ctx, p0, p0, p1, p1, 0, 0, 0, 0)
var out ml.Tensor
for ky := range kernelH {
for kx := range kernelW {
patch := padded.Slice(ctx, 0, kx, kx+s0*(outW-1)+1, s0).Contiguous(ctx)
patch = patch.Slice(ctx, 1, ky, ky+s1*(outH-1)+1, s1).Contiguous(ctx)
wk := kernel.Slice(ctx, 0, kx, kx+1, 1).Slice(ctx, 1, ky, ky+1, 1).Contiguous(ctx)
if wk.Dim(2) == 1 {
wk = wk.Permute(ctx, 0, 1, 3, 2).Contiguous(ctx)
} else {
wk = wk.Reshape(ctx, 1, 1, wk.Dim(2), wk.Dim(3))
}
term := patch.Mul(ctx, wk)
if out == nil {
out = term
} else {
out = out.Add(ctx, term)
}
}
}
return out
}
func convOutputLength(inputLength, kernel, stride, padding int) int {
if inputLength <= 0 {
return 0
}
return (inputLength+2*padding-kernel)/stride + 1
}
func audioConvPadding(kernel int) int {
return (kernel - 1) / 2
}
func parakeetPositionEmbeddings(ctx ml.Context, seqLen, hiddenSize int) ml.Tensor {
half := hiddenSize / 2
values := make([]float32, hiddenSize*(2*seqLen-1))
for posIdx, pos := 0, seqLen-1; posIdx < 2*seqLen-1; posIdx, pos = posIdx+1, pos-1 {
for i := range half {
invFreq := math.Pow(10000, -float64(2*i)/float64(hiddenSize))
angle := float64(pos) * invFreq
values[posIdx*hiddenSize+2*i] = float32(math.Sin(angle))
values[posIdx*hiddenSize+2*i+1] = float32(math.Cos(angle))
}
}
return ctx.Input().FromFloats(values, hiddenSize, 2*seqLen-1)
}
func relativeShiftParakeet(ctx ml.Context, x ml.Tensor, seqLen, numHeads int) ml.Tensor {
positionLen := 2*seqLen - 1
x = x.PadExt(ctx, 1, 0, 0, 0, 0, 0, 0, 0)
x = x.Reshape(ctx, seqLen, positionLen+1, numHeads)
x = x.Slice(ctx, 1, 1, positionLen+1, 1).Contiguous(ctx)
x = x.Reshape(ctx, positionLen, seqLen, numHeads)
return x.Slice(ctx, 0, 0, seqLen, 1).Contiguous(ctx)
}
func audioAttentionMask(ctx ml.Context, seqLen, validLen int) ml.Tensor {
values := make([]float32, seqLen*seqLen)
for q := range seqLen {
for k := range seqLen {
if q >= validLen || k >= validLen {
values[q*seqLen+k] = -1e9
}
}
}
return ctx.Input().FromFloats(values, seqLen, seqLen, 1)
}
func newAudioModel(c fs.Config) *AudioModel {
numLayers := int(c.Uint("audio.block_count", 0))
if numLayers == 0 {
return nil
}
return &AudioModel{
Layers: make([]AudioLayer, numLayers),
AudioOptions: newAudioOptions(c),
}
}
func newAudioProjector(c fs.Config) *AudioProjector {
if c.Uint("audio.block_count", 0) == 0 {
return nil
}
return &AudioProjector{}
}
func newAudioOptions(c fs.Config) *AudioOptions {
hiddenSize := int(c.Uint("audio.embedding_length", 1024))
numHeads := int(c.Uint("audio.attention.head_count", 8))
headDim := hiddenSize / max(1, numHeads)
return &AudioOptions{
hiddenSize: hiddenSize,
numHeads: numHeads,
headDim: headDim,
intermediateSize: int(c.Uint("audio.feed_forward_length", uint32(hiddenSize*4))),
convKernelSize: int(c.Uint("audio.conv_kernel_size", 9)),
melBins: int(c.Uint("audio.num_mel_bins", 128)),
sampleRate: int(c.Uint("audio.sample_rate", 16000)),
subsamplingKernel: int(c.Uint("audio.subsampling_conv_kernel_size", 3)),
subsamplingStride: int(c.Uint("audio.subsampling_conv_stride", 2)),
scaleInput: c.Bool("audio.scale_input", false),
eps: c.Float("audio.attention.layer_norm_epsilon", 1e-5),
}
}
func defaultAudioOptions() *AudioOptions {
return &AudioOptions{
hiddenSize: 1024,
numHeads: 8,
headDim: 128,
intermediateSize: 4096,
convKernelSize: 9,
melBins: 128,
sampleRate: 16000,
subsamplingKernel: 3,
subsamplingStride: 2,
eps: 1e-5,
}
}
+239
View File
@@ -0,0 +1,239 @@
package nemotronh
import (
"bytes"
"errors"
"image"
"slices"
"github.com/ollama/ollama/fs"
"github.com/ollama/ollama/ml"
"github.com/ollama/ollama/model"
"github.com/ollama/ollama/model/input"
)
type OmniModel struct {
*Model
*VisionModel `gguf:"v"`
*AudioModel `gguf:"a"`
*MultiModalProjector `gguf:"mm"`
*AudioProjector `gguf:"mm.a"`
ImageProcessor
imageTokenID int32
imageStartToken int32
imageEndToken int32
audioTokenID int32
}
var _ model.MultimodalProcessor = (*OmniModel)(nil)
func NewOmni(c fs.Config) (model.Model, error) {
textModel, err := newTextModel(c)
if err != nil {
return nil, err
}
imageTokenID := int32(c.Uint("vision.image_token_id", 18))
imageStartToken := int32(c.Uint("vision.image_start_token_id", 19))
imageEndToken := int32(c.Uint("vision.image_end_token_id", 20))
audioTokenID := int32(c.Uint("audio.sound_token_id", 27))
return &OmniModel{
Model: textModel,
VisionModel: newVisionModel(c),
AudioModel: newAudioModel(c),
MultiModalProjector: newMultiModalProjector(c),
AudioProjector: newAudioProjector(c),
ImageProcessor: newImageProcessor(c),
imageTokenID: imageTokenID,
imageStartToken: imageStartToken,
imageEndToken: imageEndToken,
audioTokenID: audioTokenID,
}, nil
}
func (m *OmniModel) EncodeMultimodal(ctx ml.Context, multimodalData []byte) ([]input.Multimodal, error) {
if isAudioData(multimodalData) {
return m.encodeAudioMultimodal(ctx, multimodalData)
}
if m.VisionModel == nil || m.MultiModalProjector == nil || len(m.VisionModel.Layers) == 0 {
return nil, model.ErrNoVisionModel
}
img, _, err := image.Decode(bytes.NewReader(multimodalData))
if err != nil {
return nil, err
}
tiles, err := m.ImageProcessor.ProcessImage(img)
if err != nil {
return nil, err
}
mm := make([]input.Multimodal, 0, len(tiles))
for _, tile := range tiles {
patches := visionPatchGrid{
Width: tile.size.X / m.ImageProcessor.patchSize,
Height: tile.size.Y / m.ImageProcessor.patchSize,
}
if patches.Width == 0 || patches.Height == 0 {
return nil, errors.New("nemotron_h_omni: invalid resized image dimensions")
}
patchInput := packVisionPatchesCHW(tile.data, tile.size.X, tile.size.Y, m.ImageProcessor.numChannels, m.ImageProcessor.patchSize)
visionOutputs := m.VisionModel.ForwardPacked(ctx, patchInput, patches)
projected := m.MultiModalProjector.Forward(ctx, visionOutputs, patches)
mm = append(mm, input.Multimodal{Tensor: projected})
}
return mm, nil
}
type audioTag struct{}
func (m *OmniModel) encodeAudioMultimodal(ctx ml.Context, data []byte) ([]input.Multimodal, error) {
if m.AudioModel == nil || m.AudioProjector == nil || len(m.AudioModel.Layers) == 0 {
return nil, model.ErrNoVisionModel
}
samples, err := decodeWAV(data, m.AudioModel.sampleRate)
if err != nil {
return nil, err
}
melData, frames, validFrames, err := computeParakeetMelSpectrogram(samples, m.AudioModel.FeatureExtractor, m.AudioModel.AudioOptions)
if err != nil {
return nil, err
}
melTensor := ctx.Input().FromFloats(melData, m.AudioModel.melBins, frames)
audioOutputs := m.AudioModel.ForwardAudio(ctx, melTensor, validFrames, m.AudioProjector)
return []input.Multimodal{{Tensor: audioOutputs, Data: audioTag{}}}, nil
}
func (m *OmniModel) PostLoad() error {
return nil
}
func (m *OmniModel) PostTokenize(inputs []*input.Input) ([]*input.Input, error) {
var result []*input.Input
imageToken := m.imageTokenID
if imageToken == 0 {
imageToken = 18
}
for _, inp := range inputs {
if len(inp.Multimodal) == 0 {
result = append(result, inp)
continue
}
totalTokens := 0
for _, mm := range inp.Multimodal {
if mm.Tensor == nil {
continue
}
totalTokens += mm.Tensor.Dim(1)
}
if totalTokens <= 0 {
return nil, errors.New("nemotron_h_omni: multimodal input has no tokens")
}
if _, ok := inp.Multimodal[0].Data.(audioTag); ok {
audioToken := m.audioTokenID
if audioToken == 0 {
audioToken = 27
}
for i, mm := range inp.Multimodal {
tokenCount := 0
if mm.Tensor != nil {
tokenCount = mm.Tensor.Dim(1)
}
if tokenCount <= 0 {
return nil, errors.New("nemotron_h_omni: multimodal input has no tokens")
}
first := &input.Input{Token: audioToken, SameBatch: tokenCount - 1}
if i == 0 {
first.MultimodalHash = inp.MultimodalHash
}
first.Multimodal = []input.Multimodal{mm}
result = append(result, first)
if tokenCount > 1 {
result = append(result, slices.Repeat([]*input.Input{{Token: audioToken}}, tokenCount-1)...)
}
}
continue
}
if m.imageStartToken > 0 {
result = append(result, &input.Input{
Token: m.imageStartToken,
SameBatch: totalTokens + btoi(m.imageEndToken > 0),
})
}
for _, mm := range inp.Multimodal {
tokenCount := 0
if mm.Tensor != nil {
tokenCount = mm.Tensor.Dim(1)
}
if tokenCount <= 0 {
return nil, errors.New("nemotron_h_omni: multimodal input has no tokens")
}
result = append(result, &input.Input{
Token: imageToken,
Multimodal: []input.Multimodal{mm},
MultimodalHash: inp.MultimodalHash,
})
if tokenCount > 1 {
result = append(result, slices.Repeat([]*input.Input{{Token: imageToken}}, tokenCount-1)...)
}
}
if m.imageEndToken > 0 {
result = append(result, &input.Input{Token: m.imageEndToken})
}
}
return result, nil
}
func btoi(v bool) int {
if v {
return 1
}
return 0
}
func (m *OmniModel) Forward(ctx ml.Context, batch input.Batch) (ml.Tensor, error) {
hiddenStates := m.TokenEmbedding.Forward(ctx, batch.Inputs)
if len(batch.Multimodal) > 0 {
hiddenStates = hiddenStates.Duplicate(ctx)
}
for _, mm := range batch.Multimodal {
offset := mm.Index
for _, multimodal := range mm.Multimodal {
if multimodal.Tensor == nil {
continue
}
tensor := multimodal.Tensor
ctx.Forward(tensor.Copy(ctx, hiddenStates.View(ctx, offset*hiddenStates.Stride(1), tensor.Dim(0)*tensor.Dim(1))))
offset += tensor.Dim(1)
}
}
return m.forwardLogits(ctx, batch, hiddenStates)
}
func init() {
model.Register("nemotron_h_omni", NewOmni)
}
+606
View File
@@ -0,0 +1,606 @@
package nemotronh
import (
"bytes"
"encoding/base64"
"encoding/binary"
"image"
"image/color"
"math"
"os"
"path/filepath"
"slices"
"strings"
"testing"
fsggml "github.com/ollama/ollama/fs/ggml"
"github.com/ollama/ollama/ml"
backendggml "github.com/ollama/ollama/ml/backend/ggml"
"github.com/ollama/ollama/ml/nn"
"github.com/ollama/ollama/model/input"
)
type fakeTensor struct {
*backendggml.Tensor
dims []int
}
func (t *fakeTensor) Dim(i int) int {
return t.dims[i]
}
func setupTestContext(t *testing.T) ml.Context {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "*.gguf")
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := fsggml.WriteGGUF(f, fsggml.KV{"general.architecture": "test"}, nil); err != nil {
t.Fatal(err)
}
b, err := ml.NewBackend(f.Name(), ml.BackendParams{AllocMemory: true})
if err != nil {
t.Fatal(err)
}
ctx := b.NewContext().Input()
t.Cleanup(func() {
ctx.Close()
b.Close()
})
return ctx
}
func TestPostTokenizeImageSpans(t *testing.T) {
m := &OmniModel{
imageTokenID: 18,
imageStartToken: 19,
imageEndToken: 20,
}
makeChunk := func() input.Multimodal {
return input.Multimodal{Tensor: &fakeTensor{dims: []int{2688, 256, 1, 1}}}
}
in := []*input.Input{
{Token: 7},
{
Multimodal: []input.Multimodal{makeChunk(), makeChunk()},
MultimodalHash: 99,
},
{Token: 8},
}
out, err := m.PostTokenize(in)
if err != nil {
t.Fatalf("PostTokenize() error = %v", err)
}
if len(out) != 516 {
t.Fatalf("len(out) = %d, want 516", len(out))
}
if out[0].Token != 7 {
t.Fatalf("out[0].Token = %d, want 7", out[0].Token)
}
if out[1].Token != 19 {
t.Fatalf("out[1].Token = %d, want 19", out[1].Token)
}
if out[1].SameBatch != 513 {
t.Fatalf("out[1].SameBatch = %d, want 513", out[1].SameBatch)
}
if out[2].Token != 18 || len(out[2].Multimodal) != 1 || out[2].MultimodalHash != 99 || out[2].SameBatch != 0 {
t.Fatalf("unexpected first image token: %+v", *out[2])
}
if out[258].Token != 18 || len(out[258].Multimodal) != 1 || out[258].MultimodalHash != 99 || out[258].SameBatch != 0 {
t.Fatalf("unexpected second image token: %+v", *out[258])
}
if out[514].Token != 20 {
t.Fatalf("out[514].Token = %d, want 20", out[514].Token)
}
if out[515].Token != 8 {
t.Fatalf("out[515].Token = %d, want 8", out[515].Token)
}
}
func TestProjectorPixelShuffleMatchesReferenceV2Order(t *testing.T) {
ctx := setupTestContext(t)
hidden := 2
width := 4
height := 2
values := make([]float32, 0, hidden*width*height)
for y := range height {
for x := range width {
for c := range hidden {
values = append(values, float32(100*y+10*x+c))
}
}
}
got := pixelShuffleVisionOutputs(ctx, ctx.FromFloats(values, hidden, width*height), visionPatchGrid{
Width: width,
Height: height,
}, 2)
ctx.Forward(got).Compute(got)
want := []float32{
0, 1, 10, 11, 100, 101, 110, 111,
20, 21, 30, 31, 120, 121, 130, 131,
}
if got.Shape()[0] != 8 || got.Shape()[1] != 2 {
t.Fatalf("shape = %v, want [8 2 1]", got.Shape())
}
gotValues := got.BackendGet()
if len(gotValues) != len(want) {
t.Fatalf("len(got) = %d, want %d", len(gotValues), len(want))
}
for i := range want {
if gotValues[i] != want[i] {
t.Fatalf("got[%d] = %v, want %v", i, gotValues[i], want[i])
}
}
}
func TestPostTokenizeAudioSpans(t *testing.T) {
m := &OmniModel{
audioTokenID: 27,
}
in := []*input.Input{
{Token: 7},
{
Multimodal: []input.Multimodal{{
Tensor: &fakeTensor{dims: []int{2688, 13, 1, 1}},
Data: audioTag{},
}},
MultimodalHash: 99,
},
{Token: 8},
}
out, err := m.PostTokenize(in)
if err != nil {
t.Fatalf("PostTokenize() error = %v", err)
}
if len(out) != 15 {
t.Fatalf("len(out) = %d, want 15", len(out))
}
if out[0].Token != 7 || out[14].Token != 8 {
t.Fatalf("unexpected surrounding tokens: first=%d last=%d", out[0].Token, out[14].Token)
}
for i := 1; i <= 13; i++ {
if out[i].Token != 27 {
t.Fatalf("out[%d].Token = %d, want 27", i, out[i].Token)
}
}
if len(out[1].Multimodal) != 1 || out[1].MultimodalHash != 99 {
t.Fatalf("first audio token did not carry multimodal payload: %+v", *out[1])
}
if out[1].SameBatch != 12 {
t.Fatalf("first audio token SameBatch = %d, want 12", out[1].SameBatch)
}
if len(out[2].Multimodal) != 0 {
t.Fatalf("only the first audio token should carry multimodal payload: %+v", *out[2])
}
}
func TestParakeetAudioPreprocessShapes(t *testing.T) {
data := sineWAV(t, 16000, 440, 1.0)
samples, err := decodeWAV(data, 16000)
if err != nil {
t.Fatal(err)
}
if got, want := len(samples), 16000; got != want {
t.Fatalf("sample count = %d, want %d", got, want)
}
mel, frames, validFrames, err := computeParakeetMelSpectrogram(samples, nil, defaultAudioOptions())
if err != nil {
t.Fatal(err)
}
if frames != 101 {
t.Fatalf("frames = %d, want 101", frames)
}
if validFrames != 100 {
t.Fatalf("validFrames = %d, want 100", validFrames)
}
if len(mel) != 101*128 {
t.Fatalf("len(mel) = %d, want %d", len(mel), 101*128)
}
lastFrame := mel[100*128 : 101*128]
if !slices.Equal(lastFrame, make([]float32, 128)) {
t.Fatal("expected masked final frame to be zero")
}
}
func TestParakeetAudioPreprocessMatchesIntegrationWAVReference(t *testing.T) {
data := integrationAudioWAV(t)
samples, err := decodeWAV(data, 16000)
if err != nil {
t.Fatal(err)
}
if got, want := len(samples), 42083; got != want {
t.Fatalf("sample count = %d, want %d", got, want)
}
mel, frames, validFrames, err := computeParakeetMelSpectrogram(samples, nil, defaultAudioOptions())
if err != nil {
t.Fatal(err)
}
if frames != 264 {
t.Fatalf("frames = %d, want 264", frames)
}
if validFrames != 263 {
t.Fatalf("validFrames = %d, want 263", validFrames)
}
if len(mel) != 264*128 {
t.Fatalf("len(mel) = %d, want %d", len(mel), 264*128)
}
lastFrame := mel[263*128 : 264*128]
if !slices.Equal(lastFrame, make([]float32, 128)) {
t.Fatal("expected masked final frame to be zero")
}
// Reference values come from the ParakeetExtractor path used by vLLM:
// pre-emphasis, torch.stft(center=True, pad_mode="constant"), Slaney mel
// filters, log guard 2^-24, and per-mel normalization over valid frames.
checks := map[[2]int]float32{
{0, 0}: -1.0855197,
{0, 50}: -0.93212974,
{1, 10}: -0.9735168,
{2, 100}: -0.6533053,
{50, 0}: 2.2483668,
{50, 127}: -0.3828735,
{100, 50}: 2.9742377,
{262, 0}: -0.9521758,
{262, 127}: -0.4602786,
{263, 50}: 0,
}
for pos, want := range checks {
got := mel[pos[0]*128+pos[1]]
if math.Abs(float64(got-want)) > 1e-4 {
t.Errorf("mel[%d,%d] = %v, want %v", pos[0], pos[1], got, want)
}
}
}
func integrationAudioWAV(t *testing.T) []byte {
t.Helper()
path := filepath.Join("..", "..", "..", "integration", "audio_test_data_test.go")
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
const marker = "const audioEncodingPrompt = `"
s := string(b)
start := strings.Index(s, marker)
if start < 0 {
t.Fatal("audioEncodingPrompt marker not found")
}
start += len(marker)
end := strings.Index(s[start:], "`")
if end < 0 {
t.Fatal("audioEncodingPrompt terminator not found")
}
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(s[start : start+end]))
if err != nil {
t.Fatal(err)
}
return data
}
func TestRelativeShiftParakeetMatchesReference(t *testing.T) {
ctx := setupTestContext(t)
seqLen := 3
positionLen := 2*seqLen - 1
values := make([]float32, seqLen*positionLen)
for q := range seqLen {
for p := range positionLen {
values[q*positionLen+p] = float32(q*10 + p)
}
}
x := ctx.FromFloats(values, positionLen, seqLen, 1)
got := relativeShiftParakeet(ctx, x, seqLen, 1)
ctx.Forward(got).Compute(got)
want := []float32{
2, 3, 4,
11, 12, 13,
20, 21, 22,
}
if !slices.Equal(got.BackendGet(), want) {
t.Fatalf("relative shift mismatch:\n got %v\nwant %v", got.BackendGet(), want)
}
}
func TestAudioDepthwiseConv2DMatchesReference(t *testing.T) {
ctx := setupTestContext(t)
freq, frames, channels := 4, 5, 2
xValues := make([]float32, freq*frames*channels)
for i := range xValues {
xValues[i] = float32(i)/10 - 1
}
kernelValues := make([]float32, 3*3*channels)
for i := range kernelValues {
kernelValues[i] = float32(i)/7 - 1
}
x := ctx.FromFloats(xValues, freq, frames, channels, 1)
kernel := ctx.FromFloats(kernelValues, 3, 3, 1, channels)
bias := ctx.FromFloats([]float32{0.25, -0.5}, channels)
got := audioDepthwiseConv2D(ctx, x, kernel, 2, 2, 1, 1, 1, 1).Add(ctx, bias.Reshape(ctx, 1, 1, -1))
ctx.Forward(got).Compute(got)
want := []float32{
0.86428565, 1.3357141,
1.2785715, 1.3642857,
-0.5928571, -1.7499999,
5.4000001, 8.8142853,
10.514286, 16.042856,
6.6857138, 9.8428574,
}
assertCloseSlice(t, got.BackendGet(), want, 1e-5)
}
func TestFlattenAudioSubsamplingOutputMatchesReference(t *testing.T) {
ctx := setupTestContext(t)
const (
freq = 2
frames = 3
channels = 2
)
values := make([]float32, freq*frames*channels)
for c := range channels {
for t := range frames {
for f := range freq {
values[f+freq*(t+frames*c)] = float32(100*c + 10*t + f)
}
}
}
got := flattenAudioSubsamplingOutput(ctx, ctx.FromFloats(values, freq, frames, channels, 1))
ctx.Forward(got).Compute(got)
want := []float32{
0, 1, 100, 101,
10, 11, 110, 111,
20, 21, 120, 121,
}
assertCloseSlice(t, got.BackendGet(), want, 0)
}
func TestAudioDepthwiseConv1DMatchesReference(t *testing.T) {
ctx := setupTestContext(t)
xValues := make([]float32, 2*5)
for i := range xValues {
xValues[i] = float32(i)/5 - 0.7
}
kernelValues := make([]float32, 3*2)
for i := range kernelValues {
kernelValues[i] = float32(i)/3 - 0.5
}
x := ctx.FromFloats(xValues, 2, 5)
kernel := ctx.FromFloats(kernelValues, 3, 2)
got := audioDepthwiseConv1DSame(ctx, x, kernel, 1)
ctx.Forward(got).Compute(got)
want := []float32{
0.066666655, -0.5333333,
0.41666666, 0.016666688,
0.21666668, 1.0166667,
0.01666667, 2.0166664,
-0.40000004, 1.2666667,
}
assertCloseSlice(t, got.BackendGet(), want, 1e-5)
}
func TestAudioSelfAttentionMatchesReference(t *testing.T) {
ctx := setupTestContext(t)
const (
hiddenSize = 4
numHeads = 2
headDim = 2
seqLen = 3
)
xValues := make([]float32, hiddenSize*seqLen)
for i := range xValues {
xValues[i] = float32(i)/10 - 0.5
}
identity := make([]float32, hiddenSize*hiddenSize)
for i := range hiddenSize {
identity[i*hiddenSize+i] = 1
}
linear := func() *nn.Linear {
return &nn.Linear{Weight: ctx.FromFloats(identity, hiddenSize, hiddenSize)}
}
attn := &AudioSelfAttention{
Query: linear(),
Key: linear(),
Value: linear(),
Output: linear(),
RelativeKey: linear(),
BiasU: ctx.FromFloats([]float32{0.1, -0.2, 0.3, -0.4}, headDim, numHeads),
BiasV: ctx.FromFloats([]float32{-0.05, 0.07, 0.11, -0.13}, headDim, numHeads),
}
got := attn.Forward(ctx, ctx.FromFloats(xValues, hiddenSize, seqLen), seqLen, &AudioOptions{
hiddenSize: hiddenSize,
numHeads: numHeads,
headDim: headDim,
})
ctx.Forward(got).Compute(got)
want := []float32{
-0.08471569, 0.015284289, 0.05532019, 0.1553202,
-0.09135241, 0.008647568, 0.11468154, 0.21468155,
-0.019152153, 0.08084783, 0.1733382, 0.2733382,
}
assertCloseSlice(t, got.BackendGet(), want, 1e-5)
}
func assertCloseSlice(t *testing.T, got, want []float32, tolerance float64) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("len(got) = %d, want %d", len(got), len(want))
}
for i := range want {
if math.Abs(float64(got[i]-want[i])) > tolerance {
t.Fatalf("got[%d] = %v, want %v\nall got: %v", i, got[i], want[i], got)
}
}
}
func TestPackPatchesCHW(t *testing.T) {
values := []float32{
0, 1, 2, 3,
4, 5, 6, 7,
8, 9, 10, 11,
12, 13, 14, 15,
100, 101, 102, 103,
104, 105, 106, 107,
108, 109, 110, 111,
112, 113, 114, 115,
}
got := packVisionPatchesCHW(values, 4, 4, 2, 2)
want := []float32{
0, 1, 4, 5, 100, 101, 104, 105,
2, 3, 6, 7, 102, 103, 106, 107,
8, 9, 12, 13, 108, 109, 112, 113,
10, 11, 14, 15, 110, 111, 114, 115,
}
if len(got) != len(want) {
t.Fatalf("len(got) = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got[%d] = %v, want %v", i, got[i], want[i])
}
}
}
func TestResizePositionEmbeddingMatchesReferenceInterpolation(t *testing.T) {
values := []float32{
0, 10,
20, 30,
}
got := resizePositionEmbedding(values, 1, 2, 2, 3, 3)
want := []float32{
0, 5, 10,
10, 15, 20,
20, 25, 30,
}
if len(got) != len(want) {
t.Fatalf("len(got) = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got[%d] = %v, want %v", i, got[i], want[i])
}
}
}
func TestDynamicImageProcessorMatchesReferencePatchBudget(t *testing.T) {
p := ImageProcessor{
imageSize: 512,
patchSize: 16,
numChannels: 3,
minNumPatches: 1024,
maxNumPatches: 13312,
projectorScale: 2,
imageMean: [3]float32{0.48145466, 0.4578275, 0.40821073},
imageStd: [3]float32{0.26862954, 0.26130258, 0.27577711},
}
img := image.NewRGBA(image.Rect(0, 0, 400, 250))
bounds := img.Bounds()
width, height := bounds.Dx(), bounds.Dy()
for y := range height {
for x := range width {
img.SetRGBA(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 128, A: 255})
}
}
tiles, err := p.ProcessImage(img)
if err != nil {
t.Fatalf("ProcessImage() error = %v", err)
}
if got, want := len(tiles), 1; got != want {
t.Fatalf("len(tiles) = %d, want %d", got, want)
}
if got, want := tiles[0].size, (image.Point{X: 672, Y: 416}); got != want {
t.Fatalf("tile size = %v, want %v", got, want)
}
if got, want := len(tiles[0].data), 3*672*416; got != want {
t.Fatalf("tile data len = %d, want %d", got, want)
}
}
func sineWAV(t *testing.T, sampleRate int, frequency float64, seconds float64) []byte {
t.Helper()
samples := int(float64(sampleRate) * seconds)
var pcm bytes.Buffer
for i := range samples {
v := int16(math.Sin(2*math.Pi*frequency*float64(i)/float64(sampleRate)) * 32767)
if err := binary.Write(&pcm, binary.LittleEndian, v); err != nil {
t.Fatal(err)
}
}
var out bytes.Buffer
out.WriteString("RIFF")
if err := binary.Write(&out, binary.LittleEndian, uint32(36+pcm.Len())); err != nil {
t.Fatal(err)
}
out.WriteString("WAVE")
out.WriteString("fmt ")
if err := binary.Write(&out, binary.LittleEndian, uint32(16)); err != nil {
t.Fatal(err)
}
if err := binary.Write(&out, binary.LittleEndian, uint16(1)); err != nil {
t.Fatal(err)
}
if err := binary.Write(&out, binary.LittleEndian, uint16(1)); err != nil {
t.Fatal(err)
}
if err := binary.Write(&out, binary.LittleEndian, uint32(sampleRate)); err != nil {
t.Fatal(err)
}
if err := binary.Write(&out, binary.LittleEndian, uint32(sampleRate*2)); err != nil {
t.Fatal(err)
}
if err := binary.Write(&out, binary.LittleEndian, uint16(2)); err != nil {
t.Fatal(err)
}
if err := binary.Write(&out, binary.LittleEndian, uint16(16)); err != nil {
t.Fatal(err)
}
out.WriteString("data")
if err := binary.Write(&out, binary.LittleEndian, uint32(pcm.Len())); err != nil {
t.Fatal(err)
}
out.Write(pcm.Bytes())
return out.Bytes()
}
+348
View File
@@ -0,0 +1,348 @@
package nemotronh
import (
"math"
"sync"
"github.com/ollama/ollama/fs"
"github.com/ollama/ollama/ml"
"github.com/ollama/ollama/ml/nn"
)
const nemotronVisionBatchSize = 1
type visionPatchGrid struct {
Width int
Height int
}
type VisionPatchEmbedding struct {
*nn.Linear
}
func packVisionPatchesCHW(values []float32, width, height, channels, patchSize int) []float32 {
patchesX, patchesY := width/patchSize, height/patchSize
patchDim := channels * patchSize * patchSize
plane := width * height
patches := make([]float32, patchDim*patchesX*patchesY)
offset := 0
for py := range patchesY {
for px := range patchesX {
for c := range channels {
channelBase := c * plane
for yy := range patchSize {
rowBase := (py*patchSize + yy) * width
for xx := range patchSize {
patches[offset] = values[channelBase+rowBase+px*patchSize+xx]
offset++
}
}
}
}
}
return patches
}
func (p *VisionPatchEmbedding) ForwardPacked(ctx ml.Context, patches []float32, patchDim, numPatches int) ml.Tensor {
hiddenState := ctx.Input().FromFloats(patches, patchDim, numPatches)
hiddenState = hiddenState.Duplicate(ctx)
return p.Linear.Forward(ctx, hiddenState)
}
func (p *VisionPatchEmbedding) Forward(ctx ml.Context, pixelValues ml.Tensor, patchSize int) ml.Tensor {
// Match the RADIO patch generator's exact flattening order: patches are laid
// out token-major with each token packed as channel, then patch-row, then
// patch-col. This is more explicit than the prior IM2Col path and likely
// slower, but it avoids backend-specific packing differences that caused the
// converted patch embedder to diverge badly from the reference model.
width, height, channels := pixelValues.Dim(0), pixelValues.Dim(1), pixelValues.Dim(2)
patchesX, patchesY := width/patchSize, height/patchSize
patchDim := channels * patchSize * patchSize
values := pixelValues.BackendGet()
return p.ForwardPacked(ctx, packVisionPatchesCHW(values, width, height, channels, patchSize), patchDim, patchesX*patchesY)
}
type VisionSelfAttention struct {
Query *nn.Linear `gguf:"attn_q"`
Key *nn.Linear `gguf:"attn_k"`
Value *nn.Linear `gguf:"attn_v"`
Output *nn.Linear `gguf:"attn_out"`
}
func (sa *VisionSelfAttention) Forward(ctx ml.Context, hiddenState ml.Tensor, opts *VisionOptions) ml.Tensor {
headDim := opts.hiddenSize / opts.numHeads
query := sa.Query.Forward(ctx, hiddenState)
key := sa.Key.Forward(ctx, hiddenState)
value := sa.Value.Forward(ctx, hiddenState)
query = query.Reshape(ctx, headDim, opts.numHeads, query.Dim(1), nemotronVisionBatchSize)
key = key.Reshape(ctx, headDim, opts.numHeads, key.Dim(1), nemotronVisionBatchSize)
value = value.Reshape(ctx, headDim, opts.numHeads, value.Dim(1), nemotronVisionBatchSize)
attention := nn.Attention(ctx, query, key, value, 1.0/math.Sqrt(float64(headDim)), nil)
attention = attention.Reshape(ctx, opts.hiddenSize, attention.Dim(2), nemotronVisionBatchSize)
return sa.Output.Forward(ctx, attention)
}
type VisionMLP struct {
Up *nn.Linear `gguf:"ffn_up"`
Down *nn.Linear `gguf:"ffn_down"`
}
func (mlp *VisionMLP) Forward(ctx ml.Context, hiddenState ml.Tensor) ml.Tensor {
return mlp.Down.Forward(ctx, mlp.Up.Forward(ctx, hiddenState).GELU(ctx))
}
type VisionEncoderLayer struct {
LayerNorm1 *nn.LayerNorm `gguf:"ln1"`
SelfAttention *VisionSelfAttention
LayerNorm2 *nn.LayerNorm `gguf:"ln2"`
MLP *VisionMLP
}
func (l *VisionEncoderLayer) Forward(ctx ml.Context, hiddenState ml.Tensor, opts *VisionOptions) ml.Tensor {
residual := hiddenState
hiddenState = l.LayerNorm1.Forward(ctx, hiddenState, opts.eps)
hiddenState = l.SelfAttention.Forward(ctx, hiddenState, opts)
hiddenState = hiddenState.Add(ctx, residual)
residual = hiddenState
hiddenState = l.LayerNorm2.Forward(ctx, hiddenState, opts.eps)
hiddenState = l.MLP.Forward(ctx, hiddenState)
return hiddenState.Add(ctx, residual)
}
type VisionOptions struct {
hiddenSize int
numHeads int
imageSize int
patchSize int
eps float32
}
type VisionModel struct {
PatchEmbedding *VisionPatchEmbedding `gguf:"patch_embd"`
PositionEmbedding ml.Tensor `gguf:"position_embd"`
ClassEmbedding ml.Tensor `gguf:"cls_embd"`
Layers []VisionEncoderLayer `gguf:"blk"`
*VisionOptions
resizedPositionEmbeddingsMu sync.Mutex
resizedPositionEmbeddings map[visionPatchGrid][]float32
}
func (m *VisionModel) Forward(ctx ml.Context, pixelValues ml.Tensor, patches visionPatchGrid) ml.Tensor {
numPatches := patches.Width * patches.Height
hiddenState := m.PatchEmbedding.Forward(ctx, pixelValues, m.patchSize)
return m.forwardPatchEmbeddings(ctx, hiddenState, patches, numPatches)
}
func (m *VisionModel) ForwardPacked(ctx ml.Context, patchValues []float32, patches visionPatchGrid) ml.Tensor {
numPatches := patches.Width * patches.Height
patchDim := 0
if numPatches > 0 {
patchDim = len(patchValues) / numPatches
}
hiddenState := m.PatchEmbedding.ForwardPacked(ctx, patchValues, patchDim, numPatches)
return m.forwardPatchEmbeddings(ctx, hiddenState, patches, numPatches)
}
func (m *VisionModel) forwardPatchEmbeddings(ctx ml.Context, hiddenState ml.Tensor, patches visionPatchGrid, numPatches int) ml.Tensor {
if m.PositionEmbedding != nil {
positionEmbeddings := m.positionEmbeddings(ctx, hiddenState, patches, numPatches)
hiddenState = hiddenState.Add(ctx, positionEmbeddings)
}
if m.ClassEmbedding != nil {
numPrefixTokens := m.ClassEmbedding.Dim(1)
classEmbeddings := m.ClassEmbedding.Cast(ctx, hiddenState.DType())
classEmbeddings = classEmbeddings.Reshape(ctx, classEmbeddings.Dim(0), numPrefixTokens, 1)
hiddenState = classEmbeddings.Concat(ctx, hiddenState, 1)
}
for _, layer := range m.Layers {
hiddenState = layer.Forward(ctx, hiddenState, m.VisionOptions)
}
if m.ClassEmbedding != nil {
hiddenState = hiddenState.Slice(ctx, 1, m.ClassEmbedding.Dim(1), hiddenState.Dim(1), 1)
}
return hiddenState.Reshape(ctx, hiddenState.Dim(0), hiddenState.Dim(1))
}
func (m *VisionModel) positionEmbeddings(ctx ml.Context, hiddenState ml.Tensor, patches visionPatchGrid, numPatches int) ml.Tensor {
posTokens := m.PositionEmbedding.Dim(1)
source := int(math.Sqrt(float64(posTokens)))
positionEmbeddings := m.PositionEmbedding.Cast(ctx, hiddenState.DType())
if !(source > 0 && source*source == posTokens && (source != patches.Width || source != patches.Height)) {
if positionEmbeddings.Dim(1) > numPatches {
positionEmbeddings = positionEmbeddings.Slice(ctx, 1, 0, numPatches, 1)
}
return positionEmbeddings
}
if cached, ok := m.cachePositionEmbeddings(ctx, hiddenState.Dim(0), patches); ok {
return ctx.Input().FromFloats(cached, hiddenState.Dim(0), numPatches)
}
// Runner fit/reserve builds worst-case multimodal graphs before weights are
// loaded, so the align-corners CPU cache path cannot materialize source
// values there. Fall back to a graph-only bilinear resize for reservation;
// the loaded inference path above still uses the cached align-corners data.
positionEmbeddings = positionEmbeddings.Reshape(ctx, -1, source, source)
positionEmbeddings = positionEmbeddings.Permute(ctx, 2, 0, 1, 3).Contiguous(ctx)
positionEmbeddings = positionEmbeddings.Interpolate(ctx, [4]int{
patches.Width,
patches.Height,
hiddenState.Dim(0),
1,
}, ml.SamplingModeBilinear)
positionEmbeddings = positionEmbeddings.Permute(ctx, 1, 2, 0, 3)
return positionEmbeddings.Contiguous(ctx, -1, patches.Width*patches.Height)
}
func (m *VisionModel) cachePositionEmbeddings(ctx ml.Context, hidden int, patches visionPatchGrid) ([]float32, bool) {
m.resizedPositionEmbeddingsMu.Lock()
cached := m.resizedPositionEmbeddings[patches]
m.resizedPositionEmbeddingsMu.Unlock()
if cached != nil {
return cached, true
}
if len(m.PositionEmbedding.Bytes()) == 0 {
return nil, false
}
posTokens := m.PositionEmbedding.Dim(1)
source := int(math.Sqrt(float64(posTokens)))
positionEmbeddingsF32 := m.PositionEmbedding.Cast(ctx, ml.DTypeF32)
ctx.Forward(positionEmbeddingsF32).Compute(positionEmbeddingsF32)
// RADIO eval-time CPE uses bilinear interpolation with align_corners=false.
// Cache a CPU-resized token-major embedding here for correctness first. This
// is likely slower than a native graph path and should be revisited if this
// precision vs speed tradeoff is not worthwhile.
cached = resizePositionEmbedding(positionEmbeddingsF32.Floats(), hidden, source, source, patches.Width, patches.Height)
m.resizedPositionEmbeddingsMu.Lock()
if m.resizedPositionEmbeddings == nil {
m.resizedPositionEmbeddings = make(map[visionPatchGrid][]float32)
}
if existing := m.resizedPositionEmbeddings[patches]; existing != nil {
cached = existing
} else {
m.resizedPositionEmbeddings[patches] = cached
}
m.resizedPositionEmbeddingsMu.Unlock()
return cached, true
}
func resizePositionEmbedding(values []float32, hidden, sourceWidth, sourceHeight, targetWidth, targetHeight int) []float32 {
out := make([]float32, hidden*targetWidth*targetHeight)
scaleX := float64(sourceWidth) / float64(targetWidth)
scaleY := float64(sourceHeight) / float64(targetHeight)
for oy := range targetHeight {
srcY := scaleY*(float64(oy)+0.5) - 0.5
y0 := int(math.Floor(srcY))
y1 := min(y0+1, sourceHeight-1)
wy := float32(srcY - float64(y0))
y0 = max(y0, 0)
for ox := range targetWidth {
srcX := scaleX*(float64(ox)+0.5) - 0.5
x0 := int(math.Floor(srcX))
x1 := min(x0+1, sourceWidth-1)
wx := float32(srcX - float64(x0))
x0 = max(x0, 0)
t00 := (y0*sourceWidth + x0) * hidden
t01 := (y0*sourceWidth + x1) * hidden
t10 := (y1*sourceWidth + x0) * hidden
t11 := (y1*sourceWidth + x1) * hidden
dst := (oy*targetWidth + ox) * hidden
for h := range hidden {
v00 := values[t00+h]
v01 := values[t01+h]
v10 := values[t10+h]
v11 := values[t11+h]
top := v00 + (v01-v00)*wx
bot := v10 + (v11-v10)*wx
out[dst+h] = top + (bot-top)*wy
}
}
}
return out
}
func newVisionModel(c fs.Config) *VisionModel {
return &VisionModel{
Layers: make([]VisionEncoderLayer, c.Uint("vision.block_count", 32)),
VisionOptions: &VisionOptions{
hiddenSize: int(c.Uint("vision.embedding_length", 1280)),
numHeads: int(c.Uint("vision.attention.head_count", 16)),
imageSize: int(c.Uint("vision.image_size", 512)),
patchSize: int(c.Uint("vision.patch_size", 16)),
eps: c.Float("vision.attention.layer_norm_epsilon", 1e-6),
},
}
}
type MultiModalProjector struct {
Norm *nn.RMSNorm `gguf:"norm"`
Linear1 *nn.Linear `gguf:"1"`
Linear2 *nn.Linear `gguf:"2"`
scaleFactor int
}
func (p *MultiModalProjector) Forward(ctx ml.Context, visionOutputs ml.Tensor, patches visionPatchGrid) ml.Tensor {
scaleFactor := max(p.scaleFactor, 1)
// The reference projector first pixel-shuffles the vision grid with
// downsample_ratio=0.5 before applying the RMSNorm/MLP. Preserve that exact
// v2 packing order here rather than flattening 2x2 neighborhoods via IM2Col.
merged := pixelShuffleVisionOutputs(ctx, visionOutputs, patches, scaleFactor)
merged = p.Norm.Forward(ctx, merged, 1e-5)
merged = p.Linear1.Forward(ctx, merged)
merged = merged.RELU(ctx)
merged = merged.Mul(ctx, merged)
return p.Linear2.Forward(ctx, merged)
}
func pixelShuffleVisionOutputs(ctx ml.Context, visionOutputs ml.Tensor, patches visionPatchGrid, scaleFactor int) ml.Tensor {
hiddenSize := visionOutputs.Dim(0)
scaleFactor = max(scaleFactor, 1)
merged := visionOutputs.Reshape(ctx, hiddenSize, patches.Width, patches.Height, 1)
width := patches.Width / scaleFactor
height := patches.Height / scaleFactor
channels := hiddenSize * scaleFactor
merged = merged.Reshape(ctx, channels, width, patches.Height, 1)
merged = merged.Reshape(ctx, channels, width, scaleFactor, height)
merged = merged.Permute(ctx, 0, 2, 1, 3).Contiguous(ctx)
return merged.Reshape(ctx, channels*scaleFactor, width*height, 1)
}
func newMultiModalProjector(c fs.Config) *MultiModalProjector {
return &MultiModalProjector{
scaleFactor: int(c.Uint("vision.projector.scale_factor", 2)),
}
}
+328
View File
@@ -0,0 +1,328 @@
package nemotronh
import (
"encoding/binary"
"fmt"
"math"
"math/cmplx"
)
const (
parakeetHopLength = 160
parakeetNFFT = 512
parakeetWinLength = 400
parakeetPreemphasis = 0.97
parakeetLogZeroGuardValue = 1.0 / (1 << 24)
parakeetNormalizeEps = 1e-5
)
func isAudioData(data []byte) bool {
return len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WAVE"
}
func decodeWAV(data []byte, targetSampleRate int) ([]float32, error) {
if len(data) < 12 {
return nil, fmt.Errorf("WAV file too short")
}
if !isAudioData(data) {
return nil, fmt.Errorf("not a WAV file")
}
var audioFormat uint16
var numChannels, sampleRate, bitsPerSample int
var audioData []byte
foundFmt := false
offset := 12
for offset+8 <= len(data) {
chunkID := string(data[offset : offset+4])
chunkSize := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))
chunkEnd := min(offset+8+chunkSize, len(data))
chunkData := data[offset+8 : chunkEnd]
switch chunkID {
case "fmt ":
if len(chunkData) < 16 {
return nil, fmt.Errorf("fmt chunk too short")
}
audioFormat = binary.LittleEndian.Uint16(chunkData[0:2])
numChannels = int(binary.LittleEndian.Uint16(chunkData[2:4]))
sampleRate = int(binary.LittleEndian.Uint32(chunkData[4:8]))
bitsPerSample = int(binary.LittleEndian.Uint16(chunkData[14:16]))
if audioFormat == 0xfffe && len(chunkData) >= 26 {
audioFormat = binary.LittleEndian.Uint16(chunkData[24:26])
}
foundFmt = true
case "data":
audioData = chunkData
}
offset += 8 + chunkSize
if chunkSize%2 != 0 {
offset++
}
}
if !foundFmt {
return nil, fmt.Errorf("no fmt chunk found in WAV file")
}
if audioFormat != 1 && audioFormat != 3 {
return nil, fmt.Errorf("unsupported WAV format: %d (need PCM=1 or float=3)", audioFormat)
}
if audioData == nil {
return nil, fmt.Errorf("no data chunk found in WAV file")
}
if numChannels <= 0 {
return nil, fmt.Errorf("invalid WAV channel count: %d", numChannels)
}
samples := decodeWAVSamples(audioData, audioFormat, bitsPerSample, numChannels)
if sampleRate != targetSampleRate {
samples = resampleLinear(samples, sampleRate, targetSampleRate)
}
return samples, nil
}
func decodeWAVSamples(data []byte, format uint16, bits, channels int) []float32 {
bytesPerSample := bits / 8
if bytesPerSample <= 0 || channels <= 0 {
return nil
}
totalSamples := len(data) / (bytesPerSample * channels)
mono := make([]float32, totalSamples)
for i := range totalSamples {
var sum float64
for ch := range channels {
off := (i*channels + ch) * bytesPerSample
if off+bytesPerSample > len(data) {
break
}
switch {
case format == 1 && bits == 16:
v := int16(binary.LittleEndian.Uint16(data[off : off+2]))
sum += float64(v) / 32768.0
case format == 1 && bits == 32:
v := int32(binary.LittleEndian.Uint32(data[off : off+4]))
sum += float64(v) / 2147483648.0
case format == 1 && bits == 24:
v := int32(data[off]) | int32(data[off+1])<<8 | int32(data[off+2])<<16
if v&0x800000 != 0 {
v |= ^0xffffff
}
sum += float64(v) / 8388608.0
case format == 3 && bits == 32:
sum += float64(math.Float32frombits(binary.LittleEndian.Uint32(data[off : off+4])))
case format == 1 && bits == 8:
sum += (float64(data[off]) - 128.0) / 128.0
}
}
mono[i] = float32(sum / float64(channels))
}
return mono
}
func resampleLinear(samples []float32, fromRate, toRate int) []float32 {
if fromRate <= 0 || toRate <= 0 || len(samples) == 0 {
return samples
}
n := int(float64(len(samples)) / float64(fromRate) * float64(toRate))
if n <= 1 {
return slicesCloneOne(samples)
}
out := make([]float32, n)
for i := range n {
pos := float64(i) * float64(len(samples)-1) / float64(n-1)
idx := int(pos)
frac := float32(pos - float64(idx))
if idx+1 < len(samples) {
out[i] = samples[idx]*(1-frac) + samples[idx+1]*frac
} else {
out[i] = samples[idx]
}
}
return out
}
func slicesCloneOne(samples []float32) []float32 {
if len(samples) == 0 {
return nil
}
return []float32{samples[0]}
}
func computeParakeetMelSpectrogram(samples []float32, extractor *AudioFeatureExtractor, opts *AudioOptions) ([]float32, int, int, error) {
if len(samples) == 0 {
return nil, 0, 0, fmt.Errorf("audio too short to encode")
}
if opts == nil {
opts = defaultAudioOptions()
}
melBins := opts.melBins
freqBins := parakeetNFFT/2 + 1
window, melFilters := extractor.windowAndFilters(melBins, freqBins, opts.sampleRate)
if len(window) != parakeetWinLength {
return nil, 0, 0, fmt.Errorf("invalid Parakeet window length: %d", len(window))
}
if len(melFilters) != melBins*freqBins {
return nil, 0, 0, fmt.Errorf("invalid Parakeet mel filter shape: %d", len(melFilters))
}
emphasized := make([]float32, len(samples))
emphasized[0] = samples[0]
for i := 1; i < len(samples); i++ {
emphasized[i] = samples[i] - parakeetPreemphasis*samples[i-1]
}
frames := len(samples)/parakeetHopLength + 1
validFrames := max(1, len(samples)/parakeetHopLength)
if validFrames > frames {
validFrames = frames
}
result := make([]float32, frames*melBins)
fftInput := make([]complex128, parakeetNFFT)
winOffset := (parakeetNFFT - parakeetWinLength) / 2
centerPad := parakeetNFFT / 2
for frame := range frames {
for i := range parakeetNFFT {
fftInput[i] = 0
}
for i := range parakeetWinLength {
src := frame*parakeetHopLength + i + winOffset - centerPad
if src >= 0 && src < len(emphasized) {
fftInput[i+winOffset] = complex(float64(emphasized[src])*float64(window[i]), 0)
}
}
fft(fftInput)
for mel := range melBins {
var v float64
filterOffset := mel * freqBins
for freq := range freqBins {
mag := cmplx.Abs(fftInput[freq])
v += float64(melFilters[filterOffset+freq]) * mag * mag
}
result[frame*melBins+mel] = float32(math.Log(v + parakeetLogZeroGuardValue))
}
}
for mel := range melBins {
var sum float64
for frame := range validFrames {
sum += float64(result[frame*melBins+mel])
}
mean := sum / float64(validFrames)
var variance float64
for frame := range validFrames {
d := float64(result[frame*melBins+mel]) - mean
variance += d * d
}
denom := max(1, validFrames-1)
std := math.Sqrt(variance / float64(denom))
for frame := range frames {
idx := frame*melBins + mel
if frame >= validFrames {
result[idx] = 0
continue
}
result[idx] = float32((float64(result[idx]) - mean) / (std + parakeetNormalizeEps))
}
}
return result, frames, validFrames, nil
}
func defaultParakeetWindow() []float32 {
window := make([]float32, parakeetWinLength)
for i := range window {
window[i] = float32(0.5 - 0.5*math.Cos(2*math.Pi*float64(i)/float64(parakeetWinLength-1)))
}
return window
}
func buildSlaneyMelFilterBank(numFreqBins, numMels int, sampleRate int) []float32 {
hzToMel := func(f float64) float64 {
if f < 1000 {
return 3 * f / 200
}
return 15 + math.Log(f/1000)*27/math.Log(6.4)
}
melToHz := func(m float64) float64 {
if m < 15 {
return 200 * m / 3
}
return 1000 * math.Exp(math.Log(6.4)*(m-15)/27)
}
minMel := hzToMel(0)
maxMel := hzToMel(float64(sampleRate) / 2)
mels := make([]float64, numMels+2)
freqs := make([]float64, numMels+2)
for i := range mels {
mels[i] = minMel + (maxMel-minMel)*float64(i)/float64(numMels+1)
freqs[i] = melToHz(mels[i])
}
fftFreqs := make([]float64, numFreqBins)
for i := range fftFreqs {
fftFreqs[i] = float64(i) * float64(sampleRate) / float64(parakeetNFFT)
}
filters := make([]float32, numMels*numFreqBins)
for mel := range numMels {
left, center, right := freqs[mel], freqs[mel+1], freqs[mel+2]
enorm := 2.0 / (right - left)
for freq, fftFreq := range fftFreqs {
var lower, upper float64
if center > left {
lower = (fftFreq - left) / (center - left)
}
if right > center {
upper = (right - fftFreq) / (right - center)
}
v := math.Max(0, math.Min(lower, upper))
filters[mel*numFreqBins+freq] = float32(v * enorm)
}
}
return filters
}
func fft(x []complex128) {
n := len(x)
if n <= 1 {
return
}
j := 0
for i := 1; i < n; i++ {
bit := n >> 1
for j&bit != 0 {
j ^= bit
bit >>= 1
}
j ^= bit
if i < j {
x[i], x[j] = x[j], x[i]
}
}
for size := 2; size <= n; size <<= 1 {
halfSize := size / 2
w := complex(math.Cos(2*math.Pi/float64(size)), -math.Sin(2*math.Pi/float64(size)))
for start := 0; start < n; start += size {
wn := complex(1, 0)
for k := range halfSize {
t := wn * x[start+k+halfSize]
x[start+k+halfSize] = x[start+k] - t
x[start+k] = x[start+k] + t
wn *= w
}
}
}
}
+498
View File
@@ -0,0 +1,498 @@
package parsers
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"unicode"
"github.com/ollama/ollama/api"
)
const (
lagunaThinkingOpenTag = "<think>"
lagunaThinkingCloseTag = "</think>"
lagunaToolCallOpenTag = "<tool_call>"
lagunaToolCallCloseTag = "</tool_call>"
lagunaUserOpenTag = "<user>"
lagunaUserCloseTag = "</user>"
)
type lagunaParserState int
const (
lagunaParserStateThinking lagunaParserState = iota
lagunaParserStateContent
lagunaParserStateTool
)
type LagunaParser struct {
state lagunaParserState
buffer strings.Builder
tools []api.Tool
callIndex int
thinkingEnabled bool
thinkingSuppressed bool
allowLeadingThinkOpen bool
}
func (p *LagunaParser) HasToolSupport() bool {
return true
}
func (p *LagunaParser) HasThinkingSupport() bool {
return true
}
func (p *LagunaParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.tools = tools
p.callIndex = 0
p.buffer.Reset()
p.thinkingEnabled = thinkValue == nil || thinkValue.Bool()
p.thinkingSuppressed = thinkValue != nil && !thinkValue.Bool()
p.state = lagunaParserStateContent
p.allowLeadingThinkOpen = false
return tools
}
func (p *LagunaParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
var contentSB, thinkingSB strings.Builder
for {
progress := false
switch p.state {
case lagunaParserStateThinking:
progress, thinking = p.consumeThinking(done)
if p.thinkingEnabled {
thinkingSB.WriteString(thinking)
}
case lagunaParserStateContent:
var parsedCalls []api.ToolCall
progress, content, parsedCalls, err = p.consumeContent(done)
if err != nil {
return "", "", nil, err
}
contentSB.WriteString(content)
calls = append(calls, parsedCalls...)
case lagunaParserStateTool:
var call api.ToolCall
progress, call, err = p.consumeTool(done)
if err != nil {
return "", "", nil, err
}
if progress {
calls = append(calls, call)
}
}
if !progress {
break
}
}
return contentSB.String(), thinkingSB.String(), calls, nil
}
func (p *LagunaParser) consumeThinking(done bool) (bool, string) {
acc := p.buffer.String()
if p.allowLeadingThinkOpen {
trimmed := strings.TrimLeftFunc(acc, unicode.IsSpace)
if strings.HasPrefix(trimmed, lagunaThinkingOpenTag) {
p.buffer.Reset()
p.buffer.WriteString(strings.TrimLeftFunc(strings.TrimPrefix(trimmed, lagunaThinkingOpenTag), unicode.IsSpace))
p.allowLeadingThinkOpen = false
return true, ""
}
if strings.HasPrefix(lagunaThinkingOpenTag, trimmed) && !done {
return false, ""
}
p.allowLeadingThinkOpen = false
}
if idx := strings.Index(acc, lagunaThinkingCloseTag); idx != -1 {
thinking := acc[:idx]
after := strings.TrimLeftFunc(acc[idx+len(lagunaThinkingCloseTag):], unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(after)
p.state = lagunaParserStateContent
return true, thinking
}
if idx := strings.Index(acc, lagunaToolCallOpenTag); idx != -1 {
thinking := strings.TrimRightFunc(acc[:idx], unicode.IsSpace)
after := acc[idx+len(lagunaToolCallOpenTag):]
p.buffer.Reset()
p.buffer.WriteString(after)
p.state = lagunaParserStateTool
return true, thinking
}
if done {
p.buffer.Reset()
p.state = lagunaParserStateContent
return acc != "", acc
}
overlapLen := max(overlap(acc, lagunaThinkingCloseTag), overlap(acc, lagunaToolCallOpenTag))
trailingLen := trailingWhitespaceLen(acc)
keep := max(overlapLen, trailingLen)
if keep > 0 && keep < len(acc) {
emit := acc[:len(acc)-keep]
p.buffer.Reset()
p.buffer.WriteString(acc[len(acc)-keep:])
return emit != "", emit
}
return false, ""
}
func (p *LagunaParser) consumeContent(done bool) (bool, string, []api.ToolCall, error) {
acc := p.buffer.String()
if p.thinkingEnabled || p.thinkingSuppressed {
if idx := strings.Index(acc, lagunaThinkingOpenTag); idx != -1 {
content := acc[:idx]
after := strings.TrimLeftFunc(acc[idx+len(lagunaThinkingOpenTag):], unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(after)
p.state = lagunaParserStateThinking
p.allowLeadingThinkOpen = false
return true, content, nil, nil
}
if !done {
overlapLen := overlap(acc, lagunaThinkingOpenTag)
if overlapLen > 0 && overlapLen < len(acc) {
content := acc[:len(acc)-overlapLen]
p.buffer.Reset()
p.buffer.WriteString(acc[len(acc)-overlapLen:])
return content != "", content, nil, nil
}
}
}
if p.thinkingEnabled {
trimmed := strings.TrimLeftFunc(acc, unicode.IsSpace)
if strings.HasPrefix(trimmed, lagunaThinkingCloseTag) {
p.buffer.Reset()
p.buffer.WriteString(strings.TrimLeftFunc(strings.TrimPrefix(trimmed, lagunaThinkingCloseTag), unicode.IsSpace))
return true, "", nil, nil
}
if strings.HasPrefix(lagunaThinkingCloseTag, trimmed) && !done {
return false, "", nil, nil
}
}
if p.thinkingSuppressed {
trimmed := strings.TrimLeftFunc(acc, unicode.IsSpace)
if strings.HasPrefix(trimmed, lagunaThinkingCloseTag) {
p.buffer.Reset()
p.buffer.WriteString(strings.TrimLeftFunc(strings.TrimPrefix(trimmed, lagunaThinkingCloseTag), unicode.IsSpace))
return true, "", nil, nil
}
if strings.HasPrefix(lagunaThinkingCloseTag, trimmed) && !done {
return false, "", nil, nil
}
}
if idx := strings.Index(acc, lagunaToolCallOpenTag); idx != -1 {
content := strings.TrimRightFunc(acc[:idx], unicode.IsSpace)
after := acc[idx+len(lagunaToolCallOpenTag):]
p.buffer.Reset()
p.buffer.WriteString(after)
p.state = lagunaParserStateTool
return true, content, nil, nil
}
if idx := strings.Index(acc, lagunaUserOpenTag); idx != -1 && len(p.tools) > 0 {
before := strings.TrimRightFunc(acc[:idx], unicode.IsSpace)
afterOpen := acc[idx+len(lagunaUserOpenTag):]
if closeIdx := strings.Index(afterOpen, lagunaUserCloseTag); closeIdx != -1 {
raw := afterOpen[:closeIdx]
if call, ok := p.parseToolAlias(raw); ok {
after := strings.TrimLeftFunc(afterOpen[closeIdx+len(lagunaUserCloseTag):], unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(after)
return true, before, []api.ToolCall{call}, nil
}
} else if !done {
if idx > 0 {
p.buffer.Reset()
p.buffer.WriteString(acc[idx:])
return true, before, nil, nil
}
return false, "", nil, nil
}
}
if len(p.tools) > 0 {
if progress, content, call, ok, err := p.consumeStandaloneJSONTool(done); ok || err != nil {
if err != nil {
return false, "", nil, err
}
if progress {
return true, content, []api.ToolCall{call}, nil
}
return false, "", nil, nil
}
}
if done {
p.buffer.Reset()
return acc != "", acc, nil, nil
}
overlapLen := max(overlap(acc, lagunaToolCallOpenTag), overlap(acc, lagunaUserOpenTag))
if p.thinkingEnabled || p.thinkingSuppressed {
overlapLen = max(overlapLen, overlap(acc, lagunaThinkingOpenTag))
}
if p.thinkingSuppressed {
overlapLen = max(overlapLen, overlap(acc, lagunaThinkingCloseTag))
}
trailingLen := trailingWhitespaceLen(acc)
keep := max(overlapLen, trailingLen)
if keep > 0 && keep < len(acc) {
emit := acc[:len(acc)-keep]
p.buffer.Reset()
p.buffer.WriteString(acc[len(acc)-keep:])
return emit != "", emit, nil, nil
}
if keep == 0 && acc != "" {
p.buffer.Reset()
return true, acc, nil, nil
}
return false, "", nil, nil
}
func (p *LagunaParser) consumeStandaloneJSONTool(done bool) (progress bool, content string, call api.ToolCall, ok bool, err error) {
acc := p.buffer.String()
jsonIdx := strings.Index(acc, "{")
if jsonIdx == -1 {
return false, "", api.ToolCall{}, false, nil
}
before := strings.TrimRightFunc(acc[:jsonIdx], unicode.IsSpace)
raw := strings.TrimLeftFunc(acc[jsonIdx:], unicode.IsSpace)
if !lagunaLooksLikeJSONToolCall(raw, done) {
return false, "", api.ToolCall{}, false, nil
}
if !done && !json.Valid([]byte(strings.TrimSpace(raw))) {
if before != "" {
p.buffer.Reset()
p.buffer.WriteString(acc[jsonIdx:])
return true, before, api.ToolCall{}, true, nil
}
return false, "", api.ToolCall{}, true, nil
}
call, err = parseLagunaToolCall(raw, p.tools)
if err != nil {
return false, "", api.ToolCall{}, true, err
}
call.Function.Index = p.callIndex
p.callIndex++
p.buffer.Reset()
p.state = lagunaParserStateContent
return true, before, call, true, nil
}
func lagunaLooksLikeJSONToolCall(raw string, done bool) bool {
trimmed := strings.TrimLeftFunc(raw, unicode.IsSpace)
if !strings.HasPrefix(trimmed, "{") {
return false
}
if strings.Contains(trimmed, `"name"`) || strings.Contains(trimmed, `"arguments"`) {
return true
}
if done {
return false
}
return strings.HasPrefix(trimmed, `{"`) || strings.HasPrefix(trimmed, "{\n") || strings.HasPrefix(trimmed, "{\r\n")
}
func (p *LagunaParser) parseToolAlias(raw string) (api.ToolCall, bool) {
raw = cleanLagunaToolCallRaw(raw)
name, ok := lagunaToolCallName(raw)
if !ok {
return api.ToolCall{}, false
}
if _, ok := lagunaResolveToolName(name, p.tools); !ok {
return api.ToolCall{}, false
}
call, err := parseLagunaToolCall(raw, p.tools)
if err != nil {
return api.ToolCall{}, false
}
call.Function.Index = p.callIndex
p.callIndex++
return call, true
}
func lagunaResolveToolName(name string, tools []api.Tool) (string, bool) {
for i := range tools {
if tools[i].Function.Name == name {
return name, true
}
}
aliases := map[string]string{
"read_file": "read",
"write_file": "write",
"edit_file": "edit",
"web_fetch": "webfetch",
}
if alias, ok := aliases[name]; ok {
for i := range tools {
if tools[i].Function.Name == alias {
return alias, true
}
}
}
return name, false
}
func cleanLagunaToolCallRaw(raw string) string {
raw = strings.TrimSpace(raw)
for strings.HasPrefix(raw, lagunaToolCallOpenTag) {
raw = strings.TrimSpace(strings.TrimPrefix(raw, lagunaToolCallOpenTag))
}
if idx := strings.Index(raw, lagunaToolCallCloseTag); idx != -1 {
raw = strings.TrimSpace(raw[:idx])
}
if idx := strings.Index(raw, lagunaToolCallOpenTag); idx != -1 {
before := strings.TrimSpace(raw[:idx])
if before != "" {
return before
}
raw = strings.TrimSpace(raw[idx+len(lagunaToolCallOpenTag):])
}
return raw
}
func lagunaToolCallName(raw string) (string, bool) {
raw = cleanLagunaToolCallRaw(raw)
if strings.HasPrefix(raw, "{") {
var parsed struct {
Name string `json:"name"`
}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return "", false
}
name := strings.TrimSpace(parsed.Name)
return name, name != ""
}
nameEnd := strings.Index(raw, "<arg_key>")
if nameEnd < 0 {
nameEnd = strings.Index(raw, "{")
}
if nameEnd < 0 {
nameEnd = strings.IndexAny(raw, "\r\n")
}
if nameEnd < 0 {
nameEnd = len(raw)
}
name := strings.TrimSpace(raw[:nameEnd])
return name, name != ""
}
func (p *LagunaParser) consumeTool(done bool) (bool, api.ToolCall, error) {
acc := p.buffer.String()
if idx := strings.Index(acc, lagunaToolCallCloseTag); idx != -1 {
raw := acc[:idx]
after := strings.TrimLeftFunc(acc[idx+len(lagunaToolCallCloseTag):], unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(after)
p.state = lagunaParserStateContent
call, err := parseLagunaToolCall(raw, p.tools)
if err != nil {
return false, api.ToolCall{}, err
}
call.Function.Index = p.callIndex
p.callIndex++
return true, call, nil
}
if done && strings.TrimSpace(acc) != "" {
p.buffer.Reset()
p.state = lagunaParserStateContent
call, err := parseLagunaToolCall(acc, p.tools)
if err != nil {
return false, api.ToolCall{}, err
}
call.Function.Index = p.callIndex
p.callIndex++
return true, call, nil
}
return false, api.ToolCall{}, nil
}
var lagunaArgRE = regexp.MustCompile(`(?s)<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>`)
func parseLagunaToolCall(raw string, tools []api.Tool) (api.ToolCall, error) {
raw = cleanLagunaToolCallRaw(raw)
if strings.HasPrefix(raw, "{") {
var parsed struct {
Name string `json:"name"`
Arguments api.ToolCallFunctionArguments `json:"arguments"`
}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return api.ToolCall{}, fmt.Errorf("failed to parse Laguna JSON tool call: %w", err)
}
if parsed.Name == "" {
return api.ToolCall{}, fmt.Errorf("empty Laguna tool call name")
}
if name, ok := lagunaResolveToolName(parsed.Name, tools); ok {
parsed.Name = name
}
return api.ToolCall{
Function: api.ToolCallFunction{
Name: parsed.Name,
Arguments: parsed.Arguments,
},
}, nil
}
nameEnd := strings.Index(raw, "<arg_key>")
name := raw
argsText := ""
if nameEnd >= 0 {
name = raw[:nameEnd]
argsText = raw[nameEnd:]
} else if jsonStart := strings.Index(raw, "{"); jsonStart >= 0 {
name = raw[:jsonStart]
argsText = raw[jsonStart:]
}
name = strings.TrimSpace(name)
if resolved, ok := lagunaResolveToolName(name, tools); ok {
name = resolved
}
var matchedTool *api.Tool
for i := range tools {
if tools[i].Function.Name == name {
matchedTool = &tools[i]
break
}
}
call := api.ToolCall{
Function: api.ToolCallFunction{
Name: name,
Arguments: api.NewToolCallFunctionArguments(),
},
}
if strings.HasPrefix(strings.TrimSpace(argsText), "{") {
if err := json.Unmarshal([]byte(strings.TrimSpace(argsText)), &call.Function.Arguments); err != nil {
return api.ToolCall{}, fmt.Errorf("failed to parse Laguna JSON tool call arguments: %w", err)
}
return call, nil
}
for _, match := range lagunaArgRE.FindAllStringSubmatch(argsText, -1) {
key := strings.TrimSpace(match[1])
value := match[2]
var paramType api.PropertyType
if matchedTool != nil && matchedTool.Function.Parameters.Properties != nil {
if prop, ok := matchedTool.Function.Parameters.Properties.Get(key); ok {
if len(prop.AnyOf) > 0 {
for _, anyOfProp := range prop.AnyOf {
paramType = append(paramType, anyOfProp.Type...)
}
} else {
paramType = prop.Type
}
}
}
call.Function.Arguments.Set(key, parseValue(value, paramType))
}
return call, nil
}
+484
View File
@@ -0,0 +1,484 @@
package parsers
import (
"testing"
"github.com/ollama/ollama/api"
)
func lagunaTestTools() []api.Tool {
props := api.NewToolPropertiesMap()
props.Set("location", api.ToolProperty{Type: api.PropertyType{"string"}})
props.Set("days", api.ToolProperty{Type: api.PropertyType{"integer"}})
return []api.Tool{{
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Properties: props,
},
},
}}
}
func TestLagunaParserToolCall(t *testing.T) {
parser := ParserForName("laguna")
if parser == nil {
t.Fatal("expected laguna parser")
}
if !parser.HasToolSupport() || !parser.HasThinkingSupport() {
t.Fatal("laguna parser should advertise tools and thinking")
}
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls, err := parser.Add("<tool_call>get_weather\n<arg_key>location</arg_key>\n<arg_value>Paris</arg_value>\n<arg_key>days</arg_key>\n<arg_value>3</arg_value>\n</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "" {
t.Fatalf("content=%q thinking=%q, want empty", content, thinking)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("name=%q, want get_weather", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("location"); got != "Paris" {
t.Fatalf("location=%v, want Paris", got)
}
if got, _ := calls[0].Function.Arguments.Get("days"); got != 3 {
t.Fatalf("days=%v, want 3", got)
}
}
func TestLagunaParserJSONToolCall(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
_, _, calls, err := parser.Add("<tool_call>\n{\"name\":\"get_weather\",\"arguments\":{\"location\":\"Paris\",\"days\":3}}\n</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("name=%q, want get_weather", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("location"); got != "Paris" {
t.Fatalf("location=%v, want Paris", got)
}
if got, _ := calls[0].Function.Arguments.Get("days"); got != float64(3) {
t.Fatalf("days=%v, want 3", got)
}
}
func TestLagunaParserStandaloneJSONToolCall(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls, err := parser.Add("{\"name\":\"get_weather\",\"arguments\":{\"location\":\"Paris\",\"days\":3}}", true)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "" {
t.Fatalf("content=%q thinking=%q", content, thinking)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("name=%q, want get_weather", calls[0].Function.Name)
}
}
func TestLagunaParserStandaloneJSONToolCallAfterLeadingContent(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls, err := parser.Add("Let me call the weather tool.\n{\"name\":\"get_weather\",\"arguments\":{\"location\":\"Paris\"}}", true)
if err != nil {
t.Fatal(err)
}
if content != "Let me call the weather tool." || thinking != "" {
t.Fatalf("content=%q thinking=%q", content, thinking)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("name=%q, want get_weather", calls[0].Function.Name)
}
}
func TestLagunaParserStreamingStandaloneJSONToolCall(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls, err := parser.Add("{\"name\":\"get_weather\",\"arguments\":{\"location\":\"San Francisco,", false)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "" || len(calls) != 0 {
t.Fatalf("first chunk content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
content, thinking, calls, err = parser.Add(" CA\"}}", true)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "" || len(calls) != 1 {
t.Fatalf("second chunk content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("name=%q, want get_weather", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("location"); got != "San Francisco, CA" {
t.Fatalf("location=%v, want San Francisco, CA", got)
}
}
func TestLagunaParserNameLineJSONToolCall(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
_, _, calls, err := parser.Add("<tool_call>get_weather\n{\"location\":\"San Francisco\"}</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("name=%q, want get_weather", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("location"); got != "San Francisco" {
t.Fatalf("location=%v, want San Francisco", got)
}
}
func TestLagunaParserNormalizesCommonToolAliases(t *testing.T) {
props := api.NewToolPropertiesMap()
props.Set("path", api.ToolProperty{Type: api.PropertyType{"string"}})
tools := []api.Tool{{
Function: api.ToolFunction{
Name: "read",
Parameters: api.ToolFunctionParameters{
Properties: props,
},
},
}}
parser := ParserForName("laguna")
parser.Init(tools, nil, nil)
_, _, calls, err := parser.Add("<tool_call>\n{\"name\":\"read_file\",\"arguments\":{\"path\":\"./go.mod\"}}\n</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "read" {
t.Fatalf("name=%q, want read", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("path"); got != "./go.mod" {
t.Fatalf("path=%v, want ./go.mod", got)
}
}
func TestLagunaParserIgnoresDuplicatedNestedToolCall(t *testing.T) {
props := api.NewToolPropertiesMap()
props.Set("name", api.ToolProperty{Type: api.PropertyType{"string"}})
tools := []api.Tool{{
Function: api.ToolFunction{
Name: "skill",
Parameters: api.ToolFunctionParameters{
Properties: props,
},
},
}}
parser := ParserForName("laguna")
parser.Init(tools, nil, nil)
_, _, calls, err := parser.Add("<tool_call>skill\n{\"name\":\"git-diff-review\"}\n<tool_call>skill\n{\"name\":\"git-diff-review\"}</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "skill" {
t.Fatalf("name=%q, want skill", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("name"); got != "git-diff-review" {
t.Fatalf("name arg=%v, want git-diff-review", got)
}
}
func TestLagunaParserThinkingThenTool(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("<think>Need current weather.</think>\n<tool_call>get_weather\n<arg_key>location</arg_key>\n<arg_value>SF</arg_value>\n</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if content != "" {
t.Fatalf("content=%q, want empty", content)
}
if thinking != "Need current weather." {
t.Fatalf("thinking=%q, want reasoning", thinking)
}
if len(calls) != 1 || calls[0].Function.Name != "get_weather" {
t.Fatalf("unexpected calls: %#v", calls)
}
}
func TestLagunaParserUserTaggedToolAlias(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls, err := parser.Add("<user>get_weather\n<arg_key>location</arg_key>\n<arg_value>San Francisco, CA</arg_value>\n</user>", true)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "" {
t.Fatalf("content=%q thinking=%q, want empty", content, thinking)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "get_weather" {
t.Fatalf("name=%q, want get_weather", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("location"); got != "San Francisco, CA" {
t.Fatalf("location=%v, want San Francisco, CA", got)
}
}
func TestLagunaParserUserTaggedToolAliasWithLeadingContent(t *testing.T) {
parser := ParserForName("laguna")
props := api.NewToolPropertiesMap()
props.Set("path", api.ToolProperty{Type: api.PropertyType{"string"}})
tools := []api.Tool{{
Function: api.ToolFunction{
Name: "read",
Parameters: api.ToolFunctionParameters{
Properties: props,
},
},
}}
parser.Init(tools, nil, nil)
content, thinking, calls, err := parser.Add("I'll read the file for you.\n<user>read\n<arg_key>path</arg_key>\n<arg_value>/Users/test/code/myproject/go.mod</arg_value>\n</user>", true)
if err != nil {
t.Fatal(err)
}
if content != "I'll read the file for you." || thinking != "" {
t.Fatalf("content=%q thinking=%q", content, thinking)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "read" {
t.Fatalf("name=%q, want read", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("path"); got != "/Users/test/code/myproject/go.mod" {
t.Fatalf("path=%v, want /Users/test/code/myproject/go.mod", got)
}
}
func TestLagunaParserUserTaggedJSONToolCallWithLeadingContent(t *testing.T) {
parser := ParserForName("laguna")
props := api.NewToolPropertiesMap()
props.Set("command", api.ToolProperty{Type: api.PropertyType{"string"}})
tools := []api.Tool{{
Function: api.ToolFunction{
Name: "bash",
Parameters: api.ToolFunctionParameters{
Properties: props,
},
},
}}
parser.Init(tools, nil, nil)
content, thinking, calls, err := parser.Add("I'll run git diff for you.<user>\n{\"name\":\"bash\",\"arguments\":{\"command\":\"git diff main\"}}\n</user>", true)
if err != nil {
t.Fatal(err)
}
if content != "I'll run git diff for you." || thinking != "" {
t.Fatalf("content=%q thinking=%q", content, thinking)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "bash" {
t.Fatalf("name=%q, want bash", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("command"); got != "git diff main" {
t.Fatalf("command=%v, want git diff main", got)
}
}
func TestLagunaParserStreamingUserTaggedToolAliasAfterContent(t *testing.T) {
parser := ParserForName("laguna")
props := api.NewToolPropertiesMap()
props.Set("path", api.ToolProperty{Type: api.PropertyType{"string"}})
tools := []api.Tool{{
Function: api.ToolFunction{
Name: "read",
Parameters: api.ToolFunctionParameters{
Properties: props,
},
},
}}
parser.Init(tools, nil, nil)
content, thinking, calls, err := parser.Add("I'll read the file for you.<us", false)
if err != nil {
t.Fatal(err)
}
if content != "I'll read the file for you." || thinking != "" || len(calls) != 0 {
t.Fatalf("first chunk content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
content, thinking, calls, err = parser.Add("er>read\n<arg_key>path</arg_key>\n<arg_value>/Users/test/code/myproject/go.mod</arg_value>\n</user>", true)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "" {
t.Fatalf("second chunk content=%q thinking=%q", content, thinking)
}
if len(calls) != 1 {
t.Fatalf("calls=%d, want 1", len(calls))
}
if calls[0].Function.Name != "read" {
t.Fatalf("name=%q, want read", calls[0].Function.Name)
}
if got, _ := calls[0].Function.Arguments.Get("path"); got != "/Users/test/code/myproject/go.mod" {
t.Fatalf("path=%v, want /Users/test/code/myproject/go.mod", got)
}
}
func TestLagunaParserUserTaggedNonToolContent(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls, err := parser.Add("<user>hello</user>", true)
if err != nil {
t.Fatal(err)
}
if content != "<user>hello</user>" || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserThinkingDefaultsOn(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, nil, nil)
content, thinking, calls, err := parser.Add("<think>Need to reason.</think>\nDirect answer.", true)
if err != nil {
t.Fatal(err)
}
if content != "Direct answer." || thinking != "Need to reason." || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserThinkingDefaultsOnWhenToolsPresent(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls, err := parser.Add("<think>Need to reason.</think>\n<tool_call>get_weather\n<arg_key>location</arg_key>\n<arg_value>Paris</arg_value>\n</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if thinking != "Need to reason." || len(calls) != 1 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
if content != "" {
t.Fatalf("content=%q, want thinking block suppressed from content when default thinking is enabled", content)
}
}
func TestLagunaParserThinkingExplicitlyDisabled(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, nil, &api.ThinkValue{Value: false})
content, thinking, calls, err := parser.Add("<think>Hidden?</think>\nDirect answer.", true)
if err != nil {
t.Fatal(err)
}
if content != "Direct answer." || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserThinkingExplicitlyDisabledDropsLeadingCloseTag(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, nil, &api.ThinkValue{Value: false})
content, thinking, calls, err := parser.Add("</think>\nTokyo\n", true)
if err != nil {
t.Fatal(err)
}
if content != "Tokyo\n" || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserThinkingEnabledDropsLeadingCloseTag(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("</think>\nTokyo\n", true)
if err != nil {
t.Fatal(err)
}
if content != "Tokyo\n" || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserThinkingDefaultOnDropsLeadingCloseTag(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, nil, nil)
content, thinking, calls, err := parser.Add("</think>\nTokyo\n", true)
if err != nil {
t.Fatal(err)
}
if content != "Tokyo\n" || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserThinkingEnabledUntaggedAnswerIsContent(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("Direct answer.", true)
if err != nil {
t.Fatal(err)
}
if content != "Direct answer." || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserSplitToolTag(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("<think>Need lookup<tool_c", false)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "Need lookup" || len(calls) != 0 {
t.Fatalf("first chunk content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
content, thinking, calls, err = parser.Add("all>get_weather\n<arg_key>location</arg_key>\n<arg_value>SF</arg_value>\n</tool_call>", true)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "" || len(calls) != 1 {
t.Fatalf("second chunk content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
+66 -4
View File
@@ -16,14 +16,17 @@ const (
)
const (
nemotronThinkOpen = "<think>"
nemotronThinkClose = "</think>"
nemotronToolCallOpen = "<tool_call>"
)
type Nemotron3NanoParser struct {
state Nemotron3NanoParserState
buffer strings.Builder
toolParser *Qwen3CoderParser
state Nemotron3NanoParserState
buffer strings.Builder
toolParser *Qwen3CoderParser
maybeThinkingOpenAtBOL bool
skipThinkingLeadingWS bool
}
func (p *Nemotron3NanoParser) HasToolSupport() bool { return true }
@@ -32,14 +35,18 @@ func (p *Nemotron3NanoParser) HasThinkingSupport() bool { return true }
func (p *Nemotron3NanoParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.toolParser = &Qwen3CoderParser{}
p.toolParser.Init(tools, nil, nil)
p.buffer.Reset()
p.maybeThinkingOpenAtBOL = false
p.skipThinkingLeadingWS = false
thinkingEnabled := thinkValue != nil && thinkValue.Bool()
thinkingEnabled := thinkValue == nil || thinkValue.Bool()
prefill := lastMessage != nil && lastMessage.Role == "assistant"
if !thinkingEnabled || (prefill && lastMessage.Content != "") {
p.state = Nemotron3NanoCollectingContent
} else {
p.state = Nemotron3NanoCollectingThinking
p.maybeThinkingOpenAtBOL = true
}
return tools
@@ -61,6 +68,29 @@ func (p *Nemotron3NanoParser) Add(s string, done bool) (content string, thinking
// Nemotron3NanoCollectingThinking - buffer and look for end markers
p.buffer.WriteString(s)
if p.skipThinkingLeadingWS {
trimmed := strings.TrimLeftFunc(p.buffer.String(), unicode.IsSpace)
p.buffer.Reset()
p.buffer.WriteString(trimmed)
if trimmed == "" {
return "", "", nil, nil
}
p.skipThinkingLeadingWS = false
}
if p.stripOpeningThinkTag() {
return p.Add("", done)
}
if p.maybeThinkingOpenAtBOL {
bufStr := p.buffer.String()
trimmed := strings.TrimLeftFunc(bufStr, unicode.IsSpace)
if trimmed == "" || overlap(trimmed, nemotronThinkOpen) == len(trimmed) {
if len(trimmed) != len(bufStr) {
p.buffer.Reset()
p.buffer.WriteString(trimmed)
}
return "", "", nil, nil
}
}
bufStr := p.buffer.String()
// Look for end of thinking: </think> or <tool_call> (model may skip </think>)
@@ -124,3 +154,35 @@ func (p *Nemotron3NanoParser) emitThinking(bufStr string) string {
p.buffer.Reset()
return bufStr
}
func (p *Nemotron3NanoParser) stripOpeningThinkTag() bool {
if !p.maybeThinkingOpenAtBOL {
return false
}
bufStr := p.buffer.String()
trimmed := strings.TrimLeftFunc(bufStr, unicode.IsSpace)
if trimmed == "" {
p.buffer.Reset()
return false
}
if strings.HasPrefix(trimmed, nemotronThinkOpen) {
p.buffer.Reset()
p.buffer.WriteString(strings.TrimLeftFunc(trimmed[len(nemotronThinkOpen):], unicode.IsSpace))
p.maybeThinkingOpenAtBOL = false
p.skipThinkingLeadingWS = true
return true
}
if overlap(trimmed, nemotronThinkOpen) == len(trimmed) {
if len(trimmed) != len(bufStr) {
p.buffer.Reset()
p.buffer.WriteString(trimmed)
}
return false
}
p.maybeThinkingOpenAtBOL = false
return false
}
+47 -3
View File
@@ -82,6 +82,20 @@ func TestNemotron3NanoParser(t *testing.T) {
expectedThinking: "My thoughts...",
expectedContent: "Content here.",
},
{
name: "leading open think tag is ignored",
input: "<think>\nLet me think about this...</think>\nHere is my answer.",
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "Let me think about this...",
expectedContent: "Here is my answer.",
},
{
name: "empty explicit think block is ignored",
input: "<think></think>\nHere is my answer.",
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "",
expectedContent: "Here is my answer.",
},
}
for _, tt := range tests {
@@ -191,6 +205,13 @@ func TestNemotron3NanoParser_Streaming(t *testing.T) {
},
},
},
{
name: "leading open think tag split across chunks",
chunks: []string{"<th", "ink>", "\nThink first", "</think>", "\nDone."},
thinkValue: &api.ThinkValue{Value: true},
expectedThinking: "Think first",
expectedContent: "Done.",
},
}
for _, tt := range tests {
@@ -265,11 +286,11 @@ func TestNemotron3NanoParser_Init(t *testing.T) {
}
})
t.Run("starts in content state when nil thinkValue", func(t *testing.T) {
t.Run("starts in thinking state when nil thinkValue", func(t *testing.T) {
p := &Nemotron3NanoParser{}
p.Init(nil, nil, nil)
if p.state != Nemotron3NanoCollectingContent {
t.Errorf("expected state Nemotron3NanoCollectingContent, got %v", p.state)
if p.state != Nemotron3NanoCollectingThinking {
t.Errorf("expected state Nemotron3NanoCollectingThinking, got %v", p.state)
}
})
@@ -281,6 +302,29 @@ func TestNemotron3NanoParser_Init(t *testing.T) {
t.Errorf("expected state Nemotron3NanoCollectingContent, got %v", p.state)
}
})
t.Run("reinit clears buffered state", func(t *testing.T) {
p := &Nemotron3NanoParser{}
p.Init(nil, nil, &api.ThinkValue{Value: true})
if _, _, _, err := p.Add("thinking in progress", false); err != nil {
t.Fatalf("unexpected error: %v", err)
}
p.Init(nil, nil, &api.ThinkValue{Value: false})
content, thinking, calls, err := p.Add("content only", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "content only" {
t.Fatalf("expected content after reinit, got %q", content)
}
if thinking != "" {
t.Fatalf("expected no thinking after reinit, got %q", thinking)
}
if len(calls) != 0 {
t.Fatalf("expected no tool calls after reinit, got %v", calls)
}
})
}
func TestNemotron3NanoParser_WithTools(t *testing.T) {
+2
View File
@@ -87,6 +87,8 @@ func ParserForName(name string) Parser {
return &LFM2Parser{hasThinkingSupport: false}
case "lfm2-thinking":
return &LFM2Parser{hasThinkingSupport: true}
case "laguna":
return &LagunaParser{}
default:
return nil
}
+5 -1
View File
@@ -12,7 +12,8 @@ import (
// <|turn>/<turn|> markers, <|"|> string delimiters, and <|tool>/
// <|tool_call>/<|tool_response> tags for function calling.
type Gemma4Renderer struct {
useImgTags bool
useImgTags bool
emptyBlockOnNothink bool
}
const (
@@ -124,6 +125,9 @@ func (r *Gemma4Renderer) Render(messages []api.Message, tools []api.Tool, thinkV
// Generation prompt.
if prevMessageType != "tool_response" && prevMessageType != "tool_call" {
sb.WriteString("<|turn>model\n")
if r.emptyBlockOnNothink && !hasThink {
sb.WriteString("<|channel>thought\n<channel|>")
}
}
return sb.String(), nil
+72 -11
View File
@@ -3,9 +3,9 @@ package renderers
// TestGemma4RendererMatchesReference verifies our renderer matches the checked-in
// Gemma 4 reference template.
//
// Current upstream Gemma 4 chat templates differ by model size, so the checked-in
// reference intentionally uses the shared baseline without an empty generation-time
// thought channel until renderer selection is split by size.
// Current upstream Gemma 4 chat templates differ by model size. The checked-in
// reference cases below use the small (e2b/e4b-style) baseline, with large
// (26b/31b-style) checks covered separately in this file.
//
// To regenerate expected values, save the E2B template to
// gemma4_e2b_chat_template.jinja2 and run:
@@ -1474,6 +1474,47 @@ Hi<turn|>
}
}
func TestGemma4RendererVariantsMatchExpectedGenerationPrompt(t *testing.T) {
messages := []api.Message{{Role: "user", Content: "Hello"}}
tests := []struct {
name string
rendererName string
expected string
}{
{
name: "legacy_alias",
rendererName: "gemma4",
expected: "<bos><|turn>user\nHello<turn|>\n<|turn>model\n",
},
{
name: "small",
rendererName: "gemma4-small",
expected: "<bos><|turn>user\nHello<turn|>\n<|turn>model\n",
},
{
name: "large",
rendererName: "gemma4-large",
expected: "<bos><|turn>user\nHello<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := RenderWithRenderer(tt.rendererName, messages, nil, nil)
assert.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
}
func TestGemma4LargeRendererOmitsEmptyThoughtBlockWhenThinkingEnabled(t *testing.T) {
got, err := RenderWithRenderer("gemma4-large", []api.Message{{Role: "user", Content: "Hello"}}, nil, thinkTrue())
assert.NoError(t, err)
assert.Equal(t, "<bos><|turn>system\n<|think|>\n<turn|>\n<|turn>user\nHello<turn|>\n<|turn>model\n", got)
assert.NotContains(t, got, "<|channel>thought\n<channel|>")
}
func TestGemma4RendererMatchesJinja2ExpandedParity(t *testing.T) {
if os.Getenv("VERIFY_JINJA2") == "" {
t.Skip("set VERIFY_JINJA2=1 to run expanded Jinja2 parity checks")
@@ -1616,15 +1657,35 @@ func TestGemma4RendererMatchesJinja2ExpandedParity(t *testing.T) {
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
renderer := &Gemma4Renderer{useImgTags: RenderImgTags}
got, err := renderer.Render(tt.messages, tt.tools, tt.think)
assert.NoError(t, err)
variants := []struct {
name string
renderer *Gemma4Renderer
templateRel string
}{
{
name: "small",
renderer: &Gemma4Renderer{useImgTags: RenderImgTags},
templateRel: gemma4E2BTemplate,
},
{
name: "large",
renderer: &Gemma4Renderer{useImgTags: RenderImgTags, emptyBlockOnNothink: true},
templateRel: gemma431BTemplate,
},
}
jinja2Output := renderWithJinja2(t, tt.messages, tt.tools, tt.think)
assert.Equal(t, jinja2Output, got,
"renderer output doesn't match Jinja2 template output")
for _, variant := range variants {
t.Run(variant.name, func(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := variant.renderer.Render(tt.messages, tt.tools, tt.think)
assert.NoError(t, err)
jinja2Output := renderWithJinja2Template(t, variant.templateRel, tt.messages, tt.tools, tt.think)
assert.Equal(t, jinja2Output, got,
"renderer output doesn't match Jinja2 template output")
})
}
})
}
}
+111
View File
@@ -0,0 +1,111 @@
package renderers
import (
"strings"
"github.com/ollama/ollama/api"
)
const (
lagunaBOS = "〈|EOS|〉"
lagunaThoughtOpen = "<think>"
lagunaThoughtClose = "</think>"
)
type LagunaRenderer struct{}
func (r *LagunaRenderer) Render(messages []api.Message, tools []api.Tool, think *api.ThinkValue) (string, error) {
var sb strings.Builder
sb.WriteString(lagunaBOS)
thinkingEnabled := think == nil || think.Bool()
systemMessage := ""
firstMessageIsSystem := len(messages) > 0 && messages[0].Role == "system"
if firstMessageIsSystem {
systemMessage = strings.TrimRight(messages[0].Content, "\n")
}
sb.WriteString("<system>\n")
if thinkingEnabled {
sb.WriteString("You should use chain-of-thought reasoning. Put your reasoning inside <think> </think> tags before your response.")
} else {
sb.WriteString("You should respond directly without using chain-of-thought reasoning tags.")
}
if strings.TrimSpace(systemMessage) != "" {
sb.WriteByte('\n')
sb.WriteString(systemMessage)
}
if len(tools) > 0 {
sb.WriteString("\n\n### Tools\n\n")
sb.WriteString("You may call functions to assist with the user query.\n")
sb.WriteString("All available function signatures are listed below:\n")
sb.WriteString("<available_tools>\n")
for _, tool := range tools {
if b, err := marshalWithSpaces(tool); err == nil {
sb.Write(b)
sb.WriteByte('\n')
}
}
sb.WriteString("</available_tools>\n\n")
sb.WriteString("For each function call, return a json object with function name and arguments within '<tool_call>' and '</tool_call>' tags:\n")
sb.WriteString("<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call>")
}
sb.WriteString("\n</system>\n")
for i, message := range messages {
if i == 0 && firstMessageIsSystem {
continue
}
content := message.Content
switch message.Role {
case "user":
sb.WriteString("<user>\n")
sb.WriteString(content)
sb.WriteString("\n</user>\n")
case "assistant":
lastMessage := i == len(messages)-1
prefill := lastMessage && (content != "" || message.Thinking != "" || len(message.ToolCalls) > 0)
sb.WriteString("<assistant>\n")
if thinkingEnabled && message.Thinking != "" {
sb.WriteString(lagunaThoughtOpen)
sb.WriteString(message.Thinking)
sb.WriteString(lagunaThoughtClose)
sb.WriteByte('\n')
}
if strings.Trim(content, "\n") != "" {
sb.WriteString(strings.Trim(content, "\n"))
sb.WriteByte('\n')
}
for _, toolCall := range message.ToolCalls {
sb.WriteString("<tool_call>")
sb.WriteString(toolCall.Function.Name)
sb.WriteByte('\n')
for name, value := range toolCall.Function.Arguments.All() {
sb.WriteString("<arg_key>")
sb.WriteString(name)
sb.WriteString("</arg_key>\n")
sb.WriteString("<arg_value>")
sb.WriteString(formatToolCallArgument(value))
sb.WriteString("</arg_value>\n")
}
sb.WriteString("</tool_call>\n")
}
if !prefill {
sb.WriteString("</assistant>\n")
}
case "tool":
sb.WriteString("<tool_response>\n")
sb.WriteString(content)
sb.WriteString("\n</tool_response>\n")
case "system":
sb.WriteString("<system>\n")
sb.WriteString(content)
sb.WriteString("\n</system>\n")
}
}
if len(messages) == 0 || messages[len(messages)-1].Role != "assistant" {
sb.WriteString("<assistant>\n")
}
return sb.String(), nil
}
+339
View File
@@ -0,0 +1,339 @@
package renderers
import (
"encoding/json"
"os"
"os/exec"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
)
const (
lagunaDirectDirective = "You should respond directly without using chain-of-thought reasoning tags."
lagunaThinkDirective = "You should use chain-of-thought reasoning. Put your reasoning inside <think> </think> tags before your response."
)
func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
weather := lagunaWeatherTool()
tests := []struct {
name string
messages []api.Message
tools []api.Tool
think *api.ThinkValue
want string
}{
{
name: "user_only_thinking_default_on",
messages: []api.Message{{Role: "user", Content: "Hello"}},
want: "" +
"〈|EOS|〉<system>\n" +
lagunaThinkDirective +
"\n</system>\n" +
"<user>\nHello\n</user>\n" +
"<assistant>\n",
},
{
name: "user_only_thinking_enabled",
messages: []api.Message{{Role: "user", Content: "Hello"}},
think: &api.ThinkValue{Value: true},
want: "" +
"〈|EOS|〉<system>\n" +
lagunaThinkDirective +
"\n</system>\n" +
"<user>\nHello\n</user>\n" +
"<assistant>\n",
},
{
name: "user_only_thinking_disabled",
messages: []api.Message{{Role: "user", Content: "Hello"}},
think: &api.ThinkValue{Value: false},
want: "" +
"〈|EOS|〉<system>\n" +
lagunaDirectDirective +
"\n</system>\n" +
"<user>\nHello\n</user>\n" +
"<assistant>\n",
},
{
name: "first_system_is_header",
messages: []api.Message{
{Role: "system", Content: "Stay concise.\n\n"},
{Role: "user", Content: "Hi"},
},
want: "" +
"〈|EOS|〉<system>\n" +
lagunaThinkDirective +
"\nStay concise." +
"\n</system>\n" +
"<user>\nHi\n</user>\n" +
"<assistant>\n",
},
{
name: "additional_system_message_renders_in_loop",
messages: []api.Message{
{Role: "system", Content: "Primary."},
{Role: "user", Content: "Hi"},
{Role: "system", Content: "Secondary."},
},
want: "" +
"〈|EOS|〉<system>\n" +
lagunaThinkDirective +
"\nPrimary." +
"\n</system>\n" +
"<user>\nHi\n</user>\n" +
"<system>\nSecondary.\n</system>\n" +
"<assistant>\n",
},
{
name: "tools_in_header",
messages: []api.Message{
{Role: "system", Content: "Stay concise."},
{Role: "user", Content: "Weather?"},
},
tools: weather,
think: &api.ThinkValue{Value: true},
want: "" +
"〈|EOS|〉<system>\n" +
lagunaThinkDirective +
"\nStay concise." +
"\n\n### Tools\n\n" +
"You may call functions to assist with the user query.\n" +
"All available function signatures are listed below:\n" +
"<available_tools>\n" +
`{"type": "function", "function": {"name": "get_weather", "description": "Get weather", "parameters": {"type": "object", "required": ["location"], "properties": {"location": {"type": "string", "description": "City"}}}}}` + "\n" +
"</available_tools>\n\n" +
"For each function call, return a json object with function name and arguments within '<tool_call>' and '</tool_call>' tags:\n" +
"<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call>" +
"\n</system>\n" +
"<user>\nWeather?\n</user>\n" +
"<assistant>\n",
},
{
name: "tools_default_thinking_on_when_unspecified",
messages: []api.Message{
{Role: "user", Content: "Weather?"},
},
tools: weather,
want: "" +
"〈|EOS|〉<system>\n" +
lagunaThinkDirective +
"\n\n### Tools\n\n" +
"You may call functions to assist with the user query.\n" +
"All available function signatures are listed below:\n" +
"<available_tools>\n" +
`{"type": "function", "function": {"name": "get_weather", "description": "Get weather", "parameters": {"type": "object", "required": ["location"], "properties": {"location": {"type": "string", "description": "City"}}}}}` + "\n" +
"</available_tools>\n\n" +
"For each function call, return a json object with function name and arguments within '<tool_call>' and '</tool_call>' tags:\n" +
"<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call>" +
"\n</system>\n" +
"<user>\nWeather?\n</user>\n" +
"<assistant>\n",
},
{
name: "assistant_history_with_thinking_content_tool_and_response",
messages: []api.Message{
{Role: "user", Content: "Add these."},
{
Role: "assistant",
Content: "\nCalling the tool.\n",
Thinking: "Need addition.",
ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{
Name: "add",
Arguments: testArgsOrdered([]orderedArg{
{Key: "a", Value: 2},
{Key: "b", Value: 3},
}),
},
}},
},
{Role: "tool", Content: "5"},
{Role: "user", Content: "Thanks"},
},
think: &api.ThinkValue{Value: true},
want: "" +
"〈|EOS|〉<system>\n" +
lagunaThinkDirective +
"\n</system>\n" +
"<user>\nAdd these.\n</user>\n" +
"<assistant>\n" +
"<think>Need addition.</think>\n" +
"Calling the tool.\n" +
"<tool_call>add\n" +
"<arg_key>a</arg_key>\n<arg_value>2</arg_value>\n" +
"<arg_key>b</arg_key>\n<arg_value>3</arg_value>\n" +
"</tool_call>\n" +
"</assistant>\n" +
"<tool_response>\n5\n</tool_response>\n" +
"<user>\nThanks\n</user>\n" +
"<assistant>\n",
},
{
name: "final_assistant_prefill_is_continued",
messages: []api.Message{
{Role: "user", Content: "Complete this"},
{Role: "assistant", Content: "Partial"},
},
want: "" +
"〈|EOS|〉<system>\n" +
lagunaThinkDirective +
"\n</system>\n" +
"<user>\nComplete this\n</user>\n" +
"<assistant>\nPartial\n",
},
}
renderer := &LagunaRenderer{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := renderer.Render(tt.messages, tt.tools, tt.think)
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Fatalf("renderer output mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestLagunaRendererMatchesLocalJinjaControlFlow(t *testing.T) {
if os.Getenv("VERIFY_LAGUNA_JINJA2") == "" {
t.Skip("set VERIFY_LAGUNA_JINJA2=1 to compare against the local Laguna chat_template.jinja")
}
python := "/Users/daniel/.codex/worktrees/7038/ollama/.venv/bin/python3"
if _, err := os.Stat(python); err != nil {
t.Fatalf("VERIFY_LAGUNA_JINJA2 requires %s with jinja2 installed", python)
}
tests := []struct {
name string
messages []api.Message
think *api.ThinkValue
}{
{
name: "user_only",
messages: []api.Message{{Role: "user", Content: "Hello"}},
},
{
name: "system_user",
messages: []api.Message{
{Role: "system", Content: "Stay concise.\n"},
{Role: "user", Content: "Hello"},
},
},
{
name: "additional_system_and_tool_response",
messages: []api.Message{
{Role: "system", Content: "Primary."},
{Role: "user", Content: "Weather?"},
{Role: "assistant", Content: "Calling."},
{Role: "tool", Content: "Sunny"},
{Role: "system", Content: "Secondary."},
},
},
{
name: "thinking_enabled",
messages: []api.Message{{Role: "user", Content: "Think briefly."}},
think: &api.ThinkValue{Value: true},
},
{
name: "thinking_disabled",
messages: []api.Message{{Role: "user", Content: "Answer directly."}},
think: &api.ThinkValue{Value: false},
},
}
renderer := &LagunaRenderer{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := renderer.Render(tt.messages, nil, tt.think)
if err != nil {
t.Fatal(err)
}
for _, modelDir := range []string{
"/Users/daniel/Models/poolside/laguna-xs-23-04-2026",
} {
want := renderLagunaChatTemplate(t, python, modelDir, tt.messages, tt.think)
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("%s mismatch (-chat_template +renderer):\n%s", modelDir, diff)
}
}
})
}
}
func renderLagunaChatTemplate(t *testing.T, python, modelDir string, messages []api.Message, think *api.ThinkValue) string {
t.Helper()
type templateMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
templateMessages := make([]templateMessage, 0, len(messages))
for _, msg := range messages {
templateMessages = append(templateMessages, templateMessage{
Role: msg.Role,
Content: msg.Content,
})
}
messagesJSON, err := json.Marshal(templateMessages)
if err != nil {
t.Fatalf("failed to marshal messages: %v", err)
}
enableThinking := "True"
if think != nil && !think.Bool() {
enableThinking = "False"
}
script := `
import json
import sys
from transformers import AutoTokenizer
model_dir = sys.argv[1]
messages = json.loads(sys.argv[2])
enable_thinking = sys.argv[3] == "True"
tok = AutoTokenizer.from_pretrained(model_dir, trust_remote_code=True)
print(tok.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=enable_thinking,
), end="")
`
cmd := exec.Command(python, "-c", script, modelDir, string(messagesJSON), enableThinking)
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
t.Fatalf("chat_template render failed: %v\nstderr: %s", err, stderr.String())
}
return stdout.String()
}
func lagunaWeatherTool() []api.Tool {
return []api.Tool{{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Description: "Get weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Required: []string{"location"},
Properties: testPropsOrdered([]orderedProp{{
Key: "location",
Value: api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "City",
},
}}),
},
},
}}
}
+263 -34
View File
@@ -3,6 +3,9 @@ package renderers
import (
"encoding/json"
"fmt"
"reflect"
"sort"
"strconv"
"strings"
"github.com/ollama/ollama/api"
@@ -12,15 +15,15 @@ type Nemotron3NanoRenderer struct{}
func (r *Nemotron3NanoRenderer) Render(messages []api.Message, tools []api.Tool, thinkValue *api.ThinkValue) (string, error) {
var sb strings.Builder
imageOffset := 0
// thinking is enabled if user requests it
enableThinking := thinkValue != nil && thinkValue.Bool()
enableThinking := r.resolveThinking(messages, thinkValue)
// Extract system message if present
var systemMessage string
var loopMessages []api.Message
if len(messages) > 0 && messages[0].Role == "system" {
systemMessage = messages[0].Content
systemMessage = r.sanitizeSystemMessage(messages[0].Content)
loopMessages = messages[1:]
} else {
loopMessages = messages
@@ -34,6 +37,7 @@ func (r *Nemotron3NanoRenderer) Render(messages []api.Message, tools []api.Tool,
}
}
sb.WriteString("\n\n\n")
sb.WriteString("<|im_start|>system\n")
if systemMessage != "" {
sb.WriteString(systemMessage)
@@ -45,28 +49,30 @@ func (r *Nemotron3NanoRenderer) Render(messages []api.Message, tools []api.Tool,
}
sb.WriteString(r.renderTools(tools))
}
sb.WriteString("<|im_end|>\n")
sb.WriteString("<|im_end|>\n\n")
for i, message := range loopMessages {
switch message.Role {
case "assistant":
// Build content with thinking tags
content := r.buildContent(message)
shouldTruncate := i < lastUserIdx
if len(message.ToolCalls) > 0 {
sb.WriteString("<|im_start|>assistant\n")
sb.WriteString(r.formatContent(content, shouldTruncate, true))
sb.WriteString(r.formatToolCallContent(content, shouldTruncate))
r.writeToolCalls(&sb, message.ToolCalls)
sb.WriteString("<|im_end|>\n")
} else {
formatted := r.formatContent(content, shouldTruncate, false)
sb.WriteString("<|im_start|>assistant\n" + formatted + "<|im_end|>\n")
formatted := r.formatAssistantContent(content, shouldTruncate)
sb.WriteString("<|im_start|>assistant\n")
sb.WriteString(formatted)
sb.WriteString("<|im_end|>\n")
}
case "user", "system":
sb.WriteString("<|im_start|>" + message.Role + "\n")
sb.WriteString(message.Content)
sb.WriteString(r.renderMessageContent(message, imageOffset))
imageOffset += len(message.Images)
sb.WriteString("<|im_end|>\n")
case "tool":
@@ -90,6 +96,8 @@ func (r *Nemotron3NanoRenderer) Render(messages []api.Message, tools []api.Tool,
}
}
sb.WriteString("\n")
// Add generation prompt
if enableThinking {
sb.WriteString("<|im_start|>assistant\n<think>\n")
@@ -119,7 +127,7 @@ func (r *Nemotron3NanoRenderer) renderTools(tools []api.Tool) string {
sb.WriteString("\n<name>" + paramName + "</name>")
if len(paramFields.Type) > 0 {
sb.WriteString("\n<type>" + strings.Join(paramFields.Type, ", ") + "</type>")
sb.WriteString("\n<type>" + r.formatPropertyType(paramFields.Type) + "</type>")
}
if paramFields.Description != "" {
@@ -127,17 +135,17 @@ func (r *Nemotron3NanoRenderer) renderTools(tools []api.Tool) string {
}
if len(paramFields.Enum) > 0 {
enumJSON, _ := json.Marshal(paramFields.Enum)
sb.WriteString("\n<enum>" + string(enumJSON) + "</enum>")
sb.WriteString("\n<enum>" + r.pythonJSON(paramFields.Enum) + "</enum>")
}
r.renderToolPropertyExtraKeys(&sb, paramFields)
sb.WriteString("\n</parameter>")
}
}
r.renderToolParameterExtraKeys(&sb, fn.Parameters)
if len(fn.Parameters.Required) > 0 {
reqJSON, _ := json.Marshal(fn.Parameters.Required)
sb.WriteString("\n<required>" + string(reqJSON) + "</required>")
sb.WriteString("\n<required>" + r.pythonJSON(fn.Parameters.Required) + "</required>")
}
sb.WriteString("\n</parameters>")
@@ -159,27 +167,38 @@ func (r *Nemotron3NanoRenderer) renderTools(tools []api.Tool) string {
}
func (r *Nemotron3NanoRenderer) buildContent(message api.Message) string {
// The parser always extracts thinking into the Thinking field,
// so Content will never have <think> tags embedded
content := nemotron3NanoRenderContent(message.Content)
if message.Thinking != "" {
return "<think>\n" + message.Thinking + "\n</think>\n" + message.Content
return "<think>\n" + message.Thinking + "\n</think>\n" + content
}
return "<think></think>" + message.Content
if !strings.Contains(content, "<think>") && !strings.Contains(content, "</think>") {
return "<think></think>" + content
}
return content
}
func (r *Nemotron3NanoRenderer) formatContent(content string, truncate bool, addNewline bool) string {
if content == "" {
func (r *Nemotron3NanoRenderer) formatAssistantContent(content string, truncate bool) string {
if !truncate {
return strings.TrimSpace(content)
}
c := content
if strings.Contains(c, "<think>") && strings.Contains(c, "</think>") {
parts := strings.Split(c, "</think>")
c = "<think></think>" + parts[len(parts)-1]
}
return strings.TrimSpace(c)
}
func (r *Nemotron3NanoRenderer) formatToolCallContent(content string, truncate bool) string {
if strings.TrimSpace(content) == "" {
return "<think></think>"
}
if !truncate {
if addNewline {
return strings.TrimSpace(content) + "\n"
}
return strings.TrimSpace(content)
return strings.TrimSpace(content) + "\n"
}
// Truncate thinking - keep only content after </think>
c := content
if strings.Contains(c, "</think>") {
parts := strings.Split(c, "</think>")
@@ -190,13 +209,7 @@ func (r *Nemotron3NanoRenderer) formatContent(content string, truncate bool, add
}
c = "<think></think>" + strings.TrimSpace(c)
if addNewline && len(c) > len("<think></think>") {
return c + "\n"
}
if c == "<think></think>" {
return c
}
return strings.TrimSpace(c)
return strings.TrimSpace(c) + "\n"
}
func (r *Nemotron3NanoRenderer) writeToolCalls(sb *strings.Builder, toolCalls []api.ToolCall) {
@@ -212,9 +225,225 @@ func (r *Nemotron3NanoRenderer) writeToolCalls(sb *strings.Builder, toolCalls []
func (r *Nemotron3NanoRenderer) formatArgValue(value any) string {
switch v := value.(type) {
case map[string]any, []any:
jsonBytes, _ := json.Marshal(v)
return string(jsonBytes)
return r.pythonJSON(v)
default:
return fmt.Sprintf("%v", v)
}
}
func (r *Nemotron3NanoRenderer) renderMessageContent(message api.Message, imageOffset int) string {
content := nemotron3NanoRenderContent(message.Content)
if len(message.Images) == 0 {
return content
}
if strings.Contains(content, "[img-") {
return content
}
if strings.Contains(content, "[img]") {
for i := range message.Images {
content = strings.Replace(content, "[img]", fmt.Sprintf("[img-%d]", imageOffset+i), 1)
}
return content
}
var sb strings.Builder
for i := range message.Images {
sb.WriteString(fmt.Sprintf("[img-%d]", imageOffset+i))
}
sb.WriteString(content)
return sb.String()
}
func nemotron3NanoRenderContent(content any) string {
switch v := content.(type) {
case string:
return v
case []any:
var sb strings.Builder
for _, item := range v {
obj, ok := item.(map[string]any)
if !ok {
bts, _ := json.Marshal(item)
sb.Write(bts)
continue
}
switch obj["type"] {
case "image":
sb.WriteString("<image>")
case "text":
if text, ok := obj["text"].(string); ok {
sb.WriteString(text)
}
default:
bts, _ := json.Marshal(item)
sb.Write(bts)
}
}
return sb.String()
default:
bts, _ := json.Marshal(v)
return string(bts)
}
}
func (r *Nemotron3NanoRenderer) resolveThinking(messages []api.Message, thinkValue *api.ThinkValue) bool {
enableThinking := thinkValue == nil || thinkValue.Bool()
for _, message := range messages {
if message.Role != "user" && message.Role != "system" {
continue
}
content := message.Content
if strings.Contains(strings.ReplaceAll(content, "</think>", ""), "/think") {
enableThinking = true
} else if strings.Contains(content, "/no_think") {
enableThinking = false
}
}
return enableThinking
}
func (r *Nemotron3NanoRenderer) sanitizeSystemMessage(content string) string {
system := nemotron3NanoRenderContent(content)
system = strings.ReplaceAll(system, "</think>", "<_end_think>")
system = strings.ReplaceAll(system, "/think", "")
system = strings.ReplaceAll(system, "/no_think", "")
system = strings.ReplaceAll(system, "<_end_think>", "</think>")
return system
}
func (r *Nemotron3NanoRenderer) formatPropertyType(propertyType api.PropertyType) string {
if len(propertyType) == 1 {
return propertyType[0]
}
quoted := make([]string, 0, len(propertyType))
for _, v := range propertyType {
quoted = append(quoted, "'"+v+"'")
}
return "[" + strings.Join(quoted, ", ") + "]"
}
func (r *Nemotron3NanoRenderer) renderToolPropertyExtraKeys(sb *strings.Builder, prop api.ToolProperty) {
if len(prop.AnyOf) > 0 {
sb.WriteString("\n<anyOf>" + r.pythonJSON(prop.AnyOf) + "</anyOf>")
}
if prop.Items != nil {
sb.WriteString("\n<items>" + r.pythonJSON(prop.Items) + "</items>")
}
if prop.Properties != nil {
sb.WriteString("\n<properties>" + r.pythonJSON(prop.Properties) + "</properties>")
}
if len(prop.Required) > 0 {
sb.WriteString("\n<required>" + r.pythonJSON(prop.Required) + "</required>")
}
}
func (r *Nemotron3NanoRenderer) renderToolParameterExtraKeys(sb *strings.Builder, params api.ToolFunctionParameters) {
if params.Defs != nil {
sb.WriteString("\n<$defs>" + r.pythonJSON(params.Defs) + "</$defs>")
}
if params.Items != nil {
sb.WriteString("\n<items>" + r.pythonJSON(params.Items) + "</items>")
}
}
func (r *Nemotron3NanoRenderer) pythonJSON(v any) string {
switch value := v.(type) {
case nil:
return "null"
case string:
return strconv.Quote(value)
case bool:
if value {
return "true"
}
return "false"
case int, int8, int16, int32, int64:
return fmt.Sprintf("%d", reflect.ValueOf(value).Int())
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprintf("%d", reflect.ValueOf(value).Uint())
case float32, float64:
b, _ := json.Marshal(value)
return string(b)
case api.PropertyType:
return r.pythonJSON([]string(value))
case []string:
parts := make([]string, 0, len(value))
for _, item := range value {
parts = append(parts, r.pythonJSON(item))
}
return "[" + strings.Join(parts, ", ") + "]"
case []any:
parts := make([]string, 0, len(value))
for _, item := range value {
parts = append(parts, r.pythonJSON(item))
}
return "[" + strings.Join(parts, ", ") + "]"
case []api.ToolProperty:
parts := make([]string, 0, len(value))
for _, item := range value {
parts = append(parts, r.pythonJSON(item))
}
return "[" + strings.Join(parts, ", ") + "]"
case map[string]any:
keys := make([]string, 0, len(value))
for key := range value {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, strconv.Quote(key)+": "+r.pythonJSON(value[key]))
}
return "{" + strings.Join(parts, ", ") + "}"
case *api.ToolPropertiesMap:
if value == nil {
return "null"
}
parts := make([]string, 0, value.Len())
for key, prop := range value.All() {
parts = append(parts, strconv.Quote(key)+": "+r.pythonJSON(prop))
}
return "{" + strings.Join(parts, ", ") + "}"
case api.ToolProperty:
parts := make([]string, 0, 6)
if len(value.AnyOf) > 0 {
parts = append(parts, `"anyOf": `+r.pythonJSON(value.AnyOf))
}
if len(value.Type) > 0 {
if len(value.Type) == 1 {
parts = append(parts, `"type": `+r.pythonJSON(value.Type[0]))
} else {
parts = append(parts, `"type": `+r.pythonJSON([]string(value.Type)))
}
}
if value.Items != nil {
parts = append(parts, `"items": `+r.pythonJSON(value.Items))
}
if value.Description != "" {
parts = append(parts, `"description": `+r.pythonJSON(value.Description))
}
if len(value.Enum) > 0 {
parts = append(parts, `"enum": `+r.pythonJSON(value.Enum))
}
if value.Properties != nil {
parts = append(parts, `"properties": `+r.pythonJSON(value.Properties))
}
if len(value.Required) > 0 {
parts = append(parts, `"required": `+r.pythonJSON(value.Required))
}
return "{" + strings.Join(parts, ", ") + "}"
default:
b, err := json.Marshal(value)
if err != nil {
return "null"
}
var generic any
if err := json.Unmarshal(b, &generic); err != nil {
return string(b)
}
return r.pythonJSON(generic)
}
}
@@ -0,0 +1,614 @@
package renderers
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
)
const nemotron3NanoTemplate = "testdata/nemotron3nano_chat_template.jinja2"
func TestNemotron3NanoRendererMatchesReference(t *testing.T) {
toolText := `<|im_start|>system
# Tools
You have access to the following functions:
<tools>
<function>
<name>search_docs</name>
<description>Search docs</description>
<parameters>
<parameter>
<name>query</name>
<type>string</type>
<description>Search query</description>
<enum>["api", "cli"]</enum>
</parameter>
<parameter>
<name>mode</name>
<type>['string', 'null']</type>
<description>Mode</description>
<anyOf>[{"type": "string"}, {"type": "number"}]</anyOf>
</parameter>
<parameter>
<name>payload</name>
<type>object</type>
<description>Payload</description>
<properties>{"enabled": {"type": "boolean"}}</properties>
<required>["enabled"]</required>
</parameter>
<parameter>
<name>tags</name>
<type>array</type>
<description>Tags</description>
<items>{"type": "string"}</items>
</parameter>
<$defs>{"shared": {"type": "string"}}</$defs>
<required>["query"]</required>
</parameters>
</function>
</tools>
If you choose to call a function ONLY reply in the following format with NO suffix:
<tool_call>
<function=example_function_name>
<parameter=example_parameter_1>
value_1
</parameter>
<parameter=example_parameter_2>
This is the value for the second parameter
that can span
multiple lines
</parameter>
</function>
</tool_call>
<IMPORTANT>
Reminder:
- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags
- Required parameters MUST be specified
- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after
- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls
</IMPORTANT><|im_end|>
`
toolTextWithSystem := `<|im_start|>system
Follow policy.
# Tools
You have access to the following functions:
<tools>
<function>
<name>search_docs</name>
<description>Search docs</description>
<parameters>
<parameter>
<name>query</name>
<type>string</type>
<description>Search query</description>
<enum>["api", "cli"]</enum>
</parameter>
<parameter>
<name>mode</name>
<type>['string', 'null']</type>
<description>Mode</description>
<anyOf>[{"type": "string"}, {"type": "number"}]</anyOf>
</parameter>
<parameter>
<name>payload</name>
<type>object</type>
<description>Payload</description>
<properties>{"enabled": {"type": "boolean"}}</properties>
<required>["enabled"]</required>
</parameter>
<parameter>
<name>tags</name>
<type>array</type>
<description>Tags</description>
<items>{"type": "string"}</items>
</parameter>
<$defs>{"shared": {"type": "string"}}</$defs>
<required>["query"]</required>
</parameters>
</function>
</tools>
If you choose to call a function ONLY reply in the following format with NO suffix:
<tool_call>
<function=example_function_name>
<parameter=example_parameter_1>
value_1
</parameter>
<parameter=example_parameter_2>
This is the value for the second parameter
that can span
multiple lines
</parameter>
</function>
</tool_call>
<IMPORTANT>
Reminder:
- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags
- Required parameters MUST be specified
- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after
- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls
</IMPORTANT><|im_end|>
`
tests := []struct {
name string
messages []api.Message
tools []api.Tool
think *api.ThinkValue
expected string
}{
{
name: "no system default thinking on",
messages: []api.Message{
{Role: "user", Content: "Hello"},
},
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHello<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "no system explicit thinking off",
messages: []api.Message{
{Role: "user", Content: "Hello"},
},
think: thinkFalse(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHello<|im_end|>\n\n<|im_start|>assistant\n<think></think>",
},
{
name: "literal endthink does not enable thinking",
messages: []api.Message{
{Role: "user", Content: "literal </think> only"},
},
think: thinkFalse(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nliteral </think> only<|im_end|>\n\n<|im_start|>assistant\n<think></think>",
},
{
name: "user no think toggle",
messages: []api.Message{
{Role: "user", Content: "Hello /no_think"},
},
think: thinkTrue(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHello /no_think<|im_end|>\n\n<|im_start|>assistant\n<think></think>",
},
{
name: "system think toggle overrides false",
messages: []api.Message{
{Role: "system", Content: "Policy /think"},
{Role: "user", Content: "Hello"},
},
think: thinkFalse(),
expected: "\n\n\n<|im_start|>system\nPolicy <|im_end|>\n\n<|im_start|>user\nHello<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "later toggle wins",
messages: []api.Message{
{Role: "system", Content: "Policy /no_think"},
{Role: "user", Content: "Actually /think"},
},
think: thinkFalse(),
expected: "\n\n\n<|im_start|>system\nPolicy <|im_end|>\n\n<|im_start|>user\nActually /think<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "system sanitizes toggles but preserves closing tag",
messages: []api.Message{
{Role: "system", Content: "A /think B /no_think C </think>"},
{Role: "user", Content: "Hello"},
},
think: thinkFalse(),
expected: "\n\n\n<|im_start|>system\nA B C </think><|im_end|>\n\n<|im_start|>user\nHello<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "assistant plain content adds empty think block",
messages: []api.Message{
{Role: "user", Content: "Hi"},
{Role: "assistant", Content: "Hello there"},
},
think: thinkTrue(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think></think>Hello there<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "assistant reasoning content",
messages: []api.Message{
{Role: "user", Content: "Hi"},
{Role: "assistant", Content: "Answer", Thinking: "Need to think"},
},
think: thinkTrue(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>\nNeed to think\n</think>\nAnswer<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "assistant preserves existing think tags",
messages: []api.Message{
{Role: "user", Content: "Hi"},
{Role: "assistant", Content: "<think>kept</think>Answer"},
},
think: thinkTrue(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think>kept</think>Answer<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "tools without system",
messages: []api.Message{
{Role: "user", Content: "Use a tool"},
},
tools: nemotron3NanoReferenceTools(),
think: thinkTrue(),
expected: "\n\n\n" + toolText + "\n<|im_start|>user\nUse a tool<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "system with tools",
messages: []api.Message{
{Role: "system", Content: "Follow policy."},
{Role: "user", Content: "Use a tool"},
},
tools: nemotron3NanoReferenceTools(),
think: thinkTrue(),
expected: "\n\n\n" + toolTextWithSystem + "\n<|im_start|>user\nUse a tool<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "assistant tool call with content",
messages: []api.Message{
{Role: "user", Content: "Weather?"},
{
Role: "assistant",
Content: "Checking now.",
ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
}},
},
},
think: thinkTrue(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nWeather?<|im_end|>\n<|im_start|>assistant\n<think></think>Checking now.\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "assistant tool call with structured arguments",
messages: []api.Message{
{Role: "user", Content: "Create data"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{
Name: "create",
Arguments: testArgsOrdered([]orderedArg{
{Key: "payload", Value: map[string]any{"count": 42, "nested": map[string]any{"value": "ok"}}},
{Key: "tags", Value: []any{"a", "b"}},
}),
},
}},
},
},
think: thinkTrue(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nCreate data<|im_end|>\n<|im_start|>assistant\n<think></think>\n<tool_call>\n<function=create>\n<parameter=payload>\n{\"count\": 42, \"nested\": {\"value\": \"ok\"}}\n</parameter>\n<parameter=tags>\n[\"a\", \"b\"]\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "assistant tool call truncated with reasoning",
messages: []api.Message{
{Role: "user", Content: "Weather?"},
{
Role: "assistant",
Content: "Checking now.",
Thinking: "Need weather",
ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
}},
},
{Role: "user", Content: "And tomorrow?"},
},
think: thinkTrue(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nWeather?<|im_end|>\n<|im_start|>assistant\n<think></think>Checking now.\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n<|im_start|>user\nAnd tomorrow?<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "assistant tool call truncated open think only",
messages: []api.Message{
{Role: "user", Content: "Weather?"},
{
Role: "assistant",
Content: "<think>draft",
ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
}},
},
{Role: "user", Content: "And tomorrow?"},
},
think: thinkTrue(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nWeather?<|im_end|>\n<|im_start|>assistant\n<think></think>\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n<|im_start|>user\nAnd tomorrow?<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "assistant tool call empty content",
messages: []api.Message{
{Role: "user", Content: "Weather?"},
{
Role: "assistant",
Content: "",
ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
}},
},
},
think: thinkTrue(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nWeather?<|im_end|>\n<|im_start|>assistant\n<think></think>\n<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "assistant truncated with think pair",
messages: []api.Message{
{Role: "user", Content: "Hi"},
{Role: "assistant", Content: "<think>hidden</think>Visible"},
{Role: "user", Content: "Next"},
},
think: thinkTrue(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think></think>Visible<|im_end|>\n<|im_start|>user\nNext<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "assistant truncated reasoning content",
messages: []api.Message{
{Role: "user", Content: "Hi"},
{Role: "assistant", Thinking: "hidden", Content: "Visible"},
{Role: "user", Content: "Next"},
},
think: thinkTrue(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think></think>\nVisible<|im_end|>\n<|im_start|>user\nNext<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "assistant truncated plain content",
messages: []api.Message{
{Role: "user", Content: "Hi"},
{Role: "assistant", Content: "Visible"},
{Role: "user", Content: "Next"},
},
think: thinkTrue(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think></think>Visible<|im_end|>\n<|im_start|>user\nNext<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "assistant truncated empty content",
messages: []api.Message{
{Role: "user", Content: "Hi"},
{Role: "assistant", Content: ""},
{Role: "user", Content: "Next"},
},
think: thinkTrue(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n<think></think><|im_end|>\n<|im_start|>user\nNext<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "consecutive tool messages grouped",
messages: []api.Message{
{Role: "user", Content: "Do work"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{
Name: "step",
Arguments: testArgs(map[string]any{"value": 1}),
},
}},
},
{Role: "tool", Content: "one"},
{Role: "tool", Content: "two"},
},
think: thinkTrue(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\nDo work<|im_end|>\n<|im_start|>assistant\n<think></think>\n<tool_call>\n<function=step>\n<parameter=value>\n1\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n<|im_start|>user\n<tool_response>\none\n</tool_response>\n<tool_response>\ntwo\n</tool_response>\n<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "fallback role",
messages: []api.Message{
{Role: "developer", Content: "Custom role content"},
},
think: thinkTrue(),
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>developer\nCustom role content<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
}
verifyJinja2 := os.Getenv("VERIFY_JINJA2") != ""
if verifyJinja2 {
if _, err := os.Stat(filepath.Join(nemotron3NanoRepoRoot(t), ".venv", "bin", "python3")); err != nil {
t.Fatal("VERIFY_JINJA2=1 requires .venv/bin/python3")
}
}
renderer := &Nemotron3NanoRenderer{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := renderer.Render(tt.messages, tt.tools, tt.think)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
if diff := cmp.Diff(tt.expected, got); diff != "" {
t.Fatalf("renderer mismatch (-want +got):\n%s", diff)
}
if verifyJinja2 {
jinja2Output := renderNemotron3NanoWithJinja2(t, tt.messages, tt.tools, tt.think)
if diff := cmp.Diff(tt.expected, jinja2Output); diff != "" {
t.Fatalf("reference template mismatch (-want +got):\n%s", diff)
}
}
})
}
}
func nemotron3NanoReferenceTools() []api.Tool {
return []api.Tool{{
Type: "function",
Function: api.ToolFunction{
Name: "search_docs",
Description: "Search docs",
Parameters: api.ToolFunctionParameters{
Type: "object",
Defs: map[string]any{"shared": map[string]any{"type": "string"}},
Required: []string{"query"},
Properties: testPropsOrdered([]orderedProp{
{
Key: "query",
Value: api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "Search query",
Enum: []any{"api", "cli"},
},
},
{
Key: "mode",
Value: api.ToolProperty{
Type: api.PropertyType{"string", "null"},
Description: "Mode",
AnyOf: []api.ToolProperty{
{Type: api.PropertyType{"string"}},
{Type: api.PropertyType{"number"}},
},
},
},
{
Key: "payload",
Value: api.ToolProperty{
Type: api.PropertyType{"object"},
Description: "Payload",
Properties: testPropsOrdered([]orderedProp{{Key: "enabled", Value: api.ToolProperty{Type: api.PropertyType{"boolean"}}}}),
Required: []string{"enabled"},
},
},
{
Key: "tags",
Value: api.ToolProperty{
Type: api.PropertyType{"array"},
Description: "Tags",
Items: map[string]any{"type": "string"},
},
},
}),
},
},
}}
}
func renderNemotron3NanoWithJinja2(t *testing.T, messages []api.Message, tools []api.Tool, think *api.ThinkValue) string {
t.Helper()
type jinja2ToolCall struct {
ID string `json:"id,omitempty"`
Function struct {
Name string `json:"name"`
Arguments any `json:"arguments"`
} `json:"function"`
}
type jinja2Message struct {
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []jinja2ToolCall `json:"tool_calls,omitempty"`
Name string `json:"name,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
var jMsgs []jinja2Message
for _, m := range messages {
jm := jinja2Message{
Role: m.Role,
Content: m.Content,
ReasoningContent: m.Thinking,
Name: m.ToolName,
ToolCallID: m.ToolCallID,
}
for _, tc := range m.ToolCalls {
jtc := jinja2ToolCall{ID: tc.ID}
jtc.Function.Name = tc.Function.Name
var args map[string]any
raw, _ := tc.Function.Arguments.MarshalJSON()
if err := json.Unmarshal(raw, &args); err != nil {
t.Fatalf("failed to unmarshal tool args: %v", err)
}
jtc.Function.Arguments = args
jm.ToolCalls = append(jm.ToolCalls, jtc)
}
jMsgs = append(jMsgs, jm)
}
msgsJSON, err := json.Marshal(jMsgs)
if err != nil {
t.Fatalf("failed to marshal messages: %v", err)
}
toolsJSON := "None"
if len(tools) > 0 {
b, err := json.Marshal(tools)
if err != nil {
t.Fatalf("failed to marshal tools: %v", err)
}
toolsJSON = string(b)
}
thinking := "unset"
if think != nil {
if think.Bool() {
thinking = "true"
} else {
thinking = "false"
}
}
repoRoot := nemotron3NanoRepoRoot(t)
templatePath := filepath.Join(repoRoot, "model", "renderers", nemotron3NanoTemplate)
pythonPath := filepath.Join(repoRoot, ".venv", "bin", "python3")
script := `
import json
import sys
from pathlib import Path
from transformers.utils.chat_template_utils import _compile_jinja_template
template_path, messages_json, tools_json, thinking = sys.argv[1:5]
tmpl = _compile_jinja_template(Path(template_path).read_text())
kwargs = {
"messages": json.loads(messages_json),
"add_generation_prompt": True,
}
if tools_json != "None":
kwargs["tools"] = json.loads(tools_json)
if thinking == "true":
kwargs["enable_thinking"] = True
elif thinking == "false":
kwargs["enable_thinking"] = False
print(tmpl.render(**kwargs), end="")
`
cmd := exec.Command(pythonPath, "-c", script, templatePath, string(msgsJSON), toolsJSON, thinking)
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
t.Fatalf("python render failed: %v\nstderr: %s", err, stderr.String())
}
return stdout.String()
}
func nemotron3NanoRepoRoot(t *testing.T) string {
t.Helper()
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("failed to locate test file")
}
return filepath.Dir(filepath.Dir(filepath.Dir(filename)))
}
+18 -533
View File
@@ -8,561 +8,46 @@ import (
"github.com/ollama/ollama/api"
)
func TestNemotron3NanoRenderer(t *testing.T) {
func TestNemotron3NanoRenderer_Images(t *testing.T) {
tests := []struct {
name string
msgs []api.Message
tools []api.Tool
thinkValue *api.ThinkValue
expected string
name string
msgs []api.Message
expected string
}{
{
name: "basic user message - thinking mode",
name: "single image inserts placeholder",
msgs: []api.Message{
{Role: "user", Content: "Hello!"},
{Role: "user", Content: "Describe this image.", Images: []api.ImageData{api.ImageData("img1")}},
},
thinkValue: &api.ThinkValue{Value: true},
expected: "<|im_start|>system\n<|im_end|>\n" +
"<|im_start|>user\nHello!<|im_end|>\n" +
"<|im_start|>assistant\n<think>\n",
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\n[img-0]Describe this image.<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "basic user message - no thinking",
name: "generic image placeholder is rewritten",
msgs: []api.Message{
{Role: "user", Content: "Hello!"},
{Role: "user", Content: "[img]Describe this image.", Images: []api.ImageData{api.ImageData("img1")}},
},
thinkValue: nil,
expected: "<|im_start|>system\n<|im_end|>\n" +
"<|im_start|>user\nHello!<|im_end|>\n" +
"<|im_start|>assistant\n<think></think>",
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\n[img-0]Describe this image.<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
{
name: "with system message",
name: "image offsets increment across messages",
msgs: []api.Message{
{Role: "system", Content: "You are a helpful assistant."},
{Role: "user", Content: "Hello!"},
{Role: "user", Content: "Describe the first image.", Images: []api.ImageData{api.ImageData("img1")}},
{Role: "assistant", Content: "It shows something."},
{Role: "user", Content: "Compare these.", Images: []api.ImageData{api.ImageData("img2"), api.ImageData("img3")}},
},
thinkValue: &api.ThinkValue{Value: true},
expected: "<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n" +
"<|im_start|>user\nHello!<|im_end|>\n" +
"<|im_start|>assistant\n<think>\n",
},
{
name: "multi-turn conversation",
msgs: []api.Message{
{Role: "user", Content: "Hi"},
{Role: "assistant", Content: "Hello! How can I help?"},
{Role: "user", Content: "Tell me a joke"},
},
thinkValue: &api.ThinkValue{Value: true},
expected: "<|im_start|>system\n<|im_end|>\n" +
"<|im_start|>user\nHi<|im_end|>\n" +
"<|im_start|>assistant\n<think></think>Hello! How can I help?<|im_end|>\n" +
"<|im_start|>user\nTell me a joke<|im_end|>\n" +
"<|im_start|>assistant\n<think>\n",
},
{
name: "with tools",
msgs: []api.Message{
{Role: "user", Content: "What's the weather in Paris?"},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Description: "Get the current weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Required: []string{"city"},
Properties: testPropsMap(map[string]api.ToolProperty{
"city": {Type: api.PropertyType{"string"}, Description: "The city name"},
}),
},
},
},
},
thinkValue: &api.ThinkValue{Value: true},
expected: "<|im_start|>system\n" +
"# Tools\n\nYou have access to the following functions:\n\n<tools>\n" +
"<function>\n<name>get_weather</name>\n" +
"<description>Get the current weather</description>\n" +
"<parameters>\n" +
"<parameter>\n<name>city</name>\n<type>string</type>\n<description>The city name</description>\n</parameter>\n" +
"<required>[\"city\"]</required>\n" +
"</parameters>\n</function>\n</tools>\n\n" +
"If you choose to call a function ONLY reply in the following format with NO suffix:\n\n" +
"<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n" +
"<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n" +
"</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n" +
"- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n" +
"- Required parameters MUST be specified\n" +
"- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n" +
"- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n" +
"</IMPORTANT><|im_end|>\n" +
"<|im_start|>user\nWhat's the weather in Paris?<|im_end|>\n" +
"<|im_start|>assistant\n<think>\n",
},
{
name: "tool call with response",
msgs: []api.Message{
{Role: "user", Content: "What's the weather in Paris?"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
},
},
},
{Role: "tool", Content: "Sunny, 72F"},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Description: "Get the current weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Required: []string{"city"},
Properties: testPropsMap(map[string]api.ToolProperty{
"city": {Type: api.PropertyType{"string"}, Description: "The city name"},
}),
},
},
},
},
thinkValue: &api.ThinkValue{Value: true},
expected: "<|im_start|>system\n" +
"# Tools\n\nYou have access to the following functions:\n\n<tools>\n" +
"<function>\n<name>get_weather</name>\n" +
"<description>Get the current weather</description>\n" +
"<parameters>\n" +
"<parameter>\n<name>city</name>\n<type>string</type>\n<description>The city name</description>\n</parameter>\n" +
"<required>[\"city\"]</required>\n" +
"</parameters>\n</function>\n</tools>\n\n" +
"If you choose to call a function ONLY reply in the following format with NO suffix:\n\n" +
"<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n" +
"<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n" +
"</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n" +
"- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n" +
"- Required parameters MUST be specified\n" +
"- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n" +
"- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n" +
"</IMPORTANT><|im_end|>\n" +
"<|im_start|>user\nWhat's the weather in Paris?<|im_end|>\n" +
"<|im_start|>assistant\n<think></think>\n" +
"<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n" +
"<|im_start|>user\n<tool_response>\nSunny, 72F\n</tool_response>\n<|im_end|>\n" +
"<|im_start|>assistant\n<think>\n",
},
{
name: "assistant with content and tool call",
msgs: []api.Message{
{Role: "user", Content: "What's the weather?"},
{
Role: "assistant",
Content: "Let me check that for you.",
ToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
},
},
},
{Role: "tool", Content: "Sunny"},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"city": {Type: api.PropertyType{"string"}},
}),
},
},
},
},
thinkValue: &api.ThinkValue{Value: true},
expected: "<|im_start|>system\n" +
"# Tools\n\nYou have access to the following functions:\n\n<tools>\n" +
"<function>\n<name>get_weather</name>\n" +
"<parameters>\n" +
"<parameter>\n<name>city</name>\n<type>string</type>\n</parameter>\n" +
"</parameters>\n</function>\n</tools>\n\n" +
"If you choose to call a function ONLY reply in the following format with NO suffix:\n\n" +
"<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n" +
"<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n" +
"</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n" +
"- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n" +
"- Required parameters MUST be specified\n" +
"- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n" +
"- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n" +
"</IMPORTANT><|im_end|>\n" +
"<|im_start|>user\nWhat's the weather?<|im_end|>\n" +
"<|im_start|>assistant\n<think></think>Let me check that for you.\n" +
"<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n" +
"<|im_start|>user\n<tool_response>\nSunny\n</tool_response>\n<|im_end|>\n" +
"<|im_start|>assistant\n<think>\n",
},
{
name: "thinking in history is truncated",
msgs: []api.Message{
{Role: "user", Content: "Hi"},
{Role: "assistant", Content: "Hello!", Thinking: "Let me think about this..."},
{Role: "user", Content: "How are you?"},
},
thinkValue: &api.ThinkValue{Value: true},
expected: "<|im_start|>system\n<|im_end|>\n" +
"<|im_start|>user\nHi<|im_end|>\n" +
"<|im_start|>assistant\n<think></think>Hello!<|im_end|>\n" +
"<|im_start|>user\nHow are you?<|im_end|>\n" +
"<|im_start|>assistant\n<think>\n",
},
{
name: "parallel tool calls",
msgs: []api.Message{
{Role: "user", Content: "Weather in Paris and London?"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "Paris"}),
},
},
{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgs(map[string]any{"city": "London"}),
},
},
},
},
{Role: "tool", Content: "Sunny"},
{Role: "tool", Content: "Rainy"},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"city": {Type: api.PropertyType{"string"}},
}),
},
},
},
},
thinkValue: &api.ThinkValue{Value: true},
expected: "<|im_start|>system\n" +
"# Tools\n\nYou have access to the following functions:\n\n<tools>\n" +
"<function>\n<name>get_weather</name>\n" +
"<parameters>\n" +
"<parameter>\n<name>city</name>\n<type>string</type>\n</parameter>\n" +
"</parameters>\n</function>\n</tools>\n\n" +
"If you choose to call a function ONLY reply in the following format with NO suffix:\n\n" +
"<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n" +
"<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n" +
"</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n" +
"- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n" +
"- Required parameters MUST be specified\n" +
"- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n" +
"- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n" +
"</IMPORTANT><|im_end|>\n" +
"<|im_start|>user\nWeather in Paris and London?<|im_end|>\n" +
"<|im_start|>assistant\n<think></think>\n" +
"<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>\n" +
"<tool_call>\n<function=get_weather>\n<parameter=city>\nLondon\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n" +
"<|im_start|>user\n<tool_response>\nSunny\n</tool_response>\n<tool_response>\nRainy\n</tool_response>\n<|im_end|>\n" +
"<|im_start|>assistant\n<think>\n",
},
{
name: "thinking disabled when user doesn't request it",
msgs: []api.Message{
{Role: "user", Content: "Hello!"},
},
thinkValue: nil,
expected: "<|im_start|>system\n<|im_end|>\n" +
"<|im_start|>user\nHello!<|im_end|>\n" +
"<|im_start|>assistant\n<think></think>",
},
{
name: "complex message history with thinking, tools, tool calls, tool results and content",
msgs: []api.Message{
{Role: "user", Content: "What's the weather in Paris and London? Also, what's 2+2?"},
{Role: "assistant", Content: "", Thinking: "I need to check the weather for both cities and calculate 2+2. Let me start with the weather calls.", ToolCalls: []api.ToolCall{
{Function: api.ToolCallFunction{Name: "get_weather", Arguments: testArgs(map[string]any{"city": "Paris"})}},
{Function: api.ToolCallFunction{Name: "get_weather", Arguments: testArgs(map[string]any{"city": "London"})}},
}},
{Role: "tool", Content: "Sunny, 22°C", ToolCallID: "call1"},
{Role: "tool", Content: "Rainy, 15°C", ToolCallID: "call2"},
{Role: "assistant", Content: "", Thinking: "Now I have the weather data. Let me calculate 2+2.", ToolCalls: []api.ToolCall{
{Function: api.ToolCallFunction{Name: "calculate", Arguments: testArgs(map[string]any{"expression": "2+2"})}},
}},
{Role: "tool", Content: "4", ToolCallID: "call3"},
{Role: "assistant", Content: "Based on the weather data, Paris is sunny at 22°C and London is rainy at 15°C. Also, 2+2 equals 4.", Thinking: "Perfect! I have all the information needed to provide a complete answer."},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"city": {Type: api.PropertyType{"string"}},
}),
},
},
},
{
Type: "function",
Function: api.ToolFunction{
Name: "calculate",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"expression": {Type: api.PropertyType{"string"}},
}),
},
},
},
},
thinkValue: &api.ThinkValue{Value: true},
expected: "<|im_start|>system\n" +
"# Tools\n\nYou have access to the following functions:\n\n<tools>\n" +
"<function>\n<name>get_weather</name>\n" +
"<parameters>\n" +
"<parameter>\n<name>city</name>\n<type>string</type>\n</parameter>\n" +
"</parameters>\n</function>\n" +
"<function>\n<name>calculate</name>\n" +
"<parameters>\n" +
"<parameter>\n<name>expression</name>\n<type>string</type>\n</parameter>\n" +
"</parameters>\n</function>\n</tools>\n\n" +
"If you choose to call a function ONLY reply in the following format with NO suffix:\n\n" +
"<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n" +
"<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n" +
"</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n" +
"- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n" +
"- Required parameters MUST be specified\n" +
"- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n" +
"- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n" +
"</IMPORTANT><|im_end|>\n" +
"<|im_start|>user\nWhat's the weather in Paris and London? Also, what's 2+2?<|im_end|>\n" +
"<|im_start|>assistant\n" +
"<think>\nI need to check the weather for both cities and calculate 2+2. Let me start with the weather calls.\n</think>\n" +
"<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n</parameter>\n</function>\n</tool_call>\n" +
"<tool_call>\n<function=get_weather>\n<parameter=city>\nLondon\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n" +
"<|im_start|>user\n<tool_response>\nSunny, 22°C\n</tool_response>\n<tool_response>\nRainy, 15°C\n</tool_response>\n<|im_end|>\n" +
"<|im_start|>assistant\n" +
"<think>\nNow I have the weather data. Let me calculate 2+2.\n</think>\n" +
"<tool_call>\n<function=calculate>\n<parameter=expression>\n2+2\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n" +
"<|im_start|>user\n<tool_response>\n4\n</tool_response>\n<|im_end|>\n" +
"<|im_start|>assistant\n" +
"<think>\nPerfect! I have all the information needed to provide a complete answer.\n</think>\n" +
"Based on the weather data, Paris is sunny at 22°C and London is rainy at 15°C. Also, 2+2 equals 4.<|im_end|>\n" +
"<|im_start|>assistant\n<think>\n",
},
{
name: "empty messages list",
msgs: []api.Message{},
thinkValue: nil,
expected: "<|im_start|>system\n<|im_end|>\n<|im_start|>assistant\n<think></think>",
},
{
name: "tool result with JSON content",
msgs: []api.Message{
{Role: "user", Content: "Get user info"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{
{Function: api.ToolCallFunction{Name: "get_user", Arguments: testArgs(map[string]any{"id": "123"})}},
},
},
{Role: "tool", Content: `{"name": "John", "age": 30, "active": true}`},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_user",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{"id": {Type: api.PropertyType{"string"}}}),
},
},
},
},
thinkValue: &api.ThinkValue{Value: true},
expected: "<|im_start|>system\n" +
"# Tools\n\nYou have access to the following functions:\n\n<tools>\n" +
"<function>\n<name>get_user</name>\n<parameters>\n" +
"<parameter>\n<name>id</name>\n<type>string</type>\n</parameter>\n" +
"</parameters>\n</function>\n</tools>\n\n" +
"If you choose to call a function ONLY reply in the following format with NO suffix:\n\n" +
"<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n" +
"<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n" +
"</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n" +
"- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n" +
"- Required parameters MUST be specified\n" +
"- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n" +
"- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n" +
"</IMPORTANT><|im_end|>\n" +
"<|im_start|>user\nGet user info<|im_end|>\n" +
"<|im_start|>assistant\n<think></think>\n" +
"<tool_call>\n<function=get_user>\n<parameter=id>\n123\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n" +
"<|im_start|>user\n<tool_response>\n{\"name\": \"John\", \"age\": 30, \"active\": true}\n</tool_response>\n<|im_end|>\n" +
"<|im_start|>assistant\n<think>\n",
},
{
name: "assistant message with only thinking no content",
msgs: []api.Message{
{Role: "user", Content: "Think about this"},
{Role: "assistant", Thinking: "Deep thoughts here...", Content: ""},
{Role: "user", Content: "What did you think?"},
},
thinkValue: &api.ThinkValue{Value: true},
expected: "<|im_start|>system\n<|im_end|>\n" +
"<|im_start|>user\nThink about this<|im_end|>\n" +
"<|im_start|>assistant\n<think></think><|im_end|>\n" +
"<|im_start|>user\nWhat did you think?<|im_end|>\n" +
"<|im_start|>assistant\n<think>\n",
},
{
name: "tool call with complex nested argument",
msgs: []api.Message{
{Role: "user", Content: "Create data"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{
{Function: api.ToolCallFunction{
Name: "create",
Arguments: testArgs(map[string]any{
"data": map[string]any{"nested": "value", "count": 42},
}),
}},
},
},
{Role: "tool", Content: "Created"},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "create",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{"data": {Type: api.PropertyType{"object"}}}),
},
},
},
},
thinkValue: &api.ThinkValue{Value: true},
expected: "<|im_start|>system\n" +
"# Tools\n\nYou have access to the following functions:\n\n<tools>\n" +
"<function>\n<name>create</name>\n<parameters>\n" +
"<parameter>\n<name>data</name>\n<type>object</type>\n</parameter>\n" +
"</parameters>\n</function>\n</tools>\n\n" +
"If you choose to call a function ONLY reply in the following format with NO suffix:\n\n" +
"<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n" +
"<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n" +
"</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n" +
"- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n" +
"- Required parameters MUST be specified\n" +
"- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n" +
"- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n" +
"</IMPORTANT><|im_end|>\n" +
"<|im_start|>user\nCreate data<|im_end|>\n" +
"<|im_start|>assistant\n<think></think>\n" +
"<tool_call>\n<function=create>\n<parameter=data>\n{\"count\":42,\"nested\":\"value\"}\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n" +
"<|im_start|>user\n<tool_response>\nCreated\n</tool_response>\n<|im_end|>\n" +
"<|im_start|>assistant\n<think>\n",
},
{
name: "content explaining the format itself",
msgs: []api.Message{
{Role: "user", Content: "How do I format a tool call?"},
{Role: "assistant", Content: "To call a tool, use <tool_call> tags with <function=name> inside."},
{Role: "user", Content: "Thanks!"},
},
thinkValue: &api.ThinkValue{Value: true},
expected: "<|im_start|>system\n<|im_end|>\n" +
"<|im_start|>user\nHow do I format a tool call?<|im_end|>\n" +
"<|im_start|>assistant\n<think></think>To call a tool, use <tool_call> tags with <function=name> inside.<|im_end|>\n" +
"<|im_start|>user\nThanks!<|im_end|>\n" +
"<|im_start|>assistant\n<think>\n",
},
{
name: "unicode in content and tool args",
msgs: []api.Message{
{Role: "user", Content: "Translate 你好"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{
{Function: api.ToolCallFunction{Name: "translate", Arguments: testArgs(map[string]any{"text": "你好"})}},
},
},
{Role: "tool", Content: "Hello"},
},
tools: []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "translate",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: testPropsMap(map[string]api.ToolProperty{
"text": {Type: api.PropertyType{"string"}},
}),
},
},
},
},
thinkValue: &api.ThinkValue{Value: true},
expected: "<|im_start|>system\n" +
"# Tools\n\nYou have access to the following functions:\n\n<tools>\n" +
"<function>\n<name>translate</name>\n<parameters>\n" +
"<parameter>\n<name>text</name>\n<type>string</type>\n</parameter>\n" +
"</parameters>\n</function>\n</tools>\n\n" +
"If you choose to call a function ONLY reply in the following format with NO suffix:\n\n" +
"<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n" +
"<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n" +
"</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n" +
"- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n" +
"- Required parameters MUST be specified\n" +
"- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n" +
"- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n" +
"</IMPORTANT><|im_end|>\n" +
"<|im_start|>user\nTranslate 你好<|im_end|>\n" +
"<|im_start|>assistant\n<think></think>\n" +
"<tool_call>\n<function=translate>\n<parameter=text>\n你好\n</parameter>\n</function>\n</tool_call>\n<|im_end|>\n" +
"<|im_start|>user\n<tool_response>\nHello\n</tool_response>\n<|im_end|>\n" +
"<|im_start|>assistant\n<think>\n",
expected: "\n\n\n<|im_start|>system\n<|im_end|>\n\n<|im_start|>user\n[img-0]Describe the first image.<|im_end|>\n<|im_start|>assistant\n<think></think>It shows something.<|im_end|>\n<|im_start|>user\n[img-1][img-2]Compare these.<|im_end|>\n\n<|im_start|>assistant\n<think>\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
renderer := &Nemotron3NanoRenderer{}
rendered, err := renderer.Render(tt.msgs, tt.tools, tt.thinkValue)
rendered, err := renderer.Render(tt.msgs, nil, nil)
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(rendered, tt.expected); diff != "" {
t.Errorf("mismatch (-got +want):\n%s", diff)
if diff := cmp.Diff(tt.expected, rendered); diff != "" {
t.Fatalf("mismatch (-want +got):\n%s", diff)
}
})
}
+5 -1
View File
@@ -81,8 +81,10 @@ func rendererForName(name string) Renderer {
return renderer
case "nemotron-3-nano":
return &Nemotron3NanoRenderer{}
case "gemma4":
case "gemma4", "gemma4-small":
return &Gemma4Renderer{useImgTags: RenderImgTags}
case "gemma4-large":
return &Gemma4Renderer{useImgTags: RenderImgTags, emptyBlockOnNothink: true}
case "functiongemma":
return &FunctionGemmaRenderer{}
case "glm-4.7":
@@ -93,6 +95,8 @@ func rendererForName(name string) Renderer {
return &LFM2Renderer{IsThinking: false, useImgTags: RenderImgTags}
case "lfm2-thinking":
return &LFM2Renderer{IsThinking: true, useImgTags: RenderImgTags}
case "laguna":
return &LagunaRenderer{}
default:
return nil
}
@@ -0,0 +1,222 @@
{% macro render_extra_keys(json_dict, handled_keys) %}
{%- if json_dict is mapping %}
{%- for json_key in json_dict if json_key not in handled_keys %}
{%- if json_dict[json_key] is mapping or (json_dict[json_key] is sequence and json_dict[json_key] is not string) %}
{{- '\n<' ~ json_key ~ '>' ~ (json_dict[json_key] | tojson | safe) ~ '</' ~ json_key ~ '>' }}
{%- else %}
{{- '\n<' ~ json_key ~ '>' ~ (json_dict[json_key] | string) ~ '</' ~ json_key ~ '>' }}
{%- endif %}
{%- endfor %}
{%- endif %}
{% endmacro %}
{%- set enable_thinking = enable_thinking if enable_thinking is defined else True %}
{%- set reasoning_budget = reasoning_budget if reasoning_budget is defined else None %}
{%- set truncate_history_thinking = truncate_history_thinking if truncate_history_thinking is defined else True %}
{%- set response_format = response_format if response_format is defined else None %}
{# Scan messages for VLM thinking toggles to override enable_thinking #}
{%- set toggle = namespace(enable=enable_thinking) %}
{%- for m in messages %}
{%- if m['role'] == 'user' or m['role'] == 'system' -%}
{%- if m['content'] is string -%}
{%- set c = m['content'] %}
{%- if '/think' in c.replace('</think>', '') -%}
{%- set toggle.enable = true -%}
{%- elif '/no_think' in c -%}
{%- set toggle.enable = false -%}
{%- endif -%}
{%- endif -%}
{%- endif -%}
{%- endfor %}
{# Prepare message iteration similar to LM template #}
{%- set ns = namespace(last_user_idx = -1) %}
{%- set loop_messages = messages %}
{%- for m in loop_messages %}
{%- if m["role"] == "user" %}
{%- set ns.last_user_idx = loop.index0 %}
{%- endif %}
{%- endfor %}
{%- if messages[0]["role"] == "system" %}
{%- set system_message = messages[0]["content"] %}
{%- set loop_messages = messages[1:] %}
{%- else %}
{%- set system_message = "" %}
{%- set loop_messages = messages %}
{%- endif %}
{%- if not tools is defined %}
{%- set tools = [] %}
{%- endif %}
{# Recompute last_user_idx relative to loop_messages after handling system #}
{%- set ns = namespace(last_user_idx = -1) %}
{%- for m in loop_messages %}
{%- if m["role"] == "user" %}
{%- set ns.last_user_idx = loop.index0 %}
{%- endif %}
{%- endfor %}
{# System preamble with LM formatting, sanitize thinking toggles #}
{%- if system_message is defined %}
{%- set sys_content = system_message | string %}
{%- set sys_content = sys_content.replace('</think>', '<_end_think>').replace('/think', '').replace('/no_think', '').replace('<_end_think>', '</think>') %}
{{- "<|im_start|>system\n" + sys_content }}
{%- else %}
{%- if tools is iterable and tools | length > 0 %}
{{- "<|im_start|>system\n" }}
{%- endif %}
{%- endif %}
{%- if tools is iterable and tools | length > 0 %}
{%- if system_message is defined and system_message | length > 0 %}
{{- "\n\n" }}
{%- endif %}
{{- "# Tools\n\nYou have access to the following functions:\n\n" }}
{{- "<tools>" }}
{%- for tool in tools %}
{%- if tool.function is defined %}
{%- set tool = tool.function %}
{%- endif %}
{{- "\n<function>\n<name>" ~ tool.name ~ "</name>" }}
{%- if tool.description is defined %}
{{- '\n<description>' ~ (tool.description | trim) ~ '</description>' }}
{%- endif %}
{{- '\n<parameters>' }}
{%- if tool.parameters is defined and tool.parameters is mapping and tool.parameters.properties is defined and tool.parameters.properties is mapping %}
{%- for param_name, param_fields in tool.parameters.properties|items %}
{{- '\n<parameter>' }}
{{- '\n<name>' ~ param_name ~ '</name>' }}
{%- if param_fields.type is defined %}
{{- '\n<type>' ~ (param_fields.type | string) ~ '</type>' }}
{%- endif %}
{%- if param_fields.description is defined %}
{{- '\n<description>' ~ (param_fields.description | trim) ~ '</description>' }}
{%- endif %}
{%- if param_fields.enum is defined %}
{{- '\n<enum>' ~ (param_fields.enum | tojson | safe) ~ '</enum>' }}
{%- endif %}
{%- set handled_keys = ['name', 'type', 'description', 'enum'] %}
{{- render_extra_keys(param_fields, handled_keys) }}
{{- '\n</parameter>' }}
{%- endfor %}
{%- endif %}
{% set handled_keys = ['type', 'properties', 'required'] %}
{{- render_extra_keys(tool.parameters, handled_keys) }}
{%- if tool.parameters is defined and tool.parameters.required is defined %}
{{- '\n<required>' ~ (tool.parameters.required | tojson | safe) ~ '</required>' }}
{%- endif %}
{{- '\n</parameters>' }}
{%- set handled_keys = ['type', 'name', 'description', 'parameters'] %}
{{- render_extra_keys(tool, handled_keys) }}
{{- '\n</function>' }}
{%- endfor %}
{{- "\n</tools>" }}
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
{%- endif %}
{%- if system_message is defined %}
{{- '<|im_end|>\n' }}
{%- else %}
{%- if tools is iterable and tools | length > 0 %}
{{- '<|im_end|>\n' }}
{%- endif %}
{%- endif %}
{# Iterate conversation #}
{%- for message in loop_messages %}
{%- if message.role == "assistant" %}
{# Use LM assistant handling #}
{%- if message.reasoning_content is defined and message.reasoning_content is string and message.reasoning_content | trim | length > 0 %}
{%- set content = "<think>\n" ~ message.reasoning_content ~ "\n</think>\n" ~ (message.content | default('', true)) %}
{%- else %}
{%- set content = message.content | default('', true) %}
{%- if content is string -%}
{%- if '<think>' not in content and '</think>' not in content -%}
{%- set content = "<think></think>" ~ content -%}
{%- endif -%}
{%- else -%}
{%- set content = content -%}
{%- endif -%}
{%- endif %}
{%- if message.tool_calls is defined and message.tool_calls is iterable and message.tool_calls | length > 0 %}
{{- '<|im_start|>assistant\n' }}
{%- set include_content = not (truncate_history_thinking and loop.index0 < ns.last_user_idx) %}
{%- if content is string and content | trim | length > 0 %}
{%- if include_content %}
{{- (content | trim) ~ '\n' -}}
{%- else %}
{%- set c = (content | string) %}
{%- if '</think>' in c %}
{%- set c = c.split('</think>')[-1] %}
{%- elif '<think>' in c %}
{%- set c = c.split('<think>')[0] %}
{%- endif %}
{%- set c = "<think></think>" ~ c | trim %}
{%- if c | length > 0 %}
{{- c ~ '\n' -}}
{%- endif %}
{%- endif %}
{%- else %}
{{- "<think></think>" -}}
{%- endif %}
{%- for tool_call in message.tool_calls %}
{%- if tool_call.function is defined %}
{%- set tool_call = tool_call.function %}
{%- endif %}
{{- '<tool_call>\n<function=' ~ tool_call.name ~ '>\n' -}}
{%- if tool_call.arguments is defined %}
{%- for args_name, args_value in tool_call.arguments|items %}
{{- '<parameter=' ~ args_name ~ '>\n' -}}
{%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
{{- args_value ~ '\n</parameter>\n' -}}
{%- endfor %}
{%- endif %}
{{- '</function>\n</tool_call>\n' -}}
{%- endfor %}
{{- '<|im_end|>\n' }}
{%- else %}
{%- if not (truncate_history_thinking and loop.index0 < ns.last_user_idx) %}
{{- '<|im_start|>assistant\n' ~ (content | default('', true) | string | trim) ~ '<|im_end|>\n' }}
{%- else %}
{%- set c = (content | default('', true) | string) %}
{%- if '<think>' in c and '</think>' in c %}
{%- set c = "<think></think>" ~ c.split('</think>')[-1] %}
{%- endif %}
{%- set c = c | trim %}
{%- if c | length > 0 %}
{{- '<|im_start|>assistant\n' ~ c ~ '<|im_end|>\n' }}
{%- else %}
{{- '<|im_start|>assistant\n<|im_end|>\n' }}
{%- endif %}
{%- endif %}
{%- endif %}
{%- elif message.role == "user" or message.role == "system" %}
{{- '<|im_start|>' + message.role + '\n' }}
{%- set content = message.content | string %}
{{- content }}
{{- '<|im_end|>\n' }}
{%- elif message.role == "tool" %}
{%- if loop.previtem and loop.previtem.role != "tool" %}
{{- '<|im_start|>user\n' }}
{%- endif %}
{{- '<tool_response>\n' }}
{{- message.content }}
{{- '\n</tool_response>\n' }}
{%- if not loop.last and loop.nextitem.role != "tool" %}
{{- '<|im_end|>\n' }}
{%- elif loop.last %}
{{- '<|im_end|>\n' }}
{%- endif %}
{%- else %}
{{- '<|im_start|>' + message.role + '\n' + message.content + '<|im_end|>\n' }}
{%- endif %}
{%- endfor %}
{# Generation prompt using computed thinking toggle #}
{%- if add_generation_prompt %}
{%- if toggle.enable %}
{{- '<|im_start|>assistant\n<think>\n' }}
{%- else %}
{{- '<|im_start|>assistant\n<think></think>' }}
{%- endif %}
{%- endif %}
+2 -2
View File
@@ -632,8 +632,8 @@ func FromChatRequest(r ChatCompletionRequest) (*api.ChatRequest, error) {
}
if effort != "" {
if !slices.Contains([]string{"high", "medium", "low", "none"}, effort) {
return nil, fmt.Errorf("invalid reasoning value: '%s' (must be \"high\", \"medium\", \"low\", or \"none\")", effort)
if !slices.Contains([]string{"high", "medium", "low", "max", "none"}, effort) {
return nil, fmt.Errorf("invalid reasoning value: '%s' (must be \"high\", \"medium\", \"low\", \"max\", or \"none\")", effort)
}
if effort == "none" {
+51
View File
@@ -55,6 +55,57 @@ func TestFromChatRequest_Basic(t *testing.T) {
}
}
func TestFromChatRequest_ReasoningEffort(t *testing.T) {
effort := func(s string) *string { return &s }
cases := []struct {
name string
effort *string
want any // expected ThinkValue.Value; nil means req.Think should be nil
wantErr bool
}{
{name: "unset", effort: nil, want: nil},
{name: "high", effort: effort("high"), want: "high"},
{name: "medium", effort: effort("medium"), want: "medium"},
{name: "low", effort: effort("low"), want: "low"},
{name: "max", effort: effort("max"), want: "max"},
{name: "none disables", effort: effort("none"), want: false},
{name: "invalid", effort: effort("extreme"), wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := ChatCompletionRequest{
Model: "test-model",
Messages: []Message{{Role: "user", Content: "hi"}},
ReasoningEffort: tc.effort,
}
result, err := FromChatRequest(req)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error for effort=%v, got none", *tc.effort)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tc.want == nil {
if result.Think != nil {
t.Fatalf("expected nil Think, got %+v", result.Think)
}
return
}
if result.Think == nil {
t.Fatalf("expected Think=%v, got nil", tc.want)
}
if result.Think.Value != tc.want {
t.Fatalf("got Think.Value=%v, want %v", result.Think.Value, tc.want)
}
})
}
}
func TestFromChatRequest_WithImage(t *testing.T) {
imgData, _ := base64.StdEncoding.DecodeString(image)
+13
View File
@@ -525,6 +525,18 @@ func FromResponsesRequest(r ResponsesRequest) (*api.ChatRequest, error) {
options["num_predict"] = *r.MaxOutputTokens
}
var think *api.ThinkValue
if effort := r.Reasoning.Effort; effort != "" {
switch effort {
case "none":
think = &api.ThinkValue{Value: false}
case "low", "medium", "high", "max":
think = &api.ThinkValue{Value: effort}
default:
return nil, fmt.Errorf("invalid reasoning value: %q (must be \"high\", \"medium\", \"low\", \"max\", or \"none\")", effort)
}
}
// Convert tools from Responses API format to api.Tool format
var tools []api.Tool
for _, t := range r.Tools {
@@ -552,6 +564,7 @@ func FromResponsesRequest(r ResponsesRequest) (*api.ChatRequest, error) {
Options: options,
Tools: tools,
Format: format,
Think: think,
}, nil
}
+80
View File
@@ -415,6 +415,86 @@ func TestFromResponsesRequest_Tools(t *testing.T) {
}
}
func TestFromResponsesRequest_ReasoningEffort(t *testing.T) {
tests := []struct {
name string
effort string
wantThink any
wantErr bool
}{
{
name: "unset",
},
{
name: "low",
effort: "low",
wantThink: "low",
},
{
name: "medium",
effort: "medium",
wantThink: "medium",
},
{
name: "high",
effort: "high",
wantThink: "high",
},
{
name: "max",
effort: "max",
wantThink: "max",
},
{
name: "none",
effort: "none",
wantThink: false,
},
{
name: "invalid",
effort: "extreme",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := ResponsesRequest{
Model: "deepseek-v4-flash",
Input: ResponsesInput{Text: "hi"},
}
if tt.effort != "" {
req.Reasoning.Effort = tt.effort
}
chatReq, err := FromResponsesRequest(req)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.wantThink == nil {
if chatReq.Think != nil {
t.Fatalf("Think = %#v, want nil", chatReq.Think)
}
return
}
if chatReq.Think == nil {
t.Fatalf("Think = nil, want %v", tt.wantThink)
}
if chatReq.Think.Value != tt.wantThink {
t.Errorf("Think.Value = %v, want %v", chatReq.Think.Value, tt.wantThink)
}
})
}
}
func TestFromResponsesRequest_FunctionCallOutput(t *testing.T) {
// Test a complete tool call round-trip:
// 1. User message asking about weather
+17 -4
View File
@@ -494,15 +494,18 @@ func createModel(r api.CreateRequest, name model.Name, baseLayers []*layerGGML,
for _, layer := range baseLayers {
if layer.GGML != nil {
quantType := strings.ToUpper(cmp.Or(r.Quantize, r.Quantization))
ft := layer.GGML.KV().FileType()
if quantType == "" && hasSourceFP8Tensors(layer.GGML.KV()) && layer.GGML.Name() == "gguf" && layer.MediaType == "application/vnd.ollama.image.model" && slices.Contains([]string{"F16", "BF16", "F32"}, ft.String()) {
quantType = "Q8_0"
}
if quantType != "" && layer.GGML.Name() == "gguf" && layer.MediaType == "application/vnd.ollama.image.model" {
want, err := ggml.ParseFileType(quantType)
if err != nil {
return err
}
ft := layer.GGML.KV().FileType()
if !slices.Contains([]string{"F16", "F32"}, ft.String()) {
return errors.New("quantization is only supported for F16 and F32 models")
if !slices.Contains([]string{"F16", "BF16", "F32"}, ft.String()) {
return errors.New("quantization is only supported for F16, BF16 and F32 models")
} else if ft != want {
layer, err = quantizeLayer(layer, quantType, fn)
if err != nil {
@@ -523,7 +526,7 @@ func createModel(r api.CreateRequest, name model.Name, baseLayers []*layerGGML,
arch := layer.GGML.KV().Architecture()
switch arch {
case "gemma4":
config.Renderer = cmp.Or(config.Renderer, "gemma4")
config.Renderer = cmp.Or(config.Renderer, gemma4RendererLegacy)
config.Parser = cmp.Or(config.Parser, "gemma4")
if _, ok := r.Parameters["stop"]; !ok {
if r.Parameters == nil {
@@ -531,6 +534,12 @@ func createModel(r api.CreateRequest, name model.Name, baseLayers []*layerGGML,
}
r.Parameters["stop"] = []string{"<turn|>"}
}
case "laguna":
config.Renderer = cmp.Or(config.Renderer, "laguna")
config.Parser = cmp.Or(config.Parser, "laguna")
case "nemotron_h", "nemotron_h_moe", "nemotron_h_omni":
config.Renderer = cmp.Or(config.Renderer, "nemotron-3-nano")
config.Parser = cmp.Or(config.Parser, "nemotron-3-nano")
}
}
}
@@ -606,6 +615,10 @@ func createModel(r api.CreateRequest, name model.Name, baseLayers []*layerGGML,
return nil
}
func hasSourceFP8Tensors(kv ggml.KV) bool {
return kv.String("source_quantization") == "hf_fp8" && len(kv.Strings("source_fp8_tensors")) > 0
}
func quantizeLayer(layer *layerGGML, quantizeType string, fn func(resp api.ProgressResponse)) (*layerGGML, error) {
ft := layer.GGML.KV().FileType()
var doneBytes atomic.Uint64
+78
View File
@@ -0,0 +1,78 @@
package server
import "testing"
func TestResolveGemma4Renderer(t *testing.T) {
tests := []struct {
name string
model *Model
want string
}{
{
name: "nil model falls back to legacy alias",
model: nil,
want: gemma4RendererLegacy,
},
{
name: "explicit small passes through",
model: &Model{
Config: testConfigWithRenderer(gemma4RendererSmall),
},
want: gemma4RendererSmall,
},
{
name: "explicit large passes through",
model: &Model{
Config: testConfigWithRenderer(gemma4RendererLarge),
},
want: gemma4RendererLarge,
},
{
name: "legacy e4b tag resolves small",
model: &Model{
Name: "gemma4:e4b",
ShortName: "gemma4:e4b",
Config: testConfigWithRenderer(gemma4RendererLegacy),
},
want: gemma4RendererSmall,
},
{
name: "legacy 31b tag resolves large",
model: &Model{
Name: "gemma4:31b-cloud",
ShortName: "gemma4:31b-cloud",
Config: testConfigWithRenderer(gemma4RendererLegacy),
},
want: gemma4RendererLarge,
},
{
name: "legacy model type resolves small",
model: &Model{
Config: testConfigWithRendererAndType(gemma4RendererLegacy, "4.3B"),
},
want: gemma4RendererSmall,
},
{
name: "legacy model type resolves large",
model: &Model{
Config: testConfigWithRendererAndType(gemma4RendererLegacy, "25.2B"),
},
want: gemma4RendererLarge,
},
{
name: "legacy unknown defaults small",
model: &Model{
Config: testConfigWithRenderer(gemma4RendererLegacy),
},
want: gemma4RendererSmall,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := resolveGemma4Renderer(tt.model); got != tt.want {
t.Fatalf("resolveGemma4Renderer() = %q, want %q", got, tt.want)
}
})
}
}
+20 -2
View File
@@ -19,6 +19,7 @@ import (
"slices"
"strconv"
"strings"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/envconfig"
@@ -33,6 +34,10 @@ import (
"github.com/ollama/ollama/x/imagegen/transfer"
)
// Blobs newer than this may belong to another process that has not written its
// manifest yet. They become eligible for the normal mark-and-sweep pass later.
const layerPruneGracePeriod = time.Hour
var (
errCapabilities = errors.New("does not support")
errCapabilityCompletion = errors.New("completion")
@@ -156,7 +161,7 @@ func (m *Model) Capabilities() []model.Capability {
// Temporary workaround — suppress vision/audio for gemma4 MLX models
// until multimodal runtime pipeline lands. Remove when imageproc.go is wired up.
if m.Config.ModelFormat == "safetensors" && m.Config.Renderer == "gemma4" {
if m.Config.ModelFormat == "safetensors" && isGemma4Renderer(m.Config.Renderer) {
capabilities = slices.DeleteFunc(capabilities, func(c model.Capability) bool {
return c == model.CapabilityVision || c == "audio"
})
@@ -478,10 +483,23 @@ func PruneLayers() error {
}
for _, blob := range blobs {
if blob.IsDir() {
continue
}
info, err := blob.Info()
if err != nil {
slog.Error("couldn't stat blob", "blob", blob.Name(), "error", err)
continue
}
if time.Since(info.ModTime()) < layerPruneGracePeriod {
continue
}
name := blob.Name()
name = strings.ReplaceAll(name, "-", ":")
_, err := manifest.BlobsPath(name)
_, err = manifest.BlobsPath(name)
if err != nil {
if errors.Is(err, manifest.ErrInvalidDigestFormat) {
// remove invalid blobs (e.g. partial downloads)
+77
View File
@@ -5,14 +5,58 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/ollama/ollama/fs/ggml"
"github.com/ollama/ollama/manifest"
"github.com/ollama/ollama/template"
"github.com/ollama/ollama/types/model"
)
func TestPruneLayersSkipsRecentOrphans(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
recentDigest := "sha256:0000000000000000000000000000000000000000000000000000000000000001"
oldDigest := "sha256:0000000000000000000000000000000000000000000000000000000000000002"
for _, digest := range []string{recentDigest, oldDigest} {
p, err := manifest.BlobsPath(digest)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, nil, 0o644); err != nil {
t.Fatal(err)
}
}
oldPath, err := manifest.BlobsPath(oldDigest)
if err != nil {
t.Fatal(err)
}
oldTime := time.Now().Add(-layerPruneGracePeriod - time.Hour)
if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
t.Fatal(err)
}
if err := PruneLayers(); err != nil {
t.Fatal(err)
}
recentPath, err := manifest.BlobsPath(recentDigest)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(recentPath); err != nil {
t.Fatalf("recent orphan was pruned: %v", err)
}
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
t.Fatalf("old orphan still exists: %v", err)
}
}
func TestModelCapabilities(t *testing.T) {
// Create completion model (llama architecture without vision)
completionModelPath, _ := createBinFile(t, ggml.KV{
@@ -118,6 +162,39 @@ func TestModelCapabilities(t *testing.T) {
},
expectedCaps: []model.Capability{model.CapabilityEmbedding},
},
{
name: "gemma4 small safetensors suppresses vision and audio",
model: Model{
Config: model.ConfigV2{
ModelFormat: "safetensors",
Renderer: gemma4RendererSmall,
Capabilities: []string{"vision", "audio"},
},
Template: chatTemplate,
},
},
{
name: "gemma4 large safetensors suppresses vision and audio",
model: Model{
Config: model.ConfigV2{
ModelFormat: "safetensors",
Renderer: gemma4RendererLarge,
Capabilities: []string{"vision", "audio"},
},
Template: chatTemplate,
},
},
{
name: "legacy gemma4 safetensors suppresses vision and audio",
model: Model{
Config: model.ConfigV2{
ModelFormat: "safetensors",
Renderer: gemma4RendererLegacy,
Capabilities: []string{"vision", "audio"},
},
Template: chatTemplate,
},
},
}
// compare two slices of model.Capability regardless of order
+90
View File
@@ -0,0 +1,90 @@
package server
import (
"testing"
fsggml "github.com/ollama/ollama/fs/ggml"
)
func TestLagunaGGUFQuantization(t *testing.T) {
cases := []struct {
name string
tensor string
originalType fsggml.TensorType
requestedType fsggml.TensorType
fileType fsggml.FileType
blockCount int
wantType fsggml.TensorType
wantQuantize bool
}{
{
name: "non_routed_weights_preserved",
tensor: "blk.1.attn_q.weight",
originalType: fsggml.TensorTypeBF16,
requestedType: fsggml.TensorTypeQ8_0,
fileType: fsggml.FileTypeQ8_0,
blockCount: 2,
wantType: fsggml.TensorTypeBF16,
wantQuantize: false,
},
{
name: "shared_expert_weights_preserved",
tensor: "blk.1.ffn_gate_shexp.weight",
originalType: fsggml.TensorTypeBF16,
requestedType: fsggml.TensorTypeQ4_K,
fileType: fsggml.FileTypeQ4_K_M,
blockCount: 2,
wantType: fsggml.TensorTypeBF16,
wantQuantize: false,
},
{
name: "routed_gate_q8",
tensor: "blk.1.ffn_gate_exps.weight",
originalType: fsggml.TensorTypeBF16,
requestedType: fsggml.TensorTypeQ8_0,
fileType: fsggml.FileTypeQ8_0,
blockCount: 2,
wantType: fsggml.TensorTypeQ8_0,
wantQuantize: true,
},
{
name: "routed_down_q4_promoted",
tensor: "blk.1.ffn_down_exps.weight",
originalType: fsggml.TensorTypeBF16,
requestedType: fsggml.TensorTypeQ4_K,
fileType: fsggml.FileTypeQ4_K_M,
blockCount: 2,
wantType: fsggml.TensorTypeQ6_K,
wantQuantize: true,
},
{
name: "routed_down_q4_not_promoted_when_q8_requested",
tensor: "blk.1.ffn_down_exps.weight",
originalType: fsggml.TensorTypeBF16,
requestedType: fsggml.TensorTypeQ8_0,
fileType: fsggml.FileTypeQ4_K_M,
blockCount: 2,
wantType: fsggml.TensorTypeQ8_0,
wantQuantize: true,
},
{
name: "routed_down_q4_k_s_promoted",
tensor: "blk.0.ffn_down_exps.weight",
originalType: fsggml.TensorTypeBF16,
requestedType: fsggml.TensorTypeQ4_K,
fileType: fsggml.FileTypeQ4_K_S,
blockCount: 8,
wantType: fsggml.TensorTypeQ5_K,
wantQuantize: true,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
gotType, gotQuantize := lagunaGGUFQuantization(tt.tensor, tt.originalType, tt.requestedType, tt.fileType, tt.blockCount)
if gotType != tt.wantType || gotQuantize != tt.wantQuantize {
t.Fatalf("lagunaGGUFQuantization(%q) = (%s, %v), want (%s, %v)", tt.tensor, gotType, gotQuantize, tt.wantType, tt.wantQuantize)
}
})
}
}
+2 -1
View File
@@ -115,7 +115,8 @@ func chatPrompt(ctx context.Context, m *Model, tokenize tokenizeFunc, opts *api.
func renderPrompt(m *Model, msgs []api.Message, tools []api.Tool, think *api.ThinkValue) (string, error) {
if m.Config.Renderer != "" {
rendered, err := renderers.RenderWithRenderer(m.Config.Renderer, msgs, tools, think)
rendererName := resolveRendererName(m)
rendered, err := renderers.RenderWithRenderer(rendererName, msgs, tools, think)
if err != nil {
return "", err
}
+48
View File
@@ -13,6 +13,14 @@ import (
"github.com/ollama/ollama/types/model"
)
func testConfigWithRenderer(renderer string) model.ConfigV2 {
return model.ConfigV2{Renderer: renderer}
}
func testConfigWithRendererAndType(renderer, modelType string) model.ConfigV2 {
return model.ConfigV2{Renderer: renderer, ModelType: modelType}
}
func TestChatPrompt(t *testing.T) {
type expect struct {
prompt string
@@ -397,3 +405,43 @@ func TestChatPromptGLMOcrRendererAddsImageTags(t *testing.T) {
t.Fatalf("prompt missing glm-ocr image tags, got: %q", prompt)
}
}
func TestRenderPromptResolvesDynamicGemma4Renderer(t *testing.T) {
msgs := []api.Message{{Role: "user", Content: "Hello"}}
tests := []struct {
name string
model Model
want string
}{
{
name: "small from name",
model: Model{
Name: "gemma4:e4b",
ShortName: "gemma4:e4b",
Config: testConfigWithRenderer(gemma4RendererLegacy),
},
want: "<bos><|turn>user\nHello<turn|>\n<|turn>model\n",
},
{
name: "large from model type",
model: Model{
Config: testConfigWithRendererAndType(gemma4RendererLegacy, "25.2B"),
},
want: "<bos><|turn>user\nHello<turn|>\n<|turn>model\n<|channel>thought\n<channel|>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := renderPrompt(&tt.model, msgs, nil, nil)
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(got, tt.want); diff != "" {
t.Fatalf("rendered prompt mismatch (-got +want):\n%s", diff)
}
})
}
}
+106 -12
View File
@@ -7,6 +7,7 @@ import (
"maps"
"os"
"slices"
"strconv"
"strings"
"unsafe"
@@ -51,11 +52,14 @@ func (q quantizer) WriteTo(w io.Writer) (int64, error) {
}
type quantizeState struct {
nAttnV int // Number of attn_*v* weight tensors
nFfnDown int // Number of ffn_down tensors
iAttnV int // Running counter of number of attn_v tensors that have been processed
iFfnDown int // Running counter of number of ffn_down tensors that have been processed
hasOutput bool // used to figure out if a model shares tok_embd with the output weight
nAttnV int // Number of attn_*v* weight tensors
nFfnDown int // Number of ffn_down tensors
iAttnV int // Running counter of number of attn_v tensors that have been processed
iFfnDown int // Running counter of number of ffn_down tensors that have been processed
hasOutput bool // used to figure out if a model shares tok_embd with the output weight
preserveSourceFP8ToQ8 bool
preserveSourceQ4 bool
sourceFP8Tensors map[string]struct{}
}
func useMoreBits(iLayer, nLayers int) bool {
@@ -108,6 +112,53 @@ func qwen3LinearAttnQuantType(name string) (fsggml.TensorType, bool) {
return 0, false
}
func isLagunaGGUFRoutedExpertWeight(name string) bool {
return strings.HasSuffix(name, ".weight") && (strings.Contains(name, "ffn_gate_exps") ||
strings.Contains(name, "ffn_up_exps") ||
strings.Contains(name, "ffn_down_exps"))
}
func lagunaGGUFBlockIndex(name string) (int, bool) {
if !strings.HasPrefix(name, "blk.") {
return 0, false
}
parts := strings.SplitN(strings.TrimPrefix(name, "blk."), ".", 2)
if len(parts) != 2 {
return 0, false
}
i, err := strconv.Atoi(parts[0])
if err != nil {
return 0, false
}
return i, true
}
func lagunaGGUFQuantization(name string, originalType, requestedType fsggml.TensorType, ftype fsggml.FileType, blockCount int) (fsggml.TensorType, bool) {
if !isLagunaGGUFRoutedExpertWeight(name) {
return originalType, false
}
if strings.HasSuffix(name, ".ffn_down_exps.weight") {
if i, ok := lagunaGGUFBlockIndex(name); ok && blockCount > 0 {
switch ftype {
case fsggml.FileTypeQ4_K_M:
if requestedType != fsggml.TensorTypeQ8_0 && useMoreBits(i, blockCount) {
return fsggml.TensorTypeQ6_K, true
}
case fsggml.FileTypeQ4_K_S:
if requestedType != fsggml.TensorTypeQ8_0 && i < blockCount/8 {
return fsggml.TensorTypeQ5_K, true
}
}
}
}
return requestedType, true
}
func getTensorNewType(kv fsggml.KV, qs *quantizeState, newType fsggml.TensorType, name string, shape []uint64, ftype fsggml.FileType) fsggml.TensorType {
// Ported from llama_tensor_get_type, removed unsupported quantization types
nExperts := max(1, kv.Uint("expert_count", 0))
@@ -120,10 +171,10 @@ func getTensorNewType(kv fsggml.KV, qs *quantizeState, newType fsggml.TensorType
newType = fsggml.TensorTypeQ6_K
}
} else if strings.Contains(name, "attn_v.weight") {
if (ftype == fsggml.FileTypeQ4_K_M) &&
if newType != fsggml.TensorTypeQ8_0 && (ftype == fsggml.FileTypeQ4_K_M) &&
useMoreBits(qs.iAttnV, qs.nAttnV) {
newType = fsggml.TensorTypeQ6_K
} else if ftype == fsggml.FileTypeQ4_K_S && qs.iAttnV < 4 {
} else if newType != fsggml.TensorTypeQ8_0 && ftype == fsggml.FileTypeQ4_K_S && qs.iAttnV < 4 {
newType = fsggml.TensorTypeQ5_K
}
@@ -158,31 +209,35 @@ func getTensorNewType(kv fsggml.KV, qs *quantizeState, newType fsggml.TensorType
// expert alphabetically, so dense increments the counter and expert uses counter-1.
var iLayer int
if strings.Contains(name, "_exps") {
if kv.Architecture() == "laguna" {
goto finalize
}
iLayer = max(0, qs.iFfnDown-1)
} else {
iLayer = qs.iFfnDown
qs.iFfnDown++
}
n_layer := qs.nFfnDown
if ftype == fsggml.FileTypeQ4_K_M {
if newType != fsggml.TensorTypeQ8_0 && ftype == fsggml.FileTypeQ4_K_M {
if useMoreBits(iLayer, n_layer) {
newType = fsggml.TensorTypeQ6_K
}
} else if ftype == fsggml.FileTypeQ4_K_S && iLayer < n_layer/8 {
} else if newType != fsggml.TensorTypeQ8_0 && ftype == fsggml.FileTypeQ4_K_S && iLayer < n_layer/8 {
newType = fsggml.TensorTypeQ5_K
}
} else if strings.Contains(name, "attn_output.weight") {
if nExperts == 8 {
if newType != fsggml.TensorTypeQ8_0 && nExperts == 8 {
if ftype == fsggml.FileTypeQ4_K_S || ftype == fsggml.FileTypeQ4_K_M {
newType = fsggml.TensorTypeQ5_K
}
}
} else if strings.Contains(name, "attn_qkv.weight") {
if ftype == fsggml.FileTypeQ4_K_M {
if newType != fsggml.TensorTypeQ8_0 && ftype == fsggml.FileTypeQ4_K_M {
newType = fsggml.TensorTypeQ5_K
}
}
finalize:
if newType.IsQuantized() {
nx := shape[0]
qk_k := newType.BlockSize()
@@ -218,7 +273,12 @@ func quantize(in, out *os.File, orig *fsggml.GGML, newFileType fsggml.FileType,
kv := maps.Clone(orig.KV())
kv["general.file_type"] = newFileType
// kv["general.quantization_version"] = ggml.QuantizationVersion()
qs := &quantizeState{}
qs := &quantizeState{
sourceFP8Tensors: sourceFP8TensorSet(kv),
}
hasSourceFP8 := hasSourceFP8Tensors(kv)
qs.preserveSourceFP8ToQ8 = hasSourceFP8 && newFileType == fsggml.FileTypeQ8_0
qs.preserveSourceQ4 = hasSourceFP8 && slices.Contains([]fsggml.FileType{fsggml.FileTypeQ4_K_M, fsggml.FileTypeQ4_K_S}, newFileType)
// Build up the quantize state so newType can adjust types
layerCount := 0
for k, l := range orig.Tensors().GroupLayers() {
@@ -304,13 +364,34 @@ func newType(t *fsggml.Tensor, kv fsggml.KV, qs *quantizeState, ftype fsggml.Fil
newType := fsggml.TensorType(t.Kind)
if quantize {
if qs.preserveSourceFP8ToQ8 {
if _, ok := qs.sourceFP8Tensors[name]; !ok {
return newType
}
}
if slices.Contains([]string{"qwen3next", "qwen35", "qwen35moe"}, kv.Architecture()) && (ftype == fsggml.FileTypeQ4_K_M || ftype == fsggml.FileTypeQ4_K_S) {
if qt, ok := qwen3LinearAttnQuantType(name); ok {
return qt
}
}
// TODO: Consider extracting architecture-specific GGUF quantization policy
// from server so different quantization backends can share one source of
// truth for model-family specializations.
// get more optimal quantization type based on the tensor shape, layer, etc.
if qs.preserveSourceQ4 {
if _, ok := qs.sourceFP8Tensors[name]; !ok {
defaultType = fsggml.TensorTypeQ8_0
}
}
if kv.Architecture() == "laguna" {
var ok bool
defaultType, ok = lagunaGGUFQuantization(name, newType, defaultType, ftype, int(kv.Uint("block_count", 0)))
if !ok {
return newType
}
}
newType = getTensorNewType(kv, qs, defaultType, t.Name, t.Shape, ftype)
if newType != defaultType {
slog.Debug("tensor quantization adjusted for better quality", "name", t.Name, "requested", defaultType, "quantization", newType)
@@ -318,3 +399,16 @@ func newType(t *fsggml.Tensor, kv fsggml.KV, qs *quantizeState, ftype fsggml.Fil
}
return newType
}
func sourceFP8TensorSet(kv fsggml.KV) map[string]struct{} {
names := kv.Strings("source_fp8_tensors")
if len(names) == 0 {
return nil
}
out := make(map[string]struct{}, len(names))
for _, name := range names {
out[name] = struct{}{}
}
return out
}
+89
View File
@@ -308,6 +308,95 @@ func TestQuantizeModel(t *testing.T) {
"output.weight": fsggml.TensorTypeQ8_0,
},
},
{
name: "source_fp8_q8_preserves_bf16_tensors",
kv: map[string]any{
"general.architecture": "test",
"source_quantization": "hf_fp8",
"source_fp8_tensors": []string{"blk.1.ffn_down_exps.weight"},
},
tensors: []*fsggml.Tensor{
{
Name: "blk.1.ffn_down_exps.weight", Kind: uint32(fsggml.TensorTypeBF16),
Offset: uint64(0), Shape: []uint64{256, 1},
WriterTo: bytes.NewReader(quantBytes[fsggml.TensorTypeBF16]),
},
{
Name: "blk.1.attn_q.weight", Kind: uint32(fsggml.TensorTypeBF16),
Offset: uint64(0), Shape: []uint64{256, 1},
WriterTo: bytes.NewReader(quantBytes[fsggml.TensorTypeBF16]),
},
},
newType: "Q8_0",
expectedTensorTypes: map[string]fsggml.TensorType{
"blk.1.ffn_down_exps.weight": fsggml.TensorTypeQ8_0,
"blk.1.attn_q.weight": fsggml.TensorTypeBF16,
},
},
{
name: "source_fp8_q4_promotes_bf16_tensors_to_q8",
kv: map[string]any{
"general.architecture": "test",
"source_quantization": "hf_fp8",
"source_fp8_tensors": []string{
"blk.1.ffn_gate_exps.weight",
"blk.1.ffn_down_exps.weight",
},
},
tensors: []*fsggml.Tensor{
{
Name: "blk.1.ffn_gate_exps.weight", Kind: uint32(fsggml.TensorTypeBF16),
Offset: uint64(0), Shape: []uint64{256, 1},
WriterTo: bytes.NewReader(quantBytes[fsggml.TensorTypeBF16]),
},
{
Name: "blk.1.ffn_down_exps.weight", Kind: uint32(fsggml.TensorTypeBF16),
Offset: uint64(0), Shape: []uint64{256, 1},
WriterTo: bytes.NewReader(quantBytes[fsggml.TensorTypeBF16]),
},
{
Name: "blk.1.attn_q.weight", Kind: uint32(fsggml.TensorTypeBF16),
Offset: uint64(0), Shape: []uint64{256, 1},
WriterTo: bytes.NewReader(quantBytes[fsggml.TensorTypeBF16]),
},
{
Name: "blk.1.attn_v.weight", Kind: uint32(fsggml.TensorTypeBF16),
Offset: uint64(0), Shape: []uint64{256, 1},
WriterTo: bytes.NewReader(quantBytes[fsggml.TensorTypeBF16]),
},
{
Name: "blk.1.ffn_down.weight", Kind: uint32(fsggml.TensorTypeBF16),
Offset: uint64(0), Shape: []uint64{256, 1},
WriterTo: bytes.NewReader(quantBytes[fsggml.TensorTypeBF16]),
},
{
Name: "blk.1.attn_q_norm.weight", Kind: uint32(fsggml.TensorTypeBF16),
Offset: uint64(0), Shape: []uint64{256},
WriterTo: bytes.NewReader(quantBytes[fsggml.TensorTypeBF16]),
},
{
Name: "blk.1.ffn_gate_inp.weight", Kind: uint32(fsggml.TensorTypeBF16),
Offset: uint64(0), Shape: []uint64{256, 1},
WriterTo: bytes.NewReader(quantBytes[fsggml.TensorTypeBF16]),
},
{
Name: "output.weight", Kind: uint32(fsggml.TensorTypeBF16),
Offset: uint64(0), Shape: []uint64{256, 1},
WriterTo: bytes.NewReader(quantBytes[fsggml.TensorTypeBF16]),
},
},
newType: "Q4_K_M",
expectedTensorTypes: map[string]fsggml.TensorType{
"blk.1.ffn_gate_exps.weight": fsggml.TensorTypeQ4_K,
"blk.1.ffn_down_exps.weight": fsggml.TensorTypeQ6_K,
"blk.1.attn_q.weight": fsggml.TensorTypeQ8_0,
"blk.1.attn_v.weight": fsggml.TensorTypeQ8_0,
"blk.1.ffn_down.weight": fsggml.TensorTypeQ8_0,
"blk.1.attn_q_norm.weight": fsggml.TensorTypeBF16,
"blk.1.ffn_gate_inp.weight": fsggml.TensorTypeBF16,
"output.weight": fsggml.TensorTypeQ8_0,
},
},
{
name: "f32_short_data",
kv: map[string]any{
+110
View File
@@ -0,0 +1,110 @@
package server
import (
"strconv"
"strings"
"github.com/ollama/ollama/format"
)
const (
gemma4RendererLegacy = "gemma4"
gemma4RendererSmall = "gemma4-small"
gemma4RendererLarge = "gemma4-large"
// Gemma 4 small templates cover the e2b/e4b family, while 26b/31b use the
// large template. Default to the small prompt unless the model is clearly in
// the large range.
gemma4LargeMinParameterCount = 16_000_000_000
)
func resolveRendererName(m *Model) string {
if m == nil || m.Config.Renderer == "" {
return ""
}
switch m.Config.Renderer {
case gemma4RendererLegacy:
return resolveGemma4Renderer(m)
default:
return m.Config.Renderer
}
}
func resolveGemma4Renderer(m *Model) string {
if m == nil || m.Config.Renderer != gemma4RendererLegacy {
if m == nil {
return gemma4RendererLegacy
}
return m.Config.Renderer
}
if renderer, ok := gemma4RendererFromName(m.ShortName); ok {
return renderer
}
if renderer, ok := gemma4RendererFromName(m.Name); ok {
return renderer
}
if parameterCount, ok := parseHumanParameterCount(m.Config.ModelType); ok {
return gemma4RendererForParameterCount(parameterCount)
}
return gemma4RendererSmall
}
func gemma4RendererForParameterCount(parameterCount uint64) string {
if parameterCount >= gemma4LargeMinParameterCount {
return gemma4RendererLarge
}
return gemma4RendererSmall
}
func gemma4RendererFromName(name string) (string, bool) {
lower := strings.ToLower(name)
switch {
case strings.Contains(lower, "e2b"), strings.Contains(lower, "e4b"):
return gemma4RendererSmall, true
case strings.Contains(lower, "26b"), strings.Contains(lower, "31b"):
return gemma4RendererLarge, true
default:
return "", false
}
}
func parseHumanParameterCount(s string) (uint64, bool) {
if s == "" {
return 0, false
}
unit := strings.ToUpper(s[len(s)-1:])
var multiplier float64
switch unit {
case "B":
multiplier = float64(format.Billion)
case "M":
multiplier = float64(format.Million)
case "K":
multiplier = float64(format.Thousand)
default:
return 0, false
}
value, err := strconv.ParseFloat(s[:len(s)-1], 64)
if err != nil {
return 0, false
}
return uint64(value * multiplier), true
}
func isGemma4Renderer(renderer string) bool {
switch renderer {
case gemma4RendererLegacy, gemma4RendererSmall, gemma4RendererLarge:
return true
default:
return false
}
}
+28 -7
View File
@@ -375,8 +375,16 @@ func (s *Server) GenerateHandler(c *gin.Context) {
}
var builtinParser parsers.Parser
if shouldUseHarmony(m) && m.Config.Parser == "" {
m.Config.Parser = "harmony"
if shouldUseHarmony(m) {
// harmony's Reasoning field only understands low/medium/high; map "max" to "high"
if req.Think != nil {
if s, ok := req.Think.Value.(string); ok && s == "max" {
req.Think.Value = "high"
}
}
if m.Config.Parser == "" {
m.Config.Parser = "harmony"
}
}
if !req.Raw && m.Config.Parser != "" {
@@ -610,8 +618,10 @@ func (s *Server) GenerateHandler(c *gin.Context) {
}
if builtinParser != nil {
// only send messages with meaningful content (empty messages confuse clients)
if res.Response != "" || res.Thinking != "" || res.Done || len(res.ToolCalls) > 0 {
// Emit chunks that carry logprobs even if the parser is still buffering
// visible content, otherwise generate logprobs disappear for models with
// builtin thinking/tool parsers.
if res.Response != "" || res.Thinking != "" || res.Done || len(res.ToolCalls) > 0 || len(res.Logprobs) > 0 {
ch <- res
}
@@ -2320,8 +2330,16 @@ func (s *Server) ChatHandler(c *gin.Context) {
}
msgs = filterThinkTags(msgs, m)
if shouldUseHarmony(m) && m.Config.Parser == "" {
m.Config.Parser = "harmony"
if shouldUseHarmony(m) {
// harmony's Reasoning field only understands low/medium/high; map "max" to "high"
if req.Think != nil {
if s, ok := req.Think.Value.(string); ok && s == "max" {
req.Think.Value = "high"
}
}
if m.Config.Parser == "" {
m.Config.Parser = "harmony"
}
}
var builtinParser parsers.Parser
@@ -2408,7 +2426,10 @@ func (s *Server) ChatHandler(c *gin.Context) {
// current approach uses the transition from parsed thinking content to
// parsed non-thinking content as the signal to turn constraining on
if req.Format != nil && structuredOutputsState == structuredOutputsState_None && ((builtinParser != nil || thinkingState != nil) && slices.Contains(m.Capabilities(), model.CapabilityThinking)) {
// TODO(parthsareen): temporary fix for https://github.com/ollama/ollama/issues/15260.
// To revisit for other models and have a consistent pattern across models through parsers.
forceImmediate := m.Config.Parser == "gemma4" && req.Think != nil && !req.Think.Bool()
if req.Format != nil && structuredOutputsState == structuredOutputsState_None && !forceImmediate && ((builtinParser != nil || thinkingState != nil) && slices.Contains(m.Capabilities(), model.CapabilityThinking)) {
currentFormat = nil
}
+211
View File
@@ -102,6 +102,36 @@ func createRequest(t *testing.T, fn func(*gin.Context), body any) *httptest.Resp
return w.ResponseRecorder
}
func readCreatedModelConfig(t *testing.T, name string) model.ConfigV2 {
t.Helper()
mf, err := manifest.ParseNamedManifest(model.ParseName(name))
if err != nil {
t.Fatalf("parse manifest: %v", err)
}
if mf.Config.Digest == "" {
t.Fatalf("unexpected empty config digest for manifest")
}
configPath, err := manifest.BlobsPath(mf.Config.Digest)
if err != nil {
t.Fatalf("config blob path: %v", err)
}
cfgFile, err := os.Open(configPath)
if err != nil {
t.Fatalf("open config blob: %v", err)
}
defer cfgFile.Close()
var cfg model.ConfigV2
if err := json.NewDecoder(cfgFile).Decode(&cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
return cfg
}
func checkFileExists(t *testing.T, p string, expect []string) {
t.Helper()
@@ -928,6 +958,187 @@ func TestCreateDetectTemplate(t *testing.T) {
})
}
func TestCreateGemma4KeepsDynamicRendererAlias(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, ggml.KV{
"general.architecture": "gemma4",
"general.parameter_count": uint64(25_200_000_000),
}, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
mf, err := manifest.ParseNamedManifest(model.ParseName("test"))
if err != nil {
t.Fatalf("parse manifest: %v", err)
}
if mf.Config.Digest == "" {
t.Fatalf("unexpected empty config digest for manifest")
}
configPath, err := manifest.BlobsPath(mf.Config.Digest)
if err != nil {
t.Fatalf("config blob path: %v", err)
}
cfgFile, err := os.Open(configPath)
if err != nil {
t.Fatalf("open config blob: %v", err)
}
defer cfgFile.Close()
var cfg model.ConfigV2
if err := json.NewDecoder(cfgFile).Decode(&cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
if cfg.Renderer != gemma4RendererLegacy {
t.Fatalf("expected renderer %q, got %q", gemma4RendererLegacy, cfg.Renderer)
}
if cfg.Parser != "gemma4" {
t.Fatalf("expected parser %q, got %q", "gemma4", cfg.Parser)
}
}
func TestCreateLagunaDetectsRendererParser(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, ggml.KV{
"general.architecture": "laguna",
"general.parameter_count": uint64(33_400_000_000),
}, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
mf, err := manifest.ParseNamedManifest(model.ParseName("test"))
if err != nil {
t.Fatalf("parse manifest: %v", err)
}
if mf.Config.Digest == "" {
t.Fatalf("unexpected empty config digest for manifest")
}
configPath, err := manifest.BlobsPath(mf.Config.Digest)
if err != nil {
t.Fatalf("config blob path: %v", err)
}
cfgFile, err := os.Open(configPath)
if err != nil {
t.Fatalf("open config blob: %v", err)
}
defer cfgFile.Close()
var cfg model.ConfigV2
if err := json.NewDecoder(cfgFile).Decode(&cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
if cfg.Renderer != "laguna" {
t.Fatalf("expected renderer %q, got %q", "laguna", cfg.Renderer)
}
if cfg.Parser != "laguna" {
t.Fatalf("expected parser %q, got %q", "laguna", cfg.Parser)
}
}
func TestCreateNemotronHDefaultsRendererParser(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, arch := range []string{"nemotron_h", "nemotron_h_moe", "nemotron_h_omni"} {
t.Run(arch, func(t *testing.T) {
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, ggml.KV{
"general.architecture": arch,
}, nil)
name := strings.ReplaceAll(arch, "_", "-")
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: name,
Files: map[string]string{"test.gguf": digest},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
cfg := readCreatedModelConfig(t, name)
if cfg.Renderer != "nemotron-3-nano" {
t.Fatalf("expected renderer %q, got %q", "nemotron-3-nano", cfg.Renderer)
}
if cfg.Parser != "nemotron-3-nano" {
t.Fatalf("expected parser %q, got %q", "nemotron-3-nano", cfg.Parser)
}
})
}
}
func TestCreateNemotronHDefaultsKeepExplicitRendererParser(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, arch := range []string{"nemotron_h", "nemotron_h_moe", "nemotron_h_omni"} {
t.Run(arch, func(t *testing.T) {
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, ggml.KV{
"general.architecture": arch,
}, nil)
const (
renderer = "custom-renderer"
parser = "custom-parser"
)
name := strings.ReplaceAll(arch, "_", "-") + "-custom"
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: name,
Files: map[string]string{"test.gguf": digest},
Renderer: renderer,
Parser: parser,
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
cfg := readCreatedModelConfig(t, name)
if cfg.Renderer != renderer {
t.Fatalf("expected renderer %q, got %q", renderer, cfg.Renderer)
}
if cfg.Parser != parser {
t.Fatalf("expected parser %q, got %q", parser, cfg.Parser)
}
})
}
}
func TestDetectModelTypeFromFiles(t *testing.T) {
t.Run("gguf file", func(t *testing.T) {
_, digest := createBinFile(t, nil, nil)
+239
View File
@@ -1473,6 +1473,119 @@ func TestGenerateLogprobs(t *testing.T) {
})
}
func TestGenerateLogprobsWithBuiltinParser(t *testing.T) {
gin.SetMode(gin.TestMode)
mock := mockRunner{}
mock.CompletionFn = func(ctx context.Context, r llm.CompletionRequest, fn func(r llm.CompletionResponse)) error {
responses := []llm.CompletionResponse{
{
Content: "h",
Logprobs: []llm.Logprob{{TokenLogprob: llm.TokenLogprob{Token: "h", Logprob: -0.1}}},
},
{
Content: "i</think>Hello",
Logprobs: []llm.Logprob{{TokenLogprob: llm.TokenLogprob{Token: "hi", Logprob: -0.2}}},
},
{
Content: " world",
Done: true,
DoneReason: llm.DoneReasonStop,
Logprobs: []llm.Logprob{{TokenLogprob: llm.TokenLogprob{Token: " world", Logprob: -0.3}}},
},
}
for _, resp := range responses {
select {
case <-ctx.Done():
return ctx.Err()
default:
fn(resp)
}
}
return nil
}
s := Server{
sched: &Scheduler{
pendingReqCh: make(chan *LlmRequest, 1),
finishedReqCh: make(chan *LlmRequest, 1),
expiredCh: make(chan *runnerRef, 1),
unloadedCh: make(chan any, 1),
loaded: make(map[string]*runnerRef),
newServerFn: newMockServer(&mock),
getGpuFn: getGpuFn,
getSystemInfoFn: getSystemInfoFn,
waitForRecovery: 250 * time.Millisecond,
loadFn: func(req *LlmRequest, _ ml.SystemInfo, _ []ml.DeviceInfo, _ bool) bool {
req.successCh <- &runnerRef{llama: &mock}
return false
},
},
}
go s.sched.Run(t.Context())
_, digest := createBinFile(t, ggml.KV{
"general.architecture": "llama",
"llama.block_count": uint32(1),
"llama.context_length": uint32(8192),
"llama.embedding_length": uint32(4096),
"llama.attention.head_count": uint32(32),
"llama.attention.head_count_kv": uint32(8),
"tokenizer.ggml.tokens": []string{""},
"tokenizer.ggml.scores": []float32{0},
"tokenizer.ggml.token_type": []int32{0},
}, []*ggml.Tensor{
{Name: "token_embd.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.attn_norm.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.ffn_down.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.ffn_gate.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.ffn_up.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.ffn_norm.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.attn_k.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.attn_output.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.attn_q.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.attn_v.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "output.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
})
if w := createRequest(t, s.CreateHandler, api.CreateRequest{
Model: "test-generate-logprob-parser",
Files: map[string]string{"file.gguf": digest},
Parser: "deepseek3",
Template: `{{ .Prompt }}`,
Stream: &stream,
}); w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
noStream := false
w := createRequest(t, s.GenerateHandler, api.GenerateRequest{
Model: "test-generate-logprob-parser",
Prompt: "Why is the sky blue?",
Stream: &noStream,
Logprobs: true,
Options: map[string]any{
"temperature": 0,
},
})
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp api.GenerateResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if got := len(resp.Logprobs); got != 3 {
t.Fatalf("expected 3 logprob entries, got %d", got)
}
}
func TestChatLogprobs(t *testing.T) {
t.Run("invalid top_logprobs negative", func(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -2108,6 +2221,132 @@ func TestChatWithPromptEndingInThinkTag(t *testing.T) {
})
}
// TestChatFormatWithThinkFalse verifies that when a model uses a builtin
// parser that supports thinking (e.g. gemma4) and the request explicitly
// disables thinking (think=false), the format constraint is passed to the
// first and only completion call. Previously, format was deferred for all
// thinking-capable parsers and only re-applied after an end-of-thinking
// transition — a transition that never fires when thinking is off. See
// https://github.com/ollama/ollama/issues/15260.
func TestChatFormatWithThinkFalse(t *testing.T) {
gin.SetMode(gin.TestMode)
mock := &mockRunner{
CompletionResponse: llm.CompletionResponse{
Done: true,
DoneReason: llm.DoneReasonStop,
PromptEvalCount: 1,
PromptEvalDuration: 1,
EvalCount: 1,
EvalDuration: 1,
},
}
s := &Server{
sched: &Scheduler{
pendingReqCh: make(chan *LlmRequest, 1),
finishedReqCh: make(chan *LlmRequest, 1),
expiredCh: make(chan *runnerRef, 1),
unloadedCh: make(chan any, 1),
loaded: make(map[string]*runnerRef),
newServerFn: newMockServer(mock),
getGpuFn: getGpuFn,
getSystemInfoFn: getSystemInfoFn,
waitForRecovery: 250 * time.Millisecond,
loadFn: func(req *LlmRequest, _ ml.SystemInfo, _ []ml.DeviceInfo, _ bool) bool {
time.Sleep(time.Millisecond)
req.successCh <- &runnerRef{llama: mock}
return false
},
},
}
go s.sched.Run(t.Context())
_, digest := createBinFile(t, ggml.KV{
"general.architecture": "llama",
"llama.block_count": uint32(1),
"llama.context_length": uint32(8192),
"llama.embedding_length": uint32(4096),
"llama.attention.head_count": uint32(32),
"llama.attention.head_count_kv": uint32(8),
"tokenizer.ggml.tokens": []string{""},
"tokenizer.ggml.scores": []float32{0},
"tokenizer.ggml.token_type": []int32{0},
}, []*ggml.Tensor{
{Name: "token_embd.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.attn_norm.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.ffn_down.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.ffn_gate.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.ffn_up.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.ffn_norm.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.attn_k.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.attn_output.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.attn_q.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.attn_v.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "output.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
})
// Use the gemma4 builtin parser — it reports HasThinkingSupport=true, which
// adds CapabilityThinking to the model and previously triggered deferral of
// the format even when the user passed think=false.
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Model: "test-gemma4-parser",
Files: map[string]string{"file.gguf": digest},
Parser: "gemma4",
Template: `{{- range .Messages }}{{ .Role }}: {{ .Content }}{{ end }}`,
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("create: expected status 200, got %d: %s", w.Code, w.Body.String())
}
format := json.RawMessage(`{"type":"object","properties":{"answer":{"type":"string"}},"required":["answer"]}`)
var (
requestsMu sync.Mutex
requests []llm.CompletionRequest
)
mock.CompletionFn = func(ctx context.Context, r llm.CompletionRequest, fn func(r llm.CompletionResponse)) error {
requestsMu.Lock()
requests = append(requests, r)
requestsMu.Unlock()
fn(llm.CompletionResponse{
Content: `{"answer":"42"}`,
Done: true,
DoneReason: llm.DoneReasonStop,
PromptEvalCount: 1,
PromptEvalDuration: 1,
EvalCount: 1,
EvalDuration: 1,
})
return nil
}
streamRequest := false
think := false
w = createRequest(t, s.ChatHandler, api.ChatRequest{
Model: "test-gemma4-parser",
Messages: []api.Message{{Role: "user", Content: "Respond in JSON."}},
Think: &api.ThinkValue{Value: think},
Stream: &streamRequest,
Format: format,
})
if w.Code != http.StatusOK {
t.Fatalf("chat: expected status 200, got %d: %s", w.Code, w.Body.String())
}
if len(requests) != 1 {
t.Fatalf("expected a single completion call, got %d", len(requests))
}
if !bytes.Equal([]byte(format), []byte(requests[0].Format)) {
t.Errorf("expected first completion format to match the request format, got %q", string(requests[0].Format))
}
}
func TestGenerateUnload(t *testing.T) {
gin.SetMode(gin.TestMode)
+1 -1
View File
@@ -418,7 +418,7 @@ func (s *Scheduler) load(req *LlmRequest, systemInfo ml.SystemInfo, gpus []ml.De
// Some architectures are not safe with num_parallel > 1.
// ref: https://github.com/ollama/ollama/issues/4165
if slices.Contains([]string{"mllama", "qwen3vl", "qwen3vlmoe", "qwen35", "qwen35moe", "qwen3next", "lfm2", "lfm2moe", "nemotron_h", "nemotron_h_moe"}, req.model.Config.ModelFamily) && numParallel != 1 {
if slices.Contains([]string{"mllama", "qwen3vl", "qwen3vlmoe", "qwen35", "qwen35moe", "qwen3next", "lfm2", "lfm2moe", "nemotron_h", "nemotron_h_moe", "nemotron_h_omni"}, req.model.Config.ModelFamily) && numParallel != 1 {
numParallel = 1
slog.Warn("model architecture does not currently support parallel requests", "architecture", req.model.Config.ModelFamily)
}
+1 -1
View File
@@ -84,7 +84,7 @@ func (bpe *BytePairEncoding) split(s string) iter.Seq[string] {
var offset int
for m, _ := re.FindRunesMatch(r); m != nil; m, _ = re.FindNextMatch(m) {
if offset-m.Index != 0 {
if !yield(string(r[:m.Index])) {
if !yield(string(r[offset:m.Index])) {
return
}
}
+55
View File
@@ -545,6 +545,61 @@ func BenchmarkBytePairEncoding(b *testing.B) {
}
}
func TestBytePairEncodingSplitMultipleRegexpsPreservesOffsets(t *testing.T) {
t.Parallel()
bpe := NewBytePairEncoding(
nil,
`(?:\r?\n)+(?!\r?\n)`,
`(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+`,
)
input := "One line\nTwo lines\n\nThree"
got := slices.Collect(bpe.split(input))
want := []string{"One", " line", "\n", "Two", " lines", "\n\n", "Three"}
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("split mismatch (-want +got):\n%s", diff)
}
}
func TestBytePairEncodingSplitRefactPreservesOffsets(t *testing.T) {
t.Parallel()
bpe := NewBytePairEncoding(
nil,
`\p{N}`,
`'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+`,
)
input := "One line\nTwo lines\n\nThree"
got := slices.Collect(bpe.split(input))
want := []string{"One", " line", "\n", "Two", " lines", "\n", "\n", "Three"}
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("split mismatch (-want +got):\n%s", diff)
}
}
func TestBytePairEncodingSplitDeepSeekV3PreservesOffsets(t *testing.T) {
t.Parallel()
bpe := NewBytePairEncoding(
nil,
"\\p{N}{1,3}",
`[一-龥぀-ゟ゠-ヿ]+`,
"[!\"#$%&'()*+,\\-./:;<=>?@\\[\\\\\\]^_`{|}~][A-Za-z]+|[^\\r\\n\\p{L}\\p{P}\\p{S}]?[\\p{L}\\p{M}]+| ?[\\p{P}\\p{S}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
)
input := "One line\nTwo lines\n\nThree"
got := slices.Collect(bpe.split(input))
want := []string{"One", " line", "\n", "Two", " lines", "\n\n", "Three"}
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("split mismatch (-want +got):\n%s", diff)
}
}
func TestSplit(t *testing.T) {
cases := []struct {
name string
+27 -5
View File
@@ -18,6 +18,7 @@ import (
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/manifest"
modelparsers "github.com/ollama/ollama/model/parsers"
"github.com/ollama/ollama/parser"
"github.com/ollama/ollama/progress"
"github.com/ollama/ollama/types/model"
@@ -132,11 +133,11 @@ func CreateModel(opts CreateOptions, p *progress.Progress) error {
if isSafetensors {
modelType = "safetensors model"
spinnerKey = "create"
capabilities = inferSafetensorsCapabilities(opts.ModelDir)
// Set parser and renderer name based on architecture
parserName = getParserName(opts.ModelDir)
rendererName = getRendererName(opts.ModelDir)
capabilities = inferSafetensorsCapabilities(opts.ModelDir, resolveParserName(opts.Modelfile, parserName))
} else {
modelType = "image generation model"
spinnerKey = "imagegen"
@@ -183,7 +184,7 @@ func CreateModel(opts CreateOptions, p *progress.Progress) error {
return nil
}
func inferSafetensorsCapabilities(modelDir string) []string {
func inferSafetensorsCapabilities(modelDir, parserName string) []string {
capabilities := []string{"completion"}
// Qwen3.5 multimodal checkpoints use ConditionalGeneration architectures.
@@ -195,7 +196,16 @@ func inferSafetensorsCapabilities(modelDir string) []string {
capabilities = append(capabilities, "audio")
}
if supportsThinking(modelDir) {
var builtinParser modelparsers.Parser
if parserName != "" {
builtinParser = modelparsers.ParserForName(parserName)
}
if builtinParser != nil && builtinParser.HasToolSupport() {
capabilities = append(capabilities, "tools")
}
if supportsThinking(modelDir) || (builtinParser != nil && builtinParser.HasThinkingSupport()) {
capabilities = append(capabilities, "thinking")
}
@@ -453,8 +463,8 @@ func createModelfileLayers(mf *ModelfileConfig) ([]manifest.Layer, error) {
return layers, nil
}
// supportsThinking checks if the model supports thinking mode based on its architecture.
// This reads the config.json from the model directory and checks the architectures field.
// supportsThinking checks if the model supports thinking mode based on known
// architectures that do not expose a cleaner signal in their local metadata.
func supportsThinking(modelDir string) bool {
configPath := filepath.Join(modelDir, "config.json")
data, err := os.ReadFile(configPath)
@@ -554,6 +564,9 @@ func getParserName(modelDir string) string {
// Check architectures for known parsers
for _, arch := range cfg.Architectures {
archLower := strings.ToLower(arch)
if strings.Contains(archLower, "laguna") {
return "laguna"
}
if strings.Contains(archLower, "glm4") || strings.Contains(archLower, "glm-4") {
return "glm-4.7"
}
@@ -571,6 +584,9 @@ func getParserName(modelDir string) string {
// Also check model_type
if cfg.ModelType != "" {
typeLower := strings.ToLower(cfg.ModelType)
if strings.Contains(typeLower, "laguna") {
return "laguna"
}
if strings.Contains(typeLower, "glm4") || strings.Contains(typeLower, "glm-4") {
return "glm-4.7"
}
@@ -608,6 +624,9 @@ func getRendererName(modelDir string) string {
// Check architectures for known renderers
for _, arch := range cfg.Architectures {
archLower := strings.ToLower(arch)
if strings.Contains(archLower, "laguna") {
return "laguna"
}
if strings.Contains(archLower, "gemma4") {
return "gemma4"
}
@@ -625,6 +644,9 @@ func getRendererName(modelDir string) string {
// Also check model_type
if cfg.ModelType != "" {
typeLower := strings.ToLower(cfg.ModelType)
if strings.Contains(typeLower, "laguna") {
return "laguna"
}
if strings.Contains(typeLower, "gemma4") {
return "gemma4"
}
+65 -1
View File
@@ -352,7 +352,7 @@ func TestInferSafetensorsCapabilities(t *testing.T) {
t.Fatal(err)
}
if got := inferSafetensorsCapabilities(dir); !slices.Equal(got, tt.want) {
if got := inferSafetensorsCapabilities(dir, ""); !slices.Equal(got, tt.want) {
t.Fatalf("inferSafetensorsCapabilities() = %#v, want %#v", got, tt.want)
}
})
@@ -554,6 +554,11 @@ func TestSupportsThinking(t *testing.T) {
configJSON: `{"model_type": "deepseek"}`,
want: true,
},
{
name: "laguna architecture without template",
configJSON: `{"architectures": ["LagunaForCausalLM"], "model_type": "laguna"}`,
want: false,
},
{
name: "empty config",
configJSON: `{}`,
@@ -584,6 +589,55 @@ func TestSupportsThinking_NoConfig(t *testing.T) {
}
}
func TestInferSafetensorsCapabilitiesFromParser(t *testing.T) {
tests := []struct {
name string
parserName string
want []string
}{
{
name: "laguna tools and thinking",
parserName: "laguna",
want: []string{"completion", "tools", "thinking"},
},
{
name: "functiongemma tools only",
parserName: "functiongemma",
want: []string{"completion", "tools"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
}
if got := inferSafetensorsCapabilities(dir, tt.parserName); !slices.Equal(got, tt.want) {
t.Fatalf("inferSafetensorsCapabilities() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestInferSafetensorsCapabilitiesLaguna(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"architectures": ["LagunaForCausalLM"], "model_type": "laguna"}`), 0o644); err != nil {
t.Fatal(err)
}
got := inferSafetensorsCapabilities(dir, "laguna")
for _, want := range []string{"completion", "tools", "thinking"} {
if !slices.Contains(got, want) {
t.Fatalf("capabilities %v missing %q", got, want)
}
}
if slices.Contains(got, "vision") || slices.Contains(got, "audio") {
t.Fatalf("unexpected non-text capability in %v", got)
}
}
func TestGetParserName(t *testing.T) {
tests := []struct {
name string
@@ -615,6 +669,11 @@ func TestGetParserName(t *testing.T) {
configJSON: `{"model_type": "qwen3"}`,
want: "qwen3",
},
{
name: "laguna model",
configJSON: `{"architectures": ["LagunaForCausalLM"], "model_type": "laguna"}`,
want: "laguna",
},
{
name: "no config",
configJSON: `{}`,
@@ -660,6 +719,11 @@ func TestGetRendererName(t *testing.T) {
configJSON: `{"architectures": ["LlamaForCausalLM"]}`,
want: "",
},
{
name: "laguna model",
configJSON: `{"architectures": ["LagunaForCausalLM"], "model_type": "laguna"}`,
want: "laguna",
},
}
for _, tt := range tests {
+9 -5
View File
@@ -70,9 +70,13 @@ func loadAndQuantizeArray(r io.Reader, name, quantize string, arrays map[string]
if info, ok := header[inputKey]; ok && info.Dtype == "F8_E4M3" {
scaleKey := inputKey + ".scale_inv"
scaleInv := st.Get(scaleKey)
if scaleInv == nil {
scaleKey = inputKey + ".scale"
scaleInv = st.Get(scaleKey)
}
if scaleInv == nil {
st.Free()
return tmpPath, nil, nil, fmt.Errorf("missing companion tensor %q for fp8 source tensor %q", scaleKey, inputKey)
return tmpPath, nil, nil, fmt.Errorf("missing companion tensor %q or %q for fp8 source tensor %q", inputKey+".scale_inv", inputKey+".scale", inputKey)
}
arr, err = decodeSourceFP8Tensor(arr, scaleInv)
if err != nil {
@@ -560,13 +564,13 @@ func safetensorsKey(preferred string, header map[string]safetensorsHeaderEntry)
return keys[0], nil
}
func decodeSourceFP8Tensor(weight, scaleInv *mlx.Array) (*mlx.Array, error) {
if weight == nil || scaleInv == nil {
func decodeSourceFP8Tensor(weight, scale *mlx.Array) (*mlx.Array, error) {
if weight == nil || scale == nil {
return nil, fmt.Errorf("fp8 weight and scale tensors are required")
}
weightShape := weight.Dims()
scaleShape := scaleInv.Dims()
scaleShape := scale.Dims()
if len(weightShape) != 2 || len(scaleShape) != 2 {
return nil, fmt.Errorf("expected 2D fp8 weight and scale tensors, got %v and %v", weightShape, scaleShape)
}
@@ -596,7 +600,7 @@ func decodeSourceFP8Tensor(weight, scaleInv *mlx.Array) (*mlx.Array, error) {
}
decoded = mlx.Reshape(decoded, int32(scaleShape[0]), int32(blockRows), int32(scaleShape[1]), int32(blockCols))
decoded = mlx.Mul(decoded, mlx.ExpandDims(mlx.ExpandDims(scaleInv, 1), 3))
decoded = mlx.Mul(decoded, mlx.ExpandDims(mlx.ExpandDims(scale, 1), 3))
decoded = mlx.Reshape(decoded, int32(rows+padBottom), int32(cols+padSide))
if padBottom > 0 || padSide > 0 {
decoded = mlx.SliceStartStop(decoded, []int32{0, 0}, []int32{int32(rows), int32(cols)})
+24
View File
@@ -0,0 +1,24 @@
package client
import (
"testing"
"github.com/ollama/ollama/x/mlxrunner/mlx"
)
func TestDecodeSourceFP8TensorAcceptsWeightScale(t *testing.T) {
if err := mlx.CheckInit(); err != nil {
t.Skipf("MLX unavailable: %v", err)
}
weight := mlx.FromValues([]uint8{0, 1, 2, 3}, 2, 2)
scale := mlx.FromValues([]float32{1}, 1, 1).AsType(mlx.DTypeBFloat16)
got, err := decodeSourceFP8Tensor(weight, scale)
if err != nil {
t.Fatal(err)
}
mlx.Eval(got)
if dims := got.Dims(); len(dims) != 2 || dims[0] != 2 || dims[1] != 2 {
t.Fatalf("decoded dims = %v, want [2 2]", dims)
}
}
+809 -27
View File
File diff suppressed because it is too large. Load diff
+592 -2
View File
@@ -4,7 +4,9 @@ import (
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"math"
"os"
"path/filepath"
"slices"
@@ -59,6 +61,43 @@ func TestIsTensorModelDir(t *testing.T) {
}
}
func TestValidateScalarFloat32TensorData(t *testing.T) {
td := st.NewTensorDataFromBytes("linear.weight_scale_2", "F32", []int32{}, encodeFloat32s(2))
got, err := validateScalarFloat32TensorData(td, "linear.weight.global_scale")
if err != nil {
t.Fatalf("validateScalarFloat32TensorData returned error: %v", err)
}
if got.Name != "linear.weight.global_scale" {
t.Fatalf("name = %q, want %q", got.Name, "linear.weight.global_scale")
}
if got.Dtype != "F32" {
t.Fatalf("dtype = %q, want F32", got.Dtype)
}
if len(got.Shape) != 0 {
t.Fatalf("shape = %v, want scalar", got.Shape)
}
}
func TestValidateScalarFloat32TensorDataRejectsNonScalar(t *testing.T) {
td := st.NewTensorDataFromBytes("linear.weight_scale_2", "F32", []int32{2}, encodeFloat32s(2, 4))
_, err := validateScalarFloat32TensorData(td, "linear.weight.global_scale")
if err == nil || !strings.Contains(err.Error(), "expected scalar F32 tensor") {
t.Fatalf("validateScalarFloat32TensorData error = %v, want scalar-shape failure", err)
}
}
func TestInvertScalarFloat32TensorDataRejectsNonF32(t *testing.T) {
td := st.NewTensorDataFromBytes("linear.weight_global_scale", "BF16", []int32{}, []byte{0, 0})
_, err := invertScalarFloat32TensorData(td, "linear.weight.global_scale")
if err == nil || !strings.Contains(err.Error(), "expected F32 tensor") {
t.Fatalf("invertScalarFloat32TensorData error = %v, want dtype failure", err)
}
}
func TestIsSafetensorsModelDir(t *testing.T) {
tests := []struct {
name string
@@ -246,6 +285,41 @@ func readSingleTensorRaw(t *testing.T, data []byte) []byte {
return nil
}
func encodeFloat32s(vals ...float32) []byte {
raw := make([]byte, 4*len(vals))
for i, v := range vals {
binary.LittleEndian.PutUint32(raw[i*4:(i+1)*4], math.Float32bits(v))
}
return raw
}
func readPackedTensorRaw(t *testing.T, data []byte, tensorName string) []byte {
t.Helper()
var headerSize uint64
if err := binary.Read(bytes.NewReader(data[:8]), binary.LittleEndian, &headerSize); err != nil {
t.Fatalf("failed to read header size: %v", err)
}
var header map[string]struct {
Dtype string `json:"dtype"`
Shape []int32 `json:"shape"`
DataOffsets [2]int `json:"data_offsets"`
}
if err := json.Unmarshal(data[8:8+headerSize], &header); err != nil {
t.Fatalf("failed to parse header: %v", err)
}
info, ok := header[tensorName]
if !ok {
t.Fatalf("tensor %q not found in header", tensorName)
}
start := 8 + int(headerSize) + info.DataOffsets[0]
end := 8 + int(headerSize) + info.DataOffsets[1]
return data[start:end]
}
func readSafetensorsHeaderNames(t *testing.T, data []byte) []string {
t.Helper()
@@ -612,10 +686,22 @@ func TestCreateSafetensorsModel_HFFP8AutoConvertsToMXFP8(t *testing.T) {
writeManifest := func(modelName string, config LayerInfo, layers []LayerInfo) error { return nil }
if err := CreateSafetensorsModel("test-model", dir, "", createLayer, createTensorLayer, writeManifest, func(string) {}); err != nil {
var statusMessages []string
progressFn := func(status string) {
statusMessages = append(statusMessages, status)
}
if err := CreateSafetensorsModel("test-model", dir, "", createLayer, createTensorLayer, writeManifest, progressFn); err != nil {
t.Fatalf("CreateSafetensorsModel failed: %v", err)
}
if len(statusMessages) == 0 {
t.Fatal("no status messages received")
}
if got, want := statusMessages[0], "importing model.safetensors (4 tensors, converting source E4M3 block-FP8 to MLX mxfp8)"; got != want {
t.Fatalf("status = %q, want %q", got, want)
}
if got := quantizeByName["linear.weight"]; got != "mxfp8" {
t.Fatalf("linear.weight quantization = %q, want %q", got, "mxfp8")
}
@@ -643,6 +729,166 @@ func TestCreateSafetensorsModel_HFFP8AutoConvertsToMXFP8(t *testing.T) {
}
}
func TestCreateSafetensorsModel_CompressedTensorsFP8WeightScale(t *testing.T) {
dir := t.TempDir()
configJSON := `{
"model_type": "test",
"architectures": ["TestModel"],
"compression_config": {
"quant_method": "compressed-tensors",
"format": "float-quantized",
"config_groups": {
"group_0": {
"format": "float-quantized",
"weights": {
"type": "float",
"num_bits": 8,
"block_structure": [128, 128]
}
}
}
}
}`
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(configJSON), 0o644); err != nil {
t.Fatalf("failed to write config.json: %v", err)
}
createTestSafetensors(t, filepath.Join(dir, "model.safetensors"), []*st.TensorData{
st.NewTensorDataFromBytes("linear.weight", "F8_E4M3", []int32{2, 2}, []byte{1, 2, 3, 4}),
st.NewTensorDataFromBytes("linear.weight_scale", "BF16", []int32{1, 1}, make([]byte, 2)),
st.NewTensorDataFromBytes("norm.weight", "BF16", []int32{2}, make([]byte, 4)),
})
quantizeByName := make(map[string]string)
headerNamesByName := make(map[string][]string)
createLayer := func(r io.Reader, mediaType, name string) (LayerInfo, error) {
if _, err := io.ReadAll(r); err != nil {
return LayerInfo{}, err
}
return LayerInfo{Name: name, Digest: "sha256:" + name, MediaType: mediaType}, nil
}
createTensorLayer := func(r io.Reader, name, dtype string, shape []int32, quantize string) ([]LayerInfo, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
quantizeByName[name] = quantize
headerNamesByName[name] = readSafetensorsHeaderNames(t, data)
return []LayerInfo{{Name: name, Digest: "sha256:tensor_" + name, MediaType: "application/vnd.ollama.image.tensor"}}, nil
}
writeManifest := func(modelName string, config LayerInfo, layers []LayerInfo) error { return nil }
var statusMessages []string
progressFn := func(status string) {
statusMessages = append(statusMessages, status)
}
if err := CreateSafetensorsModel("test-model", dir, "", createLayer, createTensorLayer, writeManifest, progressFn); err != nil {
t.Fatalf("CreateSafetensorsModel failed: %v", err)
}
if len(statusMessages) == 0 {
t.Fatal("no status messages received")
}
if got, want := statusMessages[0], "importing model.safetensors (3 tensors, converting source E4M3 block-FP8 to MLX mxfp8)"; got != want {
t.Fatalf("status = %q, want %q", got, want)
}
if got := quantizeByName["linear.weight"]; got != "mxfp8" {
t.Fatalf("linear.weight quantization = %q, want mxfp8", got)
}
if _, ok := quantizeByName["linear.weight_scale"]; ok {
t.Fatal("linear.weight_scale should not be imported as a standalone tensor")
}
if got := headerNamesByName["linear.weight"]; !slices.Equal(got, []string{"linear.weight", "linear.weight.scale"}) {
t.Fatalf("linear.weight blob tensors = %v, want %v", got, []string{"linear.weight", "linear.weight.scale"})
}
}
func TestCreateSafetensorsModel_HFFP8SourceCanConvertToNVFP4(t *testing.T) {
dir := t.TempDir()
configJSON := `{
"model_type": "test",
"architectures": ["TestModel"],
"quantization_config": {"quant_method": "fp8", "weight_block_size": [128, 128]}
}`
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(configJSON), 0o644); err != nil {
t.Fatalf("failed to write config.json: %v", err)
}
createTestSafetensors(t, filepath.Join(dir, "model.safetensors"), []*st.TensorData{
st.NewTensorDataFromBytes("linear.weight", "F8_E4M3", []int32{128, 128}, make([]byte, 128*128)),
st.NewTensorDataFromBytes("linear.weight_scale_inv", "BF16", []int32{1, 1}, make([]byte, 2)),
st.NewTensorDataFromBytes("model.layers.0.mlp.experts.0.down_proj.weight", "F8_E4M3", []int32{128, 128}, make([]byte, 128*128)),
st.NewTensorDataFromBytes("model.layers.0.mlp.experts.0.down_proj.weight_scale_inv", "BF16", []int32{1, 1}, make([]byte, 2)),
st.NewTensorDataFromBytes("model.layers.0.self_attn.q_proj.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("model.embed_tokens.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("lm_head.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("model.layers.0.mlp.gate.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("norm.weight", "BF16", []int32{128}, make([]byte, 256)),
})
quantizeByName := make(map[string]string)
headerNamesByName := make(map[string][]string)
createLayer := func(r io.Reader, mediaType, name string) (LayerInfo, error) {
if _, err := io.ReadAll(r); err != nil {
return LayerInfo{}, err
}
return LayerInfo{Name: name, Digest: "sha256:" + name, MediaType: mediaType}, nil
}
createTensorLayer := func(r io.Reader, name, dtype string, shape []int32, quantize string) ([]LayerInfo, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
quantizeByName[name] = quantize
headerNamesByName[name] = readSafetensorsHeaderNames(t, data)
return []LayerInfo{{Name: name, Digest: "sha256:tensor_" + name, MediaType: "application/vnd.ollama.image.tensor"}}, nil
}
writeManifest := func(modelName string, config LayerInfo, layers []LayerInfo) error { return nil }
var statusMessages []string
progressFn := func(status string) {
statusMessages = append(statusMessages, status)
}
if err := CreateSafetensorsModel("test-model", dir, "nvfp4", createLayer, createTensorLayer, writeManifest, progressFn); err != nil {
t.Fatalf("CreateSafetensorsModel failed: %v", err)
}
if len(statusMessages) == 0 {
t.Fatal("no status messages received")
}
if got, want := statusMessages[0], "importing model.safetensors (9 tensors, converting source E4M3 block-FP8 to MLX nvfp4)"; got != want {
t.Fatalf("status = %q, want %q", got, want)
}
if got := quantizeByName["linear.weight"]; got != "nvfp4" {
t.Fatalf("linear.weight quantization = %q, want nvfp4", got)
}
if got := quantizeByName["model.layers.0.mlp.experts.0.down_proj.weight"]; got != "mxfp8" {
t.Fatalf("source fp8 down_proj quantization = %q, want mxfp8", got)
}
for _, name := range []string{
"model.layers.0.self_attn.q_proj.weight",
"model.embed_tokens.weight",
"lm_head.weight",
} {
if got := quantizeByName[name]; got != "mxfp8" {
t.Fatalf("%s quantization = %q, want mxfp8", name, got)
}
}
if got := quantizeByName["model.layers.0.mlp.gate.weight"]; got != "" {
t.Fatalf("router gate quantization = %q, want empty", got)
}
if got := quantizeByName["norm.weight"]; got != "" {
t.Fatalf("norm.weight quantization = %q, want empty", got)
}
if got := headerNamesByName["linear.weight"]; !slices.Equal(got, []string{"linear.weight", "linear.weight.scale_inv"}) {
t.Fatalf("linear.weight blob tensors = %v, want %v", got, []string{"linear.weight", "linear.weight.scale_inv"})
}
}
func TestCreateSafetensorsModel_RejectsRequantizingQuantizedSources(t *testing.T) {
tests := []struct {
name string
@@ -670,7 +916,20 @@ func TestCreateSafetensorsModel_RejectsRequantizingQuantizedSources(t *testing.T
st.NewTensorDataFromBytes("linear.weight", "F8_E4M3", []int32{2, 2}, []byte{1, 2, 3, 4}),
st.NewTensorDataFromBytes("linear.weight_scale_inv", "BF16", []int32{1, 1}, make([]byte, 2)),
},
wantErr: `cannot requantize already-quantized fp8 source model with --quantize "int4"`,
wantErr: `cannot convert already-quantized fp8 source model with --quantize "int4"`,
},
{
name: "packed nvfp4 source",
configJSON: `{
"model_type": "test",
"architectures": ["TestModel"],
"compression_config": {"format": "nvfp4-pack-quantized"}
}`,
tensors: []*st.TensorData{
st.NewTensorDataFromBytes("linear.weight_packed", "U8", []int32{16, 8}, make([]byte, 128)),
st.NewTensorDataFromBytes("linear.weight_scale", "F8_E4M3", []int32{16, 1}, make([]byte, 16)),
},
wantErr: `cannot requantize already-quantized source model with --quantize "int4"`,
},
}
@@ -701,6 +960,317 @@ func TestCreateSafetensorsModel_RejectsRequantizingQuantizedSources(t *testing.T
}
}
func TestCreateSafetensorsModel_PackedNVFP4PreservesSourceLayout(t *testing.T) {
dir := t.TempDir()
configJSON := `{
"model_type": "test",
"architectures": ["TestModel"],
"compression_config": {"format": "nvfp4-pack-quantized"}
}`
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(configJSON), 0o644); err != nil {
t.Fatalf("failed to write config.json: %v", err)
}
createTestSafetensors(t, filepath.Join(dir, "model.safetensors"), []*st.TensorData{
st.NewTensorDataFromBytes("linear.weight_packed", "U8", []int32{16, 8}, make([]byte, 128)),
st.NewTensorDataFromBytes("linear.weight_scale", "F8_E4M3", []int32{16, 1}, make([]byte, 16)),
st.NewTensorDataFromBytes("linear.weight_global_scale", "F32", []int32{}, encodeFloat32s(4)),
st.NewTensorDataFromBytes("linear.input_global_scale", "F32", []int32{}, encodeFloat32s(8)),
st.NewTensorDataFromBytes("norm.weight", "BF16", []int32{16}, make([]byte, 32)),
})
var statusMessages []string
layerHeaders := make(map[string]map[string]json.RawMessage)
layerData := make(map[string][]byte)
var tensorLayerNames []string
createLayer := func(r io.Reader, mediaType, name string) (LayerInfo, error) {
data, err := io.ReadAll(r)
if err != nil {
return LayerInfo{}, err
}
if mediaType == "application/vnd.ollama.image.tensor" {
if len(data) < 8 {
return LayerInfo{}, io.ErrUnexpectedEOF
}
var headerSize uint64
if err := binary.Read(bytes.NewReader(data[:8]), binary.LittleEndian, &headerSize); err != nil {
return LayerInfo{}, err
}
var header map[string]json.RawMessage
if err := json.Unmarshal(data[8:8+headerSize], &header); err != nil {
return LayerInfo{}, err
}
layerHeaders[name] = header
layerData[name] = data
}
return LayerInfo{Name: name, Digest: "sha256:" + name, MediaType: mediaType}, nil
}
createTensorLayer := func(r io.Reader, name, dtype string, shape []int32, quantize string) ([]LayerInfo, error) {
if _, err := io.ReadAll(r); err != nil {
return nil, err
}
tensorLayerNames = append(tensorLayerNames, name)
return []LayerInfo{{Name: name, Digest: "sha256:tensor_" + name, MediaType: "application/vnd.ollama.image.tensor"}}, nil
}
writeManifest := func(modelName string, config LayerInfo, layers []LayerInfo) error { return nil }
progressFn := func(status string) { statusMessages = append(statusMessages, status) }
if err := CreateSafetensorsModel("test-model", dir, "", createLayer, createTensorLayer, writeManifest, progressFn); err != nil {
t.Fatalf("CreateSafetensorsModel failed: %v", err)
}
if len(statusMessages) == 0 {
t.Fatal("no status messages received")
}
if got, want := statusMessages[0], "importing model.safetensors (5 tensors, preserving source quantization)"; got != want {
t.Fatalf("status = %q, want %q", got, want)
}
if slices.Contains(tensorLayerNames, "linear.weight_scale") || slices.Contains(tensorLayerNames, "linear.weight_global_scale") || slices.Contains(tensorLayerNames, "linear.input_global_scale") {
t.Fatalf("packed nvfp4 companions unexpectedly emitted as standalone tensor layers: %v", tensorLayerNames)
}
packedHeader := layerHeaders["linear.weight"]
if packedHeader == nil {
t.Fatalf("missing packed layer header for linear.weight")
}
for _, key := range []string{
"linear.weight",
"linear.weight.scale",
"linear.weight.global_scale",
} {
if _, ok := packedHeader[key]; !ok {
t.Fatalf("packed header missing %s: %v", key, packedHeader)
}
}
if _, ok := packedHeader["linear.weight.input_global_scale"]; ok {
t.Fatalf("packed header unexpectedly includes input_global_scale: %v", packedHeader)
}
globalRaw := readPackedTensorRaw(t, layerData["linear.weight"], "linear.weight.global_scale")
if got := math.Float32frombits(binary.LittleEndian.Uint32(globalRaw)); got != 0.25 {
t.Fatalf("linear.weight.global_scale = %v, want 0.25", got)
}
var metadata map[string]string
if metaRaw, ok := packedHeader["__metadata__"]; ok {
if err := json.Unmarshal(metaRaw, &metadata); err != nil {
t.Fatalf("failed to parse metadata: %v", err)
}
}
if metadata["quant_type"] != "nvfp4" {
t.Fatalf("quant_type = %q, want %q", metadata["quant_type"], "nvfp4")
}
if metadata["group_size"] != "16" {
t.Fatalf("group_size = %q, want %q", metadata["group_size"], "16")
}
}
func TestCreateSafetensorsModel_PackedNVFP4CrossShardCompanions(t *testing.T) {
dir := t.TempDir()
configJSON := `{
"model_type": "test",
"architectures": ["TestModel"],
"compression_config": {"format": "nvfp4-pack-quantized"}
}`
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(configJSON), 0o644); err != nil {
t.Fatalf("failed to write config.json: %v", err)
}
createTestSafetensors(t, filepath.Join(dir, "model-00001-of-00002.safetensors"), []*st.TensorData{
st.NewTensorDataFromBytes("linear.weight_packed", "U8", []int32{16, 8}, make([]byte, 128)),
st.NewTensorDataFromBytes("norm.weight", "BF16", []int32{16}, make([]byte, 32)),
})
createTestSafetensors(t, filepath.Join(dir, "model-00002-of-00002.safetensors"), []*st.TensorData{
st.NewTensorDataFromBytes("linear.weight_scale", "F8_E4M3", []int32{16, 1}, make([]byte, 16)),
st.NewTensorDataFromBytes("linear.weight_global_scale", "F32", []int32{}, encodeFloat32s(2)),
st.NewTensorDataFromBytes("linear.input_global_scale", "F32", []int32{}, encodeFloat32s(8)),
})
indexJSON := `{
"metadata": {"total_size": 152},
"weight_map": {
"linear.weight_packed": "model-00001-of-00002.safetensors",
"norm.weight": "model-00001-of-00002.safetensors",
"linear.weight_scale": "model-00002-of-00002.safetensors",
"linear.weight_global_scale": "model-00002-of-00002.safetensors",
"linear.input_global_scale": "model-00002-of-00002.safetensors"
}
}`
if err := os.WriteFile(filepath.Join(dir, "model.safetensors.index.json"), []byte(indexJSON), 0o644); err != nil {
t.Fatalf("failed to write index: %v", err)
}
layerHeaders := make(map[string]map[string]json.RawMessage)
var tensorLayerNames []string
createLayer := func(r io.Reader, mediaType, name string) (LayerInfo, error) {
data, err := io.ReadAll(r)
if err != nil {
return LayerInfo{}, err
}
if mediaType == "application/vnd.ollama.image.tensor" {
var headerSize uint64
if err := binary.Read(bytes.NewReader(data[:8]), binary.LittleEndian, &headerSize); err != nil {
return LayerInfo{}, err
}
var header map[string]json.RawMessage
if err := json.Unmarshal(data[8:8+headerSize], &header); err != nil {
return LayerInfo{}, err
}
layerHeaders[name] = header
}
return LayerInfo{Name: name, Digest: "sha256:" + name, MediaType: mediaType}, nil
}
createTensorLayer := func(r io.Reader, name, dtype string, shape []int32, quantize string) ([]LayerInfo, error) {
if _, err := io.ReadAll(r); err != nil {
return nil, err
}
tensorLayerNames = append(tensorLayerNames, name)
return []LayerInfo{{Name: name, Digest: "sha256:tensor_" + name, MediaType: "application/vnd.ollama.image.tensor"}}, nil
}
writeManifest := func(modelName string, config LayerInfo, layers []LayerInfo) error { return nil }
packedCreator := func(groupName string, tensors []PackedTensorInput) (LayerInfo, error) {
return LayerInfo{}, fmt.Errorf("unexpected packedCreator call for %s", groupName)
}
if err := CreateSafetensorsModel("test-model", dir, "", createLayer, createTensorLayer, writeManifest, func(string) {}, packedCreator); err != nil {
t.Fatalf("CreateSafetensorsModel failed: %v", err)
}
if slices.Contains(tensorLayerNames, "linear.weight_packed") || slices.Contains(tensorLayerNames, "linear.weight_scale") || slices.Contains(tensorLayerNames, "linear.weight_global_scale") || slices.Contains(tensorLayerNames, "linear.input_global_scale") {
t.Fatalf("packed nvfp4 tensors unexpectedly emitted as standalone tensor layers: %v", tensorLayerNames)
}
packedHeader := layerHeaders["linear.weight"]
if packedHeader == nil {
t.Fatalf("missing packed layer header for linear.weight")
}
for _, key := range []string{
"linear.weight",
"linear.weight.scale",
"linear.weight.global_scale",
} {
if _, ok := packedHeader[key]; !ok {
t.Fatalf("packed header missing %s: %v", key, packedHeader)
}
}
if _, ok := packedHeader["linear.weight.input_global_scale"]; ok {
t.Fatalf("packed header unexpectedly includes input_global_scale: %v", packedHeader)
}
}
func TestCreateSafetensorsModel_PackedNVFP4StacksExperts(t *testing.T) {
dir := t.TempDir()
configJSON := `{
"model_type": "test",
"architectures": ["TestModel"],
"compression_config": {"format": "nvfp4-pack-quantized"}
}`
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(configJSON), 0o644); err != nil {
t.Fatalf("failed to write config.json: %v", err)
}
createTestSafetensors(t, filepath.Join(dir, "model.safetensors"), []*st.TensorData{
st.NewTensorDataFromBytes("model.layers.1.mlp.experts.0.gate_proj.weight_packed", "U8", []int32{2, 8}, make([]byte, 16)),
st.NewTensorDataFromBytes("model.layers.1.mlp.experts.0.gate_proj.weight_scale", "F8_E4M3", []int32{2, 1}, make([]byte, 2)),
st.NewTensorDataFromBytes("model.layers.1.mlp.experts.0.gate_proj.weight_global_scale", "F32", []int32{1}, encodeFloat32s(2)),
st.NewTensorDataFromBytes("model.layers.1.mlp.experts.0.gate_proj.input_global_scale", "F32", []int32{1}, encodeFloat32s(32)),
st.NewTensorDataFromBytes("model.layers.1.mlp.experts.1.gate_proj.weight_packed", "U8", []int32{2, 8}, make([]byte, 16)),
st.NewTensorDataFromBytes("model.layers.1.mlp.experts.1.gate_proj.weight_scale", "F8_E4M3", []int32{2, 1}, make([]byte, 2)),
st.NewTensorDataFromBytes("model.layers.1.mlp.experts.1.gate_proj.weight_global_scale", "F32", []int32{1}, encodeFloat32s(4)),
st.NewTensorDataFromBytes("model.layers.1.mlp.experts.1.gate_proj.input_global_scale", "F32", []int32{1}, encodeFloat32s(64)),
st.NewTensorDataFromBytes("norm.weight", "BF16", []int32{2}, make([]byte, 4)),
})
layerHeaders := make(map[string]map[string]json.RawMessage)
layerData := make(map[string][]byte)
createLayer := func(r io.Reader, mediaType, name string) (LayerInfo, error) {
data, err := io.ReadAll(r)
if err != nil {
return LayerInfo{}, err
}
if mediaType == "application/vnd.ollama.image.tensor" {
var headerSize uint64
if err := binary.Read(bytes.NewReader(data[:8]), binary.LittleEndian, &headerSize); err != nil {
return LayerInfo{}, err
}
var header map[string]json.RawMessage
if err := json.Unmarshal(data[8:8+headerSize], &header); err != nil {
return LayerInfo{}, err
}
layerHeaders[name] = header
layerData[name] = data
}
return LayerInfo{Name: name, Digest: "sha256:" + name, MediaType: mediaType}, nil
}
createTensorLayer := func(r io.Reader, name, dtype string, shape []int32, quantize string) ([]LayerInfo, error) {
if _, err := io.ReadAll(r); err != nil {
return nil, err
}
return []LayerInfo{{Name: name, Digest: "sha256:tensor_" + name, MediaType: "application/vnd.ollama.image.tensor"}}, nil
}
writeManifest := func(modelName string, config LayerInfo, layers []LayerInfo) error { return nil }
packedCreator := func(groupName string, tensors []PackedTensorInput) (LayerInfo, error) {
return LayerInfo{}, fmt.Errorf("unexpected packedCreator call for %s", groupName)
}
if err := CreateSafetensorsModel("test-model", dir, "", createLayer, createTensorLayer, writeManifest, func(string) {}, packedCreator); err != nil {
t.Fatalf("CreateSafetensorsModel failed: %v", err)
}
header := layerHeaders["model.layers.1.mlp.experts"]
if header == nil {
t.Fatalf("missing packed expert layer header")
}
for _, key := range []string{
"model.layers.1.mlp.switch_mlp.gate_proj.weight",
"model.layers.1.mlp.switch_mlp.gate_proj.weight.scale",
"model.layers.1.mlp.switch_mlp.gate_proj.weight.global_scale",
} {
if _, ok := header[key]; !ok {
t.Fatalf("stacked header missing %s: %v", key, header)
}
}
if _, ok := header["model.layers.1.mlp.switch_mlp.gate_proj.weight.input_global_scale"]; ok {
t.Fatalf("stacked header unexpectedly includes input_global_scale: %v", header)
}
if _, ok := header["model.layers.1.mlp.experts.0.gate_proj.weight"]; ok {
t.Fatalf("unexpected per-expert tensor left in packed header: %v", header)
}
var weightInfo struct {
Dtype string `json:"dtype"`
Shape []int32 `json:"shape"`
}
if err := json.Unmarshal(header["model.layers.1.mlp.switch_mlp.gate_proj.weight"], &weightInfo); err != nil {
t.Fatalf("failed to unmarshal stacked weight info: %v", err)
}
if weightInfo.Dtype != "U32" || !slices.Equal(weightInfo.Shape, []int32{2, 2, 2}) {
t.Fatalf("stacked weight = dtype %s shape %v, want U32 [2 2 2]", weightInfo.Dtype, weightInfo.Shape)
}
var globalInfo struct {
Dtype string `json:"dtype"`
Shape []int32 `json:"shape"`
}
if err := json.Unmarshal(header["model.layers.1.mlp.switch_mlp.gate_proj.weight.global_scale"], &globalInfo); err != nil {
t.Fatalf("failed to unmarshal stacked global scale info: %v", err)
}
if globalInfo.Dtype != "F32" || !slices.Equal(globalInfo.Shape, []int32{2, 1, 1}) {
t.Fatalf("stacked global scale = dtype %s shape %v, want F32 [2 1 1]", globalInfo.Dtype, globalInfo.Shape)
}
globalRaw := readPackedTensorRaw(t, layerData["model.layers.1.mlp.experts"], "model.layers.1.mlp.switch_mlp.gate_proj.weight.global_scale")
if got0 := math.Float32frombits(binary.LittleEndian.Uint32(globalRaw[0:4])); got0 != 0.5 {
t.Fatalf("stacked global scale[0] = %v, want 0.5", got0)
}
if got1 := math.Float32frombits(binary.LittleEndian.Uint32(globalRaw[4:8])); got1 != 0.25 {
t.Fatalf("stacked global scale[1] = %v, want 0.25", got1)
}
}
func TestCreateSafetensorsModel_HFFP8PacksExperts(t *testing.T) {
dir := t.TempDir()
@@ -777,6 +1347,26 @@ func TestCreateSafetensorsModel_HFFP8PacksExperts(t *testing.T) {
t.Fatalf("expected mxfp8 quantize for %s, got %q", tensor.Name, tensor.Quantize)
}
}
packedLayerNames = nil
packedLayerTensors = nil
if err := CreateSafetensorsModel("test-model", dir, "nvfp4", createLayer, createTensorLayer, writeManifest, func(string) {}, createPackedLayer); err != nil {
t.Fatalf("CreateSafetensorsModel nvfp4 failed: %v", err)
}
if len(packedLayerNames) != 1 {
t.Fatalf("expected 1 packed layer for nvfp4, got %d: %v", len(packedLayerNames), packedLayerNames)
}
for _, tensor := range packedLayerTensors[0] {
want := "nvfp4"
if strings.Contains(tensor.Name, "down_proj") {
want = "mxfp8"
}
if tensor.Quantize != want {
t.Fatalf("nvfp4 packed tensor %s quantize = %q, want %q", tensor.Name, tensor.Quantize, want)
}
}
}
func TestCreateSafetensorsModel_Qwen35Transforms(t *testing.T) {
+4
View File
@@ -19,6 +19,10 @@ func DTypeSize(dtype string) (int, error) {
return 4, nil
case "F64":
return 8, nil
case "U8", "I8":
return 1, nil
case "F8_E4M3", "F8_E5M2", "F8_E4M3FN", "F8_E5M2FNUZ":
return 1, nil
default:
return 0, fmt.Errorf("unsupported dtype %q", dtype)
}
+59
View File
@@ -0,0 +1,59 @@
package create
import (
"strings"
"github.com/ollama/ollama/x/safetensors"
)
type lagunaImportTransform struct{}
func newLagunaImportTransform(string, sourceModelConfig) (tensorImportTransform, error) {
return lagunaImportTransform{}, nil
}
func (lagunaImportTransform) skipTensor(string) bool { return false }
func (lagunaImportTransform) transformTensor(td *safetensors.TensorData) ([]*safetensors.TensorData, error) {
if td == nil {
return nil, nil
}
return []*safetensors.TensorData{td}, nil
}
func (lagunaImportTransform) quantizationType(name string, shape []int32, quantize string) string {
if !lagunaIsHFRoutedExpertWeight(name) {
return ""
}
return GetTensorQuantization(name, shape, quantize)
}
func (lagunaImportTransform) sourceFP8TensorQuantization(name string, shape []int32, requested string, fallback string) string {
if !lagunaIsHFRoutedExpertWeight(name) {
return ""
}
switch normalizeQuantType(requested) {
case "nvfp4", "mxfp4":
if lagunaKeepSourceFP8TensorAtMXFP8(name, shape) {
return "mxfp8"
}
}
return fallback
}
func (lagunaImportTransform) sourceFP8BF16Quantization(string, []int32, string) string {
return ""
}
func lagunaKeepSourceFP8TensorAtMXFP8(name string, shape []int32) bool {
if len(shape) != 2 || !isAligned(shape, "mxfp8") {
return false
}
return strings.Contains(name, "down_proj")
}
func lagunaIsHFRoutedExpertWeight(name string) bool {
return strings.HasSuffix(name, ".weight") && strings.Contains(name, ".mlp.experts.")
}
+200
View File
@@ -0,0 +1,200 @@
package create
import (
"io"
"os"
"path/filepath"
"testing"
st "github.com/ollama/ollama/x/safetensors"
)
func TestCreateSafetensorsModel_LagunaHFFP8RespectsSourceTensorPrecision(t *testing.T) {
tests := []struct {
name string
requested string
wantFP8Gate string
wantFP8Up string
wantFP8Down string
wantBF16QProj string
}{
{
name: "default mxfp8 import keeps source bf16 tensors",
requested: "",
wantFP8Gate: "mxfp8",
wantFP8Up: "mxfp8",
wantFP8Down: "mxfp8",
wantBF16QProj: "",
},
{
name: "nvfp4 import keeps source bf16 tensors and preserves down_proj at mxfp8",
requested: "nvfp4",
wantFP8Gate: "nvfp4",
wantFP8Up: "nvfp4",
wantFP8Down: "mxfp8",
wantBF16QProj: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
configJSON := `{
"model_type": "laguna",
"architectures": ["LagunaForCausalLM"],
"quantization_config": {"quant_method": "fp8", "weight_block_size": [128, 128]}
}`
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(configJSON), 0o644); err != nil {
t.Fatalf("failed to write config.json: %v", err)
}
createTestSafetensors(t, filepath.Join(dir, "model.safetensors"), []*st.TensorData{
st.NewTensorDataFromBytes("model.layers.0.mlp.experts.0.gate_proj.weight", "F8_E4M3", []int32{128, 128}, make([]byte, 128*128)),
st.NewTensorDataFromBytes("model.layers.0.mlp.experts.0.gate_proj.weight_scale_inv", "BF16", []int32{1, 1}, make([]byte, 2)),
st.NewTensorDataFromBytes("model.layers.0.mlp.experts.0.up_proj.weight", "F8_E4M3", []int32{128, 128}, make([]byte, 128*128)),
st.NewTensorDataFromBytes("model.layers.0.mlp.experts.0.up_proj.weight_scale_inv", "BF16", []int32{1, 1}, make([]byte, 2)),
st.NewTensorDataFromBytes("model.layers.0.mlp.experts.0.down_proj.weight", "F8_E4M3", []int32{128, 128}, make([]byte, 128*128)),
st.NewTensorDataFromBytes("model.layers.0.mlp.experts.0.down_proj.weight_scale_inv", "BF16", []int32{1, 1}, make([]byte, 2)),
st.NewTensorDataFromBytes("model.layers.0.self_attn.q_proj.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("model.embed_tokens.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("lm_head.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("model.layers.0.mlp.gate.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
})
quantizeByName := make(map[string]string)
createLayer := func(r io.Reader, mediaType, name string) (LayerInfo, error) {
if _, err := io.ReadAll(r); err != nil {
return LayerInfo{}, err
}
return LayerInfo{Name: name, Digest: "sha256:" + name, MediaType: mediaType}, nil
}
createTensorLayer := func(r io.Reader, name, dtype string, shape []int32, quantize string) ([]LayerInfo, error) {
if _, err := io.ReadAll(r); err != nil {
return nil, err
}
quantizeByName[name] = quantize
return []LayerInfo{{Name: name, Digest: "sha256:tensor_" + name, MediaType: "application/vnd.ollama.image.tensor"}}, nil
}
writeManifest := func(modelName string, config LayerInfo, layers []LayerInfo) error { return nil }
if err := CreateSafetensorsModel("test-model", dir, tt.requested, createLayer, createTensorLayer, writeManifest, func(string) {}); err != nil {
t.Fatalf("CreateSafetensorsModel failed: %v", err)
}
if got := quantizeByName["model.layers.0.mlp.experts.0.gate_proj.weight"]; got != tt.wantFP8Gate {
t.Fatalf("gate_proj quantization = %q, want %q", got, tt.wantFP8Gate)
}
if got := quantizeByName["model.layers.0.mlp.experts.0.up_proj.weight"]; got != tt.wantFP8Up {
t.Fatalf("up_proj quantization = %q, want %q", got, tt.wantFP8Up)
}
if got := quantizeByName["model.layers.0.mlp.experts.0.down_proj.weight"]; got != tt.wantFP8Down {
t.Fatalf("down_proj quantization = %q, want %q", got, tt.wantFP8Down)
}
for _, name := range []string{
"model.layers.0.self_attn.q_proj.weight",
"model.embed_tokens.weight",
"lm_head.weight",
"model.layers.0.mlp.gate.weight",
} {
if got := quantizeByName[name]; got != tt.wantBF16QProj {
t.Fatalf("%s quantization = %q, want %q", name, got, tt.wantBF16QProj)
}
}
})
}
}
func TestCreateSafetensorsModel_LagunaBF16QuantizesOnlyRoutedExperts(t *testing.T) {
tests := []struct {
name string
requested string
want map[string]string
}{
{
name: "int8 quantizes only routed experts",
requested: "int8",
want: map[string]string{
"model.layers.0.mlp.experts.0.gate_proj.weight": "int8",
"model.layers.0.mlp.experts.0.up_proj.weight": "int8",
"model.layers.0.mlp.experts.0.down_proj.weight": "int8",
"model.layers.0.mlp.shared_experts.gate_proj.weight": "",
"model.layers.0.mlp.shared_experts.down_proj.weight": "",
"model.layers.0.self_attn.q_proj.weight": "",
"model.layers.0.mlp.down_proj.weight": "",
"model.embed_tokens.weight": "",
"lm_head.weight": "",
"model.layers.0.mlp.gate.weight": "",
},
},
{
name: "int4 keeps routed down_proj at int8 and leaves others bf16",
requested: "int4",
want: map[string]string{
"model.layers.0.mlp.experts.0.gate_proj.weight": "int4",
"model.layers.0.mlp.experts.0.up_proj.weight": "int4",
"model.layers.0.mlp.experts.0.down_proj.weight": "int8",
"model.layers.0.mlp.shared_experts.gate_proj.weight": "",
"model.layers.0.mlp.shared_experts.down_proj.weight": "",
"model.layers.0.self_attn.q_proj.weight": "",
"model.layers.0.mlp.down_proj.weight": "",
"model.embed_tokens.weight": "",
"lm_head.weight": "",
"model.layers.0.mlp.gate.weight": "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
configJSON := `{
"model_type": "laguna",
"architectures": ["LagunaForCausalLM"]
}`
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(configJSON), 0o644); err != nil {
t.Fatalf("failed to write config.json: %v", err)
}
createTestSafetensors(t, filepath.Join(dir, "model.safetensors"), []*st.TensorData{
st.NewTensorDataFromBytes("model.layers.0.mlp.experts.0.gate_proj.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("model.layers.0.mlp.experts.0.up_proj.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("model.layers.0.mlp.experts.0.down_proj.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("model.layers.0.mlp.shared_experts.gate_proj.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("model.layers.0.mlp.shared_experts.down_proj.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("model.layers.0.self_attn.q_proj.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("model.layers.0.mlp.down_proj.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("model.embed_tokens.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("lm_head.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
st.NewTensorDataFromBytes("model.layers.0.mlp.gate.weight", "BF16", []int32{128, 128}, make([]byte, 128*128*2)),
})
quantizeByName := make(map[string]string)
createLayer := func(r io.Reader, mediaType, name string) (LayerInfo, error) {
if _, err := io.ReadAll(r); err != nil {
return LayerInfo{}, err
}
return LayerInfo{Name: name, Digest: "sha256:" + name, MediaType: mediaType}, nil
}
createTensorLayer := func(r io.Reader, name, dtype string, shape []int32, quantize string) ([]LayerInfo, error) {
if _, err := io.ReadAll(r); err != nil {
return nil, err
}
quantizeByName[name] = quantize
return []LayerInfo{{Name: name, Digest: "sha256:tensor_" + name, MediaType: "application/vnd.ollama.image.tensor"}}, nil
}
writeManifest := func(modelName string, config LayerInfo, layers []LayerInfo) error { return nil }
if err := CreateSafetensorsModel("test-model", dir, tt.requested, createLayer, createTensorLayer, writeManifest, func(string) {}); err != nil {
t.Fatalf("CreateSafetensorsModel failed: %v", err)
}
for name, want := range tt.want {
if got := quantizeByName[name]; got != want {
t.Fatalf("%s quantization = %q, want %q", name, got, want)
}
}
})
}
}
+2
View File
@@ -64,6 +64,8 @@ func dtypeFromString(s string) mlx.Dtype {
return mlx.DtypeInt64
case "U8", "UINT8":
return mlx.DtypeUint8
case "F8_E4M3", "F8_E5M2", "F8_E4M3FN", "F8_E5M2FNUZ":
return mlx.DtypeUint8 // FP8 types stored as raw uint8 bytes
default:
return mlx.DtypeFloat32
}
+48 -30
View File
@@ -115,36 +115,7 @@ func (s *Server) Load(ctx context.Context, _ ml.SystemInfo, gpus []ml.DeviceInfo
// Spawn subprocess: ollama runner --imagegen-engine --model <path> --port <port>
cmd := exec.Command(exe, "runner", "--imagegen-engine", "--model", s.modelName, "--port", strconv.Itoa(port))
cmd.Env = os.Environ()
// On Linux, set LD_LIBRARY_PATH to include MLX library directories
if runtime.GOOS == "linux" {
// Build library paths: start with LibOllamaPath, then add any mlx_* subdirectories
libraryPaths := []string{ml.LibOllamaPath}
if mlxDirs, err := filepath.Glob(filepath.Join(ml.LibOllamaPath, "mlx_*")); err == nil {
libraryPaths = append(libraryPaths, mlxDirs...)
}
// Append existing LD_LIBRARY_PATH if set
if existingPath, ok := os.LookupEnv("LD_LIBRARY_PATH"); ok {
libraryPaths = append(libraryPaths, filepath.SplitList(existingPath)...)
}
pathEnvVal := strings.Join(libraryPaths, string(filepath.ListSeparator))
// Update or add LD_LIBRARY_PATH in cmd.Env
found := false
for i := range cmd.Env {
if strings.HasPrefix(cmd.Env[i], "LD_LIBRARY_PATH=") {
cmd.Env[i] = "LD_LIBRARY_PATH=" + pathEnvVal
found = true
break
}
}
if !found {
cmd.Env = append(cmd.Env, "LD_LIBRARY_PATH="+pathEnvVal)
}
slog.Debug("mlx subprocess library path", "LD_LIBRARY_PATH", pathEnvVal)
}
configureMLXSubprocessEnv(cmd, ml.LibraryPaths(gpus))
s.cmd = cmd
@@ -200,6 +171,53 @@ func (s *Server) Ping(ctx context.Context) error {
return nil
}
func mlxLibraryPathEnv() string {
switch runtime.GOOS {
case "windows":
return "PATH"
case "darwin":
return "DYLD_LIBRARY_PATH"
default:
return "LD_LIBRARY_PATH"
}
}
func configureMLXSubprocessEnv(cmd *exec.Cmd, libraryPaths []string) {
if len(libraryPaths) == 0 {
return
}
// Search order for the imagegen runner is:
// 1. bundled lib/ollama root
// 2. backend-specific library dirs selected during GPU discovery
// 3. any existing caller-provided library path values
pathEnv := mlxLibraryPathEnv()
pathEnvPaths := append([]string{}, libraryPaths...)
if existingPath, ok := os.LookupEnv(pathEnv); ok {
pathEnvPaths = append(pathEnvPaths, filepath.SplitList(existingPath)...)
}
setSubprocessEnv(cmd, pathEnv, strings.Join(pathEnvPaths, string(filepath.ListSeparator)))
slog.Debug("mlx subprocess library path", pathEnv, strings.Join(pathEnvPaths, string(filepath.ListSeparator)))
ollamaLibraryPaths := append([]string{}, libraryPaths...)
if existingPath, ok := os.LookupEnv("OLLAMA_LIBRARY_PATH"); ok {
ollamaLibraryPaths = append(ollamaLibraryPaths, filepath.SplitList(existingPath)...)
}
setSubprocessEnv(cmd, "OLLAMA_LIBRARY_PATH", strings.Join(ollamaLibraryPaths, string(filepath.ListSeparator)))
slog.Debug("mlx subprocess library path", "OLLAMA_LIBRARY_PATH", strings.Join(ollamaLibraryPaths, string(filepath.ListSeparator)))
}
func setSubprocessEnv(cmd *exec.Cmd, key, value string) {
for i := range cmd.Env {
name, _, ok := strings.Cut(cmd.Env[i], "=")
if ok && strings.EqualFold(name, key) {
cmd.Env[i] = key + "=" + value
return
}
}
cmd.Env = append(cmd.Env, key+"="+value)
}
// getLastErr returns the last stderr line.
func (s *Server) getLastErr() string {
s.lastErrLock.Lock()
+3 -2
View File
@@ -337,9 +337,10 @@ func (c *RotatingKVCache) State() []*mlx.Array {
if c.keys == nil || c.values == nil {
return nil
}
liveLen := min(c.offset, c.keys.Dim(2))
return []*mlx.Array{
c.keys.Slice(mlx.Slice(), mlx.Slice(), mlx.Slice(0, c.offset), mlx.Slice()),
c.values.Slice(mlx.Slice(), mlx.Slice(), mlx.Slice(0, c.offset), mlx.Slice()),
c.keys.Slice(mlx.Slice(), mlx.Slice(), mlx.Slice(0, liveLen), mlx.Slice()),
c.values.Slice(mlx.Slice(), mlx.Slice(), mlx.Slice(0, liveLen), mlx.Slice()),
}
}
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more