From 8b3c9ad8ee96cae7b4da51175d998da8884cec27 Mon Sep 17 00:00:00 2001 From: mattshax Date: Thu, 24 Sep 2026 14:57:01 +0000 Subject: [PATCH] fix(models): say what the provider said, bound silent streams, ai-chat 0.5 The availability banner explained every [unavailable] mark as a key on an 8-hour lock schedule. Measured against a provider whose models were marked, the cause was the provider refusing the model names outright, streamed and not ("Requested model is not available and no compliant same-model variant was found"), which no unlock would fix. The mark was right and the explanation wrong. Each marked model now carries the provider's own sentence, unwrapped from the gateway's nested error bodies, and the banner quotes it; a lock still gets the unlock step. A stream that opens and then sends nothing had no limit other than the client's own abort. Each read now has one, reset by every chunk and set at five minutes, since some providers buffer a whole answer and take about a minute per call. ai-chat moves to 0.5.0 and @parallelworks/ui to 0.16, its new floor. The attachments list now pages by cursor and reports hasMore; the adapter maps the server's offset to that contract. The chat renders and lists models with no page errors in a headless check. The change log is regenerated, since the last two entries never reached the default branch. The earlier probe change (v1.60) removed the token cap on the claim that a cap-rejecting family was being marked; its live verification had hit the wrong gateway, and the marks it was meant to clear had this other cause. The cap removal stays, since a cap was never needed for a probe. --- CHANGELOG.md | 26 ++++++++++ pnpm-lock.yaml | 77 ++++++----------------------- server/src/chat/gateway.ts | 30 ++++++++++- server/src/chat/routes.ts | 13 ++--- server/test/providerProbe.test.mjs | 15 ++++++ web/package.json | 4 +- web/src/adapter.ts | 10 +++- web/src/api.ts | 2 +- web/src/views/ChatView.tsx | 18 +++++-- web/test/attachmentsCursor.test.tsx | 25 ++++++++++ 10 files changed, 141 insertions(+), 79 deletions(-) create mode 100644 web/test/attachmentsCursor.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 487e3d0..8477189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ Every version, newest first. Each entry is the description of the pull request that made the change, which is written as a change note when the change is made. +## v1.63 (2026-09-23) + +### Feature previews is its own settings section (#343) + +The voice preview switch sat at the foot of **External access**, below the retrieval endpoint and the MCP configuration. A deployment reported the preview missing while its own bundle carried the control; it was findability, not a deploy gap. + +Feature previews is now its own section in the settings rail, between Assistant tools and Extensions. The rail is extracted as `settingsSections()`, a pure function with a test, so a new section has to be placed deliberately. The voice text now also states that the Unmute deployment must be reachable from the browser in use, since a closed network needs its own rather than one running elsewhere. + +## v1.62 (2026-09-15) + +### The model listing answers from a cache and refreshes behind it (#342) + +Every load of the model list fetched the gateway catalog and pinged each provider family (about a second on dev, several seconds where the gateway and providers are further away and each user's credential probes on its own), and the client asked three times per mount. + +**Server:** the computed listing is cached per viewer and credential (the listing is shaped by the viewer, since shared providers are labeled with their owner). A load inside the 60 s fresh window is answered from cache; an older one is answered from cache and refreshed in the background (deduplicated), so the next load is current; only the first load per credential waits. `?refresh=1` drops the probes and recomputes before answering, and the key test in Settings clears every listing. The last-call-failed decoration is applied after the cache so it stays live. + +**Client:** one deduplicated request (`fetchModels` in `api.ts`) serves the picker, the banner, the credential notice, and the fleet page, with a 15 s reuse. Freshness without a reload: refetch when the tab becomes visible and every five minutes while visible; the picker is remounted only when the set of marked models changes, never mid-reply. + +Measured on dev: first load 1.2 s, next three under 10 ms, forced refresh 0.4 s. Tests cover cached loads, refresh seeing a changed catalog, and the live decoration. 187 server and 12 web tests pass. + +## v1.61 (2026-09-15) + +### Neutral provider names in the probe fixtures (#341) + +The two new probe cases used a real provider prefix and its model ids as fixture names. Fixtures use neutral names like the rest of the suite. + ## v1.60 (2026-09-15) ### A change log and release notes generated from pull request descriptions (#339) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9b928e..ca24e3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,11 +57,11 @@ importers: specifier: ^5.3.0 version: 5.3.0 '@parallelworks/ai-chat': - specifier: 0.4.5 - version: 0.4.5(@popperjs/core@2.11.8)(formik@2.4.9(@types/react@19.2.18)(react@19.2.8))(monaco-editor@0.55.1)(monaco-yaml@5.5.1(monaco-editor@0.55.1))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@parallelworks/ui': - specifier: ^0.5.0 + specifier: 0.5.0 version: 0.5.0(@popperjs/core@2.11.8)(formik@2.4.9(@types/react@19.2.18)(react@19.2.8))(monaco-editor@0.55.1)(monaco-yaml@5.5.1(monaco-editor@0.55.1))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@parallelworks/ui': + specifier: ^0.16.0 + version: 0.16.0(@popperjs/core@2.11.8)(formik@2.4.9(@types/react@19.2.18)(react@19.2.8))(monaco-editor@0.55.1)(monaco-yaml@5.5.1(monaco-editor@0.55.1))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: ^19.2.8 version: 19.2.8 @@ -703,29 +703,15 @@ packages: os: [linux] libc: [glibc] - '@parallelworks/ai-chat@0.4.5': - resolution: {integrity: sha512-JcPK5b+6Jtb9Tz58wqPqXowKzXCggcZGuZcTeJz3lL0MNeog/4Egw2Qr1F6twlVMhSpXU+18L4x6SMrYGLWJwA==} + '@parallelworks/ai-chat@0.5.0': + resolution: {integrity: sha512-RCa6oiSvU10TdH7+TZ+Ag65142jFD47RQ+qu/S9Ae/jIHSpUrjEkhkDS5UpMXXA76qpDl+ani5r4OanuQx1WMg==} hasBin: true peerDependencies: react: ^19.0.0 react-dom: ^19.0.0 - '@parallelworks/ui@0.14.0': - resolution: {integrity: sha512-H9y30lUgo1+ccNhBrM8pVJ5TZpJDy3tQFJE61xiBE2QmJtwZLQy5RDXxQZLCRl/TwQZvWBCaXE+7nY4zN1Y0jw==} - peerDependencies: - formik: ^2.4.9 - monaco-editor: ^0.55.1 - monaco-yaml: ^5.5.1 - react: ^19.0.0 - react-dom: ^19.0.0 - peerDependenciesMeta: - monaco-editor: - optional: true - monaco-yaml: - optional: true - - '@parallelworks/ui@0.5.0': - resolution: {integrity: sha512-qulgQFWtz33aujRrFuRfniDa4uffZkA5c3ztO3uQ6UfJA1HG+aXttvJS+rYYzXV+KrzvMlVFbuUGLxmt6Ixtqw==} + '@parallelworks/ui@0.16.0': + resolution: {integrity: sha512-iLtTMluY+D5rf48owEr14QJnLHtKBksCGLnexvqOwUYPGKVa8e6RTxEILx8Yif1d3cj9SledWWMVwFEecHwtow==} peerDependencies: formik: ^2.4.9 monaco-editor: ^0.55.1 @@ -741,8 +727,8 @@ packages: '@parallelworks/workflow-parser@0.1.0': resolution: {integrity: sha512-ptMo7X8F6aQ4hvyZz9U6noNZt7WEE3LiA9Fwr3H2mxsX7VkAKJCsa2+18ByRr9N0dQI04eUntWq1bdShh37Dug==} - '@parallelworks/workflow-parser@0.2.0': - resolution: {integrity: sha512-GaBcPnSHwpDsktmRAz8th9lIej28A1a/MnJbNQ7r8Hn9/kX3+HpeLK73HvHgDvaEMrCaVJ7E1PR7vfTYSm5skg==} + '@parallelworks/workflow-parser@0.4.0': + resolution: {integrity: sha512-M1xkV50s6pQ9/gbgMrEbxYClZlvq/k+1NLZAA0KTKxdoAV8juptoyWKmHmiIGq/IVzYr4j2Bd7Ko8Dcd7m7hSw==} '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -3297,9 +3283,9 @@ snapshots: '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@parallelworks/ai-chat@0.4.5(@popperjs/core@2.11.8)(formik@2.4.9(@types/react@19.2.18)(react@19.2.8))(monaco-editor@0.55.1)(monaco-yaml@5.5.1(monaco-editor@0.55.1))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@parallelworks/ai-chat@0.5.0(@popperjs/core@2.11.8)(formik@2.4.9(@types/react@19.2.18)(react@19.2.8))(monaco-editor@0.55.1)(monaco-yaml@5.5.1(monaco-editor@0.55.1))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@parallelworks/ui': 0.14.0(@popperjs/core@2.11.8)(formik@2.4.9(@types/react@19.2.18)(react@19.2.8))(monaco-editor@0.55.1)(monaco-yaml@5.5.1(monaco-editor@0.55.1))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@parallelworks/ui': 0.16.0(@popperjs/core@2.11.8)(formik@2.4.9(@types/react@19.2.18)(react@19.2.8))(monaco-editor@0.55.1)(monaco-yaml@5.5.1(monaco-editor@0.55.1))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@streamdown/code': 1.1.1(react@19.2.8) '@streamdown/math': 1.0.2(react@19.2.8) '@streamdown/mermaid': 1.0.2(react@19.2.8) @@ -3319,43 +3305,10 @@ snapshots: - monaco-yaml - supports-color - '@parallelworks/ui@0.14.0(@popperjs/core@2.11.8)(formik@2.4.9(@types/react@19.2.18)(react@19.2.8))(monaco-editor@0.55.1)(monaco-yaml@5.5.1(monaco-editor@0.55.1))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@headlessui/react': 2.2.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@parallelworks/workflow-parser': 0.2.0 - '@streamdown/code': 1.1.1(react@19.2.8) - '@streamdown/math': 1.0.2(react@19.2.8) - '@tanstack/react-virtual': 3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - classnames: 2.5.1 - fast-deep-equal: 3.1.3 - formik: 2.4.9(@types/react@19.2.18)(react@19.2.8) - framer-motion: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - js-yaml: 4.3.1 - luxon: 3.7.2 - re-resizable: 6.11.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-animate-height: 3.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react-dom: 19.2.8(react@19.2.8) - react-icons: 5.6.0(react@19.2.8) - react-loading-skeleton: 3.5.0(react@19.2.8) - react-number-format: 5.4.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react-tooltip: 6.0.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react-zoom-pan-pinch: 4.0.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - streamdown: 2.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - usehooks-ts: 3.1.1(react@19.2.8) - optionalDependencies: - monaco-editor: 0.55.1 - monaco-yaml: 5.5.1(monaco-editor@0.55.1) - transitivePeerDependencies: - - '@emotion/is-prop-valid' - - '@popperjs/core' - - supports-color - - '@parallelworks/ui@0.5.0(@popperjs/core@2.11.8)(formik@2.4.9(@types/react@19.2.18)(react@19.2.8))(monaco-editor@0.55.1)(monaco-yaml@5.5.1(monaco-editor@0.55.1))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@parallelworks/ui@0.16.0(@popperjs/core@2.11.8)(formik@2.4.9(@types/react@19.2.18)(react@19.2.8))(monaco-editor@0.55.1)(monaco-yaml@5.5.1(monaco-editor@0.55.1))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@headlessui/react': 2.2.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@parallelworks/workflow-parser': 0.1.0 + '@parallelworks/workflow-parser': 0.4.0 '@streamdown/code': 1.1.1(react@19.2.8) '@streamdown/math': 1.0.2(react@19.2.8) '@tanstack/react-virtual': 3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -3391,7 +3344,7 @@ snapshots: js-yaml: 4.3.1 luxon: 3.7.2 - '@parallelworks/workflow-parser@0.2.0': + '@parallelworks/workflow-parser@0.4.0': dependencies: anser: 2.3.5 js-yaml: 4.3.1 diff --git a/server/src/chat/gateway.ts b/server/src/chat/gateway.ts index 5f3f5f7..31e8a56 100644 --- a/server/src/chat/gateway.ts +++ b/server/src/chat/gateway.ts @@ -210,6 +210,18 @@ export function invalidateProviderProbes(): void { providerProbes.clear() } export function probeableProvider(prefix: string): boolean { return PROBE_PATTERN.test(prefix) } +/** The provider's own sentence from an error body, for showing a person. */ +export function providerReason(text: string): string { + let t = text + for (let i = 0; i < 3; i++) { + const m = /"message"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(t) + if (!m) break + t = m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') + } + t = t.replace(/^received error while streaming:\s*/i, '').replace(/\s+/g, ' ').trim() + return t.length > 180 ? t.slice(0, 177) + '...' : t +} + export function extractUnlockUrl(text: string): string | null { return /unlock_url[\\":\s]*(https?:\/\/[^"\\\s]+)/.exec(text)?.[1] ?? null } @@ -426,8 +438,24 @@ export async function streamTurn( const reader = res.body.getReader() const decoder = new TextDecoder() let buf = '' + // A provider can accept a request, open the stream, and send nothing. + // Only the client's own abort ended that, so a reply could sit on its + // spinner indefinitely. Each read now has a limit, reset by every chunk + // and generous enough for providers that buffer a whole answer (about a + // minute is common) or a model that thinks before its first token. + const idleMs = Number(process.env.STUDIO_STREAM_IDLE_MS) || 300_000 + const readWithin = async () => { + let timer: NodeJS.Timeout | undefined + const idle = new Promise((_, reject) => { + timer = setTimeout(() => { + reader.cancel().catch(() => {}) + reject(new Error(`The model sent nothing for ${Math.round(idleMs / 1000)} seconds, so the reply was stopped. Try again, or pick another model.`)) + }, idleMs) + }) + try { return await Promise.race([reader.read(), idle]) } finally { clearTimeout(timer) } + } for (;;) { - const { done, value } = await reader.read() + const { done, value } = await readWithin() if (done) break buf += decoder.decode(value, { stream: true }) let nl diff --git a/server/src/chat/routes.ts b/server/src/chat/routes.ts index bc394a0..4277a55 100644 --- a/server/src/chat/routes.ts +++ b/server/src/chat/routes.ts @@ -5,6 +5,7 @@ import { suggestNext } from './suggest.js' import { getConversation } from '../conversations.js' import { listRuns } from '../runs.js' import { GATEWAY_BASE, MAX_TOOL_ITERATIONS, SIDECAR_ENDPOINT, INDEX_BASE } from '../config.js' +import { providerReason } from './gateway.js' import { aiHealth, gatewayConfigured, isSidecarModel, listModels, listSidecarModels, modelFailure, probeProvider, probeableProvider, extractUnlockUrl, invalidateProviderProbes, sidecarConfigured, sidecarTarget, StreamedTurnError, streamTurn, WireMessage, WireToolCall, type TokenUsage } from './gateway.js' import { TOOL_CALLS, TOOL_SPECS, activeToolSpecs, activeToolSpecsWithRemote, commandFor, customToolSpecs, executeTool, expandSlashCommand, skillToolSpec } from './tools.js' import { systemPrompt } from './context.js' @@ -300,15 +301,15 @@ export function stripAvailabilityMark(id: string): string { */ export function markImpaired( models: M[], - verdicts: Map, -): { models: (M | (M & { callable: false; locked: boolean; unavailable: true; unlock_url: string | null }))[]; impaired: { id: string; locked: boolean; unlock_url: string | null }[] } { - const impaired: { id: string; locked: boolean; unlock_url: string | null }[] = [] + verdicts: Map, +): { models: (M | (M & { callable: false; locked: boolean; unavailable: true; unlock_url: string | null }))[]; impaired: { id: string; locked: boolean; reason?: string; unlock_url: string | null }[] } { + const impaired: { id: string; locked: boolean; reason?: string; unlock_url: string | null }[] = [] const out = models.map(m => { const id = String(m.id) const v = verdicts.get(id.slice(0, Math.max(id.indexOf('/'), 0))) if (!v || v.ok) return m const tag = v.kind === 'locked' ? 'locked' : 'unavailable' - impaired.push({ id, locked: v.kind === 'locked', unlock_url: v.unlockUrl }) + impaired.push({ id, locked: v.kind === 'locked', ...(v.message ? { reason: providerReason(v.message) } : {}), unlock_url: v.unlockUrl }) const name = typeof m.name === 'string' && m.name.trim() ? `${m.name.trim()} [${tag}]` : m.name return { ...m, id: `${id} [${tag}]`, name, callable: false as const, locked: v.kind === 'locked', unavailable: true as const, unlock_url: v.unlockUrl } }) @@ -422,7 +423,7 @@ export async function chatRoutes(app: FastifyInstance): Promise { // "look again": it drops the probes and recomputes before answering. // Per-request decorations (a model whose last call failed) are applied // after the cache, so they stay live. - type Listing = { models: any[]; impaired: { id: string; locked: boolean; unlock_url: string | null }[]; unreachableSessions: unknown[] } + type Listing = { models: any[]; impaired: { id: string; locked: boolean; reason?: string; unlock_url: string | null }[]; unreachableSessions: unknown[] } const LISTING_FRESH_MS = 60_000 const LISTING_KEEP_MS = 30 * 60_000 const listings = new Map() @@ -554,7 +555,7 @@ export async function chatRoutes(app: FastifyInstance): Promise { if (probeableProvider(prefix) && !prefixes.has(prefix)) prefixes.set(prefix, id) } } - const verdicts = new Map() + const verdicts = new Map() if (prefixes.size) { await Promise.race([ Promise.allSettled([...prefixes.entries()].map(async ([prefix, sample]) => { diff --git a/server/test/providerProbe.test.mjs b/server/test/providerProbe.test.mjs index 3656604..8b70404 100644 --- a/server/test/providerProbe.test.mjs +++ b/server/test/providerProbe.test.mjs @@ -100,3 +100,18 @@ test('a healthy stream is read only to its first frame', async () => { return { ok: r.status < 400, status: r.status, text: async () => r.text } } }) + +test('the provider reason is its own sentence, unwrapped from nested error bodies', async () => { + const { providerReason } = await import('../dist/chat/gateway.js') + const body = '{"error":{"message":"received error while streaming: {\\"message\\": \\"Requested model is not available and no compliant same-model variant was found.\\", \\"type\\": \\"invalid_request_error\\"}","type":"error"}}' + assert.equal(providerReason(body), 'Requested model is not available and no compliant same-model variant was found.') + assert.equal(providerReason('plain text'), 'plain text') +}) + +test('a marked model carries the reason to the client', async () => { + const { markImpaired } = await import('../dist/chat/routes.js') + const verdicts = new Map([['me:vega', { ok: false, kind: 'unavailable', unlockUrl: null, message: '{"error":{"message":"model not served","type":"error"}}' }]]) + const r = markImpaired([{ id: 'me:vega/model-a' }], verdicts) + assert.equal(r.impaired[0].reason, 'model not served') + assert.equal(r.impaired[0].locked, false) +}) diff --git a/web/package.json b/web/package.json index c621136..327909d 100644 --- a/web/package.json +++ b/web/package.json @@ -11,8 +11,8 @@ "dependencies": { "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", - "@parallelworks/ai-chat": "0.4.5", - "@parallelworks/ui": "^0.5.0", + "@parallelworks/ai-chat": "0.5.0", + "@parallelworks/ui": "^0.16.0", "react": "^19.2.8", "react-dom": "^19.2.8", "streamdown": "^2.5.0", diff --git a/web/src/adapter.ts b/web/src/adapter.ts index 56425bf..c829753 100644 --- a/web/src/adapter.ts +++ b/web/src/adapter.ts @@ -180,10 +180,16 @@ export function createStudioAdapter(): ChatAdapter { location.hash = `#open=file:${encodeURIComponent(rel).replace(/%2F/gi, '/')}` } catch { /* malformed id: ignore */ } }, - list: async ({ limit, offset }: { limit: number; offset: number }) => { + // ai-chat 0.5 pages by an opaque cursor and asks whether there is + // more; the server pages by offset, so the cursor is the next offset. + list: async ({ limit, cursor }: { limit: number; cursor?: string }) => { + const offset = Number(cursor) || 0 const res = await fetch(`/api/chat/attachments?limit=${limit}&offset=${offset}`) if (!res.ok) throw new Error(`attachments: ${res.status}`) - return res.json() + const page = await res.json() as { attachments: unknown[]; total: number } + const next = offset + (page.attachments?.length ?? 0) + const hasMore = next < (page.total ?? 0) + return { ...page, hasMore, ...(hasMore ? { nextCursor: String(next) } : {}) } }, upload: async (file, _conversationId) => { const fd = new FormData() diff --git a/web/src/api.ts b/web/src/api.ts index 5bfc29b..4d2bcaf 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -229,7 +229,7 @@ export interface IndexJob { // client asking three times for one answer. export interface ModelsResponse { models: { id: string; name?: string; callable?: boolean; [k: string]: unknown }[] - impaired?: { id: string; locked: boolean; unlock_url: string | null }[] + impaired?: { id: string; locked: boolean; reason?: string; unlock_url: string | null }[] unreachableSessions?: unknown[] error?: string credential?: string diff --git a/web/src/views/ChatView.tsx b/web/src/views/ChatView.tsx index 9471000..fbb00b0 100644 --- a/web/src/views/ChatView.tsx +++ b/web/src/views/ChatView.tsx @@ -168,13 +168,21 @@ export function ChatView() { const marksRef = useRef('') const applyModels = (d: ModelsResponse) => { if (d.error && !(d.models ?? []).length) { setCredNote(String(d.error)); return } - const impaired = (d.impaired ?? []) as { id: string; locked: boolean }[] + const impaired = (d.impaired ?? []) as { id: string; locked: boolean; reason?: string }[] const marks = impaired.map(m => `${m.id}:${m.locked ? 'L' : 'U'}`).sort().join(',') if (impaired.length) { - const locked = impaired.some(m => m.locked) - setCredNote(`${impaired.length} model${impaired.length === 1 ? ' is' : 's are'} marked [${locked ? 'locked' : 'unavailable'}] in the model list${locked - ? ': the provider reports the key is locked' - : ' (for GenAI this usually means the key is locked on its 8-hour schedule)'}. Unlock and Re-check under Settings, Model access; the marks clear on the next listing.`) + // Say what the provider said. A lock gets the unlock step; anything + // else is quoted, since guessing a cause misleads (this banner once + // blamed an 8-hour key lock for a provider that was rejecting the + // model names themselves). + const locked = impaired.filter(m => m.locked) + const reasons = [...new Set(impaired.map(m => m.reason).filter(Boolean))] as string[] + const n = impaired.length + const head = `${n} model${n === 1 ? ' is' : 's are'} marked [${locked.length ? 'locked' : 'unavailable'}] because the provider refused a test request` + const why = locked.length + ? ': it reports the key is locked. Unlock it, then Re-check under Settings, Model access.' + : reasons.length ? `: "${reasons[0]}". Re-check under Settings, Model access, once the provider is answering again.` : '. Re-check under Settings, Model access.' + setCredNote(head + why) } else if (marksRef.current) { setCredNote(null) } diff --git a/web/test/attachmentsCursor.test.tsx b/web/test/attachmentsCursor.test.tsx new file mode 100644 index 0000000..72c142d --- /dev/null +++ b/web/test/attachmentsCursor.test.tsx @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +describe('attachments paging under the cursor contract', () => { + afterEach(() => vi.unstubAllGlobals()) + it('turns the server offset into a cursor and reports whether there is more', async () => { + const calls: string[] = [] + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + calls.push(url) + const offset = Number(new URL(url, 'http://x').searchParams.get('offset')) + const all = Array.from({ length: 5 }, (_, i) => ({ id: `a${i}` })) + return { ok: true, json: async () => ({ attachments: all.slice(offset, offset + 2), total: 5 }) } + })) + const mod = await import('../src/adapter') + const adapter = mod.createStudioAdapter() as any + const att = adapter.attachments + const p1 = await att.list({ limit: 2 }) + expect(p1.hasMore).toBe(true) + expect(p1.nextCursor).toBe('2') + const p3 = await att.list({ limit: 2, cursor: '4' }) + expect(p3.attachments.length).toBe(1) + expect(p3.hasMore).toBe(false) + expect(p3.nextCursor).toBeUndefined() + expect(calls[1]).toContain('offset=4') + }) +})