mirror of
https://github.com/ollama/ollama.git
synced 2026-07-23 17:21:27 -05:00
Compare commits
5 Commits
v0.32.2-rc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fce745fe5e | ||
|
|
1fd1ccf7ad | ||
|
|
efb7e3c55e | ||
|
|
b517b9bd01 | ||
|
|
479664e7aa |
@@ -1 +1 @@
|
||||
b10069
|
||||
b10091
|
||||
|
||||
@@ -1 +1 @@
|
||||
b7c3dd6d27f45b5365b08a840310187dc503f1db
|
||||
33c03c486c34a7dadab5339563612c9933c4a406
|
||||
350
agent/skills.go
350
agent/skills.go
@@ -1,8 +1,10 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -271,6 +273,354 @@ type skillRoot struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// SkillImportResult describes one import attempt. Failed skills do not prevent
|
||||
// other valid skills in the same source root from being imported.
|
||||
type SkillImportResult struct {
|
||||
Source string
|
||||
SourceDir string
|
||||
Destination string
|
||||
Imported []string
|
||||
Existing []string
|
||||
Failures []SkillImportFailure
|
||||
}
|
||||
|
||||
// SkillImportFailure identifies a source skill that was deliberately skipped.
|
||||
// The destination is never changed for a failed skill.
|
||||
type SkillImportFailure struct {
|
||||
Name string
|
||||
Err error
|
||||
}
|
||||
|
||||
// ImportSkills imports skills from a conventional coding-agent source into the
|
||||
// canonical Ollama skills directory. Supported sources are codex, claude, and
|
||||
// pi. Existing skills are left untouched: an identical directory is reported
|
||||
// as existing, and a differing one is reported as a conflict.
|
||||
func ImportSkills(source string) (SkillImportResult, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return SkillImportResult{}, fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
|
||||
destination, err := SkillsDir()
|
||||
if err != nil {
|
||||
return SkillImportResult{}, fmt.Errorf("resolve Ollama skills directory: %w", err)
|
||||
}
|
||||
return importSkillsFromRoots(source, conventionalSkillImportRoots(home), destination)
|
||||
}
|
||||
|
||||
func conventionalSkillImportRoots(home string) map[string]string {
|
||||
return map[string]string{
|
||||
"codex": filepath.Join(home, ".codex", "skills"),
|
||||
"claude": filepath.Join(home, ".claude", "skills"),
|
||||
"pi": filepath.Join(home, ".pi", "agent", "skills"),
|
||||
}
|
||||
}
|
||||
|
||||
func importSkillsFromRoots(source string, roots map[string]string, destination string) (SkillImportResult, error) {
|
||||
source = strings.ToLower(strings.TrimSpace(source))
|
||||
sourceDir, ok := roots[source]
|
||||
if !ok {
|
||||
return SkillImportResult{}, fmt.Errorf("unknown skill source %q", source)
|
||||
}
|
||||
return importSkillsFromDir(source, sourceDir, destination)
|
||||
}
|
||||
|
||||
func importSkillsFromDir(source, sourceDir, destination string) (SkillImportResult, error) {
|
||||
result := SkillImportResult{Source: source, SourceDir: sourceDir, Destination: destination}
|
||||
info, err := os.Lstat(sourceDir)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return result, nil
|
||||
}
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("inspect %s skills directory: %w", source, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return result, fmt.Errorf("inspect %s skills directory: symlinks are not supported", source)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return result, fmt.Errorf("inspect %s skills directory: not a directory", source)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(sourceDir)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("read %s skills directory: %w", source, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
path := filepath.Join(sourceDir, name)
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: errors.New("symlinked skill directories are not supported")})
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: fmt.Errorf("inspect source: %w", err)})
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !skillName.MatchString(name) {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: errors.New("invalid skill directory name")})
|
||||
continue
|
||||
}
|
||||
if err := validateImportSkill(path, name); err != nil {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: err})
|
||||
continue
|
||||
}
|
||||
|
||||
state, err := importSkillDirectory(path, filepath.Join(destination, name))
|
||||
if err != nil {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: err})
|
||||
continue
|
||||
}
|
||||
if state == skillImportExisting {
|
||||
result.Existing = append(result.Existing, name)
|
||||
} else {
|
||||
result.Imported = append(result.Imported, name)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateImportSkill(dir, name string) error {
|
||||
manifest := filepath.Join(dir, skillFilename)
|
||||
info, err := os.Lstat(manifest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s: %w", skillFilename, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s must be a regular, non-symlinked file", skillFilename)
|
||||
}
|
||||
if _, err := parseSkill(manifest, name); err != nil {
|
||||
return err
|
||||
}
|
||||
return walkImportTree(dir, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
if info.IsDir() || path == dir {
|
||||
return nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("only regular files may be imported: %s", path)
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
return file.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func walkImportTree(root string, visit func(string, fs.DirEntry, fs.FileInfo) error) error {
|
||||
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("unsafe skill path %q", path)
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlinks may not be imported: %s", path)
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return visit(path, entry, info)
|
||||
})
|
||||
}
|
||||
|
||||
type skillImportState int
|
||||
|
||||
const (
|
||||
skillImportCopied skillImportState = iota
|
||||
skillImportExisting
|
||||
)
|
||||
|
||||
func importSkillDirectory(source, destination string) (skillImportState, error) {
|
||||
if info, err := os.Lstat(destination); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return 0, errors.New("destination exists but is not a regular directory")
|
||||
}
|
||||
same, err := sameImportTree(source, destination)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("inspect existing destination: %w", err)
|
||||
}
|
||||
if same {
|
||||
return skillImportExisting, nil
|
||||
}
|
||||
return 0, errors.New("destination skill already exists with different contents")
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, fmt.Errorf("inspect destination: %w", err)
|
||||
}
|
||||
|
||||
if err := ensureImportDestination(filepath.Dir(destination)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
stage, err := os.MkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".import-")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create import staging directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(stage)
|
||||
if err := copyImportTree(source, stage); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := os.Lstat(destination); err == nil {
|
||||
return 0, errors.New("destination skill was created during import")
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, fmt.Errorf("inspect destination before install: %w", err)
|
||||
}
|
||||
if err := os.Rename(stage, destination); err != nil {
|
||||
return 0, fmt.Errorf("install imported skill: %w", err)
|
||||
}
|
||||
return skillImportCopied, nil
|
||||
}
|
||||
|
||||
func ensureImportDestination(dir string) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create Ollama skills directory: %w", err)
|
||||
}
|
||||
info, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect Ollama skills directory: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return errors.New("Ollama skills directory must be a regular, non-symlinked directory")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyImportTree(source, destination string) error {
|
||||
return walkImportTree(source, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
rel, err := filepath.Rel(source, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := destination
|
||||
if rel != "." {
|
||||
target = filepath.Join(destination, rel)
|
||||
}
|
||||
if info.IsDir() {
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
return os.Mkdir(target, info.Mode().Perm())
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("only regular files may be imported: %s", path)
|
||||
}
|
||||
return copyImportFile(path, target, info.Mode().Perm())
|
||||
})
|
||||
}
|
||||
|
||||
func copyImportFile(source, destination string, mode fs.FileMode) error {
|
||||
in, err := os.Open(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", source, err)
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", destination, err)
|
||||
}
|
||||
_, copyErr := io.Copy(out, in)
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("copy %s: %w", source, copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("write %s: %w", destination, closeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameImportTree(source, destination string) (bool, error) {
|
||||
seen := make(map[string]struct{})
|
||||
same := true
|
||||
err := walkImportTree(source, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
rel, err := filepath.Rel(source, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen[rel] = struct{}{}
|
||||
other := destination
|
||||
if rel != "." {
|
||||
other = filepath.Join(destination, rel)
|
||||
}
|
||||
otherInfo, err := os.Lstat(other)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
same = false
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if otherInfo.Mode()&os.ModeSymlink != 0 || otherInfo.IsDir() != info.IsDir() || (!info.IsDir() && !otherInfo.Mode().IsRegular()) {
|
||||
same = false
|
||||
return nil
|
||||
}
|
||||
if info.Mode().IsRegular() {
|
||||
equal, err := sameImportFile(path, other)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !equal {
|
||||
same = false
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || !same {
|
||||
return same, err
|
||||
}
|
||||
err = walkImportTree(destination, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
rel, err := filepath.Rel(destination, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := seen[rel]; !ok {
|
||||
same = false
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return same, err
|
||||
}
|
||||
|
||||
func sameImportFile(first, second string) (bool, error) {
|
||||
a, err := os.Open(first)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer a.Close()
|
||||
b, err := os.Open(second)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer b.Close()
|
||||
|
||||
left := make([]byte, 32*1024)
|
||||
right := make([]byte, len(left))
|
||||
for {
|
||||
n, errA := a.Read(left)
|
||||
m, errB := b.Read(right)
|
||||
if n != m || !bytes.Equal(left[:n], right[:m]) {
|
||||
return false, nil
|
||||
}
|
||||
if errA == io.EOF && errB == io.EOF {
|
||||
return true, nil
|
||||
}
|
||||
if errA != nil && errA != io.EOF {
|
||||
return false, errA
|
||||
}
|
||||
if errB != nil && errB != io.EOF {
|
||||
return false, errB
|
||||
}
|
||||
if errA == io.EOF || errB == io.EOF {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// defaultSkillRoots returns skill directories ordered lowest- to
|
||||
// highest-precedence. Non-existent directories are scanned harmlessly
|
||||
// (DiscoverSkills skips them).
|
||||
|
||||
@@ -21,6 +21,21 @@ func writeCatalogSkill(t *testing.T, dir, name, content string) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeImportFixtureSkill(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(filepath.Join("testdata", "import", "release-notes", skillFilename))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "release-notes", skillFilename)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, contents, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverAndLoadSkills(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeCatalogSkill(t, dir, "release-notes", "---\nname: release-notes\ndescription: Draft concise release notes.\nmetadata:\n author: Ollama\n labels:\n - release\n - docs\n---\n# Release notes\n\nUse short bullets.")
|
||||
@@ -291,3 +306,187 @@ func TestSkillContentListsDirectoryAndResources(t *testing.T) {
|
||||
t.Fatalf("content missing resource listing: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsCopiesFixtureAndIsIdempotent(t *testing.T) {
|
||||
source := t.TempDir()
|
||||
destination := t.TempDir()
|
||||
writeImportFixtureSkill(t, source)
|
||||
writeCatalogSkill(t, source, "broken", "---\nname: another-skill\ndescription: Deliberately invalid.\n---\nIgnore this.")
|
||||
if err := os.MkdirAll(filepath.Join(source, "release-notes", "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(source, "release-notes", "references", "style.txt"), []byte("Keep it short.\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(source, "release-notes", "scripts"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(source, "release-notes", "scripts", "prepare.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(source, "ignored.md"), []byte("Ignored root file.\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := importSkillsFromDir("codex", source, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := strings.Join(result.Imported, ","), "release-notes"; got != want {
|
||||
t.Fatalf("imported = %q, want %q", got, want)
|
||||
}
|
||||
catalog, err := DiscoverSkills(destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skill, err := catalog.Load("release-notes")
|
||||
if err != nil || skill.Description != "Draft concise release notes." {
|
||||
t.Fatalf("imported skill = %#v, %v", skill, err)
|
||||
}
|
||||
if got := len(result.Failures); got != 1 || result.Failures[0].Name != "broken" {
|
||||
t.Fatalf("failures = %#v, want broken fixture failure", result.Failures)
|
||||
}
|
||||
for _, file := range []string{skillFilename, filepath.Join("references", "style.txt"), filepath.Join("scripts", "prepare.sh")} {
|
||||
if _, err := os.Stat(filepath.Join(destination, "release-notes", file)); err != nil {
|
||||
t.Fatalf("imported fixture file %q: %v", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err = importSkillsFromDir("codex", source, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := strings.Join(result.Existing, ","), "release-notes"; got != want {
|
||||
t.Fatalf("existing = %q, want %q", got, want)
|
||||
}
|
||||
if len(result.Imported) != 0 {
|
||||
t.Fatalf("repeated import copied skills: %#v", result.Imported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsLeavesConflictsAndUnsafeSourcesUntouched(t *testing.T) {
|
||||
source := t.TempDir()
|
||||
destination := t.TempDir()
|
||||
writeCatalogSkill(t, source, "release-notes", "source instructions")
|
||||
writeCatalogSkill(t, destination, "release-notes", "existing instructions")
|
||||
writeCatalogSkill(t, source, "nested-link", "safe manifest")
|
||||
if err := os.Symlink(filepath.Join(source, "release-notes", skillFilename), filepath.Join(source, "nested-link", "reference")); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(source, "release-notes"), filepath.Join(source, "linked-skill")); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
|
||||
result, err := importSkillsFromDir("codex", source, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Imported) != 0 || len(result.Existing) != 0 {
|
||||
t.Fatalf("unexpected successful import: %#v", result)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(destination, "release-notes", skillFilename)); err != nil || !strings.Contains(string(got), "existing instructions") {
|
||||
t.Fatalf("conflicting destination changed: %q, %v", got, err)
|
||||
}
|
||||
failed := make(map[string]bool)
|
||||
for _, failure := range result.Failures {
|
||||
failed[failure.Name] = true
|
||||
}
|
||||
for _, name := range []string{"release-notes", "nested-link", "linked-skill"} {
|
||||
if !failed[name] {
|
||||
t.Fatalf("missing failure for %q: %#v", name, result.Failures)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsRejectsSymlinkedRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
source := filepath.Join(t.TempDir(), "codex-skills")
|
||||
if err := os.Symlink(root, source); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
result, err := importSkillsFromDir("codex", source, t.TempDir())
|
||||
if err == nil || !strings.Contains(err.Error(), "symlinks are not supported") {
|
||||
t.Fatalf("symlinked root error = %v", err)
|
||||
}
|
||||
if len(result.Imported) != 0 || len(result.Existing) != 0 || len(result.Failures) != 0 {
|
||||
t.Fatalf("symlinked root result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsMissingRootAndConfiguredRoots(t *testing.T) {
|
||||
result, err := importSkillsFromDir("codex", filepath.Join(t.TempDir(), "missing"), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Imported) != 0 || len(result.Existing) != 0 || len(result.Failures) != 0 {
|
||||
t.Fatalf("missing root result = %#v", result)
|
||||
}
|
||||
|
||||
destination := t.TempDir()
|
||||
rootBase := t.TempDir()
|
||||
roots := map[string]string{
|
||||
"codex": filepath.Join(rootBase, "codex"),
|
||||
"claude": filepath.Join(rootBase, "claude"),
|
||||
"pi": filepath.Join(rootBase, "pi"),
|
||||
}
|
||||
for _, test := range []struct {
|
||||
source string
|
||||
root string
|
||||
name string
|
||||
}{
|
||||
{source: "codex", root: roots["codex"], name: "from-codex"},
|
||||
{source: "claude", root: roots["claude"], name: "from-claude"},
|
||||
{source: "pi", root: roots["pi"], name: "from-pi"},
|
||||
} {
|
||||
t.Run(test.source, func(t *testing.T) {
|
||||
writeCatalogSkill(t, test.root, test.name, "from "+test.source)
|
||||
result, err = importSkillsFromRoots(test.source, roots, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.SourceDir != test.root {
|
||||
t.Fatalf("source dir = %q, want %q", result.SourceDir, test.root)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destination, test.name, skillFilename)); err != nil {
|
||||
t.Fatalf("conventional source was not imported: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := importSkillsFromRoots("unknown", roots, destination); err == nil || !strings.Contains(err.Error(), "unknown skill source") {
|
||||
t.Fatalf("unknown source error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConventionalSkillImportRoots(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
roots := conventionalSkillImportRoots(home)
|
||||
for source, want := range map[string]string{
|
||||
"codex": filepath.Join(home, ".codex", "skills"),
|
||||
"claude": filepath.Join(home, ".claude", "skills"),
|
||||
"pi": filepath.Join(home, ".pi", "agent", "skills"),
|
||||
} {
|
||||
if got := roots[source]; got != want {
|
||||
t.Fatalf("%s root = %q, want %q", source, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsRejectsUnreadableManifest(t *testing.T) {
|
||||
source := t.TempDir()
|
||||
writeCatalogSkill(t, source, "private", "do not read")
|
||||
manifest := filepath.Join(source, "private", skillFilename)
|
||||
if err := os.Chmod(manifest, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(manifest, 0o644) })
|
||||
if _, err := os.ReadFile(manifest); err == nil {
|
||||
t.Skip("test user can read a mode-000 file")
|
||||
}
|
||||
result, err := importSkillsFromDir("codex", source, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Failures) != 1 || result.Failures[0].Name != "private" {
|
||||
t.Fatalf("failures = %#v", result.Failures)
|
||||
}
|
||||
}
|
||||
|
||||
8
agent/testdata/import/release-notes/SKILL.md
vendored
Normal file
8
agent/testdata/import/release-notes/SKILL.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: release-notes
|
||||
description: Draft concise release notes.
|
||||
---
|
||||
|
||||
# Release notes
|
||||
|
||||
Use short bullets.
|
||||
@@ -83,12 +83,20 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
|
||||
return agentContextWindowForModel(ctx, client, model, fallback)
|
||||
}
|
||||
|
||||
skillCatalog, err := coreagent.LoadDefaultSkills(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load agent skills: %w", err)
|
||||
var skillCatalog *coreagent.SkillCatalog
|
||||
reloadSkills := func() (*coreagent.SkillCatalog, error) {
|
||||
catalog, err := coreagent.LoadDefaultSkills(cwd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, diagnostic := range catalog.Diagnostics() {
|
||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m ignored invalid agent skill: %v\n", diagnostic)
|
||||
}
|
||||
skillCatalog = catalog
|
||||
return catalog, nil
|
||||
}
|
||||
for _, diagnostic := range skillCatalog.Diagnostics() {
|
||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m ignored invalid agent skill: %v\n", diagnostic)
|
||||
if _, err := reloadSkills(); err != nil {
|
||||
return fmt.Errorf("load agent skills: %w", err)
|
||||
}
|
||||
var registry *coreagent.Registry
|
||||
registryForModel := func(ctx context.Context, model string) *coreagent.Registry {
|
||||
@@ -99,7 +107,7 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
|
||||
}
|
||||
systemPrompt := agentSystemPromptWithWorkingDir(opts.Model, opts.System, agentSkillSystemContext(skillCatalog, registry, opts.ToolsDisabled), cwd)
|
||||
|
||||
_, err = agentchat.Run(cmd.Context(), agentchat.Options{
|
||||
_, err := agentchat.Run(cmd.Context(), agentchat.Options{
|
||||
Model: opts.Model,
|
||||
Client: client,
|
||||
Tools: registry,
|
||||
@@ -118,6 +126,8 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
|
||||
return agentSystemPromptWithWorkingDir(model, agentSystemFromShow(ctx, client, model), agentSkillSystemContext(skillCatalog, registry, toolsDisabled), cwd)
|
||||
},
|
||||
Skills: skillCatalog,
|
||||
ImportSkills: coreagent.ImportSkills,
|
||||
ReloadSkills: reloadSkills,
|
||||
SystemPrompt: systemPrompt,
|
||||
WorkingDir: cwd,
|
||||
Format: opts.Format,
|
||||
|
||||
@@ -55,6 +55,8 @@ type Options struct {
|
||||
Client coreagent.ChatClient
|
||||
Tools *coreagent.Registry
|
||||
Skills *coreagent.SkillCatalog
|
||||
ImportSkills func(string) (coreagent.SkillImportResult, error)
|
||||
ReloadSkills func() (*coreagent.SkillCatalog, error)
|
||||
ToolRegistryForModel func(context.Context, string) *coreagent.Registry
|
||||
ToolsDisabled bool
|
||||
MultiModalForModel func(context.Context, string) bool
|
||||
|
||||
@@ -55,7 +55,7 @@ var chatSlashCommands = []chatSlashCommand{
|
||||
{name: "/new", description: "start a new chat"},
|
||||
{name: "/think", description: "set thinking mode"},
|
||||
{name: "/tools", description: "toggle tools on or off"},
|
||||
{name: "/skills", description: "list available skills"},
|
||||
{name: "/skills", usage: "/skills [import codex|claude|pi]", description: "list or import skills"},
|
||||
{name: "/compact", description: "summarize older context"},
|
||||
{name: "/help", description: "show commands", aliases: []string{"/?"}},
|
||||
{name: "/bye", description: "exit", aliases: []string{"/exit"}},
|
||||
@@ -63,6 +63,12 @@ var chatSlashCommands = []chatSlashCommand{
|
||||
{name: "/save", usage: "/save <filename>", description: "save request JSON; saved as <filename>.json"},
|
||||
}
|
||||
|
||||
var skillsImportCompletions = []chatCompletion{
|
||||
{value: "/skills import codex", label: "/skills import codex", description: "import from ~/.codex/skills"},
|
||||
{value: "/skills import claude", label: "/skills import claude", description: "import from ~/.claude/skills"},
|
||||
{value: "/skills import pi", label: "/skills import pi", description: "import from ~/.pi/agent/skills"},
|
||||
}
|
||||
|
||||
func (m *chatModel) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
m.syncInputPlaceholders()
|
||||
input := strings.TrimSpace(string(m.input))
|
||||
@@ -159,8 +165,10 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m *chatModel) handleSkillsCommand(args string) (tea.Model, tea.Cmd) {
|
||||
if strings.TrimSpace(args) != "" {
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: "usage: /skills"}))
|
||||
if fields := strings.Fields(args); len(fields) == 2 && fields[0] == "import" {
|
||||
return m.handleSkillsImport(fields[1])
|
||||
} else if len(fields) != 0 {
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: "usage: /skills [import codex|claude|pi]"}))
|
||||
return *m, nil
|
||||
}
|
||||
skills := m.opts.Skills.List()
|
||||
@@ -181,6 +189,61 @@ func (m *chatModel) handleSkillsCommand(args string) (tea.Model, tea.Cmd) {
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *chatModel) handleSkillsImport(source string) (tea.Model, tea.Cmd) {
|
||||
importSkills := m.opts.ImportSkills
|
||||
if importSkills == nil {
|
||||
importSkills = coreagent.ImportSkills
|
||||
}
|
||||
result, err := importSkills(source)
|
||||
if err != nil {
|
||||
m.status = "error"
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: fmt.Sprintf("Could not import %s skills: %v", source, err)}))
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
if len(result.Imported) != 0 || len(result.Existing) != 0 {
|
||||
reload := m.opts.ReloadSkills
|
||||
if reload == nil {
|
||||
reload = func() (*coreagent.SkillCatalog, error) {
|
||||
return coreagent.LoadDefaultSkills(m.currentWorkingDir())
|
||||
}
|
||||
}
|
||||
catalog, err := reload()
|
||||
if err != nil {
|
||||
m.status = "error"
|
||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: fmt.Sprintf("%s\n\nCould not reload skills: %v", skillsImportSummary(result), err)}))
|
||||
return *m, nil
|
||||
}
|
||||
m.opts.Skills = catalog
|
||||
if m.opts.ToolRegistryForModel != nil && m.opts.Model != "" {
|
||||
m.opts.Tools = m.opts.ToolRegistryForModel(m.ctx, m.opts.Model)
|
||||
}
|
||||
if m.opts.SystemPromptForModel != nil {
|
||||
m.opts.SystemPrompt = m.opts.SystemPromptForModel(m.ctx, m.opts.Model, m.opts.Tools, m.opts.ToolsDisabled)
|
||||
}
|
||||
m.status = "skills reloaded"
|
||||
}
|
||||
m.entries = append(m.entries, newSlashEntry(skillsImportSummary(result)))
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func skillsImportSummary(result coreagent.SkillImportResult) string {
|
||||
if len(result.Imported) == 0 && len(result.Existing) == 0 && len(result.Failures) == 0 {
|
||||
return fmt.Sprintf("No %s skills found at %s.", result.Source, result.SourceDir)
|
||||
}
|
||||
var lines []string
|
||||
if len(result.Imported) != 0 {
|
||||
lines = append(lines, fmt.Sprintf("Imported %d skill%s from %s.", len(result.Imported), pluralSuffix(len(result.Imported)), result.SourceDir))
|
||||
}
|
||||
if len(result.Existing) != 0 {
|
||||
lines = append(lines, "Already present (left unchanged): "+strings.Join(result.Existing, ", ")+".")
|
||||
}
|
||||
for _, failure := range result.Failures {
|
||||
lines = append(lines, fmt.Sprintf("Skipped %s: %v.", failure.Name, failure.Err))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func skillsDirForDisplay(catalog *coreagent.SkillCatalog) string {
|
||||
if catalog != nil && catalog.Dir() != "" {
|
||||
return catalog.Dir()
|
||||
@@ -1117,13 +1180,16 @@ func (m chatModel) completions() []chatCompletion {
|
||||
|
||||
func (m chatModel) slashCompletions() []chatCompletion {
|
||||
rawInput := string(m.input)
|
||||
input := strings.TrimSpace(rawInput)
|
||||
input := strings.TrimLeftFunc(rawInput, unicode.IsSpace)
|
||||
if !strings.HasPrefix(input, "/") {
|
||||
return nil
|
||||
}
|
||||
if m.skillSlashPromptStarted(rawInput) {
|
||||
return nil
|
||||
}
|
||||
if completions := matchingSkillsImportCompletions(input); completions != nil {
|
||||
return completions
|
||||
}
|
||||
|
||||
commands := matchingSlashCommands(input)
|
||||
completions := make([]chatCompletion, 0, len(commands))
|
||||
@@ -1134,6 +1200,13 @@ func (m chatModel) slashCompletions() []chatCompletion {
|
||||
description: command.description,
|
||||
})
|
||||
}
|
||||
if strings.EqualFold(input, "/skills") {
|
||||
completions = append(completions, chatCompletion{
|
||||
value: "/skills import",
|
||||
label: "/skills import",
|
||||
description: "import skills from Codex, Claude, or Pi",
|
||||
})
|
||||
}
|
||||
// Each catalog skill is also invocable as "/<skill-name>"; surface them as
|
||||
// completions so they are discoverable by typing.
|
||||
if m.opts.Skills != nil {
|
||||
@@ -1163,6 +1236,38 @@ func (m chatModel) slashCompletions() []chatCompletion {
|
||||
return completions
|
||||
}
|
||||
|
||||
func matchingSkillsImportCompletions(input string) []chatCompletion {
|
||||
const importCommand = "/skills import"
|
||||
lower := strings.ToLower(input)
|
||||
if lower == "/skills" {
|
||||
return nil // Preserve Enter on /skills as the listing command.
|
||||
}
|
||||
if !strings.HasPrefix(lower, "/skills ") {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(importCommand, lower) {
|
||||
return []chatCompletion{{
|
||||
value: importCommand,
|
||||
label: importCommand,
|
||||
description: "import skills from Codex, Claude, or Pi",
|
||||
}}
|
||||
}
|
||||
if !strings.HasPrefix(lower, importCommand) {
|
||||
return nil
|
||||
}
|
||||
prefix := strings.TrimSpace(strings.TrimPrefix(lower, importCommand))
|
||||
completions := make([]chatCompletion, 0, len(skillsImportCompletions))
|
||||
for _, completion := range skillsImportCompletions {
|
||||
if strings.HasPrefix(strings.TrimPrefix(completion.value, importCommand+" "), prefix) {
|
||||
completions = append(completions, completion)
|
||||
}
|
||||
}
|
||||
if len(completions) == 0 {
|
||||
return []chatCompletion{{label: "No matching skill sources"}}
|
||||
}
|
||||
return completions
|
||||
}
|
||||
|
||||
func (m chatModel) skillSlashPromptStarted(input string) bool {
|
||||
input = strings.TrimLeftFunc(input, unicode.IsSpace)
|
||||
end := strings.IndexFunc(input, unicode.IsSpace)
|
||||
|
||||
@@ -570,6 +570,76 @@ func TestSkillCommandsListAndPersistSyntheticToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsImportReloadsCatalogRegistryAndSystemPrompt(t *testing.T) {
|
||||
before := writeTestSkillCatalog(t)
|
||||
dir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(dir, "from-codex"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "from-codex", "SKILL.md"), []byte("---\nname: from-codex\ndescription: Imported skill.\n---\nImported instructions."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := coreagent.DiscoverSkills(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := &coreagent.Registry{}
|
||||
var reloaded, rebuilt, prompted bool
|
||||
m := chatModel{
|
||||
ctx: context.Background(),
|
||||
opts: Options{
|
||||
Model: "test",
|
||||
Skills: before,
|
||||
ImportSkills: func(source string) (coreagent.SkillImportResult, error) {
|
||||
if source != "codex" {
|
||||
t.Fatalf("source = %q", source)
|
||||
}
|
||||
return coreagent.SkillImportResult{Source: source, SourceDir: "/source", Imported: []string{"from-codex"}}, nil
|
||||
},
|
||||
ReloadSkills: func() (*coreagent.SkillCatalog, error) {
|
||||
reloaded = true
|
||||
return after, nil
|
||||
},
|
||||
ToolRegistryForModel: func(context.Context, string) *coreagent.Registry {
|
||||
rebuilt = true
|
||||
return registry
|
||||
},
|
||||
SystemPromptForModel: func(_ context.Context, _ string, got *coreagent.Registry, _ bool) string {
|
||||
prompted = got == registry
|
||||
return after.SystemContext()
|
||||
},
|
||||
},
|
||||
input: []rune("/skills import codex"),
|
||||
}
|
||||
|
||||
updated, cmd := m.handleSubmit()
|
||||
if cmd != nil {
|
||||
t.Fatal("skills import should not start a model run")
|
||||
}
|
||||
m = updated.(chatModel)
|
||||
if !reloaded || !rebuilt || !prompted {
|
||||
t.Fatalf("reload=%v rebuilt=%v prompted=%v", reloaded, rebuilt, prompted)
|
||||
}
|
||||
if m.opts.Skills != after || m.opts.Tools != registry || !strings.Contains(m.opts.SystemPrompt, "from-codex") {
|
||||
t.Fatalf("reloaded options = %#v", m.opts)
|
||||
}
|
||||
if m.status != "skills reloaded" || len(m.entries) != 1 || !strings.Contains(m.entries[0].content, "Imported 1 skill") {
|
||||
t.Fatalf("import result = status %q entries %#v", m.status, m.entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsImportUsage(t *testing.T) {
|
||||
m := chatModel{input: []rune("/skills import")}
|
||||
updated, cmd := m.handleSubmit()
|
||||
if cmd != nil {
|
||||
t.Fatal("invalid skills import should not start a model run")
|
||||
}
|
||||
m = updated.(chatModel)
|
||||
if len(m.entries) != 1 || m.entries[0].role != "error" || !strings.Contains(m.entries[0].content, "usage: /skills [import codex|claude|pi]") {
|
||||
t.Fatalf("entries = %#v", m.entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSlashCommandPromptBecomesUserMessage(t *testing.T) {
|
||||
catalog := writeTestSkillCatalog(t)
|
||||
m := chatModel{ctx: context.Background(), opts: Options{Model: "test", Skills: catalog, Client: chatTestClient{}}, input: []rune("/release-notes draft the v1.2 notes")}
|
||||
@@ -665,6 +735,31 @@ func TestSkillSlashCommandAppearsInCompletions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsImportSlashCompletions(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{input: "/skills", want: []string{"/skills", "/skills import"}},
|
||||
{input: "/skills impo", want: []string{"/skills import"}},
|
||||
{input: "/skills import ", want: []string{"/skills import codex", "/skills import claude", "/skills import pi"}},
|
||||
{input: "/skills import c", want: []string{"/skills import codex", "/skills import claude"}},
|
||||
{input: "/skills import pi", want: []string{"/skills import pi"}},
|
||||
} {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
m := chatModel{input: []rune(test.input)}
|
||||
completions := m.slashCompletions()
|
||||
got := make([]string, 0, len(completions))
|
||||
for _, completion := range completions {
|
||||
got = append(got, completion.value)
|
||||
}
|
||||
if strings.Join(got, "\n") != strings.Join(test.want, "\n") {
|
||||
t.Fatalf("completions = %#v, want %#v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSlashPromptHidesCommandCompletions(t *testing.T) {
|
||||
catalog := writeTestSkillCatalog(t)
|
||||
for _, input := range []string{"/release-notes ", "/release-notes draft the release notes"} {
|
||||
|
||||
@@ -244,26 +244,33 @@ Ollama Cloud model retirement does not affect local models.
|
||||
|
||||
| Retirement date | Model | Recommended alternative |
|
||||
| --- | --- | --- |
|
||||
| July 15, 2026 | `deepseek-v3.1:671b` | `deepseek-v4-flash` |
|
||||
| July 15, 2026 | `deepseek-v3.2` | `deepseek-v4-flash` |
|
||||
| July 15, 2026 | `devstral-2:123b` | `mistral-large-3:675b` |
|
||||
| July 15, 2026 | `devstral-small-2:24b` | |
|
||||
| July 15, 2026 | `ministral-3:14b` | |
|
||||
| July 15, 2026 | `ministral-3:3b` | |
|
||||
| July 15, 2026 | `ministral-3:8b` | |
|
||||
| July 15, 2026 | `gemini-3-flash-preview` | `minimax-m3` |
|
||||
| July 15, 2026 | `gemma3:12b` | `gemma4:31b` |
|
||||
| July 15, 2026 | `gemma3:27b` | `gemma4:31b` |
|
||||
| July 15, 2026 | `gemma3:4b` | `gemma4:31b` |
|
||||
| July 15, 2026 | `glm-4.7` | `glm-5.2` |
|
||||
| July 15, 2026 | `glm-5` | `glm-5.2` |
|
||||
| July 15, 2026 | `minimax-m2.1` | `minimax-m3` |
|
||||
| July 15, 2026 | `qwen3-coder-next` | `qwen3.5:397b` |
|
||||
| July 15, 2026 | `qwen3-coder:480b` | `qwen3.5:397b` |
|
||||
| July 31, 2026 | `minimax-m2.5` | `minimax-m2.7` |
|
||||
| July 31, 2026 | `kimi-k2.5` | `kimi-k2.6` |
|
||||
|
||||
### Past retirements
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="July 15, 2026">
|
||||
| Model | Recommended alternative |
|
||||
| --- | --- |
|
||||
| `deepseek-v3.1:671b` | `deepseek-v4-flash` |
|
||||
| `deepseek-v3.2` | `deepseek-v4-flash` |
|
||||
| `devstral-2:123b` | `mistral-large-3:675b` |
|
||||
| `devstral-small-2:24b` | |
|
||||
| `ministral-3:14b` | |
|
||||
| `ministral-3:3b` | |
|
||||
| `ministral-3:8b` | |
|
||||
| `gemini-3-flash-preview` | `minimax-m3` |
|
||||
| `gemma3:12b` | `gemma4:31b` |
|
||||
| `gemma3:27b` | `gemma4:31b` |
|
||||
| `gemma3:4b` | `gemma4:31b` |
|
||||
| `glm-4.7` | `glm-5.2` |
|
||||
| `glm-5` | `glm-5.2` |
|
||||
| `minimax-m2.1` | `minimax-m3` |
|
||||
| `qwen3-coder-next` | `qwen3.5:397b` |
|
||||
| `qwen3-coder:480b` | `qwen3.5:397b` |
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="June 30, 2026">
|
||||
| Model | Recommended alternative |
|
||||
| --- | --- |
|
||||
|
||||
@@ -11,8 +11,29 @@ import (
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
// runThinkingEnabled verifies that when thinking is requested, the model
|
||||
// produces both thinking and content output without leaking raw channel tags.
|
||||
var rawThinkingProtocolTags = []string{
|
||||
"<|channel>",
|
||||
"<channel|>",
|
||||
"<think>",
|
||||
"</think>",
|
||||
"<assistant>",
|
||||
"</assistant>",
|
||||
"<tool_call>",
|
||||
"</tool_call>",
|
||||
}
|
||||
|
||||
func rejectRawThinkingProtocolTags(t *testing.T, field, value string) {
|
||||
t.Helper()
|
||||
for _, tag := range rawThinkingProtocolTags {
|
||||
if strings.Contains(value, tag) {
|
||||
t.Errorf("%s contains raw protocol tag %q: %s", field, tag, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runThinkingEnabled verifies that thinking-capable models honor an explicit
|
||||
// thinking request, complete a reasoning trace, and return the final answer
|
||||
// without leaking raw channel tags.
|
||||
func runThinkingEnabled(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
@@ -33,12 +54,15 @@ func runThinkingEnabled(t *testing.T) {
|
||||
Stream: &stream,
|
||||
Think: &think,
|
||||
Messages: []api.Message{
|
||||
{Role: "user", Content: "What is 12 * 15? Think step by step."},
|
||||
{Role: "user", Content: "What is 12 multiplied by 15? Give the final answer."},
|
||||
},
|
||||
Options: map[string]any{
|
||||
"temperature": 0,
|
||||
"seed": 42,
|
||||
"num_predict": 512,
|
||||
// Deep-thinking models can use several thousand tokens on
|
||||
// simple problems before producing their final answer. Keep
|
||||
// this high enough to verify a natural stop, not truncation.
|
||||
"num_predict": 8192,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -61,22 +85,17 @@ func runThinkingEnabled(t *testing.T) {
|
||||
if thinking == "" {
|
||||
t.Error("expected non-empty thinking output when thinking is enabled")
|
||||
}
|
||||
|
||||
// The answer (180) should appear in thinking, content, or both.
|
||||
// Some models put everything in thinking and leave content empty
|
||||
// if they hit the token limit while still thinking.
|
||||
combined := thinking + " " + content
|
||||
if !strings.Contains(combined, "180") {
|
||||
t.Errorf("expected '180' in thinking or content, got thinking=%q content=%q", thinking, content)
|
||||
if content == "" {
|
||||
t.Error("expected non-empty final content after thinking")
|
||||
} else if !strings.Contains(content, "180") {
|
||||
t.Errorf("expected final answer 180, got content=%q", content)
|
||||
}
|
||||
if response.DoneReason != "stop" {
|
||||
t.Errorf("expected completed response, got done reason %q", response.DoneReason)
|
||||
}
|
||||
|
||||
// Neither thinking nor content should contain raw channel tags
|
||||
if strings.Contains(content, "<|channel>") || strings.Contains(content, "<channel|>") {
|
||||
t.Errorf("content contains raw channel tags: %s", content)
|
||||
}
|
||||
if strings.Contains(thinking, "<|channel>") || strings.Contains(thinking, "<channel|>") {
|
||||
t.Errorf("thinking contains raw channel tags: %s", thinking)
|
||||
}
|
||||
rejectRawThinkingProtocolTags(t, "content", content)
|
||||
rejectRawThinkingProtocolTags(t, "thinking", thinking)
|
||||
|
||||
t.Logf("thinking (%d chars): %.100s...", len(thinking), thinking)
|
||||
t.Logf("content (%d chars): %s", len(content), content)
|
||||
@@ -84,7 +103,7 @@ func runThinkingEnabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// runThinkingSuppressed verifies that when thinking is NOT requested,
|
||||
// runThinkingSuppressed verifies that when thinking is explicitly disabled,
|
||||
// the model does not leak thinking/channel content into the response.
|
||||
func runThinkingSuppressed(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
@@ -100,10 +119,11 @@ func runThinkingSuppressed(t *testing.T) {
|
||||
pullOrSkip(ctx, t, client, modelName)
|
||||
|
||||
stream := false
|
||||
think := api.ThinkValue{Value: false}
|
||||
req := api.ChatRequest{
|
||||
Model: modelName,
|
||||
Stream: &stream,
|
||||
// Think is nil — thinking not requested
|
||||
Think: &think,
|
||||
Messages: []api.Message{
|
||||
{Role: "user", Content: "What is the capital of Japan? Answer in one word."},
|
||||
},
|
||||
@@ -129,24 +149,16 @@ func runThinkingSuppressed(t *testing.T) {
|
||||
content := response.Message.Content
|
||||
thinking := response.Message.Thinking
|
||||
|
||||
// The answer should appear in content or thinking
|
||||
combined := content + " " + thinking
|
||||
if !strings.Contains(combined, "Tokyo") {
|
||||
t.Errorf("expected 'Tokyo' in content or thinking, got content=%q thinking=%q", content, thinking)
|
||||
// With thinking disabled, the answer must be returned as content.
|
||||
if !strings.Contains(content, "Tokyo") {
|
||||
t.Errorf("expected 'Tokyo' in content, got content=%q thinking=%q", content, thinking)
|
||||
}
|
||||
|
||||
// Content must NOT contain channel/thinking tags
|
||||
if strings.Contains(content, "<|channel>") || strings.Contains(content, "<channel|>") {
|
||||
t.Errorf("content contains leaked channel tags when thinking not requested: %s", content)
|
||||
}
|
||||
if strings.Contains(content, "thought") && strings.Contains(content, "<channel|>") {
|
||||
t.Errorf("content contains leaked thinking block: %s", content)
|
||||
}
|
||||
rejectRawThinkingProtocolTags(t, "content", content)
|
||||
rejectRawThinkingProtocolTags(t, "thinking", thinking)
|
||||
|
||||
// Thinking field should ideally be empty when not requested.
|
||||
// Some small models may still produce thinking output; log but don't fail.
|
||||
if thinking != "" {
|
||||
t.Logf("WARNING: model produced thinking output when not requested (%d chars): %.100s...", len(thinking), thinking)
|
||||
t.Errorf("expected empty thinking when thinking is disabled, got %q", thinking)
|
||||
}
|
||||
|
||||
t.Logf("content: %s", content)
|
||||
|
||||
@@ -88,6 +88,7 @@ This table tracks the dispatch surface. Keep it brief; the handler comments in
|
||||
| `deepseekocr` | Maps to `deepseek2-ocr`, injects missing OCR/MoE metadata, and hides embedded SAM/vision/projector tensors. | DeepSeek OCR projector translation. |
|
||||
| `glmocr` | Maps GLM OCR metadata/tensors to the llama.cpp-compatible view. | GLM OCR projector translation. |
|
||||
| `glm4moelite` | Maps GLM-4.7 Flash MLA metadata to the `deepseek2` path and fixes special-token metadata. | n/a |
|
||||
| `laguna` | Renames legacy attention-gate tensors and SWA RoPE metadata to current llama.cpp names. | n/a |
|
||||
| `nemotron_h_moe` | Fixes latent-FFN variants and hides MTP tensors. | n/a |
|
||||
| `nemotron_h_omni` | Selects the Nemotron text loader and hides audio/vision/projector tensors from the text loader. | Nemotron V2 VL projector translation; audio remains disabled. |
|
||||
| `llama` with Llama 3 markers | Fixes Llama 3 tokenizer metadata. | n/a |
|
||||
|
||||
38
llama/compat/llama-ollama-compat.cpp
vendored
38
llama/compat/llama-ollama-compat.cpp
vendored
@@ -118,6 +118,43 @@ void fix_glm4moelite_eog_token_ids(gguf_context * meta) {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// laguna (text side)
|
||||
// =========================================================================
|
||||
|
||||
bool detect_ollama_laguna(const gguf_context * meta, const ggml_context * ctx) {
|
||||
const int64_t arch_kid = gguf_find_key(meta, "general.architecture");
|
||||
if (arch_kid < 0 || std::strcmp(gguf_get_val_str(meta, arch_kid), "laguna") != 0) return false;
|
||||
|
||||
return has_key(meta, "laguna.rope.swa.dimension_count")
|
||||
|| has_key(meta, "laguna.rope.swa.freq_base")
|
||||
|| ggml_get_tensor(const_cast<ggml_context *>(ctx), "blk.0.attn_g.weight") != nullptr;
|
||||
}
|
||||
|
||||
void handle_laguna(gguf_context * meta, ggml_context * ctx) {
|
||||
if (!detect_ollama_laguna(meta, ctx)) return;
|
||||
|
||||
OLLAMA_COMPAT_LOG_INFO("%s: detected Ollama-format laguna GGUF; applying compatibility fixes\n", __func__);
|
||||
|
||||
copy_u32_kv(meta, "laguna.rope.swa.dimension_count", "laguna.rope.dimension_count_swa");
|
||||
copy_f32_kv(meta, "laguna.rope.swa.freq_base", "laguna.rope.freq_base_swa");
|
||||
|
||||
std::vector<std::pair<std::string, std::string>> renames;
|
||||
const int64_t n = gguf_get_n_tensors(meta);
|
||||
constexpr const char * old_suffix = ".attn_g.weight";
|
||||
constexpr const char * new_suffix = ".attn_gate.weight";
|
||||
for (int64_t i = 0; i < n; ++i) {
|
||||
const std::string name(gguf_get_tensor_name(meta, i));
|
||||
const size_t pos = name.rfind(old_suffix);
|
||||
if (pos != std::string::npos && pos + std::strlen(old_suffix) == name.size()) {
|
||||
renames.emplace_back(name, name.substr(0, pos) + new_suffix);
|
||||
}
|
||||
}
|
||||
for (const auto & [from, to] : renames) {
|
||||
rename_tensor(meta, ctx, from.c_str(), to.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
bool get_u32_kv(const gguf_context * meta, const char * key, uint32_t & out) {
|
||||
const int64_t kid = gguf_find_key(meta, key);
|
||||
if (kid < 0) return false;
|
||||
@@ -3221,6 +3258,7 @@ bool translate_metadata(const llama_model_loader * ml,
|
||||
if (arch_name == "qwen35moe") handle_qwen35moe(ml, meta, ctx);
|
||||
if (arch_name == "qwen35") handle_qwen35 (ml, meta, ctx);
|
||||
if (arch_name == "qwen3next") handle_qwen3next(meta, ctx);
|
||||
if (arch_name == "laguna") handle_laguna (meta, ctx);
|
||||
if (arch_name == "gptoss") handle_gptoss (ml, meta, ctx, arch_name);
|
||||
if (arch_name == "lfm2") handle_lfm2 (ml, meta, ctx);
|
||||
if (arch_name == "olmo3") handle_olmo3 (meta, arch_name);
|
||||
|
||||
42
llama/compat/models/003-llama-cpp-laguna-metal.patch
Normal file
42
llama/compat/models/003-llama-cpp-laguna-metal.patch
Normal file
@@ -0,0 +1,42 @@
|
||||
diff --git a/src/models/laguna.cpp b/src/models/laguna.cpp
|
||||
index 1d705440e..7e708b144 100644
|
||||
--- a/src/models/laguna.cpp
|
||||
+++ b/src/models/laguna.cpp
|
||||
@@ -275,16 +275,35 @@ llama_model_laguna::graph::graph(const llama_model & model, const llm_graph_para
|
||||
if ((uint32_t)il >= hparams.n_layer_dense_lead) {
|
||||
// MoE: sigmoid routing + score-correction bias + sum-norm +
|
||||
// routed_scaling_factor (all handled by build_moe_ffn).
|
||||
+ ggml_tensor * up_scale = nullptr;
|
||||
+ float expert_weights_scale = hparams.expert_weights_scale;
|
||||
+
|
||||
+#if defined(GGML_USE_METAL)
|
||||
+ const ggml_type down_type = model.layers[il].ffn_down_exps->type;
|
||||
+ if (n_tokens >= 32 && (ggml_is_quantized(down_type) || down_type == GGML_TYPE_F16)) {
|
||||
+ // At 32 prompt tokens, Metal switches MUL_MAT_ID from its
|
||||
+ // range-safe matrix-vector kernel to FP16 matrix tiles.
|
||||
+ // Laguna's routed SwiGLU activations can overflow those tiles.
|
||||
+ // Generation uses one token and does not need this workaround.
|
||||
+ constexpr float down_input_scale = 1.0f / 256.0f;
|
||||
+ up_scale = ggml_fill(ctx0,
|
||||
+ model.layers[il].ffn_exp_probs_b, down_input_scale);
|
||||
+ expert_weights_scale /= down_input_scale;
|
||||
+ }
|
||||
+#endif
|
||||
+
|
||||
ggml_tensor * moe_out = build_moe_ffn(cur,
|
||||
model.layers[il].ffn_gate_inp,
|
||||
model.layers[il].ffn_up_exps,
|
||||
model.layers[il].ffn_gate_exps,
|
||||
model.layers[il].ffn_down_exps,
|
||||
model.layers[il].ffn_exp_probs_b,
|
||||
n_expert, n_expert_used,
|
||||
LLM_FFN_SILU,
|
||||
hparams.expert_weights_norm,
|
||||
- hparams.expert_weights_scale,
|
||||
+ expert_weights_scale,
|
||||
(llama_expert_gating_func_type) hparams.expert_gating_func,
|
||||
- il);
|
||||
+ il,
|
||||
+ nullptr, nullptr,
|
||||
+ up_scale);
|
||||
cb(moe_out, "ffn_moe_out", il);
|
||||
@@ -1,93 +0,0 @@
|
||||
diff --git a/src/llama-arch.cpp b/src/llama-arch.cpp
|
||||
--- a/src/llama-arch.cpp
|
||||
+++ b/src/llama-arch.cpp
|
||||
@@ -136,2 +136,3 @@ static const std::map<llm_arch, const char *> LLM_ARCH_NAMES = {
|
||||
{ LLM_ARCH_MELLUM, "mellum" },
|
||||
+ { LLM_ARCH_LAGUNA, "laguna" },
|
||||
{ LLM_ARCH_UNKNOWN, "(unknown)" },
|
||||
@@ -398,2 +399,3 @@ static const std::map<llm_tensor, const char *> LLM_TENSOR_NAMES = {
|
||||
{ LLM_TENSOR_ATTN_GATE, "blk.%d.attn_gate" },
|
||||
+ { LLM_TENSOR_ATTN_GATE_LAGUNA, "blk.%d.attn_g" },
|
||||
{ LLM_TENSOR_FFN_POST_NORM, "blk.%d.post_ffw_norm" },
|
||||
@@ -596,2 +598,3 @@ static const std::map<llm_tensor, llm_tensor_info> LLM_TENSOR_INFOS = {
|
||||
{LLM_TENSOR_ATTN_GATE, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL_MAT}},
|
||||
+ {LLM_TENSOR_ATTN_GATE_LAGUNA, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL_MAT}},
|
||||
{LLM_TENSOR_FFN_GATE, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL_MAT}},
|
||||
diff --git a/src/llama-arch.h b/src/llama-arch.h
|
||||
--- a/src/llama-arch.h
|
||||
+++ b/src/llama-arch.h
|
||||
@@ -145,2 +145,3 @@ enum llm_arch {
|
||||
LLM_ARCH_DFLASH,
|
||||
+ LLM_ARCH_LAGUNA,
|
||||
LLM_ARCH_UNKNOWN,
|
||||
@@ -382,2 +383,3 @@ enum llm_tensor {
|
||||
LLM_TENSOR_ATTN_GATE,
|
||||
+ LLM_TENSOR_ATTN_GATE_LAGUNA,
|
||||
LLM_TENSOR_FFN_GATE_INP,
|
||||
diff --git a/src/llama-model.cpp b/src/llama-model.cpp
|
||||
--- a/src/llama-model.cpp
|
||||
+++ b/src/llama-model.cpp
|
||||
@@ -48,4 +48,6 @@ static llama_model * llama_model_mapping(llm_arch arch, const llama_model_params
|
||||
case LLM_ARCH_TALKIE:
|
||||
return new llama_model_talkie(params);
|
||||
+ case LLM_ARCH_LAGUNA:
|
||||
+ return new llama_model_laguna(params);
|
||||
case LLM_ARCH_DECI:
|
||||
return new llama_model_deci(params);
|
||||
@@ -2525,2 +2527,3 @@ llama_rope_type llama_model_rope_type(const llama_model * model) {
|
||||
case LLM_ARCH_MELLUM:
|
||||
+ case LLM_ARCH_LAGUNA:
|
||||
case LLM_ARCH_DFLASH:
|
||||
diff --git a/src/llama-model-loader.cpp b/src/llama-model-loader.cpp
|
||||
--- a/src/llama-model-loader.cpp
|
||||
+++ b/src/llama-model-loader.cpp
|
||||
@@ -505,2 +505,3 @@ namespace GGUFMeta {
|
||||
template bool llama_model_loader::get_key_or_arr<std::array<uint32_t, 512>>(enum llm_kv kid, std::array<uint32_t, 512> & result, uint32_t n, bool required);
|
||||
+ template bool llama_model_loader::get_key_or_arr<uint32_t, 512>(const std::string & key, std::array<uint32_t, 512> & result, uint32_t n, bool required);
|
||||
template bool llama_model_loader::get_key_or_arr<std::array<float, 512>>(enum llm_kv kid, std::array<float, 512> & result, uint32_t n, bool required);
|
||||
diff --git a/src/llama-vocab.cpp b/src/llama-vocab.cpp
|
||||
--- a/src/llama-vocab.cpp
|
||||
+++ b/src/llama-vocab.cpp
|
||||
@@ -359,2 +359,8 @@ struct llm_tokenizer_bpe : llm_tokenizer {
|
||||
break;
|
||||
+ case LLAMA_VOCAB_PRE_TYPE_LAGUNA:
|
||||
+ regex_exprs = {
|
||||
+ "(?:\\r?\\n)+(?!\\r?\\n)",
|
||||
+ "(?:'[sS]|'[tT]|'[rR][eE]|'[vV][eE]|'[mM]|'[lL][lL]|'[dD])|[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}| ?[^\\s\\p{L}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
|
||||
+ };
|
||||
+ break;
|
||||
case LLAMA_VOCAB_PRE_TYPE_GPT2:
|
||||
@@ -2100,2 +2106,4 @@ void llama_vocab::impl::load(llama_model_loader & ml, const LLM_KV & kv) {
|
||||
ignore_merges = true;
|
||||
+ } else if (tokenizer_pre == "laguna") {
|
||||
+ pre_type = LLAMA_VOCAB_PRE_TYPE_LAGUNA;
|
||||
} else if (
|
||||
@@ -2773,2 +2781,3 @@ void llama_vocab::impl::load(llama_model_loader & ml, const LLM_KV & kv) {
|
||||
|| t.first == "<|end▁of▁sentence|>" // deepseek-ocr
|
||||
+ || t.first == "</assistant>" // poolside Laguna (eos_token_ids)
|
||||
) {
|
||||
diff --git a/src/llama-vocab.h b/src/llama-vocab.h
|
||||
--- a/src/llama-vocab.h
|
||||
+++ b/src/llama-vocab.h
|
||||
@@ -65,2 +65,3 @@ enum llama_vocab_pre_type {
|
||||
LLAMA_VOCAB_PRE_TYPE_MELLUM2 = 55,
|
||||
+ LLAMA_VOCAB_PRE_TYPE_LAGUNA = 56,
|
||||
};
|
||||
diff --git a/src/models/models.h b/src/models/models.h
|
||||
--- a/src/models/models.h
|
||||
+++ b/src/models/models.h
|
||||
@@ -1657,1 +1657,14 @@ struct llama_model_arcee : public llama_model_base {
|
||||
+struct llama_model_laguna : public llama_model_base {
|
||||
+ llama_model_laguna(const struct llama_model_params & params) : llama_model_base(params) {}
|
||||
+ void load_arch_hparams(llama_model_loader & ml) override;
|
||||
+ void load_arch_tensors(llama_model_loader & ml) override;
|
||||
+
|
||||
+ struct graph : public llm_graph_context {
|
||||
+ graph(const llama_model & model, const llm_graph_params & params);
|
||||
+ };
|
||||
+
|
||||
+ std::unique_ptr<llm_graph_context> build_arch_graph(const llm_graph_params & params) const override;
|
||||
+};
|
||||
+
|
||||
+
|
||||
struct llama_model_arcee : public llama_model_base {
|
||||
253
llama/compat/models/laguna.cpp
vendored
253
llama/compat/models/laguna.cpp
vendored
@@ -1,253 +0,0 @@
|
||||
#include "models/models.h"
|
||||
|
||||
void llama_model_laguna::load_arch_hparams(llama_model_loader & ml) {
|
||||
ml.get_key(LLM_KV_ATTENTION_LAYERNORM_RMS_EPS, hparams.f_norm_rms_eps);
|
||||
|
||||
// MoE
|
||||
ml.get_key(LLM_KV_LEADING_DENSE_BLOCK_COUNT, hparams.n_layer_dense_lead, false);
|
||||
ml.get_key(LLM_KV_EXPERT_FEED_FORWARD_LENGTH, hparams.n_ff_exp);
|
||||
ml.get_key(LLM_KV_EXPERT_SHARED_FEED_FORWARD_LENGTH, hparams.n_ff_shexp, false);
|
||||
ml.get_key(LLM_KV_EXPERT_SHARED_COUNT, hparams.n_expert_shared, false);
|
||||
ml.get_key(LLM_KV_EXPERT_WEIGHTS_SCALE, hparams.expert_weights_scale, false);
|
||||
ml.get_key(LLM_KV_EXPERT_WEIGHTS_NORM, hparams.expert_weights_norm, false);
|
||||
ml.get_key(LLM_KV_EXPERT_GATING_FUNC, hparams.expert_gating_func, false);
|
||||
|
||||
ml.get_key(LLM_KV_ATTENTION_SLIDING_WINDOW, hparams.n_swa, false);
|
||||
hparams.swa_type = LLAMA_SWA_TYPE_STANDARD;
|
||||
ml.get_key_or_arr("laguna.attention.layer_types", hparams.is_swa_impl, hparams.n_layer(), false);
|
||||
|
||||
ml.get_key("laguna.rope.swa.dimension_count", hparams.n_rot_swa, false);
|
||||
ml.get_key("laguna.rope.swa.freq_base", hparams.rope_freq_base_train_swa, false);
|
||||
ml.get_key("laguna.rope.scaling.beta_fast", hparams.yarn_beta_fast, false);
|
||||
ml.get_key("laguna.rope.scaling.beta_slow", hparams.yarn_beta_slow, false);
|
||||
|
||||
type = LLM_TYPE_UNKNOWN;
|
||||
}
|
||||
|
||||
void llama_model_laguna::load_arch_tensors(llama_model_loader &) {
|
||||
LLAMA_LOAD_LOCALS;
|
||||
|
||||
const int64_t n_ff_exp = hparams.n_ff_exp;
|
||||
const int64_t n_ff_shexp = hparams.n_ff_shexp;
|
||||
|
||||
tok_embd = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, 0);
|
||||
|
||||
output_norm = create_tensor(tn(LLM_TENSOR_OUTPUT_NORM, "weight"), {n_embd}, 0);
|
||||
output = create_tensor(tn(LLM_TENSOR_OUTPUT, "weight"), {n_embd, n_vocab}, TENSOR_NOT_REQUIRED);
|
||||
if (output == NULL) {
|
||||
output = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, TENSOR_DUPLICATED);
|
||||
}
|
||||
|
||||
for (int i = 0; i < n_layer; ++i) {
|
||||
auto & layer = layers[i];
|
||||
|
||||
const int64_t n_head_i = hparams.n_head(i);
|
||||
const int64_t n_head_kv_i = hparams.n_head_kv(i);
|
||||
const int64_t n_embd_q = n_embd_head_k * n_head_i;
|
||||
const int64_t n_embd_kv = n_embd_head_k * n_head_kv_i;
|
||||
|
||||
layer.attn_norm = create_tensor(tn(LLM_TENSOR_ATTN_NORM, "weight", i), {n_embd}, 0);
|
||||
|
||||
layer.wq = create_tensor(tn(LLM_TENSOR_ATTN_Q, "weight", i), {n_embd, n_embd_q}, 0);
|
||||
layer.wk = create_tensor(tn(LLM_TENSOR_ATTN_K, "weight", i), {n_embd, n_embd_kv}, 0);
|
||||
layer.wv = create_tensor(tn(LLM_TENSOR_ATTN_V, "weight", i), {n_embd, n_embd_kv}, 0);
|
||||
layer.wo = create_tensor(tn(LLM_TENSOR_ATTN_OUT, "weight", i), {n_embd_q, n_embd}, 0);
|
||||
|
||||
layer.wqkv_gate = create_tensor(tn(LLM_TENSOR_ATTN_GATE_LAGUNA, "weight", i), {n_embd, n_head_i}, 0);
|
||||
|
||||
layer.attn_q_norm = create_tensor(tn(LLM_TENSOR_ATTN_Q_NORM, "weight", i), {n_embd_head_k}, 0);
|
||||
layer.attn_k_norm = create_tensor(tn(LLM_TENSOR_ATTN_K_NORM, "weight", i), {n_embd_head_k}, 0);
|
||||
|
||||
layer.ffn_norm = create_tensor(tn(LLM_TENSOR_FFN_NORM, "weight", i), {n_embd}, 0);
|
||||
|
||||
if (i < (int) hparams.n_layer_dense_lead) {
|
||||
layer.ffn_gate = create_tensor(tn(LLM_TENSOR_FFN_GATE, "weight", i), {n_embd, n_ff}, 0);
|
||||
layer.ffn_down = create_tensor(tn(LLM_TENSOR_FFN_DOWN, "weight", i), { n_ff, n_embd}, 0);
|
||||
layer.ffn_up = create_tensor(tn(LLM_TENSOR_FFN_UP, "weight", i), {n_embd, n_ff}, 0);
|
||||
} else {
|
||||
layer.ffn_gate_inp = create_tensor(tn(LLM_TENSOR_FFN_GATE_INP, "weight", i), {n_embd, n_expert}, 0);
|
||||
layer.ffn_exp_probs_b = create_tensor(tn(LLM_TENSOR_FFN_EXP_PROBS_B, "bias", i), {n_expert}, TENSOR_NOT_REQUIRED);
|
||||
|
||||
layer.ffn_gate_exps = create_tensor(tn(LLM_TENSOR_FFN_GATE_EXPS, "weight", i), { n_embd, n_ff_exp, n_expert}, 0);
|
||||
layer.ffn_down_exps = create_tensor(tn(LLM_TENSOR_FFN_DOWN_EXPS, "weight", i), {n_ff_exp, n_embd, n_expert}, 0);
|
||||
layer.ffn_up_exps = create_tensor(tn(LLM_TENSOR_FFN_UP_EXPS, "weight", i), { n_embd, n_ff_exp, n_expert}, 0);
|
||||
|
||||
layer.ffn_gate_shexp = create_tensor(tn(LLM_TENSOR_FFN_GATE_SHEXP, "weight", i), {n_embd, n_ff_shexp}, 0);
|
||||
layer.ffn_down_shexp = create_tensor(tn(LLM_TENSOR_FFN_DOWN_SHEXP, "weight", i), {n_ff_shexp, n_embd}, 0);
|
||||
layer.ffn_up_shexp = create_tensor(tn(LLM_TENSOR_FFN_UP_SHEXP, "weight", i), {n_embd, n_ff_shexp}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::unique_ptr<llm_graph_context> llama_model_laguna::build_arch_graph(const llm_graph_params & params) const {
|
||||
return std::make_unique<graph>(*this, params);
|
||||
}
|
||||
|
||||
llama_model_laguna::graph::graph(const llama_model & model, const llm_graph_params & params) :
|
||||
llm_graph_context(params) {
|
||||
const int64_t n_embd_head = hparams.n_embd_head_v();
|
||||
GGML_ASSERT(n_embd_head == hparams.n_embd_head_k());
|
||||
|
||||
const float kq_scale = 1.0f / sqrtf(float(n_embd_head));
|
||||
|
||||
ggml_tensor * cur;
|
||||
ggml_tensor * inpL;
|
||||
|
||||
inpL = build_inp_embd(model.tok_embd);
|
||||
|
||||
ggml_tensor * inp_pos = build_inp_pos();
|
||||
|
||||
auto * inp_attn = build_attn_inp_kv_iswa();
|
||||
|
||||
ggml_tensor * inp_out_ids = build_inp_out_ids();
|
||||
|
||||
for (int il = 0; il < n_layer; ++il) {
|
||||
ggml_tensor * inpSA = inpL;
|
||||
|
||||
const int64_t n_head_il = hparams.n_head(il);
|
||||
const int64_t n_head_kv_il = hparams.n_head_kv(il);
|
||||
const bool is_swa = hparams.is_swa(il);
|
||||
|
||||
const int rope_n_dims = hparams.n_rot(il);
|
||||
const float rope_base = is_swa ? hparams.rope_freq_base_train_swa : hparams.rope_freq_base_train;
|
||||
const float rope_scale = is_swa ? hparams.rope_freq_scale_train_swa : hparams.rope_freq_scale_train;
|
||||
const float rope_ext = is_swa ? 0.0f : 1.0f;
|
||||
const float rope_bfast = hparams.yarn_beta_fast;
|
||||
const float rope_bslow = hparams.yarn_beta_slow;
|
||||
|
||||
cur = build_norm(inpL, model.layers[il].attn_norm, NULL, LLM_NORM_RMS, il);
|
||||
cb(cur, "attn_norm", il);
|
||||
|
||||
// self-attention
|
||||
{
|
||||
ggml_tensor * Qcur = build_lora_mm(model.layers[il].wq, cur);
|
||||
cb(Qcur, "Qcur", il);
|
||||
ggml_tensor * Kcur = build_lora_mm(model.layers[il].wk, cur);
|
||||
cb(Kcur, "Kcur", il);
|
||||
ggml_tensor * Vcur = build_lora_mm(model.layers[il].wv, cur);
|
||||
cb(Vcur, "Vcur", il);
|
||||
|
||||
ggml_tensor * gate = build_lora_mm(model.layers[il].wqkv_gate, cur);
|
||||
cb(gate, "gate", il);
|
||||
|
||||
Qcur = ggml_reshape_3d(ctx0, Qcur, n_embd_head, n_head_il, n_tokens);
|
||||
Kcur = ggml_reshape_3d(ctx0, Kcur, n_embd_head, n_head_kv_il, n_tokens);
|
||||
Vcur = ggml_reshape_3d(ctx0, Vcur, n_embd_head, n_head_kv_il, n_tokens);
|
||||
|
||||
Qcur = build_norm(Qcur, model.layers[il].attn_q_norm, NULL, LLM_NORM_RMS, il);
|
||||
cb(Qcur, "Qcur_normed", il);
|
||||
Kcur = build_norm(Kcur, model.layers[il].attn_k_norm, NULL, LLM_NORM_RMS, il);
|
||||
cb(Kcur, "Kcur_normed", il);
|
||||
|
||||
Qcur = ggml_rope_ext(ctx0, Qcur, inp_pos, nullptr,
|
||||
rope_n_dims, rope_type, hparams.n_ctx_orig_yarn, rope_base, rope_scale,
|
||||
rope_ext, hparams.rope_attn_factor, rope_bfast, rope_bslow);
|
||||
Kcur = ggml_rope_ext(ctx0, Kcur, inp_pos, nullptr,
|
||||
rope_n_dims, rope_type, hparams.n_ctx_orig_yarn, rope_base, rope_scale,
|
||||
rope_ext, hparams.rope_attn_factor, rope_bfast, rope_bslow);
|
||||
|
||||
cb(Qcur, "Qcur", il);
|
||||
cb(Kcur, "Kcur", il);
|
||||
cb(Vcur, "Vcur", il);
|
||||
|
||||
cur = build_attn(inp_attn,
|
||||
nullptr, nullptr, nullptr,
|
||||
Qcur, Kcur, Vcur, nullptr, nullptr, nullptr, kq_scale, il);
|
||||
cb(cur, "attn_pregate", il);
|
||||
|
||||
gate = ggml_softplus(ctx0, gate);
|
||||
cur = ggml_reshape_3d(ctx0, cur, n_embd_head, n_head_il, n_tokens);
|
||||
gate = ggml_reshape_3d(ctx0, gate, 1, n_head_il, n_tokens);
|
||||
cur = ggml_mul(ctx0, cur, gate);
|
||||
cur = ggml_reshape_2d(ctx0, cur, n_embd_head * n_head_il, n_tokens);
|
||||
cb(cur, "attn_gated", il);
|
||||
|
||||
cur = build_lora_mm(model.layers[il].wo, cur, model.layers[il].wo_s);
|
||||
cb(cur, "attn_out", il);
|
||||
}
|
||||
|
||||
if (il == n_layer - 1 && inp_out_ids) {
|
||||
cur = ggml_get_rows(ctx0, cur, inp_out_ids);
|
||||
inpSA = ggml_get_rows(ctx0, inpSA, inp_out_ids);
|
||||
}
|
||||
|
||||
ggml_tensor * ffn_inp = ggml_add(ctx0, cur, inpSA);
|
||||
cb(ffn_inp, "ffn_inp", il);
|
||||
|
||||
// feed-forward
|
||||
cur = build_norm(ffn_inp, model.layers[il].ffn_norm, NULL, LLM_NORM_RMS, il);
|
||||
cb(cur, "ffn_norm", il);
|
||||
|
||||
if ((uint32_t) il < hparams.n_layer_dense_lead) {
|
||||
cur = build_ffn(cur,
|
||||
model.layers[il].ffn_up, NULL, NULL,
|
||||
model.layers[il].ffn_gate, NULL, NULL,
|
||||
model.layers[il].ffn_down, NULL, NULL,
|
||||
NULL, LLM_FFN_SILU, LLM_FFN_PAR, il);
|
||||
cb(cur, "ffn_out", il);
|
||||
} else {
|
||||
ggml_tensor * up_scale = nullptr;
|
||||
float expert_weights_scale = hparams.expert_weights_scale;
|
||||
|
||||
#if defined(GGML_USE_METAL)
|
||||
if (n_tokens >= 32 && ggml_is_quantized(model.layers[il].ffn_down_exps->type)) {
|
||||
// ggml-metal switches MUL_MAT_ID from its range-safe
|
||||
// matrix-vector kernel to FP16 matrix tiles at 32 tokens
|
||||
// (ne21_mm_id_min in ggml_metal_op_mul_mat_id). Laguna's routed
|
||||
// SwiGLU activations can overflow those tiles. Scale the linear
|
||||
// up branch and fold the inverse power-of-two factor into the
|
||||
// existing routing-weight scale. Revisit this guard if the
|
||||
// Metal dispatch threshold changes.
|
||||
constexpr float down_input_scale = 1.0f / 256.0f;
|
||||
up_scale = ggml_fill(ctx0,
|
||||
model.layers[il].ffn_exp_probs_b, down_input_scale);
|
||||
expert_weights_scale /= down_input_scale;
|
||||
}
|
||||
#endif
|
||||
|
||||
ggml_tensor * moe_out = build_moe_ffn(cur,
|
||||
model.layers[il].ffn_gate_inp,
|
||||
model.layers[il].ffn_up_exps,
|
||||
model.layers[il].ffn_gate_exps,
|
||||
model.layers[il].ffn_down_exps,
|
||||
model.layers[il].ffn_exp_probs_b,
|
||||
n_expert, n_expert_used,
|
||||
LLM_FFN_SILU, hparams.expert_weights_norm,
|
||||
expert_weights_scale,
|
||||
(llama_expert_gating_func_type) hparams.expert_gating_func,
|
||||
il,
|
||||
nullptr, nullptr,
|
||||
up_scale);
|
||||
cb(moe_out, "ffn_moe_out", il);
|
||||
|
||||
ggml_tensor * ffn_shexp = build_ffn(cur,
|
||||
model.layers[il].ffn_up_shexp, NULL, NULL,
|
||||
model.layers[il].ffn_gate_shexp, NULL, NULL,
|
||||
model.layers[il].ffn_down_shexp, NULL, NULL,
|
||||
NULL, LLM_FFN_SILU, LLM_FFN_PAR, il);
|
||||
cb(ffn_shexp, "ffn_shexp", il);
|
||||
|
||||
cur = ggml_add(ctx0, moe_out, ffn_shexp);
|
||||
cb(cur, "ffn_out", il);
|
||||
}
|
||||
|
||||
cur = ggml_add(ctx0, cur, ffn_inp);
|
||||
|
||||
cur = build_cvec(cur, il);
|
||||
cb(cur, "l_out", il);
|
||||
|
||||
inpL = cur;
|
||||
}
|
||||
|
||||
cur = inpL;
|
||||
|
||||
cur = build_norm(cur, model.output_norm, NULL, LLM_NORM_RMS, -1);
|
||||
cb(cur, "result_norm", -1);
|
||||
res->t_embd = cur;
|
||||
|
||||
cur = build_lora_mm(model.output, cur, model.output_s);
|
||||
cb(cur, "result_output", -1);
|
||||
res->t_logits = cur;
|
||||
|
||||
ggml_build_forward_expand(gf, cur);
|
||||
}
|
||||
@@ -96,6 +96,14 @@ func (p *GLM46Parser) Add(s string, done bool) (content string, thinking string,
|
||||
p.buffer.WriteString(s)
|
||||
events := p.parseEvents()
|
||||
|
||||
if done && (p.state == glm46ParserState_ToolStartedEatingWhitespace || p.state == glm46ParserState_CollectingToolContent) {
|
||||
event, err := p.finalizeToolCall()
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("incomplete GLM tool call: %v", err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
|
||||
var toolCalls []api.ToolCall
|
||||
var contentSb strings.Builder
|
||||
var thinkingSb strings.Builder
|
||||
@@ -123,6 +131,66 @@ func (p *GLM46Parser) Add(s string, done bool) (content string, thinking string,
|
||||
return contentSb.String(), thinkingSb.String(), toolCalls, nil
|
||||
}
|
||||
|
||||
func (p *GLM46Parser) finalizeToolCall() (glm46EventRawToolCall, error) {
|
||||
raw := p.buffer.String()
|
||||
if overlapLen := overlap(raw, glm46ToolCloseTag); overlapLen > 0 {
|
||||
raw = strings.TrimRightFunc(raw[:len(raw)-overlapLen], unicode.IsSpace)
|
||||
}
|
||||
|
||||
escaped := escapeGLM46Content(raw)
|
||||
var parsed GLMToolCallXML
|
||||
if err := xml.Unmarshal([]byte("<tool_call>"+escaped+"</tool_call>"), &parsed); err != nil {
|
||||
return glm46EventRawToolCall{}, err
|
||||
}
|
||||
if err := validateFinalGLM46ToolCall(parsed, p.tools); err != nil {
|
||||
return glm46EventRawToolCall{}, err
|
||||
}
|
||||
|
||||
p.buffer.Reset()
|
||||
p.state = glm46ParserState_CollectingContent
|
||||
return glm46EventRawToolCall{raw: raw}, nil
|
||||
}
|
||||
|
||||
// validateFinalGLM46ToolCall is intentionally stricter than normal GLM parsing.
|
||||
// At end-of-stream only the outer closing tag may be missing; repairing a
|
||||
// truncated argument could turn partial model output into a mutating tool call.
|
||||
func validateFinalGLM46ToolCall(parsed GLMToolCallXML, tools []api.Tool) error {
|
||||
functionName := strings.TrimSpace(parsed.Content)
|
||||
if functionName == "" {
|
||||
return fmt.Errorf("empty function name")
|
||||
}
|
||||
if len(parsed.Keys) != len(parsed.Values) {
|
||||
return fmt.Errorf("mismatched arg_key and arg_value counts: %d keys, %d values", len(parsed.Keys), len(parsed.Values))
|
||||
}
|
||||
|
||||
var declaredTool *api.Tool
|
||||
for i := range tools {
|
||||
if tools[i].Function.Name == functionName {
|
||||
declaredTool = &tools[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if declaredTool == nil {
|
||||
return fmt.Errorf("tool %q is not declared", functionName)
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(parsed.Keys))
|
||||
for _, rawKey := range parsed.Keys {
|
||||
key := strings.TrimSpace(rawKey)
|
||||
if key == "" {
|
||||
return fmt.Errorf("empty argument name")
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
|
||||
for _, required := range declaredTool.Function.Parameters.Required {
|
||||
if _, ok := seen[required]; !ok {
|
||||
return fmt.Errorf("required argument %q is missing for tool %q", required, functionName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *GLM46Parser) parseEvents() []glm46Event {
|
||||
var all []glm46Event
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package parsers
|
||||
import (
|
||||
"encoding/xml"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
@@ -445,6 +446,140 @@ func TestGLM46ParserStreaming(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGLM46ParserFinalizesCompleteToolCallOnDone(t *testing.T) {
|
||||
type chunk struct {
|
||||
content string
|
||||
done bool
|
||||
}
|
||||
|
||||
toolBody := `grep
|
||||
<arg_key>pattern</arg_key>
|
||||
<arg_value>needle</arg_value>
|
||||
<arg_key>path</arg_key>
|
||||
<arg_value>.</arg_value>`
|
||||
tests := []struct {
|
||||
name string
|
||||
chunks []chunk
|
||||
}{
|
||||
{
|
||||
name: "empty final chunk",
|
||||
chunks: []chunk{
|
||||
{content: "<tool_call>" + toolBody},
|
||||
{done: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tool body in final chunk",
|
||||
chunks: []chunk{
|
||||
{content: "<tool_call>" + toolBody, done: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "partial outer close in final chunk",
|
||||
chunks: []chunk{
|
||||
{content: "<tool_call>" + toolBody + "</tool_", done: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tools := glm46FinalizationTestTools()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := GLM46Parser{}
|
||||
parser.Init(tools, nil, nil)
|
||||
|
||||
var calls []api.ToolCall
|
||||
for _, chunk := range tt.chunks {
|
||||
content, thinking, got, err := parser.Add(chunk.content, chunk.done)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "" || thinking != "" {
|
||||
t.Fatalf("content=%q thinking=%q, want empty", content, thinking)
|
||||
}
|
||||
calls = append(calls, got...)
|
||||
}
|
||||
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("got %d tool calls, want 1", len(calls))
|
||||
}
|
||||
if calls[0].Function.Name != "grep" {
|
||||
t.Fatalf("tool name=%q, want grep", calls[0].Function.Name)
|
||||
}
|
||||
if pattern, ok := calls[0].Function.Arguments.Get("pattern"); !ok || pattern != "needle" {
|
||||
t.Fatalf("pattern=%#v, %v; want needle", pattern, ok)
|
||||
}
|
||||
if path, ok := calls[0].Function.Arguments.Get("path"); !ok || path != "." {
|
||||
t.Fatalf("path=%#v, %v; want .", path, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGLM46ParserRejectsIncompleteToolCallOnDone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{name: "empty body", input: "<tool_call>"},
|
||||
{name: "partial tool name", input: "<tool_call>gr"},
|
||||
{
|
||||
name: "incomplete argument value",
|
||||
input: `<tool_call>write
|
||||
<arg_key>path</arg_key>
|
||||
<arg_value>out.txt</arg_value>
|
||||
<arg_key>content</arg_key>
|
||||
<arg_value>partial`,
|
||||
},
|
||||
{
|
||||
name: "undeclared tool",
|
||||
input: `<tool_call>shell
|
||||
<arg_key>command</arg_key>
|
||||
<arg_value>echo unsafe</arg_value>`,
|
||||
},
|
||||
{
|
||||
name: "missing required argument",
|
||||
input: `<tool_call>write
|
||||
<arg_key>path</arg_key>
|
||||
<arg_value>out.txt</arg_value>`,
|
||||
},
|
||||
}
|
||||
|
||||
tools := glm46FinalizationTestTools()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parser := GLM46Parser{}
|
||||
parser.Init(tools, nil, nil)
|
||||
|
||||
content, thinking, calls, err := parser.Add(tt.input, true)
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete GLM tool call") {
|
||||
t.Fatalf("error=%v, want incomplete GLM tool call", err)
|
||||
}
|
||||
if content != "" || thinking != "" || len(calls) != 0 {
|
||||
t.Fatalf("content=%q thinking=%q calls=%v, want no output", content, thinking, calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func glm46FinalizationTestTools() []api.Tool {
|
||||
grep := tool("grep", map[string]api.ToolProperty{
|
||||
"pattern": {Type: api.PropertyType{"string"}},
|
||||
"path": {Type: api.PropertyType{"string"}},
|
||||
})
|
||||
grep.Function.Parameters.Required = []string{"pattern", "path"}
|
||||
|
||||
write := tool("write", map[string]api.ToolProperty{
|
||||
"path": {Type: api.PropertyType{"string"}},
|
||||
"content": {Type: api.PropertyType{"string"}},
|
||||
})
|
||||
write.Function.Parameters.Required = []string{"path", "content"}
|
||||
|
||||
return []api.Tool{grep, write}
|
||||
}
|
||||
|
||||
// TestGLMToolCallXMLOrderPreservation verifies that xml.Unmarshal preserves
|
||||
// document order when collecting multiple elements with the same tag name into slices.
|
||||
// This is a critical assumption for the GLM-4.6 parser's struct-based approach.
|
||||
|
||||
@@ -535,12 +535,12 @@ func TestLagunaParserNonAssistantLastMessageStillPrimesThinking(t *testing.T) {
|
||||
func TestLagunaV8ParserAssistantHistoryStillPrimesThinking(t *testing.T) {
|
||||
// Laguna v8 closes assistant history and emits a fresh generation prompt,
|
||||
// so an assistant tail message must not switch the parser into prefill mode.
|
||||
parser := ParserForName("laguna-v8")
|
||||
parser := ParserForName("poolside-v1")
|
||||
if parser == nil {
|
||||
t.Fatal("expected laguna-v8 parser")
|
||||
t.Fatal("expected poolside-v1 parser")
|
||||
}
|
||||
if !parser.HasToolSupport() || !parser.HasThinkingSupport() {
|
||||
t.Fatal("laguna-v8 parser should advertise tools and thinking")
|
||||
t.Fatal("poolside-v1 parser should advertise tools and thinking")
|
||||
}
|
||||
|
||||
parser.Init(nil, &api.Message{Role: "assistant", Content: "Previous."}, &api.ThinkValue{Value: true})
|
||||
|
||||
@@ -94,7 +94,7 @@ func ParserForName(name string) Parser {
|
||||
return &LFM2Parser{hasThinkingSupport: true}
|
||||
case "laguna":
|
||||
return &LagunaParser{}
|
||||
case "laguna-v8":
|
||||
case "poolside-v1":
|
||||
return &LagunaV8Parser{}
|
||||
case "cohere":
|
||||
return &CohereParser{}
|
||||
|
||||
@@ -109,7 +109,7 @@ func rendererForName(name string) Renderer {
|
||||
return &LFM2Renderer{IsThinking: true, useImgTags: RenderImgTags}
|
||||
case "laguna":
|
||||
return &LagunaRenderer{}
|
||||
case "laguna-v8":
|
||||
case "poolside-v1":
|
||||
return &LagunaV8Renderer{}
|
||||
case "cohere":
|
||||
return &CohereRenderer{}
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestLeadingBOSForRenderer(t *testing.T) {
|
||||
{name: "lfm2", want: "<|startoftext|>"},
|
||||
{name: "lfm2-thinking", want: "<|startoftext|>"},
|
||||
{name: "laguna", want: "〈|EOS|〉"},
|
||||
{name: "laguna-v8", want: "〈|EOS|〉"},
|
||||
{name: "poolside-v1", want: "〈|EOS|〉"},
|
||||
{name: "deepseek3.1", want: "<|begin▁of▁sentence|>"},
|
||||
{name: "cogito", want: "<|begin▁of▁sentence|>"},
|
||||
{name: "qwen3-coder", want: ""},
|
||||
|
||||
Reference in New Issue
Block a user