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.
- graphcore/__init__.py +303 -0
- graphcore/graphcore.dll +0 -0
- graphcore_studio-0.2.0.dist-info/METADATA +171 -0
- graphcore_studio-0.2.0.dist-info/RECORD +28 -0
- graphcore_studio-0.2.0.dist-info/WHEEL +5 -0
- graphcore_studio-0.2.0.dist-info/entry_points.txt +3 -0
- graphcore_studio-0.2.0.dist-info/licenses/LICENSE +21 -0
- include/graphcore/c_api.h +51 -0
- include/graphcore/graphcore.hpp +83 -0
- lib/cmake/GraphCore/GraphCoreConfig.cmake +26 -0
- lib/cmake/GraphCore/GraphCoreConfigVersion.cmake +65 -0
- lib/cmake/GraphCore/GraphCoreTargets-release.cmake +19 -0
- lib/cmake/GraphCore/GraphCoreTargets.cmake +107 -0
- lib/graphcore.lib +0 -0
- lib/graphcore_c.lib +0 -0
- studio/__init__.py +1 -0
- studio/cli.py +9 -0
- studio/engine.py +338 -0
- studio/example_plugin.py +8 -0
- studio/server.py +366 -0
- studio/templates/01-research.json +114 -0
- studio/templates/02-structured.json +86 -0
- studio/templates/03-tools.json +70 -0
- studio/templates/04-team.json +117 -0
- studio/templates/05-model.json +19 -0
- studio/web/app.js +108 -0
- studio/web/index.html +43 -0
- studio/web/style.css +9 -0
|
@@ -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
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
|
studio/example_plugin.py
ADDED
|
@@ -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")
|