-
{assistantName}
+
{assistantName}
Artwork, sizing, delivery, Design Studio, and orders — grounded in studio sources.
-
+
) : null}
diff --git a/apps/storefront/lib/concierge/orchestrator.ts b/apps/storefront/lib/concierge/orchestrator.ts
index 9a76b23..601ff07 100644
--- a/apps/storefront/lib/concierge/orchestrator.ts
+++ b/apps/storefront/lib/concierge/orchestrator.ts
@@ -7,6 +7,7 @@ import type {
import { routeIntent } from './intent';
import { formatCitationLinks, retrieveKnowledge } from './knowledge/corpus';
import { buildSystemPrompt } from './prompts/system';
+import { hasAnyLiveProvider, polishWithFallback } from './providers/fallback';
import { redactSensitive, sanitizeModelOutput } from './security/redact';
import { getCartTool } from './tools/cart';
import {
@@ -223,17 +224,20 @@ export async function runConciergeTurn(
text = `${contextBits.join(' ')}\n\n${text}`;
}
- // Optional LLM polish — never invents tools; only rephrases when configured.
- if (process.env.AI_PROVIDER === 'openai' && process.env.AI_API_KEY) {
+ // Optional LLM polish with OpenAI → Anthropic → Gemini fallback.
+ // Never invents tools/facts — only rephrases the grounded draft.
+ if (hasAnyLiveProvider()) {
try {
- const polished = await polishWithOpenAI({
+ const polished = await polishWithFallback({
system: buildSystemPrompt(assistantName()),
user: message,
draft: text,
});
if (polished) {
- text = polished;
- provider = 'openai';
+ text = polished.text;
+ provider = polished.provider;
+ } else {
+ provider = 'fallback';
}
} catch {
provider = 'fallback';
@@ -252,35 +256,3 @@ export async function runConciergeTurn(
guarded,
};
}
-
-async function polishWithOpenAI(input: {
- system: string;
- user: string;
- draft: string;
-}): Promise
{
- const model = process.env.AI_MODEL || 'gpt-4.1-mini';
- const response = await fetch('https://api.openai.com/v1/chat/completions', {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${process.env.AI_API_KEY}`,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- model,
- temperature: 0.3,
- messages: [
- { role: 'system', content: input.system },
- {
- role: 'user',
- content: `Customer said: ${input.user}\n\nGrounded draft (do not add facts):\n${input.draft}\n\nRewrite briefly in Concierge voice. Do not add products, prices, policies, or promises absent from the draft.`,
- },
- ],
- }),
- signal: AbortSignal.timeout(12_000),
- });
- if (!response.ok) return null;
- const json = (await response.json()) as {
- choices?: Array<{ message?: { content?: string } }>;
- };
- return json.choices?.[0]?.message?.content?.trim() || null;
-}
diff --git a/apps/storefront/lib/concierge/providers/fallback.spec.ts b/apps/storefront/lib/concierge/providers/fallback.spec.ts
new file mode 100644
index 0000000..2d2e5dc
--- /dev/null
+++ b/apps/storefront/lib/concierge/providers/fallback.spec.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it, afterEach } from 'vitest';
+import { hasAnyLiveProvider, polishWithFallback } from './fallback';
+
+const KEYS = [
+ 'AI_PROVIDER',
+ 'AI_PROVIDER_ORDER',
+ 'OPENAI_API_KEY',
+ 'AI_API_KEY',
+ 'ANTHROPIC_API_KEY',
+ 'GEMINI_API_KEY',
+ 'GOOGLE_API_KEY',
+] as const;
+
+afterEach(() => {
+ for (const key of KEYS) delete process.env[key];
+});
+
+describe('hasAnyLiveProvider', () => {
+ it('is false in mock mode', () => {
+ process.env.AI_PROVIDER = 'mock';
+ process.env.OPENAI_API_KEY = 'sk-test';
+ expect(hasAnyLiveProvider()).toBe(false);
+ });
+
+ it('is true in auto mode when any key is present', () => {
+ process.env.AI_PROVIDER = 'auto';
+ process.env.ANTHROPIC_API_KEY = 'ant-test';
+ expect(hasAnyLiveProvider()).toBe(true);
+ });
+});
+
+describe('polishWithFallback', () => {
+ it('returns null when no keys are configured', async () => {
+ process.env.AI_PROVIDER = 'auto';
+ const result = await polishWithFallback({
+ system: 'sys',
+ user: 'hi',
+ draft: 'draft',
+ });
+ expect(result).toBeNull();
+ });
+});
diff --git a/apps/storefront/lib/concierge/providers/fallback.ts b/apps/storefront/lib/concierge/providers/fallback.ts
new file mode 100644
index 0000000..ff8d07e
--- /dev/null
+++ b/apps/storefront/lib/concierge/providers/fallback.ts
@@ -0,0 +1,187 @@
+/**
+ * Multi-provider LLM polish for the Concierge.
+ * Tries providers in AI_PROVIDER_ORDER (default: openai → anthropic → gemini).
+ * Grounded draft facts must not be invented — polish only rewrites tone.
+ */
+
+export type AiProviderId = 'openai' | 'anthropic' | 'gemini' | 'mock';
+
+export interface PolishInput {
+ system: string;
+ user: string;
+ draft: string;
+}
+
+export interface PolishResult {
+ text: string;
+ provider: AiProviderId;
+ model: string;
+}
+
+const DEFAULT_MODELS = {
+ openai: 'gpt-5.6',
+ anthropic: 'claude-sonnet-5',
+ gemini: 'gemini-3.5-flash',
+} as const;
+
+function polishPrompt(input: PolishInput): string {
+ return `Customer said: ${input.user}\n\nGrounded draft (do not add facts):\n${input.draft}\n\nRewrite briefly in Concierge voice. Do not add products, prices, policies, or promises absent from the draft.`;
+}
+
+function parseProviderOrder(): AiProviderId[] {
+ const raw = (process.env.AI_PROVIDER_ORDER || 'openai,anthropic,gemini')
+ .split(',')
+ .map((s) => s.trim().toLowerCase())
+ .filter(Boolean);
+ const allowed: AiProviderId[] = ['openai', 'anthropic', 'gemini'];
+ const ordered = raw.filter((id): id is AiProviderId =>
+ (allowed as string[]).includes(id),
+ );
+ return ordered.length > 0 ? ordered : [...allowed];
+}
+
+function openaiKey(): string | undefined {
+ return process.env.OPENAI_API_KEY || process.env.AI_API_KEY || undefined;
+}
+
+function anthropicKey(): string | undefined {
+ return process.env.ANTHROPIC_API_KEY || undefined;
+}
+
+function geminiKey(): string | undefined {
+ return process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || undefined;
+}
+
+function hasKey(provider: AiProviderId): boolean {
+ if (provider === 'openai') return Boolean(openaiKey());
+ if (provider === 'anthropic') return Boolean(anthropicKey());
+ if (provider === 'gemini') return Boolean(geminiKey());
+ return false;
+}
+
+async function callOpenAI(input: PolishInput): Promise {
+ const key = openaiKey();
+ if (!key) return null;
+ const model = process.env.OPENAI_MODEL || process.env.AI_MODEL || DEFAULT_MODELS.openai;
+ const response = await fetch('https://api.openai.com/v1/chat/completions', {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${key}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ model,
+ temperature: 0.3,
+ messages: [
+ { role: 'system', content: input.system },
+ { role: 'user', content: polishPrompt(input) },
+ ],
+ }),
+ signal: AbortSignal.timeout(15_000),
+ });
+ if (!response.ok) return null;
+ const json = (await response.json()) as {
+ choices?: Array<{ message?: { content?: string } }>;
+ };
+ const text = json.choices?.[0]?.message?.content?.trim();
+ return text ? { text, provider: 'openai', model } : null;
+}
+
+async function callAnthropic(input: PolishInput): Promise {
+ const key = anthropicKey();
+ if (!key) return null;
+ const model = process.env.ANTHROPIC_MODEL || DEFAULT_MODELS.anthropic;
+ const response = await fetch('https://api.anthropic.com/v1/messages', {
+ method: 'POST',
+ headers: {
+ 'x-api-key': key,
+ 'anthropic-version': '2023-06-01',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ model,
+ max_tokens: 1024,
+ temperature: 0.3,
+ system: input.system,
+ messages: [{ role: 'user', content: polishPrompt(input) }],
+ }),
+ signal: AbortSignal.timeout(15_000),
+ });
+ if (!response.ok) return null;
+ const json = (await response.json()) as {
+ content?: Array<{ type?: string; text?: string }>;
+ };
+ const text = json.content?.find((block) => block.type === 'text')?.text?.trim();
+ return text ? { text, provider: 'anthropic', model } : null;
+}
+
+async function callGemini(input: PolishInput): Promise {
+ const key = geminiKey();
+ if (!key) return null;
+ const model = process.env.GEMINI_MODEL || DEFAULT_MODELS.gemini;
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(key)}`;
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ systemInstruction: { parts: [{ text: input.system }] },
+ contents: [{ role: 'user', parts: [{ text: polishPrompt(input) }] }],
+ generationConfig: { temperature: 0.3, maxOutputTokens: 1024 },
+ }),
+ signal: AbortSignal.timeout(15_000),
+ });
+ if (!response.ok) return null;
+ const json = (await response.json()) as {
+ candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
+ };
+ const text = json.candidates?.[0]?.content?.parts?.map((p) => p.text ?? '').join('').trim();
+ return text ? { text, provider: 'gemini', model } : null;
+}
+
+const CALLERS: Record<
+ Exclude,
+ (input: PolishInput) => Promise
+> = {
+ openai: callOpenAI,
+ anthropic: callAnthropic,
+ gemini: callGemini,
+};
+
+/**
+ * Whether any live LLM key is configured (so we can skip the polish attempt).
+ */
+export function hasAnyLiveProvider(): boolean {
+ const mode = (process.env.AI_PROVIDER || 'auto').toLowerCase();
+ if (mode === 'mock') return false;
+ if (mode === 'openai' || mode === 'anthropic' || mode === 'gemini') {
+ return hasKey(mode);
+ }
+ // auto / cascade
+ return parseProviderOrder().some(hasKey);
+}
+
+/**
+ * Polish grounded draft text via the first successful provider in the fallback chain.
+ */
+export async function polishWithFallback(input: PolishInput): Promise {
+ const mode = (process.env.AI_PROVIDER || 'auto').toLowerCase();
+ if (mode === 'mock') return null;
+
+ const chain: AiProviderId[] =
+ mode === 'openai' || mode === 'anthropic' || mode === 'gemini'
+ ? [mode, ...parseProviderOrder().filter((id) => id !== mode)]
+ : parseProviderOrder();
+
+ for (const provider of chain) {
+ if (provider === 'mock' || !hasKey(provider)) continue;
+ try {
+ const result = await CALLERS[provider](input);
+ if (result?.text) return result;
+ } catch {
+ // try next provider
+ }
+ }
+ return null;
+}
+
+export const DEFAULT_AI_MODELS = DEFAULT_MODELS;
diff --git a/packages/configuration/src/index.spec.ts b/packages/configuration/src/index.spec.ts
index c1129bb..42f9296 100644
--- a/packages/configuration/src/index.spec.ts
+++ b/packages/configuration/src/index.spec.ts
@@ -35,7 +35,11 @@ describe('loadEnvironment', () => {
ADMIN_MFA_ENCRYPTION_KEY: 'bG9jYWwtZGV2ZWxvcG1lbnQtbWZhLWtleS0xMjM0NTY',
AI_ENABLED: true,
AI_PROVIDER: 'mock',
- AI_MODEL: 'gpt-4.1-mini',
+ AI_PROVIDER_ORDER: 'openai,anthropic,gemini',
+ AI_MODEL: 'gpt-5.6',
+ OPENAI_MODEL: 'gpt-5.6',
+ ANTHROPIC_MODEL: 'claude-sonnet-5',
+ GEMINI_MODEL: 'gemini-3.5-flash',
AI_MAX_DAILY_REQUESTS: 2_000,
AI_CHAT_RETENTION_DAYS: 90,
AI_ASSISTANT_NAME: 'F.A.T.U Concierge',
diff --git a/packages/configuration/src/index.ts b/packages/configuration/src/index.ts
index b279d48..a216b4f 100644
--- a/packages/configuration/src/index.ts
+++ b/packages/configuration/src/index.ts
@@ -55,14 +55,22 @@ const EnvironmentSchema = z
.regex(/^[A-Za-z0-9_-]{43}$/)
.default(localMfaEncryptionKey),
// F.A.T.U Concierge — provider keys stay server-side only (never NEXT_PUBLIC_*).
+ // AI_PROVIDER=auto tries OPENAI → ANTHROPIC → GEMINI (see AI_PROVIDER_ORDER).
AI_ENABLED: z
.enum(['true', 'false'])
.default('true')
.transform((value) => value === 'true'),
- AI_PROVIDER: z.enum(['openai', 'mock']).default('mock'),
- AI_MODEL: z.string().min(1).default('gpt-4.1-mini'),
+ AI_PROVIDER: z.enum(['auto', 'openai', 'anthropic', 'gemini', 'mock']).default('mock'),
+ AI_PROVIDER_ORDER: z.string().min(1).default('openai,anthropic,gemini'),
+ AI_MODEL: z.string().min(1).default('gpt-5.6'),
AI_API_KEY: z.string().min(1).optional(),
AI_FALLBACK_MODEL: z.string().min(1).optional(),
+ OPENAI_API_KEY: z.string().min(1).optional(),
+ OPENAI_MODEL: z.string().min(1).default('gpt-5.6'),
+ ANTHROPIC_API_KEY: z.string().min(1).optional(),
+ ANTHROPIC_MODEL: z.string().min(1).default('claude-sonnet-5'),
+ GEMINI_API_KEY: z.string().min(1).optional(),
+ GEMINI_MODEL: z.string().min(1).default('gemini-3.5-flash'),
AI_MAX_DAILY_REQUESTS: z.coerce.number().int().min(1).max(1_000_000).default(2_000),
AI_KNOWLEDGE_SYNC_SECRET: z.string().min(16).optional(),
AI_SUPPORT_EMAIL: z.string().email().optional(),
@@ -122,12 +130,29 @@ const EnvironmentSchema = z
});
}
}
- // OpenAI-compatible provider needs a key; mock mode is the safe default for CI.
- if (environment.AI_PROVIDER === 'openai' && !environment.AI_API_KEY) {
+ // A pinned provider needs its own key (auto mode uses whichever keys exist).
+ if (environment.AI_PROVIDER === 'openai') {
+ const key = environment.OPENAI_API_KEY ?? environment.AI_API_KEY;
+ if (!key) {
+ context.addIssue({
+ code: 'custom',
+ path: ['OPENAI_API_KEY'],
+ message: 'OPENAI_API_KEY (or AI_API_KEY) is required when AI_PROVIDER is openai.',
+ });
+ }
+ }
+ if (environment.AI_PROVIDER === 'anthropic' && !environment.ANTHROPIC_API_KEY) {
+ context.addIssue({
+ code: 'custom',
+ path: ['ANTHROPIC_API_KEY'],
+ message: 'ANTHROPIC_API_KEY is required when AI_PROVIDER is anthropic.',
+ });
+ }
+ if (environment.AI_PROVIDER === 'gemini' && !environment.GEMINI_API_KEY) {
context.addIssue({
code: 'custom',
- path: ['AI_API_KEY'],
- message: 'AI_API_KEY is required when AI_PROVIDER is openai.',
+ path: ['GEMINI_API_KEY'],
+ message: 'GEMINI_API_KEY is required when AI_PROVIDER is gemini.',
});
}
});
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index c98ebfb..7d6bd29 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -1157,7 +1157,7 @@ export interface ConciergeChatTurnResult {
citations: ConciergeCitation[];
cards: ConciergeProductCard[];
ticketReference?: string;
- provider: 'openai' | 'mock' | 'fallback';
+ provider: 'openai' | 'anthropic' | 'gemini' | 'mock' | 'fallback';
guarded: boolean;
}