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
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:

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()
@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()

View File

@ -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}