loki-mode 7.29.0 → 7.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.29.0'
60
+ __version__ = '7.31.0'
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ mcp/_sdk_loader.py -- shared pip-MCP-SDK FastMCP loader.
4
+
5
+ Extracted verbatim from mcp/server.py (task 566) so BOTH the main server
6
+ (mcp/server.py) and the LSP proxy (mcp/lsp_proxy.py) load the genuine pip
7
+ MCP SDK's FastMCP through one battle-tested code path. Importing this module
8
+ has NO side effects (no FastMCP instantiation, no sys.exit, no tool
9
+ registration), so consumers can pull the helpers in without booting a server.
10
+
11
+ ============================================================
12
+ Loading the pip MCP SDK's FastMCP under a NAMESPACE COLLISION
13
+ ============================================================
14
+
15
+ Root cause (task 562, re-applied to the proxy in task 566): this repo ships
16
+ a local package named `mcp/` (this module is mcp/_sdk_loader.py). That local
17
+ package SHADOWS the pip-installed MCP SDK, which is also named `mcp`. The two
18
+ cannot both own the top-level `mcp` name in one interpreter.
19
+
20
+ The pre-task code tried to sidestep this by loading mcp/server/fastmcp.py
21
+ directly from site-packages via importlib. That worked for the SDK's old
22
+ single-FILE layout, but MCP SDK 1.x ships FastMCP as a PACKAGE DIRECTORY
23
+ (mcp/server/fastmcp/__init__.py) whose own code does absolute imports like
24
+ `from mcp.types import Icon` and `from mcp.server.lowlevel import Server`.
25
+ Under shadowing those resolve to the LOCAL package and raise
26
+ `ModuleNotFoundError: No module named 'mcp.types'`, so FastMCP never loads.
27
+ mcp/server.py used to sys.exit(1) on that; mcp/lsp_proxy.py used to silently
28
+ degrade to a no-op shim, so its LSP tools never loaded for consumers.
29
+
30
+ So the real fix is NOT file-vs-directory detection: it is resolving the
31
+ namespace collision so the genuine SDK can import its own `mcp.*` subtree.
32
+ We do this by temporarily letting the REAL SDK own the `mcp` name:
33
+ 1. snapshot + evict the local `mcp` / `mcp.*` modules from sys.modules,
34
+ 2. drop the repo root (and "" / ".") from sys.path so the next import of
35
+ `mcp` resolves to site-packages, not the local package,
36
+ 3. import the real `mcp.server.fastmcp` (and eagerly `mcp.types`), which
37
+ transitively caches the real `mcp.*` subtree in sys.modules,
38
+ 4. restore the LOCAL `mcp` and `mcp.server` entries so the rest of this
39
+ codebase keeps using the local package for its own relative imports,
40
+ while the real SDK submodules (mcp.types, mcp.shared.*,
41
+ mcp.server.fastmcp.*, mcp.server.lowlevel.*) stay cached for the SDK's
42
+ runtime use. FastMCP holds direct references to its dependencies once
43
+ imported, so it does not re-resolve `mcp.server` by name at runtime.
44
+
45
+ This is the least-invasive fix (it does not rename the local package, which
46
+ mcp/__init__.py documents as intended behavior). It is inherently a bit
47
+ fragile because it juggles sys.modules; the regression test for this code
48
+ path is an END-TO-END one (start the server, complete an MCP stdio
49
+ handshake, list tools), not a file-exists check, because only a real
50
+ handshake proves FastMCP actually loaded and the subtree resolved.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import importlib
56
+ import logging
57
+ import os
58
+ import site
59
+ import sys
60
+
61
+ logger = logging.getLogger('loki-mcp-sdk-loader')
62
+
63
+
64
+ def _real_mcp_search_dirs():
65
+ """Ordered site directories to search for the pip MCP SDK."""
66
+ dirs = []
67
+ try:
68
+ dirs.extend(site.getsitepackages())
69
+ except AttributeError:
70
+ pass
71
+ try:
72
+ dirs.append(site.getusersitepackages())
73
+ except AttributeError:
74
+ pass
75
+ return dirs
76
+
77
+
78
+ def _mcp_sdk_present(search_dirs=None):
79
+ """True if the pip MCP SDK appears installed in any site dir, accepting
80
+ both the legacy single-file layout and the 1.x package-directory layout.
81
+
82
+ Pure filesystem probe (no import side effects), kept standalone so the
83
+ both-layouts behaviour can be unit-tested against mktemp fixture dirs.
84
+ """
85
+ if search_dirs is None:
86
+ search_dirs = _real_mcp_search_dirs()
87
+ for _site_dir in search_dirs:
88
+ if not _site_dir:
89
+ continue
90
+ _file_layout = os.path.join(_site_dir, "mcp", "server", "fastmcp.py")
91
+ _pkg_layout = os.path.join(
92
+ _site_dir, "mcp", "server", "fastmcp", "__init__.py"
93
+ )
94
+ if os.path.isfile(_file_layout) or os.path.isfile(_pkg_layout):
95
+ return True
96
+ return False
97
+
98
+
99
+ def _load_real_fastmcp():
100
+ """Import the genuine pip MCP SDK's FastMCP class, resolving the local-vs-
101
+ SDK `mcp` namespace collision. Returns the FastMCP class, or None if the
102
+ SDK cannot be loaded. Restores the local `mcp`/`mcp.server` modules before
103
+ returning so the rest of this module keeps working unchanged.
104
+ """
105
+ if not _mcp_sdk_present():
106
+ return None
107
+
108
+ # 1. Snapshot every currently-loaded local `mcp`/`mcp.*` module so we can
109
+ # restore the ones this codebase depends on afterwards.
110
+ _saved_local = {
111
+ _k: _v for _k, _v in list(sys.modules.items())
112
+ if _k == "mcp" or _k.startswith("mcp.")
113
+ }
114
+ # 2. Evict them so the real SDK can claim the `mcp` name on import.
115
+ for _k in list(_saved_local):
116
+ del sys.modules[_k]
117
+
118
+ # 3. Drop repo-root / cwd entries from sys.path for the SDK import so
119
+ # `mcp` resolves to site-packages rather than the local package.
120
+ _repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
121
+ _saved_path = sys.path[:]
122
+ sys.path[:] = [
123
+ _p for _p in sys.path
124
+ if _p not in ("", ".")
125
+ and os.path.abspath(_p) != os.path.abspath(_repo_root)
126
+ ]
127
+
128
+ _fastmcp_cls = None
129
+ try:
130
+ _real_fastmcp = importlib.import_module("mcp.server.fastmcp")
131
+ # Eagerly import the subtree FastMCP touches at runtime so the real
132
+ # modules are cached before we restore the local `mcp` over the name.
133
+ importlib.import_module("mcp.types")
134
+ importlib.import_module("mcp.server.lowlevel")
135
+ _fastmcp_cls = getattr(_real_fastmcp, "FastMCP", None)
136
+ except Exception as _exc: # pragma: no cover - defensive
137
+ logger.error("Failed to import the pip MCP SDK FastMCP: %s", _exc)
138
+ _fastmcp_cls = None
139
+ finally:
140
+ # 4. Restore sys.path and re-pin the LOCAL `mcp` + `mcp.server` modules
141
+ # so this codebase's own relative/absolute imports keep resolving
142
+ # locally. We intentionally leave the real `mcp.types`,
143
+ # `mcp.shared.*`, `mcp.server.fastmcp.*`, and `mcp.server.lowlevel.*`
144
+ # cached for the SDK's runtime use; the local package never defined
145
+ # those submodules, so there is nothing to clobber.
146
+ sys.path[:] = _saved_path
147
+ for _k in ("mcp", "mcp.server"):
148
+ if _k in _saved_local:
149
+ sys.modules[_k] = _saved_local[_k]
150
+ for _k, _v in _saved_local.items():
151
+ # Restore any other purely-local submodules that the SDK import did
152
+ # not legitimately replace (e.g. mcp.magic_tools, mcp.tools).
153
+ _real = sys.modules.get(_k)
154
+ if _real is None:
155
+ sys.modules[_k] = _v
156
+
157
+ return _fastmcp_cls
package/mcp/lsp_proxy.py CHANGED
@@ -26,10 +26,14 @@ Supported languages (suffix -> binary):
26
26
  .go -> gopls
27
27
  .rs -> rust-analyzer
28
28
 
29
- Tools:
29
+ Tools (7):
30
30
  lsp_find_references(file, line, character, include_declaration=False)
31
31
  lsp_go_to_definition(file, line, character)
32
32
  lsp_symbol_at_position(file, line, character)
33
+ lsp_check_exists(symbol, kind=None, ...)
34
+ lsp_get_diagnostics(file)
35
+ lsp_workspace_symbols(query, limit=20, ...)
36
+ lsp_find_definition_by_name(symbol, ...)
33
37
 
34
38
  Usage:
35
39
  python3 -m mcp.lsp_proxy # stdio mode (default)
@@ -39,14 +43,12 @@ Usage:
39
43
  from __future__ import annotations
40
44
 
41
45
  import atexit
42
- import importlib.util
43
46
  import json
44
47
  import logging
45
48
  import os
46
49
  import queue
47
50
  import shutil
48
51
  import signal
49
- import site
50
52
  import subprocess
51
53
  import sys
52
54
  import threading
@@ -660,13 +662,21 @@ def _dispatch_lsp(file: str, line: int, character: int,
660
662
  # ============================================================
661
663
  # FASTMCP LOADING
662
664
  # ============================================================
663
- # Same trick as mcp/server.py: the local `mcp/` package shadows the pip
664
- # `mcp` SDK, so we have to load FastMCP from site-packages via
665
- # importlib.util. Unlike server.py we do NOT sys.exit() when the SDK
666
- # isn't installed -- we install a no-op shim so the module imports
667
- # cleanly under test (and so production-without-MCP-SDK fails on
668
- # `.run()` rather than at import time, matching the silent-skip
669
- # philosophy).
665
+ # Task 566: the local `mcp/` package shadows the pip `mcp` SDK, which under
666
+ # MCP SDK 1.x (FastMCP shipped as a package DIRECTORY) makes a naive
667
+ # importlib.util load of mcp/server/fastmcp/__init__.py fail with
668
+ # `ModuleNotFoundError: No module named 'mcp.types'`. The previous loader
669
+ # here did exactly that and then SILENTLY degraded to a no-op shim, so the
670
+ # LSP tools never loaded for real consumers even with the SDK installed.
671
+ #
672
+ # The fix: reuse the SAME namespace-collision-resolving loader that
673
+ # mcp/server.py uses (now shared in mcp/_sdk_loader.py). Unlike server.py we
674
+ # do NOT sys.exit() when the SDK is genuinely absent -- we degrade to a no-op
675
+ # shim so the module imports cleanly under test, and production-without-SDK
676
+ # fails on `.run()` rather than at import time (silent-skip philosophy). But
677
+ # the degrade is now LOUD: one warning line naming the cause, never silent.
678
+ from mcp._sdk_loader import _load_real_fastmcp # noqa: E402
679
+
670
680
 
671
681
  class _NoopFastMCP:
672
682
  """Fallback used when the pip `mcp` SDK is not installed. Tool
@@ -688,51 +698,21 @@ class _NoopFastMCP:
688
698
 
689
699
 
690
700
  def _load_fastmcp():
691
- """Walk site-packages for the pip `mcp` SDK's FastMCP class.
692
- Returns the FastMCP class on success, or `_NoopFastMCP` on failure
693
- (so import never raises)."""
694
- search_paths: List[str] = []
695
- try:
696
- search_paths.extend(site.getsitepackages())
697
- except AttributeError:
698
- pass
699
- try:
700
- search_paths.append(site.getusersitepackages())
701
- except AttributeError:
702
- pass
703
- for site_dir in search_paths:
704
- # First shape: fastmcp.py module file.
705
- candidate_file = os.path.join(site_dir, 'mcp', 'server', 'fastmcp.py')
706
- if os.path.isfile(candidate_file):
707
- spec = importlib.util.spec_from_file_location(
708
- 'mcp_pip_sdk_lsp.server.fastmcp', candidate_file,
709
- submodule_search_locations=[],
710
- )
711
- if spec and spec.loader:
712
- mod = importlib.util.module_from_spec(spec)
713
- try:
714
- spec.loader.exec_module(mod)
715
- return mod.FastMCP
716
- except Exception as exc: # pragma: no cover - defensive
717
- logger.debug("FastMCP file-import failed: %s", exc)
718
- # Second shape: fastmcp/__init__.py package directory.
719
- candidate_pkg = os.path.join(site_dir, 'mcp', 'server', 'fastmcp', '__init__.py')
720
- if os.path.isfile(candidate_pkg):
721
- spec = importlib.util.spec_from_file_location(
722
- 'mcp_pip_sdk_lsp.server.fastmcp', candidate_pkg,
723
- submodule_search_locations=[
724
- os.path.join(site_dir, 'mcp', 'server', 'fastmcp'),
725
- ],
726
- )
727
- if spec and spec.loader:
728
- mod = importlib.util.module_from_spec(spec)
729
- try:
730
- spec.loader.exec_module(mod)
731
- if hasattr(mod, 'FastMCP'):
732
- return mod.FastMCP
733
- except Exception as exc: # pragma: no cover - defensive
734
- logger.debug("FastMCP pkg-import failed: %s", exc)
735
- logger.warning("MCP SDK not found; LSP proxy MCP server will not be runnable.")
701
+ """Load the genuine pip MCP SDK's FastMCP via the shared loader, which
702
+ resolves the local-vs-SDK `mcp` namespace collision under both SDK
703
+ layouts. Returns the real FastMCP class on success, or `_NoopFastMCP`
704
+ on a genuinely-absent SDK (so import never raises). The degrade path is
705
+ LOUD: it logs one warning naming the cause rather than failing silently
706
+ the way the old importlib.util shim did."""
707
+ cls = _load_real_fastmcp()
708
+ if cls is not None:
709
+ return cls
710
+ logger.warning(
711
+ "MCP SDK (pip package 'mcp') not found or not importable; LSP proxy "
712
+ "MCP server will not be runnable. Tools are registered against a "
713
+ "no-op shim and mcp.run() will raise. Install with: "
714
+ "pip install -r mcp/requirements.txt (or: pip install mcp)."
715
+ )
736
716
  return _NoopFastMCP
737
717
 
738
718
 
@@ -748,12 +728,32 @@ except Exception:
748
728
  _version = 'unknown'
749
729
 
750
730
 
751
- mcp = FastMCP(
752
- 'loki-mode-lsp-proxy',
753
- version=_version,
754
- description='Loki Mode LSP proxy: find references, go to definition, '
755
- 'symbol-at-position via on-PATH language servers.',
756
- )
731
+ def _build_lsp_fastmcp():
732
+ """Instantiate FastMCP forwarding only the optional kwargs the installed
733
+ SDK actually accepts. Mirrors mcp/server.py's _build_fastmcp: MCP SDK 1.x
734
+ FastMCP.__init__ has no `version=`/`description=` parameters (it uses
735
+ `instructions=`), so passing them unconditionally raises TypeError and
736
+ the proxy never starts. We introspect the signature and forward only
737
+ supported kwargs, keeping forward/backward compatibility. The no-op shim
738
+ accepts any kwargs, so this is safe on the degrade path too."""
739
+ import inspect
740
+ _kwargs = {}
741
+ try:
742
+ _params = inspect.signature(FastMCP.__init__).parameters
743
+ except (TypeError, ValueError): # pragma: no cover - defensive
744
+ _params = {}
745
+ _desc = ('Loki Mode LSP proxy: find references, go to definition, '
746
+ 'symbol-at-position via on-PATH language servers.')
747
+ if "instructions" in _params:
748
+ _kwargs["instructions"] = _desc
749
+ elif "description" in _params:
750
+ _kwargs["description"] = _desc
751
+ if "version" in _params:
752
+ _kwargs["version"] = _version
753
+ return FastMCP('loki-mode-lsp-proxy', **_kwargs)
754
+
755
+
756
+ mcp = _build_lsp_fastmcp()
757
757
 
758
758
 
759
759
  # ============================================================
package/mcp/server.py CHANGED
@@ -467,39 +467,41 @@ def _emit_context_relevance_signal(
467
467
  thread.start()
468
468
 
469
469
 
470
- # BUG #3 FIX: The local mcp/ package shadows the pip-installed mcp SDK.
471
- # Load FastMCP directly from site-packages using importlib.util to bypass
472
- # Python's package name resolution entirely (avoids infinite recursion).
473
- import importlib.util
474
- import site
475
-
476
- _fastmcp_found = False
477
- _search_paths = []
478
- try:
479
- _search_paths.extend(site.getsitepackages())
480
- except AttributeError:
481
- pass
482
- try:
483
- _search_paths.append(site.getusersitepackages())
484
- except AttributeError:
485
- pass
470
+ # ============================================================
471
+ # Loading the pip MCP SDK's FastMCP under a NAMESPACE COLLISION
472
+ # ============================================================
473
+ #
474
+ # Root cause (task 562): this repo ships a local package named `mcp/`
475
+ # (this very file is mcp/server.py). That local package SHADOWS the
476
+ # pip-installed MCP SDK, which is also named `mcp`. The two cannot both
477
+ # own the top-level `mcp` name in one interpreter.
478
+ #
479
+ # The FastMCP loader that resolves this collision used to live inline here.
480
+ # Task 566 extracted it VERBATIM into mcp/_sdk_loader.py so the LSP proxy
481
+ # (mcp/lsp_proxy.py) can load FastMCP through the SAME battle-tested path
482
+ # instead of its old importlib.util shim, which silently degraded to a
483
+ # no-op under the MCP SDK 1.x package-directory layout. The shared module
484
+ # carries the full root-cause writeup; behavior here is unchanged (the
485
+ # three helpers are re-exported below under their original names so any
486
+ # existing `mcp.server._mcp_sdk_present` / `_load_real_fastmcp` reference
487
+ # keeps resolving).
488
+ from mcp._sdk_loader import ( # noqa: E402
489
+ _real_mcp_search_dirs,
490
+ _mcp_sdk_present,
491
+ _load_real_fastmcp,
492
+ )
486
493
 
487
- for _site_dir in _search_paths:
488
- _fastmcp_path = os.path.join(_site_dir, "mcp", "server", "fastmcp.py")
489
- if os.path.isfile(_fastmcp_path):
490
- _spec = importlib.util.spec_from_file_location(
491
- "mcp_pip_sdk.server.fastmcp", _fastmcp_path,
492
- submodule_search_locations=[]
493
- )
494
- if _spec and _spec.loader:
495
- _fastmcp_mod = importlib.util.module_from_spec(_spec)
496
- _spec.loader.exec_module(_fastmcp_mod)
497
- FastMCP = _fastmcp_mod.FastMCP
498
- _fastmcp_found = True
499
- break
500
-
501
- if not _fastmcp_found:
502
- logger.error("MCP SDK (pip package 'mcp') not found in site-packages. Install with: pip install mcp")
494
+
495
+ FastMCP = _load_real_fastmcp()
496
+
497
+ if FastMCP is None:
498
+ logger.error(
499
+ "MCP SDK (pip package 'mcp') not found or not importable. "
500
+ "Install it, then re-run. The simplest path is: 'loki mcp', which "
501
+ "creates a managed virtualenv at .loki/mcp-venv and installs "
502
+ "mcp/requirements.txt for you. To install manually: "
503
+ "pip install -r mcp/requirements.txt (or: pip install mcp)."
504
+ )
503
505
  sys.exit(1)
504
506
 
505
507
  # Read version from VERSION file instead of hardcoding
@@ -509,12 +511,31 @@ try:
509
511
  except Exception:
510
512
  _version = "unknown"
511
513
 
512
- # Initialize FastMCP server
513
- mcp = FastMCP(
514
- "loki-mode",
515
- version=_version,
516
- description="Loki Mode autonomous agent orchestration"
517
- )
514
+ # Initialize FastMCP server.
515
+ #
516
+ # Task 562: pass only kwargs the installed SDK actually accepts. MCP SDK 1.x
517
+ # FastMCP.__init__ has no `version=`/`description=` parameters (it uses
518
+ # `instructions=`), so passing them raises TypeError and the server never
519
+ # starts. We introspect the signature and forward only supported optional
520
+ # kwargs, keeping forward/backward compatibility across SDK versions.
521
+ def _build_fastmcp():
522
+ import inspect
523
+ _kwargs = {}
524
+ try:
525
+ _params = inspect.signature(FastMCP.__init__).parameters
526
+ except (TypeError, ValueError): # pragma: no cover - defensive
527
+ _params = {}
528
+ _desc = "Loki Mode autonomous agent orchestration"
529
+ if "instructions" in _params:
530
+ _kwargs["instructions"] = _desc
531
+ elif "description" in _params:
532
+ _kwargs["description"] = _desc
533
+ if "version" in _params:
534
+ _kwargs["version"] = _version
535
+ return FastMCP("loki-mode", **_kwargs)
536
+
537
+
538
+ mcp = _build_fastmcp()
518
539
 
519
540
  # ============================================================
520
541
  # TOOLS - Functions Claude can call
@@ -2462,8 +2483,23 @@ def main():
2462
2483
  help='Transport mechanism (default: stdio)')
2463
2484
  parser.add_argument('--port', type=int, default=8421,
2464
2485
  help='Port for HTTP transport (default: 8421)')
2486
+ parser.add_argument('--check-sdk', action='store_true',
2487
+ help=('Probe only: exit 0 if the MCP SDK loaded and the '
2488
+ 'server object built, non-zero otherwise. Used by '
2489
+ '`loki mcp` to verify a venv before launching. '
2490
+ 'Does not start a server.'))
2465
2491
  args = parser.parse_args()
2466
2492
 
2493
+ # --check-sdk: if we reached here, the module-level loader already imported
2494
+ # FastMCP and built `mcp` (otherwise the module would have sys.exit(1)'d at
2495
+ # import). So reaching main() with a live `mcp` object means the SDK is
2496
+ # genuinely importable. Report success and exit without starting a server.
2497
+ if args.check_sdk:
2498
+ if mcp is not None:
2499
+ print("MCP SDK OK", file=sys.stderr)
2500
+ sys.exit(0)
2501
+ sys.exit(1)
2502
+
2467
2503
  # Register cleanup to prevent file handle leaks on shutdown/restart
2468
2504
  atexit.register(cleanup_mcp_singletons)
2469
2505
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "7.29.0",
4
+ "version": "7.31.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 11 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -287,6 +287,65 @@ provider_get_tier_param() {
287
287
  fi
288
288
  }
289
289
 
290
+ # Canonical model-override / session-model normalization (single source of
291
+ # truth shared by run.sh, the dashboard, and the estimator). Trim leading and
292
+ # trailing whitespace, lowercase, and accept ONLY an exact allowlisted alias.
293
+ # Interior whitespace (e.g. "fab le") is therefore rejected rather than silently
294
+ # collapsed into "fable". bash 3.2 safe (no ${var,,}); uses tr for lowercasing.
295
+ # Echoes the canonical alias on success, or the empty string when the input is
296
+ # not an exact allowlisted alias.
297
+ loki_normalize_model_alias() {
298
+ local raw="$1"
299
+ # Trim leading/trailing whitespace (interior whitespace preserved so a
300
+ # value like "fab le" stays "fab le" and fails the exact-match below).
301
+ raw="${raw#"${raw%%[![:space:]]*}"}"
302
+ raw="${raw%"${raw##*[![:space:]]}"}"
303
+ raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
304
+ case "$raw" in
305
+ haiku|sonnet|opus|fable) printf '%s' "$raw" ;;
306
+ *) printf '%s' "" ;;
307
+ esac
308
+ }
309
+
310
+ # Shared cost-ceiling clamp (single source of truth for LOKI_MAX_TIER). Given a
311
+ # resolved model name and a tier hint, return the model clamped down to the
312
+ # operator's LOKI_MAX_TIER ceiling. Used by resolve_model_for_tier AND by the
313
+ # mid-flight model-override path in run.sh so a dashboard/CLI override cannot
314
+ # silently bypass the ceiling. Clamps are byte-identical by construction:
315
+ # sonnet-cap resolves planning/fable down to PROVIDER_MODEL_DEVELOPMENT (opus by
316
+ # default), opus-cap resolves fable down to opus, haiku-cap pins everything to
317
+ # PROVIDER_MODEL_FAST. No ceiling set -> model unchanged.
318
+ loki_apply_max_tier_clamp() {
319
+ local model="$1"
320
+ local tier="${2:-}"
321
+ local max_tier="${LOKI_MAX_TIER:-}"
322
+ # Normalize EXACTLY like the python ports (dashboard _clamp_to_max_tier,
323
+ # estimator _max_tier): trim + lowercase. Without this, a user-typed cap
324
+ # like "Sonnet" (settings.json maxTier exports verbatim) was silently
325
+ # ignored here while quote and dashboard claimed the ceiling enforced:
326
+ # the run would exceed the quote. Council R1 finding, v7.31.0.
327
+ max_tier="$(printf '%s' "$max_tier" | tr '[:upper:]' '[:lower:]' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
328
+ [ -z "$max_tier" ] && { printf '%s' "$model"; return; }
329
+ case "$max_tier" in
330
+ haiku)
331
+ model="$PROVIDER_MODEL_FAST"
332
+ ;;
333
+ sonnet)
334
+ # Cap planning/fable down to development.
335
+ if [ "$tier" = "planning" ] || [ "$tier" = "fable" ] || [ "$model" = "fable" ]; then
336
+ model="$PROVIDER_MODEL_DEVELOPMENT"
337
+ fi
338
+ ;;
339
+ opus)
340
+ # Opus is the ceiling: cap fable back to opus.
341
+ if [ "$model" = "fable" ]; then
342
+ model="opus"
343
+ fi
344
+ ;;
345
+ esac
346
+ printf '%s' "$model"
347
+ }
348
+
290
349
  # Dynamic model resolution (v6.0.0)
291
350
  # Resolves a capability tier to a concrete model name at runtime.
292
351
  # Respects LOKI_MAX_TIER to cap cost (e.g., maxTier=sonnet prevents opus usage).
@@ -301,35 +360,33 @@ resolve_model_for_tier() {
301
360
  cheap) tier="fast" ;;
302
361
  esac
303
362
 
304
- local max_tier="${LOKI_MAX_TIER:-}"
305
363
  local model=""
306
364
 
307
365
  # Resolve tier to model
366
+ # fable) explicit top-tier advisory model (Fable 5, 2x Opus). Reached when
367
+ # the session is pinned to fable (LOKI_SESSION_MODEL=fable), the
368
+ # mid-flight override file selects fable, or the architect opt-in
369
+ # (LOKI_FABLE_ARCHITECT=1) pins the first iteration's tier to fable
370
+ # in run.sh. So the resolver honors the documented lever instead of
371
+ # falling through the `*` arm to opus (the model-honesty fix).
308
372
  case "$tier" in
309
373
  planning) model="$PROVIDER_MODEL_PLANNING" ;;
310
374
  development) model="$PROVIDER_MODEL_DEVELOPMENT" ;;
311
375
  fast) model="$PROVIDER_MODEL_FAST" ;;
376
+ fable) model="fable" ;;
312
377
  *) model="$PROVIDER_MODEL_DEVELOPMENT" ;;
313
378
  esac
314
379
 
315
- # Apply maxTier ceiling if set
316
- if [ -n "$max_tier" ]; then
317
- case "$max_tier" in
318
- haiku)
319
- # Cap everything to haiku/fast
320
- model="$PROVIDER_MODEL_FAST"
321
- ;;
322
- sonnet)
323
- # Cap planning to development
324
- if [ "$tier" = "planning" ]; then
325
- model="$PROVIDER_MODEL_DEVELOPMENT"
326
- fi
327
- ;;
328
- opus)
329
- # No cap needed, opus is max
330
- ;;
331
- esac
332
- fi
380
+ # Architect opt-in (LOKI_FABLE_ARCHITECT) is NOT applied here. It is applied
381
+ # in run.sh, scoped to the FIRST iteration only (the architecture pass), so
382
+ # a session pinned to opus does not silently route every iteration to fable.
383
+ # run.sh sets CURRENT_TIER=fable for that one iteration, which lands on the
384
+ # `fable)` arm above. Keeping the decision in run.sh is the only place that
385
+ # has ITERATION_COUNT, so the scoping is honest.
386
+
387
+ # Apply the shared LOKI_MAX_TIER ceiling (same clamp the run.sh override path
388
+ # uses, so the cost ceiling is enforced byte-identically on both paths).
389
+ model="$(loki_apply_max_tier_clamp "$model" "$tier")"
333
390
 
334
391
  # Phase I (v7.5.25): when ANTHROPIC_BASE_URL is set, the user is routing
335
392
  # Claude Code to an alt-provider (OpenRouter, Ollama, LiteLLM, self-hosted).
@@ -8,11 +8,20 @@
8
8
  "latest_development": "claude-opus-4-7",
9
9
  "latest_fast": "claude-sonnet-4-6",
10
10
  "cli_aliases": {
11
+ "fable": "claude-fable-5",
11
12
  "opus": "claude-opus-4-7",
12
13
  "sonnet": "claude-sonnet-4-6",
13
14
  "haiku": "claude-haiku-4-5"
14
15
  },
15
16
  "models": [
17
+ {
18
+ "id": "claude-fable-5",
19
+ "alias": "fable",
20
+ "tier": "advisor",
21
+ "context_window": 1000000,
22
+ "max_output": 128000,
23
+ "notes": "Top-tier advisory model. Pricing 2x Opus ($10/$50 per MTok). Doc-supported for architecture/root-cause; opt-in only (cost). Refuses cyber content, so NOT used for security review."
24
+ },
16
25
  {
17
26
  "id": "claude-opus-4-7",
18
27
  "alias": "opus",