Compare commits

..
8 Commits
Author SHA1 Message Date
9qeklajc 5b0829090e fmt 2025-12-22 22:01:28 +01:00
9qeklajc 4ed86b0091 update model mapping to the ui 2025-12-22 21:40:06 +01:00
9qeklajc b327c0dee1 clean up 2025-12-22 21:12:27 +01:00
9qeklajc 470c32aebf add static model mapping 2025-12-22 21:10:45 +01:00
9qeklajc b01c7b2e56 Merge branch 'v0.2.1' into embeddings 2025-12-22 21:04:28 +01:00
9qeklajc 8df0c17bc3 embedding integration 2025-12-03 22:20:31 +01:00
9qeklajc 7bc9ee0653 Merge branch 'gemini-upstream' into embeddings 2025-12-03 22:01:39 +01:00
9qeklajc 355f8601c1 embedding integration 2025-12-03 21:58:31 +01:00
22 changed files with 510 additions and 332 deletions
+37
View File
@@ -0,0 +1,37 @@
import os
import openai
client = openai.OpenAI(
api_key=os.environ["CASHU_TOKEN"],
base_url=os.environ.get("ROUTSTR_API_URL", "https://api.routstr.com/v1"),
# base_url="http://roustrjfsdgfiueghsklchg.onion/v1",
# client=httpx.AsyncClient(
# proxies={"http": "socks5://localhost:9050"},
# ), # to use onion proxy (tor)
)
history: list = []
def chat() -> None:
while True:
user_msg = {"role": "user", "content": input("\nYou: ")}
history.append(user_msg)
ai_msg = {"role": "assistant", "content": ""}
for chunk in client.chat.completions.create(
model=os.environ.get("MODEL", "openai/gpt-4o-mini"),
messages=history,
stream=True,
):
if len(chunk.choices) > 0:
content = chunk.choices[0].delta.content
if content is not None:
ai_msg["content"] += content
print(content, end="", flush=True)
print()
history.append(ai_msg)
if __name__ == "__main__":
chat()
-11
View File
@@ -1,11 +0,0 @@
import os
import httpx
# Use your Cashu token or API key as the Bearer token,
# cashu token is hashed on the server and acts as an Temporary API key
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
resp = httpx.get(f"{base_url}/balance/info", headers=headers)
print(resp.json())
-15
View File
@@ -1,15 +0,0 @@
import os
import httpx
# Send a Cashu token to the /create endpoint to get a persistent API key
token = os.environ.get("TOKEN")
if not token:
print("Please set TOKEN environment variable with a Cashu token")
exit(1)
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
resp = httpx.get(f"{base_url}/balance/create", params={"initial_balance_token": token})
print(resp.json())
-12
View File
@@ -1,12 +0,0 @@
import os
import httpx
# Use your Cashu token or API key as the Bearer token
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
resp = httpx.post(f"{base_url}/balance/refund", headers=headers)
print("Refund successful!")
print(resp.json())
-16
View File
@@ -1,16 +0,0 @@
import os
import httpx
# Use your Cashu token or API key as the Bearer token
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
# The Cashu token to top up with
cashu_token = input("Enter Cashu token to top up: ")
resp = httpx.post(
f"{base_url}/balance/topup", headers=headers, json={"cashu_token": cashu_token}
)
print(resp.json())
-15
View File
@@ -1,15 +0,0 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
response = client.chat.completions.create(
model=os.environ.get("MODEL", "gpt-5-nano"),
messages=[{"role": "user", "content": "Hello!"}],
)
print(response.choices[0].message.content)
-19
View File
@@ -1,19 +0,0 @@
import os
import httpx
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN", ""),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
for model in client.models.list():
print(model.id)
# OR
models = httpx.get(
f"{client.base_url}/v1/models",
headers={"Authorization": f"Bearer {client.api_key}"},
).json()
-31
View File
@@ -1,31 +0,0 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
conversation = [] # type: ignore
# First turn
response1 = client.responses.create( # type: ignore
model="o4-mini",
input="Hi, my name is Alice.",
conversation=conversation,
)
print("Response 1:", response1.output)
# Note: The 'conversation' parameter might need to be constructed differently
# depending on exact SDK/API spec. Typically, you pass back the previous turn's data.
# Assuming the SDK manages or returns a conversation object/ID:
# conversation.append(response1)
# Second turn - demonstrating intent, actual implementation depends on strict API spec
# response2 = client.responses.create(
# model="openai/gpt-4o-mini",
# input="What is my name?",
# conversation=conversation,
# )
# print("Response 2:", response2.output)
-17
View File
@@ -1,17 +0,0 @@
import os
from openai import OpenAI
# The OpenAI SDK handles the 'responses' endpoint if it's updated to the latest version
# and the base_url points to a compatible proxy like Routstr.
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
response = client.responses.create(
model="gpt-5-mini",
input="Tell me a three sentence bedtime story about a unicorn.",
)
print(response.output)
-20
View File
@@ -1,20 +0,0 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
stream = client.responses.create(
model="claude-4.5-sonnet",
input="Write a short poem about rust.",
stream=True,
)
for event in stream:
# Note: Depending on the SDK version and response structure,
# you might access event.output_delta or similar fields
print(event, end="", flush=True)
print()
-16
View File
@@ -1,16 +0,0 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
response = client.responses.create(
model="gpt-5-mini",
input="What is the latest news about AI?",
tools=[{"type": "web_search"}], # type: ignore
)
print(response.output)
-28
View File
@@ -1,28 +0,0 @@
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
)
messages = []
while True:
messages.append({"role": "user", "content": input("\nYou: ")})
stream = client.chat.completions.create(
model=os.environ.get("MODEL", "gpt-5.1-mini"),
messages=messages, # type: ignore
stream=True,
)
print("AI: ", end="")
response_content = ""
for chunk in stream:
if content := chunk.choices[0].delta.content: # type: ignore
print(content, end="", flush=True)
response_content += content
print()
messages.append({"role": "assistant", "content": response_content})
-20
View File
@@ -1,20 +0,0 @@
import os
import httpx
from openai import OpenAI
# Requires `pip install "httpx[socks]"` and a running Tor proxy on port 9050
client = OpenAI(
api_key=os.environ.get("TOKEN"),
base_url=os.environ.get("ONION_URL", "http://roustrjfsdgfiueghsklchg.onion/v1"),
http_client=httpx.Client(proxies="socks5://localhost:9050"),
)
print(
client.chat.completions.create(
model="openai/gpt-4o-mini",
messages=[{"role": "user", "content": "Hello from Tor!"}],
)
.choices[0]
.message.content
)
-1
View File
@@ -73,7 +73,6 @@ packages = ["routstr"]
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = ["E501"]
exclude = ["examples"]
[tool.mypy]
python_version = "3.11"
+121 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlmodel import select
from ..payment.models import _row_to_model, list_models
@@ -3165,3 +3165,123 @@ async def get_log_dates_api(request: Request) -> dict[str, object]:
continue
return {"dates": dates}
class ModelMappingRequest(BaseModel):
from_model: str = Field(..., alias="from")
to: str
class ModelMappingUpdateRequest(BaseModel):
to: str
@admin_router.get("/api/model-mappings", dependencies=[Depends(require_admin_api)])
async def get_model_mappings(request: Request) -> dict[str, str]:
from ..proxy import _manual_model_mappings
return _manual_model_mappings
@admin_router.post("/api/model-mappings", dependencies=[Depends(require_admin_api)])
async def create_model_mapping(request: Request, mapping: ModelMappingRequest) -> dict[str, str]:
import json
import os
from ..proxy import _manual_model_mappings, load_manual_model_mappings
mappings_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "model_mappings.json")
try:
if os.path.exists(mappings_file):
with open(mappings_file, "r") as f:
data = json.load(f)
else:
data = {"manual_model_mappings": {"mappings": {}}}
data["manual_model_mappings"]["mappings"][mapping.from_model.lower()] = mapping.to.lower()
with open(mappings_file, "w") as f:
json.dump(data, f, indent=2)
load_manual_model_mappings()
return _manual_model_mappings
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to create mapping: {str(e)}")
@admin_router.put("/api/model-mappings/{from_model}", dependencies=[Depends(require_admin_api)])
async def update_model_mapping(request: Request, from_model: str, mapping: ModelMappingUpdateRequest) -> dict[str, str]:
import json
import os
from ..proxy import _manual_model_mappings, load_manual_model_mappings
mappings_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "model_mappings.json")
try:
if os.path.exists(mappings_file):
with open(mappings_file, "r") as f:
data = json.load(f)
else:
data = {"manual_model_mappings": {"mappings": {}}}
if from_model.lower() not in data["manual_model_mappings"]["mappings"]:
raise HTTPException(status_code=404, detail="Mapping not found")
data["manual_model_mappings"]["mappings"][from_model.lower()] = mapping.to.lower()
with open(mappings_file, "w") as f:
json.dump(data, f, indent=2)
load_manual_model_mappings()
return _manual_model_mappings
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update mapping: {str(e)}")
@admin_router.delete("/api/model-mappings/{from_model}", dependencies=[Depends(require_admin_api)])
async def delete_model_mapping(request: Request, from_model: str) -> dict[str, str]:
import json
import os
from ..proxy import _manual_model_mappings, load_manual_model_mappings
mappings_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "model_mappings.json")
try:
if os.path.exists(mappings_file):
with open(mappings_file, "r") as f:
data = json.load(f)
else:
data = {"manual_model_mappings": {"mappings": {}}}
if from_model.lower() not in data["manual_model_mappings"]["mappings"]:
raise HTTPException(status_code=404, detail="Mapping not found")
del data["manual_model_mappings"]["mappings"][from_model.lower()]
with open(mappings_file, "w") as f:
json.dump(data, f, indent=2)
load_manual_model_mappings()
return _manual_model_mappings
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete mapping: {str(e)}")
@admin_router.post("/api/model-mappings/reload", dependencies=[Depends(require_admin_api)])
async def reload_model_mappings(request: Request) -> dict[str, object]:
from ..proxy import _manual_model_mappings, load_manual_model_mappings
try:
load_manual_model_mappings()
return {"ok": True, "mappings": _manual_model_mappings}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to reload mappings: {str(e)}")
+7
View File
@@ -0,0 +1,7 @@
{
"manual_model_mappings": {
"mappings": {
"text-embedding-ada-002-v2": "text-embedding-ada-002"
}
}
}
+43 -4
View File
@@ -1,4 +1,5 @@
import json
import os
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -33,6 +34,23 @@ _upstreams: list[BaseUpstreamProvider] = []
_model_instances: dict[str, Model] = {} # All aliases -> Model
_provider_map: dict[str, BaseUpstreamProvider] = {} # All aliases -> Provider
_unique_models: dict[str, Model] = {} # Unique model.id -> Model (no duplicates)
_manual_model_mappings: dict[str, str] = {} # Manual model_id mappings loaded from JSON
def load_manual_model_mappings() -> None:
"""Load manual model mappings from JSON file."""
global _manual_model_mappings
try:
mappings_file = os.path.join(os.path.dirname(__file__), "model_mappings.json")
if os.path.exists(mappings_file):
with open(mappings_file, "r") as f:
data = json.load(f)
_manual_model_mappings = data.get("manual_model_mappings", {}).get("mappings", {})
else:
_manual_model_mappings = {}
except Exception as e:
logger.error(f"Failed to load manual model mappings: {e}")
_manual_model_mappings = {}
async def initialize_upstreams() -> None:
@@ -40,6 +58,7 @@ async def initialize_upstreams() -> None:
global _upstreams
_upstreams = await init_upstreams()
logger.info(f"Initialized {len(_upstreams)} upstream providers")
load_manual_model_mappings()
await refresh_model_maps()
@@ -51,6 +70,7 @@ async def reinitialize_upstreams() -> None:
"Re-initialized upstream providers from admin action",
extra={"provider_count": len(_upstreams)},
)
load_manual_model_mappings()
await refresh_model_maps()
@@ -64,13 +84,32 @@ def get_upstreams() -> list[BaseUpstreamProvider]:
def get_model_instance(model_id: str) -> Model | None:
"""Get Model instance by ID from global cache."""
return _model_instances.get(model_id.lower())
"""Get Model instance by ID from global cache, with manual mapping fallback."""
model = _model_instances.get(model_id)
if model is not None:
return model
mapped_model_id = _manual_model_mappings.get(model_id.lower())
if mapped_model_id:
return _model_instances.get(mapped_model_id.lower())
return None
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
"""Get UpstreamProvider for model ID from global cache."""
return _provider_map.get(model_id)
"""Get UpstreamProvider for model ID from global cache, with manual mapping fallback."""
# First try direct lookup
provider = _provider_map.get(model_id)
if provider is not None:
return provider
# Try manual mapping as fallback
mapped_model_id = _manual_model_mappings.get(model_id)
if mapped_model_id:
logger.debug(f"Using manual mapping for provider: {model_id} -> {mapped_model_id}")
return _provider_map.get(mapped_model_id)
return None
def get_unique_models() -> list[Model]:
+1
View File
@@ -734,6 +734,7 @@ class BaseUpstreamProvider:
await client.aclose()
return mapped_error
# Handle endpoints that require cost calculation and payment adjustment
if path.endswith("chat/completions") or path.endswith("embeddings"):
if path.endswith("chat/completions"):
client_wants_streaming = False
+1 -7
View File
@@ -50,13 +50,7 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
async def fetch_models(self) -> list[Model]:
"""Fetch all OpenRouter models."""
models_data = await async_fetch_openrouter_models()
models = [Model(**model) for model in models_data] # type: ignore
# manual alias for openai/text-embedding-ada-002 due to openrouter api bug
for model in models:
if model.id == "openai/text-embedding-ada-002":
model.alias_ids = ["text-embedding-ada-002-v2"]
break
return models
return [Model(**model) for model in models_data] # type: ignore
async def get_balance(self) -> float | None:
"""Get the current account balance from OpenRouter.
-97
View File
@@ -1,97 +0,0 @@
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
@pytest.mark.integration
@pytest.mark.asyncio
async def test_proxy_embeddings_endpoint(authenticated_client: AsyncClient) -> None:
"""Test the embeddings endpoint proxy functionality"""
test_payload = {
"model": "text-embedding-ada-002",
"input": "The quick brown fox",
}
mock_response_data = {
"object": "list",
"data": [
{"object": "embedding", "embedding": [0.0023, -0.0012, 0.0045], "index": 0}
],
"model": "text-embedding-ada-002",
"usage": {"prompt_tokens": 5, "total_tokens": 5},
}
with patch("httpx.AsyncClient.send") as mock_send:
# Create a proper async generator for iter_bytes
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
yield json.dumps(mock_response_data).encode()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.text = json.dumps(mock_response_data)
# Use MagicMock for synchronous .json() method
mock_response.json = MagicMock(return_value=mock_response_data)
mock_response.iter_bytes = mock_iter_bytes
mock_response.aiter_bytes = mock_iter_bytes
mock_send.return_value = mock_response
# Make POST request to embeddings endpoint
response = await authenticated_client.post("/v1/embeddings", json=test_payload)
assert response.status_code == 200
response_data = response.json()
assert response_data["object"] == "list"
assert len(response_data["data"]) == 1
assert response_data["data"][0]["object"] == "embedding"
# Verify request was forwarded
mock_send.assert_called_once()
forwarded_request = mock_send.call_args[0][0]
# Verify the path ends with embeddings
# Note: forwarded path might be full URL
assert str(forwarded_request.url).endswith("embeddings")
@pytest.mark.integration
@pytest.mark.asyncio
async def test_model_case_insensitivity(authenticated_client: AsyncClient) -> None:
"""Test that model lookups are case insensitive"""
# We'll use a mixed-case model ID that should match the lowercase one in the system
# We assume 'gpt-3.5-turbo' is available in the mock env/database
test_payload = {
"model": "GPT-3.5-TURBO",
"messages": [{"role": "user", "content": "Hello"}],
}
with patch("httpx.AsyncClient.send") as mock_send:
mock_response_data = {
"id": "chatcmpl-123",
"object": "chat.completion",
"choices": [{"message": {"content": "Hi"}}],
"usage": {"total_tokens": 10},
}
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
yield json.dumps(mock_response_data).encode()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.text = json.dumps(mock_response_data)
mock_response.json = MagicMock(return_value=mock_response_data)
mock_response.iter_bytes = mock_iter_bytes
mock_response.aiter_bytes = mock_iter_bytes
mock_send.return_value = mock_response
response = await authenticated_client.post(
"/v1/chat/completions", json=test_payload
)
assert response.status_code == 200
+222 -2
View File
@@ -10,16 +10,26 @@ import { SiteHeader } from '@/components/site-header';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useQuery } from '@tanstack/react-query';
import { AdminService } from '@/lib/api/services/admin';
import { ModelMappingService } from '@/lib/api/services/modelMappings';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle, Users, Globe } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { useMemo, useState } from 'react';
import React, { useMemo, useState } from 'react';
import type { Model } from '@/lib/api/schemas/models';
import { groupAndSortModelsByProvider } from '@/lib/utils/modelSort';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Trash2, Plus, Edit2, Save, X } from 'lucide-react';
export default function ModelsPage() {
const [filteredModels, setFilteredModels] = useState<Model[]>([]);
const [modelMappings, setModelMappings] = useState<Record<string, string>>(
{}
);
const [editingMapping, setEditingMapping] = useState<string | null>(null);
const [newMapping, setNewMapping] = useState({ from: '', to: '' });
const {
data: modelsData,
@@ -31,6 +41,23 @@ export default function ModelsPage() {
refetchOnWindowFocus: false,
});
const {
data: mappingsData,
isLoading: isLoadingMappings,
error: mappingsError,
refetch: refetchMappings,
} = useQuery({
queryKey: ['model-mappings'],
queryFn: () => ModelMappingService.getModelMappings(),
refetchOnWindowFocus: false,
});
React.useEffect(() => {
if (mappingsData) {
setModelMappings(mappingsData);
}
}, [mappingsData]);
const { models = [], groups = [] } = modelsData || {};
const groupedModels = useMemo(() => {
@@ -67,6 +94,40 @@ export default function ModelsPage() {
});
}, [groupedModels, groupDataMap, groups]);
const handleAddMapping = async () => {
if (!newMapping.from || !newMapping.to) return;
try {
await ModelMappingService.createModelMapping({
from: newMapping.from,
to: newMapping.to,
});
setNewMapping({ from: '', to: '' });
refetchMappings();
} catch (error) {
console.error('Failed to add mapping:', error);
}
};
const handleDeleteMapping = async (from: string) => {
try {
await ModelMappingService.deleteModelMapping(from);
refetchMappings();
} catch (error) {
console.error('Failed to delete mapping:', error);
}
};
const handleUpdateMapping = async (from: string, to: string) => {
try {
await ModelMappingService.updateModelMapping(from, { to });
setEditingMapping(null);
refetchMappings();
} catch (error) {
console.error('Failed to update mapping:', error);
}
};
return (
<SidebarProvider>
<AppSidebar variant='inset' />
@@ -81,8 +142,9 @@ export default function ModelsPage() {
</div>
<Tabs defaultValue='manage' className='w-full'>
<TabsList className='grid w-full grid-cols-3'>
<TabsList className='grid w-full grid-cols-4'>
<TabsTrigger value='manage'>Manage Models</TabsTrigger>
<TabsTrigger value='mappings'>Model Mappings</TabsTrigger>
{/*<TabsTrigger value='test-basic'>Basic Testing</TabsTrigger>
<TabsTrigger value='test-api'>API Endpoints</TabsTrigger> */}
</TabsList>
@@ -267,6 +329,164 @@ export default function ModelsPage() {
)}
</TabsContent>
<TabsContent value='mappings' className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Manage model ID mappings to redirect requests from one model
to another. This is useful for maintaining compatibility with
legacy model names or creating aliases.
</div>
{isLoadingMappings ? (
<div className='space-y-4'>
<Skeleton className='h-[200px] w-full' />
</div>
) : mappingsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load model mappings. Please try refreshing the
page.
</AlertDescription>
</Alert>
) : (
<div className='space-y-6'>
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Plus className='h-5 w-5' />
Add New Model Mapping
</CardTitle>
</CardHeader>
<CardContent>
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
<Input
placeholder='From model ID'
value={newMapping.from}
onChange={(e) =>
setNewMapping({
...newMapping,
from: e.target.value,
})
}
/>
<Input
placeholder='To model ID'
value={newMapping.to}
onChange={(e) =>
setNewMapping({
...newMapping,
to: e.target.value,
})
}
/>
<Button
onClick={handleAddMapping}
disabled={!newMapping.from || !newMapping.to}
className='w-full'
>
<Plus className='mr-2 h-4 w-4' />
Add Mapping
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Current Model Mappings</CardTitle>
</CardHeader>
<CardContent>
{Object.keys(modelMappings).length === 0 ? (
<div className='text-muted-foreground py-8 text-center'>
No model mappings configured
</div>
) : (
<div className='space-y-3'>
{Object.entries(modelMappings).map(([from, to]) => (
<div
key={from}
className='flex items-center justify-between gap-4 rounded-lg border p-4'
>
<div className='grid flex-1 grid-cols-1 gap-4 md:grid-cols-2'>
<div>
<label className='text-muted-foreground text-sm font-medium'>
From
</label>
<div className='font-mono text-sm'>
{from}
</div>
</div>
<div>
<label className='text-muted-foreground text-sm font-medium'>
To
</label>
{editingMapping === from ? (
<div className='flex items-center gap-2'>
<Input
defaultValue={to}
id={`edit-${from}`}
className='text-sm'
/>
<Button
size='sm'
onClick={() => {
const input =
document.getElementById(
`edit-${from}`
) as HTMLInputElement;
handleUpdateMapping(
from,
input.value
);
}}
>
<Save className='h-4 w-4' />
</Button>
<Button
size='sm'
variant='outline'
onClick={() =>
setEditingMapping(null)
}
>
<X className='h-4 w-4' />
</Button>
</div>
) : (
<div className='font-mono text-sm'>
{to}
</div>
)}
</div>
</div>
{editingMapping !== from && (
<div className='flex items-center gap-2'>
<Button
size='sm'
variant='outline'
onClick={() => setEditingMapping(from)}
>
<Edit2 className='h-4 w-4' />
</Button>
<Button
size='sm'
variant='destructive'
onClick={() => handleDeleteMapping(from)}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)}
</TabsContent>
<TabsContent value='test-basic' className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Test model credentials and connectivity with basic chat
+78
View File
@@ -0,0 +1,78 @@
import { apiClient } from '../client';
import { z } from 'zod';
export const ModelMappingSchema = z.object({
from: z.string(),
to: z.string(),
});
export const CreateModelMappingSchema = z.object({
from: z.string(),
to: z.string(),
});
export const UpdateModelMappingSchema = z.object({
to: z.string(),
});
export const ModelMappingsResponseSchema = z.record(z.string());
export const ReloadMappingsResponseSchema = z.object({
ok: z.boolean(),
mappings: z.record(z.string()),
});
export type ModelMapping = z.infer<typeof ModelMappingSchema>;
export type CreateModelMapping = z.infer<typeof CreateModelMappingSchema>;
export type UpdateModelMapping = z.infer<typeof UpdateModelMappingSchema>;
export type ModelMappingsResponse = z.infer<typeof ModelMappingsResponseSchema>;
export type ReloadMappingsResponse = z.infer<
typeof ReloadMappingsResponseSchema
>;
export class ModelMappingService {
static async getModelMappings(): Promise<ModelMappingsResponse> {
return await apiClient.get<ModelMappingsResponse>(
'/admin/api/model-mappings'
);
}
static async createModelMapping(
data: CreateModelMapping
): Promise<ModelMappingsResponse> {
return await apiClient.post<ModelMappingsResponse>(
'/admin/api/model-mappings',
{
from: data.from,
to: data.to,
}
);
}
static async updateModelMapping(
fromModel: string,
data: UpdateModelMapping
): Promise<ModelMappingsResponse> {
return await apiClient.put<ModelMappingsResponse>(
`/admin/api/model-mappings/${encodeURIComponent(fromModel)}`,
{
to: data.to,
}
);
}
static async deleteModelMapping(
fromModel: string
): Promise<ModelMappingsResponse> {
return await apiClient.delete<ModelMappingsResponse>(
`/admin/api/model-mappings/${encodeURIComponent(fromModel)}`
);
}
static async reloadModelMappings(): Promise<ReloadMappingsResponse> {
return await apiClient.post<ReloadMappingsResponse>(
'/admin/api/model-mappings/reload',
{}
);
}
}