From 4e39b19eef73f915ae98581f325b91a92ce73d90 Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Thu, 15 May 2025 15:20:39 +0200 Subject: [PATCH 01/15] use camel-case RAG in compound class names for consistency --- think/agent.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/think/agent.py b/think/agent.py index 671c4c3..e0d25d2 100644 --- a/think/agent.py +++ b/think/agent.py @@ -88,7 +88,7 @@ def __init__( **kwargs: Any, ): """ - Construct a new Agent instance. + Initialize a new Agent instance. :param llm: The LLM instance to use for generating responses. :param system: System prompt to use for the agent (if provided, overrides the docstring). @@ -233,7 +233,7 @@ async def run(self, query: str | None = None) -> None: break -class RAGMixin: +class RagMixin: """ Agent mixin for integrating RAG (Retrieval-Augmented Generation) sources. @@ -276,7 +276,7 @@ def lookup_func(query): self.add_tool(lookup_func.__name__, lookup_func) -class SimpleRAGAgent(RAGMixin, BaseAgent): +class SimpleRagAgent(RagMixin, BaseAgent): """ Simple RAG agent that uses a single RAG source for document retrieval. @@ -293,7 +293,7 @@ class SimpleRAGAgent(RAGMixin, BaseAgent): def __init__(self, llm: LLM, rag: RAG, **kwargs: Any): """ - Construct a new SimpleRAGAgent instance. + Initialize a new SimpleRAGAgent instance. :param llm: The LLM instance to use for generating responses. :param rag: The RAG instance to use for document retrieval. From e43a958afd060e2c9390af1ff5ade4ed5afcb14a Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Thu, 15 May 2025 15:21:43 +0200 Subject: [PATCH 02/15] add missing docstrings --- think/llm/anthropic.py | 12 ++++++++++++ think/llm/bedrock.py | 14 ++++++++++++++ think/llm/google.py | 14 ++++++++++++++ think/llm/groq.py | 8 ++++++++ think/llm/ollama.py | 13 +++++++++++++ think/llm/openai.py | 14 ++++++++++++++ think/rag/base.py | 20 ++++++++++---------- think/rag/chroma_rag.py | 9 ++++++++- think/rag/eval.py | 10 +++++++--- think/rag/pinecone_rag.py | 10 ++++++++++ think/rag/txtai_rag.py | 8 ++++++++ 11 files changed, 118 insertions(+), 14 deletions(-) diff --git a/think/llm/anthropic.py b/think/llm/anthropic.py index c57c7b2..b415f3b 100644 --- a/think/llm/anthropic.py +++ b/think/llm/anthropic.py @@ -30,6 +30,12 @@ class AnthropicAdapter(BaseAdapter): + """ + Adapter for Anthropic Claude API. + + See `BaseAdapter` for more details. + """ + def get_tool_spec(self, tool: ToolDefinition) -> dict: return { "name": tool.name, @@ -193,6 +199,12 @@ def load_chat(self, messages: list[dict], system: str | None = None) -> Chat: class AnthropicClient(LLM): + """ + LLM client for Anthropic Claude API. + + See `LLM` for more details. + """ + provider = "anthropic" adapter_class = AnthropicAdapter diff --git a/think/llm/bedrock.py b/think/llm/bedrock.py index a3837f8..99a2d7a 100644 --- a/think/llm/bedrock.py +++ b/think/llm/bedrock.py @@ -26,6 +26,14 @@ class BedrockAdapter(BaseAdapter): + """ + Adapter for AWS Bedrock API request/response format. + + See `BaseAdapter` for more details on the adapter interface + and https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/bedrock-runtime/client/converse.html#BedrockRuntime.Client.converse + for the Bedrock API reference. + """ + def get_tool_spec(self, tool: ToolDefinition) -> dict: from copy import deepcopy @@ -196,6 +204,12 @@ def load_chat(self, messages: list[dict], system: str | None = None) -> Chat: class BedrockClient(LLM): + """ + LLM client for AWS Bedrock API. + + See `LLM` for more details. + """ + provider = "bedrock" adapter_class = BedrockAdapter diff --git a/think/llm/google.py b/think/llm/google.py index 5648c26..e6c2168 100644 --- a/think/llm/google.py +++ b/think/llm/google.py @@ -21,6 +21,14 @@ class GoogleAdapter(BaseAdapter): + """ + Adapter for Google Gemini API request/response format. + + See `BaseAdapter` for more details on the adapter interface + and https://ai.google.dev/gemini-api/docs/text-generation#rest + for the Gemini API documentation. + """ + def get_tool_spec(self, tool: ToolDefinition) -> dict: from copy import deepcopy @@ -174,6 +182,12 @@ def parse_message(self, message: dict) -> Message: class GoogleClient(LLM): + """ + LLM client for Google Gemini API. + + See `LLM` for more details. + """ + provider = "google" adapter_class = GoogleAdapter diff --git a/think/llm/groq.py b/think/llm/groq.py index 9f303c9..b2b0995 100644 --- a/think/llm/groq.py +++ b/think/llm/groq.py @@ -28,6 +28,14 @@ class GroqAdapter(BaseAdapter): + """ + Adapter for Groq API request/response format. + + See `BaseAdapter` for more details on the adapter interface + and https://console.groq.com/docs/api-reference#chat-create-request-body + for the Groq API reference. + """ + def get_tool_spec(self, tool: ToolDefinition) -> dict: return { "type": "function", diff --git a/think/llm/ollama.py b/think/llm/ollama.py index e7f9707..8ef33e6 100644 --- a/think/llm/ollama.py +++ b/think/llm/ollama.py @@ -18,6 +18,13 @@ class OllamaAdapter(BaseAdapter): + """ + Adapter for Ollama API request/response format. + + See `BaseAdapter` for more details on the adapter interface + and https://github.com/ollama/ollama/blob/main/docs/api.md for the Ollama API documentation. + """ + def get_tool_spec(self, tool: ToolDefinition) -> dict: return { "type": "function", @@ -141,6 +148,12 @@ def load_chat(self, messages: list[dict]) -> Chat: class OllamaClient(LLM): + """ + LLM client for Ollama API server. + + See `LLM` for more details. + """ + provider = "ollama" adapter_class = OllamaAdapter diff --git a/think/llm/openai.py b/think/llm/openai.py index 770146c..518889d 100644 --- a/think/llm/openai.py +++ b/think/llm/openai.py @@ -30,6 +30,14 @@ class OpenAIAdapter(BaseAdapter): + """ + Adapter for OpenAI API request/response format. + + See `BaseAdapte` for more details on the adapter interface + and https://platform.openai.com/docs/api-reference/chat/create + for the OpenAI API reference. + """ + def get_tool_spec(self, tool: ToolDefinition) -> dict: return { "type": "function", @@ -294,6 +302,12 @@ def load_chat(self, messages: list[dict]) -> Chat: class OpenAIClient(LLM): + """ + LLM client for OpenAI API. + + See `LLM` for more details. + """ + provider = "openai" adapter_class = OpenAIAdapter diff --git a/think/rag/base.py b/think/rag/base.py index cdb7c70..619bfb0 100644 --- a/think/rag/base.py +++ b/think/rag/base.py @@ -1,7 +1,7 @@ import math from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import TypedDict, Any +from typing import Any, TypedDict from think.ai import ask from think.llm.base import LLM @@ -112,10 +112,10 @@ def __init__( **kwargs: Any, ): """ - Initialize the RAG system. + Initialize the RAG instance. - :param llm: The LLM instance to use for query processing and answer generation - :param kwargs: Additional keyword arguments for provider-specific configuration + :param llm: The LLM instance to use for generating answers. + :param kwargs: Additional arguments for the specific RAG implementation. """ self.llm = llm @@ -184,14 +184,14 @@ async def rerank(self, results: list[RagResult]) -> list[RagResult]: async def __call__(self, query: str, limit: int = 10) -> str: """ - Execute the complete RAG pipeline for a query. + Invoke the RAG instance with a user query. - This method orchestrates the full RAG process: query preparation, - document retrieval, result reranking, and answer generation. + This method processes the query, fetches results from the RAG index, + reranks the results, and generates an answer using the LLM. - :param query: The user's query string - :param limit: Maximum number of documents to retrieve (default 10) - :return: Generated answer based on retrieved context + :param query: User input. + :param limit: Maximum number of search results to return (default 10). + :return: Answer to the user query. """ prepared_query = await self.prepare_query(query) results = await self.fetch_results(query, prepared_query, limit) diff --git a/think/rag/chroma_rag.py b/think/rag/chroma_rag.py index 2aa4075..fd19470 100644 --- a/think/rag/chroma_rag.py +++ b/think/rag/chroma_rag.py @@ -19,6 +19,14 @@ def __init__( collection: str, path: Path | str | None = None, ): + """ + Initialize a RAG instance using ChromaDB engine. + + :param llm: The LLM instance to use for generating answers. + :param collection: The name of the ChromaDB collection to use. + :param path: The path to the directory where ChromaDB will store its data. + If not specified, ChromaDB will use an in-memory store. + """ super().__init__(llm) self.collection_name = collection self.path = None if path is None else Path(path) @@ -34,7 +42,6 @@ def __init__( ) async def add_documents(self, documents: list[RagDocument]): - # Extract document data ids = [doc["id"] for doc in documents] texts = [doc["text"] for doc in documents] diff --git a/think/rag/eval.py b/think/rag/eval.py index d60f2ef..5a026cf 100644 --- a/think/rag/eval.py +++ b/think/rag/eval.py @@ -99,10 +99,10 @@ class RagEval: def __init__(self, rag: RAG, llm: LLM): """ - Initialize the RAG evaluation system. + Initialize the RAG evaluation class. - :param rag: The RAG system to evaluate - :param llm: The LLM instance to use for evaluation queries + :param rag: The RAG system to evaluate. + :param llm: The LLM used for evaluation. """ self.rag = rag self.llm = llm @@ -159,6 +159,10 @@ async def split_into_claims( Split the text into individual constituent claims. The text can be reference text (ground truth) or system output (answer). + + :param text: The text to split into claims. + :param is_reference: Whether the text is reference text (ground truth). + :return: A list of claims. """ r = await ask( self.llm, diff --git a/think/rag/pinecone_rag.py b/think/rag/pinecone_rag.py index 2a03ee3..d6579bb 100644 --- a/think/rag/pinecone_rag.py +++ b/think/rag/pinecone_rag.py @@ -25,6 +25,16 @@ def __init__( embedding_model: str = DEFAULT_EMBEDDING_MODEL, embed_batch_size: int = DEFAULT_EMBED_BATCH_SIZE, ): + """ + Initialize a RAG instance using Pinecone as the vector database. + + :param llm: The LLM instance to use for generating answers. + :param index_name: The name of the Pinecone index to use. + :param api_key: Pinecone API key. If not provided, it will be read from the + PINECONE_API_KEY environment variable. + :param embedding_model: The model to use for generating embeddings. + :param embed_batch_size: The batch size for embedding generation. + """ super().__init__(llm) self.index_name = index_name self.embedding_model = embedding_model diff --git a/think/rag/txtai_rag.py b/think/rag/txtai_rag.py index 5c704bd..7a10d59 100644 --- a/think/rag/txtai_rag.py +++ b/think/rag/txtai_rag.py @@ -21,6 +21,14 @@ def __init__( model: str = DEFAULT_EMBEDDINGS_MODEL, path: Path | str | None = None, ): + """ + Initialize a RAG instance using the TxtAI engine. + + :param llm: The LLM instance to use for generating answers. + :param model: The embeddings model to use. Default is `DEFAULT_EMBEDDINGS_MODEL`. + :param path: The path to the directory where the embeddings will be stored. + If not specified, the embeddings will not be saved to disk. + """ super().__init__(llm) self.model = model self.path = None if path is None else Path(path) From f7ea90785092071e2077b94891835fb196bf7f70 Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Thu, 15 May 2025 15:22:30 +0200 Subject: [PATCH 03/15] remove empty unused file from repo --- think/rag/rag.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 think/rag/rag.py diff --git a/think/rag/rag.py b/think/rag/rag.py deleted file mode 100644 index e69de29..0000000 From b04b8ea90b767caafcbf04c9325609cf301acbe2 Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Sun, 23 Nov 2025 21:16:07 +0100 Subject: [PATCH 04/15] add module-level docstrings documenting each module --- think/agent.py | 125 +++++++++++++++++++++++++++++++++ think/ai.py | 65 +++++++++++++++++ think/llm/__init__.py | 157 ++++++++++++++++++++++++++++++++++++++++++ think/llm/base.py | 75 ++++++++++++++++++++ think/llm/chat.py | 118 +++++++++++++++++++++++++++++++ think/llm/tool.py | 116 +++++++++++++++++++++++++++++++ think/parser.py | 119 ++++++++++++++++++++++++++++++++ think/prompt.py | 89 ++++++++++++++++++++++++ think/rag/base.py | 92 +++++++++++++++++++++++++ think/rag/eval.py | 110 +++++++++++++++++++++++++++++ 10 files changed, 1066 insertions(+) diff --git a/think/agent.py b/think/agent.py index e0d25d2..f6f68aa 100644 --- a/think/agent.py +++ b/think/agent.py @@ -1,3 +1,128 @@ +""" +# Building Agents + +The `agent` module provides classes and utilities for building autonomous AI agents +that can interact with users, use tools, access information, and perform tasks over time. + +## Basic Agent + +```python +# example: simple_agent.py +import asyncio +from think import LLM +from think.agent import BaseAgent, tool + +llm = LLM.from_url("openai:///gpt-4o-mini") + +class WeatherAgent(BaseAgent): + '''You are a helpful weather assistant.''' + + @tool + def get_current_temperature(self, city: str) -> float: + '''Get the current temperature for a city.''' + # In a real app, this would call a weather API + temperatures = {"New York": 22.5, "London": 15.0, "Tokyo": 26.8} + return temperatures.get(city, 20.0) # Default temperature if city not found + + @tool + def convert_celsius_to_fahrenheit(self, celsius: float) -> float: + '''Convert Celsius to Fahrenheit.''' + return (celsius * 9/5) + 32 + +async def main(): + agent = WeatherAgent(llm) + await agent.run("What's the temperature in London? Can you also convert it to Fahrenheit?") + +asyncio.run(main()) +``` + +## Agents with RAG + +Agents can be integrated with Retrieval-Augmented Generation (RAG) systems: + +```python +# example: rag_agent.py +import asyncio +from think import LLM +from think.agent import BaseAgent, tool +from think.rag.base import RAG, RagDocument + +llm = LLM.from_url("openai:///gpt-4o-mini") + +class KnowledgeAgent(BaseAgent): + '''You are a helpful assistant with access to a knowledge base.''' + + def __init__(self, llm: LLM): + super().__init__(llm) + # Initialize RAG system + self.rag = RAG.for_provider("txtai")(llm) + + async def setup(self): + '''Initialize the knowledge base.''' + documents = [ + RagDocument(id="doc1", text="The speed of light is approximately 299,792,458 meters per second."), + RagDocument(id="doc2", text="Water boils at 100 degrees Celsius at standard pressure."), + RagDocument(id="doc3", text="The Earth orbits the Sun at an average distance of 149.6 million kilometers.") + ] + await self.rag.add_documents(documents) + + @tool + async def search_knowledge(self, query: str) -> str: + '''Search the knowledge base for information.''' + return await self.rag(query) + +async def main(): + agent = KnowledgeAgent(llm) + await agent.setup() + await agent.run("What is the speed of light? And what is the boiling point of water?") + +asyncio.run(main()) +``` + +## Interactive Agents + +Agents can maintain ongoing conversations with users: + +```python +# example: interactive_agent.py +import asyncio +from datetime import datetime +from think import LLM +from think.agent import BaseAgent, tool + +llm = LLM.from_url("openai:///gpt-4o-mini") + +class ChatbotAgent(BaseAgent): + '''You are a friendly and helpful assistant.''' + + @tool + def get_current_time(self) -> str: + '''Get the current time.''' + return datetime.now().strftime("%H:%M:%S") + + async def interact(self, response: str) -> str: + ''' + Handle interaction with the user. + + This method displays the agent's response and + gets the next input from the user. + ''' + print(f"Agent: {response}") + return input("You: ").strip() + +async def main(): + agent = ChatbotAgent(llm) + # Start with an initial greeting + await agent.run("Hello! How can I help you today?") + +asyncio.run(main()) +``` + +See also: +- [Tool Use](#tool-use) for more about integrating tools +- [RAG (Retrieval-Augmented Generation)](#rag-retrieval-augmented-generation) for more about RAG +""" + from pathlib import Path from typing import Callable, Any, Optional, TypeVar from logging import getLogger diff --git a/think/ai.py b/think/ai.py index caa3ff9..b51aa37 100644 --- a/think/ai.py +++ b/think/ai.py @@ -1,3 +1,68 @@ +""" +# High-level API + +The `ai` module provides high-level functions and classes for interacting with LLMs, +making it easier to perform common tasks without dealing with the lower-level details. + +## Quick API Calls + +The `ask` function provides a simple way to get a response from an LLM: + +```python +# example: ask_quick.py +import asyncio +from think import LLM, ask + +llm = LLM.from_url("openai:///gpt-3.5-turbo") + +async def main(): + response = await ask(llm, "What is the capital of France?") + print(response) + + # With template variables + response = await ask(llm, "Write a haiku about {{ topic }}", topic="autumn leaves") + print(response) + +asyncio.run(main()) +``` + +## Structured Outputs with Pydantic + +The `LLMQuery` class makes it easy to get structured data from LLMs using Pydantic models: + +```python +# example: structured_query.py +import asyncio +from think import LLM, LLMQuery + +llm = LLM.from_url("openai:///gpt-4o-mini") + +class WeatherForecast(LLMQuery): + ''' + Provide a weather forecast for {{ city }} for today. + Include temperature in Celsius and conditions. + ''' + + temperature_celsius: float + conditions: str + humidity_percent: int + wind_speed_kmh: float + +async def main(): + forecast = await WeatherForecast.run(llm, city="London") + print(f"Temperature: {forecast.temperature_celsius}°C") + print(f"Conditions: {forecast.conditions}") + print(f"Humidity: {forecast.humidity_percent}%") + print(f"Wind: {forecast.wind_speed_kmh} km/h") + +asyncio.run(main()) +``` + +See also: +- [Basic LLM Use](#basic-llm-use) for more detailed LLM interaction +- [Structured Outputs and Parsing](#structured-outputs-and-parsing) for advanced parsing options +""" + from json import dumps from pydantic import BaseModel diff --git a/think/llm/__init__.py b/think/llm/__init__.py index e69de29..7f57ebc 100644 --- a/think/llm/__init__.py +++ b/think/llm/__init__.py @@ -0,0 +1,157 @@ +""" +# Supported Providers + +Think supports multiple LLM providers through a consistent interface, making it easy to +switch between different providers or use multiple providers in the same application. + +## Available Providers + +### OpenAI + +```python +# example: openai_provider.py +from think import LLM +import asyncio + +# Using API key from environment variable OPENAI_API_KEY +llm = LLM.from_url("openai:///gpt-4o-mini") + +# With explicit API key +llm = LLM.from_url("openai://sk-your-api-key@/gpt-4o-mini") + +async def main(): + response = await llm("What is artificial intelligence?") + print(response) + +asyncio.run(main()) +``` + +Supported models: gpt-3.5-turbo, gpt-4, gpt-4-turbo, gpt-4o, gpt-4o-mini, and others. + +### Anthropic + +```python +# example: anthropic_provider.py +from think import LLM +import asyncio + +# Using API key from environment variable ANTHROPIC_API_KEY +llm = LLM.from_url("anthropic:///claude-3-haiku-20240307") + +# With explicit API key +llm = LLM.from_url("anthropic://your-api-key@/claude-3-opus-20240229") + +async def main(): + response = await llm("What is artificial intelligence?") + print(response) + +asyncio.run(main()) +``` + +Supported models: claude-3-opus, claude-3-sonnet, claude-3-haiku, and others. + +### Google (Gemini) + +```python +# example: google_provider.py +from think import LLM +import asyncio + +# Using API key from environment variable GOOGLE_API_KEY +llm = LLM.from_url("google:///gemini-1.5-pro") + +async def main(): + response = await llm("What is artificial intelligence?") + print(response) + +asyncio.run(main()) +``` + +Supported models: gemini-1.0-pro, gemini-1.5-pro, gemini-1.5-flash, and others. + +### Amazon Bedrock + +```python +# example: bedrock_provider.py +from think import LLM +import asyncio + +# Using AWS credentials from environment variables +llm = LLM.from_url("bedrock:///anthropic.claude-3-sonnet-20240229-v1:0") + +async def main(): + response = await llm("What is artificial intelligence?") + print(response) + +asyncio.run(main()) +``` + +Amazon Bedrock supports models from multiple providers like Anthropic, AI21, Cohere, etc. + +### Groq + +```python +# example: groq_provider.py +from think import LLM +import asyncio + +# Using API key from environment variable GROQ_API_KEY +llm = LLM.from_url("groq:///llama-3-8b-8192") + +async def main(): + response = await llm("What is artificial intelligence?") + print(response) + +asyncio.run(main()) +``` + +Supported models: llama-3-8b, llama-3-70b, mixtral-8x7b, gemma-7b, and others. + +### Ollama (Local Models) + +```python +# example: ollama_provider.py +from think import LLM +import asyncio + +# Connect to local Ollama server +llm = LLM.from_url("ollama://localhost:11434/llama3") + +async def main(): + response = await llm("What is artificial intelligence?") + print(response) + +asyncio.run(main()) +``` + +Supports any model available in Ollama. + +## Custom Provider Configuration + +For OpenAI-compatible APIs (like LiteLLM, vLLM, etc.): + +```python +# example: custom_provider.py +from think import LLM +import asyncio + +# Custom OpenAI-compatible API server +llm = LLM.from_url("openai://api-key@localhost:8000/v1?model=llama-3-8b") + +async def main(): + response = await llm("What is artificial intelligence?") + print(response) + +asyncio.run(main()) +``` + +See also: +- [Basic LLM Use](#basic-llm-use) for more detailed usage +- [Model URL](#model-url) format documentation +""" + +# Import all providers to make them available +from .base import LLM +from .chat import Chat, Message, Role, ContentPart, ContentType + +__all__ = ["LLM", "Chat", "Message", "Role", "ContentPart", "ContentType"] diff --git a/think/llm/base.py b/think/llm/base.py index 59957ce..ce4b8c3 100644 --- a/think/llm/base.py +++ b/think/llm/base.py @@ -1,3 +1,78 @@ +""" +# Core LLM Functionality + +The `llm.base` module provides the core functionality for interacting with large language models (LLMs). +It defines the `LLM` class, which is the main entry point for sending requests to LLMs and processing +their responses. + +## Basic Usage + +```python +# example: basic_llm.py +from think import LLM + +# Initialize an LLM using a URL-based configuration +llm = LLM.from_url("openai:///gpt-4o-mini") + +# Create a simple chat +from think.llm.chat import Chat +chat = Chat("What is the capital of France?") + +# Get a response +import asyncio +response = asyncio.run(llm(chat)) +print(response) +``` + +## Model URL Format + +Think uses a URL-like format to specify the model to use: + +``` +provider://[api_key@][host[:port]]/model[?query] +``` + +- `provider` is the model provider (openai, anthropic, google, etc.) +- `api-key` is the API key (optional if set via environment) +- `host[:port]` is the server to use (optional, for local LLMs) +- `model` is the name of the model to use + +Examples: +- `openai:///gpt-4o-mini` (API key from OPENAI_API_KEY environment variable) +- `anthropic://sk-my-key@/claude-3-opus-20240229` (explicit API key) +- `openai://localhost:8080/wizard-mega` (custom server over HTTP) + +## Streaming + +For generating responses incrementally: + +```python +# example: streaming.py +import asyncio +from think import LLM +from think.llm.chat import Chat + +llm = LLM.from_url("anthropic:///claude-3-haiku-20240307") + +async def stream_response(): + chat = Chat("Generate a short poem about programming") + async for chunk in llm.stream(chat): + print(chunk, end="", flush=True) + print() + +asyncio.run(stream_response()) +``` + +## Error Handling + +The LLM class throws specific exceptions for different error cases: +- `ConfigError`: Configuration errors (invalid URL, missing API key) +- `BadRequestError`: Invalid requests (e.g., inappropriate content) +- Other standard exceptions like `ConnectionError`, `TimeoutError` + +See [Supported Providers](#supported-providers) for provider-specific information. +""" + from __future__ import annotations from abc import ABC, abstractmethod diff --git a/think/llm/chat.py b/think/llm/chat.py index 60685d8..842a8d4 100644 --- a/think/llm/chat.py +++ b/think/llm/chat.py @@ -1,3 +1,121 @@ +""" +# Chat/Conversation Manipulation + +The `llm.chat` module provides the core functionality for creating and managing chat conversations with LLMs. +It defines the `Chat` class and related components for structuring messages, managing conversation history, +and handling different content types (text, images, documents). + +## Basic Chat Usage + +```python +# example: basic_chat.py +from think import LLM +from think.llm.chat import Chat +import asyncio + +llm = LLM.from_url("openai:///gpt-4o-mini") + +async def simple_chat(): + # Create a chat with a system prompt and user message + chat = Chat("You are a helpful assistant.") + chat.user("What is the capital of France?") + + # Send to LLM and get response + response = await llm(chat) + print(response) + + # Continue the conversation + chat.user("What's the population of that city?") + response = await llm(chat) + print(response) + +asyncio.run(simple_chat()) +``` + +## Role-Based Messages + +Chat supports different message roles: + +```python +# example: chat_roles.py +from think import LLM +from think.llm.chat import Chat +import asyncio + +llm = LLM.from_url("openai:///gpt-4o-mini") + +async def role_based_chat(): + chat = Chat() + chat.system("You are a helpful but sarcastic assistant.") + chat.user("Tell me about the solar system.") + chat.assistant("The solar system? Oh, just a small collection of cosmic bodies " + + "orbiting a giant nuclear furnace we call the Sun. No big deal.") + chat.user("And what about Earth?") + + response = await llm(chat) + print(response) + +asyncio.run(role_based_chat()) +``` + +## Vision Capabilities + +For models that support vision, you can include images in your messages: + +```python +# example: vision_chat.py +from think import LLM +from think.llm.chat import Chat +import asyncio + +llm = LLM.from_url("openai:///gpt-4o-mini") # Use a vision-capable model + +async def analyze_image(): + # Load image data + with open("image.jpg", "rb") as f: + image_data = f.read() + + # Create chat with image + chat = Chat().user("What's in this image?", images=[image_data]) + + response = await llm(chat) + print(response) + +asyncio.run(analyze_image()) +``` + +## Document Handling + +For models supporting documents (like PDFs): + +```python +# example: document_chat.py +from think import LLM +from think.llm.chat import Chat +import asyncio + +llm = LLM.from_url("google:///gemini-1.5-pro") # Use a document-capable model + +async def analyze_document(): + # Load PDF data + with open("document.pdf", "rb") as f: + pdf_data = f.read() + + # Create chat with document + chat = Chat().user("Summarize this document", documents=[pdf_data]) + + response = await llm(chat) + print(response) + +asyncio.run(analyze_document()) +``` + +See also: +- [Basic LLM Use](#basic-llm-use) for more about using Chat with LLMs +- [Vision and Document Handling](#vision-and-document-handling) for advanced usage +- [Tool Use](#tool-use) for using Chat with tools +""" + from __future__ import annotations import binascii diff --git a/think/llm/tool.py b/think/llm/tool.py index 495e5de..70fb565 100644 --- a/think/llm/tool.py +++ b/think/llm/tool.py @@ -1,3 +1,119 @@ +""" +# Tool Integration + +The `llm.tool` module provides functionality for creating and using tools with LLMs. +Tools are functions that LLMs can call to perform actions or retrieve information +during a conversation, enabling more interactive and capable AI assistants. + +## Basic Tool Usage + +```python +# example: basic_tools.py +import asyncio +from think import LLM +from think.llm.chat import Chat + +llm = LLM.from_url("openai:///gpt-4o-mini") + +def get_weather(location: str) -> str: + ''' + Get the current weather for a location. + + :param location: The city name or location to get weather for + :return: Current weather information + ''' + # In a real app, this would call a weather API + return f"It's currently sunny and 22°C in {location}" + +async def travel_assistant(): + chat = Chat("You are a helpful travel assistant.") + chat.user("What's the weather like in Paris?") + + # Pass the tool to the LLM + response = await llm(chat, tools=[get_weather]) + print(response) + +asyncio.run(travel_assistant()) +``` + +## Multiple Tools + +You can provide multiple tools for the LLM to choose from: + +```python +# example: multiple_tools.py +import asyncio +from datetime import datetime +from think import LLM +from think.llm.chat import Chat + +llm = LLM.from_url("openai:///gpt-4o-mini") + +def get_time() -> str: + '''Get the current time.''' + return datetime.now().strftime("%H:%M:%S") + +def calculate_age(birth_year: int) -> int: + ''' + Calculate a person's age. + + :param birth_year: The year of birth + :return: The calculated age + ''' + current_year = datetime.now().year + return current_year - birth_year + +async def assistant_with_tools(): + chat = Chat("You are a helpful assistant.") + chat.user("What time is it now? Also, how old is someone born in 1990?") + + response = await llm(chat, tools=[get_time, calculate_age]) + print(response) + +asyncio.run(assistant_with_tools()) +``` + +## Tool Kits + +For organizing related tools: + +```python +# example: tool_kit.py +import asyncio +from think import LLM +from think.llm.chat import Chat +from think.llm.tool import ToolKit + +llm = LLM.from_url("openai:///gpt-4o-mini") + +# Create a toolkit for math operations +math_tools = ToolKit("math") + +@math_tools.tool +def add(a: float, b: float) -> float: + '''Add two numbers.''' + return a + b + +@math_tools.tool +def multiply(a: float, b: float) -> float: + '''Multiply two numbers.''' + return a * b + +async def math_assistant(): + chat = Chat("You are a math assistant.") + chat.user("What is 25 + 17, and what is 8 * 9?") + + response = await llm(chat, tools=math_tools) + print(response) + +asyncio.run(math_assistant()) +``` + +See also: +- [Agents](#agents) for building more complex tool-using systems +- [Basic LLM Use](#basic-llm-use) for general LLM interaction +""" + from __future__ import annotations import re diff --git a/think/parser.py b/think/parser.py index 2c2a587..e8348ad 100644 --- a/think/parser.py +++ b/think/parser.py @@ -1,3 +1,122 @@ +""" +# Parsing Functionality + +The `parser` module provides tools for parsing structured data from LLM responses, +making it easier to extract and validate information from raw text outputs. + +## JSON Parsing + +The `JSONParser` is useful for extracting and validating JSON from LLM responses: + +```python +# example: json_parser.py +from think import LLM +from think.llm.chat import Chat +from think.parser import JSONParser +import asyncio + +llm = LLM.from_url("openai:///gpt-4o-mini") +parser = JSONParser() + +async def get_structured_data(): + chat = Chat("List the top 3 programming languages as a JSON array") + response = await llm(chat, parser=parser) + print(type(response)) # + for lang in response: + print(lang) + +asyncio.run(get_structured_data()) +``` + +## Code Block Parsing + +For extracting code blocks from LLM responses: + +```python +# example: code_block_parser.py +from think import LLM +from think.llm.chat import Chat +from think.parser import CodeBlockParser +import asyncio + +llm = LLM.from_url("openai:///gpt-4o-mini") +parser = CodeBlockParser() + +async def get_python_code(): + chat = Chat("Write a Python function to calculate the factorial of a number") + code = await llm(chat, parser=parser) + print(code) + # Execute the code to verify it works + exec(code) + print(f"Factorial of 5: {factorial(5)}") # Uses the function from the code + +asyncio.run(get_python_code()) +``` + +## Multiple Code Blocks + +For working with multiple code blocks: + +```python +# example: multi_code_blocks.py +from think import LLM +from think.llm.chat import Chat +from think.parser import MultiCodeBlockParser +import asyncio + +llm = LLM.from_url("openai:///gpt-4o-mini") +parser = MultiCodeBlockParser() + +async def get_multiple_languages(): + chat = Chat("Write a 'Hello World' program in Python, JavaScript, and Go") + code_blocks = await llm(chat, parser=parser) + + for i, block in enumerate(code_blocks): + print(f"Code block {i+1}:") + print(block) + print() + +asyncio.run(get_multiple_languages()) +``` + +## Pydantic Integration + +For validating responses against Pydantic models: + +```python +# example: pydantic_parser.py +from pydantic import BaseModel +from typing import List +from think import LLM +from think.llm.chat import Chat +import asyncio + +class Movie(BaseModel): + title: str + director: str + year: int + rating: float + +llm = LLM.from_url("openai:///gpt-4o-mini") + +async def get_movie_data(): + chat = Chat('''Return information about the movie "The Matrix" in JSON format + with fields: title, director, year, and rating.''') + + response = await llm(chat, parser=Movie) + print(f"Title: {response.title}") + print(f"Director: {response.director}") + print(f"Year: {response.year}") + print(f"Rating: {response.rating}") + +asyncio.run(get_movie_data()) +``` + +See also: +- [Structured Outputs and Parsing](#structured-outputs-and-parsing) for more examples +- [Basic LLM Use](#basic-llm-use) for integrating parsers with LLM calls +""" + import json import re from enum import Enum diff --git a/think/prompt.py b/think/prompt.py index 03d39c7..6a6e69e 100644 --- a/think/prompt.py +++ b/think/prompt.py @@ -1,3 +1,92 @@ +""" +# Prompt Templates + +The `prompt` module provides functionality for creating and managing templates +for prompts to be sent to LLMs. It's built on top of Jinja2 and offers both +string-based and file-based templating. + +## String Templates + +The simplest way to use templates is with `JinjaStringTemplate`: + +```python +# example: string_template.py +from think.prompt import JinjaStringTemplate + +template = JinjaStringTemplate() +prompt = template("Hello, my name is {{ name }} and I'm {{ age }} years old.", + name="Alice", age=30) +print(prompt) # Outputs: Hello, my name is Alice and I'm 30 years old. +``` + +## File Templates + +For more complex prompts, you can use file-based templates: + +```python +# example: file_template.py +from pathlib import Path +from think.prompt import JinjaFileTemplate + +# Create a template file +template_path = Path("my_template.txt") +template_path.write_text("Hello, my name is {{ name }} and I'm {{ age }} years old.") + +# Use the template +template = JinjaFileTemplate(template_path.parent) +prompt = template("my_template.txt", name="Bob", age=25) +print(prompt) # Outputs: Hello, my name is Bob and I'm 25 years old. +``` + +## Multi-line Templates + +When working with multi-line templates, the `strip_block` function helps +preserve the relative indentation while removing the overall indentation: + +```python +# example: multiline_template.py +from think.prompt import JinjaStringTemplate, strip_block + +template = JinjaStringTemplate() +prompt_text = strip_block(''' + System: + You are a helpful assistant. + + User: + {{ question }} +''') + +prompt = template(prompt_text, question="How does photosynthesis work?") +print(prompt) +``` + +## Using with LLMs + +Templates integrate seamlessly with the Think LLM interface: + +```python +# example: template_with_llm.py +import asyncio +from think import LLM, ask +from think.prompt import JinjaStringTemplate + +llm = LLM.from_url("openai:///gpt-3.5-turbo") +template = JinjaStringTemplate() + +async def main(): + prompt = template("Write a {{ length }} poem about {{ topic }}.", + length="short", topic="artificial intelligence") + response = await ask(llm, prompt) + print(response) + +asyncio.run(main()) +``` + +See also: +- [Basic LLM Use](#basic-llm-use) for using templates with LLMs +- [Structured Outputs and Parsing](#structured-outputs-and-parsing) for combining templates with structured outputs +""" + from pathlib import Path from typing import Any, Optional diff --git a/think/rag/base.py b/think/rag/base.py index 619bfb0..0aa75b1 100644 --- a/think/rag/base.py +++ b/think/rag/base.py @@ -1,3 +1,95 @@ +""" +# RAG Base Functionality + +Retrieval-Augmented Generation (RAG) enhances LLM responses by incorporating relevant information +from external sources. The `rag.base` module provides the core abstractions for building +RAG systems with Think. + +## Basic RAG Usage + +```python +# example: basic_rag.py +import asyncio +from think import LLM +from think.rag.base import RAG, RagDocument + +llm = LLM.from_url("openai:///gpt-4o-mini") +rag = RAG.for_provider("txtai")(llm) + +async def index_and_query(): + # Step 1: Add documents to the RAG system + documents = [ + RagDocument(id="doc1", text="Paris is the capital of France and known for the Eiffel Tower."), + RagDocument(id="doc2", text="London is the capital of the United Kingdom."), + RagDocument(id="doc3", text="Rome is the capital of Italy and home to the Colosseum.") + ] + await rag.add_documents(documents) + + # Step 2: Query the RAG system + result = await rag("What are some European capitals and their landmarks?") + print(result) + +asyncio.run(index_and_query()) +``` + +## Available RAG Providers + +Think supports multiple vector database backends: + +- **TxtAI**: Simple in-memory vector database (`"txtai"`) +- **ChromaDB**: Persistent document storage (`"chroma"`) +- **Pinecone**: Scalable cloud vector database (`"pinecone"`) + +## Customizing RAG Behavior + +You can customize the retrieval process by extending the base RAG classes: + +```python +# example: custom_rag.py +import asyncio +from think import LLM +from think.rag.base import RAG, RagDocument +from think.rag.txtai_rag import TxtAIRag + +llm = LLM.from_url("openai:///gpt-4o-mini") + +class CustomRag(TxtAIRag): + '''Custom RAG implementation with specialized prompting.''' + + async def query_prompt(self, query: str, context: str) -> str: + '''Override the default prompt template.''' + return f''' + Based on the following context: + + {context} + + Please answer this question: {query} + + If the context doesn't contain relevant information, please say so. + ''' + +async def custom_rag_demo(): + rag = CustomRag(llm) + + # Add documents + documents = [ + RagDocument(id="doc1", text="Neural networks are a class of machine learning models."), + RagDocument(id="doc2", text="Transformers revolutionized natural language processing."), + ] + await rag.add_documents(documents) + + # Query + result = await rag("How do neural networks work?") + print(result) + +asyncio.run(custom_rag_demo()) +``` + +See also: +- [RAG Evaluation](#rag-retrieval-augmented-generation) for benchmarking RAG systems +- [Tool Use](#tool-use) for integrating RAG with other tools +""" + import math from abc import ABC, abstractmethod from dataclasses import dataclass diff --git a/think/rag/eval.py b/think/rag/eval.py index 5a026cf..4fe9077 100644 --- a/think/rag/eval.py +++ b/think/rag/eval.py @@ -1,3 +1,113 @@ +""" +# RAG Evaluation + +The `rag.eval` module provides tools for evaluating the performance of RAG systems. +It includes metrics for measuring different aspects of RAG quality and functionality. + +## Basic Evaluation + +```python +# example: rag_eval_basic.py +import asyncio +from think import LLM +from think.rag.base import RAG, RagDocument +from think.rag.eval import RagEval + +# Set up the LLM and RAG system +llm = LLM.from_url("openai:///gpt-4o-mini") +rag = RAG.for_provider("txtai")(llm) + +# Set up the evaluator +evaluator = RagEval(llm) + +async def evaluate_rag(): + # Add test documents + documents = [ + RagDocument(id="doc1", text="The Eiffel Tower is 330 meters tall and located in Paris, France."), + RagDocument(id="doc2", text="The Great Wall of China is over 21,000 kilometers long."), + RagDocument(id="doc3", text="The Grand Canyon is 446 km long and up to 29 km wide.") + ] + await rag.add_documents(documents) + + # Generate answer + query = "How tall is the Eiffel Tower?" + answer = await rag(query) + + # Evaluate answer + precision = await evaluator.context_precision(query, rag.last_context, answer) + relevance = await evaluator.answer_relevance(query, answer) + + print(f"Answer: {answer}") + print(f"Context Precision: {precision}") + print(f"Answer Relevance: {relevance}") + +asyncio.run(evaluate_rag()) +``` + +## Available Metrics + +The RagEval class provides several metrics: + +1. **Context Precision**: Measures if retrieved documents are relevant to the query +2. **Context Recall**: Measures if all relevant information is retrieved +3. **Faithfulness**: Evaluates if the answer is supported by the retrieved context +4. **Answer Relevance**: Assesses if the answer addresses the query + +## Comprehensive Evaluation + +```python +# example: rag_eval_comprehensive.py +import asyncio +from think import LLM +from think.rag.base import RAG, RagDocument +from think.rag.eval import RagEval + +llm = LLM.from_url("openai:///gpt-4o-mini") +rag = RAG.for_provider("txtai")(llm) +evaluator = RagEval(llm) + +async def comprehensive_eval(): + # Add documents (assume already done) + + # Define test cases + test_cases = [ + {"query": "What are the dimensions of the Grand Canyon?", "ground_truth": "The Grand Canyon is 446 km long and up to 29 km wide."}, + {"query": "How tall is the Eiffel Tower?", "ground_truth": "The Eiffel Tower is 330 meters tall."} + ] + + results = {} + for tc in test_cases: + query = tc["query"] + ground_truth = tc["ground_truth"] + + # Get RAG answer + answer = await rag(query) + + # Evaluate all metrics + metrics = { + "precision": await evaluator.context_precision(query, rag.last_context, answer), + "recall": await evaluator.context_recall(query, rag.last_context, ground_truth), + "faithfulness": await evaluator.faithfulness(rag.last_context, answer), + "relevance": await evaluator.answer_relevance(query, answer) + } + + results[query] = {"answer": answer, "metrics": metrics} + + # Print results + for query, result in results.items(): + print(f"Query: {query}") + print(f"Answer: {result['answer']}") + print(f"Metrics: {result['metrics']}") + print() + +asyncio.run(comprehensive_eval()) +``` + +See also: +- [RAG Base Functionality](#rag-base-functionality) for RAG implementation details +- [Basic LLM Use](#basic-llm-use) for general LLM interaction +""" + from ..ai import ask from ..llm.base import LLM from .base import RAG From 0b5d2f16aa226815745610da727f62380548e020 Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Sun, 23 Nov 2025 21:18:48 +0100 Subject: [PATCH 05/15] add API docs builder using the documentation from module docstrings --- docs/build_docs.py | 299 ++++++++++++++++++++++++++++++++++++++++++++ docs/docs.md.jinja2 | 52 ++++++++ 2 files changed, 351 insertions(+) create mode 100644 docs/build_docs.py create mode 100644 docs/docs.md.jinja2 diff --git a/docs/build_docs.py b/docs/build_docs.py new file mode 100644 index 0000000..6ccfd20 --- /dev/null +++ b/docs/build_docs.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +""" +Build script for generating Think documentation. + +This script extracts docstrings from Python modules and renders +them into a comprehensive Markdown documentation file using a +Jinja2 template. +""" + +import ast +import re +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import jinja2 + +# Define mappings of topics to files +MODULE_ROOT = Path(__file__).parent.parent / "think" +TOPICS = { + "Basic LLM Use": [ + (MODULE_ROOT / "llm" / "base.py", "Core LLM Functionality"), + (MODULE_ROOT / "ai.py", "High-level API"), + ], + "Supported Providers": [ + (MODULE_ROOT / "llm" / "__init__.py", "Overview"), + (MODULE_ROOT / "llm" / "openai.py", "OpenAI"), + (MODULE_ROOT / "llm" / "anthropic.py", "Anthropic"), + (MODULE_ROOT / "llm" / "google.py", "Google (Gemini)"), + (MODULE_ROOT / "llm" / "bedrock.py", "Amazon (Bedrock)"), + (MODULE_ROOT / "llm" / "groq.py", "Groq"), + (MODULE_ROOT / "llm" / "ollama.py", "Ollama"), + ], + "Chat/Conversation Manipulation": [ + (MODULE_ROOT / "llm" / "chat.py", "Chat Functionality") + ], + "Prompting": [(MODULE_ROOT / "prompt.py", "Prompt Templates")], + "Structured Outputs and Parsing": [ + (MODULE_ROOT / "parser.py", "Parsing Functionality") + ], + "Vision and Document Handling": [ + (MODULE_ROOT / "llm" / "chat.py", "Vision Capabilities"), + ], + "Streaming": [(MODULE_ROOT / "llm" / "base.py", "Streaming Responses")], + "Tool Use": [(MODULE_ROOT / "llm" / "tool.py", "Tool Integration")], + "RAG (Retrieval-Augmented Generation)": [ + (MODULE_ROOT / "rag" / "base.py", "RAG Base Functionality"), + (MODULE_ROOT / "rag" / "chroma_rag.py", "ChromaDB Integration"), + (MODULE_ROOT / "rag" / "pinecone_rag.py", "Pinecone Integration"), + (MODULE_ROOT / "rag" / "txtai_rag.py", "TxtAI Integration"), + (MODULE_ROOT / "rag" / "eval.py", "RAG Evaluation"), + ], + "Agents": [(MODULE_ROOT / "agent.py", "Building Agents")], +} + + +def extract_module_docstring(file_path: Path) -> Optional[str]: + """Extract the module-level docstring from a Python file.""" + try: + with open(file_path, "r", encoding="utf-8") as f: + module_ast = ast.parse(f.read()) + if ( + module_ast.body + and isinstance(module_ast.body[0], ast.Expr) + and isinstance(module_ast.body[0].value, ast.Constant) + ): + return module_ast.body[0].value.value + return None + except Exception as e: + print(f"Error extracting docstring from {file_path}: {e}") + return None + + +def get_function_signature( + func_node: ast.FunctionDef, class_name: Optional[str] = None +) -> str: + """Generate a readable function signature.""" + args = [] + + # Handle arguments + for arg in func_node.args.args: + if arg.annotation: + annotation = ast.unparse(arg.annotation).strip() + args.append(f"{arg.arg}: {annotation}") + else: + args.append(arg.arg) + + # Handle *args + if func_node.args.vararg: + if func_node.args.vararg.annotation: + annotation = ast.unparse(func_node.args.vararg.annotation).strip() + args.append(f"*{func_node.args.vararg.arg}: {annotation}") + else: + args.append(f"*{func_node.args.vararg.arg}") + + # Handle **kwargs + if func_node.args.kwarg: + if func_node.args.kwarg.annotation: + annotation = ast.unparse(func_node.args.kwarg.annotation).strip() + args.append(f"**{func_node.args.kwarg.arg}: {annotation}") + else: + args.append(f"**{func_node.args.kwarg.arg}") + + # Handle return type + returns = "" + if func_node.returns: + returns = f" -> {ast.unparse(func_node.returns).strip()}" + + if class_name: + prefix = f"{class_name}." + else: + prefix = "" + + return f"{prefix}{func_node.name}({', '.join(args)}){returns}" + + +def extract_class_info(node: ast.ClassDef) -> Dict: + """Extract information about a class from its AST node.""" + class_info = { + "name": node.name, + "description": ast.get_docstring(node) or "", + "methods": [], + } + + for item in node.body: + if isinstance(item, ast.FunctionDef) and ( + not item.name.startswith("_") or item.name == "__init__" + ): + method_info = { + "name": item.name, + "signature": get_function_signature(item, node.name), + "description": ast.get_docstring(item) or "", + } + class_info["methods"].append(method_info) + + return class_info + + +def extract_function_info(node: ast.FunctionDef) -> Dict: + """Extract information about a function from its AST node.""" + return { + "name": node.name, + "signature": get_function_signature(node), + "description": ast.get_docstring(node) or "", + } + + +def process_module(file_path: Path, module_name: str = None) -> Dict: + """Process a Python module to extract docstrings and API information.""" + with open(file_path, "r", encoding="utf-8") as f: + module_ast = ast.parse(f.read()) + + if not module_name: + module_name = file_path.stem + + module_info = { + "name": module_name, + "description": extract_module_docstring(file_path) or "", + "classes": [], + "functions": [], + } + + for node in module_ast.body: + # Only include public classes and functions (not starting with _) + if isinstance(node, ast.ClassDef) and not node.name.startswith("_"): + module_info["classes"].append(extract_class_info(node)) + elif isinstance(node, ast.FunctionDef) and not node.name.startswith("_"): + module_info["functions"].append(extract_function_info(node)) + + return module_info + + +def collect_guide_sections(think_dir: Path) -> List[Dict]: + """Collect guide sections from module docstrings.""" + guide_sections = [] + + for title, files in TOPICS.items(): + section_content = [] + + for file_info in files: + file_path, subtopic = file_info + if file_path.exists(): + docstring = extract_module_docstring(file_path) + if docstring: + if ( + len(files) > 1 + ): # If there are multiple files for this topic, add subtopic headers + section_content.append(f"#### {subtopic}\n\n{docstring}") + else: + section_content.append(docstring) + + if section_content: + guide_sections.append( + {"title": title, "content": "\n\n".join(section_content)} + ) + + return guide_sections + + +def collect_api_reference(think_dir: Path) -> List[Dict]: + """Collect API reference from class/method/function docstrings.""" + api_modules = [] + + module_paths = set() + + # Get all Python files in the project + for file_path in think_dir.glob("**/*.py"): + # Skip experimental files and test files + if "hf.py" in str(file_path) or "/tests/" in str(file_path): + continue + module_paths.add(file_path) + + # Process files in sorted order for consistent output + for file_path in sorted(module_paths): + rel_path = file_path.relative_to(think_dir) + parts = list(rel_path.parts) + + # Create module name + if len(parts) > 1: + module_name = f"think.{'.'.join(p.replace('.py', '') for p in parts)}" + else: + module_name = f"think.{parts[0].replace('.py', '')}" + + module_info = process_module(file_path, module_name) + if module_info["classes"] or module_info["functions"]: + api_modules.append(module_info) + + return api_modules + + +def extract_readme_sections(readme_path: Path) -> Tuple[str, str]: + """Extract the introduction and quickstart sections from README.md.""" + readme_text = readme_path.read_text(encoding="utf-8") + + # Extract the introduction (everything before the first ## heading) + intro_match = re.search(r"^# Think\n\n(.*?)(?=\n## )", readme_text, re.DOTALL) + intro = intro_match.group(1).strip() if intro_match else "" + + # Extract the quickstart section + quickstart_match = re.search( + r"## Quickstart\n\n(.*?)(?=\n## )", readme_text, re.DOTALL + ) + quickstart = quickstart_match.group(1).strip() if quickstart_match else "" + + return intro, quickstart + + +def extract_examples_from_readme(readme_path: Path) -> Dict[str, str]: + """Extract code examples from README.md.""" + readme_text = readme_path.read_text(encoding="utf-8") + + # Find all Python code blocks with example comments + example_pattern = r"```python\n# example: ([a-zA-Z0-9_]+\.py)\n(.*?)```" + examples = {} + + for match in re.finditer(example_pattern, readme_text, re.DOTALL): + filename, code = match.groups() + examples[filename] = code.strip() + + return examples + + +def render_template(template_path: Path, output_path: Path, context: Dict) -> None: + """Render the Jinja2 template with the provided context.""" + env = jinja2.Environment( + loader=jinja2.FileSystemLoader(template_path.parent), + autoescape=False, # We want raw markdown output + ) + template = env.get_template(template_path.name) + rendered = template.render(**context) + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(rendered, encoding="utf-8") + + +def main(): + """Build the documentation.""" + # Define paths + repo_dir = Path(__file__).parent.parent + think_dir = repo_dir / "think" + template_path = repo_dir / "docs" / "docs.md.jinja2" + output_path = repo_dir / "docs" / "docs.md" + readme_path = repo_dir / "README.md" + + intro, quickstart = extract_readme_sections(readme_path) + guide_sections = collect_guide_sections(think_dir) + api_modules = collect_api_reference(think_dir) + context = { + "intro": intro, + "quickstart": quickstart, + "guide_sections": guide_sections, + "api_modules": api_modules, + } + render_template(template_path, output_path, context) + + print(f"Documentation built successfully and saved to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/docs/docs.md.jinja2 b/docs/docs.md.jinja2 new file mode 100644 index 0000000..e80da9d --- /dev/null +++ b/docs/docs.md.jinja2 @@ -0,0 +1,52 @@ +# Think Documentation + +{{ intro }} + +## Table of Contents + +- [Quickstart](#quickstart) +{% for section in guide_sections %} +- [{{ section.title }}](#{{ section.title | lower | replace(" ", "-") | replace("/", "") }}) +{% endfor %} +- [API Reference](#api-reference) + +## Quickstart + +{{ quickstart }} + +## Guide + +{% for section in guide_sections %} +### {{ section.title }} + +{{ section.content }} + +{% endfor %} + +## API Reference + +{% for module in api_modules %} +### {{ module.name }} + +{{ module.description }} + +{% for class in module.classes %} +#### {{ class.name }} + +{{ class.description }} + +{% for method in class.methods %} +##### `{{ method.signature }}` + +{{ method.description }} + +{% endfor %} +{% endfor %} + +{% for function in module.functions %} +#### `{{ function.signature }}` + +{{ function.description }} + +{% endfor %} +{% endfor %} \ No newline at end of file From f8cd620b2b785c3c4a7e1ee3e14e32b680722130 Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Sun, 23 Nov 2025 21:25:03 +0100 Subject: [PATCH 06/15] openai: don't pass temp if not given (gpt-5.x models don't support it) --- think/llm/openai.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/think/llm/openai.py b/think/llm/openai.py index 518889d..82cb6f8 100644 --- a/think/llm/openai.py +++ b/think/llm/openai.py @@ -336,7 +336,7 @@ async def _internal_call( response = await self.client.beta.chat.completions.parse( model=self.model, messages=messages, - temperature=temperature, + temperature=NOT_GIVEN if temperature is None else temperature, tools=adapter.spec or NOT_GIVEN, response_format=response_format, max_completion_tokens=max_tokens or NOT_GIVEN, @@ -345,7 +345,7 @@ async def _internal_call( response = await self.client.chat.completions.create( model=self.model, messages=messages, - temperature=temperature, + temperature=NOT_GIVEN if temperature is None else temperature, tools=adapter.spec or NOT_GIVEN, max_completion_tokens=max_tokens or NOT_GIVEN, ) From 49b396930d59db436ee51c0748423f297b4bc575 Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Sun, 23 Nov 2025 21:31:57 +0100 Subject: [PATCH 07/15] use gpt-5-nano instead of gpt-4o-mini as an example/test OpenAI model --- README.md | 16 ++++++++-------- examples/agent.py | 2 +- examples/parsing.py | 4 ++-- examples/rag.py | 2 +- examples/structured.py | 2 +- examples/tools.py | 2 +- examples/vision.py | 2 +- tests/conftest.py | 2 +- think/agent.py | 6 +++--- think/ai.py | 2 +- think/llm/__init__.py | 2 +- think/llm/base.py | 4 ++-- think/llm/chat.py | 6 +++--- think/llm/tool.py | 6 +++--- think/parser.py | 8 ++++---- think/rag/base.py | 4 ++-- think/rag/eval.py | 4 ++-- 17 files changed, 37 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 41f8def..e750c46 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ from asyncio import run from think import LLM, LLMQuery -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") class CityInfo(LLMQuery): @@ -69,7 +69,7 @@ from datetime import date from think import LLM, Chat -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") def current_date() -> str: @@ -97,7 +97,7 @@ from asyncio import run from think import LLM, Chat -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") async def describe_image(path): @@ -140,7 +140,7 @@ from think import LLM, Chat from think.parser import CodeBlockParser from think.prompt import JinjaStringTemplate -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") def parse_python(text): @@ -183,7 +183,7 @@ from asyncio import run from think import LLM from think.rag.base import RAG, RagDocument -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") rag = RAG.for_provider("txtai")(llm) @@ -223,7 +223,7 @@ from datetime import datetime from think import LLM from think.agent import BaseAgent, tool -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") class Chatbot(BaseAgent): @@ -291,8 +291,8 @@ provider://[api_key@][host[:port]]/model[?query] - `model-name` is the name of the model to use Examples: - - `openai:///gpt-3.5-turbo` (API key in environment) - - `openai://sk-my-openai-key@/gpt-3-5-turbo` (explicit API key) + - `openai:///gpt-5-nano` (API key in environment) + - `openai://sk-my-openai-key@/gpt-5-nano` (explicit API key) - `openai://localhost:1234/v1?model=llama-3.2-8b` (custom server over HTTP) - `openai+https://openrouter.ai/api/v1?model=llama-3.2-8b` (custom server, HTTPS) diff --git a/examples/agent.py b/examples/agent.py index bef0243..a4e17f8 100644 --- a/examples/agent.py +++ b/examples/agent.py @@ -5,7 +5,7 @@ from think import LLM from think.agent import BaseAgent, tool -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") class Chatbot(BaseAgent): diff --git a/examples/parsing.py b/examples/parsing.py index 653c79b..1f14cb8 100644 --- a/examples/parsing.py +++ b/examples/parsing.py @@ -1,12 +1,12 @@ # example: parsing.py -from asyncio import run from ast import parse +from asyncio import run from think import LLM, Chat from think.parser import CodeBlockParser from think.prompt import JinjaStringTemplate -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") def parse_python(text): diff --git a/examples/rag.py b/examples/rag.py index 45622fb..378378f 100644 --- a/examples/rag.py +++ b/examples/rag.py @@ -4,7 +4,7 @@ from think import LLM from think.rag.base import RAG, RagDocument -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") rag = RAG.for_provider("txtai")(llm) diff --git a/examples/structured.py b/examples/structured.py index 21c5a82..07fbf2f 100644 --- a/examples/structured.py +++ b/examples/structured.py @@ -3,7 +3,7 @@ from think import LLM, LLMQuery -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") class CityInfo(LLMQuery): diff --git a/examples/tools.py b/examples/tools.py index d0beb3e..32a0695 100644 --- a/examples/tools.py +++ b/examples/tools.py @@ -4,7 +4,7 @@ from think import LLM, Chat -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") def current_date() -> str: diff --git a/examples/vision.py b/examples/vision.py index f273d67..c356bf5 100644 --- a/examples/vision.py +++ b/examples/vision.py @@ -3,7 +3,7 @@ from think import LLM, Chat -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") async def describe_image(path): diff --git a/tests/conftest.py b/tests/conftest.py index 9246d82..c4ded71 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ def model_urls(vision: bool = False) -> list[str]: """ retval = [] if getenv("OPENAI_API_KEY"): - retval.append("openai:///gpt-4o-mini") + retval.append("openai:///gpt-5-nano") if getenv("ANTHROPIC_API_KEY"): retval.append("anthropic:///claude-3-haiku-20240307") if getenv("GEMINI_API_KEY"): diff --git a/think/agent.py b/think/agent.py index f6f68aa..9aab37d 100644 --- a/think/agent.py +++ b/think/agent.py @@ -12,7 +12,7 @@ from think import LLM from think.agent import BaseAgent, tool -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") class WeatherAgent(BaseAgent): '''You are a helpful weather assistant.''' @@ -47,7 +47,7 @@ async def main(): from think.agent import BaseAgent, tool from think.rag.base import RAG, RagDocument -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") class KnowledgeAgent(BaseAgent): '''You are a helpful assistant with access to a knowledge base.''' @@ -90,7 +90,7 @@ async def main(): from think import LLM from think.agent import BaseAgent, tool -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") class ChatbotAgent(BaseAgent): '''You are a friendly and helpful assistant.''' diff --git a/think/ai.py b/think/ai.py index b51aa37..a49195d 100644 --- a/think/ai.py +++ b/think/ai.py @@ -35,7 +35,7 @@ async def main(): import asyncio from think import LLM, LLMQuery -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") class WeatherForecast(LLMQuery): ''' diff --git a/think/llm/__init__.py b/think/llm/__init__.py index 7f57ebc..3e586a7 100644 --- a/think/llm/__init__.py +++ b/think/llm/__init__.py @@ -14,7 +14,7 @@ import asyncio # Using API key from environment variable OPENAI_API_KEY -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") # With explicit API key llm = LLM.from_url("openai://sk-your-api-key@/gpt-4o-mini") diff --git a/think/llm/base.py b/think/llm/base.py index ce4b8c3..5b21377 100644 --- a/think/llm/base.py +++ b/think/llm/base.py @@ -12,7 +12,7 @@ from think import LLM # Initialize an LLM using a URL-based configuration -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") # Create a simple chat from think.llm.chat import Chat @@ -38,7 +38,7 @@ - `model` is the name of the model to use Examples: -- `openai:///gpt-4o-mini` (API key from OPENAI_API_KEY environment variable) +- `openai:///gpt-5-nano` (API key from OPENAI_API_KEY environment variable) - `anthropic://sk-my-key@/claude-3-opus-20240229` (explicit API key) - `openai://localhost:8080/wizard-mega` (custom server over HTTP) diff --git a/think/llm/chat.py b/think/llm/chat.py index 842a8d4..9a510ae 100644 --- a/think/llm/chat.py +++ b/think/llm/chat.py @@ -13,7 +13,7 @@ from think.llm.chat import Chat import asyncio -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") async def simple_chat(): # Create a chat with a system prompt and user message @@ -42,7 +42,7 @@ async def simple_chat(): from think.llm.chat import Chat import asyncio -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") async def role_based_chat(): chat = Chat() @@ -68,7 +68,7 @@ async def role_based_chat(): from think.llm.chat import Chat import asyncio -llm = LLM.from_url("openai:///gpt-4o-mini") # Use a vision-capable model +llm = LLM.from_url("openai:///gpt-5-nano") # Use a vision-capable model async def analyze_image(): # Load image data diff --git a/think/llm/tool.py b/think/llm/tool.py index 70fb565..3a80eac 100644 --- a/think/llm/tool.py +++ b/think/llm/tool.py @@ -13,7 +13,7 @@ from think import LLM from think.llm.chat import Chat -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") def get_weather(location: str) -> str: ''' @@ -47,7 +47,7 @@ async def travel_assistant(): from think import LLM from think.llm.chat import Chat -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") def get_time() -> str: '''Get the current time.''' @@ -84,7 +84,7 @@ async def assistant_with_tools(): from think.llm.chat import Chat from think.llm.tool import ToolKit -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") # Create a toolkit for math operations math_tools = ToolKit("math") diff --git a/think/parser.py b/think/parser.py index e8348ad..0236327 100644 --- a/think/parser.py +++ b/think/parser.py @@ -15,7 +15,7 @@ from think.parser import JSONParser import asyncio -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") parser = JSONParser() async def get_structured_data(): @@ -39,7 +39,7 @@ async def get_structured_data(): from think.parser import CodeBlockParser import asyncio -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") parser = CodeBlockParser() async def get_python_code(): @@ -64,7 +64,7 @@ async def get_python_code(): from think.parser import MultiCodeBlockParser import asyncio -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") parser = MultiCodeBlockParser() async def get_multiple_languages(): @@ -97,7 +97,7 @@ class Movie(BaseModel): year: int rating: float -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") async def get_movie_data(): chat = Chat('''Return information about the movie "The Matrix" in JSON format diff --git a/think/rag/base.py b/think/rag/base.py index 0aa75b1..93a5030 100644 --- a/think/rag/base.py +++ b/think/rag/base.py @@ -13,7 +13,7 @@ from think import LLM from think.rag.base import RAG, RagDocument -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") rag = RAG.for_provider("txtai")(llm) async def index_and_query(): @@ -51,7 +51,7 @@ async def index_and_query(): from think.rag.base import RAG, RagDocument from think.rag.txtai_rag import TxtAIRag -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") class CustomRag(TxtAIRag): '''Custom RAG implementation with specialized prompting.''' diff --git a/think/rag/eval.py b/think/rag/eval.py index 4fe9077..18d4f71 100644 --- a/think/rag/eval.py +++ b/think/rag/eval.py @@ -14,7 +14,7 @@ from think.rag.eval import RagEval # Set up the LLM and RAG system -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") rag = RAG.for_provider("txtai")(llm) # Set up the evaluator @@ -62,7 +62,7 @@ async def evaluate_rag(): from think.rag.base import RAG, RagDocument from think.rag.eval import RagEval -llm = LLM.from_url("openai:///gpt-4o-mini") +llm = LLM.from_url("openai:///gpt-5-nano") rag = RAG.for_provider("txtai")(llm) evaluator = RagEval(llm) From 13d84fff9d3fe01875b1d790a7e121fee65d8279 Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Sun, 23 Nov 2025 21:48:29 +0100 Subject: [PATCH 08/15] update anthropic, gemini and groq models for testing Also, Groq doesn't have a vision-capable model anymore. --- tests/conftest.py | 8 ++++---- think/llm/__init__.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c4ded71..47f4f1e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,11 +11,11 @@ def model_urls(vision: bool = False) -> list[str]: if getenv("OPENAI_API_KEY"): retval.append("openai:///gpt-5-nano") if getenv("ANTHROPIC_API_KEY"): - retval.append("anthropic:///claude-3-haiku-20240307") + retval.append("anthropic:///claude-haiku-4-5") if getenv("GEMINI_API_KEY"): - retval.append("google:///gemini-2.0-flash-lite-preview-02-05") - if getenv("GROQ_API_KEY"): - retval.append("groq:///?model=meta-llama/llama-4-scout-17b-16e-instruct") + retval.append("google:///gemini-2.5-flash-lite") + if getenv("GROQ_API_KEY") and not vision: + retval.append("groq:///?model=openai/gpt-oss-20b") if getenv("OLLAMA_MODEL"): if vision: retval.append(f"ollama:///{getenv('OLLAMA_VISION_MODEL')}") diff --git a/think/llm/__init__.py b/think/llm/__init__.py index 3e586a7..2182502 100644 --- a/think/llm/__init__.py +++ b/think/llm/__init__.py @@ -96,7 +96,7 @@ async def main(): import asyncio # Using API key from environment variable GROQ_API_KEY -llm = LLM.from_url("groq:///llama-3-8b-8192") +llm = LLM.from_url("groq:///?model=openai/gpt-oss-20b") async def main(): response = await llm("What is artificial intelligence?") From 790246751ef4e0c780895e0debde988222d3ac29 Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Sun, 23 Nov 2025 21:49:32 +0100 Subject: [PATCH 09/15] ruff style fixes --- think/agent.py | 11 +++++------ think/llm/__init__.py | 2 +- think/llm/base.py | 4 ++-- think/parser.py | 2 +- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/think/agent.py b/think/agent.py index 9aab37d..39d0dfa 100644 --- a/think/agent.py +++ b/think/agent.py @@ -123,18 +123,17 @@ async def main(): - [RAG (Retrieval-Augmented Generation)](#rag-retrieval-augmented-generation) for more about RAG """ -from pathlib import Path -from typing import Callable, Any, Optional, TypeVar -from logging import getLogger import inspect +from logging import getLogger +from pathlib import Path +from typing import Any, Callable, Optional, TypeVar from think.llm.base import LLM, CustomParserResultT, PydanticResultT from think.llm.chat import Chat -from think.llm.tool import ToolKit, ToolDefinition -from think.prompt import JinjaStringTemplate, JinjaFileTemplate +from think.llm.tool import ToolDefinition, ToolKit +from think.prompt import JinjaFileTemplate, JinjaStringTemplate from think.rag.base import RAG - F = TypeVar("F", bound=Callable[..., Any]) diff --git a/think/llm/__init__.py b/think/llm/__init__.py index 2182502..be9828e 100644 --- a/think/llm/__init__.py +++ b/think/llm/__init__.py @@ -152,6 +152,6 @@ async def main(): # Import all providers to make them available from .base import LLM -from .chat import Chat, Message, Role, ContentPart, ContentType +from .chat import Chat, ContentPart, ContentType, Message, Role __all__ = ["LLM", "Chat", "Message", "Role", "ContentPart", "ContentType"] diff --git a/think/llm/base.py b/think/llm/base.py index 5b21377..ea347ed 100644 --- a/think/llm/base.py +++ b/think/llm/base.py @@ -79,7 +79,7 @@ async def stream_response(): from json import JSONDecodeError from logging import getLogger from time import time -from typing import TYPE_CHECKING, AsyncGenerator, Callable, TypeVar, overload, cast +from typing import TYPE_CHECKING, AsyncGenerator, Callable, TypeVar, cast, overload from urllib.parse import parse_qs, urlparse from pydantic import BaseModel, ValidationError @@ -87,7 +87,7 @@ async def stream_response(): from think.parser import JSONParser from .chat import Chat, ContentPart, ContentType, Message, Role -from .tool import ToolDefinition, ToolKit, ToolCall, ToolResponse +from .tool import ToolCall, ToolDefinition, ToolKit, ToolResponse CustomParserResultT = TypeVar("CustomParserResultT") PydanticResultT = TypeVar("PydanticResultT", bound=BaseModel) diff --git a/think/parser.py b/think/parser.py index 0236327..1c09146 100644 --- a/think/parser.py +++ b/think/parser.py @@ -120,7 +120,7 @@ async def get_movie_data(): import json import re from enum import Enum -from typing import Optional, Union, Type, overload +from typing import Optional, Type, Union, overload from pydantic import BaseModel From 275bbc266f5f91c2e7ed3ff7fe911319ea1d3f67 Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Sun, 23 Nov 2025 22:40:18 +0100 Subject: [PATCH 10/15] use qwen3 as the ollama example/test model (qwen3-vl for vision) --- think/llm/__init__.py | 2 +- think/llm/ollama.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/think/llm/__init__.py b/think/llm/__init__.py index be9828e..6bd4d6b 100644 --- a/think/llm/__init__.py +++ b/think/llm/__init__.py @@ -115,7 +115,7 @@ async def main(): import asyncio # Connect to local Ollama server -llm = LLM.from_url("ollama://localhost:11434/llama3") +llm = LLM.from_url("ollama://localhost:11434/qwen3:8b") async def main(): response = await llm("What is artificial intelligence?") diff --git a/think/llm/ollama.py b/think/llm/ollama.py index 8ef33e6..04b2412 100644 --- a/think/llm/ollama.py +++ b/think/llm/ollama.py @@ -193,6 +193,8 @@ async def _internal_call( raise ConfigError(f"Model not found: {err.error}") from err else: raise BadRequestError(f"Bad request: {err.error}") from err + except ValueError as err: + raise BadRequestError(f"Invalid message structure: {err}") from err except AttributeError as err: raise BadRequestError(f"Bad request: {err}") from err @@ -229,5 +231,7 @@ async def _internal_stream( raise ConfigError(f"Model not found: {err.error}") from err else: raise BadRequestError(f"Bad request: {err.error}") from err + except ValueError as err: + raise BadRequestError(f"Invalid message structure: {err}") from err except AttributeError as err: raise BadRequestError(f"Bad request: {err}") from err From a29167e35a9c290a3bc1535dc34c32bb66007ef4 Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Mon, 24 Nov 2025 12:12:21 +0100 Subject: [PATCH 11/15] fix errors due to renaming of RAG -> Rag in class names --- tests/test_agent.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/test_agent.py b/tests/test_agent.py index f21f674..25c6c8d 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -4,7 +4,7 @@ import pytest from pydantic import BaseModel -from think.agent import BaseAgent, RAGMixin, SimpleRAGAgent, tool +from think.agent import BaseAgent, RagMixin, SimpleRagAgent, tool from think.llm.base import LLM from think.llm.chat import Role from think.llm.tool import ToolKit @@ -377,7 +377,7 @@ async def interact(self, response): mock_llm.assert_called_once() -class TestRAGMixin: +class TestRagMixin: @pytest.fixture def mock_llm(self): return AsyncMock(spec=LLM) @@ -389,7 +389,7 @@ def mock_rag(self): return rag def test_rag_init_single_source(self, mock_llm, mock_rag): - class TestAgent(RAGMixin, BaseAgent): + class TestAgent(RagMixin, BaseAgent): """""" agent = TestAgent(mock_llm) @@ -402,7 +402,7 @@ def test_rag_init_multiple_sources(self, mock_llm): mock_rag1 = AsyncMock() mock_rag2 = AsyncMock() - class TestAgent(RAGMixin, BaseAgent): + class TestAgent(RagMixin, BaseAgent): """""" agent = TestAgent(mock_llm) @@ -413,7 +413,7 @@ class TestAgent(RAGMixin, BaseAgent): assert "lookup_person" in agent.toolkit.tools def test_rag_init_updates_docstring(self, mock_llm, mock_rag): - class TestAgent(RAGMixin, BaseAgent): + class TestAgent(RagMixin, BaseAgent): """Original docstring""" agent = TestAgent(mock_llm) @@ -423,7 +423,7 @@ class TestAgent(RAGMixin, BaseAgent): assert "Original docstring" in agent.__doc__ # type: ignore -class TestSimpleRAGAgent: +class TestSimpleRagAgent: @pytest.fixture def mock_llm(self): return AsyncMock(spec=LLM) @@ -435,31 +435,31 @@ def mock_rag(self): return rag def test_init_with_rag_name(self, mock_llm, mock_rag): - class TestRAGAgent(SimpleRAGAgent): + class TestRagAgent(SimpleRagAgent): """Test RAG agent""" rag_name = "movie" - agent = TestRAGAgent(mock_llm, mock_rag) + agent = TestRagAgent(mock_llm, mock_rag) assert agent.rag_sources == {"movie": mock_rag} assert "lookup_movie" in agent.toolkit.tools def test_init_without_rag_name_raises_error(self, mock_llm, mock_rag): - class TestRAGAgent(SimpleRAGAgent): + class TestRagAgent(SimpleRagAgent): """Test RAG agent""" # No rag_name defined with pytest.raises(ValueError, match="rag_name must be set"): - TestRAGAgent(mock_llm, mock_rag) + TestRagAgent(mock_llm, mock_rag) def test_init_with_empty_rag_name_raises_error(self, mock_llm, mock_rag): - class TestRAGAgent(SimpleRAGAgent): + class TestRagAgent(SimpleRagAgent): """Test RAG agent""" rag_name = "" with pytest.raises(ValueError, match="rag_name must be set"): - TestRAGAgent(mock_llm, mock_rag) + TestRagAgent(mock_llm, mock_rag) class TestAgentIntegration: From 928081c6da6e306c372beabc78aaac41e89e3322 Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Mon, 24 Nov 2025 13:24:37 +0100 Subject: [PATCH 12/15] fix type errors / hints --- think/agent.py | 10 ++++++---- think/llm/base.py | 11 ++++++----- think/llm/chat.py | 12 ++++++------ think/llm/groq.py | 2 +- think/llm/litellm.py | 4 +++- think/llm/ollama.py | 2 +- think/llm/openai.py | 16 +++++++++------- think/llm/tool.py | 2 +- 8 files changed, 33 insertions(+), 26 deletions(-) diff --git a/think/agent.py b/think/agent.py index 39d0dfa..493a933 100644 --- a/think/agent.py +++ b/think/agent.py @@ -235,7 +235,7 @@ def __init__( # Add explicitly listed tools to toolkit if self.tools: for tool in self.tools: - self.add_tool(tool.__name__, tool) + self.add_tool(getattr(tool, "__name__", "tool"), tool) # Prepare the system message if isinstance(system, Path): @@ -243,11 +243,11 @@ def __init__( raise ValueError( f"System prompt file {system} does not exist or is not a file." ) - tpl = JinjaFileTemplate(system.parent) + tpl = JinjaFileTemplate(str(system.parent)) system_msg = tpl(system.name, **kwargs) elif isinstance(system, str): system_msg = system - elif system is None and self.__doc__.strip(): + elif system is None and self.__doc__ and self.__doc__.strip(): tpl = JinjaStringTemplate() system_msg = tpl(self.__doc__, **kwargs) else: @@ -381,6 +381,8 @@ def rag_init(self, rag_sources: dict[str, RAG]): """ self.rag_sources = rag_sources + assert self.__doc__ is not None + for name, rag in rag_sources.items(): def lookup_func(query): @@ -397,7 +399,7 @@ def lookup_func(query): lookup_func.__name__ = "lookup_" + name self.__doc__ += "\n\nWhen asked about {name}, use the provided tool `lookup_{name}` to look it up." - self.add_tool(lookup_func.__name__, lookup_func) + self.add_tool(lookup_func.__name__, lookup_func) # type: ignore[attr-defined] class SimpleRagAgent(RagMixin, BaseAgent): diff --git a/think/llm/base.py b/think/llm/base.py index ea347ed..8995a45 100644 --- a/think/llm/base.py +++ b/think/llm/base.py @@ -107,7 +107,7 @@ class BaseAdapter(ABC): of the API responses into the format expected by the LLM. """ - toolkit: ToolKit + toolkit: ToolKit | None def __init__(self, toolkit: ToolKit | None = None): """ @@ -131,7 +131,7 @@ def get_tool_spec(self, tool: ToolDefinition) -> dict: pass @property - def spec(self) -> dict | None: + def spec(self) -> list[dict] | None: """ Generate the provider-specific tool specification for all the tools passed to the LLM. @@ -479,7 +479,7 @@ async def __call__( message.parsed = JSONParser(spec=parser)(text) else: message.parsed = parser(text) - return message.parsed + return message.parsed # type: ignore[return-value] except (JSONDecodeError, ValidationError, ValueError) as err: log.debug(f"Error parsing response '{text}': {err}") @@ -529,7 +529,7 @@ async def _process_message( self, chat: Chat, message: Message, - toolkit: ToolKit, + toolkit: ToolKit | None, ) -> tuple[str, list[ToolResponse]]: """ Process the assistant response message - internal implementation. @@ -620,12 +620,13 @@ async def stream( text = "" try: + # Type-checker workaround: object async_generator can't be used in 'await' expression async for chunk in self._internal_stream( chat, adapter, temperature, max_tokens, - ): + ): # type:ignore text += chunk yield chunk except ConfigError as err: diff --git a/think/llm/chat.py b/think/llm/chat.py index 9a510ae..349f328 100644 --- a/think/llm/chat.py +++ b/think/llm/chat.py @@ -299,7 +299,7 @@ def is_image_url(self) -> bool: :return: True if the image is an HTTP(S) URL """ - return self.image and self.image.startswith(("http:", "https:")) + return bool(self.image and self.image.startswith(("http:", "https:"))) @property def image_data(self) -> str | None: @@ -310,7 +310,7 @@ def image_data(self) -> str | None: :return: Base64-encoded image data or None """ - return _get_file_b64(self.image) + return _get_file_b64(self.image) if self.image else None @property def image_bytes(self) -> bytes | None: @@ -331,7 +331,7 @@ def image_mime_type(self) -> str | None: :return: MIME type of the image or None """ - return _get_file_mime_type(self.image) + return _get_file_mime_type(self.image) if self.image else None @field_validator("document", mode="before") @classmethod @@ -346,7 +346,7 @@ def is_document_url(self) -> bool: :return: True if the document is an HTTP(S) URL """ - return self.document and self.document.startswith(("http:", "https:")) + return bool(self.document and self.document.startswith(("http:", "https:"))) @property def document_data(self) -> str | None: @@ -357,7 +357,7 @@ def document_data(self) -> str | None: :return: Base64-encoded document data or None """ - return _get_file_b64(self.document) + return _get_file_b64(self.document) if self.document else None @property def document_bytes(self) -> bytes | None: @@ -378,7 +378,7 @@ def document_mime_type(self) -> str | None: :return: MIME type of the document or None """ - return _get_file_mime_type(self.document) + return _get_file_mime_type(self.document) if self.document else None class Message(BaseModel): diff --git a/think/llm/groq.py b/think/llm/groq.py index b2b0995..5431c26 100644 --- a/think/llm/groq.py +++ b/think/llm/groq.py @@ -172,7 +172,7 @@ async def _internal_call( model=self.model, messages=messages, temperature=NOT_GIVEN if temperature is None else temperature, - tools=adapter.spec or NOT_GIVEN, + tools=adapter.spec or NOT_GIVEN, # type: ignore[arg-type] max_tokens=max_tokens, ) except AuthenticationError as err: diff --git a/think/llm/litellm.py b/think/llm/litellm.py index f418a92..945364c 100644 --- a/think/llm/litellm.py +++ b/think/llm/litellm.py @@ -146,7 +146,7 @@ def dump_message(self, message: Message) -> list[dict]: @staticmethod def text_content( - content: str | list[dict[str, str]], + content: str | list[dict[str, str]] | None, ) -> str | None: """Extract text content from OpenAI message content.""" if content is None: @@ -241,6 +241,8 @@ def parse_message(self, message: dict[str, Any]) -> Message: elif role == "system": text = self.text_content(message.get("content")) + if text is None: + raise ValueError(f"Missing content in system message: {message!r}") return Message.system(text) elif role == "user": diff --git a/think/llm/ollama.py b/think/llm/ollama.py index 04b2412..fda50cd 100644 --- a/think/llm/ollama.py +++ b/think/llm/ollama.py @@ -186,7 +186,7 @@ async def _internal_call( num_predict=max_tokens, temperature=temperature, ), - tools=adapter.spec, + tools=adapter.spec, # type: ignore[arg-type] ) except ResponseError as err: if err.status_code == 404: diff --git a/think/llm/openai.py b/think/llm/openai.py index 82cb6f8..bc5495f 100644 --- a/think/llm/openai.py +++ b/think/llm/openai.py @@ -23,7 +23,7 @@ ) from err from .base import LLM, BadRequestError, BaseAdapter, ConfigError, PydanticResultT -from .chat import Chat, ContentPart, ContentType, Message, Role, image_url, document_url +from .chat import Chat, ContentPart, ContentType, Message, Role, document_url, image_url from .tool import ToolCall, ToolDefinition, ToolResponse log = getLogger(__name__) @@ -151,7 +151,7 @@ def dump_message(self, message: Message) -> list[dict]: @staticmethod def text_content( - content: str | list[dict[str, str]], + content: str | list[dict[str, str]] | None, ) -> str | None: if content is None: return None @@ -242,6 +242,8 @@ def parse_message(self, message: dict[str, Any]) -> Message: elif role == "system": text = self.text_content(message.get("content")) + if text is None: + raise ValueError("Missing content in system message: %r", message) return Message.system(text) elif role == "user": @@ -336,16 +338,16 @@ async def _internal_call( response = await self.client.beta.chat.completions.parse( model=self.model, messages=messages, - temperature=NOT_GIVEN if temperature is None else temperature, - tools=adapter.spec or NOT_GIVEN, + temperature=NOT_GIVEN if temperature is None else temperature, # type: ignore[arg-type] + tools=adapter.spec or NOT_GIVEN, # type: ignore[arg-type] response_format=response_format, - max_completion_tokens=max_tokens or NOT_GIVEN, + max_completion_tokens=max_tokens or NOT_GIVEN, # type: ignore[arg-type] ) else: response = await self.client.chat.completions.create( model=self.model, messages=messages, - temperature=NOT_GIVEN if temperature is None else temperature, + temperature=NOT_GIVEN if temperature is None else temperature, # type: ignore tools=adapter.spec or NOT_GIVEN, max_completion_tokens=max_tokens or NOT_GIVEN, ) @@ -378,7 +380,7 @@ async def _internal_stream( ] = await self.client.chat.completions.create( model=self.model, messages=messages, - temperature=temperature, + temperature=NOT_GIVEN if temperature is None else temperature, # type: ignore stream=True, max_completion_tokens=max_tokens or NOT_GIVEN, ) diff --git a/think/llm/tool.py b/think/llm/tool.py index 3a80eac..88b478e 100644 --- a/think/llm/tool.py +++ b/think/llm/tool.py @@ -360,7 +360,7 @@ def add_tool(self, func: Callable, name: str | None = None) -> None: def generate_tool_spec( self, - formatter: Callable[[list[ToolDefinition]], dict], + formatter: Callable[[ToolDefinition], dict], ) -> list[dict]: """Generate tool specifications to pass to the LLM.""" return [formatter(t) for t in self.tools.values()] From 28f0bc1837e97f8e1411b10a5993b5fd94e7162c Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Mon, 24 Nov 2025 13:34:45 +0100 Subject: [PATCH 13/15] update deps --- pyproject.toml | 70 ++++++++++++++++++++++++-------------------------- 1 file changed, 34 insertions(+), 36 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bbe7a8f..9d98633 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ name = "think-llm" version = "0.0.10" description = "Create programs that think, using LLMs." readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ - "pydantic>=2.9.2", + "pydantic>=2.12.4", "jinja2>=3.1.2", "httpx>=0.27.2", "litellm>=1.75.7", @@ -18,24 +18,24 @@ keywords = ["ai", "llm", "rag", "agents"] [project.optional-dependencies] openai = ["openai>=1.53.0"] -anthropic = ["anthropic>=0.37.1"] -gemini = ["google-generativeai>=0.8.3"] -groq = ["groq>=0.12.0"] -ollama = ["ollama>=0.3.3"] -bedrock = ["aioboto3>=13.2.0"] -txtai = ["txtai>=8.1.0"] -chromadb = ["chromadb>=0.6.2"] -pinecone = ["pinecone>=5.4.2"] +anthropic = ["anthropic>=0.74.1"] +gemini = ["google-generativeai>=0.8.5"] +groq = ["groq>=0.36.0"] +ollama = ["ollama>=0.6.1"] +bedrock = ["aioboto3>=15.5.0"] +txtai = ["txtai>=9.2.0"] +chromadb = ["chromadb>=1.3.5"] +pinecone = ["pinecone>=8.0.0"] all = [ "openai>=1.53.0", - "anthropic>=0.37.1", - "google-generativeai>=0.8.3", - "groq>=0.12.0", - "ollama>=0.3.3", - "aioboto3>=13.2.0", - "txtai>=8.1.0", - "chromadb>=0.6.2", - "pinecone>=5.4.2", + "anthropic>=0.74.1", + "google-generativeai>=0.8.5", + "groq>=0.36.0", + "ollama>=0.6.1", + "aioboto3>=15.5.0", + "txtai>=9.2.0", + "chromadb>=1.3.5", + "pinecone>=8.0.0", "pinecone-client>=4.1.2", ] @@ -57,23 +57,21 @@ exclude_lines = ["if TYPE_CHECKING:"] [tool.pyright] typeCheckingMode = "off" -[tool.uv] -dev-dependencies = [ - "pytest-asyncio>=1.1.0", +[dependency-groups] +dev = [ + "aioboto3>=15.5.0", + "anthropic>=0.74.1", + "chromadb>=1.3.5", + "coverage>=7.12.0", + "google-generativeai>=0.8.5", + "groq>=0.36.0", + "ollama>=0.6.1", + "pinecone>=8.0.0", + "pre-commit>=4.5.0", + "pytest>=9.0.1", + "pytest-asyncio>=1.3.0", "pytest-coverage>=0.0", - "pytest>=8.4.1", - "ty>=0.0.1a16", - "ruff>=0.9.6", - "pre-commit>=3.8.0", - "python-dotenv>=1.0.1", - "openai>=1.53.0", - "anthropic>=0.37.1", - "google-generativeai>=0.8.3", - "groq>=0.12.0", - "ollama>=0.3.3", - "txtai>=8.1.0", - "chromadb>=0.6.2", - "pinecone>=5.4.2", - "pinecone-client>=4.1.2", - "aioboto3>=13.2.0", + "ruff>=0.14.6", + "txtai>=9.2.0", + "ty>=0.0.1a27", ] From 65d9f2283e8eb2abc2776045599f43f4a1ea6fb4 Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Mon, 24 Nov 2025 16:25:46 +0100 Subject: [PATCH 14/15] groq: add type checkers to side-step python version inconsistencies The Groq SDK uses NOT_GIVEN (type NotGiven) as a sentinel value, but the API's type annotations expect Omit for optional parameters. The type checker on Python 3.12 was stricter about this mismatch than on Python 3.13. --- .github/workflows/ci.yml | 2 +- think/llm/groq.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6abc6a3..979a869 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.12"] + python-version: ["3.10", "3.12", "3.13", "3.14"] os: [ubuntu-24.04, macos-latest, windows-latest] exclude: - os: windows-latest diff --git a/think/llm/groq.py b/think/llm/groq.py index 5431c26..2a03266 100644 --- a/think/llm/groq.py +++ b/think/llm/groq.py @@ -171,7 +171,7 @@ async def _internal_call( response: ChatCompletion = await self.client.chat.completions.create( model=self.model, messages=messages, - temperature=NOT_GIVEN if temperature is None else temperature, + temperature=NOT_GIVEN if temperature is None else temperature, # type: ignore[arg-type] tools=adapter.spec or NOT_GIVEN, # type: ignore[arg-type] max_tokens=max_tokens, ) @@ -198,10 +198,10 @@ async def _internal_stream( try: stream: AsyncStream[ ChatCompletionChunk - ] = await self.client.chat.completions.create( + ] = await self.client.chat.completions.create( # type: ignore[no-matching-overload] model=self.model, messages=messages, - temperature=NOT_GIVEN if temperature is None else temperature, + temperature=NOT_GIVEN if temperature is None else temperature, # type: ignore[arg-type] stream=True, max_tokens=max_tokens, ) From afdcb61c804415b3b5a58eb1936f0d0637ff4ef9 Mon Sep 17 00:00:00 2001 From: Senko Rasic Date: Mon, 24 Nov 2025 16:34:48 +0100 Subject: [PATCH 15/15] remove py3.14 test due to indirect dep (pypika) not supporting it The pypika package is required by chromadb. They merged the fix but haven't released it yet: https://github.com/kayak/pypika/pull/848 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 979a869..4e08832 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.12", "3.13"] os: [ubuntu-24.04, macos-latest, windows-latest] exclude: - os: windows-latest