From 5f7f2a0fdadebab9a5321132b70620cb5516669c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Sat, 3 Oct 2026 16:27:31 +0800 Subject: [PATCH 1/3] feat: support per-model Responses API protocol --- pyproject.toml | 2 +- src/iac_code/agent/agent_loop.py | 83 ++++- .../i18n/locales/de/LC_MESSAGES/messages.po | 4 + .../i18n/locales/es/LC_MESSAGES/messages.po | 4 + .../i18n/locales/fr/LC_MESSAGES/messages.po | 4 + .../i18n/locales/ja/LC_MESSAGES/messages.po | 4 + .../i18n/locales/pt/LC_MESSAGES/messages.po | 4 + .../i18n/locales/zh/LC_MESSAGES/messages.po | 4 + src/iac_code/providers/base.py | 2 + src/iac_code/providers/manager.py | 28 +- src/iac_code/providers/openai_provider.py | 4 + src/iac_code/providers/registry.py | 7 +- src/iac_code/providers/request_logging.py | 12 +- src/iac_code/providers/responses_codec.py | 344 ++++++++++++++++++ src/iac_code/providers/responses_provider.py | 261 +++++++++++++ src/iac_code/providers/thinking.py | 4 + src/iac_code/services/context_manager.py | 13 +- src/iac_code/services/telemetry/constants.py | 2 + src/iac_code/types/stream_events.py | 2 + tests/agent/test_responses_session.py | 267 ++++++++++++++ tests/providers/_responses_fakes.py | 92 +++++ .../test_dashscope_responses_provider.py | 163 +++++++++ .../test_openai_responses_provider.py | 327 +++++++++++++++++ tests/providers/test_responses_routing.py | 128 +++++++ tests/web/test_settings.py | 24 ++ uv.lock | 2 +- 26 files changed, 1766 insertions(+), 25 deletions(-) create mode 100644 src/iac_code/providers/responses_codec.py create mode 100644 src/iac_code/providers/responses_provider.py create mode 100644 tests/agent/test_responses_session.py create mode 100644 tests/providers/_responses_fakes.py create mode 100644 tests/providers/test_dashscope_responses_provider.py create mode 100644 tests/providers/test_openai_responses_provider.py create mode 100644 tests/providers/test_responses_routing.py diff --git a/pyproject.toml b/pyproject.toml index 90b3df8c6..56384b2e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "rich>=13.0", "pyyaml>=6.0", "pyperclip>=1.8.0", - "openai>=1.50", + "openai>=2.31.0", "httpx>=0.27.0", "packaging>=24.0", "tiktoken>=0.7.0", diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index aa9cdeed4..30d2083c3 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -67,6 +67,7 @@ TOOL_RENDER_VERBOSE_RESULT_IN_TRANSCRIPT_KEY, CloudResourceSelectionEvent, CompactionEvent, + ErrorEvent, MessageEndEvent, PermissionRequestEvent, PermissionWaitOutcome, @@ -843,14 +844,14 @@ def _sync_tool_definitions(self, system_prompt: str | None = None): return tool_definitions @staticmethod - def _provider_message_from_api(api_message: dict[str, Any]): + def _provider_message_from_api(api_message: dict[str, Any], metadata: dict[str, Any] | None = None): from iac_code.providers.base import ContentBlock from iac_code.providers.base import Message as ProviderMessage role = api_message["role"] content = api_message["content"] if isinstance(content, str): - return ProviderMessage(role=role, content=content) + return ProviderMessage(role=role, content=content, metadata=dict(metadata or {})) if isinstance(content, list): blocks = [] for block in content: @@ -875,14 +876,14 @@ def _provider_message_from_api(api_message: dict[str, Any]): ), ) ) - return ProviderMessage(role=role, content=blocks) + return ProviderMessage(role=role, content=blocks, metadata=dict(metadata or {})) return None def _get_provider_messages(self): """Convert context manager messages to provider Message format.""" provider_messages = [] for message in self.context_manager.get_context_messages(): - provider_message = self._provider_message_from_api(message.to_api_format()) + provider_message = self._provider_message_from_api(message.to_api_format(), message.metadata) if provider_message is not None: provider_messages.append(provider_message) return provider_messages @@ -901,7 +902,7 @@ def _get_provider_messages_with_telemetry(self): unmatched: dict[str, deque[str]] = defaultdict(deque) telemetry_messages: list[TelemetryInputMessage] = [] for message in self.context_manager.get_context_messages(): - provider_message = self._provider_message_from_api(message.to_api_format()) + provider_message = self._provider_message_from_api(message.to_api_format(), message.metadata) if provider_message is not None: provider_messages.append(provider_message) if isinstance(message.content, str): @@ -1914,9 +1915,53 @@ async def _stream_provider( async def _stream_provider_with_execution_control(self, **kwargs: Any) -> AsyncGenerator[StreamEvent, None]: async with execution_activity("llm"): - async with aclosing(self._stream_provider(**kwargs)) as provider_stream: - async for event in provider_stream: - yield event + for attempt in range(2): + provider = getattr(kwargs.get("lease"), "provider", None) + ratio = getattr(provider, "input_window_ratio", 1.0) + if isinstance(ratio, (int, float)) and ratio < 1.0: + # Bailian can silently drop input at ~80% of its window. + # Proactive compaction already ran; reject an oversized tail + # rather than sending a known over-budget tool/history chain. + output_limit = getattr(provider, "_max_completion_tokens", None) or 8192 + if self.context_manager.get_total_tokens() + output_limit > self.context_manager.context_window: + self._release_request_lease( + kwargs.get("request_manager") or self._provider_manager, kwargs.get("lease") + ) + message_id = "Responses input exceeds the safe context budget after local compaction." + yield ErrorEvent( + error=_("Responses input exceeds the safe context budget after local compaction."), + is_retryable=False, + i18n_message_id=message_id, + ) + return + context_error: ErrorEvent | None = None + async with aclosing(self._stream_provider(**kwargs)) as provider_stream: + async for event in provider_stream: + if isinstance(event, ErrorEvent) and event.context_limit_exceeded and attempt == 0: + context_error = event + else: + yield event + if context_error is None: + return + yield CompactionEvent(phase="started") + compact_event = await self._auto_compact(kwargs.get("request_manager")) + if compact_event is None: + yield CompactionEvent(phase="failed", reason="no_result") + yield context_error + return + yield compact_event + lease, system, tools = self._prepare_request_lease( + kwargs.get("request_manager") or self._provider_manager, + base_system_prompt=kwargs["system"], + ) + try: + messages, telemetry = self._get_provider_messages_with_telemetry() + except BaseException: + self._release_request_lease(kwargs.get("request_manager") or self._provider_manager, lease) + raise + kwargs.update( + lease=lease, messages=messages, system=system, tools=tools or None, telemetry_messages=telemetry + ) async def _execute_tool_batch_with_execution_control( self, @@ -1950,6 +1995,8 @@ def _prepare_request_lease( effective_model = get_model_name() if callable(get_model_name) else None if isinstance(effective_model, str) and effective_model: self.context_manager.set_model(effective_model) + ratio = getattr(getattr(lease, "provider", None), "input_window_ratio", 1.0) + self.context_manager.set_input_window_ratio(ratio if isinstance(ratio, (int, float)) else 1.0) self.context_manager.set_system_prompt(effective_system) self.context_manager.set_tool_definitions(tool_definitions) self._sync_tool_system_prompt(effective_system, tools=tools) @@ -2011,7 +2058,13 @@ async def _run_streaming_inner( # Auto-compact if needed try: - needs_compaction = self.context_manager.needs_compaction() + provider = getattr(lease, "provider", None) + ratio = getattr(provider, "input_window_ratio", 1.0) + if isinstance(ratio, (int, float)) and ratio < 1.0: + output_limit = getattr(provider, "_max_completion_tokens", None) or 8192 + needs_compaction = self.context_manager.needs_compaction(reserved_output_tokens=output_limit) + else: + needs_compaction = self.context_manager.needs_compaction() except BaseException: self._release_request_lease(request_manager, lease) raise @@ -2047,6 +2100,7 @@ async def _run_streaming_inner( thinking_blocks_by_index: dict[int, dict[str, Any]] = {} message_ended = False turn_stop_reason = "stop" + turn_provider_metadata: dict[str, Any] = {} try: provider_messages, telemetry_messages = self._get_provider_messages_with_telemetry() @@ -2132,9 +2186,11 @@ async def _run_streaming_inner( pending_tool_uses_by_id.clear() text_chunks.clear() thinking_blocks_by_index.clear() + turn_provider_metadata.clear() elif isinstance(event, MessageEndEvent): message_ended = True turn_stop_reason = event.stop_reason + turn_provider_metadata = dict(event.provider_metadata) if not message_ended: self._accepting_injected_user_messages = False @@ -2184,15 +2240,14 @@ async def _run_streaming_inner( ) self._accepting_injected_user_messages = bool(completed_tools) and _turn < self._max_turns - 1 - if assistant_blocks: - self.context_manager.add_assistant_message(assistant_blocks) + if assistant_blocks or turn_provider_metadata: + assistant_message = self.context_manager.add_assistant_message(assistant_blocks) + assistant_message.metadata.update(turn_provider_metadata) if self._session_storage: - from iac_code.agent.message import Message - self._session_storage.append( self._cwd, self._session_id, - Message(role="assistant", content=assistant_blocks), + assistant_message, git_branch=self._current_git_branch, ) diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 211dd887c..8d2ab24e4 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -769,6 +769,10 @@ msgstr "" msgid "Permission denied." msgstr "Zugriff verweigert." +#: src/iac_code/agent/agent_loop.py +msgid "Responses input exceeds the safe context budget after local compaction." +msgstr "Die Responses-Eingabe überschreitet nach der lokalen Komprimierung weiterhin das sichere Kontextlimit." + #: src/iac_code/agent/agent_loop.py msgid "" "select_cloud_resource must be called alone. Retry it as a standalone tool" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 65ae14df0..b722c51e8 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -760,6 +760,10 @@ msgstr "" msgid "Permission denied." msgstr "Permiso denegado." +#: src/iac_code/agent/agent_loop.py +msgid "Responses input exceeds the safe context budget after local compaction." +msgstr "Tras la compresión local, la entrada de Responses sigue superando el límite seguro de contexto." + #: src/iac_code/agent/agent_loop.py msgid "" "select_cloud_resource must be called alone. Retry it as a standalone tool" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 1cfc40ac9..45418ba36 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -751,6 +751,10 @@ msgstr "" msgid "Permission denied." msgstr "Permission refusée." +#: src/iac_code/agent/agent_loop.py +msgid "Responses input exceeds the safe context budget after local compaction." +msgstr "Après la compression locale, l’entrée de Responses dépasse encore la limite de contexte sûre." + #: src/iac_code/agent/agent_loop.py msgid "" "select_cloud_resource must be called alone. Retry it as a standalone tool" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index 845a1dbce..f79f3adb6 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -692,6 +692,10 @@ msgstr "" msgid "Permission denied." msgstr "権限が拒否されました。" +#: src/iac_code/agent/agent_loop.py +msgid "Responses input exceeds the safe context budget after local compaction." +msgstr "ローカル圧縮後も、Responses の入力が安全なコンテキスト上限を超えています。" + #: src/iac_code/agent/agent_loop.py msgid "" "select_cloud_resource must be called alone. Retry it as a standalone tool" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index abd42df00..742cf3cd4 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -750,6 +750,10 @@ msgstr "" msgid "Permission denied." msgstr "Permissão negada." +#: src/iac_code/agent/agent_loop.py +msgid "Responses input exceeds the safe context budget after local compaction." +msgstr "Após a compactação local, a entrada de Responses ainda excede o limite seguro de contexto." + #: src/iac_code/agent/agent_loop.py msgid "" "select_cloud_resource must be called alone. Retry it as a standalone tool" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 1360ba9f9..d1b9adc63 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -686,6 +686,10 @@ msgstr "用户明确拒绝了此次工具操作。这不是云 API 或 IAM 权 msgid "Permission denied." msgstr "权限被拒绝。" +#: src/iac_code/agent/agent_loop.py +msgid "Responses input exceeds the safe context budget after local compaction." +msgstr "本地压缩后,Responses 输入仍超过安全上下文额度。" + #: src/iac_code/agent/agent_loop.py msgid "" "select_cloud_resource must be called alone. Retry it as a standalone tool" diff --git a/src/iac_code/providers/base.py b/src/iac_code/providers/base.py index cd7901ef5..953c70f66 100644 --- a/src/iac_code/providers/base.py +++ b/src/iac_code/providers/base.py @@ -42,6 +42,7 @@ class Message: role: str # "user", "assistant" content: str | list[ContentBlock] = "" + metadata: dict[str, Any] = field(default_factory=dict, kw_only=True, repr=False) @classmethod def user(cls, text: str) -> Message: @@ -91,6 +92,7 @@ class NonStreamingResponse: usage: Usage thinking: str = "" thinking_blocks: list[dict[str, Any]] = field(default_factory=list) + provider_metadata: dict[str, Any] = field(default_factory=dict, kw_only=True, repr=False) usage_attribution: UsageAttribution | None = field(default=None, kw_only=True, compare=False) diff --git a/src/iac_code/providers/manager.py b/src/iac_code/providers/manager.py index e91f9692f..31b2cfadb 100644 --- a/src/iac_code/providers/manager.py +++ b/src/iac_code/providers/manager.py @@ -359,6 +359,7 @@ def _error_event_from_exception(exc: BaseException) -> ErrorEvent: error=summary, is_retryable=False, error_id=failure.error_id, + context_limit_exceeded=getattr(exc, "context_limit_exceeded", False) is True, i18n_message_id=message_id if isinstance(message_id, str) else None, i18n_message_args=dict(message_args) if isinstance(message_args, dict) else None, ) @@ -511,6 +512,11 @@ def create_provider( provider_cfg = get_provider_config(provider_key) else: provider_cfg = copy.deepcopy(provider_config_override) + model_cfg = _get_model_provider_config(provider_cfg, model) + model_entry = next((entry for entry in desc.models if entry.id == model), None) + api_mode = model_cfg.get("apiMode", model_entry.api_mode if model_entry is not None else "chat_completions") + if not isinstance(api_mode, str) or api_mode not in {"chat_completions", "responses"}: + raise ValueError("Model apiMode must be chat_completions or responses.") saved_base = provider_cfg.get("apiBase") configured_base_url = saved_base if isinstance(saved_base, str) and saved_base else None effective_base_url = base_url or configured_base_url or desc.base_url @@ -563,7 +569,16 @@ def create_provider( if wire_desc is not None: provider_class_path = wire_desc.provider_class provider_cls = _import_provider_class(provider_class_path) - if _should_use_qwen_provider(provider_cls, model): + if api_mode == "responses": + from iac_code.providers.responses_provider import ( + DashScopeResponsesProvider, + ResponsesProvider, + validate_responses_endpoint, + ) + + validate_responses_endpoint(provider_key, effective_base_url, model) + provider_cls = DashScopeResponsesProvider if provider_key == "dashscope" else ResponsesProvider + elif _should_use_qwen_provider(provider_cls, model): from iac_code.providers.qwen_provider import QwenProvider provider_cls = QwenProvider @@ -579,7 +594,7 @@ def create_provider( request_policy_kwargs["max_completion_tokens"] = max_completion_tokens from iac_code.providers.qwen_provider import QwenProvider - if issubclass(provider_cls, QwenProvider): + if issubclass(provider_cls, QwenProvider) or (api_mode == "responses" and provider_key == "dashscope"): request_policy_kwargs["thinking_intent"] = thinking_intent else: from iac_code.providers.anthropic_provider import AnthropicProvider @@ -828,6 +843,8 @@ def _telemetry_provider_name(provider: Any) -> str: return "dashscope" if _is_bailian_compatible_endpoint(_provider_endpoint_url(provider)): return "dashscope" + if wire_provider_key == "openai" and getattr(provider, "api_mode", None) == "responses": + return "openai" return type(provider).__name__.replace("Provider", "").lower() @@ -1665,6 +1682,12 @@ async def _stream_impl( message_id=msg_id, affected_tool_use_ids=outcome.orphaned_tool_use_ids.get(msg_id, []), ) + if ( + stream_failure_exception is not None + and getattr(stream_failure_exception, "context_limit_exceeded", False) is True + ): + yield _error_event_from_exception(stream_failure_exception) + return if isinstance(stream_failure_exception, UnsafeStreamProtocolError): span_name = f"{Spans.LLM_CHAT} {model}" session_id = _safe_session_id() @@ -1915,6 +1938,7 @@ def commit_replay_failure( yield MessageEndEvent( stop_reason=response.stop_reason, usage=response.usage, + provider_metadata=response.provider_metadata, usage_attribution=attribution, ) diff --git a/src/iac_code/providers/openai_provider.py b/src/iac_code/providers/openai_provider.py index 13cf2f934..6a9b971bc 100644 --- a/src/iac_code/providers/openai_provider.py +++ b/src/iac_code/providers/openai_provider.py @@ -352,6 +352,10 @@ def _build_chat_completion_kwargs( max_tokens: int, context: ChatRequestContext, ) -> dict[str, Any]: + if tools and self._PROVIDER_KEY == "openai" and self._model in {"gpt-6-astra", "gpt-6-sol", "gpt-6-luna"}: + effort = self._build_thinking_kwargs().get("reasoning_effort", "medium") + if self._model == "gpt-6-astra" or effort != "none": + raise ValueError("This GPT-6 tool request requires apiMode=responses.") kwargs: dict[str, Any] = { "model": self._model, "messages": self._build_api_messages(messages, system, cache_policy=context.cache_policy), diff --git a/src/iac_code/providers/registry.py b/src/iac_code/providers/registry.py index 4018f170c..c10d77785 100644 --- a/src/iac_code/providers/registry.py +++ b/src/iac_code/providers/registry.py @@ -10,6 +10,7 @@ class ModelEntry: id: str is_default: bool = False support_multimodal: bool = False + api_mode: str = "chat_completions" @dataclass(frozen=True) @@ -121,9 +122,9 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.openai_provider.OpenAIProvider", base_url=None, models=[ - # GPT-6 Astra requires the Responses API for tool calling. Keep the - # Chat-Completions-based iac-code adapter on GPT-5.6 Sol by default. - ModelEntry("gpt-6-astra", support_multimodal=True), + ModelEntry("gpt-6-astra", support_multimodal=True, api_mode="responses"), + ModelEntry("gpt-6-sol", support_multimodal=True, api_mode="responses"), + ModelEntry("gpt-6-luna", support_multimodal=True, api_mode="responses"), ModelEntry("gpt-5.6-sol", is_default=True, support_multimodal=True), ModelEntry("gpt-5.6", support_multimodal=True), ModelEntry("gpt-5.6-terra", support_multimodal=True), diff --git a/src/iac_code/providers/request_logging.py b/src/iac_code/providers/request_logging.py index bb946d66c..837d697b8 100644 --- a/src/iac_code/providers/request_logging.py +++ b/src/iac_code/providers/request_logging.py @@ -8,7 +8,14 @@ from loguru import logger _LOG_PREFIX = "[provider-request-policy]" -_REQUEST_KEYS: tuple[str, ...] = ("stream", "max_tokens", "max_completion_tokens", "reasoning_effort") +_REQUEST_KEYS: tuple[str, ...] = ( + "stream", + "max_tokens", + "max_completion_tokens", + "reasoning_effort", + "max_output_tokens", + "store", +) _EXTRA_BODY_KEYS: tuple[str, ...] = ("enable_thinking", "thinking_budget", "thinking") _THINKING_KEYS: tuple[str, ...] = ("type", "budget_tokens") @@ -35,6 +42,9 @@ def payload(self) -> dict[str, Any]: thinking = self._sanitize_thinking(self._kwargs.get("thinking")) if thinking: request["thinking"] = thinking + reasoning = self._kwargs.get("reasoning") + if isinstance(reasoning, dict) and "effort" in reasoning: + request["reasoning"] = {"effort": self._json_scalar(reasoning["effort"])} return { "provider": self._provider_key, diff --git a/src/iac_code/providers/responses_codec.py b/src/iac_code/providers/responses_codec.py new file mode 100644 index 000000000..7d538b869 --- /dev/null +++ b/src/iac_code/providers/responses_codec.py @@ -0,0 +1,344 @@ +"""Responses items and SSE conversion for the existing provider contract.""" + +from __future__ import annotations + +import copy +import json +from typing import Any + +from iac_code.providers.base import Message, NonStreamingResponse, ToolDefinition +from iac_code.types.stream_events import ( + StreamEvent, + TextDeltaEvent, + ThinkingDeltaEvent, + ToolInputDeltaEvent, + ToolUseEndEvent, + ToolUseStartEvent, + Usage, +) + +RESPONSES_METADATA_KEY = "responses" +_OUTPUT_TYPES = {"message", "reasoning", "function_call"} + + +class ResponsesProtocolError(RuntimeError): + """A response cannot be represented by the local agent contract.""" + + +class ResponsesContextLimitError(RuntimeError): + """Ask the owning agent to compact local history before one retry.""" + + context_limit_exceeded = True + + +def plain(value: Any) -> Any: + """Keep SDK extension fields (e.g. phase) when persisting output items.""" + if hasattr(value, "model_dump"): + return value.model_dump(mode="json", exclude_none=True) + if isinstance(value, dict): + return {key: plain(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [plain(item) for item in value] + if hasattr(value, "__dict__"): + return plain(vars(value)) + return value + + +def _call(item: dict[str, Any], names: set[str] | None = None) -> dict[str, Any]: + call_id, name, arguments = item.get("call_id"), item.get("name"), item.get("arguments") + if not isinstance(call_id, str) or not call_id or not isinstance(name, str) or not name: + raise ResponsesProtocolError("Responses function call is missing its call_id or name.") + if names is not None and name not in names: + raise ResponsesProtocolError("Responses returned an unknown local function.") + if item.get("status") not in {None, "completed"}: + raise ResponsesProtocolError("Responses returned an unfinished function call.") + if not isinstance(arguments, str): + raise ResponsesProtocolError("Responses function arguments are not complete JSON.") + try: + parsed = json.loads(arguments) + except (TypeError, ValueError) as exc: + raise ResponsesProtocolError("Responses function arguments are not complete JSON.") from exc + if not isinstance(parsed, dict): + raise ResponsesProtocolError("Responses function arguments must be a JSON object.") + return {"id": call_id, "name": name, "input": parsed} + + +def _pair_functions(items: list[dict[str, Any]], *, adjacent: bool) -> list[dict[str, Any]]: + """Validate tool linkage; Bailian needs each result immediately after its call.""" + calls: dict[str, int] = {} + results: dict[str, dict[str, Any]] = {} + for index, item in enumerate(items): + if item.get("type") == "function_call": + call_id = _call(item)["id"] + if call_id in calls: + raise ResponsesProtocolError("Responses history contains duplicate call IDs.") + calls[call_id] = index + elif item.get("type") == "function_call_output": + call_id = item.get("call_id") + if call_id not in calls or call_id in results: + raise ResponsesProtocolError("Responses tool result has no unique preceding call.") + results[call_id] = item + if calls.keys() != results.keys(): + raise ResponsesProtocolError("Responses history contains a function call without its result.") + if not adjacent: + return items + paired: list[dict[str, Any]] = [] + for item in items: + if item.get("type") == "function_call_output": + continue + paired.append(item) + if item.get("type") == "function_call": + paired.append(results[item["call_id"]]) + return paired + + +def encode_input(messages: list[Message], identity: dict[str, Any]) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for message in messages: + native = message.metadata.get(RESPONSES_METADATA_KEY) + if ( + message.role == "assistant" + and isinstance(native, dict) + and all(native.get(key) == value for key, value in identity.items()) + and isinstance(native.get("output"), list) + ): + for item in native["output"]: + if not isinstance(item, dict) or item.get("type") not in _OUTPUT_TYPES: + raise ResponsesProtocolError("Responses history contains an unsupported output item.") + items.append(copy.deepcopy(item)) + continue + if isinstance(message.content, str): + items.append({"role": message.role, "content": message.content}) + continue + content: list[dict[str, Any]] = [] + + def flush() -> None: + if content: + # EasyInputMessage accepts assistant text strings on both profiles; + # Bailian reserves input_text content blocks for user messages. + value = "".join(part["text"] for part in content) if message.role == "assistant" else list(content) + items.append({"role": message.role, "content": value}) + content.clear() + + for block in message.content: + if block.type == "text": + content.append({"type": "input_text", "text": block.text or ""}) + elif block.type == "image": + if message.role != "user" or not block.media_type or not block.data: + raise ResponsesProtocolError("Responses image input requires a user image data URL.") + content.append({"type": "input_image", "image_url": f"data:{block.media_type};base64,{block.data}"}) + elif block.type == "tool_use": + flush() + items.append( + { + "type": "function_call", + "call_id": block.tool_use_id, + "name": block.name, + "arguments": json.dumps(block.input or {}, ensure_ascii=False), + } + ) + elif block.type == "tool_result": + flush() + items.append( + {"type": "function_call_output", "call_id": block.tool_use_id, "output": block.content or ""} + ) + elif block.type not in {"thinking", "redacted_thinking"}: + raise ResponsesProtocolError("Responses received an unsupported input content block.") + flush() + return _pair_functions(items, adjacent=identity["profile"] == "dashscope") + + +def encode_tools(tools: list[ToolDefinition] | None, *, strict: bool) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for tool in tools or []: + item: dict[str, Any] = { + "type": "function", + "name": tool.name, + "description": tool.description, + "parameters": copy.deepcopy(tool.input_schema), + } + if strict: + item["strict"] = False + result.append(item) + return result + + +def decode_response( + response: Any, identity: dict[str, Any], tools: list[ToolDefinition] | None +) -> NonStreamingResponse: + data = plain(response) + if not isinstance(data, dict) or not isinstance(data.get("output"), list) or not data.get("id"): + raise ResponsesProtocolError("Responses returned an invalid response.") + status = data.get("status") + incomplete = status == "incomplete" and (data.get("incomplete_details") or {}).get("reason") == "max_output_tokens" + if status != "completed" and not incomplete: + raise ResponsesProtocolError("Responses did not complete successfully.") + names = {tool.name for tool in tools or []} + text: list[str] = [] + thinking: list[str] = [] + calls: list[dict[str, Any]] = [] + refusal = False + for item in data["output"]: + if not isinstance(item, dict) or item.get("type") not in _OUTPUT_TYPES: + raise ResponsesProtocolError("Responses returned an unsupported output item type.") + if item["type"] == "function_call": + calls.append(_call(item, names)) + elif item["type"] == "reasoning": + thinking.extend(part.get("text", "") for part in item.get("summary", []) if isinstance(part, dict)) + else: + for part in item.get("content", []): + if part.get("type") == "output_text": + text.append(part.get("text", "")) + elif part.get("type") == "refusal": + refusal = True + text.append(part.get("refusal", "")) + else: + raise ResponsesProtocolError("Responses returned unsupported message content.") + if len({call["id"] for call in calls}) != len(calls): + raise ResponsesProtocolError("Responses returned duplicate function call IDs.") + if incomplete and calls: + # Only text-only truncation can be committed without native replay items. + raise ResponsesProtocolError("Responses returned an incomplete tool turn.") + if not data["output"] or (status == "completed" and not text and not calls and not refusal): + raise ResponsesProtocolError("Responses returned no assistant content or function calls.") + usage = data.get("usage") or {} + details = usage.get("input_tokens_details") or {} + metadata = {} if incomplete or refusal else {RESPONSES_METADATA_KEY: {**identity, "output": data["output"]}} + return NonStreamingResponse( + message_id=data["id"], + text="".join(text), + thinking="".join(thinking), + tool_uses=[] if refusal else calls, + stop_reason="refusal" if refusal else "max_tokens" if incomplete else "tool_use" if calls else "end_turn", + usage=Usage( + input_tokens=usage.get("input_tokens", 0) or 0, + output_tokens=usage.get("output_tokens", 0) or 0, + cache_read_input_tokens=details.get("cached_tokens", 0) or 0, + reported=data.get("usage") is not None, + ), + provider_metadata=metadata, + ) + + +class ResponsesStreamAdapter: + """Request-local item assembly, validated before the terminal event.""" + + def __init__(self, identity: dict[str, Any], tools: list[ToolDefinition] | None) -> None: + self.identity = identity + self.tools = tools + self.items: dict[int, dict[str, Any]] = {} + self.started: set[str] = set() + self.ended: dict[str, dict[str, Any]] = {} + self.text = "" + self.thinking = "" + self.terminal: NonStreamingResponse | None = None + + def _finish_call(self, item: dict[str, Any]) -> list[StreamEvent]: + call = _call(item, {tool.name for tool in self.tools or []}) + call_id = call["id"] + if call_id in self.ended: + if self.ended[call_id] != call: + raise ResponsesProtocolError("Responses changed a completed function call.") + return [] + events: list[StreamEvent] = [] + if call_id not in self.started: + self.started.add(call_id) + events.append(ToolUseStartEvent(tool_use_id=call_id, name=call["name"])) + self.ended[call_id] = call + events.append(ToolUseEndEvent(tool_use_id=call_id, name=call["name"], input=call["input"])) + return events + + def feed(self, event: Any) -> list[StreamEvent]: + data = plain(event) + kind = data.get("type", "") + if self.terminal is not None: + raise ResponsesProtocolError("Responses sent events after its terminal response.") + if kind in {"response.completed", "response.incomplete"}: + response = decode_response(data.get("response"), self.identity, self.tools) + output = plain(data["response"])["output"] + for index, previous in self.items.items(): + if index < 0 or index >= len(output): + raise ResponsesProtocolError("Responses terminal response dropped a streamed item.") + final = output[index] + if previous.get("type") != final.get("type") or previous.get("id") != final.get("id"): + raise ResponsesProtocolError("Responses terminal item identity differs from its stream.") + if previous.get("type") == "function_call": + if previous.get("call_id") != final.get("call_id") or previous.get("name") != final.get("name"): + raise ResponsesProtocolError("Responses changed a function identity.") + if not final.get("arguments", "").startswith(previous.get("arguments") or ""): + raise ResponsesProtocolError("Responses final function arguments differ from their stream.") + events: list[StreamEvent] = [] + if not response.text.startswith(self.text) or not response.thinking.startswith(self.thinking): + raise ResponsesProtocolError("Responses terminal content differs from its stream.") + if response.text[len(self.text) :]: + events.append(TextDeltaEvent(text=response.text[len(self.text) :])) + if response.thinking[len(self.thinking) :]: + events.append(ThinkingDeltaEvent(text=response.thinking[len(self.thinking) :])) + if response.stop_reason != "refusal": + if not self.started.issubset({call["id"] for call in response.tool_uses}): + raise ResponsesProtocolError("Responses terminal response dropped a streamed function call.") + for item in output: + if item.get("type") == "function_call": + events.extend(self._finish_call(item)) + self.terminal = response + return events + if kind in {"response.failed", "error"}: + error = (data.get("response") or {}).get("error") if kind == "response.failed" else data + if isinstance(error, dict) and error.get("code") in { + "context_length_exceeded", + "context_window_exceeded", + "max_input_tokens", + }: + raise ResponsesContextLimitError("Responses input exceeds the model context limit.") + raise ResponsesProtocolError("Responses generation failed.") + if kind in {"response.output_text.delta", "response.refusal.delta"}: + delta = data.get("delta") or "" + self.text += delta + return [TextDeltaEvent(text=delta)] if delta else [] + if kind in {"response.reasoning_summary_text.delta", "response.reasoning_text.delta"}: + delta = data.get("delta") or "" + self.thinking += delta + return [ThinkingDeltaEvent(text=delta)] if delta else [] + if kind == "response.output_item.added": + index, item = data.get("output_index"), data.get("item") + if not isinstance(index, int) or index in self.items or not isinstance(item, dict): + raise ResponsesProtocolError("Responses stream contains an invalid or duplicate item index.") + if item.get("type") not in _OUTPUT_TYPES: + raise ResponsesProtocolError("Responses returned an unsupported output item type.") + self.items[index] = item + if item["type"] == "function_call": + call_id, name = item.get("call_id"), item.get("name") + if not call_id or not name or name not in {tool.name for tool in self.tools or []}: + raise ResponsesProtocolError("Responses stream contains an invalid local function call.") + if call_id in self.started: + raise ResponsesProtocolError("Responses stream contains duplicate function call IDs.") + self.started.add(call_id) + return [ToolUseStartEvent(tool_use_id=call_id, name=name)] + if kind.startswith("response.function_call_arguments."): + item = self.items.get(data.get("output_index")) + if item is None or item.get("type") != "function_call" or item.get("id") != data.get("item_id"): + raise ResponsesProtocolError("Responses arguments do not match a preceding function item.") + if kind.endswith(".delta"): + delta = data.get("delta") or "" + if item["call_id"] in self.ended: + raise ResponsesProtocolError("Responses arguments changed after function completion.") + item["arguments"] = (item.get("arguments") or "") + delta + return [ToolInputDeltaEvent(tool_use_id=item["call_id"], partial_json=delta)] + if kind.endswith(".done"): + arguments = data.get("arguments") + if item.get("arguments") and item["arguments"] != arguments: + raise ResponsesProtocolError("Responses final function arguments differ from their stream.") + item["arguments"] = arguments + return self._finish_call({**item, "status": "completed"}) + if kind == "response.output_item.done": + item = data.get("item") or {} + previous = self.items.get(data.get("output_index")) + if previous is None or previous.get("id") != item.get("id") or previous.get("type") != item.get("type"): + raise ResponsesProtocolError("Responses completed an unknown output item.") + if item.get("type") == "function_call": + if previous.get("call_id") != item.get("call_id") or previous.get("name") != item.get("name"): + raise ResponsesProtocolError("Responses changed a function identity.") + if previous.get("arguments") and previous["arguments"] != item.get("arguments"): + raise ResponsesProtocolError("Responses final function arguments differ from their stream.") + return self._finish_call(item) + return [] diff --git a/src/iac_code/providers/responses_provider.py b/src/iac_code/providers/responses_provider.py new file mode 100644 index 000000000..54a911f64 --- /dev/null +++ b/src/iac_code/providers/responses_provider.py @@ -0,0 +1,261 @@ +"""Opt-in OpenAI and DashScope Responses providers.""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncGenerator +from typing import Any +from urllib.parse import urlsplit + +from iac_code.providers.base import Message, NonStreamingResponse, ToolDefinition +from iac_code.providers.openai_provider import OpenAIProvider +from iac_code.providers.request_headers import get_provider_request_headers +from iac_code.providers.request_logging import log_provider_request_policy +from iac_code.providers.responses_codec import ( + ResponsesContextLimitError, + ResponsesProtocolError, + ResponsesStreamAdapter, + decode_response, + encode_input, + encode_tools, +) +from iac_code.providers.thinking import get_thinking_spec, normalize_effort +from iac_code.providers.thinking_intent import ResolvedThinkingIntent +from iac_code.types.stream_events import MessageEndEvent, MessageStartEvent, StreamEvent + +# Models documented in Bailian's Responses API reference, 2026-10-03. +DASHSCOPE_RESPONSES_MODELS = frozenset( + { + "qwen3.8-max", + "qwen3.8-max-0902", + "qwen3.8-flash", + "qwen3.8-2.4t-a95b", + "qwen3.8-27b", + "qwen3.8-omni-flash", + "qwen3.7-max", + "qwen3.7-max-2026-05-20", + "qwen3.7-max-2026-06-08", + "qwen3.7-max-2026-05-17", + "qwen3.7-max-preview", + "qwen3-max", + "qwen3-max-2026-01-23", + "qwen3.7-plus", + "qwen3.7-plus-2026-05-26", + "qwen3.6-plus", + "qwen3.6-plus-2026-04-02", + "qwen3.5-plus", + "qwen3.5-plus-2026-04-20", + "qwen3.5-plus-2026-02-15", + "qwen3.7-flash", + "qwen3.7-flash-2026-07-15", + "qwen3.6-flash", + "qwen3.6-flash-2026-04-16", + "qwen3.5-flash", + "qwen3.5-flash-2026-02-23", + "qwen3.6-35b-a3b", + "qwen3.5-397b-a17b", + "qwen3.5-122b-a10b", + "qwen3.5-27b", + "qwen3.5-35b-a3b", + } +) +_DASHSCOPE_REGIONS = { + "cn-beijing", + "ap-southeast-1", + "us-east-1", + "eu-central-1", + "ap-northeast-1", + "cn-hongkong", +} + + +def validate_responses_endpoint(provider_key: str, base_url: str | None, model: str) -> None: + if provider_key not in {"openai", "dashscope"}: + raise ValueError("Responses API is supported only for OpenAI and standard DashScope in this release.") + if provider_key == "dashscope" and model not in DASHSCOPE_RESPONSES_MODELS: + raise ValueError("This DashScope model is not registered for Responses API.") + endpoint = urlsplit(base_url or "https://api.openai.com/v1") + if endpoint.scheme != "https" or endpoint.username or endpoint.password or endpoint.query or endpoint.fragment: + raise ValueError("Responses API requires a supported HTTPS base URL.") + if endpoint.port not in {None, 443}: + raise ValueError("Responses API requires a supported HTTPS base URL.") + host = endpoint.hostname or "" + if provider_key == "openai": + supported = host == "api.openai.com" and endpoint.path.rstrip("/") == "/v1" + else: + workspace = host.split(".") + supported = ( + host in {"dashscope.aliyuncs.com", "dashscope-intl.aliyuncs.com", "dashscope-us.aliyuncs.com"} + or ( + len(workspace) == 5 + and workspace[0] not in {"token-plan", "coding", "coding-intl"} + and workspace[1] in _DASHSCOPE_REGIONS + and workspace[2:] == ["maas", "aliyuncs", "com"] + ) + ) and endpoint.path.rstrip("/") == "/compatible-mode/v1" + if not supported: + raise ValueError("This base URL is not registered for Responses API.") + + +class ResponsesProvider(OpenAIProvider): + """Reuse SDK client setup; keep all Chat wire conversion out of this path.""" + + api_mode = "responses" + input_window_ratio = 1.0 + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + actual_base_url = getattr(self._client, "base_url", None) + validate_responses_endpoint( + self._PROVIDER_KEY, str(actual_base_url) if actual_base_url else self._base_url, self._model + ) + self._responses_identity = { + "profile": self._PROVIDER_KEY, + "provider": self._PROVIDER_KEY, + "model": self._model, + "endpoint": self._metadata_endpoint_id, + } + + def _responses_effort(self) -> str | None: + spec = get_thinking_spec(self._PROVIDER_KEY, self._model) + effort = normalize_effort(self._effort) + if self._thinking_disabled(): + effort = "none" + elif effort in {None, "auto"}: + effort = spec.default_effort_value + if effort is not None and spec.effort_values and effort not in spec.effort_values: + if effort == "none": + raise ValueError("The selected reasoning effort is not supported by this Responses model.") + effort = spec.default_effort_value + return effort + + def _build_responses_kwargs( + self, + messages: list[Message], + system: str, + tools: list[ToolDefinition] | None, + max_tokens: int, + *, + streaming: bool, + ) -> dict[str, Any]: + limit = self._max_completion_tokens or max_tokens + if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1: + raise ValueError("Responses max_output_tokens must be a positive integer.") + kwargs: dict[str, Any] = { + "model": self._model, + "input": encode_input(messages, self._responses_identity), + "max_output_tokens": limit, + "store": False, + "stream": streaming, + } + if system: + kwargs["instructions"] = system + if tools: + kwargs["tools"] = encode_tools(tools, strict=self._PROVIDER_KEY == "openai") + effort = self._responses_effort() + if effort is not None: + kwargs["reasoning"] = {"effort": effort} + if self._PROVIDER_KEY == "openai": + kwargs["include"] = ["reasoning.encrypted_content"] + headers = get_provider_request_headers() + if headers: + kwargs["extra_headers"] = dict(headers) + return kwargs + + async def _create_response(self, kwargs: dict[str, Any]) -> Any: + try: + return await self._client.responses.create(**kwargs) + except Exception as error: + body = getattr(error, "body", None) + code = getattr(error, "code", None) + if isinstance(body, dict): + detail = body.get("error", body) + if isinstance(detail, dict): + code = detail.get("code", code) + if code in {"context_length_exceeded", "context_window_exceeded", "max_input_tokens"}: + raise ResponsesContextLimitError("Responses input exceeds the model context limit.") from error + raise + + async def stream( + self, + messages: list[Message], + system: str, + tools: list[ToolDefinition] | None = None, + max_tokens: int = 8192, + ) -> AsyncGenerator[StreamEvent, None]: + kwargs = self._build_responses_kwargs(messages, system, tools, max_tokens, streaming=True) + log_provider_request_policy(self._PROVIDER_KEY, self._model, "responses.stream", kwargs) + yield MessageStartEvent(message_id=str(uuid.uuid4())) + response = await self._create_response(kwargs) + adapter = ResponsesStreamAdapter(self._responses_identity, tools) + try: + async for event in response: + for unified in adapter.feed(event): + yield unified + if adapter.terminal is not None: + terminal = adapter.terminal + yield MessageEndEvent( + stop_reason=terminal.stop_reason, + usage=terminal.usage, + provider_metadata=terminal.provider_metadata, + ) + return + raise ResponsesProtocolError("Responses stream ended without a terminal response.") + finally: + close = getattr(response, "close", None) + if callable(close): + await close() + + async def complete( + self, + messages: list[Message], + system: str, + tools: list[ToolDefinition] | None = None, + max_tokens: int = 8192, + cache_policy: str = "default", + ) -> NonStreamingResponse: + kwargs = self._build_responses_kwargs(messages, system, tools, max_tokens, streaming=False) + log_provider_request_policy(self._PROVIDER_KEY, self._model, "responses.create", kwargs) + response = await self._create_response(kwargs) + return decode_response(response, self._responses_identity, tools) + + +class DashScopeResponsesProvider(ResponsesProvider): + # Bailian silently truncates at about 80%; leave additional local headroom. + input_window_ratio = 0.75 + + def __init__(self, *args: Any, thinking_intent: ResolvedThinkingIntent | None = None, **kwargs: Any) -> None: + self._thinking_intent = thinking_intent + kwargs.setdefault("provider_key", "dashscope") + kwargs.setdefault("base_url", "https://dashscope.aliyuncs.com/compatible-mode/v1") + super().__init__(*args, **kwargs) + + def _responses_effort(self) -> str | None: + intent = self._thinking_intent + effort = normalize_effort(self._effort if intent is None else intent.effort.value) + disabled = self._thinking_disabled() + if intent is not None: + if intent.enabled.value is not None: + disabled = not intent.enabled.value + dominant = intent.dominant_concrete_field() + if dominant == "disabled": + disabled = True + elif dominant in {"effort", "budget"}: + disabled = False + if disabled: + return "none" + default_effort = "xhigh" if self._model.startswith("qwen3.8-") else None + if effort in {None, "auto"}: + return default_effort + if self._model.startswith("qwen3.8-"): + effort = {"minimal": "low", "high": "xhigh", "max": "xhigh"}.get(effort, effort) + if effort not in {"none", "minimal", "low", "medium", "high", "xhigh", "max"}: + # Ignore saved efforts from other families, using this protocol's default. + return default_effort + return effort + + def _build_responses_kwargs(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + result = super()._build_responses_kwargs(*args, **kwargs) + if result["max_output_tokens"] < 16: + raise ValueError("DashScope Responses max_output_tokens must be at least 16.") + return result diff --git a/src/iac_code/providers/thinking.py b/src/iac_code/providers/thinking.py index 8df208711..ef7672427 100644 --- a/src/iac_code/providers/thinking.py +++ b/src/iac_code/providers/thinking.py @@ -444,6 +444,10 @@ def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: }, "openai": { "gpt-6-astra": ThinkingSpec(ThinkingFamily.OPENAI, _OPENAI_GPT6_EFFORTS, EffortLevel.MEDIUM), + "gpt-6-sol": ThinkingSpec(ThinkingFamily.OPENAI, (EffortLevel.NONE, *_OPENAI_GPT6_EFFORTS), EffortLevel.MEDIUM), + "gpt-6-luna": ThinkingSpec( + ThinkingFamily.OPENAI, (EffortLevel.NONE, *_OPENAI_GPT6_EFFORTS), EffortLevel.MEDIUM + ), "gpt-5.6": _OPENAI_GPT56_SPEC, "gpt-5.6-sol": _OPENAI_GPT56_SPEC, "gpt-5.6-terra": _OPENAI_GPT56_SPEC, diff --git a/src/iac_code/services/context_manager.py b/src/iac_code/services/context_manager.py index d1ada83a5..a258510f7 100644 --- a/src/iac_code/services/context_manager.py +++ b/src/iac_code/services/context_manager.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any from loguru import logger @@ -56,6 +56,8 @@ def _context_config(context_window: int, max_output_tokens: int = 8_192) -> Cont "claude-sonnet-5": _context_config(1_000_000, 128_000), "claude-sonnet-4-6-1m": _context_config(1_000_000, 64_000), "gpt-6-astra": _context_config(1_050_000, 128_000), + "gpt-6-sol": _context_config(1_050_000, 128_000), + "gpt-6-luna": _context_config(1_050_000, 128_000), "gpt-5.5": _context_config(1_050_000, 128_000), "gpt-5.4": _context_config(1_050_000, 128_000), "gpt-5.4-mini": _context_config(400_000, 128_000), @@ -218,6 +220,11 @@ def set_system_prompt(self, system_prompt: str) -> None: self._system_prompt = system_prompt self._system_prompt_tokens = self._token_counter.count_text(system_prompt) + def set_input_window_ratio(self, ratio: float = 1.0) -> None: + """Apply a request-specific input limit without changing model defaults.""" + config = get_context_window_config(self._model) + self._config = replace(config, context_window=int(config.context_window * ratio)) + def set_tool_definitions(self, tool_definitions: list[Any]) -> None: """Cache provider tool definitions and their current token footprint.""" self._tool_definitions = list(tool_definitions) @@ -337,10 +344,10 @@ def get_usage(self) -> dict[str, Any]: "message_count": len(self.get_context_messages()), } - def needs_compaction(self) -> bool: + def needs_compaction(self, reserved_output_tokens: int = 0) -> bool: total = self.get_total_tokens() threshold = self._config.context_window * self._config.compact_threshold - if total <= threshold: + if total <= threshold and total + reserved_output_tokens <= self._config.context_window: return False # 超阈值但没有可压缩的旧消息(token 权重全落在保留尾部,例如单条超大工具结果)时, # 压缩是空操作:build_compaction_prompt 为空 → 不留会话记录,且因为上下文没变,下一回合 diff --git a/src/iac_code/services/telemetry/constants.py b/src/iac_code/services/telemetry/constants.py index d66a102af..0ba385dfd 100644 --- a/src/iac_code/services/telemetry/constants.py +++ b/src/iac_code/services/telemetry/constants.py @@ -57,6 +57,8 @@ "claude-haiku-4-5-20251001", # OpenAI "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", diff --git a/src/iac_code/types/stream_events.py b/src/iac_code/types/stream_events.py index ee8a0eb32..ae4ba7398 100644 --- a/src/iac_code/types/stream_events.py +++ b/src/iac_code/types/stream_events.py @@ -151,6 +151,7 @@ class MessageEndEvent: stop_reason: str usage: Usage type: Literal["message_end"] = "message_end" + provider_metadata: dict[str, Any] = field(default_factory=dict, kw_only=True, repr=False) usage_attribution: UsageAttribution | None = field(default=None, kw_only=True, compare=False) @@ -182,6 +183,7 @@ class ErrorEvent: is_retryable: bool error_id: str | None = None type: Literal["error"] = "error" + context_limit_exceeded: bool = field(default=False, kw_only=True, repr=False) i18n_message_id: str | None = field(default=None, kw_only=True, compare=False) i18n_message_args: dict[str, Any] | None = field(default=None, kw_only=True, compare=False) diff --git a/tests/agent/test_responses_session.py b/tests/agent/test_responses_session.py new file mode 100644 index 000000000..c35faaf19 --- /dev/null +++ b/tests/agent/test_responses_session.py @@ -0,0 +1,267 @@ +"""Responses wire history through the real manager, agent and JSONL storage.""" + +import json +from unittest.mock import AsyncMock + +import pytest +from google.protobuf.json_format import MessageToDict + +from iac_code.agent.agent_loop import AgentLoop +from iac_code.agent.message import Message, TextBlock, ToolResultBlock, ToolUseBlock +from iac_code.providers.base import ToolDefinition +from iac_code.providers.manager import ProviderManager +from iac_code.providers.responses_codec import ResponsesContextLimitError, decode_response, encode_input +from iac_code.providers.responses_provider import DashScopeResponsesProvider, ResponsesProvider +from iac_code.providers.retry import RetryConfig +from iac_code.services.session_storage import SessionStorage +from iac_code.tools.base import Tool, ToolContext, ToolRegistry, ToolResult +from iac_code.types.permissions import PermissionResult +from iac_code.types.stream_events import CompactionEvent, ErrorEvent, MessageEndEvent, TombstoneEvent +from iac_code.web.events import WebEventTranslator +from iac_code.web.files import _visible_payload_for_message +from tests.providers._responses_fakes import FakeResponsesClient, call, message, reasoning, response, terminal + + +class Lookup(Tool): + name = "lookup" + description = "Read a fixture value" + input_schema = {"type": "object", "properties": {"value": {"type": "integer"}}} + + def __init__(self): + self.calls = [] + + def is_read_only(self, input=None): + return True + + async def check_permissions(self, input, context=None): + return PermissionResult(behavior="allow") + + async def execute(self, *, tool_input: dict, context: ToolContext): + self.calls.append(tool_input) + return ToolResult.success("fixture result " + str(tool_input["value"])) + + +def setup_loop(tmp_path, client, profile="openai", **kwargs): + model = "gpt-6-sol" if profile == "openai" else "qwen3.8-max" + provider_cls = ResponsesProvider if profile == "openai" else DashScopeResponsesProvider + client.base_url = ( + "https://api.openai.com/v1" if profile == "openai" else ("https://dashscope.aliyuncs.com/compatible-mode/v1") + ) + provider = provider_cls(model=model, client=client) + manager = ProviderManager( + model=model, + credentials={profile: "fake-key"}, + provider_key_override=profile, + provider_config_override={"models": {model: {"apiMode": "responses"}}}, + ignore_llm_source=True, + retry_config=RetryConfig(max_retries=0), + ) + manager._provider = provider + tool = Lookup() + registry = ToolRegistry() + registry.register(tool) + storage = SessionStorage(tmp_path / "projects") + loop = AgentLoop( + provider_manager=manager, + system_prompt="system", + tool_registry=registry, + cwd=str(tmp_path), + session_storage=storage, + session_id="responses-session", + max_turns=2, + **kwargs, + ) + return loop, provider, tool, storage + + +def native_history(provider, count=5): + """Several complete turns leave compactible history and intact tool pairs.""" + result = [] + for i in range(count): + output = [reasoning(id=f"rs{i}"), message(f"checking {i}", id=f"msg{i}", phase="commentary"), call(f"c{i}")] + native = decode_response( + response(output), + provider._responses_identity, + [ToolDefinition("lookup", Lookup.description, Lookup.input_schema)], + ) + result.extend( + [ + Message(role="user", content=f"question {i}"), + Message( + role="assistant", + content=[ + TextBlock(text=f"checking {i}"), + ToolUseBlock(id=f"c{i}", name="lookup", input={"value": 1}), + ], + metadata=native.provider_metadata, + ), + Message(role="user", content=[ToolResultBlock(tool_use_id=f"c{i}", content="fixture result")]), + Message(role="assistant", content="done"), + ] + ) + return result + + +@pytest.mark.asyncio +@pytest.mark.parametrize("profile", ["openai", "dashscope"]) +async def test_tools_native_items_round_trip_session_and_resume(tmp_path, profile): + thought = reasoning() if profile == "openai" else reasoning(encrypted_content=None) + output = [thought, message("checking", phase="commentary"), call(), call("call2", arguments='{"value": 2}')] + client = FakeResponsesClient([terminal(response(output))], [terminal(response([message("done")]))]) + loop, provider, tool, storage = setup_loop(tmp_path, client, profile) + events = [event async for event in loop.run_streaming("go")] + assert not any(isinstance(event, ErrorEvent) for event in events) + assert tool.calls == [{"value": 1}, {"value": 2}] + ends = [event for event in events if isinstance(event, MessageEndEvent)] + assert ends[0].usage.input_tokens == 20 and ends[0].usage.cache_read_input_tokens == 7 + assert ends[0].usage.provider == profile + next_input = client.calls[1]["input"] + assert next_input.count(thought) == 1 + assert next_input.count(output[1]) == 1 + for item in output[2:]: + result = next( + value + for value in next_input + if value.get("type") == "function_call_output" and value["call_id"] == item["call_id"] + ) + assert next_input.count(item) == 1 + if profile == "dashscope": + assert next_input[next_input.index(item) + 1] == result + saved = storage.load(str(tmp_path), "responses-session") + native_assistant = next(msg for msg in saved if msg.metadata.get("responses", {}).get("output") == output) + current = next(msg for msg in loop.context_manager.get_messages() if msg.metadata == native_assistant.metadata) + assert current.to_dict() == native_assistant.to_dict() + assert "opaque-fixture" not in json.dumps(_visible_payload_for_message(native_assistant)) + assert "responses" not in WebEventTranslator("s").translate_stream_event(ends[0], turn_id="t")["payload"] + from iac_code.a2a.events import publish_stream_event + + queue = AsyncMock() + await publish_stream_event(queue, task_id="task", context_id="context", event=ends[0]) + assert "opaque-fixture" not in json.dumps(MessageToDict(queue.enqueue_event.call_args.args[0])) + resumed_client = FakeResponsesClient([terminal(response([message("resumed")]))]) + resumed, _, _, _ = setup_loop(tmp_path, resumed_client, profile, resume_messages=saved) + resumed_events = [event async for event in resumed.run_streaming("continue")] + assert not any(isinstance(event, ErrorEvent) for event in resumed_events) + assert resumed_client.calls[0]["input"].count(thought) == 1 + assert resumed_client.calls[0]["input"].count(output[1]) == 1 + # Changing a model/endpoint/profile falls back to visible text and tool blocks. + for key in ("model", "endpoint", "profile", "provider"): + changed = {**provider._responses_identity, key: "different"} + converted = encode_input(resumed._get_provider_messages(), changed) + assert not any(item.get("type") == "reasoning" or "phase" in item for item in converted) + assert sum(item.get("type") == "function_call" for item in converted) == 2 + + +@pytest.mark.asyncio +async def test_stream_failure_fallback_persists_only_validated_complete_items(tmp_path): + partial = {"type": "response.output_text.delta", "delta": "discarded"} + output = [reasoning(), message("fallback", phase="final_answer")] + client = FakeResponsesClient([partial, RuntimeError("offline interrupted")], response(output)) + loop, _, tool, storage = setup_loop(tmp_path, client) + events = [event async for event in loop.run_streaming("go")] + assert any(isinstance(event, TombstoneEvent) for event in events) + assert tool.calls == [] + assert [request["stream"] for request in client.calls] == [True, False] + saved = storage.load(str(tmp_path), "responses-session") + assert saved[-1].get_text() == "fallback" + assert saved[-1].metadata["responses"]["output"] == output + assert "discarded" not in json.dumps([msg.to_dict() for msg in saved]) + + +@pytest.mark.asyncio +async def test_tool_end_before_failed_response_cannot_execute_or_persist(tmp_path): + item = call() + added = {"type": "response.output_item.added", "output_index": 0, "item": item} + done = {"type": "response.output_item.done", "output_index": 0, "item": item} + client = FakeResponsesClient([added, done, {"type": "response.failed"}], response([], status="failed")) + loop, _, tool, storage = setup_loop(tmp_path, client) + events = [event async for event in loop.run_streaming("go")] + assert any(isinstance(event, ErrorEvent) for event in events) + assert tool.calls == [] + saved = storage.load(str(tmp_path), "responses-session") + assert not any(msg.role == "assistant" or msg.metadata.get("responses") for msg in saved) + + +@pytest.mark.asyncio +async def test_incomplete_turn_cannot_commit_or_execute_even_completed_function(tmp_path): + item = call() + incomplete = response( + [message("partial"), item], status="incomplete", incomplete_details={"reason": "max_output_tokens"} + ) + events = [ + {"type": "response.output_item.added", "output_index": 1, "item": item}, + {"type": "response.output_item.done", "output_index": 1, "item": item}, + terminal(incomplete), + ] + loop, _, tool, storage = setup_loop(tmp_path, FakeResponsesClient(events, incomplete)) + emitted = [event async for event in loop.run_streaming("go")] + assert any(isinstance(event, ErrorEvent) for event in emitted) + assert tool.calls == [] + assert not any(msg.role == "assistant" for msg in storage.load(str(tmp_path), "responses-session")) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("fail_again", [False, True]) +async def test_context_limit_compacts_once_retries_and_keeps_tail_tool_pairs(tmp_path, fail_again): + limit = ResponsesContextLimitError("offline context limit") + retried = limit if fail_again else [terminal(response([message("retried")]))] + client = FakeResponsesClient(limit, response([message("local summary")]), retried) + loop, provider, _, storage = setup_loop(tmp_path, client) + loop.context_manager.load_messages(native_history(provider)) + before = encode_input(loop._get_provider_messages(), provider._responses_identity) + events = [event async for event in loop.run_streaming("new question")] + assert [request["stream"] for request in client.calls] == [True, False, True] + assert sum(isinstance(event, CompactionEvent) and event.phase == "started" for event in events) == 1 + assert any(isinstance(event, CompactionEvent) and event.summary == "local summary" for event in events) + assert any(isinstance(event, ErrorEvent) for event in events) == fail_again + after = client.calls[-1]["input"] + assert len(after) < len(before) + assert not any(item.get("id") == "rs0" for item in after) + calls = {item["call_id"] for item in after if item.get("type") == "function_call"} + results = {item["call_id"] for item in after if item.get("type") == "function_call_output"} + assert calls and calls == results + saved = storage.load(str(tmp_path), "responses-session") + assert any(msg.metadata.get("responses", {}).get("output", [{}])[0].get("id") == "rs0" for msg in saved) + + +@pytest.mark.asyncio +async def test_context_limit_without_compactible_history_reports_error(tmp_path): + client = FakeResponsesClient(ResponsesContextLimitError("offline limit")) + loop, _, _, _ = setup_loop(tmp_path, client) + events = [event async for event in loop.run_streaming("go")] + assert len(client.calls) == 1 + assert any(isinstance(event, ErrorEvent) and event.context_limit_exceeded for event in events) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("input_fraction,output_limit", [(0.72, None), (0.65, 128_000)]) +async def test_dashscope_proactive_compaction_precedes_service_truncation_threshold( + tmp_path, input_fraction, output_limit +): + client = FakeResponsesClient(response([message("summary")]), [terminal(response([message("done")]))]) + loop, provider, _, _ = setup_loop(tmp_path, client, "dashscope") + provider._max_completion_tokens = output_limit + loop.context_manager.load_messages(native_history(provider)) + original_window = loop.context_manager._config.context_window + for msg in loop.context_manager.get_messages(): + msg.token_count = int(original_window * input_fraction / len(loop.context_manager.get_messages())) + assert not loop.context_manager.needs_compaction() + events = [event async for event in loop.run_streaming("go")] + assert any(isinstance(event, CompactionEvent) and event.phase == "started" for event in events) + assert [request["stream"] for request in client.calls] == [False, True] + assert loop.context_manager._config.context_window == int(original_window * 0.75) + + +@pytest.mark.asyncio +async def test_dashscope_huge_tail_is_rejected_without_sending_truncated_history(tmp_path): + client = FakeResponsesClient() + loop, provider, _, _ = setup_loop(tmp_path, client, "dashscope") + history = native_history(provider, count=1) + loop.context_manager.load_messages(history) + history[2].token_count = loop.context_manager.context_window + events = [event async for event in loop.run_streaming("continue")] + assert client.calls == [] + assert any(isinstance(event, ErrorEvent) and "safe context budget" in event.error for event in events) + # No truncation or deletion of the large result or its matching function call. + assert history[1] in loop.context_manager.get_messages() + assert history[2] in loop.context_manager.get_messages() diff --git a/tests/providers/_responses_fakes.py b/tests/providers/_responses_fakes.py new file mode 100644 index 000000000..31d147c30 --- /dev/null +++ b/tests/providers/_responses_fakes.py @@ -0,0 +1,92 @@ +"""Offline Responses fixtures shared by provider and agent tests.""" + +from collections import deque +from typing import Any + + +def message(text="answer", **extra): + return { + "type": "message", + "id": "msg1", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": text, "annotations": []}], + **extra, + } + + +def reasoning(text="thought", **extra): + return { + "type": "reasoning", + "id": "rs1", + "summary": [{"type": "summary_text", "text": text}], + "encrypted_content": "opaque-fixture", + **extra, + } + + +def call(call_id="call1", name="lookup", arguments='{"value": 1}', **extra): + return { + "type": "function_call", + "id": "fc_" + call_id, + "call_id": call_id, + "name": name, + "arguments": arguments, + "status": "completed", + **extra, + } + + +def response(output=None, **extra): + return { + "id": "resp1", + "status": "completed", + "output": [message()] if output is None else output, + "usage": {"input_tokens": 20, "output_tokens": 5, "input_tokens_details": {"cached_tokens": 7}}, + **extra, + } + + +def terminal(data): + return {"type": "response." + data["status"], "response": data} + + +class FakeResponseStream: + def __init__(self, events): + self.events = iter(events) + self.closed = False + + def __aiter__(self): + return self + + async def __anext__(self): + try: + event = next(self.events) + except StopIteration: + raise StopAsyncIteration from None + if isinstance(event, BaseException): + raise event + return event + + async def close(self): + self.closed = True + + +class FakeResponsesClient: + def __init__(self, *results: Any, base_url="https://api.openai.com/v1"): + self.base_url = base_url + self.results = deque(results) + self.responses = self + self.calls = [] + self.streams = [] + + async def create(self, **kwargs): + self.calls.append(kwargs) + result = self.results.popleft() + if isinstance(result, BaseException): + raise result + if kwargs.get("stream"): + stream = FakeResponseStream(result) + self.streams.append(stream) + return stream + return result diff --git a/tests/providers/test_dashscope_responses_provider.py b/tests/providers/test_dashscope_responses_provider.py new file mode 100644 index 000000000..74a3ceda7 --- /dev/null +++ b/tests/providers/test_dashscope_responses_provider.py @@ -0,0 +1,163 @@ +import pytest + +from iac_code.providers.base import Message, ToolDefinition +from iac_code.providers.responses_codec import ResponsesProtocolError +from iac_code.providers.responses_provider import DashScopeResponsesProvider, validate_responses_endpoint +from iac_code.services.context_manager import ContextManager, get_context_window_config +from iac_code.types.stream_events import ThinkingDeltaEvent +from tests.providers._responses_fakes import FakeResponsesClient, call, message, reasoning, response, terminal + +BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1" +TOOLS = [ToolDefinition("lookup", "Lookup", {"type": "object"})] + + +def provider(client, **kwargs): + return DashScopeResponsesProvider(model="qwen3.8-max", client=client, **kwargs) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "effort,enabled,expected", + [ + (None, None, "xhigh"), + ("medium", None, "medium"), + ("high", None, "xhigh"), + ("max", None, "xhigh"), + ("ultra", None, "xhigh"), + ("minimal", None, "low"), + ("none", None, "none"), + ("high", False, "none"), + ], +) +async def test_qwen_request_uses_responses_parameters(effort, enabled, expected): + client = FakeResponsesClient(response(), base_url=BASE) + await provider(client, effort=effort, thinking_enabled=enabled).complete([Message.user("go")], "system", TOOLS) + kwargs = client.calls[0] + assert kwargs["reasoning"] == {"effort": expected} + assert kwargs["store"] is False + assert kwargs["instructions"] == "system" + assert "strict" not in kwargs["tools"][0] + assert "extra_body" not in kwargs and "include" not in kwargs and "parallel_tool_calls" not in kwargs + assert "cache_control" not in str(kwargs) + + +@pytest.mark.asyncio +async def test_qwen_replays_multiple_function_results_adjacent_to_calls(): + client = FakeResponsesClient( + response([reasoning(encrypted_content=None), call(), call("call2"), message()]), response(), base_url=BASE + ) + p = provider(client) + result = await p.complete([Message.user("go")], "system", TOOLS) + history = [ + Message.user("go"), + Message(role="assistant", metadata=result.provider_metadata), + Message.tool_result(tool_use_id="call2", content="two"), + Message.tool_result(tool_use_id="call1", content="one"), + ] + await p.complete(history, "system", TOOLS) + items = client.calls[1]["input"] + calls = [index for index, item in enumerate(items) if item.get("type") == "function_call"] + assert len(calls) == 2 + for index in calls: + assert items[index + 1]["type"] == "function_call_output" + assert items[index + 1]["call_id"] == items[index]["call_id"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "history", + [ + [Message.assistant_tool_use(tool_use_id="call1", name="lookup", input={})], + [Message.tool_result(tool_use_id="call1", content="orphan")], + [ + Message.assistant_tool_use(tool_use_id="call1", name="lookup", input={}), + Message.tool_result(tool_use_id="call1", content="one"), + Message.tool_result(tool_use_id="call1", content="two"), + ], + ], +) +async def test_invalid_tool_history_fails_before_sdk_call(history): + client = FakeResponsesClient(base_url=BASE) + with pytest.raises(ResponsesProtocolError): + await provider(client).complete(history, "system", TOOLS) + assert client.calls == [] + + +@pytest.mark.asyncio +async def test_qwen_reasoning_event_maps_to_existing_thinking_event(): + data = response([reasoning(encrypted_content=None), message()]) + client = FakeResponsesClient( + [{"type": "response.reasoning_text.delta", "delta": "thought"}, terminal(data)], base_url=BASE + ) + events = [event async for event in provider(client).stream([Message.user("go")], "system")] + assert "".join(event.text for event in events if isinstance(event, ThinkingDeltaEvent)) == "thought" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model", ["qwen3.8-max", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus"]) +async def test_output_limit_is_sent_as_configured(model): + client = FakeResponsesClient(response(), base_url=BASE) + p = DashScopeResponsesProvider(model=model, client=client, max_completion_tokens=123) + await p.complete([Message.user("go")], "system", max_tokens=1000) + assert client.calls[0]["max_output_tokens"] == 123 + + +@pytest.mark.asyncio +async def test_qwen_validates_output_limit_locally(): + client = FakeResponsesClient(base_url=BASE) + with pytest.raises(ValueError, match="at least 16"): + await provider(client, max_completion_tokens=15).complete([Message.user("go")], "system") + assert client.calls == [] + + +@pytest.mark.asyncio +async def test_legacy_assistant_text_uses_compatible_easy_message(): + from iac_code.providers.base import ContentBlock + + client = FakeResponsesClient(response(), base_url=BASE) + await provider(client).complete( + [ + Message.user("go"), + Message("assistant", [ContentBlock(type="text", text="old Chat answer")]), + Message.user("continue"), + ], + "system", + ) + assert client.calls[0]["input"][1] == {"role": "assistant", "content": "old Chat answer"} + + +def test_qwen_responses_input_window_does_not_change_chat_window(): + context = ContextManager("system", "qwen3.8-max") + original = get_context_window_config("qwen3.8-max").context_window + context.set_input_window_ratio(DashScopeResponsesProvider.input_window_ratio) + assert context.context_window < original * 0.8 + assert get_context_window_config("qwen3.8-max").context_window == original + context.set_input_window_ratio() + assert context.context_window == original + + +@pytest.mark.parametrize( + "base", + [ + BASE, + "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + "https://workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + "https://workspace.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + ], +) +def test_documented_endpoints_are_accepted(base): + validate_responses_endpoint("dashscope", base, "qwen3.8-max") + + +@pytest.mark.parametrize( + "base", + [ + "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + "https://coding.dashscope.aliyuncs.com/v1", + "https://custom.test/v1", + BASE + "/chat/completions", + ], +) +def test_other_endpoints_are_not_enabled(base): + with pytest.raises(ValueError): + validate_responses_endpoint("dashscope", base, "qwen3.8-max") diff --git a/tests/providers/test_openai_responses_provider.py b/tests/providers/test_openai_responses_provider.py new file mode 100644 index 000000000..a5f41bb50 --- /dev/null +++ b/tests/providers/test_openai_responses_provider.py @@ -0,0 +1,327 @@ +import asyncio +import copy + +import httpx +import pytest +from openai import AsyncOpenAI, BadRequestError + +from iac_code.providers.base import ContentBlock, Message, ToolDefinition +from iac_code.providers.request_headers import use_provider_request_headers +from iac_code.providers.responses_codec import ( + ResponsesContextLimitError, + ResponsesProtocolError, + ResponsesStreamAdapter, +) +from iac_code.providers.responses_provider import ResponsesProvider +from iac_code.types.stream_events import MessageEndEvent, TextDeltaEvent, ThinkingDeltaEvent, ToolUseEndEvent +from tests.providers._responses_fakes import FakeResponsesClient, call, message, reasoning, response, terminal + +TOOLS = [ToolDefinition("lookup", "Look up a value", {"type": "object", "properties": {"value": {"type": "integer"}}})] + + +def provider(client, **kwargs): + return ResponsesProvider(model="gpt-6-sol", client=client, **kwargs) + + +@pytest.mark.asyncio +async def test_request_converts_images_tools_effort_and_usage(): + client = FakeResponsesClient(response([reasoning(), call(), message("checking", phase="commentary")])) + p = provider(client, effort="high", max_completion_tokens=321) + messages = [ + Message( + role="user", + content=[ + ContentBlock(type="text", text="inspect"), + ContentBlock(type="image", media_type="image/png", data="ZmFrZQ=="), + ], + ) + ] + with use_provider_request_headers({"X-Trace": "fake"}): + result = await p.complete(messages, "system", TOOLS, max_tokens=100) + request = client.calls[0] + assert request["store"] is False and request["stream"] is False + assert request["instructions"] == "system" + assert request["reasoning"] == {"effort": "high"} + assert request["max_output_tokens"] == 321 + assert request["extra_headers"] == {"X-Trace": "fake"} + assert request["include"] == ["reasoning.encrypted_content"] + assert request["input"][0]["content"][1] == {"type": "input_image", "image_url": "data:image/png;base64,ZmFrZQ=="} + assert request["tools"][0]["name"] == "lookup" and request["tools"][0]["strict"] is False + assert "messages" not in request and "reasoning_effort" not in request + assert result.stop_reason == "tool_use" and result.tool_uses[0]["input"] == {"value": 1} + assert result.usage.normalized_total_tokens == 25 + assert result.usage.cache_read_input_tokens == 7 and result.usage.reported + assert result.provider_metadata["responses"]["output"][2]["phase"] == "commentary" + + +@pytest.mark.asyncio +async def test_real_sdk_posts_responses_with_extension_fields_offline(): + requests = [] + + def handle(request): + requests.append(request) + return httpx.Response( + 200, + json={ + "object": "response", + "created_at": 1, + "model": "gpt-6-sol", + **response([reasoning(), message(phase="final_answer")]), + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http_client: + async with AsyncOpenAI(api_key="fake", http_client=http_client) as sdk: + result = await provider(sdk).complete([Message.user("hi")], "system") + assert requests[0].url.path == "/v1/responses" + assert result.provider_metadata["responses"]["output"][1]["phase"] == "final_answer" + assert result.provider_metadata["responses"]["output"][0]["encrypted_content"] == "opaque-fixture" + + +@pytest.mark.asyncio +async def test_native_history_is_replayed_once_and_scope_checked(): + original = response([reasoning(), message("checking", phase="commentary"), call()]) + client = FakeResponsesClient(original, response(), response()) + p = provider(client) + result = await p.complete([Message.user("go")], "system", TOOLS) + assistant = Message( + role="assistant", + content=[ + ContentBlock(type="text", text="checking"), + ContentBlock(type="tool_use", tool_use_id="call1", name="lookup", input={"value": 1}), + ], + metadata=result.provider_metadata, + ) + history = [Message.user("go"), assistant, Message.tool_result(tool_use_id="call1", content="found")] + before = copy.deepcopy(assistant.metadata) + await p.complete(history, "system", TOOLS) + assert client.calls[1]["input"][1:4] == original["output"] + assert assistant.metadata == before + other = ResponsesProvider(model="gpt-6-luna", client=client) + await other.complete(history, "system", TOOLS) + assert not any(item.get("type") == "reasoning" for item in client.calls[2]["input"]) + assert not any("phase" in item for item in client.calls[2]["input"]) + + +def tool_events(items): + events = [] + for index, item in enumerate(items): + events.append( + { + "type": "response.output_item.added", + "output_index": index, + "item": {**item, "status": "in_progress", "arguments": ""}, + } + ) + for index, item in enumerate(items): + parts = [item["arguments"][:7], item["arguments"][7:]] + events.extend( + { + "type": "response.function_call_arguments.delta", + "output_index": index, + "item_id": item["id"], + "delta": part, + } + for part in parts + ) + events.append( + { + "type": "response.function_call_arguments.done", + "output_index": index, + "item_id": item["id"], + "arguments": item["arguments"], + } + ) + events.append({"type": "response.output_item.done", "output_index": index, "item": item}) + return events + + +@pytest.mark.asyncio +async def test_stream_multiple_functions_and_terminal_metadata(): + calls = [call(), call("call2", arguments='{"value": 2}')] + data = response([*calls, reasoning(), message("checking")]) + client = FakeResponsesClient( + [ + *tool_events(calls), + {"type": "response.reasoning_summary_text.delta", "delta": "thought"}, + {"type": "response.output_text.delta", "delta": "checking"}, + terminal(data), + ] + ) + events = [event async for event in provider(client).stream([Message.user("go")], "system", TOOLS)] + assert [event.input for event in events if isinstance(event, ToolUseEndEvent)] == [{"value": 1}, {"value": 2}] + assert "".join(event.text for event in events if isinstance(event, ThinkingDeltaEvent)) == "thought" + assert events[-1].provider_metadata["responses"]["output"] == data["output"] + assert isinstance(events[-1], MessageEndEvent) and client.streams[0].closed + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_refusal_is_not_empty_success(streaming): + data = response([{**message(), "content": [{"type": "refusal", "refusal": "declined"}]}]) + client = FakeResponsesClient( + [ + {"type": "response.refusal.delta", "delta": "declined"}, + {"type": "response.refusal.done", "refusal": "declined"}, + terminal(data), + ] + if streaming + else data + ) + p = provider(client) + if streaming: + result = [event async for event in p.stream([Message.user("go")], "system")][-1] + else: + result = await p.complete([Message.user("go")], "system") + assert result.stop_reason == "refusal" and result.provider_metadata == {} + + +@pytest.mark.asyncio +async def test_incomplete_text_has_no_native_history(): + data = response([message("partial")], status="incomplete", incomplete_details={"reason": "max_output_tokens"}) + client = FakeResponsesClient([terminal(data)]) + events = [event async for event in provider(client).stream([Message.user("go")], "system")] + assert events[-1].stop_reason == "max_tokens" and events[-1].provider_metadata == {} + assert any(isinstance(event, TextDeltaEvent) and event.text == "partial" for event in events) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "data", + [ + response([]), + response([call(arguments="{")]), + response([call(name="unknown")]), + response([call(), call()]), + response([{"type": "web_search_call"}]), + response(status="failed"), + response([call(status="in_progress")], status="incomplete", incomplete_details={"reason": "max_output_tokens"}), + response([call()], status="incomplete", incomplete_details={"reason": "max_output_tokens"}), + response(status="incomplete", incomplete_details={"reason": "content_filter"}), + ], +) +async def test_unusable_response_cannot_complete(data): + with pytest.raises(ResponsesProtocolError): + await provider(FakeResponsesClient(data)).complete([Message.user("go")], "system", TOOLS) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "events", + [ + [], + [RuntimeError("connection lost")], + [{"type": "response.failed"}], + [{"type": "response.output_item.done", "output_index": 0, "item": call()}], + [{"type": "response.function_call_arguments.delta", "output_index": 0, "item_id": "unknown", "delta": "{"}], + [*tool_events([call()]), terminal(response([call(arguments='{"value": 2}')]))], + ], +) +async def test_bad_stream_has_no_terminal_or_tool_execution(events): + client = FakeResponsesClient(events) + emitted = [] + with pytest.raises(RuntimeError): + async for event in provider(client).stream([Message.user("go")], "system", TOOLS): + emitted.append(event) + assert not any(isinstance(event, MessageEndEvent) for event in emitted) + assert client.streams[0].closed + + +@pytest.mark.asyncio +async def test_missing_usage_and_generator_close(): + client = FakeResponsesClient([{"type": "response.output_text.delta", "delta": "partial"}], response(usage=None)) + p = provider(client) + stream = p.stream([Message.user("go")], "system") + await anext(stream) + await anext(stream) + await stream.aclose() + assert client.streams[0].closed + result = await p.complete([Message.user("go")], "system") + assert not result.usage.reported and result.usage.normalized_total_tokens == 0 + + +@pytest.mark.asyncio +async def test_context_limit_is_distinguishable_from_other_400_errors(): + error = BadRequestError( + "too long", + response=httpx.Response(400, request=httpx.Request("POST", "https://fake.test")), + body={"code": "context_length_exceeded"}, + ) + with pytest.raises(ResponsesContextLimitError): + await provider(FakeResponsesClient(error)).complete([Message.user("go")], "system") + + +@pytest.mark.asyncio +async def test_request_local_stream_state_supports_concurrent_calls(): + client = FakeResponsesClient([terminal(response([message("a")]))], [terminal(response([message("b")]))]) + p = provider(client) + + async def collect(): + return [event async for event in p.stream([Message.user("go")], "system")] + + streams = await asyncio.gather(collect(), collect()) + assert ["".join(event.text for event in events if isinstance(event, TextDeltaEvent)) for events in streams] == [ + "a", + "b", + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "changed", + [ + call(arguments='{"value": 2}'), + call(name="changed"), + call(id="changed"), + call("changed"), + ], +) +async def test_terminal_checks_item_identity_and_arguments_without_done_events(changed): + client = FakeResponsesClient( + [ + {"type": "response.output_item.added", "output_index": 0, "item": call(arguments="", status="in_progress")}, + { + "type": "response.function_call_arguments.delta", + "output_index": 0, + "item_id": "fc_call1", + "delta": '{"value": 1}', + }, + terminal(response([changed])), + ] + ) + tools = [*TOOLS, ToolDefinition("changed", "Other tool", {})] + events = [] + with pytest.raises(ResponsesProtocolError): + async for event in provider(client).stream([Message.user("go")], "system", tools): + events.append(event) + assert not any(isinstance(event, (ToolUseEndEvent, MessageEndEvent)) for event in events) + + +def test_adapter_duplicate_terminals_fail_and_duplicate_function_done_is_idempotent(): + p = provider(FakeResponsesClient()) + adapter = ResponsesStreamAdapter(p._responses_identity, TOOLS) + events = tool_events([call()]) + emitted = [unified for event in [*events, events[-1]] for unified in adapter.feed(event)] + assert sum(isinstance(event, ToolUseEndEvent) for event in emitted) == 1 + adapter.feed(terminal(response([call()]))) + with pytest.raises(ResponsesProtocolError, match="after its terminal"): + adapter.feed(terminal(response([call()]))) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "event", + [ + {"type": "error", "code": "context_length_exceeded"}, + {"type": "response.failed", "response": {"error": {"code": "context_window_exceeded"}}}, + ], +) +async def test_stream_context_limit_uses_local_compaction_error(event): + with pytest.raises(ResponsesContextLimitError): + async for _ in provider(FakeResponsesClient([event])).stream([Message.user("go")], "system"): + pass + + +def test_actual_sdk_endpoint_cannot_bypass_profile_validation(): + with pytest.raises(ValueError, match="base URL"): + provider(FakeResponsesClient(base_url="https://custom.invalid/v1")) diff --git a/tests/providers/test_responses_routing.py b/tests/providers/test_responses_routing.py new file mode 100644 index 000000000..0f18fb547 --- /dev/null +++ b/tests/providers/test_responses_routing.py @@ -0,0 +1,128 @@ +import pytest + +from iac_code.providers.anthropic_provider import AnthropicProvider +from iac_code.providers.manager import create_provider +from iac_code.providers.openai_provider import OpenAIProvider +from iac_code.providers.qwen_provider import QwenProvider +from iac_code.providers.request_policy import ProviderRequestPolicy +from iac_code.providers.responses_provider import DashScopeResponsesProvider, ResponsesProvider +from iac_code.providers.thinking import get_thinking_spec +from iac_code.services.context_manager import get_context_window_config +from tests.providers.test_openai_responses_provider import TOOLS + + +def create(key, model, config=None): + return create_provider(model, {key: "fake"}, provider_key_override=key, provider_config_override=config or {}) + + +@pytest.mark.parametrize("model", ["gpt-6-astra", "gpt-6-sol", "gpt-6-luna"]) +def test_gpt6_default_protocol_and_capabilities(model): + assert type(create("openai", model)) is ResponsesProvider + assert get_context_window_config(model).context_window == 1_050_000 + assert ("none" in get_thinking_spec("openai", model).effort_values) == (model != "gpt-6-astra") + + +def test_only_explicit_qwen_model_switches_protocol(): + cfg = {"models": {"qwen3.8-max": {"apiMode": "responses"}}} + assert type(create("dashscope", "qwen3.8-max")) is QwenProvider + assert type(create("dashscope", "qwen3.8-max", cfg)) is DashScopeResponsesProvider + assert type(create("dashscope", "qwen3.8-flash", cfg)) is QwenProvider + assert type(create("openai", "gpt-5.6-sol")) is OpenAIProvider + assert type(create("anthropic", "claude-opus-5")) is AnthropicProvider + + +@pytest.mark.parametrize("mode", ["auto", "invalid", None, {}, []]) +def test_invalid_model_api_mode_rejected(mode): + with pytest.raises(ValueError, match="apiMode"): + create("openai", "gpt-5.6-sol", {"models": {"gpt-5.6-sol": {"apiMode": mode}}}) + + +@pytest.mark.parametrize( + "key,model", + [ + ("anthropic", "claude-opus-5"), + ("openai_compatible", "custom"), + ("dashscope_token_plan", "qwen3.8-max"), + ("dashscope", "qwen3-coder-plus"), + ], +) +def test_unsupported_responses_provider_or_model_fails(key, model): + with pytest.raises(ValueError): + create(key, model, {"models": {model: {"apiMode": "responses"}}}) + + +@pytest.mark.parametrize( + "model,effort,allowed", + [ + ("gpt-6-astra", "none", False), + ("gpt-6-sol", "high", False), + ("gpt-6-sol", "none", True), + ("gpt-6-luna", "none", True), + ], +) +def test_gpt6_forced_chat_checks_tool_restrictions(model, effort, allowed): + p = create("openai", model, {"effort": effort, "models": {model: {"apiMode": "chat_completions"}}}) + context = p._create_chat_request_context(streaming=False) + if allowed: + assert p._build_chat_completion_kwargs([], "system", TOOLS, 100, context)["reasoning_effort"] == "none" + else: + with pytest.raises(ValueError, match="requires apiMode=responses"): + p._build_chat_completion_kwargs([], "system", TOOLS, 100, context) + + +def test_gpt6_astra_responses_cannot_disable_reasoning(): + p = create("openai", "gpt-6-astra", {"thinkingEnabled": False}) + with pytest.raises(ValueError, match="reasoning effort"): + p._build_responses_kwargs([], "system", None, 100, streaming=False) + + +@pytest.mark.parametrize( + "key,model,expected", + [ + ("openai", "gpt-6-astra", "medium"), + ("openai", "gpt-6-sol", "medium"), + ("openai", "gpt-6-luna", "medium"), + ("openai", "gpt-5.6-sol", "medium"), + ("dashscope", "qwen3.8-max", "xhigh"), + ("dashscope", "qwen3.8-flash", "xhigh"), + ("dashscope", "qwen3.8-2.4t-a95b", "xhigh"), + ("dashscope", "qwen3.8-27b", "xhigh"), + ("dashscope", "qwen3.8-omni-flash", "xhigh"), + ("dashscope", "qwen3.7-plus", None), + ("dashscope", "qwen3.6-plus", None), + ("dashscope", "qwen3.6-flash", None), + ], +) +def test_responses_saved_unsupported_effort_uses_protocol_default(key, model, expected): + p = create(key, model, {"effort": "ultra", "models": {model: {"apiMode": "responses"}}}) + kwargs = p._build_responses_kwargs([], "system", None, 100, streaming=False) + assert kwargs.get("reasoning") == ({"effort": expected} if expected is not None else None) + assert p._effort == "ultra" + + +@pytest.mark.parametrize( + "provider_config,model_config,request_policy,expected", + [ + ({"thinkingEnabled": False}, {}, ProviderRequestPolicy(effort="low"), "low"), + ({"thinkingEnabled": False}, {"effort": "low"}, None, "low"), + ({}, {"thinkingEnabled": False}, ProviderRequestPolicy(effort="low"), "low"), + ({"thinkingEnabled": False, "effort": "low"}, {}, None, "none"), + ({}, {"thinkingEnabled": False, "effort": "low"}, None, "none"), + ({"effort": "low"}, {}, ProviderRequestPolicy(thinking_enabled=False, effort="low"), "none"), + ({"effort": "none"}, {"thinkingEnabled": True}, None, "xhigh"), + ], +) +def test_qwen_responses_preserves_thinking_source_precedence(provider_config, model_config, request_policy, expected): + p = create_provider( + "qwen3.8-max", + {"dashscope": "fake"}, + provider_key_override="dashscope", + provider_config_override={ + **provider_config, + "models": {"qwen3.8-max": {"apiMode": "responses", **model_config}}, + }, + request_policy_override=request_policy, + ) + kwargs = p._build_responses_kwargs([], "system", None, 100, streaming=False) + assert kwargs["reasoning"] == {"effort": expected} + assert "extra_body" not in kwargs diff --git a/tests/web/test_settings.py b/tests/web/test_settings.py index bc1bee005..67ed9c75e 100644 --- a/tests/web/test_settings.py +++ b/tests/web/test_settings.py @@ -973,6 +973,30 @@ def test_save_provider_config_knobs_are_per_model(_isolate_config): assert kimi["savedMaxCompletionTokens"] == 12000 +def test_save_provider_config_preserves_manual_model_api_mode(_isolate_config): + _save_yaml( + get_settings_path(), + { + "providers": { + "dashscope": { + "models": { + "qwen3.8-max": {"apiMode": "responses", "maxCompletionTokens": 8192}, + "qwen3.7-plus": {"apiMode": "chat_completions", "maxCompletionTokens": 4096}, + } + } + } + }, + ) + web_settings.save_provider_config({"provider": "dashscope", "model": "qwen3.8-max", "maxCompletionTokens": 10000}) + web_settings.save_provider_config({"provider": "dashscope", "model": "qwen3.7-plus", "effort": "low"}) + provider = _load_yaml(get_settings_path())["providers"]["dashscope"] + assert "apiMode" not in provider + assert provider["models"] == { + "qwen3.8-max": {"apiMode": "responses", "maxCompletionTokens": 10000}, + "qwen3.7-plus": {"apiMode": "chat_completions", "maxCompletionTokens": 4096}, + } + + def test_save_provider_config_null_clears_knob_but_preserves_others(_isolate_config): # 先写入两个值,再以 null 清除 thinkingBudget:该键从模型条目删除,其余键保留(回落模型默认)。 web_settings.save_provider_config( diff --git a/uv.lock b/uv.lock index 1196f5f5c..81b3836cc 100644 --- a/uv.lock +++ b/uv.lock @@ -1670,7 +1670,7 @@ requires-dist = [ { name = "keyring", specifier = ">=25.0" }, { name = "loguru", specifier = ">=0.7.0" }, { name = "mcp", specifier = ">=1.28.0" }, - { name = "openai", specifier = ">=1.50" }, + { name = "openai", specifier = ">=2.31.0" }, { name = "opentelemetry-distro", specifier = ">=0.48b0" }, { name = "opentelemetry-exporter-otlp", specifier = ">=1.27.0" }, { name = "packaging", specifier = ">=24.0" }, From e519f2460b5329b1a468e85d8020c22690e1f9ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Sat, 3 Oct 2026 19:53:14 +0800 Subject: [PATCH 2/3] fix: configure Responses capabilities and complete localization --- babel.cfg | 2 +- scripts/provider-update-guide.zh-CN.md | 55 ++++- .../i18n/locales/de/LC_MESSAGES/messages.po | 197 ++++++++++++++++++ .../i18n/locales/es/LC_MESSAGES/messages.po | 197 ++++++++++++++++++ .../i18n/locales/fr/LC_MESSAGES/messages.po | 197 ++++++++++++++++++ .../i18n/locales/ja/LC_MESSAGES/messages.po | 181 ++++++++++++++++ .../i18n/locales/pt/LC_MESSAGES/messages.po | 191 +++++++++++++++++ .../i18n/locales/zh/LC_MESSAGES/messages.po | 181 ++++++++++++++++ src/iac_code/providers/manager.py | 4 +- src/iac_code/providers/openai_provider.py | 18 +- src/iac_code/providers/registry.py | 12 +- src/iac_code/providers/responses_codec.py | 20 ++ src/iac_code/providers/responses_provider.py | 29 ++- tests/providers/test_responses_i18n.py | 112 ++++++++++ tests/providers/test_responses_routing.py | 67 +++++- website/docs/configuration/llm-providers.md | 24 +++ .../configuration/runtime-configuration.md | 5 +- .../current/configuration/llm-providers.md | 24 +++ .../configuration/runtime-configuration.md | 5 +- .../current/configuration/llm-providers.md | 24 +++ .../configuration/runtime-configuration.md | 5 +- .../current/configuration/llm-providers.md | 24 +++ .../configuration/runtime-configuration.md | 5 +- .../current/configuration/llm-providers.md | 24 +++ .../configuration/runtime-configuration.md | 5 +- .../current/configuration/llm-providers.md | 24 +++ .../configuration/runtime-configuration.md | 5 +- .../current/configuration/llm-providers.md | 24 +++ .../configuration/runtime-configuration.md | 5 +- 29 files changed, 1632 insertions(+), 34 deletions(-) create mode 100644 tests/providers/test_responses_i18n.py diff --git a/babel.cfg b/babel.cfg index cb06ce48e..7f0fe31d9 100644 --- a/babel.cfg +++ b/babel.cfg @@ -1,2 +1,2 @@ [python: src/iac_code/**.py] -keywords = translate_message CompletionEnrichmentError CompletionValidationError ToolCallProtocolError UnsafeStreamProtocolError ProviderRequestLeaseError _localizable_message_id +keywords = translate_message CompletionEnrichmentError CompletionValidationError ToolCallProtocolError UnsafeStreamProtocolError ProviderRequestLeaseError ResponsesConfigurationError ResponsesProtocolError ResponsesContextLimitError _localizable_message_id diff --git a/scripts/provider-update-guide.zh-CN.md b/scripts/provider-update-guide.zh-CN.md index d95ead24d..c4643dd3e 100644 --- a/scripts/provider-update-guide.zh-CN.md +++ b/scripts/provider-update-guide.zh-CN.md @@ -179,6 +179,8 @@ SDK schema、另一条 URL 或另一模型推断。UI 只展示稳定的标准 - 增删 `ModelEntry`,保持官方 model ID 原样。 - 每个非空 provider 最多一个 `is_default=True`。 - 仅在当前接入路径确实支持图片输入时设置 `support_multimodal=True`。 +- 核验每个模型的 `api_mode` 和 `chat_completions_tool_efforts`,按“Responses API 与 Chat 工具调用限制” + 一节维护协议选择与工具调用限制。 - 检查 `base_url`、`provider_class`、`qwenpaw_provider_ids` 和 `qwenpaw_chat_model`。 - 模型顺序通常为推荐默认、当前主力、较旧兼容模型;不要把已弃用模型放到默认位。 @@ -192,8 +194,8 @@ SDK schema、另一条 URL 或另一模型推断。UI 只展示稳定的标准 6. 是否存在同 endpoint、同协议、能力足够的 fallback。 7. 在以上条件都满足时,再参考厂商的默认/推荐模型。 -模型可以先进入可选目录但暂不成为默认。例如旗舰模型若只有 Responses API 才支持工具调用,而当前 -adapter 仍使用 Chat Completions,就应保留当前可工作的默认模型,并把 Responses adapter 作为独立需求。 +模型可以先进入可选目录但暂不成为默认。当前已支持按模型选择 Responses API;需要该协议的模型应 +声明 `api_mode="responses"`,再完成上述门禁。不能只加入模型 ID,仍让它进入默认 Chat 工具调用路径。 标准百炼和 Token Plan 的目录、生命周期与 endpoint 不同,因此可以有不同默认模型。 新增 provider 或全新模型前缀时,还要检查 @@ -226,6 +228,41 @@ adapter 仍使用 Chat Completions,就应保留当前可工作的默认模型 特别注意:能力声明只描述“支持什么”,实际请求格式由 provider adapter 组装。 +### Responses API 与 Chat 工具调用限制 + +一次模型更新要分别核验默认协议、显式切换协议后的可用性,以及思考模式与工具调用的组合: + +- `registry.py` 的 `ModelEntry.api_mode` 是未配置时的默认协议,默认值为 `chat_completions`。 + 当前 OpenAI 的 `gpt-6-astra/sol/luna` 声明为 `responses`;其他模型保持原默认协议。 +- 用户可在 `settings.yml` 的 `providers..models..apiMode` 设置 + `chat_completions` 或 `responses`。默认协议不等于另一个协议也支持该模型的全部能力。 +- `ModelEntry.chat_completions_tool_efforts` 声明 Chat 工具调用允许的实际 `reasoning_effort`: + `None` 表示不添加此项限制,空元组 `()` 表示不允许 Chat 工具调用,`("none",)` 表示仅在关闭 + reasoning 时允许。当前 Astra 使用 `()`,Sol/Luna 使用 `("none",)`;该声明按 provider 和精确 + model ID 生效,不应自动复制到 Azure、兼容端点或其他 provider。 +- `openai_provider.py` 读取上述声明,并用归一化后的 effort 校验工具请求;无工具的 Chat 请求不受 + 此限制。新增或替换模型时更新声明,不要在 adapter 中再添加模型 ID 集合、代际前缀或模型名分支。 + `thinking.py` 中的允许值和默认 effort 也必须同步,否则省略 effort 时会按错误默认值校验。 +- 当前 Responses 路径仅开放官方 OpenAI 和标准 DashScope 已核验端点。 + [`responses_provider.py`](../src/iac_code/providers/responses_provider.py) 中的 + `DASHSCOPE_RESPONSES_MODELS` 与 `validate_responses_endpoint()` 分别维护百炼支持的精确模型 ID 和 + endpoint 范围;新增 Qwen 模型要独立核验 Responses 文档,不能因为 Chat 目录存在就自动加入。 + 标准百炼模型仍默认使用 Chat,只有用户显式配置才切换;Token Plan/Coding Plan 不自动继承支持。 +- Responses 的 effort、输出限制、工具 schema 和输入窗口规则由 `responses_provider.py` 组装。 + 更新时核对 `_responses_effort()`、`max_output_tokens` 和 DashScope 输入窗口比例,不能直接复制 + Chat 的 `enable_thinking`、`thinking_budget` 或 `max_completion_tokens` 请求字段。 +- [`responses_codec.py`](../src/iac_code/providers/responses_codec.py) 维护 Responses input/output 与 + SSE 转换。只更新已有格式下的模型能力时,应更新配置;官方协议本身变化时才修改 codec/adapter。 +- 用户可见的 Responses 错误使用 `ResponsesConfigurationError`、`ResponsesProtocolError` 或 + `ResponsesContextLimitError`,首个参数保持英文字符串字面量;`babel.cfg` 已登记这些提取关键词。 + 补齐六套 `messages.po` 并编译,保留英文 `i18n_message_id`,使 A2A 可以按调用者语言重新翻译。 +- 配置方法或支持范围变化时,同步更新网站的 `configuration/llm-providers.md` 和 + `configuration/runtime-configuration.md`,覆盖英文及 `zh-Hans/es/fr/de/ja/pt` 六套本地化文档。 + +至少补充默认路由、显式切换、默认/关闭/开启 effort 的 Chat 工具调用、无工具请求,以及同名模型在 +其他 provider 下不受影响的离线用例。Responses 路径还要覆盖连续工具轮次、会话恢复和协议切换后的 +历史回传。下文的日期附录记录当时状态,其中“固定 Chat”“Responses 另行实现”不代表当前实现。 + ### 请求协议与响应解析 根据证据修改对应 adapter: @@ -233,6 +270,7 @@ adapter 仍使用 Chat Completions,就应保留当前可工作的默认模型 | 协议变化 | 常见文件 | | --- | --- | | OpenAI `reasoning_effort`、completion token | `openai_provider.py` | +| OpenAI / DashScope Responses 路由、请求与 SSE | `registry.py`、`manager.py`、`responses_provider.py`、`responses_codec.py` | | Anthropic adaptive thinking、legacy budget | `anthropic_provider.py` | | DashScope `enable_thinking`、budget、显式缓存 | `dashscope_provider.py` | | Kimi 分版本 thinking 行为 | `kimi_provider.py` | @@ -260,7 +298,8 @@ adapter 仍使用 Chat Completions,就应保留当前可工作的默认模型 | Provider/接入方式 | 典型形态 | 维护注意点 | | --- | --- | --- | -| OpenAI 官方 | `reasoning_effort` | effort 枚举随模型代际变化 | +| OpenAI 官方 Chat | `reasoning_effort` | effort 枚举与工具调用限制按模型声明 | +| OpenAI / DashScope Responses | `input`、`reasoning.effort`、`max_output_tokens` | 按模型配置选择;工具与历史使用 Responses item 格式 | | Anthropic 新模型 | `thinking.type=adaptive` + `output_config.effort` | 部分模型 adaptive always-on,旧模型仍使用 `budget_tokens` | | DashScope 兼容端点 | `extra_body.enable_thinking`,部分模型再带 budget/effort | 托管的 Kimi/GLM 也按 DashScope 协议处理 | | Kimi 官方 | 新旧代际可能分别使用 `reasoning_effort`、always-on 或 `extra_body.thinking` | 必须逐模型核验 | @@ -326,10 +365,10 @@ registry 会驱动多个入口,更新后至少检查: ### 必测内容 -1. registry:新增/移除模型、默认模型、多模态标记、区域/套餐差异。 +1. registry:新增/移除模型、默认模型、多模态标记、区域/套餐差异、`api_mode` 和 Chat 工具 effort 限制。 2. thinking registry:family、effort 枚举、默认值、budget 和 fallback。 3. adapter wire format:开启、关闭、默认、非法 effort,以及代际差异。 -4. manager:降级映射和 provider 选择。 +4. manager:降级映射、provider 选择、默认协议及模型级 `apiMode` 覆盖。 5. UI/auth:终端和 Web 的默认模型、可选模型、effort 标准值、翻译和长列表交互是否一致。 6. telemetry:新增模型不会被清洗为 `other`。 7. opaque metadata:流式、非流式、会话序列化、下一轮回传和公开输出隔离。 @@ -340,6 +379,9 @@ registry 会驱动多个入口,更新后至少检查: tests/providers/test_provider_model_research_updates.py tests/providers/test_thinking_registry.py tests/providers/test_openai_provider.py +tests/providers/test_responses_routing.py +tests/providers/test_openai_responses_provider.py +tests/providers/test_dashscope_responses_provider.py tests/providers/test_anthropic_provider.py tests/providers/test_dashscope_provider.py tests/providers/test_deepseek_provider.py @@ -347,6 +389,7 @@ tests/providers/test_manager.py tests/providers/test_new_providers.py tests/agent/test_message.py tests/agent/test_agent_loop_new.py +tests/agent/test_responses_session.py tests/services/test_token_counter.py tests/services/test_context_manager.py tests/commands/test_auth_basics.py @@ -415,6 +458,8 @@ git diff HEAD -- iac-code-rs - preview、deprecated、alias 和 dated snapshot 没有混为一谈。 - 产品版本名、控制台显示名和实际 API model ID 没有混为一谈。 - 同名模型在不同 provider 下的思考协议没有被错误复用。 +- 新模型的默认 `api_mode`、显式协议切换和 `chat_completions_tool_efforts` 已独立核验;adapter 中没有 + 新增模型名单判断。新增百炼 Responses 模型已同步其支持列表,未外推到套餐或未核验端点。 - effort 的 UI 标准值、wire alias、默认值和关闭语义已经分别验证。 - 新模型已加入遥测白名单和必要的降级链。 - 终端与 Web 的 model/effort 选择器均已验证,新增标签在所有 locale 中有正确翻译。 diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 8d2ab24e4..18f9ee289 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -14944,3 +14944,200 @@ msgstr "Bash zulassen?" #~ msgid "--interrupt-ttl must be a positive integer." #~ msgstr "--interrupt-ttl muss eine positive Ganzzahl sein." + +#: src/iac_code/providers/manager.py +msgid "Model apiMode must be chat_completions or responses." +msgstr "apiMode des Modells muss chat_completions oder responses sein." + +#: src/iac_code/providers/openai_provider.py +msgid "" +"This tool request requires apiMode=responses for the selected reasoning " +"effort." +msgstr "" +"Diese Werkzeuganfrage erfordert apiMode=responses für die gewählte " +"Denkintensität." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function call is missing its call_id or name." +msgstr "Dem Responses-Funktionsaufruf fehlt call_id oder der Name." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unknown local function." +msgstr "Responses hat eine unbekannte lokale Funktion zurückgegeben." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unfinished function call." +msgstr "Responses hat einen unvollendeten Funktionsaufruf zurückgegeben." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function arguments are not complete JSON." +msgstr "Die Funktionsargumente von Responses sind kein vollständiges JSON." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function arguments must be a JSON object." +msgstr "Die Funktionsargumente von Responses müssen ein JSON-Objekt sein." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains duplicate call IDs." +msgstr "Der Responses-Verlauf enthält doppelte Aufruf-IDs." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses tool result has no unique preceding call." +msgstr "" +"Das Responses-Werkzeugergebnis hat keinen eindeutig zugehörigen vorherigen " +"Aufruf." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains a function call without its result." +msgstr "Der Responses-Verlauf enthält einen Funktionsaufruf ohne Ergebnis." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains an unsupported output item." +msgstr "Der Responses-Verlauf enthält ein nicht unterstütztes Ausgabeelement." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses image input requires a user image data URL." +msgstr "Die Bildeingabe von Responses benötigt eine Daten-URL eines Benutzerbildes." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses received an unsupported input content block." +msgstr "Responses hat einen nicht unterstützten Eingabeinhaltsblock erhalten." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an invalid response." +msgstr "Responses hat eine ungültige Antwort zurückgegeben." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses did not complete successfully." +msgstr "Responses wurde nicht erfolgreich abgeschlossen." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unsupported output item type." +msgstr "Responses hat einen nicht unterstützten Ausgabeelementtyp zurückgegeben." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned unsupported message content." +msgstr "Responses hat nicht unterstützte Nachrichteninhalte zurückgegeben." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned duplicate function call IDs." +msgstr "Responses hat doppelte Funktionsaufruf-IDs zurückgegeben." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an incomplete tool turn." +msgstr "Responses hat eine unvollständige Runde von Werkzeugaufrufen zurückgegeben." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned no assistant content or function calls." +msgstr "Responses hat weder Assistenteninhalte noch Funktionsaufrufe zurückgegeben." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses changed a completed function call." +msgstr "Responses hat einen abgeschlossenen Funktionsaufruf geändert." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses sent events after its terminal response." +msgstr "Responses hat nach seiner abschließenden Antwort weitere Ereignisse gesendet." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal response dropped a streamed item." +msgstr "" +"Die abschließende Responses-Antwort hat ein im Stream übertragenes Element " +"ausgelassen." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal item identity differs from its stream." +msgstr "" +"Die Identität des endgültigen Responses-Elements unterscheidet sich von der " +"im Stream." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses changed a function identity." +msgstr "Responses hat die Identität einer Funktion geändert." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses final function arguments differ from their stream." +msgstr "" +"Die endgültigen Funktionsargumente von Responses unterscheiden sich von " +"denen im Stream." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal content differs from its stream." +msgstr "Der endgültige Inhalt von Responses unterscheidet sich vom Streaminhalt." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal response dropped a streamed function call." +msgstr "" +"Die abschließende Responses-Antwort hat einen im Stream übertragenen " +"Funktionsaufruf ausgelassen." + +#: src/iac_code/providers/responses_codec.py +#: src/iac_code/providers/responses_provider.py +msgid "Responses input exceeds the model context limit." +msgstr "Die Responses-Eingabe überschreitet die Kontextgrenze des Modells." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses generation failed." +msgstr "Die Responses-Generierung ist fehlgeschlagen." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains an invalid or duplicate item index." +msgstr "Der Responses-Stream enthält einen ungültigen oder doppelten Elementindex." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains an invalid local function call." +msgstr "Der Responses-Stream enthält einen ungültigen lokalen Funktionsaufruf." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains duplicate function call IDs." +msgstr "Der Responses-Stream enthält doppelte Funktionsaufruf-IDs." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses arguments do not match a preceding function item." +msgstr "" +"Die Responses-Argumente stimmen nicht mit einem vorherigen " +"Funktionsaufrufelement überein." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses arguments changed after function completion." +msgstr "Responses hat die Argumente nach Abschluss des Funktionsaufrufs geändert." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses completed an unknown output item." +msgstr "Responses hat ein unbekanntes Ausgabeelement abgeschlossen." + +#: src/iac_code/providers/responses_provider.py +msgid "" +"Responses API is supported only for OpenAI and standard DashScope in this " +"release." +msgstr "" +"In dieser Version wird die Responses API nur für OpenAI und Standard-" +"DashScope unterstützt." + +#: src/iac_code/providers/responses_provider.py +msgid "This DashScope model is not registered for Responses API." +msgstr "Dieses DashScope-Modell ist nicht für die Responses API registriert." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses API requires a supported HTTPS base URL." +msgstr "Die Responses API benötigt eine unterstützte HTTPS-Basis-URL." + +#: src/iac_code/providers/responses_provider.py +msgid "This base URL is not registered for Responses API." +msgstr "Diese Basis-URL ist nicht für die Responses API registriert." + +#: src/iac_code/providers/responses_provider.py +msgid "The selected reasoning effort is not supported by this Responses model." +msgstr "Dieses Responses-Modell unterstützt die gewählte Denkintensität nicht." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses max_output_tokens must be a positive integer." +msgstr "max_output_tokens für Responses muss eine positive ganze Zahl sein." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses stream ended without a terminal response." +msgstr "Der Responses-Stream wurde ohne abschließende Antwort beendet." + +#: src/iac_code/providers/responses_provider.py +msgid "DashScope Responses max_output_tokens must be at least 16." +msgstr "max_output_tokens für DashScope Responses muss mindestens 16 betragen." diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index b722c51e8..0883c2a02 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -14833,3 +14833,200 @@ msgstr "¿Permitir Bash?" #~ msgid "--interrupt-ttl must be a positive integer." #~ msgstr "--interrupt-ttl debe ser un entero positivo." + +#: src/iac_code/providers/manager.py +msgid "Model apiMode must be chat_completions or responses." +msgstr "El apiMode del modelo debe ser chat_completions o responses." + +#: src/iac_code/providers/openai_provider.py +msgid "" +"This tool request requires apiMode=responses for the selected reasoning " +"effort." +msgstr "" +"Esta solicitud de herramienta requiere apiMode=responses para el nivel de " +"razonamiento seleccionado." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function call is missing its call_id or name." +msgstr "A la llamada a función de Responses le falta call_id o el nombre." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unknown local function." +msgstr "Responses devolvió una función local desconocida." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unfinished function call." +msgstr "Responses devolvió una llamada a función sin finalizar." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function arguments are not complete JSON." +msgstr "Los argumentos de la función de Responses no son JSON completo." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function arguments must be a JSON object." +msgstr "Los argumentos de la función de Responses deben ser un objeto JSON." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains duplicate call IDs." +msgstr "El historial de Responses contiene ID de llamada duplicados." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses tool result has no unique preceding call." +msgstr "" +"El resultado de la herramienta de Responses no tiene una única llamada " +"anterior correspondiente." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains a function call without its result." +msgstr "El historial de Responses contiene una llamada a función sin su resultado." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains an unsupported output item." +msgstr "El historial de Responses contiene un elemento de salida no compatible." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses image input requires a user image data URL." +msgstr "" +"La entrada de imagen de Responses requiere una URL de datos de una imagen " +"del usuario." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses received an unsupported input content block." +msgstr "Responses recibió un bloque de contenido de entrada no compatible." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an invalid response." +msgstr "Responses devolvió una respuesta no válida." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses did not complete successfully." +msgstr "Responses no se completó correctamente." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unsupported output item type." +msgstr "Responses devolvió un tipo de elemento de salida no compatible." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned unsupported message content." +msgstr "Responses devolvió contenido de mensaje no compatible." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned duplicate function call IDs." +msgstr "Responses devolvió ID de llamada a función duplicados." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an incomplete tool turn." +msgstr "Responses devolvió un turno de llamadas a herramientas incompleto." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned no assistant content or function calls." +msgstr "Responses no devolvió contenido del asistente ni llamadas a funciones." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses changed a completed function call." +msgstr "Responses modificó una llamada a función ya completada." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses sent events after its terminal response." +msgstr "Responses envió eventos después de su respuesta final." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal response dropped a streamed item." +msgstr "La respuesta final de Responses omitió un elemento recibido en el flujo." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal item identity differs from its stream." +msgstr "" +"La identidad del elemento final de Responses difiere de la recibida en el " +"flujo." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses changed a function identity." +msgstr "Responses cambió la identidad de una función." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses final function arguments differ from their stream." +msgstr "" +"Los argumentos finales de la función de Responses difieren de los recibidos " +"en el flujo." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal content differs from its stream." +msgstr "El contenido final de Responses difiere del recibido en el flujo." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal response dropped a streamed function call." +msgstr "" +"La respuesta final de Responses omitió una llamada a función recibida en el " +"flujo." + +#: src/iac_code/providers/responses_codec.py +#: src/iac_code/providers/responses_provider.py +msgid "Responses input exceeds the model context limit." +msgstr "La entrada de Responses supera el límite de contexto del modelo." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses generation failed." +msgstr "La generación de Responses falló." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains an invalid or duplicate item index." +msgstr "El flujo de Responses contiene un índice de elemento no válido o duplicado." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains an invalid local function call." +msgstr "El flujo de Responses contiene una llamada a función local no válida." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains duplicate function call IDs." +msgstr "El flujo de Responses contiene ID de llamada a función duplicados." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses arguments do not match a preceding function item." +msgstr "" +"Los argumentos de Responses no coinciden con un elemento de llamada a " +"función anterior." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses arguments changed after function completion." +msgstr "Responses cambió los argumentos después de completar la llamada a la función." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses completed an unknown output item." +msgstr "Responses completó un elemento de salida desconocido." + +#: src/iac_code/providers/responses_provider.py +msgid "" +"Responses API is supported only for OpenAI and standard DashScope in this " +"release." +msgstr "" +"En esta versión, Responses API solo es compatible con OpenAI y DashScope " +"estándar." + +#: src/iac_code/providers/responses_provider.py +msgid "This DashScope model is not registered for Responses API." +msgstr "Este modelo de DashScope no está registrado para Responses API." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses API requires a supported HTTPS base URL." +msgstr "Responses API requiere una URL base HTTPS compatible." + +#: src/iac_code/providers/responses_provider.py +msgid "This base URL is not registered for Responses API." +msgstr "Esta URL base no está registrada para Responses API." + +#: src/iac_code/providers/responses_provider.py +msgid "The selected reasoning effort is not supported by this Responses model." +msgstr "Este modelo de Responses no admite el nivel de razonamiento seleccionado." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses max_output_tokens must be a positive integer." +msgstr "max_output_tokens de Responses debe ser un entero positivo." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses stream ended without a terminal response." +msgstr "El flujo de Responses terminó sin una respuesta final." + +#: src/iac_code/providers/responses_provider.py +msgid "DashScope Responses max_output_tokens must be at least 16." +msgstr "max_output_tokens de DashScope Responses debe ser al menos 16." diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 45418ba36..8319e20e7 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -14894,3 +14894,200 @@ msgstr "Autoriser Bash ?" #~ msgid "--interrupt-ttl must be a positive integer." #~ msgstr "--interrupt-ttl doit être un entier positif." + +#: src/iac_code/providers/manager.py +msgid "Model apiMode must be chat_completions or responses." +msgstr "Le paramètre apiMode du modèle doit être chat_completions ou responses." + +#: src/iac_code/providers/openai_provider.py +msgid "" +"This tool request requires apiMode=responses for the selected reasoning " +"effort." +msgstr "" +"Cette requête d’outil nécessite apiMode=responses pour le niveau de " +"raisonnement sélectionné." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function call is missing its call_id or name." +msgstr "L’appel de fonction Responses ne contient pas de call_id ou de nom." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unknown local function." +msgstr "Responses a renvoyé une fonction locale inconnue." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unfinished function call." +msgstr "Responses a renvoyé un appel de fonction inachevé." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function arguments are not complete JSON." +msgstr "Les arguments de la fonction Responses ne sont pas du JSON complet." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function arguments must be a JSON object." +msgstr "Les arguments de la fonction Responses doivent être un objet JSON." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains duplicate call IDs." +msgstr "L’historique de Responses contient des identifiants d’appel en double." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses tool result has no unique preceding call." +msgstr "" +"Le résultat d’outil de Responses ne correspond pas à un unique appel " +"précédent." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains a function call without its result." +msgstr "L’historique de Responses contient un appel de fonction sans son résultat." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains an unsupported output item." +msgstr "L’historique de Responses contient un élément de sortie non pris en charge." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses image input requires a user image data URL." +msgstr "" +"L’entrée d’image de Responses nécessite une URL de données d’une image de " +"l’utilisateur." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses received an unsupported input content block." +msgstr "Responses a reçu un bloc de contenu d’entrée non pris en charge." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an invalid response." +msgstr "Responses a renvoyé une réponse non valide." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses did not complete successfully." +msgstr "Responses ne s’est pas terminé correctement." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unsupported output item type." +msgstr "Responses a renvoyé un type d’élément de sortie non pris en charge." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned unsupported message content." +msgstr "Responses a renvoyé du contenu de message non pris en charge." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned duplicate function call IDs." +msgstr "Responses a renvoyé des identifiants d’appel de fonction en double." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an incomplete tool turn." +msgstr "Responses a renvoyé un tour d’appels d’outils incomplet." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned no assistant content or function calls." +msgstr "Responses n’a renvoyé ni contenu de l’assistant ni appel de fonction." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses changed a completed function call." +msgstr "Responses a modifié un appel de fonction déjà terminé." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses sent events after its terminal response." +msgstr "Responses a envoyé des événements après sa réponse finale." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal response dropped a streamed item." +msgstr "La réponse finale de Responses a omis un élément reçu dans le flux." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal item identity differs from its stream." +msgstr "" +"L’identité de l’élément final de Responses diffère de celle reçue dans le " +"flux." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses changed a function identity." +msgstr "Responses a modifié l’identité d’une fonction." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses final function arguments differ from their stream." +msgstr "" +"Les arguments finaux de la fonction Responses diffèrent de ceux reçus dans " +"le flux." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal content differs from its stream." +msgstr "Le contenu final de Responses diffère de celui reçu dans le flux." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal response dropped a streamed function call." +msgstr "La réponse finale de Responses a omis un appel de fonction reçu dans le flux." + +#: src/iac_code/providers/responses_codec.py +#: src/iac_code/providers/responses_provider.py +msgid "Responses input exceeds the model context limit." +msgstr "L’entrée de Responses dépasse la limite de contexte du modèle." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses generation failed." +msgstr "La génération de Responses a échoué." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains an invalid or duplicate item index." +msgstr "Le flux de Responses contient un index d’élément non valide ou en double." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains an invalid local function call." +msgstr "Le flux de Responses contient un appel de fonction locale non valide." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains duplicate function call IDs." +msgstr "Le flux de Responses contient des identifiants d’appel de fonction en double." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses arguments do not match a preceding function item." +msgstr "" +"Les arguments de Responses ne correspondent pas à un élément d’appel de " +"fonction précédent." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses arguments changed after function completion." +msgstr "Responses a modifié les arguments après la fin de l’appel de fonction." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses completed an unknown output item." +msgstr "Responses a terminé un élément de sortie inconnu." + +#: src/iac_code/providers/responses_provider.py +msgid "" +"Responses API is supported only for OpenAI and standard DashScope in this " +"release." +msgstr "" +"Dans cette version, Responses API est uniquement pris en charge pour OpenAI " +"et DashScope standard." + +#: src/iac_code/providers/responses_provider.py +msgid "This DashScope model is not registered for Responses API." +msgstr "Ce modèle DashScope n’est pas enregistré pour Responses API." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses API requires a supported HTTPS base URL." +msgstr "Responses API nécessite une URL de base HTTPS prise en charge." + +#: src/iac_code/providers/responses_provider.py +msgid "This base URL is not registered for Responses API." +msgstr "Cette URL de base n’est pas enregistrée pour Responses API." + +#: src/iac_code/providers/responses_provider.py +msgid "The selected reasoning effort is not supported by this Responses model." +msgstr "" +"Ce modèle Responses ne prend pas en charge le niveau de raisonnement " +"sélectionné." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses max_output_tokens must be a positive integer." +msgstr "max_output_tokens de Responses doit être un entier positif." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses stream ended without a terminal response." +msgstr "Le flux de Responses s’est terminé sans réponse finale." + +#: src/iac_code/providers/responses_provider.py +msgid "DashScope Responses max_output_tokens must be at least 16." +msgstr "max_output_tokens de DashScope Responses doit être au moins égal à 16." diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index f79f3adb6..ff9f5c6f1 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -13728,3 +13728,184 @@ msgstr "Bash を許可しますか?" #~ msgid "--interrupt-ttl must be a positive integer." #~ msgstr "--interrupt-ttl は正の整数で指定してください。" + +#: src/iac_code/providers/manager.py +msgid "Model apiMode must be chat_completions or responses." +msgstr "モデルの apiMode は chat_completions または responses にする必要があります。" + +#: src/iac_code/providers/openai_provider.py +msgid "" +"This tool request requires apiMode=responses for the selected reasoning " +"effort." +msgstr "選択された推論強度でのツールリクエストには apiMode=responses が必要です。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function call is missing its call_id or name." +msgstr "Responses の関数呼び出しに call_id または名前がありません。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unknown local function." +msgstr "Responses が不明なローカル関数を返しました。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unfinished function call." +msgstr "Responses が未完了の関数呼び出しを返しました。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function arguments are not complete JSON." +msgstr "Responses の関数引数が完全な JSON ではありません。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function arguments must be a JSON object." +msgstr "Responses の関数引数は JSON オブジェクトにする必要があります。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains duplicate call IDs." +msgstr "Responses の履歴に重複した呼び出し ID が含まれています。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses tool result has no unique preceding call." +msgstr "Responses のツール結果に一意に対応する先行呼び出しがありません。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains a function call without its result." +msgstr "Responses の履歴に結果のない関数呼び出しが含まれています。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains an unsupported output item." +msgstr "Responses の履歴に未対応の出力項目が含まれています。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses image input requires a user image data URL." +msgstr "Responses の画像入力にはユーザー画像の data URL が必要です。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses received an unsupported input content block." +msgstr "Responses が未対応の入力コンテンツブロックを受信しました。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an invalid response." +msgstr "Responses が無効な応答を返しました。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses did not complete successfully." +msgstr "Responses が正常に完了しませんでした。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unsupported output item type." +msgstr "Responses が未対応の出力項目タイプを返しました。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned unsupported message content." +msgstr "Responses が未対応のメッセージ内容を返しました。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned duplicate function call IDs." +msgstr "Responses が重複した関数呼び出し ID を返しました。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an incomplete tool turn." +msgstr "Responses が未完了のツール呼び出しターンを返しました。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned no assistant content or function calls." +msgstr "Responses がアシスタントの内容も関数呼び出しも返しませんでした。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses changed a completed function call." +msgstr "Responses が完了済みの関数呼び出しを変更しました。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses sent events after its terminal response." +msgstr "Responses が最終応答の後にイベントを送信しました。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal response dropped a streamed item." +msgstr "Responses の最終応答にストリームで送信された項目が含まれていません。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal item identity differs from its stream." +msgstr "Responses の最終項目の識別情報がストリーム内の項目と一致しません。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses changed a function identity." +msgstr "Responses が関数の識別情報を変更しました。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses final function arguments differ from their stream." +msgstr "Responses の最終的な関数引数がストリーム内の引数と一致しません。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal content differs from its stream." +msgstr "Responses の最終的な内容がストリーム内の内容と一致しません。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal response dropped a streamed function call." +msgstr "Responses の最終応答にストリームで送信された関数呼び出しが含まれていません。" + +#: src/iac_code/providers/responses_codec.py +#: src/iac_code/providers/responses_provider.py +msgid "Responses input exceeds the model context limit." +msgstr "Responses の入力がモデルのコンテキスト上限を超えています。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses generation failed." +msgstr "Responses の生成に失敗しました。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains an invalid or duplicate item index." +msgstr "Responses のストリームに無効または重複した項目インデックスが含まれています。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains an invalid local function call." +msgstr "Responses のストリームに無効なローカル関数呼び出しが含まれています。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains duplicate function call IDs." +msgstr "Responses のストリームに重複した関数呼び出し ID が含まれています。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses arguments do not match a preceding function item." +msgstr "Responses の引数が先行する関数呼び出し項目と一致しません。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses arguments changed after function completion." +msgstr "Responses が関数呼び出しの完了後に引数を変更しました。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses completed an unknown output item." +msgstr "Responses が不明な出力項目を完了しました。" + +#: src/iac_code/providers/responses_provider.py +msgid "" +"Responses API is supported only for OpenAI and standard DashScope in this " +"release." +msgstr "このバージョンの Responses API は、OpenAI と標準の DashScope のみ対応しています。" + +#: src/iac_code/providers/responses_provider.py +msgid "This DashScope model is not registered for Responses API." +msgstr "この DashScope モデルは Responses API の対応モデルとして登録されていません。" + +#: src/iac_code/providers/responses_provider.py +msgid "Responses API requires a supported HTTPS base URL." +msgstr "Responses API には、対応している HTTPS ベース URL が必要です。" + +#: src/iac_code/providers/responses_provider.py +msgid "This base URL is not registered for Responses API." +msgstr "このベース URL は Responses API の対応 URL として登録されていません。" + +#: src/iac_code/providers/responses_provider.py +msgid "The selected reasoning effort is not supported by this Responses model." +msgstr "この Responses モデルは選択された推論強度に対応していません。" + +#: src/iac_code/providers/responses_provider.py +msgid "Responses max_output_tokens must be a positive integer." +msgstr "Responses の max_output_tokens は正の整数にする必要があります。" + +#: src/iac_code/providers/responses_provider.py +msgid "Responses stream ended without a terminal response." +msgstr "Responses のストリームが最終応答なしで終了しました。" + +#: src/iac_code/providers/responses_provider.py +msgid "DashScope Responses max_output_tokens must be at least 16." +msgstr "DashScope Responses の max_output_tokens は 16 以上にする必要があります。" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 742cf3cd4..72dc95822 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -14701,3 +14701,194 @@ msgstr "Permitir Bash?" #~ msgid "--interrupt-ttl must be a positive integer." #~ msgstr "--interrupt-ttl deve ser um inteiro positivo." + +#: src/iac_code/providers/manager.py +msgid "Model apiMode must be chat_completions or responses." +msgstr "O apiMode do modelo deve ser chat_completions ou responses." + +#: src/iac_code/providers/openai_provider.py +msgid "" +"This tool request requires apiMode=responses for the selected reasoning " +"effort." +msgstr "" +"Esta solicitação de ferramenta exige apiMode=responses para o nível de " +"raciocínio selecionado." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function call is missing its call_id or name." +msgstr "A chamada de função de Responses não tem call_id ou nome." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unknown local function." +msgstr "Responses retornou uma função local desconhecida." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unfinished function call." +msgstr "Responses retornou uma chamada de função não concluída." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function arguments are not complete JSON." +msgstr "Os argumentos da função de Responses não são um JSON completo." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function arguments must be a JSON object." +msgstr "Os argumentos da função de Responses devem ser um objeto JSON." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains duplicate call IDs." +msgstr "O histórico de Responses contém IDs de chamada duplicados." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses tool result has no unique preceding call." +msgstr "" +"O resultado da ferramenta de Responses não tem uma única chamada anterior " +"correspondente." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains a function call without its result." +msgstr "O histórico de Responses contém uma chamada de função sem seu resultado." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains an unsupported output item." +msgstr "O histórico de Responses contém um item de saída não compatível." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses image input requires a user image data URL." +msgstr "" +"A entrada de imagem de Responses exige uma URL de dados de uma imagem do " +"usuário." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses received an unsupported input content block." +msgstr "Responses recebeu um bloco de conteúdo de entrada não compatível." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an invalid response." +msgstr "Responses retornou uma resposta inválida." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses did not complete successfully." +msgstr "Responses não foi concluído com sucesso." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unsupported output item type." +msgstr "Responses retornou um tipo de item de saída não compatível." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned unsupported message content." +msgstr "Responses retornou conteúdo de mensagem não compatível." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned duplicate function call IDs." +msgstr "Responses retornou IDs de chamada de função duplicados." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an incomplete tool turn." +msgstr "Responses retornou um turno de chamadas de ferramentas incompleto." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned no assistant content or function calls." +msgstr "Responses não retornou conteúdo do assistente nem chamadas de função." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses changed a completed function call." +msgstr "Responses alterou uma chamada de função já concluída." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses sent events after its terminal response." +msgstr "Responses enviou eventos após sua resposta final." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal response dropped a streamed item." +msgstr "A resposta final de Responses omitiu um item recebido no fluxo." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal item identity differs from its stream." +msgstr "A identidade do item final de Responses difere da recebida no fluxo." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses changed a function identity." +msgstr "Responses alterou a identidade de uma função." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses final function arguments differ from their stream." +msgstr "Os argumentos finais da função de Responses diferem dos recebidos no fluxo." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal content differs from its stream." +msgstr "O conteúdo final de Responses difere do recebido no fluxo." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal response dropped a streamed function call." +msgstr "A resposta final de Responses omitiu uma chamada de função recebida no fluxo." + +#: src/iac_code/providers/responses_codec.py +#: src/iac_code/providers/responses_provider.py +msgid "Responses input exceeds the model context limit." +msgstr "A entrada de Responses excede o limite de contexto do modelo." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses generation failed." +msgstr "A geração de Responses falhou." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains an invalid or duplicate item index." +msgstr "O fluxo de Responses contém um índice de item inválido ou duplicado." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains an invalid local function call." +msgstr "O fluxo de Responses contém uma chamada de função local inválida." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains duplicate function call IDs." +msgstr "O fluxo de Responses contém IDs de chamada de função duplicados." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses arguments do not match a preceding function item." +msgstr "" +"Os argumentos de Responses não correspondem a um item de chamada de função " +"anterior." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses arguments changed after function completion." +msgstr "Responses alterou os argumentos após concluir a chamada de função." + +#: src/iac_code/providers/responses_codec.py +msgid "Responses completed an unknown output item." +msgstr "Responses concluiu um item de saída desconhecido." + +#: src/iac_code/providers/responses_provider.py +msgid "" +"Responses API is supported only for OpenAI and standard DashScope in this " +"release." +msgstr "" +"Nesta versão, a Responses API é compatível apenas com OpenAI e DashScope " +"padrão." + +#: src/iac_code/providers/responses_provider.py +msgid "This DashScope model is not registered for Responses API." +msgstr "Este modelo de DashScope não está registrado para a Responses API." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses API requires a supported HTTPS base URL." +msgstr "A Responses API exige uma URL base HTTPS compatível." + +#: src/iac_code/providers/responses_provider.py +msgid "This base URL is not registered for Responses API." +msgstr "Esta URL base não está registrada para a Responses API." + +#: src/iac_code/providers/responses_provider.py +msgid "The selected reasoning effort is not supported by this Responses model." +msgstr "Este modelo de Responses não suporta o nível de raciocínio selecionado." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses max_output_tokens must be a positive integer." +msgstr "max_output_tokens de Responses deve ser um inteiro positivo." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses stream ended without a terminal response." +msgstr "O fluxo de Responses terminou sem uma resposta final." + +#: src/iac_code/providers/responses_provider.py +msgid "DashScope Responses max_output_tokens must be at least 16." +msgstr "max_output_tokens do DashScope Responses deve ser pelo menos 16." diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index d1b9adc63..e8480bb7f 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -13472,3 +13472,184 @@ msgstr "允许 Bash?" #~ msgid "--interrupt-ttl must be a positive integer." #~ msgstr "--interrupt-ttl 必须是正整数。" + +#: src/iac_code/providers/manager.py +msgid "Model apiMode must be chat_completions or responses." +msgstr "模型的 apiMode 必须为 chat_completions 或 responses。" + +#: src/iac_code/providers/openai_provider.py +msgid "" +"This tool request requires apiMode=responses for the selected reasoning " +"effort." +msgstr "所选推理强度下的工具请求要求设置 apiMode=responses。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function call is missing its call_id or name." +msgstr "Responses 函数调用缺少 call_id 或名称。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unknown local function." +msgstr "Responses 返回了未知的本地函数。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unfinished function call." +msgstr "Responses 返回了未完成的函数调用。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function arguments are not complete JSON." +msgstr "Responses 函数参数不是完整的 JSON。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses function arguments must be a JSON object." +msgstr "Responses 函数参数必须是 JSON 对象。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains duplicate call IDs." +msgstr "Responses 历史中包含重复的调用 ID。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses tool result has no unique preceding call." +msgstr "Responses 工具结果没有唯一对应的先前调用。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains a function call without its result." +msgstr "Responses 历史中存在缺少结果的函数调用。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses history contains an unsupported output item." +msgstr "Responses 历史中包含不支持的输出项。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses image input requires a user image data URL." +msgstr "Responses 图片输入要求使用用户图片的 data URL。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses received an unsupported input content block." +msgstr "Responses 收到了不支持的输入内容块。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an invalid response." +msgstr "Responses 返回了无效响应。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses did not complete successfully." +msgstr "Responses 未成功完成。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an unsupported output item type." +msgstr "Responses 返回了不支持的输出项类型。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned unsupported message content." +msgstr "Responses 返回了不支持的消息内容。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned duplicate function call IDs." +msgstr "Responses 返回了重复的函数调用 ID。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned an incomplete tool turn." +msgstr "Responses 返回了不完整的工具调用轮次。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses returned no assistant content or function calls." +msgstr "Responses 未返回助手内容或函数调用。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses changed a completed function call." +msgstr "Responses 更改了已完成的函数调用。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses sent events after its terminal response." +msgstr "Responses 在最终响应后继续发送了事件。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal response dropped a streamed item." +msgstr "Responses 最终响应遗漏了已流式输出的项。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal item identity differs from its stream." +msgstr "Responses 最终项的标识与流式项不一致。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses changed a function identity." +msgstr "Responses 更改了函数标识。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses final function arguments differ from their stream." +msgstr "Responses 最终函数参数与流式参数不一致。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal content differs from its stream." +msgstr "Responses 最终内容与流式内容不一致。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses terminal response dropped a streamed function call." +msgstr "Responses 最终响应遗漏了已流式输出的函数调用。" + +#: src/iac_code/providers/responses_codec.py +#: src/iac_code/providers/responses_provider.py +msgid "Responses input exceeds the model context limit." +msgstr "Responses 输入超出了模型的上下文限制。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses generation failed." +msgstr "Responses 生成失败。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains an invalid or duplicate item index." +msgstr "Responses 流中包含无效或重复的项索引。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains an invalid local function call." +msgstr "Responses 流中包含无效的本地函数调用。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses stream contains duplicate function call IDs." +msgstr "Responses 流中包含重复的函数调用 ID。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses arguments do not match a preceding function item." +msgstr "Responses 参数与之前的函数调用项不匹配。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses arguments changed after function completion." +msgstr "Responses 在函数调用完成后更改了参数。" + +#: src/iac_code/providers/responses_codec.py +msgid "Responses completed an unknown output item." +msgstr "Responses 完成了未知的输出项。" + +#: src/iac_code/providers/responses_provider.py +msgid "" +"Responses API is supported only for OpenAI and standard DashScope in this " +"release." +msgstr "此版本仅支持 OpenAI 和标准 DashScope 的 Responses API。" + +#: src/iac_code/providers/responses_provider.py +msgid "This DashScope model is not registered for Responses API." +msgstr "此 DashScope 模型尚未登记 Responses API 支持。" + +#: src/iac_code/providers/responses_provider.py +msgid "Responses API requires a supported HTTPS base URL." +msgstr "Responses API 要求使用受支持的 HTTPS 基础 URL。" + +#: src/iac_code/providers/responses_provider.py +msgid "This base URL is not registered for Responses API." +msgstr "此基础 URL 尚未登记 Responses API 支持。" + +#: src/iac_code/providers/responses_provider.py +msgid "The selected reasoning effort is not supported by this Responses model." +msgstr "此 Responses 模型不支持所选的推理强度。" + +#: src/iac_code/providers/responses_provider.py +msgid "Responses max_output_tokens must be a positive integer." +msgstr "Responses 的 max_output_tokens 必须是正整数。" + +#: src/iac_code/providers/responses_provider.py +msgid "Responses stream ended without a terminal response." +msgstr "Responses 流已结束,但没有最终响应。" + +#: src/iac_code/providers/responses_provider.py +msgid "DashScope Responses max_output_tokens must be at least 16." +msgstr "DashScope Responses 的 max_output_tokens 必须至少为 16。" diff --git a/src/iac_code/providers/manager.py b/src/iac_code/providers/manager.py index 31b2cfadb..d22f64480 100644 --- a/src/iac_code/providers/manager.py +++ b/src/iac_code/providers/manager.py @@ -516,7 +516,9 @@ def create_provider( model_entry = next((entry for entry in desc.models if entry.id == model), None) api_mode = model_cfg.get("apiMode", model_entry.api_mode if model_entry is not None else "chat_completions") if not isinstance(api_mode, str) or api_mode not in {"chat_completions", "responses"}: - raise ValueError("Model apiMode must be chat_completions or responses.") + from iac_code.providers.responses_codec import ResponsesConfigurationError + + raise ResponsesConfigurationError("Model apiMode must be chat_completions or responses.") saved_base = provider_cfg.get("apiBase") configured_base_url = saved_base if isinstance(saved_base, str) and saved_base else None effective_base_url = base_url or configured_base_url or desc.base_url diff --git a/src/iac_code/providers/openai_provider.py b/src/iac_code/providers/openai_provider.py index 6a9b971bc..2fe6a85fd 100644 --- a/src/iac_code/providers/openai_provider.py +++ b/src/iac_code/providers/openai_provider.py @@ -20,9 +20,11 @@ Provider, ToolDefinition, ) +from iac_code.providers.registry import PROVIDER_REGISTRY from iac_code.providers.request_headers import get_provider_request_headers, merge_provider_request_headers from iac_code.providers.request_logging import log_provider_request_policy from iac_code.providers.request_policy import bool_or_none, positive_int_or_none +from iac_code.providers.responses_codec import ResponsesConfigurationError from iac_code.providers.streaming import OpenAIStreamResponseAdapter from iac_code.providers.thinking import ThinkingFamily, get_thinking_spec, normalize_effort from iac_code.types.stream_events import ( @@ -352,10 +354,18 @@ def _build_chat_completion_kwargs( max_tokens: int, context: ChatRequestContext, ) -> dict[str, Any]: - if tools and self._PROVIDER_KEY == "openai" and self._model in {"gpt-6-astra", "gpt-6-sol", "gpt-6-luna"}: - effort = self._build_thinking_kwargs().get("reasoning_effort", "medium") - if self._model == "gpt-6-astra" or effort != "none": - raise ValueError("This GPT-6 tool request requires apiMode=responses.") + if tools: + descriptor = PROVIDER_REGISTRY.get(self._PROVIDER_KEY) + model_entry = ( + next((entry for entry in descriptor.models if entry.id == self._model), None) if descriptor else None + ) + if model_entry is not None and model_entry.chat_completions_tool_efforts is not None: + spec = get_thinking_spec(self._PROVIDER_KEY, self._model) + effort = self._build_thinking_kwargs().get("reasoning_effort", spec.default_effort_value) + if effort not in model_entry.chat_completions_tool_efforts: + raise ResponsesConfigurationError( + "This tool request requires apiMode=responses for the selected reasoning effort." + ) kwargs: dict[str, Any] = { "model": self._model, "messages": self._build_api_messages(messages, system, cache_policy=context.cache_policy), diff --git a/src/iac_code/providers/registry.py b/src/iac_code/providers/registry.py index c10d77785..07f063300 100644 --- a/src/iac_code/providers/registry.py +++ b/src/iac_code/providers/registry.py @@ -11,6 +11,8 @@ class ModelEntry: is_default: bool = False support_multimodal: bool = False api_mode: str = "chat_completions" + # None: no additional restriction; (): no Chat tools; otherwise allowed wire efforts. + chat_completions_tool_efforts: tuple[str, ...] | None = None @dataclass(frozen=True) @@ -122,9 +124,13 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.openai_provider.OpenAIProvider", base_url=None, models=[ - ModelEntry("gpt-6-astra", support_multimodal=True, api_mode="responses"), - ModelEntry("gpt-6-sol", support_multimodal=True, api_mode="responses"), - ModelEntry("gpt-6-luna", support_multimodal=True, api_mode="responses"), + ModelEntry("gpt-6-astra", support_multimodal=True, api_mode="responses", chat_completions_tool_efforts=()), + ModelEntry( + "gpt-6-sol", support_multimodal=True, api_mode="responses", chat_completions_tool_efforts=("none",) + ), + ModelEntry( + "gpt-6-luna", support_multimodal=True, api_mode="responses", chat_completions_tool_efforts=("none",) + ), ModelEntry("gpt-5.6-sol", is_default=True, support_multimodal=True), ModelEntry("gpt-5.6", support_multimodal=True), ModelEntry("gpt-5.6-terra", support_multimodal=True), diff --git a/src/iac_code/providers/responses_codec.py b/src/iac_code/providers/responses_codec.py index 7d538b869..79aa77d4e 100644 --- a/src/iac_code/providers/responses_codec.py +++ b/src/iac_code/providers/responses_codec.py @@ -6,6 +6,7 @@ import json from typing import Any +from iac_code.i18n import _ from iac_code.providers.base import Message, NonStreamingResponse, ToolDefinition from iac_code.types.stream_events import ( StreamEvent, @@ -24,12 +25,31 @@ class ResponsesProtocolError(RuntimeError): """A response cannot be represented by the local agent contract.""" + def __init__(self, message_id: str) -> None: + self.i18n_message_id = message_id + self.i18n_message_args: dict[str, Any] | None = None + super().__init__(_(message_id)) + + +class ResponsesConfigurationError(ValueError): + """A Responses configuration error with a deferred translation ID.""" + + def __init__(self, message_id: str) -> None: + self.i18n_message_id = message_id + self.i18n_message_args: dict[str, Any] | None = None + super().__init__(_(message_id)) + class ResponsesContextLimitError(RuntimeError): """Ask the owning agent to compact local history before one retry.""" context_limit_exceeded = True + def __init__(self, message_id: str) -> None: + self.i18n_message_id = message_id + self.i18n_message_args: dict[str, Any] | None = None + super().__init__(_(message_id)) + def plain(value: Any) -> Any: """Keep SDK extension fields (e.g. phase) when persisting output items.""" diff --git a/src/iac_code/providers/responses_provider.py b/src/iac_code/providers/responses_provider.py index 54a911f64..ad37c1106 100644 --- a/src/iac_code/providers/responses_provider.py +++ b/src/iac_code/providers/responses_provider.py @@ -12,6 +12,7 @@ from iac_code.providers.request_headers import get_provider_request_headers from iac_code.providers.request_logging import log_provider_request_policy from iac_code.providers.responses_codec import ( + ResponsesConfigurationError, ResponsesContextLimitError, ResponsesProtocolError, ResponsesStreamAdapter, @@ -71,14 +72,20 @@ def validate_responses_endpoint(provider_key: str, base_url: str | None, model: str) -> None: if provider_key not in {"openai", "dashscope"}: - raise ValueError("Responses API is supported only for OpenAI and standard DashScope in this release.") + raise ResponsesConfigurationError( + "Responses API is supported only for OpenAI and standard DashScope in this release." + ) if provider_key == "dashscope" and model not in DASHSCOPE_RESPONSES_MODELS: - raise ValueError("This DashScope model is not registered for Responses API.") - endpoint = urlsplit(base_url or "https://api.openai.com/v1") + raise ResponsesConfigurationError("This DashScope model is not registered for Responses API.") + try: + endpoint = urlsplit(base_url or "https://api.openai.com/v1") + port = endpoint.port + except ValueError as error: + raise ResponsesConfigurationError("Responses API requires a supported HTTPS base URL.") from error if endpoint.scheme != "https" or endpoint.username or endpoint.password or endpoint.query or endpoint.fragment: - raise ValueError("Responses API requires a supported HTTPS base URL.") - if endpoint.port not in {None, 443}: - raise ValueError("Responses API requires a supported HTTPS base URL.") + raise ResponsesConfigurationError("Responses API requires a supported HTTPS base URL.") + if port not in {None, 443}: + raise ResponsesConfigurationError("Responses API requires a supported HTTPS base URL.") host = endpoint.hostname or "" if provider_key == "openai": supported = host == "api.openai.com" and endpoint.path.rstrip("/") == "/v1" @@ -94,7 +101,7 @@ def validate_responses_endpoint(provider_key: str, base_url: str | None, model: ) ) and endpoint.path.rstrip("/") == "/compatible-mode/v1" if not supported: - raise ValueError("This base URL is not registered for Responses API.") + raise ResponsesConfigurationError("This base URL is not registered for Responses API.") class ResponsesProvider(OpenAIProvider): @@ -125,7 +132,9 @@ def _responses_effort(self) -> str | None: effort = spec.default_effort_value if effort is not None and spec.effort_values and effort not in spec.effort_values: if effort == "none": - raise ValueError("The selected reasoning effort is not supported by this Responses model.") + raise ResponsesConfigurationError( + "The selected reasoning effort is not supported by this Responses model." + ) effort = spec.default_effort_value return effort @@ -140,7 +149,7 @@ def _build_responses_kwargs( ) -> dict[str, Any]: limit = self._max_completion_tokens or max_tokens if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1: - raise ValueError("Responses max_output_tokens must be a positive integer.") + raise ResponsesConfigurationError("Responses max_output_tokens must be a positive integer.") kwargs: dict[str, Any] = { "model": self._model, "input": encode_input(messages, self._responses_identity), @@ -257,5 +266,5 @@ def _responses_effort(self) -> str | None: def _build_responses_kwargs(self, *args: Any, **kwargs: Any) -> dict[str, Any]: result = super()._build_responses_kwargs(*args, **kwargs) if result["max_output_tokens"] < 16: - raise ValueError("DashScope Responses max_output_tokens must be at least 16.") + raise ResponsesConfigurationError("DashScope Responses max_output_tokens must be at least 16.") return result diff --git a/tests/providers/test_responses_i18n.py b/tests/providers/test_responses_i18n.py new file mode 100644 index 000000000..22bcdde4e --- /dev/null +++ b/tests/providers/test_responses_i18n.py @@ -0,0 +1,112 @@ +import ast +import gettext +from functools import lru_cache +from io import BytesIO +from pathlib import Path + +import pytest +from babel.messages.mofile import write_mo +from babel.messages.pofile import read_po +from google.protobuf.json_format import MessageToDict + +import iac_code.i18n as i18n +from iac_code.a2a.events import publish_stream_event +from iac_code.a2a.runtime_overrides import a2a_request_context +from iac_code.providers.manager import _error_event_from_exception +from iac_code.providers.responses_codec import ( + ResponsesConfigurationError, + ResponsesContextLimitError, + ResponsesProtocolError, +) +from iac_code.providers.responses_provider import validate_responses_endpoint +from tests.a2a.fakes import FakeEventQueue + +ROOT = Path(__file__).resolve().parents[2] +ERROR_CASES = [ + (ResponsesConfigurationError, "Model apiMode must be chat_completions or responses."), + (ResponsesProtocolError, "Responses function arguments are not complete JSON."), + (ResponsesContextLimitError, "Responses input exceeds the model context limit."), +] + + +def _feature_messages(): + messages = {"Responses input exceeds the safe context budget after local compaction."} + for filename in ("responses_codec.py", "responses_provider.py", "openai_provider.py", "manager.py"): + source = ROOT / "src/iac_code/providers" / filename + for node in ast.walk(ast.parse(source.read_text(encoding="utf-8"))): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id + in {"ResponsesConfigurationError", "ResponsesProtocolError", "ResponsesContextLimitError"} + and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + ): + messages.add(node.args[0].value) + return messages + + +@lru_cache(maxsize=None) +def _catalog(language): + path = ROOT / "src/iac_code/i18n/locales" / language / "LC_MESSAGES/messages.po" + with path.open("rb") as file: + return read_po(file) + + +@pytest.fixture +def compiled_catalogs(monkeypatch): + translations = {} + for language in i18n.SUPPORTED_LANGUAGES: + if language == "en": + continue + output = BytesIO() + write_mo(output, _catalog(language)) + output.seek(0) + translations[language] = gettext.GNUTranslations(output) + monkeypatch.setattr(i18n, "_messages_catalog_cache", translations) + + +@pytest.mark.parametrize("language", [lang for lang in i18n.SUPPORTED_LANGUAGES if lang != "en"]) +def test_every_responses_user_message_has_a_complete_translation(language): + catalog = _catalog(language) + messages = _feature_messages() + assert messages + for message_id in messages: + message = catalog.get(message_id) + assert message is not None, message_id + assert message.string and not message.fuzzy, message_id + assert message.string != message_id, message_id + + +@pytest.mark.parametrize("language", i18n.SUPPORTED_LANGUAGES) +@pytest.mark.parametrize("error_class,message_id", ERROR_CASES) +def test_responses_errors_localize_and_keep_message_ids(compiled_catalogs, language, error_class, message_id): + with i18n.use_request_language(language): + error = error_class(message_id) + assert str(error) == i18n.translate_message(message_id, language=language) + event = _error_event_from_exception(error) + assert event.i18n_message_id == message_id + assert event.context_limit_exceeded == (error_class is ResponsesContextLimitError) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("language", i18n.SUPPORTED_LANGUAGES) +@pytest.mark.parametrize("error_class,message_id", ERROR_CASES) +async def test_responses_a2a_errors_use_the_callers_language(compiled_catalogs, language, error_class, message_id): + with i18n.use_request_language("zh"): + event = _error_event_from_exception(error_class(message_id)) + queue = FakeEventQueue() + with a2a_request_context(preferred_language=language): + await publish_stream_event(queue, task_id="test-task", context_id="test-context", event=event) + payload = MessageToDict(queue.events[0], preserving_proto_field_name=False) + assert payload["status"]["message"]["parts"][0]["text"] == i18n.translate_message(message_id, language=language) + + +@pytest.mark.parametrize( + "base_url", ["https://[invalid/v1", "https://api.openai.com:bad/v1", "https://api.openai.com:99999/v1"] +) +def test_malformed_responses_urls_have_a_localizable_configuration_error(base_url): + with pytest.raises(ResponsesConfigurationError) as raised: + validate_responses_endpoint("openai", base_url, "gpt-6-sol") + assert raised.value.i18n_message_id == "Responses API requires a supported HTTPS base URL." diff --git a/tests/providers/test_responses_routing.py b/tests/providers/test_responses_routing.py index 0f18fb547..979d9facb 100644 --- a/tests/providers/test_responses_routing.py +++ b/tests/providers/test_responses_routing.py @@ -1,12 +1,15 @@ +from dataclasses import replace + import pytest from iac_code.providers.anthropic_provider import AnthropicProvider from iac_code.providers.manager import create_provider from iac_code.providers.openai_provider import OpenAIProvider from iac_code.providers.qwen_provider import QwenProvider +from iac_code.providers.registry import PROVIDER_REGISTRY, ModelEntry from iac_code.providers.request_policy import ProviderRequestPolicy from iac_code.providers.responses_provider import DashScopeResponsesProvider, ResponsesProvider -from iac_code.providers.thinking import get_thinking_spec +from iac_code.providers.thinking import MODEL_THINKING, EffortLevel, ThinkingFamily, ThinkingSpec, get_thinking_spec from iac_code.services.context_manager import get_context_window_config from tests.providers.test_openai_responses_provider import TOOLS @@ -54,15 +57,22 @@ def test_unsupported_responses_provider_or_model_fails(key, model): @pytest.mark.parametrize( "model,effort,allowed", [ + ("gpt-6-astra", None, False), ("gpt-6-astra", "none", False), + ("gpt-6-astra", "high", False), + ("gpt-6-sol", None, False), ("gpt-6-sol", "high", False), ("gpt-6-sol", "none", True), + ("gpt-6-luna", None, False), + ("gpt-6-luna", "high", False), ("gpt-6-luna", "none", True), ], ) -def test_gpt6_forced_chat_checks_tool_restrictions(model, effort, allowed): +@pytest.mark.parametrize("streaming", [False, True]) +def test_gpt6_forced_chat_checks_tool_restrictions(model, effort, allowed, streaming): p = create("openai", model, {"effort": effort, "models": {model: {"apiMode": "chat_completions"}}}) - context = p._create_chat_request_context(streaming=False) + context = p._create_chat_request_context(streaming=streaming) + assert "tools" not in p._build_chat_completion_kwargs([], "system", None, 100, context) if allowed: assert p._build_chat_completion_kwargs([], "system", TOOLS, 100, context)["reasoning_effort"] == "none" else: @@ -70,6 +80,57 @@ def test_gpt6_forced_chat_checks_tool_restrictions(model, effort, allowed): p._build_chat_completion_kwargs([], "system", TOOLS, 100, context) +@pytest.mark.parametrize( + "tool_efforts,effort,allowed", + [ + (None, "high", True), + ((), None, False), + (("none",), None, False), + (("none",), "none", True), + (("high",), "high", True), + ], +) +def test_chat_tool_restrictions_follow_model_configuration(monkeypatch, tool_efforts, effort, allowed): + model = "test-configured-model" + monkeypatch.setitem( + PROVIDER_REGISTRY, + "openai", + replace( + PROVIDER_REGISTRY["openai"], + models=[ModelEntry(model, chat_completions_tool_efforts=tool_efforts)], + ), + ) + monkeypatch.setitem( + MODEL_THINKING["openai"], + model, + ThinkingSpec( + ThinkingFamily.OPENAI, (EffortLevel.NONE, EffortLevel.MEDIUM, EffortLevel.HIGH), EffortLevel.MEDIUM + ), + ) + p = OpenAIProvider(model, client=object(), effort=effort) + context = p._create_chat_request_context(streaming=False) + if allowed: + assert p._build_chat_completion_kwargs([], "system", TOOLS, 100, context)["tools"] + else: + with pytest.raises(ValueError, match="requires apiMode=responses"): + p._build_chat_completion_kwargs([], "system", TOOLS, 100, context) + + +@pytest.mark.parametrize( + "key,model", + [ + ("openai", "gpt-5.6-sol"), + ("openai", "test-unknown-model"), + ("openai_compatible", "gpt-6-astra"), + ("azure_openai", "gpt-6-astra"), + ], +) +def test_chat_tool_restrictions_do_not_affect_other_models_or_providers(key, model): + p = OpenAIProvider(model, client=object(), provider_key=key, effort="high") + context = p._create_chat_request_context(streaming=False) + assert p._build_chat_completion_kwargs([], "system", TOOLS, 100, context)["tools"] + + def test_gpt6_astra_responses_cannot_disable_reasoning(): p = create("openai", "gpt-6-astra", {"thinkingEnabled": False}) with pytest.raises(ValueError, match="reasoning effort"): diff --git a/website/docs/configuration/llm-providers.md b/website/docs/configuration/llm-providers.md index 8a7c98c21..7d2c69bb9 100644 --- a/website/docs/configuration/llm-providers.md +++ b/website/docs/configuration/llm-providers.md @@ -76,3 +76,27 @@ CLI arguments > environment variables > configuration files | `IAC_CODE_MODEL` | Model name | | `IAC_CODE_BASE_URL` | API endpoint override for the active provider; takes precedence over the saved `apiBase` and built-in default URL | | `IAC_CODE_API_KEY` | Provider API key | + +## Responses API + +OpenAI-style providers use Chat Completions by default. The registered OpenAI models `gpt-6-astra`, `gpt-6-sol`, and `gpt-6-luna` default to Responses API. Anthropic providers continue to use their existing Messages protocol. + +Responses is currently available for official OpenAI and verified standard DashScope endpoints and models, such as `qwen3.8-max`. Azure OpenAI, `openai_compatible`, DashScope Token Plan, and CodingPlan providers do not support this switch. + +To opt a Qwen model into Responses, add this model-level setting to `settings.yml`: + +```yaml +activeProvider: dashscope +providers: + dashscope: + model: qwen3.8-max + models: + qwen3.8-max: + apiMode: responses +``` + +Set `apiMode` to `chat_completions` to switch back. Omitting it restores the model's built-in protocol default; for standard DashScope models, that default remains Chat Completions. The setting applies only to the named model under the named provider. + +If you explicitly choose Chat for GPT-6, Astra cannot make tool calls; Sol and Luna can make tool calls only with `effort: none`. Use Responses for tool calls with reasoning enabled. + +See [runtime configuration](./runtime-configuration.md) for request settings and output limits. diff --git a/website/docs/configuration/runtime-configuration.md b/website/docs/configuration/runtime-configuration.md index 509052deb..5031cfef2 100644 --- a/website/docs/configuration/runtime-configuration.md +++ b/website/docs/configuration/runtime-configuration.md @@ -86,8 +86,11 @@ providers: |---|---|---| | `thinkingEnabled` | Provider or model | Optional boolean thinking switch. `true` asks supported providers/models to enable thinking; `false` asks them to disable it; omitted preserves the provider/model default. | | `thinkingBudget` | Provider or model | Positive integer reasoning/thinking budget passed to providers that support it. | -| `maxCompletionTokens` | Provider or model | Positive integer `max_completion_tokens` override for providers/models that use that request field. | +| `maxCompletionTokens` | Provider or model | Positive integer output-limit override: `max_completion_tokens` for applicable Chat providers, or `max_output_tokens` for Responses. | | `effort` | Provider or model | Optional thinking effort override for models that support effort control. | +| `apiMode` | Model only | `chat_completions` or `responses`; omitted uses the model’s built-in protocol default. See [LLM providers](./llm-providers.md) for supported providers and models. | + +For Responses, configure `apiMode` only under `providers..models.`. `effort` maps to `reasoning.effort`; `thinkingBudget` is not sent to this API. `maxCompletionTokens` sets the total output limit, including reasoning tokens, and must be at least `16` for DashScope Responses. The `settings.yml` field names stay the same when switching protocols. Model-level values under `providers..models.` override provider-level values when they are valid. Invalid numeric values are ignored, so IaC Code falls back to the provider-level value or built-in model policy. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/llm-providers.md b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/llm-providers.md index c2bd39fb3..6d5494e73 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/llm-providers.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/llm-providers.md @@ -76,3 +76,27 @@ CLI-Argumente > Umgebungsvariablen > Konfigurationsdateien | `IAC_CODE_MODEL` | Modellname | | `IAC_CODE_BASE_URL` | Überschreibt den API-Endpunkt des aktiven Anbieters; hat Vorrang vor dem gespeicherten `apiBase` und der integrierten Standard-URL | | `IAC_CODE_API_KEY` | API-Schluessel des Anbieters | + +## Responses API + +Anbieter mit OpenAI-Protokoll verwenden standardmäßig Chat Completions. Die registrierten OpenAI-Modelle `gpt-6-astra`, `gpt-6-sol` und `gpt-6-luna` verwenden standardmäßig die Responses API. Anthropic-Anbieter verwenden weiterhin ihr bisheriges Messages-Protokoll. + +Responses ist derzeit für den offiziellen OpenAI-Endpunkt sowie geprüfte Endpunkte und Modelle von Standard-DashScope verfügbar, etwa `qwen3.8-max`. Azure OpenAI, `openai_compatible`, DashScope Token Plan und CodingPlan-Anbieter unterstützen diesen Wechsel nicht. + +Um ein Qwen-Modell mit Responses zu verwenden, ergänzen Sie diese Modelleinstellung in `settings.yml`: + +```yaml +activeProvider: dashscope +providers: + dashscope: + model: qwen3.8-max + models: + qwen3.8-max: + apiMode: responses +``` + +Setzen Sie `apiMode` auf `chat_completions`, um zurückzuwechseln. Ohne diesen Eintrag gilt das integrierte Standardprotokoll des Modells; für Modelle von Standard-DashScope bleibt dies Chat Completions. Die Einstellung gilt nur für das angegebene Modell beim angegebenen Anbieter. + +Wenn Sie für GPT-6 ausdrücklich Chat wählen, kann Astra keine Werkzeuge aufrufen; Sol und Luna können dies nur mit `effort: none`. Verwenden Sie Responses für Werkzeugaufrufe mit aktiviertem Reasoning. + +Anfrageeinstellungen und Ausgabelimits finden Sie unter [Laufzeitkonfiguration](./runtime-configuration.md). diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 81a8cd30c..b551ab77a 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -86,8 +86,11 @@ providers: |---|---|---| | `thinkingEnabled` | Provider oder Modell | Optionaler boolescher Thinking-Schalter. `true` fordert unterstützte Provider/Modelle auf, Thinking zu aktivieren; `false` fordert die Deaktivierung an; ausgelassen bleibt der Provider-/Model-Default erhalten. | | `thinkingBudget` | Provider oder Modell | Positives ganzzahliges Budget für Reasoning/Thinking, das an Provider übergeben wird, die es unterstützen. | -| `maxCompletionTokens` | Provider oder Modell | Positiver ganzzahliger Überschreibungswert für `max_completion_tokens` bei Providern/Modellen, die dieses Anfragefeld verwenden. | +| `maxCompletionTokens` | Anbieter oder Modell | Positive ganze Zahl als Ausgabelimit: `max_completion_tokens` für entsprechende Chat-Anbieter oder `max_output_tokens` für Responses. | | `effort` | Provider oder Modell | Optionale Überschreibung des Thinking-Aufwands; nur wirksam bei Modellen, die Effort-Steuerung unterstützen. | +| `apiMode` | Nur Modell | `chat_completions` oder `responses`; ohne Angabe gilt das integrierte Standardprotokoll des Modells. Unterstützte Anbieter und Modelle stehen unter [LLM-Anbieter](./llm-providers.md). | + +Für Responses wird `apiMode` nur unter `providers..models.` konfiguriert. `effort` wird als `reasoning.effort` übertragen; `thinkingBudget` wird nicht an diese API gesendet. `maxCompletionTokens` legt das gesamte Ausgabelimit einschließlich Reasoning-Tokens fest und muss für DashScope Responses mindestens `16` betragen. Die Feldnamen in `settings.yml` bleiben beim Protokollwechsel gleich. Gültige modellbezogene Werte unter `providers..models.` überschreiben providerbezogene Werte. Ungültige numerische Werte werden ignoriert, sodass IaC Code auf den providerbezogenen Wert oder die eingebaute Modellrichtlinie zurückfällt. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/llm-providers.md b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/llm-providers.md index d4a986197..e2e6f77cf 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/llm-providers.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/llm-providers.md @@ -76,3 +76,27 @@ CLI arguments > environment variables > configuration files | `IAC_CODE_MODEL` | Nombre del modelo | | `IAC_CODE_BASE_URL` | Sobrescribe el endpoint de API del proveedor activo; tiene prioridad sobre el `apiBase` guardado y la URL predeterminada integrada | | `IAC_CODE_API_KEY` | Clave API del proveedor | + +## Responses API + +Los proveedores que usan el protocolo de OpenAI utilizan Chat Completions de forma predeterminada. Los modelos OpenAI registrados `gpt-6-astra`, `gpt-6-sol` y `gpt-6-luna` utilizan Responses API de forma predeterminada. Los proveedores Anthropic conservan su protocolo Messages actual. + +Actualmente, Responses está disponible para el endpoint oficial de OpenAI y los endpoints y modelos verificados de DashScope estándar, como `qwen3.8-max`. Azure OpenAI, `openai_compatible`, DashScope Token Plan y los proveedores CodingPlan no admiten este cambio. + +Para usar Responses con un modelo Qwen, añada esta configuración a nivel de modelo en `settings.yml`: + +```yaml +activeProvider: dashscope +providers: + dashscope: + model: qwen3.8-max + models: + qwen3.8-max: + apiMode: responses +``` + +Cambie `apiMode` a `chat_completions` para volver al protocolo anterior. Si lo omite, se utiliza el protocolo predeterminado del modelo; para los modelos de DashScope estándar sigue siendo Chat Completions. La configuración solo se aplica al modelo indicado dentro del proveedor indicado. + +Si selecciona Chat explícitamente para GPT-6, Astra no puede llamar a herramientas; Sol y Luna solo pueden hacerlo con `effort: none`. Use Responses para llamadas a herramientas con razonamiento habilitado. + +Consulte la [configuración de ejecución](./runtime-configuration.md) para conocer los parámetros de solicitud y los límites de salida. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 9d8c972d6..12676eb02 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -86,8 +86,11 @@ providers: |---|---|---| | `thinkingEnabled` | Proveedor o modelo | Interruptor booleano opcional de thinking. `true` pide a proveedores/modelos compatibles que lo habiliten; `false` pide deshabilitarlo; si se omite, conserva el valor predeterminado del proveedor/modelo. | | `thinkingBudget` | Proveedor o modelo | Presupuesto de reasoning/thinking como entero positivo, enviado a los proveedores que lo admiten. | -| `maxCompletionTokens` | Proveedor o modelo | Valor entero positivo que anula `max_completion_tokens` para proveedores/modelos que usan ese campo de solicitud. | +| `maxCompletionTokens` | Proveedor o modelo | Entero positivo que anula el límite de salida: `max_completion_tokens` para proveedores Chat compatibles o `max_output_tokens` para Responses. | | `effort` | Proveedor o modelo | Anulación opcional del effort de thinking, solo para modelos que admiten control de effort. | +| `apiMode` | Solo modelo | `chat_completions` o `responses`; si se omite, se usa el protocolo predeterminado del modelo. Consulte los [proveedores LLM](./llm-providers.md) para conocer los proveedores y modelos compatibles. | + +Para Responses, configure `apiMode` solo en `providers..models.`. `effort` se envía como `reasoning.effort`; `thinkingBudget` no se envía a esta API. `maxCompletionTokens` establece el límite total de salida, incluidos los tokens de razonamiento, y debe ser al menos `16` para DashScope Responses. Los nombres de los campos de `settings.yml` no cambian al cambiar de protocolo. Los valores válidos a nivel de modelo bajo `providers..models.` anulan los valores a nivel de proveedor. Los valores numéricos no válidos se ignoran, por lo que IaC Code recurre al valor del proveedor o a la política integrada del modelo. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/llm-providers.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/llm-providers.md index 5185cfd88..e655f1240 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/llm-providers.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/llm-providers.md @@ -76,3 +76,27 @@ CLI arguments > environment variables > configuration files | `IAC_CODE_MODEL` | Nom du modèle | | `IAC_CODE_BASE_URL` | Remplace le point de terminaison API du fournisseur actif ; prioritaire sur l’`apiBase` enregistré et l’URL intégrée par défaut | | `IAC_CODE_API_KEY` | Clé API du fournisseur | + +## Responses API + +Les fournisseurs utilisant le protocole OpenAI emploient Chat Completions par défaut. Les modèles OpenAI enregistrés `gpt-6-astra`, `gpt-6-sol` et `gpt-6-luna` utilisent Responses API par défaut. Les fournisseurs Anthropic conservent leur protocole Messages actuel. + +Responses est actuellement disponible pour le point de terminaison officiel d’OpenAI ainsi que les points de terminaison et modèles vérifiés de DashScope standard, comme `qwen3.8-max`. Azure OpenAI, `openai_compatible`, DashScope Token Plan et les fournisseurs CodingPlan ne prennent pas en charge ce changement. + +Pour utiliser Responses avec un modèle Qwen, ajoutez ce réglage au niveau du modèle dans `settings.yml` : + +```yaml +activeProvider: dashscope +providers: + dashscope: + model: qwen3.8-max + models: + qwen3.8-max: + apiMode: responses +``` + +Définissez `apiMode` sur `chat_completions` pour revenir au protocole précédent. En l’omettant, vous utilisez le protocole intégré par défaut du modèle ; pour les modèles DashScope standard, il reste Chat Completions. Le réglage s’applique uniquement au modèle indiqué pour le fournisseur indiqué. + +Si vous choisissez explicitement Chat pour GPT-6, Astra ne peut pas appeler d’outils ; Sol et Luna peuvent le faire uniquement avec `effort: none`. Utilisez Responses pour les appels d’outils avec raisonnement activé. + +Consultez la [configuration d’exécution](./runtime-configuration.md) pour les paramètres de requête et les limites de sortie. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index f130e1faf..208e69849 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -86,8 +86,11 @@ providers: |---|---|---| | `thinkingEnabled` | Fournisseur ou modèle | Interrupteur booléen optionnel pour le thinking. `true` demande aux fournisseurs/modèles compatibles de l'activer ; `false` demande de le désactiver ; si le champ est omis, le défaut du fournisseur/modèle est conservé. | | `thinkingBudget` | Fournisseur ou modèle | Budget de reasoning/thinking sous forme d'entier positif, transmis aux fournisseurs qui le prennent en charge. | -| `maxCompletionTokens` | Fournisseur ou modèle | Valeur positive entière qui remplace `max_completion_tokens` pour les fournisseurs/modèles utilisant ce champ de requête. | +| `maxCompletionTokens` | Fournisseur ou modèle | Entier positif remplaçant la limite de sortie : `max_completion_tokens` pour les fournisseurs Chat concernés, ou `max_output_tokens` pour Responses. | | `effort` | Fournisseur ou modèle | Surcharge optionnelle de l'effort de thinking, uniquement pour les modèles qui prennent en charge le contrôle d'effort. | +| `apiMode` | Modèle uniquement | `chat_completions` ou `responses` ; si omis, utilise le protocole intégré par défaut du modèle. Consultez les [fournisseurs LLM](./llm-providers.md) pour les fournisseurs et modèles pris en charge. | + +Pour Responses, configurez `apiMode` uniquement sous `providers..models.`. `effort` est transmis comme `reasoning.effort` ; `thinkingBudget` n’est pas envoyé à cette API. `maxCompletionTokens` fixe la limite totale de sortie, y compris les tokens de raisonnement, et doit être au moins égal à `16` pour DashScope Responses. Les noms des champs de `settings.yml` restent identiques lors du changement de protocole. Les valeurs valides définies au niveau du modèle sous `providers..models.` remplacent les valeurs définies au niveau du fournisseur. Les valeurs numériques invalides sont ignorées ; IaC Code revient alors à la valeur du fournisseur ou à la politique intégrée du modèle. diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/llm-providers.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/llm-providers.md index f7d3e3031..8d8b65c74 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/llm-providers.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/llm-providers.md @@ -76,3 +76,27 @@ CLI 引数 > 環境変数 > 設定ファイル | `IAC_CODE_MODEL` | モデル名 | | `IAC_CODE_BASE_URL` | 現在アクティブなプロバイダーの API エンドポイントを上書きします。保存済みの `apiBase` と組み込みの既定 URL より優先されます | | `IAC_CODE_API_KEY` | プロバイダー API キー | + +## Responses API + +OpenAI 形式のプロトコルを使うプロバイダーは、デフォルトで Chat Completions を使用します。登録済みの OpenAI モデル `gpt-6-astra`、`gpt-6-sol`、`gpt-6-luna` は、デフォルトで Responses API を使用します。Anthropic プロバイダーは従来の Messages プロトコルを引き続き使用します。 + +現在 Responses を利用できるのは、OpenAI 公式エンドポイントと、検証済みの標準 DashScope エンドポイントおよびモデル(`qwen3.8-max` など)です。Azure OpenAI、`openai_compatible`、DashScope Token Plan、CodingPlan プロバイダーは、この切り替えに対応していません。 + +Qwen モデルで Responses を使うには、`settings.yml` に次のモデル単位の設定を追加します。 + +```yaml +activeProvider: dashscope +providers: + dashscope: + model: qwen3.8-max + models: + qwen3.8-max: + apiMode: responses +``` + +元に戻すには、`apiMode` を `chat_completions` に変更します。省略すると、モデルに組み込まれたデフォルトのプロトコルが使われます。標準 DashScope モデルのデフォルトは引き続き Chat Completions です。この設定は、指定したプロバイダーの指定したモデルにのみ適用されます。 + +GPT-6 で明示的に Chat を選択した場合、Astra はツールを呼び出せません。Sol と Luna は `effort: none` の場合のみツールを呼び出せます。推論を有効にしたツール呼び出しには Responses を使用してください。 + +リクエスト設定と出力上限は[実行時の設定](./runtime-configuration.md)を参照してください。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index c867ae215..0f13247eb 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -86,8 +86,11 @@ providers: |---|---|---| | `thinkingEnabled` | Provider またはモデル | 任意の boolean thinking スイッチ。`true` は対応する provider/model に thinking の有効化を要求し、`false` は無効化を要求します。省略時は provider/model のデフォルトを維持します。 | | `thinkingBudget` | Provider またはモデル | 正の整数の reasoning/thinking 予算。対応している provider に渡されます。 | -| `maxCompletionTokens` | Provider またはモデル | `max_completion_tokens` を使う provider/model 向けの、正の整数の上書き値。 | +| `maxCompletionTokens` | プロバイダーまたはモデル | 正の整数で出力上限を上書きします。対応する Chat プロバイダーでは `max_completion_tokens`、Responses では `max_output_tokens` を使います。 | | `effort` | Provider またはモデル | thinking effort の任意の上書き値。effort 制御に対応したモデルでのみ有効です。 | +| `apiMode` | モデルのみ | `chat_completions` または `responses`。省略時はモデルに組み込まれたデフォルトのプロトコルを使います。対応するプロバイダーとモデルは [LLM プロバイダー](./llm-providers.md)を参照してください。 | + +Responses の `apiMode` は、`providers..models.` の下にのみ設定します。`effort` は `reasoning.effort` として送信され、`thinkingBudget` はこの API に送信されません。`maxCompletionTokens` は推論トークンを含む総出力上限を設定し、DashScope Responses では `16` 以上が必要です。プロトコルを切り替えても、`settings.yml` のフィールド名は変わりません。 `providers..models.` 以下のモデル単位の有効な値は、provider 単位の値を上書きします。無効な数値は無視されるため、IaC Code は provider 単位の値または組み込みのモデルポリシーにフォールバックします。 diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/llm-providers.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/llm-providers.md index 796a8a116..93de4f610 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/llm-providers.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/llm-providers.md @@ -76,3 +76,27 @@ CLI arguments > environment variables > configuration files | `IAC_CODE_MODEL` | Nome do modelo | | `IAC_CODE_BASE_URL` | Substitui o endpoint de API do provedor ativo; tem precedência sobre o `apiBase` salvo e a URL padrão integrada | | `IAC_CODE_API_KEY` | Chave de API do provedor | + +## Responses API + +Os provedores que usam o protocolo OpenAI utilizam Chat Completions por padrão. Os modelos OpenAI registrados `gpt-6-astra`, `gpt-6-sol` e `gpt-6-luna` utilizam a Responses API por padrão. Os provedores Anthropic continuam usando o protocolo Messages atual. + +Atualmente, Responses está disponível para o endpoint oficial da OpenAI e os endpoints e modelos verificados do DashScope padrão, como `qwen3.8-max`. Azure OpenAI, `openai_compatible`, DashScope Token Plan e provedores CodingPlan não permitem essa mudança. + +Para usar Responses com um modelo Qwen, adicione esta configuração no nível do modelo em `settings.yml`: + +```yaml +activeProvider: dashscope +providers: + dashscope: + model: qwen3.8-max + models: + qwen3.8-max: + apiMode: responses +``` + +Defina `apiMode` como `chat_completions` para voltar ao protocolo anterior. Se o campo for omitido, será usado o protocolo padrão integrado do modelo; para modelos do DashScope padrão, ele continua sendo Chat Completions. A configuração se aplica apenas ao modelo indicado no provedor indicado. + +Se você escolher Chat explicitamente para GPT-6, Astra não poderá chamar ferramentas; Sol e Luna só poderão fazer isso com `effort: none`. Use Responses para chamadas de ferramentas com raciocínio ativado. + +Consulte a [configuração de execução](./runtime-configuration.md) para ver os parâmetros de requisição e os limites de saída. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 1f3614699..d901d9698 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -86,8 +86,11 @@ providers: |---|---|---| | `thinkingEnabled` | Provedor ou modelo | Chave booleana opcional de thinking. `true` solicita que provedores/modelos compatíveis habilitem thinking; `false` solicita a desativação; se omitida, preserva o padrão do provedor/modelo. | | `thinkingBudget` | Provedor ou modelo | Orçamento de reasoning/thinking como inteiro positivo, enviado aos provedores que oferecem suporte a ele. | -| `maxCompletionTokens` | Provedor ou modelo | Valor inteiro positivo que substitui `max_completion_tokens` para provedores/modelos que usam esse campo de requisição. | +| `maxCompletionTokens` | Provedor ou modelo | Inteiro positivo que substitui o limite de saída: `max_completion_tokens` para provedores Chat compatíveis ou `max_output_tokens` para Responses. | | `effort` | Provedor ou modelo | Substituição opcional do effort de thinking, válida apenas para modelos que oferecem controle de effort. | +| `apiMode` | Apenas modelo | `chat_completions` ou `responses`; se omitido, usa o protocolo padrão integrado do modelo. Consulte os [provedores LLM](./llm-providers.md) para ver os provedores e modelos compatíveis. | + +Para Responses, configure `apiMode` apenas em `providers..models.`. `effort` é enviado como `reasoning.effort`; `thinkingBudget` não é enviado a esta API. `maxCompletionTokens` define o limite total de saída, incluindo os tokens de raciocínio, e deve ser pelo menos `16` para DashScope Responses. Os nomes dos campos de `settings.yml` continuam iguais ao mudar de protocolo. Valores válidos no nível do modelo em `providers..models.` substituem os valores no nível do provedor. Valores numéricos inválidos são ignorados, então o IaC Code volta ao valor do provedor ou à política integrada do modelo. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/llm-providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/llm-providers.md index 6e920e400..dc48ae499 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/llm-providers.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/llm-providers.md @@ -76,3 +76,27 @@ CLI 参数 > 环境变量 > 配置文件 | `IAC_CODE_MODEL` | 模型名称 | | `IAC_CODE_BASE_URL` | 当前激活 Provider 的 API 端点覆盖;优先于配置文件中的 `apiBase` 和内置默认 URL | | `IAC_CODE_API_KEY` | 提供商 API Key | + +## Responses API + +使用 OpenAI 风格协议的提供商默认走 Chat Completions。已登记的 OpenAI 模型 `gpt-6-astra`、`gpt-6-sol` 和 `gpt-6-luna` 默认走 Responses API。Anthropic 提供商继续使用原有的 Messages 协议。 + +目前 Responses 仅支持 OpenAI 官方端点,以及已核验的标准百炼端点和模型,例如 `qwen3.8-max`。Azure OpenAI、`openai_compatible`、百炼 Token Plan 和 CodingPlan 提供商不支持该切换。 + +要让某个 Qwen 模型改用 Responses,在 `settings.yml` 中添加模型级配置: + +```yaml +activeProvider: dashscope +providers: + dashscope: + model: qwen3.8-max + models: + qwen3.8-max: + apiMode: responses +``` + +把 `apiMode` 改为 `chat_completions` 即可切回;省略该项则使用模型内置的默认协议,标准百炼模型仍默认为 Chat Completions。该配置只作用于指定提供商下的指定模型。 + +若显式让 GPT-6 使用 Chat,Astra 不能调用工具;Sol 和 Luna 仅在 `effort: none` 时能调用工具。开启推理的工具调用应使用 Responses。 + +请求参数和输出限制见[运行配置](./runtime-configuration.md)。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 65d9b0a49..0887c0a8b 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -86,8 +86,11 @@ providers: |---|---|---| | `thinkingEnabled` | Provider 或模型 | 可选的布尔 thinking 开关。`true` 表示请求支持的 provider/model 开启 thinking;`false` 表示请求关闭;省略时保留 provider/model 默认行为。 | | `thinkingBudget` | Provider 或模型 | 正整数 reasoning/thinking 预算,会传给支持该参数的 provider。 | -| `maxCompletionTokens` | Provider 或模型 | 正整数 `max_completion_tokens` 覆盖值,用于采用该请求字段的 provider/model。 | +| `maxCompletionTokens` | 提供商或模型 | 正整数输出上限覆盖值:适用的 Chat 提供商使用 `max_completion_tokens`,Responses 使用 `max_output_tokens`。 | | `effort` | Provider 或模型 | 可选 thinking effort 覆盖值,仅对支持 effort 控制的模型生效。 | +| `apiMode` | 仅模型级 | `chat_completions` 或 `responses`;省略时使用模型内置的默认协议。支持范围见 [LLM 提供商](./llm-providers.md)。 | + +使用 Responses 时,`apiMode` 只能配置在 `providers..models.` 下。`effort` 映射为 `reasoning.effort`,`thinkingBudget` 不会发送给此 API。`maxCompletionTokens` 设置包含推理 token 在内的总输出上限,DashScope Responses 要求至少为 `16`。切换协议后,`settings.yml` 中的字段名称保持一致。 `providers..models.` 下的模型级有效值会覆盖 provider 级值。无效数值会被忽略,IaC Code 会回退到 provider 级值或内置模型策略。 From e6734c232c30c6b2209dbeccaea22325f3e8dcfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Sat, 3 Oct 2026 20:34:19 +0800 Subject: [PATCH 3/3] fix: allow per-model Responses API on OpenAI-style providers --- scripts/provider-update-guide.zh-CN.md | 24 ++-- .../i18n/locales/de/LC_MESSAGES/messages.po | 8 ++ .../i18n/locales/es/LC_MESSAGES/messages.po | 8 ++ .../i18n/locales/fr/LC_MESSAGES/messages.po | 8 ++ .../i18n/locales/ja/LC_MESSAGES/messages.po | 8 ++ .../i18n/locales/pt/LC_MESSAGES/messages.po | 8 ++ .../i18n/locales/zh/LC_MESSAGES/messages.po | 8 ++ src/iac_code/providers/manager.py | 40 ++++-- src/iac_code/providers/responses_provider.py | 94 ++------------ .../test_dashscope_responses_provider.py | 42 ++++-- .../test_openai_responses_provider.py | 121 +++++++++++++++++- tests/providers/test_responses_i18n.py | 4 +- tests/providers/test_responses_routing.py | 65 +++++++++- website/docs/configuration/llm-providers.md | 14 +- .../current/configuration/llm-providers.md | 14 +- .../current/configuration/llm-providers.md | 14 +- .../current/configuration/llm-providers.md | 14 +- .../current/configuration/llm-providers.md | 14 +- .../current/configuration/llm-providers.md | 14 +- .../current/configuration/llm-providers.md | 14 +- 20 files changed, 375 insertions(+), 161 deletions(-) diff --git a/scripts/provider-update-guide.zh-CN.md b/scripts/provider-update-guide.zh-CN.md index c4643dd3e..b0ee92ef8 100644 --- a/scripts/provider-update-guide.zh-CN.md +++ b/scripts/provider-update-guide.zh-CN.md @@ -233,21 +233,29 @@ SDK schema、另一条 URL 或另一模型推断。UI 只展示稳定的标准 一次模型更新要分别核验默认协议、显式切换协议后的可用性,以及思考模式与工具调用的组合: - `registry.py` 的 `ModelEntry.api_mode` 是未配置时的默认协议,默认值为 `chat_completions`。 - 当前 OpenAI 的 `gpt-6-astra/sol/luna` 声明为 `responses`;其他模型保持原默认协议。 + 根据已核验的模型能力声明 `responses`;网站说明默认选择规则,不重复列出模型名单。 - 用户可在 `settings.yml` 的 `providers..models..apiMode` 设置 `chat_completions` 或 `responses`。默认协议不等于另一个协议也支持该模型的全部能力。 - `ModelEntry.chat_completions_tool_efforts` 声明 Chat 工具调用允许的实际 `reasoning_effort`: `None` 表示不添加此项限制,空元组 `()` 表示不允许 Chat 工具调用,`("none",)` 表示仅在关闭 - reasoning 时允许。当前 Astra 使用 `()`,Sol/Luna 使用 `("none",)`;该声明按 provider 和精确 + reasoning 时允许。该声明按 provider 和精确 model ID 生效,不应自动复制到 Azure、兼容端点或其他 provider。 - `openai_provider.py` 读取上述声明,并用归一化后的 effort 校验工具请求;无工具的 Chat 请求不受 此限制。新增或替换模型时更新声明,不要在 adapter 中再添加模型 ID 集合、代际前缀或模型名分支。 `thinking.py` 中的允许值和默认 effort 也必须同步,否则省略 effort 时会按错误默认值校验。 -- 当前 Responses 路径仅开放官方 OpenAI 和标准 DashScope 已核验端点。 - [`responses_provider.py`](../src/iac_code/providers/responses_provider.py) 中的 - `DASHSCOPE_RESPONSES_MODELS` 与 `validate_responses_endpoint()` 分别维护百炼支持的精确模型 ID 和 - endpoint 范围;新增 Qwen 模型要独立核验 Responses 文档,不能因为 Chat 目录存在就自动加入。 - 标准百炼模型仍默认使用 Chat,只有用户显式配置才切换;Token Plan/Coding Plan 不自动继承支持。 +- 使用 OpenAI 协议的 provider 可通过模型级配置显式选择 Responses。新增模型应核验服务端能力, + 不在 adapter 中添加 provider、端点或模型白名单。`validate_responses_endpoint()` 仅检查 HTTP(S) + URL 格式,允许自定义网关、本地端口和新增模型;配置不会替服务端补充协议能力,也不自动降级到 Chat。 + Anthropic/Messages 适配器不能借此切换。 +- Responses 路由复用原 provider 的 SDK 客户端初始化,保留厂商请求头、本地默认凭据和有效端点。 + `ResponsesProvider.responses_profile` 与 `DashScopeResponsesProvider.responses_profile` + 区分通用 OpenAI 和百炼请求规则,不以 provider key 代替 wire profile。Token Plan 和识别出的 + 百炼兼容端点使用百炼 profile;原生历史仍按 provider、模型、端点隔离。 +- 标准百炼和 Token Plan 仍默认使用 Chat,只有用户显式配置才切换。 + [Azure OpenAI](https://learn.microsoft.com/en-us/azure/foundry/openai/api-version-lifecycle) 使用 + `/openai/v1/` 基础地址和部署名称;[百炼 Token Plan](https://help.aliyun.com/zh/model-studio/codex) + 的模型支持范围应按套餐独立核验。[阿里云 Coding Plan](https://help.aliyun.com/zh/model-studio/coding-plan-faq) + 明确不支持 Responses;其他编程套餐需分别核验端点、模型和请求格式,不能直接继承此结论。 - Responses 的 effort、输出限制、工具 schema 和输入窗口规则由 `responses_provider.py` 组装。 更新时核对 `_responses_effort()`、`max_output_tokens` 和 DashScope 输入窗口比例,不能直接复制 Chat 的 `enable_thinking`、`thinking_budget` 或 `max_completion_tokens` 请求字段。 @@ -459,7 +467,7 @@ git diff HEAD -- iac-code-rs - 产品版本名、控制台显示名和实际 API model ID 没有混为一谈。 - 同名模型在不同 provider 下的思考协议没有被错误复用。 - 新模型的默认 `api_mode`、显式协议切换和 `chat_completions_tool_efforts` 已独立核验;adapter 中没有 - 新增模型名单判断。新增百炼 Responses 模型已同步其支持列表,未外推到套餐或未核验端点。 + 新增模型名单判断。新增百炼 Responses 模型已核验其服务端能力,未外推到套餐或未核验端点。 - effort 的 UI 标准值、wire alias、默认值和关闭语义已经分别验证。 - 新模型已加入遥测白名单和必要的降级链。 - 终端与 Web 的 model/effort 选择器均已验证,新增标签在所有 locale 中有正确翻译。 diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 18f9ee289..a97e76770 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -15141,3 +15141,11 @@ msgstr "Der Responses-Stream wurde ohne abschließende Antwort beendet." #: src/iac_code/providers/responses_provider.py msgid "DashScope Responses max_output_tokens must be at least 16." msgstr "max_output_tokens für DashScope Responses muss mindestens 16 betragen." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses API requires a valid HTTP or HTTPS base URL." +msgstr "Die Responses API benötigt eine gültige HTTP- oder HTTPS-Basis-URL." + +#: src/iac_code/providers/manager.py +msgid "Responses API requires an OpenAI-style provider." +msgstr "Die Responses API benötigt einen Anbieter mit OpenAI-Protokoll." diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 0883c2a02..3ac906db8 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -15030,3 +15030,11 @@ msgstr "El flujo de Responses terminó sin una respuesta final." #: src/iac_code/providers/responses_provider.py msgid "DashScope Responses max_output_tokens must be at least 16." msgstr "max_output_tokens de DashScope Responses debe ser al menos 16." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses API requires a valid HTTP or HTTPS base URL." +msgstr "Responses API requiere una URL base HTTP o HTTPS válida." + +#: src/iac_code/providers/manager.py +msgid "Responses API requires an OpenAI-style provider." +msgstr "Responses API requiere un proveedor que use el protocolo OpenAI." diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 8319e20e7..f10126ff7 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -15091,3 +15091,11 @@ msgstr "Le flux de Responses s’est terminé sans réponse finale." #: src/iac_code/providers/responses_provider.py msgid "DashScope Responses max_output_tokens must be at least 16." msgstr "max_output_tokens de DashScope Responses doit être au moins égal à 16." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses API requires a valid HTTP or HTTPS base URL." +msgstr "Responses API nécessite une URL de base HTTP ou HTTPS valide." + +#: src/iac_code/providers/manager.py +msgid "Responses API requires an OpenAI-style provider." +msgstr "Responses API nécessite un fournisseur utilisant le protocole OpenAI." diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index ff9f5c6f1..feef7ba65 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -13909,3 +13909,11 @@ msgstr "Responses のストリームが最終応答なしで終了しました #: src/iac_code/providers/responses_provider.py msgid "DashScope Responses max_output_tokens must be at least 16." msgstr "DashScope Responses の max_output_tokens は 16 以上にする必要があります。" + +#: src/iac_code/providers/responses_provider.py +msgid "Responses API requires a valid HTTP or HTTPS base URL." +msgstr "Responses API には有効な HTTP または HTTPS のベース URL が必要です。" + +#: src/iac_code/providers/manager.py +msgid "Responses API requires an OpenAI-style provider." +msgstr "Responses API には OpenAI 形式のプロトコルを使用するプロバイダーが必要です。" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 72dc95822..c408ca85b 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -14892,3 +14892,11 @@ msgstr "O fluxo de Responses terminou sem uma resposta final." #: src/iac_code/providers/responses_provider.py msgid "DashScope Responses max_output_tokens must be at least 16." msgstr "max_output_tokens do DashScope Responses deve ser pelo menos 16." + +#: src/iac_code/providers/responses_provider.py +msgid "Responses API requires a valid HTTP or HTTPS base URL." +msgstr "A Responses API exige uma URL base HTTP ou HTTPS válida." + +#: src/iac_code/providers/manager.py +msgid "Responses API requires an OpenAI-style provider." +msgstr "A Responses API exige um provedor que use o protocolo OpenAI." diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index e8480bb7f..ae49b65b8 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -13653,3 +13653,11 @@ msgstr "Responses 流已结束,但没有最终响应。" #: src/iac_code/providers/responses_provider.py msgid "DashScope Responses max_output_tokens must be at least 16." msgstr "DashScope Responses 的 max_output_tokens 必须至少为 16。" + +#: src/iac_code/providers/responses_provider.py +msgid "Responses API requires a valid HTTP or HTTPS base URL." +msgstr "Responses API 要求使用有效的 HTTP 或 HTTPS 基础地址。" + +#: src/iac_code/providers/manager.py +msgid "Responses API requires an OpenAI-style provider." +msgstr "Responses API 要求提供商使用 OpenAI 风格协议。" diff --git a/src/iac_code/providers/manager.py b/src/iac_code/providers/manager.py index d22f64480..d9d98f61c 100644 --- a/src/iac_code/providers/manager.py +++ b/src/iac_code/providers/manager.py @@ -571,15 +571,25 @@ def create_provider( if wire_desc is not None: provider_class_path = wire_desc.provider_class provider_cls = _import_provider_class(provider_class_path) + from iac_code.providers.openai_provider import OpenAIProvider + + responses_cls = None + uses_dashscope_responses = False if api_mode == "responses": + from iac_code.providers.responses_codec import ResponsesConfigurationError from iac_code.providers.responses_provider import ( DashScopeResponsesProvider, ResponsesProvider, validate_responses_endpoint, ) - validate_responses_endpoint(provider_key, effective_base_url, model) - provider_cls = DashScopeResponsesProvider if provider_key == "dashscope" else ResponsesProvider + if not issubclass(provider_cls, OpenAIProvider): + raise ResponsesConfigurationError("Responses API requires an OpenAI-style provider.") + validate_responses_endpoint(effective_base_url) + uses_dashscope_responses = ( + wire_provider_key in DASHSCOPE_WIRE_PROVIDER_KEYS or is_bailian_compatible_endpoint(effective_base_url) + ) + responses_cls = DashScopeResponsesProvider if uses_dashscope_responses else ResponsesProvider elif _should_use_qwen_provider(provider_cls, model): from iac_code.providers.qwen_provider import QwenProvider @@ -587,8 +597,6 @@ def create_provider( request_policy_kwargs: dict[str, Any] = {} if thinking_enabled is not None: request_policy_kwargs["thinking_enabled"] = thinking_enabled - from iac_code.providers.openai_provider import OpenAIProvider - if issubclass(provider_cls, OpenAIProvider): if thinking_budget is not None: request_policy_kwargs["thinking_budget"] = thinking_budget @@ -596,7 +604,7 @@ def create_provider( request_policy_kwargs["max_completion_tokens"] = max_completion_tokens from iac_code.providers.qwen_provider import QwenProvider - if issubclass(provider_cls, QwenProvider) or (api_mode == "responses" and provider_key == "dashscope"): + if issubclass(provider_cls, QwenProvider) or uses_dashscope_responses: request_policy_kwargs["thinking_intent"] = thinking_intent else: from iac_code.providers.anthropic_provider import AnthropicProvider @@ -605,14 +613,19 @@ def create_provider( request_policy_kwargs["thinking_budget"] = thinking_budget if max_completion_tokens is not None and issubclass(provider_cls, AnthropicProvider): request_policy_kwargs["max_completion_tokens"] = max_completion_tokens - provider = provider_cls( - model=model, - api_key=api_key or None, - base_url=effective_base_url, - effort=effort, - provider_key=wire_provider_key, + provider_kwargs: dict[str, Any] = { + "model": model, + "api_key": api_key or None, + "base_url": effective_base_url, + "effort": effort, + "provider_key": wire_provider_key, **request_policy_kwargs, - ) + } + provider = provider_cls(**provider_kwargs) + if responses_cls is not None: + # Preserve provider-specific client setup, including local keys and headers. + provider = responses_cls(client=provider._client, **provider_kwargs) + setattr(provider, "_responses_transport_name", provider_class_path.rsplit(".", 1)[-1]) setattr(provider, "_logical_provider_key", provider_key) endpoint_url = _provider_endpoint_url(provider) or effective_base_url setattr(provider, "_session_endpoint_origin", sanitize_endpoint_origin(endpoint_url)) @@ -845,6 +858,9 @@ def _telemetry_provider_name(provider: Any) -> str: return "dashscope" if _is_bailian_compatible_endpoint(_provider_endpoint_url(provider)): return "dashscope" + transport_name = _string_provider_attr(provider, "_responses_transport_name") + if transport_name is not None: + return transport_name.replace("Provider", "").lower() if wire_provider_key == "openai" and getattr(provider, "api_mode", None) == "responses": return "openai" return type(provider).__name__.replace("Provider", "").lower() diff --git a/src/iac_code/providers/responses_provider.py b/src/iac_code/providers/responses_provider.py index ad37c1106..8cd80cd14 100644 --- a/src/iac_code/providers/responses_provider.py +++ b/src/iac_code/providers/responses_provider.py @@ -1,4 +1,4 @@ -"""Opt-in OpenAI and DashScope Responses providers.""" +"""Opt-in Responses providers for OpenAI-style transports.""" from __future__ import annotations @@ -24,84 +24,16 @@ from iac_code.providers.thinking_intent import ResolvedThinkingIntent from iac_code.types.stream_events import MessageEndEvent, MessageStartEvent, StreamEvent -# Models documented in Bailian's Responses API reference, 2026-10-03. -DASHSCOPE_RESPONSES_MODELS = frozenset( - { - "qwen3.8-max", - "qwen3.8-max-0902", - "qwen3.8-flash", - "qwen3.8-2.4t-a95b", - "qwen3.8-27b", - "qwen3.8-omni-flash", - "qwen3.7-max", - "qwen3.7-max-2026-05-20", - "qwen3.7-max-2026-06-08", - "qwen3.7-max-2026-05-17", - "qwen3.7-max-preview", - "qwen3-max", - "qwen3-max-2026-01-23", - "qwen3.7-plus", - "qwen3.7-plus-2026-05-26", - "qwen3.6-plus", - "qwen3.6-plus-2026-04-02", - "qwen3.5-plus", - "qwen3.5-plus-2026-04-20", - "qwen3.5-plus-2026-02-15", - "qwen3.7-flash", - "qwen3.7-flash-2026-07-15", - "qwen3.6-flash", - "qwen3.6-flash-2026-04-16", - "qwen3.5-flash", - "qwen3.5-flash-2026-02-23", - "qwen3.6-35b-a3b", - "qwen3.5-397b-a17b", - "qwen3.5-122b-a10b", - "qwen3.5-27b", - "qwen3.5-35b-a3b", - } -) -_DASHSCOPE_REGIONS = { - "cn-beijing", - "ap-southeast-1", - "us-east-1", - "eu-central-1", - "ap-northeast-1", - "cn-hongkong", -} - -def validate_responses_endpoint(provider_key: str, base_url: str | None, model: str) -> None: - if provider_key not in {"openai", "dashscope"}: - raise ResponsesConfigurationError( - "Responses API is supported only for OpenAI and standard DashScope in this release." - ) - if provider_key == "dashscope" and model not in DASHSCOPE_RESPONSES_MODELS: - raise ResponsesConfigurationError("This DashScope model is not registered for Responses API.") +def validate_responses_endpoint(base_url: str | None) -> None: + """Validate URL syntax without restricting providers, hosts, ports, or models.""" try: endpoint = urlsplit(base_url or "https://api.openai.com/v1") - port = endpoint.port + endpoint.port except ValueError as error: - raise ResponsesConfigurationError("Responses API requires a supported HTTPS base URL.") from error - if endpoint.scheme != "https" or endpoint.username or endpoint.password or endpoint.query or endpoint.fragment: - raise ResponsesConfigurationError("Responses API requires a supported HTTPS base URL.") - if port not in {None, 443}: - raise ResponsesConfigurationError("Responses API requires a supported HTTPS base URL.") - host = endpoint.hostname or "" - if provider_key == "openai": - supported = host == "api.openai.com" and endpoint.path.rstrip("/") == "/v1" - else: - workspace = host.split(".") - supported = ( - host in {"dashscope.aliyuncs.com", "dashscope-intl.aliyuncs.com", "dashscope-us.aliyuncs.com"} - or ( - len(workspace) == 5 - and workspace[0] not in {"token-plan", "coding", "coding-intl"} - and workspace[1] in _DASHSCOPE_REGIONS - and workspace[2:] == ["maas", "aliyuncs", "com"] - ) - ) and endpoint.path.rstrip("/") == "/compatible-mode/v1" - if not supported: - raise ResponsesConfigurationError("This base URL is not registered for Responses API.") + raise ResponsesConfigurationError("Responses API requires a valid HTTP or HTTPS base URL.") from error + if endpoint.scheme not in {"http", "https"} or not endpoint.hostname: + raise ResponsesConfigurationError("Responses API requires a valid HTTP or HTTPS base URL.") class ResponsesProvider(OpenAIProvider): @@ -109,15 +41,14 @@ class ResponsesProvider(OpenAIProvider): api_mode = "responses" input_window_ratio = 1.0 + responses_profile = "openai" def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) actual_base_url = getattr(self._client, "base_url", None) - validate_responses_endpoint( - self._PROVIDER_KEY, str(actual_base_url) if actual_base_url else self._base_url, self._model - ) + validate_responses_endpoint(str(actual_base_url) if actual_base_url else self._base_url) self._responses_identity = { - "profile": self._PROVIDER_KEY, + "profile": self.responses_profile, "provider": self._PROVIDER_KEY, "model": self._model, "endpoint": self._metadata_endpoint_id, @@ -160,11 +91,11 @@ def _build_responses_kwargs( if system: kwargs["instructions"] = system if tools: - kwargs["tools"] = encode_tools(tools, strict=self._PROVIDER_KEY == "openai") + kwargs["tools"] = encode_tools(tools, strict=self.responses_profile == "openai") effort = self._responses_effort() if effort is not None: kwargs["reasoning"] = {"effort": effort} - if self._PROVIDER_KEY == "openai": + if self.responses_profile == "openai": kwargs["include"] = ["reasoning.encrypted_content"] headers = get_provider_request_headers() if headers: @@ -232,6 +163,7 @@ async def complete( class DashScopeResponsesProvider(ResponsesProvider): # Bailian silently truncates at about 80%; leave additional local headroom. input_window_ratio = 0.75 + responses_profile = "dashscope" def __init__(self, *args: Any, thinking_intent: ResolvedThinkingIntent | None = None, **kwargs: Any) -> None: self._thinking_intent = thinking_intent diff --git a/tests/providers/test_dashscope_responses_provider.py b/tests/providers/test_dashscope_responses_provider.py index 74a3ceda7..47f90472e 100644 --- a/tests/providers/test_dashscope_responses_provider.py +++ b/tests/providers/test_dashscope_responses_provider.py @@ -143,21 +143,47 @@ def test_qwen_responses_input_window_does_not_change_chat_window(): "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "https://workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", "https://workspace.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + "https://coding.dashscope.aliyuncs.com/v1", + "https://custom.test/v1", + "http://127.0.0.1:8765/v1", ], ) -def test_documented_endpoints_are_accepted(base): - validate_responses_endpoint("dashscope", base, "qwen3.8-max") +def test_custom_and_plan_endpoints_are_not_blocked_locally(base): + validate_responses_endpoint(base) @pytest.mark.parametrize( "base", [ - "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", - "https://coding.dashscope.aliyuncs.com/v1", - "https://custom.test/v1", - BASE + "/chat/completions", + "not-a-url", + "ftp://custom.test/v1", + "https:///v1", + "http://127.0.0.1:invalid/v1", ], ) -def test_other_endpoints_are_not_enabled(base): +def test_invalid_endpoint_syntax_is_rejected(base): with pytest.raises(ValueError): - validate_responses_endpoint("dashscope", base, "qwen3.8-max") + validate_responses_endpoint(base) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("key", ["dashscope_token_plan", "openai_compatible"]) +async def test_bailian_profile_keeps_adjacent_results_and_omits_openai_extensions(key): + client = FakeResponsesClient(response(), base_url=BASE) + p = provider(client, provider_key=key) + await p.complete( + [ + Message.assistant_tool_use(tool_use_id="call1", name="lookup", input={}), + Message.assistant_tool_use(tool_use_id="call2", name="lookup", input={}), + Message.tool_result(tool_use_id="call2", content="two"), + Message.tool_result(tool_use_id="call1", content="one"), + ], + "system", + TOOLS, + ) + kwargs = client.calls[0] + assert [item["call_id"] for item in kwargs["input"]] == ["call1", "call1", "call2", "call2"] + assert "include" not in kwargs and "strict" not in kwargs["tools"][0] + assert p._responses_identity["profile"] == "dashscope" + assert p._responses_identity["provider"] == key diff --git a/tests/providers/test_openai_responses_provider.py b/tests/providers/test_openai_responses_provider.py index a5f41bb50..2ec429f86 100644 --- a/tests/providers/test_openai_responses_provider.py +++ b/tests/providers/test_openai_responses_provider.py @@ -1,11 +1,14 @@ import asyncio import copy +import json import httpx import pytest from openai import AsyncOpenAI, BadRequestError +import iac_code.providers.openai_provider as openai_provider from iac_code.providers.base import ContentBlock, Message, ToolDefinition +from iac_code.providers.manager import create_provider from iac_code.providers.request_headers import use_provider_request_headers from iac_code.providers.responses_codec import ( ResponsesContextLimitError, @@ -78,6 +81,117 @@ def handle(request): assert result.provider_metadata["responses"]["output"][0]["encrypted_content"] == "opaque-fixture" +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize( + "key,model,base,path,profile", + [ + ("azure_openai", "my-deployment", "https://resource.openai.azure.com/openai/v1", "/openai/v1", "openai"), + ("openai_compatible", "custom-model", "http://localhost:8765/custom/v1", "/custom/v1", "openai"), + ("openai", "custom-model", "https://proxy.example.test/v1", "/v1", "openai"), + ( + "dashscope_token_plan", + "qwen3.8-max", + "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + "/compatible-mode/v1", + "dashscope", + ), + ( + "openai_compatible", + "qwen3.8-max", + "https://workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + "/compatible-mode/v1", + "dashscope", + ), + ], +) +async def test_configured_transports_use_real_sdk_for_two_tool_turns_offline( + monkeypatch, streaming, key, model, base, path, profile +): + requests = [] + first = response([reasoning(encrypted_content="opaque-fixture" if profile == "openai" else None), call()]) + if profile == "dashscope": + del first["output"][0]["encrypted_content"] + second = response() + + def handle(request): + requests.append(request) + data = first if len(requests) == 1 else second + if not streaming: + return httpx.Response(200, json=data) + events = tool_events([call()]) if len(requests) == 1 else [] + for event in events: + event["output_index"] += 1 + events.append(terminal(data)) + body = "".join("data: " + json.dumps(event) + "\n\n" for event in events) + return httpx.Response(200, headers={"content-type": "text/event-stream"}, text=body) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http_client: + monkeypatch.setattr( + openai_provider, "AsyncOpenAI", lambda **kwargs: AsyncOpenAI(http_client=http_client, **kwargs) + ) + p = create_provider( + model, + {key: "fake"}, + provider_key_override=key, + provider_config_override={"apiBase": base, "models": {model: {"apiMode": "responses"}}}, + ) + messages = [Message.user("go")] + if streaming: + events = [event async for event in p.stream(messages, "system", TOOLS)] + assert [event.input for event in events if isinstance(event, ToolUseEndEvent)] == [{"value": 1}] + result = events[-1] + else: + result = await p.complete(messages, "system", TOOLS) + assert result.tool_uses[0]["input"] == {"value": 1} + history = [ + *messages, + Message(role="assistant", metadata=result.provider_metadata), + Message.tool_result(tool_use_id="call1", content="found"), + ] + if streaming: + result = [event async for event in p.stream(history, "system", TOOLS)][-1] + else: + result = await p.complete(history, "system", TOOLS) + assert result.stop_reason == "end_turn" + await p._client.close() + assert len(requests) == 2 and all(request.url.path == path + "/responses" for request in requests) + assert all(request.headers["authorization"] == "Bearer fake" for request in requests) + kwargs = json.loads(requests[1].content) + assert kwargs["model"] == model and kwargs["store"] is False + assert kwargs["input"][1 : 1 + len(first["output"])] == first["output"] + assert kwargs["input"][-1]["call_id"] == "call1" + if profile == "openai": + assert kwargs["include"] == ["reasoning.encrypted_content"] + assert kwargs["tools"][0]["strict"] is False + else: + assert "include" not in kwargs and "strict" not in kwargs["tools"][0] + + +@pytest.mark.asyncio +async def test_unsupported_service_returns_error_without_chat_downgrade(monkeypatch): + requests = [] + + def handle(request): + requests.append(request) + return httpx.Response(400, json={"error": {"message": "Responses is not supported", "code": "unsupported_api"}}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http_client: + monkeypatch.setattr( + openai_provider, "AsyncOpenAI", lambda **kwargs: AsyncOpenAI(http_client=http_client, **kwargs) + ) + p = create_provider( + "custom-model", + {"aliyun_codingplan": "fake"}, + provider_key_override="aliyun_codingplan", + provider_config_override={"models": {"custom-model": {"apiMode": "responses"}}}, + ) + with pytest.raises(BadRequestError): + await p.complete([Message.user("go")], "system") + await p._client.close() + assert len(requests) == 1 and requests[0].url.path == "/v1/responses" + + @pytest.mark.asyncio async def test_native_history_is_replayed_once_and_scope_checked(): original = response([reasoning(), message("checking", phase="commentary"), call()]) @@ -322,6 +436,9 @@ async def test_stream_context_limit_uses_local_compaction_error(event): pass -def test_actual_sdk_endpoint_cannot_bypass_profile_validation(): +def test_actual_sdk_endpoint_is_used_for_validation_and_history_scope(): with pytest.raises(ValueError, match="base URL"): - provider(FakeResponsesClient(base_url="https://custom.invalid/v1")) + provider(FakeResponsesClient(base_url="ftp://custom.invalid/v1")) + p = provider(FakeResponsesClient(base_url="http://localhost:8765/v1")) + assert p._responses_identity["endpoint"] == p._endpoint_id("http://localhost:8765/v1") + assert p._responses_identity["endpoint"] != p._endpoint_id("https://api.openai.com/v1") diff --git a/tests/providers/test_responses_i18n.py b/tests/providers/test_responses_i18n.py index 22bcdde4e..07bdd93ab 100644 --- a/tests/providers/test_responses_i18n.py +++ b/tests/providers/test_responses_i18n.py @@ -108,5 +108,5 @@ async def test_responses_a2a_errors_use_the_callers_language(compiled_catalogs, ) def test_malformed_responses_urls_have_a_localizable_configuration_error(base_url): with pytest.raises(ResponsesConfigurationError) as raised: - validate_responses_endpoint("openai", base_url, "gpt-6-sol") - assert raised.value.i18n_message_id == "Responses API requires a supported HTTPS base URL." + validate_responses_endpoint(base_url) + assert raised.value.i18n_message_id == "Responses API requires a valid HTTP or HTTPS base URL." diff --git a/tests/providers/test_responses_routing.py b/tests/providers/test_responses_routing.py index 979d9facb..cdacb2f88 100644 --- a/tests/providers/test_responses_routing.py +++ b/tests/providers/test_responses_routing.py @@ -3,7 +3,7 @@ import pytest from iac_code.providers.anthropic_provider import AnthropicProvider -from iac_code.providers.manager import create_provider +from iac_code.providers.manager import _telemetry_provider_name, create_provider from iac_code.providers.openai_provider import OpenAIProvider from iac_code.providers.qwen_provider import QwenProvider from iac_code.providers.registry import PROVIDER_REGISTRY, ModelEntry @@ -44,16 +44,69 @@ def test_invalid_model_api_mode_rejected(mode): "key,model", [ ("anthropic", "claude-opus-5"), - ("openai_compatible", "custom"), - ("dashscope_token_plan", "qwen3.8-max"), - ("dashscope", "qwen3-coder-plus"), + ("anthropic_compatible", "custom"), + ("minimax_cn", "MiniMax-M3"), ], ) -def test_unsupported_responses_provider_or_model_fails(key, model): - with pytest.raises(ValueError): +def test_non_openai_transports_reject_responses(key, model): + with pytest.raises(ValueError, match="OpenAI-style provider"): create(key, model, {"models": {model: {"apiMode": "responses"}}}) +@pytest.mark.parametrize( + "key,base,expected,telemetry_name", + [ + ("azure_openai", "https://resource.openai.azure.com/openai/v1/", ResponsesProvider, "azureopenai"), + ("openai_compatible", "http://127.0.0.1:8765/custom/v1", ResponsesProvider, "openai"), + ("openai", "https://proxy.example.test/v1", ResponsesProvider, "openai"), + ("deepseek", None, ResponsesProvider, "deepseek"), + ("openrouter", None, ResponsesProvider, "openrouter"), + ("ollama", None, ResponsesProvider, "ollama"), + ("lmstudio", None, ResponsesProvider, "lmstudio"), + ("dashscope", None, DashScopeResponsesProvider, "dashscope"), + ("dashscope_token_plan", None, DashScopeResponsesProvider, "dashscope"), + ("aliyun_codingplan", None, DashScopeResponsesProvider, "dashscope"), + ("aliyun_codingplan_intl", None, DashScopeResponsesProvider, "dashscope"), + ( + "openai_compatible", + "https://workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + DashScopeResponsesProvider, + "dashscope", + ), + ], +) +def test_openai_transports_can_explicitly_select_responses_for_new_models(key, base, expected, telemetry_name): + model = "future-model" + cfg = {"models": {model: {"apiMode": "responses"}}} + if base is not None: + cfg["apiBase"] = base + assert not isinstance(create(key, model, {"apiBase": base} if base else {}), ResponsesProvider) + p = create(key, model, cfg) + assert type(p) is expected + assert p._logical_provider_key == key + assert p._responses_identity["profile"] == expected.responses_profile + assert p._responses_identity["provider"] == p._PROVIDER_KEY + assert _telemetry_provider_name(p) == telemetry_name + assert type(create(key, "other-model", cfg)) is not expected + + +def test_responses_reuses_openrouter_client_headers(): + p = create("openrouter", "future-model", {"models": {"future-model": {"apiMode": "responses"}}}) + assert p._client.default_headers["HTTP-Referer"] == "https://github.com/aliyun/iac-code" + assert p._client.default_headers["X-Title"] == "iac-code" + + +@pytest.mark.parametrize("key,default_key", [("ollama", "ollama"), ("lmstudio", "lm-studio")]) +def test_responses_reuses_local_provider_default_api_key(key, default_key): + p = create_provider( + "local-model", + {}, + provider_key_override=key, + provider_config_override={"models": {"local-model": {"apiMode": "responses"}}}, + ) + assert p._client.api_key == default_key + + @pytest.mark.parametrize( "model,effort,allowed", [ diff --git a/website/docs/configuration/llm-providers.md b/website/docs/configuration/llm-providers.md index 7d2c69bb9..3ae44b076 100644 --- a/website/docs/configuration/llm-providers.md +++ b/website/docs/configuration/llm-providers.md @@ -79,24 +79,26 @@ CLI arguments > environment variables > configuration files ## Responses API -OpenAI-style providers use Chat Completions by default. The registered OpenAI models `gpt-6-astra`, `gpt-6-sol`, and `gpt-6-luna` default to Responses API. Anthropic providers continue to use their existing Messages protocol. +OpenAI-style providers use Chat Completions by default. A model's built-in protocol default may select Responses when needed for its tool-calling capabilities. Model defaults and Chat tool restrictions are maintained in the built-in model catalog; they are not listed separately here. Anthropic providers continue to use their existing Messages protocol. -Responses is currently available for official OpenAI and verified standard DashScope endpoints and models, such as `qwen3.8-max`. Azure OpenAI, `openai_compatible`, DashScope Token Plan, and CodingPlan providers do not support this switch. +Providers that use the OpenAI protocol can explicitly select Responses for individual models, including Azure OpenAI, `openai_compatible`, and DashScope Token Plan. Configure `apiBase` with a base URL whose service supports Responses. For Azure OpenAI, use the `/openai/v1/` base URL and set `model` to the deployment name. -To opt a Qwen model into Responses, add this model-level setting to `settings.yml`: +[Azure OpenAI](https://learn.microsoft.com/en-us/azure/foundry/openai/api-version-lifecycle) and [DashScope Token Plan](https://help.aliyun.com/en/model-studio/codex) document Responses support for applicable endpoints and models. Custom compatible endpoints and coding plans depend on their service capabilities. [Alibaba Cloud Coding Plan](https://help.aliyun.com/en/model-studio/coding-plan-faq) explicitly does not support Responses. Selecting Responses for an unsupported service or model returns an error; IaC Code does not automatically downgrade to Chat. + +To opt a supported Qwen model into Responses, add this model-level setting to `settings.yml`, replacing `` with the actual model ID: ```yaml activeProvider: dashscope providers: dashscope: - model: qwen3.8-max + model: models: - qwen3.8-max: + : apiMode: responses ``` Set `apiMode` to `chat_completions` to switch back. Omitting it restores the model's built-in protocol default; for standard DashScope models, that default remains Chat Completions. The setting applies only to the named model under the named provider. -If you explicitly choose Chat for GPT-6, Astra cannot make tool calls; Sol and Luna can make tool calls only with `effort: none`. Use Responses for tool calls with reasoning enabled. +If you explicitly select Chat, the model must support tool calls with the selected reasoning effort. Use Responses when the model's Chat API does not support that combination. See [runtime configuration](./runtime-configuration.md) for request settings and output limits. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/llm-providers.md b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/llm-providers.md index 6d5494e73..0a011a0b9 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/llm-providers.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/llm-providers.md @@ -79,24 +79,26 @@ CLI-Argumente > Umgebungsvariablen > Konfigurationsdateien ## Responses API -Anbieter mit OpenAI-Protokoll verwenden standardmäßig Chat Completions. Die registrierten OpenAI-Modelle `gpt-6-astra`, `gpt-6-sol` und `gpt-6-luna` verwenden standardmäßig die Responses API. Anthropic-Anbieter verwenden weiterhin ihr bisheriges Messages-Protokoll. +Anbieter mit OpenAI-Protokoll verwenden standardmäßig Chat Completions. Bei Modellen, deren Werkzeugaufrufe dies erfordern, kann Responses als Standardprotokoll hinterlegt sein. Das Standardprotokoll und die Einschränkungen für Chat-Werkzeugaufrufe werden im integrierten Modellkatalog gepflegt; eine separate Modellliste wird hier nicht geführt. Anthropic-Anbieter verwenden weiterhin ihr bisheriges Messages-Protokoll. -Responses ist derzeit für den offiziellen OpenAI-Endpunkt sowie geprüfte Endpunkte und Modelle von Standard-DashScope verfügbar, etwa `qwen3.8-max`. Azure OpenAI, `openai_compatible`, DashScope Token Plan und CodingPlan-Anbieter unterstützen diesen Wechsel nicht. +Anbieter mit OpenAI-Protokoll können Responses ausdrücklich für einzelne Modelle auswählen, darunter Azure OpenAI, `openai_compatible` und DashScope Token Plan. Tragen Sie in `apiBase` eine Basis-URL ein, deren Dienst Responses unterstützt. Für Azure OpenAI verwenden Sie die Basis-URL mit `/openai/v1/` und geben unter `model` den Bereitstellungsnamen an. -Um ein Qwen-Modell mit Responses zu verwenden, ergänzen Sie diese Modelleinstellung in `settings.yml`: +[Azure OpenAI](https://learn.microsoft.com/en-us/azure/foundry/openai/api-version-lifecycle) und [DashScope Token Plan](https://help.aliyun.com/en/model-studio/codex) dokumentieren Responses-Unterstützung für entsprechende Endpunkte und Modelle. Bei benutzerdefinierten kompatiblen Endpunkten und Coding-Tarifen hängt die Unterstützung vom jeweiligen Dienst ab. [Alibaba Cloud Coding Plan](https://help.aliyun.com/en/model-studio/coding-plan-faq) unterstützt Responses ausdrücklich nicht. Wird Responses für einen nicht unterstützten Dienst oder ein solches Modell gewählt, führt die Anfrage zu einem Fehler; IaC Code wechselt nicht automatisch zu Chat. + +Um ein unterstütztes Qwen-Modell mit Responses zu verwenden, ergänzen Sie diese Modelleinstellung in `settings.yml` und ersetzen Sie `` durch die tatsächliche Modell-ID: ```yaml activeProvider: dashscope providers: dashscope: - model: qwen3.8-max + model: models: - qwen3.8-max: + : apiMode: responses ``` Setzen Sie `apiMode` auf `chat_completions`, um zurückzuwechseln. Ohne diesen Eintrag gilt das integrierte Standardprotokoll des Modells; für Modelle von Standard-DashScope bleibt dies Chat Completions. Die Einstellung gilt nur für das angegebene Modell beim angegebenen Anbieter. -Wenn Sie für GPT-6 ausdrücklich Chat wählen, kann Astra keine Werkzeuge aufrufen; Sol und Luna können dies nur mit `effort: none`. Verwenden Sie Responses für Werkzeugaufrufe mit aktiviertem Reasoning. +Wenn Sie Chat ausdrücklich wählen, muss das Modell Werkzeugaufrufe mit der gewählten Reasoning-Stufe unterstützen. Verwenden Sie Responses, wenn die Chat-API des Modells diese Kombination nicht unterstützt. Anfrageeinstellungen und Ausgabelimits finden Sie unter [Laufzeitkonfiguration](./runtime-configuration.md). diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/llm-providers.md b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/llm-providers.md index e2e6f77cf..ea24309db 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/llm-providers.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/llm-providers.md @@ -79,24 +79,26 @@ CLI arguments > environment variables > configuration files ## Responses API -Los proveedores que usan el protocolo de OpenAI utilizan Chat Completions de forma predeterminada. Los modelos OpenAI registrados `gpt-6-astra`, `gpt-6-sol` y `gpt-6-luna` utilizan Responses API de forma predeterminada. Los proveedores Anthropic conservan su protocolo Messages actual. +Los proveedores que usan el protocolo de OpenAI utilizan Chat Completions de forma predeterminada. El protocolo integrado de un modelo puede ser Responses si sus llamadas a herramientas lo requieren. Los protocolos predeterminados y las restricciones de herramientas de Chat se mantienen en el catálogo integrado de modelos; no se incluye aquí una lista separada. Los proveedores Anthropic conservan su protocolo Messages actual. -Actualmente, Responses está disponible para el endpoint oficial de OpenAI y los endpoints y modelos verificados de DashScope estándar, como `qwen3.8-max`. Azure OpenAI, `openai_compatible`, DashScope Token Plan y los proveedores CodingPlan no admiten este cambio. +Los proveedores que utilizan el protocolo OpenAI pueden seleccionar Responses de forma explícita para cada modelo, incluidos Azure OpenAI, `openai_compatible` y DashScope Token Plan. Configure `apiBase` con la URL base de un servicio que admita Responses. Para Azure OpenAI, utilice la URL base con `/openai/v1/` y establezca `model` en el nombre del despliegue. -Para usar Responses con un modelo Qwen, añada esta configuración a nivel de modelo en `settings.yml`: +[Azure OpenAI](https://learn.microsoft.com/en-us/azure/foundry/openai/api-version-lifecycle) y [DashScope Token Plan](https://help.aliyun.com/en/model-studio/codex) documentan la compatibilidad con Responses para determinados endpoints y modelos. En los endpoints compatibles personalizados y los planes de programación, la compatibilidad depende del servicio. [Alibaba Cloud Coding Plan](https://help.aliyun.com/en/model-studio/coding-plan-faq) indica expresamente que no admite Responses. Si se selecciona Responses para un servicio o modelo que no lo admite, la solicitud devuelve un error; IaC Code no cambia automáticamente a Chat. + +Para usar Responses con un modelo Qwen compatible, añada esta configuración a nivel de modelo en `settings.yml` y sustituya `` por el ID real del modelo: ```yaml activeProvider: dashscope providers: dashscope: - model: qwen3.8-max + model: models: - qwen3.8-max: + : apiMode: responses ``` Cambie `apiMode` a `chat_completions` para volver al protocolo anterior. Si lo omite, se utiliza el protocolo predeterminado del modelo; para los modelos de DashScope estándar sigue siendo Chat Completions. La configuración solo se aplica al modelo indicado dentro del proveedor indicado. -Si selecciona Chat explícitamente para GPT-6, Astra no puede llamar a herramientas; Sol y Luna solo pueden hacerlo con `effort: none`. Use Responses para llamadas a herramientas con razonamiento habilitado. +Si selecciona Chat de forma explícita, el modelo debe admitir llamadas a herramientas con el nivel de razonamiento elegido. Utilice Responses cuando la API Chat del modelo no admita esa combinación. Consulte la [configuración de ejecución](./runtime-configuration.md) para conocer los parámetros de solicitud y los límites de salida. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/llm-providers.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/llm-providers.md index e655f1240..68e5a9936 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/llm-providers.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/llm-providers.md @@ -79,24 +79,26 @@ CLI arguments > environment variables > configuration files ## Responses API -Les fournisseurs utilisant le protocole OpenAI emploient Chat Completions par défaut. Les modèles OpenAI enregistrés `gpt-6-astra`, `gpt-6-sol` et `gpt-6-luna` utilisent Responses API par défaut. Les fournisseurs Anthropic conservent leur protocole Messages actuel. +Les fournisseurs utilisant le protocole OpenAI emploient Chat Completions par défaut. Un modèle peut avoir Responses comme protocole intégré par défaut si ses capacités d’appel d’outils l’exigent. Les protocoles par défaut et les restrictions des appels d’outils avec Chat sont maintenus dans le catalogue intégré des modèles ; aucune liste séparée n’est reproduite ici. Les fournisseurs Anthropic conservent leur protocole Messages actuel. -Responses est actuellement disponible pour le point de terminaison officiel d’OpenAI ainsi que les points de terminaison et modèles vérifiés de DashScope standard, comme `qwen3.8-max`. Azure OpenAI, `openai_compatible`, DashScope Token Plan et les fournisseurs CodingPlan ne prennent pas en charge ce changement. +Les fournisseurs utilisant le protocole OpenAI peuvent sélectionner explicitement Responses pour chaque modèle, notamment Azure OpenAI, `openai_compatible` et DashScope Token Plan. Configurez `apiBase` avec l’URL de base d’un service prenant en charge Responses. Pour Azure OpenAI, utilisez l’URL de base avec `/openai/v1/` et indiquez le nom du déploiement dans `model`. -Pour utiliser Responses avec un modèle Qwen, ajoutez ce réglage au niveau du modèle dans `settings.yml` : +[Azure OpenAI](https://learn.microsoft.com/en-us/azure/foundry/openai/api-version-lifecycle) et [DashScope Token Plan](https://help.aliyun.com/en/model-studio/codex) documentent la prise en charge de Responses pour certains points de terminaison et modèles. Pour les points de terminaison compatibles personnalisés et les offres de programmation, cette prise en charge dépend du service utilisé. [Alibaba Cloud Coding Plan](https://help.aliyun.com/en/model-studio/coding-plan-faq) indique explicitement ne pas prendre en charge Responses. Si Responses est choisi pour un service ou un modèle incompatible, la requête renvoie une erreur ; IaC Code ne revient pas automatiquement à Chat. + +Pour utiliser Responses avec un modèle Qwen pris en charge, ajoutez ce réglage au niveau du modèle dans `settings.yml` et remplacez `` par l’identifiant réel du modèle : ```yaml activeProvider: dashscope providers: dashscope: - model: qwen3.8-max + model: models: - qwen3.8-max: + : apiMode: responses ``` Définissez `apiMode` sur `chat_completions` pour revenir au protocole précédent. En l’omettant, vous utilisez le protocole intégré par défaut du modèle ; pour les modèles DashScope standard, il reste Chat Completions. Le réglage s’applique uniquement au modèle indiqué pour le fournisseur indiqué. -Si vous choisissez explicitement Chat pour GPT-6, Astra ne peut pas appeler d’outils ; Sol et Luna peuvent le faire uniquement avec `effort: none`. Utilisez Responses pour les appels d’outils avec raisonnement activé. +Si vous sélectionnez explicitement Chat, le modèle doit prendre en charge les appels d’outils avec le niveau de raisonnement choisi. Utilisez Responses si l’API Chat du modèle ne prend pas en charge cette combinaison. Consultez la [configuration d’exécution](./runtime-configuration.md) pour les paramètres de requête et les limites de sortie. diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/llm-providers.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/llm-providers.md index 8d8b65c74..c3caf3173 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/llm-providers.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/llm-providers.md @@ -79,24 +79,26 @@ CLI 引数 > 環境変数 > 設定ファイル ## Responses API -OpenAI 形式のプロトコルを使うプロバイダーは、デフォルトで Chat Completions を使用します。登録済みの OpenAI モデル `gpt-6-astra`、`gpt-6-sol`、`gpt-6-luna` は、デフォルトで Responses API を使用します。Anthropic プロバイダーは従来の Messages プロトコルを引き続き使用します。 +OpenAI 形式のプロトコルを使うプロバイダーは、デフォルトで Chat Completions を使用します。ツール呼び出しの仕様に応じて、モデルのデフォルトプロトコルが Responses に設定されている場合があります。各モデルのデフォルトプロトコルと Chat のツール呼び出し制限は、組み込みのモデルカタログで一元管理し、ここでは別のモデル一覧を掲載しません。Anthropic プロバイダーは従来の Messages プロトコルを引き続き使用します。 -現在 Responses を利用できるのは、OpenAI 公式エンドポイントと、検証済みの標準 DashScope エンドポイントおよびモデル(`qwen3.8-max` など)です。Azure OpenAI、`openai_compatible`、DashScope Token Plan、CodingPlan プロバイダーは、この切り替えに対応していません。 +OpenAI プロトコルを使用するプロバイダーでは、モデルごとに Responses を明示的に選択できます。Azure OpenAI、`openai_compatible`、DashScope Token Plan も対象です。`apiBase` には Responses に対応するサービスのベース URL を設定します。Azure OpenAI では `/openai/v1/` を含むベース URL を使い、`model` にデプロイ名を指定します。 -Qwen モデルで Responses を使うには、`settings.yml` に次のモデル単位の設定を追加します。 +[Azure OpenAI](https://learn.microsoft.com/en-us/azure/foundry/openai/api-version-lifecycle) と [DashScope Token Plan](https://help.aliyun.com/en/model-studio/codex) は、対応するエンドポイントとモデルでの Responses の利用方法を公開しています。カスタムの互換エンドポイントやコーディングプランの対応状況は、サービスによって異なります。[Alibaba Cloud Coding Plan](https://help.aliyun.com/en/model-studio/coding-plan-faq) は Responses に対応していないと明記しています。未対応のサービスやモデルで Responses を選択すると、リクエストはエラーになります。IaC Code は自動で Chat に切り替えません。 + +対応する Qwen モデルで Responses を使うには、`settings.yml` に次のモデル単位の設定を追加し、`` を実際のモデル ID に置き換えます。 ```yaml activeProvider: dashscope providers: dashscope: - model: qwen3.8-max + model: models: - qwen3.8-max: + : apiMode: responses ``` 元に戻すには、`apiMode` を `chat_completions` に変更します。省略すると、モデルに組み込まれたデフォルトのプロトコルが使われます。標準 DashScope モデルのデフォルトは引き続き Chat Completions です。この設定は、指定したプロバイダーの指定したモデルにのみ適用されます。 -GPT-6 で明示的に Chat を選択した場合、Astra はツールを呼び出せません。Sol と Luna は `effort: none` の場合のみツールを呼び出せます。推論を有効にしたツール呼び出しには Responses を使用してください。 +Chat を明示的に選ぶ場合、モデルは指定した推論レベルでのツール呼び出しに対応している必要があります。モデルの Chat API がその組み合わせに対応していなければ、Responses を使用してください。 リクエスト設定と出力上限は[実行時の設定](./runtime-configuration.md)を参照してください。 diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/llm-providers.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/llm-providers.md index 93de4f610..bfa913f63 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/llm-providers.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/llm-providers.md @@ -79,24 +79,26 @@ CLI arguments > environment variables > configuration files ## Responses API -Os provedores que usam o protocolo OpenAI utilizam Chat Completions por padrão. Os modelos OpenAI registrados `gpt-6-astra`, `gpt-6-sol` e `gpt-6-luna` utilizam a Responses API por padrão. Os provedores Anthropic continuam usando o protocolo Messages atual. +Os provedores que usam o protocolo OpenAI utilizam Chat Completions por padrão. Um modelo pode ter Responses como protocolo padrão integrado quando suas chamadas de ferramentas exigem isso. Os protocolos padrão e as restrições de ferramentas em Chat são mantidos no catálogo integrado de modelos; não há uma lista separada nesta página. Os provedores Anthropic continuam usando o protocolo Messages atual. -Atualmente, Responses está disponível para o endpoint oficial da OpenAI e os endpoints e modelos verificados do DashScope padrão, como `qwen3.8-max`. Azure OpenAI, `openai_compatible`, DashScope Token Plan e provedores CodingPlan não permitem essa mudança. +Os provedores que utilizam o protocolo OpenAI podem selecionar Responses explicitamente para cada modelo, incluindo Azure OpenAI, `openai_compatible` e DashScope Token Plan. Configure `apiBase` com a URL base de um serviço que ofereça suporte a Responses. Para Azure OpenAI, use a URL base com `/openai/v1/` e defina `model` como o nome da implantação. -Para usar Responses com um modelo Qwen, adicione esta configuração no nível do modelo em `settings.yml`: +[Azure OpenAI](https://learn.microsoft.com/en-us/azure/foundry/openai/api-version-lifecycle) e [DashScope Token Plan](https://help.aliyun.com/en/model-studio/codex) documentam o suporte a Responses para determinados endpoints e modelos. Nos endpoints compatíveis personalizados e nos planos de programação, o suporte depende do serviço utilizado. [Alibaba Cloud Coding Plan](https://help.aliyun.com/en/model-studio/coding-plan-faq) informa explicitamente que não oferece suporte a Responses. Selecionar Responses para um serviço ou modelo incompatível faz a solicitação retornar um erro; o IaC Code não muda automaticamente para Chat. + +Para usar Responses com um modelo Qwen compatível, adicione esta configuração no nível do modelo em `settings.yml` e substitua `` pelo ID real do modelo: ```yaml activeProvider: dashscope providers: dashscope: - model: qwen3.8-max + model: models: - qwen3.8-max: + : apiMode: responses ``` Defina `apiMode` como `chat_completions` para voltar ao protocolo anterior. Se o campo for omitido, será usado o protocolo padrão integrado do modelo; para modelos do DashScope padrão, ele continua sendo Chat Completions. A configuração se aplica apenas ao modelo indicado no provedor indicado. -Se você escolher Chat explicitamente para GPT-6, Astra não poderá chamar ferramentas; Sol e Luna só poderão fazer isso com `effort: none`. Use Responses para chamadas de ferramentas com raciocínio ativado. +Se você selecionar Chat explicitamente, o modelo deve oferecer suporte a chamadas de ferramentas com o nível de raciocínio escolhido. Use Responses quando a API Chat do modelo não oferecer suporte a essa combinação. Consulte a [configuração de execução](./runtime-configuration.md) para ver os parâmetros de requisição e os limites de saída. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/llm-providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/llm-providers.md index dc48ae499..5eddc3360 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/llm-providers.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/llm-providers.md @@ -79,24 +79,26 @@ CLI 参数 > 环境变量 > 配置文件 ## Responses API -使用 OpenAI 风格协议的提供商默认走 Chat Completions。已登记的 OpenAI 模型 `gpt-6-astra`、`gpt-6-sol` 和 `gpt-6-luna` 默认走 Responses API。Anthropic 提供商继续使用原有的 Messages 协议。 +使用 OpenAI 风格协议的提供商默认走 Chat Completions;部分模型会根据工具调用能力内置选择 Responses 作为默认协议。各模型的默认协议和 Chat 工具调用限制由内置模型目录统一维护,此处不另列模型名单。Anthropic 提供商继续使用原有的 Messages 协议。 -目前 Responses 仅支持 OpenAI 官方端点,以及已核验的标准百炼端点和模型,例如 `qwen3.8-max`。Azure OpenAI、`openai_compatible`、百炼 Token Plan 和 CodingPlan 提供商不支持该切换。 +使用 OpenAI 协议的提供商可按模型显式选择 Responses,包括 Azure OpenAI、`openai_compatible` 和百炼 Token Plan。`apiBase` 应配置为支持 Responses 的服务基础地址。Azure OpenAI 使用 `/openai/v1/` 基础地址,`model` 填写部署名称。 -要让某个 Qwen 模型改用 Responses,在 `settings.yml` 中添加模型级配置: +[Azure OpenAI](https://learn.microsoft.com/en-us/azure/foundry/openai/api-version-lifecycle) 和[百炼 Token Plan](https://help.aliyun.com/zh/model-studio/codex) 已有支持 Responses 的端点和模型说明。自定义兼容端点及编程套餐是否支持,取决于具体服务。[阿里云 Coding Plan](https://help.aliyun.com/zh/model-studio/coding-plan-faq) 明确不支持 Responses。若为不支持的服务或模型配置 Responses,请求会报错,iac-code 不会自动降级到 Chat。 + +要让受支持的 Qwen 模型改用 Responses,在 `settings.yml` 中添加模型级配置,并将 `` 替换为实际模型 ID: ```yaml activeProvider: dashscope providers: dashscope: - model: qwen3.8-max + model: models: - qwen3.8-max: + : apiMode: responses ``` 把 `apiMode` 改为 `chat_completions` 即可切回;省略该项则使用模型内置的默认协议,标准百炼模型仍默认为 Chat Completions。该配置只作用于指定提供商下的指定模型。 -若显式让 GPT-6 使用 Chat,Astra 不能调用工具;Sol 和 Luna 仅在 `effort: none` 时能调用工具。开启推理的工具调用应使用 Responses。 +显式选择 Chat 时,模型须支持当前推理强度下的工具调用。若模型的 Chat API 不支持该组合,应使用 Responses。 请求参数和输出限制见[运行配置](./runtime-configuration.md)。