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/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.
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__