nitpick-core 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.
- nitpick_core/__init__.py +51 -0
- nitpick_core/__main__.py +6 -0
- nitpick_core/api.py +298 -0
- nitpick_core/builder.py +292 -0
- nitpick_core/cli.py +151 -0
- nitpick_core/client.py +155 -0
- nitpick_core/config.py +102 -0
- nitpick_core/errors.py +40 -0
- nitpick_core/flows.py +213 -0
- nitpick_core/registry.py +252 -0
- nitpick_core/runtime.py +327 -0
- nitpick_core-0.1.0.dist-info/METADATA +363 -0
- nitpick_core-0.1.0.dist-info/RECORD +16 -0
- nitpick_core-0.1.0.dist-info/WHEEL +5 -0
- nitpick_core-0.1.0.dist-info/entry_points.txt +2 -0
- nitpick_core-0.1.0.dist-info/top_level.txt +1 -0
nitpick_core/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""nitpick-core — the agent runtime for nitpick.
|
|
2
|
+
|
|
3
|
+
Loads agent definitions from a minidb registry, dispatches them through
|
|
4
|
+
Prefect, and serves the voice rooms so people can talk to them.
|
|
5
|
+
|
|
6
|
+
POST /tasks {"handle": "assistant"} -> a session
|
|
7
|
+
WS /ws -> talk to it
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .builder import AgentSpec, build_agent, build_spec
|
|
12
|
+
from .client import CoreClient, CoreError
|
|
13
|
+
from .config import Config, load_config
|
|
14
|
+
from .errors import (
|
|
15
|
+
AgentNotFound,
|
|
16
|
+
BuildError,
|
|
17
|
+
DispatchError,
|
|
18
|
+
ModelNotFound,
|
|
19
|
+
NitpickCoreError,
|
|
20
|
+
RegistryError,
|
|
21
|
+
SessionNotFound,
|
|
22
|
+
)
|
|
23
|
+
from .registry import Registry, get_registry, resolve_agent
|
|
24
|
+
from .runtime import AgentRuntime, Session, SessionStatus, get_runtime
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AgentNotFound",
|
|
30
|
+
"AgentRuntime",
|
|
31
|
+
"AgentSpec",
|
|
32
|
+
"BuildError",
|
|
33
|
+
"Config",
|
|
34
|
+
"CoreClient",
|
|
35
|
+
"CoreError",
|
|
36
|
+
"DispatchError",
|
|
37
|
+
"ModelNotFound",
|
|
38
|
+
"NitpickCoreError",
|
|
39
|
+
"Registry",
|
|
40
|
+
"RegistryError",
|
|
41
|
+
"Session",
|
|
42
|
+
"SessionNotFound",
|
|
43
|
+
"SessionStatus",
|
|
44
|
+
"__version__",
|
|
45
|
+
"build_agent",
|
|
46
|
+
"build_spec",
|
|
47
|
+
"get_registry",
|
|
48
|
+
"get_runtime",
|
|
49
|
+
"load_config",
|
|
50
|
+
"resolve_agent",
|
|
51
|
+
]
|
nitpick_core/__main__.py
ADDED
nitpick_core/api.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""The nitpick-core HTTP API.
|
|
2
|
+
|
|
3
|
+
Two things live on one port:
|
|
4
|
+
|
|
5
|
+
* the **control API** — dispatch tasks, look at sessions, browse the agent
|
|
6
|
+
registry
|
|
7
|
+
* the **signaling WebSocket** at ``/ws`` — the nitpick room protocol, so
|
|
8
|
+
somebody can talk to a running agent
|
|
9
|
+
|
|
10
|
+
The browser client is served at ``/``.
|
|
11
|
+
|
|
12
|
+
POST /tasks {"handle": "assistant", "room_id": "demo"}
|
|
13
|
+
-> 202 {"id": "ses-...", "status": "queued", ...}
|
|
14
|
+
|
|
15
|
+
GET /tasks/{id} the session
|
|
16
|
+
GET /sessions every session
|
|
17
|
+
GET /agents the agent registry
|
|
18
|
+
GET /agents/{handle} one agent, resolved
|
|
19
|
+
GET /health
|
|
20
|
+
WS /ws the signaling socket
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import logging
|
|
26
|
+
from contextlib import asynccontextmanager
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
|
30
|
+
from fastapi.encoders import jsonable_encoder
|
|
31
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
32
|
+
from pydantic import BaseModel, Field
|
|
33
|
+
|
|
34
|
+
from nitpick.signaling.server import SignalingServer
|
|
35
|
+
|
|
36
|
+
from .config import Config, load_config
|
|
37
|
+
from .errors import (
|
|
38
|
+
AgentNotFound,
|
|
39
|
+
BuildError,
|
|
40
|
+
DispatchError,
|
|
41
|
+
ModelNotFound,
|
|
42
|
+
RegistryError,
|
|
43
|
+
SessionNotFound,
|
|
44
|
+
)
|
|
45
|
+
from .flows import run_flow
|
|
46
|
+
from .registry import get_registry
|
|
47
|
+
from .runtime import AgentRuntime, get_runtime
|
|
48
|
+
|
|
49
|
+
log = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
__all__ = ["create_app"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TaskRequest(BaseModel):
|
|
55
|
+
"""A request to run an agent."""
|
|
56
|
+
|
|
57
|
+
handle: str = Field(..., description="The agent's handle in the registry")
|
|
58
|
+
room_id: str | None = Field(None, description="The room to join. Defaults to the configured one.")
|
|
59
|
+
overrides: dict[str, Any] = Field(default_factory=dict, description="Overrides for the built agent.")
|
|
60
|
+
metadata: dict[str, Any] = Field(default_factory=dict, description="Arbitrary data to record on the session.")
|
|
61
|
+
wait: bool = Field(False, description="Wait for the session to finish before replying.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _encoder() -> dict[type, Any]:
|
|
65
|
+
"""Encoders for the types the registry stores.
|
|
66
|
+
|
|
67
|
+
minidb documents carry an ``ObjectId`` and a ``created_at`` timestamp;
|
|
68
|
+
FastAPI knows about the timestamp but not about the object id.
|
|
69
|
+
"""
|
|
70
|
+
encoders: dict[type, Any] = {}
|
|
71
|
+
try:
|
|
72
|
+
from minidb import ObjectId
|
|
73
|
+
|
|
74
|
+
encoders[ObjectId] = str
|
|
75
|
+
except ImportError: # pragma: no cover - minidb is a hard dependency
|
|
76
|
+
pass
|
|
77
|
+
return encoders
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def create_app(
|
|
81
|
+
config: Config | None = None,
|
|
82
|
+
*,
|
|
83
|
+
runtime: AgentRuntime | None = None,
|
|
84
|
+
) -> FastAPI:
|
|
85
|
+
"""Build the application."""
|
|
86
|
+
settings = config or load_config()
|
|
87
|
+
agent_runtime = runtime or get_runtime(settings)
|
|
88
|
+
|
|
89
|
+
# Configure Prefect before anything else touches it, so its state
|
|
90
|
+
# lives where this service can find it.
|
|
91
|
+
from .flows import prefect_settings
|
|
92
|
+
|
|
93
|
+
prefect_settings(settings)
|
|
94
|
+
|
|
95
|
+
# The signaling server shares the runtime's room manager, so every
|
|
96
|
+
# session is visible on the same socket.
|
|
97
|
+
signaling = SignalingServer(agent_runtime.manager)
|
|
98
|
+
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
# Lifecycle
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
@asynccontextmanager
|
|
104
|
+
async def lifespan(_app: FastAPI):
|
|
105
|
+
await agent_runtime.start()
|
|
106
|
+
try:
|
|
107
|
+
yield
|
|
108
|
+
finally:
|
|
109
|
+
await agent_runtime.stop()
|
|
110
|
+
|
|
111
|
+
app = FastAPI(
|
|
112
|
+
title="nitpick-core", version="0.1.0", lifespan=lifespan
|
|
113
|
+
)
|
|
114
|
+
app.state.config = settings
|
|
115
|
+
app.state.runtime = agent_runtime
|
|
116
|
+
app.state.signaling = signaling
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# Control API
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
@app.get("/health")
|
|
123
|
+
async def health() -> JSONResponse:
|
|
124
|
+
return JSONResponse(
|
|
125
|
+
{
|
|
126
|
+
"status": "ok",
|
|
127
|
+
"config": settings.to_dict(),
|
|
128
|
+
"sessions": len(agent_runtime.list_sessions()),
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@app.post("/tasks", status_code=202)
|
|
133
|
+
async def create_task(payload: TaskRequest) -> JSONResponse:
|
|
134
|
+
"""Dispatch a task. The agent runtime picks it up."""
|
|
135
|
+
try:
|
|
136
|
+
# The flow is synchronous and takes about a second while Prefect
|
|
137
|
+
# records the run, so it goes in a thread rather than blocking
|
|
138
|
+
# the event loop.
|
|
139
|
+
session = await asyncio.to_thread(
|
|
140
|
+
run_flow,
|
|
141
|
+
payload.handle,
|
|
142
|
+
room_id=payload.room_id,
|
|
143
|
+
overrides=payload.overrides or None,
|
|
144
|
+
metadata=payload.metadata or None,
|
|
145
|
+
runtime=agent_runtime,
|
|
146
|
+
config=settings,
|
|
147
|
+
)
|
|
148
|
+
except DispatchError as exc:
|
|
149
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
150
|
+
except (AgentNotFound, ModelNotFound) as exc:
|
|
151
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
152
|
+
except BuildError as exc:
|
|
153
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
154
|
+
except RegistryError as exc:
|
|
155
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
156
|
+
|
|
157
|
+
return JSONResponse(session, status_code=202)
|
|
158
|
+
|
|
159
|
+
@app.get("/tasks/{session_id}")
|
|
160
|
+
async def get_task(session_id: str) -> JSONResponse:
|
|
161
|
+
"""One session."""
|
|
162
|
+
try:
|
|
163
|
+
session = agent_runtime.get_session(session_id)
|
|
164
|
+
except SessionNotFound as exc:
|
|
165
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
166
|
+
return JSONResponse(session.to_dict())
|
|
167
|
+
|
|
168
|
+
@app.get("/sessions")
|
|
169
|
+
async def list_sessions() -> JSONResponse:
|
|
170
|
+
"""Every session."""
|
|
171
|
+
sessions = [session.to_dict() for session in agent_runtime.list_sessions()]
|
|
172
|
+
return JSONResponse({"count": len(sessions), "results": sessions})
|
|
173
|
+
|
|
174
|
+
@app.get("/sessions/{session_id}")
|
|
175
|
+
async def get_session(session_id: str) -> JSONResponse:
|
|
176
|
+
"""One session."""
|
|
177
|
+
try:
|
|
178
|
+
session = agent_runtime.get_session(session_id)
|
|
179
|
+
except SessionNotFound as exc:
|
|
180
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
181
|
+
return JSONResponse(session.to_dict())
|
|
182
|
+
|
|
183
|
+
@app.get("/agents")
|
|
184
|
+
async def list_agents() -> JSONResponse:
|
|
185
|
+
"""The agent registry."""
|
|
186
|
+
try:
|
|
187
|
+
agents = get_registry(settings).list_agents()
|
|
188
|
+
except RegistryError as exc:
|
|
189
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
190
|
+
return JSONResponse(
|
|
191
|
+
jsonable_encoder(
|
|
192
|
+
{"count": len(agents), "results": agents}, custom_encoder=_encoder()
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
@app.get("/agents/{handle}")
|
|
197
|
+
async def get_agent(handle: str) -> JSONResponse:
|
|
198
|
+
"""One agent, with the models it names resolved."""
|
|
199
|
+
try:
|
|
200
|
+
resolved = get_registry(settings).resolve_agent(handle)
|
|
201
|
+
except AgentNotFound as exc:
|
|
202
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
203
|
+
except ModelNotFound as exc:
|
|
204
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
205
|
+
except RegistryError as exc:
|
|
206
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
207
|
+
return JSONResponse(jsonable_encoder(resolved, custom_encoder=_encoder()))
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
# The signaling socket and the browser client
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
@app.websocket("/ws")
|
|
214
|
+
async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
215
|
+
await _serve_signaling(signaling, websocket)
|
|
216
|
+
|
|
217
|
+
@app.get("/", response_class=HTMLResponse)
|
|
218
|
+
async def index() -> HTMLResponse:
|
|
219
|
+
html = _load_web_client(settings)
|
|
220
|
+
if html is None:
|
|
221
|
+
return HTMLResponse(
|
|
222
|
+
"<h1>nitpick-core</h1><p>The browser client is not installed.</p>"
|
|
223
|
+
)
|
|
224
|
+
return HTMLResponse(html)
|
|
225
|
+
|
|
226
|
+
return app
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def _serve_signaling(signaling: SignalingServer, websocket: WebSocket) -> None:
|
|
230
|
+
"""Run nitpick's WebSocket protocol on this app's socket."""
|
|
231
|
+
import json
|
|
232
|
+
|
|
233
|
+
await websocket.accept()
|
|
234
|
+
participant = signaling.manager.register_participant(websocket)
|
|
235
|
+
await websocket.send_json(
|
|
236
|
+
{
|
|
237
|
+
"type": "connected",
|
|
238
|
+
"participant_id": participant.participant_id,
|
|
239
|
+
"stt": {"provider": "pcm", "transport": "audio_pcm"},
|
|
240
|
+
"audio": {"output_sample_rate": signaling.audio_output_sample_rate},
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
try:
|
|
244
|
+
while True:
|
|
245
|
+
raw = await websocket.receive_text()
|
|
246
|
+
try:
|
|
247
|
+
payload = json.loads(raw)
|
|
248
|
+
except json.JSONDecodeError:
|
|
249
|
+
await websocket.send_json(
|
|
250
|
+
{
|
|
251
|
+
"type": "error",
|
|
252
|
+
"code": "invalid_json",
|
|
253
|
+
"message": "Payload is not valid JSON",
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
continue
|
|
257
|
+
if not isinstance(payload, dict):
|
|
258
|
+
await websocket.send_json(
|
|
259
|
+
{
|
|
260
|
+
"type": "error",
|
|
261
|
+
"code": "invalid_payload",
|
|
262
|
+
"message": "Payload must be a JSON object",
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
continue
|
|
266
|
+
response = await signaling.handle_message(websocket, payload)
|
|
267
|
+
if response is not None:
|
|
268
|
+
await websocket.send_json(response)
|
|
269
|
+
except WebSocketDisconnect:
|
|
270
|
+
pass
|
|
271
|
+
finally:
|
|
272
|
+
signaling.manager.unregister_participant_by_id(participant.participant_id)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _load_web_client(config: Config) -> str | None:
|
|
276
|
+
"""The browser client.
|
|
277
|
+
|
|
278
|
+
nitpick ships one; ``NITPICK_CORE_WEB_ROOT`` overrides it.
|
|
279
|
+
"""
|
|
280
|
+
from pathlib import Path
|
|
281
|
+
|
|
282
|
+
if config.web_root:
|
|
283
|
+
path = Path(config.web_root)
|
|
284
|
+
if path.is_dir():
|
|
285
|
+
path = path / "index.html"
|
|
286
|
+
if path.exists():
|
|
287
|
+
return path.read_text(encoding="utf-8")
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
from nitpick.signaling import server as nitpick_server
|
|
292
|
+
except ImportError: # pragma: no cover - nitpick is a hard dependency
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
path = Path(nitpick_server.__file__).resolve().parent.parent / "web" / "index.html"
|
|
296
|
+
if path.exists():
|
|
297
|
+
return path.read_text(encoding="utf-8")
|
|
298
|
+
return None
|
nitpick_core/builder.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Turning registry documents into a runnable nitpick agent.
|
|
2
|
+
|
|
3
|
+
The registry stores *what* an agent is — its prompt and the models it talks
|
|
4
|
+
through — in a provider-neutral way. This module translates that into the
|
|
5
|
+
provider-specific strings nitpick's ``Agent`` facade expects.
|
|
6
|
+
|
|
7
|
+
The translation is driven by the model's ``handle``, which names the
|
|
8
|
+
provider (``openai``, ``deepgram``, ``sarvam``, ...):
|
|
9
|
+
|
|
10
|
+
============ ========== ==========================================
|
|
11
|
+
Registry nitpick How the value is derived
|
|
12
|
+
============ ========== ==========================================
|
|
13
|
+
``llm`` ``llm`` ``openai:<model>``
|
|
14
|
+
``stt`` ``stt`` ``openai`` / ``deepgram`` / ``sarvam``
|
|
15
|
+
``tts`` ``tts`` ``openai:<voice>``
|
|
16
|
+
============ ========== ==========================================
|
|
17
|
+
|
|
18
|
+
A model may override the derived value through its ``config`` — for
|
|
19
|
+
example ``{"model": "gpt-4o-mini"}`` on an LLM, or ``{"voices": ["nova"]}``
|
|
20
|
+
on a TTS model.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from nitpick import Agent
|
|
29
|
+
|
|
30
|
+
from .errors import BuildError
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
__all__ = ["AgentSpec", "build_agent", "build_spec", "provider_for"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
#: Handles nitpick can talk to directly.
|
|
38
|
+
SUPPORTED_PROVIDERS = {
|
|
39
|
+
"openai": "openai",
|
|
40
|
+
"deepgram": "deepgram",
|
|
41
|
+
"sarvam": "sarvam",
|
|
42
|
+
"ollama": "openai", # OpenAI-compatible wire format
|
|
43
|
+
"vllm": "openai",
|
|
44
|
+
"together": "openai",
|
|
45
|
+
"groq": "openai",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#: A fallback for registries where a model's handle was left as its slug
|
|
49
|
+
#: instead of naming the provider. The handle is the right way to say this;
|
|
50
|
+
#: the table only rescues data that was seeded before the handle was
|
|
51
|
+
#: introduced, or written by hand.
|
|
52
|
+
KNOWN_MODEL_PROVIDERS = {
|
|
53
|
+
"gpt-4o": "openai",
|
|
54
|
+
"gpt-4o-mini": "openai",
|
|
55
|
+
"gpt-4.1": "openai",
|
|
56
|
+
"gpt-4-turbo": "openai",
|
|
57
|
+
"o1": "openai",
|
|
58
|
+
"o3": "openai",
|
|
59
|
+
"alloy": "openai",
|
|
60
|
+
"echo": "openai",
|
|
61
|
+
"fable": "openai",
|
|
62
|
+
"onyx": "openai",
|
|
63
|
+
"nova": "openai",
|
|
64
|
+
"whisper": "openai",
|
|
65
|
+
"deepgram": "deepgram",
|
|
66
|
+
"deepgram-nova": "deepgram",
|
|
67
|
+
"nova-3": "deepgram",
|
|
68
|
+
"saaras": "sarvam",
|
|
69
|
+
"saaras:v3": "sarvam",
|
|
70
|
+
"sarvam": "sarvam",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class AgentSpec:
|
|
76
|
+
"""Everything needed to build one agent, resolved from the registry."""
|
|
77
|
+
|
|
78
|
+
handle: str
|
|
79
|
+
name: str
|
|
80
|
+
prompt: str
|
|
81
|
+
stt: str
|
|
82
|
+
llm: str
|
|
83
|
+
tts: str
|
|
84
|
+
language: str | None = None
|
|
85
|
+
#: The base URL of the LLM endpoint, for OpenAI-compatible servers.
|
|
86
|
+
llm_base_url: str | None = None
|
|
87
|
+
room_id: str = "demo"
|
|
88
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
89
|
+
#: The raw documents, kept for the session record.
|
|
90
|
+
documents: dict[str, Any] = field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
def to_dict(self) -> dict[str, Any]:
|
|
93
|
+
return {
|
|
94
|
+
"handle": self.handle,
|
|
95
|
+
"name": self.name,
|
|
96
|
+
"prompt": self.prompt,
|
|
97
|
+
"stt": self.stt,
|
|
98
|
+
"llm": self.llm,
|
|
99
|
+
"tts": self.tts,
|
|
100
|
+
"language": self.language,
|
|
101
|
+
"llm_base_url": self.llm_base_url,
|
|
102
|
+
"room_id": self.room_id,
|
|
103
|
+
"config": self.config,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def provider_for(model: dict[str, Any]) -> str:
|
|
108
|
+
"""The nitpick provider name for a registry model.
|
|
109
|
+
|
|
110
|
+
The model's ``handle`` names the provider. A model whose provider
|
|
111
|
+
nitpick does not know about is only usable if it declares an
|
|
112
|
+
OpenAI-compatible ``base_url``.
|
|
113
|
+
"""
|
|
114
|
+
handle = str(model.get("handle") or "").strip().lower()
|
|
115
|
+
config = model.get("config") or {}
|
|
116
|
+
|
|
117
|
+
if handle in SUPPORTED_PROVIDERS:
|
|
118
|
+
return SUPPORTED_PROVIDERS[handle]
|
|
119
|
+
|
|
120
|
+
# The handle was left as the model's own name — fall back to a lookup
|
|
121
|
+
# by name, so a registry written before the handle existed still works.
|
|
122
|
+
for key in ("slug", "name"):
|
|
123
|
+
candidate = str(model.get(key) or "").strip().lower()
|
|
124
|
+
if candidate in KNOWN_MODEL_PROVIDERS:
|
|
125
|
+
return KNOWN_MODEL_PROVIDERS[candidate]
|
|
126
|
+
|
|
127
|
+
if config.get("base_url"):
|
|
128
|
+
# Any OpenAI-compatible endpoint works.
|
|
129
|
+
return "openai"
|
|
130
|
+
|
|
131
|
+
raise BuildError(
|
|
132
|
+
f"the model {model.get('slug')!r} uses the provider {handle!r}, which "
|
|
133
|
+
"nitpick does not support. Supported providers are: "
|
|
134
|
+
+ ", ".join(sorted(set(SUPPORTED_PROVIDERS.values())))
|
|
135
|
+
+ ". Give the model a 'base_url' in its config if it speaks the "
|
|
136
|
+
"OpenAI wire format, or set its handle to the provider's name."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _model_id(model: dict[str, Any], fallback: str) -> str:
|
|
141
|
+
"""The provider-specific model identifier."""
|
|
142
|
+
config = model.get("config") or {}
|
|
143
|
+
for key in ("model", "model_id", "id"):
|
|
144
|
+
value = config.get(key)
|
|
145
|
+
if isinstance(value, str) and value:
|
|
146
|
+
return value
|
|
147
|
+
# The slug is a better identifier than the display name.
|
|
148
|
+
slug = model.get("slug")
|
|
149
|
+
if isinstance(slug, str) and slug:
|
|
150
|
+
return slug
|
|
151
|
+
return fallback
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _voice_for(model: dict[str, Any]) -> str:
|
|
155
|
+
"""The voice a TTS model should speak with."""
|
|
156
|
+
config = model.get("config") or {}
|
|
157
|
+
for key in ("voice", "voices"):
|
|
158
|
+
value = config.get(key)
|
|
159
|
+
if isinstance(value, str) and value:
|
|
160
|
+
return value
|
|
161
|
+
if isinstance(value, list) and value:
|
|
162
|
+
first = value[0]
|
|
163
|
+
if isinstance(first, str) and first:
|
|
164
|
+
return first
|
|
165
|
+
slug = model.get("slug")
|
|
166
|
+
return slug if isinstance(slug, str) and slug else "alloy"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _stt_spec(model: dict[str, Any]) -> str:
|
|
170
|
+
"""The ``stt`` argument for nitpick's Agent."""
|
|
171
|
+
provider = provider_for(model)
|
|
172
|
+
if provider == "openai":
|
|
173
|
+
return "openai"
|
|
174
|
+
if provider == "deepgram":
|
|
175
|
+
return "deepgram"
|
|
176
|
+
if provider == "sarvam":
|
|
177
|
+
return "sarvam"
|
|
178
|
+
raise BuildError(f"unsupported speech-to-text provider {provider!r}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _llm_spec(model: dict[str, Any]) -> str:
|
|
182
|
+
"""The ``llm`` argument for nitpick's Agent."""
|
|
183
|
+
provider = provider_for(model)
|
|
184
|
+
if provider != "openai":
|
|
185
|
+
raise BuildError(
|
|
186
|
+
f"unsupported language-model provider {provider!r} — nitpick speaks "
|
|
187
|
+
"the OpenAI wire format"
|
|
188
|
+
)
|
|
189
|
+
return f"openai:{_model_id(model, 'gpt-4o-mini')}"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _tts_spec(model: dict[str, Any]) -> str:
|
|
193
|
+
"""The ``tts`` argument for nitpick's Agent."""
|
|
194
|
+
provider = provider_for(model)
|
|
195
|
+
if provider != "openai":
|
|
196
|
+
raise BuildError(f"unsupported text-to-speech provider {provider!r}")
|
|
197
|
+
return f"openai:{_voice_for(model)}"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def build_spec(
|
|
201
|
+
resolved: dict[str, Any],
|
|
202
|
+
*,
|
|
203
|
+
room_id: str = "demo",
|
|
204
|
+
overrides: dict[str, Any] | None = None,
|
|
205
|
+
) -> AgentSpec:
|
|
206
|
+
"""Build the spec for one agent from its resolved documents."""
|
|
207
|
+
agent_document = resolved["agent"]
|
|
208
|
+
config = dict(agent_document.get("config") or {})
|
|
209
|
+
overrides = overrides or {}
|
|
210
|
+
|
|
211
|
+
stt_model = resolved.get("stt")
|
|
212
|
+
llm_model = resolved.get("llm")
|
|
213
|
+
tts_model = resolved.get("tts")
|
|
214
|
+
|
|
215
|
+
if stt_model is None:
|
|
216
|
+
raise BuildError(
|
|
217
|
+
f"the agent {agent_document.get('slug')!r} has no speech-to-text model"
|
|
218
|
+
)
|
|
219
|
+
if llm_model is None:
|
|
220
|
+
raise BuildError(
|
|
221
|
+
f"the agent {agent_document.get('slug')!r} has no language model"
|
|
222
|
+
)
|
|
223
|
+
if tts_model is None:
|
|
224
|
+
raise BuildError(
|
|
225
|
+
f"the agent {agent_document.get('slug')!r} has no text-to-speech model"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
stt = overrides.get("stt") or _stt_spec(stt_model)
|
|
229
|
+
llm = overrides.get("llm") or _llm_spec(llm_model)
|
|
230
|
+
tts = overrides.get("tts") or _tts_spec(tts_model)
|
|
231
|
+
|
|
232
|
+
language = (
|
|
233
|
+
overrides.get("language")
|
|
234
|
+
or config.get("language")
|
|
235
|
+
or (stt_model.get("config") or {}).get("language")
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# A model may point at an OpenAI-compatible endpoint — a self-hosted
|
|
239
|
+
# model, or a gateway. That is how a provider nitpick does not know
|
|
240
|
+
# about can still be used.
|
|
241
|
+
llm_base_url = (
|
|
242
|
+
overrides.get("llm_base_url")
|
|
243
|
+
or config.get("base_url")
|
|
244
|
+
or (llm_model.get("config") or {}).get("base_url")
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return AgentSpec(
|
|
248
|
+
handle=str(agent_document.get("handle") or agent_document.get("slug") or ""),
|
|
249
|
+
name=str(agent_document.get("name") or agent_document.get("slug") or "Agent"),
|
|
250
|
+
prompt=str(agent_document.get("prompt") or ""),
|
|
251
|
+
stt=stt,
|
|
252
|
+
llm=llm,
|
|
253
|
+
tts=tts,
|
|
254
|
+
language=language if isinstance(language, str) else None,
|
|
255
|
+
llm_base_url=llm_base_url if isinstance(llm_base_url, str) else None,
|
|
256
|
+
room_id=room_id,
|
|
257
|
+
config=config,
|
|
258
|
+
documents=resolved,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def build_agent(
|
|
263
|
+
spec: AgentSpec,
|
|
264
|
+
*,
|
|
265
|
+
manager: Any = None,
|
|
266
|
+
session_id: str | None = None,
|
|
267
|
+
) -> Agent:
|
|
268
|
+
"""Build the runnable nitpick agent for a spec."""
|
|
269
|
+
kwargs: dict[str, Any] = {
|
|
270
|
+
"name": spec.name,
|
|
271
|
+
"prompt": spec.prompt or None,
|
|
272
|
+
"stt": spec.stt,
|
|
273
|
+
"llm": spec.llm,
|
|
274
|
+
"tts": spec.tts,
|
|
275
|
+
}
|
|
276
|
+
if spec.language:
|
|
277
|
+
kwargs["language"] = spec.language
|
|
278
|
+
if spec.llm_base_url:
|
|
279
|
+
kwargs["llm_base_url"] = spec.llm_base_url
|
|
280
|
+
if manager is not None:
|
|
281
|
+
kwargs["manager"] = manager
|
|
282
|
+
if session_id:
|
|
283
|
+
kwargs["session_id"] = session_id
|
|
284
|
+
|
|
285
|
+
# The agent's own config may carry pipeline settings.
|
|
286
|
+
config = spec.config or {}
|
|
287
|
+
if "vad" in config:
|
|
288
|
+
kwargs["vad"] = config["vad"]
|
|
289
|
+
if "allow_interruptions" in config:
|
|
290
|
+
kwargs["allow_interruptions"] = bool(config["allow_interruptions"])
|
|
291
|
+
|
|
292
|
+
return Agent(**kwargs)
|