diff --git a/src/iac_code/a2a/client.py b/src/iac_code/a2a/client.py index 26d1688c..32743ee2 100644 --- a/src/iac_code/a2a/client.py +++ b/src/iac_code/a2a/client.py @@ -131,8 +131,15 @@ async def send_message( *, cwd: str, context_id: str | None = None, + model: str | None = None, ) -> A2AClientResponse: - payload = self._message_payload(method="SendMessage", prompt=prompt, cwd=cwd, context_id=context_id) + payload = self._message_payload( + method="SendMessage", + prompt=prompt, + cwd=cwd, + context_id=context_id, + model=model, + ) transport = self._make_transport_client(url) response = await transport.send(payload) return A2AClientResponse(payload=response) @@ -144,8 +151,15 @@ async def stream_message( *, cwd: str, context_id: str | None = None, + model: str | None = None, ) -> AsyncIterator[dict[str, Any]]: - payload = self._message_payload(method="SendStreamingMessage", prompt=prompt, cwd=cwd, context_id=context_id) + payload = self._message_payload( + method="SendStreamingMessage", + prompt=prompt, + cwd=cwd, + context_id=context_id, + model=model, + ) transport = self._make_transport_client(url) async for event in transport.stream(payload): yield event @@ -332,12 +346,25 @@ def _transport_options(self, binding: A2ATransportBinding) -> TransportClientOpt api_key_header=auth.api_key_header, ) - def _message_payload(self, *, method: str, prompt: str, cwd: str, context_id: str | None) -> dict[str, Any]: + def _message_payload( + self, + *, + method: str, + prompt: str, + cwd: str, + context_id: str | None, + model: str | None, + ) -> dict[str, Any]: + iac_code_metadata = {"cwd": cwd} + if model: + stripped_model = model.strip() + if stripped_model: + iac_code_metadata["iac_code_model"] = stripped_model message: dict[str, Any] = { "messageId": str(uuid.uuid4()), "role": "ROLE_USER", "parts": [{"text": prompt}], - "metadata": {"iac_code": {"cwd": cwd}}, + "metadata": {"iac_code": iac_code_metadata}, } if context_id: message["contextId"] = context_id diff --git a/src/iac_code/a2a/executor.py b/src/iac_code/a2a/executor.py index a862c6a8..7a4821c3 100644 --- a/src/iac_code/a2a/executor.py +++ b/src/iac_code/a2a/executor.py @@ -18,7 +18,7 @@ from iac_code.a2a.events import make_text_part, publish_stream_event from iac_code.a2a.exposure import normalize_a2a_exposure_types from iac_code.a2a.metrics import A2AMetrics, NoOpA2AMetrics -from iac_code.a2a.parts import allowed_cwd_roots, is_relative_to, parts_to_prompt +from iac_code.a2a.parts import allowed_cwd_roots, is_relative_to, parts_to_prompt, resolve_workspace_path from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor, recoverable_task_id_from_sidecar from iac_code.a2a.pipeline_paths import existing_a2a_pipeline_dir_for_session from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore @@ -33,6 +33,7 @@ from iac_code.i18n import _ from iac_code.pipeline.config import RunMode, get_run_mode from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime +from iac_code.services.providers.aliyun import DEFAULT_REGION, AliyunCredential, use_aliyun_credential from iac_code.services.session_storage import SessionStorage from iac_code.services.telemetry import use_session_id, use_user_id from iac_code.utils.public_errors import public_exception_summary, sanitize_public_text @@ -90,6 +91,9 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non ) cwd = self._resolve_cwd(metadata) user_id = self._resolve_user_id(metadata) + metadata_model = self._resolve_model(metadata) + model = metadata_model or self._model + aliyun_credential = self._resolve_aliyun_credential(metadata) prompt = self._prompt_from_context(context, cwd=cwd) pipeline_mode = get_run_mode() == RunMode.PIPELINE if pipeline_mode and requested_task_id is None: @@ -121,6 +125,7 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) self._metrics.record_executor_error() return + self._log_executor_exception("setup", task_id=task_id, context_id=context_id) await self._publish_status( event_queue, task_id=task_id, @@ -156,7 +161,7 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non if pipeline_mode and not route_pipeline_handoff_to_normal: pipeline_executor = IacCodeA2APipelineExecutor( task_store=self._task_store, - model=self._model, + model=model, metrics=self._metrics, artifact_store=self._artifact_store, push_notifier=self._push_notifier, @@ -185,7 +190,7 @@ def runtime_factory(session_id: str) -> Any: resume_messages = SessionStorage.repair_interrupted(loaded) if loaded else None return create_agent_runtime( AgentFactoryOptions( - model=self._model, + model=model, session_id=session_id, cwd=cwd, resume_messages=resume_messages, @@ -193,15 +198,20 @@ def runtime_factory(session_id: str) -> Any: ) try: - ctx = await self._task_store.get_or_create_context( - context_id=context_id, - cwd=cwd, - runtime_factory=runtime_factory, + aliyun_credential_ctx = ( + use_aliyun_credential(aliyun_credential) if aliyun_credential else contextlib.nullcontext() ) - if not hasattr(ctx.runtime, "agent_loop"): - ctx.runtime = runtime_factory(ctx.session_id) - self._task_store.mirror_context(ctx) + with aliyun_credential_ctx: + ctx = await self._task_store.get_or_create_context( + context_id=context_id, + cwd=cwd, + runtime_factory=runtime_factory, + ) + if not hasattr(ctx.runtime, "agent_loop"): + ctx.runtime = runtime_factory(ctx.session_id) + self._task_store.mirror_context(ctx) except Exception as exc: + self._log_executor_exception("runtime setup", task_id=task_id, context_id=context_id) await self._publish_status( event_queue, task_id=task_id, @@ -272,7 +282,12 @@ def runtime_factory(session_id: str) -> Any: state=TaskState.TASK_STATE_WORKING, ) user_id_ctx = use_user_id(user_id) if user_id else contextlib.nullcontext() - with use_session_id(ctx.session_id), user_id_ctx: + aliyun_credential_ctx = ( + use_aliyun_credential(aliyun_credential) if aliyun_credential else contextlib.nullcontext() + ) + with use_session_id(ctx.session_id), user_id_ctx, aliyun_credential_ctx: + self._configure_runtime_model(runtime, model, from_metadata=metadata_model is not None) + self._refresh_runtime_cloud_tools(runtime) async for event in runtime.agent_loop.run_streaming(prompt): text_chunk = await publish_stream_event( event_queue, @@ -323,6 +338,7 @@ def runtime_factory(session_id: str) -> Any: self._metrics.record_executor_error() else: task.state = TASK_STATE_FAILED + self._log_executor_exception("streaming", task_id=task_id, context_id=context_id) await self._publish_status( event_queue, task_id=task_id, @@ -377,18 +393,21 @@ async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None ) def _resolve_cwd(self, metadata: Any | None) -> str: - cwd = os.getcwd() if metadata is not None and hasattr(metadata, "DESCRIPTOR"): metadata = MessageToDict(metadata, preserving_proto_field_name=False) + cwd: str | None = None if metadata: raw_iac_meta = metadata.get("iac_code") if isinstance(metadata, Mapping) else None if isinstance(raw_iac_meta, Mapping): raw_cwd = raw_iac_meta.get("cwd") if isinstance(raw_cwd, str): cwd = raw_cwd + if cwd is None: + cwd = os.getcwd() if not isinstance(cwd, str) or not Path(cwd).is_absolute(): raise ValueError("Invalid A2A workspace metadata.") - resolved_cwd = Path(cwd).resolve() + logical_cwd = os.path.normpath(cwd) + resolved_cwd = resolve_workspace_path(Path(logical_cwd)) if not any(_is_relative_to(resolved_cwd, root) for root in _allowed_cwd_roots()): raise ValueError("Invalid A2A workspace metadata.") if resolved_cwd.exists(): @@ -396,7 +415,7 @@ def _resolve_cwd(self, metadata: Any | None) -> str: raise ValueError("Invalid A2A workspace metadata.") else: resolved_cwd.mkdir(parents=True, exist_ok=True) - return str(resolved_cwd) + return logical_cwd def _resolve_user_id(self, metadata: Any | None) -> str | None: if metadata is not None and hasattr(metadata, "DESCRIPTOR"): @@ -411,6 +430,47 @@ def _resolve_user_id(self, metadata: Any | None) -> str | None: return raw_user_id.strip() return None + def _resolve_model(self, metadata: Any | None) -> str | None: + 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 + raw_model = raw_iac_meta.get("iac_code_model") + if isinstance(raw_model, str) and raw_model.strip(): + return raw_model.strip() + return None + + def _resolve_aliyun_credential(self, metadata: Any | None) -> AliyunCredential | None: + 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 + + def _read(name: str) -> str | None: + raw_value = raw_iac_meta.get(name) + if isinstance(raw_value, str) and raw_value.strip(): + return raw_value.strip() + return None + + access_key_id = _read("alibaba_cloud_access_key_id") + access_key_secret = _read("alibaba_cloud_access_key_secret") + if not access_key_id or not access_key_secret: + return None + sts_token = _read("alibaba_cloud_security_token") or "" + return AliyunCredential( + mode="StsToken" if sts_token else "AK", + access_key_id=access_key_id, + access_key_secret=access_key_secret, + region_id=_read("alibaba_cloud_region_id") or DEFAULT_REGION, + sts_token=sts_token, + ) + def _prompt_from_context(self, context: RequestContext, *, cwd: str) -> str: message = getattr(context, "message", None) if not isinstance(message, Message): @@ -427,7 +487,6 @@ def _sanitize_error(self, exc: Exception) -> str: status = getattr(exc, "status_code", None) or getattr(exc, "status", None) if status == 401: return "Authentication required. Please configure your API credentials." - logger.exception("Unhandled A2A executor error") return _format_exception(exc) async def _should_route_pipeline_handoff_to_normal(self, *, context_id: str, cwd: str) -> bool: @@ -488,6 +547,9 @@ async def _recoverable_pipeline_task_id_for_context(self, *, context_id: str, cw logger.debug("Failed to recover A2A pipeline task id", exc_info=True) return None + def _log_executor_exception(self, stage: str, *, task_id: str, context_id: str) -> None: + logger.exception("A2A executor %s failed (task_id=%s, context_id=%s)", stage, task_id, context_id) + async def _publish_status( self, event_queue: EventQueue, @@ -528,6 +590,38 @@ async def _publish_initial_task( task.history.append(message) await event_queue.enqueue_event(task) + def _refresh_runtime_cloud_tools(self, runtime: Any) -> None: + refresh_cloud_tools = getattr(runtime, "refresh_cloud_tools", None) + if callable(refresh_cloud_tools): + refresh_cloud_tools() + return + tool_registry = getattr(runtime, "tool_registry", None) + if tool_registry is None: + return + from iac_code.services.cloud_credentials import CloudCredentials + from iac_code.tools.cloud.registry import register_cloud_tools + + register_cloud_tools(tool_registry, CloudCredentials()) + + def _configure_runtime_model(self, runtime: Any, model: str, *, from_metadata: bool) -> None: + provider_manager = getattr(runtime, "provider_manager", None) + reconfigure = getattr(provider_manager, "reconfigure", None) + if not callable(reconfigure): + return + was_metadata_model = bool(getattr(runtime, "_iac_code_a2a_metadata_model_applied", False)) + if not from_metadata and not was_metadata_model: + return + + from iac_code.config import load_credentials + + provider_key_override = getattr(provider_manager, "_provider_key_override", None) + base_url_override = getattr(provider_manager, "_base_url_override", None) + credentials = getattr(provider_manager, "_credentials", None) + if not isinstance(credentials, dict) or provider_key_override is None: + credentials = load_credentials(model=model) + reconfigure(model, credentials, provider_key_override, base_url_override) + setattr(runtime, "_iac_code_a2a_metadata_model_applied", from_metadata) + async def _notify_terminal_task(self, *, task_id: str, context_id: str, state: str) -> None: if self._push_notifier is None: return diff --git a/src/iac_code/a2a/parts.py b/src/iac_code/a2a/parts.py index 3ab3dd3c..0b546cc2 100644 --- a/src/iac_code/a2a/parts.py +++ b/src/iac_code/a2a/parts.py @@ -70,7 +70,7 @@ def allowed_cwd_roots() -> list[Path]: candidates = [Path(item) for item in raw.split(os.pathsep) if item] else: candidates = [Path.cwd(), Path(tempfile.gettempdir())] - return [path.resolve() for path in candidates if path.exists() and path.is_dir()] + return [resolve_workspace_path(path) for path in candidates if path.exists() and path.is_dir()] def is_relative_to(path: Path, root: Path) -> bool: @@ -81,6 +81,28 @@ def is_relative_to(path: Path, root: Path) -> bool: return True +def resolve_workspace_path(path: Path) -> Path: + try: + return path.resolve() + except FileNotFoundError: + if not path.is_absolute() or _has_symlink_component(path): + raise + return path.absolute() + + +def _has_symlink_component(path: Path) -> bool: + current = Path(path.anchor) if path.anchor else Path() + parts = path.parts[1:] if path.anchor else path.parts + for part in parts: + current /= part + try: + if current.is_symlink(): + return True + except OSError: + return False + return False + + def parts_to_prompt(message_parts: Iterable[Any], *, cwd: str | Path) -> str: values = [part_to_prompt(part, cwd=cwd) for part in message_parts] return "\n".join(value for value in values if value) diff --git a/src/iac_code/cli/main.py b/src/iac_code/cli/main.py index 6c97c8e6..3a60839e 100644 --- a/src/iac_code/cli/main.py +++ b/src/iac_code/cli/main.py @@ -47,6 +47,8 @@ ) app.add_typer(a2a_client_app, name="a2a-client") +_A2A_SERVER_TELEMETRY_USER_ID = "iac_user_a2a_server" + def _a2a_client_missing_dependencies_message() -> str: return _("A2A client dependencies are missing. Install with: pip install 'iac-code[a2a]'") @@ -598,6 +600,20 @@ def _format_a2a_route_config(item: Any) -> str: return ";".join(parts) +def _current_logical_cwd() -> str: + pwd = os.environ.get("PWD") + physical_cwd = Path.cwd() + if pwd: + pwd_path = Path(pwd) + if pwd_path.is_absolute(): + try: + if pwd_path.resolve() == physical_cwd.resolve(): + return os.path.normpath(pwd) + except OSError: + pass + return str(physical_cwd) + + @app.command(help=_("Run iac-code as an A2A 1.0 server.")) def a2a( ctx: typer.Context, @@ -613,6 +629,11 @@ def a2a( help=_("A2A transport: http, stdio, unix, websocket, grpc, grpc-jsonrpc, or redis-streams"), ), debug: bool = typer.Option(False, "--debug", "-d", help=_("Enable debug logging")), + log_to_stdout: bool = typer.Option( + False, + "--log-to-stdout/--no-log-to-stdout", + help=_("Mirror server logs to stdout"), + ), thinking_exposure: list[str] | None = typer.Option( None, "--thinking-exposure", @@ -654,9 +675,13 @@ def a2a( push_consumer_name = config.get("push_consumer_name", "") push_lease_timeout_ms = config.get("push_lease_timeout_ms", 300000) auto_approve_permissions = config.get("auto_approve_permissions", False) + log_to_stdout = _a2a_config_value(ctx, config, "log_to_stdout", log_to_stdout) thinking_exposure = _a2a_config_value(ctx, config, "thinking_exposure", thinking_exposure) model = load_saved_model() or DEFAULT_MODEL - setup_logging(session_id="a2a-server", debug=debug) + if transport == "stdio" and log_to_stdout: + typer.echo("--log-to-stdout cannot be used with --transport stdio because stdout carries A2A frames.", err=True) + raise typer.Exit(1) + setup_logging(session_id="a2a-server", debug=debug, stdout=bool(log_to_stdout)) try: from iac_code.a2a.app import ( resolve_api_key, @@ -676,19 +701,20 @@ def a2a( import signal as _signal_mod import time - from iac_code.services.telemetry import add_metric, bootstrap_telemetry, graceful_shutdown, log_event + from iac_code.services.telemetry import add_metric, bootstrap_telemetry, graceful_shutdown, log_event, use_user_id from iac_code.services.telemetry.names import Events, Metrics telemetry_session_id = f"a2a-server-{uuid.uuid4()}" - bootstrap_telemetry(session_id=telemetry_session_id) - log_event( - Events.SESSION_STARTED, - { - "mode": "a2a-server", - "transport": transport, - }, - ) - add_metric(Metrics.SESSION_COUNT, 1, {}) + with use_user_id(_A2A_SERVER_TELEMETRY_USER_ID): + bootstrap_telemetry(session_id=telemetry_session_id) + log_event( + Events.SESSION_STARTED, + { + "mode": "a2a-server", + "transport": transport, + }, + ) + add_metric(Metrics.SESSION_COUNT, 1, {}) started = time.monotonic() exit_reason = "normal" @@ -700,14 +726,15 @@ def _finalize_telemetry(reason_override: str | None = None) -> None: _finalized[0] = True final_reason = reason_override or exit_reason try: - log_event( - Events.SESSION_EXITED, - { - "mode": "a2a-server", - "reason": final_reason, - "duration_s": int(time.monotonic() - started), - }, - ) + with use_user_id(_A2A_SERVER_TELEMETRY_USER_ID): + log_event( + Events.SESSION_EXITED, + { + "mode": "a2a-server", + "reason": final_reason, + "duration_s": int(time.monotonic() - started), + }, + ) finally: graceful_shutdown() @@ -718,13 +745,14 @@ def _finalize_telemetry(reason_override: str | None = None) -> None: def _telemetry_excepthook(exc_type, exc_value, traceback_obj): try: - log_event( - Events.EXCEPTION_UNCAUGHT, - { - "error_name": exc_type.__name__, - "location": "a2a", - }, - ) + with use_user_id(_A2A_SERVER_TELEMETRY_USER_ID): + log_event( + Events.EXCEPTION_UNCAUGHT, + { + "error_name": exc_type.__name__, + "location": "a2a", + }, + ) _finalize_telemetry(f"exception:{exc_type.__name__}") finally: sys.__excepthook__(exc_type, exc_value, traceback_obj) @@ -808,6 +836,11 @@ def a2a_call( prompt: str = typer.Option(..., "--prompt", "-p", help=_("Prompt to send")), cwd: str = typer.Option(".", "--cwd", help=_("Working directory metadata to send with the request")), context_id: str = typer.Option("", "--context-id", help=_("A2A context ID to continue")), + iac_code_model: str = typer.Option( + "", + "--iac-code-model", + help=_("Model metadata to send with this A2A request"), + ), token: str = typer.Option("", "--token", help=_("Bearer token for A2A HTTP requests")), basic_username: str = typer.Option("", "--basic-username", help=_("Basic auth username for A2A HTTP requests")), basic_password: str = typer.Option("", "--basic-password", help=_("Basic auth password for A2A HTTP requests")), @@ -841,8 +874,9 @@ def a2a_call( route_name = _a2a_config_value(ctx, config, "route_name", route_name) cwd = _a2a_config_value(ctx, config, "cwd", cwd) if cwd in ("", "."): - cwd = str(Path.cwd()) + cwd = _current_logical_cwd() context_id = _a2a_config_value(ctx, config, "context_id", context_id) + iac_code_model = _a2a_config_value(ctx, config, "iac_code_model", iac_code_model) timeout = _a2a_config_value(ctx, config, "timeout", timeout) stream = _a2a_config_value(ctx, config, "stream", stream) auth_options = _a2a_client_auth_options( @@ -879,6 +913,7 @@ def a2a_call( prompt=prompt, cwd=cwd, context_id=context_id or None, + model=iac_code_model or None, token=auth_options["token"] or None, basic_username=auth_options["basic_username"] or None, basic_password=auth_options["basic_password"] or None, @@ -1542,6 +1577,7 @@ async def _run_a2a_call( prompt: str, cwd: str, context_id: str | None, + model: str | None, token: str | None, basic_username: str | None, basic_password: str | None, @@ -1574,14 +1610,26 @@ async def _run_a2a_call( endpoint_url = client.select_endpoint_url(card, fallback_url=url) if stream: lines = [] - async for event in client.stream_message(endpoint_url, prompt, cwd=str(Path(cwd)), context_id=context_id): + async for event in client.stream_message( + endpoint_url, + prompt, + cwd=str(Path(cwd)), + context_id=context_id, + model=model, + ): line = _format_a2a_stream_event(event) if stream_callback is not None: stream_callback(line) else: lines.append(line) return "\n".join(lines) - response = await client.send_message(endpoint_url, prompt, cwd=str(Path(cwd)), context_id=context_id) + response = await client.send_message( + endpoint_url, + prompt, + cwd=str(Path(cwd)), + context_id=context_id, + model=model, + ) return response.text or json.dumps(response.payload, ensure_ascii=False, indent=2, sort_keys=True) finally: await client.aclose() diff --git a/src/iac_code/commands/auth.py b/src/iac_code/commands/auth.py index e197e633..2829afe7 100644 --- a/src/iac_code/commands/auth.py +++ b/src/iac_code/commands/auth.py @@ -901,7 +901,7 @@ def _llm_auth_flow(console, store) -> str | None | _BackSentinel: ("Azure OpenAI", ["azure_openai"]), ("OpenRouter", ["openrouter"]), ("Local", ["ollama", "lmstudio"]), - ("Compatible", ["openapi_compatible", "anthropic_compatible"]), + ("Compatible", ["openai_compatible", "anthropic_compatible"]), ] provider_map: dict[str, LLMProvider] = {str(p["key_name"]): p for p in PROVIDERS} @@ -968,7 +968,7 @@ def _llm_auth_flow(console, store) -> str | None | _BackSentinel: # Step 3 (Compatible providers): API Base URL user_api_base = None - if provider["key_name"] in ("openapi_compatible", "anthropic_compatible"): + if provider["key_name"] in ("openai_compatible", "anthropic_compatible"): existing_api_base = _load_existing_api_base(str(provider["key_name"])) api_base_result = _input_text_with_default( _("Configure {provider}").format(provider=provider["display_name"]), diff --git a/src/iac_code/config.py b/src/iac_code/config.py index 47b390e6..2b5d13e3 100644 --- a/src/iac_code/config.py +++ b/src/iac_code/config.py @@ -84,6 +84,8 @@ def _add_alias(alias: str, provider_key: str) -> None: for desc in PROVIDER_REGISTRY.values(): _add_alias(desc.name, desc.key) _add_alias(desc.key, desc.key) + _add_alias("OpenAPI Compatible", "openai_compatible") + _add_alias("openapi_compatible", "openai_compatible") return mapping @@ -108,9 +110,10 @@ def _build_canonical_names() -> tuple[str, ...]: _PROVIDER_CANONICAL_NAMES: tuple[str, ...] = _build_canonical_names() # Legacy key_name aliases accepted when reading settings.yml (write path always uses -# the canonical key on the right). Keep DashScope's old "bailian" name readable. +# the canonical key on the right). _LEGACY_KEY_NAME_ALIASES: dict[str, str] = { "bailian": "dashscope", + "openapi_compatible": "openai_compatible", } # Model-name prefix → provider key_name. Used by _detect_provider_name (in @@ -132,7 +135,7 @@ def _build_canonical_names() -> tuple[str, ...]: ) # Module-level flag — warn once per process when IAC_CODE_BASE_URL is set -# but the active provider is not OpenAPICompatible. Reset by tests. +# but the active provider is not OpenAICompatible. Reset by tests. _warned_base_url_ignored: bool = False @@ -319,11 +322,12 @@ def get_provider_config(key_name: str) -> dict[str, Any]: When ``key_name`` is the active provider, IAC_CODE_MODEL and IAC_CODE_BASE_URL env values are overlaid. IAC_CODE_BASE_URL only - applies when the active provider is ``openapi_compatible``; setting + applies when the active provider is ``openai_compatible``; setting it for other providers logs a one-time warning and is ignored. """ global _warned_base_url_ignored + key_name = _LEGACY_KEY_NAME_ALIASES.get(key_name, key_name) settings = _load_yaml(get_settings_path()) providers = settings.get("providers") entry: dict[str, Any] = {} @@ -350,7 +354,7 @@ def get_provider_config(key_name: str) -> dict[str, Any]: if env["model"]: entry["model"] = env["model"] if env["api_base"]: - if active_key == "openapi_compatible": + if active_key == "openai_compatible": entry["apiBase"] = env["api_base"] elif not _warned_base_url_ignored: from loguru import logger @@ -358,7 +362,7 @@ def get_provider_config(key_name: str) -> dict[str, Any]: logger.warning( "IAC_CODE_BASE_URL is set but active provider is " f"{active_key!r}; the value is ignored. " - "IAC_CODE_BASE_URL only applies to OpenAPICompatible." + "IAC_CODE_BASE_URL only applies to OpenAICompatible." ) _warned_base_url_ignored = True @@ -405,7 +409,7 @@ def load_credentials(model: str | None = None) -> dict[str, str]: Returns a dict with six fixed slots: ``anthropic``, ``openai``, ``dashscope``, ``dashscope_token_plan``, ``deepseek``, - ``openapi_compatible``. The ``dashscope`` slot also accepts the legacy + ``openai_compatible``. The ``dashscope`` slot also accepts the legacy ``bailian`` key in the YAML file (file's ``dashscope`` value takes precedence when both are present). @@ -428,6 +432,8 @@ def load_credentials(model: str | None = None) -> dict[str, str]: creds[key] = str(raw.get(key, "") or "") if not creds.get("dashscope"): creds["dashscope"] = str(raw.get("bailian", "") or "") + if not creds.get("openai_compatible"): + creds["openai_compatible"] = str(raw.get("openapi_compatible", "") or "") env = _get_env_overrides() if env["api_key"]: 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 cd96f012..6a483705 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -646,6 +646,10 @@ msgstr "" "A2A-Transport: http, stdio, unix, websocket, grpc, grpc-jsonrpc oder " "redis-streams" +#: src/iac_code/cli/main.py +msgid "Mirror server logs to stdout" +msgstr "Serverprotokolle nach stdout spiegeln" + #: src/iac_code/cli/main.py msgid "" "Expose A2A thinking signal types; repeat for multiple. Values: raw-" @@ -690,6 +694,10 @@ msgstr "Arbeitsverzeichnis-Metadaten, die mit der Anfrage gesendet werden" msgid "A2A context ID to continue" msgstr "Fortzusetzende A2A-Kontext-ID" +#: src/iac_code/cli/main.py +msgid "Model metadata to send with this A2A request" +msgstr "Modell-Metadaten, die mit dieser A2A-Anfrage gesendet werden" + #: src/iac_code/cli/main.py msgid "Bearer token for A2A HTTP requests" msgstr "Bearer-Token für A2A-HTTP-Anfragen" @@ -2493,7 +2501,7 @@ msgid "Alibaba Cloud Bailian Token Plan" msgstr "Alibaba Cloud Bailian Token Plan" #: src/iac_code/providers/registry.py -msgid "OpenAPI Compatible" +msgid "OpenAI Compatible" msgstr "OpenAPI-kompatibel" #: src/iac_code/providers/registry.py @@ -4528,4 +4536,3 @@ msgstr "Stacktrace im öffentlichen Ereignis ausgelassen; siehe error_id." #~ msgid "Project Memory Index" #~ msgstr "Projektspeicherindex" - 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 f9d147c6..bea9968e 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -645,6 +645,10 @@ msgstr "" "Transporte A2A: http, stdio, unix, websocket, grpc, grpc-jsonrpc o redis-" "streams" +#: src/iac_code/cli/main.py +msgid "Mirror server logs to stdout" +msgstr "Duplicar los registros del servidor en stdout" + #: src/iac_code/cli/main.py msgid "" "Expose A2A thinking signal types; repeat for multiple. Values: raw-" @@ -689,6 +693,10 @@ msgstr "Metadatos del directorio de trabajo a enviar con la solicitud" msgid "A2A context ID to continue" msgstr "ID de contexto A2A a continuar" +#: src/iac_code/cli/main.py +msgid "Model metadata to send with this A2A request" +msgstr "Metadatos del modelo que se enviarán con esta solicitud A2A" + #: src/iac_code/cli/main.py msgid "Bearer token for A2A HTTP requests" msgstr "Token Bearer para solicitudes HTTP A2A" @@ -2492,7 +2500,7 @@ msgid "Alibaba Cloud Bailian Token Plan" msgstr "Alibaba Cloud Bailian Token Plan" #: src/iac_code/providers/registry.py -msgid "OpenAPI Compatible" +msgid "OpenAI Compatible" msgstr "Compatible con OpenAPI" #: src/iac_code/providers/registry.py @@ -4530,4 +4538,3 @@ msgstr "La traza de pila se omitió del evento público; consulta error_id." #~ msgid "Project Memory Index" #~ msgstr "Índice de memoria del proyecto" - 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 2d7ec284..2edcd6c0 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -644,6 +644,10 @@ msgstr "" "Transport A2A : http, stdio, unix, websocket, grpc, grpc-jsonrpc ou " "redis-streams" +#: src/iac_code/cli/main.py +msgid "Mirror server logs to stdout" +msgstr "Dupliquer les journaux du serveur vers stdout" + #: src/iac_code/cli/main.py msgid "" "Expose A2A thinking signal types; repeat for multiple. Values: raw-" @@ -688,6 +692,10 @@ msgstr "Métadonnées du répertoire de travail à envoyer avec la requête" msgid "A2A context ID to continue" msgstr "ID de contexte A2A à poursuivre" +#: src/iac_code/cli/main.py +msgid "Model metadata to send with this A2A request" +msgstr "Métadonnées du modèle à envoyer avec cette requête A2A" + #: src/iac_code/cli/main.py msgid "Bearer token for A2A HTTP requests" msgstr "Jeton Bearer pour les requêtes HTTP A2A" @@ -2491,7 +2499,7 @@ msgid "Alibaba Cloud Bailian Token Plan" msgstr "Alibaba Cloud Bailian Token Plan" #: src/iac_code/providers/registry.py -msgid "OpenAPI Compatible" +msgid "OpenAI Compatible" msgstr "Compatible OpenAPI" #: src/iac_code/providers/registry.py @@ -4523,4 +4531,3 @@ msgstr "Trace de pile omise de l’événement public ; consultez error_id." #~ msgid "Project Memory Index" #~ msgstr "Index de mémoire du projet" - 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 1ffeb6b2..c45c6214 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -612,6 +612,10 @@ msgid "" "redis-streams" msgstr "A2A トランスポート: http、stdio、unix、websocket、grpc、grpc-jsonrpc、または redis-streams" +#: src/iac_code/cli/main.py +msgid "Mirror server logs to stdout" +msgstr "サーバーログを標準出力にミラーリング" + #: src/iac_code/cli/main.py msgid "" "Expose A2A thinking signal types; repeat for multiple. Values: raw-" @@ -652,6 +656,10 @@ msgstr "リクエストと共に送信する作業ディレクトリのメタデ msgid "A2A context ID to continue" msgstr "継続する A2A コンテキスト ID" +#: src/iac_code/cli/main.py +msgid "Model metadata to send with this A2A request" +msgstr "この A2A リクエストで送信するモデルメタデータ" + #: src/iac_code/cli/main.py msgid "Bearer token for A2A HTTP requests" msgstr "A2A HTTP リクエスト用の Bearer トークン" @@ -2391,7 +2399,7 @@ msgid "Alibaba Cloud Bailian Token Plan" msgstr "Alibaba Cloud 百錬 Token Plan" #: src/iac_code/providers/registry.py -msgid "OpenAPI Compatible" +msgid "OpenAI Compatible" msgstr "OpenAPI 互換" #: src/iac_code/providers/registry.py @@ -4359,4 +4367,3 @@ msgstr "公開イベントではスタックトレースを省略しました。 #~ msgid "Project Memory Index" #~ 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 3edc982b..7ceb3c1b 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -638,6 +638,10 @@ msgstr "" "Transporte A2A: http, stdio, unix, websocket, grpc, grpc-jsonrpc ou " "redis-streams" +#: src/iac_code/cli/main.py +msgid "Mirror server logs to stdout" +msgstr "Espelhar logs do servidor para stdout" + #: src/iac_code/cli/main.py msgid "" "Expose A2A thinking signal types; repeat for multiple. Values: raw-" @@ -682,6 +686,10 @@ msgstr "Metadados do diretório de trabalho a enviar com a requisição" msgid "A2A context ID to continue" msgstr "ID de contexto A2A para continuar" +#: src/iac_code/cli/main.py +msgid "Model metadata to send with this A2A request" +msgstr "Metadados do modelo a enviar com esta solicitação A2A" + #: src/iac_code/cli/main.py msgid "Bearer token for A2A HTTP requests" msgstr "Token Bearer para requisições HTTP A2A" @@ -2478,7 +2486,7 @@ msgid "Alibaba Cloud Bailian Token Plan" msgstr "Alibaba Cloud Bailian Token Plan" #: src/iac_code/providers/registry.py -msgid "OpenAPI Compatible" +msgid "OpenAI Compatible" msgstr "Compatível com OpenAPI" #: src/iac_code/providers/registry.py @@ -4488,4 +4496,3 @@ msgstr "Rastreamento de pilha omitido do evento público; veja error_id." #~ msgid "Project Memory Index" #~ msgstr "Índice de memória do projeto" - 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 8ac9fb3f..bce338f6 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -602,6 +602,10 @@ msgid "" "redis-streams" msgstr "A2A 传输方式:http、stdio、unix、websocket、grpc、grpc-jsonrpc 或 redis-streams" +#: src/iac_code/cli/main.py +msgid "Mirror server logs to stdout" +msgstr "将服务器日志镜像到标准输出" + #: src/iac_code/cli/main.py msgid "" "Expose A2A thinking signal types; repeat for multiple. Values: raw-" @@ -642,6 +646,10 @@ msgstr "随请求一起发送的工作目录元数据" msgid "A2A context ID to continue" msgstr "要继续的 A2A 上下文 ID" +#: src/iac_code/cli/main.py +msgid "Model metadata to send with this A2A request" +msgstr "随本次 A2A 请求发送的模型元数据" + #: src/iac_code/cli/main.py msgid "Bearer token for A2A HTTP requests" msgstr "用于 A2A HTTP 请求的 Bearer 令牌" @@ -2367,7 +2375,7 @@ msgid "Alibaba Cloud Bailian Token Plan" msgstr "阿里云百炼 Token Plan" #: src/iac_code/providers/registry.py -msgid "OpenAPI Compatible" +msgid "OpenAI Compatible" msgstr "OpenAPI 兼容" #: src/iac_code/providers/registry.py @@ -4317,4 +4325,3 @@ msgstr "公开事件中已省略堆栈跟踪;请查看 error_id。" #~ msgid "Project Memory Index" #~ msgstr "项目记忆索引" - diff --git a/src/iac_code/memory/project_memory.py b/src/iac_code/memory/project_memory.py index bc0d30a6..e7c4cfb9 100644 --- a/src/iac_code/memory/project_memory.py +++ b/src/iac_code/memory/project_memory.py @@ -37,7 +37,10 @@ def resolve_project_root(cwd: str) -> Path: git_root = find_git_worktree_root(cwd) if git_root is not None: return git_root - return Path(cwd).expanduser().resolve() + path = Path(cwd).expanduser() + if not path.is_absolute(): + path = Path(os.path.abspath(str(path))) + return Path(os.path.normpath(str(path))) def project_key_for_cwd(cwd: str) -> str: diff --git a/src/iac_code/providers/registry.py b/src/iac_code/providers/registry.py index 96e2edac..6a6e4c05 100644 --- a/src/iac_code/providers/registry.py +++ b/src/iac_code/providers/registry.py @@ -138,10 +138,10 @@ def model_ids(self) -> list[str]: ], qwenpaw_provider_ids=["deepseek"], ), - "openapi_compatible": ProviderDescriptor( - key="openapi_compatible", - name="OpenAPI Compatible", - display_name="OpenAPI Compatible", + "openai_compatible": ProviderDescriptor( + key="openai_compatible", + name="OpenAI Compatible", + display_name="OpenAI Compatible", provider_class="iac_code.providers.openai_provider.OpenAIProvider", base_url=None, models=[], @@ -447,7 +447,7 @@ def model_ids(self) -> list[str]: _("OpenAI"), _("Anthropic"), _("DeepSeek"), - _("OpenAPI Compatible"), + _("OpenAI Compatible"), _("Google Gemini"), _("Kimi (China)"), _("Kimi (International)"), diff --git a/src/iac_code/services/capabilities/auto_detect.py b/src/iac_code/services/capabilities/auto_detect.py index 79447b06..428a487e 100644 --- a/src/iac_code/services/capabilities/auto_detect.py +++ b/src/iac_code/services/capabilities/auto_detect.py @@ -74,7 +74,7 @@ def flush(self) -> None: self._dirty = False -def probe_openapi_compatible( +def probe_openai_compatible( *, base_url: str, api_key: str | None, diff --git a/src/iac_code/services/capabilities/multimodal.py b/src/iac_code/services/capabilities/multimodal.py index 570fb408..7d1ac20b 100644 --- a/src/iac_code/services/capabilities/multimodal.py +++ b/src/iac_code/services/capabilities/multimodal.py @@ -97,17 +97,17 @@ def is_model_multimodal( return overrides[model].support_multimodal if model in _builtin_multimodal_models(): return True - if provider_key == "openapi_compatible" and base_url: + if provider_key == "openai_compatible" and base_url: from iac_code.services.capabilities.auto_detect import ( AutoDetectCache, - probe_openapi_compatible, + probe_openai_compatible, ) cache = AutoDetectCache() cached = cache.get(base_url, model) if cached is not None: return cached - result = probe_openapi_compatible(base_url=base_url, api_key=api_key, model=model) + result = probe_openai_compatible(base_url=base_url, api_key=api_key, model=model) if result is not None: cache.set(base_url, model, result) cache.flush() diff --git a/src/iac_code/services/providers/aliyun.py b/src/iac_code/services/providers/aliyun.py index 3c05a17a..147bbcc5 100644 --- a/src/iac_code/services/providers/aliyun.py +++ b/src/iac_code/services/providers/aliyun.py @@ -1,6 +1,9 @@ +import contextvars import json import os import time +from collections.abc import Iterator +from contextlib import contextmanager from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -71,6 +74,20 @@ class AliyunCredential: oauth_refresh_token_expire: int = 0 +_aliyun_credential_override: contextvars.ContextVar[AliyunCredential | None] = contextvars.ContextVar( + "iac_code_aliyun_credential_override", default=None +) + + +@contextmanager +def use_aliyun_credential(credential: AliyunCredential) -> Iterator[None]: + token = _aliyun_credential_override.set(credential) + try: + yield + finally: + _aliyun_credential_override.reset(token) + + def mask_sensitive(value: str) -> str: """Mask a sensitive value with '*' characters of the same length.""" if not value: @@ -82,6 +99,10 @@ class AliyunCredentials: @staticmethod def load(config_path: str | None = None) -> AliyunCredential | None: """Load credentials with priority: env vars > iac-code config > aliyun CLI config.""" + override = _aliyun_credential_override.get() + if override is not None: + return override + # Try environment variables first access_key_id = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_ID") access_key_secret = os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_SECRET") diff --git a/src/iac_code/utils/log.py b/src/iac_code/utils/log.py index 7649379b..2e0fd3a9 100644 --- a/src/iac_code/utils/log.py +++ b/src/iac_code/utils/log.py @@ -3,7 +3,9 @@ from __future__ import annotations import logging +import os import shutil +import sys from pathlib import Path from loguru import logger @@ -14,11 +16,20 @@ _LOG_FORMAT = "{time:YYYY-MM-DDTHH:mm:ss.SSS} [{level:<5}] {name}:{function}:{line} - {message}" _startup_handler_id: int | None = None +_stdout_handler_id: int | None = None _runtime_debug_handler_ids: list[int] = [] _debug_enabled: bool = False +_stdout_enabled: bool = False _current_log_file: Path | None = None +def _get_log_dir() -> Path: + raw = os.environ.get("IAC_CODE_LOG_DIR", "").strip() + if raw: + return ensure_private_dir(Path(os.path.expandvars(os.path.expanduser(raw))).resolve()) + return ensure_private_dir(get_config_dir() / "logs") + + def _link_latest(log_dir: Path, log_file: Path) -> None: """Create a latest.log symlink, falling back to copy on Windows without privileges. @@ -56,19 +67,24 @@ def emit(self, record: logging.LogRecord) -> None: def setup_logging( session_id: str, debug: bool = False, + *, + stdout: bool = False, ) -> None: """Configure loguru for the application. Args: session_id: Current session ID, used in log filenames. debug: Enable debug file logging. + stdout: Mirror the same log stream to stdout. """ - global _startup_handler_id, _runtime_debug_handler_ids, _debug_enabled, _current_log_file + global _startup_handler_id, _stdout_handler_id, _runtime_debug_handler_ids, _debug_enabled, _stdout_enabled + global _current_log_file logger.remove() _runtime_debug_handler_ids = [] + _stdout_handler_id = None - log_dir = ensure_private_dir(get_config_dir() / "logs") + log_dir = _get_log_dir() log_file = log_dir / f"{session_id}.log" level = "DEBUG" if debug else "INFO" @@ -78,7 +94,14 @@ def setup_logging( format=_LOG_FORMAT, encoding="utf-8", ) + if stdout: + _stdout_handler_id = logger.add( + sys.stdout, + level=level, + format=_LOG_FORMAT, + ) _debug_enabled = debug + _stdout_enabled = stdout _current_log_file = log_file ensure_private_file(log_file) @@ -103,15 +126,24 @@ def enable_debug_at_runtime(session_id: str) -> Path: Returns: Path to the log file. """ - global _debug_enabled, _current_log_file + global _debug_enabled, _current_log_file, _stdout_handler_id - log_dir = ensure_private_dir(get_config_dir() / "logs") + log_dir = _get_log_dir() log_file = log_dir / f"{session_id}.log" _current_log_file = log_file if _debug_enabled: return log_file + if _stdout_enabled: + if _stdout_handler_id is not None: + logger.remove(_stdout_handler_id) + _stdout_handler_id = logger.add( + sys.stdout, + level="DEBUG", + format=_LOG_FORMAT, + ) + handler_id = logger.add( str(log_file), level="DEBUG", @@ -129,7 +161,7 @@ def enable_debug_at_runtime(session_id: str) -> Path: def disable_debug_at_runtime() -> None: """Disable debug logging mid-session (for /debug off).""" - global _debug_enabled, _startup_handler_id, _runtime_debug_handler_ids + global _debug_enabled, _startup_handler_id, _stdout_handler_id, _runtime_debug_handler_ids if not _debug_enabled: return @@ -153,6 +185,17 @@ def disable_debug_at_runtime() -> None: encoding="utf-8", ) + if _stdout_enabled and _stdout_handler_id is not None: + try: + logger.remove(_stdout_handler_id) + except ValueError: + pass + _stdout_handler_id = logger.add( + sys.stdout, + level="INFO", + format=_LOG_FORMAT, + ) + _debug_enabled = False diff --git a/src/iac_code/utils/project_paths.py b/src/iac_code/utils/project_paths.py index a955b99e..688781ee 100644 --- a/src/iac_code/utils/project_paths.py +++ b/src/iac_code/utils/project_paths.py @@ -142,7 +142,7 @@ def find_git_worktree_root(cwd: str) -> Path | None: while True: git_path = os.path.join(current, ".git") if os.path.isdir(git_path) or os.path.isfile(git_path): - return Path(current).resolve() + return Path(os.path.normpath(current)) parent = os.path.dirname(current) if parent == current: return None diff --git a/tests/a2a/test_client.py b/tests/a2a/test_client.py index fbf839be..0382342b 100644 --- a/tests/a2a/test_client.py +++ b/tests/a2a/test_client.py @@ -138,6 +138,29 @@ async def test_send_message_posts_a2a_1_jsonrpc_request() -> None: assert headers == {"A2A-Version": "1.0"} +@pytest.mark.asyncio +async def test_message_payload_includes_metadata_iac_code_model() -> None: + http = FakeHTTPClient() + client = A2AClient(http_client=http) + + await client.send_message("http://remote/", "hello", cwd="/tmp/work", model="metadata-model") + events = [ + event async for event in client.stream_message("http://remote/", "hello", cwd="/tmp/work", model="stream-model") + ] + + assert events + send_payload = http.requests[-2][2] + stream_payload = http.requests[-1][2] + assert send_payload["params"]["message"]["metadata"]["iac_code"] == { + "cwd": "/tmp/work", + "iac_code_model": "metadata-model", + } + assert stream_payload["params"]["message"]["metadata"]["iac_code"] == { + "cwd": "/tmp/work", + "iac_code_model": "stream-model", + } + + def test_response_text_extracts_from_task_history_agent_message() -> None: response = A2AClientResponse( payload={ diff --git a/tests/a2a/test_executor.py b/tests/a2a/test_executor.py index cb409ddf..2193c6c8 100644 --- a/tests/a2a/test_executor.py +++ b/tests/a2a/test_executor.py @@ -1,4 +1,5 @@ import asyncio +import logging from pathlib import Path import pytest @@ -260,6 +261,50 @@ async def test_executor_creates_missing_workspace(monkeypatch: pytest.MonkeyPatc assert final_state != "TASK_STATE_FAILED" +@pytest.mark.asyncio +async def test_executor_uses_metadata_cwd_when_process_cwd_is_deleted( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + runtime = FakeRuntime(agent_loop=FakeAgentLoop([TextDeltaEvent(text="hi")]), session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + monkeypatch.setenv("IACCODE_A2A_ALLOWED_CWDS", str(tmp_path)) + + def deleted_process_cwd() -> str: + raise FileNotFoundError("[Errno 2] No such file or directory") + + monkeypatch.setattr("iac_code.a2a.executor.os.getcwd", deleted_process_cwd) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) + + assert runtime.agent_loop.prompts == ["hello"] + final_state = dump(queue.events[-1])["status"]["state"] + assert final_state != "TASK_STATE_FAILED" + + +def test_resolve_cwd_returns_logical_metadata_path_for_symlinked_workspace( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + physical_root = tmp_path / "mount-root" + physical_root.mkdir() + logical_root = tmp_path / "workspace" + logical_root.symlink_to(physical_root, target_is_directory=True) + logical_cwd = logical_root / "ctx-1" + monkeypatch.setenv("IACCODE_A2A_ALLOWED_CWDS", str(logical_root)) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + + cwd = executor._resolve_cwd({"iac_code": {"cwd": str(logical_cwd)}}) + + assert cwd == str(logical_cwd) + assert logical_cwd.is_dir() + assert logical_cwd.resolve() == physical_root / "ctx-1" + + @pytest.mark.asyncio async def test_executor_rejects_workspace_path_pointing_at_file(tmp_path: Path) -> None: file_path = tmp_path / "not-a-dir" @@ -931,6 +976,88 @@ async def ensure_task_not_expired(self, task_id: str) -> None: assert dumped["status"]["message"]["parts"][0]["text"] == "A temporary error occurred. Please retry." +@pytest.mark.asyncio +async def test_setup_failure_logs_traceback_with_task_context(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + class FailingTaskStore(A2ATaskStore): + async def ensure_task_not_expired(self, task_id: str) -> None: + raise FileNotFoundError(2, "No such file or directory") + + caplog.set_level(logging.ERROR, logger="iac_code.a2a.executor") + + store = FailingTaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + context = FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}) + + await executor.execute(context, queue) + + dumped = dump(queue.events[-1]) + assert dumped["status"]["state"] == "TASK_STATE_FAILED" + assert dumped["status"]["message"]["parts"][0]["text"] == "[Errno 2] No such file or directory" + assert "A2A executor setup failed" in caplog.text + assert "task_id=task-1" in caplog.text + assert "context_id=ctx-1" in caplog.text + assert "Traceback (most recent call last)" in caplog.text + assert "FileNotFoundError: [Errno 2] No such file or directory" in caplog.text + + +@pytest.mark.asyncio +async def test_runtime_creation_failure_logs_traceback_with_task_context( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + def raise_missing_dependency(options): + raise FileNotFoundError(2, "No such file or directory") + + caplog.set_level(logging.ERROR, logger="iac_code.a2a.executor") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", raise_missing_dependency) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + context = FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}) + + await executor.execute(context, queue) + + dumped = dump(queue.events[-1]) + assert dumped["status"]["state"] == "TASK_STATE_FAILED" + assert dumped["status"]["message"]["parts"][0]["text"].startswith("FileNotFoundError:") + assert "A2A executor runtime setup failed" in caplog.text + assert "task_id=task-1" in caplog.text + assert "context_id=ctx-1" in caplog.text + assert "Traceback (most recent call last)" in caplog.text + assert "FileNotFoundError: [Errno 2] No such file or directory" in caplog.text + + +@pytest.mark.asyncio +async def test_streaming_failure_logs_traceback_with_task_context( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + class ExplodingLoop: + async def run_streaming(self, prompt: str): + raise FileNotFoundError(2, "No such file or directory") + yield TextDeltaEvent(text="never") + + caplog.set_level(logging.ERROR, logger="iac_code.a2a.executor") + runtime = FakeRuntime(agent_loop=ExplodingLoop(), session_id="session-1") + 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") + queue = FakeEventQueue() + context = FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}) + + await executor.execute(context, queue) + + dumped = dump(queue.events[-1]) + assert dumped["status"]["state"] == "TASK_STATE_FAILED" + assert dumped["status"]["message"]["parts"][0]["text"].startswith("FileNotFoundError:") + assert "A2A executor streaming failed" in caplog.text + assert "task_id=task-1" in caplog.text + assert "context_id=ctx-1" in caplog.text + assert "Traceback (most recent call last)" in caplog.text + assert "FileNotFoundError: [Errno 2] No such file or directory" in caplog.text + + @pytest.mark.asyncio async def test_unexpected_error_surfaces_type_and_message(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: class ExplodingLoop: @@ -1101,6 +1228,63 @@ def test_returns_none_for_non_string_value(self) -> None: assert executor._resolve_user_id({"iac_code": {"user_id": 12345}}) is None +class TestResolveAliyunCredential: + def _make_executor(self) -> IacCodeA2AExecutor: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + return IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + + def test_extracts_aliyun_credential_from_iac_code_metadata(self) -> None: + executor = self._make_executor() + + result = executor._resolve_aliyun_credential( + { + "iac_code": { + "alibaba_cloud_access_key_id": "client-id", + "alibaba_cloud_access_key_secret": "client-secret", + "alibaba_cloud_region_id": "cn-beijing", + "alibaba_cloud_security_token": "client-sts", + } + } + ) + + assert result is not None + assert result.mode == "StsToken" + assert result.access_key_id == "client-id" + assert result.access_key_secret == "client-secret" + assert result.region_id == "cn-beijing" + assert result.sts_token == "client-sts" + + def test_uses_default_region_when_metadata_region_is_missing(self) -> None: + executor = self._make_executor() + + result = executor._resolve_aliyun_credential( + { + "iac_code": { + "alibaba_cloud_access_key_id": "client-id", + "alibaba_cloud_access_key_secret": "client-secret", + } + } + ) + + assert result is not None + assert result.region_id == "cn-hangzhou" + assert result.mode == "AK" + + def test_returns_none_for_incomplete_aliyun_metadata(self) -> None: + executor = self._make_executor() + + result = executor._resolve_aliyun_credential( + { + "iac_code": { + "alibaba_cloud_access_key_id": "client-id", + "alibaba_cloud_region_id": "cn-beijing", + } + } + ) + + assert result is None + + @pytest.mark.asyncio async def test_executor_applies_user_id_to_telemetry(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: from iac_code.services.telemetry.identity import _user_id_override @@ -1157,3 +1341,197 @@ async def capturing_run_streaming(self, prompt): await executor.execute(context, queue) assert captured_user_ids == [None] + + +@pytest.mark.asyncio +async def test_executor_uses_metadata_iac_code_model_when_creating_runtime( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + seen_models: list[str] = [] + + def factory(options): + seen_models.append(options.model) + return FakeRuntime( + agent_loop=FakeAgentLoop([TextDeltaEvent(text="ok")]), + session_id=options.session_id, + ) + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", factory) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="server-default-model") + context = FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path), "iac_code_model": "metadata-model"}}) + + await executor.execute(context, FakeEventQueue()) + + assert seen_models == ["metadata-model"] + + +@pytest.mark.asyncio +async def test_executor_reconfigures_cached_runtime_iac_code_model_per_call( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + class FakeProviderManager: + def __init__(self) -> None: + self.calls: list[str] = [] + + def reconfigure(self, model, credentials, provider_key_override=None, base_url_override=None): + self.calls.append(model) + + provider_manager = FakeProviderManager() + runtime = FakeRuntime( + agent_loop=FakeAgentLoop([TextDeltaEvent(text="ok")]), + session_id="session-1", + provider_manager=provider_manager, + ) + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="server-default-model") + + await executor.execute( + FakeRequestContext( + context_id="ctx-1", + metadata={"iac_code": {"cwd": str(tmp_path), "iac_code_model": "metadata-model"}}, + ), + FakeEventQueue(), + ) + await executor.execute( + FakeRequestContext(context_id="ctx-1", task_id="task-2", metadata={"iac_code": {"cwd": str(tmp_path)}}), + FakeEventQueue(), + ) + + assert provider_manager.calls == ["metadata-model", "server-default-model"] + + +@pytest.mark.asyncio +async def test_executor_applies_aliyun_metadata_to_task_credentials( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + from iac_code.services.providers.aliyun import AliyunCredentials + + captured_access_key_ids: list[str | None] = [] + + original_run_streaming = FakeAgentLoop.run_streaming + + async def capturing_run_streaming(self, prompt): + cred = AliyunCredentials.load() + captured_access_key_ids.append(cred.access_key_id if cred else None) + async for event in original_run_streaming(self, prompt): + yield event + + monkeypatch.setattr(FakeAgentLoop, "run_streaming", capturing_run_streaming) + + env = { + "ALIBABA_CLOUD_ACCESS_KEY_ID": "env-id", + "ALIBABA_CLOUD_ACCESS_KEY_SECRET": "env-secret", + "ALIBABA_CLOUD_REGION_ID": "cn-shanghai", + } + loop = FakeAgentLoop([TextDeltaEvent(text="ok")]) + runtime = FakeRuntime(agent_loop=loop, session_id="sess-aliyun") + 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") + context = FakeRequestContext( + metadata={ + "iac_code": { + "cwd": str(tmp_path), + "alibaba_cloud_access_key_id": "client-id", + "alibaba_cloud_access_key_secret": "client-secret", + "alibaba_cloud_region_id": "cn-beijing", + } + } + ) + + monkeypatch.setattr("iac_code.services.providers.aliyun.AliyunCredentials._load_from_iac_code_config", lambda: None) + with monkeypatch.context() as m: + for key, value in env.items(): + m.setenv(key, value) + await executor.execute(context, FakeEventQueue()) + after = AliyunCredentials.load() + + assert captured_access_key_ids == ["client-id"] + assert after is not None + assert after.access_key_id == "env-id" + + +@pytest.mark.asyncio +async def test_executor_applies_aliyun_metadata_while_creating_runtime( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + from iac_code.services.providers.aliyun import AliyunCredentials + + captured_access_key_ids: list[str | None] = [] + + def factory(options): + cred = AliyunCredentials.load() + captured_access_key_ids.append(cred.access_key_id if cred else None) + return FakeRuntime( + agent_loop=FakeAgentLoop([TextDeltaEvent(text="ok")]), + session_id=options.session_id, + ) + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", factory) + monkeypatch.setattr("iac_code.services.providers.aliyun.AliyunCredentials._load_from_iac_code_config", lambda: None) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + context = FakeRequestContext( + metadata={ + "iac_code": { + "cwd": str(tmp_path), + "alibaba_cloud_access_key_id": "client-id", + "alibaba_cloud_access_key_secret": "client-secret", + "alibaba_cloud_region_id": "cn-beijing", + } + } + ) + + await executor.execute(context, FakeEventQueue()) + + assert captured_access_key_ids == ["client-id"] + + +@pytest.mark.asyncio +async def test_executor_refreshes_cloud_tools_with_aliyun_metadata_for_reused_context( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + seen_access_key_ids: list[str | None] = [] + runtime = FakeRuntime( + agent_loop=FakeAgentLoop([TextDeltaEvent(text="ok")]), + session_id="session-1", + tool_registry=object(), + ) + + def fake_register_cloud_tools(registry, credentials): + assert registry is runtime.tool_registry + credential = credentials.get_provider("aliyun") + seen_access_key_ids.append(credential.access_key_id if credential else None) + + monkeypatch.setattr("iac_code.tools.cloud.registry.register_cloud_tools", fake_register_cloud_tools) + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + monkeypatch.setattr("iac_code.services.providers.aliyun.AliyunCredentials._load_from_iac_code_config", lambda: None) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path.resolve()), + runtime_factory=lambda session_id: runtime, + ) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + context = FakeRequestContext( + context_id="ctx-1", + metadata={ + "iac_code": { + "cwd": str(tmp_path), + "alibaba_cloud_access_key_id": "client-id", + "alibaba_cloud_access_key_secret": "client-secret", + "alibaba_cloud_region_id": "cn-beijing", + } + }, + ) + + await executor.execute(context, FakeEventQueue()) + + assert seen_access_key_ids == ["client-id"] diff --git a/tests/a2a/test_parts.py b/tests/a2a/test_parts.py index d431276e..c0f7212f 100644 --- a/tests/a2a/test_parts.py +++ b/tests/a2a/test_parts.py @@ -86,6 +86,32 @@ def test_file_url_audio_part_adds_multimodal_manifest(tmp_path) -> None: assert f"source={source.as_uri()}" in prompt +def test_resolve_workspace_path_falls_back_for_absolute_path_when_process_cwd_is_deleted(monkeypatch, tmp_path) -> None: + def deleted_process_cwd_failure(self): + raise FileNotFoundError("[Errno 2] No such file or directory") + + monkeypatch.setattr(parts.Path, "resolve", deleted_process_cwd_failure) + + assert parts.resolve_workspace_path(tmp_path) == tmp_path.absolute() + + +def test_resolve_workspace_path_does_not_fallback_through_symlink_when_process_cwd_is_deleted( + monkeypatch, tmp_path +) -> None: + outside = tmp_path.parent / "outside" + outside.mkdir() + link = tmp_path / "link" + link.symlink_to(outside, target_is_directory=True) + + def deleted_process_cwd_failure(self): + raise FileNotFoundError("[Errno 2] No such file or directory") + + monkeypatch.setattr(parts.Path, "resolve", deleted_process_cwd_failure) + + with pytest.raises(FileNotFoundError): + parts.resolve_workspace_path(link) + + def test_binary_data_part_decodes_base64_manifest(tmp_path) -> None: encoded = base64.b64encode(b"\x00\x01binary").decode("ascii") diff --git a/tests/acp/__init__.py b/tests/acp/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/acp/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/cli/test_a2a_command.py b/tests/cli/test_a2a_command.py index 8ceaa30b..6c4f087c 100644 --- a/tests/cli/test_a2a_command.py +++ b/tests/cli/test_a2a_command.py @@ -30,6 +30,7 @@ def test_a2a_help_shows_common_server_options_only() -> None: assert "--transport" in stdout assert "--thinking-exposure" in stdout assert "--debug" in stdout + assert "--log-to-stdout" in stdout assert "--socket-path" not in stdout assert "--token" not in stdout assert "--persistence-dir" not in stdout @@ -233,10 +234,14 @@ def test_a2a_command_rejects_missing_push_redis_url(tmp_path) -> None: def test_a2a_command_loads_config_file_and_cli_overrides(monkeypatch, tmp_path) -> None: captured = {} + logging_setup = {} def fake_run_server(**kwargs): captured.update(kwargs) + def fake_setup_logging(**kwargs): + logging_setup.update(kwargs) + config = tmp_path / "a2a.yml" config.write_text( "\n".join( @@ -250,16 +255,19 @@ def fake_run_server(**kwargs): "push_notifications: true", "auto_approve_permissions: true", "thinking_exposure: raw-thinking, tool-trace", + "log-to-stdout: true", ] ), encoding="utf-8", ) monkeypatch.setattr("iac_code.cli.main.load_saved_model", lambda: "qwen3.6-plus") + monkeypatch.setattr("iac_code.cli.main.setup_logging", fake_setup_logging) monkeypatch.setattr("iac_code.a2a.app.run_server", fake_run_server) result = CliRunner().invoke(app, ["a2a", "--config", str(config), "--port", "54321"]) assert result.exit_code == 0 + assert logging_setup == {"session_id": "a2a-server", "debug": False, "stdout": True} assert captured["host"] == "0.0.0.0" assert captured["port"] == 54321 assert captured["transport"] == "websocket" @@ -271,6 +279,49 @@ def fake_run_server(**kwargs): assert captured["thinking_exposure"] == "raw-thinking, tool-trace" +def test_a2a_command_log_to_stdout_cli_overrides_config(monkeypatch, tmp_path) -> None: + logging_setup = {} + + def fake_setup_logging(**kwargs): + logging_setup.update(kwargs) + + config = tmp_path / "a2a.yml" + config.write_text("log-to-stdout: false\n", encoding="utf-8") + monkeypatch.setattr("iac_code.cli.main.setup_logging", fake_setup_logging) + monkeypatch.setattr("iac_code.a2a.app.run_server", lambda **_kwargs: None) + + result = CliRunner().invoke(app, ["a2a", "--config", str(config), "--log-to-stdout"]) + + assert result.exit_code == 0 + assert logging_setup == {"session_id": "a2a-server", "debug": False, "stdout": True} + + +def test_a2a_command_no_log_to_stdout_cli_overrides_config(monkeypatch, tmp_path) -> None: + logging_setup = {} + + def fake_setup_logging(**kwargs): + logging_setup.update(kwargs) + + config = tmp_path / "a2a.yml" + config.write_text("log-to-stdout: true\n", encoding="utf-8") + monkeypatch.setattr("iac_code.cli.main.setup_logging", fake_setup_logging) + monkeypatch.setattr("iac_code.a2a.app.run_server", lambda **_kwargs: None) + + result = CliRunner().invoke(app, ["a2a", "--config", str(config), "--no-log-to-stdout"]) + + assert result.exit_code == 0 + assert logging_setup == {"session_id": "a2a-server", "debug": False, "stdout": False} + + +def test_a2a_command_rejects_stdio_log_to_stdout(monkeypatch) -> None: + monkeypatch.setattr("iac_code.cli.main.setup_logging", lambda **_kwargs: None) + + result = CliRunner().invoke(app, ["a2a", "--transport", "stdio", "--log-to-stdout"]) + + assert result.exit_code == 1 + assert "--log-to-stdout cannot be used with --transport stdio" in result.stderr + + def test_a2a_command_accepts_repeated_thinking_exposure_flags(monkeypatch) -> None: captured = {} @@ -363,8 +414,16 @@ def __init__( called["require_card_signature"] = require_card_signature called["timeout_seconds"] = timeout_seconds - async def send_message(self, url: str, prompt: str, *, cwd: str, context_id: str | None = None): - called["send"] = {"url": url, "prompt": prompt, "cwd": cwd, "context_id": context_id} + async def send_message( + self, + url: str, + prompt: str, + *, + cwd: str, + context_id: str | None = None, + model: str | None = None, + ): + called["send"] = {"url": url, "prompt": prompt, "cwd": cwd, "context_id": context_id, "model": model} return SimpleNamespace(text="created stack", payload={"result": {"text": "created stack"}}) async def discover(self, url: str): @@ -404,6 +463,8 @@ async def aclose(self) -> None: str(tmp_path), "--context-id", "ctx-1", + "--iac-code-model", + "metadata-model", "--token", "bearer", "--api-key", @@ -431,6 +492,7 @@ async def aclose(self) -> None: "prompt": "create vpc", "cwd": str(tmp_path), "context_id": "ctx-1", + "model": "metadata-model", } assert called["discover"] == "http://agent.example/rpc" assert called["fallback_url"] == "http://agent.example/rpc" @@ -448,6 +510,37 @@ async def aclose(self) -> None: assert called["closed"] is True +def test_a2a_call_default_cwd_prefers_logical_pwd(monkeypatch, tmp_path) -> None: + called = {} + physical = tmp_path / "mount-root" / "oss" / "bucket" + physical.mkdir(parents=True) + logical = tmp_path / "workspace" + logical.symlink_to(physical, target_is_directory=True) + + async def fake_run_a2a_call(**kwargs) -> str: + called.update(kwargs) + return "ok" + + monkeypatch.chdir(logical) + monkeypatch.setenv("PWD", str(logical)) + monkeypatch.setattr("iac_code.cli.main._run_a2a_call", fake_run_a2a_call) + + result = CliRunner().invoke( + app, + [ + "a2a-client", + "call", + "--url", + "http://agent.example/rpc", + "--prompt", + "create vpc", + ], + ) + + assert result.exit_code == 0 + assert called["cwd"] == str(logical) + + def test_a2a_call_stream_prints_stream_events(monkeypatch, tmp_path) -> None: called = {} @@ -463,8 +556,16 @@ async def discover(self, url: str): def select_endpoint_url(card, *, fallback_url: str) -> str: return card.get("url", fallback_url) - async def stream_message(self, url: str, prompt: str, *, cwd: str, context_id: str | None = None): - called["stream"] = {"url": url, "prompt": prompt, "cwd": cwd, "context_id": context_id} + async def stream_message( + self, + url: str, + prompt: str, + *, + cwd: str, + context_id: str | None = None, + model: str | None = None, + ): + called["stream"] = {"url": url, "prompt": prompt, "cwd": cwd, "context_id": context_id, "model": model} yield {"result": {"status": {"state": "working", "message": {"parts": [{"text": "planning"}]}}}} yield {"result": {"text": "created stack"}} @@ -501,6 +602,7 @@ async def aclose(self) -> None: "prompt": "create vpc", "cwd": str(tmp_path), "context_id": "ctx-1", + "model": None, } assert called["closed"] is True @@ -551,6 +653,7 @@ async def fake_run_a2a_call(**kwargs) -> str: "url: http://agent.example/rpc", "cwd: /workspace/from-config", "context-id: ctx-from-config", + "iac-code-model: config-model", "token: config-token", "basic-username: config-user", "basic-password: config-pass", @@ -587,6 +690,7 @@ async def fake_run_a2a_call(**kwargs) -> str: "prompt": "create vpc", "cwd": "/workspace/from-config", "context_id": "ctx-from-config", + "model": "config-model", "token": "config-token", "basic_username": "config-user", "basic_password": "config-pass", @@ -1087,6 +1191,26 @@ def fake_run_server(**kwargs): assert shutdown_calls == [1] +def test_a2a_command_does_not_create_settings_yml_before_request(monkeypatch, tmp_path) -> None: + from iac_code.services.telemetry import set_client + + config_dir = tmp_path / "config" + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + monkeypatch.setenv("DISABLE_TELEMETRY", "1") + set_client(None) + monkeypatch.setattr("iac_code.cli.main.load_saved_model", lambda: "qwen3.6-plus") + monkeypatch.setattr("iac_code.a2a.app.run_server", lambda **kwargs: None) + monkeypatch.setattr("atexit.register", lambda *args, **kwargs: None) + + try: + result = CliRunner().invoke(app, ["a2a", "--transport", "http"]) + finally: + set_client(None) + + assert result.exit_code == 0 + assert not (config_dir / "settings.yml").exists() + + def test_a2a_command_flushes_telemetry_when_validation_fails(monkeypatch) -> None: events: list[tuple[str, dict]] = [] shutdown_calls: list[int] = [] diff --git a/tests/cli/test_headless.py b/tests/cli/test_headless.py index 2b14eaf2..21051c5f 100644 --- a/tests/cli/test_headless.py +++ b/tests/cli/test_headless.py @@ -858,7 +858,7 @@ def test_create_agent_loop_builds_expected_dependencies(monkeypatch): "anthropic": "ak", "openai": "ok", "bailian": "bk", - "openapi_compatible": "compat", + "openai_compatible": "compat", }, skills=[], ) @@ -872,7 +872,7 @@ def test_create_agent_loop_builds_expected_dependencies(monkeypatch): assert pm["credentials"]["anthropic"] == "ak" assert pm["credentials"]["openai"] == "ok" assert pm["credentials"]["dashscope"] == "bk" - assert pm["credentials"]["openapi_compatible"] == "compat" + assert pm["credentials"]["openai_compatible"] == "compat" # Session storage is now project-partitioned and constructs its own # default projects_dir from get_config_dir(), so we just assert the # storage was instantiated rather than checking a specific path. @@ -909,7 +909,7 @@ def test_create_agent_loop_handles_credential_load_failure_and_skill_conflict(mo runner._create_agent_loop() creds = captured["provider_manager"]["credentials"] - for key in ("anthropic", "openai", "dashscope", "dashscope_token_plan", "deepseek", "openapi_compatible"): + for key in ("anthropic", "openai", "dashscope", "dashscope_token_plan", "deepseek", "openai_compatible"): assert creds[key] == "" warning.assert_called_once() assert fake_command_registry.registered == [] diff --git a/tests/commands/test_auth_flows.py b/tests/commands/test_auth_flows.py index 3b24fb5a..875ab5b2 100644 --- a/tests/commands/test_auth_flows.py +++ b/tests/commands/test_auth_flows.py @@ -237,8 +237,8 @@ def select_side_effect(title, options, default_index=0): assert "qwen3.6-plus" in result store.set_state.assert_called_once_with(model="qwen3.6-plus") - def test_openapi_compatible_uses_api_base_and_existing_key(self, monkeypatch): - monkeypatch.setattr("iac_code.commands.auth._get_active_key_name", lambda: "openapi_compatible") + def test_openai_compatible_uses_api_base_and_existing_key(self, monkeypatch): + monkeypatch.setattr("iac_code.commands.auth._get_active_key_name", lambda: "openai_compatible") calls = {"select": 0} def select_side_effect(title, options, default_index=0): @@ -285,13 +285,13 @@ def select_side_effect(title, options, default_index=0): assert "custom-model" in result assert "key" not in saved assert saved == { - "provider": "openapi_compatible", + "provider": "openai_compatible", "model": "custom-model", "api_base": "https://new.example/v1", } store.set_state.assert_called_once_with(model="custom-model") - def test_openapi_compatible_empty_api_base_restarts_group_selection(self, monkeypatch): + def test_openai_compatible_empty_api_base_restarts_group_selection(self, monkeypatch): calls = {"select": 0} def select_side_effect(title, options, default_index=0): diff --git a/tests/memory/test_project_memory.py b/tests/memory/test_project_memory.py index 2f9c9a9f..5df9156e 100644 --- a/tests/memory/test_project_memory.py +++ b/tests/memory/test_project_memory.py @@ -39,6 +39,42 @@ def test_project_memory_runtime_defaults_instruction_files_to_agents_md(tmp_path assert runtime.memory_manager._memory_dir == runtime.auto_memory_dir +def test_project_memory_dir_uses_logical_path_for_symlinked_workspace(tmp_path, monkeypatch): + mod = _module() + config_dir = tmp_path / "config" + physical_root = tmp_path / "mount-root" + physical_root.mkdir() + logical_root = tmp_path / "workspace" + logical_root.symlink_to(physical_root, target_is_directory=True) + logical_cwd = logical_root / "ctx-1" + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + + runtime = mod.ProjectMemoryRuntime(str(logical_cwd)) + + expected_key = mod.sanitize_path(str(logical_cwd)) + assert runtime.auto_memory_dir == config_dir / "projects" / expected_key / "memory" + assert runtime.memory_manager._memory_dir == runtime.auto_memory_dir + + +def test_project_memory_dir_uses_logical_git_root_for_symlinked_workspace(tmp_path, monkeypatch): + mod = _module() + config_dir = tmp_path / "config" + physical_root = tmp_path / "mount-root" / "oss" / "bucket" + physical_root.mkdir(parents=True) + (physical_root / ".git").mkdir() + logical_root = tmp_path / "workspace" + logical_root.symlink_to(physical_root, target_is_directory=True) + logical_cwd = logical_root / "ctx-1" + logical_cwd.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + + runtime = mod.ProjectMemoryRuntime(str(logical_cwd)) + + expected_key = mod.sanitize_path(str(logical_root)) + assert runtime.project_root == logical_root + assert runtime.auto_memory_dir == config_dir / "projects" / expected_key / "memory" + + def test_project_memory_runtime_allows_instruction_file_env_override(tmp_path, monkeypatch): mod = _module() config_dir = tmp_path / "config" diff --git a/tests/providers/test_manager.py b/tests/providers/test_manager.py index 00f30979..a6b20717 100644 --- a/tests/providers/test_manager.py +++ b/tests/providers/test_manager.py @@ -44,13 +44,13 @@ def test_unknown_raises(self, monkeypatch): with pytest.raises(ValueError, match="Cannot determine provider"): create_provider("unknown-model", credentials={}) - def test_openapi_compatible(self, monkeypatch): - monkeypatch.setattr("iac_code.config.get_active_provider_key", lambda: "openapi_compatible") + def test_openai_compatible(self, monkeypatch): + monkeypatch.setattr("iac_code.config.get_active_provider_key", lambda: "openai_compatible") monkeypatch.setattr( "iac_code.config.get_provider_config", lambda name: {"apiBase": "https://my.llm.local/v1"}, ) - p = create_provider("any-model", credentials={"openapi_compatible": "sk-x"}) + p = create_provider("any-model", credentials={"openai_compatible": "sk-x"}) assert p.get_model_name() == "any-model" assert p._base_url == "https://my.llm.local/v1" diff --git a/tests/providers/test_registry.py b/tests/providers/test_registry.py index 019aa08a..e6ec4e1b 100644 --- a/tests/providers/test_registry.py +++ b/tests/providers/test_registry.py @@ -33,7 +33,7 @@ def test_known_providers_present(self): "openrouter", "azure_openai", "modelscope", - "openapi_compatible", + "openai_compatible", ] for key in expected: assert key in PROVIDER_REGISTRY, f"Missing provider: {key}" @@ -78,7 +78,7 @@ def test_local_providers_no_api_key_required(self): assert desc.is_local is True def test_qwenpaw_provider_ids_populated(self): - compatible_keys = {"openapi_compatible", "anthropic_compatible"} + compatible_keys = {"openai_compatible", "anthropic_compatible"} for key, desc in PROVIDER_REGISTRY.items(): if key not in compatible_keys: assert desc.qwenpaw_provider_ids, f"{key} missing qwenpaw_provider_ids" diff --git a/tests/services/capabilities/test_auto_detect.py b/tests/services/capabilities/test_auto_detect.py index a41e409f..6d5daffe 100644 --- a/tests/services/capabilities/test_auto_detect.py +++ b/tests/services/capabilities/test_auto_detect.py @@ -2,7 +2,7 @@ from iac_code.services.capabilities.auto_detect import ( AutoDetectCache, - probe_openapi_compatible, + probe_openai_compatible, ) @@ -21,7 +21,7 @@ def handler(request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) with httpx.Client(transport=transport) as client: - result = probe_openapi_compatible( + result = probe_openai_compatible( base_url="https://example.com/v1", api_key="x", model="custom-vl", @@ -38,7 +38,7 @@ def handler(request: httpx.Request) -> httpx.Response: transport = httpx.MockTransport(handler) with httpx.Client(transport=transport) as client: - result = probe_openapi_compatible( + result = probe_openai_compatible( base_url="https://example.com/v1", api_key="x", model="custom-vl", diff --git a/tests/services/providers/test_aliyun.py b/tests/services/providers/test_aliyun.py index 6b65ec40..a6cc2f80 100644 --- a/tests/services/providers/test_aliyun.py +++ b/tests/services/providers/test_aliyun.py @@ -9,6 +9,7 @@ AliyunCredential, AliyunCredentials, mask_sensitive, + use_aliyun_credential, ) from iac_code.services.providers.aliyun_oauth import AliyunOAuthReloginRequired, OAuthStsCredentials, OAuthToken @@ -103,6 +104,35 @@ def test_mask_preserves_length(self): class TestAliyunCredentialsLoadFromEnv: + def test_context_credential_override_takes_priority_over_env(self): + env = { + "ALIBABA_CLOUD_ACCESS_KEY_ID": "env_id", + "ALIBABA_CLOUD_ACCESS_KEY_SECRET": "env_secret", + "ALIBABA_CLOUD_REGION_ID": "cn-shanghai", + "ALIBABA_CLOUD_SECURITY_TOKEN": "env_sts", + } + override = AliyunCredential( + mode="StsToken", + access_key_id="client_id", + access_key_secret="client_secret", + region_id="cn-beijing", + sts_token="client_sts", + ) + + with patch.dict(os.environ, env, clear=False): + with use_aliyun_credential(override): + cred = AliyunCredentials.load() + after = AliyunCredentials.load() + + assert cred is not None + assert cred.access_key_id == "client_id" + assert cred.access_key_secret == "client_secret" + assert cred.region_id == "cn-beijing" + assert cred.sts_token == "client_sts" + assert cred.mode == "StsToken" + assert after is not None + assert after.access_key_id == "env_id" + def test_load_from_env_vars_defaults_to_cn_hangzhou(self): env = { "ALIBABA_CLOUD_ACCESS_KEY_ID": "env_id", diff --git a/tests/test_config_env_overrides.py b/tests/test_config_env_overrides.py index c12eb9a8..eb8d723f 100644 --- a/tests/test_config_env_overrides.py +++ b/tests/test_config_env_overrides.py @@ -60,10 +60,10 @@ def test_provider_uppercase_accepted(self, monkeypatch): assert _get_env_overrides()["provider_key"] == "dashscope" def test_provider_mixed_case_accepted(self, monkeypatch): - monkeypatch.setenv("IAC_CODE_PROVIDER", "oPeNaPiCoMpAtIbLe") + monkeypatch.setenv("IAC_CODE_PROVIDER", "oPeNaIcoMpAtIbLe") from iac_code.config import _get_env_overrides - assert _get_env_overrides()["provider_key"] == "openapi_compatible" + assert _get_env_overrides()["provider_key"] == "openai_compatible" def test_provider_with_surrounding_whitespace_accepted(self, monkeypatch): monkeypatch.setenv("IAC_CODE_PROVIDER", " DashScope ") @@ -74,10 +74,10 @@ def test_provider_with_surrounding_whitespace_accepted(self, monkeypatch): @pytest.mark.parametrize( ("value", "expected"), [ - ("OpenAPI Compatible", "openapi_compatible"), - ("openapi-compatible", "openapi_compatible"), - ("openapi_compatible", "openapi_compatible"), - ("oPeNaPi CoMpAtIbLe", "openapi_compatible"), + ("OpenAI Compatible", "openai_compatible"), + ("openai-compatible", "openai_compatible"), + ("openai_compatible", "openai_compatible"), + ("oPeNaI CoMpAtIbLe", "openai_compatible"), ("DashScope Token Plan", "dashscope_token_plan"), ("dashscope-token-plan", "dashscope_token_plan"), ("dashscope_token_plan", "dashscope_token_plan"), @@ -89,6 +89,13 @@ def test_provider_env_accepts_normalized_display_names_and_keys(self, monkeypatc assert _get_env_overrides()["provider_key"] == expected + @pytest.mark.parametrize("value", ["OpenAPI Compatible", "openapi-compatible", "openapi_compatible"]) + def test_provider_env_accepts_openai_compatible_as_legacy_alias(self, monkeypatch, value): + monkeypatch.setenv("IAC_CODE_PROVIDER", value) + from iac_code.config import _get_env_overrides + + assert _get_env_overrides()["provider_key"] == "openai_compatible" + def test_provider_name_lookup_rejects_colliding_normalized_aliases(self, monkeypatch): from types import SimpleNamespace @@ -217,20 +224,35 @@ def test_model_env_does_not_leak_to_other_providers(self, monkeypatch, tmp_path) assert get_provider_config("bailian")["model"] == "qwen3.6-plus" - def test_base_url_env_overlays_when_active_is_openapi_compatible(self, monkeypatch, tmp_path): + def test_base_url_env_overlays_when_active_is_openai_compatible(self, monkeypatch, tmp_path): from unittest.mock import patch self._write_settings( tmp_path, - ("activeProvider: openapi_compatible\nproviders:\n openapi_compatible:\n apiBase: https://old/v1\n"), + ("activeProvider: openai_compatible\nproviders:\n openai_compatible:\n apiBase: https://old/v1\n"), ) monkeypatch.setenv("IAC_CODE_BASE_URL", "https://new/v1") with patch("iac_code.config.Path.home", return_value=tmp_path): from iac_code.config import get_provider_config + assert get_provider_config("openai_compatible")["apiBase"] == "https://new/v1" + + def test_openapi_compatible_settings_key_is_legacy_alias_for_openai_compatible(self, monkeypatch, tmp_path): + from unittest.mock import patch + + self._write_settings( + tmp_path, + ("activeProvider: openapi_compatible\nproviders:\n openapi_compatible:\n apiBase: https://old/v1\n"), + ) + monkeypatch.setenv("IAC_CODE_BASE_URL", "https://new/v1") + with patch("iac_code.config.Path.home", return_value=tmp_path): + from iac_code.config import get_active_provider_key, get_provider_config + + assert get_active_provider_key() == "openai_compatible" + assert get_provider_config("openai_compatible")["apiBase"] == "https://new/v1" assert get_provider_config("openapi_compatible")["apiBase"] == "https://new/v1" - def test_base_url_env_ignored_when_active_is_not_openapi_compatible(self, monkeypatch, tmp_path, caplog): + def test_base_url_env_ignored_when_active_is_not_openai_compatible(self, monkeypatch, tmp_path, caplog): import logging from unittest.mock import patch @@ -293,7 +315,7 @@ def test_returns_empty_when_no_files(self, monkeypatch, tmp_path): from iac_code.config import load_credentials creds = load_credentials() - for key in ("anthropic", "openai", "dashscope", "dashscope_token_plan", "deepseek", "openapi_compatible"): + for key in ("anthropic", "openai", "dashscope", "dashscope_token_plan", "deepseek", "openai_compatible"): assert creds[key] == "" assert all(v == "" for v in creds.values()) @@ -306,7 +328,7 @@ def test_loads_all_slots_from_file(self, monkeypatch, tmp_path): tmp_path, creds=( "anthropic: ak\nopenai: ok\ndashscope: ds\n" - "dashscope_token_plan: tp\ndeepseek: dk\nopenapi_compatible: oc\n" + "dashscope_token_plan: tp\ndeepseek: dk\nopenai_compatible: oc\n" ), ) with patch("iac_code.config.Path.home", return_value=tmp_path): @@ -318,7 +340,7 @@ def test_loads_all_slots_from_file(self, monkeypatch, tmp_path): assert creds["dashscope"] == "ds" assert creds["dashscope_token_plan"] == "tp" assert creds["deepseek"] == "dk" - assert creds["openapi_compatible"] == "oc" + assert creds["openai_compatible"] == "oc" def test_bailian_legacy_key_falls_back_to_dashscope_slot(self, monkeypatch, tmp_path): from unittest.mock import patch @@ -331,6 +353,17 @@ def test_bailian_legacy_key_falls_back_to_dashscope_slot(self, monkeypatch, tmp_ assert load_credentials()["dashscope"] == "legacy" + def test_openapi_compatible_legacy_key_falls_back_to_openai_compatible_slot(self, monkeypatch, tmp_path): + from unittest.mock import patch + + for n in ("IAC_CODE_PROVIDER", "IAC_CODE_API_KEY"): + monkeypatch.delenv(n, raising=False) + self._write(tmp_path, creds="openapi_compatible: legacy-compat\n") + with patch("iac_code.config.Path.home", return_value=tmp_path): + from iac_code.config import load_credentials + + assert load_credentials()["openai_compatible"] == "legacy-compat" + def test_dashscope_key_takes_precedence_over_bailian_legacy(self, monkeypatch, tmp_path): from unittest.mock import patch diff --git a/tests/ui/test_banner.py b/tests/ui/test_banner.py index cdffec55..998a4bea 100644 --- a/tests/ui/test_banner.py +++ b/tests/ui/test_banner.py @@ -90,12 +90,12 @@ def test_known_name_dashscope(self): result = self._call() assert result != "" - def test_known_name_openapi_compatible(self): + def test_known_name_openai_compatible(self): with ( patch("iac_code.config.get_active_provider_key", return_value="k"), patch( "iac_code.config.get_provider_config", - return_value={"name": "OpenAPI Compatible"}, + return_value={"name": "OpenAI Compatible"}, ), ): result = self._call() diff --git a/tests/ui/test_image_paste_capability_gate.py b/tests/ui/test_image_paste_capability_gate.py index dac78f4d..5b75f6b1 100644 --- a/tests/ui/test_image_paste_capability_gate.py +++ b/tests/ui/test_image_paste_capability_gate.py @@ -139,14 +139,14 @@ def test_ctrl_v_warns_when_store_fails(repl): def test_ctrl_v_passes_provider_context_to_is_model_multimodal(repl): - """When provider is openapi_compatible, the call must include base_url + api_key + """When provider is openai_compatible, the call must include base_url + api_key so auto-detect can probe.""" repl._current_model = "custom-vl" repl._current_provider_config = { - "keyName": "openapi_compatible", + "keyName": "openai_compatible", "apiBase": "https://example.com/v1", } - repl._credentials = {"openapi_compatible": "sk-test"} + repl._credentials = {"openai_compatible": "sk-test"} img = ClipboardImage(data=_valid_png_bytes(), media_type="image/png") with ( patch("iac_code.utils.image.clipboard.get_image_from_clipboard", return_value=img), @@ -160,7 +160,7 @@ def test_ctrl_v_passes_provider_context_to_is_model_multimodal(repl): handle_image_paste(repl) mock_capability.assert_called_once_with( "custom-vl", - provider_key="openapi_compatible", + provider_key="openai_compatible", base_url="https://example.com/v1", api_key="sk-test", ) diff --git a/tests/utils/test_log.py b/tests/utils/test_log.py index 1976cd63..b1bb279d 100644 --- a/tests/utils/test_log.py +++ b/tests/utils/test_log.py @@ -46,6 +46,21 @@ def test_setup_logging_info_level(tmp_path, monkeypatch): assert "should be present" in content +def test_setup_logging_can_mirror_to_stdout(tmp_path, monkeypatch, capsys): + """When requested, log messages should still write the log file and mirror to stdout.""" + monkeypatch.setattr("iac_code.utils.log.get_config_dir", lambda: tmp_path) + logger.remove() + + setup_logging(session_id="stdout", debug=False, stdout=True) + + logger.info("visible on stdout") + logger.complete() + + captured = capsys.readouterr() + assert "visible on stdout" in captured.out + assert "visible on stdout" in (tmp_path / "logs" / "stdout.log").read_text(encoding="utf-8") + + def test_setup_logging_creates_latest_symlink(tmp_path, monkeypatch): """Should create a 'latest.log' symlink pointing to current log.""" monkeypatch.setattr("iac_code.utils.log.get_config_dir", lambda: tmp_path) @@ -58,6 +73,23 @@ def test_setup_logging_creates_latest_symlink(tmp_path, monkeypatch): assert latest.resolve() == (tmp_path / "logs" / "sym123.log").resolve() +def test_setup_logging_uses_iac_code_log_dir(tmp_path, monkeypatch): + """IAC_CODE_LOG_DIR should move log files out of the config directory.""" + config_dir = tmp_path / "config" + log_dir = tmp_path / "runtime-logs" + monkeypatch.setattr("iac_code.utils.log.get_config_dir", lambda: config_dir) + monkeypatch.setenv("IAC_CODE_LOG_DIR", str(log_dir)) + logger.remove() + + setup_logging(session_id="custom-dir", debug=False) + logger.info("custom log dir") + logger.complete() + + assert (log_dir / "custom-dir.log").exists() + assert (log_dir / "latest.log").exists() + assert not (config_dir / "logs").exists() + + def test_is_debug_enabled_reflects_setup(tmp_path, monkeypatch): """is_debug_enabled mirrors the debug flag passed to setup_logging.""" monkeypatch.setattr("iac_code.utils.log.get_config_dir", lambda: tmp_path) diff --git a/tests/utils/test_project_paths.py b/tests/utils/test_project_paths.py index 485210cd..91a03d95 100644 --- a/tests/utils/test_project_paths.py +++ b/tests/utils/test_project_paths.py @@ -162,6 +162,17 @@ def test_repo_subdir_walks_up(self, tmp_path: Path): sub.mkdir(parents=True) assert find_git_worktree_root(str(sub)) == tmp_path.resolve() + def test_symlinked_repo_returns_logical_worktree_root(self, tmp_path: Path): + physical = tmp_path / "mount-root" / "oss" / "bucket" + physical.mkdir(parents=True) + (physical / ".git").mkdir() + logical = tmp_path / "workspace" + logical.symlink_to(physical, target_is_directory=True) + sub = logical / "ctx-1" + sub.mkdir() + + assert find_git_worktree_root(str(sub)) == logical + def test_worktree_with_absolute_gitdir_pointer(self, tmp_path: Path): """A linked worktree's root is the dir containing its .git file.""" real_git = tmp_path / "real" / ".git" diff --git a/website/docs/a2a/command-reference.md b/website/docs/a2a/command-reference.md index bbedc846..ce58f030 100644 --- a/website/docs/a2a/command-reference.md +++ b/website/docs/a2a/command-reference.md @@ -59,6 +59,7 @@ require-card-signature: true timeout: 30 cwd: /path/to/workspace context-id: ctx-123 +iac-code-model: qwen-plus task-id: task-123 config-id: webhook-1 callback-url: https://hooks.example.com/a2a @@ -95,6 +96,7 @@ By default, the server binds to `127.0.0.1:41242` and serves JSON-RPC over HTTP. | `--transport` | `http` | Server transport: `http`, `stdio`, `unix`, `websocket`, `grpc`, `grpc-jsonrpc`, or `redis-streams` | | `--thinking-exposure` | `tool-trace` | Expose an A2A thinking signal type; repeat for multiple. Values: `raw-thinking`, `tool-trace` | | `--debug`, `-d` | `false` | Enable debug logging | +| `--log-to-stdout` / `--no-log-to-stdout` | `false` | Mirror server logs to stdout. Not supported with `--transport stdio` because stdout carries A2A protocol frames | Example: @@ -114,6 +116,7 @@ token: local-dev-token persistence-dir: .iac-code-a2a/state artifact-dir: .iac-code-a2a/artifacts push-notifications: true +log-to-stdout: true ``` Run it with: @@ -267,7 +270,8 @@ Discover an Agent Card, choose the advertised endpoint, and send a prompt. ```bash iac-code a2a-client --config a2a-client.yml call \ --prompt "Create a ROS VPC template with two vSwitches." \ - --cwd "$PWD" + --cwd "$PWD" \ + --iac-code-model qwen-plus ``` | Option | Default | Description | @@ -278,6 +282,7 @@ iac-code a2a-client --config a2a-client.yml call \ | `--prompt`, `-p` | required | Prompt text | | `--cwd` | `.` | Workspace path sent as `message.metadata.iac_code.cwd` | | `--context-id` | empty | Existing A2A context ID for a follow-up message | +| `--iac-code-model` | empty | LLM model sent as `message.metadata.iac_code.iac_code_model`; overrides server model config for this message turn only | | `--verify-card-secret`, `--signing-secret` | empty | HMAC secret for Agent Card verification | | `--verify-card-jwks-url` | empty | Remote JWKS URL used for Agent Card verification | | `--require-card-signature`, `--require-signature` | `false` | Reject unsigned or invalid Agent Cards | diff --git a/website/docs/a2a/examples.md b/website/docs/a2a/examples.md index e8085bbb..5fdb63cd 100644 --- a/website/docs/a2a/examples.md +++ b/website/docs/a2a/examples.md @@ -137,6 +137,7 @@ Send a streaming request: iac-code a2a-client --config a2a-client.yml call \ --prompt "Create a ROS VPC template with two vSwitches." \ --cwd "$PWD" \ + --iac-code-model qwen-plus \ --stream ``` @@ -379,7 +380,7 @@ def handle_iac_metadata(metadata: dict[str, Any]) -> None: | Symptom | Fix | |---------|-----| | HTTP `401` | Include a configured auth scheme, such as `Authorization: Bearer `, Basic auth, or `X-API-Key: `, on both Agent Card and JSON-RPC requests | -| `Invalid A2A workspace metadata.` | Use an existing absolute path in `metadata.iac_code.cwd` | +| `Invalid A2A workspace metadata.` | Use an absolute directory path in `metadata.iac_code.cwd` that resolves inside `IACCODE_A2A_ALLOWED_CWDS` | | `A2A server currently accepts text input only.` | Send at least one non-empty text part | | `Task is already working.` | Wait for the current turn to finish before sending another message in the same context | | Follow-up rejected as different workspace | Keep `metadata.iac_code.cwd` unchanged for a reused `contextId` | diff --git a/website/docs/a2a/getting-started.md b/website/docs/a2a/getting-started.md index eabf364f..ef25e268 100644 --- a/website/docs/a2a/getting-started.md +++ b/website/docs/a2a/getting-started.md @@ -139,6 +139,7 @@ token: your-secret-token verify-card-secret: your-card-signing-secret require-card-signature: true cwd: /path/to/workspace +iac-code-model: qwen-plus ``` Use `a2a-client call` for a direct Phase 1 client call: @@ -177,7 +178,13 @@ See [Command Reference](./command-reference.md) for every A2A command, including ## Send a First Message with curl -Pass the workspace directory through `message.metadata.iac_code.cwd`; the path must be absolute, must already exist, and must be inside an allowed workspace root. By default, allowed roots are the server process directory and the system temp directory. Override them with `IACCODE_A2A_ALLOWED_CWDS`. +Pass the workspace directory through `message.metadata.iac_code.cwd`; the path must be absolute and must resolve inside an allowed workspace root. Existing paths must be directories; missing paths are created under the allowed root. By default, allowed roots are the server process directory and the system temp directory. Override them with `IACCODE_A2A_ALLOWED_CWDS`. + +For per-task telemetry attribution, include a non-empty string `user_id` under `message.metadata.iac_code`. This overrides the telemetry user ID for the current task only; it does not change A2A task or context identifiers. + +For a per-call LLM override, include a non-empty string `iac_code_model` under `message.metadata.iac_code`. This field is the lowercase form of `IAC_CODE_MODEL`; it overrides `IAC_CODE_MODEL`, `settings.yml`, and the server startup default model only for the current A2A message turn. + +For per-request Alibaba Cloud credentials, include `alibaba_cloud_access_key_id`, `alibaba_cloud_access_key_secret`, `alibaba_cloud_region_id`, and optional `alibaba_cloud_security_token` under `message.metadata.iac_code`. These task credentials override the server environment and `.cloud-credentials.yml` for that A2A execution only. The server accepts text-like parts, JSON data parts, raw UTF-8 text, local workspace `file://` text files, and bounded multimodal attachments. Remote URL ingestion is not supported; `url` parts must be local `file://` URLs inside the allowed workspace. diff --git a/website/docs/a2a/protocol-reference.md b/website/docs/a2a/protocol-reference.md index 4c371ae0..81a3b4e3 100644 --- a/website/docs/a2a/protocol-reference.md +++ b/website/docs/a2a/protocol-reference.md @@ -131,7 +131,11 @@ Runs a non-streaming A2A message turn. The response contains a task or message a "role": "ROLE_USER", "parts": [{"text": "Create a VPC with two vSwitches."}], "metadata": { - "iac_code": {"cwd": "/absolute/path/to/project"} + "iac_code": { + "cwd": "/absolute/path/to/project", + "user_id": "client-user-123", + "iac_code_model": "qwen-plus" + } } }, "configuration": { @@ -149,8 +153,20 @@ Runs a non-streaming A2A message turn. The response contains a task or message a | `role` | string | Yes | Use `ROLE_USER` for user input | | `parts` | array | Yes | Text-like, JSON data, raw text, local file URL, or bounded multimodal parts | | `metadata.iac_code.cwd` | string | Recommended | Absolute workspace path; defaults to the server process directory if omitted | +| `metadata.iac_code.user_id` | string | Optional | Per-task telemetry user ID override; ignored when blank or non-string | +| `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.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 | +| `metadata.iac_code.alibaba_cloud_security_token` | string | Optional | Alibaba Cloud STS token for this task | + +`metadata.iac_code.cwd` must be an absolute directory path when provided. It must resolve inside an allowed workspace root. If it already exists, it must be a directory; if it does not exist, the executor creates it under the allowed root. By default, allowed roots are the server process directory and the system temp directory; `IACCODE_A2A_ALLOWED_CWDS` can provide an OS-path-separated allowlist. + +When `metadata.iac_code` includes both `alibaba_cloud_access_key_id` and `alibaba_cloud_access_key_secret`, the A2A executor uses those Alibaba Cloud credentials only for the current task. They take priority over process environment variables and `.cloud-credentials.yml`; if the task metadata is incomplete or absent, normal credential fallback still applies. + +`metadata.iac_code.user_id` only affects telemetry identity for the current task. It does not change the A2A `contextId`, `taskId`, or iac-code's internal session ID. -`metadata.iac_code.cwd` must be an existing absolute directory when provided. It must be inside an allowed workspace root. By default, allowed roots are the server process directory and the system temp directory; `IACCODE_A2A_ALLOWED_CWDS` can provide an OS-path-separated allowlist. +`metadata.iac_code.iac_code_model` only affects the current A2A message turn. It takes priority over `IAC_CODE_MODEL`, `settings.yml`, and the server startup default model. Follow-up turns without this metadata field fall back to the server default model even when they reuse the same `contextId`. Supported input categories: diff --git a/website/docs/configuration/environment-variables.md b/website/docs/configuration/environment-variables.md index 3d93e4cc..3603b433 100644 --- a/website/docs/configuration/environment-variables.md +++ b/website/docs/configuration/environment-variables.md @@ -17,9 +17,9 @@ Environment variables are useful for CI/CD pipelines, containers, and one-off ov | Variable | Description | |---|---| -| `IAC_CODE_PROVIDER` | Model provider name (case-insensitive). Valid values: `DashScope`, `DashScope Token Plan`, `OpenAI`, `Anthropic`, `DeepSeek`, `Gemini`, `Azure OpenAI`, `ModelScope`, `Kimi CN`, `Kimi Intl`, `MiniMax CN`, `MiniMax Intl`, `ZhiPu CN`, `ZhiPu Intl`, `Volcengine CN`, `SiliconFlow CN`, `SiliconFlow Intl`, `Aliyun CodingPlan`, `Aliyun CodingPlan Intl`, `ZhiPu CN CodingPlan`, `ZhiPu Intl CodingPlan`, `Volcengine CodingPlan`, `OpenAPI Compatible`, `Anthropic Compatible`, `OpenRouter`, `Ollama`, `LM Studio` | +| `IAC_CODE_PROVIDER` | Model provider name (case-insensitive). Valid values: `DashScope`, `DashScope Token Plan`, `OpenAI`, `Anthropic`, `DeepSeek`, `Gemini`, `Azure OpenAI`, `ModelScope`, `Kimi CN`, `Kimi Intl`, `MiniMax CN`, `MiniMax Intl`, `ZhiPu CN`, `ZhiPu Intl`, `Volcengine CN`, `SiliconFlow CN`, `SiliconFlow Intl`, `Aliyun CodingPlan`, `Aliyun CodingPlan Intl`, `ZhiPu CN CodingPlan`, `ZhiPu Intl CodingPlan`, `Volcengine CodingPlan`, `OpenAI Compatible`, `Anthropic Compatible`, `OpenRouter`, `Ollama`, `LM Studio` | | `IAC_CODE_MODEL` | Model name | -| `IAC_CODE_BASE_URL` | API endpoint for `OpenAPI Compatible` and `Anthropic Compatible` only; ignored for other providers | +| `IAC_CODE_BASE_URL` | API endpoint for `OpenAI Compatible` and `Anthropic Compatible` only; ignored for other providers | | `IAC_CODE_API_KEY` | Provider API key; overrides the active provider's key in `.credentials.yml` | See [LLM Providers](./llm-providers.md) for provider details. @@ -52,6 +52,7 @@ See [Alibaba Cloud Credentials](./alibaba-cloud-credentials.md) for more details | Variable | Description | |---|---| | `IAC_CODE_CONFIG_DIR` | Override the runtime configuration directory (default `~/.iac-code/`); supports `~` and `$VAR` expansion. All persisted artifacts (credentials, settings, history, projects, image cache, skills, telemetry, etc.) follow it | +| `IAC_CODE_LOG_DIR` | Override the local log directory (default `/logs/`); supports `~` and `$VAR` expansion. Use this to keep startup/debug logs outside `IAC_CODE_CONFIG_DIR` when that directory is mounted or replaced at runtime | | `IAC_CODE_ENV` | Deployment environment label (default: `production`) | | `IAC_CODE_TENANT_ID` | Tenant identifier for telemetry; auto-prefixed with `iac_tenant_` if not already | | `IAC_CODE_GIT_BASH_PATH` | Path to Git Bash `bash.exe` on Windows when it is not on PATH | diff --git a/website/docs/configuration/llm-providers.md b/website/docs/configuration/llm-providers.md index 56738c76..0898336c 100644 --- a/website/docs/configuration/llm-providers.md +++ b/website/docs/configuration/llm-providers.md @@ -57,7 +57,7 @@ CLI arguments > environment variables > configuration files | Provider value | Purpose | |---|---| -| `OpenAPI Compatible` | Any OpenAI-compatible API endpoint | +| `OpenAI Compatible` | Any OpenAI-compatible API endpoint | | `Anthropic Compatible` | Any Anthropic-compatible API endpoint | | `OpenRouter` | OpenRouter aggregation gateway | @@ -74,5 +74,5 @@ CLI arguments > environment variables > configuration files |---|---| | `IAC_CODE_PROVIDER` | Model provider name (case-insensitive). See tables above for valid values | | `IAC_CODE_MODEL` | Model name | -| `IAC_CODE_BASE_URL` | API endpoint for `OpenAPI Compatible` and `Anthropic Compatible` only; ignored for other providers | +| `IAC_CODE_BASE_URL` | API endpoint for `OpenAI Compatible` and `Anthropic Compatible` only; ignored for other providers | | `IAC_CODE_API_KEY` | Provider API key | diff --git a/website/docs/configuration/runtime-configuration.md b/website/docs/configuration/runtime-configuration.md index a51ff99b..8309c4a8 100644 --- a/website/docs/configuration/runtime-configuration.md +++ b/website/docs/configuration/runtime-configuration.md @@ -19,7 +19,7 @@ The runtime directory defaults to: ~/.iac-code/ ``` -You can relocate it by setting the `IAC_CODE_CONFIG_DIR` environment variable (supports `~` and `$VAR` expansion). When set, every persisted artifact — credentials, settings, history, `projects/`, `image-cache/`, `tool-results/`, `logs/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — follows the new location. +You can relocate it by setting the `IAC_CODE_CONFIG_DIR` environment variable (supports `~` and `$VAR` expansion). When set, every persisted artifact — credentials, settings, history, `projects/`, `image-cache/`, `tool-results/`, `memory/`, `a2a/`, `telemetry/`, `skills/` — follows the new location. Logs default to `/logs/` but can be moved separately with `IAC_CODE_LOG_DIR`. Common files: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/command-reference.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/command-reference.md index a322ffe8..b45aa6a1 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/command-reference.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -59,6 +59,7 @@ require-card-signature: true timeout: 30 cwd: /path/to/workspace context-id: ctx-123 +iac-code-model: qwen-plus task-id: task-123 config-id: webhook-1 callback-url: https://hooks.example.com/a2a @@ -267,7 +268,8 @@ thinking-exposure: ```bash iac-code a2a-client --config a2a-client.yml call \ --prompt "Create a ROS VPC template with two vSwitches." \ - --cwd "$PWD" + --cwd "$PWD" \ + --iac-code-model qwen-plus ``` | 选项 | 默认值 | 描述 | @@ -278,6 +280,7 @@ iac-code a2a-client --config a2a-client.yml call \ | `--prompt`, `-p` | 必需 | Prompt text | | `--cwd` | `.` | 作为 `message.metadata.iac_code.cwd` 发送的 workspace path | | `--context-id` | 空 | 用于后续消息的现有 A2A context ID | +| `--iac-code-model` | 空 | 作为 `message.metadata.iac_code.iac_code_model` 发送的 LLM model;只在本次 message turn 覆盖 server model 配置 | | `--verify-card-secret`, `--signing-secret` | 空 | Agent Card verification 的 HMAC secret | | `--verify-card-jwks-url` | 空 | 用于 Agent Card verification 的远程 JWKS URL | | `--require-card-signature`, `--require-signature` | `false` | 拒绝未签名或无效的 Agent Cards | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/examples.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/examples.md index 43e7238f..b7f678e9 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/examples.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/examples.md @@ -137,6 +137,7 @@ iac-code a2a-client --config a2a-client.yml discover iac-code a2a-client --config a2a-client.yml call \ --prompt "Create a ROS VPC template with two vSwitches." \ --cwd "$PWD" \ + --iac-code-model qwen-plus \ --stream ``` @@ -379,7 +380,7 @@ def handle_iac_metadata(metadata: dict[str, Any]) -> None: | 现象 | 修复方式 | |---------|-----| | HTTP `401` | 在 Agent Card 和 JSON-RPC 请求中都包含已配置的 auth scheme,例如 `Authorization: Bearer `、Basic auth 或 `X-API-Key: ` | -| `Invalid A2A workspace metadata.` | 在 `metadata.iac_code.cwd` 中使用已存在的绝对路径 | +| `Invalid A2A workspace metadata.` | 在 `metadata.iac_code.cwd` 中使用绝对目录路径,并确保它 resolve 后位于 `IACCODE_A2A_ALLOWED_CWDS` 内 | | `A2A server currently accepts text input only.` | 至少发送一个非空 text part | | `Task is already working.` | 等待当前轮次完成后,再在同一 context 中发送另一条消息 | | 后续消息因不同工作区被拒绝 | 对复用的 `contextId` 保持 `metadata.iac_code.cwd` 不变 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/getting-started.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/getting-started.md index 38ffeb7d..1db90b48 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/getting-started.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/getting-started.md @@ -139,6 +139,7 @@ token: your-secret-token verify-card-secret: your-card-signing-secret require-card-signature: true cwd: /path/to/workspace +iac-code-model: qwen-plus ``` 使用 `a2a-client call` 进行直接的 Phase 1 client 调用: @@ -177,7 +178,13 @@ iac-code a2a-client route-preview \ ## 使用 curl 发送第一条消息 -通过 `message.metadata.iac_code.cwd` 传递工作区目录;该路径必须是绝对路径,必须已经存在,并且必须位于允许的工作区根目录内。默认情况下,允许的根目录是服务器进程目录和系统临时目录。可以用 `IACCODE_A2A_ALLOWED_CWDS` 覆盖它们。 +通过 `message.metadata.iac_code.cwd` 传递工作区目录;该路径必须是绝对路径,并且 resolve 后必须位于允许的工作区根目录内。已存在的路径必须是目录;缺失路径会在允许根目录下创建。默认情况下,允许的根目录是服务器进程目录和系统临时目录。可以用 `IACCODE_A2A_ALLOWED_CWDS` 覆盖它们。 + +如需按任务覆盖 telemetry attribution,请在 `message.metadata.iac_code` 下传入非空字符串 `user_id`。它只覆盖当前任务的 telemetry user ID,不会改变 A2A task 或 context identifiers。 + +如需按调用覆盖 LLM,请在 `message.metadata.iac_code` 下传入非空字符串 `iac_code_model`。该字段是 `IAC_CODE_MODEL` 的小写形式;它只在当前 A2A message turn 中覆盖 `IAC_CODE_MODEL`、`settings.yml` 和 server 启动默认 model。 + +如需按请求传入 Alibaba Cloud 凭据,请在 `message.metadata.iac_code` 下包含 `alibaba_cloud_access_key_id`、`alibaba_cloud_access_key_secret`、`alibaba_cloud_region_id` 和可选的 `alibaba_cloud_security_token`。这些任务凭据只在本次 A2A 执行中覆盖 server 环境和 `.cloud-credentials.yml`。 服务器接受类文本 parts、JSON 数据 parts、原始 UTF-8 文本、本地工作区 `file://` 文本文件和有界多模态附件。不支持远程 URL 摄取;`url` parts 必须是位于允许工作区内的本地 `file://` URLs。 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 e2adeecd..2fe387b6 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 @@ -131,7 +131,11 @@ Callback URLs 会在存储前以及分发前再次校验。默认 validator 会 "role": "ROLE_USER", "parts": [{"text": "Create a VPC with two vSwitches."}], "metadata": { - "iac_code": {"cwd": "/absolute/path/to/project"} + "iac_code": { + "cwd": "/absolute/path/to/project", + "user_id": "client-user-123", + "iac_code_model": "qwen-plus" + } } }, "configuration": { @@ -149,8 +153,20 @@ Callback URLs 会在存储前以及分发前再次校验。默认 validator 会 | `role` | string | 是 | 对用户输入使用 `ROLE_USER` | | `parts` | array | 是 | 类文本、JSON 数据、原始文本、本地 file URL 或有界多模态 parts | | `metadata.iac_code.cwd` | string | 建议 | 绝对工作区路径;省略时默认为服务器进程目录 | +| `metadata.iac_code.user_id` | string | 可选 | 当前任务的 telemetry user ID 覆盖;空字符串或非字符串会被忽略 | +| `metadata.iac_code.iac_code_model` | string | 可选 | 当前调用的 LLM model 覆盖;这是 `IAC_CODE_MODEL` 的小写形式,空字符串或非字符串会被忽略 | +| `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` | +| `metadata.iac_code.alibaba_cloud_security_token` | string | 可选 | 当前任务使用的 Alibaba Cloud STS token | + +提供 `metadata.iac_code.cwd` 时,它必须是一个绝对目录路径,并且 resolve 后位于允许的工作区根目录内。如果路径已存在,它必须是目录;如果路径不存在,executor 会在允许根目录下创建它。默认情况下,允许的根目录是服务器进程目录和系统临时目录;`IACCODE_A2A_ALLOWED_CWDS` 可以提供按 OS path separator 分隔的 allowlist。 + +当 `metadata.iac_code` 同时包含 `alibaba_cloud_access_key_id` 和 `alibaba_cloud_access_key_secret` 时,A2A executor 只会在当前任务中使用这些 Alibaba Cloud 凭据。它们优先于进程环境变量和 `.cloud-credentials.yml`;如果任务 metadata 不完整或不存在,则继续使用正常凭据 fallback。 + +`metadata.iac_code.user_id` 只影响当前任务的 telemetry identity,不会改变 A2A `contextId`、`taskId` 或 iac-code 内部 session ID。 -提供 `metadata.iac_code.cwd` 时,它必须是一个已存在的绝对目录。它必须位于允许的工作区根目录内。默认情况下,允许的根目录是服务器进程目录和系统临时目录;`IACCODE_A2A_ALLOWED_CWDS` 可以提供按 OS path separator 分隔的 allowlist。 +`metadata.iac_code.iac_code_model` 只影响当前 A2A message turn。它优先于 `IAC_CODE_MODEL`、`settings.yml` 和 server 启动默认 model;复用同一个 `contextId` 的后续轮次如果不再传该字段,会回落到 server 默认 model。 支持的输入类别: