colloquial 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.
colloquial/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Colloquial — rewrite machine text into colloquial, native-sounding language."""
2
+
3
+ __version__ = "0.1.0"
colloquial/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from colloquial.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
colloquial/cli.py ADDED
@@ -0,0 +1,500 @@
1
+ """Colloquial CLI.
2
+
3
+ Commands:
4
+ colloquial detect <path> [--lang LANG] [--report] [--format text|json]
5
+ colloquial humanize <path> [--lang] [--to LANG] [--self-check] [--report]
6
+ colloquial providers -- list OpenAI-compatible providers
7
+ colloquial models -- live model list from your configured provider
8
+ colloquial setup -- configure provider + key + model (interactive)
9
+ colloquial config -- show the resolved configuration
10
+ colloquial langs -- list available language profiles
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from collections.abc import Callable
19
+ from pathlib import Path
20
+ from typing import Any, TypedDict, cast
21
+
22
+ from colloquial import config as cfg
23
+ from colloquial.core import surface
24
+ from colloquial.core.detect import detect, prepare_text
25
+ from colloquial.core.langdetect import guess_language
26
+ from colloquial.core.report import format_report, score_document
27
+ from colloquial.providers import PROVIDERS, requires_key
28
+ from colloquial.rewrite.llm import LLMClient, LLMError
29
+
30
+ _TEXT_SUFFIXES = {".md", ".mdx", ".markdown", ".txt"}
31
+
32
+
33
+ class DetectResult(TypedDict):
34
+ path: str
35
+ language: str
36
+ words: int
37
+ sentences: int
38
+ ai_score: float
39
+ humanity_score: float
40
+ findings: int
41
+ burstiness: float
42
+ mattr: float
43
+ findings_detail: list[dict[str, Any]]
44
+
45
+
46
+ def _read(path: str) -> str:
47
+ p = Path(path)
48
+ if p.is_dir():
49
+ files = (
50
+ f.read_text(encoding="utf-8", errors="replace")
51
+ for f in sorted(p.rglob("*"))
52
+ if f.suffix in _TEXT_SUFFIXES
53
+ )
54
+ return "\n\n".join(files)
55
+ return p.read_text(encoding="utf-8", errors="replace")
56
+
57
+
58
+ def _detect_one(path: str, lang: str, fmt: str) -> DetectResult:
59
+ raw = _read(path)
60
+ det = detect(raw, language=lang)
61
+ ai, humanity = score_document(det)
62
+ result: DetectResult = {
63
+ "path": path,
64
+ "language": lang,
65
+ "words": det.text_stats.word_count,
66
+ "sentences": det.text_stats.sentence_count,
67
+ "ai_score": ai,
68
+ "humanity_score": humanity,
69
+ "findings": len(det.findings),
70
+ "burstiness": det.text_stats.burstiness,
71
+ "mattr": det.text_stats.mattr,
72
+ "findings_detail": [],
73
+ }
74
+ if fmt == "json":
75
+ result["findings_detail"] = [
76
+ {
77
+ "line": f.line,
78
+ "severity": f.severity,
79
+ "note": f.note,
80
+ "weight": f.weight,
81
+ "snippet": f.snippet[:80],
82
+ }
83
+ for f in det.findings[:20]
84
+ ]
85
+ return result
86
+
87
+
88
+ def _cmd_detect(args: argparse.Namespace) -> int:
89
+ if args.lang == "auto":
90
+ # M0: default to pt-br when the text is mostly accented; else en.
91
+ # A real language detector lands with the profile work.
92
+ args.lang = _guess_lang(args.paths[0])
93
+ results = [_detect_one(p, args.lang, args.format) for p in _expand(args.paths)]
94
+ if args.format == "json":
95
+ print(json.dumps(results, ensure_ascii=False, indent=2))
96
+ return 0
97
+
98
+ for r in results:
99
+ print(
100
+ f"{r['path']}: ai={r['ai_score']}/100 "
101
+ f"humanity={r['humanity_score']}/100 "
102
+ f"({r['words']} words, {r['findings']} surface hits)"
103
+ )
104
+ if args.report:
105
+ raw = _read(r["path"])
106
+ det = detect(raw, language=r["language"])
107
+ print()
108
+ print(format_report(det, prepare_text(raw)))
109
+ return 0
110
+
111
+
112
+ def _expand(paths: list[str]) -> list[str]:
113
+ out: list[str] = []
114
+ for p in paths:
115
+ path = Path(p)
116
+ if path.is_dir():
117
+ out.extend(
118
+ str(f)
119
+ for f in sorted(path.rglob("*"))
120
+ if f.suffix in _TEXT_SUFFIXES and ".git" not in f.parts
121
+ )
122
+ else:
123
+ out.append(str(path))
124
+ return out
125
+
126
+
127
+ def _guess_lang(path: str) -> str:
128
+ raw = _read(path)[:2000]
129
+ return guess_language(raw)
130
+
131
+
132
+ def build_parser() -> argparse.ArgumentParser:
133
+ parser = argparse.ArgumentParser(
134
+ prog="colloquial",
135
+ description=(
136
+ "Rewrite machine text into colloquial, native-sounding language — in any tongue."
137
+ ),
138
+ )
139
+ sub = parser.add_subparsers(dest="command", required=True)
140
+
141
+ det = sub.add_parser("detect", help="score text for machine-likeness (deterministic, offline)")
142
+ det.add_argument("paths", nargs="+", help="files or directories (.md/.mdx/.txt)")
143
+ det.add_argument("--lang", default="auto", help="language profile (auto, en, pt-br, ...)")
144
+ det.add_argument("--report", action="store_true", help="print full humanization report")
145
+ det.add_argument("--format", choices=["text", "json"], default="text")
146
+ det.set_defaults(func=_cmd_detect)
147
+
148
+ langs = sub.add_parser("langs", help="list available language profiles")
149
+ langs.set_defaults(func=_cmd_langs)
150
+
151
+ prov = sub.add_parser("providers", help="list built-in OpenAI-compatible providers")
152
+ prov.set_defaults(func=_cmd_providers)
153
+
154
+ mod = sub.add_parser(
155
+ "models", help="live model list from your configured provider (GET /models)"
156
+ )
157
+ mod.add_argument("--provider", default=None, help="override the configured provider")
158
+ mod.set_defaults(func=_cmd_models)
159
+
160
+ setup = sub.add_parser(
161
+ "setup",
162
+ help="configure provider, API key and model (writes ~/.config/colloquial/config.toml)",
163
+ )
164
+ setup.add_argument("--provider", default=None, help="provider key from `colloquial providers`")
165
+ setup.add_argument("--base-url", default=None, help="override the provider base URL")
166
+ setup.add_argument("--model", default=None, help="model id (skips the live list)")
167
+ setup.add_argument(
168
+ "--api-key",
169
+ default=None,
170
+ help="API key (prefer the interactive prompt; shells keep history)",
171
+ )
172
+ setup.set_defaults(func=_cmd_setup)
173
+
174
+ conf = sub.add_parser("config", help="show the resolved configuration (key masked)")
175
+ conf.set_defaults(func=_cmd_config)
176
+
177
+ mcp = sub.add_parser(
178
+ "mcp",
179
+ help="run the MCP server over stdio (Model Context Protocol; tools for any agent)",
180
+ )
181
+ mcp.set_defaults(func=_cmd_mcp)
182
+
183
+ hum = sub.add_parser(
184
+ "humanize",
185
+ help="rewrite text so it reads human (same language), on your configured provider",
186
+ )
187
+ hum.add_argument("paths", nargs="+", help="files or directories (.md/.mdx/.txt)")
188
+ hum.add_argument("--lang", default="auto", help="language profile (auto, en, pt-br, ar, ...)")
189
+ hum.add_argument(
190
+ "--to",
191
+ default=None,
192
+ help="translate-and-humanize into this language in one pass",
193
+ )
194
+ hum.add_argument(
195
+ "--from",
196
+ dest="from_lang",
197
+ default=None,
198
+ help="source language for --to (default: auto-detected)",
199
+ )
200
+ hum.add_argument(
201
+ "--self-check",
202
+ action="store_true",
203
+ help="re-detect the output and iterate once more if it still reads machine-made",
204
+ )
205
+ hum.add_argument(
206
+ "--report", action="store_true", help="print before/after scores and applied rewrites"
207
+ )
208
+ hum.add_argument("--out", default=None, help="write output to file instead of stdout")
209
+ hum.set_defaults(func=_cmd_humanize)
210
+
211
+ return parser
212
+
213
+
214
+ def _cmd_langs(args: argparse.Namespace) -> int:
215
+ for code in surface.available():
216
+ prof = surface.get_profile(code)
217
+ print(f"{code:8} {prof.name if prof else ''}")
218
+ return 0
219
+
220
+
221
+ def _cmd_providers(args: argparse.Namespace) -> int:
222
+ for key, p in PROVIDERS.items():
223
+ key_note = "" if p.requires_key else " (no key needed)"
224
+ print(f"{key:12} {p.label:28} {p.base_url}{key_note}")
225
+ if p.note:
226
+ print(f"{'':12} {p.note}")
227
+ return 0
228
+
229
+
230
+ def _cmd_models(args: argparse.Namespace) -> int:
231
+ try:
232
+ client = LLMClient(provider=args.provider)
233
+ models = client.list_models()
234
+ except LLMError as exc:
235
+ print(f"colloquial: error: {exc}", file=sys.stderr)
236
+ return 2
237
+ marker = client.model
238
+ for model in models:
239
+ current = " <- configured" if model == marker else ""
240
+ print(f"{model}{current}")
241
+ print(f"\n{len(models)} models available from {client.base_url}")
242
+ return 0
243
+
244
+
245
+ def _cmd_config(args: argparse.Namespace) -> int:
246
+ settings = cfg.resolve_llm_settings()
247
+ path = cfg.config_path()
248
+ print(f"config file : {path}{'' if path.exists() else ' (not created yet)'}")
249
+ print(f"provider : {settings.provider or '(none)'}")
250
+ print(f"base_url : {settings.base_url or '(none)'}")
251
+ print(f"model : {settings.model or '(none)'}")
252
+ print(f"api key : {cfg.mask_key(settings.api_key)}")
253
+ print(f"key needed : {'yes' if settings.needs_key else 'no'}")
254
+ print(f"source : {settings.source}")
255
+ return 0
256
+
257
+
258
+ def _ask(prompt: str) -> str:
259
+ try:
260
+ return input(prompt).strip()
261
+ except EOFError:
262
+ return ""
263
+
264
+
265
+ def _ask_key(prompt: str) -> str:
266
+ import getpass
267
+
268
+ try:
269
+ return getpass.getpass(prompt).strip()
270
+ except (EOFError, getpass.GetPassWarning):
271
+ return ""
272
+
273
+
274
+ def _pick_model(client: LLMClient, catalog_default: str) -> str:
275
+ """List live models when possible and let the user choose one."""
276
+ try:
277
+ models = client.list_models()
278
+ except LLMError as exc:
279
+ print(f" could not list models ({exc}).")
280
+ typed = _ask(f" model id [{catalog_default or 'required'}]: ")
281
+ return typed or catalog_default
282
+ if not models:
283
+ return catalog_default
284
+ for i, model in enumerate(models[:40], 1):
285
+ print(f" {i:3}. {model}")
286
+ if len(models) > 40:
287
+ print(f" ... and {len(models) - 40} more")
288
+ choice = _ask(f" model number or id [{catalog_default or '1'}]: ")
289
+ if not choice:
290
+ return catalog_default or models[0]
291
+ if choice.isdigit() and 1 <= int(choice) <= len(models[:40]):
292
+ return models[int(choice) - 1]
293
+ return choice
294
+
295
+
296
+ def _cmd_setup(args: argparse.Namespace) -> int:
297
+ provider_key: str = args.provider or ""
298
+ base_url: str = args.base_url or ""
299
+ model: str = args.model or ""
300
+ api_key: str = args.api_key or ""
301
+
302
+ interactive = not provider_key
303
+ if interactive:
304
+ # Easiest first: providers that need no account at all, then the rest.
305
+ ordered = sorted(PROVIDERS.items(), key=lambda item: item[1].requires_key)
306
+ print("Providers (all OpenAI-compatible; no-key options first):\n")
307
+ for i, (key, provider) in enumerate(ordered, 1):
308
+ suffix = " (no key needed)" if not provider.requires_key else ""
309
+ print(f"{i:3}. {key:12} {provider.label:28}{suffix}")
310
+ print(f"{len(ordered) + 1:3}. {'custom':12} any OpenAI-compatible endpoint")
311
+ choice = _ask("\nprovider number or name: ")
312
+ if choice.isdigit() and 1 <= int(choice) <= len(ordered):
313
+ provider_key = ordered[int(choice) - 1][0]
314
+ else:
315
+ provider_key = choice
316
+
317
+ if provider_key == "custom":
318
+ provider_key = ""
319
+ base_url = base_url or _ask("base_url (e.g. https://example.com/v1): ")
320
+ elif provider_key in PROVIDERS:
321
+ base_url = base_url or PROVIDERS[provider_key].base_url
322
+ model = model or PROVIDERS[provider_key].default_model
323
+ elif provider_key:
324
+ print(f"colloquial: error: unknown provider '{provider_key}'.", file=sys.stderr)
325
+ print("Run `colloquial providers` to see the catalog.", file=sys.stderr)
326
+ return 2
327
+
328
+ if not base_url:
329
+ print("colloquial: error: no base_url given; nothing to configure.", file=sys.stderr)
330
+ return 2
331
+
332
+ provider_catalog = PROVIDERS.get(provider_key)
333
+ needs_key = requires_key(provider_key, base_url)
334
+ if needs_key and not api_key:
335
+ if interactive:
336
+ api_key = _ask_key("API key (input hidden, leave empty to use env vars only): ")
337
+ if not api_key and not api_key_present_in_env():
338
+ print(
339
+ "colloquial: error: this provider needs an API key. "
340
+ "Pass --api-key, or set COLLOQUIAL_API_KEY later.",
341
+ file=sys.stderr,
342
+ )
343
+ return 2
344
+
345
+ if not model:
346
+ print("\nChecking the connection and fetching models...")
347
+ try:
348
+ client = LLMClient(
349
+ provider=provider_key or None,
350
+ base_url=base_url,
351
+ api_key=api_key or None,
352
+ )
353
+ model = _pick_model(client, provider_catalog.default_model if provider_catalog else "")
354
+ print(" connection ok.")
355
+ except LLMError as exc:
356
+ print(f" could not connect ({exc}).")
357
+ print(" (the configuration is still saved; fix the connection later)")
358
+ model = _ask(f"model id [{model or 'required'}]: ") or model
359
+ if not model:
360
+ print("colloquial: error: no model selected.", file=sys.stderr)
361
+ return 2
362
+
363
+ path = cfg.save_llm(provider=provider_key, base_url=base_url, model=model, api_key=api_key)
364
+ print(f"\nSaved {path} (mode 0600).")
365
+ print(f" provider : {provider_key or 'custom'}")
366
+ print(f" base_url : {base_url}")
367
+ print(f" model : {model}")
368
+ print(f" api key : {cfg.mask_key(api_key)}")
369
+ print("\nTry it: colloquial humanize README.md --report")
370
+ return 0
371
+
372
+
373
+ def api_key_present_in_env() -> bool:
374
+ import os
375
+
376
+ return bool(os.environ.get("COLLOQUIAL_API_KEY") or os.environ.get("OPENAI_API_KEY"))
377
+
378
+
379
+ def _cmd_mcp(args: argparse.Namespace) -> int:
380
+ from colloquial.mcp import serve
381
+
382
+ return serve()
383
+
384
+
385
+ _NO_PROVIDER_CLI_HELP = (
386
+ "colloquial: error: no provider configured — rewriting needs a model.\n"
387
+ " run `colloquial setup` (a local model such as Ollama needs no account), or\n"
388
+ " set COLLOQUIAL_API_KEY with COLLOQUIAL_BASE_URL and COLLOQUIAL_MODEL.\n"
389
+ " `colloquial detect` works without a provider."
390
+ )
391
+
392
+
393
+ def _provider_ready() -> bool:
394
+ from colloquial.config import resolve_llm_settings
395
+
396
+ settings = resolve_llm_settings()
397
+ return bool(settings.base_url) and (bool(settings.api_key) or not settings.needs_key)
398
+
399
+
400
+ def _ensure_provider() -> bool:
401
+ """Guarantee a provider before rewriting; walk the user through setup once.
402
+
403
+ On a terminal the first `humanize` with nothing configured opens the setup
404
+ interactively, because there is no rewriting without a model. In a pipe or
405
+ cron there is nobody to answer prompts, so the caller gets a clear error and
406
+ a non-zero exit instead.
407
+ """
408
+ if _provider_ready():
409
+ return True
410
+ if not sys.stdin.isatty():
411
+ print(_NO_PROVIDER_CLI_HELP, file=sys.stderr)
412
+ return False
413
+ print("colloquial: no provider configured yet — rewriting needs a model.")
414
+ print("Let's set one up now (Ctrl-C to cancel).\n")
415
+ code = _cmd_setup(argparse.Namespace(provider=None, base_url=None, model=None, api_key=None))
416
+ if code != 0 or not _provider_ready():
417
+ print(_NO_PROVIDER_CLI_HELP, file=sys.stderr)
418
+ return False
419
+ print()
420
+ return True
421
+
422
+
423
+ def _cmd_humanize(args: argparse.Namespace) -> int:
424
+ from colloquial.rewrite.engine import humanize_same_lang, humanize_translate
425
+
426
+ if not _ensure_provider():
427
+ return 2
428
+
429
+ if args.to:
430
+ for path in _expand(args.paths):
431
+ raw = _read(path)
432
+ result = humanize_translate(
433
+ raw,
434
+ to_lang=args.to,
435
+ from_lang=args.from_lang,
436
+ self_check=args.self_check,
437
+ )
438
+ if args.report:
439
+ print(f"== {path} ({result.note})")
440
+ print(f" ai before: {result.before_ai}/100 → after: {result.after_ai}/100")
441
+ if result.applied:
442
+ print(f" rewrites: {', '.join(result.applied[:12])}")
443
+ print()
444
+ if args.out:
445
+ Path(args.out).write_text(result.output, encoding="utf-8")
446
+ print(f"wrote {args.out}")
447
+ else:
448
+ print(result.output)
449
+ return 0
450
+
451
+ if args.lang == "auto":
452
+ args.lang = _guess_lang(args.paths[0])
453
+ for path in _expand(args.paths):
454
+ raw = _read(path)
455
+ result = humanize_same_lang(
456
+ raw,
457
+ lang=args.lang,
458
+ self_check=args.self_check,
459
+ )
460
+ if args.report:
461
+ print(f"== {path} ({args.lang}, {result.note})")
462
+ print(f" ai before: {result.before_ai}/100 → after: {result.after_ai}/100")
463
+ if result.applied:
464
+ print(f" rewrites: {', '.join(result.applied[:12])}")
465
+ print()
466
+ if args.out:
467
+ Path(args.out).write_text(result.output, encoding="utf-8")
468
+ print(f"wrote {args.out}")
469
+ else:
470
+ print(result.output)
471
+ return 0
472
+
473
+
474
+ def main(argv: list[str] | None = None) -> int:
475
+ parser = build_parser()
476
+ try:
477
+ args = parser.parse_args(argv)
478
+ except SystemExit as exc:
479
+ # argparse raises SystemExit(0) for --help/--version and a non-zero code
480
+ # for usage errors. Return the code as-is: turning the help exit (0)
481
+ # into an error would make `colloquial --help` fail in a script.
482
+ code = exc.code
483
+ if code is None:
484
+ return 2
485
+ return code if isinstance(code, int) else 2
486
+ func = cast(Callable[[argparse.Namespace], int] | None, getattr(args, "func", None))
487
+ if func is None:
488
+ parser.print_help()
489
+ return 2
490
+ try:
491
+ return func(args)
492
+ except KeyboardInterrupt:
493
+ return 130
494
+ except Exception as exc:
495
+ print(f"colloquial: error: {exc}", file=sys.stderr)
496
+ return 2
497
+
498
+
499
+ if __name__ == "__main__":
500
+ raise SystemExit(main())
colloquial/config.py ADDED
@@ -0,0 +1,192 @@
1
+ """User configuration for Colloquial's LLM mode.
2
+
3
+ Settings live in a small TOML file written with mode 0600:
4
+
5
+ ~/.config/colloquial/config.toml
6
+
7
+ overridable with ``$COLLOQUIAL_CONFIG`` (full path) or ``$XDG_CONFIG_HOME``.
8
+ The file is hand-parsed with a minimal parser so Python 3.10 stays supported
9
+ (stdlib ``tomllib`` only exists from 3.11). Only a flat ``[llm]`` section of
10
+ string values is read or written; unknown lines are ignored, so editing the
11
+ file by hand is safe.
12
+
13
+ Resolution precedence, highest first:
14
+ explicit arguments > environment variables > config file > catalog defaults
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import re
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ from colloquial.providers import PROVIDERS, requires_key
25
+
26
+ _SECTION = re.compile(r"^\[(?P<name>[A-Za-z0-9_.-]+)\]$")
27
+ _KV = re.compile(r'^(?P<key>[A-Za-z0-9_-]+)\s*=\s*"(?P<value>.*)"\s*(?:#.*)?$')
28
+ _ESCAPES = {"n": "\n", "t": "\t", '"': '"', "\\": "\\"}
29
+
30
+
31
+ class ConfigError(RuntimeError):
32
+ """Raised when the configuration file is unreadable or invalid."""
33
+
34
+
35
+ def config_path() -> Path:
36
+ """Where the config file lives (respects env overrides)."""
37
+ explicit = os.environ.get("COLLOQUIAL_CONFIG")
38
+ if explicit:
39
+ return Path(explicit)
40
+ xdg = os.environ.get("XDG_CONFIG_HOME")
41
+ base = Path(xdg) if xdg else Path.home() / ".config"
42
+ return base / "colloquial" / "config.toml"
43
+
44
+
45
+ def _unescape(value: str) -> str:
46
+ out: list[str] = []
47
+ i = 0
48
+ while i < len(value):
49
+ char = value[i]
50
+ if char == "\\" and i + 1 < len(value):
51
+ out.append(_ESCAPES.get(value[i + 1], value[i + 1]))
52
+ i += 2
53
+ else:
54
+ out.append(char)
55
+ i += 1
56
+ return "".join(out)
57
+
58
+
59
+ def _escape(value: str) -> str:
60
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
61
+
62
+
63
+ def load_llm() -> dict[str, str]:
64
+ """Read the ``[llm]`` section; missing file yields an empty dict."""
65
+ path = config_path()
66
+ if not path.exists():
67
+ return {}
68
+ try:
69
+ text = path.read_text(encoding="utf-8")
70
+ except OSError as exc: # pragma: no cover - unreadable file edge
71
+ raise ConfigError(f"cannot read {path}: {exc}") from exc
72
+
73
+ section = ""
74
+ values: dict[str, str] = {}
75
+ for raw in text.splitlines():
76
+ line = raw.strip()
77
+ if not line or line.startswith("#"):
78
+ continue
79
+ section_match = _SECTION.match(line)
80
+ if section_match:
81
+ section = section_match.group("name")
82
+ continue
83
+ kv_match = _KV.match(line)
84
+ if kv_match and section == "llm":
85
+ values[kv_match.group("key")] = _unescape(kv_match.group("value"))
86
+ return values
87
+
88
+
89
+ def save_llm(
90
+ *,
91
+ provider: str,
92
+ base_url: str,
93
+ model: str,
94
+ api_key: str,
95
+ ) -> Path:
96
+ """Write the ``[llm]`` section with 0600 permissions; return the path."""
97
+ path = config_path()
98
+ try:
99
+ path.parent.mkdir(parents=True, exist_ok=True)
100
+ lines = [
101
+ "# Colloquial configuration. Written by `colloquial setup`.",
102
+ "# The API key below is stored in plain text with mode 0600.",
103
+ "# Prefer `COLLOQUIAL_API_KEY` in the environment to keep it out of files.",
104
+ "",
105
+ "[llm]",
106
+ f'provider = "{_escape(provider)}"',
107
+ ]
108
+ if base_url:
109
+ lines.append(f'base_url = "{_escape(base_url)}"')
110
+ if model:
111
+ lines.append(f'model = "{_escape(model)}"')
112
+ if api_key:
113
+ lines.append(f'api_key = "{_escape(api_key)}"')
114
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
115
+ path.chmod(0o600)
116
+ except OSError as exc:
117
+ raise ConfigError(f"cannot write {path}: {exc}") from exc
118
+ return path
119
+
120
+
121
+ def mask_key(key: str) -> str:
122
+ """Show only a short prefix/suffix so logs and screens stay safe."""
123
+ if not key:
124
+ return "(none)"
125
+ if len(key) <= 8:
126
+ return "***"
127
+ return f"{key[:4]}...{key[-4:]}"
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class LLMSettings:
132
+ """Fully resolved LLM settings plus where they came from."""
133
+
134
+ provider: str
135
+ base_url: str
136
+ model: str
137
+ api_key: str
138
+ source: str
139
+ needs_key: bool
140
+
141
+
142
+ def resolve_llm_settings(
143
+ *,
144
+ provider: str | None = None,
145
+ base_url: str | None = None,
146
+ model: str | None = None,
147
+ api_key: str | None = None,
148
+ ) -> LLMSettings:
149
+ """Merge arguments, environment variables and the config file."""
150
+ file_values = load_llm()
151
+
152
+ resolved_provider = (
153
+ provider or os.environ.get("COLLOQUIAL_PROVIDER") or file_values.get("provider") or ""
154
+ )
155
+ catalog = PROVIDERS.get(resolved_provider)
156
+ resolved_base_url = (
157
+ base_url
158
+ or os.environ.get("COLLOQUIAL_BASE_URL")
159
+ or file_values.get("base_url")
160
+ or (catalog.base_url if catalog else "")
161
+ ).rstrip("/")
162
+ resolved_model = (
163
+ model
164
+ or os.environ.get("COLLOQUIAL_MODEL")
165
+ or file_values.get("model")
166
+ or (catalog.default_model if catalog else "")
167
+ )
168
+ resolved_key = (
169
+ api_key
170
+ or os.environ.get("COLLOQUIAL_API_KEY")
171
+ or os.environ.get("OPENAI_API_KEY")
172
+ or file_values.get("api_key")
173
+ or ""
174
+ )
175
+
176
+ if provider or os.environ.get("COLLOQUIAL_PROVIDER"):
177
+ source = "arguments/environment" if provider else "environment"
178
+ elif file_values:
179
+ source = "config file"
180
+ else:
181
+ # Nothing configured and no provider selected: the caller decides what
182
+ # to do about it (the CLI opens setup, the library raises).
183
+ source = "none"
184
+
185
+ return LLMSettings(
186
+ provider=resolved_provider,
187
+ base_url=resolved_base_url,
188
+ model=resolved_model,
189
+ api_key=resolved_key,
190
+ source=source,
191
+ needs_key=requires_key(resolved_provider, resolved_base_url),
192
+ )
@@ -0,0 +1 @@
1
+ """Colloquial core — deterministic detection and scoring."""