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
162 changes: 162 additions & 0 deletions app/src/components/shell/api-key-connect-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Eye, EyeOff } from "lucide-react";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Spinner,
} from "@houston-ai/core";
import type { ProviderInfo } from "../../lib/providers";
import { tauriProvider, tauriSystem } from "../../lib/tauri";
import { useUIStore } from "../../stores/ui";
import { analytics } from "../../lib/analytics";

/**
* Connect dialog for API-key-only providers (OpenRouter, …) — the ones with
* `loginKind: "apiKey"` and no CLI/OAuth sign-in. The user pastes a key; it's
* persisted by the engine (`tauriProvider.setProviderApiKey`) to that
* provider's credential store and injected into the CLI subprocess at spawn.
*
* Provider-driven (copy, console URL, env-var hint all come from
* `ProviderInfo`), so a new API-key provider needs no new dialog — just a
* `PROVIDERS` entry. Gemini keeps its own dialog because it leads with OAuth.
*/
export function ApiKeyConnectDialog(props: {
provider: ProviderInfo | null;
onOpenChange: (open: boolean) => void;
onSaved: (providerId: string) => void;
}) {
const { t } = useTranslation("providers");
const addToast = useUIStore((s) => s.addToast);

const [apiKey, setApiKey] = useState("");
const [revealed, setRevealed] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);

const provider = props.provider;
const open = provider !== null;
const trimmed = apiKey.trim();
const canSave = trimmed.length >= 10 && !saving;

const reset = () => {
setApiKey("");
setRevealed(false);
setError(null);
setSaving(false);
};

const handleOpenChange = (next: boolean) => {
if (!next) reset();
props.onOpenChange(next);
};

const handleOpenConsole = async () => {
if (!provider?.apiKeyConsoleUrl) return;
try {
await tauriSystem.openUrl(provider.apiKeyConsoleUrl);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
addToast({
title: t("apiKeyConnect.openConsoleFailed", { name: provider.name }),
description: msg,
variant: "error",
});
}
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!provider || !canSave) return;
setError(null);
setSaving(true);
try {
await tauriProvider.setProviderApiKey(provider.id, trimmed);
analytics.track("provider_configured", { provider: provider.id });
const id = provider.id;
reset();
props.onSaved(id);
props.onOpenChange(false);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
addToast({
title: t("apiKeyConnect.saveFailed", { name: provider.name }),
description: msg,
variant: "error",
});
} finally {
setSaving(false);
}
};

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("apiKeyConnect.title", { name: provider?.name ?? "" })}</DialogTitle>
<DialogDescription>
{t("apiKeyConnect.description", { name: provider?.name ?? "" })}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-3 pt-1">
{provider?.apiKeyConsoleUrl && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleOpenConsole}
className="self-start gap-1.5"
>
<ExternalLink className="size-3.5" />
{t("apiKeyConnect.openConsole", { name: provider.name })}
</Button>
)}
<div className="flex items-center gap-2">
<input
type={revealed ? "text" : "password"}
value={apiKey}
onChange={(ev) => setApiKey(ev.target.value)}
placeholder={t("apiKeyConnect.placeholder")}
className="flex-1 rounded-md border border-border bg-background px-2.5 py-1.5 text-[12px] font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
disabled={saving}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setRevealed((v) => !v)}
className="gap-1.5 shrink-0"
aria-label={revealed ? t("apiKeyConnect.hide") : t("apiKeyConnect.show")}
disabled={saving}
>
{revealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
</Button>
</div>
{provider?.apiKeyEnvVar && (
<p className="text-[11px] text-muted-foreground">
{t("apiKeyConnect.envHint", { envVar: provider.apiKeyEnvVar })}
</p>
)}
{error && (
<p className="text-[12px] text-destructive" role="alert">
{error}
</p>
)}
<Button type="submit" disabled={!canSave} className="gap-1.5 w-full" size="sm">
{saving && <Spinner className="size-3.5" />}
{saving ? t("apiKeyConnect.saving") : t("apiKeyConnect.saveKey")}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
10 changes: 10 additions & 0 deletions app/src/components/shell/provider-logos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ export function GeminiLogo({ className = "h-5 w-5" }: { className?: string } = {
);
}

export function OpenRouterLogo({ className = "h-5 w-5" }: { className?: string } = {}) {
return (
<svg viewBox="0 0 24 24" className={className} fill="currentColor">
<path d="M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z" />
</svg>
);
}

export function DeepSeekLogo() {
return (
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
Expand Down Expand Up @@ -62,6 +70,8 @@ export function ProviderGlyph({ providerId }: { providerId: string }) {
return <ClaudeLogo />;
case "openai":
return <OpenAILogo />;
case "openrouter":
return <OpenRouterLogo />;
case "gemini":
return <GeminiLogo />;
case "deepseek":
Expand Down
11 changes: 2 additions & 9 deletions app/src/components/shell/provider-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useUIStore } from "../../stores/ui";
import { analytics } from "../../lib/analytics";
import { subscribeHoustonEvents } from "../../lib/events";
import { osIsTauri } from "../../lib/os-bridge";
import { GeminiConnectDialog } from "./gemini-connect-dialog";
import { ApiKeyConnectDialog } from "./api-key-connect-dialog";
import { ProviderLoginDialog } from "./provider-login-dialog";
import { ProviderCard, ComingSoonCard } from "./provider-cards";

Expand Down Expand Up @@ -272,7 +272,7 @@ export function ProviderPicker({ onSelect }: Props) {
}}
/>

<GeminiConnectDialog
<ApiKeyConnectDialog
provider={apiKeyDialogFor}
onOpenChange={(open) => {
if (!open) setApiKeyDialogFor(null);
Expand All @@ -285,13 +285,6 @@ export function ProviderPicker({ onSelect }: Props) {
setPendingId(providerId);
loadStatuses();
}}
onLoginStarted={(providerId) => {
// OAuth path: gemini-cli is now driving the browser flow.
// Arm the picker's status poll so the card flips to
// Connected the moment gemini-cli writes its credential
// files, same as the API-key save path above.
setPendingId(providerId);
}}
/>

<ProviderLoginDialog
Expand Down
7 changes: 2 additions & 5 deletions app/src/components/shell/provider-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useUIStore } from "../../stores/ui";
import { analytics } from "../../lib/analytics";
import { subscribeHoustonEvents } from "../../lib/events";
import { osIsTauri } from "../../lib/os-bridge";
import { GeminiConnectDialog } from "./gemini-connect-dialog";
import { ApiKeyConnectDialog } from "./api-key-connect-dialog";
import { ProviderLoginDialog } from "./provider-login-dialog";
import { ProviderAccountRow } from "./provider-account-row";
import { providerAppearsConnected } from "./provider-reconnect-state";
Expand Down Expand Up @@ -337,7 +337,7 @@ export function ProviderSettings() {
}}
/>

<GeminiConnectDialog
<ApiKeyConnectDialog
provider={apiKeyDialogFor}
onOpenChange={(open) => {
if (!open) setApiKeyDialogFor(null);
Expand All @@ -346,9 +346,6 @@ export function ProviderSettings() {
setPendingId(providerId);
loadStatuses();
}}
onLoginStarted={(providerId) => {
setPendingId(providerId);
}}
/>

<ProviderLoginDialog
Expand Down
46 changes: 46 additions & 0 deletions app/src/lib/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,52 @@ export const PROVIDERS: readonly ProviderInfo[] = [
],
defaultModel: "claude-sonnet-4-6",
},
{
id: "openrouter",
name: "OpenRouter",
subtitle: "Codex via OpenRouter",
cliName: "codex",
installUrl: "https://openrouter.ai/docs",
loginCommand: "",
cost: "Pay-as-you-go OpenRouter credits",
// OpenRouter has no CLI sign-in: the user pastes an API key, persisted to
// `<houston-home>/providers/openrouter/.env` and injected as
// OPENROUTER_API_KEY into the codex subprocess (engine handles the rest).
loginKind: "apiKey",
apiKeyConsoleUrl: "https://openrouter.ai/keys",
apiKeyEnvVar: "OPENROUTER_API_KEY",
// Curated cheap + capable OPEN-SOURCE slugs. Context windows are estimates
// (the indicator self-corrects upward from observed usage). Effort is
// intentionally omitted: these are non-reasoning chat models, so the picker
// hides the effort row for all of them.
models: [
{
id: "deepseek/deepseek-chat",
label: "DeepSeek V3",
description: "Best value. Strong general + coding ability, very cheap.",
contextWindow: 64_000,
},
{
id: "meta-llama/llama-3.3-70b-instruct",
label: "Llama 3.3 70B",
description: "Solid open flagship from Meta. Cheap and reliable.",
contextWindow: 128_000,
},
{
id: "qwen/qwen-2.5-72b-instruct",
label: "Qwen2.5 72B",
description: "Strong multilingual + coding model from Alibaba.",
contextWindow: 32_768,
},
{
id: "mistralai/mistral-nemo",
label: "Mistral Nemo",
description: "Lightweight and very cheap for simple tasks.",
contextWindow: 128_000,
},
],
defaultModel: "deepseek/deepseek-chat",
},
] as const;

/** Find a provider by id. */
Expand Down
11 changes: 11 additions & 0 deletions app/src/lib/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,17 @@ export const tauriProvider = {
*/
setGeminiApiKey: (apiKey: string) =>
call<void>("set_gemini_api_key", () => getEngine().setGeminiApiKey(apiKey)),

/**
* Persist an API key for an API-key provider (OpenRouter, …) to its engine
* credential store. Generic over the provider id. Never log `apiKey` — it's
* a SECRET. Errors surface via `call`'s rejection path for the caller to
* toast with `errorMessage(err)`.
*/
setProviderApiKey: (providerId: string, apiKey: string) =>
call<void>("set_provider_api_key", () =>
getEngine().setProviderApiKey(providerId, apiKey),
),
};

// ─── System (OS-native helpers, preserved for back-compat) ────────────
Expand Down
13 changes: 13 additions & 0 deletions app/src/locales/en/providers.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,18 @@
"saving": "Saving...",
"saveFailed": "Couldn't save your {{name}} key",
"cancel": "Cancel"
},
"apiKeyConnect": {
"title": "Connect {{name}}",
"description": "Paste your {{name}} API key. It's saved on this device and used to run your agents.",
"openConsole": "Get an API key",
"openConsoleFailed": "Couldn't open {{name}}",
"placeholder": "Paste your API key here",
"show": "Show key",
"hide": "Hide key",
"envHint": "Saved as {{envVar}} on this device.",
"saveKey": "Save and connect",
"saving": "Saving...",
"saveFailed": "Couldn't save your {{name}} key"
}
}
13 changes: 13 additions & 0 deletions app/src/locales/es/providers.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,18 @@
"saving": "Guardando...",
"saveFailed": "No se pudo guardar tu llave de {{name}}",
"cancel": "Cancelar"
},
"apiKeyConnect": {
"title": "Conecta {{name}}",
"description": "Pega tu llave de API de {{name}}. Se guarda en este dispositivo y se usa para ejecutar tus agentes.",
"openConsole": "Obtener una llave de API",
"openConsoleFailed": "No se pudo abrir {{name}}",
"placeholder": "Pega aquí tu llave de API",
"show": "Mostrar la llave",
"hide": "Ocultar la llave",
"envHint": "Se guarda como {{envVar}} en este dispositivo.",
"saveKey": "Guardar y conectar",
"saving": "Guardando...",
"saveFailed": "No se pudo guardar tu llave de {{name}}"
}
}
13 changes: 13 additions & 0 deletions app/src/locales/pt/providers.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,18 @@
"saving": "Salvando...",
"saveFailed": "Não foi possível salvar sua chave do {{name}}",
"cancel": "Cancelar"
},
"apiKeyConnect": {
"title": "Conectar {{name}}",
"description": "Cole sua chave de API da {{name}}. Ela fica salva neste dispositivo e é usada para executar seus agentes.",
"openConsole": "Obter uma chave de API",
"openConsoleFailed": "Não foi possível abrir {{name}}",
"placeholder": "Cole aqui sua chave de API",
"show": "Mostrar a chave",
"hide": "Ocultar a chave",
"envHint": "Salva como {{envVar}} neste dispositivo.",
"saveKey": "Salvar e conectar",
"saving": "Salvando...",
"saveFailed": "Não foi possível salvar sua chave da {{name}}"
}
}
Loading