XFEstudio/gpt4free
feat: add tool_call emulation for OpenAI API (#3352)
* feat: add tool_call emulation for OpenAI API Avoid forcing PollinationsAI when tools are present, and add an opt-in tool_emulation mode (or G4F_TOOL_EMULATION=1) to emit OpenAI-compatible tool_calls for providers that ignore tools. * fix: avoid duplicate stream kwarg in tool emulation Tool emulation calls the upstream provider with stream=False; remove stream/stream_timeout from forwarded kwargs to prevent conflicts. * fix: prefer non-auth providers when api_key missing When routing via AnyProvider without an api_key, try providers with needs_auth=false first to reduce MissingAuthError for tool-enabled clients like MarksCode. * test: cover tool call emulation Route tool_emulation through ToolSupportProvider (avoid circular imports) and add unittest coverage for multi-tool JSON plans and run_tools integration.
405868c5
代码差异
@@ -16,5 +16,6 @@ from .thinking import *
from .web_search import *
from .models import *
from .mcp import *
from .tool_support_provider import *
unittest.main()
unittest.main()
@@ -0,0 +1,94 @@
import asyncio
import unittest
from g4f.providers.base_provider import AsyncGeneratorProvider
from g4f.providers.response import FinishReason, ToolCalls
from g4f.providers.tool_support import ToolSupportProvider
from g4f.tools.run_tools import async_iter_run_tools
class ToolPlanProviderMock(AsyncGeneratorProvider):
working = True
@staticmethod
async def create_async_generator(model, messages, stream=True, **kwargs):
# Always return a tool call plan.
yield (
'{"tool_calls":['
'{"name":"read","arguments":{"filePath":"README.md"}},'
'{"name":"glob","arguments":{"pattern":"**/*.py"}}'
"]}"
)
yield FinishReason("stop")
TOOLS = [
{
"type": "function",
"function": {
"name": "read",
"description": "Read a file",
"parameters": {
"type": "object",
"properties": {"filePath": {"type": "string"}},
"required": ["filePath"],
},
},
},
{
"type": "function",
"function": {
"name": "glob",
"description": "Glob files",
"parameters": {
"type": "object",
"properties": {"pattern": {"type": "string"}},
"required": ["pattern"],
},
},
},
]
class TestToolSupportProvider(unittest.TestCase):
def test_emits_tool_calls_from_json_plan(self):
async def run():
out = []
async for chunk in ToolSupportProvider.create_async_generator(
model="test-model",
messages=[{"role": "user", "content": "list files"}],
stream=True,
tools=TOOLS,
provider=ToolPlanProviderMock,
):
out.append(chunk)
return out
out = asyncio.run(run())
tool_chunks = [x for x in out if isinstance(x, ToolCalls)]
self.assertEqual(len(tool_chunks), 1)
calls = tool_chunks[0].get_list()
self.assertEqual(len(calls), 2)
self.assertEqual(calls[0]["function"]["name"], "read")
self.assertEqual(calls[1]["function"]["name"], "glob")
def test_run_tools_routes_to_tool_support_provider(self):
async def run():
out = []
async for chunk in async_iter_run_tools(
ToolPlanProviderMock,
model="test-model",
messages=[{"role": "user", "content": "list files"}],
stream=True,
tools=TOOLS,
tool_emulation=True,
):
out.append(chunk)
return out
out = asyncio.run(run())
self.assertTrue(any(isinstance(x, ToolCalls) for x in out))
if __name__ == "__main__":
unittest.main()
@@ -5,6 +5,7 @@ from typing import Union, Optional
from ..typing import Messages
class RequestConfig(BaseModel):
model: str = Field(default="")
provider: Optional[str] = None
@@ -17,21 +18,33 @@ class RequestConfig(BaseModel):
max_tokens: Optional[int] = None
stop: Union[list[str], str, None] = None
api_key: Optional[Union[str, dict[str, str]]] = None
base_url: str = None
base_url: Optional[str] = None
web_search: Optional[bool] = None
proxy: Optional[str] = None
conversation: Optional[dict] = None
timeout: Optional[int] = None
stream_timeout: Optional[int] = None
tool_calls: list = Field(default=[], examples=[[
{
"function": {
"arguments": {"query":"search query", "max_results":5, "max_words": 2500, "backend": "auto", "add_text": True, "timeout": 5},
"name": "search_tool"
},
"type": "function"
}
]])
tool_calls: list = Field(
default=[],
examples=[
[
{
"function": {
"arguments": {
"query": "search query",
"max_results": 5,
"max_words": 2500,
"backend": "auto",
"add_text": True,
"timeout": 5,
},
"name": "search_tool",
},
"type": "function",
}
]
],
)
reasoning_effort: Optional[str] = None
logit_bias: Optional[dict] = None
modalities: Optional[list[str]] = None
@@ -40,21 +53,29 @@ class RequestConfig(BaseModel):
download_media: bool = False
raw: bool = False
extra_body: Optional[dict] = None
# When set (or when env G4F_TOOL_EMULATION=1), the server will attempt to
# emulate OpenAI tool_calls for providers that don't support tools natively.
tool_emulation: Optional[bool] = None
class ChatCompletionsConfig(RequestConfig):
messages: Messages = Field(examples=[[{"role": "system", "content": ""}, {"role": "user", "content": ""}]])
messages: Messages = Field(
examples=[[{"role": "system", "content": ""}, {"role": "user", "content": ""}]]
)
stream: bool = False
image: Optional[str] = None
image_name: Optional[str] = None
images: Optional[list[tuple[str, str]]] = None
tools: list = None
parallel_tool_calls: bool = None
tools: Optional[list] = None
parallel_tool_calls: Optional[bool] = None
tool_choice: Optional[str] = None
conversation_id: Optional[str] = None
class ResponsesConfig(RequestConfig):
input: Union[Messages, str]
class ImageGenerationConfig(BaseModel):
prompt: str
model: Optional[str] = None
@@ -74,21 +95,22 @@ class ImageGenerationConfig(BaseModel):
audio: Optional[dict] = None
download_media: bool = True
@model_validator(mode='before')
@model_validator(mode="before")
def parse_size(cls, values):
if values.get('width') is not None and values.get('height') is not None:
if values.get("width") is not None and values.get("height") is not None:
return values
size = values.get('size')
size = values.get("size")
if size:
try:
width, height = map(int, size.split('x'))
values['width'] = width
values['height'] = height
except (ValueError, AttributeError): pass # If the format is incorrect, we simply ignore it.
width, height = map(int, size.split("x"))
values["width"] = width
values["height"] = height
except (ValueError, AttributeError):
pass # If the format is incorrect, we simply ignore it.
return values
class ProviderResponseModel(BaseModel):
id: str
object: str = "provider"
@@ -96,38 +118,46 @@ class ProviderResponseModel(BaseModel):
url: Optional[str]
label: Optional[str]
class ProviderResponseDetailModel(ProviderResponseModel):
models: list[str]
image_models: list[str]
vision_models: list[str]
params: list[str]
class ModelResponseModel(BaseModel):
id: str
object: str = "model"
created: int
owned_by: Optional[str]
class UploadResponseModel(BaseModel):
bucket_id: str
url: str
class ErrorResponseModel(BaseModel):
error: ErrorResponseMessageModel
model: Optional[str] = None
provider: Optional[str] = None
class ErrorResponseMessageModel(BaseModel):
message: str
class FileResponseModel(BaseModel):
filename: str
class TranscriptionResponseModel(BaseModel):
text: str
model: str
provider: str
class AudioSpeechConfig(BaseModel):
input: str
model: Optional[str] = None
@@ -136,4 +166,4 @@ class AudioSpeechConfig(BaseModel):
instrcutions: str = "Speech this text in a natural way."
response_format: Optional[str] = None
language: Optional[str] = None
download_media: bool = True
download_media: bool = True
@@ -9,24 +9,80 @@ from ..image import is_data_an_audio
from ..providers.retry_provider import RotatedProvider
from ..Provider.needs_auth import OpenaiChat, CopilotAccount
from ..Provider.hf_space import HuggingSpace
from ..Provider import Custom, PollinationsImage, OpenaiAccount, Copilot, Cloudflare, Gemini, Grok, Perplexity, LambdaChat, PollinationsAI, PuterJS
from ..Provider import Microsoft_Phi_4_Multimodal, DeepInfra, LMArena, EdgeTTS, gTTS, MarkItDown, OpenAIFM
from ..Provider import HuggingFace, HuggingFaceMedia, Azure, Qwen, EasyChat, GLM, OpenRouterFree, GeminiPro, Perplexity
from ..Provider import (
Custom,
PollinationsImage,
OpenaiAccount,
Copilot,
Cloudflare,
Gemini,
Grok,
Perplexity,
LambdaChat,
PollinationsAI,
PuterJS,
)
from ..Provider import (
Microsoft_Phi_4_Multimodal,
DeepInfra,
LMArena,
EdgeTTS,
gTTS,
MarkItDown,
OpenAIFM,
)
from ..Provider import (
HuggingFace,
HuggingFaceMedia,
Azure,
Qwen,
EasyChat,
GLM,
OpenRouterFree,
GeminiPro,
Perplexity,
)
from .base_provider import AsyncGeneratorProvider, ProviderModelMixin
from .. import Provider
from .. import models
from .. import debug
from .any_model_map import audio_models, image_models, vision_models, video_models, model_map, models_count, parents, model_aliases
from .any_model_map import (
audio_models,
image_models,
vision_models,
video_models,
model_map,
models_count,
parents,
model_aliases,
)
# Add providers to existing models on map
PROVIDERS_LIST_2 = [
OpenaiChat, Copilot, CopilotAccount, PollinationsAI, Perplexity, Gemini, Grok, Azure, Qwen, EasyChat, GLM, OpenRouterFree
OpenaiChat,
Copilot,
CopilotAccount,
PollinationsAI,
Perplexity,
Gemini,
Grok,
Azure,
Qwen,
EasyChat,
GLM,
OpenRouterFree,
]
# Add all models to the model map
PROVIDERS_LIST_3 = [
LambdaChat, DeepInfra, HuggingFace, HuggingFaceMedia, LMArena,
PuterJS, Cloudflare, HuggingSpace
LambdaChat,
DeepInfra,
HuggingFace,
HuggingFaceMedia,
LMArena,
PuterJS,
Cloudflare,
HuggingSpace,
]
LABELS = {
@@ -54,6 +110,7 @@ LABELS = {
"other": "Other Models",
}
class AnyModelProviderMixin(ProviderModelMixin):
"""Mixin to provide model-related methods for providers."""
@@ -95,9 +152,20 @@ class AnyModelProviderMixin(ProviderModelMixin):
cls.create_model_map()
file = os.path.join(os.path.dirname(__file__), "any_model_map.py")
with open(file, "w", encoding="utf-8") as f:
for key in ["audio_models", "image_models", "vision_models", "video_models", "model_map", "models_count", "parents", "model_aliases"]:
for key in [
"audio_models",
"image_models",
"vision_models",
"video_models",
"model_map",
"models_count",
"parents",
"model_aliases",
]:
value = getattr(cls, key)
f.write(f"{key} = {json.dumps(value, indent=2) if isinstance(value, dict) else repr(value)}\n")
f.write(
f"{key} = {json.dumps(value, indent=2) if isinstance(value, dict) else repr(value)}\n"
)
@classmethod
def create_model_map(cls):
@@ -108,14 +176,21 @@ class AnyModelProviderMixin(ProviderModelMixin):
# Get models from the models registry
cls.model_map = {
"default": {provider.__name__: "" for provider in models.default.best_provider.providers},
"default": {
provider.__name__: ""
for provider in models.default.best_provider.providers
},
}
cls.model_map.update({
name: {
provider.__name__: model.get_long_name() for provider in providers
if provider.working
} for name, (model, providers) in models.__models__.items()
})
cls.model_map.update(
{
name: {
provider.__name__: model.get_long_name()
for provider in providers
if provider.working
}
for name, (model, providers) in models.__models__.items()
}
)
for name, (model, providers) in models.__models__.items():
if isinstance(model, models.ImageModel):
cls.image_models.append(name)
@@ -137,15 +212,17 @@ class AnyModelProviderMixin(ProviderModelMixin):
cls.model_map[cleaned] = {}
cls.model_map[cleaned].update({provider.__name__: model})
except Exception as e:
debug.error(f"Error getting models for provider {provider.__name__}:", e)
debug.error(
f"Error getting models for provider {provider.__name__}:", e
)
continue
# Update special model lists
if hasattr(provider, 'image_models'):
if hasattr(provider, "image_models"):
cls.image_models.extend(provider.image_models)
if hasattr(provider, 'vision_models'):
if hasattr(provider, "vision_models"):
cls.vision_models.extend(provider.vision_models)
if hasattr(provider, 'video_models'):
if hasattr(provider, "video_models"):
cls.video_models.extend(provider.video_models)
for provider in PROVIDERS_LIST_3:
@@ -154,7 +231,9 @@ class AnyModelProviderMixin(ProviderModelMixin):
try:
new_models = provider.get_models()
except Exception as e:
debug.error(f"Error getting models for provider {provider.__name__}:", e)
debug.error(
f"Error getting models for provider {provider.__name__}:", e
)
continue
if provider == HuggingFaceMedia:
new_models = provider.video_models
@@ -171,15 +250,21 @@ class AnyModelProviderMixin(ProviderModelMixin):
cls.model_map[alias].update({provider.__name__: model})
# Update special model lists with both original and cleaned names
if hasattr(provider, 'image_models'):
if hasattr(provider, "image_models"):
cls.image_models.extend(provider.image_models)
cls.image_models.extend([clean_name(model) for model in provider.image_models])
if hasattr(provider, 'vision_models'):
cls.image_models.extend(
[clean_name(model) for model in provider.image_models]
)
if hasattr(provider, "vision_models"):
cls.vision_models.extend(provider.vision_models)
cls.vision_models.extend([clean_name(model) for model in provider.vision_models])
if hasattr(provider, 'video_models'):
cls.vision_models.extend(
[clean_name(model) for model in provider.vision_models]
)
if hasattr(provider, "video_models"):
cls.video_models.extend(provider.video_models)
cls.video_models.extend([clean_name(model) for model in provider.video_models])
cls.video_models.extend(
[clean_name(model) for model in provider.video_models]
)
for provider in Provider.__providers__:
try:
@@ -188,7 +273,12 @@ class AnyModelProviderMixin(ProviderModelMixin):
if model not in cls.model_map:
cls.model_map[model] = {}
cls.model_map[model].update({provider.__name__: model})
elif provider.working and hasattr(provider, "get_models") and provider not in [AnyProvider, Custom, PollinationsImage, OpenaiAccount]:
elif (
provider.working
and hasattr(provider, "get_models")
and provider
not in [AnyProvider, Custom, PollinationsImage, OpenaiAccount]
):
for model in provider.get_models():
clean = clean_name(model)
if clean in cls.model_map:
@@ -201,13 +291,21 @@ class AnyModelProviderMixin(ProviderModelMixin):
if "gemini" in model or "gemma" in model:
cls.model_map[alias].update({provider.__name__: model})
except Exception as e:
debug.error(f"Error getting models for provider {provider.__name__}:", e)
debug.error(
f"Error getting models for provider {provider.__name__}:", e
)
continue
# Process audio providers
for provider in [Microsoft_Phi_4_Multimodal, PollinationsAI]:
if provider.working:
cls.audio_models.extend([model for model in provider.audio_models if model not in cls.audio_models])
cls.audio_models.extend(
[
model
for model in provider.audio_models
if model not in cls.audio_models
]
)
# Update model counts
for model, providers in cls.model_map.items():
@@ -229,7 +327,11 @@ class AnyModelProviderMixin(ProviderModelMixin):
for model, providers in cls.model_map.items():
for provider, alias in providers.items():
if alias != model and isinstance(alias, str) and alias not in cls.model_map:
if (
alias != model
and isinstance(alias, str)
and alias not in cls.model_map
):
cls.model_aliases[alias] = model
@classmethod
@@ -250,10 +352,18 @@ class AnyModelProviderMixin(ProviderModelMixin):
if start in ("PollinationsAI", "openrouter"):
added = True
# Check for Mistral company models specifically
elif model.startswith("mistral") and not any(x in model for x in ["dolphin", "nous", "openhermes"]):
elif model.startswith("mistral") and not any(
x in model for x in ["dolphin", "nous", "openhermes"]
):
groups["mistral"].append(model)
added = True
elif model.startswith(("pixtral-", "ministral-", "codestral", "devstral", "magistral")) or "mistral" in model or "mixtral" in model:
elif (
model.startswith(
("pixtral-", "ministral-", "codestral", "devstral", "magistral")
)
or "mistral" in model
or "mixtral" in model
):
groups["mistral"].append(model)
added = True
# Check for Qwen models
@@ -261,7 +371,9 @@ class AnyModelProviderMixin(ProviderModelMixin):
groups["qwen"].append(model)
added = True
# Check for Microsoft Phi models
elif model.startswith(("phi-", "microsoft/")) or "wizardlm" in model.lower():
elif (
model.startswith(("phi-", "microsoft/")) or "wizardlm" in model.lower()
):
groups["phi"].append(model)
added = True
# Check for Meta LLaMA models
@@ -292,7 +404,9 @@ class AnyModelProviderMixin(ProviderModelMixin):
groups["image"].append(model)
added = True
# Check for OpenAI models
elif model.startswith(("gpt-", "chatgpt-", "o1", "o1", "o3", "o4")) or model in ("auto", "searchgpt"):
elif model.startswith(
("gpt-", "chatgpt-", "o1", "o1", "o3", "o4")
) or model in ("auto", "searchgpt"):
groups["openai"].append(model)
added = True
# Check for video models
@@ -312,6 +426,7 @@ class AnyModelProviderMixin(ProviderModelMixin):
{"group": LABELS[group], "models": names} for group, names in groups.items()
]
class AnyProvider(AsyncGeneratorProvider, AnyModelProviderMixin):
working = True
active_by_default = True
@@ -325,7 +440,7 @@ class AnyProvider(AsyncGeneratorProvider, AnyModelProviderMixin):
media: MediaListType = None,
ignored: list[str] = [],
api_key: Union[str, dict[str, str]] = None,
**kwargs
**kwargs,
) -> AsyncResult:
providers = []
if not model or model == cls.default_model:
@@ -338,9 +453,9 @@ class AnyProvider(AsyncGeneratorProvider, AnyModelProviderMixin):
has_audio = True
break
has_image = True
if kwargs.get("tools", None):
providers = [PollinationsAI]
elif "audio" in kwargs or "audio" in kwargs.get("modalities", []):
# Do not override provider selection just because tools are present.
# Tool calling is an API-level feature; routing should be based on model/media.
if "audio" in kwargs or "audio" in kwargs.get("modalities", []):
if kwargs.get("audio", {}).get("language") is None:
providers = [PollinationsAI, OpenAIFM, Gemini]
else:
@@ -381,37 +496,54 @@ class AnyProvider(AsyncGeneratorProvider, AnyModelProviderMixin):
elif model in provider.model_aliases:
providers.append(provider)
except Exception as e:
debug.error(f"Error checking provider {provider.__name__} for model {model}:", e)
providers = [provider for provider in providers if provider.working and provider.get_parent() not in ignored]
providers = list({provider.__name__: provider for provider in providers}.values())
debug.error(
f"Error checking provider {provider.__name__} for model {model}:",
e,
)
providers = [
provider
for provider in providers
if provider.working and provider.get_parent() not in ignored
]
providers = list(
{provider.__name__: provider for provider in providers}.values()
)
# Free-first routing: if no api_key is provided, prioritize providers that
# don't require auth before trying auth-gated providers.
has_api_key = bool(api_key) or bool(kwargs.get("api_key"))
if not has_api_key:
providers.sort(key=lambda p: bool(getattr(p, "needs_auth", False)))
if len(providers) == 0:
raise ModelNotFoundError(f"AnyProvider: Model {model} not found in any provider.")
raise ModelNotFoundError(
f"AnyProvider: Model {model} not found in any provider."
)
debug.log(f"AnyProvider: Using providers: {[provider.__name__ for provider in providers]} for model '{model}'")
debug.log(
f"AnyProvider: Using providers: {[provider.__name__ for provider in providers]} for model '{model}'"
)
async for chunk in RotatedProvider(providers).create_async_generator(
model,
messages,
stream=stream,
media=media,
api_key=api_key,
**kwargs
model, messages, stream=stream, media=media, api_key=api_key, **kwargs
):
yield chunk
async_create_function = create_async_generator
# Clean model names function
def clean_name(name: str) -> str:
name = name.split("/")[-1].split(":")[0].lower()
# Date patterns
name = re.sub(r'-\d{4}-\d{2}-\d{2}', '', name)
name = re.sub(r"-\d{4}-\d{2}-\d{2}", "", name)
# name = re.sub(r'-\d{3,8}', '', name)
name = re.sub(r'-\d{2}-\d{2}', '', name)
name = re.sub(r'-[0-9a-f]{8}$', '', name)
name = re.sub(r"-\d{2}-\d{2}", "", name)
name = re.sub(r"-[0-9a-f]{8}$", "", name)
# Version patterns
name = re.sub(r'-(instruct|preview|experimental|v\d+|fp8|bf16|hf|free|tput)$', '', name)
name = re.sub(
r"-(instruct|preview|experimental|v\d+|fp8|bf16|hf|free|tput)$", "", name
)
# Other replacements
name = name.replace("_", ".")
name = name.replace("c4ai-", "")
@@ -430,6 +562,7 @@ def clean_name(name: str) -> str:
name = name.replace("claude-sonnet-4.5", "claude-sonnet-4-5")
return name
setattr(Provider, "AnyProvider", AnyProvider)
Provider.__map__["AnyProvider"] = AnyProvider
Provider.__providers__.append(AnyProvider)
@@ -1,46 +1,75 @@
from __future__ import annotations
import json
import re
from typing import Optional, Union
from ..typing import AsyncResult, Messages, MediaListType
from ..client.service import get_model_and_provider
from ..client.helper import filter_json
from ..providers.types import ProviderType
from .base_provider import AsyncGeneratorProvider
from .response import ToolCalls, FinishReason, Usage
class ToolSupportProvider(AsyncGeneratorProvider):
working = True
@classmethod
@staticmethod
async def create_async_generator(
cls,
model: str,
messages: Messages,
stream: bool = True,
media: MediaListType = None,
tools: list[str] = None,
tools: list = None,
tool_choice: Optional[Union[str, dict]] = None,
response_format: dict = None,
**kwargs
provider: Optional[Union[ProviderType, str]] = None,
**kwargs,
) -> AsyncResult:
provider = None
if ":" in model:
if provider is None and ":" in model:
provider, model = model.split(":", 1)
model, provider = get_model_and_provider(
model, provider,
stream, logging=False,
has_images=media is not None
model, provider, stream, logging=False, has_images=media is not None
)
if tools is not None:
if len(tools) > 1:
raise ValueError("Only one tool is supported.")
tool_names: list[str] = []
tool_schemas: dict[str, dict] = {}
if tools:
# Tool emulation: ask for a tool call plan in strict JSON.
if response_format is None:
response_format = {"type": "json"}
tools = tools.pop()
lines = ["Respone in JSON format."]
properties = tools["function"]["parameters"]["properties"]
properties = {key: value["type"] for key, value in properties.items()}
lines.append(f"Response format: {json.dumps(properties, indent=2)}")
messages = [{"role": "user", "content": "\n".join(lines)}] + messages
tool_defs = tools if isinstance(tools, list) else []
for t in tool_defs:
if not isinstance(t, dict) or t.get("type") != "function":
continue
fn = t.get("function")
if not isinstance(fn, dict):
continue
name = fn.get("name")
if not isinstance(name, str) or not name:
continue
tool_names.append(name)
params = fn.get("parameters")
if isinstance(params, dict):
tool_schemas[name] = params
if tool_names:
lines = [
"If you need to use tools, respond with ONLY valid JSON (no markdown).",
"Format:",
'{"tool_calls": [{"name": "TOOL_NAME", "arguments": {}}]}',
"You may include multiple tool calls in the array.",
"If no tool is needed, respond normally with plain text.",
f"Available tools: {', '.join(tool_names)}",
]
if tool_choice is not None:
lines.append(f"Tool choice: {tool_choice}")
if tool_schemas:
lines.append(
f"Tool schemas: {json.dumps(tool_schemas, ensure_ascii=True)}"
)
messages = [{"role": "system", "content": "\n".join(lines)}] + messages
finish = None
chunks = []
@@ -51,7 +80,7 @@ class ToolSupportProvider(AsyncGeneratorProvider):
stream=stream,
media=media,
response_format=response_format,
**kwargs
**kwargs,
):
if isinstance(chunk, str):
chunks.append(chunk)
@@ -68,16 +97,73 @@ class ToolSupportProvider(AsyncGeneratorProvider):
yield Usage(completion_tokens=len(chunks), total_tokens=len(chunks))
chunks = "".join(chunks)
if tools is not None:
yield ToolCalls([{
"id": "",
"type": "function",
"function": {
"name": tools["function"]["name"],
"arguments": filter_json(chunks)
}
}])
yield chunks
if tool_names:
payload = filter_json(chunks)
def parse_json_maybe(s: str):
if not s:
return None
try:
return json.loads(s)
except Exception:
pass
m = None
if "{" in s and "}" in s:
m = re.search(r"\{[\s\S]*\}", s)
if m is None and "[" in s and "]" in s:
m = re.search(r"\[[\s\S]*\]", s)
if not m:
return None
try:
return json.loads(m.group(0))
except Exception:
return None
obj = parse_json_maybe(payload)
calls = None
if isinstance(obj, dict) and isinstance(obj.get("tool_calls"), list):
calls = obj.get("tool_calls")
elif isinstance(obj, dict) and ("name" in obj or "tool" in obj):
calls = [obj]
elif isinstance(obj, list):
calls = obj
openai_calls = []
if isinstance(calls, list):
idx = 0
for c in calls:
if not isinstance(c, dict):
continue
name = c.get("name") or c.get("tool")
if not isinstance(name, str) or not name or name not in tool_names:
continue
args = c.get("arguments")
if isinstance(args, str):
arguments_str = args
else:
try:
arguments_str = json.dumps(
args if isinstance(args, dict) else {},
ensure_ascii=True,
)
except Exception:
arguments_str = "{}"
idx += 1
openai_calls.append(
{
"id": f"call_{idx}",
"type": "function",
"function": {"name": name, "arguments": arguments_str},
}
)
if openai_calls:
yield ToolCalls(openai_calls)
yield FinishReason("tool_calls")
return
if chunks:
yield chunks
if finish is not None:
yield finish
yield finish
@@ -2,6 +2,7 @@ from __future__ import annotations
import re
import json
import os
import math
import asyncio
import time
@@ -11,13 +12,14 @@ from typing import Optional, AsyncIterator, Iterator, Dict, Any, Tuple, List, Un
try:
from aiofile import async_open
has_aiofile = True
except ImportError:
has_aiofile = False
from ..typing import Messages
from ..providers.helper import filter_none
from ..providers.asyncio import to_async_iterator
from ..providers.asyncio import to_async_iterator, to_sync_generator
from ..providers.response import Reasoning, FinishReason, Sources, Usage, ProviderInfo
from ..providers.types import ProviderType
from ..cookies import get_cookies_dir
@@ -34,12 +36,13 @@ Instruction: Make sure to add the sources of cites using [[domain]](Url) notatio
TOOL_NAMES = {
"SEARCH": "search_tool",
"CONTINUE": "continue_tool",
"BUCKET": "bucket_tool"
"BUCKET": "bucket_tool",
}
class ToolHandler:
"""Handles processing of different tool types"""
@staticmethod
def validate_arguments(data: dict) -> dict:
"""Validate and parse tool arguments"""
@@ -47,25 +50,28 @@ class ToolHandler:
if isinstance(data["arguments"], str):
data["arguments"] = json.loads(data["arguments"])
if not isinstance(data["arguments"], dict):
raise ValueError("Tool function arguments must be a dictionary or a json string")
raise ValueError(
"Tool function arguments must be a dictionary or a json string"
)
else:
return filter_none(**data["arguments"])
else:
return {}
@staticmethod
async def process_search_tool(messages: Messages, tool: dict) -> Messages:
"""Process search tool requests"""
messages = messages.copy()
args = ToolHandler.validate_arguments(tool["function"])
messages[-1]["content"], sources = await do_search(
messages[-1]["content"],
**args
messages[-1]["content"], **args
)
return messages, sources
@staticmethod
def process_continue_tool(messages: Messages, tool: dict, provider: Any) -> Tuple[Messages, Dict[str, Any]]:
def process_continue_tool(
messages: Messages, tool: dict, provider: Any
) -> Tuple[Messages, Dict[str, Any]]:
"""Process continue tool requests"""
kwargs = {}
if provider not in ("OpenaiAccount", "HuggingFaceAPI"):
@@ -77,32 +83,36 @@ class ToolHandler:
# Enable provider native continue
kwargs["action"] = "continue"
return messages, kwargs
@staticmethod
def process_bucket_tool(messages: Messages, tool: dict) -> Messages:
"""Process bucket tool requests"""
messages = messages.copy()
def on_bucket(match):
return "".join(read_bucket(get_bucket_dir(match.group(1))))
has_bucket = False
for message in messages:
if "content" in message and isinstance(message["content"], str):
new_message_content = re.sub(r'{"bucket_id":\s*"([^"]*)"}', on_bucket, message["content"])
new_message_content = re.sub(
r'{"bucket_id":\s*"([^"]*)"}', on_bucket, message["content"]
)
if new_message_content != message["content"]:
has_bucket = True
message["content"] = new_message_content
last_message_content = messages[-1]["content"]
last_message_content = messages[-1]["content"]
if has_bucket and isinstance(last_message_content, str):
if "\nSource: " in last_message_content:
messages[-1]["content"] = last_message_content + BUCKET_INSTRUCTIONS
return messages
@staticmethod
async def process_tools(messages: Messages, tool_calls: List[dict], provider: Any) -> Tuple[Messages, Dict[str, Any]]:
async def process_tools(
messages: Messages, tool_calls: List[dict], provider: Any
) -> Tuple[Messages, Dict[str, Any]]:
"""Process all tool calls and return updated messages and kwargs"""
if not tool_calls:
return messages, {}
@@ -119,10 +129,14 @@ class ToolHandler:
debug.log(f"Processing tool call: {function_name}")
if function_name == TOOL_NAMES["SEARCH"]:
messages, sources = await ToolHandler.process_search_tool(messages, tool)
messages, sources = await ToolHandler.process_search_tool(
messages, tool
)
elif function_name == TOOL_NAMES["CONTINUE"]:
messages, kwargs = ToolHandler.process_continue_tool(messages, tool, provider)
messages, kwargs = ToolHandler.process_continue_tool(
messages, tool, provider
)
extra_kwargs.update(kwargs)
elif function_name == TOOL_NAMES["BUCKET"]:
@@ -130,27 +144,30 @@ class ToolHandler:
return messages, sources, extra_kwargs
class ThinkingProcessor:
"""Processes thinking chunks"""
@staticmethod
def process_thinking_chunk(chunk: str, start_time: float = 0) -> Tuple[float, List[Union[str, Reasoning]]]:
def process_thinking_chunk(
chunk: str, start_time: float = 0
) -> Tuple[float, List[Union[str, Reasoning]]]:
"""Process a thinking chunk and return timing and results."""
results = []
# Handle non-thinking chunk
if not start_time and "<think>" not in chunk and "</think>" not in chunk:
return 0, [chunk]
# Handle thinking start
if "<think>" in chunk and "`<think>`" not in chunk:
before_think, *after = chunk.split("<think>", 1)
if before_think:
results.append(before_think)
results.append(Reasoning(status="🤔 Is thinking...", is_thinking="<think>"))
if after:
if "</think>" in after[0]:
after, *after_end = after[0].split("</think>", 1)
@@ -161,45 +178,55 @@ class ThinkingProcessor:
return 0, results
else:
results.append(Reasoning(after[0]))
return time.time(), results
# Handle thinking end
if "</think>" in chunk:
before_end, *after = chunk.split("</think>", 1)
if before_end:
results.append(Reasoning(before_end))
thinking_duration = time.time() - start_time if start_time > 0 else 0
status = f"Thought for {thinking_duration:.2f}s" if thinking_duration > 1 else ""
status = (
f"Thought for {thinking_duration:.2f}s" if thinking_duration > 1 else ""
)
results.append(Reasoning(status=status, is_thinking="</think>"))
# Make sure to handle text after the closing tag
if after and after[0].strip():
results.append(after[0])
return 0, results
# Handle ongoing thinking
if start_time:
return start_time, [Reasoning(chunk)]
return start_time, [chunk]
async def perform_web_search(messages: Messages, web_search_param: Any) -> Tuple[Messages, Optional[Sources]]:
async def perform_web_search(
messages: Messages, web_search_param: Any
) -> Tuple[Messages, Optional[Sources]]:
"""Perform web search and return updated messages and sources"""
messages = messages.copy()
sources = None
if not web_search_param:
return messages, sources
try:
search_query = web_search_param if isinstance(web_search_param, str) and web_search_param != "true" else None
messages[-1]["content"], sources = await do_search(messages[-1]["content"], search_query)
search_query = (
web_search_param
if isinstance(web_search_param, str) and web_search_param != "true"
else None
)
messages[-1]["content"], sources = await do_search(
messages[-1]["content"], search_query
)
except Exception as e:
debug.error(f"Couldn't do web search:", e)
@@ -207,16 +234,49 @@ async def perform_web_search(messages: Messages, web_search_param: Any) -> Tuple
async def async_iter_run_tools(
provider: ProviderType,
model: str,
messages: Messages,
tool_calls: Optional[List[dict]] = None,
**kwargs
provider: ProviderType,
model: str,
messages: Messages,
tool_calls: Optional[List[dict]] = None,
**kwargs,
) -> AsyncIterator:
"""Asynchronously run tools and yield results"""
tool_emulation = kwargs.pop("tool_emulation", None)
if tool_emulation is None:
tool_emulation = os.environ.get("G4F_TOOL_EMULATION", "").strip().lower() in (
"1",
"true",
"yes",
)
stream = bool(kwargs.get("stream"))
tools = kwargs.get("tools")
if tool_emulation and tools and not tool_calls:
from ..providers.tool_support import ToolSupportProvider
emu_kwargs = dict(kwargs)
emu_kwargs.pop("tools", None)
tool_choice = emu_kwargs.pop("tool_choice", None)
emu_kwargs.pop("parallel_tool_calls", None)
emu_kwargs.pop("stream", None)
emu_kwargs.pop("stream_timeout", None)
async for chunk in ToolSupportProvider.create_async_generator(
model=model,
messages=messages,
stream=stream,
media=kwargs.get("media"),
tools=tools,
tool_choice=tool_choice,
provider=provider,
**emu_kwargs,
):
yield chunk
return
# Process web search
sources = None
web_search = kwargs.get('web_search')
web_search = kwargs.get("web_search")
if web_search:
debug.log(f"Performing web search with value: {web_search}")
messages, sources = await perform_web_search(messages, web_search)
@@ -226,15 +286,19 @@ async def async_iter_run_tools(
api_key = AuthManager.load_api_key(provider)
if api_key:
kwargs["api_key"] = api_key
# Process tool calls
if tool_calls:
messages, sources, extra_kwargs = await ToolHandler.process_tools(messages, tool_calls, provider)
messages, sources, extra_kwargs = await ToolHandler.process_tools(
messages, tool_calls, provider
)
kwargs.update(extra_kwargs)
# Generate response
response = to_async_iterator(provider.async_create_function(model=model, messages=messages, **kwargs))
response = to_async_iterator(
provider.async_create_function(model=model, messages=messages, **kwargs)
)
try:
usage_model = model
usage_provider = provider.__name__
@@ -250,7 +314,7 @@ async def async_iter_run_tools(
elif isinstance(chunk, Sources):
sources = None
elif isinstance(chunk, str):
completion_tokens += round(len(chunk.encode("utf-8"))/4)
completion_tokens += round(len(chunk.encode("utf-8")) / 4)
elif isinstance(chunk, ProviderInfo):
usage_model = getattr(chunk, "model", usage_model)
usage_provider = getattr(chunk, "name", usage_provider)
@@ -260,7 +324,12 @@ async def async_iter_run_tools(
if usage is None:
usage = get_usage(messages, completion_tokens)
yield usage
usage = {"user": kwargs.get("user"), "model": usage_model, "provider": usage_provider, **usage.get_dict()}
usage = {
"user": kwargs.get("user"),
"model": usage_model,
"provider": usage_provider,
**usage.get_dict(),
}
usage_dir = Path(get_cookies_dir()) / ".usage"
usage_file = usage_dir / f"{datetime.date.today()}.jsonl"
usage_dir.mkdir(parents=True, exist_ok=True)
@@ -280,28 +349,69 @@ async def async_iter_run_tools(
if sources is not None:
yield sources
def iter_run_tools(
provider: ProviderType,
model: str,
messages: Messages,
tool_calls: Optional[List[dict]] = None,
**kwargs
**kwargs,
) -> Iterator:
"""Run tools synchronously and yield results"""
tool_emulation = kwargs.pop("tool_emulation", None)
if tool_emulation is None:
tool_emulation = os.environ.get("G4F_TOOL_EMULATION", "").strip().lower() in (
"1",
"true",
"yes",
)
stream = bool(kwargs.get("stream"))
tools = kwargs.get("tools")
if tool_emulation and tools and not tool_calls:
from ..providers.tool_support import ToolSupportProvider
emu_kwargs = dict(kwargs)
emu_kwargs.pop("tools", None)
tool_choice = emu_kwargs.pop("tool_choice", None)
emu_kwargs.pop("parallel_tool_calls", None)
emu_kwargs.pop("stream", None)
emu_kwargs.pop("stream_timeout", None)
yield from to_sync_generator(
ToolSupportProvider.create_async_generator(
model=model,
messages=messages,
stream=stream,
media=kwargs.get("media"),
tools=tools,
tool_choice=tool_choice,
provider=provider,
**emu_kwargs,
),
stream=stream,
)
return
# Process web search
web_search = kwargs.get('web_search')
web_search = kwargs.get("web_search")
sources = None
if web_search:
debug.log(f"Performing web search with value: {web_search}")
try:
messages = messages.copy()
search_query = web_search if isinstance(web_search, str) and web_search != "true" else None
search_query = (
web_search
if isinstance(web_search, str) and web_search != "true"
else None
)
# Note: Using asyncio.run inside sync function is not ideal, but maintaining original pattern
messages[-1]["content"], sources = asyncio.run(do_search(messages[-1]["content"], search_query))
messages[-1]["content"], sources = asyncio.run(
do_search(messages[-1]["content"], search_query)
)
except Exception as e:
debug.error(f"Couldn't do web search:", e)
# Get API key if needed
if not kwargs.get("api_key"):
api_key = AuthManager.load_api_key(provider)
@@ -315,11 +425,13 @@ def iter_run_tools(
function_name = tool.get("function", {}).get("name")
debug.log(f"Processing tool call: {function_name}")
if function_name == TOOL_NAMES["SEARCH"]:
tool["function"]["arguments"] = ToolHandler.validate_arguments(tool["function"])
tool["function"]["arguments"] = ToolHandler.validate_arguments(
tool["function"]
)
messages[-1]["content"] = get_search_message(
messages[-1]["content"],
raise_search_exceptions=True,
**tool["function"]["arguments"]
**tool["function"]["arguments"],
)
elif function_name == TOOL_NAMES["CONTINUE"]:
if provider.__name__ not in ("OpenaiAccount", "HuggingFace"):
@@ -330,12 +442,18 @@ def iter_run_tools(
# Enable provider native continue
kwargs["action"] = "continue"
elif function_name == TOOL_NAMES["BUCKET"]:
def on_bucket(match):
return "".join(read_bucket(get_bucket_dir(match.group(1))))
has_bucket = False
for message in messages:
if "content" in message and isinstance(message["content"], str):
new_message_content = re.sub(r'{"bucket_id":"([^"]*)"}', on_bucket, message["content"])
new_message_content = re.sub(
r'{"bucket_id":"([^"]*)"}',
on_bucket,
message["content"],
)
if new_message_content != message["content"]:
has_bucket = True
message["content"] = new_message_content
@@ -343,7 +461,7 @@ def iter_run_tools(
if has_bucket and isinstance(last_message, str):
if "\nSource: " in last_message:
messages[-1]["content"] = last_message + BUCKET_INSTRUCTIONS
# Process response chunks
try:
thinking_start_time = 0
@@ -352,7 +470,9 @@ def iter_run_tools(
usage_provider = provider.__name__
completion_tokens = 0
usage = None
for chunk in provider.create_function(model=model, messages=messages, provider=provider, **kwargs):
for chunk in provider.create_function(
model=model, messages=messages, provider=provider, **kwargs
):
if isinstance(chunk, FinishReason):
if sources is not None:
yield sources
@@ -362,7 +482,7 @@ def iter_run_tools(
elif isinstance(chunk, Sources):
sources = None
elif isinstance(chunk, str):
completion_tokens += round(len(chunk.encode("utf-8"))/4)
completion_tokens += round(len(chunk.encode("utf-8")) / 4)
elif isinstance(chunk, ProviderInfo):
usage_model = getattr(chunk, "model", usage_model)
usage_provider = getattr(chunk, "name", usage_provider)
@@ -371,14 +491,21 @@ def iter_run_tools(
if not isinstance(chunk, str):
yield chunk
continue
thinking_start_time, results = processor.process_thinking_chunk(chunk, thinking_start_time)
thinking_start_time, results = processor.process_thinking_chunk(
chunk, thinking_start_time
)
for result in results:
yield result
if usage is None:
usage = get_usage(messages, completion_tokens)
yield usage
usage = {"user": kwargs.get("user"), "model": usage_model, "provider": usage_provider, **usage.get_dict()}
usage = {
"user": kwargs.get("user"),
"model": usage_model,
"provider": usage_provider,
**usage.get_dict(),
}
usage_dir = Path(get_cookies_dir()) / ".usage"
usage_file = usage_dir / f"{datetime.date.today()}.jsonl"
usage_dir.mkdir(parents=True, exist_ok=True)
@@ -393,26 +520,32 @@ def iter_run_tools(
if sources is not None:
yield sources
def caculate_prompt_tokens(messages: Messages) -> int:
"""Calculate the total number of tokens in messages"""
token_count = 1 # Bos Token
token_count = 1 # Bos Token
for message in messages:
if isinstance(message.get("content"), str):
token_count += math.floor(len(message["content"].encode("utf-8")) / 4)
token_count += 4 # Role and start/end message token
token_count += 4 # Role and start/end message token
elif isinstance(message.get("content"), list):
for item in message["content"]:
if isinstance(item, str):
token_count += math.floor(len(item.encode("utf-8")) / 4)
elif isinstance(item, dict) and "text" in item and isinstance(item["text"], str):
elif (
isinstance(item, dict)
and "text" in item
and isinstance(item["text"], str)
):
token_count += math.floor(len(item["text"].encode("utf-8")) / 4)
token_count += 4 # Role and start/end message token
token_count += 4 # Role and start/end message token
return token_count
def get_usage(messages: Messages, completion_tokens: int) -> Usage:
prompt_tokens = caculate_prompt_tokens(messages)
return Usage(
completion_tokens=completion_tokens,
prompt_tokens=prompt_tokens,
total_tokens=prompt_tokens + completion_tokens
)
total_tokens=prompt_tokens + completion_tokens,
)