osintkit 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (82) hide show
  1. {osintkit-0.2.0 → osintkit-0.3.0}/CHANGELOG.md +12 -0
  2. {osintkit-0.2.0 → osintkit-0.3.0}/PKG-INFO +7 -4
  3. {osintkit-0.2.0 → osintkit-0.3.0}/README.md +6 -3
  4. {osintkit-0.2.0 → osintkit-0.3.0}/docs/REFERENCE.md +13 -4
  5. {osintkit-0.2.0 → osintkit-0.3.0}/pyproject.toml +1 -1
  6. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/__init__.py +1 -1
  7. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/cli.py +13 -4
  8. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/config.py +46 -2
  9. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/doctor.py +24 -8
  10. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/llm.py +2 -2
  11. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/pipeline.py +4 -2
  12. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_cli.py +36 -0
  13. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_doctor.py +21 -0
  14. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_llm.py +13 -0
  15. {osintkit-0.2.0 → osintkit-0.3.0}/.editorconfig +0 -0
  16. {osintkit-0.2.0 → osintkit-0.3.0}/.github/CODEOWNERS +0 -0
  17. {osintkit-0.2.0 → osintkit-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  18. {osintkit-0.2.0 → osintkit-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  19. {osintkit-0.2.0 → osintkit-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  20. {osintkit-0.2.0 → osintkit-0.3.0}/.github/dependabot.yml +0 -0
  21. {osintkit-0.2.0 → osintkit-0.3.0}/.github/pull_request_template.md +0 -0
  22. {osintkit-0.2.0 → osintkit-0.3.0}/.github/workflows/ci.yml +0 -0
  23. {osintkit-0.2.0 → osintkit-0.3.0}/.github/workflows/codeql.yml +0 -0
  24. {osintkit-0.2.0 → osintkit-0.3.0}/.github/workflows/publish.yml +0 -0
  25. {osintkit-0.2.0 → osintkit-0.3.0}/.github/workflows/release.yml +0 -0
  26. {osintkit-0.2.0 → osintkit-0.3.0}/.github/workflows/smoke.yml +0 -0
  27. {osintkit-0.2.0 → osintkit-0.3.0}/.gitignore +0 -0
  28. {osintkit-0.2.0 → osintkit-0.3.0}/.mailmap +0 -0
  29. {osintkit-0.2.0 → osintkit-0.3.0}/.pre-commit-config.yaml +0 -0
  30. {osintkit-0.2.0 → osintkit-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  31. {osintkit-0.2.0 → osintkit-0.3.0}/CONTRIBUTING.md +0 -0
  32. {osintkit-0.2.0 → osintkit-0.3.0}/LICENSE +0 -0
  33. {osintkit-0.2.0 → osintkit-0.3.0}/SECURITY.md +0 -0
  34. {osintkit-0.2.0 → osintkit-0.3.0}/docs/architecture.md +0 -0
  35. {osintkit-0.2.0 → osintkit-0.3.0}/docs/demo.svg +0 -0
  36. {osintkit-0.2.0 → osintkit-0.3.0}/docs/sample-output.md +0 -0
  37. {osintkit-0.2.0 → osintkit-0.3.0}/docs/social-preview.png +0 -0
  38. {osintkit-0.2.0 → osintkit-0.3.0}/examples/README.md +0 -0
  39. {osintkit-0.2.0 → osintkit-0.3.0}/examples/targets.csv +0 -0
  40. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/__init__.py +0 -0
  41. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/accounts.py +0 -0
  42. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/base.py +0 -0
  43. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/breaches.py +0 -0
  44. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/ct.py +0 -0
  45. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/dns.py +0 -0
  46. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/github.py +0 -0
  47. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/pastes.py +0 -0
  48. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/ports.py +0 -0
  49. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/subdomains.py +0 -0
  50. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/whois_lookup.py +0 -0
  51. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/diff.py +0 -0
  52. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/errors.py +0 -0
  53. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/http.py +0 -0
  54. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/logging_store.py +0 -0
  55. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/models.py +0 -0
  56. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/normalise.py +0 -0
  57. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/offline.py +0 -0
  58. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/py.typed +0 -0
  59. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/render.py +0 -0
  60. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/retention.py +0 -0
  61. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/synthesis.py +0 -0
  62. {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/targets.py +0 -0
  63. {osintkit-0.2.0 → osintkit-0.3.0}/tests/conftest.py +0 -0
  64. {osintkit-0.2.0 → osintkit-0.3.0}/tests/fixtures/model_reply.json +0 -0
  65. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_accounts.py +0 -0
  66. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_breaches.py +0 -0
  67. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_collectors.py +0 -0
  68. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_config.py +0 -0
  69. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_ct.py +0 -0
  70. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_diff.py +0 -0
  71. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_e2e.py +0 -0
  72. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_http.py +0 -0
  73. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_normalise.py +0 -0
  74. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_offline.py +0 -0
  75. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_pastes.py +0 -0
  76. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_pipeline.py +0 -0
  77. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_render.py +0 -0
  78. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_retention.py +0 -0
  79. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_synthesis.py +0 -0
  80. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_targets.py +0 -0
  81. {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_whois.py +0 -0
  82. {osintkit-0.2.0 → osintkit-0.3.0}/uv.lock +0 -0
@@ -6,6 +6,18 @@ All notable changes are recorded here. The format follows
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.3.0 — 2026-11-02
10
+
11
+ ### Added
12
+
13
+ - providers for DeepSeek, Mistral, Groq, Together, Anthropic, and local Ollama
14
+ - provider selection from its own environment variable (`DEEPSEEK_API_KEY`, `MISTRAL_API_KEY`, ...)
15
+ - keyless providers (Ollama) work without any key, including in `doctor`
16
+
17
+ ### Changed
18
+
19
+ - `doctor` reports the provider and where the key came from
20
+
9
21
  ## 0.2.0 — 2026-11-02
10
22
 
11
23
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: osintkit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Public-data OSINT aggregator for emails, usernames, domains, and companies
5
5
  Project-URL: Homepage, https://github.com/sjdsjadjajdjsa/osintkit
6
6
  Project-URL: Issues, https://github.com/sjdsjadjajdjsa/osintkit/issues
@@ -74,7 +74,7 @@ checksums, and `pip install -e .` works from a clone. Python 3.12+ only.
74
74
  ## Quick start
75
75
 
76
76
  ```bash
77
- export API_KEY=... # model key, any OpenAI-compatible endpoint
77
+ export API_KEY=... # or DEEPSEEK_API_KEY, MISTRAL_API_KEY, ...
78
78
  osintkit doctor --deep # prove the key and endpoint work
79
79
  osintkit lookup alice@example.com
80
80
  ```
@@ -83,8 +83,11 @@ No key? `osintkit lookup example.com --offline` builds the whole report locally
83
83
  no model involved. `--raw` gives the collected JSON and `--dry-run` prints the prompt a model
84
84
  would receive.
85
85
 
86
- Any OpenAI-compatible endpoint works. `--provider venice|openrouter|openai` picks a preset, and
87
- a recognisable key prefix picks one for you; `--base-url` and `--model` always win.
86
+ Any OpenAI-compatible endpoint works: export the provider's own variable (`DEEPSEEK_API_KEY`,
87
+ `MISTRAL_API_KEY`, `GROQ_API_KEY`, `VENICE_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`,
88
+ `TOGETHER_API_KEY`, `ANTHROPIC_API_KEY`) and that provider is used. `--provider` also accepts
89
+ `ollama` for a local server with no key. `--base-url` and `--model` always win, and the full
90
+ preset table is in [docs/REFERENCE.md](docs/REFERENCE.md#providers).
88
91
 
89
92
  Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts GitHub from 60 to
90
93
  5000 requests/hour. Everything else needs no credentials.
@@ -42,7 +42,7 @@ checksums, and `pip install -e .` works from a clone. Python 3.12+ only.
42
42
  ## Quick start
43
43
 
44
44
  ```bash
45
- export API_KEY=... # model key, any OpenAI-compatible endpoint
45
+ export API_KEY=... # or DEEPSEEK_API_KEY, MISTRAL_API_KEY, ...
46
46
  osintkit doctor --deep # prove the key and endpoint work
47
47
  osintkit lookup alice@example.com
48
48
  ```
@@ -51,8 +51,11 @@ No key? `osintkit lookup example.com --offline` builds the whole report locally
51
51
  no model involved. `--raw` gives the collected JSON and `--dry-run` prints the prompt a model
52
52
  would receive.
53
53
 
54
- Any OpenAI-compatible endpoint works. `--provider venice|openrouter|openai` picks a preset, and
55
- a recognisable key prefix picks one for you; `--base-url` and `--model` always win.
54
+ Any OpenAI-compatible endpoint works: export the provider's own variable (`DEEPSEEK_API_KEY`,
55
+ `MISTRAL_API_KEY`, `GROQ_API_KEY`, `VENICE_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`,
56
+ `TOGETHER_API_KEY`, `ANTHROPIC_API_KEY`) and that provider is used. `--provider` also accepts
57
+ `ollama` for a local server with no key. `--base-url` and `--model` always win, and the full
58
+ preset table is in [docs/REFERENCE.md](docs/REFERENCE.md#providers).
56
59
 
57
60
  Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts GitHub from 60 to
58
61
  5000 requests/hour. Everything else needs no credentials.
@@ -34,11 +34,20 @@ osintkit --provider openrouter ...
34
34
  osintkit --base-url https://gateway.internal/v1 --model house-model ...
35
35
  ```
36
36
 
37
- | `--provider` | Endpoint | Default model | Auto-selected by |
37
+ | `--provider` | Endpoint | Default model | Key variable |
38
38
  | --- | --- | --- | --- |
39
- | `venice` (default) | `api.venice.ai/api/v1` | `deepseek-v4-1-flash` | anything else |
40
- | `openrouter` | `openrouter.ai/api/v1` | `deepseek/deepseek-v4.1-flash-1017` | `sk-or-` key |
41
- | `openai` | `api.openai.com/v1` | `gpt-4.1-mini` | `sk-proj-` key |
39
+ | `venice` (default) | `api.venice.ai/api/v1` | `deepseek-v4-1-flash` | `API_KEY`, `VENICE_API_KEY` |
40
+ | `openrouter` | `openrouter.ai/api/v1` | `deepseek/deepseek-v4.1-flash-1017` | `OPENROUTER_API_KEY`, `sk-or-` keys |
41
+ | `openai` | `api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY`, `sk-proj-` keys |
42
+ | `deepseek` | `api.deepseek.com/v1` | `deepseek-chat` | `DEEPSEEK_API_KEY` |
43
+ | `mistral` | `api.mistral.ai/v1` | `mistral-small-latest` | `MISTRAL_API_KEY` |
44
+ | `groq` | `api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY`, `gsk_` keys |
45
+ | `together` | `api.together.xyz/v1` | Llama 3.3 70B | `TOGETHER_API_KEY` |
46
+ | `anthropic` | `api.anthropic.com/v1` | `claude-sonnet-4-5` | `ANTHROPIC_API_KEY`, `sk-ant-` keys |
47
+ | `ollama` | `localhost:11434/v1` | `llama3.2` | none |
48
+
49
+ Set a provider-specific variable and that provider is chosen; `API_KEY` alone falls back to the
50
+ default. Recognisable key prefixes override that inference. `--provider` beats both.
42
51
 
43
52
  Explicit `--base-url` and `--model` beat both the provider and the prefix. Temperature is 0.3,
44
53
  `--max-tokens` defaults to 2400.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "osintkit"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Public-data OSINT aggregator for emails, usernames, domains, and companies"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -1,3 +1,3 @@
1
1
  """osintkit: a public-data OSINT aggregator."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -21,10 +21,12 @@ from rich.table import Table
21
21
  from . import __version__, diff, doctor, pipeline, render, retention
22
22
  from .collectors import REGISTRY
23
23
  from .config import (
24
+ GENERIC_KEY_ENVS,
24
25
  PROVIDERS,
25
26
  Settings,
26
27
  cache_clear,
27
28
  cache_stats,
29
+ provider_from_env,
28
30
  provider_from_key,
29
31
  )
30
32
  from .errors import OsintKitError, TargetParseError
@@ -32,7 +34,7 @@ from .logging_store import RunLogger, find_report_for_run, read_report, read_run
32
34
  from .targets import VALID_TYPES
33
35
 
34
36
  app = typer.Typer(
35
- add_completion=False,
37
+ add_completion=True,
36
38
  no_args_is_help=True,
37
39
  help="Public-data OSINT aggregator for emails, usernames, domains, and companies.",
38
40
  rich_markup_mode="rich",
@@ -61,7 +63,7 @@ def _version_callback(value: bool) -> None:
61
63
  @app.callback()
62
64
  def root(
63
65
  provider: str | None = typer.Option(
64
- None, "--provider", help="Endpoint preset: venice, openrouter, or openai."
66
+ None, "--provider", help="Endpoint preset; see docs/REFERENCE.md#providers."
65
67
  ),
66
68
  base_url: str | None = typer.Option(
67
69
  None, "--base-url", help="OpenAI-compatible base URL (overrides the provider)."
@@ -88,10 +90,17 @@ def root(
88
90
  ) -> None:
89
91
  if provider and provider not in PROVIDERS:
90
92
  raise typer.BadParameter(f"unknown provider {provider!r}; choose from {', '.join(PROVIDERS)}")
91
- effective_key = api_key or os.environ.get("API_KEY") or os.environ.get("OPENAI_API_KEY")
92
- chosen = provider or provider_from_key(effective_key) or "venice"
93
+ generic_env = next((os.environ[name] for name in GENERIC_KEY_ENVS if os.environ.get(name)), None)
94
+ chosen = (
95
+ provider
96
+ or provider_from_key(api_key)
97
+ or provider_from_env()
98
+ or provider_from_key(generic_env)
99
+ or "venice"
100
+ )
93
101
  preset = PROVIDERS[chosen]
94
102
  settings = Settings(
103
+ provider=chosen,
95
104
  base_url=base_url or preset[0],
96
105
  model=model or preset[1],
97
106
  api_key=api_key,
@@ -23,8 +23,31 @@ PROVIDERS: dict[str, tuple[str, str]] = {
23
23
  "venice": (DEFAULT_BASE_URL, DEFAULT_MODEL),
24
24
  "openrouter": ("https://openrouter.ai/api/v1", "deepseek/deepseek-v4.1-flash-1017"),
25
25
  "openai": ("https://api.openai.com/v1", "gpt-4.1-mini"),
26
+ "deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
27
+ "mistral": ("https://api.mistral.ai/v1", "mistral-small-latest"),
28
+ "groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
29
+ "together": ("https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo"),
30
+ "anthropic": ("https://api.anthropic.com/v1", "claude-sonnet-4-5"),
31
+ "ollama": ("http://localhost:11434/v1", "llama3.2"),
26
32
  }
27
- KEY_PREFIXES: dict[str, str] = {"sk-or-": "openrouter", "sk-proj-": "openai"}
33
+ KEYLESS_PROVIDERS = frozenset({"ollama"})
34
+ KEY_PREFIXES: dict[str, str] = {
35
+ "sk-or-": "openrouter",
36
+ "sk-proj-": "openai",
37
+ "gsk_": "groq",
38
+ "sk-ant-": "anthropic",
39
+ }
40
+ PROVIDER_ENV: dict[str, str] = {
41
+ "VENICE_API_KEY": "venice",
42
+ "OPENROUTER_API_KEY": "openrouter",
43
+ "OPENAI_API_KEY": "openai",
44
+ "DEEPSEEK_API_KEY": "deepseek",
45
+ "MISTRAL_API_KEY": "mistral",
46
+ "GROQ_API_KEY": "groq",
47
+ "TOGETHER_API_KEY": "together",
48
+ "ANTHROPIC_API_KEY": "anthropic",
49
+ }
50
+ GENERIC_KEY_ENVS = ("API_KEY",)
28
51
 
29
52
 
30
53
  def provider_from_key(api_key: str | None) -> str | None:
@@ -37,6 +60,14 @@ def provider_from_key(api_key: str | None) -> str | None:
37
60
  return None
38
61
 
39
62
 
63
+ def provider_from_env() -> str | None:
64
+ """Infer a provider from whichever provider-specific variable is set."""
65
+ for variable, provider in PROVIDER_ENV.items():
66
+ if os.environ.get(variable):
67
+ return provider
68
+ return None
69
+
70
+
40
71
  def app_home() -> Path:
41
72
  override = os.environ.get(APP_HOME_ENV)
42
73
  if override:
@@ -67,6 +98,7 @@ class Settings:
67
98
 
68
99
  target: str = ""
69
100
  target_type: str = ""
101
+ provider: str = "venice"
70
102
  base_url: str = DEFAULT_BASE_URL
71
103
  model: str = DEFAULT_MODEL
72
104
  api_key: str | None = None
@@ -95,7 +127,19 @@ class Settings:
95
127
  return os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or None
96
128
 
97
129
  def resolved_api_key(self) -> str | None:
98
- return self.api_key or os.environ.get("API_KEY") or os.environ.get("OPENAI_API_KEY")
130
+ if self.api_key:
131
+ return self.api_key
132
+ for variable, provider in PROVIDER_ENV.items():
133
+ if provider == self.provider and os.environ.get(variable):
134
+ return os.environ[variable]
135
+ for variable in GENERIC_KEY_ENVS:
136
+ if os.environ.get(variable):
137
+ return os.environ[variable]
138
+ return None
139
+
140
+ @property
141
+ def needs_key(self) -> bool:
142
+ return self.provider not in KEYLESS_PROVIDERS
99
143
 
100
144
 
101
145
  def _cache_key(namespace: str, key: str) -> Path:
@@ -7,7 +7,7 @@ import tempfile
7
7
  from dataclasses import dataclass, replace
8
8
  from pathlib import Path
9
9
 
10
- from .config import Settings, cache_dir, ensure_dirs, log_dir
10
+ from .config import PROVIDER_ENV, Settings, cache_dir, ensure_dirs, log_dir
11
11
  from .http import HttpClient
12
12
 
13
13
 
@@ -30,10 +30,10 @@ def _writable(path: Path) -> bool:
30
30
 
31
31
  async def _reach_endpoint(settings: Settings, deep: bool = False) -> Check:
32
32
  key = settings.resolved_api_key()
33
- if not key:
33
+ if settings.needs_key and not key:
34
34
  return Check("model endpoint", "warn", "no API key; synthesis will fail, dry runs still work")
35
35
  base = settings.base_url.rstrip("/")
36
- headers = {"Authorization": f"Bearer {key}"}
36
+ headers = {"Authorization": f"Bearer {key}"} if key else {}
37
37
  fresh = replace(settings, use_cache=False)
38
38
 
39
39
  try:
@@ -81,14 +81,30 @@ async def _reach_endpoint(settings: Settings, deep: bool = False) -> Check:
81
81
  return Check("model endpoint", "ok", f"{base} accepted a completion for {settings.model}")
82
82
 
83
83
 
84
+ def _key_source(settings: Settings) -> str | None:
85
+ if settings.api_key:
86
+ return "--api-key"
87
+ for variable, provider in PROVIDER_ENV.items():
88
+ if provider == settings.provider and os.environ.get(variable):
89
+ return variable
90
+ for variable in ("API_KEY",):
91
+ if os.environ.get(variable):
92
+ return variable
93
+ return None
94
+
95
+
84
96
  def collect_checks(settings: Settings) -> list[Check]:
85
97
  key = settings.resolved_api_key()
98
+ source = _key_source(settings)
99
+ if not settings.needs_key:
100
+ key_check = Check("api key", "ok", f"not needed for {settings.provider}")
101
+ elif key:
102
+ key_check = Check("api key", "ok", f"from {source}")
103
+ else:
104
+ key_check = Check("api key", "warn", f"no key for {settings.provider}; set one or use --offline")
86
105
  checks = [
87
- Check(
88
- "api key",
89
- "ok" if key else "warn",
90
- "set" if key else "API_KEY is not set",
91
- ),
106
+ Check("provider", "ok", settings.provider),
107
+ key_check,
92
108
  Check(
93
109
  "cache dir",
94
110
  "ok" if _writable(cache_dir()) else "fail",
@@ -38,7 +38,7 @@ class LLMClient:
38
38
  response_format: Mapping[str, Any] | None = None,
39
39
  ) -> str:
40
40
  api_key = self.settings.resolved_api_key()
41
- if not api_key:
41
+ if not api_key and self.settings.needs_key:
42
42
  raise MissingAPIKey("No API key. Set API_KEY in the environment or pass --api-key.")
43
43
 
44
44
  payload: dict[str, Any] = {
@@ -57,7 +57,7 @@ class LLMClient:
57
57
  namespace="llm",
58
58
  payload=payload,
59
59
  headers={
60
- "Authorization": f"Bearer {api_key}",
60
+ **({"Authorization": f"Bearer {api_key}"} if api_key else {}),
61
61
  "Content-Type": "application/json",
62
62
  },
63
63
  )
@@ -212,8 +212,10 @@ async def execute(
212
212
  return Outcome(report=report, extra={"prompt": synthesis_prompt}, archived=archived)
213
213
 
214
214
  try:
215
- if not settings.resolved_api_key():
216
- raise MissingAPIKey("No API key. Set API_KEY or pass --api-key (dry run works without one).")
215
+ if settings.needs_key and not settings.resolved_api_key():
216
+ raise MissingAPIKey(
217
+ "No API key. Set API_KEY, pass --api-key, or use --offline for a local report."
218
+ )
217
219
  async with LLMClient(settings, http) as client:
218
220
  with _status(progress, settings, "[cyan]synthesising briefing…[/cyan]"):
219
221
  report.analysis = await synthesis.synthesize(
@@ -472,3 +472,39 @@ def test_offline_batch(monkeypatch, tmp_path):
472
472
  result = runner.invoke(app, ["batch", "--file", str(csv_file), "--offline", "--json"])
473
473
  assert result.exit_code == 0
474
474
  assert jsonish(result.output)[0]["analysis"]["risk_level"] == "low"
475
+
476
+
477
+ def test_missing_key_mentions_offline(monkeypatch):
478
+ monkeypatch.delenv("API_KEY")
479
+ monkeypatch.setattr(pipeline, "LLMClient", None)
480
+ result = runner.invoke(app, ["lookup", "--domain", "example.com"])
481
+ assert "--offline" in " ".join(result.output.split())
482
+
483
+
484
+ def test_deepseek_env_selects_provider(monkeypatch):
485
+ monkeypatch.setenv("DEEPSEEK_API_KEY", "d" * 30)
486
+ monkeypatch.setattr(pipeline, "LLMClient", FakeLLM)
487
+ result = runner.invoke(app, ["lookup", "--domain", "a.com", "--json"])
488
+ assert jsonish(result.output)["base_url"] == "https://api.deepseek.com/v1"
489
+
490
+
491
+ def test_mistral_env_selects_provider(monkeypatch):
492
+ monkeypatch.setenv("MISTRAL_API_KEY", "m" * 30)
493
+ monkeypatch.setattr(pipeline, "LLMClient", FakeLLM)
494
+ result = runner.invoke(app, ["lookup", "--domain", "a.com", "--json"])
495
+ assert jsonish(result.output)["base_url"] == "https://api.mistral.ai/v1"
496
+
497
+
498
+ def test_explicit_provider_beats_env(monkeypatch):
499
+ monkeypatch.setenv("DEEPSEEK_API_KEY", "d" * 30)
500
+ monkeypatch.setattr(pipeline, "LLMClient", FakeLLM)
501
+ result = runner.invoke(app, ["--provider", "mistral", "lookup", "--domain", "a.com", "--json"])
502
+ assert jsonish(result.output)["base_url"] == "https://api.mistral.ai/v1"
503
+
504
+
505
+ def test_ollama_needs_no_key(monkeypatch):
506
+ monkeypatch.delenv("API_KEY")
507
+ monkeypatch.setattr(pipeline, "LLMClient", FakeLLM)
508
+ result = runner.invoke(app, ["--provider", "ollama", "lookup", "--domain", "a.com", "--json"])
509
+ assert result.exit_code == 0
510
+ assert jsonish(result.output)["base_url"] == "http://localhost:11434/v1"
@@ -47,6 +47,19 @@ async def test_no_key_skips_endpoint_call():
47
47
  assert "no API key" in checks[-1].detail
48
48
 
49
49
 
50
+ async def test_keyless_provider_needs_no_key():
51
+ checks = await doctor.diagnose(Settings(provider="ollama", base_url="http://localhost:1/v1"))
52
+ key = next(c for c in checks if c.name == "api key")
53
+ assert key.status == "ok"
54
+ assert "not needed" in key.detail
55
+
56
+
57
+ def test_key_source_names_the_variable(monkeypatch):
58
+ monkeypatch.setenv("DEEPSEEK_API_KEY", "d" * 25)
59
+ check = next(c for c in doctor.collect_checks(Settings(provider="deepseek")) if c.name == "api key")
60
+ assert check.detail == "from DEEPSEEK_API_KEY"
61
+
62
+
50
63
  @respx.mock
51
64
  async def test_deep_probe_accepts_completion():
52
65
  respx.get("https://api.test/v1/models").mock(return_value=httpx.Response(200, json={}))
@@ -81,3 +94,11 @@ async def test_shallow_probe_does_not_call_the_model():
81
94
  respx.get("https://api.test/v1/models").mock(return_value=httpx.Response(200, json={}))
82
95
  checks = await doctor.diagnose(Settings(base_url="https://api.test/v1", api_key="k" * 25))
83
96
  assert "not exercised" in checks[-1].detail
97
+
98
+
99
+ @respx.mock
100
+ async def test_keyless_provider_probes_without_auth():
101
+ route = respx.get("http://localhost:1/v1/models").mock(return_value=httpx.Response(200, json={}))
102
+ checks = await doctor.diagnose(Settings(provider="ollama", base_url="http://localhost:1/v1"))
103
+ assert checks[-1].status == "ok"
104
+ assert "authorization" not in route.calls[0].request.headers
@@ -96,3 +96,16 @@ async def test_other_errors_stay_source_errors(settings):
96
96
  async with client(settings) as llm:
97
97
  with pytest.raises(SourceUnavailable):
98
98
  await llm.chat([{"role": "user", "content": "x"}])
99
+
100
+
101
+ @respx.mock
102
+ async def test_keyless_provider_sends_no_auth_header(settings):
103
+ settings.provider = "ollama"
104
+ settings.api_key = None
105
+ settings.base_url = "http://localhost:1/v1"
106
+ route = respx.post("http://localhost:1/v1/chat/completions").mock(
107
+ return_value=httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]})
108
+ )
109
+ async with LLMClient(settings) as llm:
110
+ assert await llm.chat([{"role": "user", "content": "x"}]) == "ok"
111
+ assert "authorization" not in route.calls[0].request.headers
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes