mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-03 19:14:56 -05:00
test: update stale tests after the add-on owner and Sandbox skill changes (#13045)
* test(e2e): use a core built-in skill in the agent journeys #13038 moved the docx, pdf, pptx and xlsx skills into the Sandbox add-on, so core no longer embeds them. Three journeys still named docx and xlsx as built-in skills. The backend e2e suite then failed: reading /builtin/docx answered 404, and the system prompt had no docx entry. The journeys now use localai-apps and localai-workspace, which core still embeds. They keep their intent: an agent lists only the skills it names, a plain chat lists all of them, and built-in skills are readable but cannot be deleted. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): serve owner host settings in the add-on e2e fixtures #13033 moved the network policy and the skill provider pins to the add-on owner. The add-on Settings tab and the skills switch now read and write them with the host-settings-read and host-settings-update dispatch actions. The specs still mocked only /api/settings, so the owner read failed and the network block never rendered. Six specs failed on master. mockAddons now answers both actions from a revisioned policy, checks the expected revision, applies patches like hostsettings.Patch and logs each patch. The specs assert the owner patch instead of the /api/settings POST. They also follow two other changes of #13033: the audit retention stays on the Settings page, and *.example.com is a valid host pattern. The document skill wording follows #13038: an add-on provides docx and xlsx, it does not replace a built-in. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(routes): port the UI capability tests to Ginkgo The capability tests from #13024 used t.Fatal and t.Errorf, which the forbidigo lint rule rejects. The routes package already runs a Ginkgo suite, so the two tests are now specs in it, with the same models and the same assertions. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
8b4f3c35e8
commit
8f9a0b281d
6 files changed
+154
-145
No files matched your search
@@ -9,7 +9,7 @@ import { FACE_MODEL, clipFile, faces, match, mockIdentity, people, photoFile } f
|
||||
// Studio and Talk are packaged add-ons, not built-in tiles: the specs install
|
||||
// the real packages. No page is built in today, so `builtin` is empty unless a
|
||||
// spec makes one up to check the built-in rules.
|
||||
async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [], studio = true, user = MEMBER, installed, releases, features = { addon_navigation: true } } = {}) {
|
||||
async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [], studio = true, user = MEMBER, installed, releases, hostSettings, features = { addon_navigation: true } } = {}) {
|
||||
let saved = pins
|
||||
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(user, { addons }) }))
|
||||
await page.route('**/api/features', route => route.fulfill({ json: features }))
|
||||
@@ -18,7 +18,7 @@ async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true
|
||||
// The packages are served from the build output, so a page that opens one runs it.
|
||||
let mocked = null
|
||||
if (studio) mocked = await installStudioAddons(page, { apps: [packagedApp('studio'), packagedApp('talk'), ...apps] })
|
||||
else mocked = await mockAddons(page, { apps, installed, releases })
|
||||
else mocked = await mockAddons(page, { apps, installed, releases, hostSettings })
|
||||
await page.route('**/api/addons/pins', async route => {
|
||||
if (route.request().method() === 'PUT') saved = route.request().postDataJSON()
|
||||
await route.fulfill({ json: saved })
|
||||
@@ -188,7 +188,7 @@ test.describe('Add-on navigation', () => {
|
||||
|
||||
test('an administrator gets a Settings tab in the details of an add-on with settings, with the host controls it has', async ({ page }) => {
|
||||
const controlled = withSettings({ shadows: ['bash'], ui: { entry: `/api/addons/sandbox/assets/${'a'.repeat(64)}/ui.js`, sdk: { min: 2, max: 2 }, controls: ['network-policy'] } })
|
||||
await setup(page, { pins: [], studio: false, user: ADMIN, apps: [controlled, app({ id: 'notes', title: 'Notes' })], installed: [installedAddon({ id: 'sandbox', title: 'Sandbox' }), installedAddon()] })
|
||||
await setup(page, { pins: [], studio: false, user: ADMIN, apps: [controlled, app({ id: 'notes', title: 'Notes' })], installed: [installedAddon({ id: 'sandbox', title: 'Sandbox' }), installedAddon()], hostSettings: { chats_egress_ceiling: 'registries' } })
|
||||
await page.route('**/api/settings', route => route.fulfill({ json: { chats_egress_ceiling: 'registries', chats_egress_audit_days: 14 } }))
|
||||
await page.route('**/api/chats/admin/egress', route => route.fulfill({ json: { ceiling: 'registries', default: 'off', ports: [80, 443], audit_days: 14, registries: [], allowed_24h: 0, blocked_24h: 0 } }))
|
||||
await page.goto('/app/addons?detail=sandbox')
|
||||
@@ -200,7 +200,8 @@ test.describe('Add-on navigation', () => {
|
||||
const block = pane.getByTestId('host-control-network-policy')
|
||||
await expect(block).toBeVisible()
|
||||
await expect(block.locator('[data-field="chats_egress_ceiling"] select')).toHaveValue('registries')
|
||||
await expect(block.locator('[data-field="chats_egress_audit_days"] input')).toHaveValue('14')
|
||||
// The audit retention stays on the Settings page: it is not owner policy.
|
||||
await expect(block.locator('[data-field="chats_egress_audit_days"]')).toHaveCount(0)
|
||||
await expect(pane.getByTestId('addon-server-settings')).toHaveCount(0)
|
||||
// Arrow keys move between the two tabs.
|
||||
await pane.getByTestId('addon-detail-tab-settings').press('ArrowLeft')
|
||||
|
||||
+39
-1
@@ -67,14 +67,44 @@ export async function stubAddonRoutes(page) {
|
||||
await page.context().route('**/api/addons/pins', route => route.fulfill({ json: [] }))
|
||||
}
|
||||
|
||||
// The owner's host settings policy (core/services/addons/hostsettings.Policy),
|
||||
// in the exact shape host-settings-read answers. Keys of `over` that are not
|
||||
// policy fields (other runtime settings) are ignored.
|
||||
const HOST_POLICY_DEFAULTS = {
|
||||
chats_egress_ceiling: '', chats_egress_default: '', chats_egress_allow_private: false,
|
||||
chats_egress_ports: '', chats_egress_extra_registries: '', chats_egress_allowed_hosts: '',
|
||||
chats_tool_providers: {}, auto_upgrade_addons: false,
|
||||
}
|
||||
export function hostPolicy(over = {}) {
|
||||
const policy = structuredClone(HOST_POLICY_DEFAULTS)
|
||||
for (const key of Object.keys(policy)) if (Object.hasOwn(over, key)) policy[key] = structuredClone(over[key])
|
||||
return policy
|
||||
}
|
||||
|
||||
// Applies a host-settings-update patch: present keys replace, a null provider
|
||||
// value deletes that pin. Mirrors hostsettings.Patch.
|
||||
function applyHostPatch(policy, patch) {
|
||||
const next = structuredClone(policy)
|
||||
for (const [key, value] of Object.entries(patch || {})) {
|
||||
if (key !== 'chats_tool_providers') { next[key] = value; continue }
|
||||
for (const [name, provider] of Object.entries(value || {})) {
|
||||
if (provider === null) delete next.chats_tool_providers[name]
|
||||
else next.chats_tool_providers[name] = provider
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// Installs the routes and returns the mutable state the spec reads and edits.
|
||||
export async function mockAddons(page, options = {}) {
|
||||
const state = {
|
||||
// The owner's host settings; log.hostSettings collects each update patch.
|
||||
hostSettings: { revision: 1, policy: hostPolicy(options.hostSettings) },
|
||||
apps: options.apps || [app()],
|
||||
installed: options.installed || [installedAddon()],
|
||||
sources: options.sources || [{ id: 'main', name: 'Main', url: 'https://catalog.example/releases.json', enabled: true, policy: { requireSignature: false }, refresh: { checkedAt: new Date().toISOString(), lastSuccess: new Date().toISOString(), count: 2, failed: false } }],
|
||||
releases: options.releases || [release('notes'), release('tables', { policy: { requireSignature: true, issuer: 'https://issuer.example', identity: 'release@catalog.example' } })],
|
||||
log: { appsList: 0, start: [], stop: [], dispatch: [], check: [], upgrade: [], library: [] },
|
||||
log: { appsList: 0, start: [], stop: [], dispatch: [], check: [], upgrade: [], library: [], hostSettings: [] },
|
||||
// Overrides for the update routes: each takes the parsed body.
|
||||
check: options.check || null,
|
||||
upgrade: options.upgrade || null,
|
||||
@@ -224,6 +254,14 @@ export async function mockAddons(page, options = {}) {
|
||||
return route.fulfill(ok({ OperationID: body.operationId, AddonID: body.addonId, Phase: 'completed', Generation: 0 }))
|
||||
case 'operation-status':
|
||||
return route.fulfill(ok({ OperationID: body.operationId, AddonID: body.addonId, Phase: 'completed', Generation: 0 }))
|
||||
case 'host-settings-read':
|
||||
return route.fulfill(ok({ data: { schema_version: 1, revision: state.hostSettings.revision, policy: state.hostSettings.policy } }))
|
||||
case 'host-settings-update': {
|
||||
if (args.expectedRevision !== state.hostSettings.revision) return route.fulfill(ok({ error: 'revision_conflict' }))
|
||||
state.log.hostSettings.push(args.patch)
|
||||
state.hostSettings = { revision: state.hostSettings.revision + 1, policy: applyHostPatch(state.hostSettings.policy, args.patch) }
|
||||
return route.fulfill(ok({ data: { schema_version: 1, revision: state.hostSettings.revision, policy: state.hostSettings.policy } }))
|
||||
}
|
||||
default:
|
||||
return route.fulfill(fail(403, 'addon_error'))
|
||||
}
|
||||
|
||||
@@ -280,8 +280,10 @@ test.describe('A card the administrator\'s limit cannot admit', () => {
|
||||
// The add-on pages of the network-policy control, with the settings and the
|
||||
// admin network routes stubbed.
|
||||
async function stubNetworkAdmin(page, { settings = {}, egress = {}, events = [], apps = RUNNERS } = {}) {
|
||||
const state = await mockSettings(page, settings)
|
||||
await mockAddons(page, { apps, installed: apps.map(a => installedAddon({ id: a.id, title: a.title })) })
|
||||
await mockSettings(page, settings)
|
||||
// The network policy is the owner's: the add-on page reads and writes it
|
||||
// through host-settings-read and host-settings-update, not /api/settings.
|
||||
const state = await mockAddons(page, { apps, installed: apps.map(a => installedAddon({ id: a.id, title: a.title })), hostSettings: settings })
|
||||
await page.route('**/api/chats/admin/tools', route => route.fulfill({ json: { tools: [
|
||||
{ id: 'bash', label: 'bash', description: 'Run commands', setting: 'chats_host_exec', mode: 'off', provider: 'sandbox', candidates: ['devbox', 'sandbox'], titles: { sandbox: 'Sandbox', devbox: 'Devbox' } },
|
||||
{ id: 'web_search', label: 'web_search', description: 'Search the web', setting: 'chats_web_search', provider: 'builtin', candidates: [] },
|
||||
@@ -341,7 +343,8 @@ test.describe('The network of the commands on the add-on page', () => {
|
||||
await expect(page.getByTestId('settings-egress-serves')).toHaveText('Commands run isolated in the sandbox add-on')
|
||||
await expect(page.getByTestId('settings-egress-counts')).toHaveText('Last 24 hours: 4 allowed, 1 blocked.')
|
||||
await expect(block.locator('[data-field="chats_egress_ceiling"]')).toBeVisible()
|
||||
await expect(block.locator('[data-field="chats_egress_audit_days"]')).toBeVisible()
|
||||
// The audit retention is a runtime setting of this host, on the Settings page.
|
||||
await expect(block.locator('[data-field="chats_egress_audit_days"]')).toHaveCount(0)
|
||||
await page.getByTestId('settings-egress-registries').locator('summary').click()
|
||||
await expect(block).toContainText('pypi.org, files.pythonhosted.org')
|
||||
await page.getByTestId('settings-egress-audit-toggle').click()
|
||||
@@ -356,7 +359,8 @@ test.describe('The network of the commands on the add-on page', () => {
|
||||
await expect(page.getByRole('button', { name: 'Apply' })).toBeDisabled()
|
||||
await page.getByTestId('settings-egress-private-phrase').fill('I understand that commands can reach my private network')
|
||||
await page.getByRole('button', { name: 'Apply' }).click()
|
||||
await expect.poll(() => settings.posts).toEqual([{ chats_egress_allow_private: true, chats_egress_private_confirm: 'I understand that commands can reach my private network' }])
|
||||
// The phrase is checked in the page; the owner patch carries policy fields only.
|
||||
await expect.poll(() => settings.log.hostSettings).toEqual([{ chats_egress_allow_private: true }])
|
||||
await expect(page.getByTestId('settings-pending')).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -368,7 +372,7 @@ test.describe('The network of the commands on the add-on page', () => {
|
||||
await expect(page.getByTestId('host-control-tool-providers')).toHaveCount(0)
|
||||
await block.locator('[data-field="chats_egress_ceiling"] select').selectOption('registries')
|
||||
await page.getByRole('button', { name: 'Apply' }).click()
|
||||
await expect.poll(() => settings.posts).toEqual([{ chats_egress_ceiling: 'registries' }])
|
||||
await expect.poll(() => settings.log.hostSettings).toEqual([{ chats_egress_ceiling: 'registries' }])
|
||||
})
|
||||
|
||||
test('the Always allowed hosts list adds, refuses and removes hosts and saves them with Apply', async ({ page }) => {
|
||||
@@ -382,7 +386,8 @@ test.describe('The network of the commands on the add-on page', () => {
|
||||
await expect(editor.getByTestId('settings-hosts-count')).toHaveText('1/64')
|
||||
const input = editor.getByTestId('settings-hosts-input')
|
||||
|
||||
await input.fill('*.example.com')
|
||||
// *.example.com and * are accepted patterns; a star inside a name is not.
|
||||
await input.fill('api.*.example.com')
|
||||
await input.press('Enter')
|
||||
await expect(editor.getByTestId('settings-hosts-error')).toContainText('not a pattern')
|
||||
await input.fill('https://example.com')
|
||||
@@ -408,6 +413,6 @@ test.describe('The network of the commands on the add-on page', () => {
|
||||
await editor.getByRole('button', { name: 'Remove cdn.example.org' }).click()
|
||||
await expect(editor.getByTestId('settings-hosts-item')).toHaveCount(3)
|
||||
await page.getByRole('button', { name: 'Apply' }).click()
|
||||
await expect.poll(() => settings.posts).toEqual([{ chats_egress_allowed_hosts: 'api.example.org, git.example.org:8443, xn--bcher-kva.example' }])
|
||||
await expect.poll(() => settings.log.hostSettings).toEqual([{ chats_egress_allowed_hosts: 'api.example.org, git.example.org:8443, xn--bcher-kva.example' }])
|
||||
})
|
||||
})
|
||||
@@ -147,8 +147,10 @@ test.describe('Settings > Tools: skills that add-ons replace', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Install of an add-on that replaces a built-in skill', () => {
|
||||
test('says which built-in skills it replaces, warns when another add-on already does, and still installs', async ({ page }) => {
|
||||
// docx, pptx, pdf and xlsx have no core implementation: an add-on provides
|
||||
// them, it does not replace a built-in.
|
||||
test.describe('Install of an add-on that provides a document skill', () => {
|
||||
test('says which document skills it provides, warns when another add-on already does, and still installs', async ({ page }) => {
|
||||
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN) }))
|
||||
await mockAddons(page, {
|
||||
installed: [installedAddon({ id: 'sheets', title: 'Sheets', skillShadows: ['xlsx'] })],
|
||||
@@ -158,8 +160,8 @@ test.describe('Install of an add-on that replaces a built-in skill', () => {
|
||||
await page.getByRole('button', { name: 'Install Office' }).click()
|
||||
const warnings = page.getByTestId('install-skill-warning')
|
||||
await expect(warnings).toHaveCount(2)
|
||||
await expect(warnings.nth(0)).toHaveText('This add-on replaces the built-in skill xlsx. Another add-on (Sheets) already does. Only one can be active; you choose which in Settings > Tools. Until you choose, the one installed first serves.')
|
||||
await expect(warnings.nth(1)).toHaveText('This add-on replaces the built-in skill docx.')
|
||||
await expect(warnings.nth(0)).toHaveText('This add-on provides the xlsx skill. Another add-on (Sheets) also provides it. Only one can be active; choose in Settings > Tools. Without a choice, the one installed first serves.')
|
||||
await expect(warnings.nth(1)).toHaveText('This add-on provides the docx skill.')
|
||||
await expect(page.getByTestId('install-dialog').getByRole('button', { name: 'Install and turn on' })).toBeEnabled()
|
||||
await shot(page, 'install-skill-warning')
|
||||
})
|
||||
@@ -169,11 +171,13 @@ test.describe('The skills of an add-on in its details', () => {
|
||||
test('lists its skills, and lets an administrator choose it for a skill another add-on also replaces', async ({ page }) => {
|
||||
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN) }))
|
||||
const office = { id: 'office', name: 'office', title: 'Office', skills: [{ name: 'xlsx', shadows: 'xlsx' }, { name: 'docx', shadows: 'docx' }, { name: 'office-tips' }] }
|
||||
await mockAddons(page, {
|
||||
const owner = await mockAddons(page, {
|
||||
apps: [{ ...installedAddon({ id: 'office', title: 'Office' }), ...office, admin: { canStart: false, canStop: true, managementUrl: '/app/addons?manage=office' } }],
|
||||
installed: [installedAddon({ id: 'office', title: 'Office', skillShadows: ['xlsx', 'docx'] })],
|
||||
// Sheets is the pinned provider of xlsx: the owner's pins say who serves.
|
||||
hostSettings: { chats_tool_providers: { bash: 'sandbox', xlsx: 'sheets' } },
|
||||
})
|
||||
const settings = await mockSettings(page, { chats_tool_providers: { bash: 'sandbox' } })
|
||||
await mockSettings(page)
|
||||
let rows = [
|
||||
{ id: 'xlsx', provider: 'sheets', candidates: ['office', 'sheets'], titles: { office: 'Office', sheets: 'Sheets' } },
|
||||
{ id: 'docx', provider: 'office', candidates: ['office'], titles: { office: 'Office' } },
|
||||
@@ -183,14 +187,16 @@ test.describe('The skills of an add-on in its details', () => {
|
||||
const skills = page.getByTestId('app-skills')
|
||||
await expect(skills).toContainText('3 chat skills')
|
||||
await expect(page.getByTestId('app-skill-office-tips')).not.toContainText('replaces')
|
||||
await expect(page.getByTestId('app-skill-docx')).toContainText('replaces the built-in skill · serves in the chats')
|
||||
await expect(page.getByTestId('app-skill-docx')).toContainText('provides the docx skill · serves in the chats')
|
||||
await expect(page.getByTestId('app-skill-docx-switch')).toHaveCount(0)
|
||||
const sw = page.getByTestId('app-skill-xlsx-switch')
|
||||
await expect(sw).not.toBeChecked()
|
||||
await shot(page, 'addon-skills')
|
||||
rows = [{ ...rows[0], provider: 'office', pinned: 'office' }, rows[1]]
|
||||
await sw.click()
|
||||
await expect.poll(() => settings.posts).toEqual([{ chats_tool_providers: { bash: 'sandbox', xlsx: 'office' } }])
|
||||
// The owner gets only the changed pin; the bash pin stays as it was.
|
||||
await expect.poll(() => owner.log.hostSettings).toEqual([{ chats_tool_providers: { xlsx: 'office' } }])
|
||||
expect(owner.hostSettings.policy.chats_tool_providers).toEqual({ bash: 'sandbox', xlsx: 'office' })
|
||||
await expect(sw).toBeChecked()
|
||||
})
|
||||
})
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/application"
|
||||
@@ -16,91 +14,33 @@ import (
|
||||
"github.com/mudler/LocalAI/core/services/galleryop"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
"github.com/mudler/LocalAI/pkg/system"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestUIUpscaleCapabilities(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := system.GetSystemState(system.WithModelPath(dir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cl := config.NewModelConfigLoader(dir)
|
||||
for name, options := range map[string]string{
|
||||
"upscaler": "options: [upscale_scale:4]\n",
|
||||
"absent": "",
|
||||
"malformed": "options: [upscale_scale:bad]\n",
|
||||
"nonpositive": "options: [upscale_scale:0]\n",
|
||||
"negative": "options: [upscale_scale:-1]\n",
|
||||
"overflow": "options: [upscale_scale:2147483648]\n",
|
||||
"duplicate": "options: [upscale_scale:4, upscale_scale:4]\n",
|
||||
} {
|
||||
path := filepath.Join(dir, name+".yaml")
|
||||
if err := os.WriteFile(path, []byte("name: "+name+"\nbackend: stablediffusion-ggml\nknown_usecases: [upscale]\n"+options), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cl.ReadModelConfig(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ac := config.NewApplicationConfig()
|
||||
ac.SystemState = st
|
||||
ml := model.NewModelLoader(st)
|
||||
gs := galleryop.NewGalleryService(ac, ml)
|
||||
app := echo.New()
|
||||
routes.RegisterUIAPIRoutes(app, cl, ml, ac, gs, galleryop.NewOpCache(gs), &application.Application{}, func(next echo.HandlerFunc) echo.HandlerFunc { return next })
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/models/capabilities", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Scale *int `json:"upscaleScale"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Data) != 7 {
|
||||
t.Fatalf("unexpected model count: %d", len(response.Data))
|
||||
}
|
||||
for _, entry := range response.Data {
|
||||
if len(entry.Capabilities) != 1 || entry.Capabilities[0] != "FLAG_UPSCALE" {
|
||||
t.Fatalf("unexpected capabilities: %+v", entry)
|
||||
}
|
||||
if entry.ID == "upscaler" {
|
||||
if entry.Scale == nil || *entry.Scale != 4 {
|
||||
t.Fatalf("unexpected scale: %+v", entry)
|
||||
}
|
||||
} else if entry.Scale != nil {
|
||||
t.Fatalf("invalid scale exposed: %+v", entry)
|
||||
}
|
||||
}
|
||||
type capabilityEntry struct {
|
||||
ID string `json:"id"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Scale *int `json:"upscaleScale"`
|
||||
Backend string `json:"backend"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Pinned bool `json:"pinned"`
|
||||
MCP bool `json:"mcp"`
|
||||
}
|
||||
|
||||
func TestUIAliasCapabilities(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// capabilitiesOf writes one model config per entry (name -> YAML after the
|
||||
// name line), registers the UI API routes and returns the answer of
|
||||
// GET /api/models/capabilities.
|
||||
func capabilitiesOf(models map[string]string) []capabilityEntry {
|
||||
dir := GinkgoT().TempDir()
|
||||
st, err := system.GetSystemState(system.WithModelPath(dir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
cl := config.NewModelConfigLoader(dir)
|
||||
for name, body := range map[string]string{
|
||||
"target": "backend: llama-cpp\nknown_usecases: [chat, vision]\npinned: true\n",
|
||||
"alias": "alias: target\nknown_usecases: [embeddings]\nprofile:\n system_prompt: Answer briefly.\n client_tools: drop\n",
|
||||
"disabled-alias": "alias: target\ndisabled: true\n",
|
||||
"broken": "alias: missing\nknown_usecases: [chat]\n",
|
||||
"chain": "alias: alias\nknown_usecases: [chat]\n",
|
||||
} {
|
||||
for name, body := range models {
|
||||
path := filepath.Join(dir, name+".yaml")
|
||||
if err := os.WriteFile(path, []byte("name: "+name+"\n"+body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cl.ReadModelConfig(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Expect(os.WriteFile(path, []byte("name: "+name+"\n"+body), 0600)).To(Succeed())
|
||||
Expect(cl.ReadModelConfig(path)).To(Succeed())
|
||||
}
|
||||
ac := config.NewApplicationConfig()
|
||||
ac.SystemState = st
|
||||
@@ -110,43 +50,62 @@ func TestUIAliasCapabilities(t *testing.T) {
|
||||
routes.RegisterUIAPIRoutes(app, cl, ml, ac, gs, galleryop.NewOpCache(gs), &application.Application{}, func(next echo.HandlerFunc) echo.HandlerFunc { return next })
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/models/capabilities", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
|
||||
var response struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Backend string `json:"backend"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Pinned bool `json:"pinned"`
|
||||
MCP bool `json:"mcp"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Data) != 5 {
|
||||
t.Fatalf("unexpected models: %+v", response.Data)
|
||||
}
|
||||
for _, entry := range response.Data {
|
||||
switch entry.ID {
|
||||
case "target", "alias", "disabled-alias":
|
||||
if len(entry.Capabilities) != 2 || !slices.Contains(entry.Capabilities, "FLAG_CHAT") || !slices.Contains(entry.Capabilities, "FLAG_VISION") {
|
||||
t.Errorf("%s must inherit chat/vision: %+v", entry.ID, entry)
|
||||
}
|
||||
case "broken", "chain":
|
||||
if len(entry.Capabilities) != 0 {
|
||||
t.Errorf("invalid alias gained capabilities: %+v", entry)
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected ID: %s", entry.ID)
|
||||
}
|
||||
if entry.ID != "target" && (entry.Backend != "" || entry.Pinned || entry.MCP) {
|
||||
t.Errorf("alias inherited management metadata: %+v", entry)
|
||||
}
|
||||
if entry.Disabled != (entry.ID == "disabled-alias") {
|
||||
t.Errorf("disabled changed: %+v", entry)
|
||||
}
|
||||
Data []capabilityEntry `json:"data"`
|
||||
}
|
||||
Expect(json.Unmarshal(rec.Body.Bytes(), &response)).To(Succeed())
|
||||
return response.Data
|
||||
}
|
||||
|
||||
var _ = Describe("UI model capabilities", func() {
|
||||
It("exposes only a valid upscale scale", func() {
|
||||
const upscale = "backend: stablediffusion-ggml\nknown_usecases: [upscale]\n"
|
||||
data := capabilitiesOf(map[string]string{
|
||||
"upscaler": upscale + "options: [upscale_scale:4]\n",
|
||||
"absent": upscale,
|
||||
"malformed": upscale + "options: [upscale_scale:bad]\n",
|
||||
"nonpositive": upscale + "options: [upscale_scale:0]\n",
|
||||
"negative": upscale + "options: [upscale_scale:-1]\n",
|
||||
"overflow": upscale + "options: [upscale_scale:2147483648]\n",
|
||||
"duplicate": upscale + "options: [upscale_scale:4, upscale_scale:4]\n",
|
||||
})
|
||||
Expect(data).To(HaveLen(7))
|
||||
for _, entry := range data {
|
||||
Expect(entry.Capabilities).To(Equal([]string{"FLAG_UPSCALE"}), entry.ID)
|
||||
if entry.ID == "upscaler" {
|
||||
Expect(entry.Scale).ToNot(BeNil())
|
||||
Expect(*entry.Scale).To(Equal(4))
|
||||
} else {
|
||||
Expect(entry.Scale).To(BeNil(), "invalid scale exposed for %s", entry.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
It("gives a valid alias the capabilities of its target and nothing else", func() {
|
||||
data := capabilitiesOf(map[string]string{
|
||||
"target": "backend: llama-cpp\nknown_usecases: [chat, vision]\npinned: true\n",
|
||||
"alias": "alias: target\nknown_usecases: [embeddings]\nprofile:\n system_prompt: Answer briefly.\n client_tools: drop\n",
|
||||
"disabled-alias": "alias: target\ndisabled: true\n",
|
||||
"broken": "alias: missing\nknown_usecases: [chat]\n",
|
||||
"chain": "alias: alias\nknown_usecases: [chat]\n",
|
||||
})
|
||||
Expect(data).To(HaveLen(5))
|
||||
for _, entry := range data {
|
||||
switch entry.ID {
|
||||
case "target", "alias", "disabled-alias":
|
||||
Expect(entry.Capabilities).To(ConsistOf("FLAG_CHAT", "FLAG_VISION"), "%s must inherit chat/vision", entry.ID)
|
||||
case "broken", "chain":
|
||||
Expect(entry.Capabilities).To(BeEmpty(), "invalid alias %s gained capabilities", entry.ID)
|
||||
default:
|
||||
Fail("unexpected ID: " + entry.ID)
|
||||
}
|
||||
if entry.ID != "target" {
|
||||
Expect(entry.Backend).To(BeEmpty(), "alias %s inherited management metadata", entry.ID)
|
||||
Expect(entry.Pinned).To(BeFalse(), entry.ID)
|
||||
Expect(entry.MCP).To(BeFalse(), entry.ID)
|
||||
}
|
||||
Expect(entry.Disabled).To(Equal(entry.ID == "disabled-alias"), entry.ID)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -101,7 +101,7 @@ func agGap(open bool, id, what string) {
|
||||
var _ = Describe("Chats saved agents", Label("Chats"), func() {
|
||||
It("validates, replaces and deletes an agent, and refuses a chat with a deleted one", func() {
|
||||
name := fmt.Sprintf("Agent e2e %d", time.Now().UnixNano())
|
||||
id := agCreateAgent(agObj{"name": name, "instructions": "Be brief.", "model": "mock-model", "skills": []string{"docx"}})
|
||||
id := agCreateAgent(agObj{"name": name, "instructions": "Be brief.", "model": "mock-model", "skills": []string{"localai-apps"}})
|
||||
|
||||
code, _ := chatsDo(http.MethodPost, chatsBase()+"/agents", agObj{"name": strings.ToUpper(name)})
|
||||
Expect(code).To(Equal(http.StatusConflict), "names are unique ignoring case")
|
||||
@@ -157,15 +157,15 @@ var _ = Describe("Chats saved agents", Label("Chats"), func() {
|
||||
It("sends the agent's instructions and only its skills to the model, and a plain chat gets all of them", func() {
|
||||
agNeedUpstream()
|
||||
id := agCreateAgent(agObj{"name": fmt.Sprintf("Prompt e2e %d", time.Now().UnixNano()), "model": "cp-passthrough-openai",
|
||||
"instructions": "AGENT-INSTRUCTION-MARK", "skills": []string{"docx"}})
|
||||
"instructions": "AGENT-INSTRUCTION-MARK", "skills": []string{"localai-apps"}})
|
||||
system, _ := agUpstreamRequest(agObj{"agent_id": id}, "hello")
|
||||
Expect(system).To(ContainSubstring("AGENT-INSTRUCTION-MARK"))
|
||||
Expect(system).To(ContainSubstring("- docx:"))
|
||||
Expect(system).NotTo(ContainSubstring("- xlsx:"), "an agent lists only the skills it names")
|
||||
Expect(system).To(ContainSubstring("- localai-apps:"))
|
||||
Expect(system).NotTo(ContainSubstring("- localai-workspace:"), "an agent lists only the skills it names")
|
||||
|
||||
plain, _ := agUpstreamRequest(agObj{"model": "cp-passthrough-openai"}, "hello")
|
||||
Expect(plain).To(ContainSubstring("- docx:"))
|
||||
Expect(plain).To(ContainSubstring("- xlsx:"))
|
||||
Expect(plain).To(ContainSubstring("- localai-apps:"))
|
||||
Expect(plain).To(ContainSubstring("- localai-workspace:"))
|
||||
Expect(plain).NotTo(ContainSubstring("AGENT-INSTRUCTION-MARK"))
|
||||
})
|
||||
|
||||
@@ -283,9 +283,9 @@ var _ = Describe("Chats skill layers", Label("Chats"), func() {
|
||||
code, _ := chatsDo(http.MethodPost, skillsURL(""), agObj{"layer": layer, "name": "x-layer", "description": "d", "body": "b"})
|
||||
Expect(code).To(Equal(http.StatusBadRequest), layer)
|
||||
}
|
||||
code, _ := chatsDo(http.MethodGet, skillsURL("/builtin/docx"), nil)
|
||||
code, _ := chatsDo(http.MethodGet, skillsURL("/builtin/localai-apps"), nil)
|
||||
Expect(code).To(Equal(http.StatusOK), "built-in skills are readable")
|
||||
code, _ = chatsDo(http.MethodDelete, skillsURL("/builtin/docx"), nil)
|
||||
code, _ = chatsDo(http.MethodDelete, skillsURL("/builtin/localai-apps"), nil)
|
||||
Expect(code).NotTo(Equal(http.StatusOK), "built-in skills cannot be deleted")
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user