Skip to content
Open
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
75 changes: 13 additions & 62 deletions proxy/internal/service/model_router.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ type ModelRouter struct {
providers map[string]provider.Provider
subagentMappings map[string]string // agentName -> targetModel
customAgentPrompts map[string]SubagentDefinition // promptHash -> definition
modelProviderMap map[string]string // model -> provider mapping
logger *log.Logger
}

Expand All @@ -42,7 +41,6 @@ func NewModelRouter(cfg *config.Config, providers map[string]provider.Provider,
providers: providers,
subagentMappings: cfg.Subagents.Mappings,
customAgentPrompts: make(map[string]SubagentDefinition),
modelProviderMap: initializeModelProviderMap(),
logger: logger,
}

Expand All @@ -58,63 +56,6 @@ func NewModelRouter(cfg *config.Config, providers map[string]provider.Provider,
return router
}

// initializeModelProviderMap creates a mapping of model names to their providers
func initializeModelProviderMap() map[string]string {
modelMap := make(map[string]string)

// OpenAI models
openaiModels := []string{
// GPT-5 family
"gpt-5", "gpt-5-mini", "gpt-5-nano",

// GPT-4.1 family
"gpt-4.1", "gpt-4.1-2025-04-14",
"gpt-4.1-mini", "gpt-4.1-mini-2025-04-14",
"gpt-4.1-nano", "gpt-4.1-nano-2025-04-14",

// GPT-4.5
"gpt-4.5-preview", "gpt-4.5-preview-2025-02-27",

// GPT-4o variants
"gpt-4o", "gpt-4o-2024-08-06",
"gpt-4o-mini", "gpt-4o-mini-2024-07-18",

// GPT-3.5 variants
"gpt-3.5-turbo", "gpt-3.5-turbo-0125", "gpt-3.5-turbo-1106", "gpt-3.5-turbo-instruct",

// O1 series
"o1", "o1-2024-12-17",
"o1-pro", "o1-pro-2025-03-19",
"o1-mini", "o1-mini-2024-09-12",

// O3 series
"o3-pro", "o3-pro-2025-06-10",
"o3", "o3-2025-04-16",
"o3-mini", "o3-mini-2025-01-31",
}

for _, model := range openaiModels {
modelMap[model] = "openai"
}

// Anthropic models
anthropicModels := []string{
"claude-opus-4-1-20250805",
"claude-opus-4-20250514",
"claude-sonnet-4-20250514",
"claude-sonnet-4-5-20250929",
"claude-opus-4-5-20251101",
"claude-3-7-sonnet-20250219",
"claude-3-5-haiku-20241022",
}

for _, model := range anthropicModels {
modelMap[model] = "anthropic"
}

return modelMap
}

// extractStaticPrompt extracts the portion before "Notes:" if it exists
func (r *ModelRouter) extractStaticPrompt(systemPrompt string) string {
// Find the "Notes:" section
Expand Down Expand Up @@ -265,11 +206,21 @@ func (r *ModelRouter) hashString(s string) string {
}

func (r *ModelRouter) getProviderNameForModel(model string) string {
if provider, exists := r.modelProviderMap[model]; exists {
return provider
modelLower := strings.ToLower(model)

// Anthropic models: claude-*
if strings.HasPrefix(modelLower, "claude") {
return "anthropic"
}

// OpenAI models: gpt-*, o1*, o3*
if strings.HasPrefix(modelLower, "gpt") ||
strings.HasPrefix(modelLower, "o1") ||
strings.HasPrefix(modelLower, "o3") {
return "openai"
}

// Default to anthropic
// Default to anthropic for unknown models
r.logger.Printf("⚠️ Model '%s' doesn't match any known patterns, defaulting to anthropic", model)
return "anthropic"
}
83 changes: 83 additions & 0 deletions web/app/components/CodeViewer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest';

// Test the escapeHtml and string regex patterns used in CodeViewer
// We test the logic directly since the component uses internal functions

describe('CodeViewer escapeHtml', () => {
// Replicate the escapeHtml function from CodeViewer
const escapeHtml = (str: string) => str
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');

it('escapes double quotes for attribute safety', () => {
expect(escapeHtml('class="foo"')).toBe('class=&quot;foo&quot;');
});

it('escapes single quotes for attribute safety', () => {
expect(escapeHtml("class='foo'")).toBe("class=&#039;foo&#039;");
});

it('escapes HTML tags', () => {
expect(escapeHtml('<div>')).toBe('&lt;div&gt;');
});

it('escapes ampersands', () => {
expect(escapeHtml('a && b')).toBe('a &amp;&amp; b');
});
});

describe('CodeViewer string regex patterns', () => {
// Test the improved string patterns
const doubleQuotePattern = /"(?:[^"\\]|\\.)*"/;
const singleQuotePattern = /'(?:[^'\\]|\\.)*'/;
const backtickPattern = /`(?:[^`\\]|\\.)*`/;

describe('double-quoted strings', () => {
it('matches simple double-quoted strings', () => {
expect('"hello"'.match(doubleQuotePattern)?.[0]).toBe('"hello"');
});

it('matches strings with escaped quotes', () => {
expect('"He said \\"hello\\""'.match(doubleQuotePattern)?.[0]).toBe('"He said \\"hello\\""');
});

it('matches strings with escaped backslashes', () => {
expect('"path\\\\to\\\\file"'.match(doubleQuotePattern)?.[0]).toBe('"path\\\\to\\\\file"');
});

it('matches empty strings', () => {
expect('""'.match(doubleQuotePattern)?.[0]).toBe('""');
});
});

describe('single-quoted strings', () => {
it('matches simple single-quoted strings', () => {
expect("'hello'".match(singleQuotePattern)?.[0]).toBe("'hello'");
});

it('matches strings with escaped quotes', () => {
expect("'It\\'s fine'".match(singleQuotePattern)?.[0]).toBe("'It\\'s fine'");
});

it('matches empty strings', () => {
expect("''".match(singleQuotePattern)?.[0]).toBe("''");
});
});

describe('backtick strings', () => {
it('matches simple backtick strings', () => {
expect('`hello`'.match(backtickPattern)?.[0]).toBe('`hello`');
});

it('matches strings with escaped backticks', () => {
expect('`use \\`code\\``'.match(backtickPattern)?.[0]).toBe('`use \\`code\\``');
});

it('matches empty strings', () => {
expect('``'.match(backtickPattern)?.[0]).toBe('``');
});
});
});
83 changes: 56 additions & 27 deletions web/app/components/CodeViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,39 +82,68 @@ export function CodeViewer({ code, fileName, language }: CodeViewerProps) {

const detectedLanguage = language || getLanguageFromFileName(fileName);

// Basic syntax highlighting for common tokens
// Single-pass syntax highlighting to avoid corrupting HTML class attributes
const highlightCode = (code: string): string => {
// Escape HTML
let highlighted = code
// Escape HTML helper
const escapeHtml = (str: string) => str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');

// Common patterns for many languages
const patterns = [
// Strings
{ regex: /(["'`])(?:(?=(\\?))\2.)*?\1/g, class: 'text-green-400' },
// Comments
{ regex: /(\/\/.*$)/gm, class: 'text-gray-500 italic' },
{ regex: /(\/\*[\s\S]*?\*\/)/g, class: 'text-gray-500 italic' },
{ regex: /(#.*$)/gm, class: 'text-gray-500 italic' },
// Numbers
{ regex: /\b(\d+\.?\d*)\b/g, class: 'text-purple-400' },
// Keywords (common across many languages)
{ regex: /\b(function|const|let|var|if|else|for|while|return|class|import|export|from|async|await|def|elif|except|finally|lambda|with|as|raise|del|global|nonlocal|assert|break|continue|try|catch|throw|new|this|super|extends|implements|interface|abstract|static|public|private|protected|void|int|string|boolean|float|double|char|long|short|byte|enum|struct|typedef|union|namespace|using|package|goto|switch|case|default)\b/g, class: 'text-blue-400' },
// Boolean and null values
{ regex: /\b(true|false|null|undefined|nil|None|True|False)\b/g, class: 'text-orange-400' },
// Function calls (basic)
{ regex: /(\w+)(?=\s*\()/g, class: 'text-yellow-400' },
// Types/Classes (PascalCase)
{ regex: /\b([A-Z][a-zA-Z0-9]*)\b/g, class: 'text-cyan-400' },
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');

// Define token patterns with priorities (first match wins)
// Order matters: strings and comments first to avoid highlighting inside them
const tokenPatterns = [
{ regex: /"(?:[^"\\]|\\.)*"/, className: 'text-green-400' }, // double-quoted strings
{ regex: /'(?:[^'\\]|\\.)*'/, className: 'text-green-400' }, // single-quoted strings
{ regex: /`(?:[^`\\]|\\.)*`/, className: 'text-green-400' }, // backtick strings
{ regex: /\/\/.*$/, className: 'text-gray-500 italic' }, // single-line comments
{ regex: /\/\*[\s\S]*?\*\//, className: 'text-gray-500 italic' }, // multi-line comments
{ regex: /#.*$/, className: 'text-gray-500 italic' }, // hash comments
{ regex: /\b(function|const|let|var|if|else|for|while|return|class|import|export|from|async|await|def|elif|except|finally|lambda|with|as|raise|del|global|nonlocal|assert|break|continue|try|catch|throw|new|this|super|extends|implements|interface|abstract|static|public|private|protected|void|int|string|boolean|float|double|char|long|short|byte|enum|struct|typedef|union|namespace|using|package|goto|switch|case|default|fn|pub|mod|use|mut|match|loop|impl|trait|where|type|readonly|override)\b/, className: 'text-blue-400' }, // keywords
{ regex: /\b(true|false|null|undefined|nil|None|True|False|NULL)\b/, className: 'text-orange-400' }, // literals
{ regex: /\b\d+\.?\d*\b/, className: 'text-purple-400' }, // numbers
{ regex: /\b[A-Z][a-zA-Z0-9]*\b/, className: 'text-cyan-400' }, // PascalCase (types/classes)
];

patterns.forEach(({ regex, class: className }) => {
highlighted = highlighted.replace(regex, `<span class="${className}">$&</span>`);
});
// Build a combined regex that matches any token
const combinedPattern = new RegExp(
tokenPatterns.map(p => `(${p.regex.source})`).join('|'),
'gm'
);

let result = '';
let lastIndex = 0;

// Single pass through the string
for (const match of code.matchAll(combinedPattern)) {
// Add non-matched text before this match (escaped)
if (match.index! > lastIndex) {
result += escapeHtml(code.slice(lastIndex, match.index));
}

// Find which pattern matched (first non-undefined capture group)
const matchedText = match[0];
let className = '';
for (let i = 0; i < tokenPatterns.length; i++) {
if (match[i + 1] !== undefined) {
className = tokenPatterns[i].className;
break;
}
}

// Add the highlighted token (escape the matched text too)
result += `<span class="${className}">${escapeHtml(matchedText)}</span>`;
lastIndex = match.index! + matchedText.length;
}

// Add remaining text after last match
if (lastIndex < code.length) {
result += escapeHtml(code.slice(lastIndex));
}

return highlighted;
return result;
};

const handleCopy = async () => {
Expand Down
Loading