refactor: enforce BasedPyright strict type checking (#5158)
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user