mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-09 03:59:45 -10:00
feed models: the owner's words and warn_allow policy, a family-menu component with a test, a launch model-screen test
This commit is contained in:
1 parent
8221fb8c1c
commit
d4b72bc901
5 files changed
+142
-19
No files matched your search
@@ -20,13 +20,20 @@ export function FamilySwitch({ families, selected, onPick }: { families: Catalog
|
||||
<Icon name="models" size={20}/><strong>{tr(current.title)}</strong><Icon name="chevron" size={16} className="family-trigger__chev"/>
|
||||
</button>
|
||||
{tr(open ? <FloatingMenu anchor={trigger.current} title={tr("Model family")} width={320} onClose={() => setOpen(false)}>
|
||||
<div className="family-menu">
|
||||
{shown.map(f => <button key={f.family} type="button" role="menuitemradio" aria-checked={f.family === current.family} className="family-menu__item" onClick={() => { setOpen(false); if (f.family !== current.family) onPick(f); }}>
|
||||
<Icon name="models" size={18}/><span><strong>{tr(f.title)}</strong>{f.experimental ? <small>{tr("Experimental")}</small> : null}</span>
|
||||
{f.family === current.family ? <Icon name="check" size={16} className="family-menu__check"/> : null}
|
||||
</button>)}
|
||||
{missing.map(f => <div key={f.family} className="family-menu__item" role="note" aria-disabled="true"><Icon name="models" size={18}/><span><strong>{f.title}</strong><small>{f.unavailable_reason}</small></span></div>)}
|
||||
</div>
|
||||
<FamilyMenuList shown={shown} missing={missing} current={current} onPick={f => { setOpen(false); if (f.family !== current.family) onPick(f); }}/>
|
||||
</FloatingMenu> : null)}
|
||||
</>;
|
||||
}
|
||||
|
||||
/** The rows of the family menu: the families to pick, then (as notes, not choices) the models of the update feed this engine cannot use, each with the plain reason. */
|
||||
export function FamilyMenuList({ shown, missing, current, onPick }: { shown: CatalogModel[]; missing: CatalogModel[]; current: CatalogModel; onPick: (family: CatalogModel) => void }) {
|
||||
return (
|
||||
<div className="family-menu">
|
||||
{shown.map(f => <button key={f.family} type="button" role="menuitemradio" aria-checked={f.family === current.family} className="family-menu__item" onClick={() => onPick(f)}>
|
||||
<Icon name="models" size={18}/><span><strong>{tr(f.title)}</strong>{f.experimental ? <small>{tr("Experimental")}</small> : null}{f.available === false && f.unavailable_reason ? <small>{f.unavailable_reason}</small> : null}</span>
|
||||
{f.family === current.family ? <Icon name="check" size={16} className="family-menu__check"/> : null}
|
||||
</button>)}
|
||||
{missing.map(f => <div key={f.family} className="family-menu__item" role="note" aria-disabled="true"><Icon name="models" size={18}/><span><strong>{f.title}</strong><small>{f.unavailable_reason}</small></span></div>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {resolve} from 'node:path';
|
||||
import {createRequire} from 'node:module';
|
||||
import {build} from 'esbuild';
|
||||
|
||||
/* The family menu rows for the update feed's models: a model this engine cannot use is a note with its reason, not a choice; one that is allowed shows the reason beside its name. */
|
||||
const root=resolve(import.meta.dirname,'..');
|
||||
const virtual={'./ui':"export const Icon=()=>null;",'./FloatingMenu':"export const FloatingMenu=()=>null;"};
|
||||
const stubs={name:'stubs',setup(b){b.onResolve({filter:/^\.\/(ui|FloatingMenu)$/},a=>({path:a.path,namespace:'stub'}));b.onLoad({filter:/.*/,namespace:'stub'},a=>({contents:virtual[a.path],loader:'js'}));}};
|
||||
const out=await build({entryPoints:[resolve(root,'src/components/FamilySwitch.tsx')],bundle:true,write:false,platform:'node',format:'cjs',jsx:'automatic',plugins:[stubs],external:['react','react-dom','react/jsx-runtime'],loader:{'.json':'json'}});
|
||||
const require=createRequire(resolve(root,'package.json'));
|
||||
const mod={exports:{}};new Function('module','exports','require',out.outputFiles[0].text)(mod,mod.exports,require);
|
||||
const {renderToStaticMarkup}=require('react-dom/server');
|
||||
const React=require('react');
|
||||
const {FamilyMenuList}=mod.exports;
|
||||
const fam=(family,title,extra={})=>({family,title,about:'',experimental:false,images:true,sizes:[{id:family+'-x'}],...extra});
|
||||
test('a model the engine cannot use is a note with its reason; an allowed one shows the reason beside its name',()=>{
|
||||
const reason='needs a newer Strata engine: update Strata to use it';
|
||||
const shown=[fam('qwen3.8','Qwen3.8-Flash-Next'),fam('qwen4-test','Qwen4 Test',{feed:true,available:false,unavailable_reason:reason})];
|
||||
const missing=[fam('later-one','Later One',{sizes:[],feed:true,available:false,unavailable_reason:"this Strata version doesn't know this model yet: update Strata to use it"})];
|
||||
const html=renderToStaticMarkup(React.createElement(FamilyMenuList,{shown,missing,current:shown[0],onPick(){}}));
|
||||
assert.equal((html.match(/role="menuitemradio"/g)||[]).length,2);
|
||||
assert.equal((html.match(/role="note"/g)||[]).length,1);
|
||||
assert(html.includes('Qwen4 Test</strong><small>'+reason+'</small>'));
|
||||
assert(html.includes('Later One</strong><small>this Strata version doesn't know this model yet: update Strata to use it</small>'));
|
||||
const plain=renderToStaticMarkup(React.createElement(FamilyMenuList,{shown:[shown[0]],missing:[],current:shown[0],onPick(){}}));
|
||||
assert(!plain.includes('<small>'));assert(!plain.includes('role="note"'));
|
||||
});
|
||||
@@ -34,18 +34,20 @@ ENV_OFF = "STRATA_FEED_MODELS" # "0": setup and the screens do not r
|
||||
FETCH_TIMEOUT = 5.0
|
||||
|
||||
# ---- the words a person can see (agreed with the coordinator before they ship)
|
||||
UNAVAILABLE_ENGINE = "not available on this engine (it needs the {family} model family; update the engine)"
|
||||
UNAVAILABLE_UNKNOWN = "not available on this engine (this Strata does not know the {family} model family; update Strata)"
|
||||
UNAVAILABLE_ENGINE = "needs a newer Strata engine: update Strata to use it"
|
||||
UNAVAILABLE_UNKNOWN = "this Strata version doesn't know this model yet: update Strata to use it"
|
||||
|
||||
|
||||
#: What happens to a model whose family the installed engine does not run (the owner decides; a one-line switch):
|
||||
#: "not_offered": it is listed as not available and never offered to install;
|
||||
#: (owner, 11-06: "warn_allow", his standing rule: warn plainly, still allow the download, never refuse)
|
||||
#: "warn_allow": it is offered like any other model with the same sentence as a warning, and the install goes on (a family this Control does not know is still not offered).
|
||||
UNAVAILABLE_POLICY = "not_offered"
|
||||
UNAVAILABLE_POLICY = "warn_allow"
|
||||
|
||||
|
||||
def unavailable_reason(family: str) -> str:
|
||||
return (UNAVAILABLE_ENGINE if family in feedmod.MODEL_FAMILIES_INSTALLABLE else UNAVAILABLE_UNKNOWN).format(family=family)
|
||||
"""The sentence a person sees. The family name is not part of it (support finds it in the log line)."""
|
||||
return UNAVAILABLE_ENGINE if family in feedmod.MODEL_FAMILIES_INSTALLABLE else UNAVAILABLE_UNKNOWN
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------- the cache of the last verified list
|
||||
@@ -225,6 +227,8 @@ def apply(setup, models, engine_families=None) -> Applied:
|
||||
if not runs:
|
||||
allowed = known and UNAVAILABLE_POLICY == "warn_allow"
|
||||
reason = unavailable_reason(model.family)
|
||||
_log.info("feed model %s: family %s is not run by the installed engine (%s); policy %s", model.id, model.family,
|
||||
"unknown to this Control" if not known else "engine families " + ",".join(sorted(engine_families or ())), UNAVAILABLE_POLICY)
|
||||
setup.FEED_UNAVAILABLE[model.id] = {"title": model.title, "by": model.by, "family": model.family, "reason": reason, "allowed": allowed}
|
||||
result.unavailable[model.id] = setup.FEED_UNAVAILABLE[model.id]
|
||||
if not allowed:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""The model screen of `strata launch` with models from the signed feed: a model this Strata does not know is named under the list with the plain reason, and a feed
|
||||
without models changes nothing on the screen. No engine is needed: the list comes from the cached feed in the test's own components folder (a throwaway key)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from strata_control import feed as feedmod
|
||||
from strata_control import feed_models, signing
|
||||
from tests.terminal.launch_support import text_of
|
||||
from tests.terminal.test_launch_tui import Base, make_ctx, press
|
||||
|
||||
REV = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
|
||||
def entry(**kw):
|
||||
m = {"id": "later-one", "title": "Later One", "by": "Someone", "licence": "Apache 2.0", "licence_url": "https://example.org/l", "family": "qwen5new",
|
||||
"repo": "test/Later-GGUF", "revision": REV, "quants": [{"name": "Q4", "ram_gb": 24, "files": [{"name": "later-Q4.gguf", "size": 10, "sha256": "a" * 64}]}]}
|
||||
m.update(kw)
|
||||
return m
|
||||
|
||||
|
||||
class FeedModelsOnTheModelScreen(Base):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.key = signing.generate_key()
|
||||
keys = os.path.join(self.home.tmp.name, "keys.json")
|
||||
with open(keys, "w", encoding="utf-8") as f:
|
||||
json.dump({"keys": [signing.public_part(self.key)]}, f)
|
||||
p = mock.patch.dict(os.environ, {"STRATA_UPDATE_TRUSTED_KEYS": keys})
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def keep_feed(self, models):
|
||||
doc = {"schema_version": 1, "feed": "strata-updates", "channel": "stable", "sequence": 3, "generated_at": "2026-11-06T00:00:00Z", "expires_at": None}
|
||||
if models is not None:
|
||||
doc["models"] = {"packages": models}
|
||||
raw = (json.dumps(doc, sort_keys=True) + "\n").encode()
|
||||
sig = json.dumps(signing.sign_bytes(self.key, raw)).encode()
|
||||
feed_models.remember(_with_signature(raw, sig, self.key))
|
||||
|
||||
def test_a_model_this_strata_does_not_know_is_named_with_the_reason(self):
|
||||
self.keep_feed([entry()])
|
||||
|
||||
async def case(app, pilot, ctx):
|
||||
await press(pilot, "enter")
|
||||
t = " ".join(text_of(app).split())
|
||||
self.assertIn("Which model?", t)
|
||||
self.assertIn("Later One: " + feed_models.UNAVAILABLE_UNKNOWN, t)
|
||||
self.assertNotIn("qwen5new", t) # the family name is for the log, not for people
|
||||
self.run_app(case, ctx=make_ctx())
|
||||
|
||||
def test_a_feed_without_models_changes_nothing(self):
|
||||
self.keep_feed(None)
|
||||
|
||||
async def case(app, pilot, ctx):
|
||||
await press(pilot, "enter")
|
||||
t = text_of(app)
|
||||
self.assertNotIn("update Strata to use it", t)
|
||||
self.assertIn("Which model?", t)
|
||||
self.run_app(case, ctx=make_ctx())
|
||||
|
||||
|
||||
def _with_signature(raw: bytes, sig: bytes, key: dict):
|
||||
import dataclasses
|
||||
feed = feedmod.verify_and_parse(raw, sig, source="x", trusted={key["key_id"]: base64.b64decode(key["public"])})
|
||||
return dataclasses.replace(feed, signature=sig)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -32,6 +32,7 @@ from strata_control import feed as feedmod # noqa: E402
|
||||
from strata_control import feed_models, signing # noqa: E402
|
||||
|
||||
REV = "0123456789abcdef0123456789abcdef01234567"
|
||||
SHIPPED_POLICY = feed_models.UNAVAILABLE_POLICY
|
||||
CONTENT = {"a-00001-of-00002.gguf": b"first part of a model", "a-00002-of-00002.gguf": b"second part", "mmproj-q4.gguf": b"the picture reader",
|
||||
"Q4-model.gguf": b"one whole model file"}
|
||||
|
||||
@@ -95,6 +96,9 @@ class Base(unittest.TestCase):
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
os.environ.pop("STRATA_FEED_MODELS", None)
|
||||
policy = mock.patch.object(feed_models, "UNAVAILABLE_POLICY", "not_offered") # the tests below were written for "not_offered"; OwnerRule sets its own
|
||||
policy.start()
|
||||
self.addCleanup(policy.stop)
|
||||
from strata_control import mac_fit # the Mac tables are module globals: each test gets them back as they were
|
||||
saved = (dict(mac_fit.MAC_MODELS), dict(mac_fit.MAC_ABOUT), list(mac_fit.FEED_FAMILIES))
|
||||
|
||||
@@ -320,8 +324,8 @@ class RefusalsAndOffline(Base):
|
||||
self.assertEqual(sorted(result.unavailable), ["later-one", "qwen35-test"])
|
||||
self.assertNotIn("qwen35-test", self.setup.OWN_PATH_FAMILIES)
|
||||
self.assertNotIn("later-one", self.setup.FAMILIES)
|
||||
self.assertEqual(self.setup.FEED_UNAVAILABLE["qwen35-test"]["reason"], feed_models.UNAVAILABLE_ENGINE.format(family="qwen35moe"))
|
||||
self.assertEqual(self.setup.FEED_UNAVAILABLE["later-one"]["reason"], feed_models.UNAVAILABLE_UNKNOWN.format(family="qwen5new"))
|
||||
self.assertEqual(self.setup.FEED_UNAVAILABLE["qwen35-test"]["reason"], feed_models.UNAVAILABLE_ENGINE)
|
||||
self.assertEqual(self.setup.FEED_UNAVAILABLE["later-one"]["reason"], feed_models.UNAVAILABLE_UNKNOWN)
|
||||
self.assertNotIn("test/Qwen35-Test-GGUF", self.setup.HF_REVISIONS) # no pin was added for a model nobody can install
|
||||
self.assertFalse([k for k in self.setup.REV_PINS if k[0] == "test/Qwen35-Test-GGUF"])
|
||||
|
||||
@@ -336,7 +340,7 @@ class RefusalsAndOffline(Base):
|
||||
contextlib.redirect_stdout(io.StringIO()) as out, self.assertRaises(SystemExit) as stop:
|
||||
self.setup.main()
|
||||
self.assertNotEqual(stop.exception.code, 0)
|
||||
self.assertIn(feed_models.UNAVAILABLE_ENGINE.format(family="qwen35moe"), out.getvalue())
|
||||
self.assertIn(feed_models.UNAVAILABLE_ENGINE, out.getvalue())
|
||||
|
||||
def test_the_command_line_accepts_a_feed_model_and_its_size(self):
|
||||
feed = self.signed_feed("f", 1, [own_path()])
|
||||
@@ -388,7 +392,7 @@ class TerminalAndApp(Base):
|
||||
self.assertEqual((size["id"], size["images"], size["engine_min"], size["ram_needed_gb"]), ("qwen4-test-q4_k_m", True, "0.2.12", 48.0))
|
||||
self.assertEqual(by["later-one"]["sizes"], []) # the app hides a family without sizes
|
||||
self.assertFalse(by["later-one"]["available"])
|
||||
self.assertEqual(by["later-one"]["unavailable_reason"], feed_models.UNAVAILABLE_UNKNOWN.format(family="qwen5new"))
|
||||
self.assertEqual(by["later-one"]["unavailable_reason"], feed_models.UNAVAILABLE_UNKNOWN)
|
||||
import strata_mcp as M
|
||||
plan, args = M.Tools(s).install_plan("qwen4-test", "Q4_K_M", 65536, "no", None, None, None, None, "auto", None, None)
|
||||
self.assertEqual(args[args.index("--family") + 1:args.index("--family") + 4:2], ["qwen4-test", "Q4_K_M"]) # the install runs setup with the feed model's names
|
||||
@@ -433,11 +437,15 @@ class OwnerRule(Base):
|
||||
with contextlib.redirect_stdout(out):
|
||||
rc = s.install_own_path(a, "qwen35-test", {"index": 0, "count": 1, "vram_gb": 16.0, "name": "T"}, False, [], 13, [], 8080, 64.0, None)
|
||||
self.assertEqual(rc, 0, out.getvalue())
|
||||
self.assertIn(feed_models.UNAVAILABLE_ENGINE.format(family="qwen35moe"), out.getvalue())
|
||||
self.assertIn(feed_models.UNAVAILABLE_ENGINE, out.getvalue())
|
||||
self.assertIn(("get", "Q4-model.gguf"), calls)
|
||||
|
||||
def test_the_default_is_not_offered(self):
|
||||
self.assertEqual(feed_models.UNAVAILABLE_POLICY, "not_offered")
|
||||
def test_the_shipped_policy_and_words(self):
|
||||
self.assertEqual(SHIPPED_POLICY, "warn_allow") # the owner's standing rule: warn plainly, still allow, never refuse
|
||||
self.assertEqual(feed_models.UNAVAILABLE_ENGINE, "needs a newer Strata engine: update Strata to use it")
|
||||
self.assertEqual(feed_models.UNAVAILABLE_UNKNOWN, "this Strata version doesn't know this model yet: update Strata to use it")
|
||||
for family in ("qwen35moe", "qwen5new"):
|
||||
self.assertNotIn(family, feed_models.unavailable_reason(family))
|
||||
|
||||
|
||||
class MacTables(Base):
|
||||
@@ -515,7 +523,7 @@ class TerminalRow(Base):
|
||||
feed_models.load(self.signed_feed("f", 7, [flash_next(id="later-one", family="qwen5new")]), trusted={self.key["key_id"]: base64.b64decode(self.key["public"])})
|
||||
hw = {"ram_gb": 64.0, "gpus": [], "disk_free_gb": 500.0}
|
||||
cat = Catalog(src=ROOT, root=self.root / "inst", hardware=hw)
|
||||
self.assertEqual(cat.unavailable(hw), [("Qwen4 Test", feed_models.UNAVAILABLE_UNKNOWN.format(family="qwen5new"))])
|
||||
self.assertEqual(cat.unavailable(hw), [("Qwen4 Test", feed_models.UNAVAILABLE_UNKNOWN)])
|
||||
self.assertNotIn("later-one", [r.family for r in cat.rows(hw)])
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user