mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-17 06:11:01 +08:00
初始化:独立 oclaw 仓库首提交
- 在 oclaw/ 下重新初始化 Git 仓库 - 补齐子仓库 .gitignore,避免提交本地运行态数据(_local、node_modules、logs 等) - 提交当前工程代码与配置 Made-with: Cursor
This commit is contained in:
commit
ba3836f00f
579 changed files with 83112 additions and 0 deletions
27
platform/llm/OPENCLAW_MIT_LICENSE.txt
Normal file
27
platform/llm/OPENCLAW_MIT_LICENSE.txt
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Peter Steinberger (OpenClaw)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
The Python modules `tool_call_id.py` and `replay_policy.py` in this directory
|
||||
implement replay / tool-call-id handling inspired by OpenClaw's
|
||||
`src/agents/tool-call-id.ts` and `src/agents/transcript-policy.ts`.
|
||||
1
platform/llm/__init__.py
Normal file
1
platform/llm/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# oclaw.platform.llm package
|
||||
69
platform/llm/chat_models.py
Normal file
69
platform/llm/chat_models.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
"""Compatibility re-export layer.
|
||||
|
||||
The codebase imports `ChatModel/OpenAIChatModel/...` from this module.
|
||||
Implementations live under `src.platform.llm.transports.*`.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from oclaw.platform.llm.tool_schema import shrink_openai_tools_payload_for_api
|
||||
from oclaw.platform.llm.tool_schema import openai_tools_json_byte_length as _openai_tools_json_byte_length
|
||||
from oclaw.platform.llm.transports.base import ChatModel, LLMResponse, LLMToolCall, normalize_image_b64_payload
|
||||
from oclaw.platform.llm.transports.google_gemini_sse import GoogleGeminiChatModel
|
||||
from oclaw.platform.llm.transports.openai_chat_completions import (
|
||||
OpenAIChatModel,
|
||||
_likely_gemini_openai_compat_base_url,
|
||||
_model_id_suggests_gemini,
|
||||
)
|
||||
from oclaw.platform.llm.transports.simple import RuleBasedChatModel, StaticTextChatModel
|
||||
|
||||
# Backward-compat alias used by `src/chat/agent.py`.
|
||||
_normalize_image_b64_payload = normalize_image_b64_payload
|
||||
|
||||
|
||||
def gemini_openai_compat_client(model: ChatModel) -> bool:
|
||||
if not isinstance(model, OpenAIChatModel):
|
||||
return False
|
||||
return _likely_gemini_openai_compat_base_url(getattr(model, "base_url", None)) or _model_id_suggests_gemini(
|
||||
getattr(model, "model", None)
|
||||
)
|
||||
|
||||
|
||||
def build_default_model() -> ChatModel:
|
||||
mode = (os.getenv("AIA_ASSISTANT_MODE") or "").strip().lower()
|
||||
if mode == "rule":
|
||||
return RuleBasedChatModel()
|
||||
if mode == "ollama":
|
||||
try:
|
||||
from oclaw.agents.factory import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL
|
||||
|
||||
return OpenAIChatModel(model=DEFAULT_OLLAMA_MODEL, api_key="ollama", base_url=DEFAULT_OLLAMA_BASE_URL)
|
||||
except Exception:
|
||||
return RuleBasedChatModel()
|
||||
if mode == "openai" or mode == "":
|
||||
try:
|
||||
return OpenAIChatModel()
|
||||
except Exception:
|
||||
return RuleBasedChatModel()
|
||||
# Provider selection is normally handled via LLM profiles in `src/agents/factory.py`.
|
||||
return RuleBasedChatModel()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ChatModel",
|
||||
"LLMResponse",
|
||||
"LLMToolCall",
|
||||
"OpenAIChatModel",
|
||||
"GoogleGeminiChatModel",
|
||||
"RuleBasedChatModel",
|
||||
"StaticTextChatModel",
|
||||
"build_default_model",
|
||||
"gemini_openai_compat_client",
|
||||
"normalize_image_b64_payload",
|
||||
"_normalize_image_b64_payload",
|
||||
"shrink_openai_tools_payload_for_api",
|
||||
"_openai_tools_json_byte_length",
|
||||
]
|
||||
|
||||
685
platform/llm/image_message_client.py
Normal file
685
platform/llm/image_message_client.py
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from PIL import Image
|
||||
from oclaw.prompts import render_prompt
|
||||
|
||||
|
||||
def _summarize_data_url(url: str) -> dict[str, Any]:
|
||||
"""
|
||||
Summarize data URLs without logging raw base64 payload.
|
||||
"""
|
||||
s = str(url or "")
|
||||
if not s.startswith("data:"):
|
||||
return {"kind": "url", "prefix": s[:30]}
|
||||
mime = "image/jpeg"
|
||||
try:
|
||||
# data:{mime};base64,{b64}
|
||||
head = s.split(";", 1)[0] # data:image/xxx
|
||||
if head.startswith("data:"):
|
||||
mime = head[len("data:") :] or mime
|
||||
except Exception:
|
||||
pass
|
||||
b64_part = s.split(",", 1)[1] if "," in s else ""
|
||||
has_b64 = ";base64," in s
|
||||
return {"kind": "data_url", "mime": mime, "has_base64": has_b64, "b64_len": len(b64_part)}
|
||||
|
||||
|
||||
def _summarize_dashscope_messages(messages: list[dict[str, Any]], *, max_items: int = 3) -> dict[str, Any]:
|
||||
"""
|
||||
Debug-only summary of messages[0].content structure.
|
||||
"""
|
||||
if not messages or not isinstance(messages, list):
|
||||
return {"content_items": []}
|
||||
first = messages[0] if messages else {}
|
||||
if not isinstance(first, dict):
|
||||
return {"content_items": []}
|
||||
content = first.get("content")
|
||||
if not isinstance(content, list):
|
||||
return {"content_items": []}
|
||||
out: list[dict[str, Any]] = []
|
||||
for it in content[:max_items]:
|
||||
if not isinstance(it, dict):
|
||||
out.append({"item_type": "non_dict"})
|
||||
continue
|
||||
item: dict[str, Any] = {"keys": sorted([str(k) for k in it.keys()])}
|
||||
if "type" in it:
|
||||
item["type"] = it.get("type")
|
||||
if "image" in it:
|
||||
item["image"] = _summarize_data_url(str(it.get("image") or ""))
|
||||
if "image_url" in it:
|
||||
iu = it.get("image_url")
|
||||
if isinstance(iu, dict) and "url" in iu:
|
||||
item["image_url"] = _summarize_data_url(str(iu.get("url") or ""))
|
||||
else:
|
||||
item["image_url"] = {"kind": "image_url_non_dict"}
|
||||
if "text" in it:
|
||||
item["text_len"] = len(str(it.get("text") or ""))
|
||||
out.append(item)
|
||||
return {"content_items": out}
|
||||
|
||||
|
||||
def _join_url(base: str, path: str) -> str:
|
||||
b = (base or "").rstrip("/")
|
||||
p = (path or "").lstrip("/")
|
||||
return f"{b}/{p}"
|
||||
|
||||
|
||||
def _is_data_url(s: str) -> bool:
|
||||
return s.startswith("data:") and ";base64," in s
|
||||
|
||||
|
||||
def _compress_data_url_image(
|
||||
data_url: str,
|
||||
*,
|
||||
max_side: int = 1600,
|
||||
max_bytes: int = 2 * 1024 * 1024,
|
||||
jpeg_quality: int = 82,
|
||||
) -> str:
|
||||
"""Best-effort compress image data URLs to reduce transport failures."""
|
||||
if not _is_data_url(data_url):
|
||||
return data_url
|
||||
try:
|
||||
header, b64 = data_url.split(";base64,", 1)
|
||||
mime = header.replace("data:", "", 1).strip() or "image/jpeg"
|
||||
raw = base64.b64decode(b64.encode("ascii"))
|
||||
if len(raw) <= max_bytes:
|
||||
return data_url
|
||||
|
||||
with Image.open(io.BytesIO(raw)) as im:
|
||||
im = im.convert("RGB")
|
||||
w, h = im.size
|
||||
longest = max(w, h)
|
||||
if longest > max_side:
|
||||
scale = max_side / float(longest)
|
||||
nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
|
||||
im = im.resize((nw, nh), Image.Resampling.LANCZOS)
|
||||
|
||||
out = io.BytesIO()
|
||||
# Use JPEG for broad compatibility and smaller payload.
|
||||
im.save(out, format="JPEG", quality=max(30, min(95, int(jpeg_quality))), optimize=True)
|
||||
blob = out.getvalue()
|
||||
if not blob:
|
||||
return data_url
|
||||
b64_new = base64.b64encode(blob).decode("ascii")
|
||||
return f"data:{'image/jpeg' if mime.startswith('image/') else mime};base64,{b64_new}"
|
||||
except Exception:
|
||||
return data_url
|
||||
|
||||
|
||||
def _post_with_retry(
|
||||
client: httpx.Client,
|
||||
*,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
payload: dict[str, Any],
|
||||
retries: int = 3,
|
||||
backoff_sec: float = 0.8,
|
||||
) -> httpx.Response:
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, max(1, retries) + 1):
|
||||
try:
|
||||
return client.post(url, headers=headers, json=payload)
|
||||
except (httpx.ReadError, httpx.ConnectError, httpx.TimeoutException) as e:
|
||||
last_exc = e
|
||||
if attempt >= retries:
|
||||
break
|
||||
time.sleep(backoff_sec * (2 ** (attempt - 1)))
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
||||
def _extract_text_and_images(resp_json: dict[str, Any]) -> tuple[str, list[str]]:
|
||||
text_parts: list[str] = []
|
||||
images: list[str] = []
|
||||
choices = resp_json.get("choices")
|
||||
if isinstance(choices, list) and choices:
|
||||
msg = choices[0].get("message") if isinstance(choices[0], dict) else None
|
||||
if isinstance(msg, dict):
|
||||
c = msg.get("content")
|
||||
if isinstance(c, str):
|
||||
text_parts.append(c)
|
||||
elif isinstance(c, list):
|
||||
for it in c:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
if isinstance(it.get("text"), str):
|
||||
text_parts.append(str(it.get("text")))
|
||||
elif isinstance(it.get("image"), str):
|
||||
images.append(str(it.get("image")))
|
||||
elif isinstance(it.get("image_url"), str):
|
||||
images.append(str(it.get("image_url")))
|
||||
elif isinstance(it.get("image_url"), dict) and isinstance(it["image_url"].get("url"), str):
|
||||
images.append(str(it["image_url"]["url"]))
|
||||
elif isinstance(it.get("b64_json"), str):
|
||||
images.append(f"data:image/png;base64,{it['b64_json']}")
|
||||
|
||||
# fallback for image APIs that return data[0].b64_json/url
|
||||
if not images:
|
||||
data = resp_json.get("data")
|
||||
if isinstance(data, list):
|
||||
for it in data:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
if isinstance(it.get("url"), str):
|
||||
images.append(str(it.get("url")))
|
||||
if isinstance(it.get("b64_json"), str):
|
||||
images.append(f"data:image/png;base64,{it['b64_json']}")
|
||||
return "\n".join([x for x in text_parts if x]).strip(), images
|
||||
|
||||
|
||||
def _extract_from_dashscope_response(resp: Any) -> tuple[str, list[str], str]:
|
||||
"""Parse dashscope SDK response object (or dict-like) into text/images."""
|
||||
text_parts: list[str] = []
|
||||
images: list[str] = []
|
||||
|
||||
def _as_dict(x: Any) -> dict[str, Any]:
|
||||
if isinstance(x, dict):
|
||||
return x
|
||||
if hasattr(x, "__dict__"):
|
||||
try:
|
||||
return dict(vars(x))
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
if isinstance(resp, dict):
|
||||
obj = resp
|
||||
else:
|
||||
obj = _as_dict(resp)
|
||||
if not obj and hasattr(resp, "to_dict"):
|
||||
try:
|
||||
obj = resp.to_dict()
|
||||
except Exception:
|
||||
obj = {}
|
||||
|
||||
status_code = int(obj.get("status_code") or getattr(resp, "status_code", 0) or 0)
|
||||
output = obj.get("output") if isinstance(obj.get("output"), dict) else _as_dict(getattr(resp, "output", {}))
|
||||
choices = output.get("choices") if isinstance(output, dict) else None
|
||||
if isinstance(choices, list) and choices:
|
||||
msg = choices[0].get("message") if isinstance(choices[0], dict) else _as_dict(getattr(choices[0], "message", {}))
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for it in content:
|
||||
# DashScope SDK sometimes returns content items as objects, not plain dicts.
|
||||
# Convert best-effort so we don't silently drop images.
|
||||
it_d: dict[str, Any]
|
||||
if isinstance(it, dict):
|
||||
it_d = it
|
||||
else:
|
||||
it_d = _as_dict(it)
|
||||
if not it_d:
|
||||
continue
|
||||
if isinstance(it_d.get("text"), str):
|
||||
text_parts.append(str(it_d["text"]))
|
||||
# image can be str/dict/list depending on SDK variant
|
||||
img_val = it_d.get("image")
|
||||
if isinstance(img_val, str):
|
||||
images.append(img_val)
|
||||
elif isinstance(img_val, dict):
|
||||
if isinstance(img_val.get("url"), str):
|
||||
images.append(str(img_val["url"]))
|
||||
elif isinstance(img_val, list):
|
||||
for elem in img_val:
|
||||
if isinstance(elem, str):
|
||||
images.append(elem)
|
||||
elif isinstance(elem, dict) and isinstance(elem.get("url"), str):
|
||||
images.append(str(elem["url"]))
|
||||
|
||||
if isinstance(it_d.get("image_url"), str):
|
||||
images.append(str(it_d["image_url"]))
|
||||
elif isinstance(it_d.get("image_url"), dict) and isinstance(it_d["image_url"].get("url"), str):
|
||||
images.append(str(it_d["image_url"]["url"]))
|
||||
elif isinstance(it_d.get("image_url"), list):
|
||||
for elem in it_d["image_url"]:
|
||||
if isinstance(elem, str):
|
||||
images.append(elem)
|
||||
elif isinstance(elem, dict) and isinstance(elem.get("url"), str):
|
||||
images.append(str(elem["url"]))
|
||||
|
||||
b64_val = it_d.get("b64_json")
|
||||
if isinstance(b64_val, str):
|
||||
images.append(f"data:image/png;base64,{b64_val}")
|
||||
elif isinstance(b64_val, list):
|
||||
for elem in b64_val:
|
||||
if isinstance(elem, str):
|
||||
images.append(f"data:image/png;base64,{elem}")
|
||||
|
||||
# fallback for variants returning output.data[{url|b64_json}]
|
||||
if not images and isinstance(output, dict):
|
||||
data = output.get("data")
|
||||
if isinstance(data, list):
|
||||
for it in data:
|
||||
it_d: dict[str, Any]
|
||||
if isinstance(it, dict):
|
||||
it_d = it
|
||||
else:
|
||||
it_d = _as_dict(it)
|
||||
if not it_d:
|
||||
continue
|
||||
url_val = it_d.get("url")
|
||||
if isinstance(url_val, str):
|
||||
images.append(url_val)
|
||||
elif isinstance(url_val, list):
|
||||
for elem in url_val:
|
||||
if isinstance(elem, str):
|
||||
images.append(elem)
|
||||
|
||||
b64_val = it_d.get("b64_json")
|
||||
if isinstance(b64_val, str):
|
||||
images.append(f"data:image/png;base64,{b64_val}")
|
||||
elif isinstance(b64_val, list):
|
||||
for elem in b64_val:
|
||||
if isinstance(elem, str):
|
||||
images.append(f"data:image/png;base64,{elem}")
|
||||
|
||||
return "\n".join([x for x in text_parts if x]).strip(), images, str(status_code)
|
||||
|
||||
|
||||
def _dashscope_messages(images: list[str], prompt: str) -> list[dict[str, Any]]:
|
||||
prompt_text = str(prompt or "").strip() or render_prompt("image/default_edit_prompt.zh.md", strict=True)
|
||||
content: list[dict[str, Any]] = [{"image": img} for img in images]
|
||||
content.append({"text": prompt_text})
|
||||
return [{"role": "user", "content": content}]
|
||||
|
||||
|
||||
def _http_content_blocks(images: list[str], prompt: str, *, typed: bool) -> list[dict[str, Any]]:
|
||||
prompt_text = str(prompt or "").strip() or render_prompt("image/default_edit_prompt.zh.md", strict=True)
|
||||
if not typed:
|
||||
blocks: list[dict[str, Any]] = [{"image": img} for img in images]
|
||||
blocks.append({"text": prompt_text})
|
||||
return blocks
|
||||
# DashScope compatible-mode error is very explicit about `content[*].type` missing.
|
||||
# To align with DashScope's simple schema keys ({"image": ...}, {"text": ...}),
|
||||
# we keep those keys and only add the `type` discriminator.
|
||||
blocks_typed: list[dict[str, Any]] = [{"type": "image", "image": img} for img in images]
|
||||
blocks_typed.append({"type": "text", "text": prompt_text})
|
||||
return blocks_typed
|
||||
|
||||
|
||||
def _responses_input_messages(images: list[str], prompt: str) -> list[dict[str, Any]]:
|
||||
prompt_text = str(prompt or "").strip() or render_prompt("image/default_edit_prompt.zh.md", strict=True)
|
||||
content: list[dict[str, Any]] = []
|
||||
for img in images:
|
||||
s = str(img or "").strip()
|
||||
if not s:
|
||||
continue
|
||||
if s.startswith("data:") or s.startswith("http://") or s.startswith("https://"):
|
||||
content.append({"type": "input_image", "image_url": s})
|
||||
if prompt_text:
|
||||
content.append({"type": "input_text", "text": prompt_text})
|
||||
return [{"role": "user", "content": content}]
|
||||
|
||||
|
||||
def _dashscope_typed_messages(images: list[str], prompt: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
DashScope compatible-mode often requires a typed schema, where each content item has `type`.
|
||||
Example:
|
||||
{"type":"image_url","image_url":{"url":"..."}}, {"type":"text","text":"..."}
|
||||
"""
|
||||
typed_blocks = _http_content_blocks(images, prompt, typed=True)
|
||||
return [{"role": "user", "content": typed_blocks}]
|
||||
|
||||
|
||||
def _send_via_dashscope(
|
||||
*,
|
||||
images: list[str],
|
||||
prompt: str,
|
||||
model_name: str,
|
||||
api_key: str | None = None,
|
||||
base_http_api_url: str | None = None,
|
||||
force_typed_schema: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
import dashscope # type: ignore
|
||||
from dashscope import MultiModalConversation # type: ignore
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"dashscope package unavailable: {type(e).__name__}: {e}"}
|
||||
|
||||
resolved_key = (api_key or os.getenv("DASHSCOPE_API_KEY") or "").strip()
|
||||
if not resolved_key:
|
||||
return {"ok": False, "error": "missing DASHSCOPE_API_KEY"}
|
||||
|
||||
ds_base = (base_http_api_url or os.getenv("DASHSCOPE_BASE_HTTP_API_URL") or "").strip()
|
||||
if ds_base:
|
||||
dashscope.base_http_api_url = ds_base
|
||||
|
||||
call_kwargs_base: dict[str, Any] = {
|
||||
"api_key": resolved_key,
|
||||
"model": model_name,
|
||||
"stream": False,
|
||||
}
|
||||
# Optional params compatible with user's sample
|
||||
if os.getenv("DASHSCOPE_IMAGE_N"):
|
||||
try:
|
||||
call_kwargs_base["n"] = int(os.getenv("DASHSCOPE_IMAGE_N") or "1")
|
||||
except Exception:
|
||||
pass
|
||||
if os.getenv("DASHSCOPE_IMAGE_WATERMARK"):
|
||||
call_kwargs_base["watermark"] = os.getenv("DASHSCOPE_IMAGE_WATERMARK", "false").lower() in ("1", "true", "yes")
|
||||
if os.getenv("DASHSCOPE_IMAGE_NEGATIVE_PROMPT"):
|
||||
call_kwargs_base["negative_prompt"] = os.getenv("DASHSCOPE_IMAGE_NEGATIVE_PROMPT")
|
||||
if os.getenv("DASHSCOPE_IMAGE_PROMPT_EXTEND"):
|
||||
call_kwargs_base["prompt_extend"] = os.getenv("DASHSCOPE_IMAGE_PROMPT_EXTEND", "true").lower() in ("1", "true", "yes")
|
||||
if os.getenv("DASHSCOPE_IMAGE_SIZE"):
|
||||
call_kwargs_base["size"] = os.getenv("DASHSCOPE_IMAGE_SIZE")
|
||||
|
||||
def _call_with_retry(messages: list[dict[str, Any]]) -> tuple[Any | None, Exception | None]:
|
||||
retries = max(1, int(os.getenv("AIA_IMAGE_RETRIES", "3") or "3"))
|
||||
backoff_sec = float(os.getenv("AIA_IMAGE_RETRY_BACKOFF_SEC", "0.8") or "0.8")
|
||||
last_exc: Exception | None = None
|
||||
resp_obj: Any | None = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
kwargs = dict(call_kwargs_base)
|
||||
kwargs["messages"] = messages
|
||||
resp_obj = MultiModalConversation.call(**kwargs)
|
||||
return resp_obj, None
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if attempt >= retries:
|
||||
break
|
||||
time.sleep(backoff_sec * (2 ** (attempt - 1)))
|
||||
return None, last_exc
|
||||
|
||||
# DashScope SDK path:
|
||||
# Compatible-mode may require typed content schema (`content[*].type`),
|
||||
# so if we detect compatible-mode, prefer typed schema directly.
|
||||
typed_msgs = _dashscope_typed_messages(images, prompt)
|
||||
simple_msgs = _dashscope_messages(images, prompt)
|
||||
|
||||
first_msgs = typed_msgs if force_typed_schema else simple_msgs
|
||||
second_msgs = simple_msgs if force_typed_schema else typed_msgs
|
||||
|
||||
first_schema = "typed" if first_msgs is typed_msgs else "simple"
|
||||
second_schema = "simple" if first_schema == "typed" else "typed"
|
||||
debug_first = _summarize_dashscope_messages(first_msgs)
|
||||
debug_second = _summarize_dashscope_messages(second_msgs)
|
||||
|
||||
used_schema = first_schema
|
||||
used_debug = debug_first
|
||||
|
||||
resp, err = _call_with_retry(first_msgs)
|
||||
if resp is None:
|
||||
# If the first schema fails without a response, try the other schema once.
|
||||
resp2, err2 = _call_with_retry(second_msgs)
|
||||
if resp2 is None:
|
||||
assert err2 is not None
|
||||
return {
|
||||
"ok": False,
|
||||
"error": (
|
||||
f"dashscope call failed: {type(err2).__name__}: {err2} "
|
||||
f"(debug={{first_schema={first_schema},second_schema={second_schema}}})"
|
||||
)[:900],
|
||||
}
|
||||
resp = resp2
|
||||
used_schema = second_schema
|
||||
used_debug = debug_second
|
||||
|
||||
text, out_images, status = _extract_from_dashscope_response(resp)
|
||||
try:
|
||||
status_code = int(status)
|
||||
except Exception:
|
||||
status_code = 0
|
||||
if status_code and status_code != 200:
|
||||
# DashScope may return HTTP 429 (rate limit) as a non-exception status.
|
||||
# _call_with_retry only retries on exceptions, so handle 429 explicitly here.
|
||||
if status_code == 429:
|
||||
status_retries = max(1, int(os.getenv("AIA_IMAGE_STATUS_RETRIES", "4") or "4"))
|
||||
status_backoff_sec = float(os.getenv("AIA_IMAGE_STATUS_RETRY_BACKOFF_SEC", "5.0") or "5.0")
|
||||
for attempt in range(1, status_retries):
|
||||
time.sleep(status_backoff_sec * (2 ** (attempt - 1)))
|
||||
resp_retry, _err_retry = _call_with_retry(simple_msgs)
|
||||
if resp_retry is None:
|
||||
continue
|
||||
text_r, out_images_r, status_r = _extract_from_dashscope_response(resp_retry)
|
||||
try:
|
||||
status_r_code = int(status_r)
|
||||
except Exception:
|
||||
status_r_code = 0
|
||||
if status_r_code == 200 or not status_r_code:
|
||||
return {
|
||||
"ok": True,
|
||||
"text": text_r,
|
||||
"images": out_images_r,
|
||||
"backend_shape": "dashscope",
|
||||
}
|
||||
if status_r_code == 400:
|
||||
# If we hit schema issues after rate limit, fall back to typed once.
|
||||
typed_msgs = _dashscope_typed_messages(images, prompt)
|
||||
resp_t, _err_t = _call_with_retry(typed_msgs)
|
||||
if resp_t is not None:
|
||||
text_t, out_images_t, status_t = _extract_from_dashscope_response(resp_t)
|
||||
try:
|
||||
status_t_code = int(status_t)
|
||||
except Exception:
|
||||
status_t_code = 0
|
||||
if status_t_code == 200 or not status_t_code:
|
||||
return {
|
||||
"ok": True,
|
||||
"text": text_t,
|
||||
"images": out_images_t,
|
||||
"backend_shape": "dashscope",
|
||||
}
|
||||
# continue loop for more 429 tries
|
||||
code = getattr(resp, "code", "") or ""
|
||||
msg = getattr(resp, "message", "") or ""
|
||||
return {
|
||||
"ok": False,
|
||||
"error": (
|
||||
f"dashscope status={status_code} code={code} debug={{used_schema={used_schema},used_debug={used_debug}}} "
|
||||
f"message={msg[:400]} (after 429 retries)"
|
||||
),
|
||||
"backend_shape": "dashscope",
|
||||
}
|
||||
|
||||
# Compatible-mode may reject "simple" schema with HTTP 400 and a message like:
|
||||
# messages[0].content[1].type missing required parameter.
|
||||
# To make this robust, retry typed schema whenever we see 400.
|
||||
if status_code == 400:
|
||||
resp2, _err2 = _call_with_retry(typed_msgs)
|
||||
if resp2 is not None:
|
||||
text2, out_images2, status2 = _extract_from_dashscope_response(resp2)
|
||||
try:
|
||||
status2_code = int(status2)
|
||||
except Exception:
|
||||
status2_code = 0
|
||||
if status2_code == 200 or not status2_code:
|
||||
return {
|
||||
"ok": True,
|
||||
"text": text2,
|
||||
"images": out_images2,
|
||||
"backend_shape": "dashscope",
|
||||
}
|
||||
|
||||
code = getattr(resp, "code", "") or ""
|
||||
msg = getattr(resp, "message", "") or ""
|
||||
return {
|
||||
"ok": False,
|
||||
"error": (
|
||||
f"dashscope status={status_code} code={code} debug={{used_schema={used_schema},used_debug={used_debug}}} "
|
||||
f"message={msg[:400]}"
|
||||
),
|
||||
"backend_shape": "dashscope",
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"text": text,
|
||||
"images": out_images,
|
||||
"backend_shape": "dashscope",
|
||||
"debug_used_schema": used_schema,
|
||||
"debug_used_debug": used_debug,
|
||||
}
|
||||
|
||||
|
||||
def send_image_messages(
|
||||
*,
|
||||
images: list[str],
|
||||
prompt: str,
|
||||
model: str | None = None,
|
||||
timeout_sec: int = 60,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
dashscope_api_key: str | None = None,
|
||||
dashscope_base_http_api_url: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send multimodal messages payload with image/text content blocks.
|
||||
|
||||
Preferred payload: {"messages":[{"role":"user","content":[{"image":"..."},{"text":"..."}]}]}
|
||||
Auto-compat: try multi-image first, then fallback to single `image` field style.
|
||||
"""
|
||||
model_name = (
|
||||
model
|
||||
or os.getenv("AIA_IMAGE_MODEL")
|
||||
or os.getenv("DASHSCOPE_IMAGE_MODEL")
|
||||
or os.getenv("OPENAI_MODEL")
|
||||
or "qwen-image-2.0-pro"
|
||||
).strip()
|
||||
resolved_base_url = (base_url or os.getenv("AIA_IMAGE_BASE_URL") or os.getenv("OPENAI_BASE_URL") or "https://api.openai.com/v1").strip()
|
||||
resolved_api_key = (api_key or os.getenv("AIA_IMAGE_API_KEY") or os.getenv("OPENAI_API_KEY") or "").strip()
|
||||
resolved_ds_key = (dashscope_api_key or os.getenv("DASHSCOPE_API_KEY") or "").strip()
|
||||
resolved_ds_base = (dashscope_base_http_api_url or os.getenv("DASHSCOPE_BASE_HTTP_API_URL") or "").strip()
|
||||
endpoint = (os.getenv("AIA_IMAGE_CHAT_ENDPOINT") or "/chat/completions").strip()
|
||||
url = _join_url(resolved_base_url, endpoint)
|
||||
if not images:
|
||||
return {"ok": False, "error": "at least one image input is required"}
|
||||
|
||||
raw_selected = [str(x).strip() for x in images if str(x).strip()][:3]
|
||||
selected: list[str] = []
|
||||
input_kind: list[str] = []
|
||||
for img in raw_selected:
|
||||
if _is_data_url(img):
|
||||
selected.append(_compress_data_url_image(img))
|
||||
input_kind.append("data_url")
|
||||
continue
|
||||
if img.startswith("http://") or img.startswith("https://"):
|
||||
# Keep URL as-is when caller provides URL input.
|
||||
selected.append(img)
|
||||
input_kind.append("url")
|
||||
continue
|
||||
if not selected:
|
||||
return {"ok": False, "error": "no usable image input (expected URL or data URL)"}
|
||||
|
||||
if resolved_ds_key:
|
||||
# SpecialistAgent already normalizes DashScope SDK calls to native `/api/v1`.
|
||||
# For the SDK path, prefer the official simple schema for both single and
|
||||
# multi-image requests: {"image": ...}, {"text": ...}.
|
||||
#
|
||||
# This avoids a split where multi-image succeeds but single-image still
|
||||
# trips over `messages[0].content[*].type` handling.
|
||||
ds = _send_via_dashscope(
|
||||
images=selected,
|
||||
prompt=prompt,
|
||||
model_name=model_name,
|
||||
api_key=resolved_ds_key,
|
||||
base_http_api_url=resolved_ds_base,
|
||||
force_typed_schema=False,
|
||||
)
|
||||
ds["input_kind"] = input_kind if input_kind else ["data_url"]
|
||||
# When DashScope credentials are present, always return the DashScope path result.
|
||||
# Do not silently fall through to the OpenAI-compatible HTTP path, because that can
|
||||
# mask the real SDK error with a secondary `content[*].type` error from another backend path.
|
||||
return ds
|
||||
|
||||
if not resolved_api_key:
|
||||
return {"ok": False, "error": "missing api key (AIA_IMAGE_API_KEY/OPENAI_API_KEY or DASHSCOPE_API_KEY)"}
|
||||
prefer_typed_http = "compatible-mode" in resolved_base_url.lower()
|
||||
content_multi = _http_content_blocks(selected, prompt, typed=prefer_typed_http)
|
||||
content_multi_fallback = _http_content_blocks(selected, prompt, typed=not prefer_typed_http)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {resolved_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload_multi = {
|
||||
"model": model_name,
|
||||
"messages": [{"role": "user", "content": content_multi}],
|
||||
}
|
||||
payload_single = {
|
||||
"model": model_name,
|
||||
"messages": [{"role": "user", "content": content_multi_fallback[-2:] if len(content_multi_fallback) >= 2 else content_multi_fallback}],
|
||||
}
|
||||
responses_payload = {
|
||||
"model": model_name,
|
||||
"input": {"messages": _responses_input_messages(selected, prompt)},
|
||||
}
|
||||
|
||||
with httpx.Client(timeout=float(timeout_sec)) as client:
|
||||
# try multi-image payload first
|
||||
try:
|
||||
r = _post_with_retry(client, url=url, headers=headers, payload=payload_multi)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"http request failed: {type(e).__name__}: {e}", "backend_shape": "multi"}
|
||||
if r.status_code >= 400:
|
||||
# fallback single-image shape for strict backends
|
||||
try:
|
||||
r2 = _post_with_retry(client, url=url, headers=headers, payload=payload_single)
|
||||
except Exception as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"http fallback request failed: {type(e).__name__}: {e}",
|
||||
"backend_shape": "single-fallback-failed",
|
||||
}
|
||||
if r2.status_code >= 400:
|
||||
body2 = str(r2.text or "")
|
||||
if ("input.messages" in body2) or ("Input should be 'user'" in body2):
|
||||
# OpenAI-compatible Responses schema fallback.
|
||||
try:
|
||||
r3 = _post_with_retry(
|
||||
client,
|
||||
url=_join_url(resolved_base_url, "/responses"),
|
||||
headers=headers,
|
||||
payload=responses_payload,
|
||||
)
|
||||
if r3.status_code < 400:
|
||||
obj3 = r3.json()
|
||||
text3, out_images3 = _extract_text_and_images(obj3 if isinstance(obj3, dict) else {})
|
||||
return {
|
||||
"ok": True,
|
||||
"text": text3,
|
||||
"images": out_images3,
|
||||
"backend_shape": "responses-fallback",
|
||||
"input_kind": input_kind if input_kind else ["data_url"],
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"http {r2.status_code}: {r2.text[:500]}",
|
||||
"backend_shape": "single-fallback-failed",
|
||||
}
|
||||
try:
|
||||
obj2 = r2.json()
|
||||
except Exception:
|
||||
return {"ok": False, "error": f"non-json response: {r2.text[:500]}", "backend_shape": "single"}
|
||||
text, out_images = _extract_text_and_images(obj2 if isinstance(obj2, dict) else {})
|
||||
return {
|
||||
"ok": True,
|
||||
"text": text,
|
||||
"images": out_images,
|
||||
"backend_shape": "single",
|
||||
"input_kind": input_kind[-1:] if input_kind else ["data_url"],
|
||||
}
|
||||
|
||||
try:
|
||||
obj = r.json()
|
||||
except Exception:
|
||||
return {"ok": False, "error": f"non-json response: {r.text[:500]}", "backend_shape": "multi"}
|
||||
text, out_images = _extract_text_and_images(obj if isinstance(obj, dict) else {})
|
||||
return {
|
||||
"ok": True,
|
||||
"text": text,
|
||||
"images": out_images,
|
||||
"backend_shape": "multi",
|
||||
"input_kind": input_kind if input_kind else ["data_url"],
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["send_image_messages"]
|
||||
|
||||
91
platform/llm/replay_policy.py
Normal file
91
platform/llm/replay_policy.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""Transcript replay policy for OpenAI-compatible Chat Completions.
|
||||
|
||||
Inspired by OpenClaw (MIT) `src/agents/transcript-policy.ts` defaults for
|
||||
`openai-completions`: enable strict tool-call-id sanitization so proxies and
|
||||
multi-provider histories do not break on id format / length.
|
||||
|
||||
See OPENCLAW_MIT_LICENSE.txt in this package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from oclaw.platform.llm.tool_call_id import DEFAULT_MAX_OPENAI_TOOL_CALL_ID_LEN
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReplayPolicy:
|
||||
"""Controls pre-request normalization of chat messages for tool-use flow."""
|
||||
|
||||
enabled: bool
|
||||
sanitize_tool_call_ids: bool
|
||||
repair_tool_pairing: bool
|
||||
max_tool_call_id_len: int
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
raw = str(os.getenv(name) or "").strip().lower()
|
||||
if raw in ("0", "false", "no", "off"):
|
||||
return False
|
||||
if raw in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
return default
|
||||
|
||||
|
||||
def resolve_replay_policy(
|
||||
base_url: str | None,
|
||||
model_id: str | None,
|
||||
*,
|
||||
llm_profile_mode: str | None = None,
|
||||
) -> ReplayPolicy:
|
||||
"""Resolve replay policy for OpenAI-compatible chat completions.
|
||||
|
||||
When enabled (default): sanitize assistant/tool ids before every request.
|
||||
This matches oclaw's strict OpenAI-compatible replay defaults.
|
||||
"""
|
||||
del base_url, model_id, llm_profile_mode # reserved for provider-specific overrides
|
||||
|
||||
if not _env_bool("AIA_REPLAY_POLICY_ENABLED", True):
|
||||
return ReplayPolicy(
|
||||
enabled=False,
|
||||
sanitize_tool_call_ids=False,
|
||||
repair_tool_pairing=False,
|
||||
max_tool_call_id_len=DEFAULT_MAX_OPENAI_TOOL_CALL_ID_LEN,
|
||||
)
|
||||
|
||||
max_len = DEFAULT_MAX_OPENAI_TOOL_CALL_ID_LEN
|
||||
raw_ml = str(os.getenv("AIA_TOOL_CALL_ID_MAX_LEN") or "").strip()
|
||||
if raw_ml.isdigit():
|
||||
max_len = max(8, min(int(raw_ml), 128))
|
||||
|
||||
repair = _env_bool("AIA_REPLAY_REPAIR_TOOL_PAIRING", True)
|
||||
|
||||
return ReplayPolicy(
|
||||
enabled=True,
|
||||
sanitize_tool_call_ids=True,
|
||||
repair_tool_pairing=repair,
|
||||
max_tool_call_id_len=max_len,
|
||||
)
|
||||
|
||||
|
||||
def apply_replay_policy_to_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
policy: ReplayPolicy,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Apply repair + id sanitization in order."""
|
||||
if not policy.enabled or not messages:
|
||||
return messages
|
||||
from oclaw.platform.llm.tool_call_id import (
|
||||
repair_orphan_tool_messages,
|
||||
rewrite_openai_chat_messages_tool_ids,
|
||||
)
|
||||
|
||||
out = messages
|
||||
if policy.repair_tool_pairing:
|
||||
out = repair_orphan_tool_messages(out)
|
||||
if policy.sanitize_tool_call_ids:
|
||||
out = rewrite_openai_chat_messages_tool_ids(out, max_len=policy.max_tool_call_id_len)
|
||||
return out
|
||||
160
platform/llm/tool_call_id.py
Normal file
160
platform/llm/tool_call_id.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""OpenAI-compatible tool_call_id / tool result id rewriting.
|
||||
|
||||
Inspired by OpenClaw (MIT) `src/agents/tool-call-id.ts`: sanitize ids to strict
|
||||
alphanumeric form and keep assistant.tool_calls[].id paired with role=tool
|
||||
tool_call_id in encounter order.
|
||||
|
||||
See OPENCLAW_MIT_LICENSE.txt in this package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from collections import deque
|
||||
from typing import Any, Literal
|
||||
|
||||
ToolCallIdMode = Literal["strict", "strict9"]
|
||||
|
||||
# OpenAI Chat Completions: tool_calls[].id commonly capped ~40 chars on some gateways.
|
||||
DEFAULT_MAX_OPENAI_TOOL_CALL_ID_LEN = 40
|
||||
|
||||
|
||||
def _short_hash(text: str, length: int = 8) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()[:length]
|
||||
|
||||
|
||||
def sanitize_tool_call_id(raw: str | None, *, mode: ToolCallIdMode = "strict") -> str:
|
||||
"""Strip to [a-zA-Z0-9] only; empty input gets a deterministic placeholder."""
|
||||
if not raw or not isinstance(raw, str):
|
||||
return "sanitizedtoolid" if mode == "strict" else "defaultid"
|
||||
s = raw.strip()
|
||||
if not s:
|
||||
return "sanitizedtoolid" if mode == "strict" else "defaultid"
|
||||
alnum = re.sub(r"[^a-zA-Z0-9]", "", s)
|
||||
if mode == "strict9":
|
||||
if len(alnum) >= 9:
|
||||
return alnum[:9]
|
||||
if alnum:
|
||||
return _short_hash(alnum, 9)
|
||||
return _short_hash("sanitized", 9)
|
||||
return alnum if alnum else "sanitizedtoolid"
|
||||
|
||||
|
||||
def _make_unique(seed: str, used: set[str], *, max_len: int) -> str:
|
||||
base = sanitize_tool_call_id(seed, mode="strict")[:max_len]
|
||||
if base and base not in used:
|
||||
used.add(base)
|
||||
return base
|
||||
h = _short_hash(seed, 8)
|
||||
for i in range(1000):
|
||||
cand = f"{base[: max(0, max_len - len(h) - 1)]}{h}"[:max_len]
|
||||
if i > 0:
|
||||
suf = f"x{i}"
|
||||
cand = f"{base[: max(0, max_len - len(suf) - len(h))]}{suf}{h}"[:max_len]
|
||||
if cand not in used:
|
||||
used.add(cand)
|
||||
return cand
|
||||
cand = _short_hash(f"{seed}:{id(used)}", max_len)[:max_len]
|
||||
used.add(cand)
|
||||
return cand
|
||||
|
||||
|
||||
def rewrite_openai_chat_messages_tool_ids(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
max_len: int = DEFAULT_MAX_OPENAI_TOOL_CALL_ID_LEN,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Rewrite assistant tool_calls ids and matching tool tool_call_id/call_id.
|
||||
|
||||
Uses a per-raw-id queue (oclaw-style) so repeated raw ids map to distinct
|
||||
new ids in order, and tool results consume the queue FIFO.
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
pending: dict[str, deque[str]] = {}
|
||||
used: set[str] = set()
|
||||
|
||||
def _alloc_for_raw(raw: str) -> str:
|
||||
raw = str(raw or "").strip()
|
||||
seed = raw if raw else "empty"
|
||||
return _make_unique(seed, used, max_len=max_len)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for m in messages:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
role = str(m.get("role") or "")
|
||||
if role == "assistant" and m.get("tool_calls"):
|
||||
mm = dict(m)
|
||||
tcs = mm.get("tool_calls")
|
||||
if isinstance(tcs, list):
|
||||
new_tcs: list[dict[str, Any]] = []
|
||||
for tc in tcs:
|
||||
if not isinstance(tc, dict):
|
||||
new_tcs.append(tc)
|
||||
continue
|
||||
raw_id = str(tc.get("id") or "").strip()
|
||||
new_id = _alloc_for_raw(raw_id)
|
||||
q = pending.setdefault(raw_id, deque())
|
||||
q.append(new_id)
|
||||
new_tcs.append({**tc, "id": new_id})
|
||||
mm["tool_calls"] = new_tcs
|
||||
out.append(mm)
|
||||
continue
|
||||
if role == "tool":
|
||||
mm = dict(m)
|
||||
raw_ref = (
|
||||
str(mm.get("tool_call_id") or mm.get("call_id") or "").strip()
|
||||
)
|
||||
new_ref: str | None = None
|
||||
if raw_ref and raw_ref in pending and pending[raw_ref]:
|
||||
new_ref = pending[raw_ref].popleft()
|
||||
elif raw_ref:
|
||||
# Orphan or ordering mismatch: allocate fresh id (best-effort pairing)
|
||||
new_ref = _alloc_for_raw(raw_ref)
|
||||
if new_ref:
|
||||
mm["tool_call_id"] = new_ref
|
||||
# Keep call_id in sync when both present (some gateways expect call_id)
|
||||
if "call_id" in mm:
|
||||
mm["call_id"] = new_ref
|
||||
else:
|
||||
mm.pop("tool_call_id", None)
|
||||
mm.pop("call_id", None)
|
||||
out.append(mm)
|
||||
continue
|
||||
out.append(dict(m) if isinstance(m, dict) else m)
|
||||
return out
|
||||
|
||||
|
||||
def repair_orphan_tool_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Drop tool_call_id/call_id from tool rows that reference no prior assistant tool_calls id."""
|
||||
allowed: set[str] = set()
|
||||
for m in messages:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
if str(m.get("role") or "") != "assistant":
|
||||
continue
|
||||
tcs = m.get("tool_calls")
|
||||
if not isinstance(tcs, list):
|
||||
continue
|
||||
for tc in tcs:
|
||||
if isinstance(tc, dict) and tc.get("id"):
|
||||
allowed.add(str(tc.get("id")))
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for m in messages:
|
||||
if not isinstance(m, dict):
|
||||
out.append(m)
|
||||
continue
|
||||
if str(m.get("role") or "") != "tool":
|
||||
out.append(m)
|
||||
continue
|
||||
mm = dict(m)
|
||||
tid = str(mm.get("tool_call_id") or mm.get("call_id") or "").strip()
|
||||
if tid and tid not in allowed:
|
||||
mm.pop("tool_call_id", None)
|
||||
mm.pop("call_id", None)
|
||||
out.append(mm)
|
||||
return out
|
||||
103
platform/llm/tool_schema.py
Normal file
103
platform/llm/tool_schema.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MIN_TOOL_PARAMETERS: dict[str, Any] = {"type": "object", "additionalProperties": True}
|
||||
|
||||
|
||||
def openai_tools_json_byte_length(tools: list[dict[str, Any]]) -> int:
|
||||
try:
|
||||
return len(json.dumps(tools, ensure_ascii=False, default=str))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def shrink_openai_tools_payload_for_api(tools: list[dict[str, Any]], *, max_json_bytes: int) -> list[dict[str, Any]]:
|
||||
"""Shrink OpenAI ``tools`` JSON for strict OpenAI-compatible gateways."""
|
||||
if not tools or max_json_bytes <= 0:
|
||||
return tools
|
||||
if openai_tools_json_byte_length(tools) <= max_json_bytes:
|
||||
return tools
|
||||
flat: list[dict[str, Any]] = []
|
||||
for t in tools:
|
||||
if not isinstance(t, dict) or str(t.get("type") or "") != "function":
|
||||
continue
|
||||
fn = t.get("function")
|
||||
if not isinstance(fn, dict):
|
||||
continue
|
||||
name = str(fn.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
desc = str(fn.get("description") or "")
|
||||
flat.append({"type": "function", "function": {"name": name, "description": desc, "parameters": dict(_MIN_TOOL_PARAMETERS)}})
|
||||
if not flat:
|
||||
return tools
|
||||
|
||||
def _serialized_with_desc_cap(cap: int) -> str:
|
||||
trimmed: list[dict[str, Any]] = []
|
||||
for x in flat:
|
||||
fn0 = x["function"]
|
||||
trimmed.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": fn0["name"],
|
||||
"description": (fn0.get("description") or "")[: max(0, cap)],
|
||||
"parameters": fn0["parameters"],
|
||||
},
|
||||
}
|
||||
)
|
||||
return json.dumps(trimmed, ensure_ascii=False, default=str)
|
||||
|
||||
lo, hi = 0, min(8000, max_json_bytes)
|
||||
best_cap = 0
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
s = _serialized_with_desc_cap(mid)
|
||||
if len(s) <= max_json_bytes:
|
||||
best_cap = mid
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid - 1
|
||||
out_obj: list[dict[str, Any]] = json.loads(_serialized_with_desc_cap(best_cap))
|
||||
blob = json.dumps(out_obj, ensure_ascii=False, default=str)
|
||||
dropped = 0
|
||||
while len(out_obj) > 8 and len(blob) > max_json_bytes:
|
||||
out_obj.pop()
|
||||
dropped += 1
|
||||
blob = json.dumps(out_obj, ensure_ascii=False, default=str)
|
||||
logger.warning(
|
||||
"Shrunk OpenAI tools[] for API size limits: count=%d max_json_bytes=%d desc_cap=%d dropped=%d final_bytes=%d",
|
||||
len(out_obj),
|
||||
max_json_bytes,
|
||||
best_cap,
|
||||
dropped,
|
||||
len(blob),
|
||||
)
|
||||
return out_obj
|
||||
|
||||
|
||||
def default_max_openai_tools_json_bytes(base_url: str | None) -> int | None:
|
||||
raw = str(os.getenv("AIA_OPENAI_TOOLS_MAX_JSON_CHARS") or "").strip()
|
||||
if raw.isdigit():
|
||||
return max(2000, int(raw))
|
||||
u = (base_url or "").lower()
|
||||
if "dashscope.aliyuncs.com" in u:
|
||||
return 28000
|
||||
if str(os.getenv("AIA_SHRINK_OPENAI_TOOLS") or "").strip().lower() in ("1", "true", "yes", "on"):
|
||||
mx = str(os.getenv("AIA_SHRINK_OPENAI_TOOLS_MAX_JSON") or "28000").strip()
|
||||
return max(2000, int(mx)) if mx.isdigit() else 28000
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"default_max_openai_tools_json_bytes",
|
||||
"openai_tools_json_byte_length",
|
||||
"shrink_openai_tools_payload_for_api",
|
||||
]
|
||||
|
||||
779
platform/llm/tool_wire_policy.py
Normal file
779
platform/llm/tool_wire_policy.py
Normal file
|
|
@ -0,0 +1,779 @@
|
|||
"""Graduated OpenAI ``tools[]`` shaping + usage-aware omission / per-tool admin policies.
|
||||
|
||||
Uses ``tool_log`` aggregates, ``app_setting`` for admin config / per-tool levels / penalty state.
|
||||
Wire payload differs from local :class:`~src.tools.base.ToolSpec`; validation still uses full schemas.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SETTINGS_KEY_PENALTY_STATE = "mcp_tool_wire_penalty_state"
|
||||
SETTINGS_KEY_TOOL_POLICIES = "mcp_tool_wire_tool_policies"
|
||||
SETTINGS_KEY_ADMIN_CONFIG = "mcp_tool_wire_admin_config"
|
||||
|
||||
# Role-scoped (per expert) overrides. Shape:
|
||||
# - policies: { "<role>": { "mcp__server__tool": <int level> } }
|
||||
# - penalty: { "<role>": { "mcp__server__tool": <penalty state dict> } }
|
||||
SETTINGS_KEY_PENALTY_STATE_BY_ROLE = "mcp_tool_wire_penalty_state_by_role"
|
||||
SETTINGS_KEY_TOOL_POLICIES_BY_ROLE = "mcp_tool_wire_tool_policies_by_role"
|
||||
SETTINGS_KEY_ROLE_MODE_BY_ROLE = "mcp_tool_wire_role_mode_by_role"
|
||||
|
||||
_MIN_PARAMETERS: dict[str, Any] = {"type": "object", "additionalProperties": True}
|
||||
|
||||
|
||||
def _env_prefixed(name_suffix: str, default: str = "") -> str:
|
||||
raw = str(os.getenv(f"AIA_{name_suffix}") or "").strip()
|
||||
if raw:
|
||||
return raw
|
||||
raw = str(os.getenv(f"OPS_{name_suffix}") or "").strip()
|
||||
if raw:
|
||||
return raw
|
||||
return str(default).strip()
|
||||
|
||||
|
||||
def wire_policy_enabled(base_url: str | None) -> bool:
|
||||
raw = _env_prefixed("MCP_WIRE_USAGE_POLICY", "").lower()
|
||||
if raw in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
if raw in ("0", "false", "no", "off"):
|
||||
return False
|
||||
u = (base_url or "").lower()
|
||||
return "dashscope.aliyuncs.com" in u
|
||||
|
||||
|
||||
def penalty_disabled() -> bool:
|
||||
return _env_prefixed("MCP_WIRE_PENALTY_DISABLE", "").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _admin_defaults_from_env() -> dict[str, Any]:
|
||||
return {
|
||||
"wire_policy": "inherit",
|
||||
"top_n_full": int(_env_prefixed("MCP_WIRE_TOP_N_FULL", "20") or "20"),
|
||||
"stale_hours": float(_env_prefixed("MCP_WIRE_STALE_HOURS", "3") or "3"),
|
||||
"penalty_minutes": float(_env_prefixed("MCP_WIRE_PENALTY_MINUTES", "30") or "30"),
|
||||
"medium_rank_start": int(_env_prefixed("MCP_WIRE_MEDIUM_RANK_START", "21") or "21"),
|
||||
"medium_rank_end": int(_env_prefixed("MCP_WIRE_MEDIUM_RANK_END", "50") or "50"),
|
||||
"medium_desc_chars": int(_env_prefixed("MCP_WIRE_MEDIUM_DESC_CHARS", "520") or "520"),
|
||||
"minimal_desc_cap": int(_env_prefixed("MCP_WIRE_MINIMAL_DESC_CAP", "80") or "80"),
|
||||
"penalty_disable": False,
|
||||
}
|
||||
|
||||
|
||||
def load_merged_admin_config(store: Any) -> dict[str, Any]:
|
||||
out = _admin_defaults_from_env()
|
||||
try:
|
||||
raw = store.get_setting(SETTINGS_KEY_ADMIN_CONFIG)
|
||||
if not raw:
|
||||
return out
|
||||
d = json.loads(raw) if isinstance(raw, str) else raw
|
||||
if isinstance(d, dict):
|
||||
for k, v in d.items():
|
||||
if k in out or k == "wire_policy":
|
||||
out[k] = v
|
||||
except Exception:
|
||||
pass
|
||||
pd = out.get("penalty_disable")
|
||||
if isinstance(pd, str):
|
||||
out["penalty_disable"] = pd.strip().lower() in ("1", "true", "yes", "on")
|
||||
return out
|
||||
|
||||
|
||||
def load_tool_policies_dict(store: Any) -> dict[str, int]:
|
||||
try:
|
||||
raw = store.get_setting(SETTINGS_KEY_TOOL_POLICIES) or "{}"
|
||||
d = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
return {}
|
||||
if not isinstance(d, dict):
|
||||
return {}
|
||||
out: dict[str, int] = {}
|
||||
for k, v in d.items():
|
||||
nm = str(k or "").strip()
|
||||
if not nm.startswith("mcp__"):
|
||||
continue
|
||||
try:
|
||||
n = int(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if n == 9999:
|
||||
out[nm] = 9999
|
||||
elif n <= 0:
|
||||
out[nm] = 0
|
||||
else:
|
||||
out[nm] = min(n, 9998)
|
||||
return out
|
||||
|
||||
|
||||
def load_tool_policies_dict_for_role(store: Any, *, role: str | None) -> dict[str, int]:
|
||||
"""Role-scoped policies; falls back to global if absent/unparseable."""
|
||||
r = str(role or "").strip().lower()
|
||||
if not r:
|
||||
return load_tool_policies_dict(store)
|
||||
try:
|
||||
raw = store.get_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE) or "{}"
|
||||
outer = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
outer = {}
|
||||
if not isinstance(outer, dict):
|
||||
return load_tool_policies_dict(store)
|
||||
inner = outer.get(r)
|
||||
if not isinstance(inner, dict):
|
||||
return load_tool_policies_dict(store)
|
||||
# Reuse same coercion rules as global loader.
|
||||
out: dict[str, int] = {}
|
||||
for k, v in inner.items():
|
||||
nm = str(k or "").strip()
|
||||
if not nm.startswith("mcp__"):
|
||||
continue
|
||||
try:
|
||||
n = int(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if n == 9999:
|
||||
out[nm] = 9999
|
||||
elif n <= 0:
|
||||
out[nm] = 0
|
||||
else:
|
||||
out[nm] = min(n, 9998)
|
||||
return out
|
||||
|
||||
|
||||
def load_penalty_state_for_role(store: Any, *, role: str | None) -> dict[str, Any]:
|
||||
"""Role-scoped penalty state; falls back to global if absent/unparseable."""
|
||||
r = str(role or "").strip().lower()
|
||||
if not r:
|
||||
raw_pen = store.get_setting(SETTINGS_KEY_PENALTY_STATE) or "{}"
|
||||
try:
|
||||
pen = json.loads(raw_pen) if isinstance(raw_pen, str) else {}
|
||||
except Exception:
|
||||
pen = {}
|
||||
if not isinstance(pen, dict):
|
||||
pen = {}
|
||||
return migrate_legacy_penalty_store(pen)
|
||||
try:
|
||||
raw = store.get_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE) or "{}"
|
||||
outer = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
outer = {}
|
||||
if not isinstance(outer, dict):
|
||||
return load_penalty_state_for_role(store, role=None)
|
||||
inner = outer.get(r)
|
||||
if not isinstance(inner, dict):
|
||||
return load_penalty_state_for_role(store, role=None)
|
||||
return migrate_legacy_penalty_store(inner)
|
||||
|
||||
|
||||
def _persist_penalty_state_for_role(store: Any, *, role: str | None, penalty_state: dict[str, Any]) -> None:
|
||||
r = str(role or "").strip().lower()
|
||||
clean: dict[str, Any] = {}
|
||||
for k, v in (penalty_state or {}).items():
|
||||
if v is None:
|
||||
continue
|
||||
if isinstance(v, dict):
|
||||
clean[str(k)] = v
|
||||
if not r:
|
||||
store.set_setting(SETTINGS_KEY_PENALTY_STATE, json.dumps(clean, ensure_ascii=False))
|
||||
return
|
||||
try:
|
||||
raw = store.get_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE) or "{}"
|
||||
outer = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
outer = {}
|
||||
if not isinstance(outer, dict):
|
||||
outer = {}
|
||||
outer[r] = clean
|
||||
store.set_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE, json.dumps(outer, ensure_ascii=False))
|
||||
|
||||
|
||||
def wire_graduation_effective(base_url: str | None, admin: dict[str, Any]) -> bool:
|
||||
mode = str(admin.get("wire_policy") or "inherit").strip().lower()
|
||||
if mode in ("always", "on", "true", "1", "yes"):
|
||||
return True
|
||||
if mode in ("never", "off", "false", "0", "no"):
|
||||
return False
|
||||
return wire_policy_enabled(base_url)
|
||||
|
||||
|
||||
def penalty_effective_disabled(admin: dict[str, Any]) -> bool:
|
||||
if bool(admin.get("penalty_disable")):
|
||||
return True
|
||||
return penalty_disabled()
|
||||
|
||||
|
||||
def migrate_legacy_penalty_store(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Older rows used ``omit_until`` without ``phase``; add ``kind`` for global tier."""
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in (raw or {}).items():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
v2 = dict(v)
|
||||
if str(v2.get("phase") or "") in ("active", "done"):
|
||||
if "kind" not in v2:
|
||||
v2["kind"] = "d"
|
||||
out[str(k)] = v2
|
||||
continue
|
||||
ou_s = str(v2.get("omit_until") or "")
|
||||
ws = str(v2.get("wave_ts") or "")
|
||||
ou = _parse_iso(ou_s)
|
||||
if ou_s and ou:
|
||||
if _utc_now() < ou:
|
||||
v2 = {"phase": "active", "omit_until": ou_s, "wave_ts": ws, "kind": v2.get("kind") or "d"}
|
||||
else:
|
||||
v2 = {"phase": "done", "wave_ts": ws, "kind": v2.get("kind") or "d"}
|
||||
elif ws:
|
||||
v2 = {"phase": "done", "wave_ts": ws, "kind": v2.get("kind") or "d"}
|
||||
else:
|
||||
continue
|
||||
out[str(k)] = v2
|
||||
return out
|
||||
|
||||
|
||||
def _parse_iso(ts: str) -> datetime | None:
|
||||
s = str(ts or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
if s.endswith("Z"):
|
||||
s = s[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(s)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _wire_json_size(tools: list[dict[str, Any]]) -> int:
|
||||
try:
|
||||
return len(json.dumps(tools, ensure_ascii=False, default=str))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _strip_nested_descriptions(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in obj.items():
|
||||
if str(k) == "description":
|
||||
continue
|
||||
out[str(k)] = _strip_nested_descriptions(v)
|
||||
return out
|
||||
if isinstance(obj, list):
|
||||
return [_strip_nested_descriptions(x) for x in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _tier_medium(fn: dict[str, Any], *, cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
mid_desc_len = int(cfg.get("medium_desc_chars") or 520)
|
||||
mid_desc_len = max(80, min(mid_desc_len, 4000))
|
||||
desc = str(fn.get("description") or "")[:mid_desc_len]
|
||||
params = fn.get("parameters")
|
||||
if isinstance(params, dict):
|
||||
params_out = dict(_strip_nested_descriptions(params))
|
||||
else:
|
||||
params_out = dict(_MIN_PARAMETERS)
|
||||
return {"name": fn.get("name"), "description": desc, "parameters": params_out}
|
||||
|
||||
|
||||
def _tier_minimal(fn: dict[str, Any], *, desc_cap: int) -> dict[str, Any]:
|
||||
desc = str(fn.get("description") or "")[: max(0, desc_cap)]
|
||||
return {"name": fn.get("name"), "description": desc, "parameters": dict(_MIN_PARAMETERS)}
|
||||
|
||||
|
||||
def _clone_tool_entry(t: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(json.dumps(t, ensure_ascii=False, default=str))
|
||||
except Exception:
|
||||
return dict(t)
|
||||
|
||||
|
||||
def _is_builtin_tool_name(name: str) -> bool:
|
||||
return not str(name).startswith("mcp__")
|
||||
|
||||
|
||||
def _normalize_role_mode(v: Any) -> str:
|
||||
s = str(v or "").strip().lower()
|
||||
if s in {"unrestricted", "forbidden", "restricted"}:
|
||||
return s
|
||||
return "restricted"
|
||||
|
||||
|
||||
def load_role_mode_for_role(store: Any, *, role: str | None) -> str:
|
||||
r = str(role or "").strip().lower()
|
||||
if not r:
|
||||
return "restricted"
|
||||
try:
|
||||
raw = store.get_setting(SETTINGS_KEY_ROLE_MODE_BY_ROLE) or "{}"
|
||||
obj = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
obj = {}
|
||||
if not isinstance(obj, dict):
|
||||
return "restricted"
|
||||
return _normalize_role_mode(obj.get(r))
|
||||
|
||||
|
||||
def _is_stale_hours(last_ts_iso: str | None, stale_hours: float) -> bool:
|
||||
if not last_ts_iso:
|
||||
return False
|
||||
dt = _parse_iso(last_ts_iso)
|
||||
if dt is None:
|
||||
return False
|
||||
return (_utc_now() - dt).total_seconds() > stale_hours * 3600.0
|
||||
|
||||
|
||||
def _idle_minutes_exceeded(last_ts_iso: str | None, idle_minutes: float) -> bool:
|
||||
if not last_ts_iso:
|
||||
return False
|
||||
dt = _parse_iso(last_ts_iso)
|
||||
if dt is None:
|
||||
return False
|
||||
return (_utc_now() - dt).total_seconds() > idle_minutes * 60.0
|
||||
|
||||
|
||||
def _wave_penalty_omit(
|
||||
name: str,
|
||||
last_ts: str | None,
|
||||
idle_hit: bool,
|
||||
penalty_minutes: float,
|
||||
st_nm: Any,
|
||||
out_penalty_updates: dict[str, Any],
|
||||
*,
|
||||
kind: str,
|
||||
) -> bool:
|
||||
pw = last_ts or ""
|
||||
mins = max(1.0, min(float(penalty_minutes), 24 * 60))
|
||||
if not idle_hit:
|
||||
return False
|
||||
prev = out_penalty_updates.get(name)
|
||||
if isinstance(prev, dict) and str(prev.get("kind") or "") != str(kind):
|
||||
out_penalty_updates.pop(name, None)
|
||||
st_nm = None
|
||||
else:
|
||||
st_nm = prev
|
||||
|
||||
omit = False
|
||||
if isinstance(st_nm, dict) and str(st_nm.get("phase") or "") == "active":
|
||||
ou = _parse_iso(str(st_nm.get("omit_until") or ""))
|
||||
if ou and _utc_now() < ou:
|
||||
omit = True
|
||||
else:
|
||||
out_penalty_updates[name] = {"phase": "done", "wave_ts": pw, "kind": str(kind)}
|
||||
elif isinstance(st_nm, dict) and str(st_nm.get("phase") or "") == "done" and str(st_nm.get("wave_ts") or "") == pw:
|
||||
pass
|
||||
else:
|
||||
until = (_utc_now() + timedelta(minutes=mins)).isoformat()
|
||||
out_penalty_updates[name] = {"phase": "active", "omit_until": until, "wave_ts": pw, "kind": str(kind)}
|
||||
omit = True
|
||||
return omit
|
||||
|
||||
|
||||
def filter_permanent_ban_mcp_tools(tools: list[dict[str, Any]], policies: dict[str, int]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for entry in tools:
|
||||
if not isinstance(entry, dict) or str(entry.get("type") or "") != "function":
|
||||
out.append(entry)
|
||||
continue
|
||||
fn = entry.get("function")
|
||||
if not isinstance(fn, dict):
|
||||
out.append(entry)
|
||||
continue
|
||||
name = str(fn.get("name") or "").strip()
|
||||
if not name or _is_builtin_tool_name(name):
|
||||
out.append(entry)
|
||||
continue
|
||||
if policies.get(name) == 9999:
|
||||
continue
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def prepare_openai_tools_for_llm_api(
|
||||
tools: list[dict[str, Any]],
|
||||
*,
|
||||
base_url: str | None,
|
||||
max_json_bytes: int | None,
|
||||
store: Any | None = None,
|
||||
role: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build wire ``tools[]``: optional graduated policy + byte cap."""
|
||||
|
||||
def _fallback_shrink(raw: list[dict[str, Any]], cap: int) -> list[dict[str, Any]]:
|
||||
from oclaw.platform.llm.tool_schema import shrink_openai_tools_payload_for_api
|
||||
|
||||
return shrink_openai_tools_payload_for_api(raw, max_json_bytes=cap)
|
||||
|
||||
if not tools:
|
||||
return tools
|
||||
|
||||
policies: dict[str, int] = {}
|
||||
admin: dict[str, Any] = _admin_defaults_from_env()
|
||||
role_mode = "restricted"
|
||||
try:
|
||||
if store is None:
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
store = SqliteStore(db_path())
|
||||
# Role-scoped policies if configured; otherwise fall back to global.
|
||||
policies = load_tool_policies_dict_for_role(store, role=str(role or "").strip().lower() or None)
|
||||
admin = load_merged_admin_config(store)
|
||||
role_mode = load_role_mode_for_role(store, role=str(role or "").strip().lower() or None)
|
||||
except Exception as exc:
|
||||
logger.warning("tool_wire_policy: load admin/policies (%s)", exc)
|
||||
|
||||
def _filter_mcp_out(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for entry in raw:
|
||||
if not isinstance(entry, dict) or str(entry.get("type") or "") != "function":
|
||||
out.append(entry)
|
||||
continue
|
||||
fn = entry.get("function")
|
||||
if not isinstance(fn, dict):
|
||||
out.append(entry)
|
||||
continue
|
||||
nm = str(fn.get("name") or "").strip()
|
||||
if nm.startswith("mcp__"):
|
||||
continue
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
tools_pass1 = filter_permanent_ban_mcp_tools(tools, policies)
|
||||
|
||||
if role_mode == "forbidden":
|
||||
out = _filter_mcp_out(tools)
|
||||
if max_json_bytes is not None and max_json_bytes > 0:
|
||||
return _fallback_shrink(out, max_json_bytes)
|
||||
return out
|
||||
if role_mode == "unrestricted":
|
||||
if max_json_bytes is not None and max_json_bytes > 0:
|
||||
return _fallback_shrink(tools_pass1, max_json_bytes)
|
||||
return tools_pass1
|
||||
|
||||
if not wire_graduation_effective(base_url, admin):
|
||||
if max_json_bytes is not None and max_json_bytes > 0:
|
||||
return _fallback_shrink(tools_pass1, max_json_bytes)
|
||||
return tools_pass1
|
||||
|
||||
top_n = int(admin.get("top_n_full") or 20)
|
||||
top_n = max(3, min(top_n, 80))
|
||||
stale_h = float(admin.get("stale_hours") or 3)
|
||||
stale_h = max(0.25, min(stale_h, 720.0))
|
||||
penalty_min_global = float(admin.get("penalty_minutes") or 30)
|
||||
penalty_min_global = max(1.0, min(penalty_min_global, 24 * 60))
|
||||
medium_floor = int(admin.get("medium_rank_start") or 21)
|
||||
medium_ceiling = int(admin.get("medium_rank_end") or 50)
|
||||
if medium_floor > medium_ceiling:
|
||||
medium_floor, medium_ceiling = medium_ceiling, medium_floor
|
||||
|
||||
usage_map: dict[str, dict[str, Any]] = {}
|
||||
penalty_state: dict[str, Any] = {}
|
||||
try:
|
||||
usage_map = store.list_mcp_tool_aggregate_usage()
|
||||
penalty_state = load_penalty_state_for_role(store, role=str(role or "").strip().lower() or None)
|
||||
except Exception as exc:
|
||||
logger.warning("tool_wire_policy: usage store unavailable (%s); fallback shrink only", exc)
|
||||
if max_json_bytes is not None and max_json_bytes > 0:
|
||||
return _fallback_shrink(tools_pass1, max_json_bytes)
|
||||
return tools_pass1
|
||||
|
||||
ranked = sorted(
|
||||
usage_map.items(),
|
||||
key=lambda kv: (-int(kv[1].get("count") or 0), str(kv[1].get("last_ts") or "")),
|
||||
)
|
||||
rank_by_name = {nm: idx + 1 for idx, (nm, _) in enumerate(ranked)}
|
||||
top_full = {nm for nm, _ in ranked[:top_n]}
|
||||
|
||||
out_penalty_updates: dict[str, Any] = dict(penalty_state)
|
||||
omit_set: set[str] = set()
|
||||
|
||||
for nm in list(out_penalty_updates.keys()):
|
||||
meta = usage_map.get(nm)
|
||||
cur_ts = str(meta.get("last_ts") or "") if meta else ""
|
||||
st = out_penalty_updates.get(nm)
|
||||
if not isinstance(st, dict):
|
||||
continue
|
||||
w_ts = str(st.get("wave_ts") or "")
|
||||
if w_ts and cur_ts and w_ts != cur_ts:
|
||||
out_penalty_updates.pop(nm, None)
|
||||
|
||||
pen_glob = penalty_effective_disabled(admin)
|
||||
built: list[dict[str, Any]] = []
|
||||
for entry in tools_pass1:
|
||||
if not isinstance(entry, dict) or str(entry.get("type") or "") != "function":
|
||||
built.append(entry)
|
||||
continue
|
||||
fn = entry.get("function")
|
||||
if not isinstance(fn, dict):
|
||||
built.append(entry)
|
||||
continue
|
||||
name = str(fn.get("name") or "").strip()
|
||||
if not name:
|
||||
built.append(entry)
|
||||
continue
|
||||
|
||||
if _is_builtin_tool_name(name):
|
||||
built.append(_clone_tool_entry(entry))
|
||||
continue
|
||||
|
||||
usage = usage_map.get(name) or {}
|
||||
last_ts = str(usage.get("last_ts") or "") or None
|
||||
rk = rank_by_name.get(name)
|
||||
|
||||
policy_lv = policies.get(name)
|
||||
st_nm = out_penalty_updates.get(name)
|
||||
|
||||
# policy_lv semantics (punishment only):
|
||||
# - None: inherit global graduation + idle penalty.
|
||||
# - 0: exempt this tool from idle penalty (but still participates in graduation tiers).
|
||||
# - 1..9998: per-tool idle/penalty tuning (N * 10 minutes) + still participates in graduation tiers.
|
||||
# - 9999: permanent ban (filtered earlier in filter_permanent_ban_mcp_tools).
|
||||
omit = False
|
||||
if policy_lv == 0:
|
||||
omit = False
|
||||
elif policy_lv is not None and policy_lv >= 1:
|
||||
idle_m = float(policy_lv) * 10.0
|
||||
pen_m = float(policy_lv) * 10.0
|
||||
idle_hit = _idle_minutes_exceeded(last_ts, idle_m)
|
||||
kind = f"L{int(policy_lv)}"
|
||||
if not pen_glob:
|
||||
omit = _wave_penalty_omit(
|
||||
name, last_ts, idle_hit, pen_m, st_nm, out_penalty_updates, kind=kind
|
||||
)
|
||||
else:
|
||||
idle_hit = _is_stale_hours(last_ts, stale_h)
|
||||
exempt = name in top_full
|
||||
if not pen_glob and idle_hit and not exempt:
|
||||
omit = _wave_penalty_omit(
|
||||
name,
|
||||
last_ts,
|
||||
idle_hit,
|
||||
penalty_min_global,
|
||||
st_nm,
|
||||
out_penalty_updates,
|
||||
kind="d",
|
||||
)
|
||||
|
||||
if omit:
|
||||
omit_set.add(name)
|
||||
continue
|
||||
|
||||
tier = "minimal"
|
||||
if name in top_full:
|
||||
tier = "full"
|
||||
elif rk is not None and medium_floor <= rk <= medium_ceiling:
|
||||
tier = "medium"
|
||||
else:
|
||||
tier = "minimal"
|
||||
|
||||
if tier == "full":
|
||||
built.append(_clone_tool_entry(entry))
|
||||
elif tier == "medium":
|
||||
mf = _tier_medium(fn, cfg=admin)
|
||||
built.append({"type": "function", "function": mf})
|
||||
else:
|
||||
cap0 = int(admin.get("minimal_desc_cap") or 80)
|
||||
cap0 = max(0, min(cap0, 2000))
|
||||
mf = _tier_minimal(fn, desc_cap=cap0)
|
||||
built.append({"type": "function", "function": mf})
|
||||
|
||||
try:
|
||||
_persist_penalty_state_for_role(
|
||||
store,
|
||||
role=str(role or "").strip().lower() or None,
|
||||
penalty_state=out_penalty_updates,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("tool_wire_policy: persist penalty state failed: %s", exc)
|
||||
|
||||
if omit_set:
|
||||
logger.info(
|
||||
"tool_wire_policy: omitted %d MCP tools during penalty window: %s",
|
||||
len(omit_set),
|
||||
sorted(omit_set)[:12],
|
||||
)
|
||||
|
||||
if max_json_bytes is not None and max_json_bytes > 0 and _wire_json_size(built) > max_json_bytes:
|
||||
return _squeeze_to_budget(built, max_json_bytes, admin=admin)
|
||||
|
||||
return built
|
||||
|
||||
|
||||
def _squeeze_to_budget(
|
||||
built: list[dict[str, Any]], max_json_bytes: int, *, admin: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
work = json.loads(json.dumps(built, ensure_ascii=False, default=str))
|
||||
|
||||
def sz() -> int:
|
||||
return _wire_json_size(work)
|
||||
|
||||
if sz() <= max_json_bytes:
|
||||
return work
|
||||
for i, ent in enumerate(work):
|
||||
fn = ent.get("function") if isinstance(ent.get("function"), dict) else {}
|
||||
ps = fn.get("parameters")
|
||||
if isinstance(ps, dict) and len(json.dumps(ps, ensure_ascii=False)) > 400:
|
||||
nm = str(fn.get("name") or "")
|
||||
if nm.startswith("mcp__"):
|
||||
cap = int(admin.get("minimal_desc_cap") or 80)
|
||||
work[i] = {
|
||||
"type": "function",
|
||||
"function": _tier_minimal(fn, desc_cap=max(0, cap)),
|
||||
}
|
||||
if sz() <= max_json_bytes:
|
||||
return work
|
||||
|
||||
for cap in (60, 40, 24, 12, 8, 4, 0):
|
||||
for i, ent in enumerate(work):
|
||||
fn = ent.get("function") if isinstance(ent.get("function"), dict) else {}
|
||||
nm = str(fn.get("name") or "")
|
||||
if nm.startswith("mcp__"):
|
||||
work[i] = {"type": "function", "function": _tier_minimal(fn, desc_cap=cap)}
|
||||
if sz() <= max_json_bytes:
|
||||
return work
|
||||
|
||||
idxs = [i for i, e in enumerate(work) if str((e.get("function") or {}).get("name") or "").startswith("mcp__")]
|
||||
for i in reversed(idxs):
|
||||
if sz() <= max_json_bytes:
|
||||
break
|
||||
work.pop(i)
|
||||
|
||||
if sz() > max_json_bytes:
|
||||
from oclaw.platform.llm.tool_schema import shrink_openai_tools_payload_for_api
|
||||
|
||||
work = shrink_openai_tools_payload_for_api(work, max_json_bytes=max_json_bytes)
|
||||
return work
|
||||
|
||||
|
||||
def penalty_row_status(
|
||||
*,
|
||||
wire_name: str,
|
||||
policy_level: int | None,
|
||||
penalty: dict[str, Any] | None,
|
||||
last_ts: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Human-readable row for Admin UI."""
|
||||
if policy_level is None:
|
||||
return {
|
||||
"phase": "inherit",
|
||||
"omit_until": None,
|
||||
"unblock_hint": "未配置:走全局线侧与闲置惩罚(与显式写入 0 不同)",
|
||||
"kind": None,
|
||||
}
|
||||
if policy_level == 9999:
|
||||
return {
|
||||
"phase": "permanent_ban",
|
||||
"omit_until": None,
|
||||
"unblock_hint": "永久不上送(需将策略改为非 9999)",
|
||||
"kind": None,
|
||||
}
|
||||
if policy_level == 0:
|
||||
return {
|
||||
"phase": "exempt",
|
||||
"omit_until": None,
|
||||
"unblock_hint": "策略 0:不参与闲置惩罚(仍可能受线侧分层/压缩影响)",
|
||||
"kind": None,
|
||||
}
|
||||
st = penalty.get(wire_name) if isinstance(penalty, dict) else None
|
||||
if not isinstance(st, dict):
|
||||
return {"phase": "none", "omit_until": None, "unblock_hint": "-", "kind": None}
|
||||
ph = str(st.get("phase") or "")
|
||||
ou = str(st.get("omit_until") or "")
|
||||
kd = str(st.get("kind") or "")
|
||||
if ph == "active" and ou:
|
||||
dt = _parse_iso(ou)
|
||||
hint = f"惩罚中,预计解封(omit_until): {ou}"
|
||||
if dt and _utc_now() >= dt:
|
||||
hint = "惩罚窗口应已结束;下次请求会写入 done"
|
||||
return {"phase": "active", "omit_until": ou, "unblock_hint": hint, "kind": kd}
|
||||
if ph == "done":
|
||||
return {
|
||||
"phase": "done",
|
||||
"omit_until": None,
|
||||
"unblock_hint": "本轮已服完刑;再次闲置达到阈值后会重新惩罚",
|
||||
"kind": kd,
|
||||
}
|
||||
return {"phase": ph or "unknown", "omit_until": ou or None, "unblock_hint": "-", "kind": kd or None}
|
||||
|
||||
|
||||
def build_tool_wire_snapshot(store: Any, *, role: str | None = None) -> dict[str, Any]:
|
||||
"""Aggregate MCP install list + usage + policies for Admin GET."""
|
||||
from oclaw.tools.mcp.registry import McpRegistry
|
||||
|
||||
admin = load_merged_admin_config(store)
|
||||
role_norm = str(role or "").strip().lower() or None
|
||||
role_mode = load_role_mode_for_role(store, role=role_norm)
|
||||
policies = load_tool_policies_dict_for_role(store, role=role_norm)
|
||||
pen = load_penalty_state_for_role(store, role=role_norm)
|
||||
usage = store.list_mcp_tool_aggregate_usage()
|
||||
servers = McpRegistry(store).list_servers(enabled_only=False)
|
||||
tools_out: list[dict[str, Any]] = []
|
||||
for s in servers:
|
||||
sid = str(s.get("server_id") or "").strip()
|
||||
if not sid:
|
||||
continue
|
||||
for t in store.list_mcp_server_tools(server_id=sid):
|
||||
short = str(t.get("tool_name") or "").strip()
|
||||
if not short:
|
||||
continue
|
||||
wire = f"mcp__{sid}__{short}"
|
||||
agg = usage.get(wire) or {}
|
||||
in_db = wire in policies
|
||||
lv = int(policies[wire]) if in_db else None
|
||||
st = penalty_row_status(
|
||||
wire_name=wire,
|
||||
policy_level=lv,
|
||||
penalty=pen,
|
||||
last_ts=str(agg.get("last_ts") or "") or None,
|
||||
)
|
||||
tools_out.append(
|
||||
{
|
||||
"server_id": sid,
|
||||
"mcp_tool_name": short,
|
||||
"wire_name": wire,
|
||||
"policy_level": lv,
|
||||
"policy_in_db": in_db,
|
||||
"count": int(agg.get("count") or 0),
|
||||
"last_ts": str(agg.get("last_ts") or ""),
|
||||
"penalty": st,
|
||||
"raw_penalty": pen.get(wire) if isinstance(pen.get(wire), dict) else None,
|
||||
}
|
||||
)
|
||||
tools_out.sort(key=lambda x: (x["server_id"], x["wire_name"]))
|
||||
return {
|
||||
"ok": True,
|
||||
"role": role_norm or "",
|
||||
"role_mode": role_mode,
|
||||
"config": admin,
|
||||
"policies": policies,
|
||||
"penalty_state": pen,
|
||||
"tools": tools_out,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SETTINGS_KEY_PENALTY_STATE",
|
||||
"SETTINGS_KEY_TOOL_POLICIES",
|
||||
"SETTINGS_KEY_ADMIN_CONFIG",
|
||||
"SETTINGS_KEY_PENALTY_STATE_BY_ROLE",
|
||||
"SETTINGS_KEY_TOOL_POLICIES_BY_ROLE",
|
||||
"SETTINGS_KEY_ROLE_MODE_BY_ROLE",
|
||||
"build_tool_wire_snapshot",
|
||||
"filter_permanent_ban_mcp_tools",
|
||||
"load_merged_admin_config",
|
||||
"load_tool_policies_dict",
|
||||
"load_tool_policies_dict_for_role",
|
||||
"load_penalty_state_for_role",
|
||||
"load_role_mode_for_role",
|
||||
"migrate_legacy_penalty_store",
|
||||
"penalty_row_status",
|
||||
"prepare_openai_tools_for_llm_api",
|
||||
"wire_policy_enabled",
|
||||
]
|
||||
1
platform/llm/transports/__init__.py
Normal file
1
platform/llm/transports/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# oclaw.platform.llm.transports package
|
||||
204
platform/llm/transports/anthropic_messages.py
Normal file
204
platform/llm/transports/anthropic_messages.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
from oclaw.platform.llm.transports.base import ChatModel, LLMResponse, LLMToolCall
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _as_dict(obj: Any) -> dict[str, Any] | None:
|
||||
if obj is None:
|
||||
return None
|
||||
if isinstance(obj, dict):
|
||||
return obj
|
||||
if hasattr(obj, "model_dump"):
|
||||
try:
|
||||
d = obj.model_dump(mode="python")
|
||||
return d if isinstance(d, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def parse_anthropic_stream_events(
|
||||
events: Iterable[Any],
|
||||
*,
|
||||
on_token: Optional[Callable[[str], None]] = None,
|
||||
) -> tuple[str, list[LLMToolCall]]:
|
||||
parts: list[str] = []
|
||||
tool_by_id: dict[str, dict[str, Any]] = {}
|
||||
current_block: dict[str, Any] | None = None
|
||||
|
||||
for ev in events:
|
||||
d = _as_dict(ev) or {}
|
||||
typ = str(d.get("type") or "")
|
||||
if typ in ("message_start", "message_delta", "message_stop"):
|
||||
continue
|
||||
if typ == "content_block_start":
|
||||
cb = d.get("content_block") if isinstance(d.get("content_block"), dict) else {}
|
||||
current_block = cb
|
||||
# tool_use blocks carry name/id/input in start
|
||||
if str(cb.get("type") or "") == "tool_use":
|
||||
tid = str(cb.get("id") or "") or f"call_{uuid.uuid4().hex}"
|
||||
tool_by_id[tid] = {
|
||||
"id": tid,
|
||||
"name": str(cb.get("name") or ""),
|
||||
"input_json": cb.get("input") if isinstance(cb.get("input"), dict) else {},
|
||||
"_input_buf": "",
|
||||
}
|
||||
continue
|
||||
if typ == "content_block_delta":
|
||||
delta = d.get("delta") if isinstance(d.get("delta"), dict) else {}
|
||||
dt = str(delta.get("type") or "")
|
||||
if dt == "text_delta":
|
||||
t = str(delta.get("text") or "")
|
||||
if t:
|
||||
parts.append(t)
|
||||
if on_token:
|
||||
on_token(t)
|
||||
continue
|
||||
if dt in ("input_json_delta", "tool_use_delta"):
|
||||
partial = delta.get("partial_json") or delta.get("partial") or ""
|
||||
s = str(partial or "")
|
||||
# attach to last tool_use block if any
|
||||
# Anthropics uses content block index; we just buffer by most recent tool_use id.
|
||||
if current_block and str(current_block.get("type") or "") == "tool_use":
|
||||
tid = str(current_block.get("id") or "")
|
||||
if tid and tid in tool_by_id:
|
||||
tool_by_id[tid]["_input_buf"] = str(tool_by_id[tid].get("_input_buf") or "") + s
|
||||
continue
|
||||
continue
|
||||
if typ == "content_block_stop":
|
||||
current_block = None
|
||||
continue
|
||||
|
||||
tool_calls: list[LLMToolCall] = []
|
||||
for tid, slot in tool_by_id.items():
|
||||
args = slot.get("input_json") if isinstance(slot.get("input_json"), dict) else {}
|
||||
buf = str(slot.get("_input_buf") or "").strip()
|
||||
if buf:
|
||||
try:
|
||||
parsed = json.loads(buf)
|
||||
if isinstance(parsed, dict):
|
||||
args = parsed
|
||||
else:
|
||||
args = {"_raw": parsed}
|
||||
except Exception:
|
||||
# keep best-effort
|
||||
pass
|
||||
tool_calls.append(LLMToolCall(id=tid, name=str(slot.get("name") or ""), arguments=args, thought_signature=None))
|
||||
|
||||
return "".join(parts), tool_calls
|
||||
|
||||
|
||||
class AnthropicMessagesModel(ChatModel):
|
||||
def __init__(self, *, model: str | None = None, api_key: str | None = None, base_url: str | None = None):
|
||||
self.model = (model or os.getenv("ANTHROPIC_MODEL") or "claude-3-5-sonnet-latest").strip()
|
||||
self.api_key = (api_key or os.getenv("ANTHROPIC_API_KEY") or "").strip()
|
||||
self.base_url = (base_url or os.getenv("ANTHROPIC_BASE_URL") or "").strip() or None
|
||||
if not self.api_key:
|
||||
raise RuntimeError("未设置 ANTHROPIC_API_KEY,无法使用 Anthropic 模型")
|
||||
try:
|
||||
import anthropic
|
||||
except Exception as exc:
|
||||
raise RuntimeError("未安装 anthropic 依赖,请先 pip install -r requirements.txt") from exc
|
||||
kw: dict[str, Any] = {"api_key": self.api_key}
|
||||
if self.base_url:
|
||||
kw["base_url"] = self.base_url
|
||||
self._client = anthropic.Anthropic(**kw)
|
||||
|
||||
@staticmethod
|
||||
def _to_anthropic_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||
system_chunks: list[str] = []
|
||||
out: list[dict[str, Any]] = []
|
||||
for m in messages or []:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
role = str(m.get("role") or "")
|
||||
content = m.get("content")
|
||||
if role == "system":
|
||||
if isinstance(content, str) and content.strip():
|
||||
system_chunks.append(content)
|
||||
continue
|
||||
if role in ("user", "assistant"):
|
||||
if isinstance(content, str):
|
||||
out.append({"role": role, "content": content})
|
||||
else:
|
||||
out.append({"role": role, "content": str(content)})
|
||||
continue
|
||||
if role == "tool":
|
||||
# Anthropic expects tool results as user content blocks; we stringify.
|
||||
name = str(m.get("name") or m.get("tool_name") or m.get("toolName") or "tool").strip()
|
||||
out.append({"role": "user", "content": f"[tool_result:{name}] {str(content)}"})
|
||||
continue
|
||||
return "\n\n".join(system_chunks).strip(), out
|
||||
|
||||
@staticmethod
|
||||
def _to_anthropic_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for t in tools or []:
|
||||
if not isinstance(t, dict) or str(t.get("type") or "") != "function":
|
||||
continue
|
||||
fn = t.get("function")
|
||||
if not isinstance(fn, dict):
|
||||
continue
|
||||
name = str(fn.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": str(fn.get("description") or ""),
|
||||
"input_schema": fn.get("parameters") if isinstance(fn.get("parameters"), dict) else {"type": "object"},
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
*,
|
||||
on_token: Optional[Callable[[str], None]] = None,
|
||||
) -> LLMResponse:
|
||||
system, msgs = self._to_anthropic_messages(messages)
|
||||
atools = self._to_anthropic_tools(tools)
|
||||
try:
|
||||
stream = self._client.messages.stream(
|
||||
model=self.model,
|
||||
system=system or None,
|
||||
messages=msgs,
|
||||
tools=atools or None,
|
||||
max_tokens=2048,
|
||||
)
|
||||
text, tool_calls = parse_anthropic_stream_events(stream, on_token=on_token)
|
||||
return LLMResponse(content=text, tool_calls=tool_calls)
|
||||
except Exception as exc:
|
||||
logger.info("anthropic stream failed; fallback to non-stream (%s)", exc)
|
||||
resp = self._client.messages.create(
|
||||
model=self.model,
|
||||
system=system or None,
|
||||
messages=msgs,
|
||||
tools=atools or None,
|
||||
max_tokens=2048,
|
||||
)
|
||||
d = _as_dict(resp) or {}
|
||||
content = d.get("content")
|
||||
text = ""
|
||||
if isinstance(content, list):
|
||||
for blk in content:
|
||||
if isinstance(blk, dict) and blk.get("type") == "text":
|
||||
text += str(blk.get("text") or "")
|
||||
if on_token and text:
|
||||
on_token(text)
|
||||
return LLMResponse(content=text, tool_calls=[])
|
||||
|
||||
|
||||
__all__ = ["AnthropicMessagesModel", "parse_anthropic_stream_events"]
|
||||
|
||||
63
platform/llm/transports/base.py
Normal file
63
platform/llm/transports/base.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMToolCall:
|
||||
id: str
|
||||
name: str
|
||||
arguments: dict[str, Any]
|
||||
# Gemini OpenAI-compat and native transports may require preserving a signature across tool loops.
|
||||
thought_signature: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LLMResponse:
|
||||
content: str
|
||||
tool_calls: list[LLMToolCall]
|
||||
reasoning_content: str = ""
|
||||
|
||||
|
||||
class ChatModel:
|
||||
def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
*,
|
||||
on_token: Optional[Callable[[str], None]] = None,
|
||||
) -> LLMResponse:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def coerce_thought_signature_for_storage(v: Any) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
if isinstance(v, (bytes, bytearray)):
|
||||
try:
|
||||
return bytes(v).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return base64.b64encode(bytes(v)).decode("ascii")
|
||||
if isinstance(v, (dict, list)):
|
||||
return json.dumps(v, separators=(",", ":"), ensure_ascii=False)
|
||||
return str(v)
|
||||
|
||||
|
||||
def normalize_image_b64_payload(raw: Any) -> str:
|
||||
if raw is None:
|
||||
return ""
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
return base64.b64encode(bytes(raw)).decode("ascii")
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return ""
|
||||
if s.startswith("data:") and ";base64," in s:
|
||||
s = s.split(";base64,", 1)[-1].strip()
|
||||
return "".join(s.split())
|
||||
|
||||
265
platform/llm/transports/google_gemini_sse.py
Normal file
265
platform/llm/transports/google_gemini_sse.py
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
from collections.abc import Callable
|
||||
|
||||
from oclaw.platform.llm.transports.base import ChatModel, LLMResponse, LLMToolCall
|
||||
|
||||
|
||||
class GoogleGeminiChatModel(ChatModel):
|
||||
"""Native Google Gemini streaming transport (SSE), modeled after OpenClaw's google transport."""
|
||||
|
||||
def __init__(self, *, model: str, api_key: str, base_url: str | None = None):
|
||||
self.model = (model or "").strip() or "gemini-2.5-pro"
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.base_url = (base_url or "").strip() or "https://generativelanguage.googleapis.com/v1beta"
|
||||
if not self.api_key:
|
||||
raise RuntimeError("未设置 GOOGLE_API_KEY,无法使用 Gemini 原生接口")
|
||||
|
||||
def _tool_decls_from_openai_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
decls: list[dict[str, Any]] = []
|
||||
for t in tools or []:
|
||||
if not isinstance(t, dict) or str(t.get("type") or "") != "function":
|
||||
continue
|
||||
fn = t.get("function")
|
||||
if not isinstance(fn, dict):
|
||||
continue
|
||||
name = str(fn.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
decls.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": str(fn.get("description") or ""),
|
||||
# OpenClaw uses `parametersJsonSchema`
|
||||
"parametersJsonSchema": fn.get("parameters") if isinstance(fn.get("parameters"), dict) else {"type": "object"},
|
||||
}
|
||||
)
|
||||
return decls
|
||||
|
||||
@staticmethod
|
||||
def _thinking_config_from_env() -> dict[str, Any] | None:
|
||||
enabled = str(os.getenv("AIA_GEMINI_THINKING") or "").strip().lower()
|
||||
if enabled in ("0", "false", "no", "off"):
|
||||
return {"includeThoughts": False}
|
||||
if enabled in ("1", "true", "yes", "on"):
|
||||
cfg: dict[str, Any] = {"includeThoughts": True}
|
||||
lvl = str(os.getenv("AIA_GEMINI_THINKING_LEVEL") or "").strip()
|
||||
if lvl:
|
||||
cfg["thinkingLevel"] = lvl
|
||||
bud = str(os.getenv("AIA_GEMINI_THINKING_BUDGET") or "").strip()
|
||||
if bud.isdigit():
|
||||
cfg["thinkingBudget"] = int(bud)
|
||||
return cfg
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_tool_result_payload(raw: Any) -> Any:
|
||||
if raw is None:
|
||||
return {}
|
||||
if isinstance(raw, (dict, list)):
|
||||
return raw
|
||||
s = str(raw or "").strip()
|
||||
if not s:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(s)
|
||||
except Exception:
|
||||
return {"raw": s}
|
||||
|
||||
@staticmethod
|
||||
def _openai_messages_to_gemini_contents(messages: list[dict[str, Any]]) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
||||
system_parts: list[dict[str, Any]] = []
|
||||
contents: list[dict[str, Any]] = []
|
||||
|
||||
def _append(role: str, parts: list[dict[str, Any]]) -> None:
|
||||
if not parts:
|
||||
return
|
||||
contents.append({"role": role, "parts": parts})
|
||||
|
||||
for m in messages or []:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
role = str(m.get("role") or "")
|
||||
content = m.get("content")
|
||||
if role == "system":
|
||||
if isinstance(content, str) and content.strip():
|
||||
system_parts.append({"text": content})
|
||||
continue
|
||||
if role == "user":
|
||||
parts: list[dict[str, Any]] = []
|
||||
if isinstance(content, str):
|
||||
if content.strip():
|
||||
parts.append({"text": content})
|
||||
elif isinstance(content, list):
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("type") == "text" and str(item.get("text") or "").strip():
|
||||
parts.append({"text": str(item.get("text") or "")})
|
||||
_append("user", parts)
|
||||
continue
|
||||
if role == "assistant":
|
||||
parts: list[dict[str, Any]] = []
|
||||
if isinstance(content, str) and content.strip():
|
||||
parts.append({"text": content})
|
||||
_append("model", parts)
|
||||
continue
|
||||
if role == "tool":
|
||||
name = str(m.get("name") or "").strip() or str(m.get("tool_name") or m.get("toolName") or "").strip()
|
||||
payload = GoogleGeminiChatModel._parse_tool_result_payload(content)
|
||||
_append("user", [{"functionResponse": {"name": name or "tool", "response": payload}}])
|
||||
continue
|
||||
|
||||
system_instruction = {"parts": system_parts} if system_parts else None
|
||||
return system_instruction, contents
|
||||
|
||||
def _build_url(self) -> str:
|
||||
base = self._normalize_base_url_for_native_sse(self.base_url).rstrip("/")
|
||||
model_path = self.model
|
||||
if not model_path.startswith("models/") and not model_path.startswith("tunedModels/"):
|
||||
model_path = f"models/{model_path}"
|
||||
# Prefer header auth (OpenClaw-style); avoid forcing key into query string.
|
||||
return f"{base}/{model_path}:streamGenerateContent?alt=sse"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_base_url_for_native_sse(base_url: str) -> str:
|
||||
"""Normalize unified gateway base_url for Gemini native SSE.
|
||||
|
||||
Many unified gateways expose OpenAI-compat under `/v1/*`, but Gemini native endpoints
|
||||
are usually implemented under `/v1beta/*` semantics.
|
||||
"""
|
||||
raw = str(base_url or "").strip()
|
||||
if not raw:
|
||||
return raw
|
||||
# Strip trailing slashes first.
|
||||
while raw.endswith("/"):
|
||||
raw = raw[:-1]
|
||||
# Common unified gateway layout: https://host/v1 -> https://host/v1beta
|
||||
if raw.lower().endswith("/v1"):
|
||||
return raw[:-3] + "/v1beta"
|
||||
# Some gateways keep an `/openai` suffix for compat endpoints.
|
||||
if raw.lower().endswith("/openai"):
|
||||
raw = raw[:-7]
|
||||
if raw.lower().endswith("/v1"):
|
||||
return raw[:-3] + "/v1beta"
|
||||
return raw
|
||||
return raw
|
||||
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
k = str(self.api_key or "").strip()
|
||||
if not k:
|
||||
return {}
|
||||
# If user stored a bearer token in the profile secret.
|
||||
if k.lower().startswith("bearer "):
|
||||
return {"authorization": k}
|
||||
# Gemini API supports x-goog-api-key; unified gateways often accept this too.
|
||||
return {"x-goog-api-key": k}
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
*,
|
||||
on_token: Optional[Callable[[str], None]] = None,
|
||||
) -> LLMResponse:
|
||||
system_instruction, contents = self._openai_messages_to_gemini_contents(messages)
|
||||
decls = self._tool_decls_from_openai_tools(tools)
|
||||
req: dict[str, Any] = {"contents": contents}
|
||||
if system_instruction is not None:
|
||||
req["systemInstruction"] = system_instruction
|
||||
if decls:
|
||||
req["tools"] = [{"functionDeclarations": decls}]
|
||||
thinking_cfg = self._thinking_config_from_env()
|
||||
if thinking_cfg is not None:
|
||||
req["generationConfig"] = {"thinkingConfig": thinking_cfg}
|
||||
|
||||
url = self._build_url()
|
||||
try:
|
||||
import httpx
|
||||
except Exception as exc:
|
||||
raise RuntimeError("missing dependency: httpx") from exc
|
||||
|
||||
thinking_parts: list[str] = []
|
||||
thinking_signature: str | None = None
|
||||
out_text: list[str] = []
|
||||
tool_calls: list[LLMToolCall] = []
|
||||
|
||||
def _stream_once(stream_url: str) -> None:
|
||||
with httpx.Client(timeout=httpx.Timeout(60.0, connect=10.0)) as client:
|
||||
with client.stream(
|
||||
"POST",
|
||||
stream_url,
|
||||
json=req,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"accept": "text/event-stream",
|
||||
**self._auth_headers(),
|
||||
},
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
for line in r.iter_lines():
|
||||
if not line or not str(line).startswith("data:"):
|
||||
continue
|
||||
data = str(line)[len("data:") :].strip()
|
||||
if not data or data == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except Exception:
|
||||
continue
|
||||
cands = chunk.get("candidates") if isinstance(chunk, dict) else None
|
||||
if not isinstance(cands, list) or not cands:
|
||||
continue
|
||||
c0 = cands[0] if isinstance(cands[0], dict) else {}
|
||||
content0 = c0.get("content") if isinstance(c0.get("content"), dict) else {}
|
||||
parts = content0.get("parts") if isinstance(content0.get("parts"), list) else []
|
||||
for p in parts:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
if p.get("thought") is True:
|
||||
txt = str(p.get("text") or "")
|
||||
if txt:
|
||||
thinking_parts.append(txt)
|
||||
sig = p.get("thoughtSignature")
|
||||
if isinstance(sig, str) and sig:
|
||||
thinking_signature = sig
|
||||
continue
|
||||
fc = p.get("functionCall") if isinstance(p.get("functionCall"), dict) else None
|
||||
if fc is not None:
|
||||
name = str(fc.get("name") or "").strip()
|
||||
args = fc.get("args") if isinstance(fc.get("args"), dict) else {}
|
||||
tid = str(fc.get("id") or "") or f"call_{uuid.uuid4().hex}"
|
||||
tool_calls.append(
|
||||
LLMToolCall(id=tid, name=name, arguments=args, thought_signature=thinking_signature)
|
||||
)
|
||||
continue
|
||||
txt = str(p.get("text") or "")
|
||||
if txt:
|
||||
out_text.append(txt)
|
||||
if on_token:
|
||||
on_token(txt)
|
||||
|
||||
try:
|
||||
_stream_once(url)
|
||||
except Exception as exc:
|
||||
# If gateway returns 500 on /v1* SSE path, try /v1beta* fallback once.
|
||||
msg = str(exc or "").lower()
|
||||
if "500" in msg and "/v1/" in str(url).lower():
|
||||
alt = str(url).replace("/v1/", "/v1beta/")
|
||||
_stream_once(alt)
|
||||
else:
|
||||
raise
|
||||
|
||||
return LLMResponse(
|
||||
content="".join(out_text),
|
||||
tool_calls=tool_calls,
|
||||
reasoning_content="".join(thinking_parts).strip(),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["GoogleGeminiChatModel"]
|
||||
|
||||
309
platform/llm/transports/openai_chat_completions.py
Normal file
309
platform/llm/transports/openai_chat_completions.py
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
from collections.abc import Callable
|
||||
|
||||
from oclaw.platform.llm.transports.base import ChatModel, LLMResponse, LLMToolCall, normalize_image_b64_payload, coerce_thought_signature_for_storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _likely_gemini_openai_compat_base_url(url: str | None) -> bool:
|
||||
if not url:
|
||||
return False
|
||||
u = url.lower()
|
||||
return "generativelanguage.googleapis.com" in u or "aiplatform.googleapis.com" in u
|
||||
|
||||
|
||||
def _model_id_suggests_gemini(model: str | None) -> bool:
|
||||
return "gemini" in (model or "").lower()
|
||||
|
||||
|
||||
def _find_thought_signature_in_obj(o: Any) -> str | None:
|
||||
if isinstance(o, dict):
|
||||
for k in ("thought_signature", "thoughtSignature"):
|
||||
v = o.get(k)
|
||||
if v is not None:
|
||||
coerced = coerce_thought_signature_for_storage(v)
|
||||
if coerced is not None:
|
||||
return coerced
|
||||
for v in o.values():
|
||||
found = _find_thought_signature_in_obj(v)
|
||||
if found is not None:
|
||||
return found
|
||||
elif isinstance(o, list):
|
||||
for v in o:
|
||||
found = _find_thought_signature_in_obj(v)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def _extract_thought_signature_from_message_tool_call(tc: Any) -> str | None:
|
||||
if hasattr(tc, "model_dump"):
|
||||
try:
|
||||
d = tc.model_dump(mode="python")
|
||||
ec = d.get("extra_content")
|
||||
if isinstance(ec, dict):
|
||||
g = ec.get("google")
|
||||
if isinstance(g, dict) and "thought_signature" in g:
|
||||
return coerce_thought_signature_for_storage(g.get("thought_signature"))
|
||||
except Exception:
|
||||
pass
|
||||
ec = getattr(tc, "extra_content", None)
|
||||
if isinstance(ec, dict):
|
||||
g = ec.get("google")
|
||||
if isinstance(g, dict) and "thought_signature" in g:
|
||||
return coerce_thought_signature_for_storage(g.get("thought_signature"))
|
||||
return _extract_thought_signature_from_tool_delta(tc)
|
||||
|
||||
|
||||
def _extract_thought_signature_from_tool_delta(tc: Any) -> str | None:
|
||||
for attr in ("thought_signature", "thoughtSignature"):
|
||||
v = getattr(tc, attr, None)
|
||||
if v is not None:
|
||||
return coerce_thought_signature_for_storage(v)
|
||||
extra = getattr(tc, "model_extra", None)
|
||||
if isinstance(extra, dict):
|
||||
for k in ("thought_signature", "thoughtSignature"):
|
||||
ev = extra.get(k)
|
||||
if ev is not None:
|
||||
return coerce_thought_signature_for_storage(ev)
|
||||
pyd_ex = getattr(tc, "__pydantic_extra__", None)
|
||||
if isinstance(pyd_ex, dict):
|
||||
for k in ("thought_signature", "thoughtSignature"):
|
||||
ev = pyd_ex.get(k)
|
||||
if ev is not None:
|
||||
return coerce_thought_signature_for_storage(ev)
|
||||
if hasattr(tc, "model_dump"):
|
||||
try:
|
||||
found = _find_thought_signature_in_obj(tc.model_dump(mode="python"))
|
||||
if found is not None:
|
||||
return found
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class OpenAIChatModel(ChatModel):
|
||||
def __init__(
|
||||
self,
|
||||
model: str | None = None,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
):
|
||||
self.model = model or os.getenv("OPENAI_MODEL") or "gpt-4o-mini"
|
||||
self.api_key = api_key or os.getenv("OPENAI_API_KEY")
|
||||
self.base_url = base_url or os.getenv("OPENAI_BASE_URL")
|
||||
|
||||
if not self.api_key:
|
||||
raise RuntimeError("未设置 OPENAI_API_KEY,无法使用 OpenAI 模型")
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except Exception as e:
|
||||
raise RuntimeError("未安装 openai 依赖,请先 pip install -r requirements.txt") from e
|
||||
|
||||
client_kwargs: dict[str, Any] = {"api_key": self.api_key}
|
||||
if self.base_url:
|
||||
client_kwargs["base_url"] = self.base_url
|
||||
self._client = OpenAI(**client_kwargs)
|
||||
self._skip_tools: bool = False
|
||||
|
||||
def _create_chat_completion(self, norm_msgs: list[dict[str, Any]], tools: list[dict[str, Any]], *, stream: bool):
|
||||
# replay_policy/tool_wire_policy live in sibling modules; import lazily to avoid cycles.
|
||||
try:
|
||||
from oclaw.platform.llm.replay_policy import apply_replay_policy_to_messages, resolve_replay_policy
|
||||
|
||||
policy = resolve_replay_policy(self.base_url, self.model)
|
||||
if policy.enabled:
|
||||
norm_msgs = apply_replay_policy_to_messages(norm_msgs, policy)
|
||||
except Exception as exc:
|
||||
logger.warning("replay_policy apply failed (%s); continuing without rewrite", exc)
|
||||
|
||||
use_tools = bool(tools)
|
||||
cleaned_msgs: list[dict[str, Any]] = []
|
||||
for m in norm_msgs or []:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
if str(m.get("role") or "") == "tool":
|
||||
mm = dict(m)
|
||||
for k in ("tool_call_id", "call_id"):
|
||||
if k in mm:
|
||||
v = mm.get(k)
|
||||
try:
|
||||
sv = str(v).strip() if v is not None else ""
|
||||
except Exception:
|
||||
sv = ""
|
||||
if not sv:
|
||||
mm.pop(k, None)
|
||||
else:
|
||||
mm[k] = sv
|
||||
cleaned_msgs.append(mm)
|
||||
else:
|
||||
cleaned_msgs.append(m)
|
||||
|
||||
kwargs: dict[str, Any] = {"model": self.model, "messages": cleaned_msgs, "stream": stream}
|
||||
if use_tools:
|
||||
try:
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.exposure_plan import build_llm_tools_plan
|
||||
|
||||
plan = build_llm_tools_plan(
|
||||
store=SqliteStore(db_path()),
|
||||
role="",
|
||||
base_url=self.base_url,
|
||||
max_json_bytes=_default_max_openai_tools_json_bytes(self.base_url),
|
||||
include_mcp=False,
|
||||
preview_internal=False,
|
||||
raw_openai_tools_override=tools,
|
||||
)
|
||||
kwargs["tools"] = plan.tools_wired
|
||||
except Exception:
|
||||
kwargs["tools"] = tools
|
||||
return self._client.chat.completions.create(**kwargs)
|
||||
|
||||
def _llm_response_from_completion(self, completion: Any, *, on_token: Optional[Callable[[str], None]]) -> LLMResponse:
|
||||
msg = completion.choices[0].message
|
||||
reasoning_parts = getattr(msg, "reasoning_content", None) or ""
|
||||
reasoning_text = str(reasoning_parts).strip() if reasoning_parts else ""
|
||||
content = msg.content or ""
|
||||
if on_token and content:
|
||||
on_token(content)
|
||||
|
||||
tool_calls: list[LLMToolCall] = []
|
||||
if msg.tool_calls:
|
||||
for tc in msg.tool_calls:
|
||||
tid = str(getattr(tc, "id", "") or "")
|
||||
fn = getattr(tc, "function", None)
|
||||
name = str(fn.name) if fn and getattr(fn, "name", None) else ""
|
||||
args_raw = str(fn.arguments) if fn and getattr(fn, "arguments", None) is not None else "{}"
|
||||
if not name and not tid:
|
||||
continue
|
||||
if not tid:
|
||||
tid = f"call_{uuid.uuid4().hex}"
|
||||
try:
|
||||
args = json.loads(args_raw)
|
||||
except json.JSONDecodeError:
|
||||
args = {"_raw": args_raw}
|
||||
sig = _extract_thought_signature_from_message_tool_call(tc)
|
||||
if sig is None and fn is not None:
|
||||
sig = _extract_thought_signature_from_tool_delta(fn)
|
||||
tool_calls.append(LLMToolCall(id=tid, name=name, arguments=args, thought_signature=sig))
|
||||
return LLMResponse(content=content, tool_calls=tool_calls, reasoning_content=reasoning_text)
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
*,
|
||||
on_token: Optional[Callable[[str], None]] = None,
|
||||
) -> LLMResponse:
|
||||
# Normalize input_image to image_url.
|
||||
norm_msgs = []
|
||||
for m in messages:
|
||||
if m.get("role") == "user" and isinstance(m.get("content"), list):
|
||||
norm_content = []
|
||||
for item in m["content"]:
|
||||
if isinstance(item, dict) and item.get("type") == "input_image":
|
||||
mime = str(item.get("mime") or "image/jpeg").strip() or "image/jpeg"
|
||||
b64 = normalize_image_b64_payload(item.get("image_base64") or item.get("data"))
|
||||
if not b64:
|
||||
continue
|
||||
norm_content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
|
||||
else:
|
||||
norm_content.append(item)
|
||||
norm_msgs.append({"role": m["role"], "content": norm_content})
|
||||
else:
|
||||
norm_msgs.append(m)
|
||||
|
||||
try:
|
||||
stream = self._create_chat_completion(norm_msgs, tools, stream=True)
|
||||
except Exception as exc:
|
||||
logger.info("Streaming chat completion failed; fallback to non-stream (%s)", exc)
|
||||
completion = self._create_chat_completion(norm_msgs, tools, stream=False)
|
||||
return self._llm_response_from_completion(completion, on_token=on_token)
|
||||
|
||||
content_parts: list[str] = []
|
||||
tool_acc: dict[int, dict[str, Any]] = {}
|
||||
reasoning_parts: list[str] = []
|
||||
|
||||
def _consume_stream(stream_obj: Any) -> None:
|
||||
nonlocal content_parts, tool_acc, reasoning_parts
|
||||
for chunk in stream_obj:
|
||||
if not chunk.choices:
|
||||
continue
|
||||
delta = chunk.choices[0].delta
|
||||
rc = getattr(delta, "reasoning_content", None) or ""
|
||||
if rc:
|
||||
reasoning_parts.append(rc)
|
||||
if delta.tool_calls:
|
||||
for tc in delta.tool_calls:
|
||||
idx = int(tc.index)
|
||||
slot = tool_acc.setdefault(idx, {"id": None, "name": None, "arguments": "", "thought_signature": None})
|
||||
if tc.id:
|
||||
slot["id"] = tc.id
|
||||
if tc.function:
|
||||
if tc.function.name:
|
||||
slot["name"] = tc.function.name
|
||||
if tc.function.arguments:
|
||||
slot["arguments"] = f"{slot['arguments']}{tc.function.arguments}"
|
||||
sig = _extract_thought_signature_from_message_tool_call(tc)
|
||||
if sig is None:
|
||||
sig = _extract_thought_signature_from_tool_delta(tc)
|
||||
if sig is None and tc.function is not None:
|
||||
sig = _extract_thought_signature_from_tool_delta(tc.function)
|
||||
if sig is not None:
|
||||
slot["thought_signature"] = sig
|
||||
if delta.content:
|
||||
content_parts.append(delta.content)
|
||||
if on_token:
|
||||
on_token(delta.content)
|
||||
|
||||
try:
|
||||
_consume_stream(stream)
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
content = "".join(content_parts)
|
||||
reasoning_text = "".join(reasoning_parts).strip()
|
||||
|
||||
tool_calls: list[LLMToolCall] = []
|
||||
for idx in sorted(tool_acc.keys()):
|
||||
slot = tool_acc[idx]
|
||||
tid = slot.get("id") or ""
|
||||
name = slot.get("name") or ""
|
||||
args_raw = slot.get("arguments") or "{}"
|
||||
if not name and not tid:
|
||||
continue
|
||||
if not tid:
|
||||
tid = f"call_{uuid.uuid4().hex}"
|
||||
try:
|
||||
args = json.loads(args_raw)
|
||||
except json.JSONDecodeError:
|
||||
args = {"_raw": args_raw}
|
||||
ts_raw = slot.get("thought_signature")
|
||||
tsig = coerce_thought_signature_for_storage(ts_raw) if ts_raw is not None else None
|
||||
tool_calls.append(LLMToolCall(id=str(tid), name=str(name), arguments=args, thought_signature=tsig))
|
||||
|
||||
return LLMResponse(content=content, tool_calls=tool_calls, reasoning_content=reasoning_text)
|
||||
|
||||
|
||||
__all__ = ["OpenAIChatModel", "_likely_gemini_openai_compat_base_url", "_model_id_suggests_gemini"]
|
||||
|
||||
|
||||
def _default_max_openai_tools_json_bytes(base_url: str | None) -> int | None:
|
||||
u = str(base_url or "").strip().lower()
|
||||
if not u:
|
||||
return None
|
||||
# Conservative caps for some OpenAI-compatible gateways.
|
||||
if "dashscope" in u or "aliyuncs" in u:
|
||||
return 28_000
|
||||
return None
|
||||
|
||||
236
platform/llm/transports/openai_responses.py
Normal file
236
platform/llm/transports/openai_responses.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
from oclaw.platform.llm.transports.base import ChatModel, LLMResponse, LLMToolCall, normalize_image_b64_payload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _as_dict(obj: Any) -> dict[str, Any] | None:
|
||||
if obj is None:
|
||||
return None
|
||||
if isinstance(obj, dict):
|
||||
return obj
|
||||
if hasattr(obj, "model_dump"):
|
||||
try:
|
||||
d = obj.model_dump(mode="python")
|
||||
return d if isinstance(d, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _collect_tool_calls_from_response_dict(resp: dict[str, Any]) -> list[LLMToolCall]:
|
||||
out: list[LLMToolCall] = []
|
||||
items = resp.get("output")
|
||||
if not isinstance(items, list):
|
||||
return out
|
||||
for it in items:
|
||||
d = _as_dict(it)
|
||||
if not d:
|
||||
continue
|
||||
if str(d.get("type") or "") in ("function_call", "tool_call"):
|
||||
call_id = str(d.get("call_id") or d.get("id") or "") or f"call_{uuid.uuid4().hex}"
|
||||
name = str(d.get("name") or "") or str((d.get("function") or {}).get("name") or "")
|
||||
args = d.get("arguments") or d.get("input") or {}
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except Exception:
|
||||
args = {"_raw": args}
|
||||
if not isinstance(args, dict):
|
||||
args = {"_raw": args}
|
||||
out.append(LLMToolCall(id=call_id, name=name, arguments=args, thought_signature=None))
|
||||
return out
|
||||
|
||||
|
||||
def parse_openai_responses_stream_events(
|
||||
events: Iterable[Any],
|
||||
*,
|
||||
on_token: Optional[Callable[[str], None]] = None,
|
||||
) -> tuple[str, list[LLMToolCall], dict[str, Any] | None]:
|
||||
"""Pure stream parser (offline-testable).
|
||||
|
||||
Returns: (assembled_text, tool_calls, final_response_dict?)
|
||||
"""
|
||||
parts: list[str] = []
|
||||
final_resp: dict[str, Any] | None = None
|
||||
for ev in events:
|
||||
d = _as_dict(ev) or {}
|
||||
typ = str(d.get("type") or "")
|
||||
# Text deltas
|
||||
if typ in ("response.output_text.delta", "response.output_text"):
|
||||
delta = d.get("delta")
|
||||
if delta is None:
|
||||
delta = d.get("text")
|
||||
s = str(delta or "")
|
||||
if s:
|
||||
parts.append(s)
|
||||
if on_token:
|
||||
on_token(s)
|
||||
continue
|
||||
# Sometimes the SDK emits generic `response.delta` with nested segments.
|
||||
if typ == "response.delta":
|
||||
delta = d.get("delta")
|
||||
if isinstance(delta, dict):
|
||||
txt = delta.get("output_text")
|
||||
if isinstance(txt, str) and txt:
|
||||
parts.append(txt)
|
||||
if on_token:
|
||||
on_token(txt)
|
||||
continue
|
||||
# Terminal response object
|
||||
if typ in ("response.completed", "response.complete", "response.done"):
|
||||
resp = d.get("response") or d.get("data") or d.get("result")
|
||||
final_resp = _as_dict(resp) or final_resp
|
||||
continue
|
||||
if typ == "response.created":
|
||||
continue
|
||||
# Some SDK versions yield the response object directly (no explicit event type).
|
||||
if d.get("output") is not None and d.get("id") is not None:
|
||||
final_resp = d
|
||||
|
||||
text = "".join(parts)
|
||||
tool_calls = _collect_tool_calls_from_response_dict(final_resp) if final_resp else []
|
||||
return text, tool_calls, final_resp
|
||||
|
||||
|
||||
class OpenAIResponsesModel(ChatModel):
|
||||
"""OpenAI Responses API transport (OpenAI-compatible gateways may implement this surface)."""
|
||||
|
||||
def __init__(self, *, model: str | None = None, api_key: str | None = None, base_url: str | None = None):
|
||||
self.model = (model or os.getenv("OPENAI_MODEL") or "gpt-4o-mini").strip()
|
||||
self.api_key = (api_key or os.getenv("OPENAI_API_KEY") or "").strip()
|
||||
self.base_url = (base_url or os.getenv("OPENAI_BASE_URL") or "").strip() or None
|
||||
if not self.api_key:
|
||||
raise RuntimeError("未设置 OPENAI_API_KEY,无法使用 OpenAI Responses")
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except Exception as e:
|
||||
raise RuntimeError("未安装 openai 依赖,请先 pip install -r requirements.txt") from e
|
||||
kw: dict[str, Any] = {"api_key": self.api_key}
|
||||
if self.base_url:
|
||||
kw["base_url"] = self.base_url
|
||||
self._client = OpenAI(**kw)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Normalize to the strictest OpenAI-compatible `responses` shape:
|
||||
- input.messages[*].role MUST be "user"
|
||||
- input.messages[*].content MUST be a list (content blocks)
|
||||
- text blocks use {"type":"text","text":...}
|
||||
- image blocks use {"type":"image_url","image_url":{"url":"..."}} or {"type":"input_image","image_url":"..."} depending on gateway;
|
||||
we prefer the common "image_url" block here.
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for m in messages or []:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
role = str(m.get("role") or "user").strip().lower() or "user"
|
||||
content = m.get("content")
|
||||
norm_content: list[dict[str, Any]] = []
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "input_image":
|
||||
mime = str(item.get("mime") or "image/jpeg").strip() or "image/jpeg"
|
||||
b64 = normalize_image_b64_payload(item.get("image_base64") or item.get("data"))
|
||||
if not b64:
|
||||
continue
|
||||
norm_content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
|
||||
continue
|
||||
if isinstance(item, dict) and item.get("type") in ("input_text", "text"):
|
||||
t = str(item.get("text") or "").strip()
|
||||
if t:
|
||||
norm_content.append({"type": "text", "text": t})
|
||||
continue
|
||||
if isinstance(item, dict):
|
||||
# Best-effort coerce unknown blocks into valid text blocks.
|
||||
s = str(item.get("text") or "").strip()
|
||||
if s:
|
||||
norm_content.append({"type": "text", "text": s})
|
||||
continue
|
||||
s = str(item or "").strip()
|
||||
if s:
|
||||
norm_content.append({"type": "text", "text": s})
|
||||
elif isinstance(content, str):
|
||||
txt = content.strip()
|
||||
if txt:
|
||||
norm_content.append({"type": "text", "text": txt})
|
||||
elif content is not None:
|
||||
s = str(content).strip()
|
||||
if s:
|
||||
norm_content.append({"type": "text", "text": s})
|
||||
if not norm_content:
|
||||
# Keep shape valid even for empty messages.
|
||||
continue
|
||||
|
||||
# Enforce role=user and preserve other roles via prefix tag.
|
||||
if role != "user":
|
||||
prefix = "assistant" if role == "assistant" else ("system" if role == "system" else role)
|
||||
norm_content.insert(0, {"type": "text", "text": f"[{prefix}]"})
|
||||
out.append({"role": "user", "content": norm_content})
|
||||
return out
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
*,
|
||||
on_token: Optional[Callable[[str], None]] = None,
|
||||
) -> LLMResponse:
|
||||
norm = self._normalize_messages(messages)
|
||||
# OpenAI-compatible gateways differ: some require input={"messages":[...]} with role=user only.
|
||||
stream_errors: list[str] = []
|
||||
stream_variants: list[dict[str, Any]] = [
|
||||
{"model": self.model, "input": {"messages": norm}, "tools": tools or None, "stream": True},
|
||||
{"model": self.model, "input": norm, "tools": tools or None, "stream": True},
|
||||
]
|
||||
try:
|
||||
for payload in stream_variants:
|
||||
try:
|
||||
stream = self._client.responses.create(**payload)
|
||||
text, tool_calls, final_resp = parse_openai_responses_stream_events(stream, on_token=on_token)
|
||||
if (not text.strip()) and final_resp:
|
||||
ot = final_resp.get("output_text")
|
||||
if isinstance(ot, str) and ot.strip():
|
||||
text = ot
|
||||
if on_token:
|
||||
on_token(text)
|
||||
return LLMResponse(content=text, tool_calls=tool_calls)
|
||||
except Exception as exc:
|
||||
stream_errors.append(str(exc))
|
||||
continue
|
||||
raise RuntimeError("; ".join(stream_errors) or "responses_stream_all_variants_failed")
|
||||
except Exception as exc:
|
||||
logger.info("responses stream failed; fallback to non-stream (%s)", exc)
|
||||
nonstream_errors: list[str] = []
|
||||
for payload in (
|
||||
{"model": self.model, "input": {"messages": norm}, "tools": tools or None},
|
||||
{"model": self.model, "input": norm, "tools": tools or None},
|
||||
):
|
||||
try:
|
||||
resp = self._client.responses.create(**payload)
|
||||
d = _as_dict(resp) or {}
|
||||
text = str(d.get("output_text") or "")
|
||||
tool_calls = _collect_tool_calls_from_response_dict(d)
|
||||
if on_token and text:
|
||||
on_token(text)
|
||||
return LLMResponse(content=text, tool_calls=tool_calls)
|
||||
except Exception as e2:
|
||||
nonstream_errors.append(str(e2))
|
||||
continue
|
||||
raise RuntimeError(
|
||||
"openai_responses_request_failed: "
|
||||
+ " | ".join([str(exc)] + nonstream_errors[-2:])
|
||||
) from exc
|
||||
|
||||
|
||||
__all__ = ["OpenAIResponsesModel", "parse_openai_responses_stream_events"]
|
||||
|
||||
144
platform/llm/transports/simple.py
Normal file
144
platform/llm/transports/simple.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from collections.abc import Callable
|
||||
|
||||
from oclaw.platform.llm.transports.base import ChatModel, LLMResponse, LLMToolCall
|
||||
|
||||
|
||||
class StaticTextChatModel(ChatModel):
|
||||
def __init__(self, text: str):
|
||||
self._text = text or ""
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
*,
|
||||
on_token: Optional[Callable[[str], None]] = None,
|
||||
) -> LLMResponse:
|
||||
if on_token and self._text:
|
||||
on_token(self._text)
|
||||
return LLMResponse(content=self._text, tool_calls=[])
|
||||
|
||||
|
||||
class RuleBasedChatModel(ChatModel):
|
||||
_IP_RE = re.compile(
|
||||
r"(?P<ip>(?:\d{1,3}\.){3}\d{1,3})|(?P<ipv6>(?:[0-9a-fA-F]{0,4}:){2,}[0-9a-fA-F]{0,4})"
|
||||
)
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
*,
|
||||
on_token: Optional[Callable[[str], None]] = None,
|
||||
) -> LLMResponse:
|
||||
def make_call(name: str, arguments: dict[str, Any]) -> LLMResponse:
|
||||
return LLMResponse(
|
||||
content="",
|
||||
tool_calls=[LLMToolCall(id=f"rb_{name}_1", name=name, arguments=arguments)],
|
||||
)
|
||||
|
||||
def emit(resp: LLMResponse) -> LLMResponse:
|
||||
if on_token and (resp.content or "").strip():
|
||||
on_token(resp.content)
|
||||
return resp
|
||||
|
||||
if messages:
|
||||
last = messages[-1]
|
||||
if last.get("role") == "tool":
|
||||
raw = str(last.get("content") or "")
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
data = {"ok": True, "raw": raw}
|
||||
|
||||
if isinstance(data, dict) and data.get("ok") is False:
|
||||
return emit(LLMResponse(content=f"工具执行失败:{data.get('error')}", tool_calls=[]))
|
||||
|
||||
if isinstance(data, dict) and "route" in data:
|
||||
r = data.get("route") or {}
|
||||
return emit(
|
||||
LLMResponse(
|
||||
content=f"路由查询结果:目的 {data.get('destination')} 命中 {r.get('prefix')},下一跳 {r.get('next_hop')},出接口 {r.get('out_if')}。",
|
||||
tool_calls=[],
|
||||
)
|
||||
)
|
||||
|
||||
if isinstance(data, dict) and "hops" in data:
|
||||
hops = data.get("hops") or []
|
||||
return emit(
|
||||
LLMResponse(
|
||||
content=f"路径计算结果:{data.get('src')} → {data.get('dst')} 共 {data.get('hop_count')} 跳,路径为:{' -> '.join(map(str, hops))}",
|
||||
tool_calls=[],
|
||||
)
|
||||
)
|
||||
|
||||
if isinstance(data, dict) and "diff" in data:
|
||||
diff = data.get("diff") or ""
|
||||
if not diff:
|
||||
return emit(LLMResponse(content="配置对比结果:两段配置一致。", tool_calls=[]))
|
||||
return emit(LLMResponse(content=f"配置对比结果:\n\n```diff\n{diff}\n```", tool_calls=[]))
|
||||
|
||||
if isinstance(data, dict) and "level_count" in data:
|
||||
lc = data.get("level_count") or {}
|
||||
return emit(LLMResponse(content=f"日志分析结果:统计 {lc}。建议优先查看 ERROR 样例与日志尾部定位根因。", tool_calls=[]))
|
||||
|
||||
if isinstance(data, dict) and "reachable" in data:
|
||||
reach = bool(data.get("reachable"))
|
||||
avg = data.get("avg_ms")
|
||||
if reach:
|
||||
tail = f"平均时延 {avg}ms" if isinstance(avg, int) else "时延解析失败"
|
||||
return emit(LLMResponse(content=f"设备状态:可达,{tail}。", tool_calls=[]))
|
||||
return emit(LLMResponse(content="设备状态:不可达。建议检查 ACL/路由/DNS/防火墙策略,并确认目标在线。", tool_calls=[]))
|
||||
|
||||
return emit(LLMResponse(content=f"工具执行结果:{json.dumps(data, ensure_ascii=False)}", tool_calls=[]))
|
||||
|
||||
last_user = ""
|
||||
for m in reversed(messages):
|
||||
if m.get("role") == "user":
|
||||
content = m.get("content")
|
||||
if isinstance(content, list):
|
||||
texts = [item["text"] for item in content if isinstance(item, dict) and item.get("type") == "text"]
|
||||
last_user = "\n".join(texts)
|
||||
else:
|
||||
last_user = str(content or "")
|
||||
break
|
||||
|
||||
m = self._IP_RE.search(last_user)
|
||||
if "路由" in last_user and m:
|
||||
ip = m.group("ip") or m.group("ipv6")
|
||||
return make_call("query_route", {"destination": ip})
|
||||
|
||||
if ("路径" in last_user or "链路" in last_user) and ("到" in last_user or "->" in last_user):
|
||||
m2 = re.search(r"([A-Za-z0-9_-]+)\s*(?:到|->)\s*([A-Za-z0-9_-]+)", last_user)
|
||||
if m2:
|
||||
src, dst = m2.group(1), m2.group(2)
|
||||
return make_call("get_path", {"src": src, "dst": dst})
|
||||
|
||||
if "对比" in last_user or "diff" in last_user.lower():
|
||||
if "left:" in last_user.lower() and "right:" in last_user.lower():
|
||||
left = last_user.split("left:", 1)[1].split("right:", 1)[0].strip()
|
||||
right = last_user.split("right:", 1)[1].strip()
|
||||
return make_call("config_diff", {"left": left, "right": right})
|
||||
|
||||
if "日志" in last_user and ("分析" in last_user or "统计" in last_user):
|
||||
return make_call("log_analysis", {"log": last_user})
|
||||
|
||||
if ("设备" in last_user or "主机" in last_user) and ("状态" in last_user or "可达" in last_user or "ping" in last_user.lower()) and m:
|
||||
ip = m.group("ip") or m.group("ipv6")
|
||||
return make_call("device_status", {"host": ip})
|
||||
|
||||
return emit(
|
||||
LLMResponse(
|
||||
content="我可以进行路由查询、路径计算、配置对比、设备状态检查、日志分析。请直接描述需求并提供必要输入。",
|
||||
tool_calls=[],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["StaticTextChatModel", "RuleBasedChatModel"]
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue