Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ VITE_FIREBASE_APP_ID=
# Default provider is "gemini" (Firebase AI Logic, configured above) — leaving all of
# the below blank keeps the current behavior. Set a key (or proxy) to switch providers
# with NO code changes.
# VITE_LLM_PROVIDER=gemini # gemini | openai | anthropic (auto-detected from keys if unset)
# VITE_LLM_PROVIDER=gemini # gemini | openai | anthropic | openai-proxy (auto-detected from keys if unset)
# VITE_OPENAI_API_KEY=
# VITE_ANTHROPIC_API_KEY=
# Optional model overrides (otherwise sensible defaults in providers/openai.ts & anthropic.ts):
Expand All @@ -21,3 +21,17 @@ VITE_FIREBASE_APP_ID=
# SECURITY: a raw key here is exposed in the browser bundle. For production, leave keys blank
# and set a backend proxy instead (it injects the real key server-side):
# VITE_LLM_PROXY_URL=
#
# --- SECURE OpenAI server proxy (RECOMMENDED for OpenAI; key never touches the browser) ---
# Route OpenAI through the free-tier Cloudflare Worker in `worker/` (no Firebase Blaze
# plan needed). The OpenAI key is read server-side from the Worker's OPENAI_API_KEY
# secret — it is NEVER set here or shipped to clients. The Worker requires a valid
# Firebase ID token (you must be signed in) and proxies the request to OpenAI.
#
# Deploy the Worker first (see worker/README.md), then set BOTH of these and rebuild:
# VITE_LLM_PROVIDER=openai-proxy
# VITE_AI_PROXY_URL=https://suited-ai-proxy.<your-subdomain>.workers.dev
# (the client appends /chat automatically; the base URL or a full .../chat URL both work)
#
# Optional model override (the Worker defaults to gpt-4o-mini):
# VITE_OPENAI_MODEL=gpt-4o-mini
17 changes: 14 additions & 3 deletions web/src/lib/ai/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
import { geminiProvider } from './gemini'
import { openaiProvider } from './openai'
import { anthropicProvider } from './anthropic'
import { openaiProxyProvider } from './openai-proxy'

export type LLMProviderId = 'gemini' | 'openai' | 'anthropic'
export type LLMProviderId = 'gemini' | 'openai' | 'anthropic' | 'openai-proxy'

export type LLMProvider = {
id: LLMProviderId
Expand All @@ -36,6 +37,7 @@ const PROVIDERS: Record<LLMProviderId, LLMProvider> = {
gemini: geminiProvider,
openai: openaiProvider,
anthropic: anthropicProvider,
'openai-proxy': openaiProxyProvider,
}

/** Read a string env var, trimmed; non-strings (incl. undefined) become ''. */
Expand All @@ -45,16 +47,25 @@ function readEnv(value: unknown): string {

/**
* Resolve the active provider id:
* 1. explicit `VITE_LLM_PROVIDER` ('gemini' | 'openai' | 'anthropic') wins;
* 1. explicit `VITE_LLM_PROVIDER` ('gemini' | 'openai' | 'anthropic' | 'openai-proxy') wins;
* 2. else auto-detect: `VITE_OPENAI_API_KEY` -> openai, `VITE_ANTHROPIC_API_KEY` -> anthropic;
* 3. else default to `gemini` (existing Firebase AI Logic).
*
* `openai-proxy` is opt-in only (step 1): it routes calls through the secure server
* proxy (the `aiChat` callable) and is never auto-selected, so the safe default
* (gemini, then rule-based) is unchanged until the flag is explicitly set.
*
* Each `import.meta.env.VITE_*` is read via static member access so Vite can inline
* the value at build time (dynamic indexing would not be statically replaced).
*/
function selectProviderId(): LLMProviderId {
const explicit = readEnv(import.meta.env.VITE_LLM_PROVIDER).toLowerCase()
if (explicit === 'gemini' || explicit === 'openai' || explicit === 'anthropic') {
if (
explicit === 'gemini' ||
explicit === 'openai' ||
explicit === 'anthropic' ||
explicit === 'openai-proxy'
) {
return explicit
}
if (readEnv(import.meta.env.VITE_OPENAI_API_KEY)) return 'openai'
Expand Down
147 changes: 147 additions & 0 deletions web/src/lib/ai/providers/openai-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* OpenAI-via-Cloudflare-Worker provider (SECURE).
*
* The OpenAI API key NEVER touches the browser. This provider calls a free-tier
* Cloudflare Worker (see `worker/`) over `fetch`; the Worker holds the key in a
* server-side secret, verifies the caller's Firebase ID token, and proxies the
* request to OpenAI. (We moved off the previous Firebase callable because Cloud
* Functions require the Blaze plan — this project is on the free Spark plan.)
*
* Activation (opt-in, NO code changes): in `web/.env.local` set
* VITE_LLM_PROVIDER=openai-proxy
* VITE_AI_PROXY_URL=https://suited-ai-proxy.<your-subdomain>.workers.dev
* The default provider stays `gemini`, so existing behavior is unchanged until the
* flag is set. Optionally override the model with `VITE_OPENAI_MODEL` (the Worker
* defaults to gpt-4o-mini otherwise).
*
* Failure is always soft: a missing proxy URL, a missing sign-in, a network error,
* a non-2xx response, or a timeout resolves to `null`, so callers (the AI coach,
* table talk, and the Room 2 LLM opponents) transparently fall back to rule-based
* logic.
*/
import type { LLMProvider } from './index'
import { auth } from '../../firebase'

/** Request payload accepted by the Worker's `POST /chat` (kept minimal + server-validated). */
type AiChatRequest = {
model?: string
messages: { role: 'system' | 'user' | 'assistant'; content: string }[]
temperature?: number
max_tokens?: number
json?: boolean
}

/** Response shape returned by the Worker's `POST /chat`. */
type AiChatResponse = {
text: string
model?: string
finishReason?: string | null
}

function readEnv(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}

/** The deployed Worker base URL (or full `/chat` URL); empty when not configured. */
function proxyUrl(): string {
return readEnv(import.meta.env.VITE_AI_PROXY_URL)
}

/** Optional client-side model override; the Worker defaults to gpt-4o-mini. */
function modelOverride(): string {
return readEnv(import.meta.env.VITE_OPENAI_MODEL)
}

/**
* Build the `/chat` endpoint from the configured proxy URL. Accepts either the
* base Worker URL (…workers.dev) or one that already ends in `/chat`.
*/
function chatEndpoint(base: string): string {
const trimmed = base.replace(/\/+$/, '')
return trimmed.endsWith('/chat') ? trimmed : `${trimmed}/chat`
}

/**
* "Configured" means the proxy URL is set, so the proxy path is wired up. This
* provider is opt-in (only active when `VITE_LLM_PROVIDER=openai-proxy`) and can
* never see the server-side key. Runtime problems (no sign-in, network/server
* errors) are handled by soft-failing to `null` in `generateText`.
*/
function isConfigured(): boolean {
return proxyUrl().length > 0
}

/**
* Get the current user's Firebase ID token, or `null` if nobody is signed in or
* the token can't be minted. Never throws — a `null` here soft-fails the call.
*/
async function currentIdToken(): Promise<string | null> {
try {
const user = auth.currentUser
if (!user) return null
const token = await user.getIdToken()
return typeof token === 'string' && token.length > 0 ? token : null
} catch {
return null
}
}

async function generateText(prompt: string, signal: AbortSignal): Promise<string | null> {
if (signal.aborted) return null

const base = proxyUrl()
if (!base) return null

const token = await currentIdToken()
if (!token || signal.aborted) return null

try {
const model = modelOverride()
const payload: AiChatRequest = {
messages: [{ role: 'user', content: prompt }],
...(model ? { model } : {}),
}

const response = await fetch(chatEndpoint(base), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
signal,
})

if (!response.ok) return null

const data = (await response.json()) as AiChatResponse
const text = data?.text
return typeof text === 'string' && text.trim().length > 0 ? text : null
} catch {
// Unauthenticated, unreachable proxy, network, aborted, or server error →
// soft-fail to null so callers fall back to rule-based logic. Never surface
// server/key details here.
return null
}
}

/**
* The Worker is non-streaming, so "streaming" is a single non-streamed call whose
* full result is emitted once via `onToken` (mirrors aiClient's own fallback).
*/
async function streamText(
prompt: string,
signal: AbortSignal,
onToken: (chunk: string) => void,
): Promise<string | null> {
const text = await generateText(prompt, signal)
if (text != null) onToken(text)
return text
}

export const openaiProxyProvider: LLMProvider = {
id: 'openai-proxy',
isConfigured,
generateText,
streamText,
}
15 changes: 15 additions & 0 deletions worker/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Dependencies
node_modules/

# Build / wrangler
dist/
.wrangler/

# Secrets / local env (never commit these)
.dev.vars
.env
.env.*
*.local

# Logs
*.log
107 changes: 107 additions & 0 deletions worker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Suited AI Proxy — Cloudflare Worker

A tiny, **free-tier** Cloudflare Worker that proxies OpenAI Chat Completions for
the "Suited" poker app. It exists because Firebase Cloud Functions require the
**Blaze** (paid) plan, while this project runs on the free **Spark** plan. The
Worker runs on Cloudflare's free Workers plan instead.

The OpenAI API key lives **only** in a Worker secret (`OPENAI_API_KEY`) — it is
never committed to this repo and never sent to the browser.

## What it does

- `POST /chat` — requires `Authorization: Bearer <Firebase ID token>`.
- Body: `{ model?, messages, temperature?, max_tokens?, json? }`
- Validates input, calls `https://api.openai.com/v1/chat/completions`, and
returns `{ text, model, finishReason }`.
- Default model: `gpt-4o-mini`. Set `json: true` for a `json_object` response.
- `GET /` (or `/health`) — unauthenticated liveness probe → `{ "status": "ok" }`.
- `OPTIONS` — CORS preflight.

### Auth gating (Firebase ID tokens)

Every `/chat` request must carry a Firebase ID token. The Worker verifies it with
**Web Crypto only** (no runtime dependencies):

1. Fetches Google's public x509 certs from the securetoken endpoint and caches
them per the response's `Cache-Control: max-age`.
2. Extracts the public key (SubjectPublicKeyInfo) from the matching certificate
and verifies the token's **RS256** signature.
3. Checks the claims: `aud === "brilliant-alpha-clone-54be9"`,
`iss === "https://securetoken.google.com/brilliant-alpha-clone-54be9"`, and
`exp` not expired (plus light `iat`/`auth_time`/`sub` sanity checks).

Missing/invalid tokens get a `401`. The token's `aud`/`iss` project id is set in
`src/index.ts` (`PROJECT_ID`); change it if you point this at another project.

### CORS

Allowed origins: the production hosting domains
(`https://brilliant-alpha-clone-54be9.web.app`,
`https://brilliant-alpha-clone-54be9.firebaseapp.com`) and local dev
(`http://localhost:5173`, `:5174`, `:5175`). Update `ALLOWED_ORIGINS` in
`src/index.ts` if your origins differ.

## Local development

```bash
cd worker
npm install
npm run typecheck # tsc --noEmit
npm run dry-run # wrangler deploy --dry-run --outdir dist (builds, no deploy)

# To run it locally with a key, create worker/.dev.vars (gitignored):
# OPENAI_API_KEY=sk-...
npm run dev # wrangler dev
```

## Deploy (maintainer steps)

You need a **free Cloudflare account**. From the `worker/` directory:

```bash
cd worker
npm install

# 1) Authenticate wrangler with your Cloudflare account (opens a browser).
npx wrangler login

# 2) Store the OpenAI key as a Worker SECRET (never put it in any file).
npx wrangler secret put OPENAI_API_KEY
# (paste your sk-... key when prompted)

# 3) Deploy. Note the printed Worker URL, e.g.
# https://suited-ai-proxy.<your-subdomain>.workers.dev
npx wrangler deploy
```

### Wire the client to the Worker

Set the deployed Worker URL as a build-time env var for the web app, then rebuild
and redeploy hosting:

```bash
# In web/.env.local (gitignored) — use YOUR deployed Worker URL:
VITE_AI_PROXY_URL=https://suited-ai-proxy.<your-subdomain>.workers.dev
VITE_LLM_PROVIDER=openai-proxy

cd ../web
npm run build
firebase deploy --only hosting
```

The client appends `/chat` to `VITE_AI_PROXY_URL` automatically (setting it to the
base Worker URL or the full `.../chat` URL both work).

> The OpenAI path only goes live once `VITE_LLM_PROVIDER=openai-proxy` is set
> **and** `VITE_AI_PROXY_URL` points at the deployed Worker. With either unset,
> the app keeps its default behavior (Gemini → rule-based fallback). If the proxy
> is unreachable or the user isn't signed in, calls soft-fail to the rule-based
> fallback rather than erroring.

## Files

- `src/index.ts` — request routing, CORS, auth gating, OpenAI call orchestration.
- `src/firebaseAuth.ts` — Firebase ID token verification (Web Crypto, zero deps).
- `src/openai.ts` — request validation + OpenAI Chat Completions call.
- `wrangler.toml` — Worker config (name, entry, compatibility date).
Loading