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.
Files changed (48) hide show
  1. iicp_proxy-0.2.0.dist-info/METADATA +23 -0
  2. iicp_proxy-0.2.0.dist-info/RECORD +48 -0
  3. iicp_proxy-0.2.0.dist-info/WHEEL +5 -0
  4. iicp_proxy-0.2.0.dist-info/entry_points.txt +2 -0
  5. iicp_proxy-0.2.0.dist-info/top_level.txt +1 -0
  6. proxy/__init__.py +2 -0
  7. proxy/address_state.py +29 -0
  8. proxy/anthropic_compat/__init__.py +1 -0
  9. proxy/anthropic_compat/server.py +207 -0
  10. proxy/anthropic_compat/translator.py +101 -0
  11. proxy/auth/__init__.py +4 -0
  12. proxy/auth/secrets.py +18 -0
  13. proxy/cip/__init__.py +1 -0
  14. proxy/cip/aggregation.py +77 -0
  15. proxy/cip/consumer.py +109 -0
  16. proxy/cip/coordinator.py +108 -0
  17. proxy/cip/dispatch.py +141 -0
  18. proxy/cip/gates.py +250 -0
  19. proxy/cip/receipts.py +154 -0
  20. proxy/cip/strategies.py +100 -0
  21. proxy/clients/__init__.py +5 -0
  22. proxy/clients/did_resolver.py +99 -0
  23. proxy/clients/directory.py +360 -0
  24. proxy/clients/node.py +130 -0
  25. proxy/clients/replica_registry.py +143 -0
  26. proxy/clients/replica_sig_verifier.py +98 -0
  27. proxy/clients/trust_resolver.py +127 -0
  28. proxy/config.py +139 -0
  29. proxy/main.py +188 -0
  30. proxy/metrics.py +30 -0
  31. proxy/network/__init__.py +4 -0
  32. proxy/network/peer_cache.py +118 -0
  33. proxy/ollama_compat/__init__.py +1 -0
  34. proxy/ollama_compat/server.py +187 -0
  35. proxy/ollama_compat/translator.py +96 -0
  36. proxy/openai_compat/__init__.py +5 -0
  37. proxy/openai_compat/server.py +130 -0
  38. proxy/openai_compat/translator.py +38 -0
  39. proxy/otel_tracer.py +190 -0
  40. proxy/plugin/__init__.py +2 -0
  41. proxy/plugin/iicp_provider.py +76 -0
  42. proxy/routing/__init__.py +15 -0
  43. proxy/routing/aggregator.py +115 -0
  44. proxy/routing/circuit_breaker.py +92 -0
  45. proxy/routing/fallback.py +259 -0
  46. proxy/routing/retry.py +96 -0
  47. proxy/routing/router.py +100 -0
  48. 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ iicp-proxy = proxy.main:run
@@ -0,0 +1 @@
1
+ proxy
proxy/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """IICP client proxy — routes tasks to discovered adapter nodes."""
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
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ from .secrets import load_node_token
3
+
4
+ __all__ = ["load_node_token"]
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
@@ -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]