pangram-mcp 0.1.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.
@@ -0,0 +1,49 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - name: Install uv
13
+ uses: astral-sh/setup-uv@v5
14
+ with:
15
+ python-version: "3.12"
16
+ - run: uv build
17
+ - uses: actions/upload-artifact@v4
18
+ with:
19
+ name: dist
20
+ path: dist/
21
+
22
+ publish:
23
+ needs: build
24
+ runs-on: ubuntu-latest
25
+ environment: release
26
+ permissions:
27
+ id-token: write # Required for PyPI trusted publishing
28
+ steps:
29
+ - uses: actions/download-artifact@v4
30
+ with:
31
+ name: dist
32
+ path: dist/
33
+ - name: Publish to PyPI
34
+ uses: pypa/gh-action-pypi-publish@release/v1
35
+ # Trusted publishing — no token needed.
36
+ # Configure at: https://pypi.org/manage/project/pangram-mcp/settings/publishing/
37
+
38
+ github-release:
39
+ needs: publish
40
+ runs-on: ubuntu-latest
41
+ permissions:
42
+ contents: write
43
+ steps:
44
+ - uses: actions/checkout@v4
45
+ - name: Create GitHub Release
46
+ env:
47
+ GH_TOKEN: ${{ github.token }}
48
+ TAG: ${{ github.ref_name }}
49
+ run: gh release create "$TAG" --title "$TAG" --generate-notes
@@ -0,0 +1,23 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.11", "3.12"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - name: Install uv
17
+ uses: astral-sh/setup-uv@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - run: uv sync --extra dev
21
+ - run: uv run ruff check .
22
+ - run: uv run mypy src
23
+ - run: uv run pytest -q
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .env
12
+ uv.lock
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Consiliency
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: pangram-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for Pangram Labs AI-generated-text detection
5
+ Project-URL: Repository, https://github.com/Consiliency/pangram-mcp
6
+ Project-URL: Homepage, https://github.com/Consiliency/pangram-mcp
7
+ Project-URL: Issues, https://github.com/Consiliency/pangram-mcp/issues
8
+ Author: Consiliency
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-detection,llm,mcp,model-context-protocol,pangram
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: httpx>=0.27
17
+ Requires-Dist: mcp>=1.2.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: mypy>=1.11; extra == 'dev'
20
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
21
+ Requires-Dist: pytest>=8; extra == 'dev'
22
+ Requires-Dist: respx>=0.21; extra == 'dev'
23
+ Requires-Dist: ruff>=0.6; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # pangram-mcp
27
+
28
+ An [MCP](https://modelcontextprotocol.io) server for [Pangram Labs](https://pangram.com)
29
+ AI-generated-text detection. Exposes a single `analyze` tool that classifies text as
30
+ human-written, AI-generated, or AI-assisted — with an overall verdict, per-class
31
+ fractions, and a per-segment breakdown.
32
+
33
+ The API key stays in the server's environment and is sent as the `x-api-key` header; it
34
+ is never returned to the model or logged. Callers pass text and receive a classification
35
+ — they never handle the credential.
36
+
37
+ ## Install / run
38
+
39
+ Requires an API key from [Pangram Labs](https://pangram.com):
40
+
41
+ ```sh
42
+ export PANGRAM_API_KEY="your-key"
43
+ uvx pangram-mcp
44
+ ```
45
+
46
+ ### MCP client config
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "pangram": {
52
+ "command": "uvx",
53
+ "args": ["pangram-mcp"],
54
+ "env": { "PANGRAM_API_KEY": "your-key" }
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ ## Configuration
61
+
62
+ | Env var | Required | Default | Purpose |
63
+ |---|---|---|---|
64
+ | `PANGRAM_API_KEY` | yes | — | Pangram Labs API key. |
65
+ | `PANGRAM_API_BASE` | no | `https://text.api.pangram.com/v3` | Endpoint override. |
66
+ | `PANGRAM_TIMEOUT` | no | `60` | Request timeout (seconds). |
67
+
68
+ ## Tool: `analyze`
69
+
70
+ Detect AI-generated text. Provide **either** `text` **or** `file`.
71
+
72
+ | Parameter | Type | Description |
73
+ |---|---|---|
74
+ | `text` | string | Raw text to classify. |
75
+ | `file` | string | Path to a local UTF-8 text file to read and classify. |
76
+ | `public_dashboard_link` | bool | Request a shareable Pangram dashboard link (default `false`). |
77
+
78
+ Returns structured output: `prediction`, `prediction_short`, `headline`,
79
+ `fraction_ai` / `fraction_ai_assisted` / `fraction_human` (0.0–1.0),
80
+ `num_ai_segments` / `num_ai_assisted_segments` / `num_human_segments`, a `windows`
81
+ array (per-segment `label`, `ai_assistance_score`, `confidence`, indices), and an
82
+ optional `dashboard_link`.
83
+
84
+ ## Development
85
+
86
+ ```sh
87
+ uv sync --extra dev
88
+ uv run pytest
89
+ uv run ruff check .
90
+ uv run mypy src
91
+ ```
92
+
93
+ ## License
94
+
95
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,70 @@
1
+ # pangram-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server for [Pangram Labs](https://pangram.com)
4
+ AI-generated-text detection. Exposes a single `analyze` tool that classifies text as
5
+ human-written, AI-generated, or AI-assisted — with an overall verdict, per-class
6
+ fractions, and a per-segment breakdown.
7
+
8
+ The API key stays in the server's environment and is sent as the `x-api-key` header; it
9
+ is never returned to the model or logged. Callers pass text and receive a classification
10
+ — they never handle the credential.
11
+
12
+ ## Install / run
13
+
14
+ Requires an API key from [Pangram Labs](https://pangram.com):
15
+
16
+ ```sh
17
+ export PANGRAM_API_KEY="your-key"
18
+ uvx pangram-mcp
19
+ ```
20
+
21
+ ### MCP client config
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "pangram": {
27
+ "command": "uvx",
28
+ "args": ["pangram-mcp"],
29
+ "env": { "PANGRAM_API_KEY": "your-key" }
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ ## Configuration
36
+
37
+ | Env var | Required | Default | Purpose |
38
+ |---|---|---|---|
39
+ | `PANGRAM_API_KEY` | yes | — | Pangram Labs API key. |
40
+ | `PANGRAM_API_BASE` | no | `https://text.api.pangram.com/v3` | Endpoint override. |
41
+ | `PANGRAM_TIMEOUT` | no | `60` | Request timeout (seconds). |
42
+
43
+ ## Tool: `analyze`
44
+
45
+ Detect AI-generated text. Provide **either** `text` **or** `file`.
46
+
47
+ | Parameter | Type | Description |
48
+ |---|---|---|
49
+ | `text` | string | Raw text to classify. |
50
+ | `file` | string | Path to a local UTF-8 text file to read and classify. |
51
+ | `public_dashboard_link` | bool | Request a shareable Pangram dashboard link (default `false`). |
52
+
53
+ Returns structured output: `prediction`, `prediction_short`, `headline`,
54
+ `fraction_ai` / `fraction_ai_assisted` / `fraction_human` (0.0–1.0),
55
+ `num_ai_segments` / `num_ai_assisted_segments` / `num_human_segments`, a `windows`
56
+ array (per-segment `label`, `ai_assistance_score`, `confidence`, indices), and an
57
+ optional `dashboard_link`.
58
+
59
+ ## Development
60
+
61
+ ```sh
62
+ uv sync --extra dev
63
+ uv run pytest
64
+ uv run ruff check .
65
+ uv run mypy src
66
+ ```
67
+
68
+ ## License
69
+
70
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "pangram-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server for Pangram Labs AI-generated-text detection"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Consiliency" }]
9
+ keywords = ["mcp", "model-context-protocol", "pangram", "ai-detection", "llm"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Operating System :: OS Independent",
14
+ ]
15
+ dependencies = [
16
+ "mcp>=1.2.0",
17
+ "httpx>=0.27",
18
+ ]
19
+
20
+ [project.urls]
21
+ Repository = "https://github.com/Consiliency/pangram-mcp"
22
+ Homepage = "https://github.com/Consiliency/pangram-mcp"
23
+ Issues = "https://github.com/Consiliency/pangram-mcp/issues"
24
+
25
+ [project.scripts]
26
+ pangram-mcp = "pangram_mcp.server:main"
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "respx>=0.21", "ruff>=0.6", "mypy>=1.11"]
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/pangram_mcp"]
37
+
38
+ [tool.pytest.ini_options]
39
+ asyncio_mode = "auto"
40
+
41
+ [tool.ruff]
42
+ line-length = 100
@@ -0,0 +1,3 @@
1
+ """pangram-mcp — an MCP server for Pangram Labs AI-generated-text detection."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,223 @@
1
+ """Pangram MCP server — AI-generated-text detection via the Pangram Labs API.
2
+
3
+ Exposes a single ``analyze`` tool that classifies text as human-written,
4
+ AI-generated, or AI-assisted, with an overall verdict, per-class fractions, and a
5
+ per-segment breakdown.
6
+
7
+ Configuration (environment variables):
8
+ - ``PANGRAM_API_KEY`` (required): a Pangram Labs API key (https://pangram.com).
9
+ - ``PANGRAM_API_BASE`` (optional): override the endpoint. Defaults to the canonical
10
+ synchronous v3 endpoint ``https://text.api.pangram.com/v3``.
11
+ - ``PANGRAM_TIMEOUT`` (optional): request timeout in seconds (default 60).
12
+
13
+ The API key is read from the environment and sent as the ``x-api-key`` header. It is
14
+ never logged or returned in tool output.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from typing import Annotated, Any
21
+
22
+ import httpx
23
+ from mcp.server.fastmcp import FastMCP
24
+ from mcp.types import ToolAnnotations
25
+ from pydantic import BaseModel, Field
26
+
27
+ DEFAULT_API_BASE = "https://text.api.pangram.com/v3"
28
+ API_KEY_ENV = "PANGRAM_API_KEY"
29
+ # Guard against accidentally shipping a whole book; Pangram bounds request size and
30
+ # very long inputs waste credits. Callers should chunk larger documents themselves.
31
+ MAX_CHARS = 100_000
32
+
33
+ mcp = FastMCP("pangram")
34
+
35
+
36
+ class Window(BaseModel):
37
+ """A per-segment classification returned by Pangram."""
38
+
39
+ text: str = Field(description="The segment of the input this window covers.")
40
+ label: str = Field(description="Human-readable segment label, e.g. 'AI' / 'Human'.")
41
+ ai_assistance_score: float = Field(
42
+ description="AI-assistance score for this segment (0.0-1.0)."
43
+ )
44
+ confidence: str = Field(description="Confidence bucket, e.g. 'High'/'Medium'/'Low'.")
45
+ start_index: int | None = None
46
+ end_index: int | None = None
47
+ word_count: int | None = None
48
+ token_length: int | None = None
49
+
50
+
51
+ class AnalyzeResult(BaseModel):
52
+ """Structured result of a Pangram AI-detection call."""
53
+
54
+ prediction: str = Field(description="Overall verdict for the whole text.")
55
+ prediction_short: str | None = Field(
56
+ default=None, description="Short form of the verdict."
57
+ )
58
+ headline: str | None = Field(default=None, description="One-line human summary.")
59
+ version: str | None = Field(
60
+ default=None, description="Pangram model version that produced this result."
61
+ )
62
+ fraction_ai: float = Field(
63
+ description="Fraction of the text classified as AI-generated (0.0-1.0)."
64
+ )
65
+ fraction_ai_assisted: float = Field(
66
+ description="Fraction classified as AI-assisted (0.0-1.0)."
67
+ )
68
+ fraction_human: float = Field(
69
+ description="Fraction classified as human-written (0.0-1.0)."
70
+ )
71
+ num_ai_segments: int | None = None
72
+ num_ai_assisted_segments: int | None = None
73
+ num_human_segments: int | None = None
74
+ windows: list[Window] = Field(
75
+ default_factory=list, description="Per-segment breakdown."
76
+ )
77
+ dashboard_link: str | None = Field(
78
+ default=None, description="Shareable dashboard URL (only if requested)."
79
+ )
80
+
81
+
82
+ def _resolve_input(text: str | None, file: str | None) -> str:
83
+ """Return the text to classify from exactly one of ``text`` or ``file``."""
84
+ provided = [x for x in (text, file) if x]
85
+ if len(provided) != 1:
86
+ raise ValueError(
87
+ "Provide exactly one of `text` or `file` (got "
88
+ f"{'both' if len(provided) == 2 else 'neither'})."
89
+ )
90
+ if file:
91
+ try:
92
+ with open(file, encoding="utf-8") as fh:
93
+ content = fh.read()
94
+ except FileNotFoundError:
95
+ raise ValueError(f"File not found: {file}") from None
96
+ except OSError as exc:
97
+ raise ValueError(f"Could not read {file}: {exc}") from None
98
+ else:
99
+ content = text or ""
100
+ content = content.strip()
101
+ if not content:
102
+ raise ValueError("The text to analyze is empty.")
103
+ if len(content) > MAX_CHARS:
104
+ raise ValueError(
105
+ f"Input is {len(content)} chars; the limit is {MAX_CHARS}. "
106
+ "Chunk the document and analyze segments separately."
107
+ )
108
+ return content
109
+
110
+
111
+ def _explain_http_error(status: int, body: str) -> str:
112
+ """Map a Pangram HTTP status to an actionable message."""
113
+ hints = {
114
+ 400: "Bad request — check the text payload.",
115
+ 401: f"Unauthorized — the {API_KEY_ENV} is missing or invalid.",
116
+ 402: "Payment required — the Pangram account is out of credits.",
117
+ 404: "Endpoint not found — verify PANGRAM_API_BASE.",
118
+ 422: "Unprocessable input — the text may be too short or malformed.",
119
+ 429: "Rate limited by Pangram — retry after a short backoff.",
120
+ 500: "Pangram server error — retry later.",
121
+ }
122
+ hint = hints.get(status, "Unexpected Pangram API error.")
123
+ snippet = body.strip()[:300]
124
+ return f"Pangram API {status}: {hint}" + (f" ({snippet})" if snippet else "")
125
+
126
+
127
+ @mcp.tool(
128
+ annotations=ToolAnnotations(
129
+ title="Detect AI-generated text",
130
+ readOnlyHint=True,
131
+ destructiveHint=False,
132
+ idempotentHint=True,
133
+ openWorldHint=True,
134
+ )
135
+ )
136
+ async def analyze(
137
+ text: Annotated[
138
+ str | None,
139
+ Field(description="Raw text to classify. Provide this OR `file`, not both."),
140
+ ] = None,
141
+ file: Annotated[
142
+ str | None,
143
+ Field(
144
+ description="Path to a local UTF-8 text file to read and classify. "
145
+ "Provide this OR `text`, not both."
146
+ ),
147
+ ] = None,
148
+ public_dashboard_link: Annotated[
149
+ bool,
150
+ Field(description="Request a shareable Pangram dashboard link for the result."),
151
+ ] = False,
152
+ ) -> AnalyzeResult:
153
+ """Detect AI-generated text with Pangram Labs.
154
+
155
+ Classifies the input as human-written, AI-generated, or AI-assisted and returns an
156
+ overall verdict (`prediction`), per-class fractions 0.0-1.0
157
+ (`fraction_ai` / `fraction_ai_assisted` / `fraction_human`), segment counts, and a
158
+ per-segment breakdown (`windows`). Provide either `text` or `file`.
159
+
160
+ Requires the PANGRAM_API_KEY environment variable.
161
+ """
162
+ content = _resolve_input(text, file)
163
+
164
+ api_key = os.environ.get(API_KEY_ENV)
165
+ if not api_key:
166
+ raise ValueError(
167
+ f"{API_KEY_ENV} is not set. Configure a Pangram API key "
168
+ "(https://pangram.com) in the server environment."
169
+ )
170
+ api_base = os.environ.get("PANGRAM_API_BASE", DEFAULT_API_BASE)
171
+ timeout = float(os.environ.get("PANGRAM_TIMEOUT", "60"))
172
+
173
+ payload: dict[str, Any] = {"text": content}
174
+ if public_dashboard_link:
175
+ payload["public_dashboard_link"] = True
176
+
177
+ try:
178
+ async with httpx.AsyncClient(timeout=timeout) as client:
179
+ resp = await client.post(
180
+ api_base, headers={"x-api-key": api_key}, json=payload
181
+ )
182
+ except httpx.TimeoutException:
183
+ raise ValueError(
184
+ f"Pangram request timed out after {timeout}s (PANGRAM_TIMEOUT)."
185
+ ) from None
186
+ except httpx.HTTPError as exc:
187
+ raise ValueError(f"Could not reach Pangram: {exc}") from None
188
+
189
+ if resp.status_code != 200:
190
+ raise ValueError(_explain_http_error(resp.status_code, resp.text))
191
+
192
+ try:
193
+ data = resp.json()
194
+ except ValueError:
195
+ raise ValueError("Pangram returned a non-JSON response.") from None
196
+
197
+ # Drop the echoed input text to keep the result compact; tolerate schema drift by
198
+ # ignoring unknown fields (Pydantic default) and filling known ones.
199
+ data.pop("text", None)
200
+ windows = [Window(**w) for w in data.get("windows", []) if isinstance(w, dict)]
201
+ return AnalyzeResult(
202
+ prediction=str(data.get("prediction", "unknown")),
203
+ prediction_short=data.get("prediction_short"),
204
+ headline=data.get("headline"),
205
+ version=data.get("version"),
206
+ fraction_ai=float(data.get("fraction_ai", 0.0)),
207
+ fraction_ai_assisted=float(data.get("fraction_ai_assisted", 0.0)),
208
+ fraction_human=float(data.get("fraction_human", 0.0)),
209
+ num_ai_segments=data.get("num_ai_segments"),
210
+ num_ai_assisted_segments=data.get("num_ai_assisted_segments"),
211
+ num_human_segments=data.get("num_human_segments"),
212
+ windows=windows,
213
+ dashboard_link=data.get("dashboard_link"),
214
+ )
215
+
216
+
217
+ def main() -> None:
218
+ """Console-script entry point: run the server over stdio."""
219
+ mcp.run()
220
+
221
+
222
+ if __name__ == "__main__":
223
+ main()
@@ -0,0 +1,115 @@
1
+ """Unit tests for the pangram-mcp server (API mocked with respx)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ import pytest
7
+ import respx
8
+
9
+ from pangram_mcp import server
10
+ from pangram_mcp.server import AnalyzeResult, _explain_http_error, _resolve_input, analyze
11
+
12
+ API = server.DEFAULT_API_BASE
13
+
14
+ SAMPLE = {
15
+ "text": "echoed back input",
16
+ "version": "3.3.2",
17
+ "prediction": "Likely AI-generated",
18
+ "prediction_short": "AI",
19
+ "headline": "This text is likely AI-generated.",
20
+ "fraction_ai": 0.92,
21
+ "fraction_ai_assisted": 0.05,
22
+ "fraction_human": 0.03,
23
+ "num_ai_segments": 3,
24
+ "num_ai_assisted_segments": 0,
25
+ "num_human_segments": 1,
26
+ "windows": [
27
+ {
28
+ "text": "A segment.",
29
+ "label": "AI",
30
+ "ai_assistance_score": 0.9,
31
+ "confidence": "High",
32
+ "start_index": 0,
33
+ "end_index": 10,
34
+ "word_count": 2,
35
+ "token_length": 3,
36
+ }
37
+ ],
38
+ }
39
+
40
+
41
+ # --- input resolution -------------------------------------------------------
42
+
43
+ def test_resolve_text():
44
+ assert _resolve_input(" hello ", None) == "hello"
45
+
46
+
47
+ def test_resolve_file(tmp_path):
48
+ p = tmp_path / "doc.txt"
49
+ p.write_text("from a file")
50
+ assert _resolve_input(None, str(p)) == "from a file"
51
+
52
+
53
+ def test_resolve_requires_exactly_one():
54
+ with pytest.raises(ValueError, match="exactly one"):
55
+ _resolve_input("a", "b")
56
+ with pytest.raises(ValueError, match="exactly one"):
57
+ _resolve_input(None, None)
58
+
59
+
60
+ def test_resolve_empty_text():
61
+ with pytest.raises(ValueError, match="empty"):
62
+ _resolve_input(" ", None)
63
+
64
+
65
+ def test_resolve_missing_file():
66
+ with pytest.raises(ValueError, match="File not found"):
67
+ _resolve_input(None, "/no/such/file.txt")
68
+
69
+
70
+ def test_resolve_too_long():
71
+ with pytest.raises(ValueError, match="limit"):
72
+ _resolve_input("x" * (server.MAX_CHARS + 1), None)
73
+
74
+
75
+ # --- error mapping ----------------------------------------------------------
76
+
77
+ @pytest.mark.parametrize(
78
+ "status,needle",
79
+ [(401, "missing or invalid"), (402, "out of credits"), (429, "Rate limited")],
80
+ )
81
+ def test_explain_http_error(status, needle):
82
+ assert needle in _explain_http_error(status, "body")
83
+
84
+
85
+ # --- analyze tool -----------------------------------------------------------
86
+
87
+ @respx.mock
88
+ async def test_analyze_happy_path(monkeypatch):
89
+ monkeypatch.setenv("PANGRAM_API_KEY", "test-key")
90
+ route = respx.post(API).mock(return_value=httpx.Response(200, json=SAMPLE))
91
+ result = await analyze(text="Some text to classify.")
92
+ assert isinstance(result, AnalyzeResult)
93
+ assert result.prediction == "Likely AI-generated"
94
+ assert result.version == "3.3.2"
95
+ assert result.fraction_ai == pytest.approx(0.92)
96
+ assert len(result.windows) == 1
97
+ assert result.windows[0].confidence == "High"
98
+ # request carried the key + payload, and did not leak text back into the result
99
+ sent = route.calls.last.request
100
+ assert sent.headers["x-api-key"] == "test-key"
101
+ assert b'"text"' in sent.content
102
+
103
+
104
+ async def test_analyze_requires_key(monkeypatch):
105
+ monkeypatch.delenv("PANGRAM_API_KEY", raising=False)
106
+ with pytest.raises(ValueError, match="PANGRAM_API_KEY is not set"):
107
+ await analyze(text="hello")
108
+
109
+
110
+ @respx.mock
111
+ async def test_analyze_http_error(monkeypatch):
112
+ monkeypatch.setenv("PANGRAM_API_KEY", "test-key")
113
+ respx.post(API).mock(return_value=httpx.Response(402, text="no credits"))
114
+ with pytest.raises(ValueError, match="402"):
115
+ await analyze(text="hello")