mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b861413d10 | ||
|
|
d2fd1d6103 | ||
|
|
4b76c73c3f | ||
|
|
e5ae8b8ae1 |
@@ -19,14 +19,6 @@ Configure Strix using environment variables or a config file.
|
||||
Custom API base URL. Also accepts `OPENAI_API_BASE`, `LITELLM_BASE_URL`, or `OLLAMA_API_BASE`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="LLM_EXTRA_HEADERS" type="string">
|
||||
Extra HTTP headers sent on every LLM request, as a JSON object (e.g.
|
||||
`{"X-Feature-Key":"value","X-Tenant":"acme"}`). Useful for OpenAI-compatible
|
||||
gateways that require attribution or routing headers in addition to the bearer
|
||||
token. The bearer token itself still comes from `LLM_API_KEY`. Applies to both
|
||||
the LiteLLM and native OpenAI routing paths.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="LLM_TIMEOUT" default="300" type="integer">
|
||||
Request timeout in seconds for LLM calls.
|
||||
</ParamField>
|
||||
@@ -63,12 +55,6 @@ affecting the agents that do the actual testing.
|
||||
model runs on a different endpoint than the main model.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="DEDUPE_LLM_EXTRA_HEADERS" type="string">
|
||||
Optional JSON object of extra HTTP headers sent on every deduplication-model
|
||||
request, e.g. `{"X-Feature-Key":"value"}`. A dedicated dedupe model never
|
||||
inherits `LLM_EXTRA_HEADERS`; set this when its endpoint needs custom headers.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_DEDUPE_REASONING_EFFORT" type="string">
|
||||
Reasoning effort for the deduplication model. Defaults to the model's own
|
||||
baseline when unset.
|
||||
|
||||
@@ -54,20 +54,3 @@ If you use LM Studio, vLLM, or other runners:
|
||||
export STRIX_LLM="openai/local-model"
|
||||
export LLM_API_BASE="http://localhost:1234/v1" # Adjust port as needed
|
||||
```
|
||||
|
||||
### Gateways that require custom headers
|
||||
|
||||
Some OpenAI-compatible gateways require extra HTTP headers (for attribution or
|
||||
tenant routing) alongside the bearer token. Set them with `LLM_EXTRA_HEADERS` as
|
||||
a JSON object — they are sent on every request:
|
||||
|
||||
```bash
|
||||
export STRIX_LLM="openai/your-model"
|
||||
export LLM_API_BASE="https://your-gateway.example/v1"
|
||||
export LLM_API_KEY="your-bearer-token" # sent as Authorization: Bearer ...
|
||||
export LLM_EXTRA_HEADERS='{"X-Feature-Key":"value","X-Tenant":"acme"}'
|
||||
```
|
||||
|
||||
For endpoints behind a private CA, point Strix at your certificate bundle with
|
||||
the standard `SSL_CERT_FILE=/path/to/ca-bundle.pem` — never disable TLS
|
||||
verification against a real endpoint.
|
||||
|
||||
@@ -220,7 +220,6 @@ ignore = [
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST).
|
||||
"strix/interface/auth_cli.py" = ["N802"]
|
||||
"tests/test_codex_streaming.py" = ["N802"]
|
||||
"tests/test_disable_streaming.py" = ["N802"]
|
||||
"tests/test_report_pdf.py" = ["S105", "S106"]
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
||||
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
|
||||
|
||||
+4
-179
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import inspect
|
||||
import os
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents import (
|
||||
@@ -14,8 +13,6 @@ 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 (
|
||||
@@ -24,8 +21,6 @@ from agents.retry import (
|
||||
RetryPolicyContext,
|
||||
retry_policies,
|
||||
)
|
||||
from openai.types.responses import Response, ResponseCompletedEvent
|
||||
from openai.types.responses.response_usage import ResponseUsage
|
||||
from openai.types.shared import Reasoning
|
||||
|
||||
from strix.config import codex
|
||||
@@ -35,17 +30,10 @@ from strix.config.loader import load_settings
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
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.retry import ModelRetryAdvice, ModelRetryAdviceRequest
|
||||
from agents.tool import Tool
|
||||
from agents.usage import Usage
|
||||
from agents.models.interface import Model, ModelProvider
|
||||
from openai import AsyncOpenAI
|
||||
from openai.types.responses.response_prompt_param import ResponsePromptParam
|
||||
|
||||
from strix.config.settings import LlmSettings, ReasoningEffort, Settings
|
||||
from strix.config.settings import ReasoningEffort, Settings
|
||||
|
||||
|
||||
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
|
||||
@@ -147,124 +135,6 @@ class _CodexResponsesModel(OpenAIResponsesModel):
|
||||
await result
|
||||
|
||||
|
||||
class _NonStreamingModel(Model):
|
||||
"""Serve the SDK's streamed run loop from a single non-streaming request.
|
||||
|
||||
Some OpenAI-compatible gateways do not support Server-Sent Events, or
|
||||
deliver them unreliably (dropping structured tool-call deltas, or stalling
|
||||
mid-stream so the whole turn waits out the read timeout). The SDK run loop
|
||||
Strix uses only issues streamed requests, so such a gateway fails every
|
||||
turn. Opt in with ``LLM_DISABLE_STREAMING=true`` to wrap the resolved model
|
||||
so each turn makes one non-streaming ``get_response`` (``stream:false`` on
|
||||
the wire) and the completed result is replayed as a single terminal stream
|
||||
event. The run loop then executes tools and emits run items from that final
|
||||
response exactly as it would for a real stream, so nothing else changes.
|
||||
"""
|
||||
|
||||
def __init__(self, inner: Model) -> None:
|
||||
self._inner = inner
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._inner.close()
|
||||
|
||||
def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None:
|
||||
return self._inner.get_retry_advice(request)
|
||||
|
||||
async def get_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,
|
||||
conversation_id: str | None,
|
||||
prompt: ResponsePromptParam | None,
|
||||
) -> ModelResponse:
|
||||
return 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,
|
||||
)
|
||||
|
||||
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,
|
||||
conversation_id: str | None,
|
||||
prompt: ResponsePromptParam | None,
|
||||
) -> AsyncIterator[TResponseStreamEvent]:
|
||||
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,
|
||||
)
|
||||
yield _completed_stream_event(response, getattr(self._inner, "model", None))
|
||||
|
||||
|
||||
def _completed_stream_event(
|
||||
model_response: ModelResponse, model_name: object | None
|
||||
) -> TResponseStreamEvent:
|
||||
"""Wrap a non-streamed ``ModelResponse`` as the terminal event of a stream.
|
||||
|
||||
The run loop builds its authoritative per-turn response solely from the
|
||||
``response.completed`` event, so a single event carrying the full output
|
||||
and usage is all it needs.
|
||||
"""
|
||||
response = Response(
|
||||
id=model_response.response_id or FAKE_RESPONSES_ID,
|
||||
created_at=time.time(),
|
||||
model=str(model_name) if model_name else "",
|
||||
object="response",
|
||||
output=list(model_response.output),
|
||||
tool_choice="auto",
|
||||
tools=[],
|
||||
parallel_tool_calls=False,
|
||||
usage=_response_usage(model_response.usage),
|
||||
)
|
||||
return ResponseCompletedEvent(
|
||||
response=response,
|
||||
sequence_number=0,
|
||||
type="response.completed",
|
||||
)
|
||||
|
||||
|
||||
def _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 StrixProvider(MultiProvider):
|
||||
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
|
||||
so users type ``deepseek/deepseek-chat`` rather than
|
||||
@@ -289,21 +159,14 @@ class StrixProvider(MultiProvider):
|
||||
return self._get_fallback_provider("litellm"), original_model_name
|
||||
|
||||
def get_model(self, model_name: str | None) -> Model:
|
||||
llm = load_settings().llm
|
||||
slug = codex.subscription_model(model_name)
|
||||
if slug:
|
||||
# The ChatGPT subscription backend is always streamed; it has no
|
||||
# non-streaming mode to fall back to, so LLM_DISABLE_STREAMING
|
||||
# does not apply here.
|
||||
return _CodexResponsesModel(
|
||||
slug,
|
||||
codex.get_subscription_client(),
|
||||
reasoning_effort=llm.reasoning_effort,
|
||||
reasoning_effort=load_settings().llm.reasoning_effort,
|
||||
)
|
||||
model = super().get_model(model_name)
|
||||
if llm.disable_streaming:
|
||||
return _NonStreamingModel(model)
|
||||
return model
|
||||
return super().get_model(model_name)
|
||||
|
||||
|
||||
DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
||||
@@ -380,7 +243,6 @@ def configure_sdk_model_defaults(settings: Settings) -> None:
|
||||
set_default_openai_api("chat_completions")
|
||||
else:
|
||||
set_default_openai_api("responses")
|
||||
_configure_extra_headers(llm)
|
||||
|
||||
|
||||
def _mirror_api_key_to_provider_env(model_name: str | None, api_key: str) -> None:
|
||||
@@ -485,43 +347,6 @@ def _configure_openrouter_attribution(model_name: str | None) -> None:
|
||||
litellm.headers = {**existing, **_OPENROUTER_ATTRIBUTION_HEADERS} # type: ignore[assignment]
|
||||
|
||||
|
||||
def _configure_extra_headers(llm: LlmSettings) -> None:
|
||||
"""Send user-provided default headers on every LLM request.
|
||||
|
||||
Some OpenAI-compatible endpoints require extra HTTP headers (e.g. request
|
||||
attribution or tenant routing) alongside the bearer token. Users supply
|
||||
them via ``LLM_EXTRA_HEADERS``; they are applied to both routing paths:
|
||||
the LiteLLM route (``litellm.headers``) and the SDK-native OpenAI route
|
||||
(a default client carrying ``default_headers``), so they take effect
|
||||
regardless of the ``STRIX_LLM`` prefix.
|
||||
"""
|
||||
headers = llm.extra_headers
|
||||
if not headers:
|
||||
return
|
||||
_merge_litellm_headers(headers)
|
||||
_register_openai_client_with_headers(llm, headers)
|
||||
|
||||
|
||||
def _merge_litellm_headers(headers: dict[str, str]) -> None:
|
||||
import litellm
|
||||
|
||||
current: object = litellm.headers
|
||||
existing: dict[str, str] = current if isinstance(current, dict) else {}
|
||||
litellm.headers = {**existing, **headers} # type: ignore[assignment]
|
||||
|
||||
|
||||
def _register_openai_client_with_headers(llm: LlmSettings, headers: dict[str, str]) -> None:
|
||||
from agents import set_default_openai_client
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
client = AsyncOpenAI(
|
||||
api_key=llm.api_key or "not-needed",
|
||||
base_url=llm.api_base,
|
||||
default_headers=dict(headers),
|
||||
)
|
||||
set_default_openai_client(client, use_for_tracing=False)
|
||||
|
||||
|
||||
def _register_litellm_cost_callback() -> None:
|
||||
import litellm
|
||||
|
||||
|
||||
@@ -35,23 +35,18 @@ class LlmSettings(BaseSettings):
|
||||
"OLLAMA_API_BASE",
|
||||
),
|
||||
)
|
||||
extra_headers: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
alias="LLM_EXTRA_HEADERS",
|
||||
)
|
||||
reasoning_effort: ReasoningEffort = Field(default="high", alias="STRIX_REASONING_EFFORT")
|
||||
force_required_tool_choice: bool = Field(
|
||||
default=False,
|
||||
# None = auto: force required tool choice on OpenAI-compatible custom
|
||||
# endpoints (where reasoning models otherwise burn tokens thinking without
|
||||
# ever calling a tool), off elsewhere. True/False overrides explicitly.
|
||||
force_required_tool_choice: bool | None = Field(
|
||||
default=None,
|
||||
alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE",
|
||||
)
|
||||
prompt_cache: bool = Field(
|
||||
default=True,
|
||||
alias="STRIX_PROMPT_CACHE",
|
||||
)
|
||||
disable_streaming: bool = Field(
|
||||
default=False,
|
||||
alias="LLM_DISABLE_STREAMING",
|
||||
)
|
||||
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
|
||||
|
||||
|
||||
@@ -65,10 +60,6 @@ class DedupeSettings(BaseSettings):
|
||||
)
|
||||
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY")
|
||||
api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE")
|
||||
extra_headers: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
alias="DEDUPE_LLM_EXTRA_HEADERS",
|
||||
)
|
||||
|
||||
|
||||
class ContextSettings(BaseSettings):
|
||||
|
||||
@@ -55,23 +55,6 @@ _INPUT_REJECTION_CODES = frozenset({400, 404, 422})
|
||||
_MAX_COMPACTIONS_PER_CYCLE = 2
|
||||
|
||||
|
||||
class ProviderRefusalError(AgentsException):
|
||||
"""Raised when a provider returns a structured refusal instead of an exception."""
|
||||
|
||||
|
||||
def _structured_provider_refusal(result: Any) -> str | None:
|
||||
for item in getattr(result, "new_items", ()) or ():
|
||||
raw_item = getattr(item, "raw_item", None)
|
||||
for content in getattr(raw_item, "content", ()) or ():
|
||||
if getattr(content, "type", None) != "refusal":
|
||||
continue
|
||||
refusal = getattr(content, "refusal", None)
|
||||
if isinstance(refusal, str) and refusal.strip():
|
||||
return refusal.strip()
|
||||
return "The model provider refused this request."
|
||||
return None
|
||||
|
||||
|
||||
def _run_config_model(run_config: RunConfig) -> str | None:
|
||||
return run_config.model if isinstance(run_config.model, str) else None
|
||||
|
||||
@@ -507,8 +490,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
logger.exception("stream event sink failed for %s", agent_id)
|
||||
if stream.run_loop_exception is not None:
|
||||
raise stream.run_loop_exception
|
||||
if refusal := _structured_provider_refusal(stream):
|
||||
raise ProviderRefusalError(refusal)
|
||||
except (BudgetExceededError, BudgetPausedError, SubagentBudgetReservedError):
|
||||
raise
|
||||
except RuntimeError as stream_exc:
|
||||
@@ -603,11 +584,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
return await _handle_content_guardrail(
|
||||
coordinator, agent_id, exc, interactive=interactive
|
||||
)
|
||||
if isinstance(exc, ProviderRefusalError):
|
||||
logger.warning("agent %s refused by the model provider: %s", agent_id, exc)
|
||||
await coordinator.set_status(agent_id, "failed", error=str(exc))
|
||||
await _notify_parent_on_terminal(coordinator, agent_id, "failed")
|
||||
return None
|
||||
if not interactive:
|
||||
raise
|
||||
if isinstance(exc, MaxTurnsExceeded):
|
||||
|
||||
+10
-4
@@ -129,17 +129,16 @@ def make_model_settings(
|
||||
reasoning_effort: ReasoningEffort | None,
|
||||
*,
|
||||
model_name: str,
|
||||
force_required_tool_choice: bool = False,
|
||||
force_required_tool_choice: bool | None = None,
|
||||
custom_api_base: bool = False,
|
||||
request_timeout: float | None = None,
|
||||
prompt_cache: bool = True,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
) -> ModelSettings:
|
||||
model_settings = ModelSettings(
|
||||
parallel_tool_calls=False,
|
||||
retry=DEFAULT_MODEL_RETRY,
|
||||
include_usage=True,
|
||||
extra_args=request_timeout_extra_args(request_timeout),
|
||||
extra_headers=dict(extra_headers) if extra_headers else None,
|
||||
)
|
||||
if (
|
||||
reasoning_effort is not None
|
||||
@@ -149,7 +148,14 @@ def make_model_settings(
|
||||
model_settings = model_settings.resolve(
|
||||
ModelSettings(reasoning=Reasoning(effort=reasoning_effort)),
|
||||
)
|
||||
if force_required_tool_choice and _accepts_required_tool_choice(model_name):
|
||||
# Strix is fully tool-driven, so a turn that returns prose instead of a tool
|
||||
# call stalls the scan. Reasoning models on OpenAI-compatible custom
|
||||
# endpoints (e.g. GLM / Kimi via cortecs) do exactly that under the default
|
||||
# ``auto`` tool choice, so default to ``required`` there; ``None`` means auto.
|
||||
use_required = (
|
||||
custom_api_base if force_required_tool_choice is None else force_required_tool_choice
|
||||
)
|
||||
if use_required and _accepts_required_tool_choice(model_name):
|
||||
model_settings = model_settings.resolve(ModelSettings(tool_choice="required"))
|
||||
|
||||
cache_extra_args = _prompt_cache_extra_args(model_name) if prompt_cache else None
|
||||
|
||||
@@ -248,9 +248,9 @@ async def run_strix_scan(
|
||||
settings.llm.reasoning_effort,
|
||||
model_name=resolved_model,
|
||||
force_required_tool_choice=settings.llm.force_required_tool_choice,
|
||||
custom_api_base=bool(settings.llm.api_base),
|
||||
request_timeout=settings.llm.timeout,
|
||||
prompt_cache=settings.llm.prompt_cache,
|
||||
extra_headers=settings.llm.extra_headers,
|
||||
)
|
||||
run_config = RunConfig(
|
||||
model=resolved_model,
|
||||
|
||||
+3
-21
@@ -31,7 +31,7 @@ from strix.config.models import (
|
||||
is_known_openai_bare_model,
|
||||
is_recommended_or_frontier_model,
|
||||
)
|
||||
from strix.core.inputs import DEFAULT_MAX_TURNS, make_model_settings
|
||||
from strix.core.inputs import DEFAULT_MAX_TURNS
|
||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
||||
from strix.interface.cli import run_cli
|
||||
from strix.interface.tui import run_tui
|
||||
@@ -382,13 +382,7 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||
model.get_response(
|
||||
system_instructions="You are a helpful assistant.",
|
||||
input="Reply with just 'OK'.",
|
||||
model_settings=make_model_settings(
|
||||
None,
|
||||
model_name=raw_model,
|
||||
request_timeout=llm.timeout,
|
||||
prompt_cache=False,
|
||||
extra_headers=llm.extra_headers,
|
||||
),
|
||||
model_settings=ModelSettings(),
|
||||
tools=[],
|
||||
output_schema=None,
|
||||
handoffs=[],
|
||||
@@ -410,19 +404,7 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||
# Match the runtime path: send the dedupe key/endpoint per call so a
|
||||
# separate-provider dedupe model authenticates during warm-up too.
|
||||
deduper_extra = _dedupe_extra_args(settings.dedupe)
|
||||
# A dedicated dedupe model may route to another provider, which must
|
||||
# never receive the main endpoint's headers; it has its own
|
||||
# DEDUPE_LLM_EXTRA_HEADERS.
|
||||
deduper_settings = make_model_settings(
|
||||
None,
|
||||
model_name=dedupe_model,
|
||||
request_timeout=llm.timeout,
|
||||
prompt_cache=False,
|
||||
extra_headers=settings.dedupe.extra_headers,
|
||||
)
|
||||
if deduper_extra:
|
||||
merged = {**(deduper_settings.extra_args or {}), **deduper_extra}
|
||||
deduper_settings = deduper_settings.resolve(ModelSettings(extra_args=merged))
|
||||
deduper_settings = ModelSettings(extra_args=deduper_extra or None)
|
||||
await asyncio.wait_for(
|
||||
deduper.get_response(
|
||||
system_instructions="You are a helpful assistant.",
|
||||
|
||||
@@ -15,7 +15,6 @@ from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pygments.token import _TokenType
|
||||
from textual.timer import Timer
|
||||
|
||||
from rich.align import Align
|
||||
@@ -353,7 +352,7 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
if not token_value:
|
||||
continue
|
||||
color = None
|
||||
tt: _TokenType | None = token_type
|
||||
tt = token_type
|
||||
while tt:
|
||||
if tt in colors:
|
||||
color = colors[tt]
|
||||
|
||||
+12
-44
@@ -12,20 +12,15 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents.model_settings import ModelSettings
|
||||
from agents.models.interface import ModelTracing
|
||||
import litellm
|
||||
from litellm.exceptions import BadRequestError, ContextWindowExceededError
|
||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.config.models import StrixProvider
|
||||
from strix.core.inputs import make_model_settings
|
||||
from strix.core.sessions import replace_session_items, session_write_lock
|
||||
from strix.llm.context_budget import context_window, count_tokens, output_limit
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agents.items import ModelResponse
|
||||
from agents.memory import Session
|
||||
|
||||
|
||||
@@ -273,53 +268,26 @@ def _checkpoint_item(summary: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _extract_text(response: ModelResponse) -> str:
|
||||
parts: list[str] = []
|
||||
for item in response.output:
|
||||
if not isinstance(item, ResponseOutputMessage):
|
||||
continue
|
||||
parts.extend(
|
||||
chunk.text
|
||||
for chunk in item.content
|
||||
if isinstance(chunk, ResponseOutputText) and chunk.text
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None:
|
||||
llm = load_settings().llm
|
||||
model_settings = make_model_settings(
|
||||
None,
|
||||
model_name=model,
|
||||
request_timeout=llm.timeout,
|
||||
prompt_cache=False,
|
||||
extra_headers=llm.extra_headers,
|
||||
).resolve(ModelSettings(max_tokens=max_tokens))
|
||||
try:
|
||||
response = (
|
||||
await StrixProvider()
|
||||
.get_model(model)
|
||||
.get_response(
|
||||
system_instructions=None,
|
||||
input=prompt,
|
||||
model_settings=model_settings,
|
||||
tools=[],
|
||||
output_schema=None,
|
||||
handoffs=[],
|
||||
tracing=ModelTracing.DISABLED,
|
||||
previous_response_id=None,
|
||||
conversation_id=None,
|
||||
prompt=None,
|
||||
)
|
||||
response = await litellm.acompletion(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=max_tokens,
|
||||
api_key=llm.api_key,
|
||||
api_base=llm.api_base,
|
||||
timeout=llm.timeout,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("compaction summary call failed for model %s", model)
|
||||
return None
|
||||
content = _extract_text(response).strip()
|
||||
if not content:
|
||||
try:
|
||||
content = response.choices[0].message.content
|
||||
except (AttributeError, IndexError, KeyError):
|
||||
logger.warning("compaction summary returned no content")
|
||||
return None
|
||||
return content
|
||||
return content.strip() if isinstance(content, str) and content.strip() else None
|
||||
|
||||
|
||||
async def maybe_compact(
|
||||
|
||||
@@ -17,14 +17,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# LiteLLM keys models without the routing prefix users type (``openai/``,
|
||||
# ``litellm/``, ``ollama/`` ...). Strip a leading provider segment on lookup.
|
||||
_STRIPPABLE_PREFIXES = (
|
||||
"openai/",
|
||||
"chatgpt/",
|
||||
"litellm/",
|
||||
"any-llm/",
|
||||
"ollama/",
|
||||
"ollama_chat/",
|
||||
)
|
||||
_STRIPPABLE_PREFIXES = ("openai/", "litellm/", "any-llm/", "ollama/", "ollama_chat/")
|
||||
|
||||
_DEFAULT_OUTPUT_TOKENS = 8_192
|
||||
|
||||
@@ -45,11 +38,7 @@ def _safe_get_model_info(model: str) -> dict[str, Any] | None:
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def _model_info(model: str) -> dict[str, int]:
|
||||
lookup_key = _lookup_key(model)
|
||||
# Provider-qualified ChatGPT lookups may start a synchronous device-login
|
||||
# poll. LiteLLM keys the metadata by the underlying model slug.
|
||||
candidates = (lookup_key,) if model.startswith("chatgpt/") else (model, lookup_key)
|
||||
for candidate in candidates:
|
||||
for candidate in (model, _lookup_key(model)):
|
||||
info = _safe_get_model_info(candidate)
|
||||
if info is not None:
|
||||
return {
|
||||
|
||||
@@ -51,24 +51,17 @@ def _dedupe_extra_args(dedupe: DedupeSettings) -> dict[str, str]:
|
||||
def _dedupe_model_settings(
|
||||
dedupe: DedupeSettings, model_name: str, request_timeout: float | None
|
||||
) -> ModelSettings:
|
||||
llm = load_settings().llm
|
||||
settings = make_model_settings(
|
||||
dedupe.reasoning_effort,
|
||||
model_name=model_name,
|
||||
force_required_tool_choice=False,
|
||||
request_timeout=request_timeout,
|
||||
# The main model's headers apply only when dedupe falls back to the main
|
||||
# model; a dedicated dedupe model may route to another provider, which
|
||||
# must never receive the main endpoint's credentials. A dedicated model
|
||||
# gets its own DEDUPE_LLM_EXTRA_HEADERS instead.
|
||||
extra_headers=dedupe.extra_headers if dedupe.model else llm.extra_headers,
|
||||
)
|
||||
extra = _dedupe_extra_args(dedupe)
|
||||
if extra:
|
||||
settings = settings.resolve(ModelSettings(extra_args=extra))
|
||||
return settings
|
||||
|
||||
|
||||
DEDUPE_SYSTEM_PROMPT = """You are an expert vulnerability report deduplication judge.
|
||||
Your task is to determine if a candidate vulnerability report describes the SAME vulnerability
|
||||
as any existing report.
|
||||
@@ -354,7 +347,9 @@ async def check_duplicate(
|
||||
response = await model.get_response(
|
||||
system_instructions=DEDUPE_SYSTEM_PROMPT,
|
||||
input=user_msg,
|
||||
model_settings=_dedupe_model_settings(dedupe, resolved_model, settings.llm.timeout),
|
||||
model_settings=_dedupe_model_settings(
|
||||
dedupe, resolved_model, settings.llm.timeout
|
||||
),
|
||||
tools=[],
|
||||
output_schema=None,
|
||||
handoffs=[],
|
||||
|
||||
@@ -110,19 +110,6 @@ def _apply_log_limits(create_kwargs: dict[str, Any]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _apply_run_labels(create_kwargs: dict[str, Any]) -> None:
|
||||
run_id = os.getenv("STRIX_RUN_ID")
|
||||
if not run_id:
|
||||
return
|
||||
labels = create_kwargs.setdefault("labels", {})
|
||||
if not isinstance(labels, dict):
|
||||
return
|
||||
labels["strix-run-id"] = run_id
|
||||
run_type = os.getenv("STRIX_RUN_TYPE")
|
||||
if run_type:
|
||||
labels["strix-run-type"] = run_type
|
||||
|
||||
|
||||
class StrixDockerSandboxSession(DockerSandboxSession):
|
||||
sandbox_network: str = ""
|
||||
|
||||
@@ -235,7 +222,6 @@ class StrixDockerSandboxClient(DockerSandboxClient):
|
||||
_apply_sandbox_network(create_kwargs)
|
||||
_apply_resource_limits(create_kwargs)
|
||||
_apply_log_limits(create_kwargs)
|
||||
_apply_run_labels(create_kwargs)
|
||||
|
||||
# Strix injection: host bind mounts (e.g. large repos passed via --mount)
|
||||
# that bypass the SDK's file-by-file LocalDir copy.
|
||||
|
||||
+42
-69
@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from litellm.exceptions import BadRequestError, ContextWindowExceededError, RateLimitError
|
||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||
|
||||
from strix.config import ContextSettings
|
||||
from strix.llm import compaction
|
||||
@@ -147,35 +146,17 @@ def _patch_budget(monkeypatch: pytest.MonkeyPatch, *, keep_tokens: int, window:
|
||||
context.auto_compact = True
|
||||
settings = SimpleNamespace(
|
||||
context=context,
|
||||
llm=SimpleNamespace(api_key=None, api_base=None, timeout=1, extra_headers=None),
|
||||
llm=SimpleNamespace(api_key=None, api_base=None, timeout=1),
|
||||
)
|
||||
monkeypatch.setattr(compaction, "load_settings", lambda: settings)
|
||||
|
||||
|
||||
def _model_response(text: str) -> Any:
|
||||
chunk = ResponseOutputText(annotations=[], text=text, type="output_text")
|
||||
message = ResponseOutputMessage(
|
||||
id="msg", content=[chunk], role="assistant", status="completed", type="message"
|
||||
)
|
||||
return SimpleNamespace(output=[message])
|
||||
def _patch_summary(monkeypatch: pytest.MonkeyPatch, text: str) -> None:
|
||||
async def fake_acompletion(**_kwargs: Any) -> Any:
|
||||
message = SimpleNamespace(content=text)
|
||||
return SimpleNamespace(choices=[SimpleNamespace(message=message)])
|
||||
|
||||
|
||||
def _patch_summary(
|
||||
monkeypatch: pytest.MonkeyPatch, text: str, captured: dict[str, Any] | None = None
|
||||
) -> None:
|
||||
class FakeModel:
|
||||
async def get_response(self, **kwargs: Any) -> Any:
|
||||
if captured is not None:
|
||||
captured.update(kwargs)
|
||||
return _model_response(text)
|
||||
|
||||
class FakeProvider:
|
||||
def get_model(self, model_name: str | None) -> Any:
|
||||
if captured is not None:
|
||||
captured["model"] = model_name
|
||||
return FakeModel()
|
||||
|
||||
monkeypatch.setattr(compaction, "StrixProvider", FakeProvider)
|
||||
monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -208,38 +189,19 @@ async def test_maybe_compact_rewrites_and_keeps_pairs(monkeypatch: pytest.Monkey
|
||||
async def test_maybe_compact_updates_previous_summary(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Window large enough to leave real room for the summary instructions.
|
||||
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
||||
captured: dict[str, Any] = {}
|
||||
_patch_summary(monkeypatch, "NEW", captured)
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
async def fake_acompletion(**kwargs: Any) -> Any:
|
||||
captured["prompt"] = kwargs["messages"][0]["content"]
|
||||
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="NEW"))])
|
||||
|
||||
monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion)
|
||||
|
||||
prior = compaction._checkpoint_item("OLD SUMMARY TEXT")
|
||||
session = FakeSession([prior, *_turns(12)])
|
||||
|
||||
assert await compaction.maybe_compact(session, model="m", force=True) is True
|
||||
assert "OLD SUMMARY TEXT" in captured["input"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summarize_routes_through_provider_with_settings(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
||||
monkeypatch.setattr(
|
||||
compaction,
|
||||
"load_settings",
|
||||
lambda: SimpleNamespace(
|
||||
llm=SimpleNamespace(
|
||||
api_key=None, api_base=None, timeout=1, extra_headers={"X-Feature-Key": "svc"}
|
||||
)
|
||||
),
|
||||
)
|
||||
captured: dict[str, Any] = {}
|
||||
_patch_summary(monkeypatch, "S", captured)
|
||||
|
||||
assert await compaction._summarize("litellm/openai/some-model", "p", 64) == "S"
|
||||
assert captured["model"] == "litellm/openai/some-model"
|
||||
settings = captured["model_settings"]
|
||||
assert settings.extra_headers == {"X-Feature-Key": "svc"}
|
||||
assert settings.max_tokens == 64
|
||||
assert "OLD SUMMARY TEXT" in captured["prompt"]
|
||||
|
||||
|
||||
def test_fit_to_tokens_truncates_oversized_text(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -271,14 +233,19 @@ def test_summary_output_tokens_capped_at_model_limit(monkeypatch: pytest.MonkeyP
|
||||
async def test_maybe_compact_bounds_summary_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# A tiny window with a huge head must not send an oversized summary request.
|
||||
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
||||
captured: dict[str, Any] = {}
|
||||
_patch_summary(monkeypatch, "S", captured)
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
async def fake_acompletion(**kwargs: Any) -> Any:
|
||||
captured["prompt"] = kwargs["messages"][0]["content"]
|
||||
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="S"))])
|
||||
|
||||
monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion)
|
||||
big_turns = [{"role": "user", "content": "y" * 2_000} for _ in range(50)]
|
||||
session = FakeSession(big_turns)
|
||||
|
||||
assert await compaction.maybe_compact(session, model="m") is True
|
||||
# count_tokens==len(chars); prompt must fit the model window.
|
||||
assert len(captured["input"]) <= 4_000
|
||||
assert len(captured["prompt"]) <= 4_000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -289,27 +256,27 @@ async def test_summary_request_fits_when_room_is_below_old_floor(
|
||||
instructions = len(compaction._SUMMARY_INSTRUCTIONS)
|
||||
window = instructions + 64 + 256 + 300 # summary_max(64)+slack(256)+room(300)
|
||||
_patch_budget(monkeypatch, keep_tokens=30, window=window)
|
||||
captured: dict[str, Any] = {}
|
||||
_patch_summary(monkeypatch, "S", captured)
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
async def fake_acompletion(**kwargs: Any) -> Any:
|
||||
captured["prompt"] = kwargs["messages"][0]["content"]
|
||||
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="S"))])
|
||||
|
||||
monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion)
|
||||
session = FakeSession([{"role": "user", "content": "y" * 5_000} for _ in range(20)])
|
||||
|
||||
assert await compaction.maybe_compact(session, model="m") is True
|
||||
assert len(captured["input"]) <= window
|
||||
assert len(captured["prompt"]) <= window
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_maybe_compact_skips_when_summary_fails(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_budget(monkeypatch, keep_tokens=30, window=4_000)
|
||||
|
||||
class BoomModel:
|
||||
async def get_response(self, **_kwargs: Any) -> Any:
|
||||
raise RuntimeError("boom")
|
||||
async def fake_acompletion(**_kwargs: Any) -> Any:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
class BoomProvider:
|
||||
def get_model(self, _model_name: str | None) -> Any:
|
||||
return BoomModel()
|
||||
|
||||
monkeypatch.setattr(compaction, "StrixProvider", BoomProvider)
|
||||
monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion)
|
||||
session = FakeSession(_turns(12))
|
||||
before = await session.get_items()
|
||||
|
||||
@@ -323,11 +290,17 @@ async def test_maybe_compact_skips_when_no_room_to_summarise(
|
||||
) -> None:
|
||||
# No room for any head -> no (doomed) summary is attempted.
|
||||
_patch_budget(monkeypatch, keep_tokens=30, window=200)
|
||||
captured: dict[str, Any] = {}
|
||||
_patch_summary(monkeypatch, "S", captured)
|
||||
called = False
|
||||
|
||||
async def fake_acompletion(**_kwargs: Any) -> Any:
|
||||
nonlocal called
|
||||
called = True
|
||||
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="S"))])
|
||||
|
||||
monkeypatch.setattr("strix.llm.compaction.litellm.acompletion", fake_acompletion)
|
||||
session = FakeSession(_turns(12))
|
||||
before = await session.get_items()
|
||||
|
||||
assert await compaction.maybe_compact(session, model="m", force=True) is False
|
||||
assert not captured
|
||||
assert called is False
|
||||
assert await session.get_items() == before
|
||||
|
||||
@@ -21,24 +21,6 @@ def test_context_window_strips_provider_prefix() -> None:
|
||||
assert context_budget.context_window("openai/gpt-4o") == 128_000
|
||||
|
||||
|
||||
def test_context_window_chatgpt_prefix_skips_provider_auth(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
context_budget._model_info.cache_clear()
|
||||
calls: list[str] = []
|
||||
|
||||
def _model_info(model: str) -> dict[str, int]:
|
||||
calls.append(model)
|
||||
return {"max_input_tokens": 1_050_000, "max_output_tokens": 128_000}
|
||||
|
||||
monkeypatch.setattr("strix.llm.context_budget.litellm.get_model_info", _model_info)
|
||||
try:
|
||||
assert context_budget.context_window("chatgpt/gpt-5.6-luna") == 1_050_000
|
||||
assert calls == ["gpt-5.6-luna"]
|
||||
finally:
|
||||
context_budget._model_info.cache_clear()
|
||||
|
||||
|
||||
def test_context_window_unmapped_uses_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
context_budget._model_info.cache_clear()
|
||||
|
||||
|
||||
@@ -44,38 +44,6 @@ def test_dedupe_endpoint_sent_per_call() -> None:
|
||||
assert (settings.extra_args or {})["api_key"] == "dedupe-key"
|
||||
|
||||
|
||||
def test_dedicated_dedupe_model_uses_own_headers_not_main() -> None:
|
||||
dedupe = DedupeSettings(
|
||||
STRIX_DEDUPE_MODEL="deepseek/cheap",
|
||||
DEDUPE_LLM_EXTRA_HEADERS={"X-Dedupe": "yes"},
|
||||
)
|
||||
settings = _dedupe_model_settings(dedupe, "deepseek/cheap", 300)
|
||||
assert settings.extra_headers == {"X-Dedupe": "yes"}
|
||||
|
||||
|
||||
def test_dedicated_dedupe_model_gets_no_main_headers_by_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps({"X-Main": "secret"}))
|
||||
loader._cached = None
|
||||
try:
|
||||
dedupe = DedupeSettings(STRIX_DEDUPE_MODEL="deepseek/cheap")
|
||||
settings = _dedupe_model_settings(dedupe, "deepseek/cheap", 300)
|
||||
assert settings.extra_headers is None
|
||||
finally:
|
||||
loader._cached = None
|
||||
|
||||
|
||||
def test_fallback_dedupe_inherits_main_headers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps({"X-Main": "svc"}))
|
||||
loader._cached = None
|
||||
try:
|
||||
settings = _dedupe_model_settings(DedupeSettings(), "openai/main-model", 300)
|
||||
assert settings.extra_headers == {"X-Main": "svc"}
|
||||
finally:
|
||||
loader._cached = None
|
||||
|
||||
|
||||
def test_dedupe_defaults_are_empty() -> None:
|
||||
settings = DedupeSettings()
|
||||
assert settings.model is None
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
"""Tests for LLM_DISABLE_STREAMING: serve the streamed run loop without SSE.
|
||||
|
||||
A gateway that rejects ``stream:true`` (or delivers SSE unreliably) breaks the
|
||||
SDK run loop, which only issues streamed requests. ``_NonStreamingModel`` wraps
|
||||
the resolved model so each turn makes one non-streaming ``get_response`` and
|
||||
replays the completed result as a single terminal stream event. A local server
|
||||
that rejects streamed requests but answers non-streamed ones — including a
|
||||
structured tool call — proves the wrapper works where the stock model fails.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from agents import Agent, Runner, function_tool
|
||||
from agents.model_settings import ModelSettings
|
||||
from agents.models.interface import Model, ModelProvider, ModelTracing
|
||||
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
|
||||
from agents.run import RunConfig
|
||||
from openai import AsyncOpenAI, BadRequestError
|
||||
from openai.types.responses import (
|
||||
ResponseCompletedEvent,
|
||||
ResponseFunctionToolCall,
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputText,
|
||||
)
|
||||
|
||||
from strix.config import codex, loader
|
||||
from strix.config.loader import load_settings
|
||||
from strix.config.models import StrixProvider, _NonStreamingModel
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
|
||||
|
||||
def _tool_call_completion() -> dict[str, Any]:
|
||||
return {
|
||||
"id": "chatcmpl-1",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "gw-model",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"finish_reason": "tool_calls",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "do_thing", "arguments": '{"n": 1}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7},
|
||||
}
|
||||
|
||||
|
||||
def _text_completion() -> dict[str, Any]:
|
||||
return {
|
||||
"id": "chatcmpl-2",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "gw-model",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"finish_reason": "stop",
|
||||
"message": {"role": "assistant", "content": "hello from gateway"},
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
|
||||
}
|
||||
|
||||
|
||||
_CAPTURED: dict[str, Any] = {}
|
||||
_PAYLOAD: dict[str, dict[str, Any]] = {"value": _tool_call_completion()}
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
"""A gateway that only speaks non-streaming Chat Completions."""
|
||||
|
||||
def log_message(self, *args: Any) -> None:
|
||||
pass
|
||||
|
||||
def do_POST(self) -> None:
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
_CAPTURED.clear()
|
||||
_CAPTURED.update(body)
|
||||
if body.get("stream"):
|
||||
payload = json.dumps(
|
||||
{"error": {"message": "streaming is not supported by this endpoint"}}
|
||||
).encode()
|
||||
self.send_response(400)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
return
|
||||
payload = json.dumps(_PAYLOAD["value"]).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gateway_url() -> Iterator[str]:
|
||||
_PAYLOAD["value"] = _tool_call_completion()
|
||||
server = HTTPServer(("127.0.0.1", 0), _Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_address[1]}/v1"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _model(base_url: str) -> OpenAIChatCompletionsModel:
|
||||
client = AsyncOpenAI(api_key="tok", base_url=base_url)
|
||||
return OpenAIChatCompletionsModel(model="gw-model", openai_client=client)
|
||||
|
||||
|
||||
def _call_kwargs() -> dict[str, Any]:
|
||||
return {
|
||||
"system_instructions": "s",
|
||||
"input": "hi",
|
||||
"model_settings": ModelSettings(),
|
||||
"tools": [],
|
||||
"output_schema": None,
|
||||
"handoffs": [],
|
||||
"tracing": ModelTracing.DISABLED,
|
||||
"previous_response_id": None,
|
||||
"conversation_id": None,
|
||||
"prompt": None,
|
||||
}
|
||||
|
||||
|
||||
async def _drain(gen: AsyncIterator[Any]) -> list[Any]:
|
||||
return [event async for event in gen]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stock_model_streaming_fails_on_non_streaming_gateway(gateway_url: str) -> None:
|
||||
# The stock model issues stream:true and the gateway rejects it.
|
||||
model = _model(gateway_url)
|
||||
with pytest.raises(BadRequestError, match="streaming is not supported"):
|
||||
await _drain(model.stream_response(**_call_kwargs()))
|
||||
assert _CAPTURED["stream"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapper_streams_tool_call_without_streaming_request(gateway_url: str) -> None:
|
||||
# The wrapper turns the streamed run-loop call into one non-streaming
|
||||
# request and replays the completed result as a terminal stream event.
|
||||
model = _NonStreamingModel(_model(gateway_url))
|
||||
events = await _drain(model.stream_response(**_call_kwargs()))
|
||||
|
||||
assert _CAPTURED.get("stream") is not True
|
||||
assert len(events) == 1
|
||||
completed = events[0]
|
||||
assert isinstance(completed, ResponseCompletedEvent)
|
||||
|
||||
tool_call = completed.response.output[0]
|
||||
assert isinstance(tool_call, ResponseFunctionToolCall)
|
||||
assert tool_call.name == "do_thing"
|
||||
assert json.loads(tool_call.arguments) == {"n": 1}
|
||||
|
||||
assert completed.response.usage is not None
|
||||
assert completed.response.usage.total_tokens == 7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapper_streams_plain_text(gateway_url: str) -> None:
|
||||
_PAYLOAD["value"] = _text_completion()
|
||||
model = _NonStreamingModel(_model(gateway_url))
|
||||
events = await _drain(model.stream_response(**_call_kwargs()))
|
||||
|
||||
assert _CAPTURED.get("stream") is not True
|
||||
message = events[0].response.output[0]
|
||||
assert isinstance(message, ResponseOutputMessage)
|
||||
text = message.content[0]
|
||||
assert isinstance(text, ResponseOutputText)
|
||||
assert text.text == "hello from gateway"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapper_get_response_stays_non_streaming(gateway_url: str) -> None:
|
||||
# The non-streaming path is a plain pass-through to the inner model.
|
||||
model = _NonStreamingModel(_model(gateway_url))
|
||||
response = await model.get_response(**_call_kwargs())
|
||||
assert _CAPTURED.get("stream") is not True
|
||||
tool_call = response.output[0]
|
||||
assert isinstance(tool_call, ResponseFunctionToolCall)
|
||||
assert tool_call.name == "do_thing"
|
||||
|
||||
|
||||
_TURN_STREAM_FLAGS: list[bool] = []
|
||||
|
||||
|
||||
class _MultiTurnHandler(BaseHTTPRequestHandler):
|
||||
"""Non-streaming gateway: a tool call on turn 1, a final answer on turn 2."""
|
||||
|
||||
def log_message(self, *args: Any) -> None:
|
||||
pass
|
||||
|
||||
def do_POST(self) -> None:
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
_TURN_STREAM_FLAGS.append(bool(body.get("stream")))
|
||||
completion = _tool_call_completion() if len(_TURN_STREAM_FLAGS) == 1 else _text_completion()
|
||||
if len(_TURN_STREAM_FLAGS) > 1:
|
||||
completion["choices"][0]["message"]["content"] = "all done"
|
||||
payload = json.dumps(completion).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multiturn_url() -> Iterator[str]:
|
||||
_TURN_STREAM_FLAGS.clear()
|
||||
server = HTTPServer(("127.0.0.1", 0), _MultiTurnHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_address[1]}/v1"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_loop_executes_tool_and_completes_without_streaming(multiturn_url: str) -> None:
|
||||
# The whole streamed agent loop runs against a non-streaming gateway: the
|
||||
# synthetic terminal event feeds the runner, which executes the tool and
|
||||
# continues the turn until a final answer.
|
||||
calls: list[int] = []
|
||||
|
||||
@function_tool
|
||||
def do_thing(n: int) -> str:
|
||||
calls.append(n)
|
||||
return f"did {n}"
|
||||
|
||||
class _Provider(ModelProvider):
|
||||
def get_model(self, model_name: str | None) -> Model: # noqa: ARG002
|
||||
return _NonStreamingModel(_model(multiturn_url))
|
||||
|
||||
agent = Agent(name="t", instructions="use the tool", tools=[do_thing], model="gw-model")
|
||||
result = Runner.run_streamed(
|
||||
agent, input="please", run_config=RunConfig(model_provider=_Provider())
|
||||
)
|
||||
async for _ in result.stream_events():
|
||||
pass
|
||||
|
||||
assert calls == [1] # tool executed with the streamed tool-call args
|
||||
assert result.final_output == "all done"
|
||||
assert len(_TURN_STREAM_FLAGS) == 2 # two turns, both...
|
||||
assert not any(_TURN_STREAM_FLAGS) # ...issued as non-streaming requests
|
||||
|
||||
|
||||
class _DummyModel(Model):
|
||||
async def get_response(self, *args: Any, **kwargs: Any) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
def stream_response(self, *args: Any, **kwargs: Any) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _reset_settings(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
for key in ("STRIX_LLM", "LLM_DISABLE_STREAMING"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setattr(loader, "_cached", None)
|
||||
monkeypatch.setattr(loader, "_override", None)
|
||||
yield
|
||||
|
||||
|
||||
def test_get_model_wraps_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch, _reset_settings: None
|
||||
) -> None:
|
||||
inner = _DummyModel()
|
||||
monkeypatch.setattr("strix.config.models.MultiProvider.get_model", lambda *_: inner)
|
||||
monkeypatch.setenv("LLM_DISABLE_STREAMING", "true")
|
||||
load_settings()
|
||||
|
||||
model = StrixProvider().get_model("openai/gpt-4o-mini")
|
||||
assert isinstance(model, _NonStreamingModel)
|
||||
|
||||
|
||||
def test_get_model_unwrapped_by_default(
|
||||
monkeypatch: pytest.MonkeyPatch, _reset_settings: None
|
||||
) -> None:
|
||||
inner = _DummyModel()
|
||||
monkeypatch.setattr("strix.config.models.MultiProvider.get_model", lambda *_: inner)
|
||||
load_settings()
|
||||
|
||||
model = StrixProvider().get_model("openai/gpt-4o-mini")
|
||||
assert model is inner
|
||||
|
||||
|
||||
def test_get_model_does_not_wrap_subscription_model(
|
||||
monkeypatch: pytest.MonkeyPatch, _reset_settings: None
|
||||
) -> None:
|
||||
# Subscription (ChatGPT) models are always streamed and must not be wrapped.
|
||||
monkeypatch.setattr(codex, "subscription_model", lambda *_: "gpt-5.5")
|
||||
monkeypatch.setattr(codex, "get_subscription_client", lambda: AsyncOpenAI(api_key="x"))
|
||||
monkeypatch.setenv("LLM_DISABLE_STREAMING", "true")
|
||||
load_settings()
|
||||
|
||||
model = StrixProvider().get_model("gpt-5.5")
|
||||
assert not isinstance(model, _NonStreamingModel)
|
||||
@@ -9,10 +9,8 @@ from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from agents.items import MessageOutputItem
|
||||
from agents.memory import SQLiteSession
|
||||
from agents.tool_context import ToolContext
|
||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputRefusal
|
||||
|
||||
from strix.config import codex
|
||||
from strix.core import execution
|
||||
@@ -26,30 +24,6 @@ from strix.core.execution import (
|
||||
from strix.tools.finish.tool import finish_scan
|
||||
|
||||
|
||||
class _StructuredRefusalStream:
|
||||
def __init__(self, refusal: str) -> None:
|
||||
self.run_loop_exception: BaseException | None = None
|
||||
self.new_items = [
|
||||
MessageOutputItem(
|
||||
agent=MagicMock(),
|
||||
raw_item=ResponseOutputMessage(
|
||||
id="msg-refusal",
|
||||
content=[ResponseOutputRefusal(type="refusal", refusal=refusal)],
|
||||
role="assistant",
|
||||
status="completed",
|
||||
type="message",
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
async def stream_events(self) -> Any:
|
||||
if False:
|
||||
yield None
|
||||
|
||||
def cancel(self, mode: str = "immediate") -> None: # noqa: ARG002
|
||||
return
|
||||
|
||||
|
||||
async def _call_finish_scan(
|
||||
coordinator: AgentCoordinator, agent_id: str, parent_id: str | None
|
||||
) -> dict[str, Any]:
|
||||
@@ -570,70 +544,6 @@ async def test_guardrail_noninteractive_fails_only_blocked_agent(tmp_path: Any)
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_structured_provider_refusal_fails_interactive_agent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
refusal = "This request was blocked under the provider's usage policy."
|
||||
stream = _StructuredRefusalStream(refusal)
|
||||
monkeypatch.setattr(execution.Runner, "run_streamed", lambda *_args, **_kwargs: stream)
|
||||
coordinator = AgentCoordinator()
|
||||
await coordinator.register("root", "strix", parent_id=None)
|
||||
|
||||
result = await execution._run_cycle(
|
||||
MagicMock(),
|
||||
coordinator,
|
||||
"root",
|
||||
input_data="task",
|
||||
run_config=MagicMock(),
|
||||
context={},
|
||||
max_turns=5,
|
||||
session=None,
|
||||
interactive=True,
|
||||
event_sink=None,
|
||||
hooks=None,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert coordinator.statuses["root"] == "failed"
|
||||
assert coordinator.errors["root"] == refusal
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_structured_provider_refusal_fails_noninteractive_child(
|
||||
tmp_path: Any,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
refusal = "This request was blocked under the provider's usage policy."
|
||||
stream = _StructuredRefusalStream(refusal)
|
||||
monkeypatch.setattr(execution.Runner, "run_streamed", lambda *_args, **_kwargs: stream)
|
||||
coordinator = AgentCoordinator()
|
||||
await coordinator.register("root", "strix", parent_id=None)
|
||||
await coordinator.register("child", "recon", parent_id="root")
|
||||
session = SQLiteSession("root", tmp_path / "agents.db")
|
||||
await coordinator.attach_runtime("root", session=session)
|
||||
|
||||
result = await execution._run_cycle(
|
||||
MagicMock(),
|
||||
coordinator,
|
||||
"child",
|
||||
input_data="task",
|
||||
run_config=MagicMock(),
|
||||
context={"parent_id": "root"},
|
||||
max_turns=5,
|
||||
session=None,
|
||||
interactive=False,
|
||||
event_sink=None,
|
||||
hooks=None,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert coordinator.statuses["child"] == "failed"
|
||||
assert coordinator.errors["child"] == refusal
|
||||
assert coordinator.pending_counts.get("root", 0) > 0
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_revives_guardrail_parked_child_but_not_plain_waiting(
|
||||
tmp_path: Any, monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
+38
-24
@@ -255,6 +255,44 @@ def test_make_model_settings_forces_required_for_anyllm_routed_openai_model() ->
|
||||
assert settings.tool_choice == "required"
|
||||
|
||||
|
||||
def test_make_model_settings_auto_forces_required_on_custom_openai_endpoint() -> None:
|
||||
# GLM / Kimi via cortecs: openai/-routed reasoning model on a custom base.
|
||||
settings = make_model_settings(
|
||||
None,
|
||||
model_name="openai/glm-5.2",
|
||||
custom_api_base=True,
|
||||
)
|
||||
|
||||
assert settings.tool_choice == "required"
|
||||
|
||||
|
||||
def test_make_model_settings_auto_skips_required_without_custom_endpoint() -> None:
|
||||
settings = make_model_settings(None, model_name="openai/glm-5.2")
|
||||
|
||||
assert settings.tool_choice is None
|
||||
|
||||
|
||||
def test_make_model_settings_explicit_false_overrides_custom_endpoint() -> None:
|
||||
settings = make_model_settings(
|
||||
None,
|
||||
model_name="openai/glm-5.2",
|
||||
force_required_tool_choice=False,
|
||||
custom_api_base=True,
|
||||
)
|
||||
|
||||
assert settings.tool_choice is None
|
||||
|
||||
|
||||
def test_make_model_settings_auto_skips_required_for_non_openai_custom_endpoint() -> None:
|
||||
settings = make_model_settings(
|
||||
None,
|
||||
model_name="anthropic/claude-3-7-sonnet-latest",
|
||||
custom_api_base=True,
|
||||
)
|
||||
|
||||
assert settings.tool_choice is None
|
||||
|
||||
|
||||
def test_make_model_settings_sets_request_timeout() -> None:
|
||||
settings = make_model_settings(
|
||||
"none",
|
||||
@@ -272,30 +310,6 @@ def test_make_model_settings_omits_timeout_when_unset() -> None:
|
||||
assert settings.extra_args is None
|
||||
|
||||
|
||||
def test_make_model_settings_sets_extra_headers() -> None:
|
||||
settings = make_model_settings(
|
||||
"none",
|
||||
model_name="openai/some-model",
|
||||
extra_headers={"X-Feature-Key": "svc", "X-Tenant": "acme"},
|
||||
)
|
||||
|
||||
assert settings.extra_headers == {"X-Feature-Key": "svc", "X-Tenant": "acme"}
|
||||
|
||||
|
||||
def test_make_model_settings_omits_extra_headers_when_unset() -> None:
|
||||
assert make_model_settings("none", model_name="gpt-4o").extra_headers is None
|
||||
|
||||
|
||||
def test_make_model_settings_extra_headers_survive_reasoning_resolve() -> None:
|
||||
settings = make_model_settings(
|
||||
"high",
|
||||
model_name="openai/o3",
|
||||
extra_headers={"X-Feature-Key": "svc"},
|
||||
)
|
||||
|
||||
assert settings.extra_headers == {"X-Feature-Key": "svc"}
|
||||
|
||||
|
||||
def test_make_model_settings_timeout_survives_reasoning_resolve() -> None:
|
||||
# Reasoning is resolved via ModelSettings.resolve(); the timeout in extra_args
|
||||
# must not be dropped when a reasoning override is merged in.
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Tests for LLM_EXTRA_HEADERS: custom default headers on OpenAI-compatible endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import litellm
|
||||
import pytest
|
||||
from agents.models import _openai_shared
|
||||
|
||||
from strix.config import loader
|
||||
from strix.config.loader import load_settings
|
||||
from strix.config.models import configure_sdk_model_defaults
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
_ENV_KEYS = ["STRIX_LLM", "LLM_API_KEY", "LLM_API_BASE", "LLM_EXTRA_HEADERS"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
for key in _ENV_KEYS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setattr(loader, "_cached", None)
|
||||
monkeypatch.setattr(loader, "_override", None)
|
||||
|
||||
saved_headers = litellm.headers
|
||||
saved_client = _openai_shared.get_default_openai_client()
|
||||
litellm.headers = None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
litellm.headers = saved_headers
|
||||
_openai_shared.set_default_openai_client(saved_client) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_extra_headers_parsed_from_json_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps({"X-A": "1", "X-B": "2"}))
|
||||
settings = load_settings()
|
||||
assert settings.llm.extra_headers == {"X-A": "1", "X-B": "2"}
|
||||
|
||||
|
||||
def test_extra_headers_merged_into_litellm_headers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("STRIX_LLM", "litellm/openai/some-model")
|
||||
monkeypatch.setenv("LLM_API_BASE", "https://gateway.example/v1")
|
||||
monkeypatch.setenv("LLM_API_KEY", "token")
|
||||
headers = {"X-Feature-Key": "svc", "X-Tenant": "acme"}
|
||||
monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps(headers))
|
||||
|
||||
configure_sdk_model_defaults(load_settings())
|
||||
|
||||
current: object = litellm.headers
|
||||
assert isinstance(current, dict)
|
||||
assert current["X-Feature-Key"] == "svc"
|
||||
assert current["X-Tenant"] == "acme"
|
||||
|
||||
|
||||
def test_extra_headers_applied_to_native_openai_client(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("STRIX_LLM", "openai/some-model")
|
||||
monkeypatch.setenv("LLM_API_BASE", "https://gateway.example/v1")
|
||||
monkeypatch.setenv("LLM_API_KEY", "token")
|
||||
monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps({"X-Feature-Key": "svc"}))
|
||||
|
||||
configure_sdk_model_defaults(load_settings())
|
||||
|
||||
client = _openai_shared.get_default_openai_client()
|
||||
assert client is not None
|
||||
assert client.default_headers.get("X-Feature-Key") == "svc"
|
||||
assert str(client.base_url).rstrip("/") == "https://gateway.example/v1"
|
||||
|
||||
|
||||
def test_extra_headers_applied_to_native_openai_without_custom_base(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("STRIX_LLM", "openai/gpt-5")
|
||||
monkeypatch.setenv("LLM_API_KEY", "token")
|
||||
monkeypatch.setenv("LLM_EXTRA_HEADERS", json.dumps({"X-Feature-Key": "svc"}))
|
||||
|
||||
configure_sdk_model_defaults(load_settings())
|
||||
|
||||
client = _openai_shared.get_default_openai_client()
|
||||
assert client is not None
|
||||
assert client.default_headers.get("X-Feature-Key") == "svc"
|
||||
|
||||
|
||||
def test_no_extra_headers_leaves_litellm_headers_untouched(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("STRIX_LLM", "openai/some-model")
|
||||
monkeypatch.setenv("LLM_API_BASE", "https://gateway.example/v1")
|
||||
monkeypatch.setenv("LLM_API_KEY", "token")
|
||||
|
||||
configure_sdk_model_defaults(load_settings())
|
||||
|
||||
assert litellm.headers is None
|
||||
@@ -38,9 +38,9 @@ async def test_persistent_rate_limit_stops_gracefully(
|
||||
model="openai/gpt-4o",
|
||||
reasoning_effort="high",
|
||||
force_required_tool_choice=False,
|
||||
api_base=None,
|
||||
timeout=300,
|
||||
prompt_cache=True,
|
||||
extra_headers=None,
|
||||
),
|
||||
runtime=types.SimpleNamespace(max_context_images=3),
|
||||
)
|
||||
|
||||
@@ -46,9 +46,9 @@ def _patch_engine_scaffold(
|
||||
model="openai/gpt-4o",
|
||||
reasoning_effort="high",
|
||||
force_required_tool_choice=False,
|
||||
api_base=None,
|
||||
timeout=300,
|
||||
prompt_cache=True,
|
||||
extra_headers=None,
|
||||
),
|
||||
runtime=types.SimpleNamespace(max_context_images=3),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user