Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 35 additions & 14 deletions AIGateway/src/routers/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,27 +377,48 @@ async def delete_test_dataset(
return {"deleted": True}


@router.post("/test", status_code=501)
@router.post("/test")
async def test_prompt(request: Request, body: TestPromptRequest):
"""
Test a prompt by resolving variables and proxying to the LLM completion
endpoint. The LLM proxy integration lives in the Express backend for now;
this stub returns 501 until the proxy is co-located in the AIGateway
service.
Test a prompt by resolving variables and streaming the LLM response.
Returns SSE stream compatible with the frontend's streamPromptTest().
"""
import json as _json

from fastapi.responses import StreamingResponse
from services.proxy_service import resolve_endpoint_for_key
from services.llm_service import stream_chat_completion

verify_internal_key(request)
org_id = get_org_id(request)

# Resolve variables so callers can at least validate substitution locally
# Resolve variables in the prompt content
resolved_content = crud.resolve_variables(
body.content,
body.variables or {},
)

raise HTTPException(
status_code=501,
detail=(
"test-prompt requires proxy integration — "
"LLM proxy currently runs in the Express backend. "
"Resolved content is available but cannot be forwarded yet."
),
)
# Resolve the endpoint to get provider, model, and API key
try:
endpoint = await resolve_endpoint_for_key(
organization_id=org_id,
endpoint_slug=body.endpoint_slug,
allowed_endpoint_ids=[],
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))

# Stream the LLM response
async def _stream():
try:
async for chunk_str in stream_chat_completion(
model=endpoint["model"],
messages=resolved_content,
api_key=endpoint["decrypted_key"],
):
yield chunk_str
except Exception as e:
yield f"data: {_json.dumps({'error': str(e)})}\n\n"
yield "data: [DONE]\n\n"

return StreamingResponse(_stream(), media_type="text/event-stream")
5 changes: 4 additions & 1 deletion AIGateway/src/routers/tenant_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ async def get_providers(request: Request):
provider = info.get("litellm_provider", "unknown")
if provider not in providers:
providers[provider] = []
providers[provider].append(model_name)
providers[provider].append({
"id": model_name,
"mode": info.get("mode", "chat"),
})

return {
"data": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export default function ComparePanel({
onChange={(e) => setVersionA(Number(e.target.value))}
items={versionItems}
placeholder="Select version"
getOptionValue={(item) => item._id}
sx={{ flex: 1 }}
/>
<Select
Expand All @@ -124,6 +125,7 @@ export default function ComparePanel({
onChange={(e) => setVersionB(Number(e.target.value))}
items={versionItems}
placeholder="Select version"
getOptionValue={(item) => item._id}
sx={{ flex: 1 }}
/>
</Box>
Expand All @@ -134,6 +136,7 @@ export default function ComparePanel({
onChange={(e) => setLocalEndpoint(e.target.value as string)}
items={endpoints.map((e) => ({ _id: e.slug, name: `${e.display_name} (${e.slug})` }))}
placeholder="Select endpoint"
getOptionValue={(item) => item._id}
sx={{ width: "100%" }}
/>
<Typography fontSize={11} color="text.disabled" mt="4px">
Expand Down
113 changes: 73 additions & 40 deletions Clients/src/presentation/pages/AIGateway/Prompts/PromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ export default function PromptEditorPage() {
const { providers: gwProviders, getModelsForProvider: gwModelsFor } = useGatewayModels();
// Build flat model list from all providers for the model metadata dropdown
const allModelItems = gwProviders.flatMap((p) => gwModelsFor(p));
const [modelSearch, setModelSearch] = useState("");
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
const [currentVersion, setCurrentVersion] = useState<number | null>(null);
const [currentStatus, setCurrentStatus] = useState<"draft" | "published">("draft");
const [isSaving, setIsSaving] = useState(false);
Expand Down Expand Up @@ -532,16 +534,49 @@ export default function PromptEditorPage() {

{/* Model + config */}
<Box sx={{ display: "flex", gap: "16px", mb: "16px", alignItems: "flex-end" }}>
<Select
id="prompt-model-select"
label="Model"
value={model}
onChange={(e) => setModel(e.target.value as string)}
items={allModelItems}
placeholder="Select model"
sx={{ flex: 1 }}
getOptionValue={(item) => item._id}
/>
<Box sx={{ flex: 1, position: "relative" }}>
<Field
label="Model"
placeholder="Search models (type 2+ chars)..."
value={modelSearch || model}
onChange={(e) => {
setModelSearch(e.target.value);
setModelDropdownOpen(e.target.value.length >= 2);
if (!e.target.value) setModel("");
}}
onFocus={() => { if (modelSearch.length >= 2) setModelDropdownOpen(true); }}
onBlur={() => setTimeout(() => setModelDropdownOpen(false), 200)}
sx={{ minWidth: "unset" }}
/>
{modelDropdownOpen && modelSearch.length >= 2 && (() => {
const q = modelSearch.toLowerCase();
const filtered = allModelItems.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 20);
if (filtered.length === 0) return null;
return (
<Box sx={{
position: "absolute", top: "100%", left: 0, right: 0, zIndex: 10,
bgcolor: "background.paper", border: `1px solid ${palette.border.dark}`,
borderRadius: "4px", maxHeight: 200, overflowY: "auto", mt: "2px",
boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
}}>
{filtered.map((m) => (
<Box
key={m._id}
onMouseDown={(e) => e.preventDefault()}
onClick={() => { setModel(m._id); setModelSearch(""); setModelDropdownOpen(false); }}
sx={{
px: "12px", py: "8px", cursor: "pointer", fontSize: 12,
"&:hover": { bgcolor: palette.background.alt },
bgcolor: model === m._id ? `${palette.brand.primary}08` : "transparent",
}}
>
{m.name}
</Box>
))}
</Box>
);
})()}
</Box>
<IconButton
size="small"
onClick={() => { setTempConfig({ ...config }); setIsConfigOpen(true); }}
Expand Down Expand Up @@ -650,39 +685,37 @@ export default function PromptEditorPage() {
))}
</Box>

{/* Endpoint selector + variables (shared across all tabs) */}
<Box sx={{ p: "16px", borderBottom: `1px solid ${palette.border.light}`, flexShrink: 0 }}>
<Select
id="prompt-endpoint-select"
label="Test endpoint"
value={selectedEndpoint}
onChange={(e) => setSelectedEndpoint(e.target.value as string)}
items={endpoints.map((e: any) => ({ _id: e.slug, name: `${e.display_name} (${e.slug})` }))}
placeholder="Select endpoint"
sx={{ width: "100%" }}
getOptionValue={(item) => item._id}
/>
{detectedVars.length > 0 && (
<Stack spacing="8px" sx={{ mt: "12px" }}>
<Typography fontSize={12} fontWeight={600} color="text.secondary">Variables</Typography>
{detectedVars.map((v) => (
<Field
key={v}
label={v}
value={variableValues[v] || ""}
onChange={(e) => setVariableValues((prev) => ({ ...prev, [v]: e.target.value }))}
placeholder={`Value for {{${v}}}`}
/>
))}
</Stack>
)}
</Box>

{/* Chat tab content */}
{testTab === "chat" && (
<>
{/* Endpoint selector + variables */}
<Box sx={{ p: "16px", borderBottom: `1px solid ${palette.border.light}`, flexShrink: 0 }}>
<Select
id="prompt-endpoint-select"
label="Test endpoint"
value={selectedEndpoint}
onChange={(e) => setSelectedEndpoint(e.target.value as string)}
items={endpoints.map((e: any) => ({ _id: e.slug, name: `${e.display_name} (${e.slug})` }))}
placeholder="Select endpoint"
sx={{ width: "100%" }}
/>
<Typography fontSize={11} color="text.disabled" mt="4px" mb={detectedVars.length > 0 ? "16px" : 0}>
Pick an endpoint to route test requests through. The model and API key come from the endpoint configuration.
</Typography>
{detectedVars.length > 0 && (
<Stack spacing="8px">
<Typography fontSize={12} fontWeight={600} color="text.secondary">Variables</Typography>
{detectedVars.map((v) => (
<Field
key={v}
label={v}
value={variableValues[v] || ""}
onChange={(e) => setVariableValues((prev) => ({ ...prev, [v]: e.target.value }))}
placeholder={`Value for {{${v}}}`}
/>
))}
</Stack>
)}
</Box>

{/* Chat area */}
<Box sx={{ flex: 1, overflow: "auto", p: "16px" }}>
{chatMessages.length === 0 && (
Expand Down
3 changes: 2 additions & 1 deletion Clients/src/presentation/pages/AIGateway/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@ export async function streamPromptTest(
): Promise<StreamPromptTestResult> {
const startTime = Date.now();

const response = await fetch(`${GATEWAY_API_URL}/ai-gateway/prompts/test`, {
// Use relative URL to go through Vite proxy (avoids CORS with fetch)
const response = await fetch("/api/ai-gateway/prompts/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand Down
Loading