v0 sdks wip

This commit is contained in:
phact 2025-12-16 02:04:31 -05:00
parent be62f74488
commit 74cba85ae6
37 changed files with 5359 additions and 13 deletions

View file

@ -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

View 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,
});
};

View 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,
});
};

View 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,
});
};

View file

@ -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&apos;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
View 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

View 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",
]

View 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

View 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()

View 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)

View 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

View 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

View 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", [])]
)

View 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

View file

@ -0,0 +1 @@
"""OpenRAG SDK tests."""

View 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
View 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
View 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
View 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;
}
}

View 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);
}
}
}

View 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,
};
}
}

View 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";

View 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 || [],
};
}
}

View 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";
}
}

View 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);
});
});
});

View 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,
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View file

@ -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"

View file

@ -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)

View 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)}