SunriseAI.Api — Comprehensive End-to-End Request Flow¶
Full trace of one chat request, from the HTTP wire to the persisted row, with the exact
point where behavior forks depending on AiSettings.ActiveProvider, and how the two
forks reconverge. Read top to bottom — it's linear until explicitly marked as a fork.
STAGE 0 — same for every provider: request enters the pipeline¶
POST https://{merchant}.sunrisecloud.com/api/chat/stream (or /api/chat)
Body: { sessionId, message, ... }
MerchantResolutionMiddlewareruns first, before any endpoint code.- Reads
context.Request.Host.Host(e.g.vinetiq.sunrisecloud.com). - Splits on the first
.→"vinetiq". - Case-insensitive lookup against
SyncSettings.Merchants. - No match, or matched but
IsActive == false→ hard 404, pipeline stops here, nothing downstream ever runs. -
On success: stores the resolved
MerchantConfiginHttpContext.Items["Merchant"]and pushesMerchantinto the Serilog log context for the rest of the request — every subsequent log line in this request is automatically tagged with which merchant it belongs to. -
JWT Bearer auth middleware (ASP.NET built-in, configured in
Program.cs) validates the token signature/expiry against a pre-shared symmetric secret. The API never issues tokens itself, only consumes them. -
RequestUseris built (scoped DI) from claims onHttpContext.User—ContactId,MerchantId,ChannelId,Username,Name. -
ChatEndpoints— validates the request body (request.IsValid), pullsmerchantviacontext.GetMerchant(), resolvesChatRequestHandlerfrom DI. /api/chat: callshandler.HandleAsync(merchant, requestUser, request), no status callback, returns the wholeAgentResponseas one JSON body./api/chat/stream: opens anSseWriterfirst (Content-Type: text/event-stream), callshandler.HandleAsync(..., onStatusUpdate: async status => sseWriter.WriteAsync ("status", status)), then writes a final"result"SSE event with the serializedAgentResponseonce the handler returns.- Both endpoints call the exact same handler method — streaming vs. non-streaming is not a structurally different code path, only whether a non-null status callback exists for the agent loop to invoke.
STAGE 1 — same for every provider: ChatRequestHandler.HandleAsync¶
- Builds a fresh
MariaDbExecutor(raw connection string, not DI-scoped) and runsEnsureTablesExistAsync()— idempotentCREATE TABLE IF NOT EXISTSforai_chat_sessions/ai_token_usage, run on every single request. - Loads (or creates) the
ChatSessionrow forrequest.SessionId. - Loads the last 10 messages for this session from MariaDB, strips each down to
just
{ Role, ContentText }asChatMessageContent— no raw SQL, no chart JSON, no token metadata carried forward. This is the entire "conversation memory"; anything older than 10 turns back, or anything beyond role+text, simply isn't there. - Calls
AgentService.RunAsync(merchant, request.Message, request.SessionId, strippedHistory, onStatusUpdate)— this is where provider branching starts. - (after RunAsync returns — see STAGE 4 below for what happens after the fork reconverges)
STAGE 2 — AgentService.RunAsync: setup, then the fork point¶
Still common to every provider:
1. Opens a readonly DuckDB connection for this merchant
(DuckDbConnectionFactory.OpenReadonlyConnection → ACCESS_MODE=READ_ONLY in the
connection string — enforced at the driver level, not just by convention).
2. Builds a fresh QueryExecutor (wraps that connection) and a fresh SunriseSqlPlugin
(wraps the executor) — per request, no shared/singleton state between requests.
The fork:
if (_aiSettings.ActiveProvider.ToLower() == "claude")
return await RunWithAnthropicSdkAsync(merchant, userMessage, sessionId, chatHistory, sqlPlugin, onStatusUpdate);
// only reached for gemini / openai / groq:
var (kernel, executionSettings) = _kernelFactory.Create(sqlPlugin, onStatusUpdate);
STAGE 3A — ActiveProvider == "claude" (the production path)¶
No Semantic Kernel involved at all past this point — see 03-user-flow-and-sk-question.md
for the full "why," short version: SK's OpenAI-compatible connector had no support for
Anthropic's prompt-caching cache_control field, so this path was rewritten against the
native Anthropic SDK to get first-class caching support instead of HTTP-layer JSON
rewriting hacks.
new AnthropicClient(...)— raw SDK client, built fresh per request.execute_sqltool schema is defined inline as aTool/FunctionJSON schema — hand-written here, not reflected offSunriseSqlPlugin's[KernelFunction]attribute (that attribute is inert on this path).- System prompt built via
SystemPromptBuilder.Build(merchant)— full schema always injected,{{TODAYS_DATE}}as date-only — wrapped in aSystemMessagewithCacheControl { Type = ephemeral }. chatHistory(the 10-message DTO list from Stage 1) is converted fromChatMessageContent/AuthorRoleinto the SDK's ownMessage/RoleTypeshape.- First call:
client.Messages.GetClaudeMessageAsync(parameters), wrapped inSendWithRetryAsync— retries up to 3× on HTTP 429 with exponential backoff (2s → 4s → 8s), rethrows on final failure. - The loop:
while (response.StopReason == "tool_use") - Append the assistant's tool-use response to
messages. - For each
ToolUseContentblock: extractsqlfrom the tool input JSON.onStatusUpdate?.Invoke("Querying analytical store...")(if streaming).- Check
sqlResultCache(exact-string match, this-turn-only) — hit → reuse, skip DuckDB entirely; miss →sqlPlugin.ExecuteSqlAsync(sql)(plain method call, no SK pipeline), cache the result. onStatusUpdate?.Invoke($"Got {rowCount} rows in {ms}ms — analysing...").
- Append all tool results as one
ToolResultContent-bearingMessage. onStatusUpdate?.Invoke("Drafting your answer...").- Call Claude again with the extended message list; accumulate
inputTokens/outputTokens/cacheReadTokens/cacheCreationTokensacross every iteration. - Loop exits when
StopReason != "tool_use"(normally"end_turn"). - Special case:
StopReason == "max_tokens"→ skip JSON extraction entirely, return a canned "got cut off"AgentResponseimmediately — no amount of extraction logic can recover genuinely truncated JSON. - Otherwise: pull the first
TextContentblock, run it throughExtractJsonPayload(fence-strip →{"answerText"-key-anchored brace-depth scan → pass-through unchanged as last resort — see02-agentic-loop.mdfor the mechanics and the CLAUDE.md correction), thenJsonSerializer.Deserialize<AgentResponse>. - On successful parse: stamp token counts +
ToolRounds(=loopIteration) +TotalApiCalls(=loopIteration + 1) onto theAgentResponse, return it. - On parse failure: wrap the cleaned text as
AnswerTextwith emptyCharts— graceful degradation, never throws.
What SunriseSqlPlugin.ExecuteSqlAsync does on every tool call, either path:
- QueryExecutor.ExecuteAsync(sql):
- Strips leading --//* */ comments, requires the first real keyword to be
SELECT or WITH (CTEs allowed) — anything else throws InvalidOperationException,
surfaced back to the model as plain error text so it can self-correct, not a hard
failure.
- Regex-enforces a LIMIT clause capped at 500 rows — injects one if absent, clamps
down an existing one if it's higher. This runs regardless of what the model wrote,
so the model cannot bypass the cap by writing its own large LIMIT.
- Runs the query via Dapper against the readonly DuckDB connection.
- Result serialization (SunriseSqlPlugin.SerialiseResult):
- ≤25 rows → full CSV.
- >25 rows → first 25 rows as CSV + a server-computed numeric summary
(total/min/max/avg/count) per column that's majority-numeric and not
_id/_pk/_code-suffixed, so the model can answer aggregate questions without
the full result set ever entering context.
STAGE 3B — ActiveProvider ∈ {gemini, openai, groq} (the alternate path)¶
This is the one where Semantic Kernel is real and load-bearing.
KernelFactory.Create(sqlPlugin, onStatusUpdate):- Registers
FunctionStatusFilter(IFunctionInvocationFilter) — before/after everyexecute_sqlinvocation, callsonStatusUpdate("Querying analytical store...")thenonStatusUpdate("Analysing results..."). This is SK's status-update mechanism — structurally different from the manualonStatusUpdatecalls sprinkled through the hand-written Claude loop, but produces similar-looking status text to the client. - Registers
AutoInvocationLoggingFilter(IAutoFunctionInvocationFilter) — logs "Model requested tool ... / Tool completed in Nms" around every auto-invoked function call, mirroring the manual logging inRunWithAnthropicSdkAsyncso log output looks consistent regardless of which path actually ran. - Registers the real SK connector for whichever provider is active
(
AddGoogleAIGeminiChatCompletion/AddOpenAIChatCompletion×2 for openai/groq — groq reuses the OpenAI connector pointed atapi.groq.com's OpenAI-compatible endpoint). - Registers
SunriseSqlPluginas a Kernel plugin (AddFromObject) — this is where the[KernelFunction("execute_sql")]attribute actually gets used — SK reflects over it to build the tool schema, unlike the Claude path where that schema is hand-written JSON. - Builds
PromptExecutionSettingswithFunctionChoiceBehavior.Auto()— SK's own agentic loop (auto tool-calling), not a hand-writtenwhileloop. - Builds
ChatHistory(SK's own type) — system message + stripped prior turns + new user message. chatService.GetChatMessageContentAsync(history, executionSettings, kernel)— one call, SK internally handles the entire tool-call loop (auto function invocation), theFunctionStatusFilter/AutoInvocationLoggingFilterfiring around each internalexecute_sqlcall.- Token usage extraction is provider-shape-dependent and defensive: tries a
generic
"Usage"metadata object first (OpenAI/Anthropic-shaped), wrapped indynamic+try/catchsince the shape isn't statically known; falls back to Gemini-specific"PromptTokenCount"/"CandidatesTokenCount"metadata keys if present.ToolRoundsis hardcoded to0on this path — SK's auto-invocation loop doesn't expose iteration count the way the hand-written Claude loop naturally does, so that field is simply not tracked here. - Same
ExtractJsonPayload→JsonSerializer.Deserialize<AgentResponse>→ same graceful-degradation-to-raw-text fallback as the Claude path — this part is shared code, not duplicated per provider.
Note on SunriseSqlPlugin re-use across both forks: the exact same plugin instance,
built once in Stage 2, is handed to either fork. On 3A it's called directly as a C#
method; on 3B it's invoked through SK's reflection-based function-calling pipeline. The
500-row cap, SELECT-only enforcement, and CSV/summary serialization behave identically
either way — that logic lives in QueryExecutor/SunriseSqlPlugin, not in either
provider-specific branch.
STAGE 4 — reconverges: back in ChatRequestHandler, after RunAsync returns¶
Identical regardless of which fork ran:
- Builds
assistantMessage(ContentText=AnswerText,ChartsJson= serializedCharts) — not saved yet. - Builds
tokenRecordfrom whateverAgentResponsepopulated (InputTokens,OutputTokens,CacheReadTokens,CacheCreationTokens,TotalApiCalls,ToolRounds— the last of which is always0if the SK fork ran, real if the Claude fork ran). - Returns
agentResponseto the caller immediately — this is what the user actually waits on. Everything below happens after the user already has their answer. _ = Task.Run(async () => { ... })— fire-and-forget background persistence:- Builds its own fresh
MariaDbExecutor/repos from a raw connection string, deliberately not reusing the scoped ones from steps 1-2 — the HTTP request scope (and anything tied toHttpContext) may already be gone by the time this task actually executes, so it only crosses that boundary with POCOs + a connection string. SaveSessionAsync,SaveMessageAsync× 2 (user then assistant),LogTokenUsageAsync.- Any exception here is caught, logged as a
LogWarning, and silently swallowed — no retry, no surfacing to the user (who's already moved on), meaning a transient MariaDB blip at exactly this moment quietly loses that turn's audit trail with zero visibility beyond a log line.
Back in ChatEndpoints: non-streaming returns Results.Ok(agentResponse) directly;
streaming writes a final "result" SSE event with the serialized response, then the
SSE connection ends (Results.Empty).
One-paragraph summary, if asked to compress this to an elevator pitch¶
Every request is merchant-resolved by host header, authenticated, and handed to one
handler that loads short conversation history from MariaDB and calls into AgentService.
For Claude (production), a hand-written loop drives the native Anthropic SDK directly —
manual retries, manual tool-result caching, manual JSON extraction, full control but full
responsibility for correctness. For every other provider, Semantic Kernel's own
auto-function-invocation loop does the equivalent work, at the cost of losing
Anthropic-specific features (prompt caching) and some observability (tool-round counts).
Both forks share the same SQL safety layer (QueryExecutor: SELECT-only, 500-row cap)
and the same result-shaping (CSV + numeric summaries over 25 rows), and both reconverge
on identical graceful-JSON-degradation and fire-and-forget audit persistence — the user
gets their answer before the database write for that turn even starts.