diff --git a/cmd/download_weights.go b/cmd/download_weights.go new file mode 100644 index 0000000..a77abc2 --- /dev/null +++ b/cmd/download_weights.go @@ -0,0 +1,144 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/CognitiveOS-Project/cpm/internal/weights" + "github.com/spf13/cobra" +) + +var ( + downloadProvider string + downloadKind string + downloadFormat string + downloadOutput string + downloadDryRun bool +) + +var downloadWeightsCmd = &cobra.Command{ + Use: "download-weights ", + Short: "Download model weights from a provider", + Long: `Download model weights (GGUF, safetensors) from a weight provider. + +Provider: hf (Hugging Face Hub) — searches public GGUF models sorted by downloads. + cpm download-weights --provider hf --kind wide --type gguf google/gemma-4-2b + cpm download-weights --provider hf --kind raw --type gguf CognitiveOS/raw-model + +Files are placed at: + --kind raw → /cognitiveos/models/raw/raw-model-.gguf (skip if exists) + --kind wide → /cognitiveos/models/wide/active/.gguf +`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + modelName := args[0] + + var kind weights.Kind + switch downloadKind { + case "raw": + kind = weights.KindRaw + case "wide": + kind = weights.KindWide + default: + return fmt.Errorf("invalid --kind: %q (must be raw or wide)", downloadKind) + } + + var format weights.Format + switch downloadFormat { + case "gguf": + format = weights.FormatGGUF + case "safetensors": + format = weights.FormatSafeTensors + default: + return fmt.Errorf("invalid --type: %q (must be gguf or safetensors)", downloadFormat) + } + + var prov weights.Provider + switch downloadProvider { + case "hf": + prov = weights.NewHFProvider() + default: + return fmt.Errorf("invalid --provider: %q (must be hf)", downloadProvider) + } + + ctx := context.Background() + + candidates, err := prov.Search(ctx, modelName, 5) + if err != nil { + return fmt.Errorf("search: %w", err) + } + + formatExt := "." + format.String() + + var match *weights.Candidate + for i := range candidates { + if strings.HasSuffix(strings.ToLower(candidates[i].Filename), formatExt) { + match = &candidates[i] + break + } + } + if match == nil { + for i := range candidates { + if strings.Contains(strings.ToLower(candidates[i].Filename), strings.ToLower(modelName)) { + match = &candidates[i] + break + } + } + } + if match == nil && len(candidates) > 0 { + match = &candidates[0] + } + if match == nil { + return fmt.Errorf("no %s files found for %q", formatExt, modelName) + } + + dest := downloadOutput + if dest == "" { + dest = resolveDest(kind, match) + } + fmt.Printf("Found: %s\n", match.DownloadURL) + fmt.Printf("File: %s\n", match.Filename) + fmt.Printf("Dest: %s\n", dest) + + if downloadDryRun { + return nil + } + + if kind == weights.KindRaw { + if _, err := os.Stat(dest); err == nil { + fmt.Printf("File already exists at %s — skipping (use --kind wide to re-download)\n", dest) + return nil + } + } + + fmt.Println("Downloading...") + if err := weights.Download(ctx, match.DownloadURL, dest, match.SHA256, weights.TextProgress); err != nil { + return fmt.Errorf("download: %w", err) + } + fmt.Printf("✓ Downloaded to %s\n", dest) + return nil + }, +} + +func resolveDest(kind weights.Kind, c *weights.Candidate) string { + dir := weights.ModelDir(kind) + if kind == weights.KindRaw { + base := strings.TrimSuffix(c.Filename, filepath.Ext(c.Filename)) + name := fmt.Sprintf("raw-model-%s%s", base, filepath.Ext(c.Filename)) + return filepath.Join(dir, name) + } + return filepath.Join(dir, c.Filename) +} + +func init() { + fs := downloadWeightsCmd.Flags() + fs.StringVar(&downloadProvider, "provider", "hf", "Weight provider (hf)") + fs.StringVar(&downloadKind, "kind", "wide", "Model kind (raw or wide)") + fs.StringVar(&downloadFormat, "type", "gguf", "File format (gguf or safetensors)") + fs.StringVar(&downloadOutput, "output", "", "Custom output path (overrides default)") + fs.BoolVar(&downloadDryRun, "dry-run", false, "Show what would be downloaded without downloading") + rootCmd.AddCommand(downloadWeightsCmd) +} diff --git a/cmd/info.go b/cmd/info.go index 80c4aae..e7466e8 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -64,7 +64,7 @@ var infoCmd = &cobra.Command{ func dirSize(path string) int64 { var size int64 - filepath.Walk(path, func(_ string, fi os.FileInfo, err error) error { + _ = filepath.Walk(path, func(_ string, fi os.FileInfo, err error) error { if err == nil && !fi.IsDir() { size += fi.Size() } diff --git a/cmd/init_cmd.go b/cmd/init_cmd.go index 62f5d71..465b6ac 100644 --- a/cmd/init_cmd.go +++ b/cmd/init_cmd.go @@ -9,6 +9,8 @@ import ( "github.com/spf13/cobra" ) +var initTemplate string + var initCmd = &cobra.Command{ Use: "init []", Short: "Create a .cgp skeleton directory", @@ -23,58 +25,131 @@ var initCmd = &cobra.Command{ return fmt.Errorf("directory %q already exists", dir) } - dirs := []string{ - filepath.Join(dir, "prompts", "templates"), - filepath.Join(dir, "tools"), - } - for _, d := range dirs { - if err := os.MkdirAll(d, 0755); err != nil { - return fmt.Errorf("create %s: %w", d, err) - } + switch initTemplate { + case "gguf-model": + return initGGUFModel(dir) + default: + return initDefault(dir) } + }, +} - manifest := map[string]interface{}{ - "name": filepath.Base(dir), - "version": "0.1.0", - "description": "Describe what this patch does", - "author": "", - "license": "MIT", - "hardware_requirements": map[string]interface{}{ - "min_ram_mb": 512, - "min_storage_mb": 50, - }, - "runtime": map[string]interface{}{ - "system_prompt": "prompts/system.md", - "tools_root": "tools", - }, +func initDefault(dir string) error { + dirs := []string{ + filepath.Join(dir, "prompts", "templates"), + filepath.Join(dir, "tools"), + } + for _, d := range dirs { + if err := os.MkdirAll(d, 0755); err != nil { + return fmt.Errorf("create %s: %w", d, err) } + } - mf, err := os.Create(filepath.Join(dir, "cognitive.json")) - if err != nil { - return fmt.Errorf("create cognitive.json: %w", err) - } - defer mf.Close() + manifest := map[string]interface{}{ + "name": filepath.Base(dir), + "version": "0.1.0", + "description": "Describe what this patch does", + "author": "", + "license": "MIT", + "hardware_requirements": map[string]interface{}{ + "min_ram_mb": 512, + "min_storage_mb": 50, + }, + "runtime": map[string]interface{}{ + "system_prompt": "prompts/system.md", + "tools_root": "tools", + }, + } - enc := json.NewEncoder(mf) - enc.SetIndent("", " ") - if err := enc.Encode(manifest); err != nil { - return fmt.Errorf("write cognitive.json: %w", err) - } + if err := writeManifest(dir, manifest); err != nil { + return err + } - systemPrompt := `# System Prompt + systemPrompt := `# System Prompt You are a CognitiveOS skill. When loaded, your behavior is defined here. ` - if err := os.WriteFile(filepath.Join(dir, "prompts", "system.md"), []byte(systemPrompt), 0644); err != nil { - return fmt.Errorf("write system.md: %w", err) - } + if err := os.WriteFile(filepath.Join(dir, "prompts", "system.md"), []byte(systemPrompt), 0644); err != nil { + return fmt.Errorf("write system.md: %w", err) + } - fmt.Printf("✓ Created .cgp skeleton in %s/\n", dir) - fmt.Printf(" Next: edit %s and add your tools/prompts\n", filepath.Join(dir, "cognitive.json")) - return nil - }, + fmt.Printf("✓ Created .cgp skeleton in %s/\n", dir) + fmt.Printf(" Next: edit %s and add your tools/prompts\n", filepath.Join(dir, "cognitive.json")) + return nil +} + +func initGGUFModel(dir string) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + + manifest := map[string]interface{}{ + "name": filepath.Base(dir), + "version": "1.0.0", + "description": "GGUF model distributed via CognitiveOS", + "author": "", + "license": "MIT", + "source": map[string]interface{}{ + "repository": "", + "issues": "", + }, + "hardware_requirements": map[string]interface{}{ + "min_ram_mb": 4096, + "min_storage_mb": 2048, + "npu_required": false, + }, + "checksum": map[string]interface{}{ + "sha256": "", + }, + "brain": map[string]interface{}{ + "wide_model": map[string]interface{}{ + "base_model": filepath.Base(dir), + "weights": map[string]interface{}{ + "remote": map[string]interface{}{ + "source": "huggingface", + "model_id": "org/model-name", + "filename": "model-name-Q4_K_M.gguf", + "format": "gguf", + "quant": "Q4_K_M", + "size_bytes": 0, + }, + }, + "parameters": map[string]interface{}{ + "temperature": 0.7, + "num_ctx": 8192, + }, + }, + }, + "runtime": map[string]interface{}{ + "capabilities": []string{"model.llm", "model.chat"}, + }, + } + + if err := writeManifest(dir, manifest); err != nil { + return err + } + + fmt.Printf("✓ Created gguf-model skeleton in %s/\n", dir) + fmt.Printf(" Next: edit cognitive.json — set model_id, filename, quant, size_bytes\n") + return nil +} + +func writeManifest(dir string, manifest map[string]interface{}) error { + mf, err := os.Create(filepath.Join(dir, "cognitive.json")) + if err != nil { + return fmt.Errorf("create cognitive.json: %w", err) + } + defer mf.Close() + + enc := json.NewEncoder(mf) + enc.SetIndent("", " ") + if err := enc.Encode(manifest); err != nil { + return fmt.Errorf("write cognitive.json: %w", err) + } + return nil } func init() { + initCmd.Flags().StringVar(&initTemplate, "template", "", "Skeleton template (gguf-model)") rootCmd.AddCommand(initCmd) } diff --git a/cmd/install.go b/cmd/install.go index 832cc34..31d42cd 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -1,9 +1,12 @@ package cmd import ( + "context" + "encoding/json" "fmt" "os" "path/filepath" + "strings" "github.com/CognitiveOS-Project/cpm/internal/audit" "github.com/CognitiveOS-Project/cpm/internal/check" @@ -12,6 +15,7 @@ import ( "github.com/CognitiveOS-Project/cpm/internal/patch" "github.com/CognitiveOS-Project/cpm/internal/resolver" "github.com/CognitiveOS-Project/cpm/internal/schema" + "github.com/CognitiveOS-Project/cpm/internal/weights" "github.com/spf13/cobra" ) @@ -95,6 +99,11 @@ Sources include: } } + // Remote weight download — check manifest for weights.remote.source + if err := downloadRemoteWeights(result.DataDir); err != nil { + return fmt.Errorf("download weights: %w", err) + } + // Move extracted data to install path installPath := patch.Dir(m.Name) _ = os.RemoveAll(installPath) @@ -165,6 +174,112 @@ func defaultPrimary() string { return "https://registry-us-all-distros-official.cognitive-os.org/v1" } +func downloadRemoteWeights(dataDir string) error { + raw, err := os.ReadFile(filepath.Join(dataDir, "cognitive.json")) + if err != nil { + return nil + } + + var doc map[string]interface{} + if err := json.Unmarshal(raw, &doc); err != nil { + return nil + } + + brain, _ := doc["brain"].(map[string]interface{}) + if brain == nil { + return nil + } + + var downloadErr error + + downloadIfRemote := func(kind weights.Kind, kindKey string) { + modelCfg, _ := brain[kindKey].(map[string]interface{}) + if modelCfg == nil { + return + } + weightsCfg, _ := modelCfg["weights"].(map[string]interface{}) + if weightsCfg == nil { + return + } + remote, _ := weightsCfg["remote"].(map[string]interface{}) + if remote == nil { + return + } + + source, _ := remote["source"].(string) + if source != "huggingface" { + return + } + + modelID, _ := remote["model_id"].(string) + if modelID == "" { + downloadErr = fmt.Errorf("%s.weights.remote.model_id is required", kindKey) + return + } + + filename, _ := remote["filename"].(string) + expectedSHA256, _ := remote["sha256"].(string) + if expectedSHA256 == "" { + if checksum, ok := doc["checksum"].(map[string]interface{}); ok { + expectedSHA256, _ = checksum["sha256"].(string) + } + } + + ctx := context.Background() + prov := weights.NewHFProvider() + + candidates, err := prov.Search(ctx, modelID, 3) + if err != nil { + downloadErr = fmt.Errorf("search %s: %w", modelID, err) + return + } + + var match *weights.Candidate + for i := range candidates { + if filename != "" && candidates[i].Filename == filename { + match = &candidates[i] + break + } + } + if match == nil { + for i := range candidates { + if strings.Contains(strings.ToLower(candidates[i].Filename), strings.ToLower(filename)) { + match = &candidates[i] + break + } + } + } + if match == nil && len(candidates) > 0 { + match = &candidates[0] + } + if match == nil { + downloadErr = fmt.Errorf("no matching file found for %s", modelID) + return + } + + dest := resolveDest(kind, match) + + if kind == weights.KindRaw { + if _, err := os.Stat(dest); err == nil { + log.Info("Raw model already exists at %s — skipping", dest) + return + } + } + + log.Info("Downloading weights from %s", match.DownloadURL) + if err := weights.Download(ctx, match.DownloadURL, dest, expectedSHA256, weights.TextProgress); err != nil { + downloadErr = fmt.Errorf("download %s: %w", modelID, err) + return + } + log.Info("Downloaded weights to %s", dest) + } + + downloadIfRemote(weights.KindRaw, "raw_model") + downloadIfRemote(weights.KindWide, "wide_model") + + return downloadErr +} + func copyDir(src, dst string) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { diff --git a/cmd/publish.go b/cmd/publish.go index ab7bced..c7cdbf4 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -42,7 +42,7 @@ Examples: return fmt.Errorf("read manifest: %w", err) } - f.Seek(0, 0) + _, _ = f.Seek(0, 0) hasher := sha256.New() if _, err := io.Copy(hasher, f); err != nil { return fmt.Errorf("checksum: %w", err) diff --git a/cmd/search.go b/cmd/search.go index 242376e..8970316 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -52,7 +52,7 @@ func init() { fs.StringVar(&searchLicense, "license", "", "Filter by SPDX license") fs.IntVar(&searchMinRAM, "min-ram", 0, "Minimum RAM in MB") fs.IntVar(&searchPage, "page", 1, "Page number") - searchCmd.RegisterFlagCompletionFunc("license", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + _ = searchCmd.RegisterFlagCompletionFunc("license", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"MIT", "Apache-2.0", "GPL-3.0", "BSL-1.0"}, cobra.ShellCompDirectiveDefault }) rootCmd.AddCommand(searchCmd) diff --git a/cmd/update.go b/cmd/update.go index 93ceaaf..25014fb 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -87,7 +87,7 @@ var updateCmd = &cobra.Command{ return fmt.Errorf("swap: %w", err) } if err := os.Rename(stagingDir, patch.Dir(name)); err != nil { - os.Rename(trashDir, patch.Dir(name)) + _ = os.Rename(trashDir, patch.Dir(name)) os.RemoveAll(stagingDir) return fmt.Errorf("swap: %w", err) } diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index aecdc51..3bb8b32 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -24,12 +24,12 @@ func TestReadManifest(t *testing.T) { } b, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{ + _ = tw.WriteHeader(&tar.Header{ Name: "cognitive.json", Mode: 0644, Size: int64(len(b)), }) - tw.Write(b) + _, _ = tw.Write(b) tw.Close() gz.Close() @@ -54,12 +54,12 @@ func TestReadManifest_WithDotSlash(t *testing.T) { } b, _ := json.Marshal(manifest) - tw.WriteHeader(&tar.Header{ + _ = tw.WriteHeader(&tar.Header{ Name: "./cognitive.json", Mode: 0644, Size: int64(len(b)), }) - tw.Write(b) + _, _ = tw.Write(b) tw.Close() gz.Close() @@ -80,12 +80,12 @@ func TestExtract(t *testing.T) { tw := tar.NewWriter(gz) content := []byte("hello world") - tw.WriteHeader(&tar.Header{ + _ = tw.WriteHeader(&tar.Header{ Name: "tools/test.sh", Mode: 0755, Size: int64(len(content)), }) - tw.Write(content) + _, _ = tw.Write(content) tw.Close() gz.Close() @@ -109,7 +109,7 @@ func TestExtract_TraversalProtection(t *testing.T) { gz := gzip.NewWriter(&buf) tw := tar.NewWriter(gz) - tw.WriteHeader(&tar.Header{ + _ = tw.WriteHeader(&tar.Header{ Name: "../../etc/passwd", Mode: 0644, Size: 0, diff --git a/internal/checksum/checksum_test.go b/internal/checksum/checksum_test.go index f8b7608..03d7086 100644 --- a/internal/checksum/checksum_test.go +++ b/internal/checksum/checksum_test.go @@ -9,7 +9,7 @@ import ( func TestOfFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "test.bin") - os.WriteFile(path, []byte("hello world"), 0644) + _ = os.WriteFile(path, []byte("hello world"), 0644) sum, err := OfFile(path) if err != nil { @@ -23,7 +23,7 @@ func TestOfFile(t *testing.T) { func TestVerify(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "test.bin") - os.WriteFile(path, []byte("data"), 0644) + _ = os.WriteFile(path, []byte("data"), 0644) sum, _ := OfFile(path) if err := Verify(path, sum); err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2ff6b9a..d608042 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -19,7 +19,7 @@ func TestLoadDefaultsWhenNoFile(t *testing.T) { func TestLoadFromFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "registries.toml") - os.WriteFile(path, []byte(` + _ = os.WriteFile(path, []byte(` [official] primary = "https://registry-us-all-distros-official.cognitive-os.org/v1" @@ -76,7 +76,7 @@ func TestResolve(t *testing.T) { func TestResolveCustom(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "registries.toml") - os.WriteFile(path, []byte(` + _ = os.WriteFile(path, []byte(` [official] primary = "https://primary.example.com/v1" diff --git a/internal/schema/schema.go b/internal/schema/schema.go index ebc89cb..b6c13bf 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -20,7 +20,7 @@ func init() { } compiler := jsonschema.NewCompiler() - compiler.AddResource("https://cognitive-os.org/schemas/cognitive.schema.json", doc) + _ = compiler.AddResource("https://cognitive-os.org/schemas/cognitive.schema.json", doc) sch, err := compiler.Compile("https://cognitive-os.org/schemas/cognitive.schema.json") if err != nil { diff --git a/internal/weights/download.go b/internal/weights/download.go new file mode 100644 index 0000000..c624caf --- /dev/null +++ b/internal/weights/download.go @@ -0,0 +1,110 @@ +package weights + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" +) + +func Download(ctx context.Context, url, dest string, expectedSHA256 string, fn ProgressFn) error { + if fn == nil { + fn = NoopProgress + } + + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("User-Agent", "cpm/1.0") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + + total := resp.ContentLength + + tmpDest := dest + ".cpm-partial" + f, err := os.Create(tmpDest) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + + hasher := sha256.New() + writer := io.MultiWriter(f, hasher) + + buf := make([]byte, 256*1024) // 256 KB + var written int64 + + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + wn, writeErr := writer.Write(buf[:n]) + if writeErr != nil { + f.Close() + os.Remove(tmpDest) + return fmt.Errorf("write: %w", writeErr) + } + if wn != n { + f.Close() + os.Remove(tmpDest) + return fmt.Errorf("short write") + } + written += int64(n) + fn(written, total) + } + if readErr == io.EOF { + break + } + if readErr != nil { + f.Close() + os.Remove(tmpDest) + return fmt.Errorf("read: %w", readErr) + } + } + + f.Close() + fn(written, total) + fmt.Fprintf(os.Stderr, "\n") + + if expectedSHA256 != "" { + actualSHA256 := hex.EncodeToString(hasher.Sum(nil)) + if actualSHA256 != expectedSHA256 { + os.Remove(tmpDest) + return fmt.Errorf("SHA-256 mismatch: expected %s, got %s", expectedSHA256, actualSHA256) + } + } + + if err := os.Rename(tmpDest, dest); err != nil { + os.Remove(tmpDest) + return fmt.Errorf("rename: %w", err) + } + + return nil +} + +func ModelDir(kind Kind) string { + base := "/cognitiveos/models" + switch kind { + case KindRaw: + return filepath.Join(base, "raw") + case KindWide: + return filepath.Join(base, "wide", "active") + default: + return base + } +} diff --git a/internal/weights/hf.go b/internal/weights/hf.go new file mode 100644 index 0000000..cadd676 --- /dev/null +++ b/internal/weights/hf.go @@ -0,0 +1,84 @@ +package weights + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +type hfModel struct { + ID string `json:"id"` + ModelID string `json:"modelId"` + Siblings []hfSibling `json:"siblings"` +} + +type hfSibling struct { + RFilename string `json:"rfilename"` +} + +type HFProvider struct { + client *http.Client +} + +func NewHFProvider() *HFProvider { + return &HFProvider{client: http.DefaultClient} +} + +func (p *HFProvider) Name() string { return "hf" } + +func (p *HFProvider) Search(ctx context.Context, query string, limit int) ([]Candidate, error) { + apiURL := fmt.Sprintf( + "https://huggingface.co/api/models?library=gguf&search=%s&sort=downloads&direction=-1&limit=%d&full=true", + url.QueryEscape(query), + limit, + ) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("hf: creating request: %w", err) + } + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("hf: request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("hf: %s — %s", resp.Status, strings.TrimSpace(string(body))) + } + + var models []hfModel + if err := json.NewDecoder(resp.Body).Decode(&models); err != nil { + return nil, fmt.Errorf("hf: decoding response: %w", err) + } + + var candidates []Candidate + + for _, model := range models { + modelID := model.ModelID + if modelID == "" { + modelID = model.ID + } + + for _, file := range model.Siblings { + name := strings.ToLower(file.RFilename) + if !strings.HasSuffix(name, ".gguf") && !strings.HasSuffix(name, ".safetensors") { + continue + } + + candidates = append(candidates, Candidate{ + ModelID: modelID, + Filename: file.RFilename, + DownloadURL: fmt.Sprintf("https://huggingface.co/%s/resolve/main/%s", modelID, file.RFilename), + }) + } + } + + return candidates, nil +} diff --git a/internal/weights/progress.go b/internal/weights/progress.go new file mode 100644 index 0000000..6d7ab94 --- /dev/null +++ b/internal/weights/progress.go @@ -0,0 +1,19 @@ +package weights + +import ( + "fmt" + "os" +) + +type ProgressFn func(current, total int64) + +func NoopProgress(current, total int64) {} + +func TextProgress(current, total int64) { + if total <= 0 { + fmt.Fprintf(os.Stderr, "\rDownloaded %d bytes", current) + return + } + pct := float64(current) / float64(total) * 100 + fmt.Fprintf(os.Stderr, "\r%.0f%% (%d / %d bytes)", pct, current, total) +} diff --git a/internal/weights/provider.go b/internal/weights/provider.go new file mode 100644 index 0000000..baaeca5 --- /dev/null +++ b/internal/weights/provider.go @@ -0,0 +1,52 @@ +package weights + +import "context" + +type Kind int + +const ( + KindRaw Kind = iota + KindWide +) + +func (k Kind) String() string { + switch k { + case KindRaw: + return "raw" + case KindWide: + return "wide" + default: + return "unknown" + } +} + +type Format int + +const ( + FormatGGUF Format = iota + FormatSafeTensors +) + +func (f Format) String() string { + switch f { + case FormatGGUF: + return "gguf" + case FormatSafeTensors: + return "safetensors" + default: + return "unknown" + } +} + +type Candidate struct { + ModelID string + Filename string + DownloadURL string + SHA256 string + SizeBytes int64 +} + +type Provider interface { + Name() string + Search(ctx context.Context, query string, limit int) ([]Candidate, error) +}