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.
@@ -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]