feat(reward-signals): emit content-free router-training feedback to gateway (v0.9.23)

Emit CONTENT-FREE reward signals to `POST {VITE_HANZO_API_URL}/v1/feedback` so
the router training loop gets production feedback. Payload carries ONLY
{request_id, signal, rating?} — never any prompt/response/tag/text/filename/code.
Fire-and-forget: never blocks UX, silent no-op on any failure.

The routing ledger keys each RoutingEvent on the upstream gateway response id
(chatcmpl-…/msg_…). The chat client previously held only locally-minted UUIDs,
so capture the REAL id server-side and thread it to the client as one field:

- server: ModelEndHandler captures `data.output.response_metadata.id ?? .id`
  (the last CHAT_MODEL_END = the visible answer) into a shared `runMetadata`;
  AgentClient surfaces it via `this.metadata` → `feedbackRequestId` on the saved
  response message, riding the SSE final event + DB save to the client.
- schema: add optional `feedbackRequestId` to tMessageSchema (zod), IMessage
  (mongo type) and the mongoose message schema (persists; SQLite keeps it in its
  JSON doc blob automatically). Absent id ⇒ signal no-ops (never fabricated).

Client signals wired via new `client/src/utils/rewardSignal.ts`:
- thumbs up/down → up/down; regenerate → regenerate; copy → up (weak positive);
  model-switch right after a response → switch (last assistant msg's id).
Dedicated cross-origin credentialed fetch (keepalive) to the Hanzo API — NOT the
same-origin chat backend — forwarding the end-user bearer for per-org attribution.
Honors local opt-out `VITE_HANZO_FEEDBACK=0|false|off` (server-side org/user
training opt-in is the preferred enforcement). 13 unit tests, all green.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
This commit is contained in:
hanzo-dev
2026-07-16 09:16:49 -07:00
parent 12bc51f901
commit 58bcd7c2f1
14 changed files with 346 additions and 8 deletions
+18 -2
View File
@@ -16,12 +16,15 @@ const { saveBase64Image } = require('~/server/services/Files/process');
class ModelEndHandler {
/**
* @param {Array<UsageMetadata>} collectedUsage
* @param {{ requestId?: string }} [runMetadata] - Content-free run metadata. Captures the
* upstream gateway response id (the routing-ledger join key) for reward-signal feedback.
*/
constructor(collectedUsage) {
constructor(collectedUsage, runMetadata) {
if (!Array.isArray(collectedUsage)) {
throw new Error('collectedUsage must be an array');
}
this.collectedUsage = collectedUsage;
this.runMetadata = runMetadata;
}
finalize(errorMessage) {
@@ -48,6 +51,17 @@ class ModelEndHandler {
let errorMessage;
try {
const agentContext = graph.getAgentContext(metadata);
/**
* Capture the upstream gateway response id (e.g. `chatcmpl-…`/`msg_…`) — the
* routing-ledger join key for CONTENT-FREE reward signals. The final model turn
* (last CHAT_MODEL_END) wins, matching the visible assistant answer the user rates.
*/
if (this.runMetadata) {
const upstreamId = data?.output?.response_metadata?.id ?? data?.output?.id;
if (typeof upstreamId === 'string' && upstreamId) {
this.runMetadata.requestId = upstreamId;
}
}
if (data?.output?.additional_kwargs?.stop_reason === 'refusal') {
const info = { ...data.output.additional_kwargs };
errorMessage = JSON.stringify({
@@ -121,6 +135,7 @@ async function emitEvent(res, streamId, eventData) {
* @param {ContentAggregator} options.aggregateContent - Content aggregator function.
* @param {ToolEndCallback} options.toolEndCallback - Callback to use when tool ends.
* @param {Array<UsageMetadata>} options.collectedUsage - The list of collected usage metadata.
* @param {{ requestId?: string }} [options.runMetadata] - Content-free run metadata (upstream response id).
* @param {string | null} [options.streamId] - The stream ID for resumable mode, or null for standard mode.
* @param {ToolExecuteOptions} [options.toolExecuteOptions] - Options for event-driven tool execution.
* @returns {Record<string, t.EventHandler>} The default handlers.
@@ -131,6 +146,7 @@ function getDefaultHandlers({
aggregateContent,
toolEndCallback,
collectedUsage,
runMetadata,
streamId = null,
toolExecuteOptions = null,
}) {
@@ -140,7 +156,7 @@ function getDefaultHandlers({
);
}
const handlers = {
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage),
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage, runMetadata),
[GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback, logger),
[GraphEvents.ON_RUN_STEP]: {
/**
+20
View File
@@ -74,6 +74,7 @@ class AgentClient extends BaseClient {
agentConfigs,
contentParts,
collectedUsage,
runMetadata,
artifactPromises,
maxContextTokens,
...clientOptions
@@ -85,6 +86,13 @@ class AgentClient extends BaseClient {
this.contentParts = contentParts;
/** @type {Array<UsageMetadata>} */
this.collectedUsage = collectedUsage;
/**
* Content-free run metadata populated by the model-end handler. Holds ONLY the
* upstream gateway response id (`requestId`) — surfaced on the saved response
* message as `feedbackRequestId` (the routing-ledger join key for reward signals).
* @type {{ requestId?: string } | undefined}
*/
this.runMetadata = runMetadata;
/** @type {ArtifactPromises} */
this.artifactPromises = artifactPromises;
/** @type {AgentClientOptions} */
@@ -934,6 +942,18 @@ class AgentClient extends BaseClient {
});
}
} finally {
/**
* Surface the captured upstream gateway response id on the response message as
* `feedbackRequestId`. `BaseClient.sendMessage` spreads `this.metadata` onto the
* response message, so the id rides the SSE final event + DB save to the client,
* where it becomes the CONTENT-FREE reward-signal join key (routing-ledger).
*/
if (this.runMetadata?.requestId) {
this.metadata = {
...(this.metadata ?? {}),
feedbackRequestId: this.runMetadata.requestId,
};
}
try {
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
if (attachments && attachments.length > 0) {
@@ -91,6 +91,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
/** @type {Array<UsageMetadata>} */
const collectedUsage = [];
/**
* Content-free run metadata shared with the model-end handler and the client.
* Holds ONLY the upstream gateway response id (`requestId`) — the routing-ledger
* join key threaded to the client for reward-signal feedback.
* @type {{ requestId?: string }}
*/
const runMetadata = {};
/** @type {ArtifactPromises} */
const artifactPromises = [];
const { contentParts, aggregateContent } = createContentAggregator();
@@ -139,6 +146,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
aggregateContent,
toolEndCallback,
collectedUsage,
runMetadata,
streamId,
});
@@ -394,6 +402,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
agentConfigs,
eventHandlers,
collectedUsage,
runMetadata,
aggregateContent,
artifactPromises,
agent: primaryConfig,
@@ -1,8 +1,15 @@
import debounce from 'lodash/debounce';
import { useQueryClient } from '@tanstack/react-query';
import React, { createContext, useContext, useState, useMemo, useCallback } from 'react';
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from '@hanzochat/data-provider';
import {
QueryKeys,
EModelEndpoint,
isAgentsEndpoint,
isAssistantsEndpoint,
} from '@hanzochat/data-provider';
import type * as t from '@hanzochat/data-provider';
import type { Endpoint, SelectedValues } from '~/common';
import { sendRewardSignal } from '~/utils/rewardSignal';
import {
useAgentDefaultPermissionLevel,
useSelectorEffects,
@@ -55,6 +62,7 @@ interface ModelSelectorProviderProps {
}
export function ModelSelectorProvider({ children, startupConfig }: ModelSelectorProviderProps) {
const queryClient = useQueryClient();
const agentsMap = useAgentsMapContext();
const assistantsMap = useAssistantsMapContext();
const { data: endpointsConfig } = useGetEndpointsQuery();
@@ -207,6 +215,25 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
};
const handleSelectModel = (endpoint: Endpoint, model: string) => {
/**
* Model-switch reward signal: when the user switches model/endpoint right after a
* response, credit the LAST assistant message's upstream id (the routing-ledger join
* key). No-op when nothing actually changed or no last-response id exists.
*/
const conversationId = conversation?.conversationId;
const isActualChange =
endpoint.value !== conversation?.endpoint || model !== conversation?.model;
if (isActualChange && conversationId) {
const messages = queryClient.getQueryData<t.TMessage[]>([QueryKeys.messages, conversationId]);
const lastAssistant = messages
?.slice()
.reverse()
.find((m) => m && m.isCreatedByUser === false && m.feedbackRequestId);
if (lastAssistant?.feedbackRequestId) {
sendRewardSignal(lastAssistant.feedbackRequestId, 'switch');
}
}
if (isAgentsEndpoint(endpoint.value)) {
onSelectEndpoint?.(endpoint.value, {
agent_id: model,
@@ -15,6 +15,7 @@ import { useChatContext, useAssistantsMapContext, useAgentsMapContext } from '~/
import useCopyToClipboard from './useCopyToClipboard';
import { useAuthContext } from '~/hooks/AuthContext';
import { useGetAddedConvo } from '~/hooks/Chat';
import { sendRewardSignal } from '~/utils/rewardSignal';
import { useLocalize } from '~/hooks';
import store from '~/store';
@@ -39,7 +40,7 @@ export default function useMessageActions(props: TMessageActions) {
const agentsMap = useAgentsMapContext();
const assistantMap = useAssistantsMapContext();
const { text, content, messageId = null, isCreatedByUser } = message ?? {};
const { text, content, messageId = null, isCreatedByUser, feedbackRequestId } = message ?? {};
const edit = useMemo(() => messageId === currentEditId, [messageId, currentEditId]);
const [feedback, setFeedback] = useState<TFeedback | undefined>(() => {
@@ -95,10 +96,19 @@ export default function useMessageActions(props: TMessageActions) {
return;
}
sendRewardSignal(feedbackRequestId, 'regenerate');
regenerate(message, { addedConvo: getAddedConvo() });
}, [isSubmitting, isCreatedByUser, message, regenerate, getAddedConvo]);
}, [isSubmitting, isCreatedByUser, message, regenerate, getAddedConvo, feedbackRequestId]);
const copyToClipboard = useCopyToClipboard({ text, content, searchResults });
const copyMessage = useCopyToClipboard({ text, content, searchResults });
const copyToClipboard = useCallback(
(setIsCopied: Parameters<typeof copyMessage>[0]) => {
// Copy is a weak positive signal; the contract has no weights, so send "up".
sendRewardSignal(feedbackRequestId, 'up');
return copyMessage(setIsCopied);
},
[copyMessage, feedbackRequestId],
);
const messageLabel = useMemo(() => {
if (message?.isCreatedByUser === true) {
@@ -123,6 +133,13 @@ export default function useMessageActions(props: TMessageActions) {
feedback: newFeedback ? toMinimalFeedback(newFeedback) : undefined,
};
// Content-free reward signal: thumbs up/down → "up"/"down" (join key = feedbackRequestId).
if (newFeedback?.rating === 'thumbsUp') {
sendRewardSignal(feedbackRequestId, 'up');
} else if (newFeedback?.rating === 'thumbsDown') {
sendRewardSignal(feedbackRequestId, 'down');
}
feedbackMutation.mutate(payload, {
onSuccess: (data) => {
if (!data.feedback) {
@@ -141,7 +158,7 @@ export default function useMessageActions(props: TMessageActions) {
},
});
},
[feedbackMutation],
[feedbackMutation, feedbackRequestId],
);
return {
+121
View File
@@ -0,0 +1,121 @@
import axios from 'axios';
import { sendRewardSignal } from './rewardSignal';
/**
* `import.meta.env.VITE_*` is rewritten to `process.env.VITE_*` by
* `babel-plugin-transform-vite-meta-env` (runtime read), so tests toggle env via `process.env`.
*/
describe('sendRewardSignal', () => {
const BASE = 'https://api.hanzo.ai';
let fetchMock: jest.Mock;
beforeEach(() => {
fetchMock = jest.fn(() => Promise.resolve({ ok: true } as Response));
global.fetch = fetchMock as unknown as typeof fetch;
process.env.VITE_HANZO_API_URL = BASE;
delete process.env.VITE_HANZO_FEEDBACK;
delete axios.defaults.headers.common['Authorization'];
});
afterEach(() => {
delete process.env.VITE_HANZO_API_URL;
delete process.env.VITE_HANZO_FEEDBACK;
delete axios.defaults.headers.common['Authorization'];
});
const initOf = (call = 0) => fetchMock.mock.calls[call][1] as RequestInit;
const urlOf = (call = 0) => fetchMock.mock.calls[call][0] as string;
const bodyOf = (call = 0) => JSON.parse(initOf(call).body as string);
it('POSTs to {base}/v1/feedback with exactly {request_id, signal} for "up"', () => {
sendRewardSignal('chatcmpl-up', 'up');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(urlOf()).toBe('https://api.hanzo.ai/v1/feedback');
const init = initOf();
expect(init.method).toBe('POST');
expect(init.credentials).toBe('include');
expect(init.keepalive).toBe(true);
expect(bodyOf()).toEqual({ request_id: 'chatcmpl-up', signal: 'up' });
});
it('serializes each signal string exactly and never adds extra fields', () => {
const signals = ['up', 'down', 'regenerate', 'switch', 'abandon', 'accept', 'revert'] as const;
signals.forEach((signal, i) => sendRewardSignal(`id-${signal}-${i}`, signal));
expect(fetchMock).toHaveBeenCalledTimes(signals.length);
signals.forEach((signal, i) => {
expect(bodyOf(i)).toEqual({ request_id: `id-${signal}-${i}`, signal });
expect(bodyOf(i)).not.toHaveProperty('rating');
});
});
it('includes rating ONLY when signal is "rating"', () => {
sendRewardSignal('rate-1', 'rating', 3);
expect(bodyOf()).toEqual({ request_id: 'rate-1', signal: 'rating', rating: 3 });
expect(Object.keys(bodyOf()).sort()).toEqual(['rating', 'request_id', 'signal']);
});
it('omits rating for non-rating signals even when a rating arg is passed', () => {
sendRewardSignal('up-with-rating', 'up', 2);
expect(bodyOf()).toEqual({ request_id: 'up-with-rating', signal: 'up' });
expect(bodyOf()).not.toHaveProperty('rating');
});
it('makes NO network call when requestId is missing', () => {
sendRewardSignal(undefined, 'up');
sendRewardSignal(null, 'down');
sendRewardSignal('', 'regenerate');
expect(fetchMock).not.toHaveBeenCalled();
});
it('makes NO network call when the base URL is unset', () => {
delete process.env.VITE_HANZO_API_URL;
sendRewardSignal('no-base', 'up');
expect(fetchMock).not.toHaveBeenCalled();
});
it('makes NO network call when opted out via VITE_HANZO_FEEDBACK (0/false/off)', () => {
['0', 'false', 'off'].forEach((val) => {
process.env.VITE_HANZO_FEEDBACK = val;
sendRewardSignal(`opt-${val}`, 'up');
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('mirrors the Authorization bearer from axios defaults; always sends JSON content-type', () => {
axios.defaults.headers.common['Authorization'] = 'Bearer end-user-token';
sendRewardSignal('auth-1', 'up');
const headers = initOf().headers as Record<string, string>;
expect(headers['Authorization']).toBe('Bearer end-user-token');
expect(headers['Content-Type']).toBe('application/json');
});
it('sends no Authorization header when no bearer is set', () => {
sendRewardSignal('auth-2', 'up');
const headers = initOf().headers as Record<string, string>;
expect(headers).not.toHaveProperty('Authorization');
});
it('strips a trailing slash from the base URL', () => {
process.env.VITE_HANZO_API_URL = 'https://api.hanzo.ai/';
sendRewardSignal('slash-1', 'up');
expect(urlOf()).toBe('https://api.hanzo.ai/v1/feedback');
});
it('dedupes the same signal for the same request id', () => {
sendRewardSignal('dupe-1', 'up');
sendRewardSignal('dupe-1', 'up');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('does NOT dedupe different ratings for the same request id', () => {
sendRewardSignal('rate-multi', 'rating', 1);
sendRewardSignal('rate-multi', 'rating', 2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('never throws and swallows a rejected fetch (fire-and-forget)', () => {
fetchMock.mockImplementationOnce(() => Promise.reject(new Error('network down')));
expect(() => sendRewardSignal('reject-1', 'up')).not.toThrow();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
+106
View File
@@ -0,0 +1,106 @@
import axios from 'axios';
/**
* CONTENT-FREE reward signals emitted to the Hanzo gateway (`POST {base}/v1/feedback`)
* so router training gets production feedback.
*
* The payload carries ONLY `{ request_id, signal, rating? }` — never any prompt, response,
* tag, free-text, filename, or code. `request_id` is the upstream gateway response id
* (`chatcmpl-…`/`msg_…`) that the routing ledger keys each RoutingEvent on; the chat client
* receives it as a message's `feedbackRequestId`. When it is missing (e.g. a direct,
* non-gateway provider path) the signal simply no-ops — we never fabricate an id.
*
* Transport is fire-and-forget: it NEVER blocks UX and is a SILENT no-op on any failure.
*/
export type RewardSignal =
| 'up'
| 'down'
| 'regenerate'
| 'switch'
| 'abandon'
| 'accept'
| 'revert'
| 'rating';
type RewardSignalBody = {
request_id: string;
signal: RewardSignal;
rating?: number;
};
/** Dedupe key set — avoids double-sending the same signal for the same message id. */
const sent = new Set<string>();
/**
* Whether reward-signal emission is disabled via the local opt-out flag.
*
* Server-side org/user training opt-in is the PREFERRED enforcement and, when present,
* makes this client gating redundant — but the client still honors the local opt-out.
*/
function isOptedOut(): boolean {
const flag = import.meta.env.VITE_HANZO_FEEDBACK;
return flag === '0' || flag === 'false' || flag === 'off';
}
/**
* Emit a content-free reward signal for the given upstream request id.
*
* @param requestId - The message's `feedbackRequestId` (upstream gateway response id). No-op if absent.
* @param signal - The reward signal.
* @param rating - Only sent when `signal === 'rating'` (0-3).
*/
export function sendRewardSignal(
requestId: string | null | undefined,
signal: RewardSignal,
rating?: number,
): void {
if (!requestId || !signal) {
return;
}
if (isOptedOut()) {
return;
}
const base = import.meta.env.VITE_HANZO_API_URL;
if (!base) {
return;
}
const body: RewardSignalBody = { request_id: requestId, signal };
if (signal === 'rating' && typeof rating === 'number') {
body.rating = rating;
}
const dedupeKey =
signal === 'rating' ? `${requestId}:${signal}:${body.rating}` : `${requestId}:${signal}`;
if (sent.has(dedupeKey)) {
return;
}
sent.add(dedupeKey);
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
/** Mirror `setTokenHeader`: forward the end-user bearer for per-org attribution. */
const authorization = axios.defaults.headers.common?.['Authorization'];
if (typeof authorization === 'string' && authorization) {
headers['Authorization'] = authorization;
}
/**
* Dedicated cross-origin call to the Hanzo API — NOT the same-origin chat backend.
* `credentials: 'include'` reuses the IAM cross-origin session; `keepalive` lets late
* signals (switch/abandon) survive navigation.
*/
void fetch(`${String(base).replace(/\/+$/, '')}/v1/feedback`, {
method: 'POST',
credentials: 'include',
keepalive: true,
headers,
body: JSON.stringify(body),
}).catch(() => {
/* silent no-op */
});
} catch {
/* silent no-op */
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzochat/chat",
"version": "0.9.22",
"version": "0.9.23",
"description": "Chat - Unified interface to Agents, LLMs, MCPs and any AI model or OpenAPI documented API with full RAG stack",
"packageManager": "pnpm@10.27.0",
"workspaces": [
+6
View File
@@ -611,6 +611,12 @@ export const tMessageSchema = z.object({
/* frontend components */
iconURL: z.string().nullable().optional(),
feedback: feedbackSchema.optional(),
/**
* Upstream gateway response id (e.g. `chatcmpl-…`/`msg_…`) for the assistant message.
* The CONTENT-FREE reward-signal join key (routing ledger). Absent for direct/non-gateway
* responses — signals simply no-op when it is missing.
*/
feedbackRequestId: z.string().nullable().optional(),
/** metadata */
metadata: z.record(z.unknown()).optional(),
});
+4
View File
@@ -2667,6 +2667,10 @@ const messageSchema = new mongoose.Schema({
iconURL: {
type: String,
},
/** Upstream gateway response id — the content-free reward-signal join key (routing ledger). */
feedbackRequestId: {
type: String,
},
metadata: { type: mongoose.Schema.Types.Mixed },
attachments: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined },
/*
+4
View File
@@ -2665,6 +2665,10 @@ const messageSchema = new Schema({
iconURL: {
type: String,
},
/** Upstream gateway response id — the content-free reward-signal join key (routing ledger). */
feedbackRequestId: {
type: String,
},
metadata: { type: mongoose.Schema.Types.Mixed },
attachments: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined },
/*
+2
View File
@@ -38,6 +38,8 @@ export interface IMessage extends Document {
thread_id?: string;
iconURL?: string;
addedConvo?: boolean;
/** Upstream gateway response id — the content-free reward-signal join key (routing ledger). */
feedbackRequestId?: string;
metadata?: Record<string, unknown>;
attachments?: unknown[];
expiredAt?: Date;
@@ -118,6 +118,10 @@ const messageSchema: Schema<IMessage> = new Schema(
iconURL: {
type: String,
},
/** Upstream gateway response id — the content-free reward-signal join key (routing ledger). */
feedbackRequestId: {
type: String,
},
metadata: { type: mongoose.Schema.Types.Mixed },
attachments: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined },
/*
@@ -40,6 +40,8 @@ export interface IMessage extends Document {
thread_id?: string;
iconURL?: string;
addedConvo?: boolean;
/** Upstream gateway response id — the content-free reward-signal join key (routing ledger). */
feedbackRequestId?: string;
metadata?: Record<string, unknown>;
attachments?: unknown[];
expiredAt?: Date;