mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-02 19:58:46 +00:00
Settings → Models let the owner pick the provider from a grouped select while Settings → Agents (Available subagents, every review lane, the wizard) offered one "API model" entry and required a hand-typed `provider::model` prefix; the chooser there also listed OpenRouter catalog entries under vendor names, so "OpenAI: GPT-5.6 Terra" silently meant an OpenRouter route. One grouped source select now serves every model-assigning surface (`route_editor_primitives.js::routeChoiceGroups`): Subscriptions · models, API keys (one entry per provider whose credential is stored, plus a disabled pointer to Accounts; a saved keyless choice stays selectable as "(no key)"), Agents · sessions. The model chooser lists only the chosen source's catalog and shows the model without its prefix; the editor composes the stored spelling. Route chips name the source (API · OpenAI, Codex · model, Claude Code · agent), the meta line names the credential and the exact stored id, and a last-run receipt earned under a different route says so. Also: the catalog banner reports a partial read instead of a total failure when only one source (typically the Claudexor daemon) could not be read; the advisory route memory key is provider-qualified so a drafted API target survives a source round trip; provider-card and wizard copy no longer instruct the prefix. The rule lives in DESIGN.md §7 with a static guard (tests/test_web_source_picker_static.py).
260 lines
11 KiB
Python
260 lines
11 KiB
Python
"""Static guard: a model source is CHOSEN, never spelled (``docs/DESIGN.md`` §7).
|
|
|
|
The owner's report was that Settings → Models lets you PICK a provider from a
|
|
grouped select while Settings → Agents makes you TYPE the ``::`` prefix, and
|
|
that the surrounding help copy teaches that prefix as if it were the interface.
|
|
It is not: ``provider::model``, ``claudexor::source=model`` and ``harness=model``
|
|
are serialization the editor authors from the chosen source
|
|
(``ouroboros/provider_models.py`` is the backend SSOT for the spellings).
|
|
|
|
Two structural facts hold that rule closed, and both are checkable without a
|
|
browser:
|
|
|
|
1. no owner-facing field placeholder or help string instructs the prefix, so
|
|
the copy never re-teaches what the editor is supposed to author;
|
|
2. every model-assigning editor takes its source list from ONE primitive
|
|
(``web/modules/route_editor_primitives.js``), so the groups and their order
|
|
cannot drift apart per surface.
|
|
|
|
Pattern follows ``tests/test_web_dialogs_static.py`` and
|
|
``tests/test_web_typography_static.py``: read the sources, assert the
|
|
structural fact, no browser needed, so the quick tier enforces it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import pathlib
|
|
import re
|
|
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
WEB = REPO_ROOT / "web"
|
|
WEB_MODULES = WEB / "modules"
|
|
INDEX_HTML = WEB / "index.html"
|
|
SETUP_CONTRACT = REPO_ROOT / "ouroboros" / "settings_setup_contract.py"
|
|
DESIGN_DOC = REPO_ROOT / "docs" / "DESIGN.md"
|
|
PRIMITIVES = WEB_MODULES / "route_editor_primitives.js"
|
|
|
|
# The serialized separator the owner must never be asked to type.
|
|
PREFIX = "::"
|
|
|
|
# Copy-carrying keys. A `::` inside one of these values is owner-facing text:
|
|
# a field placeholder, a card note, a hint, a step footer. Everything else in
|
|
# JS -- `value.includes('::')`, `` `claudexor::${source}=` ``, a lastIndexOf
|
|
# separator -- is the editor doing its own serialization, which is the point of
|
|
# the rule rather than a violation of it.
|
|
COPY_KEYS = (
|
|
"placeholder", # also matches apiPlaceholder / modelPlaceholder
|
|
"note",
|
|
"hint",
|
|
"help",
|
|
"helpText",
|
|
"description",
|
|
"copy", # also matches railCopy / providerCopy / modelCopy
|
|
"footer",
|
|
"emptyText",
|
|
"label",
|
|
"title",
|
|
)
|
|
|
|
# `key: '...'`, `key = "..."` and the HTML attribute `key="..."` in one shape.
|
|
# A trailing-suffix key (apiPlaceholder, providerCopy) matches through the
|
|
# leading `\w*`; a leading-substring key does not (`noteworthy:` needs the
|
|
# separator right after the name).
|
|
_COPY_VALUE = re.compile(
|
|
r"\w*(?:" + "|".join(COPY_KEYS) + r")\s*[:=]\s*"
|
|
r"(?P<quote>['\"`])(?P<value>(?:\\.|(?!(?P=quote)).)*)(?P=quote)",
|
|
re.IGNORECASE | re.DOTALL,
|
|
)
|
|
|
|
# Module-level Python names whose `::` strings are real stored model ids --
|
|
# serialization DATA offered to the chooser, not copy telling anyone to type
|
|
# it. Keep this list short and named; a new entry is a design decision.
|
|
PY_SERIALIZATION_NAMES = frozenset({
|
|
"_MODEL_SUGGESTIONS", # ids the model chooser suggests, e.g. openai::gpt-5.6-terra
|
|
})
|
|
|
|
|
|
def _code_lines(source: str) -> list[tuple[int, str]]:
|
|
"""Source lines in code position: full-line ``//`` / ``*`` comment lines are
|
|
skipped, nothing else is stripped.
|
|
|
|
Same deliberate over-approximation as ``tests/test_web_dialogs_static.py``:
|
|
no block-comment blanking, because a ``/*`` sweep cannot tell a comment
|
|
opener from the same two characters inside a string. A prefix smuggled into
|
|
a trailing comment is reported; a blind region would be a hole.
|
|
"""
|
|
lines: list[tuple[int, str]] = []
|
|
for lineno, line in enumerate(source.splitlines(), 1):
|
|
stripped = line.strip()
|
|
if stripped.startswith("//") or stripped.startswith("*"):
|
|
continue
|
|
lines.append((lineno, line))
|
|
return lines
|
|
|
|
|
|
def _text_offenses(path: pathlib.Path) -> list[str]:
|
|
"""Copy-carrying values in a JS/HTML source that contain the prefix."""
|
|
lines = _code_lines(path.read_text(encoding="utf-8"))
|
|
rel = path.relative_to(REPO_ROOT)
|
|
offenses: list[str] = []
|
|
for lineno, line in lines:
|
|
for match in _COPY_VALUE.finditer(line):
|
|
value = match.group("value")
|
|
if PREFIX in value:
|
|
offenses.append(f"{rel}:{lineno}: {value.strip()[:160]}")
|
|
return offenses
|
|
|
|
|
|
def _owner_name(node: ast.stmt) -> str:
|
|
if isinstance(node, ast.Assign):
|
|
for target in node.targets:
|
|
if isinstance(target, ast.Name):
|
|
return target.id
|
|
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
return node.target.id
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
return node.name
|
|
return f"<line {node.lineno}>"
|
|
|
|
|
|
def _python_offenses(path: pathlib.Path) -> list[str]:
|
|
"""Prefix-bearing string constants in a Python contract module, attributed
|
|
to their top-level owner so the allowlist can be read by name."""
|
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
rel = path.relative_to(REPO_ROOT)
|
|
offenses: list[str] = []
|
|
for node in tree.body:
|
|
owner = _owner_name(node)
|
|
if owner in PY_SERIALIZATION_NAMES:
|
|
continue
|
|
for sub in ast.walk(node):
|
|
if isinstance(sub, ast.Constant) and isinstance(sub.value, str) and PREFIX in sub.value:
|
|
offenses.append(f"{rel}:{sub.lineno} ({owner}): {sub.value.strip()[:160]}")
|
|
return offenses
|
|
|
|
|
|
def test_no_model_field_placeholder_or_help_instructs_a_provider_prefix() -> None:
|
|
"""No owner-facing placeholder or help string spells the stored prefix.
|
|
|
|
docs/DESIGN.md §7: the stored spellings are "never required from the owner,
|
|
never a field placeholder or help-text instruction".
|
|
"""
|
|
offenses: list[str] = []
|
|
for path in sorted(WEB_MODULES.glob("**/*.js")):
|
|
offenses.extend(_text_offenses(path))
|
|
offenses.extend(_text_offenses(INDEX_HTML))
|
|
offenses.extend(_python_offenses(SETUP_CONTRACT))
|
|
|
|
assert not offenses, (
|
|
"owner-facing copy instructs a serialized provider prefix; the source is "
|
|
"chosen, never spelled (docs/DESIGN.md §7):\n " + "\n ".join(offenses)
|
|
)
|
|
|
|
|
|
def _import_specifiers(source: str, symbol: str) -> set[str]:
|
|
"""Module specifiers from which ``source`` imports ``symbol``.
|
|
|
|
Handles the three real shapes in ``web/modules``: a named import, an
|
|
aliased named import (``routeChoiceGroups as x``) and a namespace import
|
|
whose object is then read as ``ns.routeChoiceGroups``.
|
|
"""
|
|
specifiers: set[str] = set()
|
|
named = re.compile(r"import\s*\{(?P<names>[^}]*)\}\s*from\s*['\"](?P<from>[^'\"]+)['\"]", re.DOTALL)
|
|
for match in named.finditer(source):
|
|
names = [part.split(" as ")[0].strip() for part in match.group("names").split(",")]
|
|
if symbol in names:
|
|
specifiers.add(match.group("from"))
|
|
namespace = re.compile(r"import\s*\*\s*as\s+(?P<alias>\w+)\s*from\s*['\"](?P<from>[^'\"]+)['\"]")
|
|
for match in namespace.finditer(source):
|
|
if re.search(rf"\b{re.escape(match.group('alias'))}\s*\.\s*{re.escape(symbol)}\b", source):
|
|
specifiers.add(match.group("from"))
|
|
return specifiers
|
|
|
|
|
|
def _reexports(source: str, symbol: str) -> bool:
|
|
"""``export * from './route_editor_primitives.js'`` or an explicit
|
|
``export { symbol } from`` / re-declared wrapper of the same name."""
|
|
if re.search(r"export\s*\*\s*from\s*['\"]\./route_editor_primitives\.js['\"]", source):
|
|
return True
|
|
for match in re.finditer(
|
|
r"export\s*\{(?P<names>[^}]*)\}\s*from\s*['\"](?P<from>[^'\"]+)['\"]", source, re.DOTALL
|
|
):
|
|
names = [part.split(" as ")[-1].strip() for part in match.group("names").split(",")]
|
|
if symbol in names and "route_editor_primitives" in match.group("from"):
|
|
return True
|
|
# A same-named wrapper that delegates to the primitive counts: the source
|
|
# list still has ONE owner (reviewer_slots.js narrows the API label).
|
|
if re.search(rf"export\s+function\s+{re.escape(symbol)}\b", source):
|
|
return bool(_import_specifiers(source, symbol) or re.search(
|
|
rf"\w+\s*\.\s*{re.escape(symbol)}\b", source))
|
|
return False
|
|
|
|
|
|
def test_model_assigning_editors_share_the_grouped_source_select() -> None:
|
|
"""Models roles, Available subagents and review lanes take their source
|
|
groups from ONE primitive, directly or through a module that re-exports it.
|
|
|
|
docs/DESIGN.md §7: every model-assigning surface offers "one grouped source
|
|
select with the same groups in the same order". Three private copies of the
|
|
group list is exactly how that order drifts apart per surface.
|
|
"""
|
|
symbol = "routeChoiceGroups"
|
|
editors = ("model_roles.js", "reviewer_slots.js", "subagents_settings.js")
|
|
|
|
primitives_source = PRIMITIVES.read_text(encoding="utf-8")
|
|
assert re.search(rf"export\s+(?:function|const)\s+{symbol}\b", primitives_source), (
|
|
f"web/modules/route_editor_primitives.js must export {symbol}"
|
|
)
|
|
assert re.search(r"export\s+(?:function|const)\s+configuredApiProviders\b", primitives_source), (
|
|
"web/modules/route_editor_primitives.js must export configuredApiProviders — the "
|
|
"API keys group lists one entry per provider with a stored credential (docs/DESIGN.md §7)"
|
|
)
|
|
|
|
missing: list[str] = []
|
|
for name in editors:
|
|
path = WEB_MODULES / name
|
|
source = path.read_text(encoding="utf-8")
|
|
specifiers = _import_specifiers(source, symbol)
|
|
if any("route_editor_primitives" in spec for spec in specifiers):
|
|
continue
|
|
# One hop: an editor may take the primitive through a sibling that
|
|
# re-exports (or thinly wraps) it rather than importing it directly.
|
|
if any(
|
|
(WEB_MODULES / pathlib.Path(spec).name).is_file()
|
|
and _reexports((WEB_MODULES / pathlib.Path(spec).name).read_text(encoding="utf-8"), symbol)
|
|
for spec in specifiers
|
|
):
|
|
continue
|
|
missing.append(f"web/modules/{name}")
|
|
|
|
assert not missing, (
|
|
f"these model-assigning editors do not take {symbol} from "
|
|
"web/modules/route_editor_primitives.js (directly or via a re-export): "
|
|
+ ", ".join(missing)
|
|
)
|
|
|
|
|
|
def test_design_doc_states_the_source_is_chosen_rule() -> None:
|
|
"""The rule itself lives in the design document, with its four groups
|
|
named in the order every surface renders them."""
|
|
design = DESIGN_DOC.read_text(encoding="utf-8")
|
|
rule_sentence = "A source is chosen, never spelled."
|
|
|
|
# Scope the group check to the rule's own paragraph. "API keys" is ordinary
|
|
# vocabulary elsewhere in the document, so a whole-file index would match
|
|
# the Accounts sentence instead of the group list.
|
|
paragraphs = [" ".join(block.split()) for block in design.split("\n\n")]
|
|
rule = next((block for block in paragraphs if rule_sentence in block), "")
|
|
assert rule, f"docs/DESIGN.md must state the rule: {rule_sentence!r}"
|
|
|
|
groups = ("configured subagents", "Subscriptions · models", "API keys", "Agents · sessions")
|
|
positions = []
|
|
for group in groups:
|
|
assert group in rule, f"the rule paragraph must name the source group {group!r}"
|
|
positions.append(rule.index(group))
|
|
assert positions == sorted(positions), (
|
|
"docs/DESIGN.md must name the source groups in their rendered order: "
|
|
+ ", ".join(groups)
|
|
)
|