From f8d3378f96f0a0398fb650b6d730b8fd796fa2ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 16:08:29 +0000 Subject: [PATCH 1/2] feat: surface provider meta on client and fix BYOK labeling Parse server meta through SSE/complete responses into ChatMessage, show provider details in DEV chat UI, and label BYOK by actual provider/host instead of hardcoding openrouter. Co-authored-by: Ocean82 --- server/src/index.ts | 24 ++++++++++--- src/ai/agentClient.test.ts | 43 ++++++++++++++++++++++++ src/ai/agentClient.ts | 50 +++++++++++++++++++--------- src/ai/brain.ts | 1 + src/ai/pipeline/stages/llmGateway.ts | 1 + src/ai/responseBuilder.ts | 1 + src/ai/types.ts | 1 + src/components/ChatPanel.tsx | 10 ++++++ src/services/chatService.ts | 7 ++-- src/types/api.ts | 8 +++++ 10 files changed, 123 insertions(+), 23 deletions(-) create mode 100644 src/ai/agentClient.test.ts diff --git a/server/src/index.ts b/server/src/index.ts index fa9154e..d877413 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -219,6 +219,8 @@ async function runLlmChat(params: { 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) { @@ -230,7 +232,17 @@ async function runLlmChat(params: { } 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, + } + byokSucceeded = true } catch (err) { const msg = err instanceof Error ? err.message : String(err) providerErrors.push(`byok(${byok.provider}): ${msg}`) @@ -239,7 +251,7 @@ async function runLlmChat(params: { } } - if (!usedProvider) { + if (!byokSucceeded) { for (const provider of availableProviders) { try { const providerOpts = { jsonMode: !llmOnly, maxTokens: llmOnly ? undefined : 2048 } @@ -251,6 +263,7 @@ async function runLlmChat(params: { fullText = response.text } usedProvider = provider + providerMeta = { provider, model: getModelName(provider) } break } catch (err) { const msg = err instanceof Error ? err.message : String(err) @@ -265,7 +278,7 @@ async function runLlmChat(params: { } } - if (!usedProvider) { + if (!byokSucceeded && !usedProvider) { if (providerErrors.length) { console.warn('[llm] all providers failed:', providerErrors.join(' | ')) } @@ -291,7 +304,7 @@ async function runLlmChat(params: { 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, } } @@ -301,6 +314,7 @@ async function runLlmChat(params: { // 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, @@ -329,7 +343,7 @@ async function runLlmChat(params: { message: parsed.message, actions: parsed.actions, source: 'llm', - meta: usedProvider ? { provider: usedProvider, model: getModelName(usedProvider) } : undefined, + meta: providerMeta ?? undefined, } } diff --git a/src/ai/agentClient.test.ts b/src/ai/agentClient.test.ts new file mode 100644 index 0000000..70c8687 --- /dev/null +++ b/src/ai/agentClient.test.ts @@ -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', + }) + }) +}) diff --git a/src/ai/agentClient.ts b/src/ai/agentClient.ts index 392fd9a..6e90f10 100644 --- a/src/ai/agentClient.ts +++ b/src/ai/agentClient.ts @@ -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' @@ -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 + } + 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, + } + } catch { + return null + } } export interface ServerHealth { @@ -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 } catch { // Skip malformed events } @@ -153,5 +172,6 @@ export function serverResponseToChatMessage( timestamp, suggestions: response.suggestions, actions: actions.length > 0 ? actions : undefined, + providerMeta: response.meta, } } diff --git a/src/ai/brain.ts b/src/ai/brain.ts index faa46e9..7d8739a 100644 --- a/src/ai/brain.ts +++ b/src/ai/brain.ts @@ -692,6 +692,7 @@ export async function processMessage(input: ProcessMessageInput): Promise 0 ? contextualSuggestions : (deterministic?.suggestions ?? serverResult.suggestions), diff --git a/src/ai/pipeline/stages/llmGateway.ts b/src/ai/pipeline/stages/llmGateway.ts index 5539bf7..f3d4fc1 100644 --- a/src/ai/pipeline/stages/llmGateway.ts +++ b/src/ai/pipeline/stages/llmGateway.ts @@ -96,6 +96,7 @@ export function createLLMGatewayStage(): PipelineStage { toolUsed: 'llm', source: serverResult.source, reasoning: serverResult.reasoning, + providerMeta: serverResult.meta, }, } } diff --git a/src/ai/responseBuilder.ts b/src/ai/responseBuilder.ts index bbf463c..d6a0c9c 100644 --- a/src/ai/responseBuilder.ts +++ b/src/ai/responseBuilder.ts @@ -207,5 +207,6 @@ export function toolResultToChatMessage( actions: actions.length > 0 ? actions : undefined, toolUsed: result.toolUsed ?? meta?.toolUsed, insightsSnapshot: meta?.insightsSnapshot, + providerMeta: result.providerMeta, } } diff --git a/src/ai/types.ts b/src/ai/types.ts index 9042db6..5d8f2f4 100644 --- a/src/ai/types.ts +++ b/src/ai/types.ts @@ -49,6 +49,7 @@ export interface ToolResult { chartConfig?: ChartConfig toolUsed?: string reasoning?: string + providerMeta?: { provider: string; model: string } actions?: Array<{ tool: string params: Record diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 24593f9..4ab53a5 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -320,6 +320,16 @@ export function ChatPanel({ isMobileOpen, onCloseMobile, embedded }: { isMobileO ))} )} + {isAssistant && import.meta.env.DEV && msg.providerMeta && ( +
+ + provider details + + + {msg.providerMeta.provider} · {msg.providerMeta.model} + +
+ )} {isAssistant && (