diff --git a/core/http/react-ui/e2e/addon-navigation.spec.js b/core/http/react-ui/e2e/addon-navigation.spec.js index d73a3380b..419adb17e 100644 --- a/core/http/react-ui/e2e/addon-navigation.spec.js +++ b/core/http/react-ui/e2e/addon-navigation.spec.js @@ -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') diff --git a/core/http/react-ui/e2e/addons-fixtures.js b/core/http/react-ui/e2e/addons-fixtures.js index 7d1c1fc46..ab561aaa9 100644 --- a/core/http/react-ui/e2e/addons-fixtures.js +++ b/core/http/react-ui/e2e/addons-fixtures.js @@ -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')) } diff --git a/core/http/react-ui/e2e/chats-egress.spec.js b/core/http/react-ui/e2e/chats-egress.spec.js index 9fa10b4c0..dc46d2abb 100644 --- a/core/http/react-ui/e2e/chats-egress.spec.js +++ b/core/http/react-ui/e2e/chats-egress.spec.js @@ -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' }]) }) }) diff --git a/core/http/react-ui/e2e/settings-tools.spec.js b/core/http/react-ui/e2e/settings-tools.spec.js index 02834e402..0522763c6 100644 --- a/core/http/react-ui/e2e/settings-tools.spec.js +++ b/core/http/react-ui/e2e/settings-tools.spec.js @@ -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() }) }) diff --git a/core/http/routes/ui_api_capabilities_test.go b/core/http/routes/ui_api_capabilities_test.go index bd6230fd3..d5ec1c627 100644 --- a/core/http/routes/ui_api_capabilities_test.go +++ b/core/http/routes/ui_api_capabilities_test.go @@ -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) + } + }) +}) diff --git a/tests/e2e/studio_agents_journeys_test.go b/tests/e2e/studio_agents_journeys_test.go index cd170ad62..1e7d6c686 100644 --- a/tests/e2e/studio_agents_journeys_test.go +++ b/tests/e2e/studio_agents_journeys_test.go @@ -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") })