syntropy 0.38.1 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +2 -0
  4. data/TODO.md +55 -144
  5. data/cmd/console.rb +2 -4
  6. data/cmd/new/template/config/production.rb +2 -1
  7. data/cmd/new.rb +1 -1
  8. data/cmd/serve.rb +29 -4
  9. data/cmd/test.rb +12 -4
  10. data/examples/agent/Dockerfile +25 -0
  11. data/examples/agent/Gemfile +3 -0
  12. data/examples/agent/README.md +40 -0
  13. data/examples/agent/TODO.md +5 -0
  14. data/examples/agent/app/_layout/default.rb +15 -0
  15. data/examples/agent/app/_lib/storage.rb +13 -0
  16. data/examples/agent/app/_schema/2026-01-01-initial.rb +9 -0
  17. data/examples/agent/app/assets/agent.js +63 -0
  18. data/examples/agent/app/assets/minigfm.js +207 -0
  19. data/examples/agent/app/assets/model.js +45 -0
  20. data/examples/agent/app/assets/style.css +25 -0
  21. data/examples/agent/app/assets/tools.js +74 -0
  22. data/examples/agent/app/assets/ui.js +50 -0
  23. data/examples/agent/app/index.rb +19 -0
  24. data/examples/agent/app/test.rb +7 -0
  25. data/examples/agent/config/Caddyfile +5 -0
  26. data/examples/agent/config/development.rb +5 -0
  27. data/examples/agent/config/production.rb +5 -0
  28. data/examples/agent/config/test.rb +5 -0
  29. data/examples/agent/docker-compose.yml +71 -0
  30. data/examples/agent/response_example.json +108 -0
  31. data/examples/agent/test/test_app.rb +14 -0
  32. data/examples/blog/app/foo.rb +1 -0
  33. data/examples/blog/app/posts/[id]/edit.rb +3 -3
  34. data/examples/blog/app/posts/[id]/index.rb +8 -10
  35. data/examples/blog/app/posts/index.rb +6 -8
  36. data/examples/blog/app/posts/new.rb +3 -3
  37. data/examples/blog/config/production.rb +2 -1
  38. data/lib/syntropy/app.rb +12 -60
  39. data/lib/syntropy/applets/builtin/auto_refresh/watch.js +1 -1
  40. data/lib/syntropy/applets/builtin/auto_refresh/watch.sse.rb +7 -0
  41. data/lib/syntropy/controller_extensions.rb +1 -1
  42. data/lib/syntropy/dev_mode.rb +8 -3
  43. data/lib/syntropy/http/client.rb +17 -1
  44. data/lib/syntropy/http/client_connection.rb +16 -0
  45. data/lib/syntropy/http/io_extensions.rb +32 -4
  46. data/lib/syntropy/http/server.rb +55 -24
  47. data/lib/syntropy/http/server_connection.rb +72 -2
  48. data/lib/syntropy/logger.rb +8 -0
  49. data/lib/syntropy/markdown.rb +139 -11
  50. data/lib/syntropy/module_loader.rb +110 -68
  51. data/lib/syntropy/request/request_info.rb +8 -0
  52. data/lib/syntropy/request/response.rb +76 -6
  53. data/lib/syntropy/request/validation.rb +6 -0
  54. data/lib/syntropy/storage/connection_pool.rb +34 -0
  55. data/lib/syntropy/test.rb +14 -5
  56. data/lib/syntropy/version.rb +1 -1
  57. data/lib/syntropy.rb +9 -2
  58. data/syntropy.gemspec +4 -4
  59. data/test/fixtures/app/_layout/default.rb +1 -1
  60. data/test/fixtures/app/_layout/kuku.rb +8 -0
  61. data/test/fixtures/app/_lib/circular/a.rb +2 -0
  62. data/test/fixtures/app/_lib/circular/b.rb +2 -0
  63. data/test/fixtures/app/_lib/circular/c.rb +2 -0
  64. data/test/fixtures/app/mod/concurrent.rb +9 -0
  65. data/test/test_app.rb +1 -1
  66. data/test/test_caching.rb +3 -3
  67. data/test/test_markdown.rb +268 -0
  68. data/test/test_module_loader.rb +32 -4
  69. data/test/test_request.rb +7 -0
  70. data/test/test_response.rb +30 -0
  71. data/test/test_schema.rb +1 -0
  72. metadata +40 -11
@@ -0,0 +1,207 @@
1
+ // source: https://github.com/OblivionOcean/MiniGFM
2
+ // (MIT License)
3
+ /**
4
+ * MiniGFM - 一个简单的Markdown解析器,基本支持GFM语法。
5
+ * @author OblivionOcean
6
+ * @version 1.0.7
7
+ * @class
8
+ */
9
+ export default class MiniGFM {
10
+
11
+ constructor(options) {
12
+ this.options = options || {};
13
+ }
14
+
15
+ /**
16
+ * 解析Markdown文本并返回HTML字符串
17
+ * @param {string} markdown - Markdown文本
18
+ * @returns {string} HTML字符串
19
+ */
20
+ parse(markdown) {
21
+ if (typeof markdown != "string") return '';
22
+ const codeBlocks = [], codeInline = [];
23
+ markdown = markdown
24
+ // 保存原始代码块
25
+ .replace(/(?:^|\n)[^\\]?(`{3,4})[ ]*(\w*?)\n([\s\S]*?)\n\1/g, (_, __, lang, code) => {
26
+ codeBlocks.push({ lang: lang.trim(), code: code.trim() });
27
+ return `<!----CODEBLOCK${codeBlocks.length - 1}---->`;
28
+ })
29
+ // 保持内联代码
30
+ .replace(/([^\\])`([^`]+)`/g, (_, after, code) => {
31
+ codeInline.push(this.escapeHTML(code));
32
+ return `${after}<!----CODEINLINE${codeInline.length - 1}---->`;
33
+ })
34
+ // 转义特殊字符
35
+ .replace(/\\([\\*_{}[\]()#+\-.!`])/g, (_, m) => `&#${m.charCodeAt(0)}`)
36
+ // 删除注释
37
+ .replace(/%%[\n ][^%]+[\n ]%%/g, '');
38
+
39
+ markdown = this.parseInlines(this.parseBlocks(markdown))// 解析块和内联元素
40
+ // 恢复内联代码
41
+ .replace(/<!----CODEINLINE(\d+)---->/g, (_, id) =>
42
+ codeInline[id] ? `<code>${codeInline[id]}</code>` : ''
43
+ )
44
+ // 恢复代码块
45
+ .replace(/<!----CODEBLOCK(\d+)---->/g, (_, id) => {
46
+ if (!codeBlocks[id]) return '';
47
+ const { lang, code } = codeBlocks[id];
48
+ let highlighted = code;
49
+
50
+ if (this.options.hljs) try {
51
+ highlighted = (lang
52
+ ? this.options.hljs.highlight(code, { language: lang })
53
+ : this.options.hljs.highlightAuto(code)).value;
54
+ } catch { }
55
+
56
+ return lang
57
+ ? `<pre lang="${lang}"><code class="hljs ${lang} lang-${lang}">${highlighted}</code></pre>`
58
+ : `<pre><code>${highlighted}</code></pre>`;
59
+ });
60
+ return (!this.options.unsafe) ? this.safeHTML(markdown) : markdown;
61
+ }
62
+
63
+ /**
64
+ * 解析跨行元素和行级块
65
+ * @param {string} text - 待处理的文本
66
+ * @returns {string} 处理后的文本
67
+ * @private
68
+ * @static
69
+ */
70
+ parseBlocks(text) {
71
+ return text
72
+ // 标题
73
+ .replace(/^[^\\]?\s*(#{1,6}) ([^\n]+)$/gm, (_, level, content) => `<h${level.length}>${content}</h${level.length}>`)
74
+
75
+ // 任务列表
76
+ .replace(/^[ \t]*[-*+][ \t]+\[([ ]*[ xX]?)\]\s([^\n]+)$/gm, (_, checked, content) => `<li><input type="checkbox" ${checked.trim().toLowerCase() === 'x' ? 'checked' : ''} disabled> ${content}</li>`)
77
+
78
+ // 无序列表
79
+ .replace(/^[ \t]*[-*+] ([^\n]+)$/gm, `<li>$1</li>`)
80
+
81
+ // 有序列表
82
+ .replace(/^[ \t]*(\d+\.) ([^\n]+)$/gm, `<li>$1 $2</li>`)
83
+
84
+ // 分隔线
85
+ .replace(/^ {0,3}(([*_-])( *\2 *){2,})(?:\s*$|$)/gm, () => '<hr/>')
86
+
87
+ // 引用块
88
+ .replace(/^[ \t]*((?:\>[ \t]*)+)([^\n]*)$/gm, (_, sep, content) => {
89
+ if (content.trim() === '') return '';
90
+ let num = sep.length / 2;
91
+ return "<blockquote>".repeat(num) + content + "</blockquote>".repeat(num);
92
+ })
93
+
94
+ // 表格
95
+ .replace(/^([^\n]*\|[^\n]*)\n([-:| ]+\|)+[-\| ]*\n((?:[^\n]*\|[^\n]*(?:\n|$))*)/gm, this.parseTable.bind(this))
96
+
97
+ // 段落处理
98
+ .split(/\n{2,}|\\\n/g)
99
+ .map(s => /^<(\w+)/.test(s) ? s : `<p>${s}</p>`)
100
+ .join(this.options.noMoreBr ? '' : '<br />');
101
+ }
102
+
103
+ /**
104
+ * 解析表格
105
+ * @param {*} _ 忽略参数,保持接口一致
106
+ * @param {Array} headers 表头
107
+ * @param {String} alignLine 对齐方式
108
+ * @param {Array} rows 表格行
109
+ * @return {String}
110
+ * @private
111
+ */
112
+ parseTable(_, headers, alignLine, rows) {
113
+ // 解析表头
114
+ const headerCols = headers.split('|').map(h => h.trim()).filter(Boolean);
115
+
116
+ // 解析对齐方式
117
+ const aligns = this.parseTableAlignment(alignLine);
118
+
119
+ // 解析行数据(兼容列数不一致的情况)
120
+ const bodyRows = rows.trim().split('\n').reduce((arr, line) => {
121
+ if (!line.includes('|')) return arr;
122
+ const cols = line.split('|').slice(1, -1).map(c => c.trim()); // 移除首尾空列
123
+ arr.push(headerCols.map((_, i) => cols[i] || '')); // 按表头列数填充
124
+ return arr;
125
+ }, []);
126
+ const table = ['<table>', '<thead><tr>', ...headerCols.map((h, i) => `<th${aligns[i] ? ` align="${aligns[i]}"` : ''}>${h}</th>`), '</tr></thead>'];
127
+ if (bodyRows.length) {
128
+ table.push('<tbody>');
129
+ bodyRows.forEach(row => {
130
+ table.push('<tr>',
131
+ ...row.map((c, j) => `<td${aligns[j] ? ` align="${aligns[j]}"` : ''}>${c}</td>`),
132
+ '</tr>');
133
+ });
134
+ table.push('</tbody>');
135
+ }
136
+
137
+ return [...table, '</table>'].join('');
138
+ }
139
+
140
+ /**
141
+ * 解析表格对齐方式
142
+ * @param {string} alignLine
143
+ * @returns {Array}
144
+ * @private
145
+ * @static
146
+ */
147
+ parseTableAlignment(alignLine) {
148
+ return alignLine.split('|').map(part => {
149
+ part = part.trim();
150
+ if (!part) return null;
151
+ const left = part.startsWith(':'), right = part.endsWith(':');
152
+ return left && right ? 'center' : left ? 'left' : right ? 'right' : null;
153
+ }).filter(Boolean);
154
+ }
155
+
156
+ /**
157
+ * 解析内联表达式
158
+ * @param {string} text
159
+ * @return {string} 解析后的文本
160
+ * @private
161
+ * @static
162
+ */
163
+ parseInlines(text) {
164
+ // 粗体
165
+ return text
166
+ .replace(/(.+)^(.+)/g, '$1<sup>$2</sup>')
167
+ .replace(/(.+)_(.+)/g, '$1<sub>$2</sub>')
168
+ .replace(/[*_]{2}(.+?)[*_]{2}/g, '<strong>$1</strong>')
169
+ .replace(/(?<!\*)_(.+?)_(?!\*)|(?<!\*)\*(.+?)\*(?!\*)/, (_, g1, g2) => `<em>${g1 || g2}</em>`)
170
+
171
+ // 删除线
172
+ .replace(/~~(.+?)~~/g, '<del>$1</del>')
173
+
174
+ // 自动链接
175
+ .replace(/\<([^\s@>]+@[^\s@>]+\.[^\s@>]+)\>/g, '<a href="mailto:$1">$1</a>')
176
+ .replace(/\<((?:https?:\/\/|ftp:\/\/|mailto:|tel:)[^>\s]+)\>/g, '<a href="$1">$1</a>')
177
+
178
+ // 图片
179
+ .replace(/\!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">')
180
+
181
+ // 链接
182
+ .replace(/\[([^\]]+)\]\(([^) ]+)[ ]?(\"[^)"\"]+\")?\)/g, (_, desc, url, title) => `<a href="${url}"${(title) ? " title=" + title : ""}>${desc}</a>`);
183
+ }
184
+
185
+ /**
186
+ * 转义 HTML 字符串
187
+ * @param {string} text
188
+ * @returns {string}
189
+ */
190
+ escapeHTML(text) {
191
+ return text.replace(/[&<>"']/g, m => `&#${m.charCodeAt(0)}`)
192
+ }
193
+
194
+ /**
195
+ * 安全化 HTML 字符串
196
+ * @param {string} text
197
+ * @returns {string}
198
+ */
199
+ safeHTML(text) {
200
+ return text
201
+ .replace(/<(\/?)\s*(script|iframe|object|embed|frame|link|meta|style|svg|math)[^>]*>/gi, m => this.escapeHTML(m))
202
+ .replace(/\s(?!data-)[\w-]+=\s*["'\s]*(javascript:|data:|expression:)[^"'\s>]*/gi, '').replace(
203
+ /\<[^\>]+\>/g,
204
+ tag => tag.replace(/\s+on\w+\s*=\s*["']?[^"'\\]*["']?/gi, '')
205
+ );
206
+ }
207
+ }
@@ -0,0 +1,45 @@
1
+ import * as Tools from './tools.js';
2
+
3
+ export function buildExchangeBody(prompt) {
4
+ return {
5
+ model: "google/gemma-4-26b-a4b-it",
6
+ messages: [
7
+ {
8
+ role: "user",
9
+ content: prompt
10
+ }
11
+ ],
12
+ tools: Tools.spec()
13
+ };
14
+
15
+ }
16
+
17
+ export async function query(ctx) {
18
+ const api_key = window.OPEN_ROUTER_API_KEY;
19
+ const response = await fetch(
20
+ "https://openrouter.ai/api/v1/chat/completions",
21
+ {
22
+ method: "POST",
23
+ headers: {
24
+ "Authorization": `Bearer ${api_key}`,
25
+ "Content-Type": "application/json"
26
+ },
27
+ body: JSON.stringify(ctx)
28
+ }
29
+ );
30
+ return await response.json();
31
+ }
32
+
33
+ export function addMessage(ctx, message) {
34
+ ctx.messages.push(message);
35
+ }
36
+
37
+ export function mergeToolResults(ctx, tool_results) {
38
+ tool_results.forEach((r) => {
39
+ addMessage(ctx, {
40
+ role: "tool",
41
+ tool_call_id: r.id,
42
+ content: JSON.stringify(r.result)
43
+ });
44
+ });
45
+ }
@@ -0,0 +1,25 @@
1
+ * {
2
+ border: 0;
3
+ font: inherit;
4
+ font-size: 100%;
5
+ vertical-align: baseline;
6
+ line-height: 1.5em;
7
+ margin: 0;
8
+ padding: 0;
9
+ background-color: #f8f8f8;
10
+ color: #222;
11
+ }
12
+
13
+ body {
14
+ max-width: 800px;
15
+ margin: 4em auto;
16
+
17
+ font-size: 1.5em;
18
+ font-family: sans-serif;
19
+ }
20
+
21
+ @media (max-width: 768px) {
22
+ body {
23
+ margin-inline: 1em;
24
+ }
25
+ }
@@ -0,0 +1,74 @@
1
+ export function spec() {
2
+ return [
3
+ {
4
+ type: "function",
5
+ function: {
6
+ name: "get_unit_alarms",
7
+ description: "Retrieve list of alarms for one or more units. If no unit names are given, returns all alarms",
8
+ parameters: {
9
+ type: "object",
10
+ "properties": {
11
+ units: {
12
+ type: "array",
13
+ items: { type: "string" },
14
+ description: "List of unit names"
15
+ }
16
+ },
17
+ required: ["units"]
18
+ }
19
+ }
20
+ },
21
+
22
+ {
23
+ "type": "function",
24
+ "function": {
25
+ "name": "geo_search_units",
26
+ "description": "Search for units by location",
27
+ "parameters": {
28
+ type: "object",
29
+ "properties": {
30
+ location: {
31
+ "type": "string",
32
+ "description": "Location"
33
+ }
34
+ },
35
+ required: ["location"]
36
+ }
37
+ }
38
+ }
39
+ ]
40
+ }
41
+
42
+ export async function toolHandler(name, args) {
43
+ if (name == "get_unit_alarms") {
44
+ return await get_unit_alarms(args);
45
+ }
46
+
47
+ throw new Error(`Invalid tool call: ${name}`);
48
+ }
49
+
50
+ async function get_unit_alarms({ units }) {
51
+ const alarms = [];
52
+ const all = units.length == 0;
53
+ if (all || (units.indexOf('/icex1') >= 0)) {
54
+ alarms.push({
55
+ path: '/icex1/a1',
56
+ description: 'High pressure in unit 1'
57
+ });
58
+ alarms.push({
59
+ path: '/icex1/a2',
60
+ description: 'High temperature in unit 1'
61
+ });
62
+ }
63
+ if (all || (units.indexOf('/icex2') >= 0)) {
64
+ alarms.push({
65
+ path: '/icex2/a3',
66
+ description: 'Low pressure in unit 2'
67
+ });
68
+ alarms.push({
69
+ path: '/icex2/a4',
70
+ description: 'Low temperature in unit 2'
71
+ });
72
+ }
73
+ return alarms;
74
+ }
@@ -0,0 +1,50 @@
1
+ import MiniGFM from './minigfm.js'; // for UI
2
+
3
+ export function createNewSection() {
4
+ const main = document.querySelector("main");
5
+ const section = document.createElement("section");
6
+ main.appendChild(section);
7
+ return section;
8
+ }
9
+
10
+ export async function askPrompt(section) {
11
+ return new Promise((resolve) => {
12
+ setupForm(section, resolve);
13
+ });
14
+ }
15
+
16
+ function setupForm(section, submit_callback) {
17
+ const template = document.querySelector("#template-prompt-form");
18
+ const form = document.importNode(template.content, true);
19
+ section.appendChild(form);
20
+ section
21
+ .querySelector("#prompt-form")
22
+ .addEventListener("submit", function (e) {
23
+ e.preventDefault();
24
+ submit_callback(section.querySelector("#prompt-text").value);
25
+ });
26
+ setTimeout(() => {
27
+ section.querySelector("#prompt-text").focus();
28
+ }, 50);
29
+
30
+ }
31
+
32
+ export function addSectionEntryMarkdown(section, md) {
33
+ const parser = new MiniGFM({});
34
+ const html = parser.parse(md);
35
+
36
+ const ele = document.createElement("div");
37
+ ele.innerHTML = html;
38
+ section.appendChild(ele);
39
+ return ele;
40
+ }
41
+
42
+ export function addPendingEntry(section) {
43
+ const ele = addSectionEntryMarkdown(section, "*Waiting for response...*");
44
+ ele.classList.add("pending");
45
+ }
46
+
47
+ export function removePendingEntry(section) {
48
+ const ele = section.querySelector('.pending');
49
+ if (ele) ele.remove();
50
+ }
@@ -0,0 +1,19 @@
1
+ layout = import '_layout/default'
2
+
3
+ require 'papercraft/version'
4
+
5
+ export layout.apply {
6
+ header {
7
+ h1 'Fake-MCP Agent test'
8
+ }
9
+ main {
10
+ }
11
+ template(id: 'template-prompt-form') {
12
+ form(id: 'prompt-form') {
13
+ input type: 'text', id: 'prompt-text', minlength: 5, required: true, value: 'Give me all alarms'
14
+ button "Submit", type: 'submit', id: 'prompt-submit'
15
+ }
16
+ }
17
+ # script(src: '/assets/minigfm.js' )
18
+ script(src: '/assets/agent.js', type: 'module')
19
+ }
@@ -0,0 +1,7 @@
1
+ layout = import '_layout/default'
2
+
3
+ export layout.apply {
4
+ p {
5
+ span 'Hello!'
6
+ }
7
+ }
@@ -0,0 +1,5 @@
1
+ localhost {
2
+ reverse_proxy app_server:1234
3
+ tls internal
4
+ encode
5
+ }
@@ -0,0 +1,5 @@
1
+ export(
2
+ storage: {
3
+ path: ENV['DATABASE_PATH'] || 'storage/development.db'
4
+ }
5
+ )
@@ -0,0 +1,5 @@
1
+ export(
2
+ storage: {
3
+ path: ENV['DATABASE_PATH'] || 'storage/production.db'
4
+ }
5
+ )
@@ -0,0 +1,5 @@
1
+ export({
2
+ storage: {
3
+ path: ENV['DATABASE_PATH'] || Syntropy.tmp_path('test-db')
4
+ }
5
+ })
@@ -0,0 +1,71 @@
1
+ #version: "3.8"
2
+
3
+ services:
4
+ app_server:
5
+ build: .
6
+ security_opt:
7
+ - seccomp:unconfined
8
+ volumes:
9
+ - .:/syntropy
10
+ stop_signal: SIGINT
11
+ stop_grace_period: 10s
12
+ restart: unless-stopped
13
+ healthcheck:
14
+ test: "curl 'http://localhost:1234/'"
15
+ interval: "30s"
16
+ timeout: "3s"
17
+ start_period: "5s"
18
+ retries: 3
19
+ networks:
20
+ - proxy_network
21
+
22
+ console:
23
+ build: .
24
+ command: bundle exec syntropy console
25
+ stdin_open: true # docker run -i
26
+ tty: true
27
+ security_opt:
28
+ - seccomp:unconfined
29
+ profiles:
30
+ - dev
31
+ volumes:
32
+ - .:/syntropy
33
+ stop_signal: SIGINT
34
+ restart: never
35
+
36
+ test:
37
+ build: .
38
+ command: bundle exec syntropy test -w
39
+ stdin_open: true # docker run -i
40
+ tty: true
41
+ security_opt:
42
+ - seccomp:unconfined
43
+ profiles:
44
+ - dev
45
+ volumes:
46
+ - .:/syntropy
47
+ stop_signal: SIGINT
48
+ restart: never
49
+
50
+ proxy:
51
+ depends_on:
52
+ - app_server
53
+ image: caddy:2-alpine
54
+ build:
55
+ context: ./proxy
56
+ dockerfile: Dockerfile
57
+ restart: unless-stopped
58
+ ports:
59
+ - "80:80"
60
+ - "443:443"
61
+ - "443:443/udp"
62
+ volumes:
63
+ - ./config/Caddyfile:/etc/caddy/Caddyfile
64
+ - ./storage/caddy/data:/data
65
+ - ./storage/caddy/config:/config
66
+ networks:
67
+ - proxy_network
68
+
69
+ networks:
70
+ proxy_network:
71
+ name: proxy_network
@@ -0,0 +1,108 @@
1
+ {
2
+ "id": "gen-1782133158-2Ap6AR8TBbCGsoP4ACkh",
3
+ "object": "chat.completion",
4
+ "created": 1782133158,
5
+ "model": "google/gemma-4-26b-a4b-it-20260403",
6
+ "provider": "DeepInfra",
7
+ "system_fingerprint": null,
8
+ "service_tier": null,
9
+ "choices": [
10
+ {
11
+ "index": 0,
12
+ "logprobs": null,
13
+ "finish_reason": "tool_calls",
14
+ "native_finish_reason": "tool_calls",
15
+ "message": {
16
+ "role": "assistant",
17
+ "content": null,
18
+ "refusal": null,
19
+ "reasoning": null,
20
+ "tool_calls": [
21
+ {
22
+ "type": "function",
23
+ "index": 0,
24
+ "id": "chatcmpl-tool-a10b45dc6cb1da7c",
25
+ "function": {
26
+ "name": "get_unit_alarms",
27
+ "arguments": "{\"units\": []}"
28
+ }
29
+ }
30
+ ]
31
+ }
32
+ }
33
+ ],
34
+ "usage": {
35
+ "prompt_tokens": 138,
36
+ "completion_tokens": 15,
37
+ "total_tokens": 153,
38
+ "cost": 0.00001476,
39
+ "is_byok": false,
40
+ "prompt_tokens_details": {
41
+ "cached_tokens": 0,
42
+ "cache_write_tokens": 0,
43
+ "audio_tokens": 0,
44
+ "video_tokens": 0
45
+ },
46
+ "cost_details": {
47
+ "upstream_inference_cost": 0.00001476,
48
+ "upstream_inference_prompt_cost": 0.00000966,
49
+ "upstream_inference_completions_cost": 0.0000051
50
+ },
51
+ "completion_tokens_details": {
52
+ "reasoning_tokens": 0,
53
+ "image_tokens": 0,
54
+ "audio_tokens": 0
55
+ }
56
+ }
57
+ }
58
+
59
+
60
+
61
+
62
+
63
+ {
64
+ "id": "gen-1782148953-LA0Rlt0gpMPn8Zoe0tuz",
65
+ "object": "chat.completion",
66
+ "created": 1782148953,
67
+ "model": "google/gemma-4-26b-a4b-it-20260403",
68
+ "provider": "DeepInfra",
69
+ "system_fingerprint": null,
70
+ "service_tier": null,
71
+ "choices": [
72
+ {
73
+ "index": 0,
74
+ "logprobs": null,
75
+ "finish_reason": "stop",
76
+ "native_finish_reason": "stop",
77
+ "message": {
78
+ "role": "assistant",
79
+ "content": "The current alarms are:\n- **High pressure in region 1** (Path: `/a1`)\n- **Low temperature in region 2** (Path: `/a2`)",
80
+ "refusal": null,
81
+ "reasoning": null
82
+ }
83
+ }
84
+ ],
85
+ "usage": {
86
+ "prompt_tokens": 197,
87
+ "completion_tokens": 44,
88
+ "total_tokens": 241,
89
+ "cost": 0.00002875,
90
+ "is_byok": false,
91
+ "prompt_tokens_details": {
92
+ "cached_tokens": 0,
93
+ "cache_write_tokens": 0,
94
+ "audio_tokens": 0,
95
+ "video_tokens": 0
96
+ },
97
+ "cost_details": {
98
+ "upstream_inference_cost": 0.00002875,
99
+ "upstream_inference_prompt_cost": 0.00001379,
100
+ "upstream_inference_completions_cost": 0.00001496
101
+ },
102
+ "completion_tokens_details": {
103
+ "reasoning_tokens": 0,
104
+ "image_tokens": 0,
105
+ "audio_tokens": 0
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AppTest < Syntropy::Test
4
+ def test_routing
5
+ req = get('/')
6
+ assert_equal HTTP::OK, req.response_status
7
+
8
+ req = get('/test')
9
+ assert_equal HTTP::OK, req.response_status
10
+
11
+ req = get('/foo')
12
+ assert_equal HTTP::NOT_FOUND, req.response_status
13
+ end
14
+ end
@@ -0,0 +1 @@
1
+ export ->(req) { req.respond('Foo') }
@@ -9,14 +9,14 @@ def get(req)
9
9
  raise Syntropy::Error.not_found if !post
10
10
 
11
11
  req.respond_html(
12
- @template.render(post:)
12
+ @template.render(post:, req:)
13
13
  )
14
14
  end
15
15
 
16
- @template = @layout.apply { |post:, **props|
16
+ @template = @layout.apply { |post:, req:, **props|
17
17
  h1 "Edit blog post"
18
18
  div {
19
- form(action: "/posts/#{post[:id]}", method: 'post') {
19
+ form(action: req.rel(".."), method: 'post') {
20
20
  div {
21
21
  label 'Title', for: 'title'
22
22
  input name: 'title', type: 'text', value: post[:title]