refactor: enforce BasedPyright strict type checking (#5158)

This commit is contained in:
chengyongru
2026-07-29 21:37:11 +08:00
committed by GitHub
parent e703481755
commit 757ad9c764
166 changed files with 4728 additions and 2621 deletions
+54 -35
View File
@@ -9,8 +9,8 @@ import re
import secrets
import string
from collections import deque
from collections.abc import Awaitable, Callable
from typing import Any
from collections.abc import Awaitable, Callable, Iterable
from typing import Any, cast
from loguru import logger
@@ -198,7 +198,13 @@ class AnthropicProvider(LLMProvider):
content = msg.get("content")
if role == "system":
system = content if isinstance(content, (str, list)) else str(content or "")
system = (
cast(list[dict[str, Any]], content)
if isinstance(content, list)
else content
if isinstance(content, str)
else str(content or "")
)
continue
if role == "tool":
@@ -206,7 +212,7 @@ class AnthropicProvider(LLMProvider):
if raw and raw[-1]["role"] == "user":
prev_c = raw[-1]["content"]
if isinstance(prev_c, list):
prev_c.append(block)
cast(list[Any], prev_c).append(block)
else:
raw[-1]["content"] = [
{"type": "text", "text": prev_c or ""}, block,
@@ -264,41 +270,49 @@ class AnthropicProvider(LLMProvider):
blocks: list[dict[str, Any]] = []
content = msg.get("content")
for tb in msg.get("thinking_blocks") or []:
if isinstance(tb, dict) and tb.get("type") == "thinking":
blocks.append({
"type": "thinking",
"thinking": tb.get("thinking", ""),
"signature": tb.get("signature", ""),
})
for tb in cast(Iterable[object], msg.get("thinking_blocks") or []):
if isinstance(tb, dict):
thinking_block = cast(dict[str, Any], tb)
if thinking_block.get("type") == "thinking":
blocks.append({
"type": "thinking",
"thinking": thinking_block.get("thinking", ""),
"signature": thinking_block.get("signature", ""),
})
if isinstance(content, str) and content:
blocks.append({"type": "text", "text": content})
elif isinstance(content, list):
for item in content:
for item in cast(list[object], content):
if isinstance(item, dict):
if not item.get("type"):
content_block = cast(dict[str, Any], item)
if not content_block.get("type"):
# Anthropic requires every content block to declare a "type".
# A tool that returned a bare dict lands here; coerce it to
# a text block instead of emitting one that the API rejects.
blocks.append({
"type": "text",
"text": AnthropicProvider._stringify_typeless_block(item),
"text": AnthropicProvider._stringify_typeless_block(content_block),
})
else:
blocks.append(item)
blocks.append(content_block)
else:
blocks.append({"type": "text", "text": str(item)})
for tc in msg.get("tool_calls") or []:
for tc in cast(Iterable[object], msg.get("tool_calls") or []):
if not isinstance(tc, dict):
continue
func = tc.get("function", {})
tool_call = cast(dict[str, Any], tc)
func = cast(dict[str, Any], tool_call.get("function", {}))
args = func.get("arguments", "{}")
raw_id = tc.get("id") or _gen_tool_id()
raw_id = tool_call.get("id") or _gen_tool_id()
blocks.append({
"type": "tool_use",
"id": map_tool_id(raw_id) if map_tool_id is not None else _sanitize_tool_id(raw_id),
"id": (
map_tool_id(raw_id)
if map_tool_id is not None
else _sanitize_tool_id(cast(str, raw_id))
),
"name": func.get("name", ""),
"input": tool_arguments_object_for_replay(args),
})
@@ -314,26 +328,27 @@ class AnthropicProvider(LLMProvider):
return str(content)
result: list[dict[str, Any]] = []
for item in content:
for item in cast(list[object], content):
if not isinstance(item, dict):
result.append({"type": "text", "text": str(item)})
continue
if item.get("type") == "image_url":
converted = AnthropicProvider._convert_image_block(item)
content_block = cast(dict[str, Any], item)
if content_block.get("type") == "image_url":
converted = AnthropicProvider._convert_image_block(content_block)
if converted:
result.append(converted)
continue
if not item.get("type"):
if not content_block.get("type"):
# Anthropic requires every content block to declare a "type".
# A tool that returned a bare dict (or a list of dicts) lands
# here; coerce it to a text block instead of emitting a block
# the API rejects with "content.0.type: Field required".
result.append({
"type": "text",
"text": AnthropicProvider._stringify_typeless_block(item),
"text": AnthropicProvider._stringify_typeless_block(content_block),
})
continue
result.append(item)
result.append(content_block)
return result or "(empty)"
@staticmethod
@@ -343,7 +358,8 @@ class AnthropicProvider(LLMProvider):
@staticmethod
def _convert_image_block(block: dict[str, Any]) -> dict[str, Any] | None:
"""Convert OpenAI image_url block to Anthropic image block."""
url = (block.get("image_url") or {}).get("url", "")
image_url = cast(dict[str, Any], block.get("image_url") or {})
url = cast(str, image_url.get("url", ""))
if not url:
return None
m = re.match(r"data:(image/\w+);base64,(.+)", url, re.DOTALL)
@@ -367,10 +383,13 @@ class AnthropicProvider(LLMProvider):
content = msg.get("content")
if not isinstance(content, list):
return False
return any(
isinstance(block, dict) and block.get("type") == "tool_use"
for block in content
)
for block in cast(list[object], content):
if (
isinstance(block, dict)
and cast(dict[str, Any], block).get("type") == "tool_use"
):
return True
return False
@staticmethod
def _merge_consecutive(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -402,7 +421,7 @@ class AnthropicProvider(LLMProvider):
if isinstance(cur_c, str):
cur_c = [{"type": "text", "text": cur_c}]
if isinstance(cur_c, list):
prev_c.extend(cur_c)
cast(list[Any], prev_c).extend(cast(list[Any], cur_c))
merged[-1]["content"] = prev_c
else:
merged.append(msg)
@@ -446,7 +465,7 @@ class AnthropicProvider(LLMProvider):
def _convert_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
if not tools:
return None
result = []
result: list[dict[str, Any]] = []
for tool in tools:
func = tool.get("function", tool)
entry: dict[str, Any] = {
@@ -506,7 +525,7 @@ class AnthropicProvider(LLMProvider):
if isinstance(c, str):
new_msgs[-2] = {**m, "content": [{"type": "text", "text": c, "cache_control": marker}]}
elif isinstance(c, list) and c:
nc = list(c)
nc = list(cast(list[dict[str, Any]], c))
nc[-1] = {**nc[-1], "cache_control": marker}
new_msgs[-2] = {**m, "content": nc}
@@ -570,7 +589,7 @@ class AnthropicProvider(LLMProvider):
kwargs["temperature"] = 1.0
elif thinking_enabled:
budget_map = {"low": 1024, "medium": 4096, "high": max(8192, max_tokens)}
budget = budget_map.get(reasoning_effort.lower(), 4096)
budget = budget_map.get(cast(str, reasoning_effort).lower(), 4096)
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
kwargs["max_tokens"] = max(max_tokens, budget + 4096)
if not omit_temperature:
@@ -683,7 +702,7 @@ class AnthropicProvider(LLMProvider):
reasoning_effort, tool_choice,
)
try:
response = await self._client.messages.create(**kwargs)
response = cast(Any, await self._client.messages.create(**kwargs))
return self._parse_response(response)
except Exception as e:
if self._is_streaming_required_error(e):
+3 -3
View File
@@ -21,7 +21,7 @@ from __future__ import annotations
import uuid
from collections.abc import Awaitable, Callable
from typing import Any
from typing import Any, cast
from openai import AsyncOpenAI
@@ -208,7 +208,7 @@ class AzureOpenAIProvider(LLMProvider):
reasoning_effort, tool_choice,
)
try:
response = await self._client.responses.create(**body)
response = cast(Any, await self._client.responses.create(**body))
return parse_response_output(response)
except Exception as e:
return self._handle_error(e)
@@ -234,7 +234,7 @@ class AzureOpenAIProvider(LLMProvider):
body["stream"] = True
try:
stream = await self._client.responses.create(**body)
stream = cast(Any, await self._client.responses.create(**body))
content, tool_calls, finish_reason, usage, reasoning_content = (
await consume_sdk_stream(stream, on_content_delta, on_tool_call_delta)
)
+35 -29
View File
@@ -10,7 +10,7 @@ from contextlib import suppress
from dataclasses import dataclass, field
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from typing import Any
from typing import Any, cast
import json_repair
from loguru import logger
@@ -67,7 +67,8 @@ class ToolCallRequest:
``messages.content.N.tool_use.name: Input should be a valid string``),
which permanently wedges the session.
"""
return isinstance(self.name, str) and bool(self.name)
runtime_name = cast(object, self.name)
return isinstance(runtime_name, str) and bool(runtime_name)
def to_openai_tool_call(self) -> dict[str, Any]:
"""Serialize to an OpenAI-style tool_call payload."""
@@ -76,7 +77,7 @@ class ToolCallRequest:
if isinstance(self.arguments, str)
else json.dumps(self.arguments, ensure_ascii=False)
)
tool_call = {
tool_call: dict[str, Any] = {
"id": self.id,
"type": "function",
"function": {
@@ -126,7 +127,7 @@ def tool_arguments_object_for_replay(arguments: Any) -> dict[str, Any]:
if arguments is None:
return {}
if isinstance(arguments, dict):
return arguments
return cast(dict[str, Any], arguments)
if not isinstance(arguments, str):
return {}
@@ -141,7 +142,7 @@ def tool_arguments_object_for_replay(arguments: Any) -> dict[str, Any]:
parsed = json_repair.loads(stripped)
except Exception:
return {}
return parsed if isinstance(parsed, dict) else {}
return cast(dict[str, Any], parsed) if isinstance(parsed, dict) else {}
def tool_arguments_json_for_replay(arguments: Any) -> str:
@@ -158,7 +159,7 @@ class LLMResponse:
usage: dict[str, int] = field(default_factory=dict)
retry_after: float | None = None # Provider supplied retry wait in seconds.
reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc.
thinking_blocks: list[dict] | None = None # Anthropic extended thinking
thinking_blocks: list[dict[str, Any]] | None = None # Anthropic extended thinking
# Structured error metadata used by retry policy when finish_reason == "error".
error_status_code: int | None = None
error_kind: str | None = None # e.g. "timeout", "connection"
@@ -298,19 +299,20 @@ class LLMProvider(ABC):
if isinstance(content, list):
new_items: list[Any] = []
changed = False
for item in content:
for raw_item in cast(list[object], content):
item = cast(dict[str, Any], raw_item) if isinstance(raw_item, dict) else None
if (
isinstance(item, dict)
item is not None
and item.get("type") in ("text", "input_text", "output_text")
and not item.get("text")
):
changed = True
continue
if isinstance(item, dict) and "_meta" in item:
if item is not None and "_meta" in item:
new_items.append({k: v for k, v in item.items() if k != "_meta"})
changed = True
else:
new_items.append(item)
new_items.append(raw_item)
if changed:
clean = dict(msg)
if new_items:
@@ -332,7 +334,7 @@ class LLMProvider(ABC):
# Defense-in-depth: scrub lone UTF-16 surrogates from every string leaf.
# This is idempotent and no-op when messages are already clean.
sanitized = sanitize_surrogates_deep(result)
return sanitized if isinstance(sanitized, list) else result
return cast(list[dict[str, Any]], sanitized) if isinstance(sanitized, list) else result
@staticmethod
def _tool_name(tool: dict[str, Any]) -> str:
@@ -341,8 +343,9 @@ class LLMProvider(ABC):
if isinstance(name, str):
return name
fn = tool.get("function")
if isinstance(fn, dict):
fname = fn.get("name")
fn_object = cast(dict[str, Any], fn) if isinstance(fn, dict) else None
if fn_object is not None:
fname = fn_object.get("name")
if isinstance(fname, str):
return fname
return ""
@@ -372,7 +375,7 @@ class LLMProvider(ABC):
allowed_keys: frozenset[str],
) -> list[dict[str, Any]]:
"""Keep only provider-safe message keys and normalize assistant content."""
sanitized = []
sanitized: list[dict[str, Any]] = []
for msg in messages:
clean = {k: v for k, v in msg.items() if k in allowed_keys}
if clean.get("role") == "assistant" and "content" not in clean:
@@ -465,7 +468,7 @@ class LLMProvider(ABC):
def _extract_error_type_code(cls, payload: Any) -> tuple[str | None, str | None]:
data: dict[str, Any] | None = None
if isinstance(payload, dict):
data = payload
data = cast(dict[str, Any], payload)
elif isinstance(payload, str):
text = payload.strip()
if text:
@@ -474,16 +477,17 @@ class LLMProvider(ABC):
except Exception:
parsed = None
if isinstance(parsed, dict):
data = parsed
if not isinstance(data, dict):
data = cast(dict[str, Any], parsed)
if data is None:
return None, None
error_obj = data.get("error")
type_value = data.get("type")
code_value = data.get("code")
if isinstance(error_obj, dict):
type_value = error_obj.get("type") or type_value
code_value = error_obj.get("code") or code_value
error_object = cast(dict[str, Any], error_obj) if isinstance(error_obj, dict) else None
if error_object is not None:
type_value = error_object.get("type") or type_value
code_value = error_object.get("code") or code_value
return cls._normalize_error_token(type_value), cls._normalize_error_token(code_value)
@@ -582,13 +586,14 @@ class LLMProvider(ABC):
def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
"""Replace image_url blocks with text placeholder. Returns None if no images found."""
found = False
result = []
result: list[dict[str, Any]] = []
for msg in messages:
content = msg.get("content")
if isinstance(content, list):
new_content = []
for b in content:
if isinstance(b, dict) and b.get("type") == "image_url":
new_content: list[Any] = []
for raw_block in cast(list[object], content):
block = cast(dict[str, Any], raw_block) if isinstance(raw_block, dict) else None
if block is not None and block.get("type") == "image_url":
placeholder = (
"[Image not delivered to model — "
"do not describe or reference it]"
@@ -596,7 +601,7 @@ class LLMProvider(ABC):
new_content.append({"type": "text", "text": placeholder})
found = True
else:
new_content.append(b)
new_content.append(raw_block)
result.append({**msg, "content": new_content})
else:
result.append(msg)
@@ -614,8 +619,9 @@ class LLMProvider(ABC):
for msg in messages:
content = msg.get("content")
if isinstance(content, list):
for i, b in enumerate(content):
if isinstance(b, dict) and b.get("type") == "image_url":
for i, raw_block in enumerate(cast(list[object], content)):
block = cast(dict[str, Any], raw_block) if isinstance(raw_block, dict) else None
if block is not None and block.get("type") == "image_url":
placeholder = (
"[Image not delivered to model — "
"do not describe or reference it]"
@@ -815,7 +821,7 @@ class LLMProvider(ABC):
if value is not None:
return value
if isinstance(headers, dict):
for key, value in headers.items():
for key, value in cast(dict[object, Any], headers).items():
if isinstance(key, str) and key.lower() == name.lower():
return value
return None
@@ -986,7 +992,7 @@ class LLMProvider(ABC):
on_retry_wait=on_retry_wait,
)
return last_response if last_response is not None else await call(**kw)
return last_response if last_response is not None else await call(**kw) # pyright: ignore[reportUnnecessaryComparison]
@abstractmethod
def get_default_model(self) -> str:
+108 -47
View File
@@ -1,3 +1,4 @@
# pyright: reportMissingTypeStubs=false
"""AWS Bedrock Converse provider."""
from __future__ import annotations
@@ -8,7 +9,7 @@ import json
import os
import re
from collections.abc import Awaitable, Callable, Iterator
from typing import Any
from typing import Any, cast
from nanobot.providers.base import (
LLMProvider,
@@ -30,7 +31,10 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
merged = dict(base)
for key, value in override.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
merged[key] = _deep_merge(merged[key], value)
merged[key] = _deep_merge(
cast(dict[str, Any], merged[key]),
cast(dict[str, Any], value),
)
else:
merged[key] = value
return merged
@@ -77,7 +81,8 @@ class BedrockProvider(LLMProvider):
session_kwargs: dict[str, Any] = {}
if self.profile:
session_kwargs["profile_name"] = self.profile
session = boto3.Session(**session_kwargs)
boto3_module = cast(Any, boto3)
session = boto3_module.Session(**session_kwargs)
client_kwargs: dict[str, Any] = {}
if self.region:
@@ -107,7 +112,8 @@ class BedrockProvider(LLMProvider):
@staticmethod
def _image_url_block(block: dict[str, Any]) -> dict[str, Any] | None:
url = (block.get("image_url") or {}).get("url", "")
image_url = cast(dict[str, Any], block.get("image_url") or {})
url = image_url.get("url", "")
if not isinstance(url, str) or not url:
return None
match = _IMAGE_DATA_URL.match(url)
@@ -132,10 +138,11 @@ class BedrockProvider(LLMProvider):
return [{"text": str(content)}]
blocks: list[dict[str, Any]] = []
for item in content:
if not isinstance(item, dict):
blocks.append({"text": str(item)})
for raw_item in cast(list[object], content):
if not isinstance(raw_item, dict):
blocks.append({"text": str(raw_item)})
continue
item = cast(dict[str, Any], raw_item)
item_type = item.get("type")
if item_type in _TEXT_BLOCK_TYPES or "text" in item:
@@ -181,6 +188,7 @@ class BedrockProvider(LLMProvider):
function = tool_call.get("function")
if not isinstance(function, dict):
return None
function = cast(dict[str, Any], function)
args = tool_arguments_object_for_replay(function.get("arguments", {}))
return {
"toolUse": {
@@ -216,8 +224,10 @@ class BedrockProvider(LLMProvider):
def _assistant_blocks(cls, msg: dict[str, Any]) -> list[dict[str, Any]]:
blocks: list[dict[str, Any]] = []
for thinking in msg.get("thinking_blocks") or []:
if isinstance(thinking, dict):
thinking_values = cast(list[object], msg.get("thinking_blocks") or [])
for thinking_value in thinking_values:
if isinstance(thinking_value, dict):
thinking = cast(dict[str, Any], thinking_value)
reasoning = cls._reasoning_block(thinking)
if reasoning:
blocks.append(reasoning)
@@ -228,8 +238,10 @@ class BedrockProvider(LLMProvider):
elif isinstance(content, list):
blocks.extend(block for block in cls._content_blocks(content) if "text" in block)
for tool_call in msg.get("tool_calls") or []:
if isinstance(tool_call, dict):
tool_call_values = cast(list[object], msg.get("tool_calls") or [])
for tool_call_value in tool_call_values:
if isinstance(tool_call_value, dict):
tool_call = cast(dict[str, Any], tool_call_value)
block = cls._tool_use_block(tool_call)
if block:
blocks.append(block)
@@ -240,7 +252,8 @@ class BedrockProvider(LLMProvider):
def _has_tool_use(msg: dict[str, Any]) -> bool:
content = msg.get("content")
return isinstance(content, list) and any(
isinstance(block, dict) and "toolUse" in block for block in content
isinstance(block, dict) and "toolUse" in block
for block in cast(list[object], content)
)
@staticmethod
@@ -249,12 +262,14 @@ class BedrockProvider(LLMProvider):
for msg in messages:
if merged and merged[-1].get("role") == msg.get("role"):
prev = merged[-1].setdefault("content", [])
cur = msg.get("content") or []
cur: Any = msg.get("content") or []
if not isinstance(prev, list):
prev = [{"text": str(prev)}]
merged[-1]["content"] = prev
else:
prev = cast(list[Any], prev)
if isinstance(cur, list):
prev.extend(cur)
prev.extend(cast(list[Any], cur))
else:
prev.append({"text": str(cur)})
else:
@@ -303,9 +318,12 @@ class BedrockProvider(LLMProvider):
return None
result: list[dict[str, Any]] = []
for tool in tools:
func = tool.get("function") if isinstance(tool.get("function"), dict) else tool
if not isinstance(func, dict):
continue
function_value = tool.get("function")
func = (
cast(dict[str, Any], function_value)
if isinstance(function_value, dict)
else tool
)
name = str(func.get("name") or "")
if not name:
continue
@@ -330,9 +348,11 @@ class BedrockProvider(LLMProvider):
content = msg.get("content")
if not isinstance(content, list):
continue
for block in content:
if isinstance(block, dict) and ("toolUse" in block or "toolResult" in block):
return True
for block_value in cast(list[object], content):
if isinstance(block_value, dict):
block = cast(dict[str, Any], block_value)
if "toolUse" in block or "toolResult" in block:
return True
return False
@staticmethod
@@ -356,7 +376,8 @@ class BedrockProvider(LLMProvider):
if tool_choice == "none":
return None
if isinstance(tool_choice, dict):
name = tool_choice.get("function", {}).get("name")
function = cast(dict[str, Any], tool_choice.get("function", {}))
name = function.get("name")
if name:
return {"tool": {"name": str(name)}}
return {"auto": {}}
@@ -457,8 +478,10 @@ class BedrockProvider(LLMProvider):
reasoning = block.get("reasoningContent")
if not isinstance(reasoning, dict):
return None, None
reasoning = cast(dict[str, Any], reasoning)
text_obj = reasoning.get("reasoningText")
if isinstance(text_obj, dict):
text_obj = cast(dict[str, Any], text_obj)
text = text_obj.get("text")
if isinstance(text, str):
return text, {
@@ -480,15 +503,19 @@ class BedrockProvider(LLMProvider):
reasoning_parts: list[str] = []
tool_calls: list[ToolCallRequest] = []
thinking_blocks: list[dict[str, Any]] = []
message = (response.get("output") or {}).get("message") or {}
output = cast(dict[str, Any], response.get("output") or {})
message = cast(dict[str, Any], output.get("message") or {})
for block in message.get("content") or []:
if not isinstance(block, dict):
content_blocks = cast(list[object], message.get("content") or [])
for block_value in content_blocks:
if not isinstance(block_value, dict):
continue
block = cast(dict[str, Any], block_value)
if isinstance(block.get("text"), str):
content_parts.append(block["text"])
content_parts.append(cast(str, block["text"]))
tool_use = block.get("toolUse")
if isinstance(tool_use, dict):
tool_use = cast(dict[str, Any], tool_use)
arguments = tool_use.get("input", {})
tool_calls.append(ToolCallRequest(
id=str(tool_use.get("toolUseId") or ""),
@@ -504,8 +531,8 @@ class BedrockProvider(LLMProvider):
return LLMResponse(
content="".join(content_parts) or None,
tool_calls=tool_calls,
finish_reason=cls._finish_reason(response.get("stopReason")),
usage=cls._usage(response.get("usage")),
finish_reason=cls._finish_reason(cast(str | None, response.get("stopReason"))),
usage=cls._usage(cast(dict[str, Any] | None, response.get("usage"))),
reasoning_content="".join(reasoning_parts) or None,
thinking_blocks=thinking_blocks or None,
)
@@ -522,11 +549,12 @@ class BedrockProvider(LLMProvider):
state: dict[str, Any],
) -> str | None:
if "contentBlockStart" in event:
data = event["contentBlockStart"]
data = cast(dict[str, Any], event["contentBlockStart"])
idx = int(data.get("contentBlockIndex") or 0)
start = data.get("start") or {}
start = cast(dict[str, Any], data.get("start") or {})
tool_use = start.get("toolUse")
if isinstance(tool_use, dict):
tool_use = cast(dict[str, Any], tool_use)
tool_buffers[idx] = {
"id": str(tool_use.get("toolUseId") or ""),
"name": str(tool_use.get("name") or ""),
@@ -535,21 +563,27 @@ class BedrockProvider(LLMProvider):
return None
if "contentBlockDelta" in event:
data = event["contentBlockDelta"]
data = cast(dict[str, Any], event["contentBlockDelta"])
idx = int(data.get("contentBlockIndex") or 0)
delta = data.get("delta") or {}
delta = cast(dict[str, Any], data.get("delta") or {})
text = delta.get("text")
if isinstance(text, str):
content_parts.append(text)
return text
tool_delta = delta.get("toolUse")
if isinstance(tool_delta, dict):
tool_delta = cast(dict[str, Any], tool_delta)
buf = tool_buffers.setdefault(idx, {"id": "", "name": "", "input": ""})
if isinstance(tool_delta.get("input"), str):
buf["input"] += tool_delta["input"]
reasoning = delta.get("reasoningContent")
if isinstance(reasoning, dict):
buf = state.setdefault("reasoning_buffers", {}).setdefault(
reasoning = cast(dict[str, Any], reasoning)
reasoning_buffers = cast(
dict[int, dict[str, Any]],
state.setdefault("reasoning_buffers", {}),
)
buf = reasoning_buffers.setdefault(
idx, {"text": "", "signature": "", "redactedContent": None}
)
if isinstance(reasoning.get("text"), str):
@@ -562,8 +596,13 @@ class BedrockProvider(LLMProvider):
return None
if "contentBlockStop" in event:
idx = int((event["contentBlockStop"] or {}).get("contentBlockIndex") or 0)
reasoning_buf = state.setdefault("reasoning_buffers", {}).pop(idx, None)
stop = cast(dict[str, Any], event["contentBlockStop"] or {})
idx = int(stop.get("contentBlockIndex") or 0)
reasoning_buffers = cast(
dict[int, dict[str, Any]],
state.setdefault("reasoning_buffers", {}),
)
reasoning_buf = reasoning_buffers.pop(idx, None)
if reasoning_buf:
if reasoning_buf.get("text"):
thinking_blocks.append({
@@ -589,11 +628,12 @@ class BedrockProvider(LLMProvider):
return None
if "messageStop" in event:
state["stop_reason"] = (event["messageStop"] or {}).get("stopReason")
message_stop = cast(dict[str, Any], event["messageStop"] or {})
state["stop_reason"] = message_stop.get("stopReason")
return None
if "metadata" in event:
metadata = event["metadata"] or {}
metadata = cast(dict[str, Any], event["metadata"] or {})
if isinstance(metadata.get("usage"), dict):
state["usage"] = metadata["usage"]
return None
@@ -631,14 +671,29 @@ class BedrockProvider(LLMProvider):
@classmethod
def _handle_error(cls, e: Exception) -> LLMResponse:
response = getattr(e, "response", None)
metadata = response.get("ResponseMetadata", {}) if isinstance(response, dict) else {}
headers = metadata.get("HTTPHeaders") if isinstance(metadata, dict) else None
error_obj = response.get("Error", {}) if isinstance(response, dict) else {}
message = error_obj.get("Message") if isinstance(error_obj, dict) else None
code = error_obj.get("Code") if isinstance(error_obj, dict) else None
status_code = metadata.get("HTTPStatusCode") if isinstance(metadata, dict) else None
body = message or str(e)
response_value = getattr(e, "response", None)
response = (
cast(dict[str, Any], response_value)
if isinstance(response_value, dict)
else {}
)
metadata_value = response.get("ResponseMetadata", {})
metadata = (
cast(dict[str, Any], metadata_value)
if isinstance(metadata_value, dict)
else {}
)
headers = metadata.get("HTTPHeaders")
error_value = response.get("Error", {})
error_obj = (
cast(dict[str, Any], error_value)
if isinstance(error_value, dict)
else {}
)
message = error_obj.get("Message")
code = error_obj.get("Code")
status_code = metadata.get("HTTPStatusCode")
body = cast(str, message or str(e))
retry_after = cls._extract_retry_after_from_headers(headers)
if retry_after is None:
retry_after = cls._extract_retry_after(body)
@@ -683,7 +738,10 @@ class BedrockProvider(LLMProvider):
kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature, reasoning_effort, tool_choice
)
response = await asyncio.to_thread(self._client.converse, **kwargs)
response = cast(
dict[str, Any],
await asyncio.to_thread(self._client.converse, **kwargs),
)
return self._parse_response(response)
except Exception as e:
return self._handle_error(e)
@@ -713,8 +771,11 @@ class BedrockProvider(LLMProvider):
kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature, reasoning_effort, tool_choice
)
response = await asyncio.to_thread(self._client.converse_stream, **kwargs)
stream = iter(response.get("stream") or [])
response = cast(
dict[str, Any],
await asyncio.to_thread(self._client.converse_stream, **kwargs),
)
stream = cast(Iterator[dict[str, Any]], iter(response.get("stream") or []))
while True:
event = await asyncio.wait_for(
asyncio.to_thread(_next_or_none, stream),
+2
View File
@@ -160,6 +160,8 @@ def _make_provider_core(
elif backend == "azure_openai":
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
if p is None or p.api_base is None:
raise RuntimeError("validated Azure provider setup is missing api_base")
provider = AzureOpenAIProvider(
api_key=p.api_key or "",
api_base=p.api_base,
+5 -3
View File
@@ -1,5 +1,7 @@
"""Provider wrapper that transparently fails over to fallback models on error."""
# pyright: reportIncompatibleMethodOverride=false, reportIncompatibleVariableOverride=false
from __future__ import annotations
import time
@@ -8,7 +10,7 @@ from typing import Any
from loguru import logger
from nanobot.providers.base import LLMProvider, LLMResponse
from nanobot.providers.base import GenerationSettings, LLMProvider, LLMResponse
# Circuit breaker tuned to match OpenAICompatProvider's Responses API breaker.
_PRIMARY_FAILURE_THRESHOLD = 3
@@ -121,11 +123,11 @@ class FallbackProvider(LLMProvider):
self._primary_tripped_at: float | None = None
@property
def generation(self):
def generation(self) -> GenerationSettings:
return self._primary.generation
@generation.setter
def generation(self, value):
def generation(self, value: GenerationSettings) -> None:
self._primary.generation = value
def get_default_model(self) -> str:
+15 -11
View File
@@ -1,5 +1,7 @@
"""GitHub Copilot OAuth-backed provider."""
# pyright: reportMissingTypeStubs=false
from __future__ import annotations
import asyncio
@@ -8,11 +10,13 @@ import time
import webbrowser
from collections.abc import Awaitable, Callable
from contextlib import suppress
from typing import Any, cast
import httpx
from oauth_cli_kit.models import OAuthToken
from oauth_cli_kit.storage import FileTokenStorage
from nanobot.providers.base import LLMResponse
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
@@ -232,19 +236,19 @@ class GitHubCopilotProvider(OpenAICompatProvider):
token = await self._get_copilot_access_token()
client = await self._ensure_client()
self.api_key = token
client.api_key = token
cast(Any, client).api_key = token
return token
async def chat(
self,
messages: list[dict[str, object]],
tools: list[dict[str, object]] | None = None,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None,
tool_choice: str | dict[str, object] | None = None,
):
tool_choice: str | dict[str, Any] | None = None,
) -> LLMResponse:
await self._refresh_client_api_key()
return await super().chat(
messages=messages,
@@ -258,17 +262,17 @@ class GitHubCopilotProvider(OpenAICompatProvider):
async def chat_stream(
self,
messages: list[dict[str, object]],
tools: list[dict[str, object]] | None = None,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None,
tool_choice: str | dict[str, object] | None = None,
on_content_delta: Callable[[str], None] | None = None,
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, object]], Awaitable[None]] | None = None,
):
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
await self._refresh_client_api_key()
return await super().chat_stream(
messages=messages,
+88 -74
View File
@@ -9,12 +9,13 @@ import re
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from typing import Any, cast
from urllib.parse import urljoin
import httpx
from loguru import logger
from nanobot.config.schema import Config, ProviderConfig
from nanobot.providers.registry import find_by_name
from nanobot.security.network import (
PinnedDNSAsyncTransport,
@@ -81,6 +82,18 @@ class GeneratedImageResponse:
raw: dict[str, Any]
def _as_json_object(value: object) -> dict[str, Any] | None:
"""Narrow an untrusted provider response value to a JSON object."""
return cast(dict[str, Any], value) if isinstance(value, dict) else None
def _as_json_objects(value: object) -> list[dict[str, Any]]:
"""Return object entries from an untrusted provider response array."""
if not isinstance(value, list):
return []
return [cast(dict[str, Any], item) for item in cast(list[object], value) if isinstance(item, dict)]
def _read_image_b64(path: str | Path) -> tuple[str, str]:
"""Return ``(mime, base64)`` for the image at ``path``."""
p = Path(path).expanduser()
@@ -249,7 +262,7 @@ def image_gen_provider_names() -> tuple[str, ...]:
return tuple(_IMAGE_GEN_PROVIDERS)
def image_gen_provider_configs(config: Any) -> dict[str, Any]:
def image_gen_provider_configs(config: Config) -> dict[str, ProviderConfig]:
providers_cfg = config.providers
return {
name: pc
@@ -315,7 +328,7 @@ class ImageGenerationProvider(ABC):
def _require_images(self, images: list[str], data: dict[str, Any]) -> None:
if images:
return
provider_error = data.get("error") if isinstance(data, dict) else None
provider_error = data.get("error")
label = self.provider_name
if provider_error:
raise ImageGenerationError(f"{label} returned no images: {provider_error}")
@@ -410,20 +423,17 @@ class OpenRouterImageGenerationClient(ImageGenerationProvider):
detail = response.text[:500]
raise ImageGenerationError(f"OpenRouter image generation failed: {detail}") from exc
data = response.json()
data = _as_json_object(response.json()) or {}
images: list[str] = []
text_parts: list[str] = []
for choice in data.get("choices") or []:
if not isinstance(choice, dict):
continue
message = choice.get("message") or {}
if isinstance(message.get("content"), str):
text_parts.append(message["content"])
for image in message.get("images") or []:
if not isinstance(image, dict):
continue
image_url = image.get("image_url") or image.get("imageUrl") or {}
url_value = image_url.get("url") if isinstance(image_url, dict) else None
for choice in _as_json_objects(data.get("choices")):
message = _as_json_object(choice.get("message")) or {}
message_content = message.get("content")
if isinstance(message_content, str):
text_parts.append(message_content)
for image in _as_json_objects(message.get("images")):
image_url = _as_json_object(image.get("image_url") or image.get("imageUrl"))
url_value = image_url.get("url") if image_url is not None else None
if isinstance(url_value, str) and url_value.startswith("data:image/"):
images.append(url_value)
@@ -527,7 +537,7 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider):
detail = response.text[:500]
raise ImageGenerationError(f"AIHubMix image generation failed: {detail}") from exc
payload = response.json()
payload = _as_json_object(response.json()) or {}
images = await _aihubmix_images_from_payload(payload, proxy=self.proxy)
self._require_images(images, payload)
@@ -538,11 +548,12 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider):
def _http_error_detail(response: httpx.Response) -> str:
"""Extract a readable error message from an HTTP error response."""
try:
data = response.json()
if isinstance(data, dict):
err = data.get("error")
if isinstance(err, dict):
return err.get("message") or str(err)
data = _as_json_object(response.json())
if data is not None:
err = _as_json_object(data.get("error"))
if err is not None:
message = err.get("message")
return message if isinstance(message, str) else str(err)
if err:
return str(err)
except Exception:
@@ -595,11 +606,11 @@ def _ollama_image_data_url(value: str) -> str:
def _ollama_images_from_payload(payload: dict[str, Any]) -> list[str]:
images: list[str] = []
def collect(value: Any) -> None:
def collect(value: object) -> None:
if isinstance(value, str) and value:
images.append(_ollama_image_data_url(value))
elif isinstance(value, list):
for item in value:
for item in cast(list[object], value):
collect(item)
collect(payload.get("image"))
@@ -768,14 +779,12 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
f"Gemini Imagen generation failed (HTTP {response.status_code}): {detail}"
) from exc
data = response.json()
data = _as_json_object(response.json()) or {}
images: list[str] = []
for prediction in data.get("predictions") or []:
if not isinstance(prediction, dict):
continue
for prediction in _as_json_objects(data.get("predictions")):
b64 = prediction.get("bytesBase64Encoded")
mime = prediction.get("mimeType", "image/png")
if isinstance(b64, str) and b64:
if isinstance(b64, str) and b64 and isinstance(mime, str):
images.append(f"data:{mime};base64,{b64}")
self._require_images(images, data)
@@ -824,23 +833,21 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
f"Gemini image generation failed (HTTP {response.status_code}): {detail}"
) from exc
data = response.json()
data = _as_json_object(response.json()) or {}
images: list[str] = []
text_parts: list[str] = []
for candidate in data.get("candidates") or []:
if not isinstance(candidate, dict):
continue
content = candidate.get("content") or {}
for part in content.get("parts") or []:
if not isinstance(part, dict):
continue
for candidate in _as_json_objects(data.get("candidates")):
content = _as_json_object(candidate.get("content")) or {}
for part in _as_json_objects(content.get("parts")):
if "text" in part:
text_parts.append(part["text"])
inline = part.get("inlineData")
if isinstance(inline, dict):
text = part["text"]
if isinstance(text, str):
text_parts.append(text)
inline = _as_json_object(part.get("inlineData"))
if inline is not None:
mime = inline.get("mimeType", "image/png")
b64 = inline.get("data", "")
if b64:
if isinstance(mime, str) and isinstance(b64, str) and b64:
images.append(f"data:{mime};base64,{b64}")
self._require_images(images, data)
@@ -914,9 +921,9 @@ async def _aihubmix_images_from_payload(
if "output" in payload:
candidates.append(payload["output"])
async def collect(value: Any) -> None:
async def collect(value: object) -> None:
if isinstance(value, list):
for item in value:
for item in cast(list[object], value):
await collect(item)
return
if isinstance(value, str):
@@ -925,32 +932,38 @@ async def _aihubmix_images_from_payload(
elif value.startswith(("http://", "https://")):
images.append(await _download_image_data_url(value, proxy=proxy))
return
if not isinstance(value, dict):
value_object = _as_json_object(value)
if value_object is None:
return
b64_json = value.get("b64_json")
b64_json = value_object.get("b64_json")
if isinstance(b64_json, str) and b64_json:
images.append(_b64_image_data_url(b64_json))
elif b64_json is not None:
await collect(b64_json)
bytes_base64 = value.get("bytesBase64") or value.get("bytes_base64") or value.get("base64")
bytes_base64 = (
value_object.get("bytesBase64")
or value_object.get("bytes_base64")
or value_object.get("base64")
)
if isinstance(bytes_base64, str) and bytes_base64:
images.append(_b64_image_data_url(bytes_base64))
image_url = value.get("image_url") or value.get("imageUrl")
if isinstance(image_url, dict):
await collect(image_url.get("url"))
image_url = value_object.get("image_url") or value_object.get("imageUrl")
image_url_object = _as_json_object(image_url)
if image_url_object is not None:
await collect(image_url_object.get("url"))
elif image_url is not None:
await collect(image_url)
url_value = value.get("url")
url_value = value_object.get("url")
if url_value is not None:
await collect(url_value)
for key in ("images", "image", "output"):
if key in value:
await collect(value[key])
if key in value_object:
await collect(value_object[key])
for candidate in candidates:
await collect(candidate)
@@ -1061,9 +1074,10 @@ def _minimax_images_from_payload(payload: dict[str, Any]) -> list[str]:
"""
images: list[str] = []
data = payload.get("data")
if not isinstance(data, dict):
data_object = _as_json_object(data)
if data_object is None:
return images
for b64 in data.get("image_base64") or []:
for b64 in cast(list[object], data_object.get("image_base64") or []):
if isinstance(b64, str) and b64:
images.append(_b64_image_data_url(b64))
return images
@@ -1381,11 +1395,14 @@ class CodexImageGenerationClient(ImageGenerationProvider):
image_size: str | None = None,
) -> GeneratedImageResponse:
try:
from oauth_cli_kit import get_token as get_codex_token
from oauth_cli_kit import ( # pyright: ignore[reportMissingTypeStubs]
get_token as _get_codex_token,
)
except ImportError:
raise ImageGenerationError(self.missing_key_message)
try:
get_codex_token = cast(Any, _get_codex_token)
token_kwargs = {"proxy": self.proxy} if self.proxy else {}
token = await asyncio.to_thread(get_codex_token, **token_kwargs)
except Exception as exc:
@@ -1405,9 +1422,9 @@ class CodexImageGenerationClient(ImageGenerationProvider):
len(reference_images),
)
headers = {
headers: dict[str, str] = {
"Authorization": f"Bearer {token.access}",
"chatgpt-account-id": token.account_id,
"chatgpt-account-id": str(token.account_id),
"OpenAI-Beta": "responses=experimental",
"originator": "nanobot",
"User-Agent": "nanobot (python)",
@@ -1537,9 +1554,7 @@ async def _openai_images_from_payload(
Handles both ``b64_json`` (preferred) and ``url`` (downloaded) formats.
"""
images: list[str] = []
for item in payload.get("data") or []:
if not isinstance(item, dict):
continue
for item in _as_json_objects(payload.get("data")):
b64 = item.get("b64_json")
if isinstance(b64, str) and b64:
images.append(_b64_image_data_url(b64))
@@ -1567,7 +1582,7 @@ async def _parse_codex_sse_images(
line = line_bytes.strip()
if line == "":
if buffer:
data_lines = []
data_lines: list[str] = []
for bl in buffer:
if bl.startswith("data:"):
data_lines.append(bl[5:].strip())
@@ -1577,9 +1592,11 @@ async def _parse_codex_sse_images(
if raw == "[DONE]":
break
try:
event = _json.loads(raw)
event = _as_json_object(_json.loads(raw))
except Exception:
continue
if event is None:
continue
ev_type = event.get("type", "")
if ev_type in ("error", "response.failed"):
logger.error("Codex SSE failure: {}", raw[:2000])
@@ -1596,12 +1613,13 @@ async def _parse_codex_sse_images(
raw = "".join(data_lines)
if raw and raw != "[DONE]":
try:
event = _json.loads(raw)
event = _as_json_object(_json.loads(raw))
except Exception:
pass
else:
_collect_images_from_sse_event(event, images)
_collect_text_from_sse_event(event, text_parts)
if event is not None:
_collect_images_from_sse_event(event, images)
_collect_text_from_sse_event(event, text_parts)
return images, "".join(text_parts).strip()
@@ -1609,7 +1627,7 @@ async def _parse_codex_sse_images(
def _collect_images_from_sse_event(event: dict[str, Any], images: list[str]) -> None:
if event.get("type") != "response.output_item.done":
return
item = event.get("item") or {}
item = _as_json_object(event.get("item")) or {}
if item.get("type") != "image_generation_call":
return
result = item.get("result")
@@ -1618,8 +1636,8 @@ def _collect_images_from_sse_event(event: dict[str, Any], images: list[str]) ->
images.append(result)
else:
images.append(_b64_image_data_url(result))
elif isinstance(result, dict):
image_url = result.get("image_url") or result.get("image") or ""
elif (result_object := _as_json_object(result)) is not None:
image_url = result_object.get("image_url") or result_object.get("image") or ""
if isinstance(image_url, str):
if image_url.startswith("data:image/"):
images.append(image_url)
@@ -1749,9 +1767,7 @@ def _stepfun_images_from_payload(payload: dict[str, Any]) -> list[str]:
StepFun returns images in ``data[].b64_json`` (base64 strings).
"""
images: list[str] = []
for item in payload.get("data") or []:
if not isinstance(item, dict):
continue
for item in _as_json_objects(payload.get("data")):
b64 = item.get("b64_json")
if isinstance(b64, str) and b64:
images.append(_b64_image_data_url(b64))
@@ -1894,9 +1910,7 @@ async def _zhipu_images_from_payload(
We download and re-encode as base64 data URLs.
"""
images: list[str] = []
for item in payload.get("data") or []:
if not isinstance(item, dict):
continue
for item in _as_json_objects(payload.get("data")):
url = item.get("url")
if isinstance(url, str) and url:
images.append(await _download_image_data_url(url, proxy=proxy))
@@ -2080,7 +2094,7 @@ class ModelScopeImageGenerationClient(ImageGenerationProvider):
data: dict[str, Any],
) -> list[str]:
images: list[str] = []
for url in data.get("output_images") or []:
for url in cast(list[object], data.get("output_images") or []):
if isinstance(url, str) and url:
if url.startswith("data:image/"):
images.append(url)
+4 -2
View File
@@ -1,12 +1,14 @@
"""OpenAI Codex Responses Provider."""
# pyright: reportMissingTypeStubs=false, reportPrivateUsage=false
from __future__ import annotations
import asyncio
import hashlib
import json
from collections.abc import Awaitable, Callable
from typing import Any
from typing import Any, cast
import httpx
from loguru import logger
@@ -83,7 +85,7 @@ class OpenAICodexProvider(LLMProvider):
stage = "oauth_token"
try:
token = await asyncio.to_thread(get_codex_token, proxy=self.proxy)
headers = _build_headers(token.account_id, token.access)
headers = _build_headers(cast(str, token.account_id), token.access)
stage = "codex_request"
try:
+125 -65
View File
@@ -1,5 +1,7 @@
"""OpenAI-compatible provider for all non-Anthropic LLM APIs."""
# pyright: reportPrivateImportUsage=false
from __future__ import annotations
import asyncio
@@ -13,9 +15,9 @@ import string
import time
import uuid
from collections import deque
from collections.abc import Awaitable, Callable
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
from ipaddress import ip_address
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import urlparse
from loguru import logger
@@ -91,12 +93,18 @@ _OPENAI_COMPAT_REQUEST_TIMEOUT_S = 120.0
# Maps ProviderSpec.thinking_style → extra_body builder.
# Each builder takes a bool (thinking_enabled) and returns the dict to
# merge into extra_body, keeping the style→wire-format mapping in one place.
_THINKING_STYLE_MAP: dict[str, Any] = {
_THINKING_STYLE_MAP: dict[
str,
Callable[[bool], dict[str, Any]],
] = {
"thinking_type": lambda on: {"thinking": {"type": "enabled" if on else "disabled"}},
"enable_thinking": lambda on: {"enable_thinking": on},
"reasoning_split": lambda on: {"reasoning_split": on},
}
_GATEWAY_REASONING_STYLE_MAP: dict[str, Any] = {
_GATEWAY_REASONING_STYLE_MAP: dict[
str,
Callable[[str], dict[str, Any]],
] = {
"reasoning_effort": lambda effort: {"reasoning": {"effort": effort}},
}
_QWEN_THINKING_MODELS: frozenset[str] = frozenset({
@@ -202,23 +210,30 @@ def _extract_text_tool_calls(content: str | None) -> tuple[str | None, list[Tool
spans: list[tuple[int, int]] = []
for match in _TEXT_TOOL_CALL_RE.finditer(content):
try:
payload = json.loads(_strip_json_fence(match.group(1)))
raw_payload: object = json.loads(
_strip_json_fence(match.group(1))
)
except Exception:
continue
if not isinstance(payload, dict):
if not isinstance(raw_payload, dict):
continue
payload = cast(dict[str, Any], raw_payload)
nested = payload.get("tool_call")
nested = cast(object, payload.get("tool_call"))
if isinstance(nested, dict):
payload = nested
function = payload.get("function")
payload = cast(dict[str, Any], nested)
function = cast(object, payload.get("function"))
if not isinstance(function, dict):
function = payload
name = function.get("name")
function_data = cast(dict[str, Any], function)
name = cast(object, function_data.get("name"))
if not isinstance(name, str) or not name:
continue
arguments = function.get("arguments", payload.get("arguments", {}))
arguments = function_data.get(
"arguments",
payload.get("arguments", {}),
)
tool_calls.append(ToolCallRequest(
id=str(payload.get("id") or _short_tool_id()),
name=name,
@@ -239,24 +254,24 @@ def _extract_text_tool_calls(content: str | None) -> tuple[str | None, list[Tool
return visible_content, tool_calls
def _get(obj: Any, key: str) -> Any:
def _get(obj: object, key: str) -> Any:
"""Get a value from dict or object attribute, returning None if absent."""
if isinstance(obj, dict):
return obj.get(key)
return cast(dict[str, Any], obj).get(key)
return getattr(obj, key, None)
def _coerce_dict(value: Any) -> dict[str, Any] | None:
def _coerce_dict(value: object) -> dict[str, Any] | None:
"""Try to coerce *value* to a dict; return None if not possible or empty."""
if value is None:
return None
if isinstance(value, dict):
return value if value else None
return cast(dict[str, Any], value) if value else None
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
dumped = model_dump()
dumped: object = model_dump()
if isinstance(dumped, dict) and dumped:
return dumped
return cast(dict[str, Any], dumped)
return None
@@ -368,19 +383,25 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
and isinstance(merged[key], dict)
and isinstance(value, dict)
):
merged[key] = _deep_merge(merged[key], value)
merged[key] = _deep_merge(
cast(dict[str, Any], merged[key]),
cast(dict[str, Any], value),
)
else:
merged[key] = value
return merged
def _merge_unique_list(base: Any, override: Any) -> Any:
def _merge_unique_list(base: object, override: object) -> object:
"""Append list values while preserving order and removing duplicates."""
if not isinstance(base, list) or not isinstance(override, list):
return override
result: list[Any] = []
result: list[object] = []
seen: set[str] = set()
for value in [*base, *override]:
for value in [
*cast(list[object], base),
*cast(list[object], override),
]:
try:
key = json.dumps(value, sort_keys=True, ensure_ascii=False)
except Exception:
@@ -513,7 +534,7 @@ class OpenAICompatProvider(LLMProvider):
http_client=http_client,
)
async def _ensure_client(self):
async def _ensure_client(self) -> AsyncOpenAIType:
"""Return the shared OpenAI client, creating it on first call."""
if self._client is not None:
return self._client
@@ -534,6 +555,8 @@ class OpenAICompatProvider(LLMProvider):
AsyncOpenAI = _AsyncOpenAI
self._build_client()
if self._client is None:
raise RuntimeError("OpenAI client initialization did not produce a client")
return self._client
def _setup_env(self, api_key: str, api_base: str | None) -> None:
@@ -567,7 +590,7 @@ class OpenAICompatProvider(LLMProvider):
{"type": "text", "text": content, "cache_control": cache_marker},
]}
if isinstance(content, list) and content:
nc = list(content)
nc = list(cast(list[dict[str, Any]], content))
nc[-1] = {**nc[-1], "cache_control": cache_marker}
return {**msg, "content": nc}
return msg
@@ -662,23 +685,24 @@ class OpenAICompatProvider(LLMProvider):
return map_id(value)
for clean in sanitized:
if isinstance(clean.get("tool_calls"), list):
normalized = []
tool_calls_value = cast(object, clean.get("tool_calls"))
if isinstance(tool_calls_value, list):
normalized: list[Any] = []
used_ids: set[str] = set()
for idx, tc in enumerate(clean["tool_calls"]):
for idx, tc in enumerate(cast(list[object], tool_calls_value)):
if not isinstance(tc, dict):
normalized.append(tc)
continue
tc_clean = dict(tc)
tc_clean = dict(cast(dict[str, Any], tc))
raw_id = tc_clean.get("id")
mapped_id = unique_tool_id(raw_id, used_ids, idx)
tc_clean["id"] = mapped_id
used_ids.add(mapped_id)
if isinstance(raw_id, str) and raw_id:
pending_tool_ids.setdefault(raw_id, deque()).append(mapped_id)
function = tc_clean.get("function")
function = cast(object, tc_clean.get("function"))
if isinstance(function, dict):
function_clean = dict(function)
function_clean = dict(cast(dict[str, Any], function))
if "arguments" in function_clean:
function_clean["arguments"] = tool_arguments_json_for_replay(
function_clean.get("arguments")
@@ -715,9 +739,13 @@ class OpenAICompatProvider(LLMProvider):
route_prefixes = getattr(spec, "strip_model_prefixes", ())
if not isinstance(route_prefixes, tuple) or not route_prefixes:
return model_name
typed_route_prefixes = cast(tuple[str, ...], route_prefixes)
model_prefix, routed_model = model_name.split("/", 1)
model_prefix_key = _provider_prefix_key(model_prefix)
if any(_provider_prefix_key(prefix) == model_prefix_key for prefix in route_prefixes):
if any(
_provider_prefix_key(prefix) == model_prefix_key
for prefix in typed_route_prefixes
):
return routed_model
return model_name
@@ -1050,25 +1078,25 @@ class OpenAICompatProvider(LLMProvider):
# ------------------------------------------------------------------
@staticmethod
def _maybe_mapping(value: Any) -> dict[str, Any] | None:
def _maybe_mapping(value: object) -> dict[str, Any] | None:
if isinstance(value, dict):
return value
return cast(dict[str, Any], value)
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
dumped = model_dump()
dumped: object = model_dump()
if isinstance(dumped, dict):
return dumped
return cast(dict[str, Any], dumped)
return None
@classmethod
def _extract_text_content(cls, value: Any) -> str | None:
def _extract_text_content(cls, value: object) -> str | None:
if value is None:
return None
if isinstance(value, str):
return value
if isinstance(value, list):
parts: list[str] = []
for item in value:
for item in cast(list[object], value):
item_map = cls._maybe_mapping(item)
if item_map:
# Skip Mistral-style {"type":"thinking","thinking":[...]}
@@ -1089,7 +1117,7 @@ class OpenAICompatProvider(LLMProvider):
return str(value)
@classmethod
def _extract_thinking_content(cls, value: Any) -> str | None:
def _extract_thinking_content(cls, value: object) -> str | None:
"""Extract reasoning text from Mistral-style thinking blocks.
Mistral returns content as a list mixing
@@ -1101,7 +1129,7 @@ class OpenAICompatProvider(LLMProvider):
if not isinstance(value, list):
return None
parts: list[str] = []
for item in value:
for item in cast(list[object], value):
item_map = cls._maybe_mapping(item)
if not item_map:
continue
@@ -1163,21 +1191,21 @@ class OpenAICompatProvider(LLMProvider):
return result
@staticmethod
def _get_nested_int(obj: Any, path: tuple[str, ...]) -> int:
def _get_nested_int(obj: object, path: tuple[str, ...]) -> int:
"""Drill into *obj* by *path* segments and return an ``int`` value.
Supports both dict-key access and attribute access so it works
uniformly with raw JSON dicts **and** SDK Pydantic models.
"""
current = obj
current: object = obj
for segment in path:
if current is None:
return 0
if isinstance(current, dict):
current = current.get(segment)
current = cast(dict[str, Any], current).get(segment)
else:
current = getattr(current, segment, None)
return int(current or 0) if current is not None else 0
return int(cast(Any, current) or 0) if current is not None else 0
def _parse(self, response: Any) -> LLMResponse:
if isinstance(response, str):
@@ -1185,7 +1213,10 @@ class OpenAICompatProvider(LLMProvider):
response_map = self._maybe_mapping(response)
if response_map is not None:
choices = response_map.get("choices") or []
choices = cast(
list[object],
response_map.get("choices") or [],
)
if not choices:
content = self._extract_text_content(
response_map.get("content") or response_map.get("output_text")
@@ -1211,7 +1242,7 @@ class OpenAICompatProvider(LLMProvider):
content = self._extract_text_content(msg0.get("content"))
finish_reason = str(choice0.get("finish_reason") or "stop")
raw_tool_calls: list[Any] = []
raw_tool_calls: list[object] = []
# StepFun: fallback to reasoning field when content is empty
if not content and msg0.get("reasoning") and self._spec and self._spec.reasoning_as_content:
content = self._extract_text_content(msg0.get("reasoning"))
@@ -1227,9 +1258,11 @@ class OpenAICompatProvider(LLMProvider):
for ch in choices:
ch_map = self._maybe_mapping(ch) or {}
m = self._maybe_mapping(ch_map.get("message")) or {}
tool_calls = m.get("tool_calls")
if isinstance(tool_calls, list) and tool_calls:
raw_tool_calls.extend(tool_calls)
message_tool_calls = cast(object, m.get("tool_calls"))
if isinstance(message_tool_calls, list) and message_tool_calls:
raw_tool_calls.extend(
cast(list[object], message_tool_calls)
)
if ch_map.get("finish_reason") in ("tool_calls", "stop"):
finish_reason = str(ch_map["finish_reason"])
if not content:
@@ -1240,7 +1273,7 @@ class OpenAICompatProvider(LLMProvider):
# Deduplicate tool call IDs (same pattern as streaming path)
# Some providers reuse the same ID for parallel tool calls.
_seen_tc_ids: set[str] = set()
parsed_tool_calls = []
parsed_tool_calls: list[ToolCallRequest] = []
for tc in raw_tool_calls:
tc_map = self._maybe_mapping(tc) or {}
fn = self._maybe_mapping(tc_map.get("function")) or {}
@@ -1281,11 +1314,11 @@ class OpenAICompatProvider(LLMProvider):
content = msg.content
finish_reason = choice.finish_reason
raw_tool_calls: list[Any] = []
raw_sdk_tool_calls: list[Any] = []
for ch in response.choices:
m = ch.message
if hasattr(m, "tool_calls") and m.tool_calls:
raw_tool_calls.extend(m.tool_calls)
raw_sdk_tool_calls.extend(m.tool_calls)
if ch.finish_reason in ("tool_calls", "stop"):
finish_reason = ch.finish_reason
if not content and m.content:
@@ -1293,8 +1326,8 @@ class OpenAICompatProvider(LLMProvider):
if not content and getattr(m, "reasoning", None) and self._spec and self._spec.reasoning_as_content:
content = m.reasoning
tool_calls = []
for tc in raw_tool_calls:
tool_calls: list[ToolCallRequest] = []
for tc in raw_sdk_tool_calls:
args = parse_tool_arguments(tc.function.arguments)
ec, prov, fn_prov = _extract_tc_extras(tc)
tool_calls.append(ToolCallRequest(
@@ -1376,7 +1409,10 @@ class OpenAICompatProvider(LLMProvider):
chunk_map = cls._maybe_mapping(chunk)
if chunk_map is not None:
choices = chunk_map.get("choices") or []
choices = cast(
list[object],
chunk_map.get("choices") or [],
)
if not choices:
usage = cls._extract_usage(chunk_map) or usage
text = cls._extract_text_content(
@@ -1402,7 +1438,12 @@ class OpenAICompatProvider(LLMProvider):
text = cls._extract_thinking_content(raw_delta_content)
if text:
reasoning_parts.append(text)
for idx, tc in enumerate(delta.get("tool_calls") or []):
for idx, tc in enumerate(
cast(
Iterable[object],
delta.get("tool_calls") or [],
)
):
_accum_tc(tc, idx)
_accum_legacy_function_call(delta.get("function_call"))
usage = cls._extract_usage(chunk_map) or usage
@@ -1430,7 +1471,12 @@ class OpenAICompatProvider(LLMProvider):
text = cls._extract_text_content(reasoning)
if text:
reasoning_parts.append(text)
for tc in (getattr(delta, "tool_calls", None) or []) if delta else []:
delta_tool_calls = (
cast(Iterable[object], getattr(delta, "tool_calls", None) or [])
if delta
else ()
)
for tc in delta_tool_calls:
_accum_tc(tc, getattr(tc, "index", 0))
if delta:
_accum_legacy_function_call(getattr(delta, "function_call", None))
@@ -1563,7 +1609,7 @@ class OpenAICompatProvider(LLMProvider):
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
) -> LLMResponse:
await self._ensure_client()
client = await self._ensure_client()
try:
if self._should_use_responses_api(model, reasoning_effort):
try:
@@ -1571,7 +1617,11 @@ class OpenAICompatProvider(LLMProvider):
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
)
result = parse_response_output(await self._client.responses.create(**body))
responses_raw = cast(
Any,
await client.responses.create(**body),
)
result = parse_response_output(responses_raw)
self._record_responses_success(model, reasoning_effort)
return result
except Exception as responses_error:
@@ -1590,7 +1640,11 @@ class OpenAICompatProvider(LLMProvider):
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
)
return self._parse(await self._client.chat.completions.create(**kwargs))
chat_raw = cast(
Any,
await client.chat.completions.create(**kwargs),
)
return self._parse(chat_raw)
except Exception as e:
return self._handle_error(e, spec=self._spec, api_base=self.api_base)
@@ -1607,7 +1661,7 @@ class OpenAICompatProvider(LLMProvider):
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
await self._ensure_client()
client = await self._ensure_client()
idle_timeout_s = resolve_stream_idle_timeout_s()
try:
if self._should_use_responses_api(model, reasoning_effort):
@@ -1617,10 +1671,13 @@ class OpenAICompatProvider(LLMProvider):
reasoning_effort, tool_choice,
)
body["stream"] = True
stream = await self._client.responses.create(**body)
responses_stream = cast(
Any,
await client.responses.create(**body),
)
async def _timed_stream():
stream_iter = stream.__aiter__()
async def _timed_stream() -> AsyncIterator[Any]:
stream_iter: AsyncIterator[Any] = responses_stream.__aiter__()
while True:
try:
yield await asyncio.wait_for(
@@ -1673,12 +1730,15 @@ class OpenAICompatProvider(LLMProvider):
kwargs.setdefault("extra_body", {})["tool_stream"] = True
kwargs["stream"] = True
kwargs["stream_options"] = {"include_usage": True}
stream = await self._client.chat.completions.create(**kwargs)
chat_stream = cast(
Any,
await client.chat.completions.create(**kwargs),
)
chunks: list[Any] = []
stream_iter = stream.__aiter__()
stream_iter: AsyncIterator[Any] = chat_stream.__aiter__()
while True:
try:
chunk = await asyncio.wait_for(
chunk: Any = await asyncio.wait_for(
stream_iter.__anext__(),
timeout=idle_timeout_s,
)
@@ -3,11 +3,15 @@
from __future__ import annotations
import json
from typing import Any
from typing import Any, cast
from nanobot.providers.base import tool_arguments_json_for_replay
def _as_json_object(value: object) -> dict[str, Any] | None:
return cast(dict[str, Any], value) if isinstance(value, dict) else None
def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
"""Convert Chat Completions messages to Responses API input items.
@@ -39,8 +43,11 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
"content": [{"type": "output_text", "text": content}],
"status": "completed", "id": message_id,
})
for tool_call in msg.get("tool_calls", []) or []:
fn = tool_call.get("function") or {}
for raw_tool_call in cast(list[object], msg.get("tool_calls", []) or []):
tool_call = _as_json_object(raw_tool_call)
if tool_call is None:
continue
fn = _as_json_object(tool_call.get("function")) or {}
call_id, item_id = split_tool_call_id(tool_call.get("id"))
response_item_id = _unique_item_id(item_id or f"fc_{idx}", used_item_ids)
input_items.append({
@@ -70,13 +77,15 @@ def convert_user_message(content: Any) -> dict[str, Any]:
return {"role": "user", "content": [{"type": "input_text", "text": content}]}
if isinstance(content, list):
converted: list[dict[str, Any]] = []
for item in content:
if not isinstance(item, dict):
for raw_item in cast(list[object], content):
item = _as_json_object(raw_item)
if item is None:
continue
if item.get("type") == "text":
converted.append({"type": "input_text", "text": item.get("text", "")})
elif item.get("type") == "image_url":
url = (item.get("image_url") or {}).get("url")
image = _as_json_object(item.get("image_url")) or {}
url = image.get("url")
if url:
converted.append({"type": "input_image", "image_url": url, "detail": "auto"})
if converted:
@@ -97,8 +106,9 @@ def convert_tool_output(content: Any) -> str | list[dict[str, Any]]:
return content
if isinstance(content, list):
converted: list[dict[str, Any]] = []
for item in content:
if not isinstance(item, dict):
for raw_item in cast(list[object], content):
item = _as_json_object(raw_item)
if item is None:
break
item_type = item.get("type")
if item_type in {"text", "input_text"}:
@@ -110,15 +120,16 @@ def convert_tool_output(content: Any) -> str | list[dict[str, Any]]:
converted.append({"type": "input_text", "text": text})
elif item_type in {"image_url", "input_image"}:
image = item.get("image_url")
if isinstance(image, dict) and set(image) - {"url", "detail"}:
image_object = _as_json_object(image)
if image_object is not None and set(image_object) - {"url", "detail"}:
break
if set(item) - {"type", "image_url", "file_id", "detail", "_meta"}:
break
url = image.get("url") if isinstance(image, dict) else image
url = image_object.get("url") if image_object is not None else image
file_id = item.get("file_id")
detail = item.get(
"detail",
image.get("detail", "auto") if isinstance(image, dict) else "auto",
image_object.get("detail", "auto") if image_object is not None else "auto",
)
if detail not in {"low", "high", "auto", "original"}:
break
@@ -160,11 +171,11 @@ def convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert OpenAI function-calling tool schema to Responses API flat format."""
converted: list[dict[str, Any]] = []
for tool in tools:
fn = (tool.get("function") or {}) if tool.get("type") == "function" else tool
fn = _as_json_object(tool.get("function")) or {} if tool.get("type") == "function" else tool
name = fn.get("name")
if not name:
continue
params = fn.get("parameters") or {}
params: object = fn.get("parameters") or {}
converted.append({
"type": "function",
"name": name,
+74 -50
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import json
from collections.abc import Awaitable, Callable
from typing import Any, AsyncGenerator
from typing import Any, AsyncGenerator, cast
import httpx
from loguru import logger
@@ -19,23 +19,58 @@ FINISH_REASON_MAP = {
}
def _as_json_object(value: object) -> dict[str, Any] | None:
"""Narrow untyped Responses API JSON payloads at the wire boundary."""
return cast(dict[str, Any], value) if isinstance(value, dict) else None
def _response_object(value: object) -> dict[str, Any] | None:
"""Convert a Responses SDK model or JSON object to a dictionary."""
object_value = _as_json_object(value)
if object_value is not None:
return object_value
dump = getattr(value, "model_dump", None)
if callable(dump):
return _as_json_object(dump())
try:
return _as_json_object(vars(value))
except TypeError:
return None
def _response_object_list(value: object) -> list[dict[str, Any]]:
"""Normalize a Responses API array that may contain SDK model objects."""
if not isinstance(value, list):
return []
return [
item
for raw in cast(list[object], value)
if (item := _response_object(raw)) is not None
]
def map_finish_reason(status: str | None) -> str:
"""Map a Responses API status string to a Chat-Completions-style finish_reason."""
return FINISH_REASON_MAP.get(status or "completed", "stop")
def _usage_from_response_obj(response: Any) -> dict[str, int]:
usage_raw = response.get("usage") if isinstance(response, dict) else getattr(response, "usage", None)
def _usage_from_response_obj(response: object) -> dict[str, int]:
response_object = _response_object(response)
usage_raw: object = (
response_object.get("usage")
if response_object is not None
else getattr(response, "usage", None)
)
if not usage_raw:
return {}
if not isinstance(usage_raw, dict):
dump = getattr(usage_raw, "model_dump", None)
usage_raw = dump() if callable(dump) else vars(usage_raw)
prompt_tokens = int(usage_raw.get("input_tokens") or usage_raw.get("prompt_tokens") or 0)
usage = _response_object(usage_raw)
if usage is None:
return {}
prompt_tokens = int(usage.get("input_tokens") or usage.get("prompt_tokens") or 0)
completion_tokens = int(
usage_raw.get("output_tokens") or usage_raw.get("completion_tokens") or 0
usage.get("output_tokens") or usage.get("completion_tokens") or 0
)
total_tokens = int(usage_raw.get("total_tokens") or prompt_tokens + completion_tokens)
total_tokens = int(usage.get("total_tokens") or prompt_tokens + completion_tokens)
return {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
@@ -77,7 +112,7 @@ async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], N
if not data or data == "[DONE]":
return None
try:
return json.loads(data)
return _as_json_object(json.loads(data))
except Exception:
logger.warning("Failed to parse SSE event JSON: {}", data[:200])
return None
@@ -134,7 +169,7 @@ async def consume_sse_with_reasoning(
await on_response_event(event)
event_type = event.get("type")
if event_type == "response.output_item.added":
item = event.get("item") or {}
item = _as_json_object(event.get("item")) or {}
if item.get("type") == "function_call":
call_id = item.get("call_id")
if not call_id:
@@ -170,7 +205,7 @@ async def consume_sse_with_reasoning(
if on_reasoning_delta:
await on_reasoning_delta(text)
elif event_type == "response.reasoning_summary_part.done":
part = event.get("part") or {}
part = _as_json_object(event.get("part")) or {}
text = part.get("text") if part.get("type") == "summary_text" else None
if text and not streamed_reasoning and not reasoning_content:
reasoning_content = text
@@ -203,7 +238,7 @@ async def consume_sse_with_reasoning(
"arguments": "" if arguments is None else str(arguments),
})
elif event_type == "response.output_item.done":
item = event.get("item") or {}
item = _as_json_object(event.get("item")) or {}
if item.get("type") == "function_call":
call_id = item.get("call_id")
if not call_id:
@@ -235,12 +270,12 @@ async def consume_sse_with_reasoning(
if on_reasoning_delta:
await on_reasoning_delta(summary)
elif event_type == "response.completed":
response_obj = event.get("response") or {}
response_obj = _response_object(event.get("response")) or {}
status = response_obj.get("status")
finish_reason = map_finish_reason(status)
usage = _usage_from_response_obj(response_obj) or usage
if not reasoning_content:
summary = _extract_reasoning_summary_from_output(response_obj.get("output") or [])
summary = _extract_reasoning_summary_from_output(response_obj.get("output"))
if summary:
reasoning_content = summary
if on_reasoning_delta:
@@ -252,54 +287,42 @@ async def consume_sse_with_reasoning(
return content, tool_calls, finish_reason, usage, reasoning_content
def _extract_reasoning_summary_from_output(output: Any) -> str | None:
def _extract_reasoning_summary_from_output(output: object) -> str | None:
parts: list[str] = []
for item in output or []:
if not isinstance(item, dict):
dump = getattr(item, "model_dump", None)
item = dump() if callable(dump) else vars(item)
for item in _response_object_list(output):
if item.get("type") != "reasoning":
continue
for summary in item.get("summary") or []:
if not isinstance(summary, dict):
dump = getattr(summary, "model_dump", None)
summary = dump() if callable(dump) else vars(summary)
for summary in _response_object_list(item.get("summary")):
if summary.get("type") == "summary_text" and summary.get("text"):
parts.append(summary["text"])
text = summary.get("text")
if isinstance(text, str):
parts.append(text)
return "".join(parts) or None
def parse_response_output(response: Any) -> LLMResponse:
def parse_response_output(response: object) -> LLMResponse:
"""Parse an SDK ``Response`` object into an ``LLMResponse``."""
if not isinstance(response, dict):
dump = getattr(response, "model_dump", None)
response = dump() if callable(dump) else vars(response)
response_object = _response_object(response) or {}
output = response.get("output") or []
output = _response_object_list(response_object.get("output"))
content_parts: list[str] = []
tool_calls: list[ToolCallRequest] = []
reasoning_content: str | None = None
for item in output:
if not isinstance(item, dict):
dump = getattr(item, "model_dump", None)
item = dump() if callable(dump) else vars(item)
item_type = item.get("type")
if item_type == "message":
for block in item.get("content") or []:
if not isinstance(block, dict):
dump = getattr(block, "model_dump", None)
block = dump() if callable(dump) else vars(block)
for block in _response_object_list(item.get("content")):
if block.get("type") == "output_text":
content_parts.append(block.get("text") or "")
text = block.get("text")
if isinstance(text, str):
content_parts.append(text)
elif item_type == "reasoning":
for s in item.get("summary") or []:
if not isinstance(s, dict):
dump = getattr(s, "model_dump", None)
s = dump() if callable(dump) else vars(s)
for s in _response_object_list(item.get("summary")):
if s.get("type") == "summary_text" and s.get("text"):
reasoning_content = (reasoning_content or "") + s["text"]
text = s.get("text")
if isinstance(text, str):
reasoning_content = (reasoning_content or "") + text
elif item_type == "function_call":
call_id = item.get("call_id") or ""
item_id = item.get("id") or "fc_0"
@@ -311,10 +334,10 @@ def parse_response_output(response: Any) -> LLMResponse:
arguments=args,
))
usage = _usage_from_response_obj(response)
usage = _usage_from_response_obj(response_object)
status = response.get("status")
finish_reason = map_finish_reason(status)
status = response_object.get("status")
finish_reason = map_finish_reason(status if isinstance(status, str) else None)
return LLMResponse(
content="".join(content_parts) or None,
@@ -339,7 +362,8 @@ async def consume_sdk_stream(
usage: dict[str, int] = {}
reasoning_content: str | None = None
async for event in stream:
async for raw_event in stream:
event: Any = raw_event
event_type = getattr(event, "type", None)
if event_type == "response.output_item.added":
item = getattr(event, "item", None)
@@ -431,9 +455,9 @@ async def consume_sdk_stream(
"completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0),
"total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0),
}
for out_item in getattr(resp, "output", None) or []:
for out_item in cast(list[Any], getattr(resp, "output", None) or []):
if getattr(out_item, "type", None) == "reasoning":
for s in getattr(out_item, "summary", None) or []:
for s in cast(list[Any], getattr(out_item, "summary", None) or []):
if getattr(s, "type", None) == "summary_text":
text = getattr(s, "text", None)
if text:
+5 -4
View File
@@ -13,7 +13,7 @@ import mimetypes
import os
from collections.abc import Callable
from pathlib import Path
from typing import Any
from typing import Any, cast
import httpx
from loguru import logger
@@ -116,7 +116,7 @@ async def _request_json_with_retry(
url: str,
*,
provider_label: str,
**kwargs: object,
**kwargs: Any,
) -> dict[str, Any] | None:
for attempt in range(_MAX_RETRIES + 1):
try:
@@ -190,7 +190,7 @@ async def _request_json_with_retry(
type(payload).__name__,
)
return None
return payload
return cast(dict[str, Any], payload)
return None
@@ -383,6 +383,7 @@ async def _post_stepfun_asr_with_retry(
payload = json.loads(payload_str)
except (json.JSONDecodeError, ValueError):
continue
payload = cast(dict[str, Any], payload)
event_type = payload.get("type", "")
if event_type == "error":
msg = payload.get("message", "unknown error")
@@ -503,7 +504,7 @@ async def _post_with_retry(
type(payload).__name__,
)
return ""
return extract_text(payload)
return extract_text(cast(dict[str, Any], payload))
return ""
+5 -3
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from typing import Any
from nanobot.providers.base import LLMProvider, LLMResponse
@@ -14,13 +16,13 @@ class UnconfiguredProvider(LLMProvider):
async def chat(
self,
messages: list[dict],
tools: list[dict] | None = None,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None,
tool_choice: str | dict | None = None,
tool_choice: str | dict[str, Any] | None = None,
) -> LLMResponse:
return LLMResponse(
content=(
+28 -18
View File
@@ -9,7 +9,7 @@ import re
import time
import uuid
from collections.abc import Awaitable, Callable
from typing import Any
from typing import Any, cast
import httpx
from loguru import logger
@@ -309,7 +309,7 @@ def _decode_access_token_claims(token: str) -> dict[str, Any]:
claims = json.loads(decoded)
except (ValueError, TypeError):
return {}
return claims if isinstance(claims, dict) else {}
return cast(dict[str, Any], claims) if isinstance(claims, dict) else {}
class _XAIHTTPError(RuntimeError):
@@ -356,7 +356,8 @@ async def _fetch_xai_model_capabilities(
def _parse_xai_model_capabilities(payload: Any) -> dict[str, bool]:
if isinstance(payload, dict):
rows = payload.get("data")
payload = cast(dict[str, Any], payload)
rows: object = payload.get("data")
if not isinstance(rows, list):
rows = payload.get("models")
else:
@@ -365,10 +366,12 @@ def _parse_xai_model_capabilities(payload: Any) -> dict[str, bool]:
return {}
capabilities: dict[str, bool] = {}
for row in rows:
if not isinstance(row, dict):
for row_value in cast(list[object], rows):
if not isinstance(row_value, dict):
continue
meta = row.get("_meta") if isinstance(row.get("_meta"), dict) else {}
row = cast(dict[str, Any], row_value)
meta_value = row.get("_meta")
meta = cast(dict[str, Any], meta_value) if isinstance(meta_value, dict) else {}
support_value = row.get("supportsBackendSearch")
if not isinstance(support_value, bool):
support_value = row.get("supports_backend_search")
@@ -444,7 +447,10 @@ def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
if event_type != "response.output_item.done":
return None
item = event.get("item")
if not isinstance(item, dict) or item.get("type") != "custom_tool_call":
if not isinstance(item, dict):
return None
item = cast(dict[str, Any], item)
if item.get("type") != "custom_tool_call":
return None
tool_name = item.get("name")
if not isinstance(tool_name, str) or not tool_name.startswith("x_"):
@@ -468,14 +474,14 @@ def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
def _xai_hosted_tool_arguments(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return dict(value)
return cast(dict[str, Any], value)
if not isinstance(value, str) or not value.strip():
return {}
try:
parsed = json.loads(value)
except (TypeError, ValueError):
return {}
return parsed if isinstance(parsed, dict) else {}
return cast(dict[str, Any], parsed) if isinstance(parsed, dict) else {}
def _build_xai_http_error(
@@ -483,8 +489,8 @@ def _build_xai_http_error(
headers: httpx.Headers,
raw: str,
) -> _XAIHTTPError:
retry_after = LLMProvider._extract_retry_after_from_headers(headers)
error_type, error_code = LLMProvider._extract_error_type_code(raw)
retry_after = LLMProvider._extract_retry_after_from_headers(headers) # pyright: ignore[reportPrivateUsage]
error_type, error_code = LLMProvider._extract_error_type_code(raw) # pyright: ignore[reportPrivateUsage]
response_body = _bounded_error_body(raw)
return _XAIHTTPError(
_friendly_error(status_code, response_body),
@@ -522,12 +528,16 @@ def _bounded_error_body(raw: str) -> str | None:
def _redact_error_payload(payload: Any) -> Any:
if isinstance(payload, dict):
return {
key: "[REDACTED]" if _is_sensitive_error_key(key) else _redact_error_payload(value)
for key, value in payload.items()
}
redacted: dict[str, Any] = {}
payload_mapping: dict[str, Any] = cast(dict[str, Any], payload)
for key in payload_mapping:
value = payload_mapping[key]
redacted[key] = (
"[REDACTED]" if _is_sensitive_error_key(key) else _redact_error_payload(value)
)
return redacted
if isinstance(payload, list):
return [_redact_error_payload(value) for value in payload]
return [_redact_error_payload(value) for value in cast(list[Any], payload)]
return payload
@@ -593,7 +603,7 @@ def _should_retry_status(
content: str | None,
) -> bool:
if status_code == 429:
return LLMProvider._is_retryable_429_response(
return LLMProvider._is_retryable_429_response( # pyright: ignore[reportPrivateUsage]
LLMResponse(
content=content or "",
finish_reason="error",
@@ -602,4 +612,4 @@ def _should_retry_status(
error_code=error_code,
)
)
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500 # pyright: ignore[reportPrivateUsage]
+18 -12
View File
@@ -23,7 +23,7 @@ from dataclasses import asdict, dataclass
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from typing import Any, cast
from urllib.parse import parse_qs, urlencode, urlsplit
import httpx
@@ -31,7 +31,7 @@ from filelock import FileLock
from loguru import logger
from nanobot.config.paths import get_data_dir
from nanobot.utils.helpers import _write_text_atomic
from nanobot.utils.helpers import _write_text_atomic # pyright: ignore[reportPrivateUsage]
XAI_OAUTH_ISSUER = "https://auth.x.ai"
XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
@@ -73,17 +73,18 @@ class XAIToken:
def from_dict(cls, value: Any) -> XAIToken | None:
if not isinstance(value, dict):
return None
access = value.get("access")
token_data = cast(dict[str, Any], value)
access = token_data.get("access")
if not isinstance(access, str) or not access:
return None
refresh = value.get("refresh")
refresh = token_data.get("refresh")
if not isinstance(refresh, str) or not refresh:
refresh = None
try:
expires = int(value.get("expires") or 0)
expires = int(token_data.get("expires") or 0)
except (TypeError, ValueError):
expires = 0
account_id = value.get("account_id")
account_id = token_data.get("account_id")
if not isinstance(account_id, str) or not account_id:
account_id = None
return cls(access=access, refresh=refresh, expires=expires, account_id=account_id)
@@ -521,7 +522,7 @@ def _make_callback_server(
self.send_header("Vary", "Origin")
self.send_header("Access-Control-Allow-Private-Network", "true")
def log_message(self, _format: str, *_args: Any) -> None:
def log_message(self, format: str, *_args: Any) -> None: # noqa: A002
# Callback query strings contain an authorization code.
return
@@ -641,9 +642,12 @@ def _token_payload(response: httpx.Response) -> dict[str, Any]:
payload = response.json()
except ValueError as exc:
raise XAIOAuthError("xAI sign-in returned an invalid token response.") from exc
if not isinstance(payload, dict) or not isinstance(payload.get("access_token"), str):
if not isinstance(payload, dict):
raise XAIOAuthError("xAI sign-in returned no access token.")
return payload
token_payload = cast(dict[str, Any], payload)
if not isinstance(token_payload.get("access_token"), str):
raise XAIOAuthError("xAI sign-in returned no access token.")
return token_payload
def _token_from_response(
@@ -680,8 +684,9 @@ def _fetch_account(endpoint: str | None, access_token: str, proxy: str | None) -
return None
if not isinstance(payload, dict):
return None
account_payload = cast(dict[str, Any], payload)
for key in ("email", "preferred_username", "name", "sub"):
value = payload.get(key)
value = account_payload.get(key)
if isinstance(value, str) and value:
return value
return None
@@ -693,8 +698,9 @@ def _oauth_http_error(response: httpx.Response, action: str) -> XAIOAuthError:
with suppress(ValueError):
payload = response.json()
if isinstance(payload, dict):
raw_code = payload.get("error")
raw_description = payload.get("error_description") or payload.get("message")
error_payload = cast(dict[str, Any], payload)
raw_code = error_payload.get("error")
raw_description = error_payload.get("error_description") or error_payload.get("message")
code = raw_code[:80] if isinstance(raw_code, str) else None
description = raw_description[:200] if isinstance(raw_description, str) else None
detail = ": ".join(value for value in (code, description) if value)