graphcore-studio 0.2.0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+
2
+ ####### Expanded from @PACKAGE_INIT@ by configure_package_config_file() #######
3
+ ####### Any changes to this file will be overwritten by the next CMake run ####
4
+ ####### The input file was GraphCoreConfig.cmake.in ########
5
+
6
+ get_filename_component(PACKAGE_PREFIX_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../" ABSOLUTE)
7
+
8
+ macro(set_and_check _var _file)
9
+ set(${_var} "${_file}")
10
+ if(NOT EXISTS "${_file}")
11
+ message(FATAL_ERROR "File or directory ${_file} referenced by variable ${_var} does not exist !")
12
+ endif()
13
+ endmacro()
14
+
15
+ macro(check_required_components _NAME)
16
+ foreach(comp ${${_NAME}_FIND_COMPONENTS})
17
+ if(NOT ${_NAME}_${comp}_FOUND)
18
+ if(${_NAME}_FIND_REQUIRED_${comp})
19
+ set(${_NAME}_FOUND FALSE)
20
+ endif()
21
+ endif()
22
+ endforeach()
23
+ endmacro()
24
+
25
+ ####################################################################################
26
+ include("${CMAKE_CURRENT_LIST_DIR}/GraphCoreTargets.cmake")
@@ -0,0 +1,65 @@
1
+ # This is a basic version file for the Config-mode of find_package().
2
+ # It is used by write_basic_package_version_file() as input file for configure_file()
3
+ # to create a version-file which can be installed along a config.cmake file.
4
+ #
5
+ # The created file sets PACKAGE_VERSION_EXACT if the current version string and
6
+ # the requested version string are exactly the same and it sets
7
+ # PACKAGE_VERSION_COMPATIBLE if the current version is >= requested version,
8
+ # but only if the requested major version is the same as the current one.
9
+ # The variable CVF_VERSION must be set before calling configure_file().
10
+
11
+
12
+ set(PACKAGE_VERSION "0.2.0")
13
+
14
+ if(PACKAGE_VERSION VERSION_LESS PACKAGE_FIND_VERSION)
15
+ set(PACKAGE_VERSION_COMPATIBLE FALSE)
16
+ else()
17
+
18
+ if("0.2.0" MATCHES "^([0-9]+)\\.")
19
+ set(CVF_VERSION_MAJOR "${CMAKE_MATCH_1}")
20
+ if(NOT CVF_VERSION_MAJOR VERSION_EQUAL 0)
21
+ string(REGEX REPLACE "^0+" "" CVF_VERSION_MAJOR "${CVF_VERSION_MAJOR}")
22
+ endif()
23
+ else()
24
+ set(CVF_VERSION_MAJOR "0.2.0")
25
+ endif()
26
+
27
+ if(PACKAGE_FIND_VERSION_RANGE)
28
+ # both endpoints of the range must have the expected major version
29
+ math (EXPR CVF_VERSION_MAJOR_NEXT "${CVF_VERSION_MAJOR} + 1")
30
+ if (NOT PACKAGE_FIND_VERSION_MIN_MAJOR STREQUAL CVF_VERSION_MAJOR
31
+ OR ((PACKAGE_FIND_VERSION_RANGE_MAX STREQUAL "INCLUDE" AND NOT PACKAGE_FIND_VERSION_MAX_MAJOR STREQUAL CVF_VERSION_MAJOR)
32
+ OR (PACKAGE_FIND_VERSION_RANGE_MAX STREQUAL "EXCLUDE" AND NOT PACKAGE_FIND_VERSION_MAX VERSION_LESS_EQUAL CVF_VERSION_MAJOR_NEXT)))
33
+ set(PACKAGE_VERSION_COMPATIBLE FALSE)
34
+ elseif(PACKAGE_FIND_VERSION_MIN_MAJOR STREQUAL CVF_VERSION_MAJOR
35
+ AND ((PACKAGE_FIND_VERSION_RANGE_MAX STREQUAL "INCLUDE" AND PACKAGE_VERSION VERSION_LESS_EQUAL PACKAGE_FIND_VERSION_MAX)
36
+ OR (PACKAGE_FIND_VERSION_RANGE_MAX STREQUAL "EXCLUDE" AND PACKAGE_VERSION VERSION_LESS PACKAGE_FIND_VERSION_MAX)))
37
+ set(PACKAGE_VERSION_COMPATIBLE TRUE)
38
+ else()
39
+ set(PACKAGE_VERSION_COMPATIBLE FALSE)
40
+ endif()
41
+ else()
42
+ if(PACKAGE_FIND_VERSION_MAJOR STREQUAL CVF_VERSION_MAJOR)
43
+ set(PACKAGE_VERSION_COMPATIBLE TRUE)
44
+ else()
45
+ set(PACKAGE_VERSION_COMPATIBLE FALSE)
46
+ endif()
47
+
48
+ if(PACKAGE_FIND_VERSION STREQUAL PACKAGE_VERSION)
49
+ set(PACKAGE_VERSION_EXACT TRUE)
50
+ endif()
51
+ endif()
52
+ endif()
53
+
54
+
55
+ # if the installed or the using project don't have CMAKE_SIZEOF_VOID_P set, ignore it:
56
+ if("${CMAKE_SIZEOF_VOID_P}" STREQUAL "" OR "8" STREQUAL "")
57
+ return()
58
+ endif()
59
+
60
+ # check that the installed version has the same 32/64bit-ness as the one which is currently searching:
61
+ if(NOT CMAKE_SIZEOF_VOID_P STREQUAL "8")
62
+ math(EXPR installedBits "8 * 8")
63
+ set(PACKAGE_VERSION "${PACKAGE_VERSION} (${installedBits}bit)")
64
+ set(PACKAGE_VERSION_UNSUITABLE TRUE)
65
+ endif()
@@ -0,0 +1,19 @@
1
+ #----------------------------------------------------------------
2
+ # Generated CMake target import file for configuration "Release".
3
+ #----------------------------------------------------------------
4
+
5
+ # Commands may need to know the format version.
6
+ set(CMAKE_IMPORT_FILE_VERSION 1)
7
+
8
+ # Import target "GraphCore::graphcore" for configuration "Release"
9
+ set_property(TARGET GraphCore::graphcore APPEND PROPERTY IMPORTED_CONFIGURATIONS RELEASE)
10
+ set_target_properties(GraphCore::graphcore PROPERTIES
11
+ IMPORTED_LINK_INTERFACE_LANGUAGES_RELEASE "CXX"
12
+ IMPORTED_LOCATION_RELEASE "${_IMPORT_PREFIX}/lib/graphcore.lib"
13
+ )
14
+
15
+ list(APPEND _cmake_import_check_targets GraphCore::graphcore )
16
+ list(APPEND _cmake_import_check_files_for_GraphCore::graphcore "${_IMPORT_PREFIX}/lib/graphcore.lib" )
17
+
18
+ # Commands beyond this point should not need to know the version.
19
+ set(CMAKE_IMPORT_FILE_VERSION)
@@ -0,0 +1,107 @@
1
+ # Generated by CMake
2
+
3
+ if("${CMAKE_MAJOR_VERSION}.${CMAKE_MINOR_VERSION}" LESS 2.8)
4
+ message(FATAL_ERROR "CMake >= 2.8.3 required")
5
+ endif()
6
+ if(CMAKE_VERSION VERSION_LESS "2.8.3")
7
+ message(FATAL_ERROR "CMake >= 2.8.3 required")
8
+ endif()
9
+ cmake_policy(PUSH)
10
+ cmake_policy(VERSION 2.8.3...4.2)
11
+ #----------------------------------------------------------------
12
+ # Generated CMake target import file.
13
+ #----------------------------------------------------------------
14
+
15
+ # Commands may need to know the format version.
16
+ set(CMAKE_IMPORT_FILE_VERSION 1)
17
+
18
+ # Protect against multiple inclusion, which would fail when already imported targets are added once more.
19
+ set(_cmake_targets_defined "")
20
+ set(_cmake_targets_not_defined "")
21
+ set(_cmake_expected_targets "")
22
+ foreach(_cmake_expected_target IN ITEMS GraphCore::graphcore)
23
+ list(APPEND _cmake_expected_targets "${_cmake_expected_target}")
24
+ if(TARGET "${_cmake_expected_target}")
25
+ list(APPEND _cmake_targets_defined "${_cmake_expected_target}")
26
+ else()
27
+ list(APPEND _cmake_targets_not_defined "${_cmake_expected_target}")
28
+ endif()
29
+ endforeach()
30
+ unset(_cmake_expected_target)
31
+ if(_cmake_targets_defined STREQUAL _cmake_expected_targets)
32
+ unset(_cmake_targets_defined)
33
+ unset(_cmake_targets_not_defined)
34
+ unset(_cmake_expected_targets)
35
+ unset(CMAKE_IMPORT_FILE_VERSION)
36
+ cmake_policy(POP)
37
+ return()
38
+ endif()
39
+ if(NOT _cmake_targets_defined STREQUAL "")
40
+ string(REPLACE ";" ", " _cmake_targets_defined_text "${_cmake_targets_defined}")
41
+ string(REPLACE ";" ", " _cmake_targets_not_defined_text "${_cmake_targets_not_defined}")
42
+ message(FATAL_ERROR "Some (but not all) targets in this export set were already defined.\nTargets Defined: ${_cmake_targets_defined_text}\nTargets not yet defined: ${_cmake_targets_not_defined_text}\n")
43
+ endif()
44
+ unset(_cmake_targets_defined)
45
+ unset(_cmake_targets_not_defined)
46
+ unset(_cmake_expected_targets)
47
+
48
+
49
+ # Compute the installation prefix relative to this file.
50
+ get_filename_component(_IMPORT_PREFIX "${CMAKE_CURRENT_LIST_FILE}" PATH)
51
+ get_filename_component(_IMPORT_PREFIX "${_IMPORT_PREFIX}" PATH)
52
+ get_filename_component(_IMPORT_PREFIX "${_IMPORT_PREFIX}" PATH)
53
+ get_filename_component(_IMPORT_PREFIX "${_IMPORT_PREFIX}" PATH)
54
+ if(_IMPORT_PREFIX STREQUAL "/")
55
+ set(_IMPORT_PREFIX "")
56
+ endif()
57
+
58
+ # Create imported target GraphCore::graphcore
59
+ add_library(GraphCore::graphcore STATIC IMPORTED)
60
+
61
+ set_target_properties(GraphCore::graphcore PROPERTIES
62
+ INTERFACE_COMPILE_FEATURES "cxx_std_17"
63
+ INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include"
64
+ )
65
+
66
+ # Load information for each installed configuration.
67
+ file(GLOB _cmake_config_files "${CMAKE_CURRENT_LIST_DIR}/GraphCoreTargets-*.cmake")
68
+ foreach(_cmake_config_file IN LISTS _cmake_config_files)
69
+ include("${_cmake_config_file}")
70
+ endforeach()
71
+ unset(_cmake_config_file)
72
+ unset(_cmake_config_files)
73
+
74
+ # Cleanup temporary variables.
75
+ set(_IMPORT_PREFIX)
76
+
77
+ # Loop over all imported files and verify that they actually exist
78
+ foreach(_cmake_target IN LISTS _cmake_import_check_targets)
79
+ if(CMAKE_VERSION VERSION_LESS "3.28"
80
+ OR NOT DEFINED _cmake_import_check_xcframework_for_${_cmake_target}
81
+ OR NOT IS_DIRECTORY "${_cmake_import_check_xcframework_for_${_cmake_target}}")
82
+ foreach(_cmake_file IN LISTS "_cmake_import_check_files_for_${_cmake_target}")
83
+ if(NOT EXISTS "${_cmake_file}")
84
+ message(FATAL_ERROR "The imported target \"${_cmake_target}\" references the file
85
+ \"${_cmake_file}\"
86
+ but this file does not exist. Possible reasons include:
87
+ * The file was deleted, renamed, or moved to another location.
88
+ * An install or uninstall procedure did not complete successfully.
89
+ * The installation package was faulty and contained
90
+ \"${CMAKE_CURRENT_LIST_FILE}\"
91
+ but not all the files it references.
92
+ ")
93
+ endif()
94
+ endforeach()
95
+ endif()
96
+ unset(_cmake_file)
97
+ unset("_cmake_import_check_files_for_${_cmake_target}")
98
+ endforeach()
99
+ unset(_cmake_target)
100
+ unset(_cmake_import_check_targets)
101
+
102
+ # This file does not depend on other imported targets which have
103
+ # been exported from the same project but in a separate export set.
104
+
105
+ # Commands beyond this point should not need to know the version.
106
+ set(CMAKE_IMPORT_FILE_VERSION)
107
+ cmake_policy(POP)
lib/graphcore.lib ADDED
Binary file
lib/graphcore_c.lib ADDED
Binary file
studio/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """GraphCore Studio: a local visual frontend for the native graph runtime."""
studio/cli.py ADDED
@@ -0,0 +1,9 @@
1
+ """Installed command-line entry point for GraphCore Studio."""
2
+ import os
3
+ from pathlib import Path
4
+
5
+
6
+ def main():
7
+ os.environ.setdefault("GRAPHCORE_STUDIO_DATA", str(Path.home() / ".graphcore-studio"))
8
+ from studio.server import main as serve
9
+ serve()
studio/engine.py ADDED
@@ -0,0 +1,338 @@
1
+ """Validated visual workflow compiler. Scheduling and equality routing stay native."""
2
+ import copy
3
+ import hashlib
4
+ import importlib.util
5
+ import math
6
+ import json
7
+ import os
8
+ import re
9
+ from collections import Counter
10
+ from graphcore import Graph, Interrupt, Update, END
11
+
12
+ KINDS = {"input", "agent", "model", "condition", "tool", "schema", "approval", "output"}
13
+ TYPES = {"string": str, "integer": int, "number": (int, float), "boolean": bool,
14
+ "object": dict, "array": list}
15
+ KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
16
+ TOOLS = {}
17
+ MODEL_PROVIDERS = {
18
+ "openrouter": {"label": "OpenRouter", "package": "openrouter", "module": "openrouter", "env": ["OPENROUTER_API_KEY"], "models": []},
19
+ }
20
+ MESSAGE_ROLES = {"system", "user", "assistant"}
21
+
22
+ def register_tool(name, function, description="Custom Python integration"):
23
+ if not KEY.fullmatch(name) or not callable(function):
24
+ raise ValueError("Tools need a simple name and a callable")
25
+ TOOLS[name] = {"function": function, "description": description}
26
+
27
+ register_tool("word_count", lambda value, config: {"words": len(str(value).split()),
28
+ "characters": len(str(value))}, "Count words and characters")
29
+ register_tool("uppercase", lambda value, config: str(value).upper(), "Convert text to uppercase")
30
+ register_tool("parse_json", lambda value, config: json.loads(value) if isinstance(value, str) else value,
31
+ "Parse text into a JSON object")
32
+ register_tool("keywords", lambda value, config: [word for word, _ in Counter(
33
+ re.findall(r"[a-z]{4,}", str(value).lower())).most_common(6)], "Extract frequent keywords locally")
34
+
35
+ class WorkflowError(ValueError):
36
+ pass
37
+
38
+ def pydantic_available():
39
+ return importlib.util.find_spec("pydantic") is not None
40
+
41
+ def lookup(state, field):
42
+ value = state
43
+ for key in field.split("."):
44
+ if not isinstance(value, dict) or key not in value:
45
+ raise WorkflowError("State field is missing: " + field)
46
+ value = value[key]
47
+ return value
48
+
49
+ def render(template, state):
50
+ def replace(match):
51
+ value = lookup(state, match.group(1).strip())
52
+ return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
53
+ return re.sub(r"\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}", replace, template)
54
+
55
+ def validate(workflow):
56
+ errors = []
57
+ if not isinstance(workflow, dict):
58
+ return ["Workflow must be an object"]
59
+ if workflow.get("format") != "graphcore.studio.v1":
60
+ errors.append("Unsupported format; expected graphcore.studio.v1")
61
+ nodes, edges = workflow.get("nodes"), workflow.get("edges")
62
+ if not isinstance(nodes, list) or not 1 <= len(nodes) <= 100:
63
+ return errors + ["A workflow needs between 1 and 100 nodes"]
64
+ if not isinstance(edges, list) or len(edges) > 300:
65
+ return errors + ["Edges must be a list with at most 300 entries"]
66
+ ids = set()
67
+ inputs, outputs = [], []
68
+ for node in nodes:
69
+ if not isinstance(node, dict):
70
+ errors.append("Each node must be an object")
71
+ continue
72
+ ident, kind = node.get("id"), node.get("type")
73
+ if not isinstance(ident, str) or not KEY.fullmatch(ident) or ident in ids:
74
+ errors.append("Node IDs must be unique identifiers")
75
+ continue
76
+ ids.add(ident)
77
+ if not isinstance(kind, str) or kind not in KINDS:
78
+ errors.append(ident + ": unknown node type")
79
+ if kind == "input": inputs.append(ident)
80
+ if kind == "output": outputs.append(ident)
81
+ config = node.get("config", {})
82
+ if not isinstance(config, dict):
83
+ errors.append(ident + ": config must be an object")
84
+ continue
85
+ for field in ("output_key",):
86
+ if field in config and (not isinstance(config[field], str) or not KEY.fullmatch(config[field])):
87
+ errors.append(ident + ": output key must be a simple identifier")
88
+ if kind == "condition":
89
+ if not isinstance(config.get("field"), str) or not KEY.fullmatch(config["field"]):
90
+ errors.append(ident + ": condition requires a top-level state field")
91
+ if "expected" not in config:
92
+ errors.append(ident + ": expected value is required")
93
+ if kind in ("agent", "model"):
94
+ provider = config.get("provider", "demo")
95
+ if not isinstance(provider, str) or (provider != "demo" and provider not in MODEL_PROVIDERS):
96
+ errors.append(ident + ": choose a supported chat-model provider")
97
+ if provider != "demo" and isinstance(provider, str) and provider in MODEL_PROVIDERS:
98
+ module = MODEL_PROVIDERS[provider]["module"]
99
+ try: installed = importlib.util.find_spec(module) is not None
100
+ except (ImportError, ValueError): installed = False
101
+ if not installed: errors.append(ident + ": install " + MODEL_PROVIDERS[provider]["package"] + " in the Studio Python environment")
102
+ for field in ("prompt", "system", "demo_response", "model", "endpoint"):
103
+ if field in config and not isinstance(config[field], str): errors.append(ident + ": " + field + " must be text")
104
+ if provider != "demo" and (not isinstance(config.get("model"), str) or not config["model"].strip() or len(config["model"]) > 200):
105
+ errors.append(ident + ": choose a model identifier (1–200 characters)")
106
+ if "temperature" in config and (isinstance(config["temperature"], bool) or not isinstance(config["temperature"], (int,float)) or not math.isfinite(config["temperature"]) or not 0 <= config["temperature"] <= 2):
107
+ errors.append(ident + ": temperature must be between 0 and 2")
108
+ if "max_tokens" in config and (isinstance(config["max_tokens"], bool) or not isinstance(config["max_tokens"], int) or not 1 <= config["max_tokens"] <= 200000):
109
+ errors.append(ident + ": max tokens must be between 1 and 200000")
110
+ if "timeout" in config and (isinstance(config["timeout"], bool) or not isinstance(config["timeout"], (int,float)) or not math.isfinite(config["timeout"]) or not 1 <= config["timeout"] <= 600):
111
+ errors.append(ident + ": timeout must be between 1 and 600 seconds")
112
+ if "max_retries" in config and (isinstance(config["max_retries"], bool) or not isinstance(config["max_retries"], int) or not 0 <= config["max_retries"] <= 10):
113
+ errors.append(ident + ": max retries must be between 0 and 10")
114
+ if "reasoning_effort" in config and config["reasoning_effort"] not in ("none", "minimal", "low", "medium", "high", "xhigh", "max"):
115
+ errors.append(ident + ": reasoning effort must be none, minimal, low, medium, high, xhigh, or max")
116
+ messages = config.get("messages")
117
+ if messages is not None:
118
+ if not isinstance(messages, list) or not 1 <= len(messages) <= 20: errors.append(ident + ": provide 1–20 chat messages")
119
+ else:
120
+ for message in messages:
121
+ if not isinstance(message, dict) or not isinstance(message.get("role"), str) or message.get("role") not in MESSAGE_ROLES or not isinstance(message.get("content"), str):
122
+ errors.append(ident + ": each message needs a system, user, or assistant role and text content")
123
+ break
124
+ if kind == "tool" and config.get("tool", "word_count") not in TOOLS:
125
+ errors.append(ident + ": unknown registered tool")
126
+ if kind == "schema":
127
+ fields = config.get("fields", [])
128
+ if not isinstance(fields, list) or not fields or len(fields) > 50:
129
+ errors.append(ident + ": define 1–50 schema fields")
130
+ else:
131
+ names = set()
132
+ for field in fields:
133
+ if not isinstance(field, dict):
134
+ errors.append(ident + ": invalid schema field"); continue
135
+ name = field.get("name")
136
+ if not isinstance(name, str) or not KEY.fullmatch(name) or name in names:
137
+ errors.append(ident + ": invalid or duplicate schema field")
138
+ else: names.add(name)
139
+ if field.get("type") not in TYPES:
140
+ errors.append(ident + ": unsupported schema type")
141
+ if config.get("engine", "builtin") not in ("builtin", "pydantic"):
142
+ errors.append(ident + ": invalid schema engine")
143
+ if config.get("engine") == "pydantic" and not pydantic_available():
144
+ errors.append(ident + ": install pydantic in the server Python environment to use this engine")
145
+ if errors: return errors
146
+ if len(inputs) != 1: errors.append("Use exactly one Input node")
147
+ if not outputs: errors.append("Add at least one Output node")
148
+ outgoing = {key: [] for key in ids}
149
+ incoming = {key: [] for key in ids}
150
+ for edge in edges:
151
+ if not isinstance(edge, dict):
152
+ errors.append("Each edge must be an object"); continue
153
+ source, target = edge.get("source"), edge.get("target")
154
+ if not isinstance(source, str) or not isinstance(target, str) or source not in ids or target not in ids:
155
+ errors.append("Every connection must link existing nodes"); continue
156
+ outgoing[source].append(edge)
157
+ incoming[target].append(edge)
158
+ for node in nodes:
159
+ if not isinstance(node, dict) or node.get("id") not in outgoing: continue
160
+ ident, kind = node["id"], node.get("type")
161
+ routes = outgoing[ident]
162
+ if kind == "output" and routes: errors.append(ident + ": Output cannot have outgoing connections")
163
+ elif kind == "condition":
164
+ if len(routes) != 2 or {e.get("port") for e in routes} != {"true", "false"}:
165
+ errors.append(ident + ": connect both true and false branches exactly once")
166
+ elif kind != "output" and (len(routes) != 1 or routes[0].get("port", "next") != "next"):
167
+ errors.append(ident + ": connect exactly one next output")
168
+ if kind == "input" and incoming[ident]: errors.append("Input cannot have incoming connections")
169
+ if len(inputs) == 1:
170
+ seen, pending = set(), [inputs[0]]
171
+ while pending:
172
+ ident = pending.pop()
173
+ if ident in seen: continue
174
+ seen.add(ident)
175
+ pending.extend(e["target"] for e in outgoing[ident])
176
+ for ident in sorted(ids - seen): errors.append(ident + ": node is not reachable from Input")
177
+ # Every reachable node must have some path to an output; guarded cycles remain allowed.
178
+ can_end, pending = set(), outputs[:]
179
+ while pending:
180
+ ident = pending.pop()
181
+ if ident in can_end: continue
182
+ can_end.add(ident)
183
+ pending.extend(e["source"] for e in incoming[ident])
184
+ for ident in sorted(seen - can_end): errors.append(ident + ": no path to an Output")
185
+ return errors
186
+
187
+ def structured(value, fields, engine="builtin"):
188
+ if isinstance(value, str):
189
+ value = json.loads(value)
190
+ if not isinstance(value, dict):
191
+ raise WorkflowError("Structured output must be a JSON object")
192
+ allowed = {field["name"] for field in fields}
193
+ extra = set(value) - allowed
194
+ if extra: raise WorkflowError("Unexpected fields: " + ", ".join(sorted(extra)))
195
+ # Common strict contract for the portable validator and Pydantic.
196
+ for field in fields:
197
+ name, kind = field["name"], field["type"]
198
+ if name not in value:
199
+ if field.get("required", True): raise WorkflowError("Required field missing: " + name)
200
+ continue
201
+ item = value[name]
202
+ valid = isinstance(item, TYPES[kind])
203
+ if kind in ("integer", "number") and isinstance(item, bool): valid = False
204
+ if not valid: raise WorkflowError(name + " must be " + kind)
205
+ if engine == "pydantic":
206
+ from pydantic import create_model, StrictStr, StrictInt, StrictFloat, StrictBool
207
+ from typing import Optional, Union
208
+ types = {"string": StrictStr, "integer": StrictInt, "number": Union[StrictInt, StrictFloat],
209
+ "boolean": StrictBool, "object": dict, "array": list}
210
+ definitions = {f["name"]: (types[f["type"]], ...) if f.get("required", True)
211
+ else (Optional[types[f["type"]]], None) for f in fields}
212
+ model = create_model("StudioOutput", **definitions)
213
+ parsed = model(**value)
214
+ return parsed.model_dump(exclude_unset=True) if hasattr(parsed, "model_dump") else parsed.dict(exclude_unset=True)
215
+ return value
216
+
217
+ def provider_availability():
218
+ status = []
219
+ for key, meta in MODEL_PROVIDERS.items():
220
+ try: installed = importlib.util.find_spec(meta["module"]) is not None
221
+ except (ImportError, ValueError): installed = False
222
+ status.append({"id": key, "label": meta["label"], "package": meta["package"], "env": meta["env"],
223
+ "installed": installed, "models": meta["models"]})
224
+ return status
225
+
226
+ def chat_messages(config, state):
227
+ messages = config.get("messages")
228
+ if not messages:
229
+ messages = [{"role":"system", "content":config.get("system", "You are a helpful assistant.")},
230
+ {"role":"user", "content":config.get("prompt", "{{input}}") }]
231
+ return [{"role":message["role"], "content":render(message["content"], state)} for message in messages]
232
+
233
+ def invoke_model(config, state):
234
+ provider = config.get("provider", "demo")
235
+ if provider == "demo":
236
+ text = render(config.get("demo_response", "Demo response for {{input}}"), state)
237
+ return json.loads(text) if config.get("json_output") else text
238
+ meta = MODEL_PROVIDERS.get(provider)
239
+ if not meta: raise WorkflowError("This initial release supports OpenRouter models. Choose OpenRouter or the offline fixture.")
240
+ api_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
241
+ if not api_key: raise WorkflowError("Configure OPENROUTER_API_KEY in Model integrations before running this model.")
242
+ if not isinstance(config.get("model"), str) or not config["model"].strip():
243
+ raise WorkflowError("Enter an OpenRouter model ID, such as openai/gpt-4o-mini.")
244
+ try:
245
+ from openrouter import OpenRouter
246
+ except ImportError as error:
247
+ raise WorkflowError("Install the OpenRouter SDK in the Studio Python environment: python -m pip install openrouter") from error
248
+ kwargs = {"model":config["model"], "messages":chat_messages(config, state), "stream":False,
249
+ "temperature":config.get("temperature", 0.2),
250
+ "max_completion_tokens":config.get("max_tokens", 1024),
251
+ "timeout_ms":round(config.get("timeout", 60) * 1000)}
252
+ if config.get("reasoning_effort"):
253
+ kwargs["reasoning_effort"] = config["reasoning_effort"]
254
+ if config.get("json_output"): kwargs["response_format"] = {"type":"json_object"}
255
+ try:
256
+ with OpenRouter(api_key=api_key) as client:
257
+ result = client.chat.send(**kwargs)
258
+ except Exception as error:
259
+ detail = str(error).replace(api_key, "[redacted]")[:700]
260
+ raise WorkflowError(type(error).__name__ + ": " + detail) from error
261
+ def field(value, name, default=None):
262
+ return value.get(name, default) if isinstance(value, dict) else getattr(value, name, default)
263
+
264
+ choices = field(result, "choices", []) or []
265
+ if not choices: raise WorkflowError("OpenRouter returned no completion choices")
266
+ choice = choices[0]
267
+ message = field(choice, "message", {}) or {}
268
+ content = field(message, "content")
269
+ if isinstance(content, list):
270
+ content = "".join(str(text) for part in content if (text := field(part, "text")))
271
+ if content is None or not str(content).strip():
272
+ # Reasoning models may spend the full output budget on hidden reasoning and
273
+ # return no user-facing content. Retry once with a larger budget, then give
274
+ # an actionable diagnostic instead of silently passing an empty answer.
275
+ if not config.get("_content_retry"):
276
+ retry = dict(config)
277
+ retry["_content_retry"] = True
278
+ retry["max_tokens"] = min(max(config.get("max_tokens", 1024) * 2, 128), 8192)
279
+ # A reasoning-only response often means hidden reasoning consumed the
280
+ # entire completion limit. Ask for a direct answer on the single retry.
281
+ retry["reasoning_effort"] = "none"
282
+ return invoke_model(retry, state)
283
+ finish_reason = field(choice, "finish_reason", "unknown")
284
+ reasoning = field(message, "reasoning")
285
+ reason = " The model returned reasoning but no visible answer." if reasoning else ""
286
+ raise WorkflowError(f"OpenRouter returned no visible answer (finish reason: {finish_reason}). Increase max tokens or try a model route that returns standard chat content.{reason}")
287
+ if config.get("json_output"):
288
+ if isinstance(content, str): return json.loads(content)
289
+ return content
290
+ return content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
291
+
292
+ def compile_workflow(workflow, emit=lambda kind, **data: None):
293
+ errors = validate(workflow)
294
+ if errors: raise WorkflowError("\n".join(errors))
295
+ version = hashlib.sha256(json.dumps(workflow, sort_keys=True).encode()).hexdigest()
296
+ graph = Graph(version)
297
+ outgoing = {}
298
+ for edge in workflow["edges"]: outgoing.setdefault(edge["source"], {})[edge.get("port", "next")] = edge["target"]
299
+ def callback(node):
300
+ config, kind, ident = node.get("config", {}), node["type"], node["id"]
301
+ def execute(state, context):
302
+ key = config.get("output_key", ident)
303
+ if kind == "input":
304
+ result = {}
305
+ elif kind in ("agent", "model"):
306
+ emit("model.started", node=ident, provider=config.get("provider", "demo"), model=config.get("model", ""))
307
+ result = {key: invoke_model(config, state)}
308
+ emit("model.completed", node=ident, provider=config.get("provider", "demo"), model=config.get("model", ""))
309
+ elif kind == "tool":
310
+ value = lookup(state, config.get("input_field", "input"))
311
+ result = {key: TOOLS[config.get("tool", "word_count")]["function"](copy.deepcopy(value), copy.deepcopy(config))}
312
+ elif kind == "schema":
313
+ result = {key: structured(lookup(state, config.get("input_field", "input")), config["fields"], config.get("engine", "builtin"))}
314
+ elif kind == "approval":
315
+ if not context.resumed:
316
+ return Interrupt(render(config.get("prompt", "Approve this result?"), state))
317
+ result = {key: context.response}
318
+ elif kind == "output":
319
+ result = {"output": render(config.get("template", "{{input}}"), state)}
320
+ else: raise WorkflowError("Unknown executable node")
321
+ json.dumps(result, allow_nan=False)
322
+ emit("node.output", node=ident, updates=result)
323
+ return result
324
+ return execute
325
+ try:
326
+ for node in workflow["nodes"]:
327
+ ident, kind, config = node["id"], node["type"], node.get("config", {})
328
+ if kind == "condition":
329
+ graph.add_condition(ident, config["field"], config["expected"], outgoing[ident]["true"], outgoing[ident]["false"])
330
+ else:
331
+ graph.add_node(ident, callback(node))
332
+ graph.add_edge(ident, END if kind == "output" else outgoing[ident]["next"])
333
+ if kind == "input": graph.set_entry(ident)
334
+ graph.observe(lambda kind, node: emit(kind, node=node))
335
+ return graph
336
+ except BaseException:
337
+ graph.close()
338
+ raise
@@ -0,0 +1,8 @@
1
+ """Launch with: python3 run_studio.py --plugin studio/example_plugin.py"""
2
+ def register(add_tool):
3
+ # Import third-party libraries here, from the server's virtual environment.
4
+ def sentence_count(value, config):
5
+ import re
6
+ sentences = [s.strip() for s in re.split(r"[.!?]+", str(value)) if s.strip()]
7
+ return {"sentences": len(sentences), "items": sentences}
8
+ add_tool("sentence_count", sentence_count, "Count sentences with a registered Python function")