create-workframe 0.1.0 → 0.1.2
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/LICENSE +201 -201
- package/NOTICE +12 -12
- package/README.md +8 -92
- package/SECURITY.md +38 -40
- package/bin/workframe.js +329 -329
- package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +1 -1
- package/docs/workspace-instructions/WORKFRAME_ROUTING.md +8 -8
- package/package.json +3 -6
- package/profiles/architect/AGENTS.md +29 -29
- package/profiles/architect/SOUL.md +2 -2
- package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/designer/AGENTS.md +26 -26
- package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/dev/AGENTS.md +28 -28
- package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/docs/AGENTS.md +27 -27
- package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/research/AGENTS.md +26 -26
- package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/visionary/AGENTS.md +25 -25
- package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/workframe-agent/AGENTS.md +37 -37
- package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
- package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
- package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
- package/rules/workspace-README.md +5 -5
- package/scripts/bundle-workframe-ui.mjs +3 -3
- package/scripts/ensure-compose-host-paths.mjs +51 -51
- package/scripts/lib/install-identity.mjs +212 -212
- package/scripts/set-compose-public-url.mjs +92 -92
- package/scripts/sync-canonical-to-package.mjs +27 -9
- package/shared/WORKFRAME_AGENT_LIBRARY.md +17 -17
- package/shared/WORKFRAME_AGENT_OPERATIONS.md +15 -15
- package/shared/WORKFRAME_AGENT_PACKS.json +18 -18
- package/shared/WORKFRAME_AGENT_PACKS.yaml +8 -8
- package/shared/WORKFRAME_SKILL_CURATION.md +4 -4
- package/workframe-api/README.md +26 -28
- package/workframe-api/action_proxy.py +131 -131
- package/workframe-api/auth_rate_limit.py +49 -49
- package/workframe-api/credential_vault.py +445 -445
- package/workframe-api/data/avatar-catalog.json +41 -41
- package/workframe-api/email_sender.py +220 -220
- package/workframe-api/google_auth.py +90 -90
- package/workframe-api/install_api.py +359 -359
- package/workframe-api/internal_proxy_auth.py +150 -150
- package/workframe-api/llm_proxy.py +277 -277
- package/workframe-api/oidc_jwt.py +108 -108
- package/workframe-api/package.json +12 -13
- package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
- package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
- package/workframe-api/requirements.txt +2 -2
- package/workframe-api/site_meta.py +271 -271
- package/workframe-api/stack_config.py +427 -427
- package/workframe-api/time-bind-chat.py +99 -99
- package/workframe-api/turn_credentials.py +226 -226
- package/workframe-api/updates.py +417 -417
- package/workframe-api/vault_kek.py +159 -159
- package/workframe-api/zk_auth.py +633 -633
- package/workframe-supervisor/Dockerfile +11 -11
- package/workframe-supervisor/server.py +787 -787
- package/workframe-ui/docker/nginx.conf +85 -85
- package/workframe-ui/public/assets/{arc-CBDYvkAF.js → arc-COAT3laO.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-XnBRKeW0.js → architectureDiagram-3BPJPVTR-BFjWV24l.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-VYHUfVhd.js → blockDiagram-GPEHLZMM-DSQLPfrj.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BTjUcJpm.js → c4Diagram-AAUBKEIU-DKEHv1t2.js} +1 -1
- package/workframe-ui/public/assets/channel-g7r_RGaY.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-w7uu7R-b.js → chunk-2J33WTMH-DHZg-DUi.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-Cb9LtnDX.js → chunk-3OPIFGDE-BB-OYTfp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-DiQ-qCwH.js → chunk-4BX2VUAB-C93q0YIm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-C-mLFr7z.js → chunk-55IACEB6-MAYniqik.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DOesfiCI.js → chunk-5ZQYHXKU-ChgN6YJs.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-BJ3oBZuz.js → chunk-727SXJPM-B_FYwdAv.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CCA6xpGs.js → chunk-AQP2D5EJ-1_Hw_h1A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-a0cMNFb2.js → chunk-BSJP7CBP-CFiDQ1Rv.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-kuqN8EIY.js → chunk-CSCIHK7Q-DZ9UMTlB.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-DyPgYHCg.js → chunk-FMBD7UC4-DlMlyFgw.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-CdUuvR0V.js → chunk-KSCS5N6A-DHXtQ_Hf.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-Dq9NoWmK.js → chunk-L5ZTLDWV-CuQzg-QG.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-p74rddlO.js → chunk-LZXEDZCA-BHzjzCGg.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-DBD2u1Gz.js → chunk-ND2GUHAM-DHXx05n2.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-BeIeYFnd.js → chunk-NZK2D7GU-CV5pmDM_.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-ClHc56ib.js → chunk-O5CBEL6O-6tkCHxsV.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-EFr8R_1p.js → chunk-XPW4576I-BClwIiCp.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-C7aPBODd.js → cose-bilkent-S5V4N54A-DOrGV6DQ.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-BdU1Rv-H.js → dagre-BM42HDAG-DXTPvJkX.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-DWowSo85.js → diagram-2AECGRRQ-xX_v-pbf.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-MnxBbceO.js → diagram-5GNKFQAL-Cd2pXbBe.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQaLRXFf.js → diagram-KO2AKTUF-Df3XvUtk.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-CQaBud9k.js → diagram-LMA3HP47-CsijIPaD.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-D8bAXbY9.js → diagram-OG6HWLK6-aq5fmfHd.js} +1 -1
- package/workframe-ui/public/assets/{dist-DGpTLHr_.js → dist-D1c0mkbB.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-1E-xSvBK.js → erDiagram-TEJ5UH35-DnFysVRY.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CgOVD5hu.js → flowDiagram-I6XJVG4X-C6Ebi3su.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-JFYAIauo.js → ganttDiagram-6RSMTGT7-BQXQtUpa.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-B9REenIl.js → gitGraph-WXDBUCRP-Dt0zIs_M.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BQ7NcMSn.js → gitGraphDiagram-PVQCEYII-BF8gHzRn.js} +1 -1
- package/workframe-ui/public/assets/index-DpoUZAxh.css +1 -0
- package/workframe-ui/public/assets/{index-Dnw6vjqb.js → index-lRpzpNPT.js} +2 -2
- package/workframe-ui/public/assets/{info-J43DQDTF-CL6-eTjH.js → info-J43DQDTF-CSmszQJT.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-LJTODW4W.js → infoDiagram-5YYISTIA-CVTKGW6p.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-bchrQVuo.js → ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DkrvYuxP.js → journeyDiagram-JHISSGLW-r3wD68_T.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DFRbj0IG.js → kanban-definition-UN3LZRKU-Il8VglqN.js} +1 -1
- package/workframe-ui/public/assets/{line-Vd48P7-O.js → line-oyjpfz2A.js} +1 -1
- package/workframe-ui/public/assets/{linear-Ckizh2G7.js → linear-Cf7p5tVp.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Bkimsnqj.js → mermaid-parser.core-YmbZ-AfY.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-x0TvVuPo.js → mermaid.core-BFdCAqCo.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-6ykAFPEz.js → mindmap-definition-RKZ34NQL-Cy2iCtEl.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-Dw3xgMDt.js → packet-YPE3B663-DwOBZL6K.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DATysawG.js → pie-LRSECV5Y-04PPhnKK.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-SJKD1S0S.js → pieDiagram-4H26LBE5-LxIpgHqi.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-BrYDZX8q.js → quadrantDiagram-W4KKPZXB-0nBYfYm4.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-BmWYPCds.js → radar-GUYGQ44K-D2-vBqps.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DwL9Mc8e.js → requirementDiagram-4Y6WPE33-DbuU0nlu.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DYIFsL8h.js → sankeyDiagram-5OEKKPKP-B2hQ6B2x.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-0-FPkFk8.js → sequenceDiagram-3UESZ5HK-BBrU30e1.js} +1 -1
- package/workframe-ui/public/assets/{src-B_od6b6h.js → src-BJEDmV70.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BQCiBk6u.js → stateDiagram-AJRCARHV-7FGO4kkH.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DS3tFcXj.js → timeline-definition-PNZ67QCA-ptDm4rCN.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-DSyUCKLY.js → treeView-BLDUP644-CS6Z-0q8.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-CEZaNh5Y.js → treemap-LRROVOQU-DqV4Y2VA.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CD-Vc9NF.js → vennDiagram-CIIHVFJN-C0UrZJYt.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-Drq5w1Mc.js → wardley-L42UT6IY-bNDN3_Sa.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DouXDJoF.js → wardleyDiagram-YWT4CUSO-jWiJsefM.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DDf_Lol5.js → xychartDiagram-2RQKCTM6-Dsh_fLCy.js} +1 -1
- package/workframe-ui/public/favicon.svg +7 -7
- package/workframe-ui/public/index.html +50 -50
- package/workframe-ui/public/workframe-config.json +3 -3
- package/scripts/security_audit.py +0 -156
- package/scripts/test-scaffold.mjs +0 -390
- package/workframe-api/tests/__init__.py +0 -0
- package/workframe-api/tests/db_setup.py +0 -13
- package/workframe-api/tests/test_admin_updates_gated.py +0 -30
- package/workframe-api/tests/test_agent_dm_bootstrap.py +0 -196
- package/workframe-api/tests/test_agent_profile_sync.py +0 -76
- package/workframe-api/tests/test_auth_email.py +0 -222
- package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +0 -99
- package/workframe-api/tests/test_auth_rate_limit.py +0 -19
- package/workframe-api/tests/test_avatar_resolve.py +0 -77
- package/workframe-api/tests/test_child_soul_template.py +0 -71
- package/workframe-api/tests/test_credential_canary.py +0 -135
- package/workframe-api/tests/test_credential_isolation.py +0 -448
- package/workframe-api/tests/test_credential_resolution.py +0 -206
- package/workframe-api/tests/test_device_oauth.py +0 -108
- package/workframe-api/tests/test_doctor_repair.py +0 -103
- package/workframe-api/tests/test_ensure_profile_api.py +0 -77
- package/workframe-api/tests/test_gateway_compose_security.py +0 -136
- package/workframe-api/tests/test_install_secure_host.py +0 -39
- package/workframe-api/tests/test_internal_proxy_auth.py +0 -125
- package/workframe-api/tests/test_invite_runtime_bootstrap.py +0 -72
- package/workframe-api/tests/test_kanban_delegation.py +0 -185
- package/workframe-api/tests/test_llm_proxy.py +0 -155
- package/workframe-api/tests/test_login_access_policy.py +0 -183
- package/workframe-api/tests/test_mvp_model_bootstrap.py +0 -75
- package/workframe-api/tests/test_onboarding_bootstrap.py +0 -248
- package/workframe-api/tests/test_platform_auth.py +0 -47
- package/workframe-api/tests/test_profile_config_path.py +0 -56
- package/workframe-api/tests/test_profile_config_yaml_repair.py +0 -63
- package/workframe-api/tests/test_profile_create.py +0 -72
- package/workframe-api/tests/test_profile_identity_overlay.py +0 -61
- package/workframe-api/tests/test_profile_install_health.py +0 -45
- package/workframe-api/tests/test_profile_secret_policy.py +0 -57
- package/workframe-api/tests/test_profile_workspace_cwd.py +0 -34
- package/workframe-api/tests/test_provider_bootstrap.py +0 -75
- package/workframe-api/tests/test_provider_connect.py +0 -54
- package/workframe-api/tests/test_room_crud.py +0 -192
- package/workframe-api/tests/test_room_tenancy.py +0 -701
- package/workframe-api/tests/test_runtime_identity_backfill.py +0 -34
- package/workframe-api/tests/test_site_meta.py +0 -81
- package/workframe-api/tests/test_soul_stub.py +0 -42
- package/workframe-api/tests/test_space_member_sync.py +0 -99
- package/workframe-api/tests/test_stripe_stack_config.py +0 -37
- package/workframe-api/tests/test_supervisor_lifecycle.py +0 -52
- package/workframe-api/tests/test_turn_credential_vault.py +0 -125
- package/workframe-api/tests/test_updates.py +0 -176
- package/workframe-api/tests/test_user_cohort.py +0 -113
- package/workframe-api/tests/test_vault_envelope.py +0 -110
- package/workframe-api/tests/test_workspace_members.py +0 -183
- package/workframe-api/tests/test_workspace_messaging_sync.py +0 -125
- package/workframe-api/tests/test_workspace_provider_list.py +0 -57
- package/workframe-supervisor/tests/test_exec_guard.py +0 -42
- package/workframe-supervisor/tests/test_server_import.py +0 -21
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +0 -1
- package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +0 -1
- package/workframe-ui/public/assets/index-DpAGxump.css +0 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-B89jAMFF.js +0 -1
|
@@ -1,277 +1,277 @@
|
|
|
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
|
-
|
|
148
|
-
req = urllib.request.Request(url, data=body, headers=upstream_headers, method=method.upper())
|
|
149
|
-
return req, None
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def forward_request(
|
|
153
|
-
provider: str,
|
|
154
|
-
subpath: str,
|
|
155
|
-
method: str,
|
|
156
|
-
headers: dict[str, str],
|
|
157
|
-
body: bytes | None,
|
|
158
|
-
*,
|
|
159
|
-
resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
|
|
160
|
-
) -> tuple[int, dict[str, str], bytes]:
|
|
161
|
-
req, error = _build_upstream_request(
|
|
162
|
-
provider,
|
|
163
|
-
subpath,
|
|
164
|
-
method,
|
|
165
|
-
headers,
|
|
166
|
-
body,
|
|
167
|
-
resolve_secret=resolve_secret,
|
|
168
|
-
)
|
|
169
|
-
if error:
|
|
170
|
-
return error
|
|
171
|
-
assert req is not None
|
|
172
|
-
try:
|
|
173
|
-
with urllib.request.urlopen(req, timeout=600) as resp:
|
|
174
|
-
resp_body = resp.read()
|
|
175
|
-
out_headers = {"Content-Type": resp.headers.get("Content-Type", "application/octet-stream")}
|
|
176
|
-
return resp.status, out_headers, resp_body
|
|
177
|
-
except urllib.error.HTTPError as exc:
|
|
178
|
-
raw = exc.read()
|
|
179
|
-
out_headers = {"Content-Type": exc.headers.get("Content-Type", "application/json")}
|
|
180
|
-
return exc.code, out_headers, raw
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def stream_request_to_handler(
|
|
184
|
-
handler: BaseHTTPRequestHandler,
|
|
185
|
-
provider: str,
|
|
186
|
-
subpath: str,
|
|
187
|
-
method: str,
|
|
188
|
-
headers: dict[str, str],
|
|
189
|
-
body: bytes | None,
|
|
190
|
-
*,
|
|
191
|
-
resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
|
|
192
|
-
) -> None:
|
|
193
|
-
req, error = _build_upstream_request(
|
|
194
|
-
provider,
|
|
195
|
-
subpath,
|
|
196
|
-
method,
|
|
197
|
-
headers,
|
|
198
|
-
body,
|
|
199
|
-
resolve_secret=resolve_secret,
|
|
200
|
-
)
|
|
201
|
-
if error:
|
|
202
|
-
status, out_headers, resp_body = error
|
|
203
|
-
handler.send_response(status)
|
|
204
|
-
for key, value in out_headers.items():
|
|
205
|
-
handler.send_header(key, value)
|
|
206
|
-
handler.end_headers()
|
|
207
|
-
handler.wfile.write(resp_body)
|
|
208
|
-
return
|
|
209
|
-
|
|
210
|
-
assert req is not None
|
|
211
|
-
try:
|
|
212
|
-
with urllib.request.urlopen(req, timeout=600) as resp:
|
|
213
|
-
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
|
214
|
-
handler.send_response(resp.status)
|
|
215
|
-
handler.send_header("Content-Type", content_type)
|
|
216
|
-
handler.end_headers()
|
|
217
|
-
if "text/event-stream" in content_type.lower():
|
|
218
|
-
while True:
|
|
219
|
-
line = resp.readline()
|
|
220
|
-
if not line:
|
|
221
|
-
break
|
|
222
|
-
handler.wfile.write(line)
|
|
223
|
-
handler.wfile.flush()
|
|
224
|
-
else:
|
|
225
|
-
while True:
|
|
226
|
-
chunk = resp.read(8192)
|
|
227
|
-
if not chunk:
|
|
228
|
-
break
|
|
229
|
-
handler.wfile.write(chunk)
|
|
230
|
-
handler.wfile.flush()
|
|
231
|
-
except urllib.error.HTTPError as exc:
|
|
232
|
-
raw = exc.read()
|
|
233
|
-
handler.send_response(exc.code)
|
|
234
|
-
handler.send_header("Content-Type", exc.headers.get("Content-Type", "application/json"))
|
|
235
|
-
handler.end_headers()
|
|
236
|
-
handler.wfile.write(raw)
|
|
237
|
-
except urllib.error.URLError as exc:
|
|
238
|
-
handler.send_response(502)
|
|
239
|
-
handler.send_header("Content-Type", "application/json")
|
|
240
|
-
handler.end_headers()
|
|
241
|
-
handler.wfile.write(json.dumps({"error": f"upstream unavailable: {exc}"}).encode())
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def handle_proxy_request(
|
|
245
|
-
handler: BaseHTTPRequestHandler,
|
|
246
|
-
path: str,
|
|
247
|
-
method: str,
|
|
248
|
-
body: bytes | None,
|
|
249
|
-
*,
|
|
250
|
-
resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
|
|
251
|
-
) -> bool:
|
|
252
|
-
"""Return True if handled."""
|
|
253
|
-
ok, err = authorize_internal_proxy(handler)
|
|
254
|
-
if not ok:
|
|
255
|
-
handler.send_response(403)
|
|
256
|
-
handler.send_header("Content-Type", "application/json")
|
|
257
|
-
handler.end_headers()
|
|
258
|
-
handler.wfile.write(json.dumps({"error": err}).encode())
|
|
259
|
-
return True
|
|
260
|
-
|
|
261
|
-
match = PROXY_PATH_RE.match(path)
|
|
262
|
-
if not match:
|
|
263
|
-
return False
|
|
264
|
-
|
|
265
|
-
provider = match.group(1).lower()
|
|
266
|
-
subpath = match.group(2) or "/"
|
|
267
|
-
headers = {k: v for k, v in handler.headers.items()}
|
|
268
|
-
stream_request_to_handler(
|
|
269
|
-
handler,
|
|
270
|
-
provider,
|
|
271
|
-
subpath,
|
|
272
|
-
method,
|
|
273
|
-
headers,
|
|
274
|
-
body,
|
|
275
|
-
resolve_secret=resolve_secret,
|
|
276
|
-
)
|
|
277
|
-
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
|
+
|
|
148
|
+
req = urllib.request.Request(url, data=body, headers=upstream_headers, method=method.upper())
|
|
149
|
+
return req, None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def forward_request(
|
|
153
|
+
provider: str,
|
|
154
|
+
subpath: str,
|
|
155
|
+
method: str,
|
|
156
|
+
headers: dict[str, str],
|
|
157
|
+
body: bytes | None,
|
|
158
|
+
*,
|
|
159
|
+
resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
|
|
160
|
+
) -> tuple[int, dict[str, str], bytes]:
|
|
161
|
+
req, error = _build_upstream_request(
|
|
162
|
+
provider,
|
|
163
|
+
subpath,
|
|
164
|
+
method,
|
|
165
|
+
headers,
|
|
166
|
+
body,
|
|
167
|
+
resolve_secret=resolve_secret,
|
|
168
|
+
)
|
|
169
|
+
if error:
|
|
170
|
+
return error
|
|
171
|
+
assert req is not None
|
|
172
|
+
try:
|
|
173
|
+
with urllib.request.urlopen(req, timeout=600) as resp:
|
|
174
|
+
resp_body = resp.read()
|
|
175
|
+
out_headers = {"Content-Type": resp.headers.get("Content-Type", "application/octet-stream")}
|
|
176
|
+
return resp.status, out_headers, resp_body
|
|
177
|
+
except urllib.error.HTTPError as exc:
|
|
178
|
+
raw = exc.read()
|
|
179
|
+
out_headers = {"Content-Type": exc.headers.get("Content-Type", "application/json")}
|
|
180
|
+
return exc.code, out_headers, raw
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def stream_request_to_handler(
|
|
184
|
+
handler: BaseHTTPRequestHandler,
|
|
185
|
+
provider: str,
|
|
186
|
+
subpath: str,
|
|
187
|
+
method: str,
|
|
188
|
+
headers: dict[str, str],
|
|
189
|
+
body: bytes | None,
|
|
190
|
+
*,
|
|
191
|
+
resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
|
|
192
|
+
) -> None:
|
|
193
|
+
req, error = _build_upstream_request(
|
|
194
|
+
provider,
|
|
195
|
+
subpath,
|
|
196
|
+
method,
|
|
197
|
+
headers,
|
|
198
|
+
body,
|
|
199
|
+
resolve_secret=resolve_secret,
|
|
200
|
+
)
|
|
201
|
+
if error:
|
|
202
|
+
status, out_headers, resp_body = error
|
|
203
|
+
handler.send_response(status)
|
|
204
|
+
for key, value in out_headers.items():
|
|
205
|
+
handler.send_header(key, value)
|
|
206
|
+
handler.end_headers()
|
|
207
|
+
handler.wfile.write(resp_body)
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
assert req is not None
|
|
211
|
+
try:
|
|
212
|
+
with urllib.request.urlopen(req, timeout=600) as resp:
|
|
213
|
+
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
|
214
|
+
handler.send_response(resp.status)
|
|
215
|
+
handler.send_header("Content-Type", content_type)
|
|
216
|
+
handler.end_headers()
|
|
217
|
+
if "text/event-stream" in content_type.lower():
|
|
218
|
+
while True:
|
|
219
|
+
line = resp.readline()
|
|
220
|
+
if not line:
|
|
221
|
+
break
|
|
222
|
+
handler.wfile.write(line)
|
|
223
|
+
handler.wfile.flush()
|
|
224
|
+
else:
|
|
225
|
+
while True:
|
|
226
|
+
chunk = resp.read(8192)
|
|
227
|
+
if not chunk:
|
|
228
|
+
break
|
|
229
|
+
handler.wfile.write(chunk)
|
|
230
|
+
handler.wfile.flush()
|
|
231
|
+
except urllib.error.HTTPError as exc:
|
|
232
|
+
raw = exc.read()
|
|
233
|
+
handler.send_response(exc.code)
|
|
234
|
+
handler.send_header("Content-Type", exc.headers.get("Content-Type", "application/json"))
|
|
235
|
+
handler.end_headers()
|
|
236
|
+
handler.wfile.write(raw)
|
|
237
|
+
except urllib.error.URLError as exc:
|
|
238
|
+
handler.send_response(502)
|
|
239
|
+
handler.send_header("Content-Type", "application/json")
|
|
240
|
+
handler.end_headers()
|
|
241
|
+
handler.wfile.write(json.dumps({"error": f"upstream unavailable: {exc}"}).encode())
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def handle_proxy_request(
|
|
245
|
+
handler: BaseHTTPRequestHandler,
|
|
246
|
+
path: str,
|
|
247
|
+
method: str,
|
|
248
|
+
body: bytes | None,
|
|
249
|
+
*,
|
|
250
|
+
resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
|
|
251
|
+
) -> bool:
|
|
252
|
+
"""Return True if handled."""
|
|
253
|
+
ok, err = authorize_internal_proxy(handler)
|
|
254
|
+
if not ok:
|
|
255
|
+
handler.send_response(403)
|
|
256
|
+
handler.send_header("Content-Type", "application/json")
|
|
257
|
+
handler.end_headers()
|
|
258
|
+
handler.wfile.write(json.dumps({"error": err}).encode())
|
|
259
|
+
return True
|
|
260
|
+
|
|
261
|
+
match = PROXY_PATH_RE.match(path)
|
|
262
|
+
if not match:
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
provider = match.group(1).lower()
|
|
266
|
+
subpath = match.group(2) or "/"
|
|
267
|
+
headers = {k: v for k, v in handler.headers.items()}
|
|
268
|
+
stream_request_to_handler(
|
|
269
|
+
handler,
|
|
270
|
+
provider,
|
|
271
|
+
subpath,
|
|
272
|
+
method,
|
|
273
|
+
headers,
|
|
274
|
+
body,
|
|
275
|
+
resolve_secret=resolve_secret,
|
|
276
|
+
)
|
|
277
|
+
return True
|