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.
- {osintkit-0.2.0 → osintkit-0.3.0}/CHANGELOG.md +12 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/PKG-INFO +7 -4
- {osintkit-0.2.0 → osintkit-0.3.0}/README.md +6 -3
- {osintkit-0.2.0 → osintkit-0.3.0}/docs/REFERENCE.md +13 -4
- {osintkit-0.2.0 → osintkit-0.3.0}/pyproject.toml +1 -1
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/__init__.py +1 -1
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/cli.py +13 -4
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/config.py +46 -2
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/doctor.py +24 -8
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/llm.py +2 -2
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/pipeline.py +4 -2
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_cli.py +36 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_doctor.py +21 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_llm.py +13 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/.editorconfig +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/.github/CODEOWNERS +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/.github/dependabot.yml +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/.github/pull_request_template.md +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/.github/workflows/ci.yml +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/.github/workflows/codeql.yml +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/.github/workflows/publish.yml +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/.github/workflows/release.yml +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/.github/workflows/smoke.yml +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/.gitignore +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/.mailmap +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/.pre-commit-config.yaml +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/CONTRIBUTING.md +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/LICENSE +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/SECURITY.md +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/docs/architecture.md +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/docs/demo.svg +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/docs/sample-output.md +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/docs/social-preview.png +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/examples/README.md +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/examples/targets.csv +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/__init__.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/accounts.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/base.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/breaches.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/ct.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/dns.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/github.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/pastes.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/ports.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/subdomains.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/collectors/whois_lookup.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/diff.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/errors.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/http.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/logging_store.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/models.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/normalise.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/offline.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/py.typed +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/render.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/retention.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/synthesis.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/src/osintkit/targets.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/conftest.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/fixtures/model_reply.json +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_accounts.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_breaches.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_collectors.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_config.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_ct.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_diff.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_e2e.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_http.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_normalise.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_offline.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_pastes.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_pipeline.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_render.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_retention.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_synthesis.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_targets.py +0 -0
- {osintkit-0.2.0 → osintkit-0.3.0}/tests/test_whois.py +0 -0
- {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.
|
|
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=... #
|
|
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
|
|
87
|
-
|
|
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=... #
|
|
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
|
|
55
|
-
|
|
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 |
|
|
37
|
+
| `--provider` | Endpoint | Default model | Key variable |
|
|
38
38
|
| --- | --- | --- | --- |
|
|
39
|
-
| `venice` (default) | `api.venice.ai/api/v1` | `deepseek-v4-1-flash` |
|
|
40
|
-
| `openrouter` | `openrouter.ai/api/v1` | `deepseek/deepseek-v4.1-flash-1017` | `sk-or-`
|
|
41
|
-
| `openai` | `api.openai.com/v1` | `gpt-4.1-mini` | `sk-proj-`
|
|
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.
|
|
@@ -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=
|
|
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
|
|
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
|
-
|
|
92
|
-
chosen =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|