diff --git a/api_provider.py b/api_provider.py index 5787692..0528cb8 100644 --- a/api_provider.py +++ b/api_provider.py @@ -24,7 +24,7 @@ # this module must be importable from backend/ without PySide6 loading. import graphlink_task_config as config from graphlink_audio import guess_audio_mime_type -from graphlink_model_catalog import ModelDescriptor, ollama_descriptor, sort_descriptors +from graphlink_model_catalog import FALLBACK_ENABLED_TASKS, ModelDescriptor, ModelRef, ollama_descriptor, sort_descriptors USE_API_MODE = False @@ -2802,12 +2802,211 @@ def generate_image(prompt: str, size: str = "1024x1024", *, runtime=None) -> byt raise +def _provider_for_model_ref(model_ref: ModelRef, state: "_ProviderSnapshot"): + """ADR-018 stage 18.1: construct the provider a resolved ModelRef names, + the alternative to chat()/chat_stream()'s own task-keyed branch-select + below. Only called when a caller supplies `model_ref` (every pre-18.1 + caller does not, and gets the byte-identical original behavior). + + Both local providers are constructible from the snapshot REGARDLESS of + the session's active mode (`state.ollama_reasoning_level`/ + `state.llama_cpp_settings` are always populated, not mode-gated) - so a + node/branch override CAN pin to "my local Ollama model" while the + session's configured default is a cloud provider, and vice versa; that + is the realistic mixed local+cloud comparison the ADR's context section + describes. A CLOUD override is honored only when it names the session's + OWN currently-configured provider (reusing state.api_client/api_key, + the exact credentials already snapshotted) - pinning to a DIFFERENT + cloud provider than the session's active one raises a clear, + actionable error rather than either silently falling back to the + session default or reaching for a second provider's stored credentials + the request snapshot was never given. Genuine simultaneous multi- + cloud-credential routing is deliberately out of scope for this stage; + see doc/adr/ADR-018-model-routing.md's own status note. + + llama.cpp overrides are accepted only when the named model_id matches + one of the two paths already configured in + state.llama_cpp_settings (chat_model_path/title_model_path) - llama.cpp + has no "many installed models" catalog the way Ollama does, so free + model_id selection isn't meaningful there; a mismatch raises the same + actionable-error posture as an unconfigured cloud override.""" + + if model_ref.provider == config.LOCAL_PROVIDER_OLLAMA: + from backend.providers.ollama_provider import OllamaProvider + + return OllamaProvider( + model=model_ref.model_id, reasoning_level=state.ollama_reasoning_level, + context_window=_ollama_effective_context_window(model_ref.model_id), + ) + + if model_ref.provider == config.LOCAL_PROVIDER_LLAMACPP: + configured_paths = { + Path(str(state.llama_cpp_settings.get("chat_model_path") or "")).name, + Path(str(state.llama_cpp_settings.get("title_model_path") or "")).name, + } + if model_ref.model_id not in configured_paths: + raise RuntimeError( + f"'{model_ref.model_id}' is not one of this session's configured " + "Llama.cpp model paths. Configure it in Settings > Llama.cpp first." + ) + from backend.providers.llama_cpp_provider import LlamaCppProvider + + return LlamaCppProvider(settings=state.llama_cpp_settings) + + if model_ref.provider in (config.API_PROVIDER_OPENAI, config.API_PROVIDER_ANTHROPIC, config.API_PROVIDER_GEMINI): + if not (state.use_api_mode and state.api_provider_type == model_ref.provider and state.api_client): + raise RuntimeError( + f"This model is pinned to {model_ref.provider}, but the session's active " + f"API provider is {state.api_provider_type or 'not configured'}. Switch " + "API Endpoint in Settings to use it, or change the pinned model." + ) + if model_ref.provider == config.API_PROVIDER_OPENAI: + from backend.providers.openai_provider import OpenAIProvider + + return OpenAIProvider( + client=state.api_client, model=model_ref.model_id, + reasoning_level=state.openai_reasoning_level, + ) + if model_ref.provider == config.API_PROVIDER_ANTHROPIC: + from backend.providers.anthropic_provider import AnthropicProvider + + return AnthropicProvider( + client=state.api_client, api_key=state.api_key, model=model_ref.model_id, + reasoning_level=state.anthropic_reasoning_level, + ) + from backend.providers.gemini_provider import GeminiProvider + + return GeminiProvider( + api_key=state.api_key, model=model_ref.model_id, + reasoning_level=state.gemini_reasoning_level, + ) + + raise RuntimeError(f"Unknown model provider: {model_ref.provider!r}") + + +def _auto_fallback_model_ref( + task: str, settings_manager, state: "_ProviderSnapshot", *, exclude_provider: str | None = None, +) -> ModelRef | None: + """ADR-018 stage 18.4: the auto rung of the resolution chain, tried by + chat()/chat_stream() ONLY at the exact point they are about to raise + "no model configured" - an explicit task assignment (the common case) + is never second-guessed, so an already-working setup dispatches + byte-identically to before this stage. + + Reuses _provider_for_model_ref's own single-live-cloud-credential + posture: the catalog is filtered to what THIS session can actually + dispatch right now (both local providers always constructible; a cloud + provider only when it is the session's live credentialed one) BEFORE a + policy ever picks from it - unified_catalog() otherwise spans every + provider with a cached catalog, including ones this session has no + live client for, and choose_auto_model_ref must never hand back a ref + _provider_for_model_ref would then reject. + + `exclude_provider` (ADR-018 stage 18.5): reused by the fallback-on- + failure path below to rule out the provider that just failed - a + fallback that could re-pick the SAME broken provider would not be a + fallback at all.""" + if settings_manager is None: + return None + from graphlink_model_catalog import TASK_REQUIREMENTS, choose_auto_model_ref, unified_catalog + from backend.token_counter import price_per_mtok + + catalog = [ + descriptor + for descriptor in unified_catalog( + settings_manager, + price_lookup=lambda provider, model_id: price_per_mtok( + provider, model_id, overrides=settings_manager.get_pricing_overrides(), + ), + ) + if descriptor.provider != exclude_provider + and ( + descriptor.provider in (config.LOCAL_PROVIDER_OLLAMA, config.LOCAL_PROVIDER_LLAMACPP) + or (state.use_api_mode and descriptor.provider == state.api_provider_type) + ) + ] + policy = settings_manager.get_auto_model_policy() + return choose_auto_model_ref(catalog, TASK_REQUIREMENTS.get(task, ()), policy=policy) + + +def _fallback_model_ref_on_failure( + task: str, exc: Exception, model_ref: "ModelRef | None", settings_manager, state: "_ProviderSnapshot", +) -> ModelRef | None: + """ADR-018 stage 18.5: called from chat()/chat_stream()'s OUTER wrapper + after the primary attempt (task-keyed lookup, or an explicit model_ref + override) has raised - never from inside _chat_dispatch/ + _chat_stream_dispatch itself, which stay byte-identical to pre-18.5 + behavior. Returns None (no fallback) unless ALL of: + + - the task opts in (graphlink_model_catalog.FALLBACK_ENABLED_TASKS - + "off by default for correctness-sensitive tasks, on by default for + naming/triage", per the ADR's own decision #4) + - a settings_manager was supplied (same precondition as the 18.4 auto + rung - no catalog to fall back into otherwise) + - the failure is the SAME "retryable/unavailable" shape ADR-006 + section 6 already classifies (_is_transient_transport_error) - + never a cancellation, never a content/validation error a different + model would fail identically at.""" + if settings_manager is None or task not in FALLBACK_ENABLED_TASKS or not _is_transient_transport_error(exc): + return None + failed_provider = model_ref.provider if model_ref is not None else ( + state.api_provider_type if state.use_api_mode else state.local_provider_type + ) + return _auto_fallback_model_ref(task, settings_manager, state, exclude_provider=failed_provider) + + def chat(task: str, messages: list, **kwargs) -> dict: + """ADR-018 stage 18.5: the fallback-chain outer wrapper around + _chat_dispatch (this function's entire pre-18.5 body, unchanged). The + primary attempt always dispatches exactly as before; only on a + _fallback_model_ref_on_failure-approved failure does a SECOND attempt + fire, against a different provider, with `on_fallback` (additive, + popped here so it never reaches _chat_dispatch/ChatRequest.extra_kwargs) + invoked first so the caller can surface the substitution - "never a + silent swap" per the ADR's own decision #4. `runtime`/`settings_manager` + are peeked (kwargs.get, not pop) purely so this layer can compute the + same snapshot _chat_dispatch will independently take - the inner call + still receives its own untouched, unpopped copy of every kwarg.""" + on_fallback = kwargs.pop("on_fallback", None) + runtime = kwargs.get("runtime") + settings_manager = kwargs.get("settings_manager") + try: + return _chat_dispatch(task, messages, **kwargs) + except Exception as exc: + if isinstance(exc, RequestCancelledError): + raise + state = runtime.snapshot() if runtime is not None else _snapshot_provider_state() + fallback_ref = _fallback_model_ref_on_failure(task, exc, kwargs.get("model_ref"), settings_manager, state) + if fallback_ref is None: + raise + if on_fallback is not None: + failed_provider = ( + kwargs["model_ref"].provider if kwargs.get("model_ref") is not None + else (state.api_provider_type if state.use_api_mode else state.local_provider_type) + ) + try: + on_fallback(failed_provider, fallback_ref, exc) + except Exception: + pass # a broken notification callback must never mask the real fallback result + fallback_kwargs = dict(kwargs) + fallback_kwargs["model_ref"] = fallback_ref + return _chat_dispatch(task, messages, **fallback_kwargs) + + +def _chat_dispatch(task: str, messages: list, **kwargs) -> dict: cancel_event = kwargs.pop("cancellation_event", None) + # ADR-018 stage 18.1: an explicitly resolved ModelRef, popped BEFORE + # the remaining kwargs flow into ChatRequest.extra_kwargs - see + # _provider_for_model_ref's own docstring. None (every pre-18.1 caller) + # falls through to today's unchanged task-keyed branch-select below. + model_ref = kwargs.pop("model_ref", None) # ADR-006 stage 6.5: an explicit per-session ProviderRuntime, popped # BEFORE the remaining kwargs flow into the provider call. None (every # pre-6.5 caller) means the default session's module-backed runtime. runtime = kwargs.pop("runtime", None) + # ADR-018 stage 18.4: popped the same way - only used by the auto- + # fallback rung below, never forwarded to a provider call. + settings_manager = kwargs.pop("settings_manager", None) # One consistent view of the provider state for the whole request (#9). Worker # threads call chat() while the UI thread can re-run initialize_* at any time; @@ -2818,6 +3017,36 @@ def chat(task: str, messages: list, **kwargs) -> dict: try: _raise_if_cancelled(cancel_event) + + if model_ref is not None: + # ADR-018 stage 18.1: an already-resolved ModelRef takes + # absolute precedence over every task-keyed branch below - see + # _provider_for_model_ref's own docstring for exactly which + # provider it constructs and why. Only the LOCAL branches + # report real usage today (see the Ollama branch's own scope + # comment below); a model_ref-driven local call keeps that. + from backend.providers.base import CancelToken, ChatRequest + + provider = _provider_for_model_ref(model_ref, state) + chat_request = ChatRequest(task=task, messages=messages, extra_kwargs=kwargs, model_ref=model_ref) + token = CancelToken(cancel_event) + if model_ref.provider == config.LOCAL_PROVIDER_LLAMACPP: + # llama.cpp is excluded from transport retry - in-process + # inference has no transport (mirrors every other branch's + # own posture, see chat_stream's twin comment). + content = provider.complete(chat_request, token) + else: + content = _complete_with_transport_retry(provider, chat_request, token, cancel_event) + return { + "message": {"content": content, "role": "assistant"}, + # Real usage today only for the two branches that ever + # populate provider.last_usage (Ollama/llama.cpp) - see the + # unchanged branches below's own scope comment; getattr + # simply returns None for the three cloud providers here, + # matching that same documented gap exactly. + "usage": getattr(provider, "last_usage", None), + } + if not state.use_api_mode: if state.local_provider_type == config.LOCAL_PROVIDER_OLLAMA: # ADR-006 stage 6.5 (H6): read from the SNAPSHOT's copy of @@ -2825,6 +3054,25 @@ def chat(task: str, messages: list, **kwargs) -> dict: # _ProviderSnapshot.ollama_models. model = state.ollama_models.get(task) if not model: + auto_ref = _auto_fallback_model_ref(task, settings_manager, state) + if auto_ref is not None: + # ADR-018 stage 18.5 review fix: settings_manager + # re-included (this function popped it into a local + # above, and the plain module-level `chat` name below + # resolves to the 18.5 fallback wrapper, not back to + # this function) - without it, a failure on THIS + # auto-picked ref could never trigger a further + # fallback attempt, silently defeating 18.5 for the + # exact population of requests 18.4's own auto-pick + # serves. on_fallback is NOT re-includable here: the + # wrapper already popped it before ever calling this + # function, so it is simply out of scope at this + # point - a fallback retry after THIS recursive hop + # still fires, just without a notification. + return chat( + task, messages, model_ref=auto_ref, settings_manager=settings_manager, + cancellation_event=cancel_event, runtime=runtime, **kwargs, + ) raise ValueError(f"No Ollama model configured for task: {task}") # ADR-006 stage 6.1: the Ollama branch routes through the @@ -2896,6 +3144,16 @@ def chat(task: str, messages: list, **kwargs) -> dict: api_model = state.api_models.get(task) if not api_model: + auto_ref = _auto_fallback_model_ref(task, settings_manager, state) + if auto_ref is not None: + # ADR-018 stage 18.5 review fix: see the Ollama branch's own + # comment above - settings_manager re-included so a failure + # on this auto-picked ref can still trigger a further + # fallback attempt. + return chat( + task, messages, model_ref=auto_ref, settings_manager=settings_manager, + cancellation_event=cancel_event, runtime=runtime, **kwargs, + ) raise RuntimeError( f"No API model configured for task '{task}'.\n" "Please configure models in API Settings." @@ -3127,6 +3385,56 @@ def _translate_chat_exception(exc: Exception, state, messages: list) -> None: def chat_stream(task: str, messages: list, on_chunk: Callable[[str, bool], None], **kwargs) -> dict: + """ADR-018 stage 18.5: the streaming sibling of chat()'s own fallback- + chain outer wrapper, around _chat_stream_dispatch (this function's + entire pre-18.5 body, unchanged, renamed). Same on_fallback/exclude- + the-failed-provider mechanics as chat() - see its own docstring - + with one streaming-specific guard: a fallback attempt is legal ONLY + while `on_chunk` has delivered NOTHING real yet for this request, + mirroring the transport-retry layer's own "nothing forwarded yet" + invariant (chat_stream's module docstring) - replaying a stream that + already showed the user partial text, against a DIFFERENT model, would + corrupt rather than continue that partial output. A `reset` event + (mirrored below) already means the caller's own display is meant to be + empty again, so it re-arms the guard exactly like it re-arms + chat_stream's own accumulator (backend/agents.py's accumulated["text"] + handling).""" + on_fallback = kwargs.pop("on_fallback", None) + runtime = kwargs.get("runtime") + settings_manager = kwargs.get("settings_manager") + delivered = {"any": False} + + def _tracking_on_chunk(delta: str, reset: bool) -> None: + if reset: + delivered["any"] = False + elif delta: + delivered["any"] = True + on_chunk(delta, reset) + + try: + return _chat_stream_dispatch(task, messages, _tracking_on_chunk, **kwargs) + except Exception as exc: + if isinstance(exc, RequestCancelledError) or delivered["any"]: + raise + state = runtime.snapshot() if runtime is not None else _snapshot_provider_state() + fallback_ref = _fallback_model_ref_on_failure(task, exc, kwargs.get("model_ref"), settings_manager, state) + if fallback_ref is None: + raise + if on_fallback is not None: + failed_provider = ( + kwargs["model_ref"].provider if kwargs.get("model_ref") is not None + else (state.api_provider_type if state.use_api_mode else state.local_provider_type) + ) + try: + on_fallback(failed_provider, fallback_ref, exc) + except Exception: + pass # a broken notification callback must never mask the real fallback result + fallback_kwargs = dict(kwargs) + fallback_kwargs["model_ref"] = fallback_ref + return _chat_stream_dispatch(task, messages, on_chunk, **fallback_kwargs) + + +def _chat_stream_dispatch(task: str, messages: list, on_chunk: Callable[[str, bool], None], **kwargs) -> dict: """Streaming sibling of chat() (Qt-removal R4.4: true token streaming). ADR-006 stage 6.5b: EVERY provider streams real incremental chunks now - @@ -3146,8 +3454,15 @@ def chat_stream(task: str, messages: list, on_chunk: Callable[[str, bool], None] `cancellation_event`). """ cancel_event = kwargs.get("cancellation_event") + # ADR-018 stage 18.1: same short-circuit as chat() - see + # _provider_for_model_ref's own docstring. Left in kwargs by kwargs.get + # above's sibling; popped here since it must never reach + # ChatRequest.extra_kwargs. + model_ref = kwargs.pop("model_ref", None) # ADR-006 stage 6.5: same per-session runtime resolution as chat(). runtime = kwargs.pop("runtime", None) + # ADR-018 stage 18.4: same auto-fallback popping as chat(). + settings_manager = kwargs.pop("settings_manager", None) state = runtime.snapshot() if runtime is not None else _snapshot_provider_state() try: @@ -3164,12 +3479,26 @@ def chat_stream(task: str, messages: list, on_chunk: Callable[[str, bool], None] # recursing into chat(); real streaming owns its own.) from backend.providers.base import CancelToken, ChatRequest - if not state.use_api_mode: + if model_ref is not None: + provider = _provider_for_model_ref(model_ref, state) + elif not state.use_api_mode: if state.local_provider_type == config.LOCAL_PROVIDER_OLLAMA: # ADR-006 stage 6.5 (H6): snapshot copy, not a live table # read - see chat()'s twin comment. model = state.ollama_models.get(task) if not model: + auto_ref = _auto_fallback_model_ref(task, settings_manager, state) + if auto_ref is not None: + # ADR-018 stage 18.5 review fix: settings_manager + # re-included - see chat()'s own identical fix for + # why (this function popped it into a local above; + # without re-including it, a failure on THIS + # auto-picked ref could never trigger a further + # fallback attempt). + return chat_stream( + task, messages, on_chunk, model_ref=auto_ref, + settings_manager=settings_manager, runtime=runtime, **kwargs, + ) raise ValueError(f"No Ollama model configured for task: {task}") from backend.providers.ollama_provider import OllamaProvider @@ -3189,6 +3518,14 @@ def chat_stream(task: str, messages: list, on_chunk: Callable[[str, bool], None] raise RuntimeError("API client not initialized. Configure API settings first.") api_model = state.api_models.get(task) if not api_model: + auto_ref = _auto_fallback_model_ref(task, settings_manager, state) + if auto_ref is not None: + # ADR-018 stage 18.5 review fix: settings_manager + # re-included - see chat()'s own identical fix. + return chat_stream( + task, messages, on_chunk, model_ref=auto_ref, + settings_manager=settings_manager, runtime=runtime, **kwargs, + ) raise RuntimeError( f"No API model configured for task '{task}'.\n" "Please configure models in API Settings." @@ -3230,10 +3567,13 @@ def chat_stream(task: str, messages: list, on_chunk: Callable[[str, bool], None] # to translation exactly as before (silently replaying a half- # delivered stream would corrupt the caller's accumulated text). # llama.cpp is excluded: in-process inference has no transport. - transport_retry_allowed = ( - state.use_api_mode - or state.local_provider_type == config.LOCAL_PROVIDER_OLLAMA - ) + if model_ref is not None: + transport_retry_allowed = model_ref.provider != config.LOCAL_PROVIDER_LLAMACPP + else: + transport_retry_allowed = ( + state.use_api_mode + or state.local_provider_type == config.LOCAL_PROVIDER_OLLAMA + ) attempt = 0 while True: full_response_content = None @@ -3241,7 +3581,7 @@ def chat_stream(task: str, messages: list, on_chunk: Callable[[str, bool], None] delivered_any = False try: for event in provider.stream( - ChatRequest(task=task, messages=messages, extra_kwargs=kwargs), + ChatRequest(task=task, messages=messages, extra_kwargs=kwargs, model_ref=model_ref), CancelToken(cancel_event), ): if event.type == "text": diff --git a/backend/agents.py b/backend/agents.py index 94b70e6..e19a2c1 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -682,6 +682,29 @@ def persona(self) -> str: return "" return BASE_SYSTEM_PROMPT + def _resolve_model_ref_for_dispatch(self, canvas_document, node_id: str | None): + """ADR-018 stage 18.2: the node/branch-override rungs of + graphlink_model_catalog.resolve_model_ref, computed here (mirroring + _resolve_branch_system_prompt's own "only when canvas_document/ + node_id are supplied" restriction) and returned already resolved. + auto/workspace-default (which need the unified catalog and the + session's task-keyed default) stay out of scope here - when neither + rung fires, this returns None and the caller passes no model_ref at + all, falling through to api_provider's existing task-keyed lookup + UNCHANGED (see _provider_for_model_ref's own docstring in + api_provider.py). Diagnostics-worthy (the "why this model" rung + name) is thrown away at this layer on purpose - stage 18.3's + explain-resolution intent recomputes it fresh from the SAME + SceneDocument state the UI can already read, rather than this + already-in-flight dispatch trying to smuggle it back out.""" + if canvas_document is None or node_id is None: + return None + resolve_model_for_node = getattr(canvas_document, "resolve_model_for_node", None) + if resolve_model_for_node is None: + return None + node_ref, branch_ref = resolve_model_for_node(node_id) + return node_ref or branch_ref + def _resolve_branch_system_prompt(self, canvas_document, node_id: str | None) -> str | None: """R6.1 port of legacy graphlink_chat_agent.py's resolve_branch_system_prompt: given the id of a chat node about to be @@ -937,6 +960,19 @@ async def _commit_partial() -> None: # now both read this single resolved value instead). override = self._resolve_branch_system_prompt(canvas_document, node_id) persona_text = override if override is not None else self.persona() + # ADR-018 stage 18.2: computed once, shared by both branches + # below, exactly like persona_text/override_kwargs above. + model_ref = self._resolve_model_ref_for_dispatch(canvas_document, node_id) + model_ref_kwargs = {"model_ref": model_ref} if model_ref is not None else {} + # ADR-018 stage 18.4: the auto-policy rung's own catalog/ + # settings access lives in api_provider, not here (mirrors + # model_ref's own node/branch-only scope at this layer) - + # this only threads the SettingsManager reference down, + # same omit-when-None posture as every other additive kwarg + # in this dispatch. + settings_manager_kwargs = ( + {"settings_manager": self._settings_manager} if self._settings_manager is not None else {} + ) # ADR-006 stage 6.7: a note override reaches the wire RAW # (never wrapped in "You are Graphlink Assistant. ...") - # flagged to _call_chat_agent(_stream) only when an override @@ -968,6 +1004,27 @@ async def _notify() -> None: asyncio.run_coroutine_threadsafe(_notify(), dispatch_loop) + # ADR-018 stage 18.5: fallback-substitution notification. + # api_provider's chat()/chat_stream() outer wrapper invokes + # this on the WORKER thread the instant it decides to + # dispatch a SECOND time against a different provider - + # "never a silent swap" per the ADR's own decision #4. Same + # marshal-to-loop pattern as _thread_on_context_trimmed + # above; always supplied (unconditionally, matching + # on_context_trimmed's own posture), since notifications_state/ + # bus/dispatch_loop are always available in this scope. + def _thread_on_fallback(failed_provider: str, fallback_ref, exc: Exception) -> None: + message = ( + f"{failed_provider} is unavailable right now - this reply used " + f"{fallback_ref.provider} ({fallback_ref.model_id}) instead." + ) + + async def _notify() -> None: + notifications_state.show(message, "warning") + await bus.publish("notification") + + asyncio.run_coroutine_threadsafe(_notify(), dispatch_loop) + # ADR-006 stage 6.8: real-usage capture. The worker writes # the provider's normalized usage dict into this holder # BEFORE its to_thread future resolves (ChatWorker.run calls @@ -1098,7 +1155,10 @@ async def _emit(text: str, *, done: bool = False, reset: bool = False) -> None: **self._runtime_kwargs(), **override_kwargs, **usage_kwargs, + **model_ref_kwargs, + **settings_manager_kwargs, on_context_trimmed=_thread_on_context_trimmed, + on_fallback=_thread_on_fallback, ), timeout=WATCHDOG_TIMEOUT_SECONDS, ) @@ -1119,7 +1179,10 @@ async def _emit(text: str, *, done: bool = False, reset: bool = False) -> None: **self._runtime_kwargs(), **override_kwargs, **usage_kwargs, + **model_ref_kwargs, + **settings_manager_kwargs, on_context_trimmed=_thread_on_context_trimmed, + on_fallback=_thread_on_fallback, ), timeout=WATCHDOG_TIMEOUT_SECONDS, ) @@ -3169,7 +3232,8 @@ def _is_sandbox_error_output(output_text, return_code) -> bool: def _call_chat_agent(conversation_history, persona_text, cancel_event, *, runtime=None, - persona_is_override=False, on_context_trimmed=None, on_usage=None) -> str: + persona_is_override=False, on_context_trimmed=None, on_usage=None, + model_ref=None, settings_manager=None, on_fallback=None) -> str: """Runs inside asyncio.to_thread - a real OS thread, not the event loop. ADR-006 stage 6.5: `runtime` is an additive keyword-only kwarg, forwarded @@ -3205,11 +3269,23 @@ def _call_chat_agent(conversation_history, persona_text, cancel_event, *, runtim **({"on_context_trimmed": on_context_trimmed} if on_context_trimmed is not None else {}), # ADR-006 stage 6.8: real-usage signal - forwarded omit-when-None. **({"on_usage": on_usage} if on_usage is not None else {}), + # ADR-018 stage 18.2: resolved node/branch model pin - forwarded + # omit-when-None, same posture as every other additive kwarg here. + **({"model_ref": model_ref} if model_ref is not None else {}), + # ADR-018 stage 18.4: the session's SettingsManager, forwarded + # omit-when-None - only ever consumed by api_provider's auto-policy + # fallback (see its own docstring), never by anything in this + # module or ChatAgent/ChatWorker themselves. + **({"settings_manager": settings_manager} if settings_manager is not None else {}), + # ADR-018 stage 18.5: fallback-substitution notification - forwarded + # omit-when-None, same posture as every other additive kwarg here. + **({"on_fallback": on_fallback} if on_fallback is not None else {}), ) def _call_chat_agent_stream(conversation_history, persona_text, cancel_event, on_chunk, *, runtime=None, - persona_is_override=False, on_context_trimmed=None, on_usage=None) -> str: + persona_is_override=False, on_context_trimmed=None, on_usage=None, + model_ref=None, settings_manager=None, on_fallback=None) -> str: """Runs inside asyncio.to_thread - a real OS thread, not the event loop. Streaming counterpart to _call_chat_agent (R4.4) - same persona/ current_node/resolved_system_prompt guarantees as that function (see its @@ -3245,6 +3321,15 @@ def _call_chat_agent_stream(conversation_history, persona_text, cancel_event, on **({"on_context_trimmed": on_context_trimmed} if on_context_trimmed is not None else {}), # ADR-006 stage 6.8: real-usage signal - forwarded omit-when-None. **({"on_usage": on_usage} if on_usage is not None else {}), + # ADR-018 stage 18.2: resolved node/branch model pin - forwarded + # omit-when-None, same posture as every other additive kwarg here. + **({"model_ref": model_ref} if model_ref is not None else {}), + # ADR-018 stage 18.4: forwarded omit-when-None, same posture as + # _call_chat_agent's own settings_manager kwarg. + **({"settings_manager": settings_manager} if settings_manager is not None else {}), + # ADR-018 stage 18.5: forwarded omit-when-None, same posture as + # _call_chat_agent's own on_fallback kwarg. + **({"on_fallback": on_fallback} if on_fallback is not None else {}), ) diff --git a/backend/api/intents_model_routing.py b/backend/api/intents_model_routing.py new file mode 100644 index 0000000..e740332 --- /dev/null +++ b/backend/api/intents_model_routing.py @@ -0,0 +1,38 @@ +"""ADR-018 stage 18.3: the node/branch model-override intents. + +Two plain setters (setModelOverride/clearModelOverride), same shape as +backend/api/intents_groups.py's setGroupColor - a record_command-wrapped +scalar mutation, no agent dispatch involved. Kept in their own module +rather than folded into intents_groups.py/intents_chat.py: this is a new, +independent concern (ADR-018), not an extension of either of those ADRs' +own scope. +""" + +from __future__ import annotations + +from backend.api._shared import make_publish_scene +from backend.domain.graph import SceneDocument +from backend.events import SessionBus + + +def register_model_routing_intents(bus: SessionBus, document: SceneDocument) -> None: + publish_scene = make_publish_scene(bus) + + async def set_model_override(node_id, provider, model_id): + document.record_command( + "setModelOverride", "user", + lambda: document.set_model_override(node_id, provider, model_id), + node_ids=[node_id], + ) + await publish_scene() + + async def clear_model_override(node_id): + document.record_command( + "clearModelOverride", "user", + lambda: document.clear_model_override(node_id), + node_ids=[node_id], + ) + await publish_scene() + + bus.register_intent("scene", "setModelOverride", set_model_override) + bus.register_intent("scene", "clearModelOverride", clear_model_override) diff --git a/backend/api/intents_settings_general.py b/backend/api/intents_settings_general.py index 73b789f..32a1957 100644 --- a/backend/api/intents_settings_general.py +++ b/backend/api/intents_settings_general.py @@ -77,6 +77,14 @@ async def set_log_level(level: str): apply_log_level(level) await bus.publish("app-settings") + async def set_auto_model_policy(policy: str): + # ADR-018 stage 18.4: closed-vocabulary persist, same posture as + # set_log_level - manager.set_auto_model_policy itself already + # silently ignores an unrecognized string (see its own docstring), + # so a malformed intent arg just leaves the setting unchanged. + await asyncio.to_thread(run_locked, manager.set_auto_model_policy, str(policy)) + await bus.publish("app-settings") + async def set_notification_preference(notification_type: str, enabled: bool): await asyncio.to_thread( run_locked, manager.set_notification_preferences, {str(notification_type): bool(enabled)} @@ -131,6 +139,7 @@ async def set_provider_mode(mode: str): bus.register_intent("app-settings", "setShowTokenCounter", set_show_token_counter) bus.register_intent("app-settings", "setEnableSystemPrompt", set_enable_system_prompt) bus.register_intent("app-settings", "setLogLevel", set_log_level) + bus.register_intent("app-settings", "setAutoModelPolicy", set_auto_model_policy) bus.register_intent("app-settings", "setNotificationPreference", set_notification_preference) bus.register_intent("app-settings", "setGithubToken", set_github_token) bus.register_intent("app-settings", "clearGithubToken", clear_github_token) diff --git a/backend/canvas.py b/backend/canvas.py index d4ae777..275e216 100644 --- a/backend/canvas.py +++ b/backend/canvas.py @@ -260,6 +260,7 @@ def _placeholder_chart_data(chart_type: str) -> dict[str, Any]: from backend.api.intents_gitlink import register_gitlink_intents # noqa: E402 from backend.api.intents_grid import register_grid_intents # noqa: E402 from backend.api.intents_groups import register_groups_intents # noqa: E402 +from backend.api.intents_model_routing import register_model_routing_intents # noqa: E402 from backend.api.intents_nodes import register_node_intents # noqa: E402 from backend.api.intents_pins import register_pins_intents # noqa: E402 from backend.api.intents_pycoder import register_pycoder_intents # noqa: E402 @@ -360,6 +361,7 @@ def register_canvas( register_code_sandbox_intents(bus, document, notifications, agent_dispatcher) register_groups_intents(bus, document) + register_model_routing_intents(bus, document) register_pins_intents(bus, document) register_view_intents(bus, document) # ADR-010 stage 10.2: undo/redo rides the scene topic (see that diff --git a/backend/domain/branches.py b/backend/domain/branches.py index fb0225e..2c8c7a5 100644 --- a/backend/domain/branches.py +++ b/backend/domain/branches.py @@ -122,6 +122,62 @@ def set_final_deliverable(self, node_id: str, is_final: bool) -> None: elif self.final_deliverable_node_id == node_id: self.final_deliverable_node_id = None + def set_model_override(self, node_id: str, provider: str, model_id: str) -> None: + """ADR-018 stage 18.2: pin the model this node (and, when it is a + branch root, every descendant that doesn't pin its own - see + resolve_model_for_node's own docstring) resolves to. Both fields + write together, mirroring set_group_color's own "no partial value" + posture - a pin is a real (provider, model_id) pair or nothing.""" + node = self.nodes.get(node_id) + if node is None: + raise SceneError(f"unknown node: {node_id}") + if node.kind != "chat": + raise SceneError(f"node is not a chat node: {node_id}") + provider = str(provider or "").strip() + model_id = str(model_id or "").strip() + if not provider or not model_id: + raise SceneError("set_model_override requires both provider and model_id") + node.state.override_provider = provider + node.state.override_model_id = model_id + + def clear_model_override(self, node_id: str) -> None: + node = self.nodes.get(node_id) + if node is None: + raise SceneError(f"unknown node: {node_id}") + if node.kind != "chat": + raise SceneError(f"node is not a chat node: {node_id}") + node.state.override_provider = "" + node.state.override_model_id = "" + + def resolve_model_for_node(self, node_id: str | None): + """ADR-018 stage 18.2: the node-override -> branch-override half of + graphlink_model_catalog.resolve_model_ref's chain - returns + (node_ref, branch_ref), both `ModelRef | None`, for the caller + (backend/agents.py's _dispatch) to pass straight through to that + function alongside the workspace-default/catalog/policy it already + owns. Mirrors _resolve_branch_system_prompt's own root-walk shape + exactly (get_branch_root, then read one field off the root) - the + two features share the same "root pin, inherited down the branch" + semantics, just against a different field.""" + from graphlink_model_catalog import ModelRef + + if node_id is None: + return None, None + node = self.nodes.get(node_id) + if node is None: + return None, None + + def _ref_from(candidate) -> "ModelRef | None": + state = getattr(candidate, "state", None) + provider = getattr(state, "override_provider", "") if state is not None else "" + model_id = getattr(state, "override_model_id", "") if state is not None else "" + return ModelRef(provider, model_id) if provider and model_id else None + + node_ref = _ref_from(node) if node.kind == "chat" else None + root = self.get_branch_root(node_id) + branch_ref = _ref_from(root) if root is not None and root.id != node_id and root.kind == "chat" else None + return node_ref, branch_ref + def _branch_parent_edge(self, node_id: str) -> SceneEdge | None: """R6.1: the shared 'find the edge whose target == node_id' lookup chat_branch_history/get_branch_root/regenerate_response/ diff --git a/backend/domain/graph.py b/backend/domain/graph.py index 69f2eac..4d04789 100644 --- a/backend/domain/graph.py +++ b/backend/domain/graph.py @@ -1989,6 +1989,12 @@ def _node_wire(self, n: SceneNode) -> dict[str, Any]: # ChatState's own comment, backend/domain/node_states.py. "provider": n.state.provider if isinstance(n.state, ChatState) else None, "model": n.state.model if isinstance(n.state, ChatState) else None, + # ADR-018 stage 18.3: see ChatState's own comment on override_ + # provider/override_model_id for why this is a distinct pair + # from provider/model directly above (output provenance vs. + # input routing pin). + "overrideProvider": n.state.override_provider if isinstance(n.state, ChatState) else "", + "overrideModelId": n.state.override_model_id if isinstance(n.state, ChatState) else "", "isBranchSynthesis": n.state.is_branch_synthesis if isinstance(n.state, ChatState) else False, "synthesisInstructions": ( n.state.synthesis_instructions if isinstance(n.state, ChatState) else "" diff --git a/backend/domain/node_states.py b/backend/domain/node_states.py index e074906..6925350 100644 --- a/backend/domain/node_states.py +++ b/backend/domain/node_states.py @@ -724,3 +724,16 @@ class ChatState(NodeState): # "an assistant turn that calls tools renders the calls and their # results (collapsible)"). tool_invocations: list[dict[str, Any]] = field(default_factory=list) + # ADR-018 stage 18.2: an explicit model PIN, the opposite direction from + # provider/model above - those record what a completed reply's content + # was actually generated by (output provenance); these two decide what + # the NEXT reply from this node (or, when this node is a branch root, + # every node in the branch that doesn't pin its own) will be generated + # by (input routing). Both empty means "no pin here" - resolution falls + # through to the branch root's own pin, then the workspace task + # default, then auto (see graphlink_model_catalog.resolve_model_ref). + # A pin is a real (provider, model_id) pair or nothing - there is no + # partial-pin state; set_model_override always writes both together and + # clear_model_override always clears both together. + override_provider: str = "" + override_model_id: str = "" diff --git a/backend/providers/base.py b/backend/providers/base.py index ed37f83..032444e 100644 --- a/backend/providers/base.py +++ b/backend/providers/base.py @@ -33,6 +33,8 @@ from dataclasses import dataclass, field from typing import Any, Iterator, Literal, Mapping, Protocol, runtime_checkable +from graphlink_model_catalog import ModelRef + # ADR-006 stage 6.8: "usage" joins the union the module docstring reserved # for it. Convention: providers do NOT emit a separate "usage" event - they # attach normalized usage to the terminal "done" event (done.usage), which @@ -162,12 +164,22 @@ class ChatRequest: second source of truth that a provider could silently ignore (adversarial-review finding on the first draft, which carried exactly that dead field). Stage 6.5's per-session ProviderRuntime owns where the - level ultimately lives.""" + level ultimately lives. + + `model_ref` (ADR-018 stage 18.1): the resolved graphlink_model_catalog. + ModelRef that DROVE this provider instance's construction - carried here + for TRACEABILITY (diagnostics, "why this model" inspectability), not as + a second source of truth a provider re-derives its model from. A + provider still gets its model at construction time exactly as before + (each `*Provider.__init__` takes `model`); this field never overrides + that. None for every call site that predates ADR-018 (nothing has + broken; `model_ref` is purely additive).""" task: str messages: list extra_kwargs: Mapping[str, Any] = field(default_factory=dict) tools: tuple[ToolSpec, ...] = () + model_ref: ModelRef | None = None @dataclass(frozen=True) diff --git a/backend/session_load.py b/backend/session_load.py index 41e186b..0a3c970 100644 --- a/backend/session_load.py +++ b/backend/session_load.py @@ -410,6 +410,10 @@ def _restore_chat_payload(payload: dict[str, Any]) -> SceneNode: tool_invocations=[ dict(call) for call in (payload.get("tool_invocations") or []) if isinstance(call, dict) ], + # ADR-018 stage 18.3: absent in every pre-18.3 save -> "", + # matching the dataclass default ("no pin"). + override_provider=str(payload.get("override_provider", "") or ""), + override_model_id=str(payload.get("override_model_id", "") or ""), ), ) diff --git a/backend/session_save.py b/backend/session_save.py index 485c90a..965e814 100644 --- a/backend/session_save.py +++ b/backend/session_save.py @@ -235,6 +235,11 @@ def _serialize_chat_node(node: SceneNode) -> dict[str, Any]: # boundary in graph.py's scene_payload()), so the saved session file # keeps it structured too. "tool_invocations": [dict(call) for call in node.state.tool_invocations], + # ADR-018 stage 18.3: the model pin - survives save/load like + # provider/model above, but is the opposite direction (input + # routing, not output provenance) - see ChatState's own comment. + "override_provider": node.state.override_provider, + "override_model_id": node.state.override_model_id, } diff --git a/backend/settings.py b/backend/settings.py index 97c692e..1769de8 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -201,6 +201,10 @@ def settings_payload(manager: SettingsManager) -> dict[str, Any]: # field exists now so the backend contract carries it from the start # rather than needing a second wire-payload touch later. "logLevel": manager.get_log_level(), + # ADR-018 stage 18.4: the auto-policy rung's current setting - the + # policy api_provider's auto-fallback consults when neither an + # explicit task assignment nor a node/branch override exists. + "autoModelPolicy": manager.get_auto_model_policy(), } diff --git a/backend/tests/test_agents.py b/backend/tests/test_agents.py index da916be..a752139 100644 --- a/backend/tests/test_agents.py +++ b/backend/tests/test_agents.py @@ -562,6 +562,161 @@ async def run(): asyncio.run(run()) +# -- ADR-018 stage 18.2: node/branch model-override resolution --------------- +# +# Mirrors the System Prompt note tests above exactly - _resolve_model_ref_ +# for_dispatch is unit-tested directly first, then send_message/regenerate_ +# response are proven to actually carry the resolved ModelRef through the +# real dispatch pipeline into _call_chat_agent_stream's model_ref kwarg. + + +def test_resolve_model_ref_for_dispatch_returns_none_with_no_canvas_context(): + dispatcher = AgentDispatcher(_FakeSettingsManager()) + document = SceneDocument() + root = document.add_chat_node(0, 0, "root message", True) + assert dispatcher._resolve_model_ref_for_dispatch(None, root.id) is None + assert dispatcher._resolve_model_ref_for_dispatch(document, None) is None + + +def test_resolve_model_ref_for_dispatch_returns_none_with_no_pin_anywhere(): + dispatcher = AgentDispatcher(_FakeSettingsManager()) + document = SceneDocument() + root = document.add_chat_node(0, 0, "root message", True) + assert dispatcher._resolve_model_ref_for_dispatch(document, root.id) is None + + +def test_resolve_model_ref_for_dispatch_finds_a_pin_on_the_true_branch_root(): + import graphlink_model_catalog as mc + + dispatcher = AgentDispatcher(_FakeSettingsManager()) + document = SceneDocument() + root = document.add_chat_node(0, 0, "root message", True) + mid = document.add_chat_node(0, 100, "mid reply", False, parent_id=root.id) + leaf = document.add_chat_node(0, 200, "leaf message", True, parent_id=mid.id) + document.set_model_override(root.id, "Anthropic Claude", "claude-opus-5") + + expected = mc.ModelRef("Anthropic Claude", "claude-opus-5") + assert dispatcher._resolve_model_ref_for_dispatch(document, leaf.id) == expected + assert dispatcher._resolve_model_ref_for_dispatch(document, mid.id) == expected + assert dispatcher._resolve_model_ref_for_dispatch(document, root.id) == expected + + +def test_resolve_model_ref_for_dispatch_prefers_the_nodes_own_pin_over_the_branch_root(): + import graphlink_model_catalog as mc + + dispatcher = AgentDispatcher(_FakeSettingsManager()) + document = SceneDocument() + root = document.add_chat_node(0, 0, "root message", True) + leaf = document.add_chat_node(0, 100, "leaf reply", False, parent_id=root.id) + document.set_model_override(root.id, "Anthropic Claude", "claude-opus-5") + document.set_model_override(leaf.id, "Ollama", "llama3") + + assert dispatcher._resolve_model_ref_for_dispatch(document, leaf.id) == mc.ModelRef("Ollama", "llama3") + + +def test_send_message_carries_the_branch_pinned_model_ref_into_dispatch(monkeypatch): + import graphlink_model_catalog as mc + + _configure_fake_ollama_provider_only(monkeypatch) + captured = {} + + def fake_stream(conversation_history, persona_text, cancel_event, on_chunk, **kwargs): + captured["model_ref"] = kwargs.get("model_ref") + on_chunk("a reply", False) + return "a reply" + + monkeypatch.setattr(agents_module, "_call_chat_agent_stream", fake_stream) + + async def run(): + bus = SessionBus("agents-model-override-test") + notifications = NotificationState() + bus.register_topic("notification", notifications.payload) + composer_document = ComposerDocument() + bus.register_topic("app-composer", composer_document.payload) + dispatcher = AgentDispatcher(_FakeSettingsManager()) + document = register_canvas(bus, notifications, dispatcher, composer_document) + + root = document.add_chat_node(0, 0, "root message", True) + document.last_chat_node_id = root.id + document.set_model_override(root.id, "Anthropic Claude", "claude-opus-5") + + await bus.dispatch_intent("scene", "sendMessage", ["continue the branch"]) + entry = next(iter(chat_slots(dispatcher).values())) + await entry["task"] + + assert captured["model_ref"] == mc.ModelRef("Anthropic Claude", "claude-opus-5") + + asyncio.run(run()) + + +def test_send_message_omits_model_ref_entirely_when_nothing_is_pinned(monkeypatch): + # The behavior-preservation half of 18.2: no pin anywhere means + # _call_chat_agent_stream never even RECEIVES a model_ref kwarg - + # falling all the way through to api_provider's unchanged task-keyed + # lookup, exactly like every pre-18.2 call site. + _configure_fake_ollama_provider_only(monkeypatch) + captured = {"saw_model_ref_kwarg": "unset"} + + def fake_stream(conversation_history, persona_text, cancel_event, on_chunk, **kwargs): + captured["saw_model_ref_kwarg"] = "model_ref" in kwargs + on_chunk("a reply", False) + return "a reply" + + monkeypatch.setattr(agents_module, "_call_chat_agent_stream", fake_stream) + + async def run(): + bus = SessionBus("agents-no-model-override-test") + notifications = NotificationState() + bus.register_topic("notification", notifications.payload) + composer_document = ComposerDocument() + bus.register_topic("app-composer", composer_document.payload) + dispatcher = AgentDispatcher(_FakeSettingsManager()) + register_canvas(bus, notifications, dispatcher, composer_document) + + await bus.dispatch_intent("scene", "sendMessage", ["first message, no pin"]) + entry = next(iter(chat_slots(dispatcher).values())) + await entry["task"] + + assert captured["saw_model_ref_kwarg"] is False + + asyncio.run(run()) + + +def test_regenerate_response_also_resolves_the_branch_pinned_model_ref(monkeypatch): + import graphlink_model_catalog as mc + + _configure_fake_ollama_provider_only(monkeypatch) + captured = {} + + def fake_stream(conversation_history, persona_text, cancel_event, on_chunk, **kwargs): + captured["model_ref"] = kwargs.get("model_ref") + on_chunk("regenerated reply", False) + return "regenerated reply" + + monkeypatch.setattr(agents_module, "_call_chat_agent_stream", fake_stream) + + async def run(): + bus = SessionBus("agents-regenerate-model-override-test") + notifications = NotificationState() + bus.register_topic("notification", notifications.payload) + composer_document = ComposerDocument() + bus.register_topic("app-composer", composer_document.payload) + dispatcher = AgentDispatcher(_FakeSettingsManager()) + document = register_canvas(bus, notifications, dispatcher, composer_document) + + root = document.add_chat_node(0, 0, "root message", True) + assistant_reply = document.add_chat_node(0, 100, "old reply", False, parent_id=root.id) + document.set_model_override(root.id, "Anthropic Claude", "claude-opus-5") + + await bus.dispatch_intent("scene", "regenerateResponse", [assistant_reply.id]) + entry = next(iter(chat_slots(dispatcher).values())) + await entry["task"] + + assert captured["model_ref"] == mc.ModelRef("Anthropic Claude", "claude-opus-5") + + asyncio.run(run()) + + # -- ADR-006 stage 6.7: system-prompt wire shape at the api_provider seam ----- # # These drive the REAL _call_chat_agent_stream -> ChatAgent -> ChatWorker @@ -7132,11 +7287,20 @@ def test_default_dispatcher_still_calls_the_drivers_with_the_exact_pre_65_arity( # runtime kwarg, and (6.7) no persona_is_override kwarg on the default- # persona path. ADR-006 stage 6.6 widened the contract by exactly ONE # always-passed keyword: on_context_trimmed (the trim/summarize - # notification closure) - pinned here as keyword-only so no further - # kwargs creep in unnoticed. + # notification closure). ADR-018 stage 18.4 widened it by exactly ONE + # more: settings_manager (the auto-policy fallback's own dependency) - + # unlike model_ref (still genuinely conditional: this test's minimal + # env has no canvas_document/node override, so model_ref_kwargs stays + # empty), settings_manager is always available on a real dispatcher, so + # it is always passed. ADR-018 stage 18.5 widens it by exactly ONE more: + # on_fallback (the fallback-substitution notification closure), same + # always-available posture. All three pinned here as keyword-only so no + # FURTHER kwargs creep in unnoticed. def strict_pre_65_fake(conversation_history, persona_text, cancel_event, on_chunk, *, - on_context_trimmed): + on_context_trimmed, settings_manager, on_fallback): assert callable(on_context_trimmed) + assert settings_manager is not None + assert callable(on_fallback) return "default reply" monkeypatch.setattr(agents_module, "_call_chat_agent_stream", strict_pre_65_fake) diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 105dc2c..f3331ef 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -6622,6 +6622,25 @@ async def run(): asyncio.run(run()) +def test_set_and_clear_model_override_ws_intents_mutate_and_publish(): + # ADR-018 stage 18.3. + async def run(): + bus, document, recorder = make_bus() + + root = document.add_chat_node(0, 0, "root message", True) + root_id = root.id + + await bus.dispatch_intent("scene", "setModelOverride", [root_id, "Anthropic Claude", "claude-opus-5"]) + assert document.nodes[root_id].state.override_provider == "Anthropic Claude" + assert document.nodes[root_id].state.override_model_id == "claude-opus-5" + + await bus.dispatch_intent("scene", "clearModelOverride", [root_id]) + assert document.nodes[root_id].state.override_provider == "" + assert document.nodes[root_id].state.override_model_id == "" + + asyncio.run(run()) + + # -- R6.2: chart node (add_chart_node/resize_chart/toggle_chart_aspect_lock) -- _CHART_DATA = {"type": "bar", "title": "Widgets Sold", "labels": ["Q1", "Q2"], "values": [10.0, 20.0]} diff --git a/backend/tests/test_model_routing.py b/backend/tests/test_model_routing.py new file mode 100644 index 0000000..4eb1695 --- /dev/null +++ b/backend/tests/test_model_routing.py @@ -0,0 +1,800 @@ +"""ADR-018 stage 18.1: ModelRef dispatch and the unified catalog. + +Layer map: +1. graphlink_model_catalog's pure data/resolution functions - ModelRef, + choose_auto_model_ref's three policies, resolve_model_ref's chain, + unified_catalog's aggregation. No mocking needed; these are ordinary + functions over plain data. +2. api_provider.chat()/chat_stream() actually DISPATCHING on a supplied + model_ref - the real exit criterion. A model_ref must both bypass the + task-keyed lookup AND be able to route to a DIFFERENT provider than the + session's configured mode (Ollama is always reachable regardless of + mode - see _provider_for_model_ref's own docstring for exactly why). +3. The cross-cloud-provider mismatch: pinning to a cloud provider the + session isn't currently configured for must raise an actionable error, + never silently fall back to the session default or reach for + credentials the request was never given. +""" + +from __future__ import annotations + +import time + +import pytest + +import api_provider +import graphlink_task_config as config +import graphlink_model_catalog as mc + +# Captured at import time, before the autouse conftest fixture swaps +# api_provider.chat_stream for its one-chunk stub - see test_providers.py's +# own identical pattern/comment. +_REAL_CHAT_STREAM = api_provider.chat_stream + + +# -- layer 1: pure catalog/resolution functions ------------------------------ + + +def _descriptor(model_id, provider, *, cost=None, capabilities=(), latency="", ready=True, available=True): + cost_in, cost_out = cost if cost is not None else (None, None) + return mc.ModelDescriptor( + model_id=model_id, provider=provider, ready=ready, available=available, + capabilities=frozenset(capabilities), cost_input_per_mtok=cost_in, + cost_output_per_mtok=cost_out, latency_class=latency, + ) + + +def test_cheapest_capable_prefers_a_genuinely_free_local_model(): + catalog = [ + _descriptor("llama3", "Ollama", cost=(0.0, 0.0)), + _descriptor("gpt-4o-mini", "OpenAI-Compatible", cost=(0.15, 0.60)), + ] + assert mc.choose_auto_model_ref(catalog, policy="cheapest-capable") == mc.ModelRef("Ollama", "llama3") + + +def test_cheapest_capable_never_treats_unknown_cost_as_free(): + # An unpriced cloud model must not look artificially cheaper than a + # model this build actually knows the price of. + catalog = [ + _descriptor("mystery-model", "OpenAI-Compatible", cost=(None, None)), + _descriptor("gpt-4o-mini", "OpenAI-Compatible", cost=(0.15, 0.60)), + ] + assert mc.choose_auto_model_ref(catalog, policy="cheapest-capable").model_id == "gpt-4o-mini" + + +def test_fastest_policy_orders_by_latency_class_unknown_last(): + catalog = [ + _descriptor("slow-model", "OpenAI-Compatible", latency="slow"), + _descriptor("fast-model", "Ollama", latency="fast"), + _descriptor("unknown-latency", "Anthropic Claude", latency=""), + ] + assert mc.choose_auto_model_ref(catalog, policy="fastest") == mc.ModelRef("Ollama", "fast-model") + + +def test_best_quality_policy_prefers_the_priciest_known_cost(): + catalog = [ + _descriptor("claude-opus-5", "Anthropic Claude", cost=(15.0, 75.0)), + _descriptor("claude-haiku", "Anthropic Claude", cost=(0.80, 4.0)), + ] + ref = mc.choose_auto_model_ref(catalog, policy="best-quality") + assert ref == mc.ModelRef("Anthropic Claude", "claude-opus-5") + + +def test_auto_never_picks_a_model_missing_a_required_capability(): + # THE binary exit criterion for stage 18.4: a vision request must never + # resolve to a text-only model, regardless of policy. + catalog = [ + _descriptor("cheap-text-only", "Ollama", cost=(0.0, 0.0), capabilities={"text"}), + _descriptor("pricier-vision", "OpenAI-Compatible", cost=(5.0, 10.0), capabilities={"text", "vision"}), + ] + ref = mc.choose_auto_model_ref(catalog, {"vision"}, policy="cheapest-capable") + assert ref == mc.ModelRef("OpenAI-Compatible", "pricier-vision") + + +def test_auto_returns_none_when_nothing_in_the_catalog_is_capable(): + catalog = [_descriptor("text-only", "Ollama", capabilities={"text"})] + assert mc.choose_auto_model_ref(catalog, {"vision"}) is None + + +def test_auto_skips_unready_and_unavailable_entries(): + catalog = [ + _descriptor("not-ready", "Ollama", cost=(0.0, 0.0), ready=False), + _descriptor("not-available", "Ollama", cost=(0.0, 0.0), available=False), + _descriptor("the-only-usable-one", "OpenAI-Compatible", cost=(5.0, 5.0)), + ] + assert mc.choose_auto_model_ref(catalog).model_id == "the-only-usable-one" + + +def test_resolution_chain_prefers_node_over_branch_over_workspace_over_auto(): + catalog = [_descriptor("auto-pick", "Ollama", cost=(0.0, 0.0))] + node_ref = mc.ModelRef("Anthropic Claude", "node-pinned") + branch_ref = mc.ModelRef("Anthropic Claude", "branch-pinned") + workspace_ref = mc.ModelRef("Anthropic Claude", "workspace-default") + + all_four = mc.resolve_model_ref( + "task_chat", node_ref=node_ref, branch_ref=branch_ref, workspace_ref=workspace_ref, catalog=catalog, + ) + assert all_four == mc.ResolvedModel(node_ref, "node override") + + no_node = mc.resolve_model_ref( + "task_chat", branch_ref=branch_ref, workspace_ref=workspace_ref, catalog=catalog, + ) + assert no_node == mc.ResolvedModel(branch_ref, "branch override") + + only_workspace = mc.resolve_model_ref("task_chat", workspace_ref=workspace_ref, catalog=catalog) + assert only_workspace == mc.ResolvedModel(workspace_ref, "workspace default") + + nothing_pinned = mc.resolve_model_ref("task_chat", catalog=catalog) + assert nothing_pinned == mc.ResolvedModel(mc.ModelRef("Ollama", "auto-pick"), "auto: cheapest-capable") + + +def test_resolution_chain_returns_none_when_every_rung_is_empty(): + assert mc.resolve_model_ref("task_chat", catalog=()) is None + + +def test_an_explicit_override_is_never_capability_filtered(): + # A human's (or an inherited human's) explicit pin is trusted even + # against a capability the catalog says it lacks - only auto enforces + # the filter (ModelDescriptor.supports' own established posture). + node_ref = mc.ModelRef("OpenAI-Compatible", "text-only-model") + resolved = mc.resolve_model_ref( + "task_chart", # requires text+code per TASK_REQUIREMENTS + node_ref=node_ref, catalog=[], required_capabilities={"vision"}, + ) + assert resolved == mc.ResolvedModel(node_ref, "node override") + + +class _FakeSettingsManager: + def __init__(self, *, ollama=(), llama_cpp=(), api_catalogs=None, auto_policy=mc.AUTO_POLICY_CHEAPEST_CAPABLE): + self._ollama = list(ollama) + self._llama_cpp = list(llama_cpp) + self._api_catalogs = api_catalogs or {} + self._auto_policy = auto_policy + + def get_ollama_scanned_models(self): + return list(self._ollama) + + def get_llama_cpp_scanned_models(self): + return list(self._llama_cpp) + + def get_api_model_catalog(self, provider): + return list(self._api_catalogs.get(provider, ())) + + # ADR-018 stage 18.4: read by _auto_fallback_model_ref (api_provider.py) + # - get_pricing_overrides feeds the SAME price_lookup unified_catalog + # applies everywhere else, so a real backend.token_counter.price_per_mtok + # call always succeeds against this fake. + def get_auto_model_policy(self): + return self._auto_policy + + def get_pricing_overrides(self): + return {} + + +def test_unified_catalog_aggregates_every_configured_source(): + settings = _FakeSettingsManager( + ollama=["llama3", "qwen3:8b"], + llama_cpp=["local-model.gguf"], + api_catalogs={ + "Anthropic Claude": [{"model_id": "claude-opus-5", "capabilities": ["text"]}], + "OpenAI-Compatible": [{"model_id": "gpt-4o", "ready": False}], + }, + ) + catalog = mc.unified_catalog(settings) + # "descriptor" (never a bare 1-2 letter name) so this reads unambiguously + # as a ModelDescriptor, not a SceneNode - see + # tests/test_node_state_migration.py's own _KNOWN_NON_NODE_FIELD_ACCESS_ + # SHAPES entry for this exact file/root pair. + by_id = {(descriptor.provider, descriptor.model_id): descriptor for descriptor in catalog} + + assert ("Ollama", "llama3") in by_id + assert ("Ollama", "qwen3:8b") in by_id + assert ("Llama.cpp", "local-model.gguf") in by_id + assert by_id[("Anthropic Claude", "claude-opus-5")].capabilities == frozenset({"text"}) + assert by_id[("OpenAI-Compatible", "gpt-4o")].ready is False + + +def test_unified_catalog_applies_the_supplied_price_lookup(): + settings = _FakeSettingsManager(ollama=["llama3"]) + catalog = mc.unified_catalog(settings, price_lookup=lambda provider, model_id: (0.0, 0.0)) + assert catalog[0].cost_input_per_mtok == 0.0 + assert catalog[0].cost_output_per_mtok == 0.0 + + +def test_unified_catalog_with_no_settings_manager_returns_empty(): + assert mc.unified_catalog(None) == [] + + +# -- layer 2/3: api_provider dispatch actually honoring model_ref ----------- + + +class _FakeOllamaStream: + def __init__(self, parts): + self._iter = iter(parts) + + def __iter__(self): + return self + + def __next__(self): + return next(self._iter) + + def close(self): + pass + + +def _part(content="", done=False): + return {"message": {"content": content}, "done": done} + + +class _FakeOllamaChat: + def __init__(self, streams=None, responses=None): + self.streams = list(streams or []) + self.responses = list(responses or []) + self.calls = [] + + def __call__(self, **kwargs): + self.calls.append(kwargs) + if kwargs.get("stream"): + return self.streams.pop(0) + return self.responses.pop(0) + + +@pytest.fixture +def ollama_chat(monkeypatch): + fake = _FakeOllamaChat() + import ollama + + monkeypatch.setattr(ollama, "chat", fake) + return fake + + +@pytest.fixture +def no_backoff(monkeypatch): + monkeypatch.setattr(time, "sleep", lambda _s: None) + + +def test_model_ref_bypasses_the_task_keyed_lookup_entirely(monkeypatch, ollama_chat): + """THE 18.1 exit criterion: chat_stream dispatches on a supplied + model_ref, not on config.OLLAMA_MODELS[task] - proven by leaving the + task table completely UNCONFIGURED (the pre-18.1 code would raise "No + Ollama model configured for task") while the call still succeeds.""" + monkeypatch.setattr(api_provider, "chat_stream", _REAL_CHAT_STREAM) + monkeypatch.setattr(api_provider, "USE_API_MODE", False) + monkeypatch.setattr(api_provider, "LOCAL_PROVIDER_TYPE", config.LOCAL_PROVIDER_OLLAMA) + monkeypatch.setitem(config.OLLAMA_MODELS, config.TASK_CHAT, "") # deliberately unconfigured + + ollama_chat.streams = [_FakeOllamaStream([_part(content="hi", done=True)])] + + chunks = [] + response = api_provider.chat_stream( + config.TASK_CHAT, + [{"role": "user", "content": "hello"}], + lambda delta, reset: chunks.append((delta, reset)), + model_ref=mc.ModelRef("Ollama", "pinned-model:8b"), + ) + + assert response["message"]["content"] == "hi" + assert ollama_chat.calls[0]["model"] == "pinned-model:8b" + + +def test_model_ref_can_route_to_ollama_while_session_is_in_api_mode(monkeypatch, ollama_chat): + """The mixed local+cloud comparison scenario the ADR's context section + describes: a node/branch override can pin to a local Ollama model even + though the session's CONFIGURED default is a cloud provider - Ollama + needs no credentials, so it is always constructible.""" + monkeypatch.setattr(api_provider, "chat_stream", _REAL_CHAT_STREAM) + monkeypatch.setattr(api_provider, "USE_API_MODE", True) + monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", config.API_PROVIDER_OPENAI) + monkeypatch.setattr(api_provider, "API_CLIENT", object()) + monkeypatch.setattr(api_provider, "API_KEY", "sk-fake") + + ollama_chat.streams = [_FakeOllamaStream([_part(content="local reply", done=True)])] + + response = api_provider.chat_stream( + config.TASK_CHAT, [{"role": "user", "content": "hi"}], lambda d, r: None, + model_ref=mc.ModelRef("Ollama", "local-model"), + ) + assert response["message"]["content"] == "local reply" + + +def test_model_ref_naming_a_different_cloud_provider_than_the_session_raises_actionably(monkeypatch): + """decision #3's "unresolvable produces an actionable error, not a + silent wrong-model call" - a branch pinned to Anthropic while the + session's configured API provider is OpenAI must fail clearly, not + silently dispatch to OpenAI with Anthropic's model id, and not silently + fall back to the session default.""" + monkeypatch.setattr(api_provider, "chat_stream", _REAL_CHAT_STREAM) + monkeypatch.setattr(api_provider, "USE_API_MODE", True) + monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", config.API_PROVIDER_OPENAI) + monkeypatch.setattr(api_provider, "API_CLIENT", object()) + monkeypatch.setattr(api_provider, "API_KEY", "sk-fake") + + with pytest.raises(RuntimeError, match="Anthropic Claude"): + api_provider.chat_stream( + config.TASK_CHAT, [{"role": "user", "content": "hi"}], lambda d, r: None, + model_ref=mc.ModelRef("Anthropic Claude", "claude-opus-5"), + ) + + +def test_chat_blocking_call_also_honors_model_ref(monkeypatch, ollama_chat): + monkeypatch.setattr(api_provider, "USE_API_MODE", False) + monkeypatch.setattr(api_provider, "LOCAL_PROVIDER_TYPE", config.LOCAL_PROVIDER_OLLAMA) + monkeypatch.setitem(config.OLLAMA_MODELS, config.TASK_TITLE, "") + + ollama_chat.responses = [{"message": {"content": "A Title"}}] + + response = api_provider.chat( + config.TASK_TITLE, [{"role": "user", "content": "name this"}], + model_ref=mc.ModelRef("Ollama", "pinned-title-model"), + ) + assert response["message"]["content"] == "A Title" + assert ollama_chat.calls[0]["model"] == "pinned-title-model" + + +# -- stage 18.4: the auto-fallback rung, wired into LIVE dispatch ----------- +# +# 18.1 already proves choose_auto_model_ref/resolve_model_ref are correct as +# pure functions (including the capability-filter invariant). What was still +# untested before this stage: that api_provider.chat()/chat_stream() ever +# actually CALL those functions on the real "no model configured" path, with +# a real SettingsManager-shaped catalog and a real +# backend.token_counter.price_per_mtok price_lookup - not just that the +# functions themselves behave when invoked directly by a test. + + +def test_auto_fallback_fires_when_the_ollama_task_lookup_is_empty(monkeypatch, ollama_chat): + """THE 18.4 exit criterion for the local branch: a task with NOTHING + configured in config.OLLAMA_MODELS (the pre-18.4 code raises "No Ollama + model configured") now dispatches anyway when a settings_manager with a + scanned model is supplied.""" + monkeypatch.setattr(api_provider, "chat_stream", _REAL_CHAT_STREAM) + monkeypatch.setattr(api_provider, "USE_API_MODE", False) + monkeypatch.setattr(api_provider, "LOCAL_PROVIDER_TYPE", config.LOCAL_PROVIDER_OLLAMA) + monkeypatch.setitem(config.OLLAMA_MODELS, config.TASK_CHAT, "") # deliberately unconfigured + + settings = _FakeSettingsManager(ollama=["auto-picked:8b"]) + ollama_chat.streams = [_FakeOllamaStream([_part(content="auto reply", done=True)])] + + response = api_provider.chat_stream( + config.TASK_CHAT, [{"role": "user", "content": "hi"}], lambda d, r: None, + settings_manager=settings, + ) + assert response["message"]["content"] == "auto reply" + assert ollama_chat.calls[0]["model"] == "auto-picked:8b" + + +def test_auto_fallback_fires_for_the_blocking_ollama_call_too(monkeypatch, ollama_chat): + monkeypatch.setattr(api_provider, "USE_API_MODE", False) + monkeypatch.setattr(api_provider, "LOCAL_PROVIDER_TYPE", config.LOCAL_PROVIDER_OLLAMA) + monkeypatch.setitem(config.OLLAMA_MODELS, config.TASK_TITLE, "") + + settings = _FakeSettingsManager(ollama=["auto-title-model"]) + ollama_chat.responses = [{"message": {"content": "Auto Title"}}] + + response = api_provider.chat( + config.TASK_TITLE, [{"role": "user", "content": "name this"}], + settings_manager=settings, + ) + assert response["message"]["content"] == "Auto Title" + assert ollama_chat.calls[0]["model"] == "auto-title-model" + + +def test_without_a_settings_manager_the_original_no_model_error_is_unchanged(monkeypatch): + """Backward-compat pin: every pre-18.4 caller (nothing threads + settings_manager) must keep raising the exact original message - the + auto rung is additive, never a silent behavior change for callers that + don't opt in.""" + monkeypatch.setattr(api_provider, "USE_API_MODE", False) + monkeypatch.setattr(api_provider, "LOCAL_PROVIDER_TYPE", config.LOCAL_PROVIDER_OLLAMA) + monkeypatch.setitem(config.OLLAMA_MODELS, config.TASK_CHAT, "") + + with pytest.raises(ValueError, match="No Ollama model configured for task"): + api_provider.chat_stream(config.TASK_CHAT, [{"role": "user", "content": "hi"}], lambda d, r: None) + + with pytest.raises(ValueError, match="No Ollama model configured for task"): + api_provider.chat(config.TASK_CHAT, [{"role": "user", "content": "hi"}]) + + +def test_auto_fallback_fires_for_the_api_mode_branch_and_honors_the_persisted_policy(monkeypatch): + """The API-mode sibling of the Ollama tests above, combined with the + 18.4 setting itself: two OpenAI catalog entries with real, DIFFERENT + known prices (via the real backend.token_counter pricing table, not a + stub) - "cheapest-capable" must pick the cheap one, "best-quality" must + pick the priciest KNOWN-cost one. Proves both that the auto rung fires + on the API-mode "no api_model configured" branch and that + SettingsManager.get_auto_model_policy is actually consulted, not just + unified_catalog's aggregation.""" + import types + + monkeypatch.setattr(api_provider, "USE_API_MODE", True) + monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", config.API_PROVIDER_OPENAI) + monkeypatch.setattr(api_provider, "API_KEY", "sk-fake") + monkeypatch.setitem(api_provider.API_MODELS, config.TASK_CHAT, None) # deliberately unconfigured + + captured = {} + + def create(**kwargs): + captured.update(kwargs) + return types.SimpleNamespace( + choices=[types.SimpleNamespace(message=types.SimpleNamespace(content="ok"))] + ) + + client = types.SimpleNamespace(chat=types.SimpleNamespace(completions=types.SimpleNamespace(create=create))) + monkeypatch.setattr(api_provider, "API_CLIENT", client) + + catalog = { + "OpenAI-Compatible": [ + {"model_id": "gpt-4o-mini", "capabilities": ["text"]}, # cheap, known price + {"model_id": "gpt-4o", "capabilities": ["text"]}, # pricier, known price + ], + } + + cheap_first = _FakeSettingsManager(api_catalogs=catalog, auto_policy=mc.AUTO_POLICY_CHEAPEST_CAPABLE) + api_provider.chat(config.TASK_CHAT, [{"role": "user", "content": "hi"}], settings_manager=cheap_first) + assert captured["model"] == "gpt-4o-mini" + + captured.clear() + quality_first = _FakeSettingsManager(api_catalogs=catalog, auto_policy=mc.AUTO_POLICY_BEST_QUALITY) + api_provider.chat(config.TASK_CHAT, [{"role": "user", "content": "hi"}], settings_manager=quality_first) + assert captured["model"] == "gpt-4o" + + +def test_auto_fallback_never_dispatches_a_capability_incapable_model_live(monkeypatch): + """The live-dispatch counterpart to choose_auto_model_ref's own pure- + function capability test above: task_chart requires {text, code} + (TASK_REQUIREMENTS) - a catalog where the cheaper candidate lacks + "code" must still result in the code-capable model actually being + constructed and called, never the cheaper incapable one.""" + import types + + monkeypatch.setattr(api_provider, "USE_API_MODE", True) + monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", config.API_PROVIDER_OPENAI) + monkeypatch.setattr(api_provider, "API_KEY", "sk-fake") + monkeypatch.setitem(api_provider.API_MODELS, config.TASK_CHART, None) + + captured = {} + + def create(**kwargs): + captured.update(kwargs) + return types.SimpleNamespace( + choices=[types.SimpleNamespace(message=types.SimpleNamespace(content="ok"))] + ) + + client = types.SimpleNamespace(chat=types.SimpleNamespace(completions=types.SimpleNamespace(create=create))) + monkeypatch.setattr(api_provider, "API_CLIENT", client) + + settings = _FakeSettingsManager(api_catalogs={ + "OpenAI-Compatible": [ + # Cheaper (unknown price sorts last under cheapest-capable, but + # even a KNOWN cheap price must lose here - it lacks "code"). + {"model_id": "gpt-4o-mini", "capabilities": ["text"]}, + {"model_id": "gpt-4o", "capabilities": ["text", "code"]}, + ], + }) + + api_provider.chat(config.TASK_CHART, [{"role": "user", "content": "build a chart"}], settings_manager=settings) + assert captured["model"] == "gpt-4o" + + +def test_auto_fallback_never_crosses_to_a_cheaper_model_from_a_different_cloud_provider(monkeypatch): + """Reuses _provider_for_model_ref's own single-live-cloud-credential + posture (18.1): even though the persisted catalog has a much cheaper + Anthropic entry, the session's live client is OpenAI - the auto rung + must never resolve to a ref _provider_for_model_ref would then reject, + so it falls through to the OpenAI catalog entry, not the cheaper + Anthropic one.""" + import types + + monkeypatch.setattr(api_provider, "USE_API_MODE", True) + monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", config.API_PROVIDER_OPENAI) + monkeypatch.setattr(api_provider, "API_KEY", "sk-fake") + monkeypatch.setitem(api_provider.API_MODELS, config.TASK_CHAT, None) + + captured = {} + + def create(**kwargs): + captured.update(kwargs) + return types.SimpleNamespace( + choices=[types.SimpleNamespace(message=types.SimpleNamespace(content="ok"))] + ) + + client = types.SimpleNamespace(chat=types.SimpleNamespace(completions=types.SimpleNamespace(create=create))) + monkeypatch.setattr(api_provider, "API_CLIENT", client) + + settings = _FakeSettingsManager(api_catalogs={ + "Anthropic Claude": [{"model_id": "claude-haiku", "capabilities": ["text"]}], # far cheaper + "OpenAI-Compatible": [{"model_id": "gpt-4o", "capabilities": ["text"]}], + }) + + response = api_provider.chat( + config.TASK_CHAT, [{"role": "user", "content": "hi"}], settings_manager=settings, + ) + assert response["message"]["content"] == "ok" + assert captured["model"] == "gpt-4o" + + +# -- stage 18.5: fallback chains with visible substitution ------------------ +# +# The 18.4 tests above prove the auto rung fires when NOTHING is configured. +# This section proves the DIFFERENT scenario stage 18.5 targets: something +# IS configured and working, but fails at request time - "Ollama-down falls +# back and says so" (the ADR's own literal exit criterion). Exercised from +# the cloud-down/local-fallback direction (API mode fails, Ollama - always +# constructible - is the fallback) since it reuses the existing +# ollama_chat/_fake_openai_client fixtures without needing to fake +# llama.cpp's SDK too; the reverse direction (Ollama down, cloud/llama.cpp +# fallback) is the SAME code path with the exclude_provider argument +# flipped, not a distinct branch. + + +def _raising_openai_client(exc: Exception): + import types + + def create(**kwargs): + raise exc + + return types.SimpleNamespace(chat=types.SimpleNamespace(completions=types.SimpleNamespace(create=create))) + + +def test_fallback_fires_for_a_fallback_enabled_task_when_the_configured_provider_is_down( + monkeypatch, ollama_chat, no_backoff, +): + """THE 18.5 exit criterion: task_title (naming - fallback-enabled by + default) is fully CONFIGURED for OpenAI, but the client is down + (connection refused, retried and exhausted exactly like ADR-006 + section 6 already does for same-provider transport blips) - the reply + still succeeds, via Ollama, and on_fallback is told about it.""" + monkeypatch.setattr(api_provider, "USE_API_MODE", True) + monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", config.API_PROVIDER_OPENAI) + monkeypatch.setattr(api_provider, "API_KEY", "sk-fake") + monkeypatch.setitem(api_provider.API_MODELS, config.TASK_TITLE, "gpt-4o-mini") # genuinely configured + monkeypatch.setattr( + api_provider, "API_CLIENT", _raising_openai_client(ConnectionError("Connection refused")), + ) + + settings = _FakeSettingsManager(ollama=["fallback-model:8b"]) + ollama_chat.responses = [{"message": {"content": "A Title"}}] + + fallback_calls = [] + response = api_provider.chat( + config.TASK_TITLE, [{"role": "user", "content": "name this"}], + settings_manager=settings, + on_fallback=lambda failed_provider, ref, exc: fallback_calls.append((failed_provider, ref, exc)), + ) + + assert response["message"]["content"] == "A Title" + assert ollama_chat.calls[0]["model"] == "fallback-model:8b" + assert len(fallback_calls) == 1 + failed_provider, ref, exc = fallback_calls[0] + assert failed_provider == config.API_PROVIDER_OPENAI + assert ref == mc.ModelRef("Ollama", "fallback-model:8b") + assert isinstance(exc, ConnectionError) + + +def test_fallback_fires_for_chat_stream_too(monkeypatch, ollama_chat, no_backoff): + monkeypatch.setattr(api_provider, "chat_stream", _REAL_CHAT_STREAM) + monkeypatch.setattr(api_provider, "USE_API_MODE", True) + monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", config.API_PROVIDER_OPENAI) + monkeypatch.setattr(api_provider, "API_KEY", "sk-fake") + monkeypatch.setitem(api_provider.API_MODELS, config.TASK_WEB_VALIDATE, "gpt-4o-mini") + monkeypatch.setattr( + api_provider, "API_CLIENT", _raising_openai_client(ConnectionError("Connection refused")), + ) + + settings = _FakeSettingsManager(ollama=["fallback-model:8b"]) + ollama_chat.streams = [_FakeOllamaStream([_part(content="validated", done=True)])] + + chunks = [] + fallback_calls = [] + response = api_provider.chat_stream( + config.TASK_WEB_VALIDATE, [{"role": "user", "content": "assess this"}], + lambda d, r: chunks.append((d, r)), + settings_manager=settings, + on_fallback=lambda failed_provider, ref, exc: fallback_calls.append((failed_provider, ref)), + ) + + assert response["message"]["content"] == "validated" + assert ollama_chat.calls[0]["model"] == "fallback-model:8b" + assert fallback_calls == [(config.API_PROVIDER_OPENAI, mc.ModelRef("Ollama", "fallback-model:8b"))] + # The primary (failing) attempt never reached on_chunk - only the + # fallback attempt's real delta arrives. + assert chunks == [("validated", False)] + + +def test_correctness_sensitive_tasks_never_fall_back(monkeypatch, no_backoff): + """task_chat is NOT in FALLBACK_ENABLED_TASKS - "off by default for + correctness-sensitive tasks" per the ADR's own decision #4. The same + down-provider setup as the tests above must surface the real error, + never silently swap to a different model the user never asked for.""" + monkeypatch.setattr(api_provider, "USE_API_MODE", True) + monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", config.API_PROVIDER_OPENAI) + monkeypatch.setattr(api_provider, "API_KEY", "sk-fake") + monkeypatch.setitem(api_provider.API_MODELS, config.TASK_CHAT, "gpt-4o-mini") + monkeypatch.setattr( + api_provider, "API_CLIENT", _raising_openai_client(ConnectionError("Connection refused")), + ) + + settings = _FakeSettingsManager(ollama=["fallback-model:8b"]) + fallback_calls = [] + + with pytest.raises(ConnectionError): + api_provider.chat( + config.TASK_CHAT, [{"role": "user", "content": "hi"}], + settings_manager=settings, + on_fallback=lambda *args: fallback_calls.append(args), + ) + + assert fallback_calls == [] + + +def test_fallback_never_fires_once_the_stream_has_delivered_real_text(monkeypatch, no_backoff): + """The streaming-specific guard: chat_stream's own module docstring + already establishes transport retry is legal ONLY before anything + reaches on_chunk - stage 18.5 extends that invariant to the cross-model + fallback attempt too. A provider that streams some real text and THEN + fails must surface the failure, never silently start a second reply + from a different model (which would look like corrupted/duplicated + output to the user).""" + monkeypatch.setattr(api_provider, "chat_stream", _REAL_CHAT_STREAM) + monkeypatch.setattr(api_provider, "USE_API_MODE", True) + monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", config.API_PROVIDER_OPENAI) + monkeypatch.setattr(api_provider, "API_KEY", "sk-fake") + monkeypatch.setitem(api_provider.API_MODELS, config.TASK_TITLE, "gpt-4o-mini") + + import types + + def _text_chunk(content): + return types.SimpleNamespace( + choices=[types.SimpleNamespace(delta=types.SimpleNamespace(content=content), finish_reason=None)], + ) + + class _PartialThenFailStream: + def __init__(self): + self._delivered = False + + def __iter__(self): + return self + + def __next__(self): + if not self._delivered: + self._delivered = True + return _text_chunk("partial") + raise ConnectionError("Connection refused mid-stream") + + def close(self): + pass + + def create(**kwargs): + assert kwargs.get("stream") is True + return _PartialThenFailStream() + + client = types.SimpleNamespace(chat=types.SimpleNamespace(completions=types.SimpleNamespace(create=create))) + monkeypatch.setattr(api_provider, "API_CLIENT", client) + + settings = _FakeSettingsManager(ollama=["fallback-model:8b"]) + fallback_calls = [] + chunks = [] + + with pytest.raises(ConnectionError): + api_provider.chat_stream( + config.TASK_TITLE, [{"role": "user", "content": "name this"}], lambda d, r: chunks.append((d, r)), + settings_manager=settings, + on_fallback=lambda *args: fallback_calls.append(args), + ) + + assert chunks == [("partial", False)] + assert fallback_calls == [] + + +def test_fallback_never_fires_on_cancellation(monkeypatch, no_backoff): + monkeypatch.setattr(api_provider, "USE_API_MODE", True) + monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", config.API_PROVIDER_OPENAI) + monkeypatch.setattr(api_provider, "API_KEY", "sk-fake") + monkeypatch.setitem(api_provider.API_MODELS, config.TASK_TITLE, "gpt-4o-mini") + monkeypatch.setattr( + api_provider, "API_CLIENT", _raising_openai_client(api_provider.RequestCancelledError("Request cancelled.")), + ) + + settings = _FakeSettingsManager(ollama=["fallback-model:8b"]) + fallback_calls = [] + + with pytest.raises(api_provider.RequestCancelledError): + api_provider.chat( + config.TASK_TITLE, [{"role": "user", "content": "name this"}], + settings_manager=settings, + on_fallback=lambda *args: fallback_calls.append(args), + ) + + assert fallback_calls == [] + + +# -- review-fix regressions -------------------------------------------------- + + +def test_unified_catalog_reduces_llama_cpp_scanned_paths_to_a_basename(): + """Review-fix regression: SettingsManager.get_llama_cpp_scanned_models() + returns FULL scanned paths (confirmed by backend/tests/test_settings.py's + own test_scan_llama_cpp_system_persists_results_and_reports_done, which + asserts a raw "C:/models/a.gguf" path), but _provider_for_model_ref's + llama.cpp branch only ever accepts a model_id matching the BASENAME of a + configured path - the same convention describe_active_model already + uses. Before this fix, unified_catalog stored the raw scanned path + verbatim, so any auto/fallback pick landing on a llama.cpp candidate + would be unconditionally rejected by _provider_for_model_ref.""" + settings = _FakeSettingsManager(llama_cpp=["C:/models/local-model.gguf"]) + catalog = mc.unified_catalog(settings) + assert catalog[0].provider == "Llama.cpp" + assert catalog[0].model_id == "local-model.gguf" + + +def test_auto_fallback_can_actually_dispatch_to_a_scanned_llama_cpp_model(monkeypatch): + """Live-dispatch counterpart to the pure-function test above: the + catalog's basename-reduced llama.cpp candidate must not just LOOK + right, it must actually be constructible by _provider_for_model_ref and + complete a real (faked) request.""" + from backend.providers import llama_cpp_provider as lp + + monkeypatch.setattr(api_provider, "USE_API_MODE", False) + monkeypatch.setattr(api_provider, "LOCAL_PROVIDER_TYPE", config.LOCAL_PROVIDER_OLLAMA) + monkeypatch.setitem(config.OLLAMA_MODELS, config.TASK_CHAT, "") # deliberately unconfigured + monkeypatch.setitem(api_provider.LLAMA_CPP_SETTINGS, "chat_model_path", "C:/models/local-model.gguf") + + class FakeLlamaClient: + def create_chat_completion(self, messages=None, **kwargs): + return {"choices": [{"message": {"content": "llama answer"}}]} + + monkeypatch.setattr(lp, "_get_llama_cpp_client", lambda task, s: FakeLlamaClient()) + + settings = _FakeSettingsManager(llama_cpp=["C:/models/local-model.gguf"]) + response = api_provider.chat( + config.TASK_CHAT, [{"role": "user", "content": "hi"}], settings_manager=settings, + ) + assert response["message"]["content"] == "llama answer" + + +def test_auto_pick_recursion_preserves_settings_manager_for_a_further_fallback(monkeypatch, no_backoff): + """Review-fix regression: when NOTHING is configured for a fallback- + enabled task (task_title), chat()'s "no model configured" branch + recurses into itself with an auto-picked model_ref (18.4). Before this + fix, that recursive call silently dropped settings_manager, so if the + auto-picked model then ALSO failed, 18.5's fallback-on-failure could + never fire - the ONLY population of requests this would ever affect + (auto-picked because nothing was configured) is exactly the same + population FALLBACK_ENABLED_TASKS targets. Ollama (alphabetically + first, so the auto-pick's own tie-break lands here) is down for every + attempt; llama.cpp is the only OTHER configured provider, so a + surviving fallback can only mean settings_manager reached the SECOND + dispatch too.""" + from backend.providers import llama_cpp_provider as lp + + monkeypatch.setattr(api_provider, "USE_API_MODE", False) + monkeypatch.setattr(api_provider, "LOCAL_PROVIDER_TYPE", config.LOCAL_PROVIDER_OLLAMA) + monkeypatch.setitem(config.OLLAMA_MODELS, config.TASK_TITLE, "") # deliberately unconfigured + monkeypatch.setitem(api_provider.LLAMA_CPP_SETTINGS, "chat_model_path", "C:/models/fallback.gguf") + + import ollama + monkeypatch.setattr(ollama, "chat", lambda **kwargs: (_ for _ in ()).throw(ConnectionError("Connection refused"))) + + class FakeLlamaClient: + def create_chat_completion(self, messages=None, **kwargs): + return {"choices": [{"message": {"content": "llama fallback answered"}}]} + + monkeypatch.setattr(lp, "_get_llama_cpp_client", lambda task, s: FakeLlamaClient()) + + settings = _FakeSettingsManager(ollama=["auto-picked-ollama"], llama_cpp=["C:/models/fallback.gguf"]) + fallback_calls = [] + response = api_provider.chat( + config.TASK_TITLE, [{"role": "user", "content": "name this"}], + settings_manager=settings, + on_fallback=lambda *args: fallback_calls.append(args), + ) + + assert response["message"]["content"] == "llama fallback answered" + # on_fallback itself is a KNOWN, documented gap for this specific + # compound scenario (see the two "settings_manager re-included" review- + # fix comments in api_provider.py's recursive auto-pick branches): the + # wrapper that owns on_fallback already popped it before ever calling + # into this recursive path, so it is out of scope here - the important, + # PREVIOUSLY-BROKEN thing this test pins is that the reply still + # succeeds at all. + assert fallback_calls == [] diff --git a/backend/tests/test_session_load.py b/backend/tests/test_session_load.py index 5921d29..16d370e 100644 --- a/backend/tests/test_session_load.py +++ b/backend/tests/test_session_load.py @@ -660,6 +660,25 @@ def test_restore_chat_payload_downgrades_an_unrecognized_branch_status_to_active assert node.state.branch_status == "active" +def test_restore_chat_payload_restores_a_model_override_pin(): + # ADR-018 stage 18.3. + document = _restore(nodes=[ + _chat("n0", override_provider="Anthropic Claude", override_model_id="claude-opus-5"), + ]) + node = next(iter(document.nodes.values())) + assert node.state.override_provider == "Anthropic Claude" + assert node.state.override_model_id == "claude-opus-5" + + +def test_restore_chat_payload_with_no_override_keys_defaults_to_no_pin(): + # Every save written before this stage - the "" default, never a crash + # on the missing keys. + document = _restore(nodes=[_chat("n0")]) + node = next(iter(document.nodes.values())) + assert node.state.override_provider == "" + assert node.state.override_model_id == "" + + def test_restore_chat_payload_defaults_branch_status_to_active_when_absent(): document = _restore(nodes=[_chat("n0")]) node = next(iter(document.nodes.values())) diff --git a/backend/tests/test_session_save.py b/backend/tests/test_session_save.py index e0cc0b7..3d85772 100644 --- a/backend/tests/test_session_save.py +++ b/backend/tests/test_session_save.py @@ -473,6 +473,27 @@ def test_chat_node_serializes_synthesis_provenance_and_branch_status(): assert payload["branch_status"] == "accepted" +def test_chat_node_serializes_a_model_override_pin(): + # ADR-018 stage 18.3 - the input-routing opposite of provider/model + # above (an explicit pin, not a completed reply's provenance). + doc = SceneDocument() + root = doc.add_chat_node(0, 0, "root", True) + doc.set_model_override(root.id, "Anthropic Claude", "claude-opus-5") + + payload = next(p for p in build_chat_data(doc)["nodes"] if p.get("raw_content") == "root") + assert payload["override_provider"] == "Anthropic Claude" + assert payload["override_model_id"] == "claude-opus-5" + + +def test_chat_node_with_no_model_override_serializes_empty_strings(): + doc = SceneDocument() + root = doc.add_chat_node(0, 0, "root", True) + + payload = next(p for p in build_chat_data(doc)["nodes"] if p.get("raw_content") == "root") + assert payload["override_provider"] == "" + assert payload["override_model_id"] == "" + + def test_note_serializes_branch_comparison_provenance(): doc = SceneDocument() first = doc.add_chat_node(0, 0, "first", True) diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 4a188f1..ddd9c5a 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -41,6 +41,7 @@ def test_settings_payload_shape_matches_generated_validator_shape(manager): "githubTokenConfigured", "secretsEncryptedAtRest", "logLevel", + "autoModelPolicy", } @@ -52,6 +53,8 @@ def test_settings_payload_reflects_real_manager_defaults(manager): assert payload["githubTokenConfigured"] is False assert set(payload["notificationPreferences"]) == set(SettingsManager.NOTIFICATION_TYPES) assert payload["logLevel"] == "INFO" + # ADR-018 stage 18.4: "cheapest-capable" by default. + assert payload["autoModelPolicy"] == "cheapest-capable" def test_settings_never_imports_qt(): diff --git a/backend/token_counter.py b/backend/token_counter.py index 937a9ae..aa97083 100644 --- a/backend/token_counter.py +++ b/backend/token_counter.py @@ -71,6 +71,30 @@ def estimate_tokens(text: str) -> int: _LOCAL_PROVIDERS = {"ollama", "llama.cpp", "llamacpp", "local"} +def price_per_mtok( + provider, model, *, overrides: dict[str, dict[str, float]] | None = None, +) -> tuple[float, float] | None: + """(input, output) USD per million tokens for `model`, or None when + unknown (never guess). 0.0/0.0 for local providers - a real, stated + price, not "no data". ADR-018 stage 18.1: extracted from + estimate_cost_usd's own lookup so graphlink_model_catalog. + unified_catalog can annotate ModelDescriptor.cost_*_per_mtok with the + SAME table a completed reply's cost is billed against, via a + caller-supplied callback (this module is not imported by the + dependency-free catalog module - see ModelRef's own docstring).""" + if str(provider or "").strip().lower() in _LOCAL_PROVIDERS: + return (0.0, 0.0) + normalized_model = str(model or "").strip().lower() + if overrides: + entry = overrides.get(normalized_model) + if entry is not None: + return (float(entry.get("input", 0.0)), float(entry.get("output", 0.0))) + for prefix, prices in _MODEL_PRICES_PER_MTOK: + if normalized_model.startswith(prefix): + return prices + return None + + def estimate_cost_usd( provider, model, prompt_tokens, completion_tokens, *, overrides: dict[str, dict[str, float]] | None = None, ) -> float | None: @@ -85,25 +109,15 @@ def estimate_cost_usd( supports.""" if prompt_tokens is None and completion_tokens is None: return None - if str(provider or "").strip().lower() in _LOCAL_PROVIDERS: - return 0.0 - normalized_model = str(model or "").strip().lower() - if overrides: - entry = overrides.get(normalized_model) - if entry is not None: - return round( - (prompt_tokens or 0) / 1_000_000 * float(entry.get("input", 0.0)) - + (completion_tokens or 0) / 1_000_000 * float(entry.get("output", 0.0)), - 6, - ) - for prefix, (input_price, output_price) in _MODEL_PRICES_PER_MTOK: - if normalized_model.startswith(prefix): - return round( - (prompt_tokens or 0) / 1_000_000 * input_price - + (completion_tokens or 0) / 1_000_000 * output_price, - 6, - ) - return None + prices = price_per_mtok(provider, model, overrides=overrides) + if prices is None: + return None + input_price, output_price = prices + return round( + (prompt_tokens or 0) / 1_000_000 * input_price + + (completion_tokens or 0) / 1_000_000 * output_price, + 6, + ) @dataclass diff --git a/contracts/graphlink_app_settings_payload.py b/contracts/graphlink_app_settings_payload.py index 9aa3de3..06816da 100644 --- a/contracts/graphlink_app_settings_payload.py +++ b/contracts/graphlink_app_settings_payload.py @@ -46,6 +46,9 @@ class AppSettingsStatePayload: secretsEncryptedAtRest: bool # ADR-016 stage 16.1: General page - the log-level setting. logLevel: str + # ADR-018 stage 18.4: General page - the auto-policy rung's setting + # ("cheapest-capable" | "fastest" | "best-quality"). + autoModelPolicy: str # R7.4a: API-provider page. activeApiProvider: str viewingApiProvider: str diff --git a/contracts/graphlink_scene_payload.py b/contracts/graphlink_scene_payload.py index 1d9a512..c3e628c 100644 --- a/contracts/graphlink_scene_payload.py +++ b/contracts/graphlink_scene_payload.py @@ -495,6 +495,15 @@ class SceneNodeRow: # section (the ADR's own "an assistant turn that calls tools renders # the calls and their results (collapsible)") in ChatNodeView.tsx. toolCalls: list[ToolInvocationRow] = field(default_factory=list) + # ADR-018 stage 18.3: an explicit model PIN - populated for kind=="chat" + # rows that have one, "" (both fields together, never partial) for + # every other row. The input-routing opposite of provider/model above + # (which record what a completed reply WAS generated by); these decide + # what the NEXT reply from this node - or, when this node is a branch + # root, the branch - resolves to. See backend/domain/node_states.py's + # own comment on ChatState.override_provider/override_model_id. + overrideProvider: str = "" + overrideModelId: str = "" @dataclass diff --git a/graphlink_chat_agent.py b/graphlink_chat_agent.py index dad5c84..a6c4c2d 100644 --- a/graphlink_chat_agent.py +++ b/graphlink_chat_agent.py @@ -91,7 +91,8 @@ def __init__(self, system_prompt): self.MAX_TOKENS = 8000 def run(self, conversation_history, current_node, cancellation_event=None, resolved_system_prompt=None, - on_chunk=None, *, runtime=None, on_context_trimmed=None, on_usage=None): + on_chunk=None, *, runtime=None, on_context_trimmed=None, on_usage=None, model_ref=None, + settings_manager=None, on_fallback=None): """ Executes the chat logic for a single turn. @@ -123,6 +124,33 @@ def run(self, conversation_history, current_node, cancellation_event=None, resol ({"prompt_tokens": int | None, "completion_tokens": int | None}) when the response carried real counts. Never called when the provider reported nothing. + model_ref (graphlink_model_catalog.ModelRef, optional): ADR-018 + stage 18.2. Already resolved by the caller (AgentDispatcher's + node/branch-override chain, mirroring resolved_system_prompt's + own "resolved on the caller's side, this worker never walks + the scene itself" posture) - forwarded straight through to + api_provider.chat/chat_stream, which use it instead of their + own task-keyed model lookup when supplied. Additive, + keyword-only, default-None. + settings_manager (graphlink_settings_store.SettingsManager, + optional): ADR-018 stage 18.4. Forwarded straight through to + api_provider.chat/chat_stream's own settings_manager kwarg, + which consults it ONLY when model_ref is absent AND its own + task-keyed lookup found nothing configured - the auto-policy + rung of the resolution chain. Never used by this method + directly, and never threaded onto the nested trim- + summarization call below (that call always uses + TASK_WEB_SUMMARIZE's own workspace default - an auto-picked + fallback is scoped to the reply itself, same posture as + model_ref_kwargs). Additive, keyword-only, default-None. + on_fallback (callable, optional): ADR-018 stage 18.5. Forwarded + straight through to api_provider.chat/chat_stream's own + on_fallback kwarg, called (on THIS worker thread) with + (failed_provider, fallback_ref, exc) the instant a retryable/ + unavailable failure is substituted for a different provider - + "never a silent swap" per the ADR's own decision #4. Same + main-request-only scoping as settings_manager above. + Additive, keyword-only, default-None. Returns: str: The AI-generated response text. @@ -145,6 +173,21 @@ def run(self, conversation_history, current_node, cancellation_event=None, resol # calls (runtime=None) stay byte-identical for every existing # api_provider.chat/chat_stream monkeypatch. runtime_kwargs = {"runtime": runtime} if runtime is not None else {} + # ADR-018 stage 18.2: same omit-when-None posture as runtime_kwargs + # above - only threaded onto the MAIN request below, never onto + # the trim-summarization's own nested chat() call (that call + # always uses TASK_WEB_SUMMARIZE's own workspace default; a + # node/branch model pin is scoped to the reply itself). + model_ref_kwargs = {"model_ref": model_ref} if model_ref is not None else {} + # ADR-018 stage 18.4: same omit-when-None, main-request-only + # posture as model_ref_kwargs above. + settings_manager_kwargs = ( + {"settings_manager": settings_manager} if settings_manager is not None else {} + ) + # ADR-018 stage 18.5: same omit-when-None, main-request-only + # posture as model_ref_kwargs/settings_manager_kwargs above - + # never threaded onto the nested trim-summarization call either. + on_fallback_kwargs = {"on_fallback": on_fallback} if on_fallback is not None else {} # ADR-006 stage 6.6: the history budget derives from the ACTIVE # model's real context window (llama.cpp n_ctx / Ollama show() / @@ -211,6 +254,9 @@ def run(self, conversation_history, current_node, cancellation_event=None, resol on_chunk=on_chunk, cancellation_event=cancellation_event, **runtime_kwargs, + **model_ref_kwargs, + **settings_manager_kwargs, + **on_fallback_kwargs, ) else: response = api_provider.chat( @@ -218,6 +264,9 @@ def run(self, conversation_history, current_node, cancellation_event=None, resol messages=messages, cancellation_event=cancellation_event, **runtime_kwargs, + **model_ref_kwargs, + **settings_manager_kwargs, + **on_fallback_kwargs, ) # ADR-006 stage 6.8: surface the provider's real usage counts # when present (chat_stream always includes the key; blocking @@ -320,7 +369,8 @@ def __init__(self, name, persona): self.system_prompt = "" def get_response(self, conversation_history, current_node, cancellation_event=None, resolved_system_prompt=None, - on_chunk=None, *, runtime=None, on_context_trimmed=None, on_usage=None): + on_chunk=None, *, runtime=None, on_context_trimmed=None, on_usage=None, model_ref=None, + settings_manager=None, on_fallback=None): """ Gets an AI response for a given conversation history. @@ -338,6 +388,15 @@ def get_response(self, conversation_history, current_node, cancellation_event=No on_context_trimmed (callable, optional): ADR-006 stage 6.6 trim/summarize signal; passed straight through to ChatWorker.run (see its docstring). Additive, keyword-only, default-None. + model_ref (graphlink_model_catalog.ModelRef, optional): ADR-018 stage + 18.2; passed straight through to ChatWorker.run (see its docstring). + Additive, keyword-only, default-None. + settings_manager (graphlink_settings_store.SettingsManager, optional): + ADR-018 stage 18.4; passed straight through to ChatWorker.run + (see its docstring). Additive, keyword-only, default-None. + on_fallback (callable, optional): ADR-018 stage 18.5; passed straight + through to ChatWorker.run (see its docstring). Additive, + keyword-only, default-None. Returns: str: The AI-generated response text. @@ -354,5 +413,8 @@ def get_response(self, conversation_history, current_node, cancellation_event=No runtime=runtime, on_context_trimmed=on_context_trimmed, on_usage=on_usage, + model_ref=model_ref, + settings_manager=settings_manager, + on_fallback=on_fallback, ) return ai_response diff --git a/graphlink_model_catalog.py b/graphlink_model_catalog.py index 3cbc4d1..b71a01f 100644 --- a/graphlink_model_catalog.py +++ b/graphlink_model_catalog.py @@ -9,8 +9,9 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Iterable, Mapping +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Callable, Iterable, Mapping, NamedTuple AUTO_MODEL = "auto" @@ -55,6 +56,21 @@ class ModelDescriptor: quantization: str = "" details: Mapping[str, object] = field(default_factory=dict) error: str = "" + # ADR-018 stage 18.1: USD per million tokens, from ADR-016's pricing + # table (backend/token_counter.py) - kept as plain floats here rather + # than importing token_counter, which this Qt-free root module must + # not depend on (see this module's own "zero imports beyond stdlib" + # posture, mirrored by graphlink_task_config.py's docstring on why it + # imports FROM here and never the other direction). None means "no + # pricing data for this model", never "free" - unified_catalog() is the + # only place that fills these in, via a caller-supplied price_lookup. + cost_input_per_mtok: float | None = None + cost_output_per_mtok: float | None = None + # "" (unknown) | "fast" | "standard" | "slow" - best-effort, unset by + # every source today; a future discovery pass (observed time-to-first- + # token) is the real writer. choose_auto_model_ref's "fastest" policy + # treats "" as slowest-of-the-unknowns, never assumed fast. + latency_class: str = "" def supports(self, required: Iterable[str]) -> bool: required = set(required or ()) @@ -252,3 +268,232 @@ def resolve_task_model( if chat_model: return normalize_model_id(chat_model) return choose_auto_model(task, catalog, preferred_model=chat_model if task != "task_chat" else "") + + +# -- ADR-018: ModelRef dispatch, the resolution chain, auto policies -------- + + +@dataclass(frozen=True) +class ModelRef: + """A resolved (provider, model) pair - the unit of dispatch ADR-018 + replaces task-keyed globals with. `provider` uses this codebase's own + provider constant VALUES (graphlink_task_config.LOCAL_PROVIDER_OLLAMA + == "Ollama", API_PROVIDER_ANTHROPIC == "Anthropic Claude", etc.) as + plain strings rather than importing that module's names - this module + stays dependency-free (task_config imports FROM here, never the + reverse; see that module's own docstring), and every provider-select + branch this ADR touches (api_provider.py) already switches on those + exact string values, so no translation layer is needed anywhere.""" + + provider: str + model_id: str + + +class ResolvedModel(NamedTuple): + """A ModelRef plus WHICH rung of the resolution chain produced it - + ADR-018 decision #3's inspectability requirement ("the UI can always + answer 'why this model?'"). `rung` is one of "node override", "branch + override", "workspace default", or "auto: ".""" + + ref: ModelRef + rung: str + + +AUTO_POLICY_CHEAPEST_CAPABLE = "cheapest-capable" +AUTO_POLICY_FASTEST = "fastest" +AUTO_POLICY_BEST_QUALITY = "best-quality" +AUTO_POLICIES = (AUTO_POLICY_CHEAPEST_CAPABLE, AUTO_POLICY_FASTEST, AUTO_POLICY_BEST_QUALITY) + +# ADR-018 stage 18.5: tasks where a retryable/unavailable request failure +# (ADR-006 section 6's transient-transport classification, exhausted) falls +# back to a DIFFERENT provider instead of surfacing the error - "naming/ +# triage" per the ADR's own "off by default for correctness-sensitive +# tasks, on by default for naming/triage" framing. task_title is literally +# naming; task_web_validate is the web-research pipeline's fast per- +# document relevance triage. Every other task (task_chat's own visible +# reply, task_chart's generated code, task_image_gen's specific request, +# task_web_summarize's fidelity to source text) is correctness-sensitive - +# a silent model swap there would corrupt exactly the model-comparison +# workflow this ADR's own "Alternatives considered" section rejects +# enabling by default for. +FALLBACK_ENABLED_TASKS = frozenset({"task_title", "task_web_validate"}) + +_LATENCY_RANK = {"fast": 0, "standard": 1, "slow": 2, "": 3} + + +def _known_cost(descriptor: ModelDescriptor) -> float | None: + if descriptor.cost_input_per_mtok is None or descriptor.cost_output_per_mtok is None: + return None + return descriptor.cost_input_per_mtok + descriptor.cost_output_per_mtok + + +def choose_auto_model_ref( + catalog: Iterable[ModelDescriptor], + required_capabilities: Iterable[str] = (), + *, + policy: str = AUTO_POLICY_CHEAPEST_CAPABLE, +) -> ModelRef | None: + """The auto rung: a policy over the catalog, never an LLM deciding + (ADR-018 decision #3). Capability-filtered FIRST, always - the one + invariant every policy shares, so a vision request can never resolve + to a text-only model regardless of which policy is active. Returns + None when nothing in the catalog is ready/available/capable; the + caller turns that into an actionable error, never a silent guess.""" + + required = frozenset(required_capabilities or ()) + candidates = [ + d for d in sort_descriptors(catalog) + if d.ready and d.available and d.supports(required) + ] + if not candidates: + return None + + if policy == AUTO_POLICY_FASTEST: + candidates.sort(key=lambda d: (_LATENCY_RANK.get(d.latency_class, 3), d.model_id.lower())) + elif policy == AUTO_POLICY_BEST_QUALITY: + # No independent "quality" signal exists in the catalog - cost is + # used as the proxy the ADR's own context section frames ("a cheap + # fast model for naming... a strong model for reasoning"), so the + # priciest KNOWN-cost model wins. Unknown cost sorts last, never + # assumed to be the best. + def _quality_key(d: ModelDescriptor): + cost = _known_cost(d) + return (0, -cost) if cost is not None else (1, 0.0) + candidates.sort(key=lambda d: (_quality_key(d), d.model_id.lower())) + else: + # AUTO_POLICY_CHEAPEST_CAPABLE (also the fallback for an unknown + # policy string - cost-based is the safer default to fail toward). + # A genuinely free local model (cost 0.0+0.0) always wins; among + # KNOWN nonzero costs the cheapest wins; unknown cost sorts last - + # never assumed free, which would make an unpriced cloud model look + # artificially cheaper than a priced one. + def _cost_key(d: ModelDescriptor): + cost = _known_cost(d) + if cost is None: + return (2, 0.0) + return (0, 0.0) if cost == 0.0 else (1, cost) + candidates.sort(key=lambda d: (_cost_key(d), d.model_id.lower())) + + chosen = candidates[0] + return ModelRef(provider=chosen.provider, model_id=chosen.model_id) + + +def resolve_model_ref( + task: str, + *, + node_ref: ModelRef | None = None, + branch_ref: ModelRef | None = None, + workspace_ref: ModelRef | None = None, + catalog: Iterable[ModelDescriptor] = (), + auto_policy: str = AUTO_POLICY_CHEAPEST_CAPABLE, + required_capabilities: Iterable[str] | None = None, +) -> ResolvedModel | None: + """The chain ADR-018 decision #3 specifies: node override -> branch + override -> workspace default for task -> auto(policy). Returns None + when every rung is empty AND auto can't resolve either - the caller's + signal to raise an actionable error rather than dispatch with nothing. + + Deliberately NOT capability-filtering the first three rungs: those are + a human's (or an inherited human's) explicit pin, and this module's own + ModelDescriptor.supports() docstring already establishes the posture + that missing/mismatched capability metadata must never make an + explicit choice disappear - "the runtime/provider remains the final + authority". Only the auto rung, which picks on the user's behalf, + enforces the filter (see choose_auto_model_ref).""" + + for ref, rung in ( + (node_ref, "node override"), + (branch_ref, "branch override"), + (workspace_ref, "workspace default"), + ): + if ref is not None and ref.provider and ref.model_id: + return ResolvedModel(ref, rung) + + required = ( + required_capabilities if required_capabilities is not None + else TASK_REQUIREMENTS.get(task, frozenset()) + ) + auto_ref = choose_auto_model_ref(catalog, required, policy=auto_policy) + if auto_ref is None: + return None + return ResolvedModel(auto_ref, f"auto: {auto_policy}") + + +# Literal provider-constant values duplicated from graphlink_task_config.py +# (see ModelRef's own docstring for why this module cannot import that one). +# Kept as a tuple, not individual names, so unified_catalog stays a single +# short loop rather than five near-identical blocks. +_OLLAMA = "Ollama" +_LLAMACPP = "Llama.cpp" +_API_PROVIDERS = ("OpenAI-Compatible", "Anthropic Claude", "Google Gemini") + + +def unified_catalog( + settings_manager, + *, + price_lookup: Callable[[str, str], tuple[float, float] | None] | None = None, +) -> list[ModelDescriptor]: + """ADR-018 stage 18.1: one list spanning every configured provider - + "graphlink_model_catalog.py becomes the single catalog for every + provider" (the ADR's own decision #2). A pure aggregation over already + -cached discovery data (Ollama/llama.cpp scan results, each API + provider's last catalog refresh) - it makes no network calls itself, + so it is cheap enough to call on the resolution hot path. `price_lookup + (provider, model_id) -> (input_usd_per_mtok, output_usd_per_mtok) | None` + is caller-supplied (backend/token_counter.py's pricing table) rather + than imported, for the same dependency-direction reason ModelRef's + docstring gives.""" + + if settings_manager is None: + return [] + + descriptors: list[ModelDescriptor] = [] + + ollama_models = settings_manager.get_ollama_scanned_models() or () + for model_id in ollama_models: + model_id = normalize_model_id(model_id) + if model_id: + descriptors.append(ModelDescriptor(model_id=model_id, provider=_OLLAMA, source="installed")) + + llama_cpp_models = settings_manager.get_llama_cpp_scanned_models() or () + for model_path in llama_cpp_models: + # Scanned entries are full filesystem paths (SettingsManager's own + # scan-results shape) - reduced to a basename here to match the + # ONLY identity _provider_for_model_ref's llama.cpp branch (and + # describe_active_model's own display convention) actually accepts: + # llama.cpp has no "load any installed model by id" catalog the way + # Ollama does, so the two currently-configured paths' own basenames + # are the entire addressable set. A full-path model_id here would + # never match and every auto/fallback pick would be rejected. + model_id = normalize_model_id(Path(model_path).name if model_path else "") + if model_id: + descriptors.append(ModelDescriptor(model_id=model_id, provider=_LLAMACPP, source="installed")) + + for provider in _API_PROVIDERS: + catalog_entries = settings_manager.get_api_model_catalog(provider) or () + for entry in catalog_entries: + model_id = normalize_model_id(entry.get("model_id") if isinstance(entry, Mapping) else "") + if not model_id: + continue + capabilities = entry.get("capabilities") if isinstance(entry, Mapping) else None + descriptors.append(ModelDescriptor( + model_id=model_id, + provider=provider, + ready=bool(entry.get("ready", True)) if isinstance(entry, Mapping) else True, + available=bool(entry.get("available", True)) if isinstance(entry, Mapping) else True, + capabilities=frozenset(capabilities or ()), + source="catalog", + )) + + if price_lookup is not None: + priced: list[ModelDescriptor] = [] + for descriptor in descriptors: + prices = price_lookup(descriptor.provider, descriptor.model_id) + if prices is None: + priced.append(descriptor) + else: + cost_in, cost_out = prices + priced.append(replace(descriptor, cost_input_per_mtok=cost_in, cost_output_per_mtok=cost_out)) + descriptors = priced + + return sort_descriptors(descriptors) diff --git a/graphlink_settings_store.py b/graphlink_settings_store.py index ed72379..9e2246d 100644 --- a/graphlink_settings_store.py +++ b/graphlink_settings_store.py @@ -654,6 +654,21 @@ def set_enable_system_prompt(self, enabled: bool): self.state["enable_system_prompt"] = bool(enabled) self._save_state() + def get_auto_model_policy(self): + # ADR-018 stage 18.4. "cheapest-capable" by default - matching the + # ADR's own framing (cost-aware routing is the headline feature; + # "fastest"/"best-quality" are deliberate opt-ins). + from graphlink_model_catalog import AUTO_POLICY_CHEAPEST_CAPABLE + + return self.state.get("auto_model_policy", AUTO_POLICY_CHEAPEST_CAPABLE) + + def set_auto_model_policy(self, policy: str): + from graphlink_model_catalog import AUTO_POLICIES + + if policy in AUTO_POLICIES: + self.state["auto_model_policy"] = policy + self._save_state() + def get_log_level(self): # ADR-016 stage 16.1. INFO by default - matches # backend/crash_recovery.py's own pre-existing default so a fresh diff --git a/tests/test_node_state_migration.py b/tests/test_node_state_migration.py index 08514df..793c456 100644 --- a/tests/test_node_state_migration.py +++ b/tests/test_node_state_migration.py @@ -185,9 +185,27 @@ def _root_name(expr: ast.expr) -> str | None: # backend/api/intents_settings_api_provider.py's load_api_models, # iterating a get_available_model_descriptors() list) - neither is # ever a SceneNode. + # + # ADR-018 stage 18.1: graphlink_model_catalog.ModelDescriptor is now a + # standing type (not just an inline iteration variable), so a THIRD + # shape joins these two - backend/tests/test_model_routing.py's own + # `descriptor` loop variable over a `unified_catalog()` result, same + # non-SceneNode type as the intents_settings_api_provider.py entry + # above, just a different file/root pair. "provider": ( {"root": "s", "file": "canvas.py"}, {"root": "descriptor", "file": "intents_settings_api_provider.py"}, + {"root": "descriptor", "file": "test_model_routing.py"}, + # ADR-018 stage 18.5: _thread_on_fallback's own `fallback_ref` param + # (backend/agents.py) is a graphlink_model_catalog.ModelRef - + # api_provider's fallback-chain wrapper hands it the model it just + # substituted in, never a SceneNode. + {"root": "fallback_ref", "file": "agents.py"}, + # ADR-018 stage 18.5 review-fix regression test: `catalog[0].provider` + # is a unified_catalog() result (list[ModelDescriptor]), never a + # SceneNode collection - test_model_routing.py is entirely about + # model catalogs, so "catalog" never means anything else there. + {"root": "catalog", "file": "test_model_routing.py"}, ), } @@ -289,7 +307,8 @@ def test_scene_node_core_field_count(): "imageAssetId", "isBranchComparison", "isBranchSynthesis", "isCollapsed", "isDocked", "isFinalDeliverable", "isLocked", "isSummaryNote", "isSystemPrompt", "isUser", "itemIds", "kind", "language", "mimeType", - "model", "pendingRequestId", "previewLabel", "provider", "pycoderAnalysis", + "model", "overrideModelId", "overrideProvider", + "pendingRequestId", "previewLabel", "provider", "pycoderAnalysis", "pycoderAwaitingApproval", "pycoderCode", "pycoderError", "pycoderLastRunFailed", "pycoderMode", "pycoderOutput", "pycoderPrompt", "researchActiveSourceId", "researchCompleted", "researchError", diff --git a/tests/test_undo_classification_gate.py b/tests/test_undo_classification_gate.py index f65d515..ba5830b 100644 --- a/tests/test_undo_classification_gate.py +++ b/tests/test_undo_classification_gate.py @@ -230,16 +230,19 @@ def _collect_real_registrations() -> dict[tuple[str, str], _FileIntents]: def test_the_scan_finds_the_real_population_of_registered_intents(): # Guards the guard: a broken predicate here would make every check below - # vacuously pass. 140 is the exact count locked by ADR-010's close-out - # recon (scene=89, app-settings=29, app-composer=6, app-chat-library=5, + # vacuously pass. 143 is the exact count locked by ADR-010's close-out + # recon (scene=89, app-settings=30, app-composer=6, app-chat-library=5, # grid-control=4, notification=3, app-plugins=1, system=1, diagnostics=2) # - app-settings went 27 -> 28 when ADR-006 stage 6.5 added - # setProviderMode, 28 -> 29 when ADR-016 stage 16.1 added setLogLevel, and + # setProviderMode, 28 -> 29 when ADR-016 stage 16.1 added setLogLevel, # 138 -> 140 when ADR-016 stage 16.4 added the diagnostics topic's two - # intents (exportDiagnosticBundle, openLogFolder). + # intents (exportDiagnosticBundle, openLogFolder), 140 -> 142 when + # ADR-018 stage 18.3 added scene's own setModelOverride/ + # clearModelOverride, and 142 -> 143 when ADR-018 stage 18.4 added + # app-settings' own setAutoModelPolicy. real = _collect_real_registrations() - assert len(real) == 140, ( - f"expected exactly 140 real registered intents, found {len(real)} - " + assert len(real) == 143, ( + f"expected exactly 143 real registered intents, found {len(real)} - " "either the scan broke, or the app's registered-intent surface " "genuinely changed and tests/undo_classification.py's own count " "comment (and this assertion) need a deliberate update alongside it" diff --git a/tests/undo_classification.py b/tests/undo_classification.py index 6728799..01e715d 100644 --- a/tests/undo_classification.py +++ b/tests/undo_classification.py @@ -173,6 +173,10 @@ class Classified: Classified("scene", "fitFrameToContent", "A", "content: size is document state"), Classified("scene", "ungroup", "A", "content: delete grouping"), + # -- backend/api/intents_model_routing.py (scene) - ADR-018 stage 18.3 -- + Classified("scene", "setModelOverride", "A", "content: model pin is document state, same posture as setGroupColor"), + Classified("scene", "clearModelOverride", "A", "content: model pin is document state, same posture as setGroupColor"), + # -- backend/api/intents_pins.py (scene) --------------------------------- Classified("scene", "addPin", "A", "content: user-placed navigation waypoint"), Classified("scene", "movePin", "A", "content: user-placed navigation waypoint"), @@ -223,6 +227,8 @@ class Classified: Classified("app-settings", "setProviderMode", "B", "preference: which provider mode is live/persisted"), # ADR-016 stage 16.1: Classified("app-settings", "setLogLevel", "B", "preference: local logging verbosity"), + # ADR-018 stage 18.4: + Classified("app-settings", "setAutoModelPolicy", "B", "preference: auto-routing policy, not document content"), # -- backend/api/intents_settings_api_provider.py (app-settings) -------- Classified("app-settings", "setViewingApiProvider", "B", "preference: which provider sub-page is viewed"), diff --git a/web_ui/src/app/App.tsx b/web_ui/src/app/App.tsx index 50da606..7ed2dd6 100644 --- a/web_ui/src/app/App.tsx +++ b/web_ui/src/app/App.tsx @@ -311,6 +311,14 @@ function App() { const transport = useMemo(() => new WsTransport(defaultWsUrl()), []); const sceneStore = useMemo(() => new SceneStore(transport), [transport]); const composerStore = useMemo(() => new ComposerStore(transport), [transport]); + // ADR-018 stage 18.3: "Pin to Current Model" reads the Composer's live + // route at CLICK time (getComposer() is a stable per-instance getter, not + // a subscription) - see SceneCanvas.tsx's toFlowNodes own comment on why + // this stays a getter, never a value threaded down. + const getComposerRoute = useCallback(() => { + const { provider, modelId } = composerStore.getComposer().route; + return { provider, modelId }; + }, [composerStore]); useEffect(() => { const offStatus = transport.onStatus(setStatus); @@ -364,7 +372,11 @@ function App() { />
- +
diff --git a/web_ui/src/app/canvas/ChatNodeView.test.tsx b/web_ui/src/app/canvas/ChatNodeView.test.tsx index 5ce38eb..a5b81c6 100644 --- a/web_ui/src/app/canvas/ChatNodeView.test.tsx +++ b/web_ui/src/app/canvas/ChatNodeView.test.tsx @@ -62,6 +62,8 @@ function renderChatNode(overrides: Partial = {}, selected: const onSetFinalDeliverable = vi.fn(); const onCollapseBranch = vi.fn(); const onCancelRegenerate = vi.fn(); + const onPinToCurrentModel = vi.fn(); + const onClearModelOverride = vi.fn(); function buildProps(dataOverrides: Partial) { return { id: "n0", @@ -101,6 +103,11 @@ function renderChatNode(overrides: Partial = {}, selected: promptTokens: null, completionTokens: null, estimatedCostUsd: null, + // ADR-018 stage 18.3 + overrideProvider: "", + overrideModelId: "", + onPinToCurrentModel, + onClearModelOverride, ...dataOverrides, }, } as unknown as NodeProps; @@ -127,6 +134,7 @@ function renderChatNode(overrides: Partial = {}, selected: onGenerateChart, onGenerateKeyTakeaway, onGenerateExplainerNote, onOpenDocumentView, onScrollChange, onToggleBranchFocus, onBranchFromHere, onSetBranchStatus, onSetFinalDeliverable, onCollapseBranch, onCancelRegenerate, + onPinToCurrentModel, onClearModelOverride, container, rerenderWithData, }; } @@ -508,6 +516,59 @@ describe("ChatNodeView Synthesize Branches provenance (ADR-002 Workstream 1)", ( }); }); +// ADR-018 stage 18.3: the model-override PIN - opposite direction from the +// provenance badge above (an explicit input pin, not output provenance), +// so both must be able to render at once on the same node. +describe("ChatNodeView model-override pin (ADR-018 stage 18.3)", () => { + it("renders no pin badge or Clear Model Pin item for an ordinary node", () => { + renderChatNode({ overrideProvider: "", overrideModelId: "" }); + expect(screen.queryByText(/📌/)).toBeNull(); + fireEvent.contextMenu(screen.getByText("You")); + expect(screen.queryByRole("menuitem", { name: /Clear Model Pin/ })).toBeNull(); + expect(screen.getByRole("menuitem", { name: "Pin to Current Model" })).toBeInTheDocument(); + }); + + it("renders the pin badge with the full provider/model as its tooltip", () => { + renderChatNode({ overrideProvider: "Anthropic Claude", overrideModelId: "claude-opus-5" }); + const badge = screen.getByText("📌 claude-opus-5"); + expect(badge).toHaveAttribute("title", "Pinned to Anthropic Claude - claude-opus-5"); + }); + + it("co-renders the pin badge alongside the provenance model badge - they are distinct signals", () => { + renderChatNode({ + provider: "OpenAI-Compatible", model: "gpt-4o", + overrideProvider: "Anthropic Claude", overrideModelId: "claude-opus-5", + }); + expect(screen.getByText("gpt-4o")).toBeInTheDocument(); + expect(screen.getByText("📌 claude-opus-5")).toBeInTheDocument(); + }); + + it("Pin to Current Model calls onPinToCurrentModel and closes the menu", async () => { + const user = userEvent.setup(); + const { onPinToCurrentModel } = renderChatNode(); + + fireEvent.contextMenu(screen.getByText("You")); + await user.click(screen.getByRole("menuitem", { name: "Pin to Current Model" })); + + expect(onPinToCurrentModel).toHaveBeenCalledOnce(); + expect(screen.queryByRole("menu")).toBeNull(); + }); + + it("Clear Model Pin names the pinned model, calls onClearModelOverride, and closes the menu", async () => { + const user = userEvent.setup(); + const { onClearModelOverride } = renderChatNode({ + overrideProvider: "Anthropic Claude", overrideModelId: "claude-opus-5", + }); + + fireEvent.contextMenu(screen.getByText("You")); + const item = screen.getByRole("menuitem", { name: "Clear Model Pin (claude-opus-5)" }); + await user.click(item); + + expect(onClearModelOverride).toHaveBeenCalledOnce(); + expect(screen.queryByRole("menu")).toBeNull(); + }); +}); + // ADR-002 Workstream 1 ("Branch status and lifecycle"): the final sequenced // item after fork/compare/synthesize - status marking, Final Deliverable, // and collapsing a whole branch. @@ -1236,6 +1297,10 @@ describe("ChatNodeView React.memo comparator (ADR-011 stage 11.1)", () => { estimatedCostUsd: null, provider: null, model: null, + overrideProvider: "", + overrideModelId: "", + onPinToCurrentModel: vi.fn(), + onClearModelOverride: vi.fn(), isBranchSynthesis: false, synthesisInstructions: "", synthesisSourceNodeIds: [], diff --git a/web_ui/src/app/canvas/ChatNodeView.tsx b/web_ui/src/app/canvas/ChatNodeView.tsx index 2e80e09..7c35596 100644 --- a/web_ui/src/app/canvas/ChatNodeView.tsx +++ b/web_ui/src/app/canvas/ChatNodeView.tsx @@ -107,6 +107,14 @@ export interface ChatNodeData extends Record { isBranchSynthesis: boolean; synthesisInstructions: string; synthesisSourceNodeIds: string[]; + // ADR-018 stage 18.3: the opposite direction from provider/model above - + // an explicit PIN deciding what the NEXT reply (or, when this node is a + // branch root, the branch) resolves to, not provenance of a past one. + // "" for both means "no pin here" (the overwhelming majority of nodes). + overrideProvider: string; + overrideModelId: string; + onPinToCurrentModel: () => void; + onClearModelOverride: () => void; // ADR-002 Workstream 1 ("Branch status and lifecycle"): the final // sequenced item after fork/compare/synthesize. branchStatus is one of // exactly "active" (the default)/"accepted"/"rejected"/"superseded" - @@ -240,6 +248,9 @@ function ChatNodeMenu({ onSetBranchStatus, onSetFinalDeliverable, onCollapseBranch, + overrideModelId, + onPinToCurrentModel, + onClearModelOverride, onClose, }: { position: MenuPosition; @@ -265,6 +276,9 @@ function ChatNodeMenu({ onSetBranchStatus: (status: string) => void; onSetFinalDeliverable: (isFinal: boolean) => void; onCollapseBranch: (collapsed: boolean) => void; + overrideModelId: string; + onPinToCurrentModel: () => void; + onClearModelOverride: () => void; onClose: () => void; }) { const [chartMenuOpen, setChartMenuOpen] = useState(false); @@ -382,6 +396,36 @@ function ChatNodeMenu({ > {isFinalDeliverable ? "Unmark Final Deliverable" : "Mark as Final Deliverable"} + {/* ADR-018 stage 18.3: pins this node (and, when it is a branch root, + every descendant that doesn't pin its own - see backend/domain/ + branches.py's resolve_model_for_node) to whatever model is + CURRENTLY active in the Composer. A full "browse every installed + model" picker needs a live catalog fetch this stage doesn't wire + to the frontend yet - see doc/adr/ADR-018-model-routing.md's own + status note; "pin to current" is the buildable, honestly-scoped + slice of the feature today. */} + + {overrideModelId && ( + + )} {/* "Collapse Branch"/"Expand Branch" flips off THIS node's own isCollapsed (same value/direction the plain single-node "Expand"/"Collapse" item above already reads) but applies @@ -667,6 +711,8 @@ export function chatNodePropsAreEqual(prev: NodeProps, next: NodeP a.isFinalDeliverable === b.isFinalDeliverable && a.provider === b.provider && a.model === b.model && + a.overrideProvider === b.overrideProvider && + a.overrideModelId === b.overrideModelId && a.isBranchSynthesis === b.isBranchSynthesis && a.synthesisInstructions === b.synthesisInstructions && a.pendingRequestId === b.pendingRequestId && @@ -689,6 +735,8 @@ export function chatNodePropsAreEqual(prev: NodeProps, next: NodeP a.onSetBranchStatus === b.onSetBranchStatus && a.onSetFinalDeliverable === b.onSetFinalDeliverable && a.onCollapseBranch === b.onCollapseBranch && + a.onPinToCurrentModel === b.onPinToCurrentModel && + a.onClearModelOverride === b.onClearModelOverride && a.onCancelRegenerate === b.onCancelRegenerate && a.subscribeStream === b.subscribeStream && dockedChildrenEqual(a.dockedChildren, b.dockedChildren) && @@ -939,6 +987,19 @@ export const ChatNodeView = memo(function ChatNodeView({ {data.model} )} + {data.overrideModelId && ( + // ADR-018 stage 18.3: a distinct badge from chat-node-model- + // badge above - that one is OUTPUT provenance (what a completed + // reply was generated by), this is an INPUT pin (what the NEXT + // reply will be generated by); both can legitimately show at + // once on a node whose last reply predates its current pin. + + 📌 {data.overrideModelId} + + )} {(data.promptTokens != null || data.completionTokens != null) && ( // ADR-016 stage 16.2: real usage on the node itself - data // exists on the wire since ADR-006 stage 6.8 but was never @@ -1156,6 +1217,9 @@ export const ChatNodeView = memo(function ChatNodeView({ onSetBranchStatus={data.onSetBranchStatus} onSetFinalDeliverable={data.onSetFinalDeliverable} onCollapseBranch={data.onCollapseBranch} + overrideModelId={data.overrideModelId} + onPinToCurrentModel={data.onPinToCurrentModel} + onClearModelOverride={data.onClearModelOverride} onClose={() => setMenuPosition(null)} /> )} diff --git a/web_ui/src/app/canvas/SceneCanvas.pinSearchJump.test.tsx b/web_ui/src/app/canvas/SceneCanvas.pinSearchJump.test.tsx index f9001ca..abb7b99 100644 --- a/web_ui/src/app/canvas/SceneCanvas.pinSearchJump.test.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.pinSearchJump.test.tsx @@ -123,6 +123,9 @@ function chatRow(id: string, x: number, y: number, title = id): SceneNodeRow { chartAspectLocked: true, chartSourceNodeId: "", htmlSplitterState: null, chatScrollValue: 0.0, toolCalls: [], + // ADR-018 stage 18.3 + overrideProvider: "", + overrideModelId: "", }), ) as unknown as SceneNodeRow), }; diff --git a/web_ui/src/app/canvas/SceneCanvas.test.tsx b/web_ui/src/app/canvas/SceneCanvas.test.tsx index 95e0dbc..660a34e 100644 --- a/web_ui/src/app/canvas/SceneCanvas.test.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.test.tsx @@ -8,6 +8,7 @@ import { computeNonAcceptedNodeIds, computeSmartGuideFrame, conversationHistoryToDocumentMarkdown, + createToFlowNodesCache, flowNodeOwnSize, groupDragKindOf, handleSelectionChange, @@ -133,6 +134,9 @@ function baseNode(overrides: Partial = {}): SceneNodeRow { chatScrollValue: 0.0, // ADR-007 stage 7.4 toolCalls: [], + // ADR-018 stage 18.3 + overrideProvider: "", + overrideModelId: "", ...overrides, }; } @@ -321,6 +325,62 @@ describe("toFlowNodes (R8a Open Document View wiring)", () => { expect(setReplyTargetSpy).toHaveBeenCalledWith("chat-1"); }); + it( + "a chat node's onPinToCurrentModel reads getComposerRoute at click time and calls " + + "store.setModelOverride (ADR-018 stage 18.3)", + () => { + const scene = baseScene({ + nodes: [baseNode({ id: "chat-1", kind: "chat", content: "Hello world" })], + edges: [], + }); + const store = makeStore(); + const setModelOverrideSpy = vi.spyOn(store, "setModelOverride"); + const getComposerRoute = vi.fn(() => ({ provider: "Anthropic Claude", modelId: "claude-opus-5" })); + + const flowNodes = toFlowNodes( + scene, store, () => {}, null, () => {}, false, createToFlowNodesCache(), getComposerRoute, + ); + const chatFlowNode = flowNodes.find((n) => n.id === "chat-1"); + + (chatFlowNode!.data as { onPinToCurrentModel: () => void }).onPinToCurrentModel(); + expect(getComposerRoute).toHaveBeenCalledOnce(); + expect(setModelOverrideSpy).toHaveBeenCalledWith("chat-1", "Anthropic Claude", "claude-opus-5"); + }, + ); + + it("onPinToCurrentModel is a genuine no-op when the composer route has no provider/model resolved yet", () => { + const scene = baseScene({ + nodes: [baseNode({ id: "chat-1", kind: "chat", content: "Hello world" })], + edges: [], + }); + const store = makeStore(); + const setModelOverrideSpy = vi.spyOn(store, "setModelOverride"); + const getComposerRoute = vi.fn(() => ({ provider: "", modelId: "" })); + + const flowNodes = toFlowNodes( + scene, store, () => {}, null, () => {}, false, createToFlowNodesCache(), getComposerRoute, + ); + const chatFlowNode = flowNodes.find((n) => n.id === "chat-1"); + + (chatFlowNode!.data as { onPinToCurrentModel: () => void }).onPinToCurrentModel(); + expect(setModelOverrideSpy).not.toHaveBeenCalled(); + }); + + it("a chat node's onClearModelOverride calls store.clearModelOverride with its own id (ADR-018 stage 18.3)", () => { + const scene = baseScene({ + nodes: [baseNode({ id: "chat-1", kind: "chat", content: "Hello world" })], + edges: [], + }); + const store = makeStore(); + const clearModelOverrideSpy = vi.spyOn(store, "clearModelOverride"); + + const flowNodes = toFlowNodes(scene, store); + const chatFlowNode = flowNodes.find((n) => n.id === "chat-1"); + + (chatFlowNode!.data as { onClearModelOverride: () => void }).onClearModelOverride(); + expect(clearModelOverrideSpy).toHaveBeenCalledWith("chat-1"); + }); + it("a chat node's onOpenDocumentView labels the source as the user's own message when isUser is true", () => { const scene = baseScene({ nodes: [baseNode({ id: "chat-1", kind: "chat", content: "Hello world", isUser: true })], diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx index 35b9991..7e261bd 100644 --- a/web_ui/src/app/canvas/SceneCanvas.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -454,6 +454,10 @@ interface DispatcherLive { store: SceneStore; onOpenDocumentView: (markdown: string, sourceLabel: string) => void; onToggleBranchFocus: (nodeId: string) => void; + // ADR-018 stage 18.3: optional - only the chat dispatcher's own call + // site below supplies it; every other kind's getDispatcher call omits + // it exactly like they already omit `extra` above. + getComposerRoute?: () => { provider: string; modelId: string }; // Kind-specific derived value(s) a dispatcher needs beyond `n` itself, // NOT themselves part of `n` (e.g. the code branch's parentChatNodeId, // resolved from edges rather than n's own fields) - cast to the concrete @@ -577,6 +581,18 @@ function makeChatFns(id: string, liveRef: { current: DispatcherLive }) { const { n, store } = liveRef.current; if (n.pendingRequestId) store.cancelConversationRequest(n.pendingRequestId); }, + // ADR-018 stage 18.3: pins this node to whichever model is CURRENTLY + // active in the Composer (getComposerRoute reads composerStore. + // getComposer().route at CLICK time - see toFlowNodes' own comment on + // why that's a plain getter, not a subscribed value). A blank + // provider/modelId (no route resolved yet, e.g. no provider + // configured) is a genuine no-op rather than pinning to nothing. + onPinToCurrentModel: () => { + const { store, getComposerRoute } = liveRef.current; + const route = getComposerRoute?.() ?? { provider: "", modelId: "" }; + if (route.provider && route.modelId) store.setModelOverride(id, route.provider, route.modelId); + }, + onClearModelOverride: () => liveRef.current.store.clearModelOverride(id), }; } @@ -841,6 +857,15 @@ export function toFlowNodes( // so every node "misses" and gets built the same way it always did), just // not memoized across separate calls, which none of those tests need. cache: ToFlowNodesCache = createToFlowNodesCache(), + // ADR-018 stage 18.3: read at CLICK time inside makeChatFns' own + // onPinToCurrentModel (never subscribed/rendered), so a plain getter - + // not a value - keeps this out of every cache key/memo comparator above, + // matching onOpenDocumentView/onToggleBranchFocus's own "callback, not + // data" posture. Default returns "" so every existing caller (every + // direct unit test in SceneCanvas.test.tsx) that omits it keeps working: + // the resulting Pin action would just pin to an empty ref, exactly as + // unreachable in a real session as this default itself. + getComposerRoute: () => { provider: string; modelId: string } = () => ({ provider: "", modelId: "" }), ): SceneFlowNode[] { // ADR-011 stage 11.1: ONE upfront O(N+E) pass replacing this function's // old standalone `nodesById` map build, PLUS the O(N*E) per-node edge @@ -900,7 +925,9 @@ export function toFlowNodes( const dockedChildrenSig = dockedChildren.map((c) => `${c.id}:${c.label}`).join("|"); const extraSig = `${dimmedVal ? 1 : 0}${isBranchFocusActive ? 1 : 0}|${dockedChildrenSig}`; const cached = cache.flowNodes.get(n); - const fns = getDispatcher(cache, n.id, { n, store, onOpenDocumentView, onToggleBranchFocus }, makeChatFns); + const fns = getDispatcher( + cache, n.id, { n, store, onOpenDocumentView, onToggleBranchFocus, getComposerRoute }, makeChatFns, + ); if (cached && cached.extraSig === extraSig) { flowNodes.push(cached.flowNode); continue; @@ -936,6 +963,10 @@ export function toFlowNodes( // all - see ChatNodeView.tsx's own guard. provider: n.provider ?? null, model: n.model ?? null, + // ADR-018 stage 18.3: the model-override PIN, opposite direction + // from provider/model above - see ChatNodeData's own comment. + overrideProvider: n.overrideProvider, + overrideModelId: n.overrideModelId, isBranchSynthesis: n.isBranchSynthesis, synthesisInstructions: n.synthesisInstructions, synthesisSourceNodeIds: n.itemIds, @@ -1926,9 +1957,13 @@ function useCssVar(name: string, fallback: string): string { function CanvasInner({ store, onOpenDocumentView, + getComposerRoute, }: { store: SceneStore; onOpenDocumentView: (markdown: string, sourceLabel: string) => void; + // ADR-018 stage 18.3: passed straight through to toFlowNodes - see that + // function's own comment on why a getter, not a subscribed value. + getComposerRoute: () => { provider: string; modelId: string }; }) { const scene = useSyncExternalStore(store.subscribe, store.getScene); const grid = useSyncExternalStore(store.subscribe, store.getGrid); @@ -2105,11 +2140,15 @@ function CanvasInner({ // always populated by the time this closure executes - TS just // can't prove that across the mutable ref indirection. toFlowNodesCacheRef.current!, + getComposerRoute, ), current, ), ); - }, [scene, store, onOpenDocumentView, effectiveBranchFocusOriginId, onToggleBranchFocus, focusAcceptedPaths]); + }, [ + scene, store, onOpenDocumentView, effectiveBranchFocusOriginId, onToggleBranchFocus, focusAcceptedPaths, + getComposerRoute, + ]); // ADR-011 stage 11.3 (P4): toFlowEdges rebuilds the WHOLE edges array (an // O(E) map over every edge) - hoveredEdgeId is only EVER read inside that @@ -2473,9 +2512,15 @@ function CanvasInner({ export function SceneCanvas({ store, onOpenDocumentView, + // ADR-018 stage 18.3: optional (default no-op) so every existing direct + // render of - App.tsx aside - keeps working unchanged; only + // App.tsx's real render supplies the composer's live route. See + // toFlowNodes' own comment for why this is a getter, not a value. + getComposerRoute = () => ({ provider: "", modelId: "" }), }: { store: SceneStore; onOpenDocumentView: (markdown: string, sourceLabel: string) => void; + getComposerRoute?: () => { provider: string; modelId: string }; }) { // ADR-003 stage 3.5: renders BridgeErrorState INSTEAD of the canvas - not // alongside it - the moment the scene topic's schema version is rejected. @@ -2498,5 +2543,5 @@ export function SceneCanvas({ ); } - return ; + return ; } diff --git a/web_ui/src/app/canvas/SceneCanvas.virtualization.test.tsx b/web_ui/src/app/canvas/SceneCanvas.virtualization.test.tsx index fb410a9..8401811 100644 --- a/web_ui/src/app/canvas/SceneCanvas.virtualization.test.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.virtualization.test.tsx @@ -131,6 +131,9 @@ function chatRow(id: string, x: number, y = 0): SceneNodeRow { chartAspectLocked: true, chartSourceNodeId: "", htmlSplitterState: null, chatScrollValue: 0.0, toolCalls: [], + // ADR-018 stage 18.3 + overrideProvider: "", + overrideModelId: "", }), ) as unknown as SceneNodeRow), }; diff --git a/web_ui/src/app/canvas/renderCountGate.test.tsx b/web_ui/src/app/canvas/renderCountGate.test.tsx index d543318..dcaf015 100644 --- a/web_ui/src/app/canvas/renderCountGate.test.tsx +++ b/web_ui/src/app/canvas/renderCountGate.test.tsx @@ -144,6 +144,8 @@ function chatRow(id: string, x: number): SceneNodeRow { chartAspectLocked: true, chartSourceNodeId: "", htmlSplitterState: null, chatScrollValue: 0.0, toolCalls: [], // ADR-007 stage 7.4 + overrideProvider: "", // ADR-018 stage 18.3 + overrideModelId: "", }), ) as unknown as SceneNodeRow), }; diff --git a/web_ui/src/app/canvas/sceneStore.test.ts b/web_ui/src/app/canvas/sceneStore.test.ts index f93ad3a..01110f2 100644 --- a/web_ui/src/app/canvas/sceneStore.test.ts +++ b/web_ui/src/app/canvas/sceneStore.test.ts @@ -181,6 +181,9 @@ function validScenePayload(overrides: Record = {}) { chatScrollValue: 0.0, // ADR-007 stage 7.4 toolCalls: [], + // ADR-018 stage 18.3 + overrideProvider: "", + overrideModelId: "", }, ], edges: [], @@ -1214,6 +1217,22 @@ describe("SceneStore", () => { ]); }); + it("setModelOverride sends the scene-topic setModelOverride intent with [nodeId, provider, modelId]", () => { + const { transport, intents } = makeFakeTransport(); + const store = new SceneStore(transport); + store.setModelOverride("n1", "Anthropic Claude", "claude-opus-5"); + expect(intents).toEqual([ + { topic: "scene", intent: "setModelOverride", args: ["n1", "Anthropic Claude", "claude-opus-5"] }, + ]); + }); + + it("clearModelOverride sends the scene-topic clearModelOverride intent with [nodeId]", () => { + const { transport, intents } = makeFakeTransport(); + const store = new SceneStore(transport); + store.clearModelOverride("n1"); + expect(intents).toEqual([{ topic: "scene", intent: "clearModelOverride", args: ["n1"] }]); + }); + it("toggleFrameLock sends the scene-topic toggleFrameLock intent with [nodeId]", () => { const { transport, intents } = makeFakeTransport(); const store = new SceneStore(transport); diff --git a/web_ui/src/app/canvas/sceneStore.ts b/web_ui/src/app/canvas/sceneStore.ts index def639c..1e8ea0f 100644 --- a/web_ui/src/app/canvas/sceneStore.ts +++ b/web_ui/src/app/canvas/sceneStore.ts @@ -1198,6 +1198,17 @@ export class SceneStore { this.transport.fireIntent("scene", "ungroup", [nodeId]); } + // ADR-018 stage 18.3: the model-override pin. Idempotent (setting/ + // clearing the same value twice is safe), same queueable posture as + // setGroupColor above. + setModelOverride(nodeId: string, provider: string, modelId: string): void { + this.transport.fireIntent("scene", "setModelOverride", [nodeId, provider, modelId], undefined, true); + } + + clearModelOverride(nodeId: string): void { + this.transport.fireIntent("scene", "clearModelOverride", [nodeId], undefined, true); + } + // -- R6.2: Chart node ----------------------------------------------------- // // Mirrors backend/canvas.py's register_canvas() intent names/argument order diff --git a/web_ui/src/app/chrome/SettingsDialog.test.tsx b/web_ui/src/app/chrome/SettingsDialog.test.tsx index 94c2564..27ffb4f 100644 --- a/web_ui/src/app/chrome/SettingsDialog.test.tsx +++ b/web_ui/src/app/chrome/SettingsDialog.test.tsx @@ -23,6 +23,7 @@ const snapshot = { githubTokenConfigured: false, secretsEncryptedAtRest: true, logLevel: "INFO", + autoModelPolicy: "cheapest-capable", activeApiProvider: "OpenAI-Compatible", viewingApiProvider: "OpenAI-Compatible", apiBaseUrl: "https://api.openai.com/v1", @@ -163,6 +164,24 @@ describe("SettingsDialog", () => { expect(intents).toContainEqual(["app-settings", "setActiveSection", ["integrations"]]); }); + it("General page renders the auto model policy select at its current value", async () => { + const { user } = setup(); + await user.click(screen.getByText("open settings")); + + expect(screen.getByRole("button", { name: "Automatic Model Selection Policy" })).toHaveTextContent( + "Cheapest Capable", + ); + }); + + it("choosing an auto model policy option fires setAutoModelPolicy with the option's id", async () => { + const { user, intents } = setup(); + await user.click(screen.getByText("open settings")); + + await chooseCustomOption(user, "Automatic Model Selection Policy", "Best Quality"); + + expect(intents).toContainEqual(["app-settings", "setAutoModelPolicy", ["best-quality"]]); + }); + it("API Endpoint page renders for the real (not deferred-placeholder) section", async () => { const { user, push } = setup(); await goToApiEndpoint(user, push); diff --git a/web_ui/src/app/chrome/SettingsDialog.tsx b/web_ui/src/app/chrome/SettingsDialog.tsx index 07283c0..887c82c 100644 --- a/web_ui/src/app/chrome/SettingsDialog.tsx +++ b/web_ui/src/app/chrome/SettingsDialog.tsx @@ -42,6 +42,15 @@ const REASONING_LEVEL_OPTIONS = [ { id: "high", label: "High" }, ] as const; +// ADR-018 stage 18.4: mirrors graphlink_model_catalog.AUTO_POLICIES - the +// policy the auto rung of the resolution chain applies when neither an +// explicit task assignment nor a node/branch pin resolves a model. +const AUTO_MODEL_POLICY_OPTIONS = [ + { id: "cheapest-capable", label: "Cheapest Capable" }, + { id: "fastest", label: "Fastest" }, + { id: "best-quality", label: "Best Quality" }, +] as const; + // Llama.cpp has no per-task assignment concept like Ollama's OLLAMA_TASKS - // just one global chat model path plus an optional title/naming override // (api_provider.py's _get_llama_cpp_model_path: chart/web-validate/web- @@ -102,6 +111,9 @@ const initialState: AppSettingsState = { secretsEncryptedAtRest: true, // ADR-016 stage 16.1: mirrors SettingsManager.get_log_level()'s own default. logLevel: "INFO", + // ADR-018 stage 18.4: mirrors SettingsManager.get_auto_model_policy()'s + // own default. + autoModelPolicy: "cheapest-capable", activeApiProvider: API_PROVIDER_OPENAI, viewingApiProvider: API_PROVIDER_OPENAI, apiBaseUrl: DEFAULT_OPENAI_BASE_URL, @@ -176,6 +188,20 @@ function GeneralPage({ Enable Assistant System Prompt +
+ Automatic Model Selection Policy + ({ id: option.id, label: option.label }))} + onChange={(id) => transport.fireIntent("app-settings", "setAutoModelPolicy", [id], undefined, true)} + /> +

+ Used when a task has no explicitly assigned model and no node/branch pin - picks the cheapest, fastest, + or highest-quality capable model from your configured providers. +

+
+
Notification types {Object.keys(NOTIFICATION_TYPE_LABELS).map((type) => ( diff --git a/web_ui/src/app/styles.css b/web_ui/src/app/styles.css index cda7b18..bc031d7 100644 --- a/web_ui/src/app/styles.css +++ b/web_ui/src/app/styles.css @@ -1101,6 +1101,24 @@ body, max-width: 140px; } +/* ADR-018 stage 18.3: the model-override PIN - same plain-text scale as + .chat-node-model-badge above, but tinted (not muted) since it reflects a + deliberate user choice governing the NEXT reply, not passive provenance + of a past one - the two can legitimately co-occur (see ChatNodeView.tsx's + own comment), so they need to read as visually distinct at a glance. */ +.chat-node-model-override-badge { + font-size: 10px; + line-height: 1; + /* --gl-palette-selection, same real (non-hardcoded-fallback) token + .about-links a already uses for "the one non-grey accent" - see that + rule's own comment on why --gl-accent itself is never real here. */ + color: var(--gl-palette-selection, var(--gl-surface-text-primary)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 140px; +} + /* ADR-016 stage 16.2: real per-node token/cost usage, same plain-text scale as .chat-node-model-badge next to it - a compact "prompt→completion · $cost" label, not a pill (numbers vary too widely in width). */ diff --git a/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json b/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json index 31bb018..ee77cd0 100644 --- a/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json +++ b/web_ui/src/lib/bridge-core/generated/app-settings-state.schema.json @@ -69,6 +69,9 @@ }, "type": "object" }, + "autoModelPolicy": { + "type": "string" + }, "enableSystemPrompt": { "type": "boolean" }, @@ -191,6 +194,7 @@ "githubTokenConfigured", "secretsEncryptedAtRest", "logLevel", + "autoModelPolicy", "activeApiProvider", "viewingApiProvider", "apiBaseUrl", diff --git a/web_ui/src/lib/bridge-core/generated/app-settings-state.ts b/web_ui/src/lib/bridge-core/generated/app-settings-state.ts index 7d997fa..db54939 100644 --- a/web_ui/src/lib/bridge-core/generated/app-settings-state.ts +++ b/web_ui/src/lib/bridge-core/generated/app-settings-state.ts @@ -20,6 +20,7 @@ export interface AppSettingsState { githubTokenConfigured: boolean; secretsEncryptedAtRest: boolean; logLevel: string; + autoModelPolicy: string; activeApiProvider: string; viewingApiProvider: string; apiBaseUrl: string; @@ -146,6 +147,11 @@ function checkAppSettingsState(value: unknown, path: string, errors: string[]): if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.logLevel: missing required field`); else { if (typeof fieldValue !== "string") errors.push(`${path}.logLevel` + ": expected string"); } } + { + const fieldValue = value["autoModelPolicy"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.autoModelPolicy: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.autoModelPolicy` + ": expected string"); } + } { const fieldValue = value["activeApiProvider"]; if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.activeApiProvider: missing required field`); diff --git a/web_ui/src/lib/bridge-core/generated/scene-state.schema.json b/web_ui/src/lib/bridge-core/generated/scene-state.schema.json index 1dbde1e..4963d4c 100644 --- a/web_ui/src/lib/bridge-core/generated/scene-state.schema.json +++ b/web_ui/src/lib/bridge-core/generated/scene-state.schema.json @@ -380,6 +380,12 @@ "model": { "type": "string" }, + "overrideModelId": { + "type": "string" + }, + "overrideProvider": { + "type": "string" + }, "pendingRequestId": { "type": "string" }, @@ -684,7 +690,9 @@ "chartAspectLocked", "chartSourceNodeId", "chatScrollValue", - "toolCalls" + "toolCalls", + "overrideProvider", + "overrideModelId" ], "type": "object" }, diff --git a/web_ui/src/lib/bridge-core/generated/scene-state.ts b/web_ui/src/lib/bridge-core/generated/scene-state.ts index a98c5cf..9af0efc 100644 --- a/web_ui/src/lib/bridge-core/generated/scene-state.ts +++ b/web_ui/src/lib/bridge-core/generated/scene-state.ts @@ -95,6 +95,8 @@ export interface SceneNodeRow { htmlSplitterState?: number | null; chatScrollValue: number; toolCalls: ToolInvocationRow[]; + overrideProvider: string; + overrideModelId: string; } export interface ConversationMessageRow { @@ -673,6 +675,16 @@ function checkSceneNodeRow(value: unknown, path: string, errors: string[]): void else { if (!Array.isArray(fieldValue)) errors.push(`${path}.toolCalls` + ": expected array"); else (fieldValue as unknown[]).forEach((item, i) => { checkToolInvocationRow(item, `${path}.toolCalls` + `[${i}]`, errors); }); } } + { + const fieldValue = value["overrideProvider"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.overrideProvider: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.overrideProvider` + ": expected string"); } + } + { + const fieldValue = value["overrideModelId"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.overrideModelId: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.overrideModelId` + ": expected string"); } + } } function checkConversationMessageRow(value: unknown, path: string, errors: string[]): void {