Skip to content
Merged
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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions commands/agent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
34 changes: 32 additions & 2 deletions commands/agentController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
14 changes: 6 additions & 8 deletions commands/providers/login.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 22 additions & 8 deletions commands/slash/commands.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
39 changes: 37 additions & 2 deletions config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -72,7 +97,13 @@ export async function readJsonFile(path: string, label: string): Promise<unknown

/**
* Fills in whatever the config is missing so callers never index into an
* undefined `providers` map. Unrecognised extra keys are preserved.
* undefined `providers` map.
*
* Unrecognised extra keys are preserved at the top level, but *not* inside a
* provider entry: each entry is rebuilt field by field below, so a field this
* function does not know about is dropped on the next read. Anything added to
* `ProviderEntry` has to be added to that loop too, or it survives being
* written and disappears the moment the config is loaded again.
*/
export function normalizeConfig(raw: unknown): ProvidersConfig {
const source = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
Expand All @@ -84,10 +115,14 @@ export function normalizeConfig(raw: unknown): ProvidersConfig {
const providers: Record<string, ProviderEntry> = {};
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 }
: {}),
};
}

Expand Down
Loading