feat(ui): edit ai providers
This commit is contained in:
parent
ac29c79ff2
commit
f48cf45cce
|
|
@ -952,9 +952,10 @@ async def fetch_provider_usage(
|
|||
return cached
|
||||
|
||||
if provider == "anthropic":
|
||||
# Prefer the local Claude Code OAuth token (sk-ant-oat01-...) because it's
|
||||
# the correct credential type for the oauth/usage endpoint. Fall back to
|
||||
# the manually-stored session_key only when no local OAuth token exists.
|
||||
# Auto-detected local OAuth token (sk-ant-oat01-) takes precedence — it is
|
||||
# the correct credential type for the oauth/usage endpoint and is always
|
||||
# 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()
|
||||
effective_session_key = local_oauth or session_key
|
||||
|
||||
|
|
@ -994,9 +995,11 @@ async def fetch_provider_usage(
|
|||
provider=provider, account_key=account_key,
|
||||
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()
|
||||
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:
|
||||
sub_windows, plan_label = await _fetch_codex_subscription(effective_codex_key)
|
||||
if sub_windows:
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -136,6 +136,68 @@ async def test_usage_response_includes_rate_limit_header_names(monkeypatch: pyte
|
|||
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
|
||||
async def test_test_endpoint_returns_live_result(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
engine = await _make_engine()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
HelpCircle,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
|
|
@ -190,8 +191,25 @@ function HelpToggle({
|
|||
// 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 {
|
||||
providerId: ProviderId;
|
||||
mode?: "create" | "edit";
|
||||
initialCredential?: ProviderCredentialRead;
|
||||
allowMultiple: boolean;
|
||||
showBaseUrl: boolean;
|
||||
showSessionKey: boolean;
|
||||
|
|
@ -203,24 +221,16 @@ interface CredentialFormProps {
|
|||
apiKeyHelp?: readonly string[];
|
||||
sessionKeyHelp?: readonly string[];
|
||||
autoDetectedNote?: string;
|
||||
onSave: (data: {
|
||||
account_key: string;
|
||||
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>;
|
||||
onSave: (data: CredentialFormData) => Promise<void>;
|
||||
onTest: (data: CredentialFormData) => Promise<ProviderUsageLiveRead>;
|
||||
onCancel: () => void;
|
||||
isSaving: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function CredentialForm({
|
||||
mode = "create",
|
||||
initialCredential,
|
||||
allowMultiple,
|
||||
showBaseUrl,
|
||||
showSessionKey,
|
||||
|
|
@ -238,17 +248,32 @@ function CredentialForm({
|
|||
isSaving,
|
||||
error,
|
||||
}: CredentialFormProps) {
|
||||
const [accountKey, setAccountKey] = useState(accountKeyDefault);
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const isEditMode = mode === "edit";
|
||||
const [accountKey, setAccountKey] = useState(
|
||||
initialCredential?.account_key ?? accountKeyDefault,
|
||||
);
|
||||
const [displayName, setDisplayName] = useState(
|
||||
initialCredential?.display_name ?? "",
|
||||
);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [sessionKey, setSessionKey] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState(initialCredential?.base_url ?? "");
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<ProviderUsageLiveRead | null>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
const [showApiHelp, setShowApiHelp] = 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 () => {
|
||||
setIsTesting(true);
|
||||
|
|
@ -257,7 +282,9 @@ function CredentialForm({
|
|||
try {
|
||||
const result = await onTest({
|
||||
account_key: accountKey.trim() || accountKeyDefault || "test",
|
||||
display_name: displayName.trim(),
|
||||
api_key: apiKey.trim(),
|
||||
session_key: sessionKey.trim(),
|
||||
base_url: baseUrl.trim(),
|
||||
});
|
||||
setTestResult(result);
|
||||
|
|
@ -280,16 +307,18 @@ function CredentialForm({
|
|||
value={accountKey}
|
||||
onChange={(e) => setAccountKey(e.target.value)}
|
||||
placeholder="e.g. work, personal, gpu-box"
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || isEditMode}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted">
|
||||
Display name (optional)
|
||||
{isEditMode ? "Display name" : "Display name (optional)"}
|
||||
</label>
|
||||
<Input
|
||||
value={displayName}
|
||||
|
|
@ -342,6 +371,9 @@ function CredentialForm({
|
|||
disabled={isSaving}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{existingApiKeyLabel && (
|
||||
<p className="mt-1 text-[11px] text-muted">{existingApiKeyLabel}</p>
|
||||
)}
|
||||
{showApiHelp && !showBaseUrl && apiKeyHelp?.length ? (
|
||||
<HelpSection steps={apiKeyHelp} />
|
||||
) : null}
|
||||
|
|
@ -372,6 +404,11 @@ function CredentialForm({
|
|||
disabled={isSaving}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{existingSessionKeyLabel && (
|
||||
<p className="mt-1 text-[11px] text-muted">
|
||||
{existingSessionKeyLabel}
|
||||
</p>
|
||||
)}
|
||||
{showSessionHelp && sessionKeyHelp?.length ? (
|
||||
<HelpSection steps={sessionKeyHelp} />
|
||||
) : null}
|
||||
|
|
@ -401,8 +438,8 @@ function CredentialForm({
|
|||
onSave({
|
||||
account_key: accountKey.trim(),
|
||||
display_name: displayName.trim(),
|
||||
api_key: apiKey,
|
||||
session_key: sessionKey,
|
||||
api_key: apiKey.trim(),
|
||||
session_key: sessionKey.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 (
|
||||
<div className="mt-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-2.5">
|
||||
{usage.error && (
|
||||
|
|
@ -709,14 +750,14 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{/* API rate-limit diagnostic bars */}
|
||||
{usageBars.length > 0 ? (
|
||||
{/* API rate-limit diagnostic bars — only shown when actively consuming the limit */}
|
||||
{activeBars.length > 0 ? (
|
||||
<>
|
||||
<div className="text-[11px] text-muted">
|
||||
{sourceLabel[usage.source] ?? usage.source} · {usage.confidence} confidence
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{usageBars.map((bar) => (
|
||||
{activeBars.map((bar) => (
|
||||
<UsageWindowBar
|
||||
key={bar.label}
|
||||
label={bar.label}
|
||||
|
|
@ -778,6 +819,7 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
|
|||
interface CredentialRowProps {
|
||||
cred: ProviderCredentialRead;
|
||||
isAdmin: boolean;
|
||||
onEdit: (cred: ProviderCredentialRead) => void;
|
||||
onDelete: (cred: ProviderCredentialRead) => void;
|
||||
onToggle: (cred: ProviderCredentialRead) => Promise<void>;
|
||||
showUsage?: boolean;
|
||||
|
|
@ -786,6 +828,7 @@ interface CredentialRowProps {
|
|||
function CredentialRow({
|
||||
cred,
|
||||
isAdmin,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
showUsage = true,
|
||||
|
|
@ -832,6 +875,18 @@ function CredentialRow({
|
|||
</div>
|
||||
{isAdmin && (
|
||||
<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
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
|
|
@ -876,17 +931,16 @@ interface ProviderSectionProps {
|
|||
isAdmin: boolean;
|
||||
onAdd: (
|
||||
providerId: ProviderId,
|
||||
data: {
|
||||
account_key: string;
|
||||
display_name: string;
|
||||
api_key: string;
|
||||
session_key: string;
|
||||
base_url: string;
|
||||
},
|
||||
data: CredentialFormData,
|
||||
) => Promise<void>;
|
||||
onUpdate: (
|
||||
providerId: ProviderId,
|
||||
cred: ProviderCredentialRead,
|
||||
data: CredentialFormData,
|
||||
) => Promise<void>;
|
||||
onTest: (
|
||||
providerId: ProviderId,
|
||||
data: { account_key: string; api_key: string; base_url: string },
|
||||
data: CredentialFormData,
|
||||
) => Promise<ProviderUsageLiveRead>;
|
||||
onDelete: (cred: ProviderCredentialRead) => void;
|
||||
onToggle: (cred: ProviderCredentialRead) => Promise<void>;
|
||||
|
|
@ -897,16 +951,19 @@ function ProviderSection({
|
|||
credentials,
|
||||
isAdmin,
|
||||
onAdd,
|
||||
onUpdate,
|
||||
onTest,
|
||||
onDelete,
|
||||
onToggle,
|
||||
}: ProviderSectionProps) {
|
||||
const Icon = provider.icon;
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingCredentialId, setEditingCredentialId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const canAdd = isAdmin && (provider.allowMultiple || credentials.length === 0);
|
||||
const editingCredential = credentials.find((cred) => cred.id === editingCredentialId);
|
||||
|
||||
return (
|
||||
<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"
|
||||
onClick={() => {
|
||||
setShowForm(true);
|
||||
setEditingCredentialId(null);
|
||||
setSaveError(null);
|
||||
}}
|
||||
>
|
||||
|
|
@ -937,7 +995,6 @@ function ProviderSection({
|
|||
|
||||
{showForm && (
|
||||
<CredentialForm
|
||||
providerId={provider.id}
|
||||
allowMultiple={provider.allowMultiple}
|
||||
showBaseUrl={provider.showBaseUrl}
|
||||
showSessionKey={provider.showSessionKey}
|
||||
|
|
@ -971,13 +1028,52 @@ function ProviderSection({
|
|||
{credentials.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{credentials.map((cred) => (
|
||||
<div key={cred.id} className="space-y-2">
|
||||
<CredentialRow
|
||||
key={cred.id}
|
||||
cred={cred}
|
||||
isAdmin={isAdmin}
|
||||
onEdit={(target) => {
|
||||
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>
|
||||
)}
|
||||
|
|
@ -1027,13 +1123,7 @@ export default function AIProvidersSettingsPage() {
|
|||
|
||||
const handleAdd = async (
|
||||
providerId: ProviderId,
|
||||
data: {
|
||||
account_key: string;
|
||||
display_name: string;
|
||||
api_key: string;
|
||||
session_key: string;
|
||||
base_url: string;
|
||||
},
|
||||
data: CredentialFormData,
|
||||
) => {
|
||||
const res = await createProviderCredentialApiV1ProviderCredentialsPost({
|
||||
provider: providerId,
|
||||
|
|
@ -1052,7 +1142,7 @@ export default function AIProvidersSettingsPage() {
|
|||
|
||||
const handleTest = async (
|
||||
providerId: ProviderId,
|
||||
data: { account_key: string; api_key: string; base_url: string },
|
||||
data: CredentialFormData,
|
||||
): Promise<ProviderUsageLiveRead> => {
|
||||
const response = await customFetch<{
|
||||
data: ProviderUsageLiveRead;
|
||||
|
|
@ -1064,12 +1154,49 @@ export default function AIProvidersSettingsPage() {
|
|||
provider: providerId,
|
||||
account_key: data.account_key || "test",
|
||||
api_key: data.api_key || undefined,
|
||||
session_key: data.session_key || undefined,
|
||||
base_url: data.base_url || undefined,
|
||||
}),
|
||||
});
|
||||
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 res = await updateProviderCredentialApiV1ProviderCredentialsCredentialIdPatch(
|
||||
cred.id,
|
||||
|
|
@ -1129,6 +1256,7 @@ export default function AIProvidersSettingsPage() {
|
|||
credentials={credentials.filter((c) => c.provider === provider.id)}
|
||||
isAdmin={isAdmin}
|
||||
onAdd={handleAdd}
|
||||
onUpdate={handleUpdate}
|
||||
onTest={handleTest}
|
||||
onDelete={setDeleteTarget}
|
||||
onToggle={handleToggle}
|
||||
|
|
|
|||
Loading…
Reference in New Issue