Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 63 additions & 7 deletions backend/miloco/src/miloco/admin/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,7 @@ def _full_omni_payload() -> dict:
"api_key_masked": _mask_api_key(p.api_key),
"has_key": bool(p.api_key),
"active": p.label == active.label,
"extra_headers": dict(p.extra_headers or {}),
}
for p in m.omni_profiles
]
Expand All @@ -946,6 +947,7 @@ def _full_omni_payload() -> dict:
"api_key_masked": _mask_api_key(active.api_key),
"has_key": True,
"active": True,
"extra_headers": dict(active.extra_headers or {}),
},
)
health = asdict(get_omni_circuit_breaker().snapshot())
Expand All @@ -957,6 +959,7 @@ def _full_omni_payload() -> dict:
"api_key_masked": _mask_api_key(active.api_key),
"has_key": bool(active.api_key),
"health": health,
"extra_headers": dict(active.extra_headers or {}),
},
"profiles": profiles,
}
Expand All @@ -969,6 +972,7 @@ def _profiles_as_dicts() -> list[dict]:
"model": p.model,
"base_url": p.base_url,
"api_key": p.api_key,
"extra_headers": dict(p.extra_headers or {}),
}
for p in get_settings().model.omni_profiles
]
Expand All @@ -981,6 +985,13 @@ class OmniConfigBody(BaseModel):
api_key: str | None = None # 留空 = 沿用该档案原 key(不被打码值覆盖)
original_label: str | None = None # 正在编辑的档案原名(支持改名/定位);None=新增
activate: bool = True # True=同时设为当前生效;False=只入列表(激活由 /activate 负责)
extra_headers: dict[str, str] = Field(
default_factory=dict,
description=(
"附加请求头(可选)。部分网关要求携带自定义头才走特定通道或计费口径,"
"例如智谱 GLM Coding Plan 需要 X-Title 才计入 MCP 通道。"
),
)


class OmniSelectBody(BaseModel):
Expand Down Expand Up @@ -1033,15 +1044,23 @@ async def put_omni_config(
raise HTTPException(status_code=409, detail=f"档案名「{label}」已存在")
# 传 base_url 让 _key_by_label 校验"URL 未变才沿用旧 key",防跨 URL 复用凭证。
key = _key_by_label(orig or label, body.api_key, base_url=base_url)
entry = {"label": label, "base_url": base_url, "model": model, "api_key": key}
entry = {
"label": label,
"base_url": base_url,
"model": model,
"api_key": key,
"extra_headers": dict(body.extra_headers or {}),
}
tgt = orig or label
will_activate = body.activate or _label_is_active(tgt)
if will_activate:
if not key:
raise HTTPException(
status_code=400, detail={"code": "no_key", "message": "未配置 API Key"}
)
result = await _probe.probe_omni(model, base_url, key)
result = await _probe.probe_omni(
model, base_url, key, extra_headers=dict(body.extra_headers or {})
)
if not result.get("ok"):
raise HTTPException(status_code=400, detail=result)
if target:
Expand Down Expand Up @@ -1081,7 +1100,12 @@ async def activate_omni_config(
status_code=400,
detail={"code": "no_key", "message": "未配置 API Key"},
)
result = await _probe.probe_omni(p.model, p.base_url, p.api_key)
result = await _probe.probe_omni(
p.model,
p.base_url,
p.api_key,
extra_headers=dict(p.extra_headers or {}),
)
if not result.get("ok"):
raise HTTPException(status_code=400, detail=result)
update_shared_config(
Expand All @@ -1091,6 +1115,7 @@ async def activate_omni_config(
"model": p.model,
"base_url": p.base_url,
"api_key": p.api_key,
"extra_headers": dict(p.extra_headers or {}),
}
}
)
Expand Down Expand Up @@ -1188,7 +1213,7 @@ async def test_omni_config(
):
"""用表单值(缺省回退当前已保存配置)探测配置可用性。

OpenAI 兼容族(MiMo/Qwen)两阶段:先 GET /models 验鉴权/可达,再发一次 max_tokens=1 的
OpenAI 兼容族(MiMo/Qwen/GLM)两阶段:先 GET /models 验鉴权/可达,再发一次 max_tokens=1 的
极简 chat 真正验证该模型可用;非 OpenAI 兼容族(Gemini 等原生协议)没有等价 GET /models
预检语义,直接走 adapter 化的 chat 探测。消耗极少量 token,不计入 miloco 用量统计。
返回 {ok, code, status, latency_ms, message}。"""
Expand All @@ -1208,7 +1233,19 @@ async def test_omni_config(
message="ok",
data={"ok": False, "code": "no_key", "message": "未配置 API Key"},
)
result = await _probe.probe_omni(model, base_url, api_key)
# 取匹配档案(或当前 active)的 extra_headers,探测链路与推理链路保持同一配置。
label = (body.label or "").strip()
extra_headers: dict[str, str] = {}
if label:
for p in get_settings().model.omni_profiles:
if p.label == label:
extra_headers = dict(p.extra_headers or {})
break
if not extra_headers:
extra_headers = dict(omni.extra_headers or {})
result = await _probe.probe_omni(
model, base_url, api_key, extra_headers=extra_headers
)
# 测通 + 三元组精确匹配当前 active + 熔断非 ok → 主动清熔断,与 put/activate/retry
# 恢复路径对齐。护栏:测别的档案 / 未保存的新配置时不动状态。
# OPEN_CONFIG 下 tick 不会自动探测(只探 OPEN_RECOVERABLE),不清则用户测通了红条仍不消失,
Expand Down Expand Up @@ -1275,8 +1312,22 @@ async def list_omni_models(
"message": "未配置 API Key",
},
)
# 取匹配档案(或当前 active)的 extra_headers,探测链路与推理链路保持同一配置。
label = (body.label or "").strip()
extra_headers: dict[str, str] = {}
if label:
for p in get_settings().model.omni_profiles:
if p.label == label:
extra_headers = dict(p.extra_headers or {})
break
if not extra_headers:
extra_headers = dict(get_settings().model.omni.extra_headers or {})
return NormalResponse(
code=0, message="ok", data=await _probe.fetch_models(base_url, api_key)
code=0,
message="ok",
data=await _probe.fetch_models(
base_url, api_key, extra_headers=extra_headers
),
)


Expand Down Expand Up @@ -1401,7 +1452,12 @@ async def retry_omni_probe(current_user: str = Depends(verify_token)):
return NormalResponse(code=0, message="ok", data=_full_omni_payload())

try:
result = await _probe.probe_omni(omni.model, omni.base_url, omni.api_key)
result = await _probe.probe_omni(
omni.model,
omni.base_url,
omni.api_key,
extra_headers=dict(omni.extra_headers or {}),
)
except asyncio.CancelledError:
# 客户端断开 HTTP(用户切页/关 tab/网络抖动)时 FastAPI 抛 CancelledError。
# 此前 retry_now() 已把 state 置 HALF_OPEN,若不复位则 before_call 永久短路、
Expand Down
9 changes: 9 additions & 0 deletions backend/miloco/src/miloco/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ class OmniModelSettings(BaseModel):
default="",
description="多模态模型 API Key;为空时视为未配置,插件与后端启动前校验",
)
extra_headers: dict[str, str] = Field(
default_factory=dict,
description=(
"附加请求头(可选)。部分网关要求携带自定义头才走特定通道或计费口径,"
"例如智谱 GLM Coding Plan 需要 {\"X-Title\": \"...\"} 才计入 MCP 通道;"
"默认空,是否携带由用户自行决定。"
),
)


class ModelSettings(BaseModel):
Expand Down Expand Up @@ -711,6 +719,7 @@ def _propagate_model_omni_to_perception(self) -> "MilocoSettings":
"model": self.model.omni.model,
"base_url": self.model.omni.base_url,
"api_key": self.model.omni.api_key,
"extra_headers": dict(self.model.omni.extra_headers),
}
if merged != existing:
new_engine = {**self.perception.engine, "omni": merged}
Expand Down
5 changes: 4 additions & 1 deletion backend/miloco/src/miloco/perception/engine/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class InputConfig:
# 在构造前设 config.input.video_short_edge 期望生效,那样会被静默忽略。
video_short_edge: int = 512
# media_resolution: 仅 Gemini 生效的「每帧视觉 token 预算」档位。""/"low" = 66 tok/帧
# (默认、最省);"high" = 264 tok/帧(小目标/文字更清但 4× token)。mimo/qwen 忽略此字段。
# (默认、最省);"high" = 264 tok/帧(小目标/文字更清但 4× token)。mimo/qwen/glm 忽略此字段。
# 实测:小目标清晰度主要由输入像素分辨率(video_short_edge)决定,本档位只控每帧 token 预算,
# 故默认 low;identity 等细节敏感场景可经 CLI 切 high。运行时由 GeminiAdapter 实时读 settings。
media_resolution: str = ""
Expand Down Expand Up @@ -396,6 +396,9 @@ class OmniConfig:
top_p: float = 0.95
timeout: float = 30.0
stream: bool = False
# 附加请求头(可选)。部分网关要求携带自定义头才走特定通道/计费口径
# (如智谱 GLM Coding Plan 的 X-Title MCP 流量标识);默认空。
extra_headers: dict[str, str] = field(default_factory=dict)


@dataclass
Expand Down
9 changes: 5 additions & 4 deletions backend/miloco/src/miloco/perception/engine/omni/omni.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ async def run_omni(
edge_packet: IdentityPacket, context: OmniContext, config: OmniConfig
) -> OmniOutput:
"""Run Omni layer: build prompt → call model → parse response."""
payload = build_prompt(edge_packet, context)
payload = build_prompt(edge_packet, context, adapter=get_adapter(config.model))
raw_response = await call_omni(payload, config)
output = parse_omni_response(raw_response, _rule_name_to_id(context))
output.usage = extract_usage(raw_response)
Expand All @@ -104,7 +104,7 @@ async def run_omni_batch(
edge_packets: list[IdentityPacket], context: OmniContext, config: OmniConfig
) -> OmniOutput:
"""Run Omni layer for multiple devices in the same room."""
payload = build_batch_prompt(edge_packets, context)
payload = build_batch_prompt(edge_packets, context, adapter=get_adapter(config.model))
raw_response = await call_omni(payload, config)
output = parse_omni_response(raw_response, _rule_name_to_id(context))
output.usage = extract_usage(raw_response)
Expand Down Expand Up @@ -325,6 +325,7 @@ async def _call_omni_messages(
"Content-Type": "application/json",
**adapter.auth_headers(api_key),
"User-Agent": MILOCO_USER_AGENT,
**(config.extra_headers or {}),
}
try:
await cb.before_call()
Expand Down Expand Up @@ -476,7 +477,7 @@ async def run_omni_stream(
on_early_suggestions: Callable[[list[Suggestion]], Awaitable[None]] | None = None,
) -> OmniOutput:
"""Run Omni layer with streaming — extracts actionable fields early via callbacks."""
payload = build_stream_prompt(edge_packet, context)
payload = build_stream_prompt(edge_packet, context, adapter=get_adapter(config.model))
return await _stream_and_parse(
payload,
config,
Expand All @@ -497,7 +498,7 @@ async def run_omni_batch_stream(
on_early_suggestions: Callable[[list[Suggestion]], Awaitable[None]] | None = None,
) -> OmniOutput:
"""Run Omni layer for multiple devices with streaming — extracts actionable fields early."""
payload = build_batch_stream_prompt(edge_packets, context)
payload = build_batch_stream_prompt(edge_packets, context, adapter=get_adapter(config.model))
return await _stream_and_parse(
payload,
config,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ def resolve_live_omni_config(base: OmniConfig) -> OmniConfig:
model=o.model,
base_url=o.base_url,
api_key=o.api_key or base.api_key,
extra_headers=dict(o.extra_headers),
)
_maybe_reset_breaker_on_config_change(resolved)
return resolved
Expand Down Expand Up @@ -217,6 +218,7 @@ async def call_omni(
"Content-Type": "application/json",
**adapter.auth_headers(api_key),
"User-Agent": MILOCO_USER_AGENT,
**(config.extra_headers or {}),
}
try:
await cb.before_call() # 熔断 OPEN → 直接抛 CircuitOpenError
Expand Down Expand Up @@ -362,7 +364,7 @@ def _build_messages(payload: dict, adapter: OmniProviderAdapter) -> list[dict]:

if payload.get("video_base64"):
content.append(adapter.build_video_block(payload["video_base64"], media_info))
elif payload.get("audio_base64"):
elif payload.get("audio_base64") and adapter.supports_audio_input:
content.append(adapter.build_audio_block(payload["audio_base64"], media_info))

# Crop images (from tracker)
Expand Down Expand Up @@ -435,6 +437,7 @@ async def call_omni_stream(
"Content-Type": "application/json",
**adapter.auth_headers(api_key),
"User-Agent": MILOCO_USER_AGENT,
**(config.extra_headers or {}),
}
url = adapter.endpoint(config.base_url, config.model, stream=True)

Expand Down
30 changes: 24 additions & 6 deletions backend/miloco/src/miloco/perception/engine/omni/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@
return {"code": "http_error", "message": f"服务返回异常(HTTP {r.status_code})"}


async def fetch_models(base_url: str, api_key: str) -> dict[str, Any]:
async def fetch_models(
base_url: str, api_key: str, extra_headers: dict[str, str] | None = None
) -> dict[str, Any]:
"""拉取 provider 模型列表(GET /models)。

模型下拉在「选定 model 之前」拉取,没有 model 可路由 adapter,故按 base_url 判 provider:
Expand All @@ -88,6 +90,7 @@
if is_gemini
else {"Authorization": f"Bearer {api_key}"}
)
headers.update(extra_headers or {})
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.get(f"{base}/models", headers=headers)
Expand Down Expand Up @@ -179,7 +182,12 @@
return 500, 0, False, {}


async def probe_chat(model: str, base_url: str, api_key: str) -> dict[str, Any]:
async def probe_chat(
model: str,
base_url: str,
api_key: str,
extra_headers: dict[str, str] | None = None,
) -> dict[str, Any]:
"""极简 chat 探测(max_tokens=1)真校验模型是否可用。

走 provider adapter 生成 body,兼容不同 provider 的强制要求(Qwen 强制
Expand Down Expand Up @@ -210,6 +218,7 @@
headers = {
**adapter.auth_headers(api_key),
"Content-Type": "application/json",
**(extra_headers or {}),
}
t0 = time.monotonic()
try:
Expand Down Expand Up @@ -330,7 +339,12 @@
}


async def probe_omni(model: str, base_url: str, api_key: str) -> dict[str, Any]:
async def probe_omni(
model: str,
base_url: str,
api_key: str,
extra_headers: dict[str, str] | None = None,
) -> dict[str, Any]:
"""两阶段探测:GET /models 预检 → 极简 chat 真校验。

- GET /models 网络错 → unreachable
Expand All @@ -351,12 +365,16 @@
)

if not isinstance(get_adapter(model), OpenAICompatAdapter):
return await probe_chat(model, base, api_key)
return await probe_chat(model, base, api_key, extra_headers)
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
r = await client.get(
f"{base}/models", headers={"Authorization": f"Bearer {api_key}"}
f"{base}/models",
headers={
"Authorization": f"Bearer {api_key}",
**(extra_headers or {}),
},
)

Check failure

Code scanning / CodeQL

Partial server-side request forgery Critical

Part of the URL of this request depends on a
user-provided value
.
Part of the URL of this request depends on a
user-provided value
.
except Exception as e: # noqa: BLE001
return {
"ok": False,
Expand All @@ -377,4 +395,4 @@
"status": r.status_code,
"message": f"服务返回异常(HTTP {r.status_code})",
}
return await probe_chat(model, base, api_key)
return await probe_chat(model, base, api_key, extra_headers)
Loading
Loading