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"