Files
turnstone/turnstone/core/providers/_openai.py
T
Patrick Buckley f02972c11d Add provider-native web search with Tavily fallback (#13)
* Add provider-native web search with Tavily fallback

Replace client-side Tavily web search with provider-native implementations:

- Anthropic: inject web_search_20250305 server-side tool, handle
  server_tool_use / web_search_tool_result streaming blocks, emit
  info_delta for search status display
- OpenAI: inject web_search_options for gpt-5-search-api, format
  url_citation annotations as footnote sources
- Local/vLLM: preserve existing Tavily-based web_search tool as fallback

Add supports_web_search to ModelCapabilities and info_delta to StreamChunk.
Remove end-of-life GPT-4o model entries from capability tables.
Update docs, diagrams, and README. 88 provider tests (32 new).

* Fix Copilot PR #13 review: capture streaming url_citation annotations

Accumulate url_citation annotations during OpenAI streaming and emit
formatted citations as a final info_delta chunk after the stream ends.
Previously annotations were only captured in non-streaming mode, so
search model users in the interactive path never saw citation sources.
2026-03-03 17:12:49 -08:00

389 lines
13 KiB
Python

"""OpenAI-compatible provider — wraps current behavior with zero semantic change.
Handles OpenAI, vLLM, llama.cpp, and any server that speaks the
OpenAI Chat Completions API.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
from turnstone.core.providers._protocol import (
CompletionResult,
ModelCapabilities,
StreamChunk,
ToolCallDelta,
UsageInfo,
_lookup_capabilities,
)
# -- model capabilities -------------------------------------------------------
_OPENAI_CAPABILITIES: dict[str, ModelCapabilities] = {
# GPT-5 base — NO temperature support
"gpt-5": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
supports_temperature=False,
reasoning_effort_values=("minimal", "low", "medium", "high"),
default_reasoning_effort="medium",
),
"gpt-5-mini": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
supports_temperature=False,
reasoning_effort_values=("minimal", "low", "medium", "high"),
default_reasoning_effort="medium",
),
"gpt-5-nano": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
supports_temperature=False,
reasoning_effort_values=("minimal", "low", "medium", "high"),
default_reasoning_effort="medium",
),
# GPT-5.1 — temperature OK when reasoning_effort=none (default)
"gpt-5.1": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
reasoning_effort_values=("none", "low", "medium", "high"),
default_reasoning_effort="none",
),
# GPT-5.2 — adds xhigh
"gpt-5.2": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
reasoning_effort_values=("none", "low", "medium", "high", "xhigh"),
default_reasoning_effort="none",
),
# O-series reasoning models
"o1": ModelCapabilities(
context_window=200000,
max_output_tokens=100000,
supports_temperature=False,
supports_streaming=False,
),
"o1-mini": ModelCapabilities(
context_window=128000,
max_output_tokens=65536,
supports_temperature=False,
supports_streaming=False,
),
"o3": ModelCapabilities(
context_window=200000,
max_output_tokens=100000,
supports_temperature=False,
),
"o3-mini": ModelCapabilities(
context_window=200000,
max_output_tokens=100000,
supports_temperature=False,
),
"o3-pro": ModelCapabilities(
context_window=200000,
max_output_tokens=100000,
supports_temperature=False,
supports_streaming=False,
),
"o4-mini": ModelCapabilities(
context_window=200000,
max_output_tokens=100000,
supports_temperature=False,
),
# Search models — always search on every request, no reasoning_effort
"gpt-5-search-api": ModelCapabilities(
context_window=400000,
max_output_tokens=128000,
supports_temperature=False,
supports_web_search=True,
reasoning_effort_values=(),
),
}
# Default for unknown models (local servers: vLLM, llama.cpp, etc.)
_OPENAI_DEFAULT = ModelCapabilities()
class OpenAIProvider:
"""Provider for OpenAI-compatible APIs (OpenAI, vLLM, llama.cpp, etc.)."""
@property
def provider_name(self) -> str:
return "openai"
def get_capabilities(self, model: str) -> ModelCapabilities:
return _lookup_capabilities(model, _OPENAI_CAPABILITIES, _OPENAI_DEFAULT)
# -- shared param logic --------------------------------------------------
def _apply_model_params(
self,
kwargs: dict[str, Any],
caps: ModelCapabilities,
temperature: float,
reasoning_effort: str,
) -> None:
"""Conditionally add temperature and reasoning_effort to *kwargs*.
- Models with ``supports_temperature=False`` (GPT-5 base, O-series)
never receive temperature.
- Models that list ``"none"`` in their effort values (GPT-5.1/5.2)
only receive temperature when reasoning is inactive.
- ``reasoning_effort`` is forwarded as a first-class API parameter
only for models that declare supported effort values.
"""
if caps.supports_temperature:
# GPT-5.1/5.2: temperature only valid when reasoning_effort is "none"
if "none" in caps.reasoning_effort_values and reasoning_effort not in (
"none",
"",
):
pass # Skip temperature when reasoning is active
else:
kwargs["temperature"] = temperature
if caps.reasoning_effort_values and reasoning_effort and reasoning_effort != "none":
kwargs["reasoning_effort"] = reasoning_effort
# -- web search ----------------------------------------------------------
def _apply_web_search(
self,
kwargs: dict[str, Any],
caps: ModelCapabilities,
tools: list[dict[str, Any]] | None,
) -> list[dict[str, Any]] | None:
"""Inject ``web_search_options`` for search models.
For models with ``supports_web_search``, the web search function tool
is removed (the model searches automatically) and ``web_search_options``
is added to the request kwargs.
Returns the (possibly filtered) tools list.
"""
if not caps.supports_web_search:
return tools
# Remove web_search function tool — model has built-in search
if tools:
tools = [t for t in tools if t.get("function", {}).get("name") != "web_search"]
if not tools:
tools = None
kwargs["web_search_options"] = {}
return tools
# -- streaming -----------------------------------------------------------
def create_streaming(
self,
*,
client: Any,
model: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
max_tokens: int = 4096,
temperature: float = 0.5,
reasoning_effort: str = "medium",
extra_params: dict[str, Any] | None = None,
) -> Iterator[StreamChunk]:
caps = self.get_capabilities(model)
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
caps.token_param: max_tokens,
"stream": True,
"stream_options": {"include_usage": True},
}
self._apply_model_params(kwargs, caps, temperature, reasoning_effort)
tools = self._apply_web_search(kwargs, caps, tools)
if tools:
kwargs["tools"] = tools
if extra_params:
kwargs["extra_body"] = extra_params
stream = client.chat.completions.create(**kwargs)
yield from self._iter_stream(stream)
def _iter_stream(self, stream: Any) -> Iterator[StreamChunk]:
"""Convert OpenAI stream chunks to normalized StreamChunks."""
first = True
annotations: list[Any] = []
for chunk in stream:
sc = StreamChunk()
# Finish reason
if chunk.choices and chunk.choices[0].finish_reason:
sc.finish_reason = chunk.choices[0].finish_reason
# Usage from final chunk
if hasattr(chunk, "usage") and chunk.usage is not None:
u = chunk.usage
pt = getattr(u, "prompt_tokens", None)
ct = getattr(u, "completion_tokens", None)
tt = getattr(u, "total_tokens", None)
if pt is not None and ct is not None:
sc.usage = UsageInfo(
prompt_tokens=pt,
completion_tokens=ct,
total_tokens=tt or (pt + ct),
)
if not chunk.choices:
if sc.usage:
yield sc
continue
delta = chunk.choices[0].delta
# Reasoning field (vLLM --reasoning-parser, llama.cpp)
rc = getattr(delta, "reasoning", None) or getattr(delta, "reasoning_content", None)
if rc:
sc.reasoning_delta = rc
# Content
if delta.content:
sc.content_delta = delta.content
# Tool calls
if delta.tool_calls:
for tc_delta in delta.tool_calls:
tcd = ToolCallDelta(index=tc_delta.index)
if tc_delta.id:
tcd.id = tc_delta.id
if tc_delta.function:
if tc_delta.function.name:
tcd.name = tc_delta.function.name
if tc_delta.function.arguments:
tcd.arguments_delta = tc_delta.function.arguments
sc.tool_call_deltas.append(tcd)
# Accumulate url_citation annotations from search models
delta_anns = getattr(delta, "annotations", None)
if delta_anns:
annotations.extend(delta_anns)
has_content = sc.content_delta or sc.reasoning_delta or sc.tool_call_deltas
if has_content and first:
sc.is_first = True
first = False
if has_content or sc.finish_reason or sc.usage:
yield sc
# Emit accumulated citations as a final info chunk
if annotations:
citation_text = self._format_citations("", annotations).strip()
if citation_text:
yield StreamChunk(info_delta=citation_text)
# -- non-streaming -------------------------------------------------------
def create_completion(
self,
*,
client: Any,
model: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
max_tokens: int = 4096,
temperature: float = 0.5,
reasoning_effort: str = "medium",
extra_params: dict[str, Any] | None = None,
) -> CompletionResult:
caps = self.get_capabilities(model)
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
caps.token_param: max_tokens,
"stream": False,
}
self._apply_model_params(kwargs, caps, temperature, reasoning_effort)
tools = self._apply_web_search(kwargs, caps, tools)
if tools:
kwargs["tools"] = tools
if extra_params:
kwargs["extra_body"] = extra_params
response = client.chat.completions.create(**kwargs)
choice = response.choices[0]
msg = choice.message
tool_calls = None
if msg.tool_calls:
tool_calls = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in msg.tool_calls
]
# Extract url_citation annotations from web search models
content = msg.content or ""
annotations = getattr(msg, "annotations", None)
if annotations:
content = self._format_citations(content, annotations)
usage = None
if hasattr(response, "usage") and response.usage:
u = response.usage
usage = UsageInfo(
prompt_tokens=u.prompt_tokens,
completion_tokens=u.completion_tokens,
total_tokens=getattr(u, "total_tokens", None)
or (u.prompt_tokens + u.completion_tokens),
)
return CompletionResult(
content=content,
tool_calls=tool_calls,
finish_reason=choice.finish_reason or "stop",
usage=usage,
)
@staticmethod
def _format_citations(content: str, annotations: list[Any]) -> str:
"""Append url_citation sources as footnotes at the end of the content."""
seen_urls: set[str] = set()
sources: list[str] = []
for ann in annotations:
ann_type = getattr(ann, "type", None)
if ann_type == "url_citation":
citation = getattr(ann, "url_citation", None)
if citation:
title = getattr(citation, "title", "")
url = getattr(citation, "url", "")
if url and url not in seen_urls:
seen_urls.add(url)
sources.append(f"[{title}]({url})" if title else url)
if sources:
content += "\n\nSources:\n" + "\n".join(f"- {s}" for s in sources)
return content
# -- tool conversion -----------------------------------------------------
def convert_tools(
self,
tools: list[dict[str, Any]],
) -> list[dict[str, Any]]:
return tools # Already in OpenAI format
# -- retryable errors ----------------------------------------------------
@property
def retryable_error_names(self) -> frozenset[str]:
return frozenset(
{
"APIError",
"APIConnectionError",
"RateLimitError",
"Timeout",
"APITimeoutError",
}
)