local-ai-runtime 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/__init__.py +0 -0
- backend/controllers/__init__.py +0 -0
- backend/controllers/chat_controller.py +42 -0
- backend/controllers/model_controller.py +15 -0
- backend/models/__init__.py +2 -0
- backend/models/chat_message.py +80 -0
- backend/models/model_config.py +145 -0
- backend/routes/__init__.py +0 -0
- backend/routes/chat.py +92 -0
- backend/routes/config.py +75 -0
- backend/routes/conversations.py +76 -0
- backend/routes/metrics.py +11 -0
- backend/routes/models.py +29 -0
- backend/routes/status.py +24 -0
- backend/server.py +41 -0
- backend/services/__init__.py +0 -0
- backend/services/conversation_service.py +76 -0
- backend/services/inference_service.py +189 -0
- backend/services/metrics_service.py +36 -0
- backend/services/model_service.py +88 -0
- local_ai_runtime/__init__.py +2 -0
- local_ai_runtime/cli.py +31 -0
- local_ai_runtime/config.py +44 -0
- local_ai_runtime/server.py +68 -0
- local_ai_runtime-0.1.0.dist-info/METADATA +29 -0
- local_ai_runtime-0.1.0.dist-info/RECORD +47 -0
- local_ai_runtime-0.1.0.dist-info/WHEEL +5 -0
- local_ai_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- local_ai_runtime-0.1.0.dist-info/licenses/LICENSE +21 -0
- local_ai_runtime-0.1.0.dist-info/top_level.txt +3 -0
- model_runtime/__init__.py +1 -0
- model_runtime/backends/__init__.py +22 -0
- model_runtime/backends/anthropic_backend.py +130 -0
- model_runtime/backends/base.py +71 -0
- model_runtime/backends/gemini_backend.py +133 -0
- model_runtime/backends/llama_cpp.py +151 -0
- model_runtime/backends/ollama.py +139 -0
- model_runtime/backends/openai_backend.py +110 -0
- model_runtime/backends/openai_compatible.py +117 -0
- model_runtime/backends/registry.py +94 -0
- model_runtime/backends/vllm.py +171 -0
- model_runtime/chat_template_auto.py +78 -0
- model_runtime/detector.py +219 -0
- model_runtime/inference_engine.py +208 -0
- model_runtime/model_loader.py +69 -0
- model_runtime/prompt_formatter.py +149 -0
- model_runtime/tokenizer_adapter.py +58 -0
backend/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Controller for chat request handling."""
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException
|
|
4
|
+
|
|
5
|
+
from services.inference_service import generate_response, InferenceError
|
|
6
|
+
from models.chat_message import ChatMessage, Role
|
|
7
|
+
from services.conversation_service import get_conversation
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def handle_chat_request(request) -> dict:
|
|
11
|
+
user_message = ChatMessage(role=Role.USER, content=request.message)
|
|
12
|
+
|
|
13
|
+
conversation_history = None
|
|
14
|
+
if request.conversation_id:
|
|
15
|
+
conv = get_conversation(request.conversation_id)
|
|
16
|
+
if conv:
|
|
17
|
+
conversation_history = [
|
|
18
|
+
ChatMessage(role=Role(m["role"]), content=m["content"])
|
|
19
|
+
for m in conv["messages"]
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
result = await generate_response(
|
|
24
|
+
message=user_message,
|
|
25
|
+
conversation_id=request.conversation_id,
|
|
26
|
+
conversation_history=conversation_history,
|
|
27
|
+
)
|
|
28
|
+
except InferenceError as e:
|
|
29
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
30
|
+
|
|
31
|
+
# Auto-save user message and assistant response to conversation
|
|
32
|
+
if request.conversation_id:
|
|
33
|
+
from services.conversation_service import add_message_to_conversation
|
|
34
|
+
add_message_to_conversation(request.conversation_id, "user", request.message)
|
|
35
|
+
add_message_to_conversation(request.conversation_id, "assistant", result.content)
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
"response": result.content,
|
|
39
|
+
"model": result.model_name,
|
|
40
|
+
"tokens_used": result.tokens_used,
|
|
41
|
+
"finish_reason": result.finish_reason,
|
|
42
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Controller for model management request handling."""
|
|
2
|
+
|
|
3
|
+
from services.model_service import scan_model_directory, get_active_config
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def list_available_models() -> dict:
|
|
7
|
+
models = scan_model_directory()
|
|
8
|
+
return {"models": models}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_active_model() -> dict:
|
|
12
|
+
config = get_active_config()
|
|
13
|
+
if config is None:
|
|
14
|
+
return {"active_model": None}
|
|
15
|
+
return config.to_dict()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# backend/models/chat_message.py
|
|
2
|
+
#
|
|
3
|
+
# Data model for a single chat message.
|
|
4
|
+
#
|
|
5
|
+
# STATUS: DEFINED — This can be used now even before inference works.
|
|
6
|
+
# It's a plain data class with no dependencies.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# from models.chat_message import ChatMessage, Role
|
|
10
|
+
# msg = ChatMessage(role=Role.USER, content="Hello!")
|
|
11
|
+
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Role(str, Enum):
|
|
17
|
+
"""
|
|
18
|
+
The role of the entity producing a message.
|
|
19
|
+
Mirrors the convention used in most chat template formats.
|
|
20
|
+
"""
|
|
21
|
+
SYSTEM = "system"
|
|
22
|
+
USER = "user"
|
|
23
|
+
ASSISTANT = "assistant"
|
|
24
|
+
|
|
25
|
+
# Future: tool call results
|
|
26
|
+
# TOOL = "tool"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ChatMessage:
|
|
30
|
+
"""
|
|
31
|
+
Represents a single message in a conversation.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
role (Role) — Who produced this message
|
|
35
|
+
content (str) — The text content
|
|
36
|
+
timestamp (datetime) — When this message was created (UTC)
|
|
37
|
+
id (str|None) — Optional unique ID for this message
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, role: Role, content: str, timestamp: datetime | None = None, id: str | None = None):
|
|
41
|
+
self.role = role
|
|
42
|
+
self.content = content
|
|
43
|
+
self.timestamp = timestamp or datetime.now(timezone.utc)
|
|
44
|
+
self.id = id
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict:
|
|
47
|
+
return {
|
|
48
|
+
"role": self.role.value,
|
|
49
|
+
"content": self.content,
|
|
50
|
+
"timestamp": self.timestamp.isoformat(),
|
|
51
|
+
"id": self.id,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
preview = self.content[:60] + "..." if len(self.content) > 60 else self.content
|
|
56
|
+
return f"ChatMessage(role={self.role.value}, content='{preview}')"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Conversation:
|
|
60
|
+
"""
|
|
61
|
+
A list of ChatMessages forming a conversation thread.
|
|
62
|
+
|
|
63
|
+
TODO (Phase 4.1): Add persistence, conversation ID, title generation.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, id: str | None = None):
|
|
67
|
+
self.id = id
|
|
68
|
+
self.messages: list[ChatMessage] = []
|
|
69
|
+
|
|
70
|
+
def add_message(self, message: ChatMessage) -> None:
|
|
71
|
+
self.messages.append(message)
|
|
72
|
+
|
|
73
|
+
def get_messages(self) -> list[ChatMessage]:
|
|
74
|
+
return self.messages
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict:
|
|
77
|
+
return {
|
|
78
|
+
"id": self.id,
|
|
79
|
+
"messages": [m.to_dict() for m in self.messages],
|
|
80
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Data models for model configuration and inference I/O."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
CHAT_TEMPLATES = {
|
|
8
|
+
"auto",
|
|
9
|
+
"chatml",
|
|
10
|
+
"llama3",
|
|
11
|
+
"llama2",
|
|
12
|
+
"mistral",
|
|
13
|
+
"alpaca",
|
|
14
|
+
"raw",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
BACKEND_TYPES = {
|
|
18
|
+
"llama-cpp",
|
|
19
|
+
"ollama",
|
|
20
|
+
"vllm",
|
|
21
|
+
"openai",
|
|
22
|
+
"anthropic",
|
|
23
|
+
"gemini",
|
|
24
|
+
"openai_compatible",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class GenerationParams:
|
|
30
|
+
"""Parameters controlling text generation."""
|
|
31
|
+
temperature: float = 0.7
|
|
32
|
+
top_p: float = 0.9
|
|
33
|
+
top_k: int = 40
|
|
34
|
+
repeat_penalty: float = 1.1
|
|
35
|
+
max_new_tokens: int = 512
|
|
36
|
+
min_new_tokens: int = 1
|
|
37
|
+
seed: int = -1
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict:
|
|
40
|
+
return {
|
|
41
|
+
"temperature": self.temperature,
|
|
42
|
+
"top_p": self.top_p,
|
|
43
|
+
"top_k": self.top_k,
|
|
44
|
+
"repeat_penalty": self.repeat_penalty,
|
|
45
|
+
"max_new_tokens": self.max_new_tokens,
|
|
46
|
+
"min_new_tokens": self.min_new_tokens,
|
|
47
|
+
"seed": self.seed,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ModelConfig:
|
|
53
|
+
"""Complete configuration for one model — all from config, no hardcoded defaults."""
|
|
54
|
+
model_file: str = ""
|
|
55
|
+
backend_type: str = "llama-cpp"
|
|
56
|
+
chat_template: str = "auto"
|
|
57
|
+
context_length: int = 4096
|
|
58
|
+
system_prompt: str = "You are a helpful assistant."
|
|
59
|
+
n_gpu_layers: int = 0
|
|
60
|
+
generation: GenerationParams = field(default_factory=GenerationParams)
|
|
61
|
+
profile_name: Optional[str] = None
|
|
62
|
+
models_dir: str = "./models"
|
|
63
|
+
# API backend overrides
|
|
64
|
+
openai_model: str = ""
|
|
65
|
+
anthropic_model: str = ""
|
|
66
|
+
gemini_model: str = ""
|
|
67
|
+
ollama_model: str = ""
|
|
68
|
+
vllm_model: str = ""
|
|
69
|
+
compatible_model: str = ""
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict:
|
|
72
|
+
return {
|
|
73
|
+
"profile_name": self.profile_name,
|
|
74
|
+
"backend_type": self.backend_type,
|
|
75
|
+
"model_file": self.model_file,
|
|
76
|
+
"chat_template": self.chat_template,
|
|
77
|
+
"context_length": self.context_length,
|
|
78
|
+
"system_prompt": self.system_prompt,
|
|
79
|
+
"n_gpu_layers": self.n_gpu_layers,
|
|
80
|
+
"models_dir": self.models_dir,
|
|
81
|
+
"generation": self.generation.to_dict(),
|
|
82
|
+
"openai_model": self.openai_model,
|
|
83
|
+
"anthropic_model": self.anthropic_model,
|
|
84
|
+
"gemini_model": self.gemini_model,
|
|
85
|
+
"ollama_model": self.ollama_model,
|
|
86
|
+
"vllm_model": self.vllm_model,
|
|
87
|
+
"compatible_model": self.compatible_model,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_dict(cls, data: dict) -> "ModelConfig":
|
|
92
|
+
gen_data = data.get("generation", {})
|
|
93
|
+
generation = GenerationParams(
|
|
94
|
+
temperature=gen_data.get("temperature", 0.7),
|
|
95
|
+
top_p=gen_data.get("top_p", 0.9),
|
|
96
|
+
top_k=gen_data.get("top_k", 40),
|
|
97
|
+
repeat_penalty=gen_data.get("repeat_penalty", 1.1),
|
|
98
|
+
max_new_tokens=gen_data.get("max_new_tokens", 512),
|
|
99
|
+
seed=gen_data.get("seed", -1),
|
|
100
|
+
)
|
|
101
|
+
return cls(
|
|
102
|
+
model_file=data.get("model_file", ""),
|
|
103
|
+
backend_type=data.get("backend_type", "llama-cpp"),
|
|
104
|
+
chat_template=data.get("chat_template", "auto"),
|
|
105
|
+
context_length=data.get("context_length", 4096),
|
|
106
|
+
system_prompt=data.get("system_prompt", "You are a helpful assistant."),
|
|
107
|
+
n_gpu_layers=data.get("n_gpu_layers", 0),
|
|
108
|
+
generation=generation,
|
|
109
|
+
profile_name=data.get("profile_name"),
|
|
110
|
+
models_dir=data.get("models_dir", "./models"),
|
|
111
|
+
openai_model=data.get("openai_model", ""),
|
|
112
|
+
anthropic_model=data.get("anthropic_model", ""),
|
|
113
|
+
gemini_model=data.get("gemini_model", ""),
|
|
114
|
+
ollama_model=data.get("ollama_model", ""),
|
|
115
|
+
vllm_model=data.get("vllm_model", ""),
|
|
116
|
+
compatible_model=data.get("compatible_model", ""),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class InferenceRequest:
|
|
122
|
+
"""Internal representation of an inference request."""
|
|
123
|
+
prompt: str
|
|
124
|
+
system_prompt: str = ""
|
|
125
|
+
max_tokens: int = 512
|
|
126
|
+
temperature: float = 0.7
|
|
127
|
+
top_p: float = 0.9
|
|
128
|
+
top_k: int = 40
|
|
129
|
+
repeat_penalty: float = 1.1
|
|
130
|
+
seed: int = -1
|
|
131
|
+
stop_tokens: list[str] = field(default_factory=list)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class InferenceResponse:
|
|
136
|
+
"""Internal representation of an inference response."""
|
|
137
|
+
text: str
|
|
138
|
+
tokens_prompt: int = 0
|
|
139
|
+
tokens_generated: int = 0
|
|
140
|
+
finish_reason: str = "stop"
|
|
141
|
+
model_name: str = ""
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def tokens_used(self) -> int:
|
|
145
|
+
return self.tokens_prompt + self.tokens_generated
|
|
File without changes
|
backend/routes/chat.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""POST /chat and POST /chat/stream — Chat endpoints with streaming."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_project_root = str(Path(__file__).resolve().parent.parent.parent)
|
|
7
|
+
if _project_root not in sys.path:
|
|
8
|
+
sys.path.insert(0, _project_root)
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from fastapi import APIRouter, HTTPException
|
|
12
|
+
from fastapi.responses import StreamingResponse
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from controllers.chat_controller import handle_chat_request
|
|
16
|
+
|
|
17
|
+
router = APIRouter()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ChatRequest(BaseModel):
|
|
21
|
+
message: str
|
|
22
|
+
conversation_id: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ChatResponse(BaseModel):
|
|
26
|
+
response: str
|
|
27
|
+
model: str
|
|
28
|
+
tokens_used: int
|
|
29
|
+
finish_reason: str = "stop"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("/", response_model=ChatResponse)
|
|
33
|
+
async def chat(request: ChatRequest):
|
|
34
|
+
"""Send a message and receive a response from any backend."""
|
|
35
|
+
if not request.message.strip():
|
|
36
|
+
raise HTTPException(status_code=400, detail="Message cannot be empty.")
|
|
37
|
+
return await handle_chat_request(request)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.post("/stream")
|
|
41
|
+
async def chat_stream(request: ChatRequest):
|
|
42
|
+
"""Stream a response token-by-token using SSE."""
|
|
43
|
+
if not request.message.strip():
|
|
44
|
+
raise HTTPException(status_code=400, detail="Message cannot be empty.")
|
|
45
|
+
|
|
46
|
+
from models.chat_message import ChatMessage, Role
|
|
47
|
+
from services.inference_service import generate_stream_response
|
|
48
|
+
from services.conversation_service import (
|
|
49
|
+
get_conversation,
|
|
50
|
+
add_message_to_conversation,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
conversation_history = None
|
|
54
|
+
if request.conversation_id:
|
|
55
|
+
conv = get_conversation(request.conversation_id)
|
|
56
|
+
if conv:
|
|
57
|
+
conversation_history = [
|
|
58
|
+
ChatMessage(role=Role(m["role"]), content=m["content"])
|
|
59
|
+
for m in conv["messages"]
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
user_message = ChatMessage(role=Role.USER, content=request.message)
|
|
63
|
+
|
|
64
|
+
def event_generator():
|
|
65
|
+
full_response = []
|
|
66
|
+
try:
|
|
67
|
+
for token in generate_stream_response(
|
|
68
|
+
message=user_message,
|
|
69
|
+
conversation_history=conversation_history,
|
|
70
|
+
):
|
|
71
|
+
full_response.append(token)
|
|
72
|
+
yield f"data: {json.dumps({'token': token, 'done': False})}\n\n"
|
|
73
|
+
except Exception as e:
|
|
74
|
+
yield f"data: {json.dumps({'error': str(e), 'done': True})}\n\n"
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
# Save to conversation
|
|
78
|
+
if request.conversation_id:
|
|
79
|
+
add_message_to_conversation(request.conversation_id, "user", request.message)
|
|
80
|
+
add_message_to_conversation(request.conversation_id, "assistant", "".join(full_response))
|
|
81
|
+
|
|
82
|
+
yield f"data: {json.dumps({'token': '', 'done': True})}\n\n"
|
|
83
|
+
|
|
84
|
+
return StreamingResponse(
|
|
85
|
+
event_generator(),
|
|
86
|
+
media_type="text/event-stream",
|
|
87
|
+
headers={
|
|
88
|
+
"Cache-Control": "no-cache",
|
|
89
|
+
"Connection": "keep-alive",
|
|
90
|
+
"X-Accel-Buffering": "no",
|
|
91
|
+
},
|
|
92
|
+
)
|
backend/routes/config.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""GET /config and PUT /config — Read/update active model configuration."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_project_root = str(Path(__file__).resolve().parent.parent.parent)
|
|
7
|
+
if _project_root not in sys.path:
|
|
8
|
+
sys.path.insert(0, _project_root)
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, HTTPException
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from models.model_config import ModelConfig, GenerationParams
|
|
14
|
+
from services.model_service import get_active_config, set_active_config, scan_model_directory
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigUpdate(BaseModel):
|
|
20
|
+
backend_type: str | None = None
|
|
21
|
+
model_file: str | None = None
|
|
22
|
+
chat_template: str | None = None
|
|
23
|
+
context_length: int | None = None
|
|
24
|
+
system_prompt: str | None = None
|
|
25
|
+
n_gpu_layers: int | None = None
|
|
26
|
+
generation: dict | None = None
|
|
27
|
+
ollama_model: str | None = None
|
|
28
|
+
vllm_model: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.get("/")
|
|
32
|
+
async def get_config():
|
|
33
|
+
config = get_active_config()
|
|
34
|
+
if config is None:
|
|
35
|
+
raise HTTPException(status_code=404, detail="No active model configuration.")
|
|
36
|
+
return config.to_dict()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.put("/")
|
|
40
|
+
async def update_config(update: ConfigUpdate):
|
|
41
|
+
config = get_active_config()
|
|
42
|
+
if config is None:
|
|
43
|
+
# Create new config from defaults
|
|
44
|
+
config = ModelConfig(model_file="")
|
|
45
|
+
|
|
46
|
+
if update.backend_type is not None:
|
|
47
|
+
config.backend_type = update.backend_type
|
|
48
|
+
if update.model_file is not None:
|
|
49
|
+
models = scan_model_directory()
|
|
50
|
+
filenames = [m["filename"] for m in models]
|
|
51
|
+
if update.model_file not in filenames:
|
|
52
|
+
raise HTTPException(status_code=404, detail=f"Model not found: {update.model_file}")
|
|
53
|
+
config.model_file = update.model_file
|
|
54
|
+
if update.chat_template is not None:
|
|
55
|
+
config.chat_template = update.chat_template
|
|
56
|
+
if update.context_length is not None:
|
|
57
|
+
config.context_length = update.context_length
|
|
58
|
+
if update.system_prompt is not None:
|
|
59
|
+
config.system_prompt = update.system_prompt
|
|
60
|
+
if update.n_gpu_layers is not None:
|
|
61
|
+
config.n_gpu_layers = update.n_gpu_layers
|
|
62
|
+
if update.generation is not None:
|
|
63
|
+
for key, val in update.generation.items():
|
|
64
|
+
if hasattr(config.generation, key):
|
|
65
|
+
setattr(config.generation, key, val)
|
|
66
|
+
|
|
67
|
+
set_active_config(config)
|
|
68
|
+
return {"message": "Config updated", "config": config.to_dict()}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.get("/backends")
|
|
72
|
+
async def list_backends():
|
|
73
|
+
"""List all available BYOK backends and their status."""
|
|
74
|
+
from model_runtime.backends import list_backends
|
|
75
|
+
return {"backends": list_backends()}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Conversation history routes with full CRUD."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from services.conversation_service import (
|
|
6
|
+
create_conversation,
|
|
7
|
+
get_conversation,
|
|
8
|
+
list_conversations,
|
|
9
|
+
delete_conversation,
|
|
10
|
+
add_message_to_conversation,
|
|
11
|
+
update_conversation_title,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConversationCreate(BaseModel):
|
|
18
|
+
title: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConversationRename(BaseModel):
|
|
22
|
+
title: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MessageAdd(BaseModel):
|
|
26
|
+
role: str
|
|
27
|
+
content: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get("/")
|
|
31
|
+
async def get_all_conversations():
|
|
32
|
+
"""List all conversations, sorted by most recent."""
|
|
33
|
+
return {"conversations": list_conversations()}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.post("/")
|
|
37
|
+
async def create_new_conversation(body: ConversationCreate):
|
|
38
|
+
"""Create a new conversation."""
|
|
39
|
+
conv = create_conversation(title=body.title)
|
|
40
|
+
return conv
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get("/{conversation_id}")
|
|
44
|
+
async def get_conversation_detail(conversation_id: str):
|
|
45
|
+
"""Get a conversation with all messages."""
|
|
46
|
+
conv = get_conversation(conversation_id)
|
|
47
|
+
if conv is None:
|
|
48
|
+
raise HTTPException(status_code=404, detail="Conversation not found.")
|
|
49
|
+
return conv
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.patch("/{conversation_id}")
|
|
53
|
+
async def rename_conversation(conversation_id: str, body: ConversationRename):
|
|
54
|
+
"""Rename a conversation."""
|
|
55
|
+
result = update_conversation_title(conversation_id, body.title)
|
|
56
|
+
if result is None:
|
|
57
|
+
raise HTTPException(status_code=404, detail="Conversation not found.")
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.delete("/{conversation_id}")
|
|
62
|
+
async def delete_conversation_route(conversation_id: str):
|
|
63
|
+
"""Delete a conversation."""
|
|
64
|
+
ok = delete_conversation(conversation_id)
|
|
65
|
+
if not ok:
|
|
66
|
+
raise HTTPException(status_code=404, detail="Conversation not found.")
|
|
67
|
+
return {"message": "Deleted"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@router.post("/{conversation_id}/messages")
|
|
71
|
+
async def add_message(conversation_id: str, body: MessageAdd):
|
|
72
|
+
"""Add a message to a conversation."""
|
|
73
|
+
msg = add_message_to_conversation(conversation_id, body.role, body.content)
|
|
74
|
+
if msg is None:
|
|
75
|
+
raise HTTPException(status_code=404, detail="Conversation not found.")
|
|
76
|
+
return msg
|
backend/routes/models.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""GET /models — List available GGUF model files with auto-detected metadata."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_project_root = str(Path(__file__).resolve().parent.parent.parent)
|
|
7
|
+
if _project_root not in sys.path:
|
|
8
|
+
sys.path.insert(0, _project_root)
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter
|
|
11
|
+
from services.model_service import scan_model_directory
|
|
12
|
+
|
|
13
|
+
router = APIRouter()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/")
|
|
17
|
+
async def list_models():
|
|
18
|
+
models = scan_model_directory()
|
|
19
|
+
return {"models": models}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("/detect")
|
|
23
|
+
async def detect_models():
|
|
24
|
+
"""Auto-detect metadata for all GGUF files in models/ directory."""
|
|
25
|
+
from model_runtime.detector import detect_models_in_directory
|
|
26
|
+
from services.model_service import _get_models_dir
|
|
27
|
+
models_dir = _get_models_dir()
|
|
28
|
+
results = detect_models_in_directory(models_dir)
|
|
29
|
+
return {"models": results}
|
backend/routes/status.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""GET /status — Health check and model state."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# Ensure project root on sys.path
|
|
7
|
+
_project_root = str(Path(__file__).resolve().parent.parent.parent)
|
|
8
|
+
if _project_root not in sys.path:
|
|
9
|
+
sys.path.insert(0, _project_root)
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter
|
|
12
|
+
from services.model_service import is_model_loaded, get_current_model_name
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/")
|
|
18
|
+
async def get_status():
|
|
19
|
+
return {
|
|
20
|
+
"status": "ok",
|
|
21
|
+
"model_loaded": is_model_loaded(),
|
|
22
|
+
"current_model": get_current_model_name(),
|
|
23
|
+
"version": "0.1.0",
|
|
24
|
+
}
|
backend/server.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Legacy server.py — kept for backward compat.
|
|
3
|
+
The real entry point is local_ai_runtime/cli.py -> local_ai_runtime/server.py.
|
|
4
|
+
This file exists so `python backend/server.py` still works.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# Add project root to sys.path
|
|
13
|
+
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
14
|
+
if str(_PROJECT_ROOT) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(_PROJECT_ROOT))
|
|
16
|
+
|
|
17
|
+
import uvicorn
|
|
18
|
+
|
|
19
|
+
CONFIG_DIR = _PROJECT_ROOT / "config"
|
|
20
|
+
SERVER_CONFIG_PATH = CONFIG_DIR / "server.config.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_server_config() -> dict:
|
|
24
|
+
if SERVER_CONFIG_PATH.exists():
|
|
25
|
+
with open(SERVER_CONFIG_PATH) as f:
|
|
26
|
+
return json.load(f)
|
|
27
|
+
raise FileNotFoundError(f"Config not found: {SERVER_CONFIG_PATH}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
logging.basicConfig(
|
|
32
|
+
level=logging.INFO,
|
|
33
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
34
|
+
)
|
|
35
|
+
config = load_server_config()
|
|
36
|
+
uvicorn.run(
|
|
37
|
+
"backend.server:app",
|
|
38
|
+
host=config.get("host", "0.0.0.0"),
|
|
39
|
+
port=config.get("port", 8000),
|
|
40
|
+
log_level=config.get("log_level", "info"),
|
|
41
|
+
)
|
|
File without changes
|