From 74cba85ae66ec4fbe5177eba134eba65a11b7e73 Mon Sep 17 00:00:00 2001 From: phact Date: Tue, 16 Dec 2025 02:04:31 -0500 Subject: [PATCH] v0 sdks wip --- Makefile | 69 ++- .../api/mutations/useCreateApiKeyMutation.ts | 57 +++ .../api/mutations/useRevokeApiKeyMutation.ts | 49 ++ .../app/api/queries/useGetApiKeysQuery.ts | 31 ++ frontend/app/settings/page.tsx | 280 +++++++++- sdks/python/README.md | 203 ++++++++ sdks/python/openrag_sdk/__init__.py | 91 ++++ sdks/python/openrag_sdk/chat.py | 479 ++++++++++++++++++ sdks/python/openrag_sdk/client.py | 194 +++++++ sdks/python/openrag_sdk/documents.py | 82 +++ sdks/python/openrag_sdk/exceptions.py | 40 ++ sdks/python/openrag_sdk/models.py | 149 ++++++ sdks/python/openrag_sdk/search.py | 60 +++ sdks/python/pyproject.toml | 62 +++ sdks/python/tests/__init__.py | 1 + sdks/python/tests/test_integration.py | 208 ++++++++ sdks/python/uv.lock | 406 +++++++++++++++ sdks/typescript/README.md | 248 +++++++++ sdks/typescript/src/chat.ts | 311 ++++++++++++ sdks/typescript/src/client.ts | 188 +++++++ sdks/typescript/src/documents.ts | 85 ++++ sdks/typescript/src/index.ts | 76 +++ sdks/typescript/src/search.ts | 41 ++ sdks/typescript/src/types.ts | 182 +++++++ sdks/typescript/tests/integration.test.ts | 214 ++++++++ sdks/typescript/tsup.config.ts | 12 + sdks/typescript/vitest.config.ts | 9 + src/api/keys.py | 170 +++++++ src/api/v1/__init__.py | 6 + src/api/v1/chat.py | 373 ++++++++++++++ src/api/v1/documents.py | 153 ++++++ src/api/v1/search.py | 108 ++++ src/api/v1/settings.py | 61 +++ src/api_key_middleware.py | 133 +++++ src/config/settings.py | 22 + src/main.py | 147 ++++++ src/services/api_key_service.py | 372 ++++++++++++++ 37 files changed, 5359 insertions(+), 13 deletions(-) create mode 100644 frontend/app/api/mutations/useCreateApiKeyMutation.ts create mode 100644 frontend/app/api/mutations/useRevokeApiKeyMutation.ts create mode 100644 frontend/app/api/queries/useGetApiKeysQuery.ts create mode 100644 sdks/python/README.md create mode 100644 sdks/python/openrag_sdk/__init__.py create mode 100644 sdks/python/openrag_sdk/chat.py create mode 100644 sdks/python/openrag_sdk/client.py create mode 100644 sdks/python/openrag_sdk/documents.py create mode 100644 sdks/python/openrag_sdk/exceptions.py create mode 100644 sdks/python/openrag_sdk/models.py create mode 100644 sdks/python/openrag_sdk/search.py create mode 100644 sdks/python/pyproject.toml create mode 100644 sdks/python/tests/__init__.py create mode 100644 sdks/python/tests/test_integration.py create mode 100644 sdks/python/uv.lock create mode 100644 sdks/typescript/README.md create mode 100644 sdks/typescript/src/chat.ts create mode 100644 sdks/typescript/src/client.ts create mode 100644 sdks/typescript/src/documents.ts create mode 100644 sdks/typescript/src/index.ts create mode 100644 sdks/typescript/src/search.ts create mode 100644 sdks/typescript/src/types.ts create mode 100644 sdks/typescript/tests/integration.test.ts create mode 100644 sdks/typescript/tsup.config.ts create mode 100644 sdks/typescript/vitest.config.ts create mode 100644 src/api/keys.py create mode 100644 src/api/v1/__init__.py create mode 100644 src/api/v1/chat.py create mode 100644 src/api/v1/documents.py create mode 100644 src/api/v1/search.py create mode 100644 src/api/v1/settings.py create mode 100644 src/api_key_middleware.py create mode 100644 src/services/api_key_service.py diff --git a/Makefile b/Makefile index 9da47098..1a2da7ed 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ ifneq (,$(wildcard .env)) endif .PHONY: help dev dev-cpu dev-local infra stop clean build logs shell-backend shell-frontend install \ - test test-integration test-ci test-ci-local \ + test test-integration test-ci test-ci-local test-sdk \ backend frontend install-be install-fe build-be build-fe logs-be logs-fe logs-lf logs-os \ shell-be shell-lf shell-os restart status health db-reset flow-upload quick setup @@ -46,8 +46,9 @@ help: @echo "Testing:" @echo " test - Run all backend tests" @echo " test-integration - Run integration tests (requires infra)" - @echo " test-ci - Start infra, run integration tests, tear down (uses DockerHub images)" + @echo " test-ci - Start infra, run integration + SDK tests, tear down (uses DockerHub images)" @echo " test-ci-local - Same as test-ci but builds all images locally" + @echo " test-sdk - Run SDK integration tests (requires running OpenRAG at localhost:3000)" @echo " lint - Run linting checks" @echo "" @@ -137,16 +138,19 @@ install-fe: # Building build: - @echo "🔨 Building Docker images..." - docker compose build + @echo "Building all Docker images locally..." + docker build -t langflowai/openrag-opensearch:latest -f Dockerfile . + docker build -t langflowai/openrag-backend:latest -f Dockerfile.backend . + docker build -t langflowai/openrag-frontend:latest -f Dockerfile.frontend . + docker build -t langflowai/openrag-langflow:latest -f Dockerfile.langflow . build-be: - @echo "🔨 Building backend image..." - docker build -t openrag-backend -f Dockerfile.backend . + @echo "Building backend image..." + docker build -t langflowai/openrag-backend:latest -f Dockerfile.backend . build-fe: - @echo "🔨 Building frontend image..." - docker build -t openrag-frontend -f Dockerfile.frontend . + @echo "Building frontend image..." + docker build -t langflowai/openrag-frontend:latest -f Dockerfile.frontend . # Logging and debugging logs: @@ -211,8 +215,8 @@ test-ci: docker compose -f docker-compose-cpu.yml pull; \ echo "Building OpenSearch image override..."; \ docker build --no-cache -t langflowai/openrag-opensearch:latest -f Dockerfile .; \ - echo "Starting infra (OpenSearch + Dashboards + Langflow) with CPU containers"; \ - docker compose -f docker-compose-cpu.yml up -d opensearch dashboards langflow; \ + echo "Starting infra (OpenSearch + Dashboards + Langflow + Backend + Frontend) with CPU containers"; \ + docker compose -f docker-compose-cpu.yml up -d opensearch dashboards langflow openrag-backend openrag-frontend; \ echo "Starting docling-serve..."; \ DOCLING_ENDPOINT=$$(uv run python scripts/docling_ctl.py start --port 5001 | grep "Endpoint:" | awk '{print $$2}'); \ echo "Docling-serve started at $$DOCLING_ENDPOINT"; \ @@ -257,6 +261,21 @@ test-ci: uv run pytest tests/integration -vv -s -o log_cli=true --log-cli-level=DEBUG; \ TEST_RESULT=$$?; \ echo ""; \ + echo "Waiting for frontend at http://localhost:3000..."; \ + for i in $$(seq 1 60); do \ + curl -s http://localhost:3000/ >/dev/null 2>&1 && break || sleep 2; \ + done; \ + echo "Running Python SDK integration tests"; \ + cd sdks/python && \ + uv pip install -e ".[dev]" && \ + OPENRAG_URL=http://localhost:3000 uv run pytest tests/test_integration.py -vv -s || TEST_RESULT=1; \ + cd ../..; \ + echo "Running TypeScript SDK integration tests"; \ + cd sdks/typescript && \ + npm install && npm run build && \ + OPENRAG_URL=http://localhost:3000 npm test || TEST_RESULT=1; \ + cd ../..; \ + echo ""; \ echo "=== Post-test JWT diagnostics ==="; \ echo "Generating test JWT token..."; \ TEST_TOKEN=$$(uv run python -c "from src.session_manager import SessionManager, AnonymousUser; sm = SessionManager('test'); print(sm.create_jwt_token(AnonymousUser()))" 2>/dev/null || echo ""); \ @@ -292,8 +311,8 @@ test-ci-local: docker build -t langflowai/openrag-backend:latest -f Dockerfile.backend .; \ docker build -t langflowai/openrag-frontend:latest -f Dockerfile.frontend .; \ docker build -t langflowai/openrag-langflow:latest -f Dockerfile.langflow .; \ - echo "Starting infra (OpenSearch + Dashboards + Langflow) with CPU containers"; \ - docker compose -f docker-compose-cpu.yml up -d opensearch dashboards langflow; \ + echo "Starting infra (OpenSearch + Dashboards + Langflow + Backend + Frontend) with CPU containers"; \ + docker compose -f docker-compose-cpu.yml up -d opensearch dashboards langflow openrag-backend openrag-frontend; \ echo "Starting docling-serve..."; \ DOCLING_ENDPOINT=$$(uv run python scripts/docling_ctl.py start --port 5001 | grep "Endpoint:" | awk '{print $$2}'); \ echo "Docling-serve started at $$DOCLING_ENDPOINT"; \ @@ -338,6 +357,21 @@ test-ci-local: uv run pytest tests/integration -vv -s -o log_cli=true --log-cli-level=DEBUG; \ TEST_RESULT=$$?; \ echo ""; \ + echo "Waiting for frontend at http://localhost:3000..."; \ + for i in $$(seq 1 60); do \ + curl -s http://localhost:3000/ >/dev/null 2>&1 && break || sleep 2; \ + done; \ + echo "Running Python SDK integration tests"; \ + cd sdks/python && \ + uv pip install -e ".[dev]" && \ + OPENRAG_URL=http://localhost:3000 uv run pytest tests/test_integration.py -vv -s || TEST_RESULT=1; \ + cd ../..; \ + echo "Running TypeScript SDK integration tests"; \ + cd sdks/typescript && \ + npm install && npm run build && \ + OPENRAG_URL=http://localhost:3000 npm test || TEST_RESULT=1; \ + cd ../..; \ + echo ""; \ echo "=== Post-test JWT diagnostics ==="; \ echo "Generating test JWT token..."; \ TEST_TOKEN=$$(uv run python -c "from src.session_manager import SessionManager, AnonymousUser; sm = SessionManager('test'); print(sm.create_jwt_token(AnonymousUser()))" 2>/dev/null || echo ""); \ @@ -353,6 +387,17 @@ test-ci-local: docker compose -f docker-compose-cpu.yml down -v 2>/dev/null || true; \ exit $$TEST_RESULT +# SDK integration tests (requires running OpenRAG instance) +test-sdk: + @echo "Running SDK integration tests..." + @echo "Make sure OpenRAG backend is running at localhost:8000 (make backend)" + @echo "" + @echo "Running Python SDK tests..." + cd sdks/python && uv pip install -e ".[dev]" && OPENRAG_URL=http://localhost:8000 uv run pytest tests/test_integration.py -vv -s + @echo "" + @echo "Running TypeScript SDK tests..." + cd sdks/typescript && npm install && npm run build && OPENRAG_URL=http://localhost:8000 npm test + lint: @echo "🔍 Running linting checks..." cd frontend && npm run lint diff --git a/frontend/app/api/mutations/useCreateApiKeyMutation.ts b/frontend/app/api/mutations/useCreateApiKeyMutation.ts new file mode 100644 index 00000000..ad79693d --- /dev/null +++ b/frontend/app/api/mutations/useCreateApiKeyMutation.ts @@ -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, + "mutationFn" + >, +) => { + const queryClient = useQueryClient(); + + async function createApiKey( + variables: CreateApiKeyRequest, + ): Promise { + 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, + }); +}; diff --git a/frontend/app/api/mutations/useRevokeApiKeyMutation.ts b/frontend/app/api/mutations/useRevokeApiKeyMutation.ts new file mode 100644 index 00000000..b1ef8b0d --- /dev/null +++ b/frontend/app/api/mutations/useRevokeApiKeyMutation.ts @@ -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, + "mutationFn" + >, +) => { + const queryClient = useQueryClient(); + + async function revokeApiKey( + variables: RevokeApiKeyRequest, + ): Promise { + 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, + }); +}; diff --git a/frontend/app/api/queries/useGetApiKeysQuery.ts b/frontend/app/api/queries/useGetApiKeysQuery.ts new file mode 100644 index 00000000..60803ec5 --- /dev/null +++ b/frontend/app/api/queries/useGetApiKeysQuery.ts @@ -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, "queryKey" | "queryFn">, +) => { + async function getApiKeys(): Promise { + 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, + }); +}; diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx index b504bb42..c7c707f6 100644 --- a/frontend/app/settings/page.tsx +++ b/frontend/app/settings/page.tsx @@ -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(false); + // API Keys state + const [createKeyDialogOpen, setCreateKeyDialogOpen] = useState(false); + const [newKeyName, setNewKeyName] = useState(""); + const [newlyCreatedKey, setNewlyCreatedKey] = useState(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() { + + {/* API Keys Section */} + {isAuthenticated && ( + + +
+ API Keys + +
+ + API keys allow programmatic access to OpenRAG via the public API. + Keep your keys secure and never share them publicly. + +
+ + {apiKeysLoading ? ( +
+ +
+ ) : apiKeysData?.keys && apiKeysData.keys.length > 0 ? ( +
+ + + + + + + + + + + + {apiKeysData.keys.map((key) => ( + + + + + + + + ))} + +
+ Name + + Key + + Created + + Last Used + + Actions +
+ {key.name} + + + {key.key_prefix}... + + + {formatDate(key.created_at)} + + {formatDate(key.last_used_at)} + + + + + } + title="Revoke API Key" + description={ + <> + Are you sure you want to revoke the API key{" "} + {key.name}? 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(); + }} + /> +
+
+ ) : ( +
+ +

+ No API keys yet. Create one to get started. +

+ +
+ )} +
+
+ )} + + {/* Create API Key Dialog */} + + + + Create API Key + + Give your API key a name to help you identify it later. + + +
+ + setNewKeyName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleCreateApiKey(); + } + }} + /> + +
+ + + + +
+
+ + {/* Show Created API Key Dialog */} + { + setShowKeyDialogOpen(open); + if (!open) { + setNewlyCreatedKey(null); + } + }} + > + + + API Key Created + + Copy your API key now. You won't be able to see it again. + + +
+
+ {newlyCreatedKey} +
+
+ + + + +
+
); } diff --git a/sdks/python/README.md b/sdks/python/README.md new file mode 100644 index 00000000..f282faa1 --- /dev/null +++ b/sdks/python/README.md @@ -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 diff --git a/sdks/python/openrag_sdk/__init__.py b/sdks/python/openrag_sdk/__init__.py new file mode 100644 index 00000000..2160b2e0 --- /dev/null +++ b/sdks/python/openrag_sdk/__init__.py @@ -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", +] diff --git a/sdks/python/openrag_sdk/chat.py b/sdks/python/openrag_sdk/chat.py new file mode 100644 index 00000000..4e1e7fe4 --- /dev/null +++ b/sdks/python/openrag_sdk/chat.py @@ -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 diff --git a/sdks/python/openrag_sdk/client.py b/sdks/python/openrag_sdk/client.py new file mode 100644 index 00000000..e75c913d --- /dev/null +++ b/sdks/python/openrag_sdk/client.py @@ -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() diff --git a/sdks/python/openrag_sdk/documents.py b/sdks/python/openrag_sdk/documents.py new file mode 100644 index 00000000..f4d0ac3a --- /dev/null +++ b/sdks/python/openrag_sdk/documents.py @@ -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) diff --git a/sdks/python/openrag_sdk/exceptions.py b/sdks/python/openrag_sdk/exceptions.py new file mode 100644 index 00000000..1c64429a --- /dev/null +++ b/sdks/python/openrag_sdk/exceptions.py @@ -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 diff --git a/sdks/python/openrag_sdk/models.py b/sdks/python/openrag_sdk/models.py new file mode 100644 index 00000000..576439af --- /dev/null +++ b/sdks/python/openrag_sdk/models.py @@ -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 diff --git a/sdks/python/openrag_sdk/search.py b/sdks/python/openrag_sdk/search.py new file mode 100644 index 00000000..86d6a8e8 --- /dev/null +++ b/sdks/python/openrag_sdk/search.py @@ -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", [])] + ) diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml new file mode 100644 index 00000000..b5134aae --- /dev/null +++ b/sdks/python/pyproject.toml @@ -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 diff --git a/sdks/python/tests/__init__.py b/sdks/python/tests/__init__.py new file mode 100644 index 00000000..cb74ce0c --- /dev/null +++ b/sdks/python/tests/__init__.py @@ -0,0 +1 @@ +"""OpenRAG SDK tests.""" diff --git a/sdks/python/tests/test_integration.py b/sdks/python/tests/test_integration.py new file mode 100644 index 00000000..a83af327 --- /dev/null +++ b/sdks/python/tests/test_integration.py @@ -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) diff --git a/sdks/python/uv.lock b/sdks/python/uv.lock new file mode 100644 index 00000000..5b3fc93f --- /dev/null +++ b/sdks/python/uv.lock @@ -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" }, +] diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md new file mode 100644 index 00000000..3747b1cb --- /dev/null +++ b/sdks/typescript/README.md @@ -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 diff --git a/sdks/typescript/src/chat.ts b/sdks/typescript/src/chat.ts new file mode 100644 index 00000000..7292d71c --- /dev/null +++ b/sdks/typescript/src/chat.ts @@ -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, Disposable { + private _text = ""; + private _chatId: string | null = null; + private _sources: Source[] = []; + private _consumed = false; + private _reader: ReadableStreamDefaultReader | 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 { + const body: Record = { + 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 { + 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 { + 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 { + 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; + /** + * Send a chat message (streaming). + */ + async create( + options: ChatCreateOptions & { stream: true } + ): Promise>; + /** + * Send a chat message. + * + * @param options - Chat options including message, stream flag, etc. + * @returns ChatResponse if stream=false, AsyncIterable if stream=true. + */ + async create( + options: ChatCreateOptions + ): Promise> { + if (options.stream) { + return this._createStreamingIterator(options); + } + return this._createNonStreaming(options); + } + + private async _createNonStreaming(options: ChatCreateOptions): Promise { + const body: Record = { + 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> { + 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): Promise { + const stream = new ChatStream(this.client, { ...options, stream: true }); + await stream._init(); + return stream; + } + + /** + * List all conversations. + */ + async list(): Promise { + const response = await this.client._request("GET", "/api/v1/chat"); + const data = await response.json(); + + const conversations: Conversation[] = (data.conversations || []).map( + (c: Record) => ({ + 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 { + const response = await this.client._request("GET", `/api/v1/chat/${chatId}`); + const data = await response.json(); + + const messages: Message[] = (data.messages || []).map( + (m: Record) => ({ + 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 { + const response = await this.client._request("DELETE", `/api/v1/chat/${chatId}`); + const data = await response.json(); + return data.success ?? false; + } +} diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts new file mode 100644 index 00000000..2e73ee5a --- /dev/null +++ b/sdks/typescript/src/client.ts @@ -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 { + 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 { + const headers: Record = { + "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 { + 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 = { + 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); + } + } +} diff --git a/sdks/typescript/src/documents.ts b/sdks/typescript/src/documents.ts new file mode 100644 index 00000000..2af9c193 --- /dev/null +++ b/sdks/typescript/src/documents.ts @@ -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 { + 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 { + 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, + }; + } +} diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts new file mode 100644 index 00000000..81f4f377 --- /dev/null +++ b/sdks/typescript/src/index.ts @@ -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"; diff --git a/sdks/typescript/src/search.ts b/sdks/typescript/src/search.ts new file mode 100644 index 00000000..4a10da10 --- /dev/null +++ b/sdks/typescript/src/search.ts @@ -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 + ): Promise { + const body: Record = { + 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 || [], + }; + } +} diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts new file mode 100644 index 00000000..d9d8923e --- /dev/null +++ b/sdks/typescript/src/types.ts @@ -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"; + } +} diff --git a/sdks/typescript/tests/integration.test.ts b/sdks/typescript/tests/integration.test.ts new file mode 100644 index 00000000..bb481470 --- /dev/null +++ b/sdks/typescript/tests/integration.test.ts @@ -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 { + 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; + 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); + }); + }); +}); diff --git a/sdks/typescript/tsup.config.ts b/sdks/typescript/tsup.config.ts new file mode 100644 index 00000000..8a341d27 --- /dev/null +++ b/sdks/typescript/tsup.config.ts @@ -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, +}); diff --git a/sdks/typescript/vitest.config.ts b/sdks/typescript/vitest.config.ts new file mode 100644 index 00000000..d2aa9b80 --- /dev/null +++ b/sdks/typescript/vitest.config.ts @@ -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, + }, +}); diff --git a/src/api/keys.py b/src/api/keys.py new file mode 100644 index 00000000..b18669ae --- /dev/null +++ b/src/api/keys.py @@ -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) diff --git a/src/api/v1/__init__.py b/src/api/v1/__init__.py new file mode 100644 index 00000000..c39d2288 --- /dev/null +++ b/src/api/v1/__init__.py @@ -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. +""" diff --git a/src/api/v1/chat.py b/src/api/v1/chat.py new file mode 100644 index 00000000..003ea5d1 --- /dev/null +++ b/src/api/v1/chat.py @@ -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, + ) diff --git a/src/api/v1/documents.py b/src/api/v1/documents.py new file mode 100644 index 00000000..22785ffd --- /dev/null +++ b/src/api/v1/documents.py @@ -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) diff --git a/src/api/v1/search.py b/src/api/v1/search.py new file mode 100644 index 00000000..6a523fc1 --- /dev/null +++ b/src/api/v1/search.py @@ -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) diff --git a/src/api/v1/settings.py b/src/api/v1/settings.py new file mode 100644 index 00000000..a4a329d7 --- /dev/null +++ b/src/api/v1/settings.py @@ -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, + ) diff --git a/src/api_key_middleware.py b/src/api_key_middleware.py new file mode 100644 index 00000000..907eba8f --- /dev/null +++ b/src/api_key_middleware.py @@ -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 diff --git a/src/config/settings.py b/src/config/settings.py index f3e334b4..78cf03a6 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -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" diff --git a/src/main.py b/src/main.py index 1c3d065e..98a46ddc 100644 --- a/src/main.py +++ b/src/main.py @@ -54,8 +54,16 @@ from api import ( from api.connector_router import ConnectorRouter from auth_middleware import optional_auth, require_auth +# API Key authentication +from api_key_middleware import require_api_key +from services.api_key_service import APIKeyService +from api import keys as api_keys +from api.v1 import chat as v1_chat, search as v1_search, documents as v1_documents, settings as v1_settings + # Configuration and setup from config.settings import ( + API_KEYS_INDEX_BODY, + API_KEYS_INDEX_NAME, DISABLE_INGEST_WITH_LANGFLOW, INDEX_BODY, INDEX_NAME, @@ -240,6 +248,20 @@ async def init_index(): index_name=knowledge_filter_index_name, ) + # Create API keys index for public API authentication + if not await clients.opensearch.indices.exists(index=API_KEYS_INDEX_NAME): + await clients.opensearch.indices.create( + index=API_KEYS_INDEX_NAME, body=API_KEYS_INDEX_BODY + ) + logger.info( + "Created API keys index", index_name=API_KEYS_INDEX_NAME + ) + else: + logger.info( + "API keys index already exists, skipping creation", + index_name=API_KEYS_INDEX_NAME, + ) + # Configure alerting plugin security settings await configure_alerting_security() @@ -640,6 +662,9 @@ async def initialize_services(): langflow_file_service = LangflowFileService() + # API Key service for public API authentication + api_key_service = APIKeyService(session_manager) + return { "document_service": document_service, "search_service": search_service, @@ -653,6 +678,7 @@ async def initialize_services(): "models_service": models_service, "monitor_service": monitor_service, "session_manager": session_manager, + "api_key_service": api_key_service, } @@ -1253,6 +1279,127 @@ async def create_app(): partial(docling.health), methods=["GET"], ), + # ===== API Key Management Endpoints (JWT auth for UI) ===== + Route( + "/keys", + require_auth(services["session_manager"])( + partial( + api_keys.list_keys_endpoint, + api_key_service=services["api_key_service"], + ) + ), + methods=["GET"], + ), + Route( + "/keys", + require_auth(services["session_manager"])( + partial( + api_keys.create_key_endpoint, + api_key_service=services["api_key_service"], + ) + ), + methods=["POST"], + ), + Route( + "/keys/{key_id}", + require_auth(services["session_manager"])( + partial( + api_keys.revoke_key_endpoint, + api_key_service=services["api_key_service"], + ) + ), + methods=["DELETE"], + ), + # ===== Public API v1 Endpoints (API Key auth) ===== + # Chat endpoints + Route( + "/api/v1/chat", + require_api_key(services["api_key_service"])( + partial( + v1_chat.chat_create_endpoint, + chat_service=services["chat_service"], + session_manager=services["session_manager"], + ) + ), + methods=["POST"], + ), + Route( + "/api/v1/chat", + require_api_key(services["api_key_service"])( + partial( + v1_chat.chat_list_endpoint, + chat_service=services["chat_service"], + session_manager=services["session_manager"], + ) + ), + methods=["GET"], + ), + Route( + "/api/v1/chat/{chat_id}", + require_api_key(services["api_key_service"])( + partial( + v1_chat.chat_get_endpoint, + chat_service=services["chat_service"], + session_manager=services["session_manager"], + ) + ), + methods=["GET"], + ), + Route( + "/api/v1/chat/{chat_id}", + require_api_key(services["api_key_service"])( + partial( + v1_chat.chat_delete_endpoint, + chat_service=services["chat_service"], + session_manager=services["session_manager"], + ) + ), + methods=["DELETE"], + ), + # Search endpoint + Route( + "/api/v1/search", + require_api_key(services["api_key_service"])( + partial( + v1_search.search_endpoint, + search_service=services["search_service"], + session_manager=services["session_manager"], + ) + ), + methods=["POST"], + ), + # Documents endpoints + Route( + "/api/v1/documents/ingest", + require_api_key(services["api_key_service"])( + partial( + v1_documents.ingest_endpoint, + document_service=services["document_service"], + task_service=services["task_service"], + session_manager=services["session_manager"], + ) + ), + methods=["POST"], + ), + Route( + "/api/v1/documents", + require_api_key(services["api_key_service"])( + partial( + v1_documents.delete_document_endpoint, + document_service=services["document_service"], + session_manager=services["session_manager"], + ) + ), + methods=["DELETE"], + ), + # Settings endpoint (read-only) + Route( + "/api/v1/settings", + require_api_key(services["api_key_service"])( + partial(v1_settings.get_settings_endpoint) + ), + methods=["GET"], + ), ] app = Starlette(debug=True, routes=routes) diff --git a/src/services/api_key_service.py b/src/services/api_key_service.py new file mode 100644 index 00000000..c519ecd7 --- /dev/null +++ b/src/services/api_key_service.py @@ -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)}