Merge branch 'main' into UX/connections-ux-tweaks

This commit is contained in:
Roland Tannous 2026-05-16 01:55:19 +04:00 committed by GitHub
commit 75646444d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 242 additions and 65 deletions

View file

@ -2870,7 +2870,17 @@ class ExternalProviderClient:
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
containers = data.get("data") if isinstance(data, dict) else None containers = data.get("data") if isinstance(data, dict) else None
return list(containers) if isinstance(containers, list) else [] result = list(containers) if isinstance(containers, list) else []
logger.info(
"openai_container_list.response count=%s items=%s",
len(result),
[
{"id": c.get("id"), "status": c.get("status")}
for c in result
if isinstance(c, dict)
],
)
return result
async def create_openai_container( async def create_openai_container(
self, self,
@ -2901,15 +2911,38 @@ class ExternalProviderClient:
async def delete_openai_container(self, container_id: str) -> None: async def delete_openai_container(self, container_id: str) -> None:
"""DELETE /v1/containers/{id}. 404s are surfaced as HTTPError. """DELETE /v1/containers/{id}. 404s are surfaced as HTTPError.
Uses a fresh httpx client (not the shared ``_http_client``) so
connection-pool state from earlier chat requests cannot
interfere observed in the wild that DELETEs over the shared
pool returned ``deleted: true`` while the container persisted
in subsequent /containers list calls, even though the same
DELETE issued from a fresh client genuinely removed it.
Verifies the response body reports ``deleted: true``. OpenAI Verifies the response body reports ``deleted: true``. OpenAI
returns a 2xx ``deleted: true`` body even when the request is returns a 2xx ``deleted: true`` body even when the request is
silently rejected (e.g. missing OpenAI-Beta header), so a silently rejected (e.g. missing OpenAI-Beta header), so a
status-only check is not sufficient. status-only check is not sufficient.
""" """
response = await _http_client.delete( url = f"{self.base_url}/containers/{container_id}"
f"{self.base_url}/containers/{container_id}", headers = self._container_headers()
headers = self._container_headers(), logger.info(
timeout = self._timeout, "openai_container_delete.outbound url=%s has_auth=%s openai_beta=%s",
url,
"Authorization" in headers,
headers.get("OpenAI-Beta"),
)
async with httpx.AsyncClient(timeout = self._timeout) as fresh_client:
response = await fresh_client.delete(url, headers = headers)
logger.info(
"openai_container_delete.response status=%s cf_ray=%s "
"request_id=%s organization=%s project=%s processing_ms=%s body=%s",
response.status_code,
response.headers.get("cf-ray"),
response.headers.get("x-request-id"),
response.headers.get("openai-organization"),
response.headers.get("openai-project"),
response.headers.get("openai-processing-ms"),
response.text[:300],
) )
response.raise_for_status() response.raise_for_status()
try: try:

View file

@ -650,11 +650,11 @@ class CreateOpenAIContainerBody(OpenAIContainerRequest):
ttl_minutes: int = Field( ttl_minutes: int = Field(
20, 20,
ge = 1, ge = 1,
le = 10080, # 1 week le = 20,
description = ( description = (
"Idle-timeout TTL the new container will inherit (anchor=" "Idle-timeout TTL the new container will inherit (anchor="
"last_active_at). OpenAI's default is 20; we cap at one " "last_active_at). OpenAI hard-caps this at 20 minutes and "
"week as a safety bound." "rejects larger values with integer_above_max_value."
), ),
) )

View file

@ -1716,8 +1716,15 @@ async def list_openai_containers(
status_code = 502, status_code = 502,
detail = f"Failed to reach OpenAI: {exc}", detail = f"Failed to reach OpenAI: {exc}",
) )
# OpenAI keeps expired containers in /v1/containers indefinitely
# with status="expired" — they're effectively dead but still
# listed. Hide them so the picker only shows usable containers.
return ListOpenAIContainersResponse( return ListOpenAIContainersResponse(
containers = [_summarize_container(c) for c in raw if isinstance(c, dict)], containers = [
_summarize_container(c)
for c in raw
if isinstance(c, dict) and c.get("status") != "expired"
],
) )
finally: finally:
await client.close() await client.close()
@ -1766,17 +1773,38 @@ async def delete_openai_container(
current_subject: str = Depends(get_current_subject), current_subject: str = Depends(get_current_subject),
) -> None: ) -> None:
"""Delete a named container by id.""" """Delete a named container by id."""
logger.info(
"openai_container_delete.request subject=%s container_id=%s base_url=%s",
current_subject,
body.container_id,
body.provider_base_url,
)
client = _resolve_openai_cloud_client(body) client = _resolve_openai_cloud_client(body)
try: try:
try: try:
await client.delete_openai_container(body.container_id) await client.delete_openai_container(body.container_id)
logger.info(
"openai_container_delete.success container_id=%s",
body.container_id,
)
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
detail = exc.response.text[:500] if exc.response is not None else str(exc) detail = exc.response.text[:500] if exc.response is not None else str(exc)
logger.warning(
"openai_container_delete.openai_rejected container_id=%s status=%s body=%s",
body.container_id,
exc.response.status_code if exc.response else None,
detail,
)
raise HTTPException( raise HTTPException(
status_code = exc.response.status_code if exc.response else 502, status_code = exc.response.status_code if exc.response else 502,
detail = f"OpenAI rejected /containers delete: {detail}", detail = f"OpenAI rejected /containers delete: {detail}",
) )
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
logger.warning(
"openai_container_delete.transport_error container_id=%s error=%s",
body.container_id,
exc,
)
raise HTTPException( raise HTTPException(
status_code = 502, status_code = 502,
detail = f"Failed to reach OpenAI: {exc}", detail = f"Failed to reach OpenAI: {exc}",

View file

@ -149,3 +149,41 @@ def test_delete_propagates_openai_4xx(monkeypatch):
with pytest.raises(httpx.HTTPStatusError): with pytest.raises(httpx.HTTPStatusError):
_drive(_make_client().delete_openai_container("cntr_missing")) _drive(_make_client().delete_openai_container("cntr_missing"))
def test_list_route_filters_expired_containers(monkeypatch):
"""OpenAI keeps containers in /v1/containers indefinitely with
status="expired" after their idle TTL passes they can't be
used but still show up. The list route must drop them so the
picker only surfaces usable containers."""
from routes import inference as inf_mod
from models.inference import OpenAIContainerRequest
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json = {
"data": [
{"id": "cntr_active", "name": "live", "status": "running"},
{"id": "cntr_dead", "name": "old", "status": "expired"},
{"id": "cntr_unknown", "name": "no-status"},
],
},
)
_mock_http_client(monkeypatch, handler)
def fake_resolve(_body):
return _make_client()
monkeypatch.setattr(inf_mod, "_resolve_openai_cloud_client", fake_resolve)
body = OpenAIContainerRequest(
encrypted_api_key = "enc",
provider_base_url = "https://api.openai.com/v1",
)
response = _drive(inf_mod.list_openai_containers(body, current_subject = "u"))
ids = [c.id for c in response.containers]
assert "cntr_active" in ids
assert "cntr_unknown" in ids # missing status is treated as usable
assert "cntr_dead" not in ids

View file

@ -115,7 +115,10 @@ export async function deleteOpenAIContainer(
}), }),
}, },
); );
if (!response.ok && response.status !== 204) { // 404 = container already gone (deleted elsewhere, or expired-then-purged).
// Treat as idempotent success so a stale list entry doesn't surface as a
// confusing error — the caller will refresh and the entry will disappear.
if (!response.ok && response.status !== 204 && response.status !== 404) {
throw new Error(await parseError(response)); throw new Error(await parseError(response));
} }
} }

View file

@ -59,12 +59,7 @@ import { ensureThreadRecord } from "../runtime-provider";
const AUTO_OPTION_VALUE = "__auto__"; const AUTO_OPTION_VALUE = "__auto__";
const DEFAULT_TTL_MINUTES = 20; const DEFAULT_TTL_MINUTES = 20;
const TTL_MIN = 1; const TTL_MIN = 1;
const TTL_MAX = 10080; // one week — matches backend bound const TTL_MAX = 20; // OpenAI hard cap on expires_after.minutes
// Cadence for re-fetching the container list while the section is
// mounted. OpenAI's container TTL flips at minute granularity, so 30s
// is fast enough that an expired container loses its ACTIVE pill within
// half a minute without hammering /v1/containers.
const REFRESH_POLL_MS = 30_000;
function ageLabel(epochSeconds: number | null | undefined): string { function ageLabel(epochSeconds: number | null | undefined): string {
if (!epochSeconds) return ""; if (!epochSeconds) return "";
@ -114,12 +109,13 @@ export function OpenAICodeExecSection({
const [createTtl, setCreateTtl] = useState<number>( const [createTtl, setCreateTtl] = useState<number>(
provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES, provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES,
); );
// Target row for the destructive confirmation dialog. Held in state // Ids that have been deleted in this session. Once tombstoned, an id
// (rather than blocking with window.confirm) so the dialog sits inside // stays hidden from the picker for the lifetime of the page — OpenAI's
// the settings sheet instead of a native browser alert. // /containers list can keep returning a freshly-deleted id for an
const [pendingDelete, setPendingDelete] = // undocumented and variable amount of time, and an automatic re-show
useState<OpenAIContainerSummary | null>(null); // creates more confusion than it solves. Refreshing the page resets
const [deleting, setDeleting] = useState(false); // the tombstone naturally.
const [tombstones, setTombstones] = useState<Set<string>>(() => new Set());
const thread = useLiveQuery( const thread = useLiveQuery(
async () => (activeThreadId ? db.threads.get(activeThreadId) : undefined), async () => (activeThreadId ? db.threads.get(activeThreadId) : undefined),
@ -127,14 +123,22 @@ export function OpenAICodeExecSection({
); );
const activeContainerId = thread?.openaiCodeExecContainerId ?? null; const activeContainerId = thread?.openaiCodeExecContainerId ?? null;
// Hide just-deleted containers even if OpenAI's list still returns them.
// This is the single chokepoint — every downstream view (sorted picker,
// auto-bind candidate, all-containers list) derives from visibleContainers.
const visibleContainers = useMemo(() => {
if (tombstones.size === 0) return containers;
return containers.filter((c) => !tombstones.has(c.id));
}, [containers, tombstones]);
// Containers sorted newest-first by lastActiveAt so the dropdown's // Containers sorted newest-first by lastActiveAt so the dropdown's
// default (auto-bind target) shows up first. // default (auto-bind target) shows up first.
const sortedContainers = useMemo( const sortedContainers = useMemo(
() => () =>
[...containers].sort( [...visibleContainers].sort(
(a, b) => (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0), (a, b) => (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0),
), ),
[containers], [visibleContainers],
); );
// First running container by lastActiveAt — the auto-bind target and // First running container by lastActiveAt — the auto-bind target and
@ -217,10 +221,18 @@ export function OpenAICodeExecSection({
// the chat-adapter's lazy-create path will mint the first container // the chat-adapter's lazy-create path will mint the first container
// on first send. // on first send.
useEffect(() => { useEffect(() => {
if (!activeThreadId || activeContainerId || !firstRunningContainer) { if (
!activeThreadId ||
activeContainerId ||
visibleContainers.length === 0
) {
return; return;
} }
const candidateId = firstRunningContainer.id; const sorted = [...visibleContainers].sort(
(a, b) => (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0),
);
const candidate = sorted[0];
if (!candidate) return;
void (async () => { void (async () => {
try { try {
await ensureThreadRecord({ await ensureThreadRecord({
@ -234,25 +246,7 @@ export function OpenAICodeExecSection({
// Best-effort; the chat-adapter will inherit/create on send. // Best-effort; the chat-adapter will inherit/create on send.
} }
})(); })();
}, [activeThreadId, activeContainerId, firstRunningContainer]); }, [activeThreadId, activeContainerId, visibleContainers]);
// Stale-bind sweeper: when the active thread is pinned to a container
// that the latest list no longer reports as running (expired by idle
// TTL, or deleted out-of-band), drop the binding so the auto-bind
// effect above can pick a healthy candidate and the chat adapter's
// lazy-create path can mint a fresh container on the next send. Only
// fires when `containers` has actually been fetched at least once to
// avoid clearing on a transient empty list during initial load.
useEffect(() => {
if (!activeThreadId || !activeContainerId) return;
if (containers.length === 0) return;
if (boundContainer && isContainerRunning(boundContainer)) return;
void db.threads
.update(activeThreadId, { openaiCodeExecContainerId: null })
.catch(() => {
/* best-effort cleanup */
});
}, [activeThreadId, activeContainerId, boundContainer, containers.length]);
const ttlValue = provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES; const ttlValue = provider.openaiContainerTtlMinutes ?? DEFAULT_TTL_MINUTES;
@ -304,7 +298,6 @@ export function OpenAICodeExecSection({
toast.success(`Created container ${name}`); toast.success(`Created container ${name}`);
setCreateName(""); setCreateName("");
setCreateOpen(false); setCreateOpen(false);
await refresh();
// Auto-bind the just-created container to the active thread. // Auto-bind the just-created container to the active thread.
// ensureThreadRecord first so the bind lands even when the user // ensureThreadRecord first so the bind lands even when the user
// creates a container before sending the first message — without // creates a container before sending the first message — without
@ -331,6 +324,10 @@ export function OpenAICodeExecSection({
); );
} finally { } finally {
setCreating(false); setCreating(false);
// Refresh even on failure: the request may have partially succeeded
// server-side (created container, lost response), and a re-fetch
// keeps the picker in sync with OpenAI's actual state.
await refresh();
} }
}; };
@ -343,6 +340,14 @@ export function OpenAICodeExecSection({
{ apiKey, baseUrl: provider.baseUrl || null }, { apiKey, baseUrl: provider.baseUrl || null },
id, id,
); );
// Tombstone the id so the picker hides it immediately even if
// OpenAI's list keeps returning it for a while.
setTombstones((prev) => {
if (prev.has(id)) return prev;
const next = new Set(prev);
next.add(id);
return next;
});
// Clear any thread bindings pointing at the now-deleted id. // Clear any thread bindings pointing at the now-deleted id.
const affected = await db.threads const affected = await db.threads
.filter((t) => t.openaiCodeExecContainerId === id) .filter((t) => t.openaiCodeExecContainerId === id)
@ -353,14 +358,15 @@ export function OpenAICodeExecSection({
), ),
); );
toast.success(`Deleted container ${name || id}`); toast.success(`Deleted container ${name || id}`);
setPendingDelete(null);
await refresh();
} catch (err) { } catch (err) {
toast.error( toast.error(
`Delete failed: ${err instanceof Error ? err.message : "Unknown"}`, `Delete failed: ${err instanceof Error ? err.message : "Unknown"}`,
); );
} finally { } finally {
setDeleting(false); // Always refresh so a stale list entry (e.g. container deleted
// elsewhere, or already expired) is purged from the UI even when
// the delete call itself errored.
await refresh();
} }
}; };
@ -374,7 +380,7 @@ export function OpenAICodeExecSection({
htmlFor="openai-container-ttl" htmlFor="openai-container-ttl"
className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg" className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg"
> >
Idle timeout (min) New-container idle timeout (min, max 20)
</label> </label>
<Input <Input
id="openai-container-ttl" id="openai-container-ttl"
@ -409,19 +415,44 @@ export function OpenAICodeExecSection({
/> />
</Button> </Button>
</div> </div>
{isLoading && sortedContainers.length === 0 ? ( {/* When no containers exist yet, render a disabled placeholder
instead of the picker. The first one is created by the
chat-adapter on first send (lazy-create) and will appear
here after the next refresh. */}
{sortedContainers.length === 0 ? (
<div className="h-9 w-full rounded-md border border-primary/40 bg-background px-2 flex items-center text-sm text-muted-foreground">
(none yet will be created on first send)
</div>
) : (
<select
value={displayedContainerId ?? sortedContainers[0].id}
onChange={(e) => onPick(e.target.value)}
disabled={!activeThreadId}
className="h-9 w-full rounded-md border border-primary/40 bg-background px-2 text-sm font-medium shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
>
{sortedContainers.map((c) => (
<option key={c.id} value={c.id}>
{c.name ?? "(unnamed)"} · {c.id.slice(0, 14)}
{c.lastActiveAt ? ` · active ${ageLabel(c.lastActiveAt)}` : ""}
</option>
))}
</select>
)}
</div>
{/* Container list with delete actions labeled and visually
quieter so it's clearly the "all containers, manage them"
area rather than the active selector above. */}
<div className="flex flex-col gap-1.5">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
All containers
</span>
{isLoading && visibleContainers.length === 0 ? (
<Skeleton className="h-16 w-full" /> <Skeleton className="h-16 w-full" />
) : sortedContainers.length > 0 ? ( ) : visibleContainers.length > 0 ? (
<ul className="flex max-h-52 flex-col gap-1 overflow-auto"> <ul className="flex flex-col gap-1 max-h-44 overflow-auto">
{sortedContainers.map((c) => { {visibleContainers.map((c) => {
const running = isContainerRunning(c); const isActive = c.id === activeContainerId;
const isActive = running && c.id === displayActiveId;
const ttlMinutes = c.expiresAfterMinutes ?? DEFAULT_TTL_MINUTES;
const canActivate =
activeThreadId != null && !isActive && running;
const statusLabel = !running
? (c.status ?? "expired")
: null;
return ( return (
<li <li
key={c.id} key={c.id}

View file

@ -237,7 +237,7 @@ function normalizeProvider(raw: ExternalProviderConfig): ExternalProviderConfig
providerType === "openai" && providerType === "openai" &&
typeof raw.openaiContainerTtlMinutes === "number" && typeof raw.openaiContainerTtlMinutes === "number" &&
raw.openaiContainerTtlMinutes >= 1 raw.openaiContainerTtlMinutes >= 1
? Math.min(raw.openaiContainerTtlMinutes, 10080) ? Math.min(raw.openaiContainerTtlMinutes, 20)
: undefined, : undefined,
}; };
} }

View file

@ -3433,10 +3433,52 @@ def extract_archive(archive_path: Path, destination: Path) -> None:
) from exc ) from exc
return target return target
def _try_repair_missing_slash(
member_name: str, link_name: str, archive_names: set[str]
) -> str | None:
"""Some upstream llama.cpp Mac releases (e.g. b9165, b9169) ship
symlinks whose linkname is missing the directory separator AND
the leading character of the file basename between the
top-level dir and the rest of the path:
llama-b9165/libggml-rpc.0.dylib -> llama-b9165ibggml-rpc.0.11.1.dylib
That cannot be resolved as written. Detect the pattern
(linkname starts with the top-level dir name but no following
slash) and search archive entries under that dir for a real
file whose basename ends with the mangled suffix. Only accept
when the suffix uniquely identifies a real archive entry."""
if "/" not in member_name or "/" in link_name:
return None
top, _, _ = member_name.partition("/")
if not link_name.startswith(top) or len(link_name) <= len(top):
return None
bad_suffix = link_name[len(top) :]
if not bad_suffix or bad_suffix.startswith("/"):
return None
prefix = f"{top}/"
candidates = [
name
for name in archive_names
if name.startswith(prefix)
and "/" not in name[len(prefix) :]
and name[len(prefix) :].endswith(bad_suffix)
]
if len(candidates) != 1:
return None
return candidates[0]
def safe_link_target( def safe_link_target(
base: Path, member_name: str, link_name: str, target: Path base: Path,
member_name: str,
link_name: str,
target: Path,
archive_names: set[str],
) -> tuple[str, Path]: ) -> tuple[str, Path]:
normalized = link_name.replace("\\", "/") normalized = link_name.replace("\\", "/")
repaired = _try_repair_missing_slash(member_name, normalized, archive_names)
if repaired is not None:
normalized = repaired
link_path = Path(normalized) link_path = Path(normalized)
if link_path.is_absolute(): if link_path.is_absolute():
raise PrebuiltFallback( raise PrebuiltFallback(
@ -3473,8 +3515,10 @@ def extract_archive(archive_path: Path, destination: Path) -> None:
def extract_tar_safely(source: Path, base: Path) -> None: def extract_tar_safely(source: Path, base: Path) -> None:
pending_links: list[tuple[tarfile.TarInfo, Path]] = [] pending_links: list[tuple[tarfile.TarInfo, Path]] = []
archive_names: set[str] = set()
with tarfile.open(source, "r:gz") as archive: with tarfile.open(source, "r:gz") as archive:
for member in archive.getmembers(): for member in archive.getmembers():
archive_names.add(member.name)
target = safe_extract_path(base, member.name) target = safe_extract_path(base, member.name)
if member.isdir(): if member.isdir():
target.mkdir(parents = True, exist_ok = True) target.mkdir(parents = True, exist_ok = True)
@ -3501,7 +3545,7 @@ def extract_archive(archive_path: Path, destination: Path) -> None:
progressed = False progressed = False
for member, target in unresolved: for member, target in unresolved:
normalized_link, resolved_target = safe_link_target( normalized_link, resolved_target = safe_link_target(
base, member.name, member.linkname, target base, member.name, member.linkname, target, archive_names
) )
if not resolved_target.exists() and not resolved_target.is_symlink(): if not resolved_target.exists() and not resolved_target.is_symlink():
next_round.append((member, target)) next_round.append((member, target))