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.
Files changed (44) hide show
  1. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/.github/workflows/ci.yml +1 -1
  2. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/.github/workflows/wheels.yml +1 -1
  3. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/CMakeLists.txt +1 -1
  4. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/PKG-INFO +16 -15
  5. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/README.md +10 -12
  6. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/docs/ARCHITECTURE.md +2 -2
  7. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/docs/SEMANTICS.md +2 -2
  8. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/pyproject.toml +8 -4
  9. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/python/graphcore/__init__.py +1 -1
  10. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/requirements-models.txt +1 -1
  11. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/engine.py +24 -10
  12. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/server.py +34 -7
  13. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/web/app.js +13 -11
  14. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/web/index.html +1 -1
  15. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/web/style.css +23 -0
  16. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/tests/test_studio.py +29 -1
  17. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/tests/test_studio_http.py +12 -0
  18. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/.gitignore +0 -0
  19. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/LICENSE +0 -0
  20. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/cmake/GraphCoreConfig.cmake.in +0 -0
  21. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/docs/PYPI_RELEASE.md +0 -0
  22. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/docs/ROADMAP.md +0 -0
  23. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/docs/VALIDATION.md +0 -0
  24. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/examples/approval.cpp +0 -0
  25. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/examples/complex_deepseek_workflow.json +0 -0
  26. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/examples/langgraph_deepseek_workflow_notebook.py +0 -0
  27. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/examples/python_agent.py +0 -0
  28. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/include/graphcore/c_api.h +0 -0
  29. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/include/graphcore/graphcore.hpp +0 -0
  30. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/requirements-pydantic.txt +0 -0
  31. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/run_studio.py +0 -0
  32. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/scripts/build.sh +0 -0
  33. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/src/c_api.cpp +0 -0
  34. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/src/graphcore.cpp +0 -0
  35. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/__init__.py +0 -0
  36. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/cli.py +0 -0
  37. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/example_plugin.py +0 -0
  38. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/templates/01-research.json +0 -0
  39. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/templates/02-structured.json +0 -0
  40. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/templates/03-tools.json +0 -0
  41. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/templates/04-team.json +0 -0
  42. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/studio/templates/05-model.json +0 -0
  43. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/tests/core_tests.cpp +0 -0
  44. {graphcore_studio-0.2.0 → graphcore_studio-0.2.2}/tests/test_python.py +0 -0
@@ -31,7 +31,7 @@ jobs:
31
31
  fail-fast: false
32
32
  matrix:
33
33
  os: [ubuntu-latest, macos-latest, windows-latest]
34
- python: ['3.9', '3.12']
34
+ python: ['3.9', '3.12', '3.14']
35
35
  runs-on: ${{ matrix.os }}
36
36
  steps:
37
37
  - uses: actions/checkout@v4
@@ -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.0 LANGUAGES CXX)
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.0
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 the port, data directory, or Python tool plugins.
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
- After the first release is published, install the prebuilt package with:
58
+ Install the prebuilt package and its OpenRouter/Pydantic runtime dependencies with:
56
59
 
57
60
  ```bash
58
- python -m pip install "graphcore-studio[models,pydantic]"
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 with no extra packages. To enable optional Pydantic v2 validation:
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
- Install the SDK into the Python environment used to run Studio:
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
- For a PyPI installation, use the `models` extra instead: `python -m pip install "graphcore-studio[models]"`.
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 the port, data directory, or Python tool plugins.
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
- After the first release is published, install the prebuilt package with:
29
+ Install the prebuilt package and its OpenRouter/Pydantic runtime dependencies with:
30
30
 
31
31
  ```bash
32
- python -m pip install "graphcore-studio[models,pydantic]"
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 with no extra packages. To enable optional Pydantic v2 validation:
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
- Install the SDK into the Python environment used to run Studio:
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
- For a PyPI installation, use the `models` extra instead: `python -m pip install "graphcore-studio[models]"`.
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 / optional Pydantic nodes
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 is optional and required only for live model calls; the offline fixture works with the base install. Pydantic remains optional. The canvas is served as local files and does not send workflow data to a remote service.
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 optional Pydantic validation. A Python callback crosses the C ABI and returns its update to the C++ scheduler before the next node runs.
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. Pydantic v2 is an optional backend, enabled only when installed in the Python environment that launches Studio. It adds Pydantic model validation to the same field list; custom Python validators are not configured through this UI yet.
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.0"
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]
@@ -16,7 +16,7 @@ from dataclasses import dataclass, field
16
16
  from typing import Optional
17
17
 
18
18
  END = "__end__"
19
- __version__ = "0.2.0"
19
+ __version__ = "0.2.2"
20
20
 
21
21
  class GraphCoreError(RuntimeError):
22
22
  pass
@@ -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 validate(workflow):
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
- if not isinstance(config.get("model"), str) or not config["model"].strip():
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":config["model"], "messages":chat_messages(config, state), "stream":False,
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
- emit("model.started", node=ident, provider=config.get("provider", "demo"), model=config.get("model", ""))
307
- result = {key: invoke_model(config, state)}
308
- emit("model.completed", node=ident, provider=config.get("provider", "demo"), model=config.get("model", ""))
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
- errors = validate(workflow)
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
- server = Server(("127.0.0.1", args.port), store)
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 &nbsp;↗&nbsp; 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||'demo'} · ${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.find(n=>['agent','model'].includes(n.type)&&(n.config?.provider||'demo')!=='demo');$('#mode-label').textContent=real?`${real.config.provider} model · Python integration`:'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();}
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('OpenRouter model ID','model',c.model||'openai/gpt-4o-mini',{help:'Use the model slug shown in the OpenRouter model catalog, for example openai/gpt-4o-mini.'});
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&&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
+ 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 & environment',html);$('#cancel-env').onclick=closeModal;$('#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);}}
99
- async function saveEnvironment(){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/environment',{values,remove});closeModal();toast('Environment saved locally. New model calls will use these values.');}catch(error){toast(error.message);}}
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> choose OpenRouter on a Chat Model or Agent node, enter an OpenRouter model ID, and configure OPENROUTER_API_KEY under Model integrations. 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>`);}
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)