mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
f02972c11d
* 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.
389 lines
13 KiB
Python
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",
|
|
}
|
|
)
|