From 305b362b977d235daa131e71eb784d15de963217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Mon, 14 Sep 2026 10:34:22 +0800 Subject: [PATCH 1/3] feat(a2a): support session-scoped LLM headers --- src/iac_code/a2a/executor.py | 10 +- src/iac_code/a2a/runtime_overrides.py | 54 +++++++++++ src/iac_code/a2a/task_store.py | 24 ++++- src/iac_code/providers/anthropic_provider.py | 7 +- src/iac_code/providers/openai_provider.py | 6 +- src/iac_code/providers/request_headers.py | 47 ++++++++++ tests/a2a/test_executor.py | 94 +++++++++++++++++++ tests/a2a/test_runtime_overrides.py | 51 ++++++++++ tests/a2a/test_task_store.py | 15 +++ tests/providers/test_request_headers.py | 64 +++++++++++++ website/docs/a2a/protocol-reference.md | 6 +- .../current/a2a/protocol-reference.md | 6 +- 12 files changed, 377 insertions(+), 7 deletions(-) create mode 100644 src/iac_code/providers/request_headers.py create mode 100644 tests/a2a/test_runtime_overrides.py create mode 100644 tests/providers/test_request_headers.py diff --git a/src/iac_code/a2a/executor.py b/src/iac_code/a2a/executor.py index 50adde72..49f30c25 100644 --- a/src/iac_code/a2a/executor.py +++ b/src/iac_code/a2a/executor.py @@ -81,6 +81,7 @@ configure_runtime_model, credentials_with_metadata_api_key, refresh_runtime_cloud_tools, + resolve_a2a_llm_headers, resolve_a2a_preferred_language, ) from iac_code.a2a.task_store import A2ATaskStore, _close_runtime @@ -1287,6 +1288,10 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non context_id, self._resolve_telemetry_channel(metadata), ) + llm_headers = await self._task_store.resolve_context_llm_headers( + context_id, + resolve_a2a_llm_headers(metadata), + ) try: if permission_response is not None and self._execution_control_service is not None: existing = self._execution_control_service.get_for_context(context_id) @@ -1295,7 +1300,10 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non if existing is not None and existing.owner == owner and current_task is not None: await existing.attach_task(current_task, mark_working=False) bind_execution_control(existing) - with a2a_request_context(telemetry_channel=telemetry_channel): + with a2a_request_context( + telemetry_channel=telemetry_channel, + llm_headers=llm_headers, + ): await self._execute(context, event_queue, context_id=context_id) finally: control = current_execution_control() diff --git a/src/iac_code/a2a/runtime_overrides.py b/src/iac_code/a2a/runtime_overrides.py index 3fbe3849..f0a0c128 100644 --- a/src/iac_code/a2a/runtime_overrides.py +++ b/src/iac_code/a2a/runtime_overrides.py @@ -2,18 +2,24 @@ import contextlib import contextvars +import re from collections.abc import Iterator, Mapping from typing import Any from google.protobuf.json_format import MessageToDict from iac_code.i18n import SUPPORTED_LANGUAGES, use_request_language +from iac_code.providers.request_headers import use_provider_request_headers from iac_code.providers.request_policy import ProviderRequestPolicy from iac_code.services.permission_wait import permission_execution_identity_cache_scope from iac_code.services.providers.aliyun import AliyunCredential, use_aliyun_credential from iac_code.services.telemetry import use_session_id, use_telemetry_channel, use_user_id _RUNTIME_OVERRIDE_UNSET = object() +_HTTP_HEADER_NAME = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") +_MAX_LLM_HEADERS = 64 +_MAX_LLM_HEADER_NAME_BYTES = 256 +_MAX_LLM_HEADER_VALUE_BYTES = 8192 _preferred_language: contextvars.ContextVar[str | None] = contextvars.ContextVar( "iac_code_a2a_preferred_language", default=None, @@ -43,6 +49,51 @@ def resolve_a2a_preferred_language(value: Any | None) -> str | None: return language if language in SUPPORTED_LANGUAGES else None +def resolve_a2a_llm_headers(value: Any | None) -> dict[str, str] | None: + """Resolve an explicit ``metadata.iac_code.llm_headers`` session update.""" + + metadata = getattr(value, "metadata", value) + if metadata is not None and hasattr(metadata, "DESCRIPTOR"): + metadata = MessageToDict(metadata, preserving_proto_field_name=False) + if not isinstance(metadata, Mapping): + return None + raw_iac_meta = metadata.get("iac_code") + if not isinstance(raw_iac_meta, Mapping): + return None + if "llm_headers" not in raw_iac_meta: + return None + raw_headers = raw_iac_meta.get("llm_headers") + if not isinstance(raw_headers, Mapping): + return None + + headers: dict[str, str] = {} + names_by_lowercase: dict[str, str] = {} + for raw_name, raw_value in raw_headers.items(): + if len(headers) >= _MAX_LLM_HEADERS: + break + if not isinstance(raw_name, str) or not isinstance(raw_value, str): + continue + name = raw_name.strip() + if ( + not name + or _HTTP_HEADER_NAME.fullmatch(name) is None + or len(name.encode("ascii")) > _MAX_LLM_HEADER_NAME_BYTES + or "\r" in raw_value + or "\n" in raw_value + or not raw_value.isascii() + or any((ord(character) < 32 and character != "\t") or ord(character) == 127 for character in raw_value) + or len(raw_value) > _MAX_LLM_HEADER_VALUE_BYTES + ): + continue + normalized_name = name.lower() + previous_name = names_by_lowercase.get(normalized_name) + if previous_name is not None: + headers.pop(previous_name, None) + headers[name] = raw_value + names_by_lowercase[normalized_name] = name + return headers + + @contextlib.contextmanager def a2a_request_context( *, @@ -51,9 +102,12 @@ def a2a_request_context( aliyun_credential: AliyunCredential | None = None, preferred_language: str | None = None, telemetry_channel: str | None = None, + llm_headers: Mapping[str, str] | None = None, ) -> Iterator[None]: with contextlib.ExitStack() as stack: stack.enter_context(permission_execution_identity_cache_scope()) + if llm_headers is not None: + stack.enter_context(use_provider_request_headers(llm_headers)) if telemetry_channel: stack.enter_context(use_telemetry_channel(telemetry_channel)) if preferred_language: diff --git a/src/iac_code/a2a/task_store.py b/src/iac_code/a2a/task_store.py index 39d8a312..c4566888 100644 --- a/src/iac_code/a2a/task_store.py +++ b/src/iac_code/a2a/task_store.py @@ -6,7 +6,7 @@ import logging import time import uuid -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from dataclasses import asdict from datetime import datetime, timezone from pathlib import Path @@ -65,6 +65,9 @@ def __init__( self._task_persistence_dirty: set[str] = set() self._contexts: dict[str, A2AContextRecord] = {} self._pending_context_telemetry_channels: dict[str, str] = {} + # Request headers may contain credentials, so context bindings stay in memory + # instead of being mirrored into plaintext A2A/session snapshots. + self._context_llm_headers: dict[str, dict[str, str]] = {} self._expired_task_tombstones: dict[str, float] = {} self._metrics = metrics or NoOpA2AMetrics() self._persistence = persistence @@ -573,6 +576,24 @@ async def resolve_context_telemetry_channel( self._persist_context_snapshot(snapshot) return effective_channel + async def resolve_context_llm_headers( + self, + context_id: str, + requested_headers: Mapping[str, str] | None, + ) -> dict[str, str]: + """Resolve and, when explicitly supplied, bind LLM headers to an A2A context.""" + + context_id = validate_protocol_id(context_id) + async with self._mutation_lock: + if requested_headers is not None: + headers = dict(requested_headers) + if headers: + self._context_llm_headers[context_id] = headers + else: + self._context_llm_headers.pop(context_id, None) + return dict(headers) + return dict(self._context_llm_headers.get(context_id, {})) + async def get_context_record(self, context_id: str) -> A2AContextRecord: context_id = validate_protocol_id(context_id) async with self._mutation_lock: @@ -1260,6 +1281,7 @@ async def stop_cleanup_loop(self) -> None: ] self._contexts.clear() self._pending_context_telemetry_channels.clear() + self._context_llm_headers.clear() self._context_runtime_tasks.clear() self._context_runtime_waiters.clear() for task, waiters in runtime_tasks: diff --git a/src/iac_code/providers/anthropic_provider.py b/src/iac_code/providers/anthropic_provider.py index f5a4af01..9fece21f 100644 --- a/src/iac_code/providers/anthropic_provider.py +++ b/src/iac_code/providers/anthropic_provider.py @@ -15,6 +15,7 @@ Provider, ToolDefinition, ) +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.thinking import ( @@ -375,8 +376,10 @@ def _build_kwargs( if tools: kwargs["tools"] = self._convert_tools(tools) kwargs.update(thinking_kwargs) - if extra_betas: - kwargs["extra_headers"] = {"anthropic-beta": ",".join(extra_betas)} + provider_headers = {"anthropic-beta": ",".join(extra_betas)} if extra_betas else {} + headers = merge_provider_request_headers(provider_headers, get_provider_request_headers()) + if headers: + kwargs["extra_headers"] = headers return kwargs def _convert_messages(self, messages: list[Message]) -> list[dict[str, Any]]: diff --git a/src/iac_code/providers/openai_provider.py b/src/iac_code/providers/openai_provider.py index 36c0cd02..4bba75e0 100644 --- a/src/iac_code/providers/openai_provider.py +++ b/src/iac_code/providers/openai_provider.py @@ -20,6 +20,7 @@ Provider, ToolDefinition, ) +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.streaming import OpenAIStreamResponseAdapter @@ -368,7 +369,10 @@ def _build_chat_completion_kwargs( streaming=context.streaming, cache_policy=context.cache_policy, ) - headers = self._request_headers(cache_policy=context.cache_policy) + headers = merge_provider_request_headers( + self._request_headers(cache_policy=context.cache_policy), + get_provider_request_headers(), + ) if headers: kwargs["extra_headers"] = headers kwargs.update(self._thinking_kwargs_for_context(context)) diff --git a/src/iac_code/providers/request_headers.py b/src/iac_code/providers/request_headers.py new file mode 100644 index 00000000..d8c21702 --- /dev/null +++ b/src/iac_code/providers/request_headers.py @@ -0,0 +1,47 @@ +"""Request-local HTTP headers for LLM provider calls.""" + +from __future__ import annotations + +import contextlib +import contextvars +from collections.abc import Iterator, Mapping + +_request_headers: contextvars.ContextVar[tuple[tuple[str, str], ...]] = contextvars.ContextVar( + "iac_code_provider_request_headers", + default=(), +) + + +def get_provider_request_headers() -> dict[str, str]: + """Return a copy of the HTTP headers scoped to the current provider request.""" + return dict(_request_headers.get()) + + +@contextlib.contextmanager +def use_provider_request_headers(headers: Mapping[str, str]) -> Iterator[None]: + """Apply HTTP headers to provider calls made in the current async context.""" + token = _request_headers.set(tuple(headers.items())) + try: + yield + finally: + _request_headers.reset(token) + + +def merge_provider_request_headers( + base: Mapping[str, str] | None, + overrides: Mapping[str, str] | None, +) -> dict[str, str]: + """Merge headers case-insensitively, with ``overrides`` taking precedence.""" + merged: dict[str, str] = {} + names_by_lowercase: dict[str, str] = {} + for source in (base, overrides): + if not source: + continue + for name, value in source.items(): + normalized_name = name.lower() + previous_name = names_by_lowercase.get(normalized_name) + if previous_name is not None: + merged.pop(previous_name, None) + merged[name] = value + names_by_lowercase[normalized_name] = name + return merged diff --git a/tests/a2a/test_executor.py b/tests/a2a/test_executor.py index feb8eb8a..4f5eddef 100644 --- a/tests/a2a/test_executor.py +++ b/tests/a2a/test_executor.py @@ -2556,6 +2556,7 @@ def fail_if_called(options): async def test_executor_delegates_pipeline_mode_after_validation( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + from iac_code.providers.request_headers import get_provider_request_headers from iac_code.services.telemetry.attributes import AttributeBuilder from iac_code.services.telemetry.identity import Identity @@ -2564,6 +2565,7 @@ async def test_executor_delegates_pipeline_mode_after_validation( monkeypatch.setenv("IAC_CODE_CHANNEL", "environment") calls = [] captured_channels: list[str] = [] + captured_headers: list[dict[str, str]] = [] attributes = AttributeBuilder(Identity(tmp_path / "settings.yml"), "iac-code", "0.1.0") class SpyPipelineExecutor: @@ -2583,6 +2585,7 @@ async def execute( active_followup_only=False, ): captured_channels.append(attributes.build_signal_attributes()["iac_code.channel"]) + captured_headers.append(get_provider_request_headers()) calls.append( ( "execute", @@ -2610,6 +2613,7 @@ async def execute( "user_id": "client-user", "iac_code_model": "metadata-model", "iac_code_api_key": "metadata-api-key", + "llm_headers": {"X-A2A-Session": "session-1"}, "thinking": {"enabled": True, "effort": "high", "budget": 2048}, "alibaba_cloud_access_key_id": "client-id", "alibaba_cloud_access_key_secret": "client-secret", @@ -2634,6 +2638,7 @@ async def execute( assert init_kwargs["aliyun_credential"].region_id == "cn-beijing" assert init_kwargs["aliyun_credential"].sts_token == "client-sts" assert captured_channels == ["a2a-pipeline"] + assert captured_headers == [{"X-A2A-Session": "session-1"}] assert calls[-1] == ( "execute", { @@ -4356,6 +4361,95 @@ async def capturing_run_streaming(self, prompt): assert captured_user_ids == ["client-user-xyz"] +@pytest.mark.asyncio +async def test_executor_binds_llm_headers_to_a2a_context(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + from iac_code.providers.request_headers import get_provider_request_headers + + captured_headers: list[dict[str, str]] = [] + original_run_streaming = FakeAgentLoop.run_streaming + + async def capturing_run_streaming(self, prompt): + captured_headers.append(get_provider_request_headers()) + async for event in original_run_streaming(self, prompt): + yield event + + monkeypatch.setattr(FakeAgentLoop, "run_streaming", capturing_run_streaming) + runtime = FakeRuntime(agent_loop=FakeAgentLoop([TextDeltaEvent(text="ok")]), session_id="sess-headers") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + + await executor.execute( + FakeRequestContext( + task_id="task-headers-1", + context_id="ctx-headers", + metadata={ + "iac_code": { + "cwd": str(tmp_path), + "llm_headers": {"X-A2A-Session": "session-1"}, + } + }, + ), + FakeEventQueue(), + ) + await executor.execute( + FakeRequestContext( + task_id="task-headers-2", + context_id="ctx-headers", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + FakeEventQueue(), + ) + await executor.execute( + FakeRequestContext( + task_id="task-headers-other-context", + context_id="ctx-headers-other", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + FakeEventQueue(), + ) + await executor.execute( + FakeRequestContext( + task_id="task-headers-3", + context_id="ctx-headers", + metadata={ + "iac_code": { + "cwd": str(tmp_path), + "llm_headers": {"X-A2A-Session": "session-2"}, + } + }, + ), + FakeEventQueue(), + ) + await executor.execute( + FakeRequestContext( + task_id="task-headers-4", + context_id="ctx-headers", + metadata={"iac_code": {"cwd": str(tmp_path), "llm_headers": {}}}, + ), + FakeEventQueue(), + ) + await executor.execute( + FakeRequestContext( + task_id="task-headers-5", + context_id="ctx-headers", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + FakeEventQueue(), + ) + + assert captured_headers == [ + {"X-A2A-Session": "session-1"}, + {"X-A2A-Session": "session-1"}, + {}, + {"X-A2A-Session": "session-2"}, + {}, + {}, + ] + assert get_provider_request_headers() == {} + + @pytest.mark.asyncio async def test_executor_applies_metadata_channel_over_environment( monkeypatch: pytest.MonkeyPatch, tmp_path: Path diff --git a/tests/a2a/test_runtime_overrides.py b/tests/a2a/test_runtime_overrides.py new file mode 100644 index 00000000..edfcdfef --- /dev/null +++ b/tests/a2a/test_runtime_overrides.py @@ -0,0 +1,51 @@ +from google.protobuf.struct_pb2 import Struct + +from iac_code.a2a.runtime_overrides import a2a_request_context, resolve_a2a_llm_headers +from iac_code.providers.request_headers import get_provider_request_headers + + +def test_resolve_a2a_llm_headers_accepts_mapping_and_protobuf_metadata() -> None: + expected = {"X-Request-Source": "a2a", "Authorization": "Bearer request-token"} + metadata = {"iac_code": {"llm_headers": expected}} + + assert resolve_a2a_llm_headers(metadata) == expected + + protobuf_metadata = Struct() + protobuf_metadata.update(metadata) + assert resolve_a2a_llm_headers(protobuf_metadata) == expected + + +def test_resolve_a2a_llm_headers_filters_invalid_entries_case_insensitively() -> None: + metadata = { + "iac_code": { + "llm_headers": { + " X-Trace ": "first", + "x-trace": "last", + "bad header": "ignored", + "X-Newline": "ignored\r\nInjected: yes", + "X-Control": "ignored\x00", + "X-Unicode": "忽略", + "X-Number": 123, + } + } + } + + assert resolve_a2a_llm_headers(metadata) == {"x-trace": "last"} + + +def test_resolve_a2a_llm_headers_distinguishes_omission_from_explicit_clear() -> None: + assert resolve_a2a_llm_headers(None) is None + assert resolve_a2a_llm_headers({"iac_code": {}}) is None + assert resolve_a2a_llm_headers({"iac_code": {"llm_headers": None}}) is None + assert resolve_a2a_llm_headers({"iac_code": {"llm_headers": {}}}) == {} + + +def test_a2a_request_context_scopes_and_restores_llm_headers() -> None: + assert get_provider_request_headers() == {} + + with a2a_request_context(llm_headers={"X-Session": "one"}): + assert get_provider_request_headers() == {"X-Session": "one"} + with a2a_request_context(preferred_language="en"): + assert get_provider_request_headers() == {"X-Session": "one"} + + assert get_provider_request_headers() == {} diff --git a/tests/a2a/test_task_store.py b/tests/a2a/test_task_store.py index b635ac2e..da7d12c7 100644 --- a/tests/a2a/test_task_store.py +++ b/tests/a2a/test_task_store.py @@ -182,6 +182,21 @@ async def test_context_telemetry_channel_binding_persists_and_can_be_updated(mon assert restored.telemetry_channel == "web-a2a" +@pytest.mark.asyncio +async def test_context_llm_header_binding_can_be_reused_updated_and_cleared() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + + assert await store.resolve_context_llm_headers("ctx-1", {"X-Session": "one"}) == {"X-Session": "one"} + assert await store.resolve_context_llm_headers("ctx-1", None) == {"X-Session": "one"} + assert await store.resolve_context_llm_headers("ctx-2", None) == {} + + assert await store.resolve_context_llm_headers("ctx-1", {"X-Session": "two"}) == {"X-Session": "two"} + assert await store.resolve_context_llm_headers("ctx-1", None) == {"X-Session": "two"} + + assert await store.resolve_context_llm_headers("ctx-1", {}) == {} + assert await store.resolve_context_llm_headers("ctx-1", None) == {} + + @pytest.mark.asyncio async def test_new_a2a_session_initializes_backup_generation_zero(monkeypatch, tmp_path) -> None: config_dir = tmp_path / "config" diff --git a/tests/providers/test_request_headers.py b/tests/providers/test_request_headers.py new file mode 100644 index 00000000..b33e7a60 --- /dev/null +++ b/tests/providers/test_request_headers.py @@ -0,0 +1,64 @@ +from iac_code.providers.anthropic_provider import AnthropicProvider +from iac_code.providers.base import Message +from iac_code.providers.openai_provider import OpenAIProvider +from iac_code.providers.qwen_provider import QwenProvider +from iac_code.providers.request_headers import ( + merge_provider_request_headers, + use_provider_request_headers, +) + + +def test_merge_provider_request_headers_overrides_case_insensitively() -> None: + assert merge_provider_request_headers( + {"X-Provider": "default", "X-Keep": "yes"}, + {"x-provider": "a2a"}, + ) == {"X-Keep": "yes", "x-provider": "a2a"} + + +def test_openai_provider_adds_request_local_headers() -> None: + provider = OpenAIProvider(model="gpt-5.5", api_key="test") + + with use_provider_request_headers({"X-A2A-Session": "session-1"}): + kwargs = provider._build_chat_completion_kwargs( + [Message.user("hello")], + "system", + None, + 1024, + provider._create_chat_request_context(streaming=False), + ) + + assert kwargs["extra_headers"] == {"X-A2A-Session": "session-1"} + + +def test_qwen_provider_merges_adapter_and_request_local_headers() -> None: + provider = QwenProvider( + model="qwen3.7-plus", + api_key="test", + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", + ) + + with use_provider_request_headers({"X-A2A-Session": "session-1"}): + kwargs = provider._build_chat_completion_kwargs( + [Message.user("hello")], + "system", + None, + 1024, + provider._create_chat_request_context(streaming=False), + ) + + assert kwargs["extra_headers"] == { + "X-DashScope-CacheControl": "enable", + "X-A2A-Session": "session-1", + } + + +def test_anthropic_provider_merges_alias_and_request_local_headers() -> None: + provider = AnthropicProvider(model="claude-sonnet-4-6-1m", api_key="test") + + with use_provider_request_headers({"X-A2A-Session": "session-1"}): + kwargs = provider._build_kwargs([Message.user("hello")], "system", None, 1024) + + assert kwargs["extra_headers"] == { + "anthropic-beta": "context-1m-2025-08-07", + "X-A2A-Session": "session-1", + } diff --git a/website/docs/a2a/protocol-reference.md b/website/docs/a2a/protocol-reference.md index a30fd4d9..a15ff75e 100644 --- a/website/docs/a2a/protocol-reference.md +++ b/website/docs/a2a/protocol-reference.md @@ -135,7 +135,8 @@ Runs a non-streaming A2A message turn. The response contains a task or message a "cwd": "/absolute/path/to/project", "user_id": "client-user-123", "iac_code_model": "qwen-plus", - "iac_code_api_key": "provider-api-key" + "iac_code_api_key": "provider-api-key", + "llm_headers": {"X-Caller-Session": "session-123"} } } }, @@ -158,6 +159,7 @@ Runs a non-streaming A2A message turn. The response contains a task or message a | `metadata.iac_code.channel` | string | Optional | Telemetry channel binding for this `contextId`; takes priority over `IAC_CODE_CHANNEL` | | `metadata.iac_code.iac_code_model` | string | Optional | Per-call LLM model override; this is the lowercase form of `IAC_CODE_MODEL` and is ignored when blank or non-string | | `metadata.iac_code.iac_code_api_key` | string | Optional | Per-call LLM provider API key override; this is the lowercase form of `IAC_CODE_API_KEY` and is ignored when blank or non-string | +| `metadata.iac_code.llm_headers` | object | Optional | Additional HTTP headers bound to LLM provider calls for this `contextId` | | `metadata.iac_code.alibaba_cloud_access_key_id` | string | Optional | Alibaba Cloud AccessKey ID for this task | | `metadata.iac_code.alibaba_cloud_access_key_secret` | string | Optional | Alibaba Cloud AccessKey Secret for this task | | `metadata.iac_code.alibaba_cloud_region_id` | string | Optional | Alibaba Cloud region for this task; defaults to `cn-hangzhou` when omitted with task credentials | @@ -179,6 +181,8 @@ When `metadata.iac_code` includes both `alibaba_cloud_access_key_id` and `alibab `metadata.iac_code.iac_code_api_key` only affects the current A2A message turn. It takes priority over `IAC_CODE_API_KEY` and `.credentials.yml` for the provider selected by the effective model. Follow-up turns without this metadata field reload normal credentials, so a per-call key does not leak across reused `contextId`s. This field is for the LLM provider key and is separate from A2A transport authentication such as `api-key` / `IACCODE_A2A_API_KEY`. +`metadata.iac_code.llm_headers` is a string-to-string map bound to the A2A `contextId`. Once supplied, normal chat, Pipeline execution, and later message turns that reuse the same `contextId` inherit the headers even when the field is omitted. Supplying another map replaces the complete binding; supplying `{}` clears it. Other concurrent contexts remain isolated. The headers are merged case-insensitively with provider-managed request headers and caller values take precedence. Invalid header names, non-string values, line breaks, and entries beyond the bounded header count and size limits are ignored. Because header values may contain credentials, bindings are held only in server memory and are not written to session snapshots; callers must supply them again after a server restart. Treat sensitive values like credentials and protect the A2A request accordingly. + `metadata.iac_code.run_mode` can select `normal` or `pipeline` for one message. When the effective mode is `pipeline`, `metadata.iac_code.pipeline_name` can select `selling` or `selling_solution_first`; an unsupported non-empty value is rejected. On continuation and recovery, the pipeline identity stored for the existing task/context takes precedence so a caller cannot accidentally resume durable state with another pipeline. `metadata.iac_code.preferredLanguage` only affects user-visible text (progress, questions, permission prompts, candidate presentations, result explanations); protocol field names, enums, IDs, and command shapes are never translated. Accepted values are the supported languages `en`, `zh`, `es`, `fr`, `de`, `ja`, `pt`; values are normalized by trimming whitespace, lowercasing, and stripping region suffixes (for example `zh-CN` resolves to `zh`). Unrecognized values are ignored and the server falls back to its default language. The field applies only to the current message turn; follow-up turns reusing the same `contextId` must carry it again or fall back to the default language. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md index 8ff48d38..335336f7 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -135,7 +135,8 @@ Callback URLs 会在存储前以及分发前再次校验。默认 validator 会 "cwd": "/absolute/path/to/project", "user_id": "client-user-123", "iac_code_model": "qwen-plus", - "iac_code_api_key": "provider-api-key" + "iac_code_api_key": "provider-api-key", + "llm_headers": {"X-Caller-Session": "session-123"} } } }, @@ -158,6 +159,7 @@ Callback URLs 会在存储前以及分发前再次校验。默认 validator 会 | `metadata.iac_code.channel` | string | 可选 | 当前 `contextId` 绑定的 telemetry channel;优先级高于 `IAC_CODE_CHANNEL` | | `metadata.iac_code.iac_code_model` | string | 可选 | 当前调用的 LLM model 覆盖;这是 `IAC_CODE_MODEL` 的小写形式,空字符串或非字符串会被忽略 | | `metadata.iac_code.iac_code_api_key` | string | 可选 | 当前调用的 LLM provider API key 覆盖;这是 `IAC_CODE_API_KEY` 的小写形式,空字符串或非字符串会被忽略 | +| `metadata.iac_code.llm_headers` | object | 可选 | 绑定到当前 `contextId` 的 LLM provider 调用上的额外 HTTP headers | | `metadata.iac_code.alibaba_cloud_access_key_id` | string | 可选 | 当前任务使用的 Alibaba Cloud AccessKey ID | | `metadata.iac_code.alibaba_cloud_access_key_secret` | string | 可选 | 当前任务使用的 Alibaba Cloud AccessKey Secret | | `metadata.iac_code.alibaba_cloud_region_id` | string | 可选 | 当前任务使用的 Alibaba Cloud region;和任务凭据一起省略时默认为 `cn-hangzhou` | @@ -179,6 +181,8 @@ Callback URLs 会在存储前以及分发前再次校验。默认 validator 会 `metadata.iac_code.iac_code_api_key` 只影响当前 A2A message turn。它优先于 `IAC_CODE_API_KEY` 和 `.credentials.yml` 中当前有效 model 对应 provider 的 key;复用同一个 `contextId` 的后续轮次如果不再传该字段,会重新加载正常凭据,因此单次调用 key 不会串到后续请求。这个字段用于 LLM provider key,和 A2A transport 认证里的 `api-key` / `IACCODE_A2A_API_KEY` 是两件事。 +`metadata.iac_code.llm_headers` 是绑定到 A2A `contextId` 的字符串到字符串映射。首次传入后,复用同一 `contextId` 的 normal chat、Pipeline 执行和后续消息即使省略该字段,也会继续沿用这些 headers。后续传入新的映射会整组替换当前绑定;传入 `{}` 会清空绑定。其他并发 context 与之隔离。它会与 provider 自身管理的请求 headers 按名称大小写不敏感地合并,调用方传入的值优先。无效 header 名、非字符串值、包含换行符的值,以及超出数量或大小限制的条目会被忽略。由于 header 值可能包含凭据,绑定只保存在 server 内存中,不会写入 session snapshot;server 重启后调用方需要重新传入。敏感值应按凭据保护,并通过安全的 A2A 链路传输。 + `metadata.iac_code.run_mode` 可以为单条消息选择 `normal` 或 `pipeline`。有效模式为 Pipeline 时,`metadata.iac_code.pipeline_name` 可以选择 `selling` 或 `selling_solution_first`;不受支持的非空值会被拒绝。继续或恢复任务时,已有 task/context 中保存的 Pipeline 标识优先,避免调用方用另一条流水线错误恢复持久状态。 `metadata.iac_code.preferredLanguage` 只影响用户可见文本(进度、提问、权限提示、候选展示、结果说明等);协议字段名、枚举值、ID 和命令格式不会被翻译。取值为支持的语言 `en`、`zh`、`es`、`fr`、`de`、`ja`、`pt`;值会先去除空白、转小写并去掉区域后缀(例如 `zh-CN` 归一为 `zh`),无法识别的值会被忽略并回落到服务器默认语言。该字段只影响当前消息轮次;复用同一个 `contextId` 的后续轮次要再次携带,否则回落到默认语言。 From f9e477a7824cecebd82b7507c9643e4d16d64017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Mon, 14 Sep 2026 15:25:15 +0800 Subject: [PATCH 2/3] feat: update provider catalogs and effort controls --- scripts/provider-update-guide.zh-CN.md | 138 ++++++++++++++++-- src/iac_code/commands/effort.py | 82 +++++++---- src/iac_code/config.py | 15 +- .../i18n/locales/de/LC_MESSAGES/messages.po | 4 + .../i18n/locales/de/LC_MESSAGES/webui.po | 13 +- .../i18n/locales/es/LC_MESSAGES/messages.po | 4 + .../i18n/locales/es/LC_MESSAGES/webui.po | 13 +- .../i18n/locales/fr/LC_MESSAGES/messages.po | 4 + .../i18n/locales/fr/LC_MESSAGES/webui.po | 13 +- .../i18n/locales/ja/LC_MESSAGES/messages.po | 4 + .../i18n/locales/ja/LC_MESSAGES/webui.po | 13 +- .../i18n/locales/pt/LC_MESSAGES/messages.po | 4 + .../i18n/locales/pt/LC_MESSAGES/webui.po | 13 +- .../i18n/locales/zh/LC_MESSAGES/messages.po | 4 + .../i18n/locales/zh/LC_MESSAGES/webui.po | 13 +- .../engine/architecture_semantic_planning.py | 4 +- src/iac_code/providers/dashscope_provider.py | 23 +-- src/iac_code/providers/kimi_provider.py | 7 +- src/iac_code/providers/manager.py | 29 +++- src/iac_code/providers/openai_provider.py | 10 +- src/iac_code/providers/registry.py | 50 +++++-- src/iac_code/providers/thinking.py | 99 ++++++++++++- src/iac_code/services/context_manager.py | 20 ++- src/iac_code/services/telemetry/constants.py | 14 ++ src/iac_code/ui/dialogs/model_picker.py | 4 + src/iac_code/web/session_manager.py | 2 +- src/iac_code/web/session_titler.py | 4 +- src/iac_code/web/settings.py | 6 +- src/iac_code/web/static/index.html | 2 +- src/iac_code/web/static/js/app.js | 2 +- .../web/static/js/components/composer.js | 21 ++- tests/commands/test_auth_basics.py | 26 ++-- tests/commands/test_effort.py | 114 +++++++++++++++ tests/providers/test_dashscope_provider.py | 46 +++++- tests/providers/test_deepseek_provider.py | 12 +- tests/providers/test_manager.py | 28 +++- tests/providers/test_new_providers.py | 11 ++ .../test_provider_model_research_updates.py | 110 +++++++++++--- tests/providers/test_thinking_registry.py | 32 +++- .../services/capabilities/test_multimodal.py | 3 + tests/services/test_context_manager.py | 45 +++++- tests/test_config_env_overrides.py | 9 ++ .../test_telemetry/test_constants.py | 9 ++ tests/ui/dialogs/test_model_picker.py | 3 + tests/web/test_frontend_static.py | 27 +++- tests/web/test_session_manager.py | 25 +++- tests/web/test_settings.py | 41 ++++++ 47 files changed, 990 insertions(+), 185 deletions(-) diff --git a/scripts/provider-update-guide.zh-CN.md b/scripts/provider-update-guide.zh-CN.md index 5040a93e..c9b137ba 100644 --- a/scripts/provider-update-guide.zh-CN.md +++ b/scripts/provider-update-guide.zh-CN.md @@ -18,17 +18,27 @@ 不能进入 pytest。 7. Provider 返回的签名、加密思考块等 opaque metadata 不是展示内容,但可能是下一轮请求的必填字段; 必须原样持久化、按 provider 隔离回传,并从公开输出和日志中排除。 +8. 用户点名的 provider 或模型只是调研线索,不是范围清单。每次更新都要从仓库完整 registry 出发, + 逐个检查官方目录、区域版和套餐版,不能因需求中没提到某个模型就跳过它。 +9. 能力结论必须绑定 `provider key + 精确 model ID + base URL + API surface`。文档、控制台、 + `/models` 和真实请求证明的是不同层次的事实,不能把一个端点的结论外推到另一个端点。 +10. Provider 更新是跨终端和 Web 的产品变更。模型列表、默认项、effort 选项、翻译和长列表交互 + 都必须在两个入口分别验收,不能只验证 adapter 单测。 ## 标准流程 ### 1. 确认范围和当前基线 -先确认本次要更新哪些 provider,以及是否包含它们的区域版、套餐版和兼容版。例如: +用户提供的名单只能作为“重点关注项”。先从 +[`src/iac_code/providers/registry.py`](../src/iac_code/providers/registry.py) 导出所有 provider key, +再确认每个 provider 是否包含区域版、套餐版、工作空间端点和兼容版。例如: - OpenAI 与 Azure OpenAI 是两个可用性边界,不应默认完全同步。 - Kimi CN、Kimi Intl 可以共享实现,但 model ID 和上线时间仍需分别确认。 - DashScope 标准兼容端点、Token Plan、Coding Plan 的模型集合可能不同。 - 官方 GLM/Kimi/DeepSeek 与百炼托管的同名模型可能使用不同请求参数。 +- Kimi 公共 API 与 Kimi Code 订阅是不同 provider;同一个产品版本也可能暴露不同 API model ID。 +- 百炼公共兼容端点、用户 Workspace 端点和 Token Plan 即使都使用 OpenAI SDK,也不是同一能力边界。 开始前执行: @@ -45,6 +55,18 @@ git show --stat "$PROVIDER_UPDATE_COMMIT" git show "$PROVIDER_UPDATE_COMMIT" -- src/iac_code/providers tests/providers ``` +随后建立全量盘点表。表中每个 registry provider 必须恰好出现一次;没有更新也要写明“已核验、无变化” +及证据,不能只列已知有更新的厂商: + +```markdown +| provider_key | registry 当前模型 | 官方当前目录 | 区域/套餐/端点 | 结论 | 证据与日期 | +| --- | --- | --- | --- | --- | --- | +| example | old-model | new-model | public / CN | 更新 | official URL, YYYY-MM-DD | +``` + +这个门禁用于防止只搜索用户点名的 Qwen、GLM、Gemini、GPT、Claude,而漏掉同周期更新的 DeepSeek、 +Kimi、MiniMax 或其他 registry provider。调研完成前先检查盘点表是否覆盖 registry 全集,再开始改代码。 + ### 2. 建立官方信息源 优先顺序如下: @@ -63,13 +85,14 @@ git show "$PROVIDER_UPDATE_COMMIT" -- src/iac_code/providers tests/providers | OpenAI | [Models](https://developers.openai.com/api/docs/models) | [Reasoning](https://developers.openai.com/api/docs/guides/reasoning)、[Deprecations](https://developers.openai.com/api/docs/deprecations) | | Anthropic | [Models overview](https://platform.claude.com/docs/en/about-claude/models/overview) | [Extended thinking](https://platform.claude.com/docs/en/build-with-claude/extended-thinking) | | Gemini | [Models](https://ai.google.dev/gemini-api/docs/models) | [Thinking](https://ai.google.dev/gemini-api/docs/thinking)、[OpenAI compatibility](https://ai.google.dev/gemini-api/docs/openai) | -| Qwen / DashScope | [模型列表](https://help.aliyun.com/zh/model-studio/models) | [OpenAI 兼容](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-chat-completions)、[深度思考](https://help.aliyun.com/zh/model-studio/deep-thinking) | -| Kimi | [Model List](https://platform.kimi.ai/docs/models) | [Model parameters](https://platform.kimi.ai/docs/api/models-overview)、[Chat Completion](https://platform.kimi.ai/docs/api/chat) | +| Qwen / DashScope | [模型列表](https://help.aliyun.com/zh/model-studio/models)、[百炼模型市场](https://bailian.console.aliyun.com/cn-beijing/model/market) | [OpenAI 兼容](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-chat-completions)、[深度思考](https://help.aliyun.com/zh/model-studio/deep-thinking)、[Token Plan](https://help.aliyun.com/zh/model-studio/token-plan-personal-overview);标准百炼、Token Plan 和 Workspace 分栏核对 | +| Kimi 公共 API | [Model List](https://platform.kimi.ai/docs/models) | [Model parameters](https://platform.kimi.ai/docs/api/models-overview)、[Chat Completion](https://platform.kimi.ai/docs/api/chat) | +| Kimi Code | [Models](https://www.kimi.com/code/docs/kimi-code/models.html) | 单独核对 `https://api.kimi.com/coding/v1` 的模型别名、effort 和订阅权限,不从公共 API 推导 | | GLM / Z.AI | [Chat Completion](https://docs.z.ai/api-reference/llm/chat-completion) | 在同一 API reference 中核对 `thinking`、工具调用和模型枚举 | | Azure OpenAI | [Create and deploy](https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/create-resource?view=foundry-classic) | 请求中的 `model` 是用户自定义 deployment name,不是公开模型 ID | | DeepSeek | [Models and pricing](https://api-docs.deepseek.com/quick_start/pricing) | 核对官方 Chat Completions、思考模式和精确模型 ID | | MiniMax 官方 | [Models](https://platform.minimax.io/docs/guides/models-intro)、[API Overview](https://platform.minimax.io/docs/api-reference/api-overview) | 中国/国际直连端点、Anthropic/OpenAI 兼容协议和套餐模型分别核对 | -| DashScope 托管 MiniMax | [DashScope MiniMax API](https://help.aliyun.com/en/model-studio/minimax-api-by-minimax) | 不要把官方端点的模型命名和 thinking 协议直接套到百炼命名空间 | +| 百炼 MiniMax 原厂直供 | [百炼 MiniMax API](https://help.aliyun.com/zh/model-studio/minimax-api-by-minimax) | 不要把官方端点的模型命名和 thinking 协议直接套到 `MiniMax/` 命名空间 | 官方页面可能改变路径。链接失效时,从厂商文档首页重新搜索,不要转而引用聚合站、新闻稿或 模型排行榜。查询时优先使用下面这类窄关键词: @@ -91,30 +114,62 @@ site:官方域名 deprecation migration model | 字段 | 要确认的内容 | | --- | --- | | Provider key | 仓库中的 key,例如 `openai`、`dashscope_token_plan`、`kimi_cn` | +| 精确 base URL | 公共、区域、套餐或 Workspace URL;Workspace ID 必须脱敏为 `{WorkspaceId}` | | 精确 model ID | 大小写、连字符、日期后缀、命名空间前缀是否完全一致 | | 生命周期 | GA、preview、latest alias、dated snapshot、deprecated、下线日期 | | 可用范围 | 中国/国际区域、标准计费/Token Plan/Coding Plan、账号白名单 | -| API 协议 | 原生 OpenAI、原生 Anthropic、OpenAI-compatible 或厂商扩展 | +| API surface | Chat Completions、Responses、Messages、原生 API 或厂商兼容扩展 | | 输入输出模态 | 文本、图片、音频、视频;注意“模型支持”与“当前 endpoint 支持”之别 | | Agent 能力 | tool calling、parallel tools、structured output、system message | | 容量 | context window、最大输出、思考 token 是否计入输出上限 | -| 思考控制 | 开关字段、effort 枚举、budget、默认值、是否 always-on | +| 思考控制 | wire 字段、服务端接受值、UI 标准值、alias、默认值、关闭语义、是否 always-on | | Opaque 响应字段 | thinking signature、redacted/encrypted block、tool-call signature 是否必须在下一轮原样回传 | | 缓存 | 隐式/显式缓存、cache marker、支持模型列表 | | 降级候选 | 同协议、同模态、仍在线、成本和质量可接受的模型 | -| 证据 | 官方 URL、页面标题、核验日期;有冲突时记录冲突和采用理由 | +| 默认模型理由 | 可用性、生命周期、当前 adapter 协议、工具/多模态能力、降级链和厂商推荐 | +| 证据 | 官方 URL、控制台所见、脱敏实测、核验日期;有冲突时记录冲突和采用理由 | 建议在 PR 描述或临时调研笔记中使用以下模板: ```markdown -| provider_key | model_id | status | scope | multimodal | context | max output | thinking wire format | fallback | source | checked_at | +| provider_key | base_url | API surface | model_id | status | UI efforts | wire efforts | default reason | fallback | source/test | checked_at | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| openai | example-model | GA | global | image input | 128K | 16K | reasoning_effort=... | example-small | official URL | YYYY-MM-DD | +| openai | official | Chat Completions | example-model | GA | low/high | low/high | tool loop supported | example-small | official URL | YYYY-MM-DD | ``` 不要仅凭 `GET /models` 决定能力。模型列表通常不能完整表达思考档位、多模态限制、弃用日期或 代理端点的扩展参数;它只能作为官方文档的补充证据。 +### 4. 对每个真实 URL 做端点级验证 + +先从 registry 和用户配置方式建立端点矩阵,至少区分厂商直连、CN/Intl、百炼公共兼容端点、 +Token Plan、Coding Plan 和 Workspace 端点。Workspace URL 在笔记、日志和提交中统一写成: + +```text +https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/compatible-mode/v1 +``` + +文档与实际报错冲突,或端点属于 Workspace/套餐/代理服务时,必须在开发者授权并提供已配置凭据后, +对**每个 URL**做最小真实 smoke test。一次探测只能证明“这个 URL + 这个 model ID + 这个 API +surface + 这组参数”;禁止把结论复制到名称相近的 provider。 + +对 effort 至少记录 UI 标准值、服务端接受 alias、默认值、关闭语义和实际 wire 字段。声称支持数值范围 +(例如 `1`–`100`)时,必须对同一 URL 测试下界、上界、一个中间值及非法负例;不能从控制台滑块、 +SDK schema、另一条 URL 或另一模型推断。UI 只展示稳定的标准枚举,兼容 alias 只在 transport 层接受。 + +建议把脱敏结果记录为: + +```markdown +| provider_key | base_url | API surface | model_id | field=value | HTTP/SDK result | sanitized conclusion | checked_at | +| --- | --- | --- | --- | --- | --- | --- | --- | +``` + +- `GET /models` 只证明当前凭据可能看见某个 ID,不证明 Chat/Responses、工具调用或 effort 可用。 +- `401`/`403` 只证明请求到达认证边界,不证明模型存在或参数被接受。 +- `400` 的合法值枚举可以作为该 URL 的实测证据,但要记录请求的 model、surface 和日期。 +- 不打印或提交 API key、认证 header、Workspace ID、完整响应正文和用户配置。 +- 文档、控制台和实测不一致时,在证据表分栏保留三者并说明采用哪一项,不能静默择一。 + ## 代码更新矩阵 ### 模型目录 @@ -127,6 +182,20 @@ site:官方域名 deprecation migration model - 检查 `base_url`、`provider_class`、`qwenpaw_provider_ids` 和 `qwenpaw_chat_model`。 - 模型顺序通常为推荐默认、当前主力、较旧兼容模型;不要把已弃用模型放到默认位。 +默认模型不能按“版本号最大”自动选择。按以下顺序完成门禁,并把理由写入证据表: + +1. 精确 endpoint 和账号范围是否可用。 +2. 是否为稳定版本,是否即将弃用或仅限预览/邀请。 +3. 当前 adapter 使用的 API surface 是否支持它;支持 Responses 不等于支持现有 Chat Completions 工具循环。 +4. tool calling、连续工具轮次、structured output 和 system message 是否满足 agent 运行要求。 +5. registry 的多模态标记是否与当前附件传输格式一致。 +6. 是否存在同 endpoint、同协议、能力足够的 fallback。 +7. 在以上条件都满足时,再参考厂商的默认/推荐模型。 + +模型可以先进入可选目录但暂不成为默认。例如旗舰模型若只有 Responses API 才支持工具调用,而当前 +adapter 仍使用 Chat Completions,就应保留当前可工作的默认模型,并把 Responses adapter 作为独立需求。 +标准百炼和 Token Plan 的目录、生命周期与 endpoint 不同,因此可以有不同默认模型。 + 新增 provider 或全新模型前缀时,还要检查 [`src/iac_code/config.py`](../src/iac_code/config.py) 中的 `_MODEL_PREFIX_TO_PROVIDER`。如果模型名带 命名空间,或同一前缀在不同 endpoint 上有重名模型,还必须在 `_MODEL_EXACT_TO_PROVIDER` 中登记精确 @@ -152,6 +221,8 @@ site:官方域名 deprecation migration model - 确认 `ThinkingFamily`、允许的 effort、默认 effort、budget 和输出 token 策略。 - 仅在 CN/Intl 或套餐版协议完全一致时使用 `_THINKING_FALLBACK` 复用。 - 未确认的组合应保持 `ThinkingFamily.NONE`,不要猜测“新版本应该兼容旧参数”。 +- 区分 UI 标准枚举与 wire alias;选择器只展示标准值,adapter 可按明确证据映射兼容 alias。 +- effort 超过可视阈值时,使用可滚动选择器或分组子菜单;终端和 Web 都要验证滚动、键盘选择及当前值回显。 特别注意:能力声明只描述“支持什么”,实际请求格式由 provider adapter 组装。 @@ -239,13 +310,17 @@ signature/redacted thinking 和 Gemini function-call thought signature 都属于 registry 会驱动多个入口,更新后至少检查: - `/auth` 展示的 provider 默认模型; -- `/model` 和模型选择器的列表、默认项、多模态标记; +- 终端 `/model`、`/effort` 和模型选择器的列表、默认项、多模态标记、长列表导航; +- Web provider 设置接口、会话级 model/effort 选择器和输入区快捷入口; +- 终端与 Web 是否都从同一 registry/thinking capability 读取数据,但仍分别有回归测试; - 已有 `settings.yml` 中旧模型是否仍能读取; - custom provider 和自定义 model ID 是否未被限制; - Azure 或其他 deployment-based provider 是否错误地把公开 model ID 当作 deployment ID。 -仅修改现有 model ID 通常不需要新增用户可见字符串,因此一般不需要运行 `make translate`。 -若修改了 provider 显示名、错误提示或帮助文本,则按仓库规则更新翻译。 +新增 provider 显示名、effort 标签、错误提示或帮助文本时,必须更新 `messages` 与 `webui` 两个翻译域, +并运行 `make translate`。所有 locale 都要有非空翻译;`ultra` 等新增枚举不能在中文界面回退为英文。 +翻译工具可能把 `Kimi Code` 这类品牌名猜成普通词,提交前要人工检查 fuzzy 条目,品牌名应保持品牌名。 +修改 Web JS 模块时还要更新 `index.html` 中对应的 `?v=` cache-bust token,并同步前端静态测试。 ## 测试策略 @@ -255,7 +330,7 @@ registry 会驱动多个入口,更新后至少检查: 2. thinking registry:family、effort 枚举、默认值、budget 和 fallback。 3. adapter wire format:开启、关闭、默认、非法 effort,以及代际差异。 4. manager:降级映射和 provider 选择。 -5. UI/auth:默认模型和可选模型是否来自 registry。 +5. UI/auth:终端和 Web 的默认模型、可选模型、effort 标准值、翻译和长列表交互是否一致。 6. telemetry:新增模型不会被清洗为 `other`。 7. opaque metadata:流式、非流式、会话序列化、下一轮回传和公开输出隔离。 @@ -269,20 +344,27 @@ tests/providers/test_anthropic_provider.py tests/providers/test_dashscope_provider.py tests/providers/test_deepseek_provider.py tests/providers/test_manager.py +tests/providers/test_new_providers.py tests/agent/test_message.py tests/agent/test_agent_loop_new.py tests/services/test_token_counter.py tests/services/test_context_manager.py tests/commands/test_auth_basics.py +tests/commands/test_effort.py tests/cli/test_output_formats.py tests/types/test_stream_events.py tests/test_config_env_overrides.py tests/ui/test_stream_accumulator.py tests/ui/test_renderer_events.py tests/ui/dialogs/test_model_picker.py +tests/web/test_settings.py tests/test_services/test_telemetry/test_constants.py ``` +目录类测试应同时包含精确正向列表和已移除 ID 的负向断言,并尽量加入 registry 覆盖不变量:每个静态 +模型都应按适用范围进入 thinking、context、fallback 和 telemetry 配置。这样新增模型不会只在选择器中 +可见,却在运行时落入通用默认值或遥测 `other`。 + 先运行聚焦测试: ```bash @@ -309,8 +391,9 @@ make test make lint ``` -如需真实 API smoke test,使用开发者自己的已配置凭据,只发送最小请求,并分别验证文本、思考、 -工具调用和图片输入。不要打印请求 header、API key 或用户配置,也不要把真实响应录入测试 fixture。 +真实 API smoke test 仍不能进入 pytest;按“对每个真实 URL 做端点级验证”执行并只保留脱敏结论。 +当全量测试出现单个失败时,不得把一次单测重跑通过表述为“全量测试通过”:应保留第一次全量结果, +重跑精确失败项判断是否偶发,并在提交说明中分别记录两次结果。 ## 提交前检查表 @@ -324,10 +407,17 @@ git diff HEAD -- iac-code-rs 确认以下事项后再提交: - 每个 model ID 都能追溯到官方来源和核验日期。 +- 全量盘点表覆盖 registry 中每一个 provider,而不是只覆盖用户点名的厂商。 +- 百炼公共端点、Workspace、Token Plan、Coding Plan 和厂商直连没有共用未经验证的结论。 +- 文档、控制台、`/models` 和逐 URL 实测结论已分栏记录;没有把 `401`/`403` 当作能力验证。 - 默认模型有明确理由,且没有多个默认项。 +- 默认模型已通过当前 API surface 的连续工具调用门禁;“最新模型”没有自动等同于“默认模型”。 - preview、deprecated、alias 和 dated snapshot 没有混为一谈。 +- 产品版本名、控制台显示名和实际 API model ID 没有混为一谈。 - 同名模型在不同 provider 下的思考协议没有被错误复用。 +- effort 的 UI 标准值、wire alias、默认值和关闭语义已经分别验证。 - 新模型已加入遥测白名单和必要的降级链。 +- 终端与 Web 的 model/effort 选择器均已验证,新增标签在所有 locale 中有正确翻译。 - 聚焦测试、全量测试和 lint 已通过,或在 PR 中明确记录未运行项。 - `git diff HEAD -- iac-code-rs` 为空。 - 提交中没有调研缓存、网页快照、API 响应、密钥或用户配置。 @@ -404,3 +494,21 @@ git diff HEAD -- iac-code-rs | Z.AI / GLM 官方 | `glm-5.3` 已同时开放标准 Model API 与 Coding Plan,不再只限 Coding Plan,因此进入中国站、国际站的标准 provider 并成为默认;2026-08-26 新发布 `glm-5.3-flash`,同时开放 Model API 与 Coding Plan,支持 1M context、原生图片/视频/文件输入、Function Calling 与结构化输出。其文本参数与 GLM-5.3 一致:思考始终开启,`thinking.type` 仅接受 `enabled`,`reasoning_effort` 支持 `low/high/max`(推荐 `max`);当前附件适配器使用官方明确支持的 Base64 Data URL,因此开放图片输入标记。关闭思考时继续按 GLM-5.3 迁移规则降级为 `enabled + low` | `config.py`、`registry.py`、`thinking.py`、`zhipu_provider.py`、`manager.py`、`context_manager.py`、遥测及对应测试 | [GLM-5.3(CN)](https://docs.bigmodel.cn/cn/guide/models/text/glm-5.3)、[GLM-5.3(Intl)](https://docs.z.ai/guides/llm/glm-5.3)、[GLM-5.3-Flash](https://docs.z.ai/guides/vlm/glm-5.3-flash)、[发布公告](https://z.ai/blog/glm-5.3-flash) | | DashScope 智谱直供 | 百炼华北2(北京)新增智谱原厂直供 `ZHIPU/GLM-5.3`,精确 model ID 包含大写命名空间;仅文本输入,context 为 1,048,576、最大输出为 131,072,支持 Function Calling 与隐式缓存。模型信息页把结构化输出标为支持,但端点级调用页标为不支持,本次不依赖该能力并以端点级限制为准。模型始终思考,`enable_thinking` 必须保持 `true`,`reasoning_effort` 接受 `low/high/max`;关闭请求降级为 `enable_thinking=true + low`。该模型未进入 Token Plan 目录,且按百炼兼容端点协议发送参数,不能复用 Z.AI 直连的 `thinking.type` wire format | `config.py`、`registry.py`、`thinking.py`、`dashscope_provider.py`、`manager.py`、`context_manager.py`、遥测及对应测试 | [GLM-智谱直供调用](https://help.aliyun.com/zh/model-studio/glm-zhipu)、[ZHIPU/GLM-5.3 模型信息](https://help.aliyun.com/zh/model-studio/glm-5-3-by-zhipu)、[Token Plan 个人版目录](https://help.aliyun.com/zh/model-studio/token-plan-personal-overview) | | 其他厂商直连 provider | 重新检查 OpenAI、Anthropic、Gemini、DeepSeek、Kimi 与 MiniMax 的官方模型目录和更新日志后,没有发现 2026-08-18 基线之后适合当前 Chat Completions/Message 直连接入路径的新 GA 文本模型;上表的 `kimi-k3`、MiMo 和 Stepfun 变化只属于百炼服务。Kimi K2.5 的 2026-08-31 下线日期尚未到达,继续保留以兼容存量用户 | 无代码变更 | [OpenAI Models](https://developers.openai.com/api/docs/models)、[Anthropic Models](https://platform.claude.com/docs/en/about-claude/models/overview)、[Gemini Release Notes](https://ai.google.dev/gemini-api/docs/changelog)、[DeepSeek Models](https://api-docs.deepseek.com/quick_start/pricing)、[Kimi Models](https://platform.kimi.ai/docs/models)、[MiniMax Models](https://platform.minimax.io/docs/guides/models-intro) | + +### 2026-09-14 增量证据附录 + +本节记录 2026-09-14 的完整复核;若与更早的证据附录冲突,以本节为准。这次先按用户点名列表 +调研,曾漏掉 DeepSeek V4.1 Flash,又曾把未经指定 Workspace URL 验证的 `1`–`100` 数值范围写入 +交互设计。两处问题促成了前文的“registry 全量盘点”和“逐 URL 实测”门禁。 + +| 范围 | 2026-09-14 核验结论 | 代码/测试落点 | 官方证据 | +| --- | --- | --- | --- | +| OpenAI | 新增可选模型 `gpt-6-astra`,effort 为 `low/medium/high/xhigh/max`。它虽是当前旗舰,但官方明确其 tool calling 需要 Responses API;本仓库当前 agent adapter 仍使用 Chat Completions,因此默认继续使用能完成现有工具循环的 `gpt-5.6-sol`,Responses 支持另行实现 | `registry.py`、`thinking.py`、`openai_provider.py`、默认模型及 wire-format 测试 | [Models](https://developers.openai.com/api/docs/models)、[Latest model guide](https://developers.openai.com/api/docs/guides/latest-model) | +| Anthropic | 默认升级为 `claude-fable-5-1`,保留 `claude-fable-5`;新默认采用 adaptive thinking,UI effort 为 `low/medium/high/xhigh/max`、默认 `high`,且不提供关闭选项 | `registry.py`、`thinking.py`、Anthropic provider 与目录测试 | [Models overview](https://platform.claude.com/docs/en/about-claude/models/overview)、[Adaptive thinking](https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking)、[Effort](https://platform.claude.com/docs/en/build-with-claude/effort) | +| Gemini | `gemini-3.8-flash` 成为默认,保留 3.7/3.6/3.5 兼容项;3.8 的 effort 为 `low/medium/high`、默认 `medium`,不接受 `minimal`,且当前接入不提供关闭思考 | `registry.py`、`thinking.py`、`context_manager.py`、fallback/telemetry 及 Gemini 目录测试 | [Gemini 3.8 Flash](https://ai.google.dev/gemini-api/docs/models/gemini-3.8-flash)、[Models](https://ai.google.dev/gemini-api/docs/models)、[Thinking](https://ai.google.dev/gemini-api/docs/thinking) | +| 百炼标准端点 / Qwen | 华北2标准兼容端点默认模型更新为精确快照 `qwen3.8-max-0902`,同时保留 `qwen3.8-max`;默认项依据当前标准端点目录,不能复制到 Token Plan。百炼模型市场属于动态控制台证据,应与文档页、精确 API ID 和日期一起记录 | `registry.py`、`config.py`、`thinking.py`、context/fallback/telemetry 及目录测试 | [百炼模型列表](https://help.aliyun.com/zh/model-studio/models)、[百炼模型市场](https://bailian.console.aliyun.com/cn-beijing/model/market)、[OpenAI-compatible Chat](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-chat-completions) | +| Token Plan | Token Plan 目录按套餐页重新收敛为其实际支持项,默认仍为 `qwen3.8-max`;标准百炼的 `qwen3.8-max-0902`、第三方模型和旧套餐模型不得自动复制进来。Token Plan 与标准百炼分别维护目录、默认项和负向断言 | `registry.py`、`thinking.py`、`manager.py`、Token Plan 精确列表测试 | [Token Plan 个人版](https://help.aliyun.com/zh/model-studio/token-plan-personal-overview)、[百炼模型列表](https://help.aliyun.com/zh/model-studio/models) | +| DeepSeek 直连 / 百炼 | DeepSeek 直连当前默认 API ID 为 `deepseek-flash`,保留兼容 ID `deepseek-v4-flash`,不把产品版本名直接写成直连 ID。百炼标准端点与 Token Plan 则登记 `deepseek-v4.1-flash`,两者不能复用 ID 或 endpoint 结论。对指定 Workspace Chat Completions URL 的最小请求实测表明,`reasoning_effort` 接受 `minimal/low/medium/high/xhigh/max/ultra`,数值字符串 `1`、`100` 被拒绝;该结论只绑定当次 URL、model 和 surface | `registry.py`、`thinking.py`、`dashscope_provider.py`、`deepseek_provider.py`、终端/Web effort 与 provider 测试 | [DeepSeek models](https://api-docs.deepseek.com/quick_start/pricing)、[DeepSeek thinking mode](https://api-docs.deepseek.com/guides/thinking_mode/)、[百炼模型列表](https://help.aliyun.com/zh/model-studio/models)、指定 Workspace URL 的 2026-09-14 脱敏实测 | +| Kimi 公共 API / Kimi Code | 公共 API 目录移除已下线的 `kimi-k2.5`。Kimi Code 作为独立订阅 provider 接入 `https://api.kimi.com/coding/v1`,精确 ID 为 `k3`、`k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`;其中 `kimi-for-coding` 是 K2.8 Preview 的 API 别名并作为该 provider 默认。K3 默认 effort 为 `high`,K2.8 默认 `max`,可选值均为 `low/high/max`;highspeed 为固定思考行为 | `registry.py`、`thinking.py`、`kimi_provider.py`、认证/设置/终端/Web 与 Kimi Code 测试 | [Kimi public models](https://platform.kimi.ai/docs/models)、[Kimi Code models](https://www.kimi.com/code/docs/kimi-code/models.html) | +| MiniMax 直连 / 百炼原厂直供 | MiniMax 中国/国际直连仍以 `MiniMax-M3` 为默认。百炼原厂直供使用带命名空间的 `MiniMax/MiniMax-M3`、`MiniMax/MiniMax-M2.7`、`MiniMax/MiniMax-M2.5`、`MiniMax/MiniMax-M2.1`,不能与直连 `MiniMax-*` 混用;M3 的多模态和 adaptive thinking 也按百炼端点单独声明 | `registry.py`、`thinking.py`、`minimax_provider.py`、context/telemetry 与目录测试 | [MiniMax official models](https://platform.minimax.io/docs/guides/models-intro)、[百炼 MiniMax 原厂直供](https://help.aliyun.com/zh/model-studio/minimax-api-by-minimax) | +| 终端 / Web effort 交互 | effort 选择器统一消费 provider/model capability 的标准枚举,Web 设置接口和会话选择器同步支持;若未来出现超长范围,则按前文使用可滚动选择器或分组子菜单。新增 `ultra` 后补齐 `messages` 与 `webui` 的全部 locale 翻译,并对 Web 静态模块更新 cache-bust token | `commands/effort.py`、`ui/dialogs/model_picker.py`、`web/settings.py`、`web/static/`、两套翻译域及终端/Web 回归测试 | 仓库 UI/i18n 约束与上述逐端点能力证据 | diff --git a/src/iac_code/commands/effort.py b/src/iac_code/commands/effort.py index 44479955..2a84e933 100644 --- a/src/iac_code/commands/effort.py +++ b/src/iac_code/commands/effort.py @@ -3,14 +3,13 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Collection from iac_code.commands.auth import _BACK, PROVIDERS, LLMProvider, _select, save_active_provider_config from iac_code.config import get_active_provider_key, get_provider_config from iac_code.i18n import _ if TYPE_CHECKING: - from iac_code.ui.dialogs.model_picker import EffortLevel from iac_code.ui.repl import CommandContext @@ -31,12 +30,12 @@ def _active_provider() -> LLMProvider | None: return None -def _load_saved_effort(key_name: str) -> "EffortLevel | None": - picker = _load_picker_module() - level_by_value = {lvl.value: lvl for lvl in picker.EffortLevel} +def _load_saved_effort(key_name: str, allowed: Collection[str]) -> str | None: saved = get_provider_config(key_name).get("effort") - if isinstance(saved, str) and saved in level_by_value: - return level_by_value[saved] + if isinstance(saved, str): + normalized = saved.strip().lower() + if normalized in allowed: + return normalized return None @@ -65,58 +64,79 @@ async def effort_command( if not spec.supports_effort: return _("Model {model} does not support effort.").format(model=current_model) - allowed = list(spec.allowed_efforts) - level_by_value = {lvl.value: lvl for lvl in picker.EffortLevel} + allowed = list(spec.effort_values) # Non-interactive: /effort if args: token = args[0].strip().lower() - target = level_by_value.get(token) - if target is None or target not in allowed: - labels = ", ".join(lvl.value for lvl in allowed) + if token not in allowed: + labels = _allowed_effort_label(spec.effort_range, allowed) return _("Invalid effort. Allowed: {labels}").format(labels=labels) - return _apply_effort(provider, current_model, target, store) + return _apply_effort(provider, current_model, token, store) - saved_effort = _load_saved_effort(provider_key) - has_effective_effort = saved_effort is not None or spec.default_effort is not None - default_effort = spec.default_effort or allowed[0] + saved_effort = _load_saved_effort(provider_key, allowed) + has_effective_effort = saved_effort is not None or spec.default_effort_value is not None + default_effort = spec.default_effort_value or allowed[0] current = saved_effort or default_effort # Interactive: show picker if not context or not context.console: if not has_effective_effort: return _("Current effort: {effort}").format(effort=_("not configured")) - return _("Current effort: {effort}").format(effort=current.value) + return _("Current effort: {effort}").format(effort=current) - options = [f"{picker.EFFORT_SYMBOLS[lvl]} {lvl.value}" for lvl in allowed] + level_by_value = {level.value: level for level in picker.EffortLevel} + options = [ + f"{picker.EFFORT_SYMBOLS[level_by_value[value]]} {value}" if value in level_by_value else value + for value in allowed + ] default_idx = allowed.index(current) if current in allowed else 0 sys.stdout.write("\033[?1049h") sys.stdout.flush() try: - idx = _select( - _("Select effort for {model}").format(model=current_model), - options, - default_index=default_idx, - ) + if len(options) <= 12: + idx = _select( + _("Select effort for {model}").format(model=current_model), + options, + default_index=default_idx, + ) + selected = None if idx is None or idx is _BACK else allowed[idx] + else: + from iac_code.ui.components.select import Select, SelectLayout, TextOption + + context.console.print(_("Select effort for {model}").format(model=current_model)) + selected = Select( + [TextOption(label=label, value=value) for label, value in zip(options, allowed)], + default_value=current, + layout=SelectLayout.COMPACT_VERTICAL, + visible_count=12, + ).run(console=context.console) finally: sys.stdout.write("\033[?1049l") sys.stdout.flush() - if idx is None or idx is _BACK: + if selected is None: if not has_effective_effort: return _("Kept effort as {effort}").format(effort=_("not configured")) - return _("Kept effort as {effort}").format(effort=current.value) + return _("Kept effort as {effort}").format(effort=current) - selected = allowed[idx] if selected == current and has_effective_effort: - return _("Kept effort as {effort}").format(effort=current.value) + return _("Kept effort as {effort}").format(effort=current) return _apply_effort(provider, current_model, selected, store) -def _apply_effort(provider: LLMProvider, model: str, effort: "EffortLevel", store) -> str: - save_active_provider_config(provider, model, effort=effort.value) +def _allowed_effort_label(effort_range, allowed: list[str]) -> str: + if effort_range is not None and len(allowed) > 12: + return "{}-{}".format(effort_range[0], effort_range[1]) + return ", ".join(allowed) + + +def _apply_effort(provider: LLMProvider, model: str, effort: str, store) -> str: + save_active_provider_config(provider, model, effort=effort) if store is not None: - store.set_state(effort_level=effort) - return _("Effort switched to: {effort}").format(effort=effort.value) + picker = _load_picker_module() + state_effort = next((level for level in picker.EffortLevel if level.value == effort), effort) + store.set_state(effort_level=state_effort) + return _("Effort switched to: {effort}").format(effort=effort) diff --git a/src/iac_code/config.py b/src/iac_code/config.py index 7383edcd..a58cb439 100644 --- a/src/iac_code/config.py +++ b/src/iac_code/config.py @@ -19,7 +19,7 @@ from iac_code.utils.file_security import atomic_write_text, ensure_private_dir, ensure_private_file # Default LLM model used when no model is saved in settings -DEFAULT_MODEL = "qwen3.8-max" +DEFAULT_MODEL = "qwen3.8-max-0902" # Configuration directory _CONFIG_DIR_NAME = ".iac-code" @@ -124,13 +124,22 @@ def _build_canonical_names() -> tuple[str, ...]: "glm-5.2-fast-preview": "dashscope", "kimi/kimi-k3": "dashscope", "minimax/minimax-m3": "dashscope", + "minimax/minimax-m2.7": "dashscope", + "minimax/minimax-m2.5": "dashscope", + "minimax/minimax-m2.1": "dashscope", + "k3": "kimi_code", + "k3-256k": "kimi_code", + "kimi-for-coding": "kimi_code", + "kimi-for-coding-highspeed": "kimi_code", "zhipu/glm-5.3": "dashscope", + "zhipu/glm-5.3-flash": "dashscope", "xiaomi/mimo-v2.5-pro": "dashscope", "stepfun/step-3.7-flash": "dashscope", - # Dated DeepSeek snapshots only exist on Bailian; the official DeepSeek - # endpoint keeps the undated model IDs. + # These DeepSeek snapshots/releases are Bailian-only; the official + # DeepSeek endpoint exposes a different, smaller model catalog. "deepseek-v4-pro-0813": "dashscope", "deepseek-v4-flash-0731": "dashscope", + "deepseek-v4.1-flash": "dashscope", } _MODEL_PREFIX_TO_PROVIDER: tuple[tuple[str, str], ...] = ( 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 4fb366c1..5e0d8b46 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -6486,6 +6486,10 @@ msgstr "Kimi (China)" msgid "Kimi (International)" msgstr "Kimi (International)" +#: src/iac_code/providers/registry.py +msgid "Kimi Code" +msgstr "Kimi Code" + #: src/iac_code/providers/registry.py msgid "MiniMax (China)" msgstr "MiniMax (China)" diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po index 0d1bbc4a..e3836f55 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po @@ -1331,6 +1331,10 @@ msgstr "Sehr hoch" msgid "Max" msgstr "Maximal" +#: src/iac_code/web/static/js/components/composer.js +msgid "Ultra" +msgstr "Extrem" + #: src/iac_code/web/static/js/components/composer.js msgid "Auto" msgstr "Automatisch" @@ -1413,6 +1417,11 @@ msgstr "Denk-Umschalter konnte nicht gespeichert werden" msgid "Model" msgstr "Modell" +#: src/iac_code/web/static/js/components/composer.js +#: src/iac_code/web/static/js/components/workspace.js +msgid "Reasoning effort" +msgstr "Reasoning-Aufwand" + #: src/iac_code/web/static/js/components/composer.js msgid "Provider" msgstr "Anbieter" @@ -2895,10 +2904,6 @@ msgstr "" msgid "The model used for generation under this provider" msgstr "Das Modell, das bei diesem Anbieter zur Generierung verwendet wird" -#: src/iac_code/web/static/js/components/workspace.js -msgid "Reasoning effort" -msgstr "Reasoning-Aufwand" - #: src/iac_code/web/static/js/components/workspace.js msgid "How deeply the model thinks; higher is slower but more detailed" msgstr "" 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 b81fff7b..d38776e0 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -6436,6 +6436,10 @@ msgstr "Kimi (China)" msgid "Kimi (International)" msgstr "Kimi (Internacional)" +#: src/iac_code/providers/registry.py +msgid "Kimi Code" +msgstr "Kimi Code" + #: src/iac_code/providers/registry.py msgid "MiniMax (China)" msgstr "MiniMax (China)" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po index ec0d04f2..d38a89ff 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po @@ -1328,6 +1328,10 @@ msgstr "Muy alto" msgid "Max" msgstr "Máximo" +#: src/iac_code/web/static/js/components/composer.js +msgid "Ultra" +msgstr "Extremo" + #: src/iac_code/web/static/js/components/composer.js msgid "Auto" msgstr "Automático" @@ -1406,6 +1410,11 @@ msgstr "No se pudo guardar el interruptor de pensamiento" msgid "Model" msgstr "Modelo" +#: src/iac_code/web/static/js/components/composer.js +#: src/iac_code/web/static/js/components/workspace.js +msgid "Reasoning effort" +msgstr "Esfuerzo de razonamiento" + #: src/iac_code/web/static/js/components/composer.js msgid "Provider" msgstr "Proveedor" @@ -2886,10 +2895,6 @@ msgstr "Selecciona un proveedor a la izquierda para ver y editar su configuraci msgid "The model used for generation under this provider" msgstr "El modelo usado para la generación con este proveedor" -#: src/iac_code/web/static/js/components/workspace.js -msgid "Reasoning effort" -msgstr "Esfuerzo de razonamiento" - #: src/iac_code/web/static/js/components/workspace.js msgid "How deeply the model thinks; higher is slower but more detailed" msgstr "" 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 16bb8fd8..8966ec37 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -6450,6 +6450,10 @@ msgstr "Kimi (Chine)" msgid "Kimi (International)" msgstr "Kimi (International)" +#: src/iac_code/providers/registry.py +msgid "Kimi Code" +msgstr "Kimi Code" + #: src/iac_code/providers/registry.py msgid "MiniMax (China)" msgstr "MiniMax (Chine)" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po index 7aa26f85..8d326784 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po @@ -1330,6 +1330,10 @@ msgstr "Très élevé" msgid "Max" msgstr "Maximal" +#: src/iac_code/web/static/js/components/composer.js +msgid "Ultra" +msgstr "Extrême" + #: src/iac_code/web/static/js/components/composer.js msgid "Auto" msgstr "Automatique" @@ -1412,6 +1416,11 @@ msgstr "Impossible d'enregistrer le bouton de réflexion" msgid "Model" msgstr "Modèle" +#: src/iac_code/web/static/js/components/composer.js +#: src/iac_code/web/static/js/components/workspace.js +msgid "Reasoning effort" +msgstr "Effort de raisonnement" + #: src/iac_code/web/static/js/components/composer.js msgid "Provider" msgstr "Fournisseur" @@ -2896,10 +2905,6 @@ msgstr "" msgid "The model used for generation under this provider" msgstr "Le modèle utilisé pour la génération avec ce fournisseur" -#: src/iac_code/web/static/js/components/workspace.js -msgid "Reasoning effort" -msgstr "Effort de raisonnement" - #: src/iac_code/web/static/js/components/workspace.js msgid "How deeply the model thinks; higher is slower but more detailed" msgstr "" 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 2af24ca5..003cbe85 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -6047,6 +6047,10 @@ msgstr "Kimi(中国版)" msgid "Kimi (International)" msgstr "Kimi(国際版)" +#: src/iac_code/providers/registry.py +msgid "Kimi Code" +msgstr "Kimi Code" + #: src/iac_code/providers/registry.py msgid "MiniMax (China)" msgstr "MiniMax(中国版)" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po index 02414c18..f94d46f6 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po @@ -1304,6 +1304,10 @@ msgstr "非常に高い" msgid "Max" msgstr "最大" +#: src/iac_code/web/static/js/components/composer.js +msgid "Ultra" +msgstr "極限" + #: src/iac_code/web/static/js/components/composer.js msgid "Auto" msgstr "自動" @@ -1376,6 +1380,11 @@ msgstr "思考トグルを保存できませんでした" msgid "Model" msgstr "モデル" +#: src/iac_code/web/static/js/components/composer.js +#: src/iac_code/web/static/js/components/workspace.js +msgid "Reasoning effort" +msgstr "推論の強度" + #: src/iac_code/web/static/js/components/composer.js msgid "Provider" msgstr "プロバイダー" @@ -2852,10 +2861,6 @@ msgstr "左側でプロバイダーを選択すると、その設定を表示お msgid "The model used for generation under this provider" msgstr "このプロバイダーで生成に使用するモデル" -#: src/iac_code/web/static/js/components/workspace.js -msgid "Reasoning effort" -msgstr "推論の強度" - #: src/iac_code/web/static/js/components/workspace.js msgid "How deeply the model thinks; higher is slower but more detailed" msgstr "モデルが考える深さ。高いほど遅くなりますが、より詳細になります" 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 a7bb116c..b7af2bb3 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -6388,6 +6388,10 @@ msgstr "Kimi (China)" msgid "Kimi (International)" msgstr "Kimi (Internacional)" +#: src/iac_code/providers/registry.py +msgid "Kimi Code" +msgstr "Kimi Code" + #: src/iac_code/providers/registry.py msgid "MiniMax (China)" msgstr "MiniMax (China)" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po index 4d886759..bce79364 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po @@ -1326,6 +1326,10 @@ msgstr "Muito alto" msgid "Max" msgstr "Máximo" +#: src/iac_code/web/static/js/components/composer.js +msgid "Ultra" +msgstr "Extremo" + #: src/iac_code/web/static/js/components/composer.js msgid "Auto" msgstr "Automático" @@ -1404,6 +1408,11 @@ msgstr "Não foi possível salvar o botão de raciocínio" msgid "Model" msgstr "Modelo" +#: src/iac_code/web/static/js/components/composer.js +#: src/iac_code/web/static/js/components/workspace.js +msgid "Reasoning effort" +msgstr "Esforço de raciocínio" + #: src/iac_code/web/static/js/components/composer.js msgid "Provider" msgstr "Provedor" @@ -2886,10 +2895,6 @@ msgstr "" msgid "The model used for generation under this provider" msgstr "O modelo usado para geração neste provedor" -#: src/iac_code/web/static/js/components/workspace.js -msgid "Reasoning effort" -msgstr "Esforço de raciocínio" - #: src/iac_code/web/static/js/components/workspace.js msgid "How deeply the model thinks; higher is slower but more detailed" msgstr "" 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 540c0e5f..02c579c2 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -5973,6 +5973,10 @@ msgstr "Kimi(中国版)" msgid "Kimi (International)" msgstr "Kimi(国际版)" +#: src/iac_code/providers/registry.py +msgid "Kimi Code" +msgstr "Kimi Code" + #: src/iac_code/providers/registry.py msgid "MiniMax (China)" msgstr "MiniMax(中国版)" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po index 7abf7e04..fdd72f2c 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po @@ -1302,6 +1302,10 @@ msgstr "极高" msgid "Max" msgstr "最大" +#: src/iac_code/web/static/js/components/composer.js +msgid "Ultra" +msgstr "极致" + #: src/iac_code/web/static/js/components/composer.js msgid "Auto" msgstr "自动" @@ -1374,6 +1378,11 @@ msgstr "无法保存思考开关" msgid "Model" msgstr "模型" +#: src/iac_code/web/static/js/components/composer.js +#: src/iac_code/web/static/js/components/workspace.js +msgid "Reasoning effort" +msgstr "推理强度" + #: src/iac_code/web/static/js/components/composer.js msgid "Provider" msgstr "提供商" @@ -2850,10 +2859,6 @@ msgstr "选择左侧服务商以查看与编辑其配置。" msgid "The model used for generation under this provider" msgstr "该服务商下用于生成的模型" -#: src/iac_code/web/static/js/components/workspace.js -msgid "Reasoning effort" -msgstr "推理强度" - #: src/iac_code/web/static/js/components/workspace.js msgid "How deeply the model thinks; higher is slower but more detailed" msgstr "模型思考的深度,越高越慢但更细致" diff --git a/src/iac_code/pipeline/engine/architecture_semantic_planning.py b/src/iac_code/pipeline/engine/architecture_semantic_planning.py index c9865bfa..8ee7caa4 100644 --- a/src/iac_code/pipeline/engine/architecture_semantic_planning.py +++ b/src/iac_code/pipeline/engine/architecture_semantic_planning.py @@ -2174,9 +2174,9 @@ def _resolve_semantic_plan_effort(model: str, effort_override: str | None) -> st if not provider_key: return effort_override spec = get_thinking_spec(provider_key, model) - if spec.supports_disable or not spec.allowed_efforts: + if spec.supports_disable or not spec.effort_values: return effort_override - return spec.allowed_efforts[0].value + return spec.effort_values[0] async def create_semantic_plan_for_architecture_with_llm( diff --git a/src/iac_code/providers/dashscope_provider.py b/src/iac_code/providers/dashscope_provider.py index 76957ff1..801ffb0d 100644 --- a/src/iac_code/providers/dashscope_provider.py +++ b/src/iac_code/providers/dashscope_provider.py @@ -165,22 +165,27 @@ def _mark_user_messages_cacheable(api_messages: list[dict[str, Any]]) -> None: def _build_thinking_kwargs(self) -> dict[str, Any]: spec = get_thinking_spec(self._PROVIDER_KEY, self._model) if spec.family is ThinkingFamily.MINIMAX: + # MiniMax-M3 exposes an adaptive/disabled switch. Earlier + # original-vendor models always think and reject that switch, so + # keep their server default by omitting the extension entirely. + if not spec.supports_disable: + return {} thinking_type = "disabled" if self._thinking_disabled() else "adaptive" return {"extra_body": {"thinking": {"type": thinking_type}}} if spec.family is not ThinkingFamily.DASHSCOPE: return {} effort = normalize_effort(self._effort) - allowed = {e.value for e in spec.allowed_efforts} + allowed = set(spec.effort_values) if self._model in {"kimi/kimi-k3", "qwen3.8-max-preview"}: kwargs: dict[str, Any] = {"extra_body": {"preserve_thinking": True}} if self._thinking_disabled(): return kwargs - if effort in {None, "auto"} and self._thinking_forced() and spec.default_effort is not None: - effort = spec.default_effort.value + if effort in {None, "auto"} and self._thinking_forced() and spec.default_effort_value is not None: + effort = spec.default_effort_value if effort in allowed: kwargs["reasoning_effort"] = effort - elif effort not in {None, "auto"} and spec.default_effort is not None: - kwargs["reasoning_effort"] = spec.default_effort.value + elif effort not in {None, "auto"} and spec.default_effort_value is not None: + kwargs["reasoning_effort"] = spec.default_effort_value return kwargs if self._model == "kimi-k3": # Bailian-hosted K3 is always-on and defaults to preserved @@ -206,14 +211,14 @@ def _build_thinking_kwargs(self) -> dict[str, Any]: kwargs: dict[str, Any] = {"extra_body": extra_body} if not spec.uses_reasoning_effort_param: return kwargs - if effort in {None, "auto"} and self._thinking_forced() and spec.default_effort is not None: - effort = spec.default_effort.value + if effort in {None, "auto"} and self._thinking_forced() and spec.default_effort_value is not None: + effort = spec.default_effort_value if effort is None or effort == "auto": return kwargs if effort in allowed: kwargs["reasoning_effort"] = effort - elif spec.default_effort is not None: - kwargs["reasoning_effort"] = spec.default_effort.value + elif spec.default_effort_value is not None: + kwargs["reasoning_effort"] = spec.default_effort_value return kwargs def _supports_preserve_thinking(self) -> bool: diff --git a/src/iac_code/providers/kimi_provider.py b/src/iac_code/providers/kimi_provider.py index 8123d6b2..174c9933 100644 --- a/src/iac_code/providers/kimi_provider.py +++ b/src/iac_code/providers/kimi_provider.py @@ -41,8 +41,11 @@ def _build_thinking_kwargs(self) -> dict[str, Any]: spec = get_thinking_spec(self._PROVIDER_KEY, self._model) if spec.family is not ThinkingFamily.KIMI: return {} - if self._model == "kimi-k3": + effort_models = {"kimi-k3", "k3", "k3-256k", "kimi-for-coding"} + if self._model in effort_models: if self._thinking_disabled(): + if self._PROVIDER_KEY == "kimi_code": + return {"extra_body": {"thinking": {"type": "disabled"}}} return {} effort = self._effective_effort(spec) if effort is None: @@ -53,7 +56,7 @@ def _build_thinking_kwargs(self) -> dict[str, Any]: return {} effort = spec.default_effort.value return {"reasoning_effort": effort} - if self._model in {"kimi-k2.7-code", "kimi-k2.7-code-highspeed"}: + if self._model in {"kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-for-coding-highspeed"}: # K2.7 thinking is always on. Sending type=disabled is rejected; # omit the switch and let the model keep all reasoning history. return {} diff --git a/src/iac_code/providers/manager.py b/src/iac_code/providers/manager.py index 8cc59205..15281808 100644 --- a/src/iac_code/providers/manager.py +++ b/src/iac_code/providers/manager.py @@ -135,6 +135,7 @@ def _is_retryable_provider_error(exc: BaseException) -> bool: } ) + class _BestEffortSpan: def __init__(self, span: Any | None = None) -> None: self._span = span @@ -400,18 +401,36 @@ def _error_event_from_exception(exc: BaseException) -> ErrorEvent: } _MODEL_REFUSAL_FALLBACK_MAP = { + "claude-fable-5-1": "claude-opus-5", "claude-fable-5": "claude-opus-4-8", "claude-opus-5": "claude-opus-4-8", } _PROVIDER_MODEL_FALLBACK_MAP = { "dashscope": { + "qwen3.8-max-0902": "qwen3.8-max", "qwen3.8-flash": "qwen3.7-flash", + "deepseek-v4.1-flash": "qwen3.8-flash", + "ZHIPU/GLM-5.3-Flash": "ZHIPU/GLM-5.3", "qwen3.6-plus": "qwen3.6-flash", }, "dashscope_token_plan": { "qwen3.8-flash": "qwen3.6-flash", + # Keep retry compatibility for sessions saved before qwen3.6-plus + # left the current Token Plan selector catalog. "qwen3.6-plus": "qwen3.6-flash", + "deepseek-v4.1-flash": "qwen3.8-flash", + "deepseek-v4-pro": "deepseek-v4-flash-0731", + "deepseek-v4-flash-0731": "qwen3.6-flash", + "glm-5.2": "qwen3.6-flash", + }, + "anthropic": {"claude-fable-5-1": "claude-fable-5"}, + "gemini": {"gemini-3.8-flash": "gemini-3.7-flash"}, + "openai": {"gpt-6-astra": "gpt-5.6-sol"}, + "kimi_code": { + "k3": "kimi-for-coding", + "k3-256k": "kimi-for-coding", + "kimi-for-coding-highspeed": "kimi-for-coding", }, "aliyun_codingplan": {"qwen3.6-plus": "qwen3.5-plus"}, "aliyun_codingplan_intl": {"qwen3.6-plus": "qwen3.5-plus"}, @@ -440,7 +459,7 @@ def _normalize_configured_effort( from iac_code.providers.thinking import get_thinking_spec spec = get_thinking_spec(provider_key, model) - if any(item.value == normalized_effort for item in spec.allowed_efforts): + if normalized_effort in spec.effort_values: return normalized_effort, thinking_enabled return None, False @@ -814,9 +833,7 @@ def _telemetry_provider_name(provider: Any) -> str: def _provider_telemetry_attrs(provider: Any) -> dict[str, str | bool]: attrs: dict[str, str | bool] = { - IacCodeAttr.OFFICIAL_ENDPOINT: official_dashscope_wire_provider_key( - _provider_endpoint_url(provider) - ) + IacCodeAttr.OFFICIAL_ENDPOINT: official_dashscope_wire_provider_key(_provider_endpoint_url(provider)) is not None, } adapter_name = _string_provider_attr(provider, "_ADAPTER_NAME") @@ -1790,9 +1807,7 @@ def commit_replay_failure( yield terminal_event return yield replay_event - raise UnsafeStreamProtocolError( - "Qwen replay ended before message completion." - ) + raise UnsafeStreamProtocolError("Qwen replay ended before message completion.") except UnsafeStreamProtocolError as exc: last_unsafe_error = exc commit_replay_failure(exc) diff --git a/src/iac_code/providers/openai_provider.py b/src/iac_code/providers/openai_provider.py index 4bba75e0..13cf2f93 100644 --- a/src/iac_code/providers/openai_provider.py +++ b/src/iac_code/providers/openai_provider.py @@ -143,11 +143,11 @@ def _build_openai_compatible_thinking_kwargs(self) -> dict[str, Any]: kwargs: dict[str, Any] = {"extra_body": extra_body} if spec.uses_reasoning_effort_param: effort = normalize_effort(self._effort) - allowed = {e.value for e in spec.allowed_efforts} + allowed = set(spec.effort_values) if effort in allowed: kwargs["reasoning_effort"] = effort - elif effort not in {None, "auto"} and spec.default_effort is not None: - kwargs["reasoning_effort"] = spec.default_effort.value + elif effort not in {None, "auto"} and spec.default_effort_value is not None: + kwargs["reasoning_effort"] = spec.default_effort_value return kwargs def _effective_thinking_budget(self) -> int | None: @@ -198,9 +198,7 @@ def _extract_reasoning_text(self, message_or_delta: Any) -> str: reasoning = getattr(message_or_delta, "reasoning_content", None) return reasoning if isinstance(reasoning, str) else "" - def _create_stream_response_adapter( - self, tools: list[ToolDefinition] | None - ) -> OpenAIStreamResponseAdapter: + def _create_stream_response_adapter(self, tools: list[ToolDefinition] | None) -> OpenAIStreamResponseAdapter: return OpenAIStreamResponseAdapter(self, tools) # -- Message conversion ---------------------------------------------------- diff --git a/src/iac_code/providers/registry.py b/src/iac_code/providers/registry.py index f1d519aa..bf2bf3d2 100644 --- a/src/iac_code/providers/registry.py +++ b/src/iac_code/providers/registry.py @@ -45,7 +45,8 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.dashscope_provider.DashScopeProvider", base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", models=[ - ModelEntry("qwen3.8-max", is_default=True, support_multimodal=True), + ModelEntry("qwen3.8-max-0902", is_default=True, support_multimodal=True), + ModelEntry("qwen3.8-max", support_multimodal=True), ModelEntry("qwen3.8-max-prime", support_multimodal=True), ModelEntry("qwen3.8-flash", support_multimodal=True), ModelEntry("qwen3.8-2.4t-a95b"), @@ -73,6 +74,7 @@ def model_ids(self) -> list[str]: ModelEntry("kimi-k2.7-code", support_multimodal=True), ModelEntry("kimi-k2.6", support_multimodal=True), ModelEntry("kimi-k2.5", support_multimodal=True), + ModelEntry("deepseek-v4.1-flash", support_multimodal=True), ModelEntry("deepseek-v4-pro"), ModelEntry("deepseek-v4-pro-0813"), ModelEntry("deepseek-v4-flash-0731"), @@ -81,7 +83,11 @@ def model_ids(self) -> list[str]: ModelEntry("glm-5.2"), ModelEntry("glm-5.1"), ModelEntry("ZHIPU/GLM-5.3"), + ModelEntry("ZHIPU/GLM-5.3-Flash"), ModelEntry("MiniMax/MiniMax-M3", support_multimodal=True), + ModelEntry("MiniMax/MiniMax-M2.7"), + ModelEntry("MiniMax/MiniMax-M2.5"), + ModelEntry("MiniMax/MiniMax-M2.1"), ModelEntry("MiniMax-M2.5"), ModelEntry("xiaomi/mimo-v2.5-pro"), ModelEntry("stepfun/step-3.7-flash", support_multimodal=True), @@ -99,20 +105,12 @@ def model_ids(self) -> list[str]: ModelEntry("qwen3.8-flash", support_multimodal=True), ModelEntry("qwen3.7-max"), ModelEntry("qwen3.7-plus", support_multimodal=True), - ModelEntry("qwen3.6-plus", support_multimodal=True), ModelEntry("qwen3.6-flash", support_multimodal=True), + ModelEntry("deepseek-v4.1-flash", support_multimodal=True), ModelEntry("deepseek-v4-pro"), ModelEntry("deepseek-v4-pro-0813"), ModelEntry("deepseek-v4-flash-0731"), - ModelEntry("deepseek-v4-flash"), - ModelEntry("deepseek-v3.2"), ModelEntry("glm-5.2"), - ModelEntry("glm-5.1"), - ModelEntry("glm-5"), - ModelEntry("MiniMax-M2.5"), - ModelEntry("kimi-k2.7-code", support_multimodal=True), - ModelEntry("kimi-k2.5", support_multimodal=True), - ModelEntry("kimi-k2.6", support_multimodal=True), ], qwenpaw_provider_ids=["aliyun-tokenplan"], ), @@ -123,6 +121,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-5.6-sol", is_default=True, support_multimodal=True), ModelEntry("gpt-5.6", support_multimodal=True), ModelEntry("gpt-5.6-terra", support_multimodal=True), @@ -145,7 +146,8 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.anthropic_provider.AnthropicProvider", base_url=None, models=[ - ModelEntry("claude-fable-5", is_default=True, support_multimodal=True), + ModelEntry("claude-fable-5-1", is_default=True, support_multimodal=True), + ModelEntry("claude-fable-5", support_multimodal=True), ModelEntry("claude-opus-5", support_multimodal=True), ModelEntry("claude-opus-4-8", support_multimodal=True), ModelEntry("claude-sonnet-5", support_multimodal=True), @@ -165,7 +167,9 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.deepseek_provider.DeepSeekProvider", base_url="https://api.deepseek.com/v1", models=[ - ModelEntry("deepseek-v4-pro", is_default=True), + ModelEntry("deepseek-flash", is_default=True, support_multimodal=True), + ModelEntry("deepseek-v4-pro"), + # Temporarily routed to V4.1 Flash by DeepSeek for compatibility. ModelEntry("deepseek-v4-flash"), ], qwenpaw_provider_ids=["deepseek"], @@ -194,7 +198,8 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.gemini_provider.GeminiProvider", base_url="https://generativelanguage.googleapis.com/v1beta/openai", models=[ - ModelEntry("gemini-3.7-flash", is_default=True, support_multimodal=True), + ModelEntry("gemini-3.8-flash", is_default=True, support_multimodal=True), + ModelEntry("gemini-3.7-flash", support_multimodal=True), ModelEntry("gemini-3.6-flash", support_multimodal=True), ModelEntry("gemini-3.5-flash", support_multimodal=True), ModelEntry("gemini-3.5-flash-lite", support_multimodal=True), @@ -220,7 +225,6 @@ def model_ids(self) -> list[str]: ModelEntry("kimi-k2.7-code", support_multimodal=True), ModelEntry("kimi-k2.7-code-highspeed", support_multimodal=True), ModelEntry("kimi-k2.6", support_multimodal=True), - ModelEntry("kimi-k2.5", support_multimodal=True), ], qwenpaw_provider_ids=["kimi-cn"], ), @@ -235,10 +239,23 @@ def model_ids(self) -> list[str]: ModelEntry("kimi-k2.7-code", support_multimodal=True), ModelEntry("kimi-k2.7-code-highspeed", support_multimodal=True), ModelEntry("kimi-k2.6", support_multimodal=True), - ModelEntry("kimi-k2.5", support_multimodal=True), ], qwenpaw_provider_ids=["kimi-intl"], ), + "kimi_code": ProviderDescriptor( + key="kimi_code", + name="Kimi Code", + display_name="Kimi Code", + provider_class="iac_code.providers.kimi_provider.KimiProvider", + base_url="https://api.kimi.com/coding/v1", + models=[ + ModelEntry("kimi-for-coding", is_default=True, support_multimodal=True), + ModelEntry("k3", support_multimodal=True), + ModelEntry("k3-256k", support_multimodal=True), + ModelEntry("kimi-for-coding-highspeed", support_multimodal=True), + ], + qwenpaw_provider_ids=["kimi-code"], + ), "minimax_cn": ProviderDescriptor( key="minimax_cn", name="MiniMax CN", @@ -507,7 +524,7 @@ def model_ids(self) -> list[str]: ("dashscope", "dashscope_token_plan", "aliyun_codingplan", "aliyun_codingplan_intl", "modelscope"), ), ("ZhiPu AI", ("zhipu_cn", "zhipu_intl", "zhipu_cn_codingplan", "zhipu_intl_codingplan")), - ("Kimi", ("kimi_cn", "kimi_intl")), + ("Kimi", ("kimi_code", "kimi_cn", "kimi_intl")), ("MiniMax", ("minimax_cn", "minimax_intl")), ("Volcengine", ("volcengine_cn", "volcengine_cn_codingplan")), ("SiliconFlow", ("siliconflow_cn", "siliconflow_intl")), @@ -534,6 +551,7 @@ def model_ids(self) -> list[str]: _("Google Gemini"), _("Kimi (China)"), _("Kimi (International)"), + _("Kimi Code"), _("MiniMax (China)"), _("MiniMax (International)"), _("ZhiPu AI"), diff --git a/src/iac_code/providers/thinking.py b/src/iac_code/providers/thinking.py index f70d3f13..b068caab 100644 --- a/src/iac_code/providers/thinking.py +++ b/src/iac_code/providers/thinking.py @@ -24,6 +24,7 @@ class EffortLevel(Enum): HIGH = "high" XHIGH = "xhigh" MAX = "max" + ULTRA = "ultra" NONE = "none" AUTO = "auto" @@ -36,6 +37,7 @@ class EffortLevel(Enum): EffortLevel.HIGH, EffortLevel.XHIGH, EffortLevel.MAX, + EffortLevel.ULTRA, EffortLevel.AUTO, ] @@ -48,6 +50,7 @@ class EffortLevel(Enum): EffortLevel.HIGH: "◆◆◆", EffortLevel.XHIGH: "◆◆◆◆", EffortLevel.MAX: "◆◆◆◆◆", + EffortLevel.ULTRA: "◆◆◆◆◆◆", EffortLevel.AUTO: "◆", } @@ -84,6 +87,15 @@ class ThinkingSpec: def supports_effort(self) -> bool: return bool(self.allowed_efforts) + @property + def effort_values(self) -> tuple[str, ...]: + """Return every user-selectable effort as its persisted string value.""" + return tuple(effort.value for effort in self.allowed_efforts) + + @property + def default_effort_value(self) -> str | None: + return self.default_effort.value if self.default_effort is not None else None + @property def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: if not self.allowed_efforts: @@ -113,6 +125,14 @@ def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: EffortLevel.MAX, ) +_OPENAI_GPT6_EFFORTS: tuple[EffortLevel, ...] = ( + EffortLevel.LOW, + EffortLevel.MEDIUM, + EffortLevel.HIGH, + EffortLevel.XHIGH, + EffortLevel.MAX, +) + _OPENAI_CODEX_EFFORTS: tuple[EffortLevel, ...] = ( EffortLevel.LOW, EffortLevel.MEDIUM, @@ -184,6 +204,18 @@ def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: EffortLevel.MAX, ) +# DeepSeek V4.1 Flash exposes Bailian's complete semantic reasoning-effort +# vocabulary. The endpoint rejects numeric values such as "1" and "100". +_DASHSCOPE_DEEPSEEK_V41_EFFORTS: tuple[EffortLevel, ...] = ( + EffortLevel.MINIMAL, + EffortLevel.LOW, + EffortLevel.MEDIUM, + EffortLevel.HIGH, + EffortLevel.XHIGH, + EffortLevel.MAX, + EffortLevel.ULTRA, +) + _GLM_EFFORTS: tuple[EffortLevel, ...] = ( EffortLevel.NONE, EffortLevel.MINIMAL, @@ -322,6 +354,19 @@ def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: adaptive_always_on=True, supports_disable=False, ) +_ANTHROPIC_FABLE51_SPEC = ThinkingSpec( + ThinkingFamily.ANTHROPIC_ADAPTIVE, + ( + EffortLevel.LOW, + EffortLevel.MEDIUM, + EffortLevel.HIGH, + EffortLevel.XHIGH, + EffortLevel.MAX, + ), + EffortLevel.HIGH, + adaptive_always_on=True, + supports_disable=False, +) _OPENAI_GPT56_SPEC = ThinkingSpec(ThinkingFamily.OPENAI, _OPENAI_GPT56_EFFORTS, EffortLevel.MEDIUM) _OPENAI_GPT55_SPEC = ThinkingSpec(ThinkingFamily.OPENAI, _OPENAI_EFFORTS, EffortLevel.MEDIUM) @@ -334,7 +379,35 @@ def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: EffortLevel.HIGH, thinking_enabled_by_default=True, ) +_DASHSCOPE_DEEPSEEK_V41_SPEC = ThinkingSpec( + ThinkingFamily.DASHSCOPE, + _DASHSCOPE_DEEPSEEK_V41_EFFORTS, + uses_reasoning_effort_param=True, + thinking_enabled_by_default=True, +) _KIMI_K3_SPEC = ThinkingSpec(ThinkingFamily.KIMI, _KIMI_K3_EFFORTS, EffortLevel.MAX) +_KIMI_CODE_K3_SPEC = ThinkingSpec( + ThinkingFamily.KIMI, + _KIMI_K3_EFFORTS, + EffortLevel.HIGH, + thinking_enabled_by_default=True, +) +_KIMI_CODE_K28_SPEC = ThinkingSpec( + ThinkingFamily.KIMI, + _KIMI_K3_EFFORTS, + EffortLevel.MAX, + thinking_enabled_by_default=True, +) +_KIMI_ALWAYS_ON_SPEC = ThinkingSpec( + ThinkingFamily.KIMI, + supports_disable=False, + thinking_enabled_by_default=True, +) +_MINIMAX_ALWAYS_ON_SPEC = ThinkingSpec( + ThinkingFamily.MINIMAX, + supports_disable=False, + thinking_enabled_by_default=True, +) _ZHIPU_GLM52_SPEC = ThinkingSpec( ThinkingFamily.ZHIPU, _ZHIPU_GLM52_EFFORTS, @@ -353,6 +426,7 @@ def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: MODEL_THINKING: dict[str, dict[str, ThinkingSpec]] = { "anthropic": { + "claude-fable-5-1": _ANTHROPIC_FABLE51_SPEC, "claude-fable-5": _ANTHROPIC_ADAPTIVE_ALWAYS_ON_SPEC, "claude-opus-5": _ANTHROPIC_OPUS5_SPEC, "claude-sonnet-5": _ANTHROPIC_ADAPTIVE_SPEC, @@ -369,6 +443,7 @@ def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: ), }, "openai": { + "gpt-6-astra": ThinkingSpec(ThinkingFamily.OPENAI, _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, @@ -384,11 +459,13 @@ def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: "o4-mini": _OPENAI_O_SERIES_SPEC, }, "deepseek": { + "deepseek-flash": _DEEPSEEK_SPEC, "deepseek-v4-pro": _DEEPSEEK_SPEC, "deepseek-v4-flash": _DEEPSEEK_SPEC, }, "dashscope": { "qwen3.8-max": _DASHSCOPE_QWEN38_SPEC, + "qwen3.8-max-0902": _DASHSCOPE_QWEN38_SPEC, "qwen3.8-max-prime": _DASHSCOPE_QWEN38_SPEC, "qwen3.8-flash": _DASHSCOPE_QWEN_HYBRID_SPEC, "qwen3.8-2.4t-a95b": _DASHSCOPE_QWEN_HYBRID_SPEC, @@ -415,8 +492,12 @@ def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: "glm-5.2": _DASHSCOPE_GLM52_SPEC, "glm-5.1": _DASHSCOPE_GLM51_SPEC, "ZHIPU/GLM-5.3": _DASHSCOPE_ZHIPU_GLM53_SPEC, + "ZHIPU/GLM-5.3-Flash": _DASHSCOPE_ZHIPU_GLM53_SPEC, "MiniMax-M2.5": ThinkingSpec(ThinkingFamily.DASHSCOPE), "MiniMax/MiniMax-M3": ThinkingSpec(ThinkingFamily.MINIMAX), + "MiniMax/MiniMax-M2.7": _MINIMAX_ALWAYS_ON_SPEC, + "MiniMax/MiniMax-M2.5": _MINIMAX_ALWAYS_ON_SPEC, + "MiniMax/MiniMax-M2.1": _MINIMAX_ALWAYS_ON_SPEC, "xiaomi/mimo-v2.5-pro": ThinkingSpec( ThinkingFamily.DASHSCOPE, thinking_enabled_by_default=True, @@ -446,6 +527,7 @@ def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: EffortLevel.HIGH, uses_reasoning_effort_param=True, ), + "deepseek-v4.1-flash": _DASHSCOPE_DEEPSEEK_V41_SPEC, }, "dashscope_token_plan": { "qwen3.8-max": _DASHSCOPE_QWEN38_SPEC, @@ -459,6 +541,7 @@ def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: "deepseek-v4-pro-0813": ThinkingSpec(ThinkingFamily.DASHSCOPE, _DEEPSEEK_EFFORTS, EffortLevel.HIGH), "deepseek-v4-flash-0731": ThinkingSpec(ThinkingFamily.DASHSCOPE, _DEEPSEEK_EFFORTS, EffortLevel.HIGH), "deepseek-v4-flash": ThinkingSpec(ThinkingFamily.DASHSCOPE, _DEEPSEEK_EFFORTS, EffortLevel.HIGH), + "deepseek-v4.1-flash": _DASHSCOPE_DEEPSEEK_V41_SPEC, "deepseek-v3.2": ThinkingSpec(ThinkingFamily.DASHSCOPE), "glm-5.1": _DASHSCOPE_GLM51_SPEC, "glm-5": _DASHSCOPE_GLM51_SPEC, @@ -469,6 +552,12 @@ def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: "glm-5.2": _DASHSCOPE_GLM52_SPEC, }, "gemini": { + "gemini-3.8-flash": ThinkingSpec( + ThinkingFamily.GEMINI, + _GEMINI_37_EFFORTS, + EffortLevel.MEDIUM, + supports_disable=False, + ), "gemini-3.7-flash": ThinkingSpec( ThinkingFamily.GEMINI, _GEMINI_37_EFFORTS, @@ -538,8 +627,14 @@ def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: "kimi-k3": _KIMI_K3_SPEC, "kimi-k2.6": ThinkingSpec(ThinkingFamily.KIMI), "kimi-k2.5": ThinkingSpec(ThinkingFamily.KIMI), - "kimi-k2.7-code": ThinkingSpec(ThinkingFamily.KIMI), - "kimi-k2.7-code-highspeed": ThinkingSpec(ThinkingFamily.KIMI), + "kimi-k2.7-code": _KIMI_ALWAYS_ON_SPEC, + "kimi-k2.7-code-highspeed": _KIMI_ALWAYS_ON_SPEC, + }, + "kimi_code": { + "k3": _KIMI_CODE_K3_SPEC, + "k3-256k": _KIMI_CODE_K3_SPEC, + "kimi-for-coding": _KIMI_CODE_K28_SPEC, + "kimi-for-coding-highspeed": _KIMI_ALWAYS_ON_SPEC, }, "minimax_cn": { "MiniMax-M3": ThinkingSpec(ThinkingFamily.MINIMAX), diff --git a/src/iac_code/services/context_manager.py b/src/iac_code/services/context_manager.py index f795ded5..9ee3653c 100644 --- a/src/iac_code/services/context_manager.py +++ b/src/iac_code/services/context_manager.py @@ -49,17 +49,20 @@ def _context_config(context_window: int, max_output_tokens: int = 8_192) -> Cont _MODEL_EXACT_CONFIGS: dict[str, ContextWindowConfig] = { + "claude-fable-5-1": _context_config(1_000_000, 128_000), "claude-fable-5": _context_config(1_000_000, 128_000), "claude-opus-5": _context_config(1_000_000, 128_000), "claude-opus-4-8": _context_config(1_000_000, 128_000), "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-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), "gpt-5.4-nano": _context_config(400_000, 128_000), "gpt-5.3-codex": _context_config(400_000, 128_000), "gpt-5.2": _context_config(400_000, 128_000), + "gemini-3.8-flash": _context_config(1_048_576, 65_536), "gemini-3.7-flash": _context_config(1_048_576, 65_536), "gemini-3.6-flash": _context_config(1_048_576, 65_536), "gemini-3.5-flash": _context_config(1_048_576, 65_536), @@ -82,9 +85,14 @@ def _context_config(context_window: int, max_output_tokens: int = 8_192) -> Cont "kimi-k2.7-code-highspeed": _context_config(262_144), "kimi-k2.6": _context_config(262_144), "kimi-k2.5": _context_config(262_144), - "qwen3.8-max": _context_config(1_000_000), - "qwen3.8-max-prime": _context_config(1_000_000), - "qwen3.8-flash": _context_config(1_000_000), + "k3": _context_config(1_048_576), + "k3-256k": _context_config(262_144), + "kimi-for-coding": _context_config(1_048_576), + "kimi-for-coding-highspeed": _context_config(262_144), + "qwen3.8-max": _context_config(1_000_000, 128_000), + "qwen3.8-max-0902": _context_config(1_000_000, 128_000), + "qwen3.8-max-prime": _context_config(1_000_000, 128_000), + "qwen3.8-flash": _context_config(1_000_000, 128_000), "qwen3.8-2.4t-a95b": _context_config(1_000_000, 131_072), "qwen3.8-27b": _context_config(1_000_000, 131_072), "qwen3.8-max-preview": _context_config(1_000_000), @@ -103,19 +111,25 @@ def _context_config(context_window: int, max_output_tokens: int = 8_192) -> Cont "qwen3.6-max-preview": _context_config(262_144), "qwen3-max": _context_config(262_144), "qwen3-coder-next": _context_config(262_144), + "deepseek-flash": _context_config(1_000_000, 393_216), "deepseek-v4-pro": _context_config(1_000_000, 393_216), "deepseek-v4-pro-0813": _context_config(1_000_000, 393_216), "deepseek-v4-flash-0731": _context_config(1_000_000, 393_216), "deepseek-v4-flash": _context_config(1_000_000, 393_216), + "deepseek-v4.1-flash": _context_config(1_000_000, 393_216), "glm-5.3": _context_config(1_000_000, 128_000), "glm-5.3-flash": _context_config(1_000_000, 128_000), "zhipu/glm-5.3": _context_config(1_048_576, 131_072), + "zhipu/glm-5.3-flash": _context_config(1_048_576, 131_072), "glm-5.2-fast-preview": _context_config(1_048_576, 131_072), "glm-5.2": _context_config(1_000_000, 128_000), "glm-5.1": _context_config(202_752), "glm-5": _context_config(202_752), "minimax-m3": _context_config(1_000_000), "minimax/minimax-m3": _context_config(196_608), + "minimax/minimax-m2.7": _context_config(196_608), + "minimax/minimax-m2.5": _context_config(196_608), + "minimax/minimax-m2.1": _context_config(196_608), "minimax-m2.7": _context_config(196_608), "minimax-m2.7-highspeed": _context_config(196_608), "minimax-m2.5": _context_config(196_608), diff --git a/src/iac_code/services/telemetry/constants.py b/src/iac_code/services/telemetry/constants.py index a52f1913..57461ece 100644 --- a/src/iac_code/services/telemetry/constants.py +++ b/src/iac_code/services/telemetry/constants.py @@ -43,6 +43,7 @@ KNOWN_MODELS: frozenset[str] = frozenset( { # Anthropic + "claude-fable-5-1", "claude-fable-5", "claude-opus-5", "claude-opus-4-8", @@ -55,6 +56,7 @@ "claude-sonnet-4-6-1m", "claude-haiku-4-5-20251001", # OpenAI + "gpt-6-astra", "gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", @@ -79,6 +81,7 @@ "o4-mini", # Dashscope / Qwen "qwen3.8-max", + "qwen3.8-max-0902", "qwen3.8-max-prime", "qwen3.8-flash", "qwen3.8-2.4t-a95b", @@ -113,9 +116,14 @@ "kimi-k2.6", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", + "k3", + "k3-256k", + "kimi-for-coding", + "kimi-for-coding-highspeed", "glm-5.3", "glm-5.3-flash", "ZHIPU/GLM-5.3", + "ZHIPU/GLM-5.3-Flash", "xiaomi/mimo-v2.5-pro", "stepfun/step-3.7-flash", "glm-5.2-fast-preview", @@ -132,6 +140,7 @@ "glm-4.5-airx", "glm-4.5-flash", "glm-4.5-x", + "gemini-3.8-flash", "gemini-3.7-flash", "gemini-3.6-flash", "gemini-3.5-flash", @@ -144,12 +153,17 @@ "gemini-2.5-flash", "gemini-2.5-flash-lite", # DeepSeek / MiniMax / Volcengine + "deepseek-flash", "deepseek-v4-pro", "deepseek-v4-pro-0813", "deepseek-v4-flash-0731", "deepseek-v4-flash", + "deepseek-v4.1-flash", "deepseek-v3.2", "MiniMax/MiniMax-M3", + "MiniMax/MiniMax-M2.7", + "MiniMax/MiniMax-M2.5", + "MiniMax/MiniMax-M2.1", "MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed", diff --git a/src/iac_code/ui/dialogs/model_picker.py b/src/iac_code/ui/dialogs/model_picker.py index 10ec7d0b..825ea9ad 100644 --- a/src/iac_code/ui/dialogs/model_picker.py +++ b/src/iac_code/ui/dialogs/model_picker.py @@ -242,6 +242,10 @@ def _cycle_effort(self, pair: tuple[str, str], direction: int) -> None: return allowed = spec.allowed_efforts + # Numeric ranges use the dedicated /effort selector; this legacy model + # picker only renders symbolic EffortLevel values. + if not allowed: + return current = self._efforts.get(pair) if current is None: new_idx = 0 if direction >= 0 else len(allowed) - 1 diff --git a/src/iac_code/web/session_manager.py b/src/iac_code/web/session_manager.py index dc27e8aa..67c0aa89 100644 --- a/src/iac_code/web/session_manager.py +++ b/src/iac_code/web/session_manager.py @@ -2936,7 +2936,7 @@ def set_session_model( from iac_code.providers.thinking import get_thinking_spec, normalize_effort normalized = normalize_effort(normalized_effort) - allowed = {item.value for item in get_thinking_spec(provider, model).allowed_efforts} + allowed = set(get_thinking_spec(provider, model).effort_values) if normalized is None or normalized not in allowed: raise ValueError(_("unknown effort")) normalized_effort = normalized diff --git a/src/iac_code/web/session_titler.py b/src/iac_code/web/session_titler.py index 47682374..f719c6f5 100644 --- a/src/iac_code/web/session_titler.py +++ b/src/iac_code/web/session_titler.py @@ -43,9 +43,9 @@ def _title_effort_override(provider_key: str | None, model: str) -> str: if not provider_key: return "none" spec = get_thinking_spec(provider_key, model) - if spec.supports_disable or not spec.allowed_efforts: + if spec.supports_disable or not spec.effort_values: return "none" - return spec.allowed_efforts[0].value + return spec.effort_values[0] def _build_messages(text: str | None, image_blocks: list[ContentBlock]) -> list[Message]: diff --git a/src/iac_code/web/settings.py b/src/iac_code/web/settings.py index a699b2a2..2334c846 100644 --- a/src/iac_code/web/settings.py +++ b/src/iac_code/web/settings.py @@ -878,8 +878,8 @@ def _saved(key: str) -> int | None: "id": model.id, "default": model.is_default, "supportsMultimodal": model.support_multimodal, - "efforts": [effort.value for effort in thinking.allowed_efforts], - "defaultEffort": thinking.default_effort.value if thinking.default_effort is not None else None, + "efforts": list(thinking.effort_values), + "defaultEffort": thinking.default_effort_value, # 无会话级覆盖时该模型是否默认思考(家族相关):新会话草稿据此点亮「思考」按钮。 "thinkingDefault": resolve_thinking_active(provider_key, model.id, None), # 「思考预算」字段仅对支持独立预算的模型可见(能力门控);其余家族走 effort 推导。 @@ -903,7 +903,7 @@ def _validate_effort(provider_key: str, model: str, effort: str) -> None: if normalized is None: raise ValueError(_("unknown effort")) thinking = get_thinking_spec(provider_key, model) - allowed = {item.value for item in thinking.allowed_efforts} + allowed = set(thinking.effort_values) # 无已知推理强度规格(手动输入模型/兼容模式等)时放行任意合法强度, # 支持前端组合框的自由输入;有规格时仍强制校验。 if allowed and normalized not in allowed: diff --git a/src/iac_code/web/static/index.html b/src/iac_code/web/static/index.html index d908abe3..90c69ed0 100644 --- a/src/iac_code/web/static/index.html +++ b/src/iac_code/web/static/index.html @@ -479,6 +479,6 @@

- + diff --git a/src/iac_code/web/static/js/app.js b/src/iac_code/web/static/js/app.js index a9b925ec..5c1dca41 100644 --- a/src/iac_code/web/static/js/app.js +++ b/src/iac_code/web/static/js/app.js @@ -1,5 +1,5 @@ import * as api from "./api.js?v=web-repl-ui-313"; -import { createComposerController } from "./components/composer.js?v=session-model-v20"; +import { createComposerController } from "./components/composer.js?v=session-model-v22"; import { renderBlockingPanels } from "./components/blocking.js?v=blocking-keys-v5"; import { deploymentConfirmationKey, diff --git a/src/iac_code/web/static/js/components/composer.js b/src/iac_code/web/static/js/components/composer.js index 20d7d5a4..446c4a24 100644 --- a/src/iac_code/web/static/js/components/composer.js +++ b/src/iac_code/web/static/js/components/composer.js @@ -427,6 +427,8 @@ function effortItems(model) { return Array.isArray(model?.efforts) ? model.efforts : []; } +const LONG_EFFORT_MENU_THRESHOLD = 12; + function activeProviderSummary(active = {}) { return { provider: text(active.provider), @@ -469,6 +471,7 @@ function effortLabel(effort) { high: t("High"), xhigh: t("Very high"), max: t("Max"), + ultra: t("Ultra"), auto: t("Auto"), }[text(effort)] || text(effort) ); @@ -1036,7 +1039,8 @@ export function createComposerController(elements = {}, api = {}, options = {}) submenu.className = "composer-model-submenu"; submenu.setAttribute("data-composer-submenu", kind); submenu.hidden = activeSubmenu !== kind; - submenu.append(makeComposerMenuHeading(kind === "model" ? t("Model") : t("Provider")), ...buttons); + const heading = kind === "model" ? t("Model") : kind === "effort" ? t("Reasoning effort") : t("Provider"); + submenu.append(makeComposerMenuHeading(heading), ...buttons); return submenu; } @@ -1167,7 +1171,17 @@ export function createComposerController(elements = {}, api = {}, options = {}) modelMenu.append(makeComposerMenuHeading(t("Reasoning"))); if (effortButtons.length > 0) { - modelMenu.append(...effortButtons); + if (effortButtons.length > LONG_EFFORT_MENU_THRESHOLD) { + modelMenu.append( + makeComposerSubmenuTrigger({ + kind: "effort", + label: effortLabel(selectedEffort()) || t("Reasoning effort"), + active: activeSubmenu === "effort", + }), + ); + } else { + modelMenu.append(...effortButtons); + } } else { modelMenu.append(makeComposerDisabledMenuItem(t("Not supported"))); } @@ -1190,6 +1204,9 @@ export function createComposerController(elements = {}, api = {}, options = {}) if (activeSubmenu === "provider") { modelMenu.append(makeComposerSubmenu("provider", providerButtons)); } + if (activeSubmenu === "effort") { + modelMenu.append(makeComposerSubmenu("effort", effortButtons)); + } } function renderProviderControls() { diff --git a/tests/commands/test_auth_basics.py b/tests/commands/test_auth_basics.py index 4182b14f..c37ce7d5 100644 --- a/tests/commands/test_auth_basics.py +++ b/tests/commands/test_auth_basics.py @@ -48,15 +48,23 @@ def test_dashscope_token_plan_entry_present(self): assert entry["display_name"] == "Alibaba Cloud Bailian Token Plan" assert entry["api_base"] == ("https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1") assert entry["default_model"] == "qwen3.8-max" - models = set(entry["models"]) - for expected in ( + assert entry["models"] == [ "qwen3.8-max", - "deepseek-v4-pro-0813", + "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", - "qwen3.6-plus", - "deepseek-v3.2", - "glm-5", - "MiniMax-M2.5", - ): - assert expected in models + "qwen3.6-flash", + "deepseek-v4.1-flash", + "deepseek-v4-pro", + "deepseek-v4-pro-0813", + "deepseek-v4-flash-0731", + "glm-5.2", + ] + + def test_kimi_code_entry_exposes_k28_and_effort_models(self): + entry = next(p for p in PROVIDERS if p["key_name"] == "kimi_code") + + assert entry["display_name"] == "Kimi Code" + assert entry["api_base"] == "https://api.kimi.com/coding/v1" + assert entry["default_model"] == "kimi-for-coding" + assert entry["models"] == ["kimi-for-coding", "k3", "k3-256k", "kimi-for-coding-highspeed"] diff --git a/tests/commands/test_effort.py b/tests/commands/test_effort.py index 71967858..381bcb5e 100644 --- a/tests/commands/test_effort.py +++ b/tests/commands/test_effort.py @@ -5,6 +5,7 @@ import pytest from iac_code.commands.effort import effort_command +from iac_code.providers.thinking import EffortLevel @pytest.mark.asyncio @@ -78,6 +79,119 @@ async def test_effort_no_console_shows_current(monkeypatch): assert "max" in result +@pytest.mark.asyncio +@pytest.mark.parametrize("effort", ["minimal", "ultra"]) +async def test_deepseek_v41_accepts_documented_effort_endpoints(monkeypatch, effort): + save = MagicMock() + monkeypatch.setattr("iac_code.commands.effort.get_active_provider_key", lambda: "dashscope") + monkeypatch.setattr("iac_code.commands.effort.get_provider_config", lambda key: {}) + monkeypatch.setattr("iac_code.commands.effort.save_active_provider_config", save) + + store = MagicMock() + store.get_state.return_value = MagicMock(model="deepseek-v4.1-flash") + + result = await effort_command(context=None, args=[effort], store=store) + + assert effort in result + assert save.call_args.kwargs["effort"] == effort + assert store.set_state.call_args.kwargs["effort_level"] is EffortLevel(effort) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("effort", ["1", "100", "ultra-plus"]) +async def test_deepseek_v41_rejects_unsupported_effort_values(monkeypatch, effort): + monkeypatch.setattr("iac_code.commands.effort.get_active_provider_key", lambda: "dashscope") + monkeypatch.setattr("iac_code.commands.effort.get_provider_config", lambda key: {}) + + store = MagicMock() + store.get_state.return_value = MagicMock(model="deepseek-v4.1-flash") + + result = await effort_command(context=None, args=[effort], store=store) + + assert "Invalid effort" in result + assert "minimal" in result + assert "ultra" in result + store.set_state.assert_not_called() + + +@pytest.mark.asyncio +async def test_deepseek_v41_interactive_effort_lists_documented_values(monkeypatch): + captured = {} + + def select_effort(title, options, default_index=0): + captured["title"] = title + captured["options"] = options + captured["default_index"] = default_index + return 6 + + save = MagicMock() + monkeypatch.setattr("iac_code.commands.effort.get_active_provider_key", lambda: "dashscope") + monkeypatch.setattr("iac_code.commands.effort.get_provider_config", lambda key: {}) + monkeypatch.setattr("iac_code.commands.effort.save_active_provider_config", save) + monkeypatch.setattr("iac_code.commands.effort._select", select_effort) + + store = MagicMock() + store.get_state.return_value = MagicMock(model="deepseek-v4.1-flash") + console = MagicMock() + context = MagicMock(store=store, console=console) + + result = await effort_command(context=context, args=[]) + + assert result.endswith("ultra") + assert [option.rsplit(" ", 1)[-1] for option in captured["options"]] == [ + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", + ] + assert captured["default_index"] == 0 + assert save.call_args.kwargs["effort"] == "ultra" + + +@pytest.mark.asyncio +async def test_kimi_code_interactive_effort_lists_exact_supported_values(monkeypatch): + captured = {} + + def select_effort(title, options, default_index=0): + captured["options"] = options + captured["default_index"] = default_index + return 0 + + save = MagicMock() + monkeypatch.setattr("iac_code.commands.effort.get_active_provider_key", lambda: "kimi_code") + monkeypatch.setattr("iac_code.commands.effort.get_provider_config", lambda key: {}) + monkeypatch.setattr("iac_code.commands.effort.save_active_provider_config", save) + monkeypatch.setattr("iac_code.commands.effort._select", select_effort) + + store = MagicMock() + store.get_state.return_value = MagicMock(model="kimi-for-coding") + context = MagicMock(store=store, console=MagicMock()) + + result = await effort_command(context=context, args=[]) + + assert [option.rsplit(" ", 1)[-1] for option in captured["options"]] == ["low", "high", "max"] + assert captured["default_index"] == 2 + assert result.endswith("low") + assert save.call_args.kwargs["effort"] == "low" + + +@pytest.mark.asyncio +async def test_kimi_code_rejects_server_aliases_from_user_effort_list(monkeypatch): + monkeypatch.setattr("iac_code.commands.effort.get_active_provider_key", lambda: "kimi_code") + monkeypatch.setattr("iac_code.commands.effort.get_provider_config", lambda key: {}) + store = MagicMock() + store.get_state.return_value = MagicMock(model="kimi-for-coding") + + result = await effort_command(context=None, args=["medium"], store=store) + + assert "Invalid effort" in result + assert "low" in result and "high" in result and "max" in result + store.set_state.assert_not_called() + + class TestEffortPerProviderRouting: @pytest.mark.asyncio async def test_bailian_qwen_reports_unsupported(self, tmp_path, monkeypatch): diff --git a/tests/providers/test_dashscope_provider.py b/tests/providers/test_dashscope_provider.py index 41f6ebea..d0208dec 100644 --- a/tests/providers/test_dashscope_provider.py +++ b/tests/providers/test_dashscope_provider.py @@ -103,15 +103,11 @@ def test_kimi_k3_preserves_thinking_without_enable_flag(self): def test_bailian_hosted_kimi_k3_keeps_always_on_thinking(self): p = DashScopeProvider(model="kimi-k3", api_key="k", thinking_enabled=False) - assert p._build_thinking_kwargs() == { - "extra_body": {"enable_thinking": True, "preserve_thinking": True} - } + assert p._build_thinking_kwargs() == {"extra_body": {"enable_thinking": True, "preserve_thinking": True}} def test_qwen38_open_model_supports_thinking_budget(self): p = DashScopeProvider(model="qwen3.8-2.4t-a95b", api_key="k", thinking_budget=2048) - assert p._build_thinking_kwargs() == { - "extra_body": {"enable_thinking": True, "thinking_budget": 2048} - } + assert p._build_thinking_kwargs() == {"extra_body": {"enable_thinking": True, "thinking_budget": 2048}} def test_stepfun_uses_its_documented_effort_values(self): p = DashScopeProvider(model="stepfun/step-3.7-flash", api_key="k", effort="medium") @@ -175,6 +171,26 @@ def test_bailian_deepseek_emits_enable_thinking_and_reasoning_effort(self, model "reasoning_effort": "xhigh", } + @pytest.mark.parametrize("provider_key", ["dashscope", "dashscope_token_plan"]) + @pytest.mark.parametrize("effort", ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]) + def test_deepseek_v41_flash_forwards_documented_effort(self, provider_key, effort): + provider = DashScopeProvider( + model="deepseek-v4.1-flash", + api_key="k", + provider_key=provider_key, + effort=effort, + ) + + assert provider._build_thinking_kwargs() == { + "extra_body": {"enable_thinking": True}, + "reasoning_effort": effort, + } + + def test_deepseek_v41_flash_omits_unsupported_effort(self): + provider = DashScopeProvider(model="deepseek-v4.1-flash", api_key="k", effort="100") + + assert provider._build_thinking_kwargs() == {"extra_body": {"enable_thinking": True}} + def test_unknown_model_returns_empty(self): p = DashScopeProvider(model="not-real", api_key="k") assert p._build_thinking_kwargs() == {} @@ -186,6 +202,24 @@ def test_effort_request_kwargs_delegates(self): @pytest.mark.asyncio class TestDashScopeThinkingBudgetRequestPolicy: + async def test_deepseek_v41_stream_sends_ultra_effort_as_top_level_string(self): + chunks = [ + ns( + usage=ns(prompt_tokens=1, completion_tokens=1), + choices=[ns(finish_reason="stop", delta=ns(content="ok", tool_calls=None))], + ), + ] + client = FakeOpenAIClient(stream_chunks=chunks) + provider = DashScopeProvider(model="deepseek-v4.1-flash", api_key="k", effort="ultra") + provider._client = client + + _ = [event async for event in provider.stream(messages=[Message.user("hi")], system="")] + + call_kwargs = client.chat.completions.calls[0] + assert call_kwargs["reasoning_effort"] == "ultra" + assert isinstance(call_kwargs["reasoning_effort"], str) + assert call_kwargs["extra_body"] == {"enable_thinking": True} + async def test_qwen38_stream_uses_token_plan_always_on_payload(self): chunks = [ ns( diff --git a/tests/providers/test_deepseek_provider.py b/tests/providers/test_deepseek_provider.py index aea6f2ae..e00c3e25 100644 --- a/tests/providers/test_deepseek_provider.py +++ b/tests/providers/test_deepseek_provider.py @@ -34,6 +34,13 @@ def test_effort_request_kwargs_high(self): "extra_body": {"thinking": {"type": "enabled"}}, } + def test_v41_flash_api_model_uses_current_thinking_protocol(self): + p = DeepSeekProvider(model="deepseek-flash", api_key="test", effort="high") + assert p._effort_request_kwargs() == { + "reasoning_effort": "high", + "extra_body": {"thinking": {"type": "enabled"}}, + } + def test_effort_request_kwargs_max(self): p = DeepSeekProvider(model="deepseek-v4-pro", api_key="test", effort="max") assert p._effort_request_kwargs() == { @@ -195,12 +202,13 @@ def test_deepseek_listed_in_providers(self): deepseek = next(p for p in PROVIDERS if p["name"] == "DeepSeek") assert deepseek["key_name"] == "deepseek" assert deepseek["api_base"] == DEEPSEEK_BASE_URL - assert set(deepseek["models"]) == {"deepseek-v4-pro", "deepseek-v4-flash"} + assert deepseek["default_model"] == "deepseek-flash" + assert set(deepseek["models"]) == {"deepseek-flash", "deepseek-v4-pro", "deepseek-v4-flash"} def test_deepseek_model_capabilities(self): from iac_code.providers.thinking import EffortLevel, get_thinking_spec - for model in ("deepseek-v4-pro", "deepseek-v4-flash"): + for model in ("deepseek-flash", "deepseek-v4-pro", "deepseek-v4-flash"): spec = get_thinking_spec("deepseek", model) assert spec.supports_effort is True assert spec.allowed_efforts == (EffortLevel.LOW, EffortLevel.HIGH, EffortLevel.MAX) diff --git a/tests/providers/test_manager.py b/tests/providers/test_manager.py index 36338473..77a81971 100644 --- a/tests/providers/test_manager.py +++ b/tests/providers/test_manager.py @@ -2787,6 +2787,7 @@ class TestModelPrefixAutoMapping: ("qwen3.6-plus", "dashscope"), ("qwen3.8-max", "dashscope"), ("qwen-max", "dashscope"), + ("deepseek-flash", "deepseek"), ("deepseek-v4-pro", "deepseek"), ("deepseek-chat", "deepseek"), ], @@ -2806,9 +2807,18 @@ def test_saved_config_takes_precedence_over_prefix(self, monkeypatch): ("glm-5.2-fast-preview", "dashscope"), ("kimi/kimi-k3", "dashscope"), ("MiniMax/MiniMax-M3", "dashscope"), + ("MiniMax/MiniMax-M2.7", "dashscope"), + ("MiniMax/MiniMax-M2.5", "dashscope"), + ("MiniMax/MiniMax-M2.1", "dashscope"), + ("k3", "kimi_code"), + ("k3-256k", "kimi_code"), + ("kimi-for-coding", "kimi_code"), + ("kimi-for-coding-highspeed", "kimi_code"), ("deepseek-v4-pro-0813", "dashscope"), ("deepseek-v4-flash-0731", "dashscope"), + ("deepseek-v4.1-flash", "dashscope"), ("ZHIPU/GLM-5.3", "dashscope"), + ("ZHIPU/GLM-5.3-Flash", "dashscope"), ("xiaomi/mimo-v2.5-pro", "dashscope"), ("stepfun/step-3.7-flash", "dashscope"), ("glm-5.3", "zhipu_cn"), @@ -2849,6 +2859,8 @@ def test_static_provider_fallbacks_stay_within_each_model_catalog(): models = {model.id: model for model in descriptor.models} for source_model, fallback_model in MODEL_FALLBACK_MAP.items(): if source_model in models: + if source_model in _PROVIDER_MODEL_FALLBACK_MAP.get(provider_key, {}): + continue assert fallback_model in models, ( f"{provider_key} fallback {source_model} -> {fallback_model} leaves the provider catalog" ) @@ -2859,9 +2871,8 @@ def test_static_provider_fallbacks_stay_within_each_model_catalog(): for provider_key, fallbacks in _PROVIDER_MODEL_FALLBACK_MAP.items(): models = {model.id: model for model in PROVIDER_REGISTRY[provider_key].models} for source_model, fallback_model in fallbacks.items(): - assert source_model in models assert fallback_model in models - if models[source_model].support_multimodal: + if source_model in models and models[source_model].support_multimodal: assert models[fallback_model].support_multimodal, ( f"{provider_key} fallback {source_model} -> {fallback_model} loses image support" ) @@ -2882,6 +2893,19 @@ def test_qwen36_fallback_is_available_on_each_endpoint(provider_key, expected_fa assert "qwen3.6-plus" not in MODEL_FALLBACK_MAP +def test_token_plan_hides_qwen36_plus_but_keeps_legacy_fallback(): + assert "qwen3.6-plus" not in PROVIDER_REGISTRY["dashscope_token_plan"].model_ids + assert _PROVIDER_MODEL_FALLBACK_MAP["dashscope_token_plan"]["qwen3.6-plus"] == "qwen3.6-flash" + + +def test_kimi_code_fallbacks_stay_on_the_subscription_endpoint(): + assert _PROVIDER_MODEL_FALLBACK_MAP["kimi_code"] == { + "k3": "kimi-for-coding", + "k3-256k": "kimi-for-coding", + "kimi-for-coding-highspeed": "kimi-for-coding", + } + + def test_qwen38_flash_fallback_is_available_on_each_endpoint(): assert _PROVIDER_MODEL_FALLBACK_MAP["dashscope"]["qwen3.8-flash"] == "qwen3.7-flash" assert _PROVIDER_MODEL_FALLBACK_MAP["dashscope_token_plan"]["qwen3.8-flash"] == "qwen3.6-flash" diff --git a/tests/providers/test_new_providers.py b/tests/providers/test_new_providers.py index a11806fe..19d15f7c 100644 --- a/tests/providers/test_new_providers.py +++ b/tests/providers/test_new_providers.py @@ -134,6 +134,17 @@ def test_creates_kimi_provider(self, monkeypatch): p = create_provider("kimi-k2.6", credentials={"kimi_cn": "test-key"}) assert p.get_model_name() == "kimi-k2.6" + def test_creates_kimi_code_provider_with_subscription_endpoint(self, monkeypatch): + monkeypatch.setattr("iac_code.config.get_active_provider_key", lambda: None) + monkeypatch.setattr("iac_code.config.get_provider_config", lambda name: {}) + from iac_code.providers.manager import create_provider + + p = create_provider("kimi-for-coding", credentials={"kimi_code": "test-key"}) + + assert p.get_model_name() == "kimi-for-coding" + assert p._PROVIDER_KEY == "kimi_code" + assert p._base_url == "https://api.kimi.com/coding/v1" + def test_creates_gemini_provider(self, monkeypatch): monkeypatch.setattr("iac_code.config.get_active_provider_key", lambda: "gemini") monkeypatch.setattr("iac_code.config.get_provider_config", lambda name: {}) diff --git a/tests/providers/test_provider_model_research_updates.py b/tests/providers/test_provider_model_research_updates.py index 25e96608..9eed6e1f 100644 --- a/tests/providers/test_provider_model_research_updates.py +++ b/tests/providers/test_provider_model_research_updates.py @@ -2,6 +2,7 @@ from __future__ import annotations +from iac_code.config import DEFAULT_MODEL from iac_code.providers.kimi_provider import KimiProvider from iac_code.providers.minimax_provider import MiniMaxProvider from iac_code.providers.registry import PROVIDER_REGISTRY, ModelEntry @@ -25,6 +26,7 @@ def test_dashscope_models_match_researched_bailian_catalog() -> None: for model_id in ( "qwen3.8-max", + "qwen3.8-max-0902", "qwen3.8-max-prime", "qwen3.8-flash", "qwen3.8-2.4t-a95b", @@ -41,6 +43,7 @@ def test_dashscope_models_match_researched_bailian_catalog() -> None: "qwen-plus", "qwen-flash", "deepseek-v4-pro", + "deepseek-v4.1-flash", "deepseek-v4-pro-0813", "deepseek-v4-flash-0731", "deepseek-v4-flash", @@ -53,7 +56,11 @@ def test_dashscope_models_match_researched_bailian_catalog() -> None: "glm-5.2", "glm-5.1", "ZHIPU/GLM-5.3", + "ZHIPU/GLM-5.3-Flash", "MiniMax/MiniMax-M3", + "MiniMax/MiniMax-M2.7", + "MiniMax/MiniMax-M2.5", + "MiniMax/MiniMax-M2.1", "MiniMax-M2.5", "xiaomi/mimo-v2.5-pro", "stepfun/step-3.7-flash", @@ -65,10 +72,13 @@ def test_dashscope_models_match_researched_bailian_catalog() -> None: assert model_id in models assert not _model_entry("dashscope", model_id).is_default - assert PROVIDER_REGISTRY["dashscope"].default_model == "qwen3.8-max" + assert PROVIDER_REGISTRY["dashscope"].default_model == "qwen3.8-max-0902" + assert DEFAULT_MODEL == "qwen3.8-max-0902" assert not _model_entry("dashscope", "glm-5.2-fast-preview").support_multimodal assert "glm-5.2-fast-preview" not in _model_ids("dashscope_token_plan") assert _model_entry("dashscope", "qwen3.8-max").support_multimodal + assert _model_entry("dashscope", "qwen3.8-max-0902").support_multimodal + assert _model_entry("dashscope", "deepseek-v4.1-flash").support_multimodal assert not _model_entry("dashscope", "qwen3.7-max").support_multimodal for model_id in ( "qwen3.8-max-prime", @@ -92,6 +102,7 @@ def test_dashscope_models_match_researched_bailian_catalog() -> None: assert not _model_entry("dashscope", "xiaomi/mimo-v2.5-pro").support_multimodal assert not _model_entry("dashscope", "deepseek-v4-pro-0813").support_multimodal assert not _model_entry("dashscope", "ZHIPU/GLM-5.3").support_multimodal + assert not _model_entry("dashscope", "ZHIPU/GLM-5.3-Flash").support_multimodal # The public adapter can only send local attachments as data URLs, but # Moonshot-hosted K3 on DashScope accepts public image URLs only. assert not _model_entry("dashscope", "kimi/kimi-k3").support_multimodal @@ -99,28 +110,18 @@ def test_dashscope_models_match_researched_bailian_catalog() -> None: def test_dashscope_token_plan_uses_exact_supported_chat_models() -> None: models = _model_ids("dashscope_token_plan") - - for model_id in ( + assert models == [ "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", - "qwen3.6-plus", "qwen3.6-flash", + "deepseek-v4.1-flash", "deepseek-v4-pro", "deepseek-v4-pro-0813", "deepseek-v4-flash-0731", - "deepseek-v4-flash", - "deepseek-v3.2", "glm-5.2", - "glm-5.1", - "glm-5", - "MiniMax-M2.5", - "kimi-k2.7-code", - "kimi-k2.5", - "kimi-k2.6", - ): - assert model_id in models + ] assert "glm-5-turbo" not in models assert "MiniMax-M2.7" not in models @@ -130,14 +131,16 @@ def test_dashscope_token_plan_uses_exact_supported_chat_models() -> None: assert PROVIDER_REGISTRY["dashscope_token_plan"].default_model == "qwen3.8-max" assert _model_entry("dashscope_token_plan", "qwen3.8-max").support_multimodal assert _model_entry("dashscope_token_plan", "qwen3.8-flash").support_multimodal + assert _model_entry("dashscope_token_plan", "deepseek-v4.1-flash").support_multimodal assert not _model_entry("dashscope_token_plan", "qwen3.7-max").support_multimodal assert not _model_entry("dashscope_token_plan", "deepseek-v4-pro-0813").support_multimodal - for model_id in ("qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash", "kimi-k2.7-code", "kimi-k2.5", "kimi-k2.6"): + for model_id in ("qwen3.7-plus", "qwen3.6-flash"): assert _model_entry("dashscope_token_plan", model_id).support_multimodal def test_openai_azure_anthropic_and_gemini_models_are_updated() -> None: for model_id in ( + "gpt-6-astra", "gpt-5.6-sol", "gpt-5.6", "gpt-5.6-terra", @@ -153,11 +156,31 @@ def test_openai_azure_anthropic_and_gemini_models_are_updated() -> None: assert get_thinking_spec("openai", responses_only_model).family is ThinkingFamily.NONE assert PROVIDER_REGISTRY["openai"].default_model == "gpt-5.6-sol" + gpt6 = get_thinking_spec("openai", "gpt-6-astra") + assert gpt6.allowed_efforts == ( + EffortLevel.LOW, + EffortLevel.MEDIUM, + EffortLevel.HIGH, + EffortLevel.XHIGH, + EffortLevel.MAX, + ) + assert gpt6.default_effort is EffortLevel.MEDIUM assert _model_ids("azure_openai") == [] assert PROVIDER_REGISTRY["azure_openai"].default_model == "" - assert PROVIDER_REGISTRY["anthropic"].default_model == "claude-fable-5" + assert PROVIDER_REGISTRY["anthropic"].default_model == "claude-fable-5-1" + assert get_thinking_spec("anthropic", "claude-fable-5-1").family is ThinkingFamily.ANTHROPIC_ADAPTIVE + fable_51 = get_thinking_spec("anthropic", "claude-fable-5-1") + assert fable_51.allowed_efforts == ( + EffortLevel.LOW, + EffortLevel.MEDIUM, + EffortLevel.HIGH, + EffortLevel.XHIGH, + EffortLevel.MAX, + ) + assert fable_51.default_effort is EffortLevel.HIGH + assert fable_51.supports_disable is False assert get_thinking_spec("anthropic", "claude-fable-5").family is ThinkingFamily.ANTHROPIC_ADAPTIVE assert "claude-opus-5" in _model_ids("anthropic") assert _model_entry("anthropic", "claude-opus-5").support_multimodal @@ -166,6 +189,7 @@ def test_openai_azure_anthropic_and_gemini_models_are_updated() -> None: assert get_thinking_spec("anthropic", "claude-sonnet-5").family is ThinkingFamily.ANTHROPIC_ADAPTIVE for model_id in ( + "gemini-3.8-flash", "gemini-3.7-flash", "gemini-3.6-flash", "gemini-3.5-flash-lite", @@ -174,7 +198,11 @@ def test_openai_azure_anthropic_and_gemini_models_are_updated() -> None: assert model_id in _model_ids("gemini") assert _model_entry("gemini", model_id).support_multimodal assert get_thinking_spec("gemini", model_id).family is ThinkingFamily.GEMINI - assert PROVIDER_REGISTRY["gemini"].default_model == "gemini-3.7-flash" + assert PROVIDER_REGISTRY["gemini"].default_model == "gemini-3.8-flash" + gemini_38 = get_thinking_spec("gemini", "gemini-3.8-flash") + assert gemini_38.allowed_efforts == (EffortLevel.LOW, EffortLevel.MEDIUM, EffortLevel.HIGH) + assert gemini_38.default_effort is EffortLevel.MEDIUM + assert gemini_38.supports_disable is False assert "gemini-3.1-flash-lite-preview" not in _model_ids("gemini") assert "gemini-2.0-flash" not in _model_ids("gemini") assert get_thinking_spec("gemini", "gemini-2.5-flash-lite").family is ThinkingFamily.GEMINI @@ -190,6 +218,24 @@ def test_direct_kimi_minimax_and_zhipu_models_are_updated() -> None: assert _model_entry(provider_key, "kimi-k2.7-code").support_multimodal assert get_thinking_spec(provider_key, "kimi-k3").family is ThinkingFamily.KIMI assert get_thinking_spec(provider_key, "kimi-k2.6").family is ThinkingFamily.KIMI + assert "kimi-k2.5" not in _model_ids(provider_key) + + kimi_code = PROVIDER_REGISTRY["kimi_code"] + assert kimi_code.base_url == "https://api.kimi.com/coding/v1" + assert kimi_code.default_model == "kimi-for-coding" + assert kimi_code.model_ids == ["kimi-for-coding", "k3", "k3-256k", "kimi-for-coding-highspeed"] + for model_id in kimi_code.model_ids: + assert _model_entry("kimi_code", model_id).support_multimodal + assert get_thinking_spec("kimi_code", model_id).family is ThinkingFamily.KIMI + + for model_id, default_effort in (("k3", EffortLevel.HIGH), ("k3-256k", EffortLevel.HIGH)): + spec = get_thinking_spec("kimi_code", model_id) + assert spec.allowed_efforts == (EffortLevel.LOW, EffortLevel.HIGH, EffortLevel.MAX) + assert spec.default_effort is default_effort + k28 = get_thinking_spec("kimi_code", "kimi-for-coding") + assert k28.allowed_efforts == (EffortLevel.LOW, EffortLevel.HIGH, EffortLevel.MAX) + assert k28.default_effort is EffortLevel.MAX + assert get_thinking_spec("kimi_code", "kimi-for-coding-highspeed").supports_disable is False for provider_key in ("minimax_cn", "minimax_intl"): assert PROVIDER_REGISTRY[provider_key].default_model == "MiniMax-M3" @@ -210,6 +256,14 @@ def test_direct_kimi_minimax_and_zhipu_models_are_updated() -> None: assert get_thinking_spec(provider_key, "glm-5.1").family is ThinkingFamily.ZHIPU +def test_direct_deepseek_uses_v41_flash_api_model_id() -> None: + assert PROVIDER_REGISTRY["deepseek"].default_model == "deepseek-flash" + assert _model_ids("deepseek") == ["deepseek-flash", "deepseek-v4-pro", "deepseek-v4-flash"] + assert _model_entry("deepseek", "deepseek-flash").support_multimodal + assert get_thinking_spec("deepseek", "deepseek-flash").family is ThinkingFamily.OPENAI + assert "deepseek-v4.1-flash" not in _model_ids("deepseek") + + def test_provider_specific_thinking_wire_formats_do_not_use_openai_or_anthropic_effort_fields() -> None: kimi = KimiProvider(model="kimi-k2.6", api_key="k", effort="high") assert kimi._build_thinking_kwargs() == {"extra_body": {"thinking": {"type": "enabled"}}} @@ -223,6 +277,24 @@ def test_provider_specific_thinking_wire_formats_do_not_use_openai_or_anthropic_ assert KimiProvider(model="kimi-k3", api_key="k", effort="high")._build_thinking_kwargs() == { "reasoning_effort": "high" } + assert KimiProvider( + model="kimi-for-coding", api_key="k", provider_key="kimi_code", effort="max" + )._build_thinking_kwargs() == {"reasoning_effort": "max"} + assert KimiProvider( + model="k3", api_key="k", provider_key="kimi_code", thinking_enabled=True + )._build_thinking_kwargs() == {"reasoning_effort": "high"} + assert KimiProvider( + model="kimi-for-coding", api_key="k", provider_key="kimi_code", thinking_enabled=False + )._build_thinking_kwargs() == {"extra_body": {"thinking": {"type": "disabled"}}} + assert ( + KimiProvider( + model="kimi-for-coding-highspeed", + api_key="k", + provider_key="kimi_code", + thinking_enabled=False, + )._build_thinking_kwargs() + == {} + ) zhipu = ZhiPuProvider(model="glm-5.1", api_key="k", effort="high") assert zhipu._build_thinking_kwargs() == {"extra_body": {"thinking": {"type": "enabled"}}} @@ -443,9 +515,7 @@ def test_dashscope_new_model_protocols_are_not_flattened() -> None: model="kimi-k3", api_key="k", thinking_enabled=False, - )._build_thinking_kwargs() == { - "extra_body": {"enable_thinking": True, "preserve_thinking": True} - } + )._build_thinking_kwargs() == {"extra_body": {"enable_thinking": True, "preserve_thinking": True}} stepfun = get_thinking_spec("dashscope", "stepfun/step-3.7-flash") assert stepfun.allowed_efforts == (EffortLevel.LOW, EffortLevel.MEDIUM, EffortLevel.HIGH) diff --git a/tests/providers/test_thinking_registry.py b/tests/providers/test_thinking_registry.py index adaa81fb..df77f3ff 100644 --- a/tests/providers/test_thinking_registry.py +++ b/tests/providers/test_thinking_registry.py @@ -69,11 +69,12 @@ def test_openai_codex_and_o_series_have_generation_specific_efforts(self): assert EffortLevel.NONE not in o3.allowed_efforts def test_deepseek_official_uses_openai_family_with_low_high_max(self): - spec = get_thinking_spec("deepseek", "deepseek-v4-pro") - assert spec.family is ThinkingFamily.OPENAI - assert spec.allowed_efforts == (EffortLevel.LOW, EffortLevel.HIGH, EffortLevel.MAX) - assert spec.default_effort is EffortLevel.HIGH - assert spec.thinking_enabled_by_default is True + for model in ("deepseek-flash", "deepseek-v4-pro", "deepseek-v4-flash"): + spec = get_thinking_spec("deepseek", model) + assert spec.family is ThinkingFamily.OPENAI + assert spec.allowed_efforts == (EffortLevel.LOW, EffortLevel.HIGH, EffortLevel.MAX) + assert spec.default_effort is EffortLevel.HIGH + assert spec.thinking_enabled_by_default is True def test_dashscope_qwen_supports_thinking_no_effort(self): spec = get_thinking_spec("dashscope", "qwen3.6-plus") @@ -271,6 +272,27 @@ def test_dashscope_deepseek_supports_documented_efforts(self): assert spec.default_effort is EffortLevel.HIGH assert spec.uses_reasoning_effort_param is True + @pytest.mark.parametrize("provider_key", ["dashscope", "dashscope_token_plan"]) + def test_deepseek_v41_flash_exposes_documented_efforts(self, provider_key): + spec = get_thinking_spec(provider_key, "deepseek-v4.1-flash") + + assert spec.family is ThinkingFamily.DASHSCOPE + assert spec.supports_effort is True + assert spec.allowed_efforts == ( + EffortLevel.MINIMAL, + EffortLevel.LOW, + EffortLevel.MEDIUM, + EffortLevel.HIGH, + EffortLevel.XHIGH, + EffortLevel.MAX, + EffortLevel.ULTRA, + ) + assert spec.effort_values == ("minimal", "low", "medium", "high", "xhigh", "max", "ultra") + assert spec.effort_range == (EffortLevel.MINIMAL, EffortLevel.ULTRA) + assert spec.default_effort_value is None + assert spec.uses_reasoning_effort_param is True + assert spec.thinking_enabled_by_default is True + def test_token_plan_deepseek_0731_is_registered(self): spec = get_thinking_spec("dashscope_token_plan", "deepseek-v4-flash-0731") assert spec.family is ThinkingFamily.DASHSCOPE diff --git a/tests/services/capabilities/test_multimodal.py b/tests/services/capabilities/test_multimodal.py index 0fc8650b..6be9c530 100644 --- a/tests/services/capabilities/test_multimodal.py +++ b/tests/services/capabilities/test_multimodal.py @@ -49,6 +49,9 @@ def test_builtin_set_includes_registry_flagged_models(): assert "gemini-2.5-pro" in builtin assert "qwen3.6-plus" in builtin assert "kimi-k2.6" in builtin + assert "deepseek-flash" in builtin + assert "kimi-for-coding" in builtin + assert "k3-256k" in builtin def test_builtin_set_excludes_non_multimodal_models(): diff --git a/tests/services/test_context_manager.py b/tests/services/test_context_manager.py index f664d863..6bd39070 100644 --- a/tests/services/test_context_manager.py +++ b/tests/services/test_context_manager.py @@ -43,14 +43,15 @@ def test_dashscope_kimi_k3_uses_documented_context_window(self): assert config.context_window == 1_000_000 assert config.max_output_tokens == 8_192 - @pytest.mark.parametrize("model", ["gpt-5.6-sol", "gpt-5.6", "gpt-5.6-terra", "gpt-5.6-luna"]) + @pytest.mark.parametrize("model", ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6", "gpt-5.6-terra", "gpt-5.6-luna"]) def test_gpt56_models_use_documented_capacity(self, model): config = get_context_window_config(model) assert config.context_window == 1_050_000 assert config.max_output_tokens == 128_000 - def test_claude_fable5_uses_documented_capacity(self): - config = get_context_window_config("claude-fable-5") + @pytest.mark.parametrize("model", ["claude-fable-5-1", "claude-fable-5"]) + def test_claude_fable5_uses_documented_capacity(self, model): + config = get_context_window_config(model) assert config.context_window == 1_000_000 assert config.max_output_tokens == 128_000 @@ -74,6 +75,7 @@ def test_openai_400k_models_use_documented_capacity(self, model): @pytest.mark.parametrize( "model", [ + "gemini-3.8-flash", "gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", @@ -97,10 +99,23 @@ def test_gemini_models_use_documented_capacity(self, model): def test_kimi_k2_models_use_documented_context_capacity(self, model): assert get_context_window_config(model).context_window == 262_144 + @pytest.mark.parametrize( + ("model", "context_window"), + [ + ("k3", 1_048_576), + ("k3-256k", 262_144), + ("kimi-for-coding", 1_048_576), + ("kimi-for-coding-highspeed", 262_144), + ], + ) + def test_kimi_code_models_use_documented_context_capacity(self, model, context_window): + assert get_context_window_config(model).context_window == context_window + @pytest.mark.parametrize( ("model", "context_window"), [ ("qwen3.8-max", 1_000_000), + ("qwen3.8-max-0902", 1_000_000), ("qwen3.8-max-prime", 1_000_000), ("qwen3.8-flash", 1_000_000), ("qwen3.8-2.4t-a95b", 1_000_000), @@ -112,13 +127,18 @@ def test_kimi_k2_models_use_documented_context_capacity(self, model): ("qwen3.6-flash", 1_000_000), ("qwen3.6-35b-a3b", 262_144), ("qwen3.6-27b", 262_144), + ("deepseek-flash", 1_000_000), ("deepseek-v4-pro", 1_000_000), ("deepseek-v4-pro-0813", 1_000_000), ("deepseek-v4-flash-0731", 1_000_000), ("deepseek-v4-flash", 1_000_000), + ("deepseek-v4.1-flash", 1_000_000), ("glm-5.1", 202_752), ("MiniMax-M3", 1_000_000), ("MiniMax/MiniMax-M3", 196_608), + ("MiniMax/MiniMax-M2.7", 196_608), + ("MiniMax/MiniMax-M2.5", 196_608), + ("MiniMax/MiniMax-M2.1", 196_608), ("xiaomi/mimo-v2.5-pro", 1_048_576), ("stepfun/step-3.7-flash", 262_144), ], @@ -127,7 +147,15 @@ def test_current_dashscope_models_use_documented_context_capacity(self, model, c assert get_context_window_config(model).context_window == context_window @pytest.mark.parametrize( - "model", ["deepseek-v4-pro", "deepseek-v4-pro-0813", "deepseek-v4-flash", "deepseek-v4-flash-0731"] + "model", + [ + "deepseek-flash", + "deepseek-v4.1-flash", + "deepseek-v4-pro", + "deepseek-v4-pro-0813", + "deepseek-v4-flash", + "deepseek-v4-flash-0731", + ], ) def test_deepseek_v4_models_use_documented_output_capacity(self, model): assert get_context_window_config(model).max_output_tokens == 393_216 @@ -152,6 +180,11 @@ def test_dashscope_zhipu_glm53_uses_hosted_capacity(self): assert config.context_window == 1_048_576 assert config.max_output_tokens == 131_072 + def test_dashscope_zhipu_glm53_flash_uses_hosted_capacity(self): + config = get_context_window_config("ZHIPU/GLM-5.3-Flash") + assert config.context_window == 1_048_576 + assert config.max_output_tokens == 131_072 + def test_dashscope_glm52_fast_preview_uses_documented_capacity(self): config = get_context_window_config("glm-5.2-fast-preview") assert config.context_window == 1_048_576 @@ -165,6 +198,10 @@ def test_shared_kimi_k3_id_keeps_conservative_capacity(self): @pytest.mark.parametrize( ("model", "max_output_tokens"), [ + ("qwen3.8-max", 128_000), + ("qwen3.8-max-0902", 128_000), + ("qwen3.8-max-prime", 128_000), + ("qwen3.8-flash", 128_000), ("qwen3.8-2.4t-a95b", 131_072), ("qwen3.8-27b", 131_072), ("qwen3.6-35b-a3b", 65_536), diff --git a/tests/test_config_env_overrides.py b/tests/test_config_env_overrides.py index 024496de..1c81d258 100644 --- a/tests/test_config_env_overrides.py +++ b/tests/test_config_env_overrides.py @@ -455,8 +455,17 @@ def test_api_key_env_routed_via_model_prefix_when_no_provider(self, monkeypatch, ("qwen3.8-max-preview", "dashscope_token_plan"), ("kimi/kimi-k3", "dashscope"), ("MiniMax/MiniMax-M3", "dashscope"), + ("MiniMax/MiniMax-M2.7", "dashscope"), + ("MiniMax/MiniMax-M2.5", "dashscope"), + ("MiniMax/MiniMax-M2.1", "dashscope"), + ("k3", "kimi_code"), + ("k3-256k", "kimi_code"), + ("kimi-for-coding", "kimi_code"), + ("kimi-for-coding-highspeed", "kimi_code"), ("deepseek-v4-pro-0813", "dashscope"), + ("deepseek-v4.1-flash", "dashscope"), ("ZHIPU/GLM-5.3", "dashscope"), + ("ZHIPU/GLM-5.3-Flash", "dashscope"), ("xiaomi/mimo-v2.5-pro", "dashscope"), ("stepfun/step-3.7-flash", "dashscope"), ("glm-5.3", "zhipu_cn"), diff --git a/tests/test_services/test_telemetry/test_constants.py b/tests/test_services/test_telemetry/test_constants.py index 750cd6f8..5ec3f68b 100644 --- a/tests/test_services/test_telemetry/test_constants.py +++ b/tests/test_services/test_telemetry/test_constants.py @@ -29,10 +29,13 @@ def test_known_models_contains_claude_and_openai(): def test_known_models_contains_researched_provider_updates(): for model in ( + "claude-fable-5-1", "claude-fable-5", "claude-opus-5", + "gpt-6-astra", "gpt-5.6-sol", "qwen3.8-max", + "qwen3.8-max-0902", "qwen3.8-max-prime", "qwen3.8-flash", "qwen3.8-2.4t-a95b", @@ -42,21 +45,27 @@ def test_known_models_contains_researched_provider_updates(): "qwen3.6-35b-a3b", "qwen3.6-27b", "kimi-k3", + "kimi-for-coding", + "k3", "glm-5.3", "glm-5.3-flash", "ZHIPU/GLM-5.3", + "ZHIPU/GLM-5.3-Flash", "xiaomi/mimo-v2.5-pro", "stepfun/step-3.7-flash", "glm-5.2-fast-preview", "glm-5.2", + "gemini-3.8-flash", "gemini-3.7-flash", "gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-2.5-pro", + "deepseek-flash", "deepseek-v4-pro", "deepseek-v4-pro-0813", "deepseek-v4-flash-0731", + "deepseek-v4.1-flash", "MiniMax-M3", "doubao-seed-2-0-pro-260215", ): diff --git a/tests/ui/dialogs/test_model_picker.py b/tests/ui/dialogs/test_model_picker.py index c73ad6c0..44f96c0b 100644 --- a/tests/ui/dialogs/test_model_picker.py +++ b/tests/ui/dialogs/test_model_picker.py @@ -31,6 +31,7 @@ def test_values(self): assert EffortLevel.HIGH.value == "high" assert EffortLevel.XHIGH.value == "xhigh" assert EffortLevel.MAX.value == "max" + assert EffortLevel.ULTRA.value == "ultra" assert EffortLevel.AUTO.value == "auto" def test_effort_order(self): @@ -42,6 +43,7 @@ def test_effort_order(self): EffortLevel.HIGH, EffortLevel.XHIGH, EffortLevel.MAX, + EffortLevel.ULTRA, EffortLevel.AUTO, ] @@ -53,6 +55,7 @@ def test_effort_symbols(self): assert EFFORT_SYMBOLS[EffortLevel.HIGH] == "◆◆◆" assert EFFORT_SYMBOLS[EffortLevel.XHIGH] == "◆◆◆◆" assert EFFORT_SYMBOLS[EffortLevel.MAX] == "◆◆◆◆◆" + assert EFFORT_SYMBOLS[EffortLevel.ULTRA] == "◆◆◆◆◆◆" assert EFFORT_SYMBOLS[EffortLevel.AUTO] == "◆" diff --git a/tests/web/test_frontend_static.py b/tests/web/test_frontend_static.py index 0ce1e5bb..73787c79 100644 --- a/tests/web/test_frontend_static.py +++ b/tests/web/test_frontend_static.py @@ -1286,14 +1286,14 @@ def test_static_asset_versions_reload_rename_api_changes() -> None: workspace_source = _source(WORKSPACE_JS) assert "/static/styles.css?v=web-repl-ui-318" in html - assert "/static/js/app.js?v=web-repl-ui-362" in html + assert "/static/js/app.js?v=web-repl-ui-363" in html # api.js 导出 WEB_EVENT_TYPES(EventSource 订阅白名单)与 openEventStream;新增 # pipeline.step.marker 订阅后必须 bump 其 import 版本位,否则回访浏览器加载「新 # app.js + 旧缓存 api.js」,EventSource 仍不监听该事件名,实时流水线主区照样空白。 # 已归档面板复刻(archived tab)新增 listArchivedSessions/deleteArchivedSessions, # 同样需 bump api.js 版本位,否则回访浏览器拿不到新导出。 assert "./api.js?v=web-repl-ui-313" in app_source - assert "./components/composer.js?v=session-model-v20" in app_source + assert "./components/composer.js?v=session-model-v22" in app_source # 图片灯箱模块(composer 缩略图 + 消息内图片共用),改动需 bump 其 import 版本位。 assert "./components/image_lightbox.js?v=image-lightbox-v1" in app_source assert "./components/tool_cards.js?v=live-inline-tools-v26" in app_source @@ -1320,7 +1320,7 @@ def test_static_asset_versions_reload_rename_api_changes() -> None: # cloud-creds 面板(Task 5/6)重写后须 bump 全局版本位并给 workspace.js 加 per-file # 版本位,否则回访浏览器加载旧缓存 workspace.js,拿不到新的云凭证面板结构。 - assert "web-repl-ui-362" in index_html + assert "web-repl-ui-363" in index_html assert "web-repl-ui-333" not in index_html # events.js 新增实时 MCP/工具进度归并,必须 bump 版本避免旧 reducer 丢事件。 assert "./events.js?v=web-repl-ui-323" in app_source @@ -5625,6 +5625,11 @@ class Element { models: [ { id: "gpt-5.5", name: "GPT-5.5", efforts: ["low", "medium", "high"] }, { id: "gpt-5-mini", name: "GPT-5 Mini", efforts: ["low"] }, + { + id: "deepseek-v4.1-flash", + name: "DeepSeek V4.1 Flash", + efforts: ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"], + }, ], }, { @@ -5683,6 +5688,13 @@ class Element { required(modelMenu, '[data-composer-submenu-trigger="model"]').click(); const modelSubmenuVisible = !required(modelMenu, '[data-composer-submenu="model"]').hidden; const modelSubmenuText = textOf(required(modelMenu, '[data-composer-submenu="model"]')); + required(modelMenu, '[data-composer-setting="model:deepseek-v4.1-flash"]').click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const deepseekMenuText = textOf(modelMenu); + required(modelMenu, '[data-composer-setting="effort:ultra"]').click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const ultraControl = textOf(modelControl); + required(modelMenu, '[data-composer-submenu-trigger="model"]').click(); required(modelMenu, '[data-composer-setting="model:gpt-5-mini"]').click(); await new Promise((resolve) => setTimeout(resolve, 0)); required(modelMenu, '[data-composer-submenu-trigger="provider"]').click(); @@ -5702,6 +5714,8 @@ class Element { menuText: openedMenuText, modelSubmenuVisible, modelSubmenuText, + deepseekMenuText, + ultraControl, providerSubmenuVisible, providerSubmenuText, stillOpenBeforeOutsideClick, @@ -5723,6 +5737,9 @@ class Element { assert "Anthropic" not in output["menuText"] assert output["modelSubmenuVisible"] is True assert "GPT-5 Mini" in output["modelSubmenuText"] + assert "DeepSeek V4.1 Flash" in output["modelSubmenuText"] + assert "Ultra" in output["deepseekMenuText"] + assert output["ultraControl"] == "DeepSeek V4.1 Flash Ultra" assert output["providerSubmenuVisible"] is True assert "Anthropic" in output["providerSubmenuText"] assert "DeepSeek" not in output["providerSubmenuText"] @@ -5730,6 +5747,8 @@ class Element { assert output["closedAfterOutsideClick"] is True assert output["saves"] == [ {"provider": "openai", "model": "gpt-5.5", "effort": "high"}, + {"provider": "openai", "model": "deepseek-v4.1-flash", "effort": "high"}, + {"provider": "openai", "model": "deepseek-v4.1-flash", "effort": "ultra"}, {"provider": "openai", "model": "gpt-5-mini", "effort": "low"}, {"provider": "anthropic", "model": "claude-sonnet-4", "effort": "medium"}, ] @@ -10899,7 +10918,7 @@ def test_session_updated_folds_current_session_into_sidebar_arrays() -> None: def test_index_html_cache_version_bumped() -> None: html = _source(INDEX_HTML) - assert "web-repl-ui-362" in html + assert "web-repl-ui-363" in html assert "web-repl-ui-343" not in html diff --git a/tests/web/test_session_manager.py b/tests/web/test_session_manager.py index b953e252..1e9eb460 100644 --- a/tests/web/test_session_manager.py +++ b/tests/web/test_session_manager.py @@ -114,9 +114,7 @@ def test_runtime_settings_payload_redacts_only_editable_cloud_credentials(monkey ("cancel", "Cancel"), ), ) -def test_solution_first_pipeline_user_display_text_hides_structured_control_json( - action: str, expected: str -) -> None: +def test_solution_first_pipeline_user_display_text_hides_structured_control_json(action: str, expected: str) -> None: raw = json.dumps({"action": action, "parameter_overrides": {"ZoneId": "cn-hangzhou-i"}}) assert solution_first_pipeline_user_display_text("selling_solution_first", raw) == expected @@ -1675,6 +1673,27 @@ def test_persist_pipeline_handoff_context_feeds_resume_but_hidden_in_transcript( assert "[Pipeline Handoff Context]" not in dumped +def test_set_session_model_accepts_deepseek_v41_documented_effort(tmp_path) -> None: + manager = WebSessionManager(projects_dir=tmp_path / "projects") + session = manager.create_session(cwd=str(tmp_path / "project"), mode="normal", session_id="numeric-effort-1") + + result = manager.set_session_model( + session, + provider="dashscope", + model="deepseek-v4.1-flash", + effort="ultra", + ) + + assert result == {"provider": "dashscope", "model": "deepseek-v4.1-flash", "effort": "ultra"} + with pytest.raises(ValueError, match="unknown effort"): + manager.set_session_model( + session, + provider="dashscope", + model="deepseek-v4.1-flash", + effort="100", + ) + + def test_clear_session_model_resets_override_persists_and_emits_event(tmp_path) -> None: # 合作方源不在 PROVIDER_REGISTRY,无法作为会话级 provider 存储;切换到合作方源前须先清掉 # 本会话的覆盖,否则 to_payload 里 `self.provider or runtime['provider']` 仍走旧的会话级 provider, diff --git a/tests/web/test_settings.py b/tests/web/test_settings.py index c4c70ea6..bc1bee00 100644 --- a/tests/web/test_settings.py +++ b/tests/web/test_settings.py @@ -837,6 +837,47 @@ def test_validate_effort_still_enforces_known_spec(_isolate_config): web_settings._validate_effort("openai", "gpt-5.5", "definitely-not-an-effort") +def test_deepseek_v41_payload_and_validation_use_documented_efforts(_isolate_config): + payload = web_settings.providers_payload() + dashscope = next(provider for provider in payload["providers"] if provider["key"] == "dashscope") + model = next(model for model in dashscope["models"] if model["id"] == "deepseek-v4.1-flash") + + assert model["efforts"] == ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"] + assert model["defaultEffort"] is None + web_settings._validate_effort("dashscope", "deepseek-v4.1-flash", "minimal") + web_settings._validate_effort("dashscope", "deepseek-v4.1-flash", "ultra") + with pytest.raises(ValueError): + web_settings._validate_effort("dashscope", "deepseek-v4.1-flash", "1") + with pytest.raises(ValueError): + web_settings._validate_effort("dashscope", "deepseek-v4.1-flash", "100") + + +def test_kimi_code_payload_exposes_k28_models_and_exact_efforts(_isolate_config): + payload = web_settings.providers_payload() + provider = next(item for item in payload["providers"] if item["key"] == "kimi_code") + + assert provider["group"] == "Kimi" + assert provider["apiBase"] == "https://api.kimi.com/coding/v1" + assert provider["defaultModel"] == "kimi-for-coding" + assert [model["id"] for model in provider["models"]] == [ + "kimi-for-coding", + "k3", + "k3-256k", + "kimi-for-coding-highspeed", + ] + by_id = {model["id"]: model for model in provider["models"]} + assert by_id["kimi-for-coding"]["efforts"] == ["low", "high", "max"] + assert by_id["kimi-for-coding"]["defaultEffort"] == "max" + assert by_id["k3"]["efforts"] == ["low", "high", "max"] + assert by_id["k3"]["defaultEffort"] == "high" + assert by_id["k3-256k"]["defaultEffort"] == "high" + assert by_id["kimi-for-coding-highspeed"]["efforts"] == [] + assert by_id["kimi-for-coding-highspeed"]["thinkingDefault"] is True + web_settings._validate_effort("kimi_code", "kimi-for-coding", "max") + with pytest.raises(ValueError): + web_settings._validate_effort("kimi_code", "kimi-for-coding", "medium") + + def test_provider_payload_exposes_saved_fields(_isolate_config): key = _first_provider_key() model = _first_model(key) From 11d5562cb3d1a756eb2de6678e372c3da8fa2aff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A1=82=E9=A9=AC?= Date: Mon, 14 Sep 2026 17:57:29 +0800 Subject: [PATCH 3/3] fix: harden provider updates and A2A session headers --- scripts/provider-update-guide.zh-CN.md | 7 +- src/iac_code/a2a/executor.py | 127 ++++++++- src/iac_code/a2a/input_required.py | 26 +- src/iac_code/a2a/metadata_redaction.py | 37 ++- src/iac_code/a2a/pipeline_executor.py | 4 + src/iac_code/a2a/pipeline_stream.py | 10 +- src/iac_code/a2a/task_store.py | 54 +++- src/iac_code/a2a/transports/dispatcher.py | 4 +- src/iac_code/providers/manager.py | 2 +- src/iac_code/providers/registry.py | 7 +- src/iac_code/providers/request_headers.py | 11 +- src/iac_code/providers/thinking.py | 2 +- src/iac_code/services/context_manager.py | 4 +- src/iac_code/services/permission_wait.py | 5 + src/iac_code/services/telemetry/constants.py | 2 +- .../references/solutions/iac-code-web.md | 2 +- .../references/solutions/iac-code-web.ros.yml | 2 +- tests/a2a/test_executor.py | 245 +++++++++++++++++- tests/a2a/test_input_required.py | 21 +- tests/a2a/test_metadata_redaction.py | 27 ++ tests/a2a/test_task_store.py | 117 ++++++++- tests/a2a/test_transport_dispatcher.py | 3 +- .../selling/test_iac_code_web_solution.py | 2 +- tests/providers/test_deepseek_provider.py | 14 +- tests/providers/test_manager.py | 2 +- .../test_provider_model_research_updates.py | 18 +- tests/providers/test_request_headers.py | 15 ++ tests/providers/test_thinking_registry.py | 2 +- .../services/capabilities/test_multimodal.py | 4 +- tests/services/test_context_manager.py | 6 +- tests/services/test_permission_wait.py | 7 +- .../test_telemetry/test_constants.py | 2 +- website/docs/a2a/protocol-reference.md | 2 +- .../current/a2a/protocol-reference.md | 2 +- 34 files changed, 713 insertions(+), 82 deletions(-) diff --git a/scripts/provider-update-guide.zh-CN.md b/scripts/provider-update-guide.zh-CN.md index c9b137ba..d95ead24 100644 --- a/scripts/provider-update-guide.zh-CN.md +++ b/scripts/provider-update-guide.zh-CN.md @@ -452,7 +452,7 @@ git diff HEAD -- iac-code-rs | DashScope / Token Plan | Token Plan 的 `qwen3.8-max-preview` 支持视觉输入、1M context,只能思考,effort 为 `low/medium/xhigh`;它不发送 `enable_thinking`,工具循环通过 `extra_body.preserve_thinking=true` 保留思考状态;该参数仅向端点参数文档明确列出的 Qwen 与 Kimi 模型发送,包含 `qwen3.6-flash`;`thinking_budget` 在 OpenAI-compatible Chat 参数页中限定于 Qwen3.x,GLM-5.2 只发送 `enable_thinking`、`reasoning_effort` 和总输出限制;DashScope 的 `glm-5.1` 以及 Token Plan 的 `glm-5.1/glm-5` 接受 `none/minimal/low/medium/high/xhigh`,不得误用仅 `glm-5.2` 支持的 `max`;`MiniMax/MiniMax-M3` 使用 adaptive/disabled thinking;已核实的百炼容量要按 model ID 精确配置,不能统一落到 `qwen` 的 128K 默认值 | `registry.py`、`thinking.py`、`dashscope_provider.py`、`context_manager.py`、DashScope 调研回归测试 | [OpenAI-compatible Chat](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-chat-completions)、[Token Plan](https://help.aliyun.com/zh/model-studio/token-plan-personal-overview)、[文本生成](https://help.aliyun.com/zh/model-studio/text-generation-model)、[GLM on DashScope](https://help.aliyun.com/zh/model-studio/glm)、[深度思考](https://help.aliyun.com/zh/model-studio/deep-thinking)、[Kimi on DashScope](https://help.aliyun.com/zh/model-studio/kimi-api-by-moonshot-ai)、[MiniMax on DashScope](https://help.aliyun.com/en/model-studio/minimax-api-by-minimax) | | DashScope K3 接入边界与容量 | `kimi/kimi-k3` 上下文为 100 万 token,仅支持公网 URL 图片和隐式缓存;当前附件适配器只生成 Data URL,因此不开放多模态标记;K3 的 `preserve_thinking` 默认关闭,工具循环必须显式开启;最大输出缺少精确证据,运行时保持 8192 的保守值 | `registry.py`、`dashscope_provider.py`、`context_manager.py` 及对应测试 | [K3 上架说明](https://help.aliyun.com/zh/model-studio/newly-released-models)、[Kimi on DashScope](https://help.aliyun.com/zh/model-studio/kimi-api-by-moonshot-ai)、[Context Cache](https://help.aliyun.com/zh/model-studio/context-cache) | | Kimi 官方 | K3 始终思考并支持 `low/high/max`,context 为 100 万;K2.7 始终思考,关闭参数会报错;K2.6 可启停;K2.5/K2.6/K2.7 的 context 为 256K | `kimi_provider.py`、`context_manager.py`、Kimi 调研回归测试 | [Kimi API Platform](https://platform.kimi.ai/)、[Models overview](https://platform.kimi.ai/docs/api/models-overview) | -| MiniMax 官方 | 中国/国际直连 provider 使用 MiniMax 官方兼容端点,默认模型 `MiniMax-M3` 的 context 为 100 万;DashScope 托管的 `MiniMax/MiniMax-M3` 仍按该端点文档配置为 196,608,二者必须按完整 model ID 分别验证 | `registry.py`、`thinking.py`、`minimax_provider.py`、`context_manager.py`、direct provider 调研回归测试 | [Models](https://platform.minimax.io/docs/guides/models-intro)、[API Overview](https://platform.minimax.io/docs/api-reference/api-overview) | +| MiniMax 官方 | 中国/国际直连 provider 使用 MiniMax 官方兼容端点,默认模型 `MiniMax-M3` 的 context 为 100 万;DashScope 托管的 `MiniMax/MiniMax-M3` 也必须按其独立模型信息页配置为 1,048,576,仍需按完整 model ID 分别验证其他能力和参数 | `registry.py`、`thinking.py`、`minimax_provider.py`、`context_manager.py`、direct/DashScope provider 调研回归测试 | [Models](https://platform.minimax.io/docs/guides/models-intro)、[百炼 MiniMax M3](https://help.aliyun.com/zh/model-studio/minimax-m3) | | Z.AI / GLM 官方 | 中国、国际和 Coding Plan provider 均登记 `glm-5.2`,使用 Chat Completions 的 `thinking.type=enabled/disabled` 协议;直连 GLM-5.2 支持 `reasoning_effort=high/max`、默认 `max`,context 为 100 万、最大输出为 12.8 万 | `registry.py`、`thinking.py`、`zhipu_provider.py`、`context_manager.py`、direct provider 调研回归测试 | [GLM-5.2](https://docs.bigmodel.cn/cn/guide/models/text/glm-5.2)、[Migration](https://docs.bigmodel.cn/cn/guide/start/migrate-to-glm-new)、[Chat Completion](https://docs.z.ai/api-reference/llm/chat-completion) | | DeepSeek | V4 Pro/Flash 通过 Chat Completions 提供思考能力,仓库仅开放官方确认的 `high/max` 档位 | `registry.py`、`thinking.py`、DeepSeek provider 测试 | [Models and pricing](https://api-docs.deepseek.com/quick_start/pricing) | | 遥测与公开输出 | 所有静态 registry model ID 都应进入有限白名单;provider signature 只作为内部协议状态,不进入 `stream-json` | `telemetry/constants.py`、`cli/output_formats.py` 及对应测试 | 仓库内隐私边界和本次官方模型证据 | @@ -508,7 +508,8 @@ git diff HEAD -- iac-code-rs | Gemini | `gemini-3.8-flash` 成为默认,保留 3.7/3.6/3.5 兼容项;3.8 的 effort 为 `low/medium/high`、默认 `medium`,不接受 `minimal`,且当前接入不提供关闭思考 | `registry.py`、`thinking.py`、`context_manager.py`、fallback/telemetry 及 Gemini 目录测试 | [Gemini 3.8 Flash](https://ai.google.dev/gemini-api/docs/models/gemini-3.8-flash)、[Models](https://ai.google.dev/gemini-api/docs/models)、[Thinking](https://ai.google.dev/gemini-api/docs/thinking) | | 百炼标准端点 / Qwen | 华北2标准兼容端点默认模型更新为精确快照 `qwen3.8-max-0902`,同时保留 `qwen3.8-max`;默认项依据当前标准端点目录,不能复制到 Token Plan。百炼模型市场属于动态控制台证据,应与文档页、精确 API ID 和日期一起记录 | `registry.py`、`config.py`、`thinking.py`、context/fallback/telemetry 及目录测试 | [百炼模型列表](https://help.aliyun.com/zh/model-studio/models)、[百炼模型市场](https://bailian.console.aliyun.com/cn-beijing/model/market)、[OpenAI-compatible Chat](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-chat-completions) | | Token Plan | Token Plan 目录按套餐页重新收敛为其实际支持项,默认仍为 `qwen3.8-max`;标准百炼的 `qwen3.8-max-0902`、第三方模型和旧套餐模型不得自动复制进来。Token Plan 与标准百炼分别维护目录、默认项和负向断言 | `registry.py`、`thinking.py`、`manager.py`、Token Plan 精确列表测试 | [Token Plan 个人版](https://help.aliyun.com/zh/model-studio/token-plan-personal-overview)、[百炼模型列表](https://help.aliyun.com/zh/model-studio/models) | -| DeepSeek 直连 / 百炼 | DeepSeek 直连当前默认 API ID 为 `deepseek-flash`,保留兼容 ID `deepseek-v4-flash`,不把产品版本名直接写成直连 ID。百炼标准端点与 Token Plan 则登记 `deepseek-v4.1-flash`,两者不能复用 ID 或 endpoint 结论。对指定 Workspace Chat Completions URL 的最小请求实测表明,`reasoning_effort` 接受 `minimal/low/medium/high/xhigh/max/ultra`,数值字符串 `1`、`100` 被拒绝;该结论只绑定当次 URL、model 和 surface | `registry.py`、`thinking.py`、`dashscope_provider.py`、`deepseek_provider.py`、终端/Web effort 与 provider 测试 | [DeepSeek models](https://api-docs.deepseek.com/quick_start/pricing)、[DeepSeek thinking mode](https://api-docs.deepseek.com/guides/thinking_mode/)、[百炼模型列表](https://help.aliyun.com/zh/model-studio/models)、指定 Workspace URL 的 2026-09-14 脱敏实测 | +| DeepSeek 直连 / 百炼 | DeepSeek 直连默认 API ID 为 `deepseek-v4-flash`,并登记仅该端点提供的多模态模型 `deepseek-v4-flash-vision-exp`;只有 vision 模型声明图片能力。百炼标准端点与 Token Plan 则登记 `deepseek-v4.1-flash`,两者不能复用 ID、模型能力或 endpoint 结论。对指定 Workspace Chat Completions URL 的最小请求实测表明,`reasoning_effort` 接受 `minimal/low/medium/high/xhigh/max/ultra`,数值字符串 `1`、`100` 被拒绝;该结论只绑定当次 URL、model 和 surface,不能据此改写 DeepSeek 官方端点的模型 ID | `registry.py`、`thinking.py`、`dashscope_provider.py`、`deepseek_provider.py`、context/telemetry、终端/Web effort 与 provider 测试 | [DeepSeek models](https://api-docs.deepseek.com/quick_start/pricing)、[DeepSeek thinking mode](https://api-docs.deepseek.com/guides/thinking_mode/)、[百炼模型列表](https://help.aliyun.com/zh/model-studio/models)、指定 Workspace URL 的 2026-09-14 脱敏实测 | | Kimi 公共 API / Kimi Code | 公共 API 目录移除已下线的 `kimi-k2.5`。Kimi Code 作为独立订阅 provider 接入 `https://api.kimi.com/coding/v1`,精确 ID 为 `k3`、`k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`;其中 `kimi-for-coding` 是 K2.8 Preview 的 API 别名并作为该 provider 默认。K3 默认 effort 为 `high`,K2.8 默认 `max`,可选值均为 `low/high/max`;highspeed 为固定思考行为 | `registry.py`、`thinking.py`、`kimi_provider.py`、认证/设置/终端/Web 与 Kimi Code 测试 | [Kimi public models](https://platform.kimi.ai/docs/models)、[Kimi Code models](https://www.kimi.com/code/docs/kimi-code/models.html) | -| MiniMax 直连 / 百炼原厂直供 | MiniMax 中国/国际直连仍以 `MiniMax-M3` 为默认。百炼原厂直供使用带命名空间的 `MiniMax/MiniMax-M3`、`MiniMax/MiniMax-M2.7`、`MiniMax/MiniMax-M2.5`、`MiniMax/MiniMax-M2.1`,不能与直连 `MiniMax-*` 混用;M3 的多模态和 adaptive thinking 也按百炼端点单独声明 | `registry.py`、`thinking.py`、`minimax_provider.py`、context/telemetry 与目录测试 | [MiniMax official models](https://platform.minimax.io/docs/guides/models-intro)、[百炼 MiniMax 原厂直供](https://help.aliyun.com/zh/model-studio/minimax-api-by-minimax) | +| MiniMax 直连 / 百炼原厂直供 | MiniMax 中国/国际直连仍以 `MiniMax-M3` 为默认。百炼原厂直供使用带命名空间的 `MiniMax/MiniMax-M3`、`MiniMax/MiniMax-M2.7`、`MiniMax/MiniMax-M2.5`、`MiniMax/MiniMax-M2.1`,不能与直连 `MiniMax-*` 混用;百炼 `MiniMax/MiniMax-M3` 的多模态、adaptive thinking 和 1,048,576 context 必须按其模型信息页单独声明 | `registry.py`、`thinking.py`、`minimax_provider.py`、context/telemetry 与目录测试 | [MiniMax official models](https://platform.minimax.io/docs/guides/models-intro)、[百炼 MiniMax M3](https://help.aliyun.com/zh/model-studio/minimax-m3) | +| 百炼智谱直供 | `ZHIPU/GLM-5.3-Flash` 原生支持 Image、Video、File 输入;当前附件适配器可发送其支持的 Base64 Data URL,因此必须声明图片能力。其 fallback 也必须保持图片能力,不能降级到纯文本 `ZHIPU/GLM-5.3` | `registry.py`、`manager.py`、multimodal capability 与 fallback 测试 | [ZHIPU/GLM-5.3-Flash](https://help.aliyun.com/zh/model-studio/glm-5-3-flash-by-zhipu) | | 终端 / Web effort 交互 | effort 选择器统一消费 provider/model capability 的标准枚举,Web 设置接口和会话选择器同步支持;若未来出现超长范围,则按前文使用可滚动选择器或分组子菜单。新增 `ultra` 后补齐 `messages` 与 `webui` 的全部 locale 翻译,并对 Web 静态模块更新 cache-bust token | `commands/effort.py`、`ui/dialogs/model_picker.py`、`web/settings.py`、`web/static/`、两套翻译域及终端/Web 回归测试 | 仓库 UI/i18n 约束与上述逐端点能力证据 | diff --git a/src/iac_code/a2a/executor.py b/src/iac_code/a2a/executor.py index 49f30c25..b1f75717 100644 --- a/src/iac_code/a2a/executor.py +++ b/src/iac_code/a2a/executor.py @@ -117,6 +117,7 @@ mark_cleanup_prompt_message_completed, ) from iac_code.pipeline.engine.user_input import PipelineUserInput, normalize_pipeline_user_input +from iac_code.providers.request_headers import use_provider_request_headers from iac_code.providers.request_policy import ProviderRequestPolicy from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime from iac_code.services.capabilities.multimodal import is_model_multimodal @@ -1268,10 +1269,23 @@ def __init__( execution_control_service.set_termination_cleanup(self._terminate_detached_execution) execution_control_service.set_resume_callback(self._task_store.touch_context) - async def resolve_sideband_permission(self, response: PermissionResponse) -> Message | None: + async def resolve_sideband_permission( + self, response: PermissionResponse, *, metadata: Any = None + ) -> Message | None: if not await self._permission_input_registry.is_sideband_response(response): return None - approved = await self._permission_input_registry.answer(response) + requested_llm_headers = resolve_a2a_llm_headers(metadata) + + async def commit_llm_headers() -> None: + await self._task_store.bind_context_llm_headers( + response.context_id, + requested_llm_headers, + ) + + approved = await self._permission_input_registry.answer( + response, + before_delivery=commit_llm_headers, + ) return permission_ack_message(response, approved=approved) async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: @@ -1288,10 +1302,6 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non context_id, self._resolve_telemetry_channel(metadata), ) - llm_headers = await self._task_store.resolve_context_llm_headers( - context_id, - resolve_a2a_llm_headers(metadata), - ) try: if permission_response is not None and self._execution_control_service is not None: existing = self._execution_control_service.get_for_context(context_id) @@ -1300,11 +1310,52 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non if existing is not None and existing.owner == owner and current_task is not None: await existing.attach_task(current_task, mark_working=False) bind_execution_control(existing) - with a2a_request_context( - telemetry_channel=telemetry_channel, - llm_headers=llm_headers, - ): - await self._execute(context, event_queue, context_id=context_id) + requested_llm_headers = resolve_a2a_llm_headers(metadata) + with contextlib.ExitStack() as request_scope: + request_scope.enter_context(a2a_request_context(telemetry_channel=telemetry_channel)) + llm_headers: Mapping[str, str] | None = None + llm_headers_committed = False + llm_headers_active = False + + async def commit_llm_headers() -> None: + """Update the shared session binding after task/context validation.""" + + nonlocal llm_headers, llm_headers_committed + if llm_headers_committed: + return + llm_headers = await self._task_store.bind_context_llm_headers( + context_id, + requested_llm_headers, + ) + llm_headers_committed = True + + async def activate_llm_headers() -> None: + """Activate committed headers in the current request's async context.""" + + await commit_llm_headers() + await activate_bound_llm_headers() + + async def activate_bound_llm_headers() -> None: + """Activate the session binding without replacing it from this request.""" + + nonlocal llm_headers + nonlocal llm_headers_active + if llm_headers_active: + return + if llm_headers is None: + llm_headers = await self._task_store.bind_context_llm_headers(context_id, None) + assert llm_headers is not None + request_scope.enter_context(use_provider_request_headers(llm_headers, live=True)) + llm_headers_active = True + + await self._execute( + context, + event_queue, + context_id=context_id, + commit_llm_headers=commit_llm_headers, + activate_bound_llm_headers=activate_bound_llm_headers, + activate_llm_headers=activate_llm_headers, + ) finally: control = current_execution_control() if control is not None: @@ -1320,7 +1371,31 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non reset_execution_control(execution_scope) reset_execution_participants(participant_scope) - async def _execute(self, context: RequestContext, event_queue: EventQueue, *, context_id: str) -> None: + async def _execute( + self, + context: RequestContext, + event_queue: EventQueue, + *, + context_id: str, + commit_llm_headers: Callable[[], Awaitable[None]] | None = None, + activate_bound_llm_headers: Callable[[], Awaitable[None]] | None = None, + activate_llm_headers: Callable[[], Awaitable[None]] | None = None, + ) -> None: + if commit_llm_headers is None: + + async def commit_llm_headers() -> None: + return None + + if activate_bound_llm_headers is None: + + async def activate_bound_llm_headers() -> None: + return None + + if activate_llm_headers is None: + + async def activate_llm_headers() -> None: + return None + requested_task_id = context.task_id or None task_id = requested_task_id or "task-" + uuid.uuid4().hex[:12] permission_response = parse_permission_response(getattr(context, "message", None)) @@ -1332,8 +1407,19 @@ async def _execute(self, context: RequestContext, event_queue: EventQueue, *, co pending = None try: pending = await self._permission_input_registry.pending_for_response(permission_response) + owner = self._task_store.owner_for_context(getattr(context, "call_context", None)) + if owner: + permission_task = await self._task_store.get_task_record(permission_response.task_id) + if permission_task.context_id != permission_response.context_id: + raise PermissionIdentityValidationError("permission_task_context_changed", retryable=False) + if permission_task.owner and permission_task.owner != owner: + raise PermissionIdentityValidationError("permission_task_owner_changed", retryable=False) with a2a_request_context(aliyun_credential=response_credential): - approved = await self._permission_input_registry.answer(permission_response) + approved = await self._permission_input_registry.answer( + permission_response, + before_delivery=commit_llm_headers, + ) + await activate_bound_llm_headers() except PermissionIdentityValidationError as exc: await self._publish_permission_identity_error( event_queue, @@ -1350,6 +1436,7 @@ async def _execute(self, context: RequestContext, event_queue: EventQueue, *, co context, event_queue, response=permission_response, + activate_llm_headers=activate_llm_headers, ): return raise @@ -1391,6 +1478,7 @@ async def _execute(self, context: RequestContext, event_queue: EventQueue, *, co context, event_queue, response=permission_response, + activate_llm_headers=activate_llm_headers, ): return raise InvalidParamsError("permission_resume_invalid: suspended permission is unavailable.") @@ -1620,6 +1708,7 @@ async def release_context_execution() -> None: request_policy_override=request_policy_override, backup_service=self._backup_service, pipeline_name=requested_pipeline_name, + context_ready_callback=activate_llm_headers, ) try: pipeline_result = await pipeline_executor.execute( @@ -1711,6 +1800,7 @@ def runtime_factory(session_id: str) -> Any: cwd=cwd, runtime_factory=runtime_factory, ) + await activate_llm_headers() control = current_execution_control() if control is not None: control.bind_session(ctx.session_id) @@ -2418,6 +2508,7 @@ async def _resume_persisted_permission( event_queue: EventQueue, *, response: PermissionResponse, + activate_llm_headers: Callable[[], Awaitable[None]] | None = None, ) -> bool: """Claim and resume a permission whose process-local registry was lost.""" @@ -2433,6 +2524,9 @@ async def _resume_persisted_permission( return False if task_record.context_id != response.context_id: raise InvalidParamsError("input_response_mismatch: permission task context changed.") + owner = self._task_store.owner_for_context(getattr(context, "call_context", None)) + if owner and task_record.owner and task_record.owner != owner: + raise InvalidParamsError("Task belongs to a different owner") minimum_generation = getattr(task_record, "expected_permission_backup_generation", None) try: reconcile_result = await self._reconcile_session_before_route( @@ -2476,6 +2570,8 @@ async def _resume_persisted_permission( decision = record.get("decision") if not isinstance(decision, dict) or decision.get("value") != expected_value: raise InvalidParamsError("permission_resume_invalid: permission decision conflicts with receipt.") + if activate_llm_headers is not None: + await activate_llm_headers() await self._publish_permission_recovery_ack( event_queue, response=response, @@ -2490,6 +2586,8 @@ async def _resume_persisted_permission( raise InvalidParamsError( "permission_resume_invalid: permission decision conflicts with active recovery." ) + if activate_llm_headers is not None: + await activate_llm_headers() await self._publish_permission_recovery_ack( event_queue, response=response, @@ -2526,6 +2624,7 @@ def make_pipeline_executor() -> IacCodeA2APipelineExecutor: metadata_api_key=metadata_api_key, request_policy_override=request_policy_override, backup_service=self._backup_service, + context_ready_callback=activate_llm_headers, ) persisted_decision = record.get("decision") @@ -2677,6 +2776,8 @@ def audit_claim(value: str) -> bool: task_record = await self._task_store.get_or_create_task( task_id=response.task_id, context_id=response.context_id, restore_interrupted=False ) + if activate_llm_headers is not None: + await activate_llm_headers() if not await self._permission_wait_coordinator.acquire_restore(boundary_id): await self._publish_permission_recovery_ack( event_queue, diff --git a/src/iac_code/a2a/input_required.py b/src/iac_code/a2a/input_required.py index c09a0dc5..3a9e23d6 100644 --- a/src/iac_code/a2a/input_required.py +++ b/src/iac_code/a2a/input_required.py @@ -149,7 +149,13 @@ def envelope(self) -> dict[str, Any]: class PermissionResolutionOwner(Protocol): - async def resolve_permission(self, pending: PendingPermission, response: PermissionResponse) -> bool: ... + async def resolve_permission( + self, + pending: PendingPermission, + response: PermissionResponse, + *, + before_delivery: Callable[[], Awaitable[None]] | None = None, + ) -> bool: ... async def fail_permission(self, pending: PendingPermission) -> None: ... @@ -1161,10 +1167,21 @@ async def register( request.resolution_owner_managed = True return pending - async def answer(self, response: PermissionResponse) -> bool: + async def answer( + self, + response: PermissionResponse, + *, + before_delivery: Callable[[], Awaitable[None]] | None = None, + ) -> bool: pending = await self._lookup(response) if pending.resolution_owner is not None: - return await pending.resolution_owner.resolve_permission(pending, response) + if before_delivery is None: + return await pending.resolution_owner.resolve_permission(pending, response) + return await pending.resolution_owner.resolve_permission( + pending, + response, + before_delivery=before_delivery, + ) coordinator = self._permission_wait_coordinator if coordinator is not None and pending.boundary_id is not None: @@ -1188,6 +1205,7 @@ def audit_new_claim(value: str) -> bool: source="user", on_new_claim=audit_new_claim, before_delivery=lambda record: self._backup_claim_before_delivery(pending, record), + before_release=before_delivery, ) except (LookupError, ValueError) as exc: raise InvalidParamsError(f"permission_resume_invalid: {exc}") from exc @@ -1213,6 +1231,8 @@ def audit_new_claim(value: str) -> bool: ) if approved and not audit_ok: approved = False + if before_delivery is not None: + await before_delivery() future.set_result(approved) return approved diff --git a/src/iac_code/a2a/metadata_redaction.py b/src/iac_code/a2a/metadata_redaction.py index d1ab145e..628466da 100644 --- a/src/iac_code/a2a/metadata_redaction.py +++ b/src/iac_code/a2a/metadata_redaction.py @@ -9,7 +9,7 @@ class A2AMetadataEchoRedactor: - """Compatibility wrapper that now preserves canonical A2A message data.""" + """Preserve canonical A2A message data while withholding provider headers.""" def redact_message_echo( self, @@ -18,8 +18,10 @@ def redact_message_echo( public_path_roots: Iterable[Mapping[str, str]] | None = None, ) -> Message: del public_path_roots + payload = MessageToDict(message, preserving_proto_field_name=False) + _remove_llm_headers(payload.get("metadata")) result = Message() - ParseDict(MessageToDict(message, preserving_proto_field_name=False), result) + ParseDict(payload, result) return result def redact( @@ -29,4 +31,33 @@ def redact( public_path_roots: Iterable[Mapping[str, str]] | None = None, ) -> Any: del public_path_roots - return copy.deepcopy(value) + result = copy.deepcopy(value) + if isinstance(result, dict): + metadata = result.get("metadata") + _remove_llm_headers(metadata if isinstance(metadata, dict) else result) + return result + + +def _remove_llm_headers(metadata: Any) -> None: + if not isinstance(metadata, dict): + return + iac_code = metadata.get("iac_code") + if isinstance(iac_code, dict): + iac_code.pop("llm_headers", None) + + +def strip_llm_headers_from_metadata(metadata: Any) -> None: + """Remove provider headers from dict or protobuf Struct metadata in place.""" + + if isinstance(metadata, dict): + _remove_llm_headers(metadata) + return + fields = getattr(metadata, "fields", None) + if fields is None: + return + iac_code = fields.get("iac_code") + if iac_code is None or iac_code.WhichOneof("kind") != "struct_value": + return + iac_fields = iac_code.struct_value.fields + if "llm_headers" in iac_fields: + del iac_fields["llm_headers"] diff --git a/src/iac_code/a2a/pipeline_executor.py b/src/iac_code/a2a/pipeline_executor.py index 39833e1d..3cc4eae2 100644 --- a/src/iac_code/a2a/pipeline_executor.py +++ b/src/iac_code/a2a/pipeline_executor.py @@ -417,6 +417,7 @@ def __init__( backup_service: Any | None = None, aliyun_delegated_executor_factory: Any | None = None, pipeline_name: str | None = None, + context_ready_callback: Callable[[], Awaitable[None]] | None = None, ) -> None: self._task_store = task_store self._model = model @@ -443,6 +444,7 @@ def __init__( self._backup_service = backup_service or SessionBackupService() self._aliyun_delegated_executor_factory = aliyun_delegated_executor_factory self._pipeline_name_override = pipeline_name or None + self._context_ready_callback = context_ready_callback def _resolve_pipeline_name(self) -> str: """Pipeline this executor must run. @@ -617,6 +619,8 @@ def runtime_factory(session_id: str) -> Any: cwd=cwd, runtime_factory=runtime_factory, ) + if self._context_ready_callback is not None: + await self._context_ready_callback() control = current_execution_control() if control is not None: control.bind_session(ctx.session_id) diff --git a/src/iac_code/a2a/pipeline_stream.py b/src/iac_code/a2a/pipeline_stream.py index fab579aa..85dd0a8f 100644 --- a/src/iac_code/a2a/pipeline_stream.py +++ b/src/iac_code/a2a/pipeline_stream.py @@ -1473,7 +1473,13 @@ class _PipelinePermissionResolutionOwner: def __init__(self, publisher: PipelineA2AEventPublisher) -> None: self.publisher = publisher - async def resolve_permission(self, pending: PendingPermission, response: PermissionResponse) -> bool: + async def resolve_permission( + self, + pending: PendingPermission, + response: PermissionResponse, + *, + before_delivery: Callable[[], Awaitable[None]] | None = None, + ) -> bool: registry = self.publisher.permission_input_registry if registry is None: raise PipelineA2APersistenceError("Sub Pipeline permission registry is unavailable") @@ -1503,6 +1509,8 @@ async def resolve_permission(self, pending: PendingPermission, response: Permiss if future is None or future.done(): await registry.complete(pending) raise PipelineA2APersistenceError("Sub Pipeline permission wait point is unavailable") + if before_delivery is not None: + await before_delivery() future.set_result(approved) await registry.complete(pending) return approved diff --git a/src/iac_code/a2a/task_store.py b/src/iac_code/a2a/task_store.py index c4566888..ccedee52 100644 --- a/src/iac_code/a2a/task_store.py +++ b/src/iac_code/a2a/task_store.py @@ -23,6 +23,7 @@ from iac_code.a2a.backup import await_fenced, run_sync_fenced from iac_code.a2a.events import with_iac_code_session_metadata from iac_code.a2a.execution_control import current_execution_control, current_execution_termination_reason +from iac_code.a2a.metadata_redaction import strip_llm_headers_from_metadata from iac_code.a2a.metrics import A2AMetrics, NoOpA2AMetrics from iac_code.a2a.persistence import A2AContextSnapshot, A2APersistenceStore, A2ATaskSnapshot from iac_code.a2a.types import ( @@ -142,6 +143,7 @@ async def get(self, task_id: str, context: ServerCallContext | None = None) -> T async def save(self, task: Task, context: ServerCallContext | None = None) -> None: owner = self._owner(context) task_id = validate_protocol_id(task.id) + _strip_task_llm_headers(task) async with self._mutation_lock: record = self._tasks.get(task_id) next_state = _task_state_from_sdk_task(task) @@ -469,6 +471,7 @@ async def get_or_create_context( record = self._contexts.get(context_id) if record is not None and record.runtime is None and current_execution_termination_reason() is None: self._contexts.pop(context_id, None) + self._discard_context_llm_headers_locked(context_id) if self._context_runtime_tasks.get(context_id) is create_task: discard_task = self._context_runtime_tasks.pop(context_id, None) if discard_task is not None: @@ -499,6 +502,7 @@ async def drain_runtime() -> None: record = self._contexts.get(context_id) if record is not None and record.runtime is None: self._contexts.pop(context_id, None) + self._discard_context_llm_headers_locked(context_id) if self._context_runtime_tasks.get(context_id) is create_task: self._context_runtime_tasks.pop(context_id, None) raise @@ -585,14 +589,36 @@ async def resolve_context_llm_headers( context_id = validate_protocol_id(context_id) async with self._mutation_lock: + binding = self._context_llm_headers.get(context_id) if requested_headers is not None: - headers = dict(requested_headers) - if headers: - self._context_llm_headers[context_id] = headers - else: - self._context_llm_headers.pop(context_id, None) - return dict(headers) - return dict(self._context_llm_headers.get(context_id, {})) + replacement = dict(requested_headers) + if binding is None: + binding = {} + self._context_llm_headers[context_id] = binding + binding.clear() + binding.update(replacement) + return dict(binding or {}) + + async def bind_context_llm_headers( + self, + context_id: str, + requested_headers: Mapping[str, str] | None, + ) -> Mapping[str, str]: + """Return the stable live header binding for one validated A2A context.""" + + context_id = validate_protocol_id(context_id) + async with self._mutation_lock: + binding = self._context_llm_headers.setdefault(context_id, {}) + if requested_headers is not None: + replacement = dict(requested_headers) + binding.clear() + binding.update(replacement) + return binding + + def _discard_context_llm_headers_locked(self, context_id: str) -> None: + binding = self._context_llm_headers.pop(context_id, None) + if binding is not None: + binding.clear() async def get_context_record(self, context_id: str) -> A2AContextRecord: context_id = validate_protocol_id(context_id) @@ -1242,6 +1268,7 @@ async def cleanup_once(self, *, now_offset_seconds: float = 0) -> None: record = self._contexts.pop(context_id, None) if record is not None: await _close_runtime(record.runtime) + self._discard_context_llm_headers_locked(context_id) for task_id, task in list(self._tasks.items()): if task.context_id == context_id: task.expired = True @@ -1281,6 +1308,8 @@ async def stop_cleanup_loop(self) -> None: ] self._contexts.clear() self._pending_context_telemetry_channels.clear() + for binding in self._context_llm_headers.values(): + binding.clear() self._context_llm_headers.clear() self._context_runtime_tasks.clear() self._context_runtime_waiters.clear() @@ -1628,9 +1657,20 @@ def close_result(done: asyncio.Task[Any]) -> None: def _copy_task(task: Task) -> Task: copied = Task() copied.CopyFrom(task) + _strip_task_llm_headers(copied) return copied +def _strip_task_llm_headers(task: Task) -> None: + """Keep sensitive provider headers out of every public Task projection.""" + + strip_llm_headers_from_metadata(task.metadata) + if task.HasField("status") and task.status.HasField("message"): + strip_llm_headers_from_metadata(task.status.message.metadata) + for message in task.history: + strip_llm_headers_from_metadata(message.metadata) + + def _project_task(task: Task, *, include_artifacts: bool) -> Task: projected = _copy_task(task) if not include_artifacts: diff --git a/src/iac_code/a2a/transports/dispatcher.py b/src/iac_code/a2a/transports/dispatcher.py index caf1088d..a485d473 100644 --- a/src/iac_code/a2a/transports/dispatcher.py +++ b/src/iac_code/a2a/transports/dispatcher.py @@ -525,7 +525,7 @@ async def on_message_send(self, params: SendMessageRequest, context): params.message.task_id = permission_response.task_id resolve = getattr(getattr(self, "agent_executor", None), "resolve_sideband_permission", None) if callable(resolve): - ack = await resolve(permission_response) + ack = await resolve(permission_response, metadata=params.message) if ack is not None: return ack if isinstance(self.task_store, A2ATaskStore) and not await self.task_store.is_task_active( @@ -554,7 +554,7 @@ async def on_message_send_stream(self, params: SendMessageRequest, context): params.message.task_id = permission_response.task_id resolve = getattr(getattr(self, "agent_executor", None), "resolve_sideband_permission", None) if callable(resolve): - ack = await resolve(permission_response) + ack = await resolve(permission_response, metadata=params.message) if ack is not None: yield ack return diff --git a/src/iac_code/providers/manager.py b/src/iac_code/providers/manager.py index 15281808..e91f9692 100644 --- a/src/iac_code/providers/manager.py +++ b/src/iac_code/providers/manager.py @@ -411,7 +411,7 @@ def _error_event_from_exception(exc: BaseException) -> ErrorEvent: "qwen3.8-max-0902": "qwen3.8-max", "qwen3.8-flash": "qwen3.7-flash", "deepseek-v4.1-flash": "qwen3.8-flash", - "ZHIPU/GLM-5.3-Flash": "ZHIPU/GLM-5.3", + "ZHIPU/GLM-5.3-Flash": "qwen3.8-flash", "qwen3.6-plus": "qwen3.6-flash", }, "dashscope_token_plan": { diff --git a/src/iac_code/providers/registry.py b/src/iac_code/providers/registry.py index bf2bf3d2..4018f170 100644 --- a/src/iac_code/providers/registry.py +++ b/src/iac_code/providers/registry.py @@ -83,7 +83,7 @@ def model_ids(self) -> list[str]: ModelEntry("glm-5.2"), ModelEntry("glm-5.1"), ModelEntry("ZHIPU/GLM-5.3"), - ModelEntry("ZHIPU/GLM-5.3-Flash"), + ModelEntry("ZHIPU/GLM-5.3-Flash", support_multimodal=True), ModelEntry("MiniMax/MiniMax-M3", support_multimodal=True), ModelEntry("MiniMax/MiniMax-M2.7"), ModelEntry("MiniMax/MiniMax-M2.5"), @@ -167,10 +167,9 @@ def model_ids(self) -> list[str]: provider_class="iac_code.providers.deepseek_provider.DeepSeekProvider", base_url="https://api.deepseek.com/v1", models=[ - ModelEntry("deepseek-flash", is_default=True, support_multimodal=True), + ModelEntry("deepseek-v4-flash", is_default=True), ModelEntry("deepseek-v4-pro"), - # Temporarily routed to V4.1 Flash by DeepSeek for compatibility. - ModelEntry("deepseek-v4-flash"), + ModelEntry("deepseek-v4-flash-vision-exp", support_multimodal=True), ], qwenpaw_provider_ids=["deepseek"], ), diff --git a/src/iac_code/providers/request_headers.py b/src/iac_code/providers/request_headers.py index d8c21702..2009ac79 100644 --- a/src/iac_code/providers/request_headers.py +++ b/src/iac_code/providers/request_headers.py @@ -6,7 +6,7 @@ import contextvars from collections.abc import Iterator, Mapping -_request_headers: contextvars.ContextVar[tuple[tuple[str, str], ...]] = contextvars.ContextVar( +_request_headers: contextvars.ContextVar[Mapping[str, str] | tuple[tuple[str, str], ...]] = contextvars.ContextVar( "iac_code_provider_request_headers", default=(), ) @@ -18,9 +18,14 @@ def get_provider_request_headers() -> dict[str, str]: @contextlib.contextmanager -def use_provider_request_headers(headers: Mapping[str, str]) -> Iterator[None]: +def use_provider_request_headers(headers: Mapping[str, str], *, live: bool = False) -> Iterator[None]: """Apply HTTP headers to provider calls made in the current async context.""" - token = _request_headers.set(tuple(headers.items())) + # A live binding is intentionally shared by A2A continuation tasks so a + # sideband permission response can rotate or clear session headers before + # the suspended Pipeline makes its next provider call. Other callers keep + # the immutable snapshot behavior. + value: Mapping[str, str] | tuple[tuple[str, str], ...] = headers if live else tuple(headers.items()) + token = _request_headers.set(value) try: yield finally: diff --git a/src/iac_code/providers/thinking.py b/src/iac_code/providers/thinking.py index b068caab..8df20871 100644 --- a/src/iac_code/providers/thinking.py +++ b/src/iac_code/providers/thinking.py @@ -459,9 +459,9 @@ def effort_range(self) -> tuple[EffortLevel, EffortLevel] | None: "o4-mini": _OPENAI_O_SERIES_SPEC, }, "deepseek": { - "deepseek-flash": _DEEPSEEK_SPEC, "deepseek-v4-pro": _DEEPSEEK_SPEC, "deepseek-v4-flash": _DEEPSEEK_SPEC, + "deepseek-v4-flash-vision-exp": _DEEPSEEK_SPEC, }, "dashscope": { "qwen3.8-max": _DASHSCOPE_QWEN38_SPEC, diff --git a/src/iac_code/services/context_manager.py b/src/iac_code/services/context_manager.py index 9ee3653c..d1ada83a 100644 --- a/src/iac_code/services/context_manager.py +++ b/src/iac_code/services/context_manager.py @@ -111,11 +111,11 @@ def _context_config(context_window: int, max_output_tokens: int = 8_192) -> Cont "qwen3.6-max-preview": _context_config(262_144), "qwen3-max": _context_config(262_144), "qwen3-coder-next": _context_config(262_144), - "deepseek-flash": _context_config(1_000_000, 393_216), "deepseek-v4-pro": _context_config(1_000_000, 393_216), "deepseek-v4-pro-0813": _context_config(1_000_000, 393_216), "deepseek-v4-flash-0731": _context_config(1_000_000, 393_216), "deepseek-v4-flash": _context_config(1_000_000, 393_216), + "deepseek-v4-flash-vision-exp": _context_config(1_000_000, 393_216), "deepseek-v4.1-flash": _context_config(1_000_000, 393_216), "glm-5.3": _context_config(1_000_000, 128_000), "glm-5.3-flash": _context_config(1_000_000, 128_000), @@ -126,7 +126,7 @@ def _context_config(context_window: int, max_output_tokens: int = 8_192) -> Cont "glm-5.1": _context_config(202_752), "glm-5": _context_config(202_752), "minimax-m3": _context_config(1_000_000), - "minimax/minimax-m3": _context_config(196_608), + "minimax/minimax-m3": _context_config(1_048_576), "minimax/minimax-m2.7": _context_config(196_608), "minimax/minimax-m2.5": _context_config(196_608), "minimax/minimax-m2.1": _context_config(196_608), diff --git a/src/iac_code/services/permission_wait.py b/src/iac_code/services/permission_wait.py index 2a9c1e58..1f555d9f 100644 --- a/src/iac_code/services/permission_wait.py +++ b/src/iac_code/services/permission_wait.py @@ -1166,6 +1166,7 @@ async def claim_live( source: str = "user", on_new_claim: Callable[[str], bool] | None = None, before_delivery: Callable[[dict[str, Any]], Awaitable[None] | None] | None = None, + before_release: Callable[[], Awaitable[None]] | None = None, ) -> tuple[dict[str, Any], bool]: # Keep delivery independent of the transport request that carried the # answer. Once the decision is durable, canceling that request must not @@ -1177,6 +1178,7 @@ async def claim_live( source=source, on_new_claim=on_new_claim, before_delivery=before_delivery, + before_release=before_release, ) ) delivery.add_done_callback(_consume_background_exception) @@ -1190,6 +1192,7 @@ async def _claim_live_and_deliver( source: str, on_new_claim: Callable[[str], bool] | None, before_delivery: Callable[[dict[str, Any]], Awaitable[None] | None] | None, + before_release: Callable[[], Awaitable[None]] | None, ) -> tuple[dict[str, Any], bool]: owner = self._owners.get(boundary_id) if owner is None: @@ -1229,6 +1232,8 @@ async def _claim_live_and_deliver( await result record = owner.store.mark_claim_backed_up(boundary_id, claim_id=claim_id) owner.generation = int(record["generation"]) + if created and before_release is not None: + await before_release() phase = record.get("phase") if phase in {"SUSPENDING", "SUSPENDED", "RESTORING"}: return record, created diff --git a/src/iac_code/services/telemetry/constants.py b/src/iac_code/services/telemetry/constants.py index 57461ece..d66a102a 100644 --- a/src/iac_code/services/telemetry/constants.py +++ b/src/iac_code/services/telemetry/constants.py @@ -153,11 +153,11 @@ "gemini-2.5-flash", "gemini-2.5-flash-lite", # DeepSeek / MiniMax / Volcengine - "deepseek-flash", "deepseek-v4-pro", "deepseek-v4-pro-0813", "deepseek-v4-flash-0731", "deepseek-v4-flash", + "deepseek-v4-flash-vision-exp", "deepseek-v4.1-flash", "deepseek-v3.2", "MiniMax/MiniMax-M3", diff --git a/src/iac_code/skills/bundled/iac_aliyun/references/solutions/iac-code-web.md b/src/iac_code/skills/bundled/iac_aliyun/references/solutions/iac-code-web.md index ba9769fc..86fd7f9f 100644 --- a/src/iac_code/skills/bundled/iac_aliyun/references/solutions/iac-code-web.md +++ b/src/iac_code/skills/bundled/iac_aliyun/references/solutions/iac-code-web.md @@ -27,7 +27,7 @@ API Key 参数。 `--host 0.0.0.0 --port 8766 --no-open` 以 root 用户启动 iac-code Web。 - `IAC_CODE_CONFIG_DIR` 固定为 `/root/.iac-code`。Bootstrap 将百炼 API Key 写入 `.credentials.yml`,将阿里云云凭证默认配置为当前 ECS 绑定角色对应的 `EcsRamRole`,并在 - `settings.yml` 中配置 DashScope(默认模型 `qwen3.8-max`)、自动 Memory、中文 UI、 + `settings.yml` 中配置 DashScope(默认模型 `qwen3.8-max-0902`)、自动 Memory、中文 UI、 `selling` Pipeline、普通会话模式和 `bypass_permissions`;`userID` 使用 `ALIYUN::TenantId`。 - Bootstrap 在 `/root/AGENTS.md` 注入当前 ECS 的实例 ID、规格、地域、可用区、VPC、 VSwitch、安全组、EIP 和 ECS 控制台详情链接。iac-code 将未明确指定目标的 ECS 查询、 diff --git a/src/iac_code/skills/bundled/iac_aliyun/references/solutions/iac-code-web.ros.yml b/src/iac_code/skills/bundled/iac_aliyun/references/solutions/iac-code-web.ros.yml index 66d5d91d..12473108 100644 --- a/src/iac_code/skills/bundled/iac_aliyun/references/solutions/iac-code-web.ros.yml +++ b/src/iac_code/skills/bundled/iac_aliyun/references/solutions/iac-code-web.ros.yml @@ -338,7 +338,7 @@ Resources: 'providers': { 'dashscope': { 'apiBase': 'https://dashscope.aliyuncs.com/compatible-mode/v1', - 'model': 'qwen3.8-max', + 'model': 'qwen3.8-max-0902', 'name': 'DashScope', } }, diff --git a/tests/a2a/test_executor.py b/tests/a2a/test_executor.py index 4f5eddef..7ab528f5 100644 --- a/tests/a2a/test_executor.py +++ b/tests/a2a/test_executor.py @@ -1907,11 +1907,17 @@ async def test_executor_keeps_initial_task_echo_canonical_without_changing_runti monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) prompt = f"paths: {cwd}/src/app.py {config_dir}/tool-results/session-1/result.txt /opt/iac-code-outside/config.yaml" - context = FakeRequestContext(text=prompt, metadata={"iac_code": {"cwd": str(cwd)}}) + metadata = { + "iac_code": { + "cwd": str(cwd), + "llm_headers": {"Authorization": "Bearer top-secret"}, + } + } + context = FakeRequestContext(text=prompt, metadata=metadata) context.message = Message( role=Role.ROLE_USER, parts=[Part(text=prompt)], - metadata={"iac_code": {"cwd": str(cwd)}}, + metadata=metadata, message_id="msg-paths", ) @@ -1925,6 +1931,8 @@ async def test_executor_keeps_initial_task_echo_canonical_without_changing_runti history = rendered["history"][0] assert history["parts"][0]["text"] == prompt assert history["metadata"]["iac_code"]["cwd"] == str(cwd) + assert "llm_headers" not in history["metadata"]["iac_code"] + assert "top-secret" not in str(rendered) assert str(cwd) in history["parts"][0]["text"] assert str(config_dir) in history["parts"][0]["text"] assert "/opt/iac-code-outside/config.yaml" in history["parts"][0]["text"] @@ -2570,6 +2578,7 @@ async def test_executor_delegates_pipeline_mode_after_validation( class SpyPipelineExecutor: def __init__(self, **kwargs): + self.context_ready_callback = kwargs["context_ready_callback"] calls.append(("init", kwargs)) async def execute( @@ -2584,6 +2593,7 @@ async def execute( pipeline_input, active_followup_only=False, ): + await self.context_ready_callback() captured_channels.append(attributes.build_signal_attributes()["iac_code.channel"]) captured_headers.append(get_provider_request_headers()) calls.append( @@ -4450,6 +4460,73 @@ async def capturing_run_streaming(self, prompt): assert get_provider_request_headers() == {} +@pytest.mark.asyncio +async def test_executor_rejected_workspace_request_cannot_replace_context_llm_headers( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + from iac_code.providers.request_headers import get_provider_request_headers + + captured_headers: list[dict[str, str]] = [] + original_run_streaming = FakeAgentLoop.run_streaming + + async def capturing_run_streaming(self, prompt): + captured_headers.append(get_provider_request_headers()) + async for event in original_run_streaming(self, prompt): + yield event + + monkeypatch.setattr(FakeAgentLoop, "run_streaming", capturing_run_streaming) + runtime = FakeRuntime(agent_loop=FakeAgentLoop([TextDeltaEvent(text="ok")]), session_id="sess-headers") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + valid_cwd = tmp_path / "valid" + invalid_cwd = tmp_path / "invalid" + valid_cwd.mkdir() + invalid_cwd.mkdir() + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + + await executor.execute( + FakeRequestContext( + task_id="task-headers-valid-1", + context_id="ctx-headers-protected", + metadata={ + "iac_code": { + "cwd": str(valid_cwd), + "llm_headers": {"Authorization": "Bearer victim"}, + } + }, + ), + FakeEventQueue(), + ) + await executor.execute( + FakeRequestContext( + task_id="task-headers-invalid", + context_id="ctx-headers-protected", + metadata={ + "iac_code": { + "cwd": str(invalid_cwd), + "llm_headers": {"Authorization": "Bearer attacker"}, + } + }, + ), + FakeEventQueue(), + ) + await executor.execute( + FakeRequestContext( + task_id="task-headers-valid-2", + context_id="ctx-headers-protected", + metadata={"iac_code": {"cwd": str(valid_cwd)}}, + ), + FakeEventQueue(), + ) + + assert captured_headers == [ + {"Authorization": "Bearer victim"}, + {"Authorization": "Bearer victim"}, + ] + assert await store.resolve_context_llm_headers("ctx-headers-protected", None) == {"Authorization": "Bearer victim"} + + @pytest.mark.asyncio async def test_executor_applies_metadata_channel_over_environment( monkeypatch: pytest.MonkeyPatch, tmp_path: Path @@ -4915,7 +4992,9 @@ async def test_suspending_permission_answer_waits_for_owner_then_resumes_once( async def pending_for_response(_response): return pending - async def answer(_response): + async def answer(_response, *, before_delivery=None): + if before_delivery is not None: + await before_delivery() return True async def wait_for_suspended_owner(_boundary_id): @@ -4924,7 +5003,8 @@ async def wait_for_suspended_owner(_boundary_id): async def complete(value): completed.append(value) - async def resume(_context, _queue, *, response): + async def resume(_context, _queue, *, response, activate_llm_headers=None): + del activate_llm_headers resumed.append(response) return True @@ -4977,7 +5057,8 @@ async def pending_for_response(_response): calls.append("lookup") return pending - async def answer(_response): + async def answer(_response, *, before_delivery=None): + del before_delivery calls.append("answer") raise InvalidParamsError("permission boundary has no live owner") @@ -4985,7 +5066,8 @@ async def complete(value): assert value is pending calls.append("complete") - async def resume(_context, _queue, *, response): + async def resume(_context, _queue, *, response, activate_llm_headers=None): + del activate_llm_headers calls.append("recover") return True @@ -5004,10 +5086,12 @@ async def resume(_context, _queue, *, response): assert calls == ["lookup", "answer", "complete", "recover"] +@pytest.mark.parametrize("checkpoint_phase", ["RESOLVED", "RESTORING"]) @pytest.mark.asyncio async def test_persisted_permission_restores_backup_before_checkpoint_lookup( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + checkpoint_phase: str, ) -> None: backup_root = tmp_path / "backup" origin_config = tmp_path / "origin-config" @@ -5042,7 +5126,7 @@ async def test_persisted_permission_restores_backup_before_checkpoint_lookup( "contextId": context_id, "inputId": input_id, "toolUseId": tool_use_id, - "phase": "RESOLVED", + "phase": checkpoint_phase, "decision": {"value": "deny"}, } (permission_waits_dir / f"{boundary_id}.json").write_text(json.dumps(checkpoint), encoding="utf-8") @@ -5070,11 +5154,18 @@ async def test_persisted_permission_restores_backup_before_checkpoint_lookup( ) assert not restored_storage.exists(cwd, session_id) + activations: list[str] = [] + + async def activate_llm_headers() -> None: + activations.append("activated") + assert await executor._resume_persisted_permission( FakeRequestContext(task_id=task_id, context_id=context_id), FakeEventQueue(), response=response, + activate_llm_headers=activate_llm_headers, ) + assert activations == ["activated"] assert restored_storage.exists(cwd, session_id) assert ( restored_storage.session_dir(cwd, session_id) / "permission-waits" / f"{boundary_id}.json" @@ -5245,7 +5336,8 @@ async def test_identity_lookup_failure_keeps_live_permission_pending(monkeypatch async def pending_for_response(_response): return pending - async def answer(_response): + async def answer(_response, *, before_delivery=None): + del before_delivery raise PermissionIdentityValidationError("InternalError", retryable=True) async def publish(_queue, **kwargs): @@ -5278,6 +5370,143 @@ async def publish(_queue, **kwargs): } +@pytest.mark.asyncio +async def test_rejected_live_permission_does_not_replace_context_llm_headers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + await store.resolve_context_llm_headers("ctx-1", {"Authorization": "Bearer accepted"}) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + response = PermissionResponse( + task_id="task-1", + context_id="ctx-1", + request_task_id="task-1", + input_id="input-1", + tool_use_id="tool-1", + decision="allow_once", + ) + + async def pending_for_response(_response): + return SimpleNamespace() + + async def answer(_response, *, before_delivery=None): + assert before_delivery is not None + raise PermissionIdentityValidationError("cloud_execution_identity_changed", retryable=False) + + monkeypatch.setattr("iac_code.a2a.executor.parse_permission_response", lambda _message: response) + monkeypatch.setattr(executor._permission_input_registry, "pending_for_response", pending_for_response) + monkeypatch.setattr(executor._permission_input_registry, "answer", answer) + + await executor.execute( + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + metadata={"iac_code": {"llm_headers": {"Authorization": "Bearer rejected"}}}, + ), + FakeEventQueue(), + ) + + assert await store.resolve_context_llm_headers("ctx-1", None) == { + "Authorization": "Bearer accepted" + } + + +@pytest.mark.asyncio +async def test_duplicate_live_permission_keeps_first_committed_llm_headers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + response = PermissionResponse( + task_id="task-1", + context_id="ctx-1", + request_task_id="task-1", + input_id="input-1", + tool_use_id="tool-1", + decision="allow_once", + ) + pending = SimpleNamespace(state="pending") + answer_count = 0 + + async def pending_for_response(_response): + return pending + + async def answer(_response, *, before_delivery=None): + nonlocal answer_count + answer_count += 1 + if answer_count == 1: + assert before_delivery is not None + await before_delivery() + return True + + async def claim_continuation(_pending): + return None + + monkeypatch.setattr("iac_code.a2a.executor.parse_permission_response", lambda _message: response) + monkeypatch.setattr(executor._permission_input_registry, "pending_for_response", pending_for_response) + monkeypatch.setattr(executor._permission_input_registry, "answer", answer) + monkeypatch.setattr(executor._permission_input_registry, "claim_continuation", claim_continuation) + + await executor.execute( + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + metadata={"iac_code": {"llm_headers": {"Authorization": "Bearer first"}}}, + ), + FakeEventQueue(), + ) + await executor.execute( + FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + metadata={"iac_code": {"llm_headers": {"Authorization": "Bearer duplicate"}}}, + ), + FakeEventQueue(), + ) + + assert answer_count == 2 + assert await store.resolve_context_llm_headers("ctx-1", None) == { + "Authorization": "Bearer first" + } + + +@pytest.mark.asyncio +async def test_rejected_sideband_permission_does_not_replace_context_llm_headers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + await store.resolve_context_llm_headers("ctx-1", {"Authorization": "Bearer accepted"}) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + response = PermissionResponse( + task_id="task-1", + context_id="ctx-1", + request_task_id="task-1", + input_id="input-1", + tool_use_id="tool-1", + decision="allow_once", + ) + + async def is_sideband_response(_response): + return True + + async def answer(_response, *, before_delivery=None): + assert before_delivery is not None + raise InvalidParamsError("permission_resume_invalid: pending permission is not active") + + monkeypatch.setattr(executor._permission_input_registry, "is_sideband_response", is_sideband_response) + monkeypatch.setattr(executor._permission_input_registry, "answer", answer) + + with pytest.raises(InvalidParamsError, match="pending permission is not active"): + await executor.resolve_sideband_permission( + response, + metadata={"iac_code": {"llm_headers": {"Authorization": "Bearer rejected"}}}, + ) + + assert await store.resolve_context_llm_headers("ctx-1", None) == { + "Authorization": "Bearer accepted" + } + + @pytest.mark.asyncio async def test_normal_persisted_permission_recovery_publishes_final_and_terminal_state( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/a2a/test_input_required.py b/tests/a2a/test_input_required.py index 58ab793e..b7b0454c 100644 --- a/tests/a2a/test_input_required.py +++ b/tests/a2a/test_input_required.py @@ -27,6 +27,7 @@ from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher, _unified_input_projection from iac_code.a2a.runtime_overrides import a2a_request_context from iac_code.a2a.task_store import A2ATaskStore +from iac_code.providers.request_headers import get_provider_request_headers, use_provider_request_headers from iac_code.services.permission_wait import ( PermissionExecutionIdentity, PermissionWaitCheckpointStore, @@ -447,14 +448,20 @@ async def continuation() -> None: decision="allow_once", ) - async def answer_and_continue() -> bool: - approved = await registry.answer(response) + header_commits: list[str] = [] + + async def answer_and_continue(label: str) -> bool: + async def commit_headers() -> None: + header_commits.append(label) + + approved = await registry.answer(response, before_delivery=commit_headers) claimed = await registry.claim_continuation(pending) if claimed is not None: await claimed() return approved - assert await asyncio.gather(answer_and_continue(), answer_and_continue()) == [True, True] + assert await asyncio.gather(answer_and_continue("first"), answer_and_continue("second")) == [True, True] + assert len(header_commits) == 1 assert continuation_calls == 1 assert checkpoint_store.load(record["boundaryId"])["decision"]["status"] == "applied" await registry.complete(pending) @@ -1194,7 +1201,13 @@ async def test_sub_pipeline_permissions_stay_working_and_resolve_independently(m decision="allow_once", ) executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus", permission_input_registry=registry) - ack = await executor.resolve_sideband_permission(response) + binding = await store.bind_context_llm_headers("ctx-1", {"Authorization": "Bearer old"}) + with use_provider_request_headers(binding, live=True): + ack = await executor.resolve_sideband_permission( + response, + metadata={"iac_code": {"llm_headers": {"Authorization": "Bearer rotated"}}}, + ) + assert get_provider_request_headers() == {"Authorization": "Bearer rotated"} assert ack is not None ack_payload = MessageToDict(ack, preserving_proto_field_name=False) assert ack_payload["role"] == "ROLE_AGENT" diff --git a/tests/a2a/test_metadata_redaction.py b/tests/a2a/test_metadata_redaction.py index 42539918..04357b9c 100644 --- a/tests/a2a/test_metadata_redaction.py +++ b/tests/a2a/test_metadata_redaction.py @@ -2,6 +2,9 @@ from __future__ import annotations +from a2a.types import Message, Part, Role +from google.protobuf.json_format import MessageToDict + from iac_code.a2a.metadata_redaction import A2AMetadataEchoRedactor from iac_code.utils.public_errors import suppress_all_redaction @@ -17,3 +20,27 @@ def test_redact_keeps_everything_raw_under_suppress_all() -> None: payload = {"password": "p@ss", "path": "/Users/alice/.iac-code/t.yaml"} with suppress_all_redaction(): assert redactor.redact(payload) == payload + + +def test_redact_message_echo_removes_llm_headers_but_preserves_other_metadata() -> None: + redactor = A2AMetadataEchoRedactor() + message = Message( + role=Role.ROLE_USER, + parts=[Part(text="hello")], + message_id="message-1", + metadata={ + "iac_code": { + "llm_headers": {"Authorization": "Bearer top-secret"}, + "preferredLanguage": "zh-CN", + }, + "caller": "test-client", + }, + ) + + echoed = MessageToDict(redactor.redact_message_echo(message), preserving_proto_field_name=False) + + assert echoed["metadata"] == { + "iac_code": {"preferredLanguage": "zh-CN"}, + "caller": "test-client", + } + assert "top-secret" not in str(echoed) diff --git a/tests/a2a/test_task_store.py b/tests/a2a/test_task_store.py index da7d12c7..e9200d6a 100644 --- a/tests/a2a/test_task_store.py +++ b/tests/a2a/test_task_store.py @@ -9,7 +9,7 @@ import pytest from a2a.auth.user import User from a2a.server.context import ServerCallContext -from a2a.types import Artifact, ListTasksRequest, Part, Task, TaskState, TaskStatus +from a2a.types import Artifact, ListTasksRequest, Message, Part, Role, Task, TaskState, TaskStatus from a2a.utils.errors import InvalidParamsError from google.protobuf.json_format import MessageToDict, ParseDict from google.protobuf.timestamp_pb2 import Timestamp @@ -197,6 +197,121 @@ async def test_context_llm_header_binding_can_be_reused_updated_and_cleared() -> assert await store.resolve_context_llm_headers("ctx-1", None) == {} +@pytest.mark.asyncio +async def test_context_cleanup_removes_llm_header_binding(tmp_path) -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=0, cleanup_interval_seconds=300) + await store.get_or_create_context( + context_id="ctx-headers", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: object(), + ) + await store.resolve_context_llm_headers("ctx-headers", {"Authorization": "Bearer secret"}) + + await store.cleanup_once(now_offset_seconds=1) + + assert await store.resolve_context_llm_headers("ctx-headers", None) == {} + + +@pytest.mark.asyncio +async def test_task_store_strips_llm_headers_from_followup_history() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + first = Message( + message_id="message-1", + role=Role.ROLE_USER, + parts=[Part(text="first")], + metadata={"iac_code": {"llm_headers": {"Authorization": "Bearer first"}, "keep": "yes"}}, + ) + task = Task( + id="task-headers", + context_id="ctx-headers", + status=TaskStatus(state=TaskState.TASK_STATE_WORKING), + history=[first], + ) + await store.save(task) + + followup = Message( + message_id="message-2", + role=Role.ROLE_USER, + parts=[Part(text="second")], + metadata={"iac_code": {"llm_headers": {"Authorization": "Bearer second"}, "keep": "yes"}}, + ) + task.history.append(followup) + await store.save(task) + + stored = await store.get("task-headers") + assert stored is not None + rendered = MessageToDict(stored, preserving_proto_field_name=False) + assert [message["metadata"]["iac_code"] for message in rendered["history"]] == [ + {"keep": "yes"}, + {"keep": "yes"}, + ] + assert "llm_headers" not in str(rendered) + assert "Bearer first" not in str(rendered) + assert "Bearer second" not in str(rendered) + + +@pytest.mark.asyncio +async def test_failed_context_runtime_rebuild_clears_live_llm_header_binding(tmp_path) -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + context = await store.get_or_create_context( + context_id="ctx-rebuild-failure", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: object(), + ) + binding = await store.bind_context_llm_headers( + context.context_id, + {"Authorization": "Bearer stale"}, + ) + context.runtime = None + + def fail_runtime(_session_id: str): + raise RuntimeError("bootstrap failed") + + with pytest.raises(RuntimeError, match="bootstrap failed"): + await store.get_or_create_context( + context_id=context.context_id, + cwd=str(tmp_path), + runtime_factory=fail_runtime, + ) + + assert context.context_id not in store._contexts + assert context.context_id not in store._context_llm_headers + assert dict(binding) == {} + + +@pytest.mark.asyncio +async def test_cancelled_context_runtime_rebuild_clears_live_llm_header_binding(tmp_path) -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + context = await store.get_or_create_context( + context_id="ctx-rebuild-cancel", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: object(), + ) + binding = await store.bind_context_llm_headers( + context.context_id, + {"Authorization": "Bearer stale"}, + ) + context.runtime = None + release = threading.Event() + + rebuild = asyncio.create_task( + store.get_or_create_context( + context_id=context.context_id, + cwd=str(tmp_path), + runtime_factory=lambda _session_id: release.wait(timeout=2) or object(), + ) + ) + await asyncio.sleep(0.01) + rebuild.cancel() + with pytest.raises(asyncio.CancelledError): + await rebuild + release.set() + + assert context.context_id not in store._contexts + assert context.context_id not in store._context_llm_headers + assert dict(binding) == {} + + @pytest.mark.asyncio async def test_new_a2a_session_initializes_backup_generation_zero(monkeypatch, tmp_path) -> None: config_dir = tmp_path / "config" diff --git a/tests/a2a/test_transport_dispatcher.py b/tests/a2a/test_transport_dispatcher.py index 4bc2b69b..25d875a5 100644 --- a/tests/a2a/test_transport_dispatcher.py +++ b/tests/a2a/test_transport_dispatcher.py @@ -652,7 +652,8 @@ async def test_text_gateway_sideband_permission_response_hydrates_task_and_retur ) class Executor: - async def resolve_sideband_permission(self, _response): + async def resolve_sideband_permission(self, _response, *, metadata=None): + assert metadata is message return ack async def fail_sdk_stream(*_args, **_kwargs): diff --git a/tests/pipeline/selling/test_iac_code_web_solution.py b/tests/pipeline/selling/test_iac_code_web_solution.py index db94b8f6..6e5ae638 100644 --- a/tests/pipeline/selling/test_iac_code_web_solution.py +++ b/tests/pipeline/selling/test_iac_code_web_solution.py @@ -215,7 +215,7 @@ def test_golden_template_parameters_outputs_and_bootstrap_follow_contract() -> N "'memory': {'autoMemory': True}", "'pipeline': {'sellingReviewStep': False}", "'apiBase': 'https://dashscope.aliyuncs.com/compatible-mode/v1'", - "'model': 'qwen3.8-max'", + "'model': 'qwen3.8-max-0902'", "'name': 'DashScope'", "'mode': 'normal'", "'permissionMode': 'bypass_permissions'", diff --git a/tests/providers/test_deepseek_provider.py b/tests/providers/test_deepseek_provider.py index e00c3e25..3bd242a0 100644 --- a/tests/providers/test_deepseek_provider.py +++ b/tests/providers/test_deepseek_provider.py @@ -34,8 +34,8 @@ def test_effort_request_kwargs_high(self): "extra_body": {"thinking": {"type": "enabled"}}, } - def test_v41_flash_api_model_uses_current_thinking_protocol(self): - p = DeepSeekProvider(model="deepseek-flash", api_key="test", effort="high") + def test_v4_flash_vision_api_model_uses_current_thinking_protocol(self): + p = DeepSeekProvider(model="deepseek-v4-flash-vision-exp", api_key="test", effort="high") assert p._effort_request_kwargs() == { "reasoning_effort": "high", "extra_body": {"thinking": {"type": "enabled"}}, @@ -202,13 +202,17 @@ def test_deepseek_listed_in_providers(self): deepseek = next(p for p in PROVIDERS if p["name"] == "DeepSeek") assert deepseek["key_name"] == "deepseek" assert deepseek["api_base"] == DEEPSEEK_BASE_URL - assert deepseek["default_model"] == "deepseek-flash" - assert set(deepseek["models"]) == {"deepseek-flash", "deepseek-v4-pro", "deepseek-v4-flash"} + assert deepseek["default_model"] == "deepseek-v4-flash" + assert set(deepseek["models"]) == { + "deepseek-v4-flash", + "deepseek-v4-pro", + "deepseek-v4-flash-vision-exp", + } def test_deepseek_model_capabilities(self): from iac_code.providers.thinking import EffortLevel, get_thinking_spec - for model in ("deepseek-flash", "deepseek-v4-pro", "deepseek-v4-flash"): + for model in ("deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-flash-vision-exp"): spec = get_thinking_spec("deepseek", model) assert spec.supports_effort is True assert spec.allowed_efforts == (EffortLevel.LOW, EffortLevel.HIGH, EffortLevel.MAX) diff --git a/tests/providers/test_manager.py b/tests/providers/test_manager.py index 77a81971..6fbd5d65 100644 --- a/tests/providers/test_manager.py +++ b/tests/providers/test_manager.py @@ -2787,7 +2787,7 @@ class TestModelPrefixAutoMapping: ("qwen3.6-plus", "dashscope"), ("qwen3.8-max", "dashscope"), ("qwen-max", "dashscope"), - ("deepseek-flash", "deepseek"), + ("deepseek-v4-flash-vision-exp", "deepseek"), ("deepseek-v4-pro", "deepseek"), ("deepseek-chat", "deepseek"), ], diff --git a/tests/providers/test_provider_model_research_updates.py b/tests/providers/test_provider_model_research_updates.py index 9eed6e1f..471fb5d0 100644 --- a/tests/providers/test_provider_model_research_updates.py +++ b/tests/providers/test_provider_model_research_updates.py @@ -102,7 +102,7 @@ def test_dashscope_models_match_researched_bailian_catalog() -> None: assert not _model_entry("dashscope", "xiaomi/mimo-v2.5-pro").support_multimodal assert not _model_entry("dashscope", "deepseek-v4-pro-0813").support_multimodal assert not _model_entry("dashscope", "ZHIPU/GLM-5.3").support_multimodal - assert not _model_entry("dashscope", "ZHIPU/GLM-5.3-Flash").support_multimodal + assert _model_entry("dashscope", "ZHIPU/GLM-5.3-Flash").support_multimodal # The public adapter can only send local attachments as data URLs, but # Moonshot-hosted K3 on DashScope accepts public image URLs only. assert not _model_entry("dashscope", "kimi/kimi-k3").support_multimodal @@ -256,11 +256,17 @@ def test_direct_kimi_minimax_and_zhipu_models_are_updated() -> None: assert get_thinking_spec(provider_key, "glm-5.1").family is ThinkingFamily.ZHIPU -def test_direct_deepseek_uses_v41_flash_api_model_id() -> None: - assert PROVIDER_REGISTRY["deepseek"].default_model == "deepseek-flash" - assert _model_ids("deepseek") == ["deepseek-flash", "deepseek-v4-pro", "deepseek-v4-flash"] - assert _model_entry("deepseek", "deepseek-flash").support_multimodal - assert get_thinking_spec("deepseek", "deepseek-flash").family is ThinkingFamily.OPENAI +def test_direct_deepseek_uses_documented_v4_model_ids() -> None: + assert PROVIDER_REGISTRY["deepseek"].default_model == "deepseek-v4-flash" + assert _model_ids("deepseek") == [ + "deepseek-v4-flash", + "deepseek-v4-pro", + "deepseek-v4-flash-vision-exp", + ] + assert not _model_entry("deepseek", "deepseek-v4-flash").support_multimodal + assert _model_entry("deepseek", "deepseek-v4-flash-vision-exp").support_multimodal + assert get_thinking_spec("deepseek", "deepseek-v4-flash").family is ThinkingFamily.OPENAI + assert get_thinking_spec("deepseek", "deepseek-v4-flash-vision-exp").family is ThinkingFamily.OPENAI assert "deepseek-v4.1-flash" not in _model_ids("deepseek") diff --git a/tests/providers/test_request_headers.py b/tests/providers/test_request_headers.py index b33e7a60..0c412b28 100644 --- a/tests/providers/test_request_headers.py +++ b/tests/providers/test_request_headers.py @@ -3,6 +3,7 @@ from iac_code.providers.openai_provider import OpenAIProvider from iac_code.providers.qwen_provider import QwenProvider from iac_code.providers.request_headers import ( + get_provider_request_headers, merge_provider_request_headers, use_provider_request_headers, ) @@ -15,6 +16,20 @@ def test_merge_provider_request_headers_overrides_case_insensitively() -> None: ) == {"X-Keep": "yes", "x-provider": "a2a"} +def test_live_provider_request_headers_reflect_session_binding_updates() -> None: + binding = {"Authorization": "Bearer first"} + + with use_provider_request_headers(binding, live=True): + assert get_provider_request_headers() == {"Authorization": "Bearer first"} + binding.clear() + binding["Authorization"] = "Bearer second" + assert get_provider_request_headers() == {"Authorization": "Bearer second"} + binding.clear() + assert get_provider_request_headers() == {} + + assert get_provider_request_headers() == {} + + def test_openai_provider_adds_request_local_headers() -> None: provider = OpenAIProvider(model="gpt-5.5", api_key="test") diff --git a/tests/providers/test_thinking_registry.py b/tests/providers/test_thinking_registry.py index df77f3ff..36f3cf30 100644 --- a/tests/providers/test_thinking_registry.py +++ b/tests/providers/test_thinking_registry.py @@ -69,7 +69,7 @@ def test_openai_codex_and_o_series_have_generation_specific_efforts(self): assert EffortLevel.NONE not in o3.allowed_efforts def test_deepseek_official_uses_openai_family_with_low_high_max(self): - for model in ("deepseek-flash", "deepseek-v4-pro", "deepseek-v4-flash"): + for model in ("deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-flash-vision-exp"): spec = get_thinking_spec("deepseek", model) assert spec.family is ThinkingFamily.OPENAI assert spec.allowed_efforts == (EffortLevel.LOW, EffortLevel.HIGH, EffortLevel.MAX) diff --git a/tests/services/capabilities/test_multimodal.py b/tests/services/capabilities/test_multimodal.py index 6be9c530..854f2bc5 100644 --- a/tests/services/capabilities/test_multimodal.py +++ b/tests/services/capabilities/test_multimodal.py @@ -49,7 +49,8 @@ def test_builtin_set_includes_registry_flagged_models(): assert "gemini-2.5-pro" in builtin assert "qwen3.6-plus" in builtin assert "kimi-k2.6" in builtin - assert "deepseek-flash" in builtin + assert "deepseek-v4-flash-vision-exp" in builtin + assert "ZHIPU/GLM-5.3-Flash" in builtin assert "kimi-for-coding" in builtin assert "k3-256k" in builtin @@ -60,4 +61,5 @@ def test_builtin_set_excludes_non_multimodal_models(): builtin = _builtin_multimodal_models() assert "deepseek-v4-pro" not in builtin + assert "deepseek-v4-flash" not in builtin assert "qwen3-coder-plus" not in builtin diff --git a/tests/services/test_context_manager.py b/tests/services/test_context_manager.py index 6bd39070..4cedb372 100644 --- a/tests/services/test_context_manager.py +++ b/tests/services/test_context_manager.py @@ -127,15 +127,15 @@ def test_kimi_code_models_use_documented_context_capacity(self, model, context_w ("qwen3.6-flash", 1_000_000), ("qwen3.6-35b-a3b", 262_144), ("qwen3.6-27b", 262_144), - ("deepseek-flash", 1_000_000), ("deepseek-v4-pro", 1_000_000), ("deepseek-v4-pro-0813", 1_000_000), ("deepseek-v4-flash-0731", 1_000_000), ("deepseek-v4-flash", 1_000_000), + ("deepseek-v4-flash-vision-exp", 1_000_000), ("deepseek-v4.1-flash", 1_000_000), ("glm-5.1", 202_752), ("MiniMax-M3", 1_000_000), - ("MiniMax/MiniMax-M3", 196_608), + ("MiniMax/MiniMax-M3", 1_048_576), ("MiniMax/MiniMax-M2.7", 196_608), ("MiniMax/MiniMax-M2.5", 196_608), ("MiniMax/MiniMax-M2.1", 196_608), @@ -149,11 +149,11 @@ def test_current_dashscope_models_use_documented_context_capacity(self, model, c @pytest.mark.parametrize( "model", [ - "deepseek-flash", "deepseek-v4.1-flash", "deepseek-v4-pro", "deepseek-v4-pro-0813", "deepseek-v4-flash", + "deepseek-v4-flash-vision-exp", "deepseek-v4-flash-0731", ], ) diff --git a/tests/services/test_permission_wait.py b/tests/services/test_permission_wait.py index 2fbbb8c2..09eba580 100644 --- a/tests/services/test_permission_wait.py +++ b/tests/services/test_permission_wait.py @@ -800,13 +800,18 @@ async def backup(_record: dict) -> None: decision = store.load(boundary_id)["decision"] observed.append((decision["status"], decision["backupStatus"], future.done())) + async def before_release() -> None: + decision = store.load(boundary_id)["decision"] + observed.append((decision["status"], decision["backupStatus"], future.done())) + await coordinator.claim_live( boundary_id=boundary_id, value="allow_once", before_delivery=backup, + before_release=before_release, ) - assert observed == [("claimed", "pending", False)] + assert observed == [("claimed", "pending", False), ("claimed", "committed", False)] assert await future is True decision = store.load(boundary_id)["decision"] assert decision["backupStatus"] == "committed" diff --git a/tests/test_services/test_telemetry/test_constants.py b/tests/test_services/test_telemetry/test_constants.py index 5ec3f68b..a480be37 100644 --- a/tests/test_services/test_telemetry/test_constants.py +++ b/tests/test_services/test_telemetry/test_constants.py @@ -61,10 +61,10 @@ def test_known_models_contains_researched_provider_updates(): "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-2.5-pro", - "deepseek-flash", "deepseek-v4-pro", "deepseek-v4-pro-0813", "deepseek-v4-flash-0731", + "deepseek-v4-flash-vision-exp", "deepseek-v4.1-flash", "MiniMax-M3", "doubao-seed-2-0-pro-260215", diff --git a/website/docs/a2a/protocol-reference.md b/website/docs/a2a/protocol-reference.md index a15ff75e..b2464b5d 100644 --- a/website/docs/a2a/protocol-reference.md +++ b/website/docs/a2a/protocol-reference.md @@ -181,7 +181,7 @@ When `metadata.iac_code` includes both `alibaba_cloud_access_key_id` and `alibab `metadata.iac_code.iac_code_api_key` only affects the current A2A message turn. It takes priority over `IAC_CODE_API_KEY` and `.credentials.yml` for the provider selected by the effective model. Follow-up turns without this metadata field reload normal credentials, so a per-call key does not leak across reused `contextId`s. This field is for the LLM provider key and is separate from A2A transport authentication such as `api-key` / `IACCODE_A2A_API_KEY`. -`metadata.iac_code.llm_headers` is a string-to-string map bound to the A2A `contextId`. Once supplied, normal chat, Pipeline execution, and later message turns that reuse the same `contextId` inherit the headers even when the field is omitted. Supplying another map replaces the complete binding; supplying `{}` clears it. Other concurrent contexts remain isolated. The headers are merged case-insensitively with provider-managed request headers and caller values take precedence. Invalid header names, non-string values, line breaks, and entries beyond the bounded header count and size limits are ignored. Because header values may contain credentials, bindings are held only in server memory and are not written to session snapshots; callers must supply them again after a server restart. Treat sensitive values like credentials and protect the A2A request accordingly. +`metadata.iac_code.llm_headers` is a string-to-string map bound to the A2A `contextId`. Once supplied, normal chat, Pipeline execution, and later message turns that reuse the same `contextId` inherit the headers even when the field is omitted. Supplying another map replaces the complete binding; supplying `{}` clears it. Other concurrent contexts remain isolated. The headers are merged case-insensitively with provider-managed request headers and caller values take precedence. Invalid header names, non-string values, line breaks, and entries beyond the bounded header count and size limits are ignored. Because header values may contain credentials, bindings are held only in server memory, are removed when the context expires, and are stripped from echoed Task history and events; callers must supply them again after a server restart. Treat sensitive values like credentials and protect the A2A request accordingly. `metadata.iac_code.run_mode` can select `normal` or `pipeline` for one message. When the effective mode is `pipeline`, `metadata.iac_code.pipeline_name` can select `selling` or `selling_solution_first`; an unsupported non-empty value is rejected. On continuation and recovery, the pipeline identity stored for the existing task/context takes precedence so a caller cannot accidentally resume durable state with another pipeline. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md index 335336f7..5c2d329c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -181,7 +181,7 @@ Callback URLs 会在存储前以及分发前再次校验。默认 validator 会 `metadata.iac_code.iac_code_api_key` 只影响当前 A2A message turn。它优先于 `IAC_CODE_API_KEY` 和 `.credentials.yml` 中当前有效 model 对应 provider 的 key;复用同一个 `contextId` 的后续轮次如果不再传该字段,会重新加载正常凭据,因此单次调用 key 不会串到后续请求。这个字段用于 LLM provider key,和 A2A transport 认证里的 `api-key` / `IACCODE_A2A_API_KEY` 是两件事。 -`metadata.iac_code.llm_headers` 是绑定到 A2A `contextId` 的字符串到字符串映射。首次传入后,复用同一 `contextId` 的 normal chat、Pipeline 执行和后续消息即使省略该字段,也会继续沿用这些 headers。后续传入新的映射会整组替换当前绑定;传入 `{}` 会清空绑定。其他并发 context 与之隔离。它会与 provider 自身管理的请求 headers 按名称大小写不敏感地合并,调用方传入的值优先。无效 header 名、非字符串值、包含换行符的值,以及超出数量或大小限制的条目会被忽略。由于 header 值可能包含凭据,绑定只保存在 server 内存中,不会写入 session snapshot;server 重启后调用方需要重新传入。敏感值应按凭据保护,并通过安全的 A2A 链路传输。 +`metadata.iac_code.llm_headers` 是绑定到 A2A `contextId` 的字符串到字符串映射。首次传入后,复用同一 `contextId` 的 normal chat、Pipeline 执行和后续消息即使省略该字段,也会继续沿用这些 headers。后续传入新的映射会整组替换当前绑定;传入 `{}` 会清空绑定。其他并发 context 与之隔离。它会与 provider 自身管理的请求 headers 按名称大小写不敏感地合并,调用方传入的值优先。无效 header 名、非字符串值、包含换行符的值,以及超出数量或大小限制的条目会被忽略。由于 header 值可能包含凭据,绑定只保存在 server 内存中,会在 context 过期时清理,并从回显的 Task history 和事件中移除;server 重启后调用方需要重新传入。敏感值应按凭据保护,并通过安全的 A2A 链路传输。 `metadata.iac_code.run_mode` 可以为单条消息选择 `normal` 或 `pipeline`。有效模式为 Pipeline 时,`metadata.iac_code.pipeline_name` 可以选择 `selling` 或 `selling_solution_first`;不受支持的非空值会被拒绝。继续或恢复任务时,已有 task/context 中保存的 Pipeline 标识优先,避免调用方用另一条流水线错误恢复持久状态。