feat(ui): edit ai providers

This commit is contained in:
null 2026-05-21 20:11:46 -05:00
parent ac29c79ff2
commit f48cf45cce
4 changed files with 347 additions and 54 deletions

View File

@ -952,9 +952,10 @@ async def fetch_provider_usage(
return cached return cached
if provider == "anthropic": if provider == "anthropic":
# Prefer the local Claude Code OAuth token (sk-ant-oat01-...) because it's # Auto-detected local OAuth token (sk-ant-oat01-) takes precedence — it is
# the correct credential type for the oauth/usage endpoint. Fall back to # the correct credential type for the oauth/usage endpoint and is always
# the manually-stored session_key only when no local OAuth token exists. # fresher than a manually stored key. Fall back to session_key only when
# no local token is found (e.g. Pipeline running on a different machine).
local_oauth = _read_claude_local_oauth_token() local_oauth = _read_claude_local_oauth_token()
effective_session_key = local_oauth or session_key effective_session_key = local_oauth or session_key
@ -994,9 +995,11 @@ async def fetch_provider_usage(
provider=provider, account_key=account_key, provider=provider, account_key=account_key,
checked_at=utcnow(), reachable=True, checked_at=utcnow(), reachable=True,
) )
# Overlay subscription windows — auto-detected Codex CLI token takes precedence # Overlay subscription windows.
# Explicit session_key wins (allows a second account to override auto-detection).
# Fall back to auto-detected Codex CLI token when no key is stored.
local_codex = _read_codex_local_token() local_codex = _read_codex_local_token()
effective_codex_key = local_codex or session_key effective_codex_key = session_key or local_codex
if effective_codex_key and result.reachable is not False: if effective_codex_key and result.reachable is not False:
sub_windows, plan_label = await _fetch_codex_subscription(effective_codex_key) sub_windows, plan_label = await _fetch_codex_subscription(effective_codex_key)
if sub_windows: if sub_windows:

View File

@ -0,0 +1,100 @@
"""sync_model_drift
Revision ID: a7aa29cf8a20
Revises: a1b2c3d4e5f9
Create Date: 2026-05-22 01:08:44.078222
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a7aa29cf8a20'
down_revision = 'a1b2c3d4e5f9'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('fk_activity_events_board_id_boards'), 'activity_events', type_='foreignkey')
op.create_foreign_key(None, 'activity_events', 'boards', ['board_id'], ['id'])
op.alter_column('agents', 'gateway_id',
existing_type=sa.UUID(),
nullable=False)
op.drop_index(op.f('ix_agents_openclaw_session_id'), table_name='agents', postgresql_where='(openclaw_session_id IS NOT NULL)')
op.create_index(op.f('ix_agents_openclaw_session_id'), 'agents', ['openclaw_session_id'], unique=False)
op.alter_column('approvals', 'confidence',
existing_type=sa.INTEGER(),
type_=sa.Float(),
existing_nullable=False)
op.drop_index(op.f('ix_board_repository_links_org_id'), table_name='board_repository_links')
op.create_index(op.f('ix_board_repository_links_organization_id'), 'board_repository_links', ['organization_id'], unique=False)
op.drop_constraint(op.f('board_task_custom_fields_organization_id_fkey'), 'board_task_custom_fields', type_='foreignkey')
op.drop_column('board_task_custom_fields', 'organization_id')
op.alter_column('boards', 'description',
existing_type=sa.VARCHAR(),
nullable=False)
op.drop_index(op.f('ix_forgejo_connections_org_id'), table_name='forgejo_connections')
op.create_index(op.f('ix_forgejo_connections_organization_id'), 'forgejo_connections', ['organization_id'], unique=False)
op.drop_index(op.f('ix_forgejo_issues_org_id'), table_name='forgejo_issues')
op.drop_index(op.f('ix_forgejo_issues_repo_id'), table_name='forgejo_issues')
op.create_index(op.f('ix_forgejo_issues_forgejo_issue_number'), 'forgejo_issues', ['forgejo_issue_number'], unique=False)
op.create_index(op.f('ix_forgejo_issues_organization_id'), 'forgejo_issues', ['organization_id'], unique=False)
op.create_index(op.f('ix_forgejo_issues_repository_id'), 'forgejo_issues', ['repository_id'], unique=False)
op.drop_index(op.f('ix_forgejo_repos_conn_id'), table_name='forgejo_repositories')
op.drop_index(op.f('ix_forgejo_repos_org_id'), table_name='forgejo_repositories')
op.create_index(op.f('ix_forgejo_repositories_connection_id'), 'forgejo_repositories', ['connection_id'], unique=False)
op.create_index(op.f('ix_forgejo_repositories_organization_id'), 'forgejo_repositories', ['organization_id'], unique=False)
op.drop_constraint(op.f('uq_organizations_name'), 'organizations', type_='unique')
op.drop_index(op.f('ix_provider_credentials_org_id'), table_name='provider_credentials')
op.drop_constraint(op.f('uq_provider_credentials_org_provider_key'), 'provider_credentials', type_='unique')
op.create_index(op.f('ix_provider_credentials_account_key'), 'provider_credentials', ['account_key'], unique=False)
op.create_index(op.f('ix_provider_credentials_organization_id'), 'provider_credentials', ['organization_id'], unique=False)
op.drop_constraint(op.f('task_custom_field_values_organization_id_fkey'), 'task_custom_field_values', type_='foreignkey')
op.drop_column('task_custom_field_values', 'organization_id')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('task_custom_field_values', sa.Column('organization_id', sa.UUID(), autoincrement=False, nullable=False))
op.create_foreign_key(op.f('task_custom_field_values_organization_id_fkey'), 'task_custom_field_values', 'organizations', ['organization_id'], ['id'])
op.drop_index(op.f('ix_provider_credentials_organization_id'), table_name='provider_credentials')
op.drop_index(op.f('ix_provider_credentials_account_key'), table_name='provider_credentials')
op.create_unique_constraint(op.f('uq_provider_credentials_org_provider_key'), 'provider_credentials', ['organization_id', 'provider', 'account_key'], postgresql_nulls_not_distinct=False)
op.create_index(op.f('ix_provider_credentials_org_id'), 'provider_credentials', ['organization_id'], unique=False)
op.create_unique_constraint(op.f('uq_organizations_name'), 'organizations', ['name'], postgresql_nulls_not_distinct=False)
op.drop_index(op.f('ix_forgejo_repositories_organization_id'), table_name='forgejo_repositories')
op.drop_index(op.f('ix_forgejo_repositories_connection_id'), table_name='forgejo_repositories')
op.create_index(op.f('ix_forgejo_repos_org_id'), 'forgejo_repositories', ['organization_id'], unique=False)
op.create_index(op.f('ix_forgejo_repos_conn_id'), 'forgejo_repositories', ['connection_id'], unique=False)
op.drop_index(op.f('ix_forgejo_issues_repository_id'), table_name='forgejo_issues')
op.drop_index(op.f('ix_forgejo_issues_organization_id'), table_name='forgejo_issues')
op.drop_index(op.f('ix_forgejo_issues_forgejo_issue_number'), table_name='forgejo_issues')
op.create_index(op.f('ix_forgejo_issues_repo_id'), 'forgejo_issues', ['repository_id'], unique=False)
op.create_index(op.f('ix_forgejo_issues_org_id'), 'forgejo_issues', ['organization_id'], unique=False)
op.drop_index(op.f('ix_forgejo_connections_organization_id'), table_name='forgejo_connections')
op.create_index(op.f('ix_forgejo_connections_org_id'), 'forgejo_connections', ['organization_id'], unique=False)
op.alter_column('boards', 'description',
existing_type=sa.VARCHAR(),
nullable=True)
op.add_column('board_task_custom_fields', sa.Column('organization_id', sa.UUID(), autoincrement=False, nullable=False))
op.create_foreign_key(op.f('board_task_custom_fields_organization_id_fkey'), 'board_task_custom_fields', 'organizations', ['organization_id'], ['id'])
op.drop_index(op.f('ix_board_repository_links_organization_id'), table_name='board_repository_links')
op.create_index(op.f('ix_board_repository_links_org_id'), 'board_repository_links', ['organization_id'], unique=False)
op.alter_column('approvals', 'confidence',
existing_type=sa.Float(),
type_=sa.INTEGER(),
existing_nullable=False)
op.drop_index(op.f('ix_agents_openclaw_session_id'), table_name='agents')
op.create_index(op.f('ix_agents_openclaw_session_id'), 'agents', ['openclaw_session_id'], unique=True, postgresql_where='(openclaw_session_id IS NOT NULL)')
op.alter_column('agents', 'gateway_id',
existing_type=sa.UUID(),
nullable=True)
op.drop_constraint(None, 'activity_events', type_='foreignkey')
op.create_foreign_key(op.f('fk_activity_events_board_id_boards'), 'activity_events', 'boards', ['board_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###

View File

@ -136,6 +136,68 @@ async def test_usage_response_includes_rate_limit_header_names(monkeypatch: pyte
await engine.dispose() await engine.dispose()
@pytest.mark.asyncio
async def test_update_display_name_preserves_stored_tokens() -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
organization = Organization(id=uuid4(), name="Pipeline")
member = OrganizationMember(
id=uuid4(),
organization_id=organization.id,
user_id=uuid4(),
role="owner",
)
app = _build_test_app(
session_maker,
OrganizationContext(organization=organization, member=member),
)
credential = ProviderCredential(
id=uuid4(),
organization_id=organization.id,
provider="anthropic",
account_key="default",
display_name="Claude",
api_key="sk-ant-original",
api_key_last_four="inal",
session_key="sk-ant-session-original",
session_key_last_four="inal",
active=True,
)
try:
async with session_maker() as session:
session.add(organization)
session.add(credential)
await session.commit()
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.patch(
f"/api/v1/provider-credentials/{credential.id}",
json={"display_name": "Renamed Claude"},
)
assert response.status_code == 200
data = response.json()
assert data["display_name"] == "Renamed Claude"
assert data["has_api_key"] is True
assert data["api_key_last_four"] == "inal"
assert data["has_session_key"] is True
assert data["session_key_last_four"] == "inal"
async with session_maker() as session:
stored = await session.get(ProviderCredential, credential.id)
assert stored is not None
assert stored.display_name == "Renamed Claude"
assert stored.api_key == "sk-ant-original"
assert stored.session_key == "sk-ant-session-original"
finally:
await engine.dispose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_test_endpoint_returns_live_result(monkeypatch: pytest.MonkeyPatch) -> None: async def test_test_endpoint_returns_live_result(monkeypatch: pytest.MonkeyPatch) -> None:
engine = await _make_engine() engine = await _make_engine()

View File

@ -11,6 +11,7 @@ import {
HelpCircle, HelpCircle,
KeyRound, KeyRound,
Loader2, Loader2,
Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
Server, Server,
@ -190,8 +191,25 @@ function HelpToggle({
// Add/Edit form // Add/Edit form
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type CredentialFormData = {
account_key: string;
display_name: string;
api_key: string;
session_key: string;
base_url: string;
};
type ProviderCredentialUpdateWithSession = {
display_name?: string | null;
api_key?: string | null;
session_key?: string | null;
base_url?: string | null;
active?: boolean | null;
};
interface CredentialFormProps { interface CredentialFormProps {
providerId: ProviderId; mode?: "create" | "edit";
initialCredential?: ProviderCredentialRead;
allowMultiple: boolean; allowMultiple: boolean;
showBaseUrl: boolean; showBaseUrl: boolean;
showSessionKey: boolean; showSessionKey: boolean;
@ -203,24 +221,16 @@ interface CredentialFormProps {
apiKeyHelp?: readonly string[]; apiKeyHelp?: readonly string[];
sessionKeyHelp?: readonly string[]; sessionKeyHelp?: readonly string[];
autoDetectedNote?: string; autoDetectedNote?: string;
onSave: (data: { onSave: (data: CredentialFormData) => Promise<void>;
account_key: string; onTest: (data: CredentialFormData) => Promise<ProviderUsageLiveRead>;
display_name: string;
api_key: string;
session_key: string;
base_url: string;
}) => Promise<void>;
onTest: (data: {
account_key: string;
api_key: string;
base_url: string;
}) => Promise<ProviderUsageLiveRead>;
onCancel: () => void; onCancel: () => void;
isSaving: boolean; isSaving: boolean;
error: string | null; error: string | null;
} }
function CredentialForm({ function CredentialForm({
mode = "create",
initialCredential,
allowMultiple, allowMultiple,
showBaseUrl, showBaseUrl,
showSessionKey, showSessionKey,
@ -238,17 +248,32 @@ function CredentialForm({
isSaving, isSaving,
error, error,
}: CredentialFormProps) { }: CredentialFormProps) {
const [accountKey, setAccountKey] = useState(accountKeyDefault); const isEditMode = mode === "edit";
const [displayName, setDisplayName] = useState(""); const [accountKey, setAccountKey] = useState(
initialCredential?.account_key ?? accountKeyDefault,
);
const [displayName, setDisplayName] = useState(
initialCredential?.display_name ?? "",
);
const [apiKey, setApiKey] = useState(""); const [apiKey, setApiKey] = useState("");
const [sessionKey, setSessionKey] = useState(""); const [sessionKey, setSessionKey] = useState("");
const [baseUrl, setBaseUrl] = useState(""); const [baseUrl, setBaseUrl] = useState(initialCredential?.base_url ?? "");
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<ProviderUsageLiveRead | null>(null); const [testResult, setTestResult] = useState<ProviderUsageLiveRead | null>(null);
const [testError, setTestError] = useState<string | null>(null); const [testError, setTestError] = useState<string | null>(null);
const [showApiHelp, setShowApiHelp] = useState(false); const [showApiHelp, setShowApiHelp] = useState(false);
const [showSessionHelp, setShowSessionHelp] = useState(false); const [showSessionHelp, setShowSessionHelp] = useState(false);
const canTest = showBaseUrl ? Boolean(baseUrl.trim()) : Boolean(apiKey.trim()); const canTest = showBaseUrl
? Boolean(baseUrl.trim())
: Boolean(apiKey.trim() || (showSessionKey && sessionKey.trim()));
const existingApiKeyLabel =
isEditMode && initialCredential?.has_api_key
? `Stored API key ${initialCredential.api_key_last_four ? `ending ${initialCredential.api_key_last_four}` : "is set"}. Leave blank to keep it.`
: null;
const existingSessionKeyLabel =
isEditMode && initialCredential?.has_session_key
? `Stored subscription token ${initialCredential.session_key_last_four ? `ending ${initialCredential.session_key_last_four}` : "is set"}. Leave blank to keep it.`
: null;
const runTest = async () => { const runTest = async () => {
setIsTesting(true); setIsTesting(true);
@ -257,7 +282,9 @@ function CredentialForm({
try { try {
const result = await onTest({ const result = await onTest({
account_key: accountKey.trim() || accountKeyDefault || "test", account_key: accountKey.trim() || accountKeyDefault || "test",
display_name: displayName.trim(),
api_key: apiKey.trim(), api_key: apiKey.trim(),
session_key: sessionKey.trim(),
base_url: baseUrl.trim(), base_url: baseUrl.trim(),
}); });
setTestResult(result); setTestResult(result);
@ -280,16 +307,18 @@ function CredentialForm({
value={accountKey} value={accountKey}
onChange={(e) => setAccountKey(e.target.value)} onChange={(e) => setAccountKey(e.target.value)}
placeholder="e.g. work, personal, gpu-box" placeholder="e.g. work, personal, gpu-box"
disabled={isSaving} disabled={isSaving || isEditMode}
/> />
<p className="mt-1 text-[11px] text-muted"> <p className="mt-1 text-[11px] text-muted">
Used to tell accounts apart in cost reports. {isEditMode
? "Account keys are fixed after creation."
: "Used to tell accounts apart in cost reports."}
</p> </p>
</div> </div>
)} )}
<div> <div>
<label className="mb-1 block text-xs font-medium text-muted"> <label className="mb-1 block text-xs font-medium text-muted">
Display name (optional) {isEditMode ? "Display name" : "Display name (optional)"}
</label> </label>
<Input <Input
value={displayName} value={displayName}
@ -342,6 +371,9 @@ function CredentialForm({
disabled={isSaving} disabled={isSaving}
autoComplete="new-password" autoComplete="new-password"
/> />
{existingApiKeyLabel && (
<p className="mt-1 text-[11px] text-muted">{existingApiKeyLabel}</p>
)}
{showApiHelp && !showBaseUrl && apiKeyHelp?.length ? ( {showApiHelp && !showBaseUrl && apiKeyHelp?.length ? (
<HelpSection steps={apiKeyHelp} /> <HelpSection steps={apiKeyHelp} />
) : null} ) : null}
@ -372,6 +404,11 @@ function CredentialForm({
disabled={isSaving} disabled={isSaving}
autoComplete="new-password" autoComplete="new-password"
/> />
{existingSessionKeyLabel && (
<p className="mt-1 text-[11px] text-muted">
{existingSessionKeyLabel}
</p>
)}
{showSessionHelp && sessionKeyHelp?.length ? ( {showSessionHelp && sessionKeyHelp?.length ? (
<HelpSection steps={sessionKeyHelp} /> <HelpSection steps={sessionKeyHelp} />
) : null} ) : null}
@ -401,8 +438,8 @@ function CredentialForm({
onSave({ onSave({
account_key: accountKey.trim(), account_key: accountKey.trim(),
display_name: displayName.trim(), display_name: displayName.trim(),
api_key: apiKey, api_key: apiKey.trim(),
session_key: sessionKey, session_key: sessionKey.trim(),
base_url: baseUrl.trim(), base_url: baseUrl.trim(),
}) })
} }
@ -633,6 +670,10 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
}); });
} }
// Only show rate-limit bars when something is actually being consumed.
// 0% bars with "resets in < 1m" are meaningless noise when idle.
const activeBars = usageBars.filter((b) => b.pct > 0);
return ( return (
<div className="mt-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-2.5"> <div className="mt-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-2.5">
{usage.error && ( {usage.error && (
@ -709,14 +750,14 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
</div> </div>
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
{/* API rate-limit diagnostic bars */} {/* API rate-limit diagnostic bars — only shown when actively consuming the limit */}
{usageBars.length > 0 ? ( {activeBars.length > 0 ? (
<> <>
<div className="text-[11px] text-muted"> <div className="text-[11px] text-muted">
{sourceLabel[usage.source] ?? usage.source} · {usage.confidence} confidence {sourceLabel[usage.source] ?? usage.source} · {usage.confidence} confidence
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{usageBars.map((bar) => ( {activeBars.map((bar) => (
<UsageWindowBar <UsageWindowBar
key={bar.label} key={bar.label}
label={bar.label} label={bar.label}
@ -778,6 +819,7 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
interface CredentialRowProps { interface CredentialRowProps {
cred: ProviderCredentialRead; cred: ProviderCredentialRead;
isAdmin: boolean; isAdmin: boolean;
onEdit: (cred: ProviderCredentialRead) => void;
onDelete: (cred: ProviderCredentialRead) => void; onDelete: (cred: ProviderCredentialRead) => void;
onToggle: (cred: ProviderCredentialRead) => Promise<void>; onToggle: (cred: ProviderCredentialRead) => Promise<void>;
showUsage?: boolean; showUsage?: boolean;
@ -786,6 +828,7 @@ interface CredentialRowProps {
function CredentialRow({ function CredentialRow({
cred, cred,
isAdmin, isAdmin,
onEdit,
onDelete, onDelete,
onToggle, onToggle,
showUsage = true, showUsage = true,
@ -832,6 +875,18 @@ function CredentialRow({
</div> </div>
{isAdmin && ( {isAdmin && (
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{cred.active && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => onEdit(cred)}
aria-label={`Edit ${cred.display_name || cred.account_key}`}
title="Edit provider"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
<button <button
type="button" type="button"
onClick={async () => { onClick={async () => {
@ -876,17 +931,16 @@ interface ProviderSectionProps {
isAdmin: boolean; isAdmin: boolean;
onAdd: ( onAdd: (
providerId: ProviderId, providerId: ProviderId,
data: { data: CredentialFormData,
account_key: string; ) => Promise<void>;
display_name: string; onUpdate: (
api_key: string; providerId: ProviderId,
session_key: string; cred: ProviderCredentialRead,
base_url: string; data: CredentialFormData,
},
) => Promise<void>; ) => Promise<void>;
onTest: ( onTest: (
providerId: ProviderId, providerId: ProviderId,
data: { account_key: string; api_key: string; base_url: string }, data: CredentialFormData,
) => Promise<ProviderUsageLiveRead>; ) => Promise<ProviderUsageLiveRead>;
onDelete: (cred: ProviderCredentialRead) => void; onDelete: (cred: ProviderCredentialRead) => void;
onToggle: (cred: ProviderCredentialRead) => Promise<void>; onToggle: (cred: ProviderCredentialRead) => Promise<void>;
@ -897,16 +951,19 @@ function ProviderSection({
credentials, credentials,
isAdmin, isAdmin,
onAdd, onAdd,
onUpdate,
onTest, onTest,
onDelete, onDelete,
onToggle, onToggle,
}: ProviderSectionProps) { }: ProviderSectionProps) {
const Icon = provider.icon; const Icon = provider.icon;
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingCredentialId, setEditingCredentialId] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const canAdd = isAdmin && (provider.allowMultiple || credentials.length === 0); const canAdd = isAdmin && (provider.allowMultiple || credentials.length === 0);
const editingCredential = credentials.find((cred) => cred.id === editingCredentialId);
return ( return (
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5"> <section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush md:p-5">
@ -926,6 +983,7 @@ function ProviderSection({
variant="outline" variant="outline"
onClick={() => { onClick={() => {
setShowForm(true); setShowForm(true);
setEditingCredentialId(null);
setSaveError(null); setSaveError(null);
}} }}
> >
@ -937,7 +995,6 @@ function ProviderSection({
{showForm && ( {showForm && (
<CredentialForm <CredentialForm
providerId={provider.id}
allowMultiple={provider.allowMultiple} allowMultiple={provider.allowMultiple}
showBaseUrl={provider.showBaseUrl} showBaseUrl={provider.showBaseUrl}
showSessionKey={provider.showSessionKey} showSessionKey={provider.showSessionKey}
@ -971,13 +1028,52 @@ function ProviderSection({
{credentials.length > 0 && ( {credentials.length > 0 && (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{credentials.map((cred) => ( {credentials.map((cred) => (
<CredentialRow <div key={cred.id} className="space-y-2">
key={cred.id} <CredentialRow
cred={cred} cred={cred}
isAdmin={isAdmin} isAdmin={isAdmin}
onDelete={onDelete} onEdit={(target) => {
onToggle={onToggle} setShowForm(false);
/> setSaveError(null);
setEditingCredentialId(target.id);
}}
onDelete={onDelete}
onToggle={onToggle}
/>
{cred.active && editingCredential?.id === cred.id && (
<CredentialForm
mode="edit"
initialCredential={cred}
allowMultiple={provider.allowMultiple}
showBaseUrl={provider.showBaseUrl}
showSessionKey={provider.showSessionKey}
sessionKeyLabel={provider.sessionKeyLabel}
sessionKeyPlaceholder={provider.sessionKeyPlaceholder}
keyLabel={provider.keyLabel}
keyPlaceholder={provider.keyPlaceholder}
accountKeyDefault={provider.accountKeyDefault}
apiKeyHelp={provider.apiKeyHelp}
sessionKeyHelp={provider.sessionKeyHelp}
autoDetectedNote={provider.autoDetectedNote}
onSave={async (data) => {
setSaving(true);
setSaveError(null);
try {
await onUpdate(provider.id, cred, data);
setEditingCredentialId(null);
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Save failed.");
} finally {
setSaving(false);
}
}}
onTest={(data) => onTest(provider.id, data)}
onCancel={() => setEditingCredentialId(null)}
isSaving={saving}
error={saveError}
/>
)}
</div>
))} ))}
</div> </div>
)} )}
@ -1027,13 +1123,7 @@ export default function AIProvidersSettingsPage() {
const handleAdd = async ( const handleAdd = async (
providerId: ProviderId, providerId: ProviderId,
data: { data: CredentialFormData,
account_key: string;
display_name: string;
api_key: string;
session_key: string;
base_url: string;
},
) => { ) => {
const res = await createProviderCredentialApiV1ProviderCredentialsPost({ const res = await createProviderCredentialApiV1ProviderCredentialsPost({
provider: providerId, provider: providerId,
@ -1052,7 +1142,7 @@ export default function AIProvidersSettingsPage() {
const handleTest = async ( const handleTest = async (
providerId: ProviderId, providerId: ProviderId,
data: { account_key: string; api_key: string; base_url: string }, data: CredentialFormData,
): Promise<ProviderUsageLiveRead> => { ): Promise<ProviderUsageLiveRead> => {
const response = await customFetch<{ const response = await customFetch<{
data: ProviderUsageLiveRead; data: ProviderUsageLiveRead;
@ -1064,12 +1154,49 @@ export default function AIProvidersSettingsPage() {
provider: providerId, provider: providerId,
account_key: data.account_key || "test", account_key: data.account_key || "test",
api_key: data.api_key || undefined, api_key: data.api_key || undefined,
session_key: data.session_key || undefined,
base_url: data.base_url || undefined, base_url: data.base_url || undefined,
}), }),
}); });
return response.data; return response.data;
}; };
const handleUpdate = async (
providerId: ProviderId,
cred: ProviderCredentialRead,
data: CredentialFormData,
) => {
const provider = PROVIDERS.find((item) => item.id === providerId);
const displayName = data.display_name.trim() || cred.account_key;
const apiKey = data.api_key.trim();
const sessionKey = data.session_key.trim();
const baseUrl = data.base_url.trim();
const payload: ProviderCredentialUpdateWithSession = {};
if (displayName !== cred.display_name) {
payload.display_name = displayName;
}
if (apiKey) {
payload.api_key = apiKey;
}
if (sessionKey) {
payload.session_key = sessionKey;
}
if (provider?.showBaseUrl && baseUrl !== (cred.base_url ?? "")) {
payload.base_url = baseUrl;
}
const res = await updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch(
cred.id,
payload,
);
if (res.status === 200) {
setCredentials((prev) => prev.map((c) => (c.id === cred.id ? res.data : c)));
} else {
throw new Error("Failed to update credential.");
}
};
const handleToggle = async (cred: ProviderCredentialRead) => { const handleToggle = async (cred: ProviderCredentialRead) => {
const res = await updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch( const res = await updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch(
cred.id, cred.id,
@ -1129,6 +1256,7 @@ export default function AIProvidersSettingsPage() {
credentials={credentials.filter((c) => c.provider === provider.id)} credentials={credentials.filter((c) => c.provider === provider.id)}
isAdmin={isAdmin} isAdmin={isAdmin}
onAdd={handleAdd} onAdd={handleAdd}
onUpdate={handleUpdate}
onTest={handleTest} onTest={handleTest}
onDelete={setDeleteTarget} onDelete={setDeleteTarget}
onToggle={handleToggle} onToggle={handleToggle}