From 685368bb0a6ac6bde094a4aa2514eab3ccf3085e Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Mon, 6 Apr 2026 20:09:12 +0200 Subject: [PATCH 1/2] add support to using custom model ids --- ...3e4f5a6_add_upstream_model_id_to_models.py | 33 + routstr/algorithm.py | 8 + routstr/core/admin.py | 3 + routstr/core/db.py | 4 + routstr/payment/models.py | 11 +- routstr/upstream/base.py | 12 +- ui/components/add-provider-model-dialog.tsx | 58 +- ui/components/edit-model-form.tsx | 577 ---- ui/lib/api/services/admin.ts | 1 + uv.lock | 2864 ++++++++--------- 10 files changed, 1557 insertions(+), 2014 deletions(-) create mode 100644 migrations/versions/b1c2d3e4f5a6_add_upstream_model_id_to_models.py delete mode 100644 ui/components/edit-model-form.tsx diff --git a/migrations/versions/b1c2d3e4f5a6_add_upstream_model_id_to_models.py b/migrations/versions/b1c2d3e4f5a6_add_upstream_model_id_to_models.py new file mode 100644 index 00000000..a299b32f --- /dev/null +++ b/migrations/versions/b1c2d3e4f5a6_add_upstream_model_id_to_models.py @@ -0,0 +1,33 @@ +"""add forwarded_model_id to models + +Revision ID: b1c2d3e4f5a6 +Revises: a776ca70e5fe +Create Date: 2026-04-05 00:00:00.000000 +""" + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b1c2d3e4f5a6" +down_revision = "a776ca70e5fe" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "models", + sa.Column( + "forwarded_model_id", + sqlmodel.sql.sqltypes.AutoString(), + nullable=True, + ), + ) + # Backfill: set forwarded_model_id = id for all existing rows + op.execute("UPDATE models SET forwarded_model_id = id WHERE forwarded_model_id IS NULL") + + +def downgrade() -> None: + op.drop_column("models", "forwarded_model_id") diff --git a/routstr/algorithm.py b/routstr/algorithm.py index e3efa261..73ff75d1 100644 --- a/routstr/algorithm.py +++ b/routstr/algorithm.py @@ -217,6 +217,10 @@ def create_model_mappings( if prefixed_id not in aliases: aliases.append(prefixed_id) + # Register forwarded_model_id as a routable alias + if model_to_use.forwarded_model_id and model_to_use.forwarded_model_id not in aliases: + aliases.append(model_to_use.forwarded_model_id) + # Try to set each alias for alias in aliases: _add_candidate(alias, model_to_use, upstream) @@ -305,6 +309,10 @@ def create_model_mappings( if prefixed_id not in aliases: aliases.append(prefixed_id) + # Register forwarded_model_id as a routable alias + if model_to_use.forwarded_model_id and model_to_use.forwarded_model_id not in aliases: + aliases.append(model_to_use.forwarded_model_id) + for alias in aliases: _add_candidate(alias, model_to_use, upstream_for_override) seen_model_provider.add(dedupe_key) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 46589164..d87120e2 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -295,6 +295,7 @@ class ModelCreate(BaseModel): canonical_slug: str | None = None alias_ids: list[str] | None = None enabled: bool = True + forwarded_model_id: str | None = None @admin_router.post( @@ -339,6 +340,7 @@ async def upsert_provider_model( json.dumps(payload.alias_ids) if payload.alias_ids else None ) existing_row.enabled = payload.enabled + existing_row.forwarded_model_id = payload.forwarded_model_id or payload.id session.add(existing_row) await session.commit() @@ -371,6 +373,7 @@ async def upsert_provider_model( ), upstream_provider_id=provider_id, enabled=payload.enabled, + forwarded_model_id=payload.forwarded_model_id or payload.id, ) session.add(row) await session.commit() diff --git a/routstr/core/db.py b/routstr/core/db.py index 90258dbb..4508a567 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -105,6 +105,10 @@ class ModelRow(SQLModel, table=True): # type: ignore default=None, description="JSON array of model alias IDs" ) enabled: bool = Field(default=True, description="Whether this model is enabled") + forwarded_model_id: str | None = Field( + default=None, + description="Model ID to use when forwarding requests to upstream provider. Defaults to id if not set.", + ) upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models") diff --git a/routstr/payment/models.py b/routstr/payment/models.py index ae780153..95d7ad89 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -60,6 +60,7 @@ class Model(BaseModel): upstream_provider_id: int | str | None = None canonical_slug: str | None = None alias_ids: list[str] | None = None + forwarded_model_id: str | None = None def __hash__(self) -> int: return hash(self.id) @@ -177,6 +178,7 @@ def _row_to_model( upstream_provider_id=row.upstream_provider_id, canonical_slug=getattr(row, "canonical_slug", None), alias_ids=json.loads(row.alias_ids) if row.alias_ids else None, + forwarded_model_id=getattr(row, "forwarded_model_id", None) or row.id, ) if apply_provider_fee: @@ -329,6 +331,7 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model: upstream_provider_id=model.upstream_provider_id, canonical_slug=model.canonical_slug, alias_ids=model.alias_ids, + forwarded_model_id=model.forwarded_model_id, ) except Exception as e: logger.error( @@ -411,4 +414,10 @@ async def models(session: AsyncSession = Depends(get_session)) -> dict: from ..proxy import get_unique_models items = get_unique_models() - return {"data": items} + data = [] + for model in items: + m = model.dict() + if model.forwarded_model_id: + m["id"] = model.forwarded_model_id + data.append(m) + return {"data": data} diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 00d7563a..a39a54de 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -661,6 +661,9 @@ class BaseUpstreamProvider: }, ) + if requested_model: + response_json["model"] = requested_model + cost_data = await adjust_payment_for_tokens( key, response_json, session, deducted_max_cost ) @@ -981,6 +984,9 @@ class BaseUpstreamProvider: }, ) + if requested_model: + response_json["model"] = requested_model + cost_data = await adjust_payment_for_tokens( key, response_json, session, deducted_max_cost ) @@ -1303,7 +1309,7 @@ class BaseUpstreamProvider: path = self.normalize_request_path(path, model_obj) url = self.build_request_url(path, model_obj) - original_model_id = model_obj.id if model_obj else None + original_model_id = (model_obj.forwarded_model_id or model_obj.id) if model_obj else None transformed_body = self.prepare_request_body(request_body, model_obj) @@ -1589,7 +1595,7 @@ class BaseUpstreamProvider: path = self.normalize_request_path(path, model_obj) url = self.build_request_url(path, model_obj) - original_model_id = model_obj.id if model_obj else None + original_model_id = (model_obj.forwarded_model_id or model_obj.id) if model_obj else None transformed_body = self.prepare_responses_request_body(request_body, model_obj) @@ -3384,6 +3390,7 @@ class BaseUpstreamProvider: upstream_provider_id=model.upstream_provider_id, canonical_slug=model.canonical_slug, alias_ids=model.alias_ids, + forwarded_model_id=model.forwarded_model_id, ) ( @@ -3407,6 +3414,7 @@ class BaseUpstreamProvider: upstream_provider_id=model.upstream_provider_id, canonical_slug=model.canonical_slug, alias_ids=model.alias_ids, + forwarded_model_id=model.forwarded_model_id, ) async def fetch_models(self) -> list[Model]: diff --git a/ui/components/add-provider-model-dialog.tsx b/ui/components/add-provider-model-dialog.tsx index 009eb942..d6fb211f 100644 --- a/ui/components/add-provider-model-dialog.tsx +++ b/ui/components/add-provider-model-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -39,7 +39,7 @@ import { FormMessage, } from '@/components/ui/form'; import { Switch } from '@/components/ui/switch'; -import { Loader2, Plus } from 'lucide-react'; +import { Check, Copy, Loader2, Plus } from 'lucide-react'; import { toast } from 'sonner'; import { AdminService, type AdminModel } from '@/lib/api/services/admin'; @@ -64,6 +64,7 @@ const FormSchema = z.object({ instruct_type: z.string().default(''), canonical_slug: z.string().default(''), alias_ids_raw: z.string().default(''), + forwarded_model_id: z.string().default(''), upstream_provider_id: z.string().default(''), input_cost: z.coerce.number().min(0).default(0), output_cost: z.coerce.number().min(0).default(0), @@ -119,6 +120,7 @@ export function AddProviderModelDialog({ instruct_type: '', canonical_slug: '', alias_ids_raw: '', + forwarded_model_id: '', upstream_provider_id: '', input_cost: 0, output_cost: 0, @@ -180,6 +182,7 @@ export function AddProviderModelDialog({ : '', canonical_slug: initialData.canonical_slug || '', alias_ids_raw: listToString(initialData.alias_ids), + forwarded_model_id: initialData.forwarded_model_id || initialData.id, upstream_provider_id: typeof initialData.upstream_provider_id === 'string' ? initialData.upstream_provider_id @@ -223,6 +226,7 @@ export function AddProviderModelDialog({ instruct_type: '', canonical_slug: '', alias_ids_raw: '', + forwarded_model_id: '', upstream_provider_id: '', input_cost: 0, output_cost: 0, @@ -280,6 +284,7 @@ export function AddProviderModelDialog({ ); form.setValue('canonical_slug', model.canonical_slug || ''); form.setValue('alias_ids_raw', listToString(model.alias_ids)); + form.setValue('forwarded_model_id', model.forwarded_model_id || model.id); form.setValue( 'upstream_provider_id', typeof model.upstream_provider_id === 'string' @@ -385,6 +390,7 @@ export function AddProviderModelDialog({ canonical_slug: data.canonical_slug?.trim() || null, alias_ids: listFromString(data.alias_ids_raw || ''), enabled: data.enabled, + forwarded_model_id: data.forwarded_model_id?.trim() || data.id, }; if (isEdit) { @@ -520,6 +526,54 @@ export function AddProviderModelDialog({ )} /> + { + const [copied, setCopied] = useState(false); + const handleCopy = useCallback(() => { + const value = field.value || form.getValues('id'); + if (!value) return; + navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, [field.value]); + return ( + + Upstream Model ID + +
+ + +
+
+ + Model ID sent to the upstream provider. Defaults to the + model's own ID. + + +
+ ); + }} + /> + ; - -const roundToFiveDecimals = (value: number | undefined | null): number => { - if (value === undefined || value === null || isNaN(value)) { - return 0; - } - return Math.round(value * 100000) / 100000; -}; - -const toNumber = (value: unknown, fallback = 0): number => { - if (typeof value === 'number' && Number.isFinite(value)) { - return value; - } - if (typeof value === 'string') { - const parsed = Number(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return fallback; -}; - -const toStringArray = (value: unknown, fallback: string[]): string[] => { - if (!Array.isArray(value)) { - return fallback; - } - const filtered = value.filter( - (item): item is string => typeof item === 'string' - ); - return filtered.length > 0 ? filtered : fallback; -}; - -interface EditModelFormProps { - model: Model; - providerId?: number; - onModelUpdate?: () => void; - onCancel?: () => void; - isOpen: boolean; -} - -interface AdminModelData { - id: string; - name: string; - description?: string; - created: number; - context_length: number; - architecture: { - modality: string; - input_modalities: string[]; - output_modalities: string[]; - tokenizer: string; - instruct_type: string | null; - }; - pricing: { - prompt: number; - completion: number; - request: number; - image: number; - web_search: number; - internal_reasoning: number; - }; - per_request_limits: null | undefined; - top_provider: null | undefined; - upstream_provider_id: number; - enabled: boolean; -} - -const normalizeAdminModelData = ( - adminModel: AdminModel, - fallbackModel: Model, - providerId: number -): AdminModelData => { - const pricingRecord = - adminModel.pricing && typeof adminModel.pricing === 'object' - ? (adminModel.pricing as Record) - : {}; - - const architectureRecord = - adminModel.architecture && typeof adminModel.architecture === 'object' - ? (adminModel.architecture as Record) - : {}; - - return { - id: adminModel.id, - name: adminModel.name, - description: adminModel.description || '', - created: toNumber(adminModel.created, Math.floor(Date.now() / 1000)), - context_length: Math.max( - 0, - Math.trunc( - toNumber(adminModel.context_length, fallbackModel.contextLength || 4096) - ) - ), - architecture: { - modality: - typeof architectureRecord.modality === 'string' - ? architectureRecord.modality - : fallbackModel.modelType || 'text', - input_modalities: toStringArray(architectureRecord.input_modalities, [ - fallbackModel.modelType || 'text', - ]), - output_modalities: toStringArray(architectureRecord.output_modalities, [ - fallbackModel.modelType || 'text', - ]), - tokenizer: - typeof architectureRecord.tokenizer === 'string' - ? architectureRecord.tokenizer - : '', - instruct_type: - typeof architectureRecord.instruct_type === 'string' - ? architectureRecord.instruct_type - : null, - }, - pricing: { - prompt: roundToFiveDecimals( - toNumber(pricingRecord.prompt, fallbackModel.input_cost) - ), - completion: roundToFiveDecimals( - toNumber(pricingRecord.completion, fallbackModel.output_cost) - ), - request: toNumber(pricingRecord.request, 0), - image: toNumber(pricingRecord.image, 0), - web_search: toNumber(pricingRecord.web_search, 0), - internal_reasoning: toNumber(pricingRecord.internal_reasoning, 0), - }, - per_request_limits: - adminModel.per_request_limits === null || - adminModel.per_request_limits === undefined - ? adminModel.per_request_limits - : null, - top_provider: - adminModel.top_provider === null || adminModel.top_provider === undefined - ? adminModel.top_provider - : null, - upstream_provider_id: - typeof adminModel.upstream_provider_id === 'number' - ? adminModel.upstream_provider_id - : providerId, - enabled: adminModel.enabled !== false, - }; -}; - -export function EditModelForm({ - model, - providerId, - onModelUpdate, - onCancel, - isOpen, -}: EditModelFormProps) { - const [isSubmitting, setIsSubmitting] = useState(false); - const [adminModelData, setAdminModelData] = useState( - null - ); - const [isNewOverride, setIsNewOverride] = useState(false); - - const form = useForm({ - resolver: zodResolver(EditModelFormSchema), - defaultValues: { - name: model.name, - description: model.description || '', - context_length: model.contextLength || 4096, - prompt: roundToFiveDecimals(model.input_cost), - completion: roundToFiveDecimals(model.output_cost), - enabled: model.isEnabled !== false, - }, - }); - - const loadAdminModel = useCallback(async () => { - if (!providerId) { - console.error('loadAdminModel called without providerId'); - return; - } - - try { - const adminModel = await AdminService.getProviderModel( - providerId, - model.id - ); - - const normalizedAdminModel = normalizeAdminModelData( - adminModel, - model, - providerId - ); - setAdminModelData(normalizedAdminModel); - setIsNewOverride(false); - - form.reset({ - name: normalizedAdminModel.name, - description: normalizedAdminModel.description || '', - context_length: normalizedAdminModel.context_length, - prompt: normalizedAdminModel.pricing.prompt, - completion: normalizedAdminModel.pricing.completion, - enabled: normalizedAdminModel.enabled !== false, - }); - } catch { - setIsNewOverride(true); - setAdminModelData({ - id: model.full_name, - name: model.name, - description: model.description || '', - created: Math.floor(Date.now() / 1000), - context_length: model.contextLength || 4096, - architecture: { - modality: model.modelType || 'text', - input_modalities: [model.modelType || 'text'], - output_modalities: [model.modelType || 'text'], - tokenizer: '', - instruct_type: null, - }, - pricing: { - prompt: roundToFiveDecimals(model.input_cost), - completion: roundToFiveDecimals(model.output_cost), - request: 0, - image: 0, - web_search: 0, - internal_reasoning: 0, - }, - per_request_limits: null, - top_provider: null, - upstream_provider_id: providerId, - enabled: model.isEnabled !== false, - }); - - form.reset({ - name: model.name, - description: model.description || '', - context_length: model.contextLength || 4096, - prompt: roundToFiveDecimals(model.input_cost), - completion: roundToFiveDecimals(model.output_cost), - enabled: model.isEnabled !== false, - }); - } - }, [providerId, model, form]); - - useEffect(() => { - if (isOpen && providerId) { - loadAdminModel(); - } else if (isOpen && !providerId) { - console.error('EditModelForm opened without providerId', { - model, - providerId, - }); - toast.error('Missing provider information for this model'); - } - }, [isOpen, providerId, model, loadAdminModel]); - - const onSubmit = async (data: EditModelFormData) => { - if (!providerId) { - console.error('onSubmit called without providerId', { - model, - providerId, - }); - toast.error('Missing provider ID - cannot update model'); - return; - } - - if (!adminModelData) { - console.error('onSubmit called without adminModelData', { - model, - providerId, - adminModelData, - }); - toast.error('Model data not loaded - please try reopening the form'); - return; - } - - setIsSubmitting(true); - try { - const payload = { - id: adminModelData.id, - name: data.name, - description: data.description || '', - created: adminModelData.created || Math.floor(Date.now() / 1000), - context_length: data.context_length, - architecture: adminModelData.architecture || { - modality: 'text', - input_modalities: ['text'], - output_modalities: ['text'], - tokenizer: '', - instruct_type: null, - }, - pricing: { - prompt: roundToFiveDecimals(data.prompt), - completion: roundToFiveDecimals(data.completion), - request: 0, - image: 0, - web_search: 0, - internal_reasoning: 0, - }, - per_request_limits: adminModelData.per_request_limits, - top_provider: adminModelData.top_provider, - upstream_provider_id: providerId, - enabled: data.enabled, - }; - - if (isNewOverride) { - await AdminService.createProviderModel(providerId, payload); - toast.success('Model override created successfully!'); - } else { - await AdminService.updateProviderModel( - providerId, - adminModelData.id, - payload - ); - toast.success('Model updated successfully!'); - } - - onModelUpdate?.(); - onCancel?.(); - } catch (error) { - const action = isNewOverride ? 'create' : 'update'; - toast.error(`Failed to ${action} model. Please try again.`); - console.error(`Error ${action}ing model:`, error); - } finally { - setIsSubmitting(false); - } - }; - - const handleClose = () => { - if (!isSubmitting) { - onCancel?.(); - } - }; - - return ( - - - - - - {isNewOverride ? 'Create Model Override' : 'Edit Model Override'} - - - {isNewOverride - ? `Create an override for "${model.name}"` - : `Update the model override for "${model.name}"`} - - - -
- -
- ( - - Display Name * - - - - - Custom display name for the model - - - - )} - /> - - ( - - Context Length * - - { - const value = e.target.value; - field.onChange( - value === '' ? 0 : parseInt(value, 10) || 0 - ); - }} - onBlur={(e) => { - const value = parseInt(e.target.value, 10); - field.onChange( - Number.isNaN(value) ? 0 : Math.max(0, value) - ); - }} - className='w-full' - /> - - - Maximum context window size - - - - )} - /> -
- - ( - - Description - -