mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 18:53:37 +00:00
Expose Assistant fallback model route
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user