From 45c8bfce1d6be5fd7b9c4fef446ea31b4e6664ac Mon Sep 17 00:00:00 2001 From: 9qeklajc <9qeklajc> Date: Tue, 21 Oct 2025 23:21:03 +0200 Subject: [PATCH] merge --- routstr/core/admin.py | 285 +++++++++++++++++++++++++++++++---- ui/lib/api/services/admin.ts | 220 ++++++++++++++++----------- 2 files changed, 389 insertions(+), 116 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 288b1c8e..bb11be3b 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -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)], diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts index 592953fc..b0582dbc 100644 --- a/ui/lib/api/services/admin.ts +++ b/ui/lib/api/services/admin.ts @@ -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 { - const models = await apiClient.get('/admin/api/models'); - return models.map((m) => ({ - ...m, - pricing: this.convertPricingToPerMillionTokens(m.pricing), - })); - } - - static async getAdminModel(modelId: string): Promise { - const model = await apiClient.get( - `/admin/api/models/${encodeURIComponent(modelId)}` - ); - return { - ...model, - pricing: this.convertPricingToPerMillionTokens(model.pricing), - }; - } - - static async createAdminModel(data: AdminModel): Promise { + static async createProviderModel( + providerId: number, + data: AdminModel + ): Promise { const payload = { ...data, pricing: this.convertPricingToPerToken(data.pricing), }; const model = await apiClient.post( - '/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 { + const model = await apiClient.get( + `/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 { @@ -311,7 +313,7 @@ export class AdminService { pricing: this.convertPricingToPerToken(data.pricing), }; const model = await apiClient.patch( - `/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 ): Promise { + 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 ): Promise { + 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 = { + 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( - '/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 { + const presets = await apiClient.get( + '/admin/api/openrouter-presets' + ); + return presets.map((m) => ({ + ...m, + pricing: this.convertPricingToPerMillionTokens(m.pricing), + })); + } }