Skip to content

Commit 3604f17

Browse files
RobLe3claude
andcommitted
feat: adapter parity — backends, QoS, availability, policy trio, mesh (v0.6.0)
Closes the feature gap vs the deprecated Python adapter (iicp.network#340/#356): - backends: dedicated vLLM + llama.cpp factories + get_backend_handler selector - scheduler: QoS-aware admission (realtime/interactive wait, batch/best-effort fail fast) - availability: time-window capacity shaping (ADR-006), advertised in heartbeat/health - policy trio: TokenValidator, IdempotencyGuard (opt-in IICP-E010), trust auditor - mesh: PeerManager gossip + POST /v1/peers (HMAC) + POST /v1/relay (ADR-009/022) - version harmonized to 0.6.0 (pyproject + __version__) 185 tests pass; ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2f93b5b commit 3604f17

21 files changed

Lines changed: 1374 additions & 103 deletions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "iicp-client"
7-
version = "0.5.7"
7+
version = "0.6.0"
88
description = "Official Python client SDK for the IICP protocol"
99
readme = "README.md"
1010
license = {text = "Apache-2.0"}

src/iicp_client/__init__.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
"""iicp-client — Official Python client SDK for the IICP protocol."""
22

3-
from iicp_client.backends import openai_compat_handler
3+
from iicp_client.availability import AvailabilityEvaluator, Window
4+
from iicp_client.backends import (
5+
BACKEND_TYPES,
6+
get_backend_handler,
7+
llamacpp_handler,
8+
openai_compat_handler,
9+
vllm_handler,
10+
)
411
from iicp_client.cip_policy import (
512
CooperativeInferencePolicy,
613
)
@@ -14,11 +21,21 @@
1421
from iicp_client.concurrency import CapacityExceededError, ConcurrencyGate
1522
from iicp_client.conformance import ConformanceReport, ProbeResult, run_conformance_checks
1623
from iicp_client.errors import IicpError
24+
from iicp_client.idempotency import IdempotencyGuard
1725
from iicp_client.iicp_tcp import IicpTcpClient, IicpTcpClientError, IicpTcpServer, MsgType
18-
from iicp_client.nat_detection import NatProfile, delete_ipv6_pinhole, detect_nat, renew_ipv6_pinhole
19-
from iicp_client.otel_tracer import task_execute_span, task_validate_span
26+
from iicp_client.nat_detection import (
27+
NatProfile,
28+
delete_ipv6_pinhole,
29+
detect_nat,
30+
renew_ipv6_pinhole,
31+
)
2032
from iicp_client.node import IicpNode, NodeConfig
33+
from iicp_client.otel_tracer import task_execute_span, task_validate_span
34+
from iicp_client.peer_manager import PeerManager
2135
from iicp_client.pricing import PricingConfig, build_pricing_block, sign_body, verify_signature
36+
from iicp_client.scheduler import is_queue_eligible, qos_priority
37+
from iicp_client.token_validator import TokenValidator
38+
from iicp_client.trust_auditor import AuditReport, models_diverge, run_audit_pass
2239
from iicp_client.types import (
2340
ChatMessage,
2441
ChatOptions,
@@ -32,7 +49,7 @@
3249
TaskResponse,
3350
)
3451

35-
__version__ = "0.5.6"
52+
__version__ = "0.6.0"
3653
__all__ = [
3754
"IicpClient",
3855
"IicpError",
@@ -47,6 +64,20 @@
4764
"detect_nat",
4865
"renew_ipv6_pinhole",
4966
"openai_compat_handler",
67+
"vllm_handler",
68+
"llamacpp_handler",
69+
"get_backend_handler",
70+
"BACKEND_TYPES",
71+
"qos_priority",
72+
"is_queue_eligible",
73+
"AvailabilityEvaluator",
74+
"Window",
75+
"IdempotencyGuard",
76+
"TokenValidator",
77+
"AuditReport",
78+
"models_diverge",
79+
"run_audit_pass",
80+
"PeerManager",
5081
"ClientConfig",
5182
"TaskAuth",
5283
"TaskConstraints",

src/iicp_client/availability.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Time-based availability windows — operator capacity shaping by time-of-day.
3+
4+
Port of iicp-adapter `scheduling/availability.py` (parity Block D, #340). Lets an operator
5+
dedicate different fractions of `max_concurrent` to IICP tasks at different times — e.g.
6+
full capacity overnight, 30% during business hours when the machine has other work.
7+
8+
Window semantics:
9+
- start/end: "HH:MM" strings in local system time (not UTC).
10+
- share: fraction of max_concurrent to dedicate (0.0 = no tasks, 1.0 = full capacity).
11+
- Outside all windows: 0.5 (available but not primary) so idle periods aren't dead zones.
12+
- No windows configured: always 1.0 (most operators, most of the time).
13+
14+
WHY operator-controlled windows rather than directory-controlled scheduling: IICP gives
15+
operators sovereignty over their own capacity (ADR-001). The directory learns live load via
16+
heartbeats and scores accordingly — it doesn't push scheduling decisions to nodes.
17+
18+
Spec: spec/iicp-dir.md §register `availability` field (optional, Phase 3+).
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import datetime
24+
from typing import TypedDict
25+
26+
27+
class Window(TypedDict):
28+
start: str # "HH:MM"
29+
end: str
30+
share: float # fraction of max_concurrent to dedicate (0.0–1.0)
31+
32+
33+
class AvailabilityEvaluator:
34+
"""Evaluates time-based availability windows. Local time; no windows → always 1.0."""
35+
36+
def __init__(self, windows: list[Window] | None = None) -> None:
37+
self._windows = windows or []
38+
39+
def current_share(self, now: datetime.time | None = None) -> float:
40+
"""Return the capacity share [0,1] for the current time of day."""
41+
if not self._windows:
42+
return 1.0
43+
44+
t = now or datetime.datetime.now().time()
45+
current = t.strftime("%H:%M")
46+
47+
for w in self._windows:
48+
if w["start"] <= w["end"]:
49+
# Normal window (e.g. 08:00–22:00)
50+
if w["start"] <= current <= w["end"]:
51+
return float(w["share"])
52+
else:
53+
# Midnight-spanning window (e.g. 22:00–06:00)
54+
if current >= w["start"] or current <= w["end"]:
55+
return float(w["share"])
56+
57+
# Outside all windows — half capacity (available but not primary)
58+
return 0.5
59+
60+
def effective_max_concurrent(self, base_max: int, now: datetime.time | None = None) -> int:
61+
"""Scale base max_concurrent by the current share (floor 1 when share > 0).
62+
63+
A base of 0 (operator explicitly disabled) stays 0 — the floor only protects a
64+
working node from being shaped down to 0 by a fractional share.
65+
"""
66+
if base_max <= 0:
67+
return 0
68+
share = self.current_share(now)
69+
if share <= 0.0:
70+
return 0
71+
return max(1, int(base_max * share))
72+
73+
def is_within_window(self, now: datetime.time | None = None) -> bool:
74+
if not self._windows:
75+
return True
76+
return self.current_share(now) > 0.0

src/iicp_client/backends/__init__.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,52 @@
44
`IicpNode.serve(handler, port=...)` OR `IicpTcpServer(handler=...)`.
55
66
Available backends:
7-
- openai_compat — drives Ollama, vLLM, LM Studio, or any OpenAI-compatible
8-
HTTP server. Maps intent URN → /v1/{chat/completions,
9-
completions, embeddings} path.
7+
- openai_compat — drives Ollama, LM Studio, or any OpenAI-compatible HTTP server.
8+
Maps intent URN → /v1/{chat/completions,completions,embeddings}.
9+
- vllm — vLLM OpenAI server (default port 8000).
10+
- llamacpp — llama.cpp `llama-server` (default port 8080).
1011
11-
Adding a new backend: create a module here that exports a factory like
12-
`openai_compat_handler` returning `async def(task: dict) -> dict`.
12+
Use `get_backend_handler(backend_type, ...)` to select one by name (e.g. from a CLI
13+
`--backend-type` flag). Adding a new backend: create a module here whose factory
14+
delegates to `base.build_openai_dialect_handler` (or implements a fresh dialect),
15+
then register it in `_FACTORIES` below.
1316
"""
1417

18+
from __future__ import annotations
19+
20+
from iicp_client.backends.base import TaskHandler
21+
from iicp_client.backends.llamacpp import llamacpp_handler
1522
from iicp_client.backends.openai_compat import openai_compat_handler
23+
from iicp_client.backends.vllm import vllm_handler
24+
25+
__all__ = [
26+
"openai_compat_handler",
27+
"vllm_handler",
28+
"llamacpp_handler",
29+
"get_backend_handler",
30+
"BACKEND_TYPES",
31+
]
32+
33+
_FACTORIES = {
34+
"openai_compat": openai_compat_handler,
35+
"vllm": vllm_handler,
36+
"llamacpp": llamacpp_handler,
37+
}
38+
39+
BACKEND_TYPES = tuple(_FACTORIES.keys())
40+
41+
42+
def get_backend_handler(backend_type: str, **kwargs) -> TaskHandler:
43+
"""Return a backend handler by name.
1644
17-
__all__ = ["openai_compat_handler"]
45+
`backend_type` is one of `BACKEND_TYPES`. Remaining kwargs (base_url, model,
46+
api_key, timeout_s) are forwarded to the selected factory. Each factory supplies
47+
its own engine-appropriate default `base_url` when omitted.
48+
"""
49+
try:
50+
factory = _FACTORIES[backend_type]
51+
except KeyError:
52+
raise ValueError(
53+
f"unknown backend_type {backend_type!r}; choose one of {list(BACKEND_TYPES)}"
54+
) from None
55+
return factory(**kwargs)

src/iicp_client/backends/base.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Shared core for OpenAI-dialect backend handlers.
3+
4+
vLLM, llama.cpp, LM Studio and Ollama all speak the OpenAI `/v1/*` HTTP dialect, so
5+
the request/response plumbing is identical — only the default port and the engine
6+
label in error messages differ. This module hosts that shared plumbing so the
7+
per-engine modules (`openai_compat`, `vllm`, `llamacpp`) stay thin and a new engine
8+
is one factory call, not a copy of the whole handler.
9+
10+
Port of iicp-adapter `backends/{base,vllm,llamacpp,openai_compat}.py` into the SDK's
11+
handler-factory style (tracker iicp.network#340; parity Block B).
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import logging
17+
from collections.abc import Awaitable, Callable
18+
from typing import Any
19+
20+
import httpx
21+
22+
logger = logging.getLogger(__name__)
23+
24+
TaskHandler = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]
25+
26+
# Maps IICP intent URN → OpenAI-compatible HTTP path.
27+
INTENT_TO_PATH: dict[str, str] = {
28+
"urn:iicp:intent:llm:chat:v1": "/chat/completions",
29+
"urn:iicp:intent:llm:completion:v1": "/completions",
30+
"urn:iicp:intent:llm:embedding:v1": "/embeddings",
31+
}
32+
33+
34+
def build_openai_dialect_handler(
35+
*,
36+
engine: str,
37+
base_url: str,
38+
model: str | None,
39+
api_key: str,
40+
timeout_s: float,
41+
) -> TaskHandler:
42+
"""Build a TaskHandler that proxies CALLs to an OpenAI-dialect server.
43+
44+
`engine` is the label used in error messages (e.g. "vllm"). All engines share
45+
this body; the per-engine modules differ only in their default `base_url`.
46+
"""
47+
base = base_url.rstrip("/")
48+
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
49+
50+
async def handler(task: dict[str, Any]) -> dict[str, Any]:
51+
intent = str(task.get("intent", ""))
52+
payload = task.get("payload") or {}
53+
if not isinstance(payload, dict):
54+
return {
55+
"error_code": 400,
56+
"error_message": (
57+
f"{engine}: task.payload must be a dict, got {type(payload).__name__}"
58+
),
59+
}
60+
61+
path = INTENT_TO_PATH.get(intent)
62+
if path is None:
63+
return {
64+
"error_code": 400,
65+
"error_message": (
66+
f"{engine}: unsupported intent {intent!r}; "
67+
f"supported: {sorted(INTENT_TO_PATH.keys())}"
68+
),
69+
}
70+
71+
# Merge model: explicit task payload field wins; factory default fills in.
72+
body = dict(payload)
73+
body.setdefault("model", model)
74+
if not body.get("model"):
75+
return {
76+
"error_code": 400,
77+
"error_message": (
78+
f"{engine}: no model — either pass `model=...` to the backend "
79+
"factory or include `model` in the task payload"
80+
),
81+
}
82+
83+
try:
84+
async with httpx.AsyncClient(timeout=timeout_s, headers=headers) as client:
85+
r = await client.post(f"{base}{path}", json=body)
86+
except httpx.TimeoutException:
87+
return {"error_code": 408, "error_message": f"{engine}: backend timed out"}
88+
except httpx.HTTPError as exc:
89+
return {
90+
"error_code": 502,
91+
"error_message": f"{engine}: HTTP transport error: {exc}",
92+
}
93+
94+
if r.status_code >= 400:
95+
# Surface the upstream error verbatim — operators usually need the
96+
# original message (rate-limit, model-not-loaded, etc.)
97+
return {
98+
"error_code": r.status_code,
99+
"error_message": f"{engine}: upstream {r.status_code}: {r.text[:512]}",
100+
}
101+
102+
try:
103+
data = r.json()
104+
except ValueError as exc:
105+
return {
106+
"error_code": 502,
107+
"error_message": f"{engine}: upstream returned non-JSON: {exc}",
108+
}
109+
110+
return {"result": data}
111+
112+
return handler
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""llama.cpp backend handler.
3+
4+
The `llama-server` binary (llama.cpp) exposes an OpenAI-compatible `/v1/*` API, so this
5+
is a thin factory over the shared core with llama.cpp's default port (8080). Kept as a
6+
dedicated module so operators can select `backend_type=llamacpp` explicitly. Port of
7+
iicp-adapter `backends/llamacpp.py` (parity Block B, #340).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from iicp_client.backends.base import TaskHandler, build_openai_dialect_handler
13+
14+
15+
def llamacpp_handler(
16+
*,
17+
base_url: str = "http://localhost:8080/v1",
18+
model: str | None = None,
19+
api_key: str = "",
20+
timeout_s: float = 30.0,
21+
) -> TaskHandler:
22+
"""Build a TaskHandler that proxies CALLs to a llama.cpp `llama-server`.
23+
24+
Defaults to llama.cpp's standard port 8080. llama.cpp ignores the `model` field for
25+
single-model servers, but it is still sent so multi-model builds route correctly.
26+
"""
27+
return build_openai_dialect_handler(
28+
engine="llamacpp",
29+
base_url=base_url,
30+
model=model,
31+
api_key=api_key,
32+
timeout_s=timeout_s,
33+
)

0 commit comments

Comments
 (0)