diff --git a/apps/fabro-web/app/lib/model-offerings.ts b/apps/fabro-web/app/lib/model-offerings.ts index e2bc94d2d..ad2c056f0 100644 --- a/apps/fabro-web/app/lib/model-offerings.ts +++ b/apps/fabro-web/app/lib/model-offerings.ts @@ -1,4 +1,4 @@ -import type { Model } from "@qltysh/fabro-api-client"; +import type { Model, Provider } from "@qltysh/fabro-api-client"; type ModelOfferingIdentity = Pick; @@ -6,6 +6,13 @@ export function modelOfferingKey(model: ModelOfferingIdentity): string { return `${model.provider}\u0000${model.id}`; } +/** Provider ids to the names the catalog displays, falling back to the id. */ +export function providerNamesById( + providers: readonly Pick[], +): Map { + return new Map(providers.map((p) => [p.id, p.display_name || p.id])); +} + export function modelOfferingTestArgs( model: ModelOfferingIdentity, ): [id: string, provider: string] { diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index fc51316c4..1ca6ec4f3 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -534,9 +534,10 @@ export function useServerSettings() { ); } -export function useProviders() { +/** The provider catalog; `enabled: false` skips the request. */ +export function useProviders({ enabled = true }: { enabled?: boolean } = {}) { return useSWR( - queryKeys.providers.list(), + enabled ? queryKeys.providers.list() : null, () => apiData(() => modelsApi.listProviders()), immutableOptions, ); diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts index 5c54733d9..76ccc0a0c 100644 --- a/apps/fabro-web/app/routes/run-detail.test.ts +++ b/apps/fabro-web/app/routes/run-detail.test.ts @@ -86,6 +86,7 @@ mock.module("../lib/queries", () => ({ isValidating: false, mutate: mock(() => Promise.resolve(null)), }), + useProviders: () => ({ data: null }), })); mock.module("../lib/run-events", () => ({ diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 4ebf6ed03..dcf2bec17 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -1,5 +1,6 @@ import { useCallback, + useMemo, useRef, useState, type CSSProperties, @@ -34,7 +35,8 @@ import { } from "../lib/mutations"; import { useRunEvents } from "../lib/run-events"; import { useRunToasts } from "../hooks/use-run-toasts"; -import { useRun, useRunQuestions, useRunState } from "../lib/queries"; +import { useProviders, useRun, useRunQuestions, useRunState } from "../lib/queries"; +import { providerNamesById } from "../lib/model-offerings"; import { canApprove, deleteErrorMessage, @@ -50,6 +52,8 @@ import { RunDetailAskFabroShell, RunDetailDockedControls, } from "./run-detail/docked-controls"; +import { buildRunFailureView } from "./run-detail/failure"; +import { RunFailureBanner } from "./run-detail/failure-banner"; import { RunDetailHeader } from "./run-detail/header"; import { createLifecycleToastState, @@ -148,6 +152,16 @@ export default function RunDetail({ params }: { params: { id: string } }) { useRunEvents(params.id); useRunToasts(params.id); + // Provider names only matter to a failed run's banner. + const providersQuery = useProviders({ enabled: statusKind === "failed" || statusKind === "dead" }); + const providerNames = useMemo( + () => providerNamesById(providersQuery.data?.data ?? []), + [providersQuery.data], + ); + const failure = buildRunFailureView(runStateQuery.data, { + providerNames, + lifecycleError: runQuery.data?.lifecycle.error?.message, + }); const handleLifecycleMutationResult = useCallback( ( @@ -382,6 +396,8 @@ export default function RunDetail({ params }: { params: { id: string } }) { }} /> + {failure ? : null} + {retryDialogOpen && ( +
+ + +
+
+
+ {failure.provider ? "Provider message" : "Message"} +
+
+ {failure.message} +
+
+ {failure.output ? ( +
+
+ Output +
+
+
+                  {failure.output}
+                
+
+
+ ) : null} +
+
+ + ); +} diff --git a/apps/fabro-web/app/routes/run-detail/failure.test.ts b/apps/fabro-web/app/routes/run-detail/failure.test.ts new file mode 100644 index 000000000..ebd9a5733 --- /dev/null +++ b/apps/fabro-web/app/routes/run-detail/failure.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from "bun:test"; +import type { RunProjection } from "@qltysh/fabro-api-client"; + +import { buildRunFailureView } from "./failure"; + +type State = Pick; + +const NAMES = new Map([ + ["anthropic", "Anthropic"], + ["openai", "OpenAI"], +]); + +const CREDIT = + "model request failed (invalid_request): provider anthropic Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits. [provider anthropic, status 400, code invalid_request_error]"; + +function stages(...entries: Array<[string, string, string]>): State["stages"] { + return Object.fromEntries( + entries.map(([key, state, started_at]) => [key, { state, started_at } as State["stages"][string]]), + ); +} + +function failedRun( + detail: Record, + { reason = "workflow_error", kind = "failed", runStages = {} as State["stages"] } = {}, +): State { + return { + status: (kind === "failed" ? { kind, reason } : { kind }) as State["status"], + conclusion: { + failure: { reason, detail: { category: "deterministic", ...detail } }, + } as State["conclusion"], + stages: runStages, + }; +} + +const view = (state: State) => buildRunFailureView(state, { providerNames: NAMES }); + +describe("buildRunFailureView", () => { + test("names the step the run stopped at and the provider's real message", () => { + const failure = view( + failedRun({ message: CREDIT }, { + runStages: stages(["pick@1", "succeeded", "2026-10-10T18:05:42.783Z"], ["spec@1", "failed", "2026-10-10T18:41:53.513Z"]), + }), + ); + expect(failure).toMatchObject({ + stage: "spec", + headline: "Anthropic declined the request for billing reasons", + provider: "anthropic", + message: CREDIT, + }); + expect(failure?.nextStep).toBe("Add credit or raise the spend limit with Anthropic, then choose Retry from Actions."); + }); + + test("reads a spent quota as billing, though the provider answered 429", () => { + const failure = view( + failedRun({ + message: "model request failed (quota_exceeded): provider gemini Quota exceeded for metric generate_content_requests [provider gemini, status 429, code RESOURCE_EXHAUSTED]", + }), + ); + expect(failure?.headline).toBe("gemini declined the request for billing reasons"); + }); + + test("names an unknown provider by its id", () => { + const failure = view( + failedRun({ message: "model request failed (authentication): provider acme bad key [provider acme, status 401, code unauthorized]" }), + ); + expect(failure?.headline).toBe("acme rejected the API key"); + }); + + test("reads auth, rate limits, timeouts and outages from the request's own status and kind", () => { + const headline = (message: string) => view(failedRun({ message }))?.headline; + expect(headline("model request failed (authentication): provider anthropic invalid x-api-key [provider anthropic, status 401, code authentication_error]")) + .toBe("Anthropic rejected the API key"); + expect(headline("model request failed (access_denied): provider openai The model is not available to your organization [provider openai, status 403, code model_not_found]")) + .toBe("OpenAI denied access to the request"); + expect(headline("model request failed (authentication): provider acme forbidden [provider acme, status 403, code forbidden]")) + .toBe("acme rejected the API key"); + expect(headline("model request failed (rate_limit): provider openai Too many requests [provider openai, status 429, code rate_limit_exceeded]")) + .toBe("OpenAI rate-limited the request"); + expect(headline("model request failed (server): provider anthropic Overloaded [provider anthropic, status 529, code overloaded_error]")) + .toBe("Anthropic was unavailable"); + expect(headline("model request failed (timeout): provider openai request timed out")) + .toBe("The request to OpenAI timed out"); + }); + + test("doesn't blame the provider for failures on Fabro's side", () => { + const failure = (message: string) => view(failedRun({ message })); + expect(failure("model request failed (configuration): provider anthropic is disabled in the catalog")).toMatchObject({ + headline: "Fabro couldn't send the request to Anthropic", + nextStep: "Check Anthropic and the step's model under Settings โ†’ Models, then choose Retry from Actions.", + }); + expect(failure("model request failed (model_selection): provider anthropic has no default model")?.headline) + .toBe("Fabro couldn't send the request to Anthropic"); + expect(failure("model request failed (network): provider openai connection refused")?.headline) + .toBe("The request never reached OpenAI"); + expect(failure("model request failed (resource_limit): provider openai response exceeded 16 MiB")?.headline) + .toBe("Fabro stopped the request to OpenAI"); + }); + + test("falls back to the provider's status and code", () => { + const failure = view( + failedRun({ + message: "model request failed (invalid_request): provider openai Unsupported parameter: 'temperature' is not supported with this model. [provider openai, status 400, code unsupported_parameter]", + }), + ); + expect(failure?.headline).toBe("OpenAI returned 400 unsupported parameter"); + }); + + test("never reads a provider, status or billing problem out of a command's own words", () => { + const failure = view( + failedRun({ + message: "script exited with status 401", + exec_output_tail: { stdout: "test billing::invoice ... FAILED\nretry the provider list later\n", stderr: null }, + }), + ); + expect(failure).toMatchObject({ + headline: "The step's command exited with status 401", + provider: null, + nextStep: "Its output, under Details, usually says why.", + }); + expect(view(failedRun({ message: "Ran 512 tests; line 503 failed." }))?.headline).toBe("Ran 512 tests; line 503 failed."); + }); + + test("names no step when the run recovered from an earlier failed one", () => { + const failure = view( + failedRun({ message: "push rejected: remote branch moved" }, { + reason: "publish_failed", + runStages: stages( + ["toolchain@1", "failed", "2026-10-10T15:20:45Z"], + ["fixup@1", "succeeded", "2026-10-10T15:21:00Z"], + ["verify@1", "succeeded", "2026-10-10T15:25:00.500Z"], + ), + }), + ); + expect(failure?.stage).toBeNull(); + expect(failure?.headline).toBe("The steps finished, but publishing the result failed"); + }); + + test("labels a looped step with its visit, as the Stages sidebar does", () => { + const runStages = stages(["review@1", "succeeded", "2026-10-10T15:20:00Z"], ["review@3", "failed", "2026-10-10T15:30:00Z"]); + expect(view(failedRun({ message: "script exited with status 1" }, { runStages }))?.stage).toBe("review@3"); + }); + + test("names the failed branch of a fan-out, though a sibling started after it", () => { + const runStages = stages( + ["checks@1", "failed", "2026-10-10T15:20:00.000Z"], + ["lint@1", "failed", "2026-10-10T15:20:00.010Z"], + ["test@1", "cancelled", "2026-10-10T15:20:00.013Z"], + ); + runStages["lint@1"]!.parallel_branch_id = "checks@1:0"; + runStages["test@1"]!.parallel_branch_id = "checks@1:1"; + expect(view(failedRun({ message: "script exited with status 1" }, { runStages }))?.stage).toBe("lint"); + + runStages["report@1"] = { state: "succeeded", started_at: "2026-10-10T15:21:00Z" } as State["stages"][string]; + expect(view(failedRun({ message: "push rejected" }, { reason: "publish_failed", runStages }))?.stage).toBeNull(); + }); + + test("shows nothing for a cancelled run, and says what happened for other endings", () => { + expect(view(failedRun({ message: "Run cancelled before worker launch completed" }, { reason: "cancelled" }))).toBeNull(); + expect(view(failedRun({ message: "denied at review" }, { reason: "approval_denied" }))?.headline).toBe("Approval was denied"); + expect(view(failedRun({ message: "budget of $5 reached" }, { reason: "budget_exhausted" }))?.headline).toBe("The run used up its budget"); + expect(view(failedRun({ message: "sandbox image not found" }, { reason: "sandbox_init_failed" }))?.headline).toBe("The run couldn't start"); + expect(view(failedRun({ message: "sandbox connection reset" }, { reason: "transient_infra" }))).toMatchObject({ + headline: "The step hit a temporary infrastructure error", + nextStep: "Choose Retry from Actions in a few minutes.", + }); + }); + + test("says why a dead run stopped, though a dead run records no conclusion", () => { + const dead = { + status: { kind: "dead" }, + conclusion: null, + stages: stages(["build@1", "failed", "2026-10-10T18:05:42Z"]), + } as unknown as State; + expect(buildRunFailureView(dead, { lifecycleError: "worker exited unexpectedly" })).toMatchObject({ + stage: "build", + headline: "Worker exited unexpectedly", + message: "worker exited unexpectedly", + }); + expect(buildRunFailureView(dead)?.headline).toBe("The run's worker stopped before the run finished"); + }); + + test("shows the end of a command's output, from whichever stream has it", () => { + const tail = (exec_output_tail: Record) => + view(failedRun({ message: "script exited with status 1", exec_output_tail }))?.output; + expect(tail({ stderr: "\n \n", stdout: "real error\n" })).toBe("real error"); + expect(tail({ stderr: "line one\r\nline two\r\n", stdout: null })).toBe("line one\nline two"); + expect(tail({ stdout: "10%\r50%\r100% done\n" })).toBe("100% done"); + const many = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join("\n"); + expect(tail({ stdout: many })?.split("\n")).toEqual(Array.from({ length: 12 }, (_, i) => `line ${i + 19}`)); + }); + + test("falls back to the output the failed step recorded", () => { + const runStages = stages(["test@1", "failed", "2026-10-10T18:05:42Z"]); + runStages["test@1"]!.output = "running 3 tests\ntest parse ... FAILED\n"; + const failure = view(failedRun({ message: "script exited with status 101" }, { runStages })); + expect(failure).toMatchObject({ + output: "running 3 tests\ntest parse ... FAILED", + nextStep: "Its output, under Details, usually says why.", + }); + + runStages["test@1"]!.output = "blob://sha256/abc"; + expect(view(failedRun({ message: "script exited with status 101" }, { runStages }))?.output).toBeNull(); + + runStages["test@1"]!.output = "I will now write the spec."; + expect(view(failedRun({ message: CREDIT }, { runStages }))?.output).toBeNull(); + }); + + test("cuts a long fallback headline without splitting a character", () => { + const headline = view(failedRun({ message: `${"๐Ÿ”ฅ".repeat(200)} and more` }))!.headline; + expect(Array.from(headline)).toHaveLength(111); + expect(headline.endsWith("๐Ÿ”ฅโ€ฆ")).toBe(true); + }); + + test("shows nothing for runs that didn't fail", () => { + expect(buildRunFailureView({ status: { kind: "succeeded" }, stages: {} } as State)).toBeNull(); + expect(buildRunFailureView(null)).toBeNull(); + }); +}); diff --git a/apps/fabro-web/app/routes/run-detail/failure.ts b/apps/fabro-web/app/routes/run-detail/failure.ts new file mode 100644 index 000000000..fa757e05c --- /dev/null +++ b/apps/fabro-web/app/routes/run-detail/failure.ts @@ -0,0 +1,272 @@ +import { + FailureCategory, + FailureReason, + type ExecOutputTail, + type RunProjection, +} from "@qltysh/fabro-api-client"; + +import { formatStageLabel } from "../../lib/stage-sidebar"; + +/** Lines of a failed command's output shown on the page. */ +const OUTPUT_LINES = 12; +/** Fallback headlines are cut to this many characters. */ +const HEADLINE_CHARS = 110; + +export interface RunFailureView { + /** The step the run stopped at (`spec`, `review@3`), when it stopped at one. */ + stage: string | null; + headline: string; + nextStep: string; + /** The recorded failure message, verbatim. */ + message: string; + /** Provider id, when the failure is a model request the provider refused. */ + provider: string | null; + /** + * Last OUTPUT_LINES of the failed command's stderr, else its stdout, else + * the output the step the run stopped at recorded. + */ + output: string | null; +} + +type FailureState = Pick; + +export interface RunFailureContext { + /** Provider ids to display names, from the provider catalog. */ + providerNames?: ReadonlyMap; + /** The run summary's `lifecycle.error`, the only reason a dead run records. */ + lifecycleError?: string | null; +} + +/** + * Reads the failure from run state (`conclusion.failure`); the run summary the + * status popover reads doesn't carry the message or the command's output. A + * dead run has no conclusion, so its reason comes from the summary instead. + */ +export function buildRunFailureView( + state: FailureState | null | undefined, + { providerNames = new Map(), lifecycleError = null }: RunFailureContext = {}, +): RunFailureView | null { + const kind = state?.status?.kind; + if (!state || (kind !== "failed" && kind !== "dead")) return null; + const failure = state.conclusion?.failure; + if (!failure) return kind === "dead" ? deadRunView(state, lifecycleError) : null; + if (!failure.detail.message || failure.reason === FailureReason.CANCELLED) return null; + + const { message, category } = failure.detail; + const request = parseModelRequest(message); + const stage = stoppedAt(state.stages); + // A refused model request's message is the whole story; the step's + // output would be the agent's own text. + const output = + outputTail(failure.detail.exec_output_tail) ?? + (request || !stage ? null : lastLines(state.stages[stage]?.output)); + const name = (id: string) => providerNames.get(id) ?? id; + const advice = + reasonAdvice(failure.reason, category) ?? + (request + ? requestAdvice(request, message, name(request.provider)) + : stepAdvice(message, failure.reason, category, output)); + return { + stage: stageLabel(stage), + ...advice, + message, + provider: request?.provider ?? null, + output, + }; +} + +/** A run whose worker went away before the run concluded. */ +function deadRunView(state: FailureState, lifecycleError: string | null): RunFailureView { + const message = lifecycleError?.trim() || "The run ended without recording a reason."; + return { + stage: stageLabel(stoppedAt(state.stages)), + headline: lifecycleError?.trim() ? firstSentence(message) : "The run's worker stopped before the run finished", + nextStep: `Open the failed step's logs for details. To run it again, ${RETRY}.`, + message, + provider: null, + output: null, + }; +} + +type Advice = Pick; + +const RETRY = "choose Retry from Actions"; + +function reasonAdvice(reason: FailureReason, category: FailureCategory): Advice | null { + if (reason === FailureReason.APPROVAL_DENIED) { + return { headline: "Approval was denied", nextStep: `The run stops where it was denied. To run it again, ${RETRY}.` }; + } + if (reason === FailureReason.BUDGET_EXHAUSTED || category === FailureCategory.BUDGET_EXHAUSTED) { + return { headline: "The run used up its budget", nextStep: `Raise the run's budget, then ${RETRY}.` }; + } + if (reason === FailureReason.TERMINATED) { + return { headline: "The run was terminated", nextStep: `To run it again, ${RETRY}.` }; + } + if (reason === FailureReason.PUBLISH_FAILED) { + return { + headline: "The steps finished, but publishing the result failed", + nextStep: "The work is on the run branch. The message under Details says why publishing failed.", + }; + } + if ( + reason === FailureReason.LAUNCH_FAILED || + reason === FailureReason.BOOTSTRAP_FAILED || + reason === FailureReason.SANDBOX_INIT_FAILED + ) { + return { headline: "The run couldn't start", nextStep: "The message under Details says why." }; + } + return null; +} + +interface ModelRequest { + provider: string; + kind: string | null; + status: number | null; + code: string | null; +} + +/** + * A refused model request, as the server records it: + * `model request failed (): provider [provider , status , code ]`. + * Only this shape is read, so numbers and words in a command's output can't + * pass for a provider's status or name. + */ +function parseModelRequest(message: string): ModelRequest | null { + const lead = /^model request failed \(([a-z_]+)\): provider (\S+)/i.exec(message); + const tail = /\[provider ([^,\]]+), status (\d{3})(?:, code ([^\]]+))?\]\s*$/i.exec(message); + const provider = tail?.[1] ?? lead?.[2]; + if (!provider) return null; + return { + provider: provider.toLowerCase(), + kind: lead?.[1]?.toLowerCase() ?? null, + status: tail ? Number(tail[2]) : null, + code: tail?.[3] ?? null, + }; +} + +function requestAdvice({ kind, status, code }: ModelRequest, message: string, who: string): Advice { + // These kinds fail on Fabro's side, before or without the provider's say. + if (kind === "configuration" || kind === "model_selection") { + return { + headline: `Fabro couldn't send the request to ${who}`, + nextStep: `Check ${who} and the step's model under Settings โ†’ Models, then ${RETRY}.`, + }; + } + if (kind === "network") { + return { + headline: `The request never reached ${who}`, + nextStep: "Check that the Fabro server can reach the provider, then choose Retry from Actions.", + }; + } + if (kind === "middleware" || kind === "resource_limit") { + return { headline: `Fabro stopped the request to ${who}`, nextStep: "The message, under Details, says why." }; + } + if ( + kind === "quota_exceeded" || + status === 402 || + /credit balance|insufficient[_ ]quota|payment required|billing|spend limit/i.test(message) + ) { + return { + headline: `${who} declined the request for billing reasons`, + nextStep: `Add credit or raise the spend limit with ${who}, then ${RETRY}.`, + }; + } + if (kind === "authentication" || status === 401) { + return { headline: `${who} rejected the API key`, nextStep: `Check the key under Settings โ†’ Secrets, then ${RETRY}.` }; + } + if (kind === "access_denied" || status === 403) { + return { + headline: `${who} denied access to the request`, + nextStep: `The key works, but ${who} doesn't allow this account to make the call. ${who}'s message, under Details, says why.`, + }; + } + if (kind === "rate_limit" || status === 429) { + return { headline: `${who} rate-limited the request`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." }; + } + if (kind === "timeout" || /timed out/i.test(message)) { + return { headline: `The request to ${who} timed out`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." }; + } + if ((status !== null && status >= 500) || /overloaded/i.test(message)) { + return { headline: `${who} was unavailable`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." }; + } + const what = [status, code?.replace(/_/g, " ")].filter(Boolean).join(" "); + return { + headline: what ? `${who} returned ${what}` : `${who} rejected the request`, + nextStep: `${who}'s message, under Details, says why.`, + }; +} + +// A transient model request failure keeps requestAdvice's wording, which +// names the provider, so transient infrastructure is checked here. +function stepAdvice( + message: string, + reason: FailureReason, + category: FailureCategory, + output: string | null, +): Advice { + const exit = /exited with (?:status|code) (\d+)/i.exec(message); + if (exit) { + return { + headline: `The step's command exited with status ${exit[1]}`, + nextStep: output ? "Its output, under Details, usually says why." : "Open the step's logs for its output.", + }; + } + if (reason === FailureReason.TRANSIENT_INFRA || category === FailureCategory.TRANSIENT_INFRA) { + return { headline: "The step hit a temporary infrastructure error", nextStep: "Choose Retry from Actions in a few minutes." }; + } + return { headline: firstSentence(message), nextStep: "Open the failed step's logs for details." }; +} + +/** + * The key of the failed step the run stopped at: the latest failed step that + * no later step went on from. A step that started later in a sibling branch + * of the same fan-out ran beside it, not after it, so it doesn't count; an + * earlier failure that a later step recovered from isn't named. + */ +function stoppedAt(stages: FailureState["stages"]): string | null { + const started = Object.entries(stages).flatMap(([key, stage]) => { + const at = stage.started_at ? Date.parse(stage.started_at) : NaN; + if (Number.isNaN(at)) return []; + const branch = stage.parallel_branch_id ?? null; + const group = branch && branch.includes(":") ? branch.slice(0, branch.lastIndexOf(":")) : null; + return [{ key, at, branch, group, failed: stage.state === "failed" }]; + }); + let stop: (typeof started)[number] | null = null; + for (const step of started) { + if (!step.failed) continue; + const wentOn = started.some( + (later) => + later.at > step.at && + later.group !== step.key && + !(step.group !== null && later.group === step.group && later.branch !== step.branch), + ); + if (!wentOn && (!stop || step.at > stop.at)) stop = step; + } + return stop?.key ?? null; +} + +/** A stage key (`node@visit`) as the Stages sidebar labels it. */ +function stageLabel(key: string | null): string | null { + if (!key) return null; + const at = key.lastIndexOf("@"); + if (at <= 0) return key; + return formatStageLabel({ name: key.slice(0, at), visit: Number.parseInt(key.slice(at + 1), 10) || 1 }); +} + +function outputTail(tail: ExecOutputTail | null | undefined): string | null { + return lastLines([tail?.stderr, tail?.stdout].find((text) => text?.trim())); +} + +function lastLines(stream: string | null | undefined): string | null { + // An offloaded output is a `blob://` reference, not text to show. + if (!stream?.trim() || stream.startsWith("blob://")) return null; + // Carriage-return progress lines keep only what was drawn last. + const lines = stream.trimEnd().split(/\r?\n/).map((line) => line.slice(line.lastIndexOf("\r") + 1)); + return lines.slice(-OUTPUT_LINES).join("\n"); +} + +function firstSentence(message: string): string { + const sentence = message.split(/(?<=[.!?])\s+/)[0]!.trim().replace(/\s+/g, " "); + const chars = Array.from(sentence.charAt(0).toUpperCase() + sentence.slice(1)); + return chars.length > HEADLINE_CHARS ? `${chars.slice(0, HEADLINE_CHARS).join("").trimEnd()}โ€ฆ` : chars.join(""); +} diff --git a/apps/fabro-web/app/routes/settings-models.tsx b/apps/fabro-web/app/routes/settings-models.tsx index f6e3905df..fde1fe380 100644 --- a/apps/fabro-web/app/routes/settings-models.tsx +++ b/apps/fabro-web/app/routes/settings-models.tsx @@ -31,6 +31,7 @@ import { formatContextWindow, formatTokensPerSecond } from "../lib/format"; import { modelOfferingKey, modelOfferingTestArgs, + providerNamesById, } from "../lib/model-offerings"; import { useDebouncedValue } from "../hooks/effects"; @@ -244,11 +245,7 @@ function ModelsSection({ providers }: { providers: Provider[] }) { const { data, isLoading } = useModels(providerFilter, debouncedSearch); - const providerNameById = useMemo(() => { - const map = new Map(); - for (const p of providers) map.set(p.id, p.display_name || p.id); - return map; - }, [providers]); + const providerNameById = useMemo(() => providerNamesById(providers), [providers]); const rows = useMemo(() => { const all = (data?.data ?? []).filter((m) => m.configured);