cometapi-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,3 @@
1
+ """CometAPI CLI — professional terminal interface for CometAPI."""
2
+
3
+ __version__ = "0.1.0"
cometapi_cli/app.py ADDED
@@ -0,0 +1,85 @@
1
+ """CometAPI CLI — main Typer application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import click.core
8
+ import typer
9
+
10
+ from cometapi_cli import __version__
11
+ from cometapi_cli.formatters import OutputFormat
12
+
13
+ app = typer.Typer(
14
+ name="cometapi",
15
+ help="CometAPI CLI — interact with CometAPI from the terminal.",
16
+ no_args_is_help=True,
17
+ rich_markup_mode="rich",
18
+ context_settings={"help_option_names": ["-h", "--help"]},
19
+ )
20
+
21
+
22
+ def _version_callback(value: bool) -> None:
23
+ if value:
24
+ import sys
25
+
26
+ py_ver = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
27
+ typer.echo(f"cometapi-cli {__version__} (python {py_ver})")
28
+ raise typer.Exit()
29
+
30
+
31
+ @app.callback()
32
+ def main(
33
+ ctx: typer.Context,
34
+ version: Annotated[
35
+ bool | None,
36
+ typer.Option("--version", "-V", help="Show version and exit.", callback=_version_callback, is_eager=True),
37
+ ] = None,
38
+ output_format: Annotated[
39
+ OutputFormat,
40
+ typer.Option("--format", "-f", help="Output format."),
41
+ ] = OutputFormat.TABLE,
42
+ json_output: Annotated[
43
+ bool,
44
+ typer.Option("--json", help="Output as JSON (shortcut for --format json)."),
45
+ ] = False,
46
+ ) -> None:
47
+ """CometAPI CLI — interact with CometAPI from the terminal."""
48
+ ctx.ensure_object(dict)
49
+ # Only set format in context when explicitly passed by user
50
+ if json_output:
51
+ ctx.obj["format"] = OutputFormat.JSON
52
+ elif ctx.get_parameter_source("output_format") == click.core.ParameterSource.COMMANDLINE:
53
+ ctx.obj["format"] = output_format
54
+ # Otherwise leave ctx.obj["format"] unset — resolve_format() will fall through to config file
55
+
56
+
57
+ # Register commands
58
+ from cometapi_cli.commands.account import account # noqa: E402
59
+ from cometapi_cli.commands.balance import balance # noqa: E402
60
+ from cometapi_cli.commands.chat import chat # noqa: E402
61
+ from cometapi_cli.commands.config_cmd import config_app, init # noqa: E402
62
+ from cometapi_cli.commands.doctor import doctor # noqa: E402
63
+ from cometapi_cli.commands.logs import logs # noqa: E402
64
+ from cometapi_cli.commands.models import models # noqa: E402
65
+ from cometapi_cli.commands.repl import run_repl # noqa: E402
66
+ from cometapi_cli.commands.stats import stats # noqa: E402
67
+ from cometapi_cli.commands.tasks import tasks # noqa: E402
68
+ from cometapi_cli.commands.tokens import tokens # noqa: E402
69
+
70
+ app.command()(chat)
71
+ app.command()(balance)
72
+ app.command()(models)
73
+ app.command()(account)
74
+ app.command()(stats)
75
+ app.command()(tokens)
76
+ app.command()(logs)
77
+ app.command()(tasks)
78
+ app.command()(init)
79
+ app.command()(doctor)
80
+ app.command(name="repl")(run_repl)
81
+ app.add_typer(config_app)
82
+
83
+
84
+ if __name__ == "__main__":
85
+ app()
cometapi_cli/client.py ADDED
@@ -0,0 +1,270 @@
1
+ """CometAPI client — inherits from OpenAI's official client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ import openai
9
+
10
+ COMETAPI_BASE_URL = "https://api.cometapi.com/v1"
11
+ COMETAPI_DASHBOARD_BASE = "https://api.cometapi.com"
12
+
13
+
14
+ class CometClient(openai.OpenAI):
15
+ """Synchronous CometAPI client.
16
+
17
+ Inherits all OpenAI functionality (chat.completions, embeddings, etc.)
18
+ and adds CometAPI-specific endpoints.
19
+ """
20
+
21
+ _access_token: str | None
22
+
23
+ def __init__(
24
+ self,
25
+ *,
26
+ api_key: str | None = None,
27
+ access_token: str | None = None,
28
+ base_url: str | None = None,
29
+ **kwargs: Any,
30
+ ) -> None:
31
+ api_key = api_key or os.environ.get("COMETAPI_KEY")
32
+ if not api_key:
33
+ raise openai.OpenAIError(
34
+ "CometAPI key is required. Pass api_key= or set COMETAPI_KEY env var."
35
+ )
36
+
37
+ self._access_token = access_token or os.environ.get("COMETAPI_ACCESS_TOKEN")
38
+
39
+ super().__init__(
40
+ api_key=api_key,
41
+ base_url=base_url or os.environ.get("COMETAPI_BASE_URL") or COMETAPI_BASE_URL,
42
+ **kwargs,
43
+ )
44
+
45
+ # -- Account management (access-token auth) --------------------------------
46
+
47
+ def _account_request(self, method: str, path: str, *, params: dict | None = None) -> dict:
48
+ """Make an authenticated request to a CometAPI account endpoint."""
49
+ if not self._access_token:
50
+ raise openai.OpenAIError(
51
+ "CometAPI access token is required for account endpoints. "
52
+ "Pass access_token= or set COMETAPI_ACCESS_TOKEN env var."
53
+ )
54
+ response = self._client.request(
55
+ method,
56
+ f"{COMETAPI_DASHBOARD_BASE}{path}",
57
+ headers={"Authorization": f"Bearer {self._access_token}"},
58
+ params=params,
59
+ )
60
+ response.raise_for_status()
61
+ return response.json()
62
+
63
+ def get_balance(self, *, source: str | None = None) -> dict:
64
+ """Get the current user's account balance."""
65
+ QUOTA_PER_UNIT = 500_000.0
66
+
67
+ use_user = (source == "account") or (source is None and self._access_token)
68
+
69
+ if use_user and self._access_token:
70
+ try:
71
+ resp = self._account_request("GET", "/api/user/self")
72
+ data = resp.get("data", resp)
73
+ quota = data.get("quota", 0)
74
+ used_quota = data.get("used_quota", 0)
75
+ return {
76
+ "balance": quota / QUOTA_PER_UNIT,
77
+ "used": used_quota / QUOTA_PER_UNIT,
78
+ "total_topped_up": (quota + used_quota) / QUOTA_PER_UNIT,
79
+ "currency": "USD",
80
+ "source": "user",
81
+ }
82
+ except Exception:
83
+ if source == "account":
84
+ raise
85
+
86
+ headers = {"Authorization": f"Bearer {self.api_key}"}
87
+
88
+ sub_resp = self._client.get(
89
+ f"{self.base_url}dashboard/billing/subscription",
90
+ headers=headers,
91
+ )
92
+ sub_resp.raise_for_status()
93
+ sub = sub_resp.json()
94
+
95
+ usage_resp = self._client.get(
96
+ f"{self.base_url}dashboard/billing/usage",
97
+ headers=headers,
98
+ )
99
+ usage_resp.raise_for_status()
100
+ usage = usage_resp.json()
101
+
102
+ hard_limit = sub.get("hard_limit_usd", 0)
103
+ total_usage = usage.get("total_usage", 0) / 100
104
+
105
+ if hard_limit >= 100_000_000:
106
+ balance = -1
107
+ else:
108
+ balance = hard_limit - total_usage
109
+
110
+ return {
111
+ "balance": balance,
112
+ "used": total_usage,
113
+ "total_topped_up": hard_limit if hard_limit < 100_000_000 else -1,
114
+ "currency": "USD",
115
+ "source": "billing",
116
+ }
117
+
118
+ def get_self(self) -> dict:
119
+ """Get the current user's profile (requires access token)."""
120
+ return self._account_request("GET", "/api/user/self")
121
+
122
+ def get_user_stats(self) -> dict:
123
+ """Get the current user's usage statistics (requires access token)."""
124
+ return self._account_request("GET", "/api/user/self/stats")
125
+
126
+ # -- Token management -------------------------------------------------------
127
+
128
+ def list_tokens(self, *, page: int = 1, page_size: int = 20) -> dict:
129
+ """List the user's API keys/tokens (requires access token)."""
130
+ return self._account_request("GET", "/api/token/", params={"p": page, "page_size": page_size})
131
+
132
+ def search_tokens(self, keyword: str = "", *, token: str = "") -> dict:
133
+ """Search API keys/tokens by keyword or token value (requires access token)."""
134
+ params: dict[str, Any] = {}
135
+ if keyword:
136
+ params["keyword"] = keyword
137
+ if token:
138
+ params["token"] = token
139
+ return self._account_request("GET", "/api/token/search", params=params)
140
+
141
+ # -- Logs -------------------------------------------------------------------
142
+
143
+ def list_logs(
144
+ self,
145
+ *,
146
+ page: int = 1,
147
+ page_size: int = 20,
148
+ log_type: int | None = None,
149
+ model_name: str | None = None,
150
+ token_name: str | None = None,
151
+ start_timestamp: int | None = None,
152
+ end_timestamp: int | None = None,
153
+ group: str | None = None,
154
+ ) -> dict:
155
+ """List the user's usage logs (requires access token)."""
156
+ params: dict[str, Any] = {"p": page, "page_size": page_size}
157
+ if log_type is not None:
158
+ params["type"] = log_type
159
+ if model_name:
160
+ params["model_name"] = model_name
161
+ if token_name:
162
+ params["token_name"] = token_name
163
+ if start_timestamp is not None:
164
+ params["start_timestamp"] = start_timestamp
165
+ if end_timestamp is not None:
166
+ params["end_timestamp"] = end_timestamp
167
+ if group:
168
+ params["group"] = group
169
+ return self._account_request("GET", "/api/log/self", params=params)
170
+
171
+ def search_logs(self, keyword: str) -> dict:
172
+ """Search usage logs by keyword (requires access token)."""
173
+ return self._account_request("GET", "/api/log/self/search", params={"keyword": keyword})
174
+
175
+ def get_log_stat(
176
+ self,
177
+ *,
178
+ log_type: int | None = None,
179
+ model_name: str | None = None,
180
+ token_name: str | None = None,
181
+ start_timestamp: int | None = None,
182
+ end_timestamp: int | None = None,
183
+ group: str | None = None,
184
+ request_id: str | None = None,
185
+ ) -> dict:
186
+ """Get aggregated log statistics."""
187
+ params: dict[str, Any] = {}
188
+ if log_type is not None:
189
+ params["type"] = log_type
190
+ if model_name:
191
+ params["model_name"] = model_name
192
+ if token_name:
193
+ params["token_name"] = token_name
194
+ if start_timestamp is not None:
195
+ params["start_timestamp"] = start_timestamp
196
+ if end_timestamp is not None:
197
+ params["end_timestamp"] = end_timestamp
198
+ if group:
199
+ params["group"] = group
200
+ if request_id:
201
+ params["request_id"] = request_id
202
+ return self._account_request("GET", "/api/log/self/stat", params=params)
203
+
204
+ def export_logs(
205
+ self,
206
+ *,
207
+ log_type: int | None = None,
208
+ model_name: str | None = None,
209
+ token_name: str | None = None,
210
+ start_timestamp: int | None = None,
211
+ end_timestamp: int | None = None,
212
+ group: str | None = None,
213
+ ) -> bytes:
214
+ """Export usage logs as CSV (requires access token)."""
215
+ if not self._access_token:
216
+ raise openai.OpenAIError(
217
+ "CometAPI access token is required for account endpoints. "
218
+ "Pass access_token= or set COMETAPI_ACCESS_TOKEN env var."
219
+ )
220
+ params: dict[str, Any] = {}
221
+ if log_type is not None:
222
+ params["type"] = log_type
223
+ if model_name:
224
+ params["model_name"] = model_name
225
+ if token_name:
226
+ params["token_name"] = token_name
227
+ if start_timestamp is not None:
228
+ params["start_timestamp"] = start_timestamp
229
+ if end_timestamp is not None:
230
+ params["end_timestamp"] = end_timestamp
231
+ if group:
232
+ params["group"] = group
233
+ response = self._client.request(
234
+ "GET",
235
+ f"{COMETAPI_DASHBOARD_BASE}/api/log/self/export",
236
+ headers={"Authorization": f"Bearer {self._access_token}"},
237
+ params=params,
238
+ )
239
+ response.raise_for_status()
240
+ return response.content
241
+
242
+ # -- Tasks ------------------------------------------------------------------
243
+
244
+ def list_tasks(
245
+ self,
246
+ *,
247
+ page: int = 1,
248
+ page_size: int = 20,
249
+ platform: str | None = None,
250
+ task_id: str | None = None,
251
+ status: str | None = None,
252
+ action: str | None = None,
253
+ start_timestamp: int | None = None,
254
+ end_timestamp: int | None = None,
255
+ ) -> dict:
256
+ """List the user's async task logs (requires access token)."""
257
+ params: dict[str, Any] = {"p": page, "page_size": page_size}
258
+ if platform:
259
+ params["platform"] = platform
260
+ if task_id:
261
+ params["task_id"] = task_id
262
+ if status:
263
+ params["status"] = status
264
+ if action:
265
+ params["action"] = action
266
+ if start_timestamp is not None:
267
+ params["start_timestamp"] = start_timestamp
268
+ if end_timestamp is not None:
269
+ params["end_timestamp"] = end_timestamp
270
+ return self._account_request("GET", "/api/task/self", params=params)
@@ -0,0 +1 @@
1
+ """CometAPI CLI commands."""
@@ -0,0 +1,39 @@
1
+ """Account command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from ..config import get_client
10
+ from ..errors import handle_errors
11
+ from ..formatters import OutputFormat, output, resolve_format
12
+
13
+
14
+ @handle_errors
15
+ def account(
16
+ ctx: typer.Context,
17
+ output_format: Annotated[OutputFormat | None, typer.Option("--format", "-f", help="Output format.")] = None,
18
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
19
+ ) -> None:
20
+ """Show your CometAPI account profile (requires access token)."""
21
+ fmt = resolve_format(ctx, json_output, output_format)
22
+ client = get_client(require_access_token=True)
23
+ resp = client.get_self()
24
+ data = resp.get("data", {})
25
+
26
+ display = {
27
+ "id": data.get("id", "N/A"),
28
+ "username": data.get("username", "N/A"),
29
+ "display_name": data.get("display_name", "N/A"),
30
+ "email": data.get("email", "N/A"),
31
+ "role": data.get("role", "N/A"),
32
+ "status": data.get("status", "N/A"),
33
+ }
34
+
35
+ # For JSON output, pass raw data for maximum information
36
+ if fmt == OutputFormat.JSON:
37
+ output(data, fmt)
38
+ else:
39
+ output(display, fmt, title="CometAPI Account")
@@ -0,0 +1,56 @@
1
+ """Balance command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from ..config import get_client
10
+ from ..errors import handle_errors
11
+ from ..formatters import OutputFormat, output, resolve_format
12
+
13
+
14
+ @handle_errors
15
+ def balance(
16
+ ctx: typer.Context,
17
+ source: Annotated[
18
+ str | None,
19
+ typer.Option("--source", "-s", help="Data source: 'account' (full account) or 'token' (current API key)."),
20
+ ] = None,
21
+ output_format: Annotated[OutputFormat | None, typer.Option("--format", "-f", help="Output format.")] = None,
22
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
23
+ ) -> None:
24
+ """Show your CometAPI account balance.
25
+
26
+ By default shows account-level balance (requires access token), falling back
27
+ to per-token billing stats. Use --source to force a specific view.
28
+ """
29
+ fmt = resolve_format(ctx, json_output, output_format)
30
+ client = get_client()
31
+ raw = client.get_balance(source=source)
32
+
33
+ # For JSON output, pass raw data for maximum information
34
+ if fmt == OutputFormat.JSON:
35
+ output(raw, fmt)
36
+ return
37
+
38
+ # Build human-friendly table display
39
+ bal = raw.get("balance", 0)
40
+ used = raw.get("used", 0)
41
+ total = raw.get("total_topped_up", 0)
42
+ data_source = raw.get("source", "unknown")
43
+
44
+ if data_source == "billing":
45
+ display: dict[str, str] = {
46
+ "limit": f"${bal:,.2f}" if bal >= 0 else "Unlimited",
47
+ "used": f"${used:,.2f}",
48
+ }
49
+ else:
50
+ display = {
51
+ "available_balance": f"${bal:,.2f}" if bal >= 0 else "Unlimited",
52
+ "used": f"${used:,.2f}",
53
+ "total_topped_up": f"${total:,.2f}" if total >= 0 else "N/A",
54
+ }
55
+
56
+ output(display, fmt, title="CometAPI Balance")
@@ -0,0 +1,104 @@
1
+ """Chat command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from ..config import get_client, get_default_model
12
+ from ..errors import handle_errors
13
+ from ..formatters import OutputFormat, resolve_format
14
+
15
+
16
+ @handle_errors
17
+ def chat(
18
+ ctx: typer.Context,
19
+ message: Annotated[str | None, typer.Argument(help="Message to send. Omit to enter interactive REPL.")] = None,
20
+ model: Annotated[str | None, typer.Option("--model", "-m", help="Model to use.")] = None,
21
+ system: Annotated[str | None, typer.Option("--system", "-s", help="System prompt.")] = None,
22
+ temperature: Annotated[float | None, typer.Option("--temperature", "-t", help="Sampling temperature.")] = None,
23
+ max_tokens: Annotated[int | None, typer.Option("--max-tokens", help="Max tokens in response.")] = None,
24
+ stream: Annotated[bool, typer.Option("--stream/--no-stream", help="Stream output.")] = True,
25
+ output_format: Annotated[OutputFormat | None, typer.Option("--format", "-f", help="Output format.")] = None,
26
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
27
+ ) -> None:
28
+ """Send a chat message, or start interactive REPL (no args)."""
29
+ # No message → enter REPL mode
30
+ if message is None:
31
+ from .chat_repl import run_chat_repl
32
+
33
+ run_chat_repl(
34
+ model=model,
35
+ system=system,
36
+ temperature=temperature,
37
+ max_tokens=max_tokens,
38
+ stream=stream,
39
+ )
40
+ return
41
+
42
+ from rich.markdown import Markdown
43
+
44
+ from ..console import console
45
+
46
+ fmt = resolve_format(ctx, json_output, output_format)
47
+ client = get_client()
48
+ resolved_model = model or get_default_model()
49
+
50
+ messages: list[dict[str, str]] = []
51
+ if system:
52
+ messages.append({"role": "system", "content": system})
53
+ messages.append({"role": "user", "content": message})
54
+
55
+ kwargs: dict = {"model": resolved_model, "messages": messages}
56
+ if temperature is not None:
57
+ kwargs["temperature"] = temperature
58
+ if max_tokens is not None:
59
+ kwargs["max_tokens"] = max_tokens
60
+
61
+ # JSON output: disable streaming, return structured data
62
+ if fmt == OutputFormat.JSON:
63
+ kwargs["stream"] = False
64
+ response = client.chat.completions.create(**kwargs)
65
+ text = response.choices[0].message.content or ""
66
+ print(
67
+ json.dumps(
68
+ {
69
+ "model": getattr(response, "model", resolved_model),
70
+ "role": "assistant",
71
+ "content": text,
72
+ "usage": {
73
+ "prompt_tokens": getattr(response.usage, "prompt_tokens", None),
74
+ "completion_tokens": getattr(response.usage, "completion_tokens", None),
75
+ "total_tokens": getattr(response.usage, "total_tokens", None),
76
+ },
77
+ },
78
+ ensure_ascii=False,
79
+ indent=2,
80
+ )
81
+ )
82
+ return
83
+
84
+ # Interactive output
85
+ if stream:
86
+ kwargs["stream"] = True
87
+ stream_response = client.chat.completions.create(**kwargs)
88
+ full_text = ""
89
+ for chunk in stream_response:
90
+ if not chunk.choices:
91
+ continue
92
+ delta = chunk.choices[0].delta.content or ""
93
+ sys.stdout.write(delta)
94
+ sys.stdout.flush()
95
+ full_text += delta
96
+ sys.stdout.write("\n")
97
+ sys.stdout.flush()
98
+ console.print(f"[dim]Model: {resolved_model}[/dim]")
99
+ else:
100
+ kwargs["stream"] = False
101
+ response = client.chat.completions.create(**kwargs)
102
+ text = response.choices[0].message.content or ""
103
+ console.print(Markdown(text))
104
+ console.print(f"[dim]Model: {getattr(response, 'model', resolved_model)}[/dim]")