From 784005ce4c1c2e3d5e6fc3cb0023daeaa48198f1 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Sat, 29 Aug 2026 05:56:09 +0800 Subject: [PATCH 1/4] Show per-turn model and token usage Rebase onto current master and persist provider-reported model and token usage with each retained turn. Translate the new UI across all supported locales, preserve metadata across duplicate completion events, recompute foreground summaries, avoid unsupported Array.prototype.at, and retain empty content deltas without emitting metadata-only updates. --- src/_locales/de/main.json | 11 + src/_locales/en/main.json | 11 + src/_locales/es/main.json | 11 + src/_locales/fr/main.json | 11 + src/_locales/id/main.json | 11 + src/_locales/it/main.json | 11 + src/_locales/ja/main.json | 11 + src/_locales/ko/main.json | 11 + src/_locales/pt/main.json | 11 + src/_locales/ru/main.json | 11 + src/_locales/tr/main.json | 11 + src/_locales/zh-hans/main.json | 11 + src/_locales/zh-hant/main.json | 11 + src/components/ConversationCard/index.jsx | 41 ++- src/components/ConversationCard/session.mjs | 27 +- src/components/ConversationItem/index.jsx | 55 +++- .../ConversationUsageSummary/index.jsx | 95 ++++++ src/services/apis/claude-api.mjs | 5 +- src/services/apis/openai-compatible-core.mjs | 67 +++- src/services/apis/shared.mjs | 16 +- src/utils/usage-metadata.mjs | 214 ++++++++++++ .../conversation-card-metadata.test.mjs | 74 +++++ tests/unit/locales/usage-labels.test.mjs | 49 +++ tests/unit/services/apis/custom-api.test.mjs | 17 +- .../unit/services/apis/usage-records.test.mjs | 77 +++++ .../services/apis/usage-streaming.test.mjs | 311 ++++++++++++++++++ tests/unit/utils/usage-metadata.test.mjs | 185 +++++++++++ 27 files changed, 1352 insertions(+), 24 deletions(-) create mode 100644 src/components/ConversationUsageSummary/index.jsx create mode 100644 src/utils/usage-metadata.mjs create mode 100644 tests/unit/components/conversation-card-metadata.test.mjs create mode 100644 tests/unit/locales/usage-labels.test.mjs create mode 100644 tests/unit/services/apis/usage-records.test.mjs create mode 100644 tests/unit/services/apis/usage-streaming.test.mjs create mode 100644 tests/unit/utils/usage-metadata.test.mjs diff --git a/src/_locales/de/main.json b/src/_locales/de/main.json index 8b0d204bc..6d86fb0ab 100644 --- a/src/_locales/de/main.json +++ b/src/_locales/de/main.json @@ -82,6 +82,17 @@ "Model Name": "Modellname", "Custom Model API Url": "Benutzerdefinierte Modell-API-URL", "Loading...": "Laden...", + "Input tokens": "Eingabe-Tokens", + "Output tokens": "Ausgabe-Tokens", + "Total tokens": "Tokens insgesamt", + "Cached input tokens": "Zwischengespeicherte Eingabe-Tokens", + "Cache write tokens": "In den Cache geschriebene Tokens", + "Selected model": "Ausgewähltes Modell", + "Reported model": "Gemeldetes Modell", + "Model": "Modell", + "Models": "Modelle", + "Turns": "Runden", + "Reported usage": "Gemeldete Nutzung", "Feedback": "Feedback", "Confirm": "Bestätigen", "Clear Conversation": "Konversation löschen", diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json index e558fc898..f8421ef92 100644 --- a/src/_locales/en/main.json +++ b/src/_locales/en/main.json @@ -89,6 +89,17 @@ "Model Name": "Model Name", "Custom Model API Url": "Custom Model API Url", "Loading...": "Loading...", + "Input tokens": "Input tokens", + "Output tokens": "Output tokens", + "Total tokens": "Total tokens", + "Cached input tokens": "Cached input tokens", + "Cache write tokens": "Cache write tokens", + "Selected model": "Selected model", + "Reported model": "Reported model", + "Model": "Model", + "Models": "Models", + "Turns": "Turns", + "Reported usage": "Reported usage", "Feedback": "Feedback", "Confirm": "Confirm", "Clear Conversation": "Clear Conversation", diff --git a/src/_locales/es/main.json b/src/_locales/es/main.json index c9f5b5a11..68ffadab7 100644 --- a/src/_locales/es/main.json +++ b/src/_locales/es/main.json @@ -80,6 +80,17 @@ "Model Name": "Nombre del modelo", "Custom Model API Url": "URL de la API de modelo personalizada", "Loading...": "Cargando...", + "Input tokens": "Tokens de entrada", + "Output tokens": "Tokens de salida", + "Total tokens": "Tokens totales", + "Cached input tokens": "Tokens de entrada en caché", + "Cache write tokens": "Tokens escritos en caché", + "Selected model": "Modelo seleccionado", + "Reported model": "Modelo informado", + "Model": "Modelo", + "Models": "Modelos", + "Turns": "Turnos", + "Reported usage": "Uso informado", "Feedback": "Comentarios", "Confirm": "Confirmar", "Clear Conversation": "Borrar conversación", diff --git a/src/_locales/fr/main.json b/src/_locales/fr/main.json index 1fa86e8a3..0b52d1a43 100644 --- a/src/_locales/fr/main.json +++ b/src/_locales/fr/main.json @@ -80,6 +80,17 @@ "Model Name": "Nom du modèle", "Custom Model API Url": "URL API personnalisée du modèle", "Loading...": "Chargement...", + "Input tokens": "Jetons d’entrée", + "Output tokens": "Jetons de sortie", + "Total tokens": "Total des jetons", + "Cached input tokens": "Jetons d’entrée mis en cache", + "Cache write tokens": "Jetons écrits dans le cache", + "Selected model": "Modèle sélectionné", + "Reported model": "Modèle déclaré", + "Model": "Modèle", + "Models": "Modèles", + "Turns": "Échanges", + "Reported usage": "Utilisation déclarée", "Feedback": "Commentaires", "Confirm": "Confirmer", "Clear Conversation": "Effacer la conversation", diff --git a/src/_locales/id/main.json b/src/_locales/id/main.json index 5267098d2..c4c571112 100644 --- a/src/_locales/id/main.json +++ b/src/_locales/id/main.json @@ -80,6 +80,17 @@ "Model Name": "Nama Model", "Custom Model API Url": "URL API Model Kustom", "Loading...": "Sedang Memuat...", + "Input tokens": "Token masukan", + "Output tokens": "Token keluaran", + "Total tokens": "Total token", + "Cached input tokens": "Token masukan dari cache", + "Cache write tokens": "Token yang ditulis ke cache", + "Selected model": "Model yang dipilih", + "Reported model": "Model yang dilaporkan", + "Model": "Model", + "Models": "Model", + "Turns": "Putaran", + "Reported usage": "Penggunaan yang dilaporkan", "Feedback": "Masukan", "Confirm": "Konfirmasi", "Clear Conversation": "Bersihkan Percakapan", diff --git a/src/_locales/it/main.json b/src/_locales/it/main.json index 4a592ff01..c6426dbc1 100644 --- a/src/_locales/it/main.json +++ b/src/_locales/it/main.json @@ -80,6 +80,17 @@ "Model Name": "Nome del modello", "Custom Model API Url": "URL API del modello personalizzato", "Loading...": "Caricamento...", + "Input tokens": "Token di input", + "Output tokens": "Token di output", + "Total tokens": "Token totali", + "Cached input tokens": "Token di input memorizzati nella cache", + "Cache write tokens": "Token scritti nella cache", + "Selected model": "Modello selezionato", + "Reported model": "Modello segnalato", + "Model": "Modello", + "Models": "Modelli", + "Turns": "Turni", + "Reported usage": "Utilizzo segnalato", "Feedback": "Feedback", "Confirm": "Conferma", "Clear Conversation": "Pulisci la conversazione", diff --git a/src/_locales/ja/main.json b/src/_locales/ja/main.json index 91c960949..8c9b67a2f 100644 --- a/src/_locales/ja/main.json +++ b/src/_locales/ja/main.json @@ -80,6 +80,17 @@ "Model Name": "モデル名", "Custom Model API Url": "カスタムモデルのAPI URL", "Loading...": "読み込み中...", + "Input tokens": "入力トークン", + "Output tokens": "出力トークン", + "Total tokens": "合計トークン", + "Cached input tokens": "キャッシュ済み入力トークン", + "Cache write tokens": "キャッシュ書き込みトークン", + "Selected model": "選択したモデル", + "Reported model": "報告されたモデル", + "Model": "モデル", + "Models": "モデル", + "Turns": "ターン数", + "Reported usage": "報告された使用量", "Feedback": "フィードバック", "Confirm": "確認", "Clear Conversation": "会話をクリア", diff --git a/src/_locales/ko/main.json b/src/_locales/ko/main.json index 252ed15f8..3f9b04a16 100644 --- a/src/_locales/ko/main.json +++ b/src/_locales/ko/main.json @@ -80,6 +80,17 @@ "Model Name": "모델 이름", "Custom Model API Url": "사용자 정의 모델 API URL", "Loading...": "로딩 중...", + "Input tokens": "입력 토큰", + "Output tokens": "출력 토큰", + "Total tokens": "총 토큰", + "Cached input tokens": "캐시된 입력 토큰", + "Cache write tokens": "캐시 쓰기 토큰", + "Selected model": "선택한 모델", + "Reported model": "보고된 모델", + "Model": "모델", + "Models": "모델", + "Turns": "턴 수", + "Reported usage": "보고된 사용량", "Feedback": "피드백", "Confirm": "확인", "Clear Conversation": "대화 내용 지우기", diff --git a/src/_locales/pt/main.json b/src/_locales/pt/main.json index b3379c159..803104582 100644 --- a/src/_locales/pt/main.json +++ b/src/_locales/pt/main.json @@ -80,6 +80,17 @@ "Model Name": "Nome do Modelo", "Custom Model API Url": "URL da API do Modelo Personalizado", "Loading...": "Carregando...", + "Input tokens": "Tokens de entrada", + "Output tokens": "Tokens de saída", + "Total tokens": "Total de tokens", + "Cached input tokens": "Tokens de entrada em cache", + "Cache write tokens": "Tokens gravados no cache", + "Selected model": "Modelo selecionado", + "Reported model": "Modelo informado", + "Model": "Modelo", + "Models": "Modelos", + "Turns": "Turnos", + "Reported usage": "Uso informado", "Feedback": "Feedback", "Confirm": "Confirmar", "Clear Conversation": "Limpar Conversa", diff --git a/src/_locales/ru/main.json b/src/_locales/ru/main.json index d857f3f62..8d6c56892 100644 --- a/src/_locales/ru/main.json +++ b/src/_locales/ru/main.json @@ -88,6 +88,17 @@ "Model Name": "Название модели", "Custom Model API Url": "Custom Model API Url", "Loading...": "Загрузка...", + "Input tokens": "Входные токены", + "Output tokens": "Выходные токены", + "Total tokens": "Всего токенов", + "Cached input tokens": "Кэшированные входные токены", + "Cache write tokens": "Токены, записанные в кэш", + "Selected model": "Выбранная модель", + "Reported model": "Модель, указанная провайдером", + "Model": "Модель", + "Models": "Модели", + "Turns": "Ходы", + "Reported usage": "Данные об использовании", "Feedback": "Обратная связь", "Confirm": "Подтверждение", "Clear Conversation": "Очистить беседу", diff --git a/src/_locales/tr/main.json b/src/_locales/tr/main.json index 1b2391cf4..01edc3048 100644 --- a/src/_locales/tr/main.json +++ b/src/_locales/tr/main.json @@ -80,6 +80,17 @@ "Model Name": "Model Adı", "Custom Model API Url": "Özel Model API Url'si", "Loading...": "Yükleniyor...", + "Input tokens": "Girdi tokenleri", + "Output tokens": "Çıktı tokenleri", + "Total tokens": "Toplam token", + "Cached input tokens": "Önbelleğe alınmış girdi tokenleri", + "Cache write tokens": "Önbelleğe yazılan tokenler", + "Selected model": "Seçilen model", + "Reported model": "Bildirilen model", + "Model": "Model", + "Models": "Modeller", + "Turns": "Turlar", + "Reported usage": "Bildirilen kullanım", "Feedback": "Geri Bildirim", "Confirm": "Onayla", "Clear Conversation": "Konuşmayı Temizle", diff --git a/src/_locales/zh-hans/main.json b/src/_locales/zh-hans/main.json index cfad6d2d1..4025b3fd4 100644 --- a/src/_locales/zh-hans/main.json +++ b/src/_locales/zh-hans/main.json @@ -83,6 +83,17 @@ "Model Name": "模型名", "Custom Model API Url": "自定义模型的API地址", "Loading...": "正在读取...", + "Input tokens": "输入 token", + "Output tokens": "输出 token", + "Total tokens": "token 总计", + "Cached input tokens": "缓存命中 token", + "Cache write tokens": "写入缓存 token", + "Selected model": "选用模型", + "Reported model": "上游回报模型", + "Model": "模型", + "Models": "模型", + "Turns": "轮次", + "Reported usage": "已回报用量", "Feedback": "反馈", "Confirm": "确认", "Clear Conversation": "清理对话", diff --git a/src/_locales/zh-hant/main.json b/src/_locales/zh-hant/main.json index 3dd20a229..572371ab3 100644 --- a/src/_locales/zh-hant/main.json +++ b/src/_locales/zh-hant/main.json @@ -83,6 +83,17 @@ "Model Name": "模型名稱", "Custom Model API Url": "自訂模型 API 網址", "Loading...": "載入中...", + "Input tokens": "輸入 token", + "Output tokens": "輸出 token", + "Total tokens": "token 合計", + "Cached input tokens": "快取命中 token", + "Cache write tokens": "寫入快取 token", + "Selected model": "選用模型", + "Reported model": "上游回報模型", + "Model": "模型", + "Models": "模型", + "Turns": "輪次", + "Reported usage": "已回報用量", "Feedback": "意見回饋", "Confirm": "確認", "Clear Conversation": "清除對話", diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index 6835faa6a..8bd80465e 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types' import Browser from 'webextension-polyfill' import InputBox from '../InputBox' import ConversationItem from '../ConversationItem' +import ConversationUsageSummary from '../ConversationUsageSummary' import { apiModeToModelName, createElementAtPosition, @@ -45,6 +46,7 @@ import { createConversationPortMessage, createRetrySession, finalizeInterruptedSession, + getCompletedAnswerMetadata, getCompletedAnswerUpdate, getInterruptedCompletionState, isSupersededGenerationMessage, @@ -59,12 +61,14 @@ class ConversationItemData extends Object { * @param {'question'|'answer'|'error'} type * @param {string} content * @param {bool} done + * @param {Object|null} meta */ - constructor(type, content, done = false) { + constructor(type, content, done = false, meta = null) { super() this.type = type this.content = content this.done = done + this.meta = meta } } @@ -81,6 +85,7 @@ function ConversationCard(props) { const retryRecordRef = useRef(null) const retryGenerationIdRef = useRef(0) const requestGenerationIdRef = useRef(0) + const requestModelRef = useRef('') const [completeDraggable, setCompleteDraggable] = useState(false) const useForegroundFetch = isUsingBingWebModel(session) const [apiModes, setApiModes] = useState([]) @@ -121,7 +126,7 @@ function ConversationCard(props) { const ret = [] for (const record of session.conversationRecords) { ret.push(new ConversationItemData('question', record.question, true)) - ret.push(new ConversationItemData('answer', record.answer, true)) + ret.push(new ConversationItemData('answer', record.answer, true, record.meta)) } setConversationItemData(ret) } @@ -173,8 +178,9 @@ function ConversationCard(props) { * @param {boolean} appended * @param {'question'|'answer'|'error'} newType * @param {boolean} done + * @param {Object|null|undefined} meta */ - const updateAnswer = (value, appended, newType, done = false) => { + const updateAnswer = (value, appended, newType, done = false, meta = undefined) => { setConversationItemData((old) => { const copy = [...old] const index = findLastIndex(copy, (v) => v.type === 'answer' || v.type === 'error') @@ -182,8 +188,9 @@ function ConversationCard(props) { copy[index] = new ConversationItemData( newType, appended ? copy[index].content + value : value, + done, + meta === undefined ? copy[index].meta : meta, ) - copy[index].done = done return copy }) } @@ -211,7 +218,16 @@ function ConversationCard(props) { partialAnswerRef.current = '' retryRecordRef.current = null const answerUpdate = getCompletedAnswerUpdate(completionState.restoredRetryAnswer) - updateAnswer(answerUpdate.value, answerUpdate.appended, 'answer', true) + const answerMetadata = getCompletedAnswerMetadata({ + message: msg, + restoredRetryAnswer: completionState.restoredRetryAnswer, + partialAnswer, + retryRecord, + requestedModel: requestModelRef.current, + fallbackModel: currentAiName, + }) + requestModelRef.current = '' + updateAnswer(answerUpdate.value, answerUpdate.appended, 'answer', true, answerMetadata) setIsReady(true) } if (msg.error) { @@ -274,6 +290,7 @@ function ConversationCard(props) { } partialAnswerRef.current = '' retryRecordRef.current = null + requestModelRef.current = '' setIsReady(true) } } @@ -287,6 +304,9 @@ function ConversationCard(props) { */ const postMessage = async ({ session, stop, stopGenerationId }) => { const requestGenerationId = session ? ++requestGenerationIdRef.current : undefined + if (session) { + requestModelRef.current = getConversationAiName(session, t, customOpenAIProviders) + } if (useForegroundFetch) { foregroundMessageListeners.current.forEach((listener) => listener({ session, stop, stopGenerationId, requestGenerationId }), @@ -386,7 +406,13 @@ function ConversationCard(props) { }, [port, conversationItemData]) const getRetryFn = (session) => async () => { - updateAnswer(`

${t('Waiting for response...')}

`, false, 'answer') + updateAnswer( + `

${t('Waiting for response...')}

`, + false, + 'answer', + false, + null, + ) setIsReady(false) const conversationRecords = session.conversationRecords.map((record) => ({ ...record })) @@ -574,6 +600,7 @@ function ConversationCard(props) { } partialAnswerRef.current = '' retryRecordRef.current = null + requestModelRef.current = '' Browser.runtime.sendMessage({ type: 'DELETE_CONVERSATION', data: { @@ -649,6 +676,7 @@ function ConversationCard(props) {
+
))} diff --git a/src/components/ConversationCard/session.mjs b/src/components/ConversationCard/session.mjs index 40c60f166..991ff5fc6 100644 --- a/src/components/ConversationCard/session.mjs +++ b/src/components/ConversationCard/session.mjs @@ -1,9 +1,34 @@ import { pushRecord } from '../../services/apis/shared.mjs' +export function getLastConversationRecord(records) { + if (!Array.isArray(records) || records.length === 0) return null + return records[records.length - 1] +} + +export function getCompletedAnswerMetadata({ + message, + restoredRetryAnswer, + partialAnswer, + retryRecord, + requestedModel, + fallbackModel, +}) { + if (restoredRetryAnswer !== null) return retryRecord?.meta || null + + const responseMetadata = getLastConversationRecord( + message.session?.conversationRecords, + )?.meta + if (responseMetadata) return responseMetadata + + const selectedModel = requestedModel || fallbackModel + if (partialAnswer && selectedModel) return { selectedModel } + return undefined +} + export function finalizeInterruptedSession(session, answer, retryRecord = null) { if (!answer) { if (!session.isRetry && !retryRecord) return session - const lastRecord = session.conversationRecords.at(-1) + const lastRecord = getLastConversationRecord(session.conversationRecords) const shouldRestoreRetryRecord = retryRecord && (lastRecord?.question !== retryRecord.question || lastRecord?.answer !== retryRecord.answer) diff --git a/src/components/ConversationItem/index.jsx b/src/components/ConversationItem/index.jsx index d9e9e9f35..fdbc43462 100644 --- a/src/components/ConversationItem/index.jsx +++ b/src/components/ConversationItem/index.jsx @@ -5,18 +5,64 @@ import ReadButton from '../ReadButton' import PropTypes from 'prop-types' import MarkdownRender from '../MarkdownRender/markdown.jsx' import { useTranslation } from 'react-i18next' +import { formatTokenCount, getRecordModel } from '../../utils/usage-metadata.mjs' -function AnswerTitle({ descName }) { +function getUsageText(meta, t) { + const usage = meta?.usage + if (!usage) return '' + + const parts = [] + if (usage.inputTokens !== undefined) + parts.push(`${t('Input tokens')}: ${formatTokenCount(usage.inputTokens)}`) + if (usage.outputTokens !== undefined) + parts.push(`${t('Output tokens')}: ${formatTokenCount(usage.outputTokens)}`) + if (usage.cacheReadInputTokens !== undefined) + parts.push(`${t('Cached input tokens')}: ${formatTokenCount(usage.cacheReadInputTokens)}`) + if (usage.cacheWriteInputTokens !== undefined) + parts.push(`${t('Cache write tokens')}: ${formatTokenCount(usage.cacheWriteInputTokens)}`) + if (parts.length === 0 && usage.totalTokens !== undefined) { + parts.push(`${t('Total tokens')}: ${formatTokenCount(usage.totalTokens)}`) + } + return parts.join(' · ') +} + +function AnswerTitle({ descName, meta }) { const { t } = useTranslation() + const model = getRecordModel(meta, descName) + const usageText = getUsageText(meta, t) + const selectedModel = meta?.selectedModel + const reportedModel = meta?.reportedModel + const modelTitle = + selectedModel && reportedModel && selectedModel !== reportedModel + ? `${t('Selected model')}: ${selectedModel}\n${t('Reported model')}: ${reportedModel}` + : model - return

{descName ? `${descName}:` : t('Loading...')}

+ return ( +
+

+ {model ? `${model}:` : t('Loading...')} +

+ {usageText && ( +

+ {usageText} +

+ )} +
+ ) } AnswerTitle.propTypes = { descName: PropTypes.string, + meta: PropTypes.object, } -export function ConversationItem({ type, content, descName, onRetry }) { +export function ConversationItem({ type, content, descName, meta, onRetry }) { const { t } = useTranslation() const [collapsed, setCollapsed] = useState(false) @@ -55,7 +101,7 @@ export function ConversationItem({ type, content, descName, onRetry }) { return (
- +
{onRetry && ( @@ -129,6 +175,7 @@ ConversationItem.propTypes = { type: PropTypes.oneOf(['question', 'answer', 'error']).isRequired, content: PropTypes.string.isRequired, descName: PropTypes.string, + meta: PropTypes.object, onRetry: PropTypes.func, } diff --git a/src/components/ConversationUsageSummary/index.jsx b/src/components/ConversationUsageSummary/index.jsx new file mode 100644 index 000000000..7468e7981 --- /dev/null +++ b/src/components/ConversationUsageSummary/index.jsx @@ -0,0 +1,95 @@ +import PropTypes from 'prop-types' +import { useTranslation } from 'react-i18next' +import { + formatTokenCount, + summarizeConversationUsage, +} from '../../utils/usage-metadata.mjs' + +function getMetricText(label, value, reportedTurns, totalReportedTurns, t) { + if (reportedTurns === 0) return null + const coverage = + reportedTurns === totalReportedTurns + ? '' + : ` (${t('Turns')}: ${reportedTurns}/${totalReportedTurns})` + return `${label}: ${formatTokenCount(value)}${coverage}` +} + +function ConversationUsageSummary({ records }) { + const { t } = useTranslation() + const summary = summarizeConversationUsage(records) + if (summary.models.length === 0 && summary.reportedTurns === 0) return null + + const modelText = + summary.models.length === 1 + ? `${t('Model')}: ${summary.models[0].name}` + : `${t('Models')}: ${summary.models.length}` + const modelTitle = summary.models + .map(({ name, turns }) => `${name} · ${t('Turns')}: ${turns}`) + .join('\n') + const usageParts = [] + if (summary.reportedTurns > 0) { + usageParts.push(`${t('Reported usage')}: ${summary.reportedTurns}/${summary.totalTurns}`) + usageParts.push( + getMetricText( + t('Input tokens'), + summary.inputTokens, + summary.inputReportedTurns, + summary.reportedTurns, + t, + ), + getMetricText( + t('Output tokens'), + summary.outputTokens, + summary.outputReportedTurns, + summary.reportedTurns, + t, + ), + getMetricText( + t('Total tokens'), + summary.totalTokens, + summary.totalReportedTurns, + summary.reportedTurns, + t, + ), + getMetricText( + t('Cached input tokens'), + summary.cacheReadInputTokens, + summary.cacheReadReportedTurns, + summary.reportedTurns, + t, + ), + getMetricText( + t('Cache write tokens'), + summary.cacheWriteInputTokens, + summary.cacheWriteReportedTurns, + summary.reportedTurns, + t, + ), + ) + } + + return ( +
+ {modelText} + {usageParts.filter(Boolean).map((part) => ( + {part} + ))} +
+ ) +} + +ConversationUsageSummary.propTypes = { + records: PropTypes.array, +} + +export default ConversationUsageSummary diff --git a/src/services/apis/claude-api.mjs b/src/services/apis/claude-api.mjs index d589fc894..4a0fc723d 100644 --- a/src/services/apis/claude-api.mjs +++ b/src/services/apis/claude-api.mjs @@ -5,6 +5,7 @@ import { isEmpty } from 'lodash-es' import { getConversationPairs } from '../../utils/get-conversation-pairs.mjs' import { getModelValue } from '../../utils/model-name-convert.mjs' import { getTemperatureParams } from './temperature-params.mjs' +import { mergeClaudeResponseMetadata } from '../../utils/usage-metadata.mjs' function shouldDisableDefaultThinking(model) { return model === 'claude-sonnet-5' @@ -39,6 +40,7 @@ export async function generateAnswersWithClaudeApi(port, question, session) { } let answer = '' + let responseMetadata = null let stopReason = '' let completionError let wasAborted = false @@ -70,6 +72,7 @@ export async function generateAnswersWithClaudeApi(port, question, session) { throw error } if (completedSuccessfully) return + responseMetadata = mergeClaudeResponseMetadata(responseMetadata, data, model) if (data?.type === 'message_delta') { stopReason = data?.delta?.stop_reason || stopReason return @@ -95,7 +98,7 @@ export async function generateAnswersWithClaudeApi(port, question, session) { controller.abort() throw completionError } - pushRecord(session, question, answer) + pushRecord(session, question, answer, responseMetadata) console.debug('conversation history', { content: session.conversationRecords }) port.postMessage({ answer: null, done: true, session: session }) completedSuccessfully = true diff --git a/src/services/apis/openai-compatible-core.mjs b/src/services/apis/openai-compatible-core.mjs index 02dfe4a81..d45e6e73a 100644 --- a/src/services/apis/openai-compatible-core.mjs +++ b/src/services/apis/openai-compatible-core.mjs @@ -4,6 +4,7 @@ import { isEmpty } from 'lodash-es' import { getCompletionPromptBase, pushRecord, setAbortController } from './shared.mjs' import { getChatCompletionsTokenParams } from './openai-token-params.mjs' import { getTemperatureParams } from './temperature-params.mjs' +import { mergeOpenAIResponseMetadata } from '../../utils/usage-metadata.mjs' function buildHeaders(apiKey, extraHeaders = {}) { const headers = { @@ -28,10 +29,41 @@ function buildMessageAnswer(answer, data, allowLegacyResponseField) { return answer } +function hasMessageAnswerField(data, allowLegacyResponseField) { + if (allowLegacyResponseField && typeof data?.response === 'string') return true + + const choice = data?.choices?.[0] + return ( + typeof choice?.delta?.content === 'string' || + typeof choice?.message?.content === 'string' || + typeof choice?.text === 'string' + ) +} + function hasFinished(data) { return Boolean(data?.choices?.[0]?.finish_reason) } +function getRequestUrl(requestUrl) { + try { + return new URL(requestUrl) + } catch { + return null + } +} + +function isNativeOpenAIChatCompletionsRequest(requestUrl, endpointType) { + if (endpointType !== 'chat') return false + const url = getRequestUrl(requestUrl) + if (!url || url.hostname.toLowerCase() !== 'api.openai.com') return false + return url.pathname.replace(/\/+$/, '') === '/v1/chat/completions' +} + +function isOpenRouterRequest(requestUrl, provider) { + if (provider === 'openrouter') return true + return getRequestUrl(requestUrl)?.origin === 'https://openrouter.ai' +} + /** * @param {object} params * @param {Browser.Runtime.Port} params.port @@ -112,14 +144,27 @@ export async function generateAnswersWithOpenAICompatible({ ...getTemperatureParams(config, model), ...safeExtraBody, } + if (isNativeOpenAIChatCompletionsRequest(requestUrl, endpointType)) { + requestBody.stream_options = { + ...(requestBody.stream_options && typeof requestBody.stream_options === 'object' + ? requestBody.stream_options + : {}), + include_usage: true, + } + } } let answer = '' + let responseMetadata = null + let sawFinishReason = false let finished = false + const waitForFinalUsage = + isNativeOpenAIChatCompletionsRequest(requestUrl, endpointType) || + isOpenRouterRequest(requestUrl, provider) const finish = () => { if (finished) return finished = true - pushRecord(session, question, answer) + pushRecord(session, question, answer, responseMetadata) port.postMessage({ answer: null, done: true, session: session }) } @@ -142,10 +187,20 @@ export async function generateAnswersWithOpenAICompatible({ return } + responseMetadata = mergeOpenAIResponseMetadata(responseMetadata, data, model) + const previousAnswer = answer + const hasAnswerField = hasMessageAnswerField(data, allowLegacyResponseField) answer = buildMessageAnswer(answer, data, allowLegacyResponseField) - port.postMessage({ answer: answer, done: false, session: null }) + if (answer !== previousAnswer || hasAnswerField) { + port.postMessage({ answer: answer, done: false, session: null }) + } - if (hasFinished(data)) { + const chunkFinished = hasFinished(data) + const hasUsage = Boolean(data?.usage && typeof data.usage === 'object') + if (chunkFinished) sawFinishReason = true + if (chunkFinished && (!waitForFinalUsage || hasUsage)) { + finish() + } else if (sawFinishReason && waitForFinalUsage && hasUsage) { finish() } }, @@ -157,7 +212,7 @@ export async function generateAnswersWithOpenAICompatible({ const shouldPostSession = Boolean(answer) || session.isRetry if (shouldPostSession && isCurrentSessionRequest()) { if (answer) { - pushRecord(session, question, answer) + pushRecord(session, question, answer, responseMetadata) } session.isRetry = false try { @@ -182,6 +237,10 @@ export async function generateAnswersWithOpenAICompatible({ async onError(resp) { port.onMessage.removeListener(messageListener) port.onDisconnect.removeListener(disconnectListener) + if (sawFinishReason && waitForFinalUsage) { + finish() + return + } if (resp instanceof Error) throw resp const error = await resp.json().catch(() => ({})) throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`) diff --git a/src/services/apis/shared.mjs b/src/services/apis/shared.mjs index a425305f1..65cf373aa 100644 --- a/src/services/apis/shared.mjs +++ b/src/services/apis/shared.mjs @@ -1,3 +1,5 @@ +import { createRecordMetadata } from '../../utils/usage-metadata.mjs' + export const getChatSystemPromptBase = async () => { return `You are a helpful, creative, clever, and very friendly assistant. You are familiar with various languages in the world.` } @@ -75,11 +77,19 @@ export function setAbortController(port, onStop, onDisconnect) { } } -export function pushRecord(session, question, answer) { +export function pushRecord(session, question, answer, metadata = null) { + const recordMetadata = createRecordMetadata(session, metadata) const recordLength = session.conversationRecords.length let lastRecord if (recordLength > 0) lastRecord = session.conversationRecords[recordLength - 1] - if (session.isRetry && lastRecord && lastRecord.question === question) lastRecord.answer = answer - else session.conversationRecords.push({ question: question, answer: answer }) + if (session.isRetry && lastRecord && lastRecord.question === question) { + lastRecord.answer = answer + if (recordMetadata) lastRecord.meta = recordMetadata + else delete lastRecord.meta + } else { + const record = { question: question, answer: answer } + if (recordMetadata) record.meta = recordMetadata + session.conversationRecords.push(record) + } } diff --git a/src/utils/usage-metadata.mjs b/src/utils/usage-metadata.mjs new file mode 100644 index 000000000..417bffe5a --- /dev/null +++ b/src/utils/usage-metadata.mjs @@ -0,0 +1,214 @@ +const TOKEN_USAGE_KEYS = [ + 'inputTokens', + 'outputTokens', + 'totalTokens', + 'cacheReadInputTokens', + 'cacheWriteInputTokens', +] + +function toTokenCount(value) { + if (typeof value === 'string' && value.trim()) value = Number(value) + if (!Number.isFinite(value) || value < 0) return undefined + return Math.trunc(value) +} + +function firstTokenCount(...values) { + for (const value of values) { + const tokenCount = toTokenCount(value) + if (tokenCount !== undefined) return tokenCount + } + return undefined +} + +function toNonEmptyString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function compactUsage(usage) { + if (!usage || typeof usage !== 'object') return null + const compacted = {} + for (const key of TOKEN_USAGE_KEYS) { + const value = toTokenCount(usage[key]) + if (value !== undefined) compacted[key] = value + } + if ( + compacted.totalTokens === undefined && + compacted.inputTokens !== undefined && + compacted.outputTokens !== undefined + ) { + compacted.totalTokens = compacted.inputTokens + compacted.outputTokens + } + return Object.keys(compacted).length > 0 ? compacted : null +} + +export function mergeResponseMetadata(currentMetadata, nextMetadata) { + const current = currentMetadata && typeof currentMetadata === 'object' ? currentMetadata : {} + const next = nextMetadata && typeof nextMetadata === 'object' ? nextMetadata : {} + const merged = {} + + const selectedModel = + toNonEmptyString(next.selectedModel) || toNonEmptyString(current.selectedModel) + const reportedModel = + toNonEmptyString(next.reportedModel) || toNonEmptyString(current.reportedModel) + if (selectedModel) merged.selectedModel = selectedModel + if (reportedModel) merged.reportedModel = reportedModel + + const currentUsage = compactUsage(current.usage) + const nextUsage = compactUsage(next.usage) + const mergedUsage = { ...(currentUsage || {}), ...(nextUsage || {}) } + if ( + nextUsage && + nextUsage.totalTokens === undefined && + (nextUsage.inputTokens !== undefined || nextUsage.outputTokens !== undefined) + ) { + delete mergedUsage.totalTokens + } + const usage = compactUsage(mergedUsage) + if (usage) merged.usage = usage + + return Object.keys(merged).length > 0 ? merged : null +} + +export function mergeOpenAIResponseMetadata(currentMetadata, data, selectedModel) { + const usage = data?.usage + const promptDetails = usage?.prompt_tokens_details + const inputDetails = usage?.input_tokens_details + const normalizedUsage = usage + ? compactUsage({ + inputTokens: firstTokenCount(usage.prompt_tokens, usage.input_tokens), + outputTokens: firstTokenCount(usage.completion_tokens, usage.output_tokens), + totalTokens: firstTokenCount(usage.total_tokens), + cacheReadInputTokens: firstTokenCount( + promptDetails?.cached_tokens, + inputDetails?.cached_tokens, + usage.cache_read_input_tokens, + usage.cache_read_tokens, + usage.cached_tokens, + ), + cacheWriteInputTokens: firstTokenCount( + promptDetails?.cache_write_tokens, + inputDetails?.cache_write_tokens, + usage.cache_creation_input_tokens, + usage.cache_write_input_tokens, + usage.cache_write_tokens, + ), + }) + : null + + const hasResponseMetadata = toNonEmptyString(data?.model) || normalizedUsage + if (!hasResponseMetadata) return currentMetadata || null + return mergeResponseMetadata(currentMetadata, { + selectedModel, + reportedModel: data?.model, + usage: normalizedUsage, + }) +} + +export function mergeClaudeResponseMetadata(currentMetadata, data, selectedModel) { + const usage = data?.message?.usage || data?.usage + const uncachedInputTokens = firstTokenCount(usage?.input_tokens) + const cacheReadInputTokens = firstTokenCount(usage?.cache_read_input_tokens) + const cacheWriteInputTokens = firstTokenCount(usage?.cache_creation_input_tokens) + const inputParts = [uncachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens].filter( + (value) => value !== undefined, + ) + const currentInputTokens = toTokenCount(currentMetadata?.usage?.inputTokens) + const hasInputUsage = + Boolean(data?.message?.usage) || + (currentInputTokens === undefined && + (cacheReadInputTokens !== undefined || + cacheWriteInputTokens !== undefined || + (uncachedInputTokens !== undefined && uncachedInputTokens > 0))) + const normalizedUsage = usage + ? compactUsage({ + inputTokens: + hasInputUsage && inputParts.length > 0 + ? inputParts.reduce((total, value) => total + value, 0) + : undefined, + outputTokens: firstTokenCount(usage.output_tokens), + cacheReadInputTokens, + cacheWriteInputTokens, + }) + : null + + const reportedModel = data?.message?.model + const hasResponseMetadata = toNonEmptyString(reportedModel) || normalizedUsage + if (!hasResponseMetadata) return currentMetadata || null + return mergeResponseMetadata(currentMetadata, { + selectedModel, + reportedModel, + usage: normalizedUsage, + }) +} + +export function createRecordMetadata(session, metadata) { + return mergeResponseMetadata(metadata, { + selectedModel: metadata?.selectedModel || session?.aiName, + }) +} + +export function getRecordModel(metadata, fallbackModel = '') { + return ( + toNonEmptyString(metadata?.reportedModel) || + toNonEmptyString(metadata?.selectedModel) || + toNonEmptyString(fallbackModel) || + '' + ) +} + +export function formatTokenCount(value) { + const tokenCount = toTokenCount(value) + return tokenCount === undefined ? '' : tokenCount.toLocaleString() +} + +export function summarizeConversationUsage(records) { + const conversationRecords = Array.isArray(records) ? records : [] + const modelCounts = new Map() + const summary = { + totalTurns: conversationRecords.length, + reportedTurns: 0, + inputReportedTurns: 0, + outputReportedTurns: 0, + totalReportedTurns: 0, + cacheReadReportedTurns: 0, + cacheWriteReportedTurns: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + cacheReadInputTokens: 0, + cacheWriteInputTokens: 0, + models: [], + } + + for (const record of conversationRecords) { + const model = getRecordModel(record?.meta) + if (model) modelCounts.set(model, (modelCounts.get(model) || 0) + 1) + + const usage = compactUsage(record?.meta?.usage) + if (!usage) continue + summary.reportedTurns += 1 + if (usage.inputTokens !== undefined) { + summary.inputReportedTurns += 1 + summary.inputTokens += usage.inputTokens + } + if (usage.outputTokens !== undefined) { + summary.outputReportedTurns += 1 + summary.outputTokens += usage.outputTokens + } + if (usage.totalTokens !== undefined) { + summary.totalReportedTurns += 1 + summary.totalTokens += usage.totalTokens + } + if (usage.cacheReadInputTokens !== undefined) { + summary.cacheReadReportedTurns += 1 + summary.cacheReadInputTokens += usage.cacheReadInputTokens + } + if (usage.cacheWriteInputTokens !== undefined) { + summary.cacheWriteReportedTurns += 1 + summary.cacheWriteInputTokens += usage.cacheWriteInputTokens + } + } + + summary.models = Array.from(modelCounts, ([name, turns]) => ({ name, turns })) + return summary +} diff --git a/tests/unit/components/conversation-card-metadata.test.mjs b/tests/unit/components/conversation-card-metadata.test.mjs new file mode 100644 index 000000000..fc76e4b2c --- /dev/null +++ b/tests/unit/components/conversation-card-metadata.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + getCompletedAnswerMetadata, + getLastConversationRecord, +} from '../../../src/components/ConversationCard/session.mjs' + +test('getLastConversationRecord uses length-based indexing', () => { + const records = [{ question: 'Q1' }, { question: 'Q2' }] + + assert.equal(getLastConversationRecord(records), records[1]) + assert.equal(getLastConversationRecord([]), null) + assert.equal(getLastConversationRecord(null), null) +}) + +test('getCompletedAnswerMetadata reads retained response metadata', () => { + const meta = { selectedModel: 'model-a', usage: { inputTokens: 12 } } + + assert.equal( + getCompletedAnswerMetadata({ + message: { session: { conversationRecords: [{ meta }] } }, + restoredRetryAnswer: null, + partialAnswer: 'Answer', + retryRecord: null, + requestedModel: 'model-a', + fallbackModel: 'Model A', + }), + meta, + ) +}) + +test('getCompletedAnswerMetadata preserves metadata on duplicate done messages', () => { + assert.equal( + getCompletedAnswerMetadata({ + message: { done: true }, + restoredRetryAnswer: null, + partialAnswer: '', + retryRecord: null, + requestedModel: '', + fallbackModel: 'Model A', + }), + undefined, + ) +}) + +test('getCompletedAnswerMetadata records a model for retained partial answers', () => { + assert.deepEqual( + getCompletedAnswerMetadata({ + message: { done: true }, + restoredRetryAnswer: null, + partialAnswer: 'Partial', + retryRecord: null, + requestedModel: 'model-a', + fallbackModel: 'Model A', + }), + { selectedModel: 'model-a' }, + ) +}) + +test('getCompletedAnswerMetadata restores retry metadata', () => { + const meta = { selectedModel: 'old-model' } + + assert.equal( + getCompletedAnswerMetadata({ + message: { done: true }, + restoredRetryAnswer: 'Old answer', + partialAnswer: '', + retryRecord: { meta }, + requestedModel: 'new-model', + fallbackModel: 'New Model', + }), + meta, + ) +}) diff --git a/tests/unit/locales/usage-labels.test.mjs b/tests/unit/locales/usage-labels.test.mjs new file mode 100644 index 000000000..5beb9714f --- /dev/null +++ b/tests/unit/locales/usage-labels.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { test } from 'node:test' + +const locales = [ + 'de', + 'en', + 'es', + 'fr', + 'id', + 'it', + 'ja', + 'ko', + 'pt', + 'ru', + 'tr', + 'zh-hans', + 'zh-hant', +] + +const usageKeys = [ + 'Input tokens', + 'Output tokens', + 'Total tokens', + 'Cached input tokens', + 'Cache write tokens', + 'Selected model', + 'Reported model', + 'Model', + 'Models', + 'Turns', + 'Reported usage', +] + +test('all locales include the model and token usage labels', async () => { + for (const locale of locales) { + const localeUrl = new URL(`../../../src/_locales/${locale}/main.json`, import.meta.url) + const translations = JSON.parse(await readFile(localeUrl, 'utf8')) + + for (const key of usageKeys) { + assert.equal( + typeof translations[key], + 'string', + `${locale} is missing the ${JSON.stringify(key)} translation`, + ) + assert.notEqual(translations[key].trim(), '') + } + } +}) diff --git a/tests/unit/services/apis/custom-api.test.mjs b/tests/unit/services/apis/custom-api.test.mjs index 57a6f7fde..3a1eee491 100644 --- a/tests/unit/services/apis/custom-api.test.mjs +++ b/tests/unit/services/apis/custom-api.test.mjs @@ -359,11 +359,18 @@ test('handles metadata-only SSE chunk without choices or response fields', async 'model', ) - assert.equal( - port.postedMessages.some((m) => m.done === false && m.answer === 'Hi'), - true, - ) - assert.deepEqual(session.conversationRecords.at(-1), { question: 'Q', answer: 'Hi' }) + const streamingAnswers = port.postedMessages + .filter((message) => message.done === false) + .map((message) => message.answer) + assert.deepEqual(streamingAnswers, ['Hi']) + assert.deepEqual(session.conversationRecords.at(-1), { + question: 'Q', + answer: 'Hi', + meta: { + selectedModel: 'model', + reportedModel: 'gpt-4', + }, + }) }) test('throws on non-ok response with JSON error body', async (t) => { diff --git a/tests/unit/services/apis/usage-records.test.mjs b/tests/unit/services/apis/usage-records.test.mjs new file mode 100644 index 000000000..aea99e33a --- /dev/null +++ b/tests/unit/services/apis/usage-records.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { pushRecord } from '../../../../src/services/apis/shared.mjs' + +test('pushRecord stores response metadata and falls back to the session model', () => { + const session = { + aiName: 'OpenAI (GPT-5.6)', + isRetry: false, + conversationRecords: [], + } + + pushRecord(session, 'Q', 'A', { + reportedModel: 'gpt-5.6-2026-08-01', + usage: { inputTokens: 10, outputTokens: 2 }, + }) + + assert.deepEqual(session.conversationRecords, [ + { + question: 'Q', + answer: 'A', + meta: { + selectedModel: 'OpenAI (GPT-5.6)', + reportedModel: 'gpt-5.6-2026-08-01', + usage: { inputTokens: 10, outputTokens: 2, totalTokens: 12 }, + }, + }, + ]) +}) + +test('pushRecord replaces stale answer metadata when retrying', () => { + const session = { + isRetry: true, + conversationRecords: [ + { + question: 'Q', + answer: 'Old', + meta: { + selectedModel: 'old-model', + usage: { inputTokens: 10, outputTokens: 2, totalTokens: 12 }, + }, + }, + ], + } + + pushRecord(session, 'Q', 'New', { + selectedModel: 'new-model', + usage: { inputTokens: 20, outputTokens: 4 }, + }) + + assert.deepEqual(session.conversationRecords, [ + { + question: 'Q', + answer: 'New', + meta: { + selectedModel: 'new-model', + usage: { inputTokens: 20, outputTokens: 4, totalTokens: 24 }, + }, + }, + ]) +}) + +test('pushRecord removes stale metadata when a retry has no model or usage metadata', () => { + const session = { + isRetry: true, + conversationRecords: [ + { + question: 'Q', + answer: 'Old', + meta: { selectedModel: 'old-model' }, + }, + ], + } + + pushRecord(session, 'Q', 'New') + + assert.deepEqual(session.conversationRecords, [{ question: 'Q', answer: 'New' }]) +}) diff --git a/tests/unit/services/apis/usage-streaming.test.mjs b/tests/unit/services/apis/usage-streaming.test.mjs new file mode 100644 index 000000000..a09b4690a --- /dev/null +++ b/tests/unit/services/apis/usage-streaming.test.mjs @@ -0,0 +1,311 @@ +import assert from 'node:assert/strict' +import { beforeEach, test } from 'node:test' +import { generateAnswersWithOpenAICompatible } from '../../../../src/services/apis/openai-compatible-core.mjs' +import { generateAnswersWithClaudeApi } from '../../../../src/services/apis/claude-api.mjs' +import { createFakePort } from '../../helpers/port.mjs' +import { createMockSseResponse } from '../../helpers/sse-response.mjs' + +const baseConfig = { + maxConversationContextLength: 3, + maxResponseTokenLength: 256, +} + +function sseData(data) { + return `data: ${JSON.stringify(data)}\n\n` +} + +if (!globalThis.__TEST_BROWSER_SHIM__) { + globalThis.__TEST_BROWSER_SHIM__ = { + storage: {}, + clearStorage() { + this.storage = {} + }, + replaceStorage(values) { + this.storage = { ...values } + }, + } +} + +beforeEach(() => { + globalThis.__TEST_BROWSER_SHIM__.clearStorage() +}) + +test('native OpenAI waits for the trailing usage block and requests streamed usage', async (t) => { + t.mock.method(console, 'debug', () => {}) + const session = { + aiName: 'OpenAI (GPT-5.6)', + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + let capturedInit + t.mock.method(globalThis, 'fetch', async (_input, init) => { + capturedInit = init + return createMockSseResponse([ + 'data: {"model":"gpt-5.6-2026-08-01","choices":[{"delta":{"content":"Hello"}}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n', + sseData({ + choices: [], + usage: { + prompt_tokens: 100, + completion_tokens: 20, + total_tokens: 120, + prompt_tokens_details: { cached_tokens: 80 }, + }, + }), + ]) + }) + + await generateAnswersWithOpenAICompatible({ + port, + question: 'Hi', + session, + endpointType: 'chat', + requestUrl: 'https://api.openai.com/v1/chat/completions', + model: 'gpt-5.6', + apiKey: 'sk-test', + config: baseConfig, + provider: 'openai', + }) + + const body = JSON.parse(capturedInit.body) + assert.deepEqual(body.stream_options, { include_usage: true }) + assert.deepEqual(session.conversationRecords, [ + { + question: 'Hi', + answer: 'Hello', + meta: { + selectedModel: 'gpt-5.6', + reportedModel: 'gpt-5.6-2026-08-01', + usage: { + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + cacheReadInputTokens: 80, + }, + }, + }, + ]) + assert.equal( + port.postedMessages.filter((message) => message.done === false).length, + 1, + ) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +}) + +test('OpenRouter is recognized by URL and records the routed model and cache usage', async (t) => { + t.mock.method(console, 'debug', () => {}) + const session = { + aiName: 'OpenRouter Auto', + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + let capturedInit + t.mock.method(globalThis, 'fetch', async (_input, init) => { + capturedInit = init + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"Routed"},"finish_reason":"stop"}]}\n\n', + sseData({ + model: 'anthropic/claude-sonnet-5', + choices: [{ delta: {}, finish_reason: 'stop' }], + usage: { + prompt_tokens: 90, + completion_tokens: 10, + total_tokens: 100, + prompt_tokens_details: { cached_tokens: 60, cache_write_tokens: 15 }, + }, + }), + ]) + }) + + await generateAnswersWithOpenAICompatible({ + port, + question: 'Route this', + session, + endpointType: 'chat', + requestUrl: 'https://openrouter.ai/api/v1/chat/completions', + model: 'openrouter/auto', + apiKey: 'or-test', + config: baseConfig, + provider: 'compat', + }) + + assert.equal(Object.hasOwn(JSON.parse(capturedInit.body), 'stream_options'), false) + const lastRecord = session.conversationRecords[session.conversationRecords.length - 1] + assert.deepEqual(lastRecord.meta, { + selectedModel: 'openrouter/auto', + reportedModel: 'anthropic/claude-sonnet-5', + usage: { + inputTokens: 90, + outputTokens: 10, + totalTokens: 100, + cacheReadInputTokens: 60, + cacheWriteInputTokens: 15, + }, + }) +}) + +test('Anthropic streaming stores cumulative output and full cache-aware input usage', async (t) => { + t.mock.method(console, 'debug', () => {}) + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + customAnthropicApiUrl: 'https://api.anthropic.com', + anthropicApiKey: 'sk-ant-test', + maxConversationContextLength: 3, + maxResponseTokenLength: 256, + }) + const session = { + aiName: 'Anthropic (Claude Sonnet 5)', + modelName: 'claudeSonnet5Api', + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + t.mock.method(globalThis, 'fetch', async () => + createMockSseResponse([ + sseData({ + type: 'message_start', + message: { + model: 'claude-sonnet-5-20260801', + usage: { + input_tokens: 10, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + output_tokens: 1, + }, + }, + }), + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Claude"}}\n\n', + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":0,"output_tokens":6}}\n\n', + 'data: {"type":"message_stop"}\n\n', + ]), + ) + + await generateAnswersWithClaudeApi(port, 'Hello', session) + + assert.deepEqual(session.conversationRecords, [ + { + question: 'Hello', + answer: 'Claude', + meta: { + selectedModel: 'claude-sonnet-5', + reportedModel: 'claude-sonnet-5-20260801', + usage: { + inputTokens: 60, + outputTokens: 6, + totalTokens: 66, + cacheReadInputTokens: 20, + cacheWriteInputTokens: 30, + }, + }, + }, + ]) +}) + +test('OpenAI preserves a completed answer when the stream breaks before usage arrives', async (t) => { + t.mock.method(console, 'debug', () => {}) + const session = { + aiName: 'OpenAI (GPT-5.6)', + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + const encoder = new TextEncoder() + t.mock.method(globalThis, 'fetch', async () => { + let readCount = 0 + return { + ok: true, + status: 200, + statusText: 'OK', + body: { + getReader() { + return { + async read() { + readCount += 1 + if (readCount === 1) { + return { + done: false, + value: encoder.encode( + 'data: {"model":"gpt-5.6","choices":[{"delta":{"content":"Complete"}}]}\n\n', + ), + } + } + if (readCount === 2) { + return { + done: false, + value: encoder.encode( + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n', + ), + } + } + throw new Error('stream interrupted') + }, + } + }, + }, + } + }) + + await generateAnswersWithOpenAICompatible({ + port, + question: 'Hi', + session, + endpointType: 'chat', + requestUrl: 'https://api.openai.com/v1/chat/completions', + model: 'gpt-5.6', + apiKey: 'sk-test', + config: baseConfig, + provider: 'openai', + }) + + assert.deepEqual(session.conversationRecords, [ + { + question: 'Hi', + answer: 'Complete', + meta: { + selectedModel: 'gpt-5.6', + reportedModel: 'gpt-5.6', + }, + }, + ]) + const lastMessage = port.postedMessages[port.postedMessages.length - 1] + assert.equal(lastMessage.done, true) +}) + +test('a custom OpenAI-compatible endpoint is not forced to accept stream_options', async (t) => { + t.mock.method(console, 'debug', () => {}) + const session = { + aiName: 'Custom OpenAI endpoint', + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + let capturedInit + t.mock.method(globalThis, 'fetch', async (_input, init) => { + capturedInit = init + return createMockSseResponse([ + 'data: {"choices":[{"delta":{"content":"Compatible"},"finish_reason":"stop"}]}\n\n', + ]) + }) + + await generateAnswersWithOpenAICompatible({ + port, + question: 'Hi', + session, + endpointType: 'chat', + requestUrl: 'https://proxy.example/v1/chat/completions', + model: 'custom-model', + apiKey: 'sk-test', + config: baseConfig, + provider: 'openai', + }) + + assert.equal(Object.hasOwn(JSON.parse(capturedInit.body), 'stream_options'), false) + assert.deepEqual(session.conversationRecords, [ + { + question: 'Hi', + answer: 'Compatible', + meta: { selectedModel: 'Custom OpenAI endpoint' }, + }, + ]) +}) diff --git a/tests/unit/utils/usage-metadata.test.mjs b/tests/unit/utils/usage-metadata.test.mjs new file mode 100644 index 000000000..e3c6df7be --- /dev/null +++ b/tests/unit/utils/usage-metadata.test.mjs @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + createRecordMetadata, + mergeClaudeResponseMetadata, + mergeOpenAIResponseMetadata, + summarizeConversationUsage, +} from '../../../src/utils/usage-metadata.mjs' + +test('OpenAI metadata merges the reported model and final usage block', () => { + let metadata = mergeOpenAIResponseMetadata( + null, + { + model: 'gpt-5.6-2026-08-01', + choices: [{ delta: { content: 'Hello' } }], + }, + 'gpt-5.6', + ) + + metadata = mergeOpenAIResponseMetadata( + metadata, + { + choices: [], + usage: { + prompt_tokens: 120, + completion_tokens: 30, + total_tokens: 150, + prompt_tokens_details: { + cached_tokens: 80, + cache_write_tokens: 20, + }, + }, + }, + 'gpt-5.6', + ) + + assert.deepEqual(metadata, { + selectedModel: 'gpt-5.6', + reportedModel: 'gpt-5.6-2026-08-01', + usage: { + inputTokens: 120, + outputTokens: 30, + totalTokens: 150, + cacheReadInputTokens: 80, + cacheWriteInputTokens: 20, + }, + }) +}) + +test('OpenAI metadata distinguishes zero cached tokens from an unavailable field', () => { + const zeroCacheMetadata = mergeOpenAIResponseMetadata( + null, + { + usage: { + prompt_tokens: 10, + completion_tokens: 2, + prompt_tokens_details: { cached_tokens: 0 }, + }, + }, + 'gpt-test', + ) + const unavailableCacheMetadata = mergeOpenAIResponseMetadata( + null, + { + usage: { + prompt_tokens: 10, + completion_tokens: 2, + }, + }, + 'gpt-test', + ) + + assert.equal(zeroCacheMetadata.usage.cacheReadInputTokens, 0) + assert.equal(Object.hasOwn(unavailableCacheMetadata.usage, 'cacheReadInputTokens'), false) +}) + +test('Claude metadata treats cache reads and writes as parts of the full input', () => { + let metadata = mergeClaudeResponseMetadata( + null, + { + type: 'message_start', + message: { + model: 'claude-sonnet-5-20260801', + usage: { + input_tokens: 10, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + output_tokens: 1, + }, + }, + }, + 'claude-sonnet-5', + ) + + metadata = mergeClaudeResponseMetadata( + metadata, + { + type: 'message_delta', + usage: { + input_tokens: 0, + output_tokens: 5, + }, + }, + 'claude-sonnet-5', + ) + + assert.deepEqual(metadata, { + selectedModel: 'claude-sonnet-5', + reportedModel: 'claude-sonnet-5-20260801', + usage: { + inputTokens: 60, + outputTokens: 5, + totalTokens: 65, + cacheReadInputTokens: 20, + cacheWriteInputTokens: 30, + }, + }) +}) + +test('record metadata falls back to the session model label', () => { + assert.deepEqual(createRecordMetadata({ aiName: 'OpenAI (GPT-5.6)' }, null), { + selectedModel: 'OpenAI (GPT-5.6)', + }) + assert.equal(createRecordMetadata({}, null), null) +}) + +test('conversation summary totals only complete reported usage and preserves model history', () => { + const summary = summarizeConversationUsage([ + { + question: 'one', + answer: 'a', + meta: { + selectedModel: 'router/auto', + reportedModel: 'model-a', + usage: { + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + cacheReadInputTokens: 40, + }, + }, + }, + { + question: 'two', + answer: 'b', + meta: { + selectedModel: 'model-b', + usage: { + inputTokens: 50, + outputTokens: 10, + cacheReadInputTokens: 0, + cacheWriteInputTokens: 15, + }, + }, + }, + { + question: 'three', + answer: 'c', + meta: { + selectedModel: 'model-b', + usage: { outputTokens: 4 }, + }, + }, + { question: 'legacy', answer: 'record' }, + ]) + + assert.deepEqual(summary, { + totalTurns: 4, + reportedTurns: 3, + inputReportedTurns: 2, + outputReportedTurns: 3, + totalReportedTurns: 2, + cacheReadReportedTurns: 2, + cacheWriteReportedTurns: 1, + inputTokens: 150, + outputTokens: 34, + totalTokens: 180, + cacheReadInputTokens: 40, + cacheWriteInputTokens: 15, + models: [ + { name: 'model-a', turns: 1 }, + { name: 'model-b', turns: 2 }, + ], + }) +}) From 21eac48f99a9187f588592a5c9eed1f294e345c0 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Sat, 29 Aug 2026 06:08:33 +0800 Subject: [PATCH 2/4] Hide empty model count in usage summary --- src/components/ConversationUsageSummary/index.jsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/ConversationUsageSummary/index.jsx b/src/components/ConversationUsageSummary/index.jsx index 7468e7981..294ae3629 100644 --- a/src/components/ConversationUsageSummary/index.jsx +++ b/src/components/ConversationUsageSummary/index.jsx @@ -19,10 +19,12 @@ function ConversationUsageSummary({ records }) { const summary = summarizeConversationUsage(records) if (summary.models.length === 0 && summary.reportedTurns === 0) return null - const modelText = - summary.models.length === 1 - ? `${t('Model')}: ${summary.models[0].name}` - : `${t('Models')}: ${summary.models.length}` + const hasModels = summary.models.length > 0 + const modelText = !hasModels + ? '' + : summary.models.length === 1 + ? `${t('Model')}: ${summary.models[0].name}` + : `${t('Models')}: ${summary.models.length}` const modelTitle = summary.models .map(({ name, turns }) => `${name} · ${t('Turns')}: ${turns}`) .join('\n') @@ -80,7 +82,7 @@ function ConversationUsageSummary({ records }) { padding: '0 15px 8px', }} > - {modelText} + {hasModels && {modelText}} {usageParts.filter(Boolean).map((part) => ( {part} ))} From 5ef1195c2fe98ae0258082cadb6821fa5c13fdf9 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:23:09 +0800 Subject: [PATCH 3/4] Preserve metadata after stopped streams --- .../ConversationUsageSummary/index.jsx | 4 +- src/services/apis/openai-compatible-core.mjs | 1 + .../apis/usage-abort-metadata.test.mjs | 97 +++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tests/unit/services/apis/usage-abort-metadata.test.mjs diff --git a/src/components/ConversationUsageSummary/index.jsx b/src/components/ConversationUsageSummary/index.jsx index 294ae3629..978829a1d 100644 --- a/src/components/ConversationUsageSummary/index.jsx +++ b/src/components/ConversationUsageSummary/index.jsx @@ -83,8 +83,8 @@ function ConversationUsageSummary({ records }) { }} > {hasModels && {modelText}} - {usageParts.filter(Boolean).map((part) => ( - {part} + {usageParts.filter(Boolean).map((part, index) => ( + {part} ))}
) diff --git a/src/services/apis/openai-compatible-core.mjs b/src/services/apis/openai-compatible-core.mjs index d45e6e73a..a56a2da53 100644 --- a/src/services/apis/openai-compatible-core.mjs +++ b/src/services/apis/openai-compatible-core.mjs @@ -219,6 +219,7 @@ export async function generateAnswersWithOpenAICompatible({ const stoppedGenerationId = getStopGenerationId() port.postMessage({ session, + done: true, ...(stoppedGenerationId === undefined ? {} : { stoppedGenerationId }), }) } catch (e) { diff --git a/tests/unit/services/apis/usage-abort-metadata.test.mjs b/tests/unit/services/apis/usage-abort-metadata.test.mjs new file mode 100644 index 000000000..961c23eee --- /dev/null +++ b/tests/unit/services/apis/usage-abort-metadata.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { generateAnswersWithOpenAICompatible } from '../../../../src/services/apis/openai-compatible-core.mjs' +import { createFakePort } from '../../helpers/port.mjs' + +const baseConfig = { + maxConversationContextLength: 3, + maxResponseTokenLength: 256, +} + +function sseData(data) { + return `data: ${JSON.stringify(data)}\n\n` +} + +test('aborted OpenAI-compatible stream reposts retained metadata with terminal session', async (t) => { + t.mock.method(console, 'debug', () => {}) + const session = { + aiName: 'Custom provider', + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + const encoder = new TextEncoder() + + t.mock.method(globalThis, 'fetch', async () => { + let readCount = 0 + return { + ok: true, + status: 200, + statusText: 'OK', + body: { + getReader() { + return { + async read() { + readCount += 1 + if (readCount === 1) { + return { + done: false, + value: encoder.encode( + sseData({ + model: 'reported-model', + choices: [{ delta: { content: 'Partial' } }], + usage: { + prompt_tokens: 10, + completion_tokens: 2, + total_tokens: 12, + }, + }), + ), + } + } + + port.emitMessage({ stop: true }) + const error = new Error('aborted') + error.name = 'AbortError' + throw error + }, + } + }, + }, + } + }) + + await generateAnswersWithOpenAICompatible({ + port, + question: 'Hi', + session, + endpointType: 'chat', + requestUrl: 'https://proxy.example/v1/chat/completions', + model: 'selected-model', + apiKey: 'test-key', + config: baseConfig, + provider: 'compat', + }) + + assert.deepEqual(session.conversationRecords, [ + { + question: 'Hi', + answer: 'Partial', + meta: { + selectedModel: 'selected-model', + reportedModel: 'reported-model', + usage: { + inputTokens: 10, + outputTokens: 2, + totalTokens: 12, + }, + }, + }, + ]) + + const terminalMessages = port.postedMessages.filter((message) => message.done === true) + assert.equal(terminalMessages.length, 2) + assert.equal(terminalMessages[0].session, undefined) + assert.equal(terminalMessages[1].session, session) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +}) From 16bd4a0f28fffff842134f5a53eab38d53c4c334 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:32:49 +0800 Subject: [PATCH 4/4] Address metadata review feedback --- src/components/ConversationItem/index.jsx | 18 ++- .../ConversationUsageSummary/index.jsx | 67 ++++++--- src/services/apis/claude-api.mjs | 27 +++- src/services/apis/openai-compatible-core.mjs | 2 +- src/utils/usage-metadata.mjs | 2 +- .../apis/usage-abort-metadata.test.mjs | 127 +++++++++++++++++- tests/unit/utils/usage-metadata.test.mjs | 17 ++- 7 files changed, 220 insertions(+), 40 deletions(-) diff --git a/src/components/ConversationItem/index.jsx b/src/components/ConversationItem/index.jsx index fdbc43462..e44c57cd4 100644 --- a/src/components/ConversationItem/index.jsx +++ b/src/components/ConversationItem/index.jsx @@ -5,6 +5,7 @@ import ReadButton from '../ReadButton' import PropTypes from 'prop-types' import MarkdownRender from '../MarkdownRender/markdown.jsx' import { useTranslation } from 'react-i18next' +import { modelNameToDesc } from '../../utils/model-name-convert.mjs' import { formatTokenCount, getRecordModel } from '../../utils/usage-metadata.mjs' function getUsageText(meta, t) { @@ -28,14 +29,21 @@ function getUsageText(meta, t) { function AnswerTitle({ descName, meta }) { const { t } = useTranslation() - const model = getRecordModel(meta, descName) + const model = modelNameToDesc(getRecordModel(meta, descName), t) const usageText = getUsageText(meta, t) const selectedModel = meta?.selectedModel + ? modelNameToDesc(meta.selectedModel, t) + : '' const reportedModel = meta?.reportedModel - const modelTitle = - selectedModel && reportedModel && selectedModel !== reportedModel - ? `${t('Selected model')}: ${selectedModel}\n${t('Reported model')}: ${reportedModel}` - : model + ? modelNameToDesc(meta.reportedModel, t) + : '' + const hasDifferentReportedModel = + meta?.selectedModel && + meta?.reportedModel && + meta.selectedModel !== meta.reportedModel + const modelTitle = hasDifferentReportedModel + ? `${t('Selected model')}: ${selectedModel}\n${t('Reported model')}: ${reportedModel}` + : model return (
diff --git a/src/components/ConversationUsageSummary/index.jsx b/src/components/ConversationUsageSummary/index.jsx index 978829a1d..6785221a1 100644 --- a/src/components/ConversationUsageSummary/index.jsx +++ b/src/components/ConversationUsageSummary/index.jsx @@ -1,5 +1,6 @@ import PropTypes from 'prop-types' import { useTranslation } from 'react-i18next' +import { modelNameToDesc } from '../../utils/model-name-convert.mjs' import { formatTokenCount, summarizeConversationUsage, @@ -19,56 +20,83 @@ function ConversationUsageSummary({ records }) { const summary = summarizeConversationUsage(records) if (summary.models.length === 0 && summary.reportedTurns === 0) return null - const hasModels = summary.models.length > 0 + const models = summary.models.map(({ name, turns }) => ({ + name: modelNameToDesc(name, t), + turns, + })) + const hasModels = models.length > 0 + const modelReportedTurns = models.reduce((total, { turns }) => total + turns, 0) + const modelCoverage = + modelReportedTurns === summary.totalTurns + ? '' + : ` (${t('Turns')}: ${modelReportedTurns}/${summary.totalTurns})` const modelText = !hasModels ? '' - : summary.models.length === 1 - ? `${t('Model')}: ${summary.models[0].name}` - : `${t('Models')}: ${summary.models.length}` - const modelTitle = summary.models + : models.length === 1 + ? `${t('Model')}: ${models[0].name}${modelCoverage}` + : `${t('Models')}: ${models.length}${modelCoverage}` + const modelTitle = models .map(({ name, turns }) => `${name} · ${t('Turns')}: ${turns}`) .join('\n') - const usageParts = [] - if (summary.reportedTurns > 0) { - usageParts.push(`${t('Reported usage')}: ${summary.reportedTurns}/${summary.totalTurns}`) - usageParts.push( - getMetricText( + const usageParts = [ + { + key: 'reported-usage', + text: + summary.reportedTurns > 0 + ? `${t('Reported usage')}: ${summary.reportedTurns}/${summary.totalTurns}` + : null, + }, + { + key: 'input-tokens', + text: getMetricText( t('Input tokens'), summary.inputTokens, summary.inputReportedTurns, summary.reportedTurns, t, ), - getMetricText( + }, + { + key: 'output-tokens', + text: getMetricText( t('Output tokens'), summary.outputTokens, summary.outputReportedTurns, summary.reportedTurns, t, ), - getMetricText( + }, + { + key: 'total-tokens', + text: getMetricText( t('Total tokens'), summary.totalTokens, summary.totalReportedTurns, summary.reportedTurns, t, ), - getMetricText( + }, + { + key: 'cache-read-tokens', + text: getMetricText( t('Cached input tokens'), summary.cacheReadInputTokens, summary.cacheReadReportedTurns, summary.reportedTurns, t, ), - getMetricText( + }, + { + key: 'cache-write-tokens', + text: getMetricText( t('Cache write tokens'), summary.cacheWriteInputTokens, summary.cacheWriteReportedTurns, summary.reportedTurns, t, ), - ) - } + }, + ] return (
{hasModels && {modelText}} - {usageParts.filter(Boolean).map((part, index) => ( - {part} - ))} + {usageParts.map(({ key, text }) => { + if (!text) return null + return {text} + })}
) } diff --git a/src/services/apis/claude-api.mjs b/src/services/apis/claude-api.mjs index 4a0fc723d..f018782c6 100644 --- a/src/services/apis/claude-api.mjs +++ b/src/services/apis/claude-api.mjs @@ -17,7 +17,13 @@ function shouldDisableDefaultThinking(model) { * @param {Session} session */ export async function generateAnswersWithClaudeApi(port, question, session) { - const { controller, messageListener, disconnectListener } = setAbortController(port) + const { + controller, + messageListener, + disconnectListener, + getStopGenerationId, + isCurrentSessionRequest, + } = setAbortController(port) const config = await getUserConfig() const apiUrl = config.customAnthropicApiUrl const model = getModelValue(session) @@ -128,7 +134,24 @@ export async function generateAnswersWithClaudeApi(port, question, session) { throw new Error(!isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`) }, }).catch((error) => error) - if (wasAborted) return + if (wasAborted) { + const shouldPostSession = Boolean(answer) || session.isRetry + if (shouldPostSession && isCurrentSessionRequest()) { + if (answer) pushRecord(session, question, answer, responseMetadata) + session.isRetry = false + try { + const stoppedGenerationId = getStopGenerationId() + port.postMessage({ + session, + ...(answer && responseMetadata ? { done: true } : {}), + ...(stoppedGenerationId === undefined ? {} : { stoppedGenerationId }), + }) + } catch (error) { + console.warn('[claude-api] Failed to post session on abort:', error) + } + } + return + } if (completionError) throw completionError if ( streamError && diff --git a/src/services/apis/openai-compatible-core.mjs b/src/services/apis/openai-compatible-core.mjs index a56a2da53..df5c9c96a 100644 --- a/src/services/apis/openai-compatible-core.mjs +++ b/src/services/apis/openai-compatible-core.mjs @@ -219,7 +219,7 @@ export async function generateAnswersWithOpenAICompatible({ const stoppedGenerationId = getStopGenerationId() port.postMessage({ session, - done: true, + ...(answer && responseMetadata ? { done: true } : {}), ...(stoppedGenerationId === undefined ? {} : { stoppedGenerationId }), }) } catch (e) { diff --git a/src/utils/usage-metadata.mjs b/src/utils/usage-metadata.mjs index 417bffe5a..f3b0bd8a7 100644 --- a/src/utils/usage-metadata.mjs +++ b/src/utils/usage-metadata.mjs @@ -143,7 +143,7 @@ export function mergeClaudeResponseMetadata(currentMetadata, data, selectedModel export function createRecordMetadata(session, metadata) { return mergeResponseMetadata(metadata, { - selectedModel: metadata?.selectedModel || session?.aiName, + selectedModel: metadata?.selectedModel || session?.modelName, }) } diff --git a/tests/unit/services/apis/usage-abort-metadata.test.mjs b/tests/unit/services/apis/usage-abort-metadata.test.mjs index 961c23eee..c5b6e3952 100644 --- a/tests/unit/services/apis/usage-abort-metadata.test.mjs +++ b/tests/unit/services/apis/usage-abort-metadata.test.mjs @@ -1,6 +1,9 @@ import assert from 'node:assert/strict' -import { test } from 'node:test' -import { generateAnswersWithOpenAICompatible } from '../../../../src/services/apis/openai-compatible-core.mjs' +import { beforeEach, test } from 'node:test' +import { generateAnswersWithClaudeApi } from '../../../../src/services/apis/claude-api.mjs' +import { + generateAnswersWithOpenAICompatible, +} from '../../../../src/services/apis/openai-compatible-core.mjs' import { createFakePort } from '../../helpers/port.mjs' const baseConfig = { @@ -12,7 +15,35 @@ function sseData(data) { return `data: ${JSON.stringify(data)}\n\n` } -test('aborted OpenAI-compatible stream reposts retained metadata with terminal session', async (t) => { +function assertMetadataSessionFollowsStopAcknowledgement(port, session) { + const acknowledgementIndex = port.postedMessages.findIndex( + (message) => message.done === true && message.session === undefined, + ) + const sessionIndex = port.postedMessages.findIndex((message) => message.session === session) + + assert.notEqual(acknowledgementIndex, -1) + assert.notEqual(sessionIndex, -1) + assert.equal(port.postedMessages[sessionIndex].done, true) + assert.equal(acknowledgementIndex < sessionIndex, true) +} + +if (!globalThis.__TEST_BROWSER_SHIM__) { + globalThis.__TEST_BROWSER_SHIM__ = { + storage: {}, + clearStorage() { + this.storage = {} + }, + replaceStorage(values) { + this.storage = { ...values } + }, + } +} + +beforeEach(() => { + globalThis.__TEST_BROWSER_SHIM__.clearStorage() +}) + +test('OpenAI-compatible abort reposts retained metadata in a terminal session', async (t) => { t.mock.method(console, 'debug', () => {}) const session = { aiName: 'Custom provider', @@ -88,10 +119,92 @@ test('aborted OpenAI-compatible stream reposts retained metadata with terminal s }, }, ]) + assertMetadataSessionFollowsStopAcknowledgement(port, session) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +}) + +test('aborted Claude stream reposts retained metadata with terminal session', async (t) => { + t.mock.method(console, 'debug', () => {}) + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + customAnthropicApiUrl: 'https://api.anthropic.com', + anthropicApiKey: 'sk-ant-test', + maxConversationContextLength: 3, + maxResponseTokenLength: 256, + }) + const session = { + aiName: 'Anthropic (Claude Sonnet 5)', + modelName: 'claudeSonnet5Api', + conversationRecords: [], + isRetry: false, + } + const port = createFakePort() + const encoder = new TextEncoder() - const terminalMessages = port.postedMessages.filter((message) => message.done === true) - assert.equal(terminalMessages.length, 2) - assert.equal(terminalMessages[0].session, undefined) - assert.equal(terminalMessages[1].session, session) + t.mock.method(globalThis, 'fetch', async () => { + let readCount = 0 + return { + ok: true, + status: 200, + statusText: 'OK', + body: { + getReader() { + return { + async read() { + readCount += 1 + if (readCount === 1) { + return { + done: false, + value: encoder.encode( + sseData({ + type: 'message_start', + message: { + model: 'claude-sonnet-5-20260801', + usage: { + input_tokens: 10, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + output_tokens: 1, + }, + }, + }) + + sseData({ + type: 'content_block_delta', + delta: { type: 'text_delta', text: 'Partial' }, + }), + ), + } + } + + port.emitMessage({ stop: true }) + const error = new Error('aborted') + error.name = 'AbortError' + throw error + }, + } + }, + }, + } + }) + + await generateAnswersWithClaudeApi(port, 'Hello', session) + + assert.deepEqual(session.conversationRecords, [ + { + question: 'Hello', + answer: 'Partial', + meta: { + selectedModel: 'claude-sonnet-5', + reportedModel: 'claude-sonnet-5-20260801', + usage: { + inputTokens: 60, + outputTokens: 1, + totalTokens: 61, + cacheReadInputTokens: 20, + cacheWriteInputTokens: 30, + }, + }, + }, + ]) + assertMetadataSessionFollowsStopAcknowledgement(port, session) assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) }) diff --git a/tests/unit/utils/usage-metadata.test.mjs b/tests/unit/utils/usage-metadata.test.mjs index e3c6df7be..ab0703f9c 100644 --- a/tests/unit/utils/usage-metadata.test.mjs +++ b/tests/unit/utils/usage-metadata.test.mjs @@ -117,11 +117,18 @@ test('Claude metadata treats cache reads and writes as parts of the full input', }) }) -test('record metadata falls back to the session model label', () => { - assert.deepEqual(createRecordMetadata({ aiName: 'OpenAI (GPT-5.6)' }, null), { - selectedModel: 'OpenAI (GPT-5.6)', - }) - assert.equal(createRecordMetadata({}, null), null) +test('record metadata falls back to the stable session model identifier', () => { + assert.deepEqual( + createRecordMetadata( + { + modelName: 'chatgptApi5_6', + aiName: 'OpenAI (GPT-5.6)', + }, + null, + ), + { selectedModel: 'chatgptApi5_6' }, + ) + assert.equal(createRecordMetadata({ aiName: 'OpenAI (GPT-5.6)' }, null), null) }) test('conversation summary totals only complete reported usage and preserves model history', () => {