Expose Assistant fallback model route

This commit is contained in:
rcourtman
2026-06-05 21:29:41 +01:00
parent 17c4c1c172
commit 5ef50f712d
9 changed files with 98 additions and 4 deletions
@@ -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
@@ -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
@@ -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');
});
});
@@ -55,6 +55,7 @@ export interface ContentData {
export interface DoneData {
session_id?: string;
model?: string;
input_tokens?: number;
output_tokens?: number;
}
@@ -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();
@@ -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<string, unknown>;
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<string, unknown>;
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,
};
+2
View File
@@ -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(),
})
@@ -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 {
+1
View File
@@ -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"`
}