graphcore-studio 0.2.0__tar.gz → 0.2.2__tar.gz
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_studio-0.2.0 → graphcore_studio-0.2.2}/.github/workflows/ci.yml +1 -1
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/.github/workflows/wheels.yml +1 -1
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/CMakeLists.txt +1 -1
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/PKG-INFO +16 -15
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/README.md +10 -12
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/docs/ARCHITECTURE.md +2 -2
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/docs/SEMANTICS.md +2 -2
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/pyproject.toml +8 -4
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/python/graphcore/__init__.py +1 -1
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/requirements-models.txt +1 -1
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/engine.py +24 -10
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/server.py +34 -7
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/web/app.js +13 -11
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/web/index.html +1 -1
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/web/style.css +23 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/tests/test_studio.py +29 -1
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/tests/test_studio_http.py +12 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/.gitignore +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/LICENSE +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/cmake/GraphCoreConfig.cmake.in +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/docs/PYPI_RELEASE.md +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/docs/ROADMAP.md +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/docs/VALIDATION.md +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/examples/approval.cpp +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/examples/complex_deepseek_workflow.json +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/examples/langgraph_deepseek_workflow_notebook.py +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/examples/python_agent.py +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/include/graphcore/c_api.h +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/include/graphcore/graphcore.hpp +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/requirements-pydantic.txt +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/run_studio.py +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/scripts/build.sh +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/src/c_api.cpp +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/src/graphcore.cpp +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/__init__.py +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/cli.py +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/example_plugin.py +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/templates/01-research.json +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/templates/02-structured.json +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/templates/03-tools.json +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/templates/04-team.json +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/templates/05-model.json +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/tests/core_tests.cpp +0 -0
- {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/tests/test_python.py +0 -0
|
@@ -30,7 +30,7 @@ jobs:
|
|
|
30
30
|
- name: Build wheels for supported CPython versions
|
|
31
31
|
run: python -m pip install cibuildwheel && python -m cibuildwheel --output-dir wheelhouse
|
|
32
32
|
env:
|
|
33
|
-
CIBW_BUILD: 'cp39-* cp310-* cp311-* cp312-* cp313-*'
|
|
33
|
+
CIBW_BUILD: 'cp39-* cp310-* cp311-* cp312-* cp313-* cp314-*'
|
|
34
34
|
CIBW_SKIP: '*-musllinux_*'
|
|
35
35
|
CIBW_TEST_COMMAND: >-
|
|
36
36
|
python -c "from graphcore import END, Graph; g=Graph(); g.add_node('probe', lambda s,c: {'ok': True}); g.set_entry('probe').add_edge('probe', END); assert g.invoke().state['ok']; g.close()"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
cmake_minimum_required(VERSION 3.16)
|
|
2
|
-
project(GraphCore VERSION 0.2.
|
|
2
|
+
project(GraphCore VERSION 0.2.2 LANGUAGES CXX)
|
|
3
3
|
option(GRAPHCORE_BUILD_TESTS "Build tests" ON)
|
|
4
4
|
option(GRAPHCORE_BUILD_EXAMPLES "Build examples" ON)
|
|
5
5
|
option(GRAPHCORE_BUILD_PYTHON "Build C ABI shared library for Python" ON)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphcore-studio
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: A local-first visual agent workflow studio powered by a native C++ graph runtime
|
|
5
5
|
Keywords: agents,agentic-workflows,graph,C++,visual-workflow-builder
|
|
6
6
|
License-Expression: MIT
|
|
@@ -15,11 +15,14 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
19
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
20
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
-
Requires-Python: >=3.9
|
|
21
|
+
Requires-Python: >=3.9.2
|
|
22
|
+
Requires-Dist: openrouter<1,>=0.10.8
|
|
23
|
+
Requires-Dist: pydantic<3,>=2
|
|
21
24
|
Provides-Extra: models
|
|
22
|
-
Requires-Dist: openrouter<1,>=0.10; extra == "models"
|
|
25
|
+
Requires-Dist: openrouter<1,>=0.10.8; extra == "models"
|
|
23
26
|
Provides-Extra: pydantic
|
|
24
27
|
Requires-Dist: pydantic<3,>=2; extra == "pydantic"
|
|
25
28
|
Description-Content-Type: text/markdown
|
|
@@ -32,7 +35,7 @@ GraphCore Studio is an interactive, local-first visual agent framework. Drag nod
|
|
|
32
35
|
|
|
33
36
|
## Start Studio
|
|
34
37
|
|
|
35
|
-
Requirements: Python 3.9+ and a C++17 compiler. You do not need CMake, Node.js, Docker, API keys, an internet connection, or a model to try the included workflows.
|
|
38
|
+
Requirements: Python 3.9.2+ and a C++17 compiler. You do not need CMake, Node.js, Docker, API keys, an internet connection, or a model to try the included workflows.
|
|
36
39
|
|
|
37
40
|
From the project folder, run one command:
|
|
38
41
|
|
|
@@ -48,25 +51,25 @@ py run_studio.py
|
|
|
48
51
|
|
|
49
52
|
If Python was installed through the Windows Store and `py` is unavailable, run `python run_studio.py` from the Visual Studio Developer Terminal.
|
|
50
53
|
|
|
51
|
-
Stop the server with Ctrl+C. Workflows and run history live in `studio/data/`; drafts are saved in this browser. Run `python3 run_studio.py --help` to configure
|
|
54
|
+
Stop the server with Ctrl+C. Workflows and run history live in `studio/data/`; drafts are saved in this browser. Studio starts on port 8787 when available and automatically chooses a free local port if it is occupied. The actual URL is printed in the terminal. Run `python3 run_studio.py --help` to configure a specific port, data directory, or Python tool plugins.
|
|
52
55
|
|
|
53
56
|
### Install the PyPI package
|
|
54
57
|
|
|
55
|
-
|
|
58
|
+
Install the prebuilt package and its OpenRouter/Pydantic runtime dependencies with:
|
|
56
59
|
|
|
57
60
|
```bash
|
|
58
|
-
python -m pip install
|
|
61
|
+
python -m pip install graphcore-studio
|
|
59
62
|
graphcore-studio
|
|
60
63
|
```
|
|
61
64
|
|
|
62
|
-
The wheel bundles the C++ runtime and Studio UI. Source installs build the native runtime and therefore require a C++17 compiler and CMake. The distribution is named `graphcore-studio`; its Python API imports as `graphcore`.
|
|
65
|
+
The wheel bundles the C++ runtime and Studio UI. Source installs build the native runtime and therefore require a C++17 compiler and CMake. The distribution is named `graphcore-studio`; its Python API imports as `graphcore`. Windows, macOS, and Linux wheels are provided for supported architectures.
|
|
63
66
|
|
|
64
67
|
### Use Pydantic
|
|
65
68
|
|
|
66
|
-
The built-in structured-output validator works offline
|
|
69
|
+
The built-in structured-output validator works offline. Pydantic v2 is installed by the standard PyPI command above and can be selected as the validation engine. When running directly from a source checkout, install the runtime dependencies with:
|
|
67
70
|
|
|
68
71
|
```bash
|
|
69
|
-
python3 -m pip install -r requirements-pydantic.txt
|
|
72
|
+
python3 -m pip install -r requirements-models.txt -r requirements-pydantic.txt
|
|
70
73
|
python3 run_studio.py
|
|
71
74
|
```
|
|
72
75
|
|
|
@@ -74,18 +77,16 @@ On Windows, replace `python3` with `py -3`. Restart Studio after installing pack
|
|
|
74
77
|
|
|
75
78
|
### Use OpenRouter models
|
|
76
79
|
|
|
77
|
-
The **Chat Model** and **Agent** nodes use GraphCore's model adapter with the official OpenRouter Python SDK. C++ owns graph scheduling, state commits, conditions, and checkpoints; the Python wrapper calls the SDK. The OpenRouter request runs through the project-owned adapter rather than a chain framework.
|
|
80
|
+
The **Chat Model** and **Agent** nodes use GraphCore's model adapter with the official OpenRouter Python SDK, installed automatically by the standard PyPI command. C++ owns graph scheduling, state commits, conditions, and checkpoints; the Python wrapper calls the SDK. The OpenRouter request runs through the project-owned adapter rather than a chain framework.
|
|
78
81
|
|
|
79
|
-
|
|
82
|
+
When running directly from a source checkout, install the runtime dependencies into the Python environment used to run Studio:
|
|
80
83
|
|
|
81
84
|
```bash
|
|
82
85
|
python3 -m pip install -r requirements-models.txt
|
|
83
86
|
python3 run_studio.py
|
|
84
87
|
```
|
|
85
88
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
In Studio, open **Model integrations**, add `OPENROUTER_API_KEY`, and save. Select **OpenRouter** on a Chat Model node and enter a model ID from the [OpenRouter model catalog](https://openrouter.ai/models). Compose system/user/assistant messages and use **Insert variable** to reference workflow inputs and prior node outputs. Studio loads the local `.env` file automatically; secret values are not included in workflow files. The offline fixture needs no SDK, key, or network call.
|
|
89
|
+
In Studio, open **Model integrations**, add `OPENROUTER_API_KEY`, and save. Add as many named models as you need, such as `model1 → deepseek/deepseek-v4-pro-0813` and `model2 → openai/gpt-4o-mini`, using IDs from the [OpenRouter model catalog](https://openrouter.ai/models). Choose a saved model on each Chat Model or Agent node. In workflow JSON, set `"model": "$model1"` or `"model": "$model2"` to select the model for that node. Literal model IDs still work. Compose system/user/assistant messages and use **Insert variable** to reference workflow inputs and prior node outputs. Studio loads the local `.env` file automatically; secret values are not included in workflow files. Named model IDs are stored in the local Studio data directory. The offline fixture needs no SDK, key, or network call.
|
|
89
90
|
|
|
90
91
|
## Build a workflow
|
|
91
92
|
|
|
@@ -6,7 +6,7 @@ GraphCore Studio is an interactive, local-first visual agent framework. Drag nod
|
|
|
6
6
|
|
|
7
7
|
## Start Studio
|
|
8
8
|
|
|
9
|
-
Requirements: Python 3.9+ and a C++17 compiler. You do not need CMake, Node.js, Docker, API keys, an internet connection, or a model to try the included workflows.
|
|
9
|
+
Requirements: Python 3.9.2+ and a C++17 compiler. You do not need CMake, Node.js, Docker, API keys, an internet connection, or a model to try the included workflows.
|
|
10
10
|
|
|
11
11
|
From the project folder, run one command:
|
|
12
12
|
|
|
@@ -22,25 +22,25 @@ py run_studio.py
|
|
|
22
22
|
|
|
23
23
|
If Python was installed through the Windows Store and `py` is unavailable, run `python run_studio.py` from the Visual Studio Developer Terminal.
|
|
24
24
|
|
|
25
|
-
Stop the server with Ctrl+C. Workflows and run history live in `studio/data/`; drafts are saved in this browser. Run `python3 run_studio.py --help` to configure
|
|
25
|
+
Stop the server with Ctrl+C. Workflows and run history live in `studio/data/`; drafts are saved in this browser. Studio starts on port 8787 when available and automatically chooses a free local port if it is occupied. The actual URL is printed in the terminal. Run `python3 run_studio.py --help` to configure a specific port, data directory, or Python tool plugins.
|
|
26
26
|
|
|
27
27
|
### Install the PyPI package
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Install the prebuilt package and its OpenRouter/Pydantic runtime dependencies with:
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
|
-
python -m pip install
|
|
32
|
+
python -m pip install graphcore-studio
|
|
33
33
|
graphcore-studio
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
The wheel bundles the C++ runtime and Studio UI. Source installs build the native runtime and therefore require a C++17 compiler and CMake. The distribution is named `graphcore-studio`; its Python API imports as `graphcore`.
|
|
36
|
+
The wheel bundles the C++ runtime and Studio UI. Source installs build the native runtime and therefore require a C++17 compiler and CMake. The distribution is named `graphcore-studio`; its Python API imports as `graphcore`. Windows, macOS, and Linux wheels are provided for supported architectures.
|
|
37
37
|
|
|
38
38
|
### Use Pydantic
|
|
39
39
|
|
|
40
|
-
The built-in structured-output validator works offline
|
|
40
|
+
The built-in structured-output validator works offline. Pydantic v2 is installed by the standard PyPI command above and can be selected as the validation engine. When running directly from a source checkout, install the runtime dependencies with:
|
|
41
41
|
|
|
42
42
|
```bash
|
|
43
|
-
python3 -m pip install -r requirements-pydantic.txt
|
|
43
|
+
python3 -m pip install -r requirements-models.txt -r requirements-pydantic.txt
|
|
44
44
|
python3 run_studio.py
|
|
45
45
|
```
|
|
46
46
|
|
|
@@ -48,18 +48,16 @@ On Windows, replace `python3` with `py -3`. Restart Studio after installing pack
|
|
|
48
48
|
|
|
49
49
|
### Use OpenRouter models
|
|
50
50
|
|
|
51
|
-
The **Chat Model** and **Agent** nodes use GraphCore's model adapter with the official OpenRouter Python SDK. C++ owns graph scheduling, state commits, conditions, and checkpoints; the Python wrapper calls the SDK. The OpenRouter request runs through the project-owned adapter rather than a chain framework.
|
|
51
|
+
The **Chat Model** and **Agent** nodes use GraphCore's model adapter with the official OpenRouter Python SDK, installed automatically by the standard PyPI command. C++ owns graph scheduling, state commits, conditions, and checkpoints; the Python wrapper calls the SDK. The OpenRouter request runs through the project-owned adapter rather than a chain framework.
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
When running directly from a source checkout, install the runtime dependencies into the Python environment used to run Studio:
|
|
54
54
|
|
|
55
55
|
```bash
|
|
56
56
|
python3 -m pip install -r requirements-models.txt
|
|
57
57
|
python3 run_studio.py
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
In Studio, open **Model integrations**, add `OPENROUTER_API_KEY`, and save. Select **OpenRouter** on a Chat Model node and enter a model ID from the [OpenRouter model catalog](https://openrouter.ai/models). Compose system/user/assistant messages and use **Insert variable** to reference workflow inputs and prior node outputs. Studio loads the local `.env` file automatically; secret values are not included in workflow files. The offline fixture needs no SDK, key, or network call.
|
|
60
|
+
In Studio, open **Model integrations**, add `OPENROUTER_API_KEY`, and save. Add as many named models as you need, such as `model1 → deepseek/deepseek-v4-pro-0813` and `model2 → openai/gpt-4o-mini`, using IDs from the [OpenRouter model catalog](https://openrouter.ai/models). Choose a saved model on each Chat Model or Agent node. In workflow JSON, set `"model": "$model1"` or `"model": "$model2"` to select the model for that node. Literal model IDs still work. Compose system/user/assistant messages and use **Insert variable** to reference workflow inputs and prior node outputs. Studio loads the local `.env` file automatically; secret values are not included in workflow files. Named model IDs are stored in the local Studio data directory. The offline fixture needs no SDK, key, or network call.
|
|
63
61
|
|
|
64
62
|
## Build a workflow
|
|
65
63
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
Browser visual canvas (plain HTML/CSS/JS)
|
|
5
5
|
│ local JSON API with per-launch token
|
|
6
6
|
Python standard-library HTTP server and run manager
|
|
7
|
-
├── registered Python /
|
|
7
|
+
├── registered Python / Pydantic nodes
|
|
8
8
|
├── official OpenRouter Python SDK adapter
|
|
9
9
|
└── ctypes C ABI bridge
|
|
10
10
|
│
|
|
@@ -25,7 +25,7 @@ C++17 GraphCore runtime
|
|
|
25
25
|
- `python/graphcore/`: Python SDK and asyncio callback bridge.
|
|
26
26
|
- `run_studio.py`: one-command native compilation and local startup.
|
|
27
27
|
|
|
28
|
-
No Node.js, frontend bundler, database, C++ package manager or Python web framework is required. The OpenRouter Python SDK
|
|
28
|
+
No Node.js, frontend bundler, database, C++ package manager or Python web framework is required. The standard PyPI install includes the OpenRouter Python SDK and Pydantic so model and validation nodes are ready to use. Live model calls still require an OpenRouter API key; the offline fixture needs no key or network call. The canvas is served as local files and does not send workflow data to a remote service.
|
|
29
29
|
|
|
30
30
|
## Extending nodes
|
|
31
31
|
|
|
@@ -10,11 +10,11 @@ The graph hash pins each run to the exact workflow document it started with. Sav
|
|
|
10
10
|
|
|
11
11
|
## Execution split
|
|
12
12
|
|
|
13
|
-
The C++17 runtime owns sequential scheduling, node transitions, direct equality conditions, step limits, cancellation checks between callbacks and file-checkpoint commits. Python executes the local web API, model requests, registered integrations, demo fixtures and
|
|
13
|
+
The C++17 runtime owns sequential scheduling, node transitions, direct equality conditions, step limits, cancellation checks between callbacks and file-checkpoint commits. Python executes the local web API, model requests, registered integrations, demo fixtures and Pydantic validation. A Python callback crosses the C ABI and returns its update to the C++ scheduler before the next node runs.
|
|
14
14
|
|
|
15
15
|
A **demo model node uses a deterministic response template, not a model**. Select OpenRouter on a Chat Model or Agent node to make a request through the official OpenRouter Python SDK. The API key is read from the Studio process environment, which loads the local `.env` file on startup. The initial adapter performs non-streaming chat completions; it supports role-tagged messages, temperature, token limit, and JSON output mode.
|
|
16
16
|
|
|
17
|
-
OpenRouter JSON mode requests a JSON object; the next Structured output node can validate its shape and values. Built-in validation supports string, integer, number, boolean, object and array fields with strict primitive types.
|
|
17
|
+
OpenRouter JSON mode requests a JSON object; the next Structured output node can validate its shape and values. Built-in validation supports string, integer, number, boolean, object and array fields with strict primitive types. The standard PyPI install includes Pydantic v2, which adds Pydantic model validation to the same field list; custom Python validators are not configured through this UI yet.
|
|
18
18
|
|
|
19
19
|
## Native step commit
|
|
20
20
|
|
|
@@ -4,10 +4,10 @@ build-backend = "scikit_build_core.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graphcore-studio"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "A local-first visual agent workflow studio powered by a native C++ graph runtime"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.9"
|
|
10
|
+
requires-python = ">=3.9.2"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
license-files = ["LICENSE"]
|
|
13
13
|
classifiers = [
|
|
@@ -21,14 +21,18 @@ classifiers = [
|
|
|
21
21
|
"Programming Language :: Python :: 3.11",
|
|
22
22
|
"Programming Language :: Python :: 3.12",
|
|
23
23
|
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
24
25
|
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
25
26
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
27
|
]
|
|
27
28
|
keywords = ["agents", "agentic-workflows", "graph", "C++", "visual-workflow-builder"]
|
|
28
|
-
dependencies = [
|
|
29
|
+
dependencies = [
|
|
30
|
+
"openrouter>=0.10.8,<1",
|
|
31
|
+
"pydantic>=2,<3",
|
|
32
|
+
]
|
|
29
33
|
|
|
30
34
|
[project.optional-dependencies]
|
|
31
|
-
models = ["openrouter>=0.10,<1"]
|
|
35
|
+
models = ["openrouter>=0.10.8,<1"]
|
|
32
36
|
pydantic = ["pydantic>=2,<3"]
|
|
33
37
|
|
|
34
38
|
[project.scripts]
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# Official OpenRouter client used by the Chat Model node through GraphCore's Python wrapper.
|
|
2
|
-
openrouter>=0.10,<1
|
|
2
|
+
openrouter>=0.10.8,<1
|
|
@@ -52,7 +52,16 @@ def render(template, state):
|
|
|
52
52
|
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
|
53
53
|
return re.sub(r"\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}", replace, template)
|
|
54
54
|
|
|
55
|
-
def
|
|
55
|
+
def resolve_model(config, models=None):
|
|
56
|
+
model = config.get("model", "")
|
|
57
|
+
if isinstance(model, str) and model.startswith("$"):
|
|
58
|
+
name = model[1:]
|
|
59
|
+
if not KEY.fullmatch(name) or name not in (models or {}):
|
|
60
|
+
raise WorkflowError("Unknown model variable: " + model + ". Add it in Model integrations.")
|
|
61
|
+
return models[name]
|
|
62
|
+
return model
|
|
63
|
+
|
|
64
|
+
def validate(workflow, models=None):
|
|
56
65
|
errors = []
|
|
57
66
|
if not isinstance(workflow, dict):
|
|
58
67
|
return ["Workflow must be an object"]
|
|
@@ -103,6 +112,9 @@ def validate(workflow):
|
|
|
103
112
|
if field in config and not isinstance(config[field], str): errors.append(ident + ": " + field + " must be text")
|
|
104
113
|
if provider != "demo" and (not isinstance(config.get("model"), str) or not config["model"].strip() or len(config["model"]) > 200):
|
|
105
114
|
errors.append(ident + ": choose a model identifier (1–200 characters)")
|
|
115
|
+
elif provider != "demo":
|
|
116
|
+
try: resolve_model(config, models)
|
|
117
|
+
except WorkflowError as error: errors.append(ident + ": " + str(error))
|
|
106
118
|
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
119
|
errors.append(ident + ": temperature must be between 0 and 2")
|
|
108
120
|
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):
|
|
@@ -230,7 +242,7 @@ def chat_messages(config, state):
|
|
|
230
242
|
{"role":"user", "content":config.get("prompt", "{{input}}") }]
|
|
231
243
|
return [{"role":message["role"], "content":render(message["content"], state)} for message in messages]
|
|
232
244
|
|
|
233
|
-
def invoke_model(config, state):
|
|
245
|
+
def invoke_model(config, state, models=None):
|
|
234
246
|
provider = config.get("provider", "demo")
|
|
235
247
|
if provider == "demo":
|
|
236
248
|
text = render(config.get("demo_response", "Demo response for {{input}}"), state)
|
|
@@ -239,13 +251,14 @@ def invoke_model(config, state):
|
|
|
239
251
|
if not meta: raise WorkflowError("This initial release supports OpenRouter models. Choose OpenRouter or the offline fixture.")
|
|
240
252
|
api_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
|
|
241
253
|
if not api_key: raise WorkflowError("Configure OPENROUTER_API_KEY in Model integrations before running this model.")
|
|
242
|
-
|
|
254
|
+
model_id = resolve_model(config, models)
|
|
255
|
+
if not isinstance(model_id, str) or not model_id.strip():
|
|
243
256
|
raise WorkflowError("Enter an OpenRouter model ID, such as openai/gpt-4o-mini.")
|
|
244
257
|
try:
|
|
245
258
|
from openrouter import OpenRouter
|
|
246
259
|
except ImportError as error:
|
|
247
260
|
raise WorkflowError("Install the OpenRouter SDK in the Studio Python environment: python -m pip install openrouter") from error
|
|
248
|
-
kwargs = {"model":
|
|
261
|
+
kwargs = {"model":model_id, "messages":chat_messages(config, state), "stream":False,
|
|
249
262
|
"temperature":config.get("temperature", 0.2),
|
|
250
263
|
"max_completion_tokens":config.get("max_tokens", 1024),
|
|
251
264
|
"timeout_ms":round(config.get("timeout", 60) * 1000)}
|
|
@@ -279,7 +292,7 @@ def invoke_model(config, state):
|
|
|
279
292
|
# A reasoning-only response often means hidden reasoning consumed the
|
|
280
293
|
# entire completion limit. Ask for a direct answer on the single retry.
|
|
281
294
|
retry["reasoning_effort"] = "none"
|
|
282
|
-
return invoke_model(retry, state)
|
|
295
|
+
return invoke_model(retry, state, models)
|
|
283
296
|
finish_reason = field(choice, "finish_reason", "unknown")
|
|
284
297
|
reasoning = field(message, "reasoning")
|
|
285
298
|
reason = " The model returned reasoning but no visible answer." if reasoning else ""
|
|
@@ -289,8 +302,8 @@ def invoke_model(config, state):
|
|
|
289
302
|
return content
|
|
290
303
|
return content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
|
|
291
304
|
|
|
292
|
-
def compile_workflow(workflow, emit=lambda kind, **data: None):
|
|
293
|
-
errors = validate(workflow)
|
|
305
|
+
def compile_workflow(workflow, emit=lambda kind, **data: None, models=None):
|
|
306
|
+
errors = validate(workflow, models)
|
|
294
307
|
if errors: raise WorkflowError("\n".join(errors))
|
|
295
308
|
version = hashlib.sha256(json.dumps(workflow, sort_keys=True).encode()).hexdigest()
|
|
296
309
|
graph = Graph(version)
|
|
@@ -303,9 +316,10 @@ def compile_workflow(workflow, emit=lambda kind, **data: None):
|
|
|
303
316
|
if kind == "input":
|
|
304
317
|
result = {}
|
|
305
318
|
elif kind in ("agent", "model"):
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
319
|
+
model_id = resolve_model(config, models) if config.get("provider", "demo") != "demo" else ""
|
|
320
|
+
emit("model.started", node=ident, provider=config.get("provider", "demo"), model=model_id)
|
|
321
|
+
result = {key: invoke_model(config, state, models)}
|
|
322
|
+
emit("model.completed", node=ident, provider=config.get("provider", "demo"), model=model_id)
|
|
309
323
|
elif kind == "tool":
|
|
310
324
|
value = lookup(state, config.get("input_field", "input"))
|
|
311
325
|
result = {key: TOOLS[config.get("tool", "word_count")]["function"](copy.deepcopy(value), copy.deepcopy(config))}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"""Local-only Studio server. No third-party web framework or frontend build needed."""
|
|
3
3
|
import argparse
|
|
4
4
|
import copy
|
|
5
|
+
import errno
|
|
5
6
|
from concurrent.futures import ThreadPoolExecutor
|
|
6
7
|
from datetime import datetime, timezone
|
|
7
8
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
@@ -23,7 +24,7 @@ import uuid
|
|
|
23
24
|
ROOT = Path(__file__).resolve().parents[1]
|
|
24
25
|
sys.path.insert(0, str(ROOT / "python"))
|
|
25
26
|
sys.path.insert(0, str(ROOT))
|
|
26
|
-
from studio.engine import compile_workflow, validate, TOOLS, pydantic_available, provider_availability, register_tool
|
|
27
|
+
from studio.engine import compile_workflow, validate, TOOLS, KEY, pydantic_available, provider_availability, register_tool
|
|
27
28
|
|
|
28
29
|
WEB = ROOT / "studio" / "web"
|
|
29
30
|
TEMPLATES = ROOT / "studio" / "templates"
|
|
@@ -94,6 +95,8 @@ class Store:
|
|
|
94
95
|
self.base_environment = dict(os.environ)
|
|
95
96
|
self.env_lock = threading.RLock()
|
|
96
97
|
self.saved_environment = load_dotenv(self.env_path)
|
|
98
|
+
self.models_path = self.root / "models.json"
|
|
99
|
+
self.models = json.loads(self.models_path.read_text(encoding="utf-8")) if self.models_path.exists() else {}
|
|
97
100
|
self.workflows = self.root / "workflows"; self.workflows.mkdir(exist_ok=True)
|
|
98
101
|
self.runs = self.root / "runs"; self.runs.mkdir(exist_ok=True)
|
|
99
102
|
self.lock = threading.RLock()
|
|
@@ -126,6 +129,19 @@ class Store:
|
|
|
126
129
|
return [{"name": name, "configured": name in os.environ, "stored": name in stored}
|
|
127
130
|
for name in names]
|
|
128
131
|
|
|
132
|
+
def update_models(self, models):
|
|
133
|
+
if not isinstance(models, dict) or len(models) > 50:
|
|
134
|
+
raise ValueError("Configure up to 50 named models")
|
|
135
|
+
for name, model in models.items():
|
|
136
|
+
if not isinstance(name, str) or not KEY.fullmatch(name):
|
|
137
|
+
raise ValueError("Model variable names must be simple identifiers")
|
|
138
|
+
if not isinstance(model, str) or not model.strip() or len(model) > 200 or "/" not in model:
|
|
139
|
+
raise ValueError("Each model needs an OpenRouter model ID such as openai/gpt-4o-mini")
|
|
140
|
+
with self.lock:
|
|
141
|
+
write_json(self.models_path, models)
|
|
142
|
+
self.models = dict(models)
|
|
143
|
+
return dict(self.models)
|
|
144
|
+
|
|
129
145
|
def update_environment(self, values, remove):
|
|
130
146
|
if not isinstance(values, dict) or not isinstance(remove, list):
|
|
131
147
|
raise ValueError("Environment values must be an object and removals a list")
|
|
@@ -167,14 +183,15 @@ class Store:
|
|
|
167
183
|
write_json(self.runs / (ident + ".json"), self.records[ident])
|
|
168
184
|
|
|
169
185
|
def start(self, workflow, inputs, max_steps=100):
|
|
170
|
-
|
|
186
|
+
with self.lock: models = dict(self.models)
|
|
187
|
+
errors = validate(workflow, models)
|
|
171
188
|
if errors: raise ValueError("\n".join(errors))
|
|
172
189
|
if not isinstance(inputs, dict): raise ValueError("Inputs must be a JSON object")
|
|
173
190
|
if not isinstance(max_steps, int) or not 1 <= max_steps <= 1000: raise ValueError("Step limit must be 1–1000")
|
|
174
191
|
if not self.slots.acquire(blocking=False): raise ValueError("Four runs are already active; wait for one to finish")
|
|
175
192
|
ident = uuid.uuid4().hex
|
|
176
193
|
record = {"id": ident, "name": workflow.get("name", "Untitled workflow"), "workflow": copy.deepcopy(workflow),
|
|
177
|
-
"inputs": copy.deepcopy(inputs), "max_steps": max_steps, "created": now(), "status": "queued",
|
|
194
|
+
"inputs": copy.deepcopy(inputs), "models": models, "max_steps": max_steps, "created": now(), "status": "queued",
|
|
178
195
|
"events": [], "state": {}, "prompt": "", "error": "", "duration_ms": 0}
|
|
179
196
|
try:
|
|
180
197
|
with self.lock:
|
|
@@ -222,7 +239,7 @@ class Store:
|
|
|
222
239
|
with self.lock:
|
|
223
240
|
record = self.records[ident]
|
|
224
241
|
workflow, inputs, budget = record["workflow"], record["inputs"], record["max_steps"]
|
|
225
|
-
graph = compile_workflow(workflow, emit)
|
|
242
|
+
graph = compile_workflow(workflow, emit, record.get("models", {}))
|
|
226
243
|
with self.lock:
|
|
227
244
|
self.active[ident] = graph
|
|
228
245
|
if record["status"] == "cancelling": graph.cancel()
|
|
@@ -286,6 +303,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
286
303
|
"tools": [{"name": k, "description": v["description"]} for k,v in TOOLS.items()]})
|
|
287
304
|
if path == "/api/settings/environment":
|
|
288
305
|
return self.send({"variables": self.server.store.environment_status()})
|
|
306
|
+
if path == "/api/settings/models":
|
|
307
|
+
with self.server.store.lock: return self.send({"models": dict(self.server.store.models)})
|
|
289
308
|
if path == "/api/workflows":
|
|
290
309
|
with self.server.store.lock:
|
|
291
310
|
items = [json.loads(p.read_text()) for p in self.server.store.workflows.glob("*.json")]
|
|
@@ -313,10 +332,12 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
313
332
|
body = json.loads(self.rfile.read(size), parse_constant=lambda value: (_ for _ in ()).throw(ValueError("Non-finite JSON number")))
|
|
314
333
|
if not isinstance(body, dict): raise ValueError("Request must be a JSON object")
|
|
315
334
|
path = urllib.parse.urlsplit(self.path).path
|
|
316
|
-
if path == "/api/validate": return self.send({"errors": validate(body.get("workflow"))})
|
|
335
|
+
if path == "/api/validate": return self.send({"errors": validate(body.get("workflow"), self.server.store.models)})
|
|
317
336
|
if path == "/api/settings/environment":
|
|
318
337
|
return self.send({"variables": self.server.store.update_environment(
|
|
319
338
|
body.get("values", {}), body.get("remove", []))})
|
|
339
|
+
if path == "/api/settings/models":
|
|
340
|
+
return self.send({"models": self.server.store.update_models(body.get("models"))})
|
|
320
341
|
if path == "/api/workflows":
|
|
321
342
|
workflow = body.get("workflow")
|
|
322
343
|
if not isinstance(workflow, dict) or workflow.get("format") != "graphcore.studio.v1": raise ValueError("Invalid workflow document")
|
|
@@ -346,7 +367,7 @@ class Server(ThreadingHTTPServer):
|
|
|
346
367
|
|
|
347
368
|
def main():
|
|
348
369
|
parser = argparse.ArgumentParser(description="GraphCore Studio — local visual agent builder")
|
|
349
|
-
parser.add_argument("--port", type=int, default=8787)
|
|
370
|
+
parser.add_argument("--port", type=int, default=None, help="Local port (default: 8787, then an available port)")
|
|
350
371
|
parser.add_argument("--data", default=os.environ.get("GRAPHCORE_STUDIO_DATA", str(ROOT / "studio" / "data")))
|
|
351
372
|
parser.add_argument("--plugin", action="append", default=[], help="Trusted local Python module file registering tools")
|
|
352
373
|
args = parser.parse_args()
|
|
@@ -356,7 +377,13 @@ def main():
|
|
|
356
377
|
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
|
357
378
|
module.register(register_tool)
|
|
358
379
|
store = Store(args.data)
|
|
359
|
-
|
|
380
|
+
try:
|
|
381
|
+
server = Server(("127.0.0.1", args.port if args.port is not None else 8787), store)
|
|
382
|
+
except OSError as error:
|
|
383
|
+
if args.port is not None or getattr(error, "errno", None) != errno.EADDRINUSE:
|
|
384
|
+
store.close()
|
|
385
|
+
raise
|
|
386
|
+
server = Server(("127.0.0.1", 0), store)
|
|
360
387
|
print("GraphCore Studio: http://127.0.0.1:" + str(server.server_port), flush=True)
|
|
361
388
|
print("Local files: " + str(store.root), flush=True)
|
|
362
389
|
try: server.serve_forever()
|
|
@@ -23,7 +23,7 @@ const META = {
|
|
|
23
23
|
output:{name:'Output',sub:'Return your final result',group:'FLOW',color:'#7aa39a',bg:'#edf6f4'}
|
|
24
24
|
};
|
|
25
25
|
const icon = kind => `<span class="node-icon" style="--node-color:${META[kind]?.color};--node-bg:${META[kind]?.bg}"><svg viewBox="0 0 22 22">${icons[kind]||icons.agent}</svg></span>`;
|
|
26
|
-
const S = {workflow:null,selected:null,library:'nodes',tab:'input',bootstrap:null,view:{x:35,y:55,z:.8},undo:[],redo:[],run:null,nodeStatus:{},connection:null,inputText:'{}',poll:null,dirty:false};
|
|
26
|
+
const S = {workflow:null,selected:null,library:'nodes',tab:'input',bootstrap:null,view:{x:35,y:55,z:.8},undo:[],redo:[],run:null,nodeStatus:{},connection:null,inputText:'{}',poll:null,dirty:false,models:{}};
|
|
27
27
|
let toastTimer;
|
|
28
28
|
function toast(message){$('#toast').textContent=message;$('#toast').hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('#toast').hidden=true,4500);}
|
|
29
29
|
async function api(path, body){const response=await fetch(path,{method:body===undefined?'GET':'POST',headers:body===undefined?{}:{'Content-Type':'application/json','X-Studio-Token':S.bootstrap.csrf},body:body===undefined?undefined:JSON.stringify(body)});const data=await response.json();if(!response.ok)throw Error(data.error||'Request failed');return data;}
|
|
@@ -33,8 +33,8 @@ function load(workflow){if(S.poll){clearTimeout(S.poll);S.poll=null;}S.workflow=
|
|
|
33
33
|
function renderAll(){if(!S.workflow)return;$('#workflow-name').value=S.workflow.name;$('#breadcrumb').textContent=S.workflow.name;$('#workflow-description').textContent=S.workflow.description||'Build a team of agents. Connect the way they think.';renderLibrary();renderNodes();renderInspector();renderConsole();$('#undo-button').disabled=!S.undo.length;$('#redo-button').disabled=!S.redo.length;}
|
|
34
34
|
function renderLibrary(){const text=$('#node-search').value.toLowerCase();document.querySelectorAll('[data-library]').forEach(b=>b.classList.toggle('active',b.dataset.library===S.library));let html='';if(S.library==='templates'){html=S.bootstrap.templates.map((t,i)=>`<button class="template-card" data-template="${i}">${icon(i===1?'schema':i===2?'tool':'agent')}<strong>${esc(t.name)}</strong><p>${esc(t.description)}</p><small>${t.nodes.length} nodes ↗ Open template</small></button>`).join('');html+='<button class="template-card" id="blank-template"><strong>+ Start from scratch</strong><p>A blank canvas for your next idea.</p></button>';}else{for(const group of ['INTELLIGENCE','FLOW','INTEGRATIONS']){const kinds=Object.keys(META).filter(k=>META[k].group===group&&`${META[k].name} ${META[k].sub}`.toLowerCase().includes(text));if(!kinds.length)continue;html+=`<div class="library-group">${group}</div>`;for(const kind of kinds){const m=META[kind];html+=`<button class="palette-node" draggable="true" data-kind="${kind}" aria-label="Add ${m.name}">${icon(kind)}<span><strong>${m.name}</strong><small>${m.sub}</small></span><span class="grab">⠿</span></button>`;}}}$('#node-library').innerHTML=html;document.querySelectorAll('.palette-node').forEach(el=>{el.addEventListener('dragstart',e=>{e.dataTransfer.setData('application/graphcore-node',el.dataset.kind);e.dataTransfer.effectAllowed='copy';});el.onclick=()=>{const area=$('#canvas-area');addNode(el.dataset.kind,(area.clientWidth/2-S.view.x)/S.view.z-113,(area.clientHeight/2-S.view.y)/S.view.z-54);};});document.querySelectorAll('[data-template]').forEach(b=>b.onclick=()=>confirmReplace(()=>load(S.bootstrap.templates[+b.dataset.template])));if($('#blank-template'))$('#blank-template').onclick=()=>confirmReplace(()=>load({format:'graphcore.studio.v1',id:'workflow_'+Date.now(),name:'Untitled workflow',description:'A blank canvas for your next idea.',inputs:{input:'Hello, world'},nodes:[],edges:[]}));}
|
|
35
35
|
function confirmReplace(action){if(!S.dirty){action();return;}showModal('Replace this draft?',`<p class="modal-copy">Save or export your current workflow first if you want to keep it.</p><div class="modal-actions"><button class="button" id="keep-draft">Keep editing</button><button class="button primary" id="replace-draft">Replace draft</button></div>`);$('#keep-draft').onclick=closeModal;$('#replace-draft').onclick=()=>{closeModal();action();};}
|
|
36
|
-
function nodeSubtitle(n){const c=n.config||{};if(n.type==='agent'||n.type==='model')return c.provider==='demo'?'Offline fixture':`${c.provider||'
|
|
37
|
-
function renderNodes(){const w=S.workflow;$('#nodes').innerHTML=w.nodes.map(n=>{const m=META[n.type]||META.agent;const selected=S.selected===n.id;const c=n.config||{};return `<div class="canvas-node ${esc(n.type)} ${selected?'selected':''} ${S.nodeStatus[n.id]||''}" id="node-${esc(n.id)}" data-node="${esc(n.id)}" style="left:${Number(n.x)||0}px;top:${Number(n.y)||0}px" tabindex="0" role="group" aria-label="${esc(n.label)} node"><div class="node-head">${icon(n.type)}<div><strong>${esc(n.label||m.name)}</strong><small>${esc(nodeSubtitle(n))}</small></div><span class="node-menu">⋮</span></div><div class="node-foot"><span>${n.type==='condition'?'C++ routing':n.type==='agent'?'Agent':esc(m.name)}</span><code>${esc(n.type==='input'?'workflow inputs':n.type==='output'?'output':c.output_key||n.type==='condition'&&'true / false'||n.id)}</code></div>${n.type!=='input'?`<button class="port input" data-target="${esc(n.id)}" aria-label="Connect to ${esc(n.label)}"></button>`:''}${n.type==='condition'?`<button class="port output true" data-source="${esc(n.id)}" data-port="true" aria-label="True branch from ${esc(n.label)}"></button><button class="port output false" data-source="${esc(n.id)}" data-port="false" aria-label="False branch from ${esc(n.label)}"></button>`:n.type!=='output'?`<button class="port output" data-source="${esc(n.id)}" data-port="next" aria-label="Connect from ${esc(n.label)}"></button>`:''}</div>`;}).join('');$('#empty-canvas').hidden=w.nodes.length>0;$('#graph-stats').textContent=`${w.nodes.length} nodes · ${w.edges.length} connections`;const real=w.nodes.
|
|
36
|
+
function nodeSubtitle(n){const c=n.config||{};if(n.type==='agent'||n.type==='model')return c.provider==='demo'?'Offline fixture':`${c.provider||'openrouter'} · ${c.model||'choose model'}`;if(n.type==='condition')return `${c.field||'field'} equals ${JSON.stringify(c.expected)}`;if(n.type==='tool')return c.tool||'word_count';if(n.type==='schema')return c.engine==='pydantic'?'Pydantic validation':'Portable schema validation';return META[n.type]?.sub||n.type;}
|
|
37
|
+
function renderNodes(){const w=S.workflow;$('#nodes').innerHTML=w.nodes.map(n=>{const m=META[n.type]||META.agent;const selected=S.selected===n.id;const c=n.config||{};return `<div class="canvas-node ${esc(n.type)} ${selected?'selected':''} ${S.nodeStatus[n.id]||''}" id="node-${esc(n.id)}" data-node="${esc(n.id)}" style="left:${Number(n.x)||0}px;top:${Number(n.y)||0}px" tabindex="0" role="group" aria-label="${esc(n.label)} node"><div class="node-head">${icon(n.type)}<div><strong>${esc(n.label||m.name)}</strong><small>${esc(nodeSubtitle(n))}</small></div><span class="node-menu">⋮</span></div><div class="node-foot"><span>${n.type==='condition'?'C++ routing':n.type==='agent'?'Agent':esc(m.name)}</span><code>${esc(n.type==='input'?'workflow inputs':n.type==='output'?'output':c.output_key||n.type==='condition'&&'true / false'||n.id)}</code></div>${n.type!=='input'?`<button class="port input" data-target="${esc(n.id)}" aria-label="Connect to ${esc(n.label)}"></button>`:''}${n.type==='condition'?`<button class="port output true" data-source="${esc(n.id)}" data-port="true" aria-label="True branch from ${esc(n.label)}"></button><button class="port output false" data-source="${esc(n.id)}" data-port="false" aria-label="False branch from ${esc(n.label)}"></button>`:n.type!=='output'?`<button class="port output" data-source="${esc(n.id)}" data-port="next" aria-label="Connect from ${esc(n.label)}"></button>`:''}</div>`;}).join('');$('#empty-canvas').hidden=w.nodes.length>0;$('#graph-stats').textContent=`${w.nodes.length} nodes · ${w.edges.length} connections`;const real=w.nodes.filter(n=>['agent','model'].includes(n.type)&&(n.config?.provider||'demo')!=='demo');$('#mode-label').textContent=real.length?`${real.length} model node${real.length===1?'':'s'} · ${new Set(real.map(n=>n.config.model)).size} selection${new Set(real.map(n=>n.config.model)).size===1?'':'s'}`:'Offline demo · no model calls';document.querySelectorAll('.canvas-node').forEach(el=>{el.addEventListener('pointerdown',startNodeDrag);el.addEventListener('keydown',e=>{if(e.key==='Enter'){selectNode(el.dataset.node);}});});document.querySelectorAll('.port.output').forEach(el=>{el.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();S.connection={source:el.dataset.source,port:el.dataset.port};toast('Select an input port to connect.');}});el.addEventListener('pointerdown',e=>{e.stopPropagation();S.connection={source:el.dataset.source,port:el.dataset.port};$('#canvas-hint').textContent='Click an input dot to connect · Esc to cancel';el.classList.add('connecting');});});document.querySelectorAll('.port.input').forEach(el=>{el.addEventListener('keydown',e=>{if(e.key==='Enter'&&S.connection){e.preventDefault();connect(S.connection.source,el.dataset.target,S.connection.port);}});el.addEventListener('pointerdown',e=>{e.stopPropagation();if(S.connection)connect(S.connection.source,el.dataset.target,S.connection.port);});});applyView();drawEdges();}
|
|
38
38
|
function dimensions(node){const el=document.getElementById('node-'+node.id);return {w:226,h:el?.offsetHeight||108};}
|
|
39
39
|
function anchor(node,side,port){const d=dimensions(node);return {x:(Number(node.x)||0)+(side==='out'?d.w:0),y:(Number(node.y)||0)+d.h*(port==='true'?.35:port==='false'?.72:.5)};}
|
|
40
40
|
function curve(a,b){const distance=Math.max(70,Math.abs(b.x-a.x)*.5);return `M ${a.x} ${a.y} C ${a.x+distance} ${a.y}, ${b.x-distance} ${b.y}, ${b.x} ${b.y}`;}
|
|
@@ -65,7 +65,8 @@ function modelEditor(node){
|
|
|
65
65
|
html+=field('Provider','provider',provider,{select:providers});
|
|
66
66
|
if(provider==='demo')html+=field('Demo response fixture','demo_response',c.demo_response||'Demo response: {{input}}',{area:true,rows:2,help:'Used for offline testing. It does not call a model.'});
|
|
67
67
|
else {
|
|
68
|
-
html+=field('
|
|
68
|
+
html+=field('Model selection','model',c.model||'openai/gpt-4o-mini',{help:'Use $model1 for a saved model variable, or enter an OpenRouter model ID directly.'});
|
|
69
|
+
html+=`<label class="field"><span>Insert saved model</span><select id="model-variable"><option value="">Choose a model variable…</option>${Object.entries(S.models).map(([name,id])=>`<option value="$${esc(name)}" ${c.model==='$'+name?'selected':''}>$${esc(name)} · ${esc(id)}</option>`).join('')}</select><small>Manage model variables in Model integrations.</small></label>`;
|
|
69
70
|
html+='<div class="inspector-note">Add <strong>OPENROUTER_API_KEY</strong> in <strong>Model integrations</strong>. OpenRouter model calls run through GraphCore’s Python bridge.</div>';
|
|
70
71
|
if(router&&!router.installed)html+='<div class="inspector-note">Install the SDK in the Python environment running Studio: <code>python -m pip install openrouter</code>, then restart Studio.</div>';
|
|
71
72
|
}
|
|
@@ -88,21 +89,22 @@ if(kind==='approval'){html+='<div class="inspector-section">HUMAN IN THE LOOP</d
|
|
|
88
89
|
if(kind==='output'){html+=field('Result template','template',c.template||'{{input}}',{area:true,rows:7,help:'Combine state fields into your final result.'});}
|
|
89
90
|
if(!['input','condition','output'].includes(kind)){html+='<div class="inspector-section">STATE</div>';html+=field('Save output to','output_key',c.output_key||node.id,{help:'Other nodes can read this field.'});}
|
|
90
91
|
html+='<div class="inspector-section">CONNECTIONS</div>';const ports=kind==='condition'?['true','false']:kind==='output'?[]:['next'];for(const port of ports){const edge=S.workflow.edges.find(e=>e.source===node.id&&(e.port||'next')===port);html+=`<div class="connection-row"><span>${port} →</span><select data-route="${port}" aria-label="${port} target"><option value="">Not connected</option>${S.workflow.nodes.filter(n=>n.id!==node.id&&n.type!=='input').map(n=>`<option value="${esc(n.id)}" ${edge?.target===n.id?'selected':''}>${esc(n.label)}</option>`).join('')}</select></div>`;}if(!ports.length)html+='<div class="field-help">This node completes the workflow.</div>';html+='<button class="button delete-node" id="delete-node">Delete node</button>';$('#inspector-content').innerHTML=html;
|
|
91
|
-
$('#node-label').onchange=e=>{mark();node.label=e.target.value||META[kind].name;changed();renderNodes();};document.querySelectorAll('[data-config]').forEach(el=>el.onchange=()=>{let value=el.value;try{if(el.dataset.json)value=JSON.parse(value);if(['temperature','max_tokens','timeout'].includes(el.dataset.config))value=Number(value);mark();node.config[el.dataset.config]=value;if(el.dataset.config==='provider'&&value==='openrouter'&&!String(node.config.model||'').includes('/'))node.config.model='openai/gpt-4o-mini';changed();renderNodes();if(['provider','engine'].includes(el.dataset.config))renderInspector();}catch(error){toast('Invalid JSON value: '+error.message);el.setAttribute('aria-invalid','true');}});document.querySelectorAll('[data-message-role]').forEach(el=>el.onchange=()=>{mark();node.config.messages[+el.dataset.messageRole].role=el.value;changed();});document.querySelectorAll('[data-message-content]').forEach(el=>{el.addEventListener('input',()=>{if(!el.dataset.historyMarked){mark();el.dataset.historyMarked='1';}node.config.messages[+el.dataset.messageContent].content=el.value;changed();});el.addEventListener('blur',()=>{delete el.dataset.historyMarked;});});document.querySelectorAll('[data-message-variable]').forEach(el=>el.onchange=()=>{const name=el.value;if(!name)return;const area=[...document.querySelectorAll('[data-message-content]')].find(a=>a.dataset.messageContent===el.dataset.messageVariable);const token='{{'+name+'}}';const start=area.selectionStart??area.value.length,end=area.selectionEnd??start;area.setRangeText(token,start,end,'end');node.config.messages[+el.dataset.messageVariable].content=area.value;changed();area.focus();el.value='';});document.querySelectorAll('[data-message-remove]').forEach(el=>el.onclick=()=>{mark();node.config.messages.splice(+el.dataset.messageRemove,1);changed();renderInspector();});if($('#add-message'))$('#add-message').onclick=()=>{if(node.config.messages.length>=20){toast('A model can have up to 20 messages.');return;}mark();node.config.messages.push({role:'user',content:''});changed();renderInspector();document.querySelectorAll('[data-message-content]')[node.config.messages.length-1]?.focus();};if($('#json-output'))$('#json-output').onchange=e=>{mark();node.config.json_output=e.target.checked;changed();};document.querySelectorAll('[data-schema]').forEach(el=>el.onchange=()=>{mark();node.config.fields[+el.closest('[data-index]').dataset.index][el.dataset.schema]=el.type==='checkbox'?el.checked:el.value;changed();});document.querySelectorAll('[data-remove-field]').forEach(el=>el.onclick=()=>{mark();node.config.fields.splice(+el.dataset.removeField,1);changed();renderInspector();});if($('#add-schema-field'))$('#add-schema-field').onclick=()=>{mark();node.config.fields.push({name:'field_'+(node.config.fields.length+1),type:'string',required:true});changed();renderInspector();};document.querySelectorAll('[data-route]').forEach(el=>el.onchange=()=>{if(el.value)connect(node.id,el.value,el.dataset.route);else{mark();S.workflow.edges=S.workflow.edges.filter(e=>!(e.source===node.id&&(e.port||'next')===el.dataset.route));changed();renderNodes();}});$('#delete-node').onclick=deleteSelected;}
|
|
92
|
+
$('#node-label').onchange=e=>{mark();node.label=e.target.value||META[kind].name;changed();renderNodes();};document.querySelectorAll('[data-config]').forEach(el=>el.onchange=()=>{let value=el.value;try{if(el.dataset.json)value=JSON.parse(value);if(['temperature','max_tokens','timeout'].includes(el.dataset.config))value=Number(value);mark();node.config[el.dataset.config]=value;if(el.dataset.config==='provider'&&value==='openrouter'&&!String(node.config.model||'').includes('/')&&!String(node.config.model||'').startsWith('$'))node.config.model='openai/gpt-4o-mini';changed();renderNodes();if(['provider','engine'].includes(el.dataset.config))renderInspector();}catch(error){toast('Invalid JSON value: '+error.message);el.setAttribute('aria-invalid','true');}});document.querySelectorAll('[data-message-role]').forEach(el=>el.onchange=()=>{mark();node.config.messages[+el.dataset.messageRole].role=el.value;changed();});document.querySelectorAll('[data-message-content]').forEach(el=>{el.addEventListener('input',()=>{if(!el.dataset.historyMarked){mark();el.dataset.historyMarked='1';}node.config.messages[+el.dataset.messageContent].content=el.value;changed();});el.addEventListener('blur',()=>{delete el.dataset.historyMarked;});});document.querySelectorAll('[data-message-variable]').forEach(el=>el.onchange=()=>{const name=el.value;if(!name)return;const area=[...document.querySelectorAll('[data-message-content]')].find(a=>a.dataset.messageContent===el.dataset.messageVariable);const token='{{'+name+'}}';const start=area.selectionStart??area.value.length,end=area.selectionEnd??start;area.setRangeText(token,start,end,'end');node.config.messages[+el.dataset.messageVariable].content=area.value;changed();area.focus();el.value='';});document.querySelectorAll('[data-message-remove]').forEach(el=>el.onclick=()=>{mark();node.config.messages.splice(+el.dataset.messageRemove,1);changed();renderInspector();});if($('#add-message'))$('#add-message').onclick=()=>{if(node.config.messages.length>=20){toast('A model can have up to 20 messages.');return;}mark();node.config.messages.push({role:'user',content:''});changed();renderInspector();document.querySelectorAll('[data-message-content]')[node.config.messages.length-1]?.focus();};if($('#model-variable'))$('#model-variable').onchange=e=>{if(!e.target.value)return;mark();node.config.model=e.target.value;changed();renderNodes();renderInspector();};if($('#json-output'))$('#json-output').onchange=e=>{mark();node.config.json_output=e.target.checked;changed();};document.querySelectorAll('[data-schema]').forEach(el=>el.onchange=()=>{mark();node.config.fields[+el.closest('[data-index]').dataset.index][el.dataset.schema]=el.type==='checkbox'?el.checked:el.value;changed();});document.querySelectorAll('[data-remove-field]').forEach(el=>el.onclick=()=>{mark();node.config.fields.splice(+el.dataset.removeField,1);changed();renderInspector();});if($('#add-schema-field'))$('#add-schema-field').onclick=()=>{mark();node.config.fields.push({name:'field_'+(node.config.fields.length+1),type:'string',required:true});changed();renderInspector();};document.querySelectorAll('[data-route]').forEach(el=>el.onchange=()=>{if(el.value)connect(node.id,el.value,el.dataset.route);else{mark();S.workflow.edges=S.workflow.edges.filter(e=>!(e.source===node.id&&(e.port||'next')===el.dataset.route));changed();renderNodes();}});$('#delete-node').onclick=deleteSelected;}
|
|
92
93
|
function deleteSelected(){if(!S.selected)return;mark();S.workflow.nodes=S.workflow.nodes.filter(n=>n.id!==S.selected);S.workflow.edges=S.workflow.edges.filter(e=>e.source!==S.selected&&e.target!==S.selected);S.selected=null;changed();renderNodes();renderInspector();}
|
|
93
94
|
function renderConsole(){document.querySelectorAll('[data-console]').forEach(b=>b.classList.toggle('active',b.dataset.console===S.tab));const r=S.run;const active=r&&['queued','running','cancelling'].includes(r.status);$('#stop-button').hidden=!active;$('#stop-button').disabled=r?.status==='cancelling';$('#run-button').disabled=!!active||!S.bootstrap.native;$('#run-status').textContent=r?`${r.status.charAt(0).toUpperCase()+r.status.slice(1)}${r.duration_ms?' · '+(r.duration_ms/1000).toFixed(2)+'s':''}`:'Ready to run';$('#event-count').textContent=r?.events.length||0;let html='';if(S.tab==='input'){html=`<div class="input-pane"><div><textarea id="run-input" spellcheck="false" aria-label="Workflow input JSON">${esc(S.inputText)}</textarea></div><div class="input-hint"><strong>Give your workflow a starting point.</strong>Pass a JSON object. Reference its fields in prompts with <code>{{input}}</code>.<br>Then click <strong style="display:inline;font-size:10px">Run workflow</strong>.</div></div>`;}else if(!r){html='<div class="console-empty"><span class="empty-symbol">▷</span><div><strong>A little input. A lot of possibility.</strong>Run your workflow to see results here.</div></div>';}else if(S.tab==='state'){html=`<pre class="state-json">${esc(JSON.stringify(r.state,null,2))}</pre>${!Object.keys(r.state).length?'<p class="field-help">Final state appears when the run completes or pauses. Intermediate updates are in the execution trace.</p>':''}`;}else if(S.tab==='trace'){html=r.events.map(e=>`<div class="trace-row"><time>${esc(e.time.slice(11,23))}</time><strong>${esc(e.type)}</strong><code>${esc(e.node||'')}</code>${e.updates?`<span title="${esc(JSON.stringify(e.updates))}">↳ ${esc(Object.keys(e.updates).join(', '))}</span>`:''}</div>`).join('')||'<p class="field-help">Waiting for native runtime events…</p>';}else if(r.status==='suspended'){html=`<div class="approval-panel"><span class="run-badge suspended">Awaiting human input</span><div class="question">${esc(r.prompt)}</div><div class="approval-form"><input id="approval-response" value="true" aria-label="Approval response as JSON"><button class="button primary" id="resume-button">Continue run →</button></div><p class="field-help">Enter a JSON response: true, false, or a quoted message.</p></div>`;}else if(r.error){html=`<div class="error-box">${esc(r.error)}</div><button class="button small" id="recover-button" style="margin-top:8px">Recover from checkpoint</button>`;}else if(r.status==='completed'){html=`<pre class="output-text">${esc(typeof r.state.output==='string'?r.state.output:JSON.stringify(r.state.output??r.state,null,2))}</pre>`;}else{html='<div class="console-empty"><span class="empty-symbol">◌</span><div><strong>Your workflow is running.</strong>Follow each step in the execution trace.</div></div>';}$('#console-content').innerHTML=html;if($('#run-input'))$('#run-input').oninput=e=>{S.inputText=e.target.value;try{const obj=JSON.parse(S.inputText);if(obj&&typeof obj==='object'&&!Array.isArray(obj)){S.workflow.inputs=obj;changed();}}catch{}};if($('#resume-button'))$('#resume-button').onclick=async()=>{try{const response=JSON.parse($('#approval-response').value);await api(`/api/runs/${r.id}/resume`,{response});S.run.status='queued';renderConsole();pollRun(r.id);}catch(error){toast(error.message);}};if($('#recover-button'))$('#recover-button').onclick=async()=>{try{await api(`/api/runs/${r.id}/recover`,{});S.run.status='queued';S.run.error='';renderConsole();pollRun(r.id);}catch(error){toast(error.message);}};}
|
|
94
|
-
async function pollRun(id){clearTimeout(S.poll);try{const run=await api('/api/runs/'+id);if(S.run
|
|
95
|
+
async function pollRun(id){clearTimeout(S.poll);try{const run=await api('/api/runs/'+id);if(!S.run||S.run.id!==id)return;S.run=run;S.nodeStatus={};for(const e of run.events){if(e.type==='node.started')S.nodeStatus[e.node]='running';if(e.type==='node.completed')S.nodeStatus[e.node]='completed';if(e.type==='node.failed')S.nodeStatus[e.node]='failed';if(e.type==='run.interrupted')S.nodeStatus[e.node]='suspended';}if(['failed','cancelled'].includes(run.status)){for(const [key,value]of Object.entries(S.nodeStatus))if(value==='running')S.nodeStatus[key]='failed';}if(run.status==='suspended'&&S.tab==='trace')S.tab='output';renderNodes();if(S.tab!=='input')renderConsole();else{const active=['queued','running','cancelling'].includes(run.status);$('#run-button').disabled=active;$('#run-status').textContent=run.status;$('#stop-button').hidden=!active;}if(['queued','running','cancelling'].includes(run.status))S.poll=setTimeout(()=>pollRun(id),250);else updateHistoryCount();}catch(error){toast(error.message);}}
|
|
95
96
|
async function startRun(){try{const inputs=JSON.parse(S.inputText);const data=await api('/api/runs',{workflow:clone(S.workflow),inputs,max_steps:100});S.run={id:data.id,status:'queued',events:[],state:{},duration_ms:0};S.nodeStatus={};S.tab='output';renderConsole();pollRun(data.id);}catch(error){showModal('Cannot run this workflow',`<div class="error-box">${esc(error.message)}</div>`);}}
|
|
96
97
|
function showModal(title,html){$('#modal-title').textContent=title;$('#modal-content').innerHTML=html;if(!$('#modal').open)$('#modal').showModal();}function closeModal(){$('#modal').close();}
|
|
97
98
|
function environmentRow(name,status={},custom=false){return `<div class="environment-row ${custom?'custom':''}" data-env-row><label><span>${custom?'<input data-env-name value="'+esc(name)+'" placeholder="VARIABLE_NAME" aria-label="Environment variable name">':'<strong>'+esc(name)+'</strong>'}<small>${status.stored?'Saved in local .env':status.configured?'Available from Studio environment':'Not set'}</small></span><input type="password" data-env-value="${esc(name)}" autocomplete="new-password" placeholder="${status.stored?'Saved · leave empty to keep':status.configured?'Already available · enter to override':'Enter value'}" aria-label="Value for ${esc(name)}"></label><label class="env-remove"><input type="checkbox" data-env-remove="${esc(name)}"> Remove</label></div>`;}
|
|
98
|
-
async function openIntegrations(){try{const data=await api('/api/settings/environment');const status=new Map(data.variables.map(v=>[v.name,v]));let html='<p class="modal-copy">Configure provider credentials and other <code>.env</code> variables here. Studio saves them in its local data folder and loads them before model initialization. Secret values are write-only in this UI.</p><div class="env-security">Local file: <code>studio/data/.env</code> · file permissions are restricted where the operating system supports it.</div><div class="inspector-section">PROVIDER VARIABLES</div>';const names=[...new Set(S.bootstrap.providers.flatMap(p=>p.env||[]))];if(!names.length)html+='<p class="field-help">Provider integrations do not declare API key variables.</p>';for(const name of names)html+=environmentRow(name,status.get(name));html+='<div class="inspector-section">CUSTOM .ENV VARIABLES</div><div id="custom-env-rows">';for(const v of data.variables.filter(v=>!names.includes(v.name)))html+=environmentRow(v.name,v,true);html+='</div><button class="button small" id="add-env-variable">+ Add variable</button><div class="modal-actions"><button class="button" id="cancel-env">Cancel</button><button class="button primary" id="save-env">Save integrations</button></div><p class="field-help">Changes take effect immediately for new model calls. Shell-defined variables are available too. Avoid putting secrets in workflow JSON.</p>';showModal('Model integrations
|
|
99
|
-
|
|
99
|
+
async function openIntegrations(){try{const [data,modelData]=await Promise.all([api('/api/settings/environment'),api('/api/settings/models')]);S.models=modelData.models;const status=new Map(data.variables.map(v=>[v.name,v]));let html='<p class="modal-copy">Configure provider credentials and other <code>.env</code> variables here. Studio saves them in its local data folder and loads them before model initialization. Secret values are write-only in this UI.</p><div class="env-security">Local file: <code>studio/data/.env</code> · file permissions are restricted where the operating system supports it.</div><div class="inspector-section">NAMED MODELS</div>';html+='<p class="modal-copy">Save models as variables, then put <code>$model1</code> in a Chat Model or Agent node’s <code>model</code> field in workflow JSON. Each node can use a different variable.</p><div id="model-rows">';for(const [name,id] of Object.entries(S.models))html+=modelRow(name,id);html+='</div><button class="button small" id="add-model">+ Add model</button>';html+='<div class="inspector-section">PROVIDER VARIABLES</div>';const names=[...new Set(S.bootstrap.providers.flatMap(p=>p.env||[]))];if(!names.length)html+='<p class="field-help">Provider integrations do not declare API key variables.</p>';for(const name of names)html+=environmentRow(name,status.get(name));html+='<div class="inspector-section">CUSTOM .ENV VARIABLES</div><div id="custom-env-rows">';for(const v of data.variables.filter(v=>!names.includes(v.name)))html+=environmentRow(v.name,v,true);html+='</div><button class="button small" id="add-env-variable">+ Add variable</button><div class="modal-actions"><button class="button" id="cancel-env">Cancel</button><button class="button primary" id="save-env">Save integrations</button></div><p class="field-help">Changes take effect immediately for new model calls. Shell-defined variables are available too. Avoid putting secrets in workflow JSON.</p>';showModal('Model integrations · multiple models',html);$('#cancel-env').onclick=closeModal;$('#add-model').onclick=()=>{$('#model-rows').insertAdjacentHTML('beforeend',modelRow('',''));$('#model-rows [data-model-row]:last-child [data-model-name]')?.focus();};$('#model-rows').onclick=e=>{if(e.target.matches('[data-model-remove]'))e.target.closest('[data-model-row]').remove();};$('#add-env-variable').onclick=()=>{$('#custom-env-rows').insertAdjacentHTML('beforeend',environmentRow('',{},true));document.querySelector('#custom-env-rows [data-env-row]:last-child [data-env-name]')?.focus();};$('#save-env').onclick=saveEnvironment;}catch(error){toast(error.message);}}
|
|
100
|
+
function modelRow(name,id){return `<div class="model-row" data-model-row><input data-model-name value="${esc(name)}" placeholder="model1" aria-label="Model variable name"><span>→</span><input data-model-id value="${esc(id)}" placeholder="openai/gpt-4o-mini" aria-label="OpenRouter model ID"><button type="button" data-model-remove aria-label="Remove model">×</button></div>`;}
|
|
101
|
+
async function saveEnvironment(){const models={};for(const row of document.querySelectorAll('[data-model-row]')){const name=row.querySelector('[data-model-name]').value.trim(),id=row.querySelector('[data-model-id]').value.trim();if(!name&&!id)continue;if(!name||!id){toast('Each model needs a name and OpenRouter ID.');return;}if(Object.prototype.hasOwnProperty.call(models,name)){toast('Model variable names must be unique.');return;}models[name]=id;}const values={},remove=[];for(const row of document.querySelectorAll('[data-env-row]')){const nameField=row.querySelector('[data-env-name]'),secret=row.querySelector('[data-env-value]'),removeBox=row.querySelector('[data-env-remove]');const name=(nameField?nameField.value:secret.dataset.envValue).trim();if(!name)continue;if(removeBox?.checked){remove.push(name);continue;}if(secret.value)values[name]=secret.value;}try{const result=await api('/api/settings/models',{models});S.models=result.models;await api('/api/settings/environment',{values,remove});closeModal();renderNodes();renderInspector();toast('Models and environment saved locally.');}catch(error){toast(error.message);}}
|
|
100
102
|
async function save(){try{await api('/api/workflows',{workflow:S.workflow});S.dirty=false;$('#save-status').textContent='Saved locally';toast('Workflow saved on this machine.');}catch(error){toast(error.message);}}
|
|
101
103
|
function showJSON(){showModal('Your workflow, as a document',`<p class="modal-copy">Export a portable JSON workflow, or edit and apply it here.</p><textarea id="workflow-json" class="modal-code" spellcheck="false">${esc(JSON.stringify(S.workflow,null,2))}</textarea><div class="modal-actions"><button class="button" id="import-json">Import file</button><button class="button" id="export-json">Export JSON</button><button class="button primary" id="apply-json">Apply changes</button></div>`);$('#import-json').onclick=()=>$('#import-file').click();$('#export-json').onclick=()=>{const blob=new Blob([$('#workflow-json').value],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=(S.workflow.id||'workflow')+'.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};$('#apply-json').onclick=()=>{try{const value=JSON.parse($('#workflow-json').value);checkDocument(value);mark();S.workflow=value;S.inputText=JSON.stringify(value.inputs||{},null,2);S.selected=value.nodes[0]?.id;changed();closeModal();renderAll();fit();}catch(error){toast(error.message);}};}
|
|
102
104
|
function checkDocument(w){if(!w||w.format!=='graphcore.studio.v1'||!Array.isArray(w.nodes)||!Array.isArray(w.edges)||w.nodes.length>100||w.edges.length>300)throw Error('Unsupported workflow document');const ids=new Set();for(const n of w.nodes){if(!n||!Object.prototype.hasOwnProperty.call(META,n.type)||typeof n.id!=='string'||!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(n.id)||ids.has(n.id)||!Number.isFinite(n.x)||!Number.isFinite(n.y)||!n.config||typeof n.config!=='object'||Array.isArray(n.config))throw Error('Invalid node definition');if(n.type==='schema'&&(!Array.isArray(n.config.fields)||n.config.fields.some(f=>!f||typeof f.name!=='string'||typeof f.type!=='string')))throw Error('Invalid schema fields');ids.add(n.id);}for(const e of w.edges)if(!e||!ids.has(e.source)||!ids.has(e.target))throw Error('Connection references a missing node');if(typeof w.name!=='string'||typeof w.id!=='string')throw Error('Workflow name and ID are required');}
|
|
103
105
|
async function openSaved(){try{const items=await api('/api/workflows');showModal('Open a workflow',items.length?items.map((w,i)=>`<button class="saved-row" data-saved="${i}"><strong>${esc(w.name)}</strong><small>${w.nodes?.length||0} nodes →</small></button>`).join(''):'<p class="modal-copy">No saved workflows yet. Save your draft, or start from a template.</p>');document.querySelectorAll('[data-saved]').forEach(b=>b.onclick=()=>{const w=items[+b.dataset.saved];try{checkDocument(w);closeModal();confirmReplace(()=>load(w));}catch(error){toast(error.message);}});}catch(error){toast(error.message);}}
|
|
104
106
|
async function updateHistoryCount(){try{$('#run-count').textContent=(await api('/api/runs')).length;}catch{}}
|
|
105
107
|
async function history(){try{const runs=await api('/api/runs');showModal('Run history',runs.length?runs.map(r=>`<button class="saved-row" data-run="${esc(r.id)}"><span><strong>${esc(r.name)}</strong><br><small>${esc(new Date(r.created).toLocaleString())} · ${(r.duration_ms/1000).toFixed(2)}s</small></span><span class="run-badge ${esc(r.status)}">${esc(r.status)}</span></button>`).join(''):'<p class="modal-copy">Your runs will appear here, including approvals waiting for a response.</p>');document.querySelectorAll('[data-run]').forEach(b=>b.onclick=async()=>{try{const r=await api('/api/runs/'+b.dataset.run);closeModal();confirmReplace(()=>{load(r.workflow);S.run=r;S.tab='output';pollRun(r.id);});}catch(error){toast(error.message);}});}catch(error){toast(error.message);}}
|
|
106
|
-
function help(){showModal('A visual workspace for local agents',`<div class="modal-copy"><p><strong>Build:</strong> drag nodes from the library or click to add one. Drag a node to arrange it.</p><p><strong>Connect:</strong> click an output dot, then an input dot. You can also choose targets in Node settings.</p><p><strong>Configure:</strong> select a node, set its prompts, tools, schema, or condition. Use <code>{{field}}</code> to reference state.</p><p><strong>Run:</strong> edit the Input JSON and run. Inspect Output, State, or the native execution trace.</p><p><strong>Models:</strong>
|
|
107
|
-
async function boot(){try{S.bootstrap=await api('/api/bootstrap');$('#engine-status').innerHTML=`<i></i> ${S.bootstrap.native?'C++ engine connected':'Engine needs a build'}`;if(!S.bootstrap.native)toast(S.bootstrap.native_error);let workflow=S.bootstrap.templates[0];try{const draft=JSON.parse(localStorage.getItem('graphcore.studio.draft'));if(draft){checkDocument(draft);workflow=draft;}}catch{}load(workflow);updateHistoryCount();}catch(error){$('#engine-status').textContent='Server unavailable';toast(error.message);}}
|
|
108
|
-
$('#node-search').oninput=renderLibrary;document.querySelectorAll('[data-library]').forEach(b=>b.onclick=()=>{S.library=b.dataset.library;renderLibrary();});document.querySelectorAll('[data-console]').forEach(b=>b.onclick=()=>{S.tab=b.dataset.console;renderConsole();});$('#workflow-name').onchange=e=>{mark();S.workflow.name=e.target.value||'Untitled workflow';$('#breadcrumb').textContent=S.workflow.name;changed();};$('#settings-toggle').onclick=()=>document.querySelector('.inspector').classList.toggle('mobile-open');$('#close-inspector').onclick=()=>document.querySelector('.inspector').classList.remove('mobile-open');$('#save-button').onclick=save;$('#open-button').onclick=openSaved;$('#run-button').onclick=startRun;$('#history-button').onclick=history;$('#integrations-button').onclick=openIntegrations;$('#builder-button').onclick=()=>$('#canvas-area').focus();$('#help-button').onclick=help;$('#add-note-button').onclick=help;$('#json-button').onclick=showJSON;$('#modal-close').onclick=closeModal;$('#validate-button').onclick=async()=>{try{const data=await api('/api/validate',{workflow:S.workflow});showModal(data.errors.length?'A few connections need attention':'Your workflow is ready',data.errors.length?`<div class="error-box">${esc(data.errors.join('\n'))}</div>`:'<div class="success-box">All nodes and routes are valid.<br>Run the workflow to test its behavior with your input.</div>');}catch(error){toast(error.message);}};$('#stop-button').onclick=async()=>{try{await api(`/api/runs/${S.run.id}/cancel`,{});toast('Stop requested. Active callbacks must finish before execution stops.');pollRun(S.run.id);}catch(error){toast(error.message);}};$('#zoom-in').onclick=()=>zoom(1.15);$('#zoom-out').onclick=()=>zoom(1/1.15);$('#fit-button').onclick=fit;$('#undo-button').onclick=()=>{if(!S.undo.length)return;S.redo.push(clone(S.workflow));S.workflow=S.undo.pop();changed();renderAll();};$('#redo-button').onclick=()=>{if(!S.redo.length)return;S.undo.push(clone(S.workflow));S.workflow=S.redo.pop();changed();renderAll();};$('#import-file').onchange=async e=>{const file=e.target.files[0];if(!file)return;try{if(file.size>2*1024*1024)throw Error('Workflow file exceeds 2 MiB');const value=JSON.parse(await file.text());checkDocument(value);closeModal();confirmReplace(()=>{load(value);changed();});}catch(error){toast(error.message);}e.target.value='';};const canvas=$('#canvas-area');canvas.addEventListener('dragover',e=>{e.preventDefault();e.dataTransfer.dropEffect='copy';});canvas.addEventListener('drop',e=>{e.preventDefault();const kind=e.dataTransfer.getData('application/graphcore-node');if(META[kind]){const p=worldPoint(e);addNode(kind,p.x-113,p.y-54);}});canvas.addEventListener('pointerdown',e=>{if(e.button!==0||e.target.closest('.canvas-node,.canvas-controls'))return;drag={type:'pan',x:e.clientX,y:e.clientY,vx:S.view.x,vy:S.view.y};});canvas.addEventListener('wheel',e=>{e.preventDefault();if(e.ctrlKey||e.metaKey){const rect=canvas.getBoundingClientRect();zoom(Math.exp(-e.deltaY*.005),e.clientX-rect.left,e.clientY-rect.top);}else{S.view.x-=e.deltaX;S.view.y-=e.deltaY;applyView();}},{passive:false});document.addEventListener('keydown',e=>{if(e.key==='Escape'){S.connection=null;drawEdges();$('#canvas-hint').textContent='Drag to arrange · connect the dots to build a flow';}if(e.target.matches('input,textarea,select')||$('#modal').open)return;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='z'){e.preventDefault();(e.shiftKey?$('#redo-button'):$('#undo-button')).click();}if(e.key==='Delete'||e.key==='Backspace'){e.preventDefault();deleteSelected();}if(e.key==='/'){e.preventDefault();$('#node-search').focus();}});window.addEventListener('resize',()=>drawEdges());boot();
|
|
108
|
+
function help(){showModal('A visual workspace for local agents',`<div class="modal-copy"><p><strong>Build:</strong> drag nodes from the library or click to add one. Drag a node to arrange it.</p><p><strong>Connect:</strong> click an output dot, then an input dot. You can also choose targets in Node settings.</p><p><strong>Configure:</strong> select a node, set its prompts, tools, schema, or condition. Use <code>{{field}}</code> to reference state.</p><p><strong>Run:</strong> edit the Input JSON and run. Inspect Output, State, or the native execution trace.</p><p><strong>Models:</strong> add named model variables in Model integrations, then select $model1 in each Chat Model or Agent node. Configure OPENROUTER_API_KEY there too. The offline fixture makes no AI request.</p><p><strong>Shortcuts:</strong> <kbd>Ctrl/Cmd Z</kbd> undo, <kbd>Delete</kbd> remove selected node, <kbd>Escape</kbd> cancel connection. Scroll to pan; Ctrl/Cmd + scroll to zoom.</p><p>Scheduling, checkpoints, and equality conditions run in C++. Model and tool integrations run in Python. Agent handoffs are sequential in this release.</p></div>`);}
|
|
109
|
+
async function boot(){try{S.bootstrap=await api('/api/bootstrap');S.models=(await api('/api/settings/models')).models;$('#engine-status').innerHTML=`<i></i> ${S.bootstrap.native?'C++ engine connected':'Engine needs a build'}`;if(!S.bootstrap.native)toast(S.bootstrap.native_error);let workflow=S.bootstrap.templates[0];try{const draft=JSON.parse(localStorage.getItem('graphcore.studio.draft'));if(draft){checkDocument(draft);workflow=draft;}}catch{}load(workflow);updateHistoryCount();}catch(error){$('#engine-status').textContent='Server unavailable';toast(error.message);}}
|
|
110
|
+
$('#node-search').oninput=renderLibrary;document.querySelectorAll('[data-library]').forEach(b=>b.onclick=()=>{S.library=b.dataset.library;renderLibrary();});document.querySelectorAll('[data-console]').forEach(b=>b.onclick=()=>{S.tab=b.dataset.console;renderConsole();});$('#workflow-name').onchange=e=>{mark();S.workflow.name=e.target.value||'Untitled workflow';$('#breadcrumb').textContent=S.workflow.name;changed();};$('#settings-toggle').onclick=()=>document.querySelector('.inspector').classList.toggle('mobile-open');$('#close-inspector').onclick=()=>document.querySelector('.inspector').classList.remove('mobile-open');$('#save-button').onclick=save;$('#clear-button').onclick=()=>{showModal('Clear this workflow?',`<p class="modal-copy">Remove all nodes and connections from the current canvas? You can undo this action.</p><div class="modal-actions"><button class="button" id="cancel-clear">Keep workflow</button><button class="button danger" id="confirm-clear">Clear workflow</button></div>`);$('#cancel-clear').onclick=closeModal;$('#confirm-clear').onclick=()=>{mark();S.workflow.nodes=[];S.workflow.edges=[];S.selected=null;S.connection=null;S.nodeStatus={};S.run=null;if(S.poll){clearTimeout(S.poll);S.poll=null;}changed();renderAll();closeModal();fit();};};$('#open-button').onclick=openSaved;$('#run-button').onclick=startRun;$('#history-button').onclick=history;$('#integrations-button').onclick=openIntegrations;$('#builder-button').onclick=()=>$('#canvas-area').focus();$('#help-button').onclick=help;$('#add-note-button').onclick=help;$('#json-button').onclick=showJSON;$('#modal-close').onclick=closeModal;$('#validate-button').onclick=async()=>{try{const data=await api('/api/validate',{workflow:S.workflow});showModal(data.errors.length?'A few connections need attention':'Your workflow is ready',data.errors.length?`<div class="error-box">${esc(data.errors.join('\n'))}</div>`:'<div class="success-box">All nodes and routes are valid.<br>Run the workflow to test its behavior with your input.</div>');}catch(error){toast(error.message);}};$('#stop-button').onclick=async()=>{try{await api(`/api/runs/${S.run.id}/cancel`,{});toast('Stop requested. Active callbacks must finish before execution stops.');pollRun(S.run.id);}catch(error){toast(error.message);}};$('#zoom-in').onclick=()=>zoom(1.15);$('#zoom-out').onclick=()=>zoom(1/1.15);$('#fit-button').onclick=fit;$('#undo-button').onclick=()=>{if(!S.undo.length)return;S.redo.push(clone(S.workflow));S.workflow=S.undo.pop();changed();renderAll();};$('#redo-button').onclick=()=>{if(!S.redo.length)return;S.undo.push(clone(S.workflow));S.workflow=S.redo.pop();changed();renderAll();};$('#import-file').onchange=async e=>{const file=e.target.files[0];if(!file)return;try{if(file.size>2*1024*1024)throw Error('Workflow file exceeds 2 MiB');const value=JSON.parse(await file.text());checkDocument(value);closeModal();confirmReplace(()=>{load(value);changed();});}catch(error){toast(error.message);}e.target.value='';};const canvas=$('#canvas-area');canvas.addEventListener('dragover',e=>{e.preventDefault();e.dataTransfer.dropEffect='copy';});canvas.addEventListener('drop',e=>{e.preventDefault();const kind=e.dataTransfer.getData('application/graphcore-node');if(META[kind]){const p=worldPoint(e);addNode(kind,p.x-113,p.y-54);}});canvas.addEventListener('pointerdown',e=>{if(e.button!==0||e.target.closest('.canvas-node,.canvas-controls'))return;drag={type:'pan',x:e.clientX,y:e.clientY,vx:S.view.x,vy:S.view.y};});canvas.addEventListener('wheel',e=>{e.preventDefault();if(e.ctrlKey||e.metaKey){const rect=canvas.getBoundingClientRect();zoom(Math.exp(-e.deltaY*.005),e.clientX-rect.left,e.clientY-rect.top);}else{S.view.x-=e.deltaX;S.view.y-=e.deltaY;applyView();}},{passive:false});document.addEventListener('keydown',e=>{if(e.key==='Escape'){S.connection=null;drawEdges();$('#canvas-hint').textContent='Drag to arrange · connect the dots to build a flow';}if(e.target.matches('input,textarea,select')||$('#modal').open)return;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='z'){e.preventDefault();(e.shiftKey?$('#redo-button'):$('#undo-button')).click();}if(e.key==='Delete'||e.key==='Backspace'){e.preventDefault();deleteSelected();}if(e.key==='/'){e.preventDefault();$('#node-search').focus();}});window.addEventListener('resize',()=>drawEdges());boot();
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
<main class="main">
|
|
23
23
|
<section class="workflow-header">
|
|
24
24
|
<div><div class="workflow-title"><input id="workflow-name" aria-label="Workflow name" value="Research & review"><span class="version-tag">DRAFT</span></div><p id="workflow-description">Build a team of agents. Connect the way they think.</p></div>
|
|
25
|
-
<div class="actions"><button id="open-button" class="button quiet" title="Open a saved workflow">Open</button><button id="save-button" class="button">Save</button><button id="run-button" class="button primary"><span>▶</span> Run workflow</button></div>
|
|
25
|
+
<div class="actions"><button id="open-button" class="button quiet" title="Open a saved workflow">Open</button><button id="clear-button" class="button" title="Clear the canvas">Clear</button><button id="save-button" class="button">Save</button><button id="run-button" class="button primary"><span>▶</span> Run workflow</button></div>
|
|
26
26
|
</section>
|
|
27
27
|
<div class="canvas-toolbar"><div class="toolbar-tabs"><button class="active" id="canvas-tab">◇ Canvas</button><button id="json-button">{ } Workflow JSON</button></div><div class="toolbar-right"><span id="save-status">Local draft</span><span class="divider"></span><button id="undo-button" class="icon-button" aria-label="Undo" title="Undo (Ctrl/Cmd+Z)">↶</button><button id="redo-button" class="icon-button" aria-label="Redo">↷</button><button id="settings-toggle" class="button small">Settings</button><button id="validate-button" class="button small">✓ Validate</button></div></div>
|
|
28
28
|
<div class="canvas-area" id="canvas-area" tabindex="0" aria-label="Workflow canvas">
|
|
@@ -7,3 +7,26 @@
|
|
|
7
7
|
.field>span,.field input,.field select,.field textarea{color:#675773}.field small,.field-help{color:#8b7b98}.inspector-note{color:#897298}.node-head small,.node-foot{color:#8d7c9c}.palette-node small{color:#8b8495}.workflow-header p{color:#8b8295}.input-hint{color:#8a7b97}.console-tabs button{color:#897795}.canvas-hint{color:#9685a6}.toolbar-tabs button{color:#8e7e9d}
|
|
8
8
|
.message-card{border:1px solid #ebe4f1;border-radius:7px;background:#fdfbff;padding:9px;margin:9px 0}.message-card header,.message-tools{display:flex;align-items:center;gap:8px}.message-card header{justify-content:space-between;margin-bottom:8px}.message-card header select,.message-tools select{border:1px solid #e5dbea;border-radius:4px;background:white;color:#675773;font-size:10px;padding:5px 7px}.message-remove{border:0;background:transparent;color:#a78bac;font-size:17px}.message-remove:disabled{opacity:.35;cursor:not-allowed}.message-content{display:block;width:100%;min-height:68px;resize:vertical;border:1px solid #e7dfee;border-radius:5px;padding:8px;font:10px/1.6 var(--mono);color:#675773;background:white}.message-tools{margin-top:7px;justify-content:space-between}.message-tools select{max-width:100%;min-width:0;flex:1}.message-tools small{font-size:8px;color:#91849a;white-space:nowrap}
|
|
9
9
|
.environment-row{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid #f0ebf4}.environment-row>label:first-child{flex:1;min-width:0;display:grid;grid-template-columns:minmax(120px,1fr) minmax(160px,1.2fr);align-items:center;gap:10px}.environment-row strong{display:block;font:10px var(--mono);color:#675773;overflow-wrap:anywhere}.environment-row small{display:block;margin-top:4px;font-size:9px;color:#8b7b98}.environment-row input[type=password],.environment-row input[data-env-name]{min-width:0;width:100%;border:1px solid #e7dfee;border-radius:5px;padding:8px;font-size:10px;color:#675773}.environment-row.custom>label:first-child{grid-template-columns:minmax(120px,.8fr) minmax(160px,1.2fr)}.env-remove{display:flex;align-items:center;gap:4px;color:#917f9b;font-size:9px;white-space:nowrap}.env-security{padding:9px 11px;background:#f8f5fb;border-radius:6px;color:#867791;font-size:10px}.env-security code{font:9px var(--mono)}@media(max-width:600px){.environment-row>label:first-child,.environment-row.custom>label:first-child{grid-template-columns:1fr;gap:6px}.env-remove{font-size:8px}}
|
|
10
|
+
|
|
11
|
+
/* Named model registry and clearer interactive controls. */
|
|
12
|
+
.model-row{display:grid;grid-template-columns:minmax(90px,1fr) auto minmax(160px,2fr) 28px;align-items:center;gap:9px;margin:10px 0;padding:10px;background:#fbf9fe;border:1px solid #e8e0f1;border-radius:9px}
|
|
13
|
+
.model-row input{min-width:0;width:100%;padding:9px 10px;border:1px solid #ded5ea;border-radius:6px;background:#fff;color:#514263;font:11px var(--mono)}
|
|
14
|
+
.model-row input:focus{outline:2px solid #d4c5ec;border-color:#9b83c7}
|
|
15
|
+
.model-row span{color:#9985b4;font-size:13px}
|
|
16
|
+
.model-row button{height:28px;border:0;border-radius:5px;background:transparent;color:#a77f98;font-size:19px;cursor:pointer}
|
|
17
|
+
.model-row button:hover{background:#f8eaf0;color:#ad526e}
|
|
18
|
+
.button:focus-visible,.icon-button:focus-visible,.palette-node:focus-visible,.workspace-item:focus-visible{outline:2px solid #9b83c7;outline-offset:2px}
|
|
19
|
+
#clear-button{color:#886b83}
|
|
20
|
+
@media(max-width:650px){.model-row{grid-template-columns:minmax(70px,1fr) auto minmax(100px,1.4fr) 24px;gap:5px;padding:7px}.model-row input{font-size:9px;padding:7px 5px}}
|
|
21
|
+
|
|
22
|
+
/* Readable hierarchy on the builder, inspector, and model settings. */
|
|
23
|
+
.workspace-item,.palette-node strong,.button,.toolbar-tabs button,.console-tabs button{font-size:12px}
|
|
24
|
+
.palette-node small,.node-head small,.field small,.field-help,.inspector-note,.canvas-banner,.console-meta,.input-hint{font-size:10px;color:#746c82}
|
|
25
|
+
.node-head strong,.inspector-hero strong{font-size:13px;color:#393246}
|
|
26
|
+
.field>span,.inspector-heading strong{font-size:11px;color:#5c506d}
|
|
27
|
+
.field input,.field select,.field textarea{font-size:11px;color:#43374f;border-color:#d9d0e4}
|
|
28
|
+
.workflow-header{border-bottom:1px solid #f0ecf5}
|
|
29
|
+
.canvas-node{box-shadow:0 3px 12px #3524420d,0 12px 30px #35244208}
|
|
30
|
+
.canvas-node:hover{border-color:#c9b8e3;box-shadow:0 5px 15px #35244213,0 15px 32px #3524420b}
|
|
31
|
+
.button{transition:background-color .14s,border-color .14s,box-shadow .14s}
|
|
32
|
+
.button.primary{box-shadow:0 3px 10px #6552b733}
|
|
@@ -11,11 +11,35 @@ from unittest.mock import patch
|
|
|
11
11
|
ROOT = Path(__file__).resolve().parents[1]
|
|
12
12
|
sys.path.insert(0, str(ROOT))
|
|
13
13
|
sys.path.insert(0, str(ROOT / 'python'))
|
|
14
|
-
from studio.engine import validate, compile_workflow, structured, render, WorkflowError, pydantic_available, register_tool, TOOLS, chat_messages, invoke_model
|
|
14
|
+
from studio.engine import validate, compile_workflow, structured, render, WorkflowError, pydantic_available, register_tool, TOOLS, chat_messages, invoke_model, resolve_model
|
|
15
15
|
from studio.server import Store
|
|
16
16
|
from graphcore import Graph, END, GraphCoreError
|
|
17
17
|
|
|
18
18
|
class StudioTests(unittest.TestCase):
|
|
19
|
+
def test_named_models_resolve_and_validate(self):
|
|
20
|
+
models = {'model1': 'deepseek/deepseek-v4-pro-0813', 'model2': 'openai/gpt-4o-mini'}
|
|
21
|
+
self.assertEqual(resolve_model({'model': '$model1'}, models), models['model1'])
|
|
22
|
+
self.assertEqual(resolve_model({'model': '$model2'}, models), models['model2'])
|
|
23
|
+
self.assertEqual(resolve_model({'model': 'google/gemini-2.5-flash'}, models), 'google/gemini-2.5-flash')
|
|
24
|
+
with self.assertRaisesRegex(WorkflowError, 'Unknown model variable'):
|
|
25
|
+
resolve_model({'model': '$missing'}, models)
|
|
26
|
+
|
|
27
|
+
def test_store_persists_named_models(self):
|
|
28
|
+
with tempfile.TemporaryDirectory() as path:
|
|
29
|
+
store = Store(path)
|
|
30
|
+
try:
|
|
31
|
+
self.assertEqual(store.update_models({'model1': 'deepseek/deepseek-v4-pro-0813'}),
|
|
32
|
+
{'model1': 'deepseek/deepseek-v4-pro-0813'})
|
|
33
|
+
with self.assertRaisesRegex(ValueError, 'simple identifiers'):
|
|
34
|
+
store.update_models({'bad-name': 'openai/gpt-4o-mini'})
|
|
35
|
+
finally:
|
|
36
|
+
store.close()
|
|
37
|
+
reopened = Store(path)
|
|
38
|
+
try:
|
|
39
|
+
self.assertEqual(reopened.models['model1'], 'deepseek/deepseek-v4-pro-0813')
|
|
40
|
+
finally:
|
|
41
|
+
reopened.close()
|
|
42
|
+
|
|
19
43
|
def flow(self, name='01-research.json'):
|
|
20
44
|
return json.loads((ROOT / 'studio/templates' / name).read_text())
|
|
21
45
|
|
|
@@ -122,6 +146,10 @@ class StudioTests(unittest.TestCase):
|
|
|
122
146
|
invoke_model({'provider':'openrouter','model':'deepseek/example','reasoning_effort':'low',
|
|
123
147
|
'messages':[{'role':'user','content':'hello'}]}, {})
|
|
124
148
|
self.assertEqual(seen['request']['reasoning_effort'],'low')
|
|
149
|
+
with patch.dict('sys.modules', {'openrouter':sdk}), patch.dict(os.environ, {'OPENROUTER_API_KEY':'test-secret'}):
|
|
150
|
+
invoke_model({'provider':'openrouter','model':'$model2',
|
|
151
|
+
'messages':[{'role':'user','content':'hello'}]}, {}, {'model2':'google/gemini-2.5-flash'})
|
|
152
|
+
self.assertEqual(seen['request']['model'],'google/gemini-2.5-flash')
|
|
125
153
|
|
|
126
154
|
def test_model_retries_empty_reasoning_response_and_reads_typed_text_blocks(self):
|
|
127
155
|
seen=[]
|
|
@@ -77,6 +77,18 @@ class HttpTests(unittest.TestCase):
|
|
|
77
77
|
self.assertEqual(caught.exception.code,400)
|
|
78
78
|
self.assertIn('error',json.loads(caught.exception.read()))
|
|
79
79
|
|
|
80
|
+
def test_named_model_settings_and_json_reference(self):
|
|
81
|
+
models={'model1':'deepseek/deepseek-v4-pro-0813','model2':'openai/gpt-4o-mini'}
|
|
82
|
+
self.assertEqual(self.post('/api/settings/models',{'models':models})['models'],models)
|
|
83
|
+
with self.request('/api/settings/models') as response:
|
|
84
|
+
self.assertEqual(json.loads(response.read())['models'],models)
|
|
85
|
+
workflow=json.loads((ROOT/'studio/templates/05-model.json').read_text())
|
|
86
|
+
workflow['nodes'][1]['config'].update(provider='openrouter',model='$model1')
|
|
87
|
+
self.assertEqual(self.post('/api/validate',{'workflow':workflow})['errors'],[])
|
|
88
|
+
workflow['nodes'][1]['config']['model']='$unknown'
|
|
89
|
+
self.assertTrue(any('Unknown model variable' in error for error in
|
|
90
|
+
self.post('/api/validate',{'workflow':workflow})['errors']))
|
|
91
|
+
|
|
80
92
|
def test_model_environment_settings_are_write_only_and_loaded(self):
|
|
81
93
|
name='GRAPHCORE_TEST_SECRET'
|
|
82
94
|
previous=os.environ.pop(name,None)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/examples/langgraph_deepseek_workflow_notebook.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|