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
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue