Commit graph

5,532 commits

Author SHA1 Message Date
Roland Tannous
cf7dec1f13 Studio: grey out RAG pill when the model can't call tools
RAG retrieval runs entirely through the local search_knowledge_base
tool. If the loaded model doesn't support tool calling (e.g. a
safetensors model whose template advertises tools in an unparsable
emission format, so supports_tools is suppressed), enabling RAG does
nothing — the model never calls the tool. The pill stayed lit and
clickable, which was misleading.

Gate the RAG pill on supportsTools (in addition to modelLoaded), the
same condition web/code use when there's no provider builtin. Applied
to both composer surfaces (shared-composer and the in-thread
RagToggle), with a 'RAG needs a model that supports tool calling'
tooltip on the disabled state.
2026-05-28 16:02:50 +04:00
Roland Tannous
f30c0a48dd Studio: don't duplicate composer chip when re-uploading an indexed doc
The backend dedups re-uploads and the sidepanel shows the doc once, but
the composer's pending-doc chips are created per addDoc call, so each
re-upload of an already-indexed file appended another 'Ready' chip for
the same document. In the already_indexed branch, if a chip with the
returned documentId already exists, drop the chip we just added instead
of marking it ready — so the composer shows each document only once.
2026-05-28 15:33:17 +04:00
Roland Tannous
266342a64e Studio: skip re-indexing an already-indexed document (content-hash dedup)
Re-uploading the same file into the same scope (KB or thread) used to
parse, chunk, caption and embed it all over again, creating a duplicate
set of chunks. Dedup by content hash instead:

  - schema: add rag_documents.content_hash (sha256 of the bytes) via the
    standard PRAGMA/ALTER migration, plus (scope, content_hash) indexes.
  - upload: _save_upload now streams the bytes through sha256 and returns
    the digest alongside path/name/size.
  - _start_ingestion: before inserting, look for a COMPLETED row in the
    same scope with the same hash. If found, delete the redundant upload
    from disk and return the existing document_id with already_indexed=
    true and an empty job_id — no ingestion job is started. Only
    'completed' rows dedup, so a failed/in-flight prior attempt can still
    retry. Scope-local: the same file in two KBs is indexed in each.
  - frontend: UploadResponse.already_indexed flows through the rag-store
    (skips job subscription) into both upload paths, which mark the chip
    ready immediately and toast '<file> is already indexed'.

Pre-existing rows have NULL content_hash and won't dedup until
re-uploaded once under the new path. Not build/UI-verified here (no bun
in this env); needs typecheck + browser check.
2026-05-28 15:19:06 +04:00
Roland Tannous
d55e5d1474 Revert "Studio: inline DOCX preview via docx-preview + DOMPurify"
This reverts commit ba78141ac5.
2026-05-28 15:08:19 +04:00
Roland Tannous
32e57fa1c5 Revert "Studio: render embedded images in DOCX preview"
This reverts commit f4b34f71c5.
2026-05-28 15:08:19 +04:00
Roland Tannous
f4b34f71c5 Studio: render embedded images in DOCX preview
docx-preview defaults to blob: URLs for embedded images, which DOMPurify
strips from img src (blob: isn't in its default allowed-URI list), so
figures vanished after sanitize. Switch docx-preview to useBase64URL so
images inline as data: URIs, and add ADD_DATA_URI_TAGS: ['img'] to the
DOMPurify config so those data: URIs survive sanitization. Script /
handler / javascript: stripping is unchanged.
2026-05-28 14:59:52 +04:00
Roland Tannous
ba78141ac5 Studio: inline DOCX preview via docx-preview + DOMPurify
Previously a DOCX citation only showed the extracted snippet + a
Download button (Risk #3: never render a user-supplied .docx inline).
Add a faithful inline render that keeps that guarantee:

  - New PreviewDocxView renders the .docx with docx-preview into an
    off-screen element, then injects DOMPurify-sanitized HTML into the
    live DOM (keeping <style> for docx-preview's scoped layout CSS).
    Script tags, event handlers and javascript: URLs are stripped, so
    a malicious .docx can't execute in the app origin.
  - preview-store now fetches the raw bytes for docx and exposes them
    via previewBlob, but deliberately keeps previewBlobUrl = null — no
    object URL is created, so the 'open raw original inline' path stays
    disabled (Risk #3) and Download remains the only raw-file path.
  - preview-panel routes docx -> PreviewDocxView when a blob is present,
    falling back to the text-view snippet otherwise. isInlineBlobAllowed
    still returns false for docx, so html/unknown behaviour is unchanged.
  - Deps: docx-preview + dompurify added to package.json.
  - Tests updated: docx now asserts bytes-fetched-without-object-URL.

Not build/UI-verified in this environment (deps not installed here);
needs bun install + browser check.
2026-05-28 14:53:06 +04:00
Roland Tannous
3a0c774795 Studio: humanize RAG ingest stage labels and completion toast
- Add human-readable stage labels for caption_images ('Captioning
  images') and extract_images ('Extracting images') so the raw
  underscore stage names no longer leak into the progress toast.
- On completion, the toast title is now 'RAG index ready' (was
  'Indexed') and the body reads '1 document and N chunk(s) indexed'
  (was 'Indexed N chunks'), with chunk pluralization.
2026-05-28 14:06:27 +04:00
Roland Tannous
3173689b59
Merge branch 'main' into feature/rag 2026-05-28 13:46:11 +04:00
Roland Tannous
95622dc405 Studio: don't leak exception details in RAG warmup/precache responses
CodeQL flagged information exposure through an exception in the /warmup
and /reranker/precache endpoints: both returned str(exc) in the JSON
body, exposing internal paths and stack details to the client. Keep
the full exception in the server-side warning log and return a generic
error message ('Failed to load embedder' / 'Failed to download
reranker') to the caller instead. The frontend only surfaces the
message in a toast, so a generic string is sufficient.
2026-05-28 13:42:46 +04:00
Roland Tannous
290201f62e Studio: trim captioner logs to invoked+complete, render subprocess logs as JSON
Two changes to the RAG captioning log output:

  - Drop the noisy per-image and path-selection info lines
    (using-chat-VLM, loading-helper, per-image done). Only the
    'caption_images: invoked' and 'caption_images: complete' lines
    remain; warnings for genuine failures (helper load, per-image
    request, helper unload) are kept.
  - Configure structlog at the top of the ingestion subprocess worker
    with the same env the parent uses. The worker runs in a spawned
    process where structlog was never set up, so its logs fell back to
    structlog's dev ConsoleRenderer ([info] ...) instead of the JSON
    renderer the rest of the app uses. Now captioner/parser logs from
    the subprocess match the parent's JSON format.
2026-05-28 13:39:44 +04:00
alkinun
185ff00c62
Fix non-streaming GGUF chat completion usage (#5781)
* Fix GGUF non-stream chat completion usage

* Handle nullable GGUF completion usage

---------

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
2026-05-28 13:28:52 +04:00
pre-commit-ci[bot]
d6a7c9f8c7 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-28 07:50:36 +00:00
Etherll
f25ea25570 Merge branch 'feature/rag' of https://github.com/unslothai/unsloth into feature/rag 2026-05-28 10:49:59 +03:00
Roland Tannous
7e3e69db3f Studio: raise ingestion toast stack above the settings sheet
The toast stack and the chat settings sheet were both z-50, so an open
side panel (rendered later in the DOM) covered the indexing toast. Bump
the stack to z-[9999] — comfortably above the sheet's z-50 — so the
ingestion toast stays visible like the Sonner reranker toast does.
2026-05-28 11:41:39 +04:00
Etherll
c0f8d486a4 Studio: fix RAG PDF main page rendering as a thin white strip
The thumbnail-rail refactor hoisted <Document> to wrap both the rail and
the main page so the PDF loads once. That moved the width-measuring scroll
container INSIDE <Document>, which only renders its children after the PDF
finishes loading. The old `useEffect(..., [])` ran on component mount —
when the container was still absent — so the ResizeObserver never attached,
`width` stayed null, and the main <Page> collapsed to width 0.

Replace the mount-effect measurement with a callback ref: the
ResizeObserver now attaches the instant the container node mounts,
regardless of when that happens relative to PDF load. Disconnects cleanly
on unmount / re-attach.

Verified: tsc clean, vite build succeeds, preview-pdf-smoke (incl. the
resize/debounce case) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:36:20 +03:00
Matt Van Horn
15d70a1d7b
fix: honor --ctx-size and other forwarded args from unsloth studio run in Studio's context-fit logic (#5815)
* fix: honor --ctx-size and other forwarded args from `unsloth studio run` in Studio's context-fit logic

* refactor: extract resolve_requested_ctx as single source of truth

The test helper was reimplementing the two-line
'ctx_override = parse_ctx_override(...); requested_ctx = ctx_override
if ctx_override is not None else n_ctx' pattern locally, so the test
asserted against its own reimplementation rather than production logic.
Extract the conditional into resolve_requested_ctx and have both the
production caller and the test use it.

* fix(studio): honor pass-through cache type flags in KV VRAM estimate

Studio's KV cache VRAM estimate computed from the first-class
cache_type_kv even when the user passed -ctk/--cache-type-k/-ctv/
--cache-type-v via extras. Those flags reached llama-server fine
(last-wins on the CLI) but the pre-launch estimate kept using the
default f16 bytes-per-element, so GPU placement decisions could be
off when the user lowered cache precision via pass-through.

Adds parse_cache_override + resolve_cache_type_kv in llama_server_args.py
(mirroring parse_ctx_override / resolve_requested_ctx), wires both into
load_model alongside the existing ctx resolution, and adds focused
unit tests for the parser + resolver.

Follow-up to @rolandtannous review on #5815.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-05-28 11:34:35 +04:00
Roland Tannous
3117cb5f99 Revert "Studio: restore draft thread (and its RAG docs) across page reloads"
This reverts commit d652f03b6e.
2026-05-28 11:32:22 +04:00
Roland Tannous
d652f03b6e Studio: restore draft thread (and its RAG docs) across page reloads
assistant-ui mints a fresh __LOCALID_* draft id on every page load, so
RAG docs uploaded under the previous draft id were orphaned after a
refresh — the doc panel queries useThreadDocuments(activeThreadId) and
the new id had nothing.

Persist activeThreadId in localStorage and, on the first settled render
after load, have ActiveThreadSync ask aui to switchToThread(persisted)
when it differs from the freshly-minted draft. Because the draft was
already persisted to the backend by initialize()/ensureThreadRecord
when its first doc was uploaded, the adapter's fetch() resolves it and
aui adopts it as mainThreadId. That keeps aui's mainThreadId and our
activeThreadId unified, so the earlier divergence (uploads under the
persisted id vs chat-completion reading aui's fresh id) can't recur —
unlike the reverted localStorage-only attempt, the chat-adapter's
unstable_threadId now equals the persisted id after the switch.

A one-shot ref ensures we only re-adopt on initial load; user-driven
new-chat / thread switches still flow through normally. If the
persisted draft was never initialized (no doc/message, not in the
backend), switchToThread rejects and we fall back to the fresh draft.
2026-05-28 10:26:12 +04:00
Etherll
79fc69741c Studio: post-merge build fixes — drop dead score handling, dedupe activeThreadId, add knowledgeBases i18n key
Follow-ups after merging origin/main into feature/rag:

* chat-adapter.ts: drop `score` field from DocumentSourcePart and the
  `chunk.score` copy — the remote "hide RAG retrieval scores from chunks,
  citations, and side panel" commit removed `score` from ParsedChunk.
* chat-settings-sheet.tsx: remove the duplicate `const activeThreadId =`
  introduced by the merge (kept the HEAD-side declaration at line 497).
* chat-settings-sheet.tsx: drop the "Min relevance" Slider that referenced
  `ragMinScore` / `setRagMinScore` — same intent as the hide-scores commit
  (these are still on the runtime store but the side-panel UI is gone).
* i18n locales (en, zh-CN): add `settings.tabs.knowledgeBases` translation
  key so the new TabDef entry passes the TranslationKey union check.

Verified: `tsc --noEmit` clean, `vite build` succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:20:22 +03:00
Etherll
9eb0778628 Merge remote-tracking branch 'origin/main' into feature/rag
# Conflicts:
#	studio/backend/core/inference/llama_cpp.py
#	studio/backend/routes/__init__.py
#	studio/backend/routes/inference.py
#	studio/frontend/package.json
#	studio/frontend/src/components/assistant-ui/sources.tsx
#	studio/frontend/src/components/assistant-ui/thread.tsx
#	studio/frontend/src/features/chat/api/chat-adapter.ts
#	studio/frontend/src/features/chat/chat-settings-sheet.tsx
#	studio/frontend/src/features/chat/shared-composer.tsx
#	studio/frontend/src/features/chat/stores/chat-runtime-store.ts
#	studio/frontend/src/features/settings/settings-dialog.tsx
2026-05-28 00:38:58 +03:00
Etherll
27b0a50a84 Studio: WIP — RAG preview UI, locator/auth refactor, tests, fixtures (pre-merge snapshot)
Snapshot taken before fast-forwarding feature/rag to origin and merging main.
Bundles in-flight work so the merge has a clean tree:

Frontend
- PDF preview panel (preview-panel, preview-pdf-view, preview-text-view,
  preview-unavailable) with lazy-rendered page thumbnail rail
- Resizable preview slot via useResizablePanelWidth hook (drag handle,
  localStorage persistence, viewport clamping)
- Neutral scrollbar + Source Excerpt card restyle (no brand-coloured rail)
- Preview-store + chat-adapter / rag-api / kb-detail wiring
- Frontend test harness (vitest.config, setupTests, biome update) and the
  paired __tests__ suites for preview, sources, document-row, chat-adapter,
  rag-api, knowledge-bases-tab, search-knowledge-base-tool-ui

Backend
- RAG locator + authorization modules with chunking / retrieval / tool /
  vector_store / studio_db updates
- Paired test_rag_* suites (authorization, locators, locator_backfill,
  locator_migration, preview_routes, preview_target_locators, source_identity)

Other
- tests/fixtures/rag-preview for preview route fixtures (sample.pdf,
  sample.txt, make_fixture_pdf.py)
- .gitignore + package(-lock).json adjustments for the new test runner

Will be squashed/reworked via interactive rebase after main is merged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:13:43 +03:00
Roland Tannous
dd3ee02648 Studio: revert activeThreadId persistence (caused fresh-chat doc loss)
The two previous commits (41e43b6c7 and 3d3a00c2b) persisted
activeThreadId in localStorage so RAG docs would survive a page
reload. That broke fresh chats: stale localStorage values from a
prior session pinned activeThreadId to an old draft id, but the
chat-completion path reads aui's current mainThreadId via
unstable_threadId. The two diverged — uploads went under the stale
persisted id, the chat-completion turn looked up docs under the new
aui id, and nothing matched.

Revert the persistence + ActiveThreadSync guard. We're back to the
pre-fix behaviour where uploads-in-the-same-session work, and a
proper fix for the reload case (promote drafts to real chat_threads
rows on first doc upload so the id never changes) will land next.
2026-05-27 22:31:05 +04:00
Roland Tannous
3d3a00c2be Studio: stop ActiveThreadSync clearing persisted draft on reload
ActiveThreadSync was reacting to aui's mainThreadId === null on mount
(aui hasn't booted yet) by calling setActiveThreadId(null), which
wiped the just-restored persisted draft id from localStorage and
emptied the doc panel for the user's thread. The previous fix only
covered the 'aui minted a different LOCALID' branch; it missed the
'mainThreadId is null while aui boots' branch.

Treat a null mainThreadId as a no-op for the sync. Explicit clears
(new chat, sidebar delete) keep going through setActiveThreadId(null)
directly, so this guard doesn't trap stale state — it just gives the
persisted draft id a chance to survive until aui finishes booting.
2026-05-27 21:46:52 +04:00
Lee Jackson
99e1f67322
Studio: remove dark mode upload circle (#5813)
* style: remove dark mode upload circle

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-27 10:46:49 -07:00
Daniel Han
a62eb80f7c
Detect CUDA UMD Version from newer nvidia-smi output (fixes #5812) (#5817)
* Detect CUDA UMD Version from newer nvidia-smi output (#5812)

Newer NVIDIA drivers (e.g. 610.x on Windows) print the driver's max
CUDA capability as "CUDA UMD Version: X.Y" instead of the legacy
"CUDA Version: X.Y" header.  The installers and Studio setup scripts
were only matching the legacy spelling, so on a fresh RTX 5090
laptop with a 13.x driver they failed to detect any CUDA version
and fell through to the cu126 wheel default.

Accept both spellings everywhere we parse nvidia-smi output:

- install.ps1: Get-TorchIndexUrl regex now allows " UMD"
- install.sh: two-expression sed (POSIX BRE has no "?"); the two
  patterns are mutually exclusive per line, head -1 picks the match
- studio/setup.ps1: Get-PytorchCudaTag and the $DriverMaxCuda
  detector both relaxed
- studio/install_llama_prebuilt.py: substring scan replaced with a
  regex search using the same pattern
- tests/sh/test_get_torch_index_url.sh: new make_mock_smi_umd helper
  plus three UMD cases (13.3 -> cu130, 12.8 -> cu128, 11.8 -> cu118);
  all 30 tests pass locally

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-27 10:37:21 -07:00
Roland Tannous
41e43b6c7b Studio: persist activeThreadId across reloads so RAG docs survive
When the user uploads a doc to a brand-new chat (a draft thread with
an assistant-ui __LOCALID_* id), the backend stores rag_documents
rows scoped to that id. On page reload assistant-ui mints a fresh
__LOCALID_* for the new mainThreadId, so useThreadDocuments asks the
backend for docs under the NEW id and gets nothing, even though the
original rows are still on disk under the OLD id.

  - chat-runtime-store: persist activeThreadId in localStorage via
    a new CHAT_ACTIVE_THREAD_KEY, restore on init, save on every
    setActiveThreadId call (including clears, which write '').
  - ActiveThreadSync: when aui's freshly-minted mainThreadId is a
    __LOCALID_* and we already have a persisted __LOCALID_* draft,
    keep ours instead of overwriting. This only affects RAG/doc
    lookup; aui's chat history for the new draft starts empty
    either way, so there's no regression for users who don't have
    attached docs.

User-initiated thread switches (new chat, switching to a saved
thread, deleting the current thread) all go through setActiveThreadId
with the new id (or null), so they correctly replace/clear the
persisted value.
2026-05-27 21:33:51 +04:00
Roland Tannous
6c1761f16c Studio: force RAG pill off on model load; drop auto-enable migration
RAG is opt-in but the chat store had a hydration-time migration that
silently flipped ragToolEnabled to true whenever a persisted ragSource
was anything other than 'off'. Plus there was no logic to reset the
pill across model loads, so the pill stayed on across sessions even
after the user explicitly disabled and re-enabled it.

  - Remove the migration block in chat-runtime-store.hydrate — the
    embedder warmup still runs when ragToolEnabled is genuinely
    persisted true.
  - In use-chat-model-runtime's load-success handler, call
    setRagToolEnabled(false) (via the setter, so localStorage stays
    in sync) immediately after the loaded-state setState batch. Every
    fresh model load now starts with the pill off and the user must
    toggle it explicitly.
2026-05-27 21:18:04 +04:00
Roland Tannous
29e8cad8b8 Studio: fix RAG reranker deadlock on first load (Lock -> RLock)
get_reranker() acquires the module-level _lock and then, on first load,
calls unload() to clear any stale state before _load() instantiates the
CrossEncoder. unload() acquires the same _lock — but threading.Lock is
non-reentrant, so the second acquisition by the holding thread blocked
forever. Symptom: rerank=True hung the search_knowledge_base tool with
no further log output past 'rerank entered'.

Switch to threading.RLock so the same thread can re-enter without
blocking. unload()'s independent callers still work the same way; the
only behaviour change is that re-entrant acquisition from one thread
now succeeds.
2026-05-27 21:10:38 +04:00
Roland Tannous
1db654abb1 Studio: print reranker stage milestones to stderr for diagnostic visibility
When the reranker hung on rerank=True there were zero log lines after
'retrieved=N (no threshold)', which made it impossible to tell whether
the hang was in _load (CrossEncoder construction), in get_reranker's
lock acquisition, or in predict. Structlog routing may also be the
culprit since we never saw the 'Loading RAG reranker' info line.

Add unconditional stderr prints at each milestone — entered, device
resolved, before CrossEncoder, after CrossEncoder, rerank entered,
predict starting, predict done. These bypass any logger config and
show up directly in /tmp/studio.log next to the rest of the captured
stdout/stderr. Leaving structlog logger.info calls in place too so
the structured stream still gets the same data when routing works.
2026-05-27 21:05:22 +04:00
Roland Tannous
8fb2fb9e2a Studio: precache RAG reranker on toggle-on, not at app startup
Auto-downloading a 1.1 GB cross-encoder at every studio start is
wrong for users who never use rerank — reranker is opt-in by design.
Move the precache from a startup daemon thread to an explicit
POST /api/rag/reranker/precache endpoint, and have the chat settings
sheet call it the moment the 'Use reranker' switch is flipped on.

  - Backend: drop the startup _precache_reranker thread; add the
    /api/rag/reranker/precache route that calls precache_reranker().
  - Frontend: new precacheRagReranker() in rag-api, wired into the
    Switch's onCheckedChange so the download runs synchronously
    with a loading toast. On success: 'Reranker ready'. On failure:
    error toast + auto-flip the switch back off so the next query
    doesn't trigger another long hang.

First toggle-on pays the 1.1 GB download once; subsequent toggles
hit the HF cache and return ~instantly.
2026-05-27 20:45:17 +04:00
Roland Tannous
3f6a390df6 Studio: precache RAG reranker on startup; instrument loader + predict
The reranker model (BAAI/bge-reranker-base by default, ~1.1 GB) was
never precached, so the first user-facing rerank call paid the full
download cost — which on slow connections looked like a hang and got
retried by upstream timeouts. The deprecation warning that surfaced
during the hang was actually from sentence-transformers internals
firing while the download was still in flight.

Mirror the precache_helper_gguf pattern: add precache_reranker() that
calls snapshot_download in a daemon thread at FastAPI startup. The
first opt-in rerank now finds the weights already on disk and only
pays the in-process model load.

Also tighten the loader:
  - explicit device selection (cuda when torch.cuda.is_available,
    else cpu) so we don't rely on sentence-transformers auto-detect
    behaviour that has historically picked cpu under odd
    CUDA_VISIBLE_DEVICES configs;
  - structlog-shaped logs with elapsed_seconds around load + predict
    so a real runtime hang is visible in /tmp/studio.log with
    'RAG reranker predict starting' / 'RAG reranker predict done'.
2026-05-27 20:36:19 +04:00
Datta Nimmaturi
015fa5772a
Clear MRoPE after generation for GRPO (#5683)
* clear mrope state after generation

* move clear mrope to here

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-05-27 07:32:20 -07:00
Nilay
9a907a8acb
Studio: add remote MCP server support (#5750)
* added remote MCP server support

* trim

* added tests

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* increased timeout

* disabling MCP chat toggle

* Fix MCP OpenAI function-name validation + cancel propagation for PR #5750

OpenAI requires function.name to match ^[a-zA-Z0-9_-]{1,64}$ before
streaming starts. The existing 64-char length check is necessary but
not sufficient: MCP servers can return tool names containing '.', '/',
spaces, etc. that would 400 the whole chat request. Validate the
composed mcp__<server_id>__<tool> name against the regex, skip + warn
on miss, and drop duplicate tool names from the same server (which
would also 400 the request as "duplicates").

Also propagate the agentic-loop cancel_event into MCP tool execution
so a /cancel POST during a long-running MCP call (e.g. GitHub MCP
search across a large repo) actually interrupts the in-flight HTTP
call instead of waiting out the 300 s timeout. The watcher polls the
threading.Event at 50 ms cadence inside the asyncio loop (matches
routes/inference.py's existing cancel-watcher cadence) and races
against the call task with asyncio.wait FIRST_COMPLETED.

Tests added:
  - test_mcp_specs_skip_invalid_openai_function_names: drops bad chars
  - test_mcp_specs_skip_empty_tool_name
  - test_mcp_specs_drops_duplicate_names
  - test_call_tool_sync_respects_pre_set_cancel_event

Also fix test_desktop_auth.py's router stub that listed every existing
router but missed mcp_servers_router, so importing main.py fails after
this PR adds it to routes/__init__.py.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* PR #5750 round 2: OAuth cleanup on delete/url-change + mcp_enabled standalone

Round 2 of cross-platform validation surfaced two more P1 findings:

1. OAuth tokens never get cleared. fastmcp keys tokens by MCP URL, not by
   server row, and delete / URL change / use_oauth toggle only updated
   the SQLite row. Re-registering the same URL would silently reuse the
   old account's credentials. Adds clear_oauth_tokens_async() in
   mcp_client.py and calls it from the delete + put route handlers when
   the row had use_oauth=True and either the URL changes or OAuth is
   turned off.

2. mcp_enabled=true was ignored unless the caller also sent
   enable_tools=true. The frontend always sends both together so the UI
   path was fine, but a direct API caller sending only mcp_enabled would
   silently get no MCP tools, which contradicts the field's documented
   "append tools from every enabled MCP server" behavior. Loosens the
   use_tools gate in both the GGUF and safetensors paths so mcp_enabled
   opens the tool loop on its own; when the caller did not also opt
   into built-ins, the built-in list starts empty.

Tests added:
  - test_clear_oauth_tokens_async_no_op_safe
  - test_delete_server_calls_oauth_cleanup_when_oauth_was_on
  - test_delete_server_skips_oauth_cleanup_when_oauth_off
  - test_update_server_clears_oauth_on_url_change
  - test_update_server_clears_oauth_when_oauth_disabled

26 backend MCP tests pass; full studio/backend suite 1710 passed locally.
Cross-platform CI (Linux, macOS, Windows) green on staging fork.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* PR #5750 round 3: reject null bool updates + /test surfaces 400

Round 3 of cross-platform validation:

1. PUT /api/mcp/servers/<id> would 500 with TypeError when the body
   explicitly set is_enabled or use_oauth to null. Pydantic accepts
   None for an Optional[bool] and _changes_from_payload then passed
   None into mcp_servers_db.update_server, which int(None)d. Reject
   explicit null at the validation layer with 400 instead.

2. POST /api/mcp/servers/test caught HTTPException under
   "except Exception", so an invalid URL came back as HTTP 200 with
   {"ok": false, "error": "400: ..."} instead of a real 400. The
   create + update paths return 400 for the same input. Move
   validation outside the transport try/except so it surfaces 400.

Tests added:
  - test_changes_from_payload_rejects_null_is_enabled
  - test_changes_from_payload_rejects_null_use_oauth
  - test_test_endpoint_surfaces_url_validation_as_400

* PR #5750 round 4: hyphenated MCP tool names + empty-tool-list gate

Round 4 surfaces two more interaction bugs between the new MCP path
and existing safetensors tool plumbing:

1. OpenAI accepts ^[a-zA-Z0-9_-]{1,64}$ for function.name, and round 1
   widened the MCP regex to that set, so MCP tools can now be advertised
   as `mcp__srv__list-issues`. But the XML tool-call parser in
   tool_call_parser.py used `\w+` (no hyphen), so the model could call
   the tool but Studio could not parse the call. Same in
   routes/inference.py's `_TOOL_XML_RE` stripper, which would leave
   hyphenated tool-call XML in the visible content. Both regexes now
   use `[\w-]+`.

2. safetensors_agentic treats `tools=[]` as "allow all" (documented
   contract, exercised by test_empty_tools_list_does_not_enforce_allowlist).
   When a caller sends `enable_tools=true` + `enabled_tools=[]` +
   `mcp_enabled=true` and MCP discovery returns 0, the resolved tool
   list is genuinely empty and built-in tools (web_search / python /
   terminal) could execute via the model's emitted call. Fix at the
   route gate instead of breaking the documented contract: set
   `use_tools=False` when the resolved list is empty, in both GGUF and
   safetensors paths. Existing callers who omit `enabled_tools` still
   get ALL_TOOLS and are unaffected.

Tests added (32 total):
  - test_tool_xml_parser_handles_hyphenated_function_names
  - test_tool_xml_strip_handles_hyphenated_function_names
  - test_safetensors_agentic_empty_allowlist_still_means_allow_all
    (documents the contract round 4 preserved)

1716 passed locally; cross-platform CI on staging fork still green.

* PR #5750 round 5: GGUF allow-list + CLI policy + hyphenated params + cancel race

Round 5 of parallel-reviewer aggregation surfaced six additional
findings; five are real and fixed here:

1. Hyphenated MCP parameter names (`<parameter=issue-number>`) were
   dropped by the XML parser's `\w+` regex. Extended to `[\w-]+` in
   both core/inference/tool_call_parser.py and core/tool_healing.py.
   The latter is GGUF's own copy of the parser/strip patterns and was
   missed by round 4.

2. core/tool_healing.py's `strip_tool_call_markup` still used
   `<function=\w+>` so hyphenated MCP tool-call XML leaked into the
   GGUF visible content even after round 4 fixed the shared parser.

3+4. `mcp_enabled` re-opened the tool loop even when the operator
   passed `unsloth run --disable-tools` (CLI policy False). Round 2's
   `(_tools_on or payload.mcp_enabled)` gate ignored the raw process
   policy. Now reads `state.tool_policy.get_tool_policy()` and gates
   mcp_enabled on `_cli_policy is not False`. Applied to both GGUF
   and safetensors paths.

5. GGUF's agentic loop called `execute_tool(tool_name, ...)` without
   checking the model-emitted name against the per-request tool list,
   while the safetensors loop already enforces this. Added the same
   allow-list check so a model that hallucinates a filtered MCP name
   or a built-in the caller opted out of returns "not enabled" instead
   of executing.

Bonus P2 fixes:
  - `call_tool_sync` now checks `cancel_event.is_set()` BEFORE
    creating the call task, so a pre-set cancellation does not open
    the HTTP transport.
  - `clear_oauth_tokens_async` moved the OAuth import + construction
    inside the protected try block; a fastmcp.client.auth load error
    used to escape and 500 the delete / update route.

NOT fixed (verified false or out of scope):
  - finding #10 "structured_content vs structuredContent": fastmcp's
    CallToolResult dataclass uses snake_case (verified live against
    structured-only tool result; fields are
    `dict_keys(['content', 'structured_content', 'meta', 'data', 'is_error'])`).
  - finding #11 "asyncio.run from running loop": call_tool_sync is
    invoked from `asyncio.to_thread` worker threads which have no
    event loop; asyncio.run() is safe there.

Tests added (37 total): hyphenated param names, tool_healing strip,
GGUF allow-list gate, cancel pre-set short-circuit, OAuth cleanup
constructor-error swallowing. 1721 passed locally, no regressions.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-05-27 07:01:11 -07:00
Roland Tannous
8198459597 Studio: anchor toasts to top-right, fix tight spacing in ingest stack
The RAG ingestion toast stack was pinned to bottom-right and rendered
its title and progress text with gap-0.5, so the stage label and
percentage rendered close enough to look concatenated (e.g.
'indexing5%') on narrow widths. The default Sonner toaster also
defaulted to bottom-right, so other notifications were inconsistent
with the new layout.

  - Move the ingestion toast stack to top-right.
  - Set Sonner's default position to top-right.
  - Bump vertical gap between title and progress (gap-1.5).
  - Add horizontal gap-3 between stage label and percentage, plus
    shrink-0 + tabular-nums on the percent so it never collides with
    the (truncatable) stage label.
2026-05-27 17:56:28 +04:00
Roland Tannous
5edffa3feb Studio: skip RAG tool + system prompt nudge when scope has no docs
If RAG is toggled on but the active scope (thread or KB) has zero
indexed documents, exposing search_knowledge_base to the model just
wastes a tool-call turn — the model calls the tool, gets back 'no
matching chunks', and has to re-plan. The system prompt nudge that
instructs the model to call the tool before answering is similarly
counterproductive.

Frontend: before computing ragToolPathTaken in chat-adapter, fetch the
document list for the current scope (KB or thread) and require docs to
exist. The flag now gates both the system prompt injection and the
enabled_tools list. Defensive fallback: if the docs lookup fails the
flag stays true so we don't silently swallow RAG.

Backend: add _drop_rag_tool_if_scope_empty in routes/inference.py that
counts rag_documents for the request's rag_scope and strips
search_knowledge_base from the tool list when 0. Applied at both
chat-completion tool-filter sites so the protection works regardless
of which streaming path serves the request.
2026-05-27 17:48:21 +04:00
Roland Tannous
336ad815b3 Studio: hide RAG retrieval scores from chunks, citations, and side panel
Scores were only ever useful for debugging; surfacing them in chunk
cards (score X.XXX · dense Y.YYY) and citation hovers made the UI
noisy without giving the user anything actionable. Drop them in three
places:

  - Backend search_knowledge_base no longer emits score / dense_score
    attributes on the <chunk> tags fed to the LLM; the tool description
    is updated to match.
  - Chunk-card metadata in the assistant-ui tool result strips score /
    dense lines.
  - Source-badge hover tooltips drop the 'score N' meta line.

Also remove the 'Min relevance' slider from the chat settings sheet.
The backend min_score field stays plumbed (default 0 = no filter) so
the threshold can be re-exposed later or driven programmatically.
2026-05-27 17:15:18 +04:00
Roland Tannous
8145f1d527 Studio: splice VLM figure captions next to their 'Figure N:' line
Captions were appended at the bottom of the page text, so the chunk
containing 'Figure 1: Asymmetries ...' got chunked separately from
'**Figure**: Flowchart with ...' on the same page. Retrieval surfaced
the caption-text chunk but the VLM description landed in a different
chunk, leaving the LLM without the visual content right next to the
figure label.

Splice each VLM caption right after the matching 'Figure N:' (or
'Table N:') line as '**Figure N description**: ...', so:

  - The figure-boundary chunker now keeps both the original in-PDF
    caption AND the VLM description in the same chunk (which starts
    with 'Figure N:').
  - Multi-figure pages get per-figure attribution — the prefix
    'Figure N description' lets the LLM tell two figures on the same
    page apart, even though the bbox renderer still emits one image
    per page today (multi-figure clustering is a follow-up).
  - When the page text has no figure lines (DOCX/HTML/TXT or rare
    PDF layouts) the old end-of-page appendix is kept as a fallback.
2026-05-27 17:02:36 +04:00
Daniel Han
ab48465135
Studio: add Gemini provider with web_search, code_execution, prompt caching, and Nano Banana image generation (#5720)
* Studio: add Gemini provider with web_search, code_execution, prompt caching, and Nano Banana image generation

Wires Google's native Gemini API into Studio's external-provider stack
so users can pick gemini-2.5-pro / gemini-2.5-flash / gemini-2.5-flash-image
(Nano Banana) alongside the existing OpenAI / Anthropic / OpenRouter
providers. Gemini does not speak OpenAI Chat Completions on its primary
endpoint; the new `_stream_gemini` async generator translates between
the two shapes the same way `_stream_anthropic` handles the Messages API.

Backend:
- New `_stream_gemini` translator in external_provider.py. Converts
  OpenAI messages -> Gemini `contents` + `systemInstruction`; maps
  generationConfig (temperature / topP / topK / maxOutputTokens);
  forwards `tools: [{googleSearch: {}}]` for web_search and
  `{codeExecution: {}}` for code_execution; passes `cachedContent`
  through for prompt caching; sets `responseModalities=[TEXT, IMAGE]`
  for Nano Banana image generation.
- Translates streamed `GenerateContentResponse` SSE frames back into
  OpenAI chat.completion.chunk frames (text deltas, function_call ->
  tool_calls deltas, inlineData -> image_b64 tool_end envelope, usage
  chunk before [DONE]).
- Registry entry switched to native base URL
  `https://generativelanguage.googleapis.com/v1beta` with
  `openai_compatible: False` and the `x-goog-api-key` auth header.
  Model lineup curated to current 2.5 / 2.0 family + Nano Banana.

Frontend:
- Provider-capability matrix: Gemini supports temperature, top_p, top_k,
  presence_penalty (matches generationConfig); min_p / repetition_penalty
  hidden because the API does not accept them.
- `providerSupportsBuiltinWebSearch` / `providerSupportsBuiltinCodeExecution`
  / `providerSupportsBuiltinImageGeneration` extended for Gemini.
- Prompt caching toggle now also lit on Gemini.

Tests:
- 21 new tests in `test_gemini_provider.py` using httpx.MockTransport.
  Cover request body shape conversion, URL/header wiring, web_search
  forwarded as googleSearch, function-call translation both directions,
  prompt caching passthrough, image generation emitting image_b64,
  grounded-search citations -> tool_end, finish_reason mapping, and
  vision data URL -> inlineData translation.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: forward presence_penalty to Gemini and recover function name from tool_call_id

Two follow-up fixes for the Gemini provider:

  * Thread presence_penalty into _stream_gemini and set
    generationConfig.presencePenalty when non-zero. The OpenAI-side
    capability matrix already exposes the slider for Gemini, so the
    value was being collected and silently dropped on the way out.

  * When an OpenAI role=tool message omits 'name' and only carries
    'tool_call_id', recover the function name from the matching
    functionCall on the prior assistant turn. Gemini 400s on an empty
    functionResponse name.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: surface Gemini code execution parts as code_execution tool events

The Gemini stream parser only handled text/functionCall/inlineData
parts, so when the user toggled the Code pill on a Gemini model the
sandbox output (executableCode + codeExecutionResult parts) was
dropped on the floor while adjacent text reached the UI. Reviewers
flagged this as the headline feature being silently broken.

Translate both parts into the existing code_execution tool envelope
that CodeExecutionToolUI already consumes for OpenAI / Anthropic:

  * executableCode  -> tool_start with kind=code_execution and the
    source code under arguments.code. We mint a tool_call_id and
    stash it so the matching result block can pair to it.
  * codeExecutionResult -> tool_end on that id with the stdout under
    result. Non-OK outcomes (OUTCOME_FAILED / OUTCOME_DEADLINE_EXCEEDED)
    are prefixed onto the text so the failure is visible.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: native Gemini model catalog, function-call ids, and honest cache claim

Three follow-ups to the Gemini provider PR after the codex pass:

  * list_models() now translates Gemini's native /v1beta/models
    payload ({models[{name, baseModelId, displayName,
    supportedGenerationMethods}]}) into the OpenAI-compatible shape
    Studio expects. Without this the picker stayed empty for Gemini
    and fell back to hardcoded defaults. Embedding-only models are
    filtered out.

  * Forward the OpenAI tool_call id into Gemini's functionCall.id
    and mirror it onto functionResponse.id. Two parallel calls to
    the same function name can now be paired unambiguously on the
    follow-up turn.

  * Drop Gemini from the prompt-caching capability set. The wire
    flow requires a separate cachedContents POST first and the
    boolean Studio emits today is a no-op; the toggle should not
    advertise a feature it cannot apply. Leaves a pointer to the
    docs for the eventual two-step orchestration.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: distinct tool_calls index per emitted Gemini function call

Codex flagged that the Gemini stream parser hardcoded
tool_calls[0].index to 0 on every emitted functionCall. OpenAI
reassemblers key tool_calls by index when joining deltas, so two
parallel function calls in one assistant turn collapsed onto a
single slot and the second call's arguments overwrote the first.

Track the running count via len(emitted_function_call_ids) - 1
and emit it as the per-call index. The dedupe guard above (skip
when fc_id already in the set) means the index is monotonic and
stable for the lifetime of the stream. Regression test asserts
[0, 1] across two parallel calls in one candidate parts list.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: surface Gemini 3.5/3.1/3 + Nano Banana 2/Pro and plumb thinking budget

`gemini-2.0-flash` / `gemini-2.0-flash-exp` were retired by Google in 2026
(`/v1beta/models/gemini-2.0-flash:streamGenerateContent` returns HTTP 404
"no longer available to new users"), and the picker had nothing past the
2.x family. Verified against the live ListModels catalog: drop the retired
ids from `default_models` + allowlist and surface the chat-capable
3.5 / 3.1 / 3 families plus the Nano Banana image trio.

Also plumb `enable_thinking` / `reasoning_effort` into Gemini's
`generationConfig.thinkingConfig`. Without this, Gemini 3.5 Flash,
gemini-pro-latest, and the 3.x previews silently spend the caller's
`max_tokens` budget on hidden "thoughts" before emitting any visible
answer -- the chat shows a truncated stub like "The capital of" and
streams stop. Mapping:
  - enable_thinking=False / reasoning_effort=none -> thinkingBudget=0
    (Flash tier; Pro tier coerces to a small positive budget because
    the API 400s on 0 with "This model only works in thinking mode")
  - minimal/low/medium/high -> 512/2048/8192/24576 budget tokens
  - max/xhigh -> -1 (dynamic)
  - default (neither knob set) -> thinkingConfig omitted, model decides

Frontend `getExternalReasoningCapabilities` now surfaces a
`reasoning_effort` picker for every Gemini chat id (Pro tier hides the
"none" option; image-tier ids stay knob-less). Adds 6 unit tests
covering Flash/Pro effort mapping, the off-toggle coercion on Pro,
default omission, and the nano-banana-pro-preview alias routing
through the image modalities path. 28 -> 34 tests in
`test_gemini_provider.py`, all green; full backend suite still passes
(1459/1460; the unrelated test_help_output flake is pre-existing and
not in any file this PR touches).

Live verification against generativelanguage.googleapis.com on
2026-05-24 with `_stream_gemini` directly:
  text   gemini-3.5-flash           single PASS  multi PASS
  text   gemini-3.1-pro-preview     single PASS  multi PASS
  text   gemini-3.1-flash-lite      single PASS  multi PASS
  text   gemini-3-pro-preview       single PASS  multi PASS
  text   gemini-3-flash-preview     single PASS  multi PASS
  text   gemini-2.5-pro             single PASS  multi PASS
  text   gemini-2.5-flash           single PASS  multi PASS
  text   gemini-2.5-flash-lite      single PASS  multi PASS
  text   gemini-flash-latest        single PASS  multi PASS
  text   gemini-flash-lite-latest   single PASS  multi PASS
  text   gemini-pro-latest          single PASS  multi PASS
  image  gemini-2.5-flash-image     PASS (1082 KB png returned)
  image  gemini-3.1-flash-image-preview  PASS (Nano Banana 2)
  image  gemini-3-pro-image-preview      PASS (Nano Banana Pro)
  tool   web_search                 PASS
  tool   code_execution             PASS
  -> 16/16 e2e through the actual ExternalProviderClient code path.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: tighten Gemini provider after review (PR #5720)

Fixes a batch of bugs surfaced by a second-pass review on top of the
3.5/3.1/3 + Nano Banana 2/Pro additions in c6724dbd.

Backend (external_provider.py):
- Constructor normalises legacy /v1beta/openai base URLs to /v1beta so
  Gemini providers saved before the native switch keep working without
  a manual re-config.
- Skip thinkingConfig, googleSearch, and codeExecution on image-tier
  models (-image / nano-banana). The image responseModalities path is
  mutually exclusive with text-tool wiring and stale UI state would
  otherwise 400 the turn.
- _PRO_THINKING_PREFIXES now includes gemini-3.5-pro and uses anchored
  prefix matching (exact id or "<prefix>-...") so the image-tier
  gemini-3-pro-image-preview cannot accidentally match the pro guard.
- Gemini 3 functionCall thoughtSignature is round-tripped through the
  tool_calls envelope via extra_content.google.thought_signature on
  emit, and replayed as a sibling of functionCall on the next request.
- finishReason swaps STOP -> tool_calls when any functionCall was
  emitted on the same turn so OAI clients trigger tool execution
  (matches the OpenAI Chat Completions contract).
- usageMetadata.thoughtsTokenCount is rolled into output_tokens and
  surfaced on output_tokens_details.reasoning_tokens so total_tokens
  reflects the full billable spend instead of dropping the hidden
  reasoning slice.

Registry (providers.py):
- Drop gemini-3-pro-preview from default_models. Google shut it down
  on 2026-03-09 and auto-redirects to gemini-3.1-pro-preview; we
  surface the canonical id only.
- Add model_id_deny_exact = ("gemini-3-pro-preview",) so the live
  ListModels fetch does not re-surface the redirect alias.

Route schema (models/inference.py):
- enable_prompt_caching widened to Optional[Union[bool, str]] so the
  /v1/chat/completions caller can pass a Gemini cachedContent resource
  name (e.g. cachedContents/abc123). Without this widening _stream_gemini
  s string cachedContent passthrough was unreachable from the public
  route (bool_parsing 422). stream_chat_completion signature mirrors.

Frontend (provider-capabilities.ts, chat-page.tsx, chat-adapter.ts):
- providerSupportsBuiltinImageGeneration now also recognises
  nano-banana ids (nano-banana-pro-preview was hidden from the image
  pill before).
- providerSupportsBuiltinWebSearch takes the model id so Gemini image
  models hide the Search pill (mirrors the backend skip).
- providerSupportsBuiltinCodeExecution uses the same isGeminiImageModel
  guard for nano-banana ids.
- GEMINI_THINKING_PRO_PREFIXES gains gemini-3.5-pro; gemini-3-pro
  tightened to gemini-3-pro-preview to avoid the image-id overlap.
- Updated 3 callers of providerSupportsBuiltinWebSearch to thread the
  selected model id through.

Tests (test_gemini_provider.py): 34 -> 42, all green
- test_image_models_skip_thinking_config
- test_image_models_drop_text_only_tools
- test_gemini_35_pro_recognized_as_pro_thinking
- test_legacy_openai_base_url_normalized
- test_finish_reason_swaps_to_tool_calls_when_function_call_emitted
- test_thought_signature_round_trips_into_gemini_function_call
- test_thought_signature_emitted_in_tool_call_delta
- test_usage_chunk_includes_thoughts_tokens

Verification:
- Backend pytest 1518/1519 passing (one unrelated Qwen3.5 flash-attn
  test fails on main as well; nothing in this PR touches that path).
- Frontend npx tsc -b clean.
- Live e2e 16/16 against generativelanguage.googleapis.com through the
  patched _stream_gemini code path (all 11 chat models single + multi
  turn, all 3 image models returned image bytes, web_search and
  code_execution tools both emit the expected envelope).
- Live /api/providers/models against the patched backend surfaces 16
  ids (gemini-3-pro-preview correctly filtered via deny_exact).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: address second-pass review findings on Gemini (PR #5720)

Round-2 reviewer.py flagged a phantom web_search card on image
turns (12/12 reviewers), route-layer stripping of tool_calls /
tool_call_id / name, an over-narrow image-mode tool guard, and
silent safety blocks. This patch fixes all four.

Backend (external_provider.py):
- web_search_active is now derived from the outbound tools_array
  (whether googleSearch was actually forwarded), not the raw
  enabled_tools intent. Image-mode turns dropped the tool above so
  the inbound stream no longer emits a phantom "search complete"
  tool_start / tool_end on those turns.
- text_tools_allowed now uses is_image_model (covers both `-image`
  / `nano-banana` picker models AND text models that requested
  `image_generation` via enabled_tools). Verified against the live
  Gemini API which rejects both googleSearch and codeExecution
  alongside responseModalities=["TEXT","IMAGE"] with explicit 400s
  ("Search as tool is not enabled for this model", "Code execution
  is not enabled for this model").
- promptFeedback.blockReason is surfaced as a 400 content-filter
  error chunk instead of returning an empty successful assistant
  response. The streaming loop closes the response before exiting.

Route (routes/inference.py):
- _build_external_messages now propagates tool_calls (assistant),
  tool_call_id, and name (tool result) through every code path
  (string content, multimodal content, non-vision fallback). Without
  this Gemini 3 function-call round trips lost their thoughtSignature
  + tool_call_id at the route boundary, and functionResponse.name
  arrived empty on the second turn.
- Assistant messages with content=None and tool_calls populated are
  preserved as a synthetic empty-string content turn so the
  Gemini translator can rebuild the functionCall part.

Tests (test_gemini_provider.py): 42 -> 45, all green
- test_image_models_suppress_phantom_web_search_card
- test_image_generation_tool_drops_text_tools
- test_prompt_feedback_block_reason_surfaces_as_error

Verification:
- Backend pytest 1736 / 1736 (the two pre-existing unrelated fails
  on main, test_help_output and Qwen3.5 flash-attn pin, are skipped).
- Frontend npx tsc -b clean.
- Live e2e 16/16 against generativelanguage.googleapis.com:
  11 chat models single + multi turn, 3 image models returning
  image bytes, web_search and code_execution both PASS.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: fix third-pass Gemini findings (PR #5720)

Round 3 review follow-ups:

Backend (studio/backend/core/inference/external_provider.py):
- Close response AND aiter_lines iterator in a finally so normal,
  prompt-block, and cancellation exits all clean up (eliminates the
  RuntimeWarning about aclose never being awaited).
- Pair the synthetic web_search tool_start with a tool_end on the
  promptFeedback.blockReason path so the UI does not leave a stuck
  "searching..." spinner after the error toast.
- Preserve native id and thoughtSignature on executableCode and
  codeExecutionResult tool events under google.native_part, and pair
  the tool_end on the code-exec id so multi-turn code-execution
  replays do not lose Gemini-required history.
- Carry part-level thoughtSignature on text deltas via
  delta.extra_content.google.thought_signature and on inline image
  tool_end via google.thought_signature so Gemini 3 image editing
  and tool turns round-trip the signature on the next request.
- Guess remote image_url MIME from the URL path so PNG / WebP / GIF
  inputs are not silently relabeled as JPEG.
- Roll usageMetadata.toolUsePromptTokenCount into translated input
  tokens and surface thoughtsTokenCount as
  completion_tokens_details.reasoning_tokens in _build_usage_chunk.
- Only normalize the Google-hosted /v1beta/openai legacy base URL;
  custom proxies whose paths happen to end in /openai are left
  untouched.
- Forward ChatCompletionRequest.tools and tool_choice through
  stream_chat_completion into _stream_gemini, translating to
  tools[].functionDeclarations and toolConfig.functionCallingConfig.

Frontend:
- chat-adapter: when Gemini image-generation is enabled for the turn,
  also disable Search and Code so the request, builder, and active
  pills agree with what the backend actually sends (the backend
  already strips text tools when image_generation is in enabled_tools).
- chat-adapter: consume OpenAI-shape delta.tool_calls chunks so
  Gemini function-call deltas without text surface as tool-call parts.
- shared-composer: disable Search and Code pills while Gemini image
  mode is active so the UI matches the request.

Tests (studio/backend/tests/test_gemini_provider.py): adds coverage
for proxy base-url gating, remote image MIME inference,
toolUsePromptTokenCount, reasoning_tokens propagation, prompt-block
web_search tool_end pairing, native code-exec id/thoughtSignature
metadata, inline image thoughtSignature, text-chunk extra_content,
OpenAI tools/tool_choice translation, and image-model tool drop.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: Gemini 3 thinkingLevel + image-model Search grounding (PR #5720)

Gemini 3.x migrated to a string `thinkingConfig.thinkingLevel`
(MINIMAL/LOW/MEDIUM/HIGH) and rejects `thinkingBudget`+`thinkingLevel`
in the same request. Gemini 3 also cannot turn thinking fully off, so
the lowest position is "minimal" (Flash) or "low" (Pro rejects
"minimal").

- external_provider._stream_gemini: split thinking translation by
  family. Gemini 3.x (3 / 3.1 / 3.5 + gemini-pro-latest /
  gemini-flash-latest / gemini-flash-lite-latest) emits
  thinkingConfig.thinkingLevel; effort none/off coerces to "low" on
  Pro and "minimal" on Flash. Gemini 2.5 stays on thinkingBudget.
- external_provider._stream_gemini: allow `tools: [{googleSearch: {}}]`
  on the Gemini 3 image family (gemini-3-pro-image-preview,
  gemini-3.1-flash-image-preview, nano-banana-pro). Google's docs
  document Search grounding on these. codeExecution stays blocked
  on image mode (still mutually exclusive with responseModalities).
- provider-capabilities.ts: mirror the Gemini 3 effort ladders in
  resolveGeminiReasoningCapabilities (Pro: low/medium/high; Flash:
  minimal/low/medium/high; 2.5 Flash keeps the off-position).
- provider-capabilities.ts: providerSupportsBuiltinWebSearch now
  returns true on the documented Gemini 3 image models so the pill
  is reachable; older image ids (gemini-2.5-flash-image) still hide.

Tests: splits the existing thinkingBudget cases by family (Gemini 3
checks thinkingLevel; Gemini 2.5 keeps thinkingBudget), adds positive
googleSearch coverage for Gemini 3 image models and negative
googleSearch coverage for legacy image models.

References:
- https://ai.google.dev/gemini-api/docs/thinking
- https://ai.google.dev/gemini-api/docs/gemini-3
- https://ai.google.dev/gemini-api/docs/models/gemini-3-pro-image-preview

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: attach Gemini code_execution inline images to the code card (PR #5720)

When a text Gemini turn wires codeExecution and the sandbox produces a
matplotlib plot, the inline image part ships right after the
codeExecutionResult. Previously this surfaced as a separate empty
image_generation card. Track the most recent code_execution
tool_call_id + result text and, when an inline image follows with
code_execution active, emit a second tool_end on the same id that
appends the image as a data: URI under the `__IMAGES__:` marker the
chat-adapter already understands.

Image-picker turns (`-image` / `nano-banana`) keep the standalone
image_generation envelope so Nano Banana outputs render the same way.

Tests: covers the merged code-execution card emission with no
standalone image_generation event when code_execution is the active
tool.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: fix fourth-pass Gemini findings (PR #5720)

Round 4 review follow-ups:

Backend:
- `_is_openai_compatible` + `_auth_headers` detect Gemini connections
  pointed at a custom OpenAI-compatible proxy (non-Google host whose
  path ends in `/openai`) and route them through the OpenAI-compat
  surface with `Authorization: Bearer ...` instead of the native
  `_stream_gemini` translator + `x-goog-api-key`. Google-hosted Gemini
  keeps the native dispatch path it migrated to in this PR.
- `_stream_gemini` thinkingLevel handling for Gemini 3 Pro now coerces
  both "minimal" and "medium" effort to "low" / "high" respectively
  (Pro tier only accepts low/high per
  https://ai.google.dev/gemini-api/docs/thinking).
- `providers.py` `default_models` restores the advertised
  `gemini-3.5-pro` and the rolling `gemini-pro-latest` /
  `gemini-flash-latest` / `gemini-flash-lite-latest` aliases that the
  allowlist already admits.

Frontend:
- chat-adapter: lean on `providerSupportsBuiltinWebSearch` (which
  already encodes the Gemini 3 image-model Search allowance) instead
  of blanket-disabling Search whenever Gemini image mode is active.
  Code execution stays blocked because Gemini image mode rejects it.
- shared-composer: mirror the same gate -- only the Code pill is
  unconditionally disabled in Gemini image mode; the Search pill is
  driven by `supportsBuiltinWebSearch`.
- provider-capabilities: Gemini 3 Pro reasoning levels now expose only
  "low" and "high" (no Medium pill) to match the API.

Tests: covers the Gemini 3 Pro medium / minimal coercion, the custom
proxy OAI-compat dispatch + Authorization Bearer auth, and the
native-vs-proxy detection. Also closes the mocked httpx.AsyncClient
inside the test event loop so the Python 3.13 `aclose was never
awaited` warning no longer fires.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: fix fifth-pass Gemini findings (PR #5720)

Round 5 review follow-ups:

Backend:
- `_is_openai_compatible` + `_auth_headers` now treat ANY non-Google
  Gemini base URL as OpenAI-compat (LiteLLM / custom OAI gateways /
  OpenAI-compat vLLM routers), not just paths ending in `/openai`.
  Pre-existing saved Gemini proxies on `/v1` keep working.
- Gemini 3 thinkingLevel coercion narrowed to the documented
  inconsistencies: only "minimal" is coerced to "low" on Pro tier.
  "medium" passes through (Gemini 3.1 Pro accepts it per
  https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-1-pro).
- `_stream_gemini` only flips `responseModalities=[TEXT,IMAGE]` when
  the selected model is image-capable. A stale
  `enabled_tools=["image_generation"]` on a text model is silently
  dropped instead of producing an invalid Gemini request.
- `_stream_gemini` validates the model id against
  `[A-Za-z0-9._-]+` before URL interpolation so a model like
  `../cachedContents/x` cannot redirect the request to an unintended
  endpoint with the configured API key attached.
- Empty-text Gemini parts that still carry `thoughtSignature` emit a
  content-free delta with `extra_content.google.thought_signature` so
  Gemini 3 turns that end with a signature-only fragment do not lose
  the replay state.
- ConnectError / ReadTimeout / generic HTTPError paths in
  `_stream_gemini` now close the synthetic web_search tool_start
  with a matching tool_end before the error chunk so the UI does not
  leave a stuck "searching..." card on transport failure.
- `providers.py` default_models drop the non-existent
  `gemini-3.5-pro` (Google launched only `gemini-3.5-flash` at
  I/O 2026; Pro tier remains `gemini-3.1-pro-preview`).
- `routes/inference.py` only forwards `payload.top_k` when the caller
  explicitly set it on the request (Pydantic `model_fields_set`).
  Omitted top_k stays omitted, restoring the pre-PR behavior where
  Gemini uses its server default.
- `ChatCompletionRequest.enable_prompt_caching` adds a `mode="before"`
  validator that coerces the canonical string literals "true"/"false"
  back to bool so historical opt-out callers keep working after the
  field widened to `Union[bool, str]` for Gemini cache resource names.

Frontend:
- `providerSupportsBuiltinWebSearch` / Code / Image now accept the
  saved connection `baseUrl` and return false for custom OAI-compat
  Gemini proxies. Backend skips `_stream_gemini` for those bases, so
  native tool envelopes never reach them; hiding the pills keeps the
  request, builder, and UI consistent.
- `provider-capabilities.ts` Gemini 3 Pro effort ladder restores
  `["low", "medium", "high"]` to match Google's documented levels.
- Call sites in `chat-page.tsx` and `chat-adapter.ts` pass through
  `provider.baseUrl` so the proxy gate fires.

Tests: covers Gemini 3 Pro medium pass-through, custom proxy dispatch
on `/v1` and `/openai` bases, path-traversal model id rejection,
top_k omission when not explicit, text-model image_generation drop,
empty-text + thoughtSignature surfacing, and
enable_prompt_caching string coercion.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: fix sixth-pass Gemini findings (PR #5720)

Round 6 review follow-ups:

Frontend:
- chat-adapter `delta.tool_calls` accumulates fragments by `id` /
  `index` instead of pushing a new tool-call card per chunk. The
  standard OpenAI Chat Completions stream contract sends `id`/`name`
  on the first chunk and partial `function.arguments` on subsequent
  chunks; our previous handler parsed each fragment as a standalone
  tool call. Local llama.cpp and OAI-compat providers that stream
  fragments now reassemble into a single function-call part.
- chat-adapter also preserves `extra_content` on streamed tool-call
  deltas so Gemini 3 `thoughtSignature` survives to the next turn.
- provider-capabilities Gemini 3 Pro restores "medium" in the
  reasoning-effort ladder (Google's official Gemini API thinking
  doc lists low/medium/high for Gemini 3.1 Pro; my earlier round 4
  coercion was wrong).
- provider-capabilities orders `gemini-2.5-flash-lite` ahead of the
  broader `gemini-2.5-flash` prefix so Flash-Lite falls into the
  "no native thinking knob" branch as documented.

* Studio: round-trip Gemini tool_calls and tool results (PR #5720)

Recurring round 3-6 P1: the chat-adapter renders Gemini function-call
parts and code-execution events but `toOpenAIMessage` only serialized
text + image content, so the next turn lost the assistant
`tool_calls[]` (including Gemini 3's required
`extra_content.google.thought_signature`) and the matching
`role="tool"` result. Gemini 3 multi-turn function calling and code
execution failed validation on the second turn.

Frontend:
- types/api.ts widens OpenAIChatMessage to permit `role="tool"`,
  `tool_calls`, `tool_call_id`, `name`, and `content: null`. Adds
  OpenAIToolCallPart with `extra_content` for the Gemini round-trip.
- chat-adapter: new `toOpenAIMessages` expands an assistant turn with
  tool-call parts into [assistant w/ tool_calls + extra_content,
  role=tool result, ...]. tool result content is JSON-serialized so
  the backend translator can rebuild Gemini's `functionResponse`
  shape.
- chat-adapter outbound history now uses `flatMap(toOpenAIMessages)`
  so each assistant tool-call round-trips through the standard OAI
  shape the backend's `_stream_gemini` already understands.

* Studio: replay Gemini code_execution and image native parts on history (PR #5720)

Multi-turn Gemini history previously lost the native executableCode,
codeExecutionResult, and inlineData parts because the outbound
translator regenerated a generic functionCall for every assistant
tool_call. Stow the native dict on tool_end (frontend) and replay it
verbatim with thoughtSignature (backend) so follow-up turns preserve
the prior execution and image generation state. Skip role="tool"
fan-out for server-side builtin tools so Gemini does not 400 on a
functionResponse with no matching user-declared function.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: complete Gemini built-in tool replay round-trip (PR #5720)

Round 7 follow-up to the multi-turn native-part work. Three asymmetric
storage/consume gaps remained between the backend translator and the
chat adapter, so realistic Gemini follow-up turns degraded to generic
functionCalls instead of native history.

- Frontend collectAssistantToolCalls now drops web_search outright,
  drops code_execution / image_generation when the native part is
  missing, and promotes args.google to extra_content.google so the
  backend native_part replay branch actually fires.
- Backend image_generation tool_end now emits google.native_part
  with the inlineData (mimeType + base64) and thoughtSignature so the
  follow-up image-edit turn can replay the prior image as a native
  Gemini model part.
- Backend code-execution plot tool_end now stows google.native_part
  with the inlineData so the merged code-exec card can round-trip
  executableCode + codeExecutionResult + inlineData on the same id.
- Added regression tests for image-gen native-part replay and the
  code-exec plot native_part stow.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 8 Gemini follow-ups (PR #5720)

- Text-part thoughtSignature: stow on the assistant message during
  streaming and replay onto the last text part on the next turn so
  Gemini 3 strict function-calling does not reject history.
- Function declarations: recursively strip Gemini-unsupported OpenAPI
  keys (additionalProperties, $schema, $defs, strict, etc.) so OpenAI
  strict tools stop 400ing as INVALID_ARGUMENT on Gemini.
- OpenAI-compat fallback: forward tools/tool_choice so custom Gemini
  proxies (LiteLLM, gateways) keep function-calling.
- enable_prompt_caching: cover the Pydantic v1 legacy off/on/f/n/t/y
  string set so explicit opt-outs stay opt-out (Gemini was sending
  cachedContent: "off" otherwise).
- Frontend collectAssistantToolCalls / collectToolResultMessages: use
  google.native_part + result presence to disambiguate provider
  builtins from same-named user-declared functions.
- Added regression tests for text-signature replay and schema
  sanitization.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 9 Gemini follow-ups (PR #5720)

Two round-9 convergent finds across the 12 reviewers:

- Server-side web_search was leaking onto the next turn as a fake
  user functionCall/functionResponse. The previous heuristic (skip
  builtin only when no native_part AND no result) let it through
  because the synthetic tool card has a non-empty result string.
  Always skip web_search by name on both serializers, accept that a
  user-declared function literally named "web_search" must use a
  different name.
- Assistant `extra_content` was dropped by ChatMessage validation
  before _stream_gemini could replay text-part thought signatures.
  Add the field to ChatMessage and forward it through
  _build_external_messages so the multi-turn signature path actually
  carries data.

Includes a regression test for the ChatMessage round-trip.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 10 Gemini follow-ups (PR #5720)

Three convergent round-10 reviewer findings closed:

- Tag synthetic provider-side builtins with `args._server_tool=True`
  via a central helper that runs in every `_emit_tool_event` /
  `_emit_synthetic_tool_event` path. The frontend filter now skips
  on that marker instead of on the public tool name, so local
  llama.cpp `web_search` and OpenAI function tools literally named
  `web_search` / `code_execution` / `image_generation` round-trip
  cleanly while Gemini grounding / hosted code-exec / hosted image
  cards stay skipped.
- Gate Gemini image-mode (responseModalities=[TEXT,IMAGE]) on the
  Images pill (enabled_tools containing `image_generation`).
  Selecting an image-capable model with the pill off no longer forces
  image output the UI says is disabled.
- Frontend missing-key guard now exempts custom Gemini OAI-compat
  proxies (LiteLLM, gateways) the same way the backend already
  does, so a saved Gemini connection on `http://localhost:4000/v1`
  with no API key stops being blocked.

Existing tests updated to pass `enabled_tools=["image_generation"]`
on image-mode capture paths.

* Studio: round 11 Gemini follow-ups (PR #5720)

Four round-11 findings closed:

- Kimi _stream_kimi_web_search's local _synthetic_chunk helper now
  runs through _stamp_server_tool_marker so Kimi search history is
  not replayed as a fake user functionCall on the next turn (was an
  asymmetric miss after the round-10 tagging work).
- OpenAI Responses path (/v1/responses for gpt-5.x) forwards
  caller-supplied tools / tool_choice, translating the Chat
  Completions function-tool shape into the Responses native shape.
  Without this, standard OpenAI tools silently dropped on
  Responses-routed traffic.
- Decoupled the Gemini image-tier model-id guards (text-tool /
  thinking strip) from the Images pill flip
  (responseModalities=[TEXT,IMAGE]). gemini-2.5-flash-image with
  Search/Code on and the Images pill OFF no longer forwards
  googleSearch + thinkingConfig (Gemini 400s on those for legacy
  image ids).
- Gemini-only extra_content is now forwarded by
  _build_external_messages only when provider_type=="gemini" so
  Google's thought_signature does not leak into OpenAI / Mistral /
  Kimi / OpenRouter request bodies as an unknown field.

Added a regression test for the image-tier strict-guard split and
extended the extra_content test to cover the non-Gemini suppression.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 12 Gemini follow-ups (PR #5720)

Three round-12 convergent findings closed:

- extra_content leak to custom Gemini OAI-compat proxies (8/12
  reviewers). _build_external_messages now gates extra_content on
  the native generativelanguage.googleapis.com host, not just
  provider_type=="gemini", so LiteLLM / custom gateways routed
  through /chat/completions do not get an unknown top-level field.
- OpenAI Responses function-tool round-trip (5/12 reviewers). I
  added user `tools` forwarding in round 11 but did not parse the
  matching response.output_item.done items of type=function_call.
  The parser now translates them into Chat Completions
  delta.tool_calls and the terminal chunk reports
  finish_reason="tool_calls" when the model invoked a user
  function.
- Image-tier model with Images pill OFF (2/12). Google's image
  models default to text+image when responseModalities is omitted,
  so the previous fix silently still billed image output. Force
  responseModalities=["TEXT"] when the Images pill is off and the
  selected model is image-capable.

Updated the two pre-existing tests that pinned the synthetic-tool
arguments shape to include the new `_server_tool: True` marker, and
added a regression test for the Responses function-call output
translation.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 13 Gemini/Responses follow-ups (PR #5720)

Three round-13 convergent findings closed:

- OpenAI Responses function_call indices: my round-12 translator
  hardcoded every emitted tool_calls[*].index to 0, so parallel
  function calls collapsed for index-keyed clients. Track and
  increment function_call_index per emit (mirrors the Gemini
  branch's distinct-index pattern). 10/12 reviewers flagged.
- _SERVER_SIDE_BUILTIN_TOOL_NAMES now includes web_fetch so
  Anthropic-hosted web_fetch cards carry the _server_tool marker
  and the frontend history serializer doesn't replay them as fake
  user functions. 4 reviewers flagged.
- OpenAI Responses follow-up tool results now serialize as
  Responses-shape function_call / function_call_output items keyed
  by call_id, instead of Chat Completions role="tool" content.
  Skips assistant tool_calls tagged with _server_tool so hosted
  builtins don't round-trip as user functions. 2 reviewers flagged.

Updated the Anthropic code_execution and web_fetch test argument
pins to include the new _server_tool marker, and added two
regression tests (distinct indices on parallel function_call,
function_call_output round-trip).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 14 Gemini follow-ups (PR #5720)

Three round-14 findings closed:

- Remote `image_url` translation (5 reviewers convergent). Public
  HTTPS image URLs can't be sent as `fileData.fileUri` -- Gemini
  reserves that path for Files API URIs and YouTube. Fetch the
  bytes server-side and inline them as base64 `inlineData`,
  mirroring the pre-PR OpenAI-compat behaviour. YouTube URLs and
  generativelanguage.googleapis.com/v1beta/files/* stay as
  `fileData`.
- Nullable JSON Schema type arrays. OpenAI strict tools commonly
  use `"type": ["string", "null"]`; the Gemini sanitizer now
  flattens that to `"type": "string", "nullable": true` so strict
  function tools stop 400ing.
- Parallel functionResponses now ride on one user content block
  with multiple `functionResponse` parts, matching Google's
  parallel tool docs. Consecutive `role="tool"` messages merge
  into the previous user turn instead of splitting into separate
  Gemini user turns.

Three regression tests added (remote URL fetch + inline, Files
API / YouTube fileData preservation, schema nullable flattening,
parallel-tool grouping).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: SSRF harden Gemini remote image fetch (PR #5720)

Round 15 convergent finding (12/12 reviewers). My round-14 fix to
download user-controlled image URLs for inlineData inlining was an
SSRF / data-exfiltration path: no scheme check, no private-host
guard, no size cap, no Content-Type validation, redirects could
bounce to internal services, and the full URL was logged.

Replace the inline fetch with `_safe_fetch_image_for_gemini`:

- Require https:// (reject http, file, data, ftp, etc).
- Resolve the hostname via socket.getaddrinfo and reject if ANY
  resolved address is private / loopback / link-local / multicast /
  reserved / unspecified (covers 127.0.0.0/8, 10/8, 172.16/12,
  192.168/16, ::1, 169.254/16 metadata, RFC 6890).
- Block IP-literal URLs that resolve into those same ranges.
- Cap response body at 10 MB (Content-Length pre-check + streamed
  byte counter).
- Require Content-Type to start with `image/`.
- Disable redirect following so a 302 to a private host can't slip
  past the address check.
- Use a short 15s timeout and a tiny connection pool dedicated to
  these fetches.
- Log only the host name + error class -- no full URL, no signed
  querystring leak.

If the guard rejects, the image part is silently dropped (instead
of forwarding raw bytes or a fileData fallback). Files API URIs
and YouTube URLs still ride as `fileData.fileUri` unchanged.

Tests: replaced the live-fetch test with a `_safe_fetch_image_for_gemini`
monkeypatch, added four new SSRF-guard tests (non-https rejected,
loopback / private IP literals rejected, hostnames that resolve to
private IPs rejected).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 16 Gemini follow-ups (PR #5720)

- IP-pinned image fetch (`_safe_fetch_image_for_gemini`): reuse the
  validated-once-then-pin pattern from `tools._fetch_page_text` via
  `asyncio.to_thread`, so DNS rebinding between validation and the
  HTTP connect cannot redirect us at a private/metadata address.
  Catch malformed-bracketed IPv6 urlparse errors. Follow up to 4
  redirect hops with per-hop SSRF re-validation.
- Replace contains-substring detection of Gemini Files API + YouTube
  URLs with parsed scheme/host/path checks, so attacker URLs like
  `https://evil.example/path/youtube.com/x.png` no longer skip the
  safe-fetch path and serialize as `fileData.fileUri`.
- `_build_external_messages`: strip per-tool-call `extra_content`
  for non-native-Gemini providers; the Gemini-only
  `thought_signature` payload was leaking through `tool_calls[]`
  into /chat/completions on OpenAI, Anthropic, and custom Gemini
  OAI-compat gateways.
- `_server_tool` marker now gated on the function name being one of
  the canonical builtin names (`web_search`, `web_fetch`,
  `code_execution`, `image_generation`) AND the marker being set,
  so a user function whose schema happens to define an
  `_server_tool` field is no longer dropped. Frontend filter mirrors
  the same gate, plus a backward-compat fallback for pre-PR
  persisted server-tool cards (no marker) routed via name +
  native_part / web-tool heuristic.
- Gemini schema sanitizer collapses `anyOf: [{X}, {"type":"null"}]`
  to `{X, "nullable": true}` so Optional[X] tool args from
  OpenAI/Pydantic schemas no longer 400 the Gemini request.
- Frontend tool-result serializer emits `{"result":""}` for empty
  string outputs so the ChatMessage validator does not reject
  `role="tool"` with empty content.
- Coerce `medium` thinkingLevel to `high` for legacy
  `gemini-3-pro*` / `gemini-3-pro-preview*` (only low/high
  documented; shut down 2026-03-09); 3.1+ Pro still passes through.
- Hide Gemini native thinking ladder on custom OAI-compat Gemini
  gateways by routing `getExternalReasoningCapabilities` through
  `isGeminiCustomOpenAICompatBase(baseUrl)`; thread baseUrl through
  all four call sites.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 17 Gemini follow-ups (PR #5720)

- Frontend `collectAssistantToolCalls` and `collectToolResultMessages`
  no longer drop unmarked `web_search` / `web_fetch` cards by name
  alone: a user-defined function with one of those names must
  round-trip. Pre-PR persisted `code_execution` / `image_generation`
  cards still get filtered via a shape heuristic (kind/command/code/
  prompt fields) instead of bare name.
- `_build_external_messages._filter_tool_calls` now drops marked
  server-side builtin `tool_calls` entirely for non-native-Gemini
  providers, not just their `extra_content`. An assistant turn whose
  only payload was a marked builtin is dropped completely so the
  receiving provider does not see an orphan tool_call.
- `_stream_anthropic` translates OpenAI top-level `tool_calls` into
  Anthropic native `{type:"tool_use", id, name, input}` content
  blocks, and translates `role="tool"` follow-ups into `role:"user"`
  messages carrying a `tool_result` block. Anthropic's native
  Messages API rejects the OpenAI shapes.
- `_safe_fetch_image_for_gemini_sync` factors URL validation through
  `_safe_parse_https`, so malformed `port` access (e.g.
  `https://host:bad/x.png`) and malformed redirect targets (e.g. a
  302 to `https://[bad/x.png`) drop the image instead of raising mid-
  request.
- `tool_choice="none"` now disables hosted builtins (Gemini
  googleSearch / codeExecution and OpenAI Responses web_search /
  shell / image_generation), not just user function declarations.
- Schema sanitizer handles multi-type `anyOf` with null
  (`Union[str, int, None]`): keep the slim non-null anyOf and add
  `nullable: true` so Gemini does not reject `{"type":"null"}`.
- Image fetch falls back to the caller-provided MIME (guessed from
  URL extension) when the server omits Content-Type instead of
  dropping the image as `non-image content-type=<none>`.
- Per-request aggregate caps on remote image inlining (8 images,
  20MB total) so a single chat request cannot force unbounded
  backend downloads.
- Frontend exposes the reasoning ladder for `gemini-2.5-flash-lite`
  (`none/minimal/low/medium/high/max`) so the UI can drive the
  thinkingBudget the backend already supports.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 18 Gemini follow-ups (PR #5720)

- `tool_choice="none"` now opts out of hosted builtin tools on every
  provider path, not just Gemini and OpenAI Responses. Anthropic
  web_search / web_fetch / code_execution, Kimi `$web_search` early
  return, and OpenRouter `plugins:[{id:"web"}]` are all gated on
  `tool_choice_disabled`. Passing `enabled_tools=[...]` with
  `tool_choice="none"` no longer triggers provider-side search /
  code execution for any provider.
- `_stream_anthropic` accepts `tool_choice` and threads it through;
  the dispatcher in `stream_chat_completion` forwards it.
- Frontend `isServerSideBuiltinToolPart` simplified to drop only on
  (marker) OR (canonical name + native_part). The previous shape
  heuristic on `args.kind`/`args.command`/`args.code`/`args.prompt`
  dropped real user-declared `code_execution` / `image_generation`
  functions. Pre-PR persisted hosted cards lacking the marker now
  leak to non-native providers on switch -- preferred to silently
  deleting legitimate function-call history.
- Backend `_is_marked_server_builtin_tool_call` and the OpenAI
  Responses translator's matching filter accept BOTH `_server_tool`
  marker AND `args.google.native_part` as durable provider-side
  signals so Gemini code_execution / image_generation cards are
  still dropped on a provider switch.
- Per-request remote image count cap now counts ATTEMPTS, not just
  successful inlines, so 100 failing/slow URLs cannot each consume
  the 15s fetch timeout. Data: URL images now share the same count
  and byte caps as fetched remote URLs.
- OpenAI Responses translator tracks skipped server-builtin
  `function_call` ids and drops their matching `role="tool"`
  follow-ups, preventing orphan `function_call_output` items in the
  outbound body.
- Gemini schema sanitizer preserves multi-type unions with null:
  `{"type":["string","integer","null"]}` becomes
  `anyOf:[{string},{integer}] + nullable:true` instead of being
  flattened to the first non-null type.
- Gemini model id validation moved to the top of `_stream_gemini`
  so an invalid model id rejects the request before any remote
  image fetch / message translation side effect.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 19 Gemini follow-ups (PR #5720)

- `_build_external_messages` now skips an empty assistant turn when
  `_filter_tool_calls` drops every synthetic builtin tool_call (was
  guarded only on the `content is None` branch; the string-content
  and list-content branches still forwarded
  `{"role":"assistant","content":""}` which several providers
  reject). Also tracks the dropped server-builtin tool_call ids and
  skips the matching `role="tool"` follow-ups so the receiving
  provider does not see an orphan tool_result.
- OpenRouter `web_search_active` (the synthetic tool_start /
  tool_end emitter) is now also gated on `tool_choice_disabled` so
  a request with `tool_choice="none"` does not surface a fake
  web_search card in the chat UI even though the plugin was
  correctly stripped from the outbound body.
- `_stream_anthropic` translates an OpenAI role="tool" with list
  content (`content=[{"type":"text","text":"..."}]`) into a native
  `tool_result` block on a user message; previously only the
  string-content shape was translated, so list-content tool results
  were forwarded as invalid `role:"tool"` messages.
- Gemini `data:` URL image_url parts now require an `image/*` MIME
  type; a `data:text/html;base64,...` is dropped instead of being
  forwarded as `inlineData.mimeType="text/html"` (Gemini rejects
  the malformed image part). Symmetric with the fetched-remote
  image fetch path that already rejects non-image Content-Type.
- YouTube `fileData.fileUri` now declares `video/mp4` as the
  mimeType instead of `image/jpeg` guessed from the URL path. The
  YouTube/fileData input is the documented Gemini video path; the
  guessed image MIME made valid YouTube inputs malformed.
- OpenAI Responses translator preserves `response.output` ordering
  on assistant turns that emitted both text and a function_call:
  assistant text is now serialized BEFORE the function_call item
  so the subsequent function_call_output (the matching role=tool
  follow-up) lands in the right position. Previously the order
  was function_call -> assistant text -> function_call_output,
  which can confuse multi-turn function-calling flows.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: round 20 Gemini follow-ups (PR #5720)

Convergent reviewer findings from round 20:

- tool_choice="none" no longer flips responseModalities=[TEXT,IMAGE]
  on image-tier Gemini models. Forced-function tool_choice (e.g.
  {type:function, function:{name:lookup}}) also drops hosted Search /
  code execution from the Gemini body so the caller's pinned user
  function is not silently joined by hosted builtins.

- Gemini code-execution thoughtSignature replay now uses an ordered
  parts list (native_part.parts[]) so per-part signatures stay
  attached to the exact part Gemini emitted. The previous merged
  shape fanned one top-level thoughtSignature across executableCode
  + codeExecutionResult + inlineData and tripped Gemini 3 strict
  validators. Backward-compat fallback keeps pre-round-21 persisted
  history working: a legacy native_part with a single subpart still
  replays the signature on that subpart; merged legacy objects pin
  the signature to executableCode only.

- Remote-image fetch threads the remaining per-request byte budget
  into _safe_fetch_image_for_gemini, so over-budget URLs are
  refused via Content-Length pre-check / short read instead of
  fully downloaded then discarded after the aggregate cap check.

- Gemini role=tool with OpenAI list-form content
  ([{type:text,text:result}]) now flattens text parts before
  building functionResponse.response.result; previously the parts
  arrived as the result value instead of the actual tool output.

- Frontend chat-adapter merges native_part by concatenating parts
  lists (preserving per-part thoughtSignature). Wire types expose
  enable_prompt_caching as boolean|string (Gemini cached-content
  name) and OpenAIChatDelta now carries tool_calls and extra_content.

- Test test_openrouter_no_synthetic_web_search_event_on_tool_choice_none
  reads _toolEvent from the top-level SSE payload so a backend
  regression cannot mask the assertion.

Adds 7 regression tests covering image_generation gate, forced-function
gate, native_part list replay, legacy fallback, list-content
functionResponse flattening, fetch byte-budget threading, and wire
types.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Apply forced-function tool_choice gate to Anthropic, OpenRouter, Kimi

Previously only the Gemini path treated `tool_choice={"type":"function",
"function":{"name":...}}` as a hosted-tool opt-out. Anthropic,
OpenRouter, and Kimi still attached hosted web_search / web_fetch /
code_execution when the caller explicitly pinned a user function plus
`enabled_tools=[...]`. That contradicts the explicit function pin and
bills the caller for unwanted server-side calls.

Mirror the Gemini gate symmetrically:
  - Anthropic web_search / web_fetch / code_execution
  - OpenRouter `plugins:[{id:"web"}]` + the synthetic web_search SSE
    event the same path emits at stream close
  - Kimi `_stream_kimi_web_search` dispatch

Adds 4 regression tests:
  - test_anthropic_forced_function_tool_choice_drops_hosted_tools
  - test_openrouter_forced_function_tool_choice_drops_web_plugin
  - test_kimi_forced_function_tool_choice_skips_web_search_helper
  - test_openrouter_no_synthetic_web_search_event_on_forced_function_tool_choice

All 146 existing backend tests still pass.

* Strip Gemini-only synthetic tool history on local-GGUF dispatch

After a Gemini chat that ran code_execution / image_generation, switching
the same thread to a local GGUF model used to forward the synthetic
provider-side tool_calls (tagged with `args._server_tool` or carrying a
Gemini `args.google.native_part` payload) and the message-level
`extra_content` to llama-server. The receiving backend has no tool
declaration for those names and no use for Gemini thoughtSignature
metadata; in the worst case it can produce an orphan tool_call_id and a
confused continuation.

Add `_strip_provider_synthetic_tool_history()` and wire it through the
two local message builders:
  - `_openai_messages_for_passthrough`  (OAI-compat passthrough)
  - `_openai_messages_for_gguf_chat`    (standard GGUF chat path)

Real user-function `tool_calls` and their matching `role="tool"` replies
survive unchanged; only synthetic provider-side cards and Gemini-only
`extra_content` are stripped. If the synthetic call was the assistant
turn's only payload, the now-empty turn is dropped too so llama-server
does not reject the request.

Adds 2 regression tests:
  - test_strip_provider_synthetic_tool_history_drops_synthetic_only
  - test_strip_provider_synthetic_tool_history_drops_empty_assistant

142 existing backend tests still pass.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Disable Search/Code composer pills for Gemini image-tier models

For external Gemini image-tier models (gemini-2.5-flash-image,
gemini-3.x-image-preview, etc.), the backend unconditionally strips
code_execution and strips web_search on older image ids. Search is
still allowed on Gemini 3.x Pro/Flash image models, which
supportsBuiltinWebSearch already encodes per model.

Before this commit the composer pill gates were:
  searchDisabled = !modelLoaded || !(supportsTools || supportsBuiltinWebSearch)
  codeDisabled   = !modelLoaded || !(supportsTools || supportsBuiltinCodeExecution) || imageModeDisablesCode

`supportsTools` here is a local-runtime fallback that becomes true when
any tool-capable local model has been loaded in the session. With a
local tool-capable runtime active, switching the chat to an external
Gemini image-tier model used to leave Search/Code clickable, even
though the backend will silently drop the tool on the wire.

Detect "external provider is Gemini AND the model is image-tier" (via
supportsBuiltinImageGeneration) and gate the two pills strictly on the
provider's own builtin support in that case. Non-Gemini paths and
non-image Gemini models keep the supportsTools fallback unchanged.

* Apply forced-function tool_choice gate to OpenAI Responses path

Round 22 added the gate for Gemini / Anthropic / OpenRouter / Kimi but
missed the OpenAI Responses translator. When a caller pinned a user
function via `tool_choice={"type":"function","function":{"name":...}}`
plus `enabled_tools=["web_search","code_execution","image_generation"]`,
the Responses body still attached `{"type":"web_search"}`,
`{"type":"shell"}`, and `{"type":"image_generation"}` server tools. The
function pin should suppress those for the same privacy + billing reason
the other provider paths now do.

Compute `_responses_tool_choice_forced_function` next to
`_responses_tool_choice_none` and gate each hosted-tool append on
`_responses_hosted_builtins_allowed = not none and not forced_function`.
The fix has to be applied in TWO places: the initial body builder and
`_build_body()` (called by the container-expiry retry path). User
function declarations still flow through so the pin has something to
target, and the Responses-shape `{type:"function", name:"..."}`
`tool_choice` is forwarded unchanged.

Adds regression test `test_openai_responses_forced_function_tool_choice_drops_hosted_tools`.
All 166 existing backend tests across Gemini + Responses + image-gen +
code-exec suites still pass.

* Round 24 P1s: SSRF shared-address gap + extra_content text-only leak + custom-Gemini model list

Three convergent P1s from round 24 review:

1. SSRF: the shared SSRF validator in `tools._validate_and_resolve_host`
   used a denylist (is_private / loopback / link_local / multicast /
   reserved / unspecified). Python classifies shared address space
   (100.64.0.0/10 carrier-grade NAT, plus 240.0.0.0/4, benchmarking
   ranges, etc.) with `is_private=False` AND `is_global=False`. The new
   Gemini server-side image fetcher therefore accepts URLs whose
   hostname resolves to 100.64.0.1 in cloud/VPC deployments. Add
   `not ip.is_global` as the primary gate -- a single source of truth
   that covers every current and future non-global range.

2. _strip_provider_synthetic_tool_history previously only stripped
   message-level `extra_content` when the assistant turn had tool_calls.
   A plain text Gemini reply carrying
   `extra_content.google.thought_signature` flowed through to
   llama-server when the thread was switched to a local GGUF backend.
   Always strip message-level `extra_content` on assistant turns.

3. routes/providers.list_provider_models applied Gemini's native
   `model_id_allowlist` regex to every Gemini provider, including
   custom OAI-compatible bases (LiteLLM, deployment gateways). IDs like
   `google/gemini-2.5-flash` and team-prefixed deployment aliases got
   filtered out even though the chat-dispatch path now routes them via
   the OpenAI-compatible client. Skip registry-level model-id filters
   when the configured Gemini base_url host is not the canonical
   `generativelanguage.googleapis.com`, mirroring the chat-dispatch
   gate.

Three regression tests added:
  - test_validate_and_resolve_host_blocks_shared_address_space
  - test_strip_provider_synthetic_tool_history_drops_text_only_extra_content
  - test_gemini_custom_oai_compat_base_skips_native_allowlist

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Round 25 P1s: skip synthetic server-tool replay + inline $ref/$defs into Gemini schema

Two convergent reviewer findings on the native Gemini path:

1. _stream_gemini's tool_calls replay loop falls through to a generic
   functionCall emission whenever it sees an assistant tool_call. Marked
   server-side builtin cards (web_search / web_fetch tagged with
   _server_tool or args.google.native_part) hit that fallthrough with no
   replayable native_part, which produces an outbound functionCall whose
   name is not a declared user function. The Gemini turn 400s on the
   undeclared name. Guard the loop to drop those entries instead, while
   keeping the existing code_execution / image_generation native-part
   replay branch intact.

2. _sanitize_gemini_schema uses a strict allowlist that drops local
   $ref / $defs references. Pydantic-generated tool schemas hoist nested
   object shapes into $defs and reference them via {"$ref": "#/$defs/X"},
   so a property like address: {"$ref": "#/$defs/Address"} collapsed to
   {} on the wire and the model lost the nested fields, types, and
   required keys. Resolve local #/... pointers against the schema root
   and inline the referenced subtree, with local siblings overriding
   the reference (normal JSON Schema composition) and a seen-ref guard
   for self-referential schemas.

Added regression coverage:
- test_gemini_native_skips_synthetic_server_builtin_replay
- test_function_declarations_inline_local_refs_into_gemini_schema
- test_function_declarations_inline_local_refs_in_anyof_and_items
- test_function_declarations_self_referential_schema_terminates

All 145 Gemini provider tests pass; touched provider regression set
(OpenAI Responses, code execution, image generation, Anthropic code
execution, Anthropic web_fetch) also 43/43 green.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Round 26 P1s: drop orphan Gemini functionResponse + Anthropic /messages synthetic-history strip

Reviewer round 26 surfaced two convergent asymmetric-fix bugs.

1. _stream_gemini drops a synthetic server-tool tool_call (web_search /
   web_fetch tagged _server_tool) and also replays code_execution /
   image_generation tool_calls as Gemini-native executableCode /
   codeExecutionResult / inlineData parts. The matching role="tool"
   follow-up was still falling through to the generic functionResponse
   branch, producing either an orphan functionResponse (synthetic case)
   or a duplicate response pointing at a name with no
   functionDeclarations entry (native-part case). Both forms 400 the
   next Gemini turn. Track skipped + native-replayed tool_call_ids in
   _gemini_skip_tool_result_ids and short-circuit the role="tool"
   branch on a match.

2. The Anthropic-compatible local /v1/messages route only called
   _drop_empty_assistant_sentinels on the OpenAI-translated history,
   while the sibling /v1/chat/completions and GGUF passthrough builders
   chain that with _strip_provider_synthetic_tool_history. An Anthropic
   caller replaying a prior provider-side tool_use therefore forwarded
   fake builtin tool history straight into local llama-server. Apply
   the same strip on the Anthropic route after the
   anthropic_messages_to_openai conversion.

Regression coverage added:
- test_gemini_native_skips_orphan_function_response_for_dropped_builtin
- test_gemini_native_skips_orphan_function_response_for_native_part_replay

Gemini suite 147/147; touched provider regression set 43/43.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Round 27 P1s: native_part location fallback + Gemini image request budget for base64

Two convergent reviewer findings on the native Gemini path.

1. _stream_gemini's synthetic-builtin detector at lines 3519-3524
   recognizes args.google.native_part as a server-tool marker, but
   _native_part was only loaded from tc.extra_content.google.native_part.
   A direct OpenAI-compatible API caller or imported third-party thread
   round-trips the payload through function.arguments because
   tool_calls[].extra_content is not in the OpenAI spec. The round-25
   guard then saw a synthetic builtin with no _native_part and dropped
   the entire assistant turn, so the next native Gemini request lost
   the prior executableCode / inlineData / codeExecutionResult context.
   Fall back to args.google.native_part when extra_content path is
   missing, mirroring what the synthetic detector already accepts.

2. _GEMINI_REMOTE_IMAGE_MAX_TOTAL_BYTES capped DECODED bytes at 20MB.
   Gemini receives images base64-encoded inside JSON, and base64
   inflates payload size by ~4/3. With 20MB decoded the actual JSON
   body is ~26.7MB plus prompt overhead, well over Gemini's ~20MB
   request limit. Drop the decoded cap to 14MB so realistic multi-
   image turns stay safely under 20MB encoded.

Added regression test test_gemini_native_part_falls_back_to_args_google
covering an OpenAI-compat-shaped image_generation tool_call whose
native_part lives only in function.arguments.

Gemini suite 148/148.

* Fix TS build errors from main merge: restore imageParts + refusal return [] + cast image-edit ref

Three errors in chat-adapter.ts surfaced by the frontend tsc step after merging
main into feat/gemini-provider:

1. The Anthropic refusal early-return used main's  but
   toOpenAIMessages returns SerializedMessage[]; flip to .
2. Restore  -- the line
   was lost when removing main's conflict block from the function body.
3. selectedImageEditReference splice was inserting OpenAIChatMessage
   into a SerializedMessage[] array; the shapes differ on tool_calls.id
   nullability. Cast the reference message through unknown -- it carries
   no tool_calls, so the runtime payload is structurally compatible.

Reproduced locally with `tsc -b --pretty false` (now passes). Build
also failing in the in-repo `npm run build` step on PR CI; this commit
unblocks all 12 failing UI/API workflows.

* Tighten verbose comments in external_provider.py + chat-adapter.ts

Compress multi-line explanatory comments in the Gemini translator
and the chat adapter without changing any behaviour. All 148 Gemini
provider tests still pass; tsc --noEmit clean.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@users.noreply.github.com>
2026-05-27 06:01:24 -07:00
Datta Nimmaturi
8839118268
[RL] make sync weights conditional (#4925)
* make sync weights conditional

* Also conditionalise vllm creation

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* guard sync for weight sharing

* Guard self.llm access in VLLMGeneration sync_weights and generate patches for PR #4925

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-05-27 05:53:26 -07:00
Roland Tannous
0be7ca39a4 Studio: render figure regions (vector + raster) for RAG captioning
page.get_images() only returns raster blobs embedded in the PDF's
resource dictionary, so vector schematics like Figure 1 — drawn purely
with paths/lines — were never extracted, and the VLM only ever saw
incidental embedded photos that happened to live near figures.

Replace the xref-based extraction with bbox rendering: union the
bounding rects of all vector drawings and raster image_info entries on
each page, expand a few points, and render the region with
get_pixmap(clip=bbox, matrix=2x). The captioner now receives the
actual figure — schematic arrows, box labels, legend text, and any
inset photos — and produces a caption that describes the figure as a
whole, not just one embedded sub-image.

Also sharpen the captioner prompt: explicitly tell the VLM the image
is a single figure cropped from a PDF page, and not to describe page
chrome or body paragraphs.
2026-05-27 16:36:54 +04:00
Roland Tannous
6659bdf152 Studio: add figure-reference retrieval source to RAG hybrid search
Dense vectors don't preserve numbers (BGE-small treats 'Figure 1' and
'Figure 10' as nearly identical), so a query like 'what does Figure 1
show' got out-ranked by chunks describing other figures that share more
vocabulary with the question — even after the figure-boundary chunker
ensured Figure 1's chunk started with the literal caption.

Detect 'Figure N' / 'Table N' (numbered, decimal, appendix-style)
references in the query, look up chunks that start with those captions
directly, and feed the result as a third RRF source. RRF gives them
rank-0 in the third ranking and the fused score lifts them above the
dense-vocabulary noise. No-ops when the query has no figure ref.
2026-05-27 16:22:08 +04:00
Daniel Han
131fa4fcae
Trim README Advanced launch options blurb (#5809) 2026-05-27 05:15:45 -07:00
alkinun
9222ffd9b4
Studio: add configurable CPU thread pool limit (#5760)
* Studio: add configurable CPU thread pool limit

* Studio: report invalid CPU thread setting cleanly

* Studio: also cover uvicorn main:app, harden tests, move docs to advanced

---------

Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-05-27 05:09:34 -07:00
Roland Tannous
ba0fd85e8b Studio: break RAG chunks at figure/table caption boundaries
Dense embedders mean-pool over a whole chunk, so a 'Figure 1:' caption
buried at the end of a 500-token body chunk gets washed out by the
surrounding theory text and never surfaces for queries about that
figure. Pre-split each page's markdown at the start of every
Figure/Table caption line so the caption anchors its own chunk, which
gives both BM25 and the dense vector a focused, figure-dominated
target. Handles numbered, decimal, and appendix-style labels
(Figure 1, Figure 1.2, Figure B.1, Table 4, Fig./Tab. abbreviations).
2026-05-27 16:09:23 +04:00
Dariton4000
dac2aeda1a
Studio: expose image size setting in training UI (#5743)
* Studio: add VLM image-size control for training

  Studio vision fine-tuning had no explicit way to cap image resolution, so
  users could not trade visual detail against context and memory use from the
  training UI, YAML config, or API payload. :) Add a nullable `vision_image_size`
  setting that keeps the current model default when unset and applies a
  max-side resize when provided.

  - Add `vision_image_size` to the training request model, route payload, backend
    training config, and frontend API/types plumbing.
  - Validate the value server-side as either null or an integer in the supported
    256-2048 range.
  - Surface an Image Size selector for vision LoRA training with Default plus
    common preset sizes.
  - Include the value in training start payloads only for image-dataset vision
    models, and serialize it into vision-aware YAML configs.
  - Map backend model defaults back into the training store and reset the value
    when reapplying model defaults.
  - Pass the resize through the Torch trainer via `UnslothVisionDataCollator`
    using max-dimension semantics.
  - Apply the same max-dimension resize in the MLX VLM path before mlx-vlm's
    internal collation, preserving aspect ratio and avoiding upscaling.
  - Add backend validation coverage and MLX resize-size tests for the new
    behavior.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: thread vision_image_size into DeepSeek OCR + writable MLX ndarray

- trainer.py: DeepSeek OCR collator now honors the new vision_image_size
  setting as image_size. Falls back to 640 when null. base_size stays at
  1024 and crop_mode stays True so the Gundam preset's dynamic cropping
  of large documents keeps working.
- worker.py: _resize_mlx_vlm_image returns np.array(image, copy=True)
  instead of np.asarray(image). The PIL view from np.asarray is not
  writable, which makes HF VLM processors emit "The given NumPy array
  is not writable, and PyTorch does not support non-writable tensors..."
  when they call torch.from_numpy. copy=True keeps the same shape and
  dtype but produces a writable buffer.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: align YAML export gate with API mapper + extend Image Size dropdown

- training-section.tsx: handleSaveConfig now passes
  isVisionModel && isDatasetImage === true to serializeConfigToYaml,
  matching buildTrainingStartPayload. Stops vision_image_size from
  leaking into exported YAML for text-only datasets where the API
  would have sent null.
- params-section.tsx: add 256 to visionImageSizePresets so the
  dropdown spans the validator's full [256, 2048] range. Also render
  a synthetic SelectItem for the current value when it was loaded
  from YAML or model defaults and is not in the preset list, so the
  controlled Select always shows the active size.

* Studio: validate vision_image_size in YAML/model-default loader

mapBackendModelConfigToTrainingPatch now mirrors the backend validator
at studio/backend/models/training.py:169 by dropping any value that is
not an integer in [256, 2048]. Pre-fix, an imported YAML like
vision_image_size: 4096 or 640.5 would land in the store and the UI
would happily display it, only to fail when Start Training posted to
the backend. With this guard the store never holds a value the backend
would reject.

* Studio: precise error messages for invalid vision_image_size inputs

Switch the field_validator to mode="before" so True/False surface as
bool (not Pydantic's coerced 1/0) and give a precise
"must be an integer or null" message instead of the misleading
"must be in [256, 2048] (got 1)". Also explicitly accepts numpy
Integral and integral Real scalars so YAML or programmatic callers
using numpy ints keep working.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: test that bool inputs yield the precise 'integer or null' error

Regression guard for the validator switch to mode="before". Pre-fix,
vision_image_size: True was rejected with "must be in [256, 2048]
(got 1)" because Pydantic coerced before our check ran. New test
asserts the message now reads "integer or null".

* Studio: tighten vision_image_size loader + YAML save + MLX rounding

Round 2 of follow-up review surfaced three usability issues:

- model-defaults.ts: switching to a model whose backend YAML omits
  vision_image_size now explicitly resets the store value to null.
  Pre-fix, a stale 2048 from a previous model would silently apply
  to the new run because every checked-in model-default file omits
  the key.
- training-section.tsx: handleSaveConfig now includes vision fields
  unless isDatasetImage is definitively false. isDatasetImage is null
  during dataset checks, after dataset edits, and on import; treating
  unknown as "drop" would silently lose the user's selection in those
  windows. Confirmed-text-only datasets still drop the value.
- worker.py: _mlx_vlm_max_resized_size now mirrors the Torch collator's
  integer formula (w * size + size_func // 2) // size_func instead of
  Python round(), which uses banker's rounding and disagreed by 1px on
  half-pixel inputs like 333x1000 with target 500 (was 166, now 167).
  Test_mlx_training_worker_config gains parity assertions.

* Studio: reset vision_image_size in the model-config error fallback path

mapBackendModelConfigToTrainingPatch resets stale image size on the
success path, but if the /api/models/config endpoint throws,
training-config-store.ts falls through to checkVisionModel and only
updates capability flags. Pre-fix that left a stale 2048 (or any
prior selection) in the store, so once dataset detection marked the
new dataset as image, the next training start would silently apply
the previous model's size. The error branch now also resets to the
DEFAULT_HYPERPARAMS.visionImageSize sentinel.

* Studio: revert DeepSeek OCR Image Size knob + move missing-key reset

Round 3 of the parallel-reviewer pass surfaced two issues that I had
introduced earlier in this PR's follow-ups.

- trainer.py: my prior change threaded vision_image_size into the
  DeepSeek OCR collator's image_size argument. The collator's
  (image_size, base_size, crop_mode) is a single preset
  (Tiny / Small / Base / Large / Gundam); changing image_size in
  isolation desynchronizes the per-crop pixel grid from num_queries
  downstream and produces wrong token grids on documents larger than
  the per-crop tile. The fix pins the collator back at the Gundam
  preset and logs a clear "ignored for DeepSeek OCR" notice when the
  user has selected a non-default Image Size.
- model-defaults.ts + training-config-store.ts: the round 4 fix that
  reset visionImageSize when a model YAML omitted the key also fired
  on same-model reloads (ensureModelDefaultsLoaded re-fires on page
  refresh), wiping a value the user had just selected. The reset is
  now in setSelectedModel, gated on selectedModel != previousModel,
  so true model switches still clear stale values while reloads keep
  the user's selection.

* Studio: extend DeepSeek OCR Image Size exclusion to MLX + frontend

Round 4 of the parallel-reviewer pass flagged that the Torch trainer
exclusion I added did not have a matching MLX guard, and that the UI
still offered the dropdown for DeepSeek OCR even though the backend
ignores it.

- worker.py: _run_mlx_training now mirrors the Torch exclusion. When
  the model name matches DeepSeek OCR, vision_image_size is forced
  back to None before _adapt_for_mlx_vlm sees it, so dataset images
  pass through unchanged just like the Torch path. Emits a clear
  status line when this happens.
- params-section.tsx: the Image Size Row is now gated on
  showVisionImageSize (showVisionLora && !isDeepseekOcr) instead of
  showVisionLora alone, so DeepSeek OCR users no longer see a control
  that silently has no effect.
- mappers.ts: buildTrainingStartPayload sends null for vision_image_size
  whenever the selected model is DeepSeek OCR, so the backend log line
  about ignoring the value never fires from a UI-driven start.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: tighten YAML import/save for vision_image_size

Two YAML-path asymmetries that could leak a stale image size into
training:

- parseYamlConfig now treats a missing training.vision_image_size as
  null. Without this, importing a YAML saved before this feature (or
  any config that omits the key) preserved whatever value the user had
  previously set on a different model. The model-defaults reload path
  still uses Object.hasOwn so same-model defaults reloads do not wipe
  a manual selection; only file import normalises the missing key.

- handleSaveConfig now passes a DeepSeek-OCR-specific guard to
  serializeConfigToYaml so saved YAML matches what the API mapper
  actually sends. Previously a state with visionImageSize set could
  emit the key even though Studio ignored it at training time for
  DeepSeek OCR, and a later import for a non-DeepSeek vision model
  would activate the stale value.

serializeConfigToYaml gains an optional third parameter
includeVisionImageSize defaulting to includeVisionFields, preserving
the existing 2-arg call signature for backwards compatibility.

* Studio: also reset vision_image_size when YAML lacks a training section

Round 9's parseYamlConfig normalization only fired when the YAML had a
training mapping that omitted vision_image_size. A lora-only or
logging-only YAML (or one with `training: null`) still left trainingObj
unset, the mapper saw no vision_image_size key, and the previously
selected store value persisted into the next training run.

Now an absent or null training section is synthesised as
{ vision_image_size: null } so model-defaults.ts always patches
visionImageSize back to Default on file import. Same-model defaults
reloads still preserve manual choices via the existing Object.hasOwn
gate in mapBackendModelConfigToTrainingPatch.

* Studio: unify parseYamlConfig non-object training handling

A fresh static review (Opus subagent) flagged P3-1: parseYamlConfig
only synthesised vision_image_size: null when raw.training was either
absent or a plain object missing the key. If raw.training is a scalar
or an array (malformed but still parseable), the value was passed
through unchanged, the mapper's Object.hasOwn returned false, and any
previously selected visionImageSize persisted - the same stale-state
leak the lora-only fallback was added to close.

Treat any non-plain-object raw.training (null, array, scalar) as a
malformed/missing section and reset to { vision_image_size: null }.

* Studio: tighten code comments for vision_image_size path

* Studio: tighten vision_image_size validator + restore lost comment context

Two issues surfaced by a fresh adversarial review of the validator:

1. v.strip().lstrip("+-").isdigit() let "++512" / "--256" / "+-+512"
   slip past the gate, then int("++512") raised an uncaught ValueError
   and Pydantic surfaced "invalid literal for int() with base 10: '++512'"
   instead of the contracted "vision_image_size must be an integer or null".

2. str.isdigit() returns True for Unicode digit families (full-width '512',
   Arabic-Indic '٥١٢', Devanagari '१०२४'), and int() coerces them, so the
   value reaching the backend wasn't the ASCII the user typed.

Replaced the lstrip+isdigit pair with re.fullmatch(r'[+-]?[0-9]+', stripped),
which rejects both shapes with the precise error and accepts the documented
ones ('256', '+512', ' 1024 '). Added 8 regression test cases covering
multi-sign strings, lone sign, and the three Unicode digit families.

Also restored comment context lost in f9c39331:
- model-defaults.ts: name studio/backend/models/training.py:_check_vision_image_size
  as the spec the [256, 2048] range mirrors, so a maintainer changing the
  cap in one file can find the other.
- training-section.tsx: enumerate the three windows in which isDatasetImage
  is null (before a check, after dataset edits, on import) so a future
  maintainer doesn't simplify the gate to `isCheckingDataset`.
- worker.py: qualify the writable-ndarray comment with "when a resize is
  requested" so it doesn't misadvertise the resize=None early-return.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-05-27 05:01:24 -07:00
DoubleMathew
ad2ba4202a
Harden Linux llama.cpp prebuilt reuse (#5796)
Run the Linux llama.cpp prebuilt dependency preflight before reusing an existing install, so cached trees missing newly required per-tool shared libraries (libllama-server-impl.so / libllama-quantize-impl.so introduced upstream between b9279 and b9283) trigger a repair instead of silently skipping reinstall and failing at runtime. Adds a regression test for the new lib*-impl.so overlay layout.
2026-05-27 05:01:04 -07:00
Daniel Han
ca55acbb5f
Studio: unblock install on Linux ARM64 + Windows ARM64 + Intel Mac (#5790)
* Studio: unblock cross-platform install on Linux ARM64 + Windows ARM64

Three independent bugs that together prevent `install.sh` /
`install.ps1` from completing on the ARM machines GitHub Actions now
ships (`ubuntu-24.04-arm`, `windows-11-arm`) and on equivalent real
hosts (Ampere Altra, Raspberry Pi 5, Snapdragon X Elite, ...).

Validated on the staging-2 cross-OS smoke suite -- five per-OS
workflows pinned to `ubuntu-latest`, `ubuntu-24.04-arm`, `macos-14`,
`macos-15-intel`, `windows-11-arm`. Before this change Windows ARM
exits 1 in the winget gate and Linux ARM source-builds llama.cpp
because the prebuilt selector returns 0 attempts; with it both reach
healthy /api/health.

1. studio/install_llama_prebuilt.py -- resolve_simple_install_release_plans
   had explicit branches for windows+x86_64, macos+arm64, macos+x86_64
   and linux+x86_64 only. Upstream ggml-org/llama.cpp ships
   `llama-bNNNN-bin-ubuntu-arm64.tar.gz` and
   `llama-bNNNN-bin-win-cpu-arm64.zip` (visible in the b9334 release
   manifest), so the missing elif branches force every Linux ARM64 and
   Windows ARM64 host into a source build even when a perfectly good
   upstream prebuilt is one HTTP GET away. Two new branches mirror the
   existing CPU variants; runtime_patterns_for_choice and
   runtime_payload_health_groups gain `linux-arm64` (.so layout) and
   `windows-arm64` (.dll layout) so the health-check pass-through
   matches the asset shape.

2. studio/setup.sh -- the helper-release-repo selector routed any
   non-x86_64 Linux to `unslothai/llama.cpp`, which only publishes the
   Linux CUDA bundle set. The result on Linux ARM64 was a guaranteed
   `direct_linux_release_plan` raise of "no compatible Linux prebuilt
   asset was found" on every release in the scan, then a source-build
   fallback. Pin Linux ARM64 (CPU-only) to `ggml-org/llama.cpp` so the
   new branch in (1) can see the upstream asset. setup.ps1 already
   hardcodes `ggml-org/llama.cpp`, so Windows ARM64 picks up (1)
   without an additional change.

3. install.ps1 -- the winget pre-check hard-failed before Python or uv
   detection. `windows-11-arm` runners (and many corporate Windows
   hosts without the Microsoft Store) ship without winget but already
   have a usable Python plus the Astral uv PowerShell installer
   reachable. Demote the winget check to a soft warning, defer the
   hard failure to the Python install branch (which is the only path
   that genuinely needs winget), and let the uv install fall through
   to `https://astral.sh/uv/install.ps1` when winget is absent. The
   uv PowerShell installer was already the existing fallback for the
   "winget present but uv install failed" case; this just makes it
   the primary path on hosts without winget.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: filter torchcodec on platforms without wheels

torchcodec 0.10.0 ships wheels for manylinux_2_28_x86_64,
macosx_12_0_arm64, and win_amd64 only -- visible on its PyPI page and
in the resolver error reported by #4446. install_python_stack.py
pulls torchcodec via extras-no-deps.txt, which is now installed
unconditionally during `unsloth studio update --local` (the update
command has no --no-torch flag). Result on Linux aarch64 /
Windows ARM64 / Intel Mac (when invoked outside the install.sh
auto-skip-torch path):

  ERROR: Could not find a version that satisfies the requirement
  torchcodec==0.10.0 (from versions: 0.0.0.dev0, ...)
  ERROR: No matching distribution found for torchcodec==0.10.0
  error          Installing extras (no-deps) (pip) failed (exit code 1)

`NO_TORCH_SKIP_PACKAGES` already lists torchcodec but only fires
when NO_TORCH is true -- the update path inherits no NO_TORCH from
the original install and inferrence falls back to IS_MAC_INTEL only,
so Linux aarch64 / Windows ARM64 sail past the guard. Adds a
platform predicate PLATFORM_LACKS_TORCHCODEC_WHEEL and applies the
torchcodec filter unconditionally there, independent of NO_TORCH.

Surfaced by the staging-2 cross-OS smoke `unsloth studio update`
step on ubuntu-24.04-arm; verified the same step is green with this
patch overlaid.

* Studio: skip librosa on no-torch hosts (unblocks Intel Mac install)

Closes the last cross-platform install gap surfaced by the staging-2
cross-OS smoke (see unslothai/unsloth#5046 for the original report):
`install.sh --local` on macos-15-intel fails at

  × Failed to build `llvmlite==0.47.0`
  error: failed-wheel-build-for-install
  ╰─> llvmlite
  error          studio setup failed (exit code 1)

Root cause: upstream llvmlite dropped the macosx_x86_64 wheel between
0.42.0 and 0.46.0 (https://pypi.org/project/llvmlite/0.47.0/#files --
only macosx_arm64 / manylinux / win_amd64 remain). pip falls back to
a from-source build of llvmlite's FFI, which needs LLVM 14/15 dev
headers and matching llvm-config -- not present in Xcode Command
Line Tools' libclang and not installed by install.sh's MAC_INTEL
deps branch.

llvmlite enters Studio's tree via librosa -> numba -> llvmlite in
extras.txt. openai-whisper (extras.txt:28) would also pull numba but
is already filtered on no-torch hosts. Adding librosa to the same
NO_TORCH_SKIP_PACKAGES set makes the install go through cleanly on
Intel Mac (auto-detected NO_TORCH=true via the MAC_INTEL branch) and
on any user-passed --no-torch host where torch-dependent audio
pipelines would not run anyway.

Tracked / verified on the danielhanchen/unsloth-staging-2#154 smoke
matrix (macos-15-intel).

* Studio UI tests: retry evaluate_fetch on transport-level failure (PR #5790)

Mac Studio UI CI on this PR (run 26496820814, job 78026959359) failed
with /api/models/list status=0 error='TypeError: Failed to fetch'.
The artifact studio.log shows the server answered the two preceding
/api/models/list calls from the React mount (both 200) but never
received the third call from the test script: the browser reused a
kept-alive HTTP/1.1 socket that uvicorn (5s keep_alive_timeout) had
closed ~130ms earlier. Chromium under --single-process on macos-14
free runners is most prone to this; the post /api/auth/change-password
session churn accelerates it. A rerun on the same SHA passed, which is
the classic flake signature.

evaluate_fetch in tests/studio/_playwright_robust.py already returns a
structured {status: 0, body: None, error: "..."} on JS-side throws, but
every caller treats status=0 as fatal. Add a bounded retry inside the
helper so the one class of failure recovers transparently:

  status != 0       -> real HTTP response (incl. 4xx/5xx); propagate.
  error has "AbortError" -> caller's AbortSignal deadline; propagate.
  else (status==0)  -> stale-keepalive or other transport failure;
                       retry after 250ms / 500ms backoff so the pool
                       evicts the dead socket before the next attempt.

Defaults transport_retries=2, transport_backoff_ms=250 (max added
latency on the happy path is zero; on a transport failure: up to
750ms of sleep). Callers keep the existing {status, body, error} shape;
no call-site changes needed.

Verified: tests/studio/_playwright_robust.py compiles; signature
gains two kwonly args (transport_retries, transport_backoff_ms);
8 evaluate_fetch call sites in playwright_chat_ui.py +
playwright_extra_ui.py pick up the retry without change.

---------

Co-authored-by: danielhanchen <info@unsloth.ai>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-27 04:53:38 -07:00
wuwuwu
4891118b5e
Studio: add frontend i18n support (#5765)
* Studio: add frontend i18n support

* Studio i18n: guard storage events, restore plurals, fill zh-CN, add parity check

- locale-store.ts: wrap window.localStorage access in handleStorageEvent
  with try/catch. readStoredLocale and writeStoredLocale already guard the
  same API; the storage-event path can throw the same way in privacy/
  restricted contexts and was the only unguarded localStorage call. Refactor
  the storageArea + key match into isLocaleStorageEvent for clarity.

- chat-tab.tsx + en.ts/zh-CN.ts: restore singular handling for chat-clear
  copy that the i18n migration dropped. Pre-PR code rendered "1 chat" but
  the new template strings always said "chats", so a user with exactly one
  chat saw "Cleared 1 chats", "Clear 1 chats?", and "1 chats cleared;
  1 chats remain". Add clearOneChat*, clearedOneChat, oneChatClearedRemain*,
  chatsClearedRemainOne, and storageClearFailedOne keys and pick them in
  chat-tab.tsx when count === 1.

- zh-CN.ts: fill ~50 previously English-fallback keys across studio.configure,
  studio.model VRAM helpers, studio.dataset (source, browsing, tooltips,
  preview/split/subset), studio.params tooltips and learningRateDescription,
  studio.training (audio/vision incompatible), studio.trainingStart.terminalStart,
  studio.tour.guidedTour, settings.chat.clear*, settings.connections,
  settings.apiKeys.newBadge. shell.{beta,brand,product} kept as brand strings.

- src/i18n/check-parity.ts + npm i18n:check: small script that verifies every
  locale overlay against the English baseline. Catches placeholder mismatches,
  shape mismatches, and unintended extra keys; runs via node --experimental-
  strip-types with no new devDependencies.

Verified locally:
  npm run typecheck, lint, build, biome:check, i18n:check all pass.
  24 vitest unit tests cover locale resolution, persistence failures,
  storage-event sync (including window.localStorage throwing), interpolation,
  and fallback.
  33 Playwright e2e tests pass across Chromium, Firefox, and WebKit covering
  default load, switch + reload persistence, unsupported/garbage locale
  fallback, storage-event cross-tab sync, and storage clear.

* Studio i18n: use translated API-key error copy instead of raw err.message

The API helpers in src/features/settings/api/api-keys.ts throw generic
English Error objects ("Failed to load API access", "Failed to create
access token", "Failed to revoke access token"). ApiKeysTab and
CreateKeyForm caught those and preferred err.message over the translated
"settings.apiKeys.loadError" / .createError / .revokeError keys, so in
zh-CN mode failed load/create/revoke requests still surfaced the English
strings instead of the translated copy.

Switched the four call-sites to always render the translated message and
left the helper throws unchanged (they are still useful for diagnostics
but should not be treated as user-facing localized copy).

* Studio i18n: polish two zh-CN embedding LR tooltips

Translation-pass review surfaced two awkward phrasings I introduced earlier:

  "常用区间是主学习率的 2 至 10 倍小"
    -> "常用区间是比主学习率小 2 至 10 倍"

Both versions are grammatical, but the new "比 X 小 N 倍" phrasing is the
standard idiomatic comparative for "N times smaller than X" in technical
Chinese writing. The earlier "X 的 N 倍小" reads as a non-native construction.

Applies to:
  studio.params.embeddingLearningRateTooltip
  studio.params.embeddingLearningRateDescription

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-05-27 04:52:16 -07:00
Roland Tannous
0481ac30c6 Studio: disable thinking for RAG captioner requests
Reasoning models (gemma-4, qwen3-thinking) burn the entire max_tokens
budget on <thinking> output and return empty visible content, so the
captioner produced zero captions for every image. Pass
chat_template_kwargs={enable_thinking: false} per-request to skip the
reasoning phase, and bump max_tokens 120 -> 200 as headroom.
2026-05-27 15:33:48 +04:00