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
127 changes: 98 additions & 29 deletions design/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,132 @@
/**
* Auth resolution for OpenAI API access.
* Auth resolution for image generation API access.
*
* Resolution order:
* 1. ~/.gstack/openai.json → { "api_key": "sk-..." }
* 2. OPENAI_API_KEY environment variable
* 3. null (caller handles guided setup or fallback)
* Supports multiple providers:
* - openai: GPT Image 1.5 (default, best text rendering for UI mockups)
* - gemini: Nano Banana 2 / Gemini 3.1 Flash Image (fast, good quality)
*
* Provider resolution:
* 1. ~/.gstack/design.json → { "provider": "openai"|"gemini", "openai_key": "...", "gemini_key": "..." }
* 2. Environment variables: OPENAI_API_KEY, GEMINI_API_KEY
* 3. Legacy ~/.gstack/openai.json → { "api_key": "sk-..." }
* 4. Auto-detect: whichever key is available
*/

import fs from "fs";
import path from "path";

const CONFIG_PATH = path.join(process.env.HOME || "~", ".gstack", "openai.json");
export type Provider = "openai" | "gemini";

export function resolveApiKey(): string | null {
// 1. Check ~/.gstack/openai.json
export interface ProviderConfig {
provider: Provider;
apiKey: string;
}

const DESIGN_CONFIG_PATH = path.join(process.env.HOME || "~", ".gstack", "design.json");
const LEGACY_CONFIG_PATH = path.join(process.env.HOME || "~", ".gstack", "openai.json");

function readDesignConfig(): Record<string, string> | null {
try {
if (fs.existsSync(CONFIG_PATH)) {
const content = fs.readFileSync(CONFIG_PATH, "utf-8");
const config = JSON.parse(content);
if (fs.existsSync(DESIGN_CONFIG_PATH)) {
return JSON.parse(fs.readFileSync(DESIGN_CONFIG_PATH, "utf-8"));
}
} catch { /* fall through */ }
return null;
}

function readLegacyConfig(): string | null {
try {
if (fs.existsSync(LEGACY_CONFIG_PATH)) {
const config = JSON.parse(fs.readFileSync(LEGACY_CONFIG_PATH, "utf-8"));
if (config.api_key && typeof config.api_key === "string") {
return config.api_key;
}
}
} catch {
// Fall through to env var
} catch { /* fall through */ }
return null;
}

export function resolveProvider(): ProviderConfig | null {
// 1. Check ~/.gstack/design.json (new multi-provider config)
const config = readDesignConfig();
if (config) {
const provider = (config.provider || "openai") as Provider;
if (provider === "gemini") {
const key = config.gemini_key || process.env.GEMINI_API_KEY;
if (key) return { provider: "gemini", apiKey: key };
} else {
const key = config.openai_key || process.env.OPENAI_API_KEY;
if (key) return { provider: "openai", apiKey: key };
}
}

// 2. Check environment variable
// 2. Check environment variables (auto-detect provider)
if (process.env.OPENAI_API_KEY) {
return process.env.OPENAI_API_KEY;
return { provider: "openai", apiKey: process.env.OPENAI_API_KEY };
}
if (process.env.GEMINI_API_KEY || process.env.GOOGLE_AI_API_KEY) {
return { provider: "gemini", apiKey: (process.env.GEMINI_API_KEY || process.env.GOOGLE_AI_API_KEY)! };
}

// 3. Legacy ~/.gstack/openai.json
const legacyKey = readLegacyConfig();
if (legacyKey) {
return { provider: "openai", apiKey: legacyKey };
}

return null;
}

/** Backwards-compatible: resolve just the API key (for check.ts, evolve.ts) */
export function resolveApiKey(): string | null {
const config = resolveProvider();
return config?.apiKey ?? null;
}

/**
* Save an API key to ~/.gstack/openai.json with 0600 permissions.
* Save provider config to ~/.gstack/design.json with 0600 permissions.
*/
export function saveApiKey(key: string): void {
const dir = path.dirname(CONFIG_PATH);
export function saveProviderConfig(provider: Provider, key: string): void {
const dir = path.dirname(DESIGN_CONFIG_PATH);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify({ api_key: key }, null, 2));
fs.chmodSync(CONFIG_PATH, 0o600);
const existing = readDesignConfig() || {};
if (provider === "openai") existing.openai_key = key;
if (provider === "gemini") existing.gemini_key = key;
existing.provider = provider;
fs.writeFileSync(DESIGN_CONFIG_PATH, JSON.stringify(existing, null, 2));
fs.chmodSync(DESIGN_CONFIG_PATH, 0o600);
}

/** Legacy compat */
export function saveApiKey(key: string): void {
saveProviderConfig("openai", key);
}

/**
* Get API key or exit with setup instructions.
* Get provider config or exit with setup instructions.
*/
export function requireApiKey(): string {
const key = resolveApiKey();
if (!key) {
console.error("No OpenAI API key found.");
export function requireProvider(): ProviderConfig {
const config = resolveProvider();
if (!config) {
console.error("No API key found for image generation.");
console.error("");
console.error("Run: $D setup");
console.error(" or save to ~/.gstack/openai.json: { \"api_key\": \"sk-...\" }");
console.error("Option 1 — OpenAI (best text rendering for UI mockups):");
console.error(" echo '{\"provider\":\"openai\",\"openai_key\":\"sk-...\"}' > ~/.gstack/design.json");
console.error(" or set OPENAI_API_KEY environment variable");
console.error("");
console.error("Get a key at: https://platform.openai.com/api-keys");
console.error("Option 2 — Gemini (fast, good quality, cheaper):");
console.error(" echo '{\"provider\":\"gemini\",\"gemini_key\":\"...\"}' > ~/.gstack/design.json");
console.error(" or set GEMINI_API_KEY environment variable");
console.error("");
console.error("Get keys at:");
console.error(" OpenAI: https://platform.openai.com/api-keys");
console.error(" Gemini: https://aistudio.google.com/apikey");
process.exit(1);
}
return key;
return config;
}

/** Legacy compat */
export function requireApiKey(): string {
return requireProvider().apiKey;
}
118 changes: 93 additions & 25 deletions design/src/generate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
/**
* Generate UI mockups via OpenAI Responses API with image_generation tool.
* Generate UI mockups via OpenAI GPT Image 1.5 or Google Gemini Nano Banana 2.
*
* Provider selection:
* - openai: GPT Image 1.5 via Images API (best text rendering for UI mockups)
* - gemini: Gemini 3.1 Flash Image via generateContent (fast, good quality, cheaper)
*
* See auth.ts for provider resolution order.
*/

import fs from "fs";
import path from "path";
import { requireApiKey } from "./auth";
import { requireProvider, type Provider } from "./auth";
import { parseBrief } from "./brief";
import { createSession, sessionPath } from "./session";
import { checkMockup } from "./check";
Expand All @@ -27,10 +33,10 @@ export interface GenerateResult {
}

/**
* Call OpenAI Responses API with image_generation tool.
* Returns the response ID and base64 image data.
* Call OpenAI Images API with GPT Image 1.5.
* Upgraded from gpt-4o Responses API to dedicated image model.
*/
async function callImageGeneration(
async function callOpenAIImageGeneration(
apiKey: string,
prompt: string,
size: string,
Expand All @@ -40,20 +46,19 @@ async function callImageGeneration(
const timeout = setTimeout(() => controller.abort(), 120_000);

try {
const response = await fetch("https://api.openai.com/v1/responses", {
const response = await fetch("https://api.openai.com/v1/images/generations", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4o",
input: prompt,
tools: [{
type: "image_generation",
size,
quality,
}],
model: "gpt-image-1",
prompt,
n: 1,
size: size === "1536x1024" ? "1536x1024" : size,
quality: quality === "high" ? "high" : "medium",
output_format: "png",
}),
signal: controller.signal,
});
Expand All @@ -67,35 +72,98 @@ async function callImageGeneration(
+ "After verification, wait up to 15 minutes for access to propagate.",
);
}
throw new Error(`API error (${response.status}): ${error.slice(0, 200)}`);
throw new Error(`OpenAI API error (${response.status}): ${error.slice(0, 200)}`);
}

const data = await response.json() as any;
// GPT Image models always return base64. Check both b64_json (DALL-E format) and b64 (GPT Image format).
const imageData = data.data?.[0]?.b64_json || data.data?.[0]?.b64;

if (!imageData) {
throw new Error(`No image data in OpenAI response. Keys: ${JSON.stringify(Object.keys(data.data?.[0] || {}))}`);
}

return {
responseId: data.created?.toString() || "openai-" + Date.now(),
imageData,
};
} finally {
clearTimeout(timeout);
}
}

/**
* Call Google Gemini API with Nano Banana 2 (Gemini 3.1 Flash Image).
*/
async function callGeminiImageGeneration(
apiKey: string,
prompt: string,
): Promise<{ responseId: string; imageData: string }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 120_000);

try {
const model = "gemini-2.0-flash-exp";
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;

const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{
parts: [{ text: prompt }],
}],
generationConfig: {
responseModalities: ["TEXT", "IMAGE"],
},
}),
signal: controller.signal,
});

const imageItem = data.output?.find((item: any) =>
item.type === "image_generation_call"
);
if (!response.ok) {
const error = await response.text();
throw new Error(`Gemini API error (${response.status}): ${error.slice(0, 200)}`);
}

const data = await response.json() as any;
const parts = data.candidates?.[0]?.content?.parts || [];
const imagePart = parts.find((p: any) => p.inlineData?.mimeType?.startsWith("image/"));

if (!imageItem?.result) {
throw new Error(
`No image data in response. Output types: ${data.output?.map((o: any) => o.type).join(", ") || "none"}`
);
if (!imagePart?.inlineData?.data) {
throw new Error("No image data in Gemini response.");
}

return {
responseId: data.id,
imageData: imageItem.result,
responseId: "gemini-" + Date.now(),
imageData: imagePart.inlineData.data,
};
} finally {
clearTimeout(timeout);
}
}

/**
* Route to the correct provider.
*/
async function callImageGeneration(
provider: Provider,
apiKey: string,
prompt: string,
size: string,
quality: string,
): Promise<{ responseId: string; imageData: string }> {
if (provider === "gemini") {
return callGeminiImageGeneration(apiKey, prompt);
}
return callOpenAIImageGeneration(apiKey, prompt, size, quality);
}

/**
* Generate a single mockup from a brief.
*/
export async function generate(options: GenerateOptions): Promise<GenerateResult> {
const apiKey = requireApiKey();
const { provider, apiKey } = requireProvider();
console.error(`Using provider: ${provider}`);

// Parse the brief
const prompt = options.briefFile
Expand All @@ -115,7 +183,7 @@ export async function generate(options: GenerateOptions): Promise<GenerateResult

// Generate the image
const startTime = Date.now();
const { responseId, imageData } = await callImageGeneration(apiKey, prompt, size, quality);
const { responseId, imageData } = await callImageGeneration(provider, apiKey, prompt, size, quality);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);

// Write to disk
Expand Down