diff --git a/README.md b/README.md index d9d1a00..d399572 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ postgres=# CREATE EXTENSION IF NOT EXISTS vector; Clients should communicate the API key in the `Authorization` header with a `Bearer` prefix, e.g. `Bearer 024v2013621509245f2e24`. Most operations can only be done by the (admin or the) owner of the resource in question. For projects and their embeddings, you can define other user accounts that should be authorized as readers, too. -**Public Access**: Projects can be made publicly accessible (allowing unauthenticated read access to embeddings and similars) by including `"*"` in the `authorizedReaders` array when creating or updating the project. See [docs/PUBLIC_ACCESS.md](./docs/PUBLIC_ACCESS.md) for details. +**Public Access**: Projects can be made publicly accessible (allowing unauthenticated read access to embeddings and similars) by including `"*"` in the `shared_with` array when creating or updating the project. See [docs/PUBLIC_ACCESS.md](./docs/PUBLIC_ACCESS.md) for details. ## Data Validation @@ -302,15 +302,15 @@ For resources that support both GET and PUT operations, PATCH requests are autom **Example: Enable world-readable access for a project** ```bash curl -X PATCH https:///v1/projects/alice/myproject \ - -H "Authorization: Bearer " \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ - -d '{"authorizedReaders": ["*"]}' + -d '{"shared_with": ["*"]}' ``` **Example: Update project description** ```bash curl -X PATCH https:///v1/projects/alice/myproject \ - -H "Authorization: Bearer " \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"description": "Updated project description"}' ``` @@ -367,7 +367,7 @@ dhamps-vdb/ │ │ ├── handlers.go │ │ ├── handlers_test.go │ │ ├── llm_processes.go -│ │ ├── llm_services.go +│ │ ├── instances.go │ │ ├── llm_services_test.go │ │ ├── projects.go │ │ ├── projects_test.go @@ -379,7 +379,7 @@ dhamps-vdb/ │ ├── api_standards.go │ ├── embeddings.go │ ├── llm_processes.go -│ ├── llm_services.go +│ ├── instances.go │ ├── options.go │ ├── projects.go │ ├── similars.go @@ -416,22 +416,27 @@ dhamps-vdb/ - [x] Validation with metadata schema - [x] Allow to filter similar passages by metadata field (so as to exclude e.g. documents from the same author) - [ ] Add documentation (the GET query parameters are called `metadata_path` and `metadata_value` as in: `https://xy.org/vdb-api/v1/similars/sal/sal-openai-large/https%3A%2F%2Fid.myproject.net%2Ftexts%2FW0011%3A1.3.1.3.1?threshold=0.7&limit=5&metadata_path=author_id&metadata_value=A0083`) +- [x] Use **transactions** (most importantly, when an action requires several queries, e.g. projects being added and then linked to several read-authorized users) +- [ ] Prevent acceptance of requests as user "_system" - [ ] Implement and make consequent use of **max_idle** (5), **max_concurr** (5), **timeouts**, and **cancellations** - [ ] **Concurrency** (leaky bucket approach) and **Rate limiting** (redis, sliding window, implement headers) -- [ ] Use **transactions** (most importantly, when an action requires several queries, e.g. projects being added and then linked to several read-authorized users) -- [ ] Use PATCH method to change existing resources -- [x] Add mechanism to allow anonymous/public reading access to embeddings (via `"*"` in `authorizedReaders`) +- [ ] Always use specific error messages +- [ ] Add project sharing/unsharing functions & API paths +- [ ] Add definition creation/listing/deletion functions & paths +- [ ] Allow to request verbose information even in list outputs (with a verbose=yes query parameter?) +- [ ] Add possiblity to use PATCH method to change existing resources +- [x] Add mechanism to allow anonymous/public reading access to embeddings (via `"*"` in `shared_with`) - [ ] **Dockerization** - [ ] **Batch mode** -- [ ] **Link or unlink** users/LLMs as standalone operations - [ ] **Transfer** of projects from one owner to another as new operation -- [ ] Fix automatically generated documentation +- [ ] Revisit all documentation - [ ] Proper **logging** with `--verbose` and `--quiet` modes - [ ] Caching - [ ] HTML UI? - [ ] LLM handling processing (receive text and send it to an llm service on the user's behalf, then store the results) - [ ] allow API keys for services to be read from env variables (on the server, but still maybe useful) - [ ] calls to LLM services + - [ ] include rate limiting in service definitions/instances and obey it in proxying ## License @@ -439,4 +444,5 @@ dhamps-vdb/ ## Versions +- 2026-02-XX **v0.1.0**: Fix many things, add many things, still API v1 on the way to stable... - 2024-12-10 **v0.0.1**: Initial public release (still work in progress) of API v1 diff --git a/api/openapi.yml b/api/openapi.yml index d6417bb..1162155 100644 --- a/api/openapi.yml +++ b/api/openapi.yml @@ -34,7 +34,7 @@ paths: type: string email: type: string - apiKey: + VDBKey: type: string responses: "200": @@ -107,7 +107,7 @@ paths: type: string email: type: string - apiKey: + VDBKey: type: string responses: "200": @@ -308,7 +308,7 @@ components: email: type: string format: email - apiKey: + VDBKey: type: string minLength: 32 maxLength: 32 @@ -321,7 +321,7 @@ components: required: - handle - email - - apiKey + - VDBKey project: type: object properties: @@ -332,7 +332,7 @@ components: description: type: string maxLength: 255 - authorizedReaders: + shared_with: type: array items: type: string @@ -340,7 +340,7 @@ components: maxLength: 20 uniqueItems: true default: ["*"] - llmservices: + instances: type: array items: type: string diff --git a/dhamps-vdb b/dhamps-vdb new file mode 100755 index 0000000..f2a73e7 Binary files /dev/null and b/dhamps-vdb differ diff --git a/docs/LLM_SERVICE_REFACTORING.md b/docs/LLM_SERVICE_REFACTORING.md new file mode 100644 index 0000000..6febdf4 --- /dev/null +++ b/docs/LLM_SERVICE_REFACTORING.md @@ -0,0 +1,751 @@ +# LLM Service Architecture Refactoring - Complete Documentation + +## Table of Contents + +1. [Overview](#overview) +2. [Implementation Summary](#implementation-summary) +3. [Architecture](#architecture) +4. [Completed Work](#completed-work) +5. [Usage Guide](#usage-guide) +6. [Security Features](#security-features) +7. [Migration Guide](#migration-guide) +8. [Testing](#testing) +9. [Remaining Optional Work](#remaining-optional-work) + +## Overview + +This refactoring separates LLM services into two distinct concepts: + +1. **LLM Service Definitions** - Reusable templates owned by `_system` or users + - Contain configuration templates (endpoint, model, dimensions, API standard) + - Can be owned by `_system` (global templates) or individual users + - Used as templates for creating instances + +2. **LLM Service Instances** - User-specific configurations with encrypted API keys + - Contain actual service configurations and credentials + - Owned by individual users + - Can optionally reference a definition + - Support API key encryption + - Can be shared with other users + +## Implementation Summary + +### ✅ All Core Requirements Completed + +1. **Admin can manage _system definitions** + - `_system` user created in migration + - 4 default definitions seeded (openai-large, openai-small, cohere-v4, gemini-embedding-001) + - API standards (openai, cohere, gemini) created before definitions + +2. **Users can list all accessible instances** + - `GetAllAccessibleInstances` query returns owned + shared instances + - Users see all instances they own or have been granted access to + +3. **Handle-based instance references** + - Shared instances identified as `owner/handle` + - Own instances identified as `handle` + - Queries support handle-based lookups + +4. **API keys hidden from shared instances** + - API keys NEVER returned in GET/list responses (security) + - Write-only field in API + - Shared users can use instances but cannot see API keys + +5. **Multiple ways to create instances** + - From own definitions + - From _system definitions + - Standalone (all fields specified) + +6. **1:1 project-instance relationship** + - Projects must reference exactly one instance + - Enforced at database level + +### Build & Test Status + +- ✅ Code compiles successfully +- ✅ All tests passing (100% success rate) +- ✅ Migration tested and verified +- ✅ Encryption module tested + +## Architecture + +### Database Schema + +``` +definitions (templates) +├── definition_id (PK) +├── definition_handle +├── owner (FK → users, can be '_system') +├── endpoint, description, api_standard, model, dimensions +└── UNIQUE(owner, definition_handle) +└── Indexes: (owner, definition_handle), (definition_handle) + +instances (user-specific) +├── instance_id (PK) +├── instance_handle +├── owner (FK → users) +├── definition_id (FK → definitions, nullable) +├── endpoint, description, model, dimensions, api_standard +├── api_key_encrypted (BYTEA, AES-256-GCM encrypted) +└── UNIQUE(owner, instance_handle) + +instances_shared_with (n:m sharing) +├── user_handle (FK → users) +├── instance_id (FK → instances) +├── role (reader/editor/owner) +└── PRIMARY KEY(user_handle, instance_id) + +projects (1:1 with instances) +├── project_id (PK) +├── instance_id (FK → instances) +└── One project → One instance +``` + +### Key Tables Removed + +- `users_llm_services` - Redundant (ownership tracked via `instances.owner`) +- `projects_llm_services` - Replaced by 1:1 FK in projects table + +## Completed Work + +### 1. Database Migration (004) + +**File:** `internal/database/migrations/004_refactor_llm_services_architecture.sql` + +**Changes:** +- Created `definitions` table +- Renamed `instances` → `instances` +- Added `api_key_encrypted` BYTEA column +- Created `_system` user +- Dropped `users_llm_services` table (redundant) +- Modified `projects` table: removed many-to-many, added `instance_id` FK +- Created `instances_shared_with` table +- Seeded 3 API standards with documentation URLs: + - openai: https://platform.openai.com/docs/api-reference/embeddings + - cohere: https://docs.cohere.com/reference/embed + - gemini: https://ai.google.dev/gemini-api/docs/embeddings +- Seeded 4 default LLM service definitions: + - openai-large (3072 dimensions) + - openai-small (1536 dimensions) + - cohere-v4 (1536 dimensions) + - gemini-embedding-001 (3072 dimensions, default size) + +**Data Migration:** +- First linked LLM service per project → `project.instance_id` +- Rollback support included + +### 2. Encryption Module + +**File:** `internal/crypto/encryption.go` + +**Features:** +- AES-256-GCM encryption for API keys +- Uses `ENCRYPTION_KEY` environment variable (SHA256-hashed to ensure 32-byte key) +- Functions: + - `NewEncryptionKey(keyString)` - Create key from string + - `GenerateEncryptionKey()` - Generate random key + - `GetEncryptionKeyFromEnv()` - Read from environment + - `Encrypt(plaintext) → []byte` + - `Decrypt(ciphertext) → string` + - `EncryptToBase64(plaintext) → string` + - `DecryptFromBase64(base64) → string` + +**Testing:** Full test coverage in `internal/crypto/encryption_test.go` ✅ + +### 3. Database Queries (SQLC) + +**File:** `internal/database/queries/queries.sql` + +**Definitions:** +- `UpsertDefinition` - Create/update definition +- `DeleteDefinition` - Delete definition +- `RetrieveDefinition` - Get single definition +- `GetDefinitionsByUser` - List user's definitions +- `GetAllDefinitions` - List all definitions +- `GetSystemDefinitions` - List _system definitions + +**Instances:** +- `UpsertInstance` - Create/update instance (with encryption support) +- `CreateInstanceFromDefinition` - Create instance from definition template +- `DeleteInstance` - Delete instance +- `RetrieveInstance` - Get single instance +- `RetrieveInstanceByID` - Get instance by ID +- `RetrieveInstanceByOwnerHandle` - Get by owner/handle (supports both formats) +- `ShareInstance` - Share instance with another user +- `UnshareInstance` - Remove instance sharing +- `GetSharedUsersForInstance` - List users instance is shared with +- `GetInstanceByProject` - Get instance for project (1:1, renamed from plural) +- `GetInstancesByUser` - List user's owned instances +- `GetAllAccessibleInstances` - List owned + shared instances +- `GetSharedInstances` - List instances shared with user (sorted by role, owner, handle) + +**Updated Queries:** +- `UpsertProject` - Includes `instance_id` +- `UpsertEmbeddings` - Uses `instance_id` +- All embeddings queries - Updated to use instances table + +**SQLC Code Generated:** ✅ (`internal/database/models.go`, `internal/database/queries.sql.go`) + +### 4. Go Models + +**File:** `internal/models/instances.go` + +**Models:** +- `Definition` - For definitions +- `Instance` - For instances +- `LLMService` - Kept for backward API compatibility (maps to Instance) + +**Field Updates:** +- `InstanceHandle` (was `InstanceHandle`) +- `InstanceOwner` (was `LLMServiceOwner`) +- API keys marked as write-only (never returned in responses) + +### 5. Handlers + +**Updated Files:** +- `internal/handlers/instances.go` - All functions renamed with "Instance" suffix +- `internal/handlers/projects.go` - 1:1 instance relationship +- `internal/handlers/embeddings.go` - Uses instance from project +- `internal/handlers/admin.go` - Updated field names +- `internal/handlers/users.go` - Lists accessible instances +- `internal/handlers/validation.go` - Updated to InstanceHandle + +**Function Naming:** +- `putInstanceFunc` (was `putLLMFunc`) +- `getInstanceFunc` (was `getLLMFunc`) +- `deleteInstanceFunc` (was `deleteLLMFunc`) +- `getUserLLMsFunc` - Now returns all accessible instances (own + shared) + +**API Key Handling:** +- Encrypted on write if `ENCRYPTION_KEY` is set +- Never returned on read (security) +- Uses `Valid: true` consistently for nullable fields + +### 6. Environment Configuration + +**File:** `template.env` + +Added: +```bash +# Required for API key encryption (32+ characters recommended) +ENCRYPTION_KEY=your-secret-encryption-key-here-must-be-kept-secure +``` + +## Usage Guide + +### Creating an LLM Service Instance + +**Option A: Standalone (no definition)** +```bash +PUT /v1/llm-services/jdoe/my-openai +{ + "endpoint": "https://api.openai.com/v1/embeddings", + "api_standard": "openai", + "model": "text-embedding-3-large", + "dimensions": 3072, + "api_key_encypted": "sk-..." +} +``` + +**Option B: From _system definition** +```bash +# Use CreateInstanceFromDefinition query +# Handler would accept: +POST /v1/llm-services/jdoe/my-openai-instance +{ + "definition_owner": "_system", + "definition_handle": "openai-large", + "api_key_encrypted": "sk-..." +} +``` + +**Option C: From user's own definition** +```bash +# Similar to Option B, but with user as definition_owner +POST /v1/llm-services/jdoe/my-custom-instance +{ + "definition_owner": "jdoe", + "definition_handle": "my-custom-config", + "api_key_encrypted": "sk-..." +} +``` + +### Listing Accessible Instances + +```bash +GET /v1/llm-services/jdoe +# Returns all instances jdoe owns OR has been granted access to +# API keys are NOT included in response +``` + +### Creating a Project with Instance + +```bash +POST /v1/projects/jdoe/my-project +{ + "instance_id": 123, # or use handle-based reference + "description": "My project" +} +``` + +## Security Features + +### 1. API Key Encryption + +- **Algorithm:** AES-256-GCM +- **Key Source:** `ENCRYPTION_KEY` environment variable +- **Key Derivation:** SHA256 hash of environment variable +- **Storage:** `api_key_encrypted` BYTEA column + +### 2. Write-Only API Keys + +API keys are never returned in GET/list responses: +```json +GET /v1/llm-services/jdoe/my-openai +{ + "instance_id": 1, + "instance_handle": "my-openai", + "owner": "jdoe", + "endpoint": "...", + "model": "text-embedding-3-large", + "dimensions": 3072 + // Note: "api_key_encrypted" field is NOT present +} +``` + +### 3. Shared Instance Protection + +When an instance is shared: +- Shared users can USE the instance (e.g., for projects, embeddings) +- Shared users CANNOT see the API key +- Shared users CANNOT modify the instance (owner-only operation) +- Sharing is tracked in `instances_shared_with` table with role + +### 4. Admin-Only System Definitions + +- Only admin users can create/modify `_system` definitions +- Regular users can read `_system` definitions +- Regular users can create their own definitions +- No one can log in as `_system` + +## Migration Guide + +### For New Installations + +1. Run migrations: `make migrate-up` (or equivalent) +2. Set `ENCRYPTION_KEY` environment variable +3. Service is ready to use + +### For Existing Installations + +The migration (004) handles data migration automatically: + +**Automatic Changes:** +- `instances` table renamed to `instances` +- `users_llm_services` table dropped (ownership via owner column) +- `projects_llm_services` table dropped (replaced by FK) +- First linked instance per project → `project.instance_id` + +**Post-Migration Steps:** + +1. **Set Environment Variable:** + ```bash + export ENCRYPTION_KEY="your-secure-random-string-at-least-32-chars" + ``` + +### Breaking Changes + +**API Changes:** +- `GET /v1/llm-services/{user}` - No longer returns API keys +- `GET /v1/llm-services/{user}/{handle}` - No longer returns API keys +- Projects now require single instance (many-to-many removed) + +**Database:** +- `instances` → `instances` +- `users_llm_services` table removed +- `projects_llm_services` table removed + +**Backward Compatibility:** +- Existing endpoints continue to work +- Field names preserved in JSON responses (for API compatibility) +- Old plaintext API keys continue to work + +## Client Migration Guide + +This section explains what API clients need to change after the refactoring. + +### Summary of Changes for Clients + +**Good News:** Most API endpoints remain unchanged! The main changes are: +1. Projects must be created with a valid LLM service instance (1:1 relationship) +2. Embeddings JSON uses `instance_handle` instead of `llm_service_handle` + +### API Endpoints - No Changes Required + +All existing API endpoints continue to work with the same paths: + +``` +✅ PUT /v1/llm-services/{user}/{handle} # Create/update instance +✅ GET /v1/llm-services/{user} # List user's instances +✅ GET /v1/llm-services/{user}/{handle} # Get specific instance +✅ DELETE /v1/llm-services/{user}/{handle} # Delete instance + +✅ PUT /v1/projects/{user}/{project} # Create/update project +✅ GET /v1/projects/{user}/{project} # Get project +✅ DELETE /v1/projects/{user}/{project} # Delete project + +✅ POST /v1/embeddings/{user}/{project} # Upload embeddings +✅ GET /v1/embeddings/{user}/{project} # List embeddings +✅ DELETE /v1/embeddings/{user}/{project} # Delete embeddings +``` + +### Change #2: Embeddings Field Name Update + +**Before:** Used `llm_service_handle` in embeddings JSON +```json +POST /v1/embeddings/alice/my-project +{ + "text_id": "doc1", + "llm_service_handle": "my-openai", ← Old field name + "embedding": [0.1, 0.2, ...], + "metadata": {...} +} +``` + +**After:** Use `instance_handle` instead +```json +POST /v1/embeddings/alice/my-project +{ + "text_id": "doc1", + "instance_handle": "my-openai", ← New field name + "embedding": [0.1, 0.2, ...], + "metadata": {...} +} +``` + +**Action Required:** +- ⚠️ Update embedding upload code to use `instance_handle` field +- ⚠️ Update code that reads embeddings to expect `instance_handle` in responses + +### Change #3: Projects Must Have LLM Service Instance + +**Before:** Projects could be created without specifying an LLM service +```json +PUT /v1/projects/alice/my-project +{ + "description": "My project" +} +``` + +**After:** Projects require a valid `instance_id` +```json +PUT /v1/projects/alice/my-project +{ + "description": "My project", + "instance_id": 123 ← Required +} +``` + +**Action Required:** +- ⚠️ Create an LLM service instance BEFORE creating projects +- ⚠️ Include `instance_id` in project creation requests +- ℹ️ You can find the instance_id from the GET instances response + +### Complete Migration Workflow + +Here's the recommended workflow for clients: + +#### Step 1: Create LLM Service Instance (if not exists) + +```bash +# Check if instance exists +GET /v1/llm-services/alice + +# If not, create one +PUT /v1/llm-services/alice/my-openai +Content-Type: application/json + +{ + "endpoint": "https://api.openai.com/v1/embeddings", + "api_standard": "openai", + "model": "text-embedding-3-large", + "dimensions": 3072, + "api_key_encrypted": "sk-proj-your-key-here" +} + +# Response includes instance_id +{ + "instance_id": 123, + "instance_handle": "my-openai", + "owner": "alice", + "endpoint": "https://api.openai.com/v1/embeddings", + "api_standard": "openai", + "model": "text-embedding-3-large", + "dimensions": 3072 + // Note: api_key_encrypted not returned +} +``` + +#### Step 2: Create Project with Instance ID + +```bash +PUT /v1/projects/alice/my-project +Content-Type: application/json + +{ + "description": "My research project", + "instance_id": 123 // From step 1 +} +``` + +#### Step 3: Upload Embeddings + +```bash +POST /v1/embeddings/alice/my-project +Content-Type: application/json + +{ + "text_id": "doc1", + "instance_handle": "my-openai", // Use instance_handle (not llm_service_handle) + "embedding": [0.1, 0.2, 0.3, ...], + "metadata": { + "title": "Document 1", + "author": "Alice" + } +} +``` + +### Environment Setup for New Installations + +**Before:** No encryption key needed +```bash +# .env +DATABASE_URL=postgresql://... +ADMIN_KEY=your-admin-key +``` + +**After:** Add encryption key +```bash +# .env +DATABASE_URL=postgresql://... +ADMIN_KEY=your-admin-key +ENCRYPTION_KEY=your-secure-32-char-minimum-key # NEW +``` + +**Action Required:** +- ⚠️ Add `ENCRYPTION_KEY` to your environment variables +- ✅ Use a strong, random string (32+ characters recommended) +- ✅ Keep this key secure - losing it means losing access to encrypted API keys + +### Migration Checklist for Existing Clients + +Use this checklist to ensure your client is fully migrated: + +- [ ] **Stop reading API keys from GET responses** + - Update code to store API keys locally instead + +- [ ] **Update embedding field names** + - Change `llm_service_handle` → `instance_handle` in upload code + - Update parsing code to read `instance_handle` from responses + +- [ ] **Update project creation workflow** + - Create LLM service instance first + - Include `instance_id` in project creation + - Get instance_id from instance creation/list response + +- [ ] **Update environment configuration** + - Add `ENCRYPTION_KEY` to environment variables (for new installations) + - Restart services to pick up new configuration + +- [ ] **Test end-to-end workflow** + - Create instance → Create project → Upload embeddings + - Verify all steps work correctly + +### Troubleshooting + +**Problem:** "Project must have instance_id" error + +**Solution:** Create an LLM service instance first, then use its ID when creating the project. + +--- + +**Problem:** Embeddings upload fails with "unknown field llm_service_handle" + +**Solution:** Update your JSON to use `instance_handle` instead of `llm_service_handle`. + +--- + +**Problem:** Can't see API key after creating instance + +**Solution:** This is expected behavior (security improvement). Store the API key on the client side when you create the instance. + +--- + +**Problem:** Old embeddings have `llm_service_handle` in their data + +**Solution:** Existing embeddings stored before the migration will continue to have the old field name in their metadata. This is preserved for backward compatibility. When retrieving these embeddings, your client should be able to handle both `llm_service_handle` (old data) and `instance_handle` (new data). However, all NEW embeddings uploaded after migration must use `instance_handle`. + +--- + +**Problem:** Missing ENCRYPTION_KEY environment variable + +**Solution:** Add `ENCRYPTION_KEY=your-secure-key` to your environment variables. This is only required for new installations or if you want to start encrypting API keys. + +### Testing Your Migration + +Here's a test sequence to verify your client works correctly: + +```bash +# 1. Create instance +curl -X PUT "http://localhost:8000/v1/llm-services/testuser/test-instance" \ + -H "Authorization: Bearer your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "endpoint": "https://api.openai.com/v1/embeddings", + "api_standard": "openai", + "model": "text-embedding-3-large", + "dimensions": 3072, + "api_key_encryped": "test-key" + }' + +# 2. List instances (verify api_key_encrypted is NOT returned) +curl -X GET "http://localhost:8000/v1/llm-services/testuser" \ + -H "Authorization: Bearer your-api-key" + +# 3. Create project with instance_id +curl -X PUT "http://localhost:8000/v1/projects/testuser/test-project" \ + -H "Authorization: Bearer your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "description": "Test project", + "instance_id": 123 + }' + +# 4. Upload embeddings with instance_handle +curl -X POST "http://localhost:8000/v1/embeddings/testuser/test-project" \ + -H "Authorization: Bearer your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "text_id": "test1", + "instance_handle": "test-instance", + "embedding": [0.1, 0.2, 0.3], + "metadata": {} + }' +``` + +### Summary of Required Client Changes + +| Area | Old Behavior | New Behavior | Action | +|------|-------------|--------------|--------| +| **API Endpoints** | Same paths | Same paths | ✅ No change | +| **API Keys in GET** | Returned in response | NOT returned | ⚠️ Stop reading, store locally | +| **Embeddings field** | `llm_service_handle` | `instance_handle` | ⚠️ Update field name | +| **Project creation** | Optional instance | Required `instance_id` | ⚠️ Create instance first | +| **Project-instance** | Many-to-many | 1:1 relationship | ⚠️ One instance per project | +| **Environment vars** | No encryption key | `ENCRYPTION_KEY` needed | ⚠️ Add to .env (new installs) | + +**Legend:** ✅ No action needed | ⚠️ Action required | ℹ️ Optional/informational + +## Testing + +### Test Status + +**✅ All Tests Passing (100% success rate):** +- TestLLMServicesFunc: 16/16 subtests +- TestEmbeddingsFunc: All subtests +- TestValidationFunc: All subtests (updated to use InstanceHandle) +- TestUserFunc: All subtests +- TestPublicAccess: Pass +- TestSimilarsFunc: Pass + +### Test Fixes Applied + +1. **Query Bug Fixed:** `GetAllAccessibleInstances` had user_handle filter in JOIN ON clause, preventing owned instances from being returned +2. **Test Expectations Updated:** Removed API key from expected responses (security) + +### Test Coverage + +**Current Coverage:** +- ✅ Basic Instance CRUD operations +- ✅ Authentication/authorization +- ✅ Invalid JSON handling +- ✅ Non-existent resource handling +- ✅ API key hiding in responses +- ✅ Field name updates (InstanceHandle, etc.) + +## Remaining Optional Work + +### Potential Enhancements (~7 hours total) + +#### 1. Split Test Files (1 hour) +- Create `definitions_test.go` for Definition tests +- Create `instances_test.go` for Instance tests +- Better organization and clarity + +#### 2. Add Definition Tests (2 hours) +- Creating definitions as _system user (admin only) +- Preventing non-admin users from creating _system definitions +- User-owned definitions +- Invalid input handling +- Deletion behavior + +#### 3. Add Instance Sharing Tests (2 hours) +- Sharing instances with other users +- Listing shared instances +- Access control verification +- API key protection for shared instances +- Revoking access + +#### 4. Add Encryption Tests (1 hour) +- API key encryption/decryption roundtrip +- Handling missing ENCRYPTION_KEY +- Key update scenarios + +#### 5. Documentation (1 hour) +- API documentation for new endpoints +- Examples of instance creation from definitions +- Security best practices + +### New Endpoints (Not Implemented) + +Consider adding these endpoints in the future: +- `GET /v1/llm-service-definitions` - List all available definitions +- `GET /v1/llm-service-definitions/_system` - List system definitions +- `POST /v1/llm-service-definitions/{user}` - Create user definition +- `POST /v1/llm-instances/{user}/from-definition/{handle}` - Create from definition +- `POST /v1/llm-instances/{user}/{instance}/share/{target}` - Share instance +- `DELETE /v1/llm-instances/{user}/{instance}/share/{target}` - Revoke sharing + +### API Key Migration Tool (Not Implemented) + +Create a CLI tool or admin endpoint to: +- List all instances with plaintext API keys +- Re-encrypt them using the current ENCRYPTION_KEY +- Verify successful encryption +- Remove plaintext keys + +## Design Decisions + +1. **Encryption:** Application-level encryption (not PostgreSQL's pgcrypto) for portability +2. **Key Storage:** Environment variable (not file-based) for security and container-friendliness +3. **Backward Compatibility:** Keep existing endpoints, map to new backend +4. **Default Instances:** Projects MUST specify an instance (no auto-creation) +5. **Sharing Model:** Read-only sharing (only owner can modify) +6. **System Definitions:** Owned by `_system` user, created in migration +7. **Ownership Tracking:** Via `owner` column (removed redundant join table) + +## References + +- Encryption implementation: `internal/crypto/encryption.go` +- Migration: `internal/database/migrations/004_refactor_llm_services_architecture.sql` +- Queries: `internal/database/queries/queries.sql` +- Performance notes: See `docs/PERFORMANCE_OPTIMIZATION.md` +- Test data: `testdata/valid_embeddings*.json` (updated to use instance_handle) + +## Support + +For questions or issues: +1. Review this documentation +2. Check the migration file for schema details +3. Review test files for usage examples +4. See PERFORMANCE_OPTIMIZATION.md for performance tuning diff --git a/docs/PERFORMANCE_OPTIMIZATION.md b/docs/PERFORMANCE_OPTIMIZATION.md new file mode 100644 index 0000000..564e1b3 --- /dev/null +++ b/docs/PERFORMANCE_OPTIMIZATION.md @@ -0,0 +1,132 @@ +# Performance Optimization Notes + +## Query Optimization Opportunities + +### GetAllAccessibleInstances Query + +**Current Implementation:** +```sql +SELECT instances.*, ... +FROM instances +LEFT JOIN instances_shared_with + ON instances."instance_id" = instances_shared_with."instance_id" +WHERE instances."owner" = $1 + OR instances_shared_with."user_handle" = $1 +ORDER BY instances."owner" ASC, instances."instance_handle" ASC +LIMIT $2 OFFSET $3; +``` + +**Issue:** +The LEFT JOIN combined with OR conditions in WHERE clause may result in inefficient query execution. The query planner might struggle to use indexes effectively. + +**Recommended Optimization:** +Use UNION ALL to separate owned instances from shared instances: + +```sql +-- Get owned instances +SELECT instances.*, 'owner' as "role", true as "is_owner" +FROM instances +WHERE instances."owner" = $1 + +UNION ALL + +-- Get shared instances +SELECT instances.*, + instances_shared_with."role", + false as "is_owner" +FROM instances +INNER JOIN instances_shared_with + ON instances."instance_id" = instances_shared_with."instance_id" +WHERE instances_shared_with."user_handle" = $1 + AND instances."owner" != $1 -- Avoid duplicates + +ORDER BY "owner" ASC, "instance_handle" ASC +LIMIT $2 OFFSET $3; +``` + +**Benefits:** +1. Query planner can use separate index scans for each UNION branch +2. Owned instances can use index on (owner) +3. Shared instances can use index on (user_handle) +4. Clearer query execution plan +5. Better performance with large datasets + +**Tradeoff:** +- Slightly more complex SQL +- Need to deduplicate if user somehow has instance both owned and shared (unlikely scenario) + +**Recommendation:** +- Current implementation is correct and works well for small-medium datasets +- Consider optimization if performance becomes an issue with large numbers of instances +- Profile with EXPLAIN ANALYZE before and after optimization + +## Other Optimization Opportunities + +### Index Suggestions + +Current indexes (from migration 004): +- `definitions(definition_handle)` +- `definitions(owner, definition_handle)` (composite) +- `instances(instance_handle)` +- `instances_shared_with(instance_id, user_handle)` (implicit from PK) + +**Additional indexes to consider:** +1. `instances(owner)` - for owned instance lookups +2. `instances_shared_with(user_handle)` - for shared instance lookups +3. `instances(owner, instance_handle)` - composite for unique constraint + +### Caching Opportunities + +1. **System Definitions**: Cache _system definitions since they rarely change +2. **User Instances**: Cache user's instance list with short TTL +3. **API Standards**: Cache list of API standards (nearly static) + +### Query Analysis Tools + +```bash +# Analyze query performance +EXPLAIN ANALYZE SELECT ...; + +# Check table statistics +ANALYZE instances; +ANALYZE instances_shared_with; + +# View current indexes +\di llm_service_* +``` + +## Performance Testing + +### Recommended Tests + +1. **Load Test**: 1000 users, 10 instances each +2. **Sharing Test**: 100 users sharing instances with 50 others each +3. **Query Test**: Measure GetAllAccessibleInstances with varying instance counts + +### Metrics to Track + +- Query execution time (p50, p95, p99) +- Database connection pool usage +- Index hit rates +- Cache hit rates (if implemented) + +### Performance Targets + +Based on typical usage: +- Single instance lookup: < 10ms +- List all accessible instances: < 50ms (for < 100 instances) +- Create/update instance: < 100ms (including encryption) + +## Implementation Priority + +1. **High**: Profile current performance with realistic data +2. **Medium**: Implement UNION ALL optimization if query time > 100ms +3. **Low**: Add caching layer for frequently accessed data +4. **Low**: Add indexes based on actual query patterns + +## Notes + +- Current implementation prioritizes correctness over optimization +- All tests pass with current query structure +- Performance optimization should be data-driven (measure first) +- Don't optimize prematurely - wait for actual performance issues diff --git a/docs/PUBLIC_ACCESS.md b/docs/PUBLIC_ACCESS.md index 26f476b..072234e 100644 --- a/docs/PUBLIC_ACCESS.md +++ b/docs/PUBLIC_ACCESS.md @@ -2,19 +2,19 @@ ## Overview -Projects can be configured to allow unauthenticated (public) read access to embeddings and similar documents by including the special value `"*"` in the `authorizedReaders` array when creating or updating a project. +Projects can be configured to allow unauthenticated (public) read access to embeddings and similar documents by including the special value `"*"` in the `shared_with` array when creating or updating a project. ## Usage ### Creating a Public Project -When creating or updating a project, include `"*"` in the `authorizedReaders` field: +When creating or updating a project, include `"*"` in the `shared_with` field: ```json { "project_handle": "my-public-project", "description": "A publicly accessible project", - "authorizedReaders": ["*"] + "shared_with": ["*"] } ``` @@ -22,7 +22,7 @@ When creating or updating a project, include `"*"` in the `authorizedReaders` fi When a project has public read access enabled, the following endpoints can be accessed without authentication: -- `GET /v1/projects/{user}/{project}` - Retrieve project metadata (including owner and authorizedReaders) +- `GET /v1/projects/{user}/{project}` - Retrieve project metadata (including owner and shared_with) - `GET /v1/embeddings/{user}/{project}` - Retrieve all embeddings for the project - `GET /v1/embeddings/{user}/{project}/{text_id}` - Retrieve a specific embedding - `GET /v1/similars/{user}/{project}/{text_id}` - Find similar documents @@ -50,7 +50,7 @@ A `public_read` boolean flag is stored in the `projects` table to indicate wheth ### Backwards Compatibility -For backwards compatibility, when `"*"` is included in `authorizedReaders`: +For backwards compatibility, when `"*"` is included in `shared_with`: - The `public_read` flag is set to true (enabling unauthenticated access) - All existing users are still added to the `users_projects` table as readers - This ensures that existing authentication mechanisms continue to work @@ -58,7 +58,7 @@ For backwards compatibility, when `"*"` is included in `authorizedReaders`: ### Project Metadata Display When a project has `public_read` enabled: -- The `authorizedReaders` field will display `["*"]` instead of an expanded list of all users +- The `shared_with` field will display `["*"]` instead of an expanded list of all users - This makes it clear that the project is publicly accessible - Anonymous users can view project metadata including owner, description, and the `["*"]` indicator @@ -76,7 +76,7 @@ When a project has `public_read` enabled: ```bash # Get project metadata without authentication curl http://localhost:8080/v1/projects/alice/public-project -# Returns: {"project_handle": "public-project", "owner": "alice", "authorizedReaders": ["*"], ...} +# Returns: {"project_handle": "public-project", "owner": "alice", "shared_with": ["*"], ...} # Get all embeddings without authentication curl http://localhost:8080/v1/embeddings/alice/public-project @@ -105,4 +105,4 @@ curl -X POST http://localhost:8080/v1/embeddings/alice/public-project \ ## Migration -Existing projects are not affected. The `public_read` flag defaults to `false`, so all existing projects continue to require authentication for read operations unless explicitly updated to include `"*"` in their `authorizedReaders`. +Existing projects are not affected. The `public_read` flag defaults to `false`, so all existing projects continue to require authentication for read operations unless explicitly updated to include `"*"` in their `shared_with`. diff --git a/internal/auth/authenticate.go b/internal/auth/authenticate.go index 3cdec9b..6569a04 100644 --- a/internal/auth/authenticate.go +++ b/internal/auth/authenticate.go @@ -24,19 +24,19 @@ const ( // Config is the security scheme configuration for the API. var Config = map[string]*huma.SecurityScheme{ "adminAuth": { - Type: "apiKey", + Type: "VDBKey", In: "header", Scheme: "bearer", Name: "Authorization", }, "ownerAuth": { - Type: "apiKey", + Type: "VDBKey", In: "header", Scheme: "bearer", Name: "Authorization", }, "readerAuth": { - Type: "apiKey", + Type: "VDBKey", In: "header", Scheme: "bearer", Name: "Authorization", @@ -76,10 +76,10 @@ func AuthTermination(api huma.API) func(ctx huma.Context, next func(huma.Context } } -// APIKey... functions return a middleware function that checks for a valid API key. +// VDBKey... functions return a middleware function that checks for a valid API key. -// APIKeyAdminAuth checks for an admin API key in the Authorization header. -func APIKeyAdminAuth(api huma.API, options *models.Options) func(ctx huma.Context, next func(huma.Context)) { +// VDBKeyAdminAuth checks for an admin API key in the Authorization header. +func VDBKeyAdminAuth(api huma.API, options *models.Options) func(ctx huma.Context, next func(huma.Context)) { return func(ctx huma.Context, next func(huma.Context)) { // Check if adminAuth is applicable @@ -110,8 +110,8 @@ func APIKeyAdminAuth(api huma.API, options *models.Options) func(ctx huma.Contex } } -// APIKeyOwnerAuth checks for an owner API key in the Authorization header. -func APIKeyOwnerAuth(api huma.API, pool *pgxpool.Pool, options *models.Options) func(ctx huma.Context, next func(huma.Context)) { +// VDBKeyOwnerAuth checks for an owner API key in the Authorization header. +func VDBKeyOwnerAuth(api huma.API, pool *pgxpool.Pool, options *models.Options) func(ctx huma.Context, next func(huma.Context)) { return func(ctx huma.Context, next func(huma.Context)) { // Check if ownerAuth is applicable @@ -158,7 +158,7 @@ func APIKeyOwnerAuth(api huma.API, pool *pgxpool.Pool, options *models.Options) } // fmt.Printf(" check owner hash against API token: %s/%s ...\n", storedHash, token) - if apiKeyIsValid(token, storedHash) { + if VDBKeyIsValid(token, storedHash) { ctx = huma.WithValue(ctx, IsOwnerKey, true) ctx = huma.WithValue(ctx, AuthUserKey, owner) fmt.Printf(" Owner authentication successful: %s\n", owner) @@ -170,8 +170,8 @@ func APIKeyOwnerAuth(api huma.API, pool *pgxpool.Pool, options *models.Options) } } -// APIKeyReaderAuth checks for a reader API key in the Authorization header. -func APIKeyReaderAuth(api huma.API, pool *pgxpool.Pool, options *models.Options) func(ctx huma.Context, next func(huma.Context)) { +// VDBKeyReaderAuth checks for a reader API key in the Authorization header. +func VDBKeyReaderAuth(api huma.API, pool *pgxpool.Pool, options *models.Options) func(ctx huma.Context, next func(huma.Context)) { return func(ctx huma.Context, next func(huma.Context)) { // Check if readerAuth is applicable isAuthorizationRequired := false @@ -198,64 +198,172 @@ func APIKeyReaderAuth(api huma.API, pool *pgxpool.Pool, options *models.Options) owner := ctx.Param("user_handle") project := ctx.Param("project_handle") - token := strings.TrimPrefix(ctx.Header("Authorization"), "Bearer ") + definition := ctx.Param("definition_handle") + instance := ctx.Param("instance_handle") - if len(owner) == 0 || len(project) == 0 { + // If no owner or project/definition/instance is specified, skip reader auth + if len(owner) == 0 || (len(project) == 0 && len(definition) == 0 && len(instance) == 0) { next(ctx) return } - // Check if the project has public_read enabled - queries := database.New(pool) - publicReadParams := database.IsProjectPubliclyReadableParams{ - Owner: owner, - ProjectHandle: project, - } - publicRead, err := queries.IsProjectPubliclyReadable(ctx.Context(), publicReadParams) - // If project exists and public_read is true, allow unauthenticated access - if err == nil && publicRead.Valid && publicRead.Bool { - // Public read is enabled, allow unauthenticated access - fmt.Print(" Public read access granted (no authentication required)\n") - ctx = huma.WithValue(ctx, AuthUserKey, "public") - next(ctx) + fmt.Printf(" Reader auth for owner=%s project=%s definition=%s instance=%s running...\n", owner, project, definition, instance) + // Branch based on whether project, definition, or instance is being accessed + if len(project) > 0 { + fmt.Print(" Checking project access...\n") + handleProjectReaderAuth(api, pool, owner, project)(ctx, next) return } - // If there's an error (e.g., project not found), continue to check authorized readers - // The project existence check will happen in the handler - - // If not public, check for authorized readers - getKeysParams := database.GetKeysByLinkedUsersParams{ - Owner: owner, - ProjectHandle: project, - Limit: 50, - Offset: 0, + if len(definition) > 0 { + fmt.Print(" Checking definition access...\n") + handleDefinitionReaderAuth(api, pool, owner, definition)(ctx, next) + return } - allowedUsers, err := queries.GetKeysByLinkedUsers(ctx.Context(), getKeysParams) - if err != nil && err.Error() != "no rows in result set" { - _ = huma.WriteErr(api, ctx, http.StatusInternalServerError, "unable to get linked users") + if len(instance) > 0 { + fmt.Print(" Checking instance access...\n") + handleInstanceReaderAuth(api, pool, owner, instance)(ctx, next) return } - if err != nil && err.Error() == "no rows in result set" { + } +} + +func handleProjectReaderAuth(api huma.API, pool *pgxpool.Pool, owner string, project string) func(ctx huma.Context, next func(huma.Context)) { + { + return func(ctx huma.Context, next func(huma.Context)) { + + token := strings.TrimPrefix(ctx.Header("Authorization"), "Bearer ") + + // Check if the project has public_read enabled + queries := database.New(pool) + publicReadParams := database.IsProjectPubliclyReadableParams{ + Owner: owner, + ProjectHandle: project, + } + publicRead, err := queries.IsProjectPubliclyReadable(ctx.Context(), publicReadParams) + // If project exists and public_read is true, allow unauthenticated access + if err == nil && publicRead.Valid && publicRead.Bool { + // Public read is enabled, allow unauthenticated access + fmt.Print(" Public read access granted (no authentication required)\n") + ctx = huma.WithValue(ctx, AuthUserKey, "public") + next(ctx) + return + } + + // If there's an error (e.g., project not found), continue to check authorized readers + // The project existence check will happen in the handler + + // If not public, check for authorized readers + getKeysByProjectParams := database.GetKeysByProjectParams{ + Owner: owner, + ProjectHandle: project, + Limit: 50, + Offset: 0, + } + allowedKeys, err := queries.GetKeysByProject(ctx.Context(), getKeysByProjectParams) + if err != nil && err.Error() != "no rows in result set" { + _ = huma.WriteErr(api, ctx, http.StatusInternalServerError, "unable to get linked users") + return + } + if err != nil && err.Error() == "no rows in result set" { + next(ctx) + return + } + for _, authKey := range allowedKeys { + storedHash := authKey.VDBKey + + if VDBKeyIsValid(token, storedHash) { + fmt.Print(" Reader authentication successful\n") + ctx = huma.WithValue(ctx, AuthUserKey, authKey.UserHandle) + next(ctx) + return + } + } + next(ctx) - return } - for _, authUser := range allowedUsers { - storedHash := authUser.VdbAPIKey + } +} + +func handleDefinitionReaderAuth(api huma.API, pool *pgxpool.Pool, owner string, definition string) func(ctx huma.Context, next func(huma.Context)) { + { + return func(ctx huma.Context, next func(huma.Context)) { - if apiKeyIsValid(token, storedHash) { - fmt.Print(" Reader authentication successful\n") - ctx = huma.WithValue(ctx, AuthUserKey, authUser.UserHandle) + token := strings.TrimPrefix(ctx.Header("Authorization"), "Bearer ") + + // Check for authorized readers + queries := database.New(pool) + getKeysByDefinitionParams := database.GetKeysByDefinitionParams{ + Owner: owner, + DefinitionHandle: definition, + Limit: 50, + Offset: 0, + } + allowedKeys, err := queries.GetKeysByDefinition(ctx.Context(), getKeysByDefinitionParams) + if err != nil && err.Error() != "no rows in result set" { + _ = huma.WriteErr(api, ctx, http.StatusInternalServerError, "unable to get linked users") + return + } + if err != nil && err.Error() == "no rows in result set" { next(ctx) return } + for _, authKey := range allowedKeys { + storedHash := authKey.VDBKey + + if VDBKeyIsValid(token, storedHash) { + fmt.Print(" Reader authentication successful\n") + ctx = huma.WithValue(ctx, AuthUserKey, authKey.UserHandle) + next(ctx) + return + } + } + + next(ctx) } + } +} - next(ctx) +func handleInstanceReaderAuth(api huma.API, pool *pgxpool.Pool, owner string, instance string) func(ctx huma.Context, next func(huma.Context)) { + { + return func(ctx huma.Context, next func(huma.Context)) { + + token := strings.TrimPrefix(ctx.Header("Authorization"), "Bearer ") + + // Check for authorized readers + queries := database.New(pool) + getKeysByInstanceParams := database.GetKeysByInstanceParams{ + Owner: owner, + InstanceHandle: instance, + Limit: 50, + Offset: 0, + } + allowedKeys, err := queries.GetKeysByInstance(ctx.Context(), getKeysByInstanceParams) + if err != nil && err.Error() != "no rows in result set" { + _ = huma.WriteErr(api, ctx, http.StatusInternalServerError, "unable to get linked users") + return + } + if err != nil && err.Error() == "no rows in result set" { + next(ctx) + return + } + for _, authKey := range allowedKeys { + storedHash := authKey.VDBKey + + if VDBKeyIsValid(token, storedHash) { + fmt.Print(" Reader authentication successful\n") + ctx = huma.WithValue(ctx, AuthUserKey, authKey.UserHandle) + next(ctx) + return + } + } + + next(ctx) + } } } -// apiKeyIsValid checks if the given API key is valid -func apiKeyIsValid(rawKey string, storedHash string) bool { +// VDBKeyIsValid checks if the given API key is valid +func VDBKeyIsValid(rawKey string, storedHash string) bool { hash := sha256.Sum256([]byte(rawKey)) hashedKey := hex.EncodeToString(hash[:]) diff --git a/internal/auth/authenticate_test.go b/internal/auth/authenticate_test.go index cea8d47..9389385 100644 --- a/internal/auth/authenticate_test.go +++ b/internal/auth/authenticate_test.go @@ -15,8 +15,8 @@ func TestApiKeyIsValid(t *testing.T) { want bool }{ { - name: "Valid API key", - rawKey: "test-api-key-12345", + name: "Valid API key", + rawKey: "test-api-key-12345", storedHash: func() string { hash := sha256.Sum256([]byte("test-api-key-12345")) return hex.EncodeToString(hash[:]) @@ -24,8 +24,8 @@ func TestApiKeyIsValid(t *testing.T) { want: true, }, { - name: "Invalid API key", - rawKey: "wrong-api-key", + name: "Invalid API key", + rawKey: "wrong-api-key", storedHash: func() string { hash := sha256.Sum256([]byte("test-api-key-12345")) return hex.EncodeToString(hash[:]) @@ -33,8 +33,8 @@ func TestApiKeyIsValid(t *testing.T) { want: false, }, { - name: "Empty API key", - rawKey: "", + name: "Empty API key", + rawKey: "", storedHash: func() string { hash := sha256.Sum256([]byte("test-api-key-12345")) return hex.EncodeToString(hash[:]) @@ -51,7 +51,7 @@ func TestApiKeyIsValid(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := apiKeyIsValid(tt.rawKey, tt.storedHash); got != tt.want { + if got := VDBKeyIsValid(tt.rawKey, tt.storedHash); got != tt.want { t.Errorf("apiKeyIsValid() = %v, want %v", got, tt.want) } }) @@ -64,6 +64,6 @@ func TestApiKeyIsValid(t *testing.T) { // - Admin authentication with valid/invalid keys // - Owner authentication for various resources // - Reader authentication for shared projects -// - Public access for projects with "*" in authorizedReaders +// - Public access for projects with "*" in shared_with // - Authentication failure handling // - Authorization checks for different operations diff --git a/internal/crypto/encryption.go b/internal/crypto/encryption.go new file mode 100644 index 0000000..bd3b1b9 --- /dev/null +++ b/internal/crypto/encryption.go @@ -0,0 +1,130 @@ +package crypto + +import ( +"crypto/aes" +"crypto/cipher" +"crypto/rand" +"crypto/sha256" +"encoding/base64" +"errors" +"fmt" +"io" +"os" +) + +var ( +ErrInvalidKey = errors.New("encryption key must be 32 bytes") +ErrInvalidCiphertext = errors.New("ciphertext is too short or invalid") +) + +// EncryptionKey holds the AES encryption key +type EncryptionKey struct { +key []byte +} + +// NewEncryptionKey creates a new encryption key from the provided string +// The key is hashed using SHA256 to ensure it's exactly 32 bytes (AES-256) +func NewEncryptionKey(keyString string) *EncryptionKey { +hash := sha256.Sum256([]byte(keyString)) +return &EncryptionKey{key: hash[:]} +} + +// GenerateEncryptionKey generates a random encryption key +func GenerateEncryptionKey() (*EncryptionKey, error) { +key := make([]byte, 32) +if _, err := rand.Read(key); err != nil { +return nil, fmt.Errorf("failed to generate encryption key: %w", err) +} +return &EncryptionKey{key: key}, nil +} + +// GetEncryptionKeyFromEnv retrieves the encryption key from environment variable +// If not set, it returns an error +func GetEncryptionKeyFromEnv() (*EncryptionKey, error) { +keyString := os.Getenv("ENCRYPTION_KEY") +if keyString == "" { +return nil, errors.New("ENCRYPTION_KEY environment variable is not set") +} +return NewEncryptionKey(keyString), nil +} + +// Encrypt encrypts plaintext using AES-256-GCM +func (e *EncryptionKey) Encrypt(plaintext string) ([]byte, error) { +if plaintext == "" { +return nil, nil // Allow empty strings to be stored as NULL +} + +block, err := aes.NewCipher(e.key) +if err != nil { +return nil, fmt.Errorf("failed to create cipher: %w", err) +} + +gcm, err := cipher.NewGCM(block) +if err != nil { +return nil, fmt.Errorf("failed to create GCM: %w", err) +} + +nonce := make([]byte, gcm.NonceSize()) +if _, err := io.ReadFull(rand.Reader, nonce); err != nil { +return nil, fmt.Errorf("failed to generate nonce: %w", err) +} + +ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) +return ciphertext, nil +} + +// Decrypt decrypts ciphertext using AES-256-GCM +func (e *EncryptionKey) Decrypt(ciphertext []byte) (string, error) { +if ciphertext == nil || len(ciphertext) == 0 { +return "", nil // Return empty string for NULL/empty data +} + +block, err := aes.NewCipher(e.key) +if err != nil { +return "", fmt.Errorf("failed to create cipher: %w", err) +} + +gcm, err := cipher.NewGCM(block) +if err != nil { +return "", fmt.Errorf("failed to create GCM: %w", err) +} + +nonceSize := gcm.NonceSize() +if len(ciphertext) < nonceSize { +return "", ErrInvalidCiphertext +} + +nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] +plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) +if err != nil { +return "", fmt.Errorf("failed to decrypt: %w", err) +} + +return string(plaintext), nil +} + +// EncryptToBase64 encrypts plaintext and returns base64-encoded string +func (e *EncryptionKey) EncryptToBase64(plaintext string) (string, error) { +ciphertext, err := e.Encrypt(plaintext) +if err != nil { +return "", err +} +if ciphertext == nil { +return "", nil +} +return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// DecryptFromBase64 decrypts base64-encoded ciphertext +func (e *EncryptionKey) DecryptFromBase64(base64Ciphertext string) (string, error) { +if base64Ciphertext == "" { +return "", nil +} + +ciphertext, err := base64.StdEncoding.DecodeString(base64Ciphertext) +if err != nil { +return "", fmt.Errorf("failed to decode base64: %w", err) +} + +return e.Decrypt(ciphertext) +} diff --git a/internal/crypto/encryption_test.go b/internal/crypto/encryption_test.go new file mode 100644 index 0000000..af3accf --- /dev/null +++ b/internal/crypto/encryption_test.go @@ -0,0 +1,138 @@ +package crypto + +import ( +"os" +"testing" +) + +func TestEncryptDecrypt(t *testing.T) { + key := NewEncryptionKey("test-encryption-key-12345") + + tests := []struct { + name string + plaintext string + }{ + {"simple text", "my-api-key-12345"}, + {"empty string", ""}, + {"long text", "this is a very long API key that should still be encrypted properly without any issues at all"}, + {"special chars", "key!@#$%^&*()_+-=[]{}|;':\",./<>?"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Encrypt + ciphertext, err := key.Encrypt(tt.plaintext) + if err != nil { + t.Fatalf("Encrypt failed: %v", err) + } + + if tt.plaintext == "" && ciphertext != nil { + t.Errorf("Expected nil ciphertext for empty plaintext") + } + + // Decrypt + decrypted, err := key.Decrypt(ciphertext) + if err != nil { + t.Fatalf("Decrypt failed: %v", err) + } + + if decrypted != tt.plaintext { + t.Errorf("Decrypted text doesn't match original. Got %q, want %q", decrypted, tt.plaintext) + } + }) + } +} + +func TestEncryptDecryptBase64(t *testing.T) { + key := NewEncryptionKey("test-encryption-key-67890") + plaintext := "my-secret-api-key" + + // Encrypt to base64 + encrypted, err := key.EncryptToBase64(plaintext) + if err != nil { + t.Fatalf("EncryptToBase64 failed: %v", err) + } + + if encrypted == "" { + t.Fatal("Expected non-empty encrypted string") + } + + // Decrypt from base64 + decrypted, err := key.DecryptFromBase64(encrypted) + if err != nil { + t.Fatalf("DecryptFromBase64 failed: %v", err) + } + + if decrypted != plaintext { + t.Errorf("Decrypted text doesn't match. Got %q, want %q", decrypted, plaintext) + } +} + +func TestDifferentKeys(t *testing.T) { + key1 := NewEncryptionKey("key1") + key2 := NewEncryptionKey("key2") + + plaintext := "secret-data" + + // Encrypt with key1 + ciphertext, err := key1.Encrypt(plaintext) + if err != nil { + t.Fatalf("Encrypt failed: %v", err) + } + + // Try to decrypt with key2 (should fail) + _, err = key2.Decrypt(ciphertext) + if err == nil { + t.Error("Expected decryption with wrong key to fail, but it succeeded") + } +} + +func TestGetEncryptionKeyFromEnv(t *testing.T) { + // Test with key set + os.Setenv("ENCRYPTION_KEY", "test-key") + defer os.Unsetenv("ENCRYPTION_KEY") + + key, err := GetEncryptionKeyFromEnv() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if key == nil { + t.Fatal("Expected key to be non-nil") + } + + // Test without key set + os.Unsetenv("ENCRYPTION_KEY") + _, err = GetEncryptionKeyFromEnv() + if err == nil { + t.Error("Expected error when ENCRYPTION_KEY not set") + } +} + +func TestEncryptSameTextDifferentCiphertexts(t *testing.T) { + key := NewEncryptionKey("test-key") + plaintext := "same-text" + + // Encrypt twice + cipher1, err := key.Encrypt(plaintext) + if err != nil { + t.Fatalf("First encrypt failed: %v", err) + } + + cipher2, err := key.Encrypt(plaintext) + if err != nil { + t.Fatalf("Second encrypt failed: %v", err) + } + + // Ciphertexts should be different (due to random nonce) + if string(cipher1) == string(cipher2) { + t.Error("Expected different ciphertexts for same plaintext (nonce should randomize)") + } + + // But both should decrypt to same plaintext + decrypted1, _ := key.Decrypt(cipher1) + decrypted2, _ := key.Decrypt(cipher2) + + if decrypted1 != plaintext || decrypted2 != plaintext { + t.Error("Both ciphertexts should decrypt to same plaintext") + } +} diff --git a/internal/database/database.go b/internal/database/database.go index a854ace..361ffb7 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -122,7 +122,7 @@ func testQuery(ctx context.Context, conn *pgxpool.Conn) error { defer cancel() queries := New(conn) - users, err := queries.GetUsers(ctx_cancel, GetUsersParams{Limit: 10, Offset: 0}) + users, err := queries.GetAllUsers(ctx_cancel, GetAllUsersParams{Limit: 10, Offset: 0}) if err != nil { fmt.Fprintf(os.Stderr, "EEE Unable to get users: %v\n", err) return err diff --git a/internal/database/migrations/001_create_initial_scheme.sql b/internal/database/migrations/001_create_initial_scheme.sql index 60421e6..ad6f0fc 100644 --- a/internal/database/migrations/001_create_initial_scheme.sql +++ b/internal/database/migrations/001_create_initial_scheme.sql @@ -10,8 +10,7 @@ CREATE TABLE IF NOT EXISTS users( "user_handle" VARCHAR(20) PRIMARY KEY, "name" TEXT, "email" TEXT UNIQUE NOT NULL, - -- "vdb_api_key" BYTEA UNIQUE NOT NULL, - "vdb_api_key" CHAR(64) UNIQUE NOT NULL, + "vdb_key" CHAR(64) UNIQUE NOT NULL, "created_at" TIMESTAMP NOT NULL, "updated_at" TIMESTAMP NOT NULL ); @@ -29,8 +28,6 @@ CREATE TABLE IF NOT EXISTS projects( UNIQUE ("owner", "project_handle") ); -CREATE INDEX IF NOT EXISTS projects_handle ON "projects"("project_handle"); - -- This creates the users_projects associations table. CREATE TABLE IF NOT EXISTS vdb_roles( @@ -38,7 +35,7 @@ CREATE TABLE IF NOT EXISTS vdb_roles( ); INSERT INTO "vdb_roles"("vdb_role") -VALUES ('owner'), ('writer'), ('reader'); +VALUES ('owner'), ('editor'), ('reader'); CREATE TABLE IF NOT EXISTS users_projects( "user_handle" VARCHAR(20) REFERENCES "users"("user_handle") ON DELETE CASCADE, @@ -49,7 +46,7 @@ CREATE TABLE IF NOT EXISTS users_projects( PRIMARY KEY ("user_handle", "project_id") ); --- This creates the api_standards table. +-- This creates the api_standards table (and the key_methods table it presupposes). CREATE TABLE IF NOT EXISTS key_methods( "key_method" VARCHAR(20) PRIMARY KEY @@ -75,7 +72,6 @@ CREATE TABLE IF NOT EXISTS llm_services( "owner" VARCHAR(20) NOT NULL REFERENCES "users"("user_handle") ON DELETE CASCADE, "endpoint" TEXT NOT NULL, "description" TEXT, - "api_key" TEXT, "api_standard" VARCHAR(20) NOT NULL REFERENCES "api_standards"("api_standard_handle"), "model" TEXT NOT NULL, "dimensions" INTEGER NOT NULL, @@ -84,8 +80,6 @@ CREATE TABLE IF NOT EXISTS llm_services( UNIQUE ("owner", "llm_service_handle") ); -CREATE INDEX IF NOT EXISTS llm_services_handle ON "llm_services"("llm_service_handle"); - -- This creates the users_llm_services associations table. CREATE TABLE IF NOT EXISTS users_llm_services( @@ -129,7 +123,6 @@ CREATE INDEX IF NOT EXISTS embeddings_text_id ON "embeddings"("text_id"); -- We will create the index for the vector in a separate schema version -- CREATE INDEX ON embeddings USING hnsw (vector halfvec_cosine_ops) WITH (m = 16, ef_construction = 128); - ---- create above / drop below ---- -- This removes the users table. @@ -140,8 +133,6 @@ DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS projects; -DROP INDEX IF EXISTS projects_handle; - -- This removes the users_projects associations table. DROP TABLE IF EXISTS users_projects; @@ -152,8 +143,6 @@ DROP TABLE IF EXISTS vdb_roles; DROP TABLE IF EXISTS llm_services; -DROP INDEX IF EXISTS llm_services_handle; - -- This removes the users_llm_services associations table. DROP TABLE IF EXISTS users_llm_services; diff --git a/internal/database/migrations/004_refactor_llm_services_architecture.sql b/internal/database/migrations/004_refactor_llm_services_architecture.sql new file mode 100644 index 0000000..b1e4f40 --- /dev/null +++ b/internal/database/migrations/004_refactor_llm_services_architecture.sql @@ -0,0 +1,281 @@ +-- Refactor LLM services architecture into Definitions and Instances +-- This migration separates service templates (definitions) from user-specific instances + + +-- I. API Standards + + +-- Step 1: Ensure required API standards exist before creating definitions +-- These API standards are needed for the default LLM Service Definitions + +INSERT INTO api_standards ("api_standard_handle", "description", "key_method", "key_field", "created_at", "updated_at") +VALUES ('openai', + 'OpenAI Embeddings API, Version 1, as documented in https://platform.openai.com/docs/api-reference/embeddings', + 'auth_bearer', + 'Authorization', + NOW(), + NOW()) +ON CONFLICT ("api_standard_handle") DO NOTHING; + +INSERT INTO api_standards ("api_standard_handle", "description", "key_method", "key_field", "created_at", "updated_at") +VALUES ('cohere', + 'Cohere Embed API, Version 2, as documented in https://docs.cohere.com/reference/embed', + 'auth_bearer', + 'Authorization', + NOW(), + NOW()) +ON CONFLICT ("api_standard_handle") DO NOTHING; + +INSERT INTO api_standards ("api_standard_handle", "description", "key_method", "key_field", "created_at", "updated_at") +VALUES ('gemini', + 'Gemini Embeddings API, as documented in https://ai.google.dev/gemini-api/docs/embeddings', + 'auth_bearer', + 'x-goog-api-key', + NOW(), + NOW()) +ON CONFLICT ("api_standard_handle") DO NOTHING; + +-- TODO: Add API standards for anthropic, mistral, llama.cpp, ollama, vllm, llmstudio + + +-- II. Definitions + + +-- Step 2: Create the _system user for global definitions +INSERT INTO users ("user_handle", "name", "email", "vdb_key", "created_at", "updated_at") +VALUES ('_system', 'System User', 'system@dhamps-vdb.internal', + -- TODO: Generate a system API key (64 chars of zeros as placeholder) + '0000000000000000000000000000000000000000000000000000000000000000', + NOW(), NOW()) +ON CONFLICT ("user_handle") DO NOTHING; + +-- Step 3: Create LLM Service Definitions table (templates that can be shared) +CREATE TABLE IF NOT EXISTS definitions( + "definition_id" SERIAL PRIMARY KEY, + "definition_handle" VARCHAR(20) NOT NULL, + "owner" VARCHAR(20) NOT NULL REFERENCES "users"("user_handle") ON DELETE CASCADE, + "endpoint" TEXT NOT NULL, + "description" TEXT, + "api_standard" VARCHAR(20) NOT NULL REFERENCES "api_standards"("api_standard_handle"), + "model" TEXT NOT NULL, + "dimensions" INTEGER NOT NULL, + "context_limit" INTEGER NOT NULL, + "is_public" BOOLEAN NOT NULL DEFAULT FALSE, -- If true, shared with all users + "created_at" TIMESTAMP NOT NULL, + "updated_at" TIMESTAMP NOT NULL, + UNIQUE ("owner", "definition_handle") +); + +-- Step 4: Seed default LLM Service Definitions from _system user and share them with all users +-- These serve as templates that all users can reference + +-- 4. (a) OpenAI text-embedding-3-large +INSERT INTO definitions + ("definition_handle", "owner", "endpoint", "description", "api_standard", "model", "dimensions", "context_limit", "is_public", "created_at", "updated_at") +VALUES + ('openai-large', + '_system', + 'https://api.openai.com/v1/embeddings', + 'OpenAI text-embedding-3-large service (3072 dimensions)', + 'openai', + 'text-embedding-3-large', + 3072, + 8192, + TRUE, + NOW(), + NOW()) +ON CONFLICT ("owner", "definition_handle") DO NOTHING; + +-- 4. (b) OpenAI text-embedding-3-small +INSERT INTO definitions + ("definition_handle", "owner", "endpoint", "description", "api_standard", "model", "dimensions", "context_limit", "is_public", "created_at", "updated_at") +VALUES + ('openai-small', + '_system', + 'https://api.openai.com/v1/embeddings', + 'OpenAI text-embedding-3-small service (1536 dimensions)', + 'openai', + 'text-embedding-3-small', + 1536, + 8191, + TRUE, + NOW(), + NOW()) +ON CONFLICT ("owner", "definition_handle") DO NOTHING; + +-- 4. (c) Cohere embed-v4.0 +INSERT INTO definitions + ("definition_handle", "owner", "endpoint", "description", "api_standard", "model", "dimensions", "context_limit", "is_public", "created_at", "updated_at") +VALUES + ('cohere-v4', + '_system', + 'https://api.cohere.com/v2/embed', + 'Cohere embed-v4.0 service (1536 dimensions)', + 'cohere', + 'embed-v4.0', + 1536, + 128000, + TRUE, + NOW(), + NOW()) +ON CONFLICT ("owner", "definition_handle") DO NOTHING; + +-- 4. (d) Google Gemini embedding-001 +INSERT INTO definitions + ("definition_handle", "owner", "endpoint", "description", "api_standard", "model", "dimensions", "context_limit", "is_public", "created_at", "updated_at") +VALUES + ('gemini-embedding-001', + '_system', + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent', + 'Google Gemini embedding-001 service (3072 dimensions)', + 'gemini', + 'gemini-embedding-001', + 3072, + 2048, + TRUE, + NOW(), + NOW()) +ON CONFLICT ("owner", "definition_handle") DO NOTHING; + +-- Step 5: Create table for definitions sharing (n:m relationship between instances and users) +CREATE TABLE IF NOT EXISTS definitions_shared_with( + "user_handle" VARCHAR(20) NOT NULL REFERENCES "users"("user_handle") ON DELETE CASCADE, + "definition_id" INTEGER NOT NULL REFERENCES "definitions"("definition_id") ON DELETE CASCADE, + "created_at" TIMESTAMP NOT NULL, + "updated_at" TIMESTAMP NOT NULL, + PRIMARY KEY ("user_handle", "definition_id") +); + +-- Step 6: Create indexes on definitions_shared_with for efficient lookups +CREATE INDEX IF NOT EXISTS definitions_shared_with_user_idx ON "definitions_shared_with"("user_handle"); +CREATE INDEX IF NOT EXISTS definitions_shared_with_definition_idx ON "definitions_shared_with"("definition_id"); + + +-- III. Instances + + +-- Step 7: Rename existing instances table to instances +ALTER TABLE llm_services RENAME TO instances; + +-- Step 8: Fix columns in instances table (rename id and handle, add definition_id, context_limit, and api_key_encrypted, drop api_key) +ALTER TABLE instances RENAME COLUMN llm_service_id TO instance_id; +ALTER TABLE instances RENAME COLUMN llm_service_handle TO instance_handle; +ALTER TABLE instances DROP COLUMN IF EXISTS api_key; +ALTER TABLE instances ADD COLUMN "context_limit" INTEGER NOT NULL; +ALTER TABLE instances ADD COLUMN "definition_id" INTEGER REFERENCES "definitions"("definition_id") ON DELETE SET NULL; +ALTER TABLE instances ADD COLUMN "api_key_encrypted" BYTEA; + +-- Step 9: Update the instances index +DROP INDEX IF EXISTS llm_services_handle; +CREATE INDEX IF NOT EXISTS instances_owner_handle ON "instances"("owner", "instance_handle"); + +-- Step 10: Create table for instance sharing (n:m relationship between instances and users) +CREATE TABLE IF NOT EXISTS instances_shared_with( + "user_handle" VARCHAR(20) NOT NULL REFERENCES "users"("user_handle") ON DELETE CASCADE, + "instance_id" INTEGER NOT NULL REFERENCES "instances"("instance_id") ON DELETE CASCADE, + "role" VARCHAR(20) NOT NULL REFERENCES "vdb_roles"("vdb_role"), + "created_at" TIMESTAMP NOT NULL, + "updated_at" TIMESTAMP NOT NULL, + PRIMARY KEY ("user_handle", "instance_id") +); + +-- Step 11: Drop redundant users_llm_services table +-- Ownership is tracked in instances.owner, sharing is tracked in instances_shared_with, no other table needed +DROP TABLE IF EXISTS users_llm_services; + +-- Step 12: Migrate data - Add the new column (nullable initially) +ALTER TABLE projects ADD COLUMN "instance_id" INTEGER REFERENCES "instances"("instance_id") ON DELETE RESTRICT; + +-- Step 13: Migrate data - for each project, pick the first linked LLM service instance +-- This is a best-effort migration; admins should verify manually if multiple services were used +UPDATE projects p +SET instance_id = ( + SELECT pls.llm_service_id + FROM projects_llm_services pls + WHERE pls.project_id = p.project_id + ORDER BY pls.created_at + LIMIT 1 +) +WHERE EXISTS ( + SELECT 1 FROM projects_llm_services pls WHERE pls.project_id = p.project_id +); + +-- Step 14: Update embeddings table to reference instance_id +-- and Update foreign key constraint +ALTER TABLE embeddings RENAME COLUMN llm_service_id TO instance_id; +ALTER TABLE embeddings DROP CONSTRAINT IF EXISTS embeddings_llm_service_id_fkey; +ALTER TABLE embeddings ADD CONSTRAINT embeddings_instance_id_fkey FOREIGN KEY (instance_id) REFERENCES instances(instance_id); + +-- Step 15: Drop the old projects_llm_services table (many-to-many, no longer needed) +-- Projects now have exactly one instance via the instance_id column +DROP TABLE IF EXISTS projects_llm_services; + + +---- create above / drop below ---- + + +-- Rollback instructions (reverse order) + +-- Step 15: Restore projects_llm_services table +CREATE TABLE IF NOT EXISTS projects_llm_services( + "project_id" SERIAL NOT NULL REFERENCES "projects"("project_id") ON DELETE CASCADE, + "instance_id" SERIAL NOT NULL REFERENCES "instances"("instance_id") ON DELETE CASCADE, + "created_at" TIMESTAMP NOT NULL, + "updated_at" TIMESTAMP NOT NULL, + PRIMARY KEY ("project_id", "instance_id") +); + +-- Step 14: Rename embeddings column back +ALTER TABLE embeddings RENAME COLUMN instance_id TO llm_service_id; + +-- Step 13: ?? + +-- Step 12: Remove the single instance reference from projects +ALTER TABLE projects DROP COLUMN IF EXISTS instance_id; + +-- Step 11: Restore users_llm_services table (rollback) +CREATE TABLE IF NOT EXISTS users_llm_services( + "user_handle" VARCHAR(20) NOT NULL REFERENCES "users"("user_handle") ON DELETE CASCADE, + "llm_service_id" SERIAL NOT NULL REFERENCES "instances"("llm_service_id") ON DELETE CASCADE, + "role" VARCHAR(20) NOT NULL REFERENCES "vdb_roles"("vdb_role"), + "created_at" TIMESTAMP NOT NULL, + "updated_at" TIMESTAMP NOT NULL, + PRIMARY KEY ("user_handle", "llm_service_id") +); + +-- Step 10: Drop instance sharing table +DROP TABLE IF EXISTS instances_shared_with; + +-- Step 9: Restore index name +DROP INDEX IF EXISTS instances_handle; +CREATE INDEX IF NOT EXISTS llm_services_handle ON "instances"("llm_service_handle"); + +-- Step 8: Remove new columns from instances +ALTER TABLE instances DROP COLUMN IF EXISTS api_key_encrypted; +ALTER TABLE instances DROP COLUMN IF EXISTS definition_id; +ALTER TABLE instances DROP COLUMN IF EXISTS context_limit; +ALTER TABLE instaces ADD COLUMN "api_key" TEXT; + +-- Step 7: Rename instances table back to instances +ALTER TABLE instances RENAME COLUMN instance_handle TO llm_service_handle; +ALTER TABLE instances RENAME COLUMN instance_id TO llm_service_id; +ALTER TABLE instances RENAME TO instances; + +-- Step 6: Drop indexes on definitions_shared_with +DROP INDEX IF EXISTS definitions_shared_with_user_idx; +DROP INDEX IF EXISTS definitions_shared_with_definition_idx; + +-- Step 5: Drop definitions sharing table +DROP TABLE IF EXISTS definitions_shared_with; + +-- Step 4: Drop seeded definitions +DELETE FROM definitions WHERE owner = '_system'; + +-- Step 3: Drop definitions table +DROP TABLE IF EXISTS definitions; + +-- Step 2: Remove _system user +DELETE FROM users WHERE user_handle = '_system'; + +-- Step 1: Ensure required API standards exist before creating definitions +DELETE FROM api_standards WHERE api_standard_handle IN ('openai', 'cohere', 'gemini'); diff --git a/internal/database/models.go b/internal/database/models.go index 4284c03..3fc7e26 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -18,12 +18,34 @@ type APIStandard struct { UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` } +type Definition struct { + DefinitionID int32 `db:"definition_id" json:"definition_id"` + DefinitionHandle string `db:"definition_handle" json:"definition_handle"` + Owner string `db:"owner" json:"owner"` + Endpoint string `db:"endpoint" json:"endpoint"` + Description pgtype.Text `db:"description" json:"description"` + APIStandard string `db:"api_standard" json:"api_standard"` + Model string `db:"model" json:"model"` + Dimensions int32 `db:"dimensions" json:"dimensions"` + ContextLimit int32 `db:"context_limit" json:"context_limit"` + IsPublic bool `db:"is_public" json:"is_public"` + CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` +} + +type DefinitionsSharedWith struct { + UserHandle string `db:"user_handle" json:"user_handle"` + DefinitionID int32 `db:"definition_id" json:"definition_id"` + CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` +} + type Embedding struct { EmbeddingsID int32 `db:"embeddings_id" json:"embeddings_id"` TextID pgtype.Text `db:"text_id" json:"text_id"` Owner string `db:"owner" json:"owner"` ProjectID int32 `db:"project_id" json:"project_id"` - LLMServiceID int32 `db:"llm_service_id" json:"llm_service_id"` + InstanceID int32 `db:"instance_id" json:"instance_id"` Text pgtype.Text `db:"text" json:"text"` Vector pgvector_go.HalfVector `db:"vector" json:"vector"` VectorDim int32 `db:"vector_dim" json:"vector_dim"` @@ -32,22 +54,32 @@ type Embedding struct { UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` } -type KeyMethod struct { - KeyMethod string `db:"key_method" json:"key_method"` +type Instance struct { + InstanceID int32 `db:"instance_id" json:"instance_id"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` + Owner string `db:"owner" json:"owner"` + Endpoint string `db:"endpoint" json:"endpoint"` + Description pgtype.Text `db:"description" json:"description"` + APIStandard string `db:"api_standard" json:"api_standard"` + Model string `db:"model" json:"model"` + Dimensions int32 `db:"dimensions" json:"dimensions"` + CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` + ContextLimit int32 `db:"context_limit" json:"context_limit"` + DefinitionID pgtype.Int4 `db:"definition_id" json:"definition_id"` + APIKeyEncrypted []byte `db:"api_key_encrypted" json:"api_key_encrypted"` } -type LlmService struct { - LLMServiceID int32 `db:"llm_service_id" json:"llm_service_id"` - LLMServiceHandle string `db:"llm_service_handle" json:"llm_service_handle"` - Owner string `db:"owner" json:"owner"` - Endpoint string `db:"endpoint" json:"endpoint"` - Description pgtype.Text `db:"description" json:"description"` - APIKey pgtype.Text `db:"api_key" json:"api_key"` - APIStandard string `db:"api_standard" json:"api_standard"` - Model string `db:"model" json:"model"` - Dimensions int32 `db:"dimensions" json:"dimensions"` - CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` - UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` +type InstancesSharedWith struct { + UserHandle string `db:"user_handle" json:"user_handle"` + InstanceID int32 `db:"instance_id" json:"instance_id"` + Role string `db:"role" json:"role"` + CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` +} + +type KeyMethod struct { + KeyMethod string `db:"key_method" json:"key_method"` } type Project struct { @@ -59,32 +91,18 @@ type Project struct { CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` PublicRead pgtype.Bool `db:"public_read" json:"public_read"` -} - -type ProjectsLlmService struct { - ProjectID int32 `db:"project_id" json:"project_id"` - LLMServiceID int32 `db:"llm_service_id" json:"llm_service_id"` - CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` - UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` + InstanceID pgtype.Int4 `db:"instance_id" json:"instance_id"` } type User struct { UserHandle string `db:"user_handle" json:"user_handle"` Name pgtype.Text `db:"name" json:"name"` Email string `db:"email" json:"email"` - VdbAPIKey string `db:"vdb_api_key" json:"vdb_api_key"` + VDBKey string `db:"vdb_key" json:"vdb_key"` CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` } -type UsersLlmService struct { - UserHandle string `db:"user_handle" json:"user_handle"` - LLMServiceID int32 `db:"llm_service_id" json:"llm_service_id"` - Role string `db:"role" json:"role"` - CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` - UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` -} - type UsersProject struct { UserHandle string `db:"user_handle" json:"user_handle"` ProjectID int32 `db:"project_id" json:"project_id"` diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 8f6d232..be2c97a 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -12,6 +12,80 @@ import ( pgvector_go "github.com/pgvector/pgvector-go" ) +const checkIfAPIStandardInUse = `-- name: CheckIfAPIStandardInUse :one +SELECT EXISTS ( + SELECT 1 + FROM definitions + WHERE "api_standard" = $1 + LIMIT 1 +) +` + +func (q *Queries) CheckIfAPIStandardInUse(ctx context.Context, apiStandard string) (bool, error) { + row := q.db.QueryRow(ctx, checkIfAPIStandardInUse, apiStandard) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const countAllEmbeddings = `-- name: CountAllEmbeddings :one +SELECT COUNT(*) +FROM embeddings +` + +func (q *Queries) CountAllEmbeddings(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, countAllEmbeddings) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countAllProjects = `-- name: CountAllProjects :one +SELECT COUNT(*) +FROM projects +` + +func (q *Queries) CountAllProjects(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, countAllProjects) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countEmbeddingsByProject = `-- name: CountEmbeddingsByProject :one +SELECT COUNT(*) +FROM embeddings +JOIN projects +ON embeddings."project_id" = projects."project_id" +WHERE embeddings."owner" = $1 +AND projects."project_handle" = $2 +` + +type CountEmbeddingsByProjectParams struct { + Owner string `db:"owner" json:"owner"` + ProjectHandle string `db:"project_handle" json:"project_handle"` +} + +func (q *Queries) CountEmbeddingsByProject(ctx context.Context, arg CountEmbeddingsByProjectParams) (int64, error) { + row := q.db.QueryRow(ctx, countEmbeddingsByProject, arg.Owner, arg.ProjectHandle) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countInstancesByUser = `-- name: CountInstancesByUser :one +SELECT COUNT(*) +FROM instances +WHERE "owner" = $1 +` + +func (q *Queries) CountInstancesByUser(ctx context.Context, owner string) (int64, error) { + row := q.db.QueryRow(ctx, countInstancesByUser, owner) + var count int64 + err := row.Scan(&count) + return count, err +} + const deleteAPIStandard = `-- name: DeleteAPIStandard :exec DELETE FROM api_standards @@ -32,9 +106,16 @@ BEGIN SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' - AND table_name NOT IN ('key_methods', 'vdb_roles') + AND table_name NOT IN ('key_methods', 'vdb_roles', 'api_standards') -- preserve static reference data LOOP - EXECUTE format('DELETE FROM %I;', r.table_name); + -- Preserve _system user and its definitions + IF r.table_name = 'users' THEN + EXECUTE format('DELETE FROM %I WHERE "user_handle" != ''_system'';', r.table_name); + ELSIF r.table_name = 'definitions' THEN + EXECUTE format('DELETE FROM %I WHERE "owner" != ''_system'';', r.table_name); + ELSE + EXECUTE format('DELETE FROM %I;', r.table_name); + END IF; END LOOP; END $$ ` @@ -44,7 +125,24 @@ func (q *Queries) DeleteAllRecords(ctx context.Context) error { return err } -const deleteDocEmbeddings = `-- name: DeleteDocEmbeddings :exec +const deleteDefinition = `-- name: DeleteDefinition :exec +DELETE +FROM definitions +WHERE "owner" = $1 +AND "definition_handle" = $2 +` + +type DeleteDefinitionParams struct { + Owner string `db:"owner" json:"owner"` + DefinitionHandle string `db:"definition_handle" json:"definition_handle"` +} + +func (q *Queries) DeleteDefinition(ctx context.Context, arg DeleteDefinitionParams) error { + _, err := q.db.Exec(ctx, deleteDefinition, arg.Owner, arg.DefinitionHandle) + return err +} + +const deleteEmbeddingsByDocID = `-- name: DeleteEmbeddingsByDocID :exec DELETE FROM embeddings e USING projects p WHERE e."owner" = $1 @@ -53,14 +151,14 @@ WHERE e."owner" = $1 AND e."text_id" = $3 ` -type DeleteDocEmbeddingsParams struct { +type DeleteEmbeddingsByDocIDParams struct { Owner string `db:"owner" json:"owner"` ProjectHandle string `db:"project_handle" json:"project_handle"` TextID pgtype.Text `db:"text_id" json:"text_id"` } -func (q *Queries) DeleteDocEmbeddings(ctx context.Context, arg DeleteDocEmbeddingsParams) error { - _, err := q.db.Exec(ctx, deleteDocEmbeddings, arg.Owner, arg.ProjectHandle, arg.TextID) +func (q *Queries) DeleteEmbeddingsByDocID(ctx context.Context, arg DeleteEmbeddingsByDocIDParams) error { + _, err := q.db.Exec(ctx, deleteEmbeddingsByDocID, arg.Owner, arg.ProjectHandle, arg.TextID) return err } @@ -96,20 +194,20 @@ func (q *Queries) DeleteEmbeddingsByProject(ctx context.Context, arg DeleteEmbed return err } -const deleteLLM = `-- name: DeleteLLM :exec +const deleteInstance = `-- name: DeleteInstance :exec DELETE -FROM llm_services +FROM instances WHERE "owner" = $1 -AND "llm_service_handle" = $2 +AND "instance_handle" = $2 ` -type DeleteLLMParams struct { - Owner string `db:"owner" json:"owner"` - LLMServiceHandle string `db:"llm_service_handle" json:"llm_service_handle"` +type DeleteInstanceParams struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` } -func (q *Queries) DeleteLLM(ctx context.Context, arg DeleteLLMParams) error { - _, err := q.db.Exec(ctx, deleteLLM, arg.Owner, arg.LLMServiceHandle) +func (q *Queries) DeleteInstance(ctx context.Context, arg DeleteInstanceParams) error { + _, err := q.db.Exec(ctx, deleteInstance, arg.Owner, arg.InstanceHandle) return err } @@ -142,7 +240,7 @@ func (q *Queries) DeleteUser(ctx context.Context, userHandle string) error { } const getAPIStandards = `-- name: GetAPIStandards :many -SELECT api_standard_handle, description, key_method, key_field, created_at, updated_at +SELECT api_standards."api_standard_handle" FROM api_standards ORDER BY "api_standard_handle" ASC LIMIT $1 OFFSET $2 ` @@ -152,26 +250,19 @@ type GetAPIStandardsParams struct { Offset int32 `db:"offset" json:"offset"` } -func (q *Queries) GetAPIStandards(ctx context.Context, arg GetAPIStandardsParams) ([]APIStandard, error) { +func (q *Queries) GetAPIStandards(ctx context.Context, arg GetAPIStandardsParams) ([]string, error) { rows, err := q.db.Query(ctx, getAPIStandards, arg.Limit, arg.Offset) if err != nil { return nil, err } defer rows.Close() - var items []APIStandard + var items []string for rows.Next() { - var i APIStandard - if err := rows.Scan( - &i.APIStandardHandle, - &i.Description, - &i.KeyMethod, - &i.KeyField, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { + var api_standard_handle string + if err := rows.Scan(&api_standard_handle); err != nil { return nil, err } - items = append(items, i) + items = append(items, api_standard_handle) } if err := rows.Err(); err != nil { return nil, err @@ -179,30 +270,50 @@ func (q *Queries) GetAPIStandards(ctx context.Context, arg GetAPIStandardsParams return items, nil } -const getAllProjects = `-- name: GetAllProjects :many -SELECT project_id, project_handle, owner, description, metadata_scheme, created_at, updated_at, public_read -FROM projects -ORDER BY "owner" ASC, "project_handle" ASC +const getAccessibleDefinitionsByUser = `-- name: GetAccessibleDefinitionsByUser :many +SELECT definitions."owner", + definitions."definition_handle", + definitions."definition_id", + definitions."is_public" +FROM definitions +LEFT JOIN definitions_shared_with + ON definitions."definition_id" = definitions_shared_with."definition_id" +WHERE definitions."owner" = $1 + OR definitions."owner" = '_system' + OR definitions_shared_with."user_handle" = $1 +ORDER BY definitions."owner" ASC, definitions."definition_handle" ASC +LIMIT $2 OFFSET $3 ` -func (q *Queries) GetAllProjects(ctx context.Context) ([]Project, error) { - rows, err := q.db.Query(ctx, getAllProjects) +type GetAccessibleDefinitionsByUserParams struct { + Owner string `db:"owner" json:"owner"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +type GetAccessibleDefinitionsByUserRow struct { + Owner string `db:"owner" json:"owner"` + DefinitionHandle string `db:"definition_handle" json:"definition_handle"` + DefinitionID int32 `db:"definition_id" json:"definition_id"` + IsPublic bool `db:"is_public" json:"is_public"` +} + +// Get all definitions accessible to a user (owned + shared + _system) +// Returns definitions with metadata indicating ownership +func (q *Queries) GetAccessibleDefinitionsByUser(ctx context.Context, arg GetAccessibleDefinitionsByUserParams) ([]GetAccessibleDefinitionsByUserRow, error) { + rows, err := q.db.Query(ctx, getAccessibleDefinitionsByUser, arg.Owner, arg.Limit, arg.Offset) if err != nil { return nil, err } defer rows.Close() - var items []Project + var items []GetAccessibleDefinitionsByUserRow for rows.Next() { - var i Project + var i GetAccessibleDefinitionsByUserRow if err := rows.Scan( - &i.ProjectID, - &i.ProjectHandle, &i.Owner, - &i.Description, - &i.MetadataScheme, - &i.CreatedAt, - &i.UpdatedAt, - &i.PublicRead, + &i.DefinitionHandle, + &i.DefinitionID, + &i.IsPublic, ); err != nil { return nil, err } @@ -214,69 +325,55 @@ func (q *Queries) GetAllProjects(ctx context.Context) ([]Project, error) { return items, nil } -const getEmbeddingsByProject = `-- name: GetEmbeddingsByProject :many -SELECT embeddings.embeddings_id, embeddings.text_id, embeddings.owner, embeddings.project_id, embeddings.llm_service_id, embeddings.text, embeddings.vector, embeddings.vector_dim, embeddings.metadata, embeddings.created_at, embeddings.updated_at, projects."project_handle", llm_services."llm_service_handle" -FROM embeddings -JOIN llm_services -ON llm_services."llm_service_id" = embeddings."llm_service_id" -JOIN projects -ON projects."project_id" = embeddings."project_id" -WHERE embeddings."owner" = $1 -AND projects."project_handle" = $2 -ORDER BY embeddings."text_id" ASC LIMIT $3 OFFSET $4 +const getAccessibleInstancesByUser = `-- name: GetAccessibleInstancesByUser :many +SELECT instances."owner", + instances."instance_handle", + instances."instance_id", + CASE + WHEN instances."owner" = $1 THEN 'owner' + ELSE instances_shared_with."role" + END as "role", + instances."owner" = $1 as "is_owner" +FROM instances +LEFT JOIN instances_shared_with + ON instances."instance_id" = instances_shared_with."instance_id" +WHERE instances."owner" = $1 + OR instances_shared_with."user_handle" = $1 +ORDER BY instances."owner" ASC, instances."instance_handle" ASC +LIMIT $2 OFFSET $3 ` -type GetEmbeddingsByProjectParams struct { - Owner string `db:"owner" json:"owner"` - ProjectHandle string `db:"project_handle" json:"project_handle"` - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` +type GetAccessibleInstancesByUserParams struct { + Owner string `db:"owner" json:"owner"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` } -type GetEmbeddingsByProjectRow struct { - EmbeddingsID int32 `db:"embeddings_id" json:"embeddings_id"` - TextID pgtype.Text `db:"text_id" json:"text_id"` - Owner string `db:"owner" json:"owner"` - ProjectID int32 `db:"project_id" json:"project_id"` - LLMServiceID int32 `db:"llm_service_id" json:"llm_service_id"` - Text pgtype.Text `db:"text" json:"text"` - Vector pgvector_go.HalfVector `db:"vector" json:"vector"` - VectorDim int32 `db:"vector_dim" json:"vector_dim"` - Metadata []byte `db:"metadata" json:"metadata"` - CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` - UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` - ProjectHandle string `db:"project_handle" json:"project_handle"` - LLMServiceHandle string `db:"llm_service_handle" json:"llm_service_handle"` +type GetAccessibleInstancesByUserRow struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` + InstanceID int32 `db:"instance_id" json:"instance_id"` + Role interface{} `db:"role" json:"role"` + IsOwner bool `db:"is_owner" json:"is_owner"` } -func (q *Queries) GetEmbeddingsByProject(ctx context.Context, arg GetEmbeddingsByProjectParams) ([]GetEmbeddingsByProjectRow, error) { - rows, err := q.db.Query(ctx, getEmbeddingsByProject, - arg.Owner, - arg.ProjectHandle, - arg.Limit, - arg.Offset, - ) +// Get all instances accessible to a user (owned + shared) +// Returns instances with metadata indicating ownership +func (q *Queries) GetAccessibleInstancesByUser(ctx context.Context, arg GetAccessibleInstancesByUserParams) ([]GetAccessibleInstancesByUserRow, error) { + rows, err := q.db.Query(ctx, getAccessibleInstancesByUser, arg.Owner, arg.Limit, arg.Offset) if err != nil { return nil, err } defer rows.Close() - var items []GetEmbeddingsByProjectRow + var items []GetAccessibleInstancesByUserRow for rows.Next() { - var i GetEmbeddingsByProjectRow + var i GetAccessibleInstancesByUserRow if err := rows.Scan( - &i.EmbeddingsID, - &i.TextID, &i.Owner, - &i.ProjectID, - &i.LLMServiceID, - &i.Text, - &i.Vector, - &i.VectorDim, - &i.Metadata, - &i.CreatedAt, - &i.UpdatedAt, - &i.ProjectHandle, - &i.LLMServiceHandle, + &i.InstanceHandle, + &i.InstanceID, + &i.Role, + &i.IsOwner, ); err != nil { return nil, err } @@ -288,61 +385,54 @@ func (q *Queries) GetEmbeddingsByProject(ctx context.Context, arg GetEmbeddingsB return items, nil } -const getKeyByUser = `-- name: GetKeyByUser :one -SELECT "vdb_api_key" -FROM users -WHERE "user_handle" = $1 LIMIT 1 -` - -// SELECT encode("vdb_api_key", 'hex') AS "vdb_api_key" FROM users -func (q *Queries) GetKeyByUser(ctx context.Context, userHandle string) (string, error) { - row := q.db.QueryRow(ctx, getKeyByUser, userHandle) - var vdb_api_key string - err := row.Scan(&vdb_api_key) - return vdb_api_key, err -} - -const getKeysByLinkedUsers = `-- name: GetKeysByLinkedUsers :many -SELECT users."user_handle", users_projects."role", users."vdb_api_key" -FROM users -JOIN users_projects -ON users."user_handle" = users_projects."user_handle" -JOIN projects -ON users_projects."project_id" = projects."project_id" -WHERE projects."owner" = $1 -AND projects."project_handle" = $2 -ORDER BY users."user_handle" ASC LIMIT $3 OFFSET $4 +const getAccessibleProjectsByUser = `-- name: GetAccessibleProjectsByUser :many +SELECT projects."owner", + projects."project_handle", + projects."project_id", + projects."public_read", + CASE + WHEN projects."owner" = $1 THEN 'owner' + ELSE users_projects."role" + END AS "role" +FROM projects +LEFT JOIN users_projects +ON projects."project_id" = users_projects."project_id" +WHERE users_projects."user_handle" = $1 +OR projects."owner" = $1 +ORDER BY projects."owner" ASC +LIMIT $2 OFFSET $3 ` -type GetKeysByLinkedUsersParams struct { - Owner string `db:"owner" json:"owner"` - ProjectHandle string `db:"project_handle" json:"project_handle"` - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` +type GetAccessibleProjectsByUserParams struct { + Owner string `db:"owner" json:"owner"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` } -type GetKeysByLinkedUsersRow struct { - UserHandle string `db:"user_handle" json:"user_handle"` - Role string `db:"role" json:"role"` - VdbAPIKey string `db:"vdb_api_key" json:"vdb_api_key"` +type GetAccessibleProjectsByUserRow struct { + Owner string `db:"owner" json:"owner"` + ProjectHandle string `db:"project_handle" json:"project_handle"` + ProjectID int32 `db:"project_id" json:"project_id"` + PublicRead pgtype.Bool `db:"public_read" json:"public_read"` + Role interface{} `db:"role" json:"role"` } -// SELECT users."user_handle", users_projects."role", encode(users."vdb_api_key", 'hex') AS "vdb_api_key" -func (q *Queries) GetKeysByLinkedUsers(ctx context.Context, arg GetKeysByLinkedUsersParams) ([]GetKeysByLinkedUsersRow, error) { - rows, err := q.db.Query(ctx, getKeysByLinkedUsers, - arg.Owner, - arg.ProjectHandle, - arg.Limit, - arg.Offset, - ) +func (q *Queries) GetAccessibleProjectsByUser(ctx context.Context, arg GetAccessibleProjectsByUserParams) ([]GetAccessibleProjectsByUserRow, error) { + rows, err := q.db.Query(ctx, getAccessibleProjectsByUser, arg.Owner, arg.Limit, arg.Offset) if err != nil { return nil, err } defer rows.Close() - var items []GetKeysByLinkedUsersRow + var items []GetAccessibleProjectsByUserRow for rows.Next() { - var i GetKeysByLinkedUsersRow - if err := rows.Scan(&i.UserHandle, &i.Role, &i.VdbAPIKey); err != nil { + var i GetAccessibleProjectsByUserRow + if err := rows.Scan( + &i.Owner, + &i.ProjectHandle, + &i.ProjectID, + &i.PublicRead, + &i.Role, + ); err != nil { return nil, err } items = append(items, i) @@ -353,53 +443,33 @@ func (q *Queries) GetKeysByLinkedUsers(ctx context.Context, arg GetKeysByLinkedU return items, nil } -const getLLMsByProject = `-- name: GetLLMsByProject :many -SELECT llm_services.llm_service_id, llm_services.llm_service_handle, llm_services.owner, llm_services.endpoint, llm_services.description, llm_services.api_key, llm_services.api_standard, llm_services.model, llm_services.dimensions, llm_services.created_at, llm_services.updated_at -FROM llm_services -JOIN ( - projects_llm_services JOIN projects - ON projects_llm_services."project_id" = projects."project_id" -) -ON llm_services."llm_service_id" = projects_llm_services."llm_service_id" -WHERE projects."owner" = $1 - AND projects."project_handle" = $2 -ORDER BY llm_services."llm_service_handle" ASC LIMIT $3 OFFSET $4 +const getAllDefinitions = `-- name: GetAllDefinitions :many +SELECT definitions."owner", definitions."definition_handle", definitions."definition_id" +FROM definitions +ORDER BY "owner" ASC, "definition_handle" ASC LIMIT $1 OFFSET $2 ` -type GetLLMsByProjectParams struct { - Owner string `db:"owner" json:"owner"` - ProjectHandle string `db:"project_handle" json:"project_handle"` - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` +type GetAllDefinitionsParams struct { + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` } -func (q *Queries) GetLLMsByProject(ctx context.Context, arg GetLLMsByProjectParams) ([]LlmService, error) { - rows, err := q.db.Query(ctx, getLLMsByProject, - arg.Owner, - arg.ProjectHandle, - arg.Limit, - arg.Offset, - ) +type GetAllDefinitionsRow struct { + Owner string `db:"owner" json:"owner"` + DefinitionHandle string `db:"definition_handle" json:"definition_handle"` + DefinitionID int32 `db:"definition_id" json:"definition_id"` +} + +func (q *Queries) GetAllDefinitions(ctx context.Context, arg GetAllDefinitionsParams) ([]GetAllDefinitionsRow, error) { + rows, err := q.db.Query(ctx, getAllDefinitions, arg.Limit, arg.Offset) if err != nil { return nil, err } defer rows.Close() - var items []LlmService + var items []GetAllDefinitionsRow for rows.Next() { - var i LlmService - if err := rows.Scan( - &i.LLMServiceID, - &i.LLMServiceHandle, - &i.Owner, - &i.Endpoint, - &i.Description, - &i.APIKey, - &i.APIStandard, - &i.Model, - &i.Dimensions, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { + var i GetAllDefinitionsRow + if err := rows.Scan(&i.Owner, &i.DefinitionHandle, &i.DefinitionID); err != nil { return nil, err } items = append(items, i) @@ -410,59 +480,27 @@ func (q *Queries) GetLLMsByProject(ctx context.Context, arg GetLLMsByProjectPara return items, nil } -const getLLMsByUser = `-- name: GetLLMsByUser :many -SELECT llm_services.llm_service_id, llm_services.llm_service_handle, llm_services.owner, llm_services.endpoint, llm_services.description, llm_services.api_key, llm_services.api_standard, llm_services.model, llm_services.dimensions, llm_services.created_at, llm_services.updated_at, users_llm_services."role" -FROM llm_services -JOIN users_llm_services -ON llm_services."llm_service_id" = users_llm_services."llm_service_id" -WHERE users_llm_services."user_handle" = $1 -ORDER BY llm_services."llm_service_handle" ASC LIMIT $2 OFFSET $3 +const getAllProjects = `-- name: GetAllProjects :many +SELECT projects."owner", projects."project_handle" +FROM projects +ORDER BY "owner" ASC, "project_handle" ASC ` -type GetLLMsByUserParams struct { - UserHandle string `db:"user_handle" json:"user_handle"` - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` -} - -type GetLLMsByUserRow struct { - LLMServiceID int32 `db:"llm_service_id" json:"llm_service_id"` - LLMServiceHandle string `db:"llm_service_handle" json:"llm_service_handle"` - Owner string `db:"owner" json:"owner"` - Endpoint string `db:"endpoint" json:"endpoint"` - Description pgtype.Text `db:"description" json:"description"` - APIKey pgtype.Text `db:"api_key" json:"api_key"` - APIStandard string `db:"api_standard" json:"api_standard"` - Model string `db:"model" json:"model"` - Dimensions int32 `db:"dimensions" json:"dimensions"` - CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` - UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` - Role string `db:"role" json:"role"` +type GetAllProjectsRow struct { + Owner string `db:"owner" json:"owner"` + ProjectHandle string `db:"project_handle" json:"project_handle"` } -func (q *Queries) GetLLMsByUser(ctx context.Context, arg GetLLMsByUserParams) ([]GetLLMsByUserRow, error) { - rows, err := q.db.Query(ctx, getLLMsByUser, arg.UserHandle, arg.Limit, arg.Offset) +func (q *Queries) GetAllProjects(ctx context.Context) ([]GetAllProjectsRow, error) { + rows, err := q.db.Query(ctx, getAllProjects) if err != nil { return nil, err } defer rows.Close() - var items []GetLLMsByUserRow + var items []GetAllProjectsRow for rows.Next() { - var i GetLLMsByUserRow - if err := rows.Scan( - &i.LLMServiceID, - &i.LLMServiceHandle, - &i.Owner, - &i.Endpoint, - &i.Description, - &i.APIKey, - &i.APIStandard, - &i.Model, - &i.Dimensions, - &i.CreatedAt, - &i.UpdatedAt, - &i.Role, - ); err != nil { + var i GetAllProjectsRow + if err := rows.Scan(&i.Owner, &i.ProjectHandle); err != nil { return nil, err } items = append(items, i) @@ -473,74 +511,65 @@ func (q *Queries) GetLLMsByUser(ctx context.Context, arg GetLLMsByUserParams) ([ return items, nil } -const getNumberOfEmbeddingsByProject = `-- name: GetNumberOfEmbeddingsByProject :one -SELECT COUNT(*) -FROM embeddings -JOIN projects -ON embeddings."project_id" = projects."project_id" -WHERE embeddings."owner" = $1 -AND projects."project_handle" = $2 +const getAllUsers = `-- name: GetAllUsers :many +SELECT "user_handle" +FROM users +ORDER BY "user_handle" ASC LIMIT $1 OFFSET $2 ` -type GetNumberOfEmbeddingsByProjectParams struct { - Owner string `db:"owner" json:"owner"` - ProjectHandle string `db:"project_handle" json:"project_handle"` +type GetAllUsersParams struct { + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` } -func (q *Queries) GetNumberOfEmbeddingsByProject(ctx context.Context, arg GetNumberOfEmbeddingsByProjectParams) (int64, error) { - row := q.db.QueryRow(ctx, getNumberOfEmbeddingsByProject, arg.Owner, arg.ProjectHandle) - var count int64 - err := row.Scan(&count) - return count, err +func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]string, error) { + rows, err := q.db.Query(ctx, getAllUsers, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var user_handle string + if err := rows.Scan(&user_handle); err != nil { + return nil, err + } + items = append(items, user_handle) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil } -const getProjectsByUser = `-- name: GetProjectsByUser :many -SELECT projects.project_id, projects.project_handle, projects.owner, projects.description, projects.metadata_scheme, projects.created_at, projects.updated_at, projects.public_read, users_projects."role" -FROM projects -JOIN users_projects -ON projects."project_id" = users_projects."project_id" -WHERE users_projects."user_handle" = $1 -ORDER BY projects."project_handle" ASC LIMIT $2 OFFSET $3 +const getDefinitionsByUser = `-- name: GetDefinitionsByUser :many +SELECT definitions."definition_handle", definitions."definition_id" +FROM definitions +WHERE "owner" = $1 +ORDER BY "definition_handle" ASC LIMIT $2 OFFSET $3 ` -type GetProjectsByUserParams struct { - UserHandle string `db:"user_handle" json:"user_handle"` - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` +type GetDefinitionsByUserParams struct { + Owner string `db:"owner" json:"owner"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` } -type GetProjectsByUserRow struct { - ProjectID int32 `db:"project_id" json:"project_id"` - ProjectHandle string `db:"project_handle" json:"project_handle"` - Owner string `db:"owner" json:"owner"` - Description pgtype.Text `db:"description" json:"description"` - MetadataScheme pgtype.Text `db:"metadata_scheme" json:"metadata_scheme"` - CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` - UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` - PublicRead pgtype.Bool `db:"public_read" json:"public_read"` - Role string `db:"role" json:"role"` +type GetDefinitionsByUserRow struct { + DefinitionHandle string `db:"definition_handle" json:"definition_handle"` + DefinitionID int32 `db:"definition_id" json:"definition_id"` } -func (q *Queries) GetProjectsByUser(ctx context.Context, arg GetProjectsByUserParams) ([]GetProjectsByUserRow, error) { - rows, err := q.db.Query(ctx, getProjectsByUser, arg.UserHandle, arg.Limit, arg.Offset) +func (q *Queries) GetDefinitionsByUser(ctx context.Context, arg GetDefinitionsByUserParams) ([]GetDefinitionsByUserRow, error) { + rows, err := q.db.Query(ctx, getDefinitionsByUser, arg.Owner, arg.Limit, arg.Offset) if err != nil { return nil, err } defer rows.Close() - var items []GetProjectsByUserRow + var items []GetDefinitionsByUserRow for rows.Next() { - var i GetProjectsByUserRow - if err := rows.Scan( - &i.ProjectID, - &i.ProjectHandle, - &i.Owner, - &i.Description, - &i.MetadataScheme, - &i.CreatedAt, - &i.UpdatedAt, - &i.PublicRead, - &i.Role, - ); err != nil { + var i GetDefinitionsByUserRow + if err := rows.Scan(&i.DefinitionHandle, &i.DefinitionID); err != nil { return nil, err } items = append(items, i) @@ -551,8 +580,451 @@ func (q *Queries) GetProjectsByUser(ctx context.Context, arg GetProjectsByUserPa return items, nil } -const getSimilarsByID = `-- name: GetSimilarsByID :many -SELECT e2."text_id" +const getEmbeddingsByProject = `-- name: GetEmbeddingsByProject :many +SELECT embeddings."embeddings_id", embeddings."text_id", projects."owner", projects."project_handle", instances."instance_handle" +FROM embeddings +JOIN instances +ON instances."instance_id" = embeddings."instance_id" +JOIN projects +ON projects."project_id" = embeddings."project_id" +WHERE embeddings."owner" = $1 +AND projects."project_handle" = $2 +ORDER BY embeddings."text_id" ASC LIMIT $3 OFFSET $4 +` + +type GetEmbeddingsByProjectParams struct { + Owner string `db:"owner" json:"owner"` + ProjectHandle string `db:"project_handle" json:"project_handle"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +type GetEmbeddingsByProjectRow struct { + EmbeddingsID int32 `db:"embeddings_id" json:"embeddings_id"` + TextID pgtype.Text `db:"text_id" json:"text_id"` + Owner string `db:"owner" json:"owner"` + ProjectHandle string `db:"project_handle" json:"project_handle"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` +} + +func (q *Queries) GetEmbeddingsByProject(ctx context.Context, arg GetEmbeddingsByProjectParams) ([]GetEmbeddingsByProjectRow, error) { + rows, err := q.db.Query(ctx, getEmbeddingsByProject, + arg.Owner, + arg.ProjectHandle, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetEmbeddingsByProjectRow + for rows.Next() { + var i GetEmbeddingsByProjectRow + if err := rows.Scan( + &i.EmbeddingsID, + &i.TextID, + &i.Owner, + &i.ProjectHandle, + &i.InstanceHandle, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getInstancesByUser = `-- name: GetInstancesByUser :many +SELECT instances."owner", + instances."instance_handle", + instances."instance_id" +FROM instances +WHERE instances."owner" = $1 +ORDER BY instances."instance_handle" ASC LIMIT $2 OFFSET $3 +` + +type GetInstancesByUserParams struct { + Owner string `db:"owner" json:"owner"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +type GetInstancesByUserRow struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` + InstanceID int32 `db:"instance_id" json:"instance_id"` +} + +// Get all instances owned by a user +func (q *Queries) GetInstancesByUser(ctx context.Context, arg GetInstancesByUserParams) ([]GetInstancesByUserRow, error) { + rows, err := q.db.Query(ctx, getInstancesByUser, arg.Owner, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetInstancesByUserRow + for rows.Next() { + var i GetInstancesByUserRow + if err := rows.Scan(&i.Owner, &i.InstanceHandle, &i.InstanceID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getKeyByUser = `-- name: GetKeyByUser :one +SELECT "vdb_key" +FROM users +WHERE "user_handle" = $1 LIMIT 1 +` + +func (q *Queries) GetKeyByUser(ctx context.Context, userHandle string) (string, error) { + row := q.db.QueryRow(ctx, getKeyByUser, userHandle) + var vdb_key string + err := row.Scan(&vdb_key) + return vdb_key, err +} + +const getKeysByDefinition = `-- name: GetKeysByDefinition :many +SELECT users."user_handle", users."vdb_key" +FROM users +JOIN definitions_shared_with +ON users."user_handle" = definitions_shared_with."user_handle" +JOIN definitions +ON definitions_shared_with."definition_id" = definitions."definition_id" +WHERE definitions."owner" = $1 +AND definitions."definition_handle" = $2 +ORDER BY users."user_handle" ASC LIMIT $3 OFFSET $4 +` + +type GetKeysByDefinitionParams struct { + Owner string `db:"owner" json:"owner"` + DefinitionHandle string `db:"definition_handle" json:"definition_handle"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +type GetKeysByDefinitionRow struct { + UserHandle string `db:"user_handle" json:"user_handle"` + VDBKey string `db:"vdb_key" json:"vdb_key"` +} + +func (q *Queries) GetKeysByDefinition(ctx context.Context, arg GetKeysByDefinitionParams) ([]GetKeysByDefinitionRow, error) { + rows, err := q.db.Query(ctx, getKeysByDefinition, + arg.Owner, + arg.DefinitionHandle, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetKeysByDefinitionRow + for rows.Next() { + var i GetKeysByDefinitionRow + if err := rows.Scan(&i.UserHandle, &i.VDBKey); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getKeysByInstance = `-- name: GetKeysByInstance :many +SELECT users."user_handle", instances_shared_with."role", users."vdb_key" +FROM users +JOIN instances_shared_with +ON users."user_handle" = instances_shared_with."user_handle" +JOIN instances +ON instances_shared_with."instance_id" = instances."instance_id" +WHERE instances."owner" = $1 +AND instances."instance_handle" = $2 +ORDER BY users."user_handle" ASC LIMIT $3 OFFSET $4 +` + +type GetKeysByInstanceParams struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +type GetKeysByInstanceRow struct { + UserHandle string `db:"user_handle" json:"user_handle"` + Role string `db:"role" json:"role"` + VDBKey string `db:"vdb_key" json:"vdb_key"` +} + +func (q *Queries) GetKeysByInstance(ctx context.Context, arg GetKeysByInstanceParams) ([]GetKeysByInstanceRow, error) { + rows, err := q.db.Query(ctx, getKeysByInstance, + arg.Owner, + arg.InstanceHandle, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetKeysByInstanceRow + for rows.Next() { + var i GetKeysByInstanceRow + if err := rows.Scan(&i.UserHandle, &i.Role, &i.VDBKey); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getKeysByProject = `-- name: GetKeysByProject :many +SELECT users."user_handle", users_projects."role", users."vdb_key" +FROM users +JOIN users_projects +ON users."user_handle" = users_projects."user_handle" +JOIN projects +ON users_projects."project_id" = projects."project_id" +WHERE projects."owner" = $1 +AND projects."project_handle" = $2 +ORDER BY users."user_handle" ASC LIMIT $3 OFFSET $4 +` + +type GetKeysByProjectParams struct { + Owner string `db:"owner" json:"owner"` + ProjectHandle string `db:"project_handle" json:"project_handle"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +type GetKeysByProjectRow struct { + UserHandle string `db:"user_handle" json:"user_handle"` + Role string `db:"role" json:"role"` + VDBKey string `db:"vdb_key" json:"vdb_key"` +} + +func (q *Queries) GetKeysByProject(ctx context.Context, arg GetKeysByProjectParams) ([]GetKeysByProjectRow, error) { + rows, err := q.db.Query(ctx, getKeysByProject, + arg.Owner, + arg.ProjectHandle, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetKeysByProjectRow + for rows.Next() { + var i GetKeysByProjectRow + if err := rows.Scan(&i.UserHandle, &i.Role, &i.VDBKey); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getProjectsByUser = `-- name: GetProjectsByUser :many +SELECT projects."owner", + projects."project_handle", + projects."project_id", + projects."public_read", + users_projects."role" +FROM projects +JOIN users_projects +ON projects."project_id" = users_projects."project_id" +WHERE users_projects."user_handle" = $1 +ORDER BY projects."project_handle" ASC LIMIT $2 OFFSET $3 +` + +type GetProjectsByUserParams struct { + UserHandle string `db:"user_handle" json:"user_handle"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +type GetProjectsByUserRow struct { + Owner string `db:"owner" json:"owner"` + ProjectHandle string `db:"project_handle" json:"project_handle"` + ProjectID int32 `db:"project_id" json:"project_id"` + PublicRead pgtype.Bool `db:"public_read" json:"public_read"` + Role string `db:"role" json:"role"` +} + +func (q *Queries) GetProjectsByUser(ctx context.Context, arg GetProjectsByUserParams) ([]GetProjectsByUserRow, error) { + rows, err := q.db.Query(ctx, getProjectsByUser, arg.UserHandle, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetProjectsByUserRow + for rows.Next() { + var i GetProjectsByUserRow + if err := rows.Scan( + &i.Owner, + &i.ProjectHandle, + &i.ProjectID, + &i.PublicRead, + &i.Role, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getSharedInstancesByUser = `-- name: GetSharedInstancesByUser :many +SELECT instances."owner", + instances."instance_handle", + instances."instance_id", + instances_shared_with."role" +FROM instances +JOIN instances_shared_with +ON instances."instance_id" = instances_shared_with."instance_id" +WHERE instances_shared_with."user_handle" = $1 +ORDER BY instances_shared_with."role" ASC, instances."owner" ASC, instances."instance_handle" ASC +LIMIT $2 OFFSET $3 +` + +type GetSharedInstancesByUserParams struct { + UserHandle string `db:"user_handle" json:"user_handle"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` +} + +type GetSharedInstancesByUserRow struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` + InstanceID int32 `db:"instance_id" json:"instance_id"` + Role string `db:"role" json:"role"` +} + +func (q *Queries) GetSharedInstancesByUser(ctx context.Context, arg GetSharedInstancesByUserParams) ([]GetSharedInstancesByUserRow, error) { + rows, err := q.db.Query(ctx, getSharedInstancesByUser, arg.UserHandle, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetSharedInstancesByUserRow + for rows.Next() { + var i GetSharedInstancesByUserRow + if err := rows.Scan( + &i.Owner, + &i.InstanceHandle, + &i.InstanceID, + &i.Role, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getSharedUsersForDefinition = `-- name: GetSharedUsersForDefinition :many +SELECT definitions_shared_with."user_handle" +FROM definitions_shared_with +JOIN definitions +ON definitions."definition_id" = definitions_shared_with."definition_id" +WHERE definitions."owner" = $1 + AND definitions."definition_handle" = $2 + AND definitions_shared_with."user_handle" != '*' +ORDER BY "user_handle" ASC +` + +type GetSharedUsersForDefinitionParams struct { + Owner string `db:"owner" json:"owner"` + DefinitionHandle string `db:"definition_handle" json:"definition_handle"` +} + +func (q *Queries) GetSharedUsersForDefinition(ctx context.Context, arg GetSharedUsersForDefinitionParams) ([]string, error) { + rows, err := q.db.Query(ctx, getSharedUsersForDefinition, arg.Owner, arg.DefinitionHandle) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var user_handle string + if err := rows.Scan(&user_handle); err != nil { + return nil, err + } + items = append(items, user_handle) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getSharedUsersForInstance = `-- name: GetSharedUsersForInstance :many +SELECT instances_shared_with."user_handle", + instances_shared_with."role" +FROM instances_shared_with +JOIN instances +ON instances."instance_id" = instances_shared_with."instance_id" +WHERE instances."owner" = $1 + AND instances."instance_handle" = $2 +ORDER BY "user_handle" ASC +` + +type GetSharedUsersForInstanceParams struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` +} + +type GetSharedUsersForInstanceRow struct { + UserHandle string `db:"user_handle" json:"user_handle"` + Role string `db:"role" json:"role"` +} + +func (q *Queries) GetSharedUsersForInstance(ctx context.Context, arg GetSharedUsersForInstanceParams) ([]GetSharedUsersForInstanceRow, error) { + rows, err := q.db.Query(ctx, getSharedUsersForInstance, arg.Owner, arg.InstanceHandle) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetSharedUsersForInstanceRow + for rows.Next() { + var i GetSharedUsersForInstanceRow + if err := rows.Scan(&i.UserHandle, &i.Role); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getSimilarsByID = `-- name: GetSimilarsByID :many +SELECT e2."text_id" FROM embeddings e1 CROSS JOIN embeddings e2 JOIN projects @@ -663,10 +1135,12 @@ func (q *Queries) GetSimilarsByIDWithFilter(ctx context.Context, arg GetSimilars } const getSimilarsByVector = `-- name: GetSimilarsByVector :many -SELECT embeddings."embeddings_id", embeddings."text_id", llm_services."owner", llm_services."llm_service_handle" + + +SELECT embeddings."embeddings_id", embeddings."text_id", instances."owner", instances."instance_handle" FROM embeddings -JOIN llm_services -ON embeddings."llm_service_id" = llm_services."llm_service_id" +JOIN instances +ON embeddings."instance_id" = instances."instance_id" ORDER BY "vector" <=> $1 LIMIT $2 OFFSET $3 ` @@ -678,12 +1152,13 @@ type GetSimilarsByVectorParams struct { } type GetSimilarsByVectorRow struct { - EmbeddingsID int32 `db:"embeddings_id" json:"embeddings_id"` - TextID pgtype.Text `db:"text_id" json:"text_id"` - Owner string `db:"owner" json:"owner"` - LLMServiceHandle string `db:"llm_service_handle" json:"llm_service_handle"` + EmbeddingsID int32 `db:"embeddings_id" json:"embeddings_id"` + TextID pgtype.Text `db:"text_id" json:"text_id"` + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` } +// === SIMILARITY SEARCH === func (q *Queries) GetSimilarsByVector(ctx context.Context, arg GetSimilarsByVectorParams) ([]GetSimilarsByVectorRow, error) { rows, err := q.db.Query(ctx, getSimilarsByVector, arg.Vector, arg.Limit, arg.Offset) if err != nil { @@ -697,7 +1172,7 @@ func (q *Queries) GetSimilarsByVector(ctx context.Context, arg GetSimilarsByVect &i.EmbeddingsID, &i.TextID, &i.Owner, - &i.LLMServiceHandle, + &i.InstanceHandle, ); err != nil { return nil, err } @@ -709,30 +1184,36 @@ func (q *Queries) GetSimilarsByVector(ctx context.Context, arg GetSimilarsByVect return items, nil } -const getUsers = `-- name: GetUsers :many -SELECT "user_handle" -FROM users -ORDER BY "user_handle" ASC LIMIT $1 OFFSET $2 +const getSystemDefinitions = `-- name: GetSystemDefinitions :many +SELECT definitions."definition_handle", definitions."definition_id" +FROM definitions +WHERE "owner" = '_system' +ORDER BY "definition_handle" ASC LIMIT $1 OFFSET $2 ` -type GetUsersParams struct { +type GetSystemDefinitionsParams struct { Limit int32 `db:"limit" json:"limit"` Offset int32 `db:"offset" json:"offset"` } -func (q *Queries) GetUsers(ctx context.Context, arg GetUsersParams) ([]string, error) { - rows, err := q.db.Query(ctx, getUsers, arg.Limit, arg.Offset) +type GetSystemDefinitionsRow struct { + DefinitionHandle string `db:"definition_handle" json:"definition_handle"` + DefinitionID int32 `db:"definition_id" json:"definition_id"` +} + +func (q *Queries) GetSystemDefinitions(ctx context.Context, arg GetSystemDefinitionsParams) ([]GetSystemDefinitionsRow, error) { + rows, err := q.db.Query(ctx, getSystemDefinitions, arg.Limit, arg.Offset) if err != nil { return nil, err } defer rows.Close() - var items []string + var items []GetSystemDefinitionsRow for rows.Next() { - var user_handle string - if err := rows.Scan(&user_handle); err != nil { + var i GetSystemDefinitionsRow + if err := rows.Scan(&i.DefinitionHandle, &i.DefinitionID); err != nil { return nil, err } - items = append(items, user_handle) + items = append(items, i) } if err := rows.Err(); err != nil { return nil, err @@ -740,6 +1221,19 @@ func (q *Queries) GetUsers(ctx context.Context, arg GetUsersParams) ([]string, e return items, nil } +const getUserByVDBKey = `-- name: GetUserByVDBKey :one +SELECT "user_handle" +FROM users +WHERE "vdb_key" = $1 LIMIT 1 +` + +func (q *Queries) GetUserByVDBKey(ctx context.Context, vdbKey string) (string, error) { + row := q.db.QueryRow(ctx, getUserByVDBKey, vdbKey) + var user_handle string + err := row.Scan(&user_handle) + return user_handle, err +} + const getUsersByProject = `-- name: GetUsersByProject :many SELECT users."user_handle", users_projects."role" FROM users JOIN users_projects @@ -806,24 +1300,46 @@ func (q *Queries) IsProjectPubliclyReadable(ctx context.Context, arg IsProjectPu return public_read, err } -const linkProjectToLLM = `-- name: LinkProjectToLLM :exec +const linkDefinitionToUser = `-- name: LinkDefinitionToUser :exec INSERT -INTO projects_llm_services ( - "project_id", "llm_service_id", "created_at", "updated_at" +INTO definitions_shared_with ( + "user_handle", "definition_id", "created_at", "updated_at" ) VALUES ( $1, $2, NOW(), NOW() ) -ON CONFLICT ("project_id", "llm_service_id") DO NOTHING -RETURNING project_id, llm_service_id, created_at, updated_at +ON CONFLICT ("user_handle", "definition_id") DO NOTHING +` + +type LinkDefinitionToUserParams struct { + UserHandle string `db:"user_handle" json:"user_handle"` + DefinitionID int32 `db:"definition_id" json:"definition_id"` +} + +func (q *Queries) LinkDefinitionToUser(ctx context.Context, arg LinkDefinitionToUserParams) error { + _, err := q.db.Exec(ctx, linkDefinitionToUser, arg.UserHandle, arg.DefinitionID) + return err +} + +const linkInstanceToUser = `-- name: LinkInstanceToUser :exec +INSERT +INTO instances_shared_with ( + "user_handle", "instance_id", "role", "created_at", "updated_at" +) VALUES ( + $1, $2, $3, NOW(), NOW() +) +ON CONFLICT ("user_handle", "instance_id") DO UPDATE SET + "role" = EXCLUDED."role", + "updated_at" = NOW() ` -type LinkProjectToLLMParams struct { - ProjectID int32 `db:"project_id" json:"project_id"` - LLMServiceID int32 `db:"llm_service_id" json:"llm_service_id"` +type LinkInstanceToUserParams struct { + UserHandle string `db:"user_handle" json:"user_handle"` + InstanceID int32 `db:"instance_id" json:"instance_id"` + Role string `db:"role" json:"role"` } -func (q *Queries) LinkProjectToLLM(ctx context.Context, arg LinkProjectToLLMParams) error { - _, err := q.db.Exec(ctx, linkProjectToLLM, arg.ProjectID, arg.LLMServiceID) +func (q *Queries) LinkInstanceToUser(ctx context.Context, arg LinkInstanceToUserParams) error { + _, err := q.db.Exec(ctx, linkInstanceToUser, arg.UserHandle, arg.InstanceID, arg.Role) return err } @@ -835,9 +1351,9 @@ INTO users_projects ( $1, $2, $3, NOW(), NOW() ) ON CONFLICT ("user_handle", "project_id") DO UPDATE SET - "role" = $3, + "role" = EXCLUDED."role", "updated_at" = NOW() -RETURNING user_handle, project_id, role, created_at, updated_at +RETURNING users_projects."user_handle", users_projects."project_id" ` type LinkProjectToUserParams struct { @@ -846,165 +1362,513 @@ type LinkProjectToUserParams struct { Role string `db:"role" json:"role"` } -func (q *Queries) LinkProjectToUser(ctx context.Context, arg LinkProjectToUserParams) (UsersProject, error) { +type LinkProjectToUserRow struct { + UserHandle string `db:"user_handle" json:"user_handle"` + ProjectID int32 `db:"project_id" json:"project_id"` +} + +func (q *Queries) LinkProjectToUser(ctx context.Context, arg LinkProjectToUserParams) (LinkProjectToUserRow, error) { row := q.db.QueryRow(ctx, linkProjectToUser, arg.UserHandle, arg.ProjectID, arg.Role) - var i UsersProject + var i LinkProjectToUserRow + err := row.Scan(&i.UserHandle, &i.ProjectID) + return i, err +} + +const resetAllSerials = `-- name: ResetAllSerials :exec + + +DO $$ +DECLARE + seq_name text; + max_id bigint; +BEGIN + FOR seq_name IN + SELECT sequence_name + FROM information_schema.sequences + WHERE sequence_schema = 'public' AND sequence_name LIKE '%_seq' + LOOP + -- For definitions table, set sequence to max preserved definition_id + 1 + IF seq_name = 'definitions_definition_id_seq' THEN + SELECT COALESCE(MAX("definition_id"), 0) INTO max_id + FROM definitions + WHERE "owner" = '_system'; + EXECUTE format('ALTER SEQUENCE public.%I RESTART WITH %s', seq_name, max_id + 1); + -- For all other sequences, reset to 1 + ELSE + EXECUTE format('ALTER SEQUENCE public.%I RESTART WITH 1', seq_name); + END IF; + END LOOP; +END $$ +` + +// === ADMIN QUERIES === +func (q *Queries) ResetAllSerials(ctx context.Context) error { + _, err := q.db.Exec(ctx, resetAllSerials) + return err +} + +const retrieveAPIStandard = `-- name: RetrieveAPIStandard :one +SELECT api_standard_handle, description, key_method, key_field, created_at, updated_at +FROM api_standards +WHERE "api_standard_handle" = $1 LIMIT 1 +` + +func (q *Queries) RetrieveAPIStandard(ctx context.Context, apiStandardHandle string) (APIStandard, error) { + row := q.db.QueryRow(ctx, retrieveAPIStandard, apiStandardHandle) + var i APIStandard err := row.Scan( - &i.UserHandle, + &i.APIStandardHandle, + &i.Description, + &i.KeyMethod, + &i.KeyField, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const retrieveDefinition = `-- name: RetrieveDefinition :one +SELECT definition_id, definition_handle, owner, endpoint, description, api_standard, model, dimensions, context_limit, is_public, created_at, updated_at +FROM definitions +WHERE "owner" = $1 +AND "definition_handle" = $2 +LIMIT 1 +` + +type RetrieveDefinitionParams struct { + Owner string `db:"owner" json:"owner"` + DefinitionHandle string `db:"definition_handle" json:"definition_handle"` +} + +func (q *Queries) RetrieveDefinition(ctx context.Context, arg RetrieveDefinitionParams) (Definition, error) { + row := q.db.QueryRow(ctx, retrieveDefinition, arg.Owner, arg.DefinitionHandle) + var i Definition + err := row.Scan( + &i.DefinitionID, + &i.DefinitionHandle, + &i.Owner, + &i.Endpoint, + &i.Description, + &i.APIStandard, + &i.Model, + &i.Dimensions, + &i.ContextLimit, + &i.IsPublic, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const retrieveEmbeddings = `-- name: RetrieveEmbeddings :one +SELECT embeddings.embeddings_id, embeddings.text_id, embeddings.owner, embeddings.project_id, embeddings.instance_id, embeddings.text, embeddings.vector, embeddings.vector_dim, embeddings.metadata, embeddings.created_at, embeddings.updated_at, projects."project_handle", instances."instance_handle" +FROM embeddings +JOIN instances +ON embeddings."instance_id" = instances."instance_id" +JOIN projects +ON embeddings."project_id" = projects."project_id" +WHERE embeddings."owner" = $1 +AND projects."project_handle" = $2 +AND embeddings."text_id" = $3 +LIMIT 1 +` + +type RetrieveEmbeddingsParams struct { + Owner string `db:"owner" json:"owner"` + ProjectHandle string `db:"project_handle" json:"project_handle"` + TextID pgtype.Text `db:"text_id" json:"text_id"` +} + +type RetrieveEmbeddingsRow struct { + EmbeddingsID int32 `db:"embeddings_id" json:"embeddings_id"` + TextID pgtype.Text `db:"text_id" json:"text_id"` + Owner string `db:"owner" json:"owner"` + ProjectID int32 `db:"project_id" json:"project_id"` + InstanceID int32 `db:"instance_id" json:"instance_id"` + Text pgtype.Text `db:"text" json:"text"` + Vector pgvector_go.HalfVector `db:"vector" json:"vector"` + VectorDim int32 `db:"vector_dim" json:"vector_dim"` + Metadata []byte `db:"metadata" json:"metadata"` + CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` + ProjectHandle string `db:"project_handle" json:"project_handle"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` +} + +func (q *Queries) RetrieveEmbeddings(ctx context.Context, arg RetrieveEmbeddingsParams) (RetrieveEmbeddingsRow, error) { + row := q.db.QueryRow(ctx, retrieveEmbeddings, arg.Owner, arg.ProjectHandle, arg.TextID) + var i RetrieveEmbeddingsRow + err := row.Scan( + &i.EmbeddingsID, + &i.TextID, + &i.Owner, &i.ProjectID, - &i.Role, + &i.InstanceID, + &i.Text, + &i.Vector, + &i.VectorDim, + &i.Metadata, &i.CreatedAt, &i.UpdatedAt, + &i.ProjectHandle, + &i.InstanceHandle, ) return i, err } -const linkUserToLLM = `-- name: LinkUserToLLM :exec -INSERT -INTO users_llm_services ( - "user_handle", "llm_service_id", "role", "created_at", "updated_at" -) VALUES ( - $1, $2, $3, NOW(), NOW() -) -ON CONFLICT ("user_handle", "llm_service_id") DO UPDATE SET - "role" = $3, - "updated_at" = NOW() -RETURNING user_handle, llm_service_id, role, created_at, updated_at +const retrieveEmbeddingsByID = `-- name: RetrieveEmbeddingsByID :one +SELECT embeddings.embeddings_id, embeddings.text_id, embeddings.owner, embeddings.project_id, embeddings.instance_id, embeddings.text, embeddings.vector, embeddings.vector_dim, embeddings.metadata, embeddings.created_at, embeddings.updated_at, projects."project_handle", instances."instance_handle" +FROM embeddings +JOIN instances +ON embeddings."instance_id" = instances."instance_id" +JOIN projects +ON embeddings."project_id" = projects."project_id" +WHERE embeddings."embeddings_id" = $1 +LIMIT 1 ` -type LinkUserToLLMParams struct { - UserHandle string `db:"user_handle" json:"user_handle"` - LLMServiceID int32 `db:"llm_service_id" json:"llm_service_id"` - Role string `db:"role" json:"role"` +type RetrieveEmbeddingsByIDRow struct { + EmbeddingsID int32 `db:"embeddings_id" json:"embeddings_id"` + TextID pgtype.Text `db:"text_id" json:"text_id"` + Owner string `db:"owner" json:"owner"` + ProjectID int32 `db:"project_id" json:"project_id"` + InstanceID int32 `db:"instance_id" json:"instance_id"` + Text pgtype.Text `db:"text" json:"text"` + Vector pgvector_go.HalfVector `db:"vector" json:"vector"` + VectorDim int32 `db:"vector_dim" json:"vector_dim"` + Metadata []byte `db:"metadata" json:"metadata"` + CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` + ProjectHandle string `db:"project_handle" json:"project_handle"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` +} + +func (q *Queries) RetrieveEmbeddingsByID(ctx context.Context, embeddingsID int32) (RetrieveEmbeddingsByIDRow, error) { + row := q.db.QueryRow(ctx, retrieveEmbeddingsByID, embeddingsID) + var i RetrieveEmbeddingsByIDRow + err := row.Scan( + &i.EmbeddingsID, + &i.TextID, + &i.Owner, + &i.ProjectID, + &i.InstanceID, + &i.Text, + &i.Vector, + &i.VectorDim, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + &i.ProjectHandle, + &i.InstanceHandle, + ) + return i, err } -func (q *Queries) LinkUserToLLM(ctx context.Context, arg LinkUserToLLMParams) error { - _, err := q.db.Exec(ctx, linkUserToLLM, arg.UserHandle, arg.LLMServiceID, arg.Role) - return err +const retrieveInstance = `-- name: RetrieveInstance :one +SELECT instances."owner", + instances."instance_handle", + instances."instance_id", + instances."definition_id", + definitions."owner" AS "definition_owner", + definitions."definition_handle" AS "definition_handle", + instances."endpoint", + instances."description", + -- when api_key_encrypted is not null, return true + CASE + WHEN instances."api_key_encrypted" IS NULL THEN TRUE + ELSE FALSE + END AS "has_api_key", + instances."api_standard", + instances."model", + instances."dimensions", + instances."context_limit", + instances."created_at", + instances."updated_at" +FROM instances +LEFT JOIN definitions +ON instances."definition_id" = definitions."definition_id" +WHERE instances."owner" = $1 +AND instances."instance_handle" = $2 +LIMIT 1 +` + +type RetrieveInstanceParams struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` +} + +type RetrieveInstanceRow struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` + InstanceID int32 `db:"instance_id" json:"instance_id"` + DefinitionID pgtype.Int4 `db:"definition_id" json:"definition_id"` + DefinitionOwner pgtype.Text `db:"definition_owner" json:"definition_owner"` + DefinitionHandle pgtype.Text `db:"definition_handle" json:"definition_handle"` + Endpoint string `db:"endpoint" json:"endpoint"` + Description pgtype.Text `db:"description" json:"description"` + HasAPIKey bool `db:"has_api_key" json:"has_api_key"` + APIStandard string `db:"api_standard" json:"api_standard"` + Model string `db:"model" json:"model"` + Dimensions int32 `db:"dimensions" json:"dimensions"` + ContextLimit int32 `db:"context_limit" json:"context_limit"` + CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` +} + +func (q *Queries) RetrieveInstance(ctx context.Context, arg RetrieveInstanceParams) (RetrieveInstanceRow, error) { + row := q.db.QueryRow(ctx, retrieveInstance, arg.Owner, arg.InstanceHandle) + var i RetrieveInstanceRow + err := row.Scan( + &i.Owner, + &i.InstanceHandle, + &i.InstanceID, + &i.DefinitionID, + &i.DefinitionOwner, + &i.DefinitionHandle, + &i.Endpoint, + &i.Description, + &i.HasAPIKey, + &i.APIStandard, + &i.Model, + &i.Dimensions, + &i.ContextLimit, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const retrieveInstanceByID = `-- name: RetrieveInstanceByID :one +SELECT instances."owner", + instances."instance_handle", + instances."instance_id", + instances."definition_id", + instances."endpoint", + instances."description", + instances."api_standard", + instances."model", + instances."dimensions", + instances."context_limit", + instances."created_at", + instances."updated_at" +FROM instances +WHERE "instance_id" = $1 +LIMIT 1 +` + +type RetrieveInstanceByIDRow struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` + InstanceID int32 `db:"instance_id" json:"instance_id"` + DefinitionID pgtype.Int4 `db:"definition_id" json:"definition_id"` + Endpoint string `db:"endpoint" json:"endpoint"` + Description pgtype.Text `db:"description" json:"description"` + APIStandard string `db:"api_standard" json:"api_standard"` + Model string `db:"model" json:"model"` + Dimensions int32 `db:"dimensions" json:"dimensions"` + ContextLimit int32 `db:"context_limit" json:"context_limit"` + CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` +} + +func (q *Queries) RetrieveInstanceByID(ctx context.Context, instanceID int32) (RetrieveInstanceByIDRow, error) { + row := q.db.QueryRow(ctx, retrieveInstanceByID, instanceID) + var i RetrieveInstanceByIDRow + err := row.Scan( + &i.Owner, + &i.InstanceHandle, + &i.InstanceID, + &i.DefinitionID, + &i.Endpoint, + &i.Description, + &i.APIStandard, + &i.Model, + &i.Dimensions, + &i.ContextLimit, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err } -const resetAllSerials = `-- name: ResetAllSerials :exec -DO $$ -DECLARE - seq_name text; -BEGIN - FOR seq_name IN - SELECT sequence_name - FROM information_schema.sequences - WHERE sequence_schema = 'public' AND sequence_name LIKE '%_seq' - LOOP - EXECUTE format('ALTER SEQUENCE public.%I RESTART WITH 1', seq_name); - END LOOP; -END $$ +const retrieveInstanceByProject = `-- name: RetrieveInstanceByProject :one +SELECT instances."owner", + instances."instance_handle", + instances."instance_id", + instances."definition_id", + instances."endpoint", + instances."description", + instances."api_standard", + instances."model", + instances."dimensions", + instances."context_limit", + instances."created_at", + instances."updated_at" +FROM instances +JOIN projects +ON projects."instance_id" = instances."instance_id" +WHERE projects."owner" = $1 + AND projects."project_handle" = $2 +LIMIT 1 ` -func (q *Queries) ResetAllSerials(ctx context.Context) error { - _, err := q.db.Exec(ctx, resetAllSerials) - return err +type RetrieveInstanceByProjectParams struct { + Owner string `db:"owner" json:"owner"` + ProjectHandle string `db:"project_handle" json:"project_handle"` } -const retrieveAPIStandard = `-- name: RetrieveAPIStandard :one -SELECT api_standard_handle, description, key_method, key_field, created_at, updated_at -FROM api_standards -WHERE "api_standard_handle" = $1 LIMIT 1 -` +type RetrieveInstanceByProjectRow struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` + InstanceID int32 `db:"instance_id" json:"instance_id"` + DefinitionID pgtype.Int4 `db:"definition_id" json:"definition_id"` + Endpoint string `db:"endpoint" json:"endpoint"` + Description pgtype.Text `db:"description" json:"description"` + APIStandard string `db:"api_standard" json:"api_standard"` + Model string `db:"model" json:"model"` + Dimensions int32 `db:"dimensions" json:"dimensions"` + ContextLimit int32 `db:"context_limit" json:"context_limit"` + CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` +} -func (q *Queries) RetrieveAPIStandard(ctx context.Context, apiStandardHandle string) (APIStandard, error) { - row := q.db.QueryRow(ctx, retrieveAPIStandard, apiStandardHandle) - var i APIStandard +func (q *Queries) RetrieveInstanceByProject(ctx context.Context, arg RetrieveInstanceByProjectParams) (RetrieveInstanceByProjectRow, error) { + row := q.db.QueryRow(ctx, retrieveInstanceByProject, arg.Owner, arg.ProjectHandle) + var i RetrieveInstanceByProjectRow err := row.Scan( - &i.APIStandardHandle, + &i.Owner, + &i.InstanceHandle, + &i.InstanceID, + &i.DefinitionID, + &i.Endpoint, &i.Description, - &i.KeyMethod, - &i.KeyField, + &i.APIStandard, + &i.Model, + &i.Dimensions, + &i.ContextLimit, &i.CreatedAt, &i.UpdatedAt, ) return i, err } -const retrieveEmbeddings = `-- name: RetrieveEmbeddings :one -SELECT embeddings.embeddings_id, embeddings.text_id, embeddings.owner, embeddings.project_id, embeddings.llm_service_id, embeddings.text, embeddings.vector, embeddings.vector_dim, embeddings.metadata, embeddings.created_at, embeddings.updated_at, projects."project_handle", llm_services."llm_service_handle" -FROM embeddings -JOIN llm_services -ON embeddings."llm_service_id" = llm_services."llm_service_id" +const retrieveInstanceByProjectForUser = `-- name: RetrieveInstanceByProjectForUser :one +SELECT instances."owner", + instances."instance_handle", + instances."instance_id", + instances."definition_id", + instances."endpoint", + instances."description", + instances."api_standard", + instances."model", + instances."dimensions", + instances."context_limit", + instances."created_at", + instances."updated_at", + instances_shared_with."role" AS "access_role" +FROM instances JOIN projects -ON embeddings."project_id" = projects."project_id" -WHERE embeddings."owner" = $1 -AND projects."project_handle" = $2 -AND embeddings."text_id" = $3 +ON projects."instance_id" = instances."instance_id" +LEFT JOIN instances_shared_with +ON instances_shared_with."instance_id" = instances."instance_id" +WHERE projects."owner" = $1 + AND projects."project_handle" = $2 + AND instances_shared_with."user_handle" = $3 LIMIT 1 ` -type RetrieveEmbeddingsParams struct { - Owner string `db:"owner" json:"owner"` - ProjectHandle string `db:"project_handle" json:"project_handle"` - TextID pgtype.Text `db:"text_id" json:"text_id"` +type RetrieveInstanceByProjectForUserParams struct { + Owner string `db:"owner" json:"owner"` + ProjectHandle string `db:"project_handle" json:"project_handle"` + UserHandle string `db:"user_handle" json:"user_handle"` } -type RetrieveEmbeddingsRow struct { - EmbeddingsID int32 `db:"embeddings_id" json:"embeddings_id"` - TextID pgtype.Text `db:"text_id" json:"text_id"` - Owner string `db:"owner" json:"owner"` - ProjectID int32 `db:"project_id" json:"project_id"` - LLMServiceID int32 `db:"llm_service_id" json:"llm_service_id"` - Text pgtype.Text `db:"text" json:"text"` - Vector pgvector_go.HalfVector `db:"vector" json:"vector"` - VectorDim int32 `db:"vector_dim" json:"vector_dim"` - Metadata []byte `db:"metadata" json:"metadata"` - CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` - UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` - ProjectHandle string `db:"project_handle" json:"project_handle"` - LLMServiceHandle string `db:"llm_service_handle" json:"llm_service_handle"` +type RetrieveInstanceByProjectForUserRow struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` + InstanceID int32 `db:"instance_id" json:"instance_id"` + DefinitionID pgtype.Int4 `db:"definition_id" json:"definition_id"` + Endpoint string `db:"endpoint" json:"endpoint"` + Description pgtype.Text `db:"description" json:"description"` + APIStandard string `db:"api_standard" json:"api_standard"` + Model string `db:"model" json:"model"` + Dimensions int32 `db:"dimensions" json:"dimensions"` + ContextLimit int32 `db:"context_limit" json:"context_limit"` + CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` + AccessRole pgtype.Text `db:"access_role" json:"access_role"` } -func (q *Queries) RetrieveEmbeddings(ctx context.Context, arg RetrieveEmbeddingsParams) (RetrieveEmbeddingsRow, error) { - row := q.db.QueryRow(ctx, retrieveEmbeddings, arg.Owner, arg.ProjectHandle, arg.TextID) - var i RetrieveEmbeddingsRow +func (q *Queries) RetrieveInstanceByProjectForUser(ctx context.Context, arg RetrieveInstanceByProjectForUserParams) (RetrieveInstanceByProjectForUserRow, error) { + row := q.db.QueryRow(ctx, retrieveInstanceByProjectForUser, arg.Owner, arg.ProjectHandle, arg.UserHandle) + var i RetrieveInstanceByProjectForUserRow err := row.Scan( - &i.EmbeddingsID, - &i.TextID, &i.Owner, - &i.ProjectID, - &i.LLMServiceID, - &i.Text, - &i.Vector, - &i.VectorDim, - &i.Metadata, + &i.InstanceHandle, + &i.InstanceID, + &i.DefinitionID, + &i.Endpoint, + &i.Description, + &i.APIStandard, + &i.Model, + &i.Dimensions, + &i.ContextLimit, &i.CreatedAt, &i.UpdatedAt, - &i.ProjectHandle, - &i.LLMServiceHandle, + &i.AccessRole, ) return i, err } -const retrieveLLM = `-- name: RetrieveLLM :one -SELECT llm_service_id, llm_service_handle, owner, endpoint, description, api_key, api_standard, model, dimensions, created_at, updated_at -FROM llm_services -WHERE "owner" = $1 -AND "llm_service_handle" = $2 +const retrieveInstanceByProjectID = `-- name: RetrieveInstanceByProjectID :one +SELECT instances."owner", + instances."instance_handle", + instances."instance_id", + instances."definition_id", + instances."endpoint", + instances."description", + instances."api_standard", + instances."model", + instances."dimensions", + instances."context_limit", + instances."created_at", + instances."updated_at" +FROM instances +JOIN projects +ON projects."instance_id" = instances."instance_id" +WHERE projects."project_id" = $1 LIMIT 1 ` -type RetrieveLLMParams struct { - Owner string `db:"owner" json:"owner"` - LLMServiceHandle string `db:"llm_service_handle" json:"llm_service_handle"` +type RetrieveInstanceByProjectIDRow struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` + InstanceID int32 `db:"instance_id" json:"instance_id"` + DefinitionID pgtype.Int4 `db:"definition_id" json:"definition_id"` + Endpoint string `db:"endpoint" json:"endpoint"` + Description pgtype.Text `db:"description" json:"description"` + APIStandard string `db:"api_standard" json:"api_standard"` + Model string `db:"model" json:"model"` + Dimensions int32 `db:"dimensions" json:"dimensions"` + ContextLimit int32 `db:"context_limit" json:"context_limit"` + CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` } -func (q *Queries) RetrieveLLM(ctx context.Context, arg RetrieveLLMParams) (LlmService, error) { - row := q.db.QueryRow(ctx, retrieveLLM, arg.Owner, arg.LLMServiceHandle) - var i LlmService +func (q *Queries) RetrieveInstanceByProjectID(ctx context.Context, projectID int32) (RetrieveInstanceByProjectIDRow, error) { + row := q.db.QueryRow(ctx, retrieveInstanceByProjectID, projectID) + var i RetrieveInstanceByProjectIDRow err := row.Scan( - &i.LLMServiceID, - &i.LLMServiceHandle, &i.Owner, + &i.InstanceHandle, + &i.InstanceID, + &i.DefinitionID, &i.Endpoint, &i.Description, - &i.APIKey, &i.APIStandard, &i.Model, &i.Dimensions, + &i.ContextLimit, &i.CreatedAt, &i.UpdatedAt, ) @@ -1012,7 +1876,7 @@ func (q *Queries) RetrieveLLM(ctx context.Context, arg RetrieveLLMParams) (LlmSe } const retrieveProject = `-- name: RetrieveProject :one -SELECT project_id, project_handle, owner, description, metadata_scheme, created_at, updated_at, public_read +SELECT project_id, project_handle, owner, description, metadata_scheme, created_at, updated_at, public_read, instance_id FROM projects WHERE "owner" = $1 AND "project_handle" = $2 @@ -1036,12 +1900,120 @@ func (q *Queries) RetrieveProject(ctx context.Context, arg RetrieveProjectParams &i.CreatedAt, &i.UpdatedAt, &i.PublicRead, + &i.InstanceID, + ) + return i, err +} + +const retrieveProjectForUser = `-- name: RetrieveProjectForUser :one +SELECT projects.project_id, projects.project_handle, projects.owner, projects.description, projects.metadata_scheme, projects.created_at, projects.updated_at, projects.public_read, projects.instance_id, users_projects."role" +FROM projects +LEFT JOIN users_projects +ON projects."project_id" = users_projects."project_id" +WHERE projects."owner" = $1 +AND projects."project_handle" = $2 +AND (users_projects."user_handle" = $3 OR projects."public_read" = TRUE) +LIMIT 1 +` + +type RetrieveProjectForUserParams struct { + Owner string `db:"owner" json:"owner"` + ProjectHandle string `db:"project_handle" json:"project_handle"` + UserHandle string `db:"user_handle" json:"user_handle"` +} + +type RetrieveProjectForUserRow struct { + ProjectID int32 `db:"project_id" json:"project_id"` + ProjectHandle string `db:"project_handle" json:"project_handle"` + Owner string `db:"owner" json:"owner"` + Description pgtype.Text `db:"description" json:"description"` + MetadataScheme pgtype.Text `db:"metadata_scheme" json:"metadata_scheme"` + CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` + PublicRead pgtype.Bool `db:"public_read" json:"public_read"` + InstanceID pgtype.Int4 `db:"instance_id" json:"instance_id"` + Role pgtype.Text `db:"role" json:"role"` +} + +func (q *Queries) RetrieveProjectForUser(ctx context.Context, arg RetrieveProjectForUserParams) (RetrieveProjectForUserRow, error) { + row := q.db.QueryRow(ctx, retrieveProjectForUser, arg.Owner, arg.ProjectHandle, arg.UserHandle) + var i RetrieveProjectForUserRow + err := row.Scan( + &i.ProjectID, + &i.ProjectHandle, + &i.Owner, + &i.Description, + &i.MetadataScheme, + &i.CreatedAt, + &i.UpdatedAt, + &i.PublicRead, + &i.InstanceID, + &i.Role, + ) + return i, err +} + +const retrieveSharedInstance = `-- name: RetrieveSharedInstance :one +SELECT instances."owner", + instances."instance_handle", + instances."definition_id", + instances."endpoint", + instances."description", + instances."api_standard", + instances."model", + instances."dimensions", + instances."context_limit", + instances."created_at", + instances."updated_at" +FROM instances +JOIN instances_shared_with +ON instances."instance_id" = instances_shared_with."instance_id" +WHERE (instances_shared_with."user_handle" = $1 AND instances."owner" = $2 AND instances."instance_handle" = $3) +LIMIT 1 +` + +type RetrieveSharedInstanceParams struct { + UserHandle string `db:"user_handle" json:"user_handle"` + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` +} + +type RetrieveSharedInstanceRow struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` + DefinitionID pgtype.Int4 `db:"definition_id" json:"definition_id"` + Endpoint string `db:"endpoint" json:"endpoint"` + Description pgtype.Text `db:"description" json:"description"` + APIStandard string `db:"api_standard" json:"api_standard"` + Model string `db:"model" json:"model"` + Dimensions int32 `db:"dimensions" json:"dimensions"` + ContextLimit int32 `db:"context_limit" json:"context_limit"` + CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` +} + +// Get single instance, but only if it is shared with requesting user +func (q *Queries) RetrieveSharedInstance(ctx context.Context, arg RetrieveSharedInstanceParams) (RetrieveSharedInstanceRow, error) { + row := q.db.QueryRow(ctx, retrieveSharedInstance, arg.UserHandle, arg.Owner, arg.InstanceHandle) + var i RetrieveSharedInstanceRow + err := row.Scan( + &i.Owner, + &i.InstanceHandle, + &i.DefinitionID, + &i.Endpoint, + &i.Description, + &i.APIStandard, + &i.Model, + &i.Dimensions, + &i.ContextLimit, + &i.CreatedAt, + &i.UpdatedAt, ) return i, err } const retrieveUser = `-- name: RetrieveUser :one -SELECT user_handle, name, email, vdb_api_key, created_at, updated_at +SELECT user_handle, name, email, vdb_key, created_at, updated_at FROM users WHERE "user_handle" = $1 LIMIT 1 ` @@ -1053,14 +2025,67 @@ func (q *Queries) RetrieveUser(ctx context.Context, userHandle string) (User, er &i.UserHandle, &i.Name, &i.Email, - &i.VdbAPIKey, + &i.VDBKey, &i.CreatedAt, &i.UpdatedAt, ) return i, err } +const unlinkDefinition = `-- name: UnlinkDefinition :exec +DELETE +FROM definitions_shared_with +WHERE "user_handle" = $1 +AND "definition_id" = $2 +` + +type UnlinkDefinitionParams struct { + UserHandle string `db:"user_handle" json:"user_handle"` + DefinitionID int32 `db:"definition_id" json:"definition_id"` +} + +func (q *Queries) UnlinkDefinition(ctx context.Context, arg UnlinkDefinitionParams) error { + _, err := q.db.Exec(ctx, unlinkDefinition, arg.UserHandle, arg.DefinitionID) + return err +} + +const unlinkInstance = `-- name: UnlinkInstance :exec +DELETE +FROM instances_shared_with +WHERE "user_handle" = $1 +AND "instance_id" = $2 +` + +type UnlinkInstanceParams struct { + UserHandle string `db:"user_handle" json:"user_handle"` + InstanceID int32 `db:"instance_id" json:"instance_id"` +} + +func (q *Queries) UnlinkInstance(ctx context.Context, arg UnlinkInstanceParams) error { + _, err := q.db.Exec(ctx, unlinkInstance, arg.UserHandle, arg.InstanceID) + return err +} + +const unlinkProjectFromUser = `-- name: UnlinkProjectFromUser :exec +DELETE +FROM users_projects +WHERE "user_handle" = $1 +AND "project_id" = $2 +` + +type UnlinkProjectFromUserParams struct { + UserHandle string `db:"user_handle" json:"user_handle"` + ProjectID int32 `db:"project_id" json:"project_id"` +} + +func (q *Queries) UnlinkProjectFromUser(ctx context.Context, arg UnlinkProjectFromUserParams) error { + _, err := q.db.Exec(ctx, unlinkProjectFromUser, arg.UserHandle, arg.ProjectID) + return err +} + const upsertAPIStandard = `-- name: UpsertAPIStandard :one + + INSERT INTO api_standards ( "api_standard_handle", "description", "key_method", "key_field", "created_at", "updated_at" @@ -1082,6 +2107,7 @@ type UpsertAPIStandardParams struct { KeyField pgtype.Text `db:"key_field" json:"key_field"` } +// === API STANDARDS === func (q *Queries) UpsertAPIStandard(ctx context.Context, arg UpsertAPIStandardParams) (string, error) { row := q.db.QueryRow(ctx, upsertAPIStandard, arg.APIStandardHandle, @@ -1094,31 +2120,96 @@ func (q *Queries) UpsertAPIStandard(ctx context.Context, arg UpsertAPIStandardPa return api_standard_handle, err } +const upsertDefinition = `-- name: UpsertDefinition :one + + +INSERT +INTO definitions ( + "owner", "definition_handle", "endpoint", "description", "api_standard", "model", "dimensions", "context_limit", "is_public", "created_at", "updated_at" +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW() +) +ON CONFLICT ("owner", "definition_handle") DO UPDATE SET + "endpoint" = EXCLUDED."endpoint", + "description" = EXCLUDED."description", + "api_standard" = EXCLUDED."api_standard", + "model" = EXCLUDED."model", + "dimensions" = EXCLUDED."dimensions", + "context_limit" = EXCLUDED."context_limit", + "is_public" = EXCLUDED."is_public", + "updated_at" = NOW() +RETURNING "owner", "definition_handle", "definition_id", "is_public" +` + +type UpsertDefinitionParams struct { + Owner string `db:"owner" json:"owner"` + DefinitionHandle string `db:"definition_handle" json:"definition_handle"` + Endpoint string `db:"endpoint" json:"endpoint"` + Description pgtype.Text `db:"description" json:"description"` + APIStandard string `db:"api_standard" json:"api_standard"` + Model string `db:"model" json:"model"` + Dimensions int32 `db:"dimensions" json:"dimensions"` + ContextLimit int32 `db:"context_limit" json:"context_limit"` + IsPublic bool `db:"is_public" json:"is_public"` +} + +type UpsertDefinitionRow struct { + Owner string `db:"owner" json:"owner"` + DefinitionHandle string `db:"definition_handle" json:"definition_handle"` + DefinitionID int32 `db:"definition_id" json:"definition_id"` + IsPublic bool `db:"is_public" json:"is_public"` +} + +// === LLM Service Definitions (user-shared templates) === +func (q *Queries) UpsertDefinition(ctx context.Context, arg UpsertDefinitionParams) (UpsertDefinitionRow, error) { + row := q.db.QueryRow(ctx, upsertDefinition, + arg.Owner, + arg.DefinitionHandle, + arg.Endpoint, + arg.Description, + arg.APIStandard, + arg.Model, + arg.Dimensions, + arg.ContextLimit, + arg.IsPublic, + ) + var i UpsertDefinitionRow + err := row.Scan( + &i.Owner, + &i.DefinitionHandle, + &i.DefinitionID, + &i.IsPublic, + ) + return i, err +} + const upsertEmbeddings = `-- name: UpsertEmbeddings :one + + INSERT INTO embeddings ( - "text_id", "owner", "project_id", "llm_service_id", "text", "vector", "vector_dim", "metadata", "created_at", "updated_at" + "text_id", "owner", "project_id", "instance_id", "text", "vector", "vector_dim", "metadata", "created_at", "updated_at" ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW() ) -ON CONFLICT ("text_id", "owner", "project_id", "llm_service_id") DO UPDATE SET +ON CONFLICT ("text_id", "owner", "project_id", "instance_id") DO UPDATE SET "text" = $5, "vector" = $6, "vector_dim" = $7, "metadata" = $8, "updated_at" = NOW() -RETURNING "embeddings_id", "text_id", "owner", "project_id", "llm_service_id" +RETURNING "embeddings_id", "text_id", "owner", "project_id", "instance_id" ` type UpsertEmbeddingsParams struct { - TextID pgtype.Text `db:"text_id" json:"text_id"` - Owner string `db:"owner" json:"owner"` - ProjectID int32 `db:"project_id" json:"project_id"` - LLMServiceID int32 `db:"llm_service_id" json:"llm_service_id"` - Text pgtype.Text `db:"text" json:"text"` - Vector pgvector_go.HalfVector `db:"vector" json:"vector"` - VectorDim int32 `db:"vector_dim" json:"vector_dim"` - Metadata []byte `db:"metadata" json:"metadata"` + TextID pgtype.Text `db:"text_id" json:"text_id"` + Owner string `db:"owner" json:"owner"` + ProjectID int32 `db:"project_id" json:"project_id"` + InstanceID int32 `db:"instance_id" json:"instance_id"` + Text pgtype.Text `db:"text" json:"text"` + Vector pgvector_go.HalfVector `db:"vector" json:"vector"` + VectorDim int32 `db:"vector_dim" json:"vector_dim"` + Metadata []byte `db:"metadata" json:"metadata"` } type UpsertEmbeddingsRow struct { @@ -1126,15 +2217,16 @@ type UpsertEmbeddingsRow struct { TextID pgtype.Text `db:"text_id" json:"text_id"` Owner string `db:"owner" json:"owner"` ProjectID int32 `db:"project_id" json:"project_id"` - LLMServiceID int32 `db:"llm_service_id" json:"llm_service_id"` + InstanceID int32 `db:"instance_id" json:"instance_id"` } +// === EMBEDDINGS === func (q *Queries) UpsertEmbeddings(ctx context.Context, arg UpsertEmbeddingsParams) (UpsertEmbeddingsRow, error) { row := q.db.QueryRow(ctx, upsertEmbeddings, arg.TextID, arg.Owner, arg.ProjectID, - arg.LLMServiceID, + arg.InstanceID, arg.Text, arg.Vector, arg.VectorDim, @@ -1146,73 +2238,155 @@ func (q *Queries) UpsertEmbeddings(ctx context.Context, arg UpsertEmbeddingsPara &i.TextID, &i.Owner, &i.ProjectID, - &i.LLMServiceID, + &i.InstanceID, ) return i, err } -const upsertLLM = `-- name: UpsertLLM :one +const upsertInstance = `-- name: UpsertInstance :one + + INSERT -INTO llm_services ( - "owner", "llm_service_handle", "endpoint", "description", "api_key", "api_standard", "model", "dimensions", "created_at", "updated_at" +INTO instances ( + "owner", "instance_handle", "definition_id", "endpoint", "description", "api_key_encrypted", "api_standard", "model", "dimensions", "context_limit", "created_at", "updated_at" ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW() + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW() +) +ON CONFLICT ("owner", "instance_handle") DO UPDATE SET + "definition_id" = EXCLUDED."definition_id", + "endpoint" = EXCLUDED."endpoint", + "description" = EXCLUDED."description", + "api_key_encrypted" = EXCLUDED."api_key_encrypted", + "api_standard" = EXCLUDED."api_standard", + "model" = EXCLUDED."model", + "dimensions" = EXCLUDED."dimensions", + "context_limit" = EXCLUDED."context_limit", + "updated_at" = NOW() +RETURNING "owner", "instance_handle", "instance_id" +` + +type UpsertInstanceParams struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` + DefinitionID pgtype.Int4 `db:"definition_id" json:"definition_id"` + Endpoint string `db:"endpoint" json:"endpoint"` + Description pgtype.Text `db:"description" json:"description"` + APIKeyEncrypted []byte `db:"api_key_encrypted" json:"api_key_encrypted"` + APIStandard string `db:"api_standard" json:"api_standard"` + Model string `db:"model" json:"model"` + Dimensions int32 `db:"dimensions" json:"dimensions"` + ContextLimit int32 `db:"context_limit" json:"context_limit"` +} + +type UpsertInstanceRow struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` + InstanceID int32 `db:"instance_id" json:"instance_id"` +} + +// === LLM Service Instances (user-specific instances with optional API keys) === +func (q *Queries) UpsertInstance(ctx context.Context, arg UpsertInstanceParams) (UpsertInstanceRow, error) { + row := q.db.QueryRow(ctx, upsertInstance, + arg.Owner, + arg.InstanceHandle, + arg.DefinitionID, + arg.Endpoint, + arg.Description, + arg.APIKeyEncrypted, + arg.APIStandard, + arg.Model, + arg.Dimensions, + arg.ContextLimit, + ) + var i UpsertInstanceRow + err := row.Scan(&i.Owner, &i.InstanceHandle, &i.InstanceID) + return i, err +} + +const upsertInstanceFromDefinition = `-- name: UpsertInstanceFromDefinition :one +INSERT +INTO instances ( + "owner", "instance_handle", "definition_id", "endpoint", "description", "api_key_encrypted", "api_standard", "model", "dimensions", "context_limit", "created_at", "updated_at" ) -ON CONFLICT ("owner", "llm_service_handle") DO UPDATE SET - "endpoint" = $3, - "description" = $4, - "api_key" = $5, - "api_standard" = $6, - "model" = $7, - "dimensions" = $8, +SELECT + $1 as "owner", + $2 as "instance_handle", + def."definition_id", + COALESCE($3, def."endpoint") as "endpoint", + COALESCE($4, def."description") as "description", + $5 as "api_key_encrypted", + COALESCE($6, def."api_standard") as "api_standard", + COALESCE($7, def."model") as "model", + COALESCE($8::INTEGER, def."dimensions") as "dimensions", + COALESCE($9::INTEGER, def."context_limit") as "context_limit", + NOW() as "created_at", + NOW() as "updated_at" +FROM definitions def +WHERE def."owner" = $9 AND def."definition_handle" = $10 +ON CONFLICT ("owner", "instance_handle") DO UPDATE SET + "definition_id" = EXCLUDED."definition_id", + "endpoint" = EXCLUDED."endpoint", + "description" = EXCLUDED."description", + "api_key_encrypted" = EXCLUDED."api_key_encrypted", + "api_standard" = EXCLUDED."api_standard", + "model" = EXCLUDED."model", + "dimensions" = EXCLUDED."dimensions", + "context_limit" = EXCLUDED."context_limit", "updated_at" = NOW() -RETURNING "owner", "llm_service_handle", "llm_service_id" +RETURNING "owner", "instance_handle", "instance_id" ` -type UpsertLLMParams struct { +type UpsertInstanceFromDefinitionParams struct { Owner string `db:"owner" json:"owner"` - LLMServiceHandle string `db:"llm_service_handle" json:"llm_service_handle"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` Endpoint string `db:"endpoint" json:"endpoint"` Description pgtype.Text `db:"description" json:"description"` - APIKey pgtype.Text `db:"api_key" json:"api_key"` + APIKeyEncrypted []byte `db:"api_key_encrypted" json:"api_key_encrypted"` APIStandard string `db:"api_standard" json:"api_standard"` Model string `db:"model" json:"model"` - Dimensions int32 `db:"dimensions" json:"dimensions"` + Column8 int32 `db:"column_8" json:"column_8"` + Column9 int32 `db:"column_9" json:"column_9"` + DefinitionHandle string `db:"definition_handle" json:"definition_handle"` } -type UpsertLLMRow struct { - Owner string `db:"owner" json:"owner"` - LLMServiceHandle string `db:"llm_service_handle" json:"llm_service_handle"` - LLMServiceID int32 `db:"llm_service_id" json:"llm_service_id"` +type UpsertInstanceFromDefinitionRow struct { + Owner string `db:"owner" json:"owner"` + InstanceHandle string `db:"instance_handle" json:"instance_handle"` + InstanceID int32 `db:"instance_id" json:"instance_id"` } -func (q *Queries) UpsertLLM(ctx context.Context, arg UpsertLLMParams) (UpsertLLMRow, error) { - row := q.db.QueryRow(ctx, upsertLLM, +func (q *Queries) UpsertInstanceFromDefinition(ctx context.Context, arg UpsertInstanceFromDefinitionParams) (UpsertInstanceFromDefinitionRow, error) { + row := q.db.QueryRow(ctx, upsertInstanceFromDefinition, arg.Owner, - arg.LLMServiceHandle, + arg.InstanceHandle, arg.Endpoint, arg.Description, - arg.APIKey, + arg.APIKeyEncrypted, arg.APIStandard, arg.Model, - arg.Dimensions, + arg.Column8, + arg.Column9, + arg.DefinitionHandle, ) - var i UpsertLLMRow - err := row.Scan(&i.Owner, &i.LLMServiceHandle, &i.LLMServiceID) + var i UpsertInstanceFromDefinitionRow + err := row.Scan(&i.Owner, &i.InstanceHandle, &i.InstanceID) return i, err } const upsertProject = `-- name: UpsertProject :one + + INSERT INTO projects ( - "project_handle", "owner", "description", "metadata_scheme", "public_read", "created_at", "updated_at" + "project_handle", "owner", "description", "metadata_scheme", "public_read", "instance_id", "created_at", "updated_at" ) VALUES ( - $1, $2, $3, $4, $5, NOW(), NOW() + $1, $2, $3, $4, $5, $6, NOW(), NOW() ) ON CONFLICT ("owner", "project_handle") DO UPDATE SET - "description" = $3, - "metadata_scheme" = $4, - "public_read" = $5, + "description" = EXCLUDED."description", + "metadata_scheme" = EXCLUDED."metadata_scheme", + "public_read" = EXCLUDED."public_read", + "instance_id" = EXCLUDED."instance_id", "updated_at" = NOW() RETURNING "project_id", "owner", "project_handle" ` @@ -1223,6 +2397,7 @@ type UpsertProjectParams struct { Description pgtype.Text `db:"description" json:"description"` MetadataScheme pgtype.Text `db:"metadata_scheme" json:"metadata_scheme"` PublicRead pgtype.Bool `db:"public_read" json:"public_read"` + InstanceID pgtype.Int4 `db:"instance_id" json:"instance_id"` } type UpsertProjectRow struct { @@ -1231,6 +2406,7 @@ type UpsertProjectRow struct { ProjectHandle string `db:"project_handle" json:"project_handle"` } +// === PROJECTS === func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) (UpsertProjectRow, error) { row := q.db.QueryRow(ctx, upsertProject, arg.ProjectHandle, @@ -1238,6 +2414,7 @@ func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) (U arg.Description, arg.MetadataScheme, arg.PublicRead, + arg.InstanceID, ) var i UpsertProjectRow err := row.Scan(&i.ProjectID, &i.Owner, &i.ProjectHandle) @@ -1246,45 +2423,55 @@ func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) (U const upsertUser = `-- name: UpsertUser :one + + + + + INSERT INTO users ( - "user_handle", "name", "email", "vdb_api_key", "created_at", "updated_at" + "user_handle", "name", "email", "vdb_key", "created_at", "updated_at" ) VALUES ( $1, $2, $3, $4, NOW(), NOW() ) ON CONFLICT ("user_handle") DO UPDATE SET - "name" = $2, - "email" = $3, - "vdb_api_key" = $4, + "name" = EXCLUDED."name", + "email" = EXCLUDED."email", + "vdb_key" = EXCLUDED."vdb_key", "updated_at" = NOW() -RETURNING user_handle, name, email, vdb_api_key, created_at, updated_at +RETURNING users."user_handle" ` type UpsertUserParams struct { UserHandle string `db:"user_handle" json:"user_handle"` Name pgtype.Text `db:"name" json:"name"` Email string `db:"email" json:"email"` - VdbAPIKey string `db:"vdb_api_key" json:"vdb_api_key"` + VDBKey string `db:"vdb_key" json:"vdb_key"` } // Generate go code with: sqlc generate -// -// $1, $2, $3, (decode(sqlc.arg(vdb_api_key)::bytea, 'hex')), NOW(), NOW() -func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, error) { +// sqlc creates Go functions from the SQL commands below, using annotations +// (beginning with "-- name:") to derive function names and result types. +// In the end of the annotation, :one means single result, :many means multiple results, :exec means no results. +// The conventions for function names used in this project are as follows: +// - "Get" functions return lists of objects as identifiers or minimal metadata, +// - "Retrieve" functions return single objects with full object data. +// - "Upsert" functions insert or update objects and return only identifiers or minimal metadata. +// - "Delete" functions delete objects and return no data. +// - "Link..." and "Unlink..." functions create or remove associations between objects. +// - "Is..." functions return boolean values. +// - "Count..." functions return counts of objects. +// - "...All..." functions return all objects of a type without filtering (or perform an action on all records). +// - "...By..." functions return objects filtered by some association. +// === USERS === +func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (string, error) { row := q.db.QueryRow(ctx, upsertUser, arg.UserHandle, arg.Name, arg.Email, - arg.VdbAPIKey, - ) - var i User - err := row.Scan( - &i.UserHandle, - &i.Name, - &i.Email, - &i.VdbAPIKey, - &i.CreatedAt, - &i.UpdatedAt, + arg.VDBKey, ) - return i, err + var user_handle string + err := row.Scan(&user_handle) + return user_handle, err } diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 29b8a58..192c377 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -1,19 +1,37 @@ -- Generate go code with: sqlc generate +-- sqlc creates Go functions from the SQL commands below, using annotations +-- (beginning with "-- name:") to derive function names and result types. +-- In the end of the annotation, :one means single result, :many means multiple results, :exec means no results. + +-- The conventions for function names used in this project are as follows: +-- - "Get" functions return lists of objects as identifiers or minimal metadata, +-- - "Retrieve" functions return single objects with full object data. +-- - "Upsert" functions insert or update objects and return only identifiers or minimal metadata. +-- - "Delete" functions delete objects and return no data. +-- - "Link..." and "Unlink..." functions create or remove associations between objects. +-- - "Is..." functions return boolean values. +-- - "Count..." functions return counts of objects. +-- - "...All..." functions return all objects of a type without filtering (or perform an action on all records). +-- - "...By..." functions return objects filtered by some association. + + +-- === USERS === + + -- name: UpsertUser :one INSERT INTO users ( - "user_handle", "name", "email", "vdb_api_key", "created_at", "updated_at" + "user_handle", "name", "email", "vdb_key", "created_at", "updated_at" ) VALUES ( --- $1, $2, $3, (decode(sqlc.arg(vdb_api_key)::bytea, 'hex')), NOW(), NOW() $1, $2, $3, $4, NOW(), NOW() ) ON CONFLICT ("user_handle") DO UPDATE SET - "name" = $2, - "email" = $3, - "vdb_api_key" = $4, + "name" = EXCLUDED."name", + "email" = EXCLUDED."email", + "vdb_key" = EXCLUDED."vdb_key", "updated_at" = NOW() -RETURNING *; +RETURNING users."user_handle"; -- name: DeleteUser :exec DELETE @@ -25,7 +43,7 @@ SELECT * FROM users WHERE "user_handle" = $1 LIMIT 1; --- name: GetUsers :many +-- name: GetAllUsers :many SELECT "user_handle" FROM users ORDER BY "user_handle" ASC LIMIT $1 OFFSET $2; @@ -39,14 +57,17 @@ WHERE projects."owner" = $1 AND projects."project_handle" = $2 ORDER BY users."user_handle" ASC LIMIT $3 OFFSET $4; -- name: GetKeyByUser :one -SELECT "vdb_api_key" +SELECT "vdb_key" FROM users --- SELECT encode("vdb_api_key", 'hex') AS "vdb_api_key" FROM users WHERE "user_handle" = $1 LIMIT 1; --- name: GetKeysByLinkedUsers :many -SELECT users."user_handle", users_projects."role", users."vdb_api_key" --- SELECT users."user_handle", users_projects."role", encode(users."vdb_api_key", 'hex') AS "vdb_api_key" +-- name: GetUserByVDBKey :one +SELECT "user_handle" +FROM users +WHERE "vdb_key" = $1 LIMIT 1; + +-- name: GetKeysByProject :many +SELECT users."user_handle", users_projects."role", users."vdb_key" FROM users JOIN users_projects ON users."user_handle" = users_projects."user_handle" @@ -56,17 +77,44 @@ WHERE projects."owner" = $1 AND projects."project_handle" = $2 ORDER BY users."user_handle" ASC LIMIT $3 OFFSET $4; +-- name: GetKeysByDefinition :many +SELECT users."user_handle", users."vdb_key" +FROM users +JOIN definitions_shared_with +ON users."user_handle" = definitions_shared_with."user_handle" +JOIN definitions +ON definitions_shared_with."definition_id" = definitions."definition_id" +WHERE definitions."owner" = $1 +AND definitions."definition_handle" = $2 +ORDER BY users."user_handle" ASC LIMIT $3 OFFSET $4; + +-- name: GetKeysByInstance :many +SELECT users."user_handle", instances_shared_with."role", users."vdb_key" +FROM users +JOIN instances_shared_with +ON users."user_handle" = instances_shared_with."user_handle" +JOIN instances +ON instances_shared_with."instance_id" = instances."instance_id" +WHERE instances."owner" = $1 +AND instances."instance_handle" = $2 +ORDER BY users."user_handle" ASC LIMIT $3 OFFSET $4; + + +-- === PROJECTS === + + -- name: UpsertProject :one INSERT INTO projects ( - "project_handle", "owner", "description", "metadata_scheme", "public_read", "created_at", "updated_at" + "project_handle", "owner", "description", "metadata_scheme", "public_read", "instance_id", "created_at", "updated_at" ) VALUES ( - $1, $2, $3, $4, $5, NOW(), NOW() + $1, $2, $3, $4, $5, $6, NOW(), NOW() ) ON CONFLICT ("owner", "project_handle") DO UPDATE SET - "description" = $3, - "metadata_scheme" = $4, - "public_read" = $5, + "description" = EXCLUDED."description", + "metadata_scheme" = EXCLUDED."metadata_scheme", + "public_read" = EXCLUDED."public_read", + "instance_id" = EXCLUDED."instance_id", "updated_at" = NOW() RETURNING "project_id", "owner", "project_handle"; @@ -77,13 +125,34 @@ WHERE "owner" = $1 AND "project_handle" = $2; -- name: GetProjectsByUser :many -SELECT projects.*, users_projects."role" +SELECT projects."owner", + projects."project_handle", + projects."project_id", + projects."public_read", + users_projects."role" FROM projects JOIN users_projects ON projects."project_id" = users_projects."project_id" WHERE users_projects."user_handle" = $1 ORDER BY projects."project_handle" ASC LIMIT $2 OFFSET $3; +-- name: GetAccessibleProjectsByUser :many +SELECT projects."owner", + projects."project_handle", + projects."project_id", + projects."public_read", + CASE + WHEN projects."owner" = $1 THEN 'owner' + ELSE users_projects."role" + END AS "role" +FROM projects +LEFT JOIN users_projects +ON projects."project_id" = users_projects."project_id" +WHERE users_projects."user_handle" = $1 +OR projects."owner" = $1 +ORDER BY projects."owner" ASC +LIMIT $2 OFFSET $3; + -- name: RetrieveProject :one SELECT * FROM projects @@ -91,6 +160,16 @@ WHERE "owner" = $1 AND "project_handle" = $2 LIMIT 1; +-- name: RetrieveProjectForUser :one +SELECT projects.*, users_projects."role" +FROM projects +LEFT JOIN users_projects +ON projects."project_id" = users_projects."project_id" +WHERE projects."owner" = $1 +AND projects."project_handle" = $2 +AND (users_projects."user_handle" = $3 OR projects."public_read" = TRUE) +LIMIT 1; + -- name: IsProjectPubliclyReadable :one SELECT "public_read" FROM projects @@ -99,10 +178,14 @@ AND "project_handle" = $2 LIMIT 1; -- name: GetAllProjects :many -SELECT * +SELECT projects."owner", projects."project_handle" FROM projects ORDER BY "owner" ASC, "project_handle" ASC; +-- name: CountAllProjects :one +SELECT COUNT(*) +FROM projects; + -- name: LinkProjectToUser :one INSERT INTO users_projects ( @@ -111,97 +194,386 @@ INTO users_projects ( $1, $2, $3, NOW(), NOW() ) ON CONFLICT ("user_handle", "project_id") DO UPDATE SET - "role" = $3, + "role" = EXCLUDED."role", "updated_at" = NOW() -RETURNING *; +RETURNING users_projects."user_handle", users_projects."project_id"; + +-- name: UnlinkProjectFromUser :exec +DELETE +FROM users_projects +WHERE "user_handle" = $1 +AND "project_id" = $2; --- name: UpsertLLM :one +-- === LLM Service Definitions (user-shared templates) === + + +-- name: UpsertDefinition :one INSERT -INTO llm_services ( - "owner", "llm_service_handle", "endpoint", "description", "api_key", "api_standard", "model", "dimensions", "created_at", "updated_at" +INTO definitions ( + "owner", "definition_handle", "endpoint", "description", "api_standard", "model", "dimensions", "context_limit", "is_public", "created_at", "updated_at" ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW() + $1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW() ) -ON CONFLICT ("owner", "llm_service_handle") DO UPDATE SET - "endpoint" = $3, - "description" = $4, - "api_key" = $5, - "api_standard" = $6, - "model" = $7, - "dimensions" = $8, +ON CONFLICT ("owner", "definition_handle") DO UPDATE SET + "endpoint" = EXCLUDED."endpoint", + "description" = EXCLUDED."description", + "api_standard" = EXCLUDED."api_standard", + "model" = EXCLUDED."model", + "dimensions" = EXCLUDED."dimensions", + "context_limit" = EXCLUDED."context_limit", + "is_public" = EXCLUDED."is_public", "updated_at" = NOW() -RETURNING "owner", "llm_service_handle", "llm_service_id"; +RETURNING "owner", "definition_handle", "definition_id", "is_public"; --- name: DeleteLLM :exec +-- name: DeleteDefinition :exec DELETE -FROM llm_services +FROM definitions WHERE "owner" = $1 -AND "llm_service_handle" = $2; +AND "definition_handle" = $2; --- name: RetrieveLLM :one +-- name: RetrieveDefinition :one SELECT * -FROM llm_services +FROM definitions WHERE "owner" = $1 -AND "llm_service_handle" = $2 +AND "definition_handle" = $2 LIMIT 1; --- name: LinkUserToLLM :exec +-- name: GetDefinitionsByUser :many +SELECT definitions."definition_handle", definitions."definition_id" +FROM definitions +WHERE "owner" = $1 +ORDER BY "definition_handle" ASC LIMIT $2 OFFSET $3; + +-- name: GetAllDefinitions :many +SELECT definitions."owner", definitions."definition_handle", definitions."definition_id" +FROM definitions +ORDER BY "owner" ASC, "definition_handle" ASC LIMIT $1 OFFSET $2; + +-- name: GetSystemDefinitions :many +SELECT definitions."definition_handle", definitions."definition_id" +FROM definitions +WHERE "owner" = '_system' +ORDER BY "definition_handle" ASC LIMIT $1 OFFSET $2; + +-- name: LinkDefinitionToUser :exec INSERT -INTO users_llm_services ( - "user_handle", "llm_service_id", "role", "created_at", "updated_at" +INTO definitions_shared_with ( + "user_handle", "definition_id", "created_at", "updated_at" ) VALUES ( - $1, $2, $3, NOW(), NOW() + $1, $2, NOW(), NOW() ) -ON CONFLICT ("user_handle", "llm_service_id") DO UPDATE SET - "role" = $3, - "updated_at" = NOW() -RETURNING *; +ON CONFLICT ("user_handle", "definition_id") DO NOTHING; + +-- name: UnlinkDefinition :exec +DELETE +FROM definitions_shared_with +WHERE "user_handle" = $1 +AND "definition_id" = $2; + +-- name: GetSharedUsersForDefinition :many +SELECT definitions_shared_with."user_handle" +FROM definitions_shared_with +JOIN definitions +ON definitions."definition_id" = definitions_shared_with."definition_id" +WHERE definitions."owner" = $1 + AND definitions."definition_handle" = $2 + AND definitions_shared_with."user_handle" != '*' +ORDER BY "user_handle" ASC; + +-- name: GetAccessibleDefinitionsByUser :many +-- Get all definitions accessible to a user (owned + shared + _system) +-- Returns definitions with metadata indicating ownership +SELECT definitions."owner", + definitions."definition_handle", + definitions."definition_id", + definitions."is_public" +FROM definitions +LEFT JOIN definitions_shared_with + ON definitions."definition_id" = definitions_shared_with."definition_id" +WHERE definitions."owner" = $1 + OR definitions."owner" = '_system' + OR definitions_shared_with."user_handle" = $1 +ORDER BY definitions."owner" ASC, definitions."definition_handle" ASC +LIMIT $2 OFFSET $3; + --- name: LinkProjectToLLM :exec +-- === LLM Service Instances (user-specific instances with optional API keys) === + + +-- name: UpsertInstance :one INSERT -INTO projects_llm_services ( - "project_id", "llm_service_id", "created_at", "updated_at" +INTO instances ( + "owner", "instance_handle", "definition_id", "endpoint", "description", "api_key_encrypted", "api_standard", "model", "dimensions", "context_limit", "created_at", "updated_at" ) VALUES ( - $1, $2, NOW(), NOW() + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW() ) -ON CONFLICT ("project_id", "llm_service_id") DO NOTHING -RETURNING *; - --- name: GetLLMsByProject :many -SELECT llm_services.* -FROM llm_services -JOIN ( - projects_llm_services JOIN projects - ON projects_llm_services."project_id" = projects."project_id" +ON CONFLICT ("owner", "instance_handle") DO UPDATE SET + "definition_id" = EXCLUDED."definition_id", + "endpoint" = EXCLUDED."endpoint", + "description" = EXCLUDED."description", + "api_key_encrypted" = EXCLUDED."api_key_encrypted", + "api_standard" = EXCLUDED."api_standard", + "model" = EXCLUDED."model", + "dimensions" = EXCLUDED."dimensions", + "context_limit" = EXCLUDED."context_limit", + "updated_at" = NOW() +RETURNING "owner", "instance_handle", "instance_id"; + +-- name: UpsertInstanceFromDefinition :one +INSERT +INTO instances ( + "owner", "instance_handle", "definition_id", "endpoint", "description", "api_key_encrypted", "api_standard", "model", "dimensions", "context_limit", "created_at", "updated_at" ) -ON llm_services."llm_service_id" = projects_llm_services."llm_service_id" +SELECT + $1 as "owner", + $2 as "instance_handle", + def."definition_id", + COALESCE($3, def."endpoint") as "endpoint", + COALESCE($4, def."description") as "description", + $5 as "api_key_encrypted", + COALESCE($6, def."api_standard") as "api_standard", + COALESCE($7, def."model") as "model", + COALESCE($8::INTEGER, def."dimensions") as "dimensions", + COALESCE($9::INTEGER, def."context_limit") as "context_limit", + NOW() as "created_at", + NOW() as "updated_at" +FROM definitions def +WHERE def."owner" = $9 AND def."definition_handle" = $10 +ON CONFLICT ("owner", "instance_handle") DO UPDATE SET + "definition_id" = EXCLUDED."definition_id", + "endpoint" = EXCLUDED."endpoint", + "description" = EXCLUDED."description", + "api_key_encrypted" = EXCLUDED."api_key_encrypted", + "api_standard" = EXCLUDED."api_standard", + "model" = EXCLUDED."model", + "dimensions" = EXCLUDED."dimensions", + "context_limit" = EXCLUDED."context_limit", + "updated_at" = NOW() +RETURNING "owner", "instance_handle", "instance_id"; + +-- name: DeleteInstance :exec +DELETE +FROM instances +WHERE "owner" = $1 +AND "instance_handle" = $2; + +-- name: RetrieveInstance :one +SELECT instances."owner", + instances."instance_handle", + instances."instance_id", + instances."definition_id", + definitions."owner" AS "definition_owner", + definitions."definition_handle" AS "definition_handle", + instances."endpoint", + instances."description", + -- when api_key_encrypted is not null, return true + CASE + WHEN instances."api_key_encrypted" IS NULL THEN TRUE + ELSE FALSE + END AS "has_api_key", + instances."api_standard", + instances."model", + instances."dimensions", + instances."context_limit", + instances."created_at", + instances."updated_at" +FROM instances +LEFT JOIN definitions +ON instances."definition_id" = definitions."definition_id" +WHERE instances."owner" = $1 +AND instances."instance_handle" = $2 +LIMIT 1; + +-- name: RetrieveInstanceByID :one +SELECT instances."owner", + instances."instance_handle", + instances."instance_id", + instances."definition_id", + instances."endpoint", + instances."description", + instances."api_standard", + instances."model", + instances."dimensions", + instances."context_limit", + instances."created_at", + instances."updated_at" +FROM instances +WHERE "instance_id" = $1 +LIMIT 1; + +-- name: RetrieveInstanceByProject :one +SELECT instances."owner", + instances."instance_handle", + instances."instance_id", + instances."definition_id", + instances."endpoint", + instances."description", + instances."api_standard", + instances."model", + instances."dimensions", + instances."context_limit", + instances."created_at", + instances."updated_at" +FROM instances +JOIN projects +ON projects."instance_id" = instances."instance_id" +WHERE projects."owner" = $1 + AND projects."project_handle" = $2 +LIMIT 1; + +-- name: RetrieveInstanceByProjectForUser :one +SELECT instances."owner", + instances."instance_handle", + instances."instance_id", + instances."definition_id", + instances."endpoint", + instances."description", + instances."api_standard", + instances."model", + instances."dimensions", + instances."context_limit", + instances."created_at", + instances."updated_at", + instances_shared_with."role" AS "access_role" +FROM instances +JOIN projects +ON projects."instance_id" = instances."instance_id" +LEFT JOIN instances_shared_with +ON instances_shared_with."instance_id" = instances."instance_id" WHERE projects."owner" = $1 AND projects."project_handle" = $2 -ORDER BY llm_services."llm_service_handle" ASC LIMIT $3 OFFSET $4; + AND instances_shared_with."user_handle" = $3 +LIMIT 1; + +-- name: RetrieveInstanceByProjectID :one +SELECT instances."owner", + instances."instance_handle", + instances."instance_id", + instances."definition_id", + instances."endpoint", + instances."description", + instances."api_standard", + instances."model", + instances."dimensions", + instances."context_limit", + instances."created_at", + instances."updated_at" +FROM instances +JOIN projects +ON projects."instance_id" = instances."instance_id" +WHERE projects."project_id" = $1 +LIMIT 1; + +-- name: LinkInstanceToUser :exec +INSERT +INTO instances_shared_with ( + "user_handle", "instance_id", "role", "created_at", "updated_at" +) VALUES ( + $1, $2, $3, NOW(), NOW() +) +ON CONFLICT ("user_handle", "instance_id") DO UPDATE SET + "role" = EXCLUDED."role", + "updated_at" = NOW(); + +-- name: UnlinkInstance :exec +DELETE +FROM instances_shared_with +WHERE "user_handle" = $1 +AND "instance_id" = $2; + +-- name: GetSharedUsersForInstance :many +SELECT instances_shared_with."user_handle", + instances_shared_with."role" +FROM instances_shared_with +JOIN instances +ON instances."instance_id" = instances_shared_with."instance_id" +WHERE instances."owner" = $1 + AND instances."instance_handle" = $2 +ORDER BY "user_handle" ASC; + +-- name: RetrieveSharedInstance :one +-- Get single instance, but only if it is shared with requesting user +SELECT instances."owner", + instances."instance_handle", + instances."definition_id", + instances."endpoint", + instances."description", + instances."api_standard", + instances."model", + instances."dimensions", + instances."context_limit", + instances."created_at", + instances."updated_at" +FROM instances +JOIN instances_shared_with +ON instances."instance_id" = instances_shared_with."instance_id" +WHERE (instances_shared_with."user_handle" = $1 AND instances."owner" = $2 AND instances."instance_handle" = $3) +LIMIT 1; + +-- Get all instances owned by a user +-- name: GetInstancesByUser :many +SELECT instances."owner", + instances."instance_handle", + instances."instance_id" +FROM instances +WHERE instances."owner" = $1 +ORDER BY instances."instance_handle" ASC LIMIT $2 OFFSET $3; + +-- name: GetSharedInstancesByUser :many +SELECT instances."owner", + instances."instance_handle", + instances."instance_id", + instances_shared_with."role" +FROM instances +JOIN instances_shared_with +ON instances."instance_id" = instances_shared_with."instance_id" +WHERE instances_shared_with."user_handle" = $1 +ORDER BY instances_shared_with."role" ASC, instances."owner" ASC, instances."instance_handle" ASC +LIMIT $2 OFFSET $3; + +-- name: GetAccessibleInstancesByUser :many +-- Get all instances accessible to a user (owned + shared) +-- Returns instances with metadata indicating ownership +SELECT instances."owner", + instances."instance_handle", + instances."instance_id", + CASE + WHEN instances."owner" = $1 THEN 'owner' + ELSE instances_shared_with."role" + END as "role", + instances."owner" = $1 as "is_owner" +FROM instances +LEFT JOIN instances_shared_with + ON instances."instance_id" = instances_shared_with."instance_id" +WHERE instances."owner" = $1 + OR instances_shared_with."user_handle" = $1 +ORDER BY instances."owner" ASC, instances."instance_handle" ASC +LIMIT $2 OFFSET $3; + +-- name: CountInstancesByUser :one +SELECT COUNT(*) +FROM instances +WHERE "owner" = $1; + + +-- === EMBEDDINGS === --- name: GetLLMsByUser :many -SELECT llm_services.*, users_llm_services."role" -FROM llm_services -JOIN users_llm_services -ON llm_services."llm_service_id" = users_llm_services."llm_service_id" -WHERE users_llm_services."user_handle" = $1 -ORDER BY llm_services."llm_service_handle" ASC LIMIT $2 OFFSET $3; -- name: UpsertEmbeddings :one INSERT INTO embeddings ( - "text_id", "owner", "project_id", "llm_service_id", "text", "vector", "vector_dim", "metadata", "created_at", "updated_at" + "text_id", "owner", "project_id", "instance_id", "text", "vector", "vector_dim", "metadata", "created_at", "updated_at" ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW() ) -ON CONFLICT ("text_id", "owner", "project_id", "llm_service_id") DO UPDATE SET +ON CONFLICT ("text_id", "owner", "project_id", "instance_id") DO UPDATE SET "text" = $5, "vector" = $6, "vector_dim" = $7, "metadata" = $8, "updated_at" = NOW() -RETURNING "embeddings_id", "text_id", "owner", "project_id", "llm_service_id"; +RETURNING "embeddings_id", "text_id", "owner", "project_id", "instance_id"; -- name: DeleteEmbeddingsByID :exec DELETE @@ -218,7 +590,7 @@ WHERE embeddings."owner" = $1 AND embeddings."project_id" = e."project_id" AND p."project_handle" = $2; --- name: DeleteDocEmbeddings :exec +-- name: DeleteEmbeddingsByDocID :exec DELETE FROM embeddings e USING projects p WHERE e."owner" = $1 @@ -227,10 +599,10 @@ WHERE e."owner" = $1 AND e."text_id" = $3; -- name: RetrieveEmbeddings :one -SELECT embeddings.*, projects."project_handle", llm_services."llm_service_handle" +SELECT embeddings.*, projects."project_handle", instances."instance_handle" FROM embeddings -JOIN llm_services -ON embeddings."llm_service_id" = llm_services."llm_service_id" +JOIN instances +ON embeddings."instance_id" = instances."instance_id" JOIN projects ON embeddings."project_id" = projects."project_id" WHERE embeddings."owner" = $1 @@ -238,18 +610,28 @@ AND projects."project_handle" = $2 AND embeddings."text_id" = $3 LIMIT 1; +-- name: RetrieveEmbeddingsByID :one +SELECT embeddings.*, projects."project_handle", instances."instance_handle" +FROM embeddings +JOIN instances +ON embeddings."instance_id" = instances."instance_id" +JOIN projects +ON embeddings."project_id" = projects."project_id" +WHERE embeddings."embeddings_id" = $1 +LIMIT 1; + -- name: GetEmbeddingsByProject :many -SELECT embeddings.*, projects."project_handle", llm_services."llm_service_handle" +SELECT embeddings."embeddings_id", embeddings."text_id", projects."owner", projects."project_handle", instances."instance_handle" FROM embeddings -JOIN llm_services -ON llm_services."llm_service_id" = embeddings."llm_service_id" +JOIN instances +ON instances."instance_id" = embeddings."instance_id" JOIN projects ON projects."project_id" = embeddings."project_id" WHERE embeddings."owner" = $1 AND projects."project_handle" = $2 ORDER BY embeddings."text_id" ASC LIMIT $3 OFFSET $4; --- name: GetNumberOfEmbeddingsByProject :one +-- name: CountEmbeddingsByProject :one SELECT COUNT(*) FROM embeddings JOIN projects @@ -257,43 +639,19 @@ ON embeddings."project_id" = projects."project_id" WHERE embeddings."owner" = $1 AND projects."project_handle" = $2; +-- name: CountAllEmbeddings :one +SELECT COUNT(*) +FROM embeddings; --- name: UpsertAPIStandard :one -INSERT -INTO api_standards ( - "api_standard_handle", "description", "key_method", "key_field", "created_at", "updated_at" -) VALUES ( - $1, $2, $3, $4, NOW(), NOW() -) -ON CONFLICT ("api_standard_handle") DO UPDATE SET - "description" = $2, - "key_method" = $3, - "key_field" = $4, - "updated_at" = NOW() -RETURNING "api_standard_handle"; - --- name: DeleteAPIStandard :exec -DELETE -FROM api_standards -WHERE "api_standard_handle" = $1; - --- name: RetrieveAPIStandard :one -SELECT * -FROM api_standards -WHERE "api_standard_handle" = $1 LIMIT 1; - --- name: GetAPIStandards :many -SELECT * -FROM api_standards -ORDER BY "api_standard_handle" ASC LIMIT $1 OFFSET $2; +-- === SIMILARITY SEARCH === -- name: GetSimilarsByVector :many -SELECT embeddings."embeddings_id", embeddings."text_id", llm_services."owner", llm_services."llm_service_handle" +SELECT embeddings."embeddings_id", embeddings."text_id", instances."owner", instances."instance_handle" FROM embeddings -JOIN llm_services -ON embeddings."llm_service_id" = llm_services."llm_service_id" +JOIN instances +ON embeddings."instance_id" = instances."instance_id" ORDER BY "vector" <=> $1 LIMIT $2 OFFSET $3; @@ -331,17 +689,70 @@ ORDER BY e1.vector <=> e2.vector LIMIT $7 OFFSET $8; +-- === API STANDARDS === + + +-- name: UpsertAPIStandard :one +INSERT +INTO api_standards ( + "api_standard_handle", "description", "key_method", "key_field", "created_at", "updated_at" +) VALUES ( + $1, $2, $3, $4, NOW(), NOW() +) +ON CONFLICT ("api_standard_handle") DO UPDATE SET + "description" = $2, + "key_method" = $3, + "key_field" = $4, + "updated_at" = NOW() +RETURNING "api_standard_handle"; + +-- name: DeleteAPIStandard :exec +DELETE +FROM api_standards +WHERE "api_standard_handle" = $1; + +-- name: RetrieveAPIStandard :one +SELECT * +FROM api_standards +WHERE "api_standard_handle" = $1 LIMIT 1; + +-- name: GetAPIStandards :many +SELECT api_standards."api_standard_handle" +FROM api_standards +ORDER BY "api_standard_handle" ASC LIMIT $1 OFFSET $2; + +-- name: CheckIfAPIStandardInUse :one +SELECT EXISTS ( + SELECT 1 + FROM definitions + WHERE "api_standard" = $1 + LIMIT 1 +); + +-- === ADMIN QUERIES === + + -- name: ResetAllSerials :exec DO $$ DECLARE seq_name text; + max_id bigint; BEGIN FOR seq_name IN SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public' AND sequence_name LIKE '%_seq' LOOP - EXECUTE format('ALTER SEQUENCE public.%I RESTART WITH 1', seq_name); + -- For definitions table, set sequence to max preserved definition_id + 1 + IF seq_name = 'definitions_definition_id_seq' THEN + SELECT COALESCE(MAX("definition_id"), 0) INTO max_id + FROM definitions + WHERE "owner" = '_system'; + EXECUTE format('ALTER SEQUENCE public.%I RESTART WITH %s', seq_name, max_id + 1); + -- For all other sequences, reset to 1 + ELSE + EXECUTE format('ALTER SEQUENCE public.%I RESTART WITH 1', seq_name); + END IF; END LOOP; END $$; @@ -354,8 +765,15 @@ BEGIN SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' - AND table_name NOT IN ('key_methods', 'vdb_roles') + AND table_name NOT IN ('key_methods', 'vdb_roles', 'api_standards') -- preserve static reference data LOOP - EXECUTE format('DELETE FROM %I;', r.table_name); + -- Preserve _system user and its definitions + IF r.table_name = 'users' THEN + EXECUTE format('DELETE FROM %I WHERE "user_handle" != ''_system'';', r.table_name); + ELSIF r.table_name = 'definitions' THEN + EXECUTE format('DELETE FROM %I WHERE "owner" != ''_system'';', r.table_name); + ELSE + EXECUTE format('DELETE FROM %I;', r.table_name); + END IF; END LOOP; END $$; diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go index 83e6e85..e03d0b6 100644 --- a/internal/handlers/admin.go +++ b/internal/handlers/admin.go @@ -13,6 +13,7 @@ import ( ) func resetDbFunc(ctx context.Context, input *models.ResetDbRequest) (*models.ResetDbResponse, error) { + // Get the database connection pool from the context pool, err := GetDBPool(ctx) if err != nil { @@ -22,7 +23,6 @@ func resetDbFunc(ctx context.Context, input *models.ResetDbRequest) (*models.Res fmt.Print(" Resetting Database: database connection pool is nil\n") return nil, huma.Error500InternalServerError("database connection pool is nil") } - queries := database.New(pool) // delete all records @@ -53,7 +53,7 @@ func sanityCheckFunc(ctx context.Context, input *models.SanityCheckRequest) (*mo } queries := database.New(pool) - + // Get all projects with their metadata schemes projects, err := queries.GetAllProjects(ctx) if err != nil { @@ -64,32 +64,33 @@ func sanityCheckFunc(ctx context.Context, input *models.SanityCheckRequest) (*mo var warnings []string // Check each project - for _, project := range projects { + for _, p := range projects { + project, err := queries.RetrieveProject(ctx, database.RetrieveProjectParams(p)) + if err != nil { + issues = append(issues, fmt.Sprintf("Project %s/%s: unable to retrieve project: %v", p.Owner, p.ProjectHandle, err)) + continue + } projectName := fmt.Sprintf("%s/%s", project.Owner, project.ProjectHandle) - - // Get all LLM services for this project - llmServices, err := queries.GetLLMsByProject(ctx, database.GetLLMsByProjectParams{ + + // Get the LLM service instance for this project (1:1 relationship) + instance, err := queries.RetrieveInstanceByProject(ctx, database.RetrieveInstanceByProjectParams{ Owner: project.Owner, ProjectHandle: project.ProjectHandle, - Limit: 999, - Offset: 0, }) if err != nil { - issues = append(issues, fmt.Sprintf("Project %s: unable to get LLM services: %v", projectName, err)) + issues = append(issues, fmt.Sprintf("Project %s: unable to get LLM service instance: %v", projectName, err)) continue } - // Create a map of LLM service dimensions + // Create a map with the single LLM service instance llmDimensions := make(map[int32]int32) - for _, llm := range llmServices { - llmDimensions[llm.LLMServiceID] = llm.Dimensions - } + llmDimensions[instance.InstanceID] = instance.Dimensions // Get all embeddings for this project embeddings, err := queries.GetEmbeddingsByProject(ctx, database.GetEmbeddingsByProjectParams{ Owner: project.Owner, ProjectHandle: project.ProjectHandle, - Limit: 99999, + Limit: 9999999, Offset: 0, }) if err != nil { @@ -98,14 +99,20 @@ func sanityCheckFunc(ctx context.Context, input *models.SanityCheckRequest) (*mo } // Check each embedding - for _, embedding := range embeddings { + for _, e := range embeddings { + embedding, err := queries.RetrieveEmbeddingsByID(ctx, e.EmbeddingsID) + if err != nil { + issues = append(issues, fmt.Sprintf("Project %s, embedding ID %d: unable to retrieve embedding: %v", + projectName, e.EmbeddingsID, err)) + continue + } textID := embedding.TextID.String - + // Check dimension consistency - expectedDim, ok := llmDimensions[embedding.LLMServiceID] + expectedDim, ok := llmDimensions[embedding.InstanceID] if !ok { - issues = append(issues, fmt.Sprintf("Project %s, text_id '%s': LLM service ID %d not found", - projectName, textID, embedding.LLMServiceID)) + issues = append(issues, fmt.Sprintf("Project %s, text_id '%s': LLM service ID %d not found", + projectName, textID, embedding.InstanceID)) continue } @@ -124,7 +131,7 @@ func sanityCheckFunc(ctx context.Context, input *models.SanityCheckRequest) (*mo // Warn if project has embeddings but no metadata scheme defined if len(embeddings) > 0 && (!project.MetadataScheme.Valid || project.MetadataScheme.String == "") { - warnings = append(warnings, fmt.Sprintf("Project %s has %d embeddings but no metadata schema defined", + warnings = append(warnings, fmt.Sprintf("Project %s has %d embeddings but no metadata schema defined", projectName, len(embeddings))) } } @@ -165,7 +172,6 @@ func RegisterAdminRoutes(pool *pgxpool.Pool, api huma.API) error { // Middlewares Middlewares `yaml:"-"` // Middleware to run before the operation, useful for logging, etc. Tags: []string{"admin"}, } - sanityCheckOp := huma.Operation{ OperationID: "sanityCheck", Method: http.MethodGet, diff --git a/internal/handlers/admin_test.go b/internal/handlers/admin_test.go index 440cb26..d9a99a2 100644 --- a/internal/handlers/admin_test.go +++ b/internal/handlers/admin_test.go @@ -14,6 +14,7 @@ import ( ) func TestAdminFunc(t *testing.T) { + // Get the database connection pool from package variable pool := connPool @@ -29,7 +30,7 @@ func TestAdminFunc(t *testing.T) { method string requestPath string bodyPath string - apiKey string + VDBKey string expectBody string expectStatus int16 }{ @@ -38,7 +39,7 @@ func TestAdminFunc(t *testing.T) { method: http.MethodGet, requestPath: "/v1/admin/footgun", bodyPath: "", - apiKey: "", + VDBKey: "", expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Unauthorized\",\n \"status\": 401,\n \"detail\": \"Authentication failed. Perhaps a missing or incorrect API key?\"\n}\n", expectStatus: http.StatusUnauthorized, }, @@ -47,7 +48,7 @@ func TestAdminFunc(t *testing.T) { method: http.MethodGet, requestPath: "/v1/admin/footgun", bodyPath: "", - apiKey: options.AdminKey, + VDBKey: options.AdminKey, expectBody: "", expectStatus: http.StatusNoContent, }, @@ -77,7 +78,7 @@ func TestAdminFunc(t *testing.T) { requestURL := fmt.Sprintf("http://%v:%d%v", options.Host, options.Port, v.requestPath) req, err := http.NewRequest(v.method, requestURL, reqBody) assert.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+v.apiKey) + req.Header.Set("Authorization", "Bearer "+v.VDBKey) resp, err := http.DefaultClient.Do(req) if err != nil { t.Errorf("Error sending request: %v\n", err) @@ -112,4 +113,6 @@ func TestAdminFunc(t *testing.T) { shutDownServer() }) + fmt.Printf("\n\n\n\n") + } diff --git a/internal/handlers/api_standards.go b/internal/handlers/api_standards.go index bac5e9c..4caf28e 100644 --- a/internal/handlers/api_standards.go +++ b/internal/handlers/api_standards.go @@ -94,7 +94,11 @@ func getAPIStandardsFunc(ctx context.Context, input *models.GetAPIStandardsReque // Build the response standards := []models.APIStandard{} - for _, a := range allAPIStandards { + for _, api := range allAPIStandards { + a, err := queries.RetrieveAPIStandard(ctx, api) + if err != nil { + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get API standard data for standard %s. %v", api, err)) + } standard := models.APIStandard{ APIStandardHandle: a.APIStandardHandle, Description: a.Description.String, @@ -163,6 +167,15 @@ func deleteAPIStandardFunc(ctx context.Context, input *models.DeleteAPIStandardR return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to check if API standard %s exists before deleting. %v", input.APIStandardHandle, err)) } + // Check if API standard is still in use by any LLM service definitions and prevent deletion if so, or cascade delete, depending on desired behavior and use cases. For now, we just return an error if the standard is in use. + inUse, err := queries.CheckIfAPIStandardInUse(ctx, input.APIStandardHandle) + if err != nil { + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to check if API standard %s is in use before deleting. %v", input.APIStandardHandle, err)) + } + if inUse { + return nil, huma.Error400BadRequest(fmt.Sprintf("cannot delete API standard %s because it is still in use by one or more LLM service definitions. Please update or delete those definitions first.", input.APIStandardHandle)) + } + // Run the query err = queries.DeleteAPIStandard(ctx, input.APIStandardHandle) if err != nil { diff --git a/internal/handlers/api_standards_test.go b/internal/handlers/api_standards_test.go index 60df990..02629ef 100644 --- a/internal/handlers/api_standards_test.go +++ b/internal/handlers/api_standards_test.go @@ -14,6 +14,7 @@ import ( ) func TestAPIStandardFunc(t *testing.T) { + // Get the database connection pool from package variable pool := connPool @@ -29,7 +30,7 @@ func TestAPIStandardFunc(t *testing.T) { method string requestPath string bodyPath string - apiKey string + VDBKey string expectBody string expectStatus int16 }{ @@ -38,7 +39,7 @@ func TestAPIStandardFunc(t *testing.T) { method: http.MethodPut, requestPath: "/v1/api-standards/error1", bodyPath: "../../testdata/invalid_api_standard.json", - apiKey: options.AdminKey, + VDBKey: options.AdminKey, expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"validation failed\",\n \"errors\": [\n {\n \"message\": \"expected required property key_field to be present\",\n \"location\": \"body\",\n \"value\": {\n \"api_standard_handle\": \"error1\",\n \"description\": \"Erroneous definition of an APi standard\",\n \"keX_method\": \"auth_bearer\"\n }\n },\n {\n \"message\": \"expected required property key_method to be present\",\n \"location\": \"body\",\n \"value\": {\n \"api_standard_handle\": \"error1\",\n \"description\": \"Erroneous definition of an APi standard\",\n \"keX_method\": \"auth_bearer\"\n }\n },\n {\n \"message\": \"unexpected property\",\n \"location\": \"body.keX_method\",\n \"value\": {\n \"api_standard_handle\": \"error1\",\n \"description\": \"Erroneous definition of an APi standard\",\n \"keX_method\": \"auth_bearer\"\n }\n }\n ]\n}\n", expectStatus: http.StatusUnprocessableEntity, }, @@ -47,47 +48,47 @@ func TestAPIStandardFunc(t *testing.T) { method: http.MethodPut, requestPath: "/v1/api-standards/wrongpath", bodyPath: "../../testdata/valid_api_standard_openai_v1.json", - apiKey: options.AdminKey, + VDBKey: options.AdminKey, expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Bad Request\",\n \"status\": 400,\n \"detail\": \"API standard handle in URL (wrongpath) does not match handle in body (openai).\"\n}\n", expectStatus: http.StatusBadRequest, }, { name: "Valid Put API standard", method: http.MethodPut, - requestPath: "/v1/api-standards/openai", - bodyPath: "../../testdata/valid_api_standard_openai_v1.json", - apiKey: options.AdminKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/UploadAPIStandardResponseBody.json\",\n \"api_standard_handle\": \"openai\"\n}\n", + requestPath: "/v1/api-standards/test", + bodyPath: "../../testdata/valid_api_standard_test.json", + VDBKey: options.AdminKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/UploadAPIStandardResponseBody.json\",\n \"api_standard_handle\": \"test\"\n}\n", expectStatus: http.StatusCreated, }, { name: "get all API standards", method: http.MethodGet, requestPath: "/v1/api-standards", - apiKey: "", - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetAPIStandardsResponseBody.json\",\n \"api_standards\": [\n {\n \"api_standard_handle\": \"openai\",\n \"description\": \"OpenAI Embeddings API, Version 1, as documented in https://platform.openai.com/docs/api-reference/embeddings\",\n \"key_method\": \"auth_bearer\",\n \"key_field\": \"Authorization\"\n }\n ]\n}\n", + VDBKey: "", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetAPIStandardsResponseBody.json\",\n \"api_standards\": [\n {\n \"api_standard_handle\": \"cohere\",\n \"description\": \"Cohere Embed API, Version 2, as documented in https://docs.cohere.com/reference/embed\",\n \"key_method\": \"auth_bearer\",\n \"key_field\": \"Authorization\"\n },\n {\n \"api_standard_handle\": \"gemini\",\n \"description\": \"Gemini Embeddings API, as documented in https://ai.google.dev/gemini-api/docs/embeddings\",\n \"key_method\": \"auth_bearer\",\n \"key_field\": \"x-goog-api-key\"\n },\n {\n \"api_standard_handle\": \"openai\",\n \"description\": \"OpenAI Embeddings API, Version 1, as documented in https://platform.openai.com/docs/api-reference/embeddings\",\n \"key_method\": \"auth_bearer\",\n \"key_field\": \"Authorization\"\n },\n {\n \"api_standard_handle\": \"test\",\n \"description\": \"OpenAI Embeddings API, Version 1, as documented in https://platform.openai.com/docs/api-reference/embeddings\",\n \"key_method\": \"auth_bearer\",\n \"key_field\": \"Authorization\"\n }\n ]\n}\n", expectStatus: http.StatusOK, }, { name: "get single API standard", method: http.MethodGet, - requestPath: "/v1/api-standards/openai", - apiKey: "", - expectBody: "{\n \"api_standard_handle\": \"openai\",\n \"description\": \"OpenAI Embeddings API, Version 1, as documented in https://platform.openai.com/docs/api-reference/embeddings\",\n \"key_method\": \"auth_bearer\",\n \"key_field\": \"Authorization\"\n}\n", + requestPath: "/v1/api-standards/test", + VDBKey: "", + expectBody: "{\n \"api_standard_handle\": \"test\",\n \"description\": \"OpenAI Embeddings API, Version 1, as documented in https://platform.openai.com/docs/api-reference/embeddings\",\n \"key_method\": \"auth_bearer\",\n \"key_field\": \"Authorization\"\n}\n", expectStatus: http.StatusOK, }, { name: "Delete nonexistent path", method: http.MethodDelete, requestPath: "/v1/api-standards/wrongpath", - apiKey: options.AdminKey, + VDBKey: options.AdminKey, expectStatus: http.StatusNotFound, }, { name: "delete API standard", method: http.MethodDelete, - requestPath: "/v1/api-standards/openai", - apiKey: options.AdminKey, + requestPath: "/v1/api-standards/test", + VDBKey: options.AdminKey, expectStatus: http.StatusNoContent, }, { @@ -95,7 +96,7 @@ func TestAPIStandardFunc(t *testing.T) { method: http.MethodPut, requestPath: "/v1/api-standards/openai", bodyPath: "../../testdata/valid_api_standard_openai_v1.json", - apiKey: options.AdminKey, + VDBKey: options.AdminKey, expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/UploadAPIStandardResponseBody.json\",\n \"api_standard_handle\": \"openai\"\n}\n", expectStatus: http.StatusCreated, }, @@ -125,8 +126,8 @@ func TestAPIStandardFunc(t *testing.T) { requestURL := fmt.Sprintf("http://%v:%d%v", options.Host, options.Port, v.requestPath) req, err := http.NewRequest(v.method, requestURL, reqBody) assert.NoError(t, err) - if v.apiKey != "" { - req.Header.Set("Authorization", "Bearer "+v.apiKey) + if v.VDBKey != "" { + req.Header.Set("Authorization", "Bearer "+v.VDBKey) } resp, err := http.DefaultClient.Do(req) if err != nil { @@ -174,4 +175,5 @@ func TestAPIStandardFunc(t *testing.T) { shutDownServer() }) + fmt.Printf("\n\n\n\n") } diff --git a/internal/handlers/embeddings.go b/internal/handlers/embeddings.go index 648d0b0..c2d7715 100644 --- a/internal/handlers/embeddings.go +++ b/internal/handlers/embeddings.go @@ -44,38 +44,55 @@ func getUserProj(ctx context.Context, user, project string) (string, string, int // Create a new embeddings func postProjEmbeddingsFunc(ctx context.Context, input *models.PostProjEmbeddingsRequest) (*models.UploadProjEmbeddingsResponse, error) { - // Check if user and project exist - _, _, pid, err := getUserProj(ctx, input.UserHandle, input.ProjectHandle) + + // Get the database connection pool from the context + pool, err := GetDBPool(ctx) if err != nil { - return nil, err + return nil, huma.Error500InternalServerError(fmt.Sprintf("Could not acces database connection pool: %v", err)) } + queries := database.New(pool) - // Get project details to access metadata schema - project, err := getProjectFunc(ctx, &models.GetProjectRequest{UserHandle: input.UserHandle, ProjectHandle: input.ProjectHandle}) + // Check if user exists + _, err = queries.RetrieveUser(ctx, input.UserHandle) if err != nil { - return nil, err + return nil, huma.Error404NotFound(fmt.Sprintf("User %s does not exist: %v", input.UserHandle, err)) } - // Check if llm service exists - llm, err := getLLMFunc(ctx, &models.GetLLMRequest{UserHandle: input.UserHandle, LLMServiceHandle: input.Body.Embeddings[0].LLMServiceHandle}) + // Retrieve project details + project, err := queries.RetrieveProject(ctx, database.RetrieveProjectParams{ + Owner: input.UserHandle, + ProjectHandle: input.ProjectHandle, + }) if err != nil { - return nil, err + return nil, huma.Error404NotFound(fmt.Sprintf("Project %s/%s not found: %v", input.UserHandle, input.ProjectHandle, err)) } - llmid := int32(llm.Body.LLMServiceID) - // Get the database connection pool from the context - pool, err := GetDBPool(ctx) + fmt.Printf("Found project %s ...", project.ProjectHandle) + + // Even though each of the submitted embeddings specifies an instance handle, + // we may postulate that only one instance is involved: the one that is + // connected to the project (every project has exactly one instance) passed + // in the request's URL path. Thus, we verify that it exists only once. + // Rather than checking for each embeddings' instance whether it exists and + // the current user can read it, we just validate that the instance handle + // specified in the embeddings record matches the one connected to the project. + instance, err := queries.RetrieveInstanceByProjectID(ctx, int32(project.ProjectID)) if err != nil { - return nil, err + return nil, huma.Error500InternalServerError(fmt.Sprintf("Cannot access LLM Service Instance specified in the project %s/%s: %v", input.UserHandle, input.ProjectHandle, err)) } - // For each embedding, build query parameters and run the query + // For each embedding, validate input, build query parameters and run the query ids := []string{} - queries := database.New(pool) for _, embedding := range input.Body.Embeddings { + + // Validate if instance specified in the embedding matches the one connected to the project + if embedding.InstanceHandle != instance.InstanceHandle { + return nil, huma.Error400BadRequest(fmt.Sprintf("Instance handle '%s' for embedding with text_id '%s' does not match the instance handle '%s' connected to project '%s/%s'", embedding.InstanceHandle, embedding.TextID, instance.InstanceHandle, input.UserHandle, input.ProjectHandle)) + } + // Validate embedding dimensions - if err := ValidateEmbeddingDimensions(embedding, llm.Body.Dimensions); err != nil { - return nil, huma.Error400BadRequest(fmt.Sprintf("dimension validation failed: %v", err)) + if err := ValidateEmbeddingDimensions(embedding, instance.Dimensions); err != nil { + return nil, huma.Error400BadRequest(fmt.Sprintf("Dimension validation failed for input %s: %v", embedding.TextID, err)) } // Check if embedding already exists to determine if this is an update @@ -86,31 +103,49 @@ func postProjEmbeddingsFunc(ctx context.Context, input *models.PostProjEmbedding }) isUpdate := err == nil var existingMetadata json.RawMessage + // If it already exists, integrate the update with existing data before schema validation. if isUpdate { + // If the update does not include text, keep the existing text + if embedding.Text == "" { + embedding.Text = existingEmbedding.Text.String + } existingMetadata = existingEmbedding.Metadata + // If the update has metadata, integrate it with the existing metadata + // (new keys are added, existing keys are updated, keys with null value are deleted) + if len(embedding.Metadata) != 0 { + mergedMetadata, err := mergeMetadata(existingEmbedding.Metadata, embedding.Metadata) + if err != nil { + return nil, huma.Error400BadRequest(fmt.Sprintf("Invalid metadata for text_id '%s': %v", embedding.TextID, err)) + } + embedding.Metadata = mergedMetadata + } } // Validate metadata against schema if provided - if err := ValidateMetadataAgainstSchema(embedding.Metadata, project.Body.MetadataScheme, isUpdate, existingMetadata); err != nil { - return nil, huma.Error400BadRequest(fmt.Sprintf("metadata validation failed for text_id '%s': %v", embedding.TextID, err)) + if !project.MetadataScheme.Valid || project.MetadataScheme.String != "" { + if err := ValidateMetadataAgainstSchema(embedding.Metadata, project.MetadataScheme.String, isUpdate, existingMetadata); err != nil { + return nil, huma.Error400BadRequest(fmt.Sprintf("metadata validation failed for text_id '%s': %v", embedding.TextID, err)) + } } + // Build query parameters (embeddings) params := database.UpsertEmbeddingsParams{ - TextID: pgtype.Text{String: embedding.TextID, Valid: true}, - Owner: input.UserHandle, - ProjectID: pid, - LLMServiceID: llmid, - Text: pgtype.Text{String: embedding.Text, Valid: true}, - Vector: pgvector.NewHalfVector(embedding.Vector), - VectorDim: embedding.VectorDim, - Metadata: embedding.Metadata, + TextID: pgtype.Text{String: embedding.TextID, Valid: true}, + Owner: input.UserHandle, + ProjectID: project.ProjectID, + InstanceID: instance.InstanceID, + Text: pgtype.Text{String: embedding.Text, Valid: true}, + Vector: pgvector.NewHalfVector(embedding.Vector), + VectorDim: embedding.VectorDim, + Metadata: embedding.Metadata, } // Run the queries (upload embeddings) result, err := queries.UpsertEmbeddings(ctx, params) if err != nil { fmt.Printf("Error: %v\n(Params were: %v)\n", err, params) - return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to upload embeddings. %v", err)) + return nil, huma.Error500InternalServerError(fmt.Sprintf("Unable to upload embeddings. %v", err)) } + ids = append(ids, result.TextID.String) } @@ -120,6 +155,36 @@ func postProjEmbeddingsFunc(ctx context.Context, input *models.PostProjEmbedding return response, nil } +func mergeMetadata(existing, new json.RawMessage) (json.RawMessage, error) { + var existingMap map[string]interface{} + if len(existing) != 0 { + if err := json.Unmarshal(existing, &existingMap); err != nil { + return nil, fmt.Errorf("unable to unmarshal existing metadata: %v", err) + } + } else { + existingMap = make(map[string]interface{}) + } + + var newMap map[string]interface{} + if err := json.Unmarshal(new, &newMap); err != nil { + return nil, fmt.Errorf("unable to unmarshal new metadata: %v", err) + } + + for k, v := range newMap { + if v == nil { + delete(existingMap, k) + } else { + existingMap[k] = v + } + } + + merged, err := json.Marshal(existingMap) + if err != nil { + return nil, fmt.Errorf("unable to marshal merged metadata: %v", err) + } + return merged, nil +} + func getProjEmbeddingsFunc(ctx context.Context, input *models.GetProjEmbeddingsRequest) (*models.GetProjEmbeddingsResponse, error) { // Check if user exists if _, err := getUserFunc(ctx, &models.GetUserRequest{UserHandle: input.UserHandle}); err != nil { @@ -160,22 +225,31 @@ func getProjEmbeddingsFunc(ctx context.Context, input *models.GetProjEmbeddingsR // Build the response e := []models.Embeddings{} - for _, embeddings := range embeddingss { + for _, emb := range embeddingss { + embeddings, err := queries.RetrieveEmbeddings(ctx, database.RetrieveEmbeddingsParams{ + Owner: input.UserHandle, + ProjectHandle: input.ProjectHandle, + TextID: pgtype.Text{String: emb.TextID.String, Valid: true}, + }) + if err != nil { + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get embeddings for user %s, project %s, id %s. %v", input.UserHandle, input.ProjectHandle, emb.TextID.String, err)) + } + md := map[string]interface{}{} err = json.Unmarshal(embeddings.Metadata, &md) if err != nil { return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to unmarshal metadata for user %s, project %s, id %s. Metadata: %s. %v", input.UserHandle, input.ProjectHandle, embeddings.TextID.String, string(embeddings.Metadata), err)) } e = append(e, models.Embeddings{ - TextID: embeddings.TextID.String, - UserHandle: embeddings.Owner, - ProjectHandle: embeddings.ProjectHandle, - ProjectID: int(embeddings.ProjectID), - LLMServiceHandle: embeddings.LLMServiceHandle, - Vector: embeddings.Vector.Slice(), - VectorDim: embeddings.VectorDim, - Text: embeddings.Text.String, - Metadata: md, + TextID: embeddings.TextID.String, + UserHandle: embeddings.Owner, + ProjectHandle: embeddings.ProjectHandle, + ProjectID: int(embeddings.ProjectID), + InstanceHandle: embeddings.InstanceHandle, + Vector: embeddings.Vector.Slice(), + VectorDim: embeddings.VectorDim, + Text: embeddings.Text.String, + Metadata: md, }) } response := &models.GetProjEmbeddingsResponse{} @@ -264,15 +338,15 @@ func getDocEmbeddingsFunc(ctx context.Context, input *models.GetDocEmbeddingsReq return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to unmarshal metadata for user %s, project %s, id %s. Metadata: %s. %v", input.UserHandle, input.ProjectHandle, embeddings.TextID.String, string(embeddings.Metadata), err)) } e := models.Embeddings{ - TextID: embeddings.TextID.String, - UserHandle: embeddings.Owner, - ProjectHandle: embeddings.ProjectHandle, - ProjectID: int(embeddings.ProjectID), - LLMServiceHandle: embeddings.LLMServiceHandle, - Vector: embeddings.Vector.Slice(), - VectorDim: embeddings.VectorDim, - Text: embeddings.Text.String, - Metadata: md, + TextID: embeddings.TextID.String, + UserHandle: embeddings.Owner, + ProjectHandle: embeddings.ProjectHandle, + ProjectID: int(embeddings.ProjectID), + InstanceHandle: embeddings.InstanceHandle, + Vector: embeddings.Vector.Slice(), + VectorDim: embeddings.VectorDim, + Text: embeddings.Text.String, + Metadata: md, } response := &models.GetDocEmbeddingsResponse{} response.Body = e @@ -280,7 +354,7 @@ func getDocEmbeddingsFunc(ctx context.Context, input *models.GetDocEmbeddingsReq return response, nil } -func deleteDocEmbeddingsFunc(ctx context.Context, input *models.DeleteDocEmbeddingsRequest) (*models.DeleteDocEmbeddingsResponse, error) { +func deleteDocEmbeddingsFunc(ctx context.Context, input *models.DeleteEmbeddingsByDocIDRequest) (*models.DeleteEmbeddingsByDocIDResponse, error) { // Check if user and project exist _, _, _, err := getUserProj(ctx, input.UserHandle, input.ProjectHandle) if err != nil { @@ -309,7 +383,7 @@ func deleteDocEmbeddingsFunc(ctx context.Context, input *models.DeleteDocEmbeddi } // Build query parameters for DeleteEmbeddings - params := database.DeleteDocEmbeddingsParams{ + params := database.DeleteEmbeddingsByDocIDParams{ Owner: input.UserHandle, ProjectHandle: input.ProjectHandle, TextID: pgtype.Text{String: textid, Valid: true}, @@ -319,13 +393,13 @@ func deleteDocEmbeddingsFunc(ctx context.Context, input *models.DeleteDocEmbeddi // Run the query queries := database.New(pool) - err = queries.DeleteDocEmbeddings(ctx, params) + err = queries.DeleteEmbeddingsByDocID(ctx, params) if err != nil { return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to delete embeddings for text id %s in %s's project %s. %v", textid, input.UserHandle, input.ProjectHandle, err)) } // Build the response - response := &models.DeleteDocEmbeddingsResponse{} + response := &models.DeleteEmbeddingsByDocIDResponse{} return response, nil } diff --git a/internal/handlers/embeddings_test.go b/internal/handlers/embeddings_test.go index b15ad90..0bfd383 100644 --- a/internal/handlers/embeddings_test.go +++ b/internal/handlers/embeddings_test.go @@ -14,6 +14,7 @@ import ( ) func TestEmbeddingsFunc(t *testing.T) { + // Get the database connection pool from package variable pool := connPool @@ -33,13 +34,6 @@ func TestEmbeddingsFunc(t *testing.T) { t.Fatalf("Error creating user alice for testing: %v\n", err) } - // Create project to be used in embeddings tests - projectJSON := `{"project_handle": "test1", "description": "A test project"}` - _, err = createProject(t, projectJSON, "alice", aliceAPIKey) - if err != nil { - t.Fatalf("Error creating project alice/test1 for testing: %v\n", err) - } - // Create API standard to be used in embeddings tests apiStandardJSON := `{"api_standard_handle": "openai", "description": "OpenAI Embeddings API", "key_method": "auth_bearer", "key_field": "Authorization" }` _, err = createAPIStandard(t, apiStandardJSON, options.AdminKey) @@ -47,11 +41,18 @@ func TestEmbeddingsFunc(t *testing.T) { t.Fatalf("Error creating API standard openai for testing: %v\n", err) } - // Create LLM Service to be used in embeddings tests - llmServiceJSON := `{ "llm_service_handle": "test1", "endpoint": "https://api.foo.bar/v1/embed", "description": "An LLM Service just for testing if the dhamps-vdb code is working", "api_key": "0123456789", "api_standard": "openai", "model": "embed-test1", "dimensions": 5}` - _, err = createLLMService(t, llmServiceJSON, "alice", aliceAPIKey) + // Create LLM Service Instance to be used in embeddings tests + instanceJSON := `{ "instance_handle": "embedding1", "endpoint": "https://api.foo.bar/v1/embed", "description": "An LLM Service just for testing if the dhamps-vdb code is working", "api_standard": "openai", "model": "embed-test1", "dimensions": 5}` + _, err = createInstance(t, instanceJSON, "alice", aliceAPIKey) if err != nil { - t.Fatalf("Error creating LLM service openai-large for testing: %v\n", err) + t.Fatalf("Error creating LLM service embedding1 for testing: %v\n", err) + } + + // Create project to be used in embeddings tests + projectJSON := `{ "project_handle": "test1", "instance_owner": "alice", "instance_handle": "embedding1", "description": "This is a test project" }` + _, err = createProject(t, projectJSON, "alice", aliceAPIKey) + if err != nil { + t.Fatalf("Error creating project alice/test1 for testing: %v\n", err) } fmt.Printf("\nRunning embeddings tests ...\n\n") @@ -72,7 +73,7 @@ func TestEmbeddingsFunc(t *testing.T) { requestPath: "/v1/embeddings/alice/test1", bodyPath: "../../testdata/invalid_embeddings.json", apiKeyHeader: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"validation failed\",\n \"errors\": [\n {\n \"message\": \"expected required property text_id to be present\",\n \"location\": \"body.embeddings[0]\",\n \"value\": {\n \"foo\": \"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.1.1.1.1\",\n \"llm_service_handle\": \"openai-large\",\n \"metadata\": {\n \"author\": \"Immanuel Kant\"\n },\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"text\": \"This is a test document\",\n \"user_handle\": \"alice\",\n \"vector\": [],\n \"vector_dim\": 10\n }\n },\n {\n \"message\": \"unexpected property\",\n \"location\": \"body.embeddings[0].foo\",\n \"value\": {\n \"foo\": \"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.1.1.1.1\",\n \"llm_service_handle\": \"openai-large\",\n \"metadata\": {\n \"author\": \"Immanuel Kant\"\n },\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"text\": \"This is a test document\",\n \"user_handle\": \"alice\",\n \"vector\": [],\n \"vector_dim\": 10\n }\n }\n ]\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"validation failed\",\n \"errors\": [\n {\n \"message\": \"expected required property text_id to be present\",\n \"location\": \"body.embeddings[0]\",\n \"value\": {\n \"foo\": \"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.1.1.1.1\",\n \"instance_handle\": \"embedding1\",\n \"instance_owner\": \"alice\",\n \"metadata\": {\n \"author\": \"Immanuel Kant\"\n },\n \"project_handle\": \"test1\",\n \"text\": \"This is a test document\",\n \"user_handle\": \"alice\",\n \"vector\": [],\n \"vector_dim\": 10\n }\n },\n {\n \"message\": \"unexpected property\",\n \"location\": \"body.embeddings[0].foo\",\n \"value\": {\n \"foo\": \"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.1.1.1.1\",\n \"instance_handle\": \"embedding1\",\n \"instance_owner\": \"alice\",\n \"metadata\": {\n \"author\": \"Immanuel Kant\"\n },\n \"project_handle\": \"test1\",\n \"text\": \"This is a test document\",\n \"user_handle\": \"alice\",\n \"vector\": [],\n \"vector_dim\": 10\n }\n }\n ]\n}\n", expectStatus: http.StatusUnprocessableEntity, }, { @@ -117,7 +118,7 @@ func TestEmbeddingsFunc(t *testing.T) { requestPath: "/v1/embeddings/alice/test1", bodyPath: "", apiKeyHeader: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetProjEmbeddingsResponseBody.json\",\n \"embeddings\": [\n {\n \"text_id\": \"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.1.1.1.1\",\n \"user_handle\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"llm_service_handle\": \"test1\",\n \"text\": \"This is a test document\",\n \"vector\": [\n -0.020843506,\n 0.01852417,\n 0.05328369,\n 0.07141113,\n 0.020004272\n ],\n \"vector_dim\": 5,\n \"metadata\": {\n \"author\": \"Immanuel Kant\"\n }\n },\n {\n \"text_id\": \"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.2\",\n \"user_handle\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"llm_service_handle\": \"test1\",\n \"text\": \"This is a similar test document\",\n \"vector\": [\n -0.020843506,\n 0.01852417,\n 0.05328369,\n 0.07141113,\n 0.020004272\n ],\n \"vector_dim\": 5,\n \"metadata\": {\n \"author\": \"Immanuel Kant\"\n }\n },\n {\n \"text_id\": \"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol2\",\n \"user_handle\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"llm_service_handle\": \"test1\",\n \"text\": \"This is a similar test document\",\n \"vector\": [\n -0.020843506,\n 0.01852417,\n 0.05328369,\n 0.07141113,\n 0.020004272\n ],\n \"vector_dim\": 5,\n \"metadata\": {\n \"author\": \"Immanuel Other\"\n }\n }\n ]\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetProjEmbeddingsResponseBody.json\",\n \"embeddings\": [\n {\n \"text_id\": \"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.1.1.1.1\",\n \"user_handle\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"instance_handle\": \"embedding1\",\n \"text\": \"This is a test document\",\n \"vector\": [\n -0.020843506,\n 0.01852417,\n 0.05328369,\n 0.07141113,\n 0.020004272\n ],\n \"vector_dim\": 5,\n \"metadata\": {\n \"author\": \"Immanuel Kant\"\n }\n },\n {\n \"text_id\": \"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.2\",\n \"user_handle\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"instance_handle\": \"embedding1\",\n \"text\": \"This is a similar test document\",\n \"vector\": [\n -0.020843506,\n 0.01852417,\n 0.05328369,\n 0.07141113,\n 0.020004272\n ],\n \"vector_dim\": 5,\n \"metadata\": {\n \"author\": \"Immanuel Kant\"\n }\n },\n {\n \"text_id\": \"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol2\",\n \"user_handle\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"instance_handle\": \"embedding1\",\n \"text\": \"This is a similar test document\",\n \"vector\": [\n -0.020843506,\n 0.01852417,\n 0.05328369,\n 0.07141113,\n 0.020004272\n ],\n \"vector_dim\": 5,\n \"metadata\": {\n \"author\": \"Immanuel Other\"\n }\n }\n ]\n}\n", expectStatus: http.StatusOK, }, { @@ -144,7 +145,7 @@ func TestEmbeddingsFunc(t *testing.T) { requestPath: "/v1/embeddings/alice/test1/https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.1.1.1.1", bodyPath: "", apiKeyHeader: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/Embeddings.json\",\n \"text_id\": \"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.1.1.1.1\",\n \"user_handle\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"llm_service_handle\": \"test1\",\n \"text\": \"This is a test document\",\n \"vector\": [\n -0.020843506,\n 0.01852417,\n 0.05328369,\n 0.07141113,\n 0.020004272\n ],\n \"vector_dim\": 5,\n \"metadata\": {\n \"author\": \"Immanuel Kant\"\n }\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/Embeddings.json\",\n \"text_id\": \"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.1.1.1.1\",\n \"user_handle\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"instance_handle\": \"embedding1\",\n \"text\": \"This is a test document\",\n \"vector\": [\n -0.020843506,\n 0.01852417,\n 0.05328369,\n 0.07141113,\n 0.020004272\n ],\n \"vector_dim\": 5,\n \"metadata\": {\n \"author\": \"Immanuel Kant\"\n }\n}\n", expectStatus: http.StatusOK, }, { @@ -315,7 +316,7 @@ func TestEmbeddingsFunc(t *testing.T) { // Cleanup removes items created by the put function test // (deleting '/users/alice' should delete all the - // projects, llmservices and embeddings connected to alice as well) + // projects, instances and embeddings connected to alice as well) t.Cleanup(func() { fmt.Print("\n\nRunning cleanup ...\n\n") @@ -333,4 +334,5 @@ func TestEmbeddingsFunc(t *testing.T) { shutDownServer() }) + fmt.Printf("\n\n\n\n") } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index dd071e0..3ed7bba 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -66,9 +66,14 @@ func AddRoutes(pool *pgxpool.Pool, keyGen RandomKeyGenerator, api huma.API) erro fmt.Printf(" Unable to register API standards routes: %v\n", err) return err } - err = RegisterLLMServicesRoutes(pool, api) + err = RegisterInstancesRoutes(pool, api) if err != nil { - fmt.Printf(" Unable to register Embeddings routes: %v\n", err) + fmt.Printf(" Unable to register Instances routes: %v\n", err) + return err + } + err = RegisterDefinitionsRoutes(pool, api) + if err != nil { + fmt.Printf(" Unable to register Definitions routes: %v\n", err) return err } err = RegisterSimilarRoutes(pool, api) @@ -81,7 +86,6 @@ func AddRoutes(pool *pgxpool.Pool, keyGen RandomKeyGenerator, api huma.API) erro fmt.Printf(" Unable to register Admin routes: %v\n", err) return err } - // err = handlers.RegisterLLMProcessRoutes(pool, api) return nil } diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 41fc85e..9b1e52b 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -154,7 +154,7 @@ func getTestDatabase() (*pgxpool.Pool, error, func()) { } } -// setupServer sets up server, router and API for testing. +// startTestServer sets up server, router and API for testing. // It returns an error value and a closure function that // should be called to clean up. // It is supposed to be called from the various tests. @@ -179,9 +179,9 @@ func startTestServer(t *testing.T, pool *pgxpool.Pool, keyGen handlers.RandomKey config.Components.SecuritySchemes = auth.Config router := http.NewServeMux() api := humago.New(router, config) - api.UseMiddleware(auth.APIKeyAdminAuth(api, &options)) - api.UseMiddleware(auth.APIKeyOwnerAuth(api, pool, &options)) - api.UseMiddleware(auth.APIKeyReaderAuth(api, pool, &options)) + api.UseMiddleware(auth.VDBKeyAdminAuth(api, &options)) + api.UseMiddleware(auth.VDBKeyOwnerAuth(api, pool, &options)) + api.UseMiddleware(auth.VDBKeyReaderAuth(api, pool, &options)) api.UseMiddleware(auth.AuthTermination(api)) err := handlers.AddRoutes(pool, keyGen, api) @@ -236,6 +236,8 @@ func startTestServer(t *testing.T, pool *pgxpool.Pool, keyGen handlers.RandomKey cleanup := func() { // Close the server server.Close() + // Wait a moment to ensure the port is fully released + time.Sleep(100 * time.Millisecond) /* Clean up transactions. _, err := conn.Exec(context.Background(), "ROLLBACK") if err != nil { @@ -272,7 +274,7 @@ func createUser(t *testing.T, userJSON string) (string, error) { return "", err } assert.NoError(t, err) - + fmt.Printf(" Creating user (\"%s\") for testing ...\n", jsonInput.UserHandle) requestURL := fmt.Sprintf("http://%s:%d/v1/users/%s", options.Host, options.Port, jsonInput.UserHandle) requestBody := bytes.NewReader([]byte(userJSON)) @@ -286,34 +288,38 @@ func createUser(t *testing.T, userJSON string) (string, error) { // get API key for user from response body body, err := io.ReadAll(resp.Body) assert.NoError(t, err) - + // Check if response was successful if resp.StatusCode != http.StatusCreated { fmt.Printf("Error creating user: status code %d, body: %s\n", resp.StatusCode, string(body)) return "", fmt.Errorf("status code %d: %s", resp.StatusCode, string(body)) } - + // fmt.Printf("Response body: %v\n", string(body)) - userInfo := models.HandleAPIStruct{} + userInfo := models.UserResponse{} err = json.Unmarshal(body, &userInfo) if err != nil { fmt.Printf("Error unmarshalling user info: %v\nbody: %v\n", err, string(body)) return "", err } assert.NoError(t, err) - fmt.Printf(" Successfully created user (handle: \"%s\", apiKey: \"%s\").\n", userInfo.UserHandle, userInfo.APIKey) + fmt.Printf(" Successfully created user (handle: \"%s\", VDBKey: \"%s\").\n", userInfo.UserHandle, userInfo.VDBKey) // fmt.Printf(" User info: %v\n", userInfo) - return userInfo.APIKey, nil + return userInfo.VDBKey, nil } // createProject creates a project and returns the project ID and an error value // it accepts a JSON string encoding the project object as input -func createProject(t *testing.T, projectJSON string, user string, apiKey string) (int, error) { +func createProject(t *testing.T, projectJSON string, user string, VDBKey string) (int, error) { fmt.Print(" Creating project ") jsonInput := &struct { - Handle string `json:"project_handle" doc:"Handle of created or updated project"` - Description string `json:"description" doc:"Description of the project"` + Handle string `json:"project_handle" doc:"Handle of created or updated project"` + InstanceOwner string `json:"instance_owner,omitempty" doc:"User handle of the owner of the LLM Service Instance used in the project."` + InstanceHandle string `json:"instance_handle,omitempty" doc:"Handle of the LLM Service Instance used in the project"` + Description string `json:"description" doc:"Description of the project"` + IsPublic bool `json:"is_public,omitempty" default:"false" doc:"Whether the project should be public or not"` }{} + err := json.Unmarshal([]byte(projectJSON), jsonInput) if err != nil { fmt.Printf("Error unmarshalling project JSON: %v\n", err) @@ -324,7 +330,7 @@ func createProject(t *testing.T, projectJSON string, user string, apiKey string) requestURL := fmt.Sprintf("http://%s:%d/v1/projects/%s/%s", options.Host, options.Port, user, jsonInput.Handle) requestBody := bytes.NewReader([]byte(projectJSON)) req, err := http.NewRequest(http.MethodPut, requestURL, requestBody) - req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Authorization", "Bearer "+VDBKey) assert.NoError(t, err) resp, err := http.DefaultClient.Do(req) @@ -336,8 +342,11 @@ func createProject(t *testing.T, projectJSON string, user string, apiKey string) assert.NoError(t, err) projectInfo := &struct { + Owner string `json:"owner" doc:"User handle of the project owner"` ProjectHandle string `json:"project_handle" doc:"Handle of created or updated project"` ProjectID int `json:"project_id" doc:"Unique project identifier"` + PublicRead bool `json:"public_read" doc:"Whether the project is public or not"` + Role string `json:"role,omitempty" doc:"Role of the requesting user in the project (can be owner or some other role)"` }{} err = json.Unmarshal(body, &projectInfo) if err != nil { @@ -351,7 +360,7 @@ func createProject(t *testing.T, projectJSON string, user string, apiKey string) // createAPIStandard creates an API standard definition for testing and returns the handle and an error value // it accepts a JSON string encoding the API standard object as input -func createAPIStandard(t *testing.T, apiStandardJSON string, apiKey string) (string, error) { +func createAPIStandard(t *testing.T, apiStandardJSON string, VDBKey string) (string, error) { fmt.Print(" Creating API standard ") jsonInput := &struct { APIStandardHandle string `json:"api_standard_handle" doc:"Handle of created or updated api standard"` @@ -369,7 +378,7 @@ func createAPIStandard(t *testing.T, apiStandardJSON string, apiKey string) (str requestURL := fmt.Sprintf("http://%s:%d/v1/api-standards/%s", options.Host, options.Port, jsonInput.APIStandardHandle) requestBody := bytes.NewReader([]byte(apiStandardJSON)) req, err := http.NewRequest(http.MethodPut, requestURL, requestBody) - req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Authorization", "Bearer "+VDBKey) assert.NoError(t, err) resp, err := http.DefaultClient.Do(req) @@ -393,62 +402,179 @@ func createAPIStandard(t *testing.T, apiStandardJSON string, apiKey string) (str return APIStandardInfo.APIStandardHandle, nil } -// createLLMService creates an LLM service definition for testing and returns the handle and an error value -// it accepts a JSON string encoding the LLM service object as input -func createLLMService(t *testing.T, llmServiceJSON string, user string, apiKey string) (string, error) { - fmt.Print(" Creating LLM service ") +// createInstance creates an LLM service instance for testing and returns the handle and an error value +// it accepts a JSON string encoding the LLM service instance object as input +// NOTE: This function is kept for backward compatibility with existing tests +// It creates an LLM Service Instance (not a Definition) +func createInstance(t *testing.T, instanceJSON string, user string, VDBKey string) (string, error) { + fmt.Print(" Creating LLM service instance ") + + // Parse JSON to extract handle - support both old and new field names jsonInput := &struct { - LLMServiceHandle string `json:"llm_service_handle" doc:"Handle of created or updated LLM service"` - Owner string `json:"owner" doc:"User handle of the service owner"` - Description string `json:"description" doc:"Description of the LLM service"` + InstanceHandle string `json:"instance_handle" doc:"Old field name for backward compatibility"` + Owner string `json:"owner" doc:"User handle of the service owner"` + Description string `json:"description" doc:"Description of the LLM service"` }{} - err := json.Unmarshal([]byte(llmServiceJSON), jsonInput) + err := json.Unmarshal([]byte(instanceJSON), jsonInput) if err != nil { fmt.Printf("Error unmarshalling LLM service JSON: %v\n", err) } assert.NoError(t, err) - fmt.Printf("(\"%s\") for testing ...\n", jsonInput.LLMServiceHandle) + handle := jsonInput.InstanceHandle + fmt.Printf("(\"%s/%s\") for testing ...\n", user, handle) - requestURL := fmt.Sprintf("http://%s:%d/v1/llm-services/%s/%s", options.Host, options.Port, user, jsonInput.LLMServiceHandle) - requestBody := bytes.NewReader([]byte(llmServiceJSON)) + requestURL := fmt.Sprintf("http://%s:%d/v1/llm-instances/%s/%s", options.Host, options.Port, user, handle) + requestBody := bytes.NewReader([]byte(instanceJSON)) req, err := http.NewRequest(http.MethodPut, requestURL, requestBody) - req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Authorization", "Bearer "+VDBKey) + assert.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + // get LLM service instance handle from response body + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + + // Check if response was successful + if resp.StatusCode != http.StatusCreated { + fmt.Printf("Error creating LLM service instance: status code %d, body: %s\n", resp.StatusCode, string(body)) + return "", fmt.Errorf("status code %d: %s", resp.StatusCode, string(body)) + } + + InstanceInfo := &struct { + Owner string `json:"owner" doc:"User handle of the LLM Service Instance owner"` + InstanceHandle string `json:"instance_handle" doc:"Handle of created or updated LLM Service Instance"` + InstanceID int `json:"instance_id" doc:"System identifier of created or updated LLM Service Instance"` + }{} + err = json.Unmarshal(body, &InstanceInfo) + if err != nil { + fmt.Printf("Error unmarshalling LLM Service Instance info: %v\nbody: %v", err, string(body)) + } + assert.NoError(t, err) + + fmt.Printf(" Successfully created LLM Service Instance (handle \"%s\", id %d).\n", InstanceInfo.InstanceHandle, InstanceInfo.InstanceID) + return InstanceInfo.InstanceHandle, nil +} + +// createDefinition creates an LLM service definition for testing and returns the handle and an error value +// it accepts a JSON string encoding the LLM service definition object as input +func createDefinition(t *testing.T, DefinitionJSON string, user string, VDBKey string) (int32, error) { + fmt.Print(" Creating LLM service definition ") + jsonInput := &struct { + Owner string `json:"owner" doc:"User handle of the service definition owner"` + DefinitionHandle string `json:"definition_handle" doc:"Handle of created or updated LLM service definition"` + Description string `json:"description,omitempty" doc:"Description of the LLM service definition"` + Endpoint string `json:"endpoint,omitempty" doc:"Endpoint of the LLM service definition"` + APIStandard string `json:"api_standard,omitempty" doc:"API standard followed by the LLM service definition"` + Model string `json:"model,omitempty" doc:"Model name used in the LLM service definition"` + Dimensions int `json:"dimensions,omitempty" doc:"Dimensions of the embeddings used in the LLM service definition"` + ContextLimit int `json:"context_limit,omitempty" doc:"Context limit of the LLM service definition"` + IsPublic bool `json:"is_public" doc:"Whether the LLM service definition is public or not"` + }{} + err := json.Unmarshal([]byte(DefinitionJSON), jsonInput) + if err != nil { + fmt.Printf("Error unmarshalling LLM service definition JSON: %v\nJSON: %s", err, string(DefinitionJSON)) + } + assert.NoError(t, err) + fmt.Printf("(%s/%s) for testing ...\n", user, jsonInput.DefinitionHandle) + + requestURL := fmt.Sprintf("http://%s:%d/v1/llm-definitions/%s/%s", options.Host, options.Port, user, jsonInput.DefinitionHandle) + requestBody := bytes.NewReader([]byte(DefinitionJSON)) + req, err := http.NewRequest(http.MethodPut, requestURL, requestBody) + req.Header.Set("Authorization", "Bearer "+VDBKey) + assert.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + + // get LLM service definition handle from response body + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + + DefinitionInfo := &struct { + Schema string `json:"$schema" doc:"JSON schema URL for the response body"` + DefinitionID int `json:"definition_id" doc:"System identifier of created or updated LLM Service Definition"` + DefinitionHandle string `json:"definition_handle" doc:"Handle of created or updated LLM Service Definition"` + Owner string `json:"owner" doc:"User handle of the LLM Service Definition owner"` + IsPublic bool `json:"is_public" doc:"Whether the LLM Service Definition is public or not"` + }{} + err = json.Unmarshal(body, &DefinitionInfo) + if err != nil { + fmt.Printf("Error unmarshalling LLM service definition info: %v\nbody: %v", err, string(body)) + } + assert.NoError(t, err) + + fmt.Printf(" Successfully created LLM Service Definition (%s/%s, id %d).\n", DefinitionInfo.Owner, DefinitionInfo.DefinitionHandle, DefinitionInfo.DefinitionID) + return int32(DefinitionInfo.DefinitionID), nil +} + +// createLLMInstanceFromDefinition creates an LLM service instance from a definition for testing +// it accepts the definition owner/handle, instance handle, and optional overrides +func createInstanceFromDefinition(t *testing.T, user string, instanceHandle string, definitionOwner string, definitionHandle string, VDBKey string, endpoint *string, description *string, apiKey string) (string, error) { + fmt.Printf(" Creating LLM service instance from definition (\"%s/%s\" from \"%s/%s\") for testing ...\n", user, instanceHandle, definitionOwner, definitionHandle) + + requestURL := fmt.Sprintf("http://%s:%d/v1/llm-instances/%s/%s/from-definition/%s/%s", options.Host, options.Port, user, instanceHandle, definitionOwner, definitionHandle) + + // Build request body + requestBody := map[string]interface{}{} + if endpoint != nil { + requestBody["endpoint"] = *endpoint + } + if description != nil { + requestBody["description"] = *description + } + if apiKey != "" { + requestBody["api_key"] = apiKey + } + + bodyBytes, err := json.Marshal(requestBody) + if err != nil { + return "", fmt.Errorf("error marshalling request body: %v", err) + } + + req, err := http.NewRequest(http.MethodPost, requestURL, bytes.NewReader(bodyBytes)) + req.Header.Set("Authorization", "Bearer "+VDBKey) + req.Header.Set("Content-Type", "application/json") assert.NoError(t, err) resp, err := http.DefaultClient.Do(req) assert.NoError(t, err) defer resp.Body.Close() - // get LLM service handle from response body + // get LLM service instance handle from response body body, err := io.ReadAll(resp.Body) assert.NoError(t, err) - LLMServiceInfo := &struct { - LLMServiceHandle string `json:"llm_service_handle" doc:"Handle of created or updated LLM service"` - LLMServiceID int `json:"llm_service_id" doc:"System identifier of created or updated LLM service"` + InstanceInfo := &struct { + Owner string `json:"owner" doc:"User handle of the LLM Service Instance owner"` + InstanceHandle string `json:"instance_handle" doc:"Handle of created or updated LLM Service Instance"` + InstanceID int `json:"instance_id" doc:"System identifier of created or updated LLM Service Instance"` }{} - err = json.Unmarshal(body, &LLMServiceInfo) + err = json.Unmarshal(body, &InstanceInfo) if err != nil { - fmt.Printf("Error unmarshalling LLM service info: %v\nbody: %v", err, string(body)) + fmt.Printf("Error unmarshalling LLM service instance info: %v\nbody: %v", err, string(body)) } assert.NoError(t, err) - fmt.Printf(" Successfully created LLM Service (handle \"%s\", id %d).\n", LLMServiceInfo.LLMServiceHandle, LLMServiceInfo.LLMServiceID) - return LLMServiceInfo.LLMServiceHandle, nil + fmt.Printf(" Successfully created LLM Service Instance from definition (handle \"%s\", id %d).\n", InstanceInfo.InstanceHandle, InstanceInfo.InstanceID) + return InstanceInfo.InstanceHandle, nil } // createEmbeddings creates some embeddings entries for testing and returns an error value // it accepts a JSON string encording the embeddings db entries -func createEmbeddings(t *testing.T, embeddings []byte, user string, llmService string, apiKey string) error { +func createEmbeddings(t *testing.T, embeddings []byte, user string, Instance string, VDBKey string) error { fmt.Print(" Creating Embeddings for testing ...\n") // Upload embeddings for similars testing - requestURL := fmt.Sprintf("http://%s:%d/v1/embeddings/alice/test1", options.Host, options.Port) + requestURL := fmt.Sprintf("http://%s:%d/v1/embeddings/%s/%s", options.Host, options.Port, user, Instance) req, err := http.NewRequest(http.MethodPost, requestURL, bytes.NewReader(embeddings)) if err != nil { fmt.Printf("Error creating request for uploading embeddings: %v\n", err) } - req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Authorization", "Bearer "+VDBKey) req.Header.Set("Content-Type", "application/json") assert.NoError(t, err) diff --git a/internal/handlers/llm_processes.go b/internal/handlers/llm_processes.go deleted file mode 100644 index 8fe6325..0000000 --- a/internal/handlers/llm_processes.go +++ /dev/null @@ -1,34 +0,0 @@ -package handlers - -import ( - "context" - "net/http" - - "github.com/mpilhlt/dhamps-vdb/internal/models" - - "github.com/danielgtaylor/huma/v2" -) - -// Define handler functions for each route -func postLLMProcessFunc(ctx context.Context, input *models.LLMProcessRequest) (*models.LLMProcessResponse, error) { - // Implement your logic here - return nil, nil -} - -// RegisterLLMProcessRoutes registers the routes for the LLMProcess service -func RegisterLLMProcessRoutes(api huma.API) { - // Define huma.Operations for each route - postLLMProcessOp := huma.Operation{ - OperationID: "postLLMProcess", - Method: http.MethodPost, - Path: "/v1/llm-process/{user_handle}", - Summary: "Process text with LLM service", - Security: []map[string][]string{ - {"adminAuth": []string{"admin"}}, - {"ownerAuth": []string{"owner"}}, - }, - Tags: []string{"llm-process"}, - } - - huma.Register(api, postLLMProcessOp, postLLMProcessFunc) -} diff --git a/internal/handlers/llm_processes_test.go b/internal/handlers/llm_processes_test.go deleted file mode 100644 index 6d7d723..0000000 --- a/internal/handlers/llm_processes_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package handlers_test - -import ( - "testing" -) - -// TestLLMProcessesStub is a placeholder test for the LLM processes handler. -// The LLM processes functionality is not yet implemented (returns nil, nil). -// This test documents that the handlers exist but are not yet functional. -func TestLLMProcessesStub(t *testing.T) { - t.Skip("LLM processes functionality is not yet implemented") - - // TODO: When postLLMProcessFunc is implemented, add tests for: - // - Valid POST request with LLM process data - // - Invalid POST request with malformed data - // - Authentication/authorization checks - // - Edge cases and error handling -} diff --git a/internal/handlers/llm_services.go b/internal/handlers/llm_services.go index dca43fc..f757afc 100644 --- a/internal/handlers/llm_services.go +++ b/internal/handlers/llm_services.go @@ -4,7 +4,11 @@ import ( "context" "fmt" "net/http" + "os" + "slices" + "github.com/mpilhlt/dhamps-vdb/internal/auth" + "github.com/mpilhlt/dhamps-vdb/internal/crypto" "github.com/mpilhlt/dhamps-vdb/internal/database" "github.com/mpilhlt/dhamps-vdb/internal/models" @@ -14,18 +18,32 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -func putLLMFunc(ctx context.Context, input *models.PutLLMRequest) (*models.UploadLLMResponse, error) { - if input.LLMServiceHandle != input.Body.LLMServiceHandle { - return nil, huma.Error400BadRequest(fmt.Sprintf("llm-service handle in URL (\"%s\") does not match llm-service handle in body (\"%s\")", input.LLMServiceHandle, input.Body.LLMServiceHandle)) +// getEncryptionKey retrieves the encryption key, returns nil if not set (optional encryption) +func getEncryptionKey() *crypto.EncryptionKey { + keyStr := os.Getenv("ENCRYPTION_KEY") + if keyStr == "" { + return nil + } + return crypto.NewEncryptionKey(keyStr) +} + +// === Sharing LLM Service Definitions === + +func putDefinitionFunc(ctx context.Context, input *models.PutDefinitionRequest) (*models.UploadDefinitionResponse, error) { + if input.DefinitionHandle != input.Body.DefinitionHandle { + return nil, huma.Error400BadRequest(fmt.Sprintf("definition handle in URL (\"%s\") does not match definition handle in body (\"%s\")", input.DefinitionHandle, input.Body.DefinitionHandle)) } // Check if user exists u, err := getUserFunc(ctx, &models.GetUserRequest{UserHandle: input.UserHandle}) if err != nil { - return nil, err + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("user %s not found", input.UserHandle)) + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to access user %s. %v", input.UserHandle, err)) } if u.Body.UserHandle != input.UserHandle { - return nil, huma.Error404NotFound(fmt.Sprintf("user %s not found", input.UserHandle)) + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to access user %s. %v", input.UserHandle, err)) } // Get the database connection pool from the context @@ -35,37 +53,37 @@ func putLLMFunc(ctx context.Context, input *models.PutLLMRequest) (*models.Uploa } // Execute all database operations within a transaction - var llmServiceID int32 - var llmServiceHandle string var owner string + var definitionHandle string + var definitionID int32 + var isPublic bool err = database.WithTransaction(ctx, pool, func(tx pgx.Tx) error { queries := database.New(tx) - // 1. Upsert LLM service - llm, err := queries.UpsertLLM(ctx, database.UpsertLLMParams{ + // 1. Upsert LLM service definition + llm, err := queries.UpsertDefinition(ctx, database.UpsertDefinitionParams{ Owner: input.UserHandle, - LLMServiceHandle: input.LLMServiceHandle, + DefinitionHandle: input.DefinitionHandle, Endpoint: input.Body.Endpoint, Description: pgtype.Text{String: input.Body.Description, Valid: true}, - APIKey: pgtype.Text{String: input.Body.APIKey, Valid: true}, APIStandard: input.Body.APIStandard, Model: input.Body.Model, Dimensions: int32(input.Body.Dimensions), + ContextLimit: int32(input.Body.ContextLimit), + IsPublic: input.Body.IsPublic, }) if err != nil { - return fmt.Errorf("unable to upload llm service. %v", err) + return fmt.Errorf("unable to upload llm service definition: %v", err) } - llmServiceID = llm.LLMServiceID - llmServiceHandle = llm.LLMServiceHandle owner = llm.Owner + definitionHandle = llm.DefinitionHandle + definitionID = llm.DefinitionID + isPublic = llm.IsPublic - // 2. Link llm service to user - err = queries.LinkUserToLLM(ctx, database.LinkUserToLLMParams{UserHandle: input.UserHandle, LLMServiceID: llmServiceID, Role: "owner"}) - if err != nil { - return fmt.Errorf("unable to link llm service to user. %v", err) - } + // Ownership is tracked via the owner column in instances + // No need for separate linking table return nil }) @@ -75,74 +93,410 @@ func putLLMFunc(ctx context.Context, input *models.PutLLMRequest) (*models.Uploa } // Build response - response := &models.UploadLLMResponse{} + response := &models.UploadDefinitionResponse{} response.Body.Owner = owner - response.Body.LLMServiceHandle = llmServiceHandle - response.Body.LLMServiceID = int(llmServiceID) + response.Body.DefinitionHandle = definitionHandle + response.Body.DefinitionID = int(definitionID) + response.Body.IsPublic = isPublic return response, nil } -// Create a llm service (without a handle being present in the URL) -func postLLMFunc(ctx context.Context, input *models.PostLLMRequest) (*models.UploadLLMResponse, error) { - return putLLMFunc(ctx, &models.PutLLMRequest{UserHandle: input.UserHandle, LLMServiceHandle: input.Body.LLMServiceHandle, Body: input.Body}) +func postDefinitionFunc(ctx context.Context, input *models.PostDefinitionRequest) (*models.UploadDefinitionResponse, error) { + return putDefinitionFunc(ctx, &models.PutDefinitionRequest{UserHandle: input.UserHandle, DefinitionHandle: input.Body.DefinitionHandle, Body: input.Body}) } -func getLLMFunc(ctx context.Context, input *models.GetLLMRequest) (*models.GetLLMResponse, error) { +func getDefinitionFunc(ctx context.Context, input *models.GetDefinitionRequest) (*models.GetDefinitionResponse, error) { + // Check if user exists u, err := getUserFunc(ctx, &models.GetUserRequest{UserHandle: input.UserHandle}) if err != nil { - return nil, err + return nil, huma.Error404NotFound(fmt.Sprintf("user %s not found", input.UserHandle)) } if u.Body.UserHandle != input.UserHandle { + return nil, huma.Error500InternalServerError(fmt.Sprintf("user handle in retrieved record does not match retrieve handle %s", input.UserHandle)) + } + + // Get the database connection pool from the context + pool, err := GetDBPool(ctx) + if err != nil { + return nil, huma.Error500InternalServerError("Unable to access database pool") + } + + // Run the query + queries := database.New(pool) + def, err := queries.RetrieveDefinition(ctx, database.RetrieveDefinitionParams{ + Owner: input.UserHandle, + DefinitionHandle: input.DefinitionHandle, + }) + if err != nil { + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("llm definition %s/%s not found", input.UserHandle, input.DefinitionHandle)) + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve llm definition %s/%s: %v", input.UserHandle, input.DefinitionHandle, err)) + } + if def.DefinitionHandle != input.DefinitionHandle { + return nil, huma.Error500InternalServerError(fmt.Sprintf("llm definition handle in retrieved record does not match retrieving handle %s/%s", input.UserHandle, input.DefinitionHandle)) + } + if !def.IsPublic { + authorized := false + if authUserHandle, ok := ctx.Value(auth.AuthUserKey).(string); ok { + acessibleDefinitions, err := queries.GetAccessibleDefinitionsByUser(ctx, database.GetAccessibleDefinitionsByUserParams{ + Owner: authUserHandle, + Limit: 999, + Offset: 0, + }) + if err != nil && err != pgx.ErrNoRows { + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve accessible definitions for user %s: %v", authUserHandle, err)) + } else if err == pgx.ErrNoRows { + return nil, huma.Error403Forbidden(fmt.Sprintf("user %s does not have access to llm definition %s/%s", authUserHandle, input.UserHandle, input.DefinitionHandle)) + } + for _, d := range acessibleDefinitions { + if d.DefinitionID == def.DefinitionID { + authorized = true + break + } + } + } + if !authorized { + return nil, huma.Error403Forbidden(fmt.Sprintf("user does not have access to llm definition %s/%s", input.UserHandle, input.DefinitionHandle)) + } + } + + // Build response + ls := models.DefinitionFull{ + Owner: def.Owner, + DefinitionHandle: def.DefinitionHandle, + DefinitionID: int(def.DefinitionID), + Endpoint: def.Endpoint, + Description: def.Description.String, + APIStandard: def.APIStandard, + Model: def.Model, + Dimensions: def.Dimensions, + ContextLimit: def.ContextLimit, + IsPublic: def.IsPublic, + } + response := &models.GetDefinitionResponse{} + response.Body = ls + + return response, nil +} + +func getUserDefinitionsFunc(ctx context.Context, input *models.GetUserDefinitionsRequest) (*models.GetUserDefinitionsResponse, error) { + + // Get the database connection pool from the context + pool, err := GetDBPool(ctx) + if err != nil { + return nil, huma.Error500InternalServerError("database connection error: %v", err) + } else if pool == nil { + return nil, huma.Error500InternalServerError("database connection pool is nil") + } + queries := database.New(pool) + + // - check if user exists + if _, err := queries.RetrieveUser(ctx, input.UserHandle); err != nil { + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("user %s not found", input.UserHandle)) + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to access user %s. %v", input.UserHandle, err)) + } + + // Run the query - get all accessible instances (own + shared) + def, err := queries.GetAccessibleDefinitionsByUser(ctx, database.GetAccessibleDefinitionsByUserParams{ + Owner: input.UserHandle, + Limit: int32(input.Limit), + Offset: int32(input.Offset), + }) + if err != nil { + if err.Error() == "no rows in result set" { + // Return empty list instead of error + response := &models.GetUserDefinitionsResponse{} + response.Body.Definitions = []models.DefinitionBrief{} + return response, nil + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve llm service definitions for user %s: %v", input.UserHandle, err)) + } + + // Build response + ls := []models.DefinitionBrief{} + for _, d := range def { + ls = append(ls, models.DefinitionBrief{ + Owner: d.Owner, + DefinitionHandle: d.DefinitionHandle, + DefinitionID: int(d.DefinitionID), + IsPublic: d.IsPublic, + }) + } + response := &models.GetUserDefinitionsResponse{} + response.Body.Definitions = ls + + return response, nil +} + +func deleteDefinitionFunc(ctx context.Context, input *models.DeleteDefinitionRequest) (*models.DeleteDefinitionResponse, error) { + + // Check if user exists + u, err := getUserFunc(ctx, &models.GetUserRequest{UserHandle: input.UserHandle}) + if err != nil { return nil, huma.Error404NotFound(fmt.Sprintf("user %s not found", input.UserHandle)) } + if u.Body.UserHandle != input.UserHandle { + return nil, huma.Error500InternalServerError(fmt.Sprintf("user handle of retrieved record does not match retrieving handle %s", input.UserHandle)) + } + + // Check if llm service definition exists + _, err = getDefinitionFunc(ctx, &models.GetDefinitionRequest{UserHandle: input.UserHandle, DefinitionHandle: input.DefinitionHandle}) + if err != nil { + return nil, huma.Error404NotFound(fmt.Sprintf("definition %s/%s not found", input.UserHandle, input.DefinitionHandle)) + } // Get the database connection pool from the context pool, err := GetDBPool(ctx) if err != nil { return nil, err } + queries := database.New(pool) // Run the query + err = queries.DeleteDefinition(ctx, database.DeleteDefinitionParams{ + Owner: input.UserHandle, + DefinitionHandle: input.DefinitionHandle, + }) + if err != nil { + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to delete llm service definition %s for user %s: %v", input.DefinitionHandle, input.UserHandle, err)) + } + + // Build response + response := &models.DeleteDefinitionResponse{} + + return response, nil +} + +// share/unshare LLM Service Definitions + +func shareDefinitionFunc(ctx context.Context, input *models.ShareDefinitionRequest) (*models.ShareDefinitionResponse, error) { + // Get the database connection pool from the context + pool, err := GetDBPool(ctx) + if err != nil { + return nil, huma.Error500InternalServerError("Error accessing database connection: %v", err) + } queries := database.New(pool) - llm, err := queries.RetrieveLLM(ctx, database.RetrieveLLMParams{Owner: input.UserHandle, LLMServiceHandle: input.LLMServiceHandle}) + + // Check if definition exists and belongs to owner + definition, err := queries.RetrieveDefinition(ctx, database.RetrieveDefinitionParams{ + Owner: input.UserHandle, + DefinitionHandle: input.DefinitionHandle, + }) if err != nil { if err.Error() == "no rows in result set" { - return nil, huma.Error404NotFound(fmt.Sprintf("llm service %s for user %s not found", input.LLMServiceHandle, input.UserHandle)) + return nil, huma.Error404NotFound(fmt.Sprintf("definition %s/%s not found", input.UserHandle, input.DefinitionHandle)) } - return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve llm service %s for user %s. %v", input.LLMServiceHandle, input.UserHandle, err)) + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve definition: %v", err)) } - if llm.LLMServiceHandle != input.LLMServiceHandle { - return nil, huma.Error404NotFound(fmt.Sprintf("llm service %s for user %s not found", input.LLMServiceHandle, input.UserHandle)) + if definition.Owner != ctx.Value(auth.AuthUserKey).(string) { + return nil, huma.Error401Unauthorized(fmt.Sprintf("Not authorized to share definition %s/%s", input.UserHandle, input.DefinitionHandle)) + } + + // Check if target user exists + _, err = getUserFunc(ctx, &models.GetUserRequest{UserHandle: input.Body.ShareWithHandle}) + if err != nil { + return nil, huma.Error400BadRequest(fmt.Sprintf("target user %s does not exist: %v", input.Body.ShareWithHandle, err)) + } + + // Share the definition + err = queries.LinkDefinitionToUser(ctx, database.LinkDefinitionToUserParams{ + UserHandle: input.Body.ShareWithHandle, + DefinitionID: definition.DefinitionID, + }) + if err != nil { + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to share definition: %v", err)) } // Build response - ls := models.LLMService{ - Owner: llm.Owner, - LLMServiceHandle: llm.LLMServiceHandle, - LLMServiceID: int(llm.LLMServiceID), - Endpoint: llm.Endpoint, - Description: llm.Description.String, - APIKey: llm.APIKey.String, - APIStandard: llm.APIStandard, - Model: llm.Model, - Dimensions: int32(llm.Dimensions), + sharedUsers := []string{} + sharedUsers = append(sharedUsers, input.Body.ShareWithHandle) + response := &models.ShareDefinitionResponse{} + response.Body.Owner = input.UserHandle + response.Body.DefinitionHandle = input.DefinitionHandle + response.Body.SharedWith = sharedUsers + + return response, nil +} + +func unshareDefinitionFunc(ctx context.Context, input *models.UnshareDefinitionRequest) (*models.UnshareDefinitionResponse, error) { + + // Get the database connection pool from the context + pool, err := GetDBPool(ctx) + if err != nil { + return nil, huma.Error500InternalServerError("Error accessing database connection: %v", err) } - response := &models.GetLLMResponse{} - response.Body = ls + queries := database.New(pool) + + // Check if definition exists and belongs to owner + definition, err := queries.RetrieveDefinition(ctx, database.RetrieveDefinitionParams{ + Owner: input.UserHandle, + DefinitionHandle: input.DefinitionHandle, + }) + if err != nil { + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("definition %s/%s not found", input.UserHandle, input.DefinitionHandle)) + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve definition: %v", err)) + } + if definition.Owner != ctx.Value(auth.AuthUserKey).(string) { + return nil, huma.Error401Unauthorized(fmt.Sprintf("Not authorized to share definition %s/%s", input.UserHandle, input.DefinitionHandle)) + } + fmt.Printf("Definition retrieved: %s/%s (id %d)\n", definition.Owner, definition.DefinitionHandle, definition.DefinitionID) + fmt.Printf("Attempting to unshare with %s\n", input.UnshareWithHandle) + + // Unshare the definition + err = queries.UnlinkDefinition(ctx, database.UnlinkDefinitionParams{ + UserHandle: input.UnshareWithHandle, + DefinitionID: definition.DefinitionID, + }) + if err != nil { + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to unshare definition: %v", err)) + } + + // Build response + response := &models.UnshareDefinitionResponse{} return response, nil } -func getUserLLMsFunc(ctx context.Context, input *models.GetUserLLMsRequest) (*models.GetUserLLMsResponse, error) { +func getDefinitionSharedUsersFunc(ctx context.Context, input *models.GetDefinitionSharedUsersRequest) (*models.GetDefinitionSharedUsersResponse, error) { + + // Get the database connection pool from the context + pool, err := GetDBPool(ctx) + if err != nil { + return nil, huma.Error500InternalServerError("Error accessing database connection: %v", err) + } + queries := database.New(pool) + + // Get shared users + sharedUsers, err := queries.GetSharedUsersForDefinition(ctx, database.GetSharedUsersForDefinitionParams{ + Owner: input.UserHandle, + DefinitionHandle: input.DefinitionHandle, + }) + if err != nil { + if err.Error() == "no rows in result set" { + // Return empty list instead of error + response := &models.GetDefinitionSharedUsersResponse{} + response.Body = nil + return response, nil + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve shared users: %v", err)) + } + + response := &models.GetDefinitionSharedUsersResponse{} + response.Body = sharedUsers + + return response, nil +} + +// === LLM Service Instances === + +// Create a llm service instance (with a handle being present in the URL) +func putInstanceFunc(ctx context.Context, input *models.PutInstanceRequest) (*models.UploadInstanceResponse, error) { + if input.InstanceHandle != input.Body.InstanceHandle { + return nil, huma.Error400BadRequest(fmt.Sprintf("instance handle in URL (\"%s\") does not match instance handle in body (\"%s\")", input.InstanceHandle, input.Body.InstanceHandle)) + } + // Check if user exists u, err := getUserFunc(ctx, &models.GetUserRequest{UserHandle: input.UserHandle}) + if err != nil { + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("user %s not found", input.UserHandle)) + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to access user %s. %v", input.UserHandle, err)) + } + if u.Body.UserHandle != input.UserHandle { + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to access user %s. %v", input.UserHandle, err)) + } + + // Get the database connection pool from the context + pool, err := GetDBPool(ctx) + if err != nil { + return nil, err + } + + // Get encryption key if available + encKey := getEncryptionKey() + + // Execute all database operations within a transaction + var instanceID int32 + var instanceHandle string + var owner string + + err = database.WithTransaction(ctx, pool, func(tx pgx.Tx) error { + queries := database.New(tx) + + // Prepare API key encryption + var APIKeyEncrypted []byte + if input.Body.APIKey != "" && encKey != nil { + APIKeyEncrypted, err = encKey.Encrypt(input.Body.APIKey) + if err != nil { + return huma.Error500InternalServerError(fmt.Sprintf("unable to encrypt API key: %v", err)) + } + } + + // 1. Upsert LLM service instance + llm, err := queries.UpsertInstance(ctx, database.UpsertInstanceParams{ + Owner: input.UserHandle, + InstanceHandle: input.InstanceHandle, + DefinitionID: pgtype.Int4{Valid: false}, // Standalone instance (no definition reference) + Endpoint: input.Body.Endpoint, + Description: pgtype.Text{String: input.Body.Description, Valid: true}, + APIKeyEncrypted: APIKeyEncrypted, + APIStandard: input.Body.APIStandard, + Model: input.Body.Model, + Dimensions: int32(input.Body.Dimensions), + }) + if err != nil { + return huma.Error500InternalServerError(fmt.Sprintf("unable to upload llm service instance: %v", err)) + } + + instanceID = llm.InstanceID + instanceHandle = llm.InstanceHandle + owner = llm.Owner + + return nil + }) + if err != nil { return nil, err } + + // Build response + response := &models.UploadInstanceResponse{} + response.Body.Owner = owner + response.Body.InstanceHandle = instanceHandle + response.Body.InstanceID = int(instanceID) + + return response, nil +} + +// Create a llm service (without a handle being present in the URL) +func postInstanceFunc(ctx context.Context, input *models.PostInstanceRequest) (*models.UploadInstanceResponse, error) { + return putInstanceFunc(ctx, &models.PutInstanceRequest{UserHandle: input.UserHandle, InstanceHandle: input.Body.InstanceHandle, Body: input.Body}) +} + +// Create a llm service instance based on a definition +func postInstanceFromDefinitionFunc(ctx context.Context, input *models.PostInstanceFromDefinitionRequest) (*models.UploadInstanceResponse, error) { + if input.UserHandle != input.Body.UserHandle { + return nil, huma.Error400BadRequest(fmt.Sprintf("user handle in URL (\"%s\") does not match user handle in body (\"%s\")", input.UserHandle, input.Body.UserHandle)) + } + + // Check if user exists + u, err := getUserFunc(ctx, &models.GetUserRequest{UserHandle: input.UserHandle}) + if err != nil { + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("user %s not found", input.UserHandle)) + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to access user %s. %v", input.UserHandle, err)) + } if u.Body.UserHandle != input.UserHandle { - return nil, huma.Error404NotFound(fmt.Sprintf("user %s not found", input.UserHandle)) + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to access user %s. %v", input.UserHandle, err)) } // Get the database connection pool from the context @@ -151,41 +505,254 @@ func getUserLLMsFunc(ctx context.Context, input *models.GetUserLLMsRequest) (*mo return nil, err } + // Get encryption key if available + encKey := getEncryptionKey() + + // Execute all database operations within a transaction + var instanceID int32 + var instanceHandle string + var owner string + + err = database.WithTransaction(ctx, pool, func(tx pgx.Tx) error { + queries := database.New(tx) + + // Prepare API key encryption + var APIKeyEncrypted []byte + if input.Body.APIKey != "" && encKey != nil { + APIKeyEncrypted, err = encKey.Encrypt(input.Body.APIKey) + if err != nil { + return fmt.Errorf("unable to encrypt API key: %v", err) + } + } + + // Get definition to base instance on + definition, err := queries.RetrieveDefinition(ctx, database.RetrieveDefinitionParams{ + Owner: input.Body.DefinitionOwner, + DefinitionHandle: input.Body.DefinitionHandle, + }) + if err != nil { + if err.Error() == "no rows in result set" { + return huma.Error404NotFound(fmt.Sprintf("definition %s/%s not found", input.Body.DefinitionOwner, input.Body.DefinitionHandle)) + } + return huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve definition %s/%s: %v", input.Body.DefinitionOwner, input.Body.DefinitionHandle, err)) + } + // Check if user has access to the definition (either owner or shared) + if !definition.IsPublic && definition.Owner != ctx.Value(auth.AuthUserKey).(string) { + hasAccess := false + // Check if shared with user + sharedUsers, err := queries.GetSharedUsersForDefinition(ctx, database.GetSharedUsersForDefinitionParams{ + Owner: input.Body.DefinitionOwner, + DefinitionHandle: input.Body.DefinitionHandle, + }) + if err != nil && err.Error() != "no rows in result set" { + return huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve shared users for definition %s/%s: %v", input.Body.DefinitionOwner, input.Body.DefinitionHandle, err)) + } + if slices.Contains(sharedUsers, ctx.Value(auth.AuthUserKey).(string)) { + hasAccess = true + } + if !hasAccess { + return huma.Error401Unauthorized(fmt.Sprintf("user does not have access to definition %s/%s", input.Body.DefinitionOwner, input.Body.DefinitionHandle)) + } + } + + // merge definition values with instance overrides from request body + if input.Body.Endpoint == "" { + input.Body.Endpoint = definition.Endpoint + } + if input.Body.Description == "" { + input.Body.Description = definition.Description.String + } + if input.Body.APIStandard == "" { + input.Body.APIStandard = definition.APIStandard + } + if input.Body.Model == "" { + input.Body.Model = definition.Model + } + if input.Body.Dimensions == 0 { + input.Body.Dimensions = definition.Dimensions + } + if input.Body.ContextLimit == 0 { + input.Body.ContextLimit = definition.ContextLimit + } + + // 1. Upsert LLM service instance + llm, err := queries.UpsertInstance(ctx, database.UpsertInstanceParams{ + Owner: input.UserHandle, + InstanceHandle: input.Body.InstanceHandle, + DefinitionID: pgtype.Int4{Int32: int32(definition.DefinitionID), Valid: true}, // Standalone instance (no definition reference) + Endpoint: input.Body.Endpoint, + Description: pgtype.Text{String: input.Body.Description, Valid: true}, + APIKeyEncrypted: APIKeyEncrypted, + APIStandard: input.Body.APIStandard, + Model: input.Body.Model, + Dimensions: int32(input.Body.Dimensions), + ContextLimit: int32(input.Body.ContextLimit), + }) + if err != nil { + return fmt.Errorf("unable to upload llm service instance: %v", err) + } + + instanceID = llm.InstanceID + instanceHandle = llm.InstanceHandle + owner = llm.Owner + + return nil + }) + if err != nil { + return nil, err + } + + // Build response + response := &models.UploadInstanceResponse{} + response.Body.Owner = owner + response.Body.InstanceHandle = instanceHandle + response.Body.InstanceID = int(instanceID) + + return response, nil +} + +func getInstanceFunc(ctx context.Context, input *models.GetInstanceRequest) (*models.GetInstanceResponse, error) { + // Check if user exists + u, err := getUserFunc(ctx, &models.GetUserRequest{UserHandle: input.UserHandle}) + if err != nil { + return nil, huma.Error404NotFound(fmt.Sprintf("user %s not found", input.UserHandle)) + } + if u.Body.UserHandle != input.UserHandle { + return nil, huma.Error500InternalServerError(fmt.Sprintf("user handle in retrieved record does not match retrieve handle %s", input.UserHandle)) + } + + // Get the database connection pool from the context + pool, err := GetDBPool(ctx) + if err != nil { + return nil, huma.Error500InternalServerError("Unable to access database pool") + } + // Run the query queries := database.New(pool) - llms, err := queries.GetLLMsByUser(ctx, database.GetLLMsByUserParams{UserHandle: input.UserHandle, Limit: int32(input.Limit), Offset: int32(input.Offset)}) + llm, err := queries.RetrieveInstance(ctx, database.RetrieveInstanceParams{ + Owner: input.UserHandle, + InstanceHandle: input.InstanceHandle, + }) if err != nil { if err.Error() == "no rows in result set" { - return nil, huma.Error404NotFound(fmt.Sprintf("no llm services for %s found", input.UserHandle)) + return nil, huma.Error404NotFound(fmt.Sprintf("llm service %s for user %s not found", input.InstanceHandle, input.UserHandle)) } - return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve llm services. %v", err)) + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve llm service %s for user %s: %v", input.InstanceHandle, input.UserHandle, err)) } - if len(llms) == 0 { - return nil, huma.Error404NotFound(fmt.Sprintf("no llm services for %s found", input.UserHandle)) + if llm.InstanceHandle != input.InstanceHandle { + return nil, huma.Error404NotFound(fmt.Sprintf("llm service %s for user %s not found", input.InstanceHandle, input.UserHandle)) } - // Build response - ls := []models.LLMService{} + // Retrieve the authenticated user's access role + accessRole := "" + if authUserHandle, ok := ctx.Value(auth.AuthUserKey).(string); ok { + acessibleInstances, err := queries.GetAccessibleInstancesByUser(ctx, database.GetAccessibleInstancesByUserParams{ + Owner: authUserHandle, + Limit: 999, + Offset: 0, + }) + if err != nil && err != pgx.ErrNoRows { + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve accessible instances for user %s: %v", authUserHandle, err)) + } else if err == pgx.ErrNoRows { + return nil, huma.Error403Forbidden(fmt.Sprintf("user %s does not have access to llm service %s/%s", authUserHandle, input.UserHandle, input.InstanceHandle)) + } + found := false + for _, inst := range acessibleInstances { + if inst.InstanceID == llm.InstanceID { + found = true + accessRole = inst.Role.(string) + break + } + } + if !found { + return nil, huma.Error403Forbidden(fmt.Sprintf("user %s does not have access to llm service %s/%s", authUserHandle, input.UserHandle, input.InstanceHandle)) + } + } else { + // No authenticated user in context, only possible if public access is allowed (not implemented) + // TODO: implement public access for instances + return nil, huma.Error403Forbidden("no authenticated user in context") + } + + defID := int32(0) + if llm.DefinitionID.Valid { + defID = llm.DefinitionID.Int32 + } + + // Build response (never return API key in plaintext) + ls := models.InstanceFull{ + Owner: llm.Owner, + InstanceHandle: llm.InstanceHandle, + InstanceID: int(llm.InstanceID), + AccessRole: accessRole, + DefinitionID: int(defID), + DefinitionOwner: llm.DefinitionOwner.String, + DefinitionHandle: llm.DefinitionHandle.String, + Endpoint: llm.Endpoint, + Description: llm.Description.String, + HasAPIKey: llm.HasAPIKey, + // APIKey: "", // Never return API key + APIStandard: llm.APIStandard, + Model: llm.Model, + Dimensions: llm.Dimensions, + ContextLimit: llm.ContextLimit, + } + response := &models.GetInstanceResponse{} + response.Body = ls + + return response, nil +} + +func getUserInstancesFunc(ctx context.Context, input *models.GetUserInstancesRequest) (*models.GetUserInstancesResponse, error) { + + // Get the database connection pool from the context + pool, err := GetDBPool(ctx) + if err != nil { + return nil, huma.Error500InternalServerError("database connection error: %v", err) + } else if pool == nil { + return nil, huma.Error500InternalServerError("database connection pool is nil") + } + queries := database.New(pool) + + // - check if user exists + if _, err := queries.RetrieveUser(ctx, input.UserHandle); err != nil { + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("user %s not found", input.UserHandle)) + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to access user %s. %v", input.UserHandle, err)) + } + + // Run the query - get all accessible instances (own + shared) + llms, err := queries.GetAccessibleInstancesByUser(ctx, database.GetAccessibleInstancesByUserParams{ + Owner: input.UserHandle, + Limit: int32(input.Limit), + Offset: int32(input.Offset), + }) + if err != nil { + if err.Error() == "no rows in result set" { + // Return empty list instead of error + response := &models.GetUserInstancesResponse{} + response.Body.Instances = []models.InstanceBrief{} + return response, nil + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve llm service instances: %v", err)) + } + + // Build response (hide API keys for shared instances) + ls := []models.InstanceBrief{} for _, llm := range llms { - ls = append(ls, models.LLMService{ - Owner: llm.Owner, - LLMServiceHandle: llm.LLMServiceHandle, - LLMServiceID: int(llm.LLMServiceID), - Endpoint: llm.Endpoint, - Description: llm.Description.String, - APIKey: llm.APIKey.String, - APIStandard: llm.APIStandard, - Model: llm.Model, - Dimensions: int32(llm.Dimensions), + ls = append(ls, models.InstanceBrief{ + Owner: llm.Owner, + InstanceHandle: llm.InstanceHandle, + InstanceID: int(llm.InstanceID), }) } - response := &models.GetUserLLMsResponse{} - response.Body.LLMServices = ls + response := &models.GetUserInstancesResponse{} + response.Body.Instances = ls return response, nil } -func deleteLLMFunc(ctx context.Context, input *models.DeleteLLMRequest) (*models.DeleteLLMResponse, error) { +func deleteInstanceFunc(ctx context.Context, input *models.DeleteInstanceRequest) (*models.DeleteInstanceResponse, error) { // Check if user exists u, err := getUserFunc(ctx, &models.GetUserRequest{UserHandle: input.UserHandle}) if err != nil { @@ -195,8 +762,8 @@ func deleteLLMFunc(ctx context.Context, input *models.DeleteLLMRequest) (*models return nil, huma.Error404NotFound(fmt.Sprintf("user %s not found", input.UserHandle)) } - // Check if llm service exists - _, err = getLLMFunc(ctx, &models.GetLLMRequest{UserHandle: input.UserHandle, LLMServiceHandle: input.LLMServiceHandle}) + // Check if llm service instance exists + _, err = getInstanceFunc(ctx, &models.GetInstanceRequest{UserHandle: input.UserHandle, InstanceHandle: input.InstanceHandle}) if err != nil { return nil, err } @@ -209,85 +776,407 @@ func deleteLLMFunc(ctx context.Context, input *models.DeleteLLMRequest) (*models // Run the query queries := database.New(pool) - err = queries.DeleteLLM(ctx, database.DeleteLLMParams{Owner: input.UserHandle, LLMServiceHandle: input.LLMServiceHandle}) + err = queries.DeleteInstance(ctx, database.DeleteInstanceParams{ + Owner: input.UserHandle, + InstanceHandle: input.InstanceHandle, + }) if err != nil { - return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to delete llm service %s for user %s. %v", input.LLMServiceHandle, input.UserHandle, err)) + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to delete llm service %s for user %s: %v", input.InstanceHandle, input.UserHandle, err)) } // Build response - response := &models.DeleteLLMResponse{} + response := &models.DeleteInstanceResponse{} return response, nil } -// RegisterLLMServiceRoutes registers the routes for the management of LLM services -func RegisterLLMServicesRoutes(pool *pgxpool.Pool, api huma.API) error { +// share/unshare LLM Service Instances + +func shareInstanceFunc(ctx context.Context, input *models.ShareInstanceRequest) (*models.ShareInstanceResponse, error) { + + // Get the database connection pool from the context + pool, err := GetDBPool(ctx) + if err != nil { + return nil, err + } + queries := database.New(pool) + + // Check if ShareWithUser is identical to owner (no need to share if sharing with self) + if input.Body.ShareWithHandle == input.UserHandle { + return nil, huma.Error400BadRequest("cannot share instance with owner") + } + // Check if role is valid + if input.Body.Role != "editor" && input.Body.Role != "reader" { + return nil, huma.Error400BadRequest(fmt.Sprintf("invalid role %s. Role must be either \"editor\" or \"reader\"", input.Body.Role)) + } + // Check if instance exists + instance, err := queries.RetrieveInstance(ctx, database.RetrieveInstanceParams{ + Owner: input.UserHandle, + InstanceHandle: input.InstanceHandle, + }) + if err != nil { + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("instance %s/%s not found", input.UserHandle, input.InstanceHandle)) + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve instance %s/%s: %v", input.UserHandle, input.InstanceHandle, err)) + } + // Check if instance belongs to current user (only owner can share) + if instance.Owner != ctx.Value(auth.AuthUserKey).(string) { + return nil, huma.Error401Unauthorized(fmt.Sprintf("Not authorized to share instance %s/%s", input.UserHandle, input.InstanceHandle)) + } + // Check if target user exists + _, err = getUserFunc(ctx, &models.GetUserRequest{UserHandle: input.Body.ShareWithHandle}) + if err != nil { + return nil, huma.Error400BadRequest(fmt.Sprintf("target user %s does not exist: %v", input.Body.ShareWithHandle, err)) + } + + // Share the instance + err = queries.LinkInstanceToUser(ctx, database.LinkInstanceToUserParams{ + UserHandle: input.Body.ShareWithHandle, + InstanceID: instance.InstanceID, + Role: input.Body.Role, + }) + if err != nil { + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to share instance: %v", err)) + } + + // Build response + // (only the instance owner can share, so we know they have sent the request, + // meaning we can show all shared users) + // TODO: validate: retrieve shared users from database instead of just returning the input values + sharedUsers := []models.SharedUser{} + sharedUsers = append(sharedUsers, models.SharedUser{ + UserHandle: input.Body.ShareWithHandle, + Role: input.Body.Role, + }) + response := &models.ShareInstanceResponse{} + response.Body.Owner = input.UserHandle + response.Body.InstanceHandle = input.InstanceHandle + response.Body.SharedWith = sharedUsers + + return response, nil +} + +func unshareInstanceFunc(ctx context.Context, input *models.UnshareInstanceRequest) (*models.UnshareInstanceResponse, error) { + // Get the database connection pool from the context + pool, err := GetDBPool(ctx) + if err != nil { + return nil, err + } + queries := database.New(pool) + + // Check if instance exists and belongs to owner + instance, err := queries.RetrieveInstance(ctx, database.RetrieveInstanceParams{ + Owner: input.UserHandle, + InstanceHandle: input.InstanceHandle, + }) + if err != nil { + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("instance %s/%s not found", input.UserHandle, input.InstanceHandle)) + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve instance: %v", err)) + } + + // Check if target user exists and is currently shared + sharedUsers, err := queries.GetSharedUsersForInstance(ctx, database.GetSharedUsersForInstanceParams{ + Owner: input.UserHandle, + InstanceHandle: input.InstanceHandle, + }) + if err != nil { + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("instance %s/%s is not shared with user %s", input.UserHandle, input.InstanceHandle, input.UnshareWithHandle)) + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve shared users for instance: %v", err)) + } + for _, su := range sharedUsers { + if su.UserHandle == input.UnshareWithHandle { + // Unshare the instance + err = queries.UnlinkInstance(ctx, database.UnlinkInstanceParams{ + UserHandle: input.UnshareWithHandle, + InstanceID: instance.InstanceID, + }) + if err != nil { + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to unshare instance %s/%s from user %s: %v", input.UserHandle, input.InstanceHandle, input.UnshareWithHandle, err)) + } + // Build response + response := &models.UnshareInstanceResponse{} + return response, nil + } + } + // If we get here, the target user exists but is not currently shared + return nil, huma.Error404NotFound(fmt.Sprintf("instance %s/%s is not shared with user %s", input.UserHandle, input.InstanceHandle, input.UnshareWithHandle)) +} + +func getInstanceSharedUsersFunc(ctx context.Context, input *models.GetInstanceSharedUsersRequest) (*models.GetInstanceSharedUsersResponse, error) { + + // Get the database connection pool from the context + pool, err := GetDBPool(ctx) + if err != nil { + return nil, err + } + queries := database.New(pool) + + // Get shared users + sharedUsers, err := queries.GetSharedUsersForInstance(ctx, database.GetSharedUsersForInstanceParams{ + Owner: input.UserHandle, + InstanceHandle: input.InstanceHandle, + }) + if err != nil { + if err.Error() == "no rows in result set" { + // Return empty list instead of error + response := &models.GetInstanceSharedUsersResponse{} + response.Body.SharedWith = []models.SharedUser{} + return response, nil + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve shared users: %v", err)) + } + + // Build response + users := []models.SharedUser{} + for _, su := range sharedUsers { + users = append(users, models.SharedUser{ + UserHandle: su.UserHandle, + Role: su.Role, + }) + } + + response := &models.GetInstanceSharedUsersResponse{} + response.Body.Owner = input.UserHandle + response.Body.InstanceHandle = input.InstanceHandle + response.Body.SharedWith = users + + return response, nil +} + +// === Registration of Routes === + +// RegisterDefinitionsRoutes registers the routes for the management of LLM service definitions +func RegisterDefinitionsRoutes(pool *pgxpool.Pool, api huma.API) error { + // Define huma.Operations for each route + putDefinitionOp := huma.Operation{ + OperationID: "putDefinition", + Method: http.MethodPut, + Path: "/v1/llm-definitions/{user_handle}/{definition_handle}", + DefaultStatus: http.StatusCreated, + Summary: "Create or update an llm service definition", + Security: []map[string][]string{ + {"adminAuth": []string{"admin"}}, + {"ownerAuth": []string{"owner"}}, + }, + Tags: []string{"llm-definitions"}, + } + postDefinitionOp := huma.Operation{ + OperationID: "postDefinition", + Method: http.MethodPost, + Path: "/v1/llm-definitions/{user_handle}", + DefaultStatus: http.StatusCreated, + Summary: "Create an llm service definition (with auto-generated handle)", + Security: []map[string][]string{ + {"adminAuth": []string{"admin"}}, + {"ownerAuth": []string{"owner"}}, + }, + Tags: []string{"llm-definitions"}, + } + getDefinitionOp := huma.Operation{ + OperationID: "getDefinition", + Method: http.MethodGet, + Path: "/v1/llm-definitions/{user_handle}/{definition_handle}", + Summary: "Get an llm service definition", + Security: []map[string][]string{ + {"adminAuth": []string{"admin"}}, + {"ownerAuth": []string{"owner"}}, + {"readerAuth": []string{"reader"}}, + }, + Tags: []string{"llm-definitions"}, + } + getDefinitionsOp := huma.Operation{ + OperationID: "getDefinitions", + Method: http.MethodGet, + Path: "/v1/llm-definitions/{user_handle}", + Summary: "Get all llm service definitions for a user", + Security: []map[string][]string{ + {"adminAuth": []string{"admin"}}, + {"ownerAuth": []string{"owner"}}, + }, + Tags: []string{"llm-definitions"}, + } + deleteDefinitionOp := huma.Operation{ + OperationID: "deleteDefinition", + Method: http.MethodDelete, + Path: "/v1/llm-definitions/{user_handle}/{definition_handle}", + DefaultStatus: http.StatusNoContent, + Summary: "Delete an llm service definition", + Security: []map[string][]string{ + {"adminAuth": []string{"admin"}}, + {"ownerAuth": []string{"owner"}}, + }, + Tags: []string{"llm-definitions"}, + } + shareDefinitionOp := huma.Operation{ + OperationID: "shareDefinition", + Method: http.MethodPost, + Path: "/v1/llm-definitions/{user_handle}/{definition_handle}/share", + DefaultStatus: http.StatusCreated, + Summary: "Share an llm service definition with another user", + Security: []map[string][]string{ + {"adminAuth": []string{"admin"}}, + {"ownerAuth": []string{"owner"}}, + }, + Tags: []string{"llm-definitions"}, + } + unshareDefinitionOp := huma.Operation{ + OperationID: "unshareDefinition", + Method: http.MethodDelete, + Path: "/v1/llm-definitions/{user_handle}/{definition_handle}/share/{unshare_with_handle}", + DefaultStatus: http.StatusNoContent, + Summary: "Unshare an llm service definition from a user", + Security: []map[string][]string{ + {"adminAuth": []string{"admin"}}, + {"ownerAuth": []string{"owner"}}, + }, + Tags: []string{"llm-definitions"}, + } + getDefinitionSharedUsersOp := huma.Operation{ + OperationID: "getDefinitionSharedUsers", + Method: http.MethodGet, + Path: "/v1/llm-definitions/{user_handle}/{definition_handle}/shared-with", + Summary: "Get list of users a definition is shared with", + Security: []map[string][]string{ + {"adminAuth": []string{"admin"}}, + {"ownerAuth": []string{"owner"}}, + }, + Tags: []string{"llm-definitions"}, + } + + huma.Register(api, putDefinitionOp, addPoolToContext(pool, putDefinitionFunc)) + huma.Register(api, postDefinitionOp, addPoolToContext(pool, postDefinitionFunc)) + huma.Register(api, getDefinitionOp, addPoolToContext(pool, getDefinitionFunc)) + huma.Register(api, getDefinitionsOp, addPoolToContext(pool, getUserDefinitionsFunc)) + huma.Register(api, deleteDefinitionOp, addPoolToContext(pool, deleteDefinitionFunc)) + huma.Register(api, shareDefinitionOp, addPoolToContext(pool, shareDefinitionFunc)) + huma.Register(api, unshareDefinitionOp, addPoolToContext(pool, unshareDefinitionFunc)) + huma.Register(api, getDefinitionSharedUsersOp, addPoolToContext(pool, getDefinitionSharedUsersFunc)) + return nil +} + +// RegisterInstancesRoutes registers the routes for the management of LLM service instances +func RegisterInstancesRoutes(pool *pgxpool.Pool, api huma.API) error { // Define huma.Operations for each route - postLLMServiceOp := huma.Operation{ - OperationID: "postLLMService", + postInstanceOp := huma.Operation{ + OperationID: "postInstance", Method: http.MethodPost, - Path: "/v1/llm-services/{user_handle}", + Path: "/v1/llm-instances/{user_handle}", DefaultStatus: http.StatusCreated, - Summary: "Create llm service", + Summary: "Create llm service instance", Security: []map[string][]string{ {"adminAuth": []string{"admin"}}, {"ownerAuth": []string{"owner"}}, }, - Tags: []string{"llm-services"}, + Tags: []string{"llm-instances"}, } - putLLMServiceOp := huma.Operation{ - OperationID: "putLLMService", + putInstanceOp := huma.Operation{ + OperationID: "putInstance", Method: http.MethodPut, - Path: "/v1/llm-services/{user_handle}/{llm_service_handle}", + Path: "/v1/llm-instances/{user_handle}/{instance_handle}", DefaultStatus: http.StatusCreated, - Summary: "Create or update llm service", + Summary: "Create or update llm service instance", + Security: []map[string][]string{ + {"adminAuth": []string{"admin"}}, + {"ownerAuth": []string{"owner"}}, + }, + Tags: []string{"llm-instances"}, + } + postInstanceFromDefinitionOp := huma.Operation{ + OperationID: "postInstanceFromDefinition", + Method: http.MethodPost, + Path: "/v1/llm-instances/{user_handle}/from-definition", + Summary: "Create an llm service instance based on a definition", Security: []map[string][]string{ {"adminAuth": []string{"admin"}}, {"ownerAuth": []string{"owner"}}, }, - Tags: []string{"llm-services"}, + Tags: []string{"llm-instances"}, } - getUserLLMServicesOp := huma.Operation{ - OperationID: "getUserLLMServices", + getUserInstancesOp := huma.Operation{ + OperationID: "getUserInstances", Method: http.MethodGet, - Path: "/v1/llm-services/{user_handle}", - Summary: "Get all llm services for a user", + Path: "/v1/llm-instances/{user_handle}", + Summary: "Get all llm service instances for a user", Security: []map[string][]string{ {"adminAuth": []string{"admin"}}, {"ownerAuth": []string{"owner"}}, {"readerAuth": []string{"reader"}}, }, - Tags: []string{"llm-services"}, + Tags: []string{"llm-instances"}, } - getLLMServiceOp := huma.Operation{ - OperationID: "getLLMService", + getInstanceOp := huma.Operation{ + OperationID: "getInstance", Method: http.MethodGet, - Path: "/v1/llm-services/{user_handle}/{llm_service_handle}", - Summary: "Get a specific llm service for a user", + Path: "/v1/llm-instances/{user_handle}/{instance_handle}", + Summary: "Get a specific llm service instance for a user", Security: []map[string][]string{ {"adminAuth": []string{"admin"}}, {"ownerAuth": []string{"owner"}}, {"readerAuth": []string{"reader"}}, }, - Tags: []string{"llm-services"}, + Tags: []string{"llm-instances"}, } - deleteLLMServiceOp := huma.Operation{ - OperationID: "deleteLLMService", + deleteInstanceOp := huma.Operation{ + OperationID: "deleteInstance", Method: http.MethodDelete, - Path: "/v1/llm-services/{user_handle}/{llm_service_handle}", + Path: "/v1/llm-instances/{user_handle}/{instance_handle}", DefaultStatus: http.StatusNoContent, - Summary: "Delete a user's llm_service and all embeddings associated to it", + Summary: "Delete a user's llm service instance and all embeddings associated to it", + Security: []map[string][]string{ + {"adminAuth": []string{"admin"}}, + {"ownerAuth": []string{"owner"}}, + }, + Tags: []string{"llm-instances"}, + } + shareInstanceOp := huma.Operation{ + OperationID: "shareInstance", + Method: http.MethodPost, + Path: "/v1/llm-instances/{user_handle}/{instance_handle}/share", + DefaultStatus: http.StatusCreated, + Summary: "Share an llm service instance with another user", + Security: []map[string][]string{ + {"adminAuth": []string{"admin"}}, + {"ownerAuth": []string{"owner"}}, + }, + Tags: []string{"llm-instances"}, + } + unshareInstanceOp := huma.Operation{ + OperationID: "unshareInstance", + Method: http.MethodDelete, + Path: "/v1/llm-instances/{user_handle}/{instance_handle}/share/{unshare_with_handle}", + DefaultStatus: http.StatusNoContent, + Summary: "Unshare an llm service instance from a user", + Security: []map[string][]string{ + {"adminAuth": []string{"admin"}}, + {"ownerAuth": []string{"owner"}}, + }, + Tags: []string{"llm-instances"}, + } + getInstanceSharedUsersOp := huma.Operation{ + OperationID: "getInstanceSharedUsers", + Method: http.MethodGet, + Path: "/v1/llm-instances/{user_handle}/{instance_handle}/shared-with", + Summary: "Get list of users an instance is shared with", Security: []map[string][]string{ {"adminAuth": []string{"admin"}}, {"ownerAuth": []string{"owner"}}, }, - Tags: []string{"llm-services"}, + Tags: []string{"llm-instances"}, } - huma.Register(api, postLLMServiceOp, addPoolToContext(pool, postLLMFunc)) - huma.Register(api, putLLMServiceOp, addPoolToContext(pool, putLLMFunc)) - huma.Register(api, getUserLLMServicesOp, addPoolToContext(pool, getUserLLMsFunc)) - huma.Register(api, getLLMServiceOp, addPoolToContext(pool, getLLMFunc)) - huma.Register(api, deleteLLMServiceOp, addPoolToContext(pool, deleteLLMFunc)) + huma.Register(api, postInstanceOp, addPoolToContext(pool, postInstanceFunc)) + huma.Register(api, putInstanceOp, addPoolToContext(pool, putInstanceFunc)) + huma.Register(api, postInstanceFromDefinitionOp, addPoolToContext(pool, postInstanceFromDefinitionFunc)) + huma.Register(api, getUserInstancesOp, addPoolToContext(pool, getUserInstancesFunc)) + huma.Register(api, getInstanceOp, addPoolToContext(pool, getInstanceFunc)) + huma.Register(api, deleteInstanceOp, addPoolToContext(pool, deleteInstanceFunc)) + huma.Register(api, shareInstanceOp, addPoolToContext(pool, shareInstanceFunc)) + huma.Register(api, unshareInstanceOp, addPoolToContext(pool, unshareInstanceFunc)) + huma.Register(api, getInstanceSharedUsersOp, addPoolToContext(pool, getInstanceSharedUsersFunc)) return nil } diff --git a/internal/handlers/llm_services_sharing_test.go b/internal/handlers/llm_services_sharing_test.go new file mode 100644 index 0000000..faaf52d --- /dev/null +++ b/internal/handlers/llm_services_sharing_test.go @@ -0,0 +1,380 @@ +package handlers_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInstanceSharingFunc(t *testing.T) { + + // Get the database connection pool from package variable + pool := connPool + + // Create a mock key generator + mockKeyGen := new(MockKeyGen) + // Set up expectations for the mock key generator - return different keys for each call + mockKeyGen.On("RandomKey", 32).Return("12345678901234567890123456789012", nil).Once() // Alice's key + mockKeyGen.On("RandomKey", 32).Return("abcdefghijklmnopqrstuvwxyz123456", nil).Once() // Bob's key + mockKeyGen.On("RandomKey", 32).Return("98765432109876543210987654321098", nil).Maybe() // Any additional keys + + // Start the server + err, shutDownServer := startTestServer(t, pool, mockKeyGen) + assert.NoError(t, err) + + // Create users to be used in sharing tests + aliceJSON := `{"user_handle": "alice", "name": "Alice Doe", "email": "alice@foo.bar"}` + aliceAPIKey, err := createUser(t, aliceJSON) + if err != nil { + t.Fatalf("Error creating user alice for testing: %v\n", err) + } + + bobJSON := `{"user_handle": "bob", "name": "Bob Smith", "email": "bob@foo.bar"}` + bobAPIKey, err := createUser(t, bobJSON) + if err != nil { + t.Fatalf("Error creating user bob for testing: %v\n", err) + } + + // Create API standard to be used in tests + openaiJSON := `{"api_standard_handle": "openai", "description": "OpenAI Embeddings API", "key_method": "auth_bearer", "key_field": "Authorization" }` + _, err = createAPIStandard(t, openaiJSON, options.AdminKey) + if err != nil { + t.Fatalf("Error creating API standard openai for testing: %v\n", err) + } + + // Create an instance for alice + instanceJSON := `{"instance_handle": "embedding1", "endpoint": "https://api.openai.com/v1/embeddings", "description": "Alice's OpenAI instance", "api_standard": "openai", "model": "text-embedding-3-large", "dimensions": 3072}` + _, err = createInstance(t, instanceJSON, "alice", aliceAPIKey) + if err != nil { + t.Fatalf("Error creating instance for sharing tests: %v\n", err) + } + + fmt.Printf("\nRunning llm-instances sharing tests ...\n\n") + + // Define test cases + tt := []struct { + name string + method string + requestPath string + bodyJSON string + VDBKey string + expectBody string + expectStatus int16 + }{ + { + name: "Share instance with nonexistent user - should fail", + method: http.MethodPost, + requestPath: "/v1/llm-instances/alice/embedding1/share", + bodyJSON: `{"share_with_handle": "charlie", "role": "reader"}`, + VDBKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Bad Request\",\n \"status\": 400,\n \"detail\": \"target user charlie does not exist: user charlie not found\"\n}\n", + expectStatus: http.StatusBadRequest, + }, + { + name: "Share nonexistent instance - should fail", + method: http.MethodPost, + requestPath: "/v1/llm-instances/alice/nonexistent/share", + bodyJSON: `{"share_with_handle": "bob", "role": "reader"}`, + VDBKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"instance alice/nonexistent not found\"\n}\n", + expectStatus: http.StatusNotFound, + }, + { + name: "Bob cannot share alice's instance - should fail", + method: http.MethodPost, + requestPath: "/v1/llm-instances/alice/embedding1/share", + bodyJSON: `{"share_with_handle": "alice", "role": "editor"}`, + VDBKey: bobAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Unauthorized\",\n \"status\": 401,\n \"detail\": \"Authentication failed. Perhaps a missing or incorrect API key?\"\n}\n", + expectStatus: http.StatusUnauthorized, + }, + { + name: "Share instance with bob - valid", + method: http.MethodPost, + requestPath: "/v1/llm-instances/alice/embedding1/share", + bodyJSON: `{"share_with_handle": "bob", "role": "reader"}`, + VDBKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ShareInstanceResponseBody.json\",\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"shared_with\": [\n {\n \"user_handle\": \"bob\",\n \"role\": \"reader\"\n }\n ]\n}\n", + expectStatus: http.StatusCreated, + }, + { + name: "Get shared users for instance", + method: http.MethodGet, + requestPath: "/v1/llm-instances/alice/embedding1/shared-with", + bodyJSON: "", + VDBKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetInstanceSharedUsersResponseBody.json\",\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"shared_with\": [\n {\n \"user_handle\": \"bob\",\n \"role\": \"reader\"\n }\n ]\n}\n", + expectStatus: http.StatusOK, + }, + { + name: "Unshare instance from bob", + method: http.MethodDelete, + requestPath: "/v1/llm-instances/alice/embedding1/share/bob", + bodyJSON: "", + VDBKey: aliceAPIKey, + expectBody: "", + expectStatus: http.StatusNoContent, + }, + { + name: "Get shared users after unsharing - should be empty", + method: http.MethodGet, + requestPath: "/v1/llm-instances/alice/embedding1/shared-with", + bodyJSON: "", + VDBKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetInstanceSharedUsersResponseBody.json\",\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"shared_with\": []\n}\n", + expectStatus: http.StatusOK, + }, + } + + for _, v := range tt { + t.Run(v.name, func(t *testing.T) { + requestURL := fmt.Sprintf("http://%s:%d%s", options.Host, options.Port, v.requestPath) + + var req *http.Request + if v.bodyJSON != "" { + req, err = http.NewRequest(v.method, requestURL, bytes.NewBuffer([]byte(v.bodyJSON))) + } else { + req, err = http.NewRequest(v.method, requestURL, nil) + } + assert.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+v.VDBKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Errorf("Error sending request: %v\n", err) + } + defer resp.Body.Close() + + if resp.StatusCode != int(v.expectStatus) { + t.Errorf("Expected status code %d, got %s\n", v.expectStatus, resp.Status) + } else { + t.Logf("Expected status code %d, got %s\n", v.expectStatus, resp.Status) + } + + respBody, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + formattedResp := "" + if v.expectBody != "" { + fr := new(bytes.Buffer) + err = json.Indent(fr, respBody, "", " ") + assert.NoError(t, err) + formattedResp = fr.String() + } + assert.Equal(t, v.expectBody, formattedResp, "they should be equal") + }) + } + + // Verify that the expectations regarding the mock key generation were met + mockKeyGen.AssertExpectations(t) + + // Cleanup removes items created by the tests + t.Cleanup(func() { + fmt.Print("\n\nRunning cleanup ...\n\n") + + requestURL := fmt.Sprintf("http://%s:%d/v1/admin/footgun", options.Host, options.Port) + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+options.AdminKey) + _, err = http.DefaultClient.Do(req) + if err != nil && err.Error() != "no rows in result set" { + t.Fatalf("Error sending request: %v\n", err) + } + assert.NoError(t, err) + + fmt.Print("Shutting down server\n\n") + shutDownServer() + }) + + fmt.Printf("\n") +} + +func TestDefinitionSharingFunc(t *testing.T) { + + // Get the database connection pool from package variable + pool := connPool + + // Create a mock key generator + mockKeyGen := new(MockKeyGen) + // Set up expectations for the mock key generator - return different keys for each call + mockKeyGen.On("RandomKey", 32).Return("11111111111111111111111111111111", nil).Once() // Alice's key + mockKeyGen.On("RandomKey", 32).Return("22222222222222222222222222222222", nil).Once() // Bob's key + mockKeyGen.On("RandomKey", 32).Return("33333333333333333333333333333333", nil).Maybe() // Any additional keys + + // Start the server + err, shutDownServer := startTestServer(t, pool, mockKeyGen) + assert.NoError(t, err) + + // Create users + aliceJSON := `{"user_handle": "alice", "name": "Alice Doe", "email": "alice@foo.bar"}` + aliceAPIKey, err := createUser(t, aliceJSON) + if err != nil { + t.Fatalf("Error creating user alice for testing: %v\n", err) + } + + bobJSON := `{"user_handle": "bob", "name": "Bob Smith", "email": "bob@foo.bar"}` + bobAPIKey, err := createUser(t, bobJSON) + if err != nil { + t.Fatalf("Error creating user bob for testing: %v\n", err) + } + + // Create API standard + /* + openaiJSON := `{"api_standard_handle": "openai", "description": "OpenAI Embeddings API", "key_method": "auth_bearer", "key_field": "Authorization" }` + _, err = createAPIStandard(t, openaiJSON, options.AdminKey) + if err != nil { + t.Fatalf("Error creating API standard openai for testing: %v\n", err) + } + */ + + // Create a definition for alice + openaiLargeJSON := `{"owner": "alice", "definition_handle": "openai-large", "endpoint": "https://api.openai.com/v1/embeddings", "description": "OpenAI instance with large model", "api_standard": "openai", "model": "text-embedding-3-large", "dimensions": 3072, "context_limit": 8192, "is_public": false}` + _, err = createDefinition(t, openaiLargeJSON, "alice", aliceAPIKey) + if err != nil { + t.Fatalf("Error creating definition for sharing tests: %v\n", err) + } + + // Note: _system user and definitions are created by migration 004 + // They are public by default, so we can test alice creating an + // instance based on it. We can also text alice creating a + // definition and sharing it, since users can share their own + // definitions without needing to be shared with first. + // Finally, we can test admin creating and sharing a new + // _system definition. + fmt.Printf("\nRunning llm-definitions sharing tests ...\n\n") + + // Define test cases + tt := []struct { + name string + method string + requestPath string + bodyJSON string + VDBKey string + expectBody string + expectStatus int16 + }{ + { + name: "Bob cannot create an instance based on Alice's definition - should fail", + method: http.MethodPost, + requestPath: "/v1/llm-instances/bob/from-definition", + bodyJSON: `{"user_handle": "bob", "instance_handle": "bob-instance1", "definition_owner": "alice", "definition_handle": "openai-large", "endpoint": "https://api.openai.com/v1/embeddings", "description": "Bob's instance based on Alice's definition"}`, + VDBKey: bobAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Unauthorized\",\n \"status\": 401,\n \"detail\": \"user does not have access to definition alice/openai-large\"\n}\n", + expectStatus: http.StatusUnauthorized, + }, + { + name: "Create an instance based on a nonexistent definition - should fail", + method: http.MethodPost, + requestPath: "/v1/llm-instances/bob/from-definition", + bodyJSON: `{"user_handle": "bob", "instance_handle": "bob-instance1", "definition_owner": "alice", "definition_handle": "nonexistant", "endpoint": "https://api.openai.com/v1/embeddings", "description": "Bob's instance based on Alice's definition"}`, + VDBKey: bobAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"definition alice/nonexistant not found\"\n}\n", + expectStatus: http.StatusNotFound, + }, + { + name: "Bob can create an instance based on a _system definition - should succeed", + method: http.MethodPost, + requestPath: "/v1/llm-instances/bob/from-definition", + bodyJSON: `{"user_handle": "bob", "instance_handle": "bob-instance1", "definition_owner": "_system", "definition_handle": "openai-large", "endpoint": "https://api.openai.com/v1/embeddings", "description": "Bob's instance based on _system's definition"}`, + VDBKey: bobAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/UploadInstanceResponseBody.json\",\n \"owner\": \"bob\",\n \"instance_handle\": \"bob-instance1\",\n \"instance_id\": 1\n}\n", + expectStatus: http.StatusOK, + }, + { + name: "Alice shares her definition with Bob - should succeed", + method: http.MethodPost, + requestPath: "/v1/llm-definitions/alice/openai-large/share", + bodyJSON: `{"share_with_handle": "bob"}`, + VDBKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ShareDefinitionResponseBody.json\",\n \"owner\": \"alice\",\n \"definition_handle\": \"openai-large\",\n \"shared_with\": [\n \"bob\"\n ]\n}\n", + expectStatus: http.StatusCreated, + }, + { + name: "Get shared users for alice's definition - should succeed when called by alice", + method: http.MethodGet, + requestPath: "/v1/llm-definitions/alice/openai-large/shared-with", + bodyJSON: "", + VDBKey: aliceAPIKey, + expectBody: "[\n \"bob\"\n]\n", + expectStatus: http.StatusOK, + }, + { + name: "Alice unshares her definition from Bob - should succeed", + method: http.MethodDelete, + requestPath: "/v1/llm-definitions/alice/openai-large/share/bob", + bodyJSON: "", + VDBKey: aliceAPIKey, + expectBody: "", + expectStatus: http.StatusNoContent, + }, + } + + for _, v := range tt { + t.Run(v.name, func(t *testing.T) { + requestURL := fmt.Sprintf("http://%s:%d%s", options.Host, options.Port, v.requestPath) + + var req *http.Request + if v.bodyJSON != "" { + req, err = http.NewRequest(v.method, requestURL, bytes.NewBuffer([]byte(v.bodyJSON))) + } else { + req, err = http.NewRequest(v.method, requestURL, nil) + } + assert.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+v.VDBKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Errorf("Error sending request: %v\n", err) + } + defer resp.Body.Close() + + if resp.StatusCode != int(v.expectStatus) { + t.Errorf("Expected status code %d, got %s\n", v.expectStatus, resp.Status) + } else { + t.Logf("Expected status code %d, got %s\n", v.expectStatus, resp.Status) + } + + respBody, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + formattedResp := "" + if v.expectBody != "" { + fr := new(bytes.Buffer) + err = json.Indent(fr, respBody, "", " ") + assert.NoError(t, err) + formattedResp = fr.String() + } + assert.Equal(t, v.expectBody, formattedResp, "they should be equal") + }) + } + + // Verify mock expectations + mockKeyGen.AssertExpectations(t) + + // Cleanup + t.Cleanup(func() { + fmt.Print("\n\nRunning cleanup ...\n\n") + + requestURL := fmt.Sprintf("http://%s:%d/v1/admin/footgun", options.Host, options.Port) + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+options.AdminKey) + _, err = http.DefaultClient.Do(req) + if err != nil && err.Error() != "no rows in result set" { + t.Fatalf("Error sending request: %v\n", err) + } + assert.NoError(t, err) + + fmt.Print("Shutting down server\n\n") + shutDownServer() + }) + + fmt.Printf("\n\n\n\n") +} diff --git a/internal/handlers/llm_services_test.go b/internal/handlers/llm_services_test.go index c5c0c9e..c220830 100644 --- a/internal/handlers/llm_services_test.go +++ b/internal/handlers/llm_services_test.go @@ -12,7 +12,10 @@ import ( "github.com/stretchr/testify/assert" ) -func TestLLMServicesFunc(t *testing.T) { +func TestInstancesFunc(t *testing.T) { + + fmt.Printf("\n\n\n\n") + // Get the database connection pool from package variable pool := connPool @@ -25,21 +28,21 @@ func TestLLMServicesFunc(t *testing.T) { err, shutDownServer := startTestServer(t, pool, mockKeyGen) assert.NoError(t, err) - // Create user to be used in llm-service tests + // Create user to be used in instance tests aliceJSON := `{"user_handle": "alice", "name": "Alice Doe", "email": "alice@foo.bar"}` aliceAPIKey, err := createUser(t, aliceJSON) if err != nil { t.Fatalf("Error creating user alice for testing: %v\n", err) } - // Create API standard to be used in llm-service tests + // Create API standard to be used in instance tests openaiJSON := `{"api_standard_handle": "openai", "description": "OpenAI Embeddings API", "key_method": "auth_bearer", "key_field": "Authorization" }` _, err = createAPIStandard(t, openaiJSON, options.AdminKey) if err != nil { t.Fatalf("Error creating API standard openai for testing: %v\n", err) } - fmt.Printf("\nRunning llm-services tests ...\n\n") + fmt.Printf("\nRunning llm-instances tests ...\n\n") // Define test cases tt := []struct { @@ -47,151 +50,151 @@ func TestLLMServicesFunc(t *testing.T) { method string requestPath string bodyPath string - apiKey string + VDBKey string expectBody string expectStatus int16 }{ { - name: "Put llm-service, invalid json", + name: "Put instance, invalid json", method: http.MethodPut, - requestPath: "/v1/llm-services/alice/openai-large", - bodyPath: "../../testdata/invalid_llm_service.json", - apiKey: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"validation failed\",\n \"errors\": [\n {\n \"message\": \"expected required property model to be present\",\n \"location\": \"body\",\n \"value\": {\n \"api_keX\": \"0123456789\",\n \"api_standard\": \"openai\",\n \"description\": \"My OpenAI reduced text-embedding-3-large service\",\n \"dimensions\": 1024,\n \"endpoint\": \"https://api.openai.com/v1/embeddings\",\n \"llm_service_handle\": \"openai-error\"\n }\n },\n {\n \"message\": \"unexpected property\",\n \"location\": \"body.api_keX\",\n \"value\": {\n \"api_keX\": \"0123456789\",\n \"api_standard\": \"openai\",\n \"description\": \"My OpenAI reduced text-embedding-3-large service\",\n \"dimensions\": 1024,\n \"endpoint\": \"https://api.openai.com/v1/embeddings\",\n \"llm_service_handle\": \"openai-error\"\n }\n }\n ]\n}\n", + requestPath: "/v1/llm-instances/alice/embedding1", + bodyPath: "../../testdata/invalid_instance.json", + VDBKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"validation failed\",\n \"errors\": [\n {\n \"message\": \"unexpected property\",\n \"location\": \"body.api_keX\",\n \"value\": {\n \"api_keX\": \"0123456789\",\n \"api_standard\": \"openai\",\n \"description\": \"My OpenAI reduced text-embedding-3-large service\",\n \"dimensions\": 99,\n \"endpoint\": \"https://api.openai.com/v1/embeddings\",\n \"instance_handle\": \"embedding1\"\n }\n }\n ]\n}\n", expectStatus: http.StatusUnprocessableEntity, }, { - name: "Put llm-service, wrong path", + name: "Put instance, wrong path", method: http.MethodPut, - requestPath: "/v1/llm-services/alice/nonexistent", - bodyPath: "../../testdata/valid_llm_service_test1.json", - apiKey: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Bad Request\",\n \"status\": 400,\n \"detail\": \"llm-service handle in URL (\\\"nonexistent\\\") does not match llm-service handle in body (\\\"test1\\\")\"\n}\n", + requestPath: "/v1/llm-instances/alice/nonexistent", + bodyPath: "../../testdata/valid_instance_embedding1.json", + VDBKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Bad Request\",\n \"status\": 400,\n \"detail\": \"instance handle in URL (\\\"nonexistent\\\") does not match instance handle in body (\\\"embedding1\\\")\"\n}\n", expectStatus: http.StatusBadRequest, }, { - name: "Valid put llm-service", + name: "Valid put instance", method: http.MethodPut, - requestPath: "/v1/llm-services/alice/test1", - bodyPath: "../../testdata/valid_llm_service_test1.json", - apiKey: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/UploadLLMResponseBody.json\",\n \"owner\": \"alice\",\n \"llm_service_handle\": \"test1\",\n \"llm_service_id\": 1\n}\n", + requestPath: "/v1/llm-instances/alice/embedding1", + bodyPath: "../../testdata/valid_instance_embedding1.json", + VDBKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/UploadInstanceResponseBody.json\",\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"instance_id\": 1\n}\n", expectStatus: http.StatusCreated, }, { - name: "Post llm-service, invalid json", + name: "Post instance, invalid json", method: http.MethodPost, - requestPath: "/v1/llm-services/alice", - bodyPath: "../../testdata/invalid_llm_service.json", - apiKey: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"validation failed\",\n \"errors\": [\n {\n \"message\": \"expected required property model to be present\",\n \"location\": \"body\",\n \"value\": {\n \"api_keX\": \"0123456789\",\n \"api_standard\": \"openai\",\n \"description\": \"My OpenAI reduced text-embedding-3-large service\",\n \"dimensions\": 1024,\n \"endpoint\": \"https://api.openai.com/v1/embeddings\",\n \"llm_service_handle\": \"openai-error\"\n }\n },\n {\n \"message\": \"unexpected property\",\n \"location\": \"body.api_keX\",\n \"value\": {\n \"api_keX\": \"0123456789\",\n \"api_standard\": \"openai\",\n \"description\": \"My OpenAI reduced text-embedding-3-large service\",\n \"dimensions\": 1024,\n \"endpoint\": \"https://api.openai.com/v1/embeddings\",\n \"llm_service_handle\": \"openai-error\"\n }\n }\n ]\n}\n", + requestPath: "/v1/llm-instances/alice", + bodyPath: "../../testdata/invalid_instance.json", + VDBKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Unprocessable Entity\",\n \"status\": 422,\n \"detail\": \"validation failed\",\n \"errors\": [\n {\n \"message\": \"unexpected property\",\n \"location\": \"body.api_keX\",\n \"value\": {\n \"api_keX\": \"0123456789\",\n \"api_standard\": \"openai\",\n \"description\": \"My OpenAI reduced text-embedding-3-large service\",\n \"dimensions\": 99,\n \"endpoint\": \"https://api.openai.com/v1/embeddings\",\n \"instance_handle\": \"embedding1\"\n }\n }\n ]\n}\n", expectStatus: http.StatusUnprocessableEntity, }, { - name: "Valid post llm-service", + name: "Valid post instance", method: http.MethodPost, - requestPath: "/v1/llm-services/alice", - bodyPath: "../../testdata/valid_llm_service_test1.json", - apiKey: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/UploadLLMResponseBody.json\",\n \"owner\": \"alice\",\n \"llm_service_handle\": \"test1\",\n \"llm_service_id\": 1\n}\n", + requestPath: "/v1/llm-instances/alice", + bodyPath: "../../testdata/valid_instance_embedding1.json", + VDBKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/UploadInstanceResponseBody.json\",\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"instance_id\": 1\n}\n", expectStatus: http.StatusCreated, }, { - name: "Get all llm-services, admin's api key", + name: "Get all of alice's instances, admin's api key", method: http.MethodGet, - requestPath: "/v1/llm-services/alice", + requestPath: "/v1/llm-instances/alice", bodyPath: "", - apiKey: options.AdminKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetUserLLMsResponseBody.json\",\n \"llm_service\": [\n {\n \"llm_service_id\": 1,\n \"llm_service_handle\": \"test1\",\n \"owner\": \"alice\",\n \"endpoint\": \"https://api.foo.bar/v1/embed\",\n \"description\": \"An LLM Service just for testing if the dhamps-vdb code is working\",\n \"api_key\": \"0123456789\",\n \"api_standard\": \"openai\",\n \"model\": \"embed-test1\",\n \"dimensions\": 5\n }\n ]\n}\n", + VDBKey: options.AdminKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetUserInstancesResponseBody.json\",\n \"instances\": [\n {\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"instance_id\": 1\n }\n ]\n}\n", expectStatus: http.StatusOK, }, { - name: "Get all llm-services, alice's api key", + name: "Get all of alice's instances, alice's api key", method: http.MethodGet, - requestPath: "/v1/llm-services/alice", + requestPath: "/v1/llm-instances/alice", bodyPath: "", - apiKey: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetUserLLMsResponseBody.json\",\n \"llm_service\": [\n {\n \"llm_service_id\": 1,\n \"llm_service_handle\": \"test1\",\n \"owner\": \"alice\",\n \"endpoint\": \"https://api.foo.bar/v1/embed\",\n \"description\": \"An LLM Service just for testing if the dhamps-vdb code is working\",\n \"api_key\": \"0123456789\",\n \"api_standard\": \"openai\",\n \"model\": \"embed-test1\",\n \"dimensions\": 5\n }\n ]\n}\n", + VDBKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetUserInstancesResponseBody.json\",\n \"instances\": [\n {\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"instance_id\": 1\n }\n ]\n}\n", expectStatus: http.StatusOK, }, { - name: "Get all llm-services, unauthorized", + name: "Get all llm-instances, unauthorized", method: http.MethodGet, - requestPath: "/v1/llm-services/alice", + requestPath: "/v1/llm-instances/alice", bodyPath: "", - apiKey: "", + VDBKey: "", expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Unauthorized\",\n \"status\": 401,\n \"detail\": \"Authentication failed. Perhaps a missing or incorrect API key?\"\n}\n", expectStatus: http.StatusUnauthorized, }, { - name: "Get all llm-services, nonexistent user", + name: "Get all llm-instances, nonexistent user", method: http.MethodGet, - requestPath: "/v1/llm-services/john", + requestPath: "/v1/llm-instances/john", bodyPath: "", - apiKey: options.AdminKey, + VDBKey: options.AdminKey, expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"user john not found\"\n}\n", expectStatus: http.StatusNotFound, }, { - name: "Get nonexistent llm-service", + name: "Get nonexistent instance", method: http.MethodGet, - requestPath: "/v1/llm-services/alice/nonexistent", + requestPath: "/v1/llm-instances/alice/nonexistent", bodyPath: "", - apiKey: aliceAPIKey, + VDBKey: aliceAPIKey, expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"llm service nonexistent for user alice not found\"\n}\n", expectStatus: http.StatusNotFound, }, { - name: "Get single llm-service, nonexistent path", + name: "Get single instance, nonexistent path", method: http.MethodGet, - requestPath: "/v1/llm-services/alice/nonexistant", + requestPath: "/v1/llm-instances/alice/nonexistant", bodyPath: "", - apiKey: aliceAPIKey, + VDBKey: aliceAPIKey, expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"llm service nonexistant for user alice not found\"\n}\n", expectStatus: http.StatusNotFound, }, { - name: "Valid get single llm-service", + name: "Valid get single instance", method: http.MethodGet, - requestPath: "/v1/llm-services/alice/test1", + requestPath: "/v1/llm-instances/alice/embedding1", bodyPath: "", - apiKey: aliceAPIKey, - expectBody: "{\n \"llm_service_id\": 1,\n \"llm_service_handle\": \"test1\",\n \"owner\": \"alice\",\n \"endpoint\": \"https://api.foo.bar/v1/embed\",\n \"description\": \"An LLM Service just for testing if the dhamps-vdb code is working\",\n \"api_key\": \"0123456789\",\n \"api_standard\": \"openai\",\n \"model\": \"embed-test1\",\n \"dimensions\": 5\n}\n", + VDBKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/InstanceFull.json\",\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"instance_id\": 1,\n \"access_role\": \"owner\",\n \"endpoint\": \"https://api.foo.bar/v1/embed\",\n \"description\": \"An LLM Service just for testing if the dhamps-vdb code is working\",\n \"has_api_key\": true,\n \"api_standard\": \"openai\",\n \"model\": \"embed-test1\",\n \"dimensions\": 5\n}\n", expectStatus: http.StatusOK, }, { - name: "Delete nonexistent llm-service", + name: "Delete nonexistent instance", method: http.MethodDelete, - requestPath: "/v1/llm-services/alice/nonexistent", + requestPath: "/v1/llm-instances/alice/nonexistent", bodyPath: "", - apiKey: aliceAPIKey, + VDBKey: aliceAPIKey, expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"llm service nonexistent for user alice not found\"\n}\n", expectStatus: http.StatusNotFound, }, { - name: "Delete llm-service, invalid user", + name: "Delete instance, invalid user", method: http.MethodDelete, - requestPath: "/v1/llm-services/john/test1", + requestPath: "/v1/llm-instances/john/embedding1", bodyPath: "", - apiKey: options.AdminKey, + VDBKey: options.AdminKey, expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Not Found\",\n \"status\": 404,\n \"detail\": \"user john not found\"\n}\n", expectStatus: http.StatusNotFound, }, { - name: "Delete llm-service, unauthorized", + name: "Delete instance, unauthorized", method: http.MethodDelete, - requestPath: "/v1/llm-services/alice/test1", + requestPath: "/v1/llm-instances/alice/embedding1", bodyPath: "", - apiKey: "", + VDBKey: "", expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Unauthorized\",\n \"status\": 401,\n \"detail\": \"Authentication failed. Perhaps a missing or incorrect API key?\"\n}\n", expectStatus: http.StatusUnauthorized, }, { - name: "Valid delete llm-service", + name: "Valid delete instance", method: http.MethodDelete, - requestPath: "/v1/llm-services/alice/test1", + requestPath: "/v1/llm-instances/alice/embedding1", bodyPath: "", - apiKey: aliceAPIKey, + VDBKey: aliceAPIKey, expectBody: "", expectStatus: http.StatusNoContent, }, @@ -221,7 +224,7 @@ func TestLLMServicesFunc(t *testing.T) { requestURL := fmt.Sprintf("http://%v:%d%v", options.Host, options.Port, v.requestPath) req, err := http.NewRequest(v.method, requestURL, reqBody) assert.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+v.apiKey) + req.Header.Set("Authorization", "Bearer "+v.VDBKey) resp, err := http.DefaultClient.Do(req) if err != nil { t.Errorf("Error sending request: %v\n", err) diff --git a/internal/handlers/patch_test.go b/internal/handlers/patch_test.go deleted file mode 100644 index 62b05e4..0000000 --- a/internal/handlers/patch_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package handlers_test - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestPatchProjects(t *testing.T) { - // Get the database connection pool from package variable - pool := connPool - - // Create a mock key generator - mockKeyGen := new(MockKeyGen) - // Set up expectations for the mock key generator (alice and bob) - mockKeyGen.On("RandomKey", 32).Return("12345678901234567890123456789012", nil).Once() - mockKeyGen.On("RandomKey", 32).Return("23456789012345678901234567890123", nil).Once() - - // Start the server - err, shutDownServer := startTestServer(t, pool, mockKeyGen) - assert.NoError(t, err) - - // Create user to be used in project tests - aliceJSON := `{"user_handle": "alice", "name": "Alice Doe", "email": "alice@foo.bar"}` - aliceAPIKey, err := createUser(t, aliceJSON) - if err != nil { - t.Fatalf("Error creating user alice for testing: %v\n", err) - } - - // Create bob user manually since createUser is hardcoded for alice - bobJSON := `{"user_handle": "bob", "name": "Bob Smith", "email": "bob@foo.bar"}` - requestURL := fmt.Sprintf("http://%s:%d/v1/users/bob", options.Host, options.Port) - req, err := http.NewRequest(http.MethodPut, requestURL, bytes.NewBufferString(bobJSON)) - assert.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+options.AdminKey) - resp, err := http.DefaultClient.Do(req) - assert.NoError(t, err) - if resp.StatusCode != http.StatusCreated { - t.Fatalf("Failed to create bob user, status: %d", resp.StatusCode) - } - resp.Body.Close() - - fmt.Printf("\nRunning PATCH tests for projects ...\n\n") - - // First, create a project to test PATCH on - t.Run("Setup: Create project for PATCH testing", func(t *testing.T) { - projectJSON := `{"project_handle": "patch_test", "description": "Initial description"}` - requestURL := fmt.Sprintf("http://%v:%d/v1/projects/alice", options.Host, options.Port) - req, err := http.NewRequest(http.MethodPost, requestURL, bytes.NewBufferString(projectJSON)) - assert.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+aliceAPIKey) - resp, err := http.DefaultClient.Do(req) - assert.NoError(t, err) - defer resp.Body.Close() - assert.Equal(t, http.StatusCreated, resp.StatusCode) - }) - - // Test PATCH to update description only - t.Run("PATCH project description only", func(t *testing.T) { - patchJSON := `{"description": "Updated description via PATCH"}` - requestURL := fmt.Sprintf("http://%v:%d/v1/projects/alice/patch_test", options.Host, options.Port) - req, err := http.NewRequest(http.MethodPatch, requestURL, bytes.NewBufferString(patchJSON)) - assert.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+aliceAPIKey) - req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) - assert.NoError(t, err) - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - assert.NoError(t, err) - - // PATCH should succeed with 201 Created (since it calls PUT internally) - if resp.StatusCode != http.StatusCreated { - t.Errorf("Expected status code %d, got %d. Body: %s", http.StatusCreated, resp.StatusCode, string(respBody)) - } - }) - - // Verify the PATCH update was applied - t.Run("Verify PATCH updated description", func(t *testing.T) { - requestURL := fmt.Sprintf("http://%v:%d/v1/projects/alice/patch_test", options.Host, options.Port) - req, err := http.NewRequest(http.MethodGet, requestURL, nil) - assert.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+aliceAPIKey) - resp, err := http.DefaultClient.Do(req) - assert.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - respBody, err := io.ReadAll(resp.Body) - assert.NoError(t, err) - - var project map[string]interface{} - err = json.Unmarshal(respBody, &project) - assert.NoError(t, err) - - description, ok := project["description"].(string) - assert.True(t, ok, "description field should be a string") - assert.Equal(t, "Updated description via PATCH", description, "description should be updated") - - // Verify project_handle is still the same - projectHandle, ok := project["project_handle"].(string) - assert.True(t, ok, "project_handle field should be a string") - assert.Equal(t, "patch_test", projectHandle, "project_handle should remain unchanged") - }) - - // Test PATCH to enable world-readable access - t.Run("PATCH project to enable world-readable", func(t *testing.T) { - patchJSON := `{"authorizedReaders": ["*"]}` - requestURL := fmt.Sprintf("http://%v:%d/v1/projects/alice/patch_test", options.Host, options.Port) - req, err := http.NewRequest(http.MethodPatch, requestURL, bytes.NewBufferString(patchJSON)) - assert.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+aliceAPIKey) - req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) - assert.NoError(t, err) - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - assert.NoError(t, err) - - if resp.StatusCode != http.StatusCreated { - t.Errorf("Expected status code %d, got %d. Body: %s", http.StatusCreated, resp.StatusCode, string(respBody)) - } - }) - - // Verify world-readable access was enabled - t.Run("Verify world-readable access enabled", func(t *testing.T) { - requestURL := fmt.Sprintf("http://%v:%d/v1/projects/alice/patch_test", options.Host, options.Port) - req, err := http.NewRequest(http.MethodGet, requestURL, nil) - assert.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+aliceAPIKey) - resp, err := http.DefaultClient.Do(req) - assert.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - respBody, err := io.ReadAll(resp.Body) - assert.NoError(t, err) - - var project map[string]interface{} - err = json.Unmarshal(respBody, &project) - assert.NoError(t, err) - - authorizedReaders, ok := project["authorizedReaders"].([]interface{}) - assert.True(t, ok, "authorizedReaders field should be an array") - assert.Equal(t, 1, len(authorizedReaders), "should have one authorized reader") - assert.Equal(t, "*", authorizedReaders[0], "authorized reader should be '*'") - }) - - // Test PATCH to add specific authorized readers - t.Run("PATCH project to add specific authorized readers", func(t *testing.T) { - patchJSON := `{"authorizedReaders": ["bob"]}` - requestURL := fmt.Sprintf("http://%v:%d/v1/projects/alice/patch_test", options.Host, options.Port) - req, err := http.NewRequest(http.MethodPatch, requestURL, bytes.NewBufferString(patchJSON)) - assert.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+aliceAPIKey) - req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) - assert.NoError(t, err) - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - assert.NoError(t, err) - - if resp.StatusCode != http.StatusCreated { - t.Errorf("Expected status code %d, got %d. Body: %s", http.StatusCreated, resp.StatusCode, string(respBody)) - } - }) - - // Verify authorized readers updated - t.Run("Verify authorized readers updated", func(t *testing.T) { - requestURL := fmt.Sprintf("http://%v:%d/v1/projects/alice/patch_test", options.Host, options.Port) - req, err := http.NewRequest(http.MethodGet, requestURL, nil) - assert.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+aliceAPIKey) - resp, err := http.DefaultClient.Do(req) - assert.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - respBody, err := io.ReadAll(resp.Body) - assert.NoError(t, err) - - var project map[string]interface{} - err = json.Unmarshal(respBody, &project) - assert.NoError(t, err) - - authorizedReaders, ok := project["authorizedReaders"].([]interface{}) - assert.True(t, ok, "authorizedReaders field should be an array") - // Should contain alice (owner) and bob - assert.GreaterOrEqual(t, len(authorizedReaders), 1, "should have at least one authorized reader") - - // Check if bob is in the list - foundBob := false - for _, reader := range authorizedReaders { - if reader == "bob" { - foundBob = true - break - } - } - assert.True(t, foundBob, "bob should be in authorized readers") - }) - - // Clean up - fmt.Print("\n\nRunning cleanup ...\n\n") - - cleanupURL := fmt.Sprintf("http://%s:%d/v1/admin/footgun", options.Host, options.Port) - cleanupReq, cleanupErr := http.NewRequest(http.MethodGet, cleanupURL, nil) - assert.NoError(t, cleanupErr) - cleanupReq.Header.Set("Authorization", "Bearer "+options.AdminKey) - _, cleanupErr = http.DefaultClient.Do(cleanupReq) - if cleanupErr != nil && cleanupErr.Error() != "no rows in result set" { - t.Fatalf("Error sending request: %v\n", cleanupErr) - } - assert.NoError(t, cleanupErr) - - fmt.Print("Shutting down server\n\n") - shutDownServer() -} diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index f775be6..d7b2485 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "github.com/mpilhlt/dhamps-vdb/internal/auth" "github.com/mpilhlt/dhamps-vdb/internal/database" "github.com/mpilhlt/dhamps-vdb/internal/models" @@ -20,63 +21,57 @@ func putProjectFunc(ctx context.Context, input *models.PutProjectRequest) (*mode return nil, huma.Error400BadRequest(fmt.Sprintf("project handle in URL (%s) does not match project handle in body (%s)", input.ProjectHandle, input.Body.ProjectHandle)) } - // Check if user exists - if _, err := getUserFunc(ctx, &models.GetUserRequest{UserHandle: input.UserHandle}); err != nil { - return nil, err - } - // Get the database connection pool from the context pool, err := GetDBPool(ctx) if err != nil { - return nil, err + return nil, huma.Error500InternalServerError("database connection error: %v", err) } else if pool == nil { return nil, huma.Error500InternalServerError("database connection pool is nil") } + queries := database.New(pool) - // 1. Upload project + // 1. Validation - // Build query parameters (project) - readers := make(map[string]bool) - publicRead := false - for _, user := range input.Body.AuthorizedReaders { - if user == "*" { - publicRead = true - // Still add existing users as readers for backwards compatibility - users, err := getUsersFunc(ctx, &models.GetUsersRequest{Limit: 999, Offset: 0}) - if err != nil { - return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get users list: %v", err)) - } - for _, uu := range users.Body { - if uu != input.UserHandle { - readers[uu] = true - } - } - } else { - u, err := getUserFunc(ctx, &models.GetUserRequest{UserHandle: user}) - if err != nil { - return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get user %s", user)) - } - if u.Body.UserHandle != user { - return nil, huma.Error404NotFound(fmt.Sprintf("user %s not found", user)) - } - if user != input.UserHandle { - readers[user] = true - } + // - check if user exists + if _, err := queries.RetrieveUser(ctx, input.UserHandle); err != nil { + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("user %s not found", input.UserHandle)) } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to access user %s. %v", input.UserHandle, err)) } + // - check if instance exists (if provided) + instanceID := pgtype.Int4{Valid: false} + if input.Body.InstanceHandle != "" { + instance, err := queries.RetrieveInstance(ctx, database.RetrieveInstanceParams{Owner: input.Body.InstanceOwner, InstanceHandle: input.Body.InstanceHandle}) + if err != nil { + return nil, huma.Error404NotFound(fmt.Sprintf("LLM Service Instance %s owned by %s not found", input.Body.InstanceHandle, input.Body.InstanceOwner)) + } + instanceID = pgtype.Int4{Int32: int32(instance.InstanceID), Valid: true} + } + + // NOTE: For the time being, we establish all sharing only subsequent to project + // creation. In other words, it is not possible to submit a list of users + // to share the project with upon project creation. Instead, each share must + // be created individually via API calls by the project owner. + // release queries so that they can be used in the transaction below (to link project to users) + queries = nil + + // 2. Upload project + + var projectID int32 + var projectHandle string + + // - build query parameters (project) project := database.UpsertProjectParams{ ProjectHandle: input.ProjectHandle, + Owner: input.UserHandle, Description: pgtype.Text{String: input.Body.Description, Valid: true}, MetadataScheme: pgtype.Text{String: input.Body.MetadataScheme, Valid: input.Body.MetadataScheme != ""}, - Owner: input.UserHandle, - PublicRead: pgtype.Bool{Bool: publicRead, Valid: true}, + PublicRead: pgtype.Bool{Bool: input.Body.PublicRead, Valid: true}, + InstanceID: instanceID, } - - // Execute all database operations within a transaction - var projectID int32 - var projectHandle string - + // - execute all database operations within a transaction err = database.WithTransaction(ctx, pool, func(tx pgx.Tx) error { queries := database.New(tx) @@ -95,26 +90,31 @@ func putProjectFunc(ctx context.Context, input *models.PutProjectRequest) (*mode return fmt.Errorf("unable to link project to owner %s. %v", input.UserHandle, err) } - // 3. Link project and other assigned readers - for reader := range readers { - params := database.LinkProjectToUserParams{ProjectID: projectID, UserHandle: reader, Role: "reader"} - _, err := queries.LinkProjectToUser(ctx, params) - if err != nil { - return fmt.Errorf("unable to upload project reader %s. %v", reader, err) + // 3. Link project and other shared users (if any) - we'll perhaps implement/activate this in the future + /* + for reader := range sharedUsers { + params := database.LinkProjectToUserParams{ProjectID: projectID, UserHandle: reader, Role: sharedUsers[reader]} + _, err := queries.LinkProjectToUser(ctx, params) + if err != nil { + return fmt.Errorf("unable to upload project reader %s. %v", reader, err) + } } - } + */ return nil - }) - + }) // end transaction if err != nil { return nil, huma.Error500InternalServerError(err.Error()) } - // 4. Build the response + // 3. Build the response + response := &models.UploadProjectResponse{} + response.Body.Owner = input.UserHandle response.Body.ProjectHandle = projectHandle response.Body.ProjectID = int(projectID) + response.Body.PublicRead = input.Body.PublicRead + response.Body.Role = "owner" // the user creating/updating the project is always the owner return response, nil } @@ -126,22 +126,26 @@ func postProjectFunc(ctx context.Context, input *models.PostProjectRequest) (*mo // Get all projects for a specific user func getProjectsFunc(ctx context.Context, input *models.GetProjectsRequest) (*models.GetProjectsResponse, error) { - // Check if user exists - if _, err := getUserFunc(ctx, &models.GetUserRequest{UserHandle: input.UserHandle}); err != nil { - return nil, err - } // Get the database connection pool from the context pool, err := GetDBPool(ctx) if err != nil { - return nil, err + return nil, huma.Error500InternalServerError("database connection error: %v", err) + } else if pool == nil { + return nil, huma.Error500InternalServerError("database connection pool is nil") } - - // Run the queries queries := database.New(pool) + // - check if user exists + if _, err := queries.RetrieveUser(ctx, input.UserHandle); err != nil { + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("user %s not found", input.UserHandle)) + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to access user %s. %v", input.UserHandle, err)) + } + // Get the list of projects - p, err := queries.GetProjectsByUser(ctx, database.GetProjectsByUserParams{UserHandle: input.UserHandle, Limit: int32(input.Limit), Offset: int32(input.Offset)}) + projectHandles, err := queries.GetAccessibleProjectsByUser(ctx, database.GetAccessibleProjectsByUserParams{Owner: input.UserHandle, Limit: int32(input.Limit), Offset: int32(input.Offset)}) if err != nil { if err.Error() == "no rows in result set" { return nil, huma.Error404NotFound(fmt.Sprintf("no projects found for user %s", input.UserHandle)) @@ -149,70 +153,21 @@ func getProjectsFunc(ctx context.Context, input *models.GetProjectsRequest) (*mo return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get projects for user %s. %v", input.UserHandle, err)) } - // Get the details for each project - projects := []models.Project{} - for _, project := range p { - // Get the authorized reader accounts for the project - readers := []string{} - // If the project is publicly readable, show "*" in authorizedReaders - if project.PublicRead.Valid && project.PublicRead.Bool { - readers = []string{"*"} - } else { - rows, err := queries.GetUsersByProject(ctx, database.GetUsersByProjectParams{Owner: input.UserHandle, ProjectHandle: project.ProjectHandle, Limit: 999, Offset: 0}) - if err != nil { - return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get readers for %s's project %s. %v", input.UserHandle, project.ProjectHandle, err)) - } - for _, row := range rows { - readers = append(readers, row.UserHandle) - } - } + projects := []models.ProjectBrief{} - // Get the LLM Services for the project - llmservices := []models.LLMService{} - llmRows, err := queries.GetLLMsByProject(ctx, database.GetLLMsByProjectParams{Owner: input.UserHandle, ProjectHandle: project.ProjectHandle, Limit: 999, Offset: 0}) - if err != nil { - if err.Error() == "no rows in result set" { - llmRows = []database.LlmService{} - } else { - return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get LLM Services for %s's project %s. %v", input.UserHandle, project.ProjectHandle, err)) - } - } - for _, row := range llmRows { - llmservice := models.LLMService{ - Owner: row.Owner, - LLMServiceID: int(row.LLMServiceID), - LLMServiceHandle: row.LLMServiceHandle, - Endpoint: row.Endpoint, - Description: row.Description.String, - APIStandard: row.APIStandard, - Model: row.Model, - Dimensions: row.Dimensions, - } - llmservices = append(llmservices, llmservice) - } - - // Get the (number of) embeddings for the project - count, err := queries.GetNumberOfEmbeddingsByProject(ctx, database.GetNumberOfEmbeddingsByProjectParams{Owner: input.UserHandle, ProjectHandle: project.ProjectHandle}) - if err != nil { - if err.Error() == "no rows in result set" { - count = 0 - } else { - return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get number of embeddings for %s's project %s. %v", input.UserHandle, project.ProjectHandle, err)) - } - } + /* Get the details for each project (for now, we only give the brief output...) + */ - projects = append(projects, models.Project{ - ProjectID: int(project.ProjectID), - ProjectHandle: project.ProjectHandle, - Description: project.Description.String, - MetadataScheme: project.MetadataScheme.String, - NumberOfEmbeddings: int(count), - Owner: project.Owner, - LLMServices: llmservices, - AuthorizedReaders: readers, + /* Build response array with brief output */ + for _, p := range projectHandles { + projects = append(projects, models.ProjectBrief{ + Owner: p.Owner, + ProjectHandle: p.ProjectHandle, + ProjectID: int(p.ProjectID), + PublicRead: p.PublicRead.Bool, + Role: p.Role.(string), }) } - // Build the response response := &models.GetProjectsResponse{} response.Body.Projects = projects @@ -222,74 +177,132 @@ func getProjectsFunc(ctx context.Context, input *models.GetProjectsRequest) (*mo // Retrieve a specific project func getProjectFunc(ctx context.Context, input *models.GetProjectRequest) (*models.GetProjectResponse, error) { - // Check if user exists - if _, err := getUserFunc(ctx, &models.GetUserRequest{UserHandle: input.UserHandle}); err != nil { - return nil, err - } // Get the database connection pool from the context pool, err := GetDBPool(ctx) if err != nil { - return nil, err - } - - // Build the query parameters - params := database.RetrieveProjectParams{ - Owner: input.UserHandle, - ProjectHandle: input.ProjectHandle, + return nil, huma.Error500InternalServerError("database connection error: %v", err) + } else if pool == nil { + return nil, huma.Error500InternalServerError("database connection pool is nil") } - - // Run the queries queries := database.New(pool) - p, err := queries.RetrieveProject(ctx, params) - if err != nil { + + // - check if user exists + if _, err := queries.RetrieveUser(ctx, input.UserHandle); err != nil { if err.Error() == "no rows in result set" { - return nil, huma.Error404NotFound(fmt.Sprintf("user %s's project %s not found", input.UserHandle, input.ProjectHandle)) + return nil, huma.Error404NotFound(fmt.Sprintf("user %s not found", input.UserHandle)) } - return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get project %s for user %s. %v", input.ProjectHandle, input.UserHandle, err)) + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to access user %s. %v", input.UserHandle, err)) } - // Get the authorized reader accounts for the project - readers := []string{} - // If the project is publicly readable, show "*" in authorizedReaders - if p.PublicRead.Valid && p.PublicRead.Bool { - readers = []string{"*"} + // get handle of requesting user from context (set by auth middleware) + requestingUser := ctx.Value(auth.AuthUserKey) + if requestingUser == nil { + return nil, huma.Error500InternalServerError("unable to get requesting user from context") + } + + var p database.Project + var role pgtype.Text + + // Admin users can access any project without being in users_projects + if requestingUser.(string) == "admin" { + // Use the basic RetrieveProject query for admin users + p, err = queries.RetrieveProject(ctx, database.RetrieveProjectParams{ + Owner: input.UserHandle, + ProjectHandle: input.ProjectHandle, + }) + if err != nil { + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("user %s's project %s not found", input.UserHandle, input.ProjectHandle)) + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get project %s for user %s. %v", input.ProjectHandle, input.UserHandle, err)) + } + // Admin users have admin role + role = pgtype.Text{String: "admin", Valid: true} } else { + // For non-admin users, use RetrieveProjectForUser which checks access permissions + params := database.RetrieveProjectForUserParams{ + Owner: input.UserHandle, + ProjectHandle: input.ProjectHandle, + UserHandle: requestingUser.(string), + } + projectRow, err := queries.RetrieveProjectForUser(ctx, params) + if err != nil { + if err.Error() == "no rows in result set" { + return nil, huma.Error404NotFound(fmt.Sprintf("user %s's project %s not found", input.UserHandle, input.ProjectHandle)) + } + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get project %s for user %s. %v", input.ProjectHandle, input.UserHandle, err)) + } + // Convert RetrieveProjectForUserRow to Project + p = database.Project{ + ProjectID: projectRow.ProjectID, + ProjectHandle: projectRow.ProjectHandle, + Owner: projectRow.Owner, + Description: projectRow.Description, + MetadataScheme: projectRow.MetadataScheme, + CreatedAt: projectRow.CreatedAt, + UpdatedAt: projectRow.UpdatedAt, + PublicRead: projectRow.PublicRead, + InstanceID: projectRow.InstanceID, + } + role = projectRow.Role + } + + // Get the authorized reader accounts for the project (if requested by project owner) + sharedUsers := []models.SharedUser{} + if requestingUser.(string) == input.UserHandle { + // If the project is publicly readable, show "*" in shared_with + if p.PublicRead.Valid && p.PublicRead.Bool { + sharedUsers = append(sharedUsers, models.SharedUser{UserHandle: "*", Role: "reader"}) + } + // Iterate all shared users userRows, err := queries.GetUsersByProject(ctx, database.GetUsersByProjectParams{Owner: input.UserHandle, ProjectHandle: input.ProjectHandle, Limit: 999, Offset: 0}) if err != nil { return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get authorized reader accounts for %s's project %s. %v", input.UserHandle, input.ProjectHandle, err)) } for _, row := range userRows { - readers = append(readers, row.UserHandle) + sharedUsers = append(sharedUsers, models.SharedUser{UserHandle: row.UserHandle, Role: row.Role}) } + } else { + // If the requesting user is not the project owner, do not return the list of shared users (privacy reasons) + sharedUsers = nil } - // Get the LLM Services for the project - llmservices := []models.LLMService{} - llmRows, err := queries.GetLLMsByProject(ctx, database.GetLLMsByProjectParams{Owner: input.UserHandle, ProjectHandle: input.ProjectHandle, Limit: 999, Offset: 0}) + // Get the LLM Service Instance for the project (1:1 relationship) + instance := models.InstanceBrief{} + llmRow, err := queries.RetrieveInstanceByID(ctx, p.InstanceID.Int32) if err != nil { - if err.Error() == "no rows in result set" { - llmRows = []database.LlmService{} + if err.Error() != "no rows in result set" { + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get LLM Service Instance for %s's project %s: %v", input.UserHandle, input.ProjectHandle, err)) + } + // Project has no LLM service instance assigned yet - just don't populate response's instance field + } else { + // Get user's access role for the instance (if any) - to include in the response + var accessRole string + if llmRow.Owner == requestingUser.(string) { + accessRole = "owner" } else { - return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get LLM Services for %s's project %s. %v", input.UserHandle, input.ProjectHandle, err)) + sharedUsers, err := queries.GetSharedUsersForInstance(ctx, database.GetSharedUsersForInstanceParams{Owner: llmRow.Owner, InstanceHandle: llmRow.InstanceHandle}) + if err != nil { + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to get shared users for LLM Service Instance %s owned by %s. %v", llmRow.InstanceHandle, llmRow.Owner, err)) + } + for _, su := range sharedUsers { + if su.UserHandle == requestingUser.(string) { + accessRole = su.Role + break + } + } } - } - for _, row := range llmRows { - llmservice := models.LLMService{ - Owner: row.Owner, - LLMServiceID: int(row.LLMServiceID), - LLMServiceHandle: row.LLMServiceHandle, - Endpoint: row.Endpoint, - Description: row.Description.String, - APIStandard: row.APIStandard, - Model: row.Model, - Dimensions: row.Dimensions, + instance = models.InstanceBrief{ + Owner: llmRow.Owner, + InstanceID: int(llmRow.InstanceID), + InstanceHandle: llmRow.InstanceHandle, + AccessRole: accessRole, } - llmservices = append(llmservices, llmservice) } // Get the (number of) embeddings for the project - count, err := queries.GetNumberOfEmbeddingsByProject(ctx, database.GetNumberOfEmbeddingsByProjectParams{Owner: input.UserHandle, ProjectHandle: input.ProjectHandle}) + count, err := queries.CountEmbeddingsByProject(ctx, database.CountEmbeddingsByProjectParams{Owner: input.UserHandle, ProjectHandle: input.ProjectHandle}) if err != nil { if err.Error() == "no rows in result set" { count = 0 @@ -300,14 +313,15 @@ func getProjectFunc(ctx context.Context, input *models.GetProjectRequest) (*mode // Build the response response := &models.GetProjectResponse{} - response.Body = models.Project{ + response.Body = models.ProjectFull{ ProjectID: int(p.ProjectID), ProjectHandle: p.ProjectHandle, Owner: p.Owner, Description: p.Description.String, MetadataScheme: p.MetadataScheme.String, - AuthorizedReaders: readers, - LLMServices: llmservices, + SharedWith: sharedUsers, + Instance: instance, + Role: role.String, NumberOfEmbeddings: int(count), } @@ -357,6 +371,9 @@ func deleteProjectFunc(ctx context.Context, input *models.DeleteProjectRequest) return response, nil } +// TODO: Add project sharing/unsharing/shares_listing routes +// (add user to project with reader role and to instance sharedUsers if project has an instance assigned) + // RegisterProjectRoutes registers all the project routes with the API func RegisterProjectsRoutes(pool *pgxpool.Pool, api huma.API) error { // Define huma.Operations for each route diff --git a/internal/handlers/projects_test.go b/internal/handlers/projects_test.go index 56ded12..610694a 100644 --- a/internal/handlers/projects_test.go +++ b/internal/handlers/projects_test.go @@ -13,6 +13,7 @@ import ( ) func TestProjectsFunc(t *testing.T) { + // Get the database connection pool from package variable pool := connPool @@ -32,6 +33,20 @@ func TestProjectsFunc(t *testing.T) { t.Fatalf("Error creating user alice for testing: %v\n", err) } + // Create API standard to be used in embeddings tests + apiStandardJSON := `{"api_standard_handle": "openai", "description": "OpenAI Embeddings API", "key_method": "auth_bearer", "key_field": "Authorization" }` + _, err = createAPIStandard(t, apiStandardJSON, options.AdminKey) + if err != nil { + t.Fatalf("Error creating API standard openai for testing: %v\n", err) + } + + // Create LLM Service Instance to be used in embeddings tests + instanceJSON := `{ "instance_handle": "embedding1", "endpoint": "https://api.foo.bar/v1/embed", "description": "An LLM Service just for testing if the dhamps-vdb code is working", "api_standard": "openai", "model": "embed-test1", "dimensions": 5}` + _, err = createInstance(t, instanceJSON, "alice", aliceAPIKey) + if err != nil { + t.Fatalf("Error creating LLM service embedding1 for testing: %v\n", err) + } + fmt.Printf("\nRunning projects tests ...\n\n") // Define test cases @@ -68,7 +83,7 @@ func TestProjectsFunc(t *testing.T) { requestPath: "/v1/projects/alice/test1", bodyPath: "../../testdata/valid_project.json", apiKey: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/UploadProjectResponseBody.json\",\n \"project_handle\": \"test1\",\n \"project_id\": 1\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ProjectBrief.json\",\n \"owner\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"public_read\": false,\n \"role\": \"owner\"\n}\n", expectStatus: http.StatusCreated, }, { @@ -95,7 +110,7 @@ func TestProjectsFunc(t *testing.T) { requestPath: "/v1/projects/alice", bodyPath: "../../testdata/valid_project.json", apiKey: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/UploadProjectResponseBody.json\",\n \"project_handle\": \"test1\",\n \"project_id\": 1\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ProjectBrief.json\",\n \"owner\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"public_read\": false,\n \"role\": \"owner\"\n}\n", expectStatus: http.StatusCreated, }, { @@ -113,7 +128,7 @@ func TestProjectsFunc(t *testing.T) { requestPath: "/v1/projects/alice/test1", bodyPath: "", apiKey: aliceAPIKey, - expectBody: "{\n \"project_id\": 1,\n \"project_handle\": \"test1\",\n \"owner\": \"alice\",\n \"description\": \"This is a test project\",\n \"authorizedReaders\": [\n \"alice\"\n ],\n \"number_of_embeddings\": 0\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ProjectFull.json\",\n \"project_id\": 1,\n \"project_handle\": \"test1\",\n \"owner\": \"alice\",\n \"description\": \"This is a test project\",\n \"public_read\": false,\n \"shared_with\": [\n {\n \"user_handle\": \"alice\",\n \"role\": \"owner\"\n }\n ],\n \"instance\": {\n \"owner\": \"alice\",\n \"instance_handle\": \"embedding1\",\n \"instance_id\": 1,\n \"access_role\": \"owner\"\n },\n \"role\": \"owner\",\n \"number_of_embeddings\": 0\n}\n", expectStatus: http.StatusOK, }, { @@ -122,7 +137,7 @@ func TestProjectsFunc(t *testing.T) { requestPath: "/v1/projects/alice", bodyPath: "", apiKey: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetProjectsResponseBody.json\",\n \"projects\": [\n {\n \"project_id\": 1,\n \"project_handle\": \"test1\",\n \"owner\": \"alice\",\n \"description\": \"This is a test project\",\n \"authorizedReaders\": [\n \"alice\"\n ],\n \"number_of_embeddings\": 0\n }\n ]\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetProjectsResponseBody.json\",\n \"projects\": [\n {\n \"owner\": \"alice\",\n \"project_handle\": \"test1\",\n \"project_id\": 1,\n \"public_read\": false,\n \"role\": \"owner\"\n }\n ]\n}\n", expectStatus: http.StatusOK, }, { @@ -247,11 +262,17 @@ func TestProjectsFunc(t *testing.T) { shutDownServer() }) + fmt.Printf("\n") } // TestProjectTransactionRollback verifies that transactions are properly rolled back // when an error occurs during project creation, ensuring no orphaned records. + +/* We don't test transactions now because we don't link to authorized users in project creation ... func TestProjectTransactionRollback(t *testing.T) { + + fmt.Printf("\n") + // Get the database connection pool from package variable pool := connPool @@ -273,7 +294,7 @@ func TestProjectTransactionRollback(t *testing.T) { fmt.Printf("\nRunning project transaction rollback tests ...\n\n") - t.Run("Project creation with invalid reader should rollback completely", func(t *testing.T) { + t.Run("Project creation, invalid reader (full rollback)", func(t *testing.T) { // Attempt to create a project with a non-existent reader // This should fail during user validation and not even reach the transaction f, err := os.Open("../../testdata/project_with_invalid_reader.json") @@ -297,6 +318,7 @@ func TestProjectTransactionRollback(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, resp.StatusCode, "Expected 500 error when creating project with invalid reader") + // TODO: Test against actual JSON body respBody, err := io.ReadAll(resp.Body) assert.NoError(t, err) assert.Contains(t, string(respBody), "unable to get user nonexistent_user", @@ -320,14 +342,14 @@ func TestProjectTransactionRollback(t *testing.T) { t.Log("Transaction rollback verified: no orphaned project record") }) - t.Run("Successful project creation commits all changes", func(t *testing.T) { + t.Run("Project creation, commit all", func(t *testing.T) { // Create a second user to be a reader bobJSON := `{"user_handle": "bob", "name": "Bob Smith", "email": "bob@foo.bar"}` _, err := createUser(t, bobJSON) assert.NoError(t, err) // Create a project with Bob as a reader - projectJSON := `{"project_handle": "test-success", "description": "Test successful transaction", "authorizedReaders": ["bob"]}` + projectJSON := `{"project_handle": "test-success", "description": "Test successful transaction", "shared_with": [{ "user_handle": "bob", "role": "reader"}]}` requestURL := fmt.Sprintf("http://%s:%d/v1/projects/alice/test-success", options.Host, options.Port) req, err := http.NewRequest(http.MethodPut, requestURL, bytes.NewReader([]byte(projectJSON))) assert.NoError(t, err) @@ -362,8 +384,8 @@ func TestProjectTransactionRollback(t *testing.T) { err = json.Unmarshal(respBody, &projectData) assert.NoError(t, err) - readers, ok := projectData["authorizedReaders"].([]interface{}) - assert.True(t, ok, "authorizedReaders should be an array") + readers, ok := projectData["shared_with"].([]interface{}) + assert.True(t, ok, "shared_with should be an array") // Convert to string slice for easier checking readerStrings := make([]string, len(readers)) @@ -394,4 +416,7 @@ func TestProjectTransactionRollback(t *testing.T) { fmt.Print("Shutting down server\n\n") shutDownServer() }) + + fmt.Printf("\n\n\n\n") } +*/ diff --git a/internal/handlers/public_access_test.go b/internal/handlers/public_access_test.go index 1d9a81d..97907db 100644 --- a/internal/handlers/public_access_test.go +++ b/internal/handlers/public_access_test.go @@ -2,6 +2,7 @@ package handlers_test import ( "bytes" + "encoding/json" "fmt" "io" "net/http" @@ -11,53 +12,71 @@ import ( "github.com/stretchr/testify/assert" ) -// TestPublicAccess tests the public access functionality when "*" is in authorizedReaders +// TODO: Test against actual JSON body + +// TestPublicAccess tests the public access functionality when "*" is in shared_with func TestPublicAccess(t *testing.T) { + // Get the database connection pool from package variable pool := connPool // Create a mock key generator mockKeyGen := new(MockKeyGen) - // Set up expectations for the mock key generator - mockKeyGen.On("RandomKey", 32).Return("12345678901234567890123456789012", nil).Maybe() + // Set up expectations for the mock key generator - return different keys for each call + mockKeyGen.On("RandomKey", 32).Return("12345678901234567890123456789012", nil).Once() // Alice's key + mockKeyGen.On("RandomKey", 32).Return("abcdefghijklmnopqrstuvwxyz123456", nil).Once() // Bob's key + mockKeyGen.On("RandomKey", 32).Return("98765432109876543210987654321098", nil).Maybe() // Any additional keys // Start the server err, shutDownServer := startTestServer(t, pool, mockKeyGen) assert.NoError(t, err) - // Create user bob to be used in tests - bobJSON := `{"user_handle": "bob", "name": "Bob Smith", "email": "bob@foo.bar"}` - bobAPIKey, err := createUser(t, bobJSON) + // Create users to be used in sharing tests + aliceJSON := `{"user_handle": "alice", "name": "Alice Doe", "email": "alice@foo.bar"}` + aliceAPIKey, err := createUser(t, aliceJSON) if err != nil { - t.Fatalf("Error creating user bob for testing: %v\n", err) + t.Fatalf("Error creating user alice for testing: %v\n", err) } - // Create a public project with "*" in authorizedReaders - publicProjectJSON := `{"project_handle": "public-test", "description": "A public test project", "authorizedReaders": ["*"]}` - _, err = createProject(t, publicProjectJSON, "bob", bobAPIKey) + /* + bobJSON := `{"user_handle": "bob", "name": "Bob Smith", "email": "bob@foo.bar"}` + bobAPIKey, err := createUser(t, bobJSON) + if err != nil { + t.Fatalf("Error creating user bob for testing: %v\n", err) + } + */ + + // Create API standard to be used in tests + openaiJSON := `{"api_standard_handle": "openai", "description": "OpenAI Embeddings API", "key_method": "auth_bearer", "key_field": "Authorization" }` + _, err = createAPIStandard(t, openaiJSON, options.AdminKey) if err != nil { - t.Fatalf("Error creating project bob/public-test for testing: %v\n", err) + t.Fatalf("Error creating API standard openai for testing: %v\n", err) } - // Create API standard to be used in embeddings tests - apiStandardJSON := `{"api_standard_handle": "openai", "description": "OpenAI Embeddings API", "key_method": "auth_bearer", "key_field": "Authorization" }` - _, err = createAPIStandard(t, apiStandardJSON, options.AdminKey) + // Create an instance for alice + instanceJSON := `{"instance_handle": "embedding1", "endpoint": "https://api.openai.com/v1/embeddings", "description": "Alice's OpenAI instance", "api_standard": "openai", "model": "text-embedding-3-large", "dimensions": 5}` + _, err = createInstance(t, instanceJSON, "alice", aliceAPIKey) if err != nil { - // Ignore error if API standard already exists from previous test - if err.Error() != "status code 409" { - t.Logf("Warning: Error creating API standard (may already exist): %v\n", err) - } + t.Fatalf("Error creating instance for sharing tests: %v\n", err) } - // Create LLM Service to be used in embeddings tests - llmServiceJSON := `{ "llm_service_handle": "test1", "endpoint": "https://api.foo.bar/v1/embed", "description": "An LLM Service just for testing if the dhamps-vdb code is working", "api_key": "0123456789", "api_standard": "openai", "model": "embed-test1", "dimensions": 5}` - _, err = createLLMService(t, llmServiceJSON, "bob", bobAPIKey) + // Create public project to be used in embeddings tests + projectJSON := `{ "project_handle": "public-test", "instance_owner": "alice", "instance_handle": "embedding1", "description": "This is a test project", "public_read": true }` + _, err = createProject(t, projectJSON, "alice", aliceAPIKey) if err != nil { - t.Fatalf("Error creating LLM service openai-large for testing: %v\n", err) + t.Fatalf("Error creating project alice/public-test for testing: %v\n", err) } + /* + shareProjectJSON := `{"share_with_handle": "*", "role": "reader"}` + _, err = shareProject(t, "bob", "public-test", shareProjectJSON, bobAPIKey) + if err != nil { + t.Fatalf("Error sharing project bob/public-test with *: %v\n", err) + } + */ + // Post some embeddings to the public project - _, err = postEmbeddings(t, "../../testdata/valid_embeddings.json", "bob", "public-test", bobAPIKey) + _, err = postEmbeddings(t, "../../testdata/valid_embeddings.json", "alice", "public-test", aliceAPIKey) if err != nil { t.Fatalf("Error posting embeddings: %v\n", err) } @@ -69,132 +88,174 @@ func TestPublicAccess(t *testing.T) { name string method string requestPath string - apiKeyHeader string - expectStatus int - checkSuccess bool // If true, check for 200/2xx status instead of specific body + bodyPath string + VDBKey string + expectBody string + expectStatus int16 }{ { - name: "Get project embeddings without authentication (public project)", + name: "Get project metadata without authentication (public project)", method: http.MethodGet, - requestPath: "/v1/embeddings/bob/public-test", - apiKeyHeader: "", + requestPath: "/v1/projects/alice/public-test", + bodyPath: "", + VDBKey: "", + expectBody: "", expectStatus: http.StatusOK, - checkSuccess: true, }, { - name: "Get document embeddings without authentication (public project)", + name: "Get project embeddings without authentication (public project)", method: http.MethodGet, - requestPath: "/v1/embeddings/bob/public-test/https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.1.1.1.1", - apiKeyHeader: "", + requestPath: "/v1/embeddings/alice/public-test", + bodyPath: "", + VDBKey: "", + expectBody: "", expectStatus: http.StatusOK, - checkSuccess: true, }, { - name: "Get similars without authentication (public project)", + name: "Get document embeddings without authentication (public project)", method: http.MethodGet, - requestPath: "/v1/similars/bob/public-test/https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.1.1.1.1", - apiKeyHeader: "", + requestPath: "/v1/embeddings/alice/public-test/https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.1.1.1.1", + bodyPath: "", + VDBKey: "", + expectBody: "", expectStatus: http.StatusOK, - checkSuccess: true, }, { - name: "Get project metadata without authentication (public project)", + name: "Get similars without authentication (public project)", method: http.MethodGet, - requestPath: "/v1/projects/bob/public-test", - apiKeyHeader: "", + requestPath: "/v1/similars/alice/public-test/https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.1.1.1.1", + bodyPath: "", + VDBKey: "", + expectBody: "", expectStatus: http.StatusOK, - checkSuccess: true, }, { - name: "Post embeddings without authentication should still be unauthorized (public project)", + name: "Post embeddings without authentication (public project)", method: http.MethodPost, - requestPath: "/v1/embeddings/bob/public-test", - apiKeyHeader: "", + requestPath: "/v1/embeddings/alice/public-test", + bodyPath: "../../testdata/valid_embeddings.json", + VDBKey: "", + expectBody: "", expectStatus: http.StatusUnauthorized, - checkSuccess: false, }, } for _, v := range tt { t.Run(v.name, func(t *testing.T) { - requestURL := fmt.Sprintf("http://%v:%d%v", options.Host, options.Port, v.requestPath) - req, err := http.NewRequest(v.method, requestURL, nil) - assert.NoError(t, err) - if v.apiKeyHeader != "" { - req.Header.Add("Authorization", "Bearer "+v.apiKeyHeader) + // We need to handle the body only for PUT and POST requests + // For GET and DELETE requests, the body is nil + reqBody := io.Reader(nil) + if v.method == http.MethodGet || v.method == http.MethodDelete { + reqBody = nil + } else { + f, err := os.Open(v.bodyPath) + assert.NoError(t, err) + defer func() { + if err := f.Close(); err != nil { + t.Fatal(err) + } + }() + b := new(bytes.Buffer) + _, err = io.Copy(b, f) + assert.NoError(t, err) + reqBody = bytes.NewReader(b.Bytes()) } - + requestURL := fmt.Sprintf("http://%v:%d%v", options.Host, options.Port, v.requestPath) + req, err := http.NewRequest(v.method, requestURL, reqBody) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+v.VDBKey) resp, err := http.DefaultClient.Do(req) if err != nil { t.Errorf("Error sending request: %v\n", err) } - assert.NoError(t, err) - - // Check status code - assert.Equal(t, v.expectStatus, resp.StatusCode, "Status code mismatch for %s", v.name) + // assert.NoError(t, err) + defer resp.Body.Close() - if v.checkSuccess && resp.StatusCode >= 200 && resp.StatusCode < 300 { - t.Logf("✓ %s: Got successful response with status %d", v.name, resp.StatusCode) + if resp.StatusCode != int(v.expectStatus) { + t.Errorf("Expected status code %d, got %s\n", v.expectStatus, resp.Status) + } else { + t.Logf("Expected status code %d, got %s\n", v.expectStatus, resp.Status) } - resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) // response body is []byte + assert.NoError(t, err) + formattedResp := "" + if v.expectBody != "" { + fr := new(bytes.Buffer) + err = json.Indent(fr, respBody, "", " ") + assert.NoError(t, err) + formattedResp = fr.String() + } + // if (resp.StatusCode != http.StatusOK) || (resp.StatusCode != int(v.expectStatus)) { + assert.Equal(t, v.expectBody, formattedResp, "they should be equal") + // } }) } // Cleanup - fmt.Print("\n\nRunning cleanup ...\n\n") + t.Cleanup(func() { + fmt.Print("\n\nRunning cleanup ...\n\n") - requestURL := fmt.Sprintf("http://%s:%d/v1/admin/footgun", options.Host, options.Port) - req, err := http.NewRequest(http.MethodGet, requestURL, nil) - assert.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+options.AdminKey) - _, err = http.DefaultClient.Do(req) - if err != nil && err.Error() != "no rows in result set" { - t.Fatalf("Error sending request: %v\n", err) - } - assert.NoError(t, err) + requestURL := fmt.Sprintf("http://%s:%d/v1/admin/footgun", options.Host, options.Port) + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+options.AdminKey) + _, err = http.DefaultClient.Do(req) + if err != nil && err.Error() != "no rows in result set" { + t.Fatalf("Error sending request: %v\n", err) + } + assert.NoError(t, err) - fmt.Print("Shutting down server\n\n") - shutDownServer() + fmt.Print("Shutting down server\n\n") + shutDownServer() + }) + + fmt.Printf("\n\n\n\n") } // Helper function to post embeddings func postEmbeddings(t *testing.T, bodyPath, user, project, apiKey string) (string, error) { f, err := os.Open(bodyPath) if err != nil { - return "", err + fmt.Printf("%v", err) } defer f.Close() + assert.NoError(t, err) b := new(bytes.Buffer) _, err = io.Copy(b, f) if err != nil { - return "", err + fmt.Printf("%v", err) } + assert.NoError(t, err) requestURL := fmt.Sprintf("http://%v:%d/v1/embeddings/%s/%s", options.Host, options.Port, user, project) req, err := http.NewRequest(http.MethodPost, requestURL, bytes.NewReader(b.Bytes())) if err != nil { - return "", err + fmt.Printf("%v", err) } req.Header.Add("Authorization", "Bearer "+apiKey) + assert.NoError(t, err) resp, err := http.DefaultClient.Do(req) if err != nil { - return "", err + fmt.Printf("%v", err) } defer resp.Body.Close() + assert.NoError(t, err) if resp.StatusCode != http.StatusCreated { bodyBytes, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("status code %d: %s", resp.StatusCode, string(bodyBytes)) + fmt.Printf("status code %d: %s", resp.StatusCode, string(bodyBytes)) } + assert.Equal(t, http.StatusCreated, resp.StatusCode, "Expected status code 201 Created") bodyBytes, err := io.ReadAll(resp.Body) if err != nil { - return "", err + fmt.Printf("%v", err) } + assert.NoError(t, err) return string(bodyBytes), nil } diff --git a/internal/handlers/similars.go b/internal/handlers/similars.go index 5975113..5f266f6 100644 --- a/internal/handlers/similars.go +++ b/internal/handlers/similars.go @@ -14,6 +14,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) +// TODO: Allow to get similars to a submission that includes ready-made embeddings + // Define handler functions for each route func getSimilarFunc(ctx context.Context, input *models.GetSimilarRequest) (*models.SimilarResponse, error) { // Check if only one of input.MetadataField and input.MetadataValue are given diff --git a/internal/handlers/similars_test.go b/internal/handlers/similars_test.go index c22a71b..a9eec02 100644 --- a/internal/handlers/similars_test.go +++ b/internal/handlers/similars_test.go @@ -13,6 +13,7 @@ import ( ) func TestSimilarsFunc(t *testing.T) { + // Get the database connection pool from package variable pool := connPool @@ -32,13 +33,6 @@ func TestSimilarsFunc(t *testing.T) { t.Fatalf("Error creating user alice for testing: %v\n", err) } - // Create project - projectJSON := `{"project_handle": "test1", "description": "A test project"}` - _, err = createProject(t, projectJSON, "alice", aliceAPIKey) - if err != nil { - t.Fatalf("Error creating project alice/test1 for testing: %v\n", err) - } - // Create API standard apiStandardJSON := `{"api_standard_handle": "openai", "description": "OpenAI Embeddings API", "key_method": "auth_bearer", "key_field": "Authorization" }` _, err = createAPIStandard(t, apiStandardJSON, options.AdminKey) @@ -47,18 +41,26 @@ func TestSimilarsFunc(t *testing.T) { } // Create LLM Service - llmServiceJSON := `{ "llm_service_handle": "test1", "endpoint": "https://api.foo.bar/v1/embed", "description": "An LLM Service just for testing if the dhamps-vdb code is working", "api_key": "0123456789", "api_standard": "openai", "model": "embed-test1", "dimensions": 5}` - _, err = createLLMService(t, llmServiceJSON, "alice", aliceAPIKey) + InstanceJSON := `{ "instance_handle": "embedding1", "endpoint": "https://api.foo.bar/v1/embed", "description": "An LLM Service just for testing if the dhamps-vdb code is working", "api_standard": "openai", "model": "embed-test1", "dimensions": 5}` + _, err = createInstance(t, InstanceJSON, "alice", aliceAPIKey) if err != nil { t.Fatalf("Error creating LLM service openai-large for testing: %v\n", err) } + // Create project + projectJSON := `{"project_handle": "test1", "description": "A test project", "instance_owner": "alice", "instance_handle": "embedding1"}` + _, err = createProject(t, projectJSON, "alice", aliceAPIKey) + if err != nil { + t.Fatalf("Error creating project alice/test1 for testing: %v\n", err) + } + // Upload embeddings embeddingsFilePath := "../../testdata/valid_embeddings.json" embeddingsFile, err := os.Open(embeddingsFilePath) if err != nil { t.Fatalf("Error opening embeddings file: %v\n", err) } + // Defer closing embeddingsFile defer func() { if err := embeddingsFile.Close(); err != nil { t.Fatalf("Error closing embeddings file: %v\n", err) @@ -68,7 +70,7 @@ func TestSimilarsFunc(t *testing.T) { if err != nil { t.Fatalf("Error reading embeddings file: %v\n", err) } - err = createEmbeddings(t, embeddingsData, "alice", "openai-large", aliceAPIKey) + err = createEmbeddings(t, embeddingsData, "alice", "test1", aliceAPIKey) if err != nil { t.Fatalf("Error creating embeddings for testing: %v\n", err) } @@ -88,7 +90,7 @@ func TestSimilarsFunc(t *testing.T) { method: http.MethodGet, requestPath: "/v1/similars/alice/test1/https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.1.1.1.1", bodyPath: "", - apiKey: options.AdminKey, + apiKey: aliceAPIKey, expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/SimilarResponseBody.json\",\n \"user_handle\": \"alice\",\n \"project_handle\": \"test1\",\n \"ids\": [\n \"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.2\",\n \"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol2\"\n ]\n}\n", expectStatus: http.StatusOK, }, @@ -161,7 +163,7 @@ func TestSimilarsFunc(t *testing.T) { // Cleanup removes items created by the put function test // (deleting '/users/alice' should delete all the - // projects, llmservices and embeddings connected to alice as well) + // projects, instances and embeddings connected to alice as well) t.Cleanup(func() { fmt.Print("\n\nRunning cleanup ...\n\n") @@ -179,6 +181,7 @@ func TestSimilarsFunc(t *testing.T) { shutDownServer() }) + fmt.Printf("\n\n\n\n") } // TestPostSimilarStub is a placeholder test for the POST similar functionality. diff --git a/internal/handlers/users.go b/internal/handlers/users.go index 54de99d..c9ce27d 100644 --- a/internal/handlers/users.go +++ b/internal/handlers/users.go @@ -42,23 +42,25 @@ func putUserFunc(ctx context.Context, input *models.PutUserRequest) (*models.Upl if err != nil && err.Error() != "no rows in result set" { return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to check if user %s already exists. %v", input.UserHandle, err)) } + + // Create API key if user does not exist // storeKey := make([]byte, 64) var storeKey string - APIKey := "" + VDBKey := "" if u.UserHandle == input.UserHandle { // User exists, so don't create API key - storeKey = u.VdbAPIKey + storeKey = u.VDBKey fmt.Printf(" User %s already exists, stored key hash is %s.\n", input.UserHandle, storeKey) // fmt.Printf(" User %s already exists: %v.\n", input.UserHandle, u) - // fmt.Printf(" User %s. Stored key hash: '%s'.\n", input.UserHandle, u.VdbAPIKey) - APIKey = "not changed" + // fmt.Printf(" User %s. Stored key hash: '%s'.\n", input.UserHandle, u.VDBKey) + VDBKey = "not changed" } else { // User does not exist, so create a new API key - APIKey, err = keyGen.RandomKey(32) + VDBKey, err = keyGen.RandomKey(32) if err != nil { return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to create API key for user %s. %v", input.UserHandle, err)) } - hash := sha256.Sum256([]byte(APIKey)) + hash := sha256.Sum256([]byte(VDBKey)) storeKey = hex.EncodeToString(hash[:]) // fmt.Printf(" Created user %s: API key %s (store hash: %s)\n", input.UserHandle, APIKey, storeKey) } @@ -66,19 +68,30 @@ func putUserFunc(ctx context.Context, input *models.PutUserRequest) (*models.Upl UserHandle: input.UserHandle, Name: pgtype.Text{String: input.Body.Name, Valid: true}, Email: input.Body.Email, - VdbAPIKey: storeKey, + VDBKey: storeKey, } // Run the query - u, err = queries.UpsertUser(ctx, user) + s, err := queries.UpsertUser(ctx, user) if err != nil { return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to upload user. %v", err)) } + u, err = queries.RetrieveUser(ctx, s) + if err != nil && err.Error() != "no rows in result set" { + return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to verify that user %s exists now. %v", s, err)) + } // Build the response response := &models.UploadUserResponse{} response.Body.UserHandle = u.UserHandle - response.Body.APIKey = APIKey + // Return the actual API key only if it was just created + // When updating an existing user, don't include the VDB key in the response + if VDBKey != "not changed" { + response.Body.VDBKey = VDBKey + } else { + response.Body.VDBKey = "not changed" + } + return response, nil } @@ -103,7 +116,7 @@ func getUsersFunc(ctx context.Context, input *models.GetUsersRequest) (*models.G // Run the query queries := database.New(pool) - allUsers, err := queries.GetUsers(ctx, database.GetUsersParams{Limit: int32(input.Limit), Offset: int32(input.Offset)}) + allUsers, err := queries.GetAllUsers(ctx, database.GetAllUsersParams{Limit: int32(input.Limit), Offset: int32(input.Offset)}) if err != nil { if err.Error() == "no rows in result set" { return nil, huma.Error404NotFound("no users found.") @@ -165,32 +178,51 @@ func getUserFunc(ctx context.Context, input *models.GetUserRequest) (*models.Get }) } - // Get LLM services the user is a member of - llmservices := models.LLMMemberships{} - ls, err := queries.GetLLMsByUser(ctx, database.GetLLMsByUserParams{UserHandle: input.UserHandle}) + // Get LLM service instances the user is a member of + imemberships := models.InstanceMemberships{} + instances, err := queries.GetAccessibleInstancesByUser(ctx, database.GetAccessibleInstancesByUserParams{ + Owner: input.UserHandle, + Limit: 999, + Offset: 0, + }) if err != nil { if err.Error() == "no rows in result set" { - fmt.Printf("Warning: No projects registered for user %s.", input.UserHandle) + fmt.Printf("Warning: No LLM service instances registered for user %s.", input.UserHandle) } else { - fmt.Printf("Warning: Unable to get list of projects for user %s. %v", input.UserHandle, err) + fmt.Printf("Warning: Unable to get list of LLM service instances for user %s: %v", input.UserHandle, err) } } - for _, llmservice := range ls { - llmservices = append(llmservices, models.LLMMembership{ - LLMServiceHandle: llmservice.LLMServiceHandle, - LLMServiceOwner: llmservice.Owner, - Role: llmservice.Role, + for _, i := range instances { + instance, err := queries.RetrieveInstance(ctx, database.RetrieveInstanceParams{ + Owner: i.Owner, + InstanceHandle: i.InstanceHandle, + }) + if err != nil { + fmt.Printf("Warning: Unable to get details of LLM service instance %s for user %s: %v", i.InstanceHandle, input.UserHandle, err) + continue + } + // Handle the case where Role might be nil (when instance is owned by user) + role := "owner" + if i.Role != nil { + if r, ok := i.Role.(string); ok { + role = r + } + } + imemberships = append(imemberships, models.InstanceMembership{ + InstanceHandle: instance.InstanceHandle, + InstanceOwner: instance.Owner, + Role: role, }) } // Build the response returnUser := &models.User{ - UserHandle: u.UserHandle, - Name: u.Name.String, - Email: u.Email, - APIKey: u.VdbAPIKey, - Projects: projects, - LLMServices: llmservices, + UserHandle: u.UserHandle, + Name: u.Name.String, + Email: u.Email, + VDBKey: u.VDBKey, + Projects: projects, + Instances: imemberships, } response := &models.GetUserResponse{} response.Body = *returnUser diff --git a/internal/handlers/users_test.go b/internal/handlers/users_test.go index 1e79cd7..35ff35f 100644 --- a/internal/handlers/users_test.go +++ b/internal/handlers/users_test.go @@ -13,6 +13,7 @@ import ( ) func TestUserFunc(t *testing.T) { + // Get the database connection pool from package variable pool := connPool @@ -43,7 +44,7 @@ func TestUserFunc(t *testing.T) { requestPath: "/v1/users/alice", bodyPath: "../../testdata/valid_user.json", apiKey: options.AdminKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/HandleAPIStruct.json\",\n \"user_handle\": \"alice\",\n \"api_key\": \"12345678901234567890123456789012\"\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/UserResponse.json\",\n \"user_handle\": \"alice\",\n \"vdb_key\": \"12345678901234567890123456789012\"\n}\n", expectStatus: http.StatusCreated, }, { @@ -52,7 +53,7 @@ func TestUserFunc(t *testing.T) { requestPath: "/v1/users/alice", bodyPath: "", apiKey: options.AdminKey, - expectBody: "{\n \"user_handle\": \"alice\",\n \"name\": \"Alice Doe\",\n \"email\": \"alice@foo.bar\",\n \"apiKey\": \"e1b85b27d6bcb05846c18e6a48f118e89f0c0587140de9fb3359f8370d0dba08\"\n}\n", + expectBody: "{\n \"user_handle\": \"alice\",\n \"name\": \"Alice Doe\",\n \"email\": \"alice@foo.bar\",\n \"vdb_key\": \"e1b85b27d6bcb05846c18e6a48f118e89f0c0587140de9fb3359f8370d0dba08\"\n}\n", expectStatus: http.StatusOK, }, { @@ -97,7 +98,7 @@ func TestUserFunc(t *testing.T) { requestPath: "/v1/users", bodyPath: "../../testdata/valid_user.json", apiKey: options.AdminKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/HandleAPIStruct.json\",\n \"user_handle\": \"alice\",\n \"api_key\": \"not changed\"\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/UserResponse.json\",\n \"user_handle\": \"alice\",\n \"vdb_key\": \"not changed\"\n}\n", expectStatus: http.StatusCreated, }, { @@ -133,7 +134,7 @@ func TestUserFunc(t *testing.T) { requestPath: "/v1/users", bodyPath: "", apiKey: options.AdminKey, - expectBody: "[\n \"alice\"\n]\n", + expectBody: "[\n \"alice\",\n \"_system\"\n]\n", expectStatus: http.StatusOK, }, { @@ -249,4 +250,5 @@ func TestUserFunc(t *testing.T) { shutDownServer() }) + fmt.Printf("\n\n\n\n") } diff --git a/internal/handlers/validation.go b/internal/handlers/validation.go index f883405..0291a72 100644 --- a/internal/handlers/validation.go +++ b/internal/handlers/validation.go @@ -22,8 +22,8 @@ func ValidateEmbeddingDimensions(embedding models.EmbeddingsInput, llmDimensions // Check if declared vector_dim matches LLM service dimensions if embedding.VectorDim != llmDimensions { - return fmt.Errorf("vector dimension mismatch: embedding declares %d dimensions but LLM service '%s' expects %d dimensions", - embedding.VectorDim, embedding.LLMServiceHandle, llmDimensions) + return fmt.Errorf("vector dimension mismatch: embedding declares %d dimensions but LLM service instance '%s' expects %d dimensions", + embedding.VectorDim, embedding.InstanceHandle, llmDimensions) } // Check if actual vector length matches declared vector_dim diff --git a/internal/handlers/validation_test.go b/internal/handlers/validation_test.go index 2dc8afe..475ab5e 100644 --- a/internal/handlers/validation_test.go +++ b/internal/handlers/validation_test.go @@ -2,6 +2,7 @@ package handlers_test import ( "bytes" + "encoding/json" "fmt" "io" "net/http" @@ -12,6 +13,7 @@ import ( ) func TestValidationFunc(t *testing.T) { + // Get the database connection pool from package variable pool := connPool @@ -31,32 +33,32 @@ func TestValidationFunc(t *testing.T) { t.Fatalf("Error creating user alice for testing: %v\n", err) } - // Create project without schema - projectJSON := `{"project_handle": "test1", "description": "A test project"}` - _, err = createProject(t, projectJSON, "alice", aliceAPIKey) + // Create API standard to be used in embeddings tests + apiStandardJSON := `{"api_standard_handle": "openai", "description": "OpenAI Embeddings API", "key_method": "auth_bearer", "key_field": "Authorization" }` + _, err = createAPIStandard(t, apiStandardJSON, options.AdminKey) if err != nil { - t.Fatalf("Error creating project alice/test1 for testing: %v\n", err) + t.Fatalf("Error creating API standard openai for testing: %v\n", err) } - // Create project with metadata schema - projectWithSchemaJSON := `{"project_handle": "test-schema", "description": "Test project with schema", "metadataScheme": "{\"type\":\"object\",\"properties\":{\"author\":{\"type\":\"string\"},\"year\":{\"type\":\"integer\"}},\"required\":[\"author\"]}"}` - _, err = createProject(t, projectWithSchemaJSON, "alice", aliceAPIKey) + // Create LLM Service Instance with 5 dimensions for testing + InstanceInstanceJSON := `{ "instance_handle": "embedding1", "endpoint": "https://api.openai.com/v1/embeddings", "description": "My OpenAI test service", "api_standard": "openai", "model": "text-embedding-3-large", "dimensions": 5}` + _, err = createInstance(t, InstanceInstanceJSON, "alice", aliceAPIKey) if err != nil { - t.Fatalf("Error creating project alice/test-schema for testing: %v\n", err) + t.Fatalf("Error creating LLM Service Instance embedding1 for testing: %v\n", err) } - // Create API standard to be used in embeddings tests - apiStandardJSON := `{"api_standard_handle": "openai", "description": "OpenAI Embeddings API", "key_method": "auth_bearer", "key_field": "Authorization" }` - _, err = createAPIStandard(t, apiStandardJSON, options.AdminKey) + // Create project without schema + projectJSON := `{"project_handle": "test1", "description": "A test project", "instance_owner": "alice", "instance_handle": "embedding1"}` + _, err = createProject(t, projectJSON, "alice", aliceAPIKey) if err != nil { - t.Fatalf("Error creating API standard openai for testing: %v\n", err) + t.Fatalf("Error creating project alice/test1 for testing: %v\n", err) } - // Create LLM Service with 5 dimensions for testing - llmServiceJSON := `{ "llm_service_handle": "openai-large", "endpoint": "https://api.openai.com/v1/embeddings", "description": "My OpenAI test service", "api_key": "0123456789", "api_standard": "openai", "model": "text-embedding-3-large", "dimensions": 5}` - _, err = createLLMService(t, llmServiceJSON, "alice", aliceAPIKey) + // Create project with metadata schema + projectWithSchemaJSON := `{"project_handle": "test-schema", "description": "Test project with schema", "metadataScheme": "{\"type\":\"object\",\"properties\":{\"author\":{\"type\":\"string\"},\"year\":{\"type\":\"integer\"}},\"required\":[\"author\"]}", "instance_owner": "alice", "instance_handle": "embedding1"}` + _, err = createProject(t, projectWithSchemaJSON, "alice", aliceAPIKey) if err != nil { - t.Fatalf("Error creating LLM service openai-large for testing: %v\n", err) + t.Fatalf("Error creating project alice/test-schema for testing: %v\n", err) } fmt.Printf("\nRunning validation tests ...\n\n") @@ -67,7 +69,7 @@ func TestValidationFunc(t *testing.T) { method string requestPath string bodyPath string - apiKeyHeader string + apiKey string expectBody string expectStatus int16 }{ @@ -76,8 +78,8 @@ func TestValidationFunc(t *testing.T) { method: http.MethodPost, requestPath: "/v1/embeddings/alice/test1", bodyPath: "../../testdata/invalid_embeddings_wrong_dims.json", - apiKeyHeader: aliceAPIKey, - expectBody: "dimension validation failed: vector length mismatch", + apiKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Bad Request\",\n \"status\": 400,\n \"detail\": \"Dimension validation failed for input test-wrong-dims: vector length mismatch for text_id 'test-wrong-dims': actual vector has 3 elements but vector_dim declares 5\"\n}\n", expectStatus: http.StatusBadRequest, }, { @@ -85,8 +87,8 @@ func TestValidationFunc(t *testing.T) { method: http.MethodPost, requestPath: "/v1/embeddings/alice/test1", bodyPath: "../../testdata/invalid_embeddings_dimension_mismatch.json", - apiKeyHeader: aliceAPIKey, - expectBody: "dimension validation failed: vector dimension mismatch", + apiKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Bad Request\",\n \"status\": 400,\n \"detail\": \"Dimension validation failed for input test-mismatch-dims: vector dimension mismatch: embedding declares 3072 dimensions but LLM service instance 'embedding1' expects 5 dimensions\"\n}\n", expectStatus: http.StatusBadRequest, }, { @@ -94,8 +96,8 @@ func TestValidationFunc(t *testing.T) { method: http.MethodPost, requestPath: "/v1/embeddings/alice/test-schema", bodyPath: "../../testdata/valid_embeddings_with_schema.json", - apiKeyHeader: aliceAPIKey, - expectBody: "test-valid-metadata", + apiKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/UploadProjEmbeddingsResponseBody.json\",\n \"ids\": [\n \"test-valid-metadata\"\n ]\n}\n", expectStatus: http.StatusCreated, }, { @@ -103,52 +105,83 @@ func TestValidationFunc(t *testing.T) { method: http.MethodPost, requestPath: "/v1/embeddings/alice/test-schema", bodyPath: "../../testdata/invalid_embeddings_schema_violation.json", - apiKeyHeader: aliceAPIKey, - expectBody: "metadata validation failed", + apiKey: aliceAPIKey, + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/ErrorModel.json\",\n \"title\": \"Bad Request\",\n \"status\": 400,\n \"detail\": \"metadata validation failed for text_id 'test-invalid-metadata': metadata validation failed:\\n - (root): author is required\"\n}\n", expectStatus: http.StatusBadRequest, }, } - // Run the tests - for _, tc := range tt { - t.Run(tc.name, func(t *testing.T) { - // Read the body from file if specified - var bodyReader io.Reader - if tc.bodyPath != "" { - body, err := os.ReadFile(tc.bodyPath) - if err != nil { - t.Fatalf("Error reading body file: %v", err) - } - bodyReader = bytes.NewReader(body) + for _, v := range tt { + t.Run(v.name, func(t *testing.T) { + + // We need to handle the body only for POST requests + reqBody := io.Reader(nil) + if v.method == http.MethodPost { + f, err := os.Open(v.bodyPath) + assert.NoError(t, err) + defer func() { + if err := f.Close(); err != nil { + t.Fatal(err) + } + }() + b := new(bytes.Buffer) + _, err = io.Copy(b, f) + assert.NoError(t, err) + reqBody = bytes.NewReader(b.Bytes()) } - - // Create the request - req, err := http.NewRequest(tc.method, fmt.Sprintf("http://localhost:8080%s", tc.requestPath), bodyReader) + requestURL := fmt.Sprintf("http://%v:%d%v", options.Host, options.Port, v.requestPath) + req, err := http.NewRequest(v.method, requestURL, reqBody) assert.NoError(t, err) - if tc.apiKeyHeader != "" { - req.Header.Set("Authorization", "Bearer "+tc.apiKeyHeader) - } + req.Header.Set("Authorization", "Bearer "+v.apiKey) req.Header.Set("Content-Type", "application/json") - - // Send the request resp, err := http.DefaultClient.Do(req) - assert.NoError(t, err) + if err != nil { + t.Errorf("Error sending request: %v\n", err) + } defer resp.Body.Close() - - // Read the response body - respBody, err := io.ReadAll(resp.Body) assert.NoError(t, err) - // Check the status code - assert.Equal(t, int(tc.expectStatus), resp.StatusCode, "Status code mismatch for test: %s. Response body: %s", tc.name, string(respBody)) + if resp.StatusCode != int(v.expectStatus) { + t.Errorf("Expected status code %d, got %s\n", v.expectStatus, resp.Status) + } else { + t.Logf("Expected status code %d, got %s\n", v.expectStatus, resp.Status) + } - // Check the response body contains expected text - if tc.expectBody != "" { - assert.Contains(t, string(respBody), tc.expectBody, "Response body mismatch for test: %s", tc.name) + respBody, err := io.ReadAll(resp.Body) // response body is []byte + assert.NoError(t, err) + formattedResp := "" + if v.expectBody != "" { + fr := new(bytes.Buffer) + err = json.Indent(fr, respBody, "", " ") + assert.NoError(t, err) + formattedResp = fr.String() } + assert.Equal(t, v.expectBody, formattedResp, "they should be equal") }) } - // Shutdown the server - shutDownServer() + // Verify that the expectations regarding the mock key generation were met + mockKeyGen.AssertExpectations(t) + + // Cleanup removes items created by the put function test + // (deleting '/users/alice' should delete all the + // projects, instances and embeddings connected to alice as well) + t.Cleanup(func() { + fmt.Print("\n\nRunning cleanup ...\n\n") + + requestURL := fmt.Sprintf("http://%s:%d/v1/admin/footgun", options.Host, options.Port) + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+options.AdminKey) + _, err = http.DefaultClient.Do(req) + if err != nil && err.Error() != "no rows in result set" { + t.Fatalf("Error sending request: %v\n", err) + } + assert.NoError(t, err) + + fmt.Print("Shutting down server\n\n") + shutDownServer() + }) + + fmt.Printf("\n") } diff --git a/internal/handlers/validation_unit_test.go b/internal/handlers/validation_unit_test.go index 5424be7..74cf225 100644 --- a/internal/handlers/validation_unit_test.go +++ b/internal/handlers/validation_unit_test.go @@ -19,10 +19,10 @@ func TestValidateEmbeddingDimensions(t *testing.T) { { name: "Valid embedding", embedding: models.EmbeddingsInput{ - TextID: "test-id", - LLMServiceHandle: "test-llm", - Vector: []float32{1.0, 2.0, 3.0}, - VectorDim: 3, + TextID: "test-id", + InstanceHandle: "test-llm", + Vector: []float32{1.0, 2.0, 3.0}, + VectorDim: 3, }, llmDimensions: 3, wantErr: false, @@ -30,10 +30,10 @@ func TestValidateEmbeddingDimensions(t *testing.T) { { name: "Empty text_id", embedding: models.EmbeddingsInput{ - TextID: "", - LLMServiceHandle: "test-llm", - Vector: []float32{1.0, 2.0, 3.0}, - VectorDim: 3, + TextID: "", + InstanceHandle: "test-llm", + Vector: []float32{1.0, 2.0, 3.0}, + VectorDim: 3, }, llmDimensions: 3, wantErr: true, @@ -42,10 +42,10 @@ func TestValidateEmbeddingDimensions(t *testing.T) { { name: "Empty vector", embedding: models.EmbeddingsInput{ - TextID: "test-id", - LLMServiceHandle: "test-llm", - Vector: []float32{}, - VectorDim: 3, + TextID: "test-id", + InstanceHandle: "test-llm", + Vector: []float32{}, + VectorDim: 3, }, llmDimensions: 3, wantErr: true, @@ -54,10 +54,10 @@ func TestValidateEmbeddingDimensions(t *testing.T) { { name: "Dimension mismatch with LLM service", embedding: models.EmbeddingsInput{ - TextID: "test-id", - LLMServiceHandle: "test-llm", - Vector: []float32{1.0, 2.0, 3.0}, - VectorDim: 5, + TextID: "test-id", + InstanceHandle: "test-llm", + Vector: []float32{1.0, 2.0, 3.0}, + VectorDim: 5, }, llmDimensions: 5, wantErr: true, @@ -66,10 +66,10 @@ func TestValidateEmbeddingDimensions(t *testing.T) { { name: "Vector dim doesn't match LLM service", embedding: models.EmbeddingsInput{ - TextID: "test-id", - LLMServiceHandle: "test-llm", - Vector: []float32{1.0, 2.0, 3.0}, - VectorDim: 3, + TextID: "test-id", + InstanceHandle: "test-llm", + Vector: []float32{1.0, 2.0, 3.0}, + VectorDim: 3, }, llmDimensions: 5, wantErr: true, diff --git a/internal/models/api_standards.go b/internal/models/api_standards.go index 9d6c15a..3aa450f 100644 --- a/internal/models/api_standards.go +++ b/internal/models/api_standards.go @@ -4,7 +4,7 @@ import ( "net/http" ) -// LLMService is a service for managing LLM data. +// Instance is a service for managing LLM data. type APIStandard struct { APIStandardHandle string `json:"api_standard_handle" minLength:"3" maxLength:"20" example:"openai-v1" doc:"Handle for the API standard"` Description string `json:"description" doc:"Description of the API standard"` diff --git a/internal/models/embeddings.go b/internal/models/embeddings.go index 0346293..99ab055 100644 --- a/internal/models/embeddings.go +++ b/internal/models/embeddings.go @@ -5,29 +5,33 @@ import ( "net/http" ) +// TODO: Distinguish Full and Brief Outputs + // Embeddings contains a single document's embeddings record with id, embeddings and possibly more information. type EmbeddingsInput struct { - TextID string `json:"text_id" doc:"Identifier for the document"` - UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` - ProjectHandle string `json:"project_handle" path:"project_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"Project handle"` - ProjectID int `json:"project_id,omitempty" doc:"Unique project identifier"` - LLMServiceHandle string `json:"llm_service_handle" doc:"Handle of the language model service used to generate the embeddings"` - Text string `json:"text,omitempty" doc:"Text content of the document"` - Vector []float32 `json:"vector" doc:"Half-precision embeddings vector for the document"` - VectorDim int32 `json:"vector_dim" doc:"Dimensionality of the embeddings vector"` - Metadata json.RawMessage `json:"metadata,omitempty" doc:"Metadata (json) for the document. E.g. creation year, author name or text genre." example:"{\n \"author\": \"Immanuel Kant\"\n}\n"` + TextID string `json:"text_id" doc:"Identifier for the document"` + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` + ProjectHandle string `json:"project_handle" path:"project_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"Project handle"` + ProjectID int `json:"project_id,omitempty" doc:"Unique project identifier"` + InstanceOwner string `json:"instance_owner,omitempty" doc:"Owner of the LLM service instance used to generate the embeddings"` + InstanceHandle string `json:"instance_handle" doc:"Handle of the LLM service instance used to generate the embeddings"` + Text string `json:"text,omitempty" doc:"Text content of the document"` + Vector []float32 `json:"vector" doc:"Half-precision embeddings vector for the document"` + VectorDim int32 `json:"vector_dim" doc:"Dimensionality of the embeddings vector"` + Metadata json.RawMessage `json:"metadata,omitempty" doc:"Metadata (json) for the document. E.g. creation year, author name or text genre." example:"{\n \"author\": \"Immanuel Kant\"\n}\n"` } type Embeddings struct { - TextID string `json:"text_id" doc:"Identifier for the document"` - UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` - ProjectHandle string `json:"project_handle" path:"project_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"Project handle"` - ProjectID int `json:"project_id,omitempty" doc:"Unique project identifier"` - LLMServiceHandle string `json:"llm_service_handle" doc:"Handle of the language model service used to generate the embeddings"` - Text string `json:"text,omitempty" doc:"Text content of the document"` - Vector []float32 `json:"vector" doc:"Half-precision embeddings vector for the document"` - VectorDim int32 `json:"vector_dim" doc:"Dimensionality of the embeddings vector"` - Metadata map[string]interface{} `json:"metadata,omitempty" doc:"Metadata (json) for the document. E.g. creation year, author name or text genre." example:"{\n \"author\": \"Immanuel Kant\"\n}\n"` + TextID string `json:"text_id" doc:"Identifier for the document"` + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` + ProjectHandle string `json:"project_handle" path:"project_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"Project handle"` + ProjectID int `json:"project_id,omitempty" doc:"Unique project identifier"` + InstanceOwner string `json:"instance_owner,omitempty" doc:"Owner of the LLM service instance used to generate the embeddings"` + InstanceHandle string `json:"instance_handle" doc:"Handle of the LLM service instance used to generate the embeddings"` + Text string `json:"text,omitempty" doc:"Text content of the document"` + Vector []float32 `json:"vector" doc:"Half-precision embeddings vector for the document"` + VectorDim int32 `json:"vector_dim" doc:"Dimensionality of the embeddings vector"` + Metadata map[string]interface{} `json:"metadata,omitempty" doc:"Metadata (json) for the document. E.g. creation year, author name or text genre." example:"{\n \"author\": \"Immanuel Kant\"\n}\n"` } type EmbeddingssInput []EmbeddingsInput @@ -126,12 +130,12 @@ type GetDocEmbeddingsResponse struct { // Delete document embeddings // Path: "/v1/embeddings/{user_handle}/{project_handle}/{text_id}" -type DeleteDocEmbeddingsRequest struct { +type DeleteEmbeddingsByDocIDRequest struct { UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` ProjectHandle string `json:"project_handle" path:"project_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"Project handle"` TextID string `json:"text_id" path:"text_id" maxLength:"300" minLength:"3" example:"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0017%3Afrontmatter.1.1%0A" doc:"Document identifier"` } -type DeleteDocEmbeddingsResponse struct { +type DeleteEmbeddingsByDocIDResponse struct { Header []http.Header `json:"header,omitempty" doc:"Response headers"` } diff --git a/internal/models/llm_processes.go b/internal/models/llm_processes.go deleted file mode 100644 index b31458f..0000000 --- a/internal/models/llm_processes.go +++ /dev/null @@ -1,12 +0,0 @@ -package models - -type LLMProcessRequest struct { - ServiceID string `json:"serviceId"` - ProjectID string `json:"projectId"` - ContextID string `json:"contextId"` - TextFields []string `json:"textFields"` -} - -type LLMProcessResponse struct { - TextFields []string `json:"textFields"` -} diff --git a/internal/models/llm_services.go b/internal/models/llm_services.go index e3a2444..7597f9b 100644 --- a/internal/models/llm_services.go +++ b/internal/models/llm_services.go @@ -4,106 +4,376 @@ import ( "net/http" ) -// LLMService is a service for managing LLM data. -type LLMServiceInput struct { - LLMServiceID int `json:"llm_service_id,omitempty" doc:"Unique service identifier" example:"153"` - LLMServiceHandle string `json:"llm_service_handle" minLength:"3" maxLength:"20" example:"GPT-4 API" doc:"Service name"` - Endpoint string `json:"endpoint" example:"https://api.openai.com/v1/embeddings" doc:"Service endpoint"` - Description string `json:"description,omitempty" doc:"Service description"` - APIKey string `json:"api_key,omitempty" example:"12345678901234567890123456789012" doc:"Authentication token for the service"` - APIStandard string `json:"api_standard" default:"openai" example:"openai" doc:"Standard of the API"` - Model string `json:"model" example:"text-embedding-3-large" doc:"Model name"` - Dimensions int32 `json:"dimensions" example:"3072" doc:"Number of dimensions in the embeddings"` +/* + LLM Services are manage via LLM Service Definitions and LLM Service Instances. + + While the Definitions serve as templates and a couple of them are provided by + the "_system" account for all users to use, the Instances provide fully + specified connectionInstance details, including personal or project API keys for + Embedding service providers and can - as soon as the + respective function is implemented - be used to have the VDB forward texts to + the embedding platform. This can be useful either to create the embeddings to + store in the VDB in the first place, or to encode unseen data that + similarities of stored embeddings can then be calculated against. + + Both Definitions and Instances can be shared with other users. API keys are + recorded only for Instances, saved only in an encrypted way and never + displayed in any output of the VDB. (Thus, make sure to keep your own backup + copy in some secure location, don't rely on the VDB to be able to tell you + your API key in case you forget it.) + + With regard to terminology, the following models involve "owner" and "user_handle": + in most cases, both refer to the same entity: the owner of the resource. + We use "user_handle" preferably in the API paths (i.e. in Input structs) + and "owner" in the data model (and output JSON). +*/ + +// === I. LLM Service Definitions === + +// Definition represents a template for LLM service configurations +// Definitions can be owned by _system (global templates) or individual users +type DefinitionFull struct { + DefinitionID int `json:"definition_id,omitempty" readOnly:"true" doc:"Unique LLM Service Definition identifier" example:"42"` + DefinitionHandle string `json:"definition_handle" minLength:"3" maxLength:"20" example:"openai-large" doc:"LLM Service Definition handle"` + Owner string `json:"owner" readOnly:"true" doc:"User handle of the LLM Service Definition owner (_system for global)" example:"_system"` + Endpoint string `json:"endpoint,omitempty" example:"https://api.openai.com/v1/embeddings" doc:"LLM Service endpoint"` + Description string `json:"description,omitempty" doc:"LLM Service description"` + APIStandard string `json:"api_standard" example:"openai" doc:"Standard of the API"` + Model string `json:"model,omitempty" example:"text-embedding-3-large" doc:"Embedding model name"` + Dimensions int32 `json:"dimensions,omitempty" example:"3072" doc:"Number of dimensions in the embeddings"` + ContextLimit int32 `json:"context_limit,omitempty" example:"8192" doc:"Context limit of the LLM service"` + IsPublic bool `json:"is_public,omitempty" default:"false" doc:"Whether the definition is public (shared with all users)"` +} + +type DefinitionBrief struct { + DefinitionID int `json:"definition_id" readOnly:"true" doc:"Unique LLM Service Definition identifier" example:"42"` + DefinitionHandle string `json:"definition_handle" minLength:"3" maxLength:"20" example:"openai-large" doc:"LLM Service Definition handle"` + Owner string `json:"owner" readOnly:"true" doc:"User handle of the LLM Service Definition owner (_system for global)" example:"_system"` + IsPublic bool `json:"is_public" doc:"Whether the definition is public (shared with all users)"` +} + +// Request and Response structs for the LLM Service Instance administration API +// The huma framework requires that: +// - request structs are structs with fields for the request path/query/header/cookie parameters and/or body. +// - response structs are structs with fields for the output headers and body of the operation, if any. + +// Create/update llm-definition +// PUT Path: "/v1/llm-definitions/{user_handle}/{definition_handle}" + +type PutDefinitionRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` + DefinitionHandle string `json:"definition_handle" path:"definition_handle" maxLength:"20" minLength:"3" example:"openai-large" doc:"LLM Service Definition handle"` + Body DefinitionFull `json:"body" doc:"LLM Service Definition to create or update"` +} + +// POST Path: "/v1/llm-definitions/{user_handle}" +type PostDefinitionRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` + Body DefinitionFull `json:"body" doc:"LLM Service Definition to create"` +} + +type UploadDefinitionResponse struct { + Header []http.Header `json:"header,omitempty" doc:"Response headers"` + Body DefinitionBrief `json:"definition" doc:"Information about the created LLM Service Definition"` +} + +// Get single LLM Service Definition +// Path: "/v1/llm-definitions/{user_handle}/{definition_handle}" + +type GetDefinitionRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` + DefinitionHandle string `json:"definition_handle" path:"definition_handle" maxLength:"20" minLength:"3" example:"openai-large" doc:"LLM Service Definition handle"` +} + +type GetDefinitionResponse struct { + Header []http.Header `json:"header,omitempty" doc:"Response headers"` + Body DefinitionFull `json:"definition" doc:"Information about the LLM Service Definition"` +} + +// Get all LLM Service Definitions by user (i.e. owner) +// Path: "/v1/llm-definitions/{user_handle}" + +type GetUserDefinitionsRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` + Limit int `json:"limit,omitempty" query:"limit" minimum:"1" maximum:"200" example:"10" default:"20" doc:"Maximum number of embeddings to return"` + Offset int `json:"offset,omitempty" query:"offset" minimum:"0" example:"0" default:"0" doc:"Offset into the list of embeddings"` +} + +type GetUserDefinitionsResponse struct { + Header []http.Header `json:"header,omitempty" doc:"Response headers"` + Body DefinitionsResponse +} + +type DefinitionsResponse struct { + Definitions []DefinitionBrief `json:"definitions" doc:"List of LLM Service Definitions owned by the user"` +} + +// Delete LLM Service Definition +// Path: "/v1/llm-definitions/{user_handle}/{definition_handle}" + +type DeleteDefinitionRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` + DefinitionHandle string `json:"definition_handle" path:"definition_handle" maxLength:"20" minLength:"3" example:"openai-large" doc:"LLM Service Definition handle"` +} + +type DeleteDefinitionResponse struct { + Header []http.Header `json:"header,omitempty" doc:"Response headers"` +} + +// Share Definition with User +// POST Path: "/v1/llm-definitions/{user_handle}/{definition_handle}/share" + +type ShareDefinitionRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"_system" doc:"Definition owner handle"` + DefinitionHandle string `json:"definition_handle" path:"definition_handle" maxLength:"20" minLength:"3" example:"openai-large" doc:"Definition handle"` + Body struct { + ShareWithHandle string `json:"share_with_handle" minLength:"3" maxLength:"20" example:"bob" doc:"User handle to share with"` + } +} + +type ShareDefinitionResponse struct { + Header []http.Header `json:"header,omitempty" doc:"Response headers"` + Body struct { + Owner string `json:"owner" doc:"Definition owner"` + DefinitionHandle string `json:"definition_handle" doc:"Definition handle"` + SharedWith []string `json:"shared_with" doc:"List of users this definition is shared with"` + } +} + +// Unshare Definition from User +// DELETE Path: "/v1/llm-definitions/{user_handle}/{definition_handle}/share/{unshare_with_handle}" + +type UnshareDefinitionRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"_system" doc:"Definition owner handle"` + DefinitionHandle string `json:"definition_handle" path:"definition_handle" maxLength:"20" minLength:"3" example:"openai-large" doc:"Definition handle"` + UnshareWithHandle string `json:"unshare_with_handle" path:"unshare_with_handle" maxLength:"20" minLength:"3" example:"bob" doc:"User handle to unshare from"` +} + +type UnshareDefinitionResponse struct { + Header []http.Header `json:"header,omitempty" doc:"Response headers"` +} + +// Get users a Definition is shared with (only for Definition owner) +// GET Path: "/v1/llm-definitions/{user_handle}/{definition_handle}/shared-with" + +type GetDefinitionSharedUsersRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"_system" doc:"Definition owner handle"` + DefinitionHandle string `json:"definition_handle" path:"definition_handle" maxLength:"20" minLength:"3" example:"openai-large" doc:"Definition handle"` +} + +type GetDefinitionSharedUsersResponse struct { + Header []http.Header `json:"header,omitempty" doc:"Response headers"` + Body []string `json:"shared_with" doc:"List of users this definition is shared with"` +} + +// === II. LLM Service Instances === + +// Instance represents a user-specific instance of an LLM service +// Instances can be based on a definition or standalone +type Instance struct { + Owner string `json:"owner" readOnly:"true" doc:"User handle of the LLM Service Instance owner"` + InstanceHandle string `json:"instance_handle" minLength:"3" maxLength:"20" example:"my-openai-large" doc:"LLM Service Instance handle"` + InstanceID int `json:"instance_id,omitempty" readOnly:"true" doc:"Unique LLM Service Instance identifier" example:"153"` + DefinitionID *int `json:"definition_id,omitempty" doc:"Reference to LLM Service Definition handle (if based on one)"` + DefinitionOwner string `json:"definition_owner,omitempty" readOnly:"true" doc:"User handle of the LLM Service Definition owner (if based on one)"` + DefinitionHandle string `json:"definition_handle,omitempty" readOnly:"true" doc:"Handle of the LLM Service Definition (if based on one)"` + Endpoint string `json:"endpoint,omitempty" example:"https://api.openai.com/v1/embeddings" doc:"LLM Service endpoint"` + Description string `json:"description,omitempty" doc:"LLM Service description"` + APIKeyEncrypted string `json:"api_key_encrypted,omitempty" writeOnly:"true" doc:"Authentication token (write-only, never returned)"` + HasAPIKey bool `json:"has_api_key,omitempty" readOnly:"true" doc:"Indicates if Instance has an API key configured"` + APIStandard string `json:"api_standard,omitempty" example:"openai" doc:"Standard of the API"` + Model string `json:"model,omitempty" example:"text-embedding-3-large" doc:"Embedding model name"` + Dimensions int32 `json:"dimensions,omitempty" example:"3072" doc:"Number of dimensions in the embeddings"` + ContextLimit int32 `json:"context_limit,omitempty" example:"8192" doc:"Context limit of the LLM service"` + IsPublic bool `json:"is_public,omitempty" default:"false" doc:"Whether the instance is public (shared with all users)"` + SharedWith []SharedUser `json:"shared_with,omitempty" readOnly:"true" doc:"Users this LLM Service Instance is shared with"` + // RateLimits []RateLimit `json:"rate_limits,omitempty" readOnly:"true" doc:"Rate limits configured for this LLM Service Instance"`` // ContextData string `json:"contextData,omitempty" doc:"Context data that can be fed to the LLM service. Available in the request template as contextData variable."` // SystemPrompt string `json:"systemPrompt,omitempty" example:"Return the embeddings for the following text:" doc:"System prompt for requests to the service. Available in the request template as systemPrompt variable."` // RequestTemplate string `json:"requestTemplate,omitempty" doc:"Request template for the service. Can use input, contextData, and systemPrompt variables." example:"{\"input\": \"{{ input }}\", \"model\": \"text-embedding-3-small\"}"` // RespFieldName string `json:"respFieldName,omitempty" default:"embedding" example:"embedding" doc:"Field name of the service response containing the embeddings. Supported is a top-level key of a json object."` } -type LLMService struct { - LLMServiceID int `json:"llm_service_id,omitempty" readOnly:"true" doc:"Unique service identifier" example:"153"` - LLMServiceHandle string `json:"llm_service_handle" minLength:"3" maxLength:"20" example:"GPT-4 API" doc:"Service name"` - Owner string `json:"owner" readOnly:"true" doc:"User handle of the service owner"` - Endpoint string `json:"endpoint" example:"https://api.openai.com/v1/embeddings" doc:"Service endpoint"` - Description string `json:"description,omitempty" doc:"Service description"` - APIKey string `json:"api_key,omitempty" example:"12345678901234567890123456789012" doc:"Authentication token for the service"` - APIStandard string `json:"api_standard" default:"openai" example:"openai" doc:"Standard of the API"` - Model string `json:"model" example:"text-embedding-3-large" doc:"Model name"` - Dimensions int32 `json:"dimensions" example:"3072" doc:"Number of dimensions in the embeddings"` +type InstanceInput struct { + UserHandle string `json:"user_handle,omitempty" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle of LLM Service Instance owner"` + InstanceHandle string `json:"instance_handle" minLength:"3" maxLength:"20" example:"GPT-4 API" doc:"LLM Service Instance handle"` + InstanceID int `json:"instance_id,omitempty" doc:"Unique LLM Service Instance identifier" example:"153"` + DefinitionOwner string `json:"definition_owner,omitempty" readOnly:"true" doc:"User handle of the LLM Service Definition owner (if based on one)"` + DefinitionHandle string `json:"definition_handle,omitempty" readOnly:"true" doc:"Handle of the LLM Service Definition (if based on one)"` + Endpoint string `json:"endpoint,omitempty" example:"https://api.openai.com/v1/embeddings" doc:"LLM Service endpoint"` + Description string `json:"description,omitempty" doc:"LLM Service Instance description"` + APIStandard string `json:"api_standard,omitempty" default:"openai" example:"openai" doc:"Standard of the API"` + Model string `json:"model,omitempty" example:"text-embedding-3-large" doc:"Embedding model name"` + Dimensions int32 `json:"dimensions,omitempty" example:"3072" doc:"Number of dimensions in the embeddings"` + ContextLimit int32 `json:"context_limit,omitempty" example:"8192" doc:"Context limit of the LLM service"` + APIKey string `json:"api_key,omitempty" example:"12345678901234567890123456789012" doc:"Authentication token for the service (will be saved in encrypted form only)"` + // RateLimits []RateLimit `json:"rate_limits,omitempty" readOnly:"true" doc:"Rate limits configured for this LLM Service Instance"`` // ContextData string `json:"contextData,omitempty" doc:"Context data that can be fed to the LLM service. Available in the request template as contextData variable."` // SystemPrompt string `json:"systemPrompt,omitempty" example:"Return the embeddings for the following text:" doc:"System prompt for requests to the service. Available in the request template as systemPrompt variable."` // RequestTemplate string `json:"requestTemplate,omitempty" doc:"Request template for the service. Can use input, contextData, and systemPrompt variables." example:"{\"input\": \"{{ input }}\", \"model\": \"text-embedding-3-small\"}"` // RespFieldName string `json:"respFieldName,omitempty" default:"embedding" example:"embedding" doc:"Field name of the service response containing the embeddings. Supported is a top-level key of a json object."` } -// Request and Response structs for the project administration API -// The request structs must be structs with fields for the request path/query/header/cookie parameters and/or body. -// The response structs must be structs with fields for the output headers and body of the operation, if any. +type InstanceBrief struct { + Owner string `json:"owner" readOnly:"true" doc:"User handle of the LLM Service Instance owner"` + InstanceHandle string `json:"instance_handle" minLength:"3" maxLength:"20" example:"my-openai-large" doc:"LLM Service Instance handle"` + InstanceID int `json:"instance_id" readOnly:"true" doc:"Unique LLM Service Instance identifier" example:"153"` + AccessRole string `json:"access_role,omitempty" readOnly:"true" doc:"Access role of the requesting user for this instance (owner, editor, reader)"` +} + +// In Output, never return the API key +type InstanceFull struct { + Owner string `json:"owner" readOnly:"true" doc:"User handle of the LLM Service Instance owner"` + InstanceHandle string `json:"instance_handle" minLength:"3" maxLength:"20" example:"my-openai-large" doc:"LLM Service Instance handle"` + InstanceID int `json:"instance_id" readOnly:"true" doc:"Unique LLM Service Instance identifier" example:"153"` + AccessRole string `json:"access_role,omitempty" readOnly:"true" doc:"Access role of the requesting user for this instance (owner, editor, reader)"` + DefinitionID int `json:"definition_id,omitempty" doc:"Reference to LLM Service Definition (if based on one)"` + DefinitionOwner string `json:"definition_owner,omitempty" readOnly:"true" doc:"User handle of the LLM Service Definition owner (if based on one)"` + DefinitionHandle string `json:"definition_handle,omitempty" readOnly:"true" doc:"Handle of the LLM Service Definition (if based on one)"` + Endpoint string `json:"endpoint,omitempty" example:"https://api.openai.com/v1/embeddings" doc:"LLM Service endpoint"` + Description string `json:"description,omitempty" doc:"LLM Service Instance description"` + HasAPIKey bool `json:"has_api_key,omitempty" readOnly:"true" doc:"Indicates if the LLM Service Instance has an API key configured"` + APIStandard string `json:"api_standard,omitempty" example:"openai" doc:"Standard of the API"` + Model string `json:"model,omitempty" example:"text-embedding-3-large" doc:"Embedding model name"` + Dimensions int32 `json:"dimensions,omitempty" example:"3072" doc:"Number of dimensions in the embeddings"` + ContextLimit int32 `json:"context_limit,omitempty" example:"8192" doc:"Maximum context length supported by the model"` + SharedWith []SharedUser `json:"shared_with,omitempty" readOnly:"true" doc:"Users this instance is shared with"` // This should only be reported when the request comes from the Instance owner + IsShared bool `json:"is_shared,omitempty" readOnly:"true" doc:"Indicates if this is a shared instance (not owned by requesting user)"` + // RateLimits []RateLimit `json:"rate_limits,omitempty" readOnly:"true" doc:"Rate limits configured for this LLM Service Instance"`` + // ContextData string `json:"contextData,omitempty" doc:"Context data that can be fed to the LLM service. Available in the request template as contextData variable."` + // SystemPrompt string `json:"systemPrompt,omitempty" example:"Return the embeddings for the following text:" doc:"System prompt for requests to the service. Available in the request template as systemPrompt variable."` + // RequestTemplate string `json:"requestTemplate,omitempty" doc:"Request template for the service. Can use input, contextData, and systemPrompt variables." example:"{\"input\": \"{{ input }}\", \"model\": \"text-embedding-3-small\"}"` + // RespFieldName string `json:"respFieldName,omitempty" default:"embedding" example:"embedding" doc:"Field name of the service response containing the embeddings. Supported is a top-level key of a json object."` +} + +// Request and Response structs for the LLM Service Instance administration API +// The huma framework requires that: +// - request structs are structs with fields for the request path/query/header/cookie parameters and/or body. +// - response structs are structs with fields for the output headers and body of the operation, if any. + +// Create/Update LLM Service Instance -// Put/post llm-service -// PUT Path: "/v1/llm-services/{user_handle}/{llm_service_handle}" +// PUT Path: "/v1/llm-instances/{user_handle}/{instance_handle}" -type PutLLMRequest struct { - UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` - LLMServiceHandle string `json:"llm_service_handle" path:"llm_service_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"LLM service handle"` - Body LLMService `json:"llm_service" doc:"LLM service to create or update"` +type PutInstanceRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` + InstanceHandle string `json:"instance_handle" path:"instance_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"LLM Service Instance handle"` + Body InstanceInput `json:"instance" doc:"LLM Service Instance to create or update"` } -// POST Path: "/v1/llm-services/{user_handle}" +// POST Path: "/v1/llm-instances/{user_handle}" -type PostLLMRequest struct { - UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` - Body LLMService `json:"llm_service" doc:"LLM service to create or update"` +type PostInstanceRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` + Body InstanceInput `json:"instance" doc:"LLM Service Instance to create or update"` } -type UploadLLMResponse struct { +// PostInstanceFromDefinitionRequest is for creating an instance based on a definition +// POST Path: "/v1/llm-instances/{user_handle}/from-definition" +type PostInstanceFromDefinitionRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` + Body InstanceInput `json:"instance" doc:"LLM Service Instance to create, based on the specified definition. The instance_handle field is required, other fields will override the values from the definition if provided."` +} + +type UploadInstanceResponse struct { Header []http.Header `json:"header,omitempty" doc:"Response headers"` Body struct { - Owner string `json:"owner" doc:"User handle of the service owner"` - LLMServiceHandle string `json:"llm_service_handle" doc:"Handle of created or updated LLM service"` - LLMServiceID int `json:"llm_service_id" doc:"System identifier of created or updated LLM service"` + Owner string `json:"owner" doc:"User handle of the LLM Service Instance owner"` + InstanceHandle string `json:"instance_handle" doc:"Handle of created or updated LLM Service Instance"` + InstanceID int `json:"instance_id" doc:"System identifier of created or updated LLM Service Instance"` } } -// Get all LLM services by user -// Path: "/v1/llm-services/{user_handle}" +// Get single LLM Service Instance +// Path: "/v1/llm-instances/{user_handle}/{instance_handle}" -type GetUserLLMsRequest struct { +type GetInstanceRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` + InstanceHandle string `json:"instance_handle" path:"instance_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"LLM Service Instance handle"` + Limit int `json:"limit,omitempty" query:"limit" minimum:"1" maximum:"200" example:"10" default:"20" doc:"Maximum number of instances to return"` + Offset int `json:"offset,omitempty" query:"offset" minimum:"0" example:"0" default:"0" doc:"Offset into the list of instances"` +} + +type GetInstanceResponse struct { + Header []http.Header `json:"header,omitempty" doc:"Response headers"` + Body InstanceFull `json:"instance" doc:"LLM Service Instance"` +} + +// Get all LLM Service Instances by user (i.e. owner or shared with) +// Path: "/v1/llm-instances/{user_handle}" + +type GetUserInstancesRequest struct { UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` Limit int `json:"limit,omitempty" query:"limit" minimum:"1" maximum:"200" example:"10" default:"20" doc:"Maximum number of embeddings to return"` Offset int `json:"offset,omitempty" query:"offset" minimum:"0" example:"0" default:"0" doc:"Offset into the list of embeddings"` } -type GetUserLLMsResponse struct { +type GetUserInstancesResponse struct { Header []http.Header `json:"header,omitempty" doc:"Response headers"` Body struct { - LLMServices []LLMService `json:"llm_service" doc:"List of LLM Services"` + Instances []InstanceBrief `json:"instances" doc:"List of LLM Service Instances"` } } -// Get single LLM service -// Path: "/v1/llm-services/{user_handle}/{llm_service_handle}" +// Delete LLM Service Instance +// Path: "/v1/llm-instances/{user_handle}/{instance_handle}" -type GetLLMRequest struct { - UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` - LLMServiceHandle string `json:"llm_service_handle" path:"llm_service_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"LLM service handle"` - Limit int `json:"limit,omitempty" query:"limit" minimum:"1" maximum:"200" example:"10" default:"20" doc:"Maximum number of embeddings to return"` - Offset int `json:"offset,omitempty" query:"offset" minimum:"0" example:"0" default:"0" doc:"Offset into the list of embeddings"` +type DeleteInstanceRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` + InstanceHandle string `json:"instance_handle" path:"instance_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"LLM Service Instance handle"` } -type GetLLMResponse struct { +type DeleteInstanceResponse struct { Header []http.Header `json:"header,omitempty" doc:"Response headers"` - Body LLMService `json:"llm_service" doc:"LLM Service"` } -// Delete LLM service -// Path: "/v1/llm-services/{user_handle}/{llm_service_handle}" +// Share Instance with User +// POST Path: "/v1/llm-instances/{user_handle}/{instance_handle}/share" -type DeleteLLMRequest struct { - UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` - LLMServiceHandle string `json:"llm_service_handle" path:"llm_service_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"LLM service handle"` +type ShareInstanceRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"alice" doc:"Instance owner handle"` + InstanceHandle string `json:"instance_handle" path:"instance_handle" maxLength:"20" minLength:"3" example:"my-openai" doc:"Instance handle"` + Body struct { + ShareWithHandle string `json:"share_with_handle" minLength:"3" maxLength:"20" example:"bob" doc:"User handle to share with"` + Role string `json:"role" enum:"reader,editor" example:"reader" doc:"Role for shared access"` + } +} + +type ShareInstanceResponse struct { + Header []http.Header `json:"header,omitempty" doc:"Response headers"` + Body struct { + Owner string `json:"owner" doc:"Instance owner"` + InstanceHandle string `json:"instance_handle" doc:"Instance handle"` + SharedWith []SharedUser `json:"shared_with" doc:"Users this instance is shared with"` + } +} + +// Unshare Instance from User +// DELETE Path: "/v1/llm-instances/{user_handle}/{instance_handle}/share/{unshare_with_handle}" + +type UnshareInstanceRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"alice" doc:"Instance owner handle"` + InstanceHandle string `json:"instance_handle" path:"instance_handle" maxLength:"20" minLength:"3" example:"my-openai" doc:"Instance handle"` + UnshareWithHandle string `json:"unshare_with_handle" path:"unshare_with_handle" maxLength:"20" minLength:"3" example:"bob" doc:"User handle to unshare from"` +} + +type UnshareInstanceResponse struct { + Header []http.Header `json:"header,omitempty" doc:"Response headers"` +} + +// Get users an Instance is shared with +// GET Path: "/v1/llm-instances/{user_handle}/{instance_handle}/shared-with" + +type GetInstanceSharedUsersRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"alice" doc:"Instance owner handle"` + InstanceHandle string `json:"instance_handle" path:"instance_handle" maxLength:"20" minLength:"3" example:"my-openai" doc:"Instance handle"` } -type DeleteLLMResponse struct { +type GetInstanceSharedUsersResponse struct { Header []http.Header `json:"header,omitempty" doc:"Response headers"` + Body struct { + Owner string `json:"owner" doc:"Instance owner"` + InstanceHandle string `json:"instance_handle" doc:"Instance handle"` + SharedWith []SharedUser `json:"shared_with" doc:"List of users this instance is shared with"` + } } diff --git a/internal/models/projects.go b/internal/models/projects.go index 0e091e4..c0b0c61 100644 --- a/internal/models/projects.go +++ b/internal/models/projects.go @@ -3,23 +3,34 @@ package models import "net/http" // Project is a project that a user is a member of. -type Project struct { - ProjectID int `json:"project_id" readOnly:"true" doc:"Unique project identifier"` - ProjectHandle string `json:"project_handle" minLength:"3" maxLength:"20" example:"my-gpt-4" doc:"Project handle"` - Owner string `json:"owner" readOnly:"true" doc:"User handle of the project owner"` - Description string `json:"description,omitempty" maxLength:"255" doc:"Description of the project."` - MetadataScheme string `json:"metadataScheme,omitempty" doc:"Metadata json scheme used in the project."` - AuthorizedReaders []string `json:"authorizedReaders,omitempty" default:"" example:"[\"jdoe\",\"foobar\"]" doc:"Account names allowed to retrieve information from the project. Defaults to everyone ([\"*\"])"` - LLMServices []LLMService `json:"llmServices,omitempty" doc:"LLM services used in the project"` - NumberOfEmbeddings int `json:"number_of_embeddings" readOnly:"true" doc:"Number of embeddings in the project"` +type ProjectFull struct { + ProjectID int `json:"project_id" readOnly:"true" doc:"Unique project identifier"` + ProjectHandle string `json:"project_handle" minLength:"3" maxLength:"20" example:"my-gpt-4" doc:"Project handle"` + Owner string `json:"owner" readOnly:"true" doc:"User handle of the project owner"` + Description string `json:"description,omitempty" maxLength:"255" doc:"Description of the project."` + MetadataScheme string `json:"metadataScheme,omitempty" doc:"Metadata json scheme used in the project."` + PublicRead bool `json:"public_read" doc:"Whether the project is public or not"` + SharedWith []SharedUser `json:"shared_with,omitempty" default:"" doc:"Account names allowed to retrieve information from the project. Defaults to everyone ([\"*\"])"` + Instance InstanceBrief `json:"instance,omitempty" doc:"LLM Service Instance used in the project"` + Role string `json:"role,omitempty" doc:"Role of the requesting user in the project (can be owner or some other role)"` + NumberOfEmbeddings int `json:"number_of_embeddings" readOnly:"true" doc:"Number of embeddings in the project"` +} + +type ProjectBrief struct { + Owner string `json:"owner" readOnly:"true" doc:"User handle of the project owner"` + ProjectHandle string `json:"project_handle" minLength:"3" maxLength:"20" example:"my-gpt-4" doc:"Project handle"` + ProjectID int `json:"project_id" readOnly:"true" doc:"Unique project identifier"` + PublicRead bool `json:"public_read" doc:"Whether the project is public or not"` + Role string `json:"role,omitempty" doc:"Role of the requesting user in the project (can be owner or some other role)"` } type ProjectSubmission struct { - ProjectHandle string `json:"project_handle" minLength:"3" maxLength:"20" example:"my-gpt-4" doc:"Project handle"` - Description string `json:"description,omitempty" maxLength:"255" doc:"Description of the project."` - MetadataScheme string `json:"metadataScheme,omitempty" doc:"Metadata json scheme used in the project."` - AuthorizedReaders []string `json:"authorizedReaders,omitempty" default:"" example:"[\"jdoe\",\"foobar\"]" doc:"Account names allowed to retrieve information from the project. Defaults to everyone ([\"*\"])"` - LLMServices []LLMService `json:"llmServices,omitempty" doc:"LLM services used in the project"` + ProjectHandle string `json:"project_handle" minLength:"3" maxLength:"20" example:"my-gpt-4" doc:"Project handle"` + Description string `json:"description,omitempty" maxLength:"255" doc:"Description of the project."` + MetadataScheme string `json:"metadataScheme,omitempty" doc:"Metadata json scheme used in the project."` + InstanceOwner string `json:"instance_owner,omitempty" doc:"User handle of the owner of the LLM Service Instance used in the project."` + InstanceHandle string `json:"instance_handle,omitempty" doc:"Handle of the LLM Service Instance used in the project"` + PublicRead bool `json:"public_read,omitempty", default:"false" doc:"Whether the project is public or not"` } // Request and Response structs for the project administration API @@ -32,22 +43,19 @@ type ProjectSubmission struct { type PutProjectRequest struct { UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` ProjectHandle string `json:"project_handle" path:"project_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"Project handle"` - Body Project + Body ProjectSubmission } // POST Path: "/v1/projects/{user}" type PostProjectRequest struct { UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` - Body Project + Body ProjectSubmission } type UploadProjectResponse struct { Header []http.Header `json:"header,omitempty" doc:"Response headers"` - Body struct { - ProjectHandle string `json:"project_handle" doc:"Handle of created or updated project"` - ProjectID int `json:"project_id" doc:"Unique project identifier"` - } + Body ProjectBrief `json:"project" doc:"Information about the created or updated project"` } // Get all projects by user @@ -63,7 +71,7 @@ type GetProjectsResponse struct { Header []http.Header `json:"header,omitempty" doc:"Response headers"` Body struct { // Handles []string `json:"handles" doc:"Handles of all registered projects for specified user"` - Projects []Project `json:"projects" doc:"Projects that the user is a member of"` + Projects []ProjectBrief `json:"projects" doc:"Projects that the user is a member of"` } } @@ -77,7 +85,7 @@ type GetProjectRequest struct { type GetProjectResponse struct { Header []http.Header `json:"header,omitempty" doc:"Response headers"` - Body Project `json:"project" doc:"Project information"` + Body ProjectFull `json:"project" doc:"Project information"` } // Delete project @@ -91,3 +99,54 @@ type DeleteProjectRequest struct { type DeleteProjectResponse struct { Header []http.Header `json:"header,omitempty" doc:"Response headers"` } + +// TODO: Share project (add user to project with reader role and to instance readers if project has an instance assigned) +// - POST /v1/projects/{user_handle}/{project_handle}/share + +type ShareProjectRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"alice" doc:"Project owner handle"` + ProjectHandle string `json:"project_handle" path:"project_handle" maxLength:"20" minLength:"3" example:"my-openai" doc:"Project handle"` + Body struct { + ShareWithHandle string `json:"share_with_handle" minLength:"3" maxLength:"20" example:"bob" doc:"User handle to share with"` + Role string `json:"role" enum:"reader,editor" example:"reader" doc:"Role for shared access"` + } +} + +type ShareProjectResponse struct { + Header []http.Header `json:"header,omitempty" doc:"Response headers"` + Body struct { + Owner string `json:"owner" doc:"Instance owner"` + ProjectHandle string `json:"project_handle" doc:"Project handle"` + SharedWith []SharedUser `json:"shared_with" doc:"Users this project is shared with"` + } +} + +// Unshare Instance from User +// DELETE Path: "/v1/projects/{user_handle}/{project_handle}/share/{unshare_with_handle}" + +type UnshareProjectRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"alice" doc:"Project owner handle"` + ProjectHandle string `json:"instance_handle" path:"instance_handle" maxLength:"20" minLength:"3" example:"my-openai" doc:"Instance handle"` + UnshareWithHandle string `json:"unshare_with_handle" path:"unshare_with_handle" maxLength:"20" minLength:"3" example:"bob" doc:"User handle to unshare from"` +} + +type UnshareProjectResponse struct { + Header []http.Header `json:"header,omitempty" doc:"Response headers"` +} + +// Get users a Project is shared with +// GET Path: "/v1/projects/{user_handle}/{project_handle}/shared-with" + +type GetProjectSharedUsersRequest struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"alice" doc:"Project owner handle"` + ProjectHandle string `json:"project_handle" path:"project_handle" maxLength:"20" minLength:"3" example:"my-openai" doc:"Project handle"` +} + +type GetProjectSharedUsersResponse struct { + Header []http.Header `json:"header,omitempty" doc:"Response headers"` + Body struct { + Owner string `json:"owner" doc:"Project owner"` + ProjectHandle string `json:"instance_handle" doc:"Project handle"` + SharedWith []SharedUser `json:"shared_with" doc:"List of users this project is shared with"` + } +} diff --git a/internal/models/similars.go b/internal/models/similars.go index 6e21e40..b0fa8de 100644 --- a/internal/models/similars.go +++ b/internal/models/similars.go @@ -15,13 +15,13 @@ type GetSimilarRequest struct { } type PostSimilarRequest struct { - UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` - ProjectHandle string `json:"project_handle" path:"project_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"Project handle"` - LLMServiceHandle string `json:"llm_service_handle" path:"llm_service_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"LLM service handle"` - Count int `json:"count" query:"count" minimum:"1" maximum:"200" example:"10" default:"10" doc:"Number of similar documents to return"` - Threshold float64 `json:"threshold" query:"threshold" minimum:"0" maximum:"1" example:"0.5" default:"0.5" doc:"Similarity threshold"` - Limit int `json:"limit,omitempty" query:"limit" minimum:"1" maximum:"200" example:"10" default:"10" doc:"Maximum number of similar documents to return"` - Offset int `json:"offset,omitempty" query:"offset" minimum:"0" example:"0" default:"0" doc:"Offset into the list of similar documents"` + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` + ProjectHandle string `json:"project_handle" path:"project_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"Project handle"` + InstanceHandle string `json:"instance_handle" path:"instance_handle" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"LLM service handle"` + Count int `json:"count" query:"count" minimum:"1" maximum:"200" example:"10" default:"10" doc:"Number of similar documents to return"` + Threshold float64 `json:"threshold" query:"threshold" minimum:"0" maximum:"1" example:"0.5" default:"0.5" doc:"Similarity threshold"` + Limit int `json:"limit,omitempty" query:"limit" minimum:"1" maximum:"200" example:"10" default:"10" doc:"Maximum number of similar documents to return"` + Offset int `json:"offset,omitempty" query:"offset" minimum:"0" example:"0" default:"0" doc:"Offset into the list of similar documents"` } type SimilarResponse struct { diff --git a/internal/models/users.go b/internal/models/users.go index e3c2b61..da71927 100644 --- a/internal/models/users.go +++ b/internal/models/users.go @@ -2,32 +2,42 @@ package models import "net/http" +// TODO: Distinguish Full and Brief Outputs + // User represents a user account. type User struct { - UserHandle string `json:"user_handle" doc:"User handle" maxLength:"20" minLength:"3" example:"jdoe"` - Name string `json:"name,omitempty" doc:"User name" maxLength:"50" example:"Jane Doe"` - Email string `json:"email" doc:"User email" maxLength:"100" minLength:"5" example:"foo@bar.com"` - APIKey string `json:"apiKey,omitempty" readOnly:"true" doc:"User API key for dhamps-vdb API" maxLength:"64" minLength:"64" example:"1234567890123456789012345678901212345678901234567890123456789012"` - Projects ProjectMemberships `json:"projects,omitempty" readOnly:"true" doc:"Projects that the user is a member of"` - LLMServices LLMMemberships `json:"llm_services,omitempty" readOnly:"true" doc:"LLM services that the user is a member of"` -} - -type LLMMembership struct { - LLMServiceHandle string `json:"llm_service_handle" doc:"LLM service"` - LLMServiceOwner string `json:"owner" doc:"Owner of the LLM service"` - Role string `json:"role" doc:"Role of the user in the LLM service"` + UserHandle string `json:"user_handle" doc:"User handle" maxLength:"20" minLength:"3" example:"jdoe"` + Name string `json:"name,omitempty" doc:"User name" maxLength:"50" example:"Jane Doe"` + Email string `json:"email" doc:"User email" maxLength:"100" minLength:"5" example:"foo@bar.com"` + VDBKey string `json:"vdb_key,omitempty" readOnly:"true" doc:"User API key for dhamps-vdb API" maxLength:"64" minLength:"64" example:"1234567890123456789012345678901212345678901234567890123456789012"` + Projects ProjectMemberships `json:"projects,omitempty" readOnly:"true" doc:"Projects that the user is a member of"` + Definitions Definitions `json:"definitions,omitempty" readOnly:"true" doc:"LLM Service Definitions created by the user"` + Instances InstanceMemberships `json:"instances,omitempty" readOnly:"true" doc:"LLM Service Instances that the user is a member of"` } -type LLMMemberships []LLMMembership - type ProjectMembership struct { ProjectHandle string `json:"project_handle" doc:"Project"` ProjectOwner string `json:"owner" doc:"Owner of the project"` Role string `json:"role" doc:"Role of the user in the project"` } +type SharedUser struct { + UserHandle string `json:"user_handle" path:"user_handle" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` + Role string `json:"role" path:"role" enum:"owner,editor,reader" example:"reader" doc:"Role of the user in the project"` +} + type ProjectMemberships []ProjectMembership +type Definitions []DefinitionFull + +type InstanceMembership struct { + InstanceHandle string `json:"instance_handle" doc:"LLM service instance"` + InstanceOwner string `json:"owner" doc:"Owner of the LLM service instance"` + Role string `json:"role" doc:"Role of the user in the LLM service instance"` +} + +type InstanceMemberships []InstanceMembership + // Request and Response structs for the user administration API // The request structs must be structs with fields for the request path/query/header/cookie parameters and/or body. // The response structs must be structs with fields for the output headers and body of the operation, if any. @@ -48,12 +58,12 @@ type PostUserRequest struct { type UploadUserResponse struct { Header []http.Header `json:"header,omitempty" doc:"Response headers"` - Body HandleAPIStruct + Body UserResponse } -type HandleAPIStruct struct { +type UserResponse struct { UserHandle string `json:"user_handle" doc:"Handle of created or updated user"` - APIKey string `json:"api_key" doc:"API key for the user"` + VDBKey string `json:"vdb_key" doc:"VDB API key for the user"` } // Get all users diff --git a/main.go b/main.go index 71210ba..3d14675 100644 --- a/main.go +++ b/main.go @@ -104,9 +104,9 @@ func main() { api := humago.New(router, config) api.UseMiddleware(auth.CORSMiddleware(api)) - api.UseMiddleware(auth.APIKeyAdminAuth(api, options)) - api.UseMiddleware(auth.APIKeyOwnerAuth(api, pool, options)) - api.UseMiddleware(auth.APIKeyReaderAuth(api, pool, options)) + api.UseMiddleware(auth.VDBKeyAdminAuth(api, options)) + api.UseMiddleware(auth.VDBKeyOwnerAuth(api, pool, options)) + api.UseMiddleware(auth.VDBKeyReaderAuth(api, pool, options)) api.UseMiddleware(auth.AuthTermination(api)) // Add routes to the API diff --git a/repopack-output.xml b/repopack-output.xml index 354139b..64f5819 100644 --- a/repopack-output.xml +++ b/repopack-output.xml @@ -444,7 +444,7 @@ components: description: type: string maxLength: 255 - authorizedReaders: + shared_with: type: array items: type: string @@ -452,7 +452,7 @@ components: maxLength: 20 uniqueItems: true default: ["*"] - llmservices: + instances: type: array items: type: string @@ -1124,7 +1124,7 @@ CREATE TABLE IF NOT EXISTS users( "handle" VARCHAR(20) PRIMARY KEY, "name" TEXT, "email" TEXT UNIQUE NOT NULL, - "vdb_api_key" CHAR(32) UNIQUE NOT NULL, + "vdb_key" CHAR(32) UNIQUE NOT NULL, "created_at" TIMESTAMP NOT NULL, "updated_at" TIMESTAMP NOT NULL ); @@ -1183,7 +1183,7 @@ CREATE TABLE IF NOT EXISTS api_standards( "updated_at" TIMESTAMP NOT NULL ); -CREATE TABLE IF NOT EXISTS llmservices( +CREATE TABLE IF NOT EXISTS instances( "llmservice_id" SERIAL PRIMARY KEY, "handle" VARCHAR(20) NOT NULL, "owner" VARCHAR(20) NOT NULL REFERENCES "users"("handle") ON DELETE CASCADE, @@ -1196,13 +1196,13 @@ CREATE TABLE IF NOT EXISTS llmservices( UNIQUE ("handle", "owner") ); -CREATE INDEX IF NOT EXISTS llmservices_handle ON "llmservices"("handle"); +CREATE INDEX IF NOT EXISTS llmservices_handle ON "instances"("handle"); -- This creates the users_llmservices associations table. CREATE TABLE IF NOT EXISTS users_llmservices( "user" VARCHAR(20) NOT NULL REFERENCES "users"("handle") ON DELETE CASCADE, - "llmservice" SERIAL NOT NULL REFERENCES "llmservices"("llmservice_id") ON DELETE CASCADE, + "llmservice" SERIAL NOT NULL REFERENCES "instances"("llmservice_id") ON DELETE CASCADE, "role" VARCHAR(20) NOT NULL REFERENCES "vdb_roles"("vdb_role"), "created_at" TIMESTAMP NOT NULL, "updated_at" TIMESTAMP NOT NULL, @@ -1213,7 +1213,7 @@ CREATE TABLE IF NOT EXISTS users_llmservices( CREATE TABLE IF NOT EXISTS projects_llmservices( "project" SERIAL NOT NULL REFERENCES "projects"("project_id") ON DELETE CASCADE, - "llmservice" SERIAL NOT NULL REFERENCES "llmservices"("llmservice_id") ON DELETE CASCADE, + "llmservice" SERIAL NOT NULL REFERENCES "instances"("llmservice_id") ON DELETE CASCADE, "created_at" TIMESTAMP NOT NULL, "updated_at" TIMESTAMP NOT NULL, PRIMARY KEY ("project", "llmservice") @@ -1228,7 +1228,7 @@ CREATE TABLE IF NOT EXISTS embeddings( "text_id" TEXT, "embedding" halfvec NOT NULL, "embedding_dim" INTEGER NOT NULL, - "llmservice" SERIAL NOT NULL REFERENCES "llmservices"("llmservice_id"), + "llmservice" SERIAL NOT NULL REFERENCES "instances"("llmservice_id"), "text" TEXT, -- TODO: add metadata handling -- "metadata" jsonb, @@ -1262,7 +1262,7 @@ DROP TABLE IF EXISTS vdb_roles; -- This removes the LLM Services table. -DROP TABLE IF EXISTS llmservices; +DROP TABLE IF EXISTS instances; DROP INDEX IF EXISTS llmservices_handle; @@ -1386,14 +1386,14 @@ SET hnsw.ef_search = 40; -- name: UpsertUser :one INSERT INTO users ( - "handle", "name", "email", "vdb_api_key", "created_at", "updated_at" + "handle", "name", "email", "vdb_key", "created_at", "updated_at" ) VALUES ( $1, $2, $3, $4, NOW(), NOW() ) ON CONFLICT ("handle") DO UPDATE SET "name" = $2, "email" = $3, - "vdb_api_key" = $4, + "vdb_key" = $4, "updated_at" = NOW() RETURNING *; @@ -1463,7 +1463,7 @@ RETURNING *; -- name: UpsertLLM :one -INSERT INTO llmservices ( +INSERT INTO instances ( "handle", "owner", "description", "endpoint", "api_key", "api_standard", "created_at", "updated_at" ) VALUES ( $1, $2, $3, $4, $5, $6, NOW(), NOW() @@ -1477,12 +1477,12 @@ ON CONFLICT ("handle", "owner") DO UPDATE SET RETURNING "llmservice_id", "handle", "owner"; -- name: DeleteLLM :exec -DELETE FROM llmservices +DELETE FROM instances WHERE "owner" = $1 AND "handle" = $2; -- name: RetrieveLLM :one -SELECT * FROM llmservices +SELECT * FROM instances WHERE "owner" = $1 AND "handle" = $2 LIMIT 1; @@ -1508,25 +1508,25 @@ ON CONFLICT ("project", "llmservice") DO NOTHING RETURNING *; -- name: GetLLMsByProject :many -SELECT llmservices.* FROM llmservices +SELECT instances.* FROM instances JOIN ( projects_llmservices JOIN projects ON projects_llmservices."project" = projects."project_id" ) -ON llmservices."llmservice_id" = projects_llmservices."llmservice" +ON instances."llmservice_id" = projects_llmservices."llmservice" WHERE projects."owner" = $1 AND projects."handle" = $2 -ORDER BY llmservices."handle" ASC LIMIT $3 OFFSET $4; +ORDER BY instances."handle" ASC LIMIT $3 OFFSET $4; -- name: GetLLMsByUser :many -SELECT llmservices.* FROM llmservices +SELECT instances.* FROM instances JOIN ( projects_llmservices JOIN users_projects ON projects_llmservices."project" = users_projects."project_id" ) -ON llmservices."llmservice_id" = projects_llmservices."llmservice" +ON instances."llmservice_id" = projects_llmservices."llmservice" WHERE users_projects."user_handle" = $1 -ORDER BY llmservices."handle" ASC LIMIT $2 OFFSET $3; +ORDER BY instances."handle" ASC LIMIT $2 OFFSET $3; -- TODO: Add metadata field @@ -1556,17 +1556,17 @@ DELETE FROM embeddings WHERE "owner" = $1 AND "project" = $2; --- name: DeleteDocEmbeddings :exec +-- name: DeleteEmbeddingsByDocID :exec DELETE FROM embeddings WHERE "owner" = $1 AND "project" = $2 AND "text_id" = $3; -- name: RetrieveEmbeddings :one -SELECT embeddings.*, projects."handle" AS "project", llmservices."handle" +SELECT embeddings.*, projects."handle" AS "project", instances."handle" FROM embeddings -JOIN llmservices - ON embeddings."llmservice" = llmservices."llmservice_id" +JOIN instances + ON embeddings."llmservice" = instances."llmservice_id" JOIN projects ON projects."project_id" = embeddings."project" WHERE embeddings."owner" = $1 @@ -1575,10 +1575,10 @@ WHERE embeddings."owner" = $1 LIMIT 1; -- name: GetEmbeddingsByProject :many -SELECT embeddings.*, projects."handle" AS "project", llmservices."handle" AS "llmservice" +SELECT embeddings.*, projects."handle" AS "project", instances."handle" AS "llmservice" FROM embeddings -JOIN llmservices - ON llmservices."llmservice_id" = embeddings."llmservice" +JOIN instances + ON instances."llmservice_id" = embeddings."llmservice" JOIN projects ON projects."project_id" = embeddings."project" WHERE embeddings."owner" = $1 @@ -1615,9 +1615,9 @@ ORDER BY "handle" ASC LIMIT $1 OFFSET $2; -- name: GetSimilarsByVector :many -SELECT embeddings."id", embeddings."text_id", llmservices."owner", llmservices."handle" -FROM embeddings JOIN llmservices -ON embeddings."llmservice" = llmservices."llmservice_id" +SELECT embeddings."id", embeddings."text_id", instances."owner", instances."handle" +FROM embeddings JOIN instances +ON embeddings."llmservice" = instances."llmservice_id" ORDER BY "embedding" <=> $1 LIMIT $2 OFFSET $3; @@ -1949,7 +1949,7 @@ type User struct { Handle string `db:"handle" json:"handle"` Name pgtype.Text `db:"name" json:"name"` Email string `db:"email" json:"email"` - VdbApiKey string `db:"vdb_api_key" json:"vdb_api_key"` + VDBKey string `db:"vdb_key" json:"vdb_key"` CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` UpdatedAt pgtype.Timestamp `db:"updated_at" json:"updated_at"` } @@ -2000,20 +2000,20 @@ func (q *Queries) DeleteAPI(ctx context.Context, handle string) error { return err } -const deleteDocEmbeddings = `-- name: DeleteDocEmbeddings :exec +const deleteDocEmbeddings = `-- name: DeleteEmbeddingsByDocID :exec DELETE FROM embeddings WHERE "owner" = $1 AND "project" = $2 AND "text_id" = $3 ` -type DeleteDocEmbeddingsParams struct { +type DeleteEmbeddingsByDocIDParams struct { Owner string `db:"owner" json:"owner"` Project int32 `db:"project" json:"project"` TextID pgtype.Text `db:"text_id" json:"text_id"` } -func (q *Queries) DeleteDocEmbeddings(ctx context.Context, arg DeleteDocEmbeddingsParams) error { +func (q *Queries) DeleteEmbeddingsByDocID(ctx context.Context, arg DeleteEmbeddingsByDocIDParams) error { _, err := q.db.Exec(ctx, deleteDocEmbeddings, arg.Owner, arg.Project, arg.TextID) return err } @@ -2045,7 +2045,7 @@ func (q *Queries) DeleteEmbeddingsByProject(ctx context.Context, arg DeleteEmbed } const deleteLLM = `-- name: DeleteLLM :exec -DELETE FROM llmservices +DELETE FROM instances WHERE "owner" = $1 AND "handle" = $2 ` @@ -2125,10 +2125,10 @@ func (q *Queries) GetAPIs(ctx context.Context, arg GetAPIsParams) ([]ApiStandard } const getEmbeddingsByProject = `-- name: GetEmbeddingsByProject :many -SELECT embeddings.id, embeddings.owner, embeddings.project, embeddings.text_id, embeddings.embedding, embeddings.embedding_dim, embeddings.llmservice, embeddings.text, embeddings.created_at, embeddings.updated_at, projects."handle" AS "project", llmservices."handle" AS "llmservice" +SELECT embeddings.id, embeddings.owner, embeddings.project, embeddings.text_id, embeddings.embedding, embeddings.embedding_dim, embeddings.llmservice, embeddings.text, embeddings.created_at, embeddings.updated_at, projects."handle" AS "project", instances."handle" AS "llmservice" FROM embeddings -JOIN llmservices - ON llmservices."llmservice_id" = embeddings."llmservice" +JOIN instances + ON instances."llmservice_id" = embeddings."llmservice" JOIN projects ON projects."project_id" = embeddings."project" WHERE embeddings."owner" = $1 @@ -2197,15 +2197,15 @@ func (q *Queries) GetEmbeddingsByProject(ctx context.Context, arg GetEmbeddingsB } const getLLMsByProject = `-- name: GetLLMsByProject :many -SELECT llmservices.llmservice_id, llmservices.handle, llmservices.owner, llmservices.description, llmservices.endpoint, llmservices.api_key, llmservices.api_standard, llmservices.created_at, llmservices.updated_at FROM llmservices +SELECT instances.llmservice_id, instances.handle, instances.owner, instances.description, instances.endpoint, instances.api_key, instances.api_standard, instances.created_at, instances.updated_at FROM instances JOIN ( projects_llmservices JOIN projects ON projects_llmservices."project" = projects."project_id" ) -ON llmservices."llmservice_id" = projects_llmservices."llmservice" +ON instances."llmservice_id" = projects_llmservices."llmservice" WHERE projects."owner" = $1 AND projects."handle" = $2 -ORDER BY llmservices."handle" ASC LIMIT $3 OFFSET $4 +ORDER BY instances."handle" ASC LIMIT $3 OFFSET $4 ` type GetLLMsByProjectParams struct { @@ -2251,14 +2251,14 @@ func (q *Queries) GetLLMsByProject(ctx context.Context, arg GetLLMsByProjectPara } const getLLMsByUser = `-- name: GetLLMsByUser :many -SELECT llmservices.llmservice_id, llmservices.handle, llmservices.owner, llmservices.description, llmservices.endpoint, llmservices.api_key, llmservices.api_standard, llmservices.created_at, llmservices.updated_at FROM llmservices +SELECT instances.llmservice_id, instances.handle, instances.owner, instances.description, instances.endpoint, instances.api_key, instances.api_standard, instances.created_at, instances.updated_at FROM instances JOIN ( projects_llmservices JOIN users_projects ON projects_llmservices."project" = users_projects."project_id" ) -ON llmservices."llmservice_id" = projects_llmservices."llmservice" +ON instances."llmservice_id" = projects_llmservices."llmservice" WHERE users_projects."user_handle" = $1 -ORDER BY llmservices."handle" ASC LIMIT $2 OFFSET $3 +ORDER BY instances."handle" ASC LIMIT $2 OFFSET $3 ` type GetLLMsByUserParams struct { @@ -2394,9 +2394,9 @@ func (q *Queries) GetSimilarsByID(ctx context.Context, arg GetSimilarsByIDParams } const getSimilarsByVector = `-- name: GetSimilarsByVector :many -SELECT embeddings."id", embeddings."text_id", llmservices."owner", llmservices."handle" -FROM embeddings JOIN llmservices -ON embeddings."llmservice" = llmservices."llmservice_id" +SELECT embeddings."id", embeddings."text_id", instances."owner", instances."handle" +FROM embeddings JOIN instances +ON embeddings."llmservice" = instances."llmservice_id" ORDER BY "embedding" <=> $1 LIMIT $2 OFFSET $3 ` @@ -2609,10 +2609,10 @@ func (q *Queries) RetrieveAPI(ctx context.Context, handle string) (ApiStandard, } const retrieveEmbeddings = `-- name: RetrieveEmbeddings :one -SELECT embeddings.id, embeddings.owner, embeddings.project, embeddings.text_id, embeddings.embedding, embeddings.embedding_dim, embeddings.llmservice, embeddings.text, embeddings.created_at, embeddings.updated_at, projects."handle" AS "project", llmservices."handle" +SELECT embeddings.id, embeddings.owner, embeddings.project, embeddings.text_id, embeddings.embedding, embeddings.embedding_dim, embeddings.llmservice, embeddings.text, embeddings.created_at, embeddings.updated_at, projects."handle" AS "project", instances."handle" FROM embeddings -JOIN llmservices - ON embeddings."llmservice" = llmservices."llmservice_id" +JOIN instances + ON embeddings."llmservice" = instances."llmservice_id" JOIN projects ON projects."project_id" = embeddings."project" WHERE embeddings."owner" = $1 @@ -2663,7 +2663,7 @@ func (q *Queries) RetrieveEmbeddings(ctx context.Context, arg RetrieveEmbeddings } const retrieveLLM = `-- name: RetrieveLLM :one -SELECT llmservice_id, handle, owner, description, endpoint, api_key, api_standard, created_at, updated_at FROM llmservices +SELECT llmservice_id, handle, owner, description, endpoint, api_key, api_standard, created_at, updated_at FROM instances WHERE "owner" = $1 AND "handle" = $2 LIMIT 1 @@ -2719,7 +2719,7 @@ func (q *Queries) RetrieveProject(ctx context.Context, arg RetrieveProjectParams } const retrieveUser = `-- name: RetrieveUser :one -SELECT handle, name, email, vdb_api_key, created_at, updated_at FROM users +SELECT handle, name, email, vdb_key, created_at, updated_at FROM users WHERE "handle" = $1 LIMIT 1 ` @@ -2730,7 +2730,7 @@ func (q *Queries) RetrieveUser(ctx context.Context, handle string) (User, error) &i.Handle, &i.Name, &i.Email, - &i.VdbApiKey, + &i.VDBKey, &i.CreatedAt, &i.UpdatedAt, ) @@ -2830,7 +2830,7 @@ const upsertLLM = `-- name: UpsertLLM :one -INSERT INTO llmservices ( +INSERT INTO instances ( "handle", "owner", "description", "endpoint", "api_key", "api_standard", "created_at", "updated_at" ) VALUES ( $1, $2, $3, $4, $5, $6, NOW(), NOW() @@ -2915,23 +2915,23 @@ func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) (U const upsertUser = `-- name: UpsertUser :one INSERT INTO users ( - "handle", "name", "email", "vdb_api_key", "created_at", "updated_at" + "handle", "name", "email", "vdb_key", "created_at", "updated_at" ) VALUES ( $1, $2, $3, $4, NOW(), NOW() ) ON CONFLICT ("handle") DO UPDATE SET "name" = $2, "email" = $3, - "vdb_api_key" = $4, + "vdb_key" = $4, "updated_at" = NOW() -RETURNING handle, name, email, vdb_api_key, created_at, updated_at +RETURNING handle, name, email, vdb_key, created_at, updated_at ` type UpsertUserParams struct { Handle string `db:"handle" json:"handle"` Name pgtype.Text `db:"name" json:"name"` Email string `db:"email" json:"email"` - VdbApiKey string `db:"vdb_api_key" json:"vdb_api_key"` + VDBKey string `db:"vdb_key" json:"vdb_key"` } // Generate go code with: sqlc generate @@ -2940,14 +2940,14 @@ func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, e arg.Handle, arg.Name, arg.Email, - arg.VdbApiKey, + arg.VDBKey, ) var i User err := row.Scan( &i.Handle, &i.Name, &i.Email, - &i.VdbApiKey, + &i.VDBKey, &i.CreatedAt, &i.UpdatedAt, ) @@ -3348,7 +3348,7 @@ func getDocEmbeddingsFunc(ctx context.Context, input *models.GetDocEmbeddingsReq return response, nil } -func deleteDocEmbeddingsFunc(ctx context.Context, input *models.DeleteDocEmbeddingsRequest) (*models.DeleteDocEmbeddingsResponse, error) { +func deleteDocEmbeddingsFunc(ctx context.Context, input *models.DeleteEmbeddingsByDocIDRequest) (*models.DeleteEmbeddingsByDocIDResponse, error) { // Check if user and project exist u, p, err := getUserProj(ctx, input.User, input.Project) if err != nil { @@ -3366,7 +3366,7 @@ func deleteDocEmbeddingsFunc(ctx context.Context, input *models.DeleteDocEmbeddi } // Build query parameters (embeddings) - params := database.DeleteDocEmbeddingsParams{ + params := database.DeleteEmbeddingsByDocIDParams{ Owner: u, Project: p, TextID: pgtype.Text{String: input.ID, Valid: true}, @@ -3374,13 +3374,13 @@ func deleteDocEmbeddingsFunc(ctx context.Context, input *models.DeleteDocEmbeddi // Run the query queries := database.New(pool) - err = queries.DeleteDocEmbeddings(ctx, params) + err = queries.DeleteEmbeddingsByDocID(ctx, params) if err != nil { return nil, huma.Error500InternalServerError("unable to delete embeddings") } // Build the response - response := &models.DeleteDocEmbeddingsResponse{} + response := &models.DeleteEmbeddingsByDocIDResponse{} response.Body = fmt.Sprintf("Successfully deleted embeddings for document %s (%s's project %s)", input.ID, input.User, input.Project) return response, nil } @@ -3695,7 +3695,7 @@ func createUser(t *testing.T, userJSON string) (string, error) { // get API key for user alice from response body body, err := io.ReadAll(resp.Body) assert.NoError(t, err) - userInfo := models.HandleAPIStruct{} + userInfo := models.UserResponse{} err = json.Unmarshal(body, &userInfo) assert.NoError(t, err) return userInfo.APIKey, nil @@ -4160,37 +4160,37 @@ func RegisterLLMServiceRoutes(pool *pgxpool.Pool, api huma.API) error { postLLMServiceOp := huma.Operation{ OperationID: "postLLMService", Method: http.MethodPost, - Path: "/llmservices/{user}", + Path: "/instances/{user}", Summary: "Create llm service", - Tags: []string{"llmservices"}, + Tags: []string{"instances"}, } putLLMServiceOp := huma.Operation{ OperationID: "putLLMService", Method: http.MethodPut, - Path: "/llmservices/{user}/{handle}", + Path: "/instances/{user}/{handle}", Summary: "Create or update llm service", - Tags: []string{"llmservices"}, + Tags: []string{"instances"}, } getUserLLMServicesOp := huma.Operation{ OperationID: "getUserLLMServices", Method: http.MethodGet, - Path: "/llmservices/{user}", + Path: "/instances/{user}", Summary: "Get all llm services for a user", - Tags: []string{"llmservices"}, + Tags: []string{"instances"}, } getLLMServiceOp := huma.Operation{ OperationID: "getLLMService", Method: http.MethodGet, - Path: "/llmservices/{user}/{handle}", + Path: "/instances/{user}/{handle}", Summary: "Get a specific llm service for a user", - Tags: []string{"llmservices"}, + Tags: []string{"instances"}, } deleteLLMServiceOp := huma.Operation{ OperationID: "deleteLLMService", Method: http.MethodDelete, - Path: "/llmservices/{user}/{handle}", + Path: "/instances/{user}/{handle}", Summary: "Delete all embeddings for a user", - Tags: []string{"llmservices"}, + Tags: []string{"instances"}, } huma.Register(api, postLLMServiceOp, addPoolToContext(pool, postLLMFunc)) @@ -4307,7 +4307,7 @@ func TestProjectFunc(t *testing.T) { requestPath: "/projects/alice/test1", bodyPath: "", apiKeyHeader: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetProjectResponseBody.json\",\n \"project\": {\n \"project_id\": 0,\n \"handle\": \"test1\",\n \"description\": \"This is a test project\",\n \"authorizedReaders\": [\n \"alice\"\n ]\n }\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetProjectResponseBody.json\",\n \"project\": {\n \"project_id\": 0,\n \"handle\": \"test1\",\n \"description\": \"This is a test project\",\n \"shared_with\": [\n \"alice\"\n ]\n }\n}\n", expectStatus: 200, }, { @@ -4316,7 +4316,7 @@ func TestProjectFunc(t *testing.T) { requestPath: "/projects/alice", bodyPath: "", apiKeyHeader: aliceAPIKey, - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetProjectsResponseBody.json\",\n \"projects\": [\n {\n \"project_id\": 2,\n \"handle\": \"test1\",\n \"description\": \"This is a test project\",\n \"authorizedReaders\": [\n \"alice\"\n ]\n }\n ]\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/GetProjectsResponseBody.json\",\n \"projects\": [\n {\n \"project_id\": 2,\n \"handle\": \"test1\",\n \"description\": \"This is a test project\",\n \"shared_with\": [\n \"alice\"\n ]\n }\n ]\n}\n", expectStatus: 200, }, { @@ -4508,7 +4508,7 @@ func putProjectFunc(ctx context.Context, input *models.PutProjectRequest) (*mode // Build query parameters (project) readers := make(map[string]bool) - for _, user := range input.Body.AuthorizedReaders { + for _, user := range input.Body.SharedWith { if user == "*" { users, err := getUsersFunc(ctx, &models.GetUsersRequest{}) if err != nil { @@ -4614,7 +4614,7 @@ func getProjectsFunc(ctx context.Context, input *models.GetProjectsRequest) (*mo Id: int(project.ProjectID), Handle: project.Handle, Description: project.Description.String, - AuthorizedReaders: readers, + SharedWith: readers, LLMServices: nil, }) } @@ -4674,7 +4674,7 @@ func getProjectFunc(ctx context.Context, input *models.GetProjectRequest) (*mode Handle: p.Handle, Description: p.Description.String, MetadataScheme: p.MetadataScheme.String, - AuthorizedReaders: readers, + SharedWith: readers, LLMServices: nil, } @@ -4898,7 +4898,7 @@ func TestUserFunc(t *testing.T) { method: http.MethodPut, requestPath: "/users/alice", bodyPath: "../../testdata/valid_user.json", - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/HandleAPIStruct.json\",\n \"handle\": \"alice\",\n \"apiKey\": \"12345678901234567890123456789012\"\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/UserResponse.json\",\n \"handle\": \"alice\",\n \"apiKey\": \"12345678901234567890123456789012\"\n}\n", expectStatus: 201, }, { @@ -4922,7 +4922,7 @@ func TestUserFunc(t *testing.T) { method: http.MethodPost, requestPath: "/users", bodyPath: "../../testdata/valid_user.json", - expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/HandleAPIStruct.json\",\n \"handle\": \"alice\",\n \"apiKey\": \"12345678901234567890123456789012\"\n}\n", + expectBody: "{\n \"$schema\": \"http://localhost:8080/schemas/UserResponse.json\",\n \"handle\": \"alice\",\n \"apiKey\": \"12345678901234567890123456789012\"\n}\n", expectStatus: 201, }, { @@ -5119,7 +5119,7 @@ func putUserFunc(ctx context.Context, input *models.PutUserRequest) (*models.Upl api_key := "" if u.Handle == input.Handle { // User exists, so don't create API key - api_key = u.VdbApiKey + api_key = u.VDBKey } else { // User does not exist, so create a new API key k, err := keyGen.RandomKey(64) @@ -5132,7 +5132,7 @@ func putUserFunc(ctx context.Context, input *models.PutUserRequest) (*models.Upl Handle: input.Handle, Name: pgtype.Text{String: input.Body.Name, Valid: true}, Email: input.Body.Email, - VdbApiKey: api_key, + VDBKey: api_key, } // Run the query @@ -5144,7 +5144,7 @@ func putUserFunc(ctx context.Context, input *models.PutUserRequest) (*models.Upl // Build the response response := &models.UploadUserResponse{} response.Body.Handle = u.Handle - response.Body.APIKey = u.VdbApiKey + response.Body.APIKey = u.VDBKey return response, nil } @@ -5207,7 +5207,7 @@ func getUserFunc(ctx context.Context, input *models.GetUserRequest) (*models.Get Handle: u.Handle, Name: u.Name.String, Email: u.Email, - APIKey: u.VdbApiKey, + APIKey: u.VDBKey, } response := &models.GetUserResponse{} response.Body = *returnUser @@ -5425,13 +5425,13 @@ type GetDocEmbeddingsResponse struct { // Delete document embeddings // Path: "/embeddings/{user}/{project}/{id}" -type DeleteDocEmbeddingsRequest struct { +type DeleteEmbeddingsByDocIDRequest struct { User string `json:"user" path:"user" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` Project string `json:"project" path:"project" maxLength:"20" minLength:"3" example:"my-gpt-4" doc:"Project handle"` ID string `json:"id" path:"id" maxLength:"200" minLength:"3" example:"https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0017%3Afrontmatter.1.1%0A" doc:"Document identifier"` } -type DeleteDocEmbeddingsResponse struct { +type DeleteEmbeddingsByDocIDResponse struct { Header []http.Header `json:"header,omitempty" doc:"Response headers"` Body string `json:"body" doc:"Success message"` } @@ -5477,7 +5477,7 @@ type LLMService struct { // The response structs must be structs with fields for the output headers and body of the operation, if any. // Put/post project -// PUT Path: "/llmservices/{user}/{handle}" +// PUT Path: "/instances/{user}/{handle}" type PutLLMRequest struct { User string `json:"user" path:"user" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` @@ -5487,7 +5487,7 @@ type PutLLMRequest struct { } } -// POST Path: "/llmservices/{user}" +// POST Path: "/instances/{user}" type PostLLMRequest struct { User string `json:"user" path:"user" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` @@ -5504,7 +5504,7 @@ type UploadLLMResponse struct { } // Get all LLM services by user -// Path: "/llmservices/{user}" +// Path: "/instances/{user}" type GetUserLLMsRequest struct { User string `json:"user" path:"user" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` @@ -5520,7 +5520,7 @@ type GetUserLLMsResponse struct { } // Get single LLM service -// Path: "/llmservices/{user}/{handle}" +// Path: "/instances/{user}/{handle}" type GetLLMRequest struct { User string `json:"user" path:"user" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` @@ -5537,7 +5537,7 @@ type GetLLMResponse struct { } // Delete LLM service -// Path: "/llmservices/{user}/{handle}" +// Path: "/instances/{user}/{handle}" type DeleteLLMRequest struct { User string `json:"user" path:"user" maxLength:"20" minLength:"3" example:"jdoe" doc:"User handle"` @@ -5580,7 +5580,7 @@ type Project struct { Handle string `json:"handle" minLength:"3" maxLength:"20" example:"my-gpt-4" doc:"Project handle"` Description string `json:"description,omitempty" maxLength:"255" doc:"Description of the project."` MetadataScheme string `json:"metadataScheme,omitempty" doc:"Metadata json scheme used in the project."` - AuthorizedReaders []string `json:"authorizedReaders,omitempty" default:"" example:"[\"jdoe\",\"foobar\"]" doc:"Account names allowed to retrieve information from the project. Defaults to everyone ([\"*\"])"` + SharedWith []string `json:"shared_with,omitempty" default:"" example:"[\"jdoe\",\"foobar\"]" doc:"Account names allowed to retrieve information from the project. Defaults to everyone ([\"*\"])"` LLMServices []LLMService `json:"llmServices,omitempty" doc:"LLM services used in the project"` } @@ -5588,7 +5588,7 @@ type ProjectSubmission struct { Handle string `json:"handle" minLength:"3" maxLength:"20" example:"my-gpt-4" doc:"Project handle"` Description string `json:"description,omitempty" maxLength:"255" doc:"Description of the project."` MetadataScheme string `json:"metadataScheme,omitempty" doc:"Metadata json scheme used in the project."` - AuthorizedReaders []string `json:"authorizedReaders,omitempty" default:"" example:"[\"jdoe\",\"foobar\"]" doc:"Account names allowed to retrieve information from the project. Defaults to everyone ([\"*\"])"` + SharedWith []string `json:"shared_with,omitempty" default:"" example:"[\"jdoe\",\"foobar\"]" doc:"Account names allowed to retrieve information from the project. Defaults to everyone ([\"*\"])"` LLMServices []LLMService `json:"llmServices,omitempty" doc:"LLM services used in the project"` } @@ -5734,10 +5734,10 @@ type PostUserRequest struct { type UploadUserResponse struct { Header []http.Header `json:"header,omitempty" doc:"Response headers"` - Body HandleAPIStruct + Body UserResponse } -type HandleAPIStruct struct { +type UserResponse struct { Handle string `json:"handle" doc:"Handle of created or updated user"` APIKey string `json:"apiKey" doc:"API key for the user"` } diff --git a/sqlc.yaml b/sqlc.yaml index 6b70a7f..73e510d 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -21,17 +21,22 @@ sql: - db_type: "jsonb" go_type: type: "map[string]interface{}" - rename: - llm_service_id: "LLMServiceID" - llm_service_handle: "LLMServiceHandle" + rename: # if our desired fieldnames have acronyms with multiple capital letters, + # (like "ID" or "LLM"), PascalCase does not work automatically, so we + # specify naming rules for these here. + instance_id: "InstanceID" + instance_handle: "InstanceHandle" + definition_id: "DefinitionID" + definition_handle: "DefinitionHandle" project_id: "ProjectID" text_id: "TextID" - api_standard_handle: "APIStandardHandle" api_standard: "APIStandard" - api_key: "APIKey" - vdb_api_key: "VdbAPIKey" - # - column: "users.vdb_api_key" + api_standard_handle: "APIStandardHandle" + api_key_encrypted: "APIKeyEncrypted" + has_api_key: "HasAPIKey" + vdb_key: "VDBKey" + # - column: "users.vdb_key" # go_type: # type: "[]byte" # rename: - # decode: "VdbAPIKey" + # decode: "VDBKey" diff --git a/template.env b/template.env index 36b2f9a..88fc454 100644 --- a/template.env +++ b/template.env @@ -9,3 +9,8 @@ SERVICE_DBUSER=postgres SERVICE_DBPASSWORD=postgres SERVICE_DBNAME=postgres SERVICE_ADMINKEY=Ch4ngeM3! + +# Encryption key for API keys in LLM service instances (required for API key encryption) +# Must be a secure random string, at least 32 characters recommended +# Example: openssl rand -hex 32 +ENCRYPTION_KEY=ChangeThisToASecureRandomKey123456789012 diff --git a/testdata/invalid_embeddings.json b/testdata/invalid_embeddings.json index d8c3933..352e715 100644 --- a/testdata/invalid_embeddings.json +++ b/testdata/invalid_embeddings.json @@ -3,8 +3,8 @@ "foo": "https%3A%2F%2Fid.salamanca.school%2Ftexts%2FW0001%3Avol1.1.1.1.1", "user_handle": "alice", "project_handle": "test1", - "project_id": 1, - "llm_service_handle": "openai-large", + "instance_owner": "alice", + "instance_handle": "embedding1", "text": "This is a test document", "vector": [], "vector_dim": 10, diff --git a/testdata/invalid_embeddings_dimension_mismatch.json b/testdata/invalid_embeddings_dimension_mismatch.json index afaf233..8a0cef2 100644 --- a/testdata/invalid_embeddings_dimension_mismatch.json +++ b/testdata/invalid_embeddings_dimension_mismatch.json @@ -3,8 +3,8 @@ "text_id": "test-mismatch-dims", "user_handle": "alice", "project_handle": "test1", - "project_id": 1, - "llm_service_handle": "openai-large", + "instance_owner": "alice", + "instance_handle": "embedding1", "text": "This is a test document", "vector": [ -2.085085e-02, diff --git a/testdata/invalid_embeddings_schema_violation.json b/testdata/invalid_embeddings_schema_violation.json index 44a7fc7..786736e 100644 --- a/testdata/invalid_embeddings_schema_violation.json +++ b/testdata/invalid_embeddings_schema_violation.json @@ -3,8 +3,8 @@ "text_id": "test-invalid-metadata", "user_handle": "alice", "project_handle": "test-schema", - "project_id": 1, - "llm_service_handle": "openai-large", + "instance_owner": "alice", + "instance_handle": "embedding1", "text": "This is a test document", "vector": [ -2.085085e-02, diff --git a/testdata/invalid_embeddings_wrong_dims.json b/testdata/invalid_embeddings_wrong_dims.json index 4755064..287955e 100644 --- a/testdata/invalid_embeddings_wrong_dims.json +++ b/testdata/invalid_embeddings_wrong_dims.json @@ -3,8 +3,8 @@ "text_id": "test-wrong-dims", "user_handle": "alice", "project_handle": "test1", - "project_id": 1, - "llm_service_handle": "openai-large", + "instance_owner": "alice", + "instance_handle": "embedding1", "text": "This is a test document", "vector": [ -2.085085e-02, diff --git a/testdata/invalid_llm_service.json b/testdata/invalid_instance.json similarity index 74% rename from testdata/invalid_llm_service.json rename to testdata/invalid_instance.json index 7f1f111..fd1473f 100644 --- a/testdata/invalid_llm_service.json +++ b/testdata/invalid_instance.json @@ -1,8 +1,8 @@ { - "llm_service_handle": "openai-error", + "instance_handle": "embedding1", "endpoint": "https://api.openai.com/v1/embeddings", "description": "My OpenAI reduced text-embedding-3-large service", "api_keX": "0123456789", "api_standard": "openai", - "dimensions": 1024 + "dimensions": 99 } diff --git a/testdata/project_with_invalid_reader.json b/testdata/project_with_invalid_reader.json index 067abdc..e79ebb4 100644 --- a/testdata/project_with_invalid_reader.json +++ b/testdata/project_with_invalid_reader.json @@ -1,5 +1,5 @@ { "project_handle": "test-rollback", "description": "Test project with invalid reader for transaction rollback", - "authorizedReaders": ["nonexistent_user"] + "shared_with": [{ "user_handle": "nonexistent_user", "role": "reader"}] } diff --git a/testdata/valid_api_standard_test.json b/testdata/valid_api_standard_test.json new file mode 100644 index 0000000..38a1bf4 --- /dev/null +++ b/testdata/valid_api_standard_test.json @@ -0,0 +1,6 @@ +{ + "api_standard_handle": "test", + "description": "OpenAI Embeddings API, Version 1, as documented in https://platform.openai.com/docs/api-reference/embeddings", + "key_method": "auth_bearer", + "key_field": "Authorization" +} diff --git a/testdata/valid_llm_service_cohere-multilingual-3.json b/testdata/valid_definition_cohere-multilingual-3.json similarity index 75% rename from testdata/valid_llm_service_cohere-multilingual-3.json rename to testdata/valid_definition_cohere-multilingual-3.json index f7a1da8..107dd3c 100644 --- a/testdata/valid_llm_service_cohere-multilingual-3.json +++ b/testdata/valid_definition_cohere-multilingual-3.json @@ -1,8 +1,7 @@ { - "llm_service_handle": "cohere-multi", + "instance_handle": "cohere-multi", "endpoint": "https://api.cohere.com/v2/embed", "description": "My Cohere embed-multilingual-v3.0 service", - "api_key": "0123456789", "api_standard": "cohere", "model": "embed-multilingual-v3.0", "dimensions": 1024 diff --git a/testdata/valid_llm_service_cohere4.json b/testdata/valid_definition_cohere4.json similarity index 73% rename from testdata/valid_llm_service_cohere4.json rename to testdata/valid_definition_cohere4.json index f3f21bd..76ed9a9 100644 --- a/testdata/valid_llm_service_cohere4.json +++ b/testdata/valid_definition_cohere4.json @@ -1,8 +1,7 @@ { - "llm_service_handle": "cohere4", + "instance_handle": "cohere4", "endpoint": "https://api.cohere.com/v2/embed", "description": "My Cohere embed-v4.0 service", - "api_key": "0123456789", "api_standard": "cohere", "model": "embed-v4.0", "dimensions": 1536 diff --git a/testdata/valid_llm_service_gemini.json b/testdata/valid_definition_gemini.json similarity index 80% rename from testdata/valid_llm_service_gemini.json rename to testdata/valid_definition_gemini.json index 837e9ce..e44ed53 100644 --- a/testdata/valid_llm_service_gemini.json +++ b/testdata/valid_definition_gemini.json @@ -1,8 +1,7 @@ { - "llm_service_handle": "gemini", + "instance_handle": "gemini", "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent", "description": "My OpenAI full text-embedding-3-small service", - "api_key": "0123456789", "api_standard": "gemini", "model": "gemini-embedding-001", "dimensions": 1536 diff --git a/testdata/valid_llm_service_openai-large-full.json b/testdata/valid_definition_openai-large-full.json similarity index 75% rename from testdata/valid_llm_service_openai-large-full.json rename to testdata/valid_definition_openai-large-full.json index 9698ba9..6a15881 100644 --- a/testdata/valid_llm_service_openai-large-full.json +++ b/testdata/valid_definition_openai-large-full.json @@ -1,8 +1,7 @@ { - "llm_service_handle": "openai-large", + "instance_handle": "openai-large", "endpoint": "https://api.openai.com/v1/embeddings", "description": "My OpenAI full text-embedding-3-large service", - "api_key": "0123456789", "api_standard": "openai", "model": "text-embedding-3-large", "dimensions": 3072 diff --git a/testdata/valid_llm_service_openai-large-reduced.json b/testdata/valid_definition_openai-large-reduced.json similarity index 75% rename from testdata/valid_llm_service_openai-large-reduced.json rename to testdata/valid_definition_openai-large-reduced.json index 65617b8..79af5a9 100644 --- a/testdata/valid_llm_service_openai-large-reduced.json +++ b/testdata/valid_definition_openai-large-reduced.json @@ -1,8 +1,7 @@ { - "llm_service_handle": "openai-reduced", + "instance_handle": "openai-reduced", "endpoint": "https://api.openai.com/v1/embeddings", "description": "My OpenAI reduced text-embedding-3-large service", - "api_key": "0123456789", "api_standard": "openai", "model": "text-embedding-3-large", "dimensions": 1024 diff --git a/testdata/valid_llm_service_openai-small-full.json b/testdata/valid_definition_openai-small-full.json similarity index 75% rename from testdata/valid_llm_service_openai-small-full.json rename to testdata/valid_definition_openai-small-full.json index cbb1c8d..7f58147 100644 --- a/testdata/valid_llm_service_openai-small-full.json +++ b/testdata/valid_definition_openai-small-full.json @@ -1,8 +1,7 @@ { - "llm_service_handle": "openai-small", + "instance_handle": "openai-small", "endpoint": "https://api.openai.com/v1/embeddings", "description": "My OpenAI full text-embedding-3-small service", - "api_key": "0123456789", "api_standard": "openai", "model": "text-embedding-3-small", "dimensions": 1536 diff --git a/testdata/valid_embeddings.json b/testdata/valid_embeddings.json index 150be07..e932fe2 100644 --- a/testdata/valid_embeddings.json +++ b/testdata/valid_embeddings.json @@ -4,7 +4,7 @@ "user_handle": "alice", "project_handle": "test1", "project_id": 1, - "llm_service_handle": "test1", + "instance_handle": "embedding1", "text": "This is a test document", "vector": [ -2.085085e-02, @@ -23,7 +23,7 @@ "user_handle": "alice", "project_handle": "test1", "project_id": 1, - "llm_service_handle": "test1", + "instance_handle": "embedding1", "text": "This is a similar test document", "vector": [ -2.085085e-02, @@ -42,7 +42,7 @@ "user_handle": "alice", "project_handle": "test1", "project_id": 1, - "llm_service_handle": "test1", + "instance_handle": "embedding1", "text": "This is a similar test document", "vector": [ -2.085085e-02, diff --git a/testdata/valid_embeddings_with_schema.json b/testdata/valid_embeddings_with_schema.json index a424990..c6c6a21 100644 --- a/testdata/valid_embeddings_with_schema.json +++ b/testdata/valid_embeddings_with_schema.json @@ -4,7 +4,7 @@ "user_handle": "alice", "project_handle": "test-schema", "project_id": 1, - "llm_service_handle": "openai-large", + "instance_handle": "embedding1", "text": "This is a test document", "vector": [ -2.085085e-02, diff --git a/testdata/valid_llm_service_test1.json b/testdata/valid_instance_embedding1.json similarity index 77% rename from testdata/valid_llm_service_test1.json rename to testdata/valid_instance_embedding1.json index 9035401..2920660 100644 --- a/testdata/valid_llm_service_test1.json +++ b/testdata/valid_instance_embedding1.json @@ -1,8 +1,7 @@ { - "llm_service_handle": "test1", + "instance_handle": "embedding1", "endpoint": "https://api.foo.bar/v1/embed", "description": "An LLM Service just for testing if the dhamps-vdb code is working", - "api_key": "0123456789", "api_standard": "openai", "model": "embed-test1", "dimensions": 5 diff --git a/testdata/valid_project.json b/testdata/valid_project.json index bbae918..a6486d1 100644 --- a/testdata/valid_project.json +++ b/testdata/valid_project.json @@ -1,4 +1,6 @@ { "project_handle": "test1", + "instance_owner": "alice", + "instance_handle": "embedding1", "description": "This is a test project" } diff --git a/testdata/valid_project_with_schema.json b/testdata/valid_project_with_schema.json index 2a881cc..ec9d3dc 100644 --- a/testdata/valid_project_with_schema.json +++ b/testdata/valid_project_with_schema.json @@ -1,5 +1,7 @@ { "project_handle": "test-schema", + "instance_owner": "alice1", + "instance_handle": "embedding1", "description": "Test project with metadata schema", "metadataScheme": "{\"type\":\"object\",\"properties\":{\"author\":{\"type\":\"string\"},\"year\":{\"type\":\"integer\"}},\"required\":[\"author\"]}" }