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.
- krons/__init__.py +49 -0
- krons/agent/__init__.py +144 -0
- krons/agent/mcps/__init__.py +14 -0
- krons/agent/mcps/loader.py +287 -0
- krons/agent/mcps/wrapper.py +799 -0
- krons/agent/message/__init__.py +20 -0
- krons/agent/message/action.py +69 -0
- krons/agent/message/assistant.py +52 -0
- krons/agent/message/common.py +49 -0
- krons/agent/message/instruction.py +130 -0
- krons/agent/message/prepare_msg.py +187 -0
- krons/agent/message/role.py +53 -0
- krons/agent/message/system.py +53 -0
- krons/agent/operations/__init__.py +82 -0
- krons/agent/operations/act.py +100 -0
- krons/agent/operations/generate.py +145 -0
- krons/agent/operations/llm_reparse.py +89 -0
- krons/agent/operations/operate.py +247 -0
- krons/agent/operations/parse.py +243 -0
- krons/agent/operations/react.py +286 -0
- krons/agent/operations/specs.py +235 -0
- krons/agent/operations/structure.py +151 -0
- krons/agent/operations/utils.py +79 -0
- krons/agent/providers/__init__.py +17 -0
- krons/agent/providers/anthropic_messages.py +146 -0
- krons/agent/providers/claude_code.py +276 -0
- krons/agent/providers/gemini.py +268 -0
- krons/agent/providers/match.py +75 -0
- krons/agent/providers/oai_chat.py +174 -0
- krons/agent/third_party/__init__.py +2 -0
- krons/agent/third_party/anthropic_models.py +154 -0
- krons/agent/third_party/claude_code.py +682 -0
- krons/agent/third_party/gemini_models.py +508 -0
- krons/agent/third_party/openai_models.py +295 -0
- krons/agent/tool.py +291 -0
- krons/core/__init__.py +127 -0
- krons/core/base/__init__.py +121 -0
- {kronos/core → krons/core/base}/broadcaster.py +7 -3
- {kronos/core → krons/core/base}/element.py +15 -7
- {kronos/core → krons/core/base}/event.py +41 -8
- {kronos/core → krons/core/base}/eventbus.py +4 -2
- {kronos/core → krons/core/base}/flow.py +14 -7
- {kronos/core → krons/core/base}/graph.py +27 -11
- {kronos/core → krons/core/base}/node.py +47 -22
- {kronos/core → krons/core/base}/pile.py +26 -12
- {kronos/core → krons/core/base}/processor.py +23 -9
- {kronos/core → krons/core/base}/progression.py +5 -3
- {kronos → krons/core}/specs/__init__.py +0 -5
- {kronos → krons/core}/specs/adapters/dataclass_field.py +16 -8
- {kronos → krons/core}/specs/adapters/pydantic_adapter.py +11 -5
- {kronos → krons/core}/specs/adapters/sql_ddl.py +16 -10
- {kronos → krons/core}/specs/catalog/__init__.py +2 -2
- {kronos → krons/core}/specs/catalog/_audit.py +3 -3
- {kronos → krons/core}/specs/catalog/_common.py +2 -2
- {kronos → krons/core}/specs/catalog/_content.py +5 -5
- {kronos → krons/core}/specs/catalog/_enforcement.py +4 -4
- {kronos → krons/core}/specs/factory.py +7 -7
- {kronos → krons/core}/specs/operable.py +9 -3
- {kronos → krons/core}/specs/protocol.py +4 -2
- {kronos → krons/core}/specs/spec.py +25 -13
- {kronos → krons/core}/types/base.py +7 -5
- {kronos → krons/core}/types/db_types.py +2 -2
- {kronos → krons/core}/types/identity.py +1 -1
- {kronos → krons}/errors.py +13 -13
- {kronos → krons}/protocols.py +9 -4
- krons/resource/__init__.py +89 -0
- {kronos/services → krons/resource}/backend.py +50 -24
- {kronos/services → krons/resource}/endpoint.py +28 -14
- {kronos/services → krons/resource}/hook.py +22 -9
- {kronos/services → krons/resource}/imodel.py +50 -32
- {kronos/services → krons/resource}/registry.py +27 -25
- {kronos/services → krons/resource}/utilities/rate_limited_executor.py +10 -6
- {kronos/services → krons/resource}/utilities/rate_limiter.py +4 -2
- {kronos/services → krons/resource}/utilities/resilience.py +17 -7
- krons/resource/utilities/token_calculator.py +185 -0
- {kronos → krons}/session/__init__.py +12 -17
- krons/session/constraints.py +70 -0
- {kronos → krons}/session/exchange.py +14 -6
- {kronos → krons}/session/message.py +4 -2
- krons/session/registry.py +35 -0
- {kronos → krons}/session/session.py +165 -174
- krons/utils/__init__.py +85 -0
- krons/utils/_function_arg_parser.py +99 -0
- krons/utils/_pythonic_function_call.py +249 -0
- {kronos → krons}/utils/_to_list.py +9 -3
- {kronos → krons}/utils/_utils.py +9 -5
- {kronos → krons}/utils/concurrency/__init__.py +38 -38
- {kronos → krons}/utils/concurrency/_async_call.py +6 -4
- {kronos → krons}/utils/concurrency/_errors.py +3 -1
- {kronos → krons}/utils/concurrency/_patterns.py +3 -1
- {kronos → krons}/utils/concurrency/_resource_tracker.py +6 -2
- krons/utils/display.py +257 -0
- {kronos → krons}/utils/fuzzy/__init__.py +6 -1
- {kronos → krons}/utils/fuzzy/_fuzzy_match.py +14 -8
- {kronos → krons}/utils/fuzzy/_string_similarity.py +3 -1
- {kronos → krons}/utils/fuzzy/_to_dict.py +3 -1
- krons/utils/schemas/__init__.py +26 -0
- krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
- krons/utils/schemas/_formatter.py +72 -0
- krons/utils/schemas/_minimal_yaml.py +151 -0
- krons/utils/schemas/_typescript.py +153 -0
- {kronos → krons}/utils/sql/_sql_validation.py +1 -1
- krons/utils/validators/__init__.py +3 -0
- krons/utils/validators/_validate_image_url.py +56 -0
- krons/work/__init__.py +126 -0
- krons/work/engine.py +333 -0
- krons/work/form.py +305 -0
- {kronos → krons/work}/operations/__init__.py +7 -4
- {kronos → krons/work}/operations/builder.py +4 -4
- {kronos/enforcement → krons/work/operations}/context.py +37 -6
- {kronos → krons/work}/operations/flow.py +17 -9
- krons/work/operations/node.py +103 -0
- krons/work/operations/registry.py +103 -0
- {kronos/specs → krons/work}/phrase.py +131 -14
- {kronos/enforcement → krons/work}/policy.py +3 -3
- krons/work/report.py +268 -0
- krons/work/rules/__init__.py +47 -0
- {kronos/enforcement → krons/work/rules}/common/boolean.py +3 -1
- {kronos/enforcement → krons/work/rules}/common/choice.py +9 -3
- {kronos/enforcement → krons/work/rules}/common/number.py +3 -1
- {kronos/enforcement → krons/work/rules}/common/string.py +9 -3
- {kronos/enforcement → krons/work/rules}/rule.py +2 -2
- {kronos/enforcement → krons/work/rules}/validator.py +21 -6
- {kronos/enforcement → krons/work}/service.py +16 -7
- krons/work/worker.py +266 -0
- {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/METADATA +19 -5
- krons-0.2.0.dist-info/RECORD +154 -0
- kronos/core/__init__.py +0 -145
- kronos/enforcement/__init__.py +0 -57
- kronos/operations/node.py +0 -101
- kronos/operations/registry.py +0 -92
- kronos/services/__init__.py +0 -81
- kronos/specs/adapters/__init__.py +0 -0
- kronos/utils/__init__.py +0 -40
- krons-0.1.0.dist-info/RECORD +0 -101
- {kronos → krons/core/specs/adapters}/__init__.py +0 -0
- {kronos → krons/core}/specs/adapters/_utils.py +0 -0
- {kronos → krons/core}/specs/adapters/factory.py +0 -0
- {kronos → krons/core}/types/__init__.py +0 -0
- {kronos → krons/core}/types/_sentinel.py +0 -0
- {kronos → krons}/py.typed +0 -0
- {kronos/services → krons/resource}/utilities/__init__.py +0 -0
- {kronos/services → krons/resource}/utilities/header_factory.py +0 -0
- {kronos → krons}/utils/_hash.py +0 -0
- {kronos → krons}/utils/_json_dump.py +0 -0
- {kronos → krons}/utils/_lazy_init.py +0 -0
- {kronos → krons}/utils/_to_num.py +0 -0
- {kronos → krons}/utils/concurrency/_cancel.py +0 -0
- {kronos → krons}/utils/concurrency/_primitives.py +0 -0
- {kronos → krons}/utils/concurrency/_priority_queue.py +0 -0
- {kronos → krons}/utils/concurrency/_run_async.py +0 -0
- {kronos → krons}/utils/concurrency/_task.py +0 -0
- {kronos → krons}/utils/concurrency/_utils.py +0 -0
- {kronos → krons}/utils/fuzzy/_extract_json.py +0 -0
- {kronos → krons}/utils/fuzzy/_fuzzy_json.py +0 -0
- {kronos → krons}/utils/sql/__init__.py +0 -0
- {kronos/enforcement → krons/work/rules}/common/__init__.py +0 -0
- {kronos/enforcement → krons/work/rules}/common/mapping.py +0 -0
- {kronos/enforcement → krons/work/rules}/common/model.py +0 -0
- {kronos/enforcement → krons/work/rules}/registry.py +0 -0
- {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
- {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)
|