flowgraphapp 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowgraph/__init__.py +8 -0
- flowgraph/_static/_headers +21 -0
- flowgraph/_static/assets/JsonCodeEditor-CBMWkoPU.js +12 -0
- flowgraph/_static/assets/index-BE9FlCos.css +2 -0
- flowgraph/_static/assets/index-SiLO3vOy.js +157 -0
- flowgraph/_static/assets/pdf-B_9Q7Dif.js +11 -0
- flowgraph/_static/assets/pdf.worker.min-0p99Cwul.js +1 -0
- flowgraph/_static/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
- flowgraph/_static/favicon.svg +1 -0
- flowgraph/_static/icons.svg +24 -0
- flowgraph/_static/index.html +15 -0
- flowgraph/ai_proxy.py +146 -0
- flowgraph/cli.py +201 -0
- flowgraph/config.py +125 -0
- flowgraph/keys.py +133 -0
- flowgraph/providers.py +89 -0
- flowgraph/runtime_config.py +27 -0
- flowgraph/security.py +291 -0
- flowgraph/server.py +96 -0
- flowgraph/update_check.py +118 -0
- flowgraphapp-0.1.1.dist-info/METADATA +108 -0
- flowgraphapp-0.1.1.dist-info/RECORD +25 -0
- flowgraphapp-0.1.1.dist-info/WHEEL +4 -0
- flowgraphapp-0.1.1.dist-info/entry_points.txt +2 -0
- flowgraphapp-0.1.1.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
3
|
+
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
4
|
+
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
5
|
+
</symbol>
|
|
6
|
+
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
7
|
+
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
8
|
+
</symbol>
|
|
9
|
+
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
10
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
11
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
12
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
13
|
+
</symbol>
|
|
14
|
+
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
15
|
+
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
16
|
+
</symbol>
|
|
17
|
+
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
18
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
19
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
20
|
+
</symbol>
|
|
21
|
+
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
22
|
+
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
23
|
+
</symbol>
|
|
24
|
+
</svg>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<meta name="description" content="FlowGraph — a local-first canvas where every diagram is a living knowledge graph." />
|
|
8
|
+
<title>FlowGraph</title>
|
|
9
|
+
<script type="module" crossorigin src="./assets/index-SiLO3vOy.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="./assets/index-BE9FlCos.css">
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div id="root"></div>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
flowgraph/ai_proxy.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""POST /api/ai/chat — the local mirror of the Cloudflare Worker's AI proxy.
|
|
2
|
+
|
|
3
|
+
Same request/response contract as worker/src/index.ts so the SAME SPA build talks
|
|
4
|
+
to either backend (charter §1). The provider key stays server-side — it is read
|
|
5
|
+
from the keychain and used here; it is NEVER returned to the browser. Every call
|
|
6
|
+
emits an ordered StepTrace + run id (charter §2 traceability) and reports the active
|
|
7
|
+
provider + key source (charter §8 honesty).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .providers import DEFAULT_MODELS, ProviderChoice, resolve_provider
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _step(name: str, status: str, t0: float, error: Optional[str] = None) -> dict:
|
|
22
|
+
return {
|
|
23
|
+
"name": name,
|
|
24
|
+
"status": status,
|
|
25
|
+
"durationMs": round((time.perf_counter() - t0) * 1000, 1),
|
|
26
|
+
"error": error,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def _call_anthropic(client: httpx.AsyncClient, choice: ProviderChoice, req: dict) -> dict:
|
|
31
|
+
body: dict[str, Any] = {
|
|
32
|
+
"model": req.get("model") or DEFAULT_MODELS["anthropic"],
|
|
33
|
+
"max_tokens": req.get("max_tokens", 1024),
|
|
34
|
+
"messages": req.get("messages", []),
|
|
35
|
+
}
|
|
36
|
+
if req.get("system"):
|
|
37
|
+
body["system"] = req["system"]
|
|
38
|
+
r = await client.post(
|
|
39
|
+
choice.base_url + "/v1/messages",
|
|
40
|
+
json=body,
|
|
41
|
+
headers={
|
|
42
|
+
"x-api-key": choice.api_key or "",
|
|
43
|
+
"anthropic-version": "2023-06-01",
|
|
44
|
+
"content-type": "application/json",
|
|
45
|
+
},
|
|
46
|
+
timeout=120.0,
|
|
47
|
+
)
|
|
48
|
+
r.raise_for_status()
|
|
49
|
+
data = r.json()
|
|
50
|
+
text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
|
|
51
|
+
usage = data.get("usage", {})
|
|
52
|
+
return {
|
|
53
|
+
"text": text or None,
|
|
54
|
+
"model": data.get("model"),
|
|
55
|
+
"usage": {"input": usage.get("input_tokens"), "output": usage.get("output_tokens")},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _call_openai_compat(client: httpx.AsyncClient, choice: ProviderChoice, req: dict) -> dict:
|
|
60
|
+
messages = []
|
|
61
|
+
if req.get("system"):
|
|
62
|
+
messages.append({"role": "system", "content": req["system"]})
|
|
63
|
+
messages.extend(req.get("messages", []))
|
|
64
|
+
body: dict[str, Any] = {
|
|
65
|
+
"model": req.get("model") or DEFAULT_MODELS.get(choice.kind, ""),
|
|
66
|
+
"messages": messages,
|
|
67
|
+
"max_tokens": req.get("max_tokens", 1024),
|
|
68
|
+
}
|
|
69
|
+
headers = {"content-type": "application/json"}
|
|
70
|
+
if choice.api_key:
|
|
71
|
+
headers["authorization"] = f"Bearer {choice.api_key}"
|
|
72
|
+
r = await client.post(
|
|
73
|
+
choice.base_url + "/v1/chat/completions", json=body, headers=headers, timeout=120.0
|
|
74
|
+
)
|
|
75
|
+
r.raise_for_status()
|
|
76
|
+
data = r.json()
|
|
77
|
+
choice0 = (data.get("choices") or [{}])[0]
|
|
78
|
+
text = (choice0.get("message") or {}).get("content")
|
|
79
|
+
usage = data.get("usage", {})
|
|
80
|
+
return {
|
|
81
|
+
"text": text,
|
|
82
|
+
"model": data.get("model"),
|
|
83
|
+
"usage": {"input": usage.get("prompt_tokens"), "output": usage.get("completion_tokens")},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def proxy_chat(
|
|
88
|
+
req: dict,
|
|
89
|
+
*,
|
|
90
|
+
prefer_local: bool = True,
|
|
91
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
92
|
+
choice: Optional[ProviderChoice] = None,
|
|
93
|
+
) -> dict:
|
|
94
|
+
"""Run one chat completion through the resolved provider. Returns the Worker-shaped
|
|
95
|
+
response augmented with `provider`, `keySource`, `requestId`, and `trace`."""
|
|
96
|
+
run_id = secrets.token_hex(8)
|
|
97
|
+
trace: list[dict] = []
|
|
98
|
+
own_client = client is None
|
|
99
|
+
client = client or httpx.AsyncClient()
|
|
100
|
+
try:
|
|
101
|
+
if choice is None:
|
|
102
|
+
t0 = time.perf_counter()
|
|
103
|
+
try:
|
|
104
|
+
choice = await resolve_provider(client, prefer_local=prefer_local)
|
|
105
|
+
trace.append(_step("resolve-provider", "ok" if choice else "empty", t0,
|
|
106
|
+
None if choice else "no local runtime and no BYOK key configured"))
|
|
107
|
+
except Exception as e: # pragma: no cover - defensive
|
|
108
|
+
trace.append(_step("resolve-provider", "error", t0, str(e)))
|
|
109
|
+
return {"text": None, "error": "provider-resolution-failed", "requestId": run_id, "trace": trace}
|
|
110
|
+
if choice is None:
|
|
111
|
+
return {
|
|
112
|
+
"text": None,
|
|
113
|
+
"error": "no-provider",
|
|
114
|
+
"detail": "Start a local model (Ollama/LM Studio) or set a provider key with `flowgraph keys set`.",
|
|
115
|
+
"requestId": run_id,
|
|
116
|
+
"trace": trace,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
t1 = time.perf_counter()
|
|
120
|
+
try:
|
|
121
|
+
if choice.is_anthropic:
|
|
122
|
+
result = await _call_anthropic(client, choice, req)
|
|
123
|
+
else:
|
|
124
|
+
result = await _call_openai_compat(client, choice, req)
|
|
125
|
+
trace.append(_step("provider-call", "ok", t1))
|
|
126
|
+
except httpx.HTTPStatusError as e:
|
|
127
|
+
trace.append(_step("provider-call", "error", t1, f"HTTP {e.response.status_code}"))
|
|
128
|
+
return {"text": None, "error": "provider-http-error", "status": e.response.status_code,
|
|
129
|
+
"provider": choice.kind, "keySource": choice.key_source, "requestId": run_id, "trace": trace}
|
|
130
|
+
except Exception as e:
|
|
131
|
+
trace.append(_step("provider-call", "error", t1, str(e)))
|
|
132
|
+
return {"text": None, "error": "provider-call-failed", "provider": choice.kind,
|
|
133
|
+
"keySource": choice.key_source, "requestId": run_id, "trace": trace}
|
|
134
|
+
|
|
135
|
+
# NOTE: choice.api_key is deliberately NEVER included in the response.
|
|
136
|
+
result.update({
|
|
137
|
+
"provider": choice.kind,
|
|
138
|
+
"providerLabel": choice.label,
|
|
139
|
+
"keySource": choice.key_source,
|
|
140
|
+
"requestId": run_id,
|
|
141
|
+
"trace": trace,
|
|
142
|
+
})
|
|
143
|
+
return result
|
|
144
|
+
finally:
|
|
145
|
+
if own_client:
|
|
146
|
+
await client.aclose()
|
flowgraph/cli.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""`flowgraph` command — launch the local server, manage keys, self-upgrade.
|
|
2
|
+
|
|
3
|
+
Bare `flowgraph` runs the server on 127.0.0.1 with auth ON. Off-loopback exposure
|
|
4
|
+
(`--allow-lan`) is a loud, deliberate opt-in that REQUIRES TLS (see docs/18 threat
|
|
5
|
+
model). The session token is delivered out-of-band (terminal + a 0600 file); the
|
|
6
|
+
auto-opened URL carries only a single-use ticket, never the long-lived token.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import socket
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import webbrowser
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from . import __version__, keys as keymod, update_check
|
|
20
|
+
from .config import ServerConfig, resolve_static_dir, state_path
|
|
21
|
+
from .security import new_session_token, TicketStore
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(add_completion=False, help="FlowGraph — run the local-first canvas on your own machine.")
|
|
24
|
+
keys_app = typer.Typer(help="Manage provider API keys (stored in the OS keychain).")
|
|
25
|
+
app.add_typer(keys_app, name="keys")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _pick_free_port(host: str) -> int:
|
|
29
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
30
|
+
try:
|
|
31
|
+
s.bind((host if host != "::1" else "127.0.0.1", 0))
|
|
32
|
+
return s.getsockname()[1]
|
|
33
|
+
finally:
|
|
34
|
+
s.close()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _write_token_file(token: str) -> str:
|
|
38
|
+
path = state_path("session-token")
|
|
39
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
40
|
+
try:
|
|
41
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
42
|
+
f.write(token + "\n")
|
|
43
|
+
finally:
|
|
44
|
+
try:
|
|
45
|
+
os.chmod(path, 0o600)
|
|
46
|
+
except OSError:
|
|
47
|
+
pass
|
|
48
|
+
return str(path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _run_server(config: ServerConfig) -> None:
|
|
52
|
+
import uvicorn
|
|
53
|
+
|
|
54
|
+
from .server import create_app
|
|
55
|
+
|
|
56
|
+
# Resolve the SPA up-front so a missing build fails fast with a clear message.
|
|
57
|
+
try:
|
|
58
|
+
config.static_dir = resolve_static_dir()
|
|
59
|
+
except FileNotFoundError as e:
|
|
60
|
+
typer.secho(str(e), fg="red", err=True)
|
|
61
|
+
raise typer.Exit(code=1)
|
|
62
|
+
|
|
63
|
+
# Off-loopback exposure must carry TLS (token over plain HTTP off-loopback is unsafe).
|
|
64
|
+
if config.allow_lan:
|
|
65
|
+
config.host = "0.0.0.0"
|
|
66
|
+
if not (config.tls_certfile and config.tls_keyfile):
|
|
67
|
+
typer.secho(
|
|
68
|
+
"Refusing to expose FlowGraph beyond loopback without TLS. "
|
|
69
|
+
"Pass --tls-certfile and --tls-keyfile, or drop --allow-lan.",
|
|
70
|
+
fg="red", err=True,
|
|
71
|
+
)
|
|
72
|
+
raise typer.Exit(code=1)
|
|
73
|
+
typer.secho("WARNING: serving on 0.0.0.0 — reachable by your whole network.", fg="yellow", err=True)
|
|
74
|
+
|
|
75
|
+
if config.port == 0:
|
|
76
|
+
config.port = _pick_free_port(config.host)
|
|
77
|
+
|
|
78
|
+
config.session_token = new_session_token()
|
|
79
|
+
tickets = TicketStore()
|
|
80
|
+
ticket = tickets.mint()
|
|
81
|
+
|
|
82
|
+
fg_app = create_app(config, tickets)
|
|
83
|
+
|
|
84
|
+
display_host = "127.0.0.1" if config.host in ("0.0.0.0", "::1") else config.host
|
|
85
|
+
base_url = f"{config.scheme}://{display_host}:{config.port}"
|
|
86
|
+
open_url = f"{base_url}/?ticket={ticket}" if config.auth_enabled else base_url
|
|
87
|
+
|
|
88
|
+
token_file = _write_token_file(config.session_token)
|
|
89
|
+
|
|
90
|
+
typer.secho("\n FlowGraph (local)", fg="cyan", bold=True)
|
|
91
|
+
typer.echo(f" → Open: {open_url}")
|
|
92
|
+
typer.echo(f" Bound: {config.host}:{config.port} ({'auth on' if config.auth_enabled else 'AUTH OFF'})")
|
|
93
|
+
if config.auth_enabled:
|
|
94
|
+
typer.echo(f" Session token (for CLI/API clients): {token_file}")
|
|
95
|
+
typer.echo(" Stop with Ctrl-C.\n")
|
|
96
|
+
|
|
97
|
+
if config.open_browser:
|
|
98
|
+
def _open():
|
|
99
|
+
try:
|
|
100
|
+
webbrowser.open(open_url)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
fg_app.add_event_handler("startup", lambda: threading.Timer(0.3, _open).start())
|
|
104
|
+
|
|
105
|
+
update_check.check_in_background()
|
|
106
|
+
|
|
107
|
+
# Hardened uvicorn flags: do NOT trust proxy headers (X-Forwarded-* spoofing),
|
|
108
|
+
# disable access logs so the one-time ticket never lands in a log, hide the
|
|
109
|
+
# Server header. The Host/Origin/token checks live in our ASGI middleware.
|
|
110
|
+
uvicorn.run(
|
|
111
|
+
fg_app,
|
|
112
|
+
host=config.host,
|
|
113
|
+
port=config.port,
|
|
114
|
+
proxy_headers=False,
|
|
115
|
+
forwarded_allow_ips="",
|
|
116
|
+
access_log=False,
|
|
117
|
+
server_header=False,
|
|
118
|
+
log_level="warning",
|
|
119
|
+
ssl_certfile=config.tls_certfile,
|
|
120
|
+
ssl_keyfile=config.tls_keyfile,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.callback(invoke_without_command=True)
|
|
125
|
+
def main_callback(
|
|
126
|
+
ctx: typer.Context,
|
|
127
|
+
port: int = typer.Option(8765, "--port", "-p", help="Port (0 = pick a free one)."),
|
|
128
|
+
open_browser: bool = typer.Option(True, "--open/--no-open", help="Open the browser on start."),
|
|
129
|
+
no_auth: bool = typer.Option(False, "--no-auth", help="Disable the session token (single-user trusted machine only)."),
|
|
130
|
+
allow_lan: bool = typer.Option(False, "--allow-lan", help="Expose beyond loopback (requires --tls-*)."),
|
|
131
|
+
tls_certfile: str = typer.Option(None, "--tls-certfile", help="TLS cert (enables HTTPS)."),
|
|
132
|
+
tls_keyfile: str = typer.Option(None, "--tls-keyfile", help="TLS key."),
|
|
133
|
+
local_model_first: bool = typer.Option(True, "--local-first/--byok-first", help="Prefer a local model, else BYOK."),
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Run the FlowGraph local server (default action)."""
|
|
136
|
+
if ctx.invoked_subcommand is not None:
|
|
137
|
+
return
|
|
138
|
+
if no_auth:
|
|
139
|
+
typer.secho("WARNING: --no-auth disables the session token; any local process can reach the server.", fg="yellow", err=True)
|
|
140
|
+
config = ServerConfig(
|
|
141
|
+
port=port,
|
|
142
|
+
open_browser=open_browser,
|
|
143
|
+
auth_enabled=not no_auth,
|
|
144
|
+
allow_lan=allow_lan,
|
|
145
|
+
tls_certfile=tls_certfile,
|
|
146
|
+
tls_keyfile=tls_keyfile,
|
|
147
|
+
local_model_first=local_model_first,
|
|
148
|
+
)
|
|
149
|
+
_run_server(config)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.command()
|
|
153
|
+
def version() -> None:
|
|
154
|
+
"""Print the installed version."""
|
|
155
|
+
typer.echo(f"flowgraph {__version__} (installed: {update_check.installed_version()})")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command()
|
|
159
|
+
def upgrade() -> None:
|
|
160
|
+
"""Upgrade FlowGraph to the latest release (pip or pipx)."""
|
|
161
|
+
import subprocess
|
|
162
|
+
cmd = update_check._upgrade_command()
|
|
163
|
+
typer.echo(f"Running: {cmd}")
|
|
164
|
+
parts = cmd.split()
|
|
165
|
+
if parts[0] == "pip":
|
|
166
|
+
parts = [sys.executable, "-m", *parts]
|
|
167
|
+
raise typer.Exit(code=subprocess.call(parts))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@keys_app.command("set")
|
|
171
|
+
def keys_set(provider: str = typer.Argument(..., help="anthropic | openrouter | openai | google | deepseek")) -> None:
|
|
172
|
+
"""Store a provider key (prompts hidden; saved to the OS keychain)."""
|
|
173
|
+
value = typer.prompt(f"{provider} API key", hide_input=True)
|
|
174
|
+
backend = keymod.set_key(provider, value)
|
|
175
|
+
if backend == "file":
|
|
176
|
+
typer.secho("No OS keychain available — saved to a 0600 config file instead.", fg="yellow")
|
|
177
|
+
typer.secho(f"Saved {provider} key ({backend}).", fg="green")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@keys_app.command("list")
|
|
181
|
+
def keys_list() -> None:
|
|
182
|
+
"""Show which providers have a key and where it comes from (never the key itself)."""
|
|
183
|
+
for provider in keymod.ENV_VARS:
|
|
184
|
+
_, source = keymod.get_key(provider)
|
|
185
|
+
mark = "·" if source == "none" else "✓"
|
|
186
|
+
typer.echo(f" {mark} {provider:<11} {source}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@keys_app.command("rm")
|
|
190
|
+
def keys_rm(provider: str = typer.Argument(...)) -> None:
|
|
191
|
+
"""Remove a stored provider key."""
|
|
192
|
+
keymod.delete_key(provider)
|
|
193
|
+
typer.secho(f"Removed {provider} key.", fg="green")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main() -> None:
|
|
197
|
+
app()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
main()
|
flowgraph/config.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Runtime configuration, paths, and static-asset resolution.
|
|
2
|
+
|
|
3
|
+
All user-writable paths go through ``platformdirs`` (never a hardcoded ~/.config).
|
|
4
|
+
The static SPA dir is resolved in three steps so the package works both as an
|
|
5
|
+
installed wheel and from a dev checkout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.resources
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from platformdirs import PlatformDirs
|
|
17
|
+
|
|
18
|
+
APP_NAME = "flowgraph"
|
|
19
|
+
APP_AUTHOR = "FlowGraph"
|
|
20
|
+
|
|
21
|
+
_dirs = PlatformDirs(appname=APP_NAME, appauthor=APP_AUTHOR)
|
|
22
|
+
|
|
23
|
+
# Provider base URLs the SPA may talk to directly (BYOK mode) — used to scope CSP connect-src.
|
|
24
|
+
PROVIDER_ORIGINS = (
|
|
25
|
+
"https://api.anthropic.com",
|
|
26
|
+
"https://openrouter.ai",
|
|
27
|
+
"https://api.openai.com",
|
|
28
|
+
"https://generativelanguage.googleapis.com",
|
|
29
|
+
"https://api.deepseek.com",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Common OpenAI-compatible local-runtime endpoints (Ollama / LM Studio / llama.cpp).
|
|
33
|
+
LOCAL_RUNTIME_PROBES = (
|
|
34
|
+
("ollama", "http://127.0.0.1:11434"),
|
|
35
|
+
("lmstudio", "http://127.0.0.1:1234"),
|
|
36
|
+
("llamacpp", "http://127.0.0.1:8080"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def config_dir() -> Path:
|
|
41
|
+
p = Path(_dirs.user_config_dir)
|
|
42
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return p
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def data_dir() -> Path:
|
|
47
|
+
p = Path(_dirs.user_data_dir)
|
|
48
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
return p
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cache_dir() -> Path:
|
|
53
|
+
p = Path(_dirs.user_cache_dir)
|
|
54
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
return p
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def state_path(name: str) -> Path:
|
|
59
|
+
"""A 0700 per-user state dir for short-lived secrets (the session-token file)."""
|
|
60
|
+
base = Path(_dirs.user_state_dir) if hasattr(_dirs, "user_state_dir") else Path(_dirs.user_data_dir)
|
|
61
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
try:
|
|
63
|
+
os.chmod(base, 0o700)
|
|
64
|
+
except OSError:
|
|
65
|
+
pass
|
|
66
|
+
return base / name
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_static_dir() -> Path:
|
|
70
|
+
"""Locate the compiled SPA. Order: explicit env > bundled wheel > dev checkout."""
|
|
71
|
+
env = os.environ.get("FLOWGRAPH_STATIC_DIR")
|
|
72
|
+
if env:
|
|
73
|
+
p = Path(env).expanduser().resolve()
|
|
74
|
+
if (p / "index.html").is_file():
|
|
75
|
+
return p
|
|
76
|
+
raise FileNotFoundError(f"FLOWGRAPH_STATIC_DIR={env} has no index.html")
|
|
77
|
+
|
|
78
|
+
# Installed wheel: flowgraph/_static (populated by hatch_build.py).
|
|
79
|
+
try:
|
|
80
|
+
bundled = importlib.resources.files("flowgraph") / "_static"
|
|
81
|
+
bundled_path = Path(str(bundled))
|
|
82
|
+
if (bundled_path / "index.html").is_file():
|
|
83
|
+
return bundled_path
|
|
84
|
+
except (ModuleNotFoundError, FileNotFoundError):
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
# Dev checkout: <repo>/app/dist relative to this file (server/flowgraph/config.py).
|
|
88
|
+
dev = Path(__file__).resolve().parents[2] / "app" / "dist"
|
|
89
|
+
if (dev / "index.html").is_file():
|
|
90
|
+
return dev
|
|
91
|
+
|
|
92
|
+
raise FileNotFoundError(
|
|
93
|
+
"FlowGraph SPA not found. Build it with `cd app && npm run build`, "
|
|
94
|
+
"or set FLOWGRAPH_STATIC_DIR to a directory containing index.html."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class ServerConfig:
|
|
100
|
+
host: str = "127.0.0.1"
|
|
101
|
+
port: int = 8765
|
|
102
|
+
open_browser: bool = True
|
|
103
|
+
auth_enabled: bool = True
|
|
104
|
+
local_model_first: bool = True
|
|
105
|
+
# docs/18 §4.8/§7.4 — the server runs an AI proxy by default (GET /config advertises
|
|
106
|
+
# transport='proxy'), so the SPA routes AI same-origin and never needs to reach a
|
|
107
|
+
# provider/local-runtime origin directly. proxy_mode=True ⇒ tighten CSP connect-src to
|
|
108
|
+
# 'self'. Set False (BYOK-direct) only to let the browser call providers itself.
|
|
109
|
+
proxy_mode: bool = True
|
|
110
|
+
# Off-loopback exposure is a loud, deliberate opt-in (see cli.py / security.py).
|
|
111
|
+
allow_lan: bool = False
|
|
112
|
+
tls_certfile: Optional[str] = None
|
|
113
|
+
tls_keyfile: Optional[str] = None
|
|
114
|
+
static_dir: Optional[Path] = None
|
|
115
|
+
# Populated at launch; never logged.
|
|
116
|
+
session_token: str = ""
|
|
117
|
+
extra_connect_src: tuple = field(default_factory=tuple)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def scheme(self) -> str:
|
|
121
|
+
return "https" if self.tls_certfile and self.tls_keyfile else "http"
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def is_loopback(self) -> bool:
|
|
125
|
+
return self.host in ("127.0.0.1", "::1", "localhost")
|