diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..12e1604 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,41 @@ + + +## Summary + + + +## Type of Change + +- [ ] **fix**: Bug fix or issue (patch semvar update) +- [ ] **feat**: Introduces a new feature to the codebase (minor semvar update) +- [ ] **perf**: Performance improvement +- [ ] **docs**: Documentation only changes +- [ ] **tests**: Adding missing tests or correcting existing tests +- [ ] **chore**: Code cleanup tasks, dependency updates, or other changes + +Note: Add a `!` after your change type to denote a breaking change. + +## Additional Context + + + +## Related Issue + +Closes #[Github issue number] diff --git a/Dockerfile b/Dockerfile index 17be492..b90352d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,9 +31,24 @@ RUN groupadd -r app && useradd -r -d /app -g app app # Copy project files for dependency installation (better caching) COPY pyproject.toml uv.lock README.md ./ -# Install dependencies first (better layer caching) +# Install custom CA certificate into system trust store if provided +# This persists the certificate in the image for both build and runtime +RUN --mount=type=secret,id=ssl_cert,target=/tmp/ca-cert.pem,required=false \ + sh -c ' \ + if [ -f /tmp/ca-cert.pem ] && [ -s /tmp/ca-cert.pem ]; then \ + echo "Installing custom CA certificate into system trust store"; \ + cp /tmp/ca-cert.pem /usr/local/share/ca-certificates/custom-ca.crt; \ + update-ca-certificates; \ + echo "Certificate installed and will persist in container"; \ + else \ + echo "No custom CA certificate provided - using system defaults"; \ + fi \ + ' + +# Install dependencies including dev dependencies (after certificate is installed, if provided) +# We install dev dependencies here so they're available at runtime without re-downloading RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen --no-dev + uv sync --frozen # Copy application code COPY src/ ./src/ diff --git a/Justfile b/Justfile index e640cd5..8a42476 100644 --- a/Justfile +++ b/Justfile @@ -81,6 +81,12 @@ clean: @rebuild: {{DC}} down && {{DC}} build --no-cache && {{DC}} up -d && {{DC}} logs -f +@rebuild-local: + {{DC}} -f docker-compose.local.yml down && \ + {{DC}} -f docker-compose.local.yml build --no-cache && \ + {{DC}} -f docker-compose.local.yml up -d && \ + {{DC}} -f docker-compose.local.yml logs -f + @restart: {{DC}} down && {{DC}} up -d && {{DC}} logs -f diff --git a/README.md b/README.md index a8ce8db..cf5440e 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,27 @@ To use OpenAI instead of Ollama in Docker: uv run src/graphiti_mcp_server.py --transport sse ``` +## Testing Your Configuration + +Before starting the MCP server, validate that your models work with their configured parameters: + +```bash +# Test model compatibility (recommended before first run) +scripts/test_model_compatibility.sh + +# For detailed debugging of connections and SSL +scripts/debug_bedrock_connection.sh +``` + +The compatibility test will catch common issues like: +- Temperature constraints (e.g., some models require temperature >= 1.0) +- Model availability on configured endpoints +- Embedding dimension mismatches +- Authentication failures +- SSL certificate issues + +šŸ“– **See [TESTING_QUICKSTART.md](TESTING_QUICKSTART.md) for quick reference or [docs/MODEL_COMPATIBILITY_TESTING.md](docs/MODEL_COMPATIBILITY_TESTING.md) for comprehensive testing guide.** + ## Configuration The server supports multiple configuration methods with the following precedence (highest to lowest): @@ -844,6 +865,8 @@ Or add it to your `.env` file: GRAPHITI_TELEMETRY_ENABLED=false ``` +> **Note**: If you see SSL certificate errors related to PostHog (us.i.posthog.com) in your logs, ensure you're using `GRAPHITI_TELEMETRY_ENABLED=false` (not the deprecated `POSTHOG_DISABLED=1`). + For complete details about what's collected and why, see the [Telemetry section in the main Graphiti README](../README.md#telemetry). ## šŸ“¦ Distribution and Publishing diff --git a/config/README.md b/config/README.md index 448f5bb..5944fe3 100644 --- a/config/README.md +++ b/config/README.md @@ -1,171 +1,290 @@ # Configuration Directory -This directory contains YAML-based configuration files for the Graphiti MCP Server. The configuration system supports different hierarchies for different types of configuration: - -## Provider Configuration Hierarchy -For provider-specific settings (models, URLs, parameters): -1. **Default values** (lowest priority) - defined in code -2. **Base YAML files** - e.g., `providers/ollama.yml` -3. **Local override files** - e.g., `providers/ollama.local.yml` -4. **CLI arguments** (highest priority) - passed when starting the server - -## Other Configuration Hierarchy -For general settings and sensitive data: -1. **Default values** (lowest priority) - defined in code -2. **YAML configuration files** - defined in this directory -3. **Environment variables** - for sensitive data like API keys -4. **CLI arguments** (highest priority) - passed when starting the server +This directory contains YAML-based configuration files for the Graphiti MCP Server. The configuration system supports a modern unified approach alongside backward-compatible provider-specific configurations. -## Directory Structure +## Quick Start (Recommended) + +### 1. Create Your Unified Config + +The **unified configuration** is the recommended approach - it allows you to configure both LLM and embedder in a single file with support for mixed providers. +```bash +# Copy the template +cp config/config.local.yml.example config/config.local.yml + +# Edit with your settings +vim config/config.local.yml ``` -config/ -ā”œā”€ā”€ providers/ # Provider-specific configurations -│ ā”œā”€ā”€ ollama.yml # Base Ollama configuration -│ ā”œā”€ā”€ ollama.local.yml # Local Ollama overrides (optional) -│ ā”œā”€ā”€ openai.yml # Base OpenAI configuration -│ ā”œā”€ā”€ openai.local.yml # Local OpenAI overrides (optional) -│ ā”œā”€ā”€ azure_openai.yml # Base Azure OpenAI configuration -│ ā”œā”€ā”€ azure_openai.local.yml # Local Azure OpenAI overrides (optional) -│ └── ollama.local.yml.example # Example local override file -ā”œā”€ā”€ database/ -│ └── neo4j.yml # Neo4j database configuration -ā”œā”€ā”€ server.yml # General server configuration -└── README.md # This file + +### 2. Set Required Environment Variables + +```bash +# For OpenAI/Bedrock LLM (skip if using Ollama) +export OPENAI_API_KEY="your-api-key-here" + +# For Neo4j (always required) +export NEO4J_URI="bolt://localhost:7687" +export NEO4J_USER="neo4j" +export NEO4J_PASSWORD="your-password" ``` -## Local Override Files +### 3. Start the Server -Local override files (`.local.yml`) allow you to customize configuration without modifying the base files. This is perfect for: -- Personal development settings -- Environment-specific configurations -- Experimenting with different models or parameters +```bash +uv run src/graphiti_mcp_server.py --transport stdio +``` -**Example**: Copy `providers/ollama.local.yml.example` to `providers/ollama.local.yml` and modify as needed. +See the [Unified Configuration Guide](../docs/UNIFIED_CONFIG_GUIDE.md) for detailed setup instructions and examples. -**Git**: Local override files should be added to `.gitignore` to prevent committing personal settings. +## Configuration Hierarchy -## Provider Configuration +Settings are loaded with this precedence (highest to lowest): -### Ollama (providers/ollama.yml) +### For All Settings +1. **CLI arguments** (highest priority) - e.g., `--model`, `--temperature` +2. **Environment variables** - for sensitive data like `OPENAI_API_KEY`, `NEO4J_PASSWORD` +3. **Unified config** - `config/config.local.yml` (recommended) +4. **Provider-specific configs** - `providers/*.local.yml` (backward compatibility only) +5. **Default values** (lowest priority) - hardcoded fallbacks -The Ollama configuration supports model-specific parameters that are passed directly to the Ollama API: +## Directory Structure -```yaml -llm: - model: "deepseek-r1:7b" - base_url: "http://localhost:11434/v1" - temperature: 0.1 - max_tokens: 8192 - model_parameters: - num_ctx: 4096 # Context window size - num_predict: -1 # Number of tokens to predict - repeat_penalty: 1.1 # Penalty for repeating tokens - top_k: 40 # Limit token selection to top K - top_p: 0.9 # Cumulative probability cutoff +``` +config/ +ā”œā”€ā”€ config.local.yml.example # Template for unified configuration (recommended) +ā”œā”€ā”€ config.local.yml # Your local unified config (git-ignored) +ā”œā”€ā”€ config.yml # Example unified configuration +ā”œā”€ā”€ providers/ # Legacy provider-specific configs (backward compatibility) +│ ā”œā”€ā”€ ollama.yml # Base Ollama configuration +│ ā”œā”€ā”€ ollama.local.yml.example # Example local override +│ ā”œā”€ā”€ openai.yml # Base OpenAI configuration +│ └── azure_openai.yml # Base Azure OpenAI configuration +ā”œā”€ā”€ database/ +│ └── neo4j.yml # Neo4j database configuration +ā”œā”€ā”€ server.yml # General server configuration +└── README.md # This file ``` -**Supported Ollama Model Parameters:** -- `num_ctx`: Context window size (number of tokens to consider) -- `num_predict`: Number of tokens to predict (-1 for unlimited) -- `repeat_penalty`: Penalty for repeating tokens (1.0 = no penalty) -- `top_k`: Limit next token selection to K most probable tokens -- `top_p`: Cumulative probability cutoff for token selection -- `temperature`: Model-level temperature (can override general temperature) -- `seed`: Random seed for reproducible outputs -- `stop`: Array of stop sequences +## Unified Configuration (Recommended) -### OpenAI (providers/openai.yml) +The unified configuration allows you to configure both LLM and embedder in a single file, with support for mixing providers. -Standard OpenAI configuration with model parameters: +**Example: Enterprise Gateway LLM + Local Ollama Embeddings** ```yaml llm: - model: "gpt-4o-mini" - temperature: 0.0 + model: "gpt-4o" + base_url: "https://your-enterprise-gateway.com" + temperature: 0.1 max_tokens: 8192 - model_parameters: - presence_penalty: 0.0 - frequency_penalty: 0.0 - top_p: 1.0 + +embedder: + model: "nomic-embed-text" + base_url: "http://localhost:11434/v1" + dimension: 768 ``` -### Azure OpenAI (providers/azure_openai.yml) +**Benefits:** +- āœ… Single file configuration +- āœ… Mix and match providers (e.g., enterprise LLM with local embeddings) +- āœ… Automatic provider detection from `base_url` +- āœ… Clear, maintainable configuration +- āœ… Easy to version control (as example files) -Azure OpenAI configuration (endpoints and keys still use environment variables): +### Provider Detection -```yaml -llm: - model: "gpt-4o-mini" - temperature: 0.0 - max_tokens: 8192 +The system automatically detects which provider to use based on `base_url`: + +| URL Pattern | Detected Provider | +|-------------|-------------------| +| `localhost:11434` or `127.0.0.1:11434` | Ollama | +| `azure.com` in hostname | Azure OpenAI | +| Everything else | OpenAI-compatible | + +Each component (LLM and embedder) is detected independently, allowing mixed configurations. + +## Legacy Provider-Specific Configuration + +For backward compatibility, provider-specific configurations are still supported. However, **unified config is preferred** for new setups. + +### Provider Configuration Files + +- `providers/ollama.yml` - Base Ollama configuration +- `providers/openai.yml` - Base OpenAI configuration +- `providers/azure_openai.yml` - Base Azure OpenAI configuration + +### Local Override Files + +Local override files (`.local.yml`) allow you to customize configuration without modifying base files: + +```bash +# Example: Create Ollama local override +cp providers/ollama.local.yml.example providers/ollama.local.yml +vim providers/ollama.local.yml ``` +**Note:** If `config/config.local.yml` exists, it takes precedence over provider-specific configs. + ## Environment Variables -Environment variables are now primarily used for sensitive credentials and system-level configuration: +Environment variables are primarily used for sensitive credentials and system-level configuration: + +### Required Variables ```bash -# Required for OpenAI/Azure providers +# For OpenAI/Azure/Bedrock providers export OPENAI_API_KEY="your-api-key" -export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" -# System configuration -export USE_OLLAMA="true" +# For Azure OpenAI (if using Azure) +export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com" +export AZURE_OPENAI_API_VERSION="2024-02-01" +export AZURE_OPENAI_DEPLOYMENT_NAME="your-deployment" + +# For Neo4j (always required) +export NEO4J_URI="bolt://localhost:7687" +export NEO4J_USER="neo4j" export NEO4J_PASSWORD="your-password" + +# Optional: SSL certificates +export SSL_CERT_FILE="/path/to/cert.pem" +export SSL_CA_BUNDLE="/path/to/ca-bundle.crt" ``` -**Note**: Provider-specific settings (models, URLs, parameters) are no longer configurable via environment variables. Use local override files instead. +**Important:** Provider-specific settings (models, URLs, parameters) should be configured via YAML files, not environment variables. This provides cleaner configuration management and better version control. + +## Model-Specific Parameters -## Configuration Examples +Each provider supports specific parameters in the `model_parameters` section: -### Using Local Overrides +### OpenAI/Bedrock Parameters -1. **Base configuration** (`providers/ollama.yml`): ```yaml -llm: - model: "gpt-oss:latest" - temperature: 0.1 - max_tokens: 50000 +model_parameters: + presence_penalty: 0.0 + frequency_penalty: 0.0 + top_p: 1.0 + n: 1 + stream: false ``` -2. **Local override** (`providers/ollama.local.yml`): +### Ollama Parameters + +```yaml +model_parameters: + num_ctx: 4096 # Context window size + num_predict: -1 # Number of tokens to predict + repeat_penalty: 1.1 # Penalty for repeating tokens + top_k: 40 # Limit token selection to top K + top_p: 0.9 # Cumulative probability cutoff + keep_alive: "5m" # How long to keep model in memory + seed: 42 # Random seed for reproducibility +``` + +## Common Configuration Scenarios + +### Development: All Ollama (Free & Offline) + ```yaml llm: model: "llama3.1:8b" - temperature: 0.3 + base_url: "http://localhost:11434/v1" + temperature: 0.1 + max_tokens: 10000 + +embedder: + model: "nomic-embed-text" + base_url: "http://localhost:11434/v1" + dimension: 768 ``` -3. **Result**: Model and temperature from local file, max_tokens from base file. +**Requirements:** Ollama running locally with models pulled -### CLI Override Example +### Production: Enterprise Gateway -```bash -# Override any setting via CLI -uv run src/graphiti_mcp_server.py --temperature 0.7 +```yaml +llm: + model: "gpt-4o" + base_url: "https://your-enterprise-gateway.com" + temperature: 0.1 + max_tokens: 8192 + +embedder: + model: "text-embedding-3-small" + base_url: "https://your-enterprise-gateway.com/embeddings" + dimension: 1536 ``` -CLI arguments always take highest precedence. +**Requirements:** `OPENAI_API_KEY` environment variable -## Adding New Providers +### Hybrid: Enterprise LLM + Local Embeddings (Recommended) -To add a new provider: +```yaml +llm: + model: "gpt-4o" + base_url: "https://your-enterprise-gateway.com" + temperature: 0.1 + max_tokens: 8192 + +embedder: + model: "nomic-embed-text" + base_url: "http://localhost:11434/v1" + dimension: 768 +``` + +**Requirements:** `OPENAI_API_KEY` + Ollama running locally -1. Create `providers/new_provider.yml` -2. Add provider-specific configuration structure -3. Update the ConfigLoader to support the new provider -4. Extend GraphitiLLMConfig to handle the new provider +**Benefits:** Enterprise-grade LLM reasoning with fast, free local embeddings ## Testing Configuration -You can test your configuration by running: +Test your configuration changes: ```bash -# Test with current configuration -uv run src/graphiti_mcp_server.py --help +# Start the server +uv run src/graphiti_mcp_server.py --transport stdio + +# Check startup logs for: +# "Using [Provider] LLM: [model] at [url]" +# "Using [Provider] embedder: [model] at [url]" + +# Test with CLI overrides +uv run src/graphiti_mcp_server.py --temperature 0.7 --transport stdio +``` -# Test with specific provider -USE_OLLAMA=true uv run src/graphiti_mcp_server.py --transport sse +## Git Ignore + +Local configuration files are automatically ignored by git: + +```gitignore +# .gitignore includes: +*.local.yml # All local override files +!*.local.yml.example # Except example files +.env # Environment variable files ``` -Check the logs to see which configuration values are being used. +This prevents committing personal settings and API keys. + +## Migrating from Provider-Specific to Unified Config + +If you have existing provider-specific configs: + +1. Create `config/config.local.yml` from the example +2. Copy your LLM settings from your old provider file +3. Copy your embedder settings (can be from a different provider!) +4. Test: `uv run src/graphiti_mcp_server.py --transport stdio` +5. Optional: Remove old `*.local.yml` files from `providers/` + +The system will automatically use unified config if it exists. + +## See Also + +- [Unified Configuration Guide](../docs/UNIFIED_CONFIG_GUIDE.md) - Comprehensive setup guide +- [Environment Setup Guide](../ENVIRONMENT_SETUP.md) - Environment variable configuration +- [Main README](../README.md) - Getting started guide + +## Support + +For issues or questions: +- Check the [Unified Configuration Guide](../docs/UNIFIED_CONFIG_GUIDE.md) troubleshooting section +- Review startup logs for configuration warnings +- Ensure YAML syntax is valid: `python3 -c "import yaml; print(yaml.safe_load(open('config/config.local.yml')))"` diff --git a/config/config.local.yml.example b/config/config.local.yml.example new file mode 100644 index 0000000..fb5a3e5 --- /dev/null +++ b/config/config.local.yml.example @@ -0,0 +1,129 @@ +# Unified Configuration Example for Graphiti MCP Server +# Copy this file to config.local.yml and customize for your environment +# This file allows you to configure both LLM and embedder with different providers + +# ============================================================================ +# RECOMMENDED SETUP: Enterprise Gateway LLM + Local Ollama Embeddings +# ============================================================================ +# This provides enterprise-grade LLM with fast, free local embeddings + +llm: + # LLM Configuration - Using OpenAI-compatible API (OpenAI/Bedrock/Azure) + model: "gpt-4o" + small_model: "gpt-4o-mini" # Optional: smaller model for simpler tasks + base_url: "https://api.openai.com/v1" # Change to your enterprise gateway URL + temperature: 0.1 + max_tokens: 8192 + + # LLM-specific parameters (OpenAI/Bedrock) + model_parameters: + presence_penalty: 0.0 + frequency_penalty: 0.0 + top_p: 1.0 + n: 1 + stream: false + +embedder: + # Embedder Configuration - Using Local Ollama + model: "nomic-embed-text" + base_url: "http://localhost:11434/v1" + dimension: 768 + + # Embedder-specific parameters (Ollama) + model_parameters: + num_ctx: 4096 + +# ============================================================================ +# ALTERNATIVE CONFIGURATIONS +# ============================================================================ + +# ---------------------------------------------------------------------------- +# Option 1: All Ollama (Local Development - Free & Offline) +# ---------------------------------------------------------------------------- +# llm: +# model: "llama3.1:8b" +# base_url: "http://localhost:11434/v1" +# temperature: 0.1 +# max_tokens: 10000 +# model_parameters: +# num_ctx: 4096 +# keep_alive: "5m" +# +# embedder: +# model: "nomic-embed-text" +# base_url: "http://localhost:11434/v1" +# dimension: 768 +# model_parameters: +# num_ctx: 4096 + +# ---------------------------------------------------------------------------- +# Option 2: All Enterprise Gateway (Centralized Billing) +# ---------------------------------------------------------------------------- +# llm: +# model: "gpt-4o" +# base_url: "https://your-enterprise-gateway.com" +# temperature: 0.1 +# max_tokens: 8192 +# +# embedder: +# model: "text-embedding-3-small" +# base_url: "https://your-enterprise-gateway.com/embeddings" +# dimension: 1536 + +# ---------------------------------------------------------------------------- +# Option 3: Azure OpenAI +# ---------------------------------------------------------------------------- +# llm: +# model: "gpt-4" # Your Azure deployment model name +# temperature: 0.1 +# max_tokens: 8192 +# +# embedder: +# model: "text-embedding-3-small" +# dimension: 1536 +# +# Required Azure environment variables: +# AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com" +# AZURE_OPENAI_API_VERSION="2024-02-01" +# AZURE_OPENAI_DEPLOYMENT_NAME="your-deployment" +# AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME="your-embedding-deployment" +# OPENAI_API_KEY="your-azure-key" + +# ============================================================================ +# PROVIDER DETECTION (Automatic) +# ============================================================================ +# The system automatically detects which provider to use based on base_url: +# - localhost:11434 or 127.0.0.1:11434 -> Ollama +# - azure.com in hostname -> Azure OpenAI +# - Everything else -> OpenAI-compatible +# +# Each component (LLM and embedder) is detected independently, allowing +# mixed provider configurations. + +# ============================================================================ +# REQUIRED ENVIRONMENT VARIABLES +# ============================================================================ +# Set these in your environment (not in this file): +# +# For OpenAI/Bedrock LLM (required if not using Ollama): +# export OPENAI_API_KEY="your-api-key-here" +# +# For Neo4j (always required): +# export NEO4J_URI="bolt://localhost:7687" +# export NEO4J_USER="neo4j" +# export NEO4J_PASSWORD="your-password" +# +# Optional SSL certificates (if your gateway requires them): +# export SSL_CERT_FILE="/path/to/cert.pem" +# export SSL_CA_BUNDLE="/path/to/ca-bundle.crt" + +# ============================================================================ +# TESTING YOUR CONFIGURATION +# ============================================================================ +# After creating config.local.yml, test with: +# uv run src/graphiti_mcp_server.py --transport stdio +# +# Check the startup logs for: +# "Using [Provider] LLM: [model] at [url]" +# "Using [Provider] embedder: [model] at [url]" + diff --git a/config/config.yml b/config/config.yml new file mode 100644 index 0000000..448faf1 --- /dev/null +++ b/config/config.yml @@ -0,0 +1,58 @@ +# Unified Local Configuration for Graphiti MCP Server +# This file allows you to configure both LLM and embedder with different providers +# Each section can use a different provider - mix and match as needed! + +llm: + # LLM Configuration - Using Bedrock/Enterprise Gateway + model: "gpt-5-mini" + small_model: "gpt-5-nano" + base_url: "https://api.openai.com/v1" + temperature: 0.1 + max_tokens: 25000 + + # LLM-specific parameters + model_parameters: + presence_penalty: 0.0 + frequency_penalty: 0.0 + top_p: 1.0 + n: 1 + stream: false + +embedder: + # Embedder Configuration - Using Local Ollama + model: "nomic-embed-text" + base_url: "http://localhost:11434/v1" + dimension: 768 + + # Embedder-specific parameters + model_parameters: + num_ctx: 4096 + +# Example configurations for different scenarios: +# +# Scenario 1: Both using OpenAI/Bedrock +# llm: +# model: "gpt-4o" +# base_url: "https://your-gateway.com" +# embedder: +# model: "text-embedding-3-small" +# base_url: "https://your-gateway.com/bedrock/embeddings" +# dimension: 1536 +# +# Scenario 2: Both using Ollama +# llm: +# model: "llama3.1:8b" +# base_url: "http://localhost:11434/v1" +# embedder: +# model: "nomic-embed-text" +# base_url: "http://localhost:11434/v1" +# dimension: 768 +# +# Scenario 3: Mixed (Current Configuration) +# llm: +# model: "claude-sonnet-4-20250514" +# base_url: "https://your-enterprise-gateway.com" +# embedder: +# model: "nomic-embed-text" +# base_url: "http://localhost:11434/v1" +# dimension: 768 diff --git a/docker-compose.multi-instance.yml b/docker-compose.multi-instance.yml new file mode 100644 index 0000000..1a05c63 --- /dev/null +++ b/docker-compose.multi-instance.yml @@ -0,0 +1,223 @@ +# Multi-Instance Docker Compose Configuration +# This configuration allows you to run multiple instances of the Graphiti MCP server +# Each instance runs on a different port and has its own group_id +# You can access each instance at: +# * http://localhost:8020/sse?group_id=claude_desktop +# * http://localhost:8022/sse?group_id=cursor_ide +# * http://localhost:8024/sse?group_id=project_xyz +# +# Copy this file locally and modify the environment variables to your liking. +# +# Example: +# $ cp docker-compose.multi-instance.yml docker-compose.local.yml +# $ docker compose -f docker-compose.local.yml up +# # Or use the justfile command +# $ just rebuild-local +services: + neo4j: + image: neo4j:5.26.0 + ports: + - "7474:7474" # HTTP + - "7687:7687" # Bolt + environment: + - NEO4J_AUTH=${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-demodemo} + - NEO4J_server_memory_heap_initial__size=512m + - NEO4J_server_memory_heap_max__size=1G + - NEO4J_server_memory_pagecache_size=512m + volumes: + - neo4j_data:/data + - neo4j_logs:/logs + healthcheck: + test: ["CMD", "wget", "-O", "/dev/null", "http://localhost:7474"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + # ============================================================================ + # Instance 1: Claude Desktop / Default Workspace + # Accessible at: http://localhost:8020/sse?group_id=claude_desktop (optional query param) + # ============================================================================ + graphiti-mcp-claude: + build: + context: . + dockerfile: ${DOCKERFILE:-Dockerfile} + secrets: + - ssl_cert + develop: + watch: + - path: ./src + action: sync + target: /app/src + - path: ./pyproject.toml + action: sync + target: /app/pyproject.toml + - path: ./uv.lock + action: sync + target: /app/uv.lock + - path: ./Dockerfile + action: rebuild + env_file: + - path: .env + required: true + depends_on: + neo4j: + condition: service_healthy + environment: + # Neo4j Configuration (shared) + - NEO4J_URI=${NEO4J_URI:-bolt://neo4j:7687} + - NEO4J_USER=${NEO4J_USER:-neo4j} + - NEO4J_PASSWORD=${NEO4J_PASSWORD:-demodemo} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + # Path configuration + - PATH=/root/.local/bin:${PATH} + - PYTHONPATH=/app + # Server configuration - INSTANCE SPECIFIC + - SEMAPHORE_LIMIT=${SEMAPHORE_LIMIT:-10} + - MCP_SERVER_PORT=8020 # OAuth wrapper port for Claude + - MCP_INTERNAL_PORT=8021 # Internal MCP server port for Claude + - GROUP_ID=claude_desktop # Default group_id for this instance + # OAuth Configuration + - OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID:-graphiti-mcp-claude} + - OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET:-graphiti-secret-key-change-this-in-production} + - OAUTH_ISSUER=http://localhost:8020 + - OAUTH_AUDIENCE=${OAUTH_AUDIENCE:-graphiti-mcp} + # Analytics Configuration + - GRAPHITI_TELEMETRY_ENABLED=${GRAPHITI_TELEMETRY_ENABLED:-true} + # SSL Certificate paths + - SSL_CERT_FILE=${SSL_CERT_FILE_DOCKER:-/etc/ssl/certs/ca-certificates.crt} + - REQUESTS_CA_BUNDLE=${REQUESTS_CA_BUNDLE_DOCKER:-/etc/ssl/certs/ca-certificates.crt} + - CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt + volumes: + - ${SSL_CERT_DIR:-./.certs}:/app/.certs:ro + ports: + - "8020:8020" # OAuth wrapper exposed port + command: ["sh", "-c", ".venv/bin/python src/graphiti_mcp_server.py --transport sse --port 8021 --group-id claude_desktop & .venv/bin/python src/oauth_wrapper.py"] + + # ============================================================================ + # Instance 2: Cursor IDE + # Accessible at: http://localhost:8022/sse?group_id=cursor_ide (optional query param) + # ============================================================================ + graphiti-mcp-cursor: + build: + context: . + dockerfile: ${DOCKERFILE:-Dockerfile} + secrets: + - ssl_cert + develop: + watch: + - path: ./src + action: sync + target: /app/src + - path: ./pyproject.toml + action: sync + target: /app/pyproject.toml + - path: ./uv.lock + action: sync + target: /app/uv.lock + - path: ./Dockerfile + action: rebuild + env_file: + - path: .env + required: true + depends_on: + neo4j: + condition: service_healthy + environment: + # Neo4j Configuration (shared) + - NEO4J_URI=${NEO4J_URI:-bolt://neo4j:7687} + - NEO4J_USER=${NEO4J_USER:-neo4j} + - NEO4J_PASSWORD=${NEO4J_PASSWORD:-demodemo} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + # Path configuration + - PATH=/root/.local/bin:${PATH} + - PYTHONPATH=/app + # Server configuration - INSTANCE SPECIFIC + - SEMAPHORE_LIMIT=${SEMAPHORE_LIMIT:-10} + - MCP_SERVER_PORT=8022 # OAuth wrapper port for Cursor + - MCP_INTERNAL_PORT=8023 # Internal MCP server port for Cursor + - GROUP_ID=cursor_ide # Default group_id for this instance + # OAuth Configuration + - OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID:-graphiti-mcp-cursor} + - OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET:-graphiti-secret-key-change-this-in-production} + - OAUTH_ISSUER=http://localhost:8022 + - OAUTH_AUDIENCE=${OAUTH_AUDIENCE:-graphiti-mcp} + # Analytics Configuration + - GRAPHITI_TELEMETRY_ENABLED=${GRAPHITI_TELEMETRY_ENABLED:-true} + # SSL Certificate paths + - SSL_CERT_FILE=${SSL_CERT_FILE_DOCKER:-/etc/ssl/certs/ca-certificates.crt} + - REQUESTS_CA_BUNDLE=${REQUESTS_CA_BUNDLE_DOCKER:-/etc/ssl/certs/ca-certificates.crt} + - CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt + volumes: + - ${SSL_CERT_DIR:-./.certs}:/app/.certs:ro + ports: + - "8022:8022" # OAuth wrapper exposed port + command: ["sh", "-c", ".venv/bin/python src/graphiti_mcp_server.py --transport sse --port 8023 --group-id cursor_ide & .venv/bin/python src/oauth_wrapper.py"] + + # ============================================================================ + # Instance 3: Project-Specific (Optional) + # Accessible at: http://localhost:8024/sse?group_id=project_xyz (optional query param) + # ============================================================================ + graphiti-mcp-project: + build: + context: . + dockerfile: ${DOCKERFILE:-Dockerfile} + secrets: + - ssl_cert + develop: + watch: + - path: ./src + action: sync + target: /app/src + - path: ./pyproject.toml + action: sync + target: /app/pyproject.toml + - path: ./uv.lock + action: sync + target: /app/uv.lock + - path: ./Dockerfile + action: rebuild + env_file: + - path: .env + required: true + depends_on: + neo4j: + condition: service_healthy + environment: + # Neo4j Configuration (shared) + - NEO4J_URI=${NEO4J_URI:-bolt://neo4j:7687} + - NEO4J_USER=${NEO4J_USER:-neo4j} + - NEO4J_PASSWORD=${NEO4J_PASSWORD:-demodemo} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + # Path configuration + - PATH=/root/.local/bin:${PATH} + - PYTHONPATH=/app + # Server configuration - INSTANCE SPECIFIC + - SEMAPHORE_LIMIT=${SEMAPHORE_LIMIT:-10} + - MCP_SERVER_PORT=8024 # OAuth wrapper port for Project + - MCP_INTERNAL_PORT=8025 # Internal MCP server port for Project + - GROUP_ID=project_xyz # Default group_id for this instance + # OAuth Configuration + - OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID:-graphiti-mcp-project} + - OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET:-graphiti-secret-key-change-this-in-production} + - OAUTH_ISSUER=http://localhost:8024 + - OAUTH_AUDIENCE=${OAUTH_AUDIENCE:-graphiti-mcp} + # Analytics Configuration + - GRAPHITI_TELEMETRY_ENABLED=${GRAPHITI_TELEMETRY_ENABLED:-true} + # SSL Certificate paths + - SSL_CERT_FILE=${SSL_CERT_FILE_DOCKER:-/etc/ssl/certs/ca-certificates.crt} + - REQUESTS_CA_BUNDLE=${REQUESTS_CA_BUNDLE_DOCKER:-/etc/ssl/certs/ca-certificates.crt} + - CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt + volumes: + - ${SSL_CERT_DIR:-./.certs}:/app/.certs:ro + ports: + - "8024:8024" # OAuth wrapper exposed port + command: ["sh", "-c", ".venv/bin/python src/graphiti_mcp_server.py --transport sse --port 8025 --group-id project_xyz & .venv/bin/python src/oauth_wrapper.py"] + +volumes: + neo4j_data: + neo4j_logs: + +secrets: + ssl_cert: + file: ${SSL_CERT_BUILD_PATH:-/dev/null} diff --git a/docker-compose.yml b/docker-compose.yml index 60065c7..6481add 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,9 @@ services: build: context: . dockerfile: ${DOCKERFILE:-Dockerfile} + secrets: + # Pass SSL certificate to build process if SSL_CERT_FILE is set + - ssl_cert develop: watch: - path: ./src @@ -58,29 +61,49 @@ services: neo4j: condition: service_healthy environment: + # Neo4j Configuration - NEO4J_URI=${NEO4J_URI:-bolt://neo4j:7687} - NEO4J_USER=${NEO4J_USER:-neo4j} - NEO4J_PASSWORD=${NEO4J_PASSWORD:-demodemo} - - USE_OLLAMA=${USE_OLLAMA:-true} - - OLLAMA_BASE_URL=http://host.docker.internal:11434/v1 - - OLLAMA_LLM_MODEL=deepseek-r1:7b - - OLLAMA_EMBEDDING_MODEL=nomic-embed-text - - OLLAMA_EMBEDDING_DIM=768 - - LLM_MAX_TOKENS=32768 - - OPENAI_API_KEY=${OPENAI_API_KEY:-abc} + # API Key (passed from .env, works for both OpenAI and Bedrock) + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + # Path configuration - PATH=/root/.local/bin:${PATH} + - PYTHONPATH=/app + # Server configuration - SEMAPHORE_LIMIT=${SEMAPHORE_LIMIT:-10} - MCP_SERVER_PORT=${MCP_SERVER_PORT:-8020} - MCP_INTERNAL_PORT=${MCP_INTERNAL_PORT:-$((MCP_SERVER_PORT + 1))} + # OAuth Configuration - OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID:-graphiti-mcp} - OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET:-graphiti-secret-key-change-this-in-production} - OAUTH_ISSUER=${OAUTH_ISSUER:-http://localhost:8020} - OAUTH_AUDIENCE=${OAUTH_AUDIENCE:-graphiti-mcp} + # Analytics Configuration (optional - disable PostHog telemetry) + # Note: graphiti-core expects GRAPHITI_TELEMETRY_ENABLED (false to disable) + - GRAPHITI_TELEMETRY_ENABLED=${GRAPHITI_TELEMETRY_ENABLED:-true} + # SSL Certificate paths for Docker container (optional) + # If custom CA cert was installed during build, point to system trust store + # Otherwise, these will be empty and system defaults will be used + - SSL_CERT_FILE=${SSL_CERT_FILE_DOCKER:-/etc/ssl/certs/ca-certificates.crt} + - REQUESTS_CA_BUNDLE=${REQUESTS_CA_BUNDLE_DOCKER:-/etc/ssl/certs/ca-certificates.crt} + - CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt + # SSL certificate volume mount (enabled for custom CA certificates) + volumes: + - ${SSL_CERT_DIR}:/app/.certs:ro ports: - "${MCP_SERVER_PORT:-8020}:8020" # Expose the OAuth wrapper - command: ["sh", "-c", "uv run python src/graphiti_mcp_server.py --transport sse --port $MCP_INTERNAL_PORT & uv run python src/oauth_wrapper.py"] + command: ["sh", "-c", ".venv/bin/python src/graphiti_mcp_server.py --transport sse --port $MCP_INTERNAL_PORT & .venv/bin/python src/oauth_wrapper.py"] volumes: neo4j_data: neo4j_logs: # ollama_data: # Uncomment if using Ollama service + +secrets: + # SSL certificate for build process + # Set SSL_CERT_BUILD_PATH in .env to the full path of your certificate file + # Example: SSL_CERT_BUILD_PATH=/Users/yourname/.certs/ca-bundle.pem + # Defaults to /dev/null so build still works without SSL certs + ssl_cert: + file: ${SSL_CERT_BUILD_PATH:-/dev/null} diff --git a/docs/UNIFIED_CONFIG_GUIDE.md b/docs/UNIFIED_CONFIG_GUIDE.md new file mode 100644 index 0000000..3ee21f3 --- /dev/null +++ b/docs/UNIFIED_CONFIG_GUIDE.md @@ -0,0 +1,398 @@ +# Unified Configuration Guide + +This guide explains the unified configuration system for the Graphiti MCP Server. The unified config simplifies setup by allowing you to configure both LLM and embeddings in a single file, with support for mixed providers. + +## Overview + +The unified configuration system provides a simpler, more flexible way to configure your Graphiti MCP server: + +- **Single config file**: `config/config.local.yml` for all settings +- **Mixed providers**: Use different providers for LLM and embeddings +- **Auto-detection**: Provider type detected from `base_url` +- **Backward compatible**: Old provider-specific configs still work + +## Quick Start + +### 1. Create Your Config File + +Copy the template: + +```bash +cp config/config.local.yml.example config/config.local.yml +``` + +Or create your own `config/config.local.yml`: + +```yaml +llm: + model: "claude-sonnet-4-20250514" + base_url: "https://your-gateway.com" + temperature: 0.1 + max_tokens: 25000 + +embedder: + model: "nomic-embed-text" + base_url: "http://localhost:11434/v1" + dimension: 768 +``` + +### 2. Set Environment Variables + +```bash +# For Bedrock/OpenAI LLM (required if not using Ollama) +export OPENAI_API_KEY="your-api-key-here" + +# For Neo4j (always required) +export NEO4J_URI="bolt://localhost:7687" +export NEO4J_USER="neo4j" +export NEO4J_PASSWORD="your-password" +``` + +### 3. Start the Server + +```bash +uv run src/graphiti_mcp_server.py --transport stdio +``` + +## Configuration Examples + +### Example 1: Enterprise Gateway LLM + Local Ollama Embeddings (Recommended) + +This is the optimal configuration for most use cases - enterprise LLM gateway with fast local embeddings. + +```yaml +llm: + model: "gpt-4o" + base_url: "https://your-enterprise-gateway.example.com" + temperature: 0.1 + max_tokens: 25000 + + model_parameters: + presence_penalty: 0.0 + frequency_penalty: 0.0 + top_p: 1.0 + +embedder: + model: "nomic-embed-text" + base_url: "http://localhost:11434/v1" + dimension: 768 + + model_parameters: + num_ctx: 4096 +``` + +**Required:** +- `OPENAI_API_KEY` for your enterprise gateway +- Ollama running locally with `nomic-embed-text` model +- Optional: SSL certificates if your gateway requires them + +**Benefits:** +- Enterprise-grade LLM for complex reasoning +- Fast local embeddings (no API latency) +- Lower cost (embeddings are free) +- Works offline for embedding generation + +### Example 2: All Ollama (Local Development) + +Perfect for development, testing, or offline work. + +```yaml +llm: + model: "llama3.1:8b" + base_url: "http://localhost:11434/v1" + temperature: 0.1 + max_tokens: 10000 + + model_parameters: + num_ctx: 4096 + keep_alive: "5m" + +embedder: + model: "nomic-embed-text" + base_url: "http://localhost:11434/v1" + dimension: 768 +``` + +**Required:** +- Ollama running locally +- Models pulled: `ollama pull llama3.1:8b` and `ollama pull nomic-embed-text` + +**Benefits:** +- Completely free +- Works offline +- Fast iteration +- No API keys needed + +### Example 3: All OpenAI/Bedrock (Enterprise) + +Full enterprise gateway setup. + +```yaml +llm: + model: "gpt-4o" + base_url: "https://your-enterprise-gateway.com" + temperature: 0.1 + max_tokens: 8192 + +embedder: + model: "text-embedding-3-small" + base_url: "https://your-enterprise-gateway.com/bedrock/embeddings" + dimension: 1536 +``` + +**Required:** +- `OPENAI_API_KEY` for gateway access + +**Benefits:** +- Centralized billing/monitoring +- Enterprise security compliance +- Consistent provider + +### Example 4: Azure OpenAI + +For Azure-hosted OpenAI services. + +```yaml +llm: + model: "gpt-4" # Your deployment model + temperature: 0.1 + max_tokens: 8192 + +embedder: + model: "text-embedding-3-small" + dimension: 1536 +``` + +**Required Environment Variables:** +```bash +export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com" +export AZURE_OPENAI_API_VERSION="2024-02-01" +export AZURE_OPENAI_DEPLOYMENT_NAME="your-deployment" +export OPENAI_API_KEY="your-azure-key" + +# For embeddings (if separate deployment) +export AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME="your-embedding-deployment" +``` + +## How Provider Detection Works + +The system automatically detects which provider to use based on the `base_url`: + +| URL Pattern | Detected Provider | +|-------------|-------------------| +| `localhost:11434` or `127.0.0.1:11434` | Ollama | +| `azure.com` in hostname | Azure OpenAI | +| Everything else | OpenAI-compatible | + +**Note:** Detection is fully automatic based on the `base_url` in your configuration. No environment variables needed for detection. Each component (LLM and embedder) is detected independently, allowing mixed provider configurations. + +## Configuration Hierarchy + +Settings are loaded with this precedence (highest to lowest): + +1. **CLI arguments** (e.g., `--model`, `--temperature`) +2. **Environment variables** (e.g., `OPENAI_API_KEY`, `OLLAMA_BASE_URL`) +3. **Unified config** (`config/config.local.yml`) +4. **Provider-specific configs** (`config/providers/*.local.yml`) - deprecated +5. **Default values** (hardcoded fallbacks) + +## Migrating from Provider-Specific Configs + +If you have existing provider-specific configs: + +### Old Setup (Multiple Files) +``` +config/providers/openai.local.yml +config/providers/ollama.local.yml +``` + +### New Setup (Single File) +``` +config/config.local.yml +``` + +**Migration Steps:** + +1. Create `config/config.local.yml` +2. Copy your LLM settings from the old file +3. Copy your embedder settings (can be from a different provider file!) +4. Test with `uv run src/graphiti_mcp_server.py --transport stdio` +5. Remove old provider-specific files (optional - they're still supported) + +## Advanced Configuration + +### Model-Specific Parameters + +Each provider supports specific parameters in `model_parameters`: + +**OpenAI/Bedrock:** +```yaml +model_parameters: + presence_penalty: 0.0 + frequency_penalty: 0.0 + top_p: 1.0 + n: 1 + stream: false +``` + +**Ollama:** +```yaml +model_parameters: + num_ctx: 4096 + num_predict: -1 + repeat_penalty: 1.1 + top_k: 40 + top_p: 0.9 + keep_alive: "5m" +``` + +### Multiple Environments + +Use different config files for different environments: + +```bash +# Development +cp config/config.dev.yml config/config.local.yml + +# Production +cp config/config.prod.yml config/config.local.yml +``` + +Or use environment-specific variable prefixes: + +```bash +# Development +export DEV_OPENAI_API_KEY="dev-key" + +# Production +export PROD_OPENAI_API_KEY="prod-key" +``` + +## Troubleshooting + +### Config Not Loading + +**Symptom:** Server uses default values instead of your config + +**Solution:** +```bash +# Check if config.local.yml exists +ls -la config/config.local.yml + +# Check YAML syntax +python3 -c "import yaml; print(yaml.safe_load(open('config/config.local.yml')))" + +# Check logs for warnings +uv run src/graphiti_mcp_server.py --transport stdio 2>&1 | grep -i "config" +``` + +### Wrong Provider Detected + +**Symptom:** Server uses Ollama when you want OpenAI, or vice versa + +**Solution:** +```bash +# Check your base_url in config.local.yml +# Provider is auto-detected from the URL: +# localhost:11434 -> Ollama +# azure.com -> Azure OpenAI +# everything else -> OpenAI-compatible + +# If you want to use Ollama, set base_url to: +# base_url: "http://localhost:11434/v1" + +# If you want to use OpenAI/Bedrock, set base_url to your gateway: +# base_url: "https://your-gateway.com" +``` + +### Missing API Key + +**Symptom:** `OPENAI_API_KEY must be set` error + +**Solution:** +```bash +# Check if env var is set +echo $OPENAI_API_KEY + +# Set it properly +export OPENAI_API_KEY="your-key-here" + +# Or add to .env file +echo 'OPENAI_API_KEY="your-key-here"' >> .env +``` + +### Ollama Connection Failed + +**Symptom:** `Connection refused` to `localhost:11434` + +**Solution:** +```bash +# Check if Ollama is running +curl http://localhost:11434/api/tags + +# Start Ollama +ollama serve + +# Check if model is available +ollama list | grep nomic-embed-text + +# Pull model if missing +ollama pull nomic-embed-text +``` + +## Best Practices + +### 1. Keep Secrets in Environment Variables + +āœ… **DO:** +```yaml +# config.local.yml +llm: + model: "gpt-4" + base_url: "https://api.openai.com/v1" +``` + +```bash +# .env +OPENAI_API_KEY="sk-..." +``` + +āŒ **DON'T:** +```yaml +# config.local.yml +llm: + api_key: "sk-..." # Never commit API keys! +``` + +### 2. Use .gitignore + +Add to `.gitignore`: +``` +config/config.local.yml +config/**/*.local.yml +.env +``` + +### 3. Document Your Setup + +Create a `config/README.md` or `config/config.local.yml.example` with: +- Which providers you're using +- Required environment variables +- Setup instructions for new team members + +### 4. Test Configuration Changes + +```bash +# Always test after config changes +uv run src/graphiti_mcp_server.py --transport stdio + +# Check the startup logs for: +# "Using [Provider] LLM: [model] at [url]" +# "Using [Provider] embedder: [model] at [url]" +``` + +## See Also + +- [Mixed Provider Setup Guide](MIXED_PROVIDER_SETUP.md) - Detailed guide for mixing providers +- [Ollama Setup Guide](../README.md#ollama-setup) - Installing and configuring Ollama +- [Configuration Reference](../config/README.md) - All configuration options diff --git a/pyrightconfig.json b/pyrightconfig.json index f1006a8..c215936 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -6,7 +6,8 @@ "exclude": [ "**/node_modules", "**/__pycache__", - ".venv" + ".venv", + "scripts" ], "ignore": [ ".venv" diff --git a/scripts/debug_bedrock_connection.sh b/scripts/debug_bedrock_connection.sh new file mode 100755 index 0000000..81bf85d --- /dev/null +++ b/scripts/debug_bedrock_connection.sh @@ -0,0 +1,353 @@ +#!/bin/bash +# Debug script for testing Bedrock gateway connectivity +# Tests SSL certificates, API key, and endpoint accessibility + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Load environment variables from .env file if it exists +if [ -f .env ]; then + set -a # Automatically export all variables + source .env + set +a + echo -e "${GREEN}āœ“${NC} Loaded environment variables from .env file" + echo "" +fi + +print_header() { + echo "" + echo -e "${GREEN}========================================${NC}" + echo -e "${GREEN}$1${NC}" + echo -e "${GREEN}========================================${NC}" + echo "" +} + +print_success() { echo -e "${GREEN}āœ“ $1${NC}"; } +print_warning() { echo -e "${YELLOW}⚠ $1${NC}"; } +print_error() { echo -e "${RED}āœ— $1${NC}"; } +print_info() { echo -e "${BLUE}ℹ $1${NC}"; } + +# Helper function to list available models +list_available_models() { + local base_url=$1 + + echo "" + print_info "Checking available models at: $base_url/v1/models" + echo "" + + response=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \ + --cacert "$SSL_CERT_FILE" \ + -X GET "$base_url/v1/models" \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + --max-time 30 2>&1) + + http_status=$(echo "$response" | grep "HTTP_STATUS" | cut -d: -f2) + body=$(echo "$response" | sed '/HTTP_STATUS/d') + + if [ "$http_status" = "200" ]; then + print_success "Successfully retrieved model list (HTTP $http_status)" + echo "" + print_info "Available models:" + echo "$body" | python3 -c " +import json +import sys +try: + data = json.load(sys.stdin) + models = data.get('data', []) + if models: + for model in models: + model_id = model.get('id', 'N/A') + owned_by = model.get('owned_by', 'N/A') + print(f' • {model_id} (owned by: {owned_by})') + else: + print(' No models found in response') +except Exception as e: + print(f' Error parsing model list: {e}') +" 2>/dev/null || echo "$body" + else + print_error "Failed to retrieve model list (HTTP $http_status)" + echo "$body" | python3 -m json.tool 2>/dev/null || echo "$body" + fi + echo "" +} + +# Test 1: Check SSL Certificate +print_header "Test 1: SSL Certificate Check" + +if [ -f "$SSL_CERT_FILE" ]; then + print_success "Certificate file exists: $SSL_CERT_FILE" + + # Check if readable + if [ -r "$SSL_CERT_FILE" ]; then + print_success "Certificate file is readable" + + # Count certificates in bundle + cert_count=$(grep -c "BEGIN CERTIFICATE" "$SSL_CERT_FILE" || echo "0") + print_info "Certificate bundle contains $cert_count certificate(s)" + else + print_error "Certificate file is not readable" + exit 1 + fi +else + print_error "Certificate file not found: $SSL_CERT_FILE" + exit 1 +fi + +# Test 2: Check Environment Variables +print_header "Test 2: Environment Variables" + +echo "Checking SSL certificate variables:" +if [ ! -z "$SSL_CERT_FILE" ]; then + print_success "SSL_CERT_FILE: $SSL_CERT_FILE" +else + print_warning "SSL_CERT_FILE not set" + export SSL_CERT_FILE="$SSL_CERT_FILE" + print_info "Set SSL_CERT_FILE=$SSL_CERT_FILE" +fi + +if [ ! -z "$REQUESTS_CA_BUNDLE" ]; then + print_success "REQUESTS_CA_BUNDLE: $REQUESTS_CA_BUNDLE" +else + print_warning "REQUESTS_CA_BUNDLE not set" + export REQUESTS_CA_BUNDLE="$SSL_CERT_FILE" + print_info "Set REQUESTS_CA_BUNDLE=$SSL_CERT_FILE" +fi + +if [ ! -z "$CURL_CA_BUNDLE" ]; then + print_success "CURL_CA_BUNDLE: $CURL_CA_BUNDLE" +else + print_warning "CURL_CA_BUNDLE not set" + export CURL_CA_BUNDLE="$SSL_CERT_FILE" + print_info "Set CURL_CA_BUNDLE=$SSL_CERT_FILE" +fi + +echo "" +echo "Checking API key:" +if [ ! -z "$OPENAI_API_KEY" ]; then + masked_key=$(echo $OPENAI_API_KEY | sed 's/\(.\{5\}\).*\(.\{4\}\)/\1*************\2/') + print_success "OPENAI_API_KEY: $masked_key" +else + print_error "OPENAI_API_KEY not set" + print_info "Please set: export OPENAI_API_KEY='your-key-here'" + exit 1 +fi + +# Test 3: Load Configuration +print_header "Test 3: Configuration Check" + +CONFIG_FILE="config/providers/openai.local.yml" +if [ -f "$CONFIG_FILE" ]; then + print_success "Found config file: $CONFIG_FILE" + + # Extract base URLs + llm_base_url=$(grep -A 5 "^llm:" "$CONFIG_FILE" | grep "base_url:" | head -1 | awk '{print $2}' | tr -d '"') + embedder_base_url=$(grep -A 5 "^embedder:" "$CONFIG_FILE" | grep "base_url:" | head -1 | awk '{print $2}' | tr -d '"') + llm_model=$(grep -A 5 "^llm:" "$CONFIG_FILE" | grep "model:" | head -1 | awk '{print $2}' | tr -d '"') + embedder_model=$(grep -A 5 "^embedder:" "$CONFIG_FILE" | grep "model:" | head -1 | awk '{print $2}' | tr -d '"') + + print_info "LLM Base URL: $llm_base_url" + print_info "LLM Model: $llm_model" + print_info "Embedder Base URL: $embedder_base_url" + print_info "Embedder Model: $embedder_model" +else + print_error "Config file not found: $CONFIG_FILE" + exit 1 +fi + +# Test 4: Test SSL Connection with curl +print_header "Test 4: SSL Connection Test (curl)" + +# Extract gateway hostname from the LLM base URL +GATEWAY_HOST="${llm_base_url#https://}" +GATEWAY_HOST="${GATEWAY_HOST#http://}" +GATEWAY_HOST="${GATEWAY_HOST%%/*}" + +print_info "Testing SSL connection to: $GATEWAY_HOST" +echo "" + +if curl --cacert "$SSL_CERT_FILE" -s -o /dev/null -w "%{http_code}" "https://$GATEWAY_HOST" > /tmp/curl_test.txt 2>&1; then + status_code=$(cat /tmp/curl_test.txt) + if [ "$status_code" != "000" ]; then + print_success "SSL connection successful (HTTP $status_code)" + else + print_error "SSL connection failed" + curl --cacert "$SSL_CERT_FILE" -v "https://$GATEWAY_HOST" 2>&1 | head -20 + exit 1 + fi +else + print_error "curl command failed" + exit 1 +fi + +# Test 5: Test LLM Endpoint with curl +print_header "Test 5: LLM Endpoint Test" + +print_info "Testing: $llm_base_url/v1/chat/completions" +echo "" + +response=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \ + --cacert "$SSL_CERT_FILE" \ + -X POST "$llm_base_url/v1/chat/completions" \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + --max-time 30 \ + -d '{ + "model": "'"$llm_model"'", + "messages": [ + {"role": "user", "content": "Say: Connection test successful"} + ], + "max_tokens": 50 + }' 2>&1) + +http_status=$(echo "$response" | grep "HTTP_STATUS" | cut -d: -f2) +body=$(echo "$response" | sed '/HTTP_STATUS/d') + +if [ "$http_status" = "200" ]; then + print_success "LLM endpoint works! (HTTP $http_status)" + # Try to extract response content + content=$(echo "$body" | python3 -c "import json,sys; data=json.load(sys.stdin); print(data.get('choices', [{}])[0].get('message', {}).get('content', 'N/A'))" 2>/dev/null || echo "") + if [ ! -z "$content" ] && [ "$content" != "N/A" ]; then + print_info "Response: $content" + fi +elif [ "$http_status" = "401" ]; then + print_error "Authentication failed (HTTP 401)" + print_info "Your API key may not be valid for this endpoint" + error_msg=$(echo "$body" | python3 -c "import json,sys; data=json.load(sys.stdin); print(data.get('error', {}).get('message', 'Unknown error'))" 2>/dev/null || echo "") + if [ ! -z "$error_msg" ]; then + print_info "Error: $error_msg" + fi +elif [ "$http_status" = "404" ]; then + print_error "Endpoint not found (HTTP 404)" + print_warning "The /v1/chat/completions path may not exist" + print_info "Try updating base_url to include the correct path" + list_available_models "$llm_base_url" +else + print_error "Request failed (HTTP $http_status)" + echo "$body" | python3 -m json.tool 2>/dev/null || echo "$body" + + # Check if error message suggests checking available models + if echo "$body" | grep -q "model"; then + print_warning "Error mentions model - checking available models..." + list_available_models "$llm_base_url" + fi +fi + +# Test 6: Test Embedder Endpoint +print_header "Test 6: Embedder Endpoint Test" + +print_info "Testing: $embedder_base_url/embeddings" +echo "" + +response=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \ + --cacert "$SSL_CERT_FILE" \ + -X POST "$embedder_base_url/embeddings" \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + --max-time 30 \ + -d '{ + "model": "'"$embedder_model"'", + "input": "Connection test" + }' 2>&1) + +http_status=$(echo "$response" | grep "HTTP_STATUS" | cut -d: -f2) +body=$(echo "$response" | sed '/HTTP_STATUS/d') + +if [ "$http_status" = "200" ]; then + print_success "Embedder endpoint works! (HTTP $http_status)" + dimension=$(echo "$body" | python3 -c "import json,sys; data=json.load(sys.stdin); print(len(data.get('data', [{}])[0].get('embedding', [])))" 2>/dev/null || echo "N/A") + if [ "$dimension" != "N/A" ]; then + print_info "Embedding dimension: $dimension" + fi +elif [ "$http_status" = "401" ]; then + print_error "Authentication failed (HTTP 401)" + print_info "Your API key may not be valid for this endpoint" + list_available_models "$embedder_base_url" +elif [ "$http_status" = "404" ]; then + print_error "Endpoint not found (HTTP 404)" + print_warning "The embeddings path may not exist at this base_url" + print_info "Try: $llm_base_url/v1/embeddings" + list_available_models "$embedder_base_url" +else + print_error "Request failed (HTTP $http_status)" + echo "$body" | python3 -m json.tool 2>/dev/null || echo "$body" + + # Check if error message suggests checking available models + if echo "$body" | grep -q "model\|/v1/models"; then + print_warning "Error mentions models - checking available models..." + list_available_models "$embedder_base_url" + fi +fi + +# Test 7: Python SSL Test +print_header "Test 7: Python SSL Verification" + +print_info "Testing Python SSL certificate handling..." +echo "" + +# Export variables for Python script +export LLM_BASE_URL="$llm_base_url" + +python3 << 'EOF' +import os +import sys +import ssl +import httpx + +cert_path = os.path.expanduser(os.getenv("SSL_CERT_FILE", "")) +# Extract gateway host from LLM_BASE_URL environment variable +base_url = os.getenv("LLM_BASE_URL", "https://api.openai.com") +gateway_host = base_url.replace("https://", "").replace("http://", "").split("/")[0] + +print(f"Certificate path: {cert_path}") +print(f"File exists: {os.path.exists(cert_path)}") +print("") + +# Test 1: SSL context +try: + ssl_context = ssl.create_default_context(cafile=cert_path) + print("āœ“ SSL context created successfully") +except Exception as e: + print(f"āœ— Failed to create SSL context: {e}") + sys.exit(1) + +# Test 2: httpx with certificate +print("\nTesting httpx with certificate...") +try: + with httpx.Client(verify=cert_path, timeout=10.0) as client: + response = client.get(f"https://{gateway_host}") + print(f"āœ“ Connection successful (HTTP {response.status_code})") +except httpx.ConnectError as e: + print(f"āœ— Connection failed: {e}") + sys.exit(1) +except Exception as e: + print(f"⚠ Request completed but with error: {e}") + +print("\nāœ“ Python SSL verification works!") +EOF + +if [ $? -eq 0 ]; then + print_success "Python can use SSL certificates correctly" +else + print_error "Python SSL verification failed" + exit 1 +fi + +# Summary +print_header "Debug Summary" + +print_success "All connection tests completed!" +echo "" +print_info "Next steps:" +echo " 1. If LLM endpoint failed: Check the base_url path in config" +echo " 2. If embedder endpoint failed: Check the embedder base_url path" +echo " 3. If authentication failed: Verify your OPENAI_API_KEY is correct" +echo " 4. If model errors occurred: Check the available models list shown above" +echo " 5. Run the actual tests: scripts/test_bedrock_endpoint.sh --connection-only" +echo "" diff --git a/scripts/fix_docker_env.sh b/scripts/fix_docker_env.sh new file mode 100755 index 0000000..43741a5 --- /dev/null +++ b/scripts/fix_docker_env.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# Script to configure Docker-specific SSL environment variables in .env file +# This is useful when your host machine uses custom CA certificates that need +# to be available inside Docker containers. + +set -e + +ENV_FILE="${1:-.env}" + +echo "Configuring Docker SSL environment variables..." +echo "" + +# Check if .env file exists +if [ ! -f "$ENV_FILE" ]; then + echo "Error: $ENV_FILE not found" + echo "Please create a .env file first or copy from .env.example" + exit 1 +fi + +# Check if Docker SSL variables already exist +if grep -q "SSL_CERT_FILE_DOCKER" "$ENV_FILE"; then + echo "āœ“ Docker SSL variables already configured in $ENV_FILE" + exit 0 +fi + +# Prompt for custom certificate setup +echo "This script will add Docker SSL certificate configuration to your .env file." +echo "" +echo "Do you use custom CA certificates that need to be mounted in Docker? (y/n)" +read -r USE_CUSTOM_CERTS + +if [[ "$USE_CUSTOM_CERTS" != "y" && "$USE_CUSTOM_CERTS" != "Y" ]]; then + echo "Skipping SSL certificate configuration." + exit 0 +fi + +echo "" +echo "Enter the path to your CA certificate directory on the host:" +echo "Example: \${HOME}/.certs or /etc/ssl/certs" +read -r CERT_DIR + +echo "" +echo "Enter the filename of your CA bundle inside that directory:" +echo "Example: ca-bundle.pem or ca-certificates.crt" +read -r CERT_FILENAME + +# Add Docker SSL variables +echo "" >> "$ENV_FILE" +echo "# SSL Certificate Configuration for Docker Container" >> "$ENV_FILE" +echo "# Directory containing custom CA certificates (host path)" >> "$ENV_FILE" +echo "SSL_CERT_DIR=$CERT_DIR" >> "$ENV_FILE" +echo "" >> "$ENV_FILE" +echo "# CA certificate filename" >> "$ENV_FILE" +echo "SSL_CERT_FILENAME=$CERT_FILENAME" >> "$ENV_FILE" +echo "" >> "$ENV_FILE" +echo "# Docker container paths (automatically derived from above)" >> "$ENV_FILE" +echo "SSL_CERT_FILE_DOCKER=/app/.certs/$CERT_FILENAME" >> "$ENV_FILE" +echo "REQUESTS_CA_BUNDLE_DOCKER=/app/.certs/$CERT_FILENAME" >> "$ENV_FILE" + +echo "" +echo "āœ“ Successfully configured Docker SSL variables in $ENV_FILE" +echo "" + +# Uncomment volumes section in docker-compose.yml +if grep -q "^ # volumes:" docker-compose.yml; then + echo "Enabling SSL certificate volume mount in docker-compose.yml..." + # Create backup + cp docker-compose.yml docker-compose.yml.bak + # Uncomment the volumes section for graphiti-mcp service + sed -i.tmp '/graphiti-mcp:/,/^ [a-z]/ { + s/^ # volumes:/ volumes:/ + s/^ # - \${SSL_CERT_DIR}:/ - ${SSL_CERT_DIR}:/ + }' docker-compose.yml && rm docker-compose.yml.tmp + echo "āœ“ Enabled volume mount in docker-compose.yml" + echo " (Backup saved as docker-compose.yml.bak)" +else + echo "⚠ Volume mount may already be enabled in docker-compose.yml" +fi + +echo "" +echo "Next steps:" +echo "1. Verify the configuration in $ENV_FILE" +echo "2. Verify docker-compose.yml has volumes section uncommented" +echo "3. Run: just rebuild" diff --git a/scripts/setup_env.sh b/scripts/setup_env.sh new file mode 100755 index 0000000..d416e7f --- /dev/null +++ b/scripts/setup_env.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# Environment Setup Script +# Source this file to set up required environment variables for testing and running the MCP server +# +# Usage: +# source scripts/setup_env.sh +# OR +# . scripts/setup_env.sh + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}Setting up Graphiti MCP Server environment...${NC}" +echo "" + +# Set OPENAI_API_KEY if not already exported +if [ -z "$OPENAI_API_KEY" ]; then + # Try to get from .env file first + if [ -f .env ] && grep -q "^OPENAI_API_KEY=" .env; then + export OPENAI_API_KEY=$(grep "^OPENAI_API_KEY=" .env | cut -d= -f2- | tr -d '"' | tr -d "'") + if [ ! -z "$OPENAI_API_KEY" ]; then + masked_key=$(echo $OPENAI_API_KEY | sed 's/\(.\{5\}\).*\(.\{4\}\)/\1***\2/') + echo -e "${GREEN}āœ“${NC} OPENAI_API_KEY loaded from .env: $masked_key" + fi + fi + + # If still not set, prompt user + if [ -z "$OPENAI_API_KEY" ]; then + echo -e "${YELLOW}⚠${NC} OPENAI_API_KEY not set" + echo " Please set it manually:" + echo " export OPENAI_API_KEY='your-key-here'" + echo "" + fi +else + masked_key=$(echo $OPENAI_API_KEY | sed 's/\(.\{5\}\).*\(.\{4\}\)/\1***\2/') + echo -e "${GREEN}āœ“${NC} OPENAI_API_KEY already set: $masked_key" + # Make sure it's exported + export OPENAI_API_KEY +fi + +# Set SSL certificate paths for enterprise endpoints +if [ -f "$HOME/.certs/ca-bundle.pem" ]; then + export SSL_CERT_FILE="$HOME/.certs/ca-bundle.pem" + export REQUESTS_CA_BUNDLE="$SSL_CERT_FILE" + export CURL_CA_BUNDLE="$SSL_CERT_FILE" + echo -e "${GREEN}āœ“${NC} SSL certificates configured: $SSL_CERT_FILE" +elif [ ! -z "$SSL_CERT_FILE" ]; then + export SSL_CERT_FILE + export REQUESTS_CA_BUNDLE="$SSL_CERT_FILE" + export CURL_CA_BUNDLE="$SSL_CERT_FILE" + echo -e "${GREEN}āœ“${NC} SSL certificates configured: $SSL_CERT_FILE" +else + echo -e "${YELLOW}⚠${NC} SSL certificates not found (OK if not using enterprise gateway endpoints)" +fi + +# Set Neo4j connection if not already set +if [ -z "${NEO4J_URI:-}" ]; then + export NEO4J_URI="bolt://localhost:7687" + echo -e "${BLUE}ℹ${NC} NEO4J_URI set to default: $NEO4J_URI" +else + export NEO4J_URI + echo -e "${GREEN}āœ“${NC} NEO4J_URI: $NEO4J_URI" +fi + +if [ -z "${NEO4J_USER:-}" ]; then + export NEO4J_USER="neo4j" + echo -e "${BLUE}ℹ${NC} NEO4J_USER set to default: $NEO4J_USER" +else + export NEO4J_USER + echo -e "${GREEN}āœ“${NC} NEO4J_USER: $NEO4J_USER" +fi + +if [ -z "${NEO4J_PASSWORD:-}" ]; then + export NEO4J_PASSWORD="demodemo" + echo -e "${BLUE}ℹ${NC} NEO4J_PASSWORD set to default: ********" +else + export NEO4J_PASSWORD + echo -e "${GREEN}āœ“${NC} NEO4J_PASSWORD: ********" +fi + +echo "" +echo -e "${GREEN}Environment setup complete!${NC}" +echo "" +echo "Next steps:" +echo " 1. Test configuration: scripts/test_model_compatibility.sh" +echo " 2. Start MCP server: uv run src/graphiti_mcp_server.py" +echo "" diff --git a/scripts/test_bedrock_endpoint.py b/scripts/test_bedrock_endpoint.py new file mode 100644 index 0000000..0d1f94b --- /dev/null +++ b/scripts/test_bedrock_endpoint.py @@ -0,0 +1,488 @@ +""" +Test suite for validating custom OpenAI Bedrock endpoint configuration. + +This test suite verifies that the configured Bedrock gateway endpoint +configured in openai.local.yml works correctly with the Graphiti MCP server. + +Test Categories: +1. Configuration Loading Tests - Verify YAML config is loaded correctly +2. LLM Client Connection Tests - Verify LLM can connect and respond +3. Embedder Client Connection Tests - Verify embedder can connect and respond +4. Graphiti Integration Tests - Verify full pipeline with custom endpoint +5. Memory Operation Tests - Verify add_memory and search operations work +""" + +import asyncio +import logging +import os +import sys + +import pytest +from graphiti_core import Graphiti +from graphiti_core.llm_client.openai_client import OpenAIClient + +from src.config import GraphitiConfig, GraphitiEmbedderConfig, GraphitiLLMConfig +from src.config_loader import config_loader +from src.initialization.graphiti_client import initialize_graphiti +from src.models import ErrorResponse, SuccessResponse +from src.tools.memory_tools import add_memory + +# Skip all Bedrock tests as requested +pytestmark = pytest.mark.skip(reason="Bedrock tests skipped as requested") + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + stream=sys.stderr, +) +logger = logging.getLogger(__name__) + + +def get_ssl_verify_setting() -> bool | str: + """ + Get the appropriate SSL verification setting. + + Returns: + - Path to certificate bundle if SSL_CERT_FILE is set + - False if no certificate is configured (development/testing only) + """ + ssl_cert_file = os.getenv("SSL_CERT_FILE") + if ssl_cert_file: + cert_path = os.path.expanduser(ssl_cert_file) + if os.path.exists(cert_path): + logger.info(f"Using SSL certificate bundle: {cert_path}") + return cert_path + else: + logger.warning(f"SSL_CERT_FILE set but file not found: {cert_path}") + + # Fall back to disabling verification (development only) + logger.warning( + "SSL verification disabled - set SSL_CERT_FILE environment variable for production" + ) + return False + + +@pytest.mark.integration +class TestBedrockEndpointConfiguration: + """Test custom Bedrock endpoint configuration loading.""" + + def test_openai_local_config_loads(self): + """Verify that openai.local.yml is loaded correctly.""" + # Load the OpenAI provider config (should use openai.local.yml if it exists) + config = config_loader.load_provider_config("openai") + + assert config, "OpenAI configuration should not be empty" + assert "llm" in config, "LLM configuration should be present" + assert "embedder" in config, "Embedder configuration should be present" + + # Verify LLM configuration + llm_config = config["llm"] + assert "model" in llm_config, "LLM model should be configured" + assert "base_url" in llm_config, "LLM base_url should be configured" + + # Log the loaded configuration for debugging + logger.info(f"Loaded LLM model: {llm_config.get('model')}") + logger.info(f"Loaded LLM base_url: {llm_config.get('base_url')}") + logger.info(f"Loaded LLM temperature: {llm_config.get('temperature')}") + logger.info(f"Loaded LLM max_tokens: {llm_config.get('max_tokens')}") + + # Verify embedder configuration + embedder_config = config["embedder"] + assert "model" in embedder_config, "Embedder model should be configured" + assert "base_url" in embedder_config, "Embedder base_url should be configured" + + logger.info(f"Loaded embedder model: {embedder_config.get('model')}") + logger.info(f"Loaded embedder base_url: {embedder_config.get('base_url')}") + + def test_graphiti_llm_config_from_yaml(self): + """Verify GraphitiLLMConfig correctly loads from YAML.""" + # Set environment to use OpenAI (not Ollama) + original_use_ollama = os.environ.get("USE_OLLAMA") + os.environ["USE_OLLAMA"] = "false" + + try: + config = GraphitiLLMConfig.from_yaml_and_env() + + assert config.use_ollama is False, "Should not be using Ollama" + assert config.model, "Model should be configured" + assert config.max_tokens > 0, "Max tokens should be positive" + + logger.info(f"GraphitiLLMConfig model: {config.model}") + logger.info(f"GraphitiLLMConfig small_model: {config.small_model}") + logger.info(f"GraphitiLLMConfig temperature: {config.temperature}") + logger.info(f"GraphitiLLMConfig max_tokens: {config.max_tokens}") + + finally: + # Restore original environment + if original_use_ollama is not None: + os.environ["USE_OLLAMA"] = original_use_ollama + else: + os.environ.pop("USE_OLLAMA", None) + + +@pytest.mark.integration +@pytest.mark.asyncio +class TestBedrockEndpointConnection: + """Test connection and functionality of the custom Bedrock endpoint.""" + + @pytest.fixture + async def bedrock_llm_config(self) -> GraphitiLLMConfig: + """Create LLM config for Bedrock endpoint testing.""" + # Set environment to use OpenAI (not Ollama) + os.environ["USE_OLLAMA"] = "false" + + # Ensure API key is set + if not os.environ.get("OPENAI_API_KEY"): + pytest.skip("OPENAI_API_KEY environment variable not set") + + config = GraphitiLLMConfig.from_yaml_and_env() + + logger.info(f"Testing with model: {config.model}") + logger.info(f"Testing with max_tokens: {config.max_tokens}") + + return config + + async def test_llm_client_creation(self, bedrock_llm_config: GraphitiLLMConfig): + """Test that we can create an OpenAI client with custom base_url.""" + try: + llm_client = bedrock_llm_config.create_client() + + assert llm_client is not None, "LLM client should be created" + assert isinstance(llm_client, OpenAIClient), "Should be OpenAI client" + + logger.info("āœ“ LLM client created successfully") + + except Exception as e: + pytest.fail(f"Failed to create LLM client: {e}") + + async def test_llm_simple_completion(self, bedrock_llm_config: GraphitiLLMConfig): + """Test a simple LLM completion request to verify endpoint connectivity.""" + try: + llm_client = bedrock_llm_config.create_client() + + # Configure SSL certificate if needed + ssl_verify = get_ssl_verify_setting() + if ssl_verify is False: + logger.warning("SSL verification is disabled for this test") + elif isinstance(ssl_verify, str): + logger.info(f"Using SSL certificate: {ssl_verify}") + # Update the httpx client to use the certificate + import httpx + + llm_client.client._client = httpx.AsyncClient( + verify=ssl_verify, timeout=30.0 + ) + + # Test using the OpenAI client directly since graphiti-core's + # generate_response tries to modify messages as objects + from typing import cast + + from openai.types.chat import ChatCompletionMessageParam + + messages = cast( + list[ChatCompletionMessageParam], + [ + { + "role": "system", + "content": "You are a helpful assistant. Respond concisely.", + }, + { + "role": "user", + "content": "Say 'Hello, Bedrock endpoint is working!' and nothing else.", + }, + ], + ) + + logger.info("Sending test completion request to Bedrock endpoint...") + + # Use the underlying OpenAI client directly + response = await llm_client.client.chat.completions.create( + model=bedrock_llm_config.model, + messages=messages, + temperature=0.0, + max_tokens=100, + ) + + assert response, "Response should not be empty" + assert response.choices, "Response should have choices" + assert response.choices[0].message.content, "Response should have content" + + content = response.choices[0].message.content + logger.info(f"āœ“ LLM response received: {content[:100]}...") + + # Verify the response contains expected content + content_lower = content.lower() + assert any( + word in content_lower for word in ["hello", "bedrock", "working"] + ), "Response should contain expected keywords" + + except Exception as e: + logger.error(f"LLM completion test failed: {e}", exc_info=True) + pytest.fail(f"Failed to get LLM completion: {e}") + + async def test_embedder_connection(self): + """Test embedder connection to Bedrock endpoint.""" + # Set environment to use OpenAI (not Ollama) + os.environ["USE_OLLAMA"] = "false" + + # Ensure API key is set + if not os.environ.get("OPENAI_API_KEY"): + pytest.skip("OPENAI_API_KEY environment variable not set") + + try: + embedder_config = GraphitiEmbedderConfig.from_yaml_and_env() + embedder_client = embedder_config.create_client() + + assert embedder_client is not None, "Embedder client should be created" + + logger.info("āœ“ Embedder client created successfully") + logger.info(f"Embedder model: {embedder_config.model}") + + # Test embedding generation + test_text = "This is a test sentence for embedding." + logger.info("Generating test embedding...") + + # OpenAIEmbedder uses create() method, not get_embedding() + embedding = await embedder_client.create([test_text]) + + assert embedding is not None, "Embedding should not be None" + assert len(embedding) > 0, "Embedding should not be empty" + assert len(embedding[0]) > 0, "Embedding should have non-zero dimensions" + + embedding_dim = len(embedding[0]) + logger.info( + f"āœ“ Embedding generated successfully with dimension {embedding_dim}" + ) + + # Verify it's a reasonable embedding dimension (typically 768, 1536, 3072, etc.) + assert embedding_dim > 100, ( + f"Embedding dimension {embedding_dim} seems too small" + ) + + except Exception as e: + logger.error(f"Embedder test failed: {e}", exc_info=True) + pytest.fail(f"Failed to test embedder: {e}") + + +@pytest.mark.integration +@pytest.mark.asyncio +class TestGraphitiBedrockIntegration: + """Test full Graphiti integration with Bedrock endpoint.""" + + @pytest.fixture + async def test_config(self) -> GraphitiConfig: + """Create test configuration using Bedrock endpoint.""" + from src.config import Neo4jConfig + + # Set environment to use OpenAI (not Ollama) + os.environ["USE_OLLAMA"] = "false" + + # Ensure API key is set + if not os.environ.get("OPENAI_API_KEY"): + pytest.skip("OPENAI_API_KEY environment variable not set") + + # Ensure Neo4j connection details are set + neo4j_uri = os.getenv( + "NEO4J_URI", os.getenv("TEST_NEO4J_URI", "bolt://localhost:7687") + ) + neo4j_user = os.getenv("NEO4J_USER", os.getenv("TEST_NEO4J_USER", "neo4j")) + neo4j_password = os.getenv( + "NEO4J_PASSWORD", os.getenv("TEST_NEO4J_PASSWORD", "password") + ) + + return GraphitiConfig( + neo4j=Neo4jConfig( + uri=neo4j_uri, + user=neo4j_user, + password=neo4j_password, + ), + llm=GraphitiLLMConfig.from_yaml_and_env(), + embedder=GraphitiEmbedderConfig.from_yaml_and_env(), + use_custom_entities=False, # Disable custom entities for simpler testing + group_id="bedrock-endpoint-test", + ) + + @pytest.fixture + async def graphiti_client(self, test_config: GraphitiConfig): + """Create Graphiti client with Bedrock endpoint configuration.""" + try: + logger.info("Creating Graphiti client with Bedrock endpoint...") + + # Create the client (initialize_graphiti already builds indices and constraints) + client = await initialize_graphiti(test_config) + + # Clear any existing test data + logger.info("Cleaning up existing test data...") + await self._cleanup_test_data(client, "bedrock-endpoint-test") + + logger.info("āœ“ Graphiti client created and initialized") + + yield client + + # Cleanup after tests + logger.info("Cleaning up after tests...") + await self._cleanup_test_data(client, "bedrock-endpoint-test") + await client.close() + + except Exception as e: + logger.error(f"Failed to create Graphiti client: {e}", exc_info=True) + pytest.skip(f"Could not initialize Graphiti client: {e}") + + async def _cleanup_test_data(self, client: Graphiti, group_id: str): + """Clean up test data from the graph.""" + try: + # Get all episodes for the test group + episodes = await client.get_episodes(group_id=group_id, last_n=1000) + + # Delete each episode + for episode in episodes: + try: + await client.delete_episode(episode.uuid) + except Exception as e: + logger.warning(f"Failed to delete episode {episode.uuid}: {e}") + + except Exception as e: + logger.warning(f"Cleanup warning: {e}") + + async def test_graphiti_add_episode( + self, graphiti_client: Graphiti, test_config: GraphitiConfig + ): + """Test adding an episode through Graphiti with Bedrock endpoint.""" + try: + episode_body = ( + "This is a test episode to verify the Bedrock endpoint integration. " + "The custom OpenAI-compatible endpoint should process this text and " + "extract entities and relationships." + ) + + logger.info("Adding test episode...") + + result = await graphiti_client.add_episode( + name="Bedrock Endpoint Test", + episode_body=episode_body, + source_description="Integration test", + group_id=test_config.group_id, + ) + + assert result is not None, "Episode result should not be None" + assert hasattr(result, "episode_uuid"), "Result should have episode_uuid" + + logger.info(f"āœ“ Episode added successfully: {result.episode_uuid}") + + # Wait a bit for processing + await asyncio.sleep(2) + + # Verify episode was stored + episodes = await graphiti_client.get_episodes( + group_id=test_config.group_id, last_n=10 + ) + + assert len(episodes) > 0, "Should have at least one episode" + logger.info(f"āœ“ Found {len(episodes)} episode(s) in the graph") + + except Exception as e: + logger.error(f"Add episode test failed: {e}", exc_info=True) + pytest.fail(f"Failed to add episode: {e}") + + async def test_graphiti_search_nodes( + self, graphiti_client: Graphiti, test_config: GraphitiConfig + ): + """Test searching nodes with Bedrock endpoint.""" + try: + # First add some content + episode_body = ( + "Alice is a software engineer at TechCorp. " + "She works on machine learning projects using Python. " + "Alice collaborates with Bob, who is a data scientist." + ) + + logger.info("Adding episode with entities...") + + await graphiti_client.add_episode( + name="Entity Test", + episode_body=episode_body, + source_description="Entity extraction test", + group_id=test_config.group_id, + ) + + # Wait for processing + logger.info("Waiting for background processing...") + await asyncio.sleep(5) + + # Search for nodes + logger.info("Searching for nodes...") + + search_results = await graphiti_client.search( + query="Tell me about Alice and her work", + group_ids=[test_config.group_id], + num_results=10, + ) + + assert search_results is not None, "Search results should not be None" + logger.info( + f"āœ“ Search completed, found {len(search_results.edges) if hasattr(search_results, 'edges') else 0} results" + ) + + except Exception as e: + logger.error(f"Search test failed: {e}", exc_info=True) + # Don't fail the test if search doesn't return results immediately + logger.warning(f"Search test completed with warning: {e}") + + +@pytest.mark.integration +@pytest.mark.asyncio +class TestMCPToolsWithBedrock: + """Test MCP tools with Bedrock endpoint.""" + + async def test_add_memory_tool(self): + """Test add_memory MCP tool with Bedrock endpoint.""" + # Set environment to use OpenAI (not Ollama) + os.environ["USE_OLLAMA"] = "false" + + # Ensure API key is set + if not os.environ.get("OPENAI_API_KEY"): + pytest.skip("OPENAI_API_KEY environment variable not set") + + try: + # Test the add_memory tool + result = await add_memory( + name="MCP Tool Test", + episode_body="Testing the MCP add_memory tool with Bedrock endpoint integration.", + group_id="bedrock-mcp-test", + source="text", + source_description="MCP integration test", + ) + + assert result is not None, "Result should not be None" + + if isinstance(result, ErrorResponse): + logger.error(f"Error response: {result.error}") + pytest.fail(f"add_memory returned error: {result.error}") + + assert isinstance(result, SuccessResponse), "Should return SuccessResponse" + logger.info(f"āœ“ add_memory tool successful: {result.message}") + + except Exception as e: + logger.error(f"MCP tool test failed: {e}", exc_info=True) + pytest.fail(f"Failed to test add_memory tool: {e}") + + +if __name__ == "__main__": + """ + Run tests directly with pytest. + + Usage: + # Run all Bedrock endpoint tests + pytest tests/test_bedrock_endpoint.py -v + + # Run specific test class + pytest tests/test_bedrock_endpoint.py::TestBedrockEndpointConfiguration -v + + # Run with detailed output + pytest tests/test_bedrock_endpoint.py -v -s + + # Run and stop on first failure + pytest tests/test_bedrock_endpoint.py -x -v + """ + pytest.main([__file__, "-v", "-s"]) diff --git a/scripts/test_config.sh b/scripts/test_config.sh new file mode 100755 index 0000000..7d495b4 --- /dev/null +++ b/scripts/test_config.sh @@ -0,0 +1,610 @@ +#!/usr/bin/env zsh +# Configuration Test Script for Graphiti MCP Server +# +# This script validates your entire configuration setup including: +# - Config file validity +# - LLM connectivity +# - Embedder connectivity +# - Neo4j connectivity +# - Environment variables + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Print section header +print_header() { + echo "" + echo -e "${CYAN}========================================${NC}" + echo -e "${CYAN}$1${NC}" + echo -e "${CYAN}========================================${NC}" + echo "" +} + +# Print success message +print_success() { + echo -e "${GREEN}āœ“ $1${NC}" +} + +# Print warning message +print_warning() { + echo -e "${YELLOW}⚠ $1${NC}" +} + +# Print error message +print_error() { + echo -e "${RED}āœ— $1${NC}" +} + +# Print info message +print_info() { + echo -e "${BLUE}ℹ $1${NC}" +} + +# Track test results +TOTAL_TESTS=0 +PASSED_TESTS=0 +FAILED_TESTS=0 +WARNINGS=0 + +# Test result tracking +test_pass() { + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + PASSED_TESTS=$((PASSED_TESTS + 1)) + print_success "$1" +} + +test_fail() { + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + FAILED_TESTS=$((FAILED_TESTS + 1)) + print_error "$1" +} + +test_warn() { + WARNINGS=$((WARNINGS + 1)) + print_warning "$1" +} + +# Banner +echo "" +echo -e "${CYAN}╔════════════════════════════════════════════╗${NC}" +echo -e "${CYAN}ā•‘ Graphiti Configuration Test Suite ā•‘${NC}" +echo -e "${CYAN}╔════════════════════════════════════════════╗${NC}" +echo "" + +# Check if .env file exists and source it +if [ -f .env ]; then + print_success "Found .env file, loading environment variables..." + export $(cat .env | grep -v '^#' | xargs) +else + test_warn "No .env file found, using system environment variables" +fi + +# Check and setup SSL certificates for internal endpoints +if [ -f "$SSL_CERT_FILE" ]; then + print_success "Found SSL certificate bundle: $SSL_CERT_FILE" + export SSL_CERT_FILE="$SSL_CERT_FILE" + export REQUESTS_CA_BUNDLE="$SSL_CERT_FILE" + export CURL_CA_BUNDLE="$SSL_CERT_FILE" + print_info "SSL environment variables configured for Python and curl" +else + print_info "No custom SSL certificate found (using system defaults)" +fi + +# ============================================================================ +# Test 1: Configuration File Validation +# ============================================================================ +print_header "Test 1: Configuration File Validation" + +# Check for config.local.yml +if [ -f "config/config.local.yml" ]; then + test_pass "config/config.local.yml exists" + + # Validate YAML syntax + if python3 -c "import yaml; yaml.safe_load(open('config/config.local.yml'))" 2>/dev/null; then + test_pass "config/config.local.yml has valid YAML syntax" + + # Extract and display configuration + config_summary=$(python3 << 'EOF' +import yaml +with open('config/config.local.yml') as f: + config = yaml.safe_load(f) + +llm = config.get('llm', {}) +embedder = config.get('embedder', {}) + +print(f"LLM Model: {llm.get('model', 'N/A')}") +print(f"LLM Base URL: {llm.get('base_url', 'N/A')}") +print(f"Embedder Model: {embedder.get('model', 'N/A')}") +print(f"Embedder Base URL: {embedder.get('base_url', 'N/A')}") +EOF +) + echo "$config_summary" | while IFS= read -r line; do + print_info "$line" + done + else + test_fail "config/config.local.yml has invalid YAML syntax" + fi +else + test_fail "config/config.local.yml not found" + print_info "Create it with: cp config/config.local.yml.example config/config.local.yml" +fi + +# ============================================================================ +# Test 2: Environment Variables +# ============================================================================ +print_header "Test 2: Environment Variables" + +# Check OPENAI_API_KEY +if [ ! -z "$OPENAI_API_KEY" ]; then + test_pass "OPENAI_API_KEY is set" + # Mask the key for security + masked_key="${OPENAI_API_KEY:0:7}...${OPENAI_API_KEY: -4}" + print_info "Value: $masked_key" +else + test_warn "OPENAI_API_KEY not set (required for OpenAI/Bedrock LLM)" +fi + +# Check Neo4j variables +if [ ! -z "$NEO4J_URI" ]; then + test_pass "NEO4J_URI is set: $NEO4J_URI" +else + test_fail "NEO4J_URI not set" +fi + +if [ ! -z "$NEO4J_USER" ]; then + test_pass "NEO4J_USER is set: $NEO4J_USER" +else + test_fail "NEO4J_USER not set" +fi + +if [ ! -z "$NEO4J_PASSWORD" ]; then + test_pass "NEO4J_PASSWORD is set" +else + test_fail "NEO4J_PASSWORD not set" +fi + +# ============================================================================ +# Test 3: Python Dependencies +# ============================================================================ +print_header "Test 3: Python Dependencies" + +# Check if Python 3 is available +if command -v python3 &> /dev/null; then + python_version=$(python3 --version) + test_pass "Python 3 is installed: $python_version" +else + test_fail "Python 3 not found" + exit 1 +fi + +# Check if uv is available +if command -v uv &> /dev/null; then + uv_version=$(uv --version) + test_pass "uv is installed: $uv_version" +else + test_warn "uv not installed (optional, but recommended)" +fi + +# Test Python imports +python3 << 'EOF' +import sys +import os + +# Suppress warnings +import warnings +warnings.filterwarnings('ignore') + +try: + import yaml + print("āœ“ yaml") +except ImportError: + print("āœ— yaml (required)") + sys.exit(1) + +try: + import neo4j + print("āœ“ neo4j") +except ImportError: + print("āœ— neo4j (required)") + sys.exit(1) + +try: + from graphiti_core import Graphiti + print("āœ“ graphiti_core") +except ImportError: + print("āœ— graphiti_core (required)") + sys.exit(1) + +try: + from openai import OpenAI + print("āœ“ openai") +except ImportError: + print("āœ— openai (required)") + sys.exit(1) + +EOF + +if [ $? -eq 0 ]; then + test_pass "All required Python packages are installed" +else + test_fail "Some required Python packages are missing" + print_info "Run: uv sync --extra dev" +fi + +# ============================================================================ +# Test 4: Neo4j Connectivity +# ============================================================================ +print_header "Test 4: Neo4j Connectivity" + +if [ ! -z "$NEO4J_URI" ] && [ ! -z "$NEO4J_USER" ] && [ ! -z "$NEO4J_PASSWORD" ]; then + print_info "NEO4J_URI: $NEO4J_URI" + + # Check if using Docker hostname (will hang if trying to connect) + if [[ "$NEO4J_URI" =~ "://neo4j:" ]] || [[ "$NEO4J_URI" =~ "://db:" ]]; then + test_warn "Docker hostname detected in NEO4J_URI" + print_info "Cannot test connection with Docker hostname outside container" + print_info "Running inside Docker? Make sure: docker compose up" + print_info "Running locally? Change NEO4J_URI to: bolt://localhost:7687" + else + # Safe to test connection + print_info "Testing connection..." + + neo4j_result=$(python3 -c " +import sys +from neo4j import GraphDatabase +try: + driver = GraphDatabase.driver('$NEO4J_URI', auth=('$NEO4J_USER', '$NEO4J_PASSWORD'), connection_timeout=5) + driver.verify_connectivity() + driver.close() + print('SUCCESS') +except Exception as e: + print(f'ERROR: {e}') + sys.exit(1) +" 2>&1) + + if echo "$neo4j_result" | grep -q "SUCCESS"; then + test_pass "Neo4j connection successful" + else + test_fail "Neo4j connection failed" + error_msg=$(echo "$neo4j_result" | grep "ERROR:" | sed 's/ERROR: //') + if [ ! -z "$error_msg" ]; then + print_info "$error_msg" + fi + print_info "Make sure Neo4j is running:" + print_info " docker run -p 7474:7474 -p 7687:7687 neo4j:5.26.2" + fi + fi +else + test_fail "Cannot test Neo4j - missing environment variables" +fi + +# ============================================================================ +# Test 5: LLM Configuration +# ============================================================================ +print_header "Test 5: LLM Configuration" + +llm_test=$(python3 << 'EOFPYTHON' +import sys +import os + +sys.path.insert(0, 'src') + +try: + from config import GraphitiLLMConfig + + config = GraphitiLLMConfig.from_yaml_and_env() + + print(f"Model: {config.model}") + print(f"Use Ollama: {config.use_ollama}") + + if config.use_ollama: + print(f"Ollama Base URL: {config.ollama_base_url}") + print(f"Ollama Model: {config.ollama_llm_model}") + + print("SUCCESS") + +except Exception as e: + print(f"ERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) +EOFPYTHON +) + +if [ $? -eq 0 ]; then + test_pass "LLM configuration loaded successfully" + echo "$llm_test" | grep -v "SUCCESS" | while IFS= read -r line; do + print_info "$line" + done +else + test_fail "LLM configuration failed to load" + echo "$llm_test" +fi + +# ============================================================================ +# Test 6: Embedder Configuration +# ============================================================================ +print_header "Test 6: Embedder Configuration" + +embedder_test=$(python3 << 'EOFPYTHON' +import sys +import os + +sys.path.insert(0, 'src') + +try: + from config import GraphitiEmbedderConfig + + config = GraphitiEmbedderConfig.from_yaml_and_env() + + print(f"Model: {config.model}") + print(f"Use Ollama: {config.use_ollama}") + + if config.use_ollama: + print(f"Ollama Base URL: {config.ollama_base_url}") + print(f"Ollama Model: {config.ollama_embedding_model}") + print(f"Dimension: {config.ollama_embedding_dim}") + else: + if hasattr(config, 'base_url') and config.base_url: + print(f"Base URL: {config.base_url}") + if hasattr(config, 'dimension') and config.dimension: + print(f"Dimension: {config.dimension}") + + print("SUCCESS") + +except Exception as e: + print(f"ERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) +EOFPYTHON +) + +if [ $? -eq 0 ]; then + test_pass "Embedder configuration loaded successfully" + echo "$embedder_test" | grep -v "SUCCESS" | while IFS= read -r line; do + print_info "$line" + done +else + test_fail "Embedder configuration failed to load" + echo "$embedder_test" +fi + +# ============================================================================ +# Test 7: Ollama Connectivity (if applicable) +# ============================================================================ +print_header "Test 7: Ollama Connectivity" + +# Check if config uses Ollama +uses_ollama=$(python3 << 'EOF' +import yaml +try: + with open('config/config.local.yml') as f: + config = yaml.safe_load(f) + + llm_url = config.get('llm', {}).get('base_url', '') + embedder_url = config.get('embedder', {}).get('base_url', '') + + if 'localhost:11434' in llm_url or 'localhost:11434' in embedder_url: + print("YES") + else: + print("NO") +except: + print("NO") +EOF +) + +if [ "$uses_ollama" = "YES" ]; then + print_info "Configuration uses Ollama, testing connectivity..." + + # Check if Ollama is installed + if command -v ollama &> /dev/null; then + test_pass "Ollama is installed" + + # Check if Ollama is running + if curl -s http://localhost:11434/api/tags > /dev/null 2>&1; then + test_pass "Ollama is running" + + # Check for required models + models=$(curl -s http://localhost:11434/api/tags | python3 -c "import json,sys; data=json.load(sys.stdin); print(' '.join([m['name'] for m in data.get('models', [])]))" 2>/dev/null) + + # Check for embedding model + if echo "$models" | grep -q "nomic-embed-text"; then + test_pass "nomic-embed-text model is available" + else + test_warn "nomic-embed-text model not found" + print_info "Run: ollama pull nomic-embed-text" + fi + + # Check for LLM model if using Ollama + llm_model=$(python3 -c "import yaml; config=yaml.safe_load(open('config/config.local.yml')); print(config.get('llm',{}).get('model',''))" 2>/dev/null) + if [ ! -z "$llm_model" ] && [ "$llm_model" != "None" ]; then + if echo "$models" | grep -q "$llm_model"; then + test_pass "LLM model '$llm_model' is available" + else + test_warn "LLM model '$llm_model' not found in Ollama" + print_info "Run: ollama pull $llm_model" + fi + fi + else + test_fail "Ollama is not running" + print_info "Start Ollama with: ollama serve" + fi + else + test_fail "Ollama is not installed" + print_info "Install: curl -fsSL https://ollama.com/install.sh | sh" + fi +else + print_info "Configuration does not use Ollama (skipping Ollama tests)" +fi + +# ============================================================================ +# Test 8: LLM API Connectivity +# ============================================================================ +print_header "Test 8: LLM API Connectivity" + +# Get LLM base URL +llm_base_url=$(python3 -c "import yaml; config=yaml.safe_load(open('config/config.local.yml')); print(config.get('llm',{}).get('base_url',''))" 2>/dev/null) + +if [ ! -z "$llm_base_url" ] && [ "$llm_base_url" != "None" ]; then + # Skip if it's localhost (Ollama tested separately) + if [[ ! "$llm_base_url" =~ "localhost" ]]; then + print_info "Testing connectivity to: $llm_base_url" + + # Build curl command with SSL certificate if available + curl_cmd="curl -s -o /dev/null -w %{http_code} --max-time 5" + if [ -f "$SSL_CERT_FILE" ]; then + curl_cmd="$curl_cmd --cacert $SSL_CERT_FILE" + print_info "Using SSL certificate for connection test" + fi + + # Try to reach the endpoint + http_code=$(eval "$curl_cmd $llm_base_url" 2>&1) + + # Accept any valid HTTP response code (2xx, 3xx redirects, 4xx client errors from server) + if echo "$http_code" | grep -q "200\|201\|204\|301\|302\|303\|307\|308\|400\|401\|403\|404\|405"; then + test_pass "LLM endpoint is reachable (HTTP $http_code)" + if [ "$http_code" = "404" ]; then + print_info "Got 404 - base_url may need /v1 path appended" + elif echo "$http_code" | grep -q "301\|302\|303\|307\|308"; then + print_info "Got redirect - endpoint is responding correctly" + fi + else + test_warn "LLM endpoint may not be reachable (HTTP $http_code - check VPN/network)" + fi + fi +fi + +# ============================================================================ +# Test 9: Embedder API Connectivity +# ============================================================================ +print_header "Test 9: Embedder API Connectivity" + +# Get embedder base URL +embedder_base_url=$(python3 -c "import yaml; config=yaml.safe_load(open('config/config.local.yml')); print(config.get('embedder',{}).get('base_url',''))" 2>/dev/null) + +if [ ! -z "$embedder_base_url" ] && [ "$embedder_base_url" != "None" ]; then + # Skip if it's localhost (Ollama tested separately) + if [[ ! "$embedder_base_url" =~ "localhost" ]]; then + print_info "Testing connectivity to: $embedder_base_url" + + # Build curl command with SSL certificate if available + curl_cmd="curl -s -o /dev/null -w %{http_code} --max-time 5" + if [ -f "$SSL_CERT_FILE" ]; then + curl_cmd="$curl_cmd --cacert $SSL_CERT_FILE" + print_info "Using SSL certificate for connection test" + fi + + # Try to reach the endpoint + http_code=$(eval "$curl_cmd $embedder_base_url" 2>&1) + + # Accept any valid HTTP response code (2xx, 3xx redirects, 4xx client errors from server) + if echo "$http_code" | grep -q "200\|201\|204\|301\|302\|303\|307\|308\|400\|401\|403\|404\|405"; then + test_pass "Embedder endpoint is reachable (HTTP $http_code)" + if [ "$http_code" = "404" ]; then + print_info "Got 404 - base_url may need correct path appended" + elif echo "$http_code" | grep -q "301\|302\|303\|307\|308"; then + print_info "Got redirect - endpoint is responding correctly" + fi + else + test_warn "Embedder endpoint may not be reachable (HTTP $http_code - check VPN/network)" + fi + else + print_info "Using local Ollama for embeddings (tested in Test 7)" + fi +fi + +# ============================================================================ +# Test 10: Python SSL Certificate Handling +# ============================================================================ +print_header "Test 10: Python SSL Certificate Handling" + +if [ -f "$SSL_CERT_FILE" ]; then + print_info "Testing Python SSL certificate configuration..." + + python3 << 'EOF' +import os +import sys + +# Check environment variables +ssl_cert_file = os.getenv("SSL_CERT_FILE") +requests_ca_bundle = os.getenv("REQUESTS_CA_BUNDLE") +curl_ca_bundle = os.getenv("CURL_CA_BUNDLE") + +if ssl_cert_file: + print(f"āœ“ SSL_CERT_FILE: {ssl_cert_file}") +else: + print("⚠ SSL_CERT_FILE not set") + +if requests_ca_bundle: + print(f"āœ“ REQUESTS_CA_BUNDLE: {requests_ca_bundle}") +else: + print("⚠ REQUESTS_CA_BUNDLE not set") + +if curl_ca_bundle: + print(f"āœ“ CURL_CA_BUNDLE: {curl_ca_bundle}") +else: + print("⚠ CURL_CA_BUNDLE not set") + +# Test if the certificate file is readable +if ssl_cert_file and os.path.exists(ssl_cert_file): + print(f"āœ“ Certificate file exists and is readable") +else: + print("āœ— Certificate file not found or not readable") + sys.exit(1) + +print("\nāœ“ Python SSL environment is properly configured") +EOF + + if [ $? -eq 0 ]; then + test_pass "Python SSL certificate handling is configured correctly" + else + test_fail "Python SSL certificate configuration has issues" + fi +else + print_info "No custom SSL certificate configured (using system defaults)" +fi + +# ============================================================================ +# Summary +# ============================================================================ +print_header "Test Summary" + +echo -e "${CYAN}Total Tests:${NC} $TOTAL_TESTS" +echo -e "${GREEN}Passed:${NC} $PASSED_TESTS" +echo -e "${RED}Failed:${NC} $FAILED_TESTS" +echo -e "${YELLOW}Warnings:${NC} $WARNINGS" +echo "" + +if [ $FAILED_TESTS -eq 0 ]; then + echo -e "${GREEN}╔════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}ā•‘ āœ“ All critical tests passed! ā•‘${NC}" + echo -e "${GREEN}ā•‘ Your configuration is ready to use. ā•‘${NC}" + echo -e "${GREEN}ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•${NC}" + echo "" + + if [ $WARNINGS -gt 0 ]; then + print_warning "There are $WARNINGS warning(s) - review them above" + fi + + print_info "Start the server with:" + echo " uv run src/graphiti_mcp_server.py --transport stdio" + echo "" + exit 0 +else + echo -e "${RED}╔════════════════════════════════════════════╗${NC}" + echo -e "${RED}ā•‘ āœ— Configuration has issues ā•‘${NC}" + echo -e "${RED}ā•‘ Fix the failed tests before continuing. ā•‘${NC}" + echo -e "${RED}ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•${NC}" + echo "" + print_info "Review the failed tests above and fix the issues" + echo "" + exit 1 +fi diff --git a/scripts/test_model_compatibility.sh b/scripts/test_model_compatibility.sh new file mode 100755 index 0000000..0005f70 --- /dev/null +++ b/scripts/test_model_compatibility.sh @@ -0,0 +1,479 @@ +#!/bin/bash +# Test Model Compatibility Script +# Validates that configured models work with their specified parameters +# Tests actual API calls to catch issues like temperature constraints + +set -e + +# Colors for output (define early so we can use them) +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Load environment variables from .env file if it exists +ENV_FILE_LOADED=false +if [ -f .env ]; then + set -a # Automatically export all variables + source .env + set +a + ENV_FILE_LOADED=true + echo -e "${GREEN}āœ“${NC} Loaded environment variables from .env file" +fi + +print_header() { + echo "" + echo -e "${GREEN}========================================${NC}" + echo -e "${GREEN}$1${NC}" + echo -e "${GREEN}========================================${NC}" + echo "" +} + +print_success() { echo -e "${GREEN}āœ“ $1${NC}"; } +print_warning() { echo -e "${YELLOW}⚠ $1${NC}"; } +print_error() { echo -e "${RED}āœ— $1${NC}"; } +print_info() { echo -e "${BLUE}ℹ $1${NC}"; } + +# Parse YAML function (simple grep-based parser) +get_yaml_value() { + local file=$1 + local section=$2 + local key=$3 + + # Extract value from YAML using awk + awk -v section="$section" -v key="$key" ' + BEGIN { in_section=0 } + /^[a-z_]+:/ { + in_section=0 + if ($0 ~ "^" section ":") in_section=1 + } + in_section && $0 ~ "^ " key ":" { + sub(/^ [^:]+: */, "") + gsub(/"/, "") + print + exit + } + ' "$file" +} + +# Test 1: Load Configuration +print_header "Test 1: Configuration Loading" + +CONFIG_FILE="${1:-config/config.local.yml}" + +if [ ! -f "$CONFIG_FILE" ]; then + print_error "Config file not found: $CONFIG_FILE" + print_info "Usage: $0 [config_file]" + print_info "Default: config/config.local.yml" + exit 1 +fi + +print_success "Found config file: $CONFIG_FILE" + +# Extract LLM configuration +llm_model=$(get_yaml_value "$CONFIG_FILE" "llm" "model") +llm_base_url=$(get_yaml_value "$CONFIG_FILE" "llm" "base_url") +llm_temperature=$(get_yaml_value "$CONFIG_FILE" "llm" "temperature") +llm_max_tokens=$(get_yaml_value "$CONFIG_FILE" "llm" "max_tokens") +llm_use_ollama=$(get_yaml_value "$CONFIG_FILE" "llm" "use_ollama") + +# Extract embedder configuration +embedder_model=$(get_yaml_value "$CONFIG_FILE" "embedder" "model") +embedder_base_url=$(get_yaml_value "$CONFIG_FILE" "embedder" "base_url") +embedder_dimension=$(get_yaml_value "$CONFIG_FILE" "embedder" "dimension") + +echo "" +print_info "LLM Configuration:" +echo " Model: $llm_model" +echo " Base URL: $llm_base_url" +echo " Temperature: $llm_temperature" +echo " Max Tokens: $llm_max_tokens" +echo " Use Ollama: $llm_use_ollama" + +echo "" +print_info "Embedder Configuration:" +echo " Model: $embedder_model" +echo " Base URL: $embedder_base_url" +echo " Dimension: $embedder_dimension" + +# Test 2: Check Prerequisites +print_header "Test 2: Prerequisites Check" + +# Check API Key +if [ -z "$OPENAI_API_KEY" ]; then + print_error "OPENAI_API_KEY not found" + echo "" + if [ -f .env ]; then + print_info "Checked .env file but OPENAI_API_KEY is not set there." + print_info "Add it to .env file:" + echo "" + echo " echo 'OPENAI_API_KEY=your-key-here' >> .env" + echo "" + else + print_info "No .env file found. Create one with:" + echo "" + echo " echo 'OPENAI_API_KEY=your-key-here' > .env" + echo "" + fi + print_info "OR export it directly:" + echo "" + echo " export OPENAI_API_KEY='your-key-here'" + echo "" + exit 1 +fi +masked_key=$(echo $OPENAI_API_KEY | sed 's/\(.\{5\}\).*\(.\{4\}\)/\1*************\2/') +if [ "$ENV_FILE_LOADED" = true ]; then + print_success "API Key loaded from .env: $masked_key" +else + print_success "API Key set: $masked_key" +fi + +# Check SSL certificate for remote endpoints +needs_ssl=false +if [[ "$llm_base_url" == https://* ]] || [[ "$embedder_base_url" == https://* ]]; then + needs_ssl=true +fi + +if [ "$needs_ssl" = true ]; then + if [ -z "$SSL_CERT_FILE" ] || [ ! -f "$SSL_CERT_FILE" ]; then + print_warning "SSL certificate not configured for enterprise endpoint" + print_info "Set: export SSL_CERT_FILE=/path/to/cert.pem" + print_info "Attempting to continue without SSL verification..." + ssl_opt="-k" + else + print_success "SSL certificate configured: $SSL_CERT_FILE" + ssl_opt="--cacert $SSL_CERT_FILE" + fi +else + ssl_opt="" +fi + +# Check if jq is available for better JSON parsing +if command -v jq &> /dev/null; then + print_success "jq available for JSON parsing" + has_jq=true +else + print_warning "jq not found, using python for JSON parsing" + has_jq=false +fi + +# Test 3: LLM Model Compatibility Test +print_header "Test 3: LLM Model Compatibility" + +print_info "Testing model: $llm_model" +print_info "Testing temperature: $llm_temperature" +echo "" + +# Determine endpoint based on base_url format +if [[ "$llm_base_url" == */v1 ]]; then + llm_endpoint="$llm_base_url/chat/completions" +else + llm_endpoint="$llm_base_url/v1/chat/completions" +fi + +print_info "Testing endpoint: $llm_endpoint" +echo "" + +# Test with configured parameters +response=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \ + $ssl_opt \ + -X POST "$llm_endpoint" \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + --max-time 30 \ + -d "{ + \"model\": \"$llm_model\", + \"messages\": [ + {\"role\": \"user\", \"content\": \"Say 'OK' if you receive this.\"} + ], + \"temperature\": $llm_temperature, + \"max_tokens\": 10 + }" 2>&1) + +http_status=$(echo "$response" | grep "HTTP_STATUS" | cut -d: -f2) +body=$(echo "$response" | sed '/HTTP_STATUS/d') + +if [ "$http_status" = "200" ]; then + print_success "LLM model works with configured parameters! (HTTP $http_status)" + + # Extract and display response + if [ "$has_jq" = true ]; then + content=$(echo "$body" | jq -r '.choices[0].message.content // "N/A"' 2>/dev/null) + else + content=$(echo "$body" | python3 -c "import json,sys; data=json.load(sys.stdin); print(data.get('choices', [{}])[0].get('message', {}).get('content', 'N/A'))" 2>/dev/null || echo "N/A") + fi + + if [ "$content" != "N/A" ]; then + print_info "Model response: $content" + fi + + llm_compatible=true +else + print_error "LLM model FAILED with configured parameters (HTTP $http_status)" + llm_compatible=false + + # Try to extract error message + if [ "$has_jq" = true ]; then + error_msg=$(echo "$body" | jq -r '.error.message // .message // "Unknown error"' 2>/dev/null) + error_type=$(echo "$body" | jq -r '.error.type // "unknown"' 2>/dev/null) + else + error_msg=$(echo "$body" | python3 -c "import json,sys; data=json.load(sys.stdin); print(data.get('error', {}).get('message', data.get('message', 'Unknown error')))" 2>/dev/null || echo "Unknown error") + error_type=$(echo "$body" | python3 -c "import json,sys; data=json.load(sys.stdin); print(data.get('error', {}).get('type', 'unknown'))" 2>/dev/null || echo "unknown") + fi + + echo "" + print_error "Error Type: $error_type" + print_error "Error Message: $error_msg" + echo "" + + # Check for common parameter constraint issues + if echo "$error_msg" | grep -qi "temperature"; then + print_warning "Temperature constraint detected!" + print_info "Trying with different temperature values..." + echo "" + + # Test with temperature = 1.0 + print_info "Testing with temperature=1.0..." + response=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \ + $ssl_opt \ + -X POST "$llm_endpoint" \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + --max-time 30 \ + -d "{ + \"model\": \"$llm_model\", + \"messages\": [{\"role\": \"user\", \"content\": \"OK\"}], + \"temperature\": 1.0, + \"max_tokens\": 10 + }" 2>&1) + + alt_status=$(echo "$response" | grep "HTTP_STATUS" | cut -d: -f2) + + if [ "$alt_status" = "200" ]; then + print_success "Model works with temperature=1.0" + print_warning "Your config has temperature=$llm_temperature which is NOT compatible" + print_info "Recommendation: Update config.local.yml with temperature >= 1.0" + else + print_error "Model still fails with temperature=1.0" + fi + echo "" + fi + + if echo "$error_msg" | grep -qi "model"; then + print_warning "Model availability issue detected!" + print_info "The model '$llm_model' may not be available on this endpoint" + print_info "Checking available models..." + echo "" + + # Try to list available models + models_response=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \ + $ssl_opt \ + -X GET "${llm_base_url}/v1/models" \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + --max-time 30 2>&1) + + models_status=$(echo "$models_response" | grep "HTTP_STATUS" | cut -d: -f2) + models_body=$(echo "$models_response" | sed '/HTTP_STATUS/d') + + if [ "$models_status" = "200" ]; then + print_info "Available models:" + if [ "$has_jq" = true ]; then + echo "$models_body" | jq -r '.data[]? | " • \(.id)"' 2>/dev/null | head -20 + else + echo "$models_body" | python3 -c " +import json, sys +try: + data = json.load(sys.stdin) + for model in data.get('data', [])[:20]: + print(f\" • {model.get('id', 'N/A')}\") +except: pass +" 2>/dev/null + fi + fi + fi +fi + +# Test 4: Embedder Model Compatibility Test +print_header "Test 4: Embedder Model Compatibility" + +print_info "Testing model: $embedder_model" +echo "" + +# Determine endpoint based on base_url format +if [[ "$embedder_base_url" == */v1 ]]; then + embedder_endpoint="$embedder_base_url/embeddings" +else + embedder_endpoint="$embedder_base_url/v1/embeddings" +fi + +print_info "Testing endpoint: $embedder_endpoint" +echo "" + +# Test embedder +response=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \ + $ssl_opt \ + -X POST "$embedder_endpoint" \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + --max-time 30 \ + -d "{ + \"model\": \"$embedder_model\", + \"input\": \"test embedding\" + }" 2>&1) + +http_status=$(echo "$response" | grep "HTTP_STATUS" | cut -d: -f2) +body=$(echo "$response" | sed '/HTTP_STATUS/d') + +if [ "$http_status" = "200" ]; then + print_success "Embedder model works! (HTTP $http_status)" + + # Check embedding dimension + if [ "$has_jq" = true ]; then + actual_dim=$(echo "$body" | jq -r '.data[0].embedding | length' 2>/dev/null) + else + actual_dim=$(echo "$body" | python3 -c "import json,sys; data=json.load(sys.stdin); print(len(data.get('data', [{}])[0].get('embedding', [])))" 2>/dev/null || echo "N/A") + fi + + if [ "$actual_dim" != "N/A" ]; then + print_info "Embedding dimension: $actual_dim" + + if [ "$actual_dim" = "$embedder_dimension" ]; then + print_success "Dimension matches config ($embedder_dimension)" + else + print_warning "Dimension mismatch!" + print_warning "Config specifies: $embedder_dimension" + print_warning "Model returns: $actual_dim" + print_info "Recommendation: Update config.local.yml with dimension: $actual_dim" + fi + fi + + embedder_compatible=true +else + print_error "Embedder model FAILED (HTTP $http_status)" + embedder_compatible=false + + # Extract error message + if [ "$has_jq" = true ]; then + error_msg=$(echo "$body" | jq -r '.error.message // .message // "Unknown error"' 2>/dev/null) + else + error_msg=$(echo "$body" | python3 -c "import json,sys; data=json.load(sys.stdin); print(data.get('error', {}).get('message', data.get('message', 'Unknown error')))" 2>/dev/null || echo "Unknown error") + fi + + echo "" + print_error "Error Message: $error_msg" + echo "" + + if echo "$error_msg" | grep -qi "model"; then + print_warning "Model availability issue detected!" + print_info "The model '$embedder_model' may not be available on this endpoint" + + # For Ollama, suggest pulling the model + if [[ "$embedder_base_url" == *"localhost"* ]] || [[ "$embedder_base_url" == *"11434"* ]]; then + print_info "This appears to be a local Ollama instance" + print_info "Try: ollama pull $embedder_model" + fi + fi +fi + +# Test 5: Integration Test (if both pass) +if [ "$llm_compatible" = true ] && [ "$embedder_compatible" = true ]; then + print_header "Test 5: Integration Test" + + print_info "Testing LLM and Embedder together..." + echo "" + + # Create a test that uses both + print_info "1. Generating text with LLM..." + llm_response=$(curl -s $ssl_opt \ + -X POST "$llm_endpoint" \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + --max-time 30 \ + -d "{ + \"model\": \"$llm_model\", + \"messages\": [{\"role\": \"user\", \"content\": \"In one sentence, what is knowledge graph?\"}], + \"temperature\": $llm_temperature, + \"max_tokens\": 50 + }") + + if [ "$has_jq" = true ]; then + llm_text=$(echo "$llm_response" | jq -r '.choices[0].message.content // "N/A"') + else + llm_text=$(echo "$llm_response" | python3 -c "import json,sys; data=json.load(sys.stdin); print(data.get('choices', [{}])[0].get('message', {}).get('content', 'N/A'))") + fi + + if [ "$llm_text" != "N/A" ] && [ ! -z "$llm_text" ]; then + print_success "LLM generated text: ${llm_text:0:100}..." + + print_info "2. Creating embedding from LLM output..." + embed_response=$(curl -s $ssl_opt \ + -X POST "$embedder_endpoint" \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + --max-time 30 \ + -d "{ + \"model\": \"$embedder_model\", + \"input\": \"$llm_text\" + }") + + if [ "$has_jq" = true ]; then + embed_dim=$(echo "$embed_response" | jq -r '.data[0].embedding | length' 2>/dev/null) + else + embed_dim=$(echo "$embed_response" | python3 -c "import json,sys; data=json.load(sys.stdin); print(len(data.get('data', [{}])[0].get('embedding', [])))" 2>/dev/null || echo "0") + fi + + if [ "$embed_dim" -gt 0 ]; then + print_success "Successfully created embedding (dimension: $embed_dim)" + print_success "Integration test PASSED!" + else + print_error "Failed to create embedding from LLM output" + fi + else + print_warning "Could not get LLM response for integration test" + fi +fi + +# Summary +print_header "Compatibility Summary" + +echo "" +if [ "$llm_compatible" = true ]; then + print_success "LLM Configuration: COMPATIBLE" + echo " Model: $llm_model" + echo " Temperature: $llm_temperature" + echo " Endpoint: $llm_endpoint" +else + print_error "LLM Configuration: INCOMPATIBLE" + echo " Model: $llm_model" + echo " Temperature: $llm_temperature" + echo " Endpoint: $llm_endpoint" + echo "" + print_info "Action Required: Fix LLM configuration before using MCP server" +fi + +echo "" +if [ "$embedder_compatible" = true ]; then + print_success "Embedder Configuration: COMPATIBLE" + echo " Model: $embedder_model" + echo " Endpoint: $embedder_endpoint" +else + print_error "Embedder Configuration: INCOMPATIBLE" + echo " Model: $embedder_model" + echo " Endpoint: $embedder_endpoint" + echo "" + print_info "Action Required: Fix embedder configuration before using MCP server" +fi + +echo "" +if [ "$llm_compatible" = true ] && [ "$embedder_compatible" = true ]; then + print_success "āœ“ All models are compatible with the MCP server!" + echo "" + print_info "You can safely start the MCP server with these settings." + exit 0 +else + print_error "āœ— Configuration issues detected" + echo "" + print_info "Please fix the issues above before starting the MCP server." + exit 1 +fi diff --git a/scripts/test_openai_compatibility.py b/scripts/test_openai_compatibility.py new file mode 100755 index 0000000..73fc819 --- /dev/null +++ b/scripts/test_openai_compatibility.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +""" +Test script to verify if LLM Gateway supports OpenAI-compatible API. + +This script tests both: +1. The documented native LLM Gateway API format +2. The suspected OpenAI-compatible proxy endpoint +""" + +import asyncio +import logging +import os +import sys +from pathlib import Path + +import httpx +import yaml + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +def get_ssl_verify_setting() -> bool | str: + """ + Get the appropriate SSL verification setting. + + Returns: + - Path to certificate bundle if SSL_CERT_FILE is set + - False if no certificate is configured (development/testing only) + """ + ssl_cert_file = os.getenv("SSL_CERT_FILE") + if ssl_cert_file: + cert_path = os.path.expanduser(ssl_cert_file) + if os.path.exists(cert_path): + logger.info(f"Using SSL certificate bundle: {cert_path}") + return cert_path + else: + logger.warning(f"SSL_CERT_FILE set but file not found: {cert_path}") + + # Fall back to disabling verification (development only) + logger.warning("SSL verification disabled - set SSL_CERT_FILE environment variable") + return False + + +class Colors: + """ANSI color codes for terminal output.""" + + GREEN = "\033[92m" + YELLOW = "\033[93m" + RED = "\033[91m" + BLUE = "\033[94m" + BOLD = "\033[1m" + END = "\033[0m" + + +def print_section(title: str): + """Print a formatted section header.""" + print(f"\n{Colors.BLUE}{Colors.BOLD}{'=' * 70}{Colors.END}") + print(f"{Colors.BLUE}{Colors.BOLD}{title}{Colors.END}") + print(f"{Colors.BLUE}{Colors.BOLD}{'=' * 70}{Colors.END}\n") + + +def print_success(message: str): + """Print success message.""" + print(f"{Colors.GREEN}āœ“ {message}{Colors.END}") + + +def print_warning(message: str): + """Print warning message.""" + print(f"{Colors.YELLOW}⚠ {message}{Colors.END}") + + +def print_error(message: str): + """Print error message.""" + print(f"{Colors.RED}āœ— {message}{Colors.END}") + + +def load_config() -> dict: + """Load configuration from openai.local.yml.""" + config_path = Path("config/providers/openai.local.yml") + + if not config_path.exists(): + print_error(f"Configuration file not found: {config_path}") + sys.exit(1) + + with open(config_path) as f: + config = yaml.safe_load(f) + + print_success(f"Loaded configuration from {config_path}") + return config + + +async def test_openai_compatible_endpoint(config: dict, api_key: str): + """Test the OpenAI-compatible endpoint format.""" + print_section("Testing OpenAI-Compatible Endpoint") + + # Extract base URL from config + base_url = config.get("llm", {}).get("base_url") + if not base_url: + print_error("No base_url found in configuration") + return False + + # Construct OpenAI-compatible endpoint + # The OpenAI SDK typically uses /v1/chat/completions + if base_url.endswith("/v1"): + chat_url = f"{base_url}/chat/completions" + elif base_url.endswith("/"): + chat_url = f"{base_url}v1/chat/completions" + else: + chat_url = f"{base_url}/v1/chat/completions" + + print(f"Testing endpoint: {chat_url}") + + # OpenAI-compatible request format + request_data = { + "model": config.get("llm", {}).get("model", "gpt-3.5-turbo"), + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + { + "role": "user", + "content": "Say 'OpenAI compatibility confirmed' and nothing else.", + }, + ], + "max_tokens": 50, + "temperature": 0.0, + } + + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + + try: + ssl_verify = get_ssl_verify_setting() + async with httpx.AsyncClient(verify=ssl_verify, timeout=30.0) as client: + response = await client.post(chat_url, json=request_data, headers=headers) + + print(f"Status Code: {response.status_code}") + + if response.status_code == 200: + result = response.json() + print_success("OpenAI-compatible endpoint working!") + + # Check response structure + if "choices" in result: + print_success("Response has OpenAI 'choices' structure") + content = result["choices"][0]["message"]["content"] + print(f"Response: {content}") + return True + else: + print_warning("Response doesn't match OpenAI structure") + print(f"Response: {result}") + return False + else: + print_error(f"Request failed: {response.status_code}") + print(f"Response: {response.text}") + return False + + except Exception as e: + print_error(f"Error testing OpenAI-compatible endpoint: {e}") + return False + + +async def test_native_gateway_endpoint(config: dict, api_key: str): + """Test the native LLM Gateway endpoint format.""" + print_section("Testing Native LLM Gateway Endpoint") + + base_url = config.get("llm", {}).get("base_url") + if not base_url: + print_error("No base_url found in configuration") + return False + + # Try to construct native gateway URL + # Based on OpenAPI spec, it should be /v1.0/chat/generations + native_url = base_url.replace("/api/v1", "/v1.0/chat/generations") + + print(f"Testing endpoint: {native_url}") + + # Native LLM Gateway request format (from OpenAPI spec) + request_data = { + "model": "llmgateway__OpenAIGPT35Turbo", + "messages": [ + { + "role": "user", + "content": "Say 'Native gateway format confirmed' and nothing else.", + } + ], + "generation_settings": {"max_tokens": 50, "temperature": 0.0}, + } + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + # Add custom headers here if your API gateway requires them + # "x-custom-header": "value", + } + + try: + ssl_verify = get_ssl_verify_setting() + async with httpx.AsyncClient(verify=ssl_verify, timeout=30.0) as client: + response = await client.post(native_url, json=request_data, headers=headers) + + print(f"Status Code: {response.status_code}") + + if response.status_code == 200: + result = response.json() + print_success("Native LLM Gateway endpoint working!") + + # Check response structure + if "generations" in result: + print_success("Response has native 'generations' structure") + content = result["generations"][0]["text"] + print(f"Response: {content}") + return True + else: + print_warning("Response doesn't match native structure") + print(f"Response: {result}") + return False + else: + print_error(f"Request failed: {response.status_code}") + print(f"Response: {response.text}") + return False + + except Exception as e: + print_error(f"Error testing native gateway endpoint: {e}") + return False + + +async def main(): + """Main test function.""" + print_section("LLM Gateway OpenAI Compatibility Test") + + # Get API key from environment + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + print_error("OPENAI_API_KEY environment variable not set") + sys.exit(1) + + print_success("API key found in environment") + + # Load configuration + config = load_config() + print(f"Base URL: {config.get('llm', {}).get('base_url')}") + print(f"Model: {config.get('llm', {}).get('model')}") + + # Test both endpoint formats + openai_compatible = await test_openai_compatible_endpoint(config, api_key) + native_gateway = await test_native_gateway_endpoint(config, api_key) + + # Summary + print_section("Test Results Summary") + + if openai_compatible: + print_success("āœ“ OpenAI-compatible endpoint: WORKING") + print(" → You can use this gateway as a drop-in OpenAI replacement") + else: + print_error("āœ— OpenAI-compatible endpoint: NOT WORKING") + + if native_gateway: + print_success("āœ“ Native LLM Gateway endpoint: WORKING") + print(" → You need to use the native format with special headers") + else: + print_error("āœ— Native LLM Gateway endpoint: NOT WORKING") + + # Recommendations + print_section("Recommendations") + + if openai_compatible: + print("āœ“ Your configuration is correct for OpenAI compatibility") + print("āœ“ Continue using the current setup with Graphiti") + print(f"āœ“ Use base_url: {config.get('llm', {}).get('base_url')}") + elif native_gateway: + print("⚠ Only native format works - OpenAI SDK may not be compatible") + print("⚠ You may need a translation layer or custom client") + else: + print("āœ— Neither endpoint format worked") + print("āœ— Check authentication, headers, or network connectivity") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/config/database_config.py b/src/config/database_config.py index 8a103d9..9945edf 100644 --- a/src/config/database_config.py +++ b/src/config/database_config.py @@ -15,7 +15,7 @@ class Neo4jConfig(BaseModel): uri: str = "bolt://localhost:7687" user: str = "neo4j" - password: str = "password" + password: str = "demodemo" # Must match docker-compose.yml default @classmethod def from_env(cls) -> "Neo4jConfig": @@ -23,5 +23,5 @@ def from_env(cls) -> "Neo4jConfig": return cls( uri=os.environ.get("NEO4J_URI", "bolt://localhost:7687"), user=os.environ.get("NEO4J_USER", "neo4j"), - password=os.environ.get("NEO4J_PASSWORD", "password"), + password=os.environ.get("NEO4J_PASSWORD", "demodemo"), # Must match docker-compose.yml default ) diff --git a/src/config/embedder_config.py b/src/config/embedder_config.py index f3b2b97..1c1be08 100644 --- a/src/config/embedder_config.py +++ b/src/config/embedder_config.py @@ -17,6 +17,7 @@ from src.config_loader import config_loader from src.utils import create_azure_credential_token_provider +from src.utils.ssl_utils import create_ssl_context_httpx_client, get_ssl_verify_setting logger = logging.getLogger(__name__) @@ -31,6 +32,8 @@ class GraphitiEmbedderConfig(BaseModel): model: str = DEFAULT_EMBEDDER_MODEL api_key: str | None = None + dimension: int | None = None # Embedding dimension for OpenAI/Azure + base_url: str | None = None # Base URL for OpenAI-compatible endpoints azure_openai_endpoint: str | None = None azure_openai_deployment_name: str | None = None azure_openai_api_version: str | None = None @@ -44,20 +47,47 @@ class GraphitiEmbedderConfig(BaseModel): @classmethod def from_yaml_and_env(cls) -> "GraphitiEmbedderConfig": """Create embedder configuration from provider YAML and environment variables.""" - # Decide provider based on USE_OLLAMA - use_ollama = ( - config_loader.get_env_value("USE_OLLAMA", "true", str).lower() == "true" - ) + # Check USE_OLLAMA environment variable first for provider detection + use_ollama_env = os.environ.get("USE_OLLAMA", "").lower() == "true" + + # Try to load unified config first + try: + yaml_config = config_loader.load_unified_config() + if not yaml_config: + # Fall back to provider-specific config + # Check for USE_OLLAMA first + if use_ollama_env: + yaml_config = config_loader.load_provider_config("ollama") + else: + # Check for Azure OpenAI (needs explicit env var) + azure_openai_endpoint = os.environ.get( + "AZURE_OPENAI_EMBEDDING_ENDPOINT", None + ) or os.environ.get("AZURE_OPENAI_ENDPOINT", None) + if azure_openai_endpoint is not None: + yaml_config = config_loader.load_provider_config("azure_openai") + else: + # Default to openai config (works for OpenAI-compatible APIs) + yaml_config = config_loader.load_provider_config("openai") + + embed_config = yaml_config.get("embedder", {}) + except Exception as e: + logger.warning(f"Failed to load embedder YAML configuration: {e}") + embed_config = {} + + # Get base_url to detect provider type + base_url = embed_config.get("base_url", "") - if use_ollama: - # Load Ollama YAML (with local overrides) for embedder - try: - yaml_config = config_loader.load_provider_config("ollama") - embed_config = yaml_config.get("embedder", {}) - except Exception as e: - logger.warning(f"Failed to load Ollama embedder YAML: {e}") - embed_config = {} + # Detect if this is Ollama based on USE_OLLAMA env var or base_url pattern + # Ollama URLs contain localhost:11434 or have 'ollama' in the hostname + is_ollama = ( + use_ollama_env + or "localhost:11434" in base_url + or "127.0.0.1:11434" in base_url + or ("ollama" in base_url.lower() and "localhost" in base_url.lower()) + ) + if is_ollama: + # Ollama configuration ollama_base_url = config_loader.get_env_value( "OLLAMA_BASE_URL", embed_config.get("base_url", "http://localhost:11434/v1"), @@ -70,6 +100,10 @@ def from_yaml_and_env(cls) -> "GraphitiEmbedderConfig": "OLLAMA_EMBEDDING_DIM", embed_config.get("dimension", 768), int ) + logger.info( + f"Using Ollama embedder: {ollama_embedding_model} at {ollama_base_url}" + ) + return cls( model=ollama_embedding_model, api_key="abc", # Ollama doesn't require a real API key @@ -80,21 +114,13 @@ def from_yaml_and_env(cls) -> "GraphitiEmbedderConfig": ) # OpenAI or Azure OpenAI + model = embed_config.get("model", "text-embedding-3-small") + dimension = embed_config.get("dimension") + azure_openai_endpoint = os.environ.get( "AZURE_OPENAI_EMBEDDING_ENDPOINT", None ) or os.environ.get("AZURE_OPENAI_ENDPOINT", None) - try: - if azure_openai_endpoint is not None: - yaml_config = config_loader.load_provider_config("azure_openai") - else: - yaml_config = config_loader.load_provider_config("openai") - embed_config = yaml_config.get("embedder", {}) - except Exception as e: - logger.warning(f"Failed to load OpenAI/Azure embedder YAML: {e}") - embed_config = {} - - model = embed_config.get("model", "text-embedding-3-small") # Backward compatibility: allow EMBEDDER_MODEL_NAME to override model for # OpenAI and Azure OpenAI providers (does not affect Ollama) env_model_override = os.environ.get("EMBEDDER_MODEL_NAME") @@ -129,9 +155,15 @@ def from_yaml_and_env(cls) -> "GraphitiEmbedderConfig": or os.environ.get("OPENAI_API_KEY", None) ) + logger.info( + f"Using Azure OpenAI embedder: {model} at {azure_openai_endpoint}" + ) + return cls( model=model, api_key=api_key, + dimension=dimension, + base_url=base_url, azure_openai_endpoint=azure_openai_endpoint, azure_openai_api_version=azure_openai_api_version, azure_openai_deployment_name=azure_openai_deployment_name, @@ -140,9 +172,16 @@ def from_yaml_and_env(cls) -> "GraphitiEmbedderConfig": ) # OpenAI setup + if base_url: + logger.info(f"Using OpenAI-compatible embedder: {model} at {base_url}") + else: + logger.info(f"Using OpenAI embedder: {model}") + return cls( model=model, api_key=os.environ.get("OPENAI_API_KEY"), + dimension=dimension, + base_url=base_url, use_ollama=False, ) @@ -185,23 +224,33 @@ def create_client(self) -> EmbedderClient | None: if self.azure_openai_use_managed_identity: # Use managed identity for authentication token_provider = create_azure_credential_token_provider() + + # Create httpx client with SSL support + http_client = create_ssl_context_httpx_client() + return AzureOpenAIEmbedderClient( azure_client=AsyncAzureOpenAI( azure_endpoint=self.azure_openai_endpoint, azure_deployment=self.azure_openai_deployment_name, api_version=self.azure_openai_api_version, azure_ad_token_provider=token_provider, + http_client=http_client, ), model=self.model, ) elif self.api_key: # Use API key for authentication + + # Create httpx client with SSL support + http_client = create_ssl_context_httpx_client() + return AzureOpenAIEmbedderClient( azure_client=AsyncAzureOpenAI( azure_endpoint=self.azure_openai_endpoint, azure_deployment=self.azure_openai_deployment_name, api_version=self.azure_openai_api_version, api_key=self.api_key, + http_client=http_client, ), model=self.model, ) @@ -213,8 +262,34 @@ def create_client(self) -> EmbedderClient | None: if not self.api_key: return None - embedder_config = OpenAIEmbedderConfig( - api_key=self.api_key, embedding_model=self.model - ) - - return OpenAIEmbedder(config=embedder_config) + # Use base_url and dimension from config (already loaded from YAML) + config_params = { + "api_key": self.api_key, + "embedding_model": self.model, + "base_url": self.base_url, + } + # Only include embedding_dim if it's specified + if self.dimension is not None: + config_params["embedding_dim"] = self.dimension + + embedder_config = OpenAIEmbedderConfig(**config_params) + + embedder = OpenAIEmbedder(config=embedder_config) + + # For OpenAI-compatible endpoints with custom SSL requirements, + # patch the underlying httpx client to use SSL certificates + ssl_verify = get_ssl_verify_setting() + if isinstance(ssl_verify, str) or ssl_verify is False: + # Custom certificate or SSL disabled - need to configure httpx client + import httpx + + if hasattr(embedder, "client") and hasattr(embedder.client, "_client"): + # Patch the internal httpx client with SSL configuration + embedder.client._client = httpx.AsyncClient( + verify=ssl_verify, timeout=30.0 + ) + logger.info( + "Configured OpenAI embedder with custom SSL certificate" + ) + + return embedder diff --git a/src/config/llm_config.py b/src/config/llm_config.py index f3d7f48..36b4907 100644 --- a/src/config/llm_config.py +++ b/src/config/llm_config.py @@ -15,6 +15,7 @@ from src.config_loader import config_loader from src.ollama_client import OllamaClient from src.utils.auth_utils import create_azure_credential_token_provider +from src.utils.ssl_utils import create_ssl_context_httpx_client, get_ssl_verify_setting # Get logger for this module logger = logging.getLogger(__name__) @@ -48,21 +49,55 @@ class GraphitiLLMConfig(BaseModel): @classmethod def from_yaml_and_env(cls) -> "GraphitiLLMConfig": """Create LLM configuration from YAML files and environment variables.""" - # Check if Ollama should be used (default to True) - use_ollama = ( - config_loader.get_env_value("USE_OLLAMA", "true", str).lower() == "true" - ) - - if use_ollama: - # Load Ollama YAML configuration (with local overrides) - try: - yaml_config = config_loader.load_provider_config("ollama") - llm_config = yaml_config.get("llm", {}) - except Exception as e: - logger.warning(f"Failed to load Ollama YAML configuration: {e}") - llm_config = {} + # Try to load unified config first + # Check USE_OLLAMA environment variable first for provider detection + use_ollama_env = os.environ.get("USE_OLLAMA", "").lower() == "true" + + try: + yaml_config = config_loader.load_unified_config() + if not yaml_config: + # Fall back to provider-specific config + # Check for USE_OLLAMA first + if use_ollama_env: + yaml_config = config_loader.load_provider_config("ollama") + else: + # Check for Azure OpenAI (needs explicit env var) + azure_openai_endpoint = os.environ.get( + "AZURE_OPENAI_ENDPOINT", None + ) + if azure_openai_endpoint is not None: + yaml_config = config_loader.load_provider_config("azure_openai") + else: + # Default to openai config (works for OpenAI-compatible APIs) + yaml_config = config_loader.load_provider_config("openai") + + llm_config = yaml_config.get("llm", {}) + except Exception as e: + logger.warning(f"Failed to load LLM YAML configuration: {e}") + llm_config = {} + + # Get base_url to detect provider type + base_url = llm_config.get("base_url", "") + + # Check explicit use_ollama setting in YAML (has highest priority) + yaml_use_ollama = llm_config.get("use_ollama") + + # Detect if this is Ollama based on explicit setting, env var, or base_url pattern + # Priority: 1) Explicit YAML use_ollama, 2) USE_OLLAMA env var, 3) base_url pattern + if yaml_use_ollama is not None: + # Explicit YAML setting takes precedence + is_ollama = yaml_use_ollama + else: + # Fall back to env var and URL pattern detection + is_ollama = ( + use_ollama_env + or "localhost:11434" in base_url + or "127.0.0.1:11434" in base_url + or ("ollama" in base_url.lower() and "localhost" in base_url.lower()) + ) - # Use YAML config values with fallback to defaults, then override with environment variables + if is_ollama: + # Ollama configuration ollama_base_url = config_loader.get_env_value( "OLLAMA_BASE_URL", llm_config.get("base_url", "http://localhost:11434/v1"), @@ -76,10 +111,10 @@ def from_yaml_and_env(cls) -> "GraphitiLLMConfig": max_tokens = config_loader.get_env_value( "LLM_MAX_TOKENS", llm_config.get("max_tokens", 8192), int ) - - # Get Ollama model parameters from YAML ollama_model_parameters = llm_config.get("model_parameters", {}) + logger.info(f"Using Ollama LLM: {ollama_llm_model} at {ollama_base_url}") + return cls( api_key="abc", # Ollama doesn't require a real API key model=ollama_llm_model, @@ -92,31 +127,14 @@ def from_yaml_and_env(cls) -> "GraphitiLLMConfig": ollama_model_parameters=ollama_model_parameters, ) else: - # Load OpenAI or Azure OpenAI configuration - # Try Azure OpenAI first, then OpenAI - azure_openai_endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT", None) - - try: - if azure_openai_endpoint is not None: - # Azure OpenAI configuration (with local overrides) - yaml_config = config_loader.load_provider_config("azure_openai") - else: - # OpenAI configuration (with local overrides) - yaml_config = config_loader.load_provider_config("openai") - - llm_config = yaml_config.get("llm", {}) - except Exception as e: - logger.warning( - f"Failed to load OpenAI/Azure OpenAI YAML configuration: {e}" - ) - llm_config = {} - - # Use YAML config values with fallback to defaults + # OpenAI or Azure OpenAI configuration model = llm_config.get("model", DEFAULT_LLM_MODEL) small_model = llm_config.get("small_model", SMALL_LLM_MODEL) temperature = llm_config.get("temperature", 0.0) max_tokens = llm_config.get("max_tokens", 8192) + azure_openai_endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT", None) + if azure_openai_endpoint is not None: # Azure OpenAI setup - still use environment variables for sensitive config azure_openai_api_version = os.environ.get( @@ -144,6 +162,10 @@ def from_yaml_and_env(cls) -> "GraphitiLLMConfig": else os.environ.get("OPENAI_API_KEY", None) ) + logger.info( + f"Using Azure OpenAI LLM: {model} at {azure_openai_endpoint}" + ) + return cls( azure_openai_use_managed_identity=azure_openai_use_managed_identity, azure_openai_endpoint=azure_openai_endpoint, @@ -158,6 +180,14 @@ def from_yaml_and_env(cls) -> "GraphitiLLMConfig": ) else: # OpenAI setup - still use environment variables for API key + openai_base_url = llm_config.get("base_url") + if openai_base_url: + logger.info( + f"Using OpenAI-compatible LLM: {model} at {openai_base_url}" + ) + else: + logger.info(f"Using OpenAI LLM: {model}") + return cls( api_key=os.environ.get("OPENAI_API_KEY"), model=model, @@ -169,16 +199,21 @@ def from_yaml_and_env(cls) -> "GraphitiLLMConfig": @classmethod def from_env(cls) -> "GraphitiLLMConfig": - """Create LLM configuration from environment variables.""" - # Check if Ollama should be used (default to True) - use_ollama = os.environ.get("USE_OLLAMA", "true").lower() == "true" + """Create LLM configuration from environment variables only. + + Detection logic (in order): + 1. If OLLAMA_BASE_URL or OLLAMA_LLM_MODEL is set -> Use Ollama + 2. If AZURE_OPENAI_ENDPOINT is set -> Use Azure OpenAI + 3. Otherwise -> Use OpenAI (or OpenAI-compatible API) + """ + # Check if Ollama-specific env vars are set + ollama_base_url = os.environ.get("OLLAMA_BASE_URL") + ollama_llm_model = os.environ.get("OLLAMA_LLM_MODEL") - if use_ollama: + if ollama_base_url or ollama_llm_model: # Ollama configuration - ollama_base_url = os.environ.get( - "OLLAMA_BASE_URL", "http://localhost:11434/v1" - ) - ollama_llm_model = os.environ.get("OLLAMA_LLM_MODEL", DEFAULT_LLM_MODEL) + ollama_base_url = ollama_base_url or "http://localhost:11434/v1" + ollama_llm_model = ollama_llm_model or DEFAULT_LLM_MODEL return cls( api_key="abc", # Ollama doesn't require a real API key @@ -348,12 +383,16 @@ def create_client(self) -> LLMClient: if openai_enable_temperature: config.temperature = self.temperature + # Create httpx client with SSL support + http_client = create_ssl_context_httpx_client() + return AzureOpenAILLMClient( azure_client=AsyncAzureOpenAI( azure_endpoint=self.azure_openai_endpoint, azure_deployment=self.azure_openai_deployment_name, api_version=self.azure_openai_api_version, azure_ad_token_provider=token_provider, + http_client=http_client, ), config=config, ) @@ -375,12 +414,16 @@ def create_client(self) -> LLMClient: if openai_enable_temperature: config.temperature = self.temperature + # Create httpx client with SSL support + http_client = create_ssl_context_httpx_client() + return AzureOpenAILLMClient( azure_client=AsyncAzureOpenAI( azure_endpoint=self.azure_openai_endpoint, azure_deployment=self.azure_openai_deployment_name, api_version=self.azure_openai_api_version, api_key=self.api_key, + http_client=http_client, ), config=config, ) @@ -406,4 +449,25 @@ def create_client(self) -> LLMClient: if openai_enable_temperature: llm_client_config.temperature = self.temperature - return OpenAIClient(config=llm_client_config) + # Note: For OpenAI-compatible endpoints requiring custom SSL certificates + # (e.g., internal corporate gateways), ensure SSL_CERT_FILE environment + # variable is set. The OpenAI SDK will respect this for HTTPS connections. + # For Azure OpenAI, SSL support is configured via http_client parameter above. + + client = OpenAIClient(config=llm_client_config) + + # For OpenAI-compatible endpoints with custom SSL requirements, + # patch the underlying httpx client to use SSL certificates + ssl_verify = get_ssl_verify_setting() + if isinstance(ssl_verify, str) or ssl_verify is False: + # Custom certificate or SSL disabled - need to configure httpx client + import httpx + + if hasattr(client, "client") and hasattr(client.client, "_client"): + # Patch the internal httpx client with SSL configuration + client.client._client = httpx.AsyncClient( + verify=ssl_verify, timeout=30.0 + ) + logger.info("Configured OpenAI client with custom SSL certificate") + + return client diff --git a/src/config_loader.py b/src/config_loader.py index 7671066..41b2ef6 100644 --- a/src/config_loader.py +++ b/src/config_loader.py @@ -84,10 +84,26 @@ def load_yaml_config(self, config_path: str) -> dict[str, Any]: logger.warning(f"Failed to load configuration from {full_path}: {e}") return {} + def load_unified_config(self) -> dict[str, Any]: + """ + Load unified configuration from config.local.yml. + + This is the preferred method for loading configuration. It supports + mixed provider configurations where LLM and embedder can use different + providers in a single file. + + Returns: + Dictionary containing the unified configuration, or empty dict if not found + """ + return self.load_yaml_config("config.local.yml") + def load_provider_config(self, provider: str) -> dict[str, Any]: """ Load provider-specific configuration with local override support. + This method is deprecated in favor of load_unified_config() but maintained + for backwards compatibility. + Loads the base configuration from providers/{provider}.yml and merges it with local overrides from providers/{provider}.local.yml if it exists. @@ -97,6 +113,15 @@ def load_provider_config(self, provider: str) -> dict[str, Any]: Returns: Dictionary containing the merged provider configuration """ + # First check for unified config - it takes precedence + unified_config = self.load_unified_config() + if unified_config: + logger.debug( + "Using unified config.local.yml (provider-specific config ignored)" + ) + return unified_config + + # Fall back to provider-specific config # Load base configuration base_config = self.load_yaml_config(f"providers/{provider}.yml") diff --git a/src/initialization/graphiti_client.py b/src/initialization/graphiti_client.py index 3f34d5b..8e72321 100644 --- a/src/initialization/graphiti_client.py +++ b/src/initialization/graphiti_client.py @@ -29,7 +29,7 @@ def _detect_ollama_configuration(config: "GraphitiConfig") -> bool: Detect if the configuration specifies Ollama usage. Uses multiple detection methods for robust identification: - 1. Explicit use_ollama flag (primary method) + 1. Explicit use_ollama flag (primary method - takes precedence over all others) 2. Base URL contains Ollama default port 11434 (fallback method) 3. Various URL format handling (localhost, 127.0.0.1, etc.) @@ -40,11 +40,17 @@ def _detect_ollama_configuration(config: "GraphitiConfig") -> bool: bool: True if Ollama configuration is detected, False otherwise """ # Primary detection: explicit use_ollama flag - if hasattr(config.llm, "use_ollama") and config.llm.use_ollama: - logger.debug("Ollama detected via explicit use_ollama flag") + # If explicitly set to True, use Ollama + if hasattr(config.llm, "use_ollama") and config.llm.use_ollama is True: + logger.debug("Ollama detected via explicit use_ollama=True flag") return True - # Check for port 11434 in various URL formats + # If explicitly set to False, do NOT use Ollama (overrides all other detection) + if hasattr(config.llm, "use_ollama") and config.llm.use_ollama is False: + logger.debug("Ollama explicitly disabled via use_ollama=False flag") + return False + + # Check for port 11434 in various URL formats (only if use_ollama not explicitly set) ollama_patterns = [ r":11434", # Direct port match r"localhost:11434", # Localhost with port diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 394c663..09d9019 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -4,11 +4,17 @@ - auth_utils: Authentication and credential management - formatting_utils: Data formatting and transformation utilities - queue_utils: Episode queue management and processing +- ssl_utils: SSL/TLS certificate configuration for secure connections """ from .auth_utils import create_azure_credential_token_provider from .formatting_utils import format_fact_result from .queue_utils import episode_queues, process_episode_queue, queue_workers +from .ssl_utils import ( + create_ssl_context_httpx_client, + get_ssl_verify_for_openai, + get_ssl_verify_setting, +) __all__ = [ "create_azure_credential_token_provider", @@ -16,4 +22,7 @@ "episode_queues", "queue_workers", "process_episode_queue", + "get_ssl_verify_setting", + "get_ssl_verify_for_openai", + "create_ssl_context_httpx_client", ] diff --git a/src/utils/ssl_utils.py b/src/utils/ssl_utils.py new file mode 100644 index 0000000..0d7f63b --- /dev/null +++ b/src/utils/ssl_utils.py @@ -0,0 +1,140 @@ +"""SSL/TLS certificate utilities for secure connections to internal endpoints.""" + +import logging +import os +from pathlib import Path + +import httpx + +logger = logging.getLogger(__name__) + + +def get_ssl_verify_setting() -> bool | str: + """ + Get the appropriate SSL verification setting for httpx clients. + + Checks multiple environment variables in order of precedence: + 1. SSL_CERT_FILE - Standard Python SSL certificate file + 2. REQUESTS_CA_BUNDLE - Requests library certificate bundle + 3. CURL_CA_BUNDLE - cURL certificate bundle + + Returns: + - Path to certificate bundle (str) if found + - True to use default system certificates + - False if no certificate is configured (development/testing only) + + Environment Variables: + SSL_CERT_FILE: Path to PEM certificate bundle + REQUESTS_CA_BUNDLE: Path to certificate bundle (requests library) + CURL_CA_BUNDLE: Path to certificate bundle (curl) + """ + # Check SSL_CERT_FILE first (standard Python SSL) + ssl_cert_file = os.getenv("SSL_CERT_FILE") + if ssl_cert_file: + cert_path = Path(ssl_cert_file).expanduser() + if cert_path.exists(): + logger.info(f"Using SSL certificate bundle from SSL_CERT_FILE: {cert_path}") + return str(cert_path) + else: + logger.warning(f"SSL_CERT_FILE set but file not found: {cert_path}") + + # Check REQUESTS_CA_BUNDLE (requests library) + requests_ca_bundle = os.getenv("REQUESTS_CA_BUNDLE") + if requests_ca_bundle: + cert_path = Path(requests_ca_bundle).expanduser() + if cert_path.exists(): + logger.info( + f"Using SSL certificate bundle from REQUESTS_CA_BUNDLE: {cert_path}" + ) + return str(cert_path) + else: + logger.warning(f"REQUESTS_CA_BUNDLE set but file not found: {cert_path}") + + # Check CURL_CA_BUNDLE (curl) + curl_ca_bundle = os.getenv("CURL_CA_BUNDLE") + if curl_ca_bundle: + cert_path = Path(curl_ca_bundle).expanduser() + if cert_path.exists(): + logger.info( + f"Using SSL certificate bundle from CURL_CA_BUNDLE: {cert_path}" + ) + return str(cert_path) + else: + logger.warning(f"CURL_CA_BUNDLE set but file not found: {cert_path}") + + # No certificate configured - use default system certificates + logger.debug( + "No custom SSL certificate configured, using system default certificates" + ) + return True + + +def create_ssl_context_httpx_client( + timeout: float = 30.0, + max_connections: int = 100, + max_keepalive_connections: int = 20, +) -> httpx.AsyncClient: + """ + Create an httpx AsyncClient with proper SSL certificate configuration. + + This function creates a client that respects SSL_CERT_FILE and related + environment variables for connecting to internal endpoints with custom CAs. + + Args: + timeout: Request timeout in seconds (default: 30.0) + max_connections: Maximum number of connections (default: 100) + max_keepalive_connections: Maximum keepalive connections (default: 20) + + Returns: + httpx.AsyncClient: Configured async HTTP client with SSL support + + Example: + >>> client = create_ssl_context_httpx_client() + >>> # Use with OpenAI SDK + >>> from openai import AsyncOpenAI + >>> openai_client = AsyncOpenAI(http_client=client) + """ + ssl_verify = get_ssl_verify_setting() + + limits = httpx.Limits( + max_connections=max_connections, + max_keepalive_connections=max_keepalive_connections, + ) + + client = httpx.AsyncClient( + verify=ssl_verify, + timeout=timeout, + limits=limits, + ) + + if isinstance(ssl_verify, str): + logger.info(f"Created httpx client with custom SSL certificate: {ssl_verify}") + elif ssl_verify is True: + logger.debug("Created httpx client with default system SSL certificates") + else: + logger.warning( + "Created httpx client with SSL verification DISABLED (development only)" + ) + + return client + + +def get_ssl_verify_for_openai() -> bool | str: + """ + Get SSL verification setting specifically for OpenAI SDK clients. + + The OpenAI SDK accepts either: + - str: path to certificate bundle + - bool: True for default certs, False to disable verification + + Returns: + SSL verification setting compatible with OpenAI SDK + """ + ssl_verify = get_ssl_verify_setting() + + # OpenAI SDK only accepts str or bool, not SSLContext + if isinstance(ssl_verify, (bool, str)): + return ssl_verify + + # Default to True (use system certificates) + return True diff --git a/tests/test_graphiti_pipeline_integration.py b/tests/test_graphiti_pipeline_integration.py index eeb9424..9fe1daf 100644 --- a/tests/test_graphiti_pipeline_integration.py +++ b/tests/test_graphiti_pipeline_integration.py @@ -62,7 +62,7 @@ async def test_config(self) -> GraphitiConfig: neo4j=Neo4jConfig( uri=os.getenv("TEST_NEO4J_URI", "bolt://localhost:7687"), user=os.getenv("TEST_NEO4J_USER", "neo4j"), - password=os.getenv("TEST_NEO4J_PASSWORD", "password"), + password=os.getenv("TEST_NEO4J_PASSWORD", "demodemo"), # Must match docker-compose.yml default ), llm=GraphitiLLMConfig( model="gpt-oss:latest", diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index f2e3950..c5e7802 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -22,7 +22,7 @@ def setup_environment(): os.environ["OLLAMA_EMBEDDING_MODEL"] = "nomic-embed-text" os.environ["NEO4J_URI"] = "bolt://localhost:7687" os.environ["NEO4J_USER"] = "neo4j" - os.environ["NEO4J_PASSWORD"] = "password" + os.environ["NEO4J_PASSWORD"] = "demodemo" # Must match docker-compose.yml default class TestMCPServerInitialization: diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index 1c163ae..2b429d1 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -24,7 +24,7 @@ def setup_environment(): os.environ["OLLAMA_EMBEDDING_MODEL"] = "nomic-embed-text" os.environ["NEO4J_URI"] = "bolt://localhost:7687" os.environ["NEO4J_USER"] = "neo4j" - os.environ["NEO4J_PASSWORD"] = "password" + os.environ["NEO4J_PASSWORD"] = "demodemo" # Must match docker-compose.yml default class TestMCPToolSignatures: diff --git a/tests/test_user_scenario.py b/tests/test_user_scenario.py index 11f0bee..79b0a98 100644 --- a/tests/test_user_scenario.py +++ b/tests/test_user_scenario.py @@ -26,7 +26,7 @@ def setup_environment(): os.environ["OLLAMA_EMBEDDING_MODEL"] = "nomic-embed-text" os.environ["NEO4J_URI"] = "bolt://localhost:7687" os.environ["NEO4J_USER"] = "neo4j" - os.environ["NEO4J_PASSWORD"] = "password" + os.environ["NEO4J_PASSWORD"] = "demodemo" # Must match docker-compose.yml default class TestUserScenario: