iicp-proxy 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iicp_proxy-0.2.0.dist-info/METADATA +23 -0
- iicp_proxy-0.2.0.dist-info/RECORD +48 -0
- iicp_proxy-0.2.0.dist-info/WHEEL +5 -0
- iicp_proxy-0.2.0.dist-info/entry_points.txt +2 -0
- iicp_proxy-0.2.0.dist-info/top_level.txt +1 -0
- proxy/__init__.py +2 -0
- proxy/address_state.py +29 -0
- proxy/anthropic_compat/__init__.py +1 -0
- proxy/anthropic_compat/server.py +207 -0
- proxy/anthropic_compat/translator.py +101 -0
- proxy/auth/__init__.py +4 -0
- proxy/auth/secrets.py +18 -0
- proxy/cip/__init__.py +1 -0
- proxy/cip/aggregation.py +77 -0
- proxy/cip/consumer.py +109 -0
- proxy/cip/coordinator.py +108 -0
- proxy/cip/dispatch.py +141 -0
- proxy/cip/gates.py +250 -0
- proxy/cip/receipts.py +154 -0
- proxy/cip/strategies.py +100 -0
- proxy/clients/__init__.py +5 -0
- proxy/clients/did_resolver.py +99 -0
- proxy/clients/directory.py +360 -0
- proxy/clients/node.py +130 -0
- proxy/clients/replica_registry.py +143 -0
- proxy/clients/replica_sig_verifier.py +98 -0
- proxy/clients/trust_resolver.py +127 -0
- proxy/config.py +139 -0
- proxy/main.py +188 -0
- proxy/metrics.py +30 -0
- proxy/network/__init__.py +4 -0
- proxy/network/peer_cache.py +118 -0
- proxy/ollama_compat/__init__.py +1 -0
- proxy/ollama_compat/server.py +187 -0
- proxy/ollama_compat/translator.py +96 -0
- proxy/openai_compat/__init__.py +5 -0
- proxy/openai_compat/server.py +130 -0
- proxy/openai_compat/translator.py +38 -0
- proxy/otel_tracer.py +190 -0
- proxy/plugin/__init__.py +2 -0
- proxy/plugin/iicp_provider.py +76 -0
- proxy/routing/__init__.py +15 -0
- proxy/routing/aggregator.py +115 -0
- proxy/routing/circuit_breaker.py +92 -0
- proxy/routing/fallback.py +259 -0
- proxy/routing/retry.py +96 -0
- proxy/routing/router.py +100 -0
- proxy/routing/selector.py +180 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iicp-proxy
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: IICP Client Proxy — Client Plane
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: fastapi>=0.115
|
|
8
|
+
Requires-Dist: uvicorn[standard]>=0.32
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Requires-Dist: pydantic>=2.9
|
|
11
|
+
Requires-Dist: pydantic-settings>=2.6
|
|
12
|
+
Requires-Dist: prometheus-client>=0.21
|
|
13
|
+
Requires-Dist: cryptography>=42
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=8.3; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
18
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
19
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
20
|
+
Requires-Dist: coverage[toml]>=7.6; extra == "dev"
|
|
21
|
+
Requires-Dist: cbor2>=5.4; extra == "dev"
|
|
22
|
+
Provides-Extra: cbor
|
|
23
|
+
Requires-Dist: cbor2>=5.4; extra == "cbor"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
proxy/__init__.py,sha256=Lfc1HttJAEcwR58vOPACs_2F8IdzgM7lFYWlQrxr9Iw,108
|
|
2
|
+
proxy/address_state.py,sha256=tcfUTjH_-D4wvPqqqjcPvWbyscHPNdeNvcCVUeimjv0,899
|
|
3
|
+
proxy/config.py,sha256=vsgiVDE1q1DIALPPClxYcMQvkC_D7QhHIR920yIhK3Y,6778
|
|
4
|
+
proxy/main.py,sha256=mx2yrlaQxwnFbxpobR1CuiCRcIqqBlA97GJf_6eMXOI,8241
|
|
5
|
+
proxy/metrics.py,sha256=91Lz24D49QezlaLDXck9bVctWaW4clFutLIvlUwiwuM,880
|
|
6
|
+
proxy/otel_tracer.py,sha256=25FrfYhstH79-776xrY9VHK7_20mmJ021GAQzxYV1HU,7382
|
|
7
|
+
proxy/anthropic_compat/__init__.py,sha256=bG7Ffen4LvQtgnYPFEpFccsWs81t4zqqeqn9ZeirH6E,38
|
|
8
|
+
proxy/anthropic_compat/server.py,sha256=2CKDt4aSIF0jBrL2Q_5aaVcutOpNi7NCsxvXn1g0h18,8449
|
|
9
|
+
proxy/anthropic_compat/translator.py,sha256=52IzAKwWRKZBPWxbRVBvZWCrurtIpi-FKHrp2HLfnac,3972
|
|
10
|
+
proxy/auth/__init__.py,sha256=lCBXDz1ncyBS7uKJTTW-5YWK_gFHyuzsTTxSh7GO6gA,106
|
|
11
|
+
proxy/auth/secrets.py,sha256=4g3rwPfQQKKYYLPiCvo_Qqc-T6Oi9GydUPNtBX6mL10,486
|
|
12
|
+
proxy/cip/__init__.py,sha256=bG7Ffen4LvQtgnYPFEpFccsWs81t4zqqeqn9ZeirH6E,38
|
|
13
|
+
proxy/cip/aggregation.py,sha256=C_zGV5VDWzDlwvliqg_akM-exNJ-i2FJByiBiTNR4d8,3576
|
|
14
|
+
proxy/cip/consumer.py,sha256=xcIe_nlghZe8myMNU3NoWrfNOVou3Lpzl1gPrwLvce4,4236
|
|
15
|
+
proxy/cip/coordinator.py,sha256=bj9Ex4rJ-onTjTkKyXLgQGgV9l4Clji0N_HU1Qia7Xc,4179
|
|
16
|
+
proxy/cip/dispatch.py,sha256=HWF9z3T3zMpYfYdrAEcALY4SD4yg-H4sXxCvD9cK9Lg,6336
|
|
17
|
+
proxy/cip/gates.py,sha256=75IxMgb1HOHU6wklGJquE7XqfhWhEBusz_ljXZCGINg,10070
|
|
18
|
+
proxy/cip/receipts.py,sha256=gnwcC01m2DudiHshurEzM67Tn_8u5MCOFK1_0YlkbdY,5966
|
|
19
|
+
proxy/cip/strategies.py,sha256=RvTi22m9_JdbPlcGombDufeIfkkhNhQUQ_0eHPiuOPc,3643
|
|
20
|
+
proxy/clients/__init__.py,sha256=hb3Dqx75vIw7B16DC2hf7rwqsc4i0pG0-d7ON8pdEYE,151
|
|
21
|
+
proxy/clients/did_resolver.py,sha256=AoeqKIgCl8-zYTuPKzZ7bWuk-ZR4gIkq456N8BIHQxs,3597
|
|
22
|
+
proxy/clients/directory.py,sha256=32v5HlsjrdAhYmVneuidVU14Hi-StzgaixSNo1djeN4,16945
|
|
23
|
+
proxy/clients/node.py,sha256=0Y7kB12v85xKCgpetQniJJM1Yd57EV-8tmiZsX_l9_w,5298
|
|
24
|
+
proxy/clients/replica_registry.py,sha256=URVOmAn9f4oLdD8JY8yrFMQ0AQoQfGM7MM8tE6PZ9WQ,5600
|
|
25
|
+
proxy/clients/replica_sig_verifier.py,sha256=avdWuH9Wp-5xVLjPXU-vfBbWO8uPmMlePHsmsH7J9h4,3392
|
|
26
|
+
proxy/clients/trust_resolver.py,sha256=v3z6i0BmGT6nqazRk0ELzr2iRIhq8jQ-IDbIwNInWHk,4807
|
|
27
|
+
proxy/network/__init__.py,sha256=TgsLhskDDpruFwpCNzFEHt3PnbxgdsD1aOwZCgW85Rs,110
|
|
28
|
+
proxy/network/peer_cache.py,sha256=MdQ2QNq6UfN3K8s2I1mZSab0a616XQer3BIoTe9QQq4,4438
|
|
29
|
+
proxy/ollama_compat/__init__.py,sha256=bG7Ffen4LvQtgnYPFEpFccsWs81t4zqqeqn9ZeirH6E,38
|
|
30
|
+
proxy/ollama_compat/server.py,sha256=7KOwrTltNdGm8VRg2D5uVe-iT7YRvz36tAC0OEizBC8,7925
|
|
31
|
+
proxy/ollama_compat/translator.py,sha256=BvskBs4ZTTs24TMav3KT2R_79He44UcSXYFaAN1PYZ4,3739
|
|
32
|
+
proxy/openai_compat/__init__.py,sha256=3nn-coQF54jT-NiFHAgy0VIMW6qoUfQddg_T208dUWQ,204
|
|
33
|
+
proxy/openai_compat/server.py,sha256=1Q0ug2miREFDmIW96o_JGtu91v8yhntJYRAy0rFPzaA,4933
|
|
34
|
+
proxy/openai_compat/translator.py,sha256=08dNgt2DNJKrKcFAFqYQ2bnjYdtNBs7psBfUMP6TYFE,1270
|
|
35
|
+
proxy/plugin/__init__.py,sha256=0BdZIoC_IrIwWlCthQ66Euyk_RzRPBQrj4Taj-pamaU,125
|
|
36
|
+
proxy/plugin/iicp_provider.py,sha256=JJdxHbxMOOepIhcjK0JC27Hbe50F6ssLveANeEXYJQM,2430
|
|
37
|
+
proxy/routing/__init__.py,sha256=C1aWxm8QzsgILSPN_V_oDQZLMhfsKhAV0PGSF1XZd6w,374
|
|
38
|
+
proxy/routing/aggregator.py,sha256=_lERRNMOPpQRM9eDhQTXlKzv81M3oOtzhkhTbSdAiR4,4485
|
|
39
|
+
proxy/routing/circuit_breaker.py,sha256=T-RUuwmTLmlcvOhwvh6FKvspVAfqD4cKpXWEGbe5IRc,3806
|
|
40
|
+
proxy/routing/fallback.py,sha256=0sOXYv2F3B-O8S_YX3afOULcLyy5YDvZgKcimf3bqlE,10991
|
|
41
|
+
proxy/routing/retry.py,sha256=hZmjppiVjROWBxY_b0OIu8dgrzqz51IcmVxreP-tyVI,3884
|
|
42
|
+
proxy/routing/router.py,sha256=LEcgbyA3FiMwi23z5zSKbqg_n8feGmtcXpJlFUY7974,3856
|
|
43
|
+
proxy/routing/selector.py,sha256=kuQTcL5hGBX64t6bOwU_9O1bNTqH2p72C5lfJK_XIJg,8014
|
|
44
|
+
iicp_proxy-0.2.0.dist-info/METADATA,sha256=5gsf1_FUPJIfrnZV9jmfHzRIgmaR6Mbk99FglbtDScg,779
|
|
45
|
+
iicp_proxy-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
46
|
+
iicp_proxy-0.2.0.dist-info/entry_points.txt,sha256=aIKEwg0qIZantd3tMk-DTNEvysEQqJ2vRrBw0H8YY7w,46
|
|
47
|
+
iicp_proxy-0.2.0.dist-info/top_level.txt,sha256=z6RYBTHwGoNGkSVH3TvrDF_N3yovKtb4mncOphsetf8,6
|
|
48
|
+
iicp_proxy-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
proxy
|
proxy/__init__.py
ADDED
proxy/address_state.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Runtime holder for the proxy's observed external IP (DIR-ADDR-02)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class AddressState:
|
|
10
|
+
observed_source_ip: str | None = field(default=None)
|
|
11
|
+
endpoint: str | None = field(default=None)
|
|
12
|
+
node_id: str | None = field(default=None)
|
|
13
|
+
|
|
14
|
+
def update_from_ack(self, ack: dict) -> None:
|
|
15
|
+
self.observed_source_ip = ack.get("observed_source_ip")
|
|
16
|
+
self.node_id = ack.get("node_id")
|
|
17
|
+
|
|
18
|
+
def update_from_me(self, me: dict) -> None:
|
|
19
|
+
self.observed_source_ip = me.get("observed_source_ip")
|
|
20
|
+
self.node_id = me.get("node_id")
|
|
21
|
+
self.endpoint = me.get("endpoint")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Module-level singleton — set once at startup, read by status commands
|
|
25
|
+
_state: AddressState = AddressState()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_address_state() -> AddressState:
|
|
29
|
+
return _state
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Anthropic Messages API routes added to the proxy's FastAPI application.
|
|
3
|
+
|
|
4
|
+
WHY /v1/messages alongside /v1/chat/completions: Anthropic clients call POST /v1/messages.
|
|
5
|
+
OpenAI clients call POST /v1/chat/completions. Both paths exist under /v1/ with distinct
|
|
6
|
+
route names — no collision. The proxy registers both and routes IICP tasks identically
|
|
7
|
+
through the same fallback_chain.
|
|
8
|
+
|
|
9
|
+
WHY /v1/models is a static list: Anthropic SDKs call GET /v1/models on startup to verify
|
|
10
|
+
the base URL is valid. IICP proxy returns a minimal static entry so SDK initialization
|
|
11
|
+
succeeds. Actual model selection is intent-based on the mesh, not name-based.
|
|
12
|
+
|
|
13
|
+
WHY fake SSE streaming via six typed events: Anthropic SDK stream=True requires
|
|
14
|
+
text/event-stream with message_start, content_block_start, content_block_delta,
|
|
15
|
+
content_block_stop, message_delta, and message_stop events. IICP task execution is
|
|
16
|
+
synchronous (single request-response cycle), so we emit the full response as a single
|
|
17
|
+
content_block_delta event. SDK streaming iterators receive the complete text in the
|
|
18
|
+
first delta and immediately see message_stop, satisfying the protocol without token-level
|
|
19
|
+
granularity. True streaming requires adapter-side SSE (Phase 4 stretch goal, #280).
|
|
20
|
+
|
|
21
|
+
Spec: spec/iicp-core.md §3. ADR: ADR-001, ADR-005. Issues: #279, #280.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
from collections.abc import Iterator
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from fastapi import FastAPI, Request
|
|
31
|
+
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|
32
|
+
|
|
33
|
+
from proxy.anthropic_compat.translator import to_anthropic_response, to_iicp_task
|
|
34
|
+
from proxy.cip.dispatch import (
|
|
35
|
+
CIPInsufficientCredits,
|
|
36
|
+
compute_cip_envelope,
|
|
37
|
+
resolve_consumer_balance,
|
|
38
|
+
)
|
|
39
|
+
from proxy.otel_tracer import proxy_route_span
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
def _sse_events(
|
|
44
|
+
iicp_response: dict[str, Any],
|
|
45
|
+
task_id: str,
|
|
46
|
+
model: str,
|
|
47
|
+
) -> Iterator[bytes]:
|
|
48
|
+
"""Yield Anthropic SSE events for a complete IICP response.
|
|
49
|
+
|
|
50
|
+
Emits the six required event types (message_start, content_block_start,
|
|
51
|
+
content_block_delta, content_block_stop, message_delta, message_stop) with
|
|
52
|
+
the full response text in a single content_block_delta. Anthropic SDK
|
|
53
|
+
streaming iterators collect all deltas and reassemble them — receiving the
|
|
54
|
+
full text in one delta is equivalent to receiving it word-by-word.
|
|
55
|
+
"""
|
|
56
|
+
result = iicp_response.get("result") or {}
|
|
57
|
+
choices: list[dict[str, Any]] = result.get("choices") or [{}]
|
|
58
|
+
message = (choices[0].get("message") or {}) if choices else {}
|
|
59
|
+
usage: dict[str, Any] = result.get("usage") or {}
|
|
60
|
+
text = message.get("content", "")
|
|
61
|
+
input_tokens = usage.get("prompt_tokens", 0)
|
|
62
|
+
output_tokens = usage.get("completion_tokens", 0)
|
|
63
|
+
|
|
64
|
+
def _event(name: str, data: dict[str, Any]) -> bytes:
|
|
65
|
+
return f"event: {name}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n".encode()
|
|
66
|
+
|
|
67
|
+
yield _event("message_start", {
|
|
68
|
+
"type": "message_start",
|
|
69
|
+
"message": {
|
|
70
|
+
"id": f"msg_{task_id or 'iicp'}",
|
|
71
|
+
"type": "message",
|
|
72
|
+
"role": "assistant",
|
|
73
|
+
"content": [],
|
|
74
|
+
"model": model,
|
|
75
|
+
"stop_reason": None,
|
|
76
|
+
"stop_sequence": None,
|
|
77
|
+
"usage": {"input_tokens": input_tokens, "output_tokens": 0},
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
yield _event("content_block_start", {
|
|
81
|
+
"type": "content_block_start",
|
|
82
|
+
"index": 0,
|
|
83
|
+
"content_block": {"type": "text", "text": ""},
|
|
84
|
+
})
|
|
85
|
+
yield _event("content_block_delta", {
|
|
86
|
+
"type": "content_block_delta",
|
|
87
|
+
"index": 0,
|
|
88
|
+
"delta": {"type": "text_delta", "text": text},
|
|
89
|
+
})
|
|
90
|
+
yield _event("content_block_stop", {"type": "content_block_stop", "index": 0})
|
|
91
|
+
yield _event("message_delta", {
|
|
92
|
+
"type": "message_delta",
|
|
93
|
+
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
|
|
94
|
+
"usage": {"output_tokens": output_tokens},
|
|
95
|
+
})
|
|
96
|
+
yield _event("message_stop", {"type": "message_stop"})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Static model list — Anthropic SDK calls GET /v1/models to validate base_url
|
|
100
|
+
_MODELS_RESPONSE = {
|
|
101
|
+
"data": [
|
|
102
|
+
{
|
|
103
|
+
"id": "iicp",
|
|
104
|
+
"object": "model",
|
|
105
|
+
"created": 1700000000,
|
|
106
|
+
"owned_by": "iicp",
|
|
107
|
+
}
|
|
108
|
+
],
|
|
109
|
+
"object": "list",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def _execute_iicp(request: Request, body: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
|
114
|
+
"""Run an IICP task through the proxy routing stack. Returns (response, str_task_id)."""
|
|
115
|
+
fallback_chain = request.app.state.fallback_chain
|
|
116
|
+
directory = request.app.state.directory
|
|
117
|
+
selector = request.app.state.selector
|
|
118
|
+
peer_cache = getattr(request.app.state, "peer_cache", None)
|
|
119
|
+
cip_config = getattr(request.app.state, "cip_config", None)
|
|
120
|
+
session_tracker = getattr(request.app.state, "cip_budget_tracker", None)
|
|
121
|
+
node_token = getattr(request.app.state, "node_token", None)
|
|
122
|
+
|
|
123
|
+
task_id, intent, payload = to_iicp_task(body)
|
|
124
|
+
timeout_ms = int(body.get("timeout_ms", 30000))
|
|
125
|
+
|
|
126
|
+
raw: list[dict[str, Any]] = []
|
|
127
|
+
with proxy_route_span(str(task_id), intent):
|
|
128
|
+
if peer_cache is not None:
|
|
129
|
+
raw = await peer_cache.get_nodes(intent) or []
|
|
130
|
+
if not raw:
|
|
131
|
+
try:
|
|
132
|
+
raw = await directory.discover(intent=intent)
|
|
133
|
+
if peer_cache is not None:
|
|
134
|
+
await peer_cache.fetch_and_cache(intent)
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
logger.warning("Directory discover failed: %s", exc)
|
|
137
|
+
|
|
138
|
+
nodes = selector.select(raw)
|
|
139
|
+
consumer_balance = await resolve_consumer_balance(directory, node_token, cip_config)
|
|
140
|
+
cip_envelope = compute_cip_envelope(
|
|
141
|
+
nodes, body, cip_config, str(task_id),
|
|
142
|
+
session_tracker=session_tracker, consumer_balance=consumer_balance,
|
|
143
|
+
)
|
|
144
|
+
cip_block = body.get("cip") if isinstance(body.get("cip"), dict) else {}
|
|
145
|
+
response: dict[str, Any] = await fallback_chain.execute(
|
|
146
|
+
nodes, task_id, intent, payload, timeout_ms,
|
|
147
|
+
cip_envelope=cip_envelope,
|
|
148
|
+
cip_policy=cip_block.get("policy", "best_of_n"),
|
|
149
|
+
cip_replicas=int(cip_block.get("replicas", 1)),
|
|
150
|
+
cip_quorum=cip_block.get("quorum"),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return response, str(task_id)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def add_anthropic_routes(app: FastAPI) -> None:
|
|
157
|
+
"""Register Anthropic Messages API-compatible routes on an existing FastAPI app."""
|
|
158
|
+
|
|
159
|
+
@app.get("/v1/models", include_in_schema=False)
|
|
160
|
+
async def list_models() -> JSONResponse:
|
|
161
|
+
"""Static model list — satisfies Anthropic SDK base_url validation."""
|
|
162
|
+
return JSONResponse(_MODELS_RESPONSE)
|
|
163
|
+
|
|
164
|
+
@app.post("/v1/messages", include_in_schema=False)
|
|
165
|
+
async def messages(request: Request) -> Response:
|
|
166
|
+
"""Anthropic /v1/messages — translates to IICP CALL and returns Messages API shape.
|
|
167
|
+
|
|
168
|
+
stream=True: returns text/event-stream with six typed SSE events.
|
|
169
|
+
stream=False (Anthropic SDK default): returns application/json.
|
|
170
|
+
Errors always return application/json regardless of stream value.
|
|
171
|
+
"""
|
|
172
|
+
body: dict[str, Any] = await request.json()
|
|
173
|
+
stream: bool = body.get("stream", False) # Anthropic SDK default is False
|
|
174
|
+
try:
|
|
175
|
+
response, task_id = await _execute_iicp(request, body)
|
|
176
|
+
except CIPInsufficientCredits as exc:
|
|
177
|
+
return JSONResponse(
|
|
178
|
+
status_code=402,
|
|
179
|
+
content={
|
|
180
|
+
"type": "error",
|
|
181
|
+
"error": {
|
|
182
|
+
"type": "api_error",
|
|
183
|
+
"message": exc.error_code + ": Insufficient S-Credit balance for remote dispatch",
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if response.get("status") == "error":
|
|
189
|
+
err = response.get("error", {})
|
|
190
|
+
return JSONResponse(
|
|
191
|
+
status_code=502,
|
|
192
|
+
content={
|
|
193
|
+
"type": "error",
|
|
194
|
+
"error": {
|
|
195
|
+
"type": "api_error",
|
|
196
|
+
"message": err.get("code", "proxy_error") + ": Upstream error",
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
model = body.get("model", "iicp")
|
|
202
|
+
if stream:
|
|
203
|
+
return StreamingResponse(
|
|
204
|
+
_sse_events(response, task_id, model),
|
|
205
|
+
media_type="text/event-stream",
|
|
206
|
+
)
|
|
207
|
+
return JSONResponse(content=to_anthropic_response(response, model, task_id))
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Translate between Anthropic Messages API and IICP task formats.
|
|
3
|
+
|
|
4
|
+
WHY Anthropic-compat: The Anthropic Python/TypeScript SDKs (client.messages.create)
|
|
5
|
+
are used by a growing segment of AI developers post-Claude-4. Adding a single
|
|
6
|
+
base_url override routes those apps through the IICP mesh without further code changes.
|
|
7
|
+
|
|
8
|
+
WHY system is extracted separately: Anthropic places the system prompt as a top-level
|
|
9
|
+
string, not inside the messages list. IICP task format uses OpenAI-style messages.
|
|
10
|
+
We convert the Anthropic system prompt to a {"role": "system", "content": "..."} entry
|
|
11
|
+
at the front of the messages list so adapter nodes receive a standard message array.
|
|
12
|
+
|
|
13
|
+
WHY content blocks are flattened to a string: Anthropic supports typed content blocks
|
|
14
|
+
([{"type": "text", "text": "..."}]) instead of a plain string. IICP task messages use
|
|
15
|
+
plain string content. Non-text block types (tool_use, tool_result, image) are omitted
|
|
16
|
+
with a placeholder — the proxy is a text completion gateway, not a multimodal router.
|
|
17
|
+
|
|
18
|
+
WHY max_tokens is required (but treated as optional here): Anthropic requires max_tokens;
|
|
19
|
+
IICP does not mandate it. We forward it as-is — adapter nodes apply their own token cap
|
|
20
|
+
if max_tokens is absent. Rejecting requests without max_tokens would break the Anthropic
|
|
21
|
+
SDK default path unnecessarily.
|
|
22
|
+
|
|
23
|
+
Spec: spec/iicp-core.md §3 (CALL/RESPONSE). ADR: ADR-001, ADR-005. Issue: #279.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any
|
|
28
|
+
from uuid import UUID, uuid4
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _flatten_content(content: Any) -> str:
|
|
32
|
+
"""Flatten Anthropic typed content blocks or plain strings to a plain string."""
|
|
33
|
+
if isinstance(content, str):
|
|
34
|
+
return content
|
|
35
|
+
if isinstance(content, list):
|
|
36
|
+
parts = []
|
|
37
|
+
for block in content:
|
|
38
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
39
|
+
parts.append(block.get("text", ""))
|
|
40
|
+
return " ".join(parts)
|
|
41
|
+
return ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def to_iicp_task(body: dict[str, Any]) -> tuple[UUID, str, dict[str, Any]]:
|
|
45
|
+
"""Return (task_id, intent, payload) from an Anthropic /v1/messages body.
|
|
46
|
+
|
|
47
|
+
Handles system prompt extraction, content block flattening, and max_tokens forwarding.
|
|
48
|
+
"""
|
|
49
|
+
task_id = uuid4()
|
|
50
|
+
intent = "urn:iicp:intent:llm:chat:v1"
|
|
51
|
+
|
|
52
|
+
messages: list[dict[str, Any]] = []
|
|
53
|
+
|
|
54
|
+
# Anthropic top-level system prompt → prepend as system message
|
|
55
|
+
system = body.get("system")
|
|
56
|
+
if system:
|
|
57
|
+
messages.append({"role": "system", "content": _flatten_content(system)})
|
|
58
|
+
|
|
59
|
+
for msg in body.get("messages") or []:
|
|
60
|
+
if not isinstance(msg, dict):
|
|
61
|
+
continue
|
|
62
|
+
role = msg.get("role", "user")
|
|
63
|
+
content = _flatten_content(msg.get("content", ""))
|
|
64
|
+
messages.append({"role": role, "content": content})
|
|
65
|
+
|
|
66
|
+
payload: dict[str, Any] = {
|
|
67
|
+
"messages": messages,
|
|
68
|
+
"model": body.get("model"),
|
|
69
|
+
"max_tokens": body.get("max_tokens"),
|
|
70
|
+
"temperature": body.get("temperature"),
|
|
71
|
+
"stream": body.get("stream", False),
|
|
72
|
+
}
|
|
73
|
+
return task_id, intent, payload
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def to_anthropic_response(
|
|
77
|
+
iicp_response: dict[str, Any],
|
|
78
|
+
model: str = "iicp",
|
|
79
|
+
task_id: str = "",
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
"""Translate an IICP task response to Anthropic Messages API response format."""
|
|
82
|
+
result = iicp_response.get("result") or {}
|
|
83
|
+
choices: list[dict[str, Any]] = result.get("choices") or [{}]
|
|
84
|
+
message = (choices[0].get("message") or {}) if choices else {}
|
|
85
|
+
usage: dict[str, Any] = result.get("usage") or {}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"id": f"msg_{task_id or 'iicp'}",
|
|
89
|
+
"type": "message",
|
|
90
|
+
"role": "assistant",
|
|
91
|
+
"model": model,
|
|
92
|
+
"content": [
|
|
93
|
+
{"type": "text", "text": message.get("content", "")},
|
|
94
|
+
],
|
|
95
|
+
"stop_reason": "end_turn",
|
|
96
|
+
"stop_sequence": None,
|
|
97
|
+
"usage": {
|
|
98
|
+
"input_tokens": usage.get("prompt_tokens", 0),
|
|
99
|
+
"output_tokens": usage.get("completion_tokens", 0),
|
|
100
|
+
},
|
|
101
|
+
}
|
proxy/auth/__init__.py
ADDED
proxy/auth/secrets.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Load the node token from the environment (or OS keychain as fallback)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MissingNodeTokenError(RuntimeError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_node_token(env_var: str = "IICP_NODE_TOKEN") -> str:
|
|
13
|
+
token = os.environ.get(env_var, "").strip()
|
|
14
|
+
if not token:
|
|
15
|
+
raise MissingNodeTokenError(
|
|
16
|
+
f"Node token not found. Set the {env_var} environment variable."
|
|
17
|
+
)
|
|
18
|
+
return token
|
proxy/cip/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
proxy/cip/aggregation.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""CIP-V06: CIPAggregationResult — coordinator RESPONSE aggregation object (S.12 §4.3).
|
|
3
|
+
|
|
4
|
+
When a proxy acts as CIP coordinator, every RESPONSE that involved dispatching at
|
|
5
|
+
least one worker MUST include a `cip_aggregation` object in the trace (§4.3 MUST).
|
|
6
|
+
This model is the canonical Python representation of that object.
|
|
7
|
+
|
|
8
|
+
Spec normative requirements (S.12 §4.3):
|
|
9
|
+
- policy, replicas_dispatched, replicas_responded, selected_worker_id are MUST fields
|
|
10
|
+
- aggregation_latency_ms is SHOULD
|
|
11
|
+
- cip_vote_count and cip_quorum_threshold are MUST for majority_vote policy
|
|
12
|
+
- selected_worker_id MUST be null when replicas_responded == 0
|
|
13
|
+
- replicas_responded MUST NOT exceed replicas_dispatched
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
20
|
+
|
|
21
|
+
_VALID_CIP_POLICIES = frozenset({"best_of_n", "majority_vote", "map_reduce"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CIPAggregationResult(BaseModel):
|
|
25
|
+
"""CIP-V06: §4.3 cip_aggregation object included in coordinator RESPONSE trace."""
|
|
26
|
+
|
|
27
|
+
model_config = ConfigDict(extra="ignore")
|
|
28
|
+
|
|
29
|
+
policy: str = Field(description="CIP policy used — best_of_n | majority_vote | map_reduce")
|
|
30
|
+
replicas_dispatched: int = Field(ge=0, description="Number of workers dispatched")
|
|
31
|
+
replicas_responded: int = Field(ge=0, description="Number of workers that responded")
|
|
32
|
+
selected_worker_id: str | None = Field(
|
|
33
|
+
default=None, description="Worker whose result was used; null when none responded"
|
|
34
|
+
)
|
|
35
|
+
aggregation_latency_ms: int | None = Field(
|
|
36
|
+
default=None, ge=0, description="Elapsed ms from last response to aggregation complete"
|
|
37
|
+
)
|
|
38
|
+
cip_vote_count: int | None = Field(
|
|
39
|
+
default=None, ge=0, description="Agreeing majority count (majority_vote only)"
|
|
40
|
+
)
|
|
41
|
+
cip_quorum_threshold: int | None = Field(
|
|
42
|
+
default=None, ge=1, description="Quorum required (majority_vote only)"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@model_validator(mode="before")
|
|
46
|
+
@classmethod
|
|
47
|
+
def validate_cross_fields(cls, values: Any) -> Any:
|
|
48
|
+
"""CIP-V06: enforce cross-field invariants from S.12 §4.3."""
|
|
49
|
+
if isinstance(values, dict):
|
|
50
|
+
dispatched = values.get("replicas_dispatched", 0)
|
|
51
|
+
responded = values.get("replicas_responded", 0)
|
|
52
|
+
worker_id = values.get("selected_worker_id")
|
|
53
|
+
policy = values.get("policy", "")
|
|
54
|
+
vote_count = values.get("cip_vote_count")
|
|
55
|
+
quorum_threshold = values.get("cip_quorum_threshold")
|
|
56
|
+
|
|
57
|
+
if responded > dispatched:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"replicas_responded ({responded}) must not exceed "
|
|
60
|
+
f"replicas_dispatched ({dispatched})"
|
|
61
|
+
)
|
|
62
|
+
if responded == 0 and worker_id is not None:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"selected_worker_id must be null when replicas_responded == 0 (S.12 §4.3)"
|
|
65
|
+
)
|
|
66
|
+
# S.12 §4.3: cip_vote_count and cip_quorum_threshold MUST be non-null
|
|
67
|
+
# for majority_vote policy — null values indicate incomplete aggregation
|
|
68
|
+
if policy == "majority_vote":
|
|
69
|
+
if vote_count is None:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"cip_vote_count must be non-null for majority_vote policy (S.12 §4.3)"
|
|
72
|
+
)
|
|
73
|
+
if quorum_threshold is None:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"cip_quorum_threshold must be non-null for majority_vote policy (S.12 §4.3)"
|
|
76
|
+
)
|
|
77
|
+
return values
|
proxy/cip/consumer.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Phase 5A CIP Consumer Mode — safe-default stub.
|
|
3
|
+
|
|
4
|
+
The consumer allows the proxy to use remote IICP nodes as inference fallback
|
|
5
|
+
when no local model is available. Off by default; requires explicit operator
|
|
6
|
+
configuration per CIP-S1 (S.12) and ADR-012.
|
|
7
|
+
|
|
8
|
+
Safety boundary: consumer mode only receives responses — it never exposes
|
|
9
|
+
system prompt, local tools, or private memory to remote nodes.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
17
|
+
|
|
18
|
+
_VALID_CIP_POLICIES = frozenset({"best_of_n", "majority_vote", "map_reduce"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class CIPConsumerConfig:
|
|
23
|
+
"""Configuration for CIP Phase 5A Consumer Mode.
|
|
24
|
+
|
|
25
|
+
All fields default to the safest possible state (off / local-only).
|
|
26
|
+
Operators must explicitly opt in by setting ``enabled=True`` in
|
|
27
|
+
``proxy.toml`` under ``[cooperative_inference]``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
enabled: bool = False
|
|
31
|
+
policy: str = "local_only"
|
|
32
|
+
replicas: int = 1
|
|
33
|
+
fallback_to_local: bool = True
|
|
34
|
+
coordinator_timeout_ms: int = 30_000
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
# Clamp replicas to [1, 10] — safety boundary on fan-out
|
|
38
|
+
self.replicas = max(1, min(10, self.replicas))
|
|
39
|
+
# Clamp timeout to [1, 60_000] ms
|
|
40
|
+
self.coordinator_timeout_ms = max(1, min(60_000, self.coordinator_timeout_ms))
|
|
41
|
+
|
|
42
|
+
def is_remote_allowed(self) -> bool:
|
|
43
|
+
"""Return True only when consumer mode is on and policy permits remote."""
|
|
44
|
+
return self.enabled and self.policy != "local_only"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CIPCallFields(BaseModel):
|
|
48
|
+
"""CIP-V01/CIP-V02: wire-boundary validation for the `cip` object in a CIP CALL.
|
|
49
|
+
|
|
50
|
+
A Coordinator MUST validate these fields before dispatching (S.12 §4.1).
|
|
51
|
+
Invalid values → 422 IICP-E028 at parse time, except majority_vote even
|
|
52
|
+
replicas which → IICP-E025 (S.12 §3.2).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(extra="ignore")
|
|
56
|
+
|
|
57
|
+
policy: str = Field(description="CIP aggregation policy — best_of_n | majority_vote | map_reduce")
|
|
58
|
+
replicas: int = Field(ge=1, le=10, description="Number of worker nodes to fan out to [1, 10]")
|
|
59
|
+
quorum: int | None = None
|
|
60
|
+
|
|
61
|
+
@field_validator("policy", mode="before")
|
|
62
|
+
@classmethod
|
|
63
|
+
def validate_policy(cls, v: Any) -> str:
|
|
64
|
+
"""CIP-V01: cip.policy must be one of the three normative values (S.12 §4.1)."""
|
|
65
|
+
if v not in _VALID_CIP_POLICIES:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"cip.policy must be one of {sorted(_VALID_CIP_POLICIES)}, got: {v!r}"
|
|
68
|
+
)
|
|
69
|
+
return str(v)
|
|
70
|
+
|
|
71
|
+
@model_validator(mode="after")
|
|
72
|
+
def validate_majority_vote_replicas(self) -> CIPCallFields:
|
|
73
|
+
"""CIP-V02a + CIP-V07: cross-field validation after field-level checks pass.
|
|
74
|
+
|
|
75
|
+
CIP-V02a: majority_vote requires odd replicas ≥ 3 → IICP-E025 (S.12 §3.2).
|
|
76
|
+
CIP-V07: quorum MUST be null OR a positive integer ≤ replicas → IICP-E028 (S.12 §4.1).
|
|
77
|
+
|
|
78
|
+
Uses mode="after" so both policy and replicas are already validated and typed.
|
|
79
|
+
Fires at model construction — callers do not need to invoke this explicitly.
|
|
80
|
+
"""
|
|
81
|
+
if self.policy == "majority_vote":
|
|
82
|
+
if self.replicas < 3 or self.replicas % 2 == 0:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"IICP-E025: majority_vote requires odd replicas ≥ 3, "
|
|
85
|
+
f"got: {self.replicas} (S.12 §3.2)"
|
|
86
|
+
)
|
|
87
|
+
if self.quorum is not None:
|
|
88
|
+
if self.quorum < 1:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"IICP-E028: cip.quorum must be a positive integer, got: {self.quorum} (S.12 §4.1)"
|
|
91
|
+
)
|
|
92
|
+
if self.quorum > self.replicas:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"IICP-E028: cip.quorum ({self.quorum}) must not exceed "
|
|
95
|
+
f"cip.replicas ({self.replicas}) (S.12 §4.1)"
|
|
96
|
+
)
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_consumer_config = CIPConsumerConfig()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_consumer_config() -> CIPConsumerConfig:
|
|
104
|
+
return _consumer_config
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def configure_consumer(**kwargs: object) -> None:
|
|
108
|
+
global _consumer_config
|
|
109
|
+
_consumer_config = CIPConsumerConfig(**kwargs) # type: ignore[arg-type]
|