gink-ai 0.2.0__tar.gz

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.
gink_ai-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: gink-ai
3
+ Version: 0.2.0
4
+ Summary: Gink in your terminal: ask, chat, search the web and make images with your Gink API key.
5
+ Author: Gink AI
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://amgib00.github.io/GinkAssistant/
8
+ Project-URL: Source, https://github.com/Amgib00/GinkAssistant
9
+ Keywords: gink,ai,assistant,cli,chat
10
+ Classifier: Environment :: Console
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: httpx>=0.25
16
+
17
+ # Gink in your terminal
18
+
19
+ ```bash
20
+ pip install gink-ai
21
+ gink login # opens your browser to approve (or: gink login --key)
22
+ gink "What's the weather in Dakar?"
23
+ gink # chat: /new, /model NAME, /models, /search on|off, /exit
24
+ cat notes.txt | gink "summarise this"
25
+ gink -s "latest news in Senegal" # search the web (with sources)
26
+ gink --image "a baobab at sunset" -o tree.png
27
+ gink models
28
+ ```
29
+
30
+ Your key can also come from `GINK_API_KEY`; another server from `GINK_BASE_URL`
31
+ (default `https://gink-server.onrender.com/v1`). `gink logout` forgets the saved key.
@@ -0,0 +1,15 @@
1
+ # Gink in your terminal
2
+
3
+ ```bash
4
+ pip install gink-ai
5
+ gink login # opens your browser to approve (or: gink login --key)
6
+ gink "What's the weather in Dakar?"
7
+ gink # chat: /new, /model NAME, /models, /search on|off, /exit
8
+ cat notes.txt | gink "summarise this"
9
+ gink -s "latest news in Senegal" # search the web (with sources)
10
+ gink --image "a baobab at sunset" -o tree.png
11
+ gink models
12
+ ```
13
+
14
+ Your key can also come from `GINK_API_KEY`; another server from `GINK_BASE_URL`
15
+ (default `https://gink-server.onrender.com/v1`). `gink logout` forgets the saved key.
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: gink-ai
3
+ Version: 0.2.0
4
+ Summary: Gink in your terminal: ask, chat, search the web and make images with your Gink API key.
5
+ Author: Gink AI
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://amgib00.github.io/GinkAssistant/
8
+ Project-URL: Source, https://github.com/Amgib00/GinkAssistant
9
+ Keywords: gink,ai,assistant,cli,chat
10
+ Classifier: Environment :: Console
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: httpx>=0.25
16
+
17
+ # Gink in your terminal
18
+
19
+ ```bash
20
+ pip install gink-ai
21
+ gink login # opens your browser to approve (or: gink login --key)
22
+ gink "What's the weather in Dakar?"
23
+ gink # chat: /new, /model NAME, /models, /search on|off, /exit
24
+ cat notes.txt | gink "summarise this"
25
+ gink -s "latest news in Senegal" # search the web (with sources)
26
+ gink --image "a baobab at sunset" -o tree.png
27
+ gink models
28
+ ```
29
+
30
+ Your key can also come from `GINK_API_KEY`; another server from `GINK_BASE_URL`
31
+ (default `https://gink-server.onrender.com/v1`). `gink logout` forgets the saved key.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ gink_ai.egg-info/PKG-INFO
4
+ gink_ai.egg-info/SOURCES.txt
5
+ gink_ai.egg-info/dependency_links.txt
6
+ gink_ai.egg-info/entry_points.txt
7
+ gink_ai.egg-info/requires.txt
8
+ gink_ai.egg-info/top_level.txt
9
+ gink_cli/__init__.py
10
+ gink_cli/__main__.py
11
+ gink_cli/main.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gink = gink_cli.main:main
@@ -0,0 +1 @@
1
+ httpx>=0.25
@@ -0,0 +1 @@
1
+ gink_cli
@@ -0,0 +1,2 @@
1
+ """Gink in your terminal."""
2
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,339 @@
1
+ """
2
+ gink -- Gink in your terminal (the Gink Developer API, OpenAI-compatible).
3
+
4
+ gink "question" one answer, streamed
5
+ gink a chat (type /help inside)
6
+ cat file | gink "summarise" send a file's text with the question
7
+ gink -s "question" search the web (answers list their sources)
8
+ gink --image "prompt" -o x.png make an image
9
+ gink login sign in through your browser (or: gink login --key to paste a key)
10
+ gink logout | models | version
11
+ """
12
+ import argparse
13
+ import base64
14
+ import getpass
15
+ import json
16
+ import os
17
+ import sys
18
+ import time
19
+
20
+ from . import __version__
21
+
22
+ DEFAULT_BASE = "https://gink-server.onrender.com/v1"
23
+ MAX_PIPED_CHARS = 200_000
24
+
25
+
26
+ class GinkError(Exception):
27
+ pass
28
+
29
+
30
+ # ---------------------------------------------------------------- settings
31
+ def config_path() -> str:
32
+ if os.name == "nt":
33
+ root = os.environ.get("APPDATA") or os.path.expanduser("~")
34
+ else:
35
+ root = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
36
+ return os.path.join(root, "gink", "config.json")
37
+
38
+
39
+ def load_config() -> dict:
40
+ try:
41
+ with open(config_path(), encoding="utf-8") as f:
42
+ return json.load(f)
43
+ except (OSError, ValueError):
44
+ return {}
45
+
46
+
47
+ def save_config(cfg: dict) -> None:
48
+ path = config_path()
49
+ os.makedirs(os.path.dirname(path), exist_ok=True)
50
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) # only you can read your key
51
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
52
+ json.dump(cfg, f, indent=1)
53
+ try:
54
+ os.chmod(path, 0o600)
55
+ except OSError:
56
+ pass
57
+
58
+
59
+ def settings():
60
+ cfg = load_config()
61
+ key = os.environ.get("GINK_API_KEY") or cfg.get("api_key") or ""
62
+ base = (os.environ.get("GINK_BASE_URL") or cfg.get("base_url") or DEFAULT_BASE).rstrip("/")
63
+ return key, base
64
+
65
+
66
+ # ---------------------------------------------------------------- talking to Gink
67
+ def _client(key: str, timeout: float = 120):
68
+ import httpx
69
+ return httpx.Client(timeout=httpx.Timeout(timeout, connect=30),
70
+ headers={"Authorization": f"Bearer {key}", "User-Agent": f"gink-cli/{__version__}"})
71
+
72
+
73
+ def _explain(r) -> GinkError:
74
+ try:
75
+ body = r.json()
76
+ msg = (body.get("error") or {}).get("message") if isinstance(body.get("error"), dict) else body.get("detail")
77
+ except Exception:
78
+ msg = ""
79
+ if r.status_code == 401:
80
+ return GinkError("Your API key isn't valid (or was revoked). Run: gink login")
81
+ if r.status_code == 429:
82
+ wait = r.headers.get("retry-after")
83
+ return GinkError((msg or "Limit reached.") + (f" Try again in {wait} seconds." if wait else ""))
84
+ return GinkError(msg or f"The Gink server answered {r.status_code}.")
85
+
86
+
87
+ def _network(exc) -> GinkError:
88
+ import httpx
89
+ if isinstance(exc, httpx.TimeoutException):
90
+ return GinkError("The Gink server didn't answer in time. If it was asleep it can take up to a minute "
91
+ "to wake up \u2014 please try again.")
92
+ return GinkError(f"Can't reach the Gink server ({type(exc).__name__}). Check your connection.")
93
+
94
+
95
+ def need_key(key: str):
96
+ if not key:
97
+ raise GinkError("No API key yet. Create one in Gink (Settings > Developers), then run: gink login")
98
+
99
+
100
+ def list_models(key: str, base: str) -> list:
101
+ need_key(key)
102
+ import httpx
103
+ try:
104
+ with _client(key, 90) as c:
105
+ r = c.get(base + "/models")
106
+ except httpx.HTTPError as exc:
107
+ raise _network(exc)
108
+ if r.status_code != 200:
109
+ raise _explain(r)
110
+ return [m["id"] for m in r.json().get("data", [])]
111
+
112
+
113
+ def stream_answer(key: str, base: str, messages: list, model: str = "", out=None) -> str:
114
+ """Send the chat; print the answer as it arrives; return the full text."""
115
+ need_key(key)
116
+ import httpx
117
+ out = out or sys.stdout
118
+ body = {"messages": messages, "stream": True}
119
+ if model:
120
+ body["model"] = model
121
+ text = []
122
+ try:
123
+ with _client(key) as c, c.stream("POST", base + "/chat/completions", json=body) as r:
124
+ if r.status_code != 200:
125
+ r.read()
126
+ raise _explain(r)
127
+ for line in r.iter_lines():
128
+ if not line.startswith("data:"):
129
+ continue
130
+ data = line[5:].strip()
131
+ if data == "[DONE]":
132
+ break
133
+ try:
134
+ chunk = json.loads(data)
135
+ except ValueError:
136
+ continue
137
+ if chunk.get("error"):
138
+ raise GinkError(chunk["error"].get("message") or "The AI service had a problem.")
139
+ piece = ((chunk.get("choices") or [{}])[0].get("delta") or {}).get("content") or ""
140
+ if piece:
141
+ text.append(piece)
142
+ out.write(piece)
143
+ out.flush()
144
+ except httpx.HTTPError as exc:
145
+ raise _network(exc)
146
+ out.write("\n")
147
+ out.flush()
148
+ return "".join(text)
149
+
150
+
151
+ def make_image(key: str, base: str, prompt: str, path: str) -> str:
152
+ need_key(key)
153
+ import httpx
154
+ try:
155
+ with _client(key, 180) as c:
156
+ r = c.post(base + "/images/generations", json={"prompt": prompt})
157
+ except httpx.HTTPError as exc:
158
+ raise _network(exc)
159
+ if r.status_code != 200:
160
+ raise _explain(r)
161
+ data = r.json()["data"][0]["b64_json"]
162
+ with open(path, "wb") as f:
163
+ f.write(base64.b64decode(data))
164
+ return path
165
+
166
+
167
+ # ---------------------------------------------------------------- the commands
168
+ def with_piped_text(question: str, piped: str) -> str:
169
+ if not piped:
170
+ return question
171
+ if len(piped) > MAX_PIPED_CHARS:
172
+ piped = piped[:MAX_PIPED_CHARS] + "\n[... cut: only the first 200,000 characters were sent]"
173
+ return (f'<attached_file name="input">\n{piped}\n</attached_file>\n\n'
174
+ + (question or "Please read this and summarise the important points."))
175
+
176
+
177
+ def chat(key: str, base: str, model: str, system: str = "") -> int:
178
+ """An interactive chat. /help for commands; Ctrl+C stops an answer, Ctrl+D (or /exit) leaves."""
179
+ need_key(key)
180
+ color = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
181
+ you = "\033[1;32myou \u203a\033[0m " if color else "you > "
182
+ history = [{"role": "system", "content": system}] if system else []
183
+ print(f"Gink chat \u00b7 {model or 'default model'} \u00b7 /help for commands, /exit to leave")
184
+ while True:
185
+ try:
186
+ line = input(you).strip()
187
+ except EOFError:
188
+ print()
189
+ return 0
190
+ except KeyboardInterrupt:
191
+ print()
192
+ return 0
193
+ if not line:
194
+ continue
195
+ if line.startswith("/"):
196
+ cmd, _, arg = line[1:].partition(" ")
197
+ if cmd in ("exit", "quit", "q"):
198
+ return 0
199
+ if cmd == "new":
200
+ history = history[:1] if system else []
201
+ print("New chat.")
202
+ elif cmd == "model":
203
+ model = arg.strip()
204
+ print(f"Model: {model or 'default'}")
205
+ elif cmd == "search":
206
+ model = "gink-search" if arg.strip() != "off" else ""
207
+ print("Web search on." if model == "gink-search" else "Web search off.")
208
+ elif cmd == "models":
209
+ try:
210
+ print("\n".join(list_models(key, base)))
211
+ except GinkError as exc:
212
+ print(f"gink: {exc}")
213
+ else:
214
+ print("/new start over \u00b7 /model NAME \u00b7 /models \u00b7 /search on|off \u00b7 /exit")
215
+ continue
216
+ history.append({"role": "user", "content": line})
217
+ try:
218
+ answer = stream_answer(key, base, history[-30:], model)
219
+ history.append({"role": "assistant", "content": answer})
220
+ except KeyboardInterrupt:
221
+ print("\n(stopped)")
222
+ history.pop()
223
+ except GinkError as exc:
224
+ print(f"gink: {exc}")
225
+ history.pop()
226
+
227
+
228
+ def device_login(base: str, open_browser: bool = True, sleep=time.sleep, say=print) -> str:
229
+ """Sign in through the browser: show a code, the person approves it at /device, we get a key."""
230
+ import socket
231
+ import httpx
232
+ root = base[:-3] if base.endswith("/v1") else base
233
+ client = f"gink CLI on {socket.gethostname()[:40]}"
234
+ try:
235
+ with httpx.Client(timeout=60) as c:
236
+ r = c.post(root + "/auth/device/start", json={"client": client})
237
+ if r.status_code != 200:
238
+ raise _explain(r)
239
+ d = r.json()
240
+ say(f"To sign in, open {d['verification_uri']} and enter: {d['user_code']}")
241
+ if open_browser:
242
+ try:
243
+ import webbrowser
244
+ webbrowser.open(d["verification_uri_complete"])
245
+ except Exception:
246
+ pass
247
+ say("Waiting for you to approve in the browser\u2026 (Ctrl+C to cancel)")
248
+ deadline = time.time() + int(d.get("expires_in", 600))
249
+ while time.time() < deadline:
250
+ sleep(int(d.get("interval", 5)))
251
+ p = c.post(root + "/auth/device/poll", json={"device_code": d["device_code"]}).json()
252
+ if p.get("status") == "approved":
253
+ return p["api_key"]
254
+ if p.get("status") == "denied":
255
+ raise GinkError("The sign-in was denied in the browser.")
256
+ if p.get("status") == "expired":
257
+ break
258
+ except httpx.HTTPError as exc:
259
+ raise _network(exc)
260
+ raise GinkError("The code expired. Run gink login again.")
261
+
262
+
263
+ def login(base: str, paste_key: bool = False) -> int:
264
+ if not paste_key:
265
+ key = device_login(base)
266
+ cfg = load_config()
267
+ cfg["api_key"] = key
268
+ if base != DEFAULT_BASE:
269
+ cfg["base_url"] = base
270
+ save_config(cfg)
271
+ print("\u2713 Signed in. Try: gink \"hello\"")
272
+ return 0
273
+ print("Paste your Gink API key (Gink > Settings > Developers > Create key). It starts with gink_live_")
274
+ key = getpass.getpass("API key: ").strip()
275
+ if not key.startswith("gink_live_"):
276
+ print("gink: that doesn't look like a Gink API key (it should start with gink_live_).")
277
+ return 1
278
+ models = list_models(key, base) # check it works before saving it
279
+ cfg = load_config()
280
+ cfg["api_key"] = key
281
+ if base != DEFAULT_BASE:
282
+ cfg["base_url"] = base
283
+ save_config(cfg)
284
+ print(f"Signed in. {len(models)} models available. Try: gink \"hello\"")
285
+ return 0
286
+
287
+
288
+ def main(argv=None) -> int:
289
+ argv = list(sys.argv[1:] if argv is None else argv)
290
+ key, base = settings()
291
+ try:
292
+ if argv[:1] == ["login"]:
293
+ return login(base, paste_key="--key" in argv)
294
+ if argv[:1] == ["logout"]:
295
+ cfg = load_config()
296
+ cfg.pop("api_key", None)
297
+ save_config(cfg)
298
+ print("Signed out: your API key was removed from this computer.")
299
+ return 0
300
+ if argv[:1] == ["models"]:
301
+ print("\n".join(list_models(key, base)))
302
+ return 0
303
+ if argv[:1] in (["version"], ["--version"]):
304
+ print(f"gink {__version__}")
305
+ return 0
306
+
307
+ ap = argparse.ArgumentParser(prog="gink", description="Gink in your terminal.",
308
+ epilog="Also: gink login | logout | models | version")
309
+ ap.add_argument("question", nargs="*", help="what to ask (leave out for a chat)")
310
+ ap.add_argument("-m", "--model", default="", help="a model from 'gink models' (default: your account's)")
311
+ ap.add_argument("-s", "--search", action="store_true", help="search the web; answers list their sources")
312
+ ap.add_argument("--system", default="", help="instructions for Gink in this conversation")
313
+ ap.add_argument("--image", metavar="PROMPT", help="make an image instead of answering")
314
+ ap.add_argument("-o", "--output", default="", help="where to save the image (default gink-image-<time>.png)")
315
+ a = ap.parse_args(argv)
316
+ model = "gink-search" if a.search else a.model
317
+
318
+ if a.image:
319
+ path = make_image(key, base, a.image, a.output or f"gink-image-{int(time.time())}.png")
320
+ print(f"Saved {path}")
321
+ return 0
322
+ question = " ".join(a.question).strip()
323
+ piped = "" if sys.stdin is None or sys.stdin.isatty() else sys.stdin.read()
324
+ if not question and not piped:
325
+ return chat(key, base, model, a.system)
326
+ messages = ([{"role": "system", "content": a.system}] if a.system else []) + \
327
+ [{"role": "user", "content": with_piped_text(question, piped)}]
328
+ stream_answer(key, base, messages, model)
329
+ return 0
330
+ except GinkError as exc:
331
+ print(f"gink: {exc}", file=sys.stderr)
332
+ return 1
333
+ except KeyboardInterrupt:
334
+ print("\n(stopped)", file=sys.stderr)
335
+ return 130
336
+
337
+
338
+ if __name__ == "__main__":
339
+ raise SystemExit(main())
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "gink-ai"
7
+ version = "0.2.0"
8
+ description = "Gink in your terminal: ask, chat, search the web and make images with your Gink API key."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "Gink AI" }]
13
+ dependencies = ["httpx>=0.25"]
14
+ keywords = ["gink", "ai", "assistant", "cli", "chat"]
15
+ classifiers = ["Environment :: Console", "Programming Language :: Python :: 3", "Operating System :: OS Independent"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://amgib00.github.io/GinkAssistant/"
19
+ Source = "https://github.com/Amgib00/GinkAssistant"
20
+
21
+ [project.scripts]
22
+ gink = "gink_cli.main:main"
23
+
24
+ [tool.setuptools]
25
+ packages = ["gink_cli"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+