mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-07 19:20:34 +00:00
fix(models): route managed OpenAI GPT-6 presets through Responses (#7870)
* fix(models): route managed OpenAI GPT-6 presets through Responses * test(memory): pin capacity scenarios to a stable clock * fix(models): honor Flash mode for managed OpenAI GPT-6 * fix(models): reconcile preset effort with explicit reasoning contracts * test(memory): share the fixed clock with updater capacity tests * fix(models): normalize GPT-6 reasoning overrides and sampling --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
59beff5799
commit
c1f9064e9b
@ -347,6 +347,11 @@ fences, including unclosed fences, in complete and streamed replies.
|
||||
Chat pickers support name/ID search, provider filtering, favorites and the
|
||||
eight most recently selected models, scoped to your account in this browser.
|
||||
|
||||
The OpenAI preset routes `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6.1-terra`,
|
||||
`gpt-6-sol` and `gpt-6-luna` through the Responses API for tool calling,
|
||||
with model-specific thinking and reasoning-effort defaults. Flash mode
|
||||
turns off reasoning for GPT-6.1 Terra, GPT-6 Sol and GPT-6 Luna.
|
||||
|
||||
The Moonshot (China) preset recognizes `kimi-k3.1-pro` and
|
||||
`kimi-k3.1-pro-highspeed` with required thinking, image input and native
|
||||
`low` / `high` / `max` reasoning effort (default `max`). Thinking stays on
|
||||
|
||||
@ -323,7 +323,7 @@ are:
|
||||
|
||||
| Provider ID | Service / credential scope | Endpoint | Protocol / authentication |
|
||||
| --- | --- | --- | --- |
|
||||
| `openai` | OpenAI API | `https://api.openai.com/v1` | Chat Completions / Bearer |
|
||||
| `openai` | OpenAI API | `https://api.openai.com/v1` | Responses for the GPT-6 IDs below; otherwise Chat Completions / Bearer |
|
||||
| `anthropic` | Anthropic API | `https://api.anthropic.com` | Native Messages `/v1/messages` / `x-api-key` |
|
||||
| `deepseek` | DeepSeek API | `https://api.deepseek.com` | Chat Completions / Bearer |
|
||||
| `qwen-cn` | Alibaba Cloud Bailian / Qwen, China | `https://dashscope.aliyuncs.com/compatible-mode/v1` | Chat Completions / Bearer |
|
||||
@ -362,6 +362,17 @@ with provider filtering and favorites/recent groups. The eight recent selections
|
||||
are browser-local and user-scoped, and only currently available models appear.
|
||||
Selecting a model continues to use the existing conversation/account preferences.
|
||||
|
||||
The `openai` preset recognizes the exact IDs `gpt-6-astra`, `gpt-6.1-sol`,
|
||||
`gpt-6.1-terra`, `gpt-6-sol` and `gpt-6-luna`. They use the Responses API
|
||||
for both the connection probe and chat, including tool calls and tool-result
|
||||
follow-ups. Astra and GPT-6.1 Sol require thinking, default to `medium`, and
|
||||
accept `low`, `medium`, `high`, `xhigh` and `max`. The other three also accept
|
||||
`none`: disabling thinking uses `none`, while enabling it without an explicit
|
||||
effort uses `medium`. Generic `minimal` maps to `low`. Image input is enabled.
|
||||
Explicit capability overrides remain supported; routing stays provider-owned.
|
||||
Unknown IDs, older OpenAI presets and custom endpoints keep their existing
|
||||
behavior. See OpenAI's [GPT-6 protocol and parameter guide](https://developers.openai.com/api/docs/guides/latest-model).
|
||||
|
||||
The `moonshot-cn` preset recognizes the exact IDs `kimi-k3.1-pro` and
|
||||
`kimi-k3.1-pro-highspeed`. Both require thinking, preserve reasoning history,
|
||||
and accept top-level `reasoning_effort` values `low`, `high` and `max`, with
|
||||
|
||||
@ -227,6 +227,20 @@ endpoints; `ManagedModel.runtime_config()` combines them with profile fields.
|
||||
|
||||
### Web model provider presets
|
||||
|
||||
The five exact OpenAI GPT-6 IDs in `OPENAI_GPT6_MODELS` explicitly select Responses:
|
||||
their preset supplies `base_url`, which intentionally bypasses the factory's
|
||||
fallback routing. Astra and 6.1 Sol require thinking; Terra, Sol and Luna can
|
||||
disable it with `none`. For optional presets, the enable template defaults to
|
||||
`medium` and the disabled template's `none` takes precedence over request effort.
|
||||
Explicit reasoning overrides discard incompatible preset effort values while
|
||||
preserving non-effort templates and provider-owned Responses routing. Translate
|
||||
saved effort paths to ChatOpenAI's `reasoning_effort` on a runtime copy, leaving
|
||||
encrypted records unchanged. After resolving effort and caller overrides, omit
|
||||
temperature, top_p and top_logprobs for these official presets unless effort is
|
||||
`none`; preserve sampling for that disabled mode and other providers.
|
||||
Keep custom endpoints and unknown IDs unchanged. Real SDK probe, saved-chat and tool-follow-up coverage:
|
||||
`tests/test_managed_openai_gpt6.py`.
|
||||
|
||||
Managed model provider presets live only in `managed_model_providers.py`. `managed_models.py` reads legacy custom encrypted records compatibly and converts presets into ModelConfig without changing YAML, previous snapshots or injected configurations. See the managed-model section in `../models/AGENTS.md` and the Web registration section of `backend/docs/CONFIGURATION.md`.
|
||||
|
||||
Catalog imports use `ManagedModelStore.save(deduplicate=True)` under the existing
|
||||
|
||||
@ -13,6 +13,7 @@ _OPENAI = "langchain_openai:ChatOpenAI"
|
||||
_DEEPSEEK = "deerflow.models.patched_deepseek:PatchedChatDeepSeek"
|
||||
_MINIMAX = "deerflow.models.patched_minimax:PatchedChatMiniMax"
|
||||
MOONSHOT_K31_MODELS = frozenset({"kimi-k3.1-pro", "kimi-k3.1-pro-highspeed"})
|
||||
OPENAI_GPT6_MODELS = frozenset({"gpt-6-astra", "gpt-6.1-sol", "gpt-6.1-terra", "gpt-6-sol", "gpt-6-luna"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -84,7 +85,25 @@ def _capabilities(provider: str, model: str) -> dict[str, Any]:
|
||||
thinking/vision/effort is model-specific. Do not broaden these to substrings.
|
||||
"""
|
||||
result: dict[str, Any] = {"reasoning": {"thinking": "unsupported", "dialect": "none"}}
|
||||
if provider == "openai" and model in {"gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"}:
|
||||
if provider == "openai" and model in OPENAI_GPT6_MODELS:
|
||||
# Presets supply base_url, so the factory deliberately skips its
|
||||
# endpoint-agnostic GPT-6 routing fallback. Own the official route here.
|
||||
required = model in {"gpt-6-astra", "gpt-6.1-sol"}
|
||||
effort = {"values": ["low", "medium", "high", "xhigh", "max"], "aliases": {"minimal": "low"}}
|
||||
result.update(
|
||||
use_responses_api=True,
|
||||
output_version="responses/v1",
|
||||
supports_vision=True,
|
||||
reasoning={"thinking": "required" if required else "optional", "dialect": "none", "effort": effort},
|
||||
)
|
||||
if required:
|
||||
effort["default"] = "medium"
|
||||
else:
|
||||
effort["values"].insert(0, "none")
|
||||
# Pick the default effort from the effective thinking mode.
|
||||
result["when_thinking_enabled"] = {"reasoning_effort": "medium"}
|
||||
result["when_thinking_disabled"] = {"reasoning_effort": "none"}
|
||||
elif provider == "openai" and model in {"gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"}:
|
||||
result["supports_vision"] = True
|
||||
elif provider == "anthropic" and model in {"claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-sonnet-4-5-20250929"}:
|
||||
result.update(
|
||||
|
||||
@ -21,7 +21,7 @@ from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationInfo, fi
|
||||
from deerflow.config._boolean_guards import reject_boolean
|
||||
from deerflow.config.extensions_config import extensions_config_file_lock
|
||||
from deerflow.config.file_signature import get_config_signature
|
||||
from deerflow.config.managed_model_providers import get_managed_model_provider, resolve_managed_model_provider, resolve_preset_model
|
||||
from deerflow.config.managed_model_providers import OPENAI_GPT6_MODELS, get_managed_model_provider, resolve_managed_model_provider, resolve_preset_model
|
||||
from deerflow.config.model_config import ModelConfig, ReasoningCapabilities
|
||||
from deerflow.config.runtime_paths import runtime_home
|
||||
from deerflow.config.secret_file import read_or_create_secret_file
|
||||
@ -121,7 +121,22 @@ class ManagedModel(BaseModel):
|
||||
settings.pop("when_thinking_enabled", None)
|
||||
if self.reasoning.thinking == "required":
|
||||
settings.pop("when_thinking_disabled", None)
|
||||
settings["reasoning"] = self.reasoning
|
||||
# Preset effort defaults must also obey the explicit contract.
|
||||
# Keep compatible defaults and non-effort protocol parameters.
|
||||
effort = self.reasoning.effort
|
||||
for field in ("when_thinking_enabled", "when_thinking_disabled"):
|
||||
template = settings.get(field)
|
||||
if template and "reasoning_effort" in template:
|
||||
if effort is None or effort.path != "reasoning_effort" or template["reasoning_effort"] not in effort.values:
|
||||
template.pop("reasoning_effort")
|
||||
if not template:
|
||||
settings.pop(field)
|
||||
reasoning = self.reasoning
|
||||
if self.provider == "openai" and self.model in OPENAI_GPT6_MODELS and effort is not None:
|
||||
# The preset owns Responses routing. Translate stored wire paths
|
||||
# on a runtime copy; ChatOpenAI serializes this as reasoning.effort.
|
||||
reasoning = reasoning.model_copy(update={"effort": effort.model_copy(update={"path": "reasoning_effort"})})
|
||||
settings["reasoning"] = reasoning
|
||||
if self.supports_vision is not None:
|
||||
settings["supports_vision"] = self.supports_vision
|
||||
if self.max_tokens is not None:
|
||||
|
||||
@ -78,7 +78,8 @@ generic `thinking_enabled` / `reasoning_effort` to it, and
|
||||
subagents, summarization, title, one-shot utilities): a required-thinking model
|
||||
never enters the disable branch, effort is mapped through aliases or the default
|
||||
and otherwise dropped, and `dialect` synthesizes the on/off payload when no
|
||||
template exists. Legacy profiles (no mapping-valued `reasoning:` contract) keep the historical path
|
||||
template exists. For a top-level effort contract, a disabled template that sets
|
||||
`reasoning_effort: "none"` wins over explicit or default request effort. Legacy profiles (no mapping-valued `reasoning:` contract) keep the historical path
|
||||
byte-for-byte, including the synthesized `reasoning_effort=minimal` on the
|
||||
OpenAI-compatible disable path. The lead agent and the subagent descriptor resolve
|
||||
the same policy first so run metadata reports the effective values. Design note:
|
||||
|
||||
@ -7,6 +7,7 @@ from langchain_openai.chat_models.base import BaseChatOpenAI
|
||||
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.managed_model_providers import OPENAI_GPT6_MODELS, get_managed_model_provider
|
||||
from deerflow.models.reasoning import ReasoningContract, ReasoningPolicyError, resolve_reasoning_contract, resolve_reasoning_request
|
||||
from deerflow.reflection import resolve_class
|
||||
from deerflow.tracing import build_tracing_callbacks
|
||||
@ -272,8 +273,9 @@ def _apply_contract_thinking_settings(
|
||||
* Disabled: ``when_thinking_disabled`` when present, else the dialect's
|
||||
disable payload. No effort value is ever synthesized here.
|
||||
* Effort: written last at ``effort.path`` so a validated request wins over
|
||||
a template value; ``None`` leaves any profile/template value in place. A
|
||||
model without an effort contract never forwards ``reasoning_effort``.
|
||||
a template value. An explicit top-level ``reasoning_effort: "none"`` in
|
||||
the disabled template takes precedence because it is the off switch.
|
||||
A model without an effort contract never forwards ``reasoning_effort``.
|
||||
"""
|
||||
dialect = contract.dialect if contract.dialect != "auto" else _infer_dialect(effective_wte)
|
||||
# Both directions mirror whichever vLLM switch the template declares, so a
|
||||
@ -290,6 +292,8 @@ def _apply_contract_thinking_settings(
|
||||
_merge_thinking_payload(settings, payload)
|
||||
elif model_config.when_thinking_disabled is not None:
|
||||
_merge_thinking_payload(settings, model_config.when_thinking_disabled)
|
||||
if contract.effort is not None and contract.effort.path == "reasoning_effort" and model_config.when_thinking_disabled.get("reasoning_effort") == "none":
|
||||
requested_reasoning_effort = None
|
||||
else:
|
||||
_merge_thinking_payload(settings, _dialect_payload(dialect, enabled=False, chat_template_kwargs=chat_template_kwargs))
|
||||
if contract.effort is None or contract.effort.path != "reasoning_effort":
|
||||
@ -710,6 +714,17 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
|
||||
# 先合并再展开,避免同名字段通过两个 **dict 传入时触发 TypeError。
|
||||
effective_model_settings = dict(model_settings_from_config)
|
||||
effective_model_settings.update({key: value for key, value in kwargs.items() if value is not None})
|
||||
if (
|
||||
issubclass(model_class, BaseChatOpenAI)
|
||||
and effective_model_settings.get("model") in OPENAI_GPT6_MODELS
|
||||
and effective_model_settings.get("base_url") == get_managed_model_provider("openai").endpoint
|
||||
and effective_model_settings.get("use_responses_api") is True
|
||||
and effective_model_settings.get("reasoning_effort") != "none"
|
||||
):
|
||||
# Resolve templates, request effort and caller overrides before filtering.
|
||||
# The locked SDK only applies these sampling restrictions to GPT-5.
|
||||
for key in ("temperature", "top_p", "top_logprobs"):
|
||||
effective_model_settings.pop(key, None)
|
||||
model_instance = model_class(**effective_model_settings)
|
||||
|
||||
if translate_context_window:
|
||||
|
||||
212
backend/tests/test_managed_openai_gpt6.py
Normal file
212
backend/tests/test_managed_openai_gpt6.py
Normal file
@ -0,0 +1,212 @@
|
||||
"""Official GPT-6 presets must use the real SDK's Responses tool path."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from langchain_core.messages import HumanMessage, ToolMessage
|
||||
|
||||
from app.gateway.routers import managed_models as router
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.managed_models import ManagedModel, ManagedModelStore, merge_managed_models
|
||||
from deerflow.models.factory import create_chat_model
|
||||
|
||||
MODELS = ("gpt-6-astra", "gpt-6.1-sol", "gpt-6.1-terra", "gpt-6-sol", "gpt-6-luna")
|
||||
REQUIRED = {"gpt-6-astra", "gpt-6.1-sol"}
|
||||
ADMIN = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
|
||||
TOOL = {"type": "function", "function": {"name": "connection_check", "description": "Check connectivity", "parameters": {"type": "object", "properties": {}}}}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wire(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
||||
requests = []
|
||||
|
||||
async def send(client, request, **kwargs):
|
||||
body = json.loads(request.content)
|
||||
requests.append((request, body))
|
||||
if request.url.path != "/v1/responses":
|
||||
return httpx.Response(400, request=request, json={"error": {"type": "invalid_request_error", "message": "Tools require Responses"}})
|
||||
final = any(item.get("type") == "function_call_output" for item in body["input"])
|
||||
item = (
|
||||
{"type": "message", "id": "msg-test", "role": "assistant", "status": "completed", "content": [{"type": "output_text", "text": "Connected.", "annotations": []}]}
|
||||
if final
|
||||
else {"type": "function_call", "id": "fc-test", "call_id": "call-test", "name": "connection_check", "arguments": "{}", "status": "completed"}
|
||||
)
|
||||
response = {
|
||||
"id": "resp-test",
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"model": body["model"],
|
||||
"status": "completed",
|
||||
"error": None,
|
||||
"incomplete_details": None,
|
||||
"output": [item],
|
||||
"parallel_tool_calls": True,
|
||||
"tool_choice": "auto",
|
||||
"tools": [],
|
||||
}
|
||||
events = [
|
||||
{"type": "response.created", "response": {**response, "status": "in_progress", "output": []}},
|
||||
{"type": "response.output_item.added", "output_index": 0, "item": item if final else {**item, "arguments": ""}},
|
||||
]
|
||||
events.append(
|
||||
{"type": "response.output_text.delta", "output_index": 0, "content_index": 0, "item_id": item["id"], "delta": "Connected."}
|
||||
if final
|
||||
else {"type": "response.function_call_arguments.delta", "output_index": 0, "item_id": item["id"], "delta": "{}"}
|
||||
)
|
||||
events.extend([{"type": "response.output_item.done", "output_index": 0, "item": item}, {"type": "response.completed", "response": response}])
|
||||
content = "".join(f"data: {json.dumps({**event, 'sequence_number': i})}\n\n" for i, event in enumerate(events))
|
||||
return httpx.Response(200, request=request, headers={"content-type": "text/event-stream"}, content=content)
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "send", send)
|
||||
return requests
|
||||
|
||||
|
||||
async def collect(model, messages):
|
||||
result = None
|
||||
async for chunk in model.astream(messages):
|
||||
result = chunk if result is None else result + chunk
|
||||
return result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_id", MODELS)
|
||||
async def test_web_probe_and_saved_chat_use_responses_with_tool_followup(wire, model_id):
|
||||
profile = ManagedModel(provider="openai", model=model_id, api_key="test-key")
|
||||
store = ManagedModelStore()
|
||||
assert await router.test_model(ADMIN, router.SaveModelRequest(config=profile)) == {"ok": True, "message": "success"}
|
||||
assert not store.path.exists()
|
||||
saved = store.save(profile, expected_revision=None)
|
||||
config = merge_managed_models(AppConfig.model_validate({"sandbox": {"use": "test"}}))
|
||||
model = create_chat_model(saved.name, app_config=config, attach_tracing=False)
|
||||
messages = [HumanMessage(content="Call connection_check.")]
|
||||
reply = await collect(model.bind_tools([TOOL], tool_choice="connection_check"), messages)
|
||||
assert reply.tool_calls[0]["name"] == "connection_check"
|
||||
final = await collect(model, [*messages, reply, ToolMessage(content="ok", tool_call_id=reply.tool_calls[0]["id"])])
|
||||
assert final.text == "Connected."
|
||||
assert len(wire) == 3
|
||||
for request, body in wire:
|
||||
assert str(request.url) == "https://api.openai.com/v1/responses"
|
||||
assert request.headers["authorization"] == "Bearer test-key"
|
||||
assert body["model"] == model_id
|
||||
assert body["reasoning"]["effort"] == ("medium" if model_id in REQUIRED else "none")
|
||||
assert "temperature" not in body
|
||||
assert wire[0][1]["max_output_tokens"] == (2048 if model_id in REQUIRED else 32)
|
||||
assert wire[0][1]["tool_choice"] == ("auto" if model_id in REQUIRED else {"type": "function", "name": "connection_check"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_id", MODELS)
|
||||
@pytest.mark.parametrize("effort,expected", [(None, "medium"), ("minimal", "low"), ("max", "max")])
|
||||
def test_official_gpt6_reasoning_parameters(model_id, effort, expected):
|
||||
profile = ManagedModel(provider="openai", model=model_id, api_key="test-key")
|
||||
config = AppConfig.model_validate({"models": [profile.runtime_config()], "sandbox": {"use": "test"}})
|
||||
client = create_chat_model("connection-check", app_config=config, thinking_enabled=True, reasoning_effort=effort, attach_tracing=False)
|
||||
assert client.reasoning_effort == expected
|
||||
assert client.use_responses_api is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_id", ("gpt-6.1-terra", "gpt-6-sol", "gpt-6-luna"))
|
||||
@pytest.mark.parametrize("effort", ["minimal", "low", "high"])
|
||||
async def test_flash_mode_disables_reasoning_even_with_explicit_effort(wire, model_id, effort):
|
||||
profile = ManagedModel(provider="openai", model=model_id, api_key="test-key")
|
||||
config = AppConfig.model_validate({"models": [profile.runtime_config()], "sandbox": {"use": "test"}})
|
||||
model = create_chat_model("connection-check", app_config=config, thinking_enabled=False, reasoning_effort=effort, attach_tracing=False)
|
||||
|
||||
await collect(model.bind_tools([TOOL]), [HumanMessage(content="Call connection_check.")])
|
||||
|
||||
assert wire[0][1]["reasoning"]["effort"] == "none"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider,model_id", [("openai-compatible", "gpt-6.1-sol"), ("openai", "gpt-6-future"), ("openai", "gpt-4.1"), ("qwen-cn", "gpt-6.1-sol")])
|
||||
def test_unrelated_routes_are_unchanged(provider, model_id):
|
||||
kwargs = {"base_url": "https://proxy.example.com/v1"} if provider == "openai-compatible" else {}
|
||||
config = ManagedModel(provider=provider, model=model_id, api_key="test-key", **kwargs).runtime_config()
|
||||
assert config.use_responses_api is None
|
||||
assert config.output_version is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_id", MODELS)
|
||||
def test_capability_override_keeps_official_responses_route(model_id):
|
||||
config = ManagedModel(provider="openai", model=model_id, api_key="test-key", reasoning={"thinking": "unsupported", "dialect": "none"}).runtime_config()
|
||||
assert config.use_responses_api is True
|
||||
assert not config.supports_thinking
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_id", MODELS)
|
||||
@pytest.mark.parametrize(
|
||||
"reasoning",
|
||||
[
|
||||
{"thinking": "unsupported", "dialect": "none"},
|
||||
{"thinking": "optional", "dialect": "none"},
|
||||
{"thinking": "required", "dialect": "none"},
|
||||
{"thinking": "optional", "dialect": "none", "effort": {"values": ["high"], "default": "high"}},
|
||||
{"thinking": "optional", "dialect": "none", "effort": {"values": ["high"], "path": "extra_body.reasoning_effort"}},
|
||||
],
|
||||
)
|
||||
async def test_explicit_contract_can_be_probed_saved_and_reloaded(wire, model_id, reasoning):
|
||||
profile = ManagedModel(provider="openai", model=model_id, api_key="test-key", reasoning=reasoning)
|
||||
assert await router.test_model(ADMIN, router.SaveModelRequest(config=profile)) == {"ok": True, "message": "success"}
|
||||
saved = ManagedModelStore().save(profile, expected_revision=None)
|
||||
loaded = merge_managed_models(AppConfig.model_validate({"sandbox": {"use": "test"}}))
|
||||
runtime = next(model for model in loaded.models if model.name == saved.name)
|
||||
expected = profile.reasoning.model_copy(deep=True)
|
||||
if expected.effort is not None:
|
||||
expected.effort.path = "reasoning_effort"
|
||||
assert runtime.reasoning == expected
|
||||
assert runtime.use_responses_api is True
|
||||
assert runtime.output_version == "responses/v1"
|
||||
if reasoning.get("effort", {}).get("default") == "high":
|
||||
assert wire[0][1]["reasoning"]["effort"] == "high"
|
||||
else:
|
||||
assert "reasoning" not in wire[0][1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_id", MODELS)
|
||||
@pytest.mark.parametrize("effort", ["none", "high"])
|
||||
@pytest.mark.parametrize("path", ["extra_body.reasoning_effort", "output_config.effort", "effort"])
|
||||
async def test_saved_legacy_effort_path_uses_responses_wire_format(wire, model_id, effort, path):
|
||||
profile = ManagedModel(
|
||||
provider="openai",
|
||||
model=model_id,
|
||||
api_key="test-key",
|
||||
reasoning={"thinking": "optional", "dialect": "none", "effort": {"values": ["none", "high"], "default": effort, "path": path}},
|
||||
)
|
||||
assert await router.test_model(ADMIN, router.SaveModelRequest(config=profile)) == {"ok": True, "message": "success"}
|
||||
saved = ManagedModelStore().save(profile, expected_revision=None)
|
||||
config = merge_managed_models(AppConfig.model_validate({"sandbox": {"use": "test"}}))
|
||||
model = create_chat_model(saved.name, app_config=config, attach_tracing=False)
|
||||
await collect(model.bind_tools([TOOL]), [HumanMessage(content="Call connection_check.")])
|
||||
for request, body in wire:
|
||||
assert request.url.path == "/v1/responses"
|
||||
assert body["reasoning"]["effort"] == effort
|
||||
assert "reasoning_effort" not in body
|
||||
# Runtime translation must not migrate the encrypted record or input object.
|
||||
assert ManagedModelStore().list()[0].reasoning == profile.reasoning
|
||||
assert profile.reasoning.effort.path == path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_id", MODELS)
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
@pytest.mark.parametrize("source", ["agent", "kwargs"])
|
||||
async def test_sampling_follows_effective_reasoning_effort(wire, model_id, enabled, source):
|
||||
profile = ManagedModel(provider="openai", model=model_id, api_key="test-key")
|
||||
config = AppConfig.model_validate({"models": [profile.runtime_config()], "sandbox": {"use": "test"}})
|
||||
sampling = {"temperature": 0.4, "top_p": 0.8, "top_logprobs": 2}
|
||||
options = {"model_overrides": sampling} if source == "agent" else sampling
|
||||
model = create_chat_model("connection-check", app_config=config, thinking_enabled=enabled, reasoning_effort="high", attach_tracing=False, **options)
|
||||
await collect(model.bind_tools([TOOL]), [HumanMessage(content="Call connection_check.")])
|
||||
body = wire[0][1]
|
||||
expected_effort = "high" if enabled or model_id in REQUIRED else "none"
|
||||
assert body["reasoning"]["effort"] == expected_effort
|
||||
for key, value in sampling.items():
|
||||
if expected_effort == "none":
|
||||
assert body[key] == value
|
||||
else:
|
||||
assert key not in body
|
||||
assert sampling == {"temperature": 0.4, "top_p": 0.8, "top_logprobs": 2}
|
||||
Loading…
x
Reference in New Issue
Block a user