create-workframe 0.1.5 → 0.1.6

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 (181) hide show
  1. package/.dockerignore +22 -22
  2. package/.gitignore +73 -73
  3. package/LICENSE +201 -201
  4. package/NOTICE +12 -12
  5. package/README.md +40 -40
  6. package/SECURITY.md +38 -38
  7. package/bin/create-workframe.js +2816 -2814
  8. package/bin/workframe.js +329 -329
  9. package/docs/workspace-instructions/WORKFRAME_DISCORD.md +20 -20
  10. package/docs/workspace-instructions/WORKFRAME_DOCUMENTS_AND_ARTIFACTS.md +20 -20
  11. package/docs/workspace-instructions/WORKFRAME_KANBAN.md +20 -20
  12. package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +21 -21
  13. package/docs/workspace-instructions/WORKFRAME_ROUTING.md +29 -29
  14. package/docs/workspace-instructions/WORKFRAME_TELEGRAM.md +19 -19
  15. package/package.json +64 -64
  16. package/profiles/README.md +15 -15
  17. package/profiles/architect/AGENTS.md +29 -29
  18. package/profiles/architect/SOUL.md +44 -44
  19. package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
  20. package/profiles/designer/AGENTS.md +26 -26
  21. package/profiles/designer/SOUL.md +31 -31
  22. package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
  23. package/profiles/dev/AGENTS.md +28 -28
  24. package/profiles/dev/SOUL.md +31 -31
  25. package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
  26. package/profiles/docs/AGENTS.md +27 -27
  27. package/profiles/docs/SOUL.md +30 -30
  28. package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
  29. package/profiles/research/AGENTS.md +26 -26
  30. package/profiles/research/SOUL.md +31 -31
  31. package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
  32. package/profiles/visionary/AGENTS.md +25 -25
  33. package/profiles/visionary/SOUL.md +31 -31
  34. package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
  35. package/profiles/workframe-agent/AGENTS.md +37 -37
  36. package/profiles/workframe-agent/SETUP.md +185 -185
  37. package/profiles/workframe-agent/SOUL.md +61 -61
  38. package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
  39. package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
  40. package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
  41. package/prompts/WORKFRAME_PROMPT_TEMPLATES.md +16 -16
  42. package/rules/.hermes.md +11 -11
  43. package/rules/AGENTS.md +22 -22
  44. package/rules/workspace-README.md +5 -5
  45. package/scripts/apply-update-workframe.sh +91 -21
  46. package/scripts/bundle-workframe-ui.mjs +83 -83
  47. package/scripts/create_workframe_scaffold.py +648 -648
  48. package/scripts/ensure-compose-host-paths.mjs +51 -51
  49. package/scripts/lib/install-identity.mjs +212 -212
  50. package/scripts/lib/workframe-registry.mjs +290 -290
  51. package/scripts/new-project.mjs +68 -68
  52. package/scripts/select_agent_pack.py +31 -31
  53. package/scripts/set-compose-public-url.mjs +92 -92
  54. package/scripts/sync-canonical-to-package.mjs +164 -164
  55. package/shared/WORKFRAME_AGENT_LIBRARY.md +31 -31
  56. package/shared/WORKFRAME_AGENT_OPERATIONS.md +29 -29
  57. package/shared/WORKFRAME_AGENT_PACKS.json +64 -64
  58. package/shared/WORKFRAME_AGENT_PACKS.yaml +20 -20
  59. package/shared/WORKFRAME_CHAT_PERMISSION_MODEL.md +20 -20
  60. package/shared/WORKFRAME_HANDOFF_SCHEMA.md +25 -25
  61. package/shared/WORKFRAME_SKILL_CURATION.md +27 -27
  62. package/workframe-api/Dockerfile +14 -14
  63. package/workframe-api/README.md +24 -24
  64. package/workframe-api/action_proxy.py +131 -131
  65. package/workframe-api/auth_rate_limit.py +49 -49
  66. package/workframe-api/credential_vault.py +445 -445
  67. package/workframe-api/data/avatar-catalog.json +41 -41
  68. package/workframe-api/data/logo-catalog.json +14 -14
  69. package/workframe-api/data/user-avatar-catalog.json +18 -18
  70. package/workframe-api/email_sender.py +220 -220
  71. package/workframe-api/google_auth.py +90 -90
  72. package/workframe-api/install_api.py +359 -359
  73. package/workframe-api/internal_proxy_auth.py +150 -150
  74. package/workframe-api/llm_proxy.py +280 -280
  75. package/workframe-api/oidc_jwt.py +108 -108
  76. package/workframe-api/package.json +12 -12
  77. package/workframe-api/platform_auth.py +194 -194
  78. package/workframe-api/profile_secret_policy.py +86 -86
  79. package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
  80. package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
  81. package/workframe-api/public/assets/index-DglUqFB_.js +8 -8
  82. package/workframe-api/public/index.html +12 -12
  83. package/workframe-api/requirements.txt +2 -2
  84. package/workframe-api/server.py +19866 -19866
  85. package/workframe-api/site_meta.py +271 -271
  86. package/workframe-api/stack_config.py +427 -427
  87. package/workframe-api/time-bind-chat.py +99 -99
  88. package/workframe-api/turn_credentials.py +226 -226
  89. package/workframe-api/updates.py +506 -506
  90. package/workframe-api/vault_kek.py +159 -159
  91. package/workframe-api/zk_auth.py +633 -633
  92. package/workframe-supervisor/Dockerfile +11 -11
  93. package/workframe-supervisor/profile_secret_policy.py +76 -76
  94. package/workframe-supervisor/server.py +860 -860
  95. package/workframe-ui/docker/nginx.conf +85 -85
  96. package/workframe-ui/public/assets/{arc-CnriPN3G.js → arc-DoYr2T5c.js} +1 -1
  97. package/workframe-ui/public/assets/architecture-7EHR7CIX-CmDi28hh.js +1 -0
  98. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DYYB1Ic2.js → architectureDiagram-3BPJPVTR-Clher08G.js} +1 -1
  99. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-Bn-fFOyj.js → blockDiagram-GPEHLZMM-Bdw9txrZ.js} +1 -1
  100. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BPPDtAnD.js → c4Diagram-AAUBKEIU-DlI_h_uo.js} +1 -1
  101. package/workframe-ui/public/assets/channel-DZYVoEdD.js +1 -0
  102. package/workframe-ui/public/assets/{chunk-2J33WTMH-Bz2CJiC3.js → chunk-2J33WTMH-ol-uwoeq.js} +1 -1
  103. package/workframe-ui/public/assets/{chunk-3OPIFGDE-B8aMeE1B.js → chunk-3OPIFGDE-7eDhBen0.js} +1 -1
  104. package/workframe-ui/public/assets/{chunk-4BX2VUAB-bLFHQTuT.js → chunk-4BX2VUAB-xPORD7Js.js} +1 -1
  105. package/workframe-ui/public/assets/{chunk-55IACEB6-BRxhx-lG.js → chunk-55IACEB6-Cwd7dr1z.js} +1 -1
  106. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BLyOgEJ8.js → chunk-5ZQYHXKU-Ckt4Sjy1.js} +1 -1
  107. package/workframe-ui/public/assets/{chunk-727SXJPM-DiuvBl7G.js → chunk-727SXJPM-sMk1op_G.js} +1 -1
  108. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-BE1_aBdC.js → chunk-AQP2D5EJ-CMgSvr6H.js} +1 -1
  109. package/workframe-ui/public/assets/{chunk-BSJP7CBP-BicGsmBs.js → chunk-BSJP7CBP-Dx7GN0T4.js} +1 -1
  110. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-C3VTns3d.js → chunk-CSCIHK7Q--OCu86u0.js} +1 -1
  111. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DSiXY3bY.js → chunk-FMBD7UC4-CejmwJpz.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-KSCS5N6A-Cvw5s3fR.js → chunk-KSCS5N6A-CvOj2Yvp.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-jJifGdIQ.js → chunk-L5ZTLDWV-B0nGBjLU.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-LZXEDZCA-Maee1aqo.js → chunk-LZXEDZCA-Bdzvu227.js} +2 -2
  115. package/workframe-ui/public/assets/{chunk-ND2GUHAM-C77N3VAM.js → chunk-ND2GUHAM-CGR07jTY.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-NZK2D7GU-BY68lrPB.js → chunk-NZK2D7GU-CBojDPNC.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-O5CBEL6O-D_c4kWzh.js → chunk-O5CBEL6O-DBin5MPF.js} +1 -1
  118. package/workframe-ui/public/assets/chunk-QZHKN3VN-DwiajAaK.js +1 -0
  119. package/workframe-ui/public/assets/chunk-WU5MYG2G-Bz8vA5s7.js +1 -0
  120. package/workframe-ui/public/assets/{chunk-XPW4576I-DlQgvqBp.js → chunk-XPW4576I-TD7I7pcb.js} +1 -1
  121. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DQiq8od0.js +1 -0
  122. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DQiq8od0.js +1 -0
  123. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CHCOHDSd.js → cose-bilkent-S5V4N54A-Bmm5x5xm.js} +1 -1
  124. package/workframe-ui/public/assets/{dagre-BM42HDAG-CKPcSYwU.js → dagre-BM42HDAG-zU2r0aq2.js} +1 -1
  125. package/workframe-ui/public/assets/{diagram-2AECGRRQ-C6qe0jP-.js → diagram-2AECGRRQ-AL01qukm.js} +1 -1
  126. package/workframe-ui/public/assets/{diagram-5GNKFQAL-DhQhrieU.js → diagram-5GNKFQAL-DKi1xSMg.js} +1 -1
  127. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Bqf6Iw1r.js → diagram-KO2AKTUF-DQJDQ-f-.js} +1 -1
  128. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt1ms2D2.js → diagram-LMA3HP47-B1oisGUj.js} +1 -1
  129. package/workframe-ui/public/assets/{diagram-OG6HWLK6-C0Rrxe1i.js → diagram-OG6HWLK6-DjpJKffk.js} +1 -1
  130. package/workframe-ui/public/assets/{dist-BLcLhTn6.js → dist-Jk20AnQG.js} +1 -1
  131. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-Cy1GbZVu.js → erDiagram-TEJ5UH35-Vu8RtLUf.js} +1 -1
  132. package/workframe-ui/public/assets/eventmodeling-FCH6USID-BXUnTe0c.js +1 -0
  133. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-DL3ALqDG.js → flowDiagram-I6XJVG4X-CHAXEhbR.js} +1 -1
  134. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-CMpwBhXQ.js → ganttDiagram-6RSMTGT7-DhiEkfH8.js} +1 -1
  135. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-C0mwHUXL.js → gitGraph-WXDBUCRP-Cuq44XvU.js} +1 -1
  136. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-6gzhWaF0.js → gitGraphDiagram-PVQCEYII-B7kgReRt.js} +1 -1
  137. package/workframe-ui/public/assets/index-72-j0sA5.js +129 -0
  138. package/workframe-ui/public/assets/index-auJ0bLMU.css +1 -0
  139. package/workframe-ui/public/assets/{info-J43DQDTF-Bb_wZTxU.js → info-J43DQDTF-oV4b3M4f.js} +1 -1
  140. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-601evYFV.js → infoDiagram-5YYISTIA-CthN4b6o.js} +1 -1
  141. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-BYtF-wHm.js → ishikawaDiagram-YF4QCWOH-oEGS3TKg.js} +1 -1
  142. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-BMizyHzC.js → journeyDiagram-JHISSGLW-CXJsWmKF.js} +1 -1
  143. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CLLJsdnh.js → kanban-definition-UN3LZRKU-DTPo8yHW.js} +1 -1
  144. package/workframe-ui/public/assets/{line-BFiSvu-q.js → line-Bm9Pmcnz.js} +1 -1
  145. package/workframe-ui/public/assets/{linear-D6HYZ18_.js → linear-Cn-uD6eX.js} +1 -1
  146. package/workframe-ui/public/assets/{mermaid-parser.core-Cid5yDBQ.js → mermaid-parser.core-BBVSTQZ4.js} +2 -2
  147. package/workframe-ui/public/assets/{mermaid.core-DqIiju5Z.js → mermaid.core-DE668nUy.js} +3 -3
  148. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-Bg7xJdsz.js → mindmap-definition-RKZ34NQL-4qVsiwCf.js} +1 -1
  149. package/workframe-ui/public/assets/{packet-YPE3B663-1ebGt86D.js → packet-YPE3B663-CqJeoiFG.js} +1 -1
  150. package/workframe-ui/public/assets/{pie-LRSECV5Y-DJ9wOBC_.js → pie-LRSECV5Y-DcOqVssg.js} +1 -1
  151. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-BmK96Aus.js → pieDiagram-4H26LBE5-CosMnpAM.js} +1 -1
  152. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-ByUqtWPZ.js → quadrantDiagram-W4KKPZXB-DtsnIK98.js} +1 -1
  153. package/workframe-ui/public/assets/{radar-GUYGQ44K-DN-Hkt96.js → radar-GUYGQ44K-BqLpJuWc.js} +1 -1
  154. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-ByU2r8gb.js → requirementDiagram-4Y6WPE33-Pb7cuX8_.js} +1 -1
  155. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-elyWa0GK.js → sankeyDiagram-5OEKKPKP-BJBlMM1m.js} +1 -1
  156. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-CzZwsAiK.js → sequenceDiagram-3UESZ5HK-i4HnjP39.js} +1 -1
  157. package/workframe-ui/public/assets/{src-5vgsM_dl.js → src-DIhmc_XG.js} +1 -1
  158. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-B0O5w5lJ.js → stateDiagram-AJRCARHV-Ba2iClPT.js} +1 -1
  159. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-aUpqRAhy.js +1 -0
  160. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-oyqa5w5y.js → timeline-definition-PNZ67QCA-CL4X9vSF.js} +1 -1
  161. package/workframe-ui/public/assets/{treeView-BLDUP644-MCV12-eP.js → treeView-BLDUP644-BJrjnbMj.js} +1 -1
  162. package/workframe-ui/public/assets/{treemap-LRROVOQU-DiA1P9n5.js → treemap-LRROVOQU-CN6H54RF.js} +1 -1
  163. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CO_4x7OR.js → vennDiagram-CIIHVFJN-Cee6sbUy.js} +1 -1
  164. package/workframe-ui/public/assets/{wardley-L42UT6IY-CmQNv6Xv.js → wardley-L42UT6IY-D0cXq60c.js} +1 -1
  165. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-CJxDkbIj.js → wardleyDiagram-YWT4CUSO-CaH9XkCx.js} +1 -1
  166. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-B02MxgzG.js → xychartDiagram-2RQKCTM6-BEisgFoI.js} +1 -1
  167. package/workframe-ui/public/favicon.svg +7 -7
  168. package/workframe-ui/public/icons.svg +24 -24
  169. package/workframe-ui/public/index.html +47 -47
  170. package/workframe-ui/public/manifest.webmanifest +18 -18
  171. package/workframe-ui/public/workframe-config.json +3 -3
  172. package/workframe-ui/public/assets/architecture-7EHR7CIX-CFhr7xAe.js +0 -1
  173. package/workframe-ui/public/assets/channel-Dok_-T0o.js +0 -1
  174. package/workframe-ui/public/assets/chunk-QZHKN3VN-C3wwLrTa.js +0 -1
  175. package/workframe-ui/public/assets/chunk-WU5MYG2G-GFFSg2kf.js +0 -1
  176. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DKbGfT5Z.js +0 -1
  177. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DKbGfT5Z.js +0 -1
  178. package/workframe-ui/public/assets/eventmodeling-FCH6USID-C_PrceHe.js +0 -1
  179. package/workframe-ui/public/assets/index-DyO6K0TD.css +0 -1
  180. package/workframe-ui/public/assets/index-yvpSUs8T.js +0 -133
  181. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-RXZRaSgT.js +0 -1
@@ -1,280 +1,280 @@
1
- """Internal LLM proxy — Hermes sends lease tokens; API vault supplies upstream keys."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- import re
8
- import socket
9
- import ssl
10
- import urllib.error
11
- import urllib.request
12
- from typing import Any, Callable
13
- from http.server import BaseHTTPRequestHandler
14
-
15
- import internal_proxy_auth
16
- import turn_credentials
17
-
18
- LEASE_PREFIX = turn_credentials.LEASE_PREFIX
19
-
20
- UPSTREAM_BASE: dict[str, str] = {
21
- "openrouter": "https://openrouter.ai/api/v1",
22
- "openai": "https://api.openai.com/v1",
23
- "anthropic": "https://api.anthropic.com",
24
- "google": "https://generativelanguage.googleapis.com/v1beta",
25
- "deepseek": "https://api.deepseek.com/v1",
26
- }
27
-
28
- PROXY_PATH_RE = re.compile(r"^/internal/llm/([a-z0-9_-]+)(/.*)?$", re.IGNORECASE)
29
-
30
-
31
- def normalize_upstream_path(base: str, subpath: str) -> str:
32
- """Drop a leading /v1 when upstream base already ends with /v1.
33
-
34
- Hermes uses model.base_url …/internal/llm/openrouter/v1; the OpenAI client
35
- then requests …/v1/chat/completions, which we forward as subpath /v1/….
36
- """
37
- path = subpath if str(subpath or "").startswith("/") else f"/{subpath or ''}"
38
- base_norm = str(base or "").rstrip("/")
39
- if path == "/v1":
40
- return ""
41
- if path.startswith("/v1/") and base_norm.endswith("/v1"):
42
- return path[3:]
43
- return path
44
-
45
-
46
- def is_internal_client(host: str) -> bool:
47
- """Allow docker/private callers only — not public browser origins."""
48
- return internal_proxy_auth.is_internal_client(host)
49
-
50
-
51
- def authorize_internal_proxy(handler: BaseHTTPRequestHandler) -> tuple[bool, str]:
52
- return internal_proxy_auth.authorize_internal_proxy(handler)
53
-
54
-
55
- def extract_profile_slug(headers: dict[str, str]) -> str:
56
- return str(
57
- headers.get(internal_proxy_auth.PROFILE_HEADER)
58
- or headers.get("x-workframe-profile")
59
- or ""
60
- ).strip()
61
-
62
-
63
- def validate_lease_profile(
64
- lease: dict[str, Any],
65
- headers: dict[str, str],
66
- ) -> tuple[bool, str, int]:
67
- """Bind bearer lease to calling Hermes profile (0022 N2 / 0023 C1)."""
68
- want = str(lease.get("profile_slug") or "").strip()
69
- if not want:
70
- return True, "", 0
71
- got = extract_profile_slug(headers)
72
- if not got:
73
- return False, "profile header required", 403
74
- if got != want:
75
- return False, "profile mismatch", 403
76
- return True, "", 0
77
-
78
-
79
- def extract_bearer(headers: dict[str, str]) -> str:
80
- auth = str(headers.get("Authorization") or headers.get("authorization") or "").strip()
81
- if auth.lower().startswith("bearer "):
82
- return auth[7:].strip()
83
- api_key = str(headers.get("X-Api-Key") or headers.get("x-api-key") or "").strip()
84
- if api_key:
85
- return api_key
86
- return ""
87
-
88
-
89
- def upstream_auth_header(provider: str, secret: str) -> dict[str, str]:
90
- provider = str(provider or "").strip().lower()
91
- secret = str(secret or "").strip()
92
- if provider == "anthropic":
93
- return {"x-api-key": secret, "anthropic-version": "2023-06-01"}
94
- if provider == "google":
95
- return {"x-goog-api-key": secret}
96
- return {"Authorization": f"Bearer {secret}"}
97
-
98
-
99
- def _error_response(status: int, error: str) -> tuple[int, dict[str, str], bytes]:
100
- return status, {"Content-Type": "application/json"}, json.dumps({"error": error}).encode()
101
-
102
-
103
- def _build_upstream_request(
104
- provider: str,
105
- subpath: str,
106
- method: str,
107
- headers: dict[str, str],
108
- body: bytes | None,
109
- *,
110
- resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
111
- ) -> tuple[urllib.request.Request | None, tuple[int, dict[str, str], bytes] | None]:
112
- provider = str(provider or "").strip().lower()
113
- base = UPSTREAM_BASE.get(provider)
114
- if not base:
115
- return None, _error_response(404, "unknown provider")
116
-
117
- token = extract_bearer(headers)
118
- lease = turn_credentials.validate_lease(token)
119
- if not lease:
120
- return None, _error_response(401, "invalid lease")
121
- if str(lease.get("provider") or "").lower() != provider:
122
- return None, _error_response(403, "provider mismatch")
123
-
124
- ok_profile, profile_err, profile_status = validate_lease_profile(lease, headers)
125
- if not ok_profile:
126
- return None, _error_response(profile_status, profile_err)
127
-
128
- _env_var, secret = turn_credentials.resolve_lease_secret(lease, resolve_secret)
129
- if not secret:
130
- return None, _error_response(402, "no credential")
131
-
132
- path = normalize_upstream_path(base, subpath)
133
- url = f"{base.rstrip('/')}{path}"
134
- upstream_headers = {
135
- k: v
136
- for k, v in headers.items()
137
- if k.lower() not in {
138
- "host",
139
- "connection",
140
- "content-length",
141
- "authorization",
142
- "x-api-key",
143
- "x-goog-api-key",
144
- }
145
- }
146
- upstream_headers.update(upstream_auth_header(provider, secret))
147
- if provider == "openrouter":
148
- upstream_headers.setdefault("HTTP-Referer", "https://workfra.me")
149
- upstream_headers.setdefault("X-Title", "Workframe")
150
-
151
- req = urllib.request.Request(url, data=body, headers=upstream_headers, method=method.upper())
152
- return req, None
153
-
154
-
155
- def forward_request(
156
- provider: str,
157
- subpath: str,
158
- method: str,
159
- headers: dict[str, str],
160
- body: bytes | None,
161
- *,
162
- resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
163
- ) -> tuple[int, dict[str, str], bytes]:
164
- req, error = _build_upstream_request(
165
- provider,
166
- subpath,
167
- method,
168
- headers,
169
- body,
170
- resolve_secret=resolve_secret,
171
- )
172
- if error:
173
- return error
174
- assert req is not None
175
- try:
176
- with urllib.request.urlopen(req, timeout=600) as resp:
177
- resp_body = resp.read()
178
- out_headers = {"Content-Type": resp.headers.get("Content-Type", "application/octet-stream")}
179
- return resp.status, out_headers, resp_body
180
- except urllib.error.HTTPError as exc:
181
- raw = exc.read()
182
- out_headers = {"Content-Type": exc.headers.get("Content-Type", "application/json")}
183
- return exc.code, out_headers, raw
184
-
185
-
186
- def stream_request_to_handler(
187
- handler: BaseHTTPRequestHandler,
188
- provider: str,
189
- subpath: str,
190
- method: str,
191
- headers: dict[str, str],
192
- body: bytes | None,
193
- *,
194
- resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
195
- ) -> None:
196
- req, error = _build_upstream_request(
197
- provider,
198
- subpath,
199
- method,
200
- headers,
201
- body,
202
- resolve_secret=resolve_secret,
203
- )
204
- if error:
205
- status, out_headers, resp_body = error
206
- handler.send_response(status)
207
- for key, value in out_headers.items():
208
- handler.send_header(key, value)
209
- handler.end_headers()
210
- handler.wfile.write(resp_body)
211
- return
212
-
213
- assert req is not None
214
- try:
215
- with urllib.request.urlopen(req, timeout=600) as resp:
216
- content_type = resp.headers.get("Content-Type", "application/octet-stream")
217
- handler.send_response(resp.status)
218
- handler.send_header("Content-Type", content_type)
219
- handler.end_headers()
220
- if "text/event-stream" in content_type.lower():
221
- while True:
222
- line = resp.readline()
223
- if not line:
224
- break
225
- handler.wfile.write(line)
226
- handler.wfile.flush()
227
- else:
228
- while True:
229
- chunk = resp.read(8192)
230
- if not chunk:
231
- break
232
- handler.wfile.write(chunk)
233
- handler.wfile.flush()
234
- except urllib.error.HTTPError as exc:
235
- raw = exc.read()
236
- handler.send_response(exc.code)
237
- handler.send_header("Content-Type", exc.headers.get("Content-Type", "application/json"))
238
- handler.end_headers()
239
- handler.wfile.write(raw)
240
- except urllib.error.URLError as exc:
241
- handler.send_response(502)
242
- handler.send_header("Content-Type", "application/json")
243
- handler.end_headers()
244
- handler.wfile.write(json.dumps({"error": f"upstream unavailable: {exc}"}).encode())
245
-
246
-
247
- def handle_proxy_request(
248
- handler: BaseHTTPRequestHandler,
249
- path: str,
250
- method: str,
251
- body: bytes | None,
252
- *,
253
- resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
254
- ) -> bool:
255
- """Return True if handled."""
256
- ok, err = authorize_internal_proxy(handler)
257
- if not ok:
258
- handler.send_response(403)
259
- handler.send_header("Content-Type", "application/json")
260
- handler.end_headers()
261
- handler.wfile.write(json.dumps({"error": err}).encode())
262
- return True
263
-
264
- match = PROXY_PATH_RE.match(path)
265
- if not match:
266
- return False
267
-
268
- provider = match.group(1).lower()
269
- subpath = match.group(2) or "/"
270
- headers = {k: v for k, v in handler.headers.items()}
271
- stream_request_to_handler(
272
- handler,
273
- provider,
274
- subpath,
275
- method,
276
- headers,
277
- body,
278
- resolve_secret=resolve_secret,
279
- )
280
- return True
1
+ """Internal LLM proxy — Hermes sends lease tokens; API vault supplies upstream keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import socket
9
+ import ssl
10
+ import urllib.error
11
+ import urllib.request
12
+ from typing import Any, Callable
13
+ from http.server import BaseHTTPRequestHandler
14
+
15
+ import internal_proxy_auth
16
+ import turn_credentials
17
+
18
+ LEASE_PREFIX = turn_credentials.LEASE_PREFIX
19
+
20
+ UPSTREAM_BASE: dict[str, str] = {
21
+ "openrouter": "https://openrouter.ai/api/v1",
22
+ "openai": "https://api.openai.com/v1",
23
+ "anthropic": "https://api.anthropic.com",
24
+ "google": "https://generativelanguage.googleapis.com/v1beta",
25
+ "deepseek": "https://api.deepseek.com/v1",
26
+ }
27
+
28
+ PROXY_PATH_RE = re.compile(r"^/internal/llm/([a-z0-9_-]+)(/.*)?$", re.IGNORECASE)
29
+
30
+
31
+ def normalize_upstream_path(base: str, subpath: str) -> str:
32
+ """Drop a leading /v1 when upstream base already ends with /v1.
33
+
34
+ Hermes uses model.base_url …/internal/llm/openrouter/v1; the OpenAI client
35
+ then requests …/v1/chat/completions, which we forward as subpath /v1/….
36
+ """
37
+ path = subpath if str(subpath or "").startswith("/") else f"/{subpath or ''}"
38
+ base_norm = str(base or "").rstrip("/")
39
+ if path == "/v1":
40
+ return ""
41
+ if path.startswith("/v1/") and base_norm.endswith("/v1"):
42
+ return path[3:]
43
+ return path
44
+
45
+
46
+ def is_internal_client(host: str) -> bool:
47
+ """Allow docker/private callers only — not public browser origins."""
48
+ return internal_proxy_auth.is_internal_client(host)
49
+
50
+
51
+ def authorize_internal_proxy(handler: BaseHTTPRequestHandler) -> tuple[bool, str]:
52
+ return internal_proxy_auth.authorize_internal_proxy(handler)
53
+
54
+
55
+ def extract_profile_slug(headers: dict[str, str]) -> str:
56
+ return str(
57
+ headers.get(internal_proxy_auth.PROFILE_HEADER)
58
+ or headers.get("x-workframe-profile")
59
+ or ""
60
+ ).strip()
61
+
62
+
63
+ def validate_lease_profile(
64
+ lease: dict[str, Any],
65
+ headers: dict[str, str],
66
+ ) -> tuple[bool, str, int]:
67
+ """Bind bearer lease to calling Hermes profile (0022 N2 / 0023 C1)."""
68
+ want = str(lease.get("profile_slug") or "").strip()
69
+ if not want:
70
+ return True, "", 0
71
+ got = extract_profile_slug(headers)
72
+ if not got:
73
+ return False, "profile header required", 403
74
+ if got != want:
75
+ return False, "profile mismatch", 403
76
+ return True, "", 0
77
+
78
+
79
+ def extract_bearer(headers: dict[str, str]) -> str:
80
+ auth = str(headers.get("Authorization") or headers.get("authorization") or "").strip()
81
+ if auth.lower().startswith("bearer "):
82
+ return auth[7:].strip()
83
+ api_key = str(headers.get("X-Api-Key") or headers.get("x-api-key") or "").strip()
84
+ if api_key:
85
+ return api_key
86
+ return ""
87
+
88
+
89
+ def upstream_auth_header(provider: str, secret: str) -> dict[str, str]:
90
+ provider = str(provider or "").strip().lower()
91
+ secret = str(secret or "").strip()
92
+ if provider == "anthropic":
93
+ return {"x-api-key": secret, "anthropic-version": "2023-06-01"}
94
+ if provider == "google":
95
+ return {"x-goog-api-key": secret}
96
+ return {"Authorization": f"Bearer {secret}"}
97
+
98
+
99
+ def _error_response(status: int, error: str) -> tuple[int, dict[str, str], bytes]:
100
+ return status, {"Content-Type": "application/json"}, json.dumps({"error": error}).encode()
101
+
102
+
103
+ def _build_upstream_request(
104
+ provider: str,
105
+ subpath: str,
106
+ method: str,
107
+ headers: dict[str, str],
108
+ body: bytes | None,
109
+ *,
110
+ resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
111
+ ) -> tuple[urllib.request.Request | None, tuple[int, dict[str, str], bytes] | None]:
112
+ provider = str(provider or "").strip().lower()
113
+ base = UPSTREAM_BASE.get(provider)
114
+ if not base:
115
+ return None, _error_response(404, "unknown provider")
116
+
117
+ token = extract_bearer(headers)
118
+ lease = turn_credentials.validate_lease(token)
119
+ if not lease:
120
+ return None, _error_response(401, "invalid lease")
121
+ if str(lease.get("provider") or "").lower() != provider:
122
+ return None, _error_response(403, "provider mismatch")
123
+
124
+ ok_profile, profile_err, profile_status = validate_lease_profile(lease, headers)
125
+ if not ok_profile:
126
+ return None, _error_response(profile_status, profile_err)
127
+
128
+ _env_var, secret = turn_credentials.resolve_lease_secret(lease, resolve_secret)
129
+ if not secret:
130
+ return None, _error_response(402, "no credential")
131
+
132
+ path = normalize_upstream_path(base, subpath)
133
+ url = f"{base.rstrip('/')}{path}"
134
+ upstream_headers = {
135
+ k: v
136
+ for k, v in headers.items()
137
+ if k.lower() not in {
138
+ "host",
139
+ "connection",
140
+ "content-length",
141
+ "authorization",
142
+ "x-api-key",
143
+ "x-goog-api-key",
144
+ }
145
+ }
146
+ upstream_headers.update(upstream_auth_header(provider, secret))
147
+ if provider == "openrouter":
148
+ upstream_headers.setdefault("HTTP-Referer", "https://workfra.me")
149
+ upstream_headers.setdefault("X-Title", "Workframe")
150
+
151
+ req = urllib.request.Request(url, data=body, headers=upstream_headers, method=method.upper())
152
+ return req, None
153
+
154
+
155
+ def forward_request(
156
+ provider: str,
157
+ subpath: str,
158
+ method: str,
159
+ headers: dict[str, str],
160
+ body: bytes | None,
161
+ *,
162
+ resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
163
+ ) -> tuple[int, dict[str, str], bytes]:
164
+ req, error = _build_upstream_request(
165
+ provider,
166
+ subpath,
167
+ method,
168
+ headers,
169
+ body,
170
+ resolve_secret=resolve_secret,
171
+ )
172
+ if error:
173
+ return error
174
+ assert req is not None
175
+ try:
176
+ with urllib.request.urlopen(req, timeout=600) as resp:
177
+ resp_body = resp.read()
178
+ out_headers = {"Content-Type": resp.headers.get("Content-Type", "application/octet-stream")}
179
+ return resp.status, out_headers, resp_body
180
+ except urllib.error.HTTPError as exc:
181
+ raw = exc.read()
182
+ out_headers = {"Content-Type": exc.headers.get("Content-Type", "application/json")}
183
+ return exc.code, out_headers, raw
184
+
185
+
186
+ def stream_request_to_handler(
187
+ handler: BaseHTTPRequestHandler,
188
+ provider: str,
189
+ subpath: str,
190
+ method: str,
191
+ headers: dict[str, str],
192
+ body: bytes | None,
193
+ *,
194
+ resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
195
+ ) -> None:
196
+ req, error = _build_upstream_request(
197
+ provider,
198
+ subpath,
199
+ method,
200
+ headers,
201
+ body,
202
+ resolve_secret=resolve_secret,
203
+ )
204
+ if error:
205
+ status, out_headers, resp_body = error
206
+ handler.send_response(status)
207
+ for key, value in out_headers.items():
208
+ handler.send_header(key, value)
209
+ handler.end_headers()
210
+ handler.wfile.write(resp_body)
211
+ return
212
+
213
+ assert req is not None
214
+ try:
215
+ with urllib.request.urlopen(req, timeout=600) as resp:
216
+ content_type = resp.headers.get("Content-Type", "application/octet-stream")
217
+ handler.send_response(resp.status)
218
+ handler.send_header("Content-Type", content_type)
219
+ handler.end_headers()
220
+ if "text/event-stream" in content_type.lower():
221
+ while True:
222
+ line = resp.readline()
223
+ if not line:
224
+ break
225
+ handler.wfile.write(line)
226
+ handler.wfile.flush()
227
+ else:
228
+ while True:
229
+ chunk = resp.read(8192)
230
+ if not chunk:
231
+ break
232
+ handler.wfile.write(chunk)
233
+ handler.wfile.flush()
234
+ except urllib.error.HTTPError as exc:
235
+ raw = exc.read()
236
+ handler.send_response(exc.code)
237
+ handler.send_header("Content-Type", exc.headers.get("Content-Type", "application/json"))
238
+ handler.end_headers()
239
+ handler.wfile.write(raw)
240
+ except urllib.error.URLError as exc:
241
+ handler.send_response(502)
242
+ handler.send_header("Content-Type", "application/json")
243
+ handler.end_headers()
244
+ handler.wfile.write(json.dumps({"error": f"upstream unavailable: {exc}"}).encode())
245
+
246
+
247
+ def handle_proxy_request(
248
+ handler: BaseHTTPRequestHandler,
249
+ path: str,
250
+ method: str,
251
+ body: bytes | None,
252
+ *,
253
+ resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
254
+ ) -> bool:
255
+ """Return True if handled."""
256
+ ok, err = authorize_internal_proxy(handler)
257
+ if not ok:
258
+ handler.send_response(403)
259
+ handler.send_header("Content-Type", "application/json")
260
+ handler.end_headers()
261
+ handler.wfile.write(json.dumps({"error": err}).encode())
262
+ return True
263
+
264
+ match = PROXY_PATH_RE.match(path)
265
+ if not match:
266
+ return False
267
+
268
+ provider = match.group(1).lower()
269
+ subpath = match.group(2) or "/"
270
+ headers = {k: v for k, v in handler.headers.items()}
271
+ stream_request_to_handler(
272
+ handler,
273
+ provider,
274
+ subpath,
275
+ method,
276
+ headers,
277
+ body,
278
+ resolve_secret=resolve_secret,
279
+ )
280
+ return True