mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
9d4d7b3ee0
commit
0a8d824124
19 files changed
+238
-51
No files matched your search
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.": "نفد رصيد حساب المزوّد. تحقّق من الفوترة، أو اختر مزوّد ذكاء اصطناعي آخر في الإعدادات."
|
||||||
}
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
@@ -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 プロバイダーを選んでください。"
|
||||||
}
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
@@ -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.": "На счёте провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках."
|
||||||
}
|
}
|
||||||
@@ -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.": "На рахунку провайдера закінчилися кошти. Перевірте оплату або виберіть іншого провайдера ШІ в налаштуваннях."
|
||||||
}
|
}
|
||||||
@@ -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 提供商。"
|
||||||
}
|
}
|
||||||
@@ -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 供應商。"
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
Reference in new issue
Block a user