Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/chrome/src/agent/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AGENT_TOOLS, AGENT_TOOL_NAMES, RESERVED_AGENT_TOOL_NAMES, getToolsForMo
import { validateToolArguments } from './tool-arguments.js';
import { isSessionQuotaError, serializeConversationForSession, SESSION_CONVERSATION_BUDGET_BYTES, SESSION_CONVERSATION_RETRY_BUDGET_BYTES } from './conversation-persistence.js';
import { formatErrorMessage } from '../error-format.js';
import { aggregateMessageCompletion } from '../message-info.js';
import { handleDoneJson } from './cloud-output.js';
import { applyReadPageWindow, fitReadPageWindowResult, isReadPageWindowResult } from './read-page-window.js';
import { STANDARD_TOOL_RESULT_CHARS, createReadCompletenessState, isCommunicationThreadContext, normalizeReadScope, readCompletenessBlock, readCompletenessLimitation, readCompletenessMadeProgress, readWindowLimits, recordReadCompleteness, requirePlannerReadCompleteness, requiresCompleteThreadRead } from './read-completeness.js';
Expand Down Expand Up @@ -1997,6 +1998,8 @@ export class Agent extends LoopDetector {
let reasoningContent = '';
let usage = null;
let responseItems = null;
let finishReason = '';
let terminalRaw = null;
let sawCompleted = false;
let usageRecorded = false;
const toolCalls = new Map();
Expand Down Expand Up @@ -2064,6 +2067,14 @@ export class Agent extends LoopDetector {
} else if (chunk?.type === 'done') {
if (Array.isArray(chunk.responseItems)) responseItems = chunk.responseItems;
if (chunk.usage) usage = chunk.usage;
finishReason = String(
chunk.finishReason
?? chunk.finish_reason
?? chunk.stopReason
?? chunk.stop_reason
?? '',
);
if (chunk.raw) terminalRaw = chunk.raw;
sawCompleted = true;
break;
}
Expand All @@ -2090,6 +2101,8 @@ export class Agent extends LoopDetector {
toolCalls: toolCalls.size ? [...toolCalls.entries()].sort(([a], [b]) => a - b).map(([, call]) => call) : null,
usage,
responseItems,
finishReason,
...(terminalRaw ? { raw: terminalRaw } : {}),
};
const after = await recordUsage();
if (after) result.costAllowanceMessage = after;
Expand Down Expand Up @@ -25059,6 +25072,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d

let runId = null;
let finalResponse = '';
let messageCompletion = null;
let _traceStatus = 'done'; // updated on early exits
let askStreamingTraceWrite = Promise.resolve();
let shouldOrderInteractiveAskTrace = false;
Expand Down Expand Up @@ -25242,7 +25256,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
);
};

const chatMainTurn = async (chatMessages, chatOptions, requestContext) => {
const chatMainTurnRaw = async (chatMessages, chatOptions, requestContext) => {
const decision = this._interactiveAskStreamingDecision(
provider,
mode,
Expand Down Expand Up @@ -25337,6 +25351,18 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
}
};

const chatMainTurn = async (chatMessages, chatOptions, requestContext) => {
const startedAt = Date.now();
const result = await chatMainTurnRaw(chatMessages, chatOptions, requestContext);
messageCompletion = aggregateMessageCompletion(
messageCompletion,
result,
Date.now() - startedAt,
);
onUpdate('message_info', messageCompletion);
return result;
};

if (!runId) {
runId = await this._startTraceRun(
tabId, userMessage, mode, provider, null, runOptions,
Expand Down
120 changes: 120 additions & 0 deletions src/chrome/src/message-info.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
function formatSentTime(createdAt, locale) {
const value = Number(createdAt);
if (!Number.isFinite(value) || value <= 0) return '';
try {
return new Intl.DateTimeFormat(locale || undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'UTC',
timeZoneName: 'short',
}).format(new Date(value));
} catch {
return new Date(value).toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
}
}

function firstPositiveInteger(...values) {
for (const value of values) {
const number = Number(value);
if (Number.isFinite(number) && number > 0) return Math.floor(number);
}
return 0;
}

function finishReason(result) {
const raw = result?.raw || {};
return String(
result?.finishReason
?? raw?.choices?.[0]?.finish_reason
?? raw?.stop_reason
?? raw?.stopReason
?? '',
).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 80);
}

function formatNumber(value, locale, maximumFractionDigits = 0) {
try {
return new Intl.NumberFormat(locale || undefined, {
maximumFractionDigits,
}).format(value);
} catch {
return Number(value).toFixed(maximumFractionDigits).replace(/\.0+$/, '');
}
}

export function aggregateMessageCompletion(current, result, durationMs) {
const previous = current || {};
const usage = result?.usage || {};
const reportedFinishReason = finishReason(result);
const inputTokens = firstPositiveInteger(
usage.prompt_tokens,
usage.input_tokens,
usage.promptTokens,
usage.inputTokens,
);
const outputTokens = firstPositiveInteger(
usage.completion_tokens,
usage.output_tokens,
usage.completionTokens,
usage.outputTokens,
);
const reportedTotal = firstPositiveInteger(usage.total_tokens, usage.totalTokens);
const elapsed = Number(durationMs);
return {
inputTokens: firstPositiveInteger(previous.inputTokens) + inputTokens,
outputTokens: firstPositiveInteger(previous.outputTokens) + outputTokens,
totalTokens: firstPositiveInteger(previous.totalTokens) + (reportedTotal || inputTokens + outputTokens),
durationMs: firstPositiveInteger(previous.durationMs) + (Number.isFinite(elapsed) && elapsed > 0 ? Math.round(elapsed) : 0),
finishReason: reportedFinishReason || (Object.hasOwn(result || {}, 'finishReason')
? ''
: String(previous.finishReason || '').slice(0, 80)),
};
}

export function buildMessageInfoPills({ createdAt, completion = {}, verbose = false, locale } = {}) {
const time = formatSentTime(createdAt, locale);
if (!time) return [];
const pills = [{
kind: 'sent',
key: 'sp.message_info.sent',
params: { time },
}];
if (!verbose) return pills;
const outputTokens = firstPositiveInteger(completion.outputTokens);
const displayedTokens = outputTokens || firstPositiveInteger(completion.totalTokens);
const durationMs = firstPositiveInteger(completion.durationMs);
if (outputTokens && durationMs) {
pills.push({
kind: 'speed',
key: 'sp.message_info.speed',
params: { rate: formatNumber((outputTokens * 1000) / durationMs, locale, 2) },
});
}
if (displayedTokens) {
pills.push({
kind: 'tokens',
key: 'sp.message_info.tokens',
params: { count: formatNumber(displayedTokens, locale) },
});
}
if (durationMs) {
pills.push({
kind: 'duration',
key: 'sp.message_info.duration',
params: { seconds: formatNumber(durationMs / 1000, locale, 2) },
});
}
const reason = String(completion.finishReason || '').trim().slice(0, 80);
if (reason) {
pills.push({
kind: 'finish',
key: 'sp.message_info.finish',
params: { reason },
});
}
return pills;
}
8 changes: 7 additions & 1 deletion src/chrome/src/providers/anthropic.js
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ export class AnthropicProvider extends BaseLLMProvider {
const decoder = new TextDecoder();
let buffer = '';
let sawUsage = false;
let stopReason = '';
const accumulatedUsage = {};
const updateUsage = (usage) => {
if (!usage || typeof usage !== 'object') return;
Expand Down Expand Up @@ -390,6 +391,7 @@ export class AnthropicProvider extends BaseLLMProvider {
updateUsage(event.message?.usage);
} else if (event.type === 'message_delta') {
updateUsage(event.usage);
if (event.delta?.stop_reason != null) stopReason = String(event.delta.stop_reason);
} else if (event.type === 'content_block_delta') {
if (event.delta?.type === 'text_delta') {
yield { type: 'text', content: event.delta.text };
Expand All @@ -409,7 +411,11 @@ export class AnthropicProvider extends BaseLLMProvider {
} else if (event.type === 'message_stop') {
const usage = usageChunk();
if (usage) yield { type: 'usage', usage };
yield { type: 'done', content: '' };
yield {
type: 'done',
content: '',
...(stopReason ? { finishReason: stopReason } : {}),
};
return;
}
}
Expand Down
7 changes: 6 additions & 1 deletion src/chrome/src/providers/aws-bedrock.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,12 @@ export class AwsBedrockProvider extends BaseLLMProvider {
if (res.content) yield { type: 'text', content: res.content };
if (res.toolCalls) yield { type: 'tool_call', content: res.toolCalls };
if (res.usage) yield { type: 'usage', usage: res.usage };
yield { type: 'done', content: '' };
yield {
type: 'done',
content: '',
...(res.raw?.stopReason ? { finishReason: res.raw.stopReason } : {}),
...(res.raw ? { raw: res.raw } : {}),
};
}
}

Expand Down
8 changes: 7 additions & 1 deletion src/chrome/src/providers/azure-openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export class AzureOpenAIProvider extends BaseLLMProvider {
const decoder = new TextDecoder();
let buffer = '';
let finalUsage = null;
let terminalFinishReason = '';
while (true) {
let chunk;
try {
Expand All @@ -179,7 +180,11 @@ export class AzureOpenAIProvider extends BaseLLMProvider {
const payload = trimmed.slice(6);
if (payload === '[DONE]') {
if (finalUsage) yield { type: 'usage', usage: finalUsage };
yield { type: 'done', content: '' };
yield {
type: 'done',
content: '',
...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}),
};
return;
}
let json;
Expand All @@ -205,6 +210,7 @@ export class AzureOpenAIProvider extends BaseLLMProvider {
`${this.name} stream was blocked by the Azure content filter.`,
);
}
if (choice?.finish_reason != null) terminalFinishReason = String(choice.finish_reason);
const delta = choice?.delta;
const reasoningDelta = delta?.reasoning_content || delta?.reasoning;
if (typeof reasoningDelta === 'string' && reasoningDelta) {
Expand Down
10 changes: 8 additions & 2 deletions src/chrome/src/providers/llamacpp.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export class LlamaCppProvider extends BaseLLMProvider {
const decoder = new TextDecoder();
let buffer = '';
let finalUsage = null;
let terminalFinishReason = '';

while (true) {
let chunk;
Expand All @@ -148,7 +149,11 @@ export class LlamaCppProvider extends BaseLLMProvider {
const payload = trimmed.slice(6);
if (payload === '[DONE]') {
if (finalUsage) yield { type: 'usage', usage: finalUsage };
yield { type: 'done', content: '' };
yield {
type: 'done',
content: '',
...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}),
};
return;
}
let json;
Expand All @@ -175,6 +180,7 @@ export class LlamaCppProvider extends BaseLLMProvider {
if (choice?.finish_reason === 'content_filter') {
throw this._askStreamTerminalError('llama.cpp stream was blocked by the provider content filter.');
}
if (choice?.finish_reason != null) terminalFinishReason = String(choice.finish_reason);
const delta = choice?.delta;
const reasoningDelta = delta?.reasoning_content || delta?.reasoning;
if (typeof reasoningDelta === 'string' && reasoningDelta) {
Expand All @@ -192,6 +198,6 @@ export class LlamaCppProvider extends BaseLLMProvider {
if (this._supportsInteractiveAskStreaming()) {
throw this._askStreamTransportError('llama.cpp stream ended before the [DONE] sentinel.');
}
yield { type: 'done', content: '' };
yield { type: 'done', content: '', ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}) };
}
}
27 changes: 22 additions & 5 deletions src/chrome/src/providers/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ export class OpenAICompatibleProvider extends BaseLLMProvider {
get model() {
if (this.config.model) return this.config.model;
if (this.config.requiresModel) throw new Error(`${this.config.label || this.name} model is required.`);
// Local servers (Ollama, LM Studio, vLLM, …) must never receive a model
// id the user never configured: most 404 on unknown ids and none serves
// a model named after OpenAI's default. Omit the field entirely so the
// server applies its own default. Mirrors LlamaCppProvider.
if (this.config.category === 'local') return null;
return String(this.config.providerName || '').toLowerCase() === 'openai'
&& this._isOfficialOpenAIBaseUrl()
? 'gpt-5.6-terra'
Expand Down Expand Up @@ -376,10 +381,10 @@ export class OpenAICompatibleProvider extends BaseLLMProvider {
*/
_buildChatCompletionsBody(messages, options = {}, stream = false) {
let body = {
model: this.model,
messages: this._chatMessages(messages, options),
stream,
};
if (this.model) body.model = this.model;
this._addTemperature(body, options);
this._addMaxTokens(body, options);
if (this._shouldSendTools(messages, options)) {
Expand Down Expand Up @@ -499,7 +504,6 @@ export class OpenAICompatibleProvider extends BaseLLMProvider {

_responsesBody(messages, options, stream) {
let body = {
model: this.model,
input: this._responsesInput(messages),
stream,
store: false,
Expand All @@ -514,6 +518,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider {
if (body.reasoning.effort === 'auto' || body.reasoning.effort === 'off') {
body.reasoning.effort = body.reasoning.effort === 'off' ? 'none' : 'medium';
}
if (this.model) body.model = this.model;

if (this._shouldSendTools(messages, options)) {
body.tools = this._responsesTools(options.tools);
Expand Down Expand Up @@ -838,7 +843,13 @@ export class OpenAICompatibleProvider extends BaseLLMProvider {
if (response.usage) {
yield { type: 'usage', usage: this._normalizeResponsesUsage(response.usage) };
}
yield { type: 'done', content: '', responseItems: response.output || [] };
const finishReason = response.finish_reason ?? response.stop_reason;
yield {
type: 'done',
content: '',
responseItems: response.output || [],
...(finishReason != null ? { finishReason: String(finishReason) } : {}),
};
return;
} else if (event.type === 'response.incomplete') {
// Incomplete is terminal (token limit / filter / etc.). Surface it
Expand Down Expand Up @@ -949,6 +960,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider {
let buffer = '';
let finalUsage = null;
let sawTerminalFinish = false;
let terminalFinishReason = '';

while (true) {
let chunk;
Expand All @@ -973,7 +985,11 @@ export class OpenAICompatibleProvider extends BaseLLMProvider {
const payload = trimmed.slice(6);
if (payload === '[DONE]') {
if (finalUsage) yield { type: 'usage', usage: finalUsage };
yield { type: 'done', content: '' };
yield {
type: 'done',
content: '',
...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}),
};
return;
}
let json;
Expand Down Expand Up @@ -1012,6 +1028,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider {
}
if (finishReason != null) {
sawTerminalFinish = true;
terminalFinishReason = String(finishReason);
}
const delta = choice?.delta;
const reasoningDelta = delta?.reasoning_content || delta?.reasoning;
Expand All @@ -1028,7 +1045,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider {
}
if (finalUsage) yield { type: 'usage', usage: finalUsage };
if (sawTerminalFinish) {
yield { type: 'done', content: '' };
yield { type: 'done', content: '', finishReason: terminalFinishReason };
return;
}
if (this._supportsInteractiveAskStreaming()) {
Expand Down
Loading
Loading