mirror of
https://github.com/usestrix/strix.git
synced 2026-08-24 20:02:39 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8ad8e3572 | ||
|
|
788a5393db | ||
|
|
7b82ff8432 |
+115
-3
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import inspect
|
||||
import os
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents import (
|
||||
@@ -13,6 +14,8 @@ from agents import (
|
||||
set_tracing_disabled,
|
||||
)
|
||||
from agents.model_settings import ModelSettings
|
||||
from agents.models.fake_id import FAKE_RESPONSES_ID
|
||||
from agents.models.interface import Model
|
||||
from agents.models.multi_provider import MultiProvider
|
||||
from agents.models.openai_responses import OpenAIResponsesModel
|
||||
from agents.retry import (
|
||||
@@ -21,6 +24,12 @@ from agents.retry import (
|
||||
RetryPolicyContext,
|
||||
retry_policies,
|
||||
)
|
||||
from openai.types.responses import (
|
||||
Response,
|
||||
ResponseCompletedEvent,
|
||||
ResponseOutputItemDoneEvent,
|
||||
ResponseUsage,
|
||||
)
|
||||
from openai.types.shared import Reasoning
|
||||
|
||||
from strix.config import codex
|
||||
@@ -30,8 +39,14 @@ from strix.config.loader import load_settings
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from agents.models.interface import Model, ModelProvider
|
||||
from agents.agent_output import AgentOutputSchemaBase
|
||||
from agents.handoffs import Handoff
|
||||
from agents.items import ModelResponse, TResponseInputItem, TResponseStreamEvent
|
||||
from agents.models.interface import ModelProvider, ModelTracing
|
||||
from agents.tool import Tool
|
||||
from agents.usage import Usage
|
||||
from openai import AsyncOpenAI
|
||||
from openai.types.responses import ResponsePromptParam
|
||||
|
||||
from strix.config.settings import ReasoningEffort, Settings
|
||||
|
||||
@@ -135,6 +150,99 @@ class _CodexResponsesModel(OpenAIResponsesModel):
|
||||
await result
|
||||
|
||||
|
||||
def _to_response_usage(usage: Usage | None) -> ResponseUsage | None:
|
||||
if usage is None:
|
||||
return None
|
||||
return ResponseUsage(
|
||||
input_tokens=usage.input_tokens,
|
||||
output_tokens=usage.output_tokens,
|
||||
total_tokens=usage.total_tokens,
|
||||
input_tokens_details=usage.input_tokens_details,
|
||||
output_tokens_details=usage.output_tokens_details,
|
||||
)
|
||||
|
||||
|
||||
class _NonStreamingModel(Model):
|
||||
"""Run a model non-streamed but expose the streaming interface the runner uses.
|
||||
|
||||
Some OpenAI-compatible endpoints (notably gateways serving reasoning models)
|
||||
return valid ``tool_calls`` for a non-streamed completion but, when streamed,
|
||||
emit the tool call as plain text or drop it and close the stream — leaving
|
||||
Strix's tool-driven loop with nothing to execute. Selecting
|
||||
``STRIX_STREAM_MODE=never`` routes through this wrapper, which makes the real
|
||||
request non-streamed (where tool calling works) and synthesizes the minimal
|
||||
event sequence the runner consumes from a stream, so the rest of the pipeline
|
||||
is unchanged. The only user-visible difference is no token-by-token output.
|
||||
"""
|
||||
|
||||
def __init__(self, inner: Model) -> None:
|
||||
self._inner = inner
|
||||
|
||||
async def get_response(self, *args: Any, **kwargs: Any) -> ModelResponse:
|
||||
return await self._inner.get_response(*args, **kwargs)
|
||||
|
||||
async def stream_response(
|
||||
self,
|
||||
system_instructions: str | None,
|
||||
input: str | list[TResponseInputItem], # noqa: A002
|
||||
model_settings: ModelSettings,
|
||||
tools: list[Tool],
|
||||
output_schema: AgentOutputSchemaBase | None,
|
||||
handoffs: list[Handoff],
|
||||
tracing: ModelTracing,
|
||||
*,
|
||||
previous_response_id: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
prompt: ResponsePromptParam | None = None,
|
||||
) -> AsyncIterator[TResponseStreamEvent]:
|
||||
model_response = await self._inner.get_response(
|
||||
system_instructions,
|
||||
input,
|
||||
model_settings,
|
||||
tools,
|
||||
output_schema,
|
||||
handoffs,
|
||||
tracing,
|
||||
previous_response_id=previous_response_id,
|
||||
conversation_id=conversation_id,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
sequence = 0
|
||||
for index, item in enumerate(model_response.output):
|
||||
yield ResponseOutputItemDoneEvent(
|
||||
item=item,
|
||||
output_index=index,
|
||||
type="response.output_item.done",
|
||||
sequence_number=sequence,
|
||||
)
|
||||
sequence += 1
|
||||
|
||||
response = Response(
|
||||
id=model_response.response_id or FAKE_RESPONSES_ID,
|
||||
created_at=time.time(),
|
||||
model=str(getattr(self._inner, "model", "")),
|
||||
object="response",
|
||||
output=model_response.output,
|
||||
tool_choice="auto",
|
||||
tools=[],
|
||||
parallel_tool_calls=False,
|
||||
usage=_to_response_usage(model_response.usage),
|
||||
)
|
||||
yield ResponseCompletedEvent(
|
||||
response=response,
|
||||
type="response.completed",
|
||||
sequence_number=sequence,
|
||||
)
|
||||
|
||||
def get_retry_advice(self, request: Any) -> Any:
|
||||
return self._inner.get_retry_advice(request)
|
||||
|
||||
|
||||
def _should_run_non_streamed(settings: Settings) -> bool:
|
||||
return settings.llm.stream_mode == "never"
|
||||
|
||||
|
||||
class StrixProvider(MultiProvider):
|
||||
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
|
||||
so users type ``deepseek/deepseek-chat`` rather than
|
||||
@@ -159,14 +267,18 @@ class StrixProvider(MultiProvider):
|
||||
return self._get_fallback_provider("litellm"), original_model_name
|
||||
|
||||
def get_model(self, model_name: str | None) -> Model:
|
||||
settings = load_settings()
|
||||
slug = codex.subscription_model(model_name)
|
||||
if slug:
|
||||
return _CodexResponsesModel(
|
||||
slug,
|
||||
codex.get_subscription_client(),
|
||||
reasoning_effort=load_settings().llm.reasoning_effort,
|
||||
reasoning_effort=settings.llm.reasoning_effort,
|
||||
)
|
||||
return super().get_model(model_name)
|
||||
model = super().get_model(model_name)
|
||||
if _should_run_non_streamed(settings):
|
||||
return _NonStreamingModel(model)
|
||||
return model
|
||||
|
||||
|
||||
DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
||||
|
||||
@@ -9,6 +9,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh"]
|
||||
StreamMode = Literal["auto", "always", "never"]
|
||||
|
||||
_BASE_CONFIG = SettingsConfigDict(
|
||||
case_sensitive=False,
|
||||
@@ -40,9 +41,11 @@ class LlmSettings(BaseSettings):
|
||||
default=False,
|
||||
alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE",
|
||||
)
|
||||
skip_tool_call_probe: bool = Field(
|
||||
default=False,
|
||||
alias="STRIX_SKIP_TOOL_CALL_PROBE",
|
||||
# auto/always stream; never runs non-streamed, for endpoints that stream tool
|
||||
# calls incorrectly (some OpenAI-compatible gateways serving reasoning models).
|
||||
stream_mode: StreamMode = Field(
|
||||
default="auto",
|
||||
alias="STRIX_STREAM_MODE",
|
||||
)
|
||||
prompt_cache: bool = Field(
|
||||
default=True,
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
"""Preflight probe: verify the model emits structured tool calls when streamed.
|
||||
|
||||
Strix is entirely tool-driven and runs every agent turn as a streamed request.
|
||||
Some OpenAI-compatible endpoints return a valid ``tool_calls`` response when a
|
||||
completion is requested non-streamed, but under streaming they emit the tool
|
||||
call as plain assistant text (or drop it entirely) and close the stream. The
|
||||
Agents SDK then sees a normal final message, the scan makes no progress, and it
|
||||
either stalls waiting for input or burns turns on empty output.
|
||||
|
||||
There is no safe client-side way to execute a tool call the endpoint never
|
||||
streamed, so we detect the missing capability up front — using the same
|
||||
streaming path the scan uses — and fail loudly with actionable guidance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents import ModelSettings, ModelTracing
|
||||
from agents.tool import FunctionTool
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
|
||||
from strix.config.models import StrixProvider
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from strix.config.settings import Settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROBE_RETRIES = 2
|
||||
|
||||
_GUIDANCE = (
|
||||
"The configured LLM endpoint did not return a structured tool call when "
|
||||
"streamed.\n\n"
|
||||
"Strix drives every action through native `tool_calls`, and it streams every "
|
||||
"turn. Some OpenAI-compatible servers return tool calls correctly for a "
|
||||
"non-streamed request but, when streamed, emit the tool call as plain text "
|
||||
"(or omit it) and end the response — so Strix can never act.\n\n"
|
||||
"Fixes:\n"
|
||||
" - llama.cpp / llama-server: start with `--jinja` so the chat template "
|
||||
"produces streamed `tool_calls` deltas.\n"
|
||||
" - Ollama: use a model whose template wires tool calling, and disable "
|
||||
"'thinking' if the template can't stream tools alongside it.\n"
|
||||
" - vLLM: set a matching `--tool-call-parser` (and `--enable-auto-tool-choice`) "
|
||||
"for the served model.\n"
|
||||
" - Other gateways: confirm streamed tool calling works for this model "
|
||||
"(a non-streamed test is not enough).\n\n"
|
||||
"If you know the endpoint streams tool calls correctly, set "
|
||||
"STRIX_SKIP_TOOL_CALL_PROBE=1 to skip this check."
|
||||
)
|
||||
|
||||
_TOOL_CONFIG_ERROR_MARKERS = (
|
||||
"jinja",
|
||||
"tool call parser",
|
||||
"tool-call-parser",
|
||||
"tool_choice",
|
||||
"does not support tools",
|
||||
"tools param",
|
||||
"tool use is not supported",
|
||||
)
|
||||
|
||||
|
||||
class ToolCallingUnsupportedError(RuntimeError):
|
||||
"""The endpoint cannot return structured tool calls over a streamed request."""
|
||||
|
||||
|
||||
async def _noop_invoke(_ctx: Any, _args: str) -> str:
|
||||
return "ok"
|
||||
|
||||
|
||||
_PROBE_TOOL = FunctionTool(
|
||||
name="strix_ready_check",
|
||||
description="Report readiness. Call this to acknowledge you can use tools.",
|
||||
params_json_schema={
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {"status": {"type": "string"}},
|
||||
"required": ["status"],
|
||||
},
|
||||
on_invoke_tool=_noop_invoke,
|
||||
strict_json_schema=True,
|
||||
)
|
||||
|
||||
_PROBE_SYSTEM = (
|
||||
"You are a setup probe. You can only respond by calling the provided tool. "
|
||||
"Do not produce any other output."
|
||||
)
|
||||
_PROBE_INPUT = 'Call the `strix_ready_check` tool now with {"status": "ok"}.'
|
||||
|
||||
|
||||
def requires_tool_call_probe(model_name: str, settings: Settings) -> bool:
|
||||
"""Only self-hosted / OpenAI-compatible routes, where the streaming leak happens."""
|
||||
return model_name.startswith("ollama/") or bool(settings.llm.api_base)
|
||||
|
||||
|
||||
def _is_tool_config_error(exc: Exception) -> bool:
|
||||
text = str(exc).lower()
|
||||
return any(marker in text for marker in _TOOL_CONFIG_ERROR_MARKERS)
|
||||
|
||||
|
||||
async def _stream_saw_tool_call(
|
||||
model_name: str, model_settings: ModelSettings, *, timeout: float | None
|
||||
) -> bool:
|
||||
model = StrixProvider().get_model(model_name)
|
||||
|
||||
async def _run() -> bool:
|
||||
stream = model.stream_response(
|
||||
system_instructions=_PROBE_SYSTEM,
|
||||
input=_PROBE_INPUT,
|
||||
model_settings=model_settings,
|
||||
tools=[_PROBE_TOOL],
|
||||
output_schema=None,
|
||||
handoffs=[],
|
||||
tracing=ModelTracing.DISABLED,
|
||||
previous_response_id=None,
|
||||
conversation_id=None,
|
||||
prompt=None,
|
||||
)
|
||||
# Drain the whole stream rather than returning early: the SDK wraps it in
|
||||
# a span that must be closed in the context it was opened in.
|
||||
saw_tool_call = False
|
||||
async for event in stream:
|
||||
item = getattr(event, "item", None)
|
||||
if isinstance(item, ResponseFunctionToolCall):
|
||||
saw_tool_call = True
|
||||
response = getattr(event, "response", None)
|
||||
if response is not None and any(
|
||||
isinstance(out, ResponseFunctionToolCall)
|
||||
for out in getattr(response, "output", []) or []
|
||||
):
|
||||
saw_tool_call = True
|
||||
return saw_tool_call
|
||||
|
||||
if timeout is not None:
|
||||
return await asyncio.wait_for(_run(), timeout=timeout)
|
||||
return await _run()
|
||||
|
||||
|
||||
async def probe_tool_calling(
|
||||
model_name: str,
|
||||
settings: Settings,
|
||||
*,
|
||||
request_timeout: float | None = None,
|
||||
) -> None:
|
||||
"""Fail fast if a streamed request to ``model_name`` yields no structured tool call.
|
||||
|
||||
No-op for hosted providers and when ``STRIX_SKIP_TOOL_CALL_PROBE`` is set.
|
||||
"""
|
||||
if settings.llm.skip_tool_call_probe or not requires_tool_call_probe(model_name, settings):
|
||||
return
|
||||
|
||||
model_settings = ModelSettings(
|
||||
parallel_tool_calls=False,
|
||||
include_usage=True,
|
||||
)
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(_PROBE_RETRIES + 1):
|
||||
try:
|
||||
saw_tool_call = await _stream_saw_tool_call(
|
||||
model_name, model_settings, timeout=request_timeout
|
||||
)
|
||||
except ToolCallingUnsupportedError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if _is_tool_config_error(exc):
|
||||
logger.debug("Tool-call probe hit a tool-config error", exc_info=True)
|
||||
raise ToolCallingUnsupportedError(_GUIDANCE) from exc
|
||||
last_exc = exc
|
||||
logger.debug(
|
||||
"Tool-call probe attempt %d/%d failed transiently",
|
||||
attempt + 1,
|
||||
_PROBE_RETRIES + 1,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
if saw_tool_call:
|
||||
logger.info("Tool-call probe passed for model %s", model_name)
|
||||
return
|
||||
raise ToolCallingUnsupportedError(_GUIDANCE)
|
||||
|
||||
# All attempts raised transient errors; surface the last one unchanged so the
|
||||
# caller's existing connection-error handling reports it.
|
||||
if last_exc is not None:
|
||||
raise last_exc
|
||||
@@ -33,7 +33,6 @@ from strix.config.models import (
|
||||
)
|
||||
from strix.core.inputs import DEFAULT_MAX_TURNS
|
||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
||||
from strix.core.warmup import ToolCallingUnsupportedError, probe_tool_calling
|
||||
from strix.interface.cli import run_cli
|
||||
from strix.interface.tui import run_tui
|
||||
from strix.interface.update_check import (
|
||||
@@ -423,27 +422,6 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||
)
|
||||
logger.info("LLM warm-up succeeded for dedupe model %s", dedupe_model)
|
||||
|
||||
raw_model = (llm.model or "").strip()
|
||||
await probe_tool_calling(raw_model, settings, request_timeout=llm.timeout)
|
||||
|
||||
except ToolCallingUnsupportedError as e:
|
||||
logger.debug("Tool-call probe failed", exc_info=True)
|
||||
error_text = Text()
|
||||
error_text.append("TOOL CALLING NOT SUPPORTED", style="bold red")
|
||||
error_text.append("\n\n", style="white")
|
||||
error_text.append(str(e), style="white")
|
||||
console.print("\n")
|
||||
console.print(
|
||||
Panel(
|
||||
error_text,
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
),
|
||||
)
|
||||
console.print()
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logger.debug("LLM warm-up failed", exc_info=True)
|
||||
error_text = Text()
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Tests for the non-streaming wrapper used on custom OpenAI-compatible endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from agents.items import ModelResponse
|
||||
from agents.model_settings import ModelSettings
|
||||
from agents.models.interface import Model, ModelTracing
|
||||
from agents.usage import Usage
|
||||
from openai.types.responses import (
|
||||
ResponseCompletedEvent,
|
||||
ResponseFunctionToolCall,
|
||||
ResponseOutputItemDoneEvent,
|
||||
ResponseStreamEvent,
|
||||
)
|
||||
|
||||
from strix.config.models import StrixProvider, _NonStreamingModel, _to_response_usage
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
def _tool_call() -> ResponseFunctionToolCall:
|
||||
return ResponseFunctionToolCall(
|
||||
arguments='{"command": "ls"}',
|
||||
call_id="call_1",
|
||||
name="terminal_execute",
|
||||
type="function_call",
|
||||
)
|
||||
|
||||
|
||||
class _FakeModel(Model):
|
||||
def __init__(self, response: ModelResponse) -> None:
|
||||
self.model = "fake-model"
|
||||
self._response = response
|
||||
self.get_response_calls = 0
|
||||
|
||||
async def get_response(self, *_args: object, **_kwargs: object) -> ModelResponse:
|
||||
self.get_response_calls += 1
|
||||
return self._response
|
||||
|
||||
async def stream_response( # pragma: no cover
|
||||
self, *_args: object, **_kwargs: object
|
||||
) -> AsyncIterator[ResponseStreamEvent]:
|
||||
for _ in range(0):
|
||||
yield cast("ResponseStreamEvent", None)
|
||||
raise AssertionError("inner stream_response must never be called")
|
||||
|
||||
|
||||
def _settings(*, api_base: str | None, stream_mode: str = "auto") -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
llm=SimpleNamespace(
|
||||
model="openai/glm",
|
||||
api_base=api_base,
|
||||
stream_mode=stream_mode,
|
||||
reasoning_effort="high",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_to_response_usage_maps_token_details() -> None:
|
||||
usage = Usage(requests=1, input_tokens=10, output_tokens=5, total_tokens=15)
|
||||
usage.input_tokens_details.cached_tokens = 4
|
||||
usage.output_tokens_details.reasoning_tokens = 3
|
||||
mapped = _to_response_usage(usage)
|
||||
assert mapped is not None
|
||||
assert (mapped.input_tokens, mapped.output_tokens, mapped.total_tokens) == (10, 5, 15)
|
||||
assert mapped.input_tokens_details.cached_tokens == 4
|
||||
assert mapped.output_tokens_details.reasoning_tokens == 3
|
||||
|
||||
|
||||
def test_to_response_usage_none() -> None:
|
||||
assert _to_response_usage(None) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_response_synthesizes_tool_call_from_non_streamed() -> None:
|
||||
tool_call = _tool_call()
|
||||
inner = _FakeModel(
|
||||
ModelResponse(
|
||||
output=[tool_call],
|
||||
usage=Usage(requests=1, input_tokens=10, output_tokens=5, total_tokens=15),
|
||||
response_id="resp_123",
|
||||
)
|
||||
)
|
||||
wrapper = _NonStreamingModel(inner)
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in wrapper.stream_response(
|
||||
"sys",
|
||||
"hi",
|
||||
ModelSettings(),
|
||||
[],
|
||||
None,
|
||||
[],
|
||||
ModelTracing.DISABLED,
|
||||
)
|
||||
]
|
||||
|
||||
assert inner.get_response_calls == 1
|
||||
item_done = [e for e in events if isinstance(e, ResponseOutputItemDoneEvent)]
|
||||
completed = [e for e in events if isinstance(e, ResponseCompletedEvent)]
|
||||
assert len(item_done) == 1
|
||||
assert item_done[0].item == tool_call
|
||||
assert len(completed) == 1
|
||||
final = completed[0].response
|
||||
assert final.output == [tool_call]
|
||||
assert final.id == "resp_123"
|
||||
assert final.usage is not None
|
||||
assert final.usage.total_tokens == 15
|
||||
# sequence numbers are strictly increasing
|
||||
assert [e.sequence_number for e in events] == list(range(len(events)))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_response_delegates_get_response() -> None:
|
||||
inner = _FakeModel(
|
||||
ModelResponse(output=[], usage=Usage(), response_id=None),
|
||||
)
|
||||
wrapper = _NonStreamingModel(inner)
|
||||
result = await wrapper.get_response(
|
||||
"sys", "hi", ModelSettings(), [], None, [], ModelTracing.DISABLED
|
||||
)
|
||||
assert result is inner._response
|
||||
assert inner.get_response_calls == 1
|
||||
|
||||
|
||||
def test_get_model_auto_streams_custom_endpoint() -> None:
|
||||
sentinel = _FakeModel(ModelResponse(output=[], usage=Usage(), response_id=None))
|
||||
with (
|
||||
patch("strix.config.models.load_settings", return_value=_settings(api_base="http://x/v1")),
|
||||
patch(
|
||||
"agents.models.multi_provider.MultiProvider.get_model",
|
||||
return_value=sentinel,
|
||||
),
|
||||
):
|
||||
model = StrixProvider().get_model("openai/glm")
|
||||
assert model is sentinel
|
||||
|
||||
|
||||
def test_get_model_auto_streams_hosted() -> None:
|
||||
sentinel = _FakeModel(ModelResponse(output=[], usage=Usage(), response_id=None))
|
||||
with (
|
||||
patch("strix.config.models.load_settings", return_value=_settings(api_base="")),
|
||||
patch(
|
||||
"agents.models.multi_provider.MultiProvider.get_model",
|
||||
return_value=sentinel,
|
||||
),
|
||||
):
|
||||
model = StrixProvider().get_model("openai/gpt-4o")
|
||||
assert model is sentinel
|
||||
|
||||
|
||||
def test_get_model_stream_mode_never_wraps() -> None:
|
||||
sentinel = _FakeModel(ModelResponse(output=[], usage=Usage(), response_id=None))
|
||||
with (
|
||||
patch(
|
||||
"strix.config.models.load_settings",
|
||||
return_value=_settings(api_base="http://x/v1", stream_mode="never"),
|
||||
),
|
||||
patch(
|
||||
"agents.models.multi_provider.MultiProvider.get_model",
|
||||
return_value=sentinel,
|
||||
),
|
||||
):
|
||||
model = StrixProvider().get_model("openai/glm")
|
||||
assert isinstance(model, _NonStreamingModel)
|
||||
@@ -1,158 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
|
||||
from strix.core import warmup
|
||||
from strix.core.warmup import (
|
||||
ToolCallingUnsupportedError,
|
||||
probe_tool_calling,
|
||||
requires_tool_call_probe,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
def _settings(*, api_base: str | None = None, skip: bool = False) -> Any:
|
||||
return types.SimpleNamespace(
|
||||
llm=types.SimpleNamespace(api_base=api_base, skip_tool_call_probe=skip),
|
||||
)
|
||||
|
||||
|
||||
def _tool_call_event() -> Any:
|
||||
return types.SimpleNamespace(
|
||||
item=ResponseFunctionToolCall(
|
||||
arguments='{"status": "ok"}',
|
||||
call_id="call_1",
|
||||
name="strix_ready_check",
|
||||
type="function_call",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _text_event() -> Any:
|
||||
# A completed response whose output is a plain message, no tool call.
|
||||
return types.SimpleNamespace(
|
||||
item=None,
|
||||
response=types.SimpleNamespace(output=[types.SimpleNamespace(type="message")]),
|
||||
)
|
||||
|
||||
|
||||
class _FakeModel:
|
||||
def __init__(self, events: list[Any] | None = None, raises: Exception | None = None) -> None:
|
||||
self._events = events or []
|
||||
self._raises = raises
|
||||
|
||||
def stream_response(self, **_kwargs: Any) -> AsyncIterator[Any]:
|
||||
events = self._events
|
||||
raises = self._raises
|
||||
|
||||
async def _gen() -> AsyncIterator[Any]:
|
||||
if raises is not None:
|
||||
raise raises
|
||||
for event in events:
|
||||
yield event
|
||||
|
||||
return _gen()
|
||||
|
||||
|
||||
def _patch_model(monkeypatch: pytest.MonkeyPatch, model: _FakeModel) -> None:
|
||||
monkeypatch.setattr(
|
||||
warmup, "StrixProvider", lambda: types.SimpleNamespace(get_model=lambda _m: model)
|
||||
)
|
||||
|
||||
|
||||
def test_requires_probe_only_for_custom_endpoints_and_ollama() -> None:
|
||||
assert requires_tool_call_probe("openai/glm-5.2", _settings(api_base="http://x")) is True
|
||||
assert requires_tool_call_probe("ollama/llama3", _settings()) is True
|
||||
assert requires_tool_call_probe("openai/gpt-4o", _settings()) is False
|
||||
assert requires_tool_call_probe("anthropic/claude", _settings()) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_skipped_for_hosted_provider(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Would raise if it tried to stream; gating must short-circuit first.
|
||||
_patch_model(monkeypatch, _FakeModel(raises=RuntimeError("should not be called")))
|
||||
await probe_tool_calling("openai/gpt-4o", _settings())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_skipped_when_setting_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_model(monkeypatch, _FakeModel(raises=RuntimeError("should not be called")))
|
||||
await probe_tool_calling("openai/glm-5.2", _settings(api_base="http://x", skip=True))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_passes_on_streamed_tool_call(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_model(monkeypatch, _FakeModel(events=[_tool_call_event()]))
|
||||
await probe_tool_calling("openai/glm-5.2", _settings(api_base="http://x"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_passes_when_tool_call_only_in_final_response(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
completed = types.SimpleNamespace(
|
||||
item=None,
|
||||
response=types.SimpleNamespace(
|
||||
output=[
|
||||
ResponseFunctionToolCall(
|
||||
arguments="{}",
|
||||
call_id="c",
|
||||
name="strix_ready_check",
|
||||
type="function_call",
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
_patch_model(monkeypatch, _FakeModel(events=[completed]))
|
||||
await probe_tool_calling("openai/glm-5.2", _settings(api_base="http://x"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_aborts_when_only_text_streamed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_model(monkeypatch, _FakeModel(events=[_text_event()]))
|
||||
with pytest.raises(ToolCallingUnsupportedError):
|
||||
await probe_tool_calling("openai/glm-5.2", _settings(api_base="http://x"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_aborts_on_tool_config_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_model(monkeypatch, _FakeModel(raises=RuntimeError("tools param requires --jinja flag")))
|
||||
with pytest.raises(ToolCallingUnsupportedError):
|
||||
await probe_tool_calling("ollama/llama3", _settings(api_base="http://x"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_retries_transient_then_passes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = {"n": 0}
|
||||
good = _FakeModel(events=[_tool_call_event()])
|
||||
|
||||
class _Flaky:
|
||||
def stream_response(self, **kwargs: Any) -> AsyncIterator[Any]:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
|
||||
async def _boom() -> AsyncIterator[Any]:
|
||||
for _ in range(0): # make this a generator without an unreachable yield
|
||||
yield None
|
||||
raise ConnectionError("transient")
|
||||
|
||||
return _boom()
|
||||
return good.stream_response(**kwargs)
|
||||
|
||||
_patch_model(monkeypatch, _Flaky()) # type: ignore[arg-type]
|
||||
await probe_tool_calling("openai/glm-5.2", _settings(api_base="http://x"))
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_surfaces_persistent_transient_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_model(monkeypatch, _FakeModel(raises=ConnectionError("down")))
|
||||
with pytest.raises(ConnectionError):
|
||||
await probe_tool_calling("openai/glm-5.2", _settings(api_base="http://x"))
|
||||
Reference in New Issue
Block a user