krons 0.1.0__py3-none-any.whl → 0.2.0__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 (162) 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 +127 -0
  37. krons/core/base/__init__.py +121 -0
  38. {kronos/core → krons/core/base}/broadcaster.py +7 -3
  39. {kronos/core → krons/core/base}/element.py +15 -7
  40. {kronos/core → krons/core/base}/event.py +41 -8
  41. {kronos/core → krons/core/base}/eventbus.py +4 -2
  42. {kronos/core → krons/core/base}/flow.py +14 -7
  43. {kronos/core → krons/core/base}/graph.py +27 -11
  44. {kronos/core → krons/core/base}/node.py +47 -22
  45. {kronos/core → krons/core/base}/pile.py +26 -12
  46. {kronos/core → krons/core/base}/processor.py +23 -9
  47. {kronos/core → krons/core/base}/progression.py +5 -3
  48. {kronos → krons/core}/specs/__init__.py +0 -5
  49. {kronos → krons/core}/specs/adapters/dataclass_field.py +16 -8
  50. {kronos → krons/core}/specs/adapters/pydantic_adapter.py +11 -5
  51. {kronos → krons/core}/specs/adapters/sql_ddl.py +16 -10
  52. {kronos → krons/core}/specs/catalog/__init__.py +2 -2
  53. {kronos → krons/core}/specs/catalog/_audit.py +3 -3
  54. {kronos → krons/core}/specs/catalog/_common.py +2 -2
  55. {kronos → krons/core}/specs/catalog/_content.py +5 -5
  56. {kronos → krons/core}/specs/catalog/_enforcement.py +4 -4
  57. {kronos → krons/core}/specs/factory.py +7 -7
  58. {kronos → krons/core}/specs/operable.py +9 -3
  59. {kronos → krons/core}/specs/protocol.py +4 -2
  60. {kronos → krons/core}/specs/spec.py +25 -13
  61. {kronos → krons/core}/types/base.py +7 -5
  62. {kronos → krons/core}/types/db_types.py +2 -2
  63. {kronos → krons/core}/types/identity.py +1 -1
  64. {kronos → krons}/errors.py +13 -13
  65. {kronos → krons}/protocols.py +9 -4
  66. krons/resource/__init__.py +89 -0
  67. {kronos/services → krons/resource}/backend.py +50 -24
  68. {kronos/services → krons/resource}/endpoint.py +28 -14
  69. {kronos/services → krons/resource}/hook.py +22 -9
  70. {kronos/services → krons/resource}/imodel.py +50 -32
  71. {kronos/services → krons/resource}/registry.py +27 -25
  72. {kronos/services → krons/resource}/utilities/rate_limited_executor.py +10 -6
  73. {kronos/services → krons/resource}/utilities/rate_limiter.py +4 -2
  74. {kronos/services → krons/resource}/utilities/resilience.py +17 -7
  75. krons/resource/utilities/token_calculator.py +185 -0
  76. {kronos → krons}/session/__init__.py +12 -17
  77. krons/session/constraints.py +70 -0
  78. {kronos → krons}/session/exchange.py +14 -6
  79. {kronos → krons}/session/message.py +4 -2
  80. krons/session/registry.py +35 -0
  81. {kronos → krons}/session/session.py +165 -174
  82. krons/utils/__init__.py +85 -0
  83. krons/utils/_function_arg_parser.py +99 -0
  84. krons/utils/_pythonic_function_call.py +249 -0
  85. {kronos → krons}/utils/_to_list.py +9 -3
  86. {kronos → krons}/utils/_utils.py +9 -5
  87. {kronos → krons}/utils/concurrency/__init__.py +38 -38
  88. {kronos → krons}/utils/concurrency/_async_call.py +6 -4
  89. {kronos → krons}/utils/concurrency/_errors.py +3 -1
  90. {kronos → krons}/utils/concurrency/_patterns.py +3 -1
  91. {kronos → krons}/utils/concurrency/_resource_tracker.py +6 -2
  92. krons/utils/display.py +257 -0
  93. {kronos → krons}/utils/fuzzy/__init__.py +6 -1
  94. {kronos → krons}/utils/fuzzy/_fuzzy_match.py +14 -8
  95. {kronos → krons}/utils/fuzzy/_string_similarity.py +3 -1
  96. {kronos → krons}/utils/fuzzy/_to_dict.py +3 -1
  97. krons/utils/schemas/__init__.py +26 -0
  98. krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
  99. krons/utils/schemas/_formatter.py +72 -0
  100. krons/utils/schemas/_minimal_yaml.py +151 -0
  101. krons/utils/schemas/_typescript.py +153 -0
  102. {kronos → krons}/utils/sql/_sql_validation.py +1 -1
  103. krons/utils/validators/__init__.py +3 -0
  104. krons/utils/validators/_validate_image_url.py +56 -0
  105. krons/work/__init__.py +126 -0
  106. krons/work/engine.py +333 -0
  107. krons/work/form.py +305 -0
  108. {kronos → krons/work}/operations/__init__.py +7 -4
  109. {kronos → krons/work}/operations/builder.py +4 -4
  110. {kronos/enforcement → krons/work/operations}/context.py +37 -6
  111. {kronos → krons/work}/operations/flow.py +17 -9
  112. krons/work/operations/node.py +103 -0
  113. krons/work/operations/registry.py +103 -0
  114. {kronos/specs → krons/work}/phrase.py +131 -14
  115. {kronos/enforcement → krons/work}/policy.py +3 -3
  116. krons/work/report.py +268 -0
  117. krons/work/rules/__init__.py +47 -0
  118. {kronos/enforcement → krons/work/rules}/common/boolean.py +3 -1
  119. {kronos/enforcement → krons/work/rules}/common/choice.py +9 -3
  120. {kronos/enforcement → krons/work/rules}/common/number.py +3 -1
  121. {kronos/enforcement → krons/work/rules}/common/string.py +9 -3
  122. {kronos/enforcement → krons/work/rules}/rule.py +2 -2
  123. {kronos/enforcement → krons/work/rules}/validator.py +21 -6
  124. {kronos/enforcement → krons/work}/service.py +16 -7
  125. krons/work/worker.py +266 -0
  126. {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/METADATA +19 -5
  127. krons-0.2.0.dist-info/RECORD +154 -0
  128. kronos/core/__init__.py +0 -145
  129. kronos/enforcement/__init__.py +0 -57
  130. kronos/operations/node.py +0 -101
  131. kronos/operations/registry.py +0 -92
  132. kronos/services/__init__.py +0 -81
  133. kronos/specs/adapters/__init__.py +0 -0
  134. kronos/utils/__init__.py +0 -40
  135. krons-0.1.0.dist-info/RECORD +0 -101
  136. {kronos → krons/core/specs/adapters}/__init__.py +0 -0
  137. {kronos → krons/core}/specs/adapters/_utils.py +0 -0
  138. {kronos → krons/core}/specs/adapters/factory.py +0 -0
  139. {kronos → krons/core}/types/__init__.py +0 -0
  140. {kronos → krons/core}/types/_sentinel.py +0 -0
  141. {kronos → krons}/py.typed +0 -0
  142. {kronos/services → krons/resource}/utilities/__init__.py +0 -0
  143. {kronos/services → krons/resource}/utilities/header_factory.py +0 -0
  144. {kronos → krons}/utils/_hash.py +0 -0
  145. {kronos → krons}/utils/_json_dump.py +0 -0
  146. {kronos → krons}/utils/_lazy_init.py +0 -0
  147. {kronos → krons}/utils/_to_num.py +0 -0
  148. {kronos → krons}/utils/concurrency/_cancel.py +0 -0
  149. {kronos → krons}/utils/concurrency/_primitives.py +0 -0
  150. {kronos → krons}/utils/concurrency/_priority_queue.py +0 -0
  151. {kronos → krons}/utils/concurrency/_run_async.py +0 -0
  152. {kronos → krons}/utils/concurrency/_task.py +0 -0
  153. {kronos → krons}/utils/concurrency/_utils.py +0 -0
  154. {kronos → krons}/utils/fuzzy/_extract_json.py +0 -0
  155. {kronos → krons}/utils/fuzzy/_fuzzy_json.py +0 -0
  156. {kronos → krons}/utils/sql/__init__.py +0 -0
  157. {kronos/enforcement → krons/work/rules}/common/__init__.py +0 -0
  158. {kronos/enforcement → krons/work/rules}/common/mapping.py +0 -0
  159. {kronos/enforcement → krons/work/rules}/common/model.py +0 -0
  160. {kronos/enforcement → krons/work/rules}/registry.py +0 -0
  161. {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
  162. {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,276 @@
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 collections.abc import AsyncIterator
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from krons.agent.third_party.claude_code import (
12
+ ClaudeChunk,
13
+ ClaudeCodeRequest,
14
+ ClaudeSession,
15
+ stream_claude_code_cli,
16
+ )
17
+ from krons.resource.backend import NormalizedResponseModel
18
+ from krons.resource.endpoint import Endpoint, EndpointConfig
19
+
20
+ __all__ = (
21
+ "ClaudeCodeEndpoint",
22
+ "create_claude_code_config",
23
+ )
24
+
25
+
26
+ def create_claude_code_config(
27
+ name: str | None = None,
28
+ ) -> dict:
29
+ """Factory for Claude Code CLI endpoint config.
30
+
31
+ Args:
32
+ name: Config name (default: "claude_code_cli")
33
+
34
+ Returns:
35
+ Config dict for ClaudeCodeEndpoint
36
+ """
37
+ return {
38
+ "name": name or "claude_code_cli",
39
+ "provider": "claude_code",
40
+ "base_url": "internal",
41
+ "endpoint": "query_cli",
42
+ "api_key": "dummy-key",
43
+ "request_options": ClaudeCodeRequest,
44
+ "timeout": 3600, # 1 hour max (EndpointConfig limit)
45
+ }
46
+
47
+
48
+ class ClaudeCodeEndpoint(Endpoint):
49
+ """Claude Code CLI endpoint for local AI agent execution.
50
+
51
+ Usage:
52
+ endpoint = ClaudeCodeEndpoint()
53
+ response = await endpoint.call({
54
+ "messages": [{"role": "user", "content": "List files"}]
55
+ })
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ config: dict | EndpointConfig | None = None,
61
+ circuit_breaker: Any | None = None,
62
+ **kwargs,
63
+ ):
64
+ """Initialize with Claude Code config."""
65
+ if config is None:
66
+ config = create_claude_code_config()
67
+ elif isinstance(config, EndpointConfig):
68
+ config = config.model_dump()
69
+ if not isinstance(config, dict):
70
+ raise ValueError(
71
+ "Provided config must be a dict or EndpointConfig instance"
72
+ )
73
+
74
+ super().__init__(config=config, circuit_breaker=circuit_breaker, **kwargs)
75
+
76
+ def create_payload(
77
+ self, request: dict | BaseModel, extra_headers: dict | None = None, **kwargs
78
+ ) -> tuple[dict, dict]:
79
+ """Create payload for Claude Code CLI.
80
+
81
+ Args:
82
+ request: Request dict with 'messages' or ClaudeCodeRequest
83
+ extra_headers: Ignored (CLI doesn't use HTTP headers)
84
+ **kwargs: Additional arguments merged into request
85
+
86
+ Returns:
87
+ (payload_dict, headers_dict) - headers always empty for CLI
88
+ """
89
+ from krons.utils.fuzzy import to_dict
90
+
91
+ # Convert request to dict if BaseModel
92
+ request_dict = to_dict(request) if isinstance(request, BaseModel) else request
93
+
94
+ # Merge config kwargs, request, and call kwargs (ignore extra_headers for CLI)
95
+ req_dict = {**self.config.kwargs, **request_dict, **kwargs}
96
+
97
+ # Extract messages (required)
98
+ messages = req_dict.pop("messages", None)
99
+ if not messages:
100
+ raise ValueError(
101
+ f"'messages' required for Claude Code endpoint. Got keys: {list(req_dict.keys())}"
102
+ )
103
+
104
+ # Create ClaudeCodeRequest object
105
+ # Pass messages to messages key so validator converts to prompt string
106
+ req_obj = ClaudeCodeRequest(messages=messages, **req_dict) # type: ignore[arg-type]
107
+
108
+ return {"request": req_obj}, {}
109
+
110
+ async def call(
111
+ self,
112
+ request: dict | BaseModel,
113
+ skip_payload_creation: bool = False,
114
+ extra_headers: dict | None = None,
115
+ **kwargs,
116
+ ) -> NormalizedResponseModel:
117
+ """Execute Claude Code CLI and return normalized response.
118
+
119
+ Overrides parent to handle tuple return from create_payload.
120
+
121
+ Args:
122
+ request: Request parameters or Pydantic model.
123
+ skip_payload_creation: Bypass create_payload validation.
124
+ extra_headers: Ignored (CLI doesn't use HTTP headers).
125
+ **kwargs: Extra CLI arguments.
126
+
127
+ Returns:
128
+ NormalizedResponseModel wrapping the CLI response.
129
+ """
130
+ if skip_payload_creation:
131
+ # If already a ClaudeCodeRequest, wrap it; otherwise treat as dict
132
+ if isinstance(request, ClaudeCodeRequest):
133
+ payload = {"request": request}
134
+ elif isinstance(request, dict) and "request" in request:
135
+ # Already in payload format
136
+ payload = request
137
+ else:
138
+ # Assume it's a raw dict that needs to be converted
139
+ req_dict = (
140
+ request if isinstance(request, dict) else request.model_dump()
141
+ )
142
+ messages = req_dict.pop("messages", None)
143
+ if messages:
144
+ payload = {
145
+ "request": ClaudeCodeRequest(messages=messages, **req_dict)
146
+ }
147
+ else:
148
+ raise ValueError("'messages' required for Claude Code endpoint")
149
+ else:
150
+ payload, _ = self.create_payload(request, extra_headers, **kwargs)
151
+
152
+ raw_response = await self._call(payload, {}, **kwargs)
153
+ return self.normalize_response(raw_response)
154
+
155
+ async def stream( # type: ignore[override]
156
+ self, request: dict | BaseModel, **kwargs: Any
157
+ ) -> AsyncIterator[ClaudeChunk | dict | ClaudeSession]:
158
+ """Stream Claude Code CLI response chunks.
159
+
160
+ Yields:
161
+ ClaudeChunk, dict (system messages), or ClaudeSession (final)
162
+ """
163
+ payload, _ = self.create_payload(request, **kwargs)
164
+ request_obj: ClaudeCodeRequest = payload["request"]
165
+
166
+ async for chunk in stream_claude_code_cli(request_obj):
167
+ yield chunk
168
+
169
+ async def _call(
170
+ self,
171
+ payload: dict,
172
+ headers: dict,
173
+ **kwargs,
174
+ ) -> dict:
175
+ """Execute Claude Code CLI and return raw result chunk + session data.
176
+
177
+ Returns:
178
+ Dict with:
179
+ - raw_result: Raw "result" chunk from Claude Code CLI
180
+ - session: Organized session data from ClaudeSession
181
+ """
182
+ from krons.utils.fuzzy import to_dict
183
+
184
+ request: ClaudeCodeRequest = payload["request"]
185
+ session = ClaudeSession()
186
+ system: dict | None = None
187
+
188
+ # 1. Stream the Claude Code response
189
+ async for chunk in stream_claude_code_cli(request, session, **kwargs):
190
+ if isinstance(chunk, dict):
191
+ if chunk.get("type") == "done":
192
+ break
193
+ system = chunk
194
+
195
+ # 2. Auto-finish if requested and not already finished
196
+ if request.auto_finish and not isinstance(
197
+ session.chunks[-1] if session.chunks else None, ClaudeSession
198
+ ):
199
+ req2 = request.model_copy(deep=True)
200
+ req2.prompt = "Please provide the final result message only"
201
+ req2.max_turns = 1
202
+ req2.continue_conversation = True
203
+ if system:
204
+ req2.resume = system.get("session_id")
205
+
206
+ async for chunk in stream_claude_code_cli(req2, session, **kwargs):
207
+ if isinstance(chunk, ClaudeSession):
208
+ break
209
+
210
+ # 3. Use session.result directly (intermediate chunks are conversation flow, not final output)
211
+ # Don't concatenate chunk.text with session.result - causes duplication for JSON responses
212
+
213
+ # 4. Populate summary if requested
214
+ if request.cli_include_summary:
215
+ session.populate_summary()
216
+
217
+ # 5. Extract raw "result" chunk from session.chunks
218
+ raw_result_chunk = {}
219
+ for chunk in session.chunks:
220
+ if chunk.type == "result":
221
+ raw_result_chunk = chunk.raw
222
+ break
223
+
224
+ # 6. Return both raw CLI result and organized session data
225
+ return {
226
+ "raw_result": raw_result_chunk,
227
+ "session": to_dict(session, recursive=True),
228
+ }
229
+
230
+ def normalize_response(
231
+ self, raw_response: dict[str, Any]
232
+ ) -> NormalizedResponseModel:
233
+ """Normalize Claude Code response to standard format.
234
+
235
+ Args:
236
+ raw_response: Dict with:
237
+ - raw_result: Raw "result" chunk from Claude Code CLI
238
+ - session: Organized ClaudeSession data
239
+
240
+ Returns:
241
+ NormalizedResponseModel with:
242
+ - status: "success" or "error"
243
+ - data: Final result text
244
+ - raw_response: Actual raw CLI "result" chunk
245
+ - metadata: Organized session data
246
+ """
247
+ # Extract session data (our organized structure)
248
+ session = raw_response.get("session", {})
249
+ # Extract actual raw CLI result chunk
250
+ raw_cli_result = raw_response.get("raw_result", {})
251
+
252
+ # Extract text result from session
253
+ text = session.get("result", "")
254
+
255
+ # Extract metadata from organized session
256
+ metadata: dict[str, Any] = {
257
+ "session_id": session.get("session_id"),
258
+ "model": session.get("model"),
259
+ "usage": session.get("usage", {}),
260
+ "total_cost_usd": session.get("total_cost_usd"),
261
+ "num_turns": session.get("num_turns"),
262
+ "duration_ms": session.get("duration_ms"),
263
+ "duration_api_ms": session.get("duration_api_ms"),
264
+ "is_error": session.get("is_error", False),
265
+ "tool_uses": session.get("tool_uses", []),
266
+ "tool_results": session.get("tool_results", []),
267
+ "thinking_log": session.get("thinking_log", []),
268
+ "summary": session.get("summary"), # Always include (None if not present)
269
+ }
270
+
271
+ return NormalizedResponseModel(
272
+ status="error" if session.get("is_error") else "success",
273
+ data=text,
274
+ raw_response=raw_cli_result, # Use actual raw CLI result chunk
275
+ metadata=metadata,
276
+ )
@@ -0,0 +1,268 @@
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 collections.abc import AsyncIterator
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from krons.agent.third_party.gemini_models import (
12
+ GeminiChunk,
13
+ GeminiCodeRequest,
14
+ GeminiSession,
15
+ stream_gemini_cli,
16
+ )
17
+ from krons.resource.backend import NormalizedResponseModel
18
+ from krons.resource.endpoint import Endpoint, EndpointConfig
19
+
20
+ __all__ = (
21
+ "GeminiCodeEndpoint",
22
+ "create_gemini_code_config",
23
+ )
24
+
25
+
26
+ def create_gemini_code_config(
27
+ name: str | None = None,
28
+ model: str | None = "gemini-2.5-pro",
29
+ ) -> dict:
30
+ """Factory for Gemini CLI endpoint config.
31
+
32
+ Args:
33
+ name: Config name (default: "gemini_code_cli")
34
+ model: Default model to use (gemini-2.5-pro, gemini-2.5-flash, gemini-3-pro)
35
+
36
+ Returns:
37
+ Config dict for GeminiCodeEndpoint
38
+ """
39
+ return {
40
+ "name": name or "gemini_code_cli",
41
+ "provider": "gemini_code",
42
+ "base_url": "internal",
43
+ "endpoint": "query_cli",
44
+ "api_key": "dummy-key", # CLI uses Google OAuth, not API key
45
+ "request_options": GeminiCodeRequest,
46
+ "timeout": 3600, # 1 hour max
47
+ "kwargs": {"model": model} if model else {},
48
+ }
49
+
50
+
51
+ class GeminiCodeEndpoint(Endpoint):
52
+ """Gemini CLI endpoint for local AI agent execution.
53
+
54
+ Usage:
55
+ endpoint = GeminiCodeEndpoint()
56
+ response = await endpoint.call({
57
+ "messages": [{"role": "user", "content": "List files"}]
58
+ })
59
+
60
+ # Or with direct prompt:
61
+ response = await endpoint.call({
62
+ "prompt": "Analyze this codebase",
63
+ "yolo": True # Auto-approve actions
64
+ })
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ config: dict | EndpointConfig | None = None,
70
+ circuit_breaker: Any | None = None,
71
+ **kwargs,
72
+ ):
73
+ """Initialize with Gemini CLI config."""
74
+ if config is None:
75
+ config = create_gemini_code_config(
76
+ name=kwargs.pop("name", None),
77
+ model=kwargs.pop("model", "gemini-2.5-pro"),
78
+ )
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
+ super().__init__(config=config, circuit_breaker=circuit_breaker, **kwargs)
87
+
88
+ def create_payload(
89
+ self, request: dict | BaseModel, extra_headers: dict | None = None, **kwargs
90
+ ) -> tuple[dict, dict]:
91
+ """Create payload for Gemini CLI.
92
+
93
+ Args:
94
+ request: Request dict with 'messages' or 'prompt', or GeminiCodeRequest
95
+ extra_headers: Ignored (CLI doesn't use HTTP headers)
96
+ **kwargs: Additional arguments merged into request
97
+
98
+ Returns:
99
+ (payload_dict, headers_dict) - headers always empty for CLI
100
+ """
101
+ from krons.utils.fuzzy import to_dict
102
+
103
+ # Convert request to dict if BaseModel
104
+ request_dict = to_dict(request) if isinstance(request, BaseModel) else request
105
+
106
+ # Merge config kwargs, request, and call kwargs
107
+ req_dict = {**self.config.kwargs, **request_dict, **kwargs}
108
+
109
+ # Extract prompt or messages (one is required)
110
+ prompt = req_dict.get("prompt")
111
+ messages = req_dict.get("messages")
112
+
113
+ if not prompt and not messages:
114
+ raise ValueError(
115
+ f"'prompt' or 'messages' required for Gemini CLI endpoint. "
116
+ f"Got keys: {list(req_dict.keys())}"
117
+ )
118
+
119
+ # Create GeminiCodeRequest object
120
+ req_obj = GeminiCodeRequest(**req_dict)
121
+
122
+ return {"request": req_obj}, {}
123
+
124
+ async def call(
125
+ self,
126
+ request: dict | BaseModel,
127
+ skip_payload_creation: bool = False,
128
+ extra_headers: dict | None = None,
129
+ **kwargs,
130
+ ) -> NormalizedResponseModel:
131
+ """Execute Gemini CLI and return normalized response.
132
+
133
+ Overrides parent to handle tuple return from create_payload.
134
+
135
+ Args:
136
+ request: Request parameters or Pydantic model.
137
+ skip_payload_creation: Bypass create_payload validation.
138
+ extra_headers: Ignored (CLI doesn't use HTTP headers).
139
+ **kwargs: Extra CLI arguments.
140
+
141
+ Returns:
142
+ NormalizedResponseModel wrapping the CLI response.
143
+ """
144
+ if skip_payload_creation:
145
+ # If already a GeminiCodeRequest, wrap it; otherwise treat as dict
146
+ if isinstance(request, GeminiCodeRequest):
147
+ payload = {"request": request}
148
+ elif isinstance(request, dict) and "request" in request:
149
+ # Already in payload format
150
+ payload = request
151
+ else:
152
+ # Assume it's a raw dict that needs to be converted
153
+ req_dict = (
154
+ request if isinstance(request, dict) else request.model_dump()
155
+ )
156
+ payload = {"request": GeminiCodeRequest(**req_dict)}
157
+ else:
158
+ payload, _ = self.create_payload(request, extra_headers, **kwargs)
159
+
160
+ raw_response = await self._call(payload, {}, **kwargs)
161
+ return self.normalize_response(raw_response)
162
+
163
+ async def stream( # type: ignore[override]
164
+ self, request: dict | BaseModel, **kwargs: Any
165
+ ) -> AsyncIterator[GeminiChunk | dict | GeminiSession]:
166
+ """Stream Gemini CLI response chunks.
167
+
168
+ Yields:
169
+ GeminiChunk, dict (system messages), or GeminiSession (final)
170
+ """
171
+ payload, _ = self.create_payload(request, **kwargs)
172
+ request_obj: GeminiCodeRequest = payload["request"]
173
+
174
+ async for chunk in stream_gemini_cli(request_obj):
175
+ yield chunk
176
+
177
+ async def _call(
178
+ self,
179
+ payload: dict,
180
+ headers: dict,
181
+ **kwargs,
182
+ ) -> dict:
183
+ """Execute Gemini CLI and return raw result + session data.
184
+
185
+ Returns:
186
+ Dict with:
187
+ - raw_result: Raw result chunk from Gemini CLI
188
+ - session: Organized session data from GeminiSession
189
+ """
190
+ from krons.utils.fuzzy import to_dict
191
+
192
+ request: GeminiCodeRequest = payload["request"]
193
+ session = GeminiSession()
194
+
195
+ # Stream the Gemini response
196
+ async for chunk in stream_gemini_cli(request, session, **kwargs):
197
+ if isinstance(chunk, dict):
198
+ if chunk.get("type") == "done":
199
+ break
200
+ elif isinstance(chunk, GeminiSession):
201
+ break
202
+
203
+ # Use session.result if available (from final "result" event)
204
+ # Only fall back to combining chunk texts if no result was set
205
+ if not session.result:
206
+ texts = []
207
+ for chunk in session.chunks:
208
+ if chunk.text is not None:
209
+ texts.append(chunk.text)
210
+ session.result = "\n".join(texts)
211
+
212
+ # Populate summary if requested
213
+ if request.cli_include_summary:
214
+ session.populate_summary()
215
+
216
+ # Extract raw result chunk
217
+ raw_result_chunk = {}
218
+ for chunk in session.chunks:
219
+ if chunk.type in ("result", "response"):
220
+ raw_result_chunk = chunk.raw
221
+ break
222
+
223
+ return {
224
+ "raw_result": raw_result_chunk,
225
+ "session": to_dict(session, recursive=True),
226
+ }
227
+
228
+ def normalize_response(
229
+ self, raw_response: dict[str, Any]
230
+ ) -> NormalizedResponseModel:
231
+ """Normalize Gemini CLI response to standard format.
232
+
233
+ Args:
234
+ raw_response: Dict with:
235
+ - raw_result: Raw result chunk from Gemini CLI
236
+ - session: Organized GeminiSession data
237
+
238
+ Returns:
239
+ NormalizedResponseModel with:
240
+ - status: "success" or "error"
241
+ - data: Final result text
242
+ - raw_response: Actual raw CLI result chunk
243
+ - metadata: Organized session data
244
+ """
245
+ session = raw_response.get("session", {})
246
+ raw_cli_result = raw_response.get("raw_result", {})
247
+
248
+ text = session.get("result", "")
249
+
250
+ metadata: dict[str, Any] = {
251
+ "session_id": session.get("session_id"),
252
+ "model": session.get("model"),
253
+ "usage": session.get("usage", {}),
254
+ "total_cost_usd": session.get("total_cost_usd"),
255
+ "num_turns": session.get("num_turns"),
256
+ "duration_ms": session.get("duration_ms"),
257
+ "is_error": session.get("is_error", False),
258
+ "tool_uses": session.get("tool_uses", []),
259
+ "tool_results": session.get("tool_results", []),
260
+ "summary": session.get("summary"),
261
+ }
262
+
263
+ return NormalizedResponseModel(
264
+ status="error" if session.get("is_error") else "success",
265
+ data=text,
266
+ raw_response=raw_cli_result,
267
+ metadata=metadata,
268
+ )
@@ -0,0 +1,75 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import warnings
5
+
6
+ from krons.resource.endpoint import Endpoint
7
+
8
+ KNOWN_PROVIDERS = frozenset(
9
+ {
10
+ "anthropic",
11
+ "openai",
12
+ "groq",
13
+ "openrouter",
14
+ "nvidia_nim",
15
+ "claude_code",
16
+ "gemini_code",
17
+ }
18
+ )
19
+
20
+
21
+ def match_endpoint(
22
+ provider: str,
23
+ endpoint: str,
24
+ **kwargs,
25
+ ) -> Endpoint:
26
+ """Match provider and endpoint to appropriate Endpoint class.
27
+
28
+ Args:
29
+ provider: Provider name (e.g., "anthropic", "openai", "claude_code")
30
+ endpoint: Endpoint name (e.g., "messages", "chat/completions", "query_cli")
31
+ **kwargs: Additional kwargs passed to Endpoint constructor
32
+
33
+ Returns:
34
+ Endpoint instance configured for the provider
35
+
36
+ Example:
37
+ >>> endpoint = match_endpoint("anthropic", "messages", api_key="...")
38
+ >>> endpoint = match_endpoint("openai", "chat/completions")
39
+ >>> endpoint = match_endpoint("claude_code", "query_cli")
40
+ """
41
+ if provider == "anthropic" and ("messages" in endpoint or "chat" in endpoint):
42
+ from .anthropic_messages import AnthropicMessagesEndpoint
43
+
44
+ return AnthropicMessagesEndpoint(None, **kwargs)
45
+
46
+ if provider == "claude_code":
47
+ from .claude_code import ClaudeCodeEndpoint
48
+
49
+ return ClaudeCodeEndpoint(None, **kwargs)
50
+
51
+ if provider == "gemini_code":
52
+ from .gemini import GeminiCodeEndpoint
53
+
54
+ return GeminiCodeEndpoint(None, **kwargs)
55
+
56
+ if (
57
+ provider in ("openai", "groq", "openrouter", "nvidia_nim")
58
+ and "chat" in endpoint
59
+ ):
60
+ from .oai_chat import OAIChatEndpoint
61
+
62
+ return OAIChatEndpoint(None, provider=provider, endpoint=endpoint, **kwargs)
63
+
64
+ # OpenAI-compatible fallback with warning for unknown providers
65
+ if provider not in KNOWN_PROVIDERS:
66
+ warnings.warn(
67
+ f"Unknown provider '{provider}', falling back to OpenAI-compatible endpoint. "
68
+ f"Known providers: {sorted(KNOWN_PROVIDERS)}",
69
+ UserWarning,
70
+ stacklevel=2,
71
+ )
72
+
73
+ from .oai_chat import OAIChatEndpoint
74
+
75
+ return OAIChatEndpoint(None, provider=provider, endpoint=endpoint, **kwargs)