server: add skill layer support
Add support for skill layers in model manifests: - server/skill.go: New file with skill extraction and packaging - GetSkillsPath: Returns path to extracted skills cache - ExtractSkillBlob: Extracts skill tar.gz to cache - CreateSkillLayer: Creates skill blob from directory - ParseSkillName/GetSkillManifestPath: Skill name handling - server/images.go: Extract skill layers on pull - server/create.go: Create skill layers from SKILL directives - server/routes.go: Skill-related route handling Skills are stored as gzipped tar archives with MediaType "application/vnd.ollama.image.skill".
This commit is contained in:
parent
253b035b4a
commit
d08c33faa0
101
server/create.go
101
server/create.go
|
|
@ -62,6 +62,8 @@ func (s *Server) CreateHandler(c *gin.Context) {
|
|||
config.Renderer = r.Renderer
|
||||
config.Parser = r.Parser
|
||||
config.Requires = r.Requires
|
||||
config.Skills = r.Skills
|
||||
config.AgentType = r.AgentType
|
||||
|
||||
for v := range r.Files {
|
||||
if !fs.ValidPath(v) {
|
||||
|
|
@ -543,6 +545,12 @@ func createModel(r api.CreateRequest, name model.Name, baseLayers []*layerGGML,
|
|||
return err
|
||||
}
|
||||
|
||||
// Handle skill layers for agents
|
||||
layers, config.Skills, err = setSkillLayers(layers, config.Skills, fn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configLayer, err := createConfigLayer(layers, *config)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -793,6 +801,99 @@ func setMessages(layers []Layer, m []api.Message) ([]Layer, error) {
|
|||
return layers, nil
|
||||
}
|
||||
|
||||
// setSkillLayers creates skill layers for local skill paths and updates the skill refs.
|
||||
// Local paths are converted to bundled skill layers with digests.
|
||||
// Registry references are kept as-is for later resolution during pull.
|
||||
func setSkillLayers(layers []Layer, skills []model.SkillRef, fn func(resp api.ProgressResponse)) ([]Layer, []model.SkillRef, error) {
|
||||
if len(skills) == 0 {
|
||||
return layers, skills, nil
|
||||
}
|
||||
|
||||
// Remove any existing skill layers
|
||||
layers = removeLayer(layers, MediaTypeSkill)
|
||||
|
||||
var updatedSkills []model.SkillRef
|
||||
|
||||
for _, skill := range skills {
|
||||
// Check if this is a local path
|
||||
if IsLocalSkillPath(skill.Name) {
|
||||
// Expand home directory if needed
|
||||
skillPath := skill.Name
|
||||
if strings.HasPrefix(skillPath, "~") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("expanding home directory: %w", err)
|
||||
}
|
||||
skillPath = filepath.Join(home, skillPath[1:])
|
||||
}
|
||||
|
||||
// Make absolute
|
||||
absPath, err := filepath.Abs(skillPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("resolving skill path %q: %w", skill.Name, err)
|
||||
}
|
||||
|
||||
// Check if this is a direct skill directory or a parent containing skills
|
||||
skillMdPath := filepath.Join(absPath, "SKILL.md")
|
||||
if _, err := os.Stat(skillMdPath); err == nil {
|
||||
// Direct skill directory
|
||||
fn(api.ProgressResponse{Status: fmt.Sprintf("packaging skill: %s", filepath.Base(absPath))})
|
||||
|
||||
layer, err := CreateSkillLayer(absPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating skill layer for %q: %w", skill.Name, err)
|
||||
}
|
||||
|
||||
layers = append(layers, layer)
|
||||
updatedSkills = append(updatedSkills, model.SkillRef{
|
||||
Name: filepath.Base(absPath),
|
||||
Digest: layer.Digest,
|
||||
})
|
||||
} else {
|
||||
// Parent directory - walk to find skill subdirectories
|
||||
err := filepath.WalkDir(absPath, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if entry.Name() != "SKILL.md" {
|
||||
return nil
|
||||
}
|
||||
|
||||
skillDir := filepath.Dir(path)
|
||||
skillName := filepath.Base(skillDir)
|
||||
fn(api.ProgressResponse{Status: fmt.Sprintf("packaging skill: %s", skillName)})
|
||||
|
||||
layer, err := CreateSkillLayer(skillDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating skill layer for %q: %w", skillDir, err)
|
||||
}
|
||||
|
||||
layers = append(layers, layer)
|
||||
updatedSkills = append(updatedSkills, model.SkillRef{
|
||||
Name: skillName,
|
||||
Digest: layer.Digest,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("walking skill directory %q: %w", skill.Name, err)
|
||||
}
|
||||
}
|
||||
} else if skill.Digest != "" {
|
||||
// Already has a digest (from a pulled agent), keep as-is
|
||||
updatedSkills = append(updatedSkills, skill)
|
||||
} else {
|
||||
// Registry reference - keep as-is for later resolution
|
||||
updatedSkills = append(updatedSkills, skill)
|
||||
}
|
||||
}
|
||||
|
||||
return layers, updatedSkills, nil
|
||||
}
|
||||
|
||||
func createConfigLayer(layers []Layer, config model.ConfigV2) (*Layer, error) {
|
||||
digests := make([]string, len(layers))
|
||||
for i, layer := range layers {
|
||||
|
|
|
|||
|
|
@ -657,6 +657,16 @@ func PullModel(ctx context.Context, name string, regOpts *registryOptions, fn fu
|
|||
}
|
||||
}
|
||||
|
||||
// Extract skill layers to the skills cache
|
||||
for _, layer := range manifest.Layers {
|
||||
if layer.MediaType == MediaTypeSkill {
|
||||
fn(api.ProgressResponse{Status: fmt.Sprintf("extracting skill %s", layer.Digest)})
|
||||
if _, err := ExtractSkillBlob(layer.Digest); err != nil {
|
||||
return fmt.Errorf("extracting skill layer %s: %w", layer.Digest, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn(api.ProgressResponse{Status: "writing manifest"})
|
||||
|
||||
manifestJSON, err := json.Marshal(manifest)
|
||||
|
|
|
|||
|
|
@ -1107,6 +1107,8 @@ func GetModelInfo(req api.ShowRequest) (*api.ShowResponse, error) {
|
|||
Capabilities: m.Capabilities(),
|
||||
ModifiedAt: manifest.fi.ModTime(),
|
||||
Requires: m.Config.Requires,
|
||||
Skills: m.Config.Skills,
|
||||
AgentType: m.Config.AgentType,
|
||||
}
|
||||
|
||||
if m.Config.RemoteHost != "" {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,325 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// MediaTypeSkill is the media type for skill layers in manifests.
|
||||
const MediaTypeSkill = "application/vnd.ollama.image.skill"
|
||||
|
||||
// GetSkillsPath returns the path to the extracted skills cache directory.
|
||||
// If digest is empty, returns the skills directory itself.
|
||||
// If digest is provided, returns the path to the extracted skill for that digest.
|
||||
func GetSkillsPath(digest string) (string, error) {
|
||||
// only accept actual sha256 digests
|
||||
pattern := "^sha256[:-][0-9a-fA-F]{64}$"
|
||||
re := regexp.MustCompile(pattern)
|
||||
|
||||
if digest != "" && !re.MatchString(digest) {
|
||||
return "", ErrInvalidDigestFormat
|
||||
}
|
||||
|
||||
digest = strings.ReplaceAll(digest, ":", "-")
|
||||
path := filepath.Join(envconfig.Models(), "skills", digest)
|
||||
dirPath := filepath.Dir(path)
|
||||
if digest == "" {
|
||||
dirPath = path
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dirPath, 0o755); err != nil {
|
||||
return "", fmt.Errorf("%w: ensure path elements are traversable", err)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ExtractSkillBlob extracts a skill tar.gz blob to the skills cache.
|
||||
// The blob is expected to be at the blobs path for the given digest.
|
||||
// Returns the path to the extracted skill directory.
|
||||
func ExtractSkillBlob(digest string) (string, error) {
|
||||
// Get the blob path
|
||||
blobPath, err := GetBlobsPath(digest)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getting blob path: %w", err)
|
||||
}
|
||||
|
||||
// Get the extraction path
|
||||
skillPath, err := GetSkillsPath(digest)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getting skill path: %w", err)
|
||||
}
|
||||
|
||||
// Check if already extracted
|
||||
if _, err := os.Stat(filepath.Join(skillPath, "SKILL.md")); err == nil {
|
||||
return skillPath, nil
|
||||
}
|
||||
|
||||
// Open the blob
|
||||
f, err := os.Open(blobPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("opening blob: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Create gzip reader
|
||||
gzr, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating gzip reader: %w", err)
|
||||
}
|
||||
defer gzr.Close()
|
||||
|
||||
// Create tar reader
|
||||
tr := tar.NewReader(gzr)
|
||||
|
||||
// Create the skill directory
|
||||
if err := os.MkdirAll(skillPath, 0o755); err != nil {
|
||||
return "", fmt.Errorf("creating skill directory: %w", err)
|
||||
}
|
||||
|
||||
// Extract files
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading tar: %w", err)
|
||||
}
|
||||
|
||||
// Clean the name and ensure it doesn't escape the target directory
|
||||
name := filepath.Clean(header.Name)
|
||||
if strings.HasPrefix(name, "..") {
|
||||
return "", fmt.Errorf("invalid path in archive: %s", header.Name)
|
||||
}
|
||||
|
||||
target := filepath.Join(skillPath, name)
|
||||
|
||||
// Verify the target is within skillPath
|
||||
if !strings.HasPrefix(target, filepath.Clean(skillPath)+string(os.PathSeparator)) && target != filepath.Clean(skillPath) {
|
||||
return "", fmt.Errorf("path escapes skill directory: %s", header.Name)
|
||||
}
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return "", fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
case tar.TypeReg:
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return "", fmt.Errorf("creating parent directory: %w", err)
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(outFile, tr); err != nil {
|
||||
outFile.Close()
|
||||
return "", fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
outFile.Close()
|
||||
}
|
||||
}
|
||||
|
||||
return skillPath, nil
|
||||
}
|
||||
|
||||
// CreateSkillLayer creates a skill layer from a local directory.
|
||||
// The directory must contain a SKILL.md file.
|
||||
// Returns the created layer.
|
||||
func CreateSkillLayer(skillDir string) (Layer, error) {
|
||||
// Verify SKILL.md exists
|
||||
skillMdPath := filepath.Join(skillDir, "SKILL.md")
|
||||
if _, err := os.Stat(skillMdPath); err != nil {
|
||||
return Layer{}, fmt.Errorf("skill directory must contain SKILL.md: %w", err)
|
||||
}
|
||||
|
||||
// Create a temporary file for the tar.gz
|
||||
blobsPath, err := GetBlobsPath("")
|
||||
if err != nil {
|
||||
return Layer{}, fmt.Errorf("getting blobs path: %w", err)
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp(blobsPath, "skill-*.tar.gz")
|
||||
if err != nil {
|
||||
return Layer{}, fmt.Errorf("creating temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer func() {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
}()
|
||||
|
||||
// Create gzip writer
|
||||
gzw := gzip.NewWriter(tmpFile)
|
||||
defer gzw.Close()
|
||||
|
||||
// Create tar writer
|
||||
tw := tar.NewWriter(gzw)
|
||||
defer tw.Close()
|
||||
|
||||
// Walk the skill directory and add files to tar
|
||||
err = filepath.Walk(skillDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get relative path
|
||||
relPath, err := filepath.Rel(skillDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip the root directory itself
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create tar header
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = relPath
|
||||
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write file contents if it's a regular file
|
||||
if !info.IsDir() {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(tw, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Layer{}, fmt.Errorf("creating tar archive: %w", err)
|
||||
}
|
||||
|
||||
// Close writers to flush
|
||||
if err := tw.Close(); err != nil {
|
||||
return Layer{}, fmt.Errorf("closing tar writer: %w", err)
|
||||
}
|
||||
if err := gzw.Close(); err != nil {
|
||||
return Layer{}, fmt.Errorf("closing gzip writer: %w", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return Layer{}, fmt.Errorf("closing temp file: %w", err)
|
||||
}
|
||||
|
||||
// Open the temp file for reading
|
||||
tmpFile, err = os.Open(tmpPath)
|
||||
if err != nil {
|
||||
return Layer{}, fmt.Errorf("reopening temp file: %w", err)
|
||||
}
|
||||
defer tmpFile.Close()
|
||||
|
||||
// Create the layer (this will compute the digest and move to blobs)
|
||||
layer, err := NewLayer(tmpFile, MediaTypeSkill)
|
||||
if err != nil {
|
||||
return Layer{}, fmt.Errorf("creating layer: %w", err)
|
||||
}
|
||||
|
||||
// Extract the skill to the cache so it's ready to use
|
||||
if _, err := ExtractSkillBlob(layer.Digest); err != nil {
|
||||
return Layer{}, fmt.Errorf("extracting skill: %w", err)
|
||||
}
|
||||
|
||||
return layer, nil
|
||||
}
|
||||
|
||||
// IsLocalSkillPath checks if a skill reference looks like a local path.
|
||||
func IsLocalSkillPath(name string) bool {
|
||||
// Local paths start with /, ./, or contain path separators
|
||||
return strings.HasPrefix(name, "/") ||
|
||||
strings.HasPrefix(name, "./") ||
|
||||
strings.HasPrefix(name, "../") ||
|
||||
strings.HasPrefix(name, "~") ||
|
||||
strings.Contains(name, string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// SkillNamespace is the namespace used for standalone skills in the registry.
|
||||
const SkillNamespace = "skill"
|
||||
|
||||
// IsSkillReference checks if a name refers to a skill (has skill/ prefix).
|
||||
func IsSkillReference(name string) bool {
|
||||
// Check for skill/ prefix (handles both "skill/foo" and "registry/skill/foo")
|
||||
name = strings.ReplaceAll(name, string(os.PathSeparator), "/")
|
||||
parts := strings.Split(name, "/")
|
||||
|
||||
// skill/name or skill/name:tag
|
||||
if len(parts) >= 1 && parts[0] == SkillNamespace {
|
||||
return true
|
||||
}
|
||||
// namespace/skill/name (e.g., myuser/skill/calc) - not a skill ref
|
||||
// registry/skill/name (e.g., registry.ollama.ai/skill/calc)
|
||||
if len(parts) >= 2 && parts[1] == SkillNamespace {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseSkillName parses a skill reference string into a model.Name.
|
||||
// The Kind field is set to "skill".
|
||||
// Examples:
|
||||
// - "calculator" -> library/skill/calculator:latest
|
||||
// - "myname/calculator" -> myname/skill/calculator:latest
|
||||
// - "myname/skill/calculator:1.0.0" -> myname/skill/calculator:1.0.0
|
||||
func ParseSkillName(name string) model.Name {
|
||||
// Use the standard parser which now handles Kind
|
||||
n := model.ParseName(name)
|
||||
|
||||
// If Kind wasn't set (old format without skill/), set it
|
||||
if n.Kind == "" {
|
||||
n.Kind = SkillNamespace
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// SkillDisplayName returns a user-friendly display name for a skill.
|
||||
func SkillDisplayName(n model.Name) string {
|
||||
return n.DisplayShortest()
|
||||
}
|
||||
|
||||
// GetSkillManifestPath returns the path to the skill manifest file.
|
||||
// Uses the 5-part structure: host/namespace/kind/model/tag
|
||||
func GetSkillManifestPath(n model.Name) (string, error) {
|
||||
if n.Model == "" {
|
||||
return "", fmt.Errorf("skill name is required")
|
||||
}
|
||||
|
||||
// Ensure Kind is set
|
||||
if n.Kind == "" {
|
||||
n.Kind = SkillNamespace
|
||||
}
|
||||
|
||||
path := filepath.Join(
|
||||
envconfig.Models(),
|
||||
"manifests",
|
||||
n.Filepath(),
|
||||
)
|
||||
|
||||
return path, nil
|
||||
}
|
||||
Loading…
Reference in New Issue