feat(bgalert): surface non-retryable background worker errors to admin UI

- Add bgalert package: classify provider errors (auth, billing,
  model_not_found), store alert in system_configs, broadcast WS event
- Wire AlertDeps into consolidation (episodic, semantic, dreaming)
  and vault enrich workers to report failures after retry exhaustion
- Auto-clear alert when admin changes provider-related system configs
- Add BackgroundErrorBanner component with dismiss + "Fix in Settings"
- Lift settings modal state to AppLayout so banner can open it
- Add EventBackgroundError to admin-only WS event filter
- Add i18n translations (en/vi/zh) for alert messages and reasons
This commit is contained in:
viettranx committed 2026-04-13 19:19:51 +07:00
1 parent 21cc208813
commit 1a471cbde3
16 files changed
+309 -8

No files matched your search

+3
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/cache"
@@ -201,6 +202,7 @@ func runGateway() {
SystemConfigs: pgStores.SystemConfigs,
Registry: providerRegistry,
Extractor: kgExtractor,
AlertDeps: bgalert.AlertDeps{SystemConfigs: pgStores.SystemConfigs, MsgBus: msgBus},
AgentStore: pgStores.Agents,
})
defer cleanupConsolidation()
@@ -222,6 +224,7 @@ func runGateway() {
EventBus: domainBus,
MsgBus: msgBus,
TeamStore: pgStores.Teams,
AlertDeps: bgalert.AlertDeps{SystemConfigs: pgStores.SystemConfigs, MsgBus: msgBus},
})
enrichProgress = ep
enrichWorker = ew
+120
View File
@@ -0,0 +1,120 @@
// Package bgalert reports non-retryable LLM errors from background workers
// to the web UI via system_configs + WS events.
package bgalert
import (
"context"
"encoding/json"
"log/slog"
"regexp"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// AlertKeyProviderError is the system_configs key for background provider errors.
const AlertKeyProviderError = "alert.background.provider_error"
// AlertDeps bundles dependencies for error reporting.
type AlertDeps struct {
SystemConfigs store.SystemConfigStore
MsgBus bus.EventPublisher
}
// ProviderErrorPayload is stored in system_configs and sent via WS event.
type ProviderErrorPayload struct {
Reason string `json:"reason"`
Worker string `json:"worker"`
Message string `json:"message"`
Timestamp string `json:"timestamp"`
}
// alertableReasons are non-retryable errors that should surface to the user.
var alertableReasons = map[providers.FailoverReason]bool{
providers.FailoverAuth: true,
providers.FailoverAuthPermanent: true,
providers.FailoverBilling: true,
providers.FailoverModelNotFound: true,
}
// IsAlertableError returns true if the error is non-retryable and should
// be surfaced to the user (auth, billing, model_not_found).
func IsAlertableError(err error) (providers.FailoverReason, bool) {
if err == nil {
return "", false
}
cls := providers.ClassifyHTTPError(providers.NewDefaultClassifier(), err)
if cls.Kind != "reason" {
return "", false
}
return cls.Reason, alertableReasons[cls.Reason]
}
// ReportProviderError stores an alert in system_configs and broadcasts a
// WS event when the error is non-retryable (auth/billing/model_not_found).
// Safe to call with nil deps or for retryable errors — returns silently.
func ReportProviderError(ctx context.Context, deps AlertDeps, workerName string, err error) {
if deps.SystemConfigs == nil || err == nil {
return
}
reason, alertable := IsAlertableError(err)
if !alertable {
return
}
payload := ProviderErrorPayload{
Reason: string(reason),
Worker: workerName,
Message: sanitizeErrorMessage(err.Error()),
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
data, jsonErr := json.Marshal(payload)
if jsonErr != nil {
slog.Warn("bgalert: marshal failed", "err", jsonErr)
return
}
if setErr := deps.SystemConfigs.Set(ctx, AlertKeyProviderError, string(data)); setErr != nil {
slog.Warn("bgalert: store alert failed", "err", setErr)
return
}
slog.Warn("bgalert: provider error stored",
"worker", workerName, "reason", reason, "err", err)
if deps.MsgBus != nil {
tenantID := store.TenantIDFromContext(ctx)
deps.MsgBus.Broadcast(bus.Event{
Name: protocol.EventBackgroundError,
TenantID: tenantID,
Payload: payload,
})
}
}
// ClearProviderError removes the alert key from system_configs.
// Ignores "not found" errors. Safe to call when no alert exists.
func ClearProviderError(ctx context.Context, configs store.SystemConfigStore) {
if configs == nil {
return
}
_ = configs.Delete(ctx, AlertKeyProviderError)
}
// apiKeyPattern matches common API key formats in error messages.
var apiKeyPattern = regexp.MustCompile(`(?i)(sk-|Bearer\s+|api[_-]?key[=:]\s*)[a-zA-Z0-9_-]{4,}`)
// sanitizeErrorMessage strips potentially sensitive content from error messages.
// Keeps the message useful for diagnostics while removing API keys and long bodies.
func sanitizeErrorMessage(msg string) string {
msg = apiKeyPattern.ReplaceAllString(msg, "${1}****")
// Rune-safe truncation to avoid corrupting multi-byte UTF-8 characters.
const maxLen = 200
if runes := []rune(msg); len(runes) > maxLen {
msg = string(runes[:maxLen]) + "..."
}
return msg
}
@@ -9,6 +9,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/providerresolve"
@@ -29,6 +30,7 @@ type dreamingWorker struct {
memoryStore store.MemoryStore
systemConfigs store.SystemConfigStore // per-tenant provider config
registry *providers.Registry // provider resolution
alertDeps bgalert.AlertDeps
// threshold/debounce are the global defaults. Per-agent overrides come
// from resolveConfig which reads the agent's MemoryConfig.Dreaming JSONB.
@@ -160,6 +162,7 @@ func (w *dreamingWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
// Build LLM prompt and call provider.
synthesis, err := w.synthesize(ctx, provider, model, entries)
if err != nil {
bgalert.ReportProviderError(ctx, w.alertDeps, "dreaming", err)
slog.Warn("dreaming: LLM synthesis failed", "err", err, "agent", agentID)
return nil
}
@@ -8,6 +8,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/providerresolve"
@@ -21,6 +22,7 @@ type episodicWorker struct {
systemConfigs store.SystemConfigStore // per-tenant provider config
registry *providers.Registry // provider resolution
eventBus eventbus.DomainEventBus
alertDeps bgalert.AlertDeps
}
// resolveProvider delegates to shared background provider resolution.
@@ -45,6 +47,8 @@ func (w *episodicWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
if err != nil {
return fmt.Errorf("episodic: invalid tenant_id %q: %w", event.TenantID, err)
}
// Inject tenant context so store queries and bgalert scope correctly.
ctx = store.WithTenantID(ctx, tenantUUID)
agentUUID, err := uuid.Parse(event.AgentID)
if err != nil {
return fmt.Errorf("episodic: invalid agent_id %q: %w", event.AgentID, err)
@@ -68,6 +72,7 @@ func (w *episodicWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
if provider != nil {
summary, err = w.summarizeSession(ctx, provider, model, payload)
if err != nil {
bgalert.ReportProviderError(ctx, w.alertDeps, "episodic", err)
return fmt.Errorf("episodic: summarize: %w", err)
}
}
+11
View File
@@ -6,6 +6,8 @@ import (
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -15,6 +17,7 @@ type semanticWorker struct {
kgStore store.KnowledgeGraphStore
extractor EntityExtractor
eventBus eventbus.DomainEventBus
alertDeps bgalert.AlertDeps
}
// Handle extracts entities and relations from an episodic summary.
@@ -24,6 +27,13 @@ func (w *semanticWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
return fmt.Errorf("semantic: unexpected payload type %T", event.Payload)
}
// Inject tenant context so bgalert scopes correctly.
if event.TenantID != "" {
if tid, err := uuid.Parse(event.TenantID); err == nil {
ctx = store.WithTenantID(ctx, tid)
}
}
if w.extractor == nil || payload.Summary == "" {
return nil
}
@@ -31,6 +41,7 @@ func (w *semanticWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
// Extract entities/relations from summary (much cheaper than full session)
result, err := w.extractor.Extract(ctx, payload.Summary)
if err != nil {
bgalert.ReportProviderError(ctx, w.alertDeps, "kg_extraction", err)
slog.Warn("semantic: extraction failed", "episodic_id", payload.EpisodicID, "err", err)
return nil // non-fatal: extraction failure doesn't block pipeline
}
+5
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -24,6 +25,7 @@ type ConsolidationDeps struct {
SystemConfigs store.SystemConfigStore // per-tenant provider config
Registry *providers.Registry // provider resolution
Extractor EntityExtractor
AlertDeps bgalert.AlertDeps // for reporting non-retryable LLM errors
// AgentStore is optional: when present, the dreaming worker reads
// per-agent overrides from MemoryConfig.Dreaming. If nil, the worker
// uses its built-in defaults for every agent.
@@ -39,11 +41,13 @@ func Register(deps ConsolidationDeps) func() {
systemConfigs: deps.SystemConfigs,
registry: deps.Registry,
eventBus: deps.EventBus,
alertDeps: deps.AlertDeps,
}
semantic := &semanticWorker{
kgStore: deps.KGStore,
extractor: deps.Extractor,
eventBus: deps.EventBus,
alertDeps: deps.AlertDeps,
}
dedup := &dedupWorker{
kgStore: deps.KGStore,
@@ -54,6 +58,7 @@ func Register(deps ConsolidationDeps) func() {
memoryStore: deps.MemoryStore,
systemConfigs: deps.SystemConfigs,
registry: deps.Registry,
alertDeps: deps.AlertDeps,
threshold: dreamingDefaultThreshold,
debounce: dreamingDefaultDebounce,
resolveConfig: newAgentStoreResolver(deps.AgentStore),
+2 -1
View File
@@ -147,7 +147,8 @@ func isAdminOnlyEvent(name string) bool {
case protocol.EventNodePairRequested, protocol.EventNodePairResolved,
protocol.EventDevicePairReq, protocol.EventDevicePairRes,
protocol.EventAgentLinkCreated, protocol.EventAgentLinkUpdated, protocol.EventAgentLinkDeleted,
protocol.EventWorkspaceFileChanged:
protocol.EventWorkspaceFileChanged,
protocol.EventBackgroundError:
return true
}
return false
+17
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"regexp"
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -24,6 +25,17 @@ func NewSystemConfigsHandler(s store.SystemConfigStore, msgBus *bus.MessageBus)
// validKeyRe allows alphanumeric, dots, underscores, hyphens (1-100 chars).
var validKeyRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,100}$`)
// providerRelatedKeys lists system config keys whose changes should clear
// background worker error alerts (user is likely fixing the root cause).
var providerRelatedKeys = map[string]bool{
"background.provider": true,
"background.model": true,
"agent.default_provider": true,
"agent.default_model": true,
"embedding.provider": true,
"embedding.model": true,
}
// RegisterRoutes registers system config endpoints on the given mux.
func (h *SystemConfigsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/system-configs", requireAuth("", h.handleList))
@@ -74,6 +86,11 @@ func (h *SystemConfigsHandler) handleSet(w http.ResponseWriter, r *http.Request)
return
}
// Auto-clear background error alert when provider settings change.
if providerRelatedKeys[key] {
bgalert.ClearProviderError(r.Context(), h.store)
}
// Broadcast change with tenant context so in-memory config refreshes for the right tenant.
// Use fresh context with tenant ID — request context may be canceled before subscriber runs.
if h.msgBus != nil {
+10
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
@@ -45,6 +46,7 @@ type EnrichWorkerDeps struct {
EventBus eventbus.DomainEventBus
MsgBus bus.EventPublisher // for WS event broadcast
TeamStore store.TaskCommentStore // for Phase 2.5 task-based auto-linking (nil-safe)
AlertDeps bgalert.AlertDeps // for reporting non-retryable LLM errors
}
// RegisterEnrichWorker subscribes the enrichment worker to vault doc events.
@@ -57,6 +59,7 @@ func RegisterEnrichWorker(deps EnrichWorkerDeps) (func(), *EnrichProgress, *Enri
systemConfigs: deps.SystemConfigs,
registry: deps.Registry,
msgBus: deps.MsgBus,
alertDeps: deps.AlertDeps,
dedup: make(map[string]string),
sem: semaphore.NewWeighted(enrichMaxConcurrent),
progress: progress,
@@ -75,6 +78,7 @@ type EnrichWorker struct {
systemConfigs store.SystemConfigStore // per-tenant provider config
registry *providers.Registry // provider resolution
msgBus bus.EventPublisher // for error event broadcast
alertDeps bgalert.AlertDeps // for reporting non-retryable LLM errors
queue enrichBatchQueue
progress *EnrichProgress
@@ -201,6 +205,11 @@ func (w *EnrichWorker) Handle(ctx context.Context, event eventbus.DomainEvent) e
return nil // another goroutine already processing this agent's queue
}
// Inject tenant context so store queries and bgalert scope correctly.
if tid, parseErr := uuid.Parse(payload.TenantID); parseErr == nil {
ctx = store.WithTenantID(ctx, tid)
}
// Create per-tenant cancel context for stop capability.
cancelCtx, cancel := context.WithCancel(ctx)
w.cancelFuncs.Store(payload.TenantID, cancel)
@@ -517,6 +526,7 @@ func (w *EnrichWorker) chatWithRetry(ctx context.Context, provider providers.Pro
}
return strings.TrimSpace(resp.Content), nil
}
bgalert.ReportProviderError(ctx, w.alertDeps, "vault_enrich", lastErr)
return "", fmt.Errorf("%s exhausted %d retries: %w", logPrefix, enrichMaxRetries, lastErr)
}
+3
View File
@@ -108,6 +108,9 @@ const (
// Vault enrichment pipeline progress.
EventVaultEnrichProgress = "vault.enrich.progress"
// Background worker alerts (non-retryable LLM errors).
EventBackgroundError = "background.error"
)
// Agent event subtypes (in payload.type)
+5 -2
View File
@@ -1,9 +1,10 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { Outlet, useLocation } from "react-router";
import { WifiOff } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Sidebar } from "./sidebar";
import { Topbar } from "./topbar";
import { BackgroundErrorBanner } from "./background-error-banner";
import { ErrorBoundary } from "@/components/shared/error-boundary";
import { useUiStore } from "@/stores/use-ui-store";
import { useAuthStore } from "@/stores/use-auth-store";
@@ -29,6 +30,7 @@ export function AppLayout() {
const setMobileSidebarOpen = useUiStore((s) => s.setMobileSidebarOpen);
const connected = useAuthStore((s) => s.connected);
const isMobile = useIsTablet();
const [settingsOpen, setSettingsOpen] = useState(false);
// Close mobile sidebar on route change (e.g. programmatic navigation, back button)
useEffect(() => {
@@ -62,7 +64,8 @@ export function AppLayout() {
<Sidebar collapsed={sidebarCollapsed} />
)}
<div className="flex flex-1 flex-col overflow-hidden">
<Topbar />
<Topbar settingsOpen={settingsOpen} onSettingsOpenChange={setSettingsOpen} />
<BackgroundErrorBanner settingsOpen={settingsOpen} onOpenSettings={() => setSettingsOpen(true)} />
{!connected && (
<div className="flex items-center gap-2 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<WifiOff className="h-4 w-4 shrink-0" />
@@ -0,0 +1,82 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, X, Settings2 } from "lucide-react";
import { useHttp } from "@/hooks/use-ws";
import { useWsEvent } from "@/hooks/use-ws-event";
import { useAuthStore } from "@/stores/use-auth-store";
import { Button } from "@/components/ui/button";
interface ProviderErrorPayload {
reason: string;
worker: string;
message: string;
timestamp: string;
}
interface BackgroundErrorBannerProps {
settingsOpen: boolean;
onOpenSettings: () => void;
}
export function BackgroundErrorBanner({ settingsOpen, onOpenSettings }: BackgroundErrorBannerProps) {
const { t } = useTranslation("system-settings");
const http = useHttp();
const role = useAuthStore((s) => s.role);
const isAdmin = role === "admin" || role === "owner";
const [error, setError] = useState<ProviderErrorPayload | null>(null);
const [dismissed, setDismissed] = useState(false);
// Fetch alert state from the specific system config key.
const fetchAlert = useCallback(() => {
if (!isAdmin) return;
http.get<{ key: string; value: string }>("/v1/system-configs/alert.background.provider_error")
.then((res) => {
if (res.value) {
try { setError(JSON.parse(res.value)); } catch { /* ignore */ }
} else {
setError(null);
}
})
.catch(() => { setError(null); }); // 404 = no alert
}, [http, isAdmin]);
// Fetch on mount
useEffect(() => { fetchAlert(); }, [fetchAlert]);
// Re-fetch when settings modal closes (user may have fixed the issue)
const prevSettingsOpen = useRef(settingsOpen);
useEffect(() => {
if (prevSettingsOpen.current && !settingsOpen) fetchAlert();
prevSettingsOpen.current = settingsOpen;
}, [settingsOpen, fetchAlert]);
// Real-time WS event
useWsEvent("background.error", useCallback((payload: unknown) => {
setError(payload as ProviderErrorPayload);
setDismissed(false);
}, []));
if (!isAdmin || !error || dismissed) return null;
const reasonKey = `alert.reason.${error.reason}`;
const reasonText = t(reasonKey, { defaultValue: t("alert.reason.unknown") });
return (
<div className="flex items-center gap-3 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span className="flex-1">
{t("alert.message", { reason: reasonText })}
</span>
<Button variant="outline" size="sm" onClick={onOpenSettings} className="shrink-0 gap-1.5 h-7 text-xs">
<Settings2 className="h-3.5 w-3.5" />
{t("alert.openSettings")}
</Button>
<button
onClick={() => setDismissed(true)}
className="shrink-0 rounded p-1 hover:bg-destructive/20"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}
+7 -2
View File
@@ -15,7 +15,12 @@ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { AboutDialog } from "./about-dialog";
import { SystemSettingsModal } from "./system-settings-modal";
export function Topbar() {
interface TopbarProps {
settingsOpen: boolean;
onSettingsOpenChange: (open: boolean) => void;
}
export function Topbar({ settingsOpen, onSettingsOpenChange }: TopbarProps) {
const { t } = useTranslation("topbar");
const theme = useUiStore((s) => s.theme);
const setTheme = useUiStore((s) => s.setTheme);
@@ -29,7 +34,7 @@ export function Topbar() {
const isMobile = useIsMobile();
const isDark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
const { status: embStatus } = useEmbeddingStatus();
const [settingsOpen, setSettingsOpen] = useState(false);
const setSettingsOpen = onSettingsOpenChange;
const role = useAuthStore((s) => s.role);
const isAdmin = role === "admin" || role === "owner";
@@ -76,5 +76,16 @@
"maxTokensHint": "Token limit for compacted summary.",
"info": "When a group chat exceeds the threshold, the LLM automatically summarizes older messages into a single compact entry, keeping the most recent ones intact for context."
},
"moreConfig": "More Config"
"moreConfig": "More Config",
"alert": {
"message": "Background workers failed: {{reason}}. Features like memory consolidation and vault enrichment may not work.",
"openSettings": "Fix in Settings",
"reason": {
"auth": "Authentication failed (invalid or expired API key)",
"auth_permanent": "API key revoked or disabled",
"billing": "Billing issue (credits exhausted or payment required)",
"model_not_found": "Model not found (invalid model name or not available)",
"unknown": "Unknown provider error"
}
}
}
@@ -76,5 +76,16 @@
"maxTokensHint": "Giới hạn token cho bản tóm tắt.",
"info": "Khi nhóm chat vượt ngưỡng, LLM tự động tóm tắt các tin nhắn cũ thành một mục gọn, giữ nguyên các tin gần nhất cho ngữ cảnh."
},
"moreConfig": "Cấu hình thêm"
"moreConfig": "Cấu hình thêm",
"alert": {
"message": "Tác vụ nền gặp lỗi: {{reason}}. Các tính năng như tổng hợp bộ nhớ và làm giàu vault có thể không hoạt động.",
"openSettings": "Sửa trong Cài đặt",
"reason": {
"auth": "Xác thực thất bại (API key không hợp lệ hoặc hết hạn)",
"auth_permanent": "API key đã bị thu hồi hoặc vô hiệu hóa",
"billing": "Lỗi thanh toán (hết credit hoặc cần thanh toán)",
"model_not_found": "Không tìm thấy model (tên model không hợp lệ)",
"unknown": "Lỗi nhà cung cấp không xác định"
}
}
}
@@ -76,5 +76,16 @@
"maxTokensHint": "压缩摘要的Token限制。",
"info": "当群聊超过阈值时,LLM自动将旧消息总结为一条紧凑记录,保留最近的消息以保持上下文。"
},
"moreConfig": "更多配置"
"moreConfig": "更多配置",
"alert": {
"message": "后台任务失败:{{reason}}。记忆整合和知识库增强等功能可能无法正常工作。",
"openSettings": "前往设置修复",
"reason": {
"auth": "认证失败(API密钥无效或已过期)",
"auth_permanent": "API密钥已被吊销或禁用",
"billing": "计费问题(余额不足或需要付款)",
"model_not_found": "模型未找到(模型名称无效或不可用)",
"unknown": "未知的提供商错误"
}
}
}