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
24 changes: 19 additions & 5 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
callProviderStream,
getModelName,
} from './providers.js'
import { callProviderStructured, StructuredOutputError } from './structuredOutput.js'

Check warning on line 29 in server/src/index.ts

View workflow job for this annotation

GitHub Actions / test

'StructuredOutputError' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 29 in server/src/index.ts

View workflow job for this annotation

GitHub Actions / test

'callProviderStructured' is defined but never used. Allowed unused vars must match /^_/u

import { checkUsage, recordUsage, getUsageStats } from './usage.js'
import { dbHealthCheck, closePool } from './db.js'
Expand Down Expand Up @@ -219,6 +219,8 @@
const availableProviders = providerOrder().filter(providerIsConfigured)
let fullText = ''
let usedProvider: ProviderName | null = null
let providerMeta: { provider: string; model: string } | null = null
let byokSucceeded = false
const providerErrors: string[] = []

if (byok?.apiKey && byok?.baseUrl) {
Expand All @@ -230,7 +232,17 @@
} else {
fullText = await chatWithOpenAiCompatible(byokParams, messages)
}
usedProvider = 'openrouter' // label it generically
let byokHost = 'custom'
try {
byokHost = new URL(byok.baseUrl).hostname
} catch {
// keep custom
}
providerMeta = {
provider: byok.provider?.trim() || byokHost || 'byok',
model: byok.model?.trim() || byokHost,
}
Comment on lines +241 to +244

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Defaulting BYOK model to the host name may be misleading or confusing.

Here providerMeta.model falls back to byokHost when byok.model is empty, so the "model" field may show a hostname (e.g. api.openai.com) instead of a model identifier. To avoid confusion when debugging or inspecting replies, consider using an explicit placeholder (e.g. 'unknown-model' or 'custom') or omitting the model rather than reusing the host value.

Suggested change
providerMeta = {
provider: byok.provider?.trim() || byokHost || 'byok',
model: byok.model?.trim() || byokHost,
}
providerMeta = {
provider: byok.provider?.trim() || byokHost || 'byok',
model: byok.model?.trim() || 'unknown-model',
}

Fix in Cursor

byokSucceeded = true
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
providerErrors.push(`byok(${byok.provider}): ${msg}`)
Expand All @@ -239,7 +251,7 @@
}
}

if (!usedProvider) {
if (!byokSucceeded) {
for (const provider of availableProviders) {
try {
const providerOpts = { jsonMode: !llmOnly, maxTokens: llmOnly ? undefined : 2048 }
Expand All @@ -251,6 +263,7 @@
fullText = response.text
}
usedProvider = provider
providerMeta = { provider, model: getModelName(provider) }
break
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
Expand All @@ -265,7 +278,7 @@
}
}

if (!usedProvider) {
if (!byokSucceeded && !usedProvider) {
if (providerErrors.length) {
console.warn('[llm] all providers failed:', providerErrors.join(' | '))
}
Expand All @@ -291,7 +304,7 @@
message: text || 'I could not generate a response. Try rephrasing your question.',
actions: [],
source: 'llm',
meta: usedProvider ? { provider: usedProvider, model: getModelName(usedProvider) } : undefined,
meta: providerMeta ?? undefined,
}
}

Expand All @@ -301,6 +314,7 @@
// If the LLM returned text that doesn't parse to valid actions, retry once
// with a correction hint. This catches the common case where the model
// returns prose instead of JSON, or malformed JSON.
// Only retry against server providers (not BYOK) to avoid wrong credentials.
if (!stream && parsed.actions.length === 0 && fullText.trim().length > 0 && usedProvider) {
const retryHint: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [
...messages,
Expand Down Expand Up @@ -329,7 +343,7 @@
message: parsed.message,
actions: parsed.actions,
source: 'llm',
meta: usedProvider ? { provider: usedProvider, model: getModelName(usedProvider) } : undefined,
meta: providerMeta ?? undefined,
}
}

Expand Down
43 changes: 43 additions & 0 deletions src/ai/agentClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { parseCompleteSseEvent, serverResponseToChatMessage } from './agentClient'

describe('parseCompleteSseEvent', () => {
it('retains provider meta from complete events', () => {
const raw = JSON.stringify({
type: 'complete',
message: 'Sorted column B',
actions: [],
source: 'llm',
meta: { provider: 'groq', model: 'llama-3.3-70b-versatile' },
})
const parsed = parseCompleteSseEvent(raw)
expect(parsed).not.toBeNull()
expect(parsed!.meta).toEqual({
provider: 'groq',
model: 'llama-3.3-70b-versatile',
})
})

it('returns null for token events', () => {
expect(parseCompleteSseEvent(JSON.stringify({ type: 'token', content: 'hi' }))).toBeNull()
})

it('returns null for malformed JSON', () => {
expect(parseCompleteSseEvent('{not-json')).toBeNull()
})
})

describe('serverResponseToChatMessage', () => {
it('copies provider meta onto ChatMessage', () => {
const msg = serverResponseToChatMessage({
message: 'Done',
actions: [],
source: 'llm',
meta: { provider: 'groq', model: 'llama-3.3-70b-versatile' },
})
expect(msg.providerMeta).toEqual({
provider: 'groq',
model: 'llama-3.3-70b-versatile',
})
})
})
50 changes: 35 additions & 15 deletions src/ai/agentClient.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { v4 as uuid } from 'uuid'
import type { AgentAction, ChatMessage } from '@/types'
import type { AgentAction, ChatMessage, ProviderMeta } from '@/types'
import type { SpreadsheetContextPayload } from '@/ai/buildContext'
import { getAuthHeaders } from '@/lib/cloudSync'
import { getByokPayload } from '@/lib/userApiKey'
Expand All @@ -18,6 +18,33 @@ export interface ServerChatResponse {
source: 'llm' | 'fallback' | 'template'
reasoning?: string
suggestions?: string[]
meta?: ProviderMeta
}

/** Parse an SSE `data:` JSON payload into a ServerChatResponse when type=complete. */
export function parseCompleteSseEvent(jsonStr: string): ServerChatResponse | null {
try {
const event = JSON.parse(jsonStr) as {
type?: string
message?: string
actions?: ServerAgentAction[]
source?: string
reasoning?: string
suggestions?: string[]
meta?: ProviderMeta
Comment on lines +25 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (performance): Avoid double JSON.parse for SSE events to reduce overhead and inconsistency risk.

Within the stream loop, jsonStr is parsed twice: once to inspect type, then again in parseCompleteSseEvent. This adds per-event overhead and creates two separate parsing paths (a minimal { type?: string; content?: string } shape vs. the full event type). Refactor so the event is parsed only once—either by passing the already-parsed object into parseCompleteSseEvent, or by making parseCompleteSseEvent the single parsing entry point for SSE chunks.

Suggested implementation:

/** Parse a parsed SSE `data:` JSON payload into a ServerChatResponse when type=complete. */
export function parseCompleteSseEvent(event: {
  type?: string
  message?: string
  actions?: ServerAgentAction[]
  source?: string
  reasoning?: string
  suggestions?: string[]
  meta?: ProviderMeta
}): ServerChatResponse | null {
  try {
    if (event.type !== 'complete' || typeof event.message !== 'string') return null
    return {

To fully avoid double JSON.parse:

  1. In the SSE stream loop where jsonStr is read, keep a single const event = JSON.parse(jsonStr) and pass event into parseCompleteSseEvent(event) instead of parseCompleteSseEvent(jsonStr).
  2. Remove any other callers that still pass a string and ensure they now pass the already-parsed event object.
  3. If there is a separate lightweight parse for { type?: string; content?: string }, you can reuse that parsed object by widening its type (or re-parsing once into the richer shape), then forwarding it into parseCompleteSseEvent.

Fix in Cursor

}
if (event.type !== 'complete' || typeof event.message !== 'string') return null
return {
message: event.message,
actions: Array.isArray(event.actions) ? event.actions : [],
source: (event.source as ServerChatResponse['source']) ?? 'llm',
reasoning: event.reasoning,
suggestions: event.suggestions,
meta: event.meta,
Comment on lines +39 to +43

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 | 🟠 Major | ⚡ Quick win

Validate all optional completion fields before forwarding them.

JSON.parse plus a type assertion does not validate source, suggestions, or meta. For example, a completion event with suggestions: "text" reaches ChatPanel, where the assistant-message renderer calls .map() and throws. Validate the source enum, string-array suggestions, and both ProviderMeta strings. Reject or normalize invalid values before returning the response. Add malformed-field 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 `@src/ai/agentClient.ts` around lines 39 - 43, Validate optional completion
fields in the event-mapping logic of agentClient: ensure source matches the
allowed enum, suggestions is an array of strings, and meta contains valid
ProviderMeta string fields before forwarding them. Reject or normalize malformed
values so ChatPanel receives only valid data, and add tests covering each
malformed field.

}
Comment on lines +36 to +44
} catch {
return null
}
}

export interface ServerHealth {
Expand Down Expand Up @@ -106,21 +133,13 @@ export async function chatWithAgentServerStream(
if (!jsonStr) continue

try {
const event = JSON.parse(jsonStr) as
| { type: 'token'; content: string }
| { type: 'complete'; message: string; actions: ServerAgentAction[]; source: string; reasoning?: string; suggestions?: string[] }

if (event.type === 'token') {
onToken(event.content)
} else if (event.type === 'complete') {
finalResponse = {
message: event.message,
actions: event.actions,
source: event.source as ServerChatResponse['source'],
reasoning: event.reasoning,
suggestions: event.suggestions,
}
const parsed = JSON.parse(jsonStr) as { type?: string; content?: string }
if (parsed.type === 'token' && typeof parsed.content === 'string') {
onToken(parsed.content)
continue
}
const complete = parseCompleteSseEvent(jsonStr)
if (complete) finalResponse = complete
Comment on lines +136 to +142

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 | 🟠 Major | ⚡ Quick win

Preserve SSE frames that span stream chunks.

A ReadableStream chunk can end inside a data: line. text.split('\n') discards that partial line, and the next chunk no longer starts with data: . This can drop token events or the only complete event and return null after streamed text. Keep an undecoded-line buffer between reads, process only newline-terminated frames, and flush the decoder and buffer when the stream ends.

🤖 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 `@src/ai/agentClient.ts` around lines 136 - 142, Update the streaming read loop
around JSON.parse, parseCompleteSseEvent, and onToken to retain an
undecoded-line buffer across chunks. Process only newline-terminated SSE frames,
append each decoded chunk to the buffer, and after the stream ends flush the
decoder and process any remaining buffered line so token and complete events are
preserved.

} catch {
// Skip malformed events
}
Expand Down Expand Up @@ -153,5 +172,6 @@ export function serverResponseToChatMessage(
timestamp,
suggestions: response.suggestions,
actions: actions.length > 0 ? actions : undefined,
providerMeta: response.meta,
}
}
1 change: 1 addition & 0 deletions src/ai/brain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,7 @@ export async function processMessage(input: ProcessMessageInput): Promise<ToolRe
message: combined || 'I looked at your sheet but didn\'t find enough to go on. Try selecting a range or asking a more specific question.',
toolUsed: deterministic?.toolUsed ?? (finalLlmText ? 'llm' : 'insights'),
reasoning: serverResult.reasoning,
providerMeta: serverResult.meta,
suggestions: contextualSuggestions.length > 0
? contextualSuggestions
: (deterministic?.suggestions ?? serverResult.suggestions),
Expand Down
Loading
Loading