From 206753869377bacc264346ccde68ba240e479169 Mon Sep 17 00:00:00 2001
From: seefs001
Date: Fri, 25 Sep 2026 21:06:23 +0800
Subject: [PATCH 1/2] feat: per model strategy
---
apps/desktop/bun.lock | 34 +-
apps/desktop/package.json | 2 +
apps/desktop/src-tauri/src/lib.rs | 14 +
apps/desktop/src-tauri/src/sidecar.rs | 40 +
apps/desktop/src/App.navigation.test.tsx | 90 +-
apps/desktop/src/RouteManager.test.tsx | 30 +
apps/desktop/src/RouteManager.tsx | 119 +-
apps/desktop/src/RoutingGraphEditor.tsx | 1561 +++++++++++++++++
apps/desktop/src/bridge.ts | 42 +
.../src/components/RecoveryDetails.tsx | 11 +
.../src/components/RoutingConditionEditor.tsx | 288 +++
.../src/components/RoutingGraphNode.tsx | 231 +++
.../src/components/RoutingGraphTrace.tsx | 71 +
apps/desktop/src/i18n/locales/en.json | 198 +++
apps/desktop/src/i18n/locales/zh-CN.json | 198 +++
apps/desktop/src/request-record-model.ts | 19 +
apps/desktop/src/routing-graph-model.test.ts | 140 ++
apps/desktop/src/routing-graph-model.ts | 474 +++++
apps/desktop/src/styles/globals.css | 2 +
apps/desktop/src/styles/routing-graph.css | 264 +++
apps/desktop/src/use-routing-graph.test.tsx | 115 ++
apps/desktop/src/use-routing-graph.ts | 192 ++
contracts/control-api.openapi.yaml | 268 +++
core/contract/request_recovery.go | 37 +-
core/contract/routing_graph.go | 391 +++++
core/internal/controlapi/handler.go | 2 +
core/internal/controlapi/routing_graph.go | 133 ++
.../internal/controlapi/routing_graph_test.go | 50 +
core/internal/endpoint/resolver.go | 1 +
core/internal/endpoint/routing_graph.go | 126 ++
core/internal/ingress/discovery.go | 16 +
core/internal/ingress/execution.go | 41 +
core/internal/ingress/handler.go | 9 +-
core/internal/ingress/recovery.go | 32 +-
core/internal/ingress/routing_graph.go | 335 ++++
core/internal/ingress/routing_graph_test.go | 260 +++
core/internal/routinggraph/run.go | 255 +++
core/internal/routinggraph/run_test.go | 40 +
core/internal/storage/migrate/defaults.go | 6 +
core/internal/storage/routing_graph.go | 17 +
core/internal/storage/sqlite/pricing.go | 13 +
core/internal/storage/sqlite/routing_graph.go | 174 ++
.../storage/sqlite/routing_graph_test.go | 57 +
43 files changed, 6372 insertions(+), 26 deletions(-)
create mode 100644 apps/desktop/src/RoutingGraphEditor.tsx
create mode 100644 apps/desktop/src/components/RoutingConditionEditor.tsx
create mode 100644 apps/desktop/src/components/RoutingGraphNode.tsx
create mode 100644 apps/desktop/src/components/RoutingGraphTrace.tsx
create mode 100644 apps/desktop/src/routing-graph-model.test.ts
create mode 100644 apps/desktop/src/routing-graph-model.ts
create mode 100644 apps/desktop/src/styles/routing-graph.css
create mode 100644 apps/desktop/src/use-routing-graph.test.tsx
create mode 100644 apps/desktop/src/use-routing-graph.ts
create mode 100644 core/contract/routing_graph.go
create mode 100644 core/internal/controlapi/routing_graph.go
create mode 100644 core/internal/controlapi/routing_graph_test.go
create mode 100644 core/internal/endpoint/routing_graph.go
create mode 100644 core/internal/ingress/routing_graph.go
create mode 100644 core/internal/ingress/routing_graph_test.go
create mode 100644 core/internal/routinggraph/run.go
create mode 100644 core/internal/routinggraph/run_test.go
create mode 100644 core/internal/storage/routing_graph.go
create mode 100644 core/internal/storage/sqlite/routing_graph.go
create mode 100644 core/internal/storage/sqlite/routing_graph_test.go
diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock
index 51a2ba0..cdc1522 100644
--- a/apps/desktop/bun.lock
+++ b/apps/desktop/bun.lock
@@ -10,8 +10,10 @@
"@lobehub/icons": "5.16.0",
"@tanstack/react-virtual": "^3.14.10",
"@tauri-apps/api": "2.11.1",
+ "@xyflow/react": "^12.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "elkjs": "^0.12.0",
"i18next": "26.4.0",
"motion": "13.4.0",
"radix-ui": "^1.6.7",
@@ -561,6 +563,8 @@
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
+ "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
+
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
@@ -569,12 +573,18 @@
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
+ "@types/d3-selection": ["@types/d3-selection@3.0.12", "", {}, "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw=="],
+
"@types/d3-shape": ["@types/d3-shape@3.2.0", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
+ "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="],
+
+ "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
+
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@@ -673,6 +683,10 @@
"@vitest/utils": ["@vitest/utils@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw=="],
+ "@xyflow/react": ["@xyflow/react@12.12.0", "", { "dependencies": { "@xyflow/system": "0.0.83", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ=="],
+
+ "@xyflow/system": ["@xyflow/system@0.0.83", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw=="],
+
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -717,6 +731,8 @@
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+ "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="],
+
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -741,6 +757,10 @@
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
+ "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
+
+ "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
+
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
@@ -751,6 +771,8 @@
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
+ "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
+
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
@@ -759,6 +781,10 @@
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
+ "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
+
+ "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
+
"dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -779,7 +805,7 @@
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
- "elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="],
+ "elkjs": ["elkjs@0.12.0", "", {}, "sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ=="],
"emoji-mart": ["emoji-mart@5.6.0", "", {}, "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow=="],
@@ -1421,7 +1447,7 @@
"yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
- "zustand": ["zustand@3.7.2", "", { "peerDependencies": { "react": ">=16.8" }, "optionalPeers": ["react"] }, "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA=="],
+ "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
@@ -1479,8 +1505,12 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "beautiful-mermaid/elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="],
+
"extend-shallow/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
+ "leva/zustand": ["zustand@3.7.2", "", { "peerDependencies": { "react": ">=16.8" }, "optionalPeers": ["react"] }, "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA=="],
+
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"micromark-extension-math/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 27a45cf..963895c 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -27,8 +27,10 @@
"@lobehub/icons": "5.16.0",
"@tanstack/react-virtual": "^3.14.10",
"@tauri-apps/api": "2.11.1",
+ "@xyflow/react": "^12.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "elkjs": "^0.12.0",
"i18next": "26.4.0",
"motion": "13.4.0",
"radix-ui": "^1.6.7",
diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs
index 9890604..065f403 100644
--- a/apps/desktop/src-tauri/src/lib.rs
+++ b/apps/desktop/src-tauri/src/lib.rs
@@ -1172,6 +1172,19 @@ async fn get_routing_settings(
manager.get_routing_settings().await
}
+#[tauri::command]
+async fn routing_graph(
+ operation: String,
+ input: Option,
+ etag: Option,
+ revision: Option,
+ manager: State<'_, Arc>,
+) -> Result {
+ manager
+ .routing_graph(&operation, input, etag, revision)
+ .await
+}
+
#[tauri::command]
async fn update_routing_settings(
patch: serde_json::Value,
@@ -1472,6 +1485,7 @@ pub fn run() {
purge_request_records,
get_request_audit_content,
get_routing_settings,
+ routing_graph,
update_routing_settings,
get_audit_settings,
update_audit_settings,
diff --git a/apps/desktop/src-tauri/src/sidecar.rs b/apps/desktop/src-tauri/src/sidecar.rs
index ab5bb7a..7ffbd0f 100644
--- a/apps/desktop/src-tauri/src/sidecar.rs
+++ b/apps/desktop/src-tauri/src/sidecar.rs
@@ -2074,6 +2074,46 @@ impl CoreManager {
Ok(value)
}
+ pub async fn routing_graph(
+ &self,
+ operation: &str,
+ input: Option,
+ etag: Option,
+ revision: Option,
+ ) -> Result {
+ let base = "/control/v1/routing-graph";
+ let (method, path) = match operation {
+ "get" => (Method::GET, base.to_string()),
+ "revision" => {
+ let revision = revision
+ .filter(|value| *value > 0)
+ .ok_or("revision is required")?;
+ (Method::GET, format!("{base}?revision={revision}"))
+ }
+ "save" | "preview" => {
+ let value = input
+ .as_ref()
+ .filter(|value| value.is_object())
+ .ok_or("graph input is required")?;
+ if serde_json::to_vec(value).map_err(|e| e.to_string())?.len() > 1_048_576 {
+ return Err("routing graph input exceeds 1 MiB".into());
+ }
+ if operation == "save" {
+ validate_strong_etag(etag.as_deref().ok_or("graph version is required")?)?;
+ (Method::PUT, base.to_string())
+ } else {
+ (Method::POST, format!("{base}/preview"))
+ }
+ }
+ _ => return Err("unknown routing graph operation".into()),
+ };
+ let (_, body) = self
+ .authenticated_control(method, &path, input, etag.as_deref())
+ .await?;
+ serde_json::from_slice(&body)
+ .map_err(|error| format!("invalid routing graph response: {error}"))
+ }
+
pub async fn update_routing_settings(
&self,
patch: serde_json::Value,
diff --git a/apps/desktop/src/App.navigation.test.tsx b/apps/desktop/src/App.navigation.test.tsx
index f4c141d..14b4c43 100644
--- a/apps/desktop/src/App.navigation.test.tsx
+++ b/apps/desktop/src/App.navigation.test.tsx
@@ -22,6 +22,8 @@ const bridgeMocks = vi.hoisted(() => ({
.mockRejectedValue(new Error("tray unavailable in tests")),
trayAction: vi.fn().mockRejectedValue(new Error("tray unavailable in tests")),
getRoutingSettings: vi.fn(),
+ getRoutingGraph: vi.fn(),
+ saveRoutingGraph: vi.fn(),
getServiceOrder: vi
.fn()
.mockResolvedValue({ service_ids: [], etag: '"order"' }),
@@ -207,6 +209,24 @@ describe("App workspace navigation", () => {
}
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
+ bridgeMocks.getRoutingGraph.mockResolvedValue({
+ draft: {
+ nodes: [
+ {
+ id: "entry_fixture",
+ kind: "entry",
+ enabled: true,
+ model: "fixture-route",
+ },
+ ],
+ edges: [],
+ },
+ active: { nodes: [], edges: [] },
+ layout: { entry_fixture: { x: 40, y: 40 } },
+ revision: 0,
+ etag: '"graph-fixture"',
+ history: [],
+ });
bridgeMocks.getRoutingSettings.mockResolvedValue({
default_failure_policy: defaultFailurePolicy(),
allow_unmatched_failover: false,
@@ -444,7 +464,9 @@ describe("App workspace navigation", () => {
},
];
for (const page of pages) {
- await act(async () => button(page.nav).click());
+ await act(async () => {
+ button(page.nav).click();
+ });
expect(
container.querySelector('[data-slot="workspace"]')?.textContent,
).toContain(page.content);
@@ -786,7 +808,7 @@ describe("App workspace navigation", () => {
expect(workspaceHeading().textContent).toBe("设置");
});
- it("opens default routing policy without retired routing tabs", async () => {
+ it("opens default settings and exposes the graph through an advanced entry", async () => {
await renderApp();
const serviceCalls = bridgeMocks.listServices.mock.calls.length;
const requestCalls = bridgeMocks.getUsageSummary.mock.calls.length;
@@ -799,6 +821,18 @@ describe("App workspace navigation", () => {
expect(
document.querySelector('[aria-current="page"]')?.textContent,
).toContain("路由");
+ expect(bridgeMocks.getRoutingGraph).not.toHaveBeenCalled();
+ const advanced = button("高级路由图");
+ expect(advanced.closest('[data-slot="page-header"]')).toBeNull();
+ expect(advanced.getAttribute("aria-describedby")).toBe(
+ "routing-advanced-description",
+ );
+ expect(
+ container.querySelector("#routing-advanced-description")?.textContent,
+ ).toContain("后备顺序与条件分支");
+ expect(
+ container.querySelector('[data-testid="routing-graph-workspace"]'),
+ ).toBeNull();
expect(workspaceHeading().textContent).toBe("路由");
expect(
container.querySelector('[data-testid="routing-defaults-panel"]'),
@@ -817,6 +851,58 @@ describe("App workspace navigation", () => {
expect(bridgeMocks.listRoutes).not.toHaveBeenCalled();
expect(bridgeMocks.listServices).toHaveBeenCalledTimes(serviceCalls);
expect(bridgeMocks.getUsageSummary).toHaveBeenCalledTimes(requestCalls);
+ await act(async () => {
+ button("高级路由图").click();
+ await import("./RoutingGraphEditor");
+ });
+ expect(workspaceHeading().textContent).toBe("高级路由图");
+ expect(
+ container.querySelector('[data-testid="routing-graph-workspace"]'),
+ ).not.toBeNull();
+ expect(
+ container.querySelector('[data-testid="routing-defaults-panel"]'),
+ ).toBeNull();
+ const back = button("返回路由设置");
+ expect(back.closest('[data-slot="page-header"]')).not.toBeNull();
+ expect(
+ container.querySelectorAll('button[aria-label="返回路由设置"]'),
+ ).toHaveLength(1);
+ expect(container.textContent).not.toContain("默认路由设置");
+ await act(async () => back.click());
+ expect(workspaceHeading().textContent).toBe("路由");
+ expect(
+ container.querySelector('[data-testid="routing-defaults-panel"]'),
+ ).not.toBeNull();
+ });
+
+ it("labels pending graph edits as unsaved instead of saved", async () => {
+ bridgeMocks.saveRoutingGraph.mockReturnValue(new Promise(() => {}));
+ await renderApp();
+ await act(async () => {
+ button("路由").click();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ button("高级路由图").click();
+ await import("./RoutingGraphEditor");
+ });
+ const status = () =>
+ container.querySelector(".routing-save-status")?.textContent;
+ expect(status()).toBe("草稿 · 尚未应用");
+ await act(async () => button("启用或禁用 fixture-route").click());
+ expect(status()).toBe("有未保存的修改");
+ expect(bridgeMocks.saveRoutingGraph).not.toHaveBeenCalled();
+ await act(async () => button("返回路由设置").click());
+ expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
+ await act(async () => button("继续编辑").click());
+ expect(workspaceHeading().textContent).toBe("高级路由图");
+ expect(status()).toBe("有未保存的修改");
+ await act(async () => button("返回路由设置").click());
+ await act(async () => button("放弃修改并离开").click());
+ expect(workspaceHeading().textContent).toBe("路由");
+ expect(
+ container.querySelector('[data-testid="routing-defaults-panel"]'),
+ ).not.toBeNull();
});
it("summarizes usage over the default yearly window", async () => {
diff --git a/apps/desktop/src/RouteManager.test.tsx b/apps/desktop/src/RouteManager.test.tsx
index 3161cd4..7e8d7e0 100644
--- a/apps/desktop/src/RouteManager.test.tsx
+++ b/apps/desktop/src/RouteManager.test.tsx
@@ -9,6 +9,11 @@ const bridge = vi.hoisted(() => ({
listRecoveryPaths: vi.fn(),
}));
vi.mock("./bridge", () => bridge);
+vi.mock("./RoutingGraphEditor", () => ({
+ RoutingGraphEditor: ({ onSettings }: { onSettings: () => void }) => (
+
+ ),
+}));
import { RouteManager } from "./RouteManager";
import { defaultFailurePolicy } from "./failure-policy-model";
@@ -49,6 +54,8 @@ it("shows default-setting tabs and clears dirty state on unmount under StrictMod
,
),
);
+ expect(container.textContent).toContain("高级路由图");
+ expect(container.textContent).not.toContain("Open routing defaults");
expect(
container.querySelector('[data-testid="routing-defaults-panel"]'),
).not.toBeNull();
@@ -102,6 +109,29 @@ it("shows default-setting tabs and clears dirty state on unmount under StrictMod
input.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(dirty).toHaveBeenLastCalledWith(true);
+ const click = (text: string) =>
+ [...document.querySelectorAll("button")]
+ .find(
+ (button) =>
+ button.textContent === text ||
+ button.getAttribute("aria-label") === text,
+ )!
+ .click();
+ await act(async () => click("高级路由图"));
+ expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
+ await act(async () => click("继续编辑"));
+ expect(input.value).toBe("4");
+ await act(async () => click("高级路由图"));
+ await act(async () => {
+ click("放弃修改并离开");
+ await import("./RoutingGraphEditor");
+ });
+ expect(container.textContent).toContain("Open routing defaults");
+ expect(dirty).toHaveBeenLastCalledWith(false);
+ await act(async () => click("Open routing defaults"));
+ expect(
+ container.querySelector('[data-testid="routing-defaults-panel"]'),
+ ).not.toBeNull();
} finally {
await act(async () => root.unmount());
container.remove();
diff --git a/apps/desktop/src/RouteManager.tsx b/apps/desktop/src/RouteManager.tsx
index f5fc79a..2bef34d 100644
--- a/apps/desktop/src/RouteManager.tsx
+++ b/apps/desktop/src/RouteManager.tsx
@@ -1,10 +1,23 @@
import { useT } from "./i18n";
+import { lazy, Suspense, useCallback, useRef, useState } from "react";
import { PageHeader } from "./PageHeader";
+import { ConfirmDialog } from "./components/ConfirmDialog";
+import { DataRow } from "./components/DataRow";
+import { Panel } from "./components/Panel";
+import { ChevronRight, Route } from "./components/icons";
+import { Button } from "./components/ui/button";
import { RoutingSettingsPanel } from "./RoutingSettingsPanel";
import type { RoutableService } from "./service-model";
import type { ProtocolDescriptor } from "./service-presets";
+const RoutingGraphEditor = lazy(() =>
+ import("./RoutingGraphEditor").then((module) => ({
+ default: module.RoutingGraphEditor,
+ })),
+);
+
export function RouteManager({
+ coreSessionKey,
services,
isReady,
onDirtyChange,
@@ -17,20 +30,108 @@ export function RouteManager({
protocols: ProtocolDescriptor[];
}) {
const t = useT();
+ const [settings, setSettings] = useState(true);
+ const [pendingSettings, setPendingSettings] = useState(null);
+ const dirty = useRef(false);
+ const reportDirty = useCallback(
+ (value: boolean) => {
+ dirty.current = value;
+ onDirtyChange(value);
+ },
+ [onDirtyChange],
+ );
+ function switchSettings(next: boolean) {
+ if (dirty.current) setPendingSettings(next);
+ else setSettings(next);
+ }
return (
-
-
+
+
+
+
+
+
+
+ >
+ ) : (
+
+ switchSettings(true),
+ }}
+ />
+
+ {t("common.loading")}
+
+ >
+ }
+ >
+ switchSettings(true)}
+ />
+
+ )}
+ {t("app.unsavedBody")}
}
+ onCancel={() => setPendingSettings(null)}
+ onConfirm={() => {
+ if (pendingSettings !== null) setSettings(pendingSettings);
+ setPendingSettings(null);
+ }}
+ open={pendingSettings !== null}
+ title={t("common.discardUnsaved")}
/>
);
diff --git a/apps/desktop/src/RoutingGraphEditor.tsx b/apps/desktop/src/RoutingGraphEditor.tsx
new file mode 100644
index 0000000..bbcc131
--- /dev/null
+++ b/apps/desktop/src/RoutingGraphEditor.tsx
@@ -0,0 +1,1561 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ ReactFlow,
+ Background,
+ BackgroundVariant,
+ Controls,
+ MiniMap,
+ ReactFlowProvider,
+ useReactFlow,
+ type Connection,
+ type Edge,
+ type NodeChange,
+ type OnConnectStartParams,
+} from "@xyflow/react";
+import {
+ getRequestRecord,
+ getRoutingGraphRevision,
+ previewRoutingGraph,
+} from "./bridge";
+import { useRoutingGraph } from "./use-routing-graph";
+import {
+ affectedEntries,
+ appendCall,
+ canConnect,
+ connectGraph,
+ disabledBypasses,
+ graphEdge,
+ graphID,
+ graphPort,
+ isolateNode,
+ linearCalls,
+ reachable,
+ removeGraphNode,
+ reorderCalls,
+ type GraphKind,
+ type GraphNode,
+ type GraphPreview,
+ type GraphStep,
+ type RoutingGraph,
+} from "./routing-graph-model";
+import {
+ RoutingGraphNode,
+ RoutingBypassEdge,
+ type FlowRoutingNode,
+} from "./components/RoutingGraphNode";
+import { RoutingGraphTrace } from "./components/RoutingGraphTrace";
+import {
+ GraphSelect,
+ RoutingConditionEditor,
+ defaultPredicate,
+} from "./components/RoutingConditionEditor";
+import { OrderedList } from "./components/OrderedList";
+import { Field } from "./components/Field";
+import { FormMessage } from "./components/FormMessage";
+import { EmptyState } from "./components/EmptyState";
+import { HelpPopover } from "./components/HelpPopover";
+import { ModelSelect } from "./components/ModelSelect";
+import { Panel } from "./components/Panel";
+import { Button } from "./components/ui/button";
+import { Input } from "./components/ui/input";
+import { Label } from "./components/ui/label";
+import { Switch } from "./components/ui/switch";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "./components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "./components/ui/dropdown-menu";
+import {
+ Plus,
+ Route,
+ SlidersHorizontal,
+ Settings,
+ RotateCcw,
+ Copy,
+ X,
+ Flask,
+ Maximize,
+ LoaderCircle,
+} from "./components/icons";
+import type { RoutableService } from "./service-model";
+import { useT } from "./i18n";
+import { PageHeader } from "./PageHeader";
+import { FailurePolicyEditor } from "./components/FailurePolicyEditor";
+import { defaultFailurePolicy } from "./failure-policy-model";
+
+const noSteps: GraphStep[] = [];
+const nodeTypes = { routing: RoutingGraphNode };
+const edgeTypes = { bypass: RoutingBypassEdge };
+interface Props {
+ ready: boolean;
+ services: RoutableService[];
+ onDirtyChange: (dirty: boolean) => void;
+ onSettings: () => void;
+}
+interface AddDraft {
+ kind: GraphKind;
+ after?: string;
+ port?: string;
+ publicModel: string;
+ service: string;
+ model: string;
+ x?: number;
+ y?: number;
+}
+
+export function RoutingGraphEditor(props: Props) {
+ return (
+
+
+
+ );
+}
+
+function GraphEditor({ ready, services, onDirtyChange, onSettings }: Props) {
+ const t = useT(),
+ flow = useReactFlow();
+ const state = useRoutingGraph(ready, onDirtyChange);
+ const [selected, setSelected] = useState(null);
+ const [focus, setFocus] = useState(null);
+ const [add, setAdd] = useState(null);
+ const [testOpen, setTestOpen] = useState(false);
+ const [testEntry, setTestEntry] = useState("");
+ const [protocol, setProtocol] = useState("openai.responses");
+ const [streaming, setStreaming] = useState(true);
+ const [hasTools, setHasTools] = useState(false);
+ const [hasImages, setHasImages] = useState(false);
+ const [outcomes, setOutcomes] = useState>({});
+ const [preview, setPreview] = useState(null);
+ const [testing, setTesting] = useState(false);
+ const [traceOpen, setTraceOpen] = useState(false);
+ const [requestID, setRequestID] = useState("");
+ const [requestDialog, setRequestDialog] = useState(false);
+ const [replay, setReplay] = useState<{
+ graph: RoutingGraph;
+ steps: GraphStep[];
+ revision: number;
+ } | null>(null);
+ const [layoutBusy, setLayoutBusy] = useState(false);
+ const connectStart = useRef(null);
+ const graph = replay?.graph ?? state.draft.graph;
+ const readonly = replay !== null || !ready;
+ const entries = graph.nodes.filter((node) => node.kind === "entry");
+ useEffect(() => {
+ if (focus === null && entries.length) setFocus(entries[0].id);
+ }, [focus, entries]);
+ const current = graph.nodes.find((node) => node.id === selected);
+ const visible = useMemo(
+ () =>
+ focus
+ ? reachable(graph, focus)
+ : new Set(graph.nodes.map((node) => node.id)),
+ [graph, focus],
+ );
+ const steps = replay?.steps ?? preview?.steps ?? noSteps;
+ const lastStep = useMemo(
+ () => new Map(steps.map((step) => [step.node_id, step])),
+ [steps],
+ );
+ const editGraph = useCallback(
+ (change: (graph: RoutingGraph) => RoutingGraph) => {
+ state.edit((draft) => ({ ...draft, graph: change(draft.graph) }));
+ setPreview(null);
+ },
+ [state.edit],
+ );
+ const patchNode = (id: string, patch: Partial) =>
+ editGraph((graph) => ({
+ ...graph,
+ nodes: graph.nodes.map((node) =>
+ node.id === id ? { ...node, ...patch } : node,
+ ),
+ }));
+ const toggle = useCallback(
+ (id: string) => {
+ if (!readonly)
+ editGraph((graph) => ({
+ ...graph,
+ nodes: graph.nodes.map((node) =>
+ node.id === id ? { ...node, enabled: !node.enabled } : node,
+ ),
+ }));
+ },
+ [editGraph, readonly],
+ );
+ const beginAdd = useCallback(
+ (
+ kind: GraphKind,
+ after?: string,
+ port?: string,
+ position?: { x: number; y: number },
+ ) => {
+ const service =
+ services.find((service) => service.enabled && service.models.length) ??
+ services[0];
+ setAdd({
+ kind,
+ after,
+ port,
+ publicModel: "",
+ service: service?.id ?? "",
+ model: service?.models[0] ?? "",
+ ...position,
+ });
+ },
+ [services],
+ );
+ const append = useCallback(
+ (id: string) => {
+ if (!readonly) beginAdd("call", id);
+ },
+ [beginAdd, readonly],
+ );
+ const nodeData = useMemo(() => {
+ const counts = new Map();
+ for (const entry of graph.nodes.filter((node) => node.kind === "entry"))
+ for (const id of reachable(graph, entry.id))
+ counts.set(id, (counts.get(id) ?? 0) + 1);
+ return new Map(
+ graph.nodes.map((node) => [
+ node.id,
+ {
+ node,
+ locked: readonly,
+ service: services.find((service) => service.id === node.service_id),
+ shared: counts.get(node.id) ?? 0,
+ step: lastStep.get(node.id),
+ toggle,
+ append,
+ },
+ ]),
+ );
+ }, [graph, services, lastStep, toggle, append, readonly]);
+ const nodes: FlowRoutingNode[] = useMemo(
+ () =>
+ graph.nodes
+ .filter((node) => visible.has(node.id))
+ .map((node, index) => ({
+ id: node.id,
+ type: "routing",
+ selected: node.id === selected,
+ position: state.draft.layout[node.id] ?? {
+ x: (index % 4) * 320 + 40,
+ y: Math.floor(index / 4) * 230 + 40,
+ },
+ data: nodeData.get(node.id)!,
+ })),
+ [graph, visible, selected, state.draft.layout, nodeData],
+ );
+ const edges: Edge[] = useMemo(() => {
+ const configured: Edge[] = graph.edges
+ .filter((edge) => visible.has(edge.source) && visible.has(edge.target))
+ .map((edge) => {
+ const source = graph.nodes.find((node) => node.id === edge.source),
+ target = graph.nodes.find((node) => node.id === edge.target);
+ const muted = source?.enabled === false || target?.enabled === false;
+ const active = steps.some(
+ (step) =>
+ step.node_id === edge.source &&
+ (step.port === edge.port ||
+ (edge.port === "failure" && step.status === "failed")),
+ );
+ const rule = source?.rules?.find((rule) => rule.id === edge.port);
+ return {
+ id: edge.id,
+ source: edge.source,
+ sourceHandle: edge.port,
+ target: edge.target,
+ targetHandle: "in",
+ type: "smoothstep",
+ animated: false,
+ label: edge.port === "failure" ? t("graph.failure") : rule?.label,
+ style: {
+ stroke: active
+ ? "var(--success-foreground)"
+ : muted
+ ? "var(--border)"
+ : "var(--muted-foreground)",
+ strokeWidth: active ? 2 : 1.3,
+ strokeDasharray: edge.port === "failure" ? "5 4" : undefined,
+ },
+ labelStyle: { fill: "var(--muted-foreground)", fontSize: 10 },
+ labelBgStyle: { fill: "var(--background)" },
+ };
+ });
+ return [
+ ...configured,
+ ...disabledBypasses(graph)
+ .filter((edge) => visible.has(edge.source) && visible.has(edge.target))
+ .map(
+ (edge): Edge => ({
+ id: `bypass-${edge.source}-${edge.target}`,
+ source: edge.source,
+ sourceHandle: edge.port,
+ target: edge.target,
+ targetHandle: "in",
+ type: "bypass",
+ selectable: false,
+ deletable: false,
+ data: { count: edge.skipped.length },
+ }),
+ ),
+ ];
+ }, [graph, visible, steps, t]);
+
+ const center = (id?: string) => {
+ void flow.fitView({
+ nodes: id ? [{ id }] : undefined,
+ padding: 0.22,
+ maxZoom: 1,
+ duration: 200,
+ });
+ };
+ const onConnect = (connection: Connection) => {
+ if (
+ !readonly &&
+ connection.source &&
+ connection.target &&
+ connection.sourceHandle
+ )
+ editGraph((graph) =>
+ connectGraph(
+ graph,
+ connection.source!,
+ connection.sourceHandle!,
+ connection.target!,
+ ),
+ );
+ };
+ const onNodesChange = (changes: NodeChange[]) => {
+ for (const change of changes)
+ if (change.type === "select") {
+ if (change.selected) setSelected(change.id);
+ else setSelected((current) => (current === change.id ? null : current));
+ }
+ if (readonly) return;
+ const positions = changes.filter(
+ (change) => change.type === "position" && change.position,
+ );
+ if (positions.length)
+ state.edit((draft) => {
+ const layout = { ...draft.layout };
+ for (const change of positions)
+ if (change.type === "position" && change.position)
+ layout[change.id] = change.position;
+ return { ...draft, layout };
+ }, false);
+ };
+ const createNode = () => {
+ if (!add) return;
+ const id = graphID();
+ const node: GraphNode = {
+ id,
+ kind: add.kind,
+ enabled: true,
+ ...(add.kind === "entry"
+ ? { model: add.publicModel.trim() }
+ : add.kind === "call"
+ ? { service_id: add.service, upstream_model: add.model.trim() }
+ : add.kind === "condition"
+ ? {
+ name: t("graph.newCondition"),
+ unknown_port: "otherwise",
+ rules: [
+ {
+ id: graphID(),
+ predicate: defaultPredicate(),
+ label: t("graph.matched"),
+ },
+ ],
+ }
+ : { name: t("graph.stop") }),
+ };
+ state.edit((draft) => {
+ let next = { ...draft.graph, nodes: [...draft.graph.nodes, node] };
+ const previous = add.after
+ ? draft.graph.nodes.find((node) => node.id === add.after)
+ : null;
+ if (previous) {
+ const port = add.port ?? graphPort(previous),
+ edge = draft.graph.edges.find(
+ (edge) => edge.source === previous.id && edge.port === port,
+ );
+ if (
+ node.kind === "call" &&
+ port === graphPort(previous) &&
+ previous.kind !== "condition"
+ )
+ next = appendCall(draft.graph, previous.id, node);
+ else
+ next = {
+ ...next,
+ edges: [
+ ...draft.graph.edges.filter((item) => item !== edge),
+ graphEdge(previous.id, port, id),
+ ...(edge && node.kind === "condition"
+ ? [
+ graphEdge(id, "otherwise", edge.target),
+ graphEdge(id, node.rules![0].id, edge.target),
+ ]
+ : []),
+ ],
+ };
+ }
+ const previousPosition = add.after ? draft.layout[add.after] : null;
+ return {
+ graph: next,
+ layout: {
+ ...draft.layout,
+ [id]: {
+ x: add.x ?? (previousPosition ? previousPosition.x + 330 : 60),
+ y:
+ add.y ??
+ (previousPosition
+ ? previousPosition.y
+ : entries.length * 220 + 60),
+ },
+ },
+ };
+ });
+ setSelected(id);
+ setAdd(null);
+ setPreview(null);
+ requestAnimationFrame(() => requestAnimationFrame(() => center(id)));
+ if (node.kind === "entry") {
+ setFocus("");
+ setTestEntry(id);
+ }
+ };
+ const arrange = async () => {
+ setLayoutBusy(true);
+ try {
+ const { default: ELK } = await import("elkjs/lib/elk.bundled.js");
+ const result = await new ELK().layout({
+ id: "routing",
+ layoutOptions: {
+ "elk.algorithm": "layered",
+ "elk.direction": "RIGHT",
+ "elk.spacing.nodeNode": "72",
+ "elk.layered.spacing.nodeNodeBetweenLayers": "100",
+ },
+ children: graph.nodes.map((node) => ({
+ id: node.id,
+ width: 250,
+ height:
+ node.kind === "condition"
+ ? 135 + (node.rules?.length ?? 0) * 30
+ : 142,
+ })),
+ edges: graph.edges.map((edge) => ({
+ id: edge.id,
+ sources: [edge.source],
+ targets: [edge.target],
+ })),
+ });
+ state.edit((draft) => ({
+ ...draft,
+ layout: Object.fromEntries(
+ (result.children ?? []).map((node) => [
+ node.id,
+ { x: node.x ?? 0, y: node.y ?? 0 },
+ ]),
+ ),
+ }));
+ requestAnimationFrame(() => center());
+ } catch (error) {
+ state.setError(String(error));
+ } finally {
+ setLayoutBusy(false);
+ }
+ };
+ const simulate = async () => {
+ setTesting(true);
+ state.setError(null);
+ try {
+ const result = await previewRoutingGraph({
+ graph: state.draft.graph,
+ entry_id: testEntry || entries[0]?.id || "",
+ facts: {
+ protocol,
+ streaming,
+ has_tools: hasTools,
+ has_images: hasImages,
+ },
+ outcomes,
+ });
+ setPreview(result);
+ setTraceOpen(true);
+ setTestOpen(false);
+ } catch (error) {
+ state.setError(String(error));
+ } finally {
+ setTesting(false);
+ }
+ };
+ const openRequest = async () => {
+ setTesting(true);
+ state.setError(null);
+ try {
+ const record = await getRequestRecord(requestID.trim());
+ const recovery = record.recovery;
+ if (!recovery?.graph_revision || !recovery.graph_trace)
+ throw new Error(t("graph.noRequestTrace"));
+ const graph = await getRoutingGraphRevision(recovery.graph_revision);
+ setReplay({
+ graph,
+ revision: recovery.graph_revision,
+ steps: recovery.graph_trace,
+ });
+ setFocus(recovery.graph_entry_id ?? "");
+ setSelected(null);
+ setTraceOpen(true);
+ setRequestDialog(false);
+ } catch (error) {
+ state.setError(String(error));
+ } finally {
+ setTesting(false);
+ }
+ };
+ const selectTrace = (id: string) => {
+ setSelected(id);
+ center(id);
+ };
+ const calls =
+ current?.kind === "entry" ? linearCalls(graph, current.id) : null;
+ const affected = current ? affectedEntries(graph, current.id) : [];
+ const selectedEntry = focus || (affected.length === 1 ? affected[0].id : "");
+ const portEditor = (port: string, label: string) => (
+
+ edge.source === current?.id && edge.port === port,
+ )?.target ?? ""
+ }
+ options={graph.nodes
+ .filter(
+ (node) =>
+ node.kind !== "entry" &&
+ current &&
+ canConnect(graph, current.id, node.id),
+ )
+ .map((node) => ({
+ value: node.id,
+ label:
+ node.model ||
+ node.upstream_model ||
+ node.name ||
+ t(`graph.kinds.${node.kind}`),
+ }))}
+ onChange={(target) => {
+ if (!current) return;
+ editGraph((graph) =>
+ target
+ ? connectGraph(graph, current.id, port, target)
+ : {
+ ...graph,
+ edges: graph.edges.filter(
+ (edge) => edge.source !== current.id || edge.port !== port,
+ ),
+ },
+ );
+ }}
+ />
+
+
+ );
+
+ return (
+ {
+ if (
+ event.nativeEvent.isComposing ||
+ (event.target as HTMLElement).closest(
+ "input,textarea,[contenteditable=true]",
+ )
+ )
+ return;
+ if (
+ (event.metaKey || event.ctrlKey) &&
+ event.key.toLowerCase() === "z" &&
+ !readonly
+ ) {
+ event.preventDefault();
+ if (event.shiftKey) state.redo();
+ else state.undo();
+ }
+ }}
+ >
+
+ {t("graph.entryCount", { count: entries.length })}
+
+ {t("graph.defaultHint")}
+
+
+ }
+ actions={
+
+
+ {replay
+ ? t("graph.replaying", { revision: replay.revision })
+ : state.saving
+ ? t("graph.saving")
+ : state.dirty
+ ? t("graph.unsaved")
+ : state.unpublished
+ ? t("graph.draftSaved")
+ : t("graph.applied")}
+
+ {replay ? (
+
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+
+
+ setRequestDialog(true)}>
+ {t("graph.openRequest")}
+
+ {state.document?.history.map((item) => (
+ {
+ void getRoutingGraphRevision(item.revision)
+ .then((graph) => {
+ editGraph(() => graph);
+ setFocus("");
+ setSelected(null);
+ })
+ .catch((error) => state.setError(String(error)));
+ }}
+ >
+ {t("graph.restoreRevision", { revision: item.revision })}
+
+ ))}
+
+
+
+ }
+ />
+ {state.error ? (
+
+ {state.error}
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {(["entry", "call", "condition", "stop"] as GraphKind[]).map(
+ (kind) => (
+ beginAdd(kind)}
+ >
+ {t(`graph.kinds.${kind}`)}
+
+ ),
+ )}
+
+
+
+ ({
+ value: entry.id,
+ label: entry.model ?? entry.id,
+ }))}
+ onChange={(value) => {
+ setFocus(value);
+ requestAnimationFrame(() => center());
+ }}
+ />
+
+
+
+
+
+ nodes={nodes}
+ edges={edges}
+ nodeTypes={nodeTypes}
+ edgeTypes={edgeTypes}
+ onNodesChange={onNodesChange}
+ onConnect={onConnect}
+ nodesDraggable={!readonly}
+ nodesConnectable={!readonly}
+ edgesReconnectable={false}
+ deleteKeyCode={null}
+ minZoom={0.25}
+ maxZoom={1.5}
+ fitView
+ fitViewOptions={{ padding: 0.25, minZoom: 0.75, maxZoom: 1 }}
+ onlyRenderVisibleElements
+ onNodeClick={(_, node) => setSelected(node.id)}
+ onPaneClick={() => setSelected(null)}
+ isValidConnection={(connection) =>
+ !!connection.source &&
+ !!connection.target &&
+ canConnect(graph, connection.source, connection.target)
+ }
+ onConnectStart={(_, params) => {
+ connectStart.current = params;
+ }}
+ onConnectEnd={(event, connection) => {
+ const start = connectStart.current;
+ connectStart.current = null;
+ if (
+ readonly ||
+ connection.isValid ||
+ !start?.nodeId ||
+ start.handleType !== "source"
+ )
+ return;
+ const point =
+ "changedTouches" in event ? event.changedTouches[0] : event;
+ if (point)
+ beginAdd(
+ "call",
+ start.nodeId,
+ start.handleId ?? undefined,
+ flow.screenToFlowPosition({
+ x: point.clientX,
+ y: point.clientY,
+ }),
+ );
+ }}
+ >
+
+
+ {graph.nodes.length > 12 ? (
+
+ graph.nodes.find((item) => item.id === node.id)?.enabled
+ ? "var(--primary)"
+ : "var(--border)"
+ }
+ />
+ ) : null}
+
+ {!graph.nodes.length && !state.error ? (
+
+
}
+ title={t("graph.welcome")}
+ description={t("graph.welcomeHint")}
+ action={
+
+ }
+ />
+
+ ) : null}
+
{t("graph.canvasHint")}
+
+ {current ? (
+
+
+
+ {t(`graph.kinds.${current.kind}`)}
+
+
+
+
+
+ ) : null}
+
+ {traceOpen && steps.length > 0 && !state.error ? (
+
+
+
+ {replay ? t("graph.actualTrace") : t("graph.simulatedTrace")}
+ {preview && !replay
+ ? ` · ${t("graph.attempts", { count: preview.attempts })}`
+ : ""}
+
+
+
+ {!replay && preview
+ ? t(`graph.reasons.${preview.stop_reason}`, {
+ defaultValue: preview.stop_reason,
+ })
+ : ""}
+
+
+
+
+
+
+
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/apps/desktop/src/bridge.ts b/apps/desktop/src/bridge.ts
index 45e46b1..f7120c4 100644
--- a/apps/desktop/src/bridge.ts
+++ b/apps/desktop/src/bridge.ts
@@ -4,6 +4,14 @@ import {
type ServiceTestResult,
} from "./service-test-model";
import { parseChannelBindingAudit } from "./channel-binding-model";
+import {
+ parseGraph,
+ parseGraphDocument,
+ parseGraphPreview,
+ type RoutingGraph,
+ type GraphLayout,
+ type GraphPreviewInput,
+} from "./routing-graph-model";
import {
parseRecoveryPath,
parseRecoveryPathRecord,
@@ -872,6 +880,40 @@ export async function getRoutingSettings(): Promise {
requireNativeBridge();
return parseRoutingSettings(await invoke("get_routing_settings"));
}
+
+export async function getRoutingGraph() {
+ requireNativeBridge();
+ return parseGraphDocument(
+ await invoke("routing_graph", { operation: "get" }),
+ );
+}
+export async function saveRoutingGraph(
+ graph: RoutingGraph,
+ layout: GraphLayout,
+ etag: string,
+ apply = false,
+) {
+ requireNativeBridge();
+ return parseGraphDocument(
+ await invoke("routing_graph", {
+ operation: "save",
+ etag,
+ input: { graph, layout, apply },
+ }),
+ );
+}
+export async function getRoutingGraphRevision(revision: number) {
+ requireNativeBridge();
+ return parseGraph(
+ await invoke("routing_graph", { operation: "revision", revision }),
+ );
+}
+export async function previewRoutingGraph(input: GraphPreviewInput) {
+ requireNativeBridge();
+ return parseGraphPreview(
+ await invoke("routing_graph", { operation: "preview", input }),
+ );
+}
export async function updateRoutingSettings(
patch: Partial,
): Promise {
diff --git a/apps/desktop/src/components/RecoveryDetails.tsx b/apps/desktop/src/components/RecoveryDetails.tsx
index d6529d3..d77dd1f 100644
--- a/apps/desktop/src/components/RecoveryDetails.tsx
+++ b/apps/desktop/src/components/RecoveryDetails.tsx
@@ -1,3 +1,4 @@
+import { RoutingGraphTrace } from "./RoutingGraphTrace";
import { ArrowRight } from "@/components/icons";
import { useT } from "../i18n";
import {
@@ -19,6 +20,16 @@ export function RecoveryDetails({
if (!value) return null;
return (
+ {value.graph_trace?.length ? (
+
+
-
+ {t("graph.actualTrace")} · #{value.graph_revision}
+
+ -
+
+
+
+ ) : null}
{value.path_name ? (
- {t("paths.title")}
diff --git a/apps/desktop/src/components/RoutingConditionEditor.tsx b/apps/desktop/src/components/RoutingConditionEditor.tsx
new file mode 100644
index 0000000..68332b8
--- /dev/null
+++ b/apps/desktop/src/components/RoutingConditionEditor.tsx
@@ -0,0 +1,288 @@
+import type { GraphField, GraphPredicate } from "../routing-graph-model";
+import type { RoutableService } from "../service-model";
+import { useT } from "../i18n";
+import { Field } from "./Field";
+import { Input } from "./ui/input";
+import { Button } from "./ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "./ui/select";
+import { Plus, X } from "./icons";
+
+export function GraphSelect({
+ value,
+ options,
+ onChange,
+ label,
+ disabled,
+}: {
+ value: string;
+ options: { value: string; label: string }[];
+ onChange: (value: string) => void;
+ label: string;
+ disabled?: boolean;
+}) {
+ return (
+
+ );
+}
+
+export const defaultPredicate = (): GraphPredicate => ({
+ field: "request.streaming",
+ operator: "eq",
+ value: true,
+});
+const fields: GraphField[] = [
+ "request.model",
+ "request.streaming",
+ "request.protocol",
+ "request.has_tools",
+ "request.has_images",
+ "quota.exhausted",
+ "quota.used_percent",
+ "last.status",
+ "last.error",
+ "attempts",
+];
+
+export function RoutingConditionEditor({
+ value,
+ onChange,
+ services,
+ depth = 0,
+}: {
+ value: GraphPredicate;
+ onChange: (value: GraphPredicate) => void;
+ services: readonly RoutableService[];
+ depth?: number;
+}) {
+ const t = useT();
+ const children = value.all ?? value.any;
+ if (children) {
+ const kind = value.all ? "all" : "any";
+ return (
+
+
+ onChange({ [next || "all"]: children })}
+ />
+
+
+ {children.map((child, index) => (
+
+
+ onChange({
+ [kind]: children.map((item, i) =>
+ i === index ? next : item,
+ ),
+ })
+ }
+ />
+ {children.length > 1 ? (
+
+ ) : null}
+
+ ))}
+ {children.length < 16 ? (
+
+ ) : null}
+
+ );
+ }
+ const field = value.field ?? "request.streaming";
+ const isBoolean = [
+ "request.streaming",
+ "request.has_tools",
+ "request.has_images",
+ "quota.exhausted",
+ ].includes(field);
+ const isNumber = ["last.status", "attempts", "quota.used_percent"].includes(
+ field,
+ );
+ const quota = field.startsWith("quota.");
+ return (
+
+
({
+ value,
+ label: t(`graph.fields.${value}`),
+ }))}
+ onChange={(field) => {
+ const numeric = [
+ "last.status",
+ "attempts",
+ "quota.used_percent",
+ ].includes(field);
+ const string = [
+ "request.model",
+ "request.protocol",
+ "last.error",
+ ].includes(field);
+ onChange({
+ field: (field || "request.streaming") as GraphField,
+ operator: "eq",
+ value: numeric
+ ? field === "last.status"
+ ? 429
+ : 0
+ : string
+ ? field === "request.protocol"
+ ? "openai.responses"
+ : "http_429"
+ : true,
+ ...(field.startsWith("quota.")
+ ? {
+ service_id: services[0]?.id ?? "",
+ window: "primary" as const,
+ }
+ : {}),
+ });
+ }}
+ />
+ {quota ? (
+ <>
+ ({
+ value: service.id,
+ label: service.name,
+ }))}
+ onChange={(service_id) => onChange({ ...value, service_id })}
+ />
+
+ onChange({
+ ...value,
+ window: window === "secondary" ? "secondary" : "primary",
+ })
+ }
+ />
+ >
+ ) : null}
+
+ ({ value: op, label: t(`graph.operators.${op}`) }))}
+ onChange={(op) =>
+ onChange({
+ ...value,
+ operator: (op || "eq") as GraphPredicate["operator"],
+ })
+ }
+ />
+ {isBoolean ? (
+ onChange({ ...value, value: next === "true" })}
+ />
+ ) : (
+
+ onChange({
+ ...value,
+ value: isNumber
+ ? Number(event.target.value)
+ : event.target.value,
+ })
+ }
+ />
+ )}
+
+ {depth < 3 ? (
+
+ ) : null}
+ {quota ? (
+
+
+ {t("graph.freshnessValue")}
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/desktop/src/components/RoutingGraphNode.tsx b/apps/desktop/src/components/RoutingGraphNode.tsx
new file mode 100644
index 0000000..f387ea2
--- /dev/null
+++ b/apps/desktop/src/components/RoutingGraphNode.tsx
@@ -0,0 +1,231 @@
+import { StatusDot } from "./StatusDot";
+import { memo, useEffect } from "react";
+import {
+ Handle,
+ Position,
+ useUpdateNodeInternals,
+ type Node,
+ type NodeProps,
+ BaseEdge,
+ type EdgeProps,
+} from "@xyflow/react";
+import { ModelBrandIcon } from "./ModelBrandIcon";
+import { Route, Plus, Ban, SlidersHorizontal, ArrowRight } from "./icons";
+import { Button } from "./ui/button";
+import { Switch } from "./ui/switch";
+import { useT } from "../i18n";
+import type { GraphNode, GraphStep } from "../routing-graph-model";
+import type { RoutableService } from "../service-model";
+
+export type RoutingNodeData = {
+ node: GraphNode;
+ locked: boolean;
+ service?: RoutableService;
+ shared: number;
+ step?: GraphStep;
+ toggle: (id: string) => void;
+ append: (id: string) => void;
+};
+export type FlowRoutingNode = Node
;
+
+export const RoutingGraphNode = memo(function RoutingGraphNode({
+ id,
+ data,
+ selected,
+}: NodeProps) {
+ const t = useT(),
+ update = useUpdateNodeInternals();
+ const { node, service, shared, step } = data;
+ const disabled =
+ !node.enabled && (node.kind === "call" || node.kind === "entry");
+ const title =
+ node.kind === "entry"
+ ? node.model
+ : node.kind === "call"
+ ? node.upstream_model
+ : node.name;
+ useEffect(
+ () => update(id),
+ [id, node.rules?.length, node.unknown_port, update],
+ );
+ const unavailable =
+ node.kind === "call" &&
+ service &&
+ (!service.enabled || !service.models.includes(node.upstream_model ?? ""));
+ const status = disabled
+ ? "disabled"
+ : (step?.status ??
+ (node.kind === "call" && !service
+ ? "missing"
+ : unavailable
+ ? "unavailable"
+ : "ready"));
+ const ports =
+ node.kind === "entry"
+ ? [{ id: "next", label: "" }]
+ : node.kind === "call"
+ ? [{ id: "failure", label: t("graph.failure") }]
+ : node.kind === "condition"
+ ? [
+ ...(node.rules ?? []).map((rule, index) => ({
+ id: rule.id,
+ label: rule.label || `${t("graph.rule")} ${index + 1}`,
+ })),
+ { id: "otherwise", label: t("graph.otherwise") },
+ ...(node.unknown_port === "unknown"
+ ? [{ id: "unknown", label: t("graph.unknown") }]
+ : []),
+ ]
+ : [];
+ return (
+
+ {node.kind !== "entry" ? (
+
+ ) : null}
+
+ {t(`graph.kinds.${node.kind}`)}
+ {shared > 1 ? (
+
+ {t("graph.sharedCount", { count: shared })}
+
+ ) : null}
+ {node.kind === "entry" || node.kind === "call" ? (
+ data.toggle(id)}
+ aria-label={t("graph.toggleNode", { name: title || id })}
+ />
+ ) : null}
+
+
+ {node.kind === "entry" ? (
+
+ ) : node.kind === "call" ? (
+
+ ) : node.kind === "condition" ? (
+
+ ) : (
+
+ )}
+
+ {title || t(`graph.empty.${node.kind}`)}
+
+
+ {node.kind === "call" ? (
+
+
+ {service?.name || t("graph.chooseService")}
+
+
+ ) : null}
+ {node.kind === "condition" ? (
+
+ {ports.map((port) => (
+
+ ))}
+
+ ) : null}
+
+
+
+ {disabled
+ ? t(
+ node.kind === "call"
+ ? "graph.disabledSkip"
+ : "graph.entryPaused",
+ )
+ : step
+ ? t(`graph.status.${step.status}`, { defaultValue: step.status })
+ : node.kind === "call" && !service
+ ? t("graph.missingService")
+ : unavailable
+ ? t("graph.targetUnavailable")
+ : t("graph.ready")}
+
+ {node.kind === "call" || node.kind === "entry" ? (
+
+ ) : null}
+
+ {node.kind !== "condition"
+ ? ports.map((port) => (
+
+ ))
+ : null}
+
+ );
+});
+
+export function RoutingBypassEdge(props: EdgeProps) {
+ const t = useT();
+ const lower = Math.max(props.sourceY, props.targetY) + 145;
+ const path = `M ${props.sourceX} ${props.sourceY} C ${props.sourceX + 45} ${lower}, ${props.targetX - 45} ${lower}, ${props.targetX} ${props.targetY}`;
+ const x = (props.sourceX + props.targetX) / 2,
+ y = (props.sourceY + props.targetY) / 2 + 108;
+ return (
+
+ );
+}
diff --git a/apps/desktop/src/components/RoutingGraphTrace.tsx b/apps/desktop/src/components/RoutingGraphTrace.tsx
new file mode 100644
index 0000000..f3a36e2
--- /dev/null
+++ b/apps/desktop/src/components/RoutingGraphTrace.tsx
@@ -0,0 +1,71 @@
+import { Button } from "./ui/button";
+import { Check, Ban, ArrowRight, CircleHelp } from "./icons";
+import type { GraphStep, RoutingGraph } from "../routing-graph-model";
+import { useT } from "../i18n";
+
+export function RoutingGraphTrace({
+ steps,
+ graph,
+ onSelect,
+}: {
+ steps: GraphStep[];
+ graph?: RoutingGraph;
+ onSelect?: (id: string) => void;
+}) {
+ const t = useT();
+ return (
+
+ {steps.map((step, index) => {
+ const node = graph?.nodes.find((item) => item.id === step.node_id);
+ const reason = step.reason?.startsWith("matched:")
+ ? t("graph.ruleMatched")
+ : step.reason?.startsWith("unknown:")
+ ? t("graph.unknownRule")
+ : step.reason?.startsWith("http_")
+ ? step.reason.replace("http_", "HTTP ")
+ : t(`graph.reasons.${step.reason ?? ""}`, {
+ defaultValue: step.reason ?? "",
+ });
+ return (
+ -
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/desktop/src/i18n/locales/en.json b/apps/desktop/src/i18n/locales/en.json
index 4656934..580432b 100644
--- a/apps/desktop/src/i18n/locales/en.json
+++ b/apps/desktop/src/i18n/locales/en.json
@@ -2407,5 +2407,203 @@
"sonnetModel": "Sonnet (optional)",
"opusModel": "Opus (optional)",
"claudeModelHint": "All model fields are optional. Empty fields are omitted."
+ },
+ "graph": {
+ "title": "Model routing",
+ "entryCount": "{{count}} entries",
+ "entry": "Model entry",
+ "addEntry": "Add model entry",
+ "addNode": "Add node",
+ "addToCanvas": "Add to canvas",
+ "addFallback": "Add fallback",
+ "addHint": "Choose a provider and actual model for each call. Success returns immediately; recoverable failure follows the next edge.",
+ "nodeKind": "Node type",
+ "service": "API provider / subscription",
+ "chooseService": "Choose provider",
+ "missingService": "Provider unavailable",
+ "actualModel": "Actual upstream model",
+ "publicModel": "Client model name",
+ "publicModelHint": "Exact match. It may differ from every upstream model. Use one entry per name.",
+ "mappingHint": "Cross-model and cross-provider mapping is allowed. Responses keep the public name; records show the actual model.",
+ "welcome": "One model entry. A clear fallback path.",
+ "welcomeHint": "Add a public model name, then choose a provider. Add fallback connects nodes for you; conditions and shared nodes use the same canvas.",
+ "ready": "Configured",
+ "disabledSkip": "Disabled · bypassed",
+ "entryPaused": "Entry paused",
+ "nodeEnabled": "Enable this call",
+ "entryEnabled": "Enable this entry",
+ "toggleNode": "Enable or disable {{name}}",
+ "disabledHint": "This node sends no request and consumes no attempt. Routing follows its existing fallback edge. Enable it to restore the call.",
+ "failure": "On failure",
+ "onFailure": "On failure / unavailable",
+ "next": "Next",
+ "input": "Input",
+ "firstSource": "First provider",
+ "newDestination": "Add and connect a call",
+ "newCondition": "Condition",
+ "insertCondition": "Insert failure condition",
+ "stop": "Return error",
+ "stopHint": "Stop routing here and preserve the last upstream error.",
+ "name": "Display name",
+ "rule": "Rule",
+ "ruleName": "Branch name",
+ "addRule": "Add branch rule",
+ "removeRule": "Remove branch rule",
+ "whenMatched": "When matched",
+ "matched": "Matched",
+ "otherwise": "Otherwise",
+ "unknown": "Unknown",
+ "unknownHandling": "When information is missing",
+ "unknownHint": "Stale quota snapshots and missing fields are unknown, never guessed. The chosen branch is recorded.",
+ "separateUnknown": "Separate unknown branch",
+ "ruleMatched": "Condition matched",
+ "unknownRule": "Unknown; followed the configured branch",
+ "conditionField": "Condition field",
+ "conditionValue": "Value",
+ "operator": "Operator",
+ "yes": "Yes",
+ "no": "No",
+ "groupLogic": "Group logic",
+ "all": "All conditions (AND)",
+ "any": "Any condition (OR)",
+ "combine": "Combine conditions",
+ "addCondition": "Add condition",
+ "unwrap": "Remove group",
+ "quotaService": "Quota provider",
+ "quotaWindow": "Quota window",
+ "primaryWindow": "Primary window",
+ "secondaryWindow": "Secondary window",
+ "quotaHint": "Uses collected quota only. Snapshots older than 10 minutes or past an unverified reset are unknown.",
+ "freshness": "Quota data",
+ "freshnessValue": "Local usage snapshot; no additional upstream request",
+ "maxAttempts": "Maximum inference attempts",
+ "maxAttemptsHint": "0 inherits the global limit. Disabled or locally skipped calls do not count. Maximum 20.",
+ "quickOrder": "Drag to reorder fallback calls",
+ "focus": "Focus an entry (— shows all)",
+ "fit": "Fit canvas",
+ "arrange": "Arrange",
+ "canvasHint": "Dragging changes layout · Edges define routing · Success returns immediately",
+ "defaultHint": "Unmatched models keep existing redirects and the global provider order.",
+ "advanced": "Advanced routing graph",
+ "advancedHint": "Configure fallback order and conditional branches for each model",
+ "backToSettings": "Back to routing settings",
+ "routingHelp": "How the graph works with default settings",
+ "more": "More actions",
+ "undo": "Undo",
+ "redo": "Redo",
+ "apply": "Apply",
+ "applied": "Applied",
+ "saving": "Saving…",
+ "draftSaved": "Draft · not applied",
+ "unsaved": "Unsaved changes",
+ "reload": "Reload",
+ "sharedCount": "Shared {{count}}",
+ "sharedImpact": "Affects {{count}} entries",
+ "isolateEntry": "Choose entry to isolate",
+ "isolate": "Edit for this entry only",
+ "isolateHint": "Copies the required shared prefix; downstream nodes stay shared. Accounts and quota are not duplicated.",
+ "deleteNode": "Delete node",
+ "bypassed": "Bypass {{count}} disabled nodes",
+ "simulate": "Simulate",
+ "simulateHint": "Evaluates the current draft without inference requests or changes to real quota or cooldown.",
+ "simulatedResult": "Simulated result",
+ "runSimulation": "Run simulation",
+ "testing": "Working…",
+ "protocol": "Test ingress protocol",
+ "streaming": "Streaming",
+ "tools": "Has tools",
+ "images": "Has images",
+ "trace": "Execution trace",
+ "simulatedTrace": "Draft simulation",
+ "actualTrace": "Actual request trace",
+ "attempts": "{{count}} inference attempts",
+ "openRequest": "Open request trace",
+ "openRequestHint": "Enter a request ID to view the graph revision used by that request.",
+ "requestID": "Request ID",
+ "openTrace": "View trace",
+ "noRequestTrace": "This request has no model routing graph trace",
+ "replaying": "Historical revision #{{revision}}",
+ "exitReplay": "Back to editing",
+ "restoreRevision": "Restore revision #{{revision}} to draft",
+ "kinds": {
+ "entry": "Model entry",
+ "call": "Model call",
+ "condition": "Condition",
+ "stop": "End"
+ },
+ "empty": {
+ "entry": "Set public model name",
+ "call": "Choose actual model",
+ "condition": "Configure condition",
+ "stop": "Return error"
+ },
+ "status": {
+ "selected": "Selected",
+ "skipped": "Skipped",
+ "attempted": "Attempted",
+ "succeeded": "Succeeded",
+ "failed": "Failed",
+ "stopped": "Stopped"
+ },
+ "reasons": {
+ "node_disabled": "Manually disabled; no request sent",
+ "entry_paused": "Entry paused",
+ "entry_match": "Public model matched",
+ "no_rule_matched": "No rule matched",
+ "target_unavailable": "Provider unavailable",
+ "missing_protocol_capability": "Protocol or model incompatible",
+ "circuit_open": "Provider cooling down or circuit open",
+ "already_attempted": "Target already attempted in this request",
+ "protocol_binding": "Continuation requires the original provider",
+ "explicit_stop": "Reached end node",
+ "targets_exhausted": "No remaining target",
+ "attempt_limit": "Attempt limit reached",
+ "error_rule": "Error policy stopped recovery",
+ "succeeded": "Request succeeded",
+ "upstream_timeout": "Upstream timed out",
+ "upstream_unavailable": "Upstream connection failed",
+ "conversion_failed": "Protocol conversion failed",
+ "cancelled": "Request cancelled"
+ },
+ "fields": {
+ "request": {
+ "protocol": "Ingress protocol",
+ "streaming": "Streaming request",
+ "has_tools": "Contains tools",
+ "has_images": "Contains images",
+ "model": "Requested public model"
+ },
+ "last": {
+ "status": "Last HTTP status",
+ "error": "Last failure reason"
+ },
+ "attempts": "Attempt count",
+ "quota": {
+ "exhausted": "Quota confirmed exhausted",
+ "used_percent": "Quota used percent"
+ }
+ },
+ "operators": {
+ "eq": "Equals",
+ "ne": "Not equal",
+ "gt": "Greater than",
+ "gte": "At least",
+ "lt": "Less than",
+ "lte": "At most"
+ },
+ "outcomes": {
+ "success": "Success",
+ "429": "429 · Rate / quota limit",
+ "503": "503 · Unavailable",
+ "401": "401 · Invalid credentials",
+ "400": "400 · Invalid request",
+ "network_error": "Connection failure",
+ "response_timeout": "Response timeout",
+ "unavailable": "Locally unavailable (no request)"
+ },
+ "targetUnavailable": "Provider / model unavailable",
+ "advancedPolicy": "Recovery policy",
+ "customPolicy": "Override this call",
+ "inheritPolicy": "Inherit provider error rules; one ordinary attempt by default."
}
}
diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json
index 6365ce0..4768a6b 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.json
+++ b/apps/desktop/src/i18n/locales/zh-CN.json
@@ -2407,5 +2407,203 @@
"sonnetModel": "Sonnet(可选)",
"opusModel": "Opus(可选)",
"claudeModelHint": "按需填写;留空的模型项不会导入。"
+ },
+ "graph": {
+ "title": "模型路由",
+ "entryCount": "{{count}} 个入口",
+ "entry": "模型入口",
+ "addEntry": "添加模型入口",
+ "addNode": "添加节点",
+ "addToCanvas": "添加到画布",
+ "addFallback": "添加后备",
+ "addHint": "每个调用节点独立选择来源和实际模型。成功即返回,失败才继续后备。",
+ "nodeKind": "节点类型",
+ "service": "API 提供商 / 订阅",
+ "chooseService": "选择来源",
+ "missingService": "来源不可用",
+ "actualModel": "实际调用模型",
+ "publicModel": "客户端使用的模型名",
+ "publicModelHint": "精确匹配;允许与实际模型不同。不同名称用不同入口。",
+ "mappingHint": "允许跨模型、跨提供商。客户端模型名保持不变,记录显示实际调用模型。",
+ "welcome": "一个模型入口,一条清楚的后备路线",
+ "welcomeHint": "添加客户端使用的模型名,再为它选择来源。用“添加后备”自动连线,也可以自由连接条件与共享节点。",
+ "ready": "已配置",
+ "disabledSkip": "已禁用 · 跳过",
+ "entryPaused": "入口已暂停",
+ "nodeEnabled": "启用此调用",
+ "entryEnabled": "启用此入口",
+ "toggleNode": "启用或禁用 {{name}}",
+ "disabledHint": "此节点不会发出请求,也不消耗尝试次数;沿原有失败连线继续下一个。重新启用即可恢复。",
+ "failure": "失败后",
+ "onFailure": "失败 / 不可用时继续",
+ "next": "下一步",
+ "input": "输入",
+ "firstSource": "首选来源",
+ "newDestination": "新增目标并连接",
+ "newCondition": "条件分支",
+ "insertCondition": "插入失败条件",
+ "stop": "返回错误",
+ "stopHint": "到达这里停止选路,保留最后一个上游错误。",
+ "name": "显示名称",
+ "rule": "规则",
+ "ruleName": "分支名称",
+ "addRule": "添加分支规则",
+ "removeRule": "删除分支规则",
+ "whenMatched": "命中后前往",
+ "matched": "命中条件",
+ "otherwise": "否则",
+ "unknown": "信息未知",
+ "unknownHandling": "信息缺失时",
+ "unknownHint": "额度快照过期或字段缺失时不会猜测。未知分支也会显示在执行轨迹中。",
+ "separateUnknown": "单独连接未知分支",
+ "ruleMatched": "条件命中",
+ "unknownRule": "信息未知,按配置选择分支",
+ "conditionField": "判断字段",
+ "conditionValue": "比较值",
+ "operator": "比较方式",
+ "yes": "是",
+ "no": "否",
+ "groupLogic": "组合方式",
+ "all": "全部满足(且)",
+ "any": "任一满足(或)",
+ "combine": "组合更多条件",
+ "addCondition": "添加条件",
+ "unwrap": "取消组合",
+ "quotaService": "额度所属来源",
+ "quotaWindow": "额度窗口",
+ "primaryWindow": "主要窗口",
+ "secondaryWindow": "次要窗口",
+ "quotaHint": "仅使用已采集的额度;超过 10 分钟或重置时间已过且尚未验证时,结果为未知。",
+ "freshness": "额度数据",
+ "freshnessValue": "使用本地用量快照,不额外请求上游",
+ "maxAttempts": "最多推理尝试次数",
+ "maxAttemptsHint": "0 表示继承全局;禁用和本地跳过不计次。最多 20 次。",
+ "quickOrder": "拖动调整后备顺序",
+ "focus": "聚焦一个入口(— 表示全部)",
+ "fit": "适合画布",
+ "arrange": "整理布局",
+ "canvasHint": "拖动节点只改变位置 · 连线决定路径 · 成功即返回",
+ "defaultHint": "未匹配入口的模型继续使用原来的重定向与全局顺序。",
+ "advanced": "高级路由图",
+ "advancedHint": "为单个模型编排后备顺序与条件分支",
+ "backToSettings": "返回路由设置",
+ "routingHelp": "路由图与默认设置的关系",
+ "more": "更多操作",
+ "undo": "撤销",
+ "redo": "重做",
+ "apply": "应用",
+ "applied": "已应用",
+ "saving": "正在保存…",
+ "draftSaved": "草稿 · 尚未应用",
+ "unsaved": "有未保存的修改",
+ "reload": "重新加载",
+ "sharedCount": "共享 {{count}}",
+ "sharedImpact": "影响 {{count}} 个入口",
+ "isolateEntry": "选择要独立的入口",
+ "isolate": "仅为此入口修改",
+ "isolateHint": "复制必要的共享上游,保留下游共享;账号与额度状态不会复制。",
+ "deleteNode": "删除节点",
+ "bypassed": "跳过 {{count}} 个禁用节点",
+ "simulate": "模拟",
+ "simulateHint": "验证当前草稿,不发送推理请求,也不修改真实额度或冷却。",
+ "simulatedResult": "模拟调用结果",
+ "runSimulation": "运行模拟",
+ "testing": "处理中…",
+ "protocol": "测试入口协议",
+ "streaming": "流式",
+ "tools": "含工具",
+ "images": "含图片",
+ "trace": "执行轨迹",
+ "simulatedTrace": "草稿模拟轨迹",
+ "actualTrace": "真实请求轨迹",
+ "attempts": "{{count}} 次推理尝试",
+ "openRequest": "打开请求轨迹",
+ "openRequestHint": "输入请求记录中的请求 ID,加载该请求当时使用的图版本。",
+ "requestID": "请求 ID",
+ "openTrace": "查看轨迹",
+ "noRequestTrace": "该请求没有模型路由图轨迹",
+ "replaying": "历史版本 #{{revision}}",
+ "exitReplay": "返回编辑",
+ "restoreRevision": "恢复版本 #{{revision}} 到草稿",
+ "kinds": {
+ "entry": "模型入口",
+ "call": "模型调用",
+ "condition": "条件分支",
+ "stop": "结束"
+ },
+ "empty": {
+ "entry": "设置公开模型名",
+ "call": "选择实际模型",
+ "condition": "设置分支条件",
+ "stop": "返回错误"
+ },
+ "status": {
+ "selected": "已选择",
+ "skipped": "已跳过",
+ "attempted": "发起调用",
+ "succeeded": "成功",
+ "failed": "失败",
+ "stopped": "结束"
+ },
+ "reasons": {
+ "node_disabled": "手动禁用,不发出请求",
+ "entry_paused": "入口暂停",
+ "entry_match": "匹配公开模型名",
+ "no_rule_matched": "没有条件命中",
+ "target_unavailable": "来源不可用",
+ "missing_protocol_capability": "入口协议或模型不兼容",
+ "circuit_open": "来源处于冷却或熔断中",
+ "already_attempted": "本次请求已尝试该目标",
+ "protocol_binding": "协议续接要求原始来源",
+ "explicit_stop": "到达结束节点",
+ "targets_exhausted": "已无后续目标",
+ "attempt_limit": "已达到尝试上限",
+ "error_rule": "错误策略要求停止",
+ "succeeded": "请求成功",
+ "upstream_timeout": "上游响应超时",
+ "upstream_unavailable": "上游连接失败",
+ "conversion_failed": "协议转换失败",
+ "cancelled": "请求已取消"
+ },
+ "fields": {
+ "request": {
+ "protocol": "入口协议",
+ "streaming": "是否流式",
+ "has_tools": "是否包含工具",
+ "has_images": "是否包含图片",
+ "model": "客户端模型名"
+ },
+ "last": {
+ "status": "上次 HTTP 状态",
+ "error": "上次失败原因"
+ },
+ "attempts": "已尝试次数",
+ "quota": {
+ "exhausted": "额度是否已明确耗尽",
+ "used_percent": "额度已用百分比"
+ }
+ },
+ "operators": {
+ "eq": "等于",
+ "ne": "不等于",
+ "gt": "大于",
+ "gte": "大于或等于",
+ "lt": "小于",
+ "lte": "小于或等于"
+ },
+ "outcomes": {
+ "success": "成功",
+ "429": "429 · 限流 / 额度耗尽",
+ "503": "503 · 服务不可用",
+ "401": "401 · 凭据失效",
+ "400": "400 · 请求错误",
+ "network_error": "连接失败",
+ "response_timeout": "响应超时",
+ "unavailable": "本地不可用(不发请求)"
+ },
+ "targetUnavailable": "来源 / 模型不可用",
+ "advancedPolicy": "高级恢复策略",
+ "customPolicy": "为此调用自定义",
+ "inheritPolicy": "继承来源的错误规则;默认只正常尝试一次。"
}
}
diff --git a/apps/desktop/src/request-record-model.ts b/apps/desktop/src/request-record-model.ts
index d41923d..4ef28cf 100644
--- a/apps/desktop/src/request-record-model.ts
+++ b/apps/desktop/src/request-record-model.ts
@@ -1,3 +1,4 @@
+import { parseGraphStep } from "./routing-graph-model";
import { i18n } from "./i18n";
export type RequestStatus =
@@ -106,6 +107,9 @@ export interface SessionLink {
}
export interface RequestRecovery {
+ graph_revision?: number;
+ graph_entry_id?: string;
+ graph_trace?: import("./routing-graph-model").GraphStep[];
path_id?: string;
path_name?: string;
path_version?: string;
@@ -505,6 +509,21 @@ function parseRecovery(value: unknown, path: string): RequestRecovery {
)
invalid(path, "invalid recovery action");
const result: RequestRecovery = { delay_ms: delay };
+ if (record.graph_revision !== undefined)
+ result.graph_revision = intAt(
+ record.graph_revision,
+ `${path}.graph_revision`,
+ );
+ if (record.graph_entry_id !== undefined)
+ result.graph_entry_id = stringAt(
+ record.graph_entry_id,
+ `${path}.graph_entry_id`,
+ );
+ if (record.graph_trace !== undefined) {
+ if (!Array.isArray(record.graph_trace) || record.graph_trace.length > 2048)
+ invalid(path, "invalid graph trace");
+ result.graph_trace = record.graph_trace.map(parseGraphStep);
+ }
if (record.action) result.action = record.action as RequestRecovery["action"];
for (const key of [
"upstream_model",
diff --git a/apps/desktop/src/routing-graph-model.test.ts b/apps/desktop/src/routing-graph-model.test.ts
new file mode 100644
index 0000000..65b51c3
--- /dev/null
+++ b/apps/desktop/src/routing-graph-model.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, it } from "vitest";
+import {
+ appendCall,
+ canConnect,
+ disabledBypasses,
+ isolateNode,
+ linearCalls,
+ parseGraphDocument,
+ reachable,
+ removeGraphNode,
+ reorderCalls,
+ type RoutingGraph,
+} from "./routing-graph-model";
+
+function fixture(): RoutingGraph {
+ return {
+ nodes: [
+ { id: "entry", kind: "entry", enabled: true, model: "public" },
+ ...["call_a", "call_b", "call_c"].map((id) => ({
+ id,
+ kind: "call" as const,
+ enabled: id !== "call_b",
+ service_id: "service_" + id,
+ upstream_model: "actual",
+ })),
+ ],
+ edges: [
+ { id: "first", source: "entry", port: "next", target: "call_a" },
+ { id: "second", source: "call_a", port: "failure", target: "call_b" },
+ { id: "third", source: "call_b", port: "failure", target: "call_c" },
+ ],
+ };
+}
+
+describe("routing graph editing invariants", () => {
+ it("retains disabled nodes and renders a bypass without mutating declared edges", () => {
+ const graph = fixture(),
+ original = structuredClone(graph);
+ expect(disabledBypasses(graph)).toEqual([
+ {
+ source: "call_a",
+ target: "call_c",
+ port: "failure",
+ skipped: ["call_b"],
+ },
+ ]);
+ expect(linearCalls(graph, "entry")?.map((node) => node.id)).toEqual([
+ "call_a",
+ "call_b",
+ "call_c",
+ ]);
+ expect(graph).toEqual(original);
+ graph.nodes[2].enabled = true;
+ expect(disabledBypasses(graph)).toEqual([]);
+ });
+ it("reorders the edges, not node positions, and preserves disabled state", () => {
+ const graph = reorderCalls(fixture(), "entry", [
+ "call_c",
+ "call_b",
+ "call_a",
+ ]);
+ expect(linearCalls(graph, "entry")?.map((node) => node.id)).toEqual([
+ "call_c",
+ "call_b",
+ "call_a",
+ ]);
+ expect(graph.nodes.find((node) => node.id === "call_b")?.enabled).toBe(
+ false,
+ );
+ expect(canConnect(graph, "call_a", "call_c")).toBe(false);
+ expect(canConnect(graph, "call_a", "entry")).toBe(false);
+ });
+ it("inserts and removes an exclusive fallback without losing its tail", () => {
+ const graph = appendCall(fixture(), "call_a", {
+ id: "inserted",
+ kind: "call",
+ enabled: true,
+ service_id: "new_service",
+ upstream_model: "different",
+ });
+ expect(linearCalls(graph, "entry")?.map((node) => node.id)).toEqual([
+ "call_a",
+ "inserted",
+ "call_b",
+ "call_c",
+ ]);
+ expect(
+ linearCalls(removeGraphNode(graph, "inserted"), "entry")?.map(
+ (node) => node.id,
+ ),
+ ).toEqual(["call_a", "call_b", "call_c"]);
+ });
+ it("isolates a shared ancestor and edited node while preserving the shared tail", () => {
+ const graph = fixture();
+ graph.nodes.push({
+ id: "other_entry",
+ kind: "entry",
+ enabled: true,
+ model: "other",
+ });
+ graph.edges.push({
+ id: "other_first",
+ source: "other_entry",
+ port: "next",
+ target: "call_a",
+ });
+ expect(linearCalls(graph, "entry")).toBeNull();
+ const before = reachable(graph, "other_entry");
+ const isolated = isolateNode(graph, "entry", "call_b");
+ expect([...isolated.copied.keys()]).toEqual(["call_a", "call_b"]);
+ expect(reachable(isolated.graph, "other_entry")).toEqual(before);
+ const mine = reachable(isolated.graph, "entry");
+ expect(mine.has("call_a")).toBe(false);
+ expect(mine.has("call_b")).toBe(false);
+ expect(mine.has("call_c")).toBe(true);
+ expect(mine.has(isolated.copied.get("call_b")!)).toBe(true);
+ });
+ it("rejects malformed persisted data at the bridge boundary", () => {
+ expect(() =>
+ parseGraphDocument({
+ draft: fixture(),
+ active: fixture(),
+ layout: {},
+ revision: -1,
+ etag: "tag",
+ history: [],
+ }),
+ ).toThrow();
+ expect(() =>
+ parseGraphDocument({
+ draft: fixture(),
+ active: fixture(),
+ layout: { entry: { x: NaN, y: 1 } },
+ revision: 1,
+ etag: "tag",
+ history: [],
+ }),
+ ).toThrow();
+ });
+});
diff --git a/apps/desktop/src/routing-graph-model.ts b/apps/desktop/src/routing-graph-model.ts
new file mode 100644
index 0000000..bbea6b1
--- /dev/null
+++ b/apps/desktop/src/routing-graph-model.ts
@@ -0,0 +1,474 @@
+import { parseFailurePolicy, type FailurePolicy } from "./failure-policy-model";
+
+export type GraphKind = "entry" | "call" | "condition" | "stop";
+export type GraphField =
+ | "request.model"
+ | "request.protocol"
+ | "request.streaming"
+ | "request.has_tools"
+ | "request.has_images"
+ | "last.status"
+ | "last.error"
+ | "attempts"
+ | "quota.exhausted"
+ | "quota.used_percent";
+export interface GraphPredicate {
+ all?: GraphPredicate[];
+ any?: GraphPredicate[];
+ field?: GraphField;
+ operator?: "eq" | "ne" | "gt" | "gte" | "lt" | "lte";
+ value?: string | boolean | number;
+ service_id?: string;
+ window?: "primary" | "secondary";
+}
+export interface GraphRule {
+ id: string;
+ label?: string;
+ predicate: GraphPredicate;
+}
+export interface GraphNode {
+ id: string;
+ kind: GraphKind;
+ enabled: boolean;
+ name?: string;
+ model?: string;
+ service_id?: string;
+ upstream_model?: string;
+ max_attempts?: number;
+ failure_policy?: FailurePolicy;
+ rules?: GraphRule[];
+ unknown_port?: string;
+}
+export interface GraphEdge {
+ id: string;
+ source: string;
+ port: string;
+ target: string;
+}
+export interface RoutingGraph {
+ nodes: GraphNode[];
+ edges: GraphEdge[];
+}
+export type GraphLayout = Record;
+export interface GraphDocument {
+ draft: RoutingGraph;
+ layout: GraphLayout;
+ active: RoutingGraph;
+ revision: number;
+ etag: string;
+ history: { revision: number; created_at: string }[];
+}
+export interface GraphStep {
+ node_id: string;
+ kind: string;
+ status: string;
+ reason?: string;
+ port?: string;
+ service_id?: string;
+ model?: string;
+}
+export interface GraphPreview {
+ steps: GraphStep[];
+ stop_reason: string;
+ attempts: number;
+}
+export interface GraphPreviewInput {
+ graph: RoutingGraph;
+ entry_id: string;
+ facts: {
+ protocol: string;
+ streaming: boolean;
+ has_tools?: boolean;
+ has_images?: boolean;
+ };
+ outcomes: Record;
+}
+
+const kinds: GraphKind[] = ["entry", "call", "condition", "stop"];
+function object(value: unknown): Record {
+ if (!value || typeof value !== "object" || Array.isArray(value))
+ throw new Error("Invalid routing graph document");
+ return value as Record;
+}
+function text(value: unknown): string {
+ if (typeof value !== "string") throw new Error("Invalid graph text");
+ return value;
+}
+function integer(value: unknown): number {
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
+ throw new Error("Invalid graph version");
+ return value;
+}
+
+export function parseGraph(value: unknown): RoutingGraph {
+ const graph = object(value);
+ if (
+ !Array.isArray(graph.nodes) ||
+ !Array.isArray(graph.edges) ||
+ graph.nodes.length > 512 ||
+ graph.edges.length > 1024
+ )
+ throw new Error("Invalid routing graph size");
+ const nodes = graph.nodes.map((value): GraphNode => {
+ const node = object(value);
+ if (
+ !kinds.includes(node.kind as GraphKind) ||
+ typeof node.enabled !== "boolean"
+ )
+ throw new Error("Invalid routing node");
+ const result: GraphNode = {
+ id: text(node.id),
+ kind: node.kind as GraphKind,
+ enabled: node.enabled,
+ };
+ for (const key of [
+ "name",
+ "model",
+ "service_id",
+ "upstream_model",
+ "unknown_port",
+ ] as const)
+ if (node[key] !== undefined) result[key] = text(node[key]);
+ if (node.max_attempts !== undefined)
+ result.max_attempts = integer(node.max_attempts);
+ if (node.failure_policy !== undefined)
+ result.failure_policy = parseFailurePolicy(node.failure_policy);
+ if (node.rules !== undefined) {
+ if (!Array.isArray(node.rules))
+ throw new Error("Invalid condition rules");
+ result.rules = node.rules.map((value) => {
+ const rule = object(value);
+ return {
+ id: text(rule.id),
+ label: rule.label === undefined ? undefined : text(rule.label),
+ predicate: parsePredicate(rule.predicate),
+ };
+ });
+ }
+ return result;
+ });
+ return {
+ nodes,
+ edges: graph.edges.map((value) => {
+ const edge = object(value);
+ return {
+ id: text(edge.id),
+ source: text(edge.source),
+ port: text(edge.port),
+ target: text(edge.target),
+ };
+ }),
+ };
+}
+
+export function parsePredicate(value: unknown, depth = 0): GraphPredicate {
+ if (depth > 4) throw new Error("Condition nesting exceeds limit");
+ const p = object(value);
+ for (const key of ["all", "any"] as const)
+ if (p[key] !== undefined) {
+ if (!Array.isArray(p[key]) || p[key].length > 16)
+ throw new Error("Invalid condition group");
+ return { [key]: p[key].map((child) => parsePredicate(child, depth + 1)) };
+ }
+ if (!["string", "boolean", "number"].includes(typeof p.value))
+ throw new Error("Invalid condition value");
+ return {
+ field: text(p.field) as GraphField,
+ operator: text(p.operator) as GraphPredicate["operator"],
+ value: p.value as string | number | boolean,
+ ...(p.service_id ? { service_id: text(p.service_id) } : {}),
+ ...(p.window ? { window: text(p.window) as "primary" | "secondary" } : {}),
+ };
+}
+
+export function parseGraphDocument(value: unknown): GraphDocument {
+ const doc = object(value);
+ const positions = object(doc.layout),
+ layout: GraphLayout = {};
+ for (const [id, value] of Object.entries(positions)) {
+ const p = object(value);
+ if (
+ typeof p.x !== "number" ||
+ !Number.isFinite(p.x) ||
+ typeof p.y !== "number" ||
+ !Number.isFinite(p.y)
+ )
+ throw new Error("Invalid graph layout");
+ layout[id] = { x: p.x, y: p.y };
+ }
+ if (!Array.isArray(doc.history)) throw new Error("Invalid graph history");
+ return {
+ draft: parseGraph(doc.draft),
+ active: parseGraph(doc.active),
+ layout,
+ revision: integer(doc.revision),
+ etag: text(doc.etag),
+ history: doc.history.map((value) => {
+ const item = object(value);
+ return {
+ revision: integer(item.revision),
+ created_at: text(item.created_at),
+ };
+ }),
+ };
+}
+
+export function parseGraphStep(value: unknown): GraphStep {
+ const step = object(value);
+ return {
+ node_id: text(step.node_id),
+ kind: text(step.kind),
+ status: text(step.status),
+ ...Object.fromEntries(
+ ["reason", "port", "service_id", "model"].flatMap((key) =>
+ step[key] === undefined ? [] : [[key, text(step[key])]],
+ ),
+ ),
+ };
+}
+export function parseGraphPreview(value: unknown): GraphPreview {
+ const result = object(value);
+ if (!Array.isArray(result.steps) || result.steps.length > 2048)
+ throw new Error("Invalid routing trace");
+ return {
+ steps: result.steps.map(parseGraphStep),
+ stop_reason: text(result.stop_reason),
+ attempts: integer(result.attempts),
+ };
+}
+
+export const graphID = () => `node_${crypto.randomUUID().replaceAll("-", "")}`;
+export const graphEdge = (
+ source: string,
+ port: string,
+ target: string,
+): GraphEdge => ({ id: graphID(), source, port, target });
+export const emptyGraph = (): RoutingGraph => ({ nodes: [], edges: [] });
+export const graphPort = (node: GraphNode) =>
+ node.kind === "entry" ? "next" : "failure";
+
+export function reachable(graph: RoutingGraph, start: string): Set {
+ const seen = new Set(),
+ pending = [start],
+ outgoing = new Map();
+ for (const edge of graph.edges) {
+ const targets = outgoing.get(edge.source) ?? [];
+ targets.push(edge.target);
+ outgoing.set(edge.source, targets);
+ }
+ while (pending.length) {
+ const id = pending.pop()!;
+ if (seen.has(id)) continue;
+ seen.add(id);
+ pending.push(...(outgoing.get(id) ?? []));
+ }
+ return seen;
+}
+export function affectedEntries(graph: RoutingGraph, id: string): GraphNode[] {
+ return graph.nodes.filter(
+ (node) => node.kind === "entry" && reachable(graph, node.id).has(id),
+ );
+}
+export function canConnect(
+ graph: RoutingGraph,
+ source: string,
+ target: string,
+): boolean {
+ return (
+ source !== target &&
+ graph.nodes.find((node) => node.id === target)?.kind !== "entry" &&
+ !reachable(graph, target).has(source)
+ );
+}
+export function connectGraph(
+ graph: RoutingGraph,
+ source: string,
+ port: string,
+ target: string,
+): RoutingGraph {
+ if (!canConnect(graph, source, target)) return graph;
+ return {
+ ...graph,
+ edges: [
+ ...graph.edges.filter(
+ (edge) => edge.source !== source || edge.port !== port,
+ ),
+ graphEdge(source, port, target),
+ ],
+ };
+}
+
+export function appendCall(
+ graph: RoutingGraph,
+ after: string,
+ call: GraphNode,
+): RoutingGraph {
+ const node = graph.nodes.find((item) => item.id === after);
+ if (!node || (node.kind !== "entry" && node.kind !== "call")) return graph;
+ const port = graphPort(node),
+ previous = graph.edges.find(
+ (edge) => edge.source === after && edge.port === port,
+ );
+ return {
+ nodes: [...graph.nodes, call],
+ edges: [
+ ...graph.edges.filter((edge) => edge !== previous),
+ graphEdge(after, port, call.id),
+ ...(previous ? [graphEdge(call.id, "failure", previous.target)] : []),
+ ],
+ };
+}
+
+// A compact editor may reorder only an exclusive, uninterrupted linear chain.
+export function linearCalls(
+ graph: RoutingGraph,
+ entry: string,
+): GraphNode[] | null {
+ const result: GraphNode[] = [],
+ seen = new Set();
+ let id = graph.edges.find(
+ (edge) => edge.source === entry && edge.port === "next",
+ )?.target;
+ while (id) {
+ if (seen.has(id)) return null;
+ seen.add(id);
+ const node = graph.nodes.find((item) => item.id === id);
+ if (
+ !node ||
+ node.kind !== "call" ||
+ graph.edges.filter((edge) => edge.target === id).length !== 1
+ )
+ return null;
+ const outgoing = graph.edges.filter((edge) => edge.source === id);
+ if (outgoing.length > 1 || outgoing.some((edge) => edge.port !== "failure"))
+ return null;
+ result.push(node);
+ id = outgoing[0]?.target;
+ }
+ return result;
+}
+export function reorderCalls(
+ graph: RoutingGraph,
+ entry: string,
+ order: string[],
+): RoutingGraph {
+ const calls = linearCalls(graph, entry);
+ if (
+ !calls ||
+ calls.length !== order.length ||
+ new Set(order).size !== order.length ||
+ order.some((id) => !calls.some((node) => node.id === id))
+ )
+ return graph;
+ const sources = new Set([entry, ...order]);
+ return {
+ ...graph,
+ edges: [
+ ...graph.edges.filter((edge) => !sources.has(edge.source)),
+ ...order.map((id, i) =>
+ graphEdge(i ? order[i - 1] : entry, i ? "failure" : "next", id),
+ ),
+ ],
+ };
+}
+
+export function removeGraphNode(graph: RoutingGraph, id: string): RoutingGraph {
+ const node = graph.nodes.find((item) => item.id === id),
+ incoming = graph.edges.filter((edge) => edge.target === id),
+ outgoing = graph.edges.filter((edge) => edge.source === id);
+ const reconnect =
+ node?.kind === "call" &&
+ incoming.length === 1 &&
+ outgoing.length === 1 &&
+ outgoing[0].port === "failure";
+ return {
+ nodes: graph.nodes.filter((node) => node.id !== id),
+ edges: [
+ ...graph.edges.filter((edge) => edge.source !== id && edge.target !== id),
+ ...(reconnect
+ ? [graphEdge(incoming[0].source, incoming[0].port, outgoing[0].target)]
+ : []),
+ ],
+ };
+}
+
+// Clone only the shared prefix that can reach the edited node. Outgoing paths
+// outside this prefix remain shared, so changing one call never forks a tail.
+export function isolateNode(
+ graph: RoutingGraph,
+ entryID: string,
+ nodeID: string,
+): { graph: RoutingGraph; copied: Map } {
+ const mine = reachable(graph, entryID),
+ other = new Set();
+ for (const node of graph.nodes)
+ if (node.kind === "entry" && node.id !== entryID)
+ for (const id of reachable(graph, node.id)) other.add(id);
+ const copied = new Map();
+ for (const node of graph.nodes)
+ if (
+ mine.has(node.id) &&
+ other.has(node.id) &&
+ reachable(graph, node.id).has(nodeID)
+ )
+ copied.set(node.id, graphID());
+ if (!copied.size) return { graph, copied };
+ const nodes = [
+ ...graph.nodes,
+ ...graph.nodes
+ .filter((node) => copied.has(node.id))
+ .map((node) => ({ ...structuredClone(node), id: copied.get(node.id)! })),
+ ];
+ const edges = graph.edges.map((edge) =>
+ mine.has(edge.source) && !other.has(edge.source) && copied.has(edge.target)
+ ? { ...edge, target: copied.get(edge.target)! }
+ : edge,
+ );
+ for (const edge of graph.edges)
+ if (copied.has(edge.source))
+ edges.push(
+ graphEdge(
+ copied.get(edge.source)!,
+ edge.port,
+ copied.get(edge.target) ?? edge.target,
+ ),
+ );
+ return { graph: { nodes, edges }, copied };
+}
+
+export function disabledBypasses(
+ graph: RoutingGraph,
+): { source: string; target: string; port: string; skipped: string[] }[] {
+ const result: {
+ source: string;
+ target: string;
+ port: string;
+ skipped: string[];
+ }[] = [];
+ for (const edge of graph.edges) {
+ const source = graph.nodes.find((node) => node.id === edge.source);
+ if (!source?.enabled || (source.kind !== "call" && source.kind !== "entry"))
+ continue;
+ let target = graph.nodes.find((node) => node.id === edge.target);
+ const skipped: string[] = [];
+ while (
+ target?.kind === "call" &&
+ !target.enabled &&
+ !skipped.includes(target.id)
+ ) {
+ skipped.push(target.id);
+ const next = graph.edges.find(
+ (edge) => edge.source === target?.id && edge.port === "failure",
+ );
+ target = graph.nodes.find((node) => node.id === next?.target);
+ }
+ if (target && skipped.length && !skipped.includes(target.id))
+ result.push({
+ source: edge.source,
+ target: target.id,
+ port: edge.port,
+ skipped,
+ });
+ }
+ return result;
+}
diff --git a/apps/desktop/src/styles/globals.css b/apps/desktop/src/styles/globals.css
index 3879a91..39e2b89 100644
--- a/apps/desktop/src/styles/globals.css
+++ b/apps/desktop/src/styles/globals.css
@@ -1,5 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
+@import "@xyflow/react/dist/style.css";
+@import "./routing-graph.css";
/* Native scrollbar pseudo-elements cannot reliably transition their own paint.
* Animate this inherited value on the scrollport instead. */
diff --git a/apps/desktop/src/styles/routing-graph.css b/apps/desktop/src/styles/routing-graph.css
new file mode 100644
index 0000000..6042018
--- /dev/null
+++ b/apps/desktop/src/styles/routing-graph.css
@@ -0,0 +1,264 @@
+.routing-workspace {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+ flex-direction: column;
+ overflow: hidden;
+ gap: 8px;
+}
+.routing-toolbar {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ gap: 12px;
+ min-width: 0;
+}
+.routing-save-status {
+ font-size: 11px;
+ color: var(--muted-foreground);
+ margin-right: 6px;
+ white-space: nowrap;
+}
+.routing-body {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+ position: relative;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+}
+.routing-canvas {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+ background: var(--background);
+}
+.routing-canvas-tools {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ right: 12px;
+ z-index: 5;
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ pointer-events: none;
+}
+.routing-canvas-tools > * {
+ pointer-events: auto;
+ background: var(--background);
+}
+.routing-canvas-caption {
+ position: absolute;
+ bottom: 12px;
+ left: 54px;
+ right: 16px;
+ font-size: 10px;
+ color: var(--muted-foreground);
+ pointer-events: none;
+}
+.routing-empty {
+ position: absolute;
+ inset: 68px 20px 40px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ pointer-events: none;
+}
+.routing-empty button {
+ pointer-events: auto;
+}
+.routing-inspector {
+ width: 302px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ border-radius: 0;
+ border: 0;
+ border-left: 1px solid var(--border);
+ background: var(--background);
+ z-index: 10;
+}
+.routing-trace-panel {
+ display: flex;
+ flex-direction: column;
+ flex: 0 1 172px;
+ min-height: 0;
+ max-height: 20%;
+}
+.routing-node {
+ width: 250px;
+ background: var(--card);
+ color: var(--card-foreground);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 2px 6px color-mix(in srgb, var(--foreground) 5%, transparent);
+ transition:
+ border-color 140ms,
+ box-shadow 140ms;
+}
+.routing-node[data-selected="true"] {
+ border-color: var(--primary);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 14%, transparent);
+}
+.routing-node[data-kind="entry"] {
+ border-top: 3px solid var(--primary);
+}
+.routing-node[data-kind="condition"] {
+ border-top: 3px solid var(--warning-foreground);
+}
+.routing-node[data-kind="stop"] {
+ border-top: 3px solid var(--muted-foreground);
+}
+.routing-node[data-disabled="true"] {
+ border-style: dashed;
+ background: var(--muted);
+ box-shadow: none;
+}
+.routing-node[data-disabled="true"] .routing-node-title,
+.routing-node[data-disabled="true"] .routing-node-service {
+ color: var(--muted-foreground);
+}
+.routing-node[data-status="succeeded"] {
+ border-color: var(--success-foreground);
+}
+.routing-node[data-status="failed"] {
+ border-color: var(--danger-foreground);
+}
+.routing-node-kicker {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 10px 12px 0;
+ color: var(--muted-foreground);
+ font-size: 10px;
+ min-height: 29px;
+}
+.routing-node-shared {
+ font-size: 9px;
+ border: 1px solid var(--border);
+ padding: 1px 4px;
+ border-radius: var(--radius-sm);
+}
+.routing-node-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 9px 12px 5px;
+ font-size: 13px;
+ min-height: 36px;
+}
+.routing-node-title strong {
+ font-weight: 600;
+}
+.routing-node-service {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 1px 12px 10px;
+ font-size: 11px;
+ color: var(--muted-foreground);
+}
+.routing-node-footer {
+ padding: 5px 10px 5px 12px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 33px;
+}
+.routing-node-status {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 10px;
+ color: var(--muted-foreground);
+}
+
+.routing-node-ports {
+ padding: 3px 0 8px;
+}
+.routing-node-branch {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ position: relative;
+ padding: 7px 13px 7px 34px;
+ font-size: 11px;
+ min-height: 30px;
+ color: var(--text-secondary);
+}
+.routing-node-branch + .routing-node-branch {
+ border-top: 1px solid var(--border);
+}
+.routing-workspace .react-flow__handle {
+ width: 10px;
+ height: 10px;
+ border: 2px solid var(--background);
+ background: var(--muted-foreground);
+}
+.routing-workspace .react-flow__handle:hover {
+ background: var(--primary);
+ width: 12px;
+ height: 12px;
+}
+.routing-workspace .react-flow__controls {
+ box-shadow: none;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ margin: 12px;
+}
+.routing-workspace .react-flow__controls-button {
+ background: var(--background);
+ fill: var(--foreground);
+ border-bottom-color: var(--border);
+}
+.routing-workspace .react-flow__controls-button:hover {
+ background: var(--muted);
+}
+.routing-workspace .react-flow__minimap {
+ background: var(--background);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ width: 130px;
+ height: 85px;
+}
+.routing-workspace .react-flow__attribution {
+ background: transparent;
+ color: var(--muted-foreground);
+ font-size: 8px;
+}
+@media (max-width: 1100px) {
+ .routing-inspector {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ width: min(302px, 86%);
+ box-shadow: -6px 0 20px
+ color-mix(in srgb, var(--foreground) 8%, transparent);
+ }
+}
+@media (max-width: 760px) {
+ .routing-save-status {
+ display: none;
+ }
+ .routing-toolbar {
+ gap: 4px;
+ }
+ .routing-canvas-caption {
+ display: none;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .routing-node {
+ transition: none;
+ }
+}
diff --git a/apps/desktop/src/use-routing-graph.test.tsx b/apps/desktop/src/use-routing-graph.test.tsx
new file mode 100644
index 0000000..d06189d
--- /dev/null
+++ b/apps/desktop/src/use-routing-graph.test.tsx
@@ -0,0 +1,115 @@
+// @vitest-environment happy-dom
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, expect, it, vi } from "vitest";
+import type { GraphDocument } from "./routing-graph-model";
+
+const bridge = vi.hoisted(() => ({
+ getRoutingGraph: vi.fn(),
+ saveRoutingGraph: vi.fn(),
+}));
+vi.mock("./bridge", () => bridge);
+import { useRoutingGraph } from "./use-routing-graph";
+
+const documentFor = (
+ model = "public",
+ etag = "version-one",
+): GraphDocument => ({
+ draft: {
+ nodes: [{ id: "entry_public", kind: "entry", enabled: true, model }],
+ edges: [],
+ },
+ active: { nodes: [], edges: [] },
+ layout: {},
+ revision: 0,
+ etag,
+ history: [],
+});
+let current: ReturnType;
+const dirty = () => {};
+function Harness() {
+ current = useRoutingGraph(true, dirty);
+ return {current.dirty ? "dirty" : "saved"};
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.clearAllMocks();
+});
+
+it("accepts canonical saved fields without an endless autosave loop", async () => {
+ vi.useFakeTimers();
+ bridge.getRoutingGraph.mockResolvedValue(documentFor());
+ bridge.saveRoutingGraph.mockResolvedValue(documentFor());
+ const container = document.createElement("div"),
+ root = createRoot(container);
+ try {
+ await act(async () => root.render());
+ await act(async () =>
+ current.edit((draft) => ({
+ ...draft,
+ graph: {
+ ...draft.graph,
+ nodes: draft.graph.nodes.map((node) => ({
+ ...node,
+ max_attempts: 0,
+ })),
+ },
+ })),
+ );
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(600);
+ });
+ expect(bridge.saveRoutingGraph).toHaveBeenCalledTimes(1);
+ expect(current.dirty).toBe(false);
+ expect(current.draft.graph.nodes[0].max_attempts).toBeUndefined();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2400);
+ });
+ expect(bridge.saveRoutingGraph).toHaveBeenCalledTimes(1);
+ } finally {
+ await act(async () => root.unmount());
+ }
+});
+
+it("preserves edits made while saving and uses the acknowledged version next", async () => {
+ vi.useFakeTimers();
+ bridge.getRoutingGraph.mockResolvedValue(documentFor());
+ let acknowledge!: (value: GraphDocument) => void;
+ const pending = new Promise((resolve) => {
+ acknowledge = resolve;
+ });
+ bridge.saveRoutingGraph
+ .mockImplementationOnce(() => pending)
+ .mockResolvedValue(documentFor("newer", "version-three"));
+ const container = document.createElement("div"),
+ root = createRoot(container);
+ const editModel = (model: string) =>
+ current.edit((draft) => ({
+ ...draft,
+ graph: {
+ ...draft.graph,
+ nodes: draft.graph.nodes.map((node) => ({ ...node, model })),
+ },
+ }));
+ try {
+ await act(async () => root.render());
+ await act(async () => editModel("first"));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(600);
+ });
+ await act(async () => editModel("newer"));
+ await act(async () => acknowledge(documentFor("first", "version-two")));
+ expect(current.draft.graph.nodes[0].model).toBe("newer");
+ expect(current.dirty).toBe(true);
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(600);
+ });
+ expect(bridge.saveRoutingGraph).toHaveBeenCalledTimes(2);
+ expect(bridge.saveRoutingGraph.mock.calls[1][2]).toBe("version-two");
+ expect(current.draft.graph.nodes[0].model).toBe("newer");
+ expect(current.dirty).toBe(false);
+ } finally {
+ await act(async () => root.unmount());
+ }
+});
diff --git a/apps/desktop/src/use-routing-graph.ts b/apps/desktop/src/use-routing-graph.ts
new file mode 100644
index 0000000..f610028
--- /dev/null
+++ b/apps/desktop/src/use-routing-graph.ts
@@ -0,0 +1,192 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { getRoutingGraph, saveRoutingGraph } from "./bridge";
+import { useWorkspaceSnapshot } from "./workspace-snapshots";
+import {
+ emptyGraph,
+ type GraphDocument,
+ type GraphLayout,
+ type RoutingGraph,
+} from "./routing-graph-model";
+
+export interface GraphDraft {
+ graph: RoutingGraph;
+ layout: GraphLayout;
+}
+const signature = (draft: GraphDraft) => JSON.stringify(draft);
+
+export function useRoutingGraph(
+ ready: boolean,
+ onDirtyChange: (dirty: boolean) => void,
+) {
+ // Cache acknowledged documents only; unpersisted edits remain local.
+ const [cached, setCached] = useWorkspaceSnapshot(
+ "routing-graph",
+ null,
+ );
+ const [document, setDocument] = useState(cached);
+ const [draft, setDraft] = useState(() => ({
+ graph: cached?.draft ?? emptyGraph(),
+ layout: cached?.layout ?? {},
+ }));
+ const [baseline, setBaseline] = useState(() =>
+ cached ? signature({ graph: cached.draft, layout: cached.layout }) : "",
+ );
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [past, setPast] = useState([]);
+ const [future, setFuture] = useState([]);
+ const latest = useRef(draft);
+ latest.current = draft;
+ const docRef = useRef(document);
+ docRef.current = document;
+ const busy = useRef(false);
+ const loadVersion = useRef(0);
+ const mutationVersion = useRef(0);
+ const mounted = useRef(true);
+ const dirty = document !== null && signature(draft) !== baseline;
+ const unpublished =
+ document !== null &&
+ JSON.stringify(draft.graph) !== JSON.stringify(document.active);
+
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ loadVersion.current++;
+ };
+ }, []);
+ useEffect(() => {
+ onDirtyChange(dirty);
+ return () => onDirtyChange(false);
+ }, [dirty, onDirtyChange]);
+
+ const reload = useCallback(async () => {
+ const version = ++loadVersion.current;
+ const mutations = mutationVersion.current;
+ try {
+ const doc = await getRoutingGraph();
+ if (
+ !mounted.current ||
+ version !== loadVersion.current ||
+ mutations !== mutationVersion.current
+ )
+ return;
+ const loaded = { graph: doc.draft, layout: doc.layout };
+ docRef.current = doc;
+ latest.current = loaded;
+ setDocument(doc);
+ setCached(doc);
+ setDraft(loaded);
+ setBaseline(signature(loaded));
+ setPast([]);
+ setFuture([]);
+ setError(null);
+ } catch (error) {
+ if (mounted.current && version === loadVersion.current)
+ setError(String(error));
+ }
+ }, [setCached]);
+ useEffect(() => {
+ if (ready) void reload();
+ }, [ready, reload]);
+
+ const persist = useCallback(
+ async (apply = false) => {
+ if (!ready || !docRef.current || busy.current) return false;
+ busy.current = true;
+ mutationVersion.current++;
+ setSaving(true);
+ setError(null);
+ const submitted = latest.current,
+ version = loadVersion.current;
+ try {
+ const saved = await saveRoutingGraph(
+ submitted.graph,
+ submitted.layout,
+ docRef.current.etag,
+ apply,
+ );
+ if (!mounted.current || loadVersion.current !== version) return false;
+ docRef.current = saved;
+ setDocument(saved);
+ setCached(saved);
+ const acknowledged = { graph: saved.draft, layout: saved.layout };
+ // Go omits empty optional fields. Adopt its canonical document only if
+ // no newer edit exists; otherwise the next save keeps those newer edits.
+ if (latest.current === submitted) {
+ latest.current = acknowledged;
+ setDraft(acknowledged);
+ }
+ setBaseline(signature(acknowledged));
+ return true;
+ } catch (error) {
+ if (mounted.current) setError(String(error));
+ return false;
+ } finally {
+ busy.current = false;
+ if (mounted.current) setSaving(false);
+ }
+ },
+ [ready, setCached],
+ );
+
+ useEffect(() => {
+ if (!dirty || saving || error || !ready) return;
+ const timer = setTimeout(() => void persist(), 600);
+ return () => clearTimeout(timer);
+ }, [dirty, draft, saving, error, ready, persist]);
+
+ const edit = useCallback(
+ (change: (draft: GraphDraft) => GraphDraft, remember = true) => {
+ const previous = latest.current,
+ next = change(previous);
+ if (signature(previous) === signature(next)) return;
+ mutationVersion.current++;
+ if (remember) {
+ setPast((items) => [...items.slice(-49), previous]);
+ setFuture([]);
+ }
+ latest.current = next;
+ setDraft(next);
+ setError(null);
+ },
+ [],
+ );
+ const undo = () => {
+ const previous = past.at(-1);
+ if (!previous) return;
+ mutationVersion.current++;
+ setFuture((items) => [latest.current, ...items]);
+ setPast(past.slice(0, -1));
+ latest.current = previous;
+ setDraft(previous);
+ setError(null);
+ };
+ const redo = () => {
+ const next = future[0];
+ if (!next) return;
+ mutationVersion.current++;
+ setPast((items) => [...items, latest.current]);
+ setFuture(future.slice(1));
+ latest.current = next;
+ setDraft(next);
+ setError(null);
+ };
+ return {
+ document,
+ draft,
+ dirty,
+ unpublished,
+ saving,
+ error,
+ setError,
+ edit,
+ undo,
+ redo,
+ canUndo: past.length > 0,
+ canRedo: future.length > 0,
+ apply: () => persist(true),
+ retry: () => persist(),
+ reload,
+ };
+}
diff --git a/contracts/control-api.openapi.yaml b/contracts/control-api.openapi.yaml
index 898588c..3d9275e 100644
--- a/contracts/control-api.openapi.yaml
+++ b/contracts/control-api.openapi.yaml
@@ -2060,6 +2060,113 @@ paths:
$ref: "#/components/schemas/RecoveryPreview"
"422":
$ref: "#/components/responses/UnprocessableEntity"
+ /control/v1/routing-graph:
+ x-astrlink-implementation-status: implemented
+ get:
+ tags: [Routes]
+ operationId: getRoutingGraph
+ summary: Read the routing draft and active revision, or an immutable historical graph.
+ parameters:
+ - name: revision
+ in: query
+ schema: { type: integer, minimum: 1 }
+ responses:
+ "200":
+ description: Routing document, or the requested historical graph.
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - { $ref: "#/components/schemas/RoutingGraphDocument" }
+ - { $ref: "#/components/schemas/RoutingGraph" }
+ "401": { $ref: "#/components/responses/Unauthorized" }
+ put:
+ tags: [Routes]
+ operationId: saveRoutingGraph
+ summary: Save a draft or atomically apply a validated graph. Layout never changes execution.
+ parameters:
+ - name: If-Match
+ in: header
+ required: true
+ schema: { type: string }
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [graph, layout, apply]
+ properties:
+ graph: { $ref: "#/components/schemas/RoutingGraph" }
+ layout: { $ref: "#/components/schemas/RoutingGraphLayout" }
+ apply: { type: boolean }
+ additionalProperties: false
+ responses:
+ "200":
+ description: Saved routing document. In-flight requests retain their graph snapshot.
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/RoutingGraphDocument" }
+ "401": { $ref: "#/components/responses/Unauthorized" }
+ "412":
+ {
+ description: The draft or active revision changed; reload before saving.,
+ }
+ "422": { $ref: "#/components/responses/UnprocessableEntity" }
+ "428": { description: If-Match is required. }
+ /control/v1/routing-graph/preview:
+ x-astrlink-implementation-status: implemented
+ post:
+ tags: [Routes]
+ operationId: previewRoutingGraph
+ summary: Simulate the inference scheduler without network requests or health mutations.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [graph, entry_id, facts, outcomes]
+ properties:
+ graph: { $ref: "#/components/schemas/RoutingGraph" }
+ entry_id: { type: string }
+ facts:
+ type: object
+ properties:
+ protocol: { type: string }
+ streaming: { type: boolean }
+ has_tools: { type: boolean }
+ has_images: { type: boolean }
+ quota:
+ {
+ type: object,
+ additionalProperties:
+ { $ref: "#/components/schemas/SubscriptionUsage" },
+ }
+ required: [protocol, streaming]
+ additionalProperties: false
+ outcomes:
+ { type: object, additionalProperties: { type: string } }
+ additionalProperties: false
+ responses:
+ "200":
+ description: Decision trace including disabled-node skips and actual attempt counts.
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [steps, stop_reason, attempts]
+ properties:
+ steps:
+ {
+ type: array,
+ items: { $ref: "#/components/schemas/RoutingGraphStep" },
+ }
+ stop_reason: { type: string }
+ attempts: { type: integer, minimum: 0 }
+ additionalProperties: false
+ "401": { $ref: "#/components/responses/Unauthorized" }
+ "422": { $ref: "#/components/responses/UnprocessableEntity" }
/control/v1/routing-settings:
x-astrlink-implementation-status: implemented
get:
@@ -3567,10 +3674,171 @@ components:
minimum: 1
maximum: 20
additionalProperties: false
+ RoutingGraph:
+ type: object
+ required: [nodes, edges]
+ properties:
+ nodes:
+ type: array
+ maxItems: 512
+ items:
+ type: object
+ required: [id, kind, enabled]
+ properties:
+ id: { type: string, minLength: 3, maxLength: 96 }
+ kind: { type: string, enum: [entry, call, condition, stop] }
+ enabled:
+ type: boolean
+ description: Disabled calls preserve their failure edge and are skipped without spending an inference attempt. Paused entries reject requests.
+ name: { type: string, maxLength: 512 }
+ model:
+ {
+ type: string,
+ maxLength: 256,
+ description: Exact public model name for an entry.,
+ }
+ service_id: { type: string }
+ upstream_model:
+ {
+ type: string,
+ maxLength: 256,
+ description: Explicit actual model for a call; cross-model mapping is allowed.,
+ }
+ max_attempts: { type: integer, minimum: 0, maximum: 20 }
+ failure_policy: { $ref: "#/components/schemas/FailurePolicy" }
+ unknown_port: { type: string, default: otherwise }
+ rules:
+ type: array
+ maxItems: 32
+ items:
+ type: object
+ required: [id, predicate]
+ properties:
+ id: { type: string }
+ label: { type: string }
+ predicate: { $ref: "#/components/schemas/RoutingPredicate" }
+ additionalProperties: false
+ additionalProperties: false
+ edges:
+ type: array
+ maxItems: 1024
+ items:
+ type: object
+ required: [id, source, port, target]
+ properties:
+ id: { type: string }
+ source: { type: string }
+ port:
+ {
+ type: string,
+ description: next for entries; failure for calls; rule ID,
+ otherwise or unknown for conditions.,
+ }
+ target: { type: string }
+ additionalProperties: false
+ additionalProperties: false
+ RoutingPredicate:
+ type: object
+ description: Bounded three-valued expression. Exactly one of field, all or any. Maximum nesting depth is four.
+ properties:
+ all:
+ {
+ type: array,
+ minItems: 1,
+ maxItems: 16,
+ items: { $ref: "#/components/schemas/RoutingPredicate" },
+ }
+ any:
+ {
+ type: array,
+ minItems: 1,
+ maxItems: 16,
+ items: { $ref: "#/components/schemas/RoutingPredicate" },
+ }
+ field:
+ {
+ type: string,
+ enum:
+ [
+ request.model,
+ request.protocol,
+ request.streaming,
+ request.has_tools,
+ request.has_images,
+ last.status,
+ last.error,
+ attempts,
+ quota.exhausted,
+ quota.used_percent,
+ ],
+ }
+ operator: { type: string, enum: [eq, ne, gt, gte, lt, lte] }
+ value:
+ oneOf:
+ [
+ { type: string, maxLength: 256 },
+ { type: boolean },
+ { type: number },
+ ]
+ service_id: { type: string }
+ window: { type: string, enum: [primary, secondary] }
+ additionalProperties: false
+ RoutingGraphLayout:
+ type: object
+ additionalProperties:
+ type: object
+ required: [x, y]
+ properties:
+ x: { type: number, minimum: -1000000, maximum: 1000000 }
+ y: { type: number, minimum: -1000000, maximum: 1000000 }
+ additionalProperties: false
+ RoutingGraphDocument:
+ type: object
+ required: [draft, layout, active, revision, etag, history]
+ properties:
+ draft: { $ref: "#/components/schemas/RoutingGraph" }
+ layout: { $ref: "#/components/schemas/RoutingGraphLayout" }
+ active: { $ref: "#/components/schemas/RoutingGraph" }
+ revision: { type: integer, minimum: 0 }
+ etag: { type: string }
+ history:
+ type: array
+ items:
+ type: object
+ required: [revision, created_at]
+ properties:
+ revision: { type: integer, minimum: 1 }
+ created_at: { type: string, format: date-time }
+ additionalProperties: false
+ additionalProperties: false
+ RoutingGraphStep:
+ type: object
+ required: [node_id, kind, status]
+ properties:
+ node_id: { type: string }
+ kind: { type: string, enum: [entry, call, condition, stop] }
+ status:
+ {
+ type: string,
+ enum: [selected, skipped, attempted, succeeded, failed, stopped],
+ }
+ reason: { type: string }
+ port: { type: string }
+ service_id: { type: string }
+ model: { type: string }
+ additionalProperties: false
RequestRecovery:
type: object
required: [delay_ms]
properties:
+ graph_revision: { type: integer, minimum: 0 }
+ graph_entry_id: { type: string }
+ graph_trace:
+ {
+ type: array,
+ maxItems: 2048,
+ items: { $ref: "#/components/schemas/RoutingGraphStep" },
+ }
path_id: { type: string, maxLength: 128 }
path_name: { type: string, maxLength: 128 }
path_version: { type: string, maxLength: 128 }
diff --git a/core/contract/request_recovery.go b/core/contract/request_recovery.go
index 5d6603f..0db3a39 100644
--- a/core/contract/request_recovery.go
+++ b/core/contract/request_recovery.go
@@ -4,18 +4,37 @@ import "fmt"
// RequestRecovery is metadata only; it never includes request/error bodies.
type RequestRecovery struct {
- PathID RecoveryPathID `json:"path_id,omitempty"`
- PathName string `json:"path_name,omitempty"`
- PathVersion string `json:"path_version,omitempty"`
- StepID string `json:"step_id,omitempty"`
- UpstreamModel string `json:"upstream_model,omitempty"`
- Action string `json:"action,omitempty"`
- Reason string `json:"reason,omitempty"`
- DelayMS int `json:"delay_ms"`
- StopReason string `json:"stop_reason,omitempty"`
+ GraphRevision int64 `json:"graph_revision,omitempty"`
+ GraphEntryID string `json:"graph_entry_id,omitempty"`
+ GraphTrace []RoutingGraphStep `json:"graph_trace,omitempty"`
+ PathID RecoveryPathID `json:"path_id,omitempty"`
+ PathName string `json:"path_name,omitempty"`
+ PathVersion string `json:"path_version,omitempty"`
+ StepID string `json:"step_id,omitempty"`
+ UpstreamModel string `json:"upstream_model,omitempty"`
+ Action string `json:"action,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ DelayMS int `json:"delay_ms"`
+ StopReason string `json:"stop_reason,omitempty"`
}
func (recovery RequestRecovery) Validate() error {
+ if recovery.GraphRevision < 0 || len(recovery.GraphTrace) > 2048 {
+ return fmt.Errorf("invalid routing graph trace")
+ }
+ if recovery.GraphEntryID != "" {
+ if err := validateResourceID("graph entry", recovery.GraphEntryID); err != nil {
+ return err
+ }
+ }
+ for _, step := range recovery.GraphTrace {
+ if err := validateBoundedText("graph node", step.NodeID, 128, false); err != nil {
+ return err
+ }
+ if len(step.Reason) > 512 || len(step.Port) > 128 || len(step.Model) > 1024 {
+ return fmt.Errorf("graph trace text exceeds limit")
+ }
+ }
if recovery.PathID != "" {
if err := recovery.PathID.Validate(); err != nil {
return err
diff --git a/core/contract/routing_graph.go b/core/contract/routing_graph.go
new file mode 100644
index 0000000..bbae644
--- /dev/null
+++ b/core/contract/routing_graph.go
@@ -0,0 +1,391 @@
+package contract
+
+import (
+ "encoding/json"
+ "fmt"
+ "math"
+ "strings"
+)
+
+// RoutingGraph is the executable document. Coordinates never influence routing.
+type RoutingGraph struct {
+ Nodes []RoutingGraphNode `json:"nodes"`
+ Edges []RoutingGraphEdge `json:"edges"`
+}
+
+func (graph *RoutingGraph) UnmarshalJSON(data []byte) error {
+ type document RoutingGraph
+ var value document
+ if err := decodeStrictContractJSON(data, &value); err != nil {
+ return err
+ }
+ if value.Nodes == nil || value.Edges == nil {
+ return fmt.Errorf("graph nodes and edges must be arrays")
+ }
+ *graph = RoutingGraph(value)
+ return graph.ValidateDraft(nil)
+}
+
+type RoutingGraphNode struct {
+ ID string `json:"id"`
+ Kind string `json:"kind"`
+ Name string `json:"name,omitempty"`
+ Enabled bool `json:"enabled"`
+ Model string `json:"model,omitempty"`
+ ServiceID ServiceID `json:"service_id,omitempty"`
+ UpstreamModel string `json:"upstream_model,omitempty"`
+ MaxAttempts int `json:"max_attempts,omitempty"`
+ FailurePolicy *FailurePolicy `json:"failure_policy,omitempty"`
+ Rules []RoutingGraphRule `json:"rules,omitempty"`
+ UnknownPort string `json:"unknown_port,omitempty"`
+}
+
+type RoutingGraphRule struct {
+ ID string `json:"id"`
+ Label string `json:"label,omitempty"`
+ Predicate RoutingPredicate `json:"predicate"`
+}
+
+type RoutingPredicate struct {
+ All []RoutingPredicate `json:"all,omitempty"`
+ Any []RoutingPredicate `json:"any,omitempty"`
+ Field string `json:"field,omitempty"`
+ Operator string `json:"operator,omitempty"`
+ Value json.RawMessage `json:"value,omitempty"`
+ ServiceID ServiceID `json:"service_id,omitempty"`
+ Window string `json:"window,omitempty"`
+}
+
+type RoutingGraphEdge struct {
+ ID string `json:"id"`
+ Source string `json:"source"`
+ Port string `json:"port"`
+ Target string `json:"target"`
+}
+
+type RoutingPosition struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+type RoutingLayout map[string]RoutingPosition
+
+type RoutingGraphRevision struct {
+ Revision int64 `json:"revision"`
+ CreatedAt string `json:"created_at"`
+}
+
+type RoutingGraphDocument struct {
+ Draft RoutingGraph `json:"draft"`
+ Layout RoutingLayout `json:"layout"`
+ Active RoutingGraph `json:"active"`
+ Revision int64 `json:"revision"`
+ ETag string `json:"etag"`
+ History []RoutingGraphRevision `json:"history"`
+}
+
+type RoutingGraphStep struct {
+ NodeID string `json:"node_id"`
+ Kind string `json:"kind"`
+ Status string `json:"status"`
+ Reason string `json:"reason,omitempty"`
+ Port string `json:"port,omitempty"`
+ ServiceID ServiceID `json:"service_id,omitempty"`
+ Model string `json:"model,omitempty"`
+}
+
+func EmptyRoutingGraph() RoutingGraph {
+ return RoutingGraph{Nodes: []RoutingGraphNode{}, Edges: []RoutingGraphEdge{}}
+}
+
+func (graph RoutingGraph) Entry(model string) (RoutingGraphNode, bool) {
+ for _, node := range graph.Nodes {
+ if node.Kind == "entry" && node.Model == model {
+ return node, true
+ }
+ }
+ return RoutingGraphNode{}, false
+}
+
+func (graph RoutingGraph) Reachable(entry string) map[string]bool {
+ seen := map[string]bool{}
+ var visit func(string)
+ visit = func(id string) {
+ if seen[id] {
+ return
+ }
+ seen[id] = true
+ for _, edge := range graph.Edges {
+ if edge.Source == id {
+ visit(edge.Target)
+ }
+ }
+ }
+ visit(entry)
+ return seen
+}
+
+func (graph RoutingGraph) ValidateDraft(layout RoutingLayout) error {
+ if len(graph.Nodes) > 512 || len(graph.Edges) > 1024 {
+ return fmt.Errorf("graph exceeds 512 nodes or 1024 edges")
+ }
+ ids := map[string]bool{}
+ for _, node := range graph.Nodes {
+ if err := validateResourceID("node", node.ID); err != nil {
+ return err
+ }
+ if ids[node.ID] {
+ return fmt.Errorf("duplicate node %s", node.ID)
+ }
+ ids[node.ID] = true
+ switch node.Kind {
+ case "entry", "call", "condition", "stop":
+ default:
+ return fmt.Errorf("unknown node kind %q", node.Kind)
+ }
+ if len(node.Name) > 512 || len(node.Model) > 1024 || len(node.UpstreamModel) > 1024 || len(node.Rules) > 32 || len(node.ServiceID) > 96 || len(node.UnknownPort) > 96 {
+ return fmt.Errorf("node %s exceeds field limits", node.ID)
+ }
+ if node.FailurePolicy != nil {
+ if err := node.FailurePolicy.Validate(); err != nil {
+ return fmt.Errorf("node %s: %w", node.ID, err)
+ }
+ }
+ for _, rule := range node.Rules {
+ if len(rule.ID) > 96 || len(rule.Label) > 256 {
+ return fmt.Errorf("condition rule exceeds field limits")
+ }
+ if err := rule.Predicate.validate(0); err != nil {
+ return fmt.Errorf("node %s: %w", node.ID, err)
+ }
+ }
+ }
+ edgeIDs := map[string]bool{}
+ for _, edge := range graph.Edges {
+ if err := validateResourceID("edge", edge.ID); err != nil {
+ return err
+ }
+ if edgeIDs[edge.ID] {
+ return fmt.Errorf("duplicate edge %s", edge.ID)
+ }
+ edgeIDs[edge.ID] = true
+ if len(edge.Source) > 128 || len(edge.Target) > 128 || len(edge.Port) > 128 {
+ return fmt.Errorf("edge fields too long")
+ }
+ }
+ if len(layout) > 512 {
+ return fmt.Errorf("layout exceeds node limit")
+ }
+ for _, p := range layout {
+ if math.IsNaN(p.X) || math.IsNaN(p.Y) || math.IsInf(p.X, 0) || math.IsInf(p.Y, 0) || math.Abs(p.X) > 1e6 || math.Abs(p.Y) > 1e6 {
+ return fmt.Errorf("invalid node position")
+ }
+ }
+ return nil
+}
+
+// Validate checks every enabled entry and its reachable nodes. Unconnected work
+// can remain in the draft without changing executable paths.
+func (graph RoutingGraph) Validate() error {
+ if err := graph.ValidateDraft(nil); err != nil {
+ return err
+ }
+ nodes := map[string]RoutingGraphNode{}
+ models := map[string]bool{}
+ reachable := map[string]bool{}
+ for _, node := range graph.Nodes {
+ nodes[node.ID] = node
+ if node.Kind != "entry" {
+ continue
+ }
+ if strings.TrimSpace(node.Model) == "" || strings.TrimSpace(node.Model) != node.Model || len(node.Model) > 256 {
+ return fmt.Errorf("entry %s requires an exact model name", node.ID)
+ }
+ if models[node.Model] {
+ return fmt.Errorf("duplicate entry model %s", node.Model)
+ }
+ models[node.Model] = true
+ if node.MaxAttempts < 0 || node.MaxAttempts > 20 {
+ return fmt.Errorf("entry %s max_attempts must be 1–20 or inherited", node.ID)
+ }
+ if node.Enabled {
+ for id := range graph.Reachable(node.ID) {
+ reachable[id] = true
+ }
+ }
+ }
+ ports := map[string]map[string]string{}
+ for _, edge := range graph.Edges {
+ if !reachable[edge.Source] {
+ continue
+ }
+ source, ok := nodes[edge.Source]
+ if !ok {
+ return fmt.Errorf("missing source %s", edge.Source)
+ }
+ target, ok := nodes[edge.Target]
+ if !ok {
+ return fmt.Errorf("missing target %s", edge.Target)
+ }
+ if target.Kind == "entry" {
+ return fmt.Errorf("edges cannot enter model entries")
+ }
+ valid := source.Kind == "entry" && edge.Port == "next" || source.Kind == "call" && edge.Port == "failure"
+ if source.Kind == "condition" {
+ valid = edge.Port == "otherwise" || edge.Port == "unknown"
+ for _, rule := range source.Rules {
+ valid = valid || edge.Port == rule.ID
+ }
+ }
+ if !valid {
+ return fmt.Errorf("invalid port %s on node %s", edge.Port, source.ID)
+ }
+ if ports[source.ID] == nil {
+ ports[source.ID] = map[string]string{}
+ }
+ if ports[source.ID][edge.Port] != "" {
+ return fmt.Errorf("port %s on %s has multiple destinations", edge.Port, source.ID)
+ }
+ ports[source.ID][edge.Port] = edge.Target
+ }
+ for id := range reachable {
+ node, ok := nodes[id]
+ if !ok {
+ return fmt.Errorf("missing node %s", id)
+ }
+ switch node.Kind {
+ case "entry":
+ if ports[id]["next"] == "" {
+ return fmt.Errorf("entry %s has no destination", node.Model)
+ }
+ case "call":
+ if !node.Enabled {
+ continue
+ }
+ if err := node.ServiceID.Validate(); err != nil {
+ return fmt.Errorf("call %s requires a service", id)
+ }
+ if strings.TrimSpace(node.UpstreamModel) == "" || strings.TrimSpace(node.UpstreamModel) != node.UpstreamModel || len(node.UpstreamModel) > 256 {
+ return fmt.Errorf("call %s requires an upstream model", id)
+ }
+ case "condition":
+ if ports[id]["otherwise"] == "" {
+ return fmt.Errorf("condition %s needs an otherwise destination", id)
+ }
+ unknown := node.UnknownPort
+ if unknown == "" {
+ unknown = "otherwise"
+ }
+ if ports[id][unknown] == "" {
+ return fmt.Errorf("condition %s needs an unknown destination", id)
+ }
+ rules := map[string]bool{}
+ for _, rule := range node.Rules {
+ if err := validateResourceID("rule", rule.ID); err != nil {
+ return err
+ }
+ if rule.ID == "otherwise" || rule.ID == "unknown" || rules[rule.ID] {
+ return fmt.Errorf("invalid or duplicate condition rule")
+ }
+ rules[rule.ID] = true
+ if ports[id][rule.ID] == "" {
+ return fmt.Errorf("rule %s has no destination", rule.ID)
+ }
+ }
+ }
+ }
+ colors := map[string]int{}
+ var visit func(string) error
+ visit = func(id string) error {
+ if colors[id] == 1 {
+ return fmt.Errorf("routing graph contains a cycle at %s", id)
+ }
+ if colors[id] == 2 {
+ return nil
+ }
+ colors[id] = 1
+ for _, to := range ports[id] {
+ if err := visit(to); err != nil {
+ return err
+ }
+ }
+ colors[id] = 2
+ return nil
+ }
+ for id := range reachable {
+ if err := visit(id); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (p RoutingPredicate) validate(depth int) error {
+ if depth > 4 || len(p.All)+len(p.Any) > 16 {
+ return fmt.Errorf("condition nesting exceeds limit")
+ }
+ groups := 0
+ if p.All != nil {
+ groups++
+ }
+ if p.Any != nil {
+ groups++
+ }
+ if p.Field != "" {
+ groups++
+ }
+ if groups != 1 {
+ return fmt.Errorf("condition must contain exactly one field, all or any")
+ }
+ if p.All != nil || p.Any != nil {
+ children := p.All
+ if p.Any != nil {
+ children = p.Any
+ }
+ if len(children) == 0 {
+ return fmt.Errorf("empty condition group")
+ }
+ for _, child := range children {
+ if err := child.validate(depth + 1); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+ switch p.Field {
+ case "request.model", "request.protocol", "request.streaming", "request.has_tools", "request.has_images", "last.status", "last.error", "attempts", "quota.exhausted", "quota.used_percent":
+ default:
+ return fmt.Errorf("unknown condition field %s", p.Field)
+ }
+ switch p.Operator {
+ case "eq", "ne", "gt", "gte", "lt", "lte":
+ default:
+ return fmt.Errorf("unknown condition operator")
+ }
+ var value any
+ if err := json.Unmarshal(p.Value, &value); err != nil {
+ return fmt.Errorf("invalid condition value")
+ }
+ switch p.Field {
+ case "request.model", "request.protocol", "last.error":
+ if _, ok := value.(string); !ok || len(p.Value) > 256 || p.Operator != "eq" && p.Operator != "ne" {
+ return fmt.Errorf("string condition requires eq/ne and a string")
+ }
+ case "request.streaming", "request.has_tools", "request.has_images", "quota.exhausted":
+ if _, ok := value.(bool); !ok || p.Operator != "eq" && p.Operator != "ne" {
+ return fmt.Errorf("boolean condition requires eq/ne and a boolean")
+ }
+ default:
+ if n, ok := value.(float64); !ok || math.IsNaN(n) || math.IsInf(n, 0) {
+ return fmt.Errorf("numeric condition requires a number")
+ }
+ }
+ if strings.HasPrefix(p.Field, "quota.") {
+ if err := p.ServiceID.Validate(); err != nil {
+ return fmt.Errorf("quota condition requires a service")
+ }
+ if p.Window != "primary" && p.Window != "secondary" {
+ return fmt.Errorf("quota condition requires primary or secondary window")
+ }
+ }
+ return nil
+}
diff --git a/core/internal/controlapi/handler.go b/core/internal/controlapi/handler.go
index 306b51d..9da7e94 100644
--- a/core/internal/controlapi/handler.go
+++ b/core/internal/controlapi/handler.go
@@ -190,6 +190,8 @@ func newHandler(version contract.VersionResponse, dependencies Dependencies) (*H
handler.mux.HandleFunc(ObserversPath, handler.authenticated(handler.getObservers))
handler.mux.HandleFunc(PricingPath+"/", handler.authenticated(handler.pricingResource))
handler.mux.HandleFunc(RoutingSettingsPath, handler.authenticated(handler.routingSettingsResource))
+ handler.mux.HandleFunc(RoutingGraphPath, handler.authenticated(handler.routingGraphResource))
+ handler.mux.HandleFunc(RoutingGraphPath+"/preview", handler.authenticated(handler.previewRoutingGraph))
handler.mux.HandleFunc(HealthPath, handler.getOnly(func(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, contract.HealthResponse{Status: "ok"})
}))
diff --git a/core/internal/controlapi/routing_graph.go b/core/internal/controlapi/routing_graph.go
new file mode 100644
index 0000000..a1e239c
--- /dev/null
+++ b/core/internal/controlapi/routing_graph.go
@@ -0,0 +1,133 @@
+package controlapi
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/QuantumNous/astrlink/core/contract"
+ "github.com/QuantumNous/astrlink/core/internal/endpoint"
+ "github.com/QuantumNous/astrlink/core/internal/ingress"
+ "github.com/QuantumNous/astrlink/core/internal/routinggraph"
+ "github.com/QuantumNous/astrlink/core/internal/storage"
+)
+
+const RoutingGraphPath = "/control/v1/routing-graph"
+
+func (handler *Handler) routingGraphResource(w http.ResponseWriter, r *http.Request) {
+ store, ok := handler.serviceStore.(storage.RoutingGraphStore)
+ if !ok {
+ writeError(w, 503, "routing_graph_unavailable", "routing graph storage is unavailable")
+ return
+ }
+ switch r.Method {
+ case http.MethodGet:
+ if raw := r.URL.Query().Get("revision"); raw != "" {
+ revision, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil || revision < 1 {
+ writeError(w, 400, "invalid_revision", "invalid revision")
+ return
+ }
+ graph, err := store.GetRoutingGraphRevision(r.Context(), revision)
+ if err != nil {
+ handler.writeStoreError(w, err)
+ return
+ }
+ writeJSON(w, 200, graph)
+ return
+ }
+ doc, err := store.GetRoutingGraph(r.Context())
+ if err != nil {
+ handler.writeStoreError(w, err)
+ return
+ }
+ w.Header().Set("ETag", doc.ETag)
+ writeJSON(w, 200, doc)
+ case http.MethodPut:
+ if !requireMediaType(w, r, "application/json") {
+ return
+ }
+ tag := r.Header.Get("If-Match")
+ if tag == "" {
+ writeError(w, 428, "precondition_required", "If-Match is required")
+ return
+ }
+ var input struct {
+ Graph *contract.RoutingGraph `json:"graph"`
+ Layout *contract.RoutingLayout `json:"layout"`
+ Apply *bool `json:"apply"`
+ }
+ if !decodeControlJSON(w, r, &input) {
+ return
+ }
+ if input.Graph == nil || input.Layout == nil || input.Apply == nil {
+ writeError(w, 422, "invalid_routing_graph", "graph, layout and apply are required")
+ return
+ }
+ doc, err := store.SaveRoutingGraph(r.Context(), *input.Graph, *input.Layout, tag, *input.Apply)
+ if err != nil {
+ handler.writeStoreError(w, err)
+ return
+ }
+ w.Header().Set("ETag", doc.ETag)
+ writeJSON(w, 200, doc)
+ default:
+ w.Header().Set("Allow", "GET, PUT")
+ writeError(w, 405, "method_not_allowed", "only GET and PUT are allowed")
+ }
+}
+
+func (handler *Handler) previewRoutingGraph(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.Header().Set("Allow", "POST")
+ writeError(w, 405, "method_not_allowed", "only POST is allowed")
+ return
+ }
+ if !requireMediaType(w, r, "application/json") {
+ return
+ }
+ var input routinggraph.PreviewInput
+ if !decodeControlJSON(w, r, &input) {
+ return
+ }
+ if err := input.Graph.Validate(); err != nil {
+ writeError(w, 422, "invalid_routing_graph", err.Error())
+ return
+ }
+ protocol := contract.ProtocolID(input.Facts.Protocol)
+ if protocol.Validate() != nil || protocol.IsModelDiscovery() {
+ writeError(w, 422, "invalid_protocol", "preview requires an inference protocol")
+ return
+ }
+ var entry *contract.RoutingGraphNode
+ for _, node := range input.Graph.Nodes {
+ if node.Kind == "entry" && node.ID == input.EntryID {
+ copy := node
+ entry = ©
+ break
+ }
+ }
+ if entry == nil {
+ writeError(w, 422, "invalid_entry", "select a model entry")
+ return
+ }
+ resolver := handler.recoveryResolver
+ if resolver == nil {
+ var err error
+ resolver, err = endpoint.NewStoreResolver(handler.serviceStore)
+ if err != nil {
+ writeError(w, 503, "preview_unavailable", err.Error())
+ return
+ }
+ }
+ plan, err := resolver.PrepareRoutingGraph(r.Context(), input.Graph, *entry, 0, endpoint.ResolveRequest{Protocol: protocol, Model: entry.Model, Streaming: input.Facts.Streaming})
+ if err != nil {
+ handler.writeStoreError(w, err)
+ return
+ }
+ preview, err := ingress.PreviewRoutingGraph(r.Context(), plan, input)
+ if err != nil {
+ writeError(w, 422, "invalid_preview", err.Error())
+ return
+ }
+ writeJSON(w, 200, preview)
+}
diff --git a/core/internal/controlapi/routing_graph_test.go b/core/internal/controlapi/routing_graph_test.go
new file mode 100644
index 0000000..f7b482e
--- /dev/null
+++ b/core/internal/controlapi/routing_graph_test.go
@@ -0,0 +1,50 @@
+package controlapi
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/astrlink/core/contract"
+)
+
+func TestRoutingGraphControlRequiresCompleteVersionedInput(t *testing.T) {
+ _, handler := newRouteHandler(t)
+ get := controlRequest(t, handler, http.MethodGet, RoutingGraphPath, "", "", "")
+ if get.Code != 200 {
+ t.Fatalf("read graph: %d %s", get.Code, get.Body.String())
+ }
+ var initial contract.RoutingGraphDocument
+ if err := json.Unmarshal(get.Body.Bytes(), &initial); err != nil {
+ t.Fatal(err)
+ }
+ valid := `{"graph":{"nodes":[{"id":"entry_public","kind":"entry","model":"public","enabled":true},{"id":"end_here","kind":"stop","enabled":true}],"edges":[{"id":"edge_first","source":"entry_public","port":"next","target":"end_here"}]},"layout":{},"apply":true}`
+ for _, bad := range []string{`{}`, `{"graph":{"nodes":[],"edges":[]},"apply":true}`, strings.Replace(valid, `"kind":"entry"`, `"kind":"entry","unknown_execution_flag":true`, 1)} {
+ response := controlRequest(t, handler, http.MethodPut, RoutingGraphPath, "application/json", bad, initial.ETag)
+ if response.Code < 400 {
+ t.Fatalf("invalid input applied: %s", response.Body.String())
+ }
+ }
+ missingTag := controlRequest(t, handler, http.MethodPut, RoutingGraphPath, "application/json", valid, "")
+ if missingTag.Code != 428 {
+ t.Fatalf("missing If-Match: %d", missingTag.Code)
+ }
+ applied := controlRequest(t, handler, http.MethodPut, RoutingGraphPath, "application/json", valid, initial.ETag)
+ if applied.Code != 200 {
+ t.Fatalf("apply: %d %s", applied.Code, applied.Body.String())
+ }
+ stale := controlRequest(t, handler, http.MethodPut, RoutingGraphPath, "application/json", valid, initial.ETag)
+ if stale.Code != 412 {
+ t.Fatalf("stale revision applied: %d", stale.Code)
+ }
+ history := controlRequest(t, handler, http.MethodGet, RoutingGraphPath+"?revision=1", "", "", "")
+ if history.Code != 200 || !strings.Contains(history.Body.String(), `"model":"public"`) {
+ t.Fatalf("history: %d %s", history.Code, history.Body.String())
+ }
+ preview := `{"graph":{"nodes":[{"id":"entry_public","kind":"entry","model":"public","enabled":true},{"id":"end_here","kind":"stop","enabled":true}],"edges":[{"id":"edge_first","source":"entry_public","port":"next","target":"end_here"}]},"entry_id":"entry_public","facts":{"protocol":"openai.chat","streaming":false},"outcomes":{}}`
+ result := controlRequest(t, handler, http.MethodPost, RoutingGraphPath+"/preview", "application/json", preview, "")
+ if result.Code != 200 || !strings.Contains(result.Body.String(), `"stop_reason":"explicit_stop"`) || !strings.Contains(result.Body.String(), `"attempts":0`) {
+ t.Fatalf("preview: %d %s", result.Code, result.Body.String())
+ }
+}
diff --git a/core/internal/endpoint/resolver.go b/core/internal/endpoint/resolver.go
index c6cae23..d91358d 100644
--- a/core/internal/endpoint/resolver.go
+++ b/core/internal/endpoint/resolver.go
@@ -77,6 +77,7 @@ type ResolveRequest struct {
}
type Resolved struct {
+ GraphNodeID string
Path *RecoveryPathSnapshot
Unavailable string
FailurePolicy *contract.FailurePolicy
diff --git a/core/internal/endpoint/routing_graph.go b/core/internal/endpoint/routing_graph.go
new file mode 100644
index 0000000..6bd3934
--- /dev/null
+++ b/core/internal/endpoint/routing_graph.go
@@ -0,0 +1,126 @@
+package endpoint
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/QuantumNous/astrlink/core/contract"
+ "github.com/QuantumNous/astrlink/core/internal/routinggraph"
+ "github.com/QuantumNous/astrlink/core/internal/storage"
+)
+
+type RoutingGraphPlan struct {
+ Graph contract.RoutingGraph
+ Entry contract.RoutingGraphNode
+ Revision int64
+ Candidates []Resolved
+ Facts routinggraph.Facts
+ MaxAttempts int
+}
+
+type RoutingGraphResolver interface {
+ ResolveRoutingGraph(context.Context, ResolveRequest) (*RoutingGraphPlan, error)
+ ListRoutingGraphModels(context.Context) ([]string, error)
+}
+
+func (resolver *StoreResolver) ResolveRoutingGraph(ctx context.Context, request ResolveRequest) (*RoutingGraphPlan, error) {
+ store, ok := resolver.reader.(storage.RoutingGraphStore)
+ if !ok {
+ return nil, nil
+ }
+ graph, revision, err := store.GetActiveRoutingGraph(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("read routing graph: %w", err)
+ }
+ entry, found := graph.Entry(request.Model)
+ if !found {
+ return nil, nil
+ }
+ return resolver.PrepareRoutingGraph(ctx, graph, entry, revision, request)
+}
+
+func (resolver *StoreResolver) PrepareRoutingGraph(ctx context.Context, graph contract.RoutingGraph, entry contract.RoutingGraphNode, revision int64, request ResolveRequest) (*RoutingGraphPlan, error) {
+ services, err := resolver.readEnabledServices(ctx)
+ if err != nil {
+ return nil, err
+ }
+ settings := contract.DefaultRoutingSettings()
+ if resolver.routingSettings != nil {
+ settings, err = resolver.routingSettings.GetRoutingSettings(ctx)
+ if err != nil {
+ return nil, err
+ }
+ }
+ limit := settings.MaxAttempts
+ if entry.MaxAttempts > 0 {
+ limit = entry.MaxAttempts
+ }
+ plan := &RoutingGraphPlan{Graph: graph, Entry: entry, Revision: revision, MaxAttempts: limit, Candidates: []Resolved{}, Facts: routinggraph.Facts{Model: request.Model, Protocol: string(request.Protocol), Streaming: request.Streaming, Now: time.Now()}}
+ if quota, ok := resolver.reader.(storage.RoutingQuotaReader); ok {
+ plan.Facts.Quota, _ = quota.GetRoutingQuota(ctx)
+ }
+ reachable := graph.Reachable(entry.ID)
+ for _, node := range graph.Nodes {
+ if node.Kind != "call" || !reachable[node.ID] {
+ continue
+ }
+ candidate := Resolved{Service: contract.Service{ID: node.ServiceID}, RequestedModel: request.Model, UpstreamModel: node.UpstreamModel, GraphNodeID: node.ID, Unavailable: "target_unavailable"}
+ for _, service := range services {
+ if service.ID != node.ServiceID {
+ continue
+ }
+ effective := request
+ effective.Model = node.UpstreamModel
+ resolved := defaultCandidates([]contract.Service{service}, effective, resolver.subscriptionBaseURL, resolver.runtime)
+ if len(resolved) > 0 {
+ candidate = resolved[0]
+ candidate.RequestedModel = request.Model
+ candidate.UpstreamModel = node.UpstreamModel
+ candidate.GraphNodeID = node.ID
+ } else {
+ candidate.Service = service
+ candidate.Unavailable = "missing_protocol_capability"
+ }
+ break
+ }
+ policy := settings.DefaultFailurePolicy
+ if candidate.CanonicalService().FailurePolicy != nil {
+ policy = *candidate.CanonicalService().FailurePolicy
+ }
+ if node.FailurePolicy != nil {
+ policy = *node.FailurePolicy
+ }
+ // Ordinary graph calls try once unless this node explicitly enables retry.
+ if node.FailurePolicy == nil {
+ policy.MaxRetries = 0
+ }
+ candidate.FailurePolicy = &policy
+ candidate.Failover = &contract.FailoverPolicy{Enabled: true, Strategy: contract.RetryFirst, MaxAttempts: limit}
+ if !node.Enabled {
+ candidate.Unavailable = "node_disabled"
+ } else if candidate.Unavailable == "" && !resolver.breaker.available(candidate) {
+ candidate.Unavailable = "circuit_open"
+ }
+ plan.Candidates = append(plan.Candidates, candidate)
+ }
+ return plan, nil
+}
+
+func (resolver *StoreResolver) ListRoutingGraphModels(ctx context.Context) ([]string, error) {
+ store, ok := resolver.reader.(storage.RoutingGraphStore)
+ if !ok {
+ return nil, nil
+ }
+ graph, _, err := store.GetActiveRoutingGraph(ctx)
+ if err != nil {
+ return nil, err
+ }
+ models := []string{}
+ for _, node := range graph.Nodes {
+ if node.Kind == "entry" && node.Enabled {
+ models = append(models, node.Model)
+ }
+ }
+ return models, nil
+}
diff --git a/core/internal/ingress/discovery.go b/core/internal/ingress/discovery.go
index 4acc4c6..55bea3a 100644
--- a/core/internal/ingress/discovery.go
+++ b/core/internal/ingress/discovery.go
@@ -179,6 +179,22 @@ func (handler *Handler) aggregateModelDiscovery(
mergeInput = append(mergeInput, results...)
}
}
+ if lister, ok := handler.resolver.(endpoint.RoutingGraphResolver); ok {
+ models, graphErr := lister.ListRoutingGraphModels(request.Context())
+ if graphErr != nil {
+ handler.writeResolveError(writer, request, classified, graphErr)
+ return
+ }
+ models = graphDiscoveryModels(classified.Protocol, models)
+ if len(models) > 0 {
+ entries, graphErr := synthesizeAliasDiscoveryEntries(classified.Protocol, models)
+ if graphErr != nil {
+ handler.writeResolveError(writer, request, classified, graphErr)
+ return
+ }
+ mergeInput = append([]discoveryResult{{outcome: discoveryOutcomeFetched, entries: entries}}, mergeInput...)
+ }
+ }
merged, succeeded := mergeDiscoveryEntries(mergeInput)
if succeeded == 0 {
handler.writeDiscoveryFailure(writer, request, classified, results)
diff --git a/core/internal/ingress/execution.go b/core/internal/ingress/execution.go
index ec5b9e0..b540c2a 100644
--- a/core/internal/ingress/execution.go
+++ b/core/internal/ingress/execution.go
@@ -141,6 +141,19 @@ func (handler *Handler) executeCandidatesWithTest(
controller, healthAware = nil, false
}
schedule := newRecoverySchedule(candidates, body.Replayable())
+ if plan, ok := request.Context().Value(graphPlanKey{}).(*endpoint.RoutingGraphPlan); ok {
+ var factsBody []byte
+ if body.Replayable() {
+ if reader, err := body.factory(); err == nil {
+ factsBody, _ = io.ReadAll(io.LimitReader(reader, 4*1024*1024+1))
+ _ = reader.Close()
+ if len(factsBody) > 4*1024*1024 {
+ factsBody = nil
+ }
+ }
+ }
+ schedule.attachGraph(plan, factsBody, recordSessionFromContext(request.Context()))
+ }
repairedTargets := map[string][]byte{}
var last executionFailure
var lastNetworkFailure executionFailure
@@ -153,6 +166,7 @@ func (handler *Handler) executeCandidatesWithTest(
}
candidate := candidates[candidateIndex]
if candidate.Unavailable != "" {
+ schedule.graphSkip(candidateIndex, candidate.Unavailable)
records.noteCandidateRejected(candidate.CanonicalService().ID, candidate.Unavailable)
continue
}
@@ -192,6 +206,7 @@ func (handler *Handler) executeCandidatesWithTest(
} else if planType == contract.PlanTypeRelayKit || convertTo != "" {
if handler.conversionEngine == nil {
last = executionFailure{kind: executionFailureCapability, endpointID: candidate.Service.ID}
+ schedule.graphSkip(candidateIndex, last.code())
records.noteCandidateRejected(last.endpointID, last.code())
continue
}
@@ -226,6 +241,7 @@ func (handler *Handler) executeCandidatesWithTest(
endpointID: candidate.Service.ID,
capability: capabilityErr,
}
+ schedule.graphSkip(candidateIndex, last.code())
records.noteCandidateRejected(last.endpointID, last.code())
continue
}
@@ -234,6 +250,7 @@ func (handler *Handler) executeCandidatesWithTest(
err: planErr,
endpointID: candidate.Service.ID,
}
+ schedule.graphSkip(candidateIndex, last.code())
records.noteCandidateRejected(last.endpointID, last.code())
continue
}
@@ -274,6 +291,7 @@ func (handler *Handler) executeCandidatesWithTest(
err: rewriteErr,
endpointID: candidate.Service.ID,
}
+ schedule.graphSkip(candidateIndex, last.code())
records.noteCandidateRejected(last.endpointID, last.code())
_ = attemptRequest.Body.Close()
continue
@@ -307,6 +325,7 @@ func (handler *Handler) executeCandidatesWithTest(
finishPrivacy()
_ = attemptRequest.Body.Close()
last = executionFailure{kind: executionFailureConversionUnsupported, err: readErr, endpointID: candidate.Service.ID}
+ schedule.graphSkip(candidateIndex, last.code())
records.noteCandidateRejected(last.endpointID, last.code())
continue
}
@@ -322,6 +341,7 @@ func (handler *Handler) executeCandidatesWithTest(
if convertErr != nil || adaptRelayKitRequest(attemptRequest, plan.UpstreamProtocol, classified.Streaming, upstreamModel, converted.Body) != nil {
finishPrivacy()
last = executionFailure{kind: executionFailureConversionUnsupported, err: convertErr, endpointID: candidate.Service.ID}
+ schedule.graphSkip(candidateIndex, last.code())
records.noteCandidateRejected(last.endpointID, last.code())
continue
}
@@ -329,6 +349,7 @@ func (handler *Handler) executeCandidatesWithTest(
if err := prepareCodexConvertedRequest(attemptRequest); err != nil {
finishPrivacy()
last = executionFailure{kind: executionFailureConversionUnsupported, err: err, endpointID: candidate.Service.ID}
+ schedule.graphSkip(candidateIndex, last.code())
records.noteCandidateRejected(last.endpointID, last.code())
continue
}
@@ -339,6 +360,7 @@ func (handler *Handler) executeCandidatesWithTest(
if err := prepareClaudeSubscriptionRequest(attemptRequest); err != nil {
finishPrivacy()
last = executionFailure{kind: executionFailureConfiguration, endpointID: candidate.Service.ID, err: err}
+ schedule.graphSkip(candidateIndex, last.code())
records.noteCandidateRejected(last.endpointID, last.code())
continue
}
@@ -367,6 +389,7 @@ func (handler *Handler) executeCandidatesWithTest(
err: authorizeErr,
endpointID: candidate.Service.ID,
}
+ schedule.graphSkip(candidateIndex, last.code())
records.noteCandidateRejected(last.endpointID, last.code())
if !body.Replayable() {
break
@@ -385,6 +408,7 @@ func (handler *Handler) executeCandidatesWithTest(
err: parseErr,
endpointID: candidate.Service.ID,
}
+ schedule.graphSkip(candidateIndex, last.code())
records.noteCandidateRejected(last.endpointID, last.code())
if !body.Replayable() {
break
@@ -401,6 +425,7 @@ func (handler *Handler) executeCandidatesWithTest(
}
if healthAware && !controller.BeginAttempt(candidate) {
+ schedule.graphSkip(candidateIndex, "circuit_open")
records.noteCandidateRejected(candidate.Service.ID, "circuit_open")
finishPrivacy()
_ = attemptRequest.Body.Close()
@@ -585,6 +610,7 @@ func (handler *Handler) executeCandidatesWithTest(
cooldown.RecordRateLimit(candidate, max(retryAfter, time.Duration(policy.InitialDelayMS)*time.Millisecond))
}
}
+ schedule.graphOutcome(candidateIndex, "http_"+fmt.Sprint(response.StatusCode))
recovering := false
if repaired != nil && schedule.repair(candidateIndex) {
repairedTargets[repairTarget] = repaired
@@ -714,6 +740,7 @@ func (handler *Handler) executeCandidatesWithTest(
session.noteSucceeded()
if session.status == contract.RequestStatusSucceeded {
session.noteRecoveryStop("succeeded")
+ schedule.graphResult(candidateIndex, "succeeded", "")
if test == nil {
handler.rememberResponseAffinity(request.Context(), session, candidate, plan)
handler.rememberChannelBinding(request.Context(), session, candidate)
@@ -779,6 +806,7 @@ func (handler *Handler) executeCandidatesWithTest(
err: forwardErr,
endpointID: candidate.Service.ID,
}
+ schedule.graphSkip(candidateIndex, last.code())
continue
}
if relayConversionFailed {
@@ -790,6 +818,7 @@ func (handler *Handler) executeCandidatesWithTest(
if !body.Replayable() {
break
}
+ schedule.graphOutcome(candidateIndex, "conversion_failed")
if !schedule.recover(candidateIndex, contract.FailureFailover, 0) {
recordSession.noteRecoveryStop(schedule.stopReason)
break
@@ -831,6 +860,7 @@ func (handler *Handler) executeCandidatesWithTest(
if isUpstreamTimeout(forwardErr) {
action = policy.ResponseTimeout
}
+ schedule.graphOutcome(candidateIndex, code)
if !schedule.recover(candidateIndex, action, 0) {
recordSession.noteRecoveryStop(schedule.stopReason)
break
@@ -864,6 +894,17 @@ func (handler *Handler) executeCandidatesWithTest(
return
}
resetResponseHeaders(downstream.Header(), initialHeaders)
+ if last.kind == executionFailureNone && schedule.graph != nil {
+ status := http.StatusServiceUnavailable
+ if schedule.stopReason == "protocol_binding" {
+ status = http.StatusConflict
+ }
+ message := graphStopMessage(schedule.stopReason)
+ writeInferenceError(downstream, status, "routing_graph_stopped", message, false, nil)
+ records.noteRecoveryStop(schedule.stopReason)
+ records.noteFailed(errorSummaryFromInference("routing_graph_stopped", message, false))
+ return
+ }
if last.kind == executionFailureNone {
handler.writeResolveError(
downstream,
diff --git a/core/internal/ingress/handler.go b/core/internal/ingress/handler.go
index 85ae9a8..3aa8d6e 100644
--- a/core/internal/ingress/handler.go
+++ b/core/internal/ingress/handler.go
@@ -290,6 +290,9 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
return
}
}
+ if handler.serveRoutingGraph(outWriter, request, classified, session) {
+ return
+ }
if !classified.Protocol.IsModelDiscovery() && classified.Model != "" {
classified = handler.applyModelRedirect(request.Context(), session, classified, routingSettings)
}
@@ -318,8 +321,10 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
session.noteCandidateRejected(id, "circuit_open")
}
}
- handler.writeResolveError(outWriter, request, classified, err)
- return
+ if !handler.discoverGraphWithoutUpstreams(request.Context(), classified, err) {
+ handler.writeResolveError(outWriter, request, classified, err)
+ return
+ }
}
candidates = redirectCandidates(classified, candidates)
if turn := responsesWSTurnFromContext(request.Context()); turn != nil {
diff --git a/core/internal/ingress/recovery.go b/core/internal/ingress/recovery.go
index b4e7b44..206a296 100644
--- a/core/internal/ingress/recovery.go
+++ b/core/internal/ingress/recovery.go
@@ -10,11 +10,18 @@ import (
"github.com/QuantumNous/astrlink/core/contract"
"github.com/QuantumNous/astrlink/core/internal/endpoint"
+ "github.com/QuantumNous/astrlink/core/internal/routinggraph"
)
// recoverySchedule is request-local. It is the only owner of retry quotas and
// next-target ordering; transports and protocol converters never retry.
type recoverySchedule struct {
+ graph *routinggraph.Run
+ graphIndices map[string]int
+ graphPending bool
+ graphUnbound bool // A stateful binding excluded at least one visited node.
+ graphBound bool // The graph reached a node compatible with that binding.
+ replayable bool
manual, simulation bool
nextRepair bool
repairing bool
@@ -45,7 +52,7 @@ func newRecoverySchedule(candidates []endpoint.Resolved, replayable bool) *recov
policy.MaxAttempts = 1
policy.Enabled = false
}
- schedule := &recoverySchedule{candidates: candidates, policy: policy, selected: make([]bool, len(candidates)), maySwitch: policy.Enabled, counts: make([]int, len(candidates)), retryable: make([]bool, len(candidates)), readyAt: make([]time.Time, len(candidates)), nextIndex: 0}
+ schedule := &recoverySchedule{replayable: replayable, candidates: candidates, policy: policy, selected: make([]bool, len(candidates)), maySwitch: policy.Enabled, counts: make([]int, len(candidates)), retryable: make([]bool, len(candidates)), readyAt: make([]time.Time, len(candidates)), nextIndex: 0}
schedule.previous = -1
schedule.states = map[string]*stepTargetState{}
schedule.skips = map[int]string{}
@@ -68,11 +75,19 @@ func failurePolicy(candidate endpoint.Resolved) contract.FailurePolicy {
}
func (schedule *recoverySchedule) next(ctx context.Context) (int, bool) {
+ if schedule.graph != nil && !schedule.graphPending && !schedule.nextRepair {
+ if !schedule.chooseGraph() {
+ return 0, false
+ }
+ }
if schedule.manual && !schedule.nextRepair && schedule.nextIndex >= 0 && !schedule.chooseStep(schedule.nextIndex) {
return 0, false
}
index := schedule.nextIndex
if index < 0 || schedule.total >= schedule.policy.MaxAttempts {
+ if schedule.total >= schedule.policy.MaxAttempts {
+ schedule.stopReason = "attempt_limit"
+ }
return 0, false
}
wait := time.Until(schedule.readyAt[index])
@@ -92,6 +107,12 @@ func (schedule *recoverySchedule) next(ctx context.Context) (int, bool) {
}
schedule.repairing = schedule.nextRepair
schedule.nextRepair = false
+ if schedule.graph != nil {
+ schedule.graphPending = false
+ schedule.nextIndex = -1
+ schedule.selected[index] = true
+ return index, true
+ }
if schedule.manual {
schedule.selected[index] = true
schedule.nextIndex = index + 1
@@ -122,11 +143,15 @@ func (schedule *recoverySchedule) next(ctx context.Context) (int, bool) {
func (schedule *recoverySchedule) started(index int) {
schedule.total++
+ if schedule.graph != nil {
+ schedule.graph.Facts.Attempts = schedule.total
+ schedule.graphResult(index, "attempted", "")
+ }
if schedule.repairing {
return
}
schedule.counts[index]++
- if schedule.manual {
+ if schedule.manual || schedule.graph != nil {
schedule.stepState(index).attempts++
}
}
@@ -148,6 +173,9 @@ func (schedule *recoverySchedule) repair(index int) bool {
}
func (schedule *recoverySchedule) recover(index int, action contract.FailureAction, retryAfter time.Duration) bool {
+ if schedule.graph != nil {
+ return schedule.recoverGraph(index, action, retryAfter)
+ }
if schedule.manual {
return schedule.recoverStep(index, action, retryAfter)
}
diff --git a/core/internal/ingress/routing_graph.go b/core/internal/ingress/routing_graph.go
new file mode 100644
index 0000000..4af53ee
--- /dev/null
+++ b/core/internal/ingress/routing_graph.go
@@ -0,0 +1,335 @@
+package ingress
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/QuantumNous/astrlink/core/contract"
+ "github.com/QuantumNous/astrlink/core/internal/endpoint"
+ "github.com/QuantumNous/astrlink/core/internal/routinggraph"
+)
+
+type graphPlanKey struct{}
+
+func (handler *Handler) serveRoutingGraph(writer http.ResponseWriter, request *http.Request, classified Request, session *recordSession) bool {
+ resolver, ok := handler.resolver.(endpoint.RoutingGraphResolver)
+ if !ok || classified.Protocol.IsModelDiscovery() {
+ return false
+ }
+ plan, err := resolver.ResolveRoutingGraph(request.Context(), endpoint.ResolveRequest{Protocol: classified.Protocol, Model: classified.Model, Streaming: classified.Streaming, Continuation: classified.PreviousResponseID != ""})
+ if err != nil {
+ session.captureUnreadRequestBody(request)
+ handler.writeResolveError(writer, request, classified, err)
+ return true
+ }
+ if plan == nil {
+ return false
+ }
+ if !plan.Entry.Enabled {
+ session.captureUnreadRequestBody(request)
+ writeInferenceError(writer, 503, "routing_entry_paused", "this model entry is paused", false, nil)
+ session.noteFailed(errorSummaryFromInference("routing_entry_paused", "this model entry is paused", false))
+ return true
+ }
+ // Stateful continuation stays constrained by the existing protocol binding.
+ // Mark other nodes unavailable; never inject the bound target into a branch.
+ if classified.PreviousResponseID != "" {
+ bound, err := handler.bindResponseAffinity(request.Context(), classified, plan.Candidates)
+ if err != nil {
+ session.captureUnreadRequestBody(request)
+ writeInferenceError(writer, 409, "response_affinity_unavailable", err.Error(), false, nil)
+ session.noteFailed(errorSummaryFromInference("response_affinity_unavailable", err.Error(), false))
+ return true
+ }
+ for i, candidate := range plan.Candidates {
+ if recoveryTargetKey(candidate) != recoveryTargetKey(bound[0]) {
+ plan.Candidates[i].Unavailable = "protocol_binding"
+ } else {
+ plan.Candidates[i].Failover = bound[0].Failover
+ }
+ }
+ }
+ if turn := responsesWSTurnFromContext(request.Context()); turn != nil {
+ compatible := turn.session.filterCandidates(classified.Model, classified.Model, plan.Candidates)
+ allowed := map[string]bool{}
+ for _, candidate := range compatible {
+ allowed[candidate.GraphNodeID] = true
+ }
+ for i, candidate := range plan.Candidates {
+ if !allowed[candidate.GraphNodeID] {
+ plan.Candidates[i].Unavailable = "responses_websocket_unavailable"
+ if turn.session.serviceID != "" {
+ plan.Candidates[i].Unavailable = "protocol_binding"
+ }
+ }
+ }
+ }
+ request = request.WithContext(context.WithValue(request.Context(), graphPlanKey{}, plan))
+ handler.executeCandidates(writer, request, classified, plan.Candidates)
+ return true
+}
+
+// discoverGraphWithoutUpstreams lets discovery list local graph entries when
+// no upstream can contribute a listing. Storage failures still fail closed.
+func (handler *Handler) discoverGraphWithoutUpstreams(ctx context.Context, classified Request, err error) bool {
+ var capability *endpoint.CapabilityUnavailableError
+ var unhealthy *endpoint.UnhealthyCandidatesError
+ if !classified.Protocol.IsModelDiscovery() || !errors.Is(err, endpoint.ErrNoEndpoint) && !errors.As(err, &capability) && !errors.As(err, &unhealthy) {
+ return false
+ }
+ lister, ok := handler.resolver.(endpoint.RoutingGraphResolver)
+ if !ok {
+ return false
+ }
+ models, listErr := lister.ListRoutingGraphModels(ctx)
+ return listErr == nil && len(graphDiscoveryModels(classified.Protocol, models)) > 0
+}
+
+// graphDiscoveryModels drops entries that the protocol's listing cannot name.
+func graphDiscoveryModels(protocol contract.ProtocolID, models []string) []string {
+ if protocol != contract.ProtocolGoogleModels {
+ return models
+ }
+ valid := make([]string, 0, len(models))
+ for _, model := range models {
+ if !strings.Contains(model, "/") {
+ valid = append(valid, model)
+ }
+ }
+ return valid
+}
+
+func (schedule *recoverySchedule) attachGraph(plan *endpoint.RoutingGraphPlan, body []byte, records *recordSession) {
+ facts := plan.Facts
+ var input map[string]json.RawMessage
+ if len(body) > 0 && json.Unmarshal(body, &input) == nil {
+ var tools []json.RawMessage
+ if raw, exists := input["tools"]; !exists || json.Unmarshal(raw, &tools) == nil {
+ hasTools := len(tools) > 0
+ facts.HasTools = &hasTools
+ }
+ // Image presence is derived from typed content blocks, never prompt text.
+ var value any
+ if json.Unmarshal(body, &value) == nil {
+ hasImages := hasGraphImage(value)
+ facts.HasImages = &hasImages
+ }
+ }
+ schedule.graph = routinggraph.New(plan.Graph, plan.Entry.ID, facts)
+ schedule.graphIndices = map[string]int{}
+ for i, candidate := range schedule.candidates {
+ schedule.graphIndices[candidate.GraphNodeID] = i
+ }
+ if schedule.replayable {
+ schedule.policy.MaxAttempts = plan.MaxAttempts
+ }
+ schedule.graph.Observe = func(step contract.RoutingGraphStep) {
+ if records == nil {
+ return
+ }
+ if records.recovery == nil {
+ records.recovery = &contract.RequestRecovery{}
+ }
+ records.recovery.GraphRevision = plan.Revision
+ records.recovery.GraphEntryID = plan.Entry.ID
+ records.recovery.GraphTrace = append([]contract.RoutingGraphStep(nil), schedule.graph.Steps...)
+ }
+}
+
+func hasGraphImage(value any) bool {
+ switch v := value.(type) {
+ case map[string]any:
+ if kind, _ := v["type"].(string); kind == "image" || kind == "image_url" || kind == "input_image" {
+ return true
+ }
+ if data, ok := v["inlineData"].(map[string]any); ok {
+ if mime, _ := data["mimeType"].(string); strings.HasPrefix(mime, "image/") {
+ return true
+ }
+ }
+ if data, ok := v["fileData"].(map[string]any); ok {
+ if mime, _ := data["mimeType"].(string); strings.HasPrefix(mime, "image/") {
+ return true
+ }
+ }
+ for _, key := range []string{"messages", "input", "contents", "content", "parts"} {
+ if hasGraphImage(v[key]) {
+ return true
+ }
+ }
+ case []any:
+ for _, item := range v {
+ if hasGraphImage(item) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func (schedule *recoverySchedule) chooseGraph() bool {
+ for {
+ node, ok := schedule.graph.Next()
+ if !ok {
+ schedule.nextIndex = -1
+ schedule.stopReason = schedule.graph.StopReason
+ if schedule.graphUnbound && !schedule.graphBound {
+ schedule.stopReason = "protocol_binding"
+ }
+ return false
+ }
+ index, exists := schedule.graphIndices[node.ID]
+ if !exists {
+ schedule.graph.Note(node.ID, "skipped", "target_unavailable", "failure")
+ continue
+ }
+ if schedule.candidates[index].Unavailable != "protocol_binding" {
+ schedule.graphBound = true
+ }
+ if schedule.stepState(index).attempts > 0 {
+ schedule.graph.Note(node.ID, "skipped", "already_attempted", "failure")
+ continue
+ }
+ schedule.nextIndex = index
+ return true
+ }
+}
+
+func (schedule *recoverySchedule) graphOutcome(index int, reason string) {
+ if schedule.graph == nil {
+ return
+ }
+ status := 0
+ if strings.HasPrefix(reason, "http_") {
+ status, _ = strconv.Atoi(strings.TrimPrefix(reason, "http_"))
+ }
+ schedule.graph.Outcome(schedule.candidates[index].GraphNodeID, status, reason)
+}
+
+func (schedule *recoverySchedule) graphSkip(index int, reason string) {
+ if schedule.graph == nil {
+ return
+ }
+ schedule.graph.Note(schedule.candidates[index].GraphNodeID, "skipped", reason, "failure")
+ schedule.graph.Facts.LastStatus = nil
+ schedule.graph.Facts.LastError = &reason
+ // A bound continuation never sends state elsewhere, but the selected
+ // failure path may still lead to the bound target.
+ if reason == "protocol_binding" {
+ schedule.graphUnbound = true
+ }
+}
+
+func (schedule *recoverySchedule) graphResult(index int, status, reason string) {
+ if schedule.graph != nil {
+ schedule.graph.Note(schedule.candidates[index].GraphNodeID, status, reason, "")
+ }
+}
+
+func graphStopMessage(reason string) string { return fmt.Sprintf("model route stopped: %s", reason) }
+
+func (schedule *recoverySchedule) recoverGraph(index int, action contract.FailureAction, retryAfter time.Duration) bool {
+ schedule.nextIndex = -1
+ schedule.stopReason = "error_rule"
+ if schedule.total >= schedule.policy.MaxAttempts {
+ schedule.stopReason = "attempt_limit"
+ schedule.graph.Stop(schedule.stopReason)
+ return false
+ }
+ policy := failurePolicy(schedule.candidates[index])
+ _, bounds, blocked := recoveryDelay(policy, schedule.stepState(index).attempts, retryAfter)
+ schedule.waitBounds[index] = bounds
+ if action.AllowsRetry() && schedule.stepState(index).attempts <= policy.MaxRetries && !blocked && schedule.replayable {
+ schedule.nextIndex = index
+ schedule.graphPending = true
+ schedule.readyAt[index] = time.Now().Add(time.Duration(bounds[0]) * time.Millisecond)
+ schedule.stopReason = ""
+ return true
+ }
+ canSwitch := action.AllowsFailover() && schedule.replayable
+ if policy := schedule.candidates[index].Failover; policy != nil && !policy.Enabled {
+ canSwitch = false
+ }
+ if canSwitch && schedule.chooseGraph() {
+ schedule.graphPending = true
+ schedule.stopReason = ""
+ return true
+ }
+ if !canSwitch {
+ schedule.graph.Stop(schedule.stopReason)
+ }
+ return false
+}
+
+func PreviewRoutingGraph(ctx context.Context, plan *endpoint.RoutingGraphPlan, input routinggraph.PreviewInput) (routinggraph.Preview, error) {
+ if err := input.Graph.Validate(); err != nil {
+ return routinggraph.Preview{}, err
+ }
+ schedule := newRecoverySchedule(plan.Candidates, true)
+ schedule.simulation = true
+ schedule.attachGraph(plan, nil, nil)
+ if input.Facts.HasTools != nil {
+ schedule.graph.Facts.HasTools = input.Facts.HasTools
+ }
+ if input.Facts.HasImages != nil {
+ schedule.graph.Facts.HasImages = input.Facts.HasImages
+ }
+ for id, usage := range input.Facts.Quota {
+ if err := usage.Validate(); err != nil {
+ return routinggraph.Preview{}, err
+ }
+ if schedule.graph.Facts.Quota == nil {
+ schedule.graph.Facts.Quota = map[contract.ServiceID]contract.SubscriptionUsage{}
+ }
+ schedule.graph.Facts.Quota[id] = usage
+ }
+ for {
+ index, ok := schedule.next(ctx)
+ if !ok {
+ break
+ }
+ candidate := schedule.candidates[index]
+ if candidate.Unavailable != "" {
+ schedule.graphSkip(index, candidate.Unavailable)
+ continue
+ }
+ outcome := input.Outcomes[candidate.GraphNodeID]
+ if outcome == "unavailable" {
+ schedule.graphSkip(index, "target_unavailable")
+ continue
+ }
+ schedule.started(index)
+ if outcome == "" || outcome == "success" {
+ schedule.graphResult(index, "succeeded", "")
+ schedule.stopReason = "succeeded"
+ break
+ }
+ status, _ := strconv.Atoi(outcome)
+ if status == 0 && outcome != "network_error" && outcome != "response_timeout" {
+ return routinggraph.Preview{}, fmt.Errorf("unknown simulated outcome")
+ }
+ if status != 0 && (status < 400 || status > 599) {
+ return routinggraph.Preview{}, fmt.Errorf("simulated HTTP status must be 400–599")
+ }
+ policy := failurePolicy(candidate)
+ action := policy.NetworkError
+ reason := outcome
+ if status > 0 {
+ action = policy.ActionForStatus(status)
+ reason = "http_" + outcome
+ } else if outcome == "response_timeout" {
+ action = policy.ResponseTimeout
+ }
+ schedule.graphOutcome(index, reason)
+ if !schedule.recover(index, action, 0) {
+ break
+ }
+ }
+ return routinggraph.Preview{Steps: schedule.graph.Steps, StopReason: schedule.stopReason, Attempts: schedule.total}, nil
+}
diff --git a/core/internal/ingress/routing_graph_test.go b/core/internal/ingress/routing_graph_test.go
new file mode 100644
index 0000000..04f7e43
--- /dev/null
+++ b/core/internal/ingress/routing_graph_test.go
@@ -0,0 +1,260 @@
+package ingress
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/astrlink/core/contract"
+ "github.com/QuantumNous/astrlink/core/internal/endpoint"
+ "github.com/QuantumNous/astrlink/core/internal/routinggraph"
+ "github.com/QuantumNous/astrlink/core/internal/transport"
+)
+
+type graphTestResolver struct {
+ candidateResolver
+ plan *endpoint.RoutingGraphPlan
+}
+
+func (resolver graphTestResolver) ResolveRoutingGraph(_ context.Context, request endpoint.ResolveRequest) (*endpoint.RoutingGraphPlan, error) {
+ if request.Model != resolver.plan.Entry.Model {
+ return nil, nil
+ }
+ copy := *resolver.plan
+ copy.Candidates = append([]endpoint.Resolved(nil), resolver.plan.Candidates...)
+ return ©, nil
+}
+func (resolver graphTestResolver) ListRoutingGraphModels(context.Context) ([]string, error) {
+ return []string{resolver.plan.Entry.Model}, nil
+}
+
+func graphTestPlan() *endpoint.RoutingGraphPlan {
+ policy := contract.DefaultFailurePolicy()
+ policy.MaxRetries = 0
+ policy.InitialDelayMS = 0
+ candidates := recoveryCandidates(3, policy, contract.FailoverPolicy{Enabled: true, Strategy: contract.RetryFirst, MaxAttempts: 6})
+ graph := contract.RoutingGraph{Nodes: []contract.RoutingGraphNode{{ID: "entry", Kind: "entry", Model: "public", Enabled: true}}, Edges: []contract.RoutingGraphEdge{}}
+ previous, port := "entry", "next"
+ for i, id := range []string{"node_a", "node_b", "node_c"} {
+ candidates[i].GraphNodeID = id
+ candidates[i].UpstreamModel = "actual_" + strings.TrimPrefix(id, "node_")
+ graph.Nodes = append(graph.Nodes, contract.RoutingGraphNode{ID: id, Kind: "call", Enabled: id != "node_b", ServiceID: candidates[i].CanonicalService().ID, UpstreamModel: "actual_" + strings.TrimPrefix(id, "node_")})
+ graph.Edges = append(graph.Edges, contract.RoutingGraphEdge{ID: "edge_" + id, Source: previous, Port: port, Target: id})
+ previous, port = id, "failure"
+ }
+ return &endpoint.RoutingGraphPlan{Graph: graph, Entry: graph.Nodes[0], Revision: 7, Candidates: candidates, MaxAttempts: 6, Facts: routinggraph.Facts{Protocol: string(contract.ProtocolOpenAIChat)}}
+}
+
+func TestRoutingGraphDisabledNodeBypassAndPreview(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ status, limit int
+ want []string
+ response int
+ }{{"429 skips disabled middle", 429, 6, []string{"actual_a", "actual_c"}, 200}, {"400 stops", 400, 6, []string{"actual_a"}, 400}, {"budget counts network only", 429, 1, []string{"actual_a"}, 429}} {
+ t.Run(tt.name, func(t *testing.T) {
+ plan := graphTestPlan()
+ plan.MaxAttempts = tt.limit
+ var models []string
+ records := newRedirectSettingsStore(enabledRedirect("public", "unrelated"))
+ handler := NewWithDependencies(Dependencies{Resolver: graphTestResolver{plan: plan}, RequestRecords: records, Forwarder: transport.New(roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ assertNoGatewayIdentity(t, request, "fixture-client")
+ body, _ := io.ReadAll(request.Body)
+ var value map[string]any
+ if err := json.Unmarshal(body, &value); err != nil {
+ t.Fatal(err)
+ }
+ model, _ := value["model"].(string)
+ models = append(models, model)
+ if model == "actual_a" {
+ return jsonResponse(tt.status, `{"error":{"message":"failed"}}`), nil
+ }
+ return jsonResponse(200, `{"id":"answer","model":"actual_c","choices":[]}`), nil
+ }))})
+ request := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"public","messages":[{"role":"user","content":"hello"}]}`))
+ request.Header.Set("User-Agent", "fixture-client")
+ request.Header.Set("X-AstrLink-Test", "local-only")
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+ if response.Code != tt.response || !reflect.DeepEqual(models, tt.want) {
+ t.Fatalf("status=%d models=%v body=%s", response.Code, models, response.Body.String())
+ }
+ if tt.response == 200 && !strings.Contains(response.Body.String(), `"model":"public"`) {
+ t.Fatalf("public model not restored: %s", response.Body.String())
+ }
+ root := records.root(t)
+ if root.Recovery == nil || root.Recovery.GraphRevision != 7 {
+ t.Fatalf("missing graph recovery: %#v", root.Recovery)
+ }
+ if tt.response == 200 {
+ found := false
+ for _, step := range root.Recovery.GraphTrace {
+ if step.NodeID == "node_b" && step.Reason == "node_disabled" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("disabled middle was not recorded")
+ }
+ }
+ preview, err := PreviewRoutingGraph(context.Background(), plan, routinggraph.PreviewInput{Graph: plan.Graph, EntryID: "entry", Outcomes: map[string]string{"node_a": stringStatus(tt.status)}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var attempted []string
+ for _, step := range preview.Steps {
+ if step.Status == "attempted" {
+ attempted = append(attempted, step.Model)
+ }
+ }
+ if !reflect.DeepEqual(attempted, models) {
+ t.Fatalf("preview=%v actual=%v", attempted, models)
+ }
+ })
+ }
+}
+
+func stringStatus(status int) string {
+ if status == 429 {
+ return "429"
+ }
+ return "400"
+}
+
+func TestRoutingGraphRequestFactsDoNotCountWhitespaceOrAudioAsFeatures(t *testing.T) {
+ for _, input := range []struct {
+ body string
+ tools, images bool
+ }{
+ {`{"tools": [ ], "messages":[]}`, false, false},
+ {`{"tools":[{"type":"function"}],"messages":[]}`, true, false},
+ {`{"contents":[{"parts":[{"inlineData":{"mimeType":"audio/wav","data":"fixture"}}]}]}`, false, false},
+ {`{"contents":[{"parts":[{"inlineData":{"mimeType":"image/png","data":"fixture"}}]}]}`, false, true},
+ } {
+ plan := graphTestPlan()
+ schedule := newRecoverySchedule(plan.Candidates, true)
+ schedule.attachGraph(plan, []byte(input.body), nil)
+ facts := schedule.graph.Facts
+ if facts.HasTools == nil || *facts.HasTools != input.tools || facts.HasImages == nil || *facts.HasImages != input.images {
+ t.Fatalf("incorrect feature facts for %s: %+v", input.body, facts)
+ }
+ }
+}
+
+func TestRoutingGraphDisabledPrefixAndAllDisabled(t *testing.T) {
+ for _, all := range []bool{false, true} {
+ plan := graphTestPlan()
+ plan.Graph.Nodes[1].Enabled = false
+ plan.Graph.Nodes[3].Enabled = !all
+ calls := 0
+ handler := NewWithDependencies(Dependencies{Resolver: graphTestResolver{plan: plan}, Forwarder: transport.New(roundTripFunc(func(*http.Request) (*http.Response, error) {
+ calls++
+ return jsonResponse(200, `{"model":"actual_c"}`), nil
+ }))})
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"public","messages":[]}`)))
+ if all && calls != 0 || !all && calls != 1 {
+ t.Fatalf("all=%v calls=%d", all, calls)
+ }
+ if all && response.Code != 503 {
+ t.Fatalf("all disabled status=%d", response.Code)
+ }
+ }
+}
+
+func TestRoutingGraphConditionsObserveFailureAndUnknown(t *testing.T) {
+ plan := graphTestPlan()
+ plan.Graph.Nodes = append(plan.Graph.Nodes, contract.RoutingGraphNode{ID: "condition", Kind: "condition", Enabled: true, Rules: []contract.RoutingGraphRule{{ID: "limited", Predicate: contract.RoutingPredicate{Field: "last.status", Operator: "eq", Value: json.RawMessage(`429`)}}}})
+ plan.Graph.Edges[1].Target = "condition"
+ plan.Graph.Edges = append(plan.Graph.Edges, contract.RoutingGraphEdge{ID: "limited_edge", Source: "condition", Port: "limited", Target: "node_c"}, contract.RoutingGraphEdge{ID: "otherwise_edge", Source: "condition", Port: "otherwise", Target: "node_b"})
+ preview, err := PreviewRoutingGraph(context.Background(), plan, routinggraph.PreviewInput{Graph: plan.Graph, EntryID: "entry", Outcomes: map[string]string{"node_a": "429"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, step := range preview.Steps {
+ if step.NodeID == "condition" && step.Port == "limited" {
+ found = true
+ }
+ }
+ if !found || preview.Attempts != 2 {
+ t.Fatalf("preview=%+v", preview)
+ }
+ plan.Graph.Nodes[len(plan.Graph.Nodes)-1].Rules[0].Predicate = contract.RoutingPredicate{Field: "quota.exhausted", Operator: "eq", Value: json.RawMessage(`true`), ServiceID: "service_unknown", Window: "primary"}
+ preview, err = PreviewRoutingGraph(context.Background(), plan, routinggraph.PreviewInput{Graph: plan.Graph, EntryID: "entry", Outcomes: map[string]string{"node_a": "429"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ found = false
+ for _, step := range preview.Steps {
+ if step.NodeID == "condition" && step.Port == "otherwise" && strings.HasPrefix(step.Reason, "unknown:") {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("unknown was not explained: %+v", preview)
+ }
+}
+
+func TestRoutingGraphContinuationSkipsUnboundPrefixWithoutInjectingTarget(t *testing.T) {
+ plan := graphTestPlan()
+ plan.Graph.Nodes[2].Enabled = true
+ for i := range plan.Candidates {
+ plan.Candidates[i].Endpoint.Capabilities = []contract.Capability{{Protocol: contract.ProtocolOpenAIResponses, Mode: contract.CapabilityModeNative}}
+ plan.Candidates[i].UpstreamProtocol = contract.ProtocolOpenAIResponses
+ }
+ var models []string
+ handler := NewWithDependencies(Dependencies{Resolver: graphTestResolver{plan: plan}, Forwarder: transport.New(roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ var value map[string]any
+ _ = json.NewDecoder(request.Body).Decode(&value)
+ model, _ := value["model"].(string)
+ models = append(models, model)
+ if model == "actual_a" {
+ return jsonResponse(503, `{"error":{"message":"unavailable"}}`), nil
+ }
+ return jsonResponse(200, `{"id":"resp_`+model+`","model":"`+model+`","output":[]}`), nil
+ }))})
+ send := func(body string) *httptest.ResponseRecorder {
+ response := httptest.NewRecorder()
+ request := httptest.NewRequest("POST", "/v1/responses", strings.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ handler.ServeHTTP(response, request)
+ return response
+ }
+ if response := send(`{"model":"public","input":"hello"}`); response.Code != 200 || strings.Join(models, ",") != "actual_a,actual_b" {
+ t.Fatalf("first turn %d %v %s", response.Code, models, response.Body.String())
+ }
+ models = nil
+ if response := send(`{"model":"public","input":"next","previous_response_id":"resp_actual_b"}`); response.Code != 200 || strings.Join(models, ",") != "actual_b" {
+ t.Fatalf("continuation must skip the unbound prefix: %d %v %s", response.Code, models, response.Body.String())
+ }
+ // A condition that selects another branch never reaches the bound target;
+ // the request fails instead of sending state elsewhere or injecting B.
+ plan.Graph.Nodes = append(plan.Graph.Nodes, contract.RoutingGraphNode{ID: "condition", Kind: "condition", Enabled: true, Rules: []contract.RoutingGraphRule{{ID: "plain", Predicate: contract.RoutingPredicate{Field: "request.streaming", Operator: "eq", Value: json.RawMessage(`false`)}}}})
+ plan.Graph.Edges[0].Target = "condition"
+ plan.Graph.Edges = append(plan.Graph.Edges, contract.RoutingGraphEdge{ID: "plain_edge", Source: "condition", Port: "plain", Target: "node_c"}, contract.RoutingGraphEdge{ID: "otherwise_edge", Source: "condition", Port: "otherwise", Target: "node_a"})
+ if err := plan.Graph.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ models = nil
+ if response := send(`{"model":"public","input":"next","previous_response_id":"resp_actual_b"}`); response.Code != http.StatusConflict || len(models) != 0 || !strings.Contains(response.Body.String(), "protocol_binding") {
+ t.Fatalf("branch without bound target: %d %v %s", response.Code, models, response.Body.String())
+ }
+}
+
+func TestRoutingGraphDiscoveryListsEntriesWithoutUpstreamListing(t *testing.T) {
+ handler := NewWithDependencies(Dependencies{Resolver: graphTestResolver{plan: graphTestPlan()}, Forwarder: transport.New(roundTripFunc(func(*http.Request) (*http.Response, error) {
+ t.Fatal("discovery without candidates reached an upstream")
+ return nil, nil
+ }))})
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, httptest.NewRequest("GET", "/v1/models", nil))
+ if response.Code != 200 || !strings.Contains(response.Body.String(), `"id":"public"`) {
+ t.Fatalf("graph entry missing from discovery: %d %s", response.Code, response.Body.String())
+ }
+}
diff --git a/core/internal/routinggraph/run.go b/core/internal/routinggraph/run.go
new file mode 100644
index 0000000..aa05b85
--- /dev/null
+++ b/core/internal/routinggraph/run.go
@@ -0,0 +1,255 @@
+// Package routinggraph evaluates the same bounded graph for inference and preview.
+// It never performs network I/O, obtains credentials or changes health state.
+package routinggraph
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/QuantumNous/astrlink/core/contract"
+)
+
+type Facts struct {
+ Model string `json:"-"`
+ Protocol string `json:"protocol"`
+ Streaming bool `json:"streaming"`
+ HasTools *bool `json:"has_tools,omitempty"`
+ HasImages *bool `json:"has_images,omitempty"`
+ LastStatus *int `json:"-"`
+ LastError *string `json:"-"`
+ Attempts int `json:"-"`
+ Quota map[contract.ServiceID]contract.SubscriptionUsage `json:"quota,omitempty"`
+ Now time.Time `json:"-"`
+}
+
+type Run struct {
+ Facts Facts
+ Steps []contract.RoutingGraphStep
+ StopReason string
+ nodes map[string]contract.RoutingGraphNode
+ edges map[string]map[string]string
+ cursor string
+ visited int
+ Observe func(contract.RoutingGraphStep)
+}
+
+func New(graph contract.RoutingGraph, entry string, facts Facts) *Run {
+ run := &Run{Facts: facts, Steps: []contract.RoutingGraphStep{}, nodes: map[string]contract.RoutingGraphNode{}, edges: map[string]map[string]string{}, cursor: entry}
+ if run.Facts.Now.IsZero() {
+ run.Facts.Now = time.Now()
+ }
+ for _, node := range graph.Nodes {
+ run.nodes[node.ID] = node
+ }
+ for _, edge := range graph.Edges {
+ if run.edges[edge.Source] == nil {
+ run.edges[edge.Source] = map[string]string{}
+ }
+ run.edges[edge.Source][edge.Port] = edge.Target
+ }
+ return run
+}
+
+func (run *Run) Note(nodeID, status, reason, port string) {
+ if len(run.Steps) >= 2048 {
+ return
+ }
+ node := run.nodes[nodeID]
+ step := contract.RoutingGraphStep{NodeID: nodeID, Kind: node.Kind, Status: status, Reason: reason, Port: port, ServiceID: node.ServiceID, Model: node.UpstreamModel}
+ run.Steps = append(run.Steps, step)
+ if run.Observe != nil {
+ run.Observe(step)
+ }
+}
+
+func (run *Run) Outcome(nodeID string, status int, reason string) {
+ run.Facts.LastStatus = nil
+ if status > 0 {
+ run.Facts.LastStatus = &status
+ }
+ run.Facts.LastError = &reason
+ run.Note(nodeID, "failed", reason, "")
+}
+
+// Next follows one branch. Disabled calls keep their failure edge, emit a skip,
+// and never become an executable attempt or consume a network budget.
+func (run *Run) Next() (contract.RoutingGraphNode, bool) {
+ for run.cursor != "" && run.visited < 1024 {
+ id := run.cursor
+ node, ok := run.nodes[id]
+ if !ok {
+ run.StopReason = "missing_node"
+ break
+ }
+ run.visited++
+ switch node.Kind {
+ case "entry":
+ if !node.Enabled {
+ run.Note(id, "stopped", "entry_paused", "")
+ run.StopReason = "entry_paused"
+ run.cursor = ""
+ break
+ }
+ run.Note(id, "selected", "entry_match", "next")
+ run.cursor = run.edges[id]["next"]
+ case "condition":
+ port, reason := "otherwise", "no_rule_matched"
+ for _, rule := range node.Rules {
+ value, known := Evaluate(rule.Predicate, run.Facts)
+ if !known {
+ port = node.UnknownPort
+ if port == "" {
+ port = "otherwise"
+ }
+ reason = "unknown:" + rule.ID
+ break
+ }
+ if value {
+ port = rule.ID
+ reason = "matched:" + rule.ID
+ break
+ }
+ }
+ run.Note(id, "selected", reason, port)
+ run.cursor = run.edges[id][port]
+ case "call":
+ run.cursor = run.edges[id]["failure"]
+ if !node.Enabled {
+ run.Note(id, "skipped", "node_disabled", "failure")
+ continue
+ }
+ return node, true
+ case "stop":
+ run.Note(id, "stopped", "explicit_stop", "")
+ run.StopReason = "explicit_stop"
+ run.cursor = ""
+ default:
+ run.StopReason = "invalid_node"
+ run.cursor = ""
+ }
+ }
+ if run.visited >= 1024 {
+ run.StopReason = "step_limit"
+ }
+ if run.StopReason == "" {
+ run.StopReason = "targets_exhausted"
+ }
+ return contract.RoutingGraphNode{}, false
+}
+
+func (run *Run) Stop(reason string) { run.StopReason = reason; run.cursor = "" }
+
+func Evaluate(p contract.RoutingPredicate, facts Facts) (bool, bool) {
+ if p.All != nil || p.Any != nil {
+ children, all := p.All, true
+ if p.Any != nil {
+ children, all = p.Any, false
+ }
+ known := true
+ for _, child := range children {
+ value, ok := Evaluate(child, facts)
+ if ok && value != all {
+ return value, true
+ }
+ known = known && ok
+ }
+ return all, known
+ }
+ actual, known := fact(p, facts)
+ if !known {
+ return false, false
+ }
+ var expected any
+ if json.Unmarshal(p.Value, &expected) != nil {
+ return false, false
+ }
+ if p.Operator == "eq" || p.Operator == "ne" {
+ equal := actual == expected
+ if p.Operator == "ne" {
+ equal = !equal
+ }
+ return equal, true
+ }
+ a, ok := actual.(float64)
+ b, okB := expected.(float64)
+ if !ok || !okB {
+ return false, false
+ }
+ switch p.Operator {
+ case "gt":
+ return a > b, true
+ case "gte":
+ return a >= b, true
+ case "lt":
+ return a < b, true
+ case "lte":
+ return a <= b, true
+ }
+ return false, false
+}
+
+func fact(p contract.RoutingPredicate, f Facts) (any, bool) {
+ switch p.Field {
+ case "request.model":
+ return f.Model, true
+ case "request.protocol":
+ return f.Protocol, true
+ case "request.streaming":
+ return f.Streaming, true
+ case "request.has_tools":
+ if f.HasTools != nil {
+ return *f.HasTools, true
+ }
+ case "request.has_images":
+ if f.HasImages != nil {
+ return *f.HasImages, true
+ }
+ case "last.status":
+ if f.LastStatus != nil {
+ return float64(*f.LastStatus), true
+ }
+ case "last.error":
+ if f.LastError != nil {
+ return *f.LastError, true
+ }
+ case "attempts":
+ return float64(f.Attempts), true
+ case "quota.exhausted", "quota.used_percent":
+ usage, ok := f.Quota[p.ServiceID]
+ if !ok || usage.FetchedAt.IsZero() || f.Now.Sub(usage.FetchedAt) > 10*time.Minute || usage.FetchedAt.After(f.Now.Add(time.Minute)) {
+ return nil, false
+ }
+ window := usage.Primary
+ if p.Window == "secondary" {
+ window = usage.Secondary
+ }
+ if window == nil {
+ return nil, false
+ }
+ reset := window.ResetAt
+ if reset == nil && window.ResetAfterSeconds != nil {
+ at := usage.FetchedAt.Add(time.Duration(*window.ResetAfterSeconds) * time.Second)
+ reset = &at
+ }
+ if reset != nil && !f.Now.Before(*reset) {
+ return nil, false
+ }
+ if p.Field == "quota.exhausted" {
+ return window.UsedPercent >= 100, true
+ }
+ return window.UsedPercent, true
+ }
+ return nil, false
+}
+
+type PreviewInput struct {
+ Graph contract.RoutingGraph `json:"graph"`
+ EntryID string `json:"entry_id"`
+ Facts Facts `json:"facts"`
+ Outcomes map[string]string `json:"outcomes"`
+}
+type Preview struct {
+ Steps []contract.RoutingGraphStep `json:"steps"`
+ StopReason string `json:"stop_reason"`
+ Attempts int `json:"attempts"`
+}
diff --git a/core/internal/routinggraph/run_test.go b/core/internal/routinggraph/run_test.go
new file mode 100644
index 0000000..7d6266e
--- /dev/null
+++ b/core/internal/routinggraph/run_test.go
@@ -0,0 +1,40 @@
+package routinggraph
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/astrlink/core/contract"
+)
+
+func TestThreeValuedConditionsAndQuotaExpiry(t *testing.T) {
+ now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ unknown := contract.RoutingPredicate{Field: "quota.exhausted", Operator: "eq", Value: json.RawMessage(`true`), ServiceID: "service_test", Window: "primary"}
+ truePredicate := contract.RoutingPredicate{Field: "request.streaming", Operator: "eq", Value: json.RawMessage(`true`)}
+ facts := Facts{Streaming: true, Now: now}
+ if _, known := Evaluate(unknown, facts); known {
+ t.Fatal("missing quota was treated as known")
+ }
+ if value, known := Evaluate(contract.RoutingPredicate{Any: []contract.RoutingPredicate{unknown, truePredicate}}, facts); !known || !value {
+ t.Fatal("true OR unknown must be true")
+ }
+ falsePredicate := truePredicate
+ falsePredicate.Value = json.RawMessage(`false`)
+ if value, known := Evaluate(contract.RoutingPredicate{All: []contract.RoutingPredicate{unknown, falsePredicate}}, facts); !known || value {
+ t.Fatal("false AND unknown must be false")
+ }
+ reset := now.Add(time.Minute)
+ facts.Quota = map[contract.ServiceID]contract.SubscriptionUsage{"service_test": {ServiceID: "service_test", FetchedAt: now, Primary: &contract.RateLimitWindow{UsedPercent: 100, ResetAt: &reset}}}
+ if value, known := Evaluate(unknown, facts); !known || !value {
+ t.Fatal("confirmed exhausted quota was ignored")
+ }
+ facts.Now = reset.Add(time.Second)
+ if _, known := Evaluate(unknown, facts); known {
+ t.Fatal("an unverified reset must become unknown")
+ }
+ facts.Now = now.Add(11 * time.Minute)
+ if _, known := Evaluate(unknown, facts); known {
+ t.Fatal("stale quota must become unknown")
+ }
+}
diff --git a/core/internal/storage/migrate/defaults.go b/core/internal/storage/migrate/defaults.go
index 934c6cf..c688058 100644
--- a/core/internal/storage/migrate/defaults.go
+++ b/core/internal/storage/migrate/defaults.go
@@ -655,5 +655,11 @@ WHERE id = 'policy_privacy_default'
{Version: 35, Name: "request_model_redirect", Statements: []string{
`ALTER TABLE request_records ADD COLUMN model_redirect_json TEXT`,
}},
+ {Version: 36, Name: "model_routing_graph", Statements: []string{
+ `CREATE TABLE model_routing_graph (id INTEGER PRIMARY KEY CHECK(id=1), draft_json TEXT NOT NULL, layout_json TEXT NOT NULL, active_revision INTEGER NOT NULL DEFAULT 0)`,
+ `INSERT INTO model_routing_graph VALUES(1,'{"nodes":[],"edges":[]}','{}',0)`,
+ `CREATE TABLE model_routing_revisions (revision INTEGER PRIMARY KEY AUTOINCREMENT, document_json TEXT NOT NULL, created_at TEXT NOT NULL)`,
+ `CREATE TABLE model_routing_quota (service_id TEXT PRIMARY KEY REFERENCES services(id) ON DELETE CASCADE, document_json TEXT NOT NULL)`,
+ }},
}
}
diff --git a/core/internal/storage/routing_graph.go b/core/internal/storage/routing_graph.go
new file mode 100644
index 0000000..585f68f
--- /dev/null
+++ b/core/internal/storage/routing_graph.go
@@ -0,0 +1,17 @@
+package storage
+
+import (
+ "context"
+ "github.com/QuantumNous/astrlink/core/contract"
+)
+
+type RoutingGraphStore interface {
+ GetRoutingGraph(context.Context) (contract.RoutingGraphDocument, error)
+ GetActiveRoutingGraph(context.Context) (contract.RoutingGraph, int64, error)
+ SaveRoutingGraph(context.Context, contract.RoutingGraph, contract.RoutingLayout, string, bool) (contract.RoutingGraphDocument, error)
+ GetRoutingGraphRevision(context.Context, int64) (contract.RoutingGraph, error)
+}
+
+type RoutingQuotaReader interface {
+ GetRoutingQuota(context.Context) (map[contract.ServiceID]contract.SubscriptionUsage, error)
+}
diff --git a/core/internal/storage/sqlite/pricing.go b/core/internal/storage/sqlite/pricing.go
index d29ad26..d3bb9ee 100644
--- a/core/internal/storage/sqlite/pricing.go
+++ b/core/internal/storage/sqlite/pricing.go
@@ -561,6 +561,16 @@ func (s *Store) ObserveSubscriptionUsage(ctx context.Context, service contract.S
if service.ID != usage.ServiceID {
return fmt.Errorf("usage service mismatch")
}
+ if err := usage.Validate(); err != nil {
+ return err
+ }
+ usageJSON, err := json.Marshal(usage)
+ if err != nil {
+ return err
+ }
+ if _, err = s.db.ExecContext(ctx, `INSERT INTO model_routing_quota(service_id,document_json) VALUES(?,?) ON CONFLICT(service_id) DO UPDATE SET document_json=excluded.document_json WHERE julianday(json_extract(excluded.document_json,'$.fetched_at')) >= COALESCE(julianday(json_extract(model_routing_quota.document_json,'$.fetched_at')), 0)`, service.ID, string(usageJSON)); err != nil {
+ return err
+ }
key := pricing.AccountKey(service)
windows := map[string]*contract.RateLimitWindow{"primary": usage.Primary, "secondary": usage.Secondary}
for _, extra := range usage.AdditionalRateLimits {
@@ -607,6 +617,9 @@ func (s *Store) ObserveSubscriptionReset(ctx context.Context, service contract.S
if _, err = tx.ExecContext(ctx, `UPDATE billing_periods SET end_at=?,closed=1 WHERE service_id=? AND account_key=? AND start_at AND end_at>? AND closed=0`, at, service.ID, key, at, at); err != nil {
return err
}
+ if _, err = tx.ExecContext(ctx, `DELETE FROM model_routing_quota WHERE service_id=?`, service.ID); err != nil {
+ return err
+ }
return tx.Commit()
}
func (s *Store) ServiceBilling(ctx context.Context, id contract.ServiceID) (pricing.ServiceReport, error) {
diff --git a/core/internal/storage/sqlite/routing_graph.go b/core/internal/storage/sqlite/routing_graph.go
new file mode 100644
index 0000000..270319c
--- /dev/null
+++ b/core/internal/storage/sqlite/routing_graph.go
@@ -0,0 +1,174 @@
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/QuantumNous/astrlink/core/contract"
+ "github.com/QuantumNous/astrlink/core/internal/storage"
+)
+
+type graphQuery interface {
+ QueryRowContext(context.Context, string, ...any) *sql.Row
+ QueryContext(context.Context, string, ...any) (*sql.Rows, error)
+}
+
+func readGraph(ctx context.Context, db graphQuery) (contract.RoutingGraphDocument, error) {
+ doc := contract.RoutingGraphDocument{Active: contract.EmptyRoutingGraph(), Layout: contract.RoutingLayout{}, History: []contract.RoutingGraphRevision{}}
+ var draft, layout string
+ if err := db.QueryRowContext(ctx, `SELECT draft_json,layout_json,active_revision FROM model_routing_graph WHERE id=1`).Scan(&draft, &layout, &doc.Revision); err != nil {
+ return doc, err
+ }
+ if json.Unmarshal([]byte(draft), &doc.Draft) != nil || json.Unmarshal([]byte(layout), &doc.Layout) != nil {
+ return doc, fmt.Errorf("%w: routing graph draft", storage.ErrInvalidRecord)
+ }
+ if err := doc.Draft.ValidateDraft(doc.Layout); err != nil {
+ return doc, fmt.Errorf("%w: %v", storage.ErrInvalidRecord, err)
+ }
+ if doc.Revision > 0 {
+ var active string
+ if err := db.QueryRowContext(ctx, `SELECT document_json FROM model_routing_revisions WHERE revision=?`, doc.Revision).Scan(&active); err != nil {
+ return doc, err
+ }
+ if json.Unmarshal([]byte(active), &doc.Active) != nil {
+ return doc, fmt.Errorf("%w: active routing graph", storage.ErrInvalidRecord)
+ }
+ if err := doc.Active.Validate(); err != nil {
+ return doc, fmt.Errorf("%w: %v", storage.ErrInvalidRecord, err)
+ }
+ }
+ raw, _ := json.Marshal([]any{doc.Draft, doc.Layout, doc.Revision})
+ doc.ETag = entityTag(raw)
+ rows, err := db.QueryContext(ctx, `SELECT revision,created_at FROM model_routing_revisions ORDER BY revision DESC LIMIT 20`)
+ if err != nil {
+ return doc, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var revision contract.RoutingGraphRevision
+ if err := rows.Scan(&revision.Revision, &revision.CreatedAt); err != nil {
+ return doc, err
+ }
+ doc.History = append(doc.History, revision)
+ }
+ return doc, rows.Err()
+}
+
+func (store *Store) GetRoutingGraph(ctx context.Context) (contract.RoutingGraphDocument, error) {
+ return readGraph(ctx, store.db)
+}
+
+func (store *Store) GetActiveRoutingGraph(ctx context.Context) (contract.RoutingGraph, int64, error) {
+ graph := contract.EmptyRoutingGraph()
+ var revision int64
+ var raw string
+ err := store.db.QueryRowContext(ctx, `SELECT g.active_revision,COALESCE(r.document_json,'{"nodes":[],"edges":[]}') FROM model_routing_graph g LEFT JOIN model_routing_revisions r ON r.revision=g.active_revision WHERE g.id=1`).Scan(&revision, &raw)
+ if err != nil {
+ return graph, 0, err
+ }
+ if json.Unmarshal([]byte(raw), &graph) != nil {
+ return graph, 0, fmt.Errorf("%w: routing graph", storage.ErrInvalidRecord)
+ }
+ if err := graph.Validate(); err != nil {
+ return graph, 0, fmt.Errorf("%w: %v", storage.ErrInvalidRecord, err)
+ }
+ return graph, revision, nil
+}
+
+func (store *Store) GetRoutingGraphRevision(ctx context.Context, revision int64) (contract.RoutingGraph, error) {
+ var raw string
+ graph := contract.EmptyRoutingGraph()
+ if err := store.db.QueryRowContext(ctx, `SELECT document_json FROM model_routing_revisions WHERE revision=?`, revision).Scan(&raw); err != nil {
+ if err == sql.ErrNoRows {
+ return graph, storage.ErrNotFound
+ }
+ return graph, err
+ }
+ if err := json.Unmarshal([]byte(raw), &graph); err != nil {
+ return graph, fmt.Errorf("%w: routing graph revision", storage.ErrInvalidRecord)
+ }
+ return graph, graph.Validate()
+}
+
+func (store *Store) SaveRoutingGraph(ctx context.Context, graph contract.RoutingGraph, layout contract.RoutingLayout, expected string, apply bool) (result contract.RoutingGraphDocument, err error) {
+ if err = graph.ValidateDraft(layout); err != nil {
+ return result, fmt.Errorf("%w: %v", storage.ErrInvalidArgument, err)
+ }
+ if expected == "" {
+ return result, storage.ErrPrecondition
+ }
+ if apply {
+ if err = graph.Validate(); err != nil {
+ return result, fmt.Errorf("%w: %v", storage.ErrInvalidArgument, err)
+ }
+ }
+ if graph.Nodes == nil {
+ graph.Nodes = []contract.RoutingGraphNode{}
+ }
+ if graph.Edges == nil {
+ graph.Edges = []contract.RoutingGraphEdge{}
+ }
+ if layout == nil {
+ layout = contract.RoutingLayout{}
+ }
+ tx, err := store.db.BeginTx(ctx, nil)
+ if err != nil {
+ return result, err
+ }
+ defer rollbackOnError(tx, &err)
+ if _, err = tx.ExecContext(ctx, `UPDATE model_routing_graph SET id=id WHERE id=1`); err != nil {
+ return result, err
+ }
+ current, err := readGraph(ctx, tx)
+ if err != nil {
+ return result, err
+ }
+ if current.ETag != expected {
+ return result, fmt.Errorf("%w: routing graph changed; reload before saving", storage.ErrPrecondition)
+ }
+ raw, _ := json.Marshal(graph)
+ positions, _ := json.Marshal(layout)
+ revision := current.Revision
+ if apply {
+ insert, insertErr := tx.ExecContext(ctx, `INSERT INTO model_routing_revisions(document_json,created_at) VALUES(?,?)`, string(raw), store.now().UTC().Format(time.RFC3339Nano))
+ if insertErr != nil {
+ return result, insertErr
+ }
+ revision, err = insert.LastInsertId()
+ if err != nil {
+ return result, err
+ }
+ }
+ if _, err = tx.ExecContext(ctx, `UPDATE model_routing_graph SET draft_json=?,layout_json=?,active_revision=? WHERE id=1`, string(raw), string(positions), revision); err != nil {
+ return result, err
+ }
+ result, err = readGraph(ctx, tx)
+ if err != nil {
+ return result, err
+ }
+ err = tx.Commit()
+ return result, err
+}
+
+func (store *Store) GetRoutingQuota(ctx context.Context) (map[contract.ServiceID]contract.SubscriptionUsage, error) {
+ result := map[contract.ServiceID]contract.SubscriptionUsage{}
+ rows, err := store.db.QueryContext(ctx, `SELECT q.document_json FROM model_routing_quota q JOIN services s ON s.id=q.service_id`)
+ if err != nil {
+ return result, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var raw string
+ if err := rows.Scan(&raw); err != nil {
+ return result, err
+ }
+ var usage contract.SubscriptionUsage
+ if json.Unmarshal([]byte(raw), &usage) == nil && usage.Validate() == nil {
+ result[usage.ServiceID] = usage
+ }
+ }
+ return result, rows.Err()
+}
diff --git a/core/internal/storage/sqlite/routing_graph_test.go b/core/internal/storage/sqlite/routing_graph_test.go
new file mode 100644
index 0000000..303f6bb
--- /dev/null
+++ b/core/internal/storage/sqlite/routing_graph_test.go
@@ -0,0 +1,57 @@
+package sqlite
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "testing"
+
+ "github.com/QuantumNous/astrlink/core/contract"
+ "github.com/QuantumNous/astrlink/core/internal/storage"
+)
+
+func TestRoutingGraphDraftApplyConflictAndHistory(t *testing.T) {
+ store := openTestStore(t, filepath.Join(t.TempDir(), "graph.db"))
+ defer store.Close()
+ ctx := context.Background()
+ initial, err := store.GetRoutingGraph(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph := contract.RoutingGraph{Nodes: []contract.RoutingGraphNode{{ID: "entry", Kind: "entry", Model: "public", Enabled: true}}, Edges: []contract.RoutingGraphEdge{}}
+ draft, err := store.SaveRoutingGraph(ctx, graph, nil, initial.ETag, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if draft.Revision != 0 || len(draft.Active.Nodes) != 0 {
+ t.Fatal("draft became active")
+ }
+ if _, err = store.SaveRoutingGraph(ctx, graph, nil, draft.ETag, true); !errors.Is(err, storage.ErrInvalidArgument) {
+ t.Fatalf("incomplete graph applied: %v", err)
+ }
+ graph.Nodes = append(graph.Nodes, contract.RoutingGraphNode{ID: "stop", Kind: "stop", Enabled: true})
+ graph.Edges = append(graph.Edges, contract.RoutingGraphEdge{ID: "edge", Source: "entry", Port: "next", Target: "stop"})
+ if _, err = store.SaveRoutingGraph(ctx, graph, nil, initial.ETag, true); !errors.Is(err, storage.ErrPrecondition) {
+ t.Fatalf("stale graph accepted: %v", err)
+ }
+ active, err := store.SaveRoutingGraph(ctx, graph, nil, draft.ETag, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if active.Revision != 1 || len(active.Active.Nodes) != 2 {
+ t.Fatal("apply did not atomically publish")
+ }
+ graph.Nodes[0].Enabled = false
+ if _, err = store.SaveRoutingGraph(ctx, graph, nil, active.ETag, true); err != nil {
+ t.Fatal(err)
+ }
+ old, err := store.GetRoutingGraphRevision(ctx, 1)
+ if err != nil || !old.Nodes[0].Enabled {
+ t.Fatal("historical graph changed")
+ }
+ graph.Edges = append(graph.Edges, contract.RoutingGraphEdge{ID: "cycle", Source: "stop", Port: "next", Target: "entry"})
+ graph.Nodes[0].Enabled = true
+ if graph.Validate() == nil {
+ t.Fatal("invalid executable edge was accepted")
+ }
+}
From 999d6239faff73ee09ef01519050819f9a97f847 Mon Sep 17 00:00:00 2001
From: seefs001
Date: Fri, 25 Sep 2026 21:18:05 +0800
Subject: [PATCH 2/2] feat: add return-to-content control for routing canvas
---
apps/desktop/src/RoutingGraphEditor.tsx | 15 ++-
.../components/FlowCanvasRecovery.test.tsx | 101 ++++++++++++++++++
.../src/components/FlowCanvasRecovery.tsx | 56 ++++++++++
apps/desktop/src/i18n/locales/en.json | 1 +
apps/desktop/src/i18n/locales/zh-CN.json | 1 +
5 files changed, 173 insertions(+), 1 deletion(-)
create mode 100644 apps/desktop/src/components/FlowCanvasRecovery.test.tsx
create mode 100644 apps/desktop/src/components/FlowCanvasRecovery.tsx
diff --git a/apps/desktop/src/RoutingGraphEditor.tsx b/apps/desktop/src/RoutingGraphEditor.tsx
index bbcc131..98b0979 100644
--- a/apps/desktop/src/RoutingGraphEditor.tsx
+++ b/apps/desktop/src/RoutingGraphEditor.tsx
@@ -54,6 +54,7 @@ import { Field } from "./components/Field";
import { FormMessage } from "./components/FormMessage";
import { EmptyState } from "./components/EmptyState";
import { HelpPopover } from "./components/HelpPopover";
+import { FlowCanvasRecovery } from "./components/FlowCanvasRecovery";
import { ModelSelect } from "./components/ModelSelect";
import { Panel } from "./components/Panel";
import { Button } from "./components/ui/button";
@@ -316,7 +317,9 @@ function GraphEditor({ ready, services, onDirtyChange, onSettings }: Props) {
nodes: id ? [{ id }] : undefined,
padding: 0.22,
maxZoom: 1,
- duration: 200,
+ duration: window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ ? 0
+ : 200,
});
};
const onConnect = (connection: Connection) => {
@@ -868,6 +871,16 @@ function GraphEditor({ ready, services, onDirtyChange, onSettings }: Props) {
color="var(--border)"
/>
+ {
+ const target =
+ nodes.find((node) => node.id === selected) ??
+ nodes.find((node) => node.id === focus) ??
+ nodes[0];
+ if (target) center(target.id);
+ }}
+ />
{graph.nodes.length > 12 ? (
;
+const onReturn = vi.fn();
+const node = (id: string, x = 100): Node => ({
+ id,
+ position: { x, y: 100 },
+ measured: { width: 250, height: 150 },
+ data: {},
+});
+
+function Harness() {
+ store = useStoreApi();
+ return ;
+}
+
+beforeEach(() => {
+ onReturn.mockReset();
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+});
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+});
+async function render(nodes: Node[]) {
+ await act(async () =>
+ root.render(
+
+
+ ,
+ ),
+ );
+}
+const button = () => container.querySelector("button");
+
+it("offers a return only when content leaves the viewport and hides it after returning", async () => {
+ await render([node("entry")]);
+ expect(button()).toBeNull();
+ const originalNodes = store.getState().nodes;
+ await act(async () => store.setState({ transform: [-2000, -1200, 1] }));
+ expect(button()?.textContent).toBe("回到内容");
+ onReturn.mockImplementation(() => store.setState({ transform: [0, 0, 1] }));
+ await act(async () => button()!.click());
+ expect(onReturn).toHaveBeenCalledOnce();
+ expect(button()).toBeNull();
+ expect(store.getState().nodes).toBe(originalNodes);
+});
+
+it("uses individual nodes, zoom, partial visibility and viewport size", async () => {
+ await render([node("a"), node("b", 4000)]);
+ // Being inside the combined bounds does not mean a node is visible.
+ await act(async () => store.setState({ transform: [-2000, 0, 1] }));
+ expect(button()).not.toBeNull();
+ await act(async () => store.getState().setNodes([node("a")]));
+ for (const [x, zoom, outside] of [
+ [-340, 1, false],
+ [-350, 1, true],
+ [-100, 0.25, true],
+ [-75, 0.25, false],
+ ] as const) {
+ await act(async () => store.setState({ transform: [x, 0, zoom] }));
+ expect(button() !== null).toBe(outside);
+ }
+ await act(async () => store.setState({ transform: [0, 0, 1], height: 100 }));
+ expect(button()).not.toBeNull();
+ await act(async () => store.setState({ height: 500 }));
+ expect(button()).toBeNull();
+ await act(async () => store.setState({ width: 0 }));
+ expect(button()).toBeNull();
+});
+
+it("does not offer a return for empty, hidden or unmeasured content", async () => {
+ await render([]);
+ await act(async () => store.setState({ transform: [-2000, 0, 1] }));
+ expect(button()).toBeNull();
+ await act(async () =>
+ store.getState().setNodes([{ ...node("hidden"), hidden: true }]),
+ );
+ expect(button()).toBeNull();
+ await act(async () =>
+ store.getState().setNodes([{ ...node("loading"), measured: {} }]),
+ );
+ expect(button()).toBeNull();
+ await act(async () => store.getState().setNodes([node("ready")]));
+ expect(button()).not.toBeNull();
+ await act(async () => store.getState().setNodes([]));
+ expect(button()).toBeNull();
+});
diff --git a/apps/desktop/src/components/FlowCanvasRecovery.tsx b/apps/desktop/src/components/FlowCanvasRecovery.tsx
new file mode 100644
index 0000000..be0fa56
--- /dev/null
+++ b/apps/desktop/src/components/FlowCanvasRecovery.tsx
@@ -0,0 +1,56 @@
+import {
+ Panel as FlowPanel,
+ useStore,
+ type ReactFlowState,
+} from "@xyflow/react";
+import { MapPin } from "./icons";
+import { Button } from "./ui/button";
+
+function contentIsOffscreen({
+ width,
+ height,
+ transform,
+ nodeLookup,
+}: ReactFlowState) {
+ if (width <= 0 || height <= 0) return false;
+ const [offsetX, offsetY, zoom] = transform;
+ let hasContent = false;
+ // Check each node: the viewport can sit in empty space between distant nodes.
+ for (const node of nodeLookup.values()) {
+ if (node.hidden) continue;
+ const nodeWidth = node.measured.width ?? node.width;
+ const nodeHeight = node.measured.height ?? node.height;
+ if (!nodeWidth || !nodeHeight) return false;
+ hasContent = true;
+ const left = node.internals.positionAbsolute.x * zoom + offsetX;
+ const top = node.internals.positionAbsolute.y * zoom + offsetY;
+ if (
+ left < width &&
+ left + nodeWidth * zoom > 0 &&
+ top < height &&
+ top + nodeHeight * zoom > 0
+ )
+ return false;
+ }
+ return hasContent;
+}
+
+export function FlowCanvasRecovery({
+ label,
+ onReturn,
+}: {
+ label: string;
+ onReturn: () => void;
+}) {
+ // Only rerender when visibility changes, not on every frame of a pan or zoom.
+ const offscreen = useStore(contentIsOffscreen);
+ if (!offscreen) return null;
+ return (
+
+
+
+ );
+}
diff --git a/apps/desktop/src/i18n/locales/en.json b/apps/desktop/src/i18n/locales/en.json
index 580432b..8a03d0d 100644
--- a/apps/desktop/src/i18n/locales/en.json
+++ b/apps/desktop/src/i18n/locales/en.json
@@ -2481,6 +2481,7 @@
"quickOrder": "Drag to reorder fallback calls",
"focus": "Focus an entry (— shows all)",
"fit": "Fit canvas",
+ "returnToContent": "Back to content",
"arrange": "Arrange",
"canvasHint": "Dragging changes layout · Edges define routing · Success returns immediately",
"defaultHint": "Unmatched models keep existing redirects and the global provider order.",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json
index 4768a6b..f89bafd 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.json
+++ b/apps/desktop/src/i18n/locales/zh-CN.json
@@ -2481,6 +2481,7 @@
"quickOrder": "拖动调整后备顺序",
"focus": "聚焦一个入口(— 表示全部)",
"fit": "适合画布",
+ "returnToContent": "回到内容",
"arrange": "整理布局",
"canvasHint": "拖动节点只改变位置 · 连线决定路径 · 成功即返回",
"defaultHint": "未匹配入口的模型继续使用原来的重定向与全局顺序。",