cycls 0.0.2.63__tar.gz → 0.0.2.64__tar.gz
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.
- {cycls-0.0.2.63 → cycls-0.0.2.64}/PKG-INFO +4 -3
- cycls-0.0.2.64/cycls/__init__.py +3 -0
- cycls-0.0.2.64/cycls/dev-theme/index.html +305 -0
- {cycls-0.0.2.63 → cycls-0.0.2.64}/cycls/sdk.py +16 -11
- cycls-0.0.2.64/cycls/ui.py +6 -0
- cycls-0.0.2.64/cycls/web.py +157 -0
- {cycls-0.0.2.63 → cycls-0.0.2.64}/pyproject.toml +9 -3
- cycls-0.0.2.63/cycls/__init__.py +0 -2
- cycls-0.0.2.63/cycls/web.py +0 -130
- {cycls-0.0.2.63 → cycls-0.0.2.64}/README.md +0 -0
- {cycls-0.0.2.63/cycls/theme → cycls-0.0.2.64/cycls/default-theme}/assets/index-B0ZKcm_V.css +0 -0
- {cycls-0.0.2.63/cycls/theme → cycls-0.0.2.64/cycls/default-theme}/assets/index-D5EDcI4J.js +0 -0
- {cycls-0.0.2.63/cycls/theme → cycls-0.0.2.64/cycls/default-theme}/index.html +0 -0
- {cycls-0.0.2.63 → cycls-0.0.2.64}/cycls/runtime.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cycls
|
|
3
|
-
Version: 0.0.2.
|
|
3
|
+
Version: 0.0.2.64
|
|
4
4
|
Summary: Cycls SDK
|
|
5
5
|
Author: Mohammed J. AlRujayi
|
|
6
6
|
Author-email: mj@cycls.com
|
|
@@ -12,12 +12,13 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Provides-Extra: modal
|
|
15
16
|
Requires-Dist: cloudpickle (>=3.1.1,<4.0.0)
|
|
16
17
|
Requires-Dist: docker (>=7.1.0,<8.0.0)
|
|
17
18
|
Requires-Dist: fastapi (>=0.111.0,<0.112.0)
|
|
18
19
|
Requires-Dist: httpx (>=0.27.0,<0.28.0)
|
|
19
|
-
Requires-Dist:
|
|
20
|
-
Requires-Dist:
|
|
20
|
+
Requires-Dist: modal (>=1.1.0,<2.0.0) ; extra == "modal"
|
|
21
|
+
Requires-Dist: pyjwt (>=2.8.0,<3.0.0)
|
|
21
22
|
Description-Content-Type: text/markdown
|
|
22
23
|
|
|
23
24
|
<h3 align="center">
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>Spark - Native Components</title>
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
<script src="https://cdn.tailwindcss.com?plugins=forms,typography"></script>
|
|
8
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" />
|
|
9
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
10
|
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
11
|
+
<script>
|
|
12
|
+
tailwind.config = { darkMode: "class" };
|
|
13
|
+
</script>
|
|
14
|
+
<style>
|
|
15
|
+
:root {
|
|
16
|
+
--bg-primary: #ffffff;
|
|
17
|
+
--bg-secondary: #f9fafb;
|
|
18
|
+
--text-primary: #0d0d0d;
|
|
19
|
+
--text-secondary: #6b7280;
|
|
20
|
+
--border-color: #e5e7eb;
|
|
21
|
+
--accent: #10a37f;
|
|
22
|
+
}
|
|
23
|
+
.dark {
|
|
24
|
+
--bg-primary: #212121;
|
|
25
|
+
--bg-secondary: #171717;
|
|
26
|
+
--text-primary: #ececec;
|
|
27
|
+
--text-secondary: #9ca3af;
|
|
28
|
+
--border-color: #374151;
|
|
29
|
+
--accent: #10a37f;
|
|
30
|
+
}
|
|
31
|
+
body {
|
|
32
|
+
background: var(--bg-primary);
|
|
33
|
+
color: var(--text-primary);
|
|
34
|
+
}
|
|
35
|
+
.thinking-bubble {
|
|
36
|
+
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
|
|
37
|
+
border-left: 3px solid var(--accent);
|
|
38
|
+
}
|
|
39
|
+
.dark .thinking-bubble {
|
|
40
|
+
background: linear-gradient(135deg, #374151 0%, #1f2937 100%);
|
|
41
|
+
}
|
|
42
|
+
.callout-info { border-left-color: #3b82f6; background: #eff6ff; }
|
|
43
|
+
.callout-warning { border-left-color: #f59e0b; background: #fffbeb; }
|
|
44
|
+
.callout-error { border-left-color: #ef4444; background: #fef2f2; }
|
|
45
|
+
.callout-success { border-left-color: #10b981; background: #ecfdf5; }
|
|
46
|
+
.dark .callout-info { background: #1e3a5f; }
|
|
47
|
+
.dark .callout-warning { background: #422006; }
|
|
48
|
+
.dark .callout-error { background: #450a0a; }
|
|
49
|
+
.dark .callout-success { background: #064e3b; }
|
|
50
|
+
@keyframes pulse-border {
|
|
51
|
+
0%, 100% { border-color: var(--accent); }
|
|
52
|
+
50% { border-color: transparent; }
|
|
53
|
+
}
|
|
54
|
+
.streaming { animation: pulse-border 1s infinite; }
|
|
55
|
+
</style>
|
|
56
|
+
</head>
|
|
57
|
+
<body class="dark">
|
|
58
|
+
<div id="app" class="min-h-screen flex flex-col">
|
|
59
|
+
<!-- Header -->
|
|
60
|
+
<header class="border-b border-[var(--border-color)] p-4">
|
|
61
|
+
<div class="max-w-3xl mx-auto flex items-center justify-between">
|
|
62
|
+
<h1 class="text-xl font-semibold">Spark</h1>
|
|
63
|
+
<button onclick="toggleDark()" class="p-2 rounded hover:bg-[var(--bg-secondary)]">
|
|
64
|
+
<span id="theme-icon">🌙</span>
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
</header>
|
|
68
|
+
|
|
69
|
+
<!-- Messages -->
|
|
70
|
+
<main class="flex-1 overflow-y-auto p-4">
|
|
71
|
+
<div id="messages" class="max-w-3xl mx-auto space-y-4"></div>
|
|
72
|
+
</main>
|
|
73
|
+
|
|
74
|
+
<!-- Input -->
|
|
75
|
+
<footer class="border-t border-[var(--border-color)] p-4">
|
|
76
|
+
<form id="chat-form" class="max-w-3xl mx-auto flex gap-2">
|
|
77
|
+
<input
|
|
78
|
+
type="text"
|
|
79
|
+
id="input"
|
|
80
|
+
placeholder="Send a message..."
|
|
81
|
+
class="flex-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] px-4 py-3 text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
|
82
|
+
/>
|
|
83
|
+
<button
|
|
84
|
+
type="submit"
|
|
85
|
+
class="rounded-lg bg-[var(--accent)] px-6 py-3 text-white font-medium hover:opacity-90"
|
|
86
|
+
>
|
|
87
|
+
Send
|
|
88
|
+
</button>
|
|
89
|
+
</form>
|
|
90
|
+
</footer>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<script>
|
|
94
|
+
// State
|
|
95
|
+
let messages = [];
|
|
96
|
+
let isDark = true;
|
|
97
|
+
|
|
98
|
+
// Toggle dark mode
|
|
99
|
+
function toggleDark() {
|
|
100
|
+
isDark = !isDark;
|
|
101
|
+
document.body.classList.toggle('dark', isDark);
|
|
102
|
+
document.getElementById('theme-icon').textContent = isDark ? '🌙' : '☀️';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Native component renderers
|
|
106
|
+
const components = {
|
|
107
|
+
text: (props) => marked.parse(props.content || '', { breaks: true }),
|
|
108
|
+
|
|
109
|
+
thinking: (props) => `
|
|
110
|
+
<div class="thinking-bubble rounded-lg p-4 my-3 italic text-[var(--text-secondary)]">
|
|
111
|
+
<div class="flex items-center gap-2 mb-2 text-sm font-medium text-[var(--accent)]">
|
|
112
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
113
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
|
114
|
+
</svg>
|
|
115
|
+
Thinking
|
|
116
|
+
</div>
|
|
117
|
+
<div>${props.content}</div>
|
|
118
|
+
</div>
|
|
119
|
+
`,
|
|
120
|
+
|
|
121
|
+
table: (props) => `
|
|
122
|
+
<div class="overflow-x-auto my-3">
|
|
123
|
+
<table class="min-w-full border border-[var(--border-color)] rounded-lg overflow-hidden">
|
|
124
|
+
${props.headers ? `
|
|
125
|
+
<thead class="bg-[var(--bg-secondary)]">
|
|
126
|
+
<tr>
|
|
127
|
+
${props.headers.map(h => `<th class="px-4 py-2 text-left font-medium">${h}</th>`).join('')}
|
|
128
|
+
</tr>
|
|
129
|
+
</thead>
|
|
130
|
+
` : ''}
|
|
131
|
+
<tbody>
|
|
132
|
+
${(props.rows || []).map((row, i) => `
|
|
133
|
+
<tr class="${i % 2 ? 'bg-[var(--bg-secondary)]' : ''}">
|
|
134
|
+
${row.map(cell => `<td class="px-4 py-2 border-t border-[var(--border-color)]">${cell}</td>`).join('')}
|
|
135
|
+
</tr>
|
|
136
|
+
`).join('')}
|
|
137
|
+
</tbody>
|
|
138
|
+
</table>
|
|
139
|
+
</div>
|
|
140
|
+
`,
|
|
141
|
+
|
|
142
|
+
code: (props) => {
|
|
143
|
+
const highlighted = props.language
|
|
144
|
+
? hljs.highlight(props.content, { language: props.language }).value
|
|
145
|
+
: hljs.highlightAuto(props.content).value;
|
|
146
|
+
return `
|
|
147
|
+
<div class="my-3 rounded-lg overflow-hidden border border-[var(--border-color)]">
|
|
148
|
+
<div class="bg-[var(--bg-secondary)] px-4 py-2 text-xs text-[var(--text-secondary)] flex justify-between items-center">
|
|
149
|
+
<span>${props.language || 'code'}</span>
|
|
150
|
+
<button onclick="copyCode(this)" class="hover:text-[var(--accent)]">Copy</button>
|
|
151
|
+
</div>
|
|
152
|
+
<pre class="p-4 overflow-x-auto bg-[#0d1117]"><code class="text-sm">${highlighted}</code></pre>
|
|
153
|
+
</div>
|
|
154
|
+
`;
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
callout: (props) => `
|
|
158
|
+
<div class="callout-${props.type || 'info'} border-l-4 rounded-r-lg p-4 my-3">
|
|
159
|
+
${props.title ? `<div class="font-semibold mb-1">${props.title}</div>` : ''}
|
|
160
|
+
<div class="text-sm">${props.content}</div>
|
|
161
|
+
</div>
|
|
162
|
+
`,
|
|
163
|
+
|
|
164
|
+
image: (props) => `
|
|
165
|
+
<div class="my-3">
|
|
166
|
+
<img src="${props.src}" alt="${props.alt || ''}" class="rounded-lg max-w-full" />
|
|
167
|
+
${props.caption ? `<p class="text-sm text-[var(--text-secondary)] mt-2 text-center">${props.caption}</p>` : ''}
|
|
168
|
+
</div>
|
|
169
|
+
`,
|
|
170
|
+
|
|
171
|
+
button: (props) => `
|
|
172
|
+
<button
|
|
173
|
+
onclick="handleComponentAction('${props.action || ''}', ${JSON.stringify(props.payload || {})})"
|
|
174
|
+
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--accent)] text-white font-medium hover:opacity-90 my-2"
|
|
175
|
+
>
|
|
176
|
+
${props.label}
|
|
177
|
+
</button>
|
|
178
|
+
`
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Copy code helper
|
|
182
|
+
function copyCode(btn) {
|
|
183
|
+
const code = btn.closest('.rounded-lg').querySelector('code').textContent;
|
|
184
|
+
navigator.clipboard.writeText(code);
|
|
185
|
+
btn.textContent = 'Copied!';
|
|
186
|
+
setTimeout(() => btn.textContent = 'Copy', 2000);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Component action handler (for interactive components)
|
|
190
|
+
function handleComponentAction(action, payload) {
|
|
191
|
+
console.log('Component action:', action, payload);
|
|
192
|
+
// Could send back to server or handle locally
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Render a single message
|
|
196
|
+
function renderMessage(msg) {
|
|
197
|
+
const wrapper = document.createElement('div');
|
|
198
|
+
wrapper.className = `rounded-lg p-4 ${msg.role === 'user'
|
|
199
|
+
? 'bg-[var(--accent)] text-white ml-12'
|
|
200
|
+
: 'bg-[var(--bg-secondary)] mr-12'}`;
|
|
201
|
+
|
|
202
|
+
if (msg.role === 'user') {
|
|
203
|
+
wrapper.innerHTML = `<div>${msg.content}</div>`;
|
|
204
|
+
} else {
|
|
205
|
+
let html = '';
|
|
206
|
+
for (const part of msg.parts || [])
|
|
207
|
+
html += components[part.name]?.(part) || '';
|
|
208
|
+
wrapper.innerHTML = `<div class="prose prose-invert max-w-none">${html}</div>`;
|
|
209
|
+
}
|
|
210
|
+
return wrapper;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Render all messages
|
|
214
|
+
function render() {
|
|
215
|
+
const container = document.getElementById('messages');
|
|
216
|
+
container.innerHTML = '';
|
|
217
|
+
messages.forEach(msg => container.appendChild(renderMessage(msg)));
|
|
218
|
+
container.scrollTop = container.scrollHeight;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Stream response from server
|
|
222
|
+
async function streamResponse(userMessage) {
|
|
223
|
+
messages.push({ role: 'user', content: userMessage });
|
|
224
|
+
messages.push({ role: 'assistant', parts: [] });
|
|
225
|
+
render();
|
|
226
|
+
|
|
227
|
+
const response = await fetch('/chat/cycls', {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: { 'Content-Type': 'application/json' },
|
|
230
|
+
body: JSON.stringify({
|
|
231
|
+
messages: messages.slice(0, -1).map(m => ({ role: m.role, content: m.content, parts: m.parts }))
|
|
232
|
+
})
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const reader = response.body.getReader();
|
|
236
|
+
const decoder = new TextDecoder();
|
|
237
|
+
let buffer = '';
|
|
238
|
+
let assistantMsg = messages[messages.length - 1];
|
|
239
|
+
let currentPart = null;
|
|
240
|
+
|
|
241
|
+
const decode = {
|
|
242
|
+
'+': ([, name, props]) => {
|
|
243
|
+
currentPart = {name, ...props};
|
|
244
|
+
if (props.headers) currentPart.rows = [];
|
|
245
|
+
assistantMsg.parts.push(currentPart);
|
|
246
|
+
},
|
|
247
|
+
'~': ([, props]) => {
|
|
248
|
+
if (!currentPart) return;
|
|
249
|
+
for (const [k, v] of Object.entries(props)) {
|
|
250
|
+
if (k === 'content') currentPart.content = (currentPart.content || '') + v;
|
|
251
|
+
else if (k === 'row') currentPart.rows.push(v);
|
|
252
|
+
else currentPart[k] = v;
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
'-': () => { currentPart = null; },
|
|
256
|
+
'=': ([, props]) => { assistantMsg.parts.push(props); }
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
while (true) {
|
|
260
|
+
const { done, value } = await reader.read();
|
|
261
|
+
if (done) break;
|
|
262
|
+
|
|
263
|
+
buffer += decoder.decode(value, { stream: true });
|
|
264
|
+
const lines = buffer.split('\n');
|
|
265
|
+
buffer = lines.pop() || '';
|
|
266
|
+
|
|
267
|
+
for (const line of lines) {
|
|
268
|
+
if (!line.startsWith('data: ')) continue;
|
|
269
|
+
const data = line.slice(6);
|
|
270
|
+
if (data === '[DONE]') continue;
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const msg = JSON.parse(data);
|
|
274
|
+
decode[msg[0]]?.(msg);
|
|
275
|
+
render();
|
|
276
|
+
} catch (e) {
|
|
277
|
+
console.error('Parse error:', e, data);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
assistantMsg.parts = assistantMsg.parts.filter(p =>
|
|
282
|
+
p.name !== 'text' || p.content?.trim()
|
|
283
|
+
);
|
|
284
|
+
render();
|
|
285
|
+
console.log(messages);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Form submit
|
|
289
|
+
document.getElementById('chat-form').addEventListener('submit', async (e) => {
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
const input = document.getElementById('input');
|
|
292
|
+
const message = input.value.trim();
|
|
293
|
+
if (!message) return;
|
|
294
|
+
input.value = '';
|
|
295
|
+
await streamResponse(message);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Highlight code blocks after render
|
|
299
|
+
const observer = new MutationObserver(() => {
|
|
300
|
+
document.querySelectorAll('pre code:not(.hljs)').forEach(el => hljs.highlightElement(el));
|
|
301
|
+
});
|
|
302
|
+
observer.observe(document.getElementById('messages'), { childList: true, subtree: true });
|
|
303
|
+
</script>
|
|
304
|
+
</body>
|
|
305
|
+
</html>
|
|
@@ -6,6 +6,19 @@ import importlib.resources
|
|
|
6
6
|
|
|
7
7
|
CYCLS_PATH = importlib.resources.files('cycls')
|
|
8
8
|
|
|
9
|
+
themes = {
|
|
10
|
+
"default": CYCLS_PATH.joinpath('default-theme'),
|
|
11
|
+
"dev": CYCLS_PATH.joinpath('dev-theme'),
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
def resolve_theme(theme):
|
|
15
|
+
"""Resolve theme - accepts string name or path"""
|
|
16
|
+
if isinstance(theme, str):
|
|
17
|
+
if theme in themes:
|
|
18
|
+
return themes[theme]
|
|
19
|
+
raise ValueError(f"Unknown theme: {theme}. Available: {list(themes.keys())}")
|
|
20
|
+
return theme
|
|
21
|
+
|
|
9
22
|
def function(python_version=None, pip=None, apt=None, run_commands=None, copy=None, name=None, base_url=None, key=None):
|
|
10
23
|
# """
|
|
11
24
|
# A decorator factory that transforms a Python function into a containerized,
|
|
@@ -17,9 +30,9 @@ def function(python_version=None, pip=None, apt=None, run_commands=None, copy=No
|
|
|
17
30
|
return decorator
|
|
18
31
|
|
|
19
32
|
class Agent:
|
|
20
|
-
def __init__(self, theme=
|
|
33
|
+
def __init__(self, theme="default", org=None, api_token=None, pip=[], apt=[], copy=[], copy_public=[], modal_keys=["",""], key=None, base_url=None):
|
|
21
34
|
self.org, self.api_token = org, api_token
|
|
22
|
-
self.theme = theme
|
|
35
|
+
self.theme = resolve_theme(theme)
|
|
23
36
|
self.key, self.modal_keys, self.pip, self.apt, self.copy, self.copy_public = key, modal_keys, pip, apt, copy, copy_public
|
|
24
37
|
self.base_url = base_url
|
|
25
38
|
|
|
@@ -72,16 +85,8 @@ class Agent:
|
|
|
72
85
|
copy.update({i:i for i in self.copy})
|
|
73
86
|
copy.update({i:f"public/{i}" for i in self.copy_public})
|
|
74
87
|
|
|
75
|
-
def server(port):
|
|
76
|
-
import uvicorn, logging
|
|
77
|
-
# This one-liner hides the confusing "0.0.0.0" message
|
|
78
|
-
logging.getLogger("uvicorn.error").addFilter(type("F",(),{"filter": lambda s,r: "0.0.0.0" not in r.getMessage()})())
|
|
79
|
-
print(f"\n🔨 Visit {i['name']} => http://localhost:{port}\n")
|
|
80
|
-
uvicorn.run(__import__("web").web(i["func"], *i["config"]), host="0.0.0.0", port=port)
|
|
81
|
-
|
|
82
88
|
new = Runtime(
|
|
83
|
-
|
|
84
|
-
func=server,
|
|
89
|
+
func=lambda port: __import__("web").serve(i["func"], i["config"], i["name"], port),
|
|
85
90
|
name=i["name"],
|
|
86
91
|
apt_packages=self.apt,
|
|
87
92
|
pip_packages=["fastapi[standard]", "pyjwt", "cryptography", "uvicorn", *self.pip],
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
thinking = lambda content: {"name": "thinking", "content": content}
|
|
2
|
+
status = lambda content: {"name": "status", "content": content}
|
|
3
|
+
code = lambda content, language=None: {"name": "code", "content": content, "language": language}
|
|
4
|
+
table = lambda headers=None, row=None: {"name": "table", "headers": headers} if headers else {"name": "table", "row": row} if row else None
|
|
5
|
+
callout = lambda content, type="info", title=None: {"name": "callout", "content": content, "type": type, "title": title, "_complete": True}
|
|
6
|
+
image = lambda src, alt=None, caption=None: {"name": "image", "src": src, "alt": alt, "caption": caption, "_complete": True}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import json, inspect
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
JWKS_PROD = "https://clerk.cycls.ai/.well-known/jwks.json"
|
|
5
|
+
PK_LIVE = "pk_live_Y2xlcmsuY3ljbHMuYWkk"
|
|
6
|
+
JWKS_TEST = "https://select-sloth-58.clerk.accounts.dev/.well-known/jwks.json"
|
|
7
|
+
PK_TEST = "pk_test_c2VsZWN0LXNsb3RoLTU4LmNsZXJrLmFjY291bnRzLmRldiQ"
|
|
8
|
+
|
|
9
|
+
async def openai_encoder(stream):
|
|
10
|
+
if inspect.isasyncgen(stream):
|
|
11
|
+
async for msg in stream:
|
|
12
|
+
if msg: yield f"data: {json.dumps({'choices': [{'delta': {'content': msg}}]})}\n\n"
|
|
13
|
+
else:
|
|
14
|
+
for msg in stream:
|
|
15
|
+
if msg: yield f"data: {json.dumps({'choices': [{'delta': {'content': msg}}]})}\n\n"
|
|
16
|
+
yield "data: [DONE]\n\n"
|
|
17
|
+
|
|
18
|
+
class Encoder:
|
|
19
|
+
def __init__(self): self.cur = None
|
|
20
|
+
def sse(self, d): return f"data: {json.dumps(d)}\n\n"
|
|
21
|
+
def close(self):
|
|
22
|
+
if self.cur: self.cur = None; return self.sse(["-"])
|
|
23
|
+
|
|
24
|
+
def process(self, item):
|
|
25
|
+
if not item: return
|
|
26
|
+
if not isinstance(item, dict): item = {"name": "text", "content": item}
|
|
27
|
+
n, done = item.get("name"), item.get("_complete")
|
|
28
|
+
p = {k: v for k, v in item.items() if k not in ("name", "_complete")}
|
|
29
|
+
if done:
|
|
30
|
+
if c := self.close(): yield c
|
|
31
|
+
yield self.sse(["=", {"name": n, **p}])
|
|
32
|
+
elif n != self.cur:
|
|
33
|
+
if c := self.close(): yield c
|
|
34
|
+
self.cur = n
|
|
35
|
+
yield self.sse(["+", n, p])
|
|
36
|
+
else:
|
|
37
|
+
yield self.sse(["~", p])
|
|
38
|
+
|
|
39
|
+
async def encoder(stream):
|
|
40
|
+
enc = Encoder()
|
|
41
|
+
if inspect.isasyncgen(stream):
|
|
42
|
+
async for item in stream:
|
|
43
|
+
for msg in enc.process(item): yield msg
|
|
44
|
+
else:
|
|
45
|
+
for item in stream:
|
|
46
|
+
for msg in enc.process(item): yield msg
|
|
47
|
+
if close := enc.close(): yield close
|
|
48
|
+
yield "data: [DONE]\n\n"
|
|
49
|
+
|
|
50
|
+
class Messages(list):
|
|
51
|
+
"""A list that provides text-only messages by default, with .raw for full data."""
|
|
52
|
+
def __init__(self, raw_messages):
|
|
53
|
+
self._raw = raw_messages
|
|
54
|
+
text_messages = []
|
|
55
|
+
for m in raw_messages:
|
|
56
|
+
text_content = "".join(
|
|
57
|
+
p.get("content", "") for p in m.get("parts", []) if p.get("name") == "text"
|
|
58
|
+
)
|
|
59
|
+
text_messages.append({
|
|
60
|
+
"role": m.get("role"),
|
|
61
|
+
"content": m.get("content") or text_content
|
|
62
|
+
})
|
|
63
|
+
super().__init__(text_messages)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def raw(self):
|
|
67
|
+
return self._raw
|
|
68
|
+
|
|
69
|
+
def web(func, public_path="", prod=False, org=None, api_token=None, header="", intro="", title="", auth=False, tier="", analytics=False): # API auth
|
|
70
|
+
from fastapi import FastAPI, Request, HTTPException, status, Depends
|
|
71
|
+
from fastapi.responses import StreamingResponse , HTMLResponse
|
|
72
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
73
|
+
import jwt
|
|
74
|
+
from jwt import PyJWKClient
|
|
75
|
+
from pydantic import BaseModel, EmailStr
|
|
76
|
+
from typing import List, Optional, Any
|
|
77
|
+
from fastapi.staticfiles import StaticFiles
|
|
78
|
+
|
|
79
|
+
jwks = PyJWKClient(JWKS_PROD if prod else JWKS_TEST)
|
|
80
|
+
|
|
81
|
+
class User(BaseModel):
|
|
82
|
+
id: str
|
|
83
|
+
name: Optional[str] = None
|
|
84
|
+
email: EmailStr
|
|
85
|
+
org: Optional[str] = None
|
|
86
|
+
plans: List[str] = []
|
|
87
|
+
|
|
88
|
+
class Metadata(BaseModel):
|
|
89
|
+
header: str
|
|
90
|
+
intro: str
|
|
91
|
+
title: str
|
|
92
|
+
prod: bool
|
|
93
|
+
auth: bool
|
|
94
|
+
tier: str
|
|
95
|
+
analytics: bool
|
|
96
|
+
org: Optional[str]
|
|
97
|
+
pk_live: str
|
|
98
|
+
pk_test: str
|
|
99
|
+
|
|
100
|
+
class Context(BaseModel):
|
|
101
|
+
messages: Any
|
|
102
|
+
user: Optional[User] = None
|
|
103
|
+
|
|
104
|
+
app = FastAPI()
|
|
105
|
+
bearer_scheme = HTTPBearer()
|
|
106
|
+
|
|
107
|
+
def validate(bearer: HTTPAuthorizationCredentials = Depends(bearer_scheme)):
|
|
108
|
+
try:
|
|
109
|
+
key = jwks.get_signing_key_from_jwt(bearer.credentials)
|
|
110
|
+
decoded = jwt.decode(bearer.credentials, key.key, algorithms=["RS256"], leeway=10)
|
|
111
|
+
return {"type": "user",
|
|
112
|
+
"user": {"id": decoded.get("id"), "name": decoded.get("name"), "email": decoded.get("email"), "org": decoded.get("org"),
|
|
113
|
+
"plans": decoded.get("public", {}).get("plans", [])}}
|
|
114
|
+
except:
|
|
115
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials", headers={"WWW-Authenticate": "Bearer"})
|
|
116
|
+
|
|
117
|
+
@app.post("/")
|
|
118
|
+
@app.post("/chat/cycls")
|
|
119
|
+
@app.post("/chat/completions")
|
|
120
|
+
async def back(request: Request, jwt: Optional[dict] = Depends(validate) if auth else None):
|
|
121
|
+
data = await request.json()
|
|
122
|
+
messages = data.get("messages")
|
|
123
|
+
user_data = jwt.get("user") if jwt else None
|
|
124
|
+
context = Context(messages = Messages(messages), user = User(**user_data) if user_data else None)
|
|
125
|
+
stream = await func(context) if inspect.iscoroutinefunction(func) else func(context)
|
|
126
|
+
if request.url.path == "/chat/completions":
|
|
127
|
+
stream = openai_encoder(stream)
|
|
128
|
+
elif request.url.path == "/chat/cycls":
|
|
129
|
+
stream = encoder(stream)
|
|
130
|
+
return StreamingResponse(stream, media_type="text/event-stream")
|
|
131
|
+
|
|
132
|
+
@app.get("/metadata")
|
|
133
|
+
async def metadata():
|
|
134
|
+
return Metadata(
|
|
135
|
+
header=header,
|
|
136
|
+
intro=intro,
|
|
137
|
+
title=title,
|
|
138
|
+
prod=prod,
|
|
139
|
+
auth=auth,
|
|
140
|
+
tier=tier,
|
|
141
|
+
analytics=analytics,
|
|
142
|
+
org=org,
|
|
143
|
+
pk_live=PK_LIVE,
|
|
144
|
+
pk_test=PK_TEST
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if Path("public").is_dir():
|
|
148
|
+
app.mount("/public", StaticFiles(directory="public", html=True))
|
|
149
|
+
app.mount("/", StaticFiles(directory=public_path, html=True))
|
|
150
|
+
|
|
151
|
+
return app
|
|
152
|
+
|
|
153
|
+
def serve(func, config, name, port):
|
|
154
|
+
import uvicorn, logging
|
|
155
|
+
logging.getLogger("uvicorn.error").addFilter(lambda r: "0.0.0.0" not in r.getMessage())
|
|
156
|
+
print(f"\n🔨 {name} => http://localhost:{port}\n")
|
|
157
|
+
uvicorn.run(web(func, *config), host="0.0.0.0", port=port)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "cycls"
|
|
3
|
-
version = "0.0.2.
|
|
3
|
+
version = "0.0.2.64"
|
|
4
4
|
|
|
5
5
|
packages = [{ include = "cycls" }]
|
|
6
6
|
include = ["cycls/theme/**/*"]
|
|
@@ -12,11 +12,17 @@ readme = "README.md"
|
|
|
12
12
|
python = "^3.9"
|
|
13
13
|
fastapi = "^0.111.0"
|
|
14
14
|
httpx = "^0.27.0"
|
|
15
|
-
|
|
16
|
-
jwt = "^1.4.0"
|
|
15
|
+
pyjwt = "^2.8.0"
|
|
17
16
|
docker = "^7.1.0"
|
|
18
17
|
cloudpickle = "^3.1.1"
|
|
19
18
|
|
|
19
|
+
[tool.poetry.extras]
|
|
20
|
+
modal = ["modal"]
|
|
21
|
+
|
|
22
|
+
[tool.poetry.dependencies.modal]
|
|
23
|
+
version = "^1.1.0"
|
|
24
|
+
optional = true
|
|
25
|
+
|
|
20
26
|
[build-system]
|
|
21
27
|
requires = ["poetry-core"]
|
|
22
28
|
build-backend = "poetry.core.masonry.api"
|
cycls-0.0.2.63/cycls/__init__.py
DELETED
cycls-0.0.2.63/cycls/web.py
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import json, inspect
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
async def async_openai_encoder(stream): # clean up the meta data / new API?
|
|
5
|
-
async for message in stream:
|
|
6
|
-
payload = {"id": "chatcmpl-123",
|
|
7
|
-
"object": "chat.completion.chunk",
|
|
8
|
-
"created": 1728083325,
|
|
9
|
-
"model": "model-1-2025-01-01",
|
|
10
|
-
"system_fingerprint": "fp_123456",
|
|
11
|
-
"choices": [{"delta": {"content": message}}]}
|
|
12
|
-
if message:
|
|
13
|
-
yield f"data: {json.dumps(payload)}\n\n"
|
|
14
|
-
yield "data: [DONE]\n\n"
|
|
15
|
-
|
|
16
|
-
def openai_encoder(stream):
|
|
17
|
-
for message in stream:
|
|
18
|
-
payload = {"id": "chatcmpl-123",
|
|
19
|
-
"object": "chat.completion.chunk",
|
|
20
|
-
"created": 1728083325,
|
|
21
|
-
"model": "model-1-2025-01-01",
|
|
22
|
-
"system_fingerprint": "fp_123456",
|
|
23
|
-
"choices": [{"delta": {"content": message}}]}
|
|
24
|
-
if message:
|
|
25
|
-
yield f"data: {json.dumps(payload)}\n\n"
|
|
26
|
-
yield "data: [DONE]\n\n"
|
|
27
|
-
|
|
28
|
-
test_auth_public_key = """
|
|
29
|
-
-----BEGIN PUBLIC KEY-----
|
|
30
|
-
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyDudrDtQ5irw6hPWf2rw
|
|
31
|
-
FvNAFWeOouOO3XNWVQrjXCZfegiLYkL4cJdm4eqIuMdFHGnXU+gWT5P0EkLIkbtE
|
|
32
|
-
zpqDb5Wp27WpSRb5lqJehpU7FE+oQuovCwR9m5gYXP5rfM+CQ7ZPw/CcOQPtOB5G
|
|
33
|
-
0UijBhmYqws3SFp1Rk1uFed1F/esspt6Ifq2uDSHESleylqTKUCQiBa++z4wllcV
|
|
34
|
-
PbNiooLRpsF0kGljP2dXXy/ViF7q9Cblgl+FdrqtGfHD+DHJuOSYcPnRa0IHZYS4
|
|
35
|
-
r5i9C2lejVrEDqgJk5IbmQgez0wmEG4ynAxiDLvfdtvrd27PyBI75FsyLER/ydBH
|
|
36
|
-
WwIDAQAB
|
|
37
|
-
-----END PUBLIC KEY-----
|
|
38
|
-
"""
|
|
39
|
-
|
|
40
|
-
live_auth_public_key = """
|
|
41
|
-
-----BEGIN PUBLIC KEY-----
|
|
42
|
-
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAorfL7XyxrLG/X+Kq9ImY
|
|
43
|
-
oSQ+Y3PY5qi8t8R4urY9u4ADJ48j9LkmFz8ALbubQkl3IByDDuVbka49m8id9isy
|
|
44
|
-
F9ZJErsZzzlYztrgI5Sg4R6OJXcNWLqh/tzutMWJFOrE3LnHXpeyQMo/6qAd59Dx
|
|
45
|
-
sNqzGxBTGPV1BZvpfhp/TT/sjgbPQWHS4PMpKD4vZLKXeTNJ913fMTUoFAIaL0sT
|
|
46
|
-
EhoeLUwvIuhLx4UYTmjO/sa+fS6mdghjddOkjSS/AWr/K8mN3IXDImGqh83L7/P0
|
|
47
|
-
RCru4Hvarm0qPIhfwEFfWhKFXONMj3x2fT4MM1Uw1H7qKTER2MtOjmdchKNX7x9b
|
|
48
|
-
XwIDAQAB
|
|
49
|
-
-----END PUBLIC KEY-----
|
|
50
|
-
"""
|
|
51
|
-
|
|
52
|
-
def web(func, public_path="", prod=False, org=None, api_token=None, header="", intro="", title="", auth=False, tier="", analytics=False): # API auth
|
|
53
|
-
from fastapi import FastAPI, Request, HTTPException, status, Depends
|
|
54
|
-
from fastapi.responses import StreamingResponse , HTMLResponse
|
|
55
|
-
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
56
|
-
import jwt
|
|
57
|
-
from pydantic import BaseModel, EmailStr
|
|
58
|
-
from typing import List, Optional
|
|
59
|
-
from fastapi.staticfiles import StaticFiles
|
|
60
|
-
|
|
61
|
-
class User(BaseModel):
|
|
62
|
-
id: str
|
|
63
|
-
name: Optional[str] = None
|
|
64
|
-
email: EmailStr
|
|
65
|
-
org: Optional[str] = None
|
|
66
|
-
plans: List[str] = []
|
|
67
|
-
|
|
68
|
-
class Metadata(BaseModel):
|
|
69
|
-
header: str
|
|
70
|
-
intro: str
|
|
71
|
-
title: str
|
|
72
|
-
prod: bool
|
|
73
|
-
auth: bool
|
|
74
|
-
tier: str
|
|
75
|
-
analytics: bool
|
|
76
|
-
org: Optional[str]
|
|
77
|
-
pk_live: str
|
|
78
|
-
pk_test: str
|
|
79
|
-
|
|
80
|
-
class Context(BaseModel):
|
|
81
|
-
messages: List[dict]
|
|
82
|
-
user: Optional[User] = None
|
|
83
|
-
|
|
84
|
-
app = FastAPI()
|
|
85
|
-
bearer_scheme = HTTPBearer()
|
|
86
|
-
|
|
87
|
-
def validate(bearer: HTTPAuthorizationCredentials = Depends(bearer_scheme)):
|
|
88
|
-
# if api_token and api_token==""
|
|
89
|
-
try:
|
|
90
|
-
public_key = live_auth_public_key if prod else test_auth_public_key
|
|
91
|
-
decoded = jwt.decode(bearer.credentials, public_key, algorithms=["RS256"], leeway=10)
|
|
92
|
-
# print(decoded)
|
|
93
|
-
return {"type": "user",
|
|
94
|
-
"user": {"id": decoded.get("id"), "name": decoded.get("name"), "email": decoded.get("email"), "org": decoded.get("org"),
|
|
95
|
-
"plans": decoded.get("public").get("plans", [])}}
|
|
96
|
-
except:
|
|
97
|
-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials", headers={"WWW-Authenticate": "Bearer"})
|
|
98
|
-
|
|
99
|
-
@app.post("/")
|
|
100
|
-
@app.post("/chat/completions")
|
|
101
|
-
async def back(request: Request, jwt: Optional[dict] = Depends(validate) if auth else None):
|
|
102
|
-
data = await request.json()
|
|
103
|
-
messages = data.get("messages")
|
|
104
|
-
user_data = jwt.get("user") if jwt else None
|
|
105
|
-
context = Context(messages = messages, user = User(**user_data) if user_data else None)
|
|
106
|
-
stream = await func(context) if inspect.iscoroutinefunction(func) else func(context)
|
|
107
|
-
if request.url.path == "/chat/completions":
|
|
108
|
-
stream = async_openai_encoder(stream) if inspect.isasyncgen(stream) else openai_encoder(stream)
|
|
109
|
-
return StreamingResponse(stream, media_type="text/event-stream")
|
|
110
|
-
|
|
111
|
-
@app.get("/metadata")
|
|
112
|
-
async def metadata():
|
|
113
|
-
return Metadata(
|
|
114
|
-
header=header,
|
|
115
|
-
intro=intro,
|
|
116
|
-
title=title,
|
|
117
|
-
prod=prod,
|
|
118
|
-
auth=auth,
|
|
119
|
-
tier=tier,
|
|
120
|
-
analytics=analytics,
|
|
121
|
-
org=org,
|
|
122
|
-
pk_live="pk_live_Y2xlcmsuY3ljbHMuYWkk",
|
|
123
|
-
pk_test="pk_test_c2VsZWN0LXNsb3RoLTU4LmNsZXJrLmFjY291bnRzLmRldiQ"
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
if Path("public").is_dir():
|
|
127
|
-
app.mount("/public", StaticFiles(directory="public", html=True))
|
|
128
|
-
app.mount("/", StaticFiles(directory=public_path, html=True))
|
|
129
|
-
|
|
130
|
-
return app
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|