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}`)} + + +
+
+ {affected.length > 1 ? ( +
+ + {t("graph.sharedImpact", { count: affected.length })} + +

+ {affected.map((entry) => entry.model).join(" · ")} +

+ ({ + value: entry.id, + label: entry.model ?? entry.id, + }))} + onChange={setFocus} + /> + +

+ {t("graph.isolateHint")} +

+
+ ) : null} + {current.kind === "entry" || current.kind === "call" ? ( +
+ + {t( + current.kind === "call" + ? "graph.nodeEnabled" + : "graph.entryEnabled", + )} + + toggle(current.id)} + aria-label={t("graph.nodeEnabled")} + /> +
+ ) : null} + {!current.enabled && current.kind === "call" ? ( + {t("graph.disabledHint")} + ) : null} + {current.kind === "entry" ? ( + <> + + + patchNode(current.id, { model: event.target.value }) + } + /> + + {portEditor("next", t("graph.firstSource"))} + + + patchNode(current.id, { + max_attempts: Number(event.target.value), + }) + } + /> + + {calls && calls.length > 0 ? ( +
+ + {t("graph.quickOrder")} + + + editGraph((graph) => + reorderCalls( + graph, + current.id, + nodes.map((node) => node.id), + ), + ) + } + > + {(node, _, controls) => ( +
+ {controls} + +
+ )} +
+
+ ) : null} + + ) : null} + {current.kind === "call" ? ( + <> + + ({ + value: service.id, + label: service.name, + }))} + onChange={(service_id) => + patchNode(current.id, { service_id }) + } + /> + + + service.id === current.service_id, + )?.models ?? [] + } + value={current.upstream_model ?? ""} + onValueChange={(upstream_model) => + patchNode(current.id, { upstream_model }) + } + /> + + {portEditor("failure", t("graph.onFailure"))} +
+ + {t("graph.advancedPolicy")} + +
+ + {current.failure_policy ? ( + + patchNode(current.id, { failure_policy }) + } + title={t("graph.advancedPolicy")} + /> + ) : ( +

+ {t("graph.inheritPolicy")} +

+ )} +
+
+ + + + ) : null} + {current.kind === "condition" ? ( + <> + + + patchNode(current.id, { name: event.target.value }) + } + /> + + {(current.rules ?? []).map((rule, index) => ( +
+
+ + {t("graph.rule")} {index + 1} + + +
+ + patchNode(current.id, { + rules: current.rules?.map((item) => + item.id === rule.id + ? { ...item, label: event.target.value } + : item, + ), + }) + } + /> + + patchNode(current.id, { + rules: current.rules?.map((item) => + item.id === rule.id + ? { ...item, predicate } + : item, + ), + }) + } + /> + {portEditor(rule.id, t("graph.whenMatched"))} +
+ ))} + + {portEditor("otherwise", t("graph.otherwise"))} + + ({ + value: rule.id, + label: rule.label || rule.id, + })), + { value: "unknown", label: t("graph.separateUnknown") }, + ]} + onChange={(unknown_port) => + patchNode(current.id, { + unknown_port: unknown_port || "otherwise", + }) + } + /> + + {current.unknown_port === "unknown" + ? portEditor("unknown", t("graph.unknown")) + : null} + + ) : null} + {current.kind === "stop" ? ( + + + patchNode(current.id, { name: event.target.value }) + } + /> + + ) : null} + +
+
+ ) : 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} + { + if (!open) setAdd(null); + }} + > + + + + {t(add?.after ? "graph.addFallback" : "graph.addNode")} + + {t("graph.addHint")} + + {add ? ( +
+ !add.after || kind !== "entry") + .map((kind) => ({ + value: kind, + label: t(`graph.kinds.${kind}`), + }))} + onChange={(kind) => + setAdd({ ...add, kind: (kind || "call") as GraphKind }) + } + /> + {add.kind === "entry" ? ( + + service.models)), + ]} + onValueChange={(publicModel) => + setAdd({ ...add, publicModel }) + } + /> + + ) : add.kind === "call" ? ( + <> + + ({ + value: service.id, + label: service.name, + }))} + onChange={(service) => + setAdd({ + ...add, + service, + model: + services.find((item) => item.id === service) + ?.models[0] ?? "", + }) + } + /> + + + service.id === add.service) + ?.models ?? [] + } + onValueChange={(model) => setAdd({ ...add, model })} + /> + + + ) : null} +
+ ) : null} + + + + +
+
+ + + + {t("graph.simulate")} + {t("graph.simulateHint")} + +
+ + ({ + value: entry.id, + label: entry.model ?? entry.id, + }))} + onChange={setTestEntry} + /> + + + ({ value, label: value }))} + onChange={setProtocol} + /> + +
+ {[ + { value: streaming, set: setStreaming, key: "streaming" }, + { value: hasTools, set: setHasTools, key: "tools" }, + { value: hasImages, set: setHasImages, key: "images" }, + ].map((item) => ( + + ))} +
+ {graph.nodes + .filter( + (node) => + node.kind === "call" && + reachable(graph, testEntry).has(node.id), + ) + .map((node) => ( + service.id === node.service_id)?.name ?? ""}`} + hint={!node.enabled ? t("graph.disabledHint") : undefined} + > + ({ + value, + label: t(`graph.outcomes.${value}`), + }))} + onChange={(value) => + setOutcomes({ + ...outcomes, + [node.id]: value || "success", + }) + } + /> + + ))} +
+ + + +
+
+ + + + {t("graph.openRequest")} + {t("graph.openRequestHint")} + + setRequestID(event.target.value)} + placeholder="request_…" + /> + + + + + +
+ ); +} 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) => ( +
+ {port.label} + + +
+ ))} +
+ ) : 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 ( +
  1. + +
  2. + ); + })} +
+ ); +} 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 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": "未匹配入口的模型继续使用原来的重定向与全局顺序。",