v0 sdks wip
This commit is contained in:
parent
be62f74488
commit
74cba85ae6
37 changed files with 5359 additions and 13 deletions
69
Makefile
69
Makefile
|
|
@ -11,7 +11,7 @@ ifneq (,$(wildcard .env))
|
|||
endif
|
||||
|
||||
.PHONY: help dev dev-cpu dev-local infra stop clean build logs shell-backend shell-frontend install \
|
||||
test test-integration test-ci test-ci-local \
|
||||
test test-integration test-ci test-ci-local test-sdk \
|
||||
backend frontend install-be install-fe build-be build-fe logs-be logs-fe logs-lf logs-os \
|
||||
shell-be shell-lf shell-os restart status health db-reset flow-upload quick setup
|
||||
|
||||
|
|
@ -46,8 +46,9 @@ help:
|
|||
@echo "Testing:"
|
||||
@echo " test - Run all backend tests"
|
||||
@echo " test-integration - Run integration tests (requires infra)"
|
||||
@echo " test-ci - Start infra, run integration tests, tear down (uses DockerHub images)"
|
||||
@echo " test-ci - Start infra, run integration + SDK tests, tear down (uses DockerHub images)"
|
||||
@echo " test-ci-local - Same as test-ci but builds all images locally"
|
||||
@echo " test-sdk - Run SDK integration tests (requires running OpenRAG at localhost:3000)"
|
||||
@echo " lint - Run linting checks"
|
||||
@echo ""
|
||||
|
||||
|
|
@ -137,16 +138,19 @@ install-fe:
|
|||
|
||||
# Building
|
||||
build:
|
||||
@echo "🔨 Building Docker images..."
|
||||
docker compose build
|
||||
@echo "Building all Docker images locally..."
|
||||
docker build -t langflowai/openrag-opensearch:latest -f Dockerfile .
|
||||
docker build -t langflowai/openrag-backend:latest -f Dockerfile.backend .
|
||||
docker build -t langflowai/openrag-frontend:latest -f Dockerfile.frontend .
|
||||
docker build -t langflowai/openrag-langflow:latest -f Dockerfile.langflow .
|
||||
|
||||
build-be:
|
||||
@echo "🔨 Building backend image..."
|
||||
docker build -t openrag-backend -f Dockerfile.backend .
|
||||
@echo "Building backend image..."
|
||||
docker build -t langflowai/openrag-backend:latest -f Dockerfile.backend .
|
||||
|
||||
build-fe:
|
||||
@echo "🔨 Building frontend image..."
|
||||
docker build -t openrag-frontend -f Dockerfile.frontend .
|
||||
@echo "Building frontend image..."
|
||||
docker build -t langflowai/openrag-frontend:latest -f Dockerfile.frontend .
|
||||
|
||||
# Logging and debugging
|
||||
logs:
|
||||
|
|
@ -211,8 +215,8 @@ test-ci:
|
|||
docker compose -f docker-compose-cpu.yml pull; \
|
||||
echo "Building OpenSearch image override..."; \
|
||||
docker build --no-cache -t langflowai/openrag-opensearch:latest -f Dockerfile .; \
|
||||
echo "Starting infra (OpenSearch + Dashboards + Langflow) with CPU containers"; \
|
||||
docker compose -f docker-compose-cpu.yml up -d opensearch dashboards langflow; \
|
||||
echo "Starting infra (OpenSearch + Dashboards + Langflow + Backend + Frontend) with CPU containers"; \
|
||||
docker compose -f docker-compose-cpu.yml up -d opensearch dashboards langflow openrag-backend openrag-frontend; \
|
||||
echo "Starting docling-serve..."; \
|
||||
DOCLING_ENDPOINT=$$(uv run python scripts/docling_ctl.py start --port 5001 | grep "Endpoint:" | awk '{print $$2}'); \
|
||||
echo "Docling-serve started at $$DOCLING_ENDPOINT"; \
|
||||
|
|
@ -257,6 +261,21 @@ test-ci:
|
|||
uv run pytest tests/integration -vv -s -o log_cli=true --log-cli-level=DEBUG; \
|
||||
TEST_RESULT=$$?; \
|
||||
echo ""; \
|
||||
echo "Waiting for frontend at http://localhost:3000..."; \
|
||||
for i in $$(seq 1 60); do \
|
||||
curl -s http://localhost:3000/ >/dev/null 2>&1 && break || sleep 2; \
|
||||
done; \
|
||||
echo "Running Python SDK integration tests"; \
|
||||
cd sdks/python && \
|
||||
uv pip install -e ".[dev]" && \
|
||||
OPENRAG_URL=http://localhost:3000 uv run pytest tests/test_integration.py -vv -s || TEST_RESULT=1; \
|
||||
cd ../..; \
|
||||
echo "Running TypeScript SDK integration tests"; \
|
||||
cd sdks/typescript && \
|
||||
npm install && npm run build && \
|
||||
OPENRAG_URL=http://localhost:3000 npm test || TEST_RESULT=1; \
|
||||
cd ../..; \
|
||||
echo ""; \
|
||||
echo "=== Post-test JWT diagnostics ==="; \
|
||||
echo "Generating test JWT token..."; \
|
||||
TEST_TOKEN=$$(uv run python -c "from src.session_manager import SessionManager, AnonymousUser; sm = SessionManager('test'); print(sm.create_jwt_token(AnonymousUser()))" 2>/dev/null || echo ""); \
|
||||
|
|
@ -292,8 +311,8 @@ test-ci-local:
|
|||
docker build -t langflowai/openrag-backend:latest -f Dockerfile.backend .; \
|
||||
docker build -t langflowai/openrag-frontend:latest -f Dockerfile.frontend .; \
|
||||
docker build -t langflowai/openrag-langflow:latest -f Dockerfile.langflow .; \
|
||||
echo "Starting infra (OpenSearch + Dashboards + Langflow) with CPU containers"; \
|
||||
docker compose -f docker-compose-cpu.yml up -d opensearch dashboards langflow; \
|
||||
echo "Starting infra (OpenSearch + Dashboards + Langflow + Backend + Frontend) with CPU containers"; \
|
||||
docker compose -f docker-compose-cpu.yml up -d opensearch dashboards langflow openrag-backend openrag-frontend; \
|
||||
echo "Starting docling-serve..."; \
|
||||
DOCLING_ENDPOINT=$$(uv run python scripts/docling_ctl.py start --port 5001 | grep "Endpoint:" | awk '{print $$2}'); \
|
||||
echo "Docling-serve started at $$DOCLING_ENDPOINT"; \
|
||||
|
|
@ -338,6 +357,21 @@ test-ci-local:
|
|||
uv run pytest tests/integration -vv -s -o log_cli=true --log-cli-level=DEBUG; \
|
||||
TEST_RESULT=$$?; \
|
||||
echo ""; \
|
||||
echo "Waiting for frontend at http://localhost:3000..."; \
|
||||
for i in $$(seq 1 60); do \
|
||||
curl -s http://localhost:3000/ >/dev/null 2>&1 && break || sleep 2; \
|
||||
done; \
|
||||
echo "Running Python SDK integration tests"; \
|
||||
cd sdks/python && \
|
||||
uv pip install -e ".[dev]" && \
|
||||
OPENRAG_URL=http://localhost:3000 uv run pytest tests/test_integration.py -vv -s || TEST_RESULT=1; \
|
||||
cd ../..; \
|
||||
echo "Running TypeScript SDK integration tests"; \
|
||||
cd sdks/typescript && \
|
||||
npm install && npm run build && \
|
||||
OPENRAG_URL=http://localhost:3000 npm test || TEST_RESULT=1; \
|
||||
cd ../..; \
|
||||
echo ""; \
|
||||
echo "=== Post-test JWT diagnostics ==="; \
|
||||
echo "Generating test JWT token..."; \
|
||||
TEST_TOKEN=$$(uv run python -c "from src.session_manager import SessionManager, AnonymousUser; sm = SessionManager('test'); print(sm.create_jwt_token(AnonymousUser()))" 2>/dev/null || echo ""); \
|
||||
|
|
@ -353,6 +387,17 @@ test-ci-local:
|
|||
docker compose -f docker-compose-cpu.yml down -v 2>/dev/null || true; \
|
||||
exit $$TEST_RESULT
|
||||
|
||||
# SDK integration tests (requires running OpenRAG instance)
|
||||
test-sdk:
|
||||
@echo "Running SDK integration tests..."
|
||||
@echo "Make sure OpenRAG backend is running at localhost:8000 (make backend)"
|
||||
@echo ""
|
||||
@echo "Running Python SDK tests..."
|
||||
cd sdks/python && uv pip install -e ".[dev]" && OPENRAG_URL=http://localhost:8000 uv run pytest tests/test_integration.py -vv -s
|
||||
@echo ""
|
||||
@echo "Running TypeScript SDK tests..."
|
||||
cd sdks/typescript && npm install && npm run build && OPENRAG_URL=http://localhost:8000 npm test
|
||||
|
||||
lint:
|
||||
@echo "🔍 Running linting checks..."
|
||||
cd frontend && npm run lint
|
||||
|
|
|
|||
57
frontend/app/api/mutations/useCreateApiKeyMutation.ts
Normal file
57
frontend/app/api/mutations/useCreateApiKeyMutation.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import {
|
||||
type UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
export interface CreateApiKeyRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CreateApiKeyResponse {
|
||||
key_id: string;
|
||||
api_key: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const useCreateApiKeyMutation = (
|
||||
options?: Omit<
|
||||
UseMutationOptions<CreateApiKeyResponse, Error, CreateApiKeyRequest>,
|
||||
"mutationFn"
|
||||
>,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
async function createApiKey(
|
||||
variables: CreateApiKeyRequest,
|
||||
): Promise<CreateApiKeyResponse> {
|
||||
const response = await fetch("/api/keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(variables),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || "Failed to create API key");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return useMutation({
|
||||
mutationFn: createApiKey,
|
||||
onSuccess: (...args) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["api-keys"],
|
||||
});
|
||||
options?.onSuccess?.(...args);
|
||||
},
|
||||
onError: options?.onError,
|
||||
onSettled: options?.onSettled,
|
||||
});
|
||||
};
|
||||
49
frontend/app/api/mutations/useRevokeApiKeyMutation.ts
Normal file
49
frontend/app/api/mutations/useRevokeApiKeyMutation.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import {
|
||||
type UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
export interface RevokeApiKeyRequest {
|
||||
key_id: string;
|
||||
}
|
||||
|
||||
export interface RevokeApiKeyResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export const useRevokeApiKeyMutation = (
|
||||
options?: Omit<
|
||||
UseMutationOptions<RevokeApiKeyResponse, Error, RevokeApiKeyRequest>,
|
||||
"mutationFn"
|
||||
>,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
async function revokeApiKey(
|
||||
variables: RevokeApiKeyRequest,
|
||||
): Promise<RevokeApiKeyResponse> {
|
||||
const response = await fetch(`/api/keys/${variables.key_id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || "Failed to revoke API key");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return useMutation({
|
||||
mutationFn: revokeApiKey,
|
||||
onSuccess: (...args) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["api-keys"],
|
||||
});
|
||||
options?.onSuccess?.(...args);
|
||||
},
|
||||
onError: options?.onError,
|
||||
onSettled: options?.onSettled,
|
||||
});
|
||||
};
|
||||
31
frontend/app/api/queries/useGetApiKeysQuery.ts
Normal file
31
frontend/app/api/queries/useGetApiKeysQuery.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
|
||||
|
||||
export interface ApiKey {
|
||||
key_id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
export interface GetApiKeysResponse {
|
||||
keys: ApiKey[];
|
||||
}
|
||||
|
||||
export const useGetApiKeysQuery = (
|
||||
options?: Omit<UseQueryOptions<GetApiKeysResponse>, "queryKey" | "queryFn">,
|
||||
) => {
|
||||
async function getApiKeys(): Promise<GetApiKeysResponse> {
|
||||
const response = await fetch("/api/keys");
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
throw new Error("Failed to fetch API keys");
|
||||
}
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["api-keys"],
|
||||
queryFn: getApiKeys,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowUpRight, Loader2, Minus, PlugZap, Plus } from "lucide-react";
|
||||
import { ArrowUpRight, Copy, Key, Loader2, Minus, PlugZap, Plus, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||
|
|
@ -11,8 +11,19 @@ import {
|
|||
useGetOllamaModelsQuery,
|
||||
useGetOpenAIModelsQuery,
|
||||
} from "@/app/api/queries/useGetModelsQuery";
|
||||
import { useGetApiKeysQuery } from "@/app/api/queries/useGetApiKeysQuery";
|
||||
import { useCreateApiKeyMutation } from "@/app/api/mutations/useCreateApiKeyMutation";
|
||||
import { useRevokeApiKeyMutation } from "@/app/api/mutations/useRevokeApiKeyMutation";
|
||||
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
|
||||
import { ConfirmationDialog } from "@/components/confirmation-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { LabelWrapper } from "@/components/label-wrapper";
|
||||
import { ProtectedRoute } from "@/components/protected-route";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -122,11 +133,45 @@ function KnowledgeSourcesPage() {
|
|||
const [pictureDescriptions, setPictureDescriptions] =
|
||||
useState<boolean>(false);
|
||||
|
||||
// API Keys state
|
||||
const [createKeyDialogOpen, setCreateKeyDialogOpen] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||||
const [showKeyDialogOpen, setShowKeyDialogOpen] = useState(false);
|
||||
|
||||
// Fetch settings using React Query
|
||||
const { data: settings = {} } = useGetSettingsQuery({
|
||||
enabled: isAuthenticated || isNoAuthMode,
|
||||
});
|
||||
|
||||
// Fetch API keys
|
||||
const { data: apiKeysData, isLoading: apiKeysLoading } = useGetApiKeysQuery({
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// API key mutations
|
||||
const createApiKeyMutation = useCreateApiKeyMutation({
|
||||
onSuccess: (data) => {
|
||||
setNewlyCreatedKey(data.api_key);
|
||||
setCreateKeyDialogOpen(false);
|
||||
setShowKeyDialogOpen(true);
|
||||
setNewKeyName("");
|
||||
toast.success("API key created");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to create API key", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const revokeApiKeyMutation = useRevokeApiKeyMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("API key revoked");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to revoke API key", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch models for each provider
|
||||
const { data: openaiModels, isLoading: openaiLoading } =
|
||||
useGetOpenAIModelsQuery(
|
||||
|
|
@ -387,6 +432,36 @@ function KnowledgeSourcesPage() {
|
|||
updateSettingsMutation.mutate({ picture_descriptions: checked });
|
||||
};
|
||||
|
||||
// API Keys handlers
|
||||
const handleCreateApiKey = () => {
|
||||
if (!newKeyName.trim()) {
|
||||
toast.error("Please enter a name for the API key");
|
||||
return;
|
||||
}
|
||||
createApiKeyMutation.mutate({ name: newKeyName.trim() });
|
||||
};
|
||||
|
||||
const handleRevokeApiKey = (keyId: string) => {
|
||||
revokeApiKeyMutation.mutate({ key_id: keyId });
|
||||
};
|
||||
|
||||
const handleCopyApiKey = async () => {
|
||||
if (newlyCreatedKey) {
|
||||
await navigator.clipboard.writeText(newlyCreatedKey);
|
||||
toast.success("API key copied to clipboard");
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return "Never";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to get connector icon
|
||||
const getConnectorIcon = useCallback((iconName: string) => {
|
||||
const iconMap: { [key: string]: React.ReactElement } = {
|
||||
|
|
@ -1315,6 +1390,209 @@ function KnowledgeSourcesPage() {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* API Keys Section */}
|
||||
{isAuthenticated && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<CardTitle className="text-lg">API Keys</CardTitle>
|
||||
<Button
|
||||
onClick={() => setCreateKeyDialogOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
API keys allow programmatic access to OpenRAG via the public API.
|
||||
Keep your keys secure and never share them publicly.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{apiKeysLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : apiKeysData?.keys && apiKeysData.keys.length > 0 ? (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left text-sm font-medium text-muted-foreground px-4 py-3">
|
||||
Name
|
||||
</th>
|
||||
<th className="text-left text-sm font-medium text-muted-foreground px-4 py-3">
|
||||
Key
|
||||
</th>
|
||||
<th className="text-left text-sm font-medium text-muted-foreground px-4 py-3">
|
||||
Created
|
||||
</th>
|
||||
<th className="text-left text-sm font-medium text-muted-foreground px-4 py-3">
|
||||
Last Used
|
||||
</th>
|
||||
<th className="text-right text-sm font-medium text-muted-foreground px-4 py-3">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apiKeysData.keys.map((key) => (
|
||||
<tr key={key.key_id} className="border-t">
|
||||
<td className="px-4 py-3 text-sm font-medium">
|
||||
{key.name}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{key.key_prefix}...
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{formatDate(key.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{formatDate(key.last_used_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
title="Revoke API Key"
|
||||
description={
|
||||
<>
|
||||
Are you sure you want to revoke the API key{" "}
|
||||
<strong>{key.name}</strong>? This action cannot
|
||||
be undone and any applications using this key
|
||||
will stop working.
|
||||
</>
|
||||
}
|
||||
confirmText="Revoke"
|
||||
variant="destructive"
|
||||
onConfirm={(closeDialog) => {
|
||||
handleRevokeApiKey(key.key_id);
|
||||
closeDialog();
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Key className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No API keys yet. Create one to get started.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCreateKeyDialogOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create your first API key
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create API Key Dialog */}
|
||||
<Dialog open={createKeyDialogOpen} onOpenChange={setCreateKeyDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Give your API key a name to help you identify it later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<LabelWrapper label="Name" id="api-key-name">
|
||||
<Input
|
||||
id="api-key-name"
|
||||
placeholder="e.g., Production App, Development"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleCreateApiKey();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</LabelWrapper>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setCreateKeyDialogOpen(false);
|
||||
setNewKeyName("");
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateApiKey}
|
||||
disabled={createApiKeyMutation.isPending || !newKeyName.trim()}
|
||||
size="sm"
|
||||
>
|
||||
{createApiKeyMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Key"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Show Created API Key Dialog */}
|
||||
<Dialog
|
||||
open={showKeyDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setShowKeyDialogOpen(open);
|
||||
if (!open) {
|
||||
setNewlyCreatedKey(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API Key Created</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy your API key now. You won't be able to see it again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="bg-muted rounded-lg p-4 font-mono text-sm break-all">
|
||||
{newlyCreatedKey}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setShowKeyDialogOpen(false)} size="sm">
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleCopyApiKey} size="sm">
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy Key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
203
sdks/python/README.md
Normal file
203
sdks/python/README.md
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
# OpenRAG Python SDK
|
||||
|
||||
Official Python SDK for the [OpenRAG](https://openr.ag) API.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install openrag-sdk
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from openrag_sdk import OpenRAGClient
|
||||
|
||||
async def main():
|
||||
# Client auto-discovers OPENRAG_API_KEY and OPENRAG_URL from environment
|
||||
async with OpenRAGClient() as client:
|
||||
# Simple chat
|
||||
response = await client.chat.create(message="What is RAG?")
|
||||
print(response.response)
|
||||
print(f"Chat ID: {response.chat_id}")
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The SDK can be configured via environment variables or constructor arguments:
|
||||
|
||||
| Environment Variable | Constructor Argument | Description |
|
||||
|---------------------|---------------------|-------------|
|
||||
| `OPENRAG_API_KEY` | `api_key` | API key for authentication (required) |
|
||||
| `OPENRAG_URL` | `base_url` | Base URL for the API (default: `http://localhost:8080`) |
|
||||
|
||||
```python
|
||||
# Using environment variables
|
||||
client = OpenRAGClient()
|
||||
|
||||
# Using explicit arguments
|
||||
client = OpenRAGClient(
|
||||
api_key="orag_...",
|
||||
base_url="https://api.example.com"
|
||||
)
|
||||
```
|
||||
|
||||
## Chat
|
||||
|
||||
### Non-streaming
|
||||
|
||||
```python
|
||||
response = await client.chat.create(message="What is RAG?")
|
||||
print(response.response)
|
||||
print(f"Chat ID: {response.chat_id}")
|
||||
|
||||
# Continue conversation
|
||||
followup = await client.chat.create(
|
||||
message="Tell me more",
|
||||
chat_id=response.chat_id
|
||||
)
|
||||
```
|
||||
|
||||
### Streaming with `create(stream=True)`
|
||||
|
||||
Returns an async iterator directly:
|
||||
|
||||
```python
|
||||
chat_id = None
|
||||
async for event in await client.chat.create(message="Explain RAG", stream=True):
|
||||
if event.type == "content":
|
||||
print(event.delta, end="", flush=True)
|
||||
elif event.type == "sources":
|
||||
for source in event.sources:
|
||||
print(f"\nSource: {source.filename}")
|
||||
elif event.type == "done":
|
||||
chat_id = event.chat_id
|
||||
```
|
||||
|
||||
### Streaming with `stream()` Context Manager
|
||||
|
||||
Provides additional helpers for convenience:
|
||||
|
||||
```python
|
||||
# Full event iteration
|
||||
async with client.chat.stream(message="Explain RAG") as stream:
|
||||
async for event in stream:
|
||||
if event.type == "content":
|
||||
print(event.delta, end="", flush=True)
|
||||
|
||||
# Access aggregated data after iteration
|
||||
print(f"\nChat ID: {stream.chat_id}")
|
||||
print(f"Full text: {stream.text}")
|
||||
print(f"Sources: {stream.sources}")
|
||||
|
||||
# Just text deltas
|
||||
async with client.chat.stream(message="Explain RAG") as stream:
|
||||
async for text in stream.text_stream:
|
||||
print(text, end="", flush=True)
|
||||
|
||||
# Get final text directly
|
||||
async with client.chat.stream(message="Explain RAG") as stream:
|
||||
text = await stream.final_text()
|
||||
print(text)
|
||||
```
|
||||
|
||||
### Conversation History
|
||||
|
||||
```python
|
||||
# List all conversations
|
||||
conversations = await client.chat.list()
|
||||
for conv in conversations.conversations:
|
||||
print(f"{conv.chat_id}: {conv.title}")
|
||||
|
||||
# Get specific conversation with messages
|
||||
conversation = await client.chat.get(chat_id)
|
||||
for msg in conversation.messages:
|
||||
print(f"{msg.role}: {msg.content}")
|
||||
|
||||
# Delete conversation
|
||||
await client.chat.delete(chat_id)
|
||||
```
|
||||
|
||||
## Search
|
||||
|
||||
```python
|
||||
# Basic search
|
||||
results = await client.search.query("document processing")
|
||||
for result in results.results:
|
||||
print(f"{result.filename} (score: {result.score})")
|
||||
print(f" {result.text[:100]}...")
|
||||
|
||||
# Search with filters
|
||||
from openrag_sdk import SearchFilters
|
||||
|
||||
results = await client.search.query(
|
||||
"API documentation",
|
||||
filters=SearchFilters(
|
||||
data_sources=["api-docs.pdf"],
|
||||
document_types=["application/pdf"]
|
||||
),
|
||||
limit=5,
|
||||
score_threshold=0.5
|
||||
)
|
||||
```
|
||||
|
||||
## Documents
|
||||
|
||||
```python
|
||||
# Ingest a file
|
||||
result = await client.documents.ingest(file_path="./report.pdf")
|
||||
print(f"Document ID: {result.document_id}")
|
||||
print(f"Chunks: {result.chunks}")
|
||||
|
||||
# Ingest from file object
|
||||
with open("./report.pdf", "rb") as f:
|
||||
result = await client.documents.ingest(file=f, filename="report.pdf")
|
||||
|
||||
# Delete a document
|
||||
result = await client.documents.delete("report.pdf")
|
||||
print(f"Deleted {result.deleted_chunks} chunks")
|
||||
```
|
||||
|
||||
## Settings
|
||||
|
||||
```python
|
||||
settings = await client.settings.get()
|
||||
print(f"LLM Provider: {settings.agent.llm_provider}")
|
||||
print(f"LLM Model: {settings.agent.llm_model}")
|
||||
print(f"Embedding Model: {settings.knowledge.embedding_model}")
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```python
|
||||
from openrag_sdk import (
|
||||
OpenRAGError,
|
||||
AuthenticationError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
RateLimitError,
|
||||
ServerError,
|
||||
)
|
||||
|
||||
try:
|
||||
response = await client.chat.create(message="Hello")
|
||||
except AuthenticationError as e:
|
||||
print(f"Invalid API key: {e.message}")
|
||||
except NotFoundError as e:
|
||||
print(f"Resource not found: {e.message}")
|
||||
except ValidationError as e:
|
||||
print(f"Invalid request: {e.message}")
|
||||
except RateLimitError as e:
|
||||
print(f"Rate limited: {e.message}")
|
||||
except ServerError as e:
|
||||
print(f"Server error: {e.message}")
|
||||
except OpenRAGError as e:
|
||||
print(f"API error: {e.message} (status: {e.status_code})")
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
91
sdks/python/openrag_sdk/__init__.py
Normal file
91
sdks/python/openrag_sdk/__init__.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""
|
||||
OpenRAG Python SDK.
|
||||
|
||||
A Python client library for the OpenRAG API.
|
||||
|
||||
Usage:
|
||||
from openrag_sdk import OpenRAGClient
|
||||
|
||||
# Using environment variables (OPENRAG_API_KEY, OPENRAG_URL)
|
||||
async with OpenRAGClient() as client:
|
||||
# Non-streaming chat
|
||||
response = await client.chat.create(message="What is RAG?")
|
||||
print(response.response)
|
||||
|
||||
# Streaming chat with context manager
|
||||
async with client.chat.stream(message="Explain RAG") as stream:
|
||||
async for text in stream.text_stream:
|
||||
print(text, end="")
|
||||
|
||||
# Search
|
||||
results = await client.search.query("document processing")
|
||||
|
||||
# Ingest document
|
||||
await client.documents.ingest(file_path="./report.pdf")
|
||||
|
||||
# Get settings
|
||||
settings = await client.settings.get()
|
||||
"""
|
||||
|
||||
from .client import OpenRAGClient
|
||||
from .exceptions import (
|
||||
AuthenticationError,
|
||||
NotFoundError,
|
||||
OpenRAGError,
|
||||
RateLimitError,
|
||||
ServerError,
|
||||
ValidationError,
|
||||
)
|
||||
from .models import (
|
||||
AgentSettings,
|
||||
ChatResponse,
|
||||
ContentEvent,
|
||||
Conversation,
|
||||
ConversationDetail,
|
||||
ConversationListResponse,
|
||||
DeleteDocumentResponse,
|
||||
DoneEvent,
|
||||
IngestResponse,
|
||||
KnowledgeSettings,
|
||||
Message,
|
||||
SearchFilters,
|
||||
SearchResponse,
|
||||
SearchResult,
|
||||
SettingsResponse,
|
||||
Source,
|
||||
SourcesEvent,
|
||||
StreamEvent,
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
# Main client
|
||||
"OpenRAGClient",
|
||||
# Exceptions
|
||||
"OpenRAGError",
|
||||
"AuthenticationError",
|
||||
"RateLimitError",
|
||||
"NotFoundError",
|
||||
"ValidationError",
|
||||
"ServerError",
|
||||
# Models
|
||||
"ChatResponse",
|
||||
"ContentEvent",
|
||||
"SourcesEvent",
|
||||
"DoneEvent",
|
||||
"StreamEvent",
|
||||
"Source",
|
||||
"SearchResponse",
|
||||
"SearchResult",
|
||||
"SearchFilters",
|
||||
"IngestResponse",
|
||||
"DeleteDocumentResponse",
|
||||
"Conversation",
|
||||
"ConversationDetail",
|
||||
"ConversationListResponse",
|
||||
"Message",
|
||||
"SettingsResponse",
|
||||
"AgentSettings",
|
||||
"KnowledgeSettings",
|
||||
]
|
||||
479
sdks/python/openrag_sdk/chat.py
Normal file
479
sdks/python/openrag_sdk/chat.py
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
"""OpenRAG SDK chat client with streaming support."""
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, AsyncIterator, Literal, overload
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import (
|
||||
ChatResponse,
|
||||
ContentEvent,
|
||||
Conversation,
|
||||
ConversationDetail,
|
||||
ConversationListResponse,
|
||||
DoneEvent,
|
||||
Message,
|
||||
SearchFilters,
|
||||
Source,
|
||||
SourcesEvent,
|
||||
StreamEvent,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client import OpenRAGClient
|
||||
|
||||
|
||||
class ChatStream:
|
||||
"""
|
||||
Context manager for streaming chat responses.
|
||||
|
||||
Provides convenient access to streamed content with helpers for
|
||||
text-only streaming and final text extraction.
|
||||
|
||||
Usage:
|
||||
async with client.chat.stream(message="Hello") as stream:
|
||||
async for event in stream:
|
||||
if event.type == "content":
|
||||
print(event.delta, end="")
|
||||
|
||||
# After iteration, access aggregated data
|
||||
print(f"Chat ID: {stream.chat_id}")
|
||||
print(f"Full text: {stream.text}")
|
||||
|
||||
# Or use text_stream for just text deltas
|
||||
async with client.chat.stream(message="Hello") as stream:
|
||||
async for text in stream.text_stream:
|
||||
print(text, end="")
|
||||
|
||||
# Or use final_text() to get the complete response
|
||||
async with client.chat.stream(message="Hello") as stream:
|
||||
text = await stream.final_text()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: "OpenRAGClient",
|
||||
message: str,
|
||||
chat_id: str | None = None,
|
||||
filters: SearchFilters | dict[str, Any] | None = None,
|
||||
limit: int = 10,
|
||||
score_threshold: float = 0,
|
||||
):
|
||||
self._client = client
|
||||
self._message = message
|
||||
self._chat_id_input = chat_id
|
||||
self._filters = filters
|
||||
self._limit = limit
|
||||
self._score_threshold = score_threshold
|
||||
|
||||
# Aggregated data
|
||||
self._text = ""
|
||||
self._chat_id: str | None = None
|
||||
self._sources: list[Source] = []
|
||||
self._response: httpx.Response | None = None
|
||||
self._consumed = False
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""The accumulated text from content events."""
|
||||
return self._text
|
||||
|
||||
@property
|
||||
def chat_id(self) -> str | None:
|
||||
"""The chat ID for continuing the conversation."""
|
||||
return self._chat_id
|
||||
|
||||
@property
|
||||
def sources(self) -> list[Source]:
|
||||
"""The sources retrieved during the conversation."""
|
||||
return self._sources
|
||||
|
||||
async def __aenter__(self) -> "ChatStream":
|
||||
body: dict[str, Any] = {
|
||||
"message": self._message,
|
||||
"stream": True,
|
||||
"limit": self._limit,
|
||||
"score_threshold": self._score_threshold,
|
||||
}
|
||||
|
||||
if self._chat_id_input:
|
||||
body["chat_id"] = self._chat_id_input
|
||||
|
||||
if self._filters:
|
||||
if isinstance(self._filters, SearchFilters):
|
||||
body["filters"] = self._filters.model_dump(exclude_none=True)
|
||||
else:
|
||||
body["filters"] = self._filters
|
||||
|
||||
self._response = await self._client._http.send(
|
||||
self._client._http.build_request(
|
||||
"POST",
|
||||
f"{self._client._base_url}/api/v1/chat",
|
||||
json=body,
|
||||
headers=self._client._headers,
|
||||
),
|
||||
stream=True,
|
||||
)
|
||||
|
||||
if self._response.status_code != 200:
|
||||
await self._response.aread()
|
||||
self._client._handle_error(self._response)
|
||||
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
if self._response:
|
||||
await self._response.aclose()
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[StreamEvent]:
|
||||
return self._iterate_events()
|
||||
|
||||
async def _iterate_events(self) -> AsyncIterator[StreamEvent]:
|
||||
"""Iterate over all stream events."""
|
||||
if self._consumed:
|
||||
raise RuntimeError("Stream has already been consumed")
|
||||
|
||||
self._consumed = True
|
||||
|
||||
if not self._response:
|
||||
raise RuntimeError("Stream not initialized")
|
||||
|
||||
async for line in self._response.aiter_lines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith("data:"):
|
||||
data_str = line[5:].strip()
|
||||
if not data_str:
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
event_type = data.get("type")
|
||||
|
||||
if event_type == "content":
|
||||
delta = data.get("delta", "")
|
||||
self._text += delta
|
||||
yield ContentEvent(delta=delta)
|
||||
|
||||
elif event_type == "sources":
|
||||
sources = [Source(**s) for s in data.get("sources", [])]
|
||||
self._sources = sources
|
||||
yield SourcesEvent(sources=sources)
|
||||
|
||||
elif event_type == "done":
|
||||
self._chat_id = data.get("chat_id")
|
||||
yield DoneEvent(chat_id=self._chat_id)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
@property
|
||||
def text_stream(self) -> AsyncIterator[str]:
|
||||
"""
|
||||
Iterate over just the text deltas.
|
||||
|
||||
Usage:
|
||||
async for text in stream.text_stream:
|
||||
print(text, end="")
|
||||
"""
|
||||
return self._iterate_text()
|
||||
|
||||
async def _iterate_text(self) -> AsyncIterator[str]:
|
||||
"""Iterate over text deltas only."""
|
||||
async for event in self:
|
||||
if isinstance(event, ContentEvent):
|
||||
yield event.delta
|
||||
|
||||
async def final_text(self) -> str:
|
||||
"""
|
||||
Consume the stream and return the complete text.
|
||||
|
||||
Returns:
|
||||
The full concatenated text from all content events.
|
||||
"""
|
||||
async for _ in self:
|
||||
pass
|
||||
return self._text
|
||||
|
||||
|
||||
class ChatClient:
|
||||
"""Client for chat operations with streaming support."""
|
||||
|
||||
def __init__(self, client: "OpenRAGClient"):
|
||||
self._client = client
|
||||
|
||||
@overload
|
||||
async def create(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
stream: Literal[False] = False,
|
||||
chat_id: str | None = None,
|
||||
filters: SearchFilters | dict[str, Any] | None = None,
|
||||
limit: int = 10,
|
||||
score_threshold: float = 0,
|
||||
) -> ChatResponse: ...
|
||||
|
||||
@overload
|
||||
async def create(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
stream: Literal[True],
|
||||
chat_id: str | None = None,
|
||||
filters: SearchFilters | dict[str, Any] | None = None,
|
||||
limit: int = 10,
|
||||
score_threshold: float = 0,
|
||||
) -> AsyncIterator[StreamEvent]: ...
|
||||
|
||||
async def create(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
stream: bool = False,
|
||||
chat_id: str | None = None,
|
||||
filters: SearchFilters | dict[str, Any] | None = None,
|
||||
limit: int = 10,
|
||||
score_threshold: float = 0,
|
||||
) -> ChatResponse | AsyncIterator[StreamEvent]:
|
||||
"""
|
||||
Send a chat message.
|
||||
|
||||
Args:
|
||||
message: The message to send.
|
||||
stream: Whether to stream the response (default False).
|
||||
chat_id: ID of existing conversation to continue.
|
||||
filters: Optional search filters (data_sources, document_types).
|
||||
limit: Maximum number of search results (default 10).
|
||||
score_threshold: Minimum search score threshold (default 0).
|
||||
|
||||
Returns:
|
||||
ChatResponse if stream=False, AsyncIterator[StreamEvent] if stream=True.
|
||||
|
||||
Usage:
|
||||
# Non-streaming
|
||||
response = await client.chat.create(message="Hello")
|
||||
print(response.response)
|
||||
|
||||
# Streaming
|
||||
async for event in await client.chat.create(message="Hello", stream=True):
|
||||
if event.type == "content":
|
||||
print(event.delta, end="")
|
||||
"""
|
||||
if stream:
|
||||
return self._stream_response(
|
||||
message=message,
|
||||
chat_id=chat_id,
|
||||
filters=filters,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
else:
|
||||
return await self._create_response(
|
||||
message=message,
|
||||
chat_id=chat_id,
|
||||
filters=filters,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
|
||||
async def _create_response(
|
||||
self,
|
||||
message: str,
|
||||
chat_id: str | None,
|
||||
filters: SearchFilters | dict[str, Any] | None,
|
||||
limit: int,
|
||||
score_threshold: float,
|
||||
) -> ChatResponse:
|
||||
"""Send a non-streaming chat message."""
|
||||
body: dict[str, Any] = {
|
||||
"message": message,
|
||||
"stream": False,
|
||||
"limit": limit,
|
||||
"score_threshold": score_threshold,
|
||||
}
|
||||
|
||||
if chat_id:
|
||||
body["chat_id"] = chat_id
|
||||
|
||||
if filters:
|
||||
if isinstance(filters, SearchFilters):
|
||||
body["filters"] = filters.model_dump(exclude_none=True)
|
||||
else:
|
||||
body["filters"] = filters
|
||||
|
||||
response = await self._client._request(
|
||||
"POST",
|
||||
"/api/v1/chat",
|
||||
json=body,
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
sources = [Source(**s) for s in data.get("sources", [])]
|
||||
|
||||
return ChatResponse(
|
||||
response=data.get("response", ""),
|
||||
chat_id=data.get("chat_id"),
|
||||
sources=sources,
|
||||
)
|
||||
|
||||
async def _stream_response(
|
||||
self,
|
||||
message: str,
|
||||
chat_id: str | None,
|
||||
filters: SearchFilters | dict[str, Any] | None,
|
||||
limit: int,
|
||||
score_threshold: float,
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""Stream a chat response as an async iterator."""
|
||||
body: dict[str, Any] = {
|
||||
"message": message,
|
||||
"stream": True,
|
||||
"limit": limit,
|
||||
"score_threshold": score_threshold,
|
||||
}
|
||||
|
||||
if chat_id:
|
||||
body["chat_id"] = chat_id
|
||||
|
||||
if filters:
|
||||
if isinstance(filters, SearchFilters):
|
||||
body["filters"] = filters.model_dump(exclude_none=True)
|
||||
else:
|
||||
body["filters"] = filters
|
||||
|
||||
async with self._client._http.stream(
|
||||
"POST",
|
||||
f"{self._client._base_url}/api/v1/chat",
|
||||
json=body,
|
||||
headers=self._client._headers,
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
await response.aread()
|
||||
self._client._handle_error(response)
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith("data:"):
|
||||
data_str = line[5:].strip()
|
||||
if not data_str:
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
event_type = data.get("type")
|
||||
|
||||
if event_type == "content":
|
||||
yield ContentEvent(delta=data.get("delta", ""))
|
||||
elif event_type == "sources":
|
||||
sources = [Source(**s) for s in data.get("sources", [])]
|
||||
yield SourcesEvent(sources=sources)
|
||||
elif event_type == "done":
|
||||
yield DoneEvent(chat_id=data.get("chat_id"))
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
def stream(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
chat_id: str | None = None,
|
||||
filters: SearchFilters | dict[str, Any] | None = None,
|
||||
limit: int = 10,
|
||||
score_threshold: float = 0,
|
||||
) -> ChatStream:
|
||||
"""
|
||||
Create a streaming chat context manager.
|
||||
|
||||
Args:
|
||||
message: The message to send.
|
||||
chat_id: ID of existing conversation to continue.
|
||||
filters: Optional search filters (data_sources, document_types).
|
||||
limit: Maximum number of search results (default 10).
|
||||
score_threshold: Minimum search score threshold (default 0).
|
||||
|
||||
Returns:
|
||||
ChatStream context manager.
|
||||
|
||||
Usage:
|
||||
async with client.chat.stream(message="Hello") as stream:
|
||||
async for event in stream:
|
||||
if event.type == "content":
|
||||
print(event.delta, end="")
|
||||
|
||||
# Access after iteration
|
||||
print(f"Chat ID: {stream.chat_id}")
|
||||
print(f"Full text: {stream.text}")
|
||||
"""
|
||||
return ChatStream(
|
||||
client=self._client,
|
||||
message=message,
|
||||
chat_id=chat_id,
|
||||
filters=filters,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
|
||||
async def list(self) -> ConversationListResponse:
|
||||
"""
|
||||
List all conversations.
|
||||
|
||||
Returns:
|
||||
ConversationListResponse with conversation metadata.
|
||||
"""
|
||||
response = await self._client._request("GET", "/api/v1/chat")
|
||||
data = response.json()
|
||||
|
||||
conversations = [
|
||||
Conversation(**c) for c in data.get("conversations", [])
|
||||
]
|
||||
|
||||
return ConversationListResponse(conversations=conversations)
|
||||
|
||||
async def get(self, chat_id: str) -> ConversationDetail:
|
||||
"""
|
||||
Get a specific conversation with full message history.
|
||||
|
||||
Args:
|
||||
chat_id: The ID of the conversation to retrieve.
|
||||
|
||||
Returns:
|
||||
ConversationDetail with full message history.
|
||||
"""
|
||||
response = await self._client._request("GET", f"/api/v1/chat/{chat_id}")
|
||||
data = response.json()
|
||||
|
||||
messages = [Message(**m) for m in data.get("messages", [])]
|
||||
|
||||
return ConversationDetail(
|
||||
chat_id=data.get("chat_id", chat_id),
|
||||
title=data.get("title", ""),
|
||||
created_at=data.get("created_at"),
|
||||
last_activity=data.get("last_activity"),
|
||||
message_count=len(messages),
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
async def delete(self, chat_id: str) -> bool:
|
||||
"""
|
||||
Delete a conversation.
|
||||
|
||||
Args:
|
||||
chat_id: The ID of the conversation to delete.
|
||||
|
||||
Returns:
|
||||
True if deletion was successful.
|
||||
"""
|
||||
response = await self._client._request("DELETE", f"/api/v1/chat/{chat_id}")
|
||||
data = response.json()
|
||||
return data.get("success", False)
|
||||
|
||||
|
||||
# Import Literal for type hints
|
||||
from typing import Literal
|
||||
194
sdks/python/openrag_sdk/client.py
Normal file
194
sdks/python/openrag_sdk/client.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"""OpenRAG SDK client."""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .chat import ChatClient
|
||||
from .documents import DocumentsClient
|
||||
from .exceptions import (
|
||||
AuthenticationError,
|
||||
NotFoundError,
|
||||
OpenRAGError,
|
||||
RateLimitError,
|
||||
ServerError,
|
||||
ValidationError,
|
||||
)
|
||||
from .search import SearchClient
|
||||
|
||||
|
||||
class SettingsClient:
|
||||
"""Client for settings operations."""
|
||||
|
||||
def __init__(self, client: "OpenRAGClient"):
|
||||
self._client = client
|
||||
|
||||
async def get(self):
|
||||
"""
|
||||
Get current OpenRAG configuration (read-only).
|
||||
|
||||
Returns:
|
||||
SettingsResponse with agent and knowledge settings.
|
||||
"""
|
||||
from .models import SettingsResponse
|
||||
|
||||
response = await self._client._request("GET", "/api/v1/settings")
|
||||
data = response.json()
|
||||
return SettingsResponse(**data)
|
||||
|
||||
|
||||
class OpenRAGClient:
|
||||
"""
|
||||
OpenRAG API client.
|
||||
|
||||
The client can be configured via constructor arguments or environment variables:
|
||||
- OPENRAG_API_KEY: API key for authentication
|
||||
- OPENRAG_URL: Base URL for the OpenRAG API (default: http://localhost:8080)
|
||||
|
||||
Usage:
|
||||
# Using environment variables
|
||||
async with OpenRAGClient() as client:
|
||||
response = await client.chat.create(message="Hello")
|
||||
|
||||
# Using explicit arguments
|
||||
async with OpenRAGClient(api_key="orag_...", base_url="https://api.example.com") as client:
|
||||
response = await client.chat.create(message="Hello")
|
||||
|
||||
# Without context manager
|
||||
client = OpenRAGClient()
|
||||
try:
|
||||
response = await client.chat.create(message="Hello")
|
||||
finally:
|
||||
await client.close()
|
||||
"""
|
||||
|
||||
DEFAULT_BASE_URL = "http://localhost:8080"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the OpenRAG client.
|
||||
|
||||
Args:
|
||||
api_key: API key for authentication. Falls back to OPENRAG_API_KEY env var.
|
||||
base_url: Base URL for the API. Falls back to OPENRAG_URL env var, then default.
|
||||
timeout: Request timeout in seconds (default 30).
|
||||
http_client: Optional custom httpx.AsyncClient instance.
|
||||
"""
|
||||
# Resolve API key from argument or environment
|
||||
self._api_key = api_key or os.environ.get("OPENRAG_API_KEY")
|
||||
if not self._api_key:
|
||||
raise AuthenticationError(
|
||||
"API key is required. Set OPENRAG_API_KEY environment variable or pass api_key argument."
|
||||
)
|
||||
|
||||
# Resolve base URL from argument or environment
|
||||
self._base_url = (
|
||||
base_url
|
||||
or os.environ.get("OPENRAG_URL")
|
||||
or self.DEFAULT_BASE_URL
|
||||
).rstrip("/")
|
||||
|
||||
self._timeout = timeout
|
||||
self._owns_http_client = http_client is None
|
||||
|
||||
# Create or use provided HTTP client
|
||||
if http_client:
|
||||
self._http = http_client
|
||||
else:
|
||||
self._http = httpx.AsyncClient(timeout=timeout)
|
||||
|
||||
# Initialize sub-clients
|
||||
self.chat = ChatClient(self)
|
||||
self.search = SearchClient(self)
|
||||
self.documents = DocumentsClient(self)
|
||||
self.settings = SettingsClient(self)
|
||||
|
||||
@property
|
||||
def _headers(self) -> dict[str, str]:
|
||||
"""Get request headers with authentication."""
|
||||
return {
|
||||
"X-API-Key": self._api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json: dict[str, Any] | None = None,
|
||||
files: dict[str, tuple[str, Any]] | None = None,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Make an authenticated request to the API."""
|
||||
url = f"{self._base_url}{path}"
|
||||
headers = self._headers.copy()
|
||||
|
||||
# Handle file uploads
|
||||
if files:
|
||||
del headers["Content-Type"] # Let httpx set multipart content type
|
||||
response = await self._http.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
files=files,
|
||||
**kwargs,
|
||||
)
|
||||
else:
|
||||
response = await self._http.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._handle_error(response)
|
||||
return response
|
||||
|
||||
def _handle_error(self, response: httpx.Response) -> None:
|
||||
"""Handle error responses."""
|
||||
if response.status_code < 400:
|
||||
return
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
message = data.get("error", response.text)
|
||||
except Exception:
|
||||
message = response.text or f"HTTP {response.status_code}"
|
||||
|
||||
status_code = response.status_code
|
||||
|
||||
if status_code == 401:
|
||||
raise AuthenticationError(message, status_code)
|
||||
elif status_code == 403:
|
||||
raise AuthenticationError(message, status_code)
|
||||
elif status_code == 404:
|
||||
raise NotFoundError(message, status_code)
|
||||
elif status_code == 400:
|
||||
raise ValidationError(message, status_code)
|
||||
elif status_code == 429:
|
||||
raise RateLimitError(message, status_code)
|
||||
elif status_code >= 500:
|
||||
raise ServerError(message, status_code)
|
||||
else:
|
||||
raise OpenRAGError(message, status_code)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the HTTP client."""
|
||||
if self._owns_http_client:
|
||||
await self._http.aclose()
|
||||
|
||||
async def __aenter__(self) -> "OpenRAGClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
await self.close()
|
||||
82
sdks/python/openrag_sdk/documents.py
Normal file
82
sdks/python/openrag_sdk/documents.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""OpenRAG SDK documents client."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, BinaryIO
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import DeleteDocumentResponse, IngestResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client import OpenRAGClient
|
||||
|
||||
|
||||
class DocumentsClient:
|
||||
"""Client for document operations."""
|
||||
|
||||
def __init__(self, client: "OpenRAGClient"):
|
||||
self._client = client
|
||||
|
||||
async def ingest(
|
||||
self,
|
||||
file_path: str | Path | None = None,
|
||||
*,
|
||||
file: BinaryIO | None = None,
|
||||
filename: str | None = None,
|
||||
) -> IngestResponse:
|
||||
"""
|
||||
Ingest a document into the knowledge base.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to ingest.
|
||||
file: File-like object to ingest (alternative to file_path).
|
||||
filename: Filename to use when providing file object.
|
||||
|
||||
Returns:
|
||||
IngestResponse with document_id and chunk count.
|
||||
|
||||
Raises:
|
||||
ValueError: If neither file_path nor file is provided.
|
||||
"""
|
||||
if file_path is not None:
|
||||
path = Path(file_path)
|
||||
with open(path, "rb") as f:
|
||||
files = {"file": (path.name, f)}
|
||||
response = await self._client._request(
|
||||
"POST",
|
||||
"/api/v1/documents/ingest",
|
||||
files=files,
|
||||
)
|
||||
elif file is not None:
|
||||
if filename is None:
|
||||
raise ValueError("filename is required when providing file object")
|
||||
files = {"file": (filename, file)}
|
||||
response = await self._client._request(
|
||||
"POST",
|
||||
"/api/v1/documents/ingest",
|
||||
files=files,
|
||||
)
|
||||
else:
|
||||
raise ValueError("Either file_path or file must be provided")
|
||||
|
||||
data = response.json()
|
||||
return IngestResponse(**data)
|
||||
|
||||
async def delete(self, filename: str) -> DeleteDocumentResponse:
|
||||
"""
|
||||
Delete a document from the knowledge base.
|
||||
|
||||
Args:
|
||||
filename: Name of the file to delete.
|
||||
|
||||
Returns:
|
||||
DeleteDocumentResponse with deleted chunk count.
|
||||
"""
|
||||
response = await self._client._request(
|
||||
"DELETE",
|
||||
"/api/v1/documents",
|
||||
json={"filename": filename},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
return DeleteDocumentResponse(**data)
|
||||
40
sdks/python/openrag_sdk/exceptions.py
Normal file
40
sdks/python/openrag_sdk/exceptions.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""OpenRAG SDK exceptions."""
|
||||
|
||||
|
||||
class OpenRAGError(Exception):
|
||||
"""Base exception for OpenRAG SDK."""
|
||||
|
||||
def __init__(self, message: str, status_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class AuthenticationError(OpenRAGError):
|
||||
"""Raised when API key is invalid or missing."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RateLimitError(OpenRAGError):
|
||||
"""Raised when rate limit is exceeded."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundError(OpenRAGError):
|
||||
"""Raised when a resource is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(OpenRAGError):
|
||||
"""Raised when request validation fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ServerError(OpenRAGError):
|
||||
"""Raised when server returns an error."""
|
||||
|
||||
pass
|
||||
149
sdks/python/openrag_sdk/models.py
Normal file
149
sdks/python/openrag_sdk/models.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""OpenRAG SDK data models."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# Chat models
|
||||
class Source(BaseModel):
|
||||
"""A source document returned in chat/search results."""
|
||||
|
||||
filename: str
|
||||
text: str
|
||||
score: float
|
||||
page: int | None = None
|
||||
mimetype: str | None = None
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
"""Response from a non-streaming chat request."""
|
||||
|
||||
response: str
|
||||
chat_id: str | None = None
|
||||
sources: list[Source] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StreamEvent(BaseModel):
|
||||
"""Base class for streaming events."""
|
||||
|
||||
type: Literal["content", "sources", "done"]
|
||||
|
||||
|
||||
class ContentEvent(StreamEvent):
|
||||
"""A content delta event during streaming."""
|
||||
|
||||
type: Literal["content"] = "content"
|
||||
delta: str
|
||||
|
||||
|
||||
class SourcesEvent(StreamEvent):
|
||||
"""A sources event containing retrieved documents."""
|
||||
|
||||
type: Literal["sources"] = "sources"
|
||||
sources: list[Source]
|
||||
|
||||
|
||||
class DoneEvent(StreamEvent):
|
||||
"""Indicates the stream is complete."""
|
||||
|
||||
type: Literal["done"] = "done"
|
||||
chat_id: str | None = None
|
||||
|
||||
|
||||
# Search models
|
||||
class SearchResult(BaseModel):
|
||||
"""A single search result."""
|
||||
|
||||
filename: str
|
||||
text: str
|
||||
score: float
|
||||
page: int | None = None
|
||||
mimetype: str | None = None
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Response from a search request."""
|
||||
|
||||
results: list[SearchResult]
|
||||
|
||||
|
||||
# Document models
|
||||
class IngestResponse(BaseModel):
|
||||
"""Response from document ingestion."""
|
||||
|
||||
success: bool
|
||||
document_id: str | None = None
|
||||
filename: str | None = None
|
||||
chunks: int = 0
|
||||
|
||||
|
||||
class DeleteDocumentResponse(BaseModel):
|
||||
"""Response from document deletion."""
|
||||
|
||||
success: bool
|
||||
deleted_chunks: int = 0
|
||||
|
||||
|
||||
# Chat history models
|
||||
class Message(BaseModel):
|
||||
"""A message in a conversation."""
|
||||
|
||||
role: str
|
||||
content: str
|
||||
timestamp: str | None = None
|
||||
|
||||
|
||||
class Conversation(BaseModel):
|
||||
"""A conversation summary."""
|
||||
|
||||
chat_id: str
|
||||
title: str = ""
|
||||
created_at: str | None = None
|
||||
last_activity: str | None = None
|
||||
message_count: int = 0
|
||||
|
||||
|
||||
class ConversationDetail(Conversation):
|
||||
"""A conversation with full message history."""
|
||||
|
||||
messages: list[Message] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConversationListResponse(BaseModel):
|
||||
"""Response from listing conversations."""
|
||||
|
||||
conversations: list[Conversation]
|
||||
|
||||
|
||||
# Settings models
|
||||
class AgentSettings(BaseModel):
|
||||
"""Agent configuration settings."""
|
||||
|
||||
llm_provider: str | None = None
|
||||
llm_model: str | None = None
|
||||
|
||||
|
||||
class KnowledgeSettings(BaseModel):
|
||||
"""Knowledge base configuration settings."""
|
||||
|
||||
embedding_provider: str | None = None
|
||||
embedding_model: str | None = None
|
||||
chunk_size: int | None = None
|
||||
chunk_overlap: int | None = None
|
||||
|
||||
|
||||
class SettingsResponse(BaseModel):
|
||||
"""Response from settings endpoint."""
|
||||
|
||||
agent: AgentSettings = Field(default_factory=AgentSettings)
|
||||
knowledge: KnowledgeSettings = Field(default_factory=KnowledgeSettings)
|
||||
|
||||
|
||||
# Request models
|
||||
class SearchFilters(BaseModel):
|
||||
"""Filters for search requests."""
|
||||
|
||||
data_sources: list[str] | None = None
|
||||
document_types: list[str] | None = None
|
||||
60
sdks/python/openrag_sdk/search.py
Normal file
60
sdks/python/openrag_sdk/search.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""OpenRAG SDK search client."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import SearchFilters, SearchResponse, SearchResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client import OpenRAGClient
|
||||
|
||||
|
||||
class SearchClient:
|
||||
"""Client for search operations."""
|
||||
|
||||
def __init__(self, client: "OpenRAGClient"):
|
||||
self._client = client
|
||||
|
||||
async def query(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
filters: SearchFilters | dict[str, Any] | None = None,
|
||||
limit: int = 10,
|
||||
score_threshold: float = 0,
|
||||
) -> SearchResponse:
|
||||
"""
|
||||
Perform semantic search on documents.
|
||||
|
||||
Args:
|
||||
query: The search query text.
|
||||
filters: Optional filters (data_sources, document_types).
|
||||
limit: Maximum number of results (default 10).
|
||||
score_threshold: Minimum score threshold (default 0).
|
||||
|
||||
Returns:
|
||||
SearchResponse containing the search results.
|
||||
"""
|
||||
body: dict[str, Any] = {
|
||||
"query": query,
|
||||
"limit": limit,
|
||||
"score_threshold": score_threshold,
|
||||
}
|
||||
|
||||
if filters:
|
||||
if isinstance(filters, SearchFilters):
|
||||
body["filters"] = filters.model_dump(exclude_none=True)
|
||||
else:
|
||||
body["filters"] = filters
|
||||
|
||||
response = await self._client._request(
|
||||
"POST",
|
||||
"/api/v1/search",
|
||||
json=body,
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
return SearchResponse(
|
||||
results=[SearchResult(**r) for r in data.get("results", [])]
|
||||
)
|
||||
62
sdks/python/pyproject.toml
Normal file
62
sdks/python/pyproject.toml
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "openrag-sdk"
|
||||
version = "0.1.0"
|
||||
description = "Official Python SDK for OpenRAG API"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{ name = "OpenRAG Team" }
|
||||
]
|
||||
keywords = ["openrag", "rag", "retrieval", "ai", "sdk", "api"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"httpx>=0.25.0",
|
||||
"pydantic>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/langflow-ai/openrag"
|
||||
Documentation = "https://docs.openr.ag/sdk/python"
|
||||
Repository = "https://github.com/langflow-ai/openrag"
|
||||
Issues = "https://github.com/langflow-ai/openrag/issues"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["openrag_sdk"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "module"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
strict = true
|
||||
1
sdks/python/tests/__init__.py
Normal file
1
sdks/python/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""OpenRAG SDK tests."""
|
||||
208
sdks/python/tests/test_integration.py
Normal file
208
sdks/python/tests/test_integration.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""
|
||||
Integration tests for OpenRAG Python SDK.
|
||||
|
||||
These tests run against a real OpenRAG instance.
|
||||
Requires: OPENRAG_URL environment variable (defaults to http://localhost:8000)
|
||||
|
||||
Run with: pytest sdks/python/tests/test_integration.py -v
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
# Skip all tests if no OpenRAG instance is available
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("SKIP_SDK_INTEGRATION_TESTS") == "true",
|
||||
reason="SDK integration tests skipped",
|
||||
)
|
||||
|
||||
# Module-level cache for API key (created once, reused)
|
||||
_cached_api_key: str | None = None
|
||||
_base_url = os.environ.get("OPENRAG_URL", "http://localhost:8000")
|
||||
|
||||
|
||||
def get_api_key() -> str:
|
||||
"""Get or create an API key for testing."""
|
||||
global _cached_api_key
|
||||
if _cached_api_key is None:
|
||||
response = httpx.post(
|
||||
f"{_base_url}/keys",
|
||||
json={"name": "SDK Integration Test"},
|
||||
timeout=30.0,
|
||||
)
|
||||
if response.status_code == 401:
|
||||
pytest.skip("Cannot create API key - authentication required")
|
||||
assert response.status_code == 200, f"Failed to create API key: {response.text}"
|
||||
_cached_api_key = response.json()["api_key"]
|
||||
return _cached_api_key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create an OpenRAG client for each test."""
|
||||
from openrag_sdk import OpenRAGClient
|
||||
|
||||
return OpenRAGClient(api_key=get_api_key(), base_url=_base_url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_file(tmp_path) -> Path:
|
||||
"""Create a test file for ingestion with unique content."""
|
||||
import uuid
|
||||
file_path = tmp_path / f"sdk_test_doc_{uuid.uuid4().hex[:8]}.txt"
|
||||
file_path.write_text(
|
||||
f"SDK Integration Test Document {uuid.uuid4()}\n\n"
|
||||
"This document tests the OpenRAG Python SDK.\n"
|
||||
"It contains unique content about purple elephants dancing.\n"
|
||||
)
|
||||
return file_path
|
||||
|
||||
|
||||
class TestSettings:
|
||||
"""Test settings endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_settings(self, client):
|
||||
"""Test getting settings."""
|
||||
settings = await client.settings.get()
|
||||
|
||||
assert settings.agent is not None
|
||||
assert settings.knowledge is not None
|
||||
|
||||
|
||||
class TestDocuments:
|
||||
"""Test document operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_document(self, client, test_file: Path):
|
||||
"""Test document ingestion."""
|
||||
result = await client.documents.ingest(file_path=str(test_file))
|
||||
|
||||
assert result.success is True
|
||||
assert result.chunks > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_document(self, client, test_file: Path):
|
||||
"""Test document deletion."""
|
||||
# First ingest
|
||||
await client.documents.ingest(file_path=str(test_file))
|
||||
|
||||
# Then delete
|
||||
result = await client.documents.delete(test_file.name)
|
||||
|
||||
assert result.success is True
|
||||
|
||||
|
||||
class TestSearch:
|
||||
"""Test search operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_query(self, client, test_file: Path):
|
||||
"""Test search query."""
|
||||
# Ensure document is ingested
|
||||
await client.documents.ingest(file_path=str(test_file))
|
||||
|
||||
# Wait a bit for indexing
|
||||
import asyncio
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Search for unique content
|
||||
results = await client.search.query("purple elephants dancing")
|
||||
|
||||
assert results.results is not None
|
||||
# Note: might be empty if indexing is slow, that's ok for CI
|
||||
|
||||
|
||||
class TestChat:
|
||||
"""Test chat operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_non_streaming(self, client):
|
||||
"""Test non-streaming chat."""
|
||||
response = await client.chat.create(
|
||||
message="Say hello in exactly 3 words."
|
||||
)
|
||||
|
||||
assert response.response is not None
|
||||
assert isinstance(response.response, str)
|
||||
assert len(response.response) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_streaming_create(self, client):
|
||||
"""Test streaming chat with create(stream=True)."""
|
||||
collected_text = ""
|
||||
|
||||
async for event in await client.chat.create(
|
||||
message="Say 'test' and nothing else.",
|
||||
stream=True,
|
||||
):
|
||||
if event.type == "content":
|
||||
collected_text += event.delta
|
||||
|
||||
assert len(collected_text) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_streaming_context_manager(self, client):
|
||||
"""Test streaming chat with stream() context manager."""
|
||||
async with client.chat.stream(
|
||||
message="Say 'hello' and nothing else."
|
||||
) as stream:
|
||||
async for _ in stream:
|
||||
pass
|
||||
|
||||
# Check aggregated properties
|
||||
assert len(stream.text) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_text_stream(self, client):
|
||||
"""Test text_stream helper."""
|
||||
collected = ""
|
||||
|
||||
async with client.chat.stream(
|
||||
message="Say 'world' and nothing else."
|
||||
) as stream:
|
||||
async for text in stream.text_stream:
|
||||
collected += text
|
||||
|
||||
assert len(collected) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_final_text(self, client):
|
||||
"""Test final_text() helper."""
|
||||
async with client.chat.stream(
|
||||
message="Say 'done' and nothing else."
|
||||
) as stream:
|
||||
text = await stream.final_text()
|
||||
|
||||
assert len(text) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_conversation_continuation(self, client):
|
||||
"""Test continuing a conversation."""
|
||||
# First message
|
||||
response1 = await client.chat.create(
|
||||
message="Remember the number 42."
|
||||
)
|
||||
assert response1.chat_id is not None
|
||||
|
||||
# Continue conversation
|
||||
response2 = await client.chat.create(
|
||||
message="What number did I ask you to remember?",
|
||||
chat_id=response1.chat_id,
|
||||
)
|
||||
assert response2.response is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_conversations(self, client):
|
||||
"""Test listing conversations."""
|
||||
# Create a conversation first
|
||||
await client.chat.create(message="Test message for listing.")
|
||||
|
||||
# List conversations
|
||||
result = await client.chat.list()
|
||||
|
||||
assert result.conversations is not None
|
||||
assert isinstance(result.conversations, list)
|
||||
406
sdks/python/uv.lock
generated
Normal file
406
sdks/python/uv.lock
generated
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backports-asyncio-runner"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.11.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openrag-sdk"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.25.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
248
sdks/typescript/README.md
Normal file
248
sdks/typescript/README.md
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
# OpenRAG TypeScript SDK
|
||||
|
||||
Official TypeScript/JavaScript SDK for the [OpenRAG](https://openr.ag) API.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install openrag-sdk
|
||||
# or
|
||||
yarn add openrag-sdk
|
||||
# or
|
||||
pnpm add openrag-sdk
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { OpenRAGClient } from "openrag-sdk";
|
||||
|
||||
// Client auto-discovers OPENRAG_API_KEY and OPENRAG_URL from environment
|
||||
const client = new OpenRAGClient();
|
||||
|
||||
// Simple chat
|
||||
const response = await client.chat.create({ message: "What is RAG?" });
|
||||
console.log(response.response);
|
||||
console.log(`Chat ID: ${response.chatId}`);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The SDK can be configured via environment variables or constructor arguments:
|
||||
|
||||
| Environment Variable | Constructor Option | Description |
|
||||
|---------------------|-------------------|-------------|
|
||||
| `OPENRAG_API_KEY` | `apiKey` | API key for authentication (required) |
|
||||
| `OPENRAG_URL` | `baseUrl` | Base URL for the API (default: `http://localhost:8080`) |
|
||||
|
||||
```typescript
|
||||
// Using environment variables
|
||||
const client = new OpenRAGClient();
|
||||
|
||||
// Using explicit arguments
|
||||
const client = new OpenRAGClient({
|
||||
apiKey: "orag_...",
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
```
|
||||
|
||||
## Chat
|
||||
|
||||
### Non-streaming
|
||||
|
||||
```typescript
|
||||
const response = await client.chat.create({ message: "What is RAG?" });
|
||||
console.log(response.response);
|
||||
console.log(`Chat ID: ${response.chatId}`);
|
||||
|
||||
// Continue conversation
|
||||
const followup = await client.chat.create({
|
||||
message: "Tell me more",
|
||||
chatId: response.chatId,
|
||||
});
|
||||
```
|
||||
|
||||
### Streaming with `create({ stream: true })`
|
||||
|
||||
Returns an async iterator directly:
|
||||
|
||||
```typescript
|
||||
let chatId: string | null = null;
|
||||
for await (const event of await client.chat.create({
|
||||
message: "Explain RAG",
|
||||
stream: true,
|
||||
})) {
|
||||
if (event.type === "content") {
|
||||
process.stdout.write(event.delta);
|
||||
} else if (event.type === "sources") {
|
||||
for (const source of event.sources) {
|
||||
console.log(`\nSource: ${source.filename}`);
|
||||
}
|
||||
} else if (event.type === "done") {
|
||||
chatId = event.chatId;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Streaming with `stream()` (Disposable pattern)
|
||||
|
||||
Provides additional helpers for convenience:
|
||||
|
||||
```typescript
|
||||
// Full event iteration with Disposable pattern
|
||||
{
|
||||
using stream = await client.chat.stream({ message: "Explain RAG" });
|
||||
for await (const event of stream) {
|
||||
if (event.type === "content") {
|
||||
process.stdout.write(event.delta);
|
||||
}
|
||||
}
|
||||
|
||||
// Access aggregated data after iteration
|
||||
console.log(`\nChat ID: ${stream.chatId}`);
|
||||
console.log(`Full text: ${stream.text}`);
|
||||
console.log(`Sources: ${stream.sources}`);
|
||||
}
|
||||
|
||||
// Just text deltas
|
||||
{
|
||||
using stream = await client.chat.stream({ message: "Explain RAG" });
|
||||
for await (const text of stream.textStream) {
|
||||
process.stdout.write(text);
|
||||
}
|
||||
}
|
||||
|
||||
// Get final text directly
|
||||
{
|
||||
using stream = await client.chat.stream({ message: "Explain RAG" });
|
||||
const text = await stream.finalText();
|
||||
console.log(text);
|
||||
}
|
||||
```
|
||||
|
||||
### Conversation History
|
||||
|
||||
```typescript
|
||||
// List all conversations
|
||||
const conversations = await client.chat.list();
|
||||
for (const conv of conversations.conversations) {
|
||||
console.log(`${conv.chatId}: ${conv.title}`);
|
||||
}
|
||||
|
||||
// Get specific conversation with messages
|
||||
const conversation = await client.chat.get(chatId);
|
||||
for (const msg of conversation.messages) {
|
||||
console.log(`${msg.role}: ${msg.content}`);
|
||||
}
|
||||
|
||||
// Delete conversation
|
||||
await client.chat.delete(chatId);
|
||||
```
|
||||
|
||||
## Search
|
||||
|
||||
```typescript
|
||||
// Basic search
|
||||
const results = await client.search.query("document processing");
|
||||
for (const result of results.results) {
|
||||
console.log(`${result.filename} (score: ${result.score})`);
|
||||
console.log(` ${result.text.slice(0, 100)}...`);
|
||||
}
|
||||
|
||||
// Search with filters
|
||||
const results = await client.search.query("API documentation", {
|
||||
filters: {
|
||||
data_sources: ["api-docs.pdf"],
|
||||
document_types: ["application/pdf"],
|
||||
},
|
||||
limit: 5,
|
||||
scoreThreshold: 0.5,
|
||||
});
|
||||
```
|
||||
|
||||
## Documents
|
||||
|
||||
```typescript
|
||||
// Ingest a file (Node.js)
|
||||
const result = await client.documents.ingest({
|
||||
filePath: "./report.pdf",
|
||||
});
|
||||
console.log(`Document ID: ${result.document_id}`);
|
||||
console.log(`Chunks: ${result.chunks}`);
|
||||
|
||||
// Ingest from File object (browser)
|
||||
const file = new File([...], "report.pdf");
|
||||
const result = await client.documents.ingest({
|
||||
file,
|
||||
filename: "report.pdf",
|
||||
});
|
||||
|
||||
// Delete a document
|
||||
const result = await client.documents.delete("report.pdf");
|
||||
console.log(`Deleted ${result.deleted_chunks} chunks`);
|
||||
```
|
||||
|
||||
## Settings
|
||||
|
||||
```typescript
|
||||
const settings = await client.settings.get();
|
||||
console.log(`LLM Provider: ${settings.agent.llm_provider}`);
|
||||
console.log(`LLM Model: ${settings.agent.llm_model}`);
|
||||
console.log(`Embedding Model: ${settings.knowledge.embedding_model}`);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
import {
|
||||
OpenRAGError,
|
||||
AuthenticationError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
RateLimitError,
|
||||
ServerError,
|
||||
} from "openrag-sdk";
|
||||
|
||||
try {
|
||||
const response = await client.chat.create({ message: "Hello" });
|
||||
} catch (e) {
|
||||
if (e instanceof AuthenticationError) {
|
||||
console.log(`Invalid API key: ${e.message}`);
|
||||
} else if (e instanceof NotFoundError) {
|
||||
console.log(`Resource not found: ${e.message}`);
|
||||
} else if (e instanceof ValidationError) {
|
||||
console.log(`Invalid request: ${e.message}`);
|
||||
} else if (e instanceof RateLimitError) {
|
||||
console.log(`Rate limited: ${e.message}`);
|
||||
} else if (e instanceof ServerError) {
|
||||
console.log(`Server error: ${e.message}`);
|
||||
} else if (e instanceof OpenRAGError) {
|
||||
console.log(`API error: ${e.message} (status: ${e.statusCode})`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
|
||||
This SDK works in both Node.js and browser environments. The main difference is file ingestion:
|
||||
|
||||
- **Node.js**: Use `filePath` option
|
||||
- **Browser**: Use `file` option with a `File` or `Blob` object
|
||||
|
||||
## TypeScript
|
||||
|
||||
This SDK is written in TypeScript and provides full type definitions. All types are exported from the main module:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
ChatResponse,
|
||||
StreamEvent,
|
||||
SearchResponse,
|
||||
IngestResponse,
|
||||
SettingsResponse,
|
||||
} from "openrag-sdk";
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
311
sdks/typescript/src/chat.ts
Normal file
311
sdks/typescript/src/chat.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/**
|
||||
* OpenRAG SDK chat client with streaming support.
|
||||
*/
|
||||
|
||||
import type { OpenRAGClient } from "./client";
|
||||
import type {
|
||||
ChatCreateOptions,
|
||||
ChatResponse,
|
||||
ContentEvent,
|
||||
Conversation,
|
||||
ConversationDetail,
|
||||
ConversationListResponse,
|
||||
DoneEvent,
|
||||
Message,
|
||||
SearchFilters,
|
||||
Source,
|
||||
SourcesEvent,
|
||||
StreamEvent,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Streaming chat response with helpers.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* using stream = await client.chat.stream({ message: "Hello" });
|
||||
* for await (const event of stream) {
|
||||
* if (event.type === "content") console.log(event.delta);
|
||||
* }
|
||||
* console.log(stream.chatId);
|
||||
* console.log(stream.text);
|
||||
* ```
|
||||
*/
|
||||
export class ChatStream implements AsyncIterable<StreamEvent>, Disposable {
|
||||
private _text = "";
|
||||
private _chatId: string | null = null;
|
||||
private _sources: Source[] = [];
|
||||
private _consumed = false;
|
||||
private _reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||
private _response: Response | null = null;
|
||||
|
||||
constructor(
|
||||
private client: OpenRAGClient,
|
||||
private options: ChatCreateOptions
|
||||
) {}
|
||||
|
||||
/** The accumulated text from content events. */
|
||||
get text(): string {
|
||||
return this._text;
|
||||
}
|
||||
|
||||
/** The chat ID for continuing the conversation. */
|
||||
get chatId(): string | null {
|
||||
return this._chatId;
|
||||
}
|
||||
|
||||
/** The sources retrieved during the conversation. */
|
||||
get sources(): Source[] {
|
||||
return this._sources;
|
||||
}
|
||||
|
||||
/** @internal Initialize the stream. */
|
||||
async _init(): Promise<void> {
|
||||
const body: Record<string, unknown> = {
|
||||
message: this.options.message,
|
||||
stream: true,
|
||||
limit: this.options.limit ?? 10,
|
||||
score_threshold: this.options.scoreThreshold ?? 0,
|
||||
};
|
||||
|
||||
if (this.options.chatId) {
|
||||
body.chat_id = this.options.chatId;
|
||||
}
|
||||
|
||||
if (this.options.filters) {
|
||||
body.filters = this.options.filters;
|
||||
}
|
||||
|
||||
this._response = await this.client._request("POST", "/api/v1/chat", {
|
||||
body: JSON.stringify(body),
|
||||
stream: true,
|
||||
});
|
||||
|
||||
if (!this._response.body) {
|
||||
throw new Error("Response body is null");
|
||||
}
|
||||
|
||||
this._reader = this._response.body.getReader();
|
||||
}
|
||||
|
||||
async *[Symbol.asyncIterator](): AsyncIterator<StreamEvent> {
|
||||
if (this._consumed) {
|
||||
throw new Error("Stream has already been consumed");
|
||||
}
|
||||
this._consumed = true;
|
||||
|
||||
if (!this._reader) {
|
||||
throw new Error("Stream not initialized");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await this._reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !trimmed.startsWith("data:")) continue;
|
||||
|
||||
const dataStr = trimmed.slice(5).trim();
|
||||
if (!dataStr) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
const eventType = data.type;
|
||||
|
||||
if (eventType === "content") {
|
||||
const delta = data.delta || "";
|
||||
this._text += delta;
|
||||
yield { type: "content", delta } as ContentEvent;
|
||||
} else if (eventType === "sources") {
|
||||
this._sources = data.sources || [];
|
||||
yield { type: "sources", sources: this._sources } as SourcesEvent;
|
||||
} else if (eventType === "done") {
|
||||
this._chatId = data.chat_id || null;
|
||||
yield { type: "done", chatId: this._chatId } as DoneEvent;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over just the text deltas.
|
||||
*/
|
||||
get textStream(): AsyncIterable<string> {
|
||||
const self = this;
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for await (const event of self) {
|
||||
if (event.type === "content") {
|
||||
yield event.delta;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume the stream and return the complete text.
|
||||
*/
|
||||
async finalText(): Promise<string> {
|
||||
for await (const _ of this) {
|
||||
// Consume all events
|
||||
}
|
||||
return this._text;
|
||||
}
|
||||
|
||||
/** Clean up resources. */
|
||||
[Symbol.dispose](): void {
|
||||
this._reader?.cancel().catch(() => {});
|
||||
}
|
||||
|
||||
/** Close the stream. */
|
||||
close(): void {
|
||||
this[Symbol.dispose]();
|
||||
}
|
||||
}
|
||||
|
||||
export class ChatClient {
|
||||
constructor(private client: OpenRAGClient) {}
|
||||
|
||||
/**
|
||||
* Send a chat message (non-streaming).
|
||||
*/
|
||||
async create(options: ChatCreateOptions & { stream?: false }): Promise<ChatResponse>;
|
||||
/**
|
||||
* Send a chat message (streaming).
|
||||
*/
|
||||
async create(
|
||||
options: ChatCreateOptions & { stream: true }
|
||||
): Promise<AsyncIterable<StreamEvent>>;
|
||||
/**
|
||||
* Send a chat message.
|
||||
*
|
||||
* @param options - Chat options including message, stream flag, etc.
|
||||
* @returns ChatResponse if stream=false, AsyncIterable<StreamEvent> if stream=true.
|
||||
*/
|
||||
async create(
|
||||
options: ChatCreateOptions
|
||||
): Promise<ChatResponse | AsyncIterable<StreamEvent>> {
|
||||
if (options.stream) {
|
||||
return this._createStreamingIterator(options);
|
||||
}
|
||||
return this._createNonStreaming(options);
|
||||
}
|
||||
|
||||
private async _createNonStreaming(options: ChatCreateOptions): Promise<ChatResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
message: options.message,
|
||||
stream: false,
|
||||
limit: options.limit ?? 10,
|
||||
score_threshold: options.scoreThreshold ?? 0,
|
||||
};
|
||||
|
||||
if (options.chatId) {
|
||||
body.chat_id = options.chatId;
|
||||
}
|
||||
|
||||
if (options.filters) {
|
||||
body.filters = options.filters;
|
||||
}
|
||||
|
||||
const response = await this.client._request("POST", "/api/v1/chat", {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
response: data.response || "",
|
||||
chatId: data.chat_id || null,
|
||||
sources: data.sources || [],
|
||||
};
|
||||
}
|
||||
|
||||
private async _createStreamingIterator(
|
||||
options: ChatCreateOptions
|
||||
): Promise<AsyncIterable<StreamEvent>> {
|
||||
const stream = new ChatStream(this.client, options);
|
||||
await stream._init();
|
||||
return stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a streaming chat context manager.
|
||||
*
|
||||
* @param options - Chat options.
|
||||
* @returns ChatStream with helpers.
|
||||
*/
|
||||
async stream(options: Omit<ChatCreateOptions, "stream">): Promise<ChatStream> {
|
||||
const stream = new ChatStream(this.client, { ...options, stream: true });
|
||||
await stream._init();
|
||||
return stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all conversations.
|
||||
*/
|
||||
async list(): Promise<ConversationListResponse> {
|
||||
const response = await this.client._request("GET", "/api/v1/chat");
|
||||
const data = await response.json();
|
||||
|
||||
const conversations: Conversation[] = (data.conversations || []).map(
|
||||
(c: Record<string, unknown>) => ({
|
||||
chatId: c.chat_id,
|
||||
title: c.title || "",
|
||||
createdAt: c.created_at || null,
|
||||
lastActivity: c.last_activity || null,
|
||||
messageCount: c.message_count || 0,
|
||||
})
|
||||
);
|
||||
|
||||
return { conversations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific conversation with full message history.
|
||||
*
|
||||
* @param chatId - The ID of the conversation to retrieve.
|
||||
*/
|
||||
async get(chatId: string): Promise<ConversationDetail> {
|
||||
const response = await this.client._request("GET", `/api/v1/chat/${chatId}`);
|
||||
const data = await response.json();
|
||||
|
||||
const messages: Message[] = (data.messages || []).map(
|
||||
(m: Record<string, unknown>) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp || null,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
chatId: data.chat_id || chatId,
|
||||
title: data.title || "",
|
||||
createdAt: data.created_at || null,
|
||||
lastActivity: data.last_activity || null,
|
||||
messageCount: messages.length,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a conversation.
|
||||
*
|
||||
* @param chatId - The ID of the conversation to delete.
|
||||
*/
|
||||
async delete(chatId: string): Promise<boolean> {
|
||||
const response = await this.client._request("DELETE", `/api/v1/chat/${chatId}`);
|
||||
const data = await response.json();
|
||||
return data.success ?? false;
|
||||
}
|
||||
}
|
||||
188
sdks/typescript/src/client.ts
Normal file
188
sdks/typescript/src/client.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* OpenRAG SDK client.
|
||||
*/
|
||||
|
||||
import { ChatClient } from "./chat";
|
||||
import { DocumentsClient } from "./documents";
|
||||
import { SearchClient } from "./search";
|
||||
import {
|
||||
AuthenticationError,
|
||||
NotFoundError,
|
||||
OpenRAGError,
|
||||
OpenRAGClientOptions,
|
||||
RateLimitError,
|
||||
ServerError,
|
||||
SettingsResponse,
|
||||
ValidationError,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Get environment variable value.
|
||||
* Works in Node.js and environments with process.env.
|
||||
*/
|
||||
function getEnv(key: string): string | undefined {
|
||||
if (typeof globalThis.process !== "undefined" && globalThis.process.env) {
|
||||
return globalThis.process.env[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
class SettingsClient {
|
||||
constructor(private client: OpenRAGClient) {}
|
||||
|
||||
/**
|
||||
* Get current OpenRAG configuration (read-only).
|
||||
*/
|
||||
async get(): Promise<SettingsResponse> {
|
||||
const response = await this.client._request("GET", "/api/v1/settings");
|
||||
const data = await response.json();
|
||||
return {
|
||||
agent: data.agent || {},
|
||||
knowledge: data.knowledge || {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
body?: string | FormData;
|
||||
isMultipart?: boolean;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenRAG API client.
|
||||
*
|
||||
* The client can be configured via constructor arguments or environment variables:
|
||||
* - OPENRAG_API_KEY: API key for authentication
|
||||
* - OPENRAG_URL: Base URL for the OpenRAG API (default: http://localhost:8080)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Using environment variables
|
||||
* const client = new OpenRAGClient();
|
||||
* const response = await client.chat.create({ message: "Hello" });
|
||||
*
|
||||
* // Using explicit arguments
|
||||
* const client = new OpenRAGClient({
|
||||
* apiKey: "orag_...",
|
||||
* baseUrl: "https://api.example.com"
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class OpenRAGClient {
|
||||
private static readonly DEFAULT_BASE_URL = "http://localhost:8080";
|
||||
|
||||
private readonly _apiKey: string;
|
||||
private readonly _baseUrl: string;
|
||||
private readonly _timeout: number;
|
||||
|
||||
/** Chat client for conversations. */
|
||||
readonly chat: ChatClient;
|
||||
/** Search client for semantic search. */
|
||||
readonly search: SearchClient;
|
||||
/** Documents client for ingestion and deletion. */
|
||||
readonly documents: DocumentsClient;
|
||||
/** Settings client for configuration. */
|
||||
readonly settings: SettingsClient;
|
||||
|
||||
constructor(options: OpenRAGClientOptions = {}) {
|
||||
// Resolve API key from argument or environment
|
||||
this._apiKey = options.apiKey || getEnv("OPENRAG_API_KEY") || "";
|
||||
if (!this._apiKey) {
|
||||
throw new AuthenticationError(
|
||||
"API key is required. Set OPENRAG_API_KEY environment variable or pass apiKey option."
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve base URL from argument or environment
|
||||
this._baseUrl = (
|
||||
options.baseUrl ||
|
||||
getEnv("OPENRAG_URL") ||
|
||||
OpenRAGClient.DEFAULT_BASE_URL
|
||||
).replace(/\/$/, "");
|
||||
|
||||
this._timeout = options.timeout ?? 30000;
|
||||
|
||||
// Initialize sub-clients
|
||||
this.chat = new ChatClient(this);
|
||||
this.search = new SearchClient(this);
|
||||
this.documents = new DocumentsClient(this);
|
||||
this.settings = new SettingsClient(this);
|
||||
}
|
||||
|
||||
/** @internal Get request headers with authentication. */
|
||||
_getHeaders(isMultipart = false): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"X-API-Key": this._apiKey,
|
||||
};
|
||||
|
||||
if (!isMultipart) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** @internal Make an authenticated request to the API. */
|
||||
async _request(
|
||||
method: string,
|
||||
path: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<Response> {
|
||||
const url = `${this._baseUrl}${path}`;
|
||||
const headers = this._getHeaders(options.isMultipart);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this._timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: options.body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!options.stream) {
|
||||
this._handleError(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Handle error responses. */
|
||||
_handleError(response: Response): void {
|
||||
if (response.ok) return;
|
||||
|
||||
const statusCode = response.status;
|
||||
|
||||
// We can't await the JSON here since this might be called in a sync context
|
||||
// So we throw with a generic message based on status code
|
||||
const errorMessages: Record<number, string> = {
|
||||
401: "Invalid or missing API key",
|
||||
403: "Access denied",
|
||||
404: "Resource not found",
|
||||
400: "Invalid request",
|
||||
429: "Rate limit exceeded",
|
||||
};
|
||||
|
||||
const message = errorMessages[statusCode] || `HTTP ${statusCode}`;
|
||||
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
throw new AuthenticationError(message, statusCode);
|
||||
} else if (statusCode === 404) {
|
||||
throw new NotFoundError(message, statusCode);
|
||||
} else if (statusCode === 400) {
|
||||
throw new ValidationError(message, statusCode);
|
||||
} else if (statusCode === 429) {
|
||||
throw new RateLimitError(message, statusCode);
|
||||
} else if (statusCode >= 500) {
|
||||
throw new ServerError(message, statusCode);
|
||||
} else {
|
||||
throw new OpenRAGError(message, statusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
85
sdks/typescript/src/documents.ts
Normal file
85
sdks/typescript/src/documents.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* OpenRAG SDK documents client.
|
||||
*/
|
||||
|
||||
import type { OpenRAGClient } from "./client";
|
||||
import type { DeleteDocumentResponse, IngestResponse } from "./types";
|
||||
|
||||
export interface IngestOptions {
|
||||
/** Path to file (Node.js only). */
|
||||
filePath?: string;
|
||||
/** File object (browser or Node.js). */
|
||||
file?: File | Blob;
|
||||
/** Filename when providing file/blob. */
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export class DocumentsClient {
|
||||
constructor(private client: OpenRAGClient) {}
|
||||
|
||||
/**
|
||||
* Ingest a document into the knowledge base.
|
||||
*
|
||||
* @param options - Ingest options (filePath or file+filename).
|
||||
* @returns IngestResponse with document_id and chunk count.
|
||||
*/
|
||||
async ingest(options: IngestOptions): Promise<IngestResponse> {
|
||||
const formData = new FormData();
|
||||
|
||||
if (options.filePath) {
|
||||
// Node.js: read file from path
|
||||
if (typeof globalThis.process !== "undefined") {
|
||||
const fs = await import("fs");
|
||||
const path = await import("path");
|
||||
const fileBuffer = fs.readFileSync(options.filePath);
|
||||
const filename = path.basename(options.filePath);
|
||||
const blob = new Blob([fileBuffer]);
|
||||
formData.append("file", blob, filename);
|
||||
} else {
|
||||
throw new Error("filePath is only supported in Node.js");
|
||||
}
|
||||
} else if (options.file) {
|
||||
if (!options.filename) {
|
||||
throw new Error("filename is required when providing file");
|
||||
}
|
||||
formData.append("file", options.file, options.filename);
|
||||
} else {
|
||||
throw new Error("Either filePath or file must be provided");
|
||||
}
|
||||
|
||||
const response = await this.client._request(
|
||||
"POST",
|
||||
"/api/v1/documents/ingest",
|
||||
{
|
||||
body: formData,
|
||||
isMultipart: true,
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: data.success ?? false,
|
||||
document_id: data.document_id ?? null,
|
||||
filename: data.filename ?? null,
|
||||
chunks: data.chunks ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a document from the knowledge base.
|
||||
*
|
||||
* @param filename - Name of the file to delete.
|
||||
* @returns DeleteDocumentResponse with deleted chunk count.
|
||||
*/
|
||||
async delete(filename: string): Promise<DeleteDocumentResponse> {
|
||||
const response = await this.client._request("DELETE", "/api/v1/documents", {
|
||||
body: JSON.stringify({ filename }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: data.success ?? false,
|
||||
deleted_chunks: data.deleted_chunks ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
76
sdks/typescript/src/index.ts
Normal file
76
sdks/typescript/src/index.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* OpenRAG TypeScript SDK.
|
||||
*
|
||||
* A TypeScript/JavaScript client library for the OpenRAG API.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { OpenRAGClient } from "openrag-sdk";
|
||||
*
|
||||
* // Using environment variables (OPENRAG_API_KEY, OPENRAG_URL)
|
||||
* const client = new OpenRAGClient();
|
||||
*
|
||||
* // Non-streaming chat
|
||||
* const response = await client.chat.create({ message: "What is RAG?" });
|
||||
* console.log(response.response);
|
||||
*
|
||||
* // Streaming chat with context manager (using Disposable)
|
||||
* using stream = await client.chat.stream({ message: "Explain RAG" });
|
||||
* for await (const text of stream.textStream) {
|
||||
* process.stdout.write(text);
|
||||
* }
|
||||
*
|
||||
* // Search
|
||||
* const results = await client.search.query("document processing");
|
||||
*
|
||||
* // Ingest document
|
||||
* await client.documents.ingest({ filePath: "./report.pdf" });
|
||||
*
|
||||
* // Get settings
|
||||
* const settings = await client.settings.get();
|
||||
* ```
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export { OpenRAGClient } from "./client";
|
||||
export { ChatClient, ChatStream } from "./chat";
|
||||
export { SearchClient } from "./search";
|
||||
export { DocumentsClient } from "./documents";
|
||||
|
||||
export {
|
||||
// Error types
|
||||
OpenRAGError,
|
||||
AuthenticationError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
RateLimitError,
|
||||
ServerError,
|
||||
// Request/Response types
|
||||
OpenRAGClientOptions,
|
||||
ChatCreateOptions,
|
||||
SearchQueryOptions,
|
||||
SearchFilters,
|
||||
// Chat types
|
||||
ChatResponse,
|
||||
StreamEvent,
|
||||
ContentEvent,
|
||||
SourcesEvent,
|
||||
DoneEvent,
|
||||
Source,
|
||||
// Search types
|
||||
SearchResponse,
|
||||
SearchResult,
|
||||
// Document types
|
||||
IngestResponse,
|
||||
DeleteDocumentResponse,
|
||||
// Conversation types
|
||||
Conversation,
|
||||
ConversationDetail,
|
||||
ConversationListResponse,
|
||||
Message,
|
||||
// Settings types
|
||||
SettingsResponse,
|
||||
AgentSettings,
|
||||
KnowledgeSettings,
|
||||
} from "./types";
|
||||
41
sdks/typescript/src/search.ts
Normal file
41
sdks/typescript/src/search.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* OpenRAG SDK search client.
|
||||
*/
|
||||
|
||||
import type { OpenRAGClient } from "./client";
|
||||
import type { SearchFilters, SearchQueryOptions, SearchResponse } from "./types";
|
||||
|
||||
export class SearchClient {
|
||||
constructor(private client: OpenRAGClient) {}
|
||||
|
||||
/**
|
||||
* Perform semantic search on documents.
|
||||
*
|
||||
* @param query - The search query text.
|
||||
* @param options - Optional search options.
|
||||
* @returns SearchResponse containing the search results.
|
||||
*/
|
||||
async query(
|
||||
query: string,
|
||||
options?: Omit<SearchQueryOptions, "query">
|
||||
): Promise<SearchResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
query,
|
||||
limit: options?.limit ?? 10,
|
||||
score_threshold: options?.scoreThreshold ?? 0,
|
||||
};
|
||||
|
||||
if (options?.filters) {
|
||||
body.filters = options.filters;
|
||||
}
|
||||
|
||||
const response = await this.client._request("POST", "/api/v1/search", {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
results: data.results || [],
|
||||
};
|
||||
}
|
||||
}
|
||||
182
sdks/typescript/src/types.ts
Normal file
182
sdks/typescript/src/types.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/**
|
||||
* OpenRAG SDK types.
|
||||
*/
|
||||
|
||||
// Chat types
|
||||
export interface Source {
|
||||
filename: string;
|
||||
text: string;
|
||||
score: number;
|
||||
page?: number | null;
|
||||
mimetype?: string | null;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
response: string;
|
||||
chatId?: string | null;
|
||||
sources: Source[];
|
||||
}
|
||||
|
||||
export type StreamEventType = "content" | "sources" | "done";
|
||||
|
||||
export interface ContentEvent {
|
||||
type: "content";
|
||||
delta: string;
|
||||
}
|
||||
|
||||
export interface SourcesEvent {
|
||||
type: "sources";
|
||||
sources: Source[];
|
||||
}
|
||||
|
||||
export interface DoneEvent {
|
||||
type: "done";
|
||||
chatId?: string | null;
|
||||
}
|
||||
|
||||
export type StreamEvent = ContentEvent | SourcesEvent | DoneEvent;
|
||||
|
||||
// Search types
|
||||
export interface SearchResult {
|
||||
filename: string;
|
||||
text: string;
|
||||
score: number;
|
||||
page?: number | null;
|
||||
mimetype?: string | null;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
results: SearchResult[];
|
||||
}
|
||||
|
||||
export interface SearchFilters {
|
||||
data_sources?: string[];
|
||||
document_types?: string[];
|
||||
}
|
||||
|
||||
// Document types
|
||||
export interface IngestResponse {
|
||||
success: boolean;
|
||||
document_id?: string | null;
|
||||
filename?: string | null;
|
||||
chunks: number;
|
||||
}
|
||||
|
||||
export interface DeleteDocumentResponse {
|
||||
success: boolean;
|
||||
deleted_chunks: number;
|
||||
}
|
||||
|
||||
// Chat history types
|
||||
export interface Message {
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp?: string | null;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
chatId: string;
|
||||
title: string;
|
||||
createdAt?: string | null;
|
||||
lastActivity?: string | null;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
export interface ConversationDetail extends Conversation {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export interface ConversationListResponse {
|
||||
conversations: Conversation[];
|
||||
}
|
||||
|
||||
// Settings types
|
||||
export interface AgentSettings {
|
||||
llm_provider?: string | null;
|
||||
llm_model?: string | null;
|
||||
}
|
||||
|
||||
export interface KnowledgeSettings {
|
||||
embedding_provider?: string | null;
|
||||
embedding_model?: string | null;
|
||||
chunk_size?: number | null;
|
||||
chunk_overlap?: number | null;
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
agent: AgentSettings;
|
||||
knowledge: KnowledgeSettings;
|
||||
}
|
||||
|
||||
// Client options
|
||||
export interface OpenRAGClientOptions {
|
||||
/** API key for authentication. Falls back to OPENRAG_API_KEY env var. */
|
||||
apiKey?: string;
|
||||
/** Base URL for the API. Falls back to OPENRAG_URL env var. */
|
||||
baseUrl?: string;
|
||||
/** Request timeout in milliseconds (default 30000). */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// Request types
|
||||
export interface ChatCreateOptions {
|
||||
message: string;
|
||||
stream?: boolean;
|
||||
chatId?: string;
|
||||
filters?: SearchFilters;
|
||||
limit?: number;
|
||||
scoreThreshold?: number;
|
||||
}
|
||||
|
||||
export interface SearchQueryOptions {
|
||||
query: string;
|
||||
filters?: SearchFilters;
|
||||
limit?: number;
|
||||
scoreThreshold?: number;
|
||||
}
|
||||
|
||||
// Error types
|
||||
export class OpenRAGError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode?: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = "OpenRAGError";
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthenticationError extends OpenRAGError {
|
||||
constructor(message: string, statusCode?: number) {
|
||||
super(message, statusCode);
|
||||
this.name = "AuthenticationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends OpenRAGError {
|
||||
constructor(message: string, statusCode?: number) {
|
||||
super(message, statusCode);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends OpenRAGError {
|
||||
constructor(message: string, statusCode?: number) {
|
||||
super(message, statusCode);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends OpenRAGError {
|
||||
constructor(message: string, statusCode?: number) {
|
||||
super(message, statusCode);
|
||||
this.name = "RateLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerError extends OpenRAGError {
|
||||
constructor(message: string, statusCode?: number) {
|
||||
super(message, statusCode);
|
||||
this.name = "ServerError";
|
||||
}
|
||||
}
|
||||
214
sdks/typescript/tests/integration.test.ts
Normal file
214
sdks/typescript/tests/integration.test.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* Integration tests for OpenRAG TypeScript SDK.
|
||||
*
|
||||
* These tests run against a real OpenRAG instance.
|
||||
* Requires: OPENRAG_URL environment variable (defaults to http://localhost:3000)
|
||||
*
|
||||
* Run with: npm test
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
|
||||
// Dynamic import to handle the SDK not being built yet
|
||||
let OpenRAGClient: typeof import("../src").OpenRAGClient;
|
||||
|
||||
const BASE_URL = process.env.OPENRAG_URL || "http://localhost:3000";
|
||||
const SKIP_TESTS = process.env.SKIP_SDK_INTEGRATION_TESTS === "true";
|
||||
|
||||
// Create API key for tests
|
||||
async function createApiKey(): Promise<string> {
|
||||
const response = await fetch(`${BASE_URL}/keys`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "TypeScript SDK Integration Test" }),
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error("Cannot create API key - authentication required");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create API key: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.api_key;
|
||||
}
|
||||
|
||||
// Create test file
|
||||
function createTestFile(): string {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sdk-test-"));
|
||||
const filePath = path.join(tmpDir, "sdk_test_doc.md");
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
"# SDK Integration Test Document\n\n" +
|
||||
"This document tests the OpenRAG TypeScript SDK.\n" +
|
||||
"It contains unique content about orange kangaroos jumping.\n"
|
||||
);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
describe.skipIf(SKIP_TESTS)("OpenRAG TypeScript SDK Integration", () => {
|
||||
let client: InstanceType<typeof OpenRAGClient>;
|
||||
let testFilePath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Import SDK
|
||||
const sdk = await import("../src");
|
||||
OpenRAGClient = sdk.OpenRAGClient;
|
||||
|
||||
// Create API key and client
|
||||
const apiKey = await createApiKey();
|
||||
client = new OpenRAGClient({ apiKey, baseUrl: BASE_URL });
|
||||
|
||||
// Create test file
|
||||
testFilePath = createTestFile();
|
||||
});
|
||||
|
||||
describe("Settings", () => {
|
||||
it("should get settings", async () => {
|
||||
const settings = await client.settings.get();
|
||||
|
||||
expect(settings.agent).toBeDefined();
|
||||
expect(settings.knowledge).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Documents", () => {
|
||||
it("should ingest a document", async () => {
|
||||
const result = await client.documents.ingest({ filePath: testFilePath });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.chunks).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should delete a document", async () => {
|
||||
// First ingest
|
||||
await client.documents.ingest({ filePath: testFilePath });
|
||||
|
||||
// Then delete
|
||||
const result = await client.documents.delete(path.basename(testFilePath));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Search", () => {
|
||||
it("should search documents", async () => {
|
||||
// Ensure document is ingested
|
||||
await client.documents.ingest({ filePath: testFilePath });
|
||||
|
||||
// Wait for indexing
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Search
|
||||
const results = await client.search.query("orange kangaroos jumping");
|
||||
|
||||
expect(results.results).toBeDefined();
|
||||
expect(Array.isArray(results.results)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat", () => {
|
||||
it("should send non-streaming chat", async () => {
|
||||
const response = await client.chat.create({
|
||||
message: "Say hello in exactly 3 words.",
|
||||
});
|
||||
|
||||
expect(response.response).toBeDefined();
|
||||
expect(typeof response.response).toBe("string");
|
||||
expect(response.response.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should stream chat with create({ stream: true })", async () => {
|
||||
let collectedText = "";
|
||||
|
||||
for await (const event of await client.chat.create({
|
||||
message: "Say 'test' and nothing else.",
|
||||
stream: true,
|
||||
})) {
|
||||
if (event.type === "content") {
|
||||
collectedText += event.delta;
|
||||
}
|
||||
}
|
||||
|
||||
expect(collectedText.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should stream chat with stream() context manager", async () => {
|
||||
const stream = await client.chat.stream({
|
||||
message: "Say 'hello' and nothing else.",
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _ of stream) {
|
||||
// Consume stream
|
||||
}
|
||||
|
||||
expect(stream.text.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
stream.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should use textStream helper", async () => {
|
||||
let collected = "";
|
||||
|
||||
const stream = await client.chat.stream({
|
||||
message: "Say 'world' and nothing else.",
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const text of stream.textStream) {
|
||||
collected += text;
|
||||
}
|
||||
|
||||
expect(collected.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
stream.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should use finalText() helper", async () => {
|
||||
const stream = await client.chat.stream({
|
||||
message: "Say 'done' and nothing else.",
|
||||
});
|
||||
|
||||
try {
|
||||
const text = await stream.finalText();
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
stream.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should continue a conversation", async () => {
|
||||
// First message
|
||||
const response1 = await client.chat.create({
|
||||
message: "Remember the number 99.",
|
||||
});
|
||||
expect(response1.chatId).toBeDefined();
|
||||
|
||||
// Continue conversation
|
||||
const response2 = await client.chat.create({
|
||||
message: "What number did I ask you to remember?",
|
||||
chatId: response1.chatId!,
|
||||
});
|
||||
expect(response2.response).toBeDefined();
|
||||
});
|
||||
|
||||
it("should list conversations", async () => {
|
||||
// Create a conversation first
|
||||
await client.chat.create({ message: "Test message for listing." });
|
||||
|
||||
// List conversations
|
||||
const result = await client.chat.list();
|
||||
|
||||
expect(result.conversations).toBeDefined();
|
||||
expect(Array.isArray(result.conversations)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
12
sdks/typescript/tsup.config.ts
Normal file
12
sdks/typescript/tsup.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
splitting: false,
|
||||
treeshake: true,
|
||||
minify: false,
|
||||
});
|
||||
9
sdks/typescript/vitest.config.ts
Normal file
9
sdks/typescript/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 60000, // 60 second timeout for integration tests
|
||||
hookTimeout: 60000,
|
||||
},
|
||||
});
|
||||
170
src/api/keys.py
Normal file
170
src/api/keys.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""
|
||||
API Key management endpoints.
|
||||
|
||||
These endpoints use JWT cookie authentication (for the UI) and allow users
|
||||
to create, list, and revoke their API keys for use with the public API.
|
||||
"""
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def list_keys_endpoint(request: Request, api_key_service):
|
||||
"""
|
||||
List all API keys for the authenticated user.
|
||||
|
||||
GET /keys
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"keys": [
|
||||
{
|
||||
"key_id": "...",
|
||||
"key_prefix": "orag_abc12345",
|
||||
"name": "My Key",
|
||||
"created_at": "2024-01-01T00:00:00",
|
||||
"last_used_at": "2024-01-02T00:00:00",
|
||||
"revoked": false
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
user = request.state.user
|
||||
user_id = user.user_id
|
||||
jwt_token = request.state.jwt_token
|
||||
|
||||
result = await api_key_service.list_keys(user_id, jwt_token)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
async def create_key_endpoint(request: Request, api_key_service):
|
||||
"""
|
||||
Create a new API key for the authenticated user.
|
||||
|
||||
POST /keys
|
||||
Body: {"name": "My API Key"}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"key_id": "...",
|
||||
"key_prefix": "orag_abc12345",
|
||||
"name": "My API Key",
|
||||
"created_at": "2024-01-01T00:00:00",
|
||||
"api_key": "orag_abc12345..." // Full key, only shown once!
|
||||
}
|
||||
"""
|
||||
user = request.state.user
|
||||
user_id = user.user_id
|
||||
user_email = user.email
|
||||
jwt_token = request.state.jwt_token
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
name = data.get("name", "").strip()
|
||||
|
||||
if not name:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Name is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if len(name) > 100:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Name must be 100 characters or less"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
result = await api_key_service.create_key(
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
name=name,
|
||||
jwt_token=jwt_token,
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
return JSONResponse(result)
|
||||
else:
|
||||
return JSONResponse(result, status_code=500)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create API key", error=str(e), user_id=user_id)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": str(e)},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def revoke_key_endpoint(request: Request, api_key_service):
|
||||
"""
|
||||
Revoke an API key.
|
||||
|
||||
DELETE /keys/{key_id}
|
||||
|
||||
Response:
|
||||
{"success": true}
|
||||
"""
|
||||
user = request.state.user
|
||||
user_id = user.user_id
|
||||
jwt_token = request.state.jwt_token
|
||||
key_id = request.path_params.get("key_id")
|
||||
|
||||
if not key_id:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Key ID is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
result = await api_key_service.revoke_key(
|
||||
user_id=user_id,
|
||||
key_id=key_id,
|
||||
jwt_token=jwt_token,
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
return JSONResponse(result)
|
||||
elif result.get("error") == "Not authorized to revoke this key":
|
||||
return JSONResponse(result, status_code=403)
|
||||
elif result.get("error") == "Key not found":
|
||||
return JSONResponse(result, status_code=404)
|
||||
else:
|
||||
return JSONResponse(result, status_code=500)
|
||||
|
||||
|
||||
async def delete_key_endpoint(request: Request, api_key_service):
|
||||
"""
|
||||
Permanently delete an API key.
|
||||
|
||||
DELETE /keys/{key_id}/permanent
|
||||
|
||||
Response:
|
||||
{"success": true}
|
||||
"""
|
||||
user = request.state.user
|
||||
user_id = user.user_id
|
||||
jwt_token = request.state.jwt_token
|
||||
key_id = request.path_params.get("key_id")
|
||||
|
||||
if not key_id:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Key ID is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
result = await api_key_service.delete_key(
|
||||
user_id=user_id,
|
||||
key_id=key_id,
|
||||
jwt_token=jwt_token,
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
return JSONResponse(result)
|
||||
elif result.get("error") == "Not authorized to delete this key":
|
||||
return JSONResponse(result, status_code=403)
|
||||
elif result.get("error") == "Key not found":
|
||||
return JSONResponse(result, status_code=404)
|
||||
else:
|
||||
return JSONResponse(result, status_code=500)
|
||||
6
src/api/v1/__init__.py
Normal file
6
src/api/v1/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""
|
||||
OpenRAG Public API v1
|
||||
|
||||
This module contains the public API endpoints that use API key authentication.
|
||||
These endpoints provide a clean, versioned interface for external integrations.
|
||||
"""
|
||||
373
src/api/v1/chat.py
Normal file
373
src/api/v1/chat.py
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
"""
|
||||
Public API v1 Chat endpoint.
|
||||
|
||||
Provides chat functionality with streaming support and conversation history.
|
||||
Uses API key authentication.
|
||||
"""
|
||||
import json
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, StreamingResponse
|
||||
from utils.logging_config import get_logger
|
||||
from auth_context import set_search_filters, set_search_limit, set_score_threshold, set_auth_context
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def _transform_stream_to_sse(raw_stream, chat_id_container: dict):
|
||||
"""
|
||||
Transform the raw internal streaming format to clean SSE events.
|
||||
|
||||
Yields SSE events in the format:
|
||||
event: content
|
||||
data: {"type": "content", "delta": "..."}
|
||||
|
||||
event: sources
|
||||
data: {"type": "sources", "sources": [...]}
|
||||
|
||||
event: done
|
||||
data: {"type": "done", "chat_id": "..."}
|
||||
"""
|
||||
full_text = ""
|
||||
sources = []
|
||||
chat_id = None
|
||||
|
||||
async for chunk in raw_stream:
|
||||
try:
|
||||
# Decode the chunk
|
||||
if isinstance(chunk, bytes):
|
||||
chunk_str = chunk.decode("utf-8").strip()
|
||||
else:
|
||||
chunk_str = str(chunk).strip()
|
||||
|
||||
if not chunk_str:
|
||||
continue
|
||||
|
||||
# Parse the JSON chunk
|
||||
chunk_data = json.loads(chunk_str)
|
||||
|
||||
# Extract text delta
|
||||
delta_text = None
|
||||
if "delta" in chunk_data:
|
||||
delta = chunk_data["delta"]
|
||||
if isinstance(delta, dict):
|
||||
delta_text = delta.get("content") or delta.get("text") or ""
|
||||
elif isinstance(delta, str):
|
||||
delta_text = delta
|
||||
|
||||
if "output_text" in chunk_data and chunk_data["output_text"]:
|
||||
delta_text = chunk_data["output_text"]
|
||||
|
||||
# Yield content event if we have text
|
||||
if delta_text:
|
||||
full_text += delta_text
|
||||
event = {"type": "content", "delta": delta_text}
|
||||
yield f"event: content\ndata: {json.dumps(event)}\n\n"
|
||||
|
||||
# Extract chat_id/response_id
|
||||
if "id" in chunk_data and chunk_data["id"]:
|
||||
chat_id = chunk_data["id"]
|
||||
elif "response_id" in chunk_data and chunk_data["response_id"]:
|
||||
chat_id = chunk_data["response_id"]
|
||||
|
||||
# Extract sources from tool call results
|
||||
if "item" in chunk_data and isinstance(chunk_data["item"], dict):
|
||||
item = chunk_data["item"]
|
||||
if item.get("type") in ("retrieval_call", "tool_call", "function_call"):
|
||||
results = item.get("results", [])
|
||||
if results:
|
||||
for result in results:
|
||||
if isinstance(result, dict):
|
||||
source = {
|
||||
"filename": result.get("filename", result.get("title", "Unknown")),
|
||||
"text": result.get("text", result.get("content", "")),
|
||||
"score": result.get("score", 0),
|
||||
"page": result.get("page"),
|
||||
}
|
||||
sources.append(source)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, might be raw text
|
||||
if chunk_str and not chunk_str.startswith("{"):
|
||||
event = {"type": "content", "delta": chunk_str}
|
||||
yield f"event: content\ndata: {json.dumps(event)}\n\n"
|
||||
full_text += chunk_str
|
||||
except Exception as e:
|
||||
logger.warning("Error processing stream chunk", error=str(e))
|
||||
continue
|
||||
|
||||
# Yield sources event if we have any
|
||||
if sources:
|
||||
event = {"type": "sources", "sources": sources}
|
||||
yield f"event: sources\ndata: {json.dumps(event)}\n\n"
|
||||
|
||||
# Yield done event with chat_id
|
||||
event = {"type": "done", "chat_id": chat_id}
|
||||
yield f"event: done\ndata: {json.dumps(event)}\n\n"
|
||||
|
||||
# Store chat_id for caller
|
||||
chat_id_container["chat_id"] = chat_id
|
||||
|
||||
|
||||
async def chat_create_endpoint(request: Request, chat_service, session_manager):
|
||||
"""
|
||||
Send a chat message.
|
||||
|
||||
POST /api/v1/chat
|
||||
|
||||
Request body:
|
||||
{
|
||||
"message": "What is RAG?",
|
||||
"stream": false, // optional, default false
|
||||
"chat_id": "...", // optional, to continue conversation
|
||||
"filters": {...}, // optional
|
||||
"limit": 10, // optional
|
||||
"score_threshold": 0.5 // optional
|
||||
}
|
||||
|
||||
Non-streaming response:
|
||||
{
|
||||
"response": "RAG stands for...",
|
||||
"chat_id": "chat_xyz789",
|
||||
"sources": [...]
|
||||
}
|
||||
|
||||
Streaming response (SSE):
|
||||
event: content
|
||||
data: {"type": "content", "delta": "RAG stands for"}
|
||||
|
||||
event: sources
|
||||
data: {"type": "sources", "sources": [...]}
|
||||
|
||||
event: done
|
||||
data: {"type": "done", "chat_id": "chat_xyz789"}
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Invalid JSON in request body"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
message = data.get("message", "").strip()
|
||||
if not message:
|
||||
return JSONResponse(
|
||||
{"error": "Message is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
stream = data.get("stream", False)
|
||||
chat_id = data.get("chat_id") # For conversation continuation
|
||||
filters = data.get("filters")
|
||||
limit = data.get("limit", 10)
|
||||
score_threshold = data.get("score_threshold", 0)
|
||||
|
||||
user = request.state.user
|
||||
user_id = user.user_id
|
||||
|
||||
# Note: API key auth doesn't have JWT, so we pass None
|
||||
jwt_token = None
|
||||
|
||||
# Set context variables for search tool
|
||||
if filters:
|
||||
set_search_filters(filters)
|
||||
set_search_limit(limit)
|
||||
set_score_threshold(score_threshold)
|
||||
set_auth_context(user_id, jwt_token)
|
||||
|
||||
if stream:
|
||||
# Streaming response
|
||||
raw_stream = await chat_service.chat(
|
||||
prompt=message,
|
||||
user_id=user_id,
|
||||
jwt_token=jwt_token,
|
||||
previous_response_id=chat_id,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
chat_id_container = {}
|
||||
|
||||
return StreamingResponse(
|
||||
_transform_stream_to_sse(raw_stream, chat_id_container),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Non-streaming response
|
||||
result = await chat_service.chat(
|
||||
prompt=message,
|
||||
user_id=user_id,
|
||||
jwt_token=jwt_token,
|
||||
previous_response_id=chat_id,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
# Transform response to public API format
|
||||
# Internal format: {"response": "...", "response_id": "..."}
|
||||
response_data = {
|
||||
"response": result.get("response", ""),
|
||||
"chat_id": result.get("response_id"),
|
||||
"sources": result.get("sources", []),
|
||||
}
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
|
||||
async def chat_list_endpoint(request: Request, chat_service, session_manager):
|
||||
"""
|
||||
List all conversations for the authenticated user.
|
||||
|
||||
GET /api/v1/chat
|
||||
|
||||
Response:
|
||||
{
|
||||
"conversations": [
|
||||
{
|
||||
"chat_id": "...",
|
||||
"title": "What is RAG?",
|
||||
"created_at": "...",
|
||||
"last_activity": "...",
|
||||
"message_count": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
user = request.state.user
|
||||
user_id = user.user_id
|
||||
|
||||
try:
|
||||
# Get chat history
|
||||
history = await chat_service.get_chat_history(user_id)
|
||||
|
||||
# Transform to public API format
|
||||
conversations = []
|
||||
for conv in history.get("conversations", []):
|
||||
conversations.append({
|
||||
"chat_id": conv.get("response_id"),
|
||||
"title": conv.get("title", ""),
|
||||
"created_at": conv.get("created_at"),
|
||||
"last_activity": conv.get("last_activity"),
|
||||
"message_count": conv.get("total_messages", 0),
|
||||
})
|
||||
|
||||
return JSONResponse({"conversations": conversations})
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list conversations", error=str(e), user_id=user_id)
|
||||
return JSONResponse(
|
||||
{"error": f"Failed to list conversations: {str(e)}"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def chat_get_endpoint(request: Request, chat_service, session_manager):
|
||||
"""
|
||||
Get a specific conversation with full message history.
|
||||
|
||||
GET /api/v1/chat/{chat_id}
|
||||
|
||||
Response:
|
||||
{
|
||||
"chat_id": "...",
|
||||
"title": "What is RAG?",
|
||||
"created_at": "...",
|
||||
"last_activity": "...",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is RAG?", "timestamp": "..."},
|
||||
{"role": "assistant", "content": "RAG stands for...", "timestamp": "..."}
|
||||
]
|
||||
}
|
||||
"""
|
||||
user = request.state.user
|
||||
user_id = user.user_id
|
||||
chat_id = request.path_params.get("chat_id")
|
||||
|
||||
if not chat_id:
|
||||
return JSONResponse(
|
||||
{"error": "Chat ID is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get chat history and find the specific conversation
|
||||
history = await chat_service.get_chat_history(user_id)
|
||||
|
||||
conversation = None
|
||||
for conv in history.get("conversations", []):
|
||||
if conv.get("response_id") == chat_id:
|
||||
conversation = conv
|
||||
break
|
||||
|
||||
if not conversation:
|
||||
return JSONResponse(
|
||||
{"error": "Conversation not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Transform to public API format
|
||||
messages = []
|
||||
for msg in conversation.get("messages", []):
|
||||
messages.append({
|
||||
"role": msg.get("role"),
|
||||
"content": msg.get("content"),
|
||||
"timestamp": msg.get("timestamp"),
|
||||
})
|
||||
|
||||
response_data = {
|
||||
"chat_id": conversation.get("response_id"),
|
||||
"title": conversation.get("title", ""),
|
||||
"created_at": conversation.get("created_at"),
|
||||
"last_activity": conversation.get("last_activity"),
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get conversation", error=str(e), user_id=user_id, chat_id=chat_id)
|
||||
return JSONResponse(
|
||||
{"error": f"Failed to get conversation: {str(e)}"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def chat_delete_endpoint(request: Request, chat_service, session_manager):
|
||||
"""
|
||||
Delete a conversation.
|
||||
|
||||
DELETE /api/v1/chat/{chat_id}
|
||||
|
||||
Response:
|
||||
{"success": true}
|
||||
"""
|
||||
user = request.state.user
|
||||
user_id = user.user_id
|
||||
chat_id = request.path_params.get("chat_id")
|
||||
|
||||
if not chat_id:
|
||||
return JSONResponse(
|
||||
{"error": "Chat ID is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await chat_service.delete_session(user_id, chat_id)
|
||||
|
||||
if result.get("success"):
|
||||
return JSONResponse({"success": True})
|
||||
else:
|
||||
return JSONResponse(
|
||||
{"error": result.get("error", "Failed to delete conversation")},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete conversation", error=str(e), user_id=user_id, chat_id=chat_id)
|
||||
return JSONResponse(
|
||||
{"error": f"Failed to delete conversation: {str(e)}"},
|
||||
status_code=500,
|
||||
)
|
||||
153
src/api/v1/documents.py
Normal file
153
src/api/v1/documents.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""
|
||||
Public API v1 Documents endpoint.
|
||||
|
||||
Provides document ingestion and management.
|
||||
Uses API key authentication.
|
||||
"""
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def ingest_endpoint(request: Request, document_service, task_service, session_manager):
|
||||
"""
|
||||
Ingest a document into the knowledge base.
|
||||
|
||||
POST /api/v1/documents/ingest
|
||||
|
||||
Request: multipart/form-data with "file" field
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"document_id": "...",
|
||||
"filename": "doc.pdf",
|
||||
"chunks": 10
|
||||
}
|
||||
|
||||
For bulk uploads, returns a task ID:
|
||||
{
|
||||
"task_id": "...",
|
||||
"status": "processing"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
content_type = request.headers.get("content-type", "")
|
||||
|
||||
if "multipart/form-data" in content_type:
|
||||
# Single file upload
|
||||
form = await request.form()
|
||||
upload_file = form.get("file")
|
||||
|
||||
if not upload_file:
|
||||
return JSONResponse(
|
||||
{"error": "File is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
user = request.state.user
|
||||
|
||||
result = await document_service.process_upload_file(
|
||||
upload_file,
|
||||
owner_user_id=user.user_id,
|
||||
jwt_token=None, # API key auth, no JWT
|
||||
owner_name=user.name,
|
||||
owner_email=user.email,
|
||||
)
|
||||
|
||||
if result.get("error"):
|
||||
return JSONResponse(result, status_code=500)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"document_id": result.get("id"), # process_upload_file returns "id"
|
||||
"filename": upload_file.filename,
|
||||
"chunks": result.get("chunks", 0),
|
||||
}, status_code=201)
|
||||
|
||||
else:
|
||||
return JSONResponse(
|
||||
{"error": "Content-Type must be multipart/form-data"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error("Document ingestion failed", error=error_msg)
|
||||
|
||||
if "AuthenticationException" in error_msg or "access denied" in error_msg.lower():
|
||||
return JSONResponse({"error": error_msg}, status_code=403)
|
||||
else:
|
||||
return JSONResponse({"error": error_msg}, status_code=500)
|
||||
|
||||
|
||||
async def delete_document_endpoint(request: Request, document_service, session_manager):
|
||||
"""
|
||||
Delete a document from the knowledge base.
|
||||
|
||||
DELETE /api/v1/documents
|
||||
|
||||
Request body:
|
||||
{
|
||||
"filename": "doc.pdf"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"deleted_chunks": 5
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Invalid JSON in request body"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
filename = data.get("filename", "").strip()
|
||||
if not filename:
|
||||
return JSONResponse(
|
||||
{"error": "Filename is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
user = request.state.user
|
||||
|
||||
try:
|
||||
from config.settings import INDEX_NAME
|
||||
from utils.opensearch_queries import build_filename_delete_body
|
||||
|
||||
# Get OpenSearch client (API key auth uses internal client)
|
||||
opensearch_client = session_manager.get_user_opensearch_client(
|
||||
user.user_id, None # No JWT for API key auth
|
||||
)
|
||||
|
||||
# Delete by query to remove all chunks of this document
|
||||
delete_query = build_filename_delete_body(filename)
|
||||
|
||||
result = await opensearch_client.delete_by_query(
|
||||
index=INDEX_NAME,
|
||||
body=delete_query,
|
||||
conflicts="proceed"
|
||||
)
|
||||
|
||||
deleted_count = result.get("deleted", 0)
|
||||
logger.info(f"Deleted {deleted_count} chunks for filename {filename}", user_id=user.user_id)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"deleted_chunks": deleted_count,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error("Document deletion failed", error=error_msg, filename=filename)
|
||||
|
||||
if "AuthenticationException" in error_msg or "access denied" in error_msg.lower():
|
||||
return JSONResponse({"error": error_msg}, status_code=403)
|
||||
else:
|
||||
return JSONResponse({"error": error_msg}, status_code=500)
|
||||
108
src/api/v1/search.py
Normal file
108
src/api/v1/search.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""
|
||||
Public API v1 Search endpoint.
|
||||
|
||||
Provides semantic search functionality.
|
||||
Uses API key authentication.
|
||||
"""
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def search_endpoint(request: Request, search_service, session_manager):
|
||||
"""
|
||||
Perform semantic search on documents.
|
||||
|
||||
POST /api/v1/search
|
||||
|
||||
Request body:
|
||||
{
|
||||
"query": "What is RAG?",
|
||||
"filters": { // optional
|
||||
"data_sources": ["doc.pdf"],
|
||||
"document_types": ["application/pdf"]
|
||||
},
|
||||
"limit": 10, // optional, default 10
|
||||
"score_threshold": 0.5 // optional, default 0
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"filename": "doc.pdf",
|
||||
"text": "RAG stands for...",
|
||||
"score": 0.85,
|
||||
"page": 1,
|
||||
"mimetype": "application/pdf"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Invalid JSON in request body"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
query = data.get("query", "").strip()
|
||||
if not query:
|
||||
return JSONResponse(
|
||||
{"error": "Query is required"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
filters = data.get("filters", {})
|
||||
limit = data.get("limit", 10)
|
||||
score_threshold = data.get("score_threshold", 0)
|
||||
|
||||
user = request.state.user
|
||||
user_id = user.user_id
|
||||
|
||||
# Note: API key auth doesn't have JWT
|
||||
jwt_token = None
|
||||
|
||||
logger.debug(
|
||||
"Public API search request",
|
||||
user_id=user_id,
|
||||
query=query,
|
||||
filters=filters,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await search_service.search(
|
||||
query,
|
||||
user_id=user_id,
|
||||
jwt_token=jwt_token,
|
||||
filters=filters,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
|
||||
# Transform results to public API format
|
||||
results = []
|
||||
for item in result.get("results", []):
|
||||
results.append({
|
||||
"filename": item.get("filename"),
|
||||
"text": item.get("text"),
|
||||
"score": item.get("score"),
|
||||
"page": item.get("page"),
|
||||
"mimetype": item.get("mimetype"),
|
||||
})
|
||||
|
||||
return JSONResponse({"results": results})
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error("Search failed", error=error_msg, user_id=user_id)
|
||||
|
||||
if "AuthenticationException" in error_msg or "access denied" in error_msg.lower():
|
||||
return JSONResponse({"error": error_msg}, status_code=403)
|
||||
else:
|
||||
return JSONResponse({"error": error_msg}, status_code=500)
|
||||
61
src/api/v1/settings.py
Normal file
61
src/api/v1/settings.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""
|
||||
Public API v1 Settings endpoint.
|
||||
|
||||
Provides read-only access to configuration settings.
|
||||
Uses API key authentication.
|
||||
"""
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def get_settings_endpoint(request: Request):
|
||||
"""
|
||||
Get current OpenRAG configuration (read-only).
|
||||
|
||||
GET /api/v1/settings
|
||||
|
||||
Response:
|
||||
{
|
||||
"agent": {
|
||||
"llm_provider": "openai",
|
||||
"llm_model": "gpt-4"
|
||||
},
|
||||
"knowledge": {
|
||||
"embedding_provider": "openai",
|
||||
"embedding_model": "text-embedding-3-small"
|
||||
}
|
||||
}
|
||||
|
||||
Note: This endpoint returns a limited subset of settings.
|
||||
Sensitive information (API keys, credentials) is never exposed.
|
||||
"""
|
||||
try:
|
||||
from config.settings import get_openrag_config
|
||||
|
||||
config = get_openrag_config()
|
||||
|
||||
# Return only safe, non-sensitive settings
|
||||
settings = {
|
||||
"agent": {
|
||||
"llm_provider": config.agent.llm_provider,
|
||||
"llm_model": config.agent.llm_model,
|
||||
},
|
||||
"knowledge": {
|
||||
"embedding_provider": config.knowledge.embedding_provider,
|
||||
"embedding_model": config.knowledge.embedding_model,
|
||||
"chunk_size": config.knowledge.chunk_size,
|
||||
"chunk_overlap": config.knowledge.chunk_overlap,
|
||||
},
|
||||
}
|
||||
|
||||
return JSONResponse(settings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get settings", error=str(e))
|
||||
return JSONResponse(
|
||||
{"error": "Failed to get settings"},
|
||||
status_code=500,
|
||||
)
|
||||
133
src/api_key_middleware.py
Normal file
133
src/api_key_middleware.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""
|
||||
API Key middleware for authenticating public API requests.
|
||||
"""
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from session_manager import User
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _extract_api_key(request: Request) -> str | None:
|
||||
"""
|
||||
Extract API key from request headers.
|
||||
|
||||
Supports:
|
||||
- X-API-Key header
|
||||
- Authorization: Bearer orag_... header
|
||||
"""
|
||||
# Try X-API-Key header first
|
||||
api_key = request.headers.get("X-API-Key")
|
||||
if api_key and api_key.startswith("orag_"):
|
||||
return api_key
|
||||
|
||||
# Try Authorization header
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:] # Remove "Bearer " prefix
|
||||
if token.startswith("orag_"):
|
||||
return token
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def require_api_key(api_key_service):
|
||||
"""
|
||||
Decorator to require API key authentication for public API endpoints.
|
||||
|
||||
Usage:
|
||||
@require_api_key(api_key_service)
|
||||
async def my_endpoint(request):
|
||||
user = request.state.user
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(handler):
|
||||
async def wrapper(request: Request):
|
||||
# Extract API key from headers
|
||||
api_key = _extract_api_key(request)
|
||||
|
||||
if not api_key:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "API key required",
|
||||
"message": "Provide API key via X-API-Key header or Authorization: Bearer header",
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Validate the key
|
||||
user_info = await api_key_service.validate_key(api_key)
|
||||
|
||||
if not user_info:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Invalid API key",
|
||||
"message": "The provided API key is invalid or has been revoked",
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Create a User object from the API key info
|
||||
user = User(
|
||||
user_id=user_info["user_id"],
|
||||
email=user_info["user_email"],
|
||||
name=user_info.get("name", "API User"),
|
||||
picture=None,
|
||||
provider="api_key",
|
||||
)
|
||||
|
||||
# Set request state
|
||||
request.state.user = user
|
||||
request.state.api_key_id = user_info["key_id"]
|
||||
request.state.jwt_token = None # No JWT for API key auth
|
||||
|
||||
return await handler(request)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def optional_api_key(api_key_service):
|
||||
"""
|
||||
Decorator to optionally authenticate with API key.
|
||||
Sets request.state.user to None if no valid API key is provided.
|
||||
"""
|
||||
|
||||
def decorator(handler):
|
||||
async def wrapper(request: Request):
|
||||
# Extract API key from headers
|
||||
api_key = _extract_api_key(request)
|
||||
|
||||
if api_key:
|
||||
# Validate the key
|
||||
user_info = await api_key_service.validate_key(api_key)
|
||||
|
||||
if user_info:
|
||||
# Create a User object from the API key info
|
||||
user = User(
|
||||
user_id=user_info["user_id"],
|
||||
email=user_info["user_email"],
|
||||
name=user_info.get("name", "API User"),
|
||||
picture=None,
|
||||
provider="api_key",
|
||||
)
|
||||
request.state.user = user
|
||||
request.state.api_key_id = user_info["key_id"]
|
||||
request.state.jwt_token = None
|
||||
else:
|
||||
request.state.user = None
|
||||
request.state.api_key_id = None
|
||||
request.state.jwt_token = None
|
||||
else:
|
||||
request.state.user = None
|
||||
request.state.api_key_id = None
|
||||
request.state.jwt_token = None
|
||||
|
||||
return await handler(request)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
|
@ -143,6 +143,28 @@ INDEX_BODY = {
|
|||
},
|
||||
}
|
||||
|
||||
# API Keys index for public API authentication
|
||||
API_KEYS_INDEX_NAME = "api_keys"
|
||||
API_KEYS_INDEX_BODY = {
|
||||
"settings": {
|
||||
"number_of_shards": 1,
|
||||
"number_of_replicas": 0,
|
||||
},
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"key_id": {"type": "keyword"},
|
||||
"key_hash": {"type": "keyword"}, # SHA-256 hash, never store plaintext
|
||||
"key_prefix": {"type": "keyword"}, # First 8 chars for display (e.g., "orag_abc1")
|
||||
"user_id": {"type": "keyword"},
|
||||
"user_email": {"type": "keyword"},
|
||||
"name": {"type": "text", "fields": {"keyword": {"type": "keyword"}}},
|
||||
"created_at": {"type": "date"},
|
||||
"last_used_at": {"type": "date"},
|
||||
"revoked": {"type": "boolean"},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
# Convenience base URL for Langflow REST API
|
||||
LANGFLOW_BASE_URL = f"{LANGFLOW_URL}/api/v1"
|
||||
|
||||
|
|
|
|||
147
src/main.py
147
src/main.py
|
|
@ -54,8 +54,16 @@ from api import (
|
|||
from api.connector_router import ConnectorRouter
|
||||
from auth_middleware import optional_auth, require_auth
|
||||
|
||||
# API Key authentication
|
||||
from api_key_middleware import require_api_key
|
||||
from services.api_key_service import APIKeyService
|
||||
from api import keys as api_keys
|
||||
from api.v1 import chat as v1_chat, search as v1_search, documents as v1_documents, settings as v1_settings
|
||||
|
||||
# Configuration and setup
|
||||
from config.settings import (
|
||||
API_KEYS_INDEX_BODY,
|
||||
API_KEYS_INDEX_NAME,
|
||||
DISABLE_INGEST_WITH_LANGFLOW,
|
||||
INDEX_BODY,
|
||||
INDEX_NAME,
|
||||
|
|
@ -240,6 +248,20 @@ async def init_index():
|
|||
index_name=knowledge_filter_index_name,
|
||||
)
|
||||
|
||||
# Create API keys index for public API authentication
|
||||
if not await clients.opensearch.indices.exists(index=API_KEYS_INDEX_NAME):
|
||||
await clients.opensearch.indices.create(
|
||||
index=API_KEYS_INDEX_NAME, body=API_KEYS_INDEX_BODY
|
||||
)
|
||||
logger.info(
|
||||
"Created API keys index", index_name=API_KEYS_INDEX_NAME
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"API keys index already exists, skipping creation",
|
||||
index_name=API_KEYS_INDEX_NAME,
|
||||
)
|
||||
|
||||
# Configure alerting plugin security settings
|
||||
await configure_alerting_security()
|
||||
|
||||
|
|
@ -640,6 +662,9 @@ async def initialize_services():
|
|||
|
||||
langflow_file_service = LangflowFileService()
|
||||
|
||||
# API Key service for public API authentication
|
||||
api_key_service = APIKeyService(session_manager)
|
||||
|
||||
return {
|
||||
"document_service": document_service,
|
||||
"search_service": search_service,
|
||||
|
|
@ -653,6 +678,7 @@ async def initialize_services():
|
|||
"models_service": models_service,
|
||||
"monitor_service": monitor_service,
|
||||
"session_manager": session_manager,
|
||||
"api_key_service": api_key_service,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1253,6 +1279,127 @@ async def create_app():
|
|||
partial(docling.health),
|
||||
methods=["GET"],
|
||||
),
|
||||
# ===== API Key Management Endpoints (JWT auth for UI) =====
|
||||
Route(
|
||||
"/keys",
|
||||
require_auth(services["session_manager"])(
|
||||
partial(
|
||||
api_keys.list_keys_endpoint,
|
||||
api_key_service=services["api_key_service"],
|
||||
)
|
||||
),
|
||||
methods=["GET"],
|
||||
),
|
||||
Route(
|
||||
"/keys",
|
||||
require_auth(services["session_manager"])(
|
||||
partial(
|
||||
api_keys.create_key_endpoint,
|
||||
api_key_service=services["api_key_service"],
|
||||
)
|
||||
),
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/keys/{key_id}",
|
||||
require_auth(services["session_manager"])(
|
||||
partial(
|
||||
api_keys.revoke_key_endpoint,
|
||||
api_key_service=services["api_key_service"],
|
||||
)
|
||||
),
|
||||
methods=["DELETE"],
|
||||
),
|
||||
# ===== Public API v1 Endpoints (API Key auth) =====
|
||||
# Chat endpoints
|
||||
Route(
|
||||
"/api/v1/chat",
|
||||
require_api_key(services["api_key_service"])(
|
||||
partial(
|
||||
v1_chat.chat_create_endpoint,
|
||||
chat_service=services["chat_service"],
|
||||
session_manager=services["session_manager"],
|
||||
)
|
||||
),
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/v1/chat",
|
||||
require_api_key(services["api_key_service"])(
|
||||
partial(
|
||||
v1_chat.chat_list_endpoint,
|
||||
chat_service=services["chat_service"],
|
||||
session_manager=services["session_manager"],
|
||||
)
|
||||
),
|
||||
methods=["GET"],
|
||||
),
|
||||
Route(
|
||||
"/api/v1/chat/{chat_id}",
|
||||
require_api_key(services["api_key_service"])(
|
||||
partial(
|
||||
v1_chat.chat_get_endpoint,
|
||||
chat_service=services["chat_service"],
|
||||
session_manager=services["session_manager"],
|
||||
)
|
||||
),
|
||||
methods=["GET"],
|
||||
),
|
||||
Route(
|
||||
"/api/v1/chat/{chat_id}",
|
||||
require_api_key(services["api_key_service"])(
|
||||
partial(
|
||||
v1_chat.chat_delete_endpoint,
|
||||
chat_service=services["chat_service"],
|
||||
session_manager=services["session_manager"],
|
||||
)
|
||||
),
|
||||
methods=["DELETE"],
|
||||
),
|
||||
# Search endpoint
|
||||
Route(
|
||||
"/api/v1/search",
|
||||
require_api_key(services["api_key_service"])(
|
||||
partial(
|
||||
v1_search.search_endpoint,
|
||||
search_service=services["search_service"],
|
||||
session_manager=services["session_manager"],
|
||||
)
|
||||
),
|
||||
methods=["POST"],
|
||||
),
|
||||
# Documents endpoints
|
||||
Route(
|
||||
"/api/v1/documents/ingest",
|
||||
require_api_key(services["api_key_service"])(
|
||||
partial(
|
||||
v1_documents.ingest_endpoint,
|
||||
document_service=services["document_service"],
|
||||
task_service=services["task_service"],
|
||||
session_manager=services["session_manager"],
|
||||
)
|
||||
),
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/v1/documents",
|
||||
require_api_key(services["api_key_service"])(
|
||||
partial(
|
||||
v1_documents.delete_document_endpoint,
|
||||
document_service=services["document_service"],
|
||||
session_manager=services["session_manager"],
|
||||
)
|
||||
),
|
||||
methods=["DELETE"],
|
||||
),
|
||||
# Settings endpoint (read-only)
|
||||
Route(
|
||||
"/api/v1/settings",
|
||||
require_api_key(services["api_key_service"])(
|
||||
partial(v1_settings.get_settings_endpoint)
|
||||
),
|
||||
methods=["GET"],
|
||||
),
|
||||
]
|
||||
|
||||
app = Starlette(debug=True, routes=routes)
|
||||
|
|
|
|||
372
src/services/api_key_service.py
Normal file
372
src/services/api_key_service.py
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
"""
|
||||
API Key Service for managing user API keys for public API authentication.
|
||||
"""
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config.settings import API_KEYS_INDEX_NAME
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class APIKeyService:
|
||||
"""Service for managing user API keys for public API authentication."""
|
||||
|
||||
def __init__(self, session_manager=None):
|
||||
self.session_manager = session_manager
|
||||
|
||||
def _generate_api_key(self) -> tuple[str, str, str]:
|
||||
"""
|
||||
Generate a new API key.
|
||||
|
||||
Returns:
|
||||
Tuple of (full_key, key_hash, key_prefix)
|
||||
- full_key: The complete API key to return to user (only shown once)
|
||||
- key_hash: SHA-256 hash of the key for storage
|
||||
- key_prefix: First 12 chars for display (e.g., "orag_abc12345")
|
||||
"""
|
||||
# Generate 32 bytes of random data, encode as base64url (no padding)
|
||||
random_bytes = secrets.token_urlsafe(32)
|
||||
|
||||
# Create the full key with prefix
|
||||
full_key = f"orag_{random_bytes}"
|
||||
|
||||
# Hash the full key for storage
|
||||
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
|
||||
|
||||
# Create prefix for display (orag_ + first 8 chars of random part)
|
||||
key_prefix = f"orag_{random_bytes[:8]}"
|
||||
|
||||
return full_key, key_hash, key_prefix
|
||||
|
||||
def _hash_key(self, api_key: str) -> str:
|
||||
"""Hash an API key for lookup."""
|
||||
return hashlib.sha256(api_key.encode()).hexdigest()
|
||||
|
||||
async def create_key(
|
||||
self,
|
||||
user_id: str,
|
||||
user_email: str,
|
||||
name: str,
|
||||
jwt_token: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new API key for a user.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID
|
||||
user_email: The user's email
|
||||
name: A friendly name for the key
|
||||
jwt_token: JWT token for OpenSearch authentication
|
||||
|
||||
Returns:
|
||||
Dict with success status, key info, and the full key (only shown once)
|
||||
"""
|
||||
try:
|
||||
# Generate the key
|
||||
full_key, key_hash, key_prefix = self._generate_api_key()
|
||||
|
||||
# Create a unique key_id
|
||||
key_id = secrets.token_urlsafe(16)
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
# Create the document to store
|
||||
key_doc = {
|
||||
"key_id": key_id,
|
||||
"key_hash": key_hash,
|
||||
"key_prefix": key_prefix,
|
||||
"user_id": user_id,
|
||||
"user_email": user_email,
|
||||
"name": name,
|
||||
"created_at": now,
|
||||
"last_used_at": None,
|
||||
"revoked": False,
|
||||
}
|
||||
|
||||
# Get OpenSearch client
|
||||
from config.settings import clients
|
||||
opensearch_client = clients.opensearch
|
||||
|
||||
# Index the key document
|
||||
result = await opensearch_client.index(
|
||||
index=API_KEYS_INDEX_NAME,
|
||||
id=key_id,
|
||||
body=key_doc,
|
||||
refresh="wait_for",
|
||||
)
|
||||
|
||||
if result.get("result") in ("created", "updated"):
|
||||
logger.info(
|
||||
"Created API key",
|
||||
user_id=user_id,
|
||||
key_id=key_id,
|
||||
key_prefix=key_prefix,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"key_id": key_id,
|
||||
"key_prefix": key_prefix,
|
||||
"name": name,
|
||||
"created_at": now,
|
||||
"api_key": full_key, # Only returned once!
|
||||
}
|
||||
else:
|
||||
return {"success": False, "error": "Failed to create API key"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create API key", error=str(e), user_id=user_id)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def validate_key(self, api_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Validate an API key and return user info if valid.
|
||||
|
||||
Args:
|
||||
api_key: The full API key to validate
|
||||
|
||||
Returns:
|
||||
Dict with user info if valid, None if invalid
|
||||
"""
|
||||
try:
|
||||
# Check key format
|
||||
if not api_key or not api_key.startswith("orag_"):
|
||||
return None
|
||||
|
||||
# Hash the incoming key
|
||||
key_hash = self._hash_key(api_key)
|
||||
|
||||
# Get OpenSearch client
|
||||
from config.settings import clients
|
||||
opensearch_client = clients.opensearch
|
||||
|
||||
# Search for the key by hash
|
||||
search_body = {
|
||||
"query": {
|
||||
"bool": {
|
||||
"must": [
|
||||
{"term": {"key_hash": key_hash}},
|
||||
{"term": {"revoked": False}},
|
||||
]
|
||||
}
|
||||
},
|
||||
"size": 1,
|
||||
}
|
||||
|
||||
result = await opensearch_client.search(
|
||||
index=API_KEYS_INDEX_NAME,
|
||||
body=search_body,
|
||||
)
|
||||
|
||||
hits = result.get("hits", {}).get("hits", [])
|
||||
if not hits:
|
||||
return None
|
||||
|
||||
key_doc = hits[0]["_source"]
|
||||
|
||||
# Update last_used_at (fire and forget)
|
||||
try:
|
||||
await opensearch_client.update(
|
||||
index=API_KEYS_INDEX_NAME,
|
||||
id=key_doc["key_id"],
|
||||
body={
|
||||
"doc": {
|
||||
"last_used_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass # Don't fail validation if update fails
|
||||
|
||||
return {
|
||||
"key_id": key_doc["key_id"],
|
||||
"user_id": key_doc["user_id"],
|
||||
"user_email": key_doc["user_email"],
|
||||
"name": key_doc["name"],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to validate API key", error=str(e))
|
||||
return None
|
||||
|
||||
async def list_keys(
|
||||
self,
|
||||
user_id: str,
|
||||
jwt_token: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
List all API keys for a user (without the actual keys).
|
||||
|
||||
Args:
|
||||
user_id: The user's ID
|
||||
jwt_token: JWT token for OpenSearch authentication
|
||||
|
||||
Returns:
|
||||
Dict with list of key metadata
|
||||
"""
|
||||
try:
|
||||
# Get OpenSearch client
|
||||
from config.settings import clients
|
||||
opensearch_client = clients.opensearch
|
||||
|
||||
# Search for user's keys
|
||||
search_body = {
|
||||
"query": {
|
||||
"term": {"user_id": user_id}
|
||||
},
|
||||
"sort": [{"created_at": {"order": "desc"}}],
|
||||
"_source": [
|
||||
"key_id",
|
||||
"key_prefix",
|
||||
"name",
|
||||
"created_at",
|
||||
"last_used_at",
|
||||
"revoked",
|
||||
],
|
||||
"size": 100,
|
||||
}
|
||||
|
||||
result = await opensearch_client.search(
|
||||
index=API_KEYS_INDEX_NAME,
|
||||
body=search_body,
|
||||
)
|
||||
|
||||
keys = []
|
||||
for hit in result.get("hits", {}).get("hits", []):
|
||||
keys.append(hit["_source"])
|
||||
|
||||
return {"success": True, "keys": keys}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list API keys", error=str(e), user_id=user_id)
|
||||
return {"success": False, "error": str(e), "keys": []}
|
||||
|
||||
async def revoke_key(
|
||||
self,
|
||||
user_id: str,
|
||||
key_id: str,
|
||||
jwt_token: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Revoke an API key.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID (for authorization)
|
||||
key_id: The key ID to revoke
|
||||
jwt_token: JWT token for OpenSearch authentication
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
try:
|
||||
# Get OpenSearch client
|
||||
from config.settings import clients
|
||||
opensearch_client = clients.opensearch
|
||||
|
||||
# First, verify the key belongs to this user
|
||||
try:
|
||||
doc = await opensearch_client.get(
|
||||
index=API_KEYS_INDEX_NAME,
|
||||
id=key_id,
|
||||
)
|
||||
|
||||
if doc["_source"]["user_id"] != user_id:
|
||||
return {"success": False, "error": "Not authorized to revoke this key"}
|
||||
|
||||
except Exception:
|
||||
return {"success": False, "error": "Key not found"}
|
||||
|
||||
# Update the key to mark as revoked
|
||||
result = await opensearch_client.update(
|
||||
index=API_KEYS_INDEX_NAME,
|
||||
id=key_id,
|
||||
body={
|
||||
"doc": {
|
||||
"revoked": True
|
||||
}
|
||||
},
|
||||
refresh="wait_for",
|
||||
)
|
||||
|
||||
if result.get("result") == "updated":
|
||||
logger.info(
|
||||
"Revoked API key",
|
||||
user_id=user_id,
|
||||
key_id=key_id,
|
||||
)
|
||||
return {"success": True}
|
||||
else:
|
||||
return {"success": False, "error": "Failed to revoke key"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to revoke API key",
|
||||
error=str(e),
|
||||
user_id=user_id,
|
||||
key_id=key_id,
|
||||
)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def delete_key(
|
||||
self,
|
||||
user_id: str,
|
||||
key_id: str,
|
||||
jwt_token: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Permanently delete an API key.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID (for authorization)
|
||||
key_id: The key ID to delete
|
||||
jwt_token: JWT token for OpenSearch authentication
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
try:
|
||||
# Get OpenSearch client
|
||||
from config.settings import clients
|
||||
opensearch_client = clients.opensearch
|
||||
|
||||
# First, verify the key belongs to this user
|
||||
try:
|
||||
doc = await opensearch_client.get(
|
||||
index=API_KEYS_INDEX_NAME,
|
||||
id=key_id,
|
||||
)
|
||||
|
||||
if doc["_source"]["user_id"] != user_id:
|
||||
return {"success": False, "error": "Not authorized to delete this key"}
|
||||
|
||||
except Exception:
|
||||
return {"success": False, "error": "Key not found"}
|
||||
|
||||
# Delete the key
|
||||
result = await opensearch_client.delete(
|
||||
index=API_KEYS_INDEX_NAME,
|
||||
id=key_id,
|
||||
refresh="wait_for",
|
||||
)
|
||||
|
||||
if result.get("result") == "deleted":
|
||||
logger.info(
|
||||
"Deleted API key",
|
||||
user_id=user_id,
|
||||
key_id=key_id,
|
||||
)
|
||||
return {"success": True}
|
||||
else:
|
||||
return {"success": False, "error": "Failed to delete key"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to delete API key",
|
||||
error=str(e),
|
||||
user_id=user_id,
|
||||
key_id=key_id,
|
||||
)
|
||||
return {"success": False, "error": str(e)}
|
||||
Loading…
Add table
Reference in a new issue