@josephyan/qingflow-mcp 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +517 -0
  2. package/docs/local-agent-install.md +213 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow-mcp.mjs +7 -0
  5. package/npm/lib/runtime.mjs +146 -0
  6. package/npm/scripts/postinstall.mjs +12 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +63 -0
  9. package/qingflow-mcp +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 +336 -0
  13. package/src/qingflow_mcp/config.py +166 -0
  14. package/src/qingflow_mcp/errors.py +66 -0
  15. package/src/qingflow_mcp/json_types.py +18 -0
  16. package/src/qingflow_mcp/server.py +70 -0
  17. package/src/qingflow_mcp/session_store.py +235 -0
  18. package/src/qingflow_mcp/solution/__init__.py +6 -0
  19. package/src/qingflow_mcp/solution/build_assembly_store.py +137 -0
  20. package/src/qingflow_mcp/solution/compiler/__init__.py +265 -0
  21. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  22. package/src/qingflow_mcp/solution/compiler/form_compiler.py +456 -0
  23. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  24. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  25. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  26. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  27. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  28. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +134 -0
  29. package/src/qingflow_mcp/solution/design_session.py +222 -0
  30. package/src/qingflow_mcp/solution/design_store.py +100 -0
  31. package/src/qingflow_mcp/solution/executor.py +2064 -0
  32. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  33. package/src/qingflow_mcp/solution/run_store.py +221 -0
  34. package/src/qingflow_mcp/solution/spec_models.py +755 -0
  35. package/src/qingflow_mcp/tools/__init__.py +1 -0
  36. package/src/qingflow_mcp/tools/app_tools.py +239 -0
  37. package/src/qingflow_mcp/tools/approval_tools.py +481 -0
  38. package/src/qingflow_mcp/tools/auth_tools.py +496 -0
  39. package/src/qingflow_mcp/tools/base.py +81 -0
  40. package/src/qingflow_mcp/tools/directory_tools.py +476 -0
  41. package/src/qingflow_mcp/tools/file_tools.py +375 -0
  42. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  43. package/src/qingflow_mcp/tools/package_tools.py +142 -0
  44. package/src/qingflow_mcp/tools/portal_tools.py +100 -0
  45. package/src/qingflow_mcp/tools/qingbi_report_tools.py +258 -0
  46. package/src/qingflow_mcp/tools/record_tools.py +4305 -0
  47. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  48. package/src/qingflow_mcp/tools/solution_tools.py +1860 -0
  49. package/src/qingflow_mcp/tools/task_tools.py +677 -0
  50. package/src/qingflow_mcp/tools/view_tools.py +324 -0
  51. package/src/qingflow_mcp/tools/workflow_tools.py +311 -0
  52. package/src/qingflow_mcp/tools/workspace_tools.py +143 -0
package/entry_point.py ADDED
@@ -0,0 +1,13 @@
1
+ import sys
2
+ import os
3
+
4
+ # Ensure the 'src' directory is in the Python search path
5
+ # PyInstaller will pick up everything in src/qingflow_mcp as the package
6
+ src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "src"))
7
+ if src_path not in sys.path:
8
+ sys.path.insert(0, src_path)
9
+
10
+ from qingflow_mcp.server import main
11
+
12
+ if __name__ == "__main__":
13
+ main()
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { getPackageRoot, spawnServer } from "../lib/runtime.mjs";
4
+
5
+ const packageRoot = getPackageRoot(import.meta.url);
6
+
7
+ spawnServer(packageRoot, process.argv.slice(2));
@@ -0,0 +1,146 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const WINDOWS = process.platform === "win32";
7
+
8
+ function runChecked(command, args, options = {}) {
9
+ const result = spawnSync(command, args, {
10
+ stdio: "inherit",
11
+ ...options,
12
+ });
13
+ if (result.error) {
14
+ throw result.error;
15
+ }
16
+ if (result.status !== 0) {
17
+ throw new Error(`Command failed: ${command} ${args.join(" ")}`);
18
+ }
19
+ }
20
+
21
+ function commandWorks(command, args) {
22
+ const result = spawnSync(command, args, {
23
+ stdio: "ignore",
24
+ });
25
+ return result.status === 0;
26
+ }
27
+
28
+ export function getPackageRoot(metaUrl) {
29
+ return path.resolve(path.dirname(fileURLToPath(metaUrl)), "..", "..");
30
+ }
31
+
32
+ export function getVenvDir(packageRoot) {
33
+ return path.join(packageRoot, ".npm-python");
34
+ }
35
+
36
+ export function getVenvPython(packageRoot) {
37
+ return WINDOWS
38
+ ? path.join(getVenvDir(packageRoot), "Scripts", "python.exe")
39
+ : path.join(getVenvDir(packageRoot), "bin", "python");
40
+ }
41
+
42
+ export function getVenvServerCommand(packageRoot) {
43
+ return WINDOWS
44
+ ? path.join(getVenvDir(packageRoot), "Scripts", "qingflow-mcp.exe")
45
+ : path.join(getVenvDir(packageRoot), "bin", "qingflow-mcp");
46
+ }
47
+
48
+ function getVenvPip(packageRoot) {
49
+ return WINDOWS
50
+ ? path.join(getVenvDir(packageRoot), "Scripts", "pip.exe")
51
+ : path.join(getVenvDir(packageRoot), "bin", "pip");
52
+ }
53
+
54
+ export function findPython() {
55
+ const preferred = process.env.QINGFLOW_MCP_PYTHON?.trim();
56
+ const candidates = preferred
57
+ ? [{ command: preferred, args: [], label: preferred }]
58
+ : WINDOWS
59
+ ? [
60
+ { command: "py", args: ["-3", "-V"], label: "py -3" },
61
+ { command: "python", args: ["-V"], label: "python" },
62
+ { command: "python3", args: ["-V"], label: "python3" },
63
+ ]
64
+ : [
65
+ { command: "python3", args: ["-V"], label: "python3" },
66
+ { command: "python", args: ["-V"], label: "python" },
67
+ ];
68
+
69
+ for (const candidate of candidates) {
70
+ if (commandWorks(candidate.command, candidate.args)) {
71
+ if (candidate.command === "py") {
72
+ return { command: "py", args: ["-3"], label: candidate.label };
73
+ }
74
+ return { command: candidate.command, args: [], label: candidate.label };
75
+ }
76
+ }
77
+
78
+ throw new Error(
79
+ "Python 3.11+ was not found. Set QINGFLOW_MCP_PYTHON to a Python 3 executable before running npm install."
80
+ );
81
+ }
82
+
83
+ export function ensurePythonEnv(packageRoot, { force = false } = {}) {
84
+ const python = findPython();
85
+ const venvDir = getVenvDir(packageRoot);
86
+ const venvPython = getVenvPython(packageRoot);
87
+ const serverCommand = getVenvServerCommand(packageRoot);
88
+ const stampPath = path.join(venvDir, ".bootstrap.json");
89
+
90
+ if (!force && fs.existsSync(serverCommand) && fs.existsSync(stampPath)) {
91
+ return serverCommand;
92
+ }
93
+
94
+ if (force && fs.existsSync(venvDir)) {
95
+ fs.rmSync(venvDir, { recursive: true, force: true });
96
+ }
97
+
98
+ if (!fs.existsSync(venvPython)) {
99
+ runChecked(python.command, [...python.args, "-m", "venv", venvDir], { cwd: packageRoot });
100
+ }
101
+
102
+ const pip = getVenvPip(packageRoot);
103
+ runChecked(pip, ["install", "--disable-pip-version-check", "."], { cwd: packageRoot });
104
+
105
+ fs.writeFileSync(
106
+ stampPath,
107
+ JSON.stringify(
108
+ {
109
+ installed_at: new Date().toISOString(),
110
+ installer: "npm",
111
+ },
112
+ null,
113
+ 2
114
+ )
115
+ );
116
+
117
+ if (!fs.existsSync(serverCommand)) {
118
+ throw new Error(`Bootstrap finished but ${serverCommand} was not created.`);
119
+ }
120
+
121
+ return serverCommand;
122
+ }
123
+
124
+ export function spawnServer(packageRoot, args) {
125
+ const serverCommand = fs.existsSync(getVenvServerCommand(packageRoot))
126
+ ? getVenvServerCommand(packageRoot)
127
+ : ensurePythonEnv(packageRoot);
128
+
129
+ const child = spawn(serverCommand, args, {
130
+ stdio: "inherit",
131
+ env: process.env,
132
+ });
133
+
134
+ child.on("exit", (code, signal) => {
135
+ if (signal) {
136
+ process.kill(process.pid, signal);
137
+ return;
138
+ }
139
+ process.exit(code ?? 0);
140
+ });
141
+
142
+ child.on("error", (error) => {
143
+ console.error(`[qingflow-mcp] Failed to start server: ${error.message}`);
144
+ process.exit(1);
145
+ });
146
+ }
@@ -0,0 +1,12 @@
1
+ import { ensurePythonEnv, getPackageRoot } from "../lib/runtime.mjs";
2
+
3
+ const packageRoot = getPackageRoot(import.meta.url);
4
+
5
+ try {
6
+ console.log("[qingflow-mcp] Bootstrapping Python runtime...");
7
+ ensurePythonEnv(packageRoot);
8
+ console.log("[qingflow-mcp] Python runtime is ready.");
9
+ } catch (error) {
10
+ console.error(`[qingflow-mcp] postinstall failed: ${error.message}`);
11
+ process.exit(1);
12
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@josephyan/qingflow-mcp",
3
+ "version": "0.1.0-beta.2",
4
+ "description": "Local-agent installer wrapper for the Qingflow MCP server",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "qingflow-mcp": "./npm/bin/qingflow-mcp.mjs"
9
+ },
10
+ "scripts": {
11
+ "postinstall": "node ./npm/scripts/postinstall.mjs",
12
+ "pack:npm": "node -e \"require('fs').mkdirSync('dist/npm',{recursive:true})\" && npm pack --pack-destination dist/npm"
13
+ },
14
+ "files": [
15
+ "README.md",
16
+ "pyproject.toml",
17
+ "entry_point.py",
18
+ "src/qingflow_mcp/**/*.py",
19
+ "src/qingflow_mcp/py.typed",
20
+ "qingflow-mcp",
21
+ "docs/local-agent-install.md",
22
+ "npm/"
23
+ ],
24
+ "engines": {
25
+ "node": ">=16.16.0"
26
+ },
27
+ "keywords": [
28
+ "mcp",
29
+ "qingflow",
30
+ "agent",
31
+ "stdio",
32
+ "installer"
33
+ ]
34
+ }
package/pyproject.toml ADDED
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "qingflow-mcp"
7
+ version = "0.1.0b2"
8
+ description = "User-authenticated MCP server for Qingflow"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [
13
+ { name = "Qingflow", email = "support@qingflow.com" }
14
+ ]
15
+ keywords = ["mcp", "qingflow", "automation", "workflow"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ ]
25
+ dependencies = [
26
+ "mcp>=1.9.4,<2.0.0",
27
+ "httpx>=0.27,<1.0",
28
+ "keyring>=25.5,<26.0",
29
+ "pydantic>=2.8,<3.0",
30
+ "pycryptodome>=3.20,<4.0",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=8.3,<9.0",
36
+ "respx>=0.22,<1.0",
37
+ ]
38
+ build = [
39
+ "pyinstaller>=6.0,<7.0",
40
+ "build>=1.0,<2.0",
41
+ "twine>=5.0,<6.0",
42
+ ]
43
+
44
+ [project.scripts]
45
+ qingflow-mcp = "qingflow_mcp.server:main"
46
+
47
+ [project.urls]
48
+ Homepage = "https://github.com/qingflow/qingflow-mcp"
49
+ Documentation = "https://github.com/qingflow/qingflow-mcp#readme"
50
+ Repository = "https://github.com/qingflow/qingflow-mcp"
51
+ Issues = "https://github.com/qingflow/qingflow-mcp/issues"
52
+
53
+ [tool.setuptools.package-dir]
54
+ "" = "src"
55
+
56
+ [tool.setuptools.packages.find]
57
+ where = ["src"]
58
+
59
+ [tool.setuptools.package-data]
60
+ qingflow_mcp = ["py.typed"]
61
+
62
+ [tool.pytest.ini_options]
63
+ testpaths = ["tests"]
package/qingflow-mcp ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
4
+ if [[ -n "${PYTHON_BIN:-}" ]]; then
5
+ PYTHON="${PYTHON_BIN}"
6
+ elif [[ -x "$ROOT_DIR/.venv/bin/python" ]]; then
7
+ PYTHON="$ROOT_DIR/.venv/bin/python"
8
+ elif command -v python3 >/dev/null 2>&1; then
9
+ PYTHON="$(command -v python3)"
10
+ else
11
+ echo "qingflow-mcp: python interpreter not found. Set PYTHON_BIN or create $ROOT_DIR/.venv" >&2
12
+ exit 1
13
+ fi
14
+ export PYTHONPATH="$ROOT_DIR/src${PYTHONPATH:+:$PYTHONPATH}"
15
+ exec "$PYTHON" -m qingflow_mcp.server "$@"
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0b2"
@@ -0,0 +1,5 @@
1
+ """Entry point for running qingflow_mcp as a module."""
2
+ from qingflow_mcp.server import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,336 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from uuid import uuid4
5
+
6
+ import httpx
7
+
8
+ from .config import DEFAULT_USER_AGENT, get_default_qf_version, get_timeout_seconds, normalize_base_url
9
+ from .errors import QingflowApiError
10
+ from .json_types import JSONObject, JSONScalar, JSONValue
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class BackendRequestContext:
15
+ base_url: str
16
+ token: str
17
+ ws_id: int | None
18
+ qf_request_id: str | None = None
19
+ qf_version: str | None = None
20
+ qf_version_source: str | None = None
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class BackendResponse:
25
+ data: JSONValue
26
+ headers: dict[str, str]
27
+ request_id: str
28
+ http_status: int
29
+ qf_response_version: str | None = None
30
+
31
+
32
+ class BackendClient:
33
+ def __init__(self, timeout: float | None = None, client: httpx.Client | None = None) -> None:
34
+ self._owns_client = client is None
35
+ self._default_qf_version = get_default_qf_version()
36
+ self._client = client or httpx.Client(
37
+ timeout=timeout or get_timeout_seconds(),
38
+ follow_redirects=True,
39
+ trust_env=False,
40
+ )
41
+
42
+ def close(self) -> None:
43
+ if self._owns_client:
44
+ self._client.close()
45
+
46
+ def public_request(
47
+ self,
48
+ method: str,
49
+ base_url: str,
50
+ path: str,
51
+ *,
52
+ params: JSONObject | None = None,
53
+ json_body: JSONValue = None,
54
+ unwrap: bool = True,
55
+ qf_version: str | None = None,
56
+ ) -> JSONValue:
57
+ return self.public_request_with_meta(
58
+ method,
59
+ base_url,
60
+ path,
61
+ params=params,
62
+ json_body=json_body,
63
+ unwrap=unwrap,
64
+ qf_version=qf_version,
65
+ ).data
66
+
67
+ def public_request_with_meta(
68
+ self,
69
+ method: str,
70
+ base_url: str,
71
+ path: str,
72
+ *,
73
+ params: JSONObject | None = None,
74
+ json_body: JSONValue = None,
75
+ unwrap: bool = True,
76
+ qf_version: str | None = None,
77
+ ) -> BackendResponse:
78
+ return self._request_with_meta(
79
+ method,
80
+ self._build_url(base_url, path),
81
+ params=params,
82
+ json_body=json_body,
83
+ headers=self._base_headers(None, None, qf_version=qf_version),
84
+ unwrap=unwrap,
85
+ )
86
+
87
+ def request(
88
+ self,
89
+ method: str,
90
+ context: BackendRequestContext,
91
+ path: str,
92
+ *,
93
+ params: JSONObject | None = None,
94
+ json_body: JSONValue = None,
95
+ unwrap: bool = True,
96
+ ) -> JSONValue:
97
+ return self.request_with_meta(
98
+ method,
99
+ context,
100
+ path,
101
+ params=params,
102
+ json_body=json_body,
103
+ unwrap=unwrap,
104
+ ).data
105
+
106
+ def request_with_meta(
107
+ self,
108
+ method: str,
109
+ context: BackendRequestContext,
110
+ path: str,
111
+ *,
112
+ params: JSONObject | None = None,
113
+ json_body: JSONValue = None,
114
+ unwrap: bool = True,
115
+ ) -> BackendResponse:
116
+ return self._request_with_meta(
117
+ method,
118
+ self._build_url(context.base_url, path),
119
+ params=params,
120
+ json_body=json_body,
121
+ headers=self._base_headers(
122
+ context.token,
123
+ context.ws_id,
124
+ context.qf_request_id,
125
+ qf_version=context.qf_version,
126
+ ),
127
+ unwrap=unwrap,
128
+ )
129
+
130
+ def describe_route(self, context: BackendRequestContext) -> JSONObject:
131
+ qf_version, source = self._resolve_qf_version(context.qf_version)
132
+ if context.qf_version is not None and context.qf_version_source:
133
+ source = context.qf_version_source
134
+ return {
135
+ "base_url": normalize_base_url(context.base_url) or context.base_url,
136
+ "qf_version": qf_version,
137
+ "qf_version_source": source,
138
+ }
139
+
140
+ def upload_binary(
141
+ self,
142
+ url: str,
143
+ content: bytes,
144
+ *,
145
+ content_type: str | None = None,
146
+ headers: dict[str, str] | None = None,
147
+ ) -> JSONObject:
148
+ request_headers = dict(headers or {})
149
+ if content_type:
150
+ request_headers.setdefault("Content-Type", content_type)
151
+ try:
152
+ response = self._client.put(url, content=content, headers=request_headers or None)
153
+ except httpx.RequestError as exc:
154
+ raise QingflowApiError(category="network", message=str(exc))
155
+ if response.status_code >= 400:
156
+ raise QingflowApiError(
157
+ category="http",
158
+ message=self._extract_message(response.text) or f"HTTP {response.status_code}",
159
+ http_status=response.status_code,
160
+ )
161
+ return {
162
+ "status_code": response.status_code,
163
+ "headers": dict(response.headers),
164
+ }
165
+
166
+ def upload_form_file(
167
+ self,
168
+ url: str,
169
+ *,
170
+ form_fields: dict[str, str],
171
+ file_field: str,
172
+ file_name: str,
173
+ content: bytes,
174
+ content_type: str | None = None,
175
+ headers: dict[str, str] | None = None,
176
+ ) -> JSONObject:
177
+ try:
178
+ response = self._client.post(
179
+ url,
180
+ data=form_fields,
181
+ files={file_field: (file_name, content, content_type or "application/octet-stream")},
182
+ headers=headers or None,
183
+ )
184
+ except httpx.RequestError as exc:
185
+ raise QingflowApiError(category="network", message=str(exc))
186
+ if response.status_code >= 400:
187
+ raise QingflowApiError(
188
+ category="http",
189
+ message=self._extract_message(response.text) or f"HTTP {response.status_code}",
190
+ http_status=response.status_code,
191
+ )
192
+ body: JSONValue = None
193
+ if response.content:
194
+ try:
195
+ body = response.json()
196
+ except ValueError:
197
+ body = response.text
198
+ return {
199
+ "status_code": response.status_code,
200
+ "headers": dict(response.headers),
201
+ "body": body,
202
+ }
203
+
204
+ def _request_with_meta(
205
+ self,
206
+ method: str,
207
+ url: str,
208
+ *,
209
+ params: JSONObject | None,
210
+ json_body: JSONValue,
211
+ headers: dict[str, str],
212
+ unwrap: bool,
213
+ ) -> BackendResponse:
214
+ attempts = 2 if method.upper() == "GET" else 1
215
+ last_error: QingflowApiError | None = None
216
+ for _ in range(attempts):
217
+ try:
218
+ response = self._client.request(method.upper(), url, params=params, json=json_body, headers=headers)
219
+ parsed = self._parse_response(response, headers["Qf-Request-Id"], unwrap=unwrap)
220
+ return BackendResponse(
221
+ data=parsed,
222
+ headers=dict(response.headers),
223
+ request_id=headers["Qf-Request-Id"],
224
+ http_status=response.status_code,
225
+ qf_response_version=self._extract_response_qf_version(response.headers),
226
+ )
227
+ except httpx.RequestError as exc:
228
+ last_error = QingflowApiError(category="network", message=str(exc), request_id=headers["Qf-Request-Id"])
229
+ assert last_error is not None
230
+ raise last_error
231
+
232
+ def _parse_response(self, response: httpx.Response, request_id: str, *, unwrap: bool) -> JSONValue:
233
+ payload: JSONValue
234
+ try:
235
+ payload = response.json()
236
+ except ValueError:
237
+ payload = response.text
238
+ if response.status_code >= 400:
239
+ raise QingflowApiError(
240
+ category="http",
241
+ message=self._extract_message(payload) or f"HTTP {response.status_code}",
242
+ backend_code=self._extract_code(payload),
243
+ request_id=request_id,
244
+ http_status=response.status_code,
245
+ )
246
+ if not unwrap:
247
+ return payload
248
+ return self._unwrap_payload(payload, request_id, response.status_code)
249
+
250
+ def _unwrap_payload(self, payload: JSONValue, request_id: str, http_status: int) -> JSONValue:
251
+ if not isinstance(payload, dict):
252
+ return payload
253
+ if "success" in payload:
254
+ if not bool(payload.get("success")):
255
+ raise QingflowApiError(
256
+ category="backend",
257
+ message=self._extract_message(payload) or "Qingflow request failed",
258
+ backend_code=self._extract_code(payload),
259
+ request_id=request_id,
260
+ http_status=http_status,
261
+ )
262
+ return self._extract_success_data(payload)
263
+ code = self._extract_code(payload)
264
+ if code not in (None, 0, "0"):
265
+ raise QingflowApiError(
266
+ category="backend",
267
+ message=self._extract_message(payload) or "Qingflow request failed",
268
+ backend_code=code,
269
+ request_id=request_id,
270
+ http_status=http_status,
271
+ )
272
+ if code in (0, "0"):
273
+ return self._extract_success_data(payload)
274
+ return payload
275
+
276
+ def _extract_success_data(self, payload: JSONObject) -> JSONValue:
277
+ for key in ("result", "data", "page", "obj"):
278
+ if key in payload:
279
+ return payload[key]
280
+ return payload
281
+
282
+ def _extract_message(self, payload: JSONValue) -> str | None:
283
+ if not isinstance(payload, dict):
284
+ return str(payload) if payload else None
285
+ for key in ("message", "msg", "errMsg", "error", "detail"):
286
+ value = payload.get(key)
287
+ if value:
288
+ return str(value)
289
+ return None
290
+
291
+ def _extract_code(self, payload: JSONValue) -> JSONScalar:
292
+ if isinstance(payload, dict):
293
+ return payload.get("code", payload.get("errCode"))
294
+ return None
295
+
296
+ def _base_headers(
297
+ self,
298
+ token: str | None,
299
+ ws_id: int | None,
300
+ request_id: str | None = None,
301
+ *,
302
+ qf_version: str | None = None,
303
+ ) -> dict[str, str]:
304
+ headers = {
305
+ "User-Agent": DEFAULT_USER_AGENT,
306
+ "Qf-Request-Id": request_id or str(uuid4()),
307
+ }
308
+ resolved_qf_version, _ = self._resolve_qf_version(qf_version)
309
+ if resolved_qf_version:
310
+ headers["Cookie"] = f"qfVersion={resolved_qf_version}"
311
+ if token:
312
+ headers["token"] = token
313
+ if ws_id is not None:
314
+ headers["wsId"] = str(ws_id)
315
+ return headers
316
+
317
+ def _resolve_qf_version(self, explicit_qf_version: str | None) -> tuple[str | None, str]:
318
+ if explicit_qf_version is not None:
319
+ normalized = str(explicit_qf_version).strip() or None
320
+ return normalized, "context"
321
+ if self._default_qf_version:
322
+ return self._default_qf_version, "default_config"
323
+ return None, "unset"
324
+
325
+ def _extract_response_qf_version(self, headers: httpx.Headers) -> str | None:
326
+ value = headers.get("x-q-response-version")
327
+ if value is None:
328
+ return None
329
+ normalized = str(value).strip()
330
+ return normalized or None
331
+
332
+ def _build_url(self, base_url: str, path: str) -> str:
333
+ normalized = normalize_base_url(base_url)
334
+ if not normalized:
335
+ raise QingflowApiError.config_error("base_url is required")
336
+ return f"{normalized}/{path.lstrip('/')}"