mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
Compare commits
5 commits
f9cca1ec8f
...
12bd6fbff7
| Author | SHA1 | Date | |
|---|---|---|---|
| 12bd6fbff7 | |||
|
|
f87847baa4 | ||
|
|
65cf903583 | ||
| 184bb6802f | |||
|
|
c2bc7720cd |
30 changed files with 1340 additions and 178 deletions
|
|
@ -193,6 +193,25 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
return create_deep_agent(model=model, ...)
|
||||
```
|
||||
|
||||
### Routing through the LangSmith LLM Gateway
|
||||
|
||||
Model calls can be proxied through the [LangSmith LLM Gateway](https://docs.langchain.com/langsmith/llm-gateway) (private beta) instead of hitting providers directly. The gateway authenticates with a **LangSmith API key** that has the `gateway:invoke` permission and resolves the real provider key from workspace Provider Secrets, so no provider API keys are needed at runtime — and it adds central spend limits, PII/secrets redaction, and tracing. Your org must have the gateway enabled with Provider Secrets configured.
|
||||
|
||||
Routing is opt-in and off by default. Enable it either way:
|
||||
|
||||
| Env var | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `LANGSMITH_GATEWAY_ENABLED` | `false` | Deployment-level default for gateway routing. |
|
||||
| `LANGSMITH_GATEWAY_API_KEY` | unset | Optional dedicated LangSmith key for Gateway calls. Prefer this in LangGraph Cloud if the platform-provided `LANGSMITH_API_KEY` lacks `gateway:invoke`. Falls back to `LANGSMITH_API_KEY_PROD`, then `LANGSMITH_API_KEY`. |
|
||||
| `LANGSMITH_GATEWAY_BASE_URL` | `https://gateway.smith.langchain.com` | Override for a regional or self-hosted gateway host. |
|
||||
| `LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES` | `true` | Use the OpenAI Responses API through the gateway. Set to `false` only to force Chat Completions for OpenAI models. |
|
||||
|
||||
The admin panel (**Admin → LLM Gateway**) exposes a per-workspace toggle stored in team settings; when set it overrides the `LANGSMITH_GATEWAY_ENABLED` env default (a `None`/unset team value inherits the env default).
|
||||
|
||||
Routing is applied centrally in `make_model` (`agent/utils/model.py`), which resolves the effective on/off and delegates URL/key wiring to `agent/utils/gateway.py`. **OpenAI, Anthropic, Fireworks, and Google Gemini** are routed (their LangChain integrations accept `base_url` + `api_key`); Google Vertex (service-account auth) and any other provider call the provider directly with a logged warning.
|
||||
|
||||
**Caveat — OpenAI endpoint:** open-swe uses the OpenAI Responses API by default because OpenAI reasoning models with function tools reject `reasoning_effort` on Chat Completions. Direct OpenAI calls use a `wss://` base URL; gateway-routed OpenAI uses the HTTPS gateway base URL with Responses enabled. Set `LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES=false` only if you need to force Chat Completions. Anthropic and Fireworks are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tools
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from deepagents.backends.protocol import SandboxBackendProtocol
|
|||
from deepagents.backends.state import StateBackend
|
||||
from langchain.agents.middleware import ModelCallLimitMiddleware
|
||||
|
||||
from .dashboard.team_settings import get_effective_gateway_enabled
|
||||
from .integrations.langsmith import _configure_github_proxy
|
||||
from .middleware import (
|
||||
SanitizeToolInputsMiddleware,
|
||||
|
|
@ -46,8 +47,8 @@ from .server import (
|
|||
from .tools.read_finding_outcomes import read_finding_outcomes
|
||||
from .tools.save_review_style import save_review_style_prompt
|
||||
from .utils.analyzer_skills import SKILLS_ROUTE, skill_path_for_mode
|
||||
from .utils.github_app import get_github_app_installation_token
|
||||
from .utils.deferred_model import make_model_or_defer
|
||||
from .utils.github_app import get_github_app_installation_token
|
||||
from .utils.model import DEFAULT_LLM_REASONING, provider_model_kwargs
|
||||
from .utils.sandbox_paths import aresolve_sandbox_work_dir
|
||||
from .utils.sandbox_state import unwrap_sandbox_backend
|
||||
|
|
@ -121,6 +122,7 @@ async def get_analyzer(config: RunnableConfig) -> Pregel:
|
|||
backend = CompositeBackend(default=sandbox_backend, routes={SKILLS_ROUTE: StateBackend()})
|
||||
|
||||
model_id = DEFAULT_LLM_MODEL_ID
|
||||
use_gateway = await get_effective_gateway_enabled()
|
||||
model_kwargs = provider_model_kwargs(
|
||||
model_id,
|
||||
None,
|
||||
|
|
@ -140,7 +142,7 @@ async def get_analyzer(config: RunnableConfig) -> Pregel:
|
|||
system_prompt = f"{system_prompt}\n\n{user_context}"
|
||||
|
||||
return create_deep_agent(
|
||||
model=make_model_or_defer(model_id, **model_kwargs),
|
||||
model=make_model_or_defer(model_id, use_gateway=use_gateway, **model_kwargs),
|
||||
system_prompt=system_prompt,
|
||||
tools=[save_review_style_prompt, read_finding_outcomes],
|
||||
backend=backend,
|
||||
|
|
|
|||
|
|
@ -29,9 +29,11 @@ from deepagents import create_deep_agent
|
|||
from langchain.agents.middleware import ModelCallLimitMiddleware
|
||||
|
||||
from .dashboard.options import SUPPORTED_MODEL_IDS, model_supports_effort
|
||||
from .dashboard.team_settings import get_team_default_model
|
||||
from .dashboard.team_settings import get_effective_gateway_enabled, get_team_default_model
|
||||
from .middleware import (
|
||||
ExcludeToolsMiddleware,
|
||||
SanitizeFireworksMessagesMiddleware,
|
||||
SanitizeOpenAIResponsesMiddleware,
|
||||
SanitizeThinkingBlocksMiddleware,
|
||||
SanitizeToolInputsMiddleware,
|
||||
ToolErrorMiddleware,
|
||||
|
|
@ -123,6 +125,7 @@ async def get_chat_agent(config: RunnableConfig) -> Pregel:
|
|||
configurable["chat_github_token"] = token
|
||||
|
||||
model_id, effort = await _resolve_chat_model(configurable)
|
||||
use_gateway = await get_effective_gateway_enabled()
|
||||
model_kwargs = provider_model_kwargs(
|
||||
model_id,
|
||||
effort,
|
||||
|
|
@ -137,7 +140,7 @@ async def get_chat_agent(config: RunnableConfig) -> Pregel:
|
|||
)
|
||||
|
||||
return create_deep_agent(
|
||||
model=make_model(model_id, **model_kwargs),
|
||||
model=make_model(model_id, use_gateway=use_gateway, **model_kwargs),
|
||||
system_prompt=system_prompt,
|
||||
tools=[
|
||||
read_repo_file,
|
||||
|
|
@ -151,6 +154,8 @@ async def get_chat_agent(config: RunnableConfig) -> Pregel:
|
|||
ModelCallLimitMiddleware(run_limit=CHAT_MODEL_CALL_LIMIT, exit_behavior="end"),
|
||||
ToolErrorMiddleware(),
|
||||
ExcludeToolsMiddleware(excluded=_EXCLUDED_TOOLS),
|
||||
SanitizeOpenAIResponsesMiddleware(),
|
||||
SanitizeFireworksMessagesMiddleware(),
|
||||
SanitizeThinkingBlocksMiddleware(),
|
||||
],
|
||||
).with_config(config)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from typing import Any, Literal
|
|||
from langgraph_sdk import get_client
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
from ..utils.gateway import resolve_gateway_enabled
|
||||
from .options import (
|
||||
SUPPORTED_MODEL_IDS,
|
||||
default_model_pair,
|
||||
|
|
@ -52,6 +53,9 @@ class TeamSettingsUpdate(BaseModel):
|
|||
review_draft_prs: bool = False
|
||||
pr_summaries: bool = True
|
||||
review_trace_links: bool = True
|
||||
# Tri-state LLM Gateway toggle: True/False is authoritative, None inherits the
|
||||
# LANGSMITH_GATEWAY_ENABLED deployment default.
|
||||
gateway_enabled: bool | None = None
|
||||
review_tracing_project: str | None = None
|
||||
org_guidelines: str | None = None
|
||||
default_agent_model: str | None = None
|
||||
|
|
@ -166,6 +170,7 @@ def _default_settings() -> dict[str, Any]:
|
|||
"review_draft_prs": False,
|
||||
"pr_summaries": True,
|
||||
"review_trace_links": True,
|
||||
"gateway_enabled": None,
|
||||
"review_tracing_project": None,
|
||||
"org_guidelines": DEFAULT_ORG_REVIEW_GUIDELINES,
|
||||
"default_agent_model": fallback_model,
|
||||
|
|
@ -220,6 +225,7 @@ async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]:
|
|||
"review_draft_prs": update.review_draft_prs,
|
||||
"pr_summaries": update.pr_summaries,
|
||||
"review_trace_links": update.review_trace_links,
|
||||
"gateway_enabled": update.gateway_enabled,
|
||||
"review_tracing_project": update.review_tracing_project,
|
||||
"org_guidelines": update.org_guidelines,
|
||||
"default_agent_model": update.default_agent_model,
|
||||
|
|
@ -340,6 +346,18 @@ async def get_team_review_trace_links_enabled() -> bool:
|
|||
return bool(settings.get("review_trace_links", True))
|
||||
|
||||
|
||||
async def get_team_gateway_enabled() -> bool | None:
|
||||
"""Return the stored LLM Gateway toggle (``None`` means inherit the env default)."""
|
||||
settings = await get_team_settings()
|
||||
value = settings.get("gateway_enabled")
|
||||
return value if isinstance(value, bool) else None
|
||||
|
||||
|
||||
async def get_effective_gateway_enabled() -> bool:
|
||||
"""Resolve whether LLM Gateway routing is on: team setting, else env default."""
|
||||
return resolve_gateway_enabled(await get_team_gateway_enabled())
|
||||
|
||||
|
||||
async def get_team_review_tracing_project() -> str | None:
|
||||
"""Return the LangSmith tracing project used for PR trace resolution."""
|
||||
settings = await get_team_settings()
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ _MIDDLEWARE_MODULES = {
|
|||
"notify_step_limit_reached": ".notify_step_limit",
|
||||
"PlanModeMiddleware": ".plan_mode",
|
||||
"refresh_github_proxy_before_model": ".refresh_github_proxy",
|
||||
"RepairOrphanedToolCallsMiddleware": ".repair_orphaned_tool_calls",
|
||||
"SlackAssistantStatusMiddleware": ".refresh_slack_status",
|
||||
"SandboxCircuitBreakerMiddleware": ".sandbox_circuit_breaker",
|
||||
"SanitizeFireworksMessagesMiddleware": ".sanitize_fireworks_messages",
|
||||
"SanitizeOpenAIResponsesMiddleware": ".sanitize_openai_responses",
|
||||
"SanitizeThinkingBlocksMiddleware": ".sanitize_thinking_blocks",
|
||||
"SanitizeToolInputsMiddleware": ".sanitize_tool_inputs",
|
||||
"settle_review_check_on_exit": ".settle_review_check",
|
||||
|
|
@ -28,6 +31,9 @@ __all__ = [
|
|||
"ExcludeToolsMiddleware",
|
||||
"ModelFallbackMiddleware",
|
||||
"PlanModeMiddleware",
|
||||
"RepairOrphanedToolCallsMiddleware",
|
||||
"SanitizeFireworksMessagesMiddleware",
|
||||
"SanitizeOpenAIResponsesMiddleware",
|
||||
"SanitizeThinkingBlocksMiddleware",
|
||||
"SanitizeToolInputsMiddleware",
|
||||
"SubdirAgentsReadMiddleware",
|
||||
|
|
@ -55,7 +61,10 @@ if TYPE_CHECKING:
|
|||
from .plan_mode import PlanModeMiddleware
|
||||
from .refresh_github_proxy import refresh_github_proxy_before_model
|
||||
from .refresh_slack_status import SlackAssistantStatusMiddleware
|
||||
from .repair_orphaned_tool_calls import RepairOrphanedToolCallsMiddleware
|
||||
from .sandbox_circuit_breaker import SandboxCircuitBreakerMiddleware
|
||||
from .sanitize_fireworks_messages import SanitizeFireworksMessagesMiddleware
|
||||
from .sanitize_openai_responses import SanitizeOpenAIResponsesMiddleware
|
||||
from .sanitize_thinking_blocks import SanitizeThinkingBlocksMiddleware
|
||||
from .sanitize_tool_inputs import SanitizeToolInputsMiddleware
|
||||
from .settle_review_check import settle_review_check_on_exit
|
||||
|
|
|
|||
79
agent/middleware/sanitize_fireworks_messages.py
Normal file
79
agent/middleware/sanitize_fireworks_messages.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Strip legacy ``function_call`` fields before Fireworks provider calls.
|
||||
|
||||
The LangSmith LLM Gateway enforces strict request-schema validation and rejects
|
||||
messages carrying the legacy OpenAI ``function_call`` field
|
||||
(``Extra inputs are not permitted, field: 'messages[N].function_call'``).
|
||||
``langchain_fireworks`` forwards ``function_call`` from
|
||||
``AIMessage.additional_kwargs`` whenever it is present — even when the message
|
||||
also carries modern ``tool_calls`` — so a message that was originally produced
|
||||
by (or deserialized from) a provider that populated the legacy field will break
|
||||
every subsequent Fireworks turn once routed through the gateway.
|
||||
|
||||
This middleware drops the redundant legacy field from assistant messages before
|
||||
they reach the Fireworks serializer, mirroring the thinking-block sanitizer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
try:
|
||||
from langchain_fireworks.chat_models import ChatFireworks
|
||||
except ImportError: # pragma: no cover
|
||||
ChatFireworks = None # type: ignore[assignment, misc]
|
||||
|
||||
|
||||
def _is_chat_fireworks(model: object) -> bool:
|
||||
if ChatFireworks is None:
|
||||
return False
|
||||
seen: set[int] = set()
|
||||
current = model
|
||||
for _ in range(10):
|
||||
if isinstance(current, ChatFireworks):
|
||||
return True
|
||||
current_id = id(current)
|
||||
if current_id in seen:
|
||||
return False
|
||||
seen.add(current_id)
|
||||
bound = getattr(current, "bound", None)
|
||||
if bound is None or bound is current:
|
||||
return False
|
||||
current = bound
|
||||
return False
|
||||
|
||||
|
||||
def _sanitize_messages(messages: list[Any]) -> None:
|
||||
for message in messages:
|
||||
if not isinstance(message, AIMessage):
|
||||
continue
|
||||
additional_kwargs = message.additional_kwargs
|
||||
if not isinstance(additional_kwargs, dict) or "function_call" not in additional_kwargs:
|
||||
continue
|
||||
additional_kwargs.pop("function_call", None)
|
||||
|
||||
|
||||
class SanitizeFireworksMessagesMiddleware(AgentMiddleware):
|
||||
"""Drop legacy ``function_call`` fields before Fireworks provider calls."""
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelCallResult:
|
||||
if _is_chat_fireworks(request.model):
|
||||
_sanitize_messages(request.messages)
|
||||
return handler(request)
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> Any:
|
||||
if _is_chat_fireworks(request.model):
|
||||
_sanitize_messages(request.messages)
|
||||
return await handler(request)
|
||||
85
agent/middleware/sanitize_openai_responses.py
Normal file
85
agent/middleware/sanitize_openai_responses.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""Middleware that removes stale OpenAI Responses reasoning references."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
try:
|
||||
from langchain_openai import ChatOpenAI
|
||||
except ImportError: # pragma: no cover
|
||||
ChatOpenAI = None # type: ignore[assignment, misc]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_chat_openai(model: object) -> bool:
|
||||
if ChatOpenAI is None:
|
||||
return False
|
||||
seen: set[int] = set()
|
||||
current = model
|
||||
for _ in range(10):
|
||||
if isinstance(current, ChatOpenAI):
|
||||
return True
|
||||
current_id = id(current)
|
||||
if current_id in seen:
|
||||
return False
|
||||
seen.add(current_id)
|
||||
bound = getattr(current, "bound", None)
|
||||
if bound is None or bound is current:
|
||||
return False
|
||||
current = bound
|
||||
return False
|
||||
|
||||
|
||||
def _is_stale_reasoning_reference(block: dict[str, Any]) -> bool:
|
||||
if block.get("type") != "reasoning":
|
||||
return False
|
||||
if block.get("encrypted_content"):
|
||||
return False
|
||||
block_id = block.get("id")
|
||||
return isinstance(block_id, str) and block_id.startswith("rs_")
|
||||
|
||||
|
||||
def _sanitize_messages(messages: list[Any]) -> None:
|
||||
removed = 0
|
||||
for message in messages:
|
||||
if not isinstance(message, AIMessage) or not isinstance(message.content, list):
|
||||
continue
|
||||
content = []
|
||||
for block in message.content:
|
||||
if isinstance(block, dict) and _is_stale_reasoning_reference(block):
|
||||
removed += 1
|
||||
continue
|
||||
content.append(block)
|
||||
if len(content) != len(message.content):
|
||||
message.content = content
|
||||
if removed:
|
||||
logger.warning("Removed %d stale OpenAI Responses reasoning reference(s)", removed)
|
||||
|
||||
|
||||
class SanitizeOpenAIResponsesMiddleware(AgentMiddleware):
|
||||
"""Drop non-replayable OpenAI Responses reasoning item references."""
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelCallResult:
|
||||
if _is_chat_openai(request.model):
|
||||
_sanitize_messages(request.messages)
|
||||
return handler(request)
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> Any:
|
||||
if _is_chat_openai(request.model):
|
||||
_sanitize_messages(request.messages)
|
||||
return await handler(request)
|
||||
|
|
@ -7,6 +7,7 @@ import codecs
|
|||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import threading
|
||||
|
|
@ -29,6 +30,12 @@ from ..dashboard.workflow_approval import (
|
|||
)
|
||||
from ..tools.slack_thread_reply import build_workflow_approval_blocks
|
||||
from ..utils.dashboard_links import dashboard_workflow_approval_url
|
||||
from ..utils.github_app import (
|
||||
BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS,
|
||||
)
|
||||
from ..utils.github_proxy import refresh_proxy_token
|
||||
from ..utils.sandbox_state import SANDBOX_BACKENDS
|
||||
from ..utils.slack import post_slack_thread_reply_with_ts
|
||||
|
||||
|
|
@ -850,6 +857,60 @@ async def _approval_state(request: ToolCallRequest, change: WorkflowPushChange)
|
|||
return "approval_error"
|
||||
|
||||
|
||||
async def _run_with_workflow_token(
|
||||
thread_id: str,
|
||||
request: ToolCallRequest,
|
||||
run: Callable[[], Awaitable[ToolMessage | Command]],
|
||||
) -> ToolMessage | Command:
|
||||
sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith")
|
||||
if sandbox_type != "langsmith":
|
||||
return await run()
|
||||
|
||||
elevated = await refresh_proxy_token(
|
||||
thread_id, permissions=WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
)
|
||||
if not elevated:
|
||||
logger.error(
|
||||
"Workflow push approved for thread %s, but proxy token elevation to workflows:write "
|
||||
"failed; the sandbox cannot push workflow files without an elevated token.",
|
||||
thread_id,
|
||||
)
|
||||
error_message = ToolMessage(
|
||||
content=json.dumps(
|
||||
{
|
||||
"status": "error",
|
||||
"error_type": "WorkflowPushElevationFailed",
|
||||
"error": (
|
||||
"Workflow push approved, but the sandbox could not obtain a "
|
||||
"workflows-scoped token. Please retry the push or check the "
|
||||
"GitHub proxy / token minting configuration."
|
||||
),
|
||||
}
|
||||
),
|
||||
tool_call_id="",
|
||||
status="error",
|
||||
)
|
||||
return _tool_message_for_request(error_message, request)
|
||||
try:
|
||||
return await run()
|
||||
finally:
|
||||
restored = await refresh_proxy_token(thread_id, permissions=RUNTIME_PROXY_TOKEN_PERMISSIONS)
|
||||
if not restored:
|
||||
logger.error(
|
||||
"SECURITY: failed to downscope proxy token for thread %s after an approved "
|
||||
"workflow push; retrying without actions:read.",
|
||||
thread_id,
|
||||
)
|
||||
if not await refresh_proxy_token(
|
||||
thread_id, permissions=BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
):
|
||||
logger.error(
|
||||
"SECURITY: proxy token downscope fully failed for thread %s; the sandbox "
|
||||
"may retain workflows:write for the remainder of this run.",
|
||||
thread_id,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowPushGuardMiddleware(AgentMiddleware):
|
||||
"""Require approval before pushing `.github/workflows` changes."""
|
||||
|
||||
|
|
@ -893,9 +954,9 @@ class WorkflowPushGuardMiddleware(AgentMiddleware):
|
|||
return _tool_message_for_request(_blocked_message(change, blocked=True), request)
|
||||
thread_id = _thread_id(request)
|
||||
state = await _approval_state(request, change)
|
||||
if state == "approved":
|
||||
if state == "approved" and thread_id:
|
||||
safe_request = _override_execute_command(request, change.fixed_command)
|
||||
return await handler(safe_request)
|
||||
return await _run_with_workflow_token(thread_id, request, lambda: handler(safe_request))
|
||||
if state == "stale_approval" and thread_id:
|
||||
record, _created = await ensure_workflow_push_pending(
|
||||
thread_id,
|
||||
|
|
|
|||
|
|
@ -34,12 +34,15 @@ from langchain.agents.middleware import ModelCallLimitMiddleware
|
|||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
|
||||
from .dashboard.team_settings import (
|
||||
get_effective_gateway_enabled,
|
||||
get_org_review_guidelines,
|
||||
get_team_default_grouping_model,
|
||||
get_team_default_model_pair,
|
||||
)
|
||||
from .middleware import (
|
||||
RepairOrphanedToolCallsMiddleware,
|
||||
SanitizeFireworksMessagesMiddleware,
|
||||
SanitizeOpenAIResponsesMiddleware,
|
||||
SanitizeThinkingBlocksMiddleware,
|
||||
SanitizeToolInputsMiddleware,
|
||||
SlackAssistantStatusMiddleware,
|
||||
|
|
@ -83,9 +86,9 @@ from .tools import (
|
|||
)
|
||||
from .utils.agents_md import fetch_agents_md
|
||||
from .utils.api_standards_skill import fetch_api_standards_skill
|
||||
from .utils.deferred_model import make_model_or_defer
|
||||
from .utils.github_app import get_github_app_installation_token_with_expiry
|
||||
from .utils.github_token import cache_github_token_for_thread
|
||||
from .utils.deferred_model import make_model_or_defer
|
||||
from .utils.model import DEFAULT_LLM_REASONING, provider_model_kwargs
|
||||
from .utils.repo_prep import materialize_trusted_skills, prepare_review_repo
|
||||
from .utils.sandbox_paths import aresolve_sandbox_work_dir
|
||||
|
|
@ -803,7 +806,9 @@ def _on_background_task_done(task: asyncio.Task[None]) -> None:
|
|||
logger.warning("Background reviewer task failed: %s", exc)
|
||||
|
||||
|
||||
async def _resolve_grouping_model(configurable: dict[str, object]) -> BaseChatModel:
|
||||
async def _resolve_grouping_model(
|
||||
configurable: dict[str, object], *, use_gateway: bool
|
||||
) -> BaseChatModel:
|
||||
"""Resolve the model for the diff-grouping pass.
|
||||
|
||||
Per-run override (``grouping_model_id``/``grouping_reasoning_effort``) wins;
|
||||
|
|
@ -823,7 +828,7 @@ async def _resolve_grouping_model(configurable: dict[str, object]) -> BaseChatMo
|
|||
max_tokens=DEFAULT_LLM_MAX_TOKENS,
|
||||
openai_reasoning_default=DEFAULT_LLM_REASONING,
|
||||
)
|
||||
return make_model_or_defer(model_id, **model_kwargs)
|
||||
return make_model_or_defer(model_id, use_gateway=use_gateway, **model_kwargs)
|
||||
|
||||
|
||||
async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
||||
|
|
@ -1158,8 +1163,11 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
|||
if review_context:
|
||||
system_prompt = f"{system_prompt}\n\n{review_context}"
|
||||
|
||||
reviewer_model = make_model_or_defer(model_id, **model_kwargs)
|
||||
reviewer_subagent_model = make_model_or_defer(subagent_model_id, **subagent_model_kwargs)
|
||||
use_gateway = await get_effective_gateway_enabled()
|
||||
reviewer_model = make_model_or_defer(model_id, use_gateway=use_gateway, **model_kwargs)
|
||||
reviewer_subagent_model = make_model_or_defer(
|
||||
subagent_model_id, use_gateway=use_gateway, **subagent_model_kwargs
|
||||
)
|
||||
|
||||
# Kick off the AI-sorted diff grouping pass at run start, concurrently with
|
||||
# the review, so it adds ~0 latency. First-review and re-review only — a
|
||||
|
|
@ -1167,7 +1175,9 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
|||
# its own errors and the UI falls back to the folder view when groups are
|
||||
# absent.
|
||||
if reviewer_event != "finding_reply" and pr_diff_text and thread_id:
|
||||
grouping_model = await _resolve_grouping_model(config["configurable"])
|
||||
grouping_model = await _resolve_grouping_model(
|
||||
config["configurable"], use_gateway=use_gateway
|
||||
)
|
||||
grouping_task = asyncio.create_task(
|
||||
maybe_generate_and_store_diff_groups(
|
||||
thread_id=thread_id,
|
||||
|
|
@ -1204,6 +1214,8 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
|||
check_message_queue_before_model,
|
||||
SlackAssistantStatusMiddleware(),
|
||||
TimeoutWrapupMiddleware(),
|
||||
SanitizeOpenAIResponsesMiddleware(),
|
||||
SanitizeFireworksMessagesMiddleware(),
|
||||
SanitizeThinkingBlocksMiddleware(),
|
||||
RepairOrphanedToolCallsMiddleware(),
|
||||
settle_review_check_on_exit,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ from .dashboard.agent_usage import record_agent_thread_usage
|
|||
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
|
||||
from .dashboard.repo_snapshots import resolve_repo_snapshot_id
|
||||
from .dashboard.team_settings import (
|
||||
get_effective_gateway_enabled,
|
||||
get_team_default_model_pair,
|
||||
get_team_default_repo,
|
||||
)
|
||||
|
|
@ -58,6 +59,8 @@ from .middleware import (
|
|||
ModelFallbackMiddleware,
|
||||
PlanModeMiddleware,
|
||||
SandboxCircuitBreakerMiddleware,
|
||||
SanitizeFireworksMessagesMiddleware,
|
||||
SanitizeOpenAIResponsesMiddleware,
|
||||
SanitizeThinkingBlocksMiddleware,
|
||||
SanitizeToolInputsMiddleware,
|
||||
SlackAssistantStatusMiddleware,
|
||||
|
|
@ -761,19 +764,31 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
ensure_sandbox_for_thread(thread_id, repo=prompt_default_repo)
|
||||
)
|
||||
team_defaults_task = asyncio.create_task(get_team_default_model_pair("agent"))
|
||||
gateway_task = asyncio.create_task(get_effective_gateway_enabled())
|
||||
profile_task = asyncio.create_task(load_profile(profile_login)) if profile_login else None
|
||||
try:
|
||||
triggering_user_identity, sandbox_backend, team_defaults = await asyncio.gather(
|
||||
(
|
||||
triggering_user_identity,
|
||||
sandbox_backend,
|
||||
team_defaults,
|
||||
use_gateway,
|
||||
) = await asyncio.gather(
|
||||
triggering_user_identity_task,
|
||||
sandbox_task,
|
||||
team_defaults_task,
|
||||
gateway_task,
|
||||
)
|
||||
except SandboxRepoMismatchError as exc:
|
||||
# Repo-binding refusal at the run boundary: log for alarming and surface the
|
||||
# already-sanitized terminal error (no sandbox/token internals) to the caller,
|
||||
# rather than letting an opaque deep-stack exception crash-loop the worker.
|
||||
logger.error("Refusing agent run for thread %s: %s", thread_id, exc)
|
||||
for pending in (triggering_user_identity_task, team_defaults_task, profile_task):
|
||||
for pending in (
|
||||
triggering_user_identity_task,
|
||||
team_defaults_task,
|
||||
gateway_task,
|
||||
profile_task,
|
||||
):
|
||||
if pending is not None and not pending.done():
|
||||
pending.cancel()
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
|
@ -863,7 +878,9 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
fallback_model_id, None, max_tokens=DEFAULT_LLM_MAX_TOKENS
|
||||
)
|
||||
fallback_middleware.append(
|
||||
ModelFallbackMiddleware(make_model_or_defer(fallback_model_id, **fallback_kwargs))
|
||||
ModelFallbackMiddleware(
|
||||
make_model_or_defer(fallback_model_id, use_gateway=use_gateway, **fallback_kwargs)
|
||||
)
|
||||
)
|
||||
logger.info("Configured model fallback %s -> %s", model_id, fallback_model_id)
|
||||
|
||||
|
|
@ -929,8 +946,10 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
notion_tools = []
|
||||
|
||||
logger.info("Returning agent with sandbox for thread %s", thread_id)
|
||||
main_model = make_model_or_defer(model_id, **model_kwargs)
|
||||
subagent_model = make_model_or_defer(subagent_model_id, **subagent_model_kwargs)
|
||||
main_model = make_model_or_defer(model_id, use_gateway=use_gateway, **model_kwargs)
|
||||
subagent_model = make_model_or_defer(
|
||||
subagent_model_id, use_gateway=use_gateway, **subagent_model_kwargs
|
||||
)
|
||||
return create_deep_agent(
|
||||
model=main_model,
|
||||
system_prompt=construct_system_prompt(
|
||||
|
|
@ -999,6 +1018,8 @@ async def get_agent(config: RunnableConfig) -> Pregel:
|
|||
SandboxCircuitBreakerMiddleware(),
|
||||
*fallback_middleware,
|
||||
*plan_mode_middleware,
|
||||
SanitizeOpenAIResponsesMiddleware(),
|
||||
SanitizeFireworksMessagesMiddleware(),
|
||||
SanitizeThinkingBlocksMiddleware(),
|
||||
],
|
||||
).with_config(config)
|
||||
|
|
|
|||
126
agent/utils/gateway.py
Normal file
126
agent/utils/gateway.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""LangSmith LLM Gateway routing for model construction.
|
||||
|
||||
The LLM Gateway (https://docs.langchain.com/langsmith/llm-gateway) proxies
|
||||
provider calls through LangSmith: the client authenticates with a LangSmith API
|
||||
key and the gateway resolves the real provider key from workspace Provider
|
||||
Secrets, enforcing spend/PII/secrets policies and tracing every call. Routing is
|
||||
opt-in via ``LANGSMITH_GATEWAY_ENABLED`` (deployment default) or the
|
||||
``gateway_enabled`` team setting, and is applied centrally in
|
||||
:func:`agent.utils.model.make_model`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_GATEWAY_BASE_URL = "https://gateway.smith.langchain.com"
|
||||
|
||||
# Provider prefix -> base-URL suffix appended to the gateway host. Each suffix
|
||||
# matches the SDK's own path handling: the OpenAI SDK appends
|
||||
# ``/chat/completions`` to a ``/v1`` base, Fireworks appends
|
||||
# ``/v1/chat/completions`` to a bare provider host, Anthropic appends
|
||||
# ``/v1/messages`` to a bare host, and google-genai appends
|
||||
# ``/<api_version>/models/...`` to a bare host. Vertex (``google_vertexai``, which
|
||||
# uses service-account auth rather than a bearer key) and any other provider are
|
||||
# not routed and call the provider directly.
|
||||
_GATEWAY_PROVIDER_PATHS: dict[str, str] = {
|
||||
"openai": "/openai/v1",
|
||||
"anthropic": "/anthropic",
|
||||
"fireworks": "/fireworks",
|
||||
"google_genai": "/gemini",
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(value: str | None) -> bool:
|
||||
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _langsmith_api_key() -> str | None:
|
||||
"""LangSmith API key used to authenticate gateway calls.
|
||||
|
||||
Prefer a gateway-specific key, then the prod LangSmith key. LangGraph Cloud
|
||||
may inject ``LANGSMITH_API_KEY`` for tracing/platform APIs, and that key can
|
||||
lack the ``gateway:invoke`` permission required by the LLM Gateway.
|
||||
"""
|
||||
return (
|
||||
os.environ.get("LANGSMITH_GATEWAY_API_KEY")
|
||||
or os.environ.get("LANGSMITH_API_KEY_PROD")
|
||||
or os.environ.get("LANGSMITH_API_KEY")
|
||||
)
|
||||
|
||||
|
||||
def gateway_base_url() -> str:
|
||||
"""Gateway host, overridable via ``LANGSMITH_GATEWAY_BASE_URL`` (regional/self-hosted)."""
|
||||
return (os.environ.get("LANGSMITH_GATEWAY_BASE_URL") or DEFAULT_GATEWAY_BASE_URL).rstrip("/")
|
||||
|
||||
|
||||
def gateway_env_default() -> bool:
|
||||
"""Deployment-level default for gateway routing (``LANGSMITH_GATEWAY_ENABLED``)."""
|
||||
return _env_bool(os.environ.get("LANGSMITH_GATEWAY_ENABLED"))
|
||||
|
||||
|
||||
def gateway_openai_use_responses() -> bool:
|
||||
"""Whether gateway-routed OpenAI keeps the Responses API.
|
||||
|
||||
Defaults to ``True`` because OpenAI reasoning models with tool calls reject
|
||||
``reasoning_effort`` on Chat Completions. Set
|
||||
``LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES=false`` only for deployments that
|
||||
need to force Chat Completions through the gateway.
|
||||
"""
|
||||
raw = os.environ.get("LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES")
|
||||
if raw is None:
|
||||
return True
|
||||
return _env_bool(raw)
|
||||
|
||||
|
||||
def resolve_gateway_enabled(team_value: bool | None) -> bool:
|
||||
"""Combine the team-settings toggle with the env default.
|
||||
|
||||
A team value of ``True``/``False`` is authoritative; ``None`` inherits the
|
||||
``LANGSMITH_GATEWAY_ENABLED`` deployment default.
|
||||
"""
|
||||
if team_value is None:
|
||||
return gateway_env_default()
|
||||
return team_value
|
||||
|
||||
|
||||
def _provider_of(model_id: str) -> str:
|
||||
return model_id.split(":", 1)[0]
|
||||
|
||||
|
||||
def gateway_overrides(model_id: str) -> dict[str, object] | None:
|
||||
"""``init_chat_model`` kwargs that route ``model_id`` through the gateway.
|
||||
|
||||
Returns ``None`` (so the caller keeps talking to the provider directly) when
|
||||
the provider isn't routable through the gateway or no LangSmith API key is
|
||||
available — both cases are logged rather than raised, so a run never fails
|
||||
just because gateway routing couldn't be applied.
|
||||
"""
|
||||
provider = _provider_of(model_id)
|
||||
path = _GATEWAY_PROVIDER_PATHS.get(provider)
|
||||
if path is None:
|
||||
logger.debug(
|
||||
"LangSmith gateway enabled but provider %r is not routed; calling it directly",
|
||||
provider,
|
||||
)
|
||||
return None
|
||||
api_key = _langsmith_api_key()
|
||||
if not api_key:
|
||||
logger.debug(
|
||||
"LangSmith gateway enabled but no LANGSMITH_GATEWAY_API_KEY or "
|
||||
"LANGSMITH_API_KEY(_PROD) is set; "
|
||||
"calling the provider directly"
|
||||
)
|
||||
return None
|
||||
overrides: dict[str, object] = {
|
||||
"base_url": f"{gateway_base_url()}{path}",
|
||||
"api_key": api_key,
|
||||
}
|
||||
if provider == "openai":
|
||||
# Use HTTPS Responses through the gateway by default; tool-calling OpenAI
|
||||
# reasoning models reject reasoning_effort on Chat Completions.
|
||||
overrides["use_responses_api"] = gateway_openai_use_responses()
|
||||
return overrides
|
||||
|
|
@ -4,6 +4,7 @@ from typing import Literal, TypedDict, Unpack
|
|||
from langchain.chat_models import init_chat_model
|
||||
|
||||
from ..dashboard.options import DEFAULT_MODEL_ID
|
||||
from .gateway import gateway_env_default, gateway_overrides
|
||||
|
||||
OPENAI_RESPONSES_WS_BASE_URL = "wss://api.openai.com/v1"
|
||||
|
||||
|
|
@ -39,11 +40,14 @@ class AnthropicThinking(TypedDict, total=False):
|
|||
class ModelKwargs(TypedDict, total=False):
|
||||
max_tokens: int | None
|
||||
reasoning: OpenAIReasoning | None
|
||||
reasoning_effort: OpenAIReasoningEffort | None
|
||||
thinking: AnthropicThinking | None
|
||||
effort: AnthropicEffort | None
|
||||
thinking_level: GoogleThinkingLevel | None
|
||||
temperature: float | None
|
||||
max_retries: int | None
|
||||
store: bool | None
|
||||
include: list[str] | None
|
||||
model_kwargs: dict[str, object] | None
|
||||
additional_model_request_fields: dict[str, object] | None
|
||||
region_name: str | None
|
||||
|
|
@ -52,21 +56,62 @@ class ModelKwargs(TypedDict, total=False):
|
|||
_ANTHROPIC_EFFORTS: set[AnthropicEffort] = {"low", "medium", "high", "xhigh", "max"}
|
||||
|
||||
|
||||
def make_model(model_id: str, **kwargs: Unpack[ModelKwargs]):
|
||||
def _coerce_openai_chat_completions_kwargs(model_kwargs: dict[str, object]) -> None:
|
||||
if model_kwargs.get("use_responses_api") is not False:
|
||||
return
|
||||
reasoning = model_kwargs.pop("reasoning", None)
|
||||
if isinstance(reasoning, dict):
|
||||
effort = reasoning.get("effort")
|
||||
if isinstance(effort, str):
|
||||
model_kwargs.setdefault("reasoning_effort", effort)
|
||||
|
||||
|
||||
def _configure_openai_responses_kwargs(model_kwargs: dict[str, object]) -> None:
|
||||
if model_kwargs.get("use_responses_api") is False:
|
||||
return
|
||||
model_kwargs.setdefault("store", False)
|
||||
include = model_kwargs.get("include")
|
||||
if include is None:
|
||||
model_kwargs["include"] = ["reasoning.encrypted_content"]
|
||||
elif isinstance(include, list) and "reasoning.encrypted_content" not in include:
|
||||
include.append("reasoning.encrypted_content")
|
||||
|
||||
|
||||
def make_model(model_id: str, *, use_gateway: bool | None = None, **kwargs: Unpack[ModelKwargs]):
|
||||
"""Build a chat model, optionally routed through the LangSmith LLM Gateway.
|
||||
|
||||
``use_gateway`` resolves the deployment default (``LANGSMITH_GATEWAY_ENABLED``)
|
||||
when ``None``; async callers pass the team-settings-resolved value. When on,
|
||||
gateway ``base_url``/``api_key``/``use_responses_api`` override the direct
|
||||
provider defaults below (see :mod:`agent.utils.gateway`).
|
||||
"""
|
||||
model_kwargs: dict[str, object] = kwargs.copy()
|
||||
model_kwargs.setdefault("max_retries", DEFAULT_MAX_RETRIES)
|
||||
|
||||
if model_id.startswith("openai:"):
|
||||
# Direct-provider default: Responses API over the OpenAI websocket base.
|
||||
# Gateway routing overrides this below (an HTTP(S) proxy can't carry wss).
|
||||
model_kwargs["base_url"] = OPENAI_RESPONSES_WS_BASE_URL
|
||||
model_kwargs["use_responses_api"] = True
|
||||
elif model_id.startswith("bedrock_converse:"):
|
||||
# Resolve region with the same precedence validate_local_dev_llm_config accepts
|
||||
# (AWS_REGION or AWS_DEFAULT_REGION), so the validated value is the one actually used.
|
||||
# Resolve region with the same precedence validate_local_dev_llm_config
|
||||
# accepts (AWS_REGION or AWS_DEFAULT_REGION), so the validated value
|
||||
# is the one actually used.
|
||||
model_kwargs.setdefault(
|
||||
"region_name",
|
||||
os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "us-east-1",
|
||||
)
|
||||
|
||||
enabled = gateway_env_default() if use_gateway is None else use_gateway
|
||||
if enabled:
|
||||
overrides = gateway_overrides(model_id)
|
||||
if overrides is not None:
|
||||
model_kwargs.update(overrides)
|
||||
|
||||
if model_id.startswith("openai:"):
|
||||
_configure_openai_responses_kwargs(model_kwargs)
|
||||
_coerce_openai_chat_completions_kwargs(model_kwargs)
|
||||
|
||||
return init_chat_model(model=model_id, **model_kwargs)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,121 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TypeVar, cast
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_CACHE: dict[str, tuple[object, float]] = {}
|
||||
_LOCKS: dict[tuple[str, int], asyncio.Lock] = {}
|
||||
_REFRESH_TASKS: dict[tuple[str, int], asyncio.Task[None]] = {}
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return asyncio.get_running_loop().time()
|
||||
|
||||
|
||||
def _lock_for(key: str) -> asyncio.Lock:
|
||||
loop = asyncio.get_running_loop()
|
||||
lock_key = (key, id(loop))
|
||||
lock = _LOCKS.get(lock_key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_LOCKS[lock_key] = lock
|
||||
return lock
|
||||
|
||||
|
||||
async def cached(key: str, ttl_seconds: float, loader: Callable[[], Awaitable[T]]) -> T:
|
||||
now = _now()
|
||||
entry = _CACHE.get(key)
|
||||
if entry is not None:
|
||||
value, expires_at = entry
|
||||
if expires_at > now:
|
||||
return cast(T, value)
|
||||
|
||||
async with _lock_for(key):
|
||||
now = _now()
|
||||
entry = _CACHE.get(key)
|
||||
if entry is not None:
|
||||
value, expires_at = entry
|
||||
if expires_at > now:
|
||||
return cast(T, value)
|
||||
|
||||
stale = entry[0] if entry is not None else None
|
||||
has_stale = entry is not None
|
||||
try:
|
||||
value = await loader()
|
||||
except Exception:
|
||||
if has_stale:
|
||||
logger.warning(
|
||||
"TTL cache loader failed for %s; serving stale value", key, exc_info=True
|
||||
)
|
||||
return cast(T, stale)
|
||||
raise
|
||||
_CACHE[key] = (value, now + ttl_seconds)
|
||||
return value
|
||||
|
||||
|
||||
async def _refresh_stale_entry(
|
||||
key: str, ttl_seconds: float, loader: Callable[[], Awaitable[object]]
|
||||
) -> None:
|
||||
async with _lock_for(key):
|
||||
try:
|
||||
value = await loader()
|
||||
except Exception:
|
||||
logger.warning("TTL cache background refresh failed for %s", key, exc_info=True)
|
||||
return
|
||||
_CACHE[key] = (value, _now() + ttl_seconds)
|
||||
|
||||
|
||||
def _schedule_refresh(
|
||||
key: str, ttl_seconds: float, loader: Callable[[], Awaitable[object]]
|
||||
) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
task_key = (key, id(loop))
|
||||
existing = _REFRESH_TASKS.get(task_key)
|
||||
if existing is not None and not existing.done():
|
||||
return
|
||||
|
||||
task = asyncio.create_task(_refresh_stale_entry(key, ttl_seconds, loader))
|
||||
_REFRESH_TASKS[task_key] = task
|
||||
task.add_done_callback(lambda _task: _REFRESH_TASKS.pop(task_key, None))
|
||||
|
||||
|
||||
async def cached_stale_while_revalidate(
|
||||
key: str,
|
||||
ttl_seconds: float,
|
||||
loader: Callable[[], Awaitable[T]],
|
||||
) -> T:
|
||||
now = _now()
|
||||
entry = _CACHE.get(key)
|
||||
if entry is not None:
|
||||
value, expires_at = entry
|
||||
if expires_at > now:
|
||||
return cast(T, value)
|
||||
_schedule_refresh(key, ttl_seconds, loader)
|
||||
return cast(T, value)
|
||||
return await cached(key, ttl_seconds, loader)
|
||||
|
||||
|
||||
def set_cached(key: str, value: object, ttl_seconds: float) -> None:
|
||||
try:
|
||||
expires_at = _now() + ttl_seconds
|
||||
except RuntimeError:
|
||||
expires_at = float("inf")
|
||||
_CACHE[key] = (value, expires_at)
|
||||
|
||||
|
||||
def invalidate(key: str) -> None:
|
||||
_CACHE.pop(key, None)
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
_CACHE.clear()
|
||||
_LOCKS.clear()
|
||||
for task in _REFRESH_TASKS.values():
|
||||
task.cancel()
|
||||
_REFRESH_TASKS.clear()
|
||||
|
|
@ -32,10 +32,10 @@
|
|||
{"sha": "4cd5fa5c", "pr": 1629, "subject": "avoid recapping Slack replies", "disposition": "deferred", "reason": "", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "92dbf6f9", "pr": 1630, "subject": "update Slack trace reply on web handoff", "disposition": "landed", "reason": "ported in #128 (trace_message_ts on first-mention run mapping)", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-08T22:58:21Z"}
|
||||
{"sha": "bb36448b", "pr": 1627, "subject": "surface Slack thread errors", "disposition": "landed", "reason": "", "branch": "slack-tooling", "local_sha": "e8b6fb70508861e5742d31aaa722e79f4a9a320a", "updated": "2026-07-03T19:50:30Z"}
|
||||
{"sha": "73b7d1c0", "pr": 1678, "subject": "fix OpenAI Responses reasoning replay", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "5f7c2f46", "pr": 1674, "subject": "fix Fireworks Gateway base URL", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "702ef908", "pr": 1673, "subject": "dedicated LangSmith gateway API key", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "e9dc6e01", "pr": 1671, "subject": "opt-in LangSmith LLM Gateway routing", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"}
|
||||
{"sha": "73b7d1c0", "pr": 1678, "subject": "fix OpenAI Responses reasoning replay", "disposition": "landed", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-09T16:22:05Z"}
|
||||
{"sha": "5f7c2f46", "pr": 1674, "subject": "fix Fireworks Gateway base URL", "disposition": "landed", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-09T16:22:05Z"}
|
||||
{"sha": "702ef908", "pr": 1673, "subject": "dedicated LangSmith gateway API key", "disposition": "landed", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-09T16:22:05Z"}
|
||||
{"sha": "e9dc6e01", "pr": 1671, "subject": "opt-in LangSmith LLM Gateway routing", "disposition": "landed", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-09T16:22:04Z"}
|
||||
{"sha": "289f5e3a", "pr": 1651, "subject": "add Sonnet 5 to model picker", "disposition": "landed", "reason": "already in dev; added Bedrock family fallback fix (c16fb915)", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-03T00:19:50Z"}
|
||||
{"sha": "5da3d0c6", "pr": 1624, "subject": "post reviewer resolution notes verbatim", "disposition": "landed", "reason": "cherry-picked (-x) in #127", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-08T22:58:20Z"}
|
||||
{"sha": "69148f54", "pr": 1612, "subject": "add PR trace resolution", "disposition": "landed", "reason": "cherry-picked (-x) in #127", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-08T22:58:20Z"}
|
||||
|
|
@ -67,7 +67,7 @@
|
|||
{"sha": "e5dbc788", "pr": 1696, "subject": "fix: Harden durable agent runs (#1696)", "disposition": "landed", "reason": "large durable-run hardening; 3 new modules dev lacks; rewrites fork dispatch/completion", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-09T17:22:53Z"}
|
||||
{"sha": "52fe2916", "pr": 1698, "subject": "feat: add PR review link route (#1698)", "disposition": "landed", "reason": "cherry-picked (-x) in #127 (PR review link route)", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-08T22:58:21Z"}
|
||||
{"sha": "5f7f5fbd", "pr": 1697, "subject": "fix: Reduce graph import and loader startup latency (#1697)", "disposition": "landed", "reason": "import-hygiene refactor; cross-cutting, references many deferred upstream-only modules", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-09T17:22:53Z"}
|
||||
{"sha": "216cf181", "pr": 1699, "subject": "fix: keep workflow HITL without token downscoping (#1699)", "disposition": "landed", "reason": "FLAG-HUMAN: removes token downscoping — conflicts w/ fork PR#100 workflow-token elevation (auth surface)", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "216cf181", "pr": 1699, "subject": "fix: keep workflow HITL without token downscoping (#1699)", "disposition": "landed", "reason": "DIVERGES-FROM-UPSTREAM: fork deliberately does NOT adopt #1699's standing-token workflows:write broadening. Security review (#159) BLOCKed it — the standing ALWAYS-ON proxy token carrying workflows:write turns the HITL guard's git-push-parser gaps (obfuscated-expansion push, `gh api` REST contents PUT, cross-branch refspecs) into live unapproved-workflow-push exploits. Fork keeps BASE without workflows:write and restores the transient per-approval elevation (_run_with_workflow_token mints WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS around the approved, guard-normalized fixed_command, then downscopes to RUNTIME then BASE): the token scope is the backstop the parser relies on, so a bypass hits GitHub 403. HITL diff-preview/approval-URL/Slack-card additions from #159 retained; token-model divergence only.", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T20:00:00Z"}
|
||||
{"sha": "67abf5b0", "pr": 1659, "subject": "fix: surface attributed PR creation failures (#1659)", "disposition": "deferred", "reason": "PR-attribution-failure guard (new mw, safe imports); heavy conflict on diverged open_pull_request.py", "branch": "pr-attribution", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
{"sha": "3dbc0282", "pr": 1676, "subject": "fix: preserve plan redirects after login (#1676)", "disposition": "landed", "reason": "FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-09T17:10:22Z"}
|
||||
{"sha": "c75cbb1f", "pr": 1677, "subject": "feat: re-add Fable 5 with an admin toggle to disable it (#1677)", "disposition": "deferred", "reason": "FLAG-HUMAN: re-adds Fable 5 via anthropic: — contradicts dev's deliberate hide (#1483) + Bedrock migration (#62); wont-merge candidate", "branch": "fable-admin-toggle", "local_sha": null, "updated": "2026-07-08T20:14:42Z"}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `27d90ef1` | #1633 | include Slack channel context in prompts | Landed | | slack-tooling |
|
||||
| `92dbf6f9` | #1630 | update Slack trace reply on web handoff | Landed | ported in #128 (trace_message_ts on first-mention run mapping) | slack-tooling |
|
||||
| `bb36448b` | #1627 | surface Slack thread errors | Landed | | slack-tooling |
|
||||
| `73b7d1c0` | #1678 | fix OpenAI Responses reasoning replay | Landed | | gateway-routing |
|
||||
| `5f7c2f46` | #1674 | fix Fireworks Gateway base URL | Landed | | gateway-routing |
|
||||
| `702ef908` | #1673 | dedicated LangSmith gateway API key | Landed | | gateway-routing |
|
||||
| `e9dc6e01` | #1671 | opt-in LangSmith LLM Gateway routing | Landed | | gateway-routing |
|
||||
| `289f5e3a` | #1651 | add Sonnet 5 to model picker | Landed | already in dev; added Bedrock family fallback fix (c16fb915) | gateway-routing |
|
||||
| `5da3d0c6` | #1624 | post reviewer resolution notes verbatim | Landed | cherry-picked (-x) in #127 | reviewer-misc |
|
||||
| `69148f54` | #1612 | add PR trace resolution | Landed | cherry-picked (-x) in #127 | reviewer-misc |
|
||||
|
|
@ -45,7 +49,7 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `e5dbc788` | #1696 | fix: Harden durable agent runs (#1696) | Landed | large durable-run hardening; 3 new modules dev lacks; rewrites fork dispatch/completion | durable-dispatch |
|
||||
| `52fe2916` | #1698 | feat: add PR review link route (#1698) | Landed | cherry-picked (-x) in #127 (PR review link route) | reviewer-misc |
|
||||
| `5f7f5fbd` | #1697 | fix: Reduce graph import and loader startup latency (#1697) | Landed | import-hygiene refactor; cross-cutting, references many deferred upstream-only modules | durable-dispatch |
|
||||
| `216cf181` | #1699 | fix: keep workflow HITL without token downscoping (#1699) | Landed | FLAG-HUMAN: removes token downscoping — conflicts w/ fork PR#100 workflow-token elevation (auth surface) | plan-approval |
|
||||
| `216cf181` | #1699 | fix: keep workflow HITL without token downscoping (#1699) | Landed | DIVERGES-FROM-UPSTREAM: fork deliberately does NOT adopt #1699's standing-token workflows:write broadening. Security review (#159) BLOCKed it — the standing ALWAYS-ON proxy token carrying workflows:write turns the HITL guard's git-push-parser gaps (obfuscated-expansion push, `gh api` REST contents PUT, cross-branch refspecs) into live unapproved-workflow-push exploits. Fork keeps BASE without workflows:write and restores the transient per-approval elevation (_run_with_workflow_token mints WORKFLOW_RUNTIME_PROXY_TOKEN_PERMISSIONS around the approved, guard-normalized fixed_command, then downscopes to RUNTIME then BASE): the token scope is the backstop the parser relies on, so a bypass hits GitHub 403. HITL diff-preview/approval-URL/Slack-card additions from #159 retained; token-model divergence only. | plan-approval |
|
||||
| `3dbc0282` | #1676 | fix: preserve plan redirects after login (#1676) | Landed | FLAG-HUMAN: follow-on to landed #1668 refining sanitize_redirect_to (open-redirect auth surface); not a dup | plan-approval |
|
||||
| `bb104d93` | #1679 | fix: submit plan comments with cmd enter (#1679) | Landed | applies clean but edits fork-diverged PlanReview.tsx (#130); needs UI/e2e validation — separate PR | plan-approval |
|
||||
| `7f7af715` | #1684 | feat: auto-load scoped AGENTS on reads (#1684) | Landed | ported in #129 (SubdirAgentsReadMiddleware) | subdir-agents |
|
||||
|
|
@ -69,10 +73,6 @@ Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred ro
|
|||
| `9cd7e464` | #1700 | Fix workflow approval visibility (#1700) | Won't merge | superseded — dev's list_workflow_approvals_for_thread already enforces owner-only 403 | |
|
||||
| `fd2541ce` | #1705 | fix: drop orphaned function_call items with stale OpenAI reasoning (#1705) | Won't merge | N/A — edits sanitize_openai_responses.py which dev deleted in the Bedrock/Fireworks migration (#62) | |
|
||||
| `4cd5fa5c` | #1629 | avoid recapping Slack replies | Deferred | | slack-tooling |
|
||||
| `73b7d1c0` | #1678 | fix OpenAI Responses reasoning replay | Deferred | | gateway-routing |
|
||||
| `5f7c2f46` | #1674 | fix Fireworks Gateway base URL | Deferred | | gateway-routing |
|
||||
| `702ef908` | #1673 | dedicated LangSmith gateway API key | Deferred | | gateway-routing |
|
||||
| `e9dc6e01` | #1671 | opt-in LangSmith LLM Gateway routing | Deferred | | gateway-routing |
|
||||
| `baf0c248` | #1617 | filter & grouping menu in threads sidebar | Deferred | ~998 LOC | own branch |
|
||||
| `f29868ff` | #1615 | recover thread work as patch | Deferred | ~495 LOC | own branch |
|
||||
| `8e0788dc` | #1631 | show queued dashboard follow-ups | Deferred | | own branch |
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ def apply() -> None:
|
|||
|
||||
import importlib
|
||||
|
||||
from agent import server
|
||||
from agent.utils import auth, authorship
|
||||
from agent.utils import slack as slack_utils
|
||||
|
||||
|
|
@ -52,7 +51,11 @@ def apply() -> None:
|
|||
def _fake_make_model(model_id: str, **kwargs: object): # noqa: ARG001
|
||||
return FakeScriptedChatModel(script=build_script())
|
||||
|
||||
server.make_model = _fake_make_model
|
||||
# ``make_model_or_defer`` (used by server/reviewer/analyzer) resolves
|
||||
# ``make_model`` via the module global at call time, so patch it there.
|
||||
import agent.utils.deferred_model as deferred_model
|
||||
|
||||
deferred_model.make_model = _fake_make_model
|
||||
|
||||
async def _dummy_install_token_with_expiry() -> tuple[str, str | None]:
|
||||
return "dummy-installation-token", None
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ async def _capture_create_deep_agent_kwargs() -> dict[str, object]:
|
|||
),
|
||||
patch("agent.server.load_profile", new_callable=AsyncMock, return_value=None),
|
||||
patch("agent.server.fallback_model_id_for", return_value=None),
|
||||
patch("agent.server.make_model", side_effect=[MagicMock(), MagicMock()]),
|
||||
patch("agent.utils.deferred_model.make_model", side_effect=[MagicMock(), MagicMock()]),
|
||||
patch("agent.server.construct_system_prompt", return_value="prompt"),
|
||||
patch("agent.server.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ async def test_agent_uses_profile_subagent_model_override() -> None:
|
|||
},
|
||||
),
|
||||
patch("agent.server.fallback_model_id_for", return_value=None),
|
||||
patch("agent.server.make_model", side_effect=[main_model, subagent_model]) as make_model,
|
||||
patch(
|
||||
"agent.utils.deferred_model.make_model", side_effect=[main_model, subagent_model]
|
||||
) as make_model,
|
||||
patch("agent.server.construct_system_prompt", return_value="prompt"),
|
||||
patch("agent.server.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
|
|
@ -142,7 +144,9 @@ async def test_agent_subagent_inherits_profile_model_override_without_explicit_p
|
|||
},
|
||||
),
|
||||
patch("agent.server.fallback_model_id_for", return_value=None),
|
||||
patch("agent.server.make_model", side_effect=[main_model, subagent_model]) as make_model,
|
||||
patch(
|
||||
"agent.utils.deferred_model.make_model", side_effect=[main_model, subagent_model]
|
||||
) as make_model,
|
||||
patch("agent.server.construct_system_prompt", return_value="prompt"),
|
||||
patch("agent.server.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ async def test_get_agent_passes_corridor_prompt_state() -> None:
|
|||
return_value=(("openai:gpt-5.5", "medium"), ("openai:gpt-5.5", "low")),
|
||||
),
|
||||
patch.object(server, "fallback_model_id_for", return_value=None),
|
||||
patch.object(server, "make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch.object(
|
||||
server, "_load_observability_tools", new_callable=AsyncMock, return_value=[]
|
||||
),
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@ async def test_create_durable_run_no_webhook_when_none(monkeypatch: pytest.Monke
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_agent_run_preserves_multitask_strategy(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_dispatch_agent_run_preserves_multitask_strategy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client = _FakeClient()
|
||||
monkeypatch.setattr(dispatch, "COMPLETION_WEBHOOK_URL", None)
|
||||
|
||||
|
|
|
|||
437
tests/test_gateway.py
Normal file
437
tests/test_gateway.py
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
"""Unit tests for LangSmith LLM Gateway routing (agent/utils/gateway.py + make_model)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fireworks import AsyncFireworks
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from agent.utils import gateway, model
|
||||
|
||||
_GATEWAY_ENV_VARS = (
|
||||
"LANGSMITH_API_KEY",
|
||||
"LANGSMITH_API_KEY_PROD",
|
||||
"LANGSMITH_GATEWAY_API_KEY",
|
||||
"LANGSMITH_GATEWAY_ENABLED",
|
||||
"LANGSMITH_GATEWAY_BASE_URL",
|
||||
"LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_gateway_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Start each test from a known env: no key, gateway off, default base URL."""
|
||||
for name in _GATEWAY_ENV_VARS:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
# --- gateway_overrides --------------------------------------------------------
|
||||
|
||||
|
||||
def test_openai_overrides_use_responses_by_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
overrides = gateway.gateway_overrides("openai:gpt-5.5")
|
||||
assert overrides == {
|
||||
"base_url": "https://gateway.smith.langchain.com/openai/v1",
|
||||
"api_key": "ls-key",
|
||||
"use_responses_api": True,
|
||||
}
|
||||
|
||||
|
||||
def test_openai_overrides_chat_completions_optout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
monkeypatch.setenv("LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES", "false")
|
||||
overrides = gateway.gateway_overrides("openai:gpt-5.5")
|
||||
assert overrides is not None
|
||||
assert overrides["use_responses_api"] is False
|
||||
|
||||
|
||||
async def test_openai_sdk_uses_gateway_responses_path() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"created_at": 0,
|
||||
"status": "completed",
|
||||
"model": "gpt-5.5",
|
||||
"output": [
|
||||
{
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "ok", "annotations": []}],
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
||||
},
|
||||
)
|
||||
|
||||
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
chat_model = ChatOpenAI(
|
||||
model="gpt-5.5",
|
||||
api_key="dummy",
|
||||
base_url="https://gateway.smith.langchain.com/openai/v1",
|
||||
use_responses_api=True,
|
||||
http_async_client=http_client,
|
||||
max_retries=0,
|
||||
)
|
||||
await chat_model.ainvoke([HumanMessage(content="hi")])
|
||||
finally:
|
||||
await http_client.aclose()
|
||||
|
||||
assert len(requests) == 1
|
||||
assert requests[0].url.path == "/openai/v1/responses"
|
||||
|
||||
|
||||
def test_anthropic_overrides_have_no_responses_flag(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
overrides = gateway.gateway_overrides("anthropic:claude-opus-4-8")
|
||||
assert overrides == {
|
||||
"base_url": "https://gateway.smith.langchain.com/anthropic",
|
||||
"api_key": "ls-key",
|
||||
}
|
||||
|
||||
|
||||
def test_fireworks_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
overrides = gateway.gateway_overrides("fireworks:accounts/fireworks/models/glm-5p2")
|
||||
assert overrides is not None
|
||||
assert overrides["base_url"] == "https://gateway.smith.langchain.com/fireworks"
|
||||
|
||||
|
||||
async def test_fireworks_sdk_uses_allowlisted_gateway_path() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "accounts/fireworks/models/glm-5p2",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
},
|
||||
)
|
||||
|
||||
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
client = AsyncFireworks(
|
||||
api_key="dummy",
|
||||
base_url="https://gateway.smith.langchain.com/fireworks",
|
||||
http_client=http_client,
|
||||
max_retries=0,
|
||||
)
|
||||
await client.chat.completions.create(
|
||||
model="accounts/fireworks/models/glm-5p2",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
finally:
|
||||
await http_client.aclose()
|
||||
|
||||
assert len(requests) == 1
|
||||
assert requests[0].url.path == "/fireworks/v1/chat/completions"
|
||||
|
||||
|
||||
async def test_fireworks_gateway_strips_legacy_function_call() -> None:
|
||||
"""The serializer must not emit ``function_call`` after sanitization.
|
||||
|
||||
Reproduces the production 400 — ``Extra inputs are not permitted, field:
|
||||
'messages[N].function_call'`` — by routing an ``AIMessage`` that carries the
|
||||
legacy ``function_call`` (alongside modern ``tool_calls``) through the
|
||||
Fireworks serializer toward the gateway. Without the sanitizer middleware
|
||||
the request body contains ``function_call``; with it, only ``tool_calls``
|
||||
survives.
|
||||
"""
|
||||
import json
|
||||
|
||||
from fireworks import AsyncFireworks
|
||||
from langchain_fireworks.chat_models import ChatFireworks
|
||||
|
||||
from agent.middleware.sanitize_fireworks_messages import _sanitize_messages
|
||||
|
||||
captured_bodies: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured_bodies.append(json.loads(request.content))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "accounts/fireworks/models/glm-5p2",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
},
|
||||
)
|
||||
|
||||
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
chat_model = ChatFireworks(
|
||||
model="accounts/fireworks/models/glm-5p2",
|
||||
api_key="dummy",
|
||||
base_url="https://gateway.smith.langchain.com/fireworks",
|
||||
max_retries=0,
|
||||
)
|
||||
# Inject a mock-transport client so no real network call is made.
|
||||
mock_sdk = AsyncFireworks(
|
||||
api_key="dummy",
|
||||
base_url="https://gateway.smith.langchain.com/fireworks",
|
||||
http_client=http_client,
|
||||
max_retries=0,
|
||||
)
|
||||
chat_model._async_sdk_client = mock_sdk # type: ignore[attr-defined]
|
||||
chat_model.async_client = mock_sdk.chat.completions # type: ignore[attr-defined]
|
||||
|
||||
ai_message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"name": "read_file", "args": {"file_path": "/x"}, "id": "tc1"}],
|
||||
additional_kwargs={"function_call": {"name": "read_file", "arguments": "{}"}},
|
||||
)
|
||||
messages = [HumanMessage(content="hi"), ai_message]
|
||||
|
||||
# Apply the sanitizer the same way the middleware stack does.
|
||||
_sanitize_messages(messages)
|
||||
|
||||
await chat_model.ainvoke(messages)
|
||||
await mock_sdk.close()
|
||||
finally:
|
||||
await http_client.aclose()
|
||||
|
||||
assert len(captured_bodies) == 1
|
||||
body = captured_bodies[0]
|
||||
for msg in body["messages"]:
|
||||
assert "function_call" not in msg, msg
|
||||
# The assistant message still carries tool_calls.
|
||||
assistant_msgs = [m for m in body["messages"] if m["role"] == "assistant"]
|
||||
assert assistant_msgs and "tool_calls" in assistant_msgs[0]
|
||||
|
||||
|
||||
def test_google_genai_routes_to_gemini(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
overrides = gateway.gateway_overrides("google_genai:gemini-3.5-flash")
|
||||
assert overrides == {
|
||||
"base_url": "https://gateway.smith.langchain.com/gemini",
|
||||
"api_key": "ls-key",
|
||||
}
|
||||
|
||||
|
||||
def test_unsupported_provider_passes_through(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
# Vertex authenticates with a service account, not a bearer key, so it isn't routed.
|
||||
assert gateway.gateway_overrides("google_vertexai:gemini-2.5-pro") is None
|
||||
|
||||
|
||||
def test_missing_api_key_passes_through(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert gateway.gateway_overrides("openai:gpt-5.5") is None
|
||||
|
||||
|
||||
def test_prod_key_used_as_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY_PROD", "ls-prod-key")
|
||||
overrides = gateway.gateway_overrides("anthropic:claude-opus-4-8")
|
||||
assert overrides is not None
|
||||
assert overrides["api_key"] == "ls-prod-key"
|
||||
|
||||
|
||||
def test_prod_key_preferred_over_platform_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-platform-key")
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY_PROD", "ls-prod-key")
|
||||
overrides = gateway.gateway_overrides("anthropic:claude-opus-4-8")
|
||||
assert overrides is not None
|
||||
assert overrides["api_key"] == "ls-prod-key"
|
||||
|
||||
|
||||
def test_gateway_key_preferred_over_prod_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-platform-key")
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY_PROD", "ls-prod-key")
|
||||
monkeypatch.setenv("LANGSMITH_GATEWAY_API_KEY", "ls-gateway-key")
|
||||
overrides = gateway.gateway_overrides("anthropic:claude-opus-4-8")
|
||||
assert overrides is not None
|
||||
assert overrides["api_key"] == "ls-gateway-key"
|
||||
|
||||
|
||||
def test_base_url_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
monkeypatch.setenv("LANGSMITH_GATEWAY_BASE_URL", "https://gw.internal.example.com/")
|
||||
overrides = gateway.gateway_overrides("anthropic:claude-opus-4-8")
|
||||
assert overrides is not None
|
||||
# Trailing slash is stripped, then the provider path is appended.
|
||||
assert overrides["base_url"] == "https://gw.internal.example.com/anthropic"
|
||||
|
||||
|
||||
# --- resolve_gateway_enabled --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("team_value", "env_enabled", "expected"),
|
||||
[
|
||||
(True, False, True), # team True wins over env off
|
||||
(False, True, False), # team False wins over env on
|
||||
(None, True, True), # unset inherits env on
|
||||
(None, False, False), # unset inherits env off
|
||||
],
|
||||
)
|
||||
def test_resolve_gateway_enabled_precedence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
team_value: bool | None,
|
||||
env_enabled: bool,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
if env_enabled:
|
||||
monkeypatch.setenv("LANGSMITH_GATEWAY_ENABLED", "true")
|
||||
assert gateway.resolve_gateway_enabled(team_value) is expected
|
||||
|
||||
|
||||
# --- make_model integration ---------------------------------------------------
|
||||
|
||||
|
||||
def _capture_init_chat_model() -> tuple[dict[str, Any], Any]:
|
||||
"""Patch init_chat_model to record the kwargs make_model builds."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _fake(model: str, **kwargs: Any) -> str:
|
||||
captured["model"] = model
|
||||
captured.update(kwargs)
|
||||
return "MODEL"
|
||||
|
||||
return captured, _fake
|
||||
|
||||
|
||||
def test_make_model_direct_openai_uses_responses_websocket() -> None:
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model("openai:gpt-5.5", use_gateway=False)
|
||||
assert captured["base_url"] == model.OPENAI_RESPONSES_WS_BASE_URL
|
||||
assert captured["use_responses_api"] is True
|
||||
assert captured["store"] is False
|
||||
assert captured["include"] == ["reasoning.encrypted_content"]
|
||||
|
||||
|
||||
def test_make_model_gateway_openai_replaces_websocket(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model("openai:gpt-5.5", use_gateway=True)
|
||||
assert captured["base_url"] == "https://gateway.smith.langchain.com/openai/v1"
|
||||
assert captured["use_responses_api"] is True
|
||||
assert captured["store"] is False
|
||||
assert captured["include"] == ["reasoning.encrypted_content"]
|
||||
assert captured["api_key"] == "ls-key"
|
||||
|
||||
|
||||
def test_make_model_gateway_openai_chat_completions_optout_converts_reasoning(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
monkeypatch.setenv("LANGSMITH_GATEWAY_OPENAI_USE_RESPONSES", "false")
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model(
|
||||
"openai:gpt-5.5",
|
||||
use_gateway=True,
|
||||
reasoning={"effort": "high", "summary": "auto"},
|
||||
)
|
||||
assert captured["use_responses_api"] is False
|
||||
assert captured["reasoning_effort"] == "high"
|
||||
assert "reasoning" not in captured
|
||||
assert "include" not in captured
|
||||
assert "store" not in captured
|
||||
|
||||
|
||||
def test_make_model_gateway_openai_preserves_reasoning_none(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model(
|
||||
"openai:gpt-5.5",
|
||||
use_gateway=True,
|
||||
reasoning={"effort": "none"},
|
||||
)
|
||||
assert captured["use_responses_api"] is True
|
||||
assert captured["store"] is False
|
||||
assert captured["include"] == ["reasoning.encrypted_content"]
|
||||
assert captured["reasoning"] == {"effort": "none"}
|
||||
assert "reasoning_effort" not in captured
|
||||
|
||||
|
||||
def test_make_model_gateway_openai_responses_keeps_reasoning(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
reasoning = {"effort": "high", "summary": "auto"}
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model("openai:gpt-5.5", use_gateway=True, reasoning=reasoning)
|
||||
assert captured["use_responses_api"] is True
|
||||
assert captured["store"] is False
|
||||
assert captured["include"] == ["reasoning.encrypted_content"]
|
||||
assert captured["reasoning"] == reasoning
|
||||
assert "reasoning_effort" not in captured
|
||||
|
||||
|
||||
def test_make_model_gateway_follows_env_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
monkeypatch.setenv("LANGSMITH_GATEWAY_ENABLED", "true")
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model("anthropic:claude-opus-4-8") # use_gateway=None -> env default
|
||||
assert captured["base_url"] == "https://gateway.smith.langchain.com/anthropic"
|
||||
assert captured["api_key"] == "ls-key"
|
||||
|
||||
|
||||
def test_make_model_gateway_google_genai(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LANGSMITH_API_KEY", "ls-key")
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model("google_genai:gemini-3.5-flash", use_gateway=True)
|
||||
assert captured["base_url"] == "https://gateway.smith.langchain.com/gemini"
|
||||
assert captured["api_key"] == "ls-key"
|
||||
|
||||
|
||||
def test_make_model_gateway_without_key_falls_back_direct(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured, fake = _capture_init_chat_model()
|
||||
with patch.object(model, "init_chat_model", fake):
|
||||
model.make_model("openai:gpt-5.5", use_gateway=True) # no LangSmith key
|
||||
# No key -> overrides skipped -> the direct-provider websocket base stands.
|
||||
assert captured["base_url"] == model.OPENAI_RESPONSES_WS_BASE_URL
|
||||
assert captured["use_responses_api"] is True
|
||||
assert captured["store"] is False
|
||||
assert captured["include"] == ["reasoning.encrypted_content"]
|
||||
assert "api_key" not in captured
|
||||
|
|
@ -333,7 +333,7 @@ class TestRefreshProxyOnSandboxReuse:
|
|||
new_callable=AsyncMock,
|
||||
return_value=mock_sandbox,
|
||||
),
|
||||
patch("agent.server.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.server.construct_system_prompt", return_value="prompt"),
|
||||
patch("agent.server.create_deep_agent", return_value=_DummyAgent()),
|
||||
patch.dict(
|
||||
|
|
@ -386,7 +386,7 @@ class TestRefreshProxyOnSandboxReuse:
|
|||
new_callable=AsyncMock,
|
||||
return_value="/workspace",
|
||||
),
|
||||
patch("agent.server.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.server.construct_system_prompt", return_value="prompt"),
|
||||
patch("agent.server.create_deep_agent", return_value=_DummyAgent()),
|
||||
patch.dict("agent.server.SANDBOX_BACKENDS", {}, clear=True),
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ async def test_reviewer_resolves_app_installation_token_at_run_start() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value="/workspace",
|
||||
),
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", return_value=dummy_agent) as create_agent,
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -260,7 +260,7 @@ async def test_reviewer_reuses_app_token_for_sandbox_proxy() -> None:
|
|||
new_callable=AsyncMock,
|
||||
),
|
||||
patch("agent.reviewer.fetch_agents_md", new_callable=AsyncMock, return_value=None),
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", return_value=_DummyAgent()),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -335,7 +335,7 @@ async def test_reviewer_applies_eval_model_and_effort_overrides() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value="/workspace",
|
||||
),
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()) as make_model,
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()) as make_model,
|
||||
patch("agent.reviewer.create_deep_agent", return_value=dummy_agent),
|
||||
patch(
|
||||
"agent.reviewer.fetch_agents_md",
|
||||
|
|
@ -383,7 +383,7 @@ async def test_reviewer_subagent_inherits_eval_model_without_explicit_override()
|
|||
new_callable=AsyncMock,
|
||||
return_value="/workspace",
|
||||
),
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()) as make_model,
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()) as make_model,
|
||||
patch("agent.reviewer.create_deep_agent", return_value=dummy_agent),
|
||||
patch(
|
||||
"agent.reviewer.fetch_agents_md",
|
||||
|
|
@ -441,7 +441,7 @@ async def test_reviewer_injects_repo_style_during_eval() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value="Flag table rerender regressions.",
|
||||
),
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
patch(
|
||||
"agent.reviewer.fetch_agents_md",
|
||||
|
|
@ -507,7 +507,7 @@ async def test_reviewer_inlines_org_guidelines_into_system_prompt() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -572,7 +572,7 @@ async def test_reviewer_inlines_agents_md_into_system_prompt() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value="Always use the design system IconButton.",
|
||||
) as mock_fetch_agents_md,
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -624,7 +624,7 @@ async def test_reviewer_inlines_claude_md_when_agents_md_absent() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value="# CLAUDE.md\nUse semantic tokens only.",
|
||||
) as mock_fetch_agents_md,
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -989,7 +989,7 @@ async def test_reviewer_injects_pr_review_threads_into_first_review_context() ->
|
|||
new_callable=AsyncMock,
|
||||
return_value=fake_threads,
|
||||
) as mock_fetch_threads,
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -1065,7 +1065,7 @@ async def test_reviewer_injects_pr_review_threads_into_re_review_context() -> No
|
|||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
),
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -1123,7 +1123,7 @@ async def test_reviewer_omits_threads_block_when_fetch_returns_empty() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
),
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -1182,7 +1182,7 @@ async def test_reviewer_continues_when_thread_fetch_raises() -> None:
|
|||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("network down"),
|
||||
),
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -1254,7 +1254,7 @@ async def test_reviewer_populates_diff_line_set_from_github_api() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value=pr_diff,
|
||||
) as mock_fetch_diff,
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -1319,7 +1319,7 @@ async def test_reviewer_leaves_validation_disabled_when_diff_fetch_fails() -> No
|
|||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
@ -1385,7 +1385,7 @@ async def test_reviewer_injects_pr_title_and_body_into_context() -> None:
|
|||
new_callable=AsyncMock,
|
||||
return_value=("Add retry logic for uploads", "Retries flaky uploads up to 3 times."),
|
||||
) as mock_fetch_metadata,
|
||||
patch("agent.reviewer.make_model", return_value=MagicMock()),
|
||||
patch("agent.utils.deferred_model.make_model", return_value=MagicMock()),
|
||||
patch("agent.reviewer.create_deep_agent", side_effect=fake_create_deep_agent),
|
||||
):
|
||||
await reviewer.get_reviewer_agent(config)
|
||||
|
|
|
|||
118
tests/test_sanitize_fireworks_messages.py
Normal file
118
tests/test_sanitize_fireworks_messages.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
|
||||
from agent.middleware.sanitize_fireworks_messages import SanitizeFireworksMessagesMiddleware
|
||||
|
||||
|
||||
def _make_request(messages: list[object], model: object | None = None) -> MagicMock:
|
||||
request = MagicMock()
|
||||
request.model = model
|
||||
request.messages = messages
|
||||
return request
|
||||
|
||||
|
||||
def _fireworks_model() -> MagicMock:
|
||||
"""A mock that satisfies ``_is_chat_fireworks`` via spec'd ``ChatFireworks``."""
|
||||
try:
|
||||
from langchain_fireworks.chat_models import ChatFireworks
|
||||
except ImportError: # pragma: no cover
|
||||
pytest.skip("langchain-fireworks not installed")
|
||||
return MagicMock(spec=ChatFireworks)
|
||||
|
||||
|
||||
class TestSanitizeFireworksMessagesMiddleware:
|
||||
def test_drops_legacy_function_call(self) -> None:
|
||||
message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"name": "read_file", "args": {"file_path": "/x"}, "id": "tc1"}],
|
||||
additional_kwargs={"function_call": {"name": "read_file", "arguments": "{}"}},
|
||||
)
|
||||
request = _make_request([message], model=_fireworks_model())
|
||||
response = MagicMock()
|
||||
|
||||
def handler(req: object) -> object:
|
||||
assert req is request
|
||||
return response
|
||||
|
||||
result = SanitizeFireworksMessagesMiddleware().wrap_model_call(request, handler)
|
||||
|
||||
assert result is response
|
||||
assert "function_call" not in message.additional_kwargs
|
||||
# tool_calls are untouched
|
||||
assert len(message.tool_calls) == 1
|
||||
|
||||
def test_preserves_message_without_function_call(self) -> None:
|
||||
message = AIMessage(
|
||||
content="ok",
|
||||
tool_calls=[{"name": "read_file", "args": {"file_path": "/x"}, "id": "tc1"}],
|
||||
)
|
||||
request = _make_request([message], model=_fireworks_model())
|
||||
|
||||
SanitizeFireworksMessagesMiddleware().wrap_model_call(request, lambda req: MagicMock())
|
||||
|
||||
assert "function_call" not in message.additional_kwargs
|
||||
assert len(message.tool_calls) == 1
|
||||
|
||||
def test_drops_function_call_with_no_tool_calls(self) -> None:
|
||||
message = AIMessage(
|
||||
content="",
|
||||
additional_kwargs={"function_call": {"name": "search", "arguments": "{}"}},
|
||||
)
|
||||
request = _make_request([message], model=_fireworks_model())
|
||||
|
||||
SanitizeFireworksMessagesMiddleware().wrap_model_call(request, lambda req: MagicMock())
|
||||
|
||||
assert "function_call" not in message.additional_kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_drops_legacy_function_call(self) -> None:
|
||||
tool_result = ToolMessage(content="result", tool_call_id="tc1")
|
||||
message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"name": "read_file", "args": {"file_path": "/x"}, "id": "tc1"}],
|
||||
additional_kwargs={"function_call": {"name": "read_file", "arguments": "{}"}},
|
||||
)
|
||||
request = _make_request(
|
||||
[HumanMessage(content="hi"), message, tool_result],
|
||||
model=_fireworks_model(),
|
||||
)
|
||||
response = MagicMock()
|
||||
|
||||
async def handler(req: object) -> object:
|
||||
assert req is request
|
||||
return response
|
||||
|
||||
result = await SanitizeFireworksMessagesMiddleware().awrap_model_call(request, handler)
|
||||
|
||||
assert result is response
|
||||
assert "function_call" not in message.additional_kwargs
|
||||
|
||||
def test_ignores_non_fireworks_models(self) -> None:
|
||||
message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"name": "read_file", "args": {"file_path": "/x"}, "id": "tc1"}],
|
||||
additional_kwargs={"function_call": {"name": "read_file", "arguments": "{}"}},
|
||||
)
|
||||
# Non-Fireworks model (plain MagicMock, no ChatFireworks in its spec chain)
|
||||
request = _make_request([message], model=MagicMock())
|
||||
|
||||
SanitizeFireworksMessagesMiddleware().wrap_model_call(request, lambda req: MagicMock())
|
||||
|
||||
# function_call preserved for non-Fireworks providers
|
||||
assert "function_call" in message.additional_kwargs
|
||||
|
||||
def test_skips_non_ai_messages(self) -> None:
|
||||
messages = [
|
||||
HumanMessage(content="hi"),
|
||||
ToolMessage(content="result", tool_call_id="tc1"),
|
||||
]
|
||||
request = _make_request(messages, model=_fireworks_model())
|
||||
|
||||
SanitizeFireworksMessagesMiddleware().wrap_model_call(request, lambda req: MagicMock())
|
||||
|
||||
# No AIMessages to mutate — handler still called
|
||||
assert all(not isinstance(m, AIMessage) for m in messages)
|
||||
55
tests/test_sanitize_openai_responses.py
Normal file
55
tests/test_sanitize_openai_responses.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from agent.middleware.sanitize_openai_responses import _sanitize_messages
|
||||
|
||||
|
||||
def test_sanitize_messages_drops_stale_reasoning_references() -> None:
|
||||
messages = [
|
||||
HumanMessage(content="review this"),
|
||||
AIMessage(
|
||||
content=[
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_123",
|
||||
"summary": [],
|
||||
"content": [],
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"id": "fc_123",
|
||||
"call_id": "call_123",
|
||||
"name": "read_file",
|
||||
"arguments": "{}",
|
||||
},
|
||||
],
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "read_file",
|
||||
"args": {},
|
||||
"id": "call_123",
|
||||
}
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
_sanitize_messages(messages)
|
||||
|
||||
content = messages[1].content
|
||||
assert isinstance(content, list)
|
||||
assert [block["type"] for block in content] == ["function_call"]
|
||||
assert messages[1].tool_calls[0]["id"] == "call_123"
|
||||
|
||||
|
||||
def test_sanitize_messages_preserves_encrypted_reasoning() -> None:
|
||||
reasoning = {
|
||||
"type": "reasoning",
|
||||
"id": "rs_123",
|
||||
"encrypted_content": "encrypted",
|
||||
"summary": [],
|
||||
"content": [],
|
||||
}
|
||||
messages = [AIMessage(content=[reasoning])]
|
||||
|
||||
_sanitize_messages(messages)
|
||||
|
||||
assert messages[0].content == [reasoning]
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
|
@ -347,15 +348,25 @@ async def test_unapproved_workflow_push_blocks_and_posts_slack(
|
|||
assert posted["blocks"][1]["elements"][0]["value"]
|
||||
|
||||
|
||||
async def test_approved_workflow_push_runs_fixed_command(
|
||||
async def test_approved_workflow_push_elevates_and_restores(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
guard.SANDBOX_BACKENDS["thread-1"] = _Backend()
|
||||
refreshed: list[dict[str, str]] = []
|
||||
|
||||
async def fake_approved(thread_id: str, fingerprint: str) -> bool:
|
||||
return True
|
||||
|
||||
async def fake_refresh(thread_id: str | None, *, permissions: dict[str, str]) -> bool:
|
||||
refreshed.append(dict(permissions))
|
||||
return True
|
||||
|
||||
async def fake_find_approval(*args: Any, **kwargs: Any) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(guard, "workflow_push_approved", fake_approved)
|
||||
monkeypatch.setattr(guard, "find_workflow_push_approval", fake_find_approval)
|
||||
monkeypatch.setattr(guard, "refresh_proxy_token", fake_refresh)
|
||||
|
||||
pushed_command = ""
|
||||
|
||||
|
|
@ -372,6 +383,40 @@ async def test_approved_workflow_push_runs_fixed_command(
|
|||
pushed_command
|
||||
== "git -C /repo push origin aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:refs/heads/feature"
|
||||
)
|
||||
assert refreshed[0]["workflows"] == "write"
|
||||
assert "workflows" not in refreshed[1]
|
||||
assert refreshed[1]["actions"] == "read"
|
||||
|
||||
|
||||
async def test_workflow_push_restoration_falls_back_when_actions_read_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
guard.SANDBOX_BACKENDS["thread-1"] = _Backend()
|
||||
refreshed: list[dict[str, str]] = []
|
||||
|
||||
async def fake_approved(thread_id: str, fingerprint: str) -> bool:
|
||||
return True
|
||||
|
||||
async def fake_refresh(thread_id: str | None, *, permissions: dict[str, str]) -> bool:
|
||||
refreshed.append(dict(permissions))
|
||||
return "actions" not in permissions
|
||||
|
||||
async def fake_find_approval(*args: Any, **kwargs: Any) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(guard, "workflow_push_approved", fake_approved)
|
||||
monkeypatch.setattr(guard, "find_workflow_push_approval", fake_find_approval)
|
||||
monkeypatch.setattr(guard, "refresh_proxy_token", fake_refresh)
|
||||
|
||||
async def handler(_request: Any) -> ToolMessage:
|
||||
return ToolMessage(content="pushed", tool_call_id="call-1")
|
||||
|
||||
await guard.WorkflowPushGuardMiddleware().awrap_tool_call(_Request(), handler)
|
||||
|
||||
assert refreshed[0]["workflows"] == "write"
|
||||
assert refreshed[1]["actions"] == "read"
|
||||
assert refreshed[2] == guard.BASE_RUNTIME_PROXY_TOKEN_PERMISSIONS
|
||||
assert "actions" not in refreshed[2]
|
||||
|
||||
|
||||
async def test_non_workflow_push_runs_without_approval(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
@ -665,7 +710,7 @@ async def test_exact_rejection_checked_before_stale_approval(
|
|||
assert payload["workflow_approval_status"] == "rejected"
|
||||
|
||||
|
||||
async def test_approved_workflow_push_passes_through(
|
||||
async def test_approved_workflow_push_aborts_when_elevation_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
guard.SANDBOX_BACKENDS["thread-1"] = _Backend()
|
||||
|
|
@ -673,7 +718,14 @@ async def test_approved_workflow_push_passes_through(
|
|||
async def fake_approved(thread_id: str, fingerprint: str) -> bool:
|
||||
return True
|
||||
|
||||
refresh_calls: list[dict[str, str]] = []
|
||||
|
||||
async def fake_refresh(*args: Any, **kwargs: Any) -> bool:
|
||||
refresh_calls.append(dict(kwargs.get("permissions", {})))
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(guard, "workflow_push_approved", fake_approved)
|
||||
monkeypatch.setattr(guard, "refresh_proxy_token", fake_refresh)
|
||||
|
||||
called = False
|
||||
request = _Request()
|
||||
|
|
@ -685,9 +737,51 @@ async def test_approved_workflow_push_passes_through(
|
|||
|
||||
result = await guard.WorkflowPushGuardMiddleware().awrap_tool_call(request, handler)
|
||||
|
||||
assert called is False
|
||||
assert isinstance(result, ToolMessage)
|
||||
assert result.tool_call_id == "call-1"
|
||||
assert result.status == "error"
|
||||
payload = json.loads(str(result.content))
|
||||
assert payload["status"] == "error"
|
||||
assert payload["error_type"] == "WorkflowPushElevationFailed"
|
||||
assert "workflows-scoped token" in payload["error"]
|
||||
assert len(refresh_calls) == 1
|
||||
assert refresh_calls[0].get("workflows") == "write"
|
||||
|
||||
|
||||
async def test_approved_workflow_push_runs_on_non_langsmith_providers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
guard.SANDBOX_BACKENDS["thread-1"] = _Backend()
|
||||
|
||||
async def fake_approved(thread_id: str, fingerprint: str) -> bool:
|
||||
return True
|
||||
|
||||
refresh_calls: list[dict[str, str]] = []
|
||||
|
||||
async def fake_refresh(*args: Any, **kwargs: Any) -> bool:
|
||||
refresh_calls.append(dict(kwargs.get("permissions", {})))
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(guard, "workflow_push_approved", fake_approved)
|
||||
monkeypatch.setattr(guard, "refresh_proxy_token", fake_refresh)
|
||||
monkeypatch.setattr(guard, "os", os)
|
||||
|
||||
called = False
|
||||
|
||||
async def handler(request: Any) -> ToolMessage:
|
||||
nonlocal called
|
||||
called = True
|
||||
return ToolMessage(content="pushed", tool_call_id=request.tool_call["id"])
|
||||
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setenv("SANDBOX_TYPE", "local")
|
||||
result = await guard.WorkflowPushGuardMiddleware().awrap_tool_call(_Request(), handler)
|
||||
|
||||
assert called is True
|
||||
assert isinstance(result, ToolMessage)
|
||||
assert result.content == "pushed"
|
||||
assert refresh_calls == []
|
||||
|
||||
|
||||
def test_parse_git_push_guards_wrapped_and_optioned_forms() -> None:
|
||||
|
|
|
|||
|
|
@ -164,6 +164,8 @@ export interface TeamSettings {
|
|||
review_draft_prs: boolean
|
||||
pr_summaries: boolean
|
||||
review_trace_links: boolean
|
||||
/** Tri-state LLM Gateway toggle; null inherits the LANGSMITH_GATEWAY_ENABLED default. */
|
||||
gateway_enabled?: boolean | null
|
||||
review_tracing_project?: string | null
|
||||
org_guidelines?: string | null
|
||||
default_agent_model?: string | null
|
||||
|
|
|
|||
|
|
@ -50,7 +50,12 @@ export function sanitizeAuthRedirect(
|
|||
}
|
||||
|
||||
const path = `${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
if (!path.startsWith("/") || isBlockedRedirectPath(path)) return fallback
|
||||
// Reject anything that is not a single-leading-slash path. `new URL` can resolve inputs
|
||||
// like `/..//evil.com` to a protocol-relative `//evil.com` that still matches the origin
|
||||
// check above but, when handed to `window.location.replace`, navigates cross-origin.
|
||||
if (path.startsWith("//") || !/^\/[^/]/.test(path) || isBlockedRedirectPath(path)) {
|
||||
return fallback
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,14 @@ describe("auth redirect helpers", () => {
|
|||
)
|
||||
})
|
||||
|
||||
it("rejects inputs that resolve to a protocol-relative path", () => {
|
||||
// These resolve same-origin (passing the origin check) but yield a `//host`
|
||||
// path that would navigate cross-origin via window.location.replace.
|
||||
expect(sanitizeAuthRedirect("/..//evil.com")).toBe(DEFAULT_AUTH_REDIRECT)
|
||||
expect(sanitizeAuthRedirect("/.//evil.com")).toBe(DEFAULT_AUTH_REDIRECT)
|
||||
expect(sanitizeAuthRedirect("//evil.com")).toBe(DEFAULT_AUTH_REDIRECT)
|
||||
})
|
||||
|
||||
it("builds a plan sign-in target for the current plan URL", () => {
|
||||
window.history.pushState({}, "", "/agents/thread-1/plan?from=slack")
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ function AdminPage() {
|
|||
>
|
||||
<GlobalDefaultsSection models={options.data?.models ?? []} />
|
||||
|
||||
<LLMGatewaySection />
|
||||
|
||||
<TriggerReviewSection />
|
||||
|
||||
<SettingsSection title="Evals">
|
||||
|
|
@ -552,6 +554,77 @@ function ObservabilityCredentialsSection() {
|
|||
)
|
||||
}
|
||||
|
||||
type GatewayMode = "inherit" | "enabled" | "disabled"
|
||||
|
||||
function gatewayMode(value: boolean | null | undefined): GatewayMode {
|
||||
if (value === true) return "enabled"
|
||||
if (value === false) return "disabled"
|
||||
return "inherit"
|
||||
}
|
||||
|
||||
function gatewayModeValue(mode: GatewayMode): boolean | null {
|
||||
if (mode === "enabled") return true
|
||||
if (mode === "disabled") return false
|
||||
return null
|
||||
}
|
||||
|
||||
function LLMGatewaySection() {
|
||||
const qc = useQueryClient()
|
||||
const settings = useQuery({
|
||||
queryKey: ["teamSettings"],
|
||||
queryFn: api.getTeamSettings,
|
||||
})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
|
||||
onSuccess: (saved) => {
|
||||
qc.setQueryData(["teamSettings"], saved)
|
||||
setError(null)
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
})
|
||||
|
||||
const mode = gatewayMode(settings.data?.gateway_enabled)
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="LLM Gateway"
|
||||
description="Route agent and reviewer LLM calls through the LangSmith LLM Gateway. It authenticates with the workspace LangSmith API key and resolves provider keys from Provider Secrets, so no provider keys are needed at runtime. Requires the gateway (private beta) enabled for your organization."
|
||||
>
|
||||
<div className="divide-y divide-border">
|
||||
<SettingsRow
|
||||
label="Route through the gateway"
|
||||
description="Inherit uses the LANGSMITH_GATEWAY_ENABLED deployment default. OpenAI, Anthropic, Fireworks, and Google Gemini are routed through the gateway. Bedrock (AWS) calls the provider directly and is not routed; other providers not listed also call the provider directly."
|
||||
control={
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={(next) =>
|
||||
settings.data &&
|
||||
save.mutate({
|
||||
...settings.data,
|
||||
gateway_enabled: gatewayModeValue(next as GatewayMode),
|
||||
})
|
||||
}
|
||||
disabled={!settings.data || save.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">Inherit deployment default</SelectItem>
|
||||
<SelectItem value="enabled">Enabled</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function PRTraceResolutionSection() {
|
||||
const qc = useQueryClient()
|
||||
const settings = useQuery({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue