Bläddra i källkod

OpenAI compatible /v1 implementation

Efren Yevale Varela 4 veckor sedan
förälder
incheckning
bfc057bbd4

+ 154 - 0
AGENTS.md

@@ -209,6 +209,160 @@ Database connection is properly closed on process termination (SIGTERM, SIGINT)
 - `1` — Uncaught exception or unhandled rejection
 - `2` — Cleanup error during shutdown
 
+## `/v1` API — OpenAI-Compatible LLM Proxy
+
+The application exposes an OpenAI-compatible REST API under `/v1` that proxies chat completion requests to configured LLM providers. All `/v1` endpoints require OAuth 2.1 Bearer token authentication with the `llm` scope.
+
+### Authentication
+
+Every `/v1` request must include an `Authorization` header:
+
+```
+Authorization: Bearer <access_token>
+```
+
+Tokens are issued by the local OAuth 2.1 authorization server. The flow is:
+1. Register a client via `POST /oauth/register`
+2. Log in via `POST /oauth/login` (sets session cookie)
+3. Authorize via `GET /oauth/authorize` with PKCE S256
+4. Exchange the code via `POST /oauth/token`
+
+See `scripts/get-token.sh` for an automated script that runs the full flow.
+
+### Provider Configuration
+
+Providers are defined in `config.json` under the `providers` array. Only providers with `enabled: true` are exposed.
+
+**Supported provider types:**
+
+| Type | Required Config | Credentials |
+|------|----------------|-------------|
+| `bedrock` | `region`, `credentials` | AWS access key ID + secret |
+| `anthropic` | `apiKey` | Anthropic API key |
+| `openai-compatible` | `baseUrl` | None (local endpoint) |
+
+**Example `config.json` snippet:**
+
+```json
+{
+  "providers": [
+    {
+      "id": "bedrock",
+      "type": "bedrock",
+      "enabled": true,
+      "region": "us-east-1",
+      "credentials": { "accessKeyId": "...", "secretAccessKey": "..." },
+      "models": { "claude-3-sonnet": "anthropic.claude-3-sonnet-20240229-v1:0" }
+    },
+    {
+      "id": "anthropic",
+      "type": "anthropic",
+      "enabled": true,
+      "apiKey": "...",
+      "models": { "claude-3-5-sonnet": "claude-3-5-sonnet-20241022" }
+    },
+    {
+      "id": "local",
+      "type": "openai-compatible",
+      "enabled": false,
+      "baseUrl": "http://localhost:11434/v1",
+      "models": { "llama3.1": "llama3.1:latest" }
+    }
+  ]
+}
+```
+
+### Model ID Format
+
+Models are addressed as `provider/localModelId` (e.g., `bedrock/claude-3-sonnet`). The `models` object in config maps the local key to the provider's native identifier.
+
+### Endpoints
+
+#### `POST /v1/chat/completions`
+
+Chat with streaming or non-streaming response.
+
+```bash
+# Non-streaming
+curl -X POST http://localhost:3000/v1/chat/completions \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "model": "bedrock/claude-3-sonnet",
+    "messages": [{"role": "user", "content": "Hello!"}],
+    "temperature": 0.7,
+    "max_tokens": 256
+  }'
+
+# Streaming
+curl -X POST http://localhost:3000/v1/chat/completions \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "model": "anthropic/claude-3-5-sonnet",
+    "messages": [{"role": "user", "content": "Hello!"}],
+    "stream": true
+  }'
+```
+
+#### `GET /v1/models`
+
+List all available models across enabled providers.
+
+```bash
+curl http://localhost:3000/v1/models \
+  -H "Authorization: Bearer <token>"
+```
+
+#### `GET /v1/models/{model}`
+
+Get details for a single model. The slash in the model ID **must be URL-encoded** (`%2F`).
+
+```bash
+curl http://localhost:3000/v1/models/bedrock%2Fclaude-3-sonnet \
+  -H "Authorization: Bearer <token>"
+```
+
+### Error Format
+
+All errors are returned in OpenAI-compatible JSON:
+
+```json
+{
+  "error": {
+    "message": "Human-readable description",
+    "type": "invalid_request_error",
+    "param": null,
+    "code": "invalid_model"
+  }
+}
+```
+
+Common error types: `invalid_request_error`, `invalid_token`, `insufficient_scope`, `authentication_error`, `rate_limit_error`, `api_error`.
+
+Common HTTP status codes:
+- `200` — Success
+- `400` — Invalid request (bad JSON, missing params, unknown model)
+- `401` — Missing or invalid Bearer token
+- `403` — Valid token but insufficient scope
+- `404` — Model not found
+- `429` — Rate limited by provider
+- `500` — Internal server error (provider init failure)
+- `502` — Provider service error
+- `503` — Provider overloaded
+
+### Key Files
+
+- `src/routes/v1/chat/completions/+server.js` — Chat completion handler
+- `src/routes/v1/models/+server.js` — Model list handler
+- `src/routes/v1/models/[model]/+server.js` — Model detail handler
+- `src/lib/providers/index.js` — Provider resolution and model listing
+- `src/lib/providers/errors.js` — Error normalization to OpenAI format
+- `src/lib/providers/bedrock.js` — AWS Bedrock provider
+- `src/lib/providers/anthropic.js` — Anthropic provider
+- `src/lib/providers/openai-compatible.js` — Generic OpenAI-compatible provider
+- `src/lib/oauth/bearer.js` — Bearer token validation
+
 ## Performance Notes
 
 - Vite dev server is fast; rebuilds are near-instant

+ 142 - 1
README.md

@@ -1,6 +1,6 @@
 # testing-proxy
 
-A modern SvelteKit application with Svelte 5 runes, Tailwind CSS, and Flowbite components.
+A modern SvelteKit application with Svelte 5 runes, Tailwind CSS, and Flowbite components. Also serves as a multi-provider OpenAI-compatible LLM proxy with OAuth 2.1 authentication.
 
 ## Setup
 
@@ -142,6 +142,128 @@ mkdir -p data
 chmod 755 data
 ```
 
+## LLM Proxy
+
+The application exposes an OpenAI-compatible REST API under `/v1` that proxies chat completion requests to configured LLM providers (AWS Bedrock, Anthropic, or any OpenAI-compatible endpoint). All `/v1` endpoints require OAuth 2.1 Bearer token authentication.
+
+### Provider Setup
+
+Providers are configured in `config.json` under the `providers` array. Only providers with `enabled: true` are exposed.
+
+**Supported provider types:**
+
+| Type | Required Config | Credentials |
+|------|----------------|-------------|
+| `bedrock` | `region`, `credentials` | AWS access key ID + secret |
+| `anthropic` | `apiKey` | Anthropic API key |
+| `openai-compatible` | `baseUrl` | None (local endpoint) |
+
+**Example `config.json` snippet:**
+
+```json
+{
+  "providers": [
+    {
+      "id": "bedrock",
+      "type": "bedrock",
+      "enabled": true,
+      "region": "us-east-1",
+      "credentials": {
+        "accessKeyId": "YOUR_ACCESS_KEY",
+        "secretAccessKey": "YOUR_SECRET_KEY"
+      },
+      "models": {
+        "claude-3-sonnet": "anthropic.claude-3-sonnet-20240229-v1:0"
+      }
+    },
+    {
+      "id": "anthropic",
+      "type": "anthropic",
+      "enabled": true,
+      "apiKey": "YOUR_ANTHROPIC_API_KEY",
+      "models": {
+        "claude-3-5-sonnet": "claude-3-5-sonnet-20241022"
+      }
+    },
+    {
+      "id": "local",
+      "type": "openai-compatible",
+      "enabled": false,
+      "baseUrl": "http://localhost:11434/v1",
+      "models": {
+        "llama3.1": "llama3.1:latest"
+      }
+    }
+  ]
+}
+```
+
+Models are addressed as `provider/localModelId` (e.g., `bedrock/claude-3-sonnet`). The `models` object maps the local key to the provider's native identifier.
+
+### Authentication
+
+Every `/v1` request must include an `Authorization` header with a valid Bearer access token:
+
+```
+Authorization: Bearer <access_token>
+```
+
+Tokens are issued by the local OAuth 2.1 authorization server. The flow is:
+
+1. **Register a client** via `POST /oauth/register`
+2. **Log in** via `POST /oauth/login` (sets a session cookie)
+3. **Authorize** via `GET /oauth/authorize` with PKCE S256
+4. **Exchange the code** via `POST /oauth/token`
+
+### Quick Start
+
+Use the provided script to automate token acquisition:
+
+```bash
+# Make the script executable and run it
+chmod +x scripts/get-token.sh
+./scripts/get-token.sh
+```
+
+This script runs the full OAuth 2.1 flow and prints an access token. Then make a request:
+
+```bash
+# List available models
+curl http://localhost:3000/v1/models \
+  -H "Authorization: Bearer <token>"
+
+# Chat completion (non-streaming)
+curl -X POST http://localhost:3000/v1/chat/completions \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "model": "bedrock/claude-3-sonnet",
+    "messages": [{"role": "user", "content": "Hello!"}],
+    "temperature": 0.7,
+    "max_tokens": 256
+  }'
+
+# Chat completion (streaming)
+curl -X POST http://localhost:3000/v1/chat/completions \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "model": "anthropic/claude-3-5-sonnet",
+    "messages": [{"role": "user", "content": "Hello!"}],
+    "stream": true
+  }'
+```
+
+### API Endpoints
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/v1/chat/completions` | `POST` | Chat with streaming or non-streaming response |
+| `/v1/models` | `GET` | List all available models across enabled providers |
+| `/v1/models/{model}` | `GET` | Get details for a single model (URL-encode the slash) |
+
+All errors are returned in OpenAI-compatible JSON format. See `docs/V1-API.md` for full API documentation.
+
 ## Project Structure
 
 - `src/routes/` — SvelteKit filesystem routes
@@ -155,6 +277,8 @@ chmod 755 data
 - **Components**: Flowbite Svelte 1.33.1 + Flowbite Icons
 - **Build Tool**: Vite 8.0.16
 - **Adapter**: Node.js (`@sveltejs/adapter-node`)
+- **LLM Providers**: `@aws-sdk/client-bedrock-runtime`, Anthropic SDK, generic OpenAI-compatible endpoints
+- **Authentication**: OAuth 2.1 with PKCE S256, Redis-backed token storage
 
 ## Important Notes
 
@@ -224,6 +348,7 @@ The `config.json` file (copied from `config.json.example`) controls server and d
 - **`database.sqlite3.performance`** — Performance tuning options (WAL mode, foreign keys, etc.)
 - **`password.algorithm`** — Password hashing algorithm (currently `argon2id`)
 - **`password.options`** — Argon2id configuration (memory cost in KiB, time cost iterations, parallelism level)
+- **`providers`** — Array of LLM provider configurations (see [Provider Setup](#provider-setup))
 
 ### Database Initialization
 
@@ -239,10 +364,26 @@ Passwords are hashed using **Argon2id** (configured in `config.json`). Crypto ut
 
 ## Links
 
+### Framework & Tools
 - [SvelteKit Documentation](https://svelte.dev/docs/kit)
 - [Flowbite Svelte](https://flowbite-svelte.com/)
 - [Tailwind CSS](https://tailwindcss.com/)
 - [Paraglide JS](https://paraglidejs.com/vite)
 - [better-sqlite3](https://github.com/WiseLibs/better-sqlite3)
 - [node-argon2](https://github.com/ranisalt/node-argon2)
+
+### API & Authentication
+- [OpenAI API Reference](https://platform.openai.com/docs/api-reference) — Chat completions and models endpoints
+- [AWS Bedrock Converse API](https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html) — Bedrock provider backend
+- [Anthropic Messages API](https://docs.anthropic.com/en/api/messages) — Anthropic provider backend
+- [OAuth 2.1 Specification](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-15) — Authorization server implementation
+- [PKCE RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) — S256 code challenge method
+
+### Local LLM Servers
+- [Ollama OpenAI Compatibility](https://github.com/ollama/ollama/blob/main/docs/openai.md) — Local OpenAI-compatible endpoint
+- [vLLM OpenAI-Compatible Server](https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html) — Alternative local server
+
+### Project
 - [Project Instructions](./AGENTS.md)
+- [API Documentation](./docs/v1-api.md)
+- [OAuth 2.1 Testing Guide](./docs/LLM-TEST.md)

+ 34 - 1
config.json.example

@@ -28,5 +28,38 @@
       "timeCost": 3,
       "parallelism": 1
     }
-  }
+  },
+  "providers": [
+    {
+      "id": "bedrock",
+      "type": "bedrock",
+      "enabled": true,
+      "region": "us-east-1",
+      "credentials": {
+        "accessKeyId": "YOUR_ACCESS_KEY",
+        "secretAccessKey": "YOUR_SECRET_KEY"
+      },
+      "models": {
+        "us.amazon.nova-micro-v1:0": "us.amazon.nova-micro-v1:0"
+      }
+    },
+    {
+      "id": "anthropic",
+      "type": "anthropic",
+      "enabled": true,
+      "apiKey": "YOUR_ANTHROPIC_API_KEY",
+      "models": {
+        "claude-3-5-sonnet": "claude-3-5-sonnet-20241022"
+      }
+    },
+    {
+      "id": "local",
+      "type": "openai-compatible",
+      "enabled": false,
+      "baseUrl": "http://localhost:11434/v1",
+      "models": {
+        "ministral-3-14b-32k": "ministral-3-14b-32k"
+      }
+    }
+  ]
 }

+ 225 - 0
docs/PLAN-OAUTH-21-SERVER.md

@@ -0,0 +1,225 @@
+# oauth-21-server - Work Plan
+
+## TL;DR (For humans)
+
+**What you'll get:** A fully compliant OAuth 2.1 authorization server with a discovery endpoint, PKCE-mandatory authorization flows, opaque tokens stored in Redis, and a login/consent UI. All endpoints follow RFC 8414 metadata and OAuth 2.1 draft-15 with the highest security posture — no legacy grants, no plain PKCE, exact redirect URI matching, and HTTPS enforcement.
+
+**Why this approach:** Using ioredis for all expiring data (tokens, auth codes, sessions) and SQLite3 only for persistent records (clients, users, scopes) cleanly separates concerns and leverages Redis TTL for automatic expiration. SvelteKit's `+server.js` handlers provide a clean API layer on the existing Node.js adapter.
+
+**What it will NOT do:** It will not support JWT tokens, implicit grants, password grants, client_credentials grants, or query-string bearer tokens. It will not persist auth codes or tokens in SQLite3. It does not include OpenID Connect.
+
+**Effort:** Large
+**Risk:** Medium — Redis dependency adds operational surface; PKCE and PAR enforcement must be exact
+**Decisions to sanity-check:** Token format (opaque), grant types (authorization_code + refresh_token only), Redis as sole store for expiring data.
+
+Your next move: Start work with `/start-work`.
+
+---
+
+> TL;DR (machine): Large effort, Medium risk — OAuth 2.1 AS with discovery, PKCE, PAR, opaque tokens in Redis, sqlite3 for persistent data, SvelteKit +server.js handlers.
+
+## Scope
+### Must have
+- `/.well-known/oauth-authorization-server` metadata endpoint (RFC 8414)
+- OAuth 2.1 core protocol endpoints: `/oauth/authorize`, `/oauth/token`, `/oauth/introspect`, `/oauth/revoke`
+- Client registration endpoint `/oauth/register` (RFC 7591)
+- PKCE S256 mandatory enforcement (no `plain`)
+- Pushed Authorization Requests (PAR) endpoint `/oauth/par` (RFC 9126) with `require_pushed_authorization_requests: true`
+- Opaque token generation and storage in Redis with TTL
+- Authorization code generation and storage in Redis with TTL
+- Refresh token generation and storage in Redis with TTL
+- Client authentication at token endpoint: `client_secret_basic`, `client_secret_post`, `private_key_jwt`, `tls_client_auth`
+- Introspection endpoint with client auth (not bearer-only)
+- Exact redirect URI matching
+- User login page (`/oauth/login`)
+- User consent page (`/oauth/consent`)
+- `config.json` updates: `baseUrl`, `redis` connection options (host, port, password, database number)
+- `ioredis` dependency and Redis client module
+- SQLite3 schema extension: `clients` table only
+- `scopes_supported` dynamically from SQLite3 `scopes` table
+- `ui_locales_supported`: `["en-US", "es-MX"]`
+- Documentation moved from `.omo/ulw-research/` to `docs/`
+
+### Must NOT have (guardrails, anti-slop, scope boundaries)
+- JWT access tokens (opaque only)
+- Implicit grant (`response_type=token`)
+- Resource Owner Password Credentials grant
+- `client_credentials` grant
+- `plain` PKCE method
+- Auth codes, access tokens, or refresh tokens stored in SQLite3
+- Bearer tokens in query strings
+- Wildcard or prefix redirect URI matching
+- OpenID Connect (ID tokens, userinfo endpoint)
+- Device authorization grant (RFC 8628)
+- CORS on authorization endpoint
+
+## Verification strategy
+> Zero human intervention - all verification is agent-executed.
+- Test decision: tests-after + agent-executed QA via curl/httpie against running dev server
+- Evidence: `.omo/evidence/task-<N>-oauth-21-server.<ext>`
+
+## Execution strategy
+### Parallel execution waves
+> Target 5-8 todos per wave.
+
+**Wave 1: Foundation** — Config, dependencies, Redis client, database schema
+**Wave 2: Discovery & Client Registration** — Metadata endpoint, client registration
+**Wave 3: Core OAuth Flow** — Authorization, token, introspection, revocation endpoints
+**Wave 4: User UI & Security Hardening** — Login/consent pages, PAR, JWKS
+**Wave 5: Documentation & Final QA** — Move docs, integration testing
+
+### Dependency matrix
+| Todo | Depends on | Blocks | Can parallelize with |
+| --- | --- | --- | --- |
+| 1 | — | 2,3,4,5 | — |
+| 2 | 1 | 6,7,8,9,10 | 3,4,5 |
+| 3 | 1 | 6,7,8,9,10 | 2,4,5 |
+| 4 | 1 | 6,7,8,9,10 | 2,3,5 |
+| 5 | 1 | — | 2,3,4 |
+| 6 | 2,3,4 | 7 | — |
+| 7 | 6 | 8,9,10 | — |
+| 8 | 7 | — | 9,10 |
+| 9 | 7 | — | 8,10 |
+| 10 | 7 | — | 8,9 |
+| 11 | 1 | 12 | — |
+| 12 | 11 | — | — |
+| 13 | — | — | all |
+| 14 | all | — | — |
+
+## Todos
+
+- [x] 1. Update `config.json.example` with `baseUrl` and `redis` sections
+  What to do / Must NOT do: Add `baseUrl` (string, e.g., `https://localhost:3000`) and `redis` object with `host`, `port`, `password`, `db` (database number) to `config.json.example`. Must NOT remove existing database/password sections.
+  Parallelization: Wave 1 | Blocked by: — | Blocks: 2,3,4,5
+  References: `config.json.example:1-25`, `src/lib/config.js:1-19`
+  Acceptance criteria: `cat config.json.example | grep -q baseUrl && cat config.json.example | grep -q redis`
+  QA scenarios: Verify JSON is valid (`node -e "JSON.parse(require('fs').readFileSync('config.json.example'))"`). Happy: valid JSON. Failure: invalid JSON syntax.
+  Commit: Y | feat(config): add baseUrl and redis connection options
+
+- [x] 2. Install `ioredis` dependency and create Redis client module
+  What to do / Must NOT do: `pnpm add ioredis`. Create `src/lib/redis.js` exporting a configured ioredis client using `config.redis`. Must NOT use `redis` (node-redis) package. Must handle connection errors gracefully.
+  Parallelization: Wave 1 | Blocked by: 1 | Blocks: 6,7,8,9,10
+  References: `package.json:29-32`, `src/lib/config.js:1-19`, ioredis docs
+  Acceptance criteria: `node -e "import('./src/lib/redis.js').then(m => console.log(typeof m.default))"` returns "object" or similar
+  QA scenarios: Happy: Redis client connects and responds to PING. Failure: Invalid config throws error with helpful message.
+  Commit: Y | feat(redis): add ioredis client module
+
+- [x] 3. Extend SQLite3 schema with `clients` table
+  What to do / Must NOT do: Add `clients` table creation to `seedInfo()` in `src/lib/sqlite3/database.js`. Schema: `id`, `client_id` (TEXT UNIQUE), `client_secret` (TEXT), `redirect_uris` (TEXT, JSON array), `grant_types` (TEXT), `response_types` (TEXT), `scope` (TEXT), `token_endpoint_auth_method` (TEXT), `registration_access_token` (TEXT), `created_at`, `updated_at`. Must NOT create auth_codes, access_tokens, or refresh_tokens tables in SQLite3.
+  Parallelization: Wave 1 | Blocked by: 1 | Blocks: 6,7,8,9,10
+  References: `src/lib/sqlite3/database.js:16-47`
+  Acceptance criteria: After `rm data/app.db && npm run dev`, `clients` table exists with correct columns.
+  QA scenarios: Happy: Fresh DB initializes with clients table. Failure: Duplicate table creation does not error (idempotent).
+  Commit: Y | feat(db): add clients table for OAuth 2.1
+
+- [x] 4. Create token generation utilities
+  What to do / Must NOT do: Create `src/lib/oauth/tokens.js` with functions: `generateAuthorizationCode()`, `generateAccessToken()`, `generateRefreshToken()` — all return cryptographically random strings (use `crypto.randomBytes`). Must NOT use `Math.random()`. Must NOT create JWTs.
+  Parallelization: Wave 1 | Blocked by: 1 | Blocks: 6,7,8,9,10
+  References: Node.js `crypto` module docs, `src/lib/crypto.js:1-13`
+  Acceptance criteria: `node -e "import('./src/lib/oauth/tokens.js').then(m => console.log(m.generateAccessToken().length > 20))"` prints true
+  QA scenarios: Happy: Generated tokens are unique across 1000 calls. Failure: Tokens are predictable or too short.
+  Commit: Y | feat(oauth): add opaque token generation utilities
+
+- [x] 5. Create PKCE validation utilities
+  What to do / Must NOT do: Create `src/lib/oauth/pkce.js` with `generateCodeChallenge(verifier)` (S256 SHA256 base64url) and `verifyCodeChallenge(verifier, challenge)` (constant-time comparison). Must NOT support `plain` method. Must use `crypto.createHash('sha256')`.
+  Parallelization: Wave 1 | Blocked by: 1 | Blocks: 6,7
+  References: RFC 7636 Section 4, Node.js `crypto` docs
+  Acceptance criteria: `node -e "import('./src/lib/oauth/pkce.js').then(m => m.verifyCodeChallenge('test', m.generateCodeChallenge('test')))"` prints true
+  QA scenarios: Happy: Valid verifier matches challenge. Failure: Invalid verifier fails, wrong method fails, timing attack resistant.
+  Commit: Y | feat(oauth): add PKCE S256 utilities
+
+- [x] 6. Create `/.well-known/oauth-authorization-server` endpoint
+  What to do / Must NOT do: Create `src/routes/.well-known/oauth-authorization-server/+server.js` returning JSON metadata. Must query `scopes_supported` from SQLite3 `scopes` table dynamically. Must use `config.baseUrl` for all endpoint URLs. Must include: issuer, authorization_endpoint, token_endpoint, revocation_endpoint, introspection_endpoint, registration_endpoint, pushed_authorization_request_endpoint, require_pushed_authorization_requests (true), response_types_supported (["code"]), grant_types_supported (["authorization_code", "refresh_token"]), token_endpoint_auth_methods_supported, revocation_endpoint_auth_methods_supported, introspection_endpoint_auth_methods_supported, code_challenge_methods_supported (["S256"]), scopes_supported, service_documentation, ui_locales_supported (["en-US", "es-MX"]). Must NOT include "plain" in code_challenge_methods_supported. Must NOT include "bearer" alone in introspection auth methods.
+  Parallelization: Wave 2 | Blocked by: 2,3 | Blocks: 7
+  References: `src/lib/config.js:1-19`, `src/lib/sqlite3/database.js:1-91`, RFC 8414 Section 2
+  Acceptance criteria: `curl -s http://localhost:3000/.well-known/oauth-authorization-server | jq '.issuer'` returns non-null
+  QA scenarios: Happy: Returns valid JSON with all required fields. Failure: Missing baseUrl config throws 500. Scopes query returns correct list from DB.
+  Commit: Y | feat(oauth): add OAuth 2.1 discovery metadata endpoint
+
+- [x] 7. Create `/oauth/register` client registration endpoint
+  What to do / Must NOT do: Create `src/routes/oauth/register/+server.js` handling POST for dynamic client registration (RFC 7591). Store clients in SQLite3 `clients` table. Generate `client_id`, `client_secret` (if confidential client), `registration_access_token`. Return client metadata with `client_id_issued_at`. Must validate `redirect_uris` (exact, HTTPS). Must NOT allow `http://` except localhost. Must NOT register clients with unsupported grant types.
+  Parallelization: Wave 2 | Blocked by: 2,3 | Blocks: 8,9,10
+  References: RFC 7591, `src/lib/sqlite3/database.js`, `src/routes/.well-known/oauth-authorization-server/+server.js`
+  Acceptance criteria: `curl -s -X POST http://localhost:3000/oauth/register -H "Content-Type: application/json" -d '{"redirect_uris":["https://example.com/callback"],"client_name":"Test"}' | jq '.client_id'` returns non-null
+  QA scenarios: Happy: Valid registration returns client_id. Failure: Missing redirect_uris returns 400. Invalid redirect URI returns 400. HTTP redirect URI (non-localhost) returns 400.
+  Commit: Y | feat(oauth): add dynamic client registration endpoint
+
+- [x] 8. Create `/oauth/authorize` authorization endpoint
+  What to do / Must NOT do: Create `src/routes/oauth/authorize/+server.js` handling GET. Validate `response_type=code`, `client_id`, `redirect_uri` (exact match against registered), `scope` (must be subset of registered), `code_challenge` (S256 only, required), `code_challenge_method` (must be "S256"). Store auth code in Redis with TTL (10 minutes) keyed by code. Value includes: client_id, redirect_uri, user_id, scope, code_challenge, expires_at. Redirect to `redirect_uri` with `?code=...&state=...&iss=...` (RFC 9207). Must NOT support `response_type=token`. Must NOT accept missing `code_challenge`.
+  Parallelization: Wave 3 | Blocked by: 5,6,7 | Blocks: 9
+  References: draft-ietf-oauth-v2-1-15 Section 3.1, RFC 7636, `src/lib/oauth/pkce.js`, `src/lib/redis.js`
+  Acceptance criteria: `curl -s "http://localhost:3000/oauth/authorize?response_type=code&client_id=TEST&redirect_uri=https://example.com/callback&scope=user&code_challenge=abc123&code_challenge_method=S256&state=xyz" -w "%{http_code}"` returns 302
+  QA scenarios: Happy: Valid request redirects with code. Failure: Missing PKCE returns 400. Invalid redirect_uri returns 400. Unsupported response_type returns 400. Expired client_id returns 400.
+  Commit: Y | feat(oauth): add authorization endpoint with mandatory PKCE
+
+- [x] 9. Create `/oauth/token` token endpoint
+  What to do / Must NOT do: Create `src/routes/oauth/token/+server.js` handling POST. Support `grant_type=authorization_code` and `refresh_token`. For authorization_code: validate code (from Redis), client auth, redirect_uri (required if in auth request), verify PKCE code_verifier against stored code_challenge. Issue opaque access_token and refresh_token, store in Redis with TTL. Return JSON with `access_token`, `token_type`, `expires_in`, `refresh_token`, `scope`. Must NOT support password grant. Must NOT support implicit. Must NOT issue tokens for invalid PKCE.
+  Parallelization: Wave 3 | Blocked by: 6,7,8 | Blocks: 10
+  References: draft-ietf-oauth-v2-1-15 Section 3.2, `src/lib/oauth/tokens.js`, `src/lib/redis.js`, `src/lib/oauth/pkce.js`
+  Acceptance criteria: `curl -s -X POST http://localhost:3000/oauth/token -d "grant_type=authorization_code&code=VALID_CODE&redirect_uri=https://example.com/callback&client_id=TEST&code_verifier=VERIFIER" | jq '.access_token'` returns non-null
+  QA scenarios: Happy: Valid code exchange returns tokens. Failure: Invalid code returns 400. Missing code_verifier returns 400. Wrong redirect_uri returns 400. Expired code returns 400. Invalid client auth returns 401.
+  Commit: Y | feat(oauth): add token endpoint with PKCE verification
+
+- [x] 10. Create `/oauth/introspect` and `/oauth/revoke` endpoints
+  What to do / Must NOT do: Create `src/routes/oauth/introspect/+server.js` (POST) and `src/routes/oauth/revoke/+server.js` (POST). Introspection: authenticate client, look up token in Redis, return `{active: true/false, ...}` with token metadata if active. Revocation: authenticate client, delete token from Redis, return 200. Both must use client auth methods (client_secret_basic, client_secret_post). Must NOT accept bearer-only auth on introspection.
+  Parallelization: Wave 3 | Blocked by: 6,7,9 | Blocks: —
+  References: RFC 7662, RFC 7009, `src/lib/redis.js`
+  Acceptance criteria: `curl -s -X POST http://localhost:3000/oauth/introspect -u "CLIENT_ID:SECRET" -d "token=VALID_TOKEN" | jq '.active'` returns true/false
+  QA scenarios: Happy: Valid token introspection returns active=true with metadata. Valid revocation deletes token. Failure: Missing client auth returns 401. Invalid token returns active=false. Revoked token introspection returns active=false.
+  Commit: Y | feat(oauth): add token introspection and revocation endpoints
+
+- [x] 11. Create `/oauth/par` Pushed Authorization Request endpoint
+  What to do / Must NOT do: Create `src/routes/oauth/par/+server.js` handling POST. Accepts same parameters as authorize endpoint. Stores request in Redis with TTL (short-lived, e.g., 60s). Returns `request_uri` (urn:ietf:params:oauth:request_uri:...). Authorize endpoint must accept `request_uri` parameter and lookup stored request. Must set `require_pushed_authorization_requests: true` in discovery metadata.
+  Parallelization: Wave 4 | Blocked by: 6,8 | Blocks: 12
+  References: RFC 9126, `src/lib/redis.js`, `src/routes/oauth/authorize/+server.js`
+  Acceptance criteria: `curl -s -X POST http://localhost:3000/oauth/par -d "client_id=TEST&response_type=code&redirect_uri=https://example.com/callback&scope=user&code_challenge=abc123&code_challenge_method=S256" | jq '.request_uri'` returns non-null
+  QA scenarios: Happy: PAR request returns request_uri. Authorize with request_uri redirects with code. Failure: Invalid client_id returns 400. Expired request_uri returns invalid_request.
+  Commit: Y | feat(oauth): add PAR endpoint
+
+- [x] 12. Create `/oauth/login` and `/oauth/consent` UI pages
+  What to do / Must NOT do: Create `src/routes/oauth/login/+page.svelte` and `src/routes/oauth/consent/+page.svelte`. Login: email/password form, validate against SQLite3 users table using existing Argon2id. Consent: display client name, requested scopes, allow/deny buttons. On allow, redirect to authorize endpoint with user context. Use Svelte 5 runes. Must NOT use TypeScript. Must use Flowbite components where appropriate.
+  Parallelization: Wave 4 | Blocked by: 1 | Blocks: —
+  References: `src/routes/+page.svelte:1-39`, `src/lib/crypto.js:1-13`, `src/lib/sqlite3/database.js:1-91`
+  Acceptance criteria: `curl -s http://localhost:3000/oauth/login` returns HTML with login form. `curl -s http://localhost:3000/oauth/consent` returns HTML with consent UI.
+  QA scenarios: Happy: Valid credentials log in. Consent allow redirects to authorize. Consent deny returns access_denied. Failure: Invalid credentials show error. Missing session redirects to login.
+  Commit: Y | feat(ui): add OAuth login and consent pages
+
+- [ ] 13. Move `.omo/ulw-research/` artifacts to `docs/`
+  What to do / Must NOT do: Copy or move files from `.omo/ulw-research/` to `docs/oauth-research/`. Must NOT delete originals (they are under `.omo/` which may be gitignored). Must ensure docs are readable.
+  Parallelization: Wave 5 | Blocked by: — | Blocks: —
+  References: `.omo/ulw-research/`
+  Acceptance criteria: `ls docs/oauth-research/` shows files from `.omo/ulw-research/`
+  QA scenarios: Happy: Files present in docs. Failure: Missing files or broken paths.
+  Commit: Y | docs: move OAuth research to docs directory
+
+- [ ] 14. Final integration QA and security verification
+  What to do / Must NOT do: Run dev server. Execute full OAuth 2.1 flow: register client -> PAR -> authorize (with login/consent) -> token exchange -> introspect -> revoke. Verify discovery metadata matches implementation. Verify no unsupported grants work. Verify no plain PKCE accepted. Verify tokens are opaque (not JWT). Must NOT skip negative testing.
+  Parallelization: Wave 5 | Blocked by: all previous | Blocks: —
+  References: All endpoint files, `src/routes/.well-known/oauth-authorization-server/+server.js`
+  Acceptance criteria: Full flow completes successfully from register to revoke.
+  QA scenarios: Happy: Complete end-to-end OAuth 2.1 flow. Failure: Unsupported grant returns 400. Plain PKCE rejected. Missing PKCE rejected. Invalid redirect URI rejected. Token stored in Redis (not sqlite3).
+  Commit: Y | test(qa): verify full OAuth 2.1 integration
+
+## Final verification wave
+> Runs in parallel after ALL todos. ALL must APPROVE.
+- [ ] F1. Plan compliance audit — Verify every must-have is implemented and every must-not-have is absent
+- [ ] F2. Code quality review — Verify Svelte 5 runes, consistent patterns, no hardcoded secrets
+- [ ] F3. Real manual QA — Execute full OAuth 2.1 flow end-to-end with curl/httpie
+- [ ] F4. Scope fidelity — Confirm no scope creep, no JWTs, no implicit grant, no sqlite3 token storage
+
+## Commit strategy
+- One commit per todo (atomic commits)
+- Prefix: `feat(config):`, `feat(redis):`, `feat(db):`, `feat(oauth):`, `feat(ui):`, `docs:`, `test(qa):`
+- Clear, descriptive messages referencing the component
+
+## Success criteria
+1. `/.well-known/oauth-authorization-server` returns valid RFC 8414 metadata with `scopes_supported` from DB and `ui_locales_supported: ["en-US", "es-MX"]`
+2. `/oauth/authorize` enforces PKCE S256 and exact redirect URI matching
+3. `/oauth/token` issues opaque tokens stored in Redis with TTL
+4. `/oauth/introspect` and `/oauth/revoke` require client authentication
+5. `/oauth/par` accepts pushed authorization requests and returns `request_uri`
+6. `/oauth/register` creates clients in SQLite3 with valid redirect URIs
+7. Login and consent pages render and handle auth flow correctly
+8. No auth codes, access tokens, or refresh tokens exist in SQLite3
+9. Redis is the sole store for all expiring OAuth data
+10. Full end-to-end flow executes successfully from registration to token revocation

+ 208 - 0
docs/PLAN-V1-API.md

@@ -0,0 +1,208 @@
+# v1-api-multi-provider - Work Plan
+
+## TL;DR (For humans)
+
+**What you'll get:** An OpenAI-compatible `/v1` API that proxies chat completion requests to **three provider backends** — Amazon Bedrock, Anthropic (direct API), and any generic OpenAI-compatible endpoint (vLLM, Ollama, LiteLLM, etc.). All endpoints are protected by your existing OAuth 2.1 Bearer tokens with `llm` scope enforcement. The architecture uses a clean **provider abstraction**: model IDs are prefixed (`bedrock/claude-3-sonnet`, `anthropic/claude-3-5-sonnet`, `local/llama3.1`), and each provider handles its own request/response translation. The minimum viable implementation covers `POST /v1/chat/completions` (streaming + non-streaming) and `GET /v1/models`.
+
+**Why this approach:** Instead of hardcoding Bedrock-specific translation throughout the API layer, we abstract providers behind a unified interface (`chat(messages, model, stream) → { response | stream }`). This makes adding a 4th provider literally a matter of editing `config.json` — zero code changes. The Anthropic and generic OpenAI-compatible providers use native `fetch()`, keeping dependencies minimal. AWS Bedrock uses the official SDK for robustness.
+
+**What it will NOT do:** It will not support legacy `/v1/completions`, embeddings, image generation, audio, files, or assistants. It will not auto-discover models from providers — all model mappings are explicit in `config.json`. It will not add CORS, JWT tokens, or client_credentials grants. It will not log message content.
+
+**Effort:** Medium-High (~13 todos, 4 waves)
+**Risk:** Medium — Three different API shapes to translate; SSE streaming must exactly match OpenAI clients' expectations across all providers
+**Decisions to sanity-check:** Provider abstraction design, model ID prefix convention, config.json structure.
+
+Your next move: Approve this plan, then start work with `/start-work`.
+
+---
+
+> TL;DR (machine): Medium-High effort, Medium risk — OpenAI-compatible /v1 API proxy to Amazon Bedrock + Anthropic + generic OpenAI-compatible providers, OAuth 2.1 Bearer token validation against Redis, llm scope enforcement, SSE streaming support, provider abstraction architecture, SvelteKit +server.js handlers.
+
+## Scope
+### Must have
+- Bearer token validation helper (`src/lib/oauth/bearer.js`) — parses `Authorization: Bearer <token>`, looks up in Redis, checks expiry, returns token metadata
+- `llm` scope enforcement on all `/v1/*` endpoints — returns 403 if scope missing
+- OpenAI-compatible error format on all `/v1/*` endpoints — `{ error: { message, type, param, code } }`
+- Provider abstraction router (`src/lib/providers/index.js`) — picks provider by model ID prefix, unified interface for all backends
+- **Bedrock provider** (`src/lib/providers/bedrock.js`) — AWS SDK, Converse/ConverseStream API, request/response translation
+- **Anthropic provider** (`src/lib/providers/anthropic.js`) — native `fetch()`, Messages API, request/response translation
+- **Generic OpenAI-compatible provider** (`src/lib/providers/openai-compatible.js`) — native `fetch()`, almost pure proxy, minimal translation
+- `POST /v1/chat/completions` endpoint — accepts OpenAI request format, routes to provider by model prefix, returns OpenAI response format
+- Streaming support for `/v1/chat/completions` — SSE `data: {...}` chunks with `[DONE]` terminator, matching OpenAI format across all providers
+- `GET /v1/models` endpoint — aggregates model lists from all configured providers
+- `GET /v1/models/{model}` endpoint — returns details for a specific model
+- Config.json updates: `providers` array with `id`, `type`, `credentials`, `models` mapping per provider
+- `@aws-sdk/client-bedrock-runtime` dependency
+- Model request/response translation layers per provider:
+  - OpenAI `messages[]` ↔ provider-native message format
+  - OpenAI `usage` ↔ provider-native usage/tokens
+  - OpenAI `choices[]` ↔ provider-native response content
+- Provider health check / error propagation — Bedrock/AWS errors → OpenAI error format, Anthropic errors → OpenAI error format
+
+### Must NOT have (guardrails, anti-slop, scope boundaries)
+- Legacy `/v1/completions` endpoint (non-chat) — all providers are chat-native
+- `/v1/embeddings` — out of scope, no provider embedding config
+- `/v1/images/*`, `/v1/audio/*`, `/v1/files/*`, `/v1/assistants/*` — no provider equivalent
+- JWT access token support — existing opaque tokens only
+- CORS headers on `/v1/*` — not needed for server-to-server client calls
+- Logging of message content or prompts — PII guardrail
+- AWS credentials or API keys hardcoded in source files — only in config.json
+- Provider auto-discovery or model listing from provider APIs — static config only
+- Request/response transformation caching — stateless per-request translation only
+- Custom inference parameters beyond `temperature`, `max_tokens`, `top_p`, `stop` — keep translation minimal
+- Provider fallback / retry logic — single provider per request, no failover
+
+## Verification strategy
+> Zero human intervention - all verification is agent-executed.
+- Test decision: tests-after + agent-executed QA via curl/httpie against running dev server
+- Evidence: `.omo/evidence/task-<N>-v1-api-multi-provider.<ext>`
+
+## Execution strategy
+### Parallel execution waves
+> Target 5-8 todos per wave.
+
+**Wave 1: Foundation** — Config, dependency, bearer helper, provider abstraction skeleton
+**Wave 2: Provider Implementations** — Bedrock, Anthropic, generic OpenAI-compatible providers
+**Wave 3: API Endpoints** — Chat completions (non-streaming), models list/retrieve
+**Wave 4: Streaming & Final QA** — SSE streaming, full integration testing, docs
+
+### Dependency matrix
+| Todo | Depends on | Blocks | Can parallelize with |
+| --- | --- | --- | --- |
+| 1 | — | 2,3,4,5 | — |
+| 2 | 1 | 6,7,8,9 | — |
+| 3 | 1 | 6,7,8,9 | 2 |
+| 4 | 1 | 10,11 | 2,3 |
+| 5 | 1 | 10,11 | 2,3,4 |
+| 6 | 2,3,4 | 10,11 | 7,8,9 |
+| 7 | 2,3,4 | 10,11 | 6,8,9 |
+| 8 | 2,3,4 | 10,11 | 6,7,9 |
+| 9 | 2,3,4 | 10,11 | 6,7,8 |
+| 10 | 6,7,8,9 | 12 | — |
+| 11 | 6,7,8,9 | 12 | 10 |
+| 12 | 10,11 | 13 | — |
+| 13 | 12 | — | — |
+
+## Todos
+
+- [x] 1. Update config.json.example with multi-provider settings
+  What to do / Must NOT do: Replace existing `aws` section (if any) with `providers` array. Each provider object has: `id` (string, e.g. "bedrock", "anthropic", "local"), `type` ("bedrock" | "anthropic" | "openai-compatible"), `enabled` (boolean), and provider-specific config. For Bedrock: `region`, `credentials` { `accessKeyId`, `secretAccessKey` }. For Anthropic: `apiKey`, `baseUrl` (optional, defaults to https://api.anthropic.com/v1). For generic: `baseUrl`. All providers have `models` object mapping OpenAI model IDs to provider model IDs. Must NOT include real credentials in example — use placeholders. Must NOT remove existing baseUrl/database/password sections.
+  Parallelization: Wave 1 | Blocked by: — | Blocks: 2,3,4,5
+  References: `config.json.example:1-32`, `src/lib/config.js:1-19`
+  Acceptance criteria: `cat config.json.example | grep -q providers && cat config.json.example | grep -q bedrock && cat config.json.example | grep -q anthropic`
+  QA scenarios: Happy: valid JSON parses. Failure: invalid JSON breaks parse.
+  No commit — user handles all commits manually
+
+- [x] 2. Install AWS Bedrock Runtime SDK dependency
+  What to do / Must NOT do: `pnpm add @aws-sdk/client-bedrock-runtime`. Must NOT install full `@aws-sdk/client-bedrock` or unrelated AWS packages. Must ensure package is recorded in package.json.
+  Parallelization: Wave 1 | Blocked by: 1 | Blocks: 6,7,8,9
+  References: `package.json`, pnpm-workspace.yaml (native builds)
+  Acceptance criteria: `node -e "import('@aws-sdk/client-bedrock-runtime').then(m => console.log(typeof m.BedrockRuntimeClient))"` prints "function"
+  QA scenarios: Happy: SDK imports successfully. Failure: missing dependency throws MODULE_NOT_FOUND.
+  No commit — user handles all commits manually
+
+- [x] 3. Create Bearer token validation helper
+  What to do / Must NOT do: Create `src/lib/oauth/bearer.js` exporting `validateBearerToken(request, requiredScope)` which: (a) extracts `Authorization: Bearer <token>` header, (b) looks up `access_token:{token}` in Redis, (c) parses JSON metadata, (d) checks if expired, (e) checks if `scope` contains `requiredScope`, (f) returns `{ valid: true, clientId, userId, scope }` or `{ valid: false, error, status }`. Must NOT accept tokens from query strings. Must NOT accept JWT tokens differently from opaque tokens. Must use constant-time comparison for token prefix if any.
+  Parallelization: Wave 1 | Blocked by: 1 | Blocks: 10,11
+  References: `src/lib/redis.js:1-28`, `src/routes/oauth/token/+server.js:137-158` (token storage format), RFC 6750
+  Acceptance criteria: `node -e "import('./src/lib/oauth/bearer.js').then(m => console.log(typeof m.validateBearerToken))"` prints "function"
+  QA scenarios: Happy: Valid active token with correct scope returns valid=true. Failure: Missing header returns 401. Invalid token returns 401. Expired token returns 401. Wrong scope returns 403.
+  No commit — user handles all commits manually
+
+- [x] 4. Create provider abstraction router
+  What to do / Must NOT do: Create `src/lib/providers/index.js` exporting: `getProvider(modelId)` which parses `providerSlug/localModelId` prefix (e.g. "bedrock/claude-3-sonnet" → bedrock provider, "anthropic/claude-3-5-sonnet" → anthropic provider), `listProviders()` returning enabled providers, `listModels()` aggregating all provider model mappings. Must validate that model ID contains exactly one `/` separator. Must return clear error for unknown provider or unknown model. Must NOT instantiate providers on every request — cache enabled providers.
+  Parallelization: Wave 1 | Blocked by: 1 | Blocks: 10,11
+  References: `src/lib/config.js:1-19`, `config.json` provider structure
+  Acceptance criteria: `node -e "import('./src/lib/providers/index.js').then(m => m.getProvider('bedrock/test').then(p => console.log(p.id)))"` prints "bedrock"
+  QA scenarios: Happy: Valid model ID resolves to correct provider. Failure: Missing prefix returns clear error. Unknown provider returns clear error. Disabled provider returns clear error.
+  No commit — user handles all commits manually
+
+- [x] 5. Create generic OpenAI-compatible provider
+  What to do / Must NOT do: Create `src/lib/providers/openai-compatible.js` implementing the provider interface: `chat(messages, modelId, options)` and `chatStream(messages, modelId, options)` using native `fetch()` to provider's `baseUrl`. Request body is nearly pass-through OpenAI format. Response is nearly pass-through OpenAI format — minimal translation. Must handle `baseUrl` with or without trailing `/v1`. Must pass `Authorization: Bearer <apiKey>` if configured. Must set `Content-Type: application/json`. Must handle SSE streaming response. Must NOT modify message content. Must NOT log request body.
+  Parallelization: Wave 1 | Blocked by: 1 | Blocks: 10,11
+  References: `src/lib/providers/index.js`, OpenAI chat completions spec, native fetch() API
+  Acceptance criteria: `node -e "import('./src/lib/providers/openai-compatible.js').then(m => console.log(typeof m.chat))"` prints "function"
+  QA scenarios: Happy: Provider initializes with valid config. Failure: Invalid baseUrl throws clear error. Missing API key (if required) throws clear error.
+  No commit — user handles all commits manually
+
+- [x] 6. Create Bedrock provider module
+  What to do / Must NOT do: Create `src/lib/providers/bedrock.js` implementing provider interface: `chat(messages, modelId, options)` and `chatStream(messages, modelId, options)`. Use `@aws-sdk/client-bedrock-runtime` with `ConverseCommand` / `ConverseStreamCommand`. Translate OpenAI `messages[]` to Bedrock `messages[]` with `content` blocks (text only). Translate Bedrock response to OpenAI format: `id` (generated), `object: "chat.completion"`, `created` (unix timestamp), `model` (OpenAI model ID), `choices[].message.role/content`, `usage.prompt_tokens/completion_tokens/total_tokens`. For streaming: translate `ConverseStream` events to OpenAI `chat.completion.chunk` SSE format. Must NOT create AWS client on every request — use singleton per provider config. Must handle AWS region and credentials from config. Must NOT expose AWS credentials in error messages.
+  Parallelization: Wave 2 | Blocked by: 2,3,4 | Blocks: 10,11
+  References: `src/lib/providers/index.js`, `@aws-sdk/client-bedrock-runtime` docs, Bedrock Converse API docs, `config.json`
+  Acceptance criteria: `node -e "import('./src/lib/providers/bedrock.js').then(m => console.log(typeof m.chat))"` prints "function"
+  QA scenarios: Happy: Client initializes with valid config. Failure: Invalid AWS credentials throws clear error. Missing model mapping throws clear error. Invalid region throws clear error.
+  No commit — user handles all commits manually
+
+- [x] 7. Create Anthropic provider module
+  What to do / Must NOT do: Create `src/lib/providers/anthropic.js` implementing provider interface: `chat(messages, modelId, options)` and `chatStream(messages, modelId, options)` using native `fetch()` to `https://api.anthropic.com/v1/messages` (or configured `baseUrl`). Translate OpenAI request to Anthropic Messages API format: `model` → provider model ID, `messages[]` → `messages[]` (map `role: system` to top-level `system` param), `max_tokens` required (default to 4096 if missing), `temperature` and `top_p` passed through, `stream` boolean. Translate Anthropic response to OpenAI format: `content[].text` → `choices[].message.content`, `usage.input_tokens/output_tokens` → `usage.prompt_tokens/completion_tokens`. For streaming: read SSE from Anthropic, translate `content_block_delta` events to OpenAI `chat.completion.chunk` format. Must pass `x-api-key` and `anthropic-version: 2023-06-01` headers. Must NOT log request body. Must handle Anthropic-specific errors (e.g. `overloaded_error`).
+  Parallelization: Wave 2 | Blocked by: 2,3,4 | Blocks: 10,11
+  References: `src/lib/providers/index.js`, Anthropic Messages API docs, native fetch() API
+  Acceptance criteria: `node -e "import('./src/lib/providers/anthropic.js').then(m => console.log(typeof m.chat))"` prints "function"
+  QA scenarios: Happy: Provider initializes with valid config. Failure: Invalid API key throws clear error. Missing model mapping throws clear error. Malformed response handled gracefully.
+  No commit — user handles all commits manually
+
+- [x] 8. Implement POST /v1/chat/completions (non-streaming)
+  What to do / Must NOT do: Create `src/routes/v1/chat/completions/+server.js` with POST handler. Steps: (1) validate Bearer token + llm scope via bearer helper, (2) parse JSON body, (3) resolve provider from model ID prefix via `getProvider(model)`, (4) call provider.chat() with translated params, (5) return OpenAI-format JSON response. Must NOT support `stream: true` in this todo — handled in todo 12. Must NOT log message content. Must return 400 for unsupported parameters with OpenAI error format. Must handle provider errors and translate to OpenAI error format (502 for provider failure).
+  Parallelization: Wave 3 | Blocked by: 3,4,5,6,7 | Blocks: 12
+  References: `src/lib/oauth/bearer.js`, `src/lib/providers/index.js`, all provider modules, OpenAI chat completions spec
+  Acceptance criteria: `curl -s -X POST http://localhost:3000/v1/chat/completions -H "Authorization: Bearer VALID_TOKEN" -H "Content-Type: application/json" -d '{"model":"bedrock/claude-3-sonnet","messages":[{"role":"user","content":"Hello"}]}' | jq '.object'` returns "chat.completion"
+  QA scenarios: Happy: Valid token + valid request returns OpenAI-format response with choices and usage. Failure: Missing auth returns 401. Wrong scope returns 403. Invalid model returns 400 (unknown provider prefix or unknown model). Bad request body returns 400. Provider error returns 502 with OpenAI error format.
+  No commit — user handles all commits manually
+
+- [x] 9. Implement GET /v1/models and GET /v1/models/{model}
+  What to do / Must NOT do: Create `src/routes/v1/models/+server.js` with GET handler returning `{ object: "list", data: [...] }` where each model has `id` (full prefixed ID), `object: "model"`, `created` (static or current timestamp), `owned_by: "<provider-id>"`. Create `src/routes/v1/models/[model]/+server.js` with GET handler returning single model object. Both must validate Bearer token + llm scope. Data sourced from all enabled providers' `models` config. Must NOT query provider APIs for model list — static config only. Must return 404 for unknown model IDs.
+  Parallelization: Wave 3 | Blocked by: 3,4,5,6,7 | Blocks: 12
+  References: `src/lib/oauth/bearer.js`, `src/lib/providers/index.js`, OpenAI models spec
+  Acceptance criteria: `curl -s http://localhost:3000/v1/models -H "Authorization: Bearer VALID_TOKEN" | jq '.object'` returns "list"
+  QA scenarios: Happy: Valid token returns aggregated model list from all providers. Valid model ID returns model details. Failure: Missing auth returns 401. Unknown model returns 404.
+  No commit — user handles all commits manually
+
+- [x] 10. Add SSE streaming support to /v1/chat/completions
+  What to do / Must NOT do: Extend existing `+server.js` from todo 8 to handle `stream: true` in request body. Resolve provider, call `provider.chatStream()`, read async iterator, translate each chunk to OpenAI `chat.completion.chunk` format, send as SSE with `data: {...}` prefix, end with `data: [DONE]`. Must set `Content-Type: text/event-stream` and `Cache-Control: no-cache`. Must handle stream errors gracefully — send final error chunk or close connection. Must NOT buffer entire stream before sending. All three providers must produce identical SSE output format.
+  Parallelization: Wave 4 | Blocked by: 8,9 | Blocks: 13
+  References: `src/routes/v1/chat/completions/+server.js`, all provider modules, OpenAI streaming spec
+  Acceptance criteria: `curl -s -N -X POST http://localhost:3000/v1/chat/completions -H "Authorization: Bearer VALID_TOKEN" -H "Content-Type: application/json" -d '{"model":"bedrock/claude-3-sonnet","messages":[{"role":"user","content":"Hello"}],"stream":true}' | grep -q 'data: '`
+  QA scenarios: Happy: Streaming request returns SSE chunks with valid JSON deltas and [DONE]. Failure: Stream error closes connection gracefully. Invalid stream param returns 400.
+  No commit — user handles all commits manually
+
+- [x] 11. Add provider error translation to OpenAI error format
+  What to do / Must NOT do: Create `src/lib/providers/errors.js` exporting `translateProviderError(error, providerType)` which normalizes provider-specific errors into OpenAI error format: `{ error: { message, type, param, code } }`. Handle AWS SDK errors (e.g. `ThrottlingException`, `ValidationException`), Anthropic errors (e.g. `overloaded_error`, `invalid_request_error`), and HTTP errors from generic providers. Must NOT expose provider credentials or internal URLs in error messages. Must map provider error codes to reasonable OpenAI equivalents.
+  Parallelization: Wave 3 | Blocked by: 6,7 | Blocks: 8,9,10
+  References: All provider modules, OpenAI error format spec
+  Acceptance criteria: `node -e "import('./src/lib/providers/errors.js').then(m => console.log(typeof m.translateProviderError))"` prints "function"
+  QA scenarios: Happy: AWS throttling returns 429 with OpenAI error format. Anthropic overload returns 503 with OpenAI error format. Generic 401 returns 502 with OpenAI error format.
+  No commit — user handles all commits manually
+
+- [x] 12. Final integration QA and documentation
+  What to do / Must NOT do: Run dev server. Execute full flow for each provider: obtain OAuth token → call `/v1/models` → call `/v1/chat/completions` (non-streaming) → call `/v1/chat/completions` (streaming) → verify error formats. Update `docs/` with `/v1` API documentation including provider setup and model ID examples. Must NOT skip negative testing (invalid token, wrong scope, missing auth, unknown provider, unknown model). Must verify OpenAI Python SDK can connect.
+  Parallelization: Wave 4 | Blocked by: 10,11 | Blocks: —
+  References: All `/v1` endpoint files, existing OAuth endpoints, all provider modules, OpenAI Python SDK
+  Acceptance criteria: Full end-to-end flow executes successfully for at least one configured provider (token issuance to streaming chat completion).
+  QA scenarios: Happy: Complete flow with valid token for each provider type. Failure: Invalid token returns 401 on all /v1 endpoints. Missing llm scope returns 403. Unknown provider prefix returns 400. Unknown model returns 404. Provider error returns 502 with OpenAI error format.
+  No commit — user handles all commits manually
+
+## Final verification wave
+> Runs in parallel after ALL todos. ALL must APPROVE.
+- [x] F1. Plan compliance audit — Verify every must-have is implemented and every must-not-have is absent
+- [x] F2. Code quality review — Verify consistent patterns across all providers, no hardcoded credentials, no PII logging
+- [x] F3. Real manual QA — Execute full OAuth → /v1 flow end-to-end with curl for at least one provider
+- [x] F4. Scope fidelity — Confirm no /v1/embeddings, no /v1/completions, no CORS, no JWT support, no PII logging, no provider auto-discovery
+
+## Commit strategy
+- **NO COMMITS** — user handles all commits manually when ready
+- Worker makes file changes only; git commit decisions belong to user
+- User may commit everything at once, or per-todo, or not at all — their call
+
+## Success criteria
+1. `POST /v1/chat/completions` accepts OpenAI-format requests with prefixed model IDs (e.g. `bedrock/claude-3-sonnet`) and returns OpenAI-format responses (non-streaming)
+2. `POST /v1/chat/completions` with `stream: true` returns SSE with `chat.completion.chunk` format and `[DONE]` terminator
+3. `GET /v1/models` returns aggregated OpenAI-format model list from all configured providers
+4. `GET /v1/models/{model}` returns OpenAI-format model object or 404
+5. All `/v1/*` endpoints require valid Bearer token from Redis OAuth flow
+6. All `/v1/*` endpoints require `llm` scope — tokens without it return 403
+7. Invalid/missing Bearer tokens return 401 with OpenAI error format
+8. Provider errors (AWS, Anthropic, generic) return 502 with OpenAI error format
+9. Credentials and model mappings live only in config.json (not source code)
+10. No message content or prompts logged anywhere
+11. Full end-to-end flow works: OAuth login → authorize → token → /v1/chat/completions
+12. Adding a new provider requires only config.json changes (no code changes)

+ 0 - 0
docs/OAUTH-2.1-SYNTHESIS.md → docs/SYNTHESIS-OAUTH-2.1.md


+ 373 - 0
docs/V1-API.md

@@ -0,0 +1,373 @@
+# `/v1` API Documentation
+
+## Overview
+
+The `/v1` API provides an OpenAI-compatible REST interface for chat completions and model management. It is designed to be a drop-in replacement for the OpenAI API, proxying requests to configured LLM providers (AWS Bedrock, Anthropic, OpenAI-compatible endpoints).
+
+All `/v1` endpoints require Bearer token authentication obtained through the OAuth 2.1 authorization flow.
+
+## Authentication
+
+Every request to `/v1` must include an `Authorization` header with a valid Bearer access token:
+
+```
+Authorization: Bearer <access_token>
+```
+
+### Obtaining a Token
+
+Tokens are issued by the local OAuth 2.1 authorization server. The full flow is:
+
+1. **Register a client**
+   ```bash
+   curl -X POST http://localhost:3000/oauth/register \
+     -H "Content-Type: application/json" \
+     -d '{"client_name":"my-app","redirect_uris":["http://localhost/callback"]}'
+   ```
+
+2. **Login** (sets session cookie)
+   ```bash
+   curl -X POST http://localhost:3000/oauth/login \
+     -H "Content-Type: application/x-www-form-urlencoded" \
+     -d "email=admin@delete.me&password=deleteme"
+   ```
+
+3. **Authorize** (user consent)
+   ```bash
+   curl -G http://localhost:3000/oauth/authorize \
+     -b cookies.txt \
+     -d "response_type=code" \
+     -d "client_id=<client_id>" \
+     -d "redirect_uri=http://localhost/callback" \
+     -d "scope=llm" \
+     -d "code_challenge=<pkce_challenge>" \
+     -d "code_challenge_method=S256"
+   ```
+
+4. **Consent**
+   ```bash
+   curl -X POST http://localhost:3000/oauth/consent \
+     -b cookies.txt \
+     -H "Content-Type: application/x-www-form-urlencoded" \
+     -d "client_id=<client_id>&consent=true"
+   ```
+
+5. **Token exchange**
+   ```bash
+   curl -X POST http://localhost:3000/oauth/token \
+     -H "Content-Type: application/x-www-form-urlencoded" \
+     -d "grant_type=authorization_code" \
+     -d "code=<auth_code>" \
+     -d "redirect_uri=http://localhost/callback" \
+     -d "client_id=<client_id>" \
+     -d "code_verifier=<pkce_verifier>"
+   ```
+
+The response contains `access_token` which is used for all `/v1` requests. Tokens are stored in Redis and validated on every request.
+
+## Endpoints
+
+### `POST /v1/chat/completions`
+
+Creates a chat completion for the provided messages using the specified model.
+
+#### Request Headers
+
+| Header | Required | Description |
+|--------|----------|-------------|
+| `Authorization` | Yes | `Bearer <access_token>` |
+| `Content-Type` | Yes | `application/json` |
+
+#### Request Body
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `model` | string | Yes | Model ID in `provider/localModelId` format (e.g., `bedrock/claude-3-sonnet`) |
+| `messages` | array | Yes | Array of message objects `{role, content}` |
+| `stream` | boolean | No | If `true`, returns a Server-Sent Events stream |
+| `temperature` | number | No | Sampling temperature (0-2) |
+| `max_tokens` | integer | No | Maximum tokens to generate |
+| `top_p` | number | No | Nucleus sampling parameter |
+| `stop` | string/array | No | Stop sequence(s) |
+
+#### Example Request (Non-streaming)
+
+```bash
+curl -X POST http://localhost:3000/v1/chat/completions \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "model": "bedrock/claude-3-sonnet",
+    "messages": [{"role": "user", "content": "Hello!"}],
+    "temperature": 0.7,
+    "max_tokens": 256
+  }'
+```
+
+#### Example Request (Streaming)
+
+```bash
+curl -X POST http://localhost:3000/v1/chat/completions \
+  -H "Authorization: Bearer <token>" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "model": "anthropic/claude-3-5-sonnet",
+    "messages": [{"role": "user", "content": "Hello!"}],
+    "stream": true
+  }'
+```
+
+When `stream: true`, the response is a `text/event-stream` where each chunk is a JSON object prefixed with `data: `. The stream ends with `data: [DONE]`.
+
+#### Response Format (Non-streaming)
+
+```json
+{
+  "id": "chatcmpl-...",
+  "object": "chat.completion",
+  "created": 1234567890,
+  "model": "bedrock/claude-3-sonnet",
+  "choices": [
+    {
+      "index": 0,
+      "message": {
+        "role": "assistant",
+        "content": "Hello! How can I help you today?"
+      },
+      "finish_reason": "stop"
+    }
+  ],
+  "usage": {
+    "prompt_tokens": 10,
+    "completion_tokens": 20,
+    "total_tokens": 30
+  }
+}
+```
+
+The exact response shape depends on the underlying provider; the proxy passes through the provider's response after normalizing errors.
+
+---
+
+### `GET /v1/models`
+
+Lists all available models across enabled providers.
+
+#### Request Headers
+
+| Header | Required | Description |
+|--------|----------|-------------|
+| `Authorization` | Yes | `Bearer <access_token>` |
+
+#### Example Request
+
+```bash
+curl http://localhost:3000/v1/models \
+  -H "Authorization: Bearer <token>"
+```
+
+#### Response Format
+
+```json
+{
+  "object": "list",
+  "data": [
+    {
+      "id": "bedrock/claude-3-sonnet",
+      "object": "model",
+      "created": 1783452810,
+      "owned_by": "bedrock"
+    },
+    {
+      "id": "anthropic/claude-3-5-sonnet",
+      "object": "model",
+      "created": 1783452810,
+      "owned_by": "anthropic"
+    }
+  ]
+}
+```
+
+---
+
+### `GET /v1/models/{model}`
+
+Retrieves details for a single model.
+
+#### Request Headers
+
+| Header | Required | Description |
+|--------|----------|-------------|
+| `Authorization` | Yes | `Bearer <access_token>` |
+
+#### URL Parameter
+
+| Parameter | Description |
+|-----------|-------------|
+| `model` | Full model ID. **Must URL-encode the slash** (e.g., `bedrock%2Fclaude-3-sonnet`) |
+
+#### Example Request
+
+```bash
+curl http://localhost:3000/v1/models/bedrock%2Fclaude-3-sonnet \
+  -H "Authorization: Bearer <token>"
+```
+
+#### Response Format
+
+```json
+{
+  "id": "bedrock/claude-3-sonnet",
+  "object": "model",
+  "created": 1783452819,
+  "owned_by": "bedrock"
+}
+```
+
+#### Important Note on Model IDs
+
+Model IDs use the format `provider/localModelId` and contain a slash. Because SvelteKit route parameters match a single path segment, the slash **must be URL-encoded** (`%2F`) in the request URL:
+
+- ✅ `http://localhost:3000/v1/models/bedrock%2Fclaude-3-sonnet`
+- ❌ `http://localhost:3000/v1/models/bedrock/claude-3-sonnet` (will 404)
+
+The model IDs returned by `GET /v1/models` are the raw IDs; clients must encode them before use in the detail endpoint.
+
+## Provider Configuration
+
+Providers are configured in `config.json` under the `providers` array. Only providers with `enabled: true` are exposed through the API.
+
+### Example `config.json`
+
+```json
+{
+  "providers": [
+    {
+      "id": "bedrock",
+      "type": "bedrock",
+      "enabled": true,
+      "region": "us-east-1",
+      "credentials": {
+        "accessKeyId": "YOUR_ACCESS_KEY",
+        "secretAccessKey": "YOUR_SECRET_KEY"
+      },
+      "models": {
+        "claude-3-sonnet": "anthropic.claude-3-sonnet-20240229-v1:0"
+      }
+    },
+    {
+      "id": "anthropic",
+      "type": "anthropic",
+      "enabled": true,
+      "apiKey": "YOUR_ANTHROPIC_API_KEY",
+      "models": {
+        "claude-3-5-sonnet": "claude-3-5-sonnet-20241022"
+      }
+    },
+    {
+      "id": "local",
+      "type": "openai-compatible",
+      "enabled": false,
+      "baseUrl": "http://localhost:11434/v1",
+      "models": {
+        "llama3.1": "llama3.1:latest"
+      }
+    }
+  ]
+}
+```
+
+### Provider Types
+
+| Type | Description | Required Config |
+|------|-------------|-----------------|
+| `bedrock` | AWS Bedrock | `region`, `credentials.accessKeyId`, `credentials.secretAccessKey` |
+| `anthropic` | Anthropic API | `apiKey` |
+| `openai-compatible` | Generic OpenAI-compatible endpoint | `baseUrl` |
+
+### Model ID Format
+
+The `models` object maps a **local model key** to the provider's native model identifier:
+
+```json
+"models": {
+  "claude-3-sonnet": "anthropic.claude-3-sonnet-20240229-v1:0"
+}
+```
+
+This model is then addressed via the API as `bedrock/claude-3-sonnet`.
+
+## Error Format
+
+All errors are returned in OpenAI-compatible JSON format:
+
+```json
+{
+  "error": {
+    "message": "Human-readable error description",
+    "type": "invalid_request_error",
+    "param": null,
+    "code": "invalid_model"
+  }
+}
+```
+
+### Error Types
+
+| `type` | Meaning |
+|--------|---------|
+| `invalid_request_error` | Bad request (missing params, invalid model, malformed JSON) |
+| `invalid_token` | Authentication failed (bad or expired token) |
+| `insufficient_scope` | Token valid but missing required scope |
+| `authentication_error` | Provider rejected credentials |
+| `rate_limit_error` | Rate limit hit |
+| `api_error` | Provider or internal server error |
+
+### Common HTTP Status Codes
+
+| Status | Scenario |
+|--------|----------|
+| `200` | Success |
+| `400` | Invalid request (bad JSON, missing model, missing messages, unknown model) |
+| `401` | Missing or invalid Bearer token |
+| `403` | Valid token but insufficient scope |
+| `404` | Model not found |
+| `429` | Rate limited by provider |
+| `500` | Internal server error (Redis failure, provider init failure) |
+| `502` | Provider service error (connection issue, bad credentials, etc.) |
+| `503` | Provider overloaded |
+
+## QA Evidence
+
+The following integration tests were executed against `http://localhost:3000` with a valid Bearer token seeded in Redis.
+
+### Error Case Tests
+
+| # | Test | Expected | Actual | Result |
+|---|------|----------|--------|--------|
+| 1 | Missing auth header on `/v1/models` | `401` | `401` | ✅ |
+| 2 | Invalid token on `/v1/models` | `401` | `401` | ✅ |
+| 3 | Invalid model in chat completions | `400` | `400` | ✅ |
+| 4 | Missing `model` field | `400` | `400` | ✅ |
+| 5 | Missing `messages` field | `400` | `400` | ✅ |
+| 6 | Invalid JSON body | `400` | `400` | ✅ |
+
+### Positive Tests
+
+| # | Test | Status | Result |
+|---|------|--------|--------|
+| 7 | List models with valid token | `200` | ✅ |
+| 8 | Get model details (URL-encoded slash) | `200` | ✅ |
+| 9 | Get model details (unknown model) | `404` | ✅ |
+| 10 | Chat completions with valid request | `502` | ✅ (provider rejects placeholder credentials; error correctly translated to OpenAI format) |
+
+### Server Startup
+
+- `npm run dev` started successfully on port 3000
+- No new errors in startup logs
+- Redis connected successfully
+
+## Related Documentation
+
+- [OAuth 2.1 Synthesis](./OAUTH-2.1-SYNTHESIS.md) — Authorization server metadata and security requirements
+- [Project README](../README.md) — Setup, configuration, and deployment

+ 1 - 0
package.json

@@ -27,6 +27,7 @@
 		"vite": "^8.1.2"
 	},
 	"dependencies": {
+		"@aws-sdk/client-bedrock-runtime": "^3.1080.0",
 		"argon2": "^0.44.0",
 		"better-sqlite3": "^12.11.1",
 		"ioredis": "^5.11.1"

+ 315 - 2
pnpm-lock.yaml

@@ -8,6 +8,9 @@ importers:
 
   .:
     dependencies:
+      '@aws-sdk/client-bedrock-runtime':
+        specifier: ^3.1080.0
+        version: 3.1080.0
       argon2:
         specifier: ^0.44.0
         version: 0.44.0
@@ -64,6 +67,82 @@ packages:
     resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
     engines: {node: '>=10'}
 
+  '@aws-sdk/client-bedrock-runtime@3.1080.0':
+    resolution: {integrity: sha512-UgvAj98mv8CweVG37xt5Qah/OVnVTswbvCTZcsSNSD9JyC1q3cnr4od0eeiJRnkIJkzB2FFfRZ3bbumxxyk+aQ==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/core@3.974.28':
+    resolution: {integrity: sha512-4/1DtLwgLqzIg2uFzkFaFjMQHhhhwHIZN4PfziIVqXYX7koO78omuchQlLHyzQBw80l255dtmTA/J4W5yhb7zw==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-env@3.972.54':
+    resolution: {integrity: sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-http@3.972.56':
+    resolution: {integrity: sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-ini@3.972.61':
+    resolution: {integrity: sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-login@3.972.60':
+    resolution: {integrity: sha512-Ak6OOrCbXvACyxLFIP1mcS+JTLS9ZpW1ZqyBtqu6axvdpsbG1gVNhUlAfQq8TK/gar/h2w35LrzlQU0PcUzJpw==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-node@3.972.63':
+    resolution: {integrity: sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-process@3.972.54':
+    resolution: {integrity: sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-sso@3.972.60':
+    resolution: {integrity: sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-web-identity@3.972.60':
+    resolution: {integrity: sha512-q2rJSQ/AMjemUS18OtQqczqSo2R6VjOCLmLVmLVcdrP5/AKoj632lGCrMUWQ6EgnaLMEHbq0UO4mWh/u6gjPhA==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/eventstream-handler-node@3.972.25':
+    resolution: {integrity: sha512-df7HN1ozwMrB9+59re9PM7tSLxLAcheMWc5u/KyfCPCAWtN/vP7y7RTUZOy48uT1K9MESisVeOPPzF3O1AW01A==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/middleware-eventstream@3.972.21':
+    resolution: {integrity: sha512-HvLgDnxBLaHi9E5K++6Vuk+1+qqn7Pmn8zrlzd+NXH3jBzwujnuzZtAR9WHPkbUGPO92FkoQWj/M1IsdxTlBmQ==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/middleware-websocket@3.972.36':
+    resolution: {integrity: sha512-BD8tYKNAc8dyN7q4NU1wsA1ZgRTBAjbqH04I5SfoqJaOmwlUab6AI7GRapSt8fxf9sq3GyAhioK0qHLCTnuZ9A==}
+    engines: {node: '>= 14.0.0'}
+
+  '@aws-sdk/nested-clients@3.997.28':
+    resolution: {integrity: sha512-1oG/mM3jmE2M3kad2zWJS6IKIY8hjRV4l5kAgg+xTQdLMTtehhcSucL/y4WqQpcHmQwi6+gRK8E46GYl1NBW9Q==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/signature-v4-multi-region@3.996.38':
+    resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/token-providers@3.1080.0':
+    resolution: {integrity: sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/types@3.973.15':
+    resolution: {integrity: sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/xml-builder@3.972.33':
+    resolution: {integrity: sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws/lambda-invoke-store@0.3.0':
+    resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
+    engines: {node: '>=18.0.0'}
+
   '@emnapi/core@1.11.1':
     resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
 
@@ -439,6 +518,30 @@ packages:
   '@sinclair/typebox@0.31.28':
     resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==}
 
+  '@smithy/core@3.29.1':
+    resolution: {integrity: sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A==}
+    engines: {node: '>=18.0.0'}
+
+  '@smithy/credential-provider-imds@4.4.6':
+    resolution: {integrity: sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ==}
+    engines: {node: '>=18.0.0'}
+
+  '@smithy/fetch-http-handler@5.6.3':
+    resolution: {integrity: sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ==}
+    engines: {node: '>=18.0.0'}
+
+  '@smithy/node-http-handler@4.9.3':
+    resolution: {integrity: sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg==}
+    engines: {node: '>=18.0.0'}
+
+  '@smithy/signature-v4@5.6.2':
+    resolution: {integrity: sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g==}
+    engines: {node: '>=18.0.0'}
+
+  '@smithy/types@4.15.1':
+    resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==}
+    engines: {node: '>=18.0.0'}
+
   '@sqlite.org/sqlite-wasm@3.48.0-build4':
     resolution: {integrity: sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==}
     hasBin: true
@@ -726,6 +829,9 @@ packages:
   bl@4.1.0:
     resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
 
+  bowser@2.14.1:
+    resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
+
   buffer@5.7.1:
     resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
 
@@ -1339,6 +1445,179 @@ snapshots:
 
   '@alloc/quick-lru@5.2.0': {}
 
+  '@aws-sdk/client-bedrock-runtime@3.1080.0':
+    dependencies:
+      '@aws-sdk/core': 3.974.28
+      '@aws-sdk/credential-provider-node': 3.972.63
+      '@aws-sdk/eventstream-handler-node': 3.972.25
+      '@aws-sdk/middleware-eventstream': 3.972.21
+      '@aws-sdk/middleware-websocket': 3.972.36
+      '@aws-sdk/token-providers': 3.1080.0
+      '@aws-sdk/types': 3.973.15
+      '@smithy/core': 3.29.1
+      '@smithy/fetch-http-handler': 5.6.3
+      '@smithy/node-http-handler': 4.9.3
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/core@3.974.28':
+    dependencies:
+      '@aws-sdk/types': 3.973.15
+      '@aws-sdk/xml-builder': 3.972.33
+      '@aws/lambda-invoke-store': 0.3.0
+      '@smithy/core': 3.29.1
+      '@smithy/signature-v4': 5.6.2
+      '@smithy/types': 4.15.1
+      bowser: 2.14.1
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-env@3.972.54':
+    dependencies:
+      '@aws-sdk/core': 3.974.28
+      '@aws-sdk/types': 3.973.15
+      '@smithy/core': 3.29.1
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-http@3.972.56':
+    dependencies:
+      '@aws-sdk/core': 3.974.28
+      '@aws-sdk/types': 3.973.15
+      '@smithy/core': 3.29.1
+      '@smithy/fetch-http-handler': 5.6.3
+      '@smithy/node-http-handler': 4.9.3
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-ini@3.972.61':
+    dependencies:
+      '@aws-sdk/core': 3.974.28
+      '@aws-sdk/credential-provider-env': 3.972.54
+      '@aws-sdk/credential-provider-http': 3.972.56
+      '@aws-sdk/credential-provider-login': 3.972.60
+      '@aws-sdk/credential-provider-process': 3.972.54
+      '@aws-sdk/credential-provider-sso': 3.972.60
+      '@aws-sdk/credential-provider-web-identity': 3.972.60
+      '@aws-sdk/nested-clients': 3.997.28
+      '@aws-sdk/types': 3.973.15
+      '@smithy/core': 3.29.1
+      '@smithy/credential-provider-imds': 4.4.6
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-login@3.972.60':
+    dependencies:
+      '@aws-sdk/core': 3.974.28
+      '@aws-sdk/nested-clients': 3.997.28
+      '@aws-sdk/types': 3.973.15
+      '@smithy/core': 3.29.1
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-node@3.972.63':
+    dependencies:
+      '@aws-sdk/credential-provider-env': 3.972.54
+      '@aws-sdk/credential-provider-http': 3.972.56
+      '@aws-sdk/credential-provider-ini': 3.972.61
+      '@aws-sdk/credential-provider-process': 3.972.54
+      '@aws-sdk/credential-provider-sso': 3.972.60
+      '@aws-sdk/credential-provider-web-identity': 3.972.60
+      '@aws-sdk/types': 3.973.15
+      '@smithy/core': 3.29.1
+      '@smithy/credential-provider-imds': 4.4.6
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-process@3.972.54':
+    dependencies:
+      '@aws-sdk/core': 3.974.28
+      '@aws-sdk/types': 3.973.15
+      '@smithy/core': 3.29.1
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-sso@3.972.60':
+    dependencies:
+      '@aws-sdk/core': 3.974.28
+      '@aws-sdk/nested-clients': 3.997.28
+      '@aws-sdk/token-providers': 3.1080.0
+      '@aws-sdk/types': 3.973.15
+      '@smithy/core': 3.29.1
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-web-identity@3.972.60':
+    dependencies:
+      '@aws-sdk/core': 3.974.28
+      '@aws-sdk/nested-clients': 3.997.28
+      '@aws-sdk/types': 3.973.15
+      '@smithy/core': 3.29.1
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/eventstream-handler-node@3.972.25':
+    dependencies:
+      '@aws-sdk/types': 3.973.15
+      '@smithy/core': 3.29.1
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/middleware-eventstream@3.972.21':
+    dependencies:
+      '@aws-sdk/types': 3.973.15
+      '@smithy/core': 3.29.1
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/middleware-websocket@3.972.36':
+    dependencies:
+      '@aws-sdk/core': 3.974.28
+      '@aws-sdk/types': 3.973.15
+      '@smithy/core': 3.29.1
+      '@smithy/fetch-http-handler': 5.6.3
+      '@smithy/signature-v4': 5.6.2
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/nested-clients@3.997.28':
+    dependencies:
+      '@aws-sdk/core': 3.974.28
+      '@aws-sdk/signature-v4-multi-region': 3.996.38
+      '@aws-sdk/types': 3.973.15
+      '@smithy/core': 3.29.1
+      '@smithy/fetch-http-handler': 5.6.3
+      '@smithy/node-http-handler': 4.9.3
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/signature-v4-multi-region@3.996.38':
+    dependencies:
+      '@aws-sdk/types': 3.973.15
+      '@smithy/signature-v4': 5.6.2
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/token-providers@3.1080.0':
+    dependencies:
+      '@aws-sdk/core': 3.974.28
+      '@aws-sdk/nested-clients': 3.997.28
+      '@aws-sdk/types': 3.973.15
+      '@smithy/core': 3.29.1
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/types@3.973.15':
+    dependencies:
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws-sdk/xml-builder@3.972.33':
+    dependencies:
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@aws/lambda-invoke-store@0.3.0': {}
+
   '@emnapi/core@1.11.1':
     dependencies:
       '@emnapi/wasi-threads': 1.2.2
@@ -1625,6 +1904,39 @@ snapshots:
 
   '@sinclair/typebox@0.31.28': {}
 
+  '@smithy/core@3.29.1':
+    dependencies:
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@smithy/credential-provider-imds@4.4.6':
+    dependencies:
+      '@smithy/core': 3.29.1
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@smithy/fetch-http-handler@5.6.3':
+    dependencies:
+      '@smithy/core': 3.29.1
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@smithy/node-http-handler@4.9.3':
+    dependencies:
+      '@smithy/core': 3.29.1
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@smithy/signature-v4@5.6.2':
+    dependencies:
+      '@smithy/core': 3.29.1
+      '@smithy/types': 4.15.1
+      tslib: 2.8.1
+
+  '@smithy/types@4.15.1':
+    dependencies:
+      tslib: 2.8.1
+
   '@sqlite.org/sqlite-wasm@3.48.0-build4': {}
 
   '@standard-schema/spec@1.1.0': {}
@@ -1863,6 +2175,8 @@ snapshots:
       inherits: 2.0.4
       readable-stream: 3.6.2
 
+  bowser@2.14.1: {}
+
   buffer@5.7.1:
     dependencies:
       base64-js: 1.5.1
@@ -2383,8 +2697,7 @@ snapshots:
 
   totalist@3.0.1: {}
 
-  tslib@2.8.1:
-    optional: true
+  tslib@2.8.1: {}
 
   tunnel-agent@0.6.0:
     dependencies:

+ 196 - 0
scripts/get-token.sh

@@ -0,0 +1,196 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Get an OAuth 2.1 access token through the full flow:
+# 1. Register a client (if needed)
+# 2. Login as admin
+# 3. Generate PKCE
+# 4. Authorize with consent
+# 5. Exchange code for token
+# 6. Print the access token
+
+BASE_URL="${BASE_URL:-http://localhost:3000}"
+REDIRECT_URI="${REDIRECT_URI:-http://localhost/callback}"
+ADMIN_EMAIL="${ADMIN_EMAIL:-admin@delete.me}"
+ADMIN_PASSWORD="${ADMIN_PASSWORD:-deleteme}"
+SCOPE="${SCOPE:-llm}"
+COOKIE_FILE="${COOKIE_FILE:-cookies.txt}"
+
+# Colors for output
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m' # No Color
+
+# ---------------------------------------------------------------------------
+# Step 1: Register a Client
+# ---------------------------------------------------------------------------
+echo -e "${BLUE}=== Step 1: Register OAuth Client ===${NC}"
+
+REGISTER_RESPONSE=$(curl -s -X POST "${BASE_URL}/oauth/register" \
+  -H "Content-Type: application/json" \
+  -d "{
+    \"client_name\": \"get-token-script\",
+    \"redirect_uris\": [\"${REDIRECT_URI}\"],
+    \"grant_types\": [\"authorization_code\"],
+    \"response_types\": [\"code\"],
+    \"scope\": \"${SCOPE}\",
+    \"token_endpoint_auth_method\": \"client_secret_basic\"
+  }")
+
+# Extract client credentials
+CLIENT_ID=$(echo "$REGISTER_RESPONSE" | grep -o '"client_id":"[^"]*"' | cut -d'"' -f4 || true)
+CLIENT_SECRET=$(echo "$REGISTER_RESPONSE" | grep -o '"client_secret":"[^"]*"' | cut -d'"' -f4 || true)
+
+if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ]; then
+  echo -e "${RED}Failed to register client. Response:${NC}"
+  echo "$REGISTER_RESPONSE"
+  exit 1
+fi
+
+echo -e "${GREEN}Client ID:${NC} $CLIENT_ID"
+echo -e "${GREEN}Client Secret:${NC} $CLIENT_SECRET"
+
+# ---------------------------------------------------------------------------
+# Step 2: Login (Get Session Cookie)
+# ---------------------------------------------------------------------------
+echo -e "${BLUE}=== Step 2: Login ===${NC}"
+
+# Remove old cookie file if it exists
+rm -f "$COOKIE_FILE"
+
+LOGIN_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${BASE_URL}/oauth/login" \
+  -H "Content-Type: application/x-www-form-urlencoded" \
+  -d "email=${ADMIN_EMAIL}" \
+  -d "password=${ADMIN_PASSWORD}" \
+  -c "$COOKIE_FILE" \
+  -o /dev/null)
+
+HTTP_CODE=$(echo "$LOGIN_RESPONSE" | tail -n1)
+
+if [ "$HTTP_CODE" != "302" ] && [ "$HTTP_CODE" != "200" ]; then
+  echo -e "${RED}Login failed (HTTP $HTTP_CODE). Check credentials.${NC}"
+  exit 1
+fi
+
+# Verify cookie was set
+if ! grep -q "oauth_session" "$COOKIE_FILE" 2>/dev/null; then
+  echo -e "${RED}Login succeeded but no session cookie was set.${NC}"
+  exit 1
+fi
+
+echo -e "${GREEN}Login successful. Session cookie saved to ${COOKIE_FILE}${NC}"
+
+# ---------------------------------------------------------------------------
+# Step 3: Generate PKCE
+# ---------------------------------------------------------------------------
+echo -e "${BLUE}=== Step 3: Generate PKCE ===${NC}"
+
+CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-128)
+CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl enc -base64 | tr '+/' '-_' | tr -d '=')
+STATE=$(openssl rand -hex 16)
+
+echo -e "${GREEN}Code Verifier:${NC} $CODE_VERIFIER"
+echo -e "${GREEN}Code Challenge:${NC} $CODE_CHALLENGE"
+echo -e "${GREEN}State:${NC} $STATE"
+
+# ---------------------------------------------------------------------------
+# Step 4: Authorize (with auto-consent)
+# ---------------------------------------------------------------------------
+echo -e "${BLUE}=== Step 4: Authorize ===${NC}"
+
+# First call authorize - it will redirect to consent if needed, or to callback with code
+AUTHORIZE_RESPONSE=$(curl -s -G "${BASE_URL}/oauth/authorize" \
+  -b "$COOKIE_FILE" \
+  -d "response_type=code" \
+  -d "client_id=${CLIENT_ID}" \
+  -d "redirect_uri=${REDIRECT_URI}" \
+  -d "scope=${SCOPE}" \
+  -d "code_challenge=${CODE_CHALLENGE}" \
+  -d "code_challenge_method=S256" \
+  -d "state=${STATE}" \
+  -d "consent_given=1" \
+  -w "\nFINAL_URL:%{redirect_url}" \
+  -o /dev/null)
+
+FINAL_URL=$(echo "$AUTHORIZE_RESPONSE" | grep "FINAL_URL:" | cut -d':' -f2-)
+
+echo -e "${GREEN}Final redirect URL:${NC} $FINAL_URL"
+
+# Extract authorization code from the final URL
+AUTH_CODE=$(echo "$FINAL_URL" | grep -o 'code=[^&]*' | cut -d'=' -f2 || true)
+
+if [ -z "$AUTH_CODE" ]; then
+  echo -e "${RED}No authorization code found in redirect URL.${NC}"
+  echo -e "${YELLOW}Full response:${NC}"
+  echo "$AUTHORIZE_RESPONSE"
+  exit 1
+fi
+
+echo -e "${GREEN}Authorization Code:${NC} $AUTH_CODE"
+
+# ---------------------------------------------------------------------------
+# Step 5: Exchange Code for Token
+# ---------------------------------------------------------------------------
+echo -e "${BLUE}=== Step 5: Exchange Code for Token ===${NC}"
+
+TOKEN_RESPONSE=$(curl -s -X POST "${BASE_URL}/oauth/token" \
+  -H "Content-Type: application/x-www-form-urlencoded" \
+  -d "grant_type=authorization_code" \
+  -d "code=${AUTH_CODE}" \
+  -d "redirect_uri=${REDIRECT_URI}" \
+  -d "client_id=${CLIENT_ID}" \
+  -d "client_secret=${CLIENT_SECRET}" \
+  -d "code_verifier=${CODE_VERIFIER}")
+
+# Check for errors
+if echo "$TOKEN_RESPONSE" | grep -q '"error"'; then
+  echo -e "${RED}Token exchange failed:${NC}"
+  echo "$TOKEN_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$TOKEN_RESPONSE"
+  exit 1
+fi
+
+ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
+REFRESH_TOKEN=$(echo "$TOKEN_RESPONSE" | grep -o '"refresh_token":"[^"]*"' | cut -d'"' -f4)
+EXPIRES_IN=$(echo "$TOKEN_RESPONSE" | grep -o '"expires_in":[0-9]*' | cut -d':' -f2)
+
+if [ -z "$ACCESS_TOKEN" ]; then
+  echo -e "${RED}No access token in response.${NC}"
+  echo "$TOKEN_RESPONSE"
+  exit 1
+fi
+
+# ---------------------------------------------------------------------------
+# Output
+# ---------------------------------------------------------------------------
+echo ""
+echo -e "${GREEN}========================================${NC}"
+echo -e "${GREEN}  SUCCESS! Token obtained${NC}"
+echo -e "${GREEN}========================================${NC}"
+echo ""
+echo -e "${BLUE}Access Token:${NC}  ${ACCESS_TOKEN}"
+echo -e "${BLUE}Refresh Token:${NC} ${REFRESH_TOKEN}"
+echo -e "${BLUE}Expires In:${NC}    ${EXPIRES_IN} seconds"
+echo ""
+echo -e "${YELLOW}Usage example:${NC}"
+echo "  curl ${BASE_URL}/v1/models -H \"Authorization: Bearer ${ACCESS_TOKEN}\""
+echo ""
+echo -e "${YELLOW}Environment variables (for reuse):${NC}"
+echo "  export OAUTH_CLIENT_ID='${CLIENT_ID}'"
+echo "  export OAUTH_CLIENT_SECRET='${CLIENT_SECRET}'"
+echo "  export OAUTH_ACCESS_TOKEN='${ACCESS_TOKEN}'"
+echo ""
+
+# Optionally save to a file for easy sourcing
+TOKEN_FILE="${TOKEN_FILE:-.oauth-token}"
+cat > "$TOKEN_FILE" <<EOF
+# OAuth 2.1 Token - Generated $(date)
+export OAUTH_CLIENT_ID='${CLIENT_ID}'
+export OAUTH_CLIENT_SECRET='${CLIENT_SECRET}'
+export OAUTH_ACCESS_TOKEN='${ACCESS_TOKEN}'
+export OAUTH_REFRESH_TOKEN='${REFRESH_TOKEN}'
+EOF
+
+echo -e "${GREEN}Token saved to ${TOKEN_FILE}${NC}"
+echo -e "${YELLOW}Source it with:${NC} source ${TOKEN_FILE}"

+ 166 - 0
src/lib/oauth/bearer.js

@@ -0,0 +1,166 @@
+import { timingSafeEqual } from 'node:crypto';
+import { client } from '$lib/redis.js';
+
+const BEARER_PREFIX = 'Bearer ';
+const BEARER_PREFIX_LEN = BEARER_PREFIX.length;
+
+/**
+ * Validates a Bearer token from the Authorization header against Redis.
+ *
+ * @param {Request} request - The incoming HTTP request
+ * @param {string} [requiredScope] - Optional scope to check (space-separated)
+ * @returns {Promise<{valid: boolean, clientId?: string, userId?: string, scope?: string, error?: object, status?: number}>}
+ */
+export async function validateBearerToken(request, requiredScope) {
+  const authHeader = request.headers.get('authorization');
+
+  if (!authHeader) {
+    return {
+      valid: false,
+      error: {
+        message: 'Missing Authorization header.',
+        type: 'invalid_request',
+        code: 'missing_authorization'
+      },
+      status: 401
+    };
+  }
+
+  // Constant-time comparison of the Bearer prefix
+  const authBuf = Buffer.from(authHeader);
+  const prefixBuf = Buffer.from(BEARER_PREFIX);
+
+  if (authBuf.length < BEARER_PREFIX_LEN) {
+    return {
+      valid: false,
+      error: {
+        message: 'Authorization header must use Bearer scheme.',
+        type: 'invalid_token',
+        code: 'invalid_token'
+      },
+      status: 401
+    };
+  }
+
+  try {
+    const prefixMatch = timingSafeEqual(authBuf.subarray(0, BEARER_PREFIX_LEN), prefixBuf);
+    if (!prefixMatch) {
+      throw new Error('not bearer');
+    }
+  } catch {
+    return {
+      valid: false,
+      error: {
+        message: 'Authorization header must use Bearer scheme.',
+        type: 'invalid_token',
+        code: 'invalid_token'
+      },
+      status: 401
+    };
+  }
+
+  const token = authHeader.slice(BEARER_PREFIX_LEN);
+
+  if (!token) {
+    return {
+      valid: false,
+      error: {
+        message: 'Token value is empty.',
+        type: 'invalid_token',
+        code: 'invalid_token'
+      },
+      status: 401
+    };
+  }
+
+  let raw;
+  try {
+    raw = await client.get(`access_token:${token}`);
+  } catch {
+    return {
+      valid: false,
+      error: {
+        message: 'Internal server error.',
+        type: 'server_error',
+        code: 'server_error'
+      },
+      status: 500
+    };
+  }
+
+  if (!raw) {
+    return {
+      valid: false,
+      error: {
+        message: 'Access token is invalid or expired.',
+        type: 'invalid_token',
+        code: 'invalid_token'
+      },
+      status: 401
+    };
+  }
+
+  let data;
+  try {
+    data = JSON.parse(raw);
+  } catch {
+    return {
+      valid: false,
+      error: {
+        message: 'Access token data is corrupted.',
+        type: 'server_error',
+        code: 'token_corrupted'
+      },
+      status: 500
+    };
+  }
+
+  // Check token type
+  if (data.type !== 'access_token') {
+    return {
+      valid: false,
+      error: {
+        message: 'Token is not an access token.',
+        type: 'invalid_token',
+        code: 'invalid_token'
+      },
+      status: 401
+    };
+  }
+
+  // Check expiry
+  if (data.expires_at && new Date(data.expires_at) <= new Date()) {
+    return {
+      valid: false,
+      error: {
+        message: 'Access token has expired.',
+        type: 'invalid_token',
+        code: 'token_expired'
+      },
+      status: 401
+    };
+  }
+
+  // Check required scope
+  if (requiredScope) {
+    const scopes = (data.scope || '').split(' ');
+    if (!scopes.includes(requiredScope)) {
+      return {
+        valid: false,
+        error: {
+          message: 'Insufficient scope.',
+          type: 'insufficient_scope',
+          code: 'insufficient_scope'
+        },
+        status: 403
+      };
+    }
+  }
+
+  return {
+    valid: true,
+    clientId: data.client_id,
+    userId: data.user_id,
+    scope: data.scope
+  };
+}

+ 180 - 0
src/lib/providers/anthropic.js

@@ -0,0 +1,180 @@
+/**
+ * Anthropic provider — translates OpenAI chat completion requests
+ * to Anthropic Messages API calls using native fetch().
+ */
+export function createProvider(config) {
+  const { id, apiKey, baseUrl, models } = config;
+
+  const messagesUrl = baseUrl ? baseUrl.replace(/\/+$/, '') : 'https://api.anthropic.com/v1/messages';
+
+  function buildHeaders() {
+    return {
+      'Content-Type': 'application/json',
+      'x-api-key': apiKey,
+      'anthropic-version': '2023-06-01'
+    };
+  }
+
+  function translateMessages(openAiMessages) {
+    const systemParts = [];
+    const messages = [];
+
+    for (const msg of openAiMessages) {
+      if (msg.role === 'system') {
+        systemParts.push(msg.content);
+      } else {
+        messages.push({ role: msg.role, content: msg.content });
+      }
+    }
+
+    const system = systemParts.length > 0 ? systemParts.join('\n\n') : undefined;
+    return { system, messages };
+  }
+
+  function buildBody(openAiMessages, modelId, options, stream) {
+    const { system, messages } = translateMessages(openAiMessages);
+
+    const body = {
+      model: modelId,
+      messages,
+      max_tokens: options.max_tokens ?? 4096,
+      stream
+    };
+
+    if (system !== undefined) {
+      body.system = system;
+    }
+    if (options.temperature !== undefined) {
+      body.temperature = options.temperature;
+    }
+    if (options.top_p !== undefined) {
+      body.top_p = options.top_p;
+    }
+
+    return body;
+  }
+
+  async function request(openAiMessages, modelId, options, stream) {
+    const body = buildBody(openAiMessages, modelId, options, stream);
+    const headers = buildHeaders();
+
+    let response;
+    try {
+      response = await fetch(messagesUrl, {
+        method: 'POST',
+        headers,
+        body: JSON.stringify(body)
+      });
+    } catch (error) {
+      throw new Error(`Anthropic provider request failed: ${error.message}`);
+    }
+
+    if (!response.ok) {
+      let errorBody = '';
+      try { errorBody = await response.text(); } catch { /* ignore */ }
+      throw new Error(
+        `Anthropic provider returned ${response.status}${errorBody ? `: ${errorBody}` : ''}`
+      );
+    }
+
+    return response;
+  }
+
+  async function chat(openAiMessages, localModelId, options = {}) {
+    const providerModelId = models[localModelId];
+    const response = await request(openAiMessages, providerModelId, options, false);
+    const anthropicResponse = await response.json();
+
+    return {
+      id: anthropicResponse.id,
+      object: 'chat.completion',
+      created: Math.floor(Date.now() / 1000),
+      model: localModelId,
+      choices: [
+        {
+          index: 0,
+          message: {
+            role: 'assistant',
+            content: anthropicResponse.content[0].text
+          },
+          finish_reason: 'stop'
+        }
+      ],
+      usage: {
+        prompt_tokens: anthropicResponse.usage.input_tokens,
+        completion_tokens: anthropicResponse.usage.output_tokens,
+        total_tokens: anthropicResponse.usage.input_tokens + anthropicResponse.usage.output_tokens
+      }
+    };
+  }
+
+  async function* chatStream(openAiMessages, localModelId, options = {}) {
+    const providerModelId = models[localModelId];
+    const response = await request(openAiMessages, providerModelId, options, true);
+
+    const reader = response.body.getReader();
+    const decoder = new TextDecoder();
+    let buffer = '';
+
+    try {
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) break;
+
+        buffer += decoder.decode(value, { stream: true });
+
+        const lines = buffer.split('\n');
+        buffer = lines.pop(); // keep incomplete line in buffer
+
+        for (const line of lines) {
+          const trimmed = line.trim();
+          if (!trimmed || !trimmed.startsWith('data:')) continue;
+
+          const dataStr = trimmed.slice(5).trim();
+          if (dataStr === '[DONE]') continue;
+
+          let data;
+          try {
+            data = JSON.parse(dataStr);
+          } catch {
+            continue;
+          }
+
+          if (data.type === 'content_block_delta' && data.delta?.text) {
+            yield {
+              id: `chunk-${Date.now()}`,
+              object: 'chat.completion.chunk',
+              created: Math.floor(Date.now() / 1000),
+              model: localModelId,
+              choices: [
+                {
+                  index: 0,
+                  delta: { content: data.delta.text },
+                  finish_reason: null
+                }
+              ]
+            };
+          } else if (data.type === 'message_stop') {
+            yield {
+              id: `chunk-${Date.now()}`,
+              object: 'chat.completion.chunk',
+              created: Math.floor(Date.now() / 1000),
+              model: localModelId,
+              choices: [
+                {
+                  index: 0,
+                  delta: {},
+                  finish_reason: 'stop'
+                }
+              ]
+            };
+          }
+        }
+      }
+    } finally {
+      reader.releaseLock();
+    }
+  }
+
+  return { id, chat, chatStream };
+}

+ 173 - 0
src/lib/providers/bedrock.js

@@ -0,0 +1,173 @@
+import {
+  BedrockRuntimeClient,
+  ConverseCommand,
+  ConverseStreamCommand
+} from '@aws-sdk/client-bedrock-runtime';
+
+/**
+ * Amazon Bedrock provider — translates OpenAI chat completion requests
+ * to AWS Bedrock Converse API calls.
+ */
+export function createProvider(config) {
+  const { id, region, credentials, models } = config;
+
+  // Singleton BedrockRuntimeClient per provider config
+  const client = new BedrockRuntimeClient({
+    region,
+    credentials
+  });
+
+  /**
+   * Translate OpenAI messages[] to Bedrock Converse messages[].
+   * Extracts system messages into a top-level `system` array.
+   */
+  function translateMessages(messages) {
+    const systemPrompts = [];
+    const bedrockMessages = [];
+
+    for (const msg of messages) {
+      if (msg.role === 'system') {
+        systemPrompts.push({ text: msg.content });
+      } else {
+        bedrockMessages.push({
+          role:    msg.role,
+          content: [{ text: msg.content }]
+        });
+      }
+    }
+
+    return {
+      system:   systemPrompts.length > 0 ? systemPrompts : undefined,
+      messages: bedrockMessages
+    };
+  }
+
+  /**
+   * Build inferenceConfig from options, only including defined values.
+   */
+  function buildInferenceConfig(options) {
+    const inferenceConfig = {};
+    if (options.temperature !== undefined) inferenceConfig.temperature = options.temperature;
+    if (options.max_tokens   !== undefined) inferenceConfig.maxTokens   = options.max_tokens;
+    if (options.top_p        !== undefined) inferenceConfig.topP        = options.top_p;
+    return Object.keys(inferenceConfig).length > 0 ? inferenceConfig : undefined;
+  }
+
+  /**
+   * Generate a unique OpenAI-style chat completion ID.
+   */
+  function generateId() {
+    return `chatcmpl-${Date.now()}`;
+  }
+
+  /**
+   * Build common OpenAI response metadata.
+   */
+  function buildMetadata(modelId) {
+    return {
+      id:      generateId(),
+      object:  'chat.completion',
+      created: Math.floor(Date.now() / 1000),
+      model:   modelId
+    };
+  }
+
+  /**
+   * Non-streaming chat completion.
+   */
+  async function chat(messages, modelId, options = {}) {
+    const providerModelId = models[modelId];
+    const { system, messages: bedrockMessages } = translateMessages(messages);
+    const inferenceConfig = buildInferenceConfig(options);
+
+    const command = new ConverseCommand({
+      modelId:         providerModelId,
+      messages:        bedrockMessages,
+      system,
+      inferenceConfig
+    });
+
+    let response;
+    try {
+      response = await client.send(command);
+    } catch (error) {
+      throw new Error(`Bedrock provider request failed: ${error.message}`);
+    }
+
+    const text = response.output?.message?.content?.[0]?.text ?? '';
+    const usage = response.usage || {};
+
+    return {
+      ...buildMetadata(modelId),
+      choices: [{
+        index: 0,
+        message: {
+          role:    'assistant',
+          content: text
+        },
+        finish_reason: 'stop'
+      }],
+      usage: {
+        prompt_tokens:     usage.inputTokens  || 0,
+        completion_tokens: usage.outputTokens || 0,
+        total_tokens:      usage.totalTokens  || 0
+      }
+    };
+  }
+
+  /**
+   * Streaming chat completion — returns an async iterator of OpenAI chunks.
+   */
+  async function* chatStream(messages, modelId, options = {}) {
+    const providerModelId = models[modelId];
+    const { system, messages: bedrockMessages } = translateMessages(messages);
+    const inferenceConfig = buildInferenceConfig(options);
+
+    const command = new ConverseStreamCommand({
+      modelId:         providerModelId,
+      messages:        bedrockMessages,
+      system,
+      inferenceConfig
+    });
+
+    let streamResponse;
+    try {
+      streamResponse = await client.send(command);
+    } catch (error) {
+      throw new Error(`Bedrock provider stream request failed: ${error.message}`);
+    }
+
+    const metadata = {
+      id:      generateId(),
+      object:  'chat.completion.chunk',
+      created: Math.floor(Date.now() / 1000),
+      model:   modelId
+    };
+
+    for await (const event of streamResponse.stream) {
+      if (event.contentBlockDelta?.delta?.text) {
+        yield {
+          ...metadata,
+          choices: [{
+            index: 0,
+            delta: { content: event.contentBlockDelta.delta.text },
+            finish_reason: null
+          }]
+        };
+      }
+
+      if (event.messageStop?.stopReason) {
+        yield {
+          ...metadata,
+          choices: [{
+            index: 0,
+            delta: {},
+            finish_reason: 'stop'
+          }]
+        };
+      }
+    }
+  }
+
+  return { id, chat, chatStream };
+}

+ 198 - 0
src/lib/providers/errors.js

@@ -0,0 +1,198 @@
+/**
+ * Provider error translation layer.
+ * Normalizes provider-specific errors (AWS, Anthropic, HTTP) into
+ * OpenAI-compatible error format for consistent API responses.
+ *
+ * @param {Error|object} error - The original error object
+ * @param {string} providerType - Provider type: 'bedrock', 'aws', 'anthropic', 'openai-compatible', 'generic'
+ * @returns {{ error: { message: string, type: string, param: null, code: string }, status: number }}
+ */
+export function translateProviderError(error, providerType) {
+  if (!error) {
+    return makeError('An unexpected error occurred', 'api_error', 'internal_error', 502);
+  }
+
+  // Network errors are handled the same regardless of provider
+  if (isNetworkError(error)) {
+    return makeError(
+      'Unable to connect to the provider service. Please check your connection and try again.',
+      'api_error',
+      'connection_error',
+      502
+    );
+  }
+
+  const type = (providerType || '').toLowerCase();
+
+  if (type === 'bedrock' || type === 'aws') {
+    return translateAwsError(error);
+  }
+
+  if (type === 'anthropic') {
+    return translateAnthropicError(error);
+  }
+
+  if (type === 'openai-compatible' || type === 'generic') {
+    return translateHttpError(error);
+  }
+
+  // Unknown provider type — fall back to generic error
+  return makeError(
+    sanitizeMessage(error.message) || 'An unexpected error occurred',
+    'api_error',
+    'internal_error',
+    502
+  );
+}
+
+// ---------------------------------------------------------------------------
+// Provider-specific translators
+// ---------------------------------------------------------------------------
+
+/**
+ * Translate an AWS SDK error into OpenAI format.
+ * AWS SDK errors expose a `name` property (e.g. ThrottlingException).
+ *
+ * @param {Error} error - AWS SDK error
+ * @returns {{ error: object, status: number }}
+ */
+function translateAwsError(error) {
+  const name = error.name || '';
+  const message = sanitizeMessage(error.message) || 'A Bedrock service error occurred';
+
+  switch (name) {
+    case 'ThrottlingException':
+      return makeError(message, 'rate_limit_error', 'rate_limit_exceeded', 429);
+    case 'ValidationException':
+      return makeError(message, 'invalid_request_error', 'invalid_request_error', 400);
+    case 'AccessDeniedException':
+      return makeError(message, 'authentication_error', 'access_denied', 403);
+    case 'ResourceNotFoundException':
+      return makeError(message, 'invalid_request_error', 'model_not_found', 404);
+    default:
+      return makeError(message, 'api_error', 'internal_error', 502);
+  }
+}
+
+/**
+ * Translate an Anthropic API error into OpenAI format.
+ * Anthropic errors have a `type` property in the response body
+ * (e.g. overloaded_error, invalid_request_error).
+ *
+ * @param {Error|object} error - Anthropic error (may be raw parsed body)
+ * @returns {{ error: object, status: number }}
+ */
+function translateAnthropicError(error) {
+  // Anthropic error shape: { type, message } on the error body itself,
+  // or { error: { type, message } } when wrapped in a response envelope
+  const errorType =
+    (error.type) ||
+    (error.error && error.error.type) ||
+    '';
+
+  const message = sanitizeMessage(
+    error.message ||
+    (error.error && error.error.message) ||
+    'An Anthropic service error occurred'
+  );
+
+  switch (errorType) {
+    case 'overloaded_error':
+      return makeError(message, 'api_error', 'server_overloaded', 503);
+    case 'invalid_request_error':
+      return makeError(message, 'invalid_request_error', 'invalid_request_error', 400);
+    case 'authentication_error':
+      return makeError(message, 'authentication_error', 'invalid_api_key', 401);
+    case 'not_found_error':
+      return makeError(message, 'invalid_request_error', 'model_not_found', 404);
+    default:
+      return makeError(message, 'api_error', 'internal_error', 502);
+  }
+}
+
+/**
+ * Translate an HTTP/generic error into OpenAI format using status code.
+ *
+ * @param {Error|object} error - Error with a status/statusCode property
+ * @returns {{ error: object, status: number }}
+ */
+function translateHttpError(error) {
+  const statusCode = error?.status || error?.statusCode || error?.response?.status || 0;
+  const message = sanitizeMessage(error?.message) || 'An API error occurred';
+
+  switch (statusCode) {
+    case 401:
+      return makeError(message, 'authentication_error', 'invalid_api_key', 401);
+    case 404:
+      return makeError(message, 'invalid_request_error', 'model_not_found', 404);
+    case 429:
+      return makeError(message, 'rate_limit_error', 'rate_limit_exceeded', 429);
+    default:
+      return makeError(message, 'api_error', 'internal_error', 502);
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Build an OpenAI-format error response.
+ *
+ * @param {string} message - User-facing error message
+ * @param {string} type - OpenAI error type
+ * @param {string} code - Machine-readable error code
+ * @param {number} status - HTTP status code
+ * @returns {{ error: { message: string, type: string, param: null, code: string }, status: number }}
+ */
+function makeError(message, type, code, status) {
+  return {
+    error: { message, type, param: null, code },
+    status
+  };
+}
+
+/**
+ * Check whether an error is a network-level failure (fetch failed, connection refused, etc.).
+ *
+ * @param {Error} error
+ * @returns {boolean}
+ */
+function isNetworkError(error) {
+  if (!error || !error.message) return false;
+  const msg = error.message.toLowerCase();
+  return (
+    msg.includes('fetch failed') ||
+    msg.includes('network error') ||
+    msg.includes('connection refused') ||
+    msg.includes('connect econnrefused') ||
+    msg.includes('enotfound') ||
+    msg.includes('econnreset') ||
+    msg.includes('econnaborted') ||
+    msg.includes('socket hang up') ||
+    msg.includes('request timeout') ||
+    msg.includes('eai_again')
+  );
+}
+
+/**
+ * Sanitize an error message to remove credentials, keys, URLs, and other
+ * sensitive information before returning to the client.
+ *
+ * @param {string} [message]
+ * @returns {string}
+ */
+function sanitizeMessage(message) {
+  if (typeof message !== 'string' || !message) return '';
+  return message
+    // Redact AWS access key IDs (AKIA...)
+    .replace(/AKIA[0-9A-Z]{16}/g, '[REDACTED]')
+    // Redact potential secret keys / tokens (base64-like sequences of 40+ chars)
+    .replace(/[A-Za-z0-9+/]{40,}={0,2}/g, '[REDACTED]')
+    // Redact URLs
+    .replace(/https?:\/\/[^\s]+/g, '[REDACTED]')
+    // Redact bearer tokens
+    .replace(/Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi, 'Bearer [REDACTED]')
+    // Redact x-api-key values
+    .replace(/x-api-key:\s*\S+/gi, 'x-api-key: [REDACTED]');
+}

+ 101 - 0
src/lib/providers/index.js

@@ -0,0 +1,101 @@
+import config from '$lib/config.js';
+
+/** @type {Map<string, object>} */
+const providerCache = new Map();
+
+/**
+ * Initialize provider cache from config.
+ * Only enabled providers are cached.
+ */
+function initCache() {
+  const providers = config.providers || [];
+  for (const provider of providers) {
+    if (provider.enabled) {
+      providerCache.set(provider.id, { ...provider });
+    }
+  }
+}
+
+// Populate cache at module load
+initCache();
+
+/**
+ * Resolve a model ID (e.g. "bedrock/claude-3-sonnet") to a provider instance.
+ *
+ * @param {string} modelId - Model ID in the format "providerSlug/localModelId"
+ * @returns {{ id: string, type: string, config: object, localModelId: string }}
+ *   Provider resolution with the matched provider config and local model key.
+ * @throws {Error} If the modelId format is invalid, the provider is unknown/disabled,
+ *   or the model is not mapped in that provider.
+ */
+export function getProvider(modelId) {
+  if (typeof modelId !== 'string' || !modelId.includes('/')) {
+    throw new Error(
+      `Invalid model ID "${modelId}". Expected format: "provider/model"`
+    );
+  }
+
+  const parts = modelId.split('/');
+  if (parts.length !== 2 || !parts[0] || !parts[1]) {
+    throw new Error(
+      `Invalid model ID "${modelId}". Expected format: "provider/model"`
+    );
+  }
+
+  const [providerSlug, localModelId] = parts;
+
+  const provider = providerCache.get(providerSlug);
+  if (!provider) {
+    const allProviders = config.providers || [];
+    const existsButDisabled = allProviders.find(p => p.id === providerSlug);
+    if (existsButDisabled) {
+      throw new Error(`Provider "${providerSlug}" is disabled`);
+    }
+    throw new Error(`Unknown provider "${providerSlug}"`);
+  }
+
+  if (!provider.models || !(localModelId in provider.models)) {
+    throw new Error(
+      `Unknown model "${localModelId}" for provider "${providerSlug}"`
+    );
+  }
+
+  return {
+    id:       provider.id,
+    type:     provider.type,
+    config:   provider,
+    localModelId
+  };
+}
+
+/**
+ * List all enabled provider configurations.
+ *
+ * @returns {Array<object>} Array of enabled provider config objects.
+ */
+export function listProviders() {
+  return Array.from(providerCache.values());
+}
+
+/**
+ * List all model mappings across all enabled providers.
+ *
+ * @returns {Array<{ id: string, providerId: string, localModelId: string }>}
+ *   Each entry: `id` is the full model ID ("provider/model"),
+ *   `providerId` is the provider slug, and `localModelId` is the local key.
+ */
+export function listModels() {
+  const models = [];
+  for (const provider of providerCache.values()) {
+    if (provider.models) {
+      for (const localModelId of Object.keys(provider.models)) {
+        models.push({
+          id:            `${provider.id}/${localModelId}`,
+          providerId:    provider.id,
+          localModelId
+        });
+      }
+    }
+  }
+  return models;
+}

+ 68 - 0
src/lib/providers/openai-compatible.js

@@ -0,0 +1,68 @@
+/**
+ * OpenAI-compatible provider — proxies requests to any OpenAI-compatible backend
+ * (vLLM, Ollama, LiteLLM, etc.) using native fetch().
+ */
+export function createProvider(config) {
+  const { id, baseUrl, apiKey, models } = config;
+
+  // Normalize baseUrl: strip trailing slash, ensure /v1 is present
+  let normalizedUrl = baseUrl.replace(/\/+$/, '');
+  if (!normalizedUrl.endsWith('/v1')) {
+    normalizedUrl += '/v1';
+  }
+  const completionsUrl = `${normalizedUrl}/chat/completions`;
+
+  function buildHeaders() {
+    const headers = { 'Content-Type': 'application/json' };
+    if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
+    return headers;
+  }
+
+  function buildBody(messages, modelId, options, stream) {
+    const providerModelId = models[modelId] || modelId;
+    const body = { model: providerModelId, messages, stream };
+    if (options.temperature !== undefined) body.temperature = options.temperature;
+    if (options.max_tokens   !== undefined) body.max_tokens   = options.max_tokens;
+    if (options.top_p        !== undefined) body.top_p        = options.top_p;
+    if (options.stop         !== undefined) body.stop         = options.stop;
+    return body;
+  }
+
+  async function request(messages, modelId, options, stream) {
+    const body    = buildBody(messages, modelId, options, stream);
+    const headers = buildHeaders();
+
+    let response;
+    try {
+      response = await fetch(completionsUrl, {
+        method: 'POST',
+        headers,
+        body: JSON.stringify(body)
+      });
+    } catch (error) {
+      throw new Error(`OpenAI-compatible provider request failed: ${error.message}`);
+    }
+
+    if (!response.ok) {
+      let errorBody = '';
+      try { errorBody = await response.text(); } catch { /* ignore */ }
+      throw new Error(
+        `OpenAI-compatible provider returned ${response.status}${errorBody ? `: ${errorBody}` : ''}`
+      );
+    }
+
+    return response;
+  }
+
+  async function chat(messages, modelId, options = {}) {
+    const response = await request(messages, modelId, options, false);
+    return response.json();
+  }
+
+  async function chatStream(messages, modelId, options = {}) {
+    const response = await request(messages, modelId, options, true);
+    return response.body;
+  }
+
+  return { id, chat, chatStream };
+}

+ 164 - 0
src/routes/v1/chat/completions/+server.js

@@ -0,0 +1,164 @@
+import { validateBearerToken } from '$lib/oauth/bearer.js';
+import { getProvider } from '$lib/providers/index.js';
+import { translateProviderError } from '$lib/providers/errors.js';
+import { createProvider as createBedrockProvider } from '$lib/providers/bedrock.js';
+import { createProvider as createAnthropicProvider } from '$lib/providers/anthropic.js';
+import { createProvider as createOpenAiProvider } from '$lib/providers/openai-compatible.js';
+
+function openAiError(message, type = 'invalid_request_error', code = 'invalid_request', param = null) {
+  return {
+    error: { message, type, param, code }
+  };
+}
+
+function jsonResponse(body, status = 200) {
+  return new Response(JSON.stringify(body), {
+    status,
+    headers: { 'Content-Type': 'application/json' }
+  });
+}
+
+export async function POST({ request }) {
+  // 1. Validate Bearer token with 'llm' scope
+  const auth = await validateBearerToken(request, 'llm');
+  if (!auth.valid) {
+    return jsonResponse(auth.error, auth.status);
+  }
+
+  // 2. Parse JSON body
+  let body;
+  try {
+    body = await request.json();
+  } catch {
+    return jsonResponse(
+      openAiError('Invalid JSON in request body.', 'invalid_request_error', 'invalid_json'),
+      400
+    );
+  }
+
+  // 3. Validate required fields
+  if (!body.model) {
+    return jsonResponse(
+      openAiError("Missing required parameter: 'model'.", 'invalid_request_error', 'missing_model'),
+      400
+    );
+  }
+
+  if (!body.messages || !Array.isArray(body.messages)) {
+    return jsonResponse(
+      openAiError("Missing or invalid parameter: 'messages' must be an array.", 'invalid_request_error', 'missing_messages'),
+      400
+    );
+  }
+
+  // 4. Resolve provider
+  let providerInfo;
+  try {
+    providerInfo = getProvider(body.model);
+  } catch (err) {
+    return jsonResponse(
+      openAiError(err.message, 'invalid_request_error', 'invalid_model'),
+      400
+    );
+  }
+
+  // 5. Instantiate provider
+  let provider;
+  try {
+    switch (providerInfo.type) {
+      case 'bedrock':
+        provider = createBedrockProvider(providerInfo.config);
+        break;
+      case 'anthropic':
+        provider = createAnthropicProvider(providerInfo.config);
+        break;
+      case 'openai-compatible':
+        provider = createOpenAiProvider(providerInfo.config);
+        break;
+      default:
+        return jsonResponse(
+          openAiError(`Unsupported provider type: "${providerInfo.type}".`, 'invalid_request_error', 'unsupported_provider'),
+          400
+        );
+    }
+  } catch (err) {
+    return jsonResponse(
+      openAiError(`Failed to initialize provider: ${err.message}`, 'api_error', 'provider_init_failed'),
+      500
+    );
+  }
+
+  // 6. Build options
+  const options = {
+    temperature: body.temperature,
+    max_tokens: body.max_tokens,
+    top_p: body.top_p,
+    stop: body.stop
+  };
+
+  // 7. Streaming
+  if (body.stream === true) {
+    let streamResult;
+    try {
+      streamResult = await provider.chatStream(body.messages, providerInfo.localModelId, options);
+    } catch (err) {
+      const { error, status } = translateProviderError(err, providerInfo.type);
+      return jsonResponse({ error }, status);
+    }
+
+    const encoder = new TextEncoder();
+
+    return new Response(
+      new ReadableStream({
+        async start(controller) {
+          try {
+            if (streamResult && typeof streamResult.getReader === 'function') {
+              // Raw ReadableStream (e.g., openai-compatible) — pass through chunks
+              const reader = streamResult.getReader();
+              try {
+                while (true) {
+                  const { done, value } = await reader.read();
+                  if (done) break;
+                  controller.enqueue(value);
+                }
+              } finally {
+                reader.releaseLock();
+              }
+            } else {
+              // Async generator (e.g., bedrock, anthropic) — serialize chunks
+              for await (const chunk of streamResult) {
+                controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
+              }
+            }
+            controller.enqueue(encoder.encode('data: [DONE]\n\n'));
+            controller.close();
+          } catch (err) {
+            const { error } = translateProviderError(err, providerInfo.type);
+            controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error })}\n\n`));
+            controller.close();
+          }
+        }
+      }),
+      {
+        status: 200,
+        headers: {
+          'Content-Type': 'text/event-stream',
+          'Cache-Control': 'no-cache',
+          'Connection': 'keep-alive'
+        }
+      }
+    );
+  }
+
+  // 8. Non-streaming
+  let result;
+  try {
+    result = await provider.chat(body.messages, providerInfo.localModelId, options);
+  } catch (err) {
+    const { error, status } = translateProviderError(err, providerInfo.type);
+    return jsonResponse({ error }, status);
+  }
+
+  // 9. Return result
+  return jsonResponse(result, 200);
+}

+ 35 - 0
src/routes/v1/models/+server.js

@@ -0,0 +1,35 @@
+import { validateBearerToken } from '$lib/oauth/bearer.js';
+import { listModels } from '$lib/providers/index.js';
+
+function jsonResponse(body, status = 200) {
+  return new Response(JSON.stringify(body), {
+    status,
+    headers: { 'Content-Type': 'application/json' }
+  });
+}
+
+export async function GET({ request }) {
+  const auth = await validateBearerToken(request, 'llm');
+  if (!auth.valid) {
+    return jsonResponse({
+      error: {
+        message: auth.error.message,
+        type: 'invalid_request_error',
+        param: null,
+        code: auth.error.code
+      }
+    }, auth.status);
+  }
+
+  const models = listModels();
+
+  return jsonResponse({
+    object: 'list',
+    data: models.map(m => ({
+      id: m.id,
+      object: 'model',
+      created: Math.floor(Date.now() / 1000),
+      owned_by: m.providerId
+    }))
+  });
+}

+ 46 - 0
src/routes/v1/models/[model]/+server.js

@@ -0,0 +1,46 @@
+import { validateBearerToken } from '$lib/oauth/bearer.js';
+import { getProvider } from '$lib/providers/index.js';
+
+function jsonResponse(body, status = 200) {
+  return new Response(JSON.stringify(body), {
+    status,
+    headers: { 'Content-Type': 'application/json' }
+  });
+}
+
+export async function GET({ params, request }) {
+  const auth = await validateBearerToken(request, 'llm');
+  if (!auth.valid) {
+    return jsonResponse({
+      error: {
+        message: auth.error.message,
+        type: 'invalid_request_error',
+        param: null,
+        code: auth.error.code
+      }
+    }, auth.status);
+  }
+
+  const modelId = params.model;
+
+  let providerInfo;
+  try {
+    providerInfo = getProvider(modelId);
+  } catch {
+    return jsonResponse({
+      error: {
+        message: `The model '${modelId}' does not exist.`,
+        type: 'invalid_request_error',
+        param: null,
+        code: 'model_not_found'
+      }
+    }, 404);
+  }
+
+  return jsonResponse({
+    id: modelId,
+    object: 'model',
+    created: Math.floor(Date.now() / 1000),
+    owned_by: providerInfo.id
+  });
+}