@passioncode-ai/fabric-agent-adapter 0.26.0 → 0.28.0
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.
- package/CHANGELOG.md +90 -0
- package/README.md +6 -2
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +22 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +174 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +36 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +60 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1104 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +337 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +211 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1274 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +397 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +294 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +43 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +37 -7
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -1618,9 +1618,13 @@ _PROVIDER = re.compile(r"^[a-z][a-z0-9._-]{0,63}$")
|
|
|
1618
1618
|
def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_tokens: int,
|
|
1619
1619
|
cache_read_tokens: int = 0, cache_write_tokens: int = 0,
|
|
1620
1620
|
cost_usd: Optional[float] = None, cost_basis: Optional[str] = None,
|
|
1621
|
-
at: Optional[str] = None, route: Optional[str] = None
|
|
1621
|
+
at: Optional[str] = None, route: Optional[str] = None,
|
|
1622
|
+
project_id: Optional[str] = None, task_run_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1622
1623
|
"""One model call, from the provider's own usage numbers. No prompt, output or caller.
|
|
1623
1624
|
|
|
1625
|
+
`project_id` (a URI, kept in its normal form, DEC-0076) and `task_run_id` (the service's own run id) attribute the
|
|
1626
|
+
call to the project the service ran it for (DEC-0065); they never name a caller, session or person.
|
|
1627
|
+
|
|
1624
1628
|
`cost_usd` is None when the call cannot be priced: the report counts it as unpriced and
|
|
1625
1629
|
never as $0. `cost_basis` is `provider` (the provider reported the charge) or `price-list`
|
|
1626
1630
|
(computed from a published price list); it is `unknown` when there is no cost."""
|
|
@@ -1645,9 +1649,55 @@ def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_t
|
|
|
1645
1649
|
"costUsd": None if cost_usd is None else float(cost_usd), "costBasis": basis}
|
|
1646
1650
|
if route is not None:
|
|
1647
1651
|
receipt["route"] = route # the operator's own login in its vendor's runner, an issued key, or the platform's metered models
|
|
1652
|
+
if task_run_id is not None and project_id is None:
|
|
1653
|
+
raise ServiceError("a task run belongs to a project: give project_id with task_run_id.")
|
|
1654
|
+
if project_id is not None:
|
|
1655
|
+
import fabric_identifiers # noqa: E402 - beside this file
|
|
1656
|
+
if not (isinstance(project_id, str) and _URI.fullmatch(project_id)):
|
|
1657
|
+
raise ServiceError("project_id is the URI of the project the service ran the call for.")
|
|
1658
|
+
receipt["projectId"] = fabric_identifiers.normal_identifier(project_id)
|
|
1659
|
+
if task_run_id is not None:
|
|
1660
|
+
if not (isinstance(task_run_id, str) and _TASK_RUN_ID.fullmatch(task_run_id)):
|
|
1661
|
+
raise ServiceError("task_run_id is the service's own run id: ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$.")
|
|
1662
|
+
receipt["taskRunId"] = task_run_id
|
|
1648
1663
|
return receipt
|
|
1649
1664
|
|
|
1650
1665
|
|
|
1666
|
+
_URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:\S+", re.ASCII)
|
|
1667
|
+
_TASK_RUN_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", re.ASCII)
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
def _by_project(receipts: List[Dict[str, Any]], first: str, last: str) -> List[Dict[str, Any]]:
|
|
1671
|
+
"""DEC-0065's `byProject`: the window's calls by the project the service ran them for, each project once (by its
|
|
1672
|
+
normal form, DEC-0076) and each task run once within it. A call for no project is in no row."""
|
|
1673
|
+
projects: Dict[str, Dict[str, Any]] = {}
|
|
1674
|
+
for r in receipts:
|
|
1675
|
+
if not (first <= str(r.get("at", ""))[:10] <= last) or not r.get("projectId"):
|
|
1676
|
+
continue
|
|
1677
|
+
row = projects.setdefault(r["projectId"], {"projectId": r["projectId"], "calls": 0, "unpricedCalls": 0, "_cost": 0.0, "_runs": {}})
|
|
1678
|
+
row["calls"] += 1
|
|
1679
|
+
if r.get("costUsd") is None:
|
|
1680
|
+
row["unpricedCalls"] += 1
|
|
1681
|
+
else:
|
|
1682
|
+
row["_cost"] += float(r["costUsd"])
|
|
1683
|
+
if r.get("taskRunId"):
|
|
1684
|
+
run = row["_runs"].setdefault(r["taskRunId"], {"taskRunId": r["taskRunId"], "calls": 0, "unpricedCalls": 0, "_cost": 0.0})
|
|
1685
|
+
run["calls"] += 1
|
|
1686
|
+
if r.get("costUsd") is None:
|
|
1687
|
+
run["unpricedCalls"] += 1
|
|
1688
|
+
else:
|
|
1689
|
+
run["_cost"] += float(r["costUsd"])
|
|
1690
|
+
out = []
|
|
1691
|
+
for key in sorted(projects):
|
|
1692
|
+
row = projects[key]
|
|
1693
|
+
runs = [_close_totals(run) for _, run in sorted(row.pop("_runs").items())]
|
|
1694
|
+
row = _close_totals(row)
|
|
1695
|
+
if runs:
|
|
1696
|
+
row["taskRuns"] = runs
|
|
1697
|
+
out.append(row)
|
|
1698
|
+
return out
|
|
1699
|
+
|
|
1700
|
+
|
|
1651
1701
|
def _add_totals(row: Dict[str, Any], receipt: Dict[str, Any]) -> None:
|
|
1652
1702
|
row["calls"] += 1
|
|
1653
1703
|
for name in ("inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
|
|
@@ -1664,6 +1714,246 @@ def _close_totals(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1664
1714
|
return row
|
|
1665
1715
|
|
|
1666
1716
|
|
|
1717
|
+
# --- FAC-SEM-025 whole (DEC-0021, DEC-0065, CT-40, DEC-0076): the contract's `usageRules` -----------------------------
|
|
1718
|
+
|
|
1719
|
+
_USAGE_WINDOW_DAYS = 31
|
|
1720
|
+
_DAY_MS = 86_400_000
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
def _usage_count(value: Any) -> int:
|
|
1724
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= 2 ** 53 - 1 else 0
|
|
1725
|
+
|
|
1726
|
+
|
|
1727
|
+
def _usage_cost(value: Any) -> float:
|
|
1728
|
+
return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) and value == value and value >= 0 and value != float("inf") else 0.0
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
def _exceeds(a: float, b: float) -> bool:
|
|
1732
|
+
return a - b > 0.000001 + 1e-9 * max(abs(a), abs(b))
|
|
1733
|
+
|
|
1734
|
+
|
|
1735
|
+
def _usd(value: float) -> str:
|
|
1736
|
+
rounded = round(value * 1e6) / 1e6
|
|
1737
|
+
return str(int(rounded)) if rounded == int(rounded) else repr(rounded)
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
def _utc_date_ms(value: Any) -> float:
|
|
1741
|
+
match = re.fullmatch(r"([0-9]{4})-([0-9]{2})-([0-9]{2})", value, re.ASCII) if isinstance(value, str) else None
|
|
1742
|
+
if not match:
|
|
1743
|
+
return float("nan")
|
|
1744
|
+
try:
|
|
1745
|
+
day = _dt.date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
|
1746
|
+
except ValueError:
|
|
1747
|
+
return float("nan")
|
|
1748
|
+
return float((day - _dt.date(1970, 1, 1)).days * _DAY_MS)
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
def _usage_list(value: Any) -> List[Dict[str, Any]]:
|
|
1752
|
+
return [x for x in value if isinstance(x, dict)] if isinstance(value, list) else []
|
|
1753
|
+
|
|
1754
|
+
|
|
1755
|
+
def _priced_row(row: Dict[str, Any], at: str) -> List[Dict[str, str]]:
|
|
1756
|
+
calls, unpriced = _usage_count(row.get("calls")), _usage_count(row.get("unpricedCalls"))
|
|
1757
|
+
flag = lambda path, message: [{"code": "FAC-SEM-025", "instancePath": path, "message": message}] # noqa: E731
|
|
1758
|
+
if unpriced > calls:
|
|
1759
|
+
return flag(at + "/unpricedCalls", "more unpriced calls than calls")
|
|
1760
|
+
if calls > 0 and unpriced == calls and row.get("costUsd", _MISSING_COST) is not None:
|
|
1761
|
+
return flag(at + "/costUsd", "every call is unpriced, so the cost is unknown (null), not a number")
|
|
1762
|
+
if unpriced < calls and row.get("costUsd", _MISSING_COST) is None:
|
|
1763
|
+
return flag(at + "/costUsd", "priced calls carry a cost; null is only for a row with no priced call")
|
|
1764
|
+
return []
|
|
1765
|
+
|
|
1766
|
+
|
|
1767
|
+
_MISSING_COST = object()
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def usage_findings(report: Any) -> List[Dict[str, str]]:
|
|
1771
|
+
"""FAC-SEM-025 over a usage report, in the contract's order (`src/usage-rules.ts`): the arithmetic of days and
|
|
1772
|
+
models, the window (every date exists, not after `generatedAt`'s UTC date, at most 30 days before it), and the
|
|
1773
|
+
project attribution (each project once by its normal form, each task run once; calls, priced calls and known costs
|
|
1774
|
+
bounded). Malformed input answers findings or none, never an exception."""
|
|
1775
|
+
import fabric_identifiers # noqa: E402
|
|
1776
|
+
import fabric_estate # noqa: E402 - the strict RFC 3339 reader
|
|
1777
|
+
value = report if isinstance(report, dict) else {}
|
|
1778
|
+
findings: List[Dict[str, str]] = []
|
|
1779
|
+
flag = lambda path, message: findings.append({"code": "FAC-SEM-025", "instancePath": path, "message": message}) # noqa: E731
|
|
1780
|
+
days = _usage_list(value.get("days"))
|
|
1781
|
+
# The arithmetic.
|
|
1782
|
+
previous = ""
|
|
1783
|
+
for i, day in enumerate(days):
|
|
1784
|
+
at = "/days/%d" % i
|
|
1785
|
+
date = day.get("date")
|
|
1786
|
+
date = "" if date is None else (date if isinstance(date, str) else json.dumps(date))
|
|
1787
|
+
if date <= previous:
|
|
1788
|
+
flag(at + "/date", "days must run forward without a repeat (%s after %s)" % (date, previous))
|
|
1789
|
+
previous = date
|
|
1790
|
+
findings.extend(_priced_row(day, at))
|
|
1791
|
+
models = _usage_list(day.get("byModel"))
|
|
1792
|
+
for j, m in enumerate(models):
|
|
1793
|
+
findings.extend(_priced_row(m, "%s/byModel/%d" % (at, j)))
|
|
1794
|
+
if not models:
|
|
1795
|
+
spent = any((day.get(f) or 0) > 0 for f in ("calls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens")
|
|
1796
|
+
if isinstance(day.get(f) or 0, (int, float))) or (isinstance(day.get("costUsd"), (int, float)) and day["costUsd"] > 0)
|
|
1797
|
+
if spent:
|
|
1798
|
+
flag(at + "/byModel", "a day with calls, tokens or cost names the models they belong to")
|
|
1799
|
+
continue
|
|
1800
|
+
for field in ("calls", "unpricedCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
|
|
1801
|
+
total = sum(m.get(field) or 0 for m in models if isinstance(m.get(field) or 0, (int, float)))
|
|
1802
|
+
if (day.get(field) or 0) != total:
|
|
1803
|
+
flag("%s/%s" % (at, field), "the day's %s is not the sum of its models (%s ≠ %s)" % (field, day.get(field) or 0, total))
|
|
1804
|
+
known = sum(m["costUsd"] for m in models if isinstance(m.get("costUsd"), (int, float)) and not isinstance(m.get("costUsd"), bool))
|
|
1805
|
+
day_cost = day.get("costUsd")
|
|
1806
|
+
if isinstance(day_cost, (int, float)) and not isinstance(day_cost, bool) and abs(day_cost - known) > 0.000001 + 1e-9 * max(abs(day_cost), abs(known)):
|
|
1807
|
+
flag(at + "/costUsd", "the day's cost is not the sum of its models' known costs")
|
|
1808
|
+
# The window.
|
|
1809
|
+
if days:
|
|
1810
|
+
generated = fabric_estate.estate_time(value.get("generatedAt"))
|
|
1811
|
+
if generated != generated:
|
|
1812
|
+
flag("/generatedAt", "generatedAt is an RFC 3339 date-time with an offset, so the window can be read")
|
|
1813
|
+
else:
|
|
1814
|
+
today = (generated // _DAY_MS) * _DAY_MS
|
|
1815
|
+
oldest = today - (_USAGE_WINDOW_DAYS - 1) * _DAY_MS
|
|
1816
|
+
for i, day in enumerate(days):
|
|
1817
|
+
date = _utc_date_ms(day.get("date"))
|
|
1818
|
+
if date != date:
|
|
1819
|
+
flag("/days/%d/date" % i, "a day's date exists in its month")
|
|
1820
|
+
elif date > today:
|
|
1821
|
+
flag("/days/%d/date" % i, "a day is not after the UTC date of generatedAt")
|
|
1822
|
+
elif date < oldest:
|
|
1823
|
+
flag("/days/%d/date" % i, "a day is within the last %d UTC days of generatedAt" % _USAGE_WINDOW_DAYS)
|
|
1824
|
+
# The projects.
|
|
1825
|
+
if isinstance(value.get("byProject"), list):
|
|
1826
|
+
seen: Dict[str, str] = {}
|
|
1827
|
+
project_calls, project_cost = 0, 0.0
|
|
1828
|
+
for p, project in enumerate(value["byProject"]):
|
|
1829
|
+
if not isinstance(project, dict):
|
|
1830
|
+
continue
|
|
1831
|
+
at = "/byProject/%d" % p
|
|
1832
|
+
normal = fabric_identifiers.normal_identifier(project.get("projectId"))
|
|
1833
|
+
if normal is not None:
|
|
1834
|
+
if normal in seen:
|
|
1835
|
+
flag(at + "/projectId", "each project is one row: %s names the project %s already names" % (project["projectId"], seen[normal]))
|
|
1836
|
+
else:
|
|
1837
|
+
seen[normal] = project["projectId"]
|
|
1838
|
+
project_calls += _usage_count(project.get("calls"))
|
|
1839
|
+
project_cost += _usage_cost(project.get("costUsd"))
|
|
1840
|
+
findings.extend(_priced_row(project, at))
|
|
1841
|
+
runs: List[str] = []
|
|
1842
|
+
run_calls, run_cost, run_priced = 0, 0.0, 0
|
|
1843
|
+
for r, run in enumerate(_usage_list(project.get("taskRuns"))):
|
|
1844
|
+
if isinstance(run.get("taskRunId"), str):
|
|
1845
|
+
if run["taskRunId"] in runs:
|
|
1846
|
+
flag("%s/taskRuns/%d/taskRunId" % (at, r), "each task run is one row within its project")
|
|
1847
|
+
runs.append(run["taskRunId"])
|
|
1848
|
+
run_calls += _usage_count(run.get("calls"))
|
|
1849
|
+
run_cost += _usage_cost(run.get("costUsd"))
|
|
1850
|
+
run_priced += max(0, _usage_count(run.get("calls")) - _usage_count(run.get("unpricedCalls")))
|
|
1851
|
+
findings.extend(_priced_row(run, "%s/taskRuns/%d" % (at, r)))
|
|
1852
|
+
project_priced = max(0, _usage_count(project.get("calls")) - _usage_count(project.get("unpricedCalls")))
|
|
1853
|
+
known_cost = isinstance(project.get("costUsd"), (int, float)) and not isinstance(project.get("costUsd"), bool) and _usage_cost(project.get("costUsd")) == project.get("costUsd")
|
|
1854
|
+
if run_calls > _usage_count(project.get("calls")):
|
|
1855
|
+
flag(at + "/taskRuns", "a project's task runs hold more calls than the project (%d > %d)" % (run_calls, _usage_count(project.get("calls"))))
|
|
1856
|
+
elif run_priced > project_priced:
|
|
1857
|
+
flag(at + "/taskRuns", "a project's task runs hold more priced calls than the project (%d > %d)" % (run_priced, project_priced))
|
|
1858
|
+
elif known_cost and _exceeds(run_cost, _usage_cost(project.get("costUsd"))):
|
|
1859
|
+
flag(at + "/taskRuns", "a project's task runs cost more than the project (%s > %s)" % (_usd(run_cost), _usd(_usage_cost(project.get("costUsd")))))
|
|
1860
|
+
day_calls = sum(_usage_count(d.get("calls")) for d in days)
|
|
1861
|
+
if project_calls > day_calls:
|
|
1862
|
+
flag("/byProject", "the projects hold more calls than the days (%d > %d)" % (project_calls, day_calls))
|
|
1863
|
+
day_cost_total = sum(_usage_cost(d.get("costUsd")) for d in days)
|
|
1864
|
+
if _exceeds(project_cost, day_cost_total):
|
|
1865
|
+
flag("/byProject", "the projects cost more than the days (%s > %s)" % (_usd(project_cost), _usd(day_cost_total)))
|
|
1866
|
+
return findings
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
# --- OpenRouter attribution (DEC-0065): the contract's `attributionHeaders`, off by default -----------------------------
|
|
1870
|
+
|
|
1871
|
+
OPENROUTER_CATEGORIES = ("cli-agent", "ide-extension", "cloud-agent", "programming-app", "native-app-builder",
|
|
1872
|
+
"creative-writing", "video-gen", "image-gen", "audio-gen", "writing-assistant", "general-chat",
|
|
1873
|
+
"personal-agent", "legal", "roleplay", "game")
|
|
1874
|
+
_ATTR_PRODUCT = re.compile(r"[a-z][a-z0-9-]{0,62}", re.ASCII)
|
|
1875
|
+
_ATTR_TITLE = re.compile(r"[\x21-\x7e](?:[\x20-\x7e]{0,98}[\x21-\x7e])?", re.ASCII)
|
|
1876
|
+
_ATTRIBUTION_HEADERS = ("http-referer", "referer", "x-openrouter-title", "x-title", "x-openrouter-categories")
|
|
1877
|
+
|
|
1878
|
+
|
|
1879
|
+
def attribution_setting_findings(value: Any) -> List[Dict[str, str]]:
|
|
1880
|
+
"""FAC-SEM-098: a product's setting names a product slug, a printable title and at most two recognized categories."""
|
|
1881
|
+
if not isinstance(value, dict):
|
|
1882
|
+
return [{"code": "FAC-SEM-098", "instancePath": "", "message": "an attribution setting is an object"}]
|
|
1883
|
+
findings: List[Dict[str, str]] = []
|
|
1884
|
+
flag = lambda at, message: findings.append({"code": "FAC-SEM-098", "instancePath": at, "message": message}) # noqa: E731
|
|
1885
|
+
if not (isinstance(value.get("product"), str) and _ATTR_PRODUCT.fullmatch(value["product"])):
|
|
1886
|
+
flag("/product", "the product is a lower-case slug, the last segment of its product page")
|
|
1887
|
+
if not (isinstance(value.get("title"), str) and _ATTR_TITLE.fullmatch(value["title"])):
|
|
1888
|
+
flag("/title", "the title is the product's full name: printable ASCII, 1 to 100 characters, no space at either end")
|
|
1889
|
+
if "categories" in value:
|
|
1890
|
+
categories = value["categories"]
|
|
1891
|
+
if not isinstance(categories, list):
|
|
1892
|
+
flag("/categories", "categories are a list")
|
|
1893
|
+
else:
|
|
1894
|
+
if len(categories) > 2:
|
|
1895
|
+
flag("/categories", "at most 2 categories per request")
|
|
1896
|
+
seen: List[str] = []
|
|
1897
|
+
for i, category in enumerate(categories):
|
|
1898
|
+
if not (isinstance(category, str) and category in OPENROUTER_CATEGORIES):
|
|
1899
|
+
flag("/categories/%d" % i, "a category is one OpenRouter recognizes; it drops others without an error")
|
|
1900
|
+
elif category in seen:
|
|
1901
|
+
flag("/categories/%d" % i, "each category once")
|
|
1902
|
+
else:
|
|
1903
|
+
seen.append(category)
|
|
1904
|
+
return findings
|
|
1905
|
+
|
|
1906
|
+
|
|
1907
|
+
def _is_openrouter(url: Any) -> bool:
|
|
1908
|
+
"""Whether a URL's WHATWG reading is https, host openrouter.ai, the default port and no credentials (`new URL`):
|
|
1909
|
+
leading and trailing spaces and controls trimmed, tabs and newlines removed, backslashes read as slashes, the
|
|
1910
|
+
slashes after the scheme optional, the host case-folded, port 443 the default."""
|
|
1911
|
+
if not isinstance(url, str):
|
|
1912
|
+
return False
|
|
1913
|
+
text = url.strip("".join(chr(c) for c in range(0x21))).replace("\t", "").replace("\n", "").replace("\r", "")
|
|
1914
|
+
if not text[:6].lower() == "https:":
|
|
1915
|
+
return False
|
|
1916
|
+
rest = text[6:].lstrip("/\\")
|
|
1917
|
+
end = len(rest)
|
|
1918
|
+
for stop in "/\\?#":
|
|
1919
|
+
found = rest.find(stop)
|
|
1920
|
+
if found != -1:
|
|
1921
|
+
end = min(end, found)
|
|
1922
|
+
authority = rest[:end]
|
|
1923
|
+
if "@" in authority:
|
|
1924
|
+
userinfo, authority = authority.rsplit("@", 1)
|
|
1925
|
+
if userinfo not in ("", ":"):
|
|
1926
|
+
return False
|
|
1927
|
+
host, _, port = authority.partition(":")
|
|
1928
|
+
if port and not (port.isdigit() and int(port) == 443):
|
|
1929
|
+
return False
|
|
1930
|
+
return host.lower() == "openrouter.ai"
|
|
1931
|
+
|
|
1932
|
+
|
|
1933
|
+
def attribution_headers(setting: Any, request: Any) -> Dict[str, Any]:
|
|
1934
|
+
"""The headers a product adds to one request (DEC-0065, the contract's `attributionHeaders`), or none with the reason:
|
|
1935
|
+
`no-setting` (attribution is off by default), `invalid-setting` (FAC-SEM-098), `not-openrouter` (only
|
|
1936
|
+
`https://openrouter.ai`), `forwarded` (only traffic the product originates), `user-set` (a request that already
|
|
1937
|
+
carries an attribution header keeps the user's own). Only `HTTP-Referer`, `X-OpenRouter-Title` and
|
|
1938
|
+
`X-OpenRouter-Categories`; it never reads, moves or returns a key."""
|
|
1939
|
+
req = request if isinstance(request, dict) else {}
|
|
1940
|
+
if setting is None:
|
|
1941
|
+
return {"headers": {}, "none": "no-setting"}
|
|
1942
|
+
if attribution_setting_findings(setting):
|
|
1943
|
+
return {"headers": {}, "none": "invalid-setting"}
|
|
1944
|
+
if not _is_openrouter(req.get("url")):
|
|
1945
|
+
return {"headers": {}, "none": "not-openrouter"}
|
|
1946
|
+
if req.get("originated") is not True:
|
|
1947
|
+
return {"headers": {}, "none": "forwarded"}
|
|
1948
|
+
present = [str(name).lower() for name in req["headers"]] if isinstance(req.get("headers"), dict) else []
|
|
1949
|
+
if any(name in _ATTRIBUTION_HEADERS for name in present):
|
|
1950
|
+
return {"headers": {}, "none": "user-set"}
|
|
1951
|
+
headers = {"HTTP-Referer": "https://passioncode.ai/%s/" % setting["product"], "X-OpenRouter-Title": setting["title"]}
|
|
1952
|
+
if setting.get("categories"):
|
|
1953
|
+
headers["X-OpenRouter-Categories"] = ",".join(setting["categories"])
|
|
1954
|
+
return {"headers": headers}
|
|
1955
|
+
|
|
1956
|
+
|
|
1667
1957
|
def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instance: str = "default",
|
|
1668
1958
|
now: Optional[_dt.datetime] = None,
|
|
1669
1959
|
budget: Optional[Dict[str, Any]] = None,
|
|
@@ -1714,6 +2004,9 @@ def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instanc
|
|
|
1714
2004
|
out_days.append(day)
|
|
1715
2005
|
report: Dict[str, Any] = {"protocol": PROTOCOL, "service": {"id": service_id, "instance": instance},
|
|
1716
2006
|
"generatedAt": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "currency": "USD", "days": out_days}
|
|
2007
|
+
projects = _by_project(receipts, first, last)
|
|
2008
|
+
if projects:
|
|
2009
|
+
report["byProject"] = projects
|
|
1717
2010
|
if budget is not None:
|
|
1718
2011
|
if budget.get("period") not in ("day", "month") or not isinstance(budget.get("limitUsd"), (int, float)) or budget["limitUsd"] <= 0:
|
|
1719
2012
|
raise ServiceError("budget is {period: day|month, limitUsd > 0}.")
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py
CHANGED
|
@@ -9,6 +9,11 @@ once, and `sample.draft` is a job that stops for a titled choice before it compl
|
|
|
9
9
|
|
|
10
10
|
sample_service.py serve --port 47190 --data-dir DIR [--id sample] [--instance default]
|
|
11
11
|
sample_service.py register --port 47190 --data-dir DIR [--services-dir DIR]
|
|
12
|
+
|
|
13
|
+
Opt-in, off by default: `serve --board-project <Project uuid>` also answers that Project's `sample.echo` requests on
|
|
14
|
+
Fabric's project board (fabric-project-comms/0.1, FAA-12b) with fabric_board.Responder, from a board.respond grant whose
|
|
15
|
+
credential is in `<data>/board.credential` (0600; `--board-credential` names another file). The request body is data:
|
|
16
|
+
the gate admits plain text of at most 2,000 characters, and the answer is a reply carrying the same text.
|
|
12
17
|
"""
|
|
13
18
|
|
|
14
19
|
from __future__ import annotations
|
|
@@ -27,6 +32,8 @@ from urllib.parse import parse_qs, urlparse
|
|
|
27
32
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
28
33
|
import fabric_service as fs # noqa: E402
|
|
29
34
|
import fabric_interop as fi # noqa: E402
|
|
35
|
+
import fabric_board as fbd # noqa: E402 - the board responder (opt-in)
|
|
36
|
+
import fabric_hub as fhb # noqa: E402
|
|
30
37
|
|
|
31
38
|
VERSION = "0.1.0"
|
|
32
39
|
# The dashboard is built from the dashboard kit (fabric-dashboard/0.1, DEC-0036) and declares it
|
|
@@ -77,6 +84,10 @@ class Service:
|
|
|
77
84
|
self.degraded = [{"source": "demo", "reason": args.degraded}] if args.degraded else []
|
|
78
85
|
self.started_at = fs.now_iso()
|
|
79
86
|
self.build = {"commit": os.environ.get("FABRIC_BUILD_COMMIT", "0000000"), "builtAt": self.started_at}
|
|
87
|
+
self.board_project = getattr(args, "board_project", None)
|
|
88
|
+
self.board_capability = getattr(args, "board_capability", None) or "sample.echo"
|
|
89
|
+
self.board_credential = Path(args.board_credential) if getattr(args, "board_credential", None) else self.data / "board.credential"
|
|
90
|
+
self.board: Optional[fbd.Responder] = None
|
|
80
91
|
self.drain = fs.Drain() # LC-01: SIGTERM drains in-flight calls, then the server stops
|
|
81
92
|
|
|
82
93
|
def start(self) -> None:
|
|
@@ -93,8 +104,26 @@ class Service:
|
|
|
93
104
|
for cap in CAPABILITIES:
|
|
94
105
|
handler = self.echo if cap["name"] == "sample.echo" else self.draft
|
|
95
106
|
self.mcp.add_tool(fi.tool_for_capability(cap, cap["input"], cap["output"]), handler)
|
|
107
|
+
if self.board_project:
|
|
108
|
+
# After the lock, like every other piece of state: the responder's checkpoint lives in the data directory.
|
|
109
|
+
self.board = fbd.Responder(hub=fhb.HubClient(), project_id=self.board_project, capabilities=[self.board_capability],
|
|
110
|
+
credential_file=self.board_credential, checkpoint_file=self.data / "board-checkpoint.json",
|
|
111
|
+
handler=self.board_answer, admit=self.board_admit, emit=self.log.append, service=self.id)
|
|
96
112
|
self.log.append("service.started", "info", "%s started on build %s." % (self.name, self.build["commit"]))
|
|
97
113
|
|
|
114
|
+
# --- the project board (opt-in): the input gate and the answer -------------------
|
|
115
|
+
def board_admit(self, request: fbd.BoardRequest) -> Optional[str]:
|
|
116
|
+
"""The service's own input gate: the body is data, never an instruction; only short plain text is echoed."""
|
|
117
|
+
if request.body_format != "text/plain":
|
|
118
|
+
return "this service echoes plain text only"
|
|
119
|
+
if len(request.body or "") > 2000:
|
|
120
|
+
return "the text is longer than the 2,000 characters this service echoes"
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def board_answer(self, request: fbd.BoardRequest, ctx: fbd.WorkContext) -> Dict[str, Any]:
|
|
124
|
+
# No effect outside the service: no begin_effect, so the outcome needs no observation.
|
|
125
|
+
return {"state": "completed", "summary": "Echoed the request's text.", "reply": {"text": request.body}}
|
|
126
|
+
|
|
98
127
|
# --- fabric-interop/0.1: the two capabilities ------------------------------------
|
|
99
128
|
def echo(self, args: Dict[str, Any], ctx: fi.CallContext) -> Dict[str, Any]:
|
|
100
129
|
if not isinstance(args.get("text"), str):
|
|
@@ -145,6 +174,8 @@ class Service:
|
|
|
145
174
|
"mcp": {"path": "/mcp", "transport": "streamable-http", "capabilities": [c["name"] for c in CAPABILITIES]},
|
|
146
175
|
"usage": {"path": fs.USAGE_PATH}},
|
|
147
176
|
extensions=fs.dashboard_extension() if (KIT_DIR / "fabric-dashboard.css").is_file() else None,
|
|
177
|
+
# DEC-0067: listed only when the board is on — a host pinned before DEC-0067 refuses the member.
|
|
178
|
+
protocols=["fabric-service/0.1", "fabric-project-comms/0.1"] if self.board is not None else None,
|
|
148
179
|
)
|
|
149
180
|
|
|
150
181
|
|
|
@@ -291,8 +322,15 @@ def serve(args: argparse.Namespace) -> int:
|
|
|
291
322
|
|
|
292
323
|
# Stop taking calls, let in-flight ones finish (8 s at most), then end serve_forever; a
|
|
293
324
|
# hand-over that hangs is cut by the kit's hard exit, inside launchd's ExitTimeOut.
|
|
294
|
-
|
|
295
|
-
|
|
325
|
+
import threading
|
|
326
|
+
board_stop = threading.Event()
|
|
327
|
+
if svc.board is not None:
|
|
328
|
+
threading.Thread(target=svc.board.run, args=(board_stop, 5.0), name="board-responder", daemon=True).start()
|
|
329
|
+
|
|
330
|
+
def stopping() -> None:
|
|
331
|
+
board_stop.set() # stop claiming; the responder drains its request and leaves its board session
|
|
332
|
+
svc.log.append("service.stopping", "info", "%s is stopping." % svc.name)
|
|
333
|
+
svc.drain.install(lambda drained: server.shutdown(), on_stopping=stopping)
|
|
296
334
|
try:
|
|
297
335
|
server.serve_forever(poll_interval=0.2)
|
|
298
336
|
finally:
|
|
@@ -377,6 +415,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
377
415
|
p.add_argument("--name", default="Sample Service")
|
|
378
416
|
if name == "serve":
|
|
379
417
|
p.add_argument("--degraded", help="report one degraded source with this reason")
|
|
418
|
+
p.add_argument("--board-project", help="opt-in: answer this Project's requests on Fabric's project board")
|
|
419
|
+
p.add_argument("--board-capability", default="sample.echo")
|
|
420
|
+
p.add_argument("--board-credential", help="the board.respond credential file (0600); default <data>/board.credential")
|
|
380
421
|
else:
|
|
381
422
|
p.add_argument("--services-dir")
|
|
382
423
|
p.add_argument("--label")
|
|
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.28.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Creating Fabric-compatible agents
|
|
@@ -123,7 +123,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
|
|
|
123
123
|
The effect class is declared, never defaulted: ask the author what the agent does outside itself when the request
|
|
124
124
|
does not say. Cost presets are only for a run whose cost varies; their amounts come from the author or measured runs.
|
|
125
125
|
|
|
126
|
-
Pin exactly contract `0.1.0` at commit `
|
|
126
|
+
Pin exactly contract `0.1.0` at commit `f00a7685e3861c27f7899c033fb850a145a1708f` and
|
|
127
127
|
read the pinned guide before implementing protocol details. If this skill is installed
|
|
128
128
|
without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
|
|
129
129
|
contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
|
|
@@ -9,6 +9,8 @@ This page is how a coding agent with this plugin follows it; where the two diffe
|
|
|
9
9
|
|
|
10
10
|
- Board first, at the start and whenever you are idle
|
|
11
11
|
- Asking another Project
|
|
12
|
+
- The project directory
|
|
13
|
+
- Answering a request addressed to your Project
|
|
12
14
|
- Replying
|
|
13
15
|
- What a message is, and what never goes into one
|
|
14
16
|
- Hearing about new mail
|
|
@@ -37,13 +39,39 @@ capability (a plain question may be `kind: "message"`), in a new thread whose pa
|
|
|
37
39
|
arguments: the board takes identity from the authenticated session and refuses them (`invalid_arguments`).
|
|
38
40
|
- Retrying after a lost answer: the **same idempotency key with the same arguments**, so the board returns the first
|
|
39
41
|
receipt. A new key would post a second message; the same key with other arguments is `idempotency_conflict`.
|
|
40
|
-
- No Project id in hand:
|
|
41
|
-
|
|
42
|
+
- No Project id in hand: look it up in the project directory (below). Where Fabric does not serve it yet, say that
|
|
43
|
+
addressing needs an id you already hold from a thread your Project takes part in, or from the operator, and ask the
|
|
44
|
+
person. Never guess an id from a folder or repository name.
|
|
42
45
|
- Not a cross-project ask: another repository's HANDOFF, an agent-sync mailbox, a file in another checkout,
|
|
43
46
|
SendMessage between sessions. agent-sync leases guard files; they carry no asks.
|
|
44
47
|
|
|
45
48
|
A request the board stored has not been carried out. Only its state (`completed`, `failed_known`) says what happened.
|
|
46
49
|
|
|
50
|
+
## The project directory
|
|
51
|
+
|
|
52
|
+
- **A project directory.** The contract defines one: `com.directory`, the seventeenth `com.*` tool
|
|
53
|
+
([DEC-0073](https://github.com/passioncode-ai/fabric-agent-contract/blob/main/docs/DECISIONS.md),
|
|
54
|
+
[project-comms.md, "Project directory"](https://github.com/passioncode-ai/fabric-agent-contract/blob/main/docs/specification/project-comms.md#project-directory)).
|
|
55
|
+
It lists the Projects of your estate that you may address: each one's id, name, listed capabilities and, per
|
|
56
|
+
capability, the responder state `com.status` reports. It never shows content: no message, subject, thread,
|
|
57
|
+
participant or count. Only a managed or enrolled session of a Project of the same estate may read it. Your own
|
|
58
|
+
Project is not listed, and the operator can unlist a Project, which a sender that already knows its id can still
|
|
59
|
+
address. A listed capability is not an accepted request. Fabric's `main` serves `com.directory` (COM-03); an
|
|
60
|
+
installed Fabric built before it answers `unsupported_capability`. Until Fabric serves `com.directory`, address a
|
|
61
|
+
Project only by an id you already hold from a thread your Project takes part in, or from the operator. Never guess
|
|
62
|
+
an id from a folder name.
|
|
63
|
+
- Before looking a Project up, check the page: each Project once and each capability once per Project, ids and
|
|
64
|
+
capability names compared exactly as written (DEC-0077, `FAC-SEM-107`). A page that lists one twice is refused, not
|
|
65
|
+
read; the service kit's `fabric_board.find_project` / `findProject` does both.
|
|
66
|
+
|
|
67
|
+
## Answering a request addressed to your Project
|
|
68
|
+
|
|
69
|
+
Answering it as a responder is `com.enroll` then `com.claim`, under a `board.respond` grant the operator gives in
|
|
70
|
+
Fabric (Agents & access); the request then moves with `com.accept` and `com.complete`. Without that grant, reply with
|
|
71
|
+
`com.reply` (or `com.submit` `kind: "reply"` where `com.reply` is not served) and say the request stays queued: a reply
|
|
72
|
+
is not a completion. A product agent that answers requests as a service uses the service kit's responder
|
|
73
|
+
(building-fabric-services, `references/board.md`).
|
|
74
|
+
|
|
47
75
|
## Replying
|
|
48
76
|
|
|
49
77
|
Until Fabric serves `com.reply` (COM-02.3), answer with `com.submit`, `kind: "reply"`, `replyTo` the message id, in
|
|
@@ -92,8 +120,10 @@ personal data or private path in a body or an artifact reference: name a vault s
|
|
|
92
120
|
|
|
93
121
|
Each is written "until … ships"; none has a date:
|
|
94
122
|
|
|
95
|
-
- **COM-02.3** — `com.reply` and `com.cancel`.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
123
|
+
- **COM-02.3** — `com.reply` and `com.cancel`. Built on Fabric's `main`, not in an installed release yet: until the
|
|
124
|
+
running Fabric lists them, a reply is a `com.submit` (above).
|
|
125
|
+
- **COM-03** — responder enrollment. Built on Fabric's `main` (ADR-0149), not in an installed release yet: there,
|
|
126
|
+
no agent can claim, accept or complete a request, `com.status` lists no responders, and the addressed Project's agent
|
|
127
|
+
answers with its own `com.submit`. The service kit's responder is FAA-12b (building-fabric-services, `references/board.md`).
|
|
128
|
+
- **The project directory** — decided (DEC-0073) and served by Fabric's `main`; an installed Fabric built before
|
|
129
|
+
COM-03 answers `unsupported_capability` (see "The project directory").
|
|
@@ -60,7 +60,7 @@ WATCH_MAX_BACKOFF_S = 900
|
|
|
60
60
|
SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
|
|
61
61
|
LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
|
|
62
62
|
LOCAL_ORIGIN = re.compile(r'"origin": "http://(?:127\.0\.0\.1|localhost)')
|
|
63
|
-
VERSION = "0.
|
|
63
|
+
VERSION = "0.28.0"
|
|
64
64
|
HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code"}
|
|
65
65
|
# A session Fabric launched. FABRIC_LAUNCHED_SESSION=1 is the marker Fabric is asked to set (need D6); the others are
|
|
66
66
|
# what Fabric already gives a launched session (fabric sessionBundle.ts): only their NAMES are looked at, never a value
|