krons 0.1.1__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. krons/__init__.py +49 -0
  2. krons/agent/__init__.py +144 -0
  3. krons/agent/mcps/__init__.py +14 -0
  4. krons/agent/mcps/loader.py +287 -0
  5. krons/agent/mcps/wrapper.py +799 -0
  6. krons/agent/message/__init__.py +20 -0
  7. krons/agent/message/action.py +69 -0
  8. krons/agent/message/assistant.py +52 -0
  9. krons/agent/message/common.py +49 -0
  10. krons/agent/message/instruction.py +130 -0
  11. krons/agent/message/prepare_msg.py +187 -0
  12. krons/agent/message/role.py +53 -0
  13. krons/agent/message/system.py +53 -0
  14. krons/agent/operations/__init__.py +82 -0
  15. krons/agent/operations/act.py +100 -0
  16. krons/agent/operations/generate.py +145 -0
  17. krons/agent/operations/llm_reparse.py +89 -0
  18. krons/agent/operations/operate.py +247 -0
  19. krons/agent/operations/parse.py +243 -0
  20. krons/agent/operations/react.py +286 -0
  21. krons/agent/operations/specs.py +235 -0
  22. krons/agent/operations/structure.py +151 -0
  23. krons/agent/operations/utils.py +79 -0
  24. krons/agent/providers/__init__.py +17 -0
  25. krons/agent/providers/anthropic_messages.py +146 -0
  26. krons/agent/providers/claude_code.py +276 -0
  27. krons/agent/providers/gemini.py +268 -0
  28. krons/agent/providers/match.py +75 -0
  29. krons/agent/providers/oai_chat.py +174 -0
  30. krons/agent/third_party/__init__.py +2 -0
  31. krons/agent/third_party/anthropic_models.py +154 -0
  32. krons/agent/third_party/claude_code.py +682 -0
  33. krons/agent/third_party/gemini_models.py +508 -0
  34. krons/agent/third_party/openai_models.py +295 -0
  35. krons/agent/tool.py +291 -0
  36. krons/core/__init__.py +56 -74
  37. krons/core/base/__init__.py +121 -0
  38. krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
  39. krons/core/{element.py → base/element.py} +13 -5
  40. krons/core/{event.py → base/event.py} +39 -6
  41. krons/core/{eventbus.py → base/eventbus.py} +3 -1
  42. krons/core/{flow.py → base/flow.py} +11 -4
  43. krons/core/{graph.py → base/graph.py} +24 -8
  44. krons/core/{node.py → base/node.py} +44 -19
  45. krons/core/{pile.py → base/pile.py} +22 -8
  46. krons/core/{processor.py → base/processor.py} +21 -7
  47. krons/core/{progression.py → base/progression.py} +3 -1
  48. krons/{specs → core/specs}/__init__.py +0 -5
  49. krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
  50. krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
  51. krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
  52. krons/{specs → core/specs}/catalog/__init__.py +2 -2
  53. krons/{specs → core/specs}/catalog/_audit.py +2 -2
  54. krons/{specs → core/specs}/catalog/_common.py +2 -2
  55. krons/{specs → core/specs}/catalog/_content.py +4 -4
  56. krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
  57. krons/{specs → core/specs}/factory.py +5 -5
  58. krons/{specs → core/specs}/operable.py +8 -2
  59. krons/{specs → core/specs}/protocol.py +4 -2
  60. krons/{specs → core/specs}/spec.py +23 -11
  61. krons/{types → core/types}/base.py +4 -2
  62. krons/{types → core/types}/db_types.py +2 -2
  63. krons/errors.py +13 -13
  64. krons/protocols.py +9 -4
  65. krons/resource/__init__.py +89 -0
  66. krons/{services → resource}/backend.py +48 -22
  67. krons/{services → resource}/endpoint.py +28 -14
  68. krons/{services → resource}/hook.py +20 -7
  69. krons/{services → resource}/imodel.py +46 -28
  70. krons/{services → resource}/registry.py +26 -24
  71. krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
  72. krons/{services → resource}/utilities/rate_limiter.py +3 -1
  73. krons/{services → resource}/utilities/resilience.py +15 -5
  74. krons/resource/utilities/token_calculator.py +185 -0
  75. krons/session/__init__.py +12 -17
  76. krons/session/constraints.py +70 -0
  77. krons/session/exchange.py +11 -3
  78. krons/session/message.py +3 -1
  79. krons/session/registry.py +35 -0
  80. krons/session/session.py +165 -174
  81. krons/utils/__init__.py +45 -0
  82. krons/utils/_function_arg_parser.py +99 -0
  83. krons/utils/_pythonic_function_call.py +249 -0
  84. krons/utils/_to_list.py +9 -3
  85. krons/utils/_utils.py +6 -2
  86. krons/utils/concurrency/_async_call.py +4 -2
  87. krons/utils/concurrency/_errors.py +3 -1
  88. krons/utils/concurrency/_patterns.py +3 -1
  89. krons/utils/concurrency/_resource_tracker.py +6 -2
  90. krons/utils/display.py +257 -0
  91. krons/utils/fuzzy/__init__.py +6 -1
  92. krons/utils/fuzzy/_fuzzy_match.py +14 -8
  93. krons/utils/fuzzy/_string_similarity.py +3 -1
  94. krons/utils/fuzzy/_to_dict.py +3 -1
  95. krons/utils/schemas/__init__.py +26 -0
  96. krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
  97. krons/utils/schemas/_formatter.py +72 -0
  98. krons/utils/schemas/_minimal_yaml.py +151 -0
  99. krons/utils/schemas/_typescript.py +153 -0
  100. krons/utils/validators/__init__.py +3 -0
  101. krons/utils/validators/_validate_image_url.py +56 -0
  102. krons/work/__init__.py +115 -0
  103. krons/work/engine.py +333 -0
  104. krons/work/form.py +242 -0
  105. krons/{operations → work/operations}/__init__.py +7 -4
  106. krons/{operations → work/operations}/builder.py +1 -1
  107. krons/{enforcement → work/operations}/context.py +36 -5
  108. krons/{operations → work/operations}/flow.py +13 -5
  109. krons/{operations → work/operations}/node.py +45 -43
  110. krons/work/operations/registry.py +103 -0
  111. krons/work/report.py +268 -0
  112. krons/work/rules/__init__.py +47 -0
  113. krons/{enforcement → work/rules}/common/boolean.py +3 -1
  114. krons/{enforcement → work/rules}/common/choice.py +9 -3
  115. krons/{enforcement → work/rules}/common/number.py +3 -1
  116. krons/{enforcement → work/rules}/common/string.py +9 -3
  117. krons/{enforcement → work/rules}/rule.py +1 -1
  118. krons/{enforcement → work/rules}/validator.py +20 -5
  119. krons/work/worker.py +266 -0
  120. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/METADATA +15 -1
  121. krons-0.2.1.dist-info/RECORD +151 -0
  122. krons/enforcement/__init__.py +0 -57
  123. krons/enforcement/policy.py +0 -80
  124. krons/enforcement/service.py +0 -370
  125. krons/operations/registry.py +0 -92
  126. krons/services/__init__.py +0 -81
  127. krons/specs/phrase.py +0 -405
  128. krons-0.1.1.dist-info/RECORD +0 -101
  129. /krons/{specs → core/specs}/adapters/__init__.py +0 -0
  130. /krons/{specs → core/specs}/adapters/_utils.py +0 -0
  131. /krons/{specs → core/specs}/adapters/factory.py +0 -0
  132. /krons/{types → core/types}/__init__.py +0 -0
  133. /krons/{types → core/types}/_sentinel.py +0 -0
  134. /krons/{types → core/types}/identity.py +0 -0
  135. /krons/{services → resource}/utilities/__init__.py +0 -0
  136. /krons/{services → resource}/utilities/header_factory.py +0 -0
  137. /krons/{enforcement → work/rules}/common/__init__.py +0 -0
  138. /krons/{enforcement → work/rules}/common/mapping.py +0 -0
  139. /krons/{enforcement → work/rules}/common/model.py +0 -0
  140. /krons/{enforcement → work/rules}/registry.py +0 -0
  141. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/WHEEL +0 -0
  142. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,235 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Specs for agent operations.
5
+
6
+ Action: validated tool-call request model (function + arguments).
7
+ ActionResult: validated tool-call result model (function, result, error).
8
+ get_action_spec / get_action_result_spec: Spec factories for
9
+ runtime operable composition in operate.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from functools import cache
16
+ from typing import Any, Literal
17
+
18
+ from pydantic import BaseModel, Field, JsonValue, field_validator
19
+
20
+ from krons.core.types import HashableModel
21
+ from krons.utils import extract_json, to_dict, to_list
22
+
23
+ __all__ = (
24
+ "Action",
25
+ "ActionResult",
26
+ "Instruct",
27
+ "get_action_spec",
28
+ "get_action_result_spec",
29
+ "get_instruct_spec",
30
+ )
31
+
32
+
33
+ class Action(HashableModel):
34
+ """Validated tool/action request: (function, arguments) pair.
35
+
36
+ Parsed from LLM output via fuzzy JSON extraction.
37
+ """
38
+
39
+ function: str = Field(
40
+ description=(
41
+ "Function name from tool_schemas. "
42
+ "Never invent names outside provided schemas."
43
+ ),
44
+ )
45
+ arguments: dict[str, Any] = Field(
46
+ default_factory=dict,
47
+ description=(
48
+ "Argument dict for the function. Use only names/types from tool_schemas."
49
+ ),
50
+ )
51
+
52
+ @field_validator("arguments", mode="before")
53
+ @classmethod
54
+ def _coerce_arguments(cls, value: Any) -> dict[str, Any]:
55
+ """Coerce arguments into a dict, handling JSON strings."""
56
+ if isinstance(value, dict):
57
+ return value
58
+ return to_dict(
59
+ value,
60
+ fuzzy_parse=True,
61
+ recursive=True,
62
+ recursive_python_only=False,
63
+ )
64
+
65
+ @classmethod
66
+ def create(cls, content: str | dict | BaseModel) -> list[Action]:
67
+ """Parse raw LLM output into Action instances. Returns [] on failure."""
68
+ try:
69
+ parsed = _parse_action_blocks(content)
70
+ return [cls.model_validate(item) for item in parsed] if parsed else []
71
+ except Exception:
72
+ return []
73
+
74
+
75
+ class ActionResult(HashableModel):
76
+ """Validated tool-call result: (function, result, error) triple.
77
+
78
+ Produced by act stage after executing Action requests.
79
+ """
80
+
81
+ function: str = Field(description="Function name that was called.")
82
+ result: Any = Field(default=None, description="Return value on success.")
83
+ error: str | None = Field(default=None, description="Error message on failure.")
84
+
85
+ @property
86
+ def success(self) -> bool:
87
+ return self.error is None
88
+
89
+
90
+ class Instruct(HashableModel):
91
+ """Instruction bundle for orchestrated task handoff.
92
+
93
+ Encapsulates everything needed to hand off a task: what to do,
94
+ strategic guidance, background context, and execution preferences.
95
+ Used as a structured handoff between orchestrator and workers.
96
+ """
97
+
98
+ instruction: str | None = Field(
99
+ default=None,
100
+ description=(
101
+ "Clear, actionable task definition. Specify the primary goal, "
102
+ "key constraints, and success criteria."
103
+ ),
104
+ )
105
+ guidance: JsonValue | None = Field(
106
+ default=None,
107
+ description=(
108
+ "Strategic direction: preferred methods, quality benchmarks, "
109
+ "resource constraints, or compliance requirements."
110
+ ),
111
+ )
112
+ context: JsonValue | None = Field(
113
+ default=None,
114
+ description=(
115
+ "Background information directly relevant to the task: "
116
+ "environment, prior outcomes, system states, or dependencies."
117
+ ),
118
+ )
119
+ reason: bool = Field(
120
+ default=False,
121
+ description=(
122
+ "Include reasoning: explanations of decisions, trade-offs, "
123
+ "alternatives considered, and confidence assessment."
124
+ ),
125
+ )
126
+ actions: bool = Field(
127
+ default=False,
128
+ description=(
129
+ "Enable action execution via tool_schemas. "
130
+ "True: execute tool calls. False: analysis only."
131
+ ),
132
+ )
133
+ action_strategy: Literal["sequential", "concurrent"] = Field(
134
+ default="concurrent",
135
+ description="How to execute actions: sequential or concurrent.",
136
+ )
137
+
138
+ @field_validator("action_strategy", mode="before")
139
+ @classmethod
140
+ def _validate_action_strategy(cls, v: Any) -> str:
141
+ if v not in ("sequential", "concurrent"):
142
+ return "concurrent"
143
+ return v
144
+
145
+
146
+ @cache
147
+ def get_instruct_spec():
148
+ """Spec for instruct_model: Instruct | None."""
149
+ from krons.core.specs import Spec
150
+
151
+ return Spec(Instruct, name="instruct_model").as_nullable()
152
+
153
+
154
+ @cache
155
+ def get_action_spec():
156
+ """Spec for action_requests: list[Action] | None."""
157
+ from krons.core.specs import Spec
158
+
159
+ return Spec(Action, name="action_requests").as_listable().as_nullable()
160
+
161
+
162
+ @cache
163
+ def get_action_result_spec():
164
+ """Spec for action_results: list[ActionResult] | None."""
165
+ from krons.core.specs import Spec
166
+
167
+ return Spec(ActionResult, name="action_results").as_listable().as_nullable()
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Parsing helpers
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ def _parse_action_blocks(content: str | dict | BaseModel) -> list[dict]:
176
+ """Extract action request dicts from raw content.
177
+
178
+ Normalizes provider-specific key names to {function, arguments}.
179
+ """
180
+ json_blocks: list = []
181
+
182
+ if isinstance(content, BaseModel):
183
+ json_blocks = [content.model_dump()]
184
+ elif isinstance(content, str):
185
+ json_blocks = extract_json(content, fuzzy_parse=True)
186
+ if not json_blocks:
187
+ # Fallback: try extracting from ```python ... ``` blocks
188
+ matches = re.findall(r"```python\s*(.*?)\s*```", content, re.DOTALL)
189
+ json_blocks = to_list(
190
+ [extract_json(m, fuzzy_parse=True) for m in matches],
191
+ dropna=True,
192
+ )
193
+ elif isinstance(content, dict):
194
+ json_blocks = [content]
195
+
196
+ if json_blocks and not isinstance(json_blocks, list):
197
+ json_blocks = [json_blocks]
198
+
199
+ out: list[dict] = []
200
+ for block in json_blocks:
201
+ if not isinstance(block, dict):
202
+ continue
203
+ normalized = _normalize_action_keys(block)
204
+ if normalized:
205
+ out.append(normalized)
206
+ return out
207
+
208
+
209
+ def _normalize_action_keys(d: dict) -> dict | None:
210
+ """Map provider-specific key names to canonical {function, arguments}.
211
+
212
+ Returns None if required keys are missing.
213
+ """
214
+ result: dict[str, Any] = {}
215
+
216
+ # Handle nested function.name pattern
217
+ if "function" in d and isinstance(d["function"], dict) and "name" in d["function"]:
218
+ d = {**d, "function": d["function"]["name"]}
219
+
220
+ for k, v in d.items():
221
+ # Strip common prefixes: action_name → name, recipient_name → name
222
+ normalized = (
223
+ k.replace("action_", "").replace("recipient_", "").removesuffix("s")
224
+ )
225
+ if normalized in ("name", "function", "recipient"):
226
+ result["function"] = v
227
+ elif normalized in ("parameter", "argument", "arg", "param"):
228
+ result["arguments"] = to_dict(
229
+ v, str_type="json", fuzzy_parse=True, suppress=True
230
+ )
231
+
232
+ if "function" in result:
233
+ result.setdefault("arguments", {})
234
+ return result
235
+ return None
@@ -0,0 +1,151 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Structure operation: generate -> parse -> validate pipeline.
5
+
6
+ Handler signature: structure(params, ctx) -> validated dict or model instance
7
+
8
+ Three-stage pipeline:
9
+ 1. generate: LLM call (TEXT or MESSAGE depending on persist)
10
+ 2. parse: extract JSON from text (direct + LLM reparse fallback)
11
+ 3. validate: enforce operable specs + cast to composed structure
12
+
13
+ When persist=True, the assistant message is stored in the branch
14
+ before parsing. The text is extracted from message.content.response.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ from pydantic import BaseModel
23
+
24
+ from krons.core.types import MaybeUnset, ModelConfig, Params, Unset, is_unset
25
+
26
+ from .generate import GenerateParams, generate
27
+ from .parse import ParseParams, parse
28
+ from .utils import ReturnAs
29
+
30
+ if TYPE_CHECKING:
31
+ from krons.agent.message.common import CustomParser
32
+ from krons.core.specs import Operable
33
+ from krons.resource import iModel
34
+ from krons.work.operations import RequestContext
35
+ from krons.work.rules.validator import Validator
36
+
37
+ __all__ = ("StructureParams", "structure")
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class StructureParams(Params):
42
+ """Parameters for structure operation (generate -> parse -> validate).
43
+
44
+ Attributes:
45
+ generate_params: LLM generation config.
46
+ validator: Rule-based validator for operable spec enforcement.
47
+ operable: Spec definition for field validation (required).
48
+ structure: Pre-composed Pydantic model to cast into.
49
+ If None, auto-composed from operable via compose_structure().
50
+ persist: Persist assistant message to branch before parsing.
51
+ capabilities: Allowed field subset (None = all operable fields).
52
+ auto_fix: Auto-coerce validation issues (e.g., wrap scalar -> list).
53
+ strict: Raise on validation failure vs. skip.
54
+ """
55
+
56
+ _config = ModelConfig(sentinel_additions=frozenset({"none", "empty"}))
57
+
58
+ # Generate stage
59
+ generate_params: GenerateParams
60
+
61
+ # Validate stage
62
+ validator: Validator
63
+ operable: Operable
64
+ structure: type[BaseModel] | None = None
65
+ capabilities: set[str] | None = None
66
+ auto_fix: bool = True
67
+ strict: bool = True
68
+
69
+ # Message persistence
70
+ persist: bool = False
71
+
72
+ # Parse stage overrides
73
+ parse_imodel: MaybeUnset[iModel | str] = Unset
74
+ parse_imodel_kwargs: dict[str, Any] = field(default_factory=dict)
75
+ custom_parser: CustomParser | None = None
76
+ similarity_threshold: float = 0.85
77
+ max_retries: int = 3
78
+ fill_mapping: dict[str, Any] | None = None
79
+ fill_value: Any = Unset
80
+
81
+
82
+ async def structure(params: StructureParams, ctx: RequestContext) -> Any:
83
+ """Structure operation handler: generate -> parse -> validate.
84
+
85
+ When persist=True, generates as MESSAGE, persists to branch,
86
+ then extracts text from message.content.response for parsing.
87
+ """
88
+ # Resolve structure type: explicit or auto-composed from operable
89
+ structure_type = params.structure
90
+ if structure_type is None:
91
+ structure_type = params.operable.compose_structure()
92
+
93
+ # Stage 1: Generate
94
+ if params.persist:
95
+ text = await _generate_and_persist(params.generate_params, ctx)
96
+ else:
97
+ gen_params = params.generate_params.with_updates(
98
+ copy_containers="deep", return_as=ReturnAs.TEXT
99
+ )
100
+ text = await generate(gen_params, ctx)
101
+
102
+ # Stage 2: Parse (inherit schema config from generate params)
103
+ gen = params.generate_params
104
+ parse_params = ParseParams(
105
+ text=text,
106
+ imodel=params.parse_imodel if not is_unset(params.parse_imodel) else None,
107
+ imodel_kwargs=params.parse_imodel_kwargs or {},
108
+ custom_parser=params.custom_parser,
109
+ similarity_threshold=params.similarity_threshold,
110
+ max_retries=params.max_retries,
111
+ fill_mapping=params.fill_mapping,
112
+ fill_value=params.fill_value,
113
+ request_model=gen.request_model,
114
+ tool_schemas=gen.tool_schemas,
115
+ structure_format=gen.structure_format,
116
+ custom_renderer=gen.custom_renderer,
117
+ )
118
+ parsed = await parse(parse_params, ctx)
119
+
120
+ # Stage 3: Validate against operable specs + structure type
121
+ return await params.validator.validate(
122
+ parsed,
123
+ params.operable,
124
+ capabilities=params.capabilities,
125
+ auto_fix=params.auto_fix,
126
+ strict=params.strict,
127
+ structure=structure_type,
128
+ )
129
+
130
+
131
+ async def _generate_and_persist(
132
+ generate_params: GenerateParams,
133
+ ctx: RequestContext,
134
+ ) -> str:
135
+ """Generate as MESSAGE, persist to branch, return text.
136
+
137
+ The assistant message is added to both session messages and
138
+ the branch progression for conversation continuity.
139
+ """
140
+ gen_params = generate_params.with_updates(
141
+ copy_containers="deep", return_as=ReturnAs.MESSAGE
142
+ )
143
+ message = await generate(gen_params, ctx)
144
+
145
+ # Persist to branch
146
+ session = await ctx.get_session()
147
+ branch = await ctx.get_branch()
148
+ session.add_message(message, branches=branch)
149
+
150
+ # Extract text from assistant content
151
+ return message.content.response
@@ -0,0 +1,79 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Shared utilities for agent operations.
5
+
6
+ ReturnAs: Enum controlling how Calling results are unwrapped.
7
+ handle_return: Dispatch Calling → desired output form.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from krons.core.types import Enum, MaybeUnset, Unset, is_sentinel
15
+ from krons.errors import ValidationError
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Callable
19
+
20
+ from krons.resource.backend import Calling
21
+
22
+
23
+ class ReturnAs(Enum):
24
+ """How to unwrap a Calling result.
25
+
26
+ TEXT - response.data (typically the LLM text)
27
+ RAW - response.raw_response (provider-specific dict)
28
+ RESPONSE - the NormalizedResponse object
29
+ MESSAGE - wrapped as a Message with Assistant content
30
+ CALLING - the raw Calling event (no unwrap, no validation)
31
+ CUSTOM - apply caller-supplied return_parser
32
+ """
33
+
34
+ TEXT = "text"
35
+ RAW = "raw"
36
+ RESPONSE = "response"
37
+ MESSAGE = "message"
38
+ CALLING = "calling"
39
+ CUSTOM = "custom"
40
+
41
+
42
+ def handle_return(
43
+ calling: Calling,
44
+ return_as: ReturnAs,
45
+ /,
46
+ *,
47
+ return_parser: MaybeUnset[Callable] = Unset,
48
+ ):
49
+ """Unwrap a Calling into the form requested by return_as.
50
+
51
+ CALLING and CUSTOM bypass normalization checks.
52
+ All other modes call calling.assert_is_normalized() first.
53
+ """
54
+ if return_as == ReturnAs.CALLING:
55
+ return calling
56
+
57
+ if return_as == ReturnAs.CUSTOM:
58
+ if is_sentinel(return_parser, {"none", "empty"}) or not callable(return_parser):
59
+ raise ValidationError(
60
+ "return_parser must be provided as a callable when return_as is 'custom'"
61
+ )
62
+ return return_parser(calling)
63
+
64
+ calling.assert_is_normalized()
65
+ response = calling.response
66
+
67
+ match return_as:
68
+ case ReturnAs.TEXT:
69
+ return response.data
70
+ case ReturnAs.RAW:
71
+ return response.raw_response
72
+ case ReturnAs.RESPONSE:
73
+ return response
74
+ case ReturnAs.MESSAGE:
75
+ from krons.agent.message.assistant import parse_to_assistant_message
76
+
77
+ return parse_to_assistant_message(response)
78
+ case _:
79
+ raise ValidationError(f"Unsupported return_as: {return_as.value}")
@@ -0,0 +1,17 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from .anthropic_messages import AnthropicMessagesEndpoint, create_anthropic_config
5
+ from .gemini import GeminiCodeEndpoint, create_gemini_code_config
6
+ from .match import match_endpoint
7
+ from .oai_chat import OAIChatEndpoint, create_oai_chat
8
+
9
+ __all__ = (
10
+ "AnthropicMessagesEndpoint",
11
+ "GeminiCodeEndpoint",
12
+ "OAIChatEndpoint",
13
+ "create_anthropic_config",
14
+ "create_gemini_code_config",
15
+ "create_oai_chat",
16
+ "match_endpoint",
17
+ )
@@ -0,0 +1,146 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from krons.resource.backend import NormalizedResponseModel
9
+ from krons.resource.endpoint import Endpoint, EndpointConfig
10
+
11
+ __all__ = (
12
+ "AnthropicMessagesEndpoint",
13
+ "create_anthropic_config",
14
+ )
15
+
16
+
17
+ def create_anthropic_config(
18
+ name: str = "anthropic_messages",
19
+ api_key: str | None = None,
20
+ base_url: str = "https://api.anthropic.com/v1",
21
+ endpoint: str = "messages",
22
+ anthropic_version: str = "2023-06-01",
23
+ ) -> dict:
24
+ """Factory for Anthropic Messages API config.
25
+
26
+ Args:
27
+ api_key: API key or env var name (default: "ANTHROPIC_API_KEY")
28
+ base_url: Base API URL
29
+ endpoint: Endpoint path
30
+ anthropic_version: API version header
31
+
32
+ Returns:
33
+ Config dict
34
+
35
+ Example:
36
+ >>> config = create_anthropic_config()
37
+ >>> endpoint = AnthropicMessagesEndpoint(config=config)
38
+ >>> response = await endpoint.call(
39
+ ... {"model": "claude-sonnet-4-5-20250929", "messages": [...]}
40
+ ... )
41
+ """
42
+ if api_key is None:
43
+ api_key = "ANTHROPIC_API_KEY"
44
+
45
+ return {
46
+ "name": name,
47
+ "provider": "anthropic",
48
+ "base_url": base_url,
49
+ "endpoint": endpoint,
50
+ "api_key": api_key,
51
+ "auth_type": "x-api-key",
52
+ "default_headers": {"anthropic-version": anthropic_version},
53
+ }
54
+
55
+
56
+ class AnthropicMessagesEndpoint(Endpoint):
57
+ """Anthropic Messages API endpoint.
58
+
59
+ Supports Anthropic-specific response normalization.
60
+
61
+ Usage:
62
+ endpoint = AnthropicMessagesEndpoint()
63
+ response = await endpoint.call({
64
+ "model": "claude-sonnet-4-5-20250929",
65
+ "messages": [{"role": "user", "content": "Hello"}],
66
+ "max_tokens": 1024
67
+ })
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ config: dict | EndpointConfig | None = None,
73
+ circuit_breaker: Any | None = None,
74
+ **kwargs,
75
+ ):
76
+ """Initialize with Anthropic config."""
77
+ if config is None:
78
+ config = create_anthropic_config(**kwargs)
79
+ elif isinstance(config, EndpointConfig):
80
+ config = config.model_dump()
81
+ if not isinstance(config, dict):
82
+ raise ValueError(
83
+ "Provided config must be a dict or EndpointConfig instance"
84
+ )
85
+
86
+ # Ensure request_options is set
87
+ if config.get("request_options") is None:
88
+ from ..third_party.anthropic_models import CreateMessageRequest
89
+
90
+ config["request_options"] = CreateMessageRequest
91
+
92
+ super().__init__(config=config, circuit_breaker=circuit_breaker, **kwargs)
93
+
94
+ def normalize_response(self, response: dict[str, Any]) -> NormalizedResponseModel:
95
+ """Normalize Anthropic response to standard format.
96
+
97
+ Extracts:
98
+ - Text from content blocks
99
+ - Thinking blocks (if extended thinking enabled)
100
+ - Usage stats
101
+ - Stop reason
102
+ - Model info
103
+ - Tool uses
104
+ """
105
+ # Extract text and thinking from content blocks
106
+ text_parts = []
107
+ thinking_parts = []
108
+
109
+ content = response.get("content")
110
+ if content:
111
+ for block in content:
112
+ block_type = block.get("type")
113
+ if block_type == "text":
114
+ text_parts.append(block.get("text", ""))
115
+ elif block_type == "thinking":
116
+ thinking_parts.append(block.get("thinking", ""))
117
+
118
+ # Combine text
119
+ text = "\n\n".join(text_parts)
120
+
121
+ # Extract metadata
122
+ metadata: dict[str, Any] = {
123
+ k: response[k]
124
+ for k in ("model", "usage", "stop_reason", "stop_sequence", "id")
125
+ if k in response
126
+ }
127
+
128
+ # Add thinking if present
129
+ if thinking_parts:
130
+ metadata["thinking"] = "\n\n".join(thinking_parts)
131
+
132
+ # Add tool use blocks if present
133
+ tool_uses = [
134
+ block
135
+ for block in response.get("content", [])
136
+ if block.get("type") == "tool_use"
137
+ ]
138
+ if tool_uses:
139
+ metadata["tool_uses"] = tool_uses
140
+
141
+ return NormalizedResponseModel(
142
+ status="success",
143
+ data=text,
144
+ raw_response=response,
145
+ metadata=metadata,
146
+ )