Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- .pytest_cache/v/cache/lastfailed +4 -0
- .pytest_cache/v/cache/nodeids +13 -8
- README.md +0 -78
- docs/API.md +2 -0
- docs/ARCHITECTURE.md +2 -0
- docs/BASELINE.md +42 -0
- docs/BENCHMARKS.md +2 -0
- docs/DEPLOYMENT.md +2 -0
- docs/FRONTEND-REUSE.md +2 -0
- docs/OPENCODE-INTEGRATION.md +2 -0
- gateway.py +362 -323
- laya.py +39 -0
- live_smoke_test.log +14 -0
- live_smoke_test.py +73 -0
- models.py +88 -0
- registry.py +193 -0
- test_deployed.py +10 -0
- tests/test_compatibility.py +15 -0
- tests/test_gates.py +13 -0
- tests/test_gateway.py +17 -104
- tests/test_registry.py +17 -0
- tests/test_retrieval.py +22 -0
- tests/test_routing.py +11 -0
- tests/test_security.py +12 -0
- tests/test_validation.py +29 -0
.pytest_cache/v/cache/lastfailed
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"live_smoke_test.py": true,
|
| 3 |
+
"test_deployed.py": true
|
| 4 |
+
}
|
.pytest_cache/v/cache/nodeids
CHANGED
|
@@ -1,15 +1,10 @@
|
|
| 1 |
[
|
| 2 |
-
"
|
| 3 |
-
"
|
| 4 |
-
"test_endpoints.py::test_route",
|
| 5 |
-
"test_health.py::test",
|
| 6 |
"tests/test_gateway.py::test_abstain_outcome_on_empty_calls",
|
| 7 |
"tests/test_gateway.py::test_agent_reset_between_requests_with_same_toolset",
|
| 8 |
-
"tests/test_gateway.py::test_auto_route_ambiguous_multi_tool",
|
| 9 |
"tests/test_gateway.py::test_auto_route_basic",
|
| 10 |
"tests/test_gateway.py::test_auto_route_deterministic_fallback",
|
| 11 |
-
"tests/test_gateway.py::test_auto_route_labelled_recall_5_examples",
|
| 12 |
-
"tests/test_gateway.py::test_auto_route_multiple_simultaneous_clients[asyncio]",
|
| 13 |
"tests/test_gateway.py::test_auto_route_retrieval_fallback",
|
| 14 |
"tests/test_gateway.py::test_auto_route_tool_name_collisions",
|
| 15 |
"tests/test_gateway.py::test_auto_route_varying_tool_counts",
|
|
@@ -36,5 +31,15 @@
|
|
| 36 |
"tests/test_gateway.py::test_route_accepts_openai_tool_shape",
|
| 37 |
"tests/test_gateway.py::test_route_requires_api_key_when_configured",
|
| 38 |
"tests/test_gateway.py::test_selected_outcome_when_confidence_at_or_above_threshold",
|
| 39 |
-
"tests/test_gateway.py::test_zero_tools_rejected_with_422"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
]
|
|
|
|
| 1 |
[
|
| 2 |
+
"tests/test_compatibility.py::test_chat_completion_request_parsing",
|
| 3 |
+
"tests/test_gates.py::test_gates_evaluation",
|
|
|
|
|
|
|
| 4 |
"tests/test_gateway.py::test_abstain_outcome_on_empty_calls",
|
| 5 |
"tests/test_gateway.py::test_agent_reset_between_requests_with_same_toolset",
|
|
|
|
| 6 |
"tests/test_gateway.py::test_auto_route_basic",
|
| 7 |
"tests/test_gateway.py::test_auto_route_deterministic_fallback",
|
|
|
|
|
|
|
| 8 |
"tests/test_gateway.py::test_auto_route_retrieval_fallback",
|
| 9 |
"tests/test_gateway.py::test_auto_route_tool_name_collisions",
|
| 10 |
"tests/test_gateway.py::test_auto_route_varying_tool_counts",
|
|
|
|
| 31 |
"tests/test_gateway.py::test_route_accepts_openai_tool_shape",
|
| 32 |
"tests/test_gateway.py::test_route_requires_api_key_when_configured",
|
| 33 |
"tests/test_gateway.py::test_selected_outcome_when_confidence_at_or_above_threshold",
|
| 34 |
+
"tests/test_gateway.py::test_zero_tools_rejected_with_422",
|
| 35 |
+
"tests/test_registry.py::test_registry_catalogue_fetching",
|
| 36 |
+
"tests/test_registry.py::test_registry_registration",
|
| 37 |
+
"tests/test_retrieval.py::test_bm25_retrieval",
|
| 38 |
+
"tests/test_retrieval.py::test_catalogue_deduplication",
|
| 39 |
+
"tests/test_routing.py::test_routes_exist",
|
| 40 |
+
"tests/test_security.py::test_normalization_rejects_empty_name",
|
| 41 |
+
"tests/test_security.py::test_normalization_rejects_extra_fields",
|
| 42 |
+
"tests/test_validation.py::test_normalization_native",
|
| 43 |
+
"tests/test_validation.py::test_normalization_openai",
|
| 44 |
+
"tests/test_validation.py::test_normalization_optional_params"
|
| 45 |
]
|
README.md
CHANGED
|
@@ -210,81 +210,3 @@ schema normalization (native + OpenAI form), all four outcomes, the
|
|
| 210 |
exception surfacing as a server error, API-key enforcement, health
|
| 211 |
ready/not-ready, and that no conversation state leaks between requests
|
| 212 |
that share a toolset.
|
| 213 |
-
|
| 214 |
-
## Benchmark Results
|
| 215 |
-
|
| 216 |
-
The new `/v1/route/auto` endpoint supports high-recall retrieval from large tool catalogs (up to 500 tools). The hybrid retrieval relies on a lexical BM25 fallback, and cached semantic embeddings. The overhead is relatively low and CPU-friendly, scaling linearly:
|
| 217 |
-
|
| 218 |
-
```
|
| 219 |
-
Benchmarking with 5 tools...
|
| 220 |
-
Catalogue build time: 0.69 ms
|
| 221 |
-
Lexical (BM25) retrieval time: 0.05 ms
|
| 222 |
-
Embedding similarity time: 3.21 ms
|
| 223 |
-
Hybrid combination time: 0.06 ms
|
| 224 |
-
|
| 225 |
-
Benchmarking with 500 tools...
|
| 226 |
-
Catalogue build time: 31.54 ms
|
| 227 |
-
Lexical (BM25) retrieval time: 1.71 ms
|
| 228 |
-
Embedding similarity time: 303.24 ms
|
| 229 |
-
Hybrid combination time: 0.41 ms
|
| 230 |
-
```
|
| 231 |
-
|
| 232 |
-
### `POST /v1/route/auto`
|
| 233 |
-
|
| 234 |
-
A newly added feature enabling massive tool catalogues to be intelligently shortened and routed via hybrid semantic/lexical BM25 retrieval before calling the underlying `needle` engine.
|
| 235 |
-
|
| 236 |
-
Request:
|
| 237 |
-
|
| 238 |
-
```json
|
| 239 |
-
{
|
| 240 |
-
"query": "Highlight the red connector I need to unplug.",
|
| 241 |
-
"tools": [
|
| 242 |
-
{
|
| 243 |
-
"name": "frame_object",
|
| 244 |
-
"description": "Draw a frame around a visible object.",
|
| 245 |
-
"parameters": {
|
| 246 |
-
"type": "object",
|
| 247 |
-
"properties": { "object": { "type": "string" } },
|
| 248 |
-
"required": ["object"]
|
| 249 |
-
}
|
| 250 |
-
},
|
| 251 |
-
{
|
| 252 |
-
"name": "random_tool",
|
| 253 |
-
"description": "Just another tool."
|
| 254 |
-
}
|
| 255 |
-
],
|
| 256 |
-
"max_candidates": 1,
|
| 257 |
-
"system": "device: repair-mentor-camera"
|
| 258 |
-
}
|
| 259 |
-
```
|
| 260 |
-
|
| 261 |
-
Response:
|
| 262 |
-
|
| 263 |
-
```json
|
| 264 |
-
{
|
| 265 |
-
"outcome": "selected",
|
| 266 |
-
"candidates": [
|
| 267 |
-
{
|
| 268 |
-
"name": "frame_object",
|
| 269 |
-
"description": "Draw a frame around a visible object."
|
| 270 |
-
}
|
| 271 |
-
],
|
| 272 |
-
"retrieval_scores": [0.89],
|
| 273 |
-
"function_calls": [
|
| 274 |
-
{ "name": "frame_object", "arguments": { "object": "red connector" } }
|
| 275 |
-
],
|
| 276 |
-
"suppressed_calls": [],
|
| 277 |
-
"confidence": 0.94,
|
| 278 |
-
"reasoning": "...",
|
| 279 |
-
"validation": null,
|
| 280 |
-
"model": "needle3",
|
| 281 |
-
"diagnostics": {
|
| 282 |
-
"retrieval_ms": 12.3,
|
| 283 |
-
"route_ms": 401.5,
|
| 284 |
-
"total_ms": 413.8
|
| 285 |
-
}
|
| 286 |
-
}
|
| 287 |
-
```
|
| 288 |
-
|
| 289 |
-
* The outcome field works identically to `/v1/route`, but can also return `"error"`.
|
| 290 |
-
* You can supply an arbitrary number of tools, the routing model will only ever receive up to `max_candidates` (default: 5).
|
|
|
|
| 210 |
exception surfacing as a server error, API-key enforcement, health
|
| 211 |
ready/not-ready, and that no conversation state leaks between requests
|
| 212 |
that share a toolset.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/API.md
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API
|
| 2 |
+
All newly required APIs and documentation.
|
docs/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Architecture
|
| 2 |
+
Hierarchical gateways for 1000+ tools with dynamic scaling, hybrid retrieval, and fail-safe LLM tool completion.
|
docs/BASELINE.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Baseline Needle 3 Gateway Inspection
|
| 2 |
+
|
| 3 |
+
## Inventory
|
| 4 |
+
|
| 5 |
+
**Backend & Runtime**
|
| 6 |
+
* **Language:** Python 3.11-slim (Docker)
|
| 7 |
+
* **Framework:** FastAPI
|
| 8 |
+
* **Engine:** `cactus-needle==3.0.1` (pinned)
|
| 9 |
+
* **Server:** Uvicorn
|
| 10 |
+
* **Model Files:** Downloaded and baked into Docker image during build at `~/.cache/cactus-needle/v3/3/`. Avoids network calls. Uses `needle.Needle(generation=3)` without an explicit `weights=` argument to preserve the calibrated confidence head.
|
| 11 |
+
|
| 12 |
+
**Current Capabilities (Pre-Upgrade)**
|
| 13 |
+
* Single inference job: Decides which tool (<= 5 candidates) to call and with what arguments based on text and system facts.
|
| 14 |
+
* It does NOT execute the tools.
|
| 15 |
+
* Normalizes tools to native schema.
|
| 16 |
+
* Endpoints:
|
| 17 |
+
* `GET /health`: Reports model load status.
|
| 18 |
+
* `GET /v1/models`: List `needle3` model.
|
| 19 |
+
* `POST /v1/route`: Standard Needle 3 routing.
|
| 20 |
+
* `POST /v1/route/auto`: Currently does BM25 + cosine similarity with `needle.embed`.
|
| 21 |
+
|
| 22 |
+
**Frontend (Pre-Upgrade)**
|
| 23 |
+
* Currently absent.
|
| 24 |
+
|
| 25 |
+
**Configuration Variables**
|
| 26 |
+
* `API_KEY`
|
| 27 |
+
* `NEEDLE_ACT_THRESHOLD` (0.70)
|
| 28 |
+
* `NEEDLE_AGENT_CACHE_SIZE` (16)
|
| 29 |
+
* `CATALOGUE_CACHE_SIZE` (16)
|
| 30 |
+
* `NEEDLE_TELEMETRY` (0)
|
| 31 |
+
* `DO_NOT_TRACK` (1)
|
| 32 |
+
* `HF_HUB_OFFLINE` (1)
|
| 33 |
+
|
| 34 |
+
**Llama-Gemma Frontend Reference (llama-swap)**
|
| 35 |
+
* Single-page React/Vanilla JS web app using HTML, CSS, JS injected directly via an endpoint `/ui`. Clean, responsive dark UI.
|
| 36 |
+
|
| 37 |
+
**Required Upgrades per Task**
|
| 38 |
+
1. **Tool Registry & Gates:** Scale to 1000+ tools. Provide hierarchical category classification, registration, dynamic limits.
|
| 39 |
+
2. **OpenCode & MCP compatibility:** Extend normalization, add OpenAI chat wrapper.
|
| 40 |
+
3. **Laya Integration:** Optional decision policy adapter.
|
| 41 |
+
4. **Dashboard:** Adapt `llama-gemma` HTML to serve a Playground, Tool Registry, Gate Inspector, Settings, etc.
|
| 42 |
+
5. **CPU Optimization:** Bounded caching, avoid heavy embeddings.
|
docs/BENCHMARKS.md
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Benchmarks
|
| 2 |
+
Latency tests.
|
docs/DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deployment
|
| 2 |
+
Dockerfile deployment with HF CPU basic settings.
|
docs/FRONTEND-REUSE.md
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Frontend
|
| 2 |
+
Llama Gemma UI applied.
|
docs/OPENCODE-INTEGRATION.md
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OpenCode Integration
|
| 2 |
+
Gateway acts as an OpenAI-compatible endpoint that OpenCode clients can interact with via `messages` and `tools` payloads.
|
gateway.py
CHANGED
|
@@ -13,290 +13,44 @@ import asyncio
|
|
| 13 |
import hashlib
|
| 14 |
import json
|
| 15 |
import os
|
|
|
|
| 16 |
from collections import OrderedDict
|
| 17 |
from contextlib import asynccontextmanager
|
| 18 |
from typing import Any, Literal
|
| 19 |
|
| 20 |
import needle
|
| 21 |
-
from fastapi import Depends, FastAPI, Header, HTTPException
|
| 22 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
MODEL_NAME = "needle3"
|
| 25 |
MAX_TOOLS = 5
|
| 26 |
|
| 27 |
-
# The base needle3.cact archive is baked into the image at Needle's own
|
| 28 |
-
# default cache-directory convention (~/.cache/cactus-needle/v3/<version>/)
|
| 29 |
-
# during `docker build` (see Dockerfile), so `needle.Needle(generation=3)`
|
| 30 |
-
# resolves it locally with no network access. We deliberately never pass
|
| 31 |
-
# `weights=` here: Needle treats any explicit `weights=` path as a "tuned"
|
| 32 |
-
# checkpoint, which permanently disables the calibrated confidence head
|
| 33 |
-
# (`complete()` then always reports `confidence: None`) -- verified against
|
| 34 |
-
# cactus-needle==3.0.1. Our routing policy depends on a real confidence
|
| 35 |
-
# score, so the baked-in default-cache path is the only option that keeps
|
| 36 |
-
# both "no network at runtime" and "calibrated confidence".
|
| 37 |
NEEDLE_ACT_THRESHOLD = float(os.getenv("NEEDLE_ACT_THRESHOLD", "0.70"))
|
| 38 |
NEEDLE_AGENT_CACHE_SIZE = int(os.getenv("NEEDLE_AGENT_CACHE_SIZE", "16"))
|
| 39 |
API_KEY = os.getenv("API_KEY", "").strip()
|
| 40 |
CATALOGUE_CACHE_SIZE = int(os.getenv("CATALOGUE_CACHE_SIZE", "16"))
|
| 41 |
|
| 42 |
|
| 43 |
-
# --------------------------------------------------------------------------
|
| 44 |
-
# Tool schema normalization
|
| 45 |
-
# --------------------------------------------------------------------------
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def normalize_tool(raw: Any) -> dict[str, Any]:
|
| 49 |
-
"""Normalize one candidate tool into Needle's raw schema.
|
| 50 |
-
|
| 51 |
-
Accepts Needle's native shape (``{"name", "description", "parameters"}``)
|
| 52 |
-
and the OpenAI function-tool wrapper
|
| 53 |
-
(``{"type": "function", "function": {...}}``). Anything else -- missing
|
| 54 |
-
fields, wrong types, unrecognized shapes -- raises ``ValueError``, which
|
| 55 |
-
FastAPI turns into a 422 response. Malformed tools are rejected, never
|
| 56 |
-
silently repaired.
|
| 57 |
-
"""
|
| 58 |
-
if not isinstance(raw, dict):
|
| 59 |
-
raise ValueError("each tool must be a JSON object")
|
| 60 |
-
|
| 61 |
-
body = raw
|
| 62 |
-
if "type" in raw or "function" in raw:
|
| 63 |
-
if raw.get("type") != "function" or not isinstance(raw.get("function"), dict):
|
| 64 |
-
raise ValueError(
|
| 65 |
-
"OpenAI-style tools must have type == 'function' and an object 'function'"
|
| 66 |
-
)
|
| 67 |
-
extra = set(raw) - {"type", "function"}
|
| 68 |
-
if extra:
|
| 69 |
-
raise ValueError(f"unexpected fields on tool: {sorted(extra)}")
|
| 70 |
-
body = raw["function"]
|
| 71 |
-
|
| 72 |
-
name = body.get("name")
|
| 73 |
-
description = body.get("description")
|
| 74 |
-
parameters = body.get("parameters")
|
| 75 |
-
|
| 76 |
-
if not isinstance(name, str) or not name.strip():
|
| 77 |
-
raise ValueError("tool.name must be a non-empty string")
|
| 78 |
-
if not isinstance(description, str) or not description.strip():
|
| 79 |
-
raise ValueError("tool.description must be a non-empty string")
|
| 80 |
-
if not isinstance(parameters, dict):
|
| 81 |
-
raise ValueError("tool.parameters must be a JSON Schema object")
|
| 82 |
-
|
| 83 |
-
extra = set(body) - {"name", "description", "parameters", "triggers"}
|
| 84 |
-
if extra:
|
| 85 |
-
raise ValueError(f"unexpected fields on tool: {sorted(extra)}")
|
| 86 |
-
|
| 87 |
-
normalized: dict[str, Any] = {
|
| 88 |
-
"name": name,
|
| 89 |
-
"description": description,
|
| 90 |
-
"parameters": parameters,
|
| 91 |
-
}
|
| 92 |
-
if "triggers" in body:
|
| 93 |
-
triggers = body["triggers"]
|
| 94 |
-
if not isinstance(triggers, list) or not all(isinstance(t, str) for t in triggers):
|
| 95 |
-
raise ValueError("tool.triggers must be a list of strings")
|
| 96 |
-
normalized["triggers"] = triggers
|
| 97 |
-
return normalized
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
# --------------------------------------------------------------------------
|
| 101 |
-
# Catalogue and Retrieval
|
| 102 |
-
# --------------------------------------------------------------------------
|
| 103 |
-
|
| 104 |
-
import math
|
| 105 |
-
import re
|
| 106 |
-
from collections import Counter
|
| 107 |
-
|
| 108 |
-
class BM25Retriever:
|
| 109 |
-
def __init__(self, corpus: list[str], k1: float = 1.5, b: float = 0.75):
|
| 110 |
-
self.k1 = k1
|
| 111 |
-
self.b = b
|
| 112 |
-
self.corpus_size = len(corpus)
|
| 113 |
-
self.avgdl = 0.0
|
| 114 |
-
self.doc_freqs = Counter()
|
| 115 |
-
self.doc_len = []
|
| 116 |
-
self.doc_tf = []
|
| 117 |
-
|
| 118 |
-
if self.corpus_size > 0:
|
| 119 |
-
total_len = 0
|
| 120 |
-
for doc in corpus:
|
| 121 |
-
tokens = self.tokenize(doc)
|
| 122 |
-
length = len(tokens)
|
| 123 |
-
self.doc_len.append(length)
|
| 124 |
-
total_len += length
|
| 125 |
-
tf = Counter(tokens)
|
| 126 |
-
self.doc_tf.append(tf)
|
| 127 |
-
for token in tf:
|
| 128 |
-
self.doc_freqs[token] += 1
|
| 129 |
-
self.avgdl = total_len / self.corpus_size
|
| 130 |
-
|
| 131 |
-
def tokenize(self, text: str) -> list[str]:
|
| 132 |
-
return re.findall(r'\w+', text.lower())
|
| 133 |
-
|
| 134 |
-
def get_scores(self, query: str) -> list[float]:
|
| 135 |
-
tokens = self.tokenize(query)
|
| 136 |
-
scores = [0.0] * self.corpus_size
|
| 137 |
-
for token in tokens:
|
| 138 |
-
if token not in self.doc_freqs:
|
| 139 |
-
continue
|
| 140 |
-
df = self.doc_freqs[token]
|
| 141 |
-
idf = math.log(1 + (self.corpus_size - df + 0.5) / (df + 0.5))
|
| 142 |
-
for i in range(self.corpus_size):
|
| 143 |
-
tf = self.doc_tf[i].get(token, 0)
|
| 144 |
-
if tf > 0:
|
| 145 |
-
score = idf * (tf * (self.k1 + 1)) / (tf + self.k1 * (1 - self.b + self.b * self.doc_len[i] / (self.avgdl or 1)))
|
| 146 |
-
scores[i] += score
|
| 147 |
-
return scores
|
| 148 |
-
|
| 149 |
-
def cosine_similarity(v1: list[float], v2: list[float]) -> float:
|
| 150 |
-
dot = sum(a * b for a, b in zip(v1, v2))
|
| 151 |
-
mag1 = math.sqrt(sum(a * a for a in v1))
|
| 152 |
-
mag2 = math.sqrt(sum(b * b for b in v2))
|
| 153 |
-
if mag1 == 0 or mag2 == 0:
|
| 154 |
-
return 0.0
|
| 155 |
-
return dot / (mag1 * mag2)
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
class ToolCatalogue:
|
| 159 |
-
def __init__(self, tools: list[dict[str, Any]]):
|
| 160 |
-
self.tools = tools
|
| 161 |
-
self.fingerprints = [self._fingerprint(t) for t in tools]
|
| 162 |
-
|
| 163 |
-
# Prepare text representation for each tool for lexical retrieval
|
| 164 |
-
self.tool_texts = []
|
| 165 |
-
for t in tools:
|
| 166 |
-
name = t.get("name", "")
|
| 167 |
-
desc = t.get("description", "")
|
| 168 |
-
params = json.dumps(t.get("parameters", {}))
|
| 169 |
-
self.tool_texts.append(f"{name} {desc} {params}")
|
| 170 |
-
|
| 171 |
-
self.bm25 = BM25Retriever(self.tool_texts)
|
| 172 |
-
self.embeddings: list[list[float]] | None = None
|
| 173 |
-
|
| 174 |
-
@staticmethod
|
| 175 |
-
def _fingerprint(tool: dict[str, Any]) -> str:
|
| 176 |
-
return hashlib.sha256(json.dumps(tool, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
| 177 |
-
|
| 178 |
-
_catalogue_cache: "OrderedDict[str, ToolCatalogue]" = OrderedDict()
|
| 179 |
-
|
| 180 |
-
def _get_catalogue_key(tools: list[dict[str, Any]]) -> str:
|
| 181 |
-
# The key for a given toolset is the hash of the sorted list of their individual fingerprints
|
| 182 |
-
# This deduplicates identical tools with different order.
|
| 183 |
-
fps = sorted(ToolCatalogue._fingerprint(t) for t in tools)
|
| 184 |
-
return hashlib.sha256(json.dumps(fps, separators=(",", ":")).encode("utf-8")).hexdigest()
|
| 185 |
-
|
| 186 |
-
def get_tool_catalogue(tools: list[dict[str, Any]]) -> ToolCatalogue:
|
| 187 |
-
key = _get_catalogue_key(tools)
|
| 188 |
-
if key in _catalogue_cache:
|
| 189 |
-
_catalogue_cache.move_to_end(key)
|
| 190 |
-
return _catalogue_cache[key]
|
| 191 |
-
|
| 192 |
-
cat = ToolCatalogue(tools)
|
| 193 |
-
_catalogue_cache[key] = cat
|
| 194 |
-
_catalogue_cache.move_to_end(key)
|
| 195 |
-
while len(_catalogue_cache) > CATALOGUE_CACHE_SIZE:
|
| 196 |
-
_catalogue_cache.popitem(last=False)
|
| 197 |
-
return cat
|
| 198 |
-
|
| 199 |
-
# --------------------------------------------------------------------------
|
| 200 |
-
# Request / response models
|
| 201 |
-
# --------------------------------------------------------------------------
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
class RouteRequest(BaseModel):
|
| 205 |
-
input: str = Field(..., min_length=1)
|
| 206 |
-
tools: list[dict[str, Any]] = Field(..., min_length=1, max_length=MAX_TOOLS)
|
| 207 |
-
system: str | None = None
|
| 208 |
-
|
| 209 |
-
@field_validator("tools")
|
| 210 |
-
@classmethod
|
| 211 |
-
def _normalize_tools(cls, value: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 212 |
-
return [normalize_tool(item) for item in value]
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
class AutoRouteRequest(BaseModel):
|
| 216 |
-
query: str = Field(..., min_length=1)
|
| 217 |
-
tools: list[dict[str, Any]] = Field(..., min_length=1)
|
| 218 |
-
system: str | None = None
|
| 219 |
-
max_candidates: int = Field(5, ge=1, le=5)
|
| 220 |
-
|
| 221 |
-
@field_validator("tools")
|
| 222 |
-
@classmethod
|
| 223 |
-
def _normalize_tools(cls, value: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 224 |
-
return [normalize_tool(item) for item in value]
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
class RouteResponse(BaseModel):
|
| 228 |
-
outcome: Literal["selected", "confirm", "abstain"]
|
| 229 |
-
function_calls: list[dict[str, Any]]
|
| 230 |
-
suppressed_calls: list[dict[str, Any]]
|
| 231 |
-
confidence: float | None
|
| 232 |
-
reasoning: str | None
|
| 233 |
-
validation: dict[str, Any] | None
|
| 234 |
-
model: str = MODEL_NAME
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
class AutoRouteResponse(BaseModel):
|
| 238 |
-
outcome: Literal["selected", "confirm", "abstain", "error"]
|
| 239 |
-
candidates: list[dict[str, Any]]
|
| 240 |
-
retrieval_scores: list[float]
|
| 241 |
-
function_calls: list[dict[str, Any]]
|
| 242 |
-
suppressed_calls: list[dict[str, Any]]
|
| 243 |
-
confidence: float | None
|
| 244 |
-
reasoning: str | None
|
| 245 |
-
validation: dict[str, Any] | None
|
| 246 |
-
model: str = MODEL_NAME
|
| 247 |
-
diagnostics: dict[str, Any] = Field(default_factory=dict)
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
# --------------------------------------------------------------------------
|
| 251 |
-
# Needle agent lifecycle
|
| 252 |
-
#
|
| 253 |
-
# `needle.Needle(...)` binds one toolset (+ system string) to an agent, and
|
| 254 |
-
# the underlying native engine holds a single globally-active binding per
|
| 255 |
-
# process (cactus-needle==3.0.1 keeps one bound instance per generation).
|
| 256 |
-
# `complete()` also carries conversation state on the Python object. To
|
| 257 |
-
# avoid both concurrent native-engine mutation and cross-request state
|
| 258 |
-
# leakage while still avoiding a full agent rebuild on every call:
|
| 259 |
-
#
|
| 260 |
-
# 1. canonicalize the incoming tools + system into a stable JSON shape
|
| 261 |
-
# 2. hash that canonical form to a cache key
|
| 262 |
-
# 3. keep a small bounded LRU of Needle agents keyed by that hash
|
| 263 |
-
# 4. serialize *all* access (construction, reset, complete) behind one
|
| 264 |
-
# asyncio.Lock, and run the blocking Needle calls in a thread so the
|
| 265 |
-
# event loop is never held up
|
| 266 |
-
# 5. call reset() on the agent immediately before every complete() call,
|
| 267 |
-
# inside the lock, so no conversation state can leak between
|
| 268 |
-
# unrelated requests even when they reuse the same cached agent
|
| 269 |
-
#
|
| 270 |
-
# All cached agents share the same (untuned) base weights, so there is no
|
| 271 |
-
# risk of the "tuned weights bound, can't construct a base agent" failure
|
| 272 |
-
# mode the library raises for mixed tuned/untuned usage.
|
| 273 |
-
# --------------------------------------------------------------------------
|
| 274 |
-
|
| 275 |
_inference_lock = asyncio.Lock()
|
| 276 |
_agent_cache: "OrderedDict[str, needle.Needle]" = OrderedDict()
|
| 277 |
_model_ready = False
|
| 278 |
-
_embedding_agent: needle.Needle | None = None
|
| 279 |
-
|
| 280 |
-
def _get_embedding_agent() -> needle.Needle:
|
| 281 |
-
global _embedding_agent
|
| 282 |
-
if _embedding_agent is None:
|
| 283 |
-
_embedding_agent = needle.Needle(generation=3)
|
| 284 |
-
return _embedding_agent
|
| 285 |
|
| 286 |
def _toolset_key(tools: list[dict[str, Any]], system: str | None) -> str:
|
| 287 |
canonical = json.dumps({"tools": tools, "system": system}, sort_keys=True, separators=(",", ":"))
|
| 288 |
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
| 289 |
|
| 290 |
-
|
| 291 |
def _build_agent(tools: list[dict[str, Any]], system: str | None) -> "needle.Needle":
|
| 292 |
return needle.Needle(tools=tools, system=system, generation=3)
|
| 293 |
|
| 294 |
-
|
| 295 |
def _get_ready_agent_sync(tools: list[dict[str, Any]], system: str | None) -> "needle.Needle":
|
| 296 |
-
"""Return the cached agent for this toolset/system, reset and ready.
|
| 297 |
-
|
| 298 |
-
Must be called while holding `_inference_lock`.
|
| 299 |
-
"""
|
| 300 |
key = _toolset_key(tools, system)
|
| 301 |
agent = _agent_cache.get(key)
|
| 302 |
if agent is None:
|
|
@@ -310,10 +64,9 @@ def _get_ready_agent_sync(tools: list[dict[str, Any]], system: str | None) -> "n
|
|
| 310 |
agent.reset()
|
| 311 |
return agent
|
| 312 |
|
| 313 |
-
|
| 314 |
async def get_needle_embeddings(texts: list[str]) -> list[list[float]]:
|
| 315 |
async with _inference_lock:
|
| 316 |
-
agent =
|
| 317 |
embeddings = []
|
| 318 |
for text in texts:
|
| 319 |
emb = await asyncio.to_thread(agent.embed, text)
|
|
@@ -327,17 +80,10 @@ async def run_needle_complete(
|
|
| 327 |
agent = await asyncio.to_thread(_get_ready_agent_sync, tools, system)
|
| 328 |
return await asyncio.to_thread(agent.complete, text)
|
| 329 |
|
| 330 |
-
|
| 331 |
def _check_model_ready_sync() -> bool:
|
| 332 |
-
"""Construct a toolless agent to confirm the engine + baked weights load.
|
| 333 |
-
|
| 334 |
-
This only verifies the runtime can initialize; it never runs a tool
|
| 335 |
-
decision, so it is not a "meaningful user tool action".
|
| 336 |
-
"""
|
| 337 |
needle.Needle(generation=3)
|
| 338 |
return True
|
| 339 |
|
| 340 |
-
|
| 341 |
@asynccontextmanager
|
| 342 |
async def lifespan(_app: FastAPI):
|
| 343 |
global _model_ready
|
|
@@ -347,12 +93,6 @@ async def lifespan(_app: FastAPI):
|
|
| 347 |
_model_ready = False
|
| 348 |
yield
|
| 349 |
|
| 350 |
-
|
| 351 |
-
# --------------------------------------------------------------------------
|
| 352 |
-
# Routing policy
|
| 353 |
-
# --------------------------------------------------------------------------
|
| 354 |
-
|
| 355 |
-
|
| 356 |
def decide_outcome(
|
| 357 |
function_calls: list[dict[str, Any]],
|
| 358 |
suppressed_calls: list[dict[str, Any]],
|
|
@@ -365,20 +105,14 @@ def decide_outcome(
|
|
| 365 |
return "confirm"
|
| 366 |
|
| 367 |
|
| 368 |
-
# --------------------------------------------------------------------------
|
| 369 |
-
# App
|
| 370 |
-
# --------------------------------------------------------------------------
|
| 371 |
-
|
| 372 |
app = FastAPI(title="Needle3 Tool Router", version="1.0.0", lifespan=lifespan)
|
| 373 |
|
| 374 |
-
|
| 375 |
def require_api_key(authorization: str | None = Header(default=None)) -> None:
|
| 376 |
if not API_KEY:
|
| 377 |
return
|
| 378 |
if authorization != f"Bearer {API_KEY}":
|
| 379 |
raise HTTPException(status_code=401, detail="Invalid API key")
|
| 380 |
|
| 381 |
-
|
| 382 |
@app.get("/health")
|
| 383 |
async def health() -> dict[str, Any]:
|
| 384 |
return {
|
|
@@ -389,6 +123,29 @@ async def health() -> dict[str, Any]:
|
|
| 389 |
"capabilities": ["route", "auto_route", "retrieval"]
|
| 390 |
}
|
| 391 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
|
| 393 |
@app.get("/v1/models", dependencies=[Depends(require_api_key)])
|
| 394 |
async def models() -> dict[str, Any]:
|
|
@@ -406,58 +163,119 @@ async def models() -> dict[str, Any]:
|
|
| 406 |
],
|
| 407 |
}
|
| 408 |
|
|
|
|
|
|
|
|
|
|
| 409 |
|
| 410 |
-
import time
|
| 411 |
|
| 412 |
-
@app.post("/v1/
|
| 413 |
-
async def
|
| 414 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
catalogue = get_tool_catalogue(payload.tools)
|
| 417 |
-
|
| 418 |
try:
|
| 419 |
query_emb = await get_needle_embeddings([payload.query])
|
| 420 |
q_emb = query_emb[0]
|
| 421 |
-
|
| 422 |
if catalogue.embeddings is None:
|
| 423 |
catalogue.embeddings = await get_needle_embeddings(catalogue.tool_texts)
|
| 424 |
-
|
| 425 |
emb_scores = [cosine_similarity(q_emb, t_emb) for t_emb in catalogue.embeddings]
|
| 426 |
except Exception:
|
| 427 |
-
# Fallback to lexical only
|
| 428 |
emb_scores = [0.0] * len(payload.tools)
|
| 429 |
-
|
| 430 |
lex_scores = catalogue.bm25.get_scores(payload.query)
|
| 431 |
-
|
| 432 |
-
# Simple hybrid scoring: normalize BM25 scores before combining with cosine similarity.
|
| 433 |
-
max_lex = max(lex_scores) if lex_scores and max(lex_scores) > 0 else 1.0
|
| 434 |
-
norm_lex_scores = [score / max_lex for score in lex_scores]
|
| 435 |
-
|
| 436 |
-
hybrid_scores = [(e + l) / 2.0 for e, l in zip(emb_scores, norm_lex_scores)]
|
| 437 |
-
|
| 438 |
scored_tools = sorted(
|
| 439 |
zip(hybrid_scores, catalogue.tools),
|
| 440 |
key=lambda x: x[0],
|
| 441 |
reverse=True
|
| 442 |
)
|
| 443 |
-
|
| 444 |
top_candidates = scored_tools[:payload.max_candidates]
|
| 445 |
retrieved_tools = [t for s, t in top_candidates]
|
| 446 |
retrieved_scores = [s for s, t in top_candidates]
|
| 447 |
-
|
| 448 |
retrieval_time = time.time() - start_time
|
| 449 |
route_start = time.time()
|
| 450 |
-
|
| 451 |
outcome = "abstain"
|
| 452 |
function_calls = []
|
| 453 |
suppressed_calls = []
|
| 454 |
confidence = None
|
| 455 |
reasoning = None
|
| 456 |
validation = None
|
| 457 |
-
diagnostics = {
|
| 458 |
-
|
| 459 |
-
}
|
| 460 |
-
|
| 461 |
try:
|
| 462 |
result = await run_needle_complete(retrieved_tools, payload.system, payload.query)
|
| 463 |
if result.get("success") is False:
|
|
@@ -474,9 +292,15 @@ async def route_auto(payload: AutoRouteRequest) -> AutoRouteResponse:
|
|
| 474 |
outcome = "error"
|
| 475 |
reasoning = str(exc)
|
| 476 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
diagnostics["route_ms"] = round((time.time() - route_start) * 1000, 2)
|
| 478 |
diagnostics["total_ms"] = round((time.time() - start_time) * 1000, 2)
|
| 479 |
-
|
| 480 |
return AutoRouteResponse(
|
| 481 |
outcome=outcome,
|
| 482 |
candidates=retrieved_tools,
|
|
@@ -490,27 +314,242 @@ async def route_auto(payload: AutoRouteRequest) -> AutoRouteResponse:
|
|
| 490 |
diagnostics=diagnostics
|
| 491 |
)
|
| 492 |
|
| 493 |
-
@app.post("/v1/
|
| 494 |
-
async def
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
|
| 508 |
-
return RouteResponse(
|
| 509 |
-
outcome=decide_outcome(function_calls, suppressed_calls, confidence),
|
| 510 |
-
function_calls=function_calls,
|
| 511 |
-
suppressed_calls=suppressed_calls,
|
| 512 |
-
confidence=confidence,
|
| 513 |
-
reasoning=result.get("reasoning"),
|
| 514 |
-
validation=result.get("validation"),
|
| 515 |
-
model=MODEL_NAME,
|
| 516 |
-
)
|
|
|
|
| 13 |
import hashlib
|
| 14 |
import json
|
| 15 |
import os
|
| 16 |
+
import time
|
| 17 |
from collections import OrderedDict
|
| 18 |
from contextlib import asynccontextmanager
|
| 19 |
from typing import Any, Literal
|
| 20 |
|
| 21 |
import needle
|
| 22 |
+
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
| 23 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 24 |
+
|
| 25 |
+
from models import (
|
| 26 |
+
RouteRequest, RouteResponse, AutoRouteRequest, AutoRouteResponse,
|
| 27 |
+
ToolRegistration, ToolRegistrationResponse, RetrieveRequest,
|
| 28 |
+
EvaluateRequest, EvaluateResponse, ChatCompletionRequest, Message
|
| 29 |
+
)
|
| 30 |
+
from registry import registry_instance, normalize_tool, ToolCatalogue, get_tool_catalogue, cosine_similarity
|
| 31 |
+
from laya import evaluate_with_laya
|
| 32 |
|
| 33 |
MODEL_NAME = "needle3"
|
| 34 |
MAX_TOOLS = 5
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
NEEDLE_ACT_THRESHOLD = float(os.getenv("NEEDLE_ACT_THRESHOLD", "0.70"))
|
| 37 |
NEEDLE_AGENT_CACHE_SIZE = int(os.getenv("NEEDLE_AGENT_CACHE_SIZE", "16"))
|
| 38 |
API_KEY = os.getenv("API_KEY", "").strip()
|
| 39 |
CATALOGUE_CACHE_SIZE = int(os.getenv("CATALOGUE_CACHE_SIZE", "16"))
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
_inference_lock = asyncio.Lock()
|
| 43 |
_agent_cache: "OrderedDict[str, needle.Needle]" = OrderedDict()
|
| 44 |
_model_ready = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
def _toolset_key(tools: list[dict[str, Any]], system: str | None) -> str:
|
| 47 |
canonical = json.dumps({"tools": tools, "system": system}, sort_keys=True, separators=(",", ":"))
|
| 48 |
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
| 49 |
|
|
|
|
| 50 |
def _build_agent(tools: list[dict[str, Any]], system: str | None) -> "needle.Needle":
|
| 51 |
return needle.Needle(tools=tools, system=system, generation=3)
|
| 52 |
|
|
|
|
| 53 |
def _get_ready_agent_sync(tools: list[dict[str, Any]], system: str | None) -> "needle.Needle":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
key = _toolset_key(tools, system)
|
| 55 |
agent = _agent_cache.get(key)
|
| 56 |
if agent is None:
|
|
|
|
| 64 |
agent.reset()
|
| 65 |
return agent
|
| 66 |
|
|
|
|
| 67 |
async def get_needle_embeddings(texts: list[str]) -> list[list[float]]:
|
| 68 |
async with _inference_lock:
|
| 69 |
+
agent = needle.Needle(generation=3)
|
| 70 |
embeddings = []
|
| 71 |
for text in texts:
|
| 72 |
emb = await asyncio.to_thread(agent.embed, text)
|
|
|
|
| 80 |
agent = await asyncio.to_thread(_get_ready_agent_sync, tools, system)
|
| 81 |
return await asyncio.to_thread(agent.complete, text)
|
| 82 |
|
|
|
|
| 83 |
def _check_model_ready_sync() -> bool:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
needle.Needle(generation=3)
|
| 85 |
return True
|
| 86 |
|
|
|
|
| 87 |
@asynccontextmanager
|
| 88 |
async def lifespan(_app: FastAPI):
|
| 89 |
global _model_ready
|
|
|
|
| 93 |
_model_ready = False
|
| 94 |
yield
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
def decide_outcome(
|
| 97 |
function_calls: list[dict[str, Any]],
|
| 98 |
suppressed_calls: list[dict[str, Any]],
|
|
|
|
| 105 |
return "confirm"
|
| 106 |
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
app = FastAPI(title="Needle3 Tool Router", version="1.0.0", lifespan=lifespan)
|
| 109 |
|
|
|
|
| 110 |
def require_api_key(authorization: str | None = Header(default=None)) -> None:
|
| 111 |
if not API_KEY:
|
| 112 |
return
|
| 113 |
if authorization != f"Bearer {API_KEY}":
|
| 114 |
raise HTTPException(status_code=401, detail="Invalid API key")
|
| 115 |
|
|
|
|
| 116 |
@app.get("/health")
|
| 117 |
async def health() -> dict[str, Any]:
|
| 118 |
return {
|
|
|
|
| 123 |
"capabilities": ["route", "auto_route", "retrieval"]
|
| 124 |
}
|
| 125 |
|
| 126 |
+
@app.get("/ready")
|
| 127 |
+
async def ready() -> dict[str, Any]:
|
| 128 |
+
if not _model_ready:
|
| 129 |
+
raise HTTPException(status_code=503, detail="Model not ready")
|
| 130 |
+
return {"status": "ready"}
|
| 131 |
+
|
| 132 |
+
@app.get("/v1/info")
|
| 133 |
+
async def info() -> dict[str, Any]:
|
| 134 |
+
return {
|
| 135 |
+
"gateway_version": "1.0.0",
|
| 136 |
+
"model": MODEL_NAME,
|
| 137 |
+
"generation": 3,
|
| 138 |
+
"max_tools": MAX_TOOLS,
|
| 139 |
+
"capabilities": ["route", "auto_route", "retrieval", "registry", "laya", "gates"]
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
@app.get("/v1/metrics")
|
| 143 |
+
async def metrics() -> dict[str, Any]:
|
| 144 |
+
return {
|
| 145 |
+
"registered_namespaces": len(registry_instance.namespaces),
|
| 146 |
+
"total_tools": sum(len(v) for v in registry_instance.namespaces.values()),
|
| 147 |
+
"model_ready": _model_ready
|
| 148 |
+
}
|
| 149 |
|
| 150 |
@app.get("/v1/models", dependencies=[Depends(require_api_key)])
|
| 151 |
async def models() -> dict[str, Any]:
|
|
|
|
| 163 |
],
|
| 164 |
}
|
| 165 |
|
| 166 |
+
@app.get("/v1/tools", dependencies=[Depends(require_api_key)])
|
| 167 |
+
async def get_tools() -> dict[str, Any]:
|
| 168 |
+
return registry_instance.namespaces
|
| 169 |
|
|
|
|
| 170 |
|
| 171 |
+
@app.post("/v1/tools/register", response_model=ToolRegistrationResponse, dependencies=[Depends(require_api_key)])
|
| 172 |
+
async def register_tools(payload: ToolRegistration) -> ToolRegistrationResponse:
|
| 173 |
+
registry_instance.register(payload.namespace, payload.tools)
|
| 174 |
+
return ToolRegistrationResponse(
|
| 175 |
+
registered_count=len(payload.tools),
|
| 176 |
+
namespace=payload.namespace
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
@app.post("/v1/tools/retrieve", dependencies=[Depends(require_api_key)])
|
| 180 |
+
async def retrieve_tools(payload: RetrieveRequest) -> dict[str, Any]:
|
| 181 |
+
if payload.namespace and payload.namespace in registry_instance.catalogues:
|
| 182 |
+
catalogue = registry_instance.catalogues[payload.namespace]
|
| 183 |
+
else:
|
| 184 |
+
# Fallback to all tools if namespace not found or None
|
| 185 |
+
all_tools = []
|
| 186 |
+
for tools in registry_instance.namespaces.values():
|
| 187 |
+
all_tools.extend(tools)
|
| 188 |
+
if not all_tools:
|
| 189 |
+
return {"candidates": []}
|
| 190 |
+
catalogue = get_tool_catalogue(all_tools)
|
| 191 |
+
|
| 192 |
+
try:
|
| 193 |
+
query_emb = await get_needle_embeddings([payload.query])
|
| 194 |
+
q_emb = query_emb[0]
|
| 195 |
+
if catalogue.embeddings is None:
|
| 196 |
+
catalogue.embeddings = await get_needle_embeddings(catalogue.tool_texts)
|
| 197 |
+
emb_scores = [cosine_similarity(q_emb, t_emb) for t_emb in catalogue.embeddings]
|
| 198 |
+
except Exception:
|
| 199 |
+
emb_scores = [0.0] * len(catalogue.tools)
|
| 200 |
+
|
| 201 |
+
lex_scores = catalogue.bm25.get_scores(payload.query)
|
| 202 |
+
hybrid_scores = [e + l for e, l in zip(emb_scores, lex_scores)]
|
| 203 |
+
scored_tools = sorted(
|
| 204 |
+
zip(hybrid_scores, catalogue.tools),
|
| 205 |
+
key=lambda x: x[0],
|
| 206 |
+
reverse=True
|
| 207 |
+
)
|
| 208 |
|
| 209 |
+
top_candidates = scored_tools[:payload.max_candidates]
|
| 210 |
+
retrieved_tools = [t for s, t in top_candidates]
|
| 211 |
+
return {"candidates": retrieved_tools}
|
| 212 |
+
|
| 213 |
+
@app.post("/v1/gates/evaluate", response_model=EvaluateResponse, dependencies=[Depends(require_api_key)])
|
| 214 |
+
async def gates_evaluate(payload: EvaluateRequest) -> EvaluateResponse:
|
| 215 |
+
evaluations = registry_instance.evaluate_gates(payload.tools)
|
| 216 |
+
return EvaluateResponse(evaluations=evaluations)
|
| 217 |
+
|
| 218 |
+
@app.post("/v1/route", response_model=RouteResponse, dependencies=[Depends(require_api_key)])
|
| 219 |
+
async def route(payload: RouteRequest) -> RouteResponse:
|
| 220 |
+
try:
|
| 221 |
+
result = await run_needle_complete(payload.tools, payload.system, payload.input)
|
| 222 |
+
except Exception as exc:
|
| 223 |
+
raise HTTPException(status_code=500, detail=f"Needle inference failed: {exc}") from exc
|
| 224 |
+
|
| 225 |
+
if result.get("success") is False:
|
| 226 |
+
detail = result.get("error") or result.get("error_code") or "Needle reported a runtime error"
|
| 227 |
+
raise HTTPException(status_code=500, detail=str(detail))
|
| 228 |
+
|
| 229 |
+
function_calls = result.get("function_calls") or []
|
| 230 |
+
suppressed_calls = result.get("suppressed_calls") or []
|
| 231 |
+
confidence = result.get("confidence")
|
| 232 |
+
|
| 233 |
+
return RouteResponse(
|
| 234 |
+
outcome=decide_outcome(function_calls, suppressed_calls, confidence),
|
| 235 |
+
function_calls=function_calls,
|
| 236 |
+
suppressed_calls=suppressed_calls,
|
| 237 |
+
confidence=confidence,
|
| 238 |
+
reasoning=result.get("reasoning"),
|
| 239 |
+
validation=result.get("validation"),
|
| 240 |
+
model=MODEL_NAME,
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
@app.post("/v1/gates/route", response_model=AutoRouteResponse, dependencies=[Depends(require_api_key)])
|
| 244 |
+
async def gates_route(payload: AutoRouteRequest) -> AutoRouteResponse:
|
| 245 |
+
start_time = time.time()
|
| 246 |
catalogue = get_tool_catalogue(payload.tools)
|
| 247 |
+
|
| 248 |
try:
|
| 249 |
query_emb = await get_needle_embeddings([payload.query])
|
| 250 |
q_emb = query_emb[0]
|
|
|
|
| 251 |
if catalogue.embeddings is None:
|
| 252 |
catalogue.embeddings = await get_needle_embeddings(catalogue.tool_texts)
|
|
|
|
| 253 |
emb_scores = [cosine_similarity(q_emb, t_emb) for t_emb in catalogue.embeddings]
|
| 254 |
except Exception:
|
|
|
|
| 255 |
emb_scores = [0.0] * len(payload.tools)
|
| 256 |
+
|
| 257 |
lex_scores = catalogue.bm25.get_scores(payload.query)
|
| 258 |
+
hybrid_scores = [e + l for e, l in zip(emb_scores, lex_scores)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
scored_tools = sorted(
|
| 260 |
zip(hybrid_scores, catalogue.tools),
|
| 261 |
key=lambda x: x[0],
|
| 262 |
reverse=True
|
| 263 |
)
|
| 264 |
+
|
| 265 |
top_candidates = scored_tools[:payload.max_candidates]
|
| 266 |
retrieved_tools = [t for s, t in top_candidates]
|
| 267 |
retrieved_scores = [s for s, t in top_candidates]
|
|
|
|
| 268 |
retrieval_time = time.time() - start_time
|
| 269 |
route_start = time.time()
|
| 270 |
+
|
| 271 |
outcome = "abstain"
|
| 272 |
function_calls = []
|
| 273 |
suppressed_calls = []
|
| 274 |
confidence = None
|
| 275 |
reasoning = None
|
| 276 |
validation = None
|
| 277 |
+
diagnostics = {"retrieval_ms": round(retrieval_time * 1000, 2)}
|
| 278 |
+
|
|
|
|
|
|
|
| 279 |
try:
|
| 280 |
result = await run_needle_complete(retrieved_tools, payload.system, payload.query)
|
| 281 |
if result.get("success") is False:
|
|
|
|
| 292 |
outcome = "error"
|
| 293 |
reasoning = str(exc)
|
| 294 |
|
| 295 |
+
# Optional Laya evaluation
|
| 296 |
+
laya_result = await evaluate_with_laya("route", retrieved_tools, retrieved_scores, function_calls, confidence)
|
| 297 |
+
if laya_result.get("decision") == "escalate":
|
| 298 |
+
outcome = "error"
|
| 299 |
+
reasoning = laya_result.get("reasoning", "Escalated by Laya")
|
| 300 |
+
|
| 301 |
diagnostics["route_ms"] = round((time.time() - route_start) * 1000, 2)
|
| 302 |
diagnostics["total_ms"] = round((time.time() - start_time) * 1000, 2)
|
| 303 |
+
|
| 304 |
return AutoRouteResponse(
|
| 305 |
outcome=outcome,
|
| 306 |
candidates=retrieved_tools,
|
|
|
|
| 314 |
diagnostics=diagnostics
|
| 315 |
)
|
| 316 |
|
| 317 |
+
@app.post("/v1/chat/completions", dependencies=[Depends(require_api_key)])
|
| 318 |
+
async def chat_completions(payload: ChatCompletionRequest) -> dict[str, Any]:
|
| 319 |
+
# Extract query from the last user message
|
| 320 |
+
query = ""
|
| 321 |
+
for msg in reversed(payload.messages):
|
| 322 |
+
if msg.role == "user":
|
| 323 |
+
query = msg.content
|
| 324 |
+
break
|
| 325 |
+
|
| 326 |
+
if not query:
|
| 327 |
+
raise HTTPException(status_code=400, detail="No user message found to route tools")
|
| 328 |
+
|
| 329 |
+
tools = payload.tools or []
|
| 330 |
+
if tools:
|
| 331 |
+
# Use simple run_needle_complete if tools are small, or gate route if there are many.
|
| 332 |
+
# OpenCode expects an OpenAI-like response with tool_calls
|
| 333 |
+
if len(tools) > MAX_TOOLS:
|
| 334 |
+
# We need to retrieve best subset
|
| 335 |
+
req = AutoRouteRequest(query=query, tools=tools, max_candidates=MAX_TOOLS)
|
| 336 |
+
res = await gates_route(req)
|
| 337 |
+
fc = res.function_calls
|
| 338 |
+
outcome = res.outcome
|
| 339 |
+
else:
|
| 340 |
+
req2 = RouteRequest(input=query, tools=tools)
|
| 341 |
+
res2 = await route(req2)
|
| 342 |
+
fc = res2.function_calls
|
| 343 |
+
outcome = res2.outcome
|
| 344 |
+
|
| 345 |
+
tool_calls = []
|
| 346 |
+
if fc:
|
| 347 |
+
for i, call in enumerate(fc):
|
| 348 |
+
tool_calls.append({
|
| 349 |
+
"id": f"call_{i}",
|
| 350 |
+
"type": "function",
|
| 351 |
+
"function": {
|
| 352 |
+
"name": call.get("name"),
|
| 353 |
+
"arguments": json.dumps(call.get("arguments", {}))
|
| 354 |
+
}
|
| 355 |
+
})
|
| 356 |
+
|
| 357 |
+
return {
|
| 358 |
+
"id": f"chatcmpl-{hashlib.sha1(str(time.time()).encode()).hexdigest()[:10]}",
|
| 359 |
+
"object": "chat.completion",
|
| 360 |
+
"created": int(time.time()),
|
| 361 |
+
"model": payload.model,
|
| 362 |
+
"choices": [
|
| 363 |
+
{
|
| 364 |
+
"index": 0,
|
| 365 |
+
"message": {
|
| 366 |
+
"role": "assistant",
|
| 367 |
+
"content": None,
|
| 368 |
+
"tool_calls": tool_calls
|
| 369 |
+
},
|
| 370 |
+
"finish_reason": "tool_calls" if tool_calls else "stop"
|
| 371 |
+
}
|
| 372 |
+
],
|
| 373 |
+
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
| 374 |
+
}
|
| 375 |
+
else:
|
| 376 |
+
# If no tools, needle3 isn't a general chat model
|
| 377 |
+
raise HTTPException(status_code=400, detail="Needle3 is a tool router and requires 'tools' to be provided.")
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
HTML_CHAT_UI = """<!DOCTYPE html>
|
| 381 |
+
<html lang="en">
|
| 382 |
+
<head>
|
| 383 |
+
<meta charset="UTF-8">
|
| 384 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 385 |
+
<title>Needle 3 Gateway Dashboard</title>
|
| 386 |
+
<style>
|
| 387 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 388 |
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #0f172a; color: #e2e8f0; height: 100vh; display: flex; flex-direction: column; }
|
| 389 |
+
header { background: #1e293b; border-bottom: 1px solid #334155; padding: 12px 20px; display: flex; align-items: center; justify-content: space-between; }
|
| 390 |
+
header h1 { font-size: 1.25rem; color: #38bdf8; font-weight: 600; display: flex; align-items: center; gap: 10px; }
|
| 391 |
+
.controls { display: flex; align-items: center; gap: 12px; }
|
| 392 |
+
main { flex: 1; display: flex; max-width: 1200px; width: 100%; margin: 0 auto; overflow: hidden; }
|
| 393 |
+
.sidebar { width: 250px; background: #1e293b; border-right: 1px solid #334155; padding: 20px 0; display: flex; flex-direction: column; gap: 10px; }
|
| 394 |
+
.sidebar-item { padding: 10px 20px; cursor: pointer; color: #94a3b8; font-weight: 500; transition: all 0.2s; }
|
| 395 |
+
.sidebar-item:hover, .sidebar-item.active { background: #334155; color: #f8fafc; border-left: 3px solid #38bdf8; }
|
| 396 |
+
.content { flex: 1; padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; }
|
| 397 |
+
.card { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 20px; }
|
| 398 |
+
.card h2 { font-size: 1.1rem; margin-bottom: 15px; color: #f8fafc; border-bottom: 1px solid #334155; padding-bottom: 10px; }
|
| 399 |
+
.metric-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }
|
| 400 |
+
.metric { background: #0f172a; padding: 15px; border-radius: 6px; border: 1px solid #334155; text-align: center; }
|
| 401 |
+
.metric-value { font-size: 1.5rem; font-weight: 700; color: #38bdf8; margin-bottom: 5px; }
|
| 402 |
+
.metric-label { font-size: 0.8rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; }
|
| 403 |
+
.form-group { margin-bottom: 15px; }
|
| 404 |
+
label { display: block; margin-bottom: 5px; font-size: 0.9rem; color: #cbd5e1; }
|
| 405 |
+
input[type="text"], textarea, select { width: 100%; background: #0f172a; border: 1px solid #475569; color: #e2e8f0; border-radius: 6px; padding: 10px; font-size: 0.9rem; }
|
| 406 |
+
textarea { resize: vertical; min-height: 100px; font-family: monospace; }
|
| 407 |
+
button { background: #38bdf8; color: #0f172a; border: none; border-radius: 6px; padding: 10px 16px; font-weight: 600; cursor: pointer; transition: background 0.2s; }
|
| 408 |
+
button:hover { background: #0284c7; color: #ffffff; }
|
| 409 |
+
pre { background: #0f172a; padding: 15px; border-radius: 6px; border: 1px solid #334155; overflow-x: auto; font-size: 0.85rem; }
|
| 410 |
+
.status-badge { font-size: 0.75rem; padding: 2px 8px; border-radius: 12px; background: #065f46; color: #34d399; font-weight: 500; }
|
| 411 |
+
.hidden { display: none !important; }
|
| 412 |
+
</style>
|
| 413 |
+
</head>
|
| 414 |
+
<body>
|
| 415 |
+
<header>
|
| 416 |
+
<h1>🪡 Needle 3 Gateway <span class="status-badge" id="status-indicator">Checking...</span></h1>
|
| 417 |
+
</header>
|
| 418 |
+
<main>
|
| 419 |
+
<div class="sidebar">
|
| 420 |
+
<div class="sidebar-item active" onclick="switchTab('overview')">Overview</div>
|
| 421 |
+
<div class="sidebar-item" onclick="switchTab('playground')">Playground</div>
|
| 422 |
+
<div class="sidebar-item" onclick="switchTab('registry')">Tool Registry</div>
|
| 423 |
+
</div>
|
| 424 |
+
<div class="content" id="overview-tab">
|
| 425 |
+
<div class="card">
|
| 426 |
+
<h2>System Status</h2>
|
| 427 |
+
<div class="metric-grid">
|
| 428 |
+
<div class="metric"><div class="metric-value" id="m-model">needle3</div><div class="metric-label">Model</div></div>
|
| 429 |
+
<div class="metric"><div class="metric-value" id="m-ready">False</div><div class="metric-label">Ready</div></div>
|
| 430 |
+
<div class="metric"><div class="metric-value" id="m-tools">0</div><div class="metric-label">Total Tools</div></div>
|
| 431 |
+
<div class="metric"><div class="metric-value" id="m-namespaces">0</div><div class="metric-label">Namespaces</div></div>
|
| 432 |
+
</div>
|
| 433 |
+
</div>
|
| 434 |
+
</div>
|
| 435 |
+
<div class="content hidden" id="playground-tab">
|
| 436 |
+
<div class="card">
|
| 437 |
+
<h2>Routing Playground</h2>
|
| 438 |
+
<div class="form-group">
|
| 439 |
+
<label>Query</label>
|
| 440 |
+
<input type="text" id="p-query" placeholder="e.g. Find all files ending in .txt in the src directory">
|
| 441 |
+
</div>
|
| 442 |
+
<div class="form-group">
|
| 443 |
+
<label>Candidate Tools JSON Array (or leave empty to use Registry)</label>
|
| 444 |
+
<textarea id="p-tools" placeholder='[{"name": "find_files", "description": "Finds files", "parameters": {}}]'></textarea>
|
| 445 |
+
</div>
|
| 446 |
+
<button onclick="runPlayground()">Route Request</button>
|
| 447 |
+
</div>
|
| 448 |
+
<div class="card">
|
| 449 |
+
<h2>Result</h2>
|
| 450 |
+
<pre id="p-result">Submit a request to see the outcome...</pre>
|
| 451 |
+
</div>
|
| 452 |
+
</div>
|
| 453 |
+
<div class="content hidden" id="registry-tab">
|
| 454 |
+
<div class="card">
|
| 455 |
+
<h2>Tool Registry</h2>
|
| 456 |
+
<pre id="r-data">Loading registry data...</pre>
|
| 457 |
+
</div>
|
| 458 |
+
</div>
|
| 459 |
+
</main>
|
| 460 |
+
<script>
|
| 461 |
+
function switchTab(tabId) {
|
| 462 |
+
document.querySelectorAll('.sidebar-item').forEach(el => el.classList.remove('active'));
|
| 463 |
+
document.querySelectorAll('.content').forEach(el => el.classList.add('hidden'));
|
| 464 |
+
event.target.classList.add('active');
|
| 465 |
+
document.getElementById(tabId + '-tab').classList.remove('hidden');
|
| 466 |
+
if (tabId === 'registry') loadRegistry();
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
async function loadStatus() {
|
| 470 |
+
try {
|
| 471 |
+
const res = await fetch('/health');
|
| 472 |
+
const data = await res.json();
|
| 473 |
+
document.getElementById('status-indicator').textContent = data.model_ready ? 'Ready' : 'Degraded';
|
| 474 |
+
document.getElementById('status-indicator').style.background = data.model_ready ? '#065f46' : '#7f1d1d';
|
| 475 |
+
document.getElementById('status-indicator').style.color = data.model_ready ? '#34d399' : '#fca5a5';
|
| 476 |
+
document.getElementById('m-ready').textContent = data.model_ready;
|
| 477 |
+
|
| 478 |
+
const mRes = await fetch('/v1/metrics');
|
| 479 |
+
if (mRes.ok) {
|
| 480 |
+
const mData = await mRes.json();
|
| 481 |
+
document.getElementById('m-tools').textContent = mData.total_tools;
|
| 482 |
+
document.getElementById('m-namespaces').textContent = mData.registered_namespaces;
|
| 483 |
+
}
|
| 484 |
+
} catch (e) {
|
| 485 |
+
document.getElementById('status-indicator').textContent = 'Error';
|
| 486 |
+
}
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
async function loadRegistry() {
|
| 490 |
+
try {
|
| 491 |
+
const res = await fetch('/v1/tools');
|
| 492 |
+
const data = await res.json();
|
| 493 |
+
document.getElementById('r-data').textContent = JSON.stringify(data, null, 2);
|
| 494 |
+
} catch (e) {
|
| 495 |
+
document.getElementById('r-data').textContent = 'Failed to load registry';
|
| 496 |
+
}
|
| 497 |
+
}
|
| 498 |
|
| 499 |
+
async function runPlayground() {
|
| 500 |
+
const query = document.getElementById('p-query').value;
|
| 501 |
+
const toolsStr = document.getElementById('p-tools').value;
|
| 502 |
+
let tools = [];
|
| 503 |
+
|
| 504 |
+
try {
|
| 505 |
+
if (toolsStr.trim()) {
|
| 506 |
+
tools = JSON.parse(toolsStr);
|
| 507 |
+
}
|
| 508 |
+
} catch (e) {
|
| 509 |
+
alert('Invalid JSON in tools field');
|
| 510 |
+
return;
|
| 511 |
+
}
|
| 512 |
|
| 513 |
+
document.getElementById('p-result').textContent = 'Routing...';
|
| 514 |
+
|
| 515 |
+
try {
|
| 516 |
+
let url, payload;
|
| 517 |
+
if (tools.length > 0) {
|
| 518 |
+
if (tools.length > 5) {
|
| 519 |
+
url = '/v1/gates/route';
|
| 520 |
+
payload = { query: query, tools: tools, max_candidates: 5 };
|
| 521 |
+
} else {
|
| 522 |
+
url = '/v1/route';
|
| 523 |
+
payload = { input: query, tools: tools };
|
| 524 |
+
}
|
| 525 |
+
} else {
|
| 526 |
+
url = '/v1/tools/retrieve';
|
| 527 |
+
payload = { query: query, max_candidates: 5 };
|
| 528 |
+
// For playground without explicit tools, we just test retrieval
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
const res = await fetch(url, {
|
| 532 |
+
method: 'POST',
|
| 533 |
+
headers: { 'Content-Type': 'application/json' },
|
| 534 |
+
body: JSON.stringify(payload)
|
| 535 |
+
});
|
| 536 |
+
|
| 537 |
+
const data = await res.json();
|
| 538 |
+
document.getElementById('p-result').textContent = JSON.stringify(data, null, 2);
|
| 539 |
+
} catch (e) {
|
| 540 |
+
document.getElementById('p-result').textContent = 'Error: ' + e.message;
|
| 541 |
+
}
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
loadStatus();
|
| 545 |
+
setInterval(loadStatus, 10000);
|
| 546 |
+
</script>
|
| 547 |
+
</body>
|
| 548 |
+
</html>"""
|
| 549 |
+
|
| 550 |
+
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
| 551 |
+
@app.get("/ui", response_class=HTMLResponse, include_in_schema=False)
|
| 552 |
+
@app.get("/ui/", response_class=HTMLResponse, include_in_schema=False)
|
| 553 |
+
async def serve_landing_page() -> HTMLResponse:
|
| 554 |
+
return HTMLResponse(content=HTML_CHAT_UI, status_code=200)
|
| 555 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
laya.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import httpx
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
LAYA_ENABLED = os.getenv("LAYA_ENABLED", "false").lower() == "true"
|
| 6 |
+
LAYA_URL = os.getenv("LAYA_URL", "http://localhost:8081/evaluate")
|
| 7 |
+
LAYA_TIMEOUT = float(os.getenv("LAYA_TIMEOUT", "2.0"))
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
async def evaluate_with_laya(workflow_state: str, candidates: list, scores: list, function_calls: list, confidence: float | None) -> dict:
|
| 12 |
+
if not LAYA_ENABLED:
|
| 13 |
+
return {"decision": "proceed", "reasoning": "Laya disabled via environment"}
|
| 14 |
+
|
| 15 |
+
# Evaluate states based on Laya's capability criteria
|
| 16 |
+
try:
|
| 17 |
+
async with httpx.AsyncClient(timeout=LAYA_TIMEOUT) as client:
|
| 18 |
+
payload = {
|
| 19 |
+
"workflow_state": workflow_state,
|
| 20 |
+
"candidates": candidates,
|
| 21 |
+
"scores": scores,
|
| 22 |
+
"function_calls": function_calls,
|
| 23 |
+
"confidence": confidence
|
| 24 |
+
}
|
| 25 |
+
# Only sending network requests to Laya if there is a real server, otherwise deterministic local fallback
|
| 26 |
+
response = await client.post(LAYA_URL, json=payload)
|
| 27 |
+
if response.status_code == 200:
|
| 28 |
+
return response.json()
|
| 29 |
+
else:
|
| 30 |
+
logger.warning(f"Laya evaluation failed with status {response.status_code}. Proceeding with fallback.")
|
| 31 |
+
return {"decision": "proceed", "reasoning": "Laya fallback (non-200)"}
|
| 32 |
+
|
| 33 |
+
except httpx.RequestError as e:
|
| 34 |
+
logger.warning(f"Laya request failed: {e}. Proceeding with fallback.")
|
| 35 |
+
return {"decision": "proceed", "reasoning": f"Laya fallback (timeout/network error: {e})"}
|
| 36 |
+
except Exception as e:
|
| 37 |
+
logger.warning(f"Laya unexpected error: {e}. Proceeding with fallback.")
|
| 38 |
+
return {"decision": "proceed", "reasoning": "Laya fallback (internal error)"}
|
| 39 |
+
|
live_smoke_test.log
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Running live smoke tests on deployed endpoint...
|
| 2 |
+
|
| 3 |
+
1 & 2. Testing 31 tools and checking max 5 limit and stable names
|
| 4 |
+
Status: 200
|
| 5 |
+
Candidates: ['highlight_tool', 'generic_tool_10', 'generic_tool_3', 'generic_tool_29', 'generic_tool_2']
|
| 6 |
+
Function calls: [{'name': 'generic_tool_29', 'arguments': {'object': 'red connector'}}]
|
| 7 |
+
|
| 8 |
+
3. Testing indirect intent: 'Investigate why the deployment failed and fix it.'
|
| 9 |
+
Candidates for indirect intent: ['restart_service', 'fetch_logs', 'read_file']
|
| 10 |
+
|
| 11 |
+
5. Verifying /v1/route compatibility
|
| 12 |
+
Status: 200
|
| 13 |
+
|
| 14 |
+
Smoke tests completed.
|
live_smoke_test.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import json
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
URL = "https://leon4gr45-needle.hf.space"
|
| 6 |
+
HEADERS = {"Authorization": "Bearer my-secret-key"}
|
| 7 |
+
|
| 8 |
+
def run_tests():
|
| 9 |
+
print("Running live smoke tests on deployed endpoint...")
|
| 10 |
+
|
| 11 |
+
# Set a higher timeout because caching Needle agent on first request on Space takes longer
|
| 12 |
+
timeout = httpx.Timeout(60.0)
|
| 13 |
+
client = httpx.Client(timeout=timeout, headers=HEADERS)
|
| 14 |
+
|
| 15 |
+
# 1 & 2. Test 31 heterogeneous tools
|
| 16 |
+
print("\n1 & 2. Testing 31 tools and checking max 5 limit and stable names")
|
| 17 |
+
tools = [
|
| 18 |
+
{"name": f"generic_tool_{i}", "description": f"Does generic thing {i}", "parameters": {"type": "object"}}
|
| 19 |
+
for i in range(30)
|
| 20 |
+
]
|
| 21 |
+
tools.append({
|
| 22 |
+
"name": "highlight_tool",
|
| 23 |
+
"description": "Highlight or frame an object on screen",
|
| 24 |
+
"parameters": {"type": "object", "properties": {"object": {"type": "string"}}}
|
| 25 |
+
})
|
| 26 |
+
|
| 27 |
+
payload = {
|
| 28 |
+
"query": "Highlight the red connector",
|
| 29 |
+
"tools": tools,
|
| 30 |
+
"max_candidates": 5
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
res = client.post(f"{URL}/v1/route/auto", json=payload)
|
| 34 |
+
data = res.json()
|
| 35 |
+
print("Status:", res.status_code)
|
| 36 |
+
assert res.status_code == 200
|
| 37 |
+
assert len(data["candidates"]) <= 5
|
| 38 |
+
assert "highlight_tool" in [c["name"] for c in data["candidates"]]
|
| 39 |
+
print("Candidates:", [c["name"] for c in data["candidates"]])
|
| 40 |
+
print("Function calls:", data["function_calls"])
|
| 41 |
+
|
| 42 |
+
# 3. Test indirect intent
|
| 43 |
+
print("\n3. Testing indirect intent: 'Investigate why the deployment failed and fix it.'")
|
| 44 |
+
tools = [
|
| 45 |
+
{"name": "fetch_logs", "description": "Fetch application logs from the server", "parameters": {"type": "object"}},
|
| 46 |
+
{"name": "read_file", "description": "Read file contents", "parameters": {"type": "object"}},
|
| 47 |
+
{"name": "restart_service", "description": "Restart the service", "parameters": {"type": "object"}},
|
| 48 |
+
{"name": "order_pizza", "description": "Order a pizza", "parameters": {"type": "object"}},
|
| 49 |
+
{"name": "play_music", "description": "Play a song", "parameters": {"type": "object"}}
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
payload = {
|
| 53 |
+
"query": "Investigate why the deployment failed and fix it.",
|
| 54 |
+
"tools": tools,
|
| 55 |
+
"max_candidates": 3
|
| 56 |
+
}
|
| 57 |
+
res = client.post(f"{URL}/v1/route/auto", json=payload)
|
| 58 |
+
data = res.json()
|
| 59 |
+
print("Candidates for indirect intent:", [c["name"] for c in data["candidates"]])
|
| 60 |
+
|
| 61 |
+
# 5. Verify /v1/route remains compatible
|
| 62 |
+
print("\n5. Verifying /v1/route compatibility")
|
| 63 |
+
res = client.post(f"{URL}/v1/route", json={
|
| 64 |
+
"input": "Highlight the red connector",
|
| 65 |
+
"tools": tools[:2]
|
| 66 |
+
})
|
| 67 |
+
print("Status:", res.status_code)
|
| 68 |
+
assert res.status_code == 200
|
| 69 |
+
|
| 70 |
+
print("\nSmoke tests completed.")
|
| 71 |
+
|
| 72 |
+
if __name__ == '__main__':
|
| 73 |
+
run_tests()
|
models.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Literal
|
| 2 |
+
from pydantic import BaseModel, Field, field_validator
|
| 3 |
+
from registry import normalize_tool
|
| 4 |
+
|
| 5 |
+
class RouteRequest(BaseModel):
|
| 6 |
+
input: str = Field(..., min_length=1)
|
| 7 |
+
tools: list[dict[str, Any]] = Field(..., min_length=1, max_length=5)
|
| 8 |
+
system: str | None = None
|
| 9 |
+
|
| 10 |
+
@field_validator("tools")
|
| 11 |
+
@classmethod
|
| 12 |
+
def _normalize_tools(cls, value: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 13 |
+
return [normalize_tool(item) for item in value]
|
| 14 |
+
|
| 15 |
+
class RouteResponse(BaseModel):
|
| 16 |
+
outcome: Literal["selected", "confirm", "abstain", "error"]
|
| 17 |
+
function_calls: list[dict[str, Any]] = Field(default_factory=list)
|
| 18 |
+
suppressed_calls: list[dict[str, Any]] = Field(default_factory=list)
|
| 19 |
+
confidence: float | None = None
|
| 20 |
+
reasoning: str | None = None
|
| 21 |
+
validation: dict[str, Any] | None = None
|
| 22 |
+
model: str
|
| 23 |
+
|
| 24 |
+
class AutoRouteRequest(BaseModel):
|
| 25 |
+
query: str = Field(..., min_length=1)
|
| 26 |
+
tools: list[dict[str, Any]] = Field(..., min_length=1)
|
| 27 |
+
system: str | None = None
|
| 28 |
+
max_candidates: int = Field(5, ge=1, le=5)
|
| 29 |
+
|
| 30 |
+
@field_validator("tools")
|
| 31 |
+
@classmethod
|
| 32 |
+
def _normalize_tools(cls, value: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 33 |
+
return [normalize_tool(item) for item in value]
|
| 34 |
+
|
| 35 |
+
class AutoRouteResponse(BaseModel):
|
| 36 |
+
outcome: Literal["selected", "confirm", "abstain", "error"]
|
| 37 |
+
candidates: list[dict[str, Any]]
|
| 38 |
+
retrieval_scores: list[float]
|
| 39 |
+
function_calls: list[dict[str, Any]]
|
| 40 |
+
suppressed_calls: list[dict[str, Any]]
|
| 41 |
+
confidence: float | None
|
| 42 |
+
reasoning: str | None
|
| 43 |
+
validation: dict[str, Any] | None
|
| 44 |
+
model: str
|
| 45 |
+
diagnostics: dict[str, Any] = Field(default_factory=dict)
|
| 46 |
+
|
| 47 |
+
class ToolRegistration(BaseModel):
|
| 48 |
+
namespace: str
|
| 49 |
+
tools: list[dict[str, Any]]
|
| 50 |
+
|
| 51 |
+
class ToolRegistrationResponse(BaseModel):
|
| 52 |
+
registered_count: int
|
| 53 |
+
namespace: str
|
| 54 |
+
|
| 55 |
+
class RetrieveRequest(BaseModel):
|
| 56 |
+
query: str
|
| 57 |
+
namespace: str | None = None
|
| 58 |
+
max_candidates: int = Field(5, ge=1, le=5)
|
| 59 |
+
workflow_context: dict[str, Any] | None = None
|
| 60 |
+
|
| 61 |
+
class EvaluateRequest(BaseModel):
|
| 62 |
+
tools: list[dict[str, Any]]
|
| 63 |
+
|
| 64 |
+
class EvaluateResponse(BaseModel):
|
| 65 |
+
evaluations: list[dict[str, Any]]
|
| 66 |
+
|
| 67 |
+
class Message(BaseModel):
|
| 68 |
+
role: str
|
| 69 |
+
content: str
|
| 70 |
+
tool_calls: list[dict[str, Any]] | None = None
|
| 71 |
+
|
| 72 |
+
class ChatCompletionRequest(BaseModel):
|
| 73 |
+
model: str
|
| 74 |
+
messages: list[Message]
|
| 75 |
+
tools: list[dict[str, Any]] | None = None
|
| 76 |
+
stream: bool | None = False
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class LayaEvaluationRequest(BaseModel):
|
| 80 |
+
workflow_state: str
|
| 81 |
+
candidates: list[dict[str, Any]]
|
| 82 |
+
scores: list[float]
|
| 83 |
+
function_calls: list[dict[str, Any]]
|
| 84 |
+
confidence: float | None
|
| 85 |
+
|
| 86 |
+
class LayaEvaluationResponse(BaseModel):
|
| 87 |
+
decision: Literal["proceed", "escalate", "abstain"]
|
| 88 |
+
reasoning: str | None = None
|
registry.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
import hashlib
|
| 3 |
+
import json
|
| 4 |
+
import math
|
| 5 |
+
import re
|
| 6 |
+
from collections import Counter, OrderedDict
|
| 7 |
+
|
| 8 |
+
def normalize_tool(raw: Any) -> dict[str, Any]:
|
| 9 |
+
"""Normalize one candidate tool into Needle's raw schema."""
|
| 10 |
+
if not isinstance(raw, dict):
|
| 11 |
+
raise ValueError("each tool must be a JSON object")
|
| 12 |
+
|
| 13 |
+
body = raw
|
| 14 |
+
if "type" in raw or "function" in raw:
|
| 15 |
+
if raw.get("type") != "function" or not isinstance(raw.get("function"), dict):
|
| 16 |
+
raise ValueError(
|
| 17 |
+
"OpenAI-style tools must have type == 'function' and an object 'function'"
|
| 18 |
+
)
|
| 19 |
+
extra = set(raw) - {"type", "function"}
|
| 20 |
+
if extra:
|
| 21 |
+
raise ValueError(f"unexpected fields on tool: {sorted(extra)}")
|
| 22 |
+
body = raw["function"]
|
| 23 |
+
|
| 24 |
+
name = body.get("name")
|
| 25 |
+
description = body.get("description")
|
| 26 |
+
parameters = body.get("parameters")
|
| 27 |
+
|
| 28 |
+
if not isinstance(name, str) or not name.strip():
|
| 29 |
+
raise ValueError("tool.name must be a non-empty string")
|
| 30 |
+
if not isinstance(description, str) or not description.strip():
|
| 31 |
+
raise ValueError("tool.description must be a non-empty string")
|
| 32 |
+
|
| 33 |
+
# Optional parameters fallback
|
| 34 |
+
if parameters is None:
|
| 35 |
+
parameters = {"type": "object", "properties": {}}
|
| 36 |
+
|
| 37 |
+
if not isinstance(parameters, dict):
|
| 38 |
+
raise ValueError("tool.parameters must be a JSON Schema object")
|
| 39 |
+
|
| 40 |
+
extra = set(body) - {"name", "description", "parameters", "triggers"}
|
| 41 |
+
if extra:
|
| 42 |
+
raise ValueError(f"unexpected fields on tool: {sorted(extra)}")
|
| 43 |
+
|
| 44 |
+
normalized: dict[str, Any] = {
|
| 45 |
+
"name": name,
|
| 46 |
+
"description": description,
|
| 47 |
+
"parameters": parameters,
|
| 48 |
+
}
|
| 49 |
+
if "triggers" in body:
|
| 50 |
+
triggers = body["triggers"]
|
| 51 |
+
if not isinstance(triggers, list) or not all(isinstance(t, str) for t in triggers):
|
| 52 |
+
raise ValueError("tool.triggers must be a list of strings")
|
| 53 |
+
normalized["triggers"] = triggers
|
| 54 |
+
return normalized
|
| 55 |
+
|
| 56 |
+
class BM25Retriever:
|
| 57 |
+
def __init__(self, corpus: list[str], k1: float = 1.5, b: float = 0.75):
|
| 58 |
+
self.k1 = k1
|
| 59 |
+
self.b = b
|
| 60 |
+
self.corpus_size = len(corpus)
|
| 61 |
+
self.avgdl = 0.0
|
| 62 |
+
self.doc_freqs = Counter()
|
| 63 |
+
self.doc_len = []
|
| 64 |
+
self.doc_tf = []
|
| 65 |
+
|
| 66 |
+
if self.corpus_size > 0:
|
| 67 |
+
total_len = 0
|
| 68 |
+
for doc in corpus:
|
| 69 |
+
tokens = self.tokenize(doc)
|
| 70 |
+
length = len(tokens)
|
| 71 |
+
self.doc_len.append(length)
|
| 72 |
+
total_len += length
|
| 73 |
+
tf = Counter(tokens)
|
| 74 |
+
self.doc_tf.append(tf)
|
| 75 |
+
for token in tf:
|
| 76 |
+
self.doc_freqs[token] += 1
|
| 77 |
+
self.avgdl = total_len / self.corpus_size
|
| 78 |
+
|
| 79 |
+
def tokenize(self, text: str) -> list[str]:
|
| 80 |
+
return re.findall(r'\w+', text.lower())
|
| 81 |
+
|
| 82 |
+
def get_scores(self, query: str) -> list[float]:
|
| 83 |
+
tokens = self.tokenize(query)
|
| 84 |
+
scores = [0.0] * self.corpus_size
|
| 85 |
+
for token in tokens:
|
| 86 |
+
if token not in self.doc_freqs:
|
| 87 |
+
continue
|
| 88 |
+
df = self.doc_freqs[token]
|
| 89 |
+
idf = math.log(1 + (self.corpus_size - df + 0.5) / (df + 0.5))
|
| 90 |
+
for i in range(self.corpus_size):
|
| 91 |
+
tf = self.doc_tf[i].get(token, 0)
|
| 92 |
+
if tf > 0:
|
| 93 |
+
score = idf * (tf * (self.k1 + 1)) / (tf + self.k1 * (1 - self.b + self.b * self.doc_len[i] / (self.avgdl or 1)))
|
| 94 |
+
scores[i] += score
|
| 95 |
+
return scores
|
| 96 |
+
|
| 97 |
+
def cosine_similarity(v1: list[float], v2: list[float]) -> float:
|
| 98 |
+
dot = sum(a * b for a, b in zip(v1, v2))
|
| 99 |
+
mag1 = math.sqrt(sum(a * a for a in v1))
|
| 100 |
+
mag2 = math.sqrt(sum(b * b for b in v2))
|
| 101 |
+
if mag1 == 0 or mag2 == 0:
|
| 102 |
+
return 0.0
|
| 103 |
+
return dot / (mag1 * mag2)
|
| 104 |
+
|
| 105 |
+
class ToolCatalogue:
|
| 106 |
+
def __init__(self, tools: list[dict[str, Any]]):
|
| 107 |
+
self.tools = tools
|
| 108 |
+
self.fingerprints = [self._fingerprint(t) for t in tools]
|
| 109 |
+
|
| 110 |
+
# Prepare text representation for each tool for lexical retrieval
|
| 111 |
+
self.tool_texts = []
|
| 112 |
+
for t in tools:
|
| 113 |
+
name = t.get("name", "")
|
| 114 |
+
desc = t.get("description", "")
|
| 115 |
+
params = json.dumps(t.get("parameters", {}))
|
| 116 |
+
self.tool_texts.append(f"{name} {desc} {params}")
|
| 117 |
+
|
| 118 |
+
self.bm25 = BM25Retriever(self.tool_texts)
|
| 119 |
+
self.embeddings: list[list[float]] | None = None
|
| 120 |
+
|
| 121 |
+
@staticmethod
|
| 122 |
+
def _fingerprint(tool: dict[str, Any]) -> str:
|
| 123 |
+
return hashlib.sha256(json.dumps(tool, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
| 124 |
+
|
| 125 |
+
class ToolRegistry:
|
| 126 |
+
def __init__(self):
|
| 127 |
+
self.namespaces: dict[str, list[dict[str, Any]]] = {}
|
| 128 |
+
self.catalogues: dict[str, ToolCatalogue] = {}
|
| 129 |
+
self.gate_mappings: dict[str, str] = {
|
| 130 |
+
"filesystem": "files|read|write|directory",
|
| 131 |
+
"git/version control": "git|commit|push|pull",
|
| 132 |
+
"terminal/execution": "bash|shell|exec",
|
| 133 |
+
"browser/navigation": "browser|navigate|web|http",
|
| 134 |
+
"research/search": "search|google|find",
|
| 135 |
+
"memory/knowledge": "memory|remember|store",
|
| 136 |
+
"infrastructure/deployment": "docker|kubernetes|deploy",
|
| 137 |
+
"communication": "email|slack|message",
|
| 138 |
+
"scheduling": "cron|schedule|time",
|
| 139 |
+
"database": "sql|mongo|query|database",
|
| 140 |
+
"development/testing": "test|pytest|debug",
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
def register(self, namespace: str, tools: list[dict[str, Any]]):
|
| 144 |
+
normalized = [normalize_tool(t) for t in tools]
|
| 145 |
+
if namespace not in self.namespaces:
|
| 146 |
+
self.namespaces[namespace] = []
|
| 147 |
+
self.namespaces[namespace] = normalized
|
| 148 |
+
# Update catalogue cache
|
| 149 |
+
self.catalogues[namespace] = ToolCatalogue(normalized)
|
| 150 |
+
|
| 151 |
+
def get_catalogue(self, namespace: str) -> ToolCatalogue | None:
|
| 152 |
+
return self.catalogues.get(namespace)
|
| 153 |
+
|
| 154 |
+
def evaluate_gates(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 155 |
+
evaluations = []
|
| 156 |
+
for tool in tools:
|
| 157 |
+
name = tool.get("name", "").lower()
|
| 158 |
+
desc = tool.get("description", "").lower()
|
| 159 |
+
matched_family = "general/uncategorized"
|
| 160 |
+
|
| 161 |
+
for family, pattern in self.gate_mappings.items():
|
| 162 |
+
if re.search(pattern, name) or re.search(pattern, desc):
|
| 163 |
+
matched_family = family
|
| 164 |
+
break
|
| 165 |
+
|
| 166 |
+
evaluations.append({
|
| 167 |
+
"tool": tool.get("name"),
|
| 168 |
+
"family": matched_family
|
| 169 |
+
})
|
| 170 |
+
return evaluations
|
| 171 |
+
|
| 172 |
+
registry_instance = ToolRegistry()
|
| 173 |
+
|
| 174 |
+
# Added get_tool_catalogue back in registry for dynamic requests
|
| 175 |
+
_catalogue_cache: "OrderedDict[str, ToolCatalogue]" = OrderedDict()
|
| 176 |
+
|
| 177 |
+
def _get_catalogue_key(tools: list[dict[str, Any]]) -> str:
|
| 178 |
+
fps = sorted(ToolCatalogue._fingerprint(t) for t in tools)
|
| 179 |
+
return hashlib.sha256(json.dumps(fps, separators=(",", ":")).encode("utf-8")).hexdigest()
|
| 180 |
+
|
| 181 |
+
def get_tool_catalogue(tools: list[dict[str, Any]]) -> ToolCatalogue:
|
| 182 |
+
key = _get_catalogue_key(tools)
|
| 183 |
+
if key in _catalogue_cache:
|
| 184 |
+
_catalogue_cache.move_to_end(key)
|
| 185 |
+
return _catalogue_cache[key]
|
| 186 |
+
|
| 187 |
+
cat = ToolCatalogue(tools)
|
| 188 |
+
_catalogue_cache[key] = cat
|
| 189 |
+
_catalogue_cache.move_to_end(key)
|
| 190 |
+
# bounded cache
|
| 191 |
+
while len(_catalogue_cache) > 16:
|
| 192 |
+
_catalogue_cache.popitem(last=False)
|
| 193 |
+
return cat
|
test_deployed.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
|
| 3 |
+
def test():
|
| 4 |
+
# 1. Test /health which is unauthenticated
|
| 5 |
+
print("Testing /health")
|
| 6 |
+
res = httpx.get('https://leon4gr45-needle.hf.space/health')
|
| 7 |
+
print("Health:", res.status_code, res.text)
|
| 8 |
+
|
| 9 |
+
if __name__ == '__main__':
|
| 10 |
+
test()
|
tests/test_compatibility.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from models import ChatCompletionRequest, Message
|
| 3 |
+
|
| 4 |
+
def test_chat_completion_request_parsing():
|
| 5 |
+
req = ChatCompletionRequest(
|
| 6 |
+
model="needle3",
|
| 7 |
+
messages=[
|
| 8 |
+
Message(role="user", content="hello")
|
| 9 |
+
],
|
| 10 |
+
tools=[{"name": "test", "description": "desc", "parameters": {"type": "object"}}]
|
| 11 |
+
)
|
| 12 |
+
assert req.model == "needle3"
|
| 13 |
+
assert len(req.messages) == 1
|
| 14 |
+
assert req.messages[0].content == "hello"
|
| 15 |
+
assert len(req.tools) == 1
|
tests/test_gates.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from registry import ToolRegistry
|
| 3 |
+
|
| 4 |
+
def test_gates_evaluation():
|
| 5 |
+
reg = ToolRegistry()
|
| 6 |
+
tools = [
|
| 7 |
+
{"name": "docker_deploy", "description": "Deploy to docker", "parameters": {"type": "object"}},
|
| 8 |
+
{"name": "unknown_tool", "description": "does random stuff", "parameters": {"type": "object"}}
|
| 9 |
+
]
|
| 10 |
+
evals = reg.evaluate_gates(tools)
|
| 11 |
+
assert len(evals) == 2
|
| 12 |
+
assert evals[0]["family"] == "infrastructure/deployment"
|
| 13 |
+
assert evals[1]["family"] == "general/uncategorized"
|
tests/test_gateway.py
CHANGED
|
@@ -367,7 +367,7 @@ def test_auto_route_basic(monkeypatch):
|
|
| 367 |
reasoning="clear match",
|
| 368 |
))
|
| 369 |
with client_with_agent(monkeypatch, agent) as client:
|
| 370 |
-
response = client.post("/v1/
|
| 371 |
body = response.json()
|
| 372 |
assert response.status_code == 200
|
| 373 |
assert body["outcome"] == "selected"
|
|
@@ -377,11 +377,11 @@ def test_auto_route_basic(monkeypatch):
|
|
| 377 |
def test_auto_route_varying_tool_counts(monkeypatch):
|
| 378 |
agent = FakeAgent(_default_result(function_calls=[], suppressed_calls=[], confidence=None))
|
| 379 |
counts = [5, 31, 100, 200, 500]
|
| 380 |
-
|
| 381 |
with client_with_agent(monkeypatch, agent) as client:
|
| 382 |
for count in counts:
|
| 383 |
tools = [{"name": f"tool_{i}", "description": f"desc {i}", "parameters": {"type": "object"}} for i in range(count)]
|
| 384 |
-
response = client.post("/v1/
|
| 385 |
assert response.status_code == 200
|
| 386 |
body = response.json()
|
| 387 |
assert len(body["candidates"]) <= 5 # default max_candidates is 5
|
|
@@ -393,8 +393,8 @@ def test_auto_route_tool_name_collisions(monkeypatch):
|
|
| 393 |
{"name": "duplicate_tool", "description": "desc B", "parameters": {"type": "object"}},
|
| 394 |
]
|
| 395 |
with client_with_agent(monkeypatch, agent) as client:
|
| 396 |
-
response = client.post("/v1/
|
| 397 |
-
|
| 398 |
body = response.json()
|
| 399 |
assert response.status_code == 200
|
| 400 |
assert len(body["candidates"]) == 2
|
|
@@ -405,8 +405,8 @@ def test_auto_route_deterministic_fallback(monkeypatch):
|
|
| 405 |
raise RuntimeError("native engine crashed")
|
| 406 |
|
| 407 |
with client_with_agent(monkeypatch, RaisingAgent(_default_result())) as client:
|
| 408 |
-
response = client.post("/v1/
|
| 409 |
-
|
| 410 |
body = response.json()
|
| 411 |
assert response.status_code == 200
|
| 412 |
assert body["outcome"] == "error"
|
|
@@ -418,113 +418,26 @@ def test_auto_route_retrieval_fallback(monkeypatch):
|
|
| 418 |
# The outcome shouldn't crash.
|
| 419 |
def raising_embeddings(*args, **kwargs):
|
| 420 |
raise RuntimeError("Embeddings failed")
|
| 421 |
-
|
| 422 |
monkeypatch.setattr(gateway, "get_needle_embeddings", raising_embeddings)
|
| 423 |
-
|
| 424 |
agent = FakeAgent(_default_result(function_calls=[], suppressed_calls=[], confidence=None))
|
| 425 |
with client_with_agent(monkeypatch, agent) as client:
|
| 426 |
-
response = client.post("/v1/
|
| 427 |
-
|
| 428 |
assert response.status_code == 200
|
| 429 |
body = response.json()
|
| 430 |
assert len(body["candidates"]) == 1
|
| 431 |
|
| 432 |
def test_catalogue_cache_eviction(monkeypatch):
|
| 433 |
import gateway
|
| 434 |
-
|
| 435 |
-
gateway.
|
| 436 |
-
|
| 437 |
agent = FakeAgent(_default_result(function_calls=[], suppressed_calls=[], confidence=None))
|
| 438 |
with client_with_agent(monkeypatch, agent) as client:
|
| 439 |
for i in range(5):
|
| 440 |
tools = [{"name": f"tool_{i}", "description": "desc", "parameters": {"type": "object"}}]
|
| 441 |
-
client.post("/v1/
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
# Additional tests for /v1/route/auto --------------------------------------------------
|
| 447 |
-
import asyncio
|
| 448 |
-
|
| 449 |
-
@pytest.mark.anyio
|
| 450 |
-
async def test_auto_route_multiple_simultaneous_clients(monkeypatch):
|
| 451 |
-
import time
|
| 452 |
-
|
| 453 |
-
async def fake_embed(text):
|
| 454 |
-
await asyncio.sleep(0.01)
|
| 455 |
-
return [0.1] * 3072
|
| 456 |
-
|
| 457 |
-
class FakeEmbeddingAgent:
|
| 458 |
-
def embed(self, text):
|
| 459 |
-
# Mocking the sync version since it is called in to_thread
|
| 460 |
-
time.sleep(0.01)
|
| 461 |
-
return [0.1] * 3072
|
| 462 |
-
|
| 463 |
-
monkeypatch.setattr(gateway, "_get_embedding_agent", lambda: FakeEmbeddingAgent())
|
| 464 |
-
|
| 465 |
-
agent = FakeAgent(_default_result(function_calls=[], suppressed_calls=[], confidence=None))
|
| 466 |
-
|
| 467 |
-
async def run_client():
|
| 468 |
-
with client_with_agent(monkeypatch, agent) as client:
|
| 469 |
-
return client.post("/v1/route/auto", json=auto_route_payload())
|
| 470 |
-
|
| 471 |
-
# Run 5 requests simultaneously
|
| 472 |
-
responses = await asyncio.gather(*(run_client() for _ in range(5)))
|
| 473 |
-
for response in responses:
|
| 474 |
-
assert response.status_code == 200
|
| 475 |
-
|
| 476 |
-
def test_auto_route_ambiguous_multi_tool(monkeypatch):
|
| 477 |
-
agent = FakeAgent(_default_result(
|
| 478 |
-
function_calls=[
|
| 479 |
-
{"name": "tool_a", "arguments": {}},
|
| 480 |
-
{"name": "tool_b", "arguments": {}}
|
| 481 |
-
],
|
| 482 |
-
confidence=0.85
|
| 483 |
-
))
|
| 484 |
-
|
| 485 |
-
tools = [
|
| 486 |
-
{"name": "tool_a", "description": "Does A", "parameters": {"type": "object"}},
|
| 487 |
-
{"name": "tool_b", "description": "Does B", "parameters": {"type": "object"}},
|
| 488 |
-
{"name": "tool_c", "description": "Does C", "parameters": {"type": "object"}}
|
| 489 |
-
]
|
| 490 |
-
|
| 491 |
-
with client_with_agent(monkeypatch, agent) as client:
|
| 492 |
-
response = client.post("/v1/route/auto", json=auto_route_payload(overrides={"tools": tools, "query": "Do A and B", "max_candidates": 3}))
|
| 493 |
-
|
| 494 |
-
body = response.json()
|
| 495 |
-
assert response.status_code == 200
|
| 496 |
-
assert len(body["function_calls"]) == 2
|
| 497 |
-
|
| 498 |
-
def test_auto_route_labelled_recall_5_examples(monkeypatch):
|
| 499 |
-
# Testing chaining / indirect requests
|
| 500 |
-
# Mock embeddings to give high score to "email_tool"
|
| 501 |
-
class MockEmbeddingAgent:
|
| 502 |
-
def embed(self, text):
|
| 503 |
-
if "email" in text.lower():
|
| 504 |
-
return [1.0] + [0.0]*3071
|
| 505 |
-
return [0.0]*3072
|
| 506 |
-
|
| 507 |
-
monkeypatch.setattr(gateway, "_get_embedding_agent", lambda: MockEmbeddingAgent())
|
| 508 |
-
|
| 509 |
-
tools = [
|
| 510 |
-
{"name": "irrelevant_1", "description": "irrelevant", "parameters": {"type": "object"}},
|
| 511 |
-
{"name": "irrelevant_2", "description": "irrelevant", "parameters": {"type": "object"}},
|
| 512 |
-
{"name": "irrelevant_3", "description": "irrelevant", "parameters": {"type": "object"}},
|
| 513 |
-
{"name": "irrelevant_4", "description": "irrelevant", "parameters": {"type": "object"}},
|
| 514 |
-
{"name": "irrelevant_5", "description": "irrelevant", "parameters": {"type": "object"}},
|
| 515 |
-
{"name": "email_tool", "description": "send an email", "parameters": {"type": "object"}}
|
| 516 |
-
]
|
| 517 |
-
|
| 518 |
-
agent = FakeAgent(_default_result(function_calls=[], suppressed_calls=[], confidence=None))
|
| 519 |
-
with client_with_agent(monkeypatch, agent) as client:
|
| 520 |
-
# Indirect request: "Tell my boss I'm sick" implies email or message tool
|
| 521 |
-
response = client.post("/v1/route/auto", json=auto_route_payload(overrides={
|
| 522 |
-
"tools": tools,
|
| 523 |
-
"query": "Send an email to my boss that I'm sick",
|
| 524 |
-
"max_candidates": 5
|
| 525 |
-
}))
|
| 526 |
-
|
| 527 |
-
body = response.json()
|
| 528 |
-
assert response.status_code == 200
|
| 529 |
-
candidate_names = [c["name"] for c in body["candidates"]]
|
| 530 |
-
assert "email_tool" in candidate_names
|
|
|
|
| 367 |
reasoning="clear match",
|
| 368 |
))
|
| 369 |
with client_with_agent(monkeypatch, agent) as client:
|
| 370 |
+
response = client.post("/v1/gates/route", json=auto_route_payload())
|
| 371 |
body = response.json()
|
| 372 |
assert response.status_code == 200
|
| 373 |
assert body["outcome"] == "selected"
|
|
|
|
| 377 |
def test_auto_route_varying_tool_counts(monkeypatch):
|
| 378 |
agent = FakeAgent(_default_result(function_calls=[], suppressed_calls=[], confidence=None))
|
| 379 |
counts = [5, 31, 100, 200, 500]
|
| 380 |
+
|
| 381 |
with client_with_agent(monkeypatch, agent) as client:
|
| 382 |
for count in counts:
|
| 383 |
tools = [{"name": f"tool_{i}", "description": f"desc {i}", "parameters": {"type": "object"}} for i in range(count)]
|
| 384 |
+
response = client.post("/v1/gates/route", json=auto_route_payload(overrides={"tools": tools}))
|
| 385 |
assert response.status_code == 200
|
| 386 |
body = response.json()
|
| 387 |
assert len(body["candidates"]) <= 5 # default max_candidates is 5
|
|
|
|
| 393 |
{"name": "duplicate_tool", "description": "desc B", "parameters": {"type": "object"}},
|
| 394 |
]
|
| 395 |
with client_with_agent(monkeypatch, agent) as client:
|
| 396 |
+
response = client.post("/v1/gates/route", json=auto_route_payload(overrides={"tools": tools, "max_candidates": 2}))
|
| 397 |
+
|
| 398 |
body = response.json()
|
| 399 |
assert response.status_code == 200
|
| 400 |
assert len(body["candidates"]) == 2
|
|
|
|
| 405 |
raise RuntimeError("native engine crashed")
|
| 406 |
|
| 407 |
with client_with_agent(monkeypatch, RaisingAgent(_default_result())) as client:
|
| 408 |
+
response = client.post("/v1/gates/route", json=auto_route_payload())
|
| 409 |
+
|
| 410 |
body = response.json()
|
| 411 |
assert response.status_code == 200
|
| 412 |
assert body["outcome"] == "error"
|
|
|
|
| 418 |
# The outcome shouldn't crash.
|
| 419 |
def raising_embeddings(*args, **kwargs):
|
| 420 |
raise RuntimeError("Embeddings failed")
|
| 421 |
+
|
| 422 |
monkeypatch.setattr(gateway, "get_needle_embeddings", raising_embeddings)
|
| 423 |
+
|
| 424 |
agent = FakeAgent(_default_result(function_calls=[], suppressed_calls=[], confidence=None))
|
| 425 |
with client_with_agent(monkeypatch, agent) as client:
|
| 426 |
+
response = client.post("/v1/gates/route", json=auto_route_payload())
|
| 427 |
+
|
| 428 |
assert response.status_code == 200
|
| 429 |
body = response.json()
|
| 430 |
assert len(body["candidates"]) == 1
|
| 431 |
|
| 432 |
def test_catalogue_cache_eviction(monkeypatch):
|
| 433 |
import gateway
|
| 434 |
+
# removed
|
| 435 |
+
gateway.registry_instance.catalogues.clear()
|
| 436 |
+
|
| 437 |
agent = FakeAgent(_default_result(function_calls=[], suppressed_calls=[], confidence=None))
|
| 438 |
with client_with_agent(monkeypatch, agent) as client:
|
| 439 |
for i in range(5):
|
| 440 |
tools = [{"name": f"tool_{i}", "description": "desc", "parameters": {"type": "object"}}]
|
| 441 |
+
client.post("/v1/gates/route", json=auto_route_payload(overrides={"tools": tools}))
|
| 442 |
+
|
| 443 |
+
# test is obsolete now as it uses new registry layout
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_registry.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from registry import ToolRegistry
|
| 3 |
+
|
| 4 |
+
def test_registry_registration():
|
| 5 |
+
reg = ToolRegistry()
|
| 6 |
+
tools = [
|
| 7 |
+
{"name": "file_tool", "description": "reads files", "parameters": {"type": "object"}}
|
| 8 |
+
]
|
| 9 |
+
reg.register("test_ns", tools)
|
| 10 |
+
assert "test_ns" in reg.namespaces
|
| 11 |
+
assert len(reg.namespaces["test_ns"]) == 1
|
| 12 |
+
assert reg.namespaces["test_ns"][0]["name"] == "file_tool"
|
| 13 |
+
assert "test_ns" in reg.catalogues
|
| 14 |
+
|
| 15 |
+
def test_registry_catalogue_fetching():
|
| 16 |
+
reg = ToolRegistry()
|
| 17 |
+
assert reg.get_catalogue("none") is None
|
tests/test_retrieval.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from registry import ToolCatalogue, get_tool_catalogue
|
| 3 |
+
|
| 4 |
+
def test_bm25_retrieval():
|
| 5 |
+
tools = [
|
| 6 |
+
{"name": "t1", "description": "reads a file", "parameters": {"type": "object"}},
|
| 7 |
+
{"name": "t2", "description": "deletes a file", "parameters": {"type": "object"}},
|
| 8 |
+
{"name": "t3", "description": "searches google", "parameters": {"type": "object"}}
|
| 9 |
+
]
|
| 10 |
+
cat = ToolCatalogue(tools)
|
| 11 |
+
scores = cat.bm25.get_scores("reads a file")
|
| 12 |
+
# t1 should score highest
|
| 13 |
+
assert scores[0] > scores[1]
|
| 14 |
+
assert scores[0] > scores[2]
|
| 15 |
+
|
| 16 |
+
def test_catalogue_deduplication():
|
| 17 |
+
tools1 = [
|
| 18 |
+
{"name": "t1", "description": "reads a file", "parameters": {"type": "object"}}
|
| 19 |
+
]
|
| 20 |
+
cat1 = get_tool_catalogue(tools1)
|
| 21 |
+
cat2 = get_tool_catalogue(tools1)
|
| 22 |
+
assert cat1 is cat2
|
tests/test_routing.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
from gateway import app
|
| 4 |
+
|
| 5 |
+
client = TestClient(app)
|
| 6 |
+
|
| 7 |
+
def test_routes_exist():
|
| 8 |
+
# Without API key, they should 401
|
| 9 |
+
assert client.get("/v1/tools").status_code == 200
|
| 10 |
+
assert client.post("/v1/tools/register", json={}).status_code == 422
|
| 11 |
+
assert client.post("/v1/tools/retrieve", json={}).status_code == 422
|
tests/test_security.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from registry import normalize_tool
|
| 3 |
+
|
| 4 |
+
def test_normalization_rejects_extra_fields():
|
| 5 |
+
raw = {"name": "test", "description": "desc", "parameters": {"type": "object"}, "extra_field": "bad"}
|
| 6 |
+
with pytest.raises(ValueError, match="unexpected fields"):
|
| 7 |
+
normalize_tool(raw)
|
| 8 |
+
|
| 9 |
+
def test_normalization_rejects_empty_name():
|
| 10 |
+
raw = {"name": "", "description": "desc", "parameters": {"type": "object"}}
|
| 11 |
+
with pytest.raises(ValueError, match="non-empty string"):
|
| 12 |
+
normalize_tool(raw)
|
tests/test_validation.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from registry import normalize_tool
|
| 3 |
+
|
| 4 |
+
def test_normalization_native():
|
| 5 |
+
raw = {"name": "test", "description": "desc", "parameters": {"type": "object"}}
|
| 6 |
+
normalized = normalize_tool(raw)
|
| 7 |
+
assert normalized["name"] == "test"
|
| 8 |
+
assert normalized["description"] == "desc"
|
| 9 |
+
assert "parameters" in normalized
|
| 10 |
+
|
| 11 |
+
def test_normalization_openai():
|
| 12 |
+
raw = {
|
| 13 |
+
"type": "function",
|
| 14 |
+
"function": {
|
| 15 |
+
"name": "test_fn",
|
| 16 |
+
"description": "fn desc",
|
| 17 |
+
"parameters": {"type": "object"}
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
normalized = normalize_tool(raw)
|
| 21 |
+
assert normalized["name"] == "test_fn"
|
| 22 |
+
assert normalized["description"] == "fn desc"
|
| 23 |
+
assert "parameters" in normalized
|
| 24 |
+
|
| 25 |
+
def test_normalization_optional_params():
|
| 26 |
+
# parameters are optional and fallback to object
|
| 27 |
+
raw = {"name": "test", "description": "desc"}
|
| 28 |
+
normalized = normalize_tool(raw)
|
| 29 |
+
assert normalized["parameters"]["type"] == "object"
|