Skip to content

Commit 23ec605

Browse files
authored
Merge pull request #5 from msitarzewski/v0.3.0
v0.3.0 — It's Accessible
2 parents b6f77af + adba7fe commit 23ec605

61 files changed

Lines changed: 9369 additions & 158 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,15 @@ duh ask "What database should I use for a new SaaS product?"
2121

2222
## Features
2323

24-
- **Multi-model consensus** -- Claude and GPT debate. Sycophantic challenges are detected and flagged.
24+
- **Multi-model consensus** -- Claude, GPT, Gemini, and Mistral debate. Sycophantic challenges are detected and flagged.
2525
- **Voting protocol** -- Fan out to all models in parallel, aggregate answers via majority or weighted synthesis.
2626
- **Query decomposition** -- Break complex questions into subtask DAGs, solve in parallel, synthesize results.
27+
- **REST API** -- Full HTTP API via `duh serve` with API key auth, rate limiting, and WebSocket streaming.
28+
- **MCP server** -- AI agent integration via `duh mcp` (Model Context Protocol).
29+
- **Python client** -- Async and sync client library for the REST API (`pip install duh-client`).
30+
- **Batch processing** -- Process multiple questions from a file (`duh batch`).
31+
- **Export** -- Export threads as JSON or Markdown (`duh export`).
32+
- **Mistral provider** -- Native Mistral AI support alongside Anthropic, OpenAI, and Google.
2733
- **Decision taxonomy** -- Auto-classify decisions by intent, category, and genus for structured recall.
2834
- **Outcome tracking** -- Record success/failure/partial feedback on past decisions.
2935
- **Tool-augmented reasoning** -- Models can call web search, read files, and execute code during consensus.
@@ -46,6 +52,13 @@ duh threads # List past threads
4652
duh show <thread-id> # Inspect full debate history
4753
duh models # List available models
4854
duh cost # Show cumulative costs
55+
duh serve # Start REST API server
56+
duh serve --host 0.0.0.0 --port 9000 # Custom host/port
57+
duh mcp # Start MCP server for AI agents
58+
duh batch questions.txt # Process multiple questions
59+
duh batch questions.jsonl --format json # Batch with JSON output
60+
duh export <thread-id> # Export thread as JSON
61+
duh export <thread-id> --format markdown # Export as Markdown
4962
```
5063

5164
## How consensus works
@@ -89,6 +102,11 @@ Full documentation: [docs/](docs/index.md)
89102
- [Quickstart](docs/getting-started/quickstart.md)
90103
- [How Consensus Works](docs/concepts/how-consensus-works.md)
91104
- [CLI Reference](docs/cli/index.md)
105+
- [REST API Reference](docs/api-reference.md)
106+
- [Python Client](docs/python-client.md)
107+
- [MCP Server](docs/mcp-server.md)
108+
- [Batch Mode](docs/batch-mode.md)
109+
- [Export](docs/export.md)
92110
- [Python API](docs/python-api/library-usage.md)
93111
- [Docker Guide](docs/guides/docker.md)
94112
- [Config Reference](docs/reference/config-reference.md)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""v0.3 API keys table.
2+
3+
Revision ID: 004
4+
Revises: 003
5+
Create Date: 2026-02-17
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
revision: str = "004"
14+
down_revision: str = "003"
15+
branch_labels: tuple[str, ...] | None = None
16+
depends_on: str | None = None
17+
18+
19+
def upgrade() -> None:
20+
op.create_table(
21+
"api_keys",
22+
sa.Column("id", sa.String(36), primary_key=True),
23+
sa.Column("key_hash", sa.String(64), nullable=False, unique=True),
24+
sa.Column("name", sa.String(100), nullable=False),
25+
sa.Column("created_at", sa.DateTime(), nullable=False),
26+
sa.Column("revoked_at", sa.DateTime(), nullable=True),
27+
)
28+
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
29+
30+
31+
def downgrade() -> None:
32+
op.drop_table("api_keys")

client/pyproject.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[project]
2+
name = "duh-client"
3+
version = "0.3.0"
4+
description = "Python client for the duh consensus engine API"
5+
requires-python = ">=3.11"
6+
dependencies = [
7+
"httpx>=0.27",
8+
]
9+
10+
[build-system]
11+
requires = ["hatchling"]
12+
build-backend = "hatchling.build"
13+
14+
[tool.hatch.build.targets.wheel]
15+
packages = ["src/duh_client"]

client/src/duh_client/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""duh-client -- Python client for the duh consensus engine API."""
2+
3+
from duh_client.client import DuhAPIError, DuhClient
4+
5+
__all__ = ["DuhAPIError", "DuhClient"]
6+
__version__ = "0.3.0"

client/src/duh_client/client.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""DuhClient -- async and sync client for the duh REST API."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import Any, cast
7+
8+
import httpx
9+
10+
11+
@dataclass
12+
class AskResult:
13+
decision: str
14+
confidence: float
15+
dissent: str | None
16+
cost: float
17+
thread_id: str | None
18+
protocol_used: str
19+
20+
21+
@dataclass
22+
class ThreadSummary:
23+
thread_id: str
24+
question: str
25+
status: str
26+
created_at: str
27+
28+
29+
@dataclass
30+
class RecallResult:
31+
thread_id: str
32+
question: str
33+
decision: str | None
34+
confidence: float | None
35+
36+
37+
class DuhAPIError(Exception):
38+
"""Error from the duh API."""
39+
40+
def __init__(self, status_code: int, detail: str) -> None:
41+
self.status_code = status_code
42+
self.detail = detail
43+
super().__init__(f"HTTP {status_code}: {detail}")
44+
45+
46+
class DuhClient:
47+
"""Client for the duh consensus engine REST API.
48+
49+
Provides both async and sync interfaces.
50+
51+
Usage (async)::
52+
53+
async with DuhClient("http://localhost:8080") as client:
54+
result = await client.ask("What is the best auth strategy?")
55+
print(result.decision)
56+
57+
Usage (sync)::
58+
59+
client = DuhClient("http://localhost:8080")
60+
result = client.ask_sync("What is the best auth strategy?")
61+
print(result.decision)
62+
"""
63+
64+
def __init__(
65+
self,
66+
base_url: str = "http://localhost:8080",
67+
api_key: str | None = None,
68+
timeout: float = 120.0,
69+
) -> None:
70+
headers: dict[str, str] = {}
71+
if api_key:
72+
headers["X-API-Key"] = api_key
73+
self._base_url = base_url.rstrip("/")
74+
self._async_client = httpx.AsyncClient(
75+
base_url=self._base_url,
76+
headers=headers,
77+
timeout=timeout,
78+
)
79+
self._sync_client = httpx.Client(
80+
base_url=self._base_url,
81+
headers=headers,
82+
timeout=timeout,
83+
)
84+
85+
async def __aenter__(self) -> DuhClient:
86+
return self
87+
88+
async def __aexit__(self, *args: Any) -> None:
89+
await self.aclose()
90+
91+
async def aclose(self) -> None:
92+
await self._async_client.aclose()
93+
94+
def close(self) -> None:
95+
self._sync_client.close()
96+
97+
def _raise_for_status(self, response: httpx.Response) -> None:
98+
if response.status_code >= 400:
99+
try:
100+
detail = response.json().get("detail", response.text)
101+
except Exception:
102+
detail = response.text
103+
raise DuhAPIError(response.status_code, detail)
104+
105+
# -- Async methods ---------------------------------------------------------
106+
107+
async def ask(
108+
self,
109+
question: str,
110+
*,
111+
protocol: str = "consensus",
112+
rounds: int = 3,
113+
decompose: bool = False,
114+
tools: bool = False,
115+
) -> AskResult:
116+
resp = await self._async_client.post(
117+
"/api/ask",
118+
json={
119+
"question": question,
120+
"protocol": protocol,
121+
"rounds": rounds,
122+
"decompose": decompose,
123+
"tools": tools,
124+
},
125+
)
126+
self._raise_for_status(resp)
127+
return AskResult(**resp.json())
128+
129+
async def threads(
130+
self,
131+
*,
132+
status: str | None = None,
133+
limit: int = 20,
134+
offset: int = 0,
135+
) -> list[ThreadSummary]:
136+
params: dict[str, Any] = {"limit": limit, "offset": offset}
137+
if status:
138+
params["status"] = status
139+
resp = await self._async_client.get("/api/threads", params=params)
140+
self._raise_for_status(resp)
141+
return [ThreadSummary(**t) for t in resp.json()["threads"]]
142+
143+
async def show(self, thread_id: str) -> dict[str, Any]:
144+
resp = await self._async_client.get(f"/api/threads/{thread_id}")
145+
self._raise_for_status(resp)
146+
return cast("dict[str, Any]", resp.json())
147+
148+
async def recall(
149+
self, query: str, *, limit: int = 10
150+
) -> list[RecallResult]:
151+
resp = await self._async_client.get(
152+
"/api/recall", params={"query": query, "limit": limit}
153+
)
154+
self._raise_for_status(resp)
155+
return [RecallResult(**r) for r in resp.json()["results"]]
156+
157+
async def feedback(
158+
self,
159+
thread_id: str,
160+
result: str,
161+
*,
162+
notes: str | None = None,
163+
) -> dict[str, str]:
164+
body: dict[str, Any] = {"thread_id": thread_id, "result": result}
165+
if notes:
166+
body["notes"] = notes
167+
resp = await self._async_client.post("/api/feedback", json=body)
168+
self._raise_for_status(resp)
169+
return cast("dict[str, str]", resp.json())
170+
171+
async def models(self) -> list[dict[str, Any]]:
172+
resp = await self._async_client.get("/api/models")
173+
self._raise_for_status(resp)
174+
return cast("list[dict[str, Any]]", resp.json()["models"])
175+
176+
async def cost(self) -> dict[str, Any]:
177+
resp = await self._async_client.get("/api/cost")
178+
self._raise_for_status(resp)
179+
return cast("dict[str, Any]", resp.json())
180+
181+
async def health(self) -> bool:
182+
try:
183+
resp = await self._async_client.get("/api/health")
184+
return resp.status_code == 200
185+
except httpx.HTTPError:
186+
return False
187+
188+
# -- Sync wrappers ---------------------------------------------------------
189+
190+
def ask_sync(self, question: str, **kwargs: Any) -> AskResult:
191+
resp = self._sync_client.post(
192+
"/api/ask", json={"question": question, **kwargs}
193+
)
194+
self._raise_for_status(resp)
195+
return AskResult(**resp.json())
196+
197+
def threads_sync(self, **kwargs: Any) -> list[ThreadSummary]:
198+
resp = self._sync_client.get("/api/threads", params=kwargs)
199+
self._raise_for_status(resp)
200+
return [ThreadSummary(**t) for t in resp.json()["threads"]]
201+
202+
def recall_sync(self, query: str, **kwargs: Any) -> list[RecallResult]:
203+
resp = self._sync_client.get(
204+
"/api/recall", params={"query": query, **kwargs}
205+
)
206+
self._raise_for_status(resp)
207+
return [RecallResult(**r) for r in resp.json()["results"]]
208+
209+
def models_sync(self) -> list[dict[str, Any]]:
210+
resp = self._sync_client.get("/api/models")
211+
self._raise_for_status(resp)
212+
return cast("list[dict[str, Any]]", resp.json()["models"])
213+
214+
def health_sync(self) -> bool:
215+
try:
216+
resp = self._sync_client.get("/api/health")
217+
return resp.status_code == 200
218+
except httpx.HTTPError:
219+
return False

client/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)