From 94c50353f4fa11392edd5aef922e6851d807f575 Mon Sep 17 00:00:00 2001 From: chakcy <947105045@qq.com> Date: Fri, 21 Aug 2026 09:45:47 +0800 Subject: [PATCH] =?UTF-8?q?modify:=E7=BD=91=E5=85=B3=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=8E=A7=E5=88=B6amqtt=20broker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 38 ++++- config.toml | 12 ++ public/config.toml | 12 ++ src/chongming/app/core/lifespan_setup.py | 83 +++++++++-- src/chongming/app/core/mqtt_bridge.py | 16 ++- test_mqtt_auth.py | 170 +++++++++++++++++++++++ uv.lock | 18 +-- 7 files changed, 321 insertions(+), 28 deletions(-) create mode 100644 test_mqtt_auth.py diff --git a/README.md b/README.md index 9e96e60..423ed21 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ | --------------------- | -------------------------------------------------------------------- | | **FastAPI + Uvicorn** | 高性能异步 Web 框架,自动生成 OpenAPI 文档 | | **Zenoh 服务发现** | 基于 liveliness 的 Worker 注册与 RPC 调用,负载均衡 + 超时重试 | -| **MQTT 集成** | 内嵌 amqtt Broker,MQTT ↔ Worker 双向桥接 | +| **MQTT 集成** | amqtt Broker(内嵌或外部),MQTT ↔ Worker 双向桥接 | | **动态路由生成** | 扫描 `config/workers/*/*.toml` 自动注册 HTTP 路由 | | **Worker 框架** | `@rpc_method` 装饰器 + `Plugin` 基类统一生命周期 + Pydantic 模型绑定 | | **启动契约校验** | TOML 路由 ↔ `@rpc_method` ↔ 生成模型三方一致性校验,fail fast | @@ -205,7 +205,8 @@ graph TB uv run serve → FastAPI lifespan: 1. setup_storage() — 本地文件系统存储 2. setup_zenoh() — Zenoh peer 会话 - 3. setup_mqtt_broker() — 内嵌 Broker(子进程 + 文件锁单例) + 3. setup_mqtt_broker() — 内嵌 amqtt Broker(子进程 + 文件锁单例; + `use_embedded_broker=false` 时跳过,改用外部 Broker) 4. setup_worker_manager() — WorkerManager + 定期 liveliness 刷新 5. load_workers() — 按插件 [workers].load_mode 分派: inline → 进程内 import 插件 → 启动 Worker 协程 @@ -337,6 +338,39 @@ start.sh 容器入口:uv sync + worker_serve --type /` | MQTT → Worker | | 响应 | `chongming///response` | Worker → MQTT(`None` 时跳过) | +### Broker 来源(内嵌 vs 外部) + +网关默认启动内嵌 amqtt Broker(子进程 + 文件锁单例),`[.mqtt].use_embedded_broker` 控制 Broker 来源: + +```toml +[development.mqtt] +enabled = true +host = "0.0.0.0" # true 时为内嵌 Broker 监听地址;false 时为外部 Broker 地址 +port = 1883 +use_embedded_broker = true # true(默认)网关启动 amqtt Broker;false 连接外部 Broker +``` + +- `use_embedded_broker = true`(默认):网关启动内嵌 amqtt Broker 子进程,MQTT Bridge 作为客户端连回自身。 +- `use_embedded_broker = false`:网关**不启动** Broker,MQTT Bridge 直接连接 `host:port` 指向的外部 MQTT Broker(如独立部署的 EMQX/Mosquitto)。`host` 需改为外部 Broker 地址,`enabled = false` 时整个 MQTT 通道关闭。 + +### Broker 认证(用户名/密码) + +`[.mqtt].username` / `password` 配置 Broker 认证(可选,默认匿名): + +```toml +[development.mqtt] +enabled = true +host = "0.0.0.0" +port = 1883 +username = "chongming" # 可选;内嵌/外部 Broker 均生效 +password = "change-me" +``` + +- **内嵌 Broker**(`use_embedded_broker=true`):配置后启用认证(amqtt FileAuthPlugin + argon2 哈希),所有客户端(含网关 Bridge)必须携带该账号密码连接;未配置则保持匿名。 +- **外部 Broker**:网关 Bridge 以该账号密码连接,broker 侧需自行创建对应账号。 +- 客户端连接示例:`mqtt://chongming:change-me@localhost:1883/`(密码含特殊字符时需百分号编码,如 `@` → `%40`;网关 Bridge 内部自动处理)。 +- 注意:密码明文存放于配置,生产环境建议使用外部 Broker + 环境变量注入。 + ### 自定义订阅主题 除内置的 `chongming//` 接口外,可将外部 MQTT 主题桥接到任意 Worker。**推荐在插件自己的配置** `config/workers//config.toml` 中通过 `[[workers.mqtt_topics]]` 声明(目标 worker 即该插件本身): diff --git a/config.toml b/config.toml index 4af0f65..a61e8bf 100644 --- a/config.toml +++ b/config.toml @@ -53,6 +53,18 @@ allow_origins = ["*"] enabled = true host = "0.0.0.0" port = 1883 +# 是否由网关启动内嵌 amqtt Broker 子进程: +# true (默认)= 网关自带 amqtt Broker,host/port 为 Broker 监听地址 +# false = 使用外部 MQTT Broker,网关仅启动 Bridge 客户端连接 +# host/port 需指向外部 Broker 地址(如 "192.168.1.10") +use_embedded_broker = true +# MQTT Broker 认证(可选,默认匿名): +# - 内嵌 Broker(use_embedded_broker=true):配置后要求所有客户端(含网关 Bridge) +# 用该账号密码连接,未配置则保持匿名 +# - 外部 Broker:网关 Bridge 用该账号密码连接(broker 侧自行配置对应账号) +# 注意:密码为明文存储,生产环境建议使用外部 Broker + 环境变量注入 +# username = "chongming" +# password = "change-me" # MQTT Bridge 额外订阅主题推荐声明在各插件自己的配置里: # config/workers//config.toml → [[workers.mqtt_topics]] diff --git a/public/config.toml b/public/config.toml index b7fb129..4ec3a40 100644 --- a/public/config.toml +++ b/public/config.toml @@ -52,6 +52,18 @@ allow_origins = ["*"] enabled = true host = "0.0.0.0" port = 1883 +# 是否由网关启动内嵌 amqtt Broker 子进程: +# true (默认)= 网关自带 amqtt Broker,host/port 为 Broker 监听地址 +# false = 使用外部 MQTT Broker,网关仅启动 Bridge 客户端连接 +# host/port 需指向外部 Broker 地址(如 "192.168.1.10") +use_embedded_broker = true +# MQTT Broker 认证(可选,默认匿名): +# - 内嵌 Broker(use_embedded_broker=true):配置后要求所有客户端(含网关 Bridge) +# 用该账号密码连接,未配置则保持匿名 +# - 外部 Broker:网关 Bridge 用该账号密码连接(broker 侧自行配置对应账号) +# 注意:密码为明文存储,生产环境建议使用外部 Broker + 环境变量注入 +# username = "chongming" +# password = "change-me" # MQTT Bridge 额外订阅主题推荐声明在各插件自己的配置里: # config/workers//config.toml → [[workers.mqtt_topics]] diff --git a/src/chongming/app/core/lifespan_setup.py b/src/chongming/app/core/lifespan_setup.py index c011d67..33409f0 100644 --- a/src/chongming/app/core/lifespan_setup.py +++ b/src/chongming/app/core/lifespan_setup.py @@ -77,8 +77,9 @@ def _release_singleton_lock(name: str): pass -def _run_mqtt_broker(host: str, port: int): - """子进程入口:启动 amqtt Broker""" +def _run_mqtt_broker(host: str, port: int, username: str | None = None, + password: str | None = None): + """子进程入口:启动 amqtt Broker(配置了账号密码时启用认证)""" import asyncio as _asyncio from amqtt.broker import Broker @@ -155,6 +156,33 @@ def _run_mqtt_broker(host: str, port: int): _Broker._initialize_client_session = _patched_init_client async def _serve(): + # 认证插件:配置了账号密码 → FileAuthPlugin(argon2 密码文件,仅此账号可连); + # 未配置 → 匿名(默认行为) + auth_plugins = {} + passwd_file = None + if username and password: + import tempfile + from pwdlib import PasswordHash + + try: + fd, passwd_file = tempfile.mkstemp(prefix="chongming_mqtt_", suffix=".passwd") + with os.fdopen(fd, "w") as f: + f.write(f"{username}:{PasswordHash.recommended().hash(password)}\n") + auth_plugins = { + "amqtt.plugins.authentication.FileAuthPlugin": { + "password_file": passwd_file, + }, + } + except Exception as e: + logger.warning(f"MQTT 认证初始化失败,回退匿名模式: {e}") + if passwd_file: + os.unlink(passwd_file) + passwd_file = None + else: + auth_plugins = { + "amqtt.plugins.authentication.AnonymousAuthPlugin": {"allow_anonymous": True}, + } + cfg = { "listeners": { "default": { @@ -163,7 +191,7 @@ def _run_mqtt_broker(host: str, port: int): }, "timeout_disconnect_delay": 2, "plugins": { - "amqtt.plugins.authentication.AnonymousAuthPlugin": {"allow_anonymous": True}, + **auth_plugins, "amqtt.plugins.sys.broker.BrokerSysPlugin": {"sys_interval": 0}, }, } @@ -173,7 +201,15 @@ def _run_mqtt_broker(host: str, port: int): except Exception: # 端口已被其他 worker 的 Broker 占用,静默退出 return - print(f"[MQTT Broker] 已启动 @ {host}:{port}") + finally: + # 密码文件仅在启动时读取,启动完成后即可删除 + if passwd_file: + try: + os.unlink(passwd_file) + except OSError: + pass + auth_note = f" (认证用户: {username})" if username else " (匿名)" + print(f"[MQTT Broker] 已启动 @ {host}:{port}{auth_note}") try: while True: await _asyncio.sleep(1) @@ -465,15 +501,31 @@ def validate_contracts(app: FastAPI | None = None): _validate(inline_plugins) def setup_mqtt_broker(app: FastAPI): - """启动 MQTT Broker 子进程(原子锁确保多 worker 仅启动一个)""" + """ + 启动内嵌 amqtt Broker 子进程(原子锁确保多 worker 仅启动一个)。 + + 由配置 [.mqtt].use_embedded_broker 控制: + true (默认)→ 网关启动 amqtt Broker 子进程,host/port 为监听地址 + false → 使用外部 MQTT Broker,网关仅运行 Bridge 客户端,跳过本步 + 配置了 username/password 时 Broker 启用认证,否则保持匿名。 + """ env_config = config.get(env, {}) mqtt_config = env_config.get("mqtt", {}) if not mqtt_config.get("enabled", True): logger.info("MQTT 已禁用") return None + if not mqtt_config.get("use_embedded_broker", True): + logger.info( + f"use_embedded_broker=false:使用外部 MQTT Broker " + f"({mqtt_config.get('host', 'localhost')}:{mqtt_config.get('port', 1883)})," + f"跳过内嵌 amqtt Broker 启动" + ) + return None host = mqtt_config.get("host", "localhost") port = mqtt_config.get("port", 1883) + username = mqtt_config.get("username") or None + password = mqtt_config.get("password") or None if not _acquire_singleton_lock("mqtt_broker"): for _ in range(50): if is_port_in_use(port, host): @@ -491,11 +543,12 @@ def setup_mqtt_broker(app: FastAPI): app.state.mqtt_broker_lock_acquired = False return proc = multiprocessing.Process( - target=_run_mqtt_broker, args=(host, port), daemon=False + target=_run_mqtt_broker, args=(host, port, username, password), daemon=False ) proc.start() app.state.mqtt_broker_process = proc - logger.info(f"MQTT Broker 子进程已启动 (PID: {proc.pid})") + auth_note = f",认证用户 {username}" if username else "(匿名)" + logger.info(f"MQTT Broker 子进程已启动 (PID: {proc.pid}){auth_note}") for _ in range(50): if is_port_in_use(port, host): logger.info(f"MQTT Broker 已监听 {port} 端口") @@ -521,9 +574,12 @@ async def shutdown_mqtt_broker(app: FastAPI): async def setup_mqtt(app: FastAPI, worker_manager: WorkerManager) -> MQTTBridge | None: """ - 启动 MQTT Bridge(客户端模式,Broker 由子进程管理)。 + 启动 MQTT Bridge(纯客户端模式)。 从 config.toml 的环境段读取 host/port/enabled;若 enabled 为 false 则跳过。 + Broker 来源由 use_embedded_broker 控制: + true (默认)= 内嵌 amqtt Broker(lifespan 第 3 步已启动子进程) + false = 外部 MQTT Broker(host/port 指向外部地址,网关不启动 Broker) 订阅主题 = 全局 [.mqtt].custom_topics(可选兜底) + 各插件在其 config/workers//config.toml 中声明的 [[workers.mqtt_topics]]。 使用文件锁确保多个 gunicorn worker 中只有一个启动 Bridge。 @@ -536,10 +592,13 @@ async def setup_mqtt(app: FastAPI, worker_manager: WorkerManager) -> MQTTBridge host = mqtt_config.get("host", "localhost") port = mqtt_config.get("port", 1883) + username = mqtt_config.get("username") or None + password = mqtt_config.get("password") or None custom_topics = list(mqtt_config.get("custom_topics", []) or []) custom_topics += list(getattr(app.state, "plugin_mqtt_topics", []) or []) - # Broker 子进程已在 lifespan 第 3 步启动(setup_mqtt_broker + 文件锁单例) + # Broker 来源:use_embedded_broker=true 时内嵌 amqtt Broker 子进程已在 + # lifespan 第 3 步启动(setup_mqtt_broker + 文件锁单例);false 时连接外部 Broker。 # 原子锁确保只有一个 worker 启动 MQTT Bridge if not _acquire_singleton_lock("mqtt_bridge"): @@ -553,6 +612,8 @@ async def setup_mqtt(app: FastAPI, worker_manager: WorkerManager) -> MQTTBridge host=host, port=port, custom_topics=custom_topics, + username=username, + password=password, ) app.state.mqtt_bridge = bridge await bridge.start() @@ -560,7 +621,9 @@ async def setup_mqtt(app: FastAPI, worker_manager: WorkerManager) -> MQTTBridge registry = getattr(app.state, "route_registry", None) if registry is not None: registry.attach_bridge(bridge) - logger.info(f"MQTT Bridge 已启动 ({host}:{port})") + broker_mode = "内嵌 amqtt" if mqtt_config.get("use_embedded_broker", True) else "外部" + auth_note = f",认证用户 {username}" if username else "(匿名)" + logger.info(f"MQTT Bridge 已启动({broker_mode} Broker {host}:{port}{auth_note})") return bridge diff --git a/src/chongming/app/core/mqtt_bridge.py b/src/chongming/app/core/mqtt_bridge.py index f14eb79..6fab0b9 100644 --- a/src/chongming/app/core/mqtt_bridge.py +++ b/src/chongming/app/core/mqtt_bridge.py @@ -3,6 +3,7 @@ import asyncio import json import re from typing import Optional +from urllib.parse import quote from amqtt.client import MQTTClient from amqtt.mqtt.constants import QOS_1 @@ -40,10 +41,13 @@ def _compile_topic(topic: str) -> re.Pattern: class MQTTBridge: - def __init__(self, worker_manager, host="localhost", port=1883, custom_topics=None): + def __init__(self, worker_manager, host="localhost", port=1883, custom_topics=None, + username=None, password=None): self._wm = worker_manager self._host = host self._port = port + self._username = username + self._password = password self._client: Optional[MQTTClient] = None self._lt: Optional[asyncio.Task] = None self._running = False @@ -82,9 +86,17 @@ class MQTTBridge: self._running = False await self._sc_stop() + def _broker_uri(self) -> str: + """构建 Broker URI;凭据经百分号编码(amqtt 连接时自动 unquote)。""" + if self._username: + user = quote(self._username, safe="") + pwd = quote(self._password or "", safe="") + return f"mqtt://{user}:{pwd}@{self._host}:{self._port}/" + return f"mqtt://{self._host}:{self._port}/" + async def _sc(self): self._client = MQTTClient(config={"auto_reconnect": False}) - await self._client.connect(f"mqtt://{self._host}:{self._port}/") + await self._client.connect(self._broker_uri()) topics = [(MQTT_SUBSCRIBE_TOPIC, QOS_1)] topics += [(ct["topic"], QOS_1) for ct, _ in self._compiled] await self._client.subscribe(topics) diff --git a/test_mqtt_auth.py b/test_mqtt_auth.py new file mode 100644 index 0000000..9ba9699 --- /dev/null +++ b/test_mqtt_auth.py @@ -0,0 +1,170 @@ +""" +MQTT Broker 认证集成测试 + +验证 [.mqtt].username/password 配置生效: + A. 内嵌 Broker 启用认证(FileAuthPlugin): + 1. 正确凭据 → 连接/订阅/发布正常 + 2. 错误密码 → 连接被拒(ConnectError) + 3. 匿名连接 → 连接被拒 + B. 未配置凭据(默认)→ 匿名连接正常(原行为) + C. MQTTBridge 客户端凭据传递(含特殊字符密码的 URI 编码) +""" +import asyncio +import json +import multiprocessing +import socket +import sys +import time +from pathlib import Path +from urllib.parse import quote + +sys.path.insert(0, str(Path(__file__).parent)) + +from amqtt.client import MQTTClient +from amqtt.errors import ConnectError + +from src.chongming.app.core.lifespan_setup import _run_mqtt_broker +from src.chongming.app.core.mqtt_bridge import MQTTBridge + +HOST = "127.0.0.1" +USERNAME = "cm_user" +PASSWORD = "p@ss w0rd!%#" + + +def cred_uri(port: int, user: str | None = None, pwd: str | None = None) -> str: + """构建带凭据的 Broker URI(百分号编码,与 MQTTBridge._broker_uri 同语义)""" + if user: + return f"mqtt://{quote(user, safe='')}:{quote(pwd or '', safe='')}@{HOST}:{port}/" + return f"mqtt://{HOST}:{port}/" + + +def free_port() -> int: + with socket.socket() as s: + s.bind((HOST, 0)) + return s.getsockname()[1] + + +def wait_port(port: int, timeout: float = 20.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + with socket.socket() as s: + s.settimeout(0.5) + if s.connect_ex((HOST, port)) == 0: + return True + time.sleep(0.1) + return False + + +def start_broker(port: int, username=None, password=None): + proc = multiprocessing.Process( + target=_run_mqtt_broker, args=(HOST, port, username, password), daemon=True + ) + proc.start() + assert wait_port(port), f"Broker 未在 {port} 端口就绪" + return proc + + +async def try_connect(uri: str, expect_success: bool, label: str): + client = MQTTClient(config={"auto_reconnect": False}) + try: + await client.connect(uri) + except ConnectError as e: + assert not expect_success, f"{label}: 应连接成功,实际被拒 ({e})" + return None + assert expect_success, f"{label}: 应被拒绝,实际连接成功" + return client + + +async def scenario_auth(port: int): + """配置了账号密码 → 正确凭据通过,错误/匿名被拒""" + proc = start_broker(port, USERNAME, PASSWORD) + try: + # 1. 正确凭据 + c = await try_connect(cred_uri(port, USERNAME, PASSWORD), True, "正确凭据") + await c.subscribe([("auth/t/#", 1)]) + await c.publish("auth/t/1", b"hello", 1) + m = await asyncio.wait_for(c.deliver_message(), timeout=10) + assert m.data == b"hello", f"收到数据异常: {m.data}" + await c.disconnect() + print("PASS [A1] 正确凭据: 连接/订阅/发布/接收正常") + + # 2. 错误密码 + await try_connect(cred_uri(port, USERNAME, "wrong"), False, "错误密码") + print("PASS [A2] 错误密码: 连接被拒") + + # 3. 匿名 + await try_connect(cred_uri(port), False, "匿名连接") + print("PASS [A3] 匿名连接: 连接被拒") + finally: + proc.terminate() + proc.join(timeout=5) + + +async def scenario_anonymous(port: int): + """未配置凭据(默认)→ 匿名可用""" + proc = start_broker(port) + try: + c = await try_connect(cred_uri(port), True, "匿名连接(默认)") + await c.disconnect() + print("PASS [B] 默认匿名模式: 匿名连接正常") + finally: + proc.terminate() + proc.join(timeout=5) + + +class _FakeWorkerManager: + async def call(self, *a, **kw): + return {"ok": True} + + +async def scenario_bridge(port: int): + """MQTTBridge 客户端携带凭据(特殊字符密码经 URI 百分号编码)""" + proc = start_broker(port, USERNAME, PASSWORD) + sub = None + try: + sub = MQTTClient(config={"auto_reconnect": False}) + await sub.connect(cred_uri(port, USERNAME, PASSWORD)) + await sub.subscribe([("chongming/#", 1)]) + + bridge = MQTTBridge(worker_manager=_FakeWorkerManager(), + host=HOST, port=port, + username=USERNAME, password=PASSWORD) + await bridge.start() + assert bridge.is_running, "Bridge 未连接" + + # 发布请求 → Bridge 路由到 worker → 响应回发(验证完整链路带认证) + pub = MQTTClient(config={"auto_reconnect": False}) + await pub.connect(cred_uri(port, USERNAME, PASSWORD)) + await pub.subscribe([("chongming/demo/op/response", 1)]) + await pub.publish( + "chongming/demo/op", + b'{"meta": {"timeout": 10}, "data": {"x": 1}}', 1) + m = await asyncio.wait_for(sub.deliver_message(), timeout=10) + assert m.topic == "chongming/demo/op", f"未收到请求: {m.topic}" + m2 = await asyncio.wait_for(sub.deliver_message(), timeout=10) + assert m2.topic == "chongming/demo/op/response", f"未收到响应: {m2.topic}" + assert json.loads(m2.data) == {"ok": True}, f"响应内容异常: {m2.data}" + await bridge.stop() + await pub.disconnect() + print("PASS [C] MQTTBridge 凭据传递: 连接/路由/响应链路正常(含特殊字符密码)") + finally: + if sub and sub.session is not None: + try: + await sub.disconnect() + except Exception: + pass + proc.terminate() + proc.join(timeout=5) + + +async def main(): + p1, p2, p3 = free_port(), free_port(), free_port() + await scenario_auth(p1) + await scenario_anonymous(p2) + await scenario_bridge(p3) + print("\n全部通过 ✔") + + +if __name__ == "__main__": + multiprocessing.set_start_method("fork", force=True) + asyncio.run(main()) diff --git a/uv.lock b/uv.lock index 6ad06be..4417525 100644 --- a/uv.lock +++ b/uv.lock @@ -425,7 +425,7 @@ dependencies = [ [[package]] name = "chongming-cli" version = "0.1.0" -source = { git = "http://124.71.68.6:3000/chongming/chongming-utils-cli.git#0bf60021cbeb32610dce232eaf0e25be5d8d8b29" } +source = { git = "http://124.71.68.6:3000/chongming/chongming-utils-cli.git#e11d69d1ef49b5bec84f9dd7efdbc2259dac37d1" } dependencies = [ { name = "chongming-config" }, { name = "module-bank" }, @@ -661,15 +661,6 @@ wheels = [ { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19" }, ] -[[package]] -name = "distro" -version = "1.9.0" -source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" } -sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } -wheels = [ - { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, -] - [[package]] name = "eclipse-zenoh" version = "1.10.0" @@ -998,20 +989,19 @@ sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3f/d8/bd [[package]] name = "openai" -version = "3.3.0" +version = "3.3.1" source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" } dependencies = [ { name = "anyio" }, - { name = "distro" }, { name = "httpx2" }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e6/6c/670271205379061112bf5ed900b78a68e21bf11733de829772c5322f92aa/openai-3.3.0.tar.gz", hash = "sha256:28f904a9fbff15288e9dd67bc0f7020d4f576d37786383a480db02fe5d8139d3", size = 1166178, upload-time = "2026-08-18T21:17:53.419Z" } +sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7d/9c/ba0c292b4032ede74c249ca314ad64eb1bb5a03a843f6e01facb02f80cd8/openai-3.3.1.tar.gz", hash = "sha256:6f22807de1a976c932cecda620e8172a8c3fdbaeed29c7f21564e0c2410edf56", size = 1282113, upload-time = "2026-08-19T16:31:35.006Z" } wheels = [ - { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/82/83/d44980712bf51dddaca090cd772b3102e75377ddac877eaa4b2cb58d886a/openai-3.3.0-py3-none-any.whl", hash = "sha256:ded6b2112e6d299c7a2573ff6f165dc92fb64ceaa4d7daa42345f091157bd373", size = 1690265, upload-time = "2026-08-18T21:17:51.351Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6a/db/2b7a1b3de659bb82aef979116c74e809982b13e42c057759767552b5155f/openai-3.3.1-py3-none-any.whl", hash = "sha256:9652df7fdf8ee6f5bd58e0a12f2b1d414a18e0f06bb7a9a57c8643a5f5469bd3", size = 1690337, upload-time = "2026-08-19T16:31:32.812Z" }, ] [[package]]