Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion llm-router/ui/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ import type { Host } from '@iii-dev/console-ui'
import { LlmRouterConfigForm } from './src/configuration'

export default function setup(host: Host) {
host.configForms.register('llm-router', LlmRouterConfigForm)
host.configForms.register('llm-router', (props) => <LlmRouterConfigForm {...props} host={host} />)
}
16 changes: 16 additions & 0 deletions llm-router/ui/src/configuration/duration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { minutesToMs, msToMinutes } from './duration'

describe('timeout minutes', () => {
it('converts the router defaults without rounding error', () => {
expect(msToMinutes(300_000)).toBe(5)
expect(msToMinutes(120_000)).toBe(2)
expect(minutesToMs(5)).toBe(300_000)
expect(minutesToMs(2)).toBe(120_000)
})

it('round-trips fractional minutes to whole milliseconds', () => {
expect(minutesToMs(1.5)).toBe(90_000)
expect(msToMinutes(90_000)).toBe(1.5)
})
})
9 changes: 9 additions & 0 deletions llm-router/ui/src/configuration/duration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** Stream/idle timeouts are stored as milliseconds; the form edits minutes. */

export function msToMinutes(ms: number): number {
return ms / 60_000
}

export function minutesToMs(minutes: number): number {
return Math.round(minutes * 60_000)
}
10 changes: 10 additions & 0 deletions llm-router/ui/src/configuration/field-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export { errorAt, pointer } from './pointers'

export function FieldError({ message }: { message: string | null }) {
if (!message) return null
return (
<div className="llmr-cfg-field-error" role="alert">
{message}
</div>
)
}
34 changes: 34 additions & 0 deletions llm-router/ui/src/configuration/heuristics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { moveItem, winningHeuristicIndex } from './heuristics'

describe('winningHeuristicIndex', () => {
const rows = [
{ pattern: '^gpt-', provider: 'openai' },
{ pattern: 'claude', provider: 'anthropic' },
{ pattern: '(', provider: 'broken' },
{ pattern: '', provider: 'openai' },
]

it('returns the first regex that matches the model id', () => {
expect(winningHeuristicIndex('gpt-4.1', rows)).toBe(0)
expect(winningHeuristicIndex('claude-sonnet-4', rows)).toBe(1)
})

it('skips invalid regex and empty patterns', () => {
expect(winningHeuristicIndex('nope', rows)).toBe(null)
})

it('returns null when the model id is empty', () => {
expect(winningHeuristicIndex('', rows)).toBe(null)
expect(winningHeuristicIndex(' ', rows)).toBe(null)
})
})

describe('moveItem', () => {
it('moves a row up or down and no-ops out of range', () => {
expect(moveItem(['a', 'b', 'c'], 2, 0)).toEqual(['c', 'a', 'b'])
expect(moveItem(['a', 'b', 'c'], 0, 1)).toEqual(['b', 'a', 'c'])
expect(moveItem(['a', 'b', 'c'], 0, -1)).toEqual(['a', 'b', 'c'])
expect(moveItem(['a', 'b', 'c'], 0, 3)).toEqual(['a', 'b', 'c'])
})
})
24 changes: 24 additions & 0 deletions llm-router/ui/src/configuration/heuristics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/** Draft-only heuristic preview and row reorder. Order is routing priority. */

export function winningHeuristicIndex(model: string, rows: { pattern: string; provider: string }[]): number | null {
const needle = model.trim()
if (!needle) return null
for (let i = 0; i < rows.length; i++) {
const pattern = rows[i].pattern
if (!pattern || !rows[i].provider) continue
try {
if (new RegExp(pattern).test(needle)) return i
} catch {
// An invalid operator regex never takes the router down.
}
Comment on lines +3 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 5 'winningHeuristicIndex|new RegExp\(|routing_heuristics|probe' llm-router/ui/src

Repository: iii-hq/workers

Length of output: 16524


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- heuristics.ts ---'
cat -n llm-router/ui/src/configuration/heuristics.ts

printf '%s\n' '--- relevant configuration render and validation ---'
sed -n '110,180p' llm-router/ui/src/configuration/index.tsx
sed -n '290,410p' llm-router/ui/src/configuration/index.tsx

printf '%s\n' '--- tests ---'
cat -n llm-router/ui/src/configuration/heuristics.test.ts

printf '%s\n' '--- regex/config validation references ---'
rg -n -C 4 'routing_heuristics|pattern|RegExp|regex|regular expression' llm-router --glob '!**/*.lock'

Repository: iii-hq/workers

Length of output: 36939


🏁 Script executed:

#!/bin/bash
set -euo pipefail

node - <<'JS'
const pattern = '^(a+)+$'
const needle = 'a'.repeat(28) + '!'
const start = process.hrtime.bigint()
let result
try {
  result = new RegExp(pattern).test(needle)
} catch (error) {
  result = String(error)
}
const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6
console.log(JSON.stringify({ pattern, needleLength: needle.length, result, elapsedMs }))
JS

Repository: iii-hq/workers

Length of output: 232


Prevent catastrophic regex evaluation on the browser main thread.

winningHeuristicIndex synchronously evaluates patterns from the draft configuration against free-form probe input. A pattern such as ^(a+)+$ can block the UI for seconds. The catch block handles syntax errors only. Use a linear-time regex engine or a terminable worker with a strict timeout. Do not rely on input-length limits alone.

🧰 Tools
🪛 ast-grep (0.45.1)

[warning] 9-9: Do not use variable for regular expressions
Context: new RegExp(pattern)
Note: [CWE-1333] Inefficient Regular Expression Complexity. Security best practice.

(regexp-non-literal-typescript)


[warning] 9-9: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(pattern)
Note: [CWE-1333] Inefficient Regular Expression Complexity

(regexp-from-variable)

🤖 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 `@llm-router/ui/src/configuration/heuristics.ts` around lines 3 - 13, Update
winningHeuristicIndex so pattern matching cannot block the browser main thread
through catastrophic backtracking: replace synchronous RegExp evaluation with a
linear-time regex engine or delegate evaluation to a terminable worker enforcing
a strict timeout. Preserve the existing null, row-skipping, and invalid-pattern
behavior, and do not use input-length limits as the sole mitigation.

Source: Linters/SAST tools

}
return null
}

export function moveItem<T>(items: T[], from: number, to: number): T[] {
if (to < 0 || to >= items.length || from === to) return items
const next = [...items]
const [row] = next.splice(from, 1)
next.splice(to, 0, row)
Comment on lines +18 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 4 'moveItem\s*\(|from: number|to: number' llm-router/ui/src

Repository: iii-hq/workers

Length of output: 3373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- heuristics.ts ---'
cat -n llm-router/ui/src/configuration/heuristics.ts
printf '%s\n' '--- heuristics.test.ts ---'
cat -n llm-router/ui/src/configuration/heuristics.test.ts
printf '%s\n' '--- runtime splice probes ---'
node - <<'JS'
const cases = [
  ['negative from', ['a', 'b', 'c'], -1, 0],
  ['from >= length', ['a', 'b', 'c'], 3, 1],
  ['fractional from', ['a', 'b', 'c'], 1.5, 0],
  ['NaN from', ['a', 'b', 'c'], NaN, 1],
  ['negative fractional from', ['a', 'b', 'c'], -1.5, 0],
]
for (const [name, items, from, to] of cases) {
  if (to < 0 || to >= items.length || from === to) {
    console.log(name, JSON.stringify(items))
    continue
  }
  const next = [...items]
  const [row] = next.splice(from, 1)
  next.splice(to, 0, row)
  console.log(name, JSON.stringify(next))
}
JS

Repository: iii-hq/workers

Length of output: 2808


Validate the source index before calling splice.

Invalid from values can move the wrong item or insert undefined. Return items unless both from and to are integer indexes within the array, and add boundary tests.

🤖 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 `@llm-router/ui/src/configuration/heuristics.ts` around lines 18 - 22, Update
moveItem to return items unless both from and to are integer indexes within the
array bounds, before copying or calling splice; preserve the no-op behavior when
from equals to. Add boundary tests covering negative, out-of-range, and
non-integer source and destination indexes.

return next
}
Loading
Loading