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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { useState } from "react";
|
||||
|
||||
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
|
||||
import { formatUsageTiming, type UsageTimingView } from "@/lib/consultation-step-timing";
|
||||
import { formatAdminDate, ResourceTable } from "./resource-table";
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -416,6 +417,7 @@ type Usage = {
|
||||
costMicrousd: number;
|
||||
durationMs: number | null;
|
||||
cacheReadTokens: number | null;
|
||||
timing: UsageTimingView | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -468,6 +470,13 @@ export function UsageResource() {
|
||||
dataIndex: "durationMs",
|
||||
render: (value) => (value == null ? "—" : `${value} ms`),
|
||||
},
|
||||
{
|
||||
title: "分段",
|
||||
dataIndex: "timing",
|
||||
render: (value: UsageTimingView | null) => (
|
||||
<Text style={{ whiteSpace: "pre-line" }}>{formatUsageTiming(value)}</Text>
|
||||
),
|
||||
},
|
||||
{ title: "时间", dataIndex: "createdAt", render: formatAdminDate },
|
||||
];
|
||||
return (
|
||||
|
||||
@@ -99,6 +99,27 @@ export function settlementTelemetryOutcome(
|
||||
};
|
||||
}
|
||||
|
||||
const nullableDurationSchema = durationMsSchema.nullable();
|
||||
const nullableTokenSchema = tokenCountSchema.nullable();
|
||||
|
||||
/** Step 0 decides the chart tool. Step 1 writes the answer. Tokens stay null when the provider omits them. */
|
||||
export const consultationModelStepTimingSchema = z.object({
|
||||
index: z.union([z.literal(0), z.literal(1)]),
|
||||
durationMs: nullableDurationSchema,
|
||||
reasoningTokens: nullableTokenSchema,
|
||||
outputTokens: nullableTokenSchema,
|
||||
inputTokens: nullableTokenSchema,
|
||||
cachedInputTokens: nullableTokenSchema,
|
||||
}).strict().readonly();
|
||||
|
||||
export const consultationAnswerTimingSchema = z.object({
|
||||
reasoning_ms: nullableDurationSchema,
|
||||
}).strict().readonly();
|
||||
|
||||
export const consultationClassificationTimingSchema = z.object({
|
||||
durationMs: nullableDurationSchema,
|
||||
}).strict().readonly();
|
||||
|
||||
export const agentObservabilityToolCallSchema = z.object({
|
||||
name: machineCodeSchema,
|
||||
durationMs: durationMsSchema,
|
||||
@@ -153,6 +174,9 @@ export const agentObservabilityEventSchema = z.object({
|
||||
|
||||
toolCalls: z.array(agentObservabilityToolCallSchema).max(64).optional(),
|
||||
contractPhases: z.array(agentObservabilityContractPhaseSchema).max(64).optional(),
|
||||
classification: consultationClassificationTimingSchema.optional(),
|
||||
modelSteps: z.array(consultationModelStepTimingSchema).max(2).optional(),
|
||||
answer: consultationAnswerTimingSchema.optional(),
|
||||
retryCount: z.number().int().min(0).max(100).optional(),
|
||||
errorCode: machineCodeSchema.optional(),
|
||||
// Why the model stopped, and how many model steps the run consumed across
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Per-step clocks for one ordinary consultation.
|
||||
*
|
||||
* Numbers and statuses only. Provider usage is copied when the provider sent
|
||||
* the field; a missing field stays null and is never estimated. Answer text,
|
||||
* prompts, and birth data are not fields here.
|
||||
*/
|
||||
|
||||
export type ConsultationModelStepTiming = {
|
||||
index: 0 | 1;
|
||||
durationMs: number | null;
|
||||
reasoningTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
inputTokens: number | null;
|
||||
cachedInputTokens: number | null;
|
||||
};
|
||||
|
||||
export type ConsultationLatencyRecord = {
|
||||
steps: Array<ConsultationModelStepTiming | undefined>;
|
||||
openIndex: number | null;
|
||||
openStartedAt: number | null;
|
||||
nextIndex: number;
|
||||
step1StartedAt: number | null;
|
||||
answerReasoningMs: number | null;
|
||||
};
|
||||
|
||||
export type UsageTimingView = {
|
||||
classificationDurationMs: number | null;
|
||||
modelSteps: ConsultationModelStepTiming[];
|
||||
answerReasoningMs: number | null;
|
||||
};
|
||||
|
||||
type UsageRecord = Record<string, unknown>;
|
||||
|
||||
export function createConsultationLatencyRecord(): ConsultationLatencyRecord {
|
||||
return {
|
||||
steps: [],
|
||||
openIndex: null,
|
||||
openStartedAt: null,
|
||||
nextIndex: 0,
|
||||
step1StartedAt: null,
|
||||
answerReasoningMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
function tokenOrNull(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null;
|
||||
const truncated = Math.trunc(value);
|
||||
return truncated > 1_000_000_000 ? null : truncated;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): UsageRecord | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as UsageRecord
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Read the provider's cached-input count. Absent means null, including when only a sibling field exists. */
|
||||
export function cachedInputTokensFromUsage(usage: UsageRecord): number | null {
|
||||
if ("cachedInputTokens" in usage) return tokenOrNull(usage.cachedInputTokens);
|
||||
const details = asRecord(usage.inputTokenDetails);
|
||||
if (details && "cacheReadTokens" in details) return tokenOrNull(details.cacheReadTokens);
|
||||
const promptDetails = asRecord(usage.prompt_tokens_details);
|
||||
if (promptDetails && "cached_tokens" in promptDetails) return tokenOrNull(promptDetails.cached_tokens);
|
||||
return null;
|
||||
}
|
||||
|
||||
function usageFromChunk(chunk: { payload?: unknown }): UsageRecord {
|
||||
const payload = asRecord(chunk.payload);
|
||||
const output = asRecord(payload?.output);
|
||||
return asRecord(output?.usage) ?? asRecord(payload?.usage) ?? {};
|
||||
}
|
||||
|
||||
function emptyStep(index: 0 | 1): ConsultationModelStepTiming {
|
||||
return {
|
||||
index,
|
||||
durationMs: null,
|
||||
reasoningTokens: null,
|
||||
outputTokens: null,
|
||||
inputTokens: null,
|
||||
cachedInputTokens: null,
|
||||
};
|
||||
}
|
||||
|
||||
function closeOpen(record: ConsultationLatencyRecord, now: number, usage: UsageRecord): void {
|
||||
const index = record.openIndex;
|
||||
const startedAt = record.openStartedAt;
|
||||
record.openIndex = null;
|
||||
record.openStartedAt = null;
|
||||
if (index === null) return;
|
||||
record.nextIndex = index + 1;
|
||||
if (index !== 0 && index !== 1) return;
|
||||
const durationMs = startedAt === null ? null : Math.max(0, Math.trunc(now - startedAt));
|
||||
record.steps[index] = {
|
||||
index,
|
||||
durationMs: durationMs !== null && durationMs > 7 * 24 * 60 * 60 * 1000 ? null : durationMs,
|
||||
reasoningTokens: tokenOrNull(usage.reasoningTokens),
|
||||
outputTokens: tokenOrNull(usage.outputTokens),
|
||||
inputTokens: tokenOrNull(usage.inputTokens),
|
||||
cachedInputTokens: cachedInputTokensFromUsage(usage),
|
||||
};
|
||||
}
|
||||
|
||||
/** Fold one model-loop chunk into step 0 / step 1. Text payloads are ignored. */
|
||||
export function observeConsultationModelChunk(
|
||||
record: ConsultationLatencyRecord,
|
||||
chunk: { type?: string; payload?: unknown },
|
||||
now: number,
|
||||
): void {
|
||||
if (chunk.type === "step-start") {
|
||||
if (record.openIndex !== null) closeOpen(record, now, {});
|
||||
const index = record.nextIndex;
|
||||
record.openIndex = index;
|
||||
record.openStartedAt = now;
|
||||
if (index === 1) record.step1StartedAt = now;
|
||||
return;
|
||||
}
|
||||
if (chunk.type === "step-finish") {
|
||||
if (record.openIndex === null) {
|
||||
record.openIndex = record.nextIndex;
|
||||
record.openStartedAt = null;
|
||||
}
|
||||
closeOpen(record, now, usageFromChunk(chunk));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First answer-body character of step 1. Step 0 narration does not count,
|
||||
* and a later continuation does not move the clock backward or forward.
|
||||
*/
|
||||
export function noteAnswerBodyCharacter(record: ConsultationLatencyRecord, now: number): void {
|
||||
if (record.answerReasoningMs !== null) return;
|
||||
if (record.openIndex !== 1 || record.step1StartedAt === null) return;
|
||||
record.answerReasoningMs = Math.max(0, Math.trunc(now - record.step1StartedAt));
|
||||
}
|
||||
|
||||
export function consultationModelSteps(record: ConsultationLatencyRecord): ConsultationModelStepTiming[] {
|
||||
return [emptyStep(0), emptyStep(1)].map((blank, index) => record.steps[index] ?? blank);
|
||||
}
|
||||
|
||||
export function consultationUsageMetadata(input: {
|
||||
modelSteps: readonly ConsultationModelStepTiming[] | null | undefined;
|
||||
answerReasoningMs: number | null | undefined;
|
||||
}): { modelSteps: ConsultationModelStepTiming[]; answer: { reasoning_ms: number | null } } {
|
||||
const view = consultationUsageTiming({
|
||||
classificationDurationMs: null,
|
||||
modelSteps: input.modelSteps,
|
||||
answerReasoningMs: input.answerReasoningMs,
|
||||
});
|
||||
return {
|
||||
modelSteps: view.modelSteps,
|
||||
answer: { reasoning_ms: view.answerReasoningMs },
|
||||
};
|
||||
}
|
||||
|
||||
export function consultationUsageTiming(input: {
|
||||
classificationDurationMs: number | null;
|
||||
modelSteps: readonly ConsultationModelStepTiming[] | null | undefined;
|
||||
answerReasoningMs: number | null | undefined;
|
||||
}): UsageTimingView {
|
||||
const steps = input.modelSteps ?? [];
|
||||
return {
|
||||
classificationDurationMs: input.classificationDurationMs,
|
||||
modelSteps: [0, 1].map((index) => {
|
||||
const found = steps.find((step) => step.index === index);
|
||||
return found ?? emptyStep(index as 0 | 1);
|
||||
}),
|
||||
answerReasoningMs: input.answerReasoningMs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function integerOrNull(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return tokenOrNull(value);
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? tokenOrNull(parsed) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Keep only the numeric timing fields stored on usage_ledger.metadata. */
|
||||
function parsedJson(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function usageTimingFromLedger(
|
||||
modelSteps: unknown,
|
||||
classificationDurationMs: unknown,
|
||||
answerReasoningMs: unknown,
|
||||
): UsageTimingView {
|
||||
const decoded = parsedJson(modelSteps);
|
||||
const steps = Array.isArray(decoded) ? decoded : [];
|
||||
const parsed = steps.map((item) => {
|
||||
const record = asRecord(item);
|
||||
if (!record || (record.index !== 0 && record.index !== 1)) return null;
|
||||
return {
|
||||
index: record.index as 0 | 1,
|
||||
durationMs: integerOrNull(record.durationMs),
|
||||
reasoningTokens: integerOrNull(record.reasoningTokens),
|
||||
outputTokens: integerOrNull(record.outputTokens),
|
||||
inputTokens: integerOrNull(record.inputTokens),
|
||||
cachedInputTokens: integerOrNull(record.cachedInputTokens),
|
||||
};
|
||||
}).filter((step): step is ConsultationModelStepTiming => step !== null);
|
||||
return consultationUsageTiming({
|
||||
classificationDurationMs: integerOrNull(classificationDurationMs),
|
||||
modelSteps: parsed,
|
||||
answerReasoningMs: integerOrNull(answerReasoningMs),
|
||||
});
|
||||
}
|
||||
|
||||
function ms(value: number | null): string {
|
||||
return value === null ? "—" : `${value} ms`;
|
||||
}
|
||||
|
||||
function tok(value: number | null): string {
|
||||
return value === null ? "—" : value.toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
/** One admin-table cell. Numbers only, so an old row with no timing still reads as dashes. */
|
||||
export function formatUsageTiming(view: UsageTimingView | null | undefined): string {
|
||||
if (!view) return "—";
|
||||
const steps = view.modelSteps.length > 0 ? view.modelSteps : [emptyStep(0), emptyStep(1)];
|
||||
const lines = [
|
||||
`分类 ${ms(view.classificationDurationMs)}`,
|
||||
...steps.map((step) => (
|
||||
`第 ${step.index} 步 ${ms(step.durationMs)} · 推理 ${tok(step.reasoningTokens)} · 出 ${tok(step.outputTokens)} · 入 ${tok(step.inputTokens)} · 缓存入 ${tok(step.cachedInputTokens)}`
|
||||
)),
|
||||
`写到正文 ${ms(view.answerReasoningMs)}`,
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -13,6 +13,13 @@ import {
|
||||
type ConsultationAgentPublicEvent,
|
||||
} from "./consultation-agent-events.ts";
|
||||
import { toAgentModelFinishReason, type AgentModelFinishReason } from "./agent-observability.ts";
|
||||
import {
|
||||
createConsultationLatencyRecord,
|
||||
noteAnswerBodyCharacter,
|
||||
observeConsultationModelChunk,
|
||||
consultationModelSteps,
|
||||
type ConsultationLatencyRecord,
|
||||
} from "./consultation-step-timing.ts";
|
||||
import { createVisibleTextTransformer } from "./stream-text-response.ts";
|
||||
import { consultationWriteLabel } from "./consultation-activity-labels.ts";
|
||||
import { logTruncatedReasoning } from "./consultation-budget.ts";
|
||||
@@ -482,6 +489,12 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
// The calculation result the model saw, kept so a length continuation
|
||||
// writes with the same evidence (BUG-1053). Never sent to the client.
|
||||
let calculationEvidence: unknown;
|
||||
const latencyRecord: ConsultationLatencyRecord = createConsultationLatencyRecord();
|
||||
const publishLatency = () => {
|
||||
if (!options.state) return;
|
||||
options.state.modelStepTimings = consultationModelSteps(latencyRecord);
|
||||
options.state.answerReasoningMs = latencyRecord.answerReasoningMs;
|
||||
};
|
||||
// The one-shot evidence lookup's result, if the model used it; carried into
|
||||
// a length continuation with the card (TASK-consult-evidence-card-20260927).
|
||||
let lookupEvidence: unknown;
|
||||
@@ -751,6 +764,16 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
const stepCountBeforeAttempt = options.state.modelStepCount;
|
||||
try {
|
||||
for await (const chunk of readChunks(stream)) {
|
||||
if (chunk.type === "step-start" || chunk.type === "step-finish") {
|
||||
observeConsultationModelChunk(latencyRecord, chunk, Date.now());
|
||||
}
|
||||
if (
|
||||
chunk.type === "text-delta"
|
||||
&& typeof chunk.payload?.text === "string"
|
||||
&& /\S/.test(chunk.payload.text)
|
||||
) {
|
||||
noteAnswerBodyCharacter(latencyRecord, Date.now());
|
||||
}
|
||||
if (isSkillBindingTripwire(chunk)) {
|
||||
recordSkillBindingAbort(options);
|
||||
throw new Error(SKILL_BINDING_FAILED);
|
||||
@@ -863,6 +886,8 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
lastAttempt = outcome;
|
||||
if (outcome.contributed || outcome.answerPhase) answerTail = outcome;
|
||||
throw error;
|
||||
} finally {
|
||||
publishLatency();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,17 @@ export type ConsultationRuntimeState = {
|
||||
// strict and would reject them, so they never enter it.
|
||||
modelStepCount: number;
|
||||
modelFinishReason?: AgentModelFinishReason;
|
||||
/** Step 0 and step 1 clocks. Observability only; absent until the stream records them. */
|
||||
modelStepTimings?: ReadonlyArray<{
|
||||
index: 0 | 1;
|
||||
durationMs: number | null;
|
||||
reasoningTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
inputTokens: number | null;
|
||||
cachedInputTokens: number | null;
|
||||
}>;
|
||||
/** Milliseconds from step 1 start to the first answer-body character. Null when that character never arrived. */
|
||||
answerReasoningMs?: number | null;
|
||||
// How the stream that wrote the answer ended (compose, continuation or answer
|
||||
// retry; the main loop when a path has no compose; the last stream when no
|
||||
// answer was written). "missing" means the stream closed without a finish
|
||||
@@ -351,6 +362,8 @@ export function consultationModelStepTelemetry(state: ConsultationRuntimeState)
|
||||
...(state.composeFinishReason === undefined ? {} : { composeFinishReason: state.composeFinishReason }),
|
||||
...(state.composeAborted === undefined ? {} : { composeAborted: state.composeAborted }),
|
||||
...(state.answerVisibleChars === undefined ? {} : { answerVisibleChars: state.answerVisibleChars }),
|
||||
...(state.modelStepTimings === undefined ? {} : { modelSteps: state.modelStepTimings }),
|
||||
...(state.answerReasoningMs === undefined ? {} : { answer: { reasoning_ms: state.answerReasoningMs } }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user