fix frontend css issue
This commit is contained in:
parent
0f6e33dd76
commit
79952b088b
|
|
@ -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
|
||||
|
|
|
|||
25
CHANGELOG.md
25
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary">{t("save")}</button></div>
|
||||
</form></div>
|
||||
}
|
||||
function ModelSettingsDialog({close}:{close:()=>void}){
|
||||
const[settings,setSettings]=useState<WorkspaceSettings|null>(null);
|
||||
const[provider,setProvider]=useState<ModelProviderId>("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<WorkspaceSettings>("/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 <div className="modal-backdrop"><form className="dialog settings-dialog" onSubmit={async e=>{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)}}}>
|
||||
<div className="dialog-title"><h2>{t("modelSettings")}</h2><button type="button" onClick={close}><X/></button></div>
|
||||
<p className="dialog-lead">{t("modelSettingsHint")}</p>
|
||||
<fieldset className="provider-fieldset"><legend>{t("modelProvider")}</legend>
|
||||
<div className="provider-grid">{(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=><button type="button" key={item.id} className={provider===item.id?"picked":""} onClick={()=>pickProvider(item.id)}>{item.id==="xai"?t("providerXai"):item.id==="opencode-go"?t("providerOpencodeGo"):t("providerOpenaiCompatible")}</button>)}</div>
|
||||
<p className="dialog-lead">{provider==="xai"?t("providerXaiHint"):provider==="opencode-go"?t("providerOpencodeGoHint"):t("providerOpenaiCompatibleHint")}</p>
|
||||
</fieldset>
|
||||
<label>{t("apiKey")}<input type="password" autoComplete="off" value={apiKey} onChange={e=>{setApiKey(e.target.value);setClearKey(false)}} placeholder={keyKept?t("apiKeyStored"):t("apiKeyPlaceholder")}/></label>
|
||||
{!keyKept&&!apiKey&&!clearKey&&<p className="dialog-lead">{settings?.apiKeySet?t("modelKeyNeededForProvider"):t("modelKeyNotSet")}</p>}
|
||||
{keyKept&&<label className="memory-toggle"><input type="checkbox" checked={clearKey} onChange={e=>setClearKey(e.target.checked)}/> {t("clearApiKey")}</label>}
|
||||
{(current?.needsBaseUrl||provider==="openai-compatible")&&<label>{t("baseUrl")}<input value={baseUrl} onChange={e=>setBaseUrl(e.target.value)} placeholder={t("baseUrlPlaceholder")}/><small>{t("baseUrlHint")}</small></label>}
|
||||
<label>{t("modelId")}
|
||||
{models.length>0?<select value={models.some(item=>item.id===modelId)?modelId:""} onChange={e=>setModelId(e.target.value)}>
|
||||
{models.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
{!models.some(item=>item.id===modelId)&&modelId?<option value={modelId}>{modelId}</option>:null}
|
||||
</select>:<input value={modelId} onChange={e=>setModelId(e.target.value)} placeholder={t("modelIdPlaceholder")}/>}
|
||||
{models.length>0&&<input value={modelId} onChange={e=>setModelId(e.target.value)} placeholder={t("modelIdPlaceholder")}/>}
|
||||
</label>
|
||||
<button type="button" className="outline" onClick={()=>void loadModels(provider,baseUrl)}>{t("reloadModels")}</button>
|
||||
{error&&<div className="pane-error">{error}</div>}
|
||||
<div className="dialog-actions"><button type="button" className="outline" onClick={close}>{t("cancel")}</button><button className="primary" disabled={busy||!modelId.trim()||(provider==="openai-compatible"&&!baseUrl.trim())}>{busy?t("saving"):t("save")}</button></div>
|
||||
</form></div>
|
||||
}
|
||||
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"))},[]);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "重試",
|
||||
|
|
|
|||
|
|
@ -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<ProviderDraft, "keyEdited" | "clearKey" | "apiKey">,
|
||||
): string {
|
||||
if (stored && !draft.keyEdited && !draft.clearKey) return STORED_API_KEY_MASK;
|
||||
return draft.apiKey;
|
||||
}
|
||||
|
||||
export function applyApiKeyInput(
|
||||
showingMask: boolean,
|
||||
next: string,
|
||||
): Pick<ProviderDraft, "apiKey" | "keyEdited" | "clearKey"> {
|
||||
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<ProviderDraft, "keyEdited" | "clearKey" | "apiKey">,
|
||||
): { 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);
|
||||
}
|
||||
|
|
@ -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<WorkspaceSettings | null>(null);
|
||||
const [provider, setProvider] = useState<ModelProviderId>("xai");
|
||||
const [drafts, setDrafts] = useState<Partial<Record<ModelProviderId, ProviderDraft>>>({});
|
||||
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<WorkspaceSettings>("/api/workspace/settings")
|
||||
.then((value) => {
|
||||
setSettings(value);
|
||||
setProvider(value.provider);
|
||||
setModels(value.models);
|
||||
const next: Partial<Record<ModelProviderId, ProviderDraft>> = {};
|
||||
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<ProviderDraft>) {
|
||||
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 (
|
||||
<div className="modal-backdrop">
|
||||
<form
|
||||
className="dialog settings-dialog"
|
||||
onSubmit={async (event) => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="dialog-title">
|
||||
<h2>{t("modelSettings")}</h2>
|
||||
<button type="button" onClick={close}><X /></button>
|
||||
</div>
|
||||
<p className="dialog-lead">{t("modelSettingsHint")}</p>
|
||||
<fieldset className="provider-fieldset">
|
||||
<legend>{t("modelProvider")}</legend>
|
||||
<div className="provider-grid">
|
||||
{(settings?.providers.map((item) => item.id) || (["xai", "opencode-go", "openai-compatible"] as ModelProviderId[])).map((id) => (
|
||||
<button type="button" key={id} className={provider === id ? "picked" : ""} onClick={() => pickProvider(id)}>
|
||||
{id === "xai" ? t("providerXai") : id === "opencode-go" ? t("providerOpencodeGo") : t("providerOpenaiCompatible")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="dialog-lead">
|
||||
{provider === "xai" ? t("providerXaiHint") : provider === "opencode-go" ? t("providerOpencodeGoHint") : t("providerOpenaiCompatibleHint")}
|
||||
</p>
|
||||
</fieldset>
|
||||
<label>
|
||||
{t("apiKey")}
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
value={fieldValue}
|
||||
onFocus={(event) => { if (showingMask) event.currentTarget.select(); }}
|
||||
onChange={(event) => patchDraft(provider, applyApiKeyInput(showingMask, event.target.value))}
|
||||
placeholder={stored ? t("apiKeyStored") : t("apiKeyPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
{missingKey ? (
|
||||
<p className="dialog-lead">
|
||||
{settings?.providers.some((item) => item.apiKeySet) || settings?.apiKeySet
|
||||
? t("modelKeyNeededForProvider")
|
||||
: t("modelKeyNotSet")}
|
||||
</p>
|
||||
) : null}
|
||||
{stored ? (
|
||||
<label className="memory-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.clearKey}
|
||||
onChange={(event) => patchDraft(provider, { clearKey: event.target.checked, keyEdited: false, apiKey: "" })}
|
||||
/>
|
||||
{t("clearApiKey")}
|
||||
</label>
|
||||
) : null}
|
||||
{(current?.needsBaseUrl || provider === "openai-compatible") ? (
|
||||
<label>
|
||||
{t("baseUrl")}
|
||||
<input value={draft.baseUrl} onChange={(event) => patchDraft(provider, { baseUrl: event.target.value })} placeholder={t("baseUrlPlaceholder")} />
|
||||
<small>{t("baseUrlHint")}</small>
|
||||
</label>
|
||||
) : null}
|
||||
<label>
|
||||
{t("modelId")}
|
||||
{models.length > 0 ? (
|
||||
<select
|
||||
value={models.some((item) => item.id === draft.modelId) ? draft.modelId : ""}
|
||||
onChange={(event) => patchDraft(provider, { modelId: event.target.value })}
|
||||
>
|
||||
{models.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
{!models.some((item) => item.id === draft.modelId) && draft.modelId ? <option value={draft.modelId}>{draft.modelId}</option> : null}
|
||||
</select>
|
||||
) : (
|
||||
<input value={draft.modelId} onChange={(event) => patchDraft(provider, { modelId: event.target.value })} placeholder={t("modelIdPlaceholder")} />
|
||||
)}
|
||||
{models.length > 0 ? (
|
||||
<input value={draft.modelId} onChange={(event) => patchDraft(provider, { modelId: event.target.value })} placeholder={t("modelIdPlaceholder")} />
|
||||
) : null}
|
||||
</label>
|
||||
<button type="button" className="outline" onClick={() => void loadModels(provider, draft.baseUrl)}>{t("reloadModels")}</button>
|
||||
{error ? <div className="pane-error">{error}</div> : null}
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="outline" onClick={close}>{t("cancel")}</button>
|
||||
<button className="primary" disabled={busy || !draft.modelId.trim() || (provider === "openai-compatible" && !draft.baseUrl.trim())}>
|
||||
{busy ? t("saving") : t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ const ACTIONS: Record<string, RunActionId[]> = {
|
|||
network: ["retry", "settings"],
|
||||
computer_gone: ["screen", "retry"],
|
||||
lease_lost: ["retry"],
|
||||
context_overflow: ["retry"],
|
||||
unknown: ["retry"],
|
||||
};
|
||||
|
||||
|
|
@ -48,6 +49,7 @@ const TITLES: Record<string, MessageKey> = {
|
|||
network: "errorTitleNetwork",
|
||||
computer_gone: "errorTitleComputerGone",
|
||||
lease_lost: "errorTitleLeaseLost",
|
||||
context_overflow: "errorTitleContextOverflow",
|
||||
unknown: "errorTitleUnknown",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] }
|
||||
|
||||
|
|
|
|||
|
|
@ -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::<usize>()
|
||||
+ message_chars(pending)
|
||||
}
|
||||
|
||||
/// Shrink `history` and `pending` until they fit `budget` (or `char_budget()`).
|
||||
pub fn fit_model_context(
|
||||
history: &mut Vec<Message>,
|
||||
pending: &mut Message,
|
||||
preamble: &str,
|
||||
defs_chars: usize,
|
||||
budget: Option<usize>,
|
||||
) -> 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<u64> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -137,6 +137,16 @@ pub struct SpaceRow {
|
|||
pub voice_api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Register a person.
|
||||
///
|
||||
|
|
@ -345,6 +355,40 @@ impl Db {
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn space_model_provider(
|
||||
&self,
|
||||
actor: &Actor,
|
||||
provider: &str,
|
||||
) -> Result<Option<SpaceModelProviderRow>, 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<Vec<SpaceModelProviderRow>, 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<Option<&str>>,
|
||||
) -> Result<SpaceRow, sqlx::Error> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ mod accounts;
|
|||
mod attachments;
|
||||
mod auth;
|
||||
mod computer;
|
||||
mod context_fit;
|
||||
mod db;
|
||||
mod file_skills;
|
||||
mod mcp;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())?;
|
||||
|
|
|
|||
|
|
@ -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<Message>,
|
||||
defs: &[ToolDefinition],
|
||||
trace: Trace<'_>,
|
||||
) -> Result<Vec<AssistantContent>, 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,13 +1766,44 @@ 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,
|
||||
|
|
@ -1726,6 +1811,7 @@ async fn complete_with_retry(
|
|||
);
|
||||
last = error;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let error = "model request timed out after 165 seconds";
|
||||
crate::monitor::record(
|
||||
|
|
@ -1951,26 +2037,10 @@ fn assistant_texts(history: &[Message]) -> Vec<String> {
|
|||
fn shrink_checkpoint(history: &mut Vec<Message>, 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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AppState> {
|
||||
|
|
@ -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<String>, Option<String>) {
|
||||
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<String>, Option<String>), 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<Json<Value>, StatusCode> {
|
||||
let space = state
|
||||
.db
|
||||
|
|
@ -68,12 +152,25 @@ async fn settings_payload(state: &AppState, actor: &Actor) -> Result<Json<Value>
|
|||
.default_model_provider
|
||||
.parse::<ModelProvider>()
|
||||
.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::<Vec<_>>();
|
||||
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::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
|
@ -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)
|
||||
.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 {
|
||||
supplied.map(Some)
|
||||
};
|
||||
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::<ModelProvider>()
|
||||
.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::<Vec<_>>();
|
||||
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\":"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ curl -N -b cookies.txt http://127.0.0.1:3101/api/sessions/<id>/events
|
|||
# 另開一個終端機送訊息,看事件幾毫秒後出現在這條串流裡
|
||||
```
|
||||
|
||||
長任務還有一層上下文保險絲:舊的網頁快照會收成摘要,最新畫面保持完整。這不是另開一個模型做壓縮,而是避免 70 輪 DOM 把 128k 窗口塞爆。細節見 [模型上下文](./operations.md#模型上下文)。
|
||||
|
||||
## 已知取捨
|
||||
|
||||
- 模型回覆還是「整個完成」才出現,沒有串流 token。思考中的狀態有顯示,但要像 ChatGPT 那樣逐字
|
||||
|
|
|
|||
|
|
@ -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),所以會比模型窗口先開始摘要。不要把這個值開到比模型窗口還大:那只會讓失敗發生在供應商那一側,任務一樣中斷。
|
||||
|
||||
## 群組聊天:誰說話
|
||||
|
||||
群組不是廣播。訊息進來的當下就決定好誰要回覆,其他人不被叫醒,也就不會各打一輪模型。
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue