feat(mcp): configure OpenCode models per workflow
Add server-side model and variant settings for MCP ingest and query OpenCode jobs. Ingest defaults to openai/gpt-5.5 high, while query defaults to openai/gpt-5.5 minimal, and readiness/status surfaces the active settings. Persist the settings through the managed service env file and preserve local overrides across start, tools, and token rotations. Remote MCP clients still cannot choose arbitrary models per call. Validation: python3 -m unittest discover -v; python3 -m py_compile obsidian_wiki/mcp_ingest.py obsidian_wiki/mcp_query.py obsidian_wiki/mcp_server.py scripts/manage_mcp_service.py tests/test_mcp_ingest.py tests/test_mcp_query.py tests/test_mcp_server_remote.py tests/test_mcp_service.py; git diff --check.
This commit is contained in:
@@ -71,6 +71,8 @@ OBSIDIAN_MCP_INGEST_MAX_CHUNK_BYTES=4194304
|
||||
OBSIDIAN_MCP_INGEST_MAX_FILES_PER_JOB=10
|
||||
OBSIDIAN_MCP_OPENCODE_PATH=opencode
|
||||
OBSIDIAN_MCP_INGEST_OPENCODE_PATH=opencode
|
||||
OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5
|
||||
OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high
|
||||
OBSIDIAN_MCP_INGEST_RUNTIME_ROOT=.runtime/mcp-ingest
|
||||
|
||||
# Optional MCP query tools. Default is disabled.
|
||||
@@ -81,6 +83,8 @@ OBSIDIAN_MCP_INGEST_RUNTIME_ROOT=.runtime/mcp-ingest
|
||||
# pages. Use OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,query or:
|
||||
# bash scripts/mcp_service.sh tools enable query
|
||||
# bash scripts/mcp_service.sh start --enable-query
|
||||
OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5
|
||||
OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal
|
||||
OBSIDIAN_MCP_QUERY_MAX_CHARS=8000
|
||||
OBSIDIAN_MCP_QUERY_RUNTIME_ROOT=.runtime/mcp-query
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ See `wiki-query` and `wiki-export` skills for how the filter is applied.
|
||||
- **Prefer precise identifiers in search ranking.** Exact or prefix matches in structured part-number and series fields should rank above incidental filename substrings such as timestamps or archive hashes.
|
||||
- **Keep remote ingest gated.** MCP ingest tools are write-capable and must remain disabled unless the service config explicitly enables the `ingest` tool group via `OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest`, `mcp_service.sh tools enable ingest`, or `mcp_service.sh start --enable-ingest`. Uploaded files must stage outside the vault under the MCP runtime root; `wiki-ingest` is responsible for accepting, archiving, and indexing sources inside the vault. Use the HTTP multipart endpoint `/mcp-ingest/uploads` for remote file transfer; if the remote agent cannot read the main MCP bearer token, it must call `create_ingest_upload_ticket` and send the returned single-use `X-Obsidian-Wiki-Upload-Token` header. Do not expose base64 or chunked upload tools in the MCP tool surface. They may launch local OpenCode only through the fixed `/wiki-ingest` prompt; never expose arbitrary prompt or shell execution through MCP. Remote ingest must keep the OpenCode readiness preflight in front of job launch, and `check_ingest_opencode_readiness(run_smoke_test=true)` should stay available for operators to verify that OpenCode can load the project `wiki-ingest` skill before accepting uploads.
|
||||
- **Keep remote query gated.** MCP query tools must remain disabled unless the service config explicitly enables the `query` tool group via `OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,query`, `mcp_service.sh tools enable query`, or `mcp_service.sh start --enable-query`. They may launch local OpenCode only through the fixed `/wiki-query` prompt, with no arbitrary prompt or shell execution. `public_only` defaults to false, meaning remote answers may use the full local vault visibility; enable the tool group only for trusted clients and set `public_only=true` per call for filtered user-facing answers.
|
||||
- **Keep OpenCode model choice server-side.** MCP clients must not pass arbitrary model names through ingest/query tools. Configure local defaults in `.runtime/obsidian-wiki-mcp.env`: ingest uses `OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5` with `OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high`; query uses `OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5` with `OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal`.
|
||||
|
||||
## Core Principles
|
||||
|
||||
|
||||
@@ -408,6 +408,8 @@ This adds write-capable MCP tools to the same `wiki-file-assets` server and bear
|
||||
- `start_ingest_job` — queues a local `opencode run --auto --format json` job with a fixed `/wiki-ingest` prompt for the completed uploads.
|
||||
- `get_ingest_job_status`, `get_ingest_job_result`, `cancel_ingest_job` — inspect or cancel the local job.
|
||||
|
||||
The managed service config sets the OpenCode model server-side. By default ingest uses `OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5` and `OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high` for stronger file processing. Change those in `.runtime/obsidian-wiki-mcp.env` on the server if needed; remote MCP clients do not choose the model per call.
|
||||
|
||||
For binary files, use the HTTP upload endpoint. If the remote agent cannot read the main MCP bearer token, first call `create_ingest_upload_ticket`, then upload with the returned token:
|
||||
|
||||
```bash
|
||||
@@ -439,6 +441,8 @@ This adds read-oriented OpenCode-backed query tools to the same `wiki-file-asset
|
||||
- `start_wiki_query` — queues a local `opencode run --auto --format default` job with a fixed `/wiki-query` prompt. Parameters: `query`, `mode` (`normal` or `index_only`), `public_only` (default `false`), and optional `note`.
|
||||
- `get_wiki_query_status`, `get_wiki_query_result`, `cancel_wiki_query` — inspect, retrieve, or cancel the local query job.
|
||||
|
||||
The managed service config sets query to `OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5` and `OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal` by default, prioritizing response speed over maximum reasoning depth. Tune the server env file if a different balance is needed.
|
||||
|
||||
`public_only` defaults to `false`, so the answer may use the same full vault visibility that a local operator sees. Set `public_only=true` when the remote answer must exclude `visibility/internal` and `visibility/pii` pages. Job state and logs live under `.runtime/mcp-query/jobs/`, outside the vault. A normal `wiki-query` log entry is allowed, but the generated prompt forbids ingest/update/rebuild workflows and arbitrary shell execution.
|
||||
|
||||
Rotate the token later with:
|
||||
|
||||
@@ -25,6 +25,8 @@ DEFAULT_INGEST_MAX_FILES_PER_JOB = 10
|
||||
DEFAULT_UPLOAD_TICKET_SECONDS = 30 * 60
|
||||
MAX_UPLOAD_TICKET_SECONDS = 24 * 60 * 60
|
||||
DEFAULT_OPENCODE_PATH = "opencode"
|
||||
DEFAULT_INGEST_OPENCODE_MODEL = "openai/gpt-5.5"
|
||||
DEFAULT_INGEST_OPENCODE_VARIANT = "high"
|
||||
DEFAULT_OPENCODE_SMOKE_TIMEOUT_SECONDS = 120
|
||||
|
||||
SHA256_RE = re.compile(r"^(?:sha256:)?([0-9a-fA-F]{64})$")
|
||||
@@ -63,6 +65,8 @@ class IngestManager:
|
||||
project_root: str | Path,
|
||||
runtime_root: str | Path,
|
||||
opencode_path: str | Path = DEFAULT_OPENCODE_PATH,
|
||||
opencode_model: str | None = DEFAULT_INGEST_OPENCODE_MODEL,
|
||||
opencode_variant: str | None = DEFAULT_INGEST_OPENCODE_VARIANT,
|
||||
max_file_bytes: int = DEFAULT_INGEST_MAX_FILE_BYTES,
|
||||
max_chunk_bytes: int = DEFAULT_INGEST_MAX_CHUNK_BYTES,
|
||||
max_files_per_job: int = DEFAULT_INGEST_MAX_FILES_PER_JOB,
|
||||
@@ -76,6 +80,8 @@ class IngestManager:
|
||||
f"got {self.runtime_root}"
|
||||
)
|
||||
self.opencode_path = str(opencode_path)
|
||||
self.opencode_model = clean_config_value(str(opencode_model or "")).strip()
|
||||
self.opencode_variant = clean_config_value(str(opencode_variant or "")).strip()
|
||||
self.max_file_bytes = max_file_bytes
|
||||
self.max_chunk_bytes = max_chunk_bytes
|
||||
self.max_files_per_job = max_files_per_job
|
||||
@@ -285,6 +291,8 @@ class IngestManager:
|
||||
"static_preflight": True,
|
||||
"runtime_smoke_test": "skipped",
|
||||
"opencode_version": "",
|
||||
"opencode_model": self.opencode_model,
|
||||
"opencode_variant": self.opencode_variant,
|
||||
"checked_paths": [],
|
||||
"warnings": [],
|
||||
"errors": [],
|
||||
@@ -385,6 +393,7 @@ class IngestManager:
|
||||
"--format",
|
||||
"json",
|
||||
"--auto",
|
||||
*self._opencode_model_args(),
|
||||
"--dir",
|
||||
str(self.project_root),
|
||||
"--title",
|
||||
@@ -662,6 +671,7 @@ class IngestManager:
|
||||
"--format",
|
||||
"json",
|
||||
"--auto",
|
||||
*self._opencode_model_args(),
|
||||
"--dir",
|
||||
str(self.project_root),
|
||||
"--title",
|
||||
@@ -718,6 +728,14 @@ class IngestManager:
|
||||
text = str(value).replace("\x00", "").strip()
|
||||
return text[:2000] if text else None
|
||||
|
||||
def _opencode_model_args(self) -> list[str]:
|
||||
args = []
|
||||
if self.opencode_model:
|
||||
args.extend(["--model", self.opencode_model])
|
||||
if self.opencode_variant:
|
||||
args.extend(["--variant", self.opencode_variant])
|
||||
return args
|
||||
|
||||
def _sha256_file(self, path: Path) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
with path.open("rb") as fh:
|
||||
|
||||
@@ -17,6 +17,8 @@ from obsidian_wiki.file_assets import clean_config_value
|
||||
|
||||
DEFAULT_QUERY_MAX_CHARS = 8000
|
||||
DEFAULT_OPENCODE_PATH = "opencode"
|
||||
DEFAULT_QUERY_OPENCODE_MODEL = "openai/gpt-5.5"
|
||||
DEFAULT_QUERY_OPENCODE_VARIANT = "minimal"
|
||||
DEFAULT_OPENCODE_SMOKE_TIMEOUT_SECONDS = 120
|
||||
|
||||
QUERY_MODES = {"normal", "index_only"}
|
||||
@@ -49,6 +51,8 @@ class QueryManager:
|
||||
project_root: str | Path,
|
||||
runtime_root: str | Path,
|
||||
opencode_path: str | Path = DEFAULT_OPENCODE_PATH,
|
||||
opencode_model: str | None = DEFAULT_QUERY_OPENCODE_MODEL,
|
||||
opencode_variant: str | None = DEFAULT_QUERY_OPENCODE_VARIANT,
|
||||
max_query_chars: int = DEFAULT_QUERY_MAX_CHARS,
|
||||
) -> None:
|
||||
self.vault = Path(clean_config_value(str(vault_path))).expanduser().resolve()
|
||||
@@ -60,6 +64,8 @@ class QueryManager:
|
||||
f"got {self.runtime_root}"
|
||||
)
|
||||
self.opencode_path = str(opencode_path)
|
||||
self.opencode_model = clean_config_value(str(opencode_model or "")).strip()
|
||||
self.opencode_variant = clean_config_value(str(opencode_variant or "")).strip()
|
||||
self.max_query_chars = max_query_chars
|
||||
self.jobs_root = self.runtime_root / "jobs"
|
||||
self._lock = RLock()
|
||||
@@ -108,6 +114,8 @@ class QueryManager:
|
||||
"static_preflight": True,
|
||||
"runtime_smoke_test": "skipped",
|
||||
"opencode_version": "",
|
||||
"opencode_model": self.opencode_model,
|
||||
"opencode_variant": self.opencode_variant,
|
||||
"checked_paths": [],
|
||||
"warnings": [],
|
||||
"errors": [],
|
||||
@@ -205,6 +213,7 @@ class QueryManager:
|
||||
"--format",
|
||||
"default",
|
||||
"--auto",
|
||||
*self._opencode_model_args(),
|
||||
"--dir",
|
||||
str(self.project_root),
|
||||
"--title",
|
||||
@@ -418,6 +427,7 @@ class QueryManager:
|
||||
"--format",
|
||||
"default",
|
||||
"--auto",
|
||||
*self._opencode_model_args(),
|
||||
"--dir",
|
||||
str(self.project_root),
|
||||
"--title",
|
||||
@@ -474,6 +484,14 @@ class QueryManager:
|
||||
text = str(value).replace("\x00", "").strip()
|
||||
return text[:2000] if text else None
|
||||
|
||||
def _opencode_model_args(self) -> list[str]:
|
||||
args = []
|
||||
if self.opencode_model:
|
||||
args.extend(["--model", self.opencode_model])
|
||||
if self.opencode_variant:
|
||||
args.extend(["--variant", self.opencode_variant])
|
||||
return args
|
||||
|
||||
def _read_json(self, path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ from obsidian_wiki.mcp_ingest import (
|
||||
DEFAULT_INGEST_MAX_CHUNK_BYTES,
|
||||
DEFAULT_INGEST_MAX_FILE_BYTES,
|
||||
DEFAULT_INGEST_MAX_FILES_PER_JOB,
|
||||
DEFAULT_INGEST_OPENCODE_MODEL,
|
||||
DEFAULT_INGEST_OPENCODE_VARIANT,
|
||||
DEFAULT_UPLOAD_TICKET_SECONDS,
|
||||
DEFAULT_OPENCODE_PATH,
|
||||
MAX_UPLOAD_TICKET_SECONDS,
|
||||
@@ -25,7 +27,13 @@ from obsidian_wiki.mcp_ingest import (
|
||||
IngestManager,
|
||||
parse_bool,
|
||||
)
|
||||
from obsidian_wiki.mcp_query import DEFAULT_QUERY_MAX_CHARS, QueryError, QueryManager
|
||||
from obsidian_wiki.mcp_query import (
|
||||
DEFAULT_QUERY_MAX_CHARS,
|
||||
DEFAULT_QUERY_OPENCODE_MODEL,
|
||||
DEFAULT_QUERY_OPENCODE_VARIANT,
|
||||
QueryError,
|
||||
QueryManager,
|
||||
)
|
||||
from obsidian_wiki.mcp_assets import (
|
||||
DEFAULT_MAX_FILE_BYTES,
|
||||
DEFAULT_PROFILE_NAME,
|
||||
@@ -153,6 +161,10 @@ def create_server(
|
||||
ingest_runtime_root: str | Path | None = None,
|
||||
query_runtime_root: str | Path | None = None,
|
||||
opencode_path: str | Path = DEFAULT_OPENCODE_PATH,
|
||||
ingest_opencode_model: str | None = DEFAULT_INGEST_OPENCODE_MODEL,
|
||||
ingest_opencode_variant: str | None = DEFAULT_INGEST_OPENCODE_VARIANT,
|
||||
query_opencode_model: str | None = DEFAULT_QUERY_OPENCODE_MODEL,
|
||||
query_opencode_variant: str | None = DEFAULT_QUERY_OPENCODE_VARIANT,
|
||||
ingest_max_file_bytes: int = DEFAULT_INGEST_MAX_FILE_BYTES,
|
||||
ingest_max_chunk_bytes: int = DEFAULT_INGEST_MAX_CHUNK_BYTES,
|
||||
ingest_max_files_per_job: int = DEFAULT_INGEST_MAX_FILES_PER_JOB,
|
||||
@@ -219,6 +231,10 @@ def create_server(
|
||||
),
|
||||
"ingest_enabled": enable_ingest,
|
||||
"query_enabled": enable_query,
|
||||
"ingest_opencode_model": ingest_opencode_model or "",
|
||||
"ingest_opencode_variant": ingest_opencode_variant or "",
|
||||
"query_opencode_model": query_opencode_model or "",
|
||||
"query_opencode_variant": query_opencode_variant or "",
|
||||
}
|
||||
|
||||
if enable_ingest:
|
||||
@@ -228,6 +244,8 @@ def create_server(
|
||||
project_root=project,
|
||||
runtime_root=ingest_runtime,
|
||||
opencode_path=opencode_path,
|
||||
opencode_model=ingest_opencode_model,
|
||||
opencode_variant=ingest_opencode_variant,
|
||||
max_file_bytes=ingest_max_file_bytes,
|
||||
max_chunk_bytes=ingest_max_chunk_bytes,
|
||||
max_files_per_job=ingest_max_files_per_job,
|
||||
@@ -338,6 +356,8 @@ def create_server(
|
||||
project_root=project,
|
||||
runtime_root=query_runtime,
|
||||
opencode_path=opencode_path,
|
||||
opencode_model=query_opencode_model,
|
||||
opencode_variant=query_opencode_variant,
|
||||
max_query_chars=query_max_chars,
|
||||
)
|
||||
|
||||
@@ -704,6 +724,10 @@ def run_http_server(
|
||||
ingest_runtime_root: str | Path | None = None,
|
||||
query_runtime_root: str | Path | None = None,
|
||||
opencode_path: str | Path = DEFAULT_OPENCODE_PATH,
|
||||
ingest_opencode_model: str | None = DEFAULT_INGEST_OPENCODE_MODEL,
|
||||
ingest_opencode_variant: str | None = DEFAULT_INGEST_OPENCODE_VARIANT,
|
||||
query_opencode_model: str | None = DEFAULT_QUERY_OPENCODE_MODEL,
|
||||
query_opencode_variant: str | None = DEFAULT_QUERY_OPENCODE_VARIANT,
|
||||
ingest_max_file_bytes: int = DEFAULT_INGEST_MAX_FILE_BYTES,
|
||||
ingest_max_chunk_bytes: int = DEFAULT_INGEST_MAX_CHUNK_BYTES,
|
||||
ingest_max_files_per_job: int = DEFAULT_INGEST_MAX_FILES_PER_JOB,
|
||||
@@ -728,6 +752,8 @@ def run_http_server(
|
||||
project_root=project,
|
||||
runtime_root=ingest_runtime,
|
||||
opencode_path=opencode_path,
|
||||
opencode_model=ingest_opencode_model,
|
||||
opencode_variant=ingest_opencode_variant,
|
||||
max_file_bytes=ingest_max_file_bytes,
|
||||
max_chunk_bytes=ingest_max_chunk_bytes,
|
||||
max_files_per_job=ingest_max_files_per_job,
|
||||
@@ -746,6 +772,8 @@ def run_http_server(
|
||||
project_root=project,
|
||||
runtime_root=query_runtime,
|
||||
opencode_path=opencode_path,
|
||||
opencode_model=query_opencode_model,
|
||||
opencode_variant=query_opencode_variant,
|
||||
max_query_chars=query_max_chars,
|
||||
)
|
||||
|
||||
@@ -767,6 +795,10 @@ def run_http_server(
|
||||
ingest_runtime_root=ingest_runtime_root,
|
||||
query_runtime_root=query_runtime_root,
|
||||
opencode_path=opencode_path,
|
||||
ingest_opencode_model=ingest_opencode_model,
|
||||
ingest_opencode_variant=ingest_opencode_variant,
|
||||
query_opencode_model=query_opencode_model,
|
||||
query_opencode_variant=query_opencode_variant,
|
||||
ingest_max_file_bytes=ingest_max_file_bytes,
|
||||
ingest_max_chunk_bytes=ingest_max_chunk_bytes,
|
||||
ingest_max_files_per_job=ingest_max_files_per_job,
|
||||
@@ -844,6 +876,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"OBSIDIAN_MCP_QUERY_RUNTIME_ROOT",
|
||||
str(Path.cwd() / ".runtime" / "mcp-query"),
|
||||
)
|
||||
ingest_opencode_model = runtime_config_value("OBSIDIAN_MCP_INGEST_OPENCODE_MODEL", DEFAULT_INGEST_OPENCODE_MODEL)
|
||||
ingest_opencode_variant = runtime_config_value("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT", DEFAULT_INGEST_OPENCODE_VARIANT)
|
||||
query_opencode_model = runtime_config_value("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL", DEFAULT_QUERY_OPENCODE_MODEL)
|
||||
query_opencode_variant = runtime_config_value("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT", DEFAULT_QUERY_OPENCODE_VARIANT)
|
||||
query_max_chars = runtime_config_int("OBSIDIAN_MCP_QUERY_MAX_CHARS", DEFAULT_QUERY_MAX_CHARS)
|
||||
try:
|
||||
if transport == "stdio":
|
||||
@@ -860,6 +896,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
ingest_runtime_root=ingest_runtime_root,
|
||||
query_runtime_root=query_runtime_root,
|
||||
opencode_path=opencode_path,
|
||||
ingest_opencode_model=ingest_opencode_model,
|
||||
ingest_opencode_variant=ingest_opencode_variant,
|
||||
query_opencode_model=query_opencode_model,
|
||||
query_opencode_variant=query_opencode_variant,
|
||||
ingest_max_file_bytes=ingest_max_file_bytes,
|
||||
ingest_max_chunk_bytes=ingest_max_chunk_bytes,
|
||||
ingest_max_files_per_job=ingest_max_files_per_job,
|
||||
@@ -891,6 +931,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
ingest_runtime_root=ingest_runtime_root,
|
||||
query_runtime_root=query_runtime_root,
|
||||
opencode_path=opencode_path,
|
||||
ingest_opencode_model=ingest_opencode_model,
|
||||
ingest_opencode_variant=ingest_opencode_variant,
|
||||
query_opencode_model=query_opencode_model,
|
||||
query_opencode_variant=query_opencode_variant,
|
||||
ingest_max_file_bytes=ingest_max_file_bytes,
|
||||
ingest_max_chunk_bytes=ingest_max_chunk_bytes,
|
||||
ingest_max_files_per_job=ingest_max_files_per_job,
|
||||
|
||||
@@ -32,6 +32,10 @@ DEFAULT_INGEST_UPLOAD_PATH = "/mcp-ingest/uploads"
|
||||
DEFAULT_TOKEN_BYTES = 32
|
||||
DEFAULT_UV_CACHE_DIR = "/tmp/uv-cache"
|
||||
DEFAULT_OPENCODE_PATH = "opencode"
|
||||
DEFAULT_INGEST_OPENCODE_MODEL = "openai/gpt-5.5"
|
||||
DEFAULT_INGEST_OPENCODE_VARIANT = "high"
|
||||
DEFAULT_QUERY_OPENCODE_MODEL = "openai/gpt-5.5"
|
||||
DEFAULT_QUERY_OPENCODE_VARIANT = "minimal"
|
||||
TOOL_FILE_ASSETS = "file-assets"
|
||||
TOOL_INGEST = "ingest"
|
||||
TOOL_QUERY = "query"
|
||||
@@ -115,6 +119,14 @@ class ServicePaths:
|
||||
env_path: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenCodeModelSettings:
|
||||
ingest_model: str = DEFAULT_INGEST_OPENCODE_MODEL
|
||||
ingest_variant: str = DEFAULT_INGEST_OPENCODE_VARIANT
|
||||
query_model: str = DEFAULT_QUERY_OPENCODE_MODEL
|
||||
query_variant: str = DEFAULT_QUERY_OPENCODE_VARIANT
|
||||
|
||||
|
||||
def generate_token(num_bytes: int = DEFAULT_TOKEN_BYTES) -> str:
|
||||
return secrets.token_urlsafe(num_bytes)
|
||||
|
||||
@@ -248,10 +260,12 @@ def write_token_env_file(
|
||||
enable_ingest: bool | None = None,
|
||||
enable_query: bool | None = None,
|
||||
opencode_path: str | Path | None = None,
|
||||
model_settings: OpenCodeModelSettings | None = None,
|
||||
) -> None:
|
||||
tools = normalize_enabled_tools(DEFAULT_ENABLED_TOOLS if enabled_tools is None else enabled_tools)
|
||||
tools = update_ingest_tool(tools, enable_ingest)
|
||||
tools = update_query_tool(tools, enable_query)
|
||||
models = model_settings or OpenCodeModelSettings()
|
||||
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
env_path.parent.chmod(0o700)
|
||||
fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
@@ -262,6 +276,10 @@ def write_token_env_file(
|
||||
env_file.write(f"OBSIDIAN_MCP_PATH={path}\n")
|
||||
env_file.write(f"OBSIDIAN_MCP_ENABLED_TOOLS={format_enabled_tools(tools)}\n")
|
||||
env_file.write(f"OBSIDIAN_MCP_ENABLE_INGEST={1 if TOOL_INGEST in tools else 0}\n")
|
||||
env_file.write(f"OBSIDIAN_MCP_INGEST_OPENCODE_MODEL={models.ingest_model}\n")
|
||||
env_file.write(f"OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT={models.ingest_variant}\n")
|
||||
env_file.write(f"OBSIDIAN_MCP_QUERY_OPENCODE_MODEL={models.query_model}\n")
|
||||
env_file.write(f"OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT={models.query_variant}\n")
|
||||
if opencode_path:
|
||||
env_file.write(f"OBSIDIAN_MCP_OPENCODE_PATH={opencode_path}\n")
|
||||
if TOOL_INGEST in tools:
|
||||
@@ -284,7 +302,20 @@ def read_service_env_file(env_path: Path) -> dict[str, str]:
|
||||
return values
|
||||
|
||||
|
||||
def saved_service_config(project_root: Path) -> tuple[str, str, int, str, tuple[str, ...], str]:
|
||||
def model_settings_from_config(values: dict[str, str]) -> OpenCodeModelSettings:
|
||||
return OpenCodeModelSettings(
|
||||
ingest_model=values.get("OBSIDIAN_MCP_INGEST_OPENCODE_MODEL", DEFAULT_INGEST_OPENCODE_MODEL).strip()
|
||||
or DEFAULT_INGEST_OPENCODE_MODEL,
|
||||
ingest_variant=values.get("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT", DEFAULT_INGEST_OPENCODE_VARIANT).strip()
|
||||
or DEFAULT_INGEST_OPENCODE_VARIANT,
|
||||
query_model=values.get("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL", DEFAULT_QUERY_OPENCODE_MODEL).strip()
|
||||
or DEFAULT_QUERY_OPENCODE_MODEL,
|
||||
query_variant=values.get("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT", DEFAULT_QUERY_OPENCODE_VARIANT).strip()
|
||||
or DEFAULT_QUERY_OPENCODE_VARIANT,
|
||||
)
|
||||
|
||||
|
||||
def saved_service_config(project_root: Path) -> tuple[str, str, int, str, tuple[str, ...], str, OpenCodeModelSettings]:
|
||||
paths = resolve_service_paths(project_root)
|
||||
values = read_service_env_file(paths.env_path)
|
||||
token = values.get("OBSIDIAN_MCP_TOKEN", "").strip()
|
||||
@@ -298,7 +329,7 @@ def saved_service_config(project_root: Path) -> tuple[str, str, int, str, tuple[
|
||||
values.get("OBSIDIAN_MCP_OPENCODE_PATH", "").strip()
|
||||
or values.get("OBSIDIAN_MCP_INGEST_OPENCODE_PATH", "").strip()
|
||||
)
|
||||
return token, host, port, path, enabled_tools, opencode_path
|
||||
return token, host, port, path, enabled_tools, opencode_path, model_settings_from_config(values)
|
||||
|
||||
|
||||
def write_service_unit(
|
||||
@@ -420,7 +451,7 @@ def render_remote_config(
|
||||
|
||||
def show_config(project_root: Path) -> str:
|
||||
paths = resolve_service_paths(project_root)
|
||||
token, _host, port, path, enabled_tools, _opencode_path = saved_service_config(project_root)
|
||||
token, _host, port, path, enabled_tools, _opencode_path, _models = saved_service_config(project_root)
|
||||
return render_remote_config(
|
||||
hosts=detect_remote_hosts(),
|
||||
token=token,
|
||||
@@ -459,8 +490,9 @@ def new_token(
|
||||
paths = resolve_service_paths(project_root)
|
||||
enabled_tools = DEFAULT_ENABLED_TOOLS
|
||||
opencode_path = ""
|
||||
model_settings = OpenCodeModelSettings()
|
||||
try:
|
||||
_old_token, _old_host, _old_port, _old_path, enabled_tools, opencode_path = saved_service_config(project_root)
|
||||
_old_token, _old_host, _old_port, _old_path, enabled_tools, opencode_path, model_settings = saved_service_config(project_root)
|
||||
except RuntimeError:
|
||||
pass
|
||||
token = generate_token(token_bytes)
|
||||
@@ -472,6 +504,7 @@ def new_token(
|
||||
path=path,
|
||||
enabled_tools=enabled_tools,
|
||||
opencode_path=opencode_path,
|
||||
model_settings=model_settings,
|
||||
)
|
||||
restart_service_if_installed(paths)
|
||||
return render_remote_config(
|
||||
@@ -497,13 +530,14 @@ def start_service(
|
||||
) -> str:
|
||||
paths = resolve_service_paths(project_root)
|
||||
try:
|
||||
token, host, port, path, enabled_tools, opencode_path = saved_service_config(project_root)
|
||||
token, host, port, path, enabled_tools, opencode_path, model_settings = saved_service_config(project_root)
|
||||
except RuntimeError:
|
||||
if paths.env_path.exists():
|
||||
raise
|
||||
token = generate_token(token_bytes)
|
||||
enabled_tools = DEFAULT_ENABLED_TOOLS
|
||||
opencode_path = ""
|
||||
model_settings = OpenCodeModelSettings()
|
||||
enabled_tools = update_ingest_tool(enabled_tools, enable_ingest)
|
||||
enabled_tools = update_query_tool(enabled_tools, enable_query)
|
||||
if ({TOOL_INGEST, TOOL_QUERY} & set(enabled_tools)) and not opencode_path:
|
||||
@@ -516,6 +550,7 @@ def start_service(
|
||||
path=path,
|
||||
enabled_tools=enabled_tools,
|
||||
opencode_path=opencode_path,
|
||||
model_settings=model_settings,
|
||||
)
|
||||
uv_path = find_uv_path()
|
||||
prepare_runtime(paths.project_root, uv_path=uv_path)
|
||||
@@ -560,10 +595,10 @@ def render_tool_status(
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def ensure_service_config(project_root: Path) -> tuple[ServicePaths, str, str, int, str, tuple[str, ...], str]:
|
||||
def ensure_service_config(project_root: Path) -> tuple[ServicePaths, str, str, int, str, tuple[str, ...], str, OpenCodeModelSettings]:
|
||||
paths = resolve_service_paths(project_root)
|
||||
try:
|
||||
token, host, port, path, enabled_tools, opencode_path = saved_service_config(project_root)
|
||||
token, host, port, path, enabled_tools, opencode_path, model_settings = saved_service_config(project_root)
|
||||
except RuntimeError:
|
||||
token = generate_token()
|
||||
host = DEFAULT_HOST
|
||||
@@ -571,18 +606,27 @@ def ensure_service_config(project_root: Path) -> tuple[ServicePaths, str, str, i
|
||||
path = DEFAULT_PATH
|
||||
enabled_tools = DEFAULT_ENABLED_TOOLS
|
||||
opencode_path = ""
|
||||
write_token_env_file(paths.env_path, token, host=host, port=port, path=path, enabled_tools=enabled_tools)
|
||||
return paths, token, host, port, path, enabled_tools, opencode_path
|
||||
model_settings = OpenCodeModelSettings()
|
||||
write_token_env_file(
|
||||
paths.env_path,
|
||||
token,
|
||||
host=host,
|
||||
port=port,
|
||||
path=path,
|
||||
enabled_tools=enabled_tools,
|
||||
model_settings=model_settings,
|
||||
)
|
||||
return paths, token, host, port, path, enabled_tools, opencode_path, model_settings
|
||||
|
||||
|
||||
def list_tools(project_root: Path) -> str:
|
||||
paths, _token, _host, _port, _path, enabled_tools, _opencode_path = ensure_service_config(project_root)
|
||||
paths, _token, _host, _port, _path, enabled_tools, _opencode_path, _models = ensure_service_config(project_root)
|
||||
return render_tool_status(config_path=paths.env_path, enabled_tools=enabled_tools)
|
||||
|
||||
|
||||
def set_tool_enabled(project_root: Path, tool: str, enabled: bool) -> str:
|
||||
tool_name = normalize_tool_name(tool)
|
||||
paths, token, host, port, path, enabled_tools, opencode_path = ensure_service_config(project_root)
|
||||
paths, token, host, port, path, enabled_tools, opencode_path, model_settings = ensure_service_config(project_root)
|
||||
current = set(enabled_tools)
|
||||
if enabled:
|
||||
current.add(tool_name)
|
||||
@@ -599,6 +643,7 @@ def set_tool_enabled(project_root: Path, tool: str, enabled: bool) -> str:
|
||||
path=path,
|
||||
enabled_tools=updated_tools,
|
||||
opencode_path=opencode_path,
|
||||
model_settings=model_settings,
|
||||
)
|
||||
if paths.unit_path.exists() and {TOOL_INGEST, TOOL_QUERY} & set(updated_tools):
|
||||
import_proxy_environment()
|
||||
|
||||
@@ -91,6 +91,8 @@ class McpIngestTests(unittest.TestCase):
|
||||
self.assertIn(".agents/skills/llm-wiki/SKILL.md", "\n".join(result["errors"]))
|
||||
|
||||
def test_check_opencode_readiness_smoke_test_success(self):
|
||||
self.manager.opencode_model = "openai/gpt-5.5"
|
||||
self.manager.opencode_variant = "high"
|
||||
with patch("obsidian_wiki.mcp_ingest.subprocess.run", side_effect=self._fake_smoke_success_run) as run_mock:
|
||||
result = self.manager.check_opencode_readiness(run_smoke_test=True)
|
||||
|
||||
@@ -99,6 +101,8 @@ class McpIngestTests(unittest.TestCase):
|
||||
self.assertEqual(run_mock.call_count, 3)
|
||||
smoke_args = run_mock.call_args_list[-1].args[0]
|
||||
self.assertEqual(smoke_args[:4], ["/usr/bin/opencode", "run", "--format", "json"])
|
||||
self.assertEqual(smoke_args[smoke_args.index("--model") + 1], "openai/gpt-5.5")
|
||||
self.assertEqual(smoke_args[smoke_args.index("--variant") + 1], "high")
|
||||
self.assertIn("--auto", smoke_args)
|
||||
self.assertIn("--dir", smoke_args)
|
||||
self.assertIn("MCP ingest readiness smoke test", smoke_args)
|
||||
@@ -231,6 +235,8 @@ class McpIngestTests(unittest.TestCase):
|
||||
def test_start_ingest_job_constructs_restricted_opencode_command(self):
|
||||
payload = b"source data"
|
||||
upload = self._complete_upload("source.pdf", payload)
|
||||
self.manager.opencode_model = "openai/gpt-5.5"
|
||||
self.manager.opencode_variant = "high"
|
||||
popen = FakePopen()
|
||||
|
||||
with (
|
||||
@@ -241,6 +247,8 @@ class McpIngestTests(unittest.TestCase):
|
||||
|
||||
args = popen_mock.call_args.args[0]
|
||||
self.assertEqual(args[:5], ["/usr/bin/opencode", "run", "--format", "json", "--auto"])
|
||||
self.assertEqual(args[args.index("--model") + 1], "openai/gpt-5.5")
|
||||
self.assertEqual(args[args.index("--variant") + 1], "high")
|
||||
self.assertIn("--dir", args)
|
||||
self.assertEqual(args[args.index("--dir") + 1], str(self.project))
|
||||
self.assertIn("--title", args)
|
||||
|
||||
@@ -61,6 +61,8 @@ class McpQueryTests(unittest.TestCase):
|
||||
self.assertIn(".agents/skills/llm-wiki/SKILL.md", "\n".join(result["errors"]))
|
||||
|
||||
def test_check_opencode_readiness_smoke_test_success(self):
|
||||
self.manager.opencode_model = "openai/gpt-5.5"
|
||||
self.manager.opencode_variant = "minimal"
|
||||
with patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_smoke_success_run) as run_mock:
|
||||
result = self.manager.check_opencode_readiness(run_smoke_test=True)
|
||||
|
||||
@@ -69,6 +71,8 @@ class McpQueryTests(unittest.TestCase):
|
||||
self.assertEqual(run_mock.call_count, 3)
|
||||
smoke_args = run_mock.call_args_list[-1].args[0]
|
||||
self.assertEqual(smoke_args[:4], ["/usr/bin/opencode", "run", "--format", "default"])
|
||||
self.assertEqual(smoke_args[smoke_args.index("--model") + 1], "openai/gpt-5.5")
|
||||
self.assertEqual(smoke_args[smoke_args.index("--variant") + 1], "minimal")
|
||||
self.assertIn("--auto", smoke_args)
|
||||
self.assertIn("--dir", smoke_args)
|
||||
self.assertIn("MCP query readiness smoke test", smoke_args)
|
||||
@@ -82,6 +86,8 @@ class McpQueryTests(unittest.TestCase):
|
||||
self.manager.start_query_job("what do I know?", mode="full")
|
||||
|
||||
def test_start_query_job_constructs_restricted_opencode_command(self):
|
||||
self.manager.opencode_model = "openai/gpt-5.5"
|
||||
self.manager.opencode_variant = "minimal"
|
||||
popen = FakePopen()
|
||||
|
||||
with (
|
||||
@@ -97,6 +103,8 @@ class McpQueryTests(unittest.TestCase):
|
||||
|
||||
args = popen_mock.call_args.args[0]
|
||||
self.assertEqual(args[:5], ["/usr/bin/opencode", "run", "--format", "default", "--auto"])
|
||||
self.assertEqual(args[args.index("--model") + 1], "openai/gpt-5.5")
|
||||
self.assertEqual(args[args.index("--variant") + 1], "minimal")
|
||||
self.assertIn("--dir", args)
|
||||
self.assertEqual(args[args.index("--dir") + 1], str(self.project))
|
||||
self.assertIn("--title", args)
|
||||
|
||||
@@ -425,6 +425,73 @@ class RemoteMcpServerTests(unittest.TestCase):
|
||||
self.assertIn("cancel_wiki_query", FakeMCP.tool_names)
|
||||
self.assertIn("check_query_opencode_readiness", FakeMCP.tool_names)
|
||||
|
||||
def test_file_index_status_reports_opencode_model_settings(self):
|
||||
class FakeMCP:
|
||||
tools = {}
|
||||
|
||||
def __init__(self, _name, **_kwargs):
|
||||
pass
|
||||
|
||||
def tool(self):
|
||||
def decorator(func):
|
||||
self.tools[func.__name__] = func
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def resource(self, *_args, **_kwargs):
|
||||
def decorator(func):
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def prompt(self, *_args, **_kwargs):
|
||||
def decorator(func):
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmp,
|
||||
patch.dict("sys.modules", {"mcp.server.fastmcp": type("Mod", (), {"FastMCP": FakeMCP})()}),
|
||||
):
|
||||
root = Path(tmp)
|
||||
mcp_server.create_server(
|
||||
vault_path=root / "vault",
|
||||
enable_ingest=True,
|
||||
enable_query=True,
|
||||
project_root=root / "project",
|
||||
ingest_runtime_root=root / "ingest-runtime",
|
||||
query_runtime_root=root / "query-runtime",
|
||||
ingest_opencode_model="openai/gpt-5.5",
|
||||
ingest_opencode_variant="high",
|
||||
query_opencode_model="openai/gpt-5.5",
|
||||
query_opencode_variant="minimal",
|
||||
ingest_manager=IngestManager(
|
||||
vault_path=root / "vault",
|
||||
project_root=root / "project",
|
||||
runtime_root=root / "fake-ingest",
|
||||
),
|
||||
query_manager=type(
|
||||
"FakeQueryManager",
|
||||
(),
|
||||
{
|
||||
"start_query_job": lambda self, query, mode="normal", public_only=False, note=None: {},
|
||||
"get_query_job_status": lambda self, job_id: {},
|
||||
"get_query_job_result": lambda self, job_id: {},
|
||||
"cancel_query_job": lambda self, job_id: {},
|
||||
"check_opencode_readiness": lambda self, run_smoke_test=False: {},
|
||||
},
|
||||
)(),
|
||||
)
|
||||
status = FakeMCP.tools["file_index_status"]()
|
||||
|
||||
self.assertEqual(status["enabled_tools"], ["file-assets", "ingest", "query"])
|
||||
self.assertEqual(status["ingest_opencode_model"], "openai/gpt-5.5")
|
||||
self.assertEqual(status["ingest_opencode_variant"], "high")
|
||||
self.assertEqual(status["query_opencode_model"], "openai/gpt-5.5")
|
||||
self.assertEqual(status["query_opencode_variant"], "minimal")
|
||||
|
||||
def test_create_ingest_upload_ticket_tool_returns_http_only_token(self):
|
||||
class FakeMCP:
|
||||
tools = {}
|
||||
|
||||
@@ -70,6 +70,10 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("OBSIDIAN_MCP_PATH=/mcp\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5\n", content)
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal\n", content)
|
||||
self.assertEqual(stat.S_IMODE(os.stat(env_path).st_mode), 0o600)
|
||||
self.assertEqual(stat.S_IMODE(os.stat(env_path.parent).st_mode), 0o700)
|
||||
|
||||
@@ -233,6 +237,10 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_PATH=/home/nac/.opencode/bin/opencode\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal\n", env_content)
|
||||
self.assertIn("http://10.0.0.4:9876/wiki-mcp", output)
|
||||
self.assertIn("Authorization: Bearer created-token", output)
|
||||
self.assertIn('"type": "streamable-http"', output)
|
||||
@@ -264,6 +272,14 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
+ "OBSIDIAN_MCP_INGEST_OPENCODE_PATH=/custom/bin/opencode\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
env_path.write_text(
|
||||
env_path.read_text(encoding="utf-8")
|
||||
+ "OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/custom-strong\n"
|
||||
+ "OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=max\n"
|
||||
+ "OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/custom-fast\n"
|
||||
+ "OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=low\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"XDG_CONFIG_HOME": str(config_home)}),
|
||||
@@ -279,6 +295,10 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,ingest\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=1\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_PATH=/custom/bin/opencode\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/custom-strong\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=max\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/custom-fast\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=low\n", env_content)
|
||||
|
||||
def test_start_service_can_enable_query_without_ingest(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -304,6 +324,10 @@ class McpServiceConfigTests(unittest.TestCase):
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLED_TOOLS=file-assets,query\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_ENABLE_INGEST=0\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_OPENCODE_PATH=/home/nac/.opencode/bin/opencode\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_MODEL=openai/gpt-5.5\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_INGEST_OPENCODE_VARIANT=high\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_MODEL=openai/gpt-5.5\n", env_content)
|
||||
self.assertIn("OBSIDIAN_MCP_QUERY_OPENCODE_VARIANT=minimal\n", env_content)
|
||||
self.assertNotIn("OBSIDIAN_MCP_INGEST_OPENCODE_PATH=", env_content)
|
||||
self.assertIn("Enabled tool groups:\nfile-assets,query", output)
|
||||
prepare_runtime.assert_called_once_with(project_root, uv_path=Path("/home/nac/.local/bin/uv"))
|
||||
|
||||
Reference in New Issue
Block a user