Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/features/15138-connection-test-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **feat(dashboard):** per-account "Test message" action on each provider connection — a saved test model per connection (Edit connection) and a global test prompt in Settings → AI, backed by `GET/PUT/POST /api/providers/{id}/test-message`; the quota card footer actions also wrap responsively (#15138 — thanks @JxnLexn)
64 changes: 64 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2225,6 +2225,70 @@ paths:
"200":
description: Test result

/api/providers/{id}/test-message:
get:
tags: [Providers]
summary: Read an account's saved test model and the global test message
description: Requires management authentication. Returns modelId and prompt without credentials.
parameters:
- $ref: "#/components/parameters/ResourceId"
responses:
"200":
description: Saved modelId and effective prompt
"404":
description: Connection not found
put:
tags: [Providers]
summary: Save the chat model used by this account's test-message button
description: Requires management authentication. Saving never sends an inference request.
parameters:
- $ref: "#/components/parameters/ResourceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [modelId]
additionalProperties: false
properties:
modelId:
type: string
minLength: 1
maxLength: 256
responses:
"200":
description: Saved modelId
"400":
description: Invalid model selection
"404":
description: Connection not found
post:
tags: [Providers]
summary: Send one real test message through the selected account
description: >-
Requires management authentication. Uses the account's saved model and the global
connectionTestPrompt setting (default: What is 1 + 1? Reply with only the number.).
The prompt is configurable through PATCH /api/settings and limited to 500 characters.
This manual request bypasses the response cache, uses only this connection and
never falls back to another account. It consumes quota; usage-window behavior
is determined by the provider. Saving settings does not schedule requests.
parameters:
- $ref: "#/components/parameters/ResourceId"
responses:
"200":
description: modelId, prompt, responseText and latencyMs
"400":
description: Missing model selection or invalid chat model
"403":
description: Paid model blocked by settings
"404":
description: Connection not found
"409":
description: Account inactive or a test message is already running
"504":
description: No completed response within the test deadline

/api/providers/{id}/models:
get:
tags: [Providers]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use client";

import ConnectionTestButton from "@/shared/components/ConnectionTestButton";

// Phase 1d extraction — Issue #3501
// ConnectionRow (and its local helpers CooldownTimer, inferErrorType,
// getStatusPresentation) moved out of ProviderDetailPageClient.tsx.
Expand Down Expand Up @@ -885,6 +887,10 @@ export default function ConnectionRow({
</div>
</div>
<div className="flex items-center gap-2">
<ConnectionTestButton
connectionId={connection.id}
disabled={connection.isActive === false}
/>
<Button
size="sm"
variant="ghost"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { useState } from "react";
import { useTranslations } from "next-intl";
import ConnectionTestModelField from "@/shared/components/ConnectionTestModelField";
import { useConnectionTestModelDraft } from "@/shared/components/useConnectionTestModelDraft";
import { Button, Badge, Input, Modal, Toggle, Select } from "@/shared/components";
import { CHATGPT_WEB_CODEX_CONNECTOR_NAME } from "@/shared/constants/chatgptWebCodex";
import {
Expand Down Expand Up @@ -53,6 +55,7 @@ import { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
import HarImportButton from "../HarImportButton";
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
import type { ApiKeyHealthMap } from "./connectionApiKeyHealth";
import ClaudeConnectionFields from "./ClaudeConnectionFields";
import {
claudeConnectionFieldPatch,
Expand Down Expand Up @@ -181,21 +184,11 @@ export default function EditConnectionModal({
const [doctorStatus, setDoctorStatus] = useState<Record<string, any> | null>(null);
const [doctorLoading, setDoctorLoading] = useState(false);
const [saving, setSaving] = useState(false);
const testModel = useConnectionTestModelDraft(isOpen ? connection : null, saving);
const [saveError, setSaveError] = useState<string | null>(null);
const [extraApiKeys, setExtraApiKeys] = useState<string[]>([]);
const [newExtraKey, setNewExtraKey] = useState("");
const [apiKeyHealth, setApiKeyHealth] = useState<
Record<
string,
{
status: "active" | "warning" | "invalid";
failures: number;
lastFailure: string | null;
totalRequests?: number;
totalFailures?: number;
}
>
>({});
const [apiKeyHealth, setApiKeyHealth] = useState<ApiKeyHealthMap>({});
const [showAdvanced, setShowAdvanced] = useState(false);
const showEmail = useEmailPrivacyStore((state) => state.emailsVisible);
// #6147 — built-in providers can opt in to an advanced base-URL override.
Expand Down Expand Up @@ -422,18 +415,7 @@ export default function EditConnectionModal({
});
const existing = connection.providerSpecificData?.extraApiKeys;
setExtraApiKeys(Array.isArray(existing) ? existing : []);
const health = connection.providerSpecificData?.apiKeyHealth as
| Record<
string,
{
status: "active" | "warning" | "invalid";
failures: number;
lastFailure: string | null;
totalRequests?: number;
totalFailures?: number;
}
>
| undefined;
const health = connection.providerSpecificData?.apiKeyHealth as ApiKeyHealthMap | undefined;
setApiKeyHealth(health || {});
setNewExtraKey("");
setOpenRouterPreset(existingOpenRouterPreset);
Expand Down Expand Up @@ -762,6 +744,7 @@ export default function EditConnectionModal({
// previously-saved `true` and unchecking would never take effect.
updates.providerSpecificData.importFreeModelsOnly = formData.importFreeModelsOnly === true;
}
testModel.applyTo(updates.providerSpecificData);
const error = (await onSave(updates)) as void | unknown;
if (error) {
setSaveError(typeof error === "string" ? error : t("failedSaveConnection"));
Expand Down Expand Up @@ -810,6 +793,9 @@ export default function EditConnectionModal({
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={isOAuth ? t("accountName") : t("productionKey")}
/>
{isOpen && connection.id && (
<ConnectionTestModelField key={connection.id} {...testModel.fieldProps} />
)}
<Input
label={t("tagGroupLabel")}
value={formData.tag}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type ApiKeyHealthEntry = {
status: "active" | "warning" | "invalid";
failures: number;
lastFailure: string | null;
totalRequests?: number;
totalFailures?: number;
};

export type ApiKeyHealthMap = Record<string, ApiKeyHealthEntry>;
2 changes: 2 additions & 0 deletions src/app/(dashboard)/dashboard/settings/ai/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useTranslations } from "next-intl";
import ConnectionTestSettings from "../components/ConnectionTestSettings";
import ThinkingBudgetTab from "../components/ThinkingBudgetTab";
import ModalityBridgeMovedCard from "../components/ModalityBridgeMovedCard";
import SystemPromptTab from "../components/SystemPromptTab";
Expand All @@ -24,6 +25,7 @@ export default function SettingsAiPage() {
<ResponsesStatePolicyTab />
<UsageTokenBufferTab />
<CodexFastTierTab />
<ConnectionTestSettings />
<CodexAutoPingTab />
<ClaudeFastModeTab />
<MemorySkillsTab />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"use client";

import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import Card from "@/shared/components/Card";
import Button from "@/shared/components/Button";
import {
DEFAULT_CONNECTION_TEST_PROMPT,
CONNECTION_TEST_PROMPT_MAX_LENGTH,
} from "@/shared/constants/connectionTest";

export default function ConnectionTestSettings() {
const t = useTranslations("connectionTest");
const [prompt, setPrompt] = useState(DEFAULT_CONNECTION_TEST_PROMPT);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
fetch("/api/settings")
.then(async (response) => {
if (!response.ok) throw new Error();
const data = await response.json();
if (!cancelled) setPrompt(data.connectionTestPrompt || DEFAULT_CONNECTION_TEST_PROMPT);
})
.catch(() => {
if (!cancelled) setError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
async function save() {
if (saving || loading || !prompt.trim()) return;
setSaving(true);
setSaved(false);
setError(false);
try {
const response = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ connectionTestPrompt: prompt.trim() }),
});
if (!response.ok) throw new Error();
setPrompt(prompt.trim());
setSaved(true);
} catch {
setError(true);
} finally {
setSaving(false);
}
}
return (
<Card>
<h3 className="text-lg font-semibold">{t("settingsTitle")}</h3>
<p className="mt-1 text-sm text-text-muted">{t("settingsDescription")}</p>
<label htmlFor="connection-test-prompt" className="mt-4 block text-sm font-medium">
{t("message")}
</label>
<textarea
id="connection-test-prompt"
rows={3}
value={prompt}
maxLength={CONNECTION_TEST_PROMPT_MAX_LENGTH}
disabled={loading || saving}
className="mt-2 w-full rounded-lg border border-border bg-bg-main p-3 text-sm"
onChange={(event) => {
setPrompt(event.target.value);
setSaved(false);
}}
/>
<div className="mt-3 flex items-center gap-3">
<Button loading={saving} disabled={loading || !prompt.trim()} onClick={save}>
{t("saveMessage")}
</Button>
{saved && (
<span role="status" className="text-sm text-green-600">
{t("saved")}
</span>
)}
{error && (
<span role="alert" className="text-sm text-red-500">
{t("failed")}
</span>
)}
</div>
</Card>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export default function QuotaCard({
hasStaleData={hasStaleData}
onToggleActive={onToggleActive}
togglingActive={togglingActive}
onTestSent={onRefresh}
/>
<QuotaCardExpanded
quotas={quotas}
Expand Down
Loading
Loading