| 1 | import os |
| 2 | import shutil |
| 3 | import socket |
| 4 | |
| 5 | import toml |
| 6 | from loguru import logger |
| 7 | |
| 8 | root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) |
| 9 | config_file = f"{root_dir}/config.toml" |
| 10 | _CONTAINER_CGROUP_MARKERS = ("docker", "containerd", "kubepods", "libpod", "podman") |
| 11 | _DOCKER_HOST_GATEWAY_NAME = "host.docker.internal" |
| 12 | |
| 13 | |
| 14 | def is_running_in_container( |
| 15 | dockerenv_path: str = "/.dockerenv", |
| 16 | containerenv_path: str = "/run/.containerenv", |
| 17 | cgroup_path: str = "/proc/1/cgroup", |
| 18 | ) -> bool: |
| 19 | """ |
| 20 | 判断当前进程是否运行在容器内。 |
| 21 | |
| 22 | 这个判断主要用于 Ollama 默认地址选择: |
| 23 | - 普通本机运行时,`localhost` 指向用户机器本身; |
| 24 | - Docker 容器内,`localhost` 指向容器自己,访问宿主机 Ollama |
| 25 | 通常需要使用 `host.docker.internal`。 |
| 26 | |
| 27 | 不能只判断 `/proc/1/cgroup` 是否存在,因为普通 Linux 也会有这个文件。 |
| 28 | 这里只在检测到明确的容器标记时返回 True,避免误伤非 Docker Linux 用户。 |
| 29 | 参数保留为可注入路径,便于单元测试覆盖不同运行环境。 |
| 30 | """ |
| 31 | if os.path.isfile(dockerenv_path) or os.path.isfile(containerenv_path): |
| 32 | return True |
| 33 | |
| 34 | try: |
| 35 | with open(cgroup_path, mode="r", encoding="utf-8") as fp: |
| 36 | cgroup_content = fp.read().lower() |
| 37 | except OSError: |
| 38 | return False |
| 39 | |
| 40 | return any(marker in cgroup_content for marker in _CONTAINER_CGROUP_MARKERS) |
| 41 | |
| 42 | |
| 43 | def _can_resolve_hostname(hostname: str) -> bool: |
| 44 | try: |
| 45 | socket.gethostbyname(hostname) |
| 46 | except OSError: |
| 47 | return False |
| 48 | return True |
| 49 | |
| 50 | |
| 51 | def _decode_linux_route_gateway(hex_gateway: str) -> str: |
| 52 | # /proc/net/route 里的 Gateway 是 16 进制小端序,例如 010011AC 表示 |
| 53 | # 172.17.0.1。这里单独解析,是为了在原生 Linux Docker 没有 |
| 54 | # host.docker.internal DNS 记录时,还能尝试访问容器默认网关上的宿主机。 |
| 55 | if len(hex_gateway) != 8: |
| 56 | raise ValueError("invalid gateway length") |
| 57 | |
| 58 | octets = [ |
| 59 | str(int(hex_gateway[index : index + 2], 16)) |
| 60 | for index in range(6, -1, -2) |
| 61 | ] |
| 62 | return ".".join(octets) |
| 63 | |
| 64 | |
| 65 | def get_container_default_gateway_ip(route_path: str = "/proc/net/route") -> str: |
| 66 | """ |
| 67 | 读取 Linux 容器里的默认网关 IP。 |
| 68 | |
| 69 | Docker Desktop 通常提供 `host.docker.internal`,但原生 Linux Docker |
| 70 | 默认不一定提供这个 DNS 名称。默认网关通常可以作为访问宿主机服务的 |
| 71 | 兜底地址;如果用户的 Ollama 只监听 127.0.0.1,则仍需要用户让 |
| 72 | Ollama 监听宿主机网卡或手动配置 `ollama_base_url`。 |
| 73 | """ |
| 74 | try: |
| 75 | with open(route_path, mode="r", encoding="utf-8") as fp: |
| 76 | route_lines = fp.readlines() |
| 77 | except OSError: |
| 78 | return "" |
| 79 | |
| 80 | for line in route_lines[1:]: |
| 81 | fields = line.strip().split() |
| 82 | if len(fields) < 3: |
| 83 | continue |
| 84 | |
| 85 | destination = fields[1] |
| 86 | gateway = fields[2] |
| 87 | if destination != "00000000" or gateway == "00000000": |
| 88 | continue |
| 89 | |
| 90 | try: |
| 91 | return _decode_linux_route_gateway(gateway) |
| 92 | except ValueError: |
| 93 | logger.warning(f"invalid container gateway route entry: {line.strip()}") |
| 94 | return "" |
| 95 | |
| 96 | return "" |
| 97 | |
| 98 | |
| 99 | def get_default_ollama_base_url() -> str: |
| 100 | """ |
| 101 | 返回 Ollama 的默认 OpenAI-compatible base_url。 |
| 102 | |
| 103 | 用户显式配置 `ollama_base_url` 时不会走这里;这里只处理“未配置时的 |
| 104 | 最佳默认值”。容器内默认指向宿主机,普通本机运行默认指向 localhost。 |
| 105 | """ |
| 106 | if not is_running_in_container(): |
| 107 | return "http://localhost:11434/v1" |
| 108 | |
| 109 | if _can_resolve_hostname(_DOCKER_HOST_GATEWAY_NAME): |
| 110 | return f"http://{_DOCKER_HOST_GATEWAY_NAME}:11434/v1" |
| 111 | |
| 112 | gateway_ip = get_container_default_gateway_ip() |
| 113 | if gateway_ip: |
| 114 | logger.info( |
| 115 | "host.docker.internal is not resolvable, fallback to container " |
| 116 | f"default gateway for Ollama: {gateway_ip}" |
| 117 | ) |
| 118 | return f"http://{gateway_ip}:11434/v1" |
| 119 | |
| 120 | logger.warning( |
| 121 | "failed to resolve host.docker.internal and container default gateway; " |
| 122 | "fallback to host.docker.internal for Ollama" |
| 123 | ) |
| 124 | return f"http://{_DOCKER_HOST_GATEWAY_NAME}:11434/v1" |
| 125 | |
| 126 | |
| 127 | def load_config(): |
| 128 | # fix: IsADirectoryError: [Errno 21] Is a directory: '/MoneyPrinterTurbo/config.toml' |
| 129 | if os.path.isdir(config_file): |
| 130 | shutil.rmtree(config_file) |
| 131 | |
| 132 | if not os.path.isfile(config_file): |
| 133 | example_file = f"{root_dir}/config.example.toml" |
| 134 | if os.path.isfile(example_file): |
| 135 | shutil.copyfile(example_file, config_file) |
| 136 | logger.info("copy config.example.toml to config.toml") |
| 137 | |
| 138 | logger.info(f"load config from file: {config_file}") |
| 139 | |
| 140 | try: |
| 141 | _config_ = toml.load(config_file) |
| 142 | except Exception as e: |
| 143 | logger.warning(f"load config failed: {str(e)}, try to load as utf-8-sig") |
| 144 | with open(config_file, mode="r", encoding="utf-8-sig") as fp: |
| 145 | _cfg_content = fp.read() |
| 146 | _config_ = toml.loads(_cfg_content) |
| 147 | return _config_ |
| 148 | |
| 149 | |
| 150 | def save_config(): |
| 151 | with open(config_file, "w", encoding="utf-8") as f: |
| 152 | _cfg["app"] = app |
| 153 | _cfg["azure"] = azure |
| 154 | _cfg["siliconflow"] = siliconflow |
| 155 | _cfg["elevenlabs"] = elevenlabs |
| 156 | _cfg["chatterbox"] = chatterbox |
| 157 | _cfg["ui"] = ui |
| 158 | f.write(toml.dumps(_cfg)) |
| 159 | |
| 160 | |
| 161 | _cfg = load_config() |
| 162 | app = _cfg.get("app", {}) |
| 163 | whisper = _cfg.get("whisper", {}) |
| 164 | proxy = _cfg.get("proxy", {}) |
| 165 | azure = _cfg.get("azure", {}) |
| 166 | siliconflow = _cfg.get("siliconflow", {}) |
| 167 | elevenlabs = _cfg.get("elevenlabs", {}) |
| 168 | chatterbox = _cfg.get("chatterbox", {}) |
| 169 | ui = _cfg.get( |
| 170 | "ui", |
| 171 | { |
| 172 | "hide_log": False, |
| 173 | }, |
| 174 | ) |
| 175 | |
| 176 | hostname = socket.gethostname() |
| 177 | |
| 178 | log_level = _cfg.get("log_level", "DEBUG") |
| 179 | listen_host = _cfg.get("listen_host", "0.0.0.0") |
| 180 | listen_port = _cfg.get("listen_port", 8080) |
| 181 | project_name = _cfg.get("project_name", "MoneyPrinterTurbo") |
| 182 | project_description = _cfg.get( |
| 183 | "project_description", |
| 184 | "<a href='https://github.com/harry0703/MoneyPrinterTurbo'>https://github.com/harry0703/MoneyPrinterTurbo</a>" |
| 185 | "<br><small>Supported by <a href='https://aihubmix.com/?aff=CEve'>AIHubMix</a></small>", |
| 186 | ) |
| 187 | project_version = _cfg.get("project_version", "1.3.0") |
| 188 | reload_debug = False |
| 189 | |
| 190 | app["redis_host"] = os.getenv( |
| 191 | "MPT_APP_REDIS_HOST", |
| 192 | os.getenv("REDIS_HOST", app.get("redis_host", "localhost")), |
| 193 | ) |
| 194 | |
| 195 | imagemagick_path = app.get("imagemagick_path", "") |
| 196 | if imagemagick_path and os.path.isfile(imagemagick_path): |
| 197 | os.environ["IMAGEMAGICK_BINARY"] = imagemagick_path |
| 198 | |
| 199 | ffmpeg_path = app.get("ffmpeg_path", "") |
| 200 | if ffmpeg_path and os.path.isfile(ffmpeg_path): |
| 201 | os.environ["IMAGEIO_FFMPEG_EXE"] = ffmpeg_path |
| 202 | |
| 203 | logger.info(f"{project_name} v{project_version}") |
| 204 |