diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 01d39b8fe..4e87b4f49 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -141,7 +141,11 @@ runtime cost control, and shared AI transport surfaces. as realtime, audio, moderation, embedding, and content-safety catalog entries. Once visible output has streamed, Pulse must not silently switch providers for that turn; the error belongs to the visible attempt and is - surfaced through normal failed-turn recovery. + surfaced through normal failed-turn recovery. Assistant completion events + must carry the effective model route that actually completed the turn, and + the drawer must update the in-flight transcript row when `provider_fallback` + names the next route so message labels, cost context, retry decisions, and + model-route recovery do not continue to point at the failed provider. Streamed provider startup must be bounded by the configured Assistant request timeout and the OpenAI-compatible SSE response-header guard; transient startup failures may retry once before surfacing failed-turn recovery, but a diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 893e0ca75..d3ba7b707 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -448,7 +448,10 @@ payload shape change when the portal presents compact client rows. create-session request. The generated frontend union, stream parser tests, and backend JSON snapshot proof must stay in lockstep with that payload; `done.session_id` and `question.session_id` remain compatibility payloads, - not the primary cold-session creation contract. + not the primary cold-session creation contract. Assistant `done` events + must also carry the effective `model` route that completed the stream so + provider fallback, transcript labels, retries, and model-route recovery stay + tied to the actual responding provider rather than the failed request route. 34. `internal/api/ai_handlers.go` shared with `ai-runtime`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary. Provider test responses from `/api/ai/test` and provider-specific `/api/ai/test/{provider}` preflight responses must return one safe diff --git a/frontend-modern/src/api/__tests__/aiChatEvents.test.ts b/frontend-modern/src/api/__tests__/aiChatEvents.test.ts index 187d4b6e5..fcde26bd8 100644 --- a/frontend-modern/src/api/__tests__/aiChatEvents.test.ts +++ b/frontend-modern/src/api/__tests__/aiChatEvents.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import aiChatEventsSource from '@/api/generated/aiChatEvents.ts?raw'; import type { AIChatStreamEvent, + DoneData, SessionData, WorkflowStateData, } from '@/api/generated/aiChatEvents'; @@ -38,4 +39,17 @@ describe('AI chat stream event contract', () => { expect(aiChatEventsSource).toContain('failed_model?: string'); expect(aiChatEventsSource).toContain('next_model?: string'); }); + + it('exposes the effective completion model on done events', () => { + const done: DoneData = { + session_id: 'sess-stream', + model: 'deepseek:deepseek-v4-pro', + input_tokens: 904, + output_tokens: 30, + }; + const event: AIChatStreamEvent = { type: 'done', data: done }; + + expect(event.data?.model).toBe('deepseek:deepseek-v4-pro'); + expect(aiChatEventsSource).toContain('model?: string'); + }); }); diff --git a/frontend-modern/src/api/generated/aiChatEvents.ts b/frontend-modern/src/api/generated/aiChatEvents.ts index 23f5479bc..392fe3e36 100644 --- a/frontend-modern/src/api/generated/aiChatEvents.ts +++ b/frontend-modern/src/api/generated/aiChatEvents.ts @@ -55,6 +55,7 @@ export interface ContentData { export interface DoneData { session_id?: string; + model?: string; input_tokens?: number; output_tokens?: number; } diff --git a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts index 216ab8132..bad68349b 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -934,6 +934,34 @@ describe('useChat', () => { dispose(); }); + it('updates the in-flight message model when provider fallback starts', async () => { + const { getFireEvent } = setupWithEventCapture(); + const { value: chat, dispose } = withRoot(() => + useChat({ sessionId: 's', model: 'openrouter:openai/gpt-4o-mini' }), + ); + + await chat.sendMessage('hi'); + const fire = getFireEvent(); + + fire({ + type: 'workflow_state', + data: { + phase: 'provider_fallback', + message: 'OpenRouter did not start a response; trying Gemini.', + failed_model: 'openrouter:openai/gpt-4o-mini', + next_model: 'gemini:gemini-3.1-flash-lite', + }, + }); + + const assistant = chat.messages().find((m) => m.role === 'assistant')!; + expect(assistant.model).toBe('gemini:gemini-3.1-flash-lite'); + expect(assistant.workflowStatus).toEqual({ + phase: 'provider_fallback', + message: 'OpenRouter did not start a response; trying Gemini.', + }); + dispose(); + }); + it('processes tool_start events', async () => { const { getFireEvent } = setupWithEventCapture(); const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' })); @@ -1238,10 +1266,18 @@ describe('useChat', () => { const fire = getFireEvent(); fire({ type: 'content', data: 'response' }); - fire({ type: 'done', data: { input_tokens: 100, output_tokens: 50 } }); + fire({ + type: 'done', + data: { + model: 'gemini:gemini-3.1-flash-lite', + input_tokens: 100, + output_tokens: 50, + }, + }); const assistant = chat.messages().find((m) => m.role === 'assistant')!; expect(assistant.isStreaming).toBe(false); + expect(assistant.model).toBe('gemini:gemini-3.1-flash-lite'); expect(assistant.tokens).toEqual({ input: 100, output: 50 }); expect(assistant.pendingTools).toHaveLength(0); dispose(); diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 3e7c26c55..86da8091d 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -321,6 +321,20 @@ export function useChat(options: UseChatOptions = {}) { }; }; + const extractCompletedModel = (data: unknown): string => { + if (!data || typeof data !== 'object') return ''; + const record = data as Record; + const modelRoute = record.model ?? record.model_route ?? record.modelRoute; + return typeof modelRoute === 'string' ? modelRoute.trim() : ''; + }; + + const extractWorkflowNextModel = (data: unknown): string => { + if (!data || typeof data !== 'object') return ''; + const record = data as Record; + const nextModel = record.next_model ?? record.nextModel; + return typeof nextModel === 'string' ? nextModel.trim() : ''; + }; + const extractErrorMessage = (data: unknown): string => { if (typeof data === 'string') return data; if (data && typeof data === 'object') { @@ -519,7 +533,13 @@ export function useChat(options: UseChatOptions = {}) { case 'workflow_state': { const workflowStatus = extractWorkflowStatus(event.data); - return workflowStatus ? { ...msg, workflowStatus } : msg; + const nextModel = extractWorkflowNextModel(event.data); + if (!workflowStatus && !nextModel) return msg; + return { + ...msg, + ...(workflowStatus ? { workflowStatus } : {}), + ...(nextModel ? { model: nextModel } : {}), + }; } case 'tool_start': { @@ -791,10 +811,12 @@ export function useChat(options: UseChatOptions = {}) { } : msg; const tokens = extractTokens(event.data); + const completedModel = extractCompletedModel(event.data); if (tokens && (tokens.input > 0 || tokens.output > 0)) { return { ...flushedMsg, isStreaming: false, + ...(completedModel ? { model: completedModel } : {}), pendingTools: [], tokens, workflowStatus: undefined, @@ -803,6 +825,7 @@ export function useChat(options: UseChatOptions = {}) { return { ...flushedMsg, isStreaming: false, + ...(completedModel ? { model: completedModel } : {}), pendingTools: [], workflowStatus: undefined, }; diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index b3c616636..d33ea01ca 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -942,6 +942,7 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac // Send done event with token usage for this request. doneData, _ := json.Marshal(DoneData{ SessionID: session.ID, + Model: selectedModel, InputTokens: loop.GetTotalInputTokens(), OutputTokens: loop.GetTotalOutputTokens(), }) @@ -2304,6 +2305,7 @@ func (s *Service) ExecutePatrolStream(ctx context.Context, req PatrolRequest, ca // Send done event doneData, _ := json.Marshal(DoneData{ SessionID: session.ID, + Model: patrolModel, InputTokens: tempLoop.GetTotalInputTokens(), OutputTokens: tempLoop.GetTotalOutputTokens(), }) diff --git a/internal/ai/chat/service_execute_additional_test.go b/internal/ai/chat/service_execute_additional_test.go index 146de345a..e84d6d7e7 100644 --- a/internal/ai/chat/service_execute_additional_test.go +++ b/internal/ai/chat/service_execute_additional_test.go @@ -431,6 +431,7 @@ func TestService_ExecuteStream_FallsBackWhenPrimaryProviderFailsBeforeVisibleOut var content strings.Builder var errorEvents int var fallbackEvents int + var doneModel string err = service.ExecuteStream(context.Background(), ExecuteRequest{ SessionID: "fallback-before-visible-output", Prompt: "reply", @@ -452,6 +453,12 @@ func TestService_ExecuteStream_FallsBackWhenPrimaryProviderFailsBeforeVisibleOut if data.Phase == "provider_fallback" { fallbackEvents++ } + case "done": + var data DoneData + if err := json.Unmarshal(event.Data, &data); err != nil { + t.Fatalf("unmarshal done: %v", err) + } + doneModel = data.Model } }) if err != nil { @@ -466,6 +473,9 @@ func TestService_ExecuteStream_FallsBackWhenPrimaryProviderFailsBeforeVisibleOut if fallbackEvents != 1 { t.Fatalf("fallback workflow events = %d, want 1", fallbackEvents) } + if doneModel != "gemini:gemini-test" { + t.Fatalf("done model = %q, want fallback model", doneModel) + } messages, err := store.GetMessages("fallback-before-visible-output") if err != nil { diff --git a/internal/ai/chat/types.go b/internal/ai/chat/types.go index df9fc4fcc..d17fc380e 100644 --- a/internal/ai/chat/types.go +++ b/internal/ai/chat/types.go @@ -359,6 +359,7 @@ type ErrorData struct { // DoneData is the data for "done" events type DoneData struct { SessionID string `json:"session_id,omitempty"` + Model string `json:"model,omitempty"` InputTokens int `json:"input_tokens,omitempty"` OutputTokens int `json:"output_tokens,omitempty"` }