diff --git a/controlplane/src/chat/handler.ts b/controlplane/src/chat/handler.ts index c74fe455..332d60f7 100644 --- a/controlplane/src/chat/handler.ts +++ b/controlplane/src/chat/handler.ts @@ -17,20 +17,14 @@ console.log(`[chat] REVISION: chat-v19-help-docs-grounding loaded at ${new Date().toISOString()}`); import type { Env, ChatMessage, ChatToolCall, ChatToolResult, ChatStreamEvent, AnyUIGuidanceCommand } from '../types'; -import { - streamChat, - buildTextMessage, - buildFunctionCallMessage, - buildFunctionResponse, - type GeminiMessage, - type GeminiTool, -} from '../gemini/client'; +import { type GeminiTool } from '../gemini/client'; +import { selectChatProvider } from './providers/select'; +import type { CanonMsg, CanonToolCall, CanonToolResult, ChatToolDef } from './providers/types'; import { UI_TOOLS, callTool as callUiTool } from '../mcp-ui/handler'; import * as dashboards from '../dashboards/handler'; import * as secrets from '../secrets/handler'; import * as integrationPolicies from '../integration-policies/handler'; import { SandboxClient } from '../sandbox/client'; -import { decryptSecret, getEncryptionKey, hasEncryptionKey, isEncryptedValue } from '../crypto/secrets'; import { HELP_DOCS_GROUNDING } from './help-docs'; // System prompt for Orcabot @@ -501,6 +495,29 @@ function convertMcpToGeminiTools(mcpTools: typeof UI_TOOLS): GeminiTool[] { } // Get all available tools for Orcabot +const PROVIDER_LABEL: Record = { gemini: 'Gemini', anthropic: 'Anthropic', openai: 'OpenAI' }; + +/** + * Turn a raw provider error into a short, user-actionable message. Keeps the + * common self-serviceable cases (quota, bad key, rate limit) distinct from the + * generic fallback, without leaking the raw provider JSON. + */ +function friendlyProviderError(raw: string | undefined, providerId: string): string { + const label = PROVIDER_LABEL[providerId] || 'The model provider'; + const s = (raw || '').toLowerCase(); + if (s.includes('insufficient_quota') || s.includes('exceeded your current quota') || s.includes('quota')) { + return `${label}: quota exceeded — check your plan and billing.`; + } + if (s.includes('invalid_api_key') || s.includes('incorrect api key') || s.includes('invalid api key') || + s.includes('authentication_error') || s.includes('invalid x-api-key')) { + return `${label}: API key rejected — check the key.`; + } + if (s.includes('rate_limit') || s.includes('rate limit')) { + return `${label}: rate-limited — try again in a moment.`; + } + return 'Something went wrong — please try again.'; +} + function getOrcabotTools(): GeminiTool[] { return [ ...DASHBOARD_TOOLS, @@ -1247,55 +1264,45 @@ async function loadHistory( * Note: Gemini 3 requires thoughtSignature for function calls. * We skip any function call/response pairs without thoughtSignature (legacy data). */ -function historyToGeminiMessages(history: ChatMessage[], dashboardId?: string, baseSystemPrompt?: string): GeminiMessage[] { - const messages: GeminiMessage[] = []; +function historyToCanon(history: ChatMessage[]): CanonMsg[] { + const out: CanonMsg[] = []; - // Add system prompt as first user message (Gemini doesn't have system role) - let systemPrompt = baseSystemPrompt ?? ORCABOT_SYSTEM_PROMPT; - if (dashboardId) { - systemPrompt += `\n\nCURRENT CONTEXT:\n- The user is viewing dashboard_id: "${dashboardId}". Use this as the dashboard_id for all tool calls unless the user explicitly refers to a different dashboard.`; - } - messages.push(buildTextMessage('user', systemPrompt)); - messages.push(buildTextMessage('model', 'Ready.')); - - // Build a lookup of tool results from legacy 'tool' role rows, - // so we can fall back when assistant rows don't have toolResults (old data). + // Legacy 'tool' rows hold results for older data where assistant rows lacked + // toolResults; index them by toolCallId so we can pair them below. const legacyToolResults = new Map(); for (const msg of history) { if (msg.role === 'tool' && msg.toolResults) { - for (const tr of msg.toolResults) { - legacyToolResults.set(tr.toolCallId, tr); - } + for (const tr of msg.toolResults) legacyToolResults.set(tr.toolCallId, tr); } } for (const msg of history) { if (msg.role === 'user') { - messages.push(buildTextMessage('user', msg.content)); + out.push({ role: 'user', text: msg.content }); } else if (msg.role === 'assistant') { - // Add text content if present - if (msg.content) { - messages.push(buildTextMessage('model', msg.content)); + const toolCalls: CanonToolCall[] = (msg.toolCalls || []).map(tc => ({ + id: tc.id, + name: tc.name, + args: tc.args, + // thoughtSignature is Gemini-only; carried as opaque meta and ignored by + // the other providers. The Gemini provider drops calls lacking it. + meta: tc.thoughtSignature ? { thoughtSignature: tc.thoughtSignature } : undefined, + })); + if (msg.content || toolCalls.length) { + out.push({ role: 'assistant', text: msg.content || undefined, toolCalls: toolCalls.length ? toolCalls : undefined }); } - // Add tool calls with thoughtSignatures - if (msg.toolCalls && msg.toolCalls.length > 0) { - for (const tc of msg.toolCalls) { - if (tc.thoughtSignature) { - messages.push(buildFunctionCallMessage(tc.name, tc.args, tc.thoughtSignature)); - // Look for result on assistant row first, then fall back to legacy tool rows - const result = msg.toolResults?.find(tr => tr.toolCallId === tc.id) - || legacyToolResults.get(tc.id); - if (result) { - messages.push(buildFunctionResponse(tc.name, result.result)); - } - } - } + // Pair each call with its result (assistant row first, then legacy rows). + const results: CanonToolResult[] = []; + for (const tc of msg.toolCalls || []) { + const r = msg.toolResults?.find(tr => tr.toolCallId === tc.id) || legacyToolResults.get(tc.id); + if (r) results.push({ id: tc.id, name: tc.name, result: r.result, isError: r.isError }); } + if (results.length) out.push({ role: 'tool', toolResults: results }); } - // Skip 'tool' role messages - results are consumed above via legacyToolResults fallback + // 'tool' rows consumed via legacyToolResults above } - return messages; + return out; } // ============================================ @@ -1320,12 +1327,10 @@ export async function streamMessage( env: Env, userId: string ): Promise { - if (!env.GEMINI_ORCABOT_KEY) { - return Response.json( - { error: 'Orcabot Gemini API key not configured' }, - { status: 500 } - ); - } + // NOTE: don't hard-require GEMINI_ORCABOT_KEY here. On desktop no system key + // ships, but the user can bring their own GEMINI_API_KEY (used below). The key + // is resolved after we load the user's stored keys; if neither exists we return + // a distinct CHAT_NO_KEY error the client turns into an "add a key" prompt. let body: { message: string; dashboardId?: string }; try { @@ -1386,19 +1391,18 @@ When they ask to set up a coding agent or terminal, automatically use ${bestProv If they explicitly name a different provider they have a key for, use that one instead.`; } - // ---- Use user's Gemini key for Orcabot chat if available (saves system quota) ---- - let apiKey = env.GEMINI_ORCABOT_KEY; - const geminiKeyRow = (userKeyRows.results || []).find(r => r.name === 'GEMINI_API_KEY'); - if (geminiKeyRow && hasEncryptionKey(env)) { - try { - const encKey = await getEncryptionKey(env); - const decrypted = isEncryptedValue(geminiKeyRow.value) - ? await decryptSecret(geminiKeyRow.value, encKey) - : geminiKeyRow.value; - if (decrypted) apiKey = decrypted; - } catch { - // Fall back to system key if decryption fails - } + // Pick the chat provider + key. Cloud stays on Gemini (system key, or the + // user's own Gemini key to save quota); desktop uses whichever provider key the + // user brought (Gemini → Anthropic → OpenAI). None → CHAT_NO_KEY prompt. + const provider = await selectChatProvider(env, (userKeyRows.results || []) as { name: string; value: string }[]); + if (!provider) { + return Response.json( + { + error: 'E79230: Orcabot chat needs an API key. Add a supported provider key (Claude, Gemini, or OpenAI) to continue.', + code: 'CHAT_NO_KEY', + }, + { status: 400 } + ); } // Load conversation history @@ -1407,12 +1411,21 @@ If they explicitly name a different provider they have a key for, use that one i // Save user message await saveMessage(env, userId, dashboardId || null, 'user', message); - // Build Gemini messages (inject dashboardId as context so model knows the active dashboard) - const geminiMessages = historyToGeminiMessages(history, dashboardId, systemPrompt); - geminiMessages.push(buildTextMessage('user', message)); - - // Get available tools - const tools = getOrcabotTools(); + // Build canonical conversation + system prompt (each provider converts these to + // its own wire format). Inject the active dashboard as context. + let system = systemPrompt; + if (dashboardId) { + system += `\n\nCURRENT CONTEXT:\n- The user is viewing dashboard_id: "${dashboardId}". Use this as the dashboard_id for all tool calls unless the user explicitly refers to a different dashboard.`; + } + const convo: CanonMsg[] = historyToCanon(history); + convo.push({ role: 'user', text: message }); + + // Get available tools (canonical form: name + description + JSON-schema params) + const tools: ChatToolDef[] = getOrcabotTools().map(t => ({ + name: t.name, + description: t.description, + parameters: t.inputSchema, + })); // Derive the control plane origin from the incoming request for OAuth URLs const requestOrigin = new URL(request.url).origin; @@ -1424,55 +1437,54 @@ If they explicitly name a different provider they have a key for, use that one i try { let fullContent = ''; const toolCalls: (ChatToolCall & { result?: Record; isError?: boolean })[] = []; - let currentMessages = geminiMessages; + let convoState: CanonMsg[] = convo; // Loop to handle multi-turn tool calls let maxTurns = 10; // Increased for complex workflows while (maxTurns > 0) { maxTurns--; let hasToolCall = false; + let turnText = ''; + const turnToolCalls: CanonToolCall[] = []; + const turnToolResults: CanonToolResult[] = []; - for await (const chunk of streamChat(apiKey, currentMessages, tools, { - model: 'gemini-3-flash', - thinkingLevel: 'low', - temperature: 1.0, - maxOutputTokens: 4096, - })) { + for await (const chunk of provider.streamTurn(system, convoState, tools)) { if (chunk.type === 'text' && chunk.text) { fullContent += chunk.text; + turnText += chunk.text; const event: ChatStreamEvent = { type: 'text', content: chunk.text }; controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); - } else if (chunk.type === 'function_call' && chunk.functionCall) { + } else if (chunk.type === 'tool_call') { hasToolCall = true; - const tcId = `tc_${generateId()}`; - const thoughtSignature = chunk.thoughtSignature || chunk.functionCall.thoughtSignature; + const tcId = chunk.id; + const sig = typeof chunk.meta?.thoughtSignature === 'string' ? chunk.meta.thoughtSignature : undefined; // Send tool call event const tcEvent: ChatStreamEvent = { type: 'tool_call', id: tcId, - name: chunk.functionCall.name, - args: chunk.functionCall.args, + name: chunk.name, + args: chunk.args, }; controller.enqueue(encoder.encode(`data: ${JSON.stringify(tcEvent)}\n\n`)); // Auto-fill dashboard_id from request context if the model omitted it - const toolArgs = { ...chunk.functionCall.args }; + const toolArgs = { ...chunk.args }; if (!toolArgs.dashboard_id && dashboardId) { toolArgs.dashboard_id = dashboardId; } // Execute the tool - const { result, isError } = await executeTool(env, userId, chunk.functionCall.name, toolArgs, requestOrigin); + const { result, isError } = await executeTool(env, userId, chunk.name, toolArgs, requestOrigin); // Store the tool call with result for persistence (use toolArgs which includes auto-filled dashboard_id) const tc: ChatToolCall & { result?: Record; isError?: boolean; thoughtSignature?: string } = { id: tcId, - name: chunk.functionCall.name, + name: chunk.name, args: toolArgs, result, isError, - thoughtSignature: thoughtSignature, + thoughtSignature: sig, }; toolCalls.push(tc); @@ -1505,24 +1517,28 @@ If they explicitly name a different provider they have a key for, use that one i } } - // Add tool call and result to messages for next turn - // Include thoughtSignature as required by Gemini 3 - currentMessages = [ - ...currentMessages, - buildFunctionCallMessage(tc.name, tc.args, thoughtSignature), - buildFunctionResponse(tc.name, result), - ]; + // Collect this call + result for the next turn's canonical messages + turnToolCalls.push({ id: tcId, name: chunk.name, args: toolArgs, meta: chunk.meta }); + turnToolResults.push({ id: tcId, name: chunk.name, result, isError }); } else if (chunk.type === 'error') { - console.error(`[chat] Gemini API error (raw) (dashboardId=${dashboardId || 'N/A'}):`, chunk.error); - const errorEvent: ChatStreamEvent = { type: 'error', error: 'Something went wrong — please try again.' }; + console.error(`[chat] provider error (provider=${provider.id} dashboardId=${dashboardId || 'N/A'}):`, chunk.error); + const errorEvent: ChatStreamEvent = { type: 'error', error: friendlyProviderError(chunk.error, provider.id) }; controller.enqueue(encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`)); } + // chunk.type === 'done' — end of this turn's stream } // If no tool call, we're done if (!hasToolCall) { break; } + + // Append this turn's assistant tool calls + results for the next turn. + convoState = [ + ...convoState, + { role: 'assistant', text: turnText || undefined, toolCalls: turnToolCalls }, + { role: 'tool', toolResults: turnToolResults }, + ]; } // Save assistant message with tool calls AND results on the same row diff --git a/controlplane/src/chat/providers/anthropic.ts b/controlplane/src/chat/providers/anthropic.ts new file mode 100644 index 00000000..98f23df5 --- /dev/null +++ b/controlplane/src/chat/providers/anthropic.ts @@ -0,0 +1,155 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary +// REVISION: chat-providers-v1-multi-backend + +/** + * Anthropic provider — Messages API with streaming + tool use. + * Docs: system is a top-level param; tools are {name, description, input_schema}; + * assistant tool calls are `tool_use` content blocks; results go back as + * `tool_result` blocks in a following user message. + */ + +import type { + ChatProvider, + ChatToolDef, + ChatChunk, + ChatStreamOpts, + CanonMsg, +} from './types'; + +const ANTHROPIC_MODEL = 'claude-haiku-4-5-20251001'; +const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages'; +const ANTHROPIC_VERSION = '2023-06-01'; + +type Block = + | { type: 'text'; text: string } + | { type: 'tool_use'; id: string; name: string; input: Record } + | { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean }; + +function toAnthropicMessages(messages: CanonMsg[]): Array<{ role: 'user' | 'assistant'; content: Block[] }> { + const out: Array<{ role: 'user' | 'assistant'; content: Block[] }> = []; + for (const msg of messages) { + if (msg.role === 'user' && msg.text) { + out.push({ role: 'user', content: [{ type: 'text', text: msg.text }] }); + } else if (msg.role === 'assistant') { + const content: Block[] = []; + if (msg.text) content.push({ type: 'text', text: msg.text }); + for (const tc of msg.toolCalls || []) { + content.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.args }); + } + if (content.length) out.push({ role: 'assistant', content }); + } else if (msg.role === 'tool') { + const content: Block[] = (msg.toolResults || []).map(tr => ({ + type: 'tool_result' as const, + tool_use_id: tr.id, + content: JSON.stringify(tr.result), + is_error: tr.isError || undefined, + })); + if (content.length) out.push({ role: 'user', content }); + } + } + return out; +} + +export class AnthropicProvider implements ChatProvider { + readonly id = 'anthropic' as const; + readonly model = ANTHROPIC_MODEL; + constructor(private apiKey: string) {} + + async *streamTurn( + system: string, + messages: CanonMsg[], + tools: ChatToolDef[], + opts?: ChatStreamOpts, + ): AsyncGenerator { + const body = { + model: ANTHROPIC_MODEL, + max_tokens: opts?.maxOutputTokens ?? 4096, + temperature: opts?.temperature ?? 1.0, + system, + messages: toAnthropicMessages(messages), + tools: tools.map(t => ({ name: t.name, description: t.description, input_schema: t.parameters })), + stream: true, + }; + + const response = await fetch(ANTHROPIC_URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': ANTHROPIC_VERSION, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const err = await response.text(); + yield { type: 'error', error: `Anthropic API error: ${response.status} - ${err}` }; + return; + } + if (!response.body) { + yield { type: 'error', error: 'No response body' }; + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + // Track in-progress tool_use blocks by content index: accumulate partial JSON. + const pending: Record = {}; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const data = line.slice(5).trim(); + if (!data || data === '[DONE]') continue; + let ev: Record; + try { + ev = JSON.parse(data); + } catch { + continue; + } + const type = ev.type as string; + if (type === 'content_block_start') { + const idx = ev.index as number; + const block = ev.content_block as { type: string; id?: string; name?: string }; + if (block?.type === 'tool_use') { + pending[idx] = { id: block.id || `ant_${idx}`, name: block.name || '', json: '' }; + } + } else if (type === 'content_block_delta') { + const idx = ev.index as number; + const delta = ev.delta as { type: string; text?: string; partial_json?: string }; + if (delta?.type === 'text_delta' && delta.text) { + yield { type: 'text', text: delta.text }; + } else if (delta?.type === 'input_json_delta' && pending[idx]) { + pending[idx].json += delta.partial_json || ''; + } + } else if (type === 'content_block_stop') { + const idx = ev.index as number; + const tc = pending[idx]; + if (tc) { + let args: Record = {}; + try { + args = tc.json ? JSON.parse(tc.json) : {}; + } catch { + args = {}; + } + yield { type: 'tool_call', id: tc.id, name: tc.name, args }; + delete pending[idx]; + } + } + // message_stop / message_delta need no action here. + } + } + yield { type: 'done' }; + } finally { + reader.releaseLock(); + } + } +} diff --git a/controlplane/src/chat/providers/gemini.ts b/controlplane/src/chat/providers/gemini.ts new file mode 100644 index 00000000..61481264 --- /dev/null +++ b/controlplane/src/chat/providers/gemini.ts @@ -0,0 +1,105 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary +// REVISION: chat-providers-v1-multi-backend + +/** + * Gemini provider — thin adapter over the existing (tested) gemini/client.ts. + * Preserves current behavior exactly: system prompt as a priming user/model + * pair, thoughtSignature round-tripped through CanonToolCall.meta. + */ + +import { + streamChat, + buildTextMessage, + buildFunctionCallMessage, + buildFunctionResponse, + type GeminiMessage, + type GeminiTool, +} from '../../gemini/client'; +import type { + ChatProvider, + ChatToolDef, + ChatChunk, + ChatStreamOpts, + CanonMsg, +} from './types'; + +const GEMINI_MODEL = 'gemini-3-flash' as const; + +function toGeminiTools(tools: ChatToolDef[]): GeminiTool[] { + return tools.map(t => ({ + name: t.name, + description: t.description, + inputSchema: t.parameters, + })); +} + +function toGeminiMessages(system: string, messages: CanonMsg[]): GeminiMessage[] { + // Gemini has no system role — prime with a user/model pair (current behavior). + const out: GeminiMessage[] = [ + buildTextMessage('user', system), + buildTextMessage('model', 'Ready.'), + ]; + // Gemini 3 requires a thoughtSignature on every function call replayed in + // history. Drop calls without one AND their paired results (matched by id) so + // we never emit an orphaned functionResponse, which the API rejects. + const emitted = new Set(); + for (const msg of messages) { + if (msg.role === 'user' && msg.text) { + out.push(buildTextMessage('user', msg.text)); + } else if (msg.role === 'assistant') { + if (msg.text) out.push(buildTextMessage('model', msg.text)); + for (const tc of msg.toolCalls || []) { + const sig = typeof tc.meta?.thoughtSignature === 'string' ? tc.meta.thoughtSignature : undefined; + if (!sig) continue; + out.push(buildFunctionCallMessage(tc.name, tc.args, sig)); + emitted.add(tc.id); + } + } else if (msg.role === 'tool') { + for (const tr of msg.toolResults || []) { + if (!emitted.has(tr.id)) continue; + out.push(buildFunctionResponse(tr.name, tr.result)); + } + } + } + return out; +} + +export class GeminiProvider implements ChatProvider { + readonly id = 'gemini' as const; + readonly model = GEMINI_MODEL; + constructor(private apiKey: string) {} + + async *streamTurn( + system: string, + messages: CanonMsg[], + tools: ChatToolDef[], + opts?: ChatStreamOpts, + ): AsyncGenerator { + const geminiMessages = toGeminiMessages(system, messages); + let synth = 0; + for await (const chunk of streamChat(this.apiKey, geminiMessages, toGeminiTools(tools), { + model: GEMINI_MODEL, + thinkingLevel: 'low', + temperature: opts?.temperature ?? 1.0, + maxOutputTokens: opts?.maxOutputTokens ?? 4096, + })) { + if (chunk.type === 'text' && chunk.text) { + yield { type: 'text', text: chunk.text }; + } else if (chunk.type === 'function_call' && chunk.functionCall) { + const sig = chunk.thoughtSignature || chunk.functionCall.thoughtSignature; + yield { + type: 'tool_call', + id: `gem_${Date.now()}_${synth++}`, // Gemini is name-based; synthesize an id + name: chunk.functionCall.name, + args: chunk.functionCall.args, + meta: sig ? { thoughtSignature: sig } : undefined, + }; + } else if (chunk.type === 'error') { + yield { type: 'error', error: chunk.error || 'Gemini error' }; + } else if (chunk.type === 'done') { + yield { type: 'done' }; + } + } + } +} diff --git a/controlplane/src/chat/providers/openai.ts b/controlplane/src/chat/providers/openai.ts new file mode 100644 index 00000000..3a6e215b --- /dev/null +++ b/controlplane/src/chat/providers/openai.ts @@ -0,0 +1,163 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary +// REVISION: chat-providers-v1-multi-backend + +/** + * OpenAI provider — Chat Completions API with streaming + tool calls. + * system is a message; tools are {type:'function', function:{...}}; assistant + * tool calls stream as `tool_calls` deltas accumulated by index; results go back + * as `{role:'tool', tool_call_id}` messages. + */ + +import type { + ChatProvider, + ChatToolDef, + ChatChunk, + ChatStreamOpts, + CanonMsg, +} from './types'; + +const OPENAI_MODEL = 'gpt-4o-mini'; +const OPENAI_URL = 'https://api.openai.com/v1/chat/completions'; + +interface OpenAIMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string | null; + tool_calls?: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }>; + tool_call_id?: string; +} + +function toOpenAIMessages(system: string, messages: CanonMsg[]): OpenAIMessage[] { + const out: OpenAIMessage[] = [{ role: 'system', content: system }]; + for (const msg of messages) { + if (msg.role === 'user' && msg.text) { + out.push({ role: 'user', content: msg.text }); + } else if (msg.role === 'assistant') { + const toolCalls = (msg.toolCalls || []).map(tc => ({ + id: tc.id, + type: 'function' as const, + function: { name: tc.name, arguments: JSON.stringify(tc.args) }, + })); + out.push({ + role: 'assistant', + content: msg.text || null, + tool_calls: toolCalls.length ? toolCalls : undefined, + }); + } else if (msg.role === 'tool') { + for (const tr of msg.toolResults || []) { + out.push({ role: 'tool', tool_call_id: tr.id, content: JSON.stringify(tr.result) }); + } + } + } + return out; +} + +export class OpenAIProvider implements ChatProvider { + readonly id = 'openai' as const; + readonly model = OPENAI_MODEL; + constructor(private apiKey: string) {} + + async *streamTurn( + system: string, + messages: CanonMsg[], + tools: ChatToolDef[], + opts?: ChatStreamOpts, + ): AsyncGenerator { + const body = { + model: OPENAI_MODEL, + messages: toOpenAIMessages(system, messages), + tools: tools.map(t => ({ type: 'function' as const, function: { name: t.name, description: t.description, parameters: t.parameters } })), + stream: true, + temperature: opts?.temperature ?? 1.0, + max_tokens: opts?.maxOutputTokens ?? 4096, + }; + + const response = await fetch(OPENAI_URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const err = await response.text(); + yield { type: 'error', error: `OpenAI API error: ${response.status} - ${err}` }; + return; + } + if (!response.body) { + yield { type: 'error', error: 'No response body' }; + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + // Accumulate streamed tool calls by index: id + name arrive first, arguments stream in fragments. + const pending: Record = {}; + + // Emit accumulated tool calls exactly once. OpenAI streams send BOTH a + // `finish_reason` chunk and a `[DONE]` sentinel, so this can be called twice — + // delete each entry as it's yielded so the second call is a no-op (otherwise + // every tool runs twice and duplicate ids corrupt the next turn). + const flushToolCalls = function* (): Generator { + for (const idx of Object.keys(pending).map(Number).sort((a, b) => a - b)) { + const tc = pending[idx]; + delete pending[idx]; + let args: Record = {}; + try { + args = tc.args ? JSON.parse(tc.args) : {}; + } catch { + args = {}; + } + yield { type: 'tool_call', id: tc.id || `oai_${idx}`, name: tc.name, args }; + } + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const data = line.slice(5).trim(); + if (!data) continue; + if (data === '[DONE]') { + yield* flushToolCalls(); + yield { type: 'done' }; + return; + } + let ev: { choices?: Array<{ delta?: { content?: string; tool_calls?: Array<{ index: number; id?: string; function?: { name?: string; arguments?: string } }> }; finish_reason?: string }> }; + try { + ev = JSON.parse(data); + } catch { + continue; + } + const choice = ev.choices?.[0]; + if (!choice) continue; + const delta = choice.delta; + if (delta?.content) { + yield { type: 'text', text: delta.content }; + } + for (const tcDelta of delta?.tool_calls || []) { + const idx = tcDelta.index; + if (!pending[idx]) pending[idx] = { id: '', name: '', args: '' }; + if (tcDelta.id) pending[idx].id = tcDelta.id; + if (tcDelta.function?.name) pending[idx].name += tcDelta.function.name; + if (tcDelta.function?.arguments) pending[idx].args += tcDelta.function.arguments; + } + if (choice.finish_reason) { + yield* flushToolCalls(); + } + } + } + yield { type: 'done' }; + } finally { + reader.releaseLock(); + } + } +} diff --git a/controlplane/src/chat/providers/select.ts b/controlplane/src/chat/providers/select.ts new file mode 100644 index 00000000..5f479c4d --- /dev/null +++ b/controlplane/src/chat/providers/select.ts @@ -0,0 +1,60 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary +// REVISION: chat-providers-v1-multi-backend + +import { decryptSecret, getEncryptionKey, hasEncryptionKey, isEncryptedValue } from '../../crypto/secrets'; +import type { Env } from '../../types'; +import { GeminiProvider } from './gemini'; +import { AnthropicProvider } from './anthropic'; +import { OpenAIProvider } from './openai'; +import type { ChatProvider } from './types'; + +interface KeyRow { name: string; value: string } + +/** Decrypt whatever provider keys the user has stored (skips anything unreadable). */ +async function decryptKeys(env: Env, rows: KeyRow[]): Promise> { + const out: Record = {}; + if (!rows.length) return out; + const canDecrypt = hasEncryptionKey(env); + let encKey: CryptoKey | undefined; + for (const row of rows) { + if (!row.value) continue; + if (isEncryptedValue(row.value)) { + if (!canDecrypt) continue; + try { + if (!encKey) encKey = await getEncryptionKey(env); + const dec = await decryptSecret(row.value, encKey); + if (dec) out[row.name] = dec; + } catch { + // skip unreadable key + } + } else { + out[row.name] = row.value; + } + } + return out; +} + +/** + * Pick the chat provider + key. + * + * - Cloud (GEMINI_ORCABOT_KEY set): unchanged — prefer the user's own Gemini key + * (saves system quota), else the system key. Chat stays on free Gemini; we do + * NOT silently spend the user's paid Anthropic/OpenAI key when free Gemini is + * available. + * - Desktop (no system key): use whichever provider key the user brought, in + * priority order Gemini → Anthropic → OpenAI (Gemini first: cheap/free tier). + * - Neither: null → caller returns the CHAT_NO_KEY prompt. + */ +export async function selectChatProvider(env: Env, rows: KeyRow[]): Promise { + const keys = await decryptKeys(env, rows); + const systemGemini = env.GEMINI_ORCABOT_KEY; + + if (systemGemini) { + return new GeminiProvider(keys.GEMINI_API_KEY || systemGemini); + } + if (keys.GEMINI_API_KEY) return new GeminiProvider(keys.GEMINI_API_KEY); + if (keys.ANTHROPIC_API_KEY) return new AnthropicProvider(keys.ANTHROPIC_API_KEY); + if (keys.OPENAI_API_KEY) return new OpenAIProvider(keys.OPENAI_API_KEY); + return null; +} diff --git a/controlplane/src/chat/providers/types.ts b/controlplane/src/chat/providers/types.ts new file mode 100644 index 00000000..b6ac3f04 --- /dev/null +++ b/controlplane/src/chat/providers/types.ts @@ -0,0 +1,79 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary +// REVISION: chat-providers-v1-multi-backend + +/** + * Provider-neutral chat abstraction. + * + * The Orcabot chat was hardwired to Gemini's wire format (messages, tools, + * streaming, tool-call parsing). This layer normalizes those so the same + * agentic loop can run against Gemini, Anthropic, or OpenAI — whichever key the + * user has. Each ChatProvider converts canonical <-> its native format + * internally and yields canonical ChatChunk events. + */ + +export type ChatRole = 'user' | 'assistant' | 'tool'; + +export interface CanonToolCall { + /** Stable id: real for Anthropic/OpenAI, synthesized for Gemini (name-based). */ + id: string; + name: string; + args: Record; + /** Provider-opaque round-trip data (e.g. Gemini thoughtSignature). */ + meta?: Record; +} + +export interface CanonToolResult { + id: string; // matches CanonToolCall.id + name: string; + result: Record; + isError?: boolean; +} + +export interface CanonMsg { + role: ChatRole; + text?: string; + toolCalls?: CanonToolCall[]; // assistant turns + toolResults?: CanonToolResult[]; // tool turns +} + +/** Canonical tool definition: name + description + JSON Schema for the args. */ +export interface ChatToolDef { + name: string; + description: string; + parameters: { + type: 'object'; + properties: Record; + required?: string[]; + }; +} + +export type ChatChunk = + | { type: 'text'; text: string } + | { type: 'tool_call'; id: string; name: string; args: Record; meta?: Record } + | { type: 'error'; error: string } + | { type: 'done' }; + +export interface ChatStreamOpts { + temperature?: number; + maxOutputTokens?: number; +} + +export type ProviderId = 'gemini' | 'anthropic' | 'openai'; + +export interface ChatProvider { + readonly id: ProviderId; + /** Model id this provider streams against (for logging/telemetry). */ + readonly model: string; + /** + * Stream one assistant turn. Converts `messages` to the provider's native + * format, calls the provider API, and yields canonical chunks. `system` is the + * system prompt (each provider places it natively). + */ + streamTurn( + system: string, + messages: CanonMsg[], + tools: ChatToolDef[], + opts?: ChatStreamOpts, + ): AsyncGenerator; +} diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx index 76d36171..44ef4e79 100644 --- a/frontend/src/components/chat/ChatPanel.tsx +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -1,6 +1,6 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: chat-v33-setup-race-fix +// REVISION: chat-v34-no-key-provider-card "use client"; @@ -12,7 +12,7 @@ * Supports smooth handoff from splash page transition overlay. */ -const CHAT_PANEL_REVISION = "chat-v33-setup-race-fix"; +const CHAT_PANEL_REVISION = "chat-v34-no-key-provider-card"; const AI_ONBOARD_KEYWORD = "force_ai_onboard"; console.log(`[ChatPanel] REVISION: ${CHAT_PANEL_REVISION} loaded at ${new Date().toISOString()}`); @@ -58,6 +58,9 @@ export function ChatPanel({ dashboardId, className, onUICommand, needsAiSetup, o // - user needs AI provider setup (needsAiSetup) const [isExpanded, setIsExpanded] = React.useState(false); const [showSetupCard, setShowSetupCard] = React.useState(false); + // When chat fails for lack of an API key we render the setup card inline in the + // error slot; this hides it again once the user finishes (before they re-send). + const [keyErrorDismissed, setKeyErrorDismissed] = React.useState(false); // Persists the provider names saved via the setup card — shown as a permanent bubble in chat const [setupSavedKeys, setSetupSavedKeys] = React.useState([]); const [inputValue, setInputValue] = React.useState(""); @@ -196,6 +199,12 @@ export function ChatPanel({ dashboardId, className, onUICommand, needsAiSetup, o } }, [needsAiSetup, dashboardId]); + // Reset the inline key-setup dismissal whenever the chat error changes, so a + // fresh no-key error re-shows the setup card. + React.useEffect(() => { + setKeyErrorDismissed(false); + }, [error]); + // First time the user interacts with the page *outside* the chat while it's // expanded, auto-minimize it so it's out of the way. Fires once per mount, and // never interrupts the required AI-setup flow. Uses capture so it still sees the @@ -452,11 +461,36 @@ export function ChatPanel({ dashboardId, className, onUICommand, needsAiSetup, o )} - {/* Error message — show friendly text, log raw for debugging */} + {/* Error message. A missing-API-key error (CHAT_NO_KEY / E79230) shows + the provider setup card inline so the user can add a Gemini key and + retry; anything else falls back to the generic notice. */} {error && ( -
- Something went wrong — please try again. -
+ error.includes("E79230") ? ( + !keyErrorDismissed && ( +
+

+ Orcabot chat needs an API key to run. Add a provider key below (Claude, Gemini, or OpenAI), then send your message again. +

+ { + setKeyErrorDismissed(true); + handleSetupCardDone(savedKeys); + // Retry the message that hit the no-key error. Its bubble is + // already shown from the failed attempt, so skip the echo to + // avoid duplicating it. + if (savedKeys && savedKeys.length > 0) { + const lastUser = [...messages].reverse().find((m) => m.role === "user"); + if (lastUser) sendMessage(lastUser.content, { skipUserEcho: true }); + } + }} + /> +
+ ) + ) : ( +
+ {error} +
+ ) )} {/* Streaming response — newest content, shown at top */} diff --git a/frontend/src/hooks/useChat.ts b/frontend/src/hooks/useChat.ts index 634f8d7d..2c8b2439 100644 --- a/frontend/src/hooks/useChat.ts +++ b/frontend/src/hooks/useChat.ts @@ -1,6 +1,6 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: chat-v3-history-race-fix +// REVISION: chat-v4-skip-user-echo /** * useChat hook for Orcabot conversational interface @@ -19,7 +19,7 @@ import { type AnyUIGuidanceCommand, } from "@/lib/api/cloudflare/chat"; -const HOOK_REVISION = "chat-v3-history-race-fix"; +const HOOK_REVISION = "chat-v4-skip-user-echo"; console.log(`[useChat] REVISION: ${HOOK_REVISION} loaded at ${new Date().toISOString()}`); export interface PendingToolCall { @@ -40,7 +40,7 @@ export interface UseChatState { } export interface UseChatActions { - sendMessage: (message: string) => Promise; + sendMessage: (message: string, opts?: { skipUserEcho?: boolean }) => Promise; clearHistory: () => Promise; loadHistory: () => Promise; } @@ -90,19 +90,23 @@ export function useChat(dashboardId?: string, options?: UseChatOptions): UseChat }, [loadHistory]); // Send a message - const sendMessage = React.useCallback(async (message: string) => { + const sendMessage = React.useCallback(async (message: string, opts?: { skipUserEcho?: boolean }) => { if (!message.trim()) return; - // Add user message to local state immediately - const userMessage: ChatMessage = { - id: `temp_${Date.now()}`, - userId: "", - dashboardId: dashboardId || null, - role: "user", - content: message, - createdAt: new Date().toISOString(), - }; - setMessages(prev => [...prev, userMessage]); + // Add user message to local state immediately — unless this is a retry (e.g. + // after adding an API key), where the bubble is already shown from the + // failed attempt and we'd otherwise duplicate it. + if (!opts?.skipUserEcho) { + const userMessage: ChatMessage = { + id: `temp_${Date.now()}`, + userId: "", + dashboardId: dashboardId || null, + role: "user", + content: message, + createdAt: new Date().toISOString(), + }; + setMessages(prev => [...prev, userMessage]); + } sendingRef.current = true; setIsStreaming(true);