Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/api/clients/open_responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ For models that support extended thinking (like o1/o3), you can configure the re
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `model` | `str` | required | Model identifier (e.g., `"gpt-4o"`, `"o1"`) |
| `max_tokens` | `int` | `64_000` | Maximum output tokens |
| `max_tokens` | `int` | `64_000` | Maximum output tokens per response |
| `context_window` | `int \| None` | `None` | Model context window size for summarization; defaults to `max_tokens` |
| `base_url` | `str \| None` | `None` | Custom API base URL |
| `api_key` | `str \| None` | `None` | API key (falls back to `OPENAI_API_KEY` env var) |
| `reasoning_effort` | `str \| None` | `None` | Reasoning effort for o1/o3 models: `"low"`, `"medium"`, `"high"` |
Expand Down
10 changes: 8 additions & 2 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@ Use `ChatCompletionsClient` for OpenAI or OpenAI-compatible APIs:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `model` | `str` | required | Model identifier (e.g., `"gpt-5"`, `"deepseek-chat"`) |
| `max_tokens` | `int` | `64_000` | Context window size |
| `max_tokens` | `int` | `64_000` | Maximum output tokens per completion request |
| `context_window` | `int \| None` | `None` | Model context window size for summarization; defaults to `max_tokens` |
| `base_url` | `str \| None` | `None` | Custom API URL (for Deepseek, vLLM, etc.) |
| `api_key` | `str \| None` | `None` | API key (defaults to `OPENROUTER_API_KEY` env var) |
| `timeout` | `float \| None` | `None` | Request timeout in seconds |
Expand All @@ -230,7 +231,8 @@ Use `LiteLLMClient` for Anthropic, Google, and other providers via [LiteLLM](htt
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `model_slug` | `str` | required | Provider/model string (e.g., `"anthropic/claude-sonnet-4-5"`) |
| `max_tokens` | `int` | required | Context window size |
| `max_tokens` | `int` | `64_000` | Maximum output tokens per completion request |
| `context_window` | `int \| None` | `None` | Model context window size for summarization; defaults to `max_tokens` |
| `reasoning_effort` | `str \| None` | `None` | For reasoning models (o1/o3) |
| `kwargs` | `dict \| None` | `None` | Additional provider-specific arguments |

Expand All @@ -256,6 +258,10 @@ class MyCustomClient(LLMClient):
@property
def max_tokens(self) -> int:
return 128_000

@property
def context_window(self) -> int:
return 128_000
```

→ See [Custom Clients](extending/clients.md) for full documentation.
Expand Down
20 changes: 18 additions & 2 deletions docs/extending/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ All LLM clients must implement the [`LLMClient`][stirrup.core.models.LLMClient]
|--------|------|-------------|
| `generate()` | `async method` | Generate next message with optional tool calls |
| `model_slug` | `property` | Model identifier string (e.g., `"openai/gpt-4o"`) |
| `max_tokens` | `property` | Maximum context window size |
| `max_tokens` | `property` | Maximum output tokens per completion request |
| `context_window` | `property` | Model context window size, used for summarization threshold calculation (recommended; Agent falls back to `max_tokens` if absent) |

## Basic Implementation

Expand All @@ -40,10 +41,12 @@ class MyCustomClient:
self,
model: str,
max_tokens: int = 64_000,
context_window: int | None = None,
api_key: str | None = None,
):
self._model = model
self._max_tokens = max_tokens
self._context_window = context_window if context_window is not None else max_tokens
self._api_key = api_key

@property
Expand All @@ -54,6 +57,10 @@ class MyCustomClient:
def max_tokens(self) -> int:
return self._max_tokens

@property
def context_window(self) -> int:
return self._context_window

async def generate(
self,
messages: list[ChatMessage],
Expand Down Expand Up @@ -102,9 +109,10 @@ from stirrup import AssistantMessage, ChatMessage, Tool, ToolCall, ToolMessage,
class OpenAIClient:
"""Direct OpenAI API client."""

def __init__(self, model: str = "gpt-4o", max_tokens: int = 128_000):
def __init__(self, model: str = "gpt-4o", max_tokens: int = 128_000, context_window: int | None = None):
self._model = model
self._max_tokens = max_tokens
self._context_window = context_window if context_window is not None else max_tokens
self._client = openai.AsyncOpenAI()

@property
Expand All @@ -115,6 +123,10 @@ class OpenAIClient:
def max_tokens(self) -> int:
return self._max_tokens

@property
def context_window(self) -> int:
return self._context_window

def _convert_message(self, msg: ChatMessage) -> dict:
"""Convert a message to OpenAI format."""
# SystemMessage, UserMessage, ToolMessage have compatible structure
Expand Down Expand Up @@ -170,6 +182,10 @@ class MockClient:
def max_tokens(self) -> int:
return 10_000

@property
def context_window(self) -> int:
return 10_000

async def generate(self, messages, tools) -> AssistantMessage:
response = self._responses[self._call_count]
self._call_count += 1
Expand Down
20 changes: 15 additions & 5 deletions src/stirrup/clients/chat_completions_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def __init__(
self,
model: str,
max_tokens: int = 64_000,
context_window: int | None = None,
*,
base_url: str | None = None,
api_key: str | None = None,
Expand All @@ -77,7 +78,9 @@ def __init__(

Args:
model: Model identifier (e.g., 'gpt-5', 'gpt-4o', 'o1-preview').
max_tokens: Maximum context window size in tokens. Defaults to 64,000.
max_tokens: Maximum output tokens per completion request. Defaults to 64,000.
context_window: Model context window size in tokens, used for summarization
threshold calculation. Defaults to ``max_tokens`` when omitted.
base_url: API base URL. If None, uses OpenAI's standard URL.
Use for OpenAI-compatible providers (e.g., 'http://localhost:8000/v1').
api_key: API key for authentication. If None, reads from OPENROUTER_API_KEY
Expand All @@ -91,6 +94,7 @@ def __init__(
"""
self._model = model
self._max_tokens = max_tokens
self._context_window = context_window if context_window is not None else max_tokens
self._reasoning_effort = reasoning_effort
self._kwargs = kwargs or {}

Expand All @@ -106,9 +110,14 @@ def __init__(

@property
def max_tokens(self) -> int:
"""Maximum context window size in tokens."""
"""Maximum output tokens per completion request."""
return self._max_tokens

@property
def context_window(self) -> int:
"""Model context window size in tokens, used for summarization threshold calculation."""
return self._context_window

@property
def model_slug(self) -> str:
"""Model identifier."""
Expand Down Expand Up @@ -174,9 +183,10 @@ async def generate(
# Check for context overflow
if choice.finish_reason in ("max_tokens", "length"):
raise ContextOverflowError(
f"Maximal context window tokens reached for model {self.model_slug}, "
f"resulting in finish reason: {choice.finish_reason}. "
"Reduce agent.max_tokens and try again."
f"Context or output token limit reached for model {self.model_slug} "
f"(finish reason: {choice.finish_reason}). "
"max_tokens caps per-request output; context_window drives summarization. "
"Shorten the conversation or adjust client settings."
)

msg = choice.message
Expand Down
18 changes: 15 additions & 3 deletions src/stirrup/clients/litellm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def __init__(
self,
model: str | None = None,
max_tokens: int = 64_000,
context_window: int | None = None,
*,
model_slug: str | None = None,
api_key: str | None = None,
Expand All @@ -63,7 +64,9 @@ def __init__(

Args:
model: Model identifier for LiteLLM (e.g., 'anthropic/claude-3-5-sonnet-20241022')
max_tokens: Maximum context window size in tokens
max_tokens: Maximum output tokens per completion request
context_window: Model context window size in tokens, used for summarization
threshold calculation. Defaults to ``max_tokens`` when omitted.
model_slug: Deprecated. Use model instead.
reasoning_effort: Reasoning effort level for extended thinking models (e.g., 'medium', 'high')
kwargs: Additional arguments to pass to LiteLLM completion calls
Expand All @@ -80,15 +83,21 @@ def __init__(
raise ValueError("model is required")
self._model = model
self._max_tokens = max_tokens
self._context_window = context_window if context_window is not None else max_tokens
self._reasoning_effort: ReasoningEffort | None = reasoning_effort
self._api_key = api_key
self._kwargs = kwargs or {}

@property
def max_tokens(self) -> int:
"""Maximum context window size in tokens."""
"""Maximum output tokens per completion request."""
return self._max_tokens

@property
def context_window(self) -> int:
"""Model context window size in tokens, used for summarization threshold calculation."""
return self._context_window

@property
def model_slug(self) -> str:
"""Model identifier used by LiteLLM."""
Expand Down Expand Up @@ -118,7 +127,10 @@ async def generate(self, messages: list[ChatMessage], tools: dict[str, Tool]) ->

if choice.finish_reason in ["max_tokens", "length"]:
raise ContextOverflowError(
f"Maximal context window tokens reached for model {self.model_slug}, resulting in finish reason: {choice.finish_reason}. Reduce agent.max_tokens and try again."
f"Context or output token limit reached for model {self.model_slug} "
f"(finish reason: {choice.finish_reason}). "
"max_tokens caps per-request output; context_window drives summarization. "
"Shorten the conversation or adjust client settings."
)

msg = choice["message"]
Expand Down
16 changes: 13 additions & 3 deletions src/stirrup/clients/open_responses_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ def __init__(
self,
model: str,
max_tokens: int = 64_000,
context_window: int | None = None,
*,
base_url: str | None = None,
api_key: str | None = None,
Expand All @@ -294,7 +295,9 @@ def __init__(

Args:
model: Model identifier (e.g., 'gpt-4o', 'o1-preview').
max_tokens: Maximum output tokens. Defaults to 64,000.
max_tokens: Maximum output tokens per response. Defaults to 64,000.
context_window: Model context window size in tokens, used for summarization
threshold calculation. Defaults to ``max_tokens`` when omitted.
base_url: API base URL. If None, uses OpenAI's standard URL.
Use for OpenAI-compatible providers.
api_key: API key for authentication. If None, reads from OPENROUTER_API_KEY
Expand All @@ -309,6 +312,7 @@ def __init__(
"""
self._model = model
self._max_tokens = max_tokens
self._context_window = context_window if context_window is not None else max_tokens
self._reasoning_effort = reasoning_effort
self._default_instructions = instructions
self._kwargs = kwargs or {}
Expand All @@ -330,9 +334,14 @@ def __init__(

@property
def max_tokens(self) -> int:
"""Maximum output tokens."""
"""Maximum output tokens per response."""
return self._max_tokens

@property
def context_window(self) -> int:
"""Model context window size in tokens, used for summarization threshold calculation."""
return self._context_window

@property
def model_slug(self) -> str:
"""Model identifier."""
Expand Down Expand Up @@ -408,7 +417,8 @@ async def generate(
stop_reason = getattr(response, "incomplete_details", None)
raise ContextOverflowError(
f"Response incomplete for model {self.model_slug}: {stop_reason}. "
"Reduce max_tokens or message length and try again."
"max_tokens caps per-request output; context_window drives summarization. "
"Shorten the conversation or adjust client settings."
)

# Parse response output
Expand Down
3 changes: 2 additions & 1 deletion src/stirrup/core/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
ToolUseCountMetadata,
TurnWarningMessage,
UserMessage,
llm_client_context_window,
)
from stirrup.prompts import MESSAGE_SUMMARIZER, MESSAGE_SUMMARIZER_BRIDGE_TEMPLATE
from stirrup.skills import SkillMetadata, format_skills_section, load_skills_metadata
Expand Down Expand Up @@ -1465,7 +1466,7 @@ async def run(
if finish_params:
break

pct_context_used = assistant_message.token_usage.total / self._client.max_tokens
pct_context_used = assistant_message.token_usage.total / llm_client_context_window(self._client)
if pct_context_used >= self._context_summarization_cutoff and accepted_turn != self._max_turns:
self._logger.context_summarization_start(pct_context_used, self._context_summarization_cutoff)
messages_to_summarize, msgs = await self.summarize_messages(msgs, run_metadata_by_turn)
Expand Down
15 changes: 15 additions & 0 deletions src/stirrup/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,10 @@ class LLMClient(Protocol):

Any LLM client must implement this protocol to work with the Agent class.
Provides text generation with tool support and model capability inspection.

``context_window`` is recommended for accurate summarization thresholds.
Legacy clients that only expose ``max_tokens`` remain supported: the Agent
falls back to ``max_tokens`` when ``context_window`` is absent.
"""

@abstractmethod
Expand All @@ -596,6 +600,17 @@ def model_slug(self) -> str: ...
@property
def max_tokens(self) -> int: ...

@property
def context_window(self) -> int: ...


def llm_client_context_window(client: LLMClient) -> int:
"""Return the context window size used for summarization threshold calculation.

Falls back to ``max_tokens`` for legacy clients that predate ``context_window``.
"""
return getattr(client, "context_window", client.max_tokens)


class ToolCall(BaseModel):
"""Represents a tool invocation request from the LLM.
Expand Down
Loading