diff --git a/CLAUDE.md b/CLAUDE.md index f19e186..0dee339 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,7 +118,9 @@ Reading a trajectory, `run_end`'s `ok: true` means the loop finished, not that t ## Environment variables -`WOOPCODE_API_KEY`, `WOOPCODE_PROVIDER`, `WOOPCODE_MAX_ITERATIONS`, `WOOPCODE_MAX_ATTEMPTS` (retry), `WOOPCODE_TOOL_HISTORY_BUDGET`, `WOOPCODE_THINKING_BUDGET`, `WOOPCODE_NON_INTERACTIVE`. Bun loads `.env` automatically — no `dotenv`. +`WOOPCODE_API_KEY`, `WOOPCODE_PROVIDER`, `WOOPCODE_MAX_ITERATIONS`, `WOOPCODE_MAX_ATTEMPTS` (retry), `WOOPCODE_TOOL_HISTORY_BUDGET`, `WOOPCODE_THINKING_BUDGET`, `WOOPCODE_NON_INTERACTIVE`, `WOOPCODE_DEMO_URL`. Bun loads `.env` automatically — no `dotenv`. + +`WOOPCODE_DEMO_URL` points demo mode at a proxy other than the production one, which is how the proxy is run locally. Demo mode stores a token, not a key: the credential in `providers.json` is only valid against that URL, so the two are written and cleared together (`config/demoAccount.ts`). A shared Gemini key cannot be shipped instead — the free-tier quota belongs to the project rather than the caller, and a key printed in a terminal gets scraped and revoked with no way to replace it. `WOOPCODE_THINKING_BUDGET` takes `off`, `-1` (the default, meaning automatic), or a token count. `off` omits `thinkingConfig` from the request entirely, and exists because `gemini-3.5-flash-lite` rejects a budget of `0` with a 400 — so "disable" cannot be expressed as a number. Budgets below roughly a thousand are ignored rather than honoured: measured, 128 and 512 return zero thinking tokens while 1024 and -1 return 54–202. diff --git a/commands/agent.tsx b/commands/agent.tsx index 8661963..ce7dd13 100644 --- a/commands/agent.tsx +++ b/commands/agent.tsx @@ -146,7 +146,7 @@ async function runHeadless( options: { model?: string; events?: string; session?: InitializeOptions } = {}, ) { registerCommands(); - const { provider, apiKey } = await ensureProviderConfigured(); + const { provider, apiKey, baseUrl } = await ensureProviderConfigured(); const selectedModel = await resolveModel(options.model); store.setSelectedModel(selectedModel); @@ -248,7 +248,7 @@ async function runHeadless( }, }; - const controller = new AgentController(provider, apiKey, selectedModel, callbacks); + const controller = new AgentController(provider, apiKey, selectedModel, callbacks, baseUrl); await controller.initialize(options.session); // On stderr, not stdout: stdout is the agent's answer and a caller pipes it. @@ -296,7 +296,7 @@ async function runInteractive( // Launches onboarding when nothing is configured, so this may not return // immediately on a first run. - const { provider, apiKey } = await ensureProviderConfigured(); + const { provider, apiKey, baseUrl } = await ensureProviderConfigured(); const config = await getConfig(); const selectedModel = await resolveModel(modelOverride); @@ -390,7 +390,7 @@ async function runInteractive( store.setTransientStatus("Cancelled", CANCEL_STATUS_MS); }, }; - const controller = new AgentController(provider, apiKey, selectedModel, callbacks); + const controller = new AgentController(provider, apiKey, selectedModel, callbacks, baseUrl); try { await controller.initialize(session); } catch (error) { diff --git a/commands/agentController.ts b/commands/agentController.ts index dc3f8f9..66fd920 100644 --- a/commands/agentController.ts +++ b/commands/agentController.ts @@ -22,6 +22,7 @@ import { type SessionRecord, } from "../config/sessions"; import { agentLoop } from "../runtime/loop"; +import { demoExhaustionMessage } from "../config/demoAccount"; import { stopAllProcesses } from "../tools/process"; import { PLAN_MODE_PROMPT } from "../config/systemPrompt"; import { @@ -121,6 +122,8 @@ export class AgentController { private apiKey: string, modelOrCallbacks: string | AgentCallbacks, callbacks?: AgentCallbacks, + /** Non-vendor host for this provider's requests; see ProviderEntry.baseUrl. */ + private baseUrl?: string, ) { this.model = typeof modelOrCallbacks === "string" ? modelOrCallbacks : DEFAULT_MODEL_ID; this.callbacks = typeof modelOrCallbacks === "string" ? callbacks! : modelOrCallbacks; @@ -140,11 +143,17 @@ export class AgentController { * * Returns false when a turn is in flight, so the caller can report that * instead of swapping credentials underneath a running request. + * + * `baseUrl` is assigned unconditionally, unlike `model`. It belongs to the + * credential rather than to the session: a demo session that switches to a + * real key must stop talking to the proxy, and leaving the old value in + * place would send that key to a server it was never issued for. */ - setProvider(provider: string, apiKey: string, model?: string) { + setProvider(provider: string, apiKey: string, model?: string, baseUrl?: string) { if (this.isRunning) return false; this.provider = provider; this.apiKey = apiKey; + this.baseUrl = baseUrl; if (model) this.model = model; return true; } @@ -153,6 +162,19 @@ export class AgentController { return this.provider; } + /** + * Replaces a failure the user cannot act on with one they can. + * + * Lives here rather than in the loop, which is deliberately ignorant of + * providers and of how this session was credentialed. Anything it does not + * recognise is passed through untouched — a mapping that swallowed unknown + * errors would turn a real bug into a wrong explanation. + */ + private describeTurnError(error: Error): Error { + const demo = demoExhaustionMessage(error, { baseUrl: this.baseUrl }); + return demo ? new Error(demo) : error; + } + getSessionMode() { return this.sessionMode; } @@ -239,7 +261,12 @@ export class AgentController { let failed = false; try { - const client = createProviderClient(this.provider, this.apiKey, this.model); + const client = createProviderClient( + this.provider, + this.apiKey, + this.model, + this.baseUrl, + ); agentLoopStarted = true; response = await agentLoop( client, @@ -255,6 +282,9 @@ export class AgentController { this.wasCancelled = true; this.callbacks.onCancel?.(); }, + onError: (error) => { + this.callbacks.onError?.(this.describeTurnError(error)); + }, }, this.abortController.signal, !conversational, diff --git a/commands/providers/login.ts b/commands/providers/login.ts index 395ea03..cb7f869 100644 --- a/commands/providers/login.ts +++ b/commands/providers/login.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import { loginProvider } from "../../config/authProvider"; -import { getConfig, saveConfig } from "../../config/config"; +import { apiProviderEntry, getConfig, saveConfig } from "../../config/config"; import { isProviderEnabled, unsupportedProviderMessage, @@ -30,13 +30,11 @@ export const loginCommand = new Command("login") const config = await getConfig(); config.defaultProvider = options.provider; - // The entry can be absent in a config written by an older version or - // trimmed by hand, so create it rather than indexing into undefined. - config.providers[options.provider] = { - ...config.providers[options.provider], - type: "api", - apiKey: options.apiKey, - }; + // Built fresh rather than spread over the previous entry: that entry may + // be a demo one, whose proxy URL would otherwise survive underneath a real + // key. It also covers an entry that is absent entirely, in a config + // written by an older version or trimmed by hand. + config.providers[options.provider] = apiProviderEntry(options.apiKey); await saveConfig(config); console.log("logging into " + options.provider); diff --git a/commands/slash/commands.ts b/commands/slash/commands.ts index 2847ab3..b6a9873 100644 --- a/commands/slash/commands.ts +++ b/commands/slash/commands.ts @@ -1,7 +1,7 @@ import type { SlashCommand, SlashCommandContext } from "./types"; import { registry } from "./registry"; import { APPROVAL_MODES, describeApprovalMode, parseApprovalMode } from "../../runtime/approval"; -import { getConfig, saveConfig } from "../../config/config"; +import { apiProviderEntry, getConfig, saveConfig } from "../../config/config"; import { listSessions, UNTITLED, type SessionSummary } from "../../config/sessions"; import { relativeTime } from "../../tui/src/relative-time"; import { isProviderEnabled, unsupportedProviderMessage } from "../../providers/providerRegistry"; @@ -273,7 +273,12 @@ const providerCommand: SlashCommand = { // The running controller holds its own provider/key, so the config write // alone would leave this session on the previous provider. - context.controller.setProvider(newProvider, providerConfig.apiKey, model); + context.controller.setProvider( + newProvider, + providerConfig.apiKey, + model, + providerConfig.baseUrl, + ); config.defaultProvider = newProvider; if (model) config.selectedModel = model; @@ -357,7 +362,9 @@ const loginCommand: SlashCommand = { return `Cannot change provider while the agent is running. Press Esc to cancel first.`; } - config.providers[provider].apiKey = apiKey; + // Replaces the entry outright: this is the way out of demo mode, and the + // demo's proxy URL must not outlive the token it belonged to. + config.providers[provider] = apiProviderEntry(apiKey); config.defaultProvider = provider; const model = modelForProvider(provider, config.selectedModel); @@ -366,7 +373,8 @@ const loginCommand: SlashCommand = { // A re-login with a fresh key must reach the running session too, not just // the config file. - context.controller.setProvider(provider, apiKey, model); + // No base URL: a user's own key always goes to the vendor directly. + context.controller.setProvider(provider, apiKey, model, undefined); if (model) { const { store } = await import("../../tui/src/store/ui-store"); @@ -424,15 +432,21 @@ const logoutCommand: SlashCommand = { // Drop the revoked key from the live session as well. With no provider // left, the next turn reports that instead of using stale credentials. - const nextKey = config.defaultProvider - ? config.providers[config.defaultProvider]?.apiKey ?? "" - : ""; + const nextEntry = config.defaultProvider + ? config.providers[config.defaultProvider] + : undefined; + const nextKey = nextEntry?.apiKey ?? ""; const nextModel = modelForProvider( config.defaultProvider, config.selectedModel, ); if (nextModel) config.selectedModel = nextModel; - context.controller.setProvider(config.defaultProvider, nextKey, nextModel); + context.controller.setProvider( + config.defaultProvider, + nextKey, + nextModel, + nextEntry?.baseUrl, + ); if (nextModel) { const { store } = await import("../../tui/src/store/ui-store"); diff --git a/config/config.ts b/config/config.ts index 96ae1f8..76594f8 100644 --- a/config/config.ts +++ b/config/config.ts @@ -6,6 +6,31 @@ import { type ApprovalMode, parseApprovalMode } from "../runtime/approval"; export interface ProviderEntry { type?: string; apiKey?: string; + /** + * Where to send this provider's requests, when not the vendor's own host. + * + * Set for demo mode, whose credential is a token issued by Woopcode's proxy + * rather than a Google key. The two travel together and separating them is a + * failure: the token is meaningless to Google, so an entry that keeps the + * key but loses the URL authenticates against the wrong server. + */ + baseUrl?: string; + /** Epoch ms after which a demo token is dead. Absent on a real key. */ + demoExpiresAt?: number; +} + +/** + * The entry for a key the user supplied themselves. + * + * Built fresh rather than spread over whatever was stored before, because the + * entry it replaces may be a demo one. Spreading kept `type: "demo"`, the + * proxy's `baseUrl` and the old expiry alive underneath the new key — so + * upgrading out of demo mode sent a real Google credential to Woopcode's + * proxy, and expired the moment the demo token would have. Every path that + * stores a user key goes through here so that cannot come back. + */ +export function apiProviderEntry(apiKey: string): ProviderEntry { + return { type: "api", apiKey }; } export interface ProvidersConfig { @@ -72,7 +97,13 @@ export async function readJsonFile(path: string, label: string): Promise; @@ -84,10 +115,14 @@ export function normalizeConfig(raw: unknown): ProvidersConfig { const providers: Record = {}; for (const [name, entry] of Object.entries(rawProviders)) { if (!entry || typeof entry !== "object") continue; - const { type, apiKey } = entry as ProviderEntry; + const { type, apiKey, baseUrl, demoExpiresAt } = entry as ProviderEntry; providers[name] = { ...(typeof type === "string" ? { type } : { type: "api" }), ...(typeof apiKey === "string" ? { apiKey } : {}), + ...(typeof baseUrl === "string" ? { baseUrl } : {}), + ...(typeof demoExpiresAt === "number" && Number.isFinite(demoExpiresAt) + ? { demoExpiresAt } + : {}), }; } diff --git a/config/demoAccount.ts b/config/demoAccount.ts new file mode 100644 index 0000000..32918f0 --- /dev/null +++ b/config/demoAccount.ts @@ -0,0 +1,236 @@ +import { join } from "path"; +import { getConfigDir } from "./paths"; +import type { ProviderEntry } from "./config"; +import { readJsonFile } from "./config"; + +/** + * Demo mode: running Woopcode without an API key of your own. + * + * A shared Gemini key cannot be shipped to users. The free-tier quota belongs + * to the project rather than the caller, so everyone holding a copy competes + * for one bucket while a single turn is up to 40 requests; a key printed in a + * terminal ends up in screenshots and gets revoked; and there is no way to + * replace it once it is on thousands of disks. + * + * So the key stays on a proxy and the client holds a token instead. The token + * is worthless to Google — it authenticates to Woopcode's proxy, which applies + * the quota and forwards under the real key. That makes the demo revocable, + * rationable and switchable off without shipping a release. + * + * This module owns the whole lifecycle so no other file knows the endpoint. + */ + +/** The provider a demo session runs as; the proxy speaks Gemini's wire format. */ +export const DEMO_PROVIDER = "google"; + +/** Marks a provider entry as demo-issued rather than user-supplied. */ +export const DEMO_ENTRY_TYPE = "demo"; + +/** + * The proxy Woopcode's demo talks to. + * + * Overridable so the proxy can be run on localhost during development. It is + * read per call rather than captured at import, because a test that sets the + * variable in `beforeAll` would otherwise race module loading. + */ +export function demoEndpoint( + env: Record = process.env, +): string { + return (env.WOOPCODE_DEMO_URL?.trim() || "https://demo.woopcode.dev").replace( + /\/+$/, + "", + ); +} + +/** What the proxy hands back when a demo session is granted. */ +export interface DemoSession { + token: string; + /** Epoch ms. */ + expiresAt: number; + /** Requests this install may make per day, for display only. */ + dailyLimit?: number; +} + +function installIdPath(): string { + return join(getConfigDir(), "install-id.json"); +} + +/** + * A stable, random identifier for this installation. + * + * The proxy rations per install, and needs something to ration by that is not + * an IP — shared NATs and cloud egress make addresses both leaky and unfair. + * It is random and carries nothing about the machine or the user: its only job + * is to be the same string tomorrow. + */ +export async function getInstallId(): Promise { + const path = installIdPath(); + const existing = await readJsonFile(path, "install id"); + + if ( + existing && + typeof existing === "object" && + typeof (existing as { installId?: unknown }).installId === "string" && + (existing as { installId: string }).installId.length > 0 + ) { + return (existing as { installId: string }).installId; + } + + const installId = crypto.randomUUID(); + await Bun.write(path, JSON.stringify({ installId }, null, 2)); + return installId; +} + +/** + * How long to wait for the proxy before giving up on it. + * + * Short, because of where this runs. A hung request leaves the wizard on a + * spinner with no key handler and no way out but Ctrl+C — on the first screen + * a new user ever sees. Ten seconds is long enough for a cold start and short + * enough that failing lands them back on "use my own API key" while they are + * still willing to. + */ +const SESSION_REQUEST_TIMEOUT_MS = 10_000; + +/** + * Asks the proxy for a demo session. + * + * Every failure is reported as a message a user can act on, because this runs + * inside the setup wizard where the alternative on screen is "use my own API + * key" — a raw fetch error there reads as the product being broken rather than + * as one optional path being unavailable. + * + * @throws If the proxy refuses, is unreachable, or answers with something that + * is not a session. + */ +export async function requestDemoSession( + signal?: AbortSignal, + /** Overridable so a test can prove the deadline fires without waiting it out. */ + timeoutMs: number = SESSION_REQUEST_TIMEOUT_MS, +): Promise { + const installId = await getInstallId(); + const endpoint = `${demoEndpoint()}/v1/session`; + + // The caller's signal still wins; this only adds a deadline of its own, so a + // wizard that is cancelled does not also wait out the timeout. + const timeout = AbortSignal.timeout(timeoutMs); + const deadline = signal ? AbortSignal.any([signal, timeout]) : timeout; + + let response: Response; + try { + response = await fetch(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ installId }), + signal: deadline, + }); + } catch (error) { + const timedOut = timeout.aborted; + throw new Error( + timedOut + ? "The demo service did not respond in time. You can still set up your own API key." + : `Could not reach the demo service (${ + error instanceof Error ? error.message : "network error" + }). You can still set up your own API key.`, + ); + } + + if (!response.ok) { + // 503 is the kill switch: the demo is off, deliberately, and no amount of + // retrying changes that. Say so rather than blaming the connection. + const reason = + response.status === 503 + ? "The demo is temporarily unavailable." + : `The demo service refused the request (HTTP ${response.status}).`; + throw new Error(`${reason} You can still set up your own API key.`); + } + + const body = (await response.json().catch(() => null)) as Partial | null; + + if (!body || typeof body.token !== "string" || !body.token) { + throw new Error( + "The demo service returned an unusable response. You can still set up your own API key.", + ); + } + + // An absent or unparseable expiry is treated as already expired rather than + // as forever. A token with no deadline would sit in the config outliving + // whatever the server thought it granted, and fail on some later turn + // instead of here, where there is still a wizard to fall back into. + const expiresAt = + typeof body.expiresAt === "number" && Number.isFinite(body.expiresAt) + ? body.expiresAt + : 0; + + return { + token: body.token, + expiresAt, + ...(typeof body.dailyLimit === "number" ? { dailyLimit: body.dailyLimit } : {}), + }; +} + +/** The provider entry a granted session is stored as. */ +export function demoProviderEntry(session: DemoSession): ProviderEntry { + return { + type: DEMO_ENTRY_TYPE, + apiKey: session.token, + baseUrl: demoEndpoint(), + demoExpiresAt: session.expiresAt, + }; +} + +/** + * The marker the proxy puts in the body of a 403 when an install has spent its + * daily allowance. + * + * A token rather than prose, because this is matched against an error message + * the SDK assembled: matching on wording would break the moment the proxy + * rephrases its own error, and silently — the user would get the raw provider + * failure back with no sign that the mapping had stopped working. + * + * 403 and not 429 on purpose. `runtime/retry.ts` treats 429 as transient and + * would spend every attempt re-asking a question whose answer is fixed until + * tomorrow; 403 is already in its fatal set, so this fails immediately. + */ +export const DEMO_EXHAUSTED_MARKER = "woopcode_demo_exhausted"; + +/** + * Rewrites a spent-allowance failure into something a user can act on, or + * returns null to leave the error alone. + * + * Only applies to a demo session: the same marker arriving on a real key would + * mean something has gone wrong that this message would misdescribe. + */ +export function demoExhaustionMessage( + error: unknown, + entry: { baseUrl?: string } | undefined, +): string | null { + if (!entry?.baseUrl) return null; + + const text = error instanceof Error ? error.message : String(error); + if (!text.includes(DEMO_EXHAUSTED_MARKER)) return null; + + return ( + "Demo limit reached for today.\n" + + "Run /login to continue with your own key." + ); +} + +export function isDemoEntry(entry: ProviderEntry | undefined): boolean { + return entry?.type === DEMO_ENTRY_TYPE; +} + +/** + * Whether a demo entry is past its deadline. + * + * Only demo entries expire. A user's own key has no `demoExpiresAt` and must + * never be judged by this — treating a missing deadline as "expired" would + * lock every ordinary user out of their own credentials. + */ +export function isDemoExpired( + entry: ProviderEntry | undefined, + now: number = Date.now(), +): boolean { + if (!isDemoEntry(entry)) return false; + return (entry?.demoExpiresAt ?? 0) <= now; +} diff --git a/onboarding/index.ts b/onboarding/index.ts index ceac304..6f8aaa6 100644 --- a/onboarding/index.ts +++ b/onboarding/index.ts @@ -7,27 +7,44 @@ import { canPromptInteractively, resolveEnvCredentials, } from "../config/envCredentials"; +import { isDemoExpired } from "../config/demoAccount"; export interface ProviderCredentials { provider: string; apiKey: string; + /** Set for demo mode, whose token is only valid against Woopcode's proxy. */ + baseUrl?: string; } /** * Resolves the active provider and its key, or null when the config cannot - * currently run: no default provider, a missing or keyless provider entry, or - * a provider Woopcode has no client for. + * currently run: no default provider, a missing or keyless provider entry, a + * provider Woopcode has no client for, or a demo token that has expired. + * + * An expired demo token counts as unconfigured on purpose. Returning it would + * put the failure on the first turn, as a 403 from a server the user has never + * heard of; returning null puts it in the wizard, where "try the demo again" + * and "use my own key" are both one keypress away. */ export async function resolveCredentials(): Promise { const config = await getConfig(); const provider = config.defaultProvider; - const apiKey = config.providers[provider]?.apiKey; + const entry = config.providers[provider]; + const apiKey = entry?.apiKey; if (!provider || !apiKey || !isProviderEnabled(provider)) { return null; } - return { provider, apiKey }; + if (isDemoExpired(entry)) { + return null; + } + + return { + provider, + apiKey, + ...(entry?.baseUrl ? { baseUrl: entry.baseUrl } : {}), + }; } /** diff --git a/onboarding/setupWizard.tsx b/onboarding/setupWizard.tsx index 09bf348..3900c6c 100644 --- a/onboarding/setupWizard.tsx +++ b/onboarding/setupWizard.tsx @@ -4,17 +4,29 @@ import TextInput from "ink-text-input"; import Spinner from "ink-spinner"; import { getEnabledProviders, type ProviderInfo } from "../providers/providerRegistry"; import { loginProvider } from "../config/authProvider"; -import { getConfig, saveConfig } from "../config/config"; +import { apiProviderEntry, getConfig, saveConfig } from "../config/config"; +import { + DEMO_PROVIDER, + demoProviderEntry, + requestDemoSession, +} from "../config/demoAccount"; import { colors } from "../tui/src/styles/theme"; type WizardStep = | "welcome" + | "choose-path" + | "demo-disclosure" + | "requesting-demo" | "select-provider" | "api-key-info" | "enter-key" | "validating" | "complete"; +/** The two ways out of the welcome screen, in the order they are listed. */ +const PATHS = ["demo", "own-key"] as const; +type SetupPath = (typeof PATHS)[number]; + interface SetupWizardProps { onComplete: () => void; onError: (error: string) => void; @@ -27,12 +39,38 @@ export function SetupWizard({ onComplete, onError }: SetupWizardProps) { null, ); const [apiKey, setApiKey] = useState(""); + const [pathIndex, setPathIndex] = useState(0); + /** + * Which path reached the final screen. Not derived from `pathIndex`: a demo + * request that fails falls back to provider selection while leaving the + * highlight where it was, so the index says "demo" for a run that ended with + * the user pasting their own key. + */ + const [completedVia, setCompletedVia] = useState("own-key"); const enabledProviders = getEnabledProviders(); useInput((input, key) => { if (step === "welcome") { if (key.return) { + setStep("choose-path"); + } + } else if (step === "choose-path") { + if (key.upArrow) { + setPathIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setPathIndex((prev) => Math.min(PATHS.length - 1, prev + 1)); + } else if (key.return) { + setStep(PATHS[pathIndex]! === "demo" ? "demo-disclosure" : "select-provider"); + } + } else if (step === "demo-disclosure") { + // Two distinct keys, not "any key". The disclosure below is the only + // notice a demo user gets that their code reaches Google's free tier, + // and a screen dismissed by whatever they happened to press next is not + // a notice anyone read. + if (input === "y" || input === "Y") { + void startDemo(); + } else if (key.escape || input === "n" || input === "N") { setStep("select-provider"); } } else if (step === "select-provider") { @@ -53,6 +91,33 @@ export function SetupWizard({ onComplete, onError }: SetupWizardProps) { } }); + const startDemo = async () => { + setStep("requesting-demo"); + + try { + const session = await requestDemoSession(); + + const config = await getConfig(); + config.defaultProvider = DEMO_PROVIDER; + config.providers[DEMO_PROVIDER] = demoProviderEntry(session); + await saveConfig(config); + + setCompletedVia("demo"); + setStep("complete"); + setTimeout(onComplete, 1000); + } catch (error) { + // The demo is the optional path. When it is unavailable the wizard drops + // into provider selection rather than dead-ending, so a user who came to + // set up their own key is not blocked by a service they never wanted. + onError( + error instanceof Error + ? error.message + : "Could not start the demo. You can still set up your own API key.", + ); + setStep("select-provider"); + } + }; + const handleKeySubmit = async (value: string) => { if (!selectedProvider || !value.trim()) { return; @@ -73,14 +138,12 @@ export function SetupWizard({ onComplete, onError }: SetupWizardProps) { const config = await getConfig(); config.defaultProvider = selectedProvider.id; - // A provider chosen in the wizard may have no entry yet. - config.providers[selectedProvider.id] = { - ...config.providers[selectedProvider.id], - type: "api", - apiKey: value.trim(), - }; + // Built fresh, which covers both a provider with no entry yet and one + // holding a demo entry whose proxy URL must not survive a real key. + config.providers[selectedProvider.id] = apiProviderEntry(value.trim()); await saveConfig(config); + setCompletedVia("own-key"); setStep("complete"); setTimeout(onComplete, 1000); } catch (error) { @@ -101,14 +164,86 @@ export function SetupWizard({ onComplete, onError }: SetupWizardProps) { Welcome to Woopcode! - You'll need an AI provider to use Woopcode. - We'll only ask for this once. + Let's get you set up. We'll only ask for this once. Press Enter to continue... ); } + if (step === "choose-path") { + const options = [ + { + label: "Try the demo — no API key needed", + hint: "Runs on Gemini through Woopcode's demo service. Limited daily usage.", + }, + { + label: "Use my own API key", + hint: "Google, OpenAI or Anthropic. Your key stays on this machine.", + }, + ]; + + return ( + + How would you like to start? + + {options.map((option, index) => ( + + + {index === pathIndex ? "❯ " : " "} + {option.label} + + {index === pathIndex && {option.hint}} + + ))} + + Use ↑↓ arrows to select, Enter to confirm + + ); + } + + if (step === "demo-disclosure") { + return ( + + + Before you try the demo + + + + The demo runs on Google's free tier. Under Google's terms, anything + you send there — including the contents of files in this repository — + may be used to improve Google's models, and may be read by human + reviewers. + + + Don't use the demo on private or confidential code. + + + Using your own API key avoids this. You can switch any time with + /login. + + + + Press y to accept and start the demo, or{" "} + n to set up your own key. + + + ); + } + + if (step === "requesting-demo") { + return ( + + + + + + Starting your demo session... + + + ); + } + if (step === "select-provider") { return ( @@ -185,8 +320,19 @@ export function SetupWizard({ onComplete, onError }: SetupWizardProps) { if (step === "complete") { return ( - ✓ API key verified - ✓ Configuration saved + {completedVia === "demo" ? ( + <> + ✓ Demo session started + + Limited daily usage. Run /login to switch to your own API key. + + + ) : ( + <> + ✓ API key verified + ✓ Configuration saved + + )} Starting Woopcode... diff --git a/packages/tests/config/demoMode.test.ts b/packages/tests/config/demoMode.test.ts new file mode 100644 index 0000000..510a902 --- /dev/null +++ b/packages/tests/config/demoMode.test.ts @@ -0,0 +1,326 @@ +import { describe, test, expect, beforeEach, afterAll } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +// Redirected before the modules under test are imported, and for the whole +// file rather than per test: config paths are resolved at call time from this +// variable, and restoring it in an afterEach would point the rest of the file +// at the developer's real ~/.config/woopcode. +const previousConfigHome = process.env.XDG_CONFIG_HOME; +const previousDemoUrl = process.env.WOOPCODE_DEMO_URL; +const configHome = mkdtempSync(join(tmpdir(), `woopcode-demo-${crypto.randomUUID()}-`)); +process.env.XDG_CONFIG_HOME = configHome; + +const { getConfig, saveConfig, normalizeConfig, apiProviderEntry } = await import( + "../../../config/config" +); +const { + DEMO_EXHAUSTED_MARKER, + demoEndpoint, + demoExhaustionMessage, + demoProviderEntry, + getInstallId, + isDemoEntry, + isDemoExpired, + requestDemoSession, +} = await import("../../../config/demoAccount"); +const { resolveCredentials } = await import("../../../onboarding"); + +const configDir = join(configHome, "woopcode"); +const providersPath = join(configDir, "providers.json"); + +const HOUR = 60 * 60 * 1000; + +afterAll(() => { + if (previousConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousConfigHome; + if (previousDemoUrl === undefined) delete process.env.WOOPCODE_DEMO_URL; + else process.env.WOOPCODE_DEMO_URL = previousDemoUrl; + rmSync(configHome, { recursive: true, force: true }); +}); + +beforeEach(async () => { + await Bun.write( + providersPath, + JSON.stringify({ defaultProvider: "", providers: {} }, null, 2), + ); +}); + +describe("a demo entry survives being stored", () => { + // The regression this file exists for. normalizeConfig rebuilds every + // provider entry field by field, so a field it does not name is dropped on + // the next read — silently, and with the worst possible result: the demo + // token stays but the proxy URL it is only valid against does not, so the + // next turn sends a Woopcode token to Google as if it were a Google key. + test("baseUrl and demoExpiresAt round-trip through disk", async () => { + const config = await getConfig(); + config.defaultProvider = "google"; + config.providers.google = demoProviderEntry({ + token: "demo-token", + expiresAt: Date.now() + HOUR, + }); + await saveConfig(config); + + const reloaded = await getConfig(); + + expect(reloaded.providers.google?.apiKey).toBe("demo-token"); + expect(reloaded.providers.google?.baseUrl).toBe(demoEndpoint()); + expect(reloaded.providers.google?.type).toBe("demo"); + expect(typeof reloaded.providers.google?.demoExpiresAt).toBe("number"); + }); + + test("a non-numeric expiry is dropped rather than carried through", () => { + const normalized = normalizeConfig({ + defaultProvider: "google", + providers: { google: { type: "demo", apiKey: "t", demoExpiresAt: "soon" } }, + }); + + expect(normalized.providers.google).not.toHaveProperty("demoExpiresAt"); + }); +}); + +describe("leaving demo mode", () => { + // Every path that stores a user's own key builds the entry with + // apiProviderEntry. Spreading over the previous entry instead kept the + // demo's type, proxy URL and expiry alive underneath the new key, which sent + // a real Google credential to Woopcode's proxy and expired it on the demo's + // schedule. + test("apiProviderEntry carries nothing over from a demo entry", () => { + const demo = demoProviderEntry({ token: "demo-token", expiresAt: Date.now() + HOUR }); + const upgraded = { ...demo, ...apiProviderEntry("real-key") }; + + // The spread above is what a caller must NOT do; the assertion is that + // apiProviderEntry's own result is clean. + expect(upgraded.baseUrl).toBeDefined(); + expect(apiProviderEntry("real-key")).toEqual({ type: "api", apiKey: "real-key" }); + expect(apiProviderEntry("real-key")).not.toHaveProperty("baseUrl"); + expect(apiProviderEntry("real-key")).not.toHaveProperty("demoExpiresAt"); + }); +}); + +describe("expiry", () => { + test("a live demo entry is not expired", () => { + expect(isDemoExpired({ type: "demo", demoExpiresAt: Date.now() + HOUR })).toBe(false); + }); + + test("a past deadline is expired", () => { + expect(isDemoExpired({ type: "demo", demoExpiresAt: Date.now() - 1 })).toBe(true); + }); + + test("a demo entry with no deadline is expired, not immortal", () => { + expect(isDemoExpired({ type: "demo", apiKey: "t" })).toBe(true); + }); + + // A user's own key has no deadline either. Judging it by the same rule would + // lock every ordinary user out of credentials they supplied themselves. + test("a real key is never expired", () => { + expect(isDemoExpired({ type: "api", apiKey: "real" })).toBe(false); + expect(isDemoEntry({ type: "api", apiKey: "real" })).toBe(false); + }); +}); + +describe("resolveCredentials", () => { + async function store(entry: Record) { + await Bun.write( + providersPath, + JSON.stringify({ defaultProvider: "google", providers: { google: entry } }, null, 2), + ); + } + + test("a live demo session resolves with its base URL", async () => { + await store({ + type: "demo", + apiKey: "demo-token", + baseUrl: "https://demo.example", + demoExpiresAt: Date.now() + HOUR, + }); + + expect(await resolveCredentials()).toEqual({ + provider: "google", + apiKey: "demo-token", + baseUrl: "https://demo.example", + }); + }); + + // Not "returns the entry and lets the turn 403": that puts the failure in + // front of a user with no wizard left to fall back into. + test("an expired demo session counts as unconfigured", async () => { + await store({ + type: "demo", + apiKey: "demo-token", + baseUrl: "https://demo.example", + demoExpiresAt: Date.now() - 1, + }); + + expect(await resolveCredentials()).toBeNull(); + }); + + test("a real key resolves with no base URL at all", async () => { + await store({ type: "api", apiKey: "real-key" }); + + const resolved = await resolveCredentials(); + expect(resolved).toEqual({ provider: "google", apiKey: "real-key" }); + expect(resolved).not.toHaveProperty("baseUrl"); + }); +}); + +describe("requesting a session from the proxy", () => { + /** A real server on a real port, so the request is not imagined. */ + function serve(handler: (request: Request) => Response | Promise) { + const server = Bun.serve({ port: 0, fetch: handler }); + process.env.WOOPCODE_DEMO_URL = `http://localhost:${server.port}`; + return server; + } + + test("a granted session is returned and the install id is sent", async () => { + let body: unknown; + let path = ""; + const server = serve(async (request) => { + path = new URL(request.url).pathname; + body = await request.json(); + return Response.json({ + token: "issued-token", + expiresAt: 4102444800000, + dailyLimit: 50, + }); + }); + + try { + const session = await requestDemoSession(); + expect(session).toEqual({ + token: "issued-token", + expiresAt: 4102444800000, + dailyLimit: 50, + }); + expect(path).toBe("/v1/session"); + expect((body as { installId: string }).installId).toBe(await getInstallId()); + } finally { + server.stop(true); + } + }); + + test("the install id is stable across calls", async () => { + const first = await getInstallId(); + expect(await getInstallId()).toBe(first); + expect(first.length).toBeGreaterThan(0); + }); + + // Absent, not forever. A token with no deadline would outlive whatever the + // server granted and fail on some later turn instead of here. + test("a session with no expiry is treated as already expired", async () => { + const server = serve(() => Response.json({ token: "t" })); + + try { + const session = await requestDemoSession(); + expect(session.expiresAt).toBe(0); + expect(isDemoExpired(demoProviderEntry(session))).toBe(true); + } finally { + server.stop(true); + } + }); + + test("the kill switch reports the demo as unavailable", async () => { + const server = serve(() => new Response("off", { status: 503 })); + + try { + await expect(requestDemoSession()).rejects.toThrow(/temporarily unavailable/i); + } finally { + server.stop(true); + } + }); + + test("every failure still points at the other way in", async () => { + const server = serve(() => new Response("no", { status: 429 })); + + try { + await expect(requestDemoSession()).rejects.toThrow(/your own API key/i); + } finally { + server.stop(true); + } + }); + + // Otherwise the wizard sits on a spinner with no key handler, on the first + // screen a new user ever sees, with only Ctrl+C out. + test("a proxy that never answers gives up instead of hanging", async () => { + const server = serve(() => new Promise(() => {})); + + try { + // A short deadline rather than the real one: this proves the mechanism + // fires, and waiting out the production value would add ten seconds to + // every run of the whole suite for a single assertion. + await expect(requestDemoSession(undefined, 50)).rejects.toThrow( + /did not respond in time/i, + ); + } finally { + server.stop(true); + } + }); + + test("a caller's own cancellation is still honoured", async () => { + const server = serve(() => new Promise(() => {})); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 50); + + try { + await expect(requestDemoSession(controller.signal)).rejects.toThrow( + /demo service/i, + ); + } finally { + server.stop(true); + } + }); + + test("a response that is not a session is refused", async () => { + const server = serve(() => Response.json({ nope: true })); + + try { + await expect(requestDemoSession()).rejects.toThrow(/unusable response/i); + } finally { + server.stop(true); + } + }); +}); + +// The proxy's two refusals are chosen to land on opposite sides of a rule that +// already exists in runtime/retry.ts, and nothing in that file mentions the +// demo. If someone moves 403 into the retryable set, exhaustion starts costing +// three round trips to reach the same answer, and load shedding stops working +// if 429 leaves it. This is the test that notices. +describe("the proxy's status codes match what retry already does", () => { + test("403 — allowance spent — is fatal, so it is not retried", async () => { + const { isRetryableError } = await import("../../../runtime/retry"); + expect(isRetryableError({ status: 403 })).toBe(false); + }); + + test("429 — proxy busy — is retried, which is how load shedding works", async () => { + const { isRetryableError } = await import("../../../runtime/retry"); + expect(isRetryableError({ status: 429 })).toBe(true); + }); +}); + +describe("exhaustion is explained, not passed through", () => { + const demoEntry = { baseUrl: "https://demo.example" }; + + test("the marker becomes an instruction naming /login", () => { + const message = demoExhaustionMessage( + new Error(`403 Forbidden {"error":"${DEMO_EXHAUSTED_MARKER}"}`), + demoEntry, + ); + + expect(message).toContain("Demo limit reached"); + expect(message).toContain("/login"); + }); + + // Anything unrecognised is left alone: a mapping that swallowed other errors + // would replace a real bug with a wrong explanation. + test("an unrelated failure is left untouched", () => { + expect(demoExhaustionMessage(new Error("socket hang up"), demoEntry)).toBeNull(); + }); + + test("the same marker on a real key is not rewritten", () => { + expect( + demoExhaustionMessage(new Error(DEMO_EXHAUSTED_MARKER), { baseUrl: undefined }), + ).toBeNull(); + }); +}); diff --git a/packages/tests/providers/demoBaseUrl.test.ts b/packages/tests/providers/demoBaseUrl.test.ts new file mode 100644 index 0000000..c4caab1 --- /dev/null +++ b/packages/tests/providers/demoBaseUrl.test.ts @@ -0,0 +1,149 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { createProviderClient, geminiClient } from "../../../providers/client"; +import type { Message } from "../../../config/types"; + +/** + * Demo mode is one line: the Gemini client is handed a base URL and the SDK + * sends everything to it instead of to Google. Every other part of the feature + * — the token, the config field, the wizard — is worthless if that line is + * wrong, and wrong in the direction that matters silently: a request that + * still goes to Google carries a Woopcode demo token as if it were a Google + * key, and comes back as an authentication error nobody would connect to this. + * + * So this is checked against a real server on a real port rather than a + * stubbed fetch. The assertion is that the request arrives here — which it can + * only do by not having gone to Google. + */ + +const messages: Message[] = [{ role: "user", content: "hello" }]; + +let server: ReturnType | null = null; + +afterEach(() => { + server?.stop(true); + server = null; +}); + +/** Records what reaches it and answers with a minimal, valid stream. */ +function recordingProxy() { + const seen: { path: string; apiKey: string | null }[] = []; + + server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + seen.push({ + path: url.pathname, + // The SDK sends the credential as a header; the proxy authenticates + // the demo token from exactly here. + apiKey: request.headers.get("x-goog-api-key"), + }); + + return new Response( + `data: ${JSON.stringify({ + candidates: [{ content: { parts: [{ text: "hi" }] }, finishReason: "STOP" }], + })}\r\n\r\n`, + { headers: { "content-type": "text/event-stream" } }, + ); + }, + }); + + return { seen, url: `http://localhost:${server.port}` }; +} + +async function drain(stream: AsyncGenerator) { + for await (const _ of stream) { + // The events themselves are covered elsewhere; this file is about where + // the request went. + } +} + +describe("baseUrl diverts requests away from Google", () => { + test("geminiClient sends the turn to the base URL it was given", async () => { + const { seen, url } = recordingProxy(); + const client = geminiClient("demo-token", "gemini-3.5-flash-lite", undefined, url); + + await drain(client.stream(messages, "", undefined, false)); + + expect(seen).toHaveLength(1); + expect(seen[0]!.path).toContain("gemini-3.5-flash-lite"); + expect(seen[0]!.apiKey).toBe("demo-token"); + }); + + test("createProviderClient passes it through for google", async () => { + const { seen, url } = recordingProxy(); + const client = createProviderClient("google", "demo-token", undefined, url); + + await drain(client.stream(messages, "", undefined, false)); + + expect(seen).toHaveLength(1); + expect(seen[0]!.apiKey).toBe("demo-token"); + }); + + // The alias is what a config written by an older version stores, and it + // reaches the same client by a different branch of the switch. + test("the gemini alias gets it too", async () => { + const { seen, url } = recordingProxy(); + const client = createProviderClient("gemini", "demo-token", undefined, url); + + await drain(client.stream(messages, "", undefined, false)); + + expect(seen).toHaveLength(1); + }); + + /** + * Where a request would have gone, without letting it go anywhere. + * + * The cases below are about traffic that must NOT reach the proxy, and the + * honest version of that assertion would otherwise be a real call to Google, + * Anthropic or OpenAI — an outbound request per run, failing whenever CI has + * no network and reading as a code break. Stubbing the global is the + * per-file, restorable way to fake a network boundary. + */ + function recordDestinations() { + const urls: string[] = []; + const original = globalThis.fetch; + + globalThis.fetch = (async (input: any, init?: any) => { + urls.push(typeof input === "string" ? input : (input?.url ?? String(input))); + throw new Error("blocked in test"); + }) as unknown as typeof fetch; + + return { urls, restore: () => (globalThis.fetch = original) }; + } + + // Not merely untested: forwarding it would point an SDK that speaks another + // wire format at a proxy that only answers Gemini's. + test("anthropic and openai ignore it rather than being pointed at the proxy", async () => { + const proxyUrl = "http://demo.invalid:9999"; + const { urls, restore } = recordDestinations(); + + try { + for (const provider of ["anthropic", "openai"]) { + const client = createProviderClient(provider, "key", undefined, proxyUrl); + await drain(client.stream(messages, "", undefined, false)).catch(() => {}); + } + } finally { + restore(); + } + + expect(urls.length).toBeGreaterThan(0); + expect(urls.some((url) => url.includes("demo.invalid"))).toBe(false); + }); + + test("without a base URL the Gemini client still goes to Google", async () => { + const { urls, restore } = recordDestinations(); + + try { + const client = createProviderClient("google", "key-only", undefined, undefined); + await drain(client.stream(messages, "", undefined, false)).catch(() => {}); + } finally { + restore(); + } + + expect(urls.length).toBeGreaterThan(0); + expect(urls.every((url) => url.includes("generativelanguage.googleapis.com"))).toBe( + true, + ); + }); +}); diff --git a/providers/client.ts b/providers/client.ts index 1d0a7b5..6551d4c 100644 --- a/providers/client.ts +++ b/providers/client.ts @@ -128,6 +128,7 @@ export function geminiClient( apiKey: string, model = DEFAULT_MODEL_ID, injected?: Pick, + baseUrl?: string, ): ProviderClient { // Built on the first request rather than here. As a default argument this ran // whenever a client was constructed, which made merely naming a provider do @@ -135,8 +136,17 @@ export function geminiClient( // can reach for credentials, so a caller that only wanted to know a provider // is available paid for a network client and could block waiting for one. // Constructing a client should cost nothing until it is used. + // + // `baseUrl` sends the same requests somewhere other than Google. Demo mode + // uses it to reach Woopcode's proxy, which holds the real key: the SDK takes + // a full-URL override, so the wire format, the streaming and every code path + // below stay exactly what they are for a direct key. let ai = injected; - const sdk = () => (ai ??= new GoogleGenAI({ apiKey })); + const sdk = () => + (ai ??= new GoogleGenAI({ + apiKey, + ...(baseUrl ? { httpOptions: { baseUrl } } : {}), + })); return { async *stream( @@ -483,6 +493,7 @@ export function createProviderClient( provider: string, apiKey: string, model?: string, + baseUrl?: string, ): ProviderClient { // A config written before a provider existed can pair it with another // provider's model — providers.json stores the two independently. Sending a @@ -497,10 +508,19 @@ export function createProviderClient( model !== undefined && findModel(model) !== undefined && !modelBelongsToProvider(model, provider); const runnable = mismatched ? undefined : model; + // `baseUrl` is honoured only by Google, because demo mode is the only thing + // that sets it and the proxy speaks one vendor's wire format. Passing it to + // the others would point an Anthropic or OpenAI SDK at a server that cannot + // answer them, so it is ignored there rather than forwarded. switch (provider) { case "google": case "gemini": - return geminiClient(apiKey, runnable ?? defaultModelForProvider("google")); + return geminiClient( + apiKey, + runnable ?? defaultModelForProvider("google"), + undefined, + baseUrl, + ); case "anthropic": return anthropicClient(apiKey, runnable ?? defaultModelForProvider("anthropic"));