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 326b7fa..65712bf 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,11 @@ 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). + +> **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): @@ -106,9 +110,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 +121,49 @@ 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. `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 | -| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------- | -| `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 | +| `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 `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, 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 9a073a8..c7de82c 100644 --- a/apps/promptions-chat/.env.example +++ b/apps/promptions-chat/.env.example @@ -1,15 +1,24 @@ # 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. +# +# 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 # 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..c075fdb 100644 --- a/apps/promptions-chat/README.md +++ b/apps/promptions-chat/README.md @@ -41,20 +41,24 @@ 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. + +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 @@ -92,16 +96,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..ffa1cf3 100644 --- a/apps/promptions-image/.env.example +++ b/apps/promptions-image/.env.example @@ -1,16 +1,29 @@ # 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. +# +# 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 # 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 + +# 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/package.json b/apps/promptions-image/package.json index 53dc5c1..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", @@ -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/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 f332ee1..7f07afc 100644 --- a/apps/promptions-image/src/services/ImageService.ts +++ b/apps/promptions-image/src/services/ImageService.ts @@ -1,34 +1,40 @@ 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; + private imageModel?: 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"; + // Azure routes by deployment name, which need not match the model ID. + this.imageModel = import.meta.env.VITE_OPENAI_IMAGE_MODEL || undefined; - 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 { @@ -37,12 +43,11 @@ 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, 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 { diff --git a/apps/promptions-image/src/vite-env.d.ts b/apps/promptions-image/src/vite-env.d.ts index e7a0439..c3edd34 100644 --- a/apps/promptions-image/src/vite-env.d.ts +++ b/apps/promptions-image/src/vite-env.d.ts @@ -1,10 +1,13 @@ /// 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; + readonly VITE_OPENAI_IMAGE_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/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-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-openai-proxy/README.md b/packages/promptions-openai-proxy/README.md new file mode 100644 index 0000000..41b42fc --- /dev/null +++ b/packages/promptions-openai-proxy/README.md @@ -0,0 +1,95 @@ +# @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_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`, `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 + +- 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..0b9a7b6 --- /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.5" + }, + "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..79943de --- /dev/null +++ b/packages/promptions-openai-proxy/project.json @@ -0,0 +1,23 @@ +{ + "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" + } + }, + "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 new file mode 100644 index 0000000..44d3433 --- /dev/null +++ b/packages/promptions-openai-proxy/src/index.d.ts @@ -0,0 +1,17 @@ +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; +} + +/** + * 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..0f7b0b8 --- /dev/null +++ b/packages/promptions-openai-proxy/src/index.js @@ -0,0 +1,349 @@ +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"; + +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. + */ +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`. + * @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 + */ + +/** + * 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; + const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES; + + /** @type {ProxySettings} */ + 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 + * 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 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 { + 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(); + + 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(/\/+$/, ""), + // 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: 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); + }, + + 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.`, + ); + } +} + +/** + * @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) { + // 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; + } + + 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, settings.maxBodyBytes); + + 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; + } + 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, 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) { + 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; +} + +/** + * @param {import("node:http").ServerResponse} res + * @param {number} status + * @param {unknown} payload + * @param {Record} [headers] + */ +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)); +} 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 new file mode 100644 index 0000000..f50a634 --- /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", "src/**/*.test.js"] +} 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": { diff --git a/yarn.lock b/yarn.lock index 2711921..7b3281f 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.5" + 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"