@josephyan/qingflow-cli 0.2.0-beta.55

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 (79) hide show
  1. package/README.md +30 -0
  2. package/docs/local-agent-install.md +235 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +204 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +5 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +547 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +985 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +8243 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +78 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +184 -0
  21. package/src/qingflow_mcp/cli/commands/common.py +47 -0
  22. package/src/qingflow_mcp/cli/commands/imports.py +86 -0
  23. package/src/qingflow_mcp/cli/commands/record.py +202 -0
  24. package/src/qingflow_mcp/cli/commands/task.py +87 -0
  25. package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
  26. package/src/qingflow_mcp/cli/context.py +48 -0
  27. package/src/qingflow_mcp/cli/formatters.py +269 -0
  28. package/src/qingflow_mcp/cli/json_io.py +50 -0
  29. package/src/qingflow_mcp/cli/main.py +147 -0
  30. package/src/qingflow_mcp/config.py +221 -0
  31. package/src/qingflow_mcp/errors.py +66 -0
  32. package/src/qingflow_mcp/import_store.py +121 -0
  33. package/src/qingflow_mcp/json_types.py +18 -0
  34. package/src/qingflow_mcp/list_type_labels.py +76 -0
  35. package/src/qingflow_mcp/server.py +211 -0
  36. package/src/qingflow_mcp/server_app_builder.py +387 -0
  37. package/src/qingflow_mcp/server_app_user.py +317 -0
  38. package/src/qingflow_mcp/session_store.py +289 -0
  39. package/src/qingflow_mcp/solution/__init__.py +6 -0
  40. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  41. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  42. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  43. package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
  44. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  45. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  46. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  47. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  48. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  49. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  50. package/src/qingflow_mcp/solution/design_session.py +222 -0
  51. package/src/qingflow_mcp/solution/design_store.py +100 -0
  52. package/src/qingflow_mcp/solution/executor.py +2339 -0
  53. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  54. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  55. package/src/qingflow_mcp/solution/run_store.py +244 -0
  56. package/src/qingflow_mcp/solution/spec_models.py +853 -0
  57. package/src/qingflow_mcp/tools/__init__.py +1 -0
  58. package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
  59. package/src/qingflow_mcp/tools/app_tools.py +850 -0
  60. package/src/qingflow_mcp/tools/approval_tools.py +833 -0
  61. package/src/qingflow_mcp/tools/auth_tools.py +697 -0
  62. package/src/qingflow_mcp/tools/base.py +81 -0
  63. package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
  64. package/src/qingflow_mcp/tools/directory_tools.py +648 -0
  65. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  66. package/src/qingflow_mcp/tools/file_tools.py +385 -0
  67. package/src/qingflow_mcp/tools/import_tools.py +1971 -0
  68. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  69. package/src/qingflow_mcp/tools/package_tools.py +240 -0
  70. package/src/qingflow_mcp/tools/portal_tools.py +131 -0
  71. package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
  72. package/src/qingflow_mcp/tools/record_tools.py +12739 -0
  73. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  74. package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
  75. package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
  76. package/src/qingflow_mcp/tools/task_tools.py +843 -0
  77. package/src/qingflow_mcp/tools/view_tools.py +280 -0
  78. package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
  79. package/src/qingflow_mcp/tools/workspace_tools.py +219 -0
@@ -0,0 +1,547 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from threading import Event
5
+ from time import sleep
6
+ from typing import Any
7
+ from urllib.parse import urlsplit, urlunsplit
8
+ from uuid import uuid4
9
+
10
+ import httpx
11
+
12
+ from .config import DEFAULT_USER_AGENT, get_default_qf_version, get_timeout_seconds, normalize_base_url
13
+ from .errors import QingflowApiError
14
+ from .json_types import JSONObject, JSONScalar, JSONValue
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class BackendRequestContext:
19
+ base_url: str
20
+ token: str
21
+ ws_id: int | None
22
+ qf_request_id: str | None = None
23
+ qf_version: str | None = None
24
+ qf_version_source: str | None = None
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class BackendResponse:
29
+ data: JSONValue
30
+ headers: dict[str, str]
31
+ request_id: str
32
+ http_status: int
33
+ qf_response_version: str | None = None
34
+
35
+
36
+ class BackendClient:
37
+ def __init__(self, timeout: float | None = None, client: httpx.Client | None = None) -> None:
38
+ self._owns_client = client is None
39
+ self._default_qf_version = get_default_qf_version()
40
+ self._client = client or httpx.Client(
41
+ timeout=timeout or get_timeout_seconds(),
42
+ follow_redirects=True,
43
+ trust_env=False,
44
+ )
45
+
46
+ def close(self) -> None:
47
+ if self._owns_client:
48
+ self._client.close()
49
+
50
+ def public_request(
51
+ self,
52
+ method: str,
53
+ base_url: str,
54
+ path: str,
55
+ *,
56
+ params: JSONObject | None = None,
57
+ json_body: JSONValue = None,
58
+ unwrap: bool = True,
59
+ qf_version: str | None = None,
60
+ ) -> JSONValue:
61
+ return self.public_request_with_meta(
62
+ method,
63
+ base_url,
64
+ path,
65
+ params=params,
66
+ json_body=json_body,
67
+ unwrap=unwrap,
68
+ qf_version=qf_version,
69
+ ).data
70
+
71
+ def public_request_with_meta(
72
+ self,
73
+ method: str,
74
+ base_url: str,
75
+ path: str,
76
+ *,
77
+ params: JSONObject | None = None,
78
+ json_body: JSONValue = None,
79
+ unwrap: bool = True,
80
+ qf_version: str | None = None,
81
+ ) -> BackendResponse:
82
+ return self._request_with_meta(
83
+ method,
84
+ self._build_url(base_url, path),
85
+ params=params,
86
+ json_body=json_body,
87
+ headers=self._base_headers(None, None, qf_version=qf_version),
88
+ unwrap=unwrap,
89
+ )
90
+
91
+ def request(
92
+ self,
93
+ method: str,
94
+ context: BackendRequestContext,
95
+ path: str,
96
+ *,
97
+ params: JSONObject | None = None,
98
+ json_body: JSONValue = None,
99
+ unwrap: bool = True,
100
+ ) -> JSONValue:
101
+ return self.request_with_meta(
102
+ method,
103
+ context,
104
+ path,
105
+ params=params,
106
+ json_body=json_body,
107
+ unwrap=unwrap,
108
+ ).data
109
+
110
+ def request_with_meta(
111
+ self,
112
+ method: str,
113
+ context: BackendRequestContext,
114
+ path: str,
115
+ *,
116
+ params: JSONObject | None = None,
117
+ json_body: JSONValue = None,
118
+ unwrap: bool = True,
119
+ ) -> BackendResponse:
120
+ return self._request_with_meta(
121
+ method,
122
+ self._build_url(context.base_url, path),
123
+ params=params,
124
+ json_body=json_body,
125
+ headers=self._base_headers(
126
+ context.token,
127
+ context.ws_id,
128
+ context.qf_request_id,
129
+ qf_version=context.qf_version,
130
+ ),
131
+ unwrap=unwrap,
132
+ )
133
+
134
+ def describe_route(self, context: BackendRequestContext) -> JSONObject:
135
+ qf_version, source = self._resolve_qf_version(context.qf_version)
136
+ if context.qf_version is not None and context.qf_version_source:
137
+ source = context.qf_version_source
138
+ return {
139
+ "base_url": normalize_base_url(context.base_url) or context.base_url,
140
+ "qf_version": qf_version,
141
+ "qf_version_source": source,
142
+ }
143
+
144
+ def upload_binary(
145
+ self,
146
+ url: str,
147
+ content: bytes,
148
+ *,
149
+ content_type: str | None = None,
150
+ headers: dict[str, str] | None = None,
151
+ ) -> JSONObject:
152
+ request_headers = dict(headers or {})
153
+ if content_type:
154
+ request_headers.setdefault("Content-Type", content_type)
155
+ try:
156
+ response = self._client.put(url, content=content, headers=request_headers or None)
157
+ except httpx.RequestError as exc:
158
+ raise QingflowApiError(category="network", message=str(exc))
159
+ if response.status_code >= 400:
160
+ raise QingflowApiError(
161
+ category="http",
162
+ message=self._extract_message(response.text) or f"HTTP {response.status_code}",
163
+ http_status=response.status_code,
164
+ )
165
+ return {
166
+ "status_code": response.status_code,
167
+ "headers": dict(response.headers),
168
+ }
169
+
170
+ def upload_form_file(
171
+ self,
172
+ url: str,
173
+ *,
174
+ form_fields: dict[str, str],
175
+ file_field: str,
176
+ file_name: str,
177
+ content: bytes,
178
+ content_type: str | None = None,
179
+ headers: dict[str, str] | None = None,
180
+ ) -> JSONObject:
181
+ try:
182
+ response = self._client.post(
183
+ url,
184
+ data=form_fields,
185
+ files={file_field: (file_name, content, content_type or "application/octet-stream")},
186
+ headers=headers or None,
187
+ )
188
+ except httpx.RequestError as exc:
189
+ raise QingflowApiError(category="network", message=str(exc))
190
+ if response.status_code >= 400:
191
+ raise QingflowApiError(
192
+ category="http",
193
+ message=self._extract_message(response.text) or f"HTTP {response.status_code}",
194
+ http_status=response.status_code,
195
+ )
196
+ body: JSONValue = None
197
+ if response.content:
198
+ try:
199
+ body = response.json()
200
+ except ValueError:
201
+ body = response.text
202
+ return {
203
+ "status_code": response.status_code,
204
+ "headers": dict(response.headers),
205
+ "body": body,
206
+ }
207
+
208
+ def download_binary(self, url: str, *, headers: dict[str, str] | None = None) -> bytes:
209
+ try:
210
+ response = self._client.get(url, headers=headers or None)
211
+ except httpx.RequestError as exc:
212
+ raise QingflowApiError(category="network", message=str(exc))
213
+ if response.status_code >= 400:
214
+ raise QingflowApiError(
215
+ category="http",
216
+ message=self._extract_message(response.text) or f"HTTP {response.status_code}",
217
+ http_status=response.status_code,
218
+ )
219
+ return response.content
220
+
221
+ def request_multipart(
222
+ self,
223
+ method: str,
224
+ context: BackendRequestContext,
225
+ path: str,
226
+ *,
227
+ data: dict[str, Any] | None = None,
228
+ files: dict[str, tuple[str, bytes, str | None]] | None = None,
229
+ unwrap: bool = True,
230
+ ) -> JSONValue:
231
+ return self.request_multipart_with_meta(
232
+ method,
233
+ context,
234
+ path,
235
+ data=data,
236
+ files=files,
237
+ unwrap=unwrap,
238
+ ).data
239
+
240
+ def request_multipart_with_meta(
241
+ self,
242
+ method: str,
243
+ context: BackendRequestContext,
244
+ path: str,
245
+ *,
246
+ data: dict[str, Any] | None = None,
247
+ files: dict[str, tuple[str, bytes, str | None]] | None = None,
248
+ unwrap: bool = True,
249
+ ) -> BackendResponse:
250
+ headers = self._base_headers(
251
+ context.token,
252
+ context.ws_id,
253
+ context.qf_request_id,
254
+ qf_version=context.qf_version,
255
+ )
256
+ request_files: dict[str, tuple[str, bytes, str | None]] | None = None
257
+ if files:
258
+ request_files = {key: value for key, value in files.items()}
259
+ try:
260
+ response = self._client.request(
261
+ method.upper(),
262
+ self._build_url(context.base_url, path),
263
+ data=data,
264
+ files=request_files,
265
+ headers=headers,
266
+ )
267
+ except httpx.RequestError as exc:
268
+ raise QingflowApiError(category="network", message=str(exc), request_id=headers["Qf-Request-Id"])
269
+ parsed = self._parse_response(response, headers["Qf-Request-Id"], unwrap=unwrap)
270
+ return BackendResponse(
271
+ data=parsed,
272
+ headers=dict(response.headers),
273
+ request_id=headers["Qf-Request-Id"],
274
+ http_status=response.status_code,
275
+ qf_response_version=self._extract_response_qf_version(response.headers),
276
+ )
277
+
278
+ def start_socket_data_import(
279
+ self,
280
+ context: BackendRequestContext,
281
+ *,
282
+ app_key: str,
283
+ being_enter_auditing: bool,
284
+ view_key: str | None,
285
+ excel_url: str,
286
+ excel_name: str,
287
+ ack_timeout_seconds: float = 8.0,
288
+ initial_wait_seconds: float = 4.0,
289
+ ) -> dict[str, Any]:
290
+ try:
291
+ import socketio # type: ignore[import-not-found]
292
+ except ImportError as exc:
293
+ raise QingflowApiError(
294
+ category="config",
295
+ message=f"socket.io client dependency is missing: {exc}",
296
+ )
297
+
298
+ socket_base_url = self._build_socket_base_url(context.base_url)
299
+ import_result: dict[str, Any] = {
300
+ "import_id": None,
301
+ "process_id_str": None,
302
+ "status": "accepted",
303
+ "warnings": [],
304
+ "initial_event": None,
305
+ "failure_event": None,
306
+ }
307
+ initial_event_received = Event()
308
+ failure_event_received = Event()
309
+ sio = socketio.Client(reconnection=False, logger=False, engineio_logger=False)
310
+
311
+ def _unwrap_socket_event_payload(payload: Any) -> Any:
312
+ if isinstance(payload, list) and payload:
313
+ return payload[0]
314
+ return payload
315
+
316
+ def _handle_initial(payload: Any) -> None:
317
+ normalized_payload = _unwrap_socket_event_payload(payload)
318
+ import_result["initial_event"] = normalized_payload
319
+ if isinstance(normalized_payload, dict):
320
+ process_id = normalized_payload.get("processIdStr") or normalized_payload.get("process_id_str") or normalized_payload.get("processId")
321
+ if process_id is not None:
322
+ import_result["process_id_str"] = str(process_id)
323
+ initial_event_received.set()
324
+
325
+ def _handle_failure(payload: Any) -> None:
326
+ normalized_payload = _unwrap_socket_event_payload(payload)
327
+ import_result["failure_event"] = normalized_payload
328
+ if isinstance(normalized_payload, dict):
329
+ process_id = normalized_payload.get("processIdStr") or normalized_payload.get("process_id_str") or normalized_payload.get("processId")
330
+ if process_id is not None:
331
+ import_result["process_id_str"] = str(process_id)
332
+ failure_event_received.set()
333
+
334
+ try:
335
+ sio.connect(
336
+ socket_base_url,
337
+ transports=["websocket"],
338
+ socketio_path="socket.io",
339
+ headers=self._base_headers(
340
+ context.token,
341
+ context.ws_id,
342
+ qf_version=context.qf_version,
343
+ ),
344
+ wait_timeout=ack_timeout_seconds,
345
+ )
346
+ sio.emit("token", context.token)
347
+ sleep(0.2)
348
+ ack = sio.call(
349
+ "dataImport",
350
+ (
351
+ context.token,
352
+ app_key,
353
+ bool(being_enter_auditing),
354
+ view_key,
355
+ False,
356
+ excel_url,
357
+ excel_name,
358
+ ),
359
+ timeout=ack_timeout_seconds,
360
+ )
361
+ ack_payload = ack[0] if isinstance(ack, list) and ack else ack
362
+ if isinstance(ack_payload, dict):
363
+ error_code = ack_payload.get("error")
364
+ ack_message = ack_payload.get("message")
365
+ import_id = ack_payload.get("data")
366
+ if error_code not in (None, 0):
367
+ raise QingflowApiError(
368
+ category="backend",
369
+ message=str(ack_message or f"socket import rejected with error {error_code}"),
370
+ details={"socket_error_code": error_code, "import_id": import_id},
371
+ )
372
+ else:
373
+ import_id = ack_payload
374
+ if not import_id:
375
+ raise QingflowApiError(category="backend", message="socket import ack did not return import_id")
376
+ import_result["import_id"] = str(import_id)
377
+ sio.on(f"dataImportRes_{import_result['import_id']}", _handle_initial)
378
+ sio.on(f"dataImportFail_{import_result['import_id']}", _handle_failure)
379
+ if not initial_event_received.wait(timeout=initial_wait_seconds) and not failure_event_received.wait(timeout=0.1):
380
+ import_result["warnings"].append(
381
+ {
382
+ "code": "IMPORT_SOCKET_INITIAL_EVENT_PENDING",
383
+ "message": "Import ack received, but no initial progress payload arrived within the initial wait window.",
384
+ }
385
+ )
386
+ except Exception as exc:
387
+ message = str(exc)
388
+ if "timeout" in message.lower():
389
+ raise QingflowApiError(
390
+ category="network",
391
+ message="socket import ack timed out",
392
+ details={"error_code": "IMPORT_SOCKET_ACK_TIMEOUT"},
393
+ )
394
+ if isinstance(exc, QingflowApiError):
395
+ raise
396
+ raise QingflowApiError(category="network", message=message or "socket import failed")
397
+ finally:
398
+ try:
399
+ if sio.connected:
400
+ sio.disconnect()
401
+ except Exception:
402
+ pass
403
+ return import_result
404
+
405
+ def _request_with_meta(
406
+ self,
407
+ method: str,
408
+ url: str,
409
+ *,
410
+ params: JSONObject | None,
411
+ json_body: JSONValue,
412
+ headers: dict[str, str],
413
+ unwrap: bool,
414
+ ) -> BackendResponse:
415
+ attempts = 2 if method.upper() == "GET" else 1
416
+ last_error: QingflowApiError | None = None
417
+ for _ in range(attempts):
418
+ try:
419
+ response = self._client.request(method.upper(), url, params=params, json=json_body, headers=headers)
420
+ parsed = self._parse_response(response, headers["Qf-Request-Id"], unwrap=unwrap)
421
+ return BackendResponse(
422
+ data=parsed,
423
+ headers=dict(response.headers),
424
+ request_id=headers["Qf-Request-Id"],
425
+ http_status=response.status_code,
426
+ qf_response_version=self._extract_response_qf_version(response.headers),
427
+ )
428
+ except httpx.RequestError as exc:
429
+ last_error = QingflowApiError(category="network", message=str(exc), request_id=headers["Qf-Request-Id"])
430
+ assert last_error is not None
431
+ raise last_error
432
+
433
+ def _parse_response(self, response: httpx.Response, request_id: str, *, unwrap: bool) -> JSONValue:
434
+ payload: JSONValue
435
+ try:
436
+ payload = response.json()
437
+ except ValueError:
438
+ payload = response.text
439
+ if response.status_code >= 400:
440
+ raise QingflowApiError(
441
+ category="http",
442
+ message=self._extract_message(payload) or f"HTTP {response.status_code}",
443
+ backend_code=self._extract_code(payload),
444
+ request_id=request_id,
445
+ http_status=response.status_code,
446
+ )
447
+ if not unwrap:
448
+ return payload
449
+ return self._unwrap_payload(payload, request_id, response.status_code)
450
+
451
+ def _unwrap_payload(self, payload: JSONValue, request_id: str, http_status: int) -> JSONValue:
452
+ if not isinstance(payload, dict):
453
+ return payload
454
+ if "success" in payload:
455
+ if not bool(payload.get("success")):
456
+ raise QingflowApiError(
457
+ category="backend",
458
+ message=self._extract_message(payload) or "Qingflow request failed",
459
+ backend_code=self._extract_code(payload),
460
+ request_id=request_id,
461
+ http_status=http_status,
462
+ )
463
+ return self._extract_success_data(payload)
464
+ code = self._extract_code(payload)
465
+ if code not in (None, 0, "0"):
466
+ raise QingflowApiError(
467
+ category="backend",
468
+ message=self._extract_message(payload) or "Qingflow request failed",
469
+ backend_code=code,
470
+ request_id=request_id,
471
+ http_status=http_status,
472
+ )
473
+ if code in (0, "0"):
474
+ return self._extract_success_data(payload)
475
+ return payload
476
+
477
+ def _extract_success_data(self, payload: JSONObject) -> JSONValue:
478
+ for key in ("result", "data", "page", "obj"):
479
+ if key in payload:
480
+ return payload[key]
481
+ return payload
482
+
483
+ def _extract_message(self, payload: JSONValue) -> str | None:
484
+ if not isinstance(payload, dict):
485
+ return str(payload) if payload else None
486
+ for key in ("message", "msg", "errMsg", "error", "detail"):
487
+ value = payload.get(key)
488
+ if value:
489
+ return str(value)
490
+ return None
491
+
492
+ def _extract_code(self, payload: JSONValue) -> JSONScalar:
493
+ if isinstance(payload, dict):
494
+ return payload.get("code", payload.get("errCode"))
495
+ return None
496
+
497
+ def _base_headers(
498
+ self,
499
+ token: str | None,
500
+ ws_id: int | None,
501
+ request_id: str | None = None,
502
+ *,
503
+ qf_version: str | None = None,
504
+ ) -> dict[str, str]:
505
+ headers = {
506
+ "User-Agent": DEFAULT_USER_AGENT,
507
+ "Qf-Request-Id": request_id or str(uuid4()),
508
+ }
509
+ resolved_qf_version, _ = self._resolve_qf_version(qf_version)
510
+ if resolved_qf_version:
511
+ headers["Cookie"] = f"qfVersion={resolved_qf_version}"
512
+ if token:
513
+ headers["token"] = token
514
+ if ws_id is not None:
515
+ headers["wsId"] = str(ws_id)
516
+ return headers
517
+
518
+ def _resolve_qf_version(self, explicit_qf_version: str | None) -> tuple[str | None, str]:
519
+ if explicit_qf_version is not None:
520
+ normalized = str(explicit_qf_version).strip() or None
521
+ return normalized, "context"
522
+ if self._default_qf_version:
523
+ return self._default_qf_version, "default_config"
524
+ return None, "unset"
525
+
526
+ def _extract_response_qf_version(self, headers: httpx.Headers) -> str | None:
527
+ value = headers.get("x-q-response-version")
528
+ if value is None:
529
+ return None
530
+ normalized = str(value).strip()
531
+ return normalized or None
532
+
533
+ def _build_url(self, base_url: str, path: str) -> str:
534
+ normalized = normalize_base_url(base_url)
535
+ if not normalized:
536
+ raise QingflowApiError.config_error("base_url is required")
537
+ return f"{normalized}/{path.lstrip('/')}"
538
+
539
+ def _build_socket_base_url(self, base_url: str) -> str:
540
+ normalized = normalize_base_url(base_url)
541
+ if not normalized:
542
+ raise QingflowApiError.config_error("base_url is required")
543
+ parsed = urlsplit(normalized)
544
+ path = parsed.path.rstrip("/")
545
+ if path.endswith("/api"):
546
+ path = path[:-4]
547
+ return urlunsplit((parsed.scheme, parsed.netloc, path or "", "", ""))
@@ -0,0 +1,3 @@
1
+ from .service import AiBuilderFacade
2
+
3
+ __all__ = ["AiBuilderFacade"]