cometapi-cli 0.2.2__py3-none-any.whl → 0.3.1__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.
- cometapi_cli/__init__.py +1 -1
- cometapi_cli/app.py +4 -0
- cometapi_cli/catalog.py +141 -0
- cometapi_cli/commands/account.py +4 -1
- cometapi_cli/commands/chat.py +12 -3
- cometapi_cli/commands/config_cmd.py +14 -3
- cometapi_cli/commands/doctor.py +4 -1
- cometapi_cli/commands/logs.py +117 -54
- cometapi_cli/commands/model.py +70 -0
- cometapi_cli/commands/models.py +174 -14
- cometapi_cli/commands/run.py +210 -0
- cometapi_cli/commands/stats.py +4 -1
- cometapi_cli/commands/tasks.py +12 -2
- cometapi_cli/commands/tokens.py +4 -1
- cometapi_cli/constants.py +7 -0
- cometapi_cli/formatters.py +1 -1
- cometapi_cli/urls.py +20 -0
- cometapi_cli-0.3.1.dist-info/METADATA +311 -0
- cometapi_cli-0.3.1.dist-info/RECORD +31 -0
- cometapi_cli-0.2.2.dist-info/METADATA +0 -246
- cometapi_cli-0.2.2.dist-info/RECORD +0 -27
- {cometapi_cli-0.2.2.dist-info → cometapi_cli-0.3.1.dist-info}/WHEEL +0 -0
- {cometapi_cli-0.2.2.dist-info → cometapi_cli-0.3.1.dist-info}/entry_points.txt +0 -0
- {cometapi_cli-0.2.2.dist-info → cometapi_cli-0.3.1.dist-info}/licenses/LICENSE +0 -0
cometapi_cli/__init__.py
CHANGED
cometapi_cli/app.py
CHANGED
|
@@ -61,8 +61,10 @@ from cometapi_cli.commands.chat import chat # noqa: E402
|
|
|
61
61
|
from cometapi_cli.commands.config_cmd import config_app, init # noqa: E402
|
|
62
62
|
from cometapi_cli.commands.doctor import doctor # noqa: E402
|
|
63
63
|
from cometapi_cli.commands.logs import logs # noqa: E402
|
|
64
|
+
from cometapi_cli.commands.model import model_app # noqa: E402
|
|
64
65
|
from cometapi_cli.commands.models import models # noqa: E402
|
|
65
66
|
from cometapi_cli.commands.repl import run_repl # noqa: E402
|
|
67
|
+
from cometapi_cli.commands.run import run # noqa: E402
|
|
66
68
|
from cometapi_cli.commands.stats import stats # noqa: E402
|
|
67
69
|
from cometapi_cli.commands.tasks import tasks # noqa: E402
|
|
68
70
|
from cometapi_cli.commands.tokens import tokens # noqa: E402
|
|
@@ -75,9 +77,11 @@ app.command()(stats)
|
|
|
75
77
|
app.command()(tokens)
|
|
76
78
|
app.command()(logs)
|
|
77
79
|
app.command()(tasks)
|
|
80
|
+
app.command()(run)
|
|
78
81
|
app.command()(init)
|
|
79
82
|
app.command()(doctor)
|
|
80
83
|
app.command(name="repl")(run_repl)
|
|
84
|
+
app.add_typer(model_app)
|
|
81
85
|
app.add_typer(config_app)
|
|
82
86
|
|
|
83
87
|
|
cometapi_cli/catalog.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Public CometAPI model catalog helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .urls import get_api_root_url
|
|
14
|
+
|
|
15
|
+
CATALOG_CACHE_FILE = Path.home() / ".cache" / "cometapi" / "models.json"
|
|
16
|
+
CATALOG_TTL_SECONDS = 60 * 60
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _request_json(url: str) -> dict[str, Any]:
|
|
20
|
+
try:
|
|
21
|
+
with urllib.request.urlopen(url, timeout=30) as response:
|
|
22
|
+
return json.loads(response.read().decode("utf-8"))
|
|
23
|
+
except urllib.error.HTTPError as e:
|
|
24
|
+
body = e.read().decode("utf-8", errors="replace")
|
|
25
|
+
raise RuntimeError(f"GET {url} failed: HTTP {e.code} {body[:200]}") from e
|
|
26
|
+
except urllib.error.URLError as e:
|
|
27
|
+
raise RuntimeError(f"GET {url} failed: {e.reason}") from e
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _read_cache(base_url: str) -> list[dict[str, Any]] | None:
|
|
31
|
+
try:
|
|
32
|
+
raw = json.loads(CATALOG_CACHE_FILE.read_text(encoding="utf-8"))
|
|
33
|
+
except (OSError, json.JSONDecodeError):
|
|
34
|
+
return None
|
|
35
|
+
if raw.get("base_url") != base_url:
|
|
36
|
+
return None
|
|
37
|
+
if time.time() - float(raw.get("fetched_at", 0)) > CATALOG_TTL_SECONDS:
|
|
38
|
+
return None
|
|
39
|
+
data = raw.get("data")
|
|
40
|
+
return data if isinstance(data, list) else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _write_cache(base_url: str, data: list[dict[str, Any]]) -> None:
|
|
44
|
+
try:
|
|
45
|
+
CATALOG_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
CATALOG_CACHE_FILE.write_text(
|
|
47
|
+
json.dumps({"base_url": base_url, "fetched_at": time.time(), "data": data}),
|
|
48
|
+
encoding="utf-8",
|
|
49
|
+
)
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def fetch_catalog_models(*, refresh: bool = False, no_cache: bool = False) -> list[dict[str, Any]]:
|
|
55
|
+
"""Fetch the public model catalog, using a one-hour local cache by default."""
|
|
56
|
+
base_url = get_api_root_url()
|
|
57
|
+
if not refresh and not no_cache:
|
|
58
|
+
cached = _read_cache(base_url)
|
|
59
|
+
if cached is not None:
|
|
60
|
+
return cached
|
|
61
|
+
|
|
62
|
+
payload = _request_json(f"{base_url}/api/models")
|
|
63
|
+
if not payload.get("success", False):
|
|
64
|
+
raise RuntimeError(payload.get("message") or "GET /api/models returned success=false")
|
|
65
|
+
|
|
66
|
+
data = payload.get("data", [])
|
|
67
|
+
if not isinstance(data, list):
|
|
68
|
+
raise RuntimeError("GET /api/models returned an unexpected data shape")
|
|
69
|
+
|
|
70
|
+
if not no_cache:
|
|
71
|
+
_write_cache(base_url, data)
|
|
72
|
+
return data
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_jsonish(value: Any) -> Any:
|
|
76
|
+
"""Parse JSON-in-string fields from the catalog when possible."""
|
|
77
|
+
if not isinstance(value, str):
|
|
78
|
+
return value
|
|
79
|
+
stripped = value.strip()
|
|
80
|
+
if not stripped:
|
|
81
|
+
return value
|
|
82
|
+
try:
|
|
83
|
+
return json.loads(stripped)
|
|
84
|
+
except json.JSONDecodeError:
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def endpoint_terms(model: dict[str, Any]) -> list[str]:
|
|
89
|
+
endpoints = parse_jsonish(model.get("endpoints"))
|
|
90
|
+
if isinstance(endpoints, dict):
|
|
91
|
+
terms: list[str] = []
|
|
92
|
+
for name, detail in endpoints.items():
|
|
93
|
+
terms.append(str(name))
|
|
94
|
+
if isinstance(detail, dict):
|
|
95
|
+
for key in ("path", "method"):
|
|
96
|
+
value = detail.get(key)
|
|
97
|
+
if isinstance(value, str):
|
|
98
|
+
terms.append(value)
|
|
99
|
+
return terms
|
|
100
|
+
if isinstance(endpoints, list):
|
|
101
|
+
return [str(item) for item in endpoints]
|
|
102
|
+
if endpoints:
|
|
103
|
+
return [str(endpoints)]
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def endpoint_labels(model: dict[str, Any]) -> list[str]:
|
|
108
|
+
"""Return compact endpoint labels for display."""
|
|
109
|
+
endpoints = parse_jsonish(model.get("endpoints"))
|
|
110
|
+
if isinstance(endpoints, dict):
|
|
111
|
+
return [str(name) for name in endpoints]
|
|
112
|
+
if isinstance(endpoints, list):
|
|
113
|
+
return [str(item) for item in endpoints]
|
|
114
|
+
if endpoints:
|
|
115
|
+
return [str(endpoints)]
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def find_catalog_model(model_id: str, models: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
120
|
+
normalized = model_id.replace(".", "-")
|
|
121
|
+
for model in models:
|
|
122
|
+
if model.get("id") == model_id or model.get("code") == model_id:
|
|
123
|
+
return model
|
|
124
|
+
if model.get("code") == normalized:
|
|
125
|
+
return model
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def fetch_model_detail(model_id: str, *, refresh: bool = False, no_cache: bool = False) -> dict[str, Any]:
|
|
130
|
+
"""Fetch public model detail by model id, resolving catalog code first."""
|
|
131
|
+
models = fetch_catalog_models(refresh=refresh, no_cache=no_cache)
|
|
132
|
+
model = find_catalog_model(model_id, models)
|
|
133
|
+
code = model.get("code") if model else model_id.replace(".", "-")
|
|
134
|
+
url_code = urllib.parse.quote(str(code), safe="/")
|
|
135
|
+
payload = _request_json(f"{get_api_root_url()}/api/model/{url_code}")
|
|
136
|
+
if not payload.get("success", False):
|
|
137
|
+
raise RuntimeError(payload.get("message") or f"model not found: {model_id}")
|
|
138
|
+
data = payload.get("data")
|
|
139
|
+
if not isinstance(data, dict):
|
|
140
|
+
raise RuntimeError("GET /api/model returned an unexpected data shape")
|
|
141
|
+
return data
|
cometapi_cli/commands/account.py
CHANGED
|
@@ -14,7 +14,10 @@ from ..formatters import OutputFormat, output, resolve_format
|
|
|
14
14
|
@handle_errors
|
|
15
15
|
def account(
|
|
16
16
|
ctx: typer.Context,
|
|
17
|
-
output_format: Annotated[
|
|
17
|
+
output_format: Annotated[
|
|
18
|
+
OutputFormat | None,
|
|
19
|
+
typer.Option("--format", "-f", help="Output format (table, json, yaml, csv, markdown)."),
|
|
20
|
+
] = None,
|
|
18
21
|
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
|
|
19
22
|
) -> None:
|
|
20
23
|
"""Show your CometAPI account profile (requires access token)."""
|
cometapi_cli/commands/chat.py
CHANGED
|
@@ -17,12 +17,21 @@ from ..formatters import OutputFormat, resolve_format
|
|
|
17
17
|
def chat(
|
|
18
18
|
ctx: typer.Context,
|
|
19
19
|
message: Annotated[str | None, typer.Argument(help="Message to send. Omit to enter interactive REPL.")] = None,
|
|
20
|
-
model: Annotated[
|
|
20
|
+
model: Annotated[
|
|
21
|
+
str | None,
|
|
22
|
+
typer.Option("--model", "-m", help="Model to use (default: from config or gpt-5.4)."),
|
|
23
|
+
] = None,
|
|
21
24
|
system: Annotated[str | None, typer.Option("--system", "-s", help="System prompt.")] = None,
|
|
22
|
-
temperature: Annotated[
|
|
25
|
+
temperature: Annotated[
|
|
26
|
+
float | None,
|
|
27
|
+
typer.Option("--temperature", "-t", help="Sampling temperature (0.0-2.0)."),
|
|
28
|
+
] = None,
|
|
23
29
|
max_tokens: Annotated[int | None, typer.Option("--max-tokens", help="Max tokens in response.")] = None,
|
|
24
30
|
stream: Annotated[bool, typer.Option("--stream/--no-stream", help="Stream output.")] = True,
|
|
25
|
-
output_format: Annotated[
|
|
31
|
+
output_format: Annotated[
|
|
32
|
+
OutputFormat | None,
|
|
33
|
+
typer.Option("--format", "-f", help="Output format (table, json, yaml, csv, markdown)."),
|
|
34
|
+
] = None,
|
|
26
35
|
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
|
|
27
36
|
) -> None:
|
|
28
37
|
"""Send a chat message, or start interactive REPL (no args)."""
|
|
@@ -171,7 +171,10 @@ def init(ctx: typer.Context) -> None:
|
|
|
171
171
|
@handle_errors
|
|
172
172
|
def config_show(
|
|
173
173
|
ctx: typer.Context,
|
|
174
|
-
output_format: Annotated[
|
|
174
|
+
output_format: Annotated[
|
|
175
|
+
OutputFormat | None,
|
|
176
|
+
typer.Option("--format", "-f", help="Output format (table, json, yaml, csv, markdown)."),
|
|
177
|
+
] = None,
|
|
175
178
|
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
|
|
176
179
|
) -> None:
|
|
177
180
|
"""Display current configuration (secrets are masked)."""
|
|
@@ -195,7 +198,10 @@ def config_show(
|
|
|
195
198
|
@config_app.command("set")
|
|
196
199
|
@handle_errors
|
|
197
200
|
def config_set(
|
|
198
|
-
key: Annotated[
|
|
201
|
+
key: Annotated[
|
|
202
|
+
str,
|
|
203
|
+
typer.Argument(help="Configuration key (api_key, access_token, base_url, default_model, output_format)."),
|
|
204
|
+
],
|
|
199
205
|
value: Annotated[str, typer.Argument(help="Value to set. For output_format: table, json, yaml, csv, markdown.")],
|
|
200
206
|
) -> None:
|
|
201
207
|
"""Set a configuration value.
|
|
@@ -221,7 +227,12 @@ def config_set(
|
|
|
221
227
|
@config_app.command("unset")
|
|
222
228
|
@handle_errors
|
|
223
229
|
def config_unset(
|
|
224
|
-
key: Annotated[
|
|
230
|
+
key: Annotated[
|
|
231
|
+
str,
|
|
232
|
+
typer.Argument(
|
|
233
|
+
help="Configuration key to remove (api_key, access_token, base_url, default_model, output_format)."
|
|
234
|
+
),
|
|
235
|
+
],
|
|
225
236
|
) -> None:
|
|
226
237
|
"""Remove a configuration value."""
|
|
227
238
|
cfg = load_config()
|
cometapi_cli/commands/doctor.py
CHANGED
|
@@ -25,7 +25,10 @@ def _warn_mark() -> str:
|
|
|
25
25
|
@handle_errors
|
|
26
26
|
def doctor(
|
|
27
27
|
ctx: typer.Context,
|
|
28
|
-
output_format: Annotated[
|
|
28
|
+
output_format: Annotated[
|
|
29
|
+
OutputFormat | None,
|
|
30
|
+
typer.Option("--format", "-f", help="Output format (table, json, yaml, csv, markdown)."),
|
|
31
|
+
] = None,
|
|
29
32
|
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
|
|
30
33
|
) -> None:
|
|
31
34
|
"""Check CLI configuration and API connectivity."""
|
cometapi_cli/commands/logs.py
CHANGED
|
@@ -9,7 +9,14 @@ from typing import Annotated
|
|
|
9
9
|
import typer
|
|
10
10
|
|
|
11
11
|
from ..config import get_client
|
|
12
|
-
from ..constants import
|
|
12
|
+
from ..constants import (
|
|
13
|
+
QUOTA_PER_UNIT,
|
|
14
|
+
extract_items,
|
|
15
|
+
format_iso,
|
|
16
|
+
format_ts,
|
|
17
|
+
parse_date,
|
|
18
|
+
quota_to_usd,
|
|
19
|
+
)
|
|
13
20
|
from ..errors import handle_errors
|
|
14
21
|
from ..formatters import OutputFormat, output, resolve_format
|
|
15
22
|
|
|
@@ -70,61 +77,111 @@ def _find_log_by_id(
|
|
|
70
77
|
return None
|
|
71
78
|
|
|
72
79
|
|
|
73
|
-
def
|
|
74
|
-
"""Build
|
|
80
|
+
def _build_log_record(log: dict) -> dict:
|
|
81
|
+
"""Build the canonical, machine-friendly record for a single log entry.
|
|
82
|
+
|
|
83
|
+
Returns a stable schema with raw, typed values (numbers, booleans, ISO 8601
|
|
84
|
+
time). The same field set drives every output format; human-facing formats
|
|
85
|
+
layer display formatting on top via :func:`_record_to_detail_display`.
|
|
86
|
+
Missing values are ``None`` so the schema stays consistent across entries.
|
|
87
|
+
"""
|
|
75
88
|
other = _parse_other(log.get("other"))
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
89
|
+
quota = log.get("quota", 0) or 0
|
|
90
|
+
|
|
91
|
+
# model_price == -1 is the backend sentinel for "use ratio-based pricing"
|
|
92
|
+
# (i.e. no fixed per-call price); expose it as null in the canonical record.
|
|
93
|
+
raw_model_price = other.get("model_price")
|
|
94
|
+
model_price = None if raw_model_price == -1 else raw_model_price
|
|
95
|
+
|
|
96
|
+
# Prefer total_ms (true end-to-end latency) over use_time, which the backend
|
|
97
|
+
# records in seconds and is too coarse to be useful as a duration.
|
|
98
|
+
total_ms = other.get("total_ms")
|
|
99
|
+
use_time = log.get("use_time", 0) or 0
|
|
100
|
+
duration_ms = total_ms if total_ms else (use_time or None)
|
|
101
|
+
|
|
102
|
+
frt = other.get("frt")
|
|
103
|
+
user_id = log.get("user_id")
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
"request_id": log.get("request_id") or None,
|
|
107
|
+
"response_id": log.get("response_id") or None,
|
|
108
|
+
"time": format_iso(log.get("created_at", 0)),
|
|
109
|
+
"type": _LOG_TYPE_NAMES.get(log.get("type", 0), "unknown"),
|
|
110
|
+
"model": log.get("model_name") or None,
|
|
111
|
+
"token_name": log.get("token_name") or None,
|
|
112
|
+
"username": log.get("username") or None,
|
|
113
|
+
"user_id": user_id if user_id else None,
|
|
114
|
+
"ip": log.get("ip") or None,
|
|
115
|
+
"stream": bool(log.get("is_stream")),
|
|
116
|
+
"prompt_tokens": log.get("prompt_tokens", 0) or 0,
|
|
117
|
+
"completion_tokens": log.get("completion_tokens", 0) or 0,
|
|
118
|
+
"cache_tokens": other.get("cache_tokens", 0) or 0,
|
|
119
|
+
"cost_usd": round(quota / QUOTA_PER_UNIT, 6),
|
|
120
|
+
"quota": quota,
|
|
121
|
+
"model_ratio": other.get("model_ratio"),
|
|
122
|
+
"completion_ratio": other.get("completion_ratio"),
|
|
123
|
+
"group_ratio": other.get("group_ratio"),
|
|
124
|
+
"model_price": model_price,
|
|
125
|
+
"cache_ratio": other.get("cache_ratio"),
|
|
126
|
+
"duration_ms": duration_ms,
|
|
127
|
+
"first_token_ms": frt if frt and frt > 0 else None,
|
|
128
|
+
"endpoint": other.get("request_path") or None,
|
|
129
|
+
}
|
|
130
|
+
|
|
79
131
|
|
|
132
|
+
def _record_to_detail_display(rec: dict) -> dict:
|
|
133
|
+
"""Render a canonical log record as a formatted key-value detail card.
|
|
134
|
+
|
|
135
|
+
Mirrors the field set of :func:`_build_log_record` but uses human-friendly
|
|
136
|
+
labels and formatted strings (USD, ``Yes``/``No``, ``ms``). Optional fields
|
|
137
|
+
are omitted when absent to keep the card readable.
|
|
138
|
+
"""
|
|
139
|
+
em_dash = "—"
|
|
80
140
|
info: dict[str, str | int | float] = {}
|
|
81
|
-
info["Request ID"] =
|
|
82
|
-
info["Response ID"] =
|
|
83
|
-
|
|
84
|
-
info["
|
|
85
|
-
info["
|
|
86
|
-
info["
|
|
87
|
-
|
|
141
|
+
info["Request ID"] = rec["request_id"] or em_dash
|
|
142
|
+
info["Response ID"] = rec["response_id"] or em_dash
|
|
143
|
+
iso = rec["time"]
|
|
144
|
+
info["Time"] = iso[:19].replace("T", " ") if iso else em_dash
|
|
145
|
+
info["Model"] = rec["model"] or em_dash
|
|
146
|
+
info["Token Name"] = rec["token_name"] or em_dash
|
|
147
|
+
if rec["username"]:
|
|
148
|
+
info["Username"] = rec["username"]
|
|
149
|
+
if rec["user_id"] is not None:
|
|
150
|
+
info["User ID"] = rec["user_id"]
|
|
151
|
+
if rec["ip"]:
|
|
152
|
+
info["IP"] = rec["ip"]
|
|
153
|
+
info["Type"] = rec["type"]
|
|
154
|
+
info["Stream"] = "Yes" if rec["stream"] else "No"
|
|
88
155
|
|
|
89
156
|
# Tokens
|
|
90
|
-
info["Prompt Tokens"] = f"{prompt_tokens:,}"
|
|
91
|
-
info["Completion Tokens"] = f"{completion_tokens:,}"
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
info["
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if
|
|
103
|
-
info["
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
info["Group Ratio"] = group_ratio
|
|
110
|
-
model_price = other.get("model_price")
|
|
111
|
-
if model_price is not None:
|
|
112
|
-
info["Model Price"] = "default" if model_price == -1 else model_price
|
|
113
|
-
cache_ratio = other.get("cache_ratio")
|
|
114
|
-
if cache_ratio is not None:
|
|
115
|
-
info["Cache Ratio"] = cache_ratio
|
|
157
|
+
info["Prompt Tokens"] = f"{rec['prompt_tokens']:,}"
|
|
158
|
+
info["Completion Tokens"] = f"{rec['completion_tokens']:,}"
|
|
159
|
+
if rec["cache_tokens"]:
|
|
160
|
+
info["Cache Tokens"] = f"{rec['cache_tokens']:,}"
|
|
161
|
+
|
|
162
|
+
# Cost (6 decimals so sub-cent calls are not rounded away)
|
|
163
|
+
info["Cost (USD)"] = f"${rec['cost_usd']:,.6f}"
|
|
164
|
+
info["Quota (raw)"] = f"{rec['quota']:,}"
|
|
165
|
+
|
|
166
|
+
# Pricing breakdown
|
|
167
|
+
if rec["model_ratio"] is not None:
|
|
168
|
+
info["Model Ratio"] = rec["model_ratio"]
|
|
169
|
+
if rec["completion_ratio"] is not None:
|
|
170
|
+
info["Completion Ratio"] = f"{rec['completion_ratio']}x"
|
|
171
|
+
if rec["group_ratio"] is not None:
|
|
172
|
+
info["Group Ratio"] = rec["group_ratio"]
|
|
173
|
+
info["Model Price"] = "default" if rec["model_price"] is None else rec["model_price"]
|
|
174
|
+
if rec["cache_ratio"] is not None:
|
|
175
|
+
info["Cache Ratio"] = rec["cache_ratio"]
|
|
116
176
|
|
|
117
177
|
# Timing
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if frt and frt > 0:
|
|
122
|
-
info["First Token"] = f"{frt:,} ms"
|
|
178
|
+
info["Duration"] = f"{rec['duration_ms']:,} ms" if rec["duration_ms"] else em_dash
|
|
179
|
+
if rec["first_token_ms"]:
|
|
180
|
+
info["First Token"] = f"{rec['first_token_ms']:,} ms"
|
|
123
181
|
|
|
124
182
|
# Path
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
info["Endpoint"] = request_path
|
|
183
|
+
if rec["endpoint"]:
|
|
184
|
+
info["Endpoint"] = rec["endpoint"]
|
|
128
185
|
|
|
129
186
|
return info
|
|
130
187
|
|
|
@@ -176,7 +233,10 @@ def logs(
|
|
|
176
233
|
page: Annotated[int, typer.Option("--page", "-p", help="Page number.")] = 1,
|
|
177
234
|
limit: Annotated[int, typer.Option("--limit", "-l", help="Results per page.")] = 20,
|
|
178
235
|
export: Annotated[bool, typer.Option("--export", help="Export logs as CSV to stdout.")] = False,
|
|
179
|
-
output_format: Annotated[
|
|
236
|
+
output_format: Annotated[
|
|
237
|
+
OutputFormat | None,
|
|
238
|
+
typer.Option("--format", "-f", help="Output format (table, json, yaml, csv, markdown)."),
|
|
239
|
+
] = None,
|
|
180
240
|
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
|
|
181
241
|
) -> None:
|
|
182
242
|
"""Show your usage logs (requires access token)."""
|
|
@@ -218,12 +278,13 @@ def logs(
|
|
|
218
278
|
)
|
|
219
279
|
raise typer.Exit(code=1)
|
|
220
280
|
|
|
221
|
-
|
|
222
|
-
|
|
281
|
+
record = _build_log_record(log_entry)
|
|
282
|
+
# Machine formats get raw typed values; human formats get the formatted card.
|
|
283
|
+
if fmt in (OutputFormat.JSON, OutputFormat.YAML, OutputFormat.CSV):
|
|
284
|
+
output(record, fmt)
|
|
223
285
|
return
|
|
224
286
|
|
|
225
|
-
|
|
226
|
-
output(info, fmt, title="Request Detail")
|
|
287
|
+
output(_record_to_detail_display(record), fmt, title="Request Detail")
|
|
227
288
|
return
|
|
228
289
|
|
|
229
290
|
# Server-side CSV export — write raw bytes to stdout and return early
|
|
@@ -267,8 +328,10 @@ def logs(
|
|
|
267
328
|
|
|
268
329
|
data = extract_items(resp)
|
|
269
330
|
|
|
270
|
-
|
|
271
|
-
|
|
331
|
+
# Machine formats emit the full canonical record per entry (typed values,
|
|
332
|
+
# incl. cost_usd); table/markdown/csv keep the compact summary rows.
|
|
333
|
+
if fmt in (OutputFormat.JSON, OutputFormat.YAML):
|
|
334
|
+
output([_build_log_record(log) for log in data], fmt)
|
|
272
335
|
return
|
|
273
336
|
|
|
274
337
|
if not data:
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Model detail commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ..catalog import endpoint_terms, fetch_model_detail
|
|
11
|
+
from ..errors import handle_errors
|
|
12
|
+
from ..formatters import OutputFormat, output, resolve_format
|
|
13
|
+
from .models import _pricing_label
|
|
14
|
+
|
|
15
|
+
model_app = typer.Typer(
|
|
16
|
+
name="model",
|
|
17
|
+
help="Inspect CometAPI model metadata.",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _strip_markdown(text: str | None, *, limit: int = 360) -> str:
|
|
24
|
+
if not text:
|
|
25
|
+
return "-"
|
|
26
|
+
cleaned = re.sub(r"[`#\\*]", "", text)
|
|
27
|
+
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
|
28
|
+
if len(cleaned) > limit:
|
|
29
|
+
return cleaned[: limit - 3] + "..."
|
|
30
|
+
return cleaned or "-"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _format_detail(detail: dict[str, Any]) -> dict[str, Any]:
|
|
34
|
+
pricing = detail.get("pricing") if isinstance(detail.get("pricing"), dict) else {}
|
|
35
|
+
features = detail.get("features") if isinstance(detail.get("features"), list) else []
|
|
36
|
+
return {
|
|
37
|
+
"id": detail.get("id", "") or "-",
|
|
38
|
+
"code": detail.get("code", "") or "-",
|
|
39
|
+
"provider": detail.get("provider", "") or "-",
|
|
40
|
+
"model_type": detail.get("model_type", "") or "-",
|
|
41
|
+
"features": ", ".join(str(item) for item in features) or "-",
|
|
42
|
+
"endpoints": ", ".join(endpoint_terms(detail)) or "-",
|
|
43
|
+
"pricing": _pricing_label(pricing),
|
|
44
|
+
"context_length": detail.get("context_length") or "-",
|
|
45
|
+
"max_completion_tokens": detail.get("max_completion_tokens") or "-",
|
|
46
|
+
"api_doc_url": detail.get("api_doc_url") or "-",
|
|
47
|
+
"overview": _strip_markdown(detail.get("overview")),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@model_app.command("info")
|
|
52
|
+
@handle_errors
|
|
53
|
+
def model_info(
|
|
54
|
+
ctx: typer.Context,
|
|
55
|
+
model_id: Annotated[str, typer.Argument(help="Model ID to inspect.")],
|
|
56
|
+
refresh: Annotated[bool, typer.Option("--refresh", help="Refresh the public catalog cache.")] = False,
|
|
57
|
+
no_cache: Annotated[bool, typer.Option("--no-cache", help="Bypass the public catalog cache.")] = False,
|
|
58
|
+
output_format: Annotated[
|
|
59
|
+
OutputFormat | None,
|
|
60
|
+
typer.Option("--format", "-f", help="Output format (table, json, yaml, csv, markdown)."),
|
|
61
|
+
] = None,
|
|
62
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Show public metadata for one model."""
|
|
65
|
+
fmt = resolve_format(ctx, json_output, output_format)
|
|
66
|
+
detail = fetch_model_detail(model_id, refresh=refresh, no_cache=no_cache)
|
|
67
|
+
if fmt == OutputFormat.JSON:
|
|
68
|
+
output(detail, fmt)
|
|
69
|
+
return
|
|
70
|
+
output(_format_detail(detail), fmt, title="Model Info")
|