This commit is contained in:
9qeklajc
2025-10-21 23:21:03 +02:00
parent 4d28586d13
commit 45c8bfce1d
2 changed files with 389 additions and 116 deletions
+257 -28
View File
@@ -1,5 +1,6 @@
import json
import os
import secrets
from datetime import datetime, timezone
from pathlib import Path
@@ -25,14 +26,31 @@ logger = get_logger(__name__)
admin_router = APIRouter(prefix="/admin", include_in_schema=False)
admin_sessions: dict[str, int] = {}
ADMIN_SESSION_DURATION = 3600
def require_admin_api(request: Request) -> None:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1]
expiry = admin_sessions.get(token)
if expiry and expiry > int(datetime.now(timezone.utc).timestamp()):
return
admin_cookie = request.cookies.get("admin_password")
if not admin_cookie or admin_cookie != settings.admin_password:
raise HTTPException(status_code=403, detail="Unauthorized")
def is_admin_authenticated(request: Request) -> bool:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1]
expiry = admin_sessions.get(token)
if expiry and expiry > int(datetime.now(timezone.utc).timestamp()):
return True
admin_cookie = request.cookies.get("admin_password")
return bool(admin_cookie and admin_cookie == settings.admin_password)
@@ -187,6 +205,53 @@ async def initial_setup(request: Request, payload: SetupRequest) -> dict[str, ob
return {"ok": True}
class AdminLoginRequest(BaseModel):
password: str
@admin_router.post("/api/login")
async def admin_login(request: Request, payload: AdminLoginRequest) -> dict[str, object]:
try:
current = SettingsService.get()
admin_pw = current.admin_password
except Exception:
admin_pw = os.getenv("ADMIN_PASSWORD", "")
if not admin_pw:
raise HTTPException(status_code=500, detail="Admin password not configured")
if payload.password != admin_pw:
raise HTTPException(status_code=401, detail="Invalid password")
token = secrets.token_urlsafe(32)
expiry_timestamp = int(datetime.now(timezone.utc).timestamp()) + ADMIN_SESSION_DURATION
admin_sessions[token] = expiry_timestamp
expired_tokens = [
t for t, exp in admin_sessions.items()
if exp <= int(datetime.now(timezone.utc).timestamp())
]
for t in expired_tokens:
del admin_sessions[t]
return {
"ok": True,
"token": token,
"expires_in": ADMIN_SESSION_DURATION
}
@admin_router.post("/api/logout", dependencies=[Depends(require_admin_api)])
async def admin_logout(request: Request) -> dict[str, object]:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1]
if token in admin_sessions:
del admin_sessions[token]
return {"ok": True}
class WithdrawRequest(BaseModel):
amount: int
mint_url: str | None = None
@@ -1378,6 +1443,198 @@ def models_page() -> str:
)
class ModelCreate(BaseModel):
id: str
name: str
description: str
created: int
context_length: int
architecture: dict[str, object]
pricing: dict[str, object]
per_request_limits: dict[str, object] | None = None
top_provider: dict[str, object] | None = None
upstream_provider_id: int | None = None
enabled: bool = True
class ModelUpdate(BaseModel):
id: str
name: str
description: str
created: int
context_length: int
architecture: dict[str, object]
pricing: dict[str, object]
per_request_limits: dict[str, object] | None = None
top_provider: dict[str, object] | None = None
upstream_provider_id: int | None = None
enabled: bool = True
@admin_router.get("/api/models", dependencies=[Depends(require_admin_api)])
async def get_all_models() -> list[dict[str, object]]:
async with create_session() as session:
models = await list_models(session=session, include_disabled=True)
return [m.dict() for m in models]
@admin_router.get("/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)])
async def get_model(model_id: str) -> dict[str, object]:
async with create_session() as session:
result = await session.exec(
select(ModelRow).where(
ModelRow.id == model_id,
ModelRow.upstream_provider_id.is_(None)
)
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="Model not found")
return _row_to_model(row, apply_provider_fee=False).dict()
@admin_router.post("/api/models", dependencies=[Depends(require_admin_api)])
async def create_model(payload: ModelCreate) -> dict[str, object]:
async with create_session() as session:
exists = await session.get(ModelRow, (payload.id, None))
if exists:
raise HTTPException(
status_code=409, detail="Model with this ID already exists"
)
row = ModelRow(
id=payload.id,
name=payload.name,
description=payload.description,
created=int(payload.created),
context_length=int(payload.context_length),
architecture=json.dumps(payload.architecture),
pricing=json.dumps(payload.pricing),
sats_pricing=None,
per_request_limits=(
json.dumps(payload.per_request_limits)
if payload.per_request_limits is not None
else None
),
top_provider=(
json.dumps(payload.top_provider) if payload.top_provider else None
),
upstream_provider_id=None,
enabled=payload.enabled,
)
session.add(row)
await session.commit()
await session.refresh(row)
await refresh_model_maps()
return _row_to_model(row, apply_provider_fee=False).dict()
@admin_router.patch("/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)])
async def update_model(model_id: str, payload: ModelUpdate) -> dict[str, object]:
if payload.id != model_id:
raise HTTPException(status_code=400, detail="Path id does not match payload id")
async with create_session() as session:
row = await session.get(ModelRow, (model_id, None))
if not row:
raise HTTPException(status_code=404, detail="Model not found")
row.name = payload.name
row.description = payload.description
row.created = int(payload.created)
row.context_length = int(payload.context_length)
row.architecture = json.dumps(payload.architecture)
row.pricing = json.dumps(payload.pricing)
row.sats_pricing = None
row.per_request_limits = (
json.dumps(payload.per_request_limits)
if payload.per_request_limits is not None
else None
)
row.top_provider = (
json.dumps(payload.top_provider) if payload.top_provider else None
)
row.enabled = payload.enabled
session.add(row)
await session.commit()
await session.refresh(row)
await refresh_model_maps()
return _row_to_model(row, apply_provider_fee=False).dict()
@admin_router.delete("/api/models/{model_id:path}", dependencies=[Depends(require_admin_api)])
async def delete_model(model_id: str) -> dict[str, object]:
async with create_session() as session:
row = await session.get(ModelRow, (model_id, None))
if not row:
raise HTTPException(status_code=404, detail="Model not found")
await session.delete(row)
await session.commit()
await refresh_model_maps()
return {"ok": True, "deleted_id": model_id}
@admin_router.delete("/api/models", dependencies=[Depends(require_admin_api)])
async def delete_all_models() -> dict[str, object]:
async with create_session() as session:
result = await session.exec(
select(ModelRow).where(ModelRow.upstream_provider_id.is_(None))
)
rows = result.all()
for row in rows:
await session.delete(row)
await session.commit()
await refresh_model_maps()
return {"ok": True, "deleted": len(rows)}
class BatchModelsRequest(BaseModel):
models: list[ModelCreate]
@admin_router.post("/api/models/batch", dependencies=[Depends(require_admin_api)])
async def batch_add_models(payload: BatchModelsRequest) -> dict[str, object]:
async with create_session() as session:
created_models = []
for model_data in payload.models:
exists = await session.get(ModelRow, (model_data.id, None))
if exists:
continue
row = ModelRow(
id=model_data.id,
name=model_data.name,
description=model_data.description,
created=int(model_data.created),
context_length=int(model_data.context_length),
architecture=json.dumps(model_data.architecture),
pricing=json.dumps(model_data.pricing),
sats_pricing=None,
per_request_limits=(
json.dumps(model_data.per_request_limits)
if model_data.per_request_limits is not None
else None
),
top_provider=(
json.dumps(model_data.top_provider)
if model_data.top_provider
else None
),
upstream_provider_id=None,
enabled=model_data.enabled,
)
session.add(row)
created_models.append(model_data.id)
await session.commit()
await refresh_model_maps()
return {"ok": True, "created": len(created_models), "model_ids": created_models}
@admin_router.get("/models", response_class=HTMLResponse)
async def admin_models(request: Request) -> str:
if is_admin_authenticated(request):
@@ -2280,20 +2537,6 @@ async def admin_upstream_providers(request: Request) -> str:
return admin_auth()
class ModelCreate(BaseModel):
id: str
name: str
description: str
created: int
context_length: int
architecture: dict[str, object]
pricing: dict[str, object]
per_request_limits: dict[str, object] | None = None
top_provider: dict[str, object] | None = None
upstream_provider_id: int | None = None
enabled: bool = True
@admin_router.post(
"/api/upstream-providers/{provider_id}/models",
dependencies=[Depends(require_admin_api)],
@@ -2363,20 +2606,6 @@ async def get_provider_model(provider_id: int, model_id: str) -> dict[str, objec
).dict() # type: ignore
class ModelUpdate(BaseModel):
id: str
name: str
description: str
created: int
context_length: int
architecture: dict[str, object]
pricing: dict[str, object]
per_request_limits: dict[str, object] | None = None
top_provider: dict[str, object] | None = None
upstream_provider_id: int | None = None
enabled: bool = True
@admin_router.patch(
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
dependencies=[Depends(require_admin_api)],
+132 -88
View File
@@ -8,6 +8,7 @@ export const UpstreamProviderSchema = z.object({
api_key: z.string().optional(),
api_version: z.string().nullable().optional(),
enabled: z.boolean(),
provider_fee: z.number().optional(),
});
export const CreateUpstreamProviderSchema = z.object({
@@ -16,6 +17,7 @@ export const CreateUpstreamProviderSchema = z.object({
api_key: z.string(),
api_version: z.string().nullable().optional(),
enabled: z.boolean().default(true),
provider_fee: z.number().optional(),
});
export const UpdateUpstreamProviderSchema = z.object({
@@ -24,6 +26,7 @@ export const UpdateUpstreamProviderSchema = z.object({
api_key: z.string().optional(),
api_version: z.string().nullable().optional(),
enabled: z.boolean().optional(),
provider_fee: z.number().optional(),
});
export const AdminModelPricingSchema = z.object({
@@ -269,31 +272,16 @@ export class AdminService {
};
}
static async getAdminModels(): Promise<AdminModel[]> {
const models = await apiClient.get<AdminModel[]>('/admin/api/models');
return models.map((m) => ({
...m,
pricing: this.convertPricingToPerMillionTokens(m.pricing),
}));
}
static async getAdminModel(modelId: string): Promise<AdminModel> {
const model = await apiClient.get<AdminModel>(
`/admin/api/models/${encodeURIComponent(modelId)}`
);
return {
...model,
pricing: this.convertPricingToPerMillionTokens(model.pricing),
};
}
static async createAdminModel(data: AdminModel): Promise<AdminModel> {
static async createProviderModel(
providerId: number,
data: AdminModel
): Promise<AdminModel> {
const payload = {
...data,
pricing: this.convertPricingToPerToken(data.pricing),
};
const model = await apiClient.post<AdminModel>(
'/admin/api/models',
`/admin/api/upstream-providers/${providerId}/models`,
payload
);
return {
@@ -302,7 +290,21 @@ export class AdminService {
};
}
static async updateAdminModel(
static async getProviderModel(
providerId: number,
modelId: string
): Promise<AdminModel> {
const model = await apiClient.get<AdminModel>(
`/admin/api/upstream-providers/${providerId}/models/${encodeURIComponent(modelId)}`
);
return {
...model,
pricing: this.convertPricingToPerMillionTokens(model.pricing),
};
}
static async updateProviderModel(
providerId: number,
modelId: string,
data: AdminModel
): Promise<AdminModel> {
@@ -311,7 +313,7 @@ export class AdminService {
pricing: this.convertPricingToPerToken(data.pricing),
};
const model = await apiClient.patch<AdminModel>(
`/admin/api/models/${encodeURIComponent(modelId)}`,
`/admin/api/upstream-providers/${providerId}/models/${encodeURIComponent(modelId)}`,
payload
);
return {
@@ -320,35 +322,20 @@ export class AdminService {
};
}
static async deleteAdminModel(
static async deleteProviderModel(
providerId: number,
modelId: string
): Promise<{ ok: boolean; deleted_id: string }> {
return await apiClient.delete<{ ok: boolean; deleted_id: string }>(
`/admin/api/models/${encodeURIComponent(modelId)}`
`/admin/api/upstream-providers/${providerId}/models/${encodeURIComponent(modelId)}`
);
}
static async deleteAllAdminModels(): Promise<{
ok: boolean;
deleted: string;
}> {
return await apiClient.delete<{ ok: boolean; deleted: string }>(
'/admin/api/models'
);
}
static async batchCreateModels(
models: AdminModel[]
): Promise<{ created: number; skipped: number }> {
const payload = {
models: models.map((m) => ({
...m,
pricing: this.convertPricingToPerToken(m.pricing),
})),
};
return await apiClient.post<{ created: number; skipped: number }>(
'/admin/api/models/batch',
payload
static async deleteAllProviderModels(
providerId: number
): Promise<{ ok: boolean; deleted: number }> {
return await apiClient.delete<{ ok: boolean; deleted: number }>(
`/admin/api/upstream-providers/${providerId}/models`
);
}
@@ -450,6 +437,10 @@ export class AdminService {
static async createModel(
data: Record<string, unknown>
): Promise<AdminModelAsModel> {
if (!data.provider_id) {
throw new Error('provider_id is required to create a model');
}
const pricing = {
prompt: (data.input_cost as number) / 1000000,
completion: (data.output_cost as number) / 1000000,
@@ -475,13 +466,12 @@ export class AdminService {
pricing,
per_request_limits: null,
top_provider: null,
upstream_provider_id: data.provider_id
? parseInt(data.provider_id as string)
: null,
upstream_provider_id: parseInt(data.provider_id as string),
enabled: data.isEnabled !== false,
};
const created = await this.createAdminModel(adminModel);
const providerId = parseInt(data.provider_id as string);
const created = await this.createProviderModel(providerId, adminModel);
return this.transformAdminModelToModel(created, data.provider as string);
}
@@ -489,51 +479,75 @@ export class AdminService {
modelId: string,
data: Record<string, unknown>
): Promise<AdminModelAsModel> {
if (!data.provider_id) {
throw new Error('provider_id is required to update a model');
}
const providerId = parseInt(data.provider_id as string);
const existingModel = await this.getProviderModel(providerId, modelId);
const pricing = {
prompt: (data.input_cost as number) / 1000000,
completion: (data.output_cost as number) / 1000000,
request: (data.min_cost_per_request as number) || 0,
image: 0,
web_search: 0,
internal_reasoning: 0,
};
const payload: Record<string, unknown> = {
const payload: AdminModel = {
...existingModel,
id: modelId,
pricing,
};
if (data.name) payload.name = data.name;
if (data.description) payload.description = data.description;
if (data.name) payload.name = data.name as string;
if (data.description) payload.description = data.description as string;
if (data.contextLength !== undefined)
payload.context_length = data.contextLength;
if (data.provider_id)
payload.upstream_provider_id = parseInt(data.provider_id as string);
if (data.isEnabled !== undefined) payload.enabled = data.isEnabled;
const updated = await apiClient.post<AdminModel>(
'/admin/api/models/update',
payload
);
payload.context_length = data.contextLength as number;
if (data.isEnabled !== undefined) payload.enabled = data.isEnabled as boolean;
const updated = await this.updateProviderModel(providerId, modelId, payload);
return this.transformAdminModelToModel(updated, data.provider as string);
}
static async deleteModel(modelId: string): Promise<{ message: string }> {
await this.deleteAdminModel(modelId);
static async deleteModel(
modelId: string,
providerId?: string
): Promise<{ message: string }> {
if (!providerId) {
throw new Error('provider_id is required to delete a model');
}
await this.deleteProviderModel(parseInt(providerId), modelId);
return { message: 'Model deleted successfully' };
}
static async softDeleteModel(modelId: string): Promise<{ message: string }> {
await apiClient.post('/admin/api/models/update', {
id: modelId,
static async softDeleteModel(
modelId: string,
providerId?: string
): Promise<{ message: string }> {
if (!providerId) {
throw new Error('provider_id is required to soft delete a model');
}
const providerIdNum = parseInt(providerId);
const model = await this.getProviderModel(providerIdNum, modelId);
await this.updateProviderModel(providerIdNum, modelId, {
...model,
enabled: false,
});
return { message: 'Model soft deleted successfully' };
}
static async deleteModels(
modelIds: string[]
modelIds: string[],
providerId?: string
): Promise<{ deleted_count: number; message: string }> {
if (!providerId) {
throw new Error('provider_id is required to delete models');
}
const providerIdNum = parseInt(providerId);
for (const id of modelIds) {
await this.deleteAdminModel(id);
await this.deleteProviderModel(providerIdNum, id);
}
return {
deleted_count: modelIds.length,
@@ -542,11 +556,19 @@ export class AdminService {
}
static async softDeleteModels(
modelIds: string[]
modelIds: string[],
providerId?: string
): Promise<{ deleted_count: number; message: string }> {
if (!providerId) {
throw new Error('provider_id is required to soft delete models');
}
const providerIdNum = parseInt(providerId);
for (const id of modelIds) {
const model = await this.getAdminModel(id);
await this.updateAdminModel(id, { ...model, enabled: false });
const model = await this.getProviderModel(providerIdNum, id);
await this.updateProviderModel(providerIdNum, id, {
...model,
enabled: false,
});
}
return {
deleted_count: modelIds.length,
@@ -556,22 +578,28 @@ export class AdminService {
static async bulkUpdateModels(
modelIds: string[],
updates: { api_key?: string; url?: string }
updates: { api_key?: string; url?: string },
providerId?: string
): Promise<{
updated_count: number;
total_count: number;
message: string;
errors: string[];
}> {
if (!providerId) {
throw new Error('provider_id is required for bulk updates');
}
const errors: string[] = [];
let updated_count = 0;
const providerIdNum = parseInt(providerId);
console.log('Bulk update not implemented, ignoring updates:', updates);
for (const id of modelIds) {
try {
const model = await this.getAdminModel(id);
await this.updateAdminModel(id, model);
const model = await this.getProviderModel(providerIdNum, id);
await this.updateProviderModel(providerIdNum, id, model);
updated_count++;
} catch (error: unknown) {
const errorMessage =
@@ -592,10 +620,16 @@ export class AdminService {
deleted_count: number;
message: string;
}> {
const models = await this.getAdminModels();
await this.deleteAllAdminModels();
const providers = await this.getUpstreamProviders();
let totalDeleted = 0;
for (const provider of providers) {
const result = await this.deleteAllProviderModels(provider.id);
totalDeleted += result.deleted;
}
return {
deleted_count: models.length,
deleted_count: totalDeleted,
message: 'All models deleted successfully',
};
}
@@ -603,25 +637,25 @@ export class AdminService {
static async deleteModelsByProvider(
providerId: string
): Promise<{ deleted_count: number; message: string }> {
const models = await this.getAdminModels();
const providerModels = models.filter(
(m) => m.upstream_provider_id === parseInt(providerId)
);
for (const model of providerModels) {
await this.deleteAdminModel(model.id);
}
const result = await this.deleteAllProviderModels(parseInt(providerId));
return {
deleted_count: providerModels.length,
deleted_count: result.deleted,
message: 'Provider models deleted successfully',
};
}
static async restoreModels(
modelIds: string[]
modelIds: string[],
providerId?: string
): Promise<{ restored_count: number; message: string }> {
if (!providerId) {
throw new Error('provider_id is required to restore models');
}
const providerIdNum = parseInt(providerId);
for (const id of modelIds) {
await apiClient.post('/admin/api/models/update', {
id,
const model = await this.getProviderModel(providerIdNum, id);
await this.updateProviderModel(providerIdNum, id, {
...model,
enabled: true,
});
}
@@ -644,4 +678,14 @@ export class AdminService {
);
return { message: 'Refresh not implemented for admin API' };
}
static async getOpenRouterPresets(): Promise<AdminModel[]> {
const presets = await apiClient.get<AdminModel[]>(
'/admin/api/openrouter-presets'
);
return presets.map((m) => ({
...m,
pricing: this.convertPricingToPerMillionTokens(m.pricing),
}));
}
}