scripts.ask_openrouter
Reference › scripts › ask_openrouter
A command-line tool, built on typer, that sends a question to any model hosted on OpenRouter and optionally attaches scoped context. A human runs it; no module or test imports it. It reuses the OpenRouterAdapter from the provider layer (client and the protocols in core.protocols) for non-streaming calls.
Invocation: main takes the prompt as an optional argument and reads standard input when it is omitted and input is piped. Options are --model (required unless listing), --system, --context-file and --context (both repeatable), --max-context-tokens (default 8000), --temperature (0.2), --max-tokens (4000), --stream, --json, --list-models and a free-only filter for the catalog. Results go to stdout; status lines go to stderr.
run_cli holds the flow and returns an exit code, with 1 for configuration or usage errors. The key comes from the OPENROUTER_API_KEY variable, falling back to a dotenv file (resolve_api_key). --list-models fetches the catalog and prints a table (parse_models, format_models_table). Otherwise assemble_context reads the named files, passes the text through redact_context, caps it by approximate tokens (characters divided by four) and reports how many lines were redacted and how many tokens were sent. run_completion makes the call, or iter_sse_chunks parses the streamed events. Each run appends an entry to an audit log (build_audit_entry, append_audit_log, default_audit_log_path) recording the model, prompt, context file names and byte and token counts.
At a glance
| Source | scripts/ask_openrouter.py |
| Lines of code | 642 |
| Classes | 2 |
| Functions | 22 |
| Imports (internal) | orchestrata_ultimate.core.protocols, orchestrata_ultimate.llm.client, orchestrata_ultimate.providers.adapters.openrouter_adapter |
| Imported by | none |
| Tests that import it | none |
Class diagram
Dependency diagram
API
Classes
ContextMeta (dataclass)
Decorators: dataclass · Line 171
Metadata about an assembled context payload (for the stderr notice and the audit log — never the context text itself).
| Attribute | Type | Default |
|---|---|---|
n_files |
int |
|
n_lines_redacted |
int |
|
n_tokens |
int |
|
truncated |
bool |
|
truncated_chars |
int |
0 |
ModelRow (dataclass)
Decorators: dataclass · Line 263
One row of the OpenRouter model catalog.
| Attribute | Type | Default |
|---|---|---|
id |
str |
|
context_length |
int |
|
price_prompt |
str |
|
price_completion |
str |
Functions
| Function | Signature | Summary |
|---|---|---|
_fallback_parse_dotenv |
_fallback_parse_dotenv(paths: Iterable[str] = _DEFAULT_DOTENV_PATHS) -> dict[str, str] |
Minimal .env parser mirroring llm.client._parse_dotenv_file. |
_default_dotenv_loader |
_default_dotenv_loader() -> dict[str, str] |
Reuse llm.client._load_dotenv (repo root + /opt/orchestrata) when importable; otherwise fall back to a minimal inline parser. |
resolve_api_key |
resolve_api_key(env: Mapping[str, str] | None = None, dotenv_loader: DotenvLoader | None = None) -> str |
Resolve OPENROUTER_API_KEY: process env first, then .env. |
redact_context |
redact_context(text: str) -> tuple[str, int] |
Strip any line that looks like it carries a credential. |
validate_context_path |
validate_context_path(path: Path) -> None |
Refuse .env-like files and anything under a .git/ directory. |
assemble_context |
assemble_context(files: Iterable[Path], inline: Iterable[str], max_tokens: int) -> tuple[str, ContextMeta] |
Read, redact, and token-cap opt-in context from files + inline text. |
build_messages |
build_messages(system: str | None, user: str, context: str | None) -> list[Message] |
Build the OpenAI-compatible message list for the completion call. |
resolve_prompt |
resolve_prompt(prompt_arg: str | None, list_models: bool, stdin_is_tty: bool, stdin_reader: Callable[[], str]) -> str | None |
Resolve the prompt: the positional arg wins; otherwise read stdin (unless it's a tty or we're only listing models). |
parse_models |
parse_models(payload: dict[str, Any]) -> list[ModelRow] |
Extract id/context_length/pricing from a GET /models JSON body. |
_is_free |
_is_free(row: ModelRow) -> bool |
|
filter_free_models |
filter_free_models(rows: list[ModelRow]) -> list[ModelRow] |
Keep only free-tier models (:free suffix or zero pricing). |
format_models_table |
format_models_table(rows: list[ModelRow]) -> str |
Render model rows as an aligned plain-text table. |
_fetch_models_payload |
_fetch_models_payload(api_key: str) -> dict[str, Any] |
Real network call: GET the OpenRouter model catalog. |
run_completion |
run_completion(adapter_or_call: OpenRouterAdapter | CompleteCall, messages: list[Message], model: str, params: dict[str, Any]) -> RawProviderResponse |
Execute a completion via an :class:OpenRouterAdapter (uses its .complete) or a raw async callable with the same signature. |
_default_complete_call |
_default_complete_call(api_key: str) -> OpenRouterAdapter |
|
iter_sse_chunks |
iter_sse_chunks(lines: Iterable[str]) -> Iterator[str] |
Parse OpenAI-compatible SSE lines into content-delta strings. |
_default_stream_fetch |
_default_stream_fetch(messages: list[Message], model: str, params: dict[str, Any], api_key: str) -> Iterator[str] |
Real network call: POST with stream: true and yield token deltas. |
build_audit_entry |
build_audit_entry(model: str, prompt: str, context_files: list[str], bytes_sent: int, input_tokens: int, output_tokens: int) -> dict[str, Any] |
Build one audit record. |
append_audit_log |
append_audit_log(log_path: Path, entry: dict[str, Any]) -> None |
Append one JSON line to the audit log, creating its directory if needed. |
default_audit_log_path |
default_audit_log_path() -> Path |
|
run_cli |
run_cli(prompt: str | None, *, model: str | None, system: str | None, context_files: list[Path], context_inline: list[str], max_context_tokens: int, temperature: float, max_tokens: int, stream: bool, json_output: bool, list_models: bool, free_only: bool, env: Mapping[str, str] | None = None, dotenv… |
Resolve credentials, assemble context, run the completion (or catalog listing), print output, and write the audit log. |
main app.command |
main(prompt: str | None = typer.Argument(None, help='The prompt to send. Reads stdin …, model: str | None = typer.Option(None, '--model', help='OpenRouter model id, e.…, system: str | None = typer.Option(None, '--system', help='Optional system messag…, context_file: list[Path] = typer.Option([], '-… |
Ask any OpenRouter-hosted model (GPT/Gemini/Kimi/Qwen/DeepSeek/...) a question, with opt-in, redacted, token-capped project context. |
Source
#!/usr/bin/env python3
"""Ask any OpenRouter-hosted model a question, with opt-in scoped context.
Design: ``plans/openrouter-tool-design.md`` (task #26). Distinct from the
internal volk OpenRouter routing (``orchestrata_ultimate.llm.client`` /
``_register_openrouter``) — this is a Claude-Code-invokable CLI for
cross-vendor second opinions, judge panels, and cheap delegation. It reuses
the settled :class:`OpenRouterAdapter` for the actual chat call rather than
reimplementing the HTTP client.
Usage:
uv run python scripts/ask_openrouter.py "PROMPT" --model anthropic/claude-sonnet-4-6
echo "PROMPT" | uv run python scripts/ask_openrouter.py --model deepseek/deepseek-chat
uv run python scripts/ask_openrouter.py --list-models [--free]
Context is OPT-IN ONLY: by default no project context is sent. Passing
``--context-file``/``--context`` runs a mandatory redaction + token-cap pass
before anything leaves the process — see ``redact_context``/``assemble_context``
below and the ``.claude/skills/ask-openrouter`` skill for the full contract.
Every source/network boundary in this module is dependency-injected
(``run_cli``'s ``env``/``dotenv_loader``/``complete_call``/``models_fetch``/
``stream_fetch``/``audit_log_path`` keywords) so tests exercise the real
control flow without live API calls or mocking — the only sanctioned test
double in this project is the LLM/network boundary itself.
"""
import asyncio
import hashlib
import json
import os
import re
import sys
from collections.abc import Awaitable, Callable, Iterable, Iterator, Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, TextIO
import httpx
import typer
from orchestrata_ultimate.core.protocols import (
Message,
ProviderConfig,
RawProviderResponse,
)
from orchestrata_ultimate.providers.adapters.openrouter_adapter import (
OPENROUTER_BASE_URL,
OpenRouterAdapter,
)
MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
CHAT_URL = f"{OPENROUTER_BASE_URL}/chat/completions"
AUDIT_LOG_REL_PATH = Path(".orchestrata") / "openrouter_audit.log"
CompleteCall = Callable[
[list[Message], str, dict[str, Any]], Awaitable[RawProviderResponse]
]
StreamFetch = Callable[[list[Message], str, dict[str, Any], str], Iterator[str]]
ModelsFetch = Callable[[str], dict[str, Any]]
DotenvLoader = Callable[[], dict[str, str]]
app = typer.Typer(add_completion=False, no_args_is_help=True)
# ============================================================================
# Credential resolution — process env first, then .env (repo root)
# ============================================================================
_DEFAULT_DOTENV_PATHS = (".env", "/opt/orchestrata/.env")
def _fallback_parse_dotenv(
paths: Iterable[str] = _DEFAULT_DOTENV_PATHS,
) -> dict[str, str]:
"""Minimal .env parser mirroring ``llm.client._parse_dotenv_file``.
Used only if ``orchestrata_ultimate.llm.client`` becomes unimportable —
the primary path reuses that module's real ``_load_dotenv`` directly.
``paths`` defaults to the real lookup locations; tests pass explicit
paths instead.
"""
loaded: dict[str, str] = {}
for env_path in paths:
path = Path(env_path)
if not path.exists():
continue
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
value = value.strip().strip('"').strip("'")
key = key.strip()
if value and key not in os.environ:
loaded[key] = value
return loaded
def _default_dotenv_loader() -> dict[str, str]:
"""Reuse ``llm.client._load_dotenv`` (repo root + /opt/orchestrata) when
importable; otherwise fall back to a minimal inline parser."""
try:
from orchestrata_ultimate.llm.client import _load_dotenv
except ImportError:
return _fallback_parse_dotenv()
return _load_dotenv()
def resolve_api_key(
env: Mapping[str, str] | None = None,
dotenv_loader: DotenvLoader | None = None,
) -> str:
"""Resolve ``OPENROUTER_API_KEY``: process env first, then ``.env``.
Both sources are injectable so tests can exercise "key missing" and
"key found via .env" deterministically, independent of the real
process environment or the real repo-root ``.env`` file.
"""
active_env = env if env is not None else os.environ
key = active_env.get("OPENROUTER_API_KEY", "")
if key:
return key
loader = dotenv_loader or _default_dotenv_loader
return loader().get("OPENROUTER_API_KEY", "")
# ============================================================================
# Context redaction, capping, and assembly (security-critical — opt-in only)
# ============================================================================
_REDACT_PATTERN = re.compile(r"(API_KEY|SECRET|TOKEN|PASSWORD|Bearer\s)", re.IGNORECASE)
def redact_context(text: str) -> tuple[str, int]:
"""Strip any line that looks like it carries a credential.
Case-insensitive match on ``API_KEY|SECRET|TOKEN|PASSWORD|Bearer `` (a
literal space after Bearer, so only header-shaped tokens match) anywhere
in the line; the whole line is replaced with ``[REDACTED]``.
Returns:
(redacted_text, n_lines_redacted).
"""
n_redacted = 0
out_lines: list[str] = []
for line in text.splitlines():
if _REDACT_PATTERN.search(line):
out_lines.append("[REDACTED]")
n_redacted += 1
else:
out_lines.append(line)
return "\n".join(out_lines), n_redacted
def validate_context_path(path: Path) -> None:
"""Refuse .env-like files and anything under a ``.git/`` directory.
Raises:
ValueError: if the path is disallowed as a context source.
"""
if path.name.startswith(".env"):
raise ValueError(f"refusing to read env file as context: {path}")
if ".git" in path.parts:
raise ValueError(f"refusing to read .git-tracked path as context: {path}")
@dataclass
class ContextMeta:
"""Metadata about an assembled context payload (for the stderr notice
and the audit log — never the context text itself)."""
n_files: int
n_lines_redacted: int
n_tokens: int
truncated: bool
truncated_chars: int = 0
def assemble_context(
files: Iterable[Path],
inline: Iterable[str],
max_tokens: int,
) -> tuple[str, ContextMeta]:
"""Read, redact, and token-cap opt-in context from files + inline text.
Raises:
ValueError: if any file fails :func:`validate_context_path`.
"""
file_list = list(files)
for f in file_list:
validate_context_path(f)
sections: list[str] = []
n_lines_redacted = 0
for f in file_list:
raw = f.read_text(encoding="utf-8", errors="replace")
clean, n = redact_context(raw)
n_lines_redacted += n
sections.append(f"--- {f} ---\n{clean}")
for text in inline:
clean, n = redact_context(text)
n_lines_redacted += n
sections.append(clean)
combined = "\n\n".join(sections)
truncated = False
truncated_chars = 0
if max_tokens > 0:
max_chars = max_tokens * 4
if len(combined) > max_chars:
truncated_chars = len(combined) - max_chars
combined = (
combined[:max_chars] + f"\n[...truncated {truncated_chars} chars...]"
)
truncated = True
meta = ContextMeta(
n_files=len(file_list),
n_lines_redacted=n_lines_redacted,
n_tokens=len(combined) // 4,
truncated=truncated,
truncated_chars=truncated_chars,
)
return combined, meta
def build_messages(system: str | None, user: str, context: str | None) -> list[Message]:
"""Build the OpenAI-compatible message list for the completion call."""
messages: list[Message] = []
if system:
messages.append(Message(role="system", content=system))
content = user if not context else f"{user}\n\n---\nContext:\n{context}"
messages.append(Message(role="user", content=content))
return messages
def resolve_prompt(
prompt_arg: str | None,
list_models: bool,
stdin_is_tty: bool,
stdin_reader: Callable[[], str],
) -> str | None:
"""Resolve the prompt: the positional arg wins; otherwise read stdin
(unless it's a tty or we're only listing models). Returns None if no
prompt could be resolved."""
if prompt_arg is not None:
return prompt_arg
if list_models or stdin_is_tty:
return None
data = stdin_reader().strip()
return data or None
# ============================================================================
# Model catalog (--list-models)
# ============================================================================
@dataclass
class ModelRow:
"""One row of the OpenRouter model catalog."""
id: str
context_length: int
price_prompt: str
price_completion: str
def parse_models(payload: dict[str, Any]) -> list[ModelRow]:
"""Extract id/context_length/pricing from a ``GET /models`` JSON body."""
rows: list[ModelRow] = []
for entry in payload.get("data", []) or []:
pricing = entry.get("pricing") or {}
rows.append(
ModelRow(
id=str(entry.get("id", "")),
context_length=int(entry.get("context_length") or 0),
price_prompt=str(pricing.get("prompt", "0")),
price_completion=str(pricing.get("completion", "0")),
)
)
return rows
def _is_free(row: ModelRow) -> bool:
if row.id.endswith(":free"):
return True
try:
return float(row.price_prompt) == 0.0 and float(row.price_completion) == 0.0
except ValueError:
return False
def filter_free_models(rows: list[ModelRow]) -> list[ModelRow]:
"""Keep only free-tier models (``:free`` suffix or zero pricing)."""
return [r for r in rows if _is_free(r)]
def format_models_table(rows: list[ModelRow]) -> str:
"""Render model rows as an aligned plain-text table."""
header = f"{'MODEL':50} {'CONTEXT':>10} {'PROMPT $/1M':>14} {'COMPLETION $/1M':>16}"
lines = [header]
for r in rows:
lines.append(
f"{r.id:50} {r.context_length:>10} {r.price_prompt:>14} {r.price_completion:>16}"
)
return "\n".join(lines)
def _fetch_models_payload(api_key: str) -> dict[str, Any]:
"""Real network call: GET the OpenRouter model catalog."""
headers = {"Authorization": f"Bearer {api_key}"}
response = httpx.get(MODELS_URL, headers=headers, timeout=30.0)
response.raise_for_status()
payload: dict[str, Any] = response.json()
return payload
# ============================================================================
# Completion (non-streaming) — reuses OpenRouterAdapter
# ============================================================================
def run_completion(
adapter_or_call: OpenRouterAdapter | CompleteCall,
messages: list[Message],
model: str,
params: dict[str, Any],
) -> RawProviderResponse:
"""Execute a completion via an :class:`OpenRouterAdapter` (uses its
``.complete``) or a raw async callable with the same signature.
Tests inject a plain async function matching that signature — the
sanctioned LLM-boundary double — so no live API call is needed.
"""
call: CompleteCall = (
adapter_or_call.complete
if isinstance(adapter_or_call, OpenRouterAdapter)
else adapter_or_call
)
return asyncio.run(call(messages, model, params))
def _default_complete_call(api_key: str) -> OpenRouterAdapter:
cfg = ProviderConfig(
api_key=api_key, base_url=OPENROUTER_BASE_URL, timeout_seconds=180.0
)
return OpenRouterAdapter(cfg)
# ============================================================================
# Streaming (SSE) — the adapter has no streaming path; written directly here
# ============================================================================
def iter_sse_chunks(lines: Iterable[str]) -> Iterator[str]:
"""Parse OpenAI-compatible SSE lines into content-delta strings.
Stops at ``data: [DONE]``; ignores non-``data:`` lines (blank
keep-alives) and malformed JSON chunks.
"""
for line in lines:
if not line.startswith("data: "):
continue
data = line[len("data: ") :].strip()
if data == "[DONE]":
return
try:
payload = json.loads(data)
except json.JSONDecodeError:
continue
choices = payload.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta") or {}
content = delta.get("content")
if content:
yield content
def _default_stream_fetch(
messages: list[Message],
model: str,
params: dict[str, Any],
api_key: str,
) -> Iterator[str]:
"""Real network call: POST with ``stream: true`` and yield token deltas."""
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": model,
"messages": [{"role": m.role, "content": m.content} for m in messages],
"stream": True,
**params,
}
with httpx.stream(
"POST", CHAT_URL, headers=headers, json=payload, timeout=180.0
) as response:
response.raise_for_status()
yield from iter_sse_chunks(response.iter_lines())
# ============================================================================
# Audit log — sha256 + sizes only, NEVER the prompt/context text or the key
# ============================================================================
def build_audit_entry(
model: str,
prompt: str,
context_files: list[str],
bytes_sent: int,
input_tokens: int,
output_tokens: int,
) -> dict[str, Any]:
"""Build one audit record. Only the prompt's sha256 is logged — never
the prompt or context text, and never the API key."""
return {
"ts": datetime.now(UTC).isoformat(),
"model": model,
"prompt_sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
"context_files": context_files,
"bytes_sent": bytes_sent,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
}
def append_audit_log(log_path: Path, entry: dict[str, Any]) -> None:
"""Append one JSON line to the audit log, creating its directory if
needed."""
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(entry) + "\n")
def default_audit_log_path() -> Path:
return Path.cwd() / AUDIT_LOG_REL_PATH
# ============================================================================
# Core orchestration — every I/O boundary is an injectable keyword so tests
# drive the full control flow without live network calls or mocking.
# ============================================================================
def run_cli(
prompt: str | None,
*,
model: str | None,
system: str | None,
context_files: list[Path],
context_inline: list[str],
max_context_tokens: int,
temperature: float,
max_tokens: int,
stream: bool,
json_output: bool,
list_models: bool,
free_only: bool,
env: Mapping[str, str] | None = None,
dotenv_loader: DotenvLoader | None = None,
complete_call: OpenRouterAdapter | CompleteCall | None = None,
models_fetch: ModelsFetch | None = None,
stream_fetch: StreamFetch | None = None,
audit_log_path: Path | None = None,
stdout: TextIO = sys.stdout,
stderr: TextIO = sys.stderr,
) -> int:
"""Resolve credentials, assemble context, run the completion (or catalog
listing), print output, and write the audit log. Returns a process exit
code (0 success, 1 user/config error)."""
api_key = resolve_api_key(env, dotenv_loader)
if not api_key:
print(
"Error: OPENROUTER_API_KEY not set (checked process env and .env)",
file=stderr,
)
return 1
if list_models:
fetch = models_fetch or _fetch_models_payload
rows = parse_models(fetch(api_key))
if free_only:
rows = filter_free_models(rows)
print(format_models_table(rows), file=stdout)
return 0
if model is None:
print("Error: --model is required (use --list-models to browse)", file=stderr)
return 1
if prompt is None:
print("Error: no prompt provided (pass PROMPT or pipe via stdin)", file=stderr)
return 1
context_text: str | None = None
context_file_names: list[str] = [str(p) for p in context_files]
if context_files or context_inline:
try:
context_text, meta = assemble_context(
context_files, context_inline, max_context_tokens
)
except ValueError as exc:
print(f"Error: {exc}", file=stderr)
return 1
print(
f"redacted {meta.n_lines_redacted} lines, sent {meta.n_tokens} tokens "
f"of context from {meta.n_files} files",
file=stderr,
)
messages = build_messages(system, prompt, context_text)
params: dict[str, Any] = {"temperature": temperature, "max_tokens": max_tokens}
bytes_sent = sum(len(m.content.encode("utf-8")) for m in messages)
log_path = audit_log_path or default_audit_log_path()
if stream:
fetch_stream = stream_fetch or _default_stream_fetch
parts: list[str] = []
for chunk in fetch_stream(messages, model, params, api_key):
print(chunk, end="", file=stdout, flush=True)
parts.append(chunk)
print(file=stdout)
full_text = "".join(parts)
entry = build_audit_entry(
model,
prompt,
context_file_names,
bytes_sent,
len(prompt) // 4,
len(full_text) // 4,
)
append_audit_log(log_path, entry)
return 0
call = (
complete_call if complete_call is not None else _default_complete_call(api_key)
)
resp = run_completion(call, messages, model, params)
entry = build_audit_entry(
model,
prompt,
context_file_names,
bytes_sent,
resp.input_tokens,
resp.output_tokens,
)
append_audit_log(log_path, entry)
if json_output:
print(
json.dumps(
{
"content": resp.content,
"model": resp.model,
"input_tokens": resp.input_tokens,
"output_tokens": resp.output_tokens,
}
),
file=stdout,
)
else:
print(resp.content, file=stdout)
return 0
# ============================================================================
# Typer CLI — thin wiring only; all logic lives in run_cli() above
# ============================================================================
@app.command()
def main(
prompt: str | None = typer.Argument(
None, help="The prompt to send. Reads stdin if omitted (and not a tty)."
),
model: str | None = typer.Option(
None, "--model", help="OpenRouter model id, e.g. anthropic/claude-sonnet-4-6."
),
system: str | None = typer.Option(
None, "--system", help="Optional system message."
),
context_file: list[Path] = typer.Option(
[],
"--context-file",
help="Path to include as redacted, capped context (repeatable).",
),
context: list[str] = typer.Option(
[], "--context", help="Inline context text (repeatable)."
),
max_context_tokens: int = typer.Option(
8000,
"--max-context-tokens",
help="Hard cap on context tokens (approx chars/4).",
),
temperature: float = typer.Option(0.2, "--temperature"),
max_tokens: int = typer.Option(4000, "--max-tokens"),
stream: bool = typer.Option(
False, "--stream", help="Stream tokens to stdout via SSE."
),
json_output: bool = typer.Option(
False,
"--json",
help="Print {content, model, input_tokens, output_tokens} JSON.",
),
list_models: bool = typer.Option(
False,
"--list-models",
help="Print the OpenRouter catalog (id/context/pricing) and exit.",
),
free: bool = typer.Option(
False, "--free", help="With --list-models, only show free-tier models."
),
) -> None:
"""Ask any OpenRouter-hosted model (GPT/Gemini/Kimi/Qwen/DeepSeek/...) a
question, with opt-in, redacted, token-capped project context."""
resolved_prompt = resolve_prompt(
prompt, list_models, sys.stdin.isatty(), sys.stdin.read
)
exit_code = run_cli(
resolved_prompt,
model=model,
system=system,
context_files=list(context_file),
context_inline=list(context),
max_context_tokens=max_context_tokens,
temperature=temperature,
max_tokens=max_tokens,
stream=stream,
json_output=json_output,
list_models=list_models,
free_only=free,
)
raise typer.Exit(code=exit_code)
if __name__ == "__main__":
app()