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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """CometAPI CLI — professional terminal interface for CometAPI."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.3.1"
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
 
@@ -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
@@ -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[OutputFormat | None, typer.Option("--format", "-f", help="Output format (table, json, yaml, csv, markdown).")] = None,
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)."""
@@ -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[str | None, typer.Option("--model", "-m", help="Model to use (default: from config or gpt-5.4).")] = None,
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[float | None, typer.Option("--temperature", "-t", help="Sampling temperature (0.0–2.0).")] = None,
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[OutputFormat | None, typer.Option("--format", "-f", help="Output format (table, json, yaml, csv, markdown).")] = None,
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[OutputFormat | None, typer.Option("--format", "-f", help="Output format (table, json, yaml, csv, markdown).")] = None,
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[str, typer.Argument(help="Configuration key (api_key, access_token, base_url, default_model, output_format).")],
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[str, typer.Argument(help="Configuration key to remove (api_key, access_token, base_url, default_model, output_format).")],
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()
@@ -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[OutputFormat | None, typer.Option("--format", "-f", help="Output format (table, json, yaml, csv, markdown).")] = None,
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."""
@@ -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 extract_items, format_ts, parse_date, quota_to_usd
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 _format_log_detail(log: dict) -> dict:
74
- """Build a rich key-value detail card from a single log entry."""
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
- prompt_tokens = log.get("prompt_tokens", 0)
77
- completion_tokens = log.get("completion_tokens", 0)
78
- quota = log.get("quota", 0)
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"] = log.get("request_id", "") or "—"
82
- info["Response ID"] = log.get("response_id", "") or "—"
83
- info["Time"] = format_ts(log.get("created_at", 0))
84
- info["Model"] = log.get("model_name", "") or "—"
85
- info["Token Name"] = log.get("token_name", "") or "—"
86
- info["Group"] = log.get("group", "") or "—"
87
- info["Stream"] = "Yes" if log.get("is_stream") else "No"
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
- cache_tokens = other.get("cache_tokens", 0)
93
- if cache_tokens:
94
- info["Cache Tokens"] = f"{cache_tokens:,}"
95
-
96
- # Cost
97
- info["Cost (USD)"] = quota_to_usd(quota)
98
- info["Quota (raw)"] = f"{quota:,}"
99
-
100
- # Pricing breakdown from 'other'
101
- model_ratio = other.get("model_ratio")
102
- if model_ratio is not None:
103
- info["Model Ratio"] = model_ratio
104
- completion_ratio = other.get("completion_ratio")
105
- if completion_ratio is not None:
106
- info["Completion Ratio"] = f"{completion_ratio}x"
107
- group_ratio = other.get("group_ratio")
108
- if group_ratio is not None:
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
- use_time = log.get("use_time", 0)
119
- info["Duration"] = f"{use_time:,} ms" if use_time else "—"
120
- frt = other.get("frt")
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
- request_path = other.get("request_path")
126
- if request_path:
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[OutputFormat | None, typer.Option("--format", "-f", help="Output format (table, json, yaml, csv, markdown).")] = None,
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
- if fmt == OutputFormat.JSON:
222
- output(log_entry, fmt)
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
- info = _format_log_detail(log_entry)
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
- if fmt == OutputFormat.JSON:
271
- output(data, fmt)
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")