mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-01 16:16:14 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b0829090e | ||
|
|
4ed86b0091 | ||
|
|
b327c0dee1 | ||
|
|
470c32aebf | ||
|
|
b01c7b2e56 | ||
|
|
8df0c17bc3 | ||
|
|
7bc9ee0653 | ||
|
|
355f8601c1 |
+37
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
@@ -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)}")
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"manual_model_mappings": {
|
||||
"mappings": {
|
||||
"text-embedding-ada-002-v2": "text-embedding-ada-002"
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-4
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
{}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user