diff --git a/.env.example b/.env.example index 8deca20..7815ca0 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,31 @@ CHATMOCK_IMAGE=storagetime/chatmock:latest # Auth dir CHATGPT_LOCAL_HOME=/data +# Prompt runtime defaults for the main service +CHATMOCK_PROMPT_DIR=/app/prompts/bare +CHATMOCK_PROMPT_CONFIG=/data/prompt-config-chatmock.json + +# Prompt runtime defaults for the ClawMem service +CHATMOCK_CLAWMEM_PROMPT_DIR=/app/prompts/clawmem +CHATMOCK_CLAWMEM_PROMPT_CONFIG=/data/prompt-config-chatmock-clawmem.json + +# Optional admin token for /admin/prompts endpoints +# CHATMOCK_ADMIN_TOKEN=change-me +# Optional comma-separated trusted admin IPs/CIDRs +# CHATMOCK_ADMIN_TRUSTED_IPS=172.17.0.1,10.0.0.0/8 +# Allow non-local admin access only when paired with CHATMOCK_ADMIN_TOKEN +# CHATMOCK_ALLOW_ADMIN_EXTERNAL=false + +# Isolated compose test stack defaults +CHATMOCK_TEST_PORT=18000 +CHATMOCK_TEST_CLAWMEM_PORT=18001 +CHATMOCK_TEST_LOGIN_PORT=11455 +CHATMOCK_TEST_AUTH_VOLUME=chatmock_chatmock_data +CHATMOCK_TEST_PROMPT_DIR=/app/prompts/bare +CHATMOCK_TEST_PROMPT_CONFIG=/data/prompt-config-chatmock-test.json +CHATMOCK_TEST_CLAWMEM_PROMPT_DIR=/app/prompts/clawmem +CHATMOCK_TEST_CLAWMEM_PROMPT_CONFIG=/data/prompt-config-chatmock-clawmem-test.json + # show request/stream logs VERBOSE=false diff --git a/DOCKER.md b/DOCKER.md index c483d63..c963cdc 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -16,6 +16,100 @@ 4) Free to use it in whichever chat app you like! +## Live prompt switching + +The compose stack now mounts the whole `./prompts` tree into `/app/prompts` and each service keeps its own runtime prompt config file under `/data`. + +Default runtime prompt selection: + +- `chatmock` uses `${CHATMOCK_PROMPT_DIR:-/app/prompts/bare}` +- `chatmock-clawmem` uses `${CHATMOCK_CLAWMEM_PROMPT_DIR:-/app/prompts/clawmem}` + +Live prompt controls are local-only: + +- `GET /admin/prompts` +- `POST /admin/prompts/reload` +- `POST /admin/prompts/config` + +If `CHATMOCK_ADMIN_TOKEN` is configured, include the header: + +```bash +-H "X-ChatMock-Admin-Token: $CHATMOCK_ADMIN_TOKEN" +``` + +By default the admin endpoints only accept: + +- loopback clients +- known Docker host gateway addresses such as `172.17.0.1` and `172.18.0.1` + +If your Docker/network setup uses a different host address, set: + +```bash +CHATMOCK_ALLOW_ADMIN_EXTERNAL=true +``` + +and rely on `CHATMOCK_ADMIN_TOKEN` for access control. External admin access is rejected unless a non-empty `CHATMOCK_ADMIN_TOKEN` is configured. + +For non-standard container networks, you can also trust specific admin IPs or CIDR ranges: + +```bash +CHATMOCK_ADMIN_TRUSTED_IPS=172.17.0.1,10.0.0.0/8 +``` + +Example: switch the main service to the ClawMem prompt directory without restarting the container: + +```bash +curl -sS -X POST http://127.0.0.1:8000/admin/prompts/config \ + -H "X-ChatMock-Admin-Token: $CHATMOCK_ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"prompt_dir":"/app/prompts/clawmem"}' | jq . +``` + +Example: reload prompt file contents after editing files in the mounted directory: + +```bash +curl -sS -X POST http://127.0.0.1:8000/admin/prompts/reload \ + -H "X-ChatMock-Admin-Token: $CHATMOCK_ADMIN_TOKEN" | jq . +``` + +## Isolated test stack + +Use the override file to bring up a disposable stack on separate ports without touching the live services or their `/data` volume. + +Bring it up: + +```bash +docker compose -f docker-compose.yml -f docker-compose.test.yml -p chatmock-test up -d chatmock chatmock-clawmem +``` + +Verify it: + +```bash +curl -fsS http://127.0.0.1:18000/health +curl -fsS http://127.0.0.1:18001/health +``` + +Tear it down completely: + +```bash +docker compose -f docker-compose.yml -f docker-compose.test.yml -p chatmock-test down -v +``` + +The override isolates: + +- ports: `18000`, `18001`, `11455` +- container names: `chatmock-test`, `chatmock-clawmem-test` +- prompt runtime config files under the test stack's own `/data` +- writable runtime state in the test stack's own `chatmock-test_chatmock_data` volume + +The override also mounts the live stack's auth volume read-only at `/live-auth` and sets `CODEX_HOME=/live-auth`, so the test services can reuse existing auth without sharing the live stack's writable runtime state. + +If your live stack uses a different Compose project name, set the external auth volume explicitly: + +```bash +CHATMOCK_TEST_AUTH_VOLUME=_chatmock_data +``` + ## Configuration Set options in `.env` or pass environment variables: - `PORT`: Container listening port (default 8000) @@ -28,6 +122,21 @@ Set options in `.env` or pass environment variables: - `CHATGPT_LOCAL_CLIENT_ID`: OAuth client id override (rarely needed) - `CHATGPT_LOCAL_EXPOSE_REASONING_MODELS`: `true|false` to add reasoning model variants to `/v1/models` - `CHATGPT_LOCAL_ENABLE_WEB_SEARCH`: `true|false` to enable default web search tool +- `CHATMOCK_PROMPT_DIR`: default prompt directory for the main service +- `CHATMOCK_PROMPT_CONFIG`: runtime prompt config file for the main service +- `CHATMOCK_CLAWMEM_PROMPT_DIR`: default prompt directory for the ClawMem service +- `CHATMOCK_CLAWMEM_PROMPT_CONFIG`: runtime prompt config file for the ClawMem service +- `CHATMOCK_ADMIN_TOKEN`: optional token required by the `/admin/prompts/*` endpoints when set +- `CHATMOCK_ADMIN_TRUSTED_IPS`: optional comma-separated trusted admin IPs/CIDRs for non-standard local container networks +- `CHATMOCK_ALLOW_ADMIN_EXTERNAL`: allow non-local admin access only when paired with `CHATMOCK_ADMIN_TOKEN` +- `CHATMOCK_TEST_PORT`: main service port for the isolated test stack +- `CHATMOCK_TEST_CLAWMEM_PORT`: ClawMem service port for the isolated test stack +- `CHATMOCK_TEST_LOGIN_PORT`: login callback port for the isolated test stack +- `CHATMOCK_TEST_AUTH_VOLUME`: external live auth volume name mounted read-only into the isolated test stack +- `CHATMOCK_TEST_PROMPT_DIR`: main service prompt dir in the isolated test stack +- `CHATMOCK_TEST_PROMPT_CONFIG`: main service runtime prompt config in the isolated test stack +- `CHATMOCK_TEST_CLAWMEM_PROMPT_DIR`: ClawMem prompt dir in the isolated test stack +- `CHATMOCK_TEST_CLAWMEM_PROMPT_CONFIG`: ClawMem runtime prompt config in the isolated test stack ## Logs Set `VERBOSE=true` to include extra logging for troubleshooting upstream or chat app requests. Please include and use these logs when submitting bug reports. diff --git a/chatmock/app.py b/chatmock/app.py index c7648e7..2f45611 100644 --- a/chatmock/app.py +++ b/chatmock/app.py @@ -1,9 +1,14 @@ from __future__ import annotations -from flask import Flask, jsonify +import hmac +import ipaddress +import os + +from flask import Flask, jsonify, request from flask_sock import Sock +from werkzeug.exceptions import BadRequest, UnsupportedMediaType -from .config import BASE_INSTRUCTIONS, GPT5_CODEX_INSTRUCTIONS +from .config import get_prompt_manager from .http import build_cors_headers from .routes_openai import openai_bp from .routes_ollama import ollama_bp @@ -21,8 +26,16 @@ def create_app( expose_reasoning_models: bool = False, default_web_search: bool = False, inject_default_instructions: bool = True, + prompt_dir: str | None = None, + prompt_config_path: str | None = None, + admin_token: str | None = None, ) -> Flask: app = Flask(__name__) + prompt_manager = get_prompt_manager( + prompt_dir=prompt_dir, + prompt_config_path=prompt_config_path, + reset=True, + ) app.config.update( VERBOSE=bool(verbose), @@ -32,11 +45,15 @@ def create_app( REASONING_COMPAT=reasoning_compat, FAST_MODE=bool(fast_mode), DEBUG_MODEL=debug_model, - BASE_INSTRUCTIONS=BASE_INSTRUCTIONS, - GPT5_CODEX_INSTRUCTIONS=GPT5_CODEX_INSTRUCTIONS, EXPOSE_REASONING_MODELS=bool(expose_reasoning_models), DEFAULT_WEB_SEARCH=bool(default_web_search), INJECT_DEFAULT_INSTRUCTIONS=bool(inject_default_instructions), + PROMPT_MANAGER=prompt_manager, + ADMIN_TOKEN=( + admin_token + if isinstance(admin_token, str) and admin_token + else os.getenv("CHATMOCK_ADMIN_TOKEN") or os.getenv("CHATGPT_LOCAL_ADMIN_TOKEN") or None + ), ) @app.get("/") @@ -44,6 +61,87 @@ def create_app( def health(): return jsonify({"status": "ok"}) + def _require_local_admin(): + remote_addr = request.remote_addr + allow_admin_external = os.getenv("CHATMOCK_ALLOW_ADMIN_EXTERNAL", "false").lower() == "true" + allowed_local_addresses = {"127.0.0.1", "::1"} + allowed_bridge_addresses = {"172.17.0.1", "172.18.0.1"} + trusted_remote_spec = os.getenv("CHATMOCK_ADMIN_TRUSTED_IPS", "").strip() + + def _is_in_trusted_ranges(value: str | None) -> bool: + if not isinstance(value, str) or not value or not trusted_remote_spec: + return False + try: + remote_ip = ipaddress.ip_address(value) + except ValueError: + return False + for token in (item.strip() for item in trusted_remote_spec.split(",")): + if not token: + continue + try: + if "/" in token: + if remote_ip in ipaddress.ip_network(token, strict=False): + return True + elif remote_ip == ipaddress.ip_address(token): + return True + except ValueError: + continue + return False + + is_allowed_bridge = isinstance(remote_addr, str) and remote_addr in allowed_bridge_addresses + is_local = isinstance(remote_addr, str) and remote_addr in allowed_local_addresses + is_trusted_remote = _is_in_trusted_ranges(remote_addr) + if not (is_local or is_allowed_bridge or is_trusted_remote or allow_admin_external): + return jsonify({"error": {"message": "Admin routes are local-only"}}), 403 + expected_token = app.config.get("ADMIN_TOKEN") + provided_token = ( + request.headers.get("X-ChatMock-Admin-Token") + or request.headers.get("X-Admin-Token") + or request.headers.get("Authorization", "").removeprefix("Bearer ").strip() + ) + if allow_admin_external and not (is_local or is_allowed_bridge or is_trusted_remote): + if not (isinstance(expected_token, str) and expected_token): + return jsonify({"error": {"message": "External admin access requires CHATMOCK_ADMIN_TOKEN"}}), 403 + if isinstance(expected_token, str) and expected_token: + if not isinstance(provided_token, str) or not hmac.compare_digest(provided_token, expected_token): + return jsonify({"error": {"message": "Invalid admin token"}}), 403 + return None + + @app.get("/admin/prompts") + def admin_prompts_state(): + denied = _require_local_admin() + if denied is not None: + return denied + return jsonify(prompt_manager.as_dict()) + + @app.post("/admin/prompts/reload") + def admin_prompts_reload(): + denied = _require_local_admin() + if denied is not None: + return denied + try: + prompt_manager.reload() + except (FileNotFoundError, ValueError, OSError, PermissionError) as exc: + return jsonify({"error": {"message": str(exc)}}), 400 + return jsonify(prompt_manager.as_dict()) + + @app.post("/admin/prompts/config") + def admin_prompts_config(): + denied = _require_local_admin() + if denied is not None: + return denied + try: + payload = request.get_json(silent=False) + except (BadRequest, UnsupportedMediaType): + return jsonify({"error": {"message": "Invalid JSON body"}}), 400 + if not isinstance(payload, dict): + return jsonify({"error": {"message": "Invalid JSON body"}}), 400 + try: + prompt_manager.update_config(payload) + except (FileNotFoundError, ValueError, OSError, PermissionError) as exc: + return jsonify({"error": {"message": str(exc)}}), 400 + return jsonify(prompt_manager.as_dict()) + @app.after_request def _cors(resp): for k, v in build_cors_headers().items(): diff --git a/chatmock/config.py b/chatmock/config.py index dc5ca81..f13eb14 100644 --- a/chatmock/config.py +++ b/chatmock/config.py @@ -1,8 +1,14 @@ from __future__ import annotations +import json import os import sys +import tempfile +import threading +import time +from dataclasses import dataclass from pathlib import Path +from typing import Any CLIENT_ID_DEFAULT = os.getenv("CHATGPT_LOCAL_CLIENT_ID") or "app_EMoamEEZ73f0CkXaXp7hrann" @@ -12,35 +18,296 @@ CHATGPT_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses" -def _read_prompt_text(filename: str) -> str | None: - candidates = [ +def prompt_state_home() -> Path: + home = os.getenv("CHATGPT_LOCAL_HOME") or os.getenv("CODEX_HOME") + if not home: + home = os.path.expanduser("~/.chatgpt-local") + return Path(home) + + +def default_prompt_config_path() -> Path: + override = os.getenv("CHATMOCK_PROMPT_CONFIG") or os.getenv("CHATGPT_LOCAL_PROMPT_CONFIG") + if isinstance(override, str) and override.strip(): + return Path(override.strip()) + return prompt_state_home() / "prompt-config.json" + + +def _candidate_prompt_paths(filename: str) -> list[Path]: + return [ Path(__file__).parent.parent / filename, Path(__file__).parent / filename, Path(getattr(sys, "_MEIPASS", "")) / filename if getattr(sys, "_MEIPASS", None) else None, Path.cwd() / filename, ] - for candidate in candidates: + + +def _read_prompt_text(path: Path) -> str: + content = path.read_text(encoding="utf-8") + if not isinstance(content, str) or not content.strip(): + raise ValueError(f"Prompt file {path} is empty") + return content + + +def _resolve_static_prompt_config() -> dict[str, str | None]: + explicit_prompt_dir = os.getenv("CHATMOCK_PROMPT_DIR") or os.getenv("CHATGPT_LOCAL_PROMPT_DIR") + explicit_base = os.getenv("CHATMOCK_PROMPT_BASE_PATH") or os.getenv("CHATGPT_LOCAL_PROMPT_BASE_PATH") + explicit_codex = os.getenv("CHATMOCK_PROMPT_CODEX_PATH") or os.getenv("CHATGPT_LOCAL_PROMPT_CODEX_PATH") + if isinstance(explicit_prompt_dir, str) and explicit_prompt_dir.strip(): + prompt_dir = Path(explicit_prompt_dir.strip()).expanduser() + base_path = prompt_dir / "prompt.md" + codex_path = prompt_dir / "prompt_gpt5_codex.md" + return { + "prompt_dir": str(prompt_dir), + "base_prompt_path": str(base_path), + "codex_prompt_path": str(codex_path if codex_path.exists() else base_path), + } + if isinstance(explicit_base, str) and explicit_base.strip(): + base_path = Path(explicit_base.strip()).expanduser() + codex_path = ( + Path(explicit_codex.strip()).expanduser() + if isinstance(explicit_codex, str) and explicit_codex.strip() + else base_path + ) + return { + "prompt_dir": str(base_path.parent), + "base_prompt_path": str(base_path), + "codex_prompt_path": str(codex_path), + } + for candidate in _candidate_prompt_paths("prompt.md"): if not candidate: continue + codex_candidate = candidate.with_name("prompt_gpt5_codex.md") + if candidate.exists(): + return { + "prompt_dir": str(candidate.parent), + "base_prompt_path": str(candidate), + "codex_prompt_path": str(codex_candidate if codex_candidate.exists() else candidate), + } + raise FileNotFoundError("Failed to locate prompt.md via env, package paths, or CWD.") + + +@dataclass(frozen=True) +class PromptConfigState: + prompt_dir: str | None + base_prompt_path: str + codex_prompt_path: str + loaded_at: float + + +class PromptManager: + def __init__( + self, + *, + prompt_dir: str | None = None, + base_prompt_path: str | None = None, + codex_prompt_path: str | None = None, + prompt_config_path: str | None = None, + ) -> None: + self._lock = threading.RLock() + self._config_path = Path(prompt_config_path) if prompt_config_path else default_prompt_config_path() + self._state: PromptConfigState | None = None + self._base_instructions = "" + self._codex_instructions = "" + self._initial_defaults = { + "prompt_dir": prompt_dir, + "base_prompt_path": base_prompt_path, + "codex_prompt_path": codex_prompt_path, + } + self.reload() + + def _resolve_legacy_defaults(self) -> dict[str, str | None]: + return self._normalize_config(_resolve_static_prompt_config()) + + def _load_persisted_config(self) -> dict[str, Any] | None: + if not self._config_path.exists(): + return None try: - if candidate.exists(): - content = candidate.read_text(encoding="utf-8") - if isinstance(content, str) and content.strip(): - return content - except Exception: - continue - return None + data = json.loads(self._config_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ValueError(f"Prompt config at {self._config_path} is not valid JSON: {exc}") from exc + if not isinstance(data, dict): + raise ValueError(f"Prompt config at {self._config_path} is not a JSON object") + return data + + def _normalize_config(self, config: dict[str, Any]) -> dict[str, str | None]: + prompt_dir = config.get("prompt_dir") + base_prompt_path = config.get("base_prompt_path") + codex_prompt_path = config.get("codex_prompt_path") + + normalized_dir = str(Path(prompt_dir).expanduser()) if isinstance(prompt_dir, str) and prompt_dir.strip() else None + normalized_base = str(Path(base_prompt_path).expanduser()) if isinstance(base_prompt_path, str) and base_prompt_path.strip() else None + normalized_codex = str(Path(codex_prompt_path).expanduser()) if isinstance(codex_prompt_path, str) and codex_prompt_path.strip() else None + + if normalized_dir: + normalized_base = normalized_base or str(Path(normalized_dir) / "prompt.md") + codex_candidate = Path(normalized_dir) / "prompt_gpt5_codex.md" + normalized_codex = normalized_codex or (str(codex_candidate) if codex_candidate.exists() else None) + + if not normalized_base: + raise ValueError("Prompt config requires prompt_dir or base_prompt_path") + if not normalized_codex: + normalized_codex = normalized_base + + base_path = Path(normalized_base) + codex_path = Path(normalized_codex) + if not base_path.exists(): + raise FileNotFoundError(f"Base prompt file not found: {base_path}") + if not codex_path.exists(): + raise FileNotFoundError(f"Codex prompt file not found: {codex_path}") + + return { + "prompt_dir": normalized_dir, + "base_prompt_path": str(base_path), + "codex_prompt_path": str(codex_path), + } + + def _write_config(self, config: dict[str, str | None]) -> None: + self._config_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "prompt_dir": config.get("prompt_dir"), + "base_prompt_path": config["base_prompt_path"], + "codex_prompt_path": config["codex_prompt_path"], + } + with tempfile.NamedTemporaryFile( + "w", + dir=self._config_path.parent, + delete=False, + encoding="utf-8", + ) as temp_file: + temp_file.write(json.dumps(payload, indent=2) + "\n") + temp_path = Path(temp_file.name) + temp_path.replace(self._config_path) + + def _load_prompt_contents(self, normalized: dict[str, str | None]) -> tuple[str, str]: + base_text = _read_prompt_text(Path(normalized["base_prompt_path"])) + codex_text = _read_prompt_text(Path(normalized["codex_prompt_path"])) + return base_text, codex_text + + def _set_loaded_state( + self, + normalized: dict[str, str | None], + base_text: str, + codex_text: str, + ) -> PromptConfigState: + self._base_instructions = base_text + self._codex_instructions = codex_text + self._state = PromptConfigState( + prompt_dir=normalized.get("prompt_dir"), + base_prompt_path=normalized["base_prompt_path"], + codex_prompt_path=normalized["codex_prompt_path"], + loaded_at=time.time(), + ) + return self._state + + def reload(self, *, save_defaults: bool = False) -> PromptConfigState: + with self._lock: + persisted = self._load_persisted_config() + if persisted is None: + seed = {k: v for k, v in self._initial_defaults.items() if isinstance(v, str) and v.strip()} + if seed: + normalized = self._normalize_config(seed) + else: + normalized = self._resolve_legacy_defaults() + if save_defaults: + self._write_config(normalized) + else: + normalized = self._normalize_config(persisted) + base_text, codex_text = self._load_prompt_contents(normalized) + return self._set_loaded_state(normalized, base_text, codex_text) + + def persist_defaults(self) -> PromptConfigState: + with self._lock: + state = self.reload(save_defaults=False) + self._write_config( + { + "prompt_dir": state.prompt_dir, + "base_prompt_path": state.base_prompt_path, + "codex_prompt_path": state.codex_prompt_path, + } + ) + return state + + def update_config(self, config: dict[str, Any]) -> PromptConfigState: + with self._lock: + current = self.get_state() + prompt_dir = config.get("prompt_dir", current.prompt_dir) + if "prompt_dir" in config and "base_prompt_path" not in config: + base_prompt_path = None + else: + base_prompt_path = config.get("base_prompt_path", current.base_prompt_path) + if "prompt_dir" in config and "codex_prompt_path" not in config: + codex_prompt_path = None + else: + codex_prompt_path = config.get("codex_prompt_path", current.codex_prompt_path) + merged = { + "prompt_dir": prompt_dir, + "base_prompt_path": base_prompt_path, + "codex_prompt_path": codex_prompt_path, + } + normalized = self._normalize_config(merged) + base_text, codex_text = self._load_prompt_contents(normalized) + self._write_config(normalized) + return self._set_loaded_state(normalized, base_text, codex_text) + + def get_state(self) -> PromptConfigState: + with self._lock: + if self._state is None: + return self.reload() + return self._state + + def get_base_instructions(self) -> str: + with self._lock: + if not self._base_instructions: + self.reload() + return self._base_instructions + + def get_codex_instructions(self) -> str: + with self._lock: + if not self._codex_instructions: + self.reload() + return self._codex_instructions + + def as_dict(self) -> dict[str, Any]: + state = self.get_state() + return { + "prompt_dir": state.prompt_dir, + "base_prompt_path": state.base_prompt_path, + "codex_prompt_path": state.codex_prompt_path, + "loaded_at": state.loaded_at, + "prompt_config_path": str(self._config_path), + } + + +_PROMPT_MANAGER: PromptManager | None = None + + +def get_prompt_manager( + *, + prompt_dir: str | None = None, + base_prompt_path: str | None = None, + codex_prompt_path: str | None = None, + prompt_config_path: str | None = None, + reset: bool = False, +) -> PromptManager: + global _PROMPT_MANAGER + if reset or _PROMPT_MANAGER is None: + _PROMPT_MANAGER = PromptManager( + prompt_dir=prompt_dir, + base_prompt_path=base_prompt_path, + codex_prompt_path=codex_prompt_path, + prompt_config_path=prompt_config_path, + ) + return _PROMPT_MANAGER def read_base_instructions() -> str: - content = _read_prompt_text("prompt.md") - if content is None: - raise FileNotFoundError("Failed to read prompt.md; expected adjacent to package or CWD.") - return content + static_config = _resolve_static_prompt_config() + return _read_prompt_text(Path(static_config["base_prompt_path"])) def read_gpt5_codex_instructions(fallback: str) -> str: - content = _read_prompt_text("prompt_gpt5_codex.md") + static_config = _resolve_static_prompt_config() + content = _read_prompt_text(Path(static_config["codex_prompt_path"])) return content if isinstance(content, str) and content.strip() else fallback diff --git a/chatmock/reasoning.py b/chatmock/reasoning.py index 37c276c..06b43b8 100644 --- a/chatmock/reasoning.py +++ b/chatmock/reasoning.py @@ -36,6 +36,36 @@ def build_reasoning_param( return reasoning +def resolve_request_reasoning_param( + payload: Dict[str, Any], + *, + requested_model: str | None, + base_effort: str = "medium", + base_summary: str = "auto", + allowed_efforts: frozenset[str] | None = None, +) -> Dict[str, Any]: + reasoning_overrides = ( + payload.get("reasoning") + if isinstance(payload.get("reasoning"), dict) + else extract_reasoning_from_model_name(requested_model) + ) + + standard_effort = payload.get("reasoning_effort") + valid_efforts = allowed_efforts or DEFAULT_REASONING_EFFORTS + normalized_standard_effort = standard_effort.strip().lower() if isinstance(standard_effort, str) else "" + if normalized_standard_effort in valid_efforts: + merged_overrides = dict(reasoning_overrides) if isinstance(reasoning_overrides, dict) else {} + merged_overrides["effort"] = normalized_standard_effort + reasoning_overrides = merged_overrides + + return build_reasoning_param( + base_effort, + base_summary, + reasoning_overrides, + allowed_efforts=allowed_efforts, + ) + + def apply_reasoning_to_message( message: Dict[str, Any], reasoning_summary_text: str, diff --git a/chatmock/responses_api.py b/chatmock/responses_api.py index fbf5b54..4eb049a 100644 --- a/chatmock/responses_api.py +++ b/chatmock/responses_api.py @@ -37,9 +37,18 @@ class NormalizedResponsesRequest: def instructions_for_model(config: Dict[str, Any], model: str) -> str: - base = config.get("BASE_INSTRUCTIONS", BASE_INSTRUCTIONS) + prompt_manager = config.get("PROMPT_MANAGER") + base = config.get("BASE_INSTRUCTIONS") + if (not isinstance(base, str) or not base.strip()) and hasattr(prompt_manager, "get_base_instructions"): + base = prompt_manager.get_base_instructions() + if not isinstance(base, str) or not base.strip(): + base = BASE_INSTRUCTIONS if uses_codex_instructions(model): - codex = config.get("GPT5_CODEX_INSTRUCTIONS") or GPT5_CODEX_INSTRUCTIONS + codex = config.get("GPT5_CODEX_INSTRUCTIONS") + if (not isinstance(codex, str) or not codex.strip()) and hasattr(prompt_manager, "get_codex_instructions"): + codex = prompt_manager.get_codex_instructions() + if not isinstance(codex, str) or not codex.strip(): + codex = GPT5_CODEX_INSTRUCTIONS if isinstance(codex, str) and codex.strip(): return codex return base diff --git a/chatmock/routes_openai.py b/chatmock/routes_openai.py index 71e307d..830ac46 100644 --- a/chatmock/routes_openai.py +++ b/chatmock/routes_openai.py @@ -23,6 +23,7 @@ apply_reasoning_to_message, build_reasoning_param, extract_reasoning_from_model_name, + resolve_request_reasoning_param, ) from .session import ( clear_responses_reuse_state, @@ -199,12 +200,11 @@ def chat_completions() -> Response: {"type": "message", "role": "user", "content": [{"type": "input_text", "text": payload.get("prompt")}]} ] - model_reasoning = extract_reasoning_from_model_name(requested_model) - reasoning_overrides = payload.get("reasoning") if isinstance(payload.get("reasoning"), dict) else model_reasoning - reasoning_param = build_reasoning_param( - reasoning_effort, - reasoning_summary, - reasoning_overrides, + reasoning_param = resolve_request_reasoning_param( + payload, + requested_model=requested_model, + base_effort=reasoning_effort, + base_summary=reasoning_summary, allowed_efforts=allowed_efforts_for_model(model), ) service_tier, tier_error = _service_tier_from_payload(model, payload, verbose=verbose) diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..fc1bcbd --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,31 @@ +services: + chatmock: + container_name: ${CHATMOCK_TEST_CONTAINER_NAME:-chatmock-test} + environment: + - CODEX_HOME=/live-auth + - CHATGPT_LOCAL_PROMPT_DIR=${CHATMOCK_TEST_PROMPT_DIR:-/app/prompts/bare} + - CHATGPT_LOCAL_PROMPT_CONFIG=${CHATMOCK_TEST_PROMPT_CONFIG:-/data/prompt-config-chatmock-test.json} + ports: !override + - "${CHATMOCK_TEST_BIND_HOST:-127.0.0.1}:${CHATMOCK_TEST_PORT:-18000}:8000" + volumes: + - chatmock_live_auth:/live-auth:ro + + chatmock-clawmem: + container_name: ${CHATMOCK_TEST_CLAWMEM_CONTAINER_NAME:-chatmock-clawmem-test} + environment: + - CODEX_HOME=/live-auth + - CHATGPT_LOCAL_PROMPT_DIR=${CHATMOCK_TEST_CLAWMEM_PROMPT_DIR:-/app/prompts/clawmem} + - CHATGPT_LOCAL_PROMPT_CONFIG=${CHATMOCK_TEST_CLAWMEM_PROMPT_CONFIG:-/data/prompt-config-chatmock-clawmem-test.json} + ports: !override + - "${CHATMOCK_TEST_CLAWMEM_BIND_HOST:-127.0.0.1}:${CHATMOCK_TEST_CLAWMEM_PORT:-18001}:8000" + volumes: + - chatmock_live_auth:/live-auth:ro + + chatmock-login: + ports: !override + - "${CHATMOCK_TEST_BIND_HOST:-127.0.0.1}:${CHATMOCK_TEST_LOGIN_PORT:-11455}:1455" + +volumes: + chatmock_live_auth: + external: true + name: ${CHATMOCK_TEST_AUTH_VOLUME:-chatmock_chatmock_data} diff --git a/docker-compose.yml b/docker-compose.yml index 225551a..56ec364 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,12 +6,14 @@ services: env_file: .env environment: - CHATGPT_LOCAL_HOME=${CHATGPT_LOCAL_HOME:-/data} + - CHATGPT_LOCAL_PROMPT_DIR=${CHATMOCK_PROMPT_DIR:-/app/prompts/bare} + - CHATGPT_LOCAL_PROMPT_CONFIG=${CHATMOCK_PROMPT_CONFIG:-/data/prompt-config-chatmock.json} + - CHATMOCK_ADMIN_TOKEN=${CHATMOCK_ADMIN_TOKEN:-} ports: - "${CHATMOCK_BIND_HOST:-127.0.0.1}:${PORT:-8000}:8000" volumes: - chatmock_data:${CHATGPT_LOCAL_HOME:-/data} - - ./prompts/bare/prompt.md:/app/prompt.md:ro - - ./prompts/bare/prompt_gpt5_codex.md:/app/prompt_gpt5_codex.md:ro + - ./prompts:/app/prompts:ro healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health').status==200 else 1)\" "] interval: 10s @@ -26,12 +28,14 @@ services: env_file: .env environment: - CHATGPT_LOCAL_HOME=${CHATGPT_LOCAL_HOME:-/data} + - CHATGPT_LOCAL_PROMPT_DIR=${CHATMOCK_CLAWMEM_PROMPT_DIR:-/app/prompts/clawmem} + - CHATGPT_LOCAL_PROMPT_CONFIG=${CHATMOCK_CLAWMEM_PROMPT_CONFIG:-/data/prompt-config-chatmock-clawmem.json} + - CHATMOCK_ADMIN_TOKEN=${CHATMOCK_ADMIN_TOKEN:-} ports: - "${CHATMOCK_CLAWMEM_BIND_HOST:-127.0.0.1}:${CHATMOCK_CLAWMEM_PORT:-8001}:8000" volumes: - chatmock_data:${CHATGPT_LOCAL_HOME:-/data} - - ./prompts/clawmem/prompt.md:/app/prompt.md:ro - - ./prompts/clawmem/prompt_gpt5_codex.md:/app/prompt_gpt5_codex.md:ro + - ./prompts:/app/prompts:ro healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health').status==200 else 1)\" "] interval: 10s diff --git a/docs/superpowers/plans/2026-04-22-live-prompt-reload.md b/docs/superpowers/plans/2026-04-22-live-prompt-reload.md new file mode 100644 index 0000000..74ed67c --- /dev/null +++ b/docs/superpowers/plans/2026-04-22-live-prompt-reload.md @@ -0,0 +1,189 @@ +# Live Prompt Reload Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a local-only admin HTTP API that can switch and reload prompt paths live, using a file-backed runtime prompt config and cached prompt contents without restarting the ChatMock process. + +**Architecture:** Introduce a prompt manager in `chatmock/config.py` that owns service-specific runtime prompt config, cached prompt text, and reload/update operations. Wire request-time instruction resolution through that manager, add loopback-only admin routes in `chatmock/app.py`, and update Docker wiring so each service mounts a stable prompt root plus its own runtime config path under `/data`. + +**Tech Stack:** Flask, unittest, tempfile, Docker Compose + +--- + +## File Structure + +- Modify `chatmock/config.py` + - Add the prompt manager, runtime config persistence, validation, and cache reload logic. +- Modify `chatmock/app.py` + - Register the prompt manager on the app and add the local-only admin routes. +- Modify `chatmock/responses_api.py` + - Resolve instructions from the prompt manager cache instead of startup-loaded globals. +- Modify `tests/test_routes.py` + - Add route-level coverage for inspect, reload, config update, and local-only guard behavior. +- Modify `docker-compose.yml` + - Replace direct prompt-file mounts with a stable prompts-root mount and distinct runtime config env vars per service. +- Modify `.env.example` + - Document the new prompt runtime env vars. +- Modify `DOCKER.md` + - Document how live prompt config and reload work. + +### Task 1: Prompt manager and runtime config + +**Files:** +- Modify: `chatmock/config.py` + +- [ ] **Step 1: Write the failing tests for runtime prompt management** + +Add route tests that exercise: +- live reload after editing prompt files +- config update to a different prompt directory +- rejection of invalid prompt paths + +- [ ] **Step 2: Run the targeted tests to verify they fail** + +Run: + +```bash +./.venv/bin/python -m pytest tests/test_routes.py -k "admin_prompts" -v +``` + +Expected: FAIL because no prompt admin routes or runtime prompt manager exist yet. + +- [ ] **Step 3: Implement the prompt manager** + +Add a manager that: +- loads defaults from env or legacy prompt resolution +- persists service-specific runtime config to `${CHATMOCK_PROMPT_CONFIG}` and `${CHATMOCK_CLAWMEM_PROMPT_CONFIG}` via the compose-mapped runtime env +- caches prompt text in memory +- exposes `get_state()`, `reload()`, and `update_config(...)` + +- [ ] **Step 4: Run the targeted tests again** + +Run: + +```bash +./.venv/bin/python -m pytest tests/test_routes.py -k "admin_prompts" -v +``` + +Expected: still FAIL until the admin routes are wired. + +### Task 2: Admin endpoints and request-path integration + +**Files:** +- Modify: `chatmock/app.py` +- Modify: `chatmock/responses_api.py` +- Modify: `tests/test_routes.py` + +- [ ] **Step 1: Add failing route tests for the admin API** + +Cover: +- `GET /admin/prompts` returns current config and cache metadata +- `POST /admin/prompts/reload` refreshes cached prompt text +- `POST /admin/prompts/config` switches prompt directories live +- non-loopback access is rejected + +- [ ] **Step 2: Run those route tests to confirm failure** + +Run: + +```bash +./.venv/bin/python -m pytest tests/test_routes.py -k "admin_prompts or prompt_reload" -v +``` + +Expected: FAIL with `404` or missing behavior. + +- [ ] **Step 3: Implement the admin routes and integration** + +Wire: +- one shared prompt manager into `create_app()` +- loopback-only guard in `app.py` +- `resolve_effective_instructions(...)` to use cached prompt text from the prompt manager + +- [ ] **Step 4: Run the focused route tests** + +Run: + +```bash +./.venv/bin/python -m pytest tests/test_routes.py -k "admin_prompts or prompt_reload" -v +``` + +Expected: PASS + +### Task 3: Docker/runtime wiring + +**Files:** +- Modify: `docker-compose.yml` +- Modify: `.env.example` +- Modify: `DOCKER.md` + +- [ ] **Step 1: Update compose to mount the whole prompts tree** + +Replace direct prompt file mounts with a stable root mount: + +```yaml +- ./prompts:/app/prompts:ro +``` + +and set per-service env defaults such as: + +```yaml +- CHATMOCK_PROMPT_DIR=/app/prompts/bare +- CHATMOCK_PROMPT_CONFIG=/data/prompt-config-chatmock.json +``` + +and for the ClawMem service: + +```yaml +- CHATMOCK_CLAWMEM_PROMPT_DIR=/app/prompts/clawmem +- CHATMOCK_CLAWMEM_PROMPT_CONFIG=/data/prompt-config-clawmem.json +``` + +- [ ] **Step 2: Document the new env vars** + +Add to `.env.example` and `DOCKER.md`: +- `CHATMOCK_PROMPT_DIR` +- `CHATMOCK_PROMPT_CONFIG` +- `CHATMOCK_CLAWMEM_PROMPT_DIR` +- `CHATMOCK_CLAWMEM_PROMPT_CONFIG` +- optional admin token env if implemented + +- [ ] **Step 3: Verify the docs/config slice** + +Run: + +```bash +git diff -- docker-compose.yml .env.example DOCKER.md +``` + +Expected: prompt path control is documented and each service has a distinct runtime config file. + +### Task 4: Full verification + +**Files:** +- Test: `tests/test_routes.py` + +- [ ] **Step 1: Run the focused prompt-admin slice** + +```bash +./.venv/bin/python -m pytest tests/test_routes.py -k "admin_prompts or prompt_reload" -v +``` + +- [ ] **Step 2: Run the full route regression module** + +```bash +./.venv/bin/python -m pytest tests/test_routes.py -v +``` + +- [ ] **Step 3: Verify no unintended branch drift** + +```bash +git status --short +git diff -- chatmock/config.py chatmock/app.py chatmock/responses_api.py tests/test_routes.py docker-compose.yml .env.example DOCKER.md +``` + +- [ ] **Step 4: Commit** + +```bash +git add chatmock/config.py chatmock/app.py chatmock/responses_api.py tests/test_routes.py docker-compose.yml .env.example DOCKER.md docs/superpowers/specs/2026-04-22-live-prompt-reload-design.md docs/superpowers/plans/2026-04-22-live-prompt-reload.md +git commit -m "feat: add live prompt reload controls" +``` diff --git a/docs/superpowers/specs/2026-04-22-live-prompt-reload-design.md b/docs/superpowers/specs/2026-04-22-live-prompt-reload-design.md new file mode 100644 index 0000000..f4f674a --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-live-prompt-reload-design.md @@ -0,0 +1,207 @@ +# ChatMock Live Prompt Reload Design + +**Date:** 2026-04-22 + +**Goal** + +Allow the running ChatMock process to switch prompt directories and reload prompt content through a local-only HTTP admin API, without restarting the container or process. + +## Problem + +ChatMock currently loads `prompt.md` and `prompt_gpt5_codex.md` once at import time in `chatmock/config.py`, then stores the contents in module globals. The Docker split between `prompts/bare/` and `prompts/clawmem/` is implemented by bind mounts in `docker-compose.yml`, so changing the active prompt set requires editing compose and recreating containers. + +That is operationally heavier than necessary and makes prompt switching a deployment action instead of a runtime action. + +## Recommended Approach + +Implement a file-backed prompt runtime config plus an explicit local admin HTTP API. + +### Why this approach + +- It supports true live prompt path changes without container restarts. +- It keeps the source of truth on disk instead of hiding it in process memory. +- It avoids per-request file I/O by keeping prompts cached until an explicit reload. +- It keeps the runtime surface narrow and auditable. + +## Scope + +### In scope + +- Add a prompt manager that owns: + - active prompt directory or explicit file paths + - cached prompt contents + - reload logic +- Add a small runtime config file for prompt path selection. +- Add local-only admin endpoints to: + - inspect current prompt config + - update prompt config + - reload prompt cache +- Update instruction selection to read prompt contents from the prompt manager rather than startup-time globals. +- Add focused tests for reload and config mutation behavior. + +### Out of scope + +- Multi-user auth or broad remote administration. +- Automatic file-watch reload. +- Non-HTTP control surfaces such as signals. +- Redesign of prompt semantics or instruction-selection policy. + +## Architecture + +### 1. Prompt manager + +Create a small prompt manager in `chatmock/config.py` or a nearby dedicated module. + +Responsibilities: + +- Resolve the active prompt file locations. +- Load `prompt.md` and `prompt_gpt5_codex.md`. +- Cache the loaded text in memory. +- Expose read methods used by request handling. +- Expose `reload()` and `update_config(...)` methods. +- Persist prompt-path selection to a small runtime config file. + +Suggested shape: + +- `PromptConfig` + - `prompt_dir` + - `base_prompt_path` + - `codex_prompt_path` +- `PromptManager` + - `get_instructions(model, use_codex_variant)` + - `get_state()` + - `reload()` + - `update_config(...)` + +### 2. File-backed runtime config + +Use a small JSON file under the app’s writable state directory so runtime prompt selection survives restart. + +Suggested location: + +- `${CHATGPT_LOCAL_HOME}/prompt-config.json` + +Suggested contents: + +```json +{ + "prompt_dir": "/app/prompts/bare", + "base_prompt_path": "/app/prompts/bare/prompt.md", + "codex_prompt_path": "/app/prompts/bare/prompt_gpt5_codex.md" +} +``` + +Rules: + +- If `base_prompt_path` and `codex_prompt_path` are not provided, derive them from `prompt_dir`. +- If no runtime config file exists, initialize from env defaults and current behavior. +- Validate that target files exist and are readable before accepting an update. + +### 3. Admin HTTP API + +Add a local-only admin surface in `chatmock/app.py`. + +Endpoints: + +- `GET /admin/prompts` + - Returns current runtime prompt config and metadata about the loaded cache. +- `POST /admin/prompts/config` + - Updates the runtime config on disk and reloads prompt cache. +- `POST /admin/prompts/reload` + - Reloads prompt cache from the current runtime config without changing config. + +Suggested response fields: + +- `prompt_dir` +- `base_prompt_path` +- `codex_prompt_path` +- `loaded_at` +- `source` + +### 4. Local-only guardrail + +The admin API must not be generally exposed. + +Guardrails: + +- Accept requests only from loopback (`127.0.0.1`, `::1`) or equivalent proxied-local cases if clearly identified. +- Optionally require a static header token if `CHATMOCK_ADMIN_TOKEN` is configured. +- Return `403` for non-local callers. + +### 5. Request-path integration + +Update instruction selection so request handlers do not use startup-loaded module globals. + +Instead: + +- `create_app()` initializes one prompt manager instance. +- Store it on `app.extensions` or `app.config`. +- `resolve_effective_instructions(...)` reads current prompt text from the manager each request, but only from the in-memory cache. + +This keeps request behavior stable while allowing explicit runtime reloads. + +## Docker implications + +The runtime prompt switch should stop requiring compose edits. + +Preferred Docker shape: + +- mount a stable prompts root into the container, such as: + - `/app/prompts/bare/...` + - `/app/prompts/clawmem/...` +- keep the running app pointed at one active prompt directory via runtime config. + +That means future prompt switching can use the admin API to move between mounted prompt directories rather than changing bind mounts. + +## Failure handling + +- If a config update points to missing or unreadable files, reject the request with `400`. +- If reload fails, keep the previous cached prompts active. +- Surface structured error messages so operators can see exactly which file failed validation. + +## Testing + +Add focused tests for: + +- prompt manager loads defaults from current startup behavior +- reload updates cache after on-disk file change +- config update switches between two prompt directories +- invalid path update is rejected and prior cache remains active +- admin endpoints are local-only +- instruction resolution uses the prompt manager cache instead of module globals + +## Operational workflow after change + +Example live switch: + +1. `POST /admin/prompts/config` with `{"prompt_dir":"/app/prompts/clawmem"}` +2. server validates files and rewrites runtime config +3. server reloads prompt cache +4. subsequent requests use ClawMem prompts immediately + +Example manual refresh after editing prompt files: + +1. edit files in the mounted prompt directory +2. `POST /admin/prompts/reload` +3. new requests use refreshed prompt text + +## Tradeoffs + +### Advantages + +- no container restart for prompt switching +- explicit and auditable control path +- deterministic persisted state +- no per-request file reads + +### Costs + +- introduces a new admin surface that must be guarded +- adds mutable runtime config state +- requires refactoring prompt loading away from startup globals + +## Implementation notes + +- Keep the first version JSON-only for the runtime config. +- Keep endpoint behavior narrow and explicit; no generic config mutation API. +- Keep the existing prompt file names (`prompt.md`, `prompt_gpt5_codex.md`) so current prompt sets remain compatible. diff --git a/tests/test_routes.py b/tests/test_routes.py index 30ad79a..45da6ea 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -4,7 +4,9 @@ import socket import threading import time +import tempfile import unittest +from pathlib import Path from unittest.mock import patch from chatmock.app import create_app @@ -16,6 +18,8 @@ from chatmock.session import reset_session_state from websockets.sync.client import connect as ws_connect +ADMIN_TOKEN = "test-token" + class FakeUpstream: def __init__( @@ -99,6 +103,292 @@ def test_ollama_tags_list(self) -> None: self.assertIn("gpt-5.4", model_names) self.assertIn("gpt-5.4-mini", model_names) + def _write_prompt_set(self, root: Path, profile: str, base_text: str, codex_text: str) -> Path: + prompt_dir = root / profile + prompt_dir.mkdir(parents=True, exist_ok=True) + (prompt_dir / "prompt.md").write_text(base_text, encoding="utf-8") + (prompt_dir / "prompt_gpt5_codex.md").write_text(codex_text, encoding="utf-8") + return prompt_dir + + def test_admin_prompts_returns_current_prompt_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + prompt_dir = self._write_prompt_set(root, "bare", "bare base", "bare codex") + app = create_app( + prompt_dir=str(prompt_dir), + prompt_config_path=str(root / "prompt-config-chatmock.json"), + admin_token=ADMIN_TOKEN, + ) + client = app.test_client() + headers = {"X-ChatMock-Admin-Token": ADMIN_TOKEN} + + response = client.get("/admin/prompts", headers=headers) + + self.assertEqual(response.status_code, 200) + body = response.get_json() + self.assertEqual(body["prompt_dir"], str(prompt_dir)) + self.assertEqual(body["base_prompt_path"], str(prompt_dir / "prompt.md")) + self.assertEqual(body["codex_prompt_path"], str(prompt_dir / "prompt_gpt5_codex.md")) + + @patch("chatmock.routes_openai.start_upstream_request") + def test_admin_prompts_reload_refreshes_cached_prompt_contents(self, mock_start) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + prompt_dir = self._write_prompt_set(root, "bare", "bare base v1", "bare codex v1") + app = create_app( + prompt_dir=str(prompt_dir), + prompt_config_path=str(root / "prompt-config-chatmock.json"), + admin_token=ADMIN_TOKEN, + ) + client = app.test_client() + headers = {"X-ChatMock-Admin-Token": ADMIN_TOKEN} + mock_start.return_value = ( + FakeUpstream( + [ + {"type": "response.output_text.delta", "delta": "hello"}, + {"type": "response.completed", "response": {"id": "resp-openai"}}, + ] + ), + None, + ) + + first = client.post( + "/v1/chat/completions", + json={"model": "gpt-5.4", "messages": [{"role": "user", "content": "hi"}]}, + ) + self.assertEqual(first.status_code, 200) + self.assertEqual(mock_start.call_args.kwargs["instructions"], "bare base v1") + + (prompt_dir / "prompt.md").write_text("bare base v2", encoding="utf-8") + reload_response = client.post("/admin/prompts/reload", headers=headers) + self.assertEqual(reload_response.status_code, 200) + + second = client.post( + "/v1/chat/completions", + json={"model": "gpt-5.4", "messages": [{"role": "user", "content": "hi"}]}, + ) + self.assertEqual(second.status_code, 200) + self.assertEqual(mock_start.call_args.kwargs["instructions"], "bare base v2") + + @patch("chatmock.routes_openai.start_upstream_request") + def test_admin_prompts_config_switches_prompt_directory_live(self, mock_start) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + bare_dir = self._write_prompt_set(root, "bare", "bare base", "bare codex") + clawmem_dir = self._write_prompt_set(root, "clawmem", "clawmem base", "clawmem codex") + app = create_app( + prompt_dir=str(bare_dir), + prompt_config_path=str(root / "prompt-config-chatmock.json"), + admin_token=ADMIN_TOKEN, + ) + client = app.test_client() + headers = {"X-ChatMock-Admin-Token": ADMIN_TOKEN} + mock_start.return_value = ( + FakeUpstream( + [ + {"type": "response.output_text.delta", "delta": "hello"}, + {"type": "response.completed", "response": {"id": "resp-openai"}}, + ] + ), + None, + ) + + update = client.post("/admin/prompts/config", json={"prompt_dir": str(clawmem_dir)}, headers=headers) + self.assertEqual(update.status_code, 200) + + response = client.post( + "/v1/chat/completions", + json={"model": "gpt-5.3-codex", "messages": [{"role": "user", "content": "hi"}]}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(mock_start.call_args.kwargs["instructions"], "clawmem codex") + + def test_admin_prompts_rejects_non_loopback_access(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + prompt_dir = self._write_prompt_set(root, "bare", "bare base", "bare codex") + app = create_app( + prompt_dir=str(prompt_dir), + prompt_config_path=str(root / "prompt-config-chatmock.json"), + ) + client = app.test_client() + + response = client.get("/admin/prompts", environ_overrides={"REMOTE_ADDR": "10.0.0.2"}) + + self.assertEqual(response.status_code, 403) + + def test_admin_prompts_rejects_missing_remote_addr(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + prompt_dir = self._write_prompt_set(root, "bare", "bare base", "bare codex") + app = create_app( + prompt_dir=str(prompt_dir), + prompt_config_path=str(root / "prompt-config-chatmock.json"), + ) + client = app.test_client() + + response = client.get("/admin/prompts", environ_overrides={"REMOTE_ADDR": None}) + + self.assertEqual(response.status_code, 403) + + @patch.dict( + "os.environ", + { + "CHATMOCK_ADMIN_TRUSTED_IPS": "10.0.0.0/8", + "CHATMOCK_ADMIN_TOKEN": "", + "CHATGPT_LOCAL_ADMIN_TOKEN": "", + }, + ) + def test_admin_prompts_allows_configured_trusted_ip_range(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + prompt_dir = self._write_prompt_set(root, "bare", "bare base", "bare codex") + app = create_app( + prompt_dir=str(prompt_dir), + prompt_config_path=str(root / "prompt-config-chatmock.json"), + ) + client = app.test_client() + + response = client.get("/admin/prompts", environ_overrides={"REMOTE_ADDR": "10.12.0.5"}) + + self.assertEqual(response.status_code, 200) + + @patch.dict("os.environ", {"CHATMOCK_ALLOW_ADMIN_EXTERNAL": "true"}) + def test_admin_prompts_external_access_requires_token(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + prompt_dir = self._write_prompt_set(root, "bare", "bare base", "bare codex") + app = create_app( + prompt_dir=str(prompt_dir), + prompt_config_path=str(root / "prompt-config-chatmock.json"), + ) + client = app.test_client() + + response = client.get("/admin/prompts", environ_overrides={"REMOTE_ADDR": "203.0.113.9"}) + + self.assertEqual(response.status_code, 403) + + @patch("chatmock.routes_openai.start_upstream_request") + def test_chat_completions_invalid_reasoning_effort_does_not_override_nested_reasoning(self, mock_start) -> None: + mock_start.return_value = ( + FakeUpstream( + [ + {"type": "response.output_text.delta", "delta": "hello"}, + {"type": "response.completed", "response": {"id": "resp-openai"}}, + ] + ), + None, + ) + response = self.client.post( + "/v1/chat/completions", + json={ + "model": "gpt-5.4", + "reasoning_effort": "bogus", + "reasoning": {"effort": "high", "summary": "detailed"}, + "messages": [{"role": "user", "content": "hi"}], + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(mock_start.call_args.kwargs["reasoning_param"]["effort"], "high") + self.assertEqual(mock_start.call_args.kwargs["reasoning_param"]["summary"], "detailed") + + @patch("chatmock.routes_openai.start_upstream_request") + def test_chat_completions_codex_prompt_falls_back_to_base_when_variant_missing(self, mock_start) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + prompt_dir = root / "bare" + prompt_dir.mkdir(parents=True, exist_ok=True) + (prompt_dir / "prompt.md").write_text("base only", encoding="utf-8") + app = create_app( + prompt_dir=str(prompt_dir), + prompt_config_path=str(root / "prompt-config-chatmock.json"), + ) + client = app.test_client() + mock_start.return_value = ( + FakeUpstream( + [ + {"type": "response.output_text.delta", "delta": "hello"}, + {"type": "response.completed", "response": {"id": "resp-openai"}}, + ] + ), + None, + ) + + response = client.post( + "/v1/chat/completions", + json={"model": "gpt-5.3-codex", "messages": [{"role": "user", "content": "hi"}]}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(mock_start.call_args.kwargs["instructions"], "base only") + + @patch("chatmock.routes_openai.start_upstream_request") + def test_chat_completions_accepts_standard_reasoning_effort(self, mock_start) -> None: + mock_start.return_value = ( + FakeUpstream( + [ + {"type": "response.output_text.delta", "delta": "hello"}, + {"type": "response.completed", "response": {"id": "resp-openai"}}, + ] + ), + None, + ) + response = self.client.post( + "/v1/chat/completions", + json={ + "model": "gpt-5.4", + "reasoning_effort": "low", + "messages": [{"role": "user", "content": "hi"}], + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(mock_start.call_args.kwargs["reasoning_param"]["effort"], "low") + + @patch("chatmock.routes_openai.start_upstream_request") + def test_chat_completions_standard_reasoning_effort_overrides_nested_reasoning(self, mock_start) -> None: + mock_start.return_value = ( + FakeUpstream( + [ + {"type": "response.output_text.delta", "delta": "hello"}, + {"type": "response.completed", "response": {"id": "resp-openai"}}, + ] + ), + None, + ) + response = self.client.post( + "/v1/chat/completions", + json={ + "model": "gpt-5.4", + "reasoning_effort": "low", + "reasoning": {"effort": "high", "summary": "detailed"}, + "messages": [{"role": "user", "content": "hi"}], + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(mock_start.call_args.kwargs["reasoning_param"]["effort"], "low") + self.assertEqual(mock_start.call_args.kwargs["reasoning_param"]["summary"], "detailed") + + @patch("chatmock.routes_openai.start_upstream_request") + def test_chat_completions_reasoning_defaults_unchanged_without_explicit_reasoning_fields(self, mock_start) -> None: + mock_start.return_value = ( + FakeUpstream( + [ + {"type": "response.output_text.delta", "delta": "hello"}, + {"type": "response.completed", "response": {"id": "resp-openai"}}, + ] + ), + None, + ) + response = self.client.post( + "/v1/chat/completions", + json={"model": "gpt-5.4", "messages": [{"role": "user", "content": "hi"}]}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + mock_start.call_args.kwargs["reasoning_param"], + {"effort": "medium", "summary": "auto"}, + ) + @patch("chatmock.routes_openai.start_upstream_request") def test_chat_completions(self, mock_start) -> None: mock_start.return_value = (