fix(consult): stop the consult-gate VedAstro wait and record step timing

Consult turns reuse a same-day official snapshot inside the rectification gate, or record deferred_in_consultation instead of starting another 4-second snapshot. Classification, step 0, step 1, and the time to the first answer character go into the existing usage metadata and a new admin usage column.

BUG-1231, BUG-1232
This commit is contained in:
jesse-ux
2026-10-05 02:33:36 +08:00
parent 4c64733f91
commit 137ed6f408
16 changed files with 1188 additions and 9 deletions
+65 -3
View File
@@ -2,6 +2,68 @@ import { NextResponse } from "next/server";
import { requirePermission } from "@/lib/admin/auth";
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse, invalidQueryResponse, parseListQuery } from "@/lib/admin/http";
export const runtime="nodejs";
type Row={id:string;user_id:string;email:string|null;request_id:string;feature_key:string;source:string;requested_model_id:string|null;actual_model_id:string|null;model_config_version:number|null;input_tokens:number;output_tokens:number;cost_microusd:string;duration_ms:number|null;created_at:Date;cache_read_tokens:string|null;total_count:string};
export async function GET(request:Request){try{await requirePermission("billing.orders.read");const p=parseListQuery(request);if(!p.success)return invalidQueryResponse(p.error.flatten());const q=p.data.q?`%${p.data.q}%`:null;const rows=await queryAdminRows<Row>(`select l.id,l.user_id,u.email,l.request_id,l.feature_key,l.source,l.requested_model_id,l.actual_model_id,l.model_config_version,l.input_tokens,l.output_tokens,l.cost_microusd::text,l.duration_ms,l.created_at,l.metadata->'cache'->>'readTokens' as cache_read_tokens,count(*) over()::text total_count from public.usage_ledger l left join identity.users u on u.id=l.user_id where ($1::text is null or u.email ilike $1 or l.request_id ilike $1 or l.actual_model_id ilike $1) and ($2::text is null or l.source=$2 or l.feature_key=$2) order by l.created_at desc limit $3 offset $4`,[q,p.data.status??null,p.data.pageSize,pageOffset(p.data.page,p.data.pageSize)]);return NextResponse.json({data:rows.map(r=>({id:r.id,userId:r.user_id,email:r.email,requestId:r.request_id,featureKey:r.feature_key,source:r.source,requestedModelId:r.requested_model_id,actualModelId:r.actual_model_id,modelConfigVersion:r.model_config_version,inputTokens:r.input_tokens,outputTokens:r.output_tokens,costMicrousd:Number(r.cost_microusd),durationMs:r.duration_ms,cacheReadTokens:r.cache_read_tokens==null?null:Number(r.cache_read_tokens),createdAt:r.created_at.toISOString()})),total:Number(rows[0]?.total_count??0)});}catch(e){return adminErrorResponse(e)}}
import { usageTimingFromLedger } from "@/lib/consultation-step-timing";
export const runtime = "nodejs";
type Row = {
id: string;
user_id: string;
email: string | null;
request_id: string;
feature_key: string;
source: string;
requested_model_id: string | null;
actual_model_id: string | null;
model_config_version: number | null;
input_tokens: number;
output_tokens: number;
cost_microusd: string;
duration_ms: number | null;
created_at: Date;
cache_read_tokens: string | null;
model_steps: unknown;
classification_duration_ms: string | null;
answer_reasoning_ms: string | null;
total_count: string;
};
const LIST_SQL = `select l.id,l.user_id,u.email,l.request_id,l.feature_key,l.source,l.requested_model_id,l.actual_model_id,l.model_config_version,l.input_tokens,l.output_tokens,l.cost_microusd::text,l.duration_ms,l.created_at,l.metadata->'cache'->>'readTokens' as cache_read_tokens,l.metadata->'modelSteps' as model_steps,l.metadata->'classification'->>'durationMs' as classification_duration_ms,l.metadata->'answer'->>'reasoning_ms' as answer_reasoning_ms,count(*) over()::text total_count from public.usage_ledger l left join identity.users u on u.id=l.user_id where ($1::text is null or u.email ilike $1 or l.request_id ilike $1 or l.actual_model_id ilike $1) and ($2::text is null or l.source=$2 or l.feature_key=$2) order by l.created_at desc limit $3 offset $4`;
export async function GET(request: Request) {
try {
await requirePermission("billing.orders.read");
const parsed = parseListQuery(request);
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const q = parsed.data.q ? `%${parsed.data.q}%` : null;
const rows = await queryAdminRows<Row>(LIST_SQL, [
q,
parsed.data.status ?? null,
parsed.data.pageSize,
pageOffset(parsed.data.page, parsed.data.pageSize),
]);
return NextResponse.json({
data: rows.map((row) => ({
id: row.id,
userId: row.user_id,
email: row.email,
requestId: row.request_id,
featureKey: row.feature_key,
source: row.source,
requestedModelId: row.requested_model_id,
actualModelId: row.actual_model_id,
modelConfigVersion: row.model_config_version,
inputTokens: row.input_tokens,
outputTokens: row.output_tokens,
costMicrousd: Number(row.cost_microusd),
durationMs: row.duration_ms,
cacheReadTokens: row.cache_read_tokens == null ? null : Number(row.cache_read_tokens),
timing: usageTimingFromLedger(row.model_steps, row.classification_duration_ms, row.answer_reasoning_ms),
createdAt: row.created_at.toISOString(),
})),
total: Number(rows[0]?.total_count ?? 0),
});
} catch (error) {
return adminErrorResponse(error);
}
}
+14 -2
View File
@@ -42,6 +42,7 @@ import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { streamTextResponse } from "@/lib/stream-text-response";
import { classifyConsultationTurn, type SmalltalkUsage } from "@/lib/consultation-smalltalk";
import { consultationUsageMetadata } from "@/lib/consultation-step-timing";
import { streamSmalltalkResponse } from "@/lib/stream-smalltalk-response";
import { streamFirstResponse } from "@/lib/stream-first-response";
import { consultationPublicActivityEvent, streamAgentResponse } from "@/lib/stream-agent-response";
@@ -662,6 +663,8 @@ export async function POST(request: Request) {
const usageStartedAt = Date.now();
let classificationUsage: SmalltalkUsage = {};
let classificationOutcome: string | undefined;
let classificationDurationMs: number | null = null;
let latencyState: ReturnType<typeof createConsultationRuntimeState> | null = null;
const turn = parsed.data.entrypoint === undefined
? await classifyConsultationTurn({
model: selectedModel,
@@ -673,6 +676,7 @@ export async function POST(request: Request) {
if (!observation.late) {
classificationUsage = observation.usage ?? {};
classificationOutcome = observation.outcome;
classificationDurationMs = observation.durationMs;
}
const inputTokens = observation.usage?.inputTokens ?? 0;
const outputTokens = observation.usage?.outputTokens ?? 0;
@@ -682,6 +686,7 @@ export async function POST(request: Request) {
modelVersion: String(selectedModel.configVersion),
policyVersion: "consultation-smalltalk-v1",
toolCalls: [],
classification: { durationMs: observation.durationMs },
contractPhases: [{
phase: observation.late ? "classification.late_usage" : `classification.${observation.outcome}`,
durationMs: observation.durationMs,
@@ -789,12 +794,17 @@ export async function POST(request: Request) {
durationMs: Date.now() - usageStartedAt,
metadata: {
...(cache ? { cache: { ...cache, hit: cache.readTokens > 0 } } : {}),
...(classificationOutcome ? { classification: {
outcome: classificationOutcome,
...(classificationOutcome || classificationDurationMs !== null ? { classification: {
...(classificationOutcome ? { outcome: classificationOutcome } : {}),
usageKnown: classificationUsage.inputTokens !== undefined || classificationUsage.outputTokens !== undefined,
inputTokens: classificationUsage.inputTokens ?? null,
outputTokens: classificationUsage.outputTokens ?? null,
durationMs: classificationDurationMs,
} } : {}),
...(latencyState?.modelStepTimings === undefined ? {} : consultationUsageMetadata({
modelSteps: latencyState.modelStepTimings,
answerReasoningMs: latencyState.answerReasoningMs,
})),
},
};
}
@@ -939,6 +949,7 @@ export async function POST(request: Request) {
// can produce, otherwise a run that exhausts its steps also truncates the
// evidence of having done so.
const state = createConsultationRuntimeState({ plannedSteps: AGENT_MAX_STEPS });
latencyState = state;
const hooks = createConsultationRuntimeHooks(state);
const usages: Promise<Usage>[] = [];
const agentStartedAt = Date.now();
@@ -1012,6 +1023,7 @@ export async function POST(request: Request) {
],
retryCount: Math.max(0, usages.length - 1),
...consultationModelStepTelemetry(state),
...(classificationDurationMs === null ? {} : { classification: { durationMs: classificationDurationMs } }),
...(errorCode === undefined ? {} : { errorCode }),
inputTokens,
outputTokens,