gemi-cli 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.
- gemi/__init__.py +1 -0
- gemi/agent/__init__.py +0 -0
- gemi/agent/loop.py +594 -0
- gemi/agent/tools.py +571 -0
- gemi/compaction.py +67 -0
- gemi/config.py +53 -0
- gemi/keys/__init__.py +0 -0
- gemi/keys/manager.py +265 -0
- gemi/keys/store.py +92 -0
- gemi/main.py +426 -0
- gemi/providers/__init__.py +0 -0
- gemi/providers/base.py +35 -0
- gemi/providers/gemini.py +126 -0
- gemi/providers/ollama.py +72 -0
- gemi/providers/openai_compat.py +140 -0
- gemi/registry.py +201 -0
- gemi/sessions.py +84 -0
- gemi/ui.py +387 -0
- gemi_cli-0.1.0.dist-info/METADATA +462 -0
- gemi_cli-0.1.0.dist-info/RECORD +22 -0
- gemi_cli-0.1.0.dist-info/WHEEL +4 -0
- gemi_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import AsyncIterator
|
|
3
|
+
|
|
4
|
+
from openai import AsyncOpenAI
|
|
5
|
+
|
|
6
|
+
from gemi.providers.base import BaseProvider, Chunk, Message
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OpenAICompatProvider(BaseProvider):
|
|
10
|
+
def __init__(self, api_key: str, base_url: str = "https://api.openai.com/v1", provider_name: str = ""):
|
|
11
|
+
extra_headers = {}
|
|
12
|
+
if "openrouter" in base_url:
|
|
13
|
+
extra_headers["HTTP-Referer"] = "https://github.com/gemi-cli/gemi"
|
|
14
|
+
extra_headers["X-Title"] = "gemi"
|
|
15
|
+
self.client = AsyncOpenAI(
|
|
16
|
+
api_key=api_key,
|
|
17
|
+
base_url=base_url,
|
|
18
|
+
default_headers=extra_headers or None,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def update_key(self, api_key: str):
|
|
22
|
+
self.client.api_key = api_key
|
|
23
|
+
|
|
24
|
+
async def chat(
|
|
25
|
+
self,
|
|
26
|
+
messages: list[Message],
|
|
27
|
+
tools: list[dict] | None = None,
|
|
28
|
+
model: str | None = None,
|
|
29
|
+
stream: bool = True,
|
|
30
|
+
) -> AsyncIterator[Chunk]:
|
|
31
|
+
model = model or "gpt-4o-mini"
|
|
32
|
+
formatted_messages = self._build_messages(messages)
|
|
33
|
+
formatted_tools = self.format_tools(tools) if tools else None
|
|
34
|
+
|
|
35
|
+
kwargs = {"model": model, "messages": formatted_messages}
|
|
36
|
+
if formatted_tools:
|
|
37
|
+
kwargs["tools"] = formatted_tools
|
|
38
|
+
|
|
39
|
+
if stream:
|
|
40
|
+
kwargs["stream"] = True
|
|
41
|
+
response = await self.client.chat.completions.create(**kwargs)
|
|
42
|
+
tool_calls_acc = {}
|
|
43
|
+
async for chunk in response:
|
|
44
|
+
if not chunk.choices:
|
|
45
|
+
continue
|
|
46
|
+
choice = chunk.choices[0]
|
|
47
|
+
if choice.finish_reason == "error":
|
|
48
|
+
error_msg = choice.delta.content if choice.delta and choice.delta.content else "Provider returned error"
|
|
49
|
+
raise Exception(f"Provider returned error: {error_msg}")
|
|
50
|
+
delta = choice.delta
|
|
51
|
+
if not delta:
|
|
52
|
+
continue
|
|
53
|
+
if delta.content:
|
|
54
|
+
yield Chunk(text=delta.content)
|
|
55
|
+
if delta.tool_calls:
|
|
56
|
+
for tc in delta.tool_calls:
|
|
57
|
+
idx = tc.index
|
|
58
|
+
if idx not in tool_calls_acc:
|
|
59
|
+
tool_calls_acc[idx] = {
|
|
60
|
+
"id": tc.id or "",
|
|
61
|
+
"function": {"name": "", "arguments": ""},
|
|
62
|
+
}
|
|
63
|
+
if tc.id:
|
|
64
|
+
tool_calls_acc[idx]["id"] = tc.id
|
|
65
|
+
if tc.function:
|
|
66
|
+
if tc.function.name:
|
|
67
|
+
tool_calls_acc[idx]["function"]["name"] = tc.function.name
|
|
68
|
+
if tc.function.arguments:
|
|
69
|
+
tool_calls_acc[idx]["function"]["arguments"] += tc.function.arguments
|
|
70
|
+
|
|
71
|
+
if tool_calls_acc:
|
|
72
|
+
calls = []
|
|
73
|
+
for tc_data in tool_calls_acc.values():
|
|
74
|
+
try:
|
|
75
|
+
tc_data["function"]["arguments"] = json.loads(
|
|
76
|
+
tc_data["function"]["arguments"]
|
|
77
|
+
)
|
|
78
|
+
except json.JSONDecodeError:
|
|
79
|
+
pass
|
|
80
|
+
calls.append(tc_data)
|
|
81
|
+
yield Chunk(tool_calls=calls)
|
|
82
|
+
else:
|
|
83
|
+
response = await self.client.chat.completions.create(**kwargs)
|
|
84
|
+
choice = response.choices[0]
|
|
85
|
+
if choice.message.content:
|
|
86
|
+
yield Chunk(text=choice.message.content)
|
|
87
|
+
if choice.message.tool_calls:
|
|
88
|
+
calls = []
|
|
89
|
+
for tc in choice.message.tool_calls:
|
|
90
|
+
args = tc.function.arguments
|
|
91
|
+
try:
|
|
92
|
+
args = json.loads(args)
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
pass
|
|
95
|
+
calls.append({
|
|
96
|
+
"id": tc.id,
|
|
97
|
+
"function": {
|
|
98
|
+
"name": tc.function.name,
|
|
99
|
+
"arguments": args,
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
yield Chunk(tool_calls=calls)
|
|
103
|
+
|
|
104
|
+
def _build_messages(self, messages: list[Message]) -> list[dict]:
|
|
105
|
+
result = []
|
|
106
|
+
for msg in messages:
|
|
107
|
+
entry = {"role": msg.role, "content": msg.content}
|
|
108
|
+
if msg.tool_calls:
|
|
109
|
+
entry["tool_calls"] = [
|
|
110
|
+
{
|
|
111
|
+
"id": tc["id"],
|
|
112
|
+
"type": "function",
|
|
113
|
+
"function": {
|
|
114
|
+
"name": tc["function"]["name"],
|
|
115
|
+
"arguments": json.dumps(tc["function"]["arguments"])
|
|
116
|
+
if isinstance(tc["function"]["arguments"], dict)
|
|
117
|
+
else tc["function"]["arguments"],
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
for tc in msg.tool_calls
|
|
121
|
+
]
|
|
122
|
+
if msg.tool_call_id:
|
|
123
|
+
entry["tool_call_id"] = msg.tool_call_id
|
|
124
|
+
if msg.name:
|
|
125
|
+
entry["name"] = msg.name
|
|
126
|
+
result.append(entry)
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
def format_tools(self, tools: list[dict]) -> list[dict]:
|
|
130
|
+
formatted = []
|
|
131
|
+
for tool in tools:
|
|
132
|
+
formatted.append({
|
|
133
|
+
"type": "function",
|
|
134
|
+
"function": {
|
|
135
|
+
"name": tool["name"],
|
|
136
|
+
"description": tool.get("description", ""),
|
|
137
|
+
"parameters": tool.get("parameters", {}),
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
return formatted
|
gemi/registry.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
PROVIDERS = {
|
|
2
|
+
"gemini": {
|
|
3
|
+
"name": "Google Gemini",
|
|
4
|
+
"type": "gemini",
|
|
5
|
+
"base_url": None,
|
|
6
|
+
"default_model": "gemini-2.5-flash",
|
|
7
|
+
"models": [
|
|
8
|
+
"gemini-2.5-flash",
|
|
9
|
+
"gemini-2.5-flash-lite",
|
|
10
|
+
"gemini-2.5-pro",
|
|
11
|
+
],
|
|
12
|
+
"free_tier": True,
|
|
13
|
+
"key_url": "https://aistudio.google.com/apikey",
|
|
14
|
+
"key_prefix": "AIza",
|
|
15
|
+
"needs_key": True,
|
|
16
|
+
"context_window": 1_000_000,
|
|
17
|
+
},
|
|
18
|
+
"groq": {
|
|
19
|
+
"name": "Groq",
|
|
20
|
+
"type": "openai_compat",
|
|
21
|
+
"base_url": "https://api.groq.com/openai/v1",
|
|
22
|
+
"default_model": "llama-3.3-70b-versatile",
|
|
23
|
+
"models": [
|
|
24
|
+
"llama-3.3-70b-versatile",
|
|
25
|
+
"llama-3.1-8b-instant",
|
|
26
|
+
"meta-llama/llama-4-scout-17b-16e-instruct",
|
|
27
|
+
"openai/gpt-oss-120b",
|
|
28
|
+
"openai/gpt-oss-20b",
|
|
29
|
+
"qwen/qwen3-32b",
|
|
30
|
+
"allam-2-7b",
|
|
31
|
+
],
|
|
32
|
+
"free_tier": True,
|
|
33
|
+
"key_url": "https://console.groq.com/keys",
|
|
34
|
+
"key_prefix": "gsk_",
|
|
35
|
+
"needs_key": True,
|
|
36
|
+
"context_window": 128_000,
|
|
37
|
+
},
|
|
38
|
+
"deepseek": {
|
|
39
|
+
"name": "DeepSeek",
|
|
40
|
+
"type": "openai_compat",
|
|
41
|
+
"base_url": "https://api.deepseek.com",
|
|
42
|
+
"default_model": "deepseek-chat",
|
|
43
|
+
"models": [
|
|
44
|
+
"deepseek-chat",
|
|
45
|
+
"deepseek-reasoner",
|
|
46
|
+
],
|
|
47
|
+
"free_tier": False,
|
|
48
|
+
"key_url": "https://platform.deepseek.com/api_keys",
|
|
49
|
+
"key_prefix": "sk-",
|
|
50
|
+
"needs_key": True,
|
|
51
|
+
"context_window": 128_000,
|
|
52
|
+
},
|
|
53
|
+
"openrouter": {
|
|
54
|
+
"name": "OpenRouter",
|
|
55
|
+
"type": "openai_compat",
|
|
56
|
+
"base_url": "https://openrouter.ai/api/v1",
|
|
57
|
+
"default_model": "qwen/qwen3-coder:free",
|
|
58
|
+
"models": [
|
|
59
|
+
"qwen/qwen3-coder:free",
|
|
60
|
+
"nvidia/nemotron-3-ultra-550b-a55b:free",
|
|
61
|
+
"nvidia/nemotron-3-super-120b-a12b:free",
|
|
62
|
+
"openai/gpt-oss-120b:free",
|
|
63
|
+
"google/gemma-4-31b-it:free",
|
|
64
|
+
"qwen/qwen3-next-80b-a3b-instruct:free",
|
|
65
|
+
"moonshotai/kimi-k2.6:free",
|
|
66
|
+
"meta-llama/llama-3.3-70b-instruct:free",
|
|
67
|
+
"nousresearch/hermes-3-llama-3.1-405b:free",
|
|
68
|
+
"poolside/laguna-m.1:free",
|
|
69
|
+
"z-ai/glm-4.5-air:free",
|
|
70
|
+
],
|
|
71
|
+
"free_tier": True,
|
|
72
|
+
"key_url": "https://openrouter.ai/keys",
|
|
73
|
+
"key_prefix": "sk-or-",
|
|
74
|
+
"needs_key": True,
|
|
75
|
+
"context_window": 1_000_000,
|
|
76
|
+
},
|
|
77
|
+
"mistral": {
|
|
78
|
+
"name": "Mistral AI",
|
|
79
|
+
"type": "openai_compat",
|
|
80
|
+
"base_url": "https://api.mistral.ai/v1",
|
|
81
|
+
"default_model": "codestral-latest",
|
|
82
|
+
"models": [
|
|
83
|
+
"codestral-latest",
|
|
84
|
+
"mistral-large-latest",
|
|
85
|
+
"mistral-small-latest",
|
|
86
|
+
"pixtral-large-latest",
|
|
87
|
+
],
|
|
88
|
+
"free_tier": True,
|
|
89
|
+
"key_url": "https://console.mistral.ai/api-keys",
|
|
90
|
+
"key_prefix": "",
|
|
91
|
+
"needs_key": True,
|
|
92
|
+
"context_window": 128_000,
|
|
93
|
+
},
|
|
94
|
+
"openai": {
|
|
95
|
+
"name": "OpenAI",
|
|
96
|
+
"type": "openai_compat",
|
|
97
|
+
"base_url": "https://api.openai.com/v1",
|
|
98
|
+
"default_model": "gpt-4o-mini",
|
|
99
|
+
"models": [
|
|
100
|
+
"gpt-4o-mini",
|
|
101
|
+
"gpt-4o",
|
|
102
|
+
"gpt-4.1-mini",
|
|
103
|
+
"gpt-4.1-nano",
|
|
104
|
+
],
|
|
105
|
+
"free_tier": False,
|
|
106
|
+
"key_url": "https://platform.openai.com/api-keys",
|
|
107
|
+
"key_prefix": "sk-",
|
|
108
|
+
"needs_key": True,
|
|
109
|
+
"context_window": 128_000,
|
|
110
|
+
},
|
|
111
|
+
"together": {
|
|
112
|
+
"name": "Together AI",
|
|
113
|
+
"type": "openai_compat",
|
|
114
|
+
"base_url": "https://api.together.xyz/v1",
|
|
115
|
+
"default_model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
|
116
|
+
"models": [
|
|
117
|
+
"meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
|
118
|
+
"deepseek-ai/DeepSeek-R1",
|
|
119
|
+
"Qwen/Qwen2.5-Coder-32B-Instruct",
|
|
120
|
+
],
|
|
121
|
+
"free_tier": False,
|
|
122
|
+
"key_url": "https://api.together.ai/settings/api-keys",
|
|
123
|
+
"key_prefix": "",
|
|
124
|
+
"needs_key": True,
|
|
125
|
+
"context_window": 128_000,
|
|
126
|
+
},
|
|
127
|
+
"cerebras": {
|
|
128
|
+
"name": "Cerebras",
|
|
129
|
+
"type": "openai_compat",
|
|
130
|
+
"base_url": "https://api.cerebras.ai/v1",
|
|
131
|
+
"default_model": "llama-3.3-70b",
|
|
132
|
+
"models": [
|
|
133
|
+
"llama-3.3-70b",
|
|
134
|
+
"llama-4-scout-17b-16e-instruct",
|
|
135
|
+
],
|
|
136
|
+
"free_tier": True,
|
|
137
|
+
"key_url": "https://cloud.cerebras.ai/",
|
|
138
|
+
"key_prefix": "csk-",
|
|
139
|
+
"needs_key": True,
|
|
140
|
+
"context_window": 128_000,
|
|
141
|
+
},
|
|
142
|
+
"ollama": {
|
|
143
|
+
"name": "Ollama (Local)",
|
|
144
|
+
"type": "ollama",
|
|
145
|
+
"base_url": "http://localhost:11434",
|
|
146
|
+
"default_model": "qwen3",
|
|
147
|
+
"models": [
|
|
148
|
+
"qwen3",
|
|
149
|
+
"gemma4",
|
|
150
|
+
"mistral-small",
|
|
151
|
+
"llama3.3",
|
|
152
|
+
"deepseek-v3.2",
|
|
153
|
+
"llama4-scout",
|
|
154
|
+
],
|
|
155
|
+
"free_tier": True,
|
|
156
|
+
"key_url": None,
|
|
157
|
+
"key_prefix": "",
|
|
158
|
+
"needs_key": False,
|
|
159
|
+
"context_window": 32_768,
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
ALL_PROVIDER_NAMES = list(PROVIDERS.keys())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_provider_info(name: str) -> dict | None:
|
|
167
|
+
return PROVIDERS.get(name)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_provider_type(name: str) -> str:
|
|
171
|
+
info = PROVIDERS.get(name)
|
|
172
|
+
if not info:
|
|
173
|
+
return "openai_compat"
|
|
174
|
+
return info["type"]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_base_url(name: str) -> str | None:
|
|
178
|
+
info = PROVIDERS.get(name)
|
|
179
|
+
if not info:
|
|
180
|
+
return None
|
|
181
|
+
return info["base_url"]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get_default_model(name: str) -> str:
|
|
185
|
+
info = PROVIDERS.get(name)
|
|
186
|
+
if not info:
|
|
187
|
+
return "gpt-4o-mini"
|
|
188
|
+
return info["default_model"]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def get_context_window(name: str, model: str | None = None) -> int:
|
|
192
|
+
if name == "gemini":
|
|
193
|
+
return 1_000_000
|
|
194
|
+
info = PROVIDERS.get(name)
|
|
195
|
+
if info:
|
|
196
|
+
return info["context_window"]
|
|
197
|
+
return 128_000
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def list_free_providers() -> list[str]:
|
|
201
|
+
return [name for name, info in PROVIDERS.items() if info["free_tier"]]
|
gemi/sessions.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from gemi.config import GEMI_DIR
|
|
6
|
+
from gemi.providers.base import Message
|
|
7
|
+
|
|
8
|
+
SESSIONS_DIR = GEMI_DIR / "sessions"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _ensure_dir():
|
|
12
|
+
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def save_session(session_id: str, messages: list[Message], metadata: dict | None = None):
|
|
16
|
+
_ensure_dir()
|
|
17
|
+
data = {
|
|
18
|
+
"session_id": session_id,
|
|
19
|
+
"saved_at": time.time(),
|
|
20
|
+
"metadata": metadata or {},
|
|
21
|
+
"messages": [
|
|
22
|
+
{
|
|
23
|
+
"role": m.role,
|
|
24
|
+
"content": m.content,
|
|
25
|
+
"tool_calls": m.tool_calls,
|
|
26
|
+
"tool_call_id": m.tool_call_id,
|
|
27
|
+
"name": m.name,
|
|
28
|
+
}
|
|
29
|
+
for m in messages
|
|
30
|
+
],
|
|
31
|
+
}
|
|
32
|
+
path = SESSIONS_DIR / f"{session_id}.json"
|
|
33
|
+
path.write_text(json.dumps(data, indent=2))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_session(session_id: str) -> list[Message] | None:
|
|
37
|
+
path = SESSIONS_DIR / f"{session_id}.json"
|
|
38
|
+
if not path.exists():
|
|
39
|
+
return None
|
|
40
|
+
data = json.loads(path.read_text())
|
|
41
|
+
return [
|
|
42
|
+
Message(
|
|
43
|
+
role=m["role"],
|
|
44
|
+
content=m.get("content", ""),
|
|
45
|
+
tool_calls=m.get("tool_calls"),
|
|
46
|
+
tool_call_id=m.get("tool_call_id"),
|
|
47
|
+
name=m.get("name"),
|
|
48
|
+
)
|
|
49
|
+
for m in data["messages"]
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def list_sessions() -> list[dict]:
|
|
54
|
+
_ensure_dir()
|
|
55
|
+
sessions = []
|
|
56
|
+
for path in sorted(SESSIONS_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
57
|
+
try:
|
|
58
|
+
data = json.loads(path.read_text())
|
|
59
|
+
msg_count = len(data.get("messages", []))
|
|
60
|
+
user_msgs = [m for m in data.get("messages", []) if m["role"] == "user"]
|
|
61
|
+
preview = user_msgs[0]["content"][:80] if user_msgs else "(empty)"
|
|
62
|
+
sessions.append({
|
|
63
|
+
"id": data["session_id"],
|
|
64
|
+
"saved_at": data.get("saved_at", 0),
|
|
65
|
+
"messages": msg_count,
|
|
66
|
+
"preview": preview,
|
|
67
|
+
"cwd": data.get("metadata", {}).get("cwd", ""),
|
|
68
|
+
})
|
|
69
|
+
except (json.JSONDecodeError, KeyError):
|
|
70
|
+
continue
|
|
71
|
+
return sessions
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def delete_session(session_id: str) -> bool:
|
|
75
|
+
path = SESSIONS_DIR / f"{session_id}.json"
|
|
76
|
+
if path.exists():
|
|
77
|
+
path.unlink()
|
|
78
|
+
return True
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def generate_session_id() -> str:
|
|
83
|
+
import hashlib
|
|
84
|
+
return hashlib.md5(str(time.time()).encode()).hexdigest()[:8]
|