Leon4gr45 commited on
Commit
f049d1e
·
verified ·
1 Parent(s): b1d7e69

Upload folder using huggingface_hub

Browse files
.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
- "test_auto_route.py::test",
3
- "test_deployed.py::test",
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 pydantic import BaseModel, Field, field_validator
 
 
 
 
 
 
 
 
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 = await asyncio.to_thread(_get_embedding_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/route/auto", response_model=AutoRouteResponse, dependencies=[Depends(require_api_key)])
413
- async def route_auto(payload: AutoRouteRequest) -> AutoRouteResponse:
414
- start_time = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "retrieval_ms": round(retrieval_time * 1000, 2),
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/route", response_model=RouteResponse, dependencies=[Depends(require_api_key)])
494
- async def route(payload: RouteRequest) -> RouteResponse:
495
- try:
496
- result = await run_needle_complete(payload.tools, payload.system, payload.input)
497
- except Exception as exc:
498
- raise HTTPException(status_code=500, detail=f"Needle inference failed: {exc}") from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
 
500
- if result.get("success") is False:
501
- detail = result.get("error") or result.get("error_code") or "Needle reported a runtime error"
502
- raise HTTPException(status_code=500, detail=str(detail))
 
 
 
 
 
 
 
 
 
 
503
 
504
- function_calls = result.get("function_calls") or []
505
- suppressed_calls = result.get("suppressed_calls") or []
506
- confidence = result.get("confidence")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/route/auto", json=auto_route_payload())
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/route/auto", 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,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/route/auto", 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,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/route/auto", json=auto_route_payload())
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/route/auto", 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
- gateway.CATALOGUE_CACHE_SIZE = 2
435
- gateway._catalogue_cache.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/route/auto", json=auto_route_payload(overrides={"tools": tools}))
442
-
443
- assert len(gateway._catalogue_cache) == 2
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"