gumloop 0.2.0__tar.gz → 0.3.1__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.
- {gumloop-0.2.0 → gumloop-0.3.1}/PKG-INFO +2 -1
- {gumloop-0.2.0 → gumloop-0.3.1}/pyproject.toml +9 -1
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/__init__.py +1 -1
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/_client.py +10 -4
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/_http.py +108 -0
- gumloop-0.3.1/src/gumloop/cli/commands/chat.py +212 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/mcp.py +176 -3
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/skills.py +23 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/main.py +2 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/resources/__init__.py +4 -0
- gumloop-0.3.1/src/gumloop/resources/chat.py +117 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/resources/mcp.py +78 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/resources/skills.py +14 -0
- gumloop-0.3.1/src/gumloop/spec/__init__.py +44 -0
- gumloop-0.3.1/src/gumloop/spec/_compat.py +21 -0
- gumloop-0.3.1/src/gumloop/spec/_extensions.py +57 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/types.py +112 -0
- gumloop-0.3.1/tests/cli/test_chat.py +495 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_mcp.py +98 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_skills.py +12 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/integration/conftest.py +6 -2
- gumloop-0.3.1/tests/sdk/test_chat.py +456 -0
- gumloop-0.3.1/tests/sdk/test_mcp.py +264 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/test_skills.py +22 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/uv.lock +65 -1
- gumloop-0.2.0/tests/sdk/test_mcp.py +0 -139
- {gumloop-0.2.0 → gumloop-0.3.1}/.env.example +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/.gitignore +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/.pre-commit-config.yaml +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/.python-version +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/LICENSE +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/README.md +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/scripts/check +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/scripts/fmt +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/scripts/sdk_examples.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/__init__.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/__init__.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/_args_input.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/_downloads.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/agents.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/artifacts.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/auth.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/sessions.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/console.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/context.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/credentials.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/errors.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/oauth.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/errors.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/oauth.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/py.typed +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/resources/agents.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/resources/artifacts.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/resources/sessions.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/resources/teams.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/__init__.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/__init__.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/conftest.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_agents.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_args_input.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_artifacts.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_context.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_credentials.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_login.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_main.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_sessions.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/conftest.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/integration/test_live.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/__init__.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/conftest.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/helpers.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/test_agents.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/test_artifacts.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/test_client.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/test_oauth.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/test_sessions.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/test_teams.py +0 -0
- {gumloop-0.2.0 → gumloop-0.3.1}/tests/test_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gumloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Python client for the Gumloop API
|
|
5
5
|
Project-URL: Homepage, https://github.com/gumloop/gumloop-py
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/gumloop/gumloop-py/issues
|
|
@@ -15,6 +15,7 @@ Requires-Python: >=3.10
|
|
|
15
15
|
Requires-Dist: httpx-sse>=0.4.0
|
|
16
16
|
Requires-Dist: httpx>=0.24.1
|
|
17
17
|
Requires-Dist: keyring>=24
|
|
18
|
+
Requires-Dist: openrouter==0.9.1
|
|
18
19
|
Requires-Dist: pydantic>=2.0.0
|
|
19
20
|
Requires-Dist: questionary>=2.0
|
|
20
21
|
Requires-Dist: rich
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gumloop"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Python client for the Gumloop API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -22,6 +22,9 @@ dependencies = [
|
|
|
22
22
|
"httpx>=0.24.1",
|
|
23
23
|
"httpx-sse>=0.4.0",
|
|
24
24
|
"keyring>=24",
|
|
25
|
+
# Spec source of truth — we import only openrouter.components.*.
|
|
26
|
+
# Exact pin: Speakeasy 0.x can rename fields on any bump.
|
|
27
|
+
"openrouter==0.9.1",
|
|
25
28
|
"pydantic>=2.0.0",
|
|
26
29
|
"questionary>=2.0",
|
|
27
30
|
"rich",
|
|
@@ -40,6 +43,8 @@ dev = [
|
|
|
40
43
|
"pre-commit>=2.21.0",
|
|
41
44
|
"pyright>=1.1.409",
|
|
42
45
|
"pytest>=7.4.4",
|
|
46
|
+
"pytest-timeout>=2.4.0",
|
|
47
|
+
"pytest-xdist>=3.8.0",
|
|
43
48
|
"python-dotenv>=0.21.1",
|
|
44
49
|
"respx>=0.21.1",
|
|
45
50
|
"ruff>=0.15.12",
|
|
@@ -52,6 +57,9 @@ build = [
|
|
|
52
57
|
|
|
53
58
|
[tool.pytest.ini_options]
|
|
54
59
|
testpaths = ["tests"]
|
|
60
|
+
markers = [
|
|
61
|
+
"live: hits the real Gumloop API and upstream providers; opt-in via `-m live`",
|
|
62
|
+
]
|
|
55
63
|
|
|
56
64
|
[tool.ruff]
|
|
57
65
|
line-length = 120
|
|
@@ -15,11 +15,13 @@ from gumloop.resources import Agents
|
|
|
15
15
|
from gumloop.resources import Artifacts
|
|
16
16
|
from gumloop.resources import AsyncAgents
|
|
17
17
|
from gumloop.resources import AsyncArtifacts
|
|
18
|
+
from gumloop.resources import AsyncChat
|
|
18
19
|
from gumloop.resources import AsyncMCP
|
|
19
20
|
from gumloop.resources import AsyncModels
|
|
20
21
|
from gumloop.resources import AsyncSessions
|
|
21
22
|
from gumloop.resources import AsyncSkills
|
|
22
23
|
from gumloop.resources import AsyncTeams
|
|
24
|
+
from gumloop.resources import Chat
|
|
23
25
|
from gumloop.resources import Models
|
|
24
26
|
from gumloop.resources import Sessions
|
|
25
27
|
from gumloop.resources import Skills
|
|
@@ -49,7 +51,7 @@ class Gumloop:
|
|
|
49
51
|
*,
|
|
50
52
|
access_token: str | None = None,
|
|
51
53
|
user_id: str | None = None,
|
|
52
|
-
base_url: str =
|
|
54
|
+
base_url: str | None = None,
|
|
53
55
|
stream_base_url: str | None = None,
|
|
54
56
|
timeout: float = DEFAULT_TIMEOUT,
|
|
55
57
|
stream_timeout: float | None = DEFAULT_STREAM_TIMEOUT,
|
|
@@ -60,7 +62,8 @@ class Gumloop:
|
|
|
60
62
|
# x-auth-key header to look up the owner's stored secret. OAuth
|
|
61
63
|
# callers omit it because the bearer token already identifies them.
|
|
62
64
|
self.user_id = user_id or os.environ.get("GUMLOOP_USER_ID")
|
|
63
|
-
|
|
65
|
+
# Defaults to production; GUMLOOP_BASE_URL is an optional override.
|
|
66
|
+
self.base_url = (base_url or os.environ.get("GUMLOOP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
64
67
|
self.stream_base_url = (stream_base_url or _derive_stream_base_url(self.base_url)).rstrip("/")
|
|
65
68
|
self.timeout = timeout
|
|
66
69
|
self.stream_timeout = stream_timeout
|
|
@@ -76,6 +79,7 @@ class Gumloop:
|
|
|
76
79
|
|
|
77
80
|
self.agents = Agents(self._http)
|
|
78
81
|
self.sessions = Sessions(self._http)
|
|
82
|
+
self.chat = Chat(self._http)
|
|
79
83
|
self.models = Models(self._http)
|
|
80
84
|
self.mcp = MCP(self._http)
|
|
81
85
|
self.teams = Teams(self._http)
|
|
@@ -102,7 +106,7 @@ class AsyncGumloop:
|
|
|
102
106
|
*,
|
|
103
107
|
access_token: str | None = None,
|
|
104
108
|
user_id: str | None = None,
|
|
105
|
-
base_url: str =
|
|
109
|
+
base_url: str | None = None,
|
|
106
110
|
stream_base_url: str | None = None,
|
|
107
111
|
timeout: float = DEFAULT_TIMEOUT,
|
|
108
112
|
stream_timeout: float | None = DEFAULT_STREAM_TIMEOUT,
|
|
@@ -110,7 +114,8 @@ class AsyncGumloop:
|
|
|
110
114
|
self.api_key = api_key
|
|
111
115
|
self.access_token = access_token or api_key or os.environ.get("GUMLOOP_ACCESS_TOKEN")
|
|
112
116
|
self.user_id = user_id or os.environ.get("GUMLOOP_USER_ID")
|
|
113
|
-
|
|
117
|
+
# Defaults to production; GUMLOOP_BASE_URL is an optional override.
|
|
118
|
+
self.base_url = (base_url or os.environ.get("GUMLOOP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
114
119
|
self.stream_base_url = (stream_base_url or _derive_stream_base_url(self.base_url)).rstrip("/")
|
|
115
120
|
self.timeout = timeout
|
|
116
121
|
self.stream_timeout = stream_timeout
|
|
@@ -126,6 +131,7 @@ class AsyncGumloop:
|
|
|
126
131
|
|
|
127
132
|
self.agents = AsyncAgents(self._http)
|
|
128
133
|
self.sessions = AsyncSessions(self._http)
|
|
134
|
+
self.chat = AsyncChat(self._http)
|
|
129
135
|
self.models = AsyncModels(self._http)
|
|
130
136
|
self.mcp = AsyncMCP(self._http)
|
|
131
137
|
self.teams = AsyncTeams(self._http)
|
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
from collections.abc import AsyncIterator
|
|
4
5
|
from collections.abc import Iterator
|
|
5
6
|
from collections.abc import Mapping
|
|
6
7
|
from typing import Any
|
|
8
|
+
from typing import TypeVar
|
|
7
9
|
|
|
8
10
|
import httpx
|
|
9
11
|
from httpx_sse import EventSource
|
|
10
12
|
from httpx_sse import ServerSentEvent
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
from pydantic import ValidationError
|
|
11
15
|
|
|
12
16
|
from gumloop.errors import AuthenticationError
|
|
13
17
|
from gumloop.errors import to_api_error
|
|
14
18
|
from gumloop.types import StreamEvent
|
|
15
19
|
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_DONE_SENTINEL = "[DONE]"
|
|
23
|
+
_T = TypeVar("_T", bound=BaseModel)
|
|
24
|
+
|
|
16
25
|
|
|
17
26
|
def _auth_headers(access_token: str | None, user_id: str | None) -> dict[str, str]:
|
|
18
27
|
if not access_token:
|
|
@@ -104,6 +113,22 @@ class HttpClient:
|
|
|
104
113
|
def delete(self, path: str) -> Any:
|
|
105
114
|
return self._request("DELETE", path)
|
|
106
115
|
|
|
116
|
+
def post_to_stream_host(self, path: str, *, json: Any = None) -> Any:
|
|
117
|
+
# Endpoints whose streaming variant lives on the stream host (e.g. chat
|
|
118
|
+
# completions) must accept their unary counterparts at the same host —
|
|
119
|
+
# the api host has no handler for them.
|
|
120
|
+
headers = _auth_headers(self.access_token, self.user_id)
|
|
121
|
+
headers["Content-Type"] = "application/json"
|
|
122
|
+
response = self._client.post(
|
|
123
|
+
f"{self._stream_base_url}/{path.lstrip('/')}",
|
|
124
|
+
headers=headers,
|
|
125
|
+
timeout=self._stream_timeout,
|
|
126
|
+
json=json,
|
|
127
|
+
)
|
|
128
|
+
if response.status_code >= 400:
|
|
129
|
+
raise to_api_error(response)
|
|
130
|
+
return response.json() if response.content else None
|
|
131
|
+
|
|
107
132
|
def stream(
|
|
108
133
|
self,
|
|
109
134
|
method: str,
|
|
@@ -127,6 +152,42 @@ class HttpClient:
|
|
|
127
152
|
for event in EventSource(response).iter_sse():
|
|
128
153
|
yield _decode_sse(event)
|
|
129
154
|
|
|
155
|
+
def stream_typed(
|
|
156
|
+
self,
|
|
157
|
+
method: str,
|
|
158
|
+
path: str,
|
|
159
|
+
response_model: type[_T],
|
|
160
|
+
*,
|
|
161
|
+
json: Any = None,
|
|
162
|
+
params: Mapping[str, Any] | None = None,
|
|
163
|
+
) -> Iterator[_T]:
|
|
164
|
+
# Skips the StreamEvent envelope and honors OpenRouter's `data: [DONE]`
|
|
165
|
+
# terminator. Unparseable events (keep-alives, comments) are skipped.
|
|
166
|
+
headers = {**_auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
|
|
167
|
+
with self._client.stream(
|
|
168
|
+
method,
|
|
169
|
+
f"{self._stream_base_url}/{path.lstrip('/')}",
|
|
170
|
+
headers=headers,
|
|
171
|
+
timeout=self._stream_timeout,
|
|
172
|
+
json=json,
|
|
173
|
+
params=_omit_none_params(params),
|
|
174
|
+
) as response:
|
|
175
|
+
if response.status_code >= 400:
|
|
176
|
+
response.read()
|
|
177
|
+
raise to_api_error(response)
|
|
178
|
+
for event in EventSource(response).iter_sse():
|
|
179
|
+
if event.data == _DONE_SENTINEL:
|
|
180
|
+
return
|
|
181
|
+
if not event.data:
|
|
182
|
+
continue
|
|
183
|
+
try:
|
|
184
|
+
yield response_model.model_validate_json(event.data)
|
|
185
|
+
except ValidationError:
|
|
186
|
+
# Server-side mid-stream error frames or schema-drift events
|
|
187
|
+
# land here.
|
|
188
|
+
logger.debug("dropped non-%s SSE: %s", response_model.__name__, event.data)
|
|
189
|
+
continue
|
|
190
|
+
|
|
130
191
|
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
131
192
|
# Headers are rebuilt per request so ``access_token`` / ``user_id``
|
|
132
193
|
# can be rotated on a live client without reconstructing it.
|
|
@@ -193,6 +254,19 @@ class AsyncHttpClient:
|
|
|
193
254
|
async def delete(self, path: str) -> Any:
|
|
194
255
|
return await self._request("DELETE", path)
|
|
195
256
|
|
|
257
|
+
async def post_to_stream_host(self, path: str, *, json: Any = None) -> Any:
|
|
258
|
+
headers = _auth_headers(self.access_token, self.user_id)
|
|
259
|
+
headers["Content-Type"] = "application/json"
|
|
260
|
+
response = await self._client.post(
|
|
261
|
+
f"{self._stream_base_url}/{path.lstrip('/')}",
|
|
262
|
+
headers=headers,
|
|
263
|
+
timeout=self._stream_timeout,
|
|
264
|
+
json=json,
|
|
265
|
+
)
|
|
266
|
+
if response.status_code >= 400:
|
|
267
|
+
raise to_api_error(response)
|
|
268
|
+
return response.json() if response.content else None
|
|
269
|
+
|
|
196
270
|
async def stream(
|
|
197
271
|
self,
|
|
198
272
|
method: str,
|
|
@@ -216,6 +290,40 @@ class AsyncHttpClient:
|
|
|
216
290
|
async for event in EventSource(response).aiter_sse():
|
|
217
291
|
yield _decode_sse(event)
|
|
218
292
|
|
|
293
|
+
async def stream_typed(
|
|
294
|
+
self,
|
|
295
|
+
method: str,
|
|
296
|
+
path: str,
|
|
297
|
+
response_model: type[_T],
|
|
298
|
+
*,
|
|
299
|
+
json: Any = None,
|
|
300
|
+
params: Mapping[str, Any] | None = None,
|
|
301
|
+
) -> AsyncIterator[_T]:
|
|
302
|
+
headers = {**_auth_headers(self.access_token, self.user_id), "Accept": "text/event-stream"}
|
|
303
|
+
async with self._client.stream(
|
|
304
|
+
method,
|
|
305
|
+
f"{self._stream_base_url}/{path.lstrip('/')}",
|
|
306
|
+
headers=headers,
|
|
307
|
+
timeout=self._stream_timeout,
|
|
308
|
+
json=json,
|
|
309
|
+
params=_omit_none_params(params),
|
|
310
|
+
) as response:
|
|
311
|
+
if response.status_code >= 400:
|
|
312
|
+
await response.aread()
|
|
313
|
+
raise to_api_error(response)
|
|
314
|
+
async for event in EventSource(response).aiter_sse():
|
|
315
|
+
if event.data == _DONE_SENTINEL:
|
|
316
|
+
return
|
|
317
|
+
if not event.data:
|
|
318
|
+
continue
|
|
319
|
+
try:
|
|
320
|
+
yield response_model.model_validate_json(event.data)
|
|
321
|
+
except ValidationError:
|
|
322
|
+
# Server-side mid-stream error frames or schema-drift events
|
|
323
|
+
# land here.
|
|
324
|
+
logger.debug("dropped non-%s SSE: %s", response_model.__name__, event.data)
|
|
325
|
+
continue
|
|
326
|
+
|
|
219
327
|
async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
220
328
|
headers = _auth_headers(self.access_token, self.user_id)
|
|
221
329
|
if not kwargs.get("files"):
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
from typing import Any
|
|
8
|
+
from typing import Iterator
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from gumloop import GumloopError
|
|
13
|
+
from gumloop.cli.console import console
|
|
14
|
+
from gumloop.cli.console import print_json
|
|
15
|
+
from gumloop.cli.context import CliContext
|
|
16
|
+
from gumloop.cli.errors import exit_with_error
|
|
17
|
+
from gumloop.spec import ChatStreamChunk
|
|
18
|
+
|
|
19
|
+
chat_app = typer.Typer(
|
|
20
|
+
help="Chat completions.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
rich_markup_mode="rich",
|
|
23
|
+
)
|
|
24
|
+
completions_app = typer.Typer(
|
|
25
|
+
help="Create chat completions.",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
rich_markup_mode="rich",
|
|
28
|
+
)
|
|
29
|
+
chat_app.add_typer(completions_app, name="completions")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_EPILOG = (
|
|
33
|
+
"Examples:\n"
|
|
34
|
+
' gumloop chat completions create "ping" -m claude-sonnet-4-5\n'
|
|
35
|
+
' echo "summarize" | gumloop chat completions create -m gpt-4o-mini --message-stdin -\n'
|
|
36
|
+
' gumloop chat completions create "x" -m claude-haiku-4-5 --json\n'
|
|
37
|
+
' gumloop chat completions create "stream me" -m claude-sonnet-4-5 --stream --json # ndjson\n'
|
|
38
|
+
' gumloop chat completions create "image please" -m gemini-2.5-flash-image \\\n'
|
|
39
|
+
" --modality image --modality text --json\n"
|
|
40
|
+
"\n"
|
|
41
|
+
"This command mirrors the Python SDK call `client.chat.completions.create(...)`\n"
|
|
42
|
+
"1:1. Every flag maps to the matching SDK kwarg (e.g. --modality -> modalities,\n"
|
|
43
|
+
"--max-completion-tokens -> max_completion_tokens). Output streams when stdout\n"
|
|
44
|
+
"is a TTY and the request supports it; pass --stream or --no-stream to be\n"
|
|
45
|
+
"explicit."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@completions_app.command("create", epilog=_EPILOG)
|
|
50
|
+
def create_completion(
|
|
51
|
+
ctx: typer.Context,
|
|
52
|
+
prompt: Annotated[
|
|
53
|
+
str | None,
|
|
54
|
+
typer.Argument(help="User message. Pass --message-stdin - to read from stdin instead."),
|
|
55
|
+
] = None,
|
|
56
|
+
model: Annotated[
|
|
57
|
+
str,
|
|
58
|
+
typer.Option("--model", "-m", help="Model slug, e.g. claude-sonnet-4-5."),
|
|
59
|
+
] = "",
|
|
60
|
+
system: Annotated[
|
|
61
|
+
list[str] | None,
|
|
62
|
+
typer.Option("--system", "-s", help="System message prepended to the conversation. Repeatable."),
|
|
63
|
+
] = None,
|
|
64
|
+
message_stdin: Annotated[
|
|
65
|
+
str | None,
|
|
66
|
+
typer.Option("--message-stdin", help="Use '-' to read the user message from stdin.", metavar="-"),
|
|
67
|
+
] = None,
|
|
68
|
+
max_completion_tokens: Annotated[
|
|
69
|
+
int | None,
|
|
70
|
+
typer.Option(
|
|
71
|
+
"--max-completion-tokens",
|
|
72
|
+
help="Cap on completion tokens. SDK-aligned name; maps to max_completion_tokens.",
|
|
73
|
+
),
|
|
74
|
+
] = None,
|
|
75
|
+
max_tokens: Annotated[
|
|
76
|
+
int | None,
|
|
77
|
+
typer.Option(
|
|
78
|
+
"--max-tokens",
|
|
79
|
+
help="Legacy alias for --max-completion-tokens. Same wire field.",
|
|
80
|
+
hidden=True,
|
|
81
|
+
),
|
|
82
|
+
] = None,
|
|
83
|
+
temperature: Annotated[
|
|
84
|
+
float | None,
|
|
85
|
+
typer.Option("--temperature", help="Sampling temperature."),
|
|
86
|
+
] = None,
|
|
87
|
+
modality: Annotated[
|
|
88
|
+
list[str] | None,
|
|
89
|
+
typer.Option(
|
|
90
|
+
"--modality",
|
|
91
|
+
help="Modality to request. Repeatable (e.g. --modality image --modality text). Maps to `modalities`.",
|
|
92
|
+
),
|
|
93
|
+
] = None,
|
|
94
|
+
schema_file: Annotated[
|
|
95
|
+
Path | None,
|
|
96
|
+
typer.Option(
|
|
97
|
+
"--schema-file",
|
|
98
|
+
help="Path to a JSON Schema file. Sent as response_format={type:json_schema,...}.",
|
|
99
|
+
),
|
|
100
|
+
] = None,
|
|
101
|
+
schema_name: Annotated[
|
|
102
|
+
str,
|
|
103
|
+
typer.Option("--schema-name", help="json_schema.name field (paired with --schema-file)."),
|
|
104
|
+
] = "schema",
|
|
105
|
+
stream: Annotated[
|
|
106
|
+
bool,
|
|
107
|
+
typer.Option("--stream", help="Force streaming output (always wins over TTY auto-detect)."),
|
|
108
|
+
] = False,
|
|
109
|
+
no_stream: Annotated[
|
|
110
|
+
bool,
|
|
111
|
+
typer.Option("--no-stream", help="Wait for the full response then print. Always wins over TTY auto-detect."),
|
|
112
|
+
] = False,
|
|
113
|
+
json_output: Annotated[
|
|
114
|
+
bool,
|
|
115
|
+
typer.Option("--json", help="Print the response as JSON. Streaming + --json emits ndjson (one chunk per line)."),
|
|
116
|
+
] = False,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Create a chat completion."""
|
|
119
|
+
cli: CliContext = ctx.obj
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
if not model:
|
|
123
|
+
raise GumloopError("Pass --model / -m with a model slug.")
|
|
124
|
+
if prompt is not None and message_stdin is not None:
|
|
125
|
+
raise GumloopError("Pass at most one of PROMPT or --message-stdin.")
|
|
126
|
+
if message_stdin is not None and message_stdin != "-":
|
|
127
|
+
raise GumloopError("--message-stdin only accepts '-' (reads from stdin).")
|
|
128
|
+
if stream and no_stream:
|
|
129
|
+
raise GumloopError("Pass at most one of --stream and --no-stream.")
|
|
130
|
+
if max_tokens is not None and max_completion_tokens is not None:
|
|
131
|
+
raise GumloopError("Pass at most one of --max-tokens and --max-completion-tokens.")
|
|
132
|
+
|
|
133
|
+
user_message = sys.stdin.read() if message_stdin == "-" else prompt
|
|
134
|
+
if not user_message:
|
|
135
|
+
raise GumloopError("Pass a PROMPT or --message-stdin - with text to send.")
|
|
136
|
+
|
|
137
|
+
messages: list[dict[str, Any]] = [
|
|
138
|
+
{"role": "system", "content": s} for s in (system or [])
|
|
139
|
+
]
|
|
140
|
+
messages.append({"role": "user", "content": user_message})
|
|
141
|
+
|
|
142
|
+
# Stream resolution: explicit flags always win; --json without --stream
|
|
143
|
+
# implies unary so machine output is byte-stable; otherwise stream-when-TTY.
|
|
144
|
+
if stream:
|
|
145
|
+
should_stream = True
|
|
146
|
+
elif no_stream or json_output:
|
|
147
|
+
should_stream = False
|
|
148
|
+
else:
|
|
149
|
+
should_stream = _stdout_is_tty()
|
|
150
|
+
|
|
151
|
+
kwargs: dict[str, Any] = {"model": model, "messages": messages}
|
|
152
|
+
token_cap = max_completion_tokens if max_completion_tokens is not None else max_tokens
|
|
153
|
+
if token_cap is not None:
|
|
154
|
+
kwargs["max_completion_tokens"] = token_cap
|
|
155
|
+
if temperature is not None:
|
|
156
|
+
kwargs["temperature"] = temperature
|
|
157
|
+
if modality:
|
|
158
|
+
kwargs["modalities"] = modality
|
|
159
|
+
if schema_file is not None:
|
|
160
|
+
schema = json.loads(schema_file.read_text())
|
|
161
|
+
kwargs["response_format"] = {
|
|
162
|
+
"type": "json_schema",
|
|
163
|
+
"json_schema": {"name": schema_name, "strict": True, "schema": schema},
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if should_stream:
|
|
167
|
+
kwargs["stream"] = True
|
|
168
|
+
stream_iter: Iterator[ChatStreamChunk] = cli.call_with_refresh(
|
|
169
|
+
lambda client: client.chat.completions.create(**kwargs)
|
|
170
|
+
)
|
|
171
|
+
for chunk in stream_iter:
|
|
172
|
+
_emit_stream_chunk(chunk, json_mode=json_output)
|
|
173
|
+
if not json_output:
|
|
174
|
+
console.print() # trailing newline to separate the prompt that follows
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
result = cli.call_with_refresh(
|
|
178
|
+
lambda client: client.chat.completions.create(**kwargs)
|
|
179
|
+
)
|
|
180
|
+
except GumloopError as error:
|
|
181
|
+
exit_with_error(error, json_output=json_output)
|
|
182
|
+
|
|
183
|
+
if json_output:
|
|
184
|
+
print_json(result)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
for choice in result.choices:
|
|
188
|
+
content = choice.message.content
|
|
189
|
+
if isinstance(content, list):
|
|
190
|
+
content = "".join(p.get("text", "") for p in content if isinstance(p, dict))
|
|
191
|
+
if content:
|
|
192
|
+
console.print(content, markup=False, highlight=False)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _stdout_is_tty() -> bool:
|
|
196
|
+
# Wrapped so tests can monkeypatch; CliRunner replaces ``sys.stdout`` with
|
|
197
|
+
# a buffer whose ``isatty()`` always returns False, which would always
|
|
198
|
+
# collapse the streaming branch.
|
|
199
|
+
return sys.stdout.isatty()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _emit_stream_chunk(chunk: ChatStreamChunk, *, json_mode: bool = False) -> None:
|
|
203
|
+
if json_mode:
|
|
204
|
+
# ndjson: one chunk per line, by_alias so wire field names match the SDK.
|
|
205
|
+
sys.stdout.write(chunk.model_dump_json(by_alias=True) + "\n")
|
|
206
|
+
sys.stdout.flush()
|
|
207
|
+
return
|
|
208
|
+
for choice in chunk.choices:
|
|
209
|
+
piece = getattr(choice.delta, "content", None)
|
|
210
|
+
if piece:
|
|
211
|
+
# markup=False so output containing [brackets] doesn't trip Rich.
|
|
212
|
+
console.print(piece, end="", markup=False, highlight=False)
|