diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index c378a3e1..ce2278c0 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -427,13 +427,16 @@ export default function ChatPanel({ let openModelConfig = false if (data?.type === "provider") { const hints = dict.errors.llm as Record - text = hints[data.code] - ? `${hints[data.code]}\n\n${data.message}` - : data.message + const hint = hints[data.code] + text = + hint && data.message + ? `${hint}\n\n${data.message}` + : hint || data.message openModelConfig = [ "invalid_api_key", "forbidden", "model_not_found", + "server_key_forbidden", ].includes(data.code) } else if (typeof data?.error === "string") { text = data.error diff --git a/lib/i18n/dictionaries/en.json b/lib/i18n/dictionaries/en.json index fa5ee712..fb9cb9cb 100644 --- a/lib/i18n/dictionaries/en.json +++ b/lib/i18n/dictionaries/en.json @@ -192,6 +192,7 @@ "llm": { "invalid_api_key": "The provider rejected the API key. Check it in model settings.", "forbidden": "The provider refused the request. The key may not have access to this model or region.", + "server_key_forbidden": "Today's free quota is used up. It resets tomorrow. You can also add your own API key in model settings to keep going.", "model_not_found": "The provider does not know this model. Check the model ID in model settings.", "insufficient_quota": "The provider account has no credit or quota left.", "rate_limited": "The provider is limiting requests. Wait a moment and try again.", diff --git a/lib/i18n/dictionaries/ja.json b/lib/i18n/dictionaries/ja.json index 97c5c754..8c549ace 100644 --- a/lib/i18n/dictionaries/ja.json +++ b/lib/i18n/dictionaries/ja.json @@ -192,6 +192,7 @@ "llm": { "invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。", "forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。", + "server_key_forbidden": "本日の無料枠を使い切りました。明日になると自動的に回復します。モデル設定でご自身の API キーを入力すると、引き続きご利用いただけます。", "model_not_found": "プロバイダーがこのモデルを認識できません。モデル設定でモデル ID を確認してください。", "insufficient_quota": "プロバイダーのアカウントの残高または利用枠がなくなりました。", "rate_limited": "プロバイダーがリクエスト数を制限しています。少し待ってから再試行してください。", diff --git a/lib/i18n/dictionaries/zh-Hant.json b/lib/i18n/dictionaries/zh-Hant.json index d3521c80..96282987 100644 --- a/lib/i18n/dictionaries/zh-Hant.json +++ b/lib/i18n/dictionaries/zh-Hant.json @@ -192,6 +192,7 @@ "llm": { "invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。", "forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。", + "server_key_forbidden": "今天的免費額度已經用完,明天會自動恢復。您也可以在模型設定中填寫自己的 API Key 繼續使用。", "model_not_found": "服務商找不到這個模型,請在模型設定中檢查模型 ID。", "insufficient_quota": "服務商帳戶的餘額或額度已經用完。", "rate_limited": "服務商正在限制請求頻率,請稍候再試。", diff --git a/lib/i18n/dictionaries/zh.json b/lib/i18n/dictionaries/zh.json index 1d8f9adb..2c5a50c9 100644 --- a/lib/i18n/dictionaries/zh.json +++ b/lib/i18n/dictionaries/zh.json @@ -192,6 +192,7 @@ "llm": { "invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。", "forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。", + "server_key_forbidden": "今天的免费额度已经用完,明天会自动恢复。您也可以在模型设置里填写自己的 API Key 继续使用。", "model_not_found": "服务商找不到这个模型,请在模型设置里检查模型 ID。", "insufficient_quota": "服务商账户的余额或额度已经用完。", "rate_limited": "服务商正在限制请求频率,请稍等片刻再试。", diff --git a/lib/llm-errors.ts b/lib/llm-errors.ts index c587276d..224eeec7 100644 --- a/lib/llm-errors.ts +++ b/lib/llm-errors.ts @@ -24,6 +24,8 @@ export type LLMErrorCode = | "provider_unavailable" | "cannot_connect" | "timeout" + // A 403 on the server's own key, e.g. a daily spend cap blocked it + | "server_key_forbidden" | "unknown" export interface LLMError { @@ -130,7 +132,13 @@ export function streamErrorText(error: unknown, hideDetails = false): string { const classified = classifyLLMError(error) if (hideDetails) { console.error("[chat] Provider error:", error) - classified.message = "The provider returned an error." + if (classified.code === "forbidden") { + // The hint says all the user can do; there is no message to add + classified.code = "server_key_forbidden" + classified.message = "" + } else { + classified.message = "The provider returned an error." + } } return JSON.stringify(classified) } diff --git a/tests/e2e/chat-errors.spec.ts b/tests/e2e/chat-errors.spec.ts index ff9813f5..b29c0b66 100644 --- a/tests/e2e/chat-errors.spec.ts +++ b/tests/e2e/chat-errors.spec.ts @@ -75,3 +75,30 @@ test("a provider rate limit is not shown as this site's quota", async ({ // The site's own tokens-per-minute toast await expect(page.getByText("Rate limit reached")).toHaveCount(0) }) + +test("a refused server key shows only the quota hint and a settings button", async ({ + page, +}) => { + // What the chat route streams when the server's key gets a 403, e.g. + // after a daily spend cap blocked it + const errorText = JSON.stringify({ + type: "provider", + code: "server_key_forbidden", + message: "", + }) + await chatWith(page, { + status: 200, + contentType: "text/event-stream", + body: `data: {"type":"start"}\n\ndata: ${JSON.stringify({ type: "error", errorText })}\n\ndata: [DONE]\n\n`, + }) + await expect( + page.getByText("Today's free quota is used up", { exact: false }), + ).toBeVisible({ timeout: 15000 }) + await expect(page.getByText("The provider returned an error")).toHaveCount( + 0, + ) + await page.getByRole("button", { name: "Open model settings" }).click() + await expect( + page.getByRole("dialog", { name: "AI Model Configuration" }), + ).toBeVisible() +}) diff --git a/tests/unit/chat-route-errors.test.ts b/tests/unit/chat-route-errors.test.ts index e89e6b94..d0ef66ee 100644 --- a/tests/unit/chat-route-errors.test.ts +++ b/tests/unit/chat-route-errors.test.ts @@ -139,7 +139,8 @@ describe("provider error texts in the stream", () => { ) const message = await streamedError({}) expect(message).not.toMatch(/org-operator/) - expect(message).toBe("The provider returned an error.") + // A 403 on the server's key gets its own hint and no message + expect(message).toBe("") }) }) diff --git a/tests/unit/llm-errors.test.ts b/tests/unit/llm-errors.test.ts index 9105eb54..7632ae11 100644 --- a/tests/unit/llm-errors.test.ts +++ b/tests/unit/llm-errors.test.ts @@ -215,11 +215,29 @@ describe("streamErrorText", () => { "User: arn:aws:sts::123456789012:assumed-role/app/s is not authorized to perform: bedrock:InvokeModel", ) const hidden = JSON.parse(streamErrorText(error, true)) - expect(hidden.code).toBe("forbidden") expect(hidden.message).not.toMatch(/arn:aws|123456789012/) expect(JSON.parse(streamErrorText(error)).message).toMatch( /not authorized/, ) + const throttled = JSON.parse( + streamErrorText(apiError(429, "Too many tokens"), true), + ) + expect(throttled).toEqual({ + type: "provider", + code: "rate_limited", + message: "The provider returned an error.", + }) + }) + + it("names a 403 on the server's keys, e.g. a spend cap blocked them", () => { + const error = apiError(403, "explicit deny in an identity-based policy") + expect(JSON.parse(streamErrorText(error, true))).toEqual({ + type: "provider", + code: "server_key_forbidden", + message: "", + }) + // On the user's own key it stays a plain refusal + expect(JSON.parse(streamErrorText(error)).code).toBe("forbidden") }) it("classifies a provider error", () => {