diff --git a/.env.example b/.env.example index 38f6449..d9c019a 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,10 @@ LAZYBOY_MEMORY_ENABLED=true LAZYBOY_MEMORY_MODEL_CACHE=./data/fastembed LAZYBOY_MEMORY_TOP_K=8 LAZYBOY_MEMORY_BYTE_BUDGET=6000 +# 一次模型請求裡,系統提示 + 工具 schema + 對話(含網頁快照)的字元上限。 +# 超過就先把較舊的畫面紀錄收成摘要,再丟掉更舊的輪次,避免整份任務因 +# context 爆掉而失敗。預設對 128k 視窗留有餘地;不要開到比模型窗口還大。 +LAZYBOY_MODEL_CONTEXT_CHARS=200000 # Hourly cleanup of diagnostics; conversations and current memories are retained. LAZYBOY_EVENT_RETENTION_DAYS=30 diff --git a/CHANGELOG.md b/CHANGELOG.md index befa2df..1f2a97d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to LazyBoy are documented here. ## [Unreleased] +Long computer-use runs no longer die when the prompt creeps a few dozen tokens +over the model's context window. Stale browser snapshots are stubbed, oversized +tool dumps are capped, and an `exceed_context_size` response compact-retries +the same turn instead of failing the job. + +Each model provider keeps its own API key, model, and endpoint in the +database. Switching xAI → OpenCode Go no longer wipes the xAI key, and the +settings field shows dots instead of the secret. + Sign in with your own account. The shared install token is gone, and so are the model keys in the environment. @@ -24,9 +33,20 @@ model keys in the environment. - Removed: the token login screen, `LAZYBOY_APP_TOKEN`, and the rule that a non-loopback bind required a 32-character token. `SANDBOX_SUPERVISOR_TOKEN` and `LAZYBOY_VAULT_KEY` are still required in `.env`. +- **Context window fitting.** Long computer-use runs stub stale browser + snapshots, cap tool dumps, and compact-retry on `exceed_context_size` + instead of failing the job 42 tokens over a 128k window. Tune with + `LAZYBOY_MODEL_CONTEXT_CHARS`. +- **Model keys stay put.** Each provider keeps its own key, model, and base + URL. Switching provider no longer clears the previous key; the form shows + dots and never echoes the token. ### 繁體中文 +長時間操作電腦時,舊的網頁快照不再把模型上下文塞爆:較舊的觀察會收成摘要,工具輸出有上限,模型回 `exceed_context_size` 時會壓縮後重試同一輪,而不是整份任務失敗。 + +每個供應商的 API 金鑰、模型與端點會各自記住。從 xAI 換到 OpenCode Go 不會清掉另一家的金鑰;設定畫面只顯示圓點,真正的 token 留在資料庫。 + 改成自己的帳號登入:共享的安裝 token 拿掉了,環境變數裡的模型金鑰也拿掉了。 - **在 App 裡註冊與登入**:密碼以 PBKDF2-HMAC-SHA256(120,000 輪、每組密碼各自的 @@ -41,6 +61,11 @@ model keys in the environment. 用 IP 或 `localhost` 不用設定。 - 移除:token 登入畫面、`LAZYBOY_APP_TOKEN`,以及「綁非 loopback 需 32 字元 token」 的規則。`.env` 仍需要 `SANDBOX_SUPERVISOR_TOKEN` 與 `LAZYBOY_VAULT_KEY`。 +- **模型上下文配額。** 長時間操作電腦時,較舊的網頁快照會收成摘要、工具輸出有上限, + 模型回 `exceed_context_size` 時會壓縮後重試同一輪,而不是只超出幾十個 token 就 + 整份任務失敗。可用 `LAZYBOY_MODEL_CONTEXT_CHARS` 調整。 +- **模型金鑰會記住。** 每個供應商的金鑰、模型與端點各自存在資料庫。從 xAI 換到 + OpenCode Go 不會清掉 xAI 的金鑰;畫面上只顯示圓點,真正的 token 不會再出現。 ## [v0.1.0-alpha] - 2026-09-09 diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 98a7892..273ad92 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -22,12 +22,13 @@ import { clockTime, dayLabel, sameDay } from "./chat-time"; import { HANDOFF_MS, VEIL_FADE_MS, handoffRemaining, keepScreenUrl, nextVeil, viewOnlyFor, viewerPath, type Veil } from "./handoff"; import { Avatar, AvatarLookProvider, AvatarStack, BLOBATAR_BACKGROUNDS, BLOBATAR_EXPRESSIONS, BLOBATAR_SHAPES, DEFAULT_LOOK, persistBlobatarShape, readAvatarLooks, resolveBlobatarShape, writeAvatarLook, type AvatarBackground, type AvatarExpression, type AvatarLook } from "./avatar"; import { dateLocale, getLocale, listJoin, setLocale, t, useLocale, type MessageKey } from "./i18n"; -import type { AvatarShape, Bot, ComputerMode, ComputerStatus, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, MemoryStatus, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings, WorkspaceSettings } from "./types"; +import type { AvatarShape, Bot, ComputerMode, ComputerStatus, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, MemoryStatus, Message, MessageFile, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings } from "./types"; import { ChatMarkdown, MentionText, copyText } from "./markdown"; import { RunProbe, errorActions, errorTitle } from "./run-monitor"; import { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, scheduleWhen, type CronPreset, type ScheduleItem } from "./schedule"; import { CallOverlay, PhoneIcon } from "./call"; import { VoiceSettingsDialog } from "./voice-settings"; +import { ModelSettingsDialog } from "./model-settings"; const blankComputer:ComputerStatus={botId:"",mode:"team",state:"stopped",sharedInput:true,controlHolder:"none",takeoverRequested:false,busyBotName:null,busySessionId:null,busyRunId:null,busyStep:null,usingComputer:false,waitingRunId:null,waitingSessionId:null,queuedRuns:0,display:null,profileMode:"per-bot",screenAvailable:false}; const SESSION_STORE="lazyboy.sessionByBot"; @@ -1086,67 +1087,6 @@ function WorkspaceSettingsDialog({name,setName,showHidden,setShowHidden,rightCol
} -function ModelSettingsDialog({close}:{close:()=>void}){ - const[settings,setSettings]=useState(null); - const[provider,setProvider]=useState("xai"); - const[modelId,setModelId]=useState(""); - const[baseUrl,setBaseUrl]=useState(""); - const[apiKey,setApiKey]=useState(""); - const[clearKey,setClearKey]=useState(false); - const[models,setModels]=useState<{id:string;name:string}[]>([]); - const[busy,setBusy]=useState(false); - const[error,setError]=useState(""); - const current=settings?.providers.find(item=>item.id===provider); - // The stored key belongs to the provider it was entered for; the server drops - // it on a provider switch, so the form must stop claiming one is stored. - const keyKept=Boolean(settings?.apiKeySet)&&provider===settings?.provider; - useEffect(()=>{api("/api/workspace/settings").then(value=>{setSettings(value);setProvider(value.provider);setModelId(value.modelId);setBaseUrl(value.baseUrl);setModels(value.models)}).catch(e=>setError(e instanceof Error?localizeError(e.message):t("loadFailed")))},[]); - async function loadModels(nextProvider:ModelProviderId,nextBaseUrl:string){ - if(nextProvider==="openai-compatible"&&!nextBaseUrl.trim()){setModels([]);return} - try{ - const query=new URLSearchParams({provider:nextProvider}); - if(nextBaseUrl.trim())query.set("baseUrl",nextBaseUrl.trim()); - const result=await api<{models:{id:string;name:string}[]}>(`/api/workspace/models?${query}`); - setModels(result.models); - setModelId(current=>result.models.some(item=>item.id===current)?current:result.models[0]?.id||current); - }catch{if(nextProvider==="openai-compatible")setModels([])} - } - function pickProvider(id:ModelProviderId){ - setProvider(id); - const info=settings?.providers.find(item=>item.id===id); - if(id==="openai-compatible"){ - setModels([]); - setBaseUrl(current=>current.includes("opencode.ai")||current.includes("api.x.ai")?"":current); - if(info?.defaultModel)setModelId(info.defaultModel);else setModelId(""); - return; - } - if(info?.defaultBaseUrl)setBaseUrl(info.defaultBaseUrl); - if(info?.defaultModel)setModelId(info.defaultModel); - void loadModels(id,info?.defaultBaseUrl||""); - } - return
{e.preventDefault();setBusy(true);setError("");try{await api("/api/workspace/settings",{method:"PATCH",body:JSON.stringify({provider,modelId:modelId.trim(),baseUrl:baseUrl.trim()||null,apiKey:clearKey?"":apiKey.trim()||null,clearApiKey:clearKey})});close()}catch(e){setError(e instanceof Error?localizeError(e.message):t("settingsFailed"))}finally{setBusy(false)}}}> -

{t("modelSettings")}

-

{t("modelSettingsHint")}

-
{t("modelProvider")} -
{(settings?.providers||[{id:"xai" as const,name:t("providerXai")},{id:"opencode-go" as const,name:t("providerOpencodeGo")},{id:"openai-compatible" as const,name:t("providerOpenaiCompatible")}]).map(item=>)}
-

{provider==="xai"?t("providerXaiHint"):provider==="opencode-go"?t("providerOpencodeGoHint"):t("providerOpenaiCompatibleHint")}

-
- - {!keyKept&&!apiKey&&!clearKey&&

{settings?.apiKeySet?t("modelKeyNeededForProvider"):t("modelKeyNotSet")}

} - {keyKept&&} - {(current?.needsBaseUrl||provider==="openai-compatible")&&} - - - {error&&
{error}
} -
-
-} function AboutDialog({close}:{close:()=>void}){ const[health,setHealth]=useState<"ok"|"bad"|"…">("…"); useEffect(()=>{fetch("/api/health").then(r=>r.ok?setHealth("ok"):setHealth("bad")).catch(()=>setHealth("bad"))},[]); diff --git a/apps/web/src/locales/en.ts b/apps/web/src/locales/en.ts index 2e70b12..73b8910 100644 --- a/apps/web/src/locales/en.ts +++ b/apps/web/src/locales/en.ts @@ -322,7 +322,7 @@ export const en: { [K in keyof typeof zhTW]: string } = { collapseRightSidebar: "Collapse right sidebar", workspaceSettingsHint: "Avatar, memory, and MCP live in the right-hand tabs. This only changes how this workspace is shown.", modelSettings: "Models", - modelSettingsHint: "This provider and model apply to bots that don’t have their own.", + modelSettingsHint: "Each provider keeps its own key, model, and endpoint. The key stays on the server; this screen only shows dots.", modelProvider: "Model provider", providerXai: "xAI", providerOpencodeGo: "OpenCode Go", @@ -332,7 +332,7 @@ export const en: { [K in keyof typeof zhTW]: string } = { providerOpenaiCompatibleHint: "Self-hosted vLLM, Ollama, LiteLLM, or another OpenAI-compatible endpoint.", apiKey: "API key", apiKeyPlaceholder: "sk-…", - apiKeyStored: "A key is saved. Leave blank to keep it.", + apiKeyStored: "Saved. Dots only — the real key stays on the server.", clearApiKey: "Clear saved key", modelId: "Model", modelIdPlaceholder: "For example: qwen2.5 or llama3.1", @@ -423,7 +423,7 @@ export const en: { [K in keyof typeof zhTW]: string } = { loginPasswordTooLong: "That password is too long.", loginPasswordCharacters: "The password contains characters we can’t use.", modelKeyNotSet: "This workspace has no API key yet — paste one below before an agent can work.", - modelKeyNeededForProvider: "You switched provider, so the stored key no longer applies. Paste a key for this provider, or agents will not be able to start after saving.", + modelKeyNeededForProvider: "No key saved for this provider yet. Paste one, or agents will not be able to start after saving.", agentComputer: "Agent computer", url: "URL", stdio: "stdio", @@ -568,6 +568,7 @@ export const en: { [K in keyof typeof zhTW]: string } = { errorTitleNetwork: "Can’t reach the model", errorTitleComputerGone: "Computer is gone", errorTitleLeaseLost: "Work picked up elsewhere", + errorTitleContextOverflow: "Ran out of model context", errorTitleUnknown: "Something broke", errorChip: "Task failed", errorRetry: "Retry", diff --git a/apps/web/src/locales/zh-TW.ts b/apps/web/src/locales/zh-TW.ts index 240798d..76cc487 100644 --- a/apps/web/src/locales/zh-TW.ts +++ b/apps/web/src/locales/zh-TW.ts @@ -104,12 +104,12 @@ export const zhTW = { phoneAccessDescription: "同一區網的手機用瀏覽器打開這個網址,再用同一個存取 token 登入。", localAddressWarning: "這是本機位址,手機打不開。請改成這台電腦的區網 IP,例如 http://192.168.x.x:3101。", close: "關閉", copied: "已複製", copyUrl: "複製網址", workspaceName: "工作區名稱", showHiddenBots: "顯示已隱藏的機器人", collapseRightSidebar: "收合右側欄", workspaceSettingsHint: "機器人外觀、記憶與 MCP 外掛在右側分頁。這裡只改這個工作區的顯示。", - modelSettings: "模型", modelSettingsHint: "這裡的供應商與模型會套用到還沒單獨指定模型的機器人。", + modelSettings: "模型", modelSettingsHint: "每個供應商的金鑰、模型與端點會各自記住。金鑰留在伺服器,這裡只顯示圓點。", modelProvider: "模型供應商", providerXai: "xAI", providerOpencodeGo: "OpenCode Go", providerOpenaiCompatible: "OpenAI 相容", providerXaiHint: "Grok。金鑰從 console.x.ai 取得。", providerOpencodeGoHint: "OpenCode Go 訂閱。金鑰從 opencode.ai/auth 取得。", providerOpenaiCompatibleHint: "自架 vLLM、Ollama、LiteLLM 或其他 OpenAI 相容端點。", - apiKey: "API 金鑰", apiKeyPlaceholder: "sk-…", apiKeyStored: "已儲存金鑰,留空表示沿用。", + apiKey: "API 金鑰", apiKeyPlaceholder: "sk-…", apiKeyStored: "已儲存。只顯示圓點,真正的金鑰留在伺服器。", clearApiKey: "清除已存金鑰", modelId: "模型", modelIdPlaceholder: "例如:qwen2.5 或 llama3.1", reloadModels: "重新載入模型列表", @@ -148,7 +148,7 @@ export const zhTW = { loginUsernameTooShort: "帳號至少 3 個字元。", loginUsernameTooLong: "帳號最多 32 個字元。", loginUsernameCharacters: "帳號只能用字母、數字與 . _ -。", loginUsernameEdges: "帳號必須以字母或數字開頭與結尾。", loginPasswordTooShort: "密碼至少 8 個字元。", loginPasswordTooLong: "密碼太長了。", loginPasswordCharacters: "密碼含有無法使用的字元。", modelKeyNotSet: "這個工作區還沒有 API 金鑰,貼上金鑰後 agent 才能開始工作。", - modelKeyNeededForProvider: "換了供應商,原本存的金鑰不能沿用;請貼上這個供應商的金鑰,否則儲存後 agent 會無法開始。", + modelKeyNeededForProvider: "這個供應商還沒存過金鑰。請貼上,否則儲存後 agent 會無法開始。", agentComputer: "Agent 電腦", url: "URL", stdio: "stdio", http: "HTTP", sse: "SSE", argumentsPlaceholder: "-y @modelcontextprotocol/server-github", environmentVariablesPlaceholder: "GITHUB_TOKEN=…", urlPlaceholder: "https://mcp.example.com/mcp", headersPlaceholder: "Authorization=Bearer …", @@ -242,6 +242,7 @@ export const zhTW = { errorTitleNetwork: "連不到模型", errorTitleComputerGone: "電腦不見了", errorTitleLeaseLost: "工作被另一邊接手", + errorTitleContextOverflow: "對話超過模型能記住的長度", errorTitleUnknown: "出錯了", errorChip: "任務失敗", errorRetry: "重試", diff --git a/apps/web/src/model-key.ts b/apps/web/src/model-key.ts new file mode 100644 index 0000000..cef9ad7 --- /dev/null +++ b/apps/web/src/model-key.ts @@ -0,0 +1,58 @@ +/** Mask shown in the API key field when a key is already stored. The real + * token never leaves the server; this is only so the field looks filled. */ +export const STORED_API_KEY_MASK = "••••••••"; + +export type ProviderDraft = { + modelId: string; + baseUrl: string; + apiKey: string; + keyEdited: boolean; + clearKey: boolean; +}; + +export function emptyProviderDraft(modelId = "", baseUrl = ""): ProviderDraft { + return { modelId, baseUrl, apiKey: "", keyEdited: false, clearKey: false }; +} + +export function storedApiKeyFieldValue( + stored: boolean, + draft: Pick, +): string { + if (stored && !draft.keyEdited && !draft.clearKey) return STORED_API_KEY_MASK; + return draft.apiKey; +} + +export function applyApiKeyInput( + showingMask: boolean, + next: string, +): Pick { + if (showingMask) { + const value = next.split("•").join(""); + return { apiKey: value, keyEdited: value.length > 0, clearKey: false }; + } + return { apiKey: next, keyEdited: true, clearKey: false }; +} + +/** What to send on save. `apiKey: null` means keep the stored secret. */ +export function apiKeySavePayload( + draft: Pick, +): { apiKey: string | null; clearApiKey: boolean } { + if (draft.clearKey) return { apiKey: "", clearApiKey: true }; + if (!draft.keyEdited) return { apiKey: null, clearApiKey: false }; + const value = draft.apiKey.trim(); + if (!value || value === STORED_API_KEY_MASK) return { apiKey: null, clearApiKey: false }; + return { apiKey: value, clearApiKey: false }; +} + +export function providerHasStoredKey( + settings: { + apiKeySet: boolean; + provider: string; + providers?: { id: string; apiKeySet?: boolean }[]; + } | null, + provider: string, +): boolean { + const listed = settings?.providers?.find((item) => item.id === provider); + if (listed && typeof listed.apiKeySet === "boolean") return listed.apiKeySet; + return Boolean(settings?.apiKeySet && settings.provider === provider); +} diff --git a/apps/web/src/model-settings.tsx b/apps/web/src/model-settings.tsx new file mode 100644 index 0000000..20d48c6 --- /dev/null +++ b/apps/web/src/model-settings.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from "react"; +import { api } from "./api"; +import { t } from "./i18n"; +import { X } from "./animated-icons"; +import { + applyApiKeyInput, + apiKeySavePayload, + emptyProviderDraft, + providerHasStoredKey, + storedApiKeyFieldValue, + type ProviderDraft, +} from "./model-key"; +import type { ModelProviderId, WorkspaceSettings } from "./types"; + +export function ModelSettingsDialog({ close }: { close: () => void }) { + const [settings, setSettings] = useState(null); + const [provider, setProvider] = useState("xai"); + const [drafts, setDrafts] = useState>>({}); + const [models, setModels] = useState<{ id: string; name: string }[]>([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const current = settings?.providers.find((item) => item.id === provider); + const draft = drafts[provider] ?? emptyProviderDraft(); + const stored = providerHasStoredKey(settings, provider); + const showingMask = stored && !draft.keyEdited && !draft.clearKey; + const fieldValue = storedApiKeyFieldValue(stored, draft); + const needsKey = current?.needsKey ?? provider !== "openai-compatible"; + const hasTypedKey = draft.keyEdited && Boolean(draft.apiKey.trim()); + const missingKey = needsKey && !stored && !hasTypedKey && !draft.clearKey; + + useEffect(() => { + api("/api/workspace/settings") + .then((value) => { + setSettings(value); + setProvider(value.provider); + setModels(value.models); + const next: Partial> = {}; + for (const item of value.providers) { + const active = item.id === value.provider; + next[item.id] = emptyProviderDraft( + active ? value.modelId : item.modelId || item.defaultModel || "", + active ? value.baseUrl : item.baseUrl || item.defaultBaseUrl || "", + ); + } + if (!next[value.provider]) { + next[value.provider] = emptyProviderDraft(value.modelId, value.baseUrl); + } + setDrafts(next); + }) + .catch((err) => setError(err instanceof Error ? err.message : t("loadFailed"))); + }, []); + + function patchDraft(id: ModelProviderId, patch: Partial) { + setDrafts((currentDrafts) => { + const previous = currentDrafts[id] ?? emptyProviderDraft(); + return { ...currentDrafts, [id]: { ...previous, ...patch } }; + }); + } + + async function loadModels(nextProvider: ModelProviderId, nextBaseUrl: string) { + if (nextProvider === "openai-compatible" && !nextBaseUrl.trim()) { + setModels([]); + return; + } + try { + const query = new URLSearchParams({ provider: nextProvider }); + if (nextBaseUrl.trim()) query.set("baseUrl", nextBaseUrl.trim()); + const result = await api<{ models: { id: string; name: string }[] }>(`/api/workspace/models?${query}`); + setModels(result.models); + setDrafts((currentDrafts) => { + const previous = currentDrafts[nextProvider] ?? emptyProviderDraft(); + if (previous.modelId.trim()) return currentDrafts; + const modelId = result.models[0]?.id || previous.modelId; + return { ...currentDrafts, [nextProvider]: { ...previous, modelId } }; + }); + } catch { + if (nextProvider === "openai-compatible") setModels([]); + } + } + + function pickProvider(id: ModelProviderId) { + setProvider(id); + const info = settings?.providers.find((item) => item.id === id); + setDrafts((currentDrafts) => { + if (currentDrafts[id]) return currentDrafts; + return { + ...currentDrafts, + [id]: emptyProviderDraft(info?.defaultModel || "", info?.defaultBaseUrl || ""), + }; + }); + const existing = drafts[id]; + const baseUrl = existing?.baseUrl ?? info?.baseUrl ?? info?.defaultBaseUrl ?? ""; + if (id === "openai-compatible" && !baseUrl.trim()) { + setModels([]); + return; + } + void loadModels(id, baseUrl); + } + + return ( +
+
{ + event.preventDefault(); + setBusy(true); + setError(""); + const key = apiKeySavePayload(draft); + try { + await api("/api/workspace/settings", { + method: "PATCH", + body: JSON.stringify({ + provider, + modelId: draft.modelId.trim(), + baseUrl: draft.baseUrl.trim() || null, + apiKey: key.apiKey, + clearApiKey: key.clearApiKey, + }), + }); + close(); + } catch (err) { + setError(err instanceof Error ? err.message : t("settingsFailed")); + } finally { + setBusy(false); + } + }} + > +
+

{t("modelSettings")}

+ +
+

{t("modelSettingsHint")}

+
+ {t("modelProvider")} +
+ {(settings?.providers.map((item) => item.id) || (["xai", "opencode-go", "openai-compatible"] as ModelProviderId[])).map((id) => ( + + ))} +
+

+ {provider === "xai" ? t("providerXaiHint") : provider === "opencode-go" ? t("providerOpencodeGoHint") : t("providerOpenaiCompatibleHint")} +

+
+ + {missingKey ? ( +

+ {settings?.providers.some((item) => item.apiKeySet) || settings?.apiKeySet + ? t("modelKeyNeededForProvider") + : t("modelKeyNotSet")} +

+ ) : null} + {stored ? ( + + ) : null} + {(current?.needsBaseUrl || provider === "openai-compatible") ? ( + + ) : null} + + + {error ?
{error}
: null} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/run-monitor.tsx b/apps/web/src/run-monitor.tsx index 3d44233..2a749df 100644 --- a/apps/web/src/run-monitor.tsx +++ b/apps/web/src/run-monitor.tsx @@ -33,6 +33,7 @@ const ACTIONS: Record = { network: ["retry", "settings"], computer_gone: ["screen", "retry"], lease_lost: ["retry"], + context_overflow: ["retry"], unknown: ["retry"], }; @@ -48,6 +49,7 @@ const TITLES: Record = { network: "errorTitleNetwork", computer_gone: "errorTitleComputerGone", lease_lost: "errorTitleLeaseLost", + context_overflow: "errorTitleContextOverflow", unknown: "errorTitleUnknown", }; diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 45afd7e..c44472e 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -43,7 +43,17 @@ export interface VoiceSettings { models: { id: string; name: string }[]; voices: { id: string; name: string }[]; } -export interface WorkspaceProvider { id:ModelProviderId; name:string; needsBaseUrl:boolean; needsKey:boolean; defaultBaseUrl:string|null; defaultModel:string|null } +export interface WorkspaceProvider { + id: ModelProviderId; + name: string; + needsBaseUrl: boolean; + needsKey: boolean; + defaultBaseUrl: string | null; + defaultModel: string | null; + apiKeySet: boolean; + modelId: string; + baseUrl: string; +} export interface WorkspaceModel { id:string; name:string } export interface WorkspaceSettings { provider:ModelProviderId; modelId:string; baseUrl:string; apiKeySet:boolean; providers:WorkspaceProvider[]; models:WorkspaceModel[] } diff --git a/crates/api/src/context_fit.rs b/crates/api/src/context_fit.rs new file mode 100644 index 0000000..4a37bb0 --- /dev/null +++ b/crates/api/src/context_fit.rs @@ -0,0 +1,453 @@ +//! Fit an in-run conversation into the model's context window. +//! +//! Computer-use runs have no turn quota, so every browser snapshot and shell +//! dump stays in `history` until the provider rejects the prompt. The latest +//! observation must stay readable (element ids live there). Older page dumps +//! are stubbed, long parts are capped, then the oldest turns are dropped. + +use rig_core::completion::message::{ + AssistantContent, Message, ReasoningContent, ToolResultContent, UserContent, +}; + +/// Marker written into a stubbed observation so we do not stub twice. +const STUB_MARK: &str = "…(earlier page dump omitted; use the latest observation)"; +const TRUNCATED_MARK: &str = "\n…(truncated)"; + +/// Conservative stand-in for one high-detail screenshot in the char budget. +const IMAGE_CHARS: usize = 8_000; + +/// Latest history messages that stay "hot": capped, never stubbed. +const KEEP_RECENT: usize = 6; +/// Cap for a single text part on the latest turns / pending. +pub const HOT_PART: usize = 24 * 1024; +/// Cap for stubbed stale observations. +const STUB_PART: usize = 700; +/// Cap for other stale text (assistant thinking). +const COLD_PART: usize = 2 * 1024; + +/// Default payload budget in bytes (preamble + tool schemas + messages). +/// ~2 bytes/token is conservative for mixed CJK; English is cheaper, so this +/// leaves headroom under a 128k-token window after output and images. +const DEFAULT_BUDGET: usize = 200_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FitReport { + pub compacted: bool, + pub dropped: usize, + pub chars_before: usize, + pub chars_after: usize, +} + +/// Bytes we are willing to send as text (+ image stand-ins) in one request. +pub fn char_budget() -> usize { + std::env::var("LAZYBOY_MODEL_CONTEXT_CHARS") + .ok() + .and_then(|value| value.trim().parse().ok()) + .filter(|value: &usize| *value >= 32_000) + .unwrap_or(DEFAULT_BUDGET) +} + +pub fn is_context_overflow(error: &str) -> bool { + let text = error.to_lowercase(); + text.contains("exceed_context_size") + || text.contains("exceeds the available context size") + || text.contains("context_length_exceeded") + || text.contains("maximum context length") + || text.contains("prompt is too long") + || text.contains("context window") + || (text.contains("too many tokens") && text.contains("context")) +} + +/// `n_prompt_tokens` / `n_ctx` when the provider included them in the body. +pub fn parse_context_window(error: &str) -> Option<(u64, u64)> { + let prompt = json_u64(error, "n_prompt_tokens")?; + let ctx = json_u64(error, "n_ctx")?; + (prompt > 0 && ctx > 0).then_some((prompt, ctx)) +} + +/// After an overflow, how many chars we should aim to send next. +pub fn overflow_char_budget(error: &str, chars_now: usize) -> usize { + if let Some((prompt, ctx)) = parse_context_window(error) + && prompt > 0 + { + let ratio = (ctx as f64 * 0.85) / prompt as f64; + return ((chars_now as f64 * ratio) as usize).clamp(32_000, chars_now.saturating_sub(1)); + } + chars_now.saturating_mul(3) / 4 +} + +pub fn truncate_chars(text: &str, max_bytes: usize) -> String { + if text.len() <= max_bytes { + return text.to_string(); + } + let keep = max_bytes.saturating_sub(TRUNCATED_MARK.len()).max(1); + let mut cut = keep.min(text.len()); + while cut > 0 && !text.is_char_boundary(cut) { + cut -= 1; + } + let mut out = text[..cut].to_string(); + out.push_str(TRUNCATED_MARK); + out +} + +pub fn cap_message_parts(message: &mut Message, max_bytes: usize) { + for_each_text_mut(message, |text| { + if text.len() > max_bytes { + *text = truncate_chars(text, max_bytes); + } + }); +} + +pub fn estimate_payload_chars( + preamble: &str, + history: &[Message], + pending: &Message, + defs_chars: usize, +) -> usize { + preamble.len() + + defs_chars + + history.iter().map(message_chars).sum::() + + message_chars(pending) +} + +/// Shrink `history` and `pending` until they fit `budget` (or `char_budget()`). +pub fn fit_model_context( + history: &mut Vec, + pending: &mut Message, + preamble: &str, + defs_chars: usize, + budget: Option, +) -> FitReport { + let budget = budget.unwrap_or_else(char_budget); + let chars_before = estimate_payload_chars(preamble, history, pending, defs_chars); + let mut dropped = 0; + let mut compacted = false; + + compacted |= stub_and_cap(history); + compacted |= cap_message_parts_all(history, pending, HOT_PART, COLD_PART); + + while estimate_payload_chars(preamble, history, pending, defs_chars) > budget + && history.len() > KEEP_RECENT + { + history.remove(0); + dropped += 1; + compacted = true; + } + while estimate_payload_chars(preamble, history, pending, defs_chars) > budget + && history.len() > 2 + { + history.remove(0); + dropped += 1; + compacted = true; + } + if estimate_payload_chars(preamble, history, pending, defs_chars) > budget { + let tight = HOT_PART / 2; + compacted |= cap_message_parts_all(history, pending, tight, tight.min(COLD_PART)); + } + while estimate_payload_chars(preamble, history, pending, defs_chars) > budget + && history.len() > 1 + { + history.remove(0); + dropped += 1; + compacted = true; + } + + FitReport { + compacted, + dropped, + chars_before, + chars_after: estimate_payload_chars(preamble, history, pending, defs_chars), + } +} + +fn stub_and_cap(history: &mut [Message]) -> bool { + let hot_from = history.len().saturating_sub(KEEP_RECENT); + let mut changed = false; + for (index, message) in history.iter_mut().enumerate() { + if index >= hot_from { + continue; + } + changed |= stub_observation_message(message); + cap_message_parts(message, COLD_PART); + } + changed +} + +fn cap_message_parts_all( + history: &mut [Message], + pending: &mut Message, + hot: usize, + cold: usize, +) -> bool { + let hot_from = history.len().saturating_sub(KEEP_RECENT); + let mut changed = false; + for (index, message) in history.iter_mut().enumerate() { + let before = message_chars(message); + cap_message_parts(message, if index >= hot_from { hot } else { cold }); + changed |= message_chars(message) != before; + } + let before = message_chars(pending); + cap_message_parts(pending, hot); + changed || message_chars(pending) != before +} + +fn stub_observation_message(message: &mut Message) -> bool { + let mut changed = false; + for_each_text_mut(message, |text| { + if looks_like_observation(text) && !text.contains(STUB_MARK) { + *text = stub_observation(text); + changed = true; + } + }); + changed +} + +fn looks_like_observation(text: &str) -> bool { + text.contains("Clickable page elements:") + || text.contains("Clickable controls") + || text.contains("Clickable windows") + || text.contains("Visible text:\n") + || text.contains("computer observed") +} + +fn stub_observation(text: &str) -> String { + let head: Vec<&str> = text.lines().take(4).collect(); + let stub = format!("{}\n{STUB_MARK}", head.join("\n")); + if stub.len() > STUB_PART { + truncate_chars(&stub, STUB_PART) + } else { + stub + } +} + +fn message_chars(message: &Message) -> usize { + let mut total = 0; + match message { + Message::System { content } => total += content.len(), + Message::User { content } => { + for part in content { + match part { + UserContent::Text(text) => total += text.text.len(), + UserContent::ToolResult(result) => { + for item in &result.content { + match item { + ToolResultContent::Text(text) => total += text.text.len(), + ToolResultContent::Image(_) => total += IMAGE_CHARS, + ToolResultContent::Json { value } => { + total += value.to_string().len(); + } + } + } + } + UserContent::Image(_) => total += IMAGE_CHARS, + UserContent::Document(doc) => { + total += serde_json::to_string(doc).map(|s| s.len()).unwrap_or(0); + } + _ => {} + } + } + } + Message::Assistant { content, .. } => { + for part in content { + match part { + AssistantContent::Text(text) => total += text.text.len(), + AssistantContent::ToolCall(call) => { + total += + call.function.name.len() + call.function.arguments.to_string().len(); + } + AssistantContent::Reasoning(reasoning) => { + for item in &reasoning.content { + match item { + ReasoningContent::Text { text, .. } + | ReasoningContent::Summary(text) => total += text.len(), + ReasoningContent::Encrypted(data) => total += data.len(), + ReasoningContent::Redacted { data } => total += data.len(), + } + } + } + AssistantContent::Image(_) => total += IMAGE_CHARS, + } + } + } + } + total +} + +fn for_each_text_mut(message: &mut Message, mut visit: impl FnMut(&mut String)) { + match message { + Message::System { content } => visit(content), + Message::User { content } => { + for part in content { + match part { + UserContent::Text(text) => visit(&mut text.text), + UserContent::ToolResult(result) => { + for item in &mut result.content { + if let ToolResultContent::Text(text) = item { + visit(&mut text.text); + } + } + } + _ => {} + } + } + } + Message::Assistant { content, .. } => { + for part in content { + match part { + AssistantContent::Text(text) => visit(&mut text.text), + AssistantContent::Reasoning(reasoning) => { + for item in &mut reasoning.content { + match item { + ReasoningContent::Text { text, .. } + | ReasoningContent::Summary(text) => visit(text), + _ => {} + } + } + } + _ => {} + } + } + } + } +} + +fn json_u64(error: &str, key: &str) -> Option { + let needle = format!("\"{key}\":"); + let rest = error.split(&needle).nth(1)?.trim_start(); + let digits: String = rest.chars().take_while(|ch| ch.is_ascii_digit()).collect(); + digits.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use rig_core::completion::message::{AssistantContent, Message, UserContent}; + + fn user(text: &str) -> Message { + Message::User { + content: vec![UserContent::text(text)], + } + } + + fn assistant(text: &str) -> Message { + Message::Assistant { + id: None, + content: vec![AssistantContent::text(text)], + } + } + + fn observation(page: &str, dump: &str) -> String { + format!( + "browser snapshot\nPage: {page} https://example/{page}\nClickable page elements: [1] Go\nVisible text:\n{dump}" + ) + } + + #[test] + fn the_gmail_overflow_error_is_detected_and_parsed() { + let error = r#"ProviderResponseError: status 400 Bad Request: {"error":{"code":400,"message":"request (132117 tokens) exceeds the available context size (131072 tokens), try increasing it","type":"exceed_context_size_error","n_prompt_tokens":132117,"n_ctx":131072}}"#; + assert!(is_context_overflow(error)); + assert_eq!(parse_context_window(error), Some((132117, 131072))); + let next = overflow_char_budget(error, 200_000); + assert!(next < 200_000); + assert!(next >= 32_000); + } + + #[test] + fn stale_page_dumps_are_stubbed_and_the_latest_stays() { + let latest = observation("inbox", &"unread ".repeat(2000)); + let mut history = Vec::new(); + history.push(user(&observation("github", &"readme ".repeat(2000)))); + history.push(assistant("click 73")); + for turn in 0..8 { + history.push(user(&observation( + "mid", + &format!("step {turn} ").repeat(200), + ))); + history.push(assistant("next")); + } + history.push(user(&observation("oauth", &"consent ".repeat(2000)))); + history.push(assistant("continue")); + let mut pending = user(&latest); + let report = fit_model_context(&mut history, &mut pending, "sys", 0, Some(80_000)); + assert!(report.compacted); + let Message::User { content } = &history[0] else { + panic!("expected user"); + }; + let UserContent::Text(text) = &content[0] else { + panic!("expected text"); + }; + assert!(text.text.contains(STUB_MARK), "{}", text.text); + assert!(!text.text.contains("readme readme")); + let Message::User { content } = &pending else { + panic!("pending"); + }; + let UserContent::Text(text) = &content[0] else { + panic!("pending text"); + }; + assert!(text.text.contains("unread unread")); + assert!(!text.text.contains(STUB_MARK)); + } + + #[test] + fn a_72_turn_trace_fits_a_128k_class_budget() { + let dump = observation("page", &"x".repeat(4_000)); + let mut history = Vec::new(); + for turn in 0..72 { + history.push(user(&format!("{dump} turn {turn}"))); + history.push(assistant("next")); + } + let mut pending = user(&observation("now", &"y".repeat(3_000))); + let before = estimate_payload_chars("preamble", &history, &pending, 10_000); + assert!(before > 200_000, "fixture should start oversized: {before}"); + let report = fit_model_context( + &mut history, + &mut pending, + "preamble", + 10_000, + Some(200_000), + ); + assert!(report.compacted); + assert!(report.chars_after <= 200_000); + let Message::User { content } = &history[0] else { + panic!("expected user"); + }; + let UserContent::Text(text) = &content[0] else { + panic!("expected text"); + }; + assert!( + text.text.contains(STUB_MARK), + "oldest dumps should be stubbed" + ); + let Message::User { content } = &pending else { + panic!("pending"); + }; + let UserContent::Text(text) = &content[0] else { + panic!("text"); + }; + assert!(text.text.contains("yyyy")); + assert!(!text.text.contains(STUB_MARK)); + } + + #[test] + fn tool_result_dumps_are_capped() { + let mut message = Message::User { + content: vec![UserContent::tool_result( + "c1", + "browser", + vec![ToolResultContent::text( + "Clickable page elements:\n".to_string() + &"z".repeat(80_000), + )], + )], + }; + cap_message_parts(&mut message, HOT_PART); + assert!(message_chars(&message) <= HOT_PART + TRUNCATED_MARK.len()); + } + + #[test] + fn truncate_respects_char_boundaries() { + let text = "你好世界".repeat(20); + let out = truncate_chars(&text, 20); + assert!(out.ends_with("…(truncated)")); + assert!(out.is_char_boundary(out.len() - TRUNCATED_MARK.len())); + } +} diff --git a/crates/api/src/db.rs b/crates/api/src/db.rs index da7ad68..219e0eb 100644 --- a/crates/api/src/db.rs +++ b/crates/api/src/db.rs @@ -137,6 +137,16 @@ pub struct SpaceRow { pub voice_api_key: Option, } +#[derive(Debug, Clone, FromRow)] +#[allow(dead_code)] +pub struct SpaceModelProviderRow { + pub space_id: String, + pub provider: String, + pub model_id: String, + pub base_url: Option, + pub api_key: Option, +} + impl Db { /// Register a person. /// @@ -345,6 +355,40 @@ impl Db { .await } + pub async fn space_model_provider( + &self, + actor: &Actor, + provider: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT p.space_id, p.provider, p.model_id, p.base_url, p.api_key + FROM space_model_providers p + JOIN spaces s ON s.id = p.space_id + WHERE p.space_id = $1 AND s.user_id = $2 AND p.provider = $3", + ) + .bind(&actor.space_id) + .bind(&actor.user_id) + .bind(provider) + .fetch_optional(&self.pool) + .await + } + + pub async fn list_space_model_providers( + &self, + actor: &Actor, + ) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT p.space_id, p.provider, p.model_id, p.base_url, p.api_key + FROM space_model_providers p + JOIN spaces s ON s.id = p.space_id + WHERE p.space_id = $1 AND s.user_id = $2", + ) + .bind(&actor.space_id) + .bind(&actor.user_id) + .fetch_all(&self.pool) + .await + } + pub async fn update_workspace_model( &self, actor: &Actor, @@ -353,6 +397,7 @@ impl Db { base_url: Option<&str>, api_key: Option>, ) -> Result { + let mut tx = self.pool.begin().await?; match api_key { Some(key) => { sqlx::query( @@ -367,7 +412,23 @@ impl Db { .bind(model_id) .bind(base_url) .bind(key) - .execute(&self.pool) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO space_model_providers (space_id, provider, model_id, base_url, api_key) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (space_id, provider) DO UPDATE + SET model_id = EXCLUDED.model_id, + base_url = EXCLUDED.base_url, + api_key = EXCLUDED.api_key, + updated_at = now()", + ) + .bind(&actor.space_id) + .bind(provider) + .bind(model_id) + .bind(base_url) + .bind(key) + .execute(&mut *tx) .await?; } None => { @@ -382,10 +443,25 @@ impl Db { .bind(provider) .bind(model_id) .bind(base_url) - .execute(&self.pool) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO space_model_providers (space_id, provider, model_id, base_url) + VALUES ($1, $2, $3, $4) + ON CONFLICT (space_id, provider) DO UPDATE + SET model_id = EXCLUDED.model_id, + base_url = EXCLUDED.base_url, + updated_at = now()", + ) + .bind(&actor.space_id) + .bind(provider) + .bind(model_id) + .bind(base_url) + .execute(&mut *tx) .await?; } } + tx.commit().await?; self.get_space(actor).await?.ok_or(sqlx::Error::RowNotFound) } @@ -780,4 +856,63 @@ mod tests { ); assert!(scoped.contains(&(bob, bob_actor.space_id))); } + + #[sqlx::test(migrations = "../../migrations")] + async fn switching_provider_keeps_the_previous_key(pool: sqlx::PgPool) { + let db = Db { pool }; + let user = db.create_account("sam", "hash").await.unwrap(); + let space_id = db.ensure_default_space(&user).await.unwrap(); + let actor = Actor { + user_id: user, + space_id, + }; + + db.update_workspace_model(&actor, "xai", "grok-4.6", None, Some(Some("xai-key"))) + .await + .unwrap(); + db.update_workspace_model(&actor, "opencode-go", "glm-5.1", None, Some(Some("go-key"))) + .await + .unwrap(); + + let xai = db + .space_model_provider(&actor, "xai") + .await + .unwrap() + .expect("xAI row stays after switching away"); + assert_eq!(xai.api_key.as_deref(), Some("xai-key")); + assert_eq!(xai.model_id, "grok-4.6"); + + let go = db + .space_model_provider(&actor, "opencode-go") + .await + .unwrap() + .unwrap(); + assert_eq!(go.api_key.as_deref(), Some("go-key")); + + let stored = db + .space_model_provider(&actor, "xai") + .await + .unwrap() + .unwrap(); + db.update_workspace_model( + &actor, + "xai", + "grok-4.5", + None, + Some(stored.api_key.as_deref()), + ) + .await + .unwrap(); + + let space = db.get_space(&actor).await.unwrap().unwrap(); + assert_eq!(space.default_model_provider, "xai"); + assert_eq!(space.default_model_id, "grok-4.5"); + assert_eq!(space.default_model_api_key.as_deref(), Some("xai-key")); + let go = db + .space_model_provider(&actor, "opencode-go") + .await + .unwrap() + .unwrap(); + assert_eq!(go.api_key.as_deref(), Some("go-key")); + } } diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index 447b759..0346d5b 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -2,6 +2,7 @@ mod accounts; mod attachments; mod auth; mod computer; +mod context_fit; mod db; mod file_skills; mod mcp; diff --git a/crates/api/src/monitor.rs b/crates/api/src/monitor.rs index 47eca6e..c37710a 100644 --- a/crates/api/src/monitor.rs +++ b/crates/api/src/monitor.rs @@ -325,6 +325,22 @@ pub fn classify_run_error(error: &str) -> RunFailure { false, ); } + if has(&[ + "exceed_context_size", + "exceeds the available context size", + "context_length_exceeded", + "maximum context length", + "prompt is too long", + ]) { + return RunFailure::new( + "context_overflow", + "這一輪對話超過模型能記住的長度,中途的網頁快照把上下文塞滿了。", + "按重試;系統會先把較舊的畫面紀錄收成摘要再繼續,不會重做已完成的步驟。", + true, + false, + false, + ); + } RunFailure::new( "unknown", "我卡住了,這一輪沒有完成。", @@ -551,10 +567,21 @@ mod tests { #[test] fn please_in_a_provider_message_is_not_a_lost_lease() { + assert_eq!( + code( + r#"ProviderResponseError: status 400 Bad Request: {"error":{"message":"request (132117 tokens) exceeds the available context size (131072 tokens)","type":"exceed_context_size_error"}}"# + ), + "context_overflow" + ); + let overflow = classify_run_error("exceed_context_size_error"); + assert_eq!(overflow.code, "context_overflow"); + assert!(overflow.retryable); assert_eq!(code("run lease was lost before completion"), "lease_lost"); assert_eq!(code("run lease lost before tool dispatch"), "lease_lost"); assert_ne!( - code("status 400 Bad Request: Request is missing x-opencode-session. Please see https://opencode.ai/docs/go/"), + code( + "status 400 Bad Request: Request is missing x-opencode-session. Please see https://opencode.ai/docs/go/" + ), "lease_lost" ); } diff --git a/crates/api/src/routing.rs b/crates/api/src/routing.rs index c6bd770..360646e 100644 --- a/crates/api/src/routing.rs +++ b/crates/api/src/routing.rs @@ -451,13 +451,17 @@ async fn router_model( .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); + let (base_url, api_key) = + crate::workspace::credentials_for_provider(&state.db, actor, &space, provider.as_str()) + .await + .map_err(|error| error.to_string())?; let backend = resolve_backend(ResolveModelRequest { provider, model_id: override_id.or(Some(space.default_model_id)), - base_url: space.default_model_base_url.clone(), + base_url, credentials: CredentialChain { bot: None, - space: space.default_model_api_key.clone(), + space: api_key, }, }) .map_err(|error| error.to_string())?; diff --git a/crates/api/src/runs.rs b/crates/api/src/runs.rs index a674b9c..8bf9828 100644 --- a/crates/api/src/runs.rs +++ b/crates/api/src/runs.rs @@ -1036,6 +1036,51 @@ async fn execute_run( } } drop_history_screenshots(&mut history, &pending); + let defs_chars = serde_json::to_string(&defs).map(|s| s.len()).unwrap_or(0); + let fit = crate::context_fit::fit_model_context( + &mut history, + &mut pending, + &preamble, + defs_chars, + None, + ); + if fit.compacted { + tracing::info!( + run_id, + turn = turns, + dropped = fit.dropped, + chars_before = fit.chars_before, + chars_after = fit.chars_after, + "fitted run history into the model context window" + ); + crate::monitor::record( + state, + run_id, + "notice", + json!({ + "turn": turns, + "text": if fit.dropped > 0 { + format!( + "對話太長,已把較舊的網頁快照收成摘要(丟掉 {} 則舊輪次),繼續用最新畫面做事。", + fit.dropped + ) + } else { + "對話太長,已把較舊的網頁快照收成摘要,繼續用最新畫面做事。".to_string() + }, + }), + ) + .await; + let _ = save_harness_checkpoint( + state, + run_id, + lease_owner, + &history, + &pending, + turns, + steering_seq, + ) + .await; + } set_run_progress(state, run_id, MODEL_STEP, turns, turn_limit).await; let model_started = std::time::Instant::now(); let content = tokio::select! { @@ -1055,9 +1100,9 @@ async fn execute_run( } result = complete_with_retry( &model, - pending.clone(), + &mut pending, &preamble, - &history, + &mut history, &defs, Trace { state, run_id, thread_id, bot_id, turn: turns }, ) => result @@ -1394,7 +1439,10 @@ async fn execute_run( call.id.clone(), call.provider.clone(), name, - vec![ToolResultContent::text(&outcome.text)], + vec![ToolResultContent::text(crate::context_fit::truncate_chars( + &outcome.text, + crate::context_fit::HOT_PART, + ))], )); if !outcome.blocks.is_empty() && !outcome.pause { let _ = append_bot_message_with( @@ -1622,13 +1670,17 @@ pub(crate) async fn bot_model( .clone() .filter(|value| !value.is_empty()) .or_else(|| Some(space.default_model_id.clone()).filter(|value| !value.is_empty())); + let (base_url, api_key) = + crate::workspace::credentials_for_provider(&state.db, actor, &space, provider.as_str()) + .await + .map_err(|error| error.to_string())?; let backend = resolve_backend(ResolveModelRequest { provider, model_id, - base_url: space.default_model_base_url.clone(), + base_url, credentials: CredentialChain { bot: None, - space: space.default_model_api_key.clone(), + space: api_key, }, }) .map_err(|error| error.to_string())?; @@ -1674,13 +1726,14 @@ struct Trace<'a> { async fn complete_with_retry( model: &DynModel, - pending: Message, + pending: &mut Message, preamble: &str, - history: &[Message], + history: &mut Vec, defs: &[ToolDefinition], trace: Trace<'_>, ) -> Result, String> { let mut last = String::new(); + let defs_chars = serde_json::to_string(defs).map(|s| s.len()).unwrap_or(0); for attempt in 0..3 { if attempt > 0 { let failure = crate::monitor::classify_run_error(&last); @@ -1704,6 +1757,7 @@ async fn complete_with_retry( match result { Ok(Ok(content)) => return Ok(content), Ok(Err(error)) => { + let overflow = crate::context_fit::is_context_overflow(&error); crate::monitor::record( trace.state, trace.run_id, @@ -1712,19 +1766,51 @@ async fn complete_with_retry( "turn": trace.turn, "attempt": attempt + 1, "error": error, - "gaveUp": attempt == 2 || !retryable_run_error(&error), + "gaveUp": attempt == 2 || (!overflow && !retryable_run_error(&error)), }), ) .await; - if !retryable_run_error(&error) { + if overflow { + let now = crate::context_fit::estimate_payload_chars( + preamble, history, pending, defs_chars, + ); + let budget = crate::context_fit::overflow_char_budget(&error, now); + let fit = crate::context_fit::fit_model_context( + history, + pending, + preamble, + defs_chars, + Some(budget), + ); + tracing::warn!( + attempt = attempt + 1, + dropped = fit.dropped, + chars_before = fit.chars_before, + chars_after = fit.chars_after, + budget, + "model rejected an oversized prompt; compacted and retrying" + ); + crate::monitor::record( + trace.state, + trace.run_id, + "notice", + json!({ + "turn": trace.turn, + "text": "模型說這輪對話超過上下文上限,已再壓縮一次後重試,不會重做已完成的步驟。", + }), + ) + .await; + last = error; + } else if !retryable_run_error(&error) { return Err(error); + } else { + tracing::warn!( + attempt = attempt + 1, + elapsed_ms = started.elapsed().as_millis() as u64, + "model attempt failed: {error}" + ); + last = error; } - tracing::warn!( - attempt = attempt + 1, - elapsed_ms = started.elapsed().as_millis() as u64, - "model attempt failed: {error}" - ); - last = error; } Err(_) => { let error = "model request timed out after 165 seconds"; @@ -1951,26 +2037,10 @@ fn assistant_texts(history: &[Message]) -> Vec { fn shrink_checkpoint(history: &mut Vec, pending: &mut Message) { const LIMIT: usize = 768 * 1024; const MAX_PART: usize = 48 * 1024; - let cap_parts = |message: &mut Message| { - let Message::User { content } = message else { - return; - }; - for part in content.iter_mut() { - let UserContent::Text(text) = part else { - continue; - }; - if text.text.len() > MAX_PART { - let mut cut = MAX_PART; - while cut > 0 && !text.text.is_char_boundary(cut) { - cut -= 1; - } - text.text.truncate(cut); - text.text.push_str("\n…(truncated)"); - } - } - }; - cap_parts(pending); - history.iter_mut().for_each(cap_parts); + crate::context_fit::cap_message_parts(pending, MAX_PART); + history + .iter_mut() + .for_each(|message| crate::context_fit::cap_message_parts(message, MAX_PART)); let fits = |turns: &[Message]| { json!({"harnessHistory": turns, "harnessPending": pending}) .to_string() @@ -3761,5 +3831,11 @@ mod tests { "ProviderResponseError: status 429 Too Many Requests" )); assert!(retryable_run_error("connection reset")); + // 400 overflow is handled inside the model turn (compact + retry). + // Re-queuing the same oversized checkpoint at the worker would just + // bounce. Fitting happens when the run is claimed again. + assert!(!retryable_run_error( + "ProviderResponseError: status 400 Bad Request: exceed_context_size_error" + )); } } diff --git a/crates/api/src/skills.rs b/crates/api/src/skills.rs index 39ca6d2..3707f52 100644 --- a/crates/api/src/skills.rs +++ b/crates/api/src/skills.rs @@ -1014,14 +1014,7 @@ async fn finalize( let distilled = match bot { Some(bot) => { distill( - state, - actor, - &bot, - &row.id, - &row.goal, - &events, - &frames, - &dir, + state, actor, &bot, &row.id, &row.goal, &events, &frames, &dir, ) .await } diff --git a/crates/api/src/workspace.rs b/crates/api/src/workspace.rs index d6a2790..5ad0c17 100644 --- a/crates/api/src/workspace.rs +++ b/crates/api/src/workspace.rs @@ -6,7 +6,7 @@ use lazyboy_contracts::{ModelProvider, catalog_models, default_model_id}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use crate::db::Actor; +use crate::db::{Actor, SpaceModelProviderRow, SpaceRow}; use crate::state::AppState; pub fn router() -> Router { @@ -27,6 +27,9 @@ struct ProviderInfo { needs_key: bool, default_base_url: Option<&'static str>, default_model: Option<&'static str>, + api_key_set: bool, + model_id: String, + base_url: String, } #[derive(Serialize)] @@ -36,7 +39,7 @@ struct ModelChoice { name: String, } -fn provider_info(provider: ModelProvider) -> ProviderInfo { +fn provider_catalog(provider: ModelProvider) -> ProviderInfo { ProviderInfo { id: provider.as_str(), name: match provider { @@ -51,12 +54,93 @@ fn provider_info(provider: ModelProvider) -> ProviderInfo { needs_key: provider.requires_api_key(), default_base_url: provider.default_base_url(), default_model: default_model_id(provider), + api_key_set: false, + model_id: default_model_id(provider).unwrap_or("").to_string(), + base_url: provider.default_base_url().unwrap_or("").to_string(), } } -/// The model settings for one workspace. `apiKeySet` is the whole story now: -/// a key exists only if this person pasted one here, and while it is false no -/// agent can run. +fn nonempty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|item| !item.is_empty()) +} + +/// Key and base URL for one provider: the row saved for it, or the workspace +/// default columns when that provider is the one currently selected. +pub(crate) fn credentials_for( + space: &SpaceRow, + stored: Option<&SpaceModelProviderRow>, + provider: &str, +) -> (Option, Option) { + let from_space = space.default_model_provider == provider; + let base_url = stored + .and_then(|row| nonempty(row.base_url.as_deref()).map(str::to_string)) + .or_else(|| { + if from_space { + space.default_model_base_url.clone() + } else { + None + } + }); + let api_key = stored + .and_then(|row| nonempty(row.api_key.as_deref()).map(str::to_string)) + .or_else(|| { + if from_space { + space.default_model_api_key.clone() + } else { + None + } + }); + (base_url, api_key) +} + +pub(crate) async fn credentials_for_provider( + db: &crate::db::Db, + actor: &Actor, + space: &SpaceRow, + provider: &str, +) -> Result<(Option, Option), sqlx::Error> { + let stored = db.space_model_provider(actor, provider).await?; + Ok(credentials_for(space, stored.as_ref(), provider)) +} + +/// Keep a newly pasted key, otherwise the key already stored for this +/// provider. Switching provider no longer wipes a key that belongs to +/// another provider — that row is left untouched. +fn persist_api_key<'a>( + clear: bool, + supplied: Option<&'a str>, + stored: Option<&'a str>, +) -> Option<&'a str> { + if clear { None } else { supplied.or(stored) } +} + +fn provider_status( + provider: ModelProvider, + space: &SpaceRow, + stored: Option<&SpaceModelProviderRow>, +) -> ProviderInfo { + let mut info = provider_catalog(provider); + let active = space.default_model_provider == provider.as_str(); + let (base_url, api_key) = credentials_for(space, stored, provider.as_str()); + info.api_key_set = nonempty(api_key.as_deref()).is_some(); + info.model_id = if active { + space.default_model_id.clone() + } else { + stored + .map(|row| row.model_id.clone()) + .filter(|value| !value.is_empty()) + .unwrap_or(info.model_id) + }; + info.base_url = if active { + space.default_model_base_url.clone().unwrap_or_default() + } else { + base_url.unwrap_or(info.base_url) + }; + info +} + +/// The model settings for one workspace. `apiKeySet` is a flag only: the +/// secret itself stays in the database and is never returned to the client. async fn settings_payload(state: &AppState, actor: &Actor) -> Result, StatusCode> { let space = state .db @@ -68,12 +152,25 @@ async fn settings_payload(state: &AppState, actor: &Actor) -> Result .default_model_provider .parse::() .unwrap_or(ModelProvider::Xai); + let stored = state + .db + .list_space_model_providers(actor) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let providers = ModelProvider::selectable() + .iter() + .copied() + .map(|item| { + let row = stored.iter().find(|row| row.provider == item.as_str()); + provider_status(item, &space, row) + }) + .collect::>(); Ok(Json(json!({ "provider": provider.as_str(), "modelId": space.default_model_id, - "baseUrl": space.default_model_base_url.unwrap_or_default(), - "apiKeySet": space.default_model_api_key.as_deref().is_some_and(|value| !value.is_empty()), - "providers": ModelProvider::selectable().iter().copied().map(provider_info).collect::>(), + "baseUrl": space.default_model_base_url.clone().unwrap_or_default(), + "apiKeySet": nonempty(space.default_model_api_key.as_deref()).is_some(), + "providers": providers, "models": catalog_models(provider).iter().map(|(id, name)| ModelChoice { id: (*id).into(), name: (*name).into() }).collect::>(), }))) } @@ -120,25 +217,28 @@ async fn update_settings( .get_space(&actor) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let provider_changed = current + let stored = state + .db + .space_model_provider(&actor, provider.as_str()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let stored_key = stored .as_ref() - .is_some_and(|space| space.default_model_provider != provider.as_str()); - let supplied = input - .api_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - // A stored key belongs to the provider it was entered for. Carrying one - // over to a different provider only guarantees an "Incorrect API key" - // error, so it is dropped unless a new key is supplied. - let api_key = if input.clear_api_key || (provider_changed && supplied.is_none()) { - Some(None) - } else { - supplied.map(Some) - }; + .and_then(|row| nonempty(row.api_key.as_deref())) + .or_else(|| { + current.as_ref().and_then(|space| { + if space.default_model_provider == provider.as_str() { + nonempty(space.default_model_api_key.as_deref()) + } else { + None + } + }) + }); + let supplied = nonempty(input.api_key.as_deref()); + let api_key = persist_api_key(input.clear_api_key, supplied, stored_key); state .db - .update_workspace_model(&actor, provider.as_str(), model_id, base_url, api_key) + .update_workspace_model(&actor, provider.as_str(), model_id, base_url, Some(api_key)) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; settings_payload(&state, &actor).await @@ -169,6 +269,16 @@ async fn list_models( .parse::() .map_err(|_| StatusCode::BAD_REQUEST)?; let space = state.db.get_space(&actor).await.ok().flatten(); + let stored = state + .db + .space_model_provider(&actor, provider.as_str()) + .await + .ok() + .flatten(); + let (stored_base, stored_key) = match &space { + Some(space) => credentials_for(space, stored.as_ref(), provider.as_str()), + None => (None, None), + }; let fallback = catalog_models(provider) .iter() .map(|(id, name)| ModelChoice { @@ -178,12 +288,8 @@ async fn list_models( .collect::>(); let live = fetch_remote_models( provider, - query.base_url.as_deref().or(space - .as_ref() - .and_then(|row| row.default_model_base_url.as_deref())), - space - .as_ref() - .and_then(|row| row.default_model_api_key.as_deref()), + query.base_url.as_deref().or(stored_base.as_deref()), + stored_key.as_deref(), ) .await .unwrap_or_default(); @@ -235,3 +341,75 @@ async fn fetch_remote_models( }) .collect()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn space(provider: &str, key: &str) -> SpaceRow { + SpaceRow { + id: "space-1".into(), + user_id: "user-1".into(), + name: "Workspace".into(), + default_model_provider: provider.into(), + default_model_id: "grok-4.6".into(), + default_model_base_url: None, + default_model_api_key: Some(key.into()), + voice_enabled: false, + voice_provider: None, + voice_model_id: None, + voice_id: None, + voice_api_key: None, + } + } + + #[test] + fn persist_api_key_keeps_the_stored_secret_when_the_field_is_blank() { + assert_eq!( + persist_api_key(false, None, Some("stored-key")), + Some("stored-key") + ); + assert_eq!( + persist_api_key(false, Some("new-key"), Some("stored-key")), + Some("new-key") + ); + assert_eq!( + persist_api_key(true, Some("new-key"), Some("stored-key")), + None + ); + assert_eq!(persist_api_key(false, None, None), None); + } + + #[test] + fn credentials_for_uses_the_row_even_when_another_provider_is_active() { + let space = space("opencode-go", "go-key"); + let stored = SpaceModelProviderRow { + space_id: space.id.clone(), + provider: "xai".into(), + model_id: "grok-4.6".into(), + base_url: Some("https://api.x.ai/v1".into()), + api_key: Some("xai-key".into()), + }; + let (base, key) = credentials_for(&space, Some(&stored), "xai"); + assert_eq!(key.as_deref(), Some("xai-key")); + assert_eq!(base.as_deref(), Some("https://api.x.ai/v1")); + } + + #[test] + fn provider_status_never_echoes_the_secret() { + let space = space("xai", "super-secret-token"); + let stored = SpaceModelProviderRow { + space_id: space.id.clone(), + provider: "xai".into(), + model_id: "grok-4.6".into(), + base_url: None, + api_key: Some("super-secret-token".into()), + }; + let encoded = + serde_json::to_string(&provider_status(ModelProvider::Xai, &space, Some(&stored))) + .unwrap(); + assert!(encoded.contains("\"apiKeySet\":true")); + assert!(!encoded.contains("super-secret-token")); + assert!(!encoded.contains("\"apiKey\":")); + } +} diff --git a/crates/harness/src/resolve.rs b/crates/harness/src/resolve.rs index 3fdf621..0a2a93f 100644 --- a/crates/harness/src/resolve.rs +++ b/crates/harness/src/resolve.rs @@ -317,8 +317,13 @@ mod tests { .starts_with("lazyboy/") ); // No conversation id is better than a made-up or malformed one. - assert!(!provider_headers(ModelProvider::OpencodeGo, " ").contains_key("x-opencode-session")); - assert!(!provider_headers(ModelProvider::OpencodeGo, "bad\nvalue").contains_key("x-opencode-session")); + assert!( + !provider_headers(ModelProvider::OpencodeGo, " ").contains_key("x-opencode-session") + ); + assert!( + !provider_headers(ModelProvider::OpencodeGo, "bad\nvalue") + .contains_key("x-opencode-session") + ); assert!(provider_headers(ModelProvider::Xai, "thread-42").is_empty()); } diff --git a/docs/agent-experience.md b/docs/agent-experience.md index b301fe7..1326c88 100644 --- a/docs/agent-experience.md +++ b/docs/agent-experience.md @@ -74,6 +74,8 @@ curl -N -b cookies.txt http://127.0.0.1:3101/api/sessions//events # 另開一個終端機送訊息,看事件幾毫秒後出現在這條串流裡 ``` +長任務還有一層上下文保險絲:舊的網頁快照會收成摘要,最新畫面保持完整。這不是另開一個模型做壓縮,而是避免 70 輪 DOM 把 128k 窗口塞爆。細節見 [模型上下文](./operations.md#模型上下文)。 + ## 已知取捨 - 模型回覆還是「整個完成」才出現,沒有串流 token。思考中的狀態有顯示,但要像 ChatGPT 那樣逐字 diff --git a/docs/operations.md b/docs/operations.md index 6c94f4b..a3224b1 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -24,7 +24,7 @@ 3. `SANDBOX_SUPERVISOR_TOKEN` 與 `LAZYBOY_VAULT_KEY` 必須使用不同的高熵值。 4. 模型仍可能看見任務所需的網頁內容與截圖;密碼、token 與高敏感資料不要放進提示詞。 5. 簡單的 Cloudflare 連線驗證可嘗試一次正常點擊;未通過、其他 CAPTCHA 與 2FA 交由使用者接管。 -6. 模型 API 金鑰只吃各 workspace 在「設定 → 模型」裡貼上的值,不再讀環境變數;沒設定金鑰的 workspace 跑任務會立刻失敗,並在畫面告訴你要去哪裡補。 +6. 模型 API 金鑰只吃各 workspace 在「設定 → 模型」裡貼上的值,不再讀環境變數;沒設定金鑰的 workspace 跑任務會立刻失敗,並在畫面告訴你要去哪裡補。每個供應商各自記住金鑰、模型與端點,切換時不會清掉另一家的金鑰;畫面只顯示圓點,真正的 token 留在資料庫,不會再回傳給瀏覽器。 ## 硬體與資源 @@ -137,6 +137,26 @@ LAZYBOY_RUN_HARD_MINUTES=240 # 時間保險絲 真的很久的工作(大計畫、批量資料處理)不該塞在一個 run 裡,改用排程工作分段跑,比較容易驗證也比較省 token。 +## 模型上下文 + +電腦任務沒有輪數額度,每一輪的瀏覽器快照與終端機輸出都會留在這次 run 的對話裡。模型真正需要的是**最新那一張畫面的 element id**;七十輪之前的整頁 DOM 只會把 128k 窗口塞滿,最後只超出幾十個 token 就整份任務失敗。 + +系統在每次呼叫模型前會: + +1. 只留最新一張截圖(原本就這樣)。 +2. 把較舊的網頁/桌面觀察收成幾行摘要(標題、網址、動作),最新幾輪維持完整。 +3. 單次工具輸出超過約 24 KB 就截斷。 +4. 還是太長就從最舊的輪次往下丟,直到估出來的長度低於 `LAZYBOY_MODEL_CONTEXT_CHARS`。 +5. 若模型仍回 `exceed_context_size`,再壓一次後重試同一輪,不重做已完成的步驟。 + +這不是把整段對話丟進另一個模型做摘要。舊的 element id 本來就不能再用,丟掉它們是對的;最新觀察不能壓掉,否則下一步點擊會失去目標。 + +```bash +LAZYBOY_MODEL_CONTEXT_CHARS=200000 # 一次請求的字元預算(含系統提示與工具結果) +``` + +數字是字元不是 token,而且估得偏保守(約 2 字元 / token),所以會比模型窗口先開始摘要。不要把這個值開到比模型窗口還大:那只會讓失敗發生在供應商那一側,任務一樣中斷。 + ## 群組聊天:誰說話 群組不是廣播。訊息進來的當下就決定好誰要回覆,其他人不被叫醒,也就不會各打一輪模型。 diff --git a/migrations/021_space_model_providers.sql b/migrations/021_space_model_providers.sql new file mode 100644 index 0000000..145df20 --- /dev/null +++ b/migrations/021_space_model_providers.sql @@ -0,0 +1,20 @@ +-- Each provider keeps its own key, model, and base URL so switching +-- xAI → OpenCode Go (and back) does not wipe what was already saved. +-- The active choice still lives on spaces.default_model_*; this table is +-- the memory for the providers that are not currently selected. +-- API keys stay here and are never returned to the client. + +CREATE TABLE IF NOT EXISTS space_model_providers ( + space_id TEXT NOT NULL REFERENCES spaces (id) ON DELETE CASCADE, + provider TEXT NOT NULL, + model_id TEXT NOT NULL DEFAULT '', + base_url TEXT, + api_key TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (space_id, provider) +); + +INSERT INTO space_model_providers (space_id, provider, model_id, base_url, api_key) +SELECT id, default_model_provider, default_model_id, default_model_base_url, default_model_api_key + FROM spaces +ON CONFLICT (space_id, provider) DO NOTHING; diff --git a/tests/frontend.test.mjs b/tests/frontend.test.mjs index 2040840..6581b93 100644 --- a/tests/frontend.test.mjs +++ b/tests/frontend.test.mjs @@ -305,7 +305,7 @@ const monitorBox={exports:{},require:name=>{ }}; vm.runInNewContext(monitorJs,monitorBox); const {formatElapsed,shortDuration,errorActions,errorTitle,trailText}=monitorBox.exports; -const FAILURE_CODES=['interrupted','tool_timeout','model_key','model_key_missing','model_opt_in','model_quota','model_unknown','model_timeout','network','computer_gone','lease_lost','unknown']; +const FAILURE_CODES=['interrupted','tool_timeout','model_key','model_key_missing','model_opt_in','model_quota','model_unknown','model_timeout','network','computer_gone','lease_lost','context_overflow','unknown']; test('run timings stay on one glanceable line',()=>{ assert.equal(formatElapsed(0),'0:00'); @@ -332,6 +332,7 @@ test('every failure code offers at least one action, in the right order',()=>{ assert.equal(buttons('tool_timeout'),'screen,retry'); assert.equal(buttons('interrupted'),'screen,retry'); assert.equal(buttons('lease_lost'),'retry'); + assert.equal(buttons('context_overflow'),'retry'); for(const code of [...FAILURE_CODES,'made_up',undefined,null]){ const actions=errorActions(code); assert.ok(actions.length>0,String(code)); @@ -779,3 +780,39 @@ test('messages split by calendar day and show only their clock time',()=>{ assert.equal(clockTime(new Date(2026,9,7,15,30),'en'),'15:30'); assert.equal(clockTime('garbage','en'),''); }); + +const keyJs=ts.transpileModule(fs.readFileSync('apps/web/src/model-key.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText; +const keyBox={exports:{},require:()=>{throw new Error('unexpected import')}}; +vm.runInNewContext(keyJs,keyBox); +const {STORED_API_KEY_MASK,storedApiKeyFieldValue,applyApiKeyInput,apiKeySavePayload,providerHasStoredKey,emptyProviderDraft}=keyBox.exports; + +test('a stored API key is shown as dots and never sent back',()=>{ + const stored=emptyProviderDraft(); + assert.equal(storedApiKeyFieldValue(true,stored),STORED_API_KEY_MASK); + const keep=apiKeySavePayload(stored); + assert.equal(keep.apiKey,null); + assert.equal(keep.clearApiKey,false); + const typed=applyApiKeyInput(true,STORED_API_KEY_MASK+'sk-new'); + assert.equal(typed.apiKey,'sk-new'); + assert.equal(typed.keyEdited,true); + const replaced=apiKeySavePayload(typed); + assert.equal(replaced.apiKey,'sk-new'); + assert.equal(replaced.clearApiKey,false); + const cleared=apiKeySavePayload({...stored,clearKey:true}); + assert.equal(cleared.apiKey,''); + assert.equal(cleared.clearApiKey,true); + const masked=apiKeySavePayload({apiKey:STORED_API_KEY_MASK,keyEdited:true,clearKey:false}); + assert.equal(masked.apiKey,null); + assert.equal(masked.clearApiKey,false); +}); + +test('each provider remembers whether it already has a key',()=>{ + const settings={apiKeySet:true,provider:'xai',providers:[ + {id:'xai',apiKeySet:true}, + {id:'opencode-go',apiKeySet:false}, + ]}; + assert.equal(providerHasStoredKey(settings,'xai'),true); + assert.equal(providerHasStoredKey(settings,'opencode-go'),false); + assert.equal(providerHasStoredKey({apiKeySet:true,provider:'xai'},'xai'),true); + assert.equal(providerHasStoredKey({apiKeySet:true,provider:'xai'},'opencode-go'),false); +});