Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions cmd/seed-skills/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Command seed-skills seeds the R2 demo Skills (DR-51) into the configured
// database as published, packaged, downloadable Skills.
//
// It boots the same way the gateway does (loads .env, InitEnv, InitDB — which
// runs migrations including skill_versions), then runs the idempotent seeder.
//
// Usage:
//
// go run ./cmd/seed-skills [-created-by <user_id>]
//
// Reads SQL_DSN (and friends) from the environment / .env, exactly like the
// server. Safe to run repeatedly: existing Skills are upserted, and a new active
// version is created only when the template or tier whitelist changed.
//
// Note: on SQLite, re-running against an existing database file hits a known
// glebarez/sqlite AutoMigrate-over-IN()-CHECK driver bug at the migration layer
// (same limitation the gateway has; see internal/skill/model integration tests).
// Production runs on PostgreSQL, where re-runs are clean. The seeder logic itself
// is idempotent regardless (proven by internal/skill/seed tests).
package main

import (
"flag"
"fmt"
"os"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/internal/skill/seed"
"github.com/QuantumNous/new-api/model"
"github.com/joho/godotenv"
)

func main() {
createdBy := flag.Int64("created-by", 1, "platform user id recorded as the Skill author (default: root user 1)")
flag.Parse()

if err := godotenv.Load(".env"); err != nil {
// .env is optional; environment variables may already be set.
common.SysLog("seed-skills: no .env loaded (" + err.Error() + "), relying on environment")
}
common.InitEnv()

if err := model.InitDB(); err != nil {
fmt.Fprintln(os.Stderr, "seed-skills: failed to initialize database:", err)
os.Exit(1)
}
// InitLogDB points LOG_DB at the main DB when LOG_SQL_DSN is unset; required
// so model.CloseDB() does not dereference a nil LOG_DB on shutdown.
if err := model.InitLogDB(); err != nil {
fmt.Fprintln(os.Stderr, "seed-skills: failed to initialize log database:", err)
os.Exit(1)
}
defer func() { _ = model.CloseDB() }()

if model.DB == nil {
fmt.Fprintln(os.Stderr, "seed-skills: database is not initialized")
os.Exit(1)
}

result, err := seed.SeedDemoSkills(model.DB, *createdBy)
if err != nil {
fmt.Fprintln(os.Stderr, "seed-skills: seeding failed:", err)
os.Exit(1)
}

fmt.Println("seed-skills: done")
for _, o := range result.Outcomes {
fmt.Printf(" %-20s %-11s skill=%s version=v%d (%s)\n", o.Slug, o.Action, o.SkillID, o.VersionNumber, o.VersionID)
}
}
90 changes: 90 additions & 0 deletions internal/skill/handler/seed_download_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package handler

import (
"archive/zip"
"bytes"
"net/http"
"path/filepath"
"strings"
"testing"

skillmodel "github.com/QuantumNous/new-api/internal/skill/model"
"github.com/QuantumNous/new-api/internal/skill/seed"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)

// seededDownloadDB uses a file-based SQLite DB (not :memory:) because the seeder
// runs inside a transaction; a file DB guarantees migrated tables are visible on
// the transaction's connection. Migrates the full set the download path touches.
func seededDownloadDB(t *testing.T) *gorm.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "seed_dl.db")
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, skillmodel.MigrateSkills(db))
require.NoError(t, skillmodel.MigrateSkillVersions(db))
require.NoError(t, skillmodel.MigrateUserEnabledSkills(db))
require.NoError(t, skillmodel.MigrateSkillUsageEvents(db))
t.Cleanup(func() {
if sq, err := db.DB(); err == nil {
sq.Close()
}
})
return db
}

// TestDownloadSkillPackage_SeededDemoSkills is the DR-51 "downloadable" acceptance:
// each seeded demo Skill must download end-to-end through main's DR-81 handler,
// which means passing main's D-09 runtime-dependency guard
// (validateSkillPackageRuntimeDependency) — a capability package whose SKILL.md
// has a "## Work step" calling the DeepRouter routing API. This is the integration
// proof that the seeder's Work-step Description survives main's packager.
func TestDownloadSkillPackage_SeededDemoSkills(t *testing.T) {
db := seededDownloadDB(t)
SetDB(db)
if _, err := seed.SeedDemoSkills(db, 1); err != nil {
t.Fatalf("seed: %v", err)
}

for _, slug := range []string{"polished-writer", "faithful-translator", "code-helper", "data-analyst"} {
// userID 1, group "default" → free plan; demo skills are free → entitled.
c, w := testDownloadCtx(slug, 1, "default")
DownloadSkillPackage(c)

require.Equalf(t, http.StatusOK, w.Code, "%s: download failed, body=%s", slug, w.Body.String())
require.Equalf(t, "application/zip", w.Header().Get("Content-Type"), "%s: content-type", slug)

zr, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
require.NoErrorf(t, err, "%s: open zip", slug)
files := map[string]string{}
for _, f := range zr.File {
rc, err := f.Open()
require.NoError(t, err)
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(rc)
rc.Close()
files[f.Name] = buf.String()
}

require.Containsf(t, files, "manifest.json", "%s: manifest", slug)
require.Containsf(t, files, "SKILL.md", "%s: SKILL.md", slug)
// D-09 guard inputs: SKILL.md routes through DeepRouter (the work step).
require.Containsf(t, strings.ToLower(files["SKILL.md"]), "deeprouter", "%s: SKILL.md mentions DeepRouter", slug)
// Must reference the PUBLIC ROUTING endpoint, which is the only path wired to
// the DR-82 abuse gate (markSkillPublicRoutingAPI + PublicRoutingAbuseControl).
require.Containsf(t, files["SKILL.md"], "/v1/routing/chat/completions", "%s: SKILL.md must reference the public routing endpoint", slug)
// And must NOT point at the ordinary chat endpoint, which bypasses that gate.
require.NotContainsf(t, files["SKILL.md"], "/v1/chat/completions", "%s: SKILL.md must not reference the ordinary chat endpoint (bypasses the abuse gate)", slug)
// Capability package: manifest pins the published version.
require.Containsf(t, files["manifest.json"], "skill_version_id", "%s: manifest pins version", slug)
require.Containsf(t, files["manifest.json"], "requires_deeprouter_key", "%s: manifest flags runner key", slug)
}

// Download recorded entitlement rows (download == enable, DR-55).
var enabled int64
db.Model(&skillmodel.UserEnabledSkill{}).Where("user_id = ?", 1).Count(&enabled)
require.Equal(t, int64(4), enabled, "each download should upsert a user_enabled_skills row")
}
16 changes: 13 additions & 3 deletions internal/skill/relay/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/internal/skill/errcodes"
skillmodel "github.com/QuantumNous/new-api/internal/skill/model"
"github.com/QuantumNous/new-api/internal/skill/tiers"
"gorm.io/gorm"
)

Expand Down Expand Up @@ -104,13 +105,22 @@ func parseModelWhitelist(raw skillmodel.SkillJSONB) ([]string, error) {
}

// selectModel picks the server-authoritative model from the whitelist.
// V1: returns the first non-empty entry (list is priority-ordered by admin at publish time).
// V1: takes the first non-empty entry (list is priority-ordered by admin at publish time).
//
// DR-96: a whitelist entry may be a platform tier alias (e.g. "smart-tier") rather
// than a concrete model id. Tier aliases are resolved server-side to the current
// best model via the platform alias registry; non-alias entries are treated as
// literal model names and passed through unchanged (backward compatible).
// TODO(DR-68-model-selection): add plan-based filtering and context-budget check.
func selectModel(whitelist []string) (string, errcodes.ErrorCode) {
for _, m := range whitelist {
if m != "" {
return m, ""
if m == "" {
continue
}
if resolved, ok := tiers.Resolve(m); ok {
return resolved, ""
}
return m, ""
}
return "", errcodes.ErrSkillInternalError
}
Expand Down
48 changes: 48 additions & 0 deletions internal/skill/relay/tier_resolution_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package skillrelay

import (
"testing"

"github.com/QuantumNous/new-api/internal/skill/tiers"
)

// selectModel must resolve a platform tier alias to the concrete model the gateway
// routes to (DR-96), so Skills that declare tiers (e.g. the DR-51 demo set) route
// to a real model instead of trying to call a provider named "smart-tier".
func TestSelectModel_ResolvesTierAlias(t *testing.T) {
want, ok := tiers.Resolve("smart-tier")
if !ok {
t.Fatal("precondition: smart-tier must resolve in the registry")
}
got, code := selectModel([]string{"smart-tier", "balanced-tier"})
if code != "" {
t.Fatalf("unexpected error code %q", code)
}
if got != want {
t.Fatalf("smart-tier should resolve to %q, got %q", want, got)
}
}

// A literal (non-alias) model id must pass through unchanged — backward compatible
// with whitelists authored before the tier registry.
func TestSelectModel_LiteralModelPassesThrough(t *testing.T) {
got, code := selectModel([]string{"gpt-4o"})
if code != "" || got != "gpt-4o" {
t.Fatalf("literal model should pass through, got %q code %q", got, code)
}
}

// The first non-empty entry wins; empty strings are skipped.
func TestSelectModel_SkipsEmptyEntries(t *testing.T) {
got, code := selectModel([]string{"", "fast-tier"})
want, _ := tiers.Resolve("fast-tier")
if code != "" || got != want {
t.Fatalf("expected resolved fast-tier %q, got %q code %q", want, got, code)
}
}

func TestSelectModel_EmptyWhitelistErrors(t *testing.T) {
if _, code := selectModel(nil); code == "" {
t.Fatal("empty whitelist must return an error code")
}
}
Loading
Loading