fix(local): a deleted connection is never asked about again, and a plan's test costs no money

Deleting a connection refreshed its own model list, which answered
"connection not found" on the card being removed; it is now taken off
the list and left out of the refresh. Its model list is cached by the
connection, not its id alone, since SQLite gives a deleted id to the
next connection. Testing a chat model on a ChatGPT or Claude plan says
it counts toward the plan's limits instead of costing money.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anish Sarkar 2026-10-17 00:52:30 +05:30
parent b810d57b45
commit 86b94aebfe
16 changed files with 257 additions and 11 deletions

View file

@ -0,0 +1,132 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { cleanup, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { render } from "@/test-utils"
import { useRefreshModels } from "../../models-query"
import { useConnectionModels } from "../models/use-connection-models"
import type { Connection } from "./api"
import { useConnections, useDeleteConnection } from "./use-connections"
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
const CLAUDE: Connection = {
id: 3,
label: "Claude plan",
provider: "openai_compatible",
base_url: "https://api.anthropic.com",
catalog_provider: "anthropic",
has_api_key: false,
auth_kind: "claude_plan",
signed_in: false,
account_email: null,
serves: ["text_gen"],
created_at: "2026-10-17T00:00:00",
updated_at: "2026-10-17T00:00:00",
}
function Card({ connection }: { connection: Connection }) {
const remove = useDeleteConnection()
const models = useConnectionModels(connection, true)
return (
<section aria-label={connection.label}>
{models.isError ? <p>{models.error.message}</p> : null}
<p>{models.data?.map((model) => model.name).join(", ")}</p>
<button type="button" onClick={() => remove.mutate(connection.id)}>
Delete
</button>
</section>
)
}
function Settings() {
const connections = useConnections()
// What the app does once a connection is made.
const refresh = useRefreshModels()
return (
<>
{connections.data?.map((connection) => (
<Card key={connection.id} connection={connection} />
))}
<button type="button" onClick={() => void refresh()}>
Connected
</button>
</>
)
}
describe("deleting a connection", () => {
it("never asks the server about it again, so its card never says it is gone", async () => {
let deleted = false
const asked: string[] = []
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = String(input)
asked.push(`${init?.method ?? "GET"} ${path}`)
if (init?.method === "DELETE") {
deleted = true
return new Response(null, { status: 204 })
}
if (path === "/llm/connections")
return Response.json(deleted ? [] : [CLAUDE])
if (path === "/llm/connections/3/models")
return deleted
? Response.json({ detail: "connection not found" }, { status: 404 })
: Response.json([])
return Response.json({ detail: "not found" }, { status: 404 })
})
)
const user = userEvent.setup()
render(<Settings />)
await user.click(await screen.findByRole("button", { name: "Delete" }))
await waitFor(() =>
expect(screen.queryByRole("region", { name: "Claude plan" })).toBeNull()
)
const afterDelete = asked.slice(asked.indexOf("DELETE /llm/connections/3"))
expect(afterDelete).not.toContain("GET /llm/connections/3/models")
})
})
describe("a new connection given a deleted one's id", () => {
it("shows its own models, never the deleted one's", async () => {
// SQLite gives the highest id out again once it is deleted.
const again = { ...CLAUDE, created_at: "2026-10-17T00:05:00" }
let deleted = false
let added = false
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = String(input)
if (init?.method === "DELETE") {
deleted = true
return new Response(null, { status: 204 })
}
if (path === "/llm/connections")
return Response.json(added ? [again] : deleted ? [] : [CLAUDE])
if (path === "/llm/connections/3/models")
return Response.json([{ name: added ? "new-model" : "old-model" }])
return Response.json({ detail: "not found" }, { status: 404 })
})
)
const user = userEvent.setup()
render(<Settings />)
expect(await screen.findByText("old-model")).toBeTruthy()
await user.click(screen.getByRole("button", { name: "Delete" }))
await waitFor(() =>
expect(screen.queryByRole("region", { name: "Claude plan" })).toBeNull()
)
added = true
await user.click(screen.getByRole("button", { name: "Connected" }))
expect(await screen.findByText("new-model")).toBeTruthy()
expect(screen.queryByText("old-model")).toBeNull()
})
})

View file

@ -1,8 +1,9 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import type { ModelType } from "../../model-type"
import { MODELS_QUERY_KEY, useRefreshModels } from "../../models-query"
import { deleteConnection, getConnections } from "./api"
import { MODELS_QUERY_KEY } from "../../models-query"
import { connectionIdQueryKey } from "../models/use-connection-models"
import { deleteConnection, getConnections, type Connection } from "./api"
export const connectionsQueryKey = [
...MODELS_QUERY_KEY,
@ -34,11 +35,27 @@ export function useConnectionsServing(modelType: ModelType | null) {
})
}
/** Removing a server clears every slot it filled, whichever section asked. */
/**
* Removing a server clears every slot it filled, whichever section asked. The
* server itself is taken off the list rather than asked about again: it would
* answer "not found" on a card that is going.
*/
export function useDeleteConnection() {
const refresh = useRefreshModels()
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => deleteConnection(id),
onSuccess: () => refresh(),
onSuccess: async (_deleted, id) => {
const its = connectionIdQueryKey(id)
queryClient.setQueryData<Connection[]>(
connectionsQueryKey,
(connections) =>
connections?.filter((connection) => connection.id !== id)
)
await queryClient.invalidateQueries({
queryKey: MODELS_QUERY_KEY,
predicate: (query) =>
!its.every((part, index) => query.queryKey[index] === part),
})
},
})
}

View file

@ -104,7 +104,7 @@ export function ServerModels({
unlisted: boolean
} | null>(null)
const [tryOpen, setTryOpen] = useState(false)
const models = useConnectionModels(connection.id, open)
const models = useConnectionModels(connection, open)
const selection = useSelection(modelType)
const inUse = (name: string) =>
@ -503,6 +503,10 @@ export function ServerModels({
modelType={modelType}
model={trying.model}
unlisted={trying.unlisted}
plan={
connection.auth_kind === "chatgpt" ||
connection.auth_kind === "claude_plan"
}
onOpenChange={setTryOpen}
onOpenChangeComplete={(open) => {
if (!open) setTrying(null)

View file

@ -0,0 +1,51 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { cleanup, screen } from "@testing-library/react"
import { render } from "@/test-utils"
import type { ConnectionModel } from "./api"
import { TryModelDialog } from "./try-model-dialog"
afterEach(cleanup)
const FABLE: ConnectionModel = {
connection_id: 3,
connection_label: "Claude plan",
name: "claude-fable-5-1",
types: ["text_gen"],
capability_source: "catalog",
selectable_for: ["text_gen"],
reads_images: true,
}
function open(plan: boolean) {
render(
<TryModelDialog
open
modelType="text_gen"
model={FABLE}
unlisted={false}
plan={plan}
onOpenChange={vi.fn()}
onOpenChangeComplete={vi.fn()}
/>
)
}
describe("what testing a chat model costs", () => {
it("counts toward a plan's limits, and costs no money, on a plan", async () => {
open(true)
const dialog = await screen.findByRole("dialog")
expect(dialog.textContent).toContain("counts toward your plan’s limits")
expect(dialog.textContent).not.toContain("may cost money")
})
it("may cost money on a server that bills per request", async () => {
open(false)
expect((await screen.findByRole("dialog")).textContent).toContain(
"may cost money"
)
})
})

View file

@ -46,11 +46,21 @@ function messageFrom(error: unknown) {
function Explanation({
modelType,
model,
plan,
}: {
modelType: ModelType
model: ConnectionModel
plan: boolean
}) {
const image = modelType === "image_gen"
// A plan bills nothing per prompt; it fills the chat slot alone.
if (plan && modelType === "text_gen") {
return intl.formatMessage({
id: "models_try_dialog_chat_plan_body",
defaultMessage:
"Chat support is confirmed. Testing sends one short prompt, which counts toward your plan’s limits.",
})
}
if (modelType === "audio_gen") {
return model.capability_source === "unknown"
? intl.formatMessage({
@ -100,6 +110,7 @@ export function TryModelDialog({
modelType,
model,
unlisted,
plan = false,
onOpenChange,
onOpenChangeComplete,
onSelected,
@ -108,6 +119,8 @@ export function TryModelDialog({
modelType: ModelType
model: ConnectionModel
unlisted: boolean
/** A ChatGPT or Claude plan: a test uses the plan's limits, not money. */
plan?: boolean
onOpenChange: (open: boolean) => void
onOpenChangeComplete: (open: boolean) => void
onSelected?: (selection: ModelSelection) => void
@ -204,7 +217,7 @@ export function TryModelDialog({
)}
</DialogTitle>
<DialogDescription>
<Explanation modelType={modelType} model={model} />
<Explanation modelType={modelType} model={model} plan={plan} />
</DialogDescription>
</DialogHeader>
{modelType === "audio_gen" ? (

View file

@ -1,13 +1,32 @@
import { useQuery } from "@tanstack/react-query"
import { MODELS_QUERY_KEY } from "../../models-query"
import type { Connection } from "../connections/api"
import { getConnectionModels } from "./api"
type Identity = Pick<Connection, "id" | "created_at">
/** Every query about one connection id, whichever connection held it. */
export function connectionIdQueryKey(id: number) {
return [...MODELS_QUERY_KEY, "remote", "connection", id] as const
}
/**
* By the connection, not its id alone: SQLite gives a deleted connection's id
* to the next one, which must never see the last one's models.
*/
export function connectionModelsQueryKey(connection: Identity) {
return [
...connectionIdQueryKey(connection.id),
connection.created_at,
] as const
}
/** Fetched only once asked for: listing a hosted catalog is a network call. */
export function useConnectionModels(connectionId: number, enabled: boolean) {
export function useConnectionModels(connection: Identity, enabled: boolean) {
return useQuery({
queryKey: [...MODELS_QUERY_KEY, "remote", "connection", connectionId],
queryFn: ({ signal }) => getConnectionModels(connectionId, signal),
queryKey: connectionModelsQueryKey(connection),
queryFn: ({ signal }) => getConnectionModels(connection.id, signal),
enabled,
})
}

View file

@ -707,6 +707,7 @@
"models_speech_model_languages_button": "{count, plural, one {# Sprache} other {# Sprachen}}",
"models_try_dialog_cancel_button": "Abbrechen",
"models_try_dialog_chat_confirmed_body": "Chat-Unterstützung ist bestätigt. Der Test sendet einen kurzen Prompt und kann Kosten verursachen.",
"models_try_dialog_chat_plan_body": "Chat-Unterstützung ist bestätigt. Der Test sendet einen kurzen Prompt, der auf die Limits deines Plans angerechnet wird.",
"models_try_dialog_chat_unconfirmed_body": "Dieser Endpunkt veröffentlicht keine Fähigkeiten, daher ist Chat-Unterstützung nicht bestätigt. Der Test sendet einen kurzen Prompt und kann Kosten verursachen.",
"models_try_dialog_image_confirmed_body": "Bildunterstützung ist bestätigt. Der Test führt eine echte Inferenz aus und kann Kosten verursachen.",
"models_try_dialog_image_unconfirmed_body": "Dieser Endpunkt veröffentlicht keine Fähigkeiten, daher ist Bildunterstützung nicht bestätigt. Er muss /images/generations oder /images unterstützen. Der Test führt eine echte Inferenz aus und kann Kosten verursachen.",

View file

@ -707,6 +707,7 @@
"models_speech_model_languages_button": "{count, plural, one {# language} other {# languages}}",
"models_try_dialog_cancel_button": "Cancel",
"models_try_dialog_chat_confirmed_body": "Chat support is confirmed. Testing sends one short prompt and may cost money.",
"models_try_dialog_chat_plan_body": "Chat support is confirmed. Testing sends one short prompt, which counts toward your plan’s limits.",
"models_try_dialog_chat_unconfirmed_body": "This endpoint does not publish capabilities, so chat support is unconfirmed. Testing sends one short prompt and may cost money.",
"models_try_dialog_image_confirmed_body": "Image support is confirmed. Testing runs real inference and may cost money.",
"models_try_dialog_image_unconfirmed_body": "This endpoint does not publish capabilities, so image support is unconfirmed. It must implement /images/generations or /images. Testing runs real inference and may cost money.",

View file

@ -707,6 +707,7 @@
"models_speech_model_languages_button": "{count, plural, one {# idioma} many {# idiomas} other {# idiomas}}",
"models_try_dialog_cancel_button": "Cancelar",
"models_try_dialog_chat_confirmed_body": "La compatibilidad con chat está confirmada. La prueba envía un prompt corto y puede costar dinero.",
"models_try_dialog_chat_plan_body": "La compatibilidad con chat está confirmada. La prueba envía un prompt corto, que cuenta para los límites de tu plan.",
"models_try_dialog_chat_unconfirmed_body": "Este endpoint no publica sus capacidades, así que la compatibilidad con chat no está confirmada. La prueba envía un prompt corto y puede costar dinero.",
"models_try_dialog_image_confirmed_body": "La compatibilidad con imagen está confirmada. La prueba ejecuta una inferencia real y puede costar dinero.",
"models_try_dialog_image_unconfirmed_body": "Este endpoint no publica sus capacidades, así que la compatibilidad con imagen no está confirmada. Debe implementar /images/generations o /images. La prueba ejecuta una inferencia real y puede costar dinero.",

View file

@ -707,6 +707,7 @@
"models_speech_model_languages_button": "{count, plural, one {# langue} many {# langues} other {# langues}}",
"models_try_dialog_cancel_button": "Annuler",
"models_try_dialog_chat_confirmed_body": "La prise en charge du chat est confirmée. Le test envoie un prompt court et peut être facturé.",
"models_try_dialog_chat_plan_body": "La prise en charge du chat est confirmée. Le test envoie un prompt court, qui compte dans les limites de votre forfait.",
"models_try_dialog_chat_unconfirmed_body": "Cet endpoint ne publie pas ses capacités, la prise en charge du chat n’est donc pas confirmée. Le test envoie un prompt court et peut être facturé.",
"models_try_dialog_image_confirmed_body": "La prise en charge de l’image est confirmée. Le test lance une véritable inférence et peut être facturé.",
"models_try_dialog_image_unconfirmed_body": "Cet endpoint ne publie pas ses capacités, la prise en charge de l’image n’est donc pas confirmée. Il doit implémenter /images/generations ou /images. Le test lance une véritable inférence et peut être facturé.",

View file

@ -707,6 +707,7 @@
"models_speech_model_languages_button": "{count, plural, one {# भाषा} other {# भाषाएं}}",
"models_try_dialog_cancel_button": "रद्द करें",
"models_try_dialog_chat_confirmed_body": "चैट सपोर्ट की पुष्टि हो चुकी है। जांच में एक छोटा प्रॉम्प्ट भेजा जाता है, जिसका शुल्क लग सकता है।",
"models_try_dialog_chat_plan_body": "चैट सपोर्ट की पुष्टि हो चुकी है। जांच में एक छोटा प्रॉम्प्ट भेजा जाता है, जो आपके प्लान की सीमा में गिना जाता है।",
"models_try_dialog_chat_unconfirmed_body": "यह एंडपॉइंट अपनी क्षमताएं प्रकाशित नहीं करता, इसलिए चैट सपोर्ट की पुष्टि नहीं हुई है। जांच में एक छोटा प्रॉम्प्ट भेजा जाता है, जिसका शुल्क लग सकता है।",
"models_try_dialog_image_confirmed_body": "इमेज सपोर्ट की पुष्टि हो चुकी है। जांच में असली इन्फ़रेंस चलता है, जिसका शुल्क लग सकता है।",
"models_try_dialog_image_unconfirmed_body": "यह एंडपॉइंट अपनी क्षमताएं प्रकाशित नहीं करता, इसलिए इमेज सपोर्ट की पुष्टि नहीं हुई है। इसे /images/generations या /images लागू करना ज़रूरी है। जांच में असली इन्फ़रेंस चलता है, जिसका शुल्क लग सकता है।",

View file

@ -707,6 +707,7 @@
"models_speech_model_languages_button": "{count, plural, other {#言語}}",
"models_try_dialog_cancel_button": "キャンセル",
"models_try_dialog_chat_confirmed_body": "チャットへの対応は確認済みです。テストでは短いプロンプトを1回送信するため、料金が発生する場合があります。",
"models_try_dialog_chat_plan_body": "チャットへの対応は確認済みです。テストでは短いプロンプトを1回送信し、プランの使用上限に含まれます。",
"models_try_dialog_chat_unconfirmed_body": "このエンドポイントは機能を公開していないため、チャットへの対応は未確認です。テストでは短いプロンプトを1回送信するため、料金が発生する場合があります。",
"models_try_dialog_image_confirmed_body": "画像への対応は確認済みです。テストでは実際に推論を行うため、料金が発生する場合があります。",
"models_try_dialog_image_unconfirmed_body": "このエンドポイントは機能を公開していないため、画像への対応は未確認です。/images/generationsまたは/imagesを実装している必要があります。テストでは実際に推論を行うため、料金が発生する場合があります。",

View file

@ -707,6 +707,7 @@
"models_speech_model_languages_button": "{count, plural, other {#개 언어}}",
"models_try_dialog_cancel_button": "취소",
"models_try_dialog_chat_confirmed_body": "채팅 지원이 확인되었습니다. 테스트는 짧은 프롬프트를 한 번 전송하며 비용이 발생할 수 있습니다.",
"models_try_dialog_chat_plan_body": "채팅 지원이 확인되었습니다. 테스트는 짧은 프롬프트를 한 번 보내며, 플랜 한도에 포함됩니다.",
"models_try_dialog_chat_unconfirmed_body": "이 엔드포인트는 기능을 공개하지 않으므로 채팅 지원 여부가 확인되지 않았습니다. 테스트는 짧은 프롬프트를 한 번 전송하며 비용이 발생할 수 있습니다.",
"models_try_dialog_image_confirmed_body": "이미지 지원이 확인되었습니다. 테스트는 실제 추론을 실행하며 비용이 발생할 수 있습니다.",
"models_try_dialog_image_unconfirmed_body": "이 엔드포인트는 기능을 공개하지 않으므로 이미지 지원 여부가 확인되지 않았습니다. /images/generations 또는 /images를 구현해야 합니다. 테스트는 실제 추론을 실행하며 비용이 발생할 수 있습니다.",

View file

@ -707,6 +707,7 @@
"models_speech_model_languages_button": "{count, plural, one {# idioma} many {# idiomas} other {# idiomas}}",
"models_try_dialog_cancel_button": "Cancelar",
"models_try_dialog_chat_confirmed_body": "O suporte a chat está confirmado. O teste envia um prompt curto e pode gerar custos.",
"models_try_dialog_chat_plan_body": "O suporte a chat está confirmado. O teste envia um prompt curto, que conta para os limites do seu plano.",
"models_try_dialog_chat_unconfirmed_body": "Este endpoint não publica suas capacidades, então o suporte a chat não está confirmado. O teste envia um prompt curto e pode gerar custos.",
"models_try_dialog_image_confirmed_body": "O suporte a imagem está confirmado. O teste executa uma inferência real e pode gerar custos.",
"models_try_dialog_image_unconfirmed_body": "Este endpoint não publica suas capacidades, então o suporte a imagem não está confirmado. Ele precisa implementar /images/generations ou /images. O teste executa uma inferência real e pode gerar custos.",

View file

@ -707,6 +707,7 @@
"models_speech_model_languages_button": "{count, plural, one {# язык} few {# языка} many {# языков} other {# языка}}",
"models_try_dialog_cancel_button": "Отмена",
"models_try_dialog_chat_confirmed_body": "Поддержка чата подтверждена. При тесте отправляется один короткий промпт, это может стоить денег.",
"models_try_dialog_chat_plan_body": "Поддержка чата подтверждена. Тест отправляет один короткий промпт, который учитывается в лимитах вашего плана.",
"models_try_dialog_chat_unconfirmed_body": "Эта конечная точка не сообщает о своих возможностях, поэтому поддержка чата не подтверждена. При тесте отправляется один короткий промпт, это может стоить денег.",
"models_try_dialog_image_confirmed_body": "Поддержка изображений подтверждена. Тест выполняет настоящий инференс, это может стоить денег.",
"models_try_dialog_image_unconfirmed_body": "Эта конечная точка не сообщает о своих возможностях, поэтому поддержка изображений не подтверждена. Она должна реализовывать /images/generations или /images. Тест выполняет настоящий инференс, это может стоить денег.",

View file

@ -707,6 +707,7 @@
"models_speech_model_languages_button": "{count, plural, other {# 种语言}}",
"models_try_dialog_cancel_button": "取消",
"models_try_dialog_chat_confirmed_body": "已确认支持对话。测试会发送一条简短的提示词,可能产生费用。",
"models_try_dialog_chat_plan_body": "已确认支持对话。测试会发送一条简短提示词,计入你的套餐额度。",
"models_try_dialog_chat_unconfirmed_body": "这个端点没有公布能力,因此是否支持对话尚未确认。测试会发送一条简短的提示词,可能产生费用。",
"models_try_dialog_image_confirmed_body": "已确认支持图像。测试会真实执行推理,可能产生费用。",
"models_try_dialog_image_unconfirmed_body": "这个端点没有公布能力,因此是否支持图像尚未确认。它必须实现 /images/generations 或 /images。测试会真实执行推理,可能产生费用。",