citrus-code 0.1.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.
- citrus_code/__init__.py +0 -0
- citrus_code/__main__.py +5 -0
- citrus_code/agent/__init__.py +0 -0
- citrus_code/agent/agent.py +255 -0
- citrus_code/agent/cancellation.py +66 -0
- citrus_code/agent/context.py +20 -0
- citrus_code/agent/events.py +97 -0
- citrus_code/agent/hooks.py +37 -0
- citrus_code/agent/loop.py +429 -0
- citrus_code/agent/message_queue.py +44 -0
- citrus_code/agent/state.py +13 -0
- citrus_code/agent/tool_runner.py +161 -0
- citrus_code/agent/types.py +34 -0
- citrus_code/ai/__init__.py +0 -0
- citrus_code/ai/completion.py +30 -0
- citrus_code/ai/events.py +78 -0
- citrus_code/ai/provider.py +20 -0
- citrus_code/ai/providers/__init__.py +0 -0
- citrus_code/ai/providers/chat_completions.py +503 -0
- citrus_code/ai/providers/chat_stream_types.py +47 -0
- citrus_code/ai/providers/deepseek.py +104 -0
- citrus_code/ai/providers/fake.py +101 -0
- citrus_code/ai/providers/tool_call_buffer.py +73 -0
- citrus_code/ai/registry.py +40 -0
- citrus_code/ai/types.py +142 -0
- citrus_code/cli/__init__.py +5 -0
- citrus_code/cli/main.py +258 -0
- citrus_code/cli/preview.py +55 -0
- citrus_code/cli/prompts.py +52 -0
- citrus_code/cli/render.py +306 -0
- citrus_code/cli/session_files.py +75 -0
- citrus_code/cli/terminal.py +280 -0
- citrus_code/cli/theme.py +51 -0
- citrus_code/coding/__init__.py +0 -0
- citrus_code/coding/coding_session.py +287 -0
- citrus_code/coding/compaction/__init__.py +5 -0
- citrus_code/coding/compaction/controller.py +168 -0
- citrus_code/coding/compaction/summarize.py +125 -0
- citrus_code/coding/compaction/tokens.py +48 -0
- citrus_code/coding/context_hooks.py +41 -0
- citrus_code/coding/history.py +47 -0
- citrus_code/coding/project_context.py +44 -0
- citrus_code/coding/session/__init__.py +30 -0
- citrus_code/coding/session/manager.py +200 -0
- citrus_code/coding/session/projection.py +50 -0
- citrus_code/coding/session/recovery.py +33 -0
- citrus_code/coding/session/storage.py +138 -0
- citrus_code/coding/session/types.py +108 -0
- citrus_code/coding/tools/__init__.py +35 -0
- citrus_code/coding/tools/bash.py +5 -0
- citrus_code/coding/tools/edit.py +101 -0
- citrus_code/coding/tools/edit_text.py +172 -0
- citrus_code/coding/tools/factory.py +57 -0
- citrus_code/coding/tools/find.py +86 -0
- citrus_code/coding/tools/grep.py +151 -0
- citrus_code/coding/tools/ls.py +61 -0
- citrus_code/coding/tools/mutation.py +39 -0
- citrus_code/coding/tools/paths.py +11 -0
- citrus_code/coding/tools/powershell.py +5 -0
- citrus_code/coding/tools/processes.py +103 -0
- citrus_code/coding/tools/read.py +133 -0
- citrus_code/coding/tools/shell.py +218 -0
- citrus_code/coding/tools/truncate.py +87 -0
- citrus_code/coding/tools/write.py +94 -0
- citrus_code/config.py +34 -0
- citrus_code-0.1.0.dist-info/METADATA +412 -0
- citrus_code-0.1.0.dist-info/RECORD +70 -0
- citrus_code-0.1.0.dist-info/WHEEL +4 -0
- citrus_code-0.1.0.dist-info/entry_points.txt +3 -0
- citrus_code-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from pydantic import SecretStr
|
|
5
|
+
|
|
6
|
+
from ..events import AssistantMessageEvent
|
|
7
|
+
from ..types import (
|
|
8
|
+
ImageContent, Message, ModelContext, ModelOptions, ModelSpec,
|
|
9
|
+
TextContent, ToolResultMessage, UserMessage,
|
|
10
|
+
)
|
|
11
|
+
from .chat_completions import (
|
|
12
|
+
build_chat_request,
|
|
13
|
+
stream_chat_data,
|
|
14
|
+
stream_assistant_events,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEEPSEEK_VISION_MODEL_IDS = frozenset({
|
|
19
|
+
"deepseek-flash",
|
|
20
|
+
"deepseek-v4-flash",
|
|
21
|
+
"deepseek-v4-flash-vision-exp",
|
|
22
|
+
})
|
|
23
|
+
DEEPSEEK_IMAGE_MIME_TYPES = frozenset({
|
|
24
|
+
"image/png", "image/jpeg", "image/gif", "image/webp",
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DeepSeekProvider:
|
|
29
|
+
id: str = "deepseek"
|
|
30
|
+
name: str = "DeepSeek"
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
client: httpx.AsyncClient,
|
|
35
|
+
model: ModelSpec,
|
|
36
|
+
api_key: SecretStr,
|
|
37
|
+
) -> None:
|
|
38
|
+
if model.provider != self.id:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"The model does not belong to this provider"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if model.api != "chat-completions":
|
|
44
|
+
raise ValueError(
|
|
45
|
+
"This provider requires the chat-completions API"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
self._client = client
|
|
49
|
+
self._model = model
|
|
50
|
+
self._api_key = api_key
|
|
51
|
+
|
|
52
|
+
def get_models(self) -> list[ModelSpec]:
|
|
53
|
+
return [self._model]
|
|
54
|
+
|
|
55
|
+
def stream(
|
|
56
|
+
self,
|
|
57
|
+
model: ModelSpec,
|
|
58
|
+
context: ModelContext,
|
|
59
|
+
options: ModelOptions | None = None,
|
|
60
|
+
) -> AsyncIterator[AssistantMessageEvent]:
|
|
61
|
+
if model != self._model:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"The requested model is not registered "
|
|
64
|
+
"with this provider"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if model.supports_images:
|
|
68
|
+
messages: list[Message] = []
|
|
69
|
+
for message in context.messages:
|
|
70
|
+
if isinstance(message, (UserMessage, ToolResultMessage)) and not isinstance(message.content, str):
|
|
71
|
+
content = [
|
|
72
|
+
TextContent(text=(
|
|
73
|
+
f"[图片未发送:DeepSeek 不支持 {block.mime_type};"
|
|
74
|
+
"请先转换为 PNG、JPEG、GIF 或 WebP。]"
|
|
75
|
+
))
|
|
76
|
+
if isinstance(block, ImageContent) and block.mime_type not in DEEPSEEK_IMAGE_MIME_TYPES
|
|
77
|
+
else block
|
|
78
|
+
for block in message.content
|
|
79
|
+
]
|
|
80
|
+
message = message.model_copy(update={"content": content})
|
|
81
|
+
messages.append(message)
|
|
82
|
+
# Transform only the request. An unsupported old attachment must
|
|
83
|
+
# not make every later request in the session fail with HTTP 400.
|
|
84
|
+
context = ModelContext(messages=messages, tools=context.tools)
|
|
85
|
+
|
|
86
|
+
body = build_chat_request(
|
|
87
|
+
model=model,
|
|
88
|
+
context=context,
|
|
89
|
+
options=options,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
body["thinking"] = {"type": "disabled"}
|
|
93
|
+
|
|
94
|
+
data_stream = stream_chat_data(
|
|
95
|
+
client=self._client,
|
|
96
|
+
model=model,
|
|
97
|
+
api_key=self._api_key,
|
|
98
|
+
body=body,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return stream_assistant_events(
|
|
102
|
+
model=model,
|
|
103
|
+
data_stream=data_stream,
|
|
104
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
|
|
5
|
+
from ..events import (
|
|
6
|
+
AssistantMessageEvent,
|
|
7
|
+
DoneEvent,
|
|
8
|
+
StartEvent,
|
|
9
|
+
TextDeltaEvent,
|
|
10
|
+
TextEndEvent,
|
|
11
|
+
TextStartEvent,
|
|
12
|
+
)
|
|
13
|
+
from ..types import (
|
|
14
|
+
AssistantMessage,
|
|
15
|
+
ModelContext,
|
|
16
|
+
ModelOptions,
|
|
17
|
+
ModelSpec,
|
|
18
|
+
TextContent,
|
|
19
|
+
)
|
|
20
|
+
class FakeProvider:
|
|
21
|
+
id: str = "fake"
|
|
22
|
+
name: str = "Fake Provider"
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
chunks: list[str],
|
|
27
|
+
delay_seconds: float=0.2
|
|
28
|
+
) -> None:
|
|
29
|
+
if delay_seconds < 0:
|
|
30
|
+
raise ValueError("delay_seconds must not be negative")
|
|
31
|
+
|
|
32
|
+
self._chunks = chunks.copy()
|
|
33
|
+
self._delay_seconds = delay_seconds
|
|
34
|
+
|
|
35
|
+
self._model = ModelSpec(
|
|
36
|
+
id="fake-text",
|
|
37
|
+
name="Fake Text Model",
|
|
38
|
+
provider=self.id,
|
|
39
|
+
api="fake",
|
|
40
|
+
base_url="fake://local",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def get_models(self) -> list[ModelSpec]:
|
|
44
|
+
return [self._model]
|
|
45
|
+
|
|
46
|
+
async def stream(
|
|
47
|
+
self,
|
|
48
|
+
model: ModelSpec,
|
|
49
|
+
context: ModelContext,
|
|
50
|
+
options: ModelOptions | None = None,
|
|
51
|
+
) -> AsyncIterator[AssistantMessageEvent]:
|
|
52
|
+
if(
|
|
53
|
+
model.provider != self.id
|
|
54
|
+
or model.id !=self._model.id
|
|
55
|
+
or model.api !=self._model.api
|
|
56
|
+
):
|
|
57
|
+
raise ValueError("The request model is not supported")
|
|
58
|
+
|
|
59
|
+
message = AssistantMessage(
|
|
60
|
+
content=[],
|
|
61
|
+
api=model.api,
|
|
62
|
+
provider=model.provider,
|
|
63
|
+
model=model.id,
|
|
64
|
+
usage=None,
|
|
65
|
+
stop_reason="pending",
|
|
66
|
+
timestamp=int(time.time()*1000),
|
|
67
|
+
)
|
|
68
|
+
yield StartEvent(partial=message)
|
|
69
|
+
|
|
70
|
+
text_block = TextContent(text="")
|
|
71
|
+
message.content.append(text_block)
|
|
72
|
+
|
|
73
|
+
yield TextStartEvent(
|
|
74
|
+
content_index=0,
|
|
75
|
+
partial=message,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
for chunk in self._chunks:
|
|
79
|
+
await asyncio.sleep(self._delay_seconds)
|
|
80
|
+
|
|
81
|
+
text_block.text += chunk
|
|
82
|
+
|
|
83
|
+
yield TextDeltaEvent(
|
|
84
|
+
content_index=0,
|
|
85
|
+
delta=chunk,
|
|
86
|
+
partial=message,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
yield TextEndEvent(
|
|
90
|
+
content_index=0,
|
|
91
|
+
content=text_block.text,
|
|
92
|
+
partial=message,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
message.stop_reason = "stop"
|
|
96
|
+
|
|
97
|
+
yield DoneEvent(
|
|
98
|
+
reason="stop",
|
|
99
|
+
message=message,
|
|
100
|
+
)
|
|
101
|
+
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from pydantic import JsonValue, TypeAdapter
|
|
2
|
+
|
|
3
|
+
from ..types import ToolCall
|
|
4
|
+
from .chat_stream_types import ChatToolCallDelta
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
ARGUMENTS_ADAPTER = TypeAdapter(dict[str, JsonValue])
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ToolCallBuffer:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
tool_index: int,
|
|
14
|
+
content_index: int
|
|
15
|
+
) -> None:
|
|
16
|
+
self.tool_index = tool_index
|
|
17
|
+
self.content_index = content_index
|
|
18
|
+
self.arguments_text = ""
|
|
19
|
+
|
|
20
|
+
self.tool_call = ToolCall(
|
|
21
|
+
id="",
|
|
22
|
+
name="",
|
|
23
|
+
arguments={},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def append(self, delta: ChatToolCallDelta) -> str:
|
|
28
|
+
if delta.index != self.tool_index:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
"The delta belongs to a different tool call"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if delta.id is not None:
|
|
34
|
+
if self.tool_call.id and self.tool_call.id !=delta.id:
|
|
35
|
+
raise ValueError("The tool call ID changed")
|
|
36
|
+
|
|
37
|
+
self.tool_call.id = delta.id
|
|
38
|
+
|
|
39
|
+
if delta.function is None:
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
if delta.function.name is not None:
|
|
43
|
+
name = delta.function.name
|
|
44
|
+
|
|
45
|
+
if self.tool_call.name and self.tool_call.name != name:
|
|
46
|
+
raise ValueError("The tool name changed")
|
|
47
|
+
|
|
48
|
+
self.tool_call.name = name
|
|
49
|
+
|
|
50
|
+
fragment = delta.function.arguments
|
|
51
|
+
|
|
52
|
+
if fragment is None:
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
self.arguments_text += fragment
|
|
56
|
+
|
|
57
|
+
return fragment
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def finish(self) -> ToolCall:
|
|
61
|
+
if not self.tool_call.id:
|
|
62
|
+
raise ValueError("The tool call ID is missing")
|
|
63
|
+
|
|
64
|
+
if not self.tool_call.name:
|
|
65
|
+
raise ValueError("The tool name is missing")
|
|
66
|
+
|
|
67
|
+
arguments = ARGUMENTS_ADAPTER.validate_json(
|
|
68
|
+
self.arguments_text
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
self.tool_call.arguments = arguments
|
|
72
|
+
|
|
73
|
+
return self.tool_call
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from .provider import Provider
|
|
2
|
+
from .types import ModelSpec
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ProviderRegistry:
|
|
6
|
+
def __init__(self) -> None:
|
|
7
|
+
self._providers: dict[str,Provider] = {}
|
|
8
|
+
|
|
9
|
+
def register(self,provider: Provider) -> None:
|
|
10
|
+
if provider.id in self._providers:
|
|
11
|
+
raise ValueError(
|
|
12
|
+
f"Provider already registered: {provider.id}"
|
|
13
|
+
)
|
|
14
|
+
self._providers[provider.id]=provider
|
|
15
|
+
|
|
16
|
+
def get_provider(self,provider_id: str) -> Provider:
|
|
17
|
+
provider = self._providers.get(provider_id)
|
|
18
|
+
|
|
19
|
+
if provider is None:
|
|
20
|
+
raise KeyError(
|
|
21
|
+
f"Provider not found: {provider_id}"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
return provider
|
|
25
|
+
|
|
26
|
+
def get_model(
|
|
27
|
+
self,
|
|
28
|
+
provider_id: str,
|
|
29
|
+
model_id: str,
|
|
30
|
+
) -> ModelSpec:
|
|
31
|
+
provider = self.get_provider(provider_id)
|
|
32
|
+
|
|
33
|
+
for model in provider.get_models():
|
|
34
|
+
if model.id == model_id:
|
|
35
|
+
return model
|
|
36
|
+
|
|
37
|
+
raise KeyError(
|
|
38
|
+
f"Model not found: {provider_id}/{model_id}"
|
|
39
|
+
)
|
|
40
|
+
|
citrus_code/ai/types.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from typing import Annotated,Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel,ConfigDict,Field,JsonValue
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TextContent(BaseModel):
|
|
7
|
+
model_config = ConfigDict(extra="forbid")
|
|
8
|
+
|
|
9
|
+
type: Literal["text"] = "text"
|
|
10
|
+
text: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ImageContent(BaseModel):
|
|
14
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
15
|
+
|
|
16
|
+
type: Literal["image"] = "image"
|
|
17
|
+
data: str
|
|
18
|
+
mime_type: str = Field(alias="mimeType")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ToolContent = Annotated[
|
|
22
|
+
TextContent | ImageContent,
|
|
23
|
+
Field(discriminator="type"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ToolCall(BaseModel):
|
|
28
|
+
model_config = ConfigDict(extra="forbid")
|
|
29
|
+
|
|
30
|
+
type: Literal["toolCall"]="toolCall"
|
|
31
|
+
id: str
|
|
32
|
+
name: str
|
|
33
|
+
arguments: dict[str,JsonValue]
|
|
34
|
+
|
|
35
|
+
class SystemMessage(BaseModel):
|
|
36
|
+
model_config = ConfigDict(extra="forbid")
|
|
37
|
+
role: Literal["system"] = "system"
|
|
38
|
+
content: str | list[TextContent]
|
|
39
|
+
timestamp: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UserMessage(BaseModel):
|
|
43
|
+
model_config = ConfigDict(extra="forbid")
|
|
44
|
+
|
|
45
|
+
role: Literal["user"] = "user"
|
|
46
|
+
content: str | list[ToolContent]
|
|
47
|
+
timestamp: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ToolResultMessage(BaseModel):
|
|
51
|
+
model_config = ConfigDict(extra="forbid")
|
|
52
|
+
|
|
53
|
+
role: Literal["toolResult"] = "toolResult"
|
|
54
|
+
tool_call_id: str
|
|
55
|
+
tool_name: str
|
|
56
|
+
content: list[ToolContent]
|
|
57
|
+
is_error: bool
|
|
58
|
+
timestamp: int
|
|
59
|
+
|
|
60
|
+
StopReason = Literal[
|
|
61
|
+
"pending",
|
|
62
|
+
"stop",
|
|
63
|
+
"length",
|
|
64
|
+
"toolUse",
|
|
65
|
+
"error",
|
|
66
|
+
"aborted",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Usage(BaseModel):
|
|
71
|
+
model_config = ConfigDict(extra="forbid")
|
|
72
|
+
|
|
73
|
+
input_tokens: int = Field(ge=0)
|
|
74
|
+
output_tokens: int = Field(ge=0)
|
|
75
|
+
total_tokens: int = Field(ge=0)
|
|
76
|
+
|
|
77
|
+
cache_read_tokens: int | None = Field(default=None,ge=0)
|
|
78
|
+
cache_write_tokens: int | None = Field(default=None,ge=0)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
AssistantContent = Annotated[
|
|
82
|
+
TextContent | ToolCall,
|
|
83
|
+
Field(discriminator="type"),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AssistantMessage(BaseModel):
|
|
88
|
+
model_config = ConfigDict(extra="forbid")
|
|
89
|
+
|
|
90
|
+
role: Literal["assistant"] = "assistant"
|
|
91
|
+
content: list[AssistantContent]
|
|
92
|
+
|
|
93
|
+
api: str
|
|
94
|
+
provider: str
|
|
95
|
+
model: str
|
|
96
|
+
|
|
97
|
+
usage: Usage | None
|
|
98
|
+
stop_reason: StopReason
|
|
99
|
+
error_message: str | None = None
|
|
100
|
+
timestamp: int
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
Message = Annotated[
|
|
104
|
+
SystemMessage | UserMessage | AssistantMessage | ToolResultMessage,
|
|
105
|
+
Field(discriminator="role"),
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ModelSpec(BaseModel):
|
|
110
|
+
model_config = ConfigDict(extra="forbid")
|
|
111
|
+
|
|
112
|
+
id: str
|
|
113
|
+
name: str
|
|
114
|
+
provider: str
|
|
115
|
+
api: str
|
|
116
|
+
base_url: str
|
|
117
|
+
|
|
118
|
+
context_window: int | None = Field(default=None,gt=0)
|
|
119
|
+
max_output_tokens: int | None = Field(default=None,gt=0)
|
|
120
|
+
supports_images: bool = False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ToolDefinition(BaseModel):
|
|
124
|
+
model_config = ConfigDict(extra="forbid")
|
|
125
|
+
|
|
126
|
+
name: str
|
|
127
|
+
description: str
|
|
128
|
+
parameters: dict[str,JsonValue]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ModelContext(BaseModel):
|
|
132
|
+
model_config = ConfigDict(extra="forbid")
|
|
133
|
+
|
|
134
|
+
messages: list[Message]
|
|
135
|
+
tools: list[ToolDefinition] = Field(default_factory=list)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ModelOptions(BaseModel):
|
|
139
|
+
model_config = ConfigDict(extra="forbid")
|
|
140
|
+
|
|
141
|
+
max_output_tokens: int | None = Field(default=None,gt=0)
|
|
142
|
+
temperature: float | None = None
|