Skip to content

feat(gateway): dynamic routes via dynamic/<name> - #3432

Merged
steebchen merged 21 commits into
mainfrom
cloudflare-dynamic-routing
Aug 8, 2026
Merged

steebchen merged 21 commits into
mainfrom
cloudflare-dynamic-routing

Conversation

@steebchen

@steebchen steebchen commented Aug 5, 2026 •

Copy link
Copy Markdown
Member

Problem

There is no way to define reusable, versioned routing logic. Callers must hardcode a model (or use auto), so A/B tests, gradual rollouts, and per-segment model selection all require client-side changes and redeploys. Cloudflare AI Gateway ships this as "dynamic routing" (https://developers.cloudflare.com/ai-gateway/features/dynamic-routing/).

Approach

Named, versioned dynamic routes: put dynamic/<name> in the OpenAI-compatible model field and the gateway evaluates a per-project routing graph at request time, then hands the resolved target to the existing scoring + fallback machinery (nothing re-implemented).

Graph nodes (Zod-validated in packages/shared/src/dynamic-route.ts, shared by API + gateway):

  • conditional — if/else on request headers, body dot-paths, or request metadata (orgId/projectId/apiKeyId/plan); ops: eq, neq, in, contains, gt, lt, exists
  • percentage — weighted splits, deterministic per session (x-session-id et al hashed via FNV-1a) so A/B assignments stay stable across a conversation; per-request otherwise
  • model — target catalog model + optional provider allowlist; provider selection/fallback within the target still uses weighted smart routing
  • end — explicit reject branch (400)

Integration seam: parseModelInput only tags the dynamic/ prefix and piggybacks the existing auto placeholder model; the graph is evaluated in chat.ts right before the auto-routing block (where project/org context exists). The resolved model narrows the auto-candidate loop to exactly that model/providers, so IAM, compliance, capability filters, dev-plan gating, scoring, sticky sessions, and cross-provider retry all apply unchanged. A route never falls back to the hardcoded default model — it 400s instead. routingMetadata.dynamicRoute records {name, version, path} for observability.

Versioning: dynamic_route holds a mutable draft + published_version_id pointer; publishing snapshots the draft into an immutable dynamic_route_version row (re-validated against the current model catalog at publish time). Rollback just re-points the pointer. Gateway reads the published version through an SWR-cached loader; API mutations write via cdb so caches bust centrally.

Access: enterprise-only (same gate as routing config). dynamic is now a reserved custom-provider name so BYOK providers can't shadow the prefix.

Surfaces:

  • API: /dynamic-routes/* CRUD with draft/publish/rollback/versions (Hono + Zod + OpenAPI)
  • UI: "Dynamic Routes" project settings page — route list, Visual (React Flow) + JSON draft editors, publish/rollback, version history, enable toggle; contact-sales card off-plan

Verification

  • 21 new unit tests (evaluator, graph schema validation, model-input parsing) — all pass; full unit suite green (one pre-existing flaky fallback.spec.ts log-wait timeout passes in isolation)
  • Manually verified against a local gateway + Postgres/Redis with a published 3-branch route:
    • conditional header match → routed to gemini-2.5-flash ✔
    • percentage split distributes across both branches and is sticky per x-session-id ✔
    • unknown/disabled/unpublished route → 404, non-enterprise org → 403, invalid route name → 400 ✔
  • DB-outage resilience verified end-to-end: primed the caches, then ran a gateway instance pointed at an unreachable DATABASE_URL (past the 60s Drizzle-cache TTL, so the SWR stale mirrors were doing the work). dynamic/support kept serving HTTP 200 with correct conditional routing and sticky percentage assignment; the log shows serving SWR stale fallback for dynamicRoute:<project>:<name> alongside apiKey/project/org/IAM/provider-key/routing-config mirrors. Route lookups ride the same two-layer store as every other gateway config read (Redis query cache → Postgres → 4h stale mirror on failure), and API mutations invalidate both layers via cdb.
  • pnpm format, pnpm build (17/17), migration generated via drizzle-kit

Docs

Ships with a changelog entry (/changelog/dynamic-routes, with generated OG image) and a docs page (docs.llmgateway.io/features/dynamic-routes) covering the graph format, node types, condition operators, validation rules, versioning/rollback, and the caching/stale-fallback behavior.

Deliberate MVP omissions (follow-ups)

  • Cross-model ordered failover (a model group failing over to a different model)
  • Rate-limit / budget-limit nodes (will reuse existing sliding-window + credits infra)

Screenshots

Route list with published/unpublished states:

Dynamic Routes settings page showing the route list

Editing the support route (conditional + percentage graph, publish controls, version history with one-click rollback):

Dynamic route editor with JSON draft, publish controls and version history

Visual flow editor (second commit): the route editor now has Visual/JSON tabs. The visual tab is a React Flow canvas with a drag-and-drop node palette, per-branch connection handles (each condition, else, and split is its own handle), and a node inspector with a catalog model picker and an ordered provider-fallback list. Both tabs edit the same draft; saving is blocked (with a readable error list) until the graph passes the same shared Zod validation the API enforces — unconnected branches, unset models, unknown catalog ids, and cycles all surface before save.

Visual flow editor tab with drag-and-drop nodes, branch handles and node inspector

Example configs

Invoke any of these with {"model": "dynamic/<name>", ...} on /v1/chat/completions.

Tiered support — paid users (via header) get Claude, everyone else is A/B split 80/20:

{
	"entry": "tier",
	"nodes": [
		{
			"id": "tier",
			"type": "conditional",
			"conditions": [
				{ "field": { "source": "header", "path": "x-user-tier" }, "op": "eq", "value": "paid", "next": "premium" },
				{ "field": { "source": "body", "path": "metadata.beta" }, "op": "eq", "value": true, "next": "split" }
			],
			"else": "split"
		},
		{ "id": "premium", "type": "model", "model": "claude-sonnet-4-6" },
		{
			"id": "split",
			"type": "percentage",
			"splits": [
				{ "weight": 80, "next": "stable" },
				{ "weight": 20, "next": "experiment" }
			]
		},
		{ "id": "stable", "type": "model", "model": "gpt-5-nano", "providers": ["openai"] },
		{ "id": "experiment", "type": "model", "model": "gemini-2.5-flash" }
	]
}

Canary rollout — 95/5 gradual rollout, sticky per session so a conversation never flip-flops:

{
	"entry": "rollout",
	"nodes": [
		{
			"id": "rollout",
			"type": "percentage",
			"splits": [
				{ "weight": 95, "next": "current" },
				{ "weight": 5, "next": "canary" }
			]
		},
		{ "id": "current", "type": "model", "model": "gpt-4o-mini" },
		{ "id": "canary", "type": "model", "model": "gpt-5-nano" }
	]
}

Metadata gate — allow only enterprise-plan traffic, explicitly reject the rest with a 400:

{
	"entry": "gate",
	"nodes": [
		{
			"id": "gate",
			"type": "conditional",
			"conditions": [
				{ "field": { "source": "metadata", "path": "plan" }, "op": "eq", "value": "enterprise", "next": "allowed" }
			],
			"else": "reject"
		},
		{ "id": "allowed", "type": "model", "model": "claude-sonnet-4-6" },
		{ "id": "reject", "type": "end" }
	]
}

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added enterprise Dynamic Routes for project-specific model routing based on headers, request data, metadata, and percentage splits.
    • Added visual graph and JSON editors for drafting, publishing, versioning, enabling, disabling, and rolling back routes.
    • Added dynamic/<route-name> support in chat completions.
    • Added routing details showing the selected route version and evaluation path.
  • Validation

    • Added graph validation and clearer errors for invalid routes, unavailable providers, and reserved provider names.
  • Documentation

    • Documented Dynamic Routes configuration, validation, versioning, rollback, and observability.

Add Cloudflare-AI-Gateway-style named dynamic routes: versioned
per-project routing graphs invoked by putting dynamic/<name> in the
model field. Graphs support conditional nodes (header/body/metadata),
session-sticky percentage splits, model nodes with optional provider
allowlists, and end nodes. Resolution hands off to the existing
auto-routing scoring and provider fallback machinery.

- packages/shared: dynamic-route graph Zod schema + validation
- packages/db: dynamic_route + dynamic_route_version tables (draft +
  published version pointer, instant rollback)
- gateway: parse dynamic/ prefix, SWR-cached route loader, pure graph
  evaluator, chat.ts handoff into the auto-routing block
- api: enterprise-gated CRUD with draft/publish/rollback/versions
- ui: Dynamic Routes settings page with JSON editor
- reserve "dynamic" as a custom provider name

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 5, 2026 17:26
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds project-scoped dynamic routes with validated graphs, drafts, publishing, rollback, version history, gateway execution, routing metadata, and an enterprise dashboard management page.

Changes

Dynamic Routes

Layer / File(s) Summary
Route graph contracts and validation
packages/shared/src/dynamic-route.ts, packages/shared/src/custom-providers.ts, packages/shared/src/index.ts, packages/shared/package.json, apps/api/src/routes/keys-provider.ts
Defines graph node types, validation rules, route-name parsing, evaluation limits, and reserved provider names.
Graph evaluation engine
apps/gateway/src/chat/tools/evaluate-dynamic-route.ts, apps/gateway/src/chat/tools/evaluate-dynamic-route.spec.ts
Evaluates request conditions and percentage splits, follows graph paths, enforces hop limits, and validates evaluation behavior.
Versioned route storage and API
packages/db/src/schema.ts, packages/db/src/relations.ts, packages/db/migrations/*, apps/api/src/routes/dynamic-routes.ts, apps/api/src/routes/routing-config.ts, apps/api/src/routes/index.ts
Adds route and version tables plus authenticated endpoints for route CRUD, draft saving, publishing, rollback, and version history.
Chat route resolution
apps/gateway/src/chat/tools/parse-model-input.ts, apps/gateway/src/chat/tools/parse-model-input.spec.ts, apps/gateway/src/lib/dynamic-route-loader.ts, apps/gateway/src/chat/chat.ts, packages/actions/src/get-cheapest-from-available-providers.ts
Resolves published dynamic/<name> routes during chat completions, constrains model and provider selection, and records route metadata.
Dashboard route management
apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/dynamic-routes/*, apps/ui/src/components/dashboard/dashboard-sidebar.tsx, apps/ui/package.json
Adds enterprise-admin route management for drafts, publishing, rollback, enablement, deletion, and version inspection through a visual graph editor.
Feature documentation
apps/docs/content/features/dynamic-routes.mdx, apps/ui/src/content/changelog/2026-08-07-dynamic-routes.md
Documents route invocation, graph behavior, validation, versioning, observability, caching, and Enterprise availability.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Dashboard
  participant DynamicRoutesAPI
  participant Database
  participant ChatGateway
  participant RouteEvaluator

  Dashboard->>DynamicRoutesAPI: create or update route draft
  DynamicRoutesAPI->>Database: store route graph
  Dashboard->>DynamicRoutesAPI: publish draft
  DynamicRoutesAPI->>Database: create version and update published pointer
  ChatGateway->>Database: load enabled published route
  ChatGateway->>RouteEvaluator: evaluate graph with request context
  RouteEvaluator-->>ChatGateway: selected model and traversal path
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main feature: gateway support for dynamic routes invoked with dynamic/.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch cloudflare-dynamic-routing
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cloudflare-dynamic-routing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds “dynamic routes” to the gateway: requests can pass model: "dynamic/<name>" and the gateway resolves that name to a published, versioned routing graph at request time, then reuses existing auto-routing/scoring/fallback for the resolved target.

Changes:

  • Introduces a shared dynamic-route graph schema + evaluator (conditional/percentage/model/end) and dynamic model parsing.
  • Adds DB tables + API CRUD/publish/rollback/version endpoints for managing dynamic routes (enterprise-gated) and a gateway loader for published routes.
  • Adds a UI “Dynamic Routes” project settings page for editing JSON graphs and publishing/rollback/version history.

Reviewed changes

Copilot reviewed 22 out of 23 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/shared/src/index.ts Re-exports new reserved custom-provider constants.
packages/shared/src/dynamic-route.ts Adds dynamic-route prefix/name parsing and Zod graph schema validated against model catalog.
packages/shared/src/custom-providers.ts Adds reserved custom-provider name list (e.g. "dynamic").
packages/shared/package.json Exposes ./dynamic-route entrypoint for shared consumers.
packages/db/src/schema.ts Adds dynamic_route + dynamic_route_version tables/columns for draft + published snapshots.
packages/db/src/relations.ts Adds Drizzle relations for dynamic routes and versions.
packages/db/migrations/meta/_journal.json Records the new migration.
packages/db/migrations/1785948725_funny_triathlon.sql Creates dynamic route tables + indexes + FKs.
packages/actions/src/get-cheapest-from-available-providers.ts Extends RoutingMetadata with dynamicRoute (name/version/path).
apps/ui/src/components/dashboard/dashboard-sidebar.tsx Adds “Dynamic Routes” link under project settings.
apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/dynamic-routes/page.tsx New settings page wrapper for the dynamic routes client UI.
apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/dynamic-routes/_components/dynamic-routes-client.tsx New dynamic routes management UI (list/create/edit draft/publish/rollback/version history).
apps/gateway/src/lib/dynamic-route-loader.ts Adds SWR-cached loader for published route graph by project/name.
apps/gateway/src/chat/tools/parse-model-input.ts Tags dynamic/<name> model inputs and defers to auto placeholder.
apps/gateway/src/chat/tools/parse-model-input.spec.ts Adds unit coverage for dynamic route parsing and validation.
apps/gateway/src/chat/tools/evaluate-dynamic-route.ts Implements graph evaluation (conditions + deterministic percentage splits + end/model).
apps/gateway/src/chat/tools/evaluate-dynamic-route.spec.ts Adds evaluator + schema validation unit tests.
apps/gateway/src/chat/chat.ts Resolves dynamic route before auto-routing; narrows candidates; records routing metadata; fails closed on no match.
apps/api/src/routes/routing-config.ts Exports checkProjectEnterpriseAccess for reuse by dynamic routes.
apps/api/src/routes/keys-provider.ts Prevents creating custom providers with reserved names (e.g. "dynamic").
apps/api/src/routes/index.ts Registers /dynamic-routes routes.
apps/api/src/routes/dynamic-routes.ts Adds dynamic routes CRUD + draft/publish/rollback + version history endpoints.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 3238 to 3242
// When free_models_only is true, only consider models marked as free
// Otherwise, only consider hardcoded allowed models
if (effectiveFreeModelsOnly) {
else if (effectiveFreeModelsOnly) {
if (!("free" in modelDef && modelDef.free)) {
continue;
Comment thread packages/shared/src/dynamic-route.ts
Comment on lines +439 to +447
const version = await db.query.dynamicRouteVersion.findFirst({
where: { id: { eq: body.versionId }, routeId: { eq: route.id } },
});
if (!version) {
throw new HTTPException(404, {
message: "Version not found for this route",
});
}

Comment on lines +40 to +49
const conditionFieldSchema = z.object({
/**
* - `header`: request header (path = header name, case-insensitive)
* - `body`: dot-path into the JSON request body (e.g. "metadata.plan")
* - `metadata`: gateway request context; path is one of "orgId",
* "projectId", "apiKeyId", "plan"
*/
source: z.enum(["header", "body", "metadata"]),
path: z.string().min(1).max(256),
});

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/api/src/routes/routing-config.ts (1)

33-38: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The error messages now reach dynamic-route callers and name the wrong feature.

apps/api/src/routes/dynamic-routes.ts calls this helper for every dynamic-route endpoint. A user who lacks access there receives "Only owners and admins can manage routing configuration" or "Routing configuration requires an enterprise plan". Both messages name routing configuration, not dynamic routes. Pass the feature name so each caller reports the correct resource.

♻️ Proposed change
 export async function checkProjectEnterpriseAccess(
 	userId: string,
 	projectId: string,
+	feature = "routing configuration",
 ): Promise<{
 	project: { id: string; organizationId: string };
 }> {
@@
 	if (userOrg.role !== "owner" && userOrg.role !== "admin") {
 		throw new HTTPException(403, {
-			message: "Only owners and admins can manage routing configuration",
+			message: `Only owners and admins can manage ${feature}`,
 		});
 	}
@@
 	if (userOrg.organization?.plan !== "enterprise") {
 		throw new HTTPException(403, {
-			message: "Routing configuration requires an enterprise plan",
+			message: `${feature} requires an enterprise plan`,
 		});
 	}

Then call checkProjectEnterpriseAccess(user.id, projectId, "dynamic routes") from the dynamic-route handlers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/routing-config.ts` around lines 33 - 38, Update
checkProjectEnterpriseAccess to accept a feature-name parameter and use it in
both access-denied error messages instead of hardcoding routing configuration.
Update routing-config callers to pass the routing configuration feature name,
and update every dynamic-route handler calling this helper to pass "dynamic
routes".
🧹 Nitpick comments (7)
apps/gateway/src/chat/tools/evaluate-dynamic-route.spec.ts (1)

305-337: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a schema test for cycle rejection.

The comment on line 306 states that a two-node cycle bypasses save-time validation. That is accurate today, because dynamicRouteGraphSchema does not detect cycles. Keep this hop-bound test as a runtime safeguard. Add a dynamicRouteGraphSchema case that asserts the same graph fails validation, after cycle detection is added in packages/shared/src/dynamic-route.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gateway/src/chat/tools/evaluate-dynamic-route.spec.ts` around lines 305
- 337, Add a schema-validation test for the same two-node cycle used in the
hop-bound test, asserting that dynamicRouteGraphSchema rejects it after cycle
detection is implemented in dynamic-route.ts. Keep the existing
evaluateDynamicRoute runtime safeguard test unchanged and reuse the graph
structure and validation assertion conventions already present in the
surrounding tests.
apps/gateway/src/lib/dynamic-route-loader.ts (1)

37-58: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider the relations query API for consistency.

apps/api/src/routes/dynamic-routes.ts lines 67-92 read the same data with db().query.dynamicRoute.findFirst({ with: { publishedVersion: true } }). This loader uses the select-builder join instead. The behavior is equivalent, and project scoping is correct here. Aligning on the relations API would keep both readers on one shape and avoid drift if the version pointer changes.

The Drizzle read convention in the coding guidelines targets packages/db/src/**, so this is a consistency suggestion, not a violation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gateway/src/lib/dynamic-route-loader.ts` around lines 37 - 58, Update
the dynamic-route lookup in the loader to use the Drizzle relations API,
specifically dynamicRoute.findFirst with publishedVersion included, instead of
the select-builder innerJoin. Preserve the existing projectId, name, and enabled
filters, limit behavior, and null result when no route is found; adapt the
returned fields to the loader’s current shape.
apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/dynamic-routes/_components/dynamic-routes-client.tsx (2)

26-40: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

STARTER_GRAPH pins two model ids that the catalog can retire.

gpt-5-nano and gemini-2.5-flash are literals. dynamicRouteGraphSchema rejects an unknown model, so if either id leaves the catalog, both the Create button at line 388 and the default draft text stop validating for every user. Derive the starter models from the model catalog, or keep the starter graph in @llmgateway/shared next to the schema so both move together.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/dynamic-routes/_components/dynamic-routes-client.tsx
around lines 26 - 40, Update STARTER_GRAPH to avoid hard-coded model IDs that
can become invalid when the catalog changes. Derive its model values from the
shared model catalog, or move the starter graph alongside
dynamicRouteGraphSchema in `@llmgateway/shared` so the defaults and validation
remain synchronized.

284-300: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the as never casts with client-side parsing through the shared schema.

handleSaveDraft validates only that the text is JSON, then casts with graph as never. Line 392 casts STARTER_GRAPH as never for the same reason. Both casts defeat the typed client and defer every structural error to the API round trip. dynamicRouteGraphSchema is already exported from @llmgateway/shared/dynamic-route and is the same schema the API applies. Parse with it in the client to get a typed value and a specific message for a bad node reference or an unknown model.

As per coding guidelines: "This is a pure TypeScript project: never use any or as any unless absolutely necessary."

♻️ Proposed refactor
+import { dynamicRouteGraphSchema } from "`@llmgateway/shared/dynamic-route`";
+
+import type { DynamicRouteGraph } from "`@llmgateway/shared/dynamic-route`";
 	const handleSaveDraft = () => {
 		if (!selectedName) {
 			return;
 		}
-		let graph: unknown;
+		let graph: DynamicRouteGraph;
 		try {
-			graph = JSON.parse(effectiveDraftText);
+			graph = dynamicRouteGraphSchema.parse(JSON.parse(effectiveDraftText));
 		} catch (error) {
-			setDraftError(`Invalid JSON: ${String(error)}`);
+			setDraftError(
+				error instanceof Error ? error.message : "Invalid graph",
+			);
 			return;
 		}
 		setDraftError(null);
 		draftMutation.mutate({
 			params: { path: { projectId, name: selectedName } },
-			body: { graph: graph as never },
+			body: { graph },
 		});
 	};

Type STARTER_GRAPH as DynamicRouteGraph at line 26 so the create call at line 392 also drops its cast. Confirm that @llmgateway/shared/dynamic-route is importable from apps/ui, and that pulling in the schema's model and provider catalog does not grow the client bundle beyond what is acceptable.

Also applies to: 387-395

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/dynamic-routes/_components/dynamic-routes-client.tsx
around lines 284 - 300, Update handleSaveDraft to validate effectiveDraftText
with dynamicRouteGraphSchema after JSON parsing, surface the schema validation
message through setDraftError, and pass the resulting typed graph to
draftMutation without an as never cast. Type STARTER_GRAPH as DynamicRouteGraph
and remove its corresponding cast in the create flow around the STARTER_GRAPH
usage, reusing the shared `@llmgateway/shared/dynamic-route` exports without
introducing any or as any.

Source: Coding guidelines

packages/db/src/schema.ts (1)

3746-3749: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

The project_id index duplicates the unique constraint prefix.

unique("dynamic_route_project_id_name_idx") on (project_id, name) already creates a B-tree index whose leading column is project_id. Postgres can use that index for project_id-only lookups, so index("dynamic_route_project_id_idx") adds write cost without a new access path. Consider dropping it.

♻️ Proposed change
 	(table) => [
 		unique("dynamic_route_project_id_name_idx").on(table.projectId, table.name),
-		index("dynamic_route_project_id_idx").on(table.projectId),
 	],

Also remove the matching CREATE INDEX from packages/db/migrations/1785948725_funny_triathlon.sql, and regenerate with pnpm migrations instead of hand-editing the journal/snapshot.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/schema.ts` around lines 3746 - 3749, Remove the redundant
projectId-only index declaration from the dynamic route table definition,
keeping the existing unique constraint on projectId and name. Regenerate
migrations using pnpm migrations so the matching CREATE INDEX is removed from
the generated migration and related metadata remains consistent; do not
hand-edit the journal or snapshot.
apps/api/src/routes/dynamic-routes.ts (2)

496-510: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Version history returns every version with its full graph and no limit.

Each publish appends a row, and each row carries a complete graph up to DYNAMIC_ROUTE_MAX_NODES nodes. A long-lived route makes this response grow without bound, and the UI at apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/dynamic-routes/_components/dynamic-routes-client.tsx renders all of them. Add a limit with an optional cursor, and consider omitting graph from the list while serving it from a per-version endpoint.

♻️ Minimal change
 	const versions = await db.query.dynamicRouteVersion.findMany({
 		where: { routeId: { eq: route.id } },
 		orderBy: { version: "desc" },
+		limit: 50,
 	});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/dynamic-routes.ts` around lines 496 - 510, Update the
version-history query and response in the dynamic route handler around findMany
to support a bounded limit and optional cursor, returning only the fields needed
for the list instead of each full version.graph. Add or reuse a per-version
endpoint to fetch an individual graph, and update the client’s version-history
flow to request additional pages and fetch graph data only when needed.

439-456: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Rollback skips the catalog re-validation that publish performs.

Lines 376-383 re-validate the draft at publish time so a graph that references a removed model can never become the published version. Rollback re-points publishedVersionId to a stored version with no equivalent check. An operator can therefore restore a version whose model or providers no longer exist in the catalog, and the gateway then fails at request time instead of at rollback time. Apply the same dynamicRouteGraphSchema.safeParse guard to version.graph.

♻️ Proposed fix
 	if (!version) {
 		throw new HTTPException(404, {
 			message: "Version not found for this route",
 		});
 	}
+
+	// Same guard as publish: a stored version can reference a model or provider
+	// that has since left the catalog.
+	const parsed = dynamicRouteGraphSchema.safeParse(version.graph);
+	if (!parsed.success) {
+		throw new HTTPException(400, {
+			message: `Version ${version.version} is no longer valid: ${parsed.error.issues[0]?.message}`,
+		});
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/dynamic-routes.ts` around lines 439 - 456, Before
updating publishedVersionId in the rollback flow, apply the same
dynamicRouteGraphSchema.safeParse validation used by the publish path to
version.graph. Reject invalid graphs, including references to unavailable models
or providers, and only perform the existing update when validation succeeds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/routes/dynamic-routes.ts`:
- Around line 178-195: Update the dynamic-route creation flow around the
existing check and cdb.insert call so the insert catches a database
unique-constraint violation for the project/name pair and converts it to
HTTPException 409 with the existing conflict message. The pre-check may remain
as a fast path, but no concurrent duplicate insert should propagate an unhandled
driver error.
- Around line 385-403: The publish flow around dynamicRouteVersion insertion and
dynamicRoute publishedVersionId updating must run inside a single
cdb.transaction() so both writes commit or roll back together. Within that
transaction, derive the next version using a database-side atomic operation or
locking strategy rather than the cached latest read, preventing concurrent
publishes from selecting the same route version; keep the existing graph and
creator data and repoint the route only after the version insert succeeds.

In `@apps/gateway/src/chat/chat.ts`:
- Around line 3231-3240: Update the model candidate filtering around
dynamicRouteSelection and effectiveFreeModelsOnly so free_models_only is
enforced after dynamic-route resolution, including for the resolved model. If
the resolved target is paid or no free matching model remains, return the
existing 400 error path instead of allowing an upstream request; retain the
hardcoded allowlist behavior for non-dynamic routes.

In `@apps/gateway/src/chat/tools/evaluate-dynamic-route.ts`:
- Around line 58-59: Restrict the metadata path accepted by conditionFieldSchema
to the four documented metadata keys, using an enum or equivalent literal-union
constraint instead of a general string. Update the schema definition in
conditionFieldSchema so invalid or prototype keys are rejected during
validation, while resolveFieldValue continues resolving the permitted keys
unchanged.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/dynamic-routes/_components/dynamic-routes-client.tsx:
- Around line 439-457: Add an accessible aria-label to the icon-only delete
Button in the dynamic-routes client component, clearly identifying that it
deletes the corresponding route. Keep the existing click confirmation and
mutation behavior unchanged.
- Around line 278-282: Update the effectiveDraftText fallback near
detail.draftGraph to use the published graph from detail when no draft exists,
before falling back to STARTER_GRAPH. Preserve draftText precedence and the
existing JSON formatting, ensuring routes with a published graph display that
graph rather than the starter template.
- Around line 222-254: Add a shared error-toast handler and assign it as onError
for rollbackMutation, patchMutation, and deleteMutation, matching the
destructive failure behavior used by createMutation, draftMutation, and
publishMutation. Reuse the helper to replace those existing duplicated onError
bodies without changing the mutations’ success behavior.

In `@packages/shared/src/dynamic-route.ts`:
- Around line 223-248: dynamicRouteGraphSchema currently permits cyclic graphs,
so move cycle failures to validation time. In
packages/shared/src/dynamic-route.ts lines 223-248, extend the superRefine logic
beside the reachability check to detect cycles and add a validation issue
identifying the node that closes each cycle. In
apps/gateway/src/chat/tools/evaluate-dynamic-route.spec.ts lines 305-337,
preserve the existing hop-bound runtime test and add a
dynamicRouteGraphSchema.safeParse assertion showing the same two-node cycle is
rejected.

---

Outside diff comments:
In `@apps/api/src/routes/routing-config.ts`:
- Around line 33-38: Update checkProjectEnterpriseAccess to accept a
feature-name parameter and use it in both access-denied error messages instead
of hardcoding routing configuration. Update routing-config callers to pass the
routing configuration feature name, and update every dynamic-route handler
calling this helper to pass "dynamic routes".

---

Nitpick comments:
In `@apps/api/src/routes/dynamic-routes.ts`:
- Around line 496-510: Update the version-history query and response in the
dynamic route handler around findMany to support a bounded limit and optional
cursor, returning only the fields needed for the list instead of each full
version.graph. Add or reuse a per-version endpoint to fetch an individual graph,
and update the client’s version-history flow to request additional pages and
fetch graph data only when needed.
- Around line 439-456: Before updating publishedVersionId in the rollback flow,
apply the same dynamicRouteGraphSchema.safeParse validation used by the publish
path to version.graph. Reject invalid graphs, including references to
unavailable models or providers, and only perform the existing update when
validation succeeds.

In `@apps/gateway/src/chat/tools/evaluate-dynamic-route.spec.ts`:
- Around line 305-337: Add a schema-validation test for the same two-node cycle
used in the hop-bound test, asserting that dynamicRouteGraphSchema rejects it
after cycle detection is implemented in dynamic-route.ts. Keep the existing
evaluateDynamicRoute runtime safeguard test unchanged and reuse the graph
structure and validation assertion conventions already present in the
surrounding tests.

In `@apps/gateway/src/lib/dynamic-route-loader.ts`:
- Around line 37-58: Update the dynamic-route lookup in the loader to use the
Drizzle relations API, specifically dynamicRoute.findFirst with publishedVersion
included, instead of the select-builder innerJoin. Preserve the existing
projectId, name, and enabled filters, limit behavior, and null result when no
route is found; adapt the returned fields to the loader’s current shape.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/dynamic-routes/_components/dynamic-routes-client.tsx:
- Around line 26-40: Update STARTER_GRAPH to avoid hard-coded model IDs that can
become invalid when the catalog changes. Derive its model values from the shared
model catalog, or move the starter graph alongside dynamicRouteGraphSchema in
`@llmgateway/shared` so the defaults and validation remain synchronized.
- Around line 284-300: Update handleSaveDraft to validate effectiveDraftText
with dynamicRouteGraphSchema after JSON parsing, surface the schema validation
message through setDraftError, and pass the resulting typed graph to
draftMutation without an as never cast. Type STARTER_GRAPH as DynamicRouteGraph
and remove its corresponding cast in the create flow around the STARTER_GRAPH
usage, reusing the shared `@llmgateway/shared/dynamic-route` exports without
introducing any or as any.

In `@packages/db/src/schema.ts`:
- Around line 3746-3749: Remove the redundant projectId-only index declaration
from the dynamic route table definition, keeping the existing unique constraint
on projectId and name. Regenerate migrations using pnpm migrations so the
matching CREATE INDEX is removed from the generated migration and related
metadata remains consistent; do not hand-edit the journal or snapshot.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 43090ac0-742f-4f15-a2c0-d387cf21e164

📥 Commits

Reviewing files that changed from the base of the PR and between 5c4c92c and 6520704.

📒 Files selected for processing (23)
  • apps/api/src/routes/dynamic-routes.ts
  • apps/api/src/routes/index.ts
  • apps/api/src/routes/keys-provider.ts
  • apps/api/src/routes/routing-config.ts
  • apps/gateway/src/chat/chat.ts
  • apps/gateway/src/chat/tools/evaluate-dynamic-route.spec.ts
  • apps/gateway/src/chat/tools/evaluate-dynamic-route.ts
  • apps/gateway/src/chat/tools/parse-model-input.spec.ts
  • apps/gateway/src/chat/tools/parse-model-input.ts
  • apps/gateway/src/lib/dynamic-route-loader.ts
  • apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/dynamic-routes/_components/dynamic-routes-client.tsx
  • apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/dynamic-routes/page.tsx
  • apps/ui/src/components/dashboard/dashboard-sidebar.tsx
  • packages/actions/src/get-cheapest-from-available-providers.ts
  • packages/db/migrations/1785948725_funny_triathlon.sql
  • packages/db/migrations/meta/1785948725_snapshot.json
  • packages/db/migrations/meta/_journal.json
  • packages/db/src/relations.ts
  • packages/db/src/schema.ts
  • packages/shared/package.json
  • packages/shared/src/custom-providers.ts
  • packages/shared/src/dynamic-route.ts
  • packages/shared/src/index.ts

Comment thread apps/api/src/routes/dynamic-routes.ts Outdated
Comment thread apps/api/src/routes/dynamic-routes.ts Outdated
Comment thread apps/gateway/src/chat/chat.ts
Comment thread apps/gateway/src/chat/tools/evaluate-dynamic-route.ts Outdated
Comment thread packages/shared/src/dynamic-route.ts
- enforce free_models_only (incl. test wallets) on route-selected models
- reject cyclic graphs, unknown metadata paths, and array values for
  non-in operators at validation time
- guard prototype keys in metadata/body field resolution
- re-validate version graphs on rollback like publish does
- make create rely on the unique constraint (409 instead of racy 500)
- wrap publish in a transaction with DB-derived version numbers
- UI: error toasts for rollback/patch/delete, published-graph draft
  fallback, aria-label on delete button

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@steebchen

Copy link
Copy Markdown
Member Author

Addressed all review feedback in 0886704:

  • free_models_only bypass (Copilot, CodeRabbit): route-selected models now also pass the free-models filter; failure message calls out free_models_only when it's the cause. Test wallets stay guarded by assertTestWalletModelAllowed as before.
  • Cycle detection (CodeRabbit): graphs with cycles are rejected at save/publish time (evaluation is deterministic per request, so any revisit would always loop) — with a schema test for the two-node cycle.
  • Metadata path allowlist (Copilot, CodeRabbit): field.path for source: metadata restricted to orgId|projectId|apiKeyId|plan in the schema; evaluator additionally uses own-property checks for metadata and body lookups so prototype keys never resolve.
  • Array values (Copilot): only the in operator accepts arrays now.
  • Rollback re-validation (Copilot): rollback re-validates the target version's graph against the current catalog, same as publish.
  • Create race (CodeRabbit): insert uses onConflictDoNothing on the unique (projectId, name) target → deterministic 409 instead of a driver 500.
  • Publish atomicity (CodeRabbit): version insert + pointer re-point wrapped in cdb.transaction() with the version number derived in-database (coalesce(max(version),0)+1), verified against Postgres.
  • UI: error toasts on rollback/patch/delete mutations, draft editor falls back to the published graph before the starter template, delete button has an aria-label.

All 30 unit tests pass (4 new); gateway/api/ui builds green.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/routes/dynamic-routes.ts`:
- Around line 388-405: Serialize per-route version allocation in the transaction
around the publish flow: lock or otherwise serialize the corresponding
dynamicRoute row before the dynamicRouteVersion insert derives max(version) + 1.
Update the transaction beginning before the insert, while preserving the
existing version insert and publishedVersionId update behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 97f74e03-53f1-42c0-8b86-fb0493a0d0eb

📥 Commits

Reviewing files that changed from the base of the PR and between 6520704 and 0886704.

📒 Files selected for processing (6)
  • apps/api/src/routes/dynamic-routes.ts
  • apps/gateway/src/chat/chat.ts
  • apps/gateway/src/chat/tools/evaluate-dynamic-route.spec.ts
  • apps/gateway/src/chat/tools/evaluate-dynamic-route.ts
  • apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/dynamic-routes/_components/dynamic-routes-client.tsx
  • packages/shared/src/dynamic-route.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • apps/gateway/src/chat/chat.ts
  • apps/gateway/src/chat/tools/evaluate-dynamic-route.spec.ts
  • apps/gateway/src/chat/tools/evaluate-dynamic-route.ts
  • apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/dynamic-routes/_components/dynamic-routes-client.tsx

Comment thread apps/api/src/routes/dynamic-routes.ts
Add a Visual/JSON tab pair to the dynamic-route editor. The visual tab
is a React Flow canvas with drag-and-drop node palette (model,
conditional, percentage, end), per-branch connection handles, a node
inspector (model picker from the catalog, ordered provider fallback
list, condition and split editing), and a synthetic Start node for the
entry edge. Edges are derived from branch targets so connect/delete
operations stay consistent, and a deterministic layered layout places
loaded graphs.

Both tabs share one draft: visual edits sync to the JSON text and
switching to Visual re-reads it (blocked while the JSON is invalid).
Saving is gated on the same shared Zod schema the API enforces, with
friendly structural errors (unconnected branches, unset models) shown
before catalog-level issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/dynamic-routes/_components/flow-graph.ts (1)

96-101: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the as DynamicRouteGraph cast.

The cast suppresses type checking on the returned object. If DynamicRouteGraph later gains a required field, this function keeps compiling and produces an invalid graph. Type the return through the object literal instead.

♻️ Proposed refactor
 export function editorToGraph(state: EditorState): DynamicRouteGraph {
-	return {
-		entry: state.entry,
-		nodes: state.nodes.map((n) => n.node),
-	} as DynamicRouteGraph;
+	return {
+		entry: state.entry,
+		nodes: state.nodes.map((n) => n.node),
+	};
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/dynamic-routes/_components/flow-graph.ts
around lines 96 - 101, Update editorToGraph so the returned object literal is
type-checked as DynamicRouteGraph without using an `as DynamicRouteGraph`
assertion. Preserve the existing `entry` and mapped `nodes` fields while
allowing missing required fields to surface as compile-time errors.
apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/dynamic-routes/_components/route-flow-editor.tsx (2)

294-303: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Run the Zod validation only when the graph content changes.

state.nodes gets a new array reference on every position change. Each drag frame therefore triggers validateEditorState, which runs a full dynamicRouteGraphSchema.safeParse. Positions are not part of the serialized graph, so this work has no effect on the reported errors. Reuse the serialization guard for validation as well.

♻️ Proposed refactor
 	useEffect(() => {
 		const graph = editorToGraph(state);
 		const serialized = JSON.stringify(graph);
-		if (serialized !== initialSerialized.current) {
-			initialSerialized.current = serialized;
-			onGraphChange(graph);
-		}
-		onValidationChange(validateEditorState(state));
+		if (serialized === initialSerialized.current) {
+			return;
+		}
+		initialSerialized.current = serialized;
+		onGraphChange(graph);
+		onValidationChange(validateEditorState(state));
 		// eslint-disable-next-line react-hooks/exhaustive-deps
 	}, [state.entry, state.nodes]);

Keep an initial onValidationChange call on mount so the parent receives the errors of the loaded draft.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/dynamic-routes/_components/route-flow-editor.tsx
around lines 294 - 303, Update the useEffect in the route-flow editor to run
validateEditorState and call onValidationChange only when the serialized graph
content differs from initialSerialized.current, preventing validation during
position-only updates. Preserve an initial validation call on mount so the
parent receives errors for the loaded draft, while keeping onGraphChange
behavior tied to actual graph changes.

750-768: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Numeric condition values collapse to 0 while the user types.

parseConditionValue returns 0 for any non-finite input, and Number("") is 0. The user cannot clear the field, and an intermediate value such as - becomes 0. Keep the raw text in component state, and convert only when the value is finite and non-empty.

Also applies to: 892-909

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/dynamic-routes/_components/route-flow-editor.tsx
around lines 750 - 768, Update parseConditionValue and the associated condition
editor state to preserve raw numeric text while the user is typing, including
empty and intermediate values such as "-". For "gt" and "lt", convert the text
only when it is non-empty and Number(text) is finite; otherwise retain the raw
text until a valid numeric value is entered, and apply the same behavior in the
alternate code path around the condition editor handlers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/dynamic-routes/_components/flow-graph.ts:
- Around line 1-6: Add exported DynamicRouteGraph and DynamicRouteNode type
definitions to the shared dynamic-route module alongside
dynamicRouteGraphSchema, then expose the DynamicRoute* symbols from the shared
package root index consistently with its existing exports so the UI import and
root consumers type-check.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/dynamic-routes/_components/route-flow-editor.tsx:
- Around line 487-502: Update onDrop to validate the value returned by
getData(DND_MIME) against the known DynamicRouteNode types before calling
addNode. Return early for any unsupported string, ensuring createNode receives
only valid types and the editor never stores an undefined node.
- Around line 427-440: Update nodeIdSchema to reject node IDs containing “:”
while preserving its existing non-empty, 1–64 character constraints, before
graph state can be changed. This keeps edge IDs produced by the route-flow
editor unambiguous for onEdgesChange and its colon-based splitting.

---

Nitpick comments:
In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/dynamic-routes/_components/flow-graph.ts:
- Around line 96-101: Update editorToGraph so the returned object literal is
type-checked as DynamicRouteGraph without using an `as DynamicRouteGraph`
assertion. Preserve the existing `entry` and mapped `nodes` fields while
allowing missing required fields to surface as compile-time errors.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/dynamic-routes/_components/route-flow-editor.tsx:
- Around line 294-303: Update the useEffect in the route-flow editor to run
validateEditorState and call onValidationChange only when the serialized graph
content differs from initialSerialized.current, preventing validation during
position-only updates. Preserve an initial validation call on mount so the
parent receives errors for the loaded draft, while keeping onGraphChange
behavior tied to actual graph changes.
- Around line 750-768: Update parseConditionValue and the associated condition
editor state to preserve raw numeric text while the user is typing, including
empty and intermediate values such as "-". For "gt" and "lt", convert the text
only when it is non-empty and Number(text) is finite; otherwise retain the raw
text until a valid numeric value is entered, and apply the same behavior in the
alternate code path around the condition editor handlers.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 98dc7637-de5e-4eed-8181-ccd576d4cb69

📥 Commits

Reviewing files that changed from the base of the PR and between 0886704 and 3754601.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (4)
  • apps/ui/package.json
  • apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/dynamic-routes/_components/dynamic-routes-client.tsx
  • apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/dynamic-routes/_components/flow-graph.ts
  • apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/dynamic-routes/_components/route-flow-editor.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/dynamic-routes/_components/dynamic-routes-client.tsx

Comment on lines +1 to +6
import { dynamicRouteGraphSchema } from "@llmgateway/shared/dynamic-route";

import type {
DynamicRouteGraph,
DynamicRouteNode,
} from "@llmgateway/shared/dynamic-route";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check the shared package export map and the dynamic-route entry point.
fd -t f 'package.json' packages/shared --max-depth 2 --exec jq '{name, exports, main, types}'
fd -t f 'dynamic-route.ts' packages/shared/src
rg -n 'dynamic-route' --glob '**/package.json'

Repository: theopenco/llmgateway

Length of output: 1487


Share route schema types and export the submodule consistently.

@llmgateway/shared/dynamic-route points at dist/dynamic-route.{js,d.ts}, but packages/shared/src/dynamic-route.ts contains only schema values without exported DynamicRouteGraph/DynamicRouteNode types. Add the type exports there, otherwise this UI import cannot type-check, and add the DynamicRoute* exports to packages/shared/src/index.ts if callers should import them from the package root.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/dynamic-routes/_components/flow-graph.ts
around lines 1 - 6, Add exported DynamicRouteGraph and DynamicRouteNode type
definitions to the shared dynamic-route module alongside
dynamicRouteGraphSchema, then expose the DynamicRoute* symbols from the shared
package root index consistently with its existing exports so the UI import and
root consumers type-check.

- lock the route row during publish so concurrent publishes serialize
  instead of racing max(version)+1 into a unique-constraint 500
- restrict node ids to [a-zA-Z0-9_-] so composite edge ids stay parseable
- split edge ids on the last colon when clearing branch targets
- validate the drag-and-drop payload type before creating a node

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@steebchen

Copy link
Copy Markdown
Member Author

Addressed the second review round in a3dcc20:

  • Publish version race (CodeRabbit, major): the publish transaction now takes a SELECT … FOR UPDATE lock on the route row before deriving max(version)+1, so concurrent publishes serialize instead of one dying on the unique constraint.
  • Node ids containing : (CodeRabbit): nodeIdSchema now restricts ids to [a-zA-Z0-9_-] (with a schema test), and the visual editor additionally splits composite edge ids on the last colon.
  • Unvalidated drag-and-drop payload (CodeRabbit): onDrop now checks the transferred type against the known node types before creating a node.
  • "Missing type exports in @llmgateway/shared/dynamic-route" (CodeRabbit on flow-graph.ts): skipped — the module already exports DynamicRouteGraph, DynamicRouteNode, and the other DynamicRoute* types (see packages/shared/src/dynamic-route.ts), and the UI import type-checks in CI/build. Subpath-only export matches the existing routing-config pattern, so nothing was added to the package root.

23 unit tests pass; api/gateway/ui builds green.

steebchen and others added 2 commits August 7, 2026 15:51
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/docs/content/features/dynamic-routes.mdx`:
- Around line 15-20: Replace leading spaces with tabs in the continued curl
command and JSON body at apps/docs/content/features/dynamic-routes.mdx lines
15-20 and apps/ui/src/content/changelog/2026-08-07-dynamic-routes.md lines
18-23. Run pnpm format and pnpm build before committing.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7afea89f-5672-4fbd-bf98-683b6769abb7

📥 Commits

Reviewing files that changed from the base of the PR and between 3e9695f and 4304c16.

⛔ Files ignored due to path filters (1)
  • apps/ui/public/changelog/dynamic-routes.png is excluded by !**/*.png
📒 Files selected for processing (2)
  • apps/docs/content/features/dynamic-routes.mdx
  • apps/ui/src/content/changelog/2026-08-07-dynamic-routes.md

Comment on lines +15 to +20
-H "Authorization: Bearer $LLM_GATEWAY_API_KEY" \
-H "x-user-tier: paid" \
-d '{
"model": "dynamic/support",
"messages": [{"role": "user", "content": "Hello!"}]
}'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use tabs in the fenced shell examples.

Both shell examples use spaces for indentation. Replace the leading spaces with tabs. Run pnpm format and pnpm build before committing.

  • apps/docs/content/features/dynamic-routes.mdx#L15-L20: use tabs for the continued curl command and JSON body.
  • apps/ui/src/content/changelog/2026-08-07-dynamic-routes.md#L18-L23: use tabs for the continued curl command and JSON body.

As per coding guidelines, **/*.{ts,tsx,js,jsx,json,md,yml,yaml} must use tabs for indentation.

📍 Affects 2 files
  • apps/docs/content/features/dynamic-routes.mdx#L15-L20 (this comment)
  • apps/ui/src/content/changelog/2026-08-07-dynamic-routes.md#L18-L23
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/docs/content/features/dynamic-routes.mdx` around lines 15 - 20, Replace
leading spaces with tabs in the continued curl command and JSON body at
apps/docs/content/features/dynamic-routes.mdx lines 15-20 and
apps/ui/src/content/changelog/2026-08-07-dynamic-routes.md lines 18-23. Run pnpm
format and pnpm build before committing.

Source: Coding guidelines

steebchen and others added 5 commits August 7, 2026 17:41
Also fix the flow editor to follow the app theme instead of the OS
setting, so React Flow's chrome renders dark in app dark mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@steebchen

Copy link
Copy Markdown
Member Author

Images automagically compressed by Calibre's image-actions ✨

Compression reduced images by 66.2%, saving 1.6 MB.

Filename Before After Improvement Visual comparison
apps/ui/public/changelog/dynamic-routes.png 1.9 MB 640.8 KB 67.0% View diff
apps/docs/public/learn/dynamic-routes-dark.png 133.0 KB 48.9 KB 63.2% View diff
apps/docs/public/learn/dynamic-routes-light.png 134.9 KB 54.7 KB 59.5% View diff
apps/docs/public/learn/dynamic-routes-json-dark.png 96.1 KB 28.8 KB 70.0% View diff
apps/docs/public/learn/dynamic-routes-json-light.png 98.0 KB 37.3 KB 61.9% View diff

@steebchen

Copy link
Copy Markdown
Member Author

Images automagically compressed by Calibre's image-actions ✨

Compression reduced images by 15.2%, saving 109.5 KB.

Filename Before After Improvement Visual comparison
apps/ui/public/changelog/dynamic-routes.png 640.8 KB 544.0 KB 15.1% View diff
apps/docs/public/learn/dynamic-routes-dark.png 48.9 KB 37.7 KB 22.8% View diff
apps/docs/public/learn/dynamic-routes-json-dark.png 28.8 KB 27.4 KB 5.1% View diff

2 images did not require optimisation.

@steebchen

Copy link
Copy Markdown
Member Author

Images automagically compressed by Calibre's image-actions ✨

Compression reduced images by 13.3%, saving 72.4 KB.

Filename Before After Improvement Visual comparison
apps/ui/public/changelog/dynamic-routes.png 544.0 KB 471.6 KB 13.3% View diff

4 images did not require optimisation.

@steebchen

Copy link
Copy Markdown
Member Author

Images automagically compressed by Calibre's image-actions ✨

Compression reduced images by 11.1%, saving 52.3 KB.

Filename Before After Improvement Visual comparison
apps/ui/public/changelog/dynamic-routes.png 471.6 KB 419.2 KB 11.1% View diff

4 images did not require optimisation.

steebchen and others added 2 commits August 7, 2026 22:37
Replace the plain datalist input and native select in the model-node
inspector with the searchable ModelSelector (family logos, pricing,
capabilities; rootOnly) and MultiProviderSelector (provider logos,
options limited to providers serving the chosen model) used elsewhere
in the dashboard. The selector emits provider/model ids, so the
handler normalizes to the root catalog id the graph schema expects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@steebchen

Copy link
Copy Markdown
Member Author

The model-node inspector now reuses the dashboard's rich pickers instead of a plain text input and native select (commit 9595a48):

  • Model: the searchable ModelSelector with family logos, pricing/capability details, and rootOnly so only root catalog ids can be chosen (the selector emits provider/model ids, so the handler normalizes back to the root id the graph schema expects; changing the model still clears the provider restriction).
  • Provider fallback order: the MultiProviderSelector from IAM rules, with provider logos and options limited to providers actually serving the chosen model; selection order is the fallback order, shown as icon badges.

Verified end-to-end in the browser: picking a model via search stores the root id, adding a provider syncs into the JSON tab, and Save draft passes API validation.

Model node inspector with searchable model picker showing family logos

Add request-path integration tests (gateway harness) covering the
enterprise gate, 404 semantics, conditional/body/metadata evaluation,
provider restriction vs natural routing, session-sticky splits, end
nodes, free_models_only, and the recorded dynamicRoute metadata; plus
API lifecycle tests for create/draft/publish/rollback/versions,
validation rejections, and 409 conflicts.

Also fix a bug the tests surfaced: evaluation received the
schema-validated body, which strips unknown keys, so body conditions
on caller-defined fields (e.g. metadata.segment) could never match.
Evaluation now reads the raw request body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@steebchen

Copy link
Copy Markdown
Member Author

Test coverage review for the routing feature, and the gaps are now filled in 7e9349c. Coverage by layer:

Layer Suite What it covers
Graph schema (shared Zod) evaluate-dynamic-route.spec.ts cycles, reachability, dangling refs, duplicate/unsafe node ids, catalog checks (unknown model/provider, provider not serving model), operator/value mismatches, metadata-path allowlist
Evaluator (pure) evaluate-dynamic-route.spec.ts every operator, all 3 field sources incl. body dot-paths, prototype-key safety, percentage determinism and statistical distribution, conditional→percentage combinations, hop bound
Model-string parsing parse-model-input.spec.ts dynamic/ tagging, invalid names, non-interference with normal model strings
Gateway request path (new) apps/gateway/src/dynamic-routes.spec.ts enterprise gate (403), unknown/disabled/unpublished (404), header/body/metadata conditions end-to-end, provider restriction beating natural routing (gpt-5.5: azure wins unrestricted, openai wins restricted), session-sticky percentage splits, end-node 400, free_models_only enforcement, routingMetadata.dynamicRoute {name, version, path} in the log
API lifecycle (new) apps/api/src/routes/dynamic-routes.spec.ts auth (401), plan gate (403), create/list/get, invalid graph + invalid name rejection, duplicate 409, draft→publish→republish→rollback with version flags, rollback to foreign version (404), publish-without-draft (400), enable toggle, delete

Writing the matrix also surfaced a real bug, fixed in the same commit: body conditions evaluated against the schema-validated body, which strips unknown keys — so a condition on a caller-defined field like metadata.segment (the documented example!) could never match. Evaluation now reads the raw request body, with a regression test that pins it.

48 dynamic-route tests total across the four suites, all green.

@steebchen

Copy link
Copy Markdown
Member Author

Reviewed the latest round: no critical/major findings are outstanding. The single new comment (tabs inside the fenced curl examples in the docs/changelog) is skipped intentionally: the tabs rule covers source-file indentation, not shell-example content inside fenced code blocks — every existing changelog entry and feature doc (e.g. moderations-flat-request-pricing.md, anthropic-endpoint.mdx) uses spaces there, and prettier --check passes both files as-is. Matching the established style keeps the docs consistent.

Status of all earlier major/high findings: free_models_only bypass, publish version race (row lock), cycle detection, metadata-path allowlist, rollback re-validation, create-race 409, node-id charset, and the raw-body evaluation bug are all fixed with tests; the two incorrect findings (missing type exports, shared-selector claims) were skipped with reasons in the earlier comments.

steebchen and others added 4 commits August 8, 2026 12:53
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uting

# Conflicts:
#	packages/shared/package.json
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@steebchen
steebchen enabled auto-merge August 8, 2026 11:08
@steebchen
steebchen added this pull request to the merge queue Aug 8, 2026
@steebchen
steebchen removed this pull request from the merge queue due to a manual request Aug 8, 2026
@steebchen
steebchen merged commit 0931c15 into main Aug 8, 2026
28 checks passed
@steebchen
steebchen deleted the cloudflare-dynamic-routing branch August 8, 2026 11:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants