From 51dab19987c4280a8131a8496437affaf604133e Mon Sep 17 00:00:00 2001 From: Jack Williams Date: Thu, 3 Sep 2026 12:08:25 +0200 Subject: [PATCH 1/6] Keep the OpenAI API key out of the browser bundle VITE_OPENAI_API_KEY was inlined into the shipped JavaScript by Vite and used client-side with dangerouslyAllowBrowser, so any visitor could extract the credential from the served assets. Add @promptions/promptions-openai-proxy, a Vite plugin that proxies requests through the dev and preview servers. The key is now read from OPENAI_API_KEY without the VITE_ prefix, which makes inlining impossible, and is attached to requests server-side. The browser calls a same-origin /api/openai path with a placeholder credential. The proxy forwards the path and query verbatim, so both OpenAI and Azure OpenAI URL shapes work unchanged, and streams responses back so SSE token streaming is unaffected. OPENAI_API_STYLE overrides the inferred auth scheme for OpenAI-compatible backends that need a custom base URL. dangerouslyAllowBrowser remains set because the SDK requires it to run in a browser at all, but there is no longer a real credential to expose. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 47 ++-- apps/promptions-chat/.env.example | 15 +- apps/promptions-chat/README.md | 28 +-- apps/promptions-chat/package.json | 1 + .../src/services/ChatService.ts | 45 ++-- apps/promptions-chat/src/vite-env.d.ts | 6 +- apps/promptions-chat/vite.config.ts | 3 +- apps/promptions-image/.env.example | 15 +- apps/promptions-image/package.json | 1 + .../src/services/ImageService.ts | 43 ++-- apps/promptions-image/src/vite-env.d.ts | 6 +- apps/promptions-image/vite.config.ts | 3 +- packages/promptions-openai-proxy/README.md | 86 ++++++++ packages/promptions-openai-proxy/package.json | 37 ++++ packages/promptions-openai-proxy/project.json | 16 ++ .../promptions-openai-proxy/src/index.d.ts | 12 ++ packages/promptions-openai-proxy/src/index.js | 201 ++++++++++++++++++ .../promptions-openai-proxy/tsconfig.json | 19 ++ yarn.lock | 14 ++ 19 files changed, 503 insertions(+), 95 deletions(-) create mode 100644 packages/promptions-openai-proxy/README.md create mode 100644 packages/promptions-openai-proxy/package.json create mode 100644 packages/promptions-openai-proxy/project.json create mode 100644 packages/promptions-openai-proxy/src/index.d.ts create mode 100644 packages/promptions-openai-proxy/src/index.js create mode 100644 packages/promptions-openai-proxy/tsconfig.json diff --git a/README.md b/README.md index 326b7fa..42ece53 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,9 @@ yarn build ### 3. Run the applications (and set your API key) -The apps can call either the **standard OpenAI API** or your own **Azure OpenAI**-hosted models. Configure whichever you have access to via environment variables — the same `VITE_OPENAI_API_KEY` variable is used in both cases (it holds either your OpenAI key or your Azure OpenAI key). +The apps can call either the **standard OpenAI API** or your own **Azure OpenAI**-hosted models. Configure whichever you have access to via environment variables — the same `OPENAI_API_KEY` variable is used in both cases (it holds either your OpenAI key or your Azure OpenAI key). + +> **The API key stays on the server.** These variables are deliberately **not** prefixed with `VITE_`, so Vite cannot inline them into the browser bundle. The dev/preview server proxies requests to your provider at `/api/openai` and attaches the credential server-side. See [`packages/promptions-openai-proxy`](packages/promptions-openai-proxy/README.md). Option A — .env files (recommended for local development): @@ -106,9 +108,9 @@ Option A — .env files (recommended for local development): - Create `apps/promptions-chat/.env` (and `apps/promptions-image/.env`) with: ```dotenv - VITE_OPENAI_API_KEY=your_openai_api_key_here + OPENAI_API_KEY=your_openai_api_key_here # Optional: override the chat model (defaults to gpt-5.4-nano). - # VITE_OPENAI_MODEL=gpt-5.4-nano + # OPENAI_MODEL=gpt-5.4-nano ``` **Azure OpenAI** (using your own hosted deployment) @@ -117,47 +119,48 @@ Option A — .env files (recommended for local development): ```dotenv # Your Azure OpenAI resource key - VITE_OPENAI_API_KEY=your_azure_openai_key_here + OPENAI_API_KEY=your_azure_openai_key_here # Your Azure OpenAI resource endpoint - VITE_OPENAI_BASE_URL=https://your-resource.openai.azure.com + OPENAI_BASE_URL=https://your-resource.openai.azure.com # Required for Azure OpenAI - VITE_OPENAI_API_VERSION=2024-12-01-preview + OPENAI_API_VERSION=2024-12-01-preview # On Azure, this is your DEPLOYMENT NAME (not the underlying model id). # Ensure this deployment targets a chat-completions-compatible model. - VITE_OPENAI_MODEL=your_chat_deployment_name + OPENAI_MODEL=your_chat_deployment_name ``` Option B — set it in your shell (PowerShell example): ```powershell # Chat app — standard OpenAI -$env:VITE_OPENAI_API_KEY="your_openai_api_key_here" ; yarn workspace @promptions/promptions-chat dev +$env:OPENAI_API_KEY="your_openai_api_key_here" ; yarn workspace @promptions/promptions-chat dev # Chat app — Azure OpenAI -$env:VITE_OPENAI_API_KEY="your_azure_openai_key_here" -$env:VITE_OPENAI_BASE_URL="https://your-resource.openai.azure.com" -$env:VITE_OPENAI_API_VERSION="2024-12-01-preview" -$env:VITE_OPENAI_MODEL="your_chat_deployment_name" +$env:OPENAI_API_KEY="your_azure_openai_key_here" +$env:OPENAI_BASE_URL="https://your-resource.openai.azure.com" +$env:OPENAI_API_VERSION="2024-12-01-preview" +$env:OPENAI_MODEL="your_chat_deployment_name" yarn workspace @promptions/promptions-chat dev # Image app (swap workspace name; same variable conventions apply) -$env:VITE_OPENAI_API_KEY="your_openai_api_key_here" ; yarn workspace @promptions/promptions-image dev +$env:OPENAI_API_KEY="your_openai_api_key_here" ; yarn workspace @promptions/promptions-image dev ``` #### Configuration reference -Both apps read these `VITE_*` variables from their respective `.env` files. +Both apps read these variables from their respective `.env` files. They are read by the dev/preview server only and are never sent to the browser (except `OPENAI_API_VERSION` and `OPENAI_MODEL`, which are not secret). -| Variable | Description | Default | -| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------- | -| `VITE_OPENAI_API_KEY` | **Required.** Your OpenAI API key, or your Azure OpenAI resource key when `VITE_OPENAI_BASE_URL` is set. | _(unset)_ | -| `VITE_OPENAI_MODEL` | Chat model used for completions. On Azure OpenAI this is the **deployment name**. The image-generation model is selected in the UI. | `gpt-5.4-nano` | -| `VITE_OPENAI_BASE_URL` | Custom endpoint. Set this to use Azure OpenAI (e.g. `https://your-resource.openai.azure.com`) or another OpenAI-compatible service. | _(unset)_ | -| `VITE_OPENAI_API_VERSION` | API version. **Required** when `VITE_OPENAI_BASE_URL` points at Azure OpenAI (e.g. `2024-12-01-preview`). | _(unset)_ | +| Variable | Description | Default | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| `OPENAI_API_KEY` | **Required.** Your OpenAI API key, or your Azure OpenAI resource key when `OPENAI_BASE_URL` is set. | _(unset)_ | +| `OPENAI_MODEL` | Chat model used for completions. On Azure OpenAI this is the **deployment name**. The image-generation model is selected in the UI. | `gpt-5.4-nano` | +| `OPENAI_BASE_URL` | Custom endpoint. Set this to use Azure OpenAI (e.g. `https://your-resource.openai.azure.com`) or another OpenAI-compatible service. | _(unset)_ | +| `OPENAI_API_VERSION` | API version. **Required** when `OPENAI_BASE_URL` points at Azure OpenAI (e.g. `2024-12-01-preview`). | _(unset)_ | +| `OPENAI_API_STYLE` | `openai` or `azure`. Overrides how the credential is sent, for OpenAI-compatible backends that need a custom `OPENAI_BASE_URL`. | inferred | -When `VITE_OPENAI_BASE_URL` is set, the apps use the Azure OpenAI client; otherwise they use the standard OpenAI client. +When `OPENAI_BASE_URL` is set, the apps use Azure OpenAI conventions (`api-key` header, deployment-based URLs); otherwise they use the standard OpenAI conventions (`Authorization: Bearer`). Set `OPENAI_API_STYLE=openai` to use a custom endpoint with standard OpenAI conventions. -> **Model compatibility:** The chat reference app uses `VITE_OPENAI_MODEL`, defaulting to `gpt-5.4-nano`. On Azure OpenAI, make sure the deployment named in `VITE_OPENAI_MODEL` targets a chat-completions-compatible model. +> **Model compatibility:** The chat reference app uses `OPENAI_MODEL`, defaulting to `gpt-5.4-nano`. On Azure OpenAI, make sure the deployment named in `OPENAI_MODEL` targets a chat-completions-compatible model. Start the dev servers: diff --git a/apps/promptions-chat/.env.example b/apps/promptions-chat/.env.example index 9a073a8..8772dbd 100644 --- a/apps/promptions-chat/.env.example +++ b/apps/promptions-chat/.env.example @@ -1,15 +1,18 @@ # Copy this file to .env -# Required: Set your API key -VITE_OPENAI_API_KEY=your_openai_api_key_here +# These variables are read by the Vite dev/preview server only. They are NOT +# prefixed with VITE_, so Vite cannot inline them into the browser bundle. + +# Required: your API key. Stays server-side; the browser talks to /api/openai. +OPENAI_API_KEY=your_openai_api_key_here # Optional: only set for Azure or other custom OpenAI-compatible endpoints. # Omit for standard OpenAI API usage. -# VITE_OPENAI_BASE_URL=https://your-resource.openai.azure.com +# OPENAI_BASE_URL=https://your-resource.openai.azure.com # Optional: API version is typically Azure-specific/custom-endpoint specific. -# Required when VITE_OPENAI_BASE_URL points at Azure OpenAI. -# VITE_OPENAI_API_VERSION=2024-12-01-preview +# Required when OPENAI_BASE_URL points at Azure OpenAI. +# OPENAI_API_VERSION=2024-12-01-preview # Optional: override the chat model (defaults to gpt-5.4-nano). -# VITE_OPENAI_MODEL=gpt-5.4-nano +# OPENAI_MODEL=gpt-5.4-nano diff --git a/apps/promptions-chat/README.md b/apps/promptions-chat/README.md index da9f2f7..9742320 100644 --- a/apps/promptions-chat/README.md +++ b/apps/promptions-chat/README.md @@ -41,20 +41,22 @@ cp .env.example .env **Standard OpenAI** — edit `.env` and add your OpenAI API key: ``` -VITE_OPENAI_API_KEY=your_api_key_here +OPENAI_API_KEY=your_api_key_here ``` **Azure OpenAI** — to use your own Azure-hosted deployment, set: ``` -VITE_OPENAI_API_KEY=your_azure_openai_key_here -VITE_OPENAI_BASE_URL=https://your-resource.openai.azure.com -VITE_OPENAI_API_VERSION=2024-12-01-preview -# On Azure, VITE_OPENAI_MODEL is your DEPLOYMENT NAME (not a model id). -VITE_OPENAI_MODEL=your_chat_deployment_name +OPENAI_API_KEY=your_azure_openai_key_here +OPENAI_BASE_URL=https://your-resource.openai.azure.com +OPENAI_API_VERSION=2024-12-01-preview +# On Azure, OPENAI_MODEL is your DEPLOYMENT NAME (not a model id). +OPENAI_MODEL=your_chat_deployment_name ``` -When `VITE_OPENAI_BASE_URL` is set, the app uses the Azure OpenAI client; otherwise it uses the standard OpenAI client. +These variables are deliberately **not** prefixed with `VITE_`, so Vite cannot inline them into the browser bundle. The dev/preview server proxies requests at `/api/openai` and attaches the credential server-side. + +When `OPENAI_BASE_URL` is set, the app uses Azure OpenAI conventions; otherwise it uses the standard OpenAI conventions. Set `OPENAI_API_STYLE=openai` to use a custom endpoint with standard OpenAI conventions. ### Development @@ -92,16 +94,16 @@ yarn typecheck ## Model compatibility -The chat app uses the model configured in `VITE_OPENAI_MODEL`, defaulting to `gpt-5.4-nano`. When using Azure OpenAI, ensure the deployment named in `VITE_OPENAI_MODEL` targets a chat-completions-compatible model. +The chat app uses the model configured in `OPENAI_MODEL`, defaulting to `gpt-5.4-nano`. When using Azure OpenAI, ensure the deployment named in `OPENAI_MODEL` targets a chat-completions-compatible model. ## Security Notes -⚠️ **Important**: This demo uses `dangerouslyAllowBrowser: true` for the OpenAI client, which exposes your API key in the browser. In a production application, you should: +The API key is held by the Vite dev/preview server and injected into requests there. The browser talks only to the same-origin `/api/openai` proxy with a placeholder credential, so no key is present in the shipped bundle. `dangerouslyAllowBrowser: true` remains set because the OpenAI SDK refuses to run in a browser otherwise, but there is no real credential for it to expose. + +⚠️ Two limits to be aware of before deploying this beyond local development: -1. Move OpenAI API calls to a backend server -2. Implement proper authentication -3. Use environment variables on the server side -4. Add rate limiting and other security measures +1. The proxy runs only under `vite dev` and `vite preview`. A static build of `dist/` has no server, so it needs an equivalent proxy (for example a serverless function holding the key) in front of it. +2. The proxy endpoint is an unauthenticated pass-through to your credential. Anyone who can reach it can spend your quota, so add authentication and rate limiting before exposing it beyond `localhost`. ## Contributing diff --git a/apps/promptions-chat/package.json b/apps/promptions-chat/package.json index 8646dce..f36f6f4 100644 --- a/apps/promptions-chat/package.json +++ b/apps/promptions-chat/package.json @@ -24,6 +24,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "@promptions/promptions-openai-proxy": "workspace:*", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", diff --git a/apps/promptions-chat/src/services/ChatService.ts b/apps/promptions-chat/src/services/ChatService.ts index 7d87841..ea60f7f 100644 --- a/apps/promptions-chat/src/services/ChatService.ts +++ b/apps/promptions-chat/src/services/ChatService.ts @@ -1,5 +1,11 @@ import OpenAI, { AzureOpenAI } from "openai"; +/** + * The OpenAI SDK requires a non-empty key. The proxy replaces it with the real + * credential, so this literal is all the browser ever sees. + */ +const PROXY_PLACEHOLDER_API_KEY = "injected-by-proxy"; + interface ChatMessage { role: "user" | "assistant" | "system"; content: string; @@ -10,31 +16,26 @@ export class ChatService { private model: string; constructor() { - // In a real application, you'd want to handle the API key more securely - // For development, you can set VITE_OPENAI_API_KEY in your .env file - const apiKey = import.meta.env.VITE_OPENAI_API_KEY; - - if (!apiKey) { - throw new Error( - "OpenAI API key is required. Please set VITE_OPENAI_API_KEY in your environment variables.", - ); - } - - const baseURL = import.meta.env.VITE_OPENAI_BASE_URL; + // The API key is never available to the browser. Requests go to the + // same-origin proxy path, which injects the real credential + // server-side (see @promptions/promptions-openai-proxy). + const proxyUrl = `${window.location.origin}${import.meta.env.VITE_OPENAI_PROXY_PATH || "/api/openai"}`; const apiVersion = import.meta.env.VITE_OPENAI_API_VERSION; this.model = import.meta.env.VITE_OPENAI_MODEL || "gpt-5.4-nano"; - this.client = baseURL - ? new AzureOpenAI({ - apiKey, - endpoint: baseURL, - apiVersion, - dangerouslyAllowBrowser: true, // Only for demo purposes - use a backend in production - }) - : new OpenAI({ - apiKey, - dangerouslyAllowBrowser: true, // Only for demo purposes - use a backend in production - }); + this.client = + import.meta.env.VITE_OPENAI_PROXY_MODE === "azure" + ? new AzureOpenAI({ + endpoint: proxyUrl, + apiVersion, + apiKey: PROXY_PLACEHOLDER_API_KEY, + dangerouslyAllowBrowser: true, + }) + : new OpenAI({ + baseURL: `${proxyUrl}/v1`, + apiKey: PROXY_PLACEHOLDER_API_KEY, + dangerouslyAllowBrowser: true, + }); } async streamChat( diff --git a/apps/promptions-chat/src/vite-env.d.ts b/apps/promptions-chat/src/vite-env.d.ts index e7a0439..4b7484f 100644 --- a/apps/promptions-chat/src/vite-env.d.ts +++ b/apps/promptions-chat/src/vite-env.d.ts @@ -1,8 +1,10 @@ /// interface ImportMetaEnv { - readonly VITE_OPENAI_API_KEY: string; - readonly VITE_OPENAI_BASE_URL?: string; + // Injected by @promptions/promptions-openai-proxy. Non-secret values only: + // the API key is read server-side from OPENAI_API_KEY and never exposed here. + readonly VITE_OPENAI_PROXY_PATH: string; + readonly VITE_OPENAI_PROXY_MODE: "azure" | "openai"; readonly VITE_OPENAI_API_VERSION?: string; readonly VITE_OPENAI_MODEL?: string; // more env variables... diff --git a/apps/promptions-chat/vite.config.ts b/apps/promptions-chat/vite.config.ts index df5243f..2d5f444 100644 --- a/apps/promptions-chat/vite.config.ts +++ b/apps/promptions-chat/vite.config.ts @@ -1,9 +1,10 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { openaiProxy } from "@promptions/promptions-openai-proxy"; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), openaiProxy()], server: { port: 3003, }, diff --git a/apps/promptions-image/.env.example b/apps/promptions-image/.env.example index 9ee2de3..e79ba51 100644 --- a/apps/promptions-image/.env.example +++ b/apps/promptions-image/.env.example @@ -1,16 +1,19 @@ # Copy this file to .env -# Required: Set your API key -VITE_OPENAI_API_KEY=your_openai_api_key_here +# These variables are read by the Vite dev/preview server only. They are NOT +# prefixed with VITE_, so Vite cannot inline them into the browser bundle. + +# Required: your API key. Stays server-side; the browser talks to /api/openai. +OPENAI_API_KEY=your_openai_api_key_here # Optional: only set for Azure or other custom OpenAI-compatible endpoints. # Omit for standard OpenAI API usage. -# VITE_OPENAI_BASE_URL=https://your-resource.openai.azure.com +# OPENAI_BASE_URL=https://your-resource.openai.azure.com # Optional: API version is typically Azure-specific/custom-endpoint specific. -# Required when VITE_OPENAI_BASE_URL points at Azure OpenAI. -# VITE_OPENAI_API_VERSION=2024-12-01-preview +# Required when OPENAI_BASE_URL points at Azure OpenAI. +# OPENAI_API_VERSION=2024-12-01-preview # Optional: override the chat model used for prompt-related completions (defaults to gpt-5.4-nano). # The image-generation model is selected in the UI. -# VITE_OPENAI_MODEL=gpt-5.4-nano +# OPENAI_MODEL=gpt-5.4-nano diff --git a/apps/promptions-image/package.json b/apps/promptions-image/package.json index 53dc5c1..5c13032 100644 --- a/apps/promptions-image/package.json +++ b/apps/promptions-image/package.json @@ -21,6 +21,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@promptions/promptions-openai-proxy": "workspace:*", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", diff --git a/apps/promptions-image/src/services/ImageService.ts b/apps/promptions-image/src/services/ImageService.ts index f332ee1..a584c13 100644 --- a/apps/promptions-image/src/services/ImageService.ts +++ b/apps/promptions-image/src/services/ImageService.ts @@ -1,34 +1,37 @@ import OpenAI, { AzureOpenAI } from "openai"; import { ImageGenerationParams, GeneratedImage } from "../types"; +/** + * The OpenAI SDK requires a non-empty key. The proxy replaces it with the real + * credential, so this literal is all the browser ever sees. + */ +const PROXY_PLACEHOLDER_API_KEY = "injected-by-proxy"; + export class ImageService { private client: OpenAI; private chatModel: string; constructor() { - const apiKey = import.meta.env.VITE_OPENAI_API_KEY; - - if (!apiKey) { - throw new Error( - "OpenAI API key is required. Please set VITE_OPENAI_API_KEY in your environment variables.", - ); - } - - const baseURL = import.meta.env.VITE_OPENAI_BASE_URL; + // The API key is never available to the browser. Requests go to the + // same-origin proxy path, which injects the real credential + // server-side (see @promptions/promptions-openai-proxy). + const proxyUrl = `${window.location.origin}${import.meta.env.VITE_OPENAI_PROXY_PATH || "/api/openai"}`; const apiVersion = import.meta.env.VITE_OPENAI_API_VERSION; this.chatModel = import.meta.env.VITE_OPENAI_MODEL || "gpt-5.4-nano"; - this.client = baseURL - ? new AzureOpenAI({ - apiKey, - endpoint: baseURL, - apiVersion, - dangerouslyAllowBrowser: true, // Only for demo purposes - use a backend in production - }) - : new OpenAI({ - apiKey, - dangerouslyAllowBrowser: true, // Only for demo purposes - use a backend in production - }); + this.client = + import.meta.env.VITE_OPENAI_PROXY_MODE === "azure" + ? new AzureOpenAI({ + endpoint: proxyUrl, + apiVersion, + apiKey: PROXY_PLACEHOLDER_API_KEY, + dangerouslyAllowBrowser: true, + }) + : new OpenAI({ + baseURL: `${proxyUrl}/v1`, + apiKey: PROXY_PLACEHOLDER_API_KEY, + dangerouslyAllowBrowser: true, + }); } async generateImage(params: ImageGenerationParams, options?: { signal?: AbortSignal }): Promise { diff --git a/apps/promptions-image/src/vite-env.d.ts b/apps/promptions-image/src/vite-env.d.ts index e7a0439..4b7484f 100644 --- a/apps/promptions-image/src/vite-env.d.ts +++ b/apps/promptions-image/src/vite-env.d.ts @@ -1,8 +1,10 @@ /// interface ImportMetaEnv { - readonly VITE_OPENAI_API_KEY: string; - readonly VITE_OPENAI_BASE_URL?: string; + // Injected by @promptions/promptions-openai-proxy. Non-secret values only: + // the API key is read server-side from OPENAI_API_KEY and never exposed here. + readonly VITE_OPENAI_PROXY_PATH: string; + readonly VITE_OPENAI_PROXY_MODE: "azure" | "openai"; readonly VITE_OPENAI_API_VERSION?: string; readonly VITE_OPENAI_MODEL?: string; // more env variables... diff --git a/apps/promptions-image/vite.config.ts b/apps/promptions-image/vite.config.ts index bec44b1..5c7d780 100644 --- a/apps/promptions-image/vite.config.ts +++ b/apps/promptions-image/vite.config.ts @@ -1,9 +1,10 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { openaiProxy } from "@promptions/promptions-openai-proxy"; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), openaiProxy()], server: { port: 3004, }, diff --git a/packages/promptions-openai-proxy/README.md b/packages/promptions-openai-proxy/README.md new file mode 100644 index 0000000..0d85853 --- /dev/null +++ b/packages/promptions-openai-proxy/README.md @@ -0,0 +1,86 @@ +# @promptions/promptions-openai-proxy + +Vite plugin that keeps the OpenAI / Azure OpenAI credential on the server. + +The browser never receives an API key. Requests go to a same-origin path +(`/api/openai` by default), and the Vite dev/preview server attaches the real +credential before forwarding upstream. + +## Why + +`VITE_`-prefixed variables are inlined into the client bundle at build time, so +`VITE_OPENAI_API_KEY` would ship the secret in the served JavaScript. This +plugin reads `OPENAI_API_KEY` **without** the prefix, which makes that inlining +impossible. + +## Usage + +```ts +// vite.config.ts +import { defineConfig } from "vite"; +import { openaiProxy } from "@promptions/promptions-openai-proxy"; + +export default defineConfig({ + plugins: [openaiProxy()], +}); +``` + +```sh +# .env (git-ignored) — no VITE_ prefix +OPENAI_API_KEY=sk-... +# OPENAI_BASE_URL=https://your-resource.openai.azure.com +# OPENAI_API_VERSION=2024-12-01-preview +# OPENAI_MODEL=gpt-5.4-nano +``` + +Client code points the OpenAI SDK at the proxy and passes a placeholder key: + +```ts +const proxyUrl = `${window.location.origin}${import.meta.env.VITE_OPENAI_PROXY_PATH}`; + +const client = + import.meta.env.VITE_OPENAI_PROXY_MODE === "azure" + ? new AzureOpenAI({ + endpoint: proxyUrl, + apiVersion: import.meta.env.VITE_OPENAI_API_VERSION, + apiKey: "proxy-injects-the-real-key", + dangerouslyAllowBrowser: true, + }) + : new OpenAI({ + baseURL: `${proxyUrl}/v1`, + apiKey: "proxy-injects-the-real-key", + dangerouslyAllowBrowser: true, + }); +``` + +`dangerouslyAllowBrowser` is still required because the SDK refuses to run in a +browser otherwise, but there is no longer a real credential to leak. + +## Environment variables + +| Variable | Scope | Description | +| -------------------- | ------ | ------------------------------------------------------------------------ | +| `OPENAI_API_KEY` | server | Required. Never exposed to the client. | +| `OPENAI_BASE_URL` | server | Optional. Azure/custom endpoint. When set, the proxy uses Azure headers. | +| `OPENAI_API_VERSION` | client | Optional. Required for Azure. Not secret. | +| `OPENAI_MODEL` | client | Optional chat model override. Not secret. | + +The plugin exposes the non-secret values to client code as +`import.meta.env.VITE_OPENAI_PROXY_PATH`, `VITE_OPENAI_PROXY_MODE`, +`VITE_OPENAI_API_VERSION` and `VITE_OPENAI_MODEL`. + +## Request handling + +- Forwards the request path and query verbatim onto the upstream base URL, so + both OpenAI and Azure OpenAI URL shapes work unchanged. +- Injects `Authorization: Bearer ` (OpenAI) or `api-key: ` (Azure). +- Forwards only `accept`, `content-type` and `openai-beta` upstream; cookies and + the client's placeholder credential are dropped. +- Streams responses back unbuffered, so SSE token streaming works. +- Aborts the upstream request when the browser disconnects. + +## Scope + +Active for `vite dev` and `vite preview`. A static deployment of `dist/` has no +server, so it needs an equivalent proxy (for example a serverless function +holding the key) in front of it. diff --git a/packages/promptions-openai-proxy/package.json b/packages/promptions-openai-proxy/package.json new file mode 100644 index 0000000..c05bd7b --- /dev/null +++ b/packages/promptions-openai-proxy/package.json @@ -0,0 +1,37 @@ +{ + "name": "@promptions/promptions-openai-proxy", + "version": "1.0.0", + "description": "Vite plugin that proxies OpenAI/Azure OpenAI requests server-side so the API key never reaches the browser bundle", + "type": "module", + "main": "src/index.js", + "types": "src/index.d.ts", + "exports": { + ".": { + "types": "./src/index.d.ts", + "default": "./src/index.js" + } + }, + "license": "MIT", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "files": [ + "src", + "README.md" + ], + "keywords": [ + "vite", + "vite-plugin", + "openai", + "proxy", + "promptions" + ], + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0", + "vite": "^7.3.2" + }, + "peerDependencies": { + "vite": ">=5.0.0" + } +} diff --git a/packages/promptions-openai-proxy/project.json b/packages/promptions-openai-proxy/project.json new file mode 100644 index 0000000..1a70506 --- /dev/null +++ b/packages/promptions-openai-proxy/project.json @@ -0,0 +1,16 @@ +{ + "name": "promptions-openai-proxy", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/promptions-openai-proxy/src", + "projectType": "library", + "targets": { + "typecheck": { + "executor": "nx:run-commands", + "options": { + "command": "tsc --noEmit", + "cwd": "packages/promptions-openai-proxy" + } + } + }, + "tags": ["type:lib", "scope:promptions-openai-proxy"] +} diff --git a/packages/promptions-openai-proxy/src/index.d.ts b/packages/promptions-openai-proxy/src/index.d.ts new file mode 100644 index 0000000..0297e09 --- /dev/null +++ b/packages/promptions-openai-proxy/src/index.d.ts @@ -0,0 +1,12 @@ +import type { Plugin } from "vite"; + +export interface OpenAIProxyOptions { + /** Path the browser calls. Defaults to `/api/openai`. */ + path?: string; +} + +/** + * Proxies OpenAI / Azure OpenAI traffic through the Vite dev and preview + * servers so the API key stays on the server and never reaches the bundle. + */ +export declare function openaiProxy(options?: OpenAIProxyOptions): Plugin; diff --git a/packages/promptions-openai-proxy/src/index.js b/packages/promptions-openai-proxy/src/index.js new file mode 100644 index 0000000..158621f --- /dev/null +++ b/packages/promptions-openai-proxy/src/index.js @@ -0,0 +1,201 @@ +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { loadEnv } from "vite"; + +const DEFAULT_UPSTREAM = "https://api.openai.com"; +const DEFAULT_PROXY_PATH = "/api/openai"; + +/** + * Request headers forwarded upstream. Everything else (cookies, the client's + * placeholder credential, hop-by-hop headers) is dropped. + */ +const FORWARDED_REQUEST_HEADERS = ["accept", "content-type", "openai-beta"]; + +/** Response headers forwarded back to the browser. */ +const FORWARDED_RESPONSE_HEADERS = ["content-type", "cache-control"]; + +/** + * @typedef {"azure" | "openai"} ApiStyle + * + * @typedef {object} OpenAIProxyOptions + * @property {string} [path] Path the browser calls. Defaults to `/api/openai`. + * + * @typedef {object} ProxySettings + * @property {string} [apiKey] + * @property {string} upstream + * @property {ApiStyle} mode + */ + +/** + * Proxies OpenAI / Azure OpenAI traffic through the Vite dev and preview + * servers so the credential stays on the server. + * + * The key is read from `OPENAI_API_KEY` without the `VITE_` prefix, which means + * Vite never inlines it into the client bundle. The browser sends its requests + * to the proxy path with no credential; this plugin attaches the real one + * before forwarding upstream. + * + * @param {OpenAIProxyOptions} [options] + * @returns {import("vite").Plugin} + */ +export function openaiProxy(options = {}) { + const proxyPath = options.path ?? DEFAULT_PROXY_PATH; + + /** @type {ProxySettings} */ + let settings = { upstream: DEFAULT_UPSTREAM, mode: "openai" }; + + /** @type {import("vite").Connect.NextHandleFunction} */ + const handler = (req, res) => { + void forward(req, res, settings); + }; + + /** @param {(message: string) => void} warn */ + const warnIfUnconfigured = (warn) => { + if (!settings.apiKey) { + warn( + `[openai-proxy] OPENAI_API_KEY is not set. Requests to ${proxyPath} will fail. ` + + `Copy .env.example to .env and set OPENAI_API_KEY (note: no VITE_ prefix).`, + ); + } + }; + + return { + name: "promptions:openai-proxy", + + config(config, { mode }) { + const envDir = config.envDir ?? config.root ?? process.cwd(); + const env = loadEnv(mode, envDir, ""); + const upstream = env.OPENAI_BASE_URL?.trim(); + const style = env.OPENAI_API_STYLE?.trim().toLowerCase(); + + settings = { + apiKey: env.OPENAI_API_KEY?.trim() || undefined, + upstream: (upstream || DEFAULT_UPSTREAM).replace(/\/+$/, ""), + // A custom endpoint implies Azure unless told otherwise, which + // lets other OpenAI-compatible backends opt into bearer auth. + mode: style === "azure" || style === "openai" ? style : upstream ? "azure" : "openai", + }; + + return { + define: { + "import.meta.env.VITE_OPENAI_PROXY_PATH": JSON.stringify(proxyPath), + "import.meta.env.VITE_OPENAI_PROXY_MODE": JSON.stringify(settings.mode), + "import.meta.env.VITE_OPENAI_API_VERSION": JSON.stringify(env.OPENAI_API_VERSION?.trim() ?? ""), + "import.meta.env.VITE_OPENAI_MODEL": JSON.stringify(env.OPENAI_MODEL?.trim() ?? ""), + }, + }; + }, + + configureServer(server) { + warnIfUnconfigured((message) => server.config.logger.warn(message)); + server.middlewares.use(proxyPath, handler); + }, + + configurePreviewServer(server) { + warnIfUnconfigured((message) => server.config.logger.warn(message)); + server.middlewares.use(proxyPath, handler); + }, + }; +} + +/** + * @param {import("node:http").IncomingMessage} req + * @param {import("node:http").ServerResponse} res + * @param {ProxySettings} settings + */ +async function forward(req, res, settings) { + const apiKey = settings.apiKey; + + if (!apiKey) { + sendJson(res, 500, { + error: { + message: "OpenAI proxy is not configured. Set OPENAI_API_KEY (without the VITE_ prefix) in your .env.", + }, + }); + return; + } + + const controller = new AbortController(); + const abort = () => controller.abort(); + req.once("aborted", abort); + res.once("close", abort); + + const method = req.method ?? "GET"; + const headers = new Headers(); + + for (const name of FORWARDED_REQUEST_HEADERS) { + const value = req.headers[name]; + if (typeof value === "string") { + headers.set(name, value); + } + } + + if (settings.mode === "azure") { + headers.set("api-key", apiKey); + } else { + headers.set("authorization", `Bearer ${apiKey}`); + } + + try { + const body = method === "GET" || method === "HEAD" ? undefined : await readBody(req); + + const upstreamResponse = await fetch(`${settings.upstream}${req.url ?? "/"}`, { + method, + headers, + body, + redirect: "manual", + signal: controller.signal, + }); + + res.statusCode = upstreamResponse.status; + for (const name of FORWARDED_RESPONSE_HEADERS) { + const value = upstreamResponse.headers.get(name); + if (value) { + res.setHeader(name, value); + } + } + + if (!upstreamResponse.body) { + res.end(); + return; + } + + await pipeline(Readable.fromWeb(/** @type {any} */ (upstreamResponse.body)), res); + } catch (error) { + if (controller.signal.aborted) { + res.destroy(); + return; + } + sendJson(res, 502, { + error: { message: `OpenAI proxy request failed: ${/** @type {Error} */ (error).message}` }, + }); + } +} + +/** + * @param {import("node:http").IncomingMessage} req + * @returns {Promise} + */ +async function readBody(req) { + /** @type {Buffer[]} */ + const chunks = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return chunks.length > 0 ? Buffer.concat(chunks) : undefined; +} + +/** + * @param {import("node:http").ServerResponse} res + * @param {number} status + * @param {unknown} payload + */ +function sendJson(res, status, payload) { + if (res.headersSent) { + res.destroy(); + return; + } + res.statusCode = status; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(payload)); +} diff --git a/packages/promptions-openai-proxy/tsconfig.json b/packages/promptions-openai-proxy/tsconfig.json new file mode 100644 index 0000000..95e121f --- /dev/null +++ b/packages/promptions-openai-proxy/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "allowJs": true, + "checkJs": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/yarn.lock b/yarn.lock index 2711921..7f78d35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3613,6 +3613,7 @@ __metadata: dependencies: "@fluentui/react-components": "npm:^9.54.0" "@fluentui/react-icons": "npm:^2.0.258" + "@promptions/promptions-openai-proxy": "workspace:*" "@promptions/promptions-ui": "workspace:*" "@types/react": "npm:^18.3.12" "@types/react-dom": "npm:^18.3.1" @@ -3636,6 +3637,7 @@ __metadata: dependencies: "@fluentui/react-components": "npm:^9.54.0" "@fluentui/react-icons": "npm:^2.0.258" + "@promptions/promptions-openai-proxy": "workspace:*" "@promptions/promptions-ui": "workspace:*" "@types/react": "npm:^18.3.12" "@types/react-dom": "npm:^18.3.1" @@ -3663,6 +3665,18 @@ __metadata: languageName: unknown linkType: soft +"@promptions/promptions-openai-proxy@workspace:*, @promptions/promptions-openai-proxy@workspace:packages/promptions-openai-proxy": + version: 0.0.0-use.local + resolution: "@promptions/promptions-openai-proxy@workspace:packages/promptions-openai-proxy" + dependencies: + "@types/node": "npm:^20.0.0" + typescript: "npm:^5.0.0" + vite: "npm:^7.3.2" + peerDependencies: + vite: ">=5.0.0" + languageName: unknown + linkType: soft + "@promptions/promptions-ui@workspace:*, @promptions/promptions-ui@workspace:packages/promptions-ui": version: 0.0.0-use.local resolution: "@promptions/promptions-ui@workspace:packages/promptions-ui" From db379126e108d819e0e0143b92ac5b7d73138258 Mon Sep 17 00:00:00 2001 From: Jack Williams Date: Thu, 3 Sep 2026 12:29:26 +0200 Subject: [PATCH 2/6] Switch image generation to gpt-image-1 The current OpenAI images API rejects `response_format` and no longer serves `dall-e-3` or `dall-e-2` on new keys, so image generation failed with `unknown_parameter` / `model does not exist`. gpt-image-1 returns base64 payloads by default, so the parameter is redundant. Drop the DALL-E parameter type and correct the gpt-image-1 size and quality unions to the values the API accepts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/promptions-image/package.json | 2 +- apps/promptions-image/src/App.tsx | 4 ++-- .../src/services/ImageService.ts | 1 - apps/promptions-image/src/types.ts | 22 +++++-------------- 4 files changed, 9 insertions(+), 20 deletions(-) diff --git a/apps/promptions-image/package.json b/apps/promptions-image/package.json index 5c13032..e72c254 100644 --- a/apps/promptions-image/package.json +++ b/apps/promptions-image/package.json @@ -2,7 +2,7 @@ "name": "@promptions/promptions-image", "version": "1.0.0", "type": "module", - "description": "Image generation interface for promptions using OpenAI DALL-E and Fluent UI", + "description": "Image generation interface for promptions using OpenAI gpt-image-1 and Fluent UI", "license": "MIT", "scripts": { "dev": "vite --port 3004", diff --git a/apps/promptions-image/src/App.tsx b/apps/promptions-image/src/App.tsx index 8219e32..317aca8 100644 --- a/apps/promptions-image/src/App.tsx +++ b/apps/promptions-image/src/App.tsx @@ -222,10 +222,10 @@ function App() { const images = await imageService.generateImage( { - kind: "dall-e-3", + kind: "gpt-image-1", prompt: enhancedPrompt, size: "1024x1024", - quality: "hd", + quality: "high", n: 1, }, { diff --git a/apps/promptions-image/src/services/ImageService.ts b/apps/promptions-image/src/services/ImageService.ts index a584c13..276351f 100644 --- a/apps/promptions-image/src/services/ImageService.ts +++ b/apps/promptions-image/src/services/ImageService.ts @@ -45,7 +45,6 @@ export class ImageService { size: params.size, quality: params.quality, n: params.n || 1, - response_format: "b64_json", }, { signal: options?.signal, diff --git a/apps/promptions-image/src/types.ts b/apps/promptions-image/src/types.ts index 6f736f4..2de4fe2 100644 --- a/apps/promptions-image/src/types.ts +++ b/apps/promptions-image/src/types.ts @@ -4,8 +4,8 @@ export type State = { get: T; set: (fn: (prev: T) => void) => void }; // Image generation parameters export interface BaseImageGenerationParams { prompt: string; - size?: "1024x1024" | "1024x1792" | "1792x1024"; - quality?: "high" | "medium" | "low"; + size?: "1024x1024" | "1024x1536" | "1536x1024" | "auto"; + quality?: "high" | "medium" | "low" | "auto"; n?: number; } @@ -13,23 +13,13 @@ export interface BaseImageGenerationParams { export interface GPTImage1Params { kind: "gpt-image-1"; prompt: string; - size?: "1024x1024" | "1024x1792" | "1792x1024"; - quality?: "high" | "medium" | "low"; + size?: "1024x1024" | "1024x1536" | "1536x1024" | "auto"; + quality?: "high" | "medium" | "low" | "auto"; n?: number; } -// DALL-E 3 parameters -export interface DallE3Params { - kind: "dall-e-3"; - prompt: string; - size?: "1024x1024" | "1024x1792" | "1792x1024"; - quality?: "standard" | "hd"; - style?: "vivid" | "natural"; - n?: number; -} - -// Union type for all image generation parameters -export type ImageGenerationParams = GPTImage1Params | DallE3Params; +// Image generation parameters +export type ImageGenerationParams = GPTImage1Params; // Generated image result export interface GeneratedImage { From 6c1660f6ac2467a62e28f52441f8e6718ebb5d62 Mon Sep 17 00:00:00 2001 From: Jack Williams Date: Thu, 3 Sep 2026 14:04:11 +0200 Subject: [PATCH 3/6] Harden the proxy against leftover VITE_ credentials and Azure deployments Follow-up to code review of the proxy change: - Fail startup when VITE_OPENAI_API_KEY is set. Vite serves the whole import.meta.env object to the browser in dev, so a key left over from the pre-proxy setup is still handed to every page visitor even though no code references it. Warn about any other credential-shaped VITE_ variable (KEY/SECRET/TOKEN/PASSWORD). - Add OPENAI_IMAGE_MODEL. On Azure the SDK turns the request's model into a deployment name, so hardcoding gpt-image-1 404s unless the deployment happens to share the model id. - Send x-should-retry: false with the unconfigured-proxy 500 so the SDK does not turn a missing key into three requests and several seconds of backoff. - Document the migration in both .env.example files and the READMEs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 5 +- apps/promptions-chat/.env.example | 6 ++ apps/promptions-chat/README.md | 2 + apps/promptions-image/.env.example | 10 +++ .../src/services/ImageService.ts | 5 +- apps/promptions-image/src/vite-env.d.ts | 1 + packages/promptions-openai-proxy/README.md | 23 ++++--- packages/promptions-openai-proxy/src/index.js | 63 ++++++++++++++++--- 8 files changed, 98 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 42ece53..e35d5d2 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ The apps can call either the **standard OpenAI API** or your own **Azure OpenAI* > **The API key stays on the server.** These variables are deliberately **not** prefixed with `VITE_`, so Vite cannot inline them into the browser bundle. The dev/preview server proxies requests to your provider at `/api/openai` and attaches the credential server-side. See [`packages/promptions-openai-proxy`](packages/promptions-openai-proxy/README.md). +> **Upgrading from an earlier version?** Delete `VITE_OPENAI_API_KEY` from your `.env` files and rotate that key. Vite serves every `VITE_`-prefixed variable to client code, so a key left there is still readable by anyone loading the app even though no code references it. The dev server warns if it finds one. + Option A — .env files (recommended for local development): **Standard OpenAI** @@ -157,10 +159,11 @@ Both apps read these variables from their respective `.env` files. They are read | `OPENAI_BASE_URL` | Custom endpoint. Set this to use Azure OpenAI (e.g. `https://your-resource.openai.azure.com`) or another OpenAI-compatible service. | _(unset)_ | | `OPENAI_API_VERSION` | API version. **Required** when `OPENAI_BASE_URL` points at Azure OpenAI (e.g. `2024-12-01-preview`). | _(unset)_ | | `OPENAI_API_STYLE` | `openai` or `azure`. Overrides how the credential is sent, for OpenAI-compatible backends that need a custom `OPENAI_BASE_URL`. | inferred | +| `OPENAI_IMAGE_MODEL` | Image model used by the image app. On Azure OpenAI this is the image **deployment name**, which need not match the model id. | `gpt-image-1` | When `OPENAI_BASE_URL` is set, the apps use Azure OpenAI conventions (`api-key` header, deployment-based URLs); otherwise they use the standard OpenAI conventions (`Authorization: Bearer`). Set `OPENAI_API_STYLE=openai` to use a custom endpoint with standard OpenAI conventions. -> **Model compatibility:** The chat reference app uses `OPENAI_MODEL`, defaulting to `gpt-5.4-nano`. On Azure OpenAI, make sure the deployment named in `OPENAI_MODEL` targets a chat-completions-compatible model. +> **Model compatibility:** The chat reference app uses `OPENAI_MODEL`, defaulting to `gpt-5.4-nano`. On Azure OpenAI, make sure the deployment named in `OPENAI_MODEL` targets a chat-completions-compatible model, and set `OPENAI_IMAGE_MODEL` to your image deployment name — the image app otherwise requests the model id `gpt-image-1`, which Azure resolves as a deployment name. Start the dev servers: diff --git a/apps/promptions-chat/.env.example b/apps/promptions-chat/.env.example index 8772dbd..c7de82c 100644 --- a/apps/promptions-chat/.env.example +++ b/apps/promptions-chat/.env.example @@ -2,6 +2,12 @@ # These variables are read by the Vite dev/preview server only. They are NOT # prefixed with VITE_, so Vite cannot inline them into the browser bundle. +# +# MIGRATION: earlier versions of this app used VITE_OPENAI_API_KEY. The dev +# server refuses to start while that variable is set — delete it from your .env +# and rotate the credential. Vite serves every VITE_-prefixed variable to client +# code, so any key left there is exposed to the browser even though no code +# reads it any more. # Required: your API key. Stays server-side; the browser talks to /api/openai. OPENAI_API_KEY=your_openai_api_key_here diff --git a/apps/promptions-chat/README.md b/apps/promptions-chat/README.md index 9742320..c075fdb 100644 --- a/apps/promptions-chat/README.md +++ b/apps/promptions-chat/README.md @@ -56,6 +56,8 @@ OPENAI_MODEL=your_chat_deployment_name These variables are deliberately **not** prefixed with `VITE_`, so Vite cannot inline them into the browser bundle. The dev/preview server proxies requests at `/api/openai` and attaches the credential server-side. +If you used an earlier version of this app, delete `VITE_OPENAI_API_KEY` from your `.env` and rotate that key — Vite serves every `VITE_`-prefixed variable to client code, so it remains exposed to the browser even though no code reads it now. + When `OPENAI_BASE_URL` is set, the app uses Azure OpenAI conventions; otherwise it uses the standard OpenAI conventions. Set `OPENAI_API_STYLE=openai` to use a custom endpoint with standard OpenAI conventions. ### Development diff --git a/apps/promptions-image/.env.example b/apps/promptions-image/.env.example index e79ba51..ffa1cf3 100644 --- a/apps/promptions-image/.env.example +++ b/apps/promptions-image/.env.example @@ -2,6 +2,12 @@ # These variables are read by the Vite dev/preview server only. They are NOT # prefixed with VITE_, so Vite cannot inline them into the browser bundle. +# +# MIGRATION: earlier versions of this app used VITE_OPENAI_API_KEY. The dev +# server refuses to start while that variable is set — delete it from your .env +# and rotate the credential. Vite serves every VITE_-prefixed variable to client +# code, so any key left there is exposed to the browser even though no code +# reads it any more. # Required: your API key. Stays server-side; the browser talks to /api/openai. OPENAI_API_KEY=your_openai_api_key_here @@ -17,3 +23,7 @@ OPENAI_API_KEY=your_openai_api_key_here # Optional: override the chat model used for prompt-related completions (defaults to gpt-5.4-nano). # The image-generation model is selected in the UI. # OPENAI_MODEL=gpt-5.4-nano + +# Optional: override the image model. On Azure this must be your image +# *deployment* name, which need not match the underlying model ID. +# OPENAI_IMAGE_MODEL=gpt-image-1 diff --git a/apps/promptions-image/src/services/ImageService.ts b/apps/promptions-image/src/services/ImageService.ts index 276351f..7f07afc 100644 --- a/apps/promptions-image/src/services/ImageService.ts +++ b/apps/promptions-image/src/services/ImageService.ts @@ -10,6 +10,7 @@ const PROXY_PLACEHOLDER_API_KEY = "injected-by-proxy"; export class ImageService { private client: OpenAI; private chatModel: string; + private imageModel?: string; constructor() { // The API key is never available to the browser. Requests go to the @@ -18,6 +19,8 @@ export class ImageService { const proxyUrl = `${window.location.origin}${import.meta.env.VITE_OPENAI_PROXY_PATH || "/api/openai"}`; const apiVersion = import.meta.env.VITE_OPENAI_API_VERSION; this.chatModel = import.meta.env.VITE_OPENAI_MODEL || "gpt-5.4-nano"; + // Azure routes by deployment name, which need not match the model ID. + this.imageModel = import.meta.env.VITE_OPENAI_IMAGE_MODEL || undefined; this.client = import.meta.env.VITE_OPENAI_PROXY_MODE === "azure" @@ -40,7 +43,7 @@ export class ImageService { const response = await this.client.images.generate( { - model: params.kind, + model: this.imageModel ?? params.kind, prompt: params.prompt, size: params.size, quality: params.quality, diff --git a/apps/promptions-image/src/vite-env.d.ts b/apps/promptions-image/src/vite-env.d.ts index 4b7484f..c3edd34 100644 --- a/apps/promptions-image/src/vite-env.d.ts +++ b/apps/promptions-image/src/vite-env.d.ts @@ -7,6 +7,7 @@ interface ImportMetaEnv { readonly VITE_OPENAI_PROXY_MODE: "azure" | "openai"; readonly VITE_OPENAI_API_VERSION?: string; readonly VITE_OPENAI_MODEL?: string; + readonly VITE_OPENAI_IMAGE_MODEL?: string; // more env variables... } diff --git a/packages/promptions-openai-proxy/README.md b/packages/promptions-openai-proxy/README.md index 0d85853..41b42fc 100644 --- a/packages/promptions-openai-proxy/README.md +++ b/packages/promptions-openai-proxy/README.md @@ -58,16 +58,25 @@ browser otherwise, but there is no longer a real credential to leak. ## Environment variables -| Variable | Scope | Description | -| -------------------- | ------ | ------------------------------------------------------------------------ | -| `OPENAI_API_KEY` | server | Required. Never exposed to the client. | -| `OPENAI_BASE_URL` | server | Optional. Azure/custom endpoint. When set, the proxy uses Azure headers. | -| `OPENAI_API_VERSION` | client | Optional. Required for Azure. Not secret. | -| `OPENAI_MODEL` | client | Optional chat model override. Not secret. | +| Variable | Scope | Description | +| -------------------- | ------ | ---------------------------------------------------------------------------------------- | +| `OPENAI_API_KEY` | server | Required. Never exposed to the client. | +| `OPENAI_BASE_URL` | server | Optional. Azure/custom endpoint. When set, the proxy uses Azure headers. | +| `OPENAI_API_STYLE` | server | Optional. `openai` or `azure`. Overrides the inference above. | +| `OPENAI_API_VERSION` | client | Optional. Required for Azure. Not secret. | +| `OPENAI_MODEL` | client | Optional chat model override. Not secret. | +| `OPENAI_IMAGE_MODEL` | client | Optional image model override. Set this to your Azure image deployment name. Not secret. | The plugin exposes the non-secret values to client code as `import.meta.env.VITE_OPENAI_PROXY_PATH`, `VITE_OPENAI_PROXY_MODE`, -`VITE_OPENAI_API_VERSION` and `VITE_OPENAI_MODEL`. +`VITE_OPENAI_API_VERSION`, `VITE_OPENAI_MODEL` and `VITE_OPENAI_IMAGE_MODEL`. + +Startup **fails** if `VITE_OPENAI_API_KEY` is set, and warns if any other +`VITE_`-prefixed variable looks like a credential (`KEY`, `SECRET`, `TOKEN`, +`PASSWORD`). Vite serves the whole `import.meta.env` object to the browser in +dev, so such a variable is exposed to any page visitor even when no code +references it — most often a `VITE_OPENAI_API_KEY` left over from before this +proxy existed. ## Request handling diff --git a/packages/promptions-openai-proxy/src/index.js b/packages/promptions-openai-proxy/src/index.js index 158621f..642152f 100644 --- a/packages/promptions-openai-proxy/src/index.js +++ b/packages/promptions-openai-proxy/src/index.js @@ -44,19 +44,37 @@ export function openaiProxy(options = {}) { /** @type {ProxySettings} */ let settings = { upstream: DEFAULT_UPSTREAM, mode: "openai" }; + /** + * Names of `VITE_`-prefixed variables that look like credentials. Vite + * serves the whole `import.meta.env` object to the browser in dev, so any + * such variable is exposed regardless of whether code references it. + * @type {string[]} + */ + let exposedSecretNames = []; + /** @type {import("vite").Connect.NextHandleFunction} */ const handler = (req, res) => { void forward(req, res, settings); }; /** @param {(message: string) => void} warn */ - const warnIfUnconfigured = (warn) => { + const warnAboutConfig = (warn) => { if (!settings.apiKey) { warn( `[openai-proxy] OPENAI_API_KEY is not set. Requests to ${proxyPath} will fail. ` + `Copy .env.example to .env and set OPENAI_API_KEY (note: no VITE_ prefix).`, ); } + if (exposedSecretNames.length > 0) { + warn( + `[openai-proxy] SECURITY: ${exposedSecretNames.join(", ")} ${ + exposedSecretNames.length === 1 ? "is" : "are" + } exposed to the browser. ` + + `Vite serves every VITE_-prefixed variable to client code, so this value is readable by anyone ` + + `loading the app. It is left over from a version of this app that ran the API key in the browser. ` + + `Remove it from your .env — the proxy reads OPENAI_API_KEY instead — and rotate the credential.`, + ); + } }; return { @@ -68,6 +86,21 @@ export function openaiProxy(options = {}) { const upstream = env.OPENAI_BASE_URL?.trim(); const style = env.OPENAI_API_STYLE?.trim().toLowerCase(); + exposedSecretNames = Object.keys(env).filter( + (name) => name.startsWith("VITE_") && /KEY|SECRET|TOKEN|PASSWORD/i.test(name) && env[name], + ); + + // The variable this plugin exists to eliminate. Anything still set + // here is a live credential being served to the browser, so refuse + // to start rather than let it look fixed. + if (env.VITE_OPENAI_API_KEY) { + throw new Error( + `[openai-proxy] VITE_OPENAI_API_KEY is set in your environment. Vite serves every VITE_-prefixed ` + + `variable to client code, so this credential is readable by anyone loading the app. ` + + `Rename it to OPENAI_API_KEY (no VITE_ prefix) and rotate the key.`, + ); + } + settings = { apiKey: env.OPENAI_API_KEY?.trim() || undefined, upstream: (upstream || DEFAULT_UPSTREAM).replace(/\/+$/, ""), @@ -82,17 +115,18 @@ export function openaiProxy(options = {}) { "import.meta.env.VITE_OPENAI_PROXY_MODE": JSON.stringify(settings.mode), "import.meta.env.VITE_OPENAI_API_VERSION": JSON.stringify(env.OPENAI_API_VERSION?.trim() ?? ""), "import.meta.env.VITE_OPENAI_MODEL": JSON.stringify(env.OPENAI_MODEL?.trim() ?? ""), + "import.meta.env.VITE_OPENAI_IMAGE_MODEL": JSON.stringify(env.OPENAI_IMAGE_MODEL?.trim() ?? ""), }, }; }, configureServer(server) { - warnIfUnconfigured((message) => server.config.logger.warn(message)); + warnAboutConfig((message) => server.config.logger.warn(message)); server.middlewares.use(proxyPath, handler); }, configurePreviewServer(server) { - warnIfUnconfigured((message) => server.config.logger.warn(message)); + warnAboutConfig((message) => server.config.logger.warn(message)); server.middlewares.use(proxyPath, handler); }, }; @@ -107,11 +141,20 @@ async function forward(req, res, settings) { const apiKey = settings.apiKey; if (!apiKey) { - sendJson(res, 500, { - error: { - message: "OpenAI proxy is not configured. Set OPENAI_API_KEY (without the VITE_ prefix) in your .env.", + // 500 is accurate (the server is misconfigured), but the OpenAI SDK + // retries any 5xx unless told not to, which would turn a config typo + // into three requests and several seconds of backoff. + sendJson( + res, + 500, + { + error: { + message: + "OpenAI proxy is not configured. Set OPENAI_API_KEY (without the VITE_ prefix) in your .env.", + }, }, - }); + { "x-should-retry": "false" }, + ); return; } @@ -189,13 +232,17 @@ async function readBody(req) { * @param {import("node:http").ServerResponse} res * @param {number} status * @param {unknown} payload + * @param {Record} [headers] */ -function sendJson(res, status, payload) { +function sendJson(res, status, payload, headers = {}) { if (res.headersSent) { res.destroy(); return; } res.statusCode = status; res.setHeader("content-type", "application/json"); + for (const [name, value] of Object.entries(headers)) { + res.setHeader(name, value); + } res.end(JSON.stringify(payload)); } From 4af8ded3013f314c7a7939b57ea3e549769e18cf Mon Sep 17 00:00:00 2001 From: Jack Williams Date: Thu, 3 Sep 2026 14:51:48 +0200 Subject: [PATCH 4/6] Address review feedback on the OpenAI proxy - Cap proxied request bodies at 10 MB (413, no SDK retry) so a browser cannot stream an unbounded payload through the dev/preview server. - Emit the resolved non-secret client config into the build and warn on ite preview when dist/ was built with different proxy settings. - Add a node:test suite covering config resolution, build/preview drift and forwarding (credential injection, auth styles, streaming, 413, missing key), wired into a est target and the CI workflow. - Clarify the README migration note and the injected client values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 3 + README.md | 4 +- package.json | 1 + packages/promptions-openai-proxy/project.json | 7 + .../promptions-openai-proxy/src/index.d.ts | 5 + packages/promptions-openai-proxy/src/index.js | 123 +++++- .../promptions-openai-proxy/src/index.test.js | 351 ++++++++++++++++++ .../promptions-openai-proxy/tsconfig.json | 2 +- 8 files changed, 482 insertions(+), 14 deletions(-) create mode 100644 packages/promptions-openai-proxy/src/index.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84bf662..37fe80c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,5 +59,8 @@ jobs: - name: Typecheck run: yarn typecheck + - name: Test + run: yarn test + - name: Build run: yarn build diff --git a/README.md b/README.md index e35d5d2..65712bf 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ The apps can call either the **standard OpenAI API** or your own **Azure OpenAI* > **The API key stays on the server.** These variables are deliberately **not** prefixed with `VITE_`, so Vite cannot inline them into the browser bundle. The dev/preview server proxies requests to your provider at `/api/openai` and attaches the credential server-side. See [`packages/promptions-openai-proxy`](packages/promptions-openai-proxy/README.md). -> **Upgrading from an earlier version?** Delete `VITE_OPENAI_API_KEY` from your `.env` files and rotate that key. Vite serves every `VITE_`-prefixed variable to client code, so a key left there is still readable by anyone loading the app even though no code references it. The dev server warns if it finds one. +> **Upgrading from an earlier version?** Delete `VITE_OPENAI_API_KEY` from your `.env` files and rotate that key. Vite serves every `VITE_`-prefixed variable to client code, so a key left there is still readable by anyone loading the app even though no code references it. **The dev server refuses to start while that variable is set**, so you cannot miss this step. Option A — .env files (recommended for local development): @@ -150,7 +150,7 @@ $env:OPENAI_API_KEY="your_openai_api_key_here" ; yarn workspace @promptions/prom #### Configuration reference -Both apps read these variables from their respective `.env` files. They are read by the dev/preview server only and are never sent to the browser (except `OPENAI_API_VERSION` and `OPENAI_MODEL`, which are not secret). +Both apps read these variables from their respective `.env` files. They are read by the dev/preview server only. `OPENAI_API_KEY` and `OPENAI_BASE_URL` never reach the browser; `OPENAI_API_VERSION`, `OPENAI_MODEL` and `OPENAI_IMAGE_MODEL` are injected into client code, and are not secret. | Variable | Description | Default | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------- | diff --git a/package.json b/package.json index 3d10fda..4a679cd 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "scripts": { "clean": "yarn nx run-many -t clean", "typecheck": "yarn nx run-many -t typecheck", + "test": "yarn nx run-many -t test", "build": "yarn nx run-many -t build", "prettier:check": "prettier --check .", "prettier:write": "prettier --write ." diff --git a/packages/promptions-openai-proxy/project.json b/packages/promptions-openai-proxy/project.json index 1a70506..79943de 100644 --- a/packages/promptions-openai-proxy/project.json +++ b/packages/promptions-openai-proxy/project.json @@ -10,6 +10,13 @@ "command": "tsc --noEmit", "cwd": "packages/promptions-openai-proxy" } + }, + "test": { + "executor": "nx:run-commands", + "options": { + "command": "node --test src/index.test.js", + "cwd": "packages/promptions-openai-proxy" + } } }, "tags": ["type:lib", "scope:promptions-openai-proxy"] diff --git a/packages/promptions-openai-proxy/src/index.d.ts b/packages/promptions-openai-proxy/src/index.d.ts index 0297e09..44d3433 100644 --- a/packages/promptions-openai-proxy/src/index.d.ts +++ b/packages/promptions-openai-proxy/src/index.d.ts @@ -3,6 +3,11 @@ import type { Plugin } from "vite"; export interface OpenAIProxyOptions { /** Path the browser calls. Defaults to `/api/openai`. */ path?: string; + /** + * Largest request body forwarded upstream, in bytes. Defaults to 10 MiB. + * Larger requests are rejected with 413. + */ + maxBodyBytes?: number; } /** diff --git a/packages/promptions-openai-proxy/src/index.js b/packages/promptions-openai-proxy/src/index.js index 642152f..0f7b0b8 100644 --- a/packages/promptions-openai-proxy/src/index.js +++ b/packages/promptions-openai-proxy/src/index.js @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { loadEnv } from "vite"; @@ -5,6 +7,17 @@ import { loadEnv } from "vite"; const DEFAULT_UPSTREAM = "https://api.openai.com"; const DEFAULT_PROXY_PATH = "/api/openai"; +/** + * Request bodies are buffered before being forwarded, so they need a ceiling: + * the proxy is unauthenticated, and a dev server bound to a reachable + * interface would otherwise let one request grow the process heap without + * limit. Chat and image prompts are JSON payloads far below this. + */ +const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024; + +/** Name of the build manifest used to detect build/preview config drift. */ +const CLIENT_CONFIG_FILE = "openai-proxy.config.json"; + /** * Request headers forwarded upstream. Everything else (cookies, the client's * placeholder credential, hop-by-hop headers) is dropped. @@ -19,11 +32,14 @@ const FORWARDED_RESPONSE_HEADERS = ["content-type", "cache-control"]; * * @typedef {object} OpenAIProxyOptions * @property {string} [path] Path the browser calls. Defaults to `/api/openai`. + * @property {number} [maxBodyBytes] Largest request body forwarded upstream. + * Defaults to 10 MiB. Larger requests are rejected with 413. * * @typedef {object} ProxySettings * @property {string} [apiKey] * @property {string} upstream * @property {ApiStyle} mode + * @property {number} maxBodyBytes */ /** @@ -40,9 +56,18 @@ const FORWARDED_RESPONSE_HEADERS = ["content-type", "cache-control"]; */ export function openaiProxy(options = {}) { const proxyPath = options.path ?? DEFAULT_PROXY_PATH; + const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES; /** @type {ProxySettings} */ - let settings = { upstream: DEFAULT_UPSTREAM, mode: "openai" }; + let settings = { upstream: DEFAULT_UPSTREAM, mode: "openai", maxBodyBytes }; + + /** + * The values baked into client code by `define`. Recorded into the build + * so `vite preview` can detect that it is serving a bundle built with + * different settings than the proxy is now running with. + * @type {Record} + */ + let clientConfig = {}; /** * Names of `VITE_`-prefixed variables that look like credentials. Vite @@ -107,19 +132,37 @@ export function openaiProxy(options = {}) { // A custom endpoint implies Azure unless told otherwise, which // lets other OpenAI-compatible backends opt into bearer auth. mode: style === "azure" || style === "openai" ? style : upstream ? "azure" : "openai", + maxBodyBytes, + }; + + clientConfig = { + VITE_OPENAI_PROXY_PATH: proxyPath, + VITE_OPENAI_PROXY_MODE: settings.mode, + VITE_OPENAI_API_VERSION: env.OPENAI_API_VERSION?.trim() ?? "", + VITE_OPENAI_MODEL: env.OPENAI_MODEL?.trim() ?? "", + VITE_OPENAI_IMAGE_MODEL: env.OPENAI_IMAGE_MODEL?.trim() ?? "", }; return { - define: { - "import.meta.env.VITE_OPENAI_PROXY_PATH": JSON.stringify(proxyPath), - "import.meta.env.VITE_OPENAI_PROXY_MODE": JSON.stringify(settings.mode), - "import.meta.env.VITE_OPENAI_API_VERSION": JSON.stringify(env.OPENAI_API_VERSION?.trim() ?? ""), - "import.meta.env.VITE_OPENAI_MODEL": JSON.stringify(env.OPENAI_MODEL?.trim() ?? ""), - "import.meta.env.VITE_OPENAI_IMAGE_MODEL": JSON.stringify(env.OPENAI_IMAGE_MODEL?.trim() ?? ""), - }, + define: Object.fromEntries( + Object.entries(clientConfig).map(([name, value]) => [ + `import.meta.env.${name}`, + JSON.stringify(value), + ]), + ), }; }, + generateBundle() { + // `vite preview` serves a prebuilt bundle, so the values above are + // already frozen into it. Record them so preview can compare. + this.emitFile({ + type: "asset", + fileName: CLIENT_CONFIG_FILE, + source: JSON.stringify(clientConfig, null, 2), + }); + }, + configureServer(server) { warnAboutConfig((message) => server.config.logger.warn(message)); server.middlewares.use(proxyPath, handler); @@ -127,9 +170,39 @@ export function openaiProxy(options = {}) { configurePreviewServer(server) { warnAboutConfig((message) => server.config.logger.warn(message)); + warnAboutBuildDrift(server.config, (message) => server.config.logger.warn(message)); server.middlewares.use(proxyPath, handler); }, }; + + /** + * @param {{ root: string, build: { outDir: string } }} config + * @param {(message: string) => void} warn + */ + function warnAboutBuildDrift(config, warn) { + const manifestPath = path.resolve(config.root, config.build.outDir, CLIENT_CONFIG_FILE); + let built; + try { + built = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch { + // Built by an older version of this plugin, or not built at all. + return; + } + + const drifted = Object.keys(clientConfig).filter((name) => built[name] !== clientConfig[name]); + if (drifted.length === 0) { + return; + } + + warn( + `[openai-proxy] The bundle in ${config.build.outDir} was built with different settings than this ` + + `preview server is using: ` + + drifted.map((name) => `${name} was "${built[name]}", now "${clientConfig[name]}"`).join("; ") + + `. Client code reads these values at build time, so preview will not pick up the new ones — ` + + `for example a bundle built without Azure settings keeps sending OpenAI-style paths while the ` + + `proxy talks to Azure. Rebuild before previewing.`, + ); + } } /** @@ -180,7 +253,7 @@ async function forward(req, res, settings) { } try { - const body = method === "GET" || method === "HEAD" ? undefined : await readBody(req); + const body = method === "GET" || method === "HEAD" ? undefined : await readBody(req, settings.maxBodyBytes); const upstreamResponse = await fetch(`${settings.upstream}${req.url ?? "/"}`, { method, @@ -209,21 +282,49 @@ async function forward(req, res, settings) { res.destroy(); return; } + if (error instanceof BodyTooLargeError) { + sendJson( + res, + 413, + { error: { message: `Request body exceeds the proxy limit of ${settings.maxBodyBytes} bytes.` } }, + { "x-should-retry": "false" }, + ); + req.destroy(); + return; + } sendJson(res, 502, { error: { message: `OpenAI proxy request failed: ${/** @type {Error} */ (error).message}` }, }); } } +class BodyTooLargeError extends Error {} + /** + * Buffers the request body, refusing to grow past `limit` bytes. + * * @param {import("node:http").IncomingMessage} req + * @param {number} limit * @returns {Promise} */ -async function readBody(req) { +async function readBody(req, limit) { + const declared = Number(req.headers["content-length"]); + if (Number.isFinite(declared) && declared > limit) { + throw new BodyTooLargeError(); + } + /** @type {Buffer[]} */ const chunks = []; + let size = 0; for await (const chunk of req) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.byteLength; + // Checked per chunk so a request that lies about (or omits) its + // content-length cannot stream past the limit either. + if (size > limit) { + throw new BodyTooLargeError(); + } + chunks.push(buffer); } return chunks.length > 0 ? Buffer.concat(chunks) : undefined; } diff --git a/packages/promptions-openai-proxy/src/index.test.js b/packages/promptions-openai-proxy/src/index.test.js new file mode 100644 index 0000000..42981a7 --- /dev/null +++ b/packages/promptions-openai-proxy/src/index.test.js @@ -0,0 +1,351 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, beforeEach, describe, test } from "node:test"; +import { openaiProxy } from "./index.js"; + +const PROXY_PATH = "/api/openai"; +const PLACEHOLDER = "injected-by-proxy"; + +/** `loadEnv` merges `process.env`, so ambient values would leak into tests. */ +const MANAGED_ENV = [ + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_API_STYLE", + "OPENAI_API_VERSION", + "OPENAI_MODEL", + "OPENAI_IMAGE_MODEL", + "VITE_OPENAI_API_KEY", +]; + +beforeEach(() => { + for (const name of MANAGED_ENV) delete process.env[name]; +}); + +/** Writes a throwaway env dir so each test gets an isolated configuration. */ +function envDirWith(vars) { + const dir = mkdtempSync(join(tmpdir(), "openai-proxy-test-")); + const contents = Object.entries(vars) + .map(([name, value]) => `${name}=${value}`) + .join("\n"); + writeFileSync(join(dir, ".env"), contents); + return dir; +} + +/** + * Applies the plugin's config hook and returns what it produced, mirroring how + * Vite drives it. + */ +function configure(vars, options = {}) { + const plugin = openaiProxy(options); + const warnings = []; + const result = plugin.config({ envDir: envDirWith(vars) }, { mode: "development" }); + const start = () => { + plugin.configureServer({ + config: { logger: { warn: (message) => warnings.push(message) } }, + middlewares: { use: () => {} }, + }); + return warnings; + }; + return { plugin, define: result?.define ?? {}, start }; +} + +describe("client configuration", () => { + test("refuses to start while the pre-proxy browser key is set", () => { + assert.throws( + () => configure({ OPENAI_API_KEY: "sk-server", VITE_OPENAI_API_KEY: "sk-exposed" }), + (error) => { + assert.match(error.message, /VITE_OPENAI_API_KEY/); + // The message is a startup banner; it must not print the secret. + assert.ok(!error.message.includes("sk-exposed")); + return true; + }, + ); + }); + + test("warns about other credential-shaped client variables without echoing them", () => { + const { start } = configure({ OPENAI_API_KEY: "sk-server", VITE_SOME_TOKEN: "abc123" }); + const warnings = start().join("\n"); + assert.match(warnings, /VITE_SOME_TOKEN/); + assert.ok(!warnings.includes("abc123")); + }); + + test("does not warn when nothing is exposed", () => { + const { start } = configure({ OPENAI_API_KEY: "sk-server", VITE_APP_TITLE: "Hello" }); + assert.deepEqual(start(), []); + }); + + test("warns when no key is configured at all", () => { + const { start } = configure({}); + assert.match(start().join("\n"), /OPENAI_API_KEY/); + }); + + test("never hands the credential to client code", () => { + const { define } = configure({ OPENAI_API_KEY: "sk-server", OPENAI_MODEL: "gpt-5.4-nano" }); + const serialized = JSON.stringify(define); + assert.ok(!serialized.includes("sk-server")); + assert.ok(Object.keys(define).every((name) => !/API_KEY/i.test(name))); + // Non-secret settings still have to reach the client. + assert.match(serialized, /gpt-5\.4-nano/); + }); + + test("infers the auth style from the endpoint and honours an explicit override", () => { + const styleOf = (vars) => JSON.parse(configure(vars).define["import.meta.env.VITE_OPENAI_PROXY_MODE"]); + + assert.equal(styleOf({ OPENAI_API_KEY: "k" }), "openai"); + assert.equal(styleOf({ OPENAI_API_KEY: "k", OPENAI_BASE_URL: "https://r.openai.azure.com" }), "azure"); + assert.equal( + styleOf({ OPENAI_API_KEY: "k", OPENAI_BASE_URL: "https://compatible.example", OPENAI_API_STYLE: "openai" }), + "openai", + ); + assert.equal(styleOf({ OPENAI_API_KEY: "k", OPENAI_API_STYLE: "azure" }), "azure"); + }); +}); + +describe("build and preview consistency", () => { + test("warns when the built bundle was made with different settings", () => { + const built = configure({ OPENAI_API_KEY: "k", OPENAI_MODEL: "built-model" }); + let emitted; + built.plugin.generateBundle.call({ emitFile: (file) => (emitted = file) }); + + const outDir = mkdtempSync(join(tmpdir(), "openai-proxy-dist-")); + writeFileSync(join(outDir, emitted.fileName), emitted.source); + + const previewWith = (vars) => { + const plugin = openaiProxy(); + plugin.config({ envDir: envDirWith(vars) }, { mode: "development" }); + const warnings = []; + plugin.configurePreviewServer({ + config: { + root: outDir, + build: { outDir: "." }, + logger: { warn: (message) => warnings.push(message) }, + }, + middlewares: { use: () => {} }, + }); + return warnings.join("\n"); + }; + + assert.equal(previewWith({ OPENAI_API_KEY: "k", OPENAI_MODEL: "built-model" }), ""); + + const drifted = previewWith({ OPENAI_API_KEY: "k", OPENAI_MODEL: "different-model" }); + assert.match(drifted, /VITE_OPENAI_MODEL/); + assert.match(drifted, /different-model/); + }); +}); + +describe("forwarding", () => { + /** @type {import("node:http").Server} */ + let upstream; + /** @type {string} */ + let upstreamUrl; + /** @type {Array<{ url: string, headers: Record, body: string }>} */ + let received; + + /** @param {import("node:http").Server} server */ + const portOf = (server) => /** @type {import("node:net").AddressInfo} */ (server.address()).port; + + before(async () => { + upstream = createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + received.push({ + url: req.url ?? "", + headers: /** @type {Record} */ (req.headers), + body: Buffer.concat(chunks).toString("utf8"), + }); + + if (req.url?.includes("/stream")) { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write("data: first\n\n"); + setTimeout(() => { + res.write("data: second\n\n"); + res.end(); + }, 150); + return; + } + + res.writeHead(200, { "content-type": "application/json", "x-upstream-secret": "must-not-be-relayed" }); + res.end(JSON.stringify({ ok: true })); + }); + await new Promise((resolve) => upstream.listen(0, "127.0.0.1", () => resolve(undefined))); + upstreamUrl = `http://127.0.0.1:${portOf(upstream)}`; + }); + + after(() => upstream.close()); + + beforeEach(() => { + received = []; + }); + + /** Starts a server hosting the proxy middleware, as Vite's connect app does. */ + async function startProxy(vars, options = {}) { + const plugin = openaiProxy(options); + plugin.config({ envDir: envDirWith(vars) }, { mode: "development" }); + + let middleware; + plugin.configureServer({ + config: { logger: { warn: () => {} } }, + middlewares: { use: (_path, handler) => (middleware = handler) }, + }); + + const server = createServer((req, res) => { + // connect strips the mount path before invoking the middleware. + req.url = (req.url ?? "").slice(PROXY_PATH.length) || "/"; + middleware(req, res, () => {}); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(undefined))); + return { + url: `http://127.0.0.1:${portOf(server)}${PROXY_PATH}`, + close: () => server.close(), + }; + } + + test("injects the credential and drops what the browser sent", async () => { + const proxy = await startProxy({ OPENAI_API_KEY: "sk-secret", OPENAI_BASE_URL: upstreamUrl }); + try { + const response = await fetch(`${proxy.url}/v1/chat/completions`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${PLACEHOLDER}`, + cookie: "session=should-not-travel", + }, + body: JSON.stringify({ messages: [] }), + }); + + assert.equal(response.status, 200); + assert.equal(received.length, 1); + + const [request] = received; + assert.ok(!JSON.stringify(request.headers).includes(PLACEHOLDER)); + assert.equal(request.headers.cookie, undefined); + assert.match(request.body, /messages/); + + // Response headers the upstream sets are not blindly relayed. + assert.equal(response.headers.get("x-upstream-secret"), null); + } finally { + proxy.close(); + } + }); + + test("uses the auth scheme each provider expects and preserves its URL shape", async () => { + const azure = await startProxy({ + OPENAI_API_KEY: "sk-secret", + OPENAI_BASE_URL: upstreamUrl, + OPENAI_API_STYLE: "azure", + }); + try { + await fetch( + `${azure.url}/openai/deployments/my-deployment/chat/completions?api-version=2024-12-01-preview`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }, + ); + } finally { + azure.close(); + } + + const azureRequest = received.at(-1); + assert.equal(azureRequest.headers["api-key"], "sk-secret"); + assert.equal(azureRequest.headers.authorization, undefined); + assert.match(azureRequest.url, /^\/openai\/deployments\/my-deployment\/.*api-version=/); + + const openai = await startProxy({ + OPENAI_API_KEY: "sk-secret", + OPENAI_BASE_URL: upstreamUrl, + OPENAI_API_STYLE: "openai", + }); + try { + await fetch(`${openai.url}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + } finally { + openai.close(); + } + + const openaiRequest = received.at(-1); + assert.equal(openaiRequest.headers["api-key"], undefined); + assert.ok(openaiRequest.headers.authorization.includes("sk-secret")); + assert.equal(openaiRequest.url, "/v1/chat/completions"); + }); + + test("streams responses instead of buffering them", async () => { + const proxy = await startProxy({ OPENAI_API_KEY: "sk-secret", OPENAI_BASE_URL: upstreamUrl }); + try { + const response = await fetch(`${proxy.url}/v1/stream`, { method: "POST", body: "{}" }); + assert.ok(response.body); + const reader = response.body.getReader(); + + const firstChunkAt = Date.now(); + const first = await reader.read(); + const elapsed = Date.now() - firstChunkAt; + + assert.ok(!first.done); + // The upstream holds the connection open for 150ms after the first + // frame; a buffering proxy could not deliver anything before then. + assert.ok(elapsed < 140, `first chunk took ${elapsed}ms`); + await reader.cancel(); + } finally { + proxy.close(); + } + }); + + test("rejects oversized bodies without contacting the upstream", async () => { + const proxy = await startProxy( + { OPENAI_API_KEY: "sk-secret", OPENAI_BASE_URL: upstreamUrl }, + { maxBodyBytes: 64 }, + ); + try { + const response = await fetch(`${proxy.url}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "x".repeat(5000), + }); + + assert.equal(response.status, 413); + assert.equal(received.length, 0); + } finally { + proxy.close(); + } + }); + + test("accepts bodies within the limit", async () => { + const proxy = await startProxy( + { OPENAI_API_KEY: "sk-secret", OPENAI_BASE_URL: upstreamUrl }, + { maxBodyBytes: 5000 }, + ); + try { + const response = await fetch(`${proxy.url}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "x".repeat(1000) }), + }); + + assert.equal(response.status, 200); + assert.equal(received.length, 1); + } finally { + proxy.close(); + } + }); + + test("fails without retries when no credential is configured", async () => { + const proxy = await startProxy({ OPENAI_BASE_URL: upstreamUrl }); + try { + const response = await fetch(`${proxy.url}/v1/chat/completions`, { method: "POST", body: "{}" }); + + assert.equal(response.status, 500); + // The OpenAI SDK retries 5xx unless told the failure is terminal. + assert.equal(response.headers.get("x-should-retry"), "false"); + assert.equal(received.length, 0); + } finally { + proxy.close(); + } + }); +}); diff --git a/packages/promptions-openai-proxy/tsconfig.json b/packages/promptions-openai-proxy/tsconfig.json index 95e121f..f50a634 100644 --- a/packages/promptions-openai-proxy/tsconfig.json +++ b/packages/promptions-openai-proxy/tsconfig.json @@ -15,5 +15,5 @@ "isolatedModules": true }, "include": ["src/**/*"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "src/**/*.test.js"] } From 31027e80ed624997d5540596c580b06bbb23e2c4 Mon Sep 17 00:00:00 2001 From: Jack Williams Date: Thu, 3 Sep 2026 15:04:24 +0200 Subject: [PATCH 5/6] Drop dead jest test targets from promptions-llm and promptions-ui Neither package has any test files and jest is not a dependency anywhere in the workspace, so both targets failed unconditionally. They only surfaced now that CI runs \yarn test\. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/promptions-llm/project.json | 7 ------- packages/promptions-ui/project.json | 7 ------- 2 files changed, 14 deletions(-) diff --git a/packages/promptions-llm/project.json b/packages/promptions-llm/project.json index d5f3a6d..3b3f1ba 100644 --- a/packages/promptions-llm/project.json +++ b/packages/promptions-llm/project.json @@ -18,13 +18,6 @@ "cwd": "packages/promptions-llm" } }, - "test": { - "executor": "nx:run-commands", - "options": { - "command": "jest", - "cwd": "packages/promptions-llm" - } - }, "clean": { "executor": "nx:run-commands", "options": { diff --git a/packages/promptions-ui/project.json b/packages/promptions-ui/project.json index bff1a87..5bf52d8 100644 --- a/packages/promptions-ui/project.json +++ b/packages/promptions-ui/project.json @@ -18,13 +18,6 @@ "cwd": "packages/promptions-ui" } }, - "test": { - "executor": "nx:run-commands", - "options": { - "command": "jest", - "cwd": "packages/promptions-ui" - } - }, "clean": { "executor": "nx:run-commands", "options": { From 3d48e6c969c58b15e64e55ab62c33dbc426ddda7 Mon Sep 17 00:00:00 2001 From: Jack Williams Date: Thu, 3 Sep 2026 15:11:34 +0200 Subject: [PATCH 6/6] Pin the proxy plugin's vite devDependency to the workspace version Aligns with the 7.3.5 the apps use so the lockfile does not gain a second vite resolution and its esbuild platform packages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/promptions-openai-proxy/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/promptions-openai-proxy/package.json b/packages/promptions-openai-proxy/package.json index c05bd7b..0b9a7b6 100644 --- a/packages/promptions-openai-proxy/package.json +++ b/packages/promptions-openai-proxy/package.json @@ -29,7 +29,7 @@ "devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.0.0", - "vite": "^7.3.2" + "vite": "7.3.5" }, "peerDependencies": { "vite": ">=5.0.0" diff --git a/yarn.lock b/yarn.lock index 7f78d35..7b3281f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3671,7 +3671,7 @@ __metadata: dependencies: "@types/node": "npm:^20.0.0" typescript: "npm:^5.0.0" - vite: "npm:^7.3.2" + vite: "npm:7.3.5" peerDependencies: vite: ">=5.0.0" languageName: unknown