From 775232a88151aaade548f24babfd5d4b27d35b09 Mon Sep 17 00:00:00 2001 From: moonyue-w <300878504+moonyue-w@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:17:17 +0800 Subject: [PATCH 1/2] fix: align session and resource contracts with QCA docs --- CHANGELOG.md | 17 ++ docs/api/reference.md | 198 ++++++++++++++++++ .../forward/resources/sessions/sessions.py | 11 +- src/qca/forward/types/__init__.py | 4 + src/qca/forward/types/budget_limit.py | 13 ++ src/qca/forward/types/budget_limit_param.py | 13 ++ src/qca/forward/types/environment_config.py | 2 + src/qca/forward/types/model_config.py | 1 + src/qca/forward/types/model_config_param.py | 1 + src/qca/forward/types/session.py | 2 + src/qca/forward/types/session_config.py | 5 +- .../forward/types/session_create_params.py | 3 + .../session_create_params_config_param.py | 6 +- src/qca/forward/types/session_event.py | 12 ++ .../forward/types/session_update_params.py | 3 + .../session_update_params_config_param.py | 6 +- .../forward/types/tool_approval_evaluation.py | 21 ++ src/qca/managed/types/cloud_config_params.py | 2 + .../managed/types/environment_config_union.py | 2 + src/qca/managed/types/memory.py | 1 + .../managed/types/memory_list_item_union.py | 4 +- src/qca/managed/types/memory_version.py | 1 + .../types/self_hosted_config_params.py | 2 + src/qca/managed/types/session_thread.py | 6 +- src/qca/managed/types/session_thread_usage.py | 2 + src/qca/managed/types/vault.py | 7 +- tests/test_documentation_alignment.py | 194 +++++++++++++++++ 27 files changed, 531 insertions(+), 8 deletions(-) create mode 100644 src/qca/forward/types/budget_limit.py create mode 100644 src/qca/forward/types/budget_limit_param.py create mode 100644 src/qca/forward/types/tool_approval_evaluation.py create mode 100644 tests/test_documentation_alignment.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 93dab10..551581a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ existing `0.1.0` release; earlier development prereleases are not listed. ## [Unreleased] +### Added + +- Add typed Forward Session budgets across create/update responses and events, including omission, explicit null clearing and decimal-string limits. +- Add Forward Session `config.model` overrides and model `speed` configuration, plus optional approval evaluation details, Outcome/status event fields and cumulative top-level Credits on `session.usage`. +- Add `startup_script` to Forward/Managed Environment models and Managed cloud/self-hosted parameters. It runs on sandbox start/resume; the external worker controls self-hosted execution. +- Add Managed Memory `version`, version `redacted`, and Vault creation-response `credentials` fields. Vault creation still does not accept credentials in its request. + +### Fixed + +- Declare Managed Memory list-item metadata/version fields so strict response parsing provides typed access instead of unknown attributes. + +### Migration + +- Forward `sessions.create()` and `sessions.update()` accept `budget=` directly. Omit it to keep the default/current limit, or pass `None` to clear it. +- Omit `config.model` on create to inherit; `{"model": None}` restores inheritance on update. Optional approval details are under `event.evaluation.evaluated_permission`; the top-level approval result remains a string, with legacy object parsing retained for compatibility. +- Managed Thread `usage` is deprecated because current responses omit it; read Session Credits instead. Treat `session.usage` as cumulative snapshots rather than adding successive events. + ### Changed - Remove resource-specific sections and inventories from the README to keep it focused on SDK setup and shared client behavior. diff --git a/docs/api/reference.md b/docs/api/reference.md index 7d19e87..61ea7a8 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -8500,6 +8500,7 @@ def create( *, identity_id: str, template_id: str, + budget: Union[BudgetLimitParam, None, NotGiven] = NOT_GIVEN, title: Union[str, None, NotGiven] = NOT_GIVEN, metadata: Union[Dict[str, Any], None, NotGiven] = NOT_GIVEN, config: Union[SessionCreateParamsConfigParam, None, @@ -8545,6 +8546,7 @@ GET /sessions/{session_id}. def update( session_id: str, *, + budget: Union[BudgetLimitParam, None, NotGiven] = NOT_GIVEN, title: Union[str, None, NotGiven] = NOT_GIVEN, metadata: Union[Dict[str, Any], None, NotGiven] = NOT_GIVEN, config: Union[SessionUpdateParamsConfigParam, None, @@ -8688,6 +8690,7 @@ async def create( *, identity_id: str, template_id: str, + budget: Union[BudgetLimitParam, None, NotGiven] = NOT_GIVEN, title: Union[str, None, NotGiven] = NOT_GIVEN, metadata: Union[Dict[str, Any], None, NotGiven] = NOT_GIVEN, config: Union[SessionCreateParamsConfigParam, None, @@ -8733,6 +8736,7 @@ GET /sessions/{session_id}. async def update( session_id: str, *, + budget: Union[BudgetLimitParam, None, NotGiven] = NOT_GIVEN, title: Union[str, None, NotGiven] = NOT_GIVEN, metadata: Union[Dict[str, Any], None, NotGiven] = NOT_GIVEN, config: Union[SessionUpdateParamsConfigParam, None, @@ -11575,6 +11579,50 @@ class BatchUsage(BaseModel) #### total\_credits + + +# qca.forward.types.budget\_limit + + + +## BudgetLimit + +```python +class BudgetLimit(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/budget_limit.py) + + + +#### max\_credit\_cost + + + +#### type + + + +# qca.forward.types.budget\_limit\_param + + + +## BudgetLimitParam + +```python +class BudgetLimitParam(TypedDict) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/budget_limit_param.py) + + + +#### max\_credit\_cost + + + +#### type + # qca.forward.types.channel @@ -13496,6 +13544,10 @@ class EnvironmentConfig(BaseModel) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/environment_config.py) + + +#### startup\_script + #### setup\_script @@ -15444,6 +15496,10 @@ class ModelConfig(BaseModel) #### effort + + +#### speed + #### context\_window @@ -15470,6 +15526,10 @@ class ModelConfigParam(TypedDict) #### effort + + +#### speed + #### context\_window @@ -17360,6 +17420,10 @@ class Session(BaseModel) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/session.py) + + +#### budget + #### outcome\_evaluations @@ -17474,6 +17538,10 @@ class SessionConfig(BaseModel) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/session_config.py) + + +#### model + #### environment\_variables @@ -17492,6 +17560,10 @@ class SessionCreateParams(TypedDict) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/session_create_params.py) + + +#### budget + #### identity\_id @@ -17534,6 +17606,10 @@ class SessionCreateParamsConfigParam(TypedDict) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/session_create_params_config_param.py) + + +#### model + #### environment\_variables @@ -17552,6 +17628,42 @@ class SessionEvent(BaseModel) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/session_event.py) + + +#### evaluation + + + +#### budget + + + +#### reason + + + +#### iteration + + + +#### explanation + + + +#### outcome\_evaluation\_start\_id + + + +#### model\_credits + + + +#### sandbox\_runtime\_credits + + + +#### total\_credits + #### id @@ -18398,6 +18510,10 @@ class SessionUpdateParams(TypedDict) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/session_update_params.py) + + +#### budget + #### title @@ -18428,6 +18544,10 @@ class SessionUpdateParamsConfigParam(TypedDict) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/session_update_params_config_param.py) + + +#### model + #### environment\_variables @@ -19420,6 +19540,50 @@ class Tool(BaseModel) #### input\_schema + + +# qca.forward.types.tool\_approval\_evaluation + + + +## ToolApprovalDecision + +```python +class ToolApprovalDecision(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/tool_approval_evaluation.py) + +Automatic decision; reason_code may be indeterminate or high_risk. + + + +#### type + + + +#### reason\_code + + + +## ToolApprovalEvaluation + +```python +class ToolApprovalEvaluation(BaseModel) +``` + +[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/tool_approval_evaluation.py) + +Policy source (always_allow, always_ask, auto) and optional automatic decision. + + + +#### type + + + +#### evaluated\_permission + # qca.forward.types.tool\_config @@ -28217,6 +28381,10 @@ class CloudConfigParams(TypedDict) #### storage + + +#### startup\_script + #### setup\_script @@ -30301,6 +30469,10 @@ class EnvironmentConfigUnion(BaseModel) #### storage + + +#### startup\_script + #### setup\_script @@ -31939,6 +32111,10 @@ class Memory(BaseModel) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/types/memory.py) + + +#### version + #### metadata @@ -31997,6 +32173,14 @@ class MemoryListItemUnion(BaseModel) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/types/memory_list_item_union.py) + + +#### metadata + + + +#### version + #### id @@ -32665,6 +32849,10 @@ class MemoryVersion(BaseModel) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/types/memory_version.py) + + +#### redacted + #### id @@ -33707,6 +33895,10 @@ class SelfHostedConfigParams(TypedDict) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/types/self_hosted_config_params.py) + + +#### startup\_script + #### setup\_script @@ -36195,6 +36387,8 @@ class SessionThreadUsage(BaseModel) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/types/session_thread_usage.py) +Deprecated legacy Token/list-cost usage; current Thread responses omit it. + #### active\_seconds @@ -37967,6 +38161,10 @@ class Vault(BaseModel) [[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/managed/types/vault.py) + + +#### credentials + #### id diff --git a/src/qca/forward/resources/sessions/sessions.py b/src/qca/forward/resources/sessions/sessions.py index 0014e46..6d1e84a 100644 --- a/src/qca/forward/resources/sessions/sessions.py +++ b/src/qca/forward/resources/sessions/sessions.py @@ -14,6 +14,7 @@ from qca.forward.resources.sessions.events import AsyncEvents, Events from qca.forward.resources.sessions.resources import AsyncResources, Resources from qca.forward.resources.sessions.threads.threads import AsyncThreads, Threads +from qca.forward.types.budget_limit_param import BudgetLimitParam from qca.forward.types.session import Session from qca.forward.types.session_create_params_config_param import SessionCreateParamsConfigParam from qca.forward.types.session_resource_spec_param import SessionResourceSpecParam @@ -96,6 +97,7 @@ def create( *, identity_id: str, template_id: str, + budget: Union[BudgetLimitParam, None, NotGiven] = NOT_GIVEN, title: Union[str, None, NotGiven] = NOT_GIVEN, metadata: Union[Dict[str, Any], None, NotGiven] = NOT_GIVEN, config: Union[SessionCreateParamsConfigParam, None, NotGiven] = NOT_GIVEN, @@ -110,6 +112,7 @@ def create( _path = path_template("/sessions") options = make_request_options( body={ + "budget": budget, "identity_id": identity_id, "template_id": template_id, "title": title, @@ -153,6 +156,7 @@ def update( self, session_id: str, *, + budget: Union[BudgetLimitParam, None, NotGiven] = NOT_GIVEN, title: Union[str, None, NotGiven] = NOT_GIVEN, metadata: Union[Dict[str, Any], None, NotGiven] = NOT_GIVEN, config: Union[SessionUpdateParamsConfigParam, None, NotGiven] = NOT_GIVEN, @@ -165,7 +169,7 @@ def update( """POST /sessions/{session_id}.""" _path = path_template("/sessions/{session_id}", session_id=session_id) options = make_request_options( - body={"title": title, "metadata": metadata, "config": config}, + body={"budget": budget, "title": title, "metadata": metadata, "config": config}, query={}, headers={"Idempotency-Key": idempotency_key}, extra_headers=extra_headers, @@ -298,6 +302,7 @@ async def create( *, identity_id: str, template_id: str, + budget: Union[BudgetLimitParam, None, NotGiven] = NOT_GIVEN, title: Union[str, None, NotGiven] = NOT_GIVEN, metadata: Union[Dict[str, Any], None, NotGiven] = NOT_GIVEN, config: Union[SessionCreateParamsConfigParam, None, NotGiven] = NOT_GIVEN, @@ -312,6 +317,7 @@ async def create( _path = path_template("/sessions") options = make_request_options( body={ + "budget": budget, "identity_id": identity_id, "template_id": template_id, "title": title, @@ -355,6 +361,7 @@ async def update( self, session_id: str, *, + budget: Union[BudgetLimitParam, None, NotGiven] = NOT_GIVEN, title: Union[str, None, NotGiven] = NOT_GIVEN, metadata: Union[Dict[str, Any], None, NotGiven] = NOT_GIVEN, config: Union[SessionUpdateParamsConfigParam, None, NotGiven] = NOT_GIVEN, @@ -367,7 +374,7 @@ async def update( """POST /sessions/{session_id}.""" _path = path_template("/sessions/{session_id}", session_id=session_id) options = make_request_options( - body={"title": title, "metadata": metadata, "config": config}, + body={"budget": budget, "title": title, "metadata": metadata, "config": config}, query={}, headers={"Idempotency-Key": idempotency_key}, extra_headers=extra_headers, diff --git a/src/qca/forward/types/__init__.py b/src/qca/forward/types/__init__.py index 67e82bb..318d8d6 100644 --- a/src/qca/forward/types/__init__.py +++ b/src/qca/forward/types/__init__.py @@ -16,6 +16,8 @@ from .batch_task_list_params import BatchTaskListParams as BatchTaskListParams from .batch_task_usage import BatchTaskUsage as BatchTaskUsage from .batch_usage import BatchUsage as BatchUsage +from .budget_limit import BudgetLimit as BudgetLimit +from .budget_limit_param import BudgetLimitParam as BudgetLimitParam from .channel import Channel as Channel from .channel_channel_config import ChannelChannelConfig as ChannelChannelConfig from .channel_channel_config_response_options import ( @@ -271,6 +273,8 @@ from .template_update_params import TemplateUpdateParams as TemplateUpdateParams from .template_usage import TemplateUsage as TemplateUsage from .tool import Tool as Tool +from .tool_approval_evaluation import ToolApprovalDecision as ToolApprovalDecision +from .tool_approval_evaluation import ToolApprovalEvaluation as ToolApprovalEvaluation from .tool_config import ToolConfig as ToolConfig from .tool_config_param import ToolConfigParam as ToolConfigParam from .tool_override import ToolOverride as ToolOverride diff --git a/src/qca/forward/types/budget_limit.py b/src/qca/forward/types/budget_limit.py new file mode 100644 index 0000000..b8c257e --- /dev/null +++ b/src/qca/forward/types/budget_limit.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Optional + +from qca.common._models import BaseModel + +__all__ = ["BudgetLimit"] + + +class BudgetLimit(BaseModel): + # Positive decimal Credits, at most 12 integer and 2 fractional digits. + max_credit_cost: Optional[str] = None + type: Optional[str] = None diff --git a/src/qca/forward/types/budget_limit_param.py b/src/qca/forward/types/budget_limit_param.py new file mode 100644 index 0000000..8c1b439 --- /dev/null +++ b/src/qca/forward/types/budget_limit_param.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Literal + +from typing_extensions import Required, TypedDict + +__all__ = ["BudgetLimitParam"] + + +class BudgetLimitParam(TypedDict, total=False): + # Positive decimal Credits, at most 12 integer and 2 fractional digits. + max_credit_cost: Required[str] + type: Required[Literal["limit"]] diff --git a/src/qca/forward/types/environment_config.py b/src/qca/forward/types/environment_config.py index be9fd92..62ab16d 100644 --- a/src/qca/forward/types/environment_config.py +++ b/src/qca/forward/types/environment_config.py @@ -11,6 +11,8 @@ class EnvironmentConfig(BaseModel): + # Runs on start/resume; self-hosted execution depends on the worker. + startup_script: Optional[str] = None setup_script: Optional[str] = None type: Optional[str] = None packages: Optional[EnvironmentConfigPackages] = None diff --git a/src/qca/forward/types/model_config.py b/src/qca/forward/types/model_config.py index 23db2f0..841dff6 100644 --- a/src/qca/forward/types/model_config.py +++ b/src/qca/forward/types/model_config.py @@ -10,4 +10,5 @@ class ModelConfig(BaseModel): id: Optional[str] = None effort: Optional[str] = None + speed: Optional[str] = None context_window: Optional[int] = None diff --git a/src/qca/forward/types/model_config_param.py b/src/qca/forward/types/model_config_param.py index a1d412a..ed720b8 100644 --- a/src/qca/forward/types/model_config_param.py +++ b/src/qca/forward/types/model_config_param.py @@ -10,4 +10,5 @@ class ModelConfigParam(TypedDict, total=False): id: Required[str] effort: Optional[str] + speed: Optional[str] context_window: Optional[int] diff --git a/src/qca/forward/types/session.py b/src/qca/forward/types/session.py index f59f636..f70e403 100644 --- a/src/qca/forward/types/session.py +++ b/src/qca/forward/types/session.py @@ -6,6 +6,7 @@ from qca.common._models import BaseModel if TYPE_CHECKING: + from .budget_limit import BudgetLimit from .session_config import SessionConfig from .session_outcome_evaluation import SessionOutcomeEvaluation from .session_resource import SessionResource @@ -17,6 +18,7 @@ class Session(BaseModel): + budget: Optional[BudgetLimit] = None outcome_evaluations: Optional[List[SessionOutcomeEvaluation]] = None id: Optional[str] = None type: Optional[str] = None diff --git a/src/qca/forward/types/session_config.py b/src/qca/forward/types/session_config.py index 76588f1..322c9e9 100644 --- a/src/qca/forward/types/session_config.py +++ b/src/qca/forward/types/session_config.py @@ -1,11 +1,14 @@ from __future__ import annotations -from typing import Dict, Optional +from typing import Dict, Optional, Union from qca.common._models import BaseModel +from .model_config import ModelConfig + __all__ = ["SessionConfig"] class SessionConfig(BaseModel): + model: Optional[Union[str, ModelConfig]] = None environment_variables: Optional[Dict[str, str]] = None diff --git a/src/qca/forward/types/session_create_params.py b/src/qca/forward/types/session_create_params.py index bd326bb..0dcf7fa 100644 --- a/src/qca/forward/types/session_create_params.py +++ b/src/qca/forward/types/session_create_params.py @@ -5,6 +5,7 @@ from typing_extensions import Required, TypedDict if TYPE_CHECKING: + from .budget_limit_param import BudgetLimitParam from .session_create_params_config_param import SessionCreateParamsConfigParam from .session_resource_spec_param import SessionResourceSpecParam @@ -12,6 +13,8 @@ class SessionCreateParams(TypedDict, total=False): + # Omit to keep the default/current budget; None clears it. + budget: Optional[BudgetLimitParam] identity_id: Required[str] template_id: Required[str] title: Optional[str] diff --git a/src/qca/forward/types/session_create_params_config_param.py b/src/qca/forward/types/session_create_params_config_param.py index da67012..630bc3c 100644 --- a/src/qca/forward/types/session_create_params_config_param.py +++ b/src/qca/forward/types/session_create_params_config_param.py @@ -1,11 +1,15 @@ from __future__ import annotations -from typing import Any, Dict +from typing import Any, Dict, Union from typing_extensions import TypedDict +from .model_config_param import ModelConfigParam + __all__ = ["SessionCreateParamsConfigParam"] class SessionCreateParamsConfigParam(TypedDict, total=False): + # Omit on create to inherit; None on update restores inheritance. + model: Union[str, ModelConfigParam] environment_variables: Dict[str, Any] diff --git a/src/qca/forward/types/session_event.py b/src/qca/forward/types/session_event.py index 512d8cb..f16f0ac 100644 --- a/src/qca/forward/types/session_event.py +++ b/src/qca/forward/types/session_event.py @@ -5,12 +5,24 @@ from qca.common._models import BaseModel +from .budget_limit import BudgetLimit from .outcome_rubric import OutcomeRubric +from .tool_approval_evaluation import ToolApprovalEvaluation __all__ = ["SessionEvent"] class SessionEvent(BaseModel): + evaluation: Optional[ToolApprovalEvaluation] = None + budget: Optional[BudgetLimit] = None + reason: Optional[str] = None + iteration: Optional[int] = None + explanation: Optional[str] = None + outcome_evaluation_start_id: Optional[str] = None + # Cumulative Credits: replace with the latest values, never sum successive events. + model_credits: Optional[float] = None + sandbox_runtime_credits: Optional[float] = None + total_credits: Optional[float] = None id: Optional[str] = None type: Optional[str] = None session_id: Optional[str] = None diff --git a/src/qca/forward/types/session_update_params.py b/src/qca/forward/types/session_update_params.py index b562acd..c0743b9 100644 --- a/src/qca/forward/types/session_update_params.py +++ b/src/qca/forward/types/session_update_params.py @@ -5,12 +5,15 @@ from typing_extensions import TypedDict if TYPE_CHECKING: + from .budget_limit_param import BudgetLimitParam from .session_update_params_config_param import SessionUpdateParamsConfigParam __all__ = ["SessionUpdateParams"] class SessionUpdateParams(TypedDict, total=False): + # Omit to keep the default/current budget; None clears it. + budget: Optional[BudgetLimitParam] title: Optional[str] metadata: Dict[str, Any] config: SessionUpdateParamsConfigParam diff --git a/src/qca/forward/types/session_update_params_config_param.py b/src/qca/forward/types/session_update_params_config_param.py index 5f28183..eaabd6b 100644 --- a/src/qca/forward/types/session_update_params_config_param.py +++ b/src/qca/forward/types/session_update_params_config_param.py @@ -1,11 +1,15 @@ from __future__ import annotations -from typing import Any, Dict +from typing import Any, Dict, Optional, Union from typing_extensions import TypedDict +from .model_config_param import ModelConfigParam + __all__ = ["SessionUpdateParamsConfigParam"] class SessionUpdateParamsConfigParam(TypedDict, total=False): + # Omit on create to inherit; None on update restores inheritance. + model: Optional[Union[str, ModelConfigParam]] environment_variables: Dict[str, Any] diff --git a/src/qca/forward/types/tool_approval_evaluation.py b/src/qca/forward/types/tool_approval_evaluation.py new file mode 100644 index 0000000..1591508 --- /dev/null +++ b/src/qca/forward/types/tool_approval_evaluation.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Optional + +from qca.common._models import BaseModel + +__all__ = ["ToolApprovalDecision", "ToolApprovalEvaluation"] + + +class ToolApprovalDecision(BaseModel): + """Automatic decision; reason_code may be indeterminate or high_risk.""" + + type: Optional[str] = None + reason_code: Optional[str] = None + + +class ToolApprovalEvaluation(BaseModel): + """Policy source (always_allow, always_ask, auto) and optional automatic decision.""" + + type: Optional[str] = None + evaluated_permission: Optional[ToolApprovalDecision] = None diff --git a/src/qca/managed/types/cloud_config_params.py b/src/qca/managed/types/cloud_config_params.py index 4cac283..c953fce 100644 --- a/src/qca/managed/types/cloud_config_params.py +++ b/src/qca/managed/types/cloud_config_params.py @@ -15,6 +15,8 @@ class CloudConfigParams(TypedDict, total=False): storage: BucketStorageParam + # Runs on start/resume; self-hosted execution depends on the worker. + startup_script: Optional[str] setup_script: Optional[str] networking: Union[UnrestrictedNetworkParam, LimitedNetworkParams] packages: PackagesParams diff --git a/src/qca/managed/types/environment_config_union.py b/src/qca/managed/types/environment_config_union.py index 0df728f..f5a7bdc 100644 --- a/src/qca/managed/types/environment_config_union.py +++ b/src/qca/managed/types/environment_config_union.py @@ -14,6 +14,8 @@ class EnvironmentConfigUnion(BaseModel): storage: Optional[BucketStorage] = None + # Runs on start/resume; self-hosted execution depends on the worker. + startup_script: Optional[str] = None setup_script: Optional[str] = None networking: Optional[CloudConfigNetworkingUnion] = None packages: Optional[Packages] = None diff --git a/src/qca/managed/types/memory.py b/src/qca/managed/types/memory.py index eb5e2b9..f9cb06b 100644 --- a/src/qca/managed/types/memory.py +++ b/src/qca/managed/types/memory.py @@ -9,6 +9,7 @@ class Memory(BaseModel): + version: Optional[int] = None metadata: Optional[Dict[str, str]] = None id: Optional[str] = None content_sha256: Optional[str] = None diff --git a/src/qca/managed/types/memory_list_item_union.py b/src/qca/managed/types/memory_list_item_union.py index bcae6eb..73f5c5f 100644 --- a/src/qca/managed/types/memory_list_item_union.py +++ b/src/qca/managed/types/memory_list_item_union.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime -from typing import Optional +from typing import Dict, Optional from qca.common._models import BaseModel @@ -9,6 +9,8 @@ class MemoryListItemUnion(BaseModel): + metadata: Optional[Dict[str, str]] = None + version: Optional[int] = None id: Optional[str] = None content_sha256: Optional[str] = None content_size_bytes: Optional[int] = None diff --git a/src/qca/managed/types/memory_version.py b/src/qca/managed/types/memory_version.py index a3d901e..b22dba1 100644 --- a/src/qca/managed/types/memory_version.py +++ b/src/qca/managed/types/memory_version.py @@ -12,6 +12,7 @@ class MemoryVersion(BaseModel): + redacted: Optional[bool] = None id: Optional[str] = None created_at: Optional[datetime] = None memory_id: Optional[str] = None diff --git a/src/qca/managed/types/self_hosted_config_params.py b/src/qca/managed/types/self_hosted_config_params.py index f07f86d..1cd8127 100644 --- a/src/qca/managed/types/self_hosted_config_params.py +++ b/src/qca/managed/types/self_hosted_config_params.py @@ -8,5 +8,7 @@ class SelfHostedConfigParams(TypedDict, total=False): + # Runs on start/resume; self-hosted execution depends on the worker. + startup_script: str setup_script: str type: Literal["self_hosted"] diff --git a/src/qca/managed/types/session_thread.py b/src/qca/managed/types/session_thread.py index cac2b3e..f22955f 100644 --- a/src/qca/managed/types/session_thread.py +++ b/src/qca/managed/types/session_thread.py @@ -3,6 +3,8 @@ from datetime import datetime from typing import TYPE_CHECKING, Optional +from pydantic import Field + from qca.common._models import BaseModel if TYPE_CHECKING: @@ -24,4 +26,6 @@ class SessionThread(BaseModel): status: Optional[str] = None type: Optional[str] = None updated_at: Optional[datetime] = None - usage: Optional[SessionThreadUsage] = None + usage: Optional[SessionThreadUsage] = Field( + default=None, deprecated="Current Thread responses omit usage; read Session Credits instead." + ) diff --git a/src/qca/managed/types/session_thread_usage.py b/src/qca/managed/types/session_thread_usage.py index bd77980..b53692d 100644 --- a/src/qca/managed/types/session_thread_usage.py +++ b/src/qca/managed/types/session_thread_usage.py @@ -13,6 +13,8 @@ class SessionThreadUsage(BaseModel): + """Deprecated legacy Token/list-cost usage; current Thread responses omit it.""" + active_seconds: Optional[float] = None cache_creation: Optional[CacheCreationUsage] = None cache_read_input_tokens: Optional[int] = None diff --git a/src/qca/managed/types/vault.py b/src/qca/managed/types/vault.py index c59abdf..387cd5c 100644 --- a/src/qca/managed/types/vault.py +++ b/src/qca/managed/types/vault.py @@ -1,14 +1,19 @@ from __future__ import annotations from datetime import datetime -from typing import Dict, Optional +from typing import TYPE_CHECKING, Dict, List, Optional from qca.common._models import BaseModel +if TYPE_CHECKING: + from .credential import Credential + __all__ = ["Vault"] class Vault(BaseModel): + # Empty on creation. Use the separate Credential API to add credentials. + credentials: Optional[List[Credential]] = None id: Optional[str] = None archived_at: Optional[datetime] = None created_at: Optional[datetime] = None diff --git a/tests/test_documentation_alignment.py b/tests/test_documentation_alignment.py new file mode 100644 index 0000000..b1a0021 --- /dev/null +++ b/tests/test_documentation_alignment.py @@ -0,0 +1,194 @@ +"""Official Forward Session and Managed Memory/Environment/Vault schemas, 2026-11-03.""" + +import inspect +import json + +import httpx +import pytest + +from qca import AsyncForward, Forward +from qca.forward.types import SessionEvent +from qca.managed.types import Memory, MemoryListItemUnion, MemoryVersion, SessionThread, Vault + + +async def resolve(value): + return await value if inspect.isawaitable(value) else value + + +@pytest.mark.parametrize("async_", [False, True]) +@pytest.mark.parametrize( + "body", + [ + {}, + {"budget": None, "config": {"model": None}}, + {"budget": {"type": "limit", "max_credit_cost": "100.00"}, "config": {"model": "ultimate"}}, + {"config": {"model": {"id": "ultimate", "speed": "high", "effort": "none", "context_window": 200000}}}, + ], +) +async def test_forward_update_budget_and_model_three_states(async_, body): + def handle(request): + assert json.loads(request.content) == body + return httpx.Response(200, json={"id": "sess_test", **body}) + + http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle)) + client = (AsyncForward if async_ else Forward)(pat="test", http_client=http, _strict_response_validation=True) + try: + session = await resolve(client.sessions.update("sess_test", **body)) + assert ("budget" in session.model_fields_set) == ("budget" in body) + if body.get("budget"): + assert session.budget.max_credit_cost == "100.00" + if body.get("config", {}).get("model") is None: + if session.config: + assert session.config.model is None + elif isinstance(body["config"]["model"], str): + assert session.config.model == "ultimate" + else: + assert session.config.model.speed == "high" + assert "speed" in type(session.config.model).model_fields + finally: + await resolve(client.close()) + + +@pytest.mark.parametrize("async_", [False, True]) +@pytest.mark.parametrize("budget", [{"type": "limit", "max_credit_cost": "100.00"}, None]) +async def test_forward_create_keeps_budget_and_metadata(async_, budget): + metadata = {"business": "kept"} + config = {"model": {"id": "ultimate", "speed": "high"}} + + def handle(request): + body = json.loads(request.content) + assert body["budget"] == budget + assert body["config"] == config + assert body["metadata"]["business"] == "kept" + assert body["metadata"]["__qca_sdk_created"] == "true" + return httpx.Response(200, json=body) + + http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle)) + client = (AsyncForward if async_ else Forward)(pat="test", http_client=http, _strict_response_validation=True) + try: + await resolve( + client.sessions.create( + identity_id="idn", template_id="tmpl", budget=budget, config=config, metadata=metadata + ) + ) + assert metadata == {"business": "kept"} + finally: + await resolve(client.close()) + + +@pytest.mark.parametrize( + "result,policy,reason", + [ + ("allow", "always_allow", None), + ("ask", "always_ask", None), + ("allow", "auto", None), + ("ask", "auto", "indeterminate"), + ("deny", "auto", "high_risk"), + ], +) +def test_approval_root_and_nested_details(result, policy, reason): + evaluation = {"type": policy} + if policy == "auto": + evaluation["evaluated_permission"] = {"type": result} + if reason: + evaluation["evaluated_permission"]["reason_code"] = reason + event = SessionEvent.model_validate({"evaluated_permission": result, "evaluation": evaluation, "future": 17}) + assert event.evaluated_permission == result + assert event.evaluation.type == policy + if policy == "auto": + assert event.evaluation.evaluated_permission.type == result + assert event.evaluation.evaluated_permission.reason_code == reason + else: + assert event.evaluation.evaluated_permission is None + assert event.model_extra == {"future": 17} + + +@pytest.mark.parametrize("async_", [False, True]) +async def test_forward_history_and_both_streams_use_typed_event_fields(async_): + events = [ + {"id": "zero", "type": "session.usage", "model_credits": 0, "sandbox_runtime_credits": 0, "total_credits": 0}, + { + "id": "usage", + "type": "session.usage", + "model_credits": 0.68, + "sandbox_runtime_credits": 0.04, + "total_credits": 0.72, + }, + {"id": "idle", "type": "session.status_idle", "reason": "outcome_evaluation"}, + { + "id": "end", + "type": "span.outcome_evaluation_end", + "iteration": 2, + "explanation": "done", + "outcome_evaluation_start_id": "start", + }, + {"id": "clear", "type": "session.updated", "budget": None}, + {"id": "set", "type": "session.updated", "budget": {"type": "limit", "max_credit_cost": "0.01"}}, + ] + + def handle(request): + if request.url.path.endswith("/stream"): + return httpx.Response( + 200, + text="".join(f"id: {e['id']}\ndata: {json.dumps(e)}\n\n" for e in events), + headers={"Content-Type": "text/event-stream"}, + ) + return httpx.Response(200, json={"data": events, "has_more": False}) + + def check(items): + assert items[0].total_credits == 0 + assert items[1].total_credits == 0.72 + assert items[1].model_credits == 0.68 + assert items[1].sandbox_runtime_credits == 0.04 + assert items[2].reason == "outcome_evaluation" + assert items[3].iteration == 2 and items[3].explanation == "done" + assert items[3].outcome_evaluation_start_id == "start" + assert items[4].budget is None and "budget" in items[4].model_fields_set + assert "budget" not in items[0].model_fields_set + assert items[5].budget.max_credit_cost == "0.01" + for item in items: + assert not item.model_extra + + http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle)) + client = (AsyncForward if async_ else Forward)(pat="test", http_client=http, _strict_response_validation=True) + try: + check((await resolve(client.sessions.events.list("s"))).data) + for stream in [ + await resolve(client.sessions.events.stream("s")), + await resolve(client.sessions.threads.events.stream("t", session_id="s")), + ]: + try: + check([e async for e in stream] if async_ else list(stream)) + finally: + await resolve(stream.close()) + finally: + await resolve(client.close()) + + +def test_managed_memory_vault_and_legacy_usage(): + payload = {"version": 2, "metadata": {"source": "test"}} + for model in [Memory, MemoryListItemUnion]: + item = model.model_validate(payload) + assert item.version == 2 and item.metadata == {"source": "test"} + assert not item.model_extra + for redacted in [False, True]: + version = MemoryVersion.model_validate({"operation": "updated", "redacted": redacted}) + assert version.operation == "updated" and version.redacted is redacted + assert not version.model_extra + assert Vault.model_validate({"credentials": []}).credentials == [] + assert "credentials" in Vault.model_fields + assert SessionThread.model_fields["usage"].deprecated + assert "usage" not in SessionThread.model_validate({"id": "t"}).model_fields_set + + +def test_environment_startup_script_is_typed_and_preserved(): + from qca.forward.types import EnvironmentConfig + from qca.managed.types import CloudConfigParams, EnvironmentConfigUnion, SelfHostedConfigParams + + script = "#!/bin/sh\necho ready\n" + for model in [EnvironmentConfig, EnvironmentConfigUnion]: + config = model.model_validate({"type": "cloud", "startup_script": script}) + assert config.startup_script == script + assert not config.model_extra + assert "startup_script" in CloudConfigParams.__annotations__ + assert "startup_script" in SelfHostedConfigParams.__annotations__ From e8ebd0f4de32e17241cc1d426c1615fcce330816 Mon Sep 17 00:00:00 2001 From: moonyue-w <300878504+moonyue-w@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:51:32 +0800 Subject: [PATCH 2/2] docs(environments): explain startup script constraints and failures --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 97e3b4e..83886ae 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,22 @@ asyncio.run(main()) Every method shown in this document has an async counterpart with the same name and signature. Async streams are opened with `async with await client.sessions.events.stream(...)`. +## Environment startup scripts + +Both Forward and Managed accept `config.startup_script`. For cloud environments, +it runs after `setup_script` on the first sandbox start and again on every resume, +before the first tool call. The server enforces a 64 KB limit and a 5-minute +timeout. Scripts must exit; start long-lived services in the background and wait +for readiness when needed. + +Failure emits a `session.error` event whose `error.type` is +`environment_startup_failed_error`; the session and sandbox remain usable. +The script is not retried during the same start. For self-hosted environments, +the API stores and returns the script; execution depends on the external worker. +See the official [Forward](https://docs.qoder.com/cloud-agents/api/forward/environments/schemas#environment-startup-script) +and [Managed](https://docs.qoder.com/cloud-agents/api/environments/schemas#environment-startup-script) +contracts. + ## Streaming Streaming responses are consumed incrementally. Close the stream when finished and handle interruption explicitly; closing a local stream does not cancel server-side work.