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.
Files changed (78) hide show
  1. {gumloop-0.2.0 → gumloop-0.3.1}/PKG-INFO +2 -1
  2. {gumloop-0.2.0 → gumloop-0.3.1}/pyproject.toml +9 -1
  3. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/__init__.py +1 -1
  4. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/_client.py +10 -4
  5. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/_http.py +108 -0
  6. gumloop-0.3.1/src/gumloop/cli/commands/chat.py +212 -0
  7. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/mcp.py +176 -3
  8. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/skills.py +23 -0
  9. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/main.py +2 -0
  10. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/resources/__init__.py +4 -0
  11. gumloop-0.3.1/src/gumloop/resources/chat.py +117 -0
  12. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/resources/mcp.py +78 -0
  13. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/resources/skills.py +14 -0
  14. gumloop-0.3.1/src/gumloop/spec/__init__.py +44 -0
  15. gumloop-0.3.1/src/gumloop/spec/_compat.py +21 -0
  16. gumloop-0.3.1/src/gumloop/spec/_extensions.py +57 -0
  17. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/types.py +112 -0
  18. gumloop-0.3.1/tests/cli/test_chat.py +495 -0
  19. {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_mcp.py +98 -0
  20. {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_skills.py +12 -0
  21. {gumloop-0.2.0 → gumloop-0.3.1}/tests/integration/conftest.py +6 -2
  22. gumloop-0.3.1/tests/sdk/test_chat.py +456 -0
  23. gumloop-0.3.1/tests/sdk/test_mcp.py +264 -0
  24. {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/test_skills.py +22 -0
  25. {gumloop-0.2.0 → gumloop-0.3.1}/uv.lock +65 -1
  26. gumloop-0.2.0/tests/sdk/test_mcp.py +0 -139
  27. {gumloop-0.2.0 → gumloop-0.3.1}/.env.example +0 -0
  28. {gumloop-0.2.0 → gumloop-0.3.1}/.gitignore +0 -0
  29. {gumloop-0.2.0 → gumloop-0.3.1}/.pre-commit-config.yaml +0 -0
  30. {gumloop-0.2.0 → gumloop-0.3.1}/.python-version +0 -0
  31. {gumloop-0.2.0 → gumloop-0.3.1}/LICENSE +0 -0
  32. {gumloop-0.2.0 → gumloop-0.3.1}/README.md +0 -0
  33. {gumloop-0.2.0 → gumloop-0.3.1}/scripts/check +0 -0
  34. {gumloop-0.2.0 → gumloop-0.3.1}/scripts/fmt +0 -0
  35. {gumloop-0.2.0 → gumloop-0.3.1}/scripts/sdk_examples.py +0 -0
  36. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/__init__.py +0 -0
  37. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/__init__.py +0 -0
  38. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/_args_input.py +0 -0
  39. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/_downloads.py +0 -0
  40. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/agents.py +0 -0
  41. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/artifacts.py +0 -0
  42. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/auth.py +0 -0
  43. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/commands/sessions.py +0 -0
  44. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/console.py +0 -0
  45. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/context.py +0 -0
  46. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/credentials.py +0 -0
  47. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/errors.py +0 -0
  48. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/cli/oauth.py +0 -0
  49. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/errors.py +0 -0
  50. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/oauth.py +0 -0
  51. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/py.typed +0 -0
  52. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/resources/agents.py +0 -0
  53. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/resources/artifacts.py +0 -0
  54. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/resources/sessions.py +0 -0
  55. {gumloop-0.2.0 → gumloop-0.3.1}/src/gumloop/resources/teams.py +0 -0
  56. {gumloop-0.2.0 → gumloop-0.3.1}/tests/__init__.py +0 -0
  57. {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/__init__.py +0 -0
  58. {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/conftest.py +0 -0
  59. {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_agents.py +0 -0
  60. {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_args_input.py +0 -0
  61. {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_artifacts.py +0 -0
  62. {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_context.py +0 -0
  63. {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_credentials.py +0 -0
  64. {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_login.py +0 -0
  65. {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_main.py +0 -0
  66. {gumloop-0.2.0 → gumloop-0.3.1}/tests/cli/test_sessions.py +0 -0
  67. {gumloop-0.2.0 → gumloop-0.3.1}/tests/conftest.py +0 -0
  68. {gumloop-0.2.0 → gumloop-0.3.1}/tests/integration/test_live.py +0 -0
  69. {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/__init__.py +0 -0
  70. {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/conftest.py +0 -0
  71. {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/helpers.py +0 -0
  72. {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/test_agents.py +0 -0
  73. {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/test_artifacts.py +0 -0
  74. {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/test_client.py +0 -0
  75. {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/test_oauth.py +0 -0
  76. {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/test_sessions.py +0 -0
  77. {gumloop-0.2.0 → gumloop-0.3.1}/tests/sdk/test_teams.py +0 -0
  78. {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.2.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.2.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
@@ -6,7 +6,7 @@ from gumloop.errors import AuthenticationError
6
6
  from gumloop.errors import GumloopError
7
7
  from gumloop.oauth import OAuth
8
8
 
9
- __version__ = "0.2.0"
9
+ __version__ = "0.3.1"
10
10
  __all__ = [
11
11
  "APIStatusError",
12
12
  "AsyncGumloop",
@@ -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 = DEFAULT_BASE_URL,
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
- self.base_url = base_url.rstrip("/")
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 = DEFAULT_BASE_URL,
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
- self.base_url = base_url.rstrip("/")
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)