fix(voice): keep a stopped round's actions and follow-up memory

A reply stopped partway through a round now keeps the actions that ran,
answering the calls it never reached as skipped, so the history stays
paired for both providers. A failed typed turn no longer resets the
pointer, referents and follow-up memory, since its conversation continues.

The server now tells a rejected key (401, or Gemini's 400 with reason
API_KEY_INVALID, matched exactly), denied access (403) and an account out
of credit (OpenAI's 429 insufficient_quota) apart; each cools down for a
minute and gets its own message in the chat.
This commit is contained in:
manjunath22466 committed 2026-11-03 22:34:21 +05:30
1 parent 9d4d7b3ee0
commit 0a8d824124
19 files changed
+238 -51

No files matched your search

+1 -1
View File
@@ -85,7 +85,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance. Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements. - **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
- **Typed chat spends the provider keys.** `/api/openai/chat` and `/api/gemini/chat` send the conversation to OpenAI Chat Completions or Gemini `generateContent` with the server's `OPENAI_API_KEY` or `GEMINI_API_KEY`. They require a same-site `Origin` (the Gemini request check), cap request bodies at 512 KB, refuse upstream redirects, cut the upstream call off after 60 s and return only sanitized errors. OpenAI chat can only bill `OPENAI_API_KEY`, so while `GEV_PREFER_CODEX_OAUTH` pins OpenAI to ChatGPT OAuth it refuses with `503` (`CODEX_OAUTH_REQUIRED`) rather than spend the key. After an upstream `401` or `403`, a route stops sending that key upstream for a minute, or until the key changes. - **Typed chat spends the provider keys.** `/api/openai/chat` and `/api/gemini/chat` send the conversation to OpenAI Chat Completions or Gemini `generateContent` with the server's `OPENAI_API_KEY` or `GEMINI_API_KEY`. They require a same-site `Origin` (the Gemini request check), cap request bodies at 512 KB, refuse upstream redirects, cut the upstream call off after 60 s and return only sanitized errors. OpenAI chat can only bill `OPENAI_API_KEY`, so while `GEV_PREFER_CODEX_OAUTH` pins OpenAI to ChatGPT OAuth it refuses with `503` (`CODEX_OAUTH_REQUIRED`) rather than spend the key. After the provider refuses the key or account (a `401`, a `403`, Gemini's `400` `API_KEY_INVALID` or OpenAI's `429` `insufficient_quota`), a route stops sending that key upstream for a minute, or until the key changes.
- **ChatGPT OAuth voice reuses the local Codex sign-in.** `/api/realtime/oauth-status`, `/api/realtime/oauth-login` and `/api/realtime/token?auth=oauth` answer only loopback connections, behind the same cross-site gate as the token endpoint; the login route also refuses a foreign `Origin`. None of them returns the access token. The login route runs `codex login` (or the executable named by `CODEX_BIN`) with an allowlisted environment that leaves out GEV's provider keys, keeps one attempt pending at a time and cancels it after two minutes or when the server stops. **Limits:** any program on this machine that can reach the server can mint Realtime sessions on the signed-in ChatGPT account, as it can with the API key; a local reverse proxy that hides the client's address defeats the loopback check; and OpenAI has not documented this use of Codex credentials for Realtime, so it may stop working or fall outside the account's terms. - **ChatGPT OAuth voice reuses the local Codex sign-in.** `/api/realtime/oauth-status`, `/api/realtime/oauth-login` and `/api/realtime/token?auth=oauth` answer only loopback connections, behind the same cross-site gate as the token endpoint; the login route also refuses a foreign `Origin`. None of them returns the access token. The login route runs `codex login` (or the executable named by `CODEX_BIN`) with an allowlisted environment that leaves out GEV's provider keys, keeps one attempt pending at a time and cancels it after two minutes or when the server stops. **Limits:** any program on this machine that can reach the server can mint Realtime sessions on the signed-in ChatGPT account, as it can with the API key; a local reverse proxy that hides the client's address defeats the loopback check; and OpenAI has not documented this use of Codex credentials for Realtime, so it may stop working or fall outside the account's terms.
## Network exposure — the operator threat model ## Network exposure — the operator threat model
+7 -3
View File
@@ -703,9 +703,13 @@ The chat routes answer a missing key with `503` and code `API_KEY_MISSING`.
OpenAI chat can only bill `OPENAI_API_KEY`, so while `GEV_PREFER_CODEX_OAUTH` OpenAI chat can only bill `OPENAI_API_KEY`, so while `GEV_PREFER_CODEX_OAUTH`
pins OpenAI to ChatGPT OAuth it answers `503` with code `CODEX_OAUTH_REQUIRED` pins OpenAI to ChatGPT OAuth it answers `503` with code `CODEX_OAUTH_REQUIRED`
instead; Gemini chat is unaffected. An upstream `429` or `503` keeps its status instead; Gemini chat is unaffected. An upstream `429` or `503` keeps its status
and passes on a `Retry-After` given in seconds, capped at an hour. After an and passes on a `Retry-After` given in seconds, capped at an hour; the chat
upstream `401` or `403`, the route answers `502` without calling the provider names the wait, and a throttled reply carries code `RATE_LIMITED`. A refused
for a minute, unless the configured key changes. key or account stops reaching the provider for a minute, unless the configured
key changes: `API_KEY_REJECTED` for a `401` or Gemini's `400` with reason
`API_KEY_INVALID`, `ACCESS_DENIED` for a `403`, and `QUOTA_EXCEEDED` for
OpenAI's `429` `insufficient_quota`. Each refusal answers `502` with its code,
and the chat shows a matching message.
Chat and the voice panel sit above the dock or dock into the right rail's lane, Chat and the voice panel sit above the dock or dock into the right rail's lane,
which hides the rail until the panel minimizes or closes, or a rail panel opens. which hides the rail until the panel minimizes or closes, or a rail panel opens.
+31 -12
View File
@@ -14,17 +14,31 @@ const FAILURE = 'Could not connect. Try again.';
// A rejected key is not retried upstream for this long, unless the key changes. // A rejected key is not retried upstream for this long, unless the key changes.
const REJECTED_KEY_COOLDOWN_MS = 60_000; const REJECTED_KEY_COOLDOWN_MS = 60_000;
// Gemini answers an invalid key with 400 and this reason, not 401 or 403. // What an upstream failure says about the key or the account. Gemini answers
const KEY_REJECTED_REASON = 'API_KEY_INVALID'; // an invalid key with 400 and an ErrorInfo reason, not 401; OpenAI answers an
async function rejectsKey(response, signal) { // account without credit with 429 insufficient_quota, which waiting won't fix.
if ([401, 403].includes(response.status)) return true; // A 403 is the key's project, region or permissions, so it cools down too but
if (response.status !== 400) return false; // does not blame the key. Only small error bodies are read.
async function upstreamRefusal(response, signal) {
const { status } = response;
if (status === 401) return 'API_KEY_REJECTED';
if (status === 403) return 'ACCESS_DENIED';
if (![400, 429].includes(status)) return null;
let error;
try { try {
const body = await readResponseJsonCapped(response, 16 * 1024, signal); error = (await readResponseJsonCapped(response, 16 * 1024, signal))?.error;
return JSON.stringify(body).includes(KEY_REJECTED_REASON);
} catch { } catch {
return false; return null;
} }
if (
status === 400 &&
Array.isArray(error?.details) &&
error.details.some((detail) => detail?.reason === 'API_KEY_INVALID')
)
return 'API_KEY_REJECTED';
if (status === 429 && error?.code === 'insufficient_quota')
return 'QUOTA_EXCEEDED';
return null;
} }
// Pass through only a delay in whole seconds, capped at an hour. // Pass through only a delay in whole seconds, capped at an hour.
@@ -106,7 +120,7 @@ export function createTextChatHandler({
'Retry-After', 'Retry-After',
String(Math.max(1, Math.ceil((rejected.until - now()) / 1000))), String(Math.max(1, Math.ceil((rejected.until - now()) / 1000))),
); );
return reply(502, FAILURE, 'API_KEY_REJECTED'); return reply(502, FAILURE, rejected.code);
} }
if (limiter === undefined) if (limiter === undefined)
limiter = makeCostRateLimiter( limiter = makeCostRateLimiter(
@@ -156,10 +170,15 @@ export function createTextChatHandler({
if (response.redirected || (response.url && response.url !== endpoint)) if (response.redirected || (response.url && response.url !== endpoint))
throw new Error('Unexpected destination'); throw new Error('Unexpected destination');
if (!response.ok) { if (!response.ok) {
if (await rejectsKey(response, lifetime.signal)) { const refusal = await upstreamRefusal(response, lifetime.signal);
if (refusal) {
void response.body?.cancel().catch(() => {}); void response.body?.cancel().catch(() => {});
rejected = { key: apiKey, until: now() + REJECTED_KEY_COOLDOWN_MS }; rejected = {
return reply(502, FAILURE, 'API_KEY_REJECTED'); key: apiKey,
code: refusal,
until: now() + REJECTED_KEY_COOLDOWN_MS,
};
return reply(502, FAILURE, refusal);
} }
void response.body?.cancel().catch(() => {}); void response.body?.cancel().catch(() => {});
if (![429, 503].includes(response.status)) return reply(502, FAILURE); if (![429, 503].includes(response.status)) return reply(502, FAILURE);
+3 -1
View File
@@ -3293,5 +3293,7 @@
"That request needed too many steps. Changes already made to the map stay in place.": "تطلّب هذا الطلب خطوات كثيرة جدًا. تبقى التغييرات التي أُجريت على الخريطة كما هي.", "That request needed too many steps. Changes already made to the map stay in place.": "تطلّب هذا الطلب خطوات كثيرة جدًا. تبقى التغييرات التي أُجريت على الخريطة كما هي.",
"That request needed too many steps. Try a simpler request.": "تطلّب هذا الطلب خطوات كثيرة جدًا. جرّب طلبًا أبسط.", "That request needed too many steps. Try a simpler request.": "تطلّب هذا الطلب خطوات كثيرة جدًا. جرّب طلبًا أبسط.",
"The provider rejected the API key. Check it in Provider Settings.": "رفض المزوّد مفتاح API. تحقّق منه في إعدادات المزوّد.", "The provider rejected the API key. Check it in Provider Settings.": "رفض المزوّد مفتاح API. تحقّق منه في إعدادات المزوّد.",
"Too many chat requests. Try again in {seconds} s.": "طلبات دردشة كثيرة جدًا. حاول مجددًا بعد {seconds} ث." "Too many chat requests. Try again in {seconds} s.": "طلبات دردشة كثيرة جدًا. حاول مجددًا بعد {seconds} ث.",
"The provider refused access for this key. Check the key's project, region or permissions.": "رفض المزوّد الوصول لهذا المفتاح. تحقّق من مشروع المفتاح أو منطقته أو أذوناته.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "نفد رصيد حساب المزوّد. تحقّق من الفوترة، أو اختر مزوّد ذكاء اصطناعي آخر في الإعدادات."
} }
+3 -1
View File
@@ -3013,5 +3013,7 @@
"That request needed too many steps. Changes already made to the map stay in place.": "Diese Anfrage brauchte zu viele Schritte. Bereits vorgenommene Kartenänderungen bleiben erhalten.", "That request needed too many steps. Changes already made to the map stay in place.": "Diese Anfrage brauchte zu viele Schritte. Bereits vorgenommene Kartenänderungen bleiben erhalten.",
"That request needed too many steps. Try a simpler request.": "Diese Anfrage brauchte zu viele Schritte. Versuche eine einfachere Anfrage.", "That request needed too many steps. Try a simpler request.": "Diese Anfrage brauchte zu viele Schritte. Versuche eine einfachere Anfrage.",
"The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Prüfe ihn in den Anbieter-Einstellungen.", "The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Prüfe ihn in den Anbieter-Einstellungen.",
"Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen." "Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Prüfe Projekt, Region oder Berechtigungen des Schlüssels.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Prüfe die Abrechnung oder wähle in den Einstellungen einen anderen KI-Anbieter."
} }
+3 -1
View File
@@ -3083,5 +3083,7 @@
"That request needed too many steps. Changes already made to the map stay in place.": "Esa solicitud necesitó demasiados pasos. Los cambios ya hechos en el mapa se mantienen.", "That request needed too many steps. Changes already made to the map stay in place.": "Esa solicitud necesitó demasiados pasos. Los cambios ya hechos en el mapa se mantienen.",
"That request needed too many steps. Try a simpler request.": "Esa solicitud necesitó demasiados pasos. Prueba una solicitud más sencilla.", "That request needed too many steps. Try a simpler request.": "Esa solicitud necesitó demasiados pasos. Prueba una solicitud más sencilla.",
"The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes del proveedor.", "The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes del proveedor.",
"Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s." "Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "El proveedor denegó el acceso a esta clave. Revisa el proyecto, la región o los permisos de la clave.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes."
} }
+3 -1
View File
@@ -3083,5 +3083,7 @@
"That request needed too many steps. Changes already made to the map stay in place.": "Cette demande a nécessité trop d’étapes. Les modifications déjà apportées à la carte sont conservées.", "That request needed too many steps. Changes already made to the map stay in place.": "Cette demande a nécessité trop d’étapes. Les modifications déjà apportées à la carte sont conservées.",
"That request needed too many steps. Try a simpler request.": "Cette demande a nécessité trop d’étapes. Essayez une demande plus simple.", "That request needed too many steps. Try a simpler request.": "Cette demande a nécessité trop d’étapes. Essayez une demande plus simple.",
"The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé d’API. Vérifiez-la dans les paramètres du fournisseur.", "The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé d’API. Vérifiez-la dans les paramètres du fournisseur.",
"Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de chat. Réessayez dans {seconds} s." "Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de chat. Réessayez dans {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l’accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n’a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d’IA dans les paramètres."
} }
+3 -1
View File
@@ -3083,5 +3083,7 @@
"That request needed too many steps. Changes already made to the map stay in place.": "Questa richiesta ha richiesto troppi passaggi. Le modifiche già fatte alla mappa restano.", "That request needed too many steps. Changes already made to the map stay in place.": "Questa richiesta ha richiesto troppi passaggi. Le modifiche già fatte alla mappa restano.",
"That request needed too many steps. Try a simpler request.": "Questa richiesta ha richiesto troppi passaggi. Prova una richiesta più semplice.", "That request needed too many steps. Try a simpler request.": "Questa richiesta ha richiesto troppi passaggi. Prova una richiesta più semplice.",
"The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala nelle impostazioni del provider.", "The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala nelle impostazioni del provider.",
"Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s." "Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l’accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L’account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA nelle impostazioni."
} }
+3 -1
View File
@@ -2943,5 +2943,7 @@
"That request needed too many steps. Changes already made to the map stay in place.": "このリクエストは手順が多すぎました。地図に加えた変更はそのまま残ります。", "That request needed too many steps. Changes already made to the map stay in place.": "このリクエストは手順が多すぎました。地図に加えた変更はそのまま残ります。",
"That request needed too many steps. Try a simpler request.": "このリクエストは手順が多すぎました。より簡単なリクエストを試してください。", "That request needed too many steps. Try a simpler request.": "このリクエストは手順が多すぎました。より簡単なリクエストを試してください。",
"The provider rejected the API key. Check it in Provider Settings.": "プロバイダーが API キーを拒否しました。プロバイダー設定で確認してください。", "The provider rejected the API key. Check it in Provider Settings.": "プロバイダーが API キーを拒否しました。プロバイダー設定で確認してください。",
"Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds} 秒後にもう一度お試しください。" "Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds} 秒後にもう一度お試しください。",
"The provider refused access for this key. Check the key's project, region or permissions.": "プロバイダーがこのキーへのアクセスを拒否しました。キーのプロジェクト、地域、権限を確認してください。",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーのアカウントのクレジットが不足しています。請求を確認するか、設定で別の AI プロバイダーを選んでください。"
} }
+3 -1
View File
@@ -3083,5 +3083,7 @@
"That request needed too many steps. Changes already made to the map stay in place.": "Esse pedido precisou de etapas demais. As alterações já feitas no mapa continuam.", "That request needed too many steps. Changes already made to the map stay in place.": "Esse pedido precisou de etapas demais. As alterações já feitas no mapa continuam.",
"That request needed too many steps. Try a simpler request.": "Esse pedido precisou de etapas demais. Tente um pedido mais simples.", "That request needed too many steps. Try a simpler request.": "Esse pedido precisou de etapas demais. Tente um pedido mais simples.",
"The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a nas configurações do provedor.", "The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a nas configurações do provedor.",
"Too many chat requests. Try again in {seconds} s.": "Pedidos de chat demais. Tente de novo em {seconds} s." "Too many chat requests. Try again in {seconds} s.": "Pedidos de chat demais. Tente de novo em {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA nas configurações."
} }
+3 -1
View File
@@ -3153,5 +3153,7 @@
"That request needed too many steps. Changes already made to the map stay in place.": "Этот запрос потребовал слишком много шагов. Уже внесённые изменения карты сохранятся.", "That request needed too many steps. Changes already made to the map stay in place.": "Этот запрос потребовал слишком много шагов. Уже внесённые изменения карты сохранятся.",
"That request needed too many steps. Try a simpler request.": "Этот запрос потребовал слишком много шагов. Попробуйте запрос попроще.", "That request needed too many steps. Try a simpler request.": "Этот запрос потребовал слишком много шагов. Попробуйте запрос попроще.",
"The provider rejected the API key. Check it in Provider Settings.": "Провайдер отклонил ключ API. Проверьте его в настройках провайдера.", "The provider rejected the API key. Check it in Provider Settings.": "Провайдер отклонил ключ API. Проверьте его в настройках провайдера.",
"Too many chat requests. Try again in {seconds} s.": "Слишком много запросов чата. Повторите через {seconds} с." "Too many chat requests. Try again in {seconds} s.": "Слишком много запросов чата. Повторите через {seconds} с.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Провайдер запретил доступ для этого ключа. Проверьте проект, регион или разрешения ключа.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На счёте провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках."
} }
+3 -1
View File
@@ -3153,5 +3153,7 @@
"That request needed too many steps. Changes already made to the map stay in place.": "Цей запит потребував забагато кроків. Уже внесені зміни на мапі залишаються.", "That request needed too many steps. Changes already made to the map stay in place.": "Цей запит потребував забагато кроків. Уже внесені зміни на мапі залишаються.",
"That request needed too many steps. Try a simpler request.": "Цей запит потребував забагато кроків. Спробуйте простіший запит.", "That request needed too many steps. Try a simpler request.": "Цей запит потребував забагато кроків. Спробуйте простіший запит.",
"The provider rejected the API key. Check it in Provider Settings.": "Провайдер відхилив ключ API. Перевірте його в налаштуваннях провайдера.", "The provider rejected the API key. Check it in Provider Settings.": "Провайдер відхилив ключ API. Перевірте його в налаштуваннях провайдера.",
"Too many chat requests. Try again in {seconds} s.": "Забагато запитів до чату. Спробуйте ще раз через {seconds} с." "Too many chat requests. Try again in {seconds} s.": "Забагато запитів до чату. Спробуйте ще раз через {seconds} с.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Провайдер заборонив доступ для цього ключа. Перевірте проєкт, регіон або дозволи ключа.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На рахунку провайдера закінчилися кошти. Перевірте оплату або виберіть іншого провайдера ШІ в налаштуваннях."
} }
+3 -1
View File
@@ -2943,5 +2943,7 @@
"That request needed too many steps. Changes already made to the map stay in place.": "此请求所需步骤过多。已对地图做出的更改会保留。", "That request needed too many steps. Changes already made to the map stay in place.": "此请求所需步骤过多。已对地图做出的更改会保留。",
"That request needed too many steps. Try a simpler request.": "此请求所需步骤过多。请尝试更简单的请求。", "That request needed too many steps. Try a simpler request.": "此请求所需步骤过多。请尝试更简单的请求。",
"The provider rejected the API key. Check it in Provider Settings.": "提供商拒绝了 API 密钥。请在提供商设置中检查。", "The provider rejected the API key. Check it in Provider Settings.": "提供商拒绝了 API 密钥。请在提供商设置中检查。",
"Too many chat requests. Try again in {seconds} s.": "聊天请求过多。请在 {seconds} 秒后重试。" "Too many chat requests. Try again in {seconds} s.": "聊天请求过多。请在 {seconds} 秒后重试。",
"The provider refused access for this key. Check the key's project, region or permissions.": "提供商拒绝了此密钥的访问。请检查该密钥的项目、地区或权限。",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "提供商账户余额不足。请检查账单,或在设置中选择其他 AI 提供商。"
} }
+3 -1
View File
@@ -2943,5 +2943,7 @@
"That request needed too many steps. Changes already made to the map stay in place.": "此請求所需步驟過多。已對地圖做出的變更會保留。", "That request needed too many steps. Changes already made to the map stay in place.": "此請求所需步驟過多。已對地圖做出的變更會保留。",
"That request needed too many steps. Try a simpler request.": "此請求所需步驟過多。請嘗試更簡單的請求。", "That request needed too many steps. Try a simpler request.": "此請求所需步驟過多。請嘗試更簡單的請求。",
"The provider rejected the API key. Check it in Provider Settings.": "供應商拒絕了 API 金鑰。請在供應商設定中檢查。", "The provider rejected the API key. Check it in Provider Settings.": "供應商拒絕了 API 金鑰。請在供應商設定中檢查。",
"Too many chat requests. Try again in {seconds} s.": "聊天請求過多。請在 {seconds} 秒後重試。" "Too many chat requests. Try again in {seconds} s.": "聊天請求過多。請在 {seconds} 秒後重試。",
"The provider refused access for this key. Check the key's project, region or permissions.": "供應商拒絕了此金鑰的存取。請檢查該金鑰的專案、地區或權限。",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "供應商帳戶餘額不足。請檢查帳單,或在設定中選擇其他 AI 供應商。"
} }
+37
View File
@@ -442,6 +442,43 @@ for (const [name, provider] of Object.entries(providers)) {
await plain.send(); await plain.send();
assert.equal(upstream, 3); assert.equal(upstream, 3);
}); });
test(`${name} a 403 or an out-of-credit 429 cools down with its own code`, async (t) => {
for (const [status, body, code] of [
[
403,
{ error: { code: 'unsupported_country_region_territory' } },
'ACCESS_DENIED',
],
[429, { error: { code: 'insufficient_quota' } }, 'QUOTA_EXCEEDED'],
]) {
let upstream = 0;
const f = await fixture(t, provider, async () => {
upstream++;
return Response.json(body, { status });
});
const first = await f.send();
assert.equal(first.status, 502);
assert.equal((await first.json()).code, code);
const cooling = await f.send();
assert.equal((await cooling.json()).code, code);
assert.equal(upstream, 1, `${code} is not retried at once`);
}
});
test(`${name} only an exact invalid-key reason marks a 400 as a rejected key`, async (t) => {
let upstream = 0;
const f = await fixture(t, provider, async () => {
upstream++;
return Response.json(
{ error: { code: 400, message: 'Field API_KEY_INVALID is unknown' } },
{ status: 400 },
);
});
const reply = await f.send();
assert.equal(reply.status, 502);
assert.equal((await reply.json()).code, undefined);
await f.send();
assert.equal(upstream, 2);
});
test(`${name} throttled replies say they are rate limited`, async (t) => { test(`${name} throttled replies say they are rate limited`, async (t) => {
const local = await fixture(t, provider, undefined, { const local = await fixture(t, provider, undefined, {
resolveRateLimit: () => '1', resolveRateLimit: () => '1',
+18
View File
@@ -472,6 +472,24 @@ test('native connecting phases retain output observation; idle starts before fir
assert.equal(adapter.stops, 1); assert.equal(adapter.stops, 1);
}); });
test('a failed typed turn keeps follow-up memory; a stop or voice error clears it', async (t) => {
let resets = 0;
const runner = async () => ({ ok: true });
runner.resetConversation = () => resets++;
const f = mounted(t, { runner });
const adapter = f.instances[0];
adapter.getStartOptions = () => ({ textOnly: true });
await f.commands.session.start({ textOnly: true });
adapter.hooks.emit({ type: 'state', state: 'error', detail: 'Busy' });
assert.equal(resets, 0, 'the typed conversation continues');
f.commands.session.stop();
assert.equal(resets, 1);
adapter.getStartOptions = () => ({});
await f.commands.session.start();
adapter.hooks.emit({ type: 'state', state: 'error', detail: 'Lost' });
assert.equal(resets, 2);
});
test('the voice inactivity timeout never ends a typed chat session', async (t) => { test('the voice inactivity timeout never ends a typed chat session', async (t) => {
const f = mounted(t, { native: true }); const f = mounted(t, { native: true });
const adapter = f.instances[0]; const adapter = f.instances[0];
+6 -1
View File
@@ -166,7 +166,12 @@ export function createVoiceCommands({
const reticle = pointer ? createReticle(pointer) : null; const reticle = pointer ? createReticle(pointer) : null;
const updatePointer = session.subscribe((event) => { const updatePointer = session.subscribe((event) => {
reticle?.handle(event); reticle?.handle(event);
if (event.type === 'state' && ['idle', 'error'].includes(event.state)) { // A failed typed turn ends only that reply; its conversation continues.
if (
event.type === 'state' &&
(event.state === 'idle' ||
(event.state === 'error' && !adapter.getStartOptions?.().textOnly))
) {
pointer?.clear(); pointer?.clear();
referents?.clear(); referents?.clear();
// Conversation memory ("those", "the last answer") ends with the // Conversation memory ("those", "the last answer") ends with the
+48 -23
View File
@@ -20,6 +20,7 @@ const HISTORY_BYTES = 180000;
const REQUEST_BYTES = 512 * 1024; const REQUEST_BYTES = 512 * 1024;
// Results from earlier turns keep their outcome and say line, not their records. // Results from earlier turns keep their outcome and say line, not their records.
const COMPACT_RESULT_BYTES = 1500; const COMPACT_RESULT_BYTES = 1500;
const NOT_RUN = { ok: false, skipped: true, reason: 'reply-stopped' };
const PARTIAL_NOTE = const PARTIAL_NOTE =
'This reply stopped before it finished. The map actions above already ran.'; 'This reply stopped before it finished. The map actions above already ran.';
@@ -159,15 +160,29 @@ export function createTextChat({
// A turn that stops after some map actions ran keeps them, closed by a // A turn that stops after some map actions ran keeps them, closed by a
// note, so the next message knows they happened. // note, so the next message knows they happened.
let settled = 0; let settled = 0;
// The round in progress: its calls, and the results of those that ran.
let openRound = null;
partial = { partial = {
owner, owner,
keep() { keep() {
if (settled) const ran = openRound?.pairs.length ? contents.length : settled;
history = [ if (!ran) return;
...past, // Calls that never ran are answered as skipped, so every call in the
...contents.slice(0, settled), // kept history stays paired with a result.
protocol.note(PARTIAL_NOTE), const closing = openRound?.pairs.length
]; ? protocol.results(
openRound.calls.map(
(call, index) =>
openRound.pairs[index] ?? { call, result: NOT_RUN },
),
)
: [];
history = [
...past,
...contents.slice(0, ran),
...closing,
protocol.note(PARTIAL_NOTE),
];
}, },
}; };
const encode = () => const encode = () =>
@@ -279,6 +294,7 @@ export function createTextChat({
totalCalls += calls.length; totalCalls += calls.length;
if (totalCalls > 16) throw tooManySteps('Tool limit'); if (totalCalls > 16) throw tooManySteps('Tool limit');
const pairs = []; const pairs = [];
openRound = { calls, pairs };
const invalid = new Map( const invalid = new Map(
(data.invalidCalls || []).map((item) => [item.index, item.error]), (data.invalidCalls || []).map((item) => [item.index, item.error]),
); );
@@ -387,6 +403,7 @@ export function createTextChat({
} }
contents.push(...protocol.results(pairs)); contents.push(...protocol.results(pairs));
settled = contents.length; settled = contents.length;
openRound = null;
} }
throw tooManySteps('Round limit'); throw tooManySteps('Round limit');
} catch (error) { } catch (error) {
@@ -404,23 +421,31 @@ export function createTextChat({
? t( ? t(
'The provider rejected the API key. Check it in Provider Settings.', 'The provider rejected the API key. Check it in Provider Settings.',
) )
: error?.code === 'RATE_LIMITED' || error?.status === 429 : error?.code === 'ACCESS_DENIED'
? t('Too many chat requests. Try again in {seconds} s.', { ? t(
seconds: Math.min(error.retryAfter || 60, 3600), "The provider refused access for this key. Check the key's project, region or permissions.",
}) )
: error?.status === 504 || owner.signal.aborted : error?.code === 'QUOTA_EXCEEDED'
? t('Connection timed out. Try again.') ? t(
: error?.incomplete 'The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.',
? t('Reply interrupted. You can try again.') )
: error?.tooManySteps : error?.code === 'RATE_LIMITED' || error?.status === 429
? applied ? t('Too many chat requests. Try again in {seconds} s.', {
? t( seconds: Math.min(error.retryAfter || 60, 3600),
'That request needed too many steps. Changes already made to the map stay in place.', })
) : error?.status === 504 || owner.signal.aborted
: t( ? t('Connection timed out. Try again.')
'That request needed too many steps. Try a simpler request.', : error?.incomplete
) ? t('Reply interrupted. You can try again.')
: t('Could not connect. Try again.'), : error?.tooManySteps
? applied
? t(
'That request needed too many steps. Changes already made to the map stay in place.',
)
: t(
'That request needed too many steps. Try a simpler request.',
)
: t('Could not connect. Try again.'),
); );
} finally { } finally {
recordUsage(null); recordUsage(null);
+57
View File
@@ -136,6 +136,45 @@ for (const provider of ['openai', 'gemini']) {
h.chat.stop(); h.chat.stop();
}); });
test(`${provider}: Stop during the second action keeps the first, pairing the rest as skipped`, async () => {
let runs = 0;
const h = harness(
provider,
(n) => (n === 1 ? w.calls(2) : w.final('Done')),
{
runAction: () => (++runs === 1 ? { ok: true } : new Promise(() => {})),
},
);
h.chat.start();
h.chat.sendText('Do both');
await settle();
assert.equal(runs, 2);
assert.equal(h.chat.cancelTurn(), true);
h.chat.sendText('Continue');
await settle();
const items = w.items(h.requests[1]);
assert.deepEqual(w.userTexts(items), ['Do both', 'Continue']);
const results =
provider === 'openai'
? items
.filter((item) => item.role === 'tool')
.map((item) => JSON.parse(item.content))
: items.flatMap((item) =>
item.parts
.filter((part) => part.functionResponse)
.map((part) => part.functionResponse.response),
);
assert.equal(results.length, 2);
assert.equal(results[0].ok, true);
assert.equal(results[1].skipped, true);
const note = items.at(-2);
assert.match(
provider === 'openai' ? note.content : note.parts[0].text,
/map actions above already ran/,
);
h.chat.stop();
});
test(`${provider}: cancelTurn aborts only the reply in progress`, async () => { test(`${provider}: cancelTurn aborts only the reply in progress`, async () => {
let toolSignal; let toolSignal;
const h = harness( const h = harness(
@@ -210,6 +249,24 @@ for (const provider of ['openai', 'gemini']) {
() => () =>
t('The provider rejected the API key. Check it in Provider Settings.'), t('The provider rejected the API key. Check it in Provider Settings.'),
], ],
[
502,
{ error: 'Refused', code: 'ACCESS_DENIED' },
{},
() =>
t(
"The provider refused access for this key. Check the key's project, region or permissions.",
),
],
[
502,
{ error: 'Refused', code: 'QUOTA_EXCEEDED' },
{},
() =>
t(
'The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.',
),
],
[ [
429, 429,
{ error: 'Refused', code: 'RATE_LIMITED' }, { error: 'Refused', code: 'RATE_LIMITED' },