Coverage for stackone_ai / types.py: 100%
52 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-10-09 08:03 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-10-09 08:03 +0000
1"""Shared types, configuration and errors for the StackOne AI SDK."""
3from __future__ import annotations
5from collections.abc import Sequence
6from typing import Annotated, Any, Literal, TypeAlias, TypedDict
8from pydantic import (
9 BaseModel,
10 ConfigDict,
11 Field,
12 PlainSerializer,
13 SerializerFunctionWrapHandler,
14 SkipValidation,
15 model_serializer,
16)
18JsonDict: TypeAlias = dict[str, Any]
19Headers: TypeAlias = dict[str, str]
21# StackOne API base URL
22DEFAULT_BASE_URL: str = "https://api.stackone.com"
25ToolMode = Literal["individual", "search_execute"]
26"""How the MCP endpoint lists tools.
28``"individual"`` (the server default) lists one tool per action — hundreds per
29account. ``"search_execute"`` lists two meta tools per connector instead, a
30``*_search_actions`` that ranks actions for a natural-language query and an
31``*_execute_action`` that runs one by id. The catalog stays small regardless of
32how many accounts are linked, which is what keeps it inside a model's context.
33"""
35SUBMIT_FEEDBACK_TOOL_NAME: str = "stackone_submit_feedback"
36"""The one global tool the MCP endpoint serves in every mode when feedback is enabled.
38It is not a connector action, and it is global rather than account-scoped, so the toolset
39keeps one copy of it however many accounts list it.
40"""
42FeedbackRating = Literal["positive", "negative", "neutral"]
43FeedbackSource = Literal["model", "user", "system"]
44FeedbackCategory = Literal["search", "execute", "defender", "connection", "general"]
47class StackOneError(Exception):
48 """Base exception for StackOne errors"""
50 pass
53class StackOneAPIError(StackOneError):
54 """Raised when the StackOne API returns an error"""
56 def __init__(self, message: str, status_code: int, response_body: Any) -> None:
57 super().__init__(message)
58 self.status_code = status_code
59 self.response_body = response_body
62class ToolArgumentsError(StackOneError, ValueError):
63 """Raised when a tool's arguments are unusable, before any request is made:
65 - a JSON string argument that does not parse, or parses to something other than
66 an object
67 - an argument that cannot be encoded as JSON (a set, bytes, NaN/Infinity, a lone
68 surrogate, or a value that refers to itself, directly or through a cycle)
69 - ``call()`` given both positional and keyword arguments, or more than one
70 positional argument
72 A subclass of both StackOneError, so ``except StackOneError`` catches it, and
73 ValueError, which is what these errors were before, so existing
74 ``except ValueError`` clauses still do.
75 """
77 pass
80class ToolsetError(StackOneError):
81 """Base exception for toolset errors.
83 A subclass of StackOneError, so ``except StackOneError`` catches everything this
84 SDK raises. The two used to be unrelated siblings, which meant the obvious
85 catch-all silently missed ToolsetConfigError and ToolsetLoadError — the errors a
86 user is most likely to hit on their very first call. Existing
87 ``except ToolsetError`` clauses are unaffected.
88 """
90 pass
93class ToolsetConfigError(ToolsetError):
94 """Raised when there is an error in the toolset configuration"""
96 pass
99class ToolsetLoadError(ToolsetError):
100 """Raised when there is an error loading tools.
102 ``failures`` holds each account's error when every account failed to list tools for
103 differing reasons, in account order; otherwise it is empty.
104 """
106 def __init__(self, message: str, *, failures: Sequence[Exception] = ()) -> None:
107 super().__init__(message)
108 self.failures: list[Exception] = list(failures)
111class ExecuteToolsConfig(TypedDict, total=False):
112 """Execution configuration for the StackOneToolSet constructor.
114 Controls default account scoping and timeout for tool execution.
115 """
117 account_ids: list[str]
118 """Account IDs to scope tool discovery and execution."""
120 timeout: float
121 """Request timeout in seconds. Default: 60. Can also be set as a top-level
122 constructor param which takes precedence."""
125class ExecuteConfig(BaseModel):
126 """How a tool is called: its name, any extra request headers, and the timeout.
128 Every tool executes over MCP ``tools/call``, so there is no HTTP method, URL, body
129 type or parameter location to configure. Extra fields are refused rather than ignored,
130 so code still passing them finds out here instead of assuming they took effect.
131 """
133 model_config = ConfigDict(extra="forbid")
135 name: str = Field(description="Tool name")
136 headers: Headers = Field(
137 default_factory=dict,
138 description="Extra HTTP headers for the MCP request. Authorization, x-account-id and "
139 "User-Agent are always the SDK's own and cannot be replaced here.",
140 )
141 timeout: float = Field(default=60.0, description="Request timeout in seconds")
144class ToolParameters(BaseModel):
145 """Schema definition for tool parameters.
147 ``properties`` is a faithful mirror of the ``inputSchema`` the MCP server served,
148 with the SDK's internal ``nullable`` marker added per property. Every other root
149 keyword the server sent, ``required`` included, is kept verbatim as an extra field.
150 Consumers that need the raw served schema (for example the ADK plugin) read this
151 directly, so nothing here may be invented or dropped.
152 """
154 model_config = ConfigDict(extra="allow")
156 type: str = Field(description="JSON Schema type")
157 properties: JsonDict = Field(description="JSON Schema properties")
158 # Typed for callers building a tool by hand, but neither validated nor converted: a
159 # served `required` is kept exactly as sent, even when malformed, and the adapters
160 # drop what is not a list of names.
161 required: Annotated[SkipValidation[list[str] | None], PlainSerializer(lambda value: value)] = Field(
162 default=None, description="The names of the required properties, as served"
163 )
165 @model_serializer(mode="wrap")
166 def _dump_as_served(self, handler: SerializerFunctionWrapHandler) -> JsonDict:
167 # Omitted when it was never given, so a schema served without `required` dumps
168 # without one, as it did when `required` was only an extra field.
169 dumped = handler(self)
170 if "required" not in self.model_fields_set:
171 dumped.pop("required", None)
172 return dumped