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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ during the wait.
- Notifications in `AppShell` and settings cards in `SettingsConfig` present the update notification alongside copyable terminal update commands.

### Auth and model config
- Auth flows go through RPC commands (`get_login_providers`, `login`) against the omp child process; credentials live in omp's `agent.db` (SQLite) which omp-web never touches directly.
- Auth flows go through RPC commands (`get_login_providers`, `login`, `get_logout_accounts`, `logout`) against the omp child process; credentials live in omp's `agent.db` (SQLite) which omp-web never touches directly. `/api/auth/accounts/[provider]` lists stored accounts and removes one by `credentialId`; omp without those commands reports `supported: false` and the UI falls back to the read-only `omp usage` account list.
- The Models panel reads and writes `models.yml` in the omp agent directory (`~/.omp/agent/models.yml`, `.yaml` fallback).
- API-key status endpoints must never return the raw key.

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f

### Added

- List every account omp stores for a subscription provider in Settings → API Keys & Providers and disconnect a single account with its **Disconnect** button; the other accounts stay connected and omp keeps choosing and rotating between them. The sign-in button of a connected provider now reads **Add account**: signing in with another account adds it, and signing in with a connected account refreshes it. Requires omp 18.7.0 or later ([can1357/oh-my-pi#14588](https://github.com/can1357/oh-my-pi/pull/14588)); older omp keeps the read-only account list from `omp usage` and explains how to disconnect from a terminal.
- Choose **Classifier** or **Vendor default** in Settings → AI Model Defaults → **Auto Thinking Source** when Reasoning is `auto`. The choice is saved in omp's native config for newly started or restarted agents. Vendor mode prefers the publisher's effort, then omp's per-model default, then normal Auto fallback. Requires an omp release containing [can1357/oh-my-pi#14114](https://github.com/can1357/oh-my-pi/pull/14114).
- Show conflicting skill names and deduplicated installations when an OMP session starts, with resolution details, a **×** button that dismisses the notice for the session until the diagnostic report changes, and a persisted **Turn off** action that disables startup notices. Inspect a running session manually in **Settings → Extensions & Tools → Skills**, even when notices are off; inspection never resumes a stopped session. Requires an OMP runtime with the skill-diagnostics RPC commands; older runtimes report live diagnostics as unavailable.
- Ask a side question with `/btw <question>`: omp answers from the current conversation in a panel above the composer, even while the agent is working, and the question never enters the chat. Cancel a running answer, copy it, or ask follow-ups in the same topic. `/btw` on its own lists the session's past side questions, including ones asked in the terminal, each expandable to all its turns with **Copy** and **Follow up**. Side questions are text-only: with an attachment in the composer, `/btw` is refused and the draft is kept. An answer that omp lost (for example after its process restarted) shows as **Interrupted** instead of spinning. Requires an omp with the `btw` RPC commands; older omp shows "/btw requires a newer omp" instead of sending the text to the model.
Expand Down
72 changes: 72 additions & 0 deletions app/api/auth/accounts/[provider]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { invalidateModelsCache } from "@/lib/models-cache";
import { runUtilityCommand } from "@/lib/omp/rpc-utility";

export const dynamic = "force-dynamic";

/** Entry of omp's `get_logout_accounts` response. Labels never carry a raw key. */
interface OmpProviderAccount {
credentialId: number;
label: string;
detail: string;
type: "api_key" | "oauth";
}

// omp releases older than the get_logout_accounts/logout RPC answer "Unknown command".
const UNSUPPORTED = "Unknown command";

function isAccount(value: unknown): value is OmpProviderAccount {
if (typeof value !== "object" || value === null) return false;
const a = value as Record<string, unknown>;
return Number.isInteger(a.credentialId) && typeof a.label === "string" && typeof a.detail === "string"
&& (a.type === "api_key" || a.type === "oauth");
}

// omp's `active` flag is relative to the utility process's own throwaway
// session, not to any chat, so it is deliberately not passed on.
/** Stored credentials omp holds for one provider. */
export async function GET(_req: Request, { params }: { params: Promise<{ provider: string }> }) {
const { provider } = await params;
try {
const { accounts } = await runUtilityCommand<{ accounts?: unknown }>(
{ type: "get_logout_accounts", providerId: provider },
30_000,
);
const list = Array.isArray(accounts) ? accounts.filter(isAccount) : [];
return NextResponse.json({
supported: true,
accounts: list.map(({ credentialId, label, detail, type }) => ({ credentialId, label, detail, type })),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes(UNSUPPORTED)) return NextResponse.json({ supported: false, accounts: [] });
return NextResponse.json({ error: message }, { status: 500 });
}
}

/** Remove one stored credential (`?credentialId=`); omp picks the next account. */
export async function DELETE(req: Request, { params }: { params: Promise<{ provider: string }> }) {
const raw = new URL(req.url).searchParams.get("credentialId") ?? "";
const credentialId = Number(raw);
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(credentialId)) {
return NextResponse.json({ error: "credentialId must be an integer" }, { status: 400 });
}
const { provider } = await params;
try {
await runUtilityCommand({ type: "logout", providerId: provider, credentialId }, 60_000);
invalidateModelsCache();
return NextResponse.json({ ok: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes(UNSUPPORTED)) {
return NextResponse.json(
{
error: "omp-web cannot disconnect accounts with this omp version. Update omp, or run `omp` in a terminal and use /logout.",
code: "logout_unsupported",
},
{ status: 501 },
);
}
return NextResponse.json({ error: message }, { status: 500 });
}
}
23 changes: 0 additions & 23 deletions app/api/auth/logout/[provider]/route.ts

This file was deleted.

43 changes: 10 additions & 33 deletions components/ModelsConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,8 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre
const { t, tn } = useI18n();
const [loginState, setLoginState] = useState<OAuthLoginState>({ phase: "idle" });
const [inputValue, setInputValue] = useState("");
// Bumped on login so the account list remounts and shows the new account.
const [accountsVersion, setAccountsVersion] = useState(0);
const eventSourceRef = useRef<EventSource | null>(null);
const inputRef = useRef<HTMLInputElement>(null);

Expand Down Expand Up @@ -847,6 +849,7 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre
} else if (data.type === "success") {
es.close();
setLoginState({ phase: "success" });
setAccountsVersion((v) => v + 1);
onRefresh();
} else if (data.type === "error") {
es.close();
Expand All @@ -862,22 +865,6 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre
};
}, [provider.id, onRefresh, t]);

const handleLogout = useCallback(async () => {
try {
const res = await fetch(`/api/auth/logout/${encodeURIComponent(provider.id)}`, { method: "POST" });
const d = await res.json().catch(() => ({})) as { error?: string; code?: string };
if (!res.ok || d.error) {
// omp has no logout RPC/CLI surface; the route returns 501 with guidance.
setLoginState({ phase: "error", message: d.error || d.code ? formatApiError(d) : `HTTP ${res.status}` });
return;
}
setLoginState({ phase: "idle" });
onRefresh();
} catch (e) {
setLoginState({ phase: "error", message: e instanceof Error ? e.message : String(e) });
}
}, [provider.id, onRefresh]);

const submitCode = useCallback(async (token: string, code: string) => {
if (!code.trim()) return;
setLoginState({ phase: "progress", message: t("modelsConfig.verifying") });
Expand Down Expand Up @@ -1020,7 +1007,7 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre
)}
</div>

<ProviderAccounts providerId={provider.id} enabled={provider.loggedIn} />
<ProviderAccounts key={`${provider.id}:${accountsVersion}`} providerId={provider.id} enabled={provider.loggedIn} onChanged={onRefresh} />

{/* Actions */}
<div style={{ display: "flex", gap: 8 }}>
Expand All @@ -1032,22 +1019,12 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre
{t("modelsConfig.cancel")}
</button>
) : (
<>
<button
onClick={handleLogin}
style={{ padding: "5px 14px", background: "var(--accent-strong)", border: "none", borderRadius: 5, color: "var(--on-accent)", cursor: "pointer", fontSize: 12, fontWeight: 600 }}
>
{provider.loggedIn ? t("modelsConfig.relogin") : t("modelsConfig.login")}
</button>
{provider.loggedIn && (
<button
onClick={handleLogout}
style={{ padding: "5px 12px", background: "none", border: "1px solid color-mix(in srgb, var(--status-error) 30%, transparent)", borderRadius: 5, color: "var(--status-error)", cursor: "pointer", fontSize: 12 }}
>
{t("modelsConfig.disconnect")}
</button>
)}
</>
<button
onClick={handleLogin}
style={{ padding: "5px 14px", background: "var(--accent-strong)", border: "none", borderRadius: 5, color: "var(--on-accent)", cursor: "pointer", fontSize: 12, fontWeight: 600 }}
>
{provider.loggedIn ? t("modelsConfig.addAccount") : t("modelsConfig.login")}
</button>
)}
</div>
</div>
Expand Down
119 changes: 103 additions & 16 deletions components/ProviderAccounts.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,135 @@
"use client";

import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { LogOut } from "lucide-react";
import { useI18n } from "@/lib/i18n";
import { summarizeProviderAccounts } from "@/lib/provider-accounts";
import { formatApiError } from "@/lib/i18n/api-error";
import { planForStoredAccount, summarizeProviderAccounts } from "@/lib/provider-accounts";
import { useProviderUsage } from "./AppShell-provider-usage";
import { ConfirmDialog } from "./ui/field";

interface StoredAccount {
credentialId: number;
label: string;
detail: string;
type: "api_key" | "oauth";
}

/**
* Accounts omp manages for one provider (read-only). omp stays responsible for
* credentials, account selection and rotation; this only surfaces what it
* reports, so multi-account setups are discoverable.
* Accounts omp stores for one provider. omp stays responsible for credentials,
* account selection and rotation; this lists its stored credentials
* (`get_logout_accounts`) and removes one through its `logout` RPC. omp
* releases without that RPC fall back to the read-only list `omp usage` reports.
*/
export function ProviderAccounts({ providerId, enabled }: { providerId: string; enabled: boolean }) {
export function ProviderAccounts({ providerId, enabled, onChanged }: { providerId: string; enabled: boolean; onChanged: () => void }) {
const { t } = useI18n();
const { snapshot, loading } = useProviderUsage(enabled ? `provider=${encodeURIComponent(providerId)}` : null);
const accounts = useMemo(
const { snapshot, loading: usageLoading } = useProviderUsage(enabled ? `provider=${encodeURIComponent(providerId)}` : null);
const usageAccounts = useMemo(
() => (snapshot ? summarizeProviderAccounts(snapshot.reports, providerId) : []),
[snapshot, providerId],
);
const [stored, setStored] = useState<{ supported: boolean; accounts: StoredAccount[] } | null>(null);
const [version, setVersion] = useState(0);
const [confirm, setConfirm] = useState<StoredAccount | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
fetch(`/api/auth/accounts/${encodeURIComponent(providerId)}`, { signal: controller.signal })
.then((res) => res.json() as Promise<{ supported?: boolean; accounts?: StoredAccount[]; error?: string }>)
// A failed listing degrades to the usage-based list rather than hiding accounts.
.then((d) => {
if (d.error) setError(d.error);
setStored({ supported: d.supported === true, accounts: d.accounts ?? [] });
})
.catch((e: unknown) => {
if (controller.signal.aborted) return;
setError(e instanceof Error ? e.message : String(e));
setStored({ supported: false, accounts: [] });
});
return () => controller.abort();
}, [enabled, providerId, version]);

if (!enabled || (!loading && accounts.length === 0)) return null;
const disconnect = async (account: StoredAccount) => {
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/auth/accounts/${encodeURIComponent(providerId)}?credentialId=${account.credentialId}`, { method: "DELETE" });
const d = await res.json().catch(() => ({})) as { error?: string; code?: string };
if (!res.ok) throw new Error(d.error || d.code ? formatApiError(d) : `HTTP ${res.status}`);
setConfirm(null);
// Drop the row now; the refetch reconciles with omp's store.
setStored((s) => s && { ...s, accounts: s.accounts.filter((a) => a.credentialId !== account.credentialId) });
setVersion((v) => v + 1);
onChanged();
} catch (e) {
setConfirm(null);
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};

if (!enabled) return null;
const loading = stored === null || (!stored.supported && usageLoading);
const rows = stored?.supported
? stored.accounts.map((a) => ({ key: String(a.credentialId), label: a.label, title: a.detail, plan: planForStoredAccount(a.label, stored.accounts.map((b) => b.label), usageAccounts), account: a }))
: usageAccounts.map((u) => ({ key: u.key, label: u.label ?? t("modelsConfig.accountNumber", { index: u.index ?? 1 }), title: u.label, plan: u.plan, account: null }));
if (!loading && rows.length === 0 && !error) return null;

return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }} data-testid="provider-accounts">
<div style={{ fontSize: 11, fontWeight: 600, color: "var(--text-muted)" }}>
{accounts.length > 1 ? t("modelsConfig.accountsCount", { count: accounts.length }) : t("modelsConfig.accounts")}
{rows.length > 1 ? t("modelsConfig.accountsCount", { count: rows.length }) : t("modelsConfig.accounts")}
</div>
{loading ? (
<div style={{ fontSize: 12, color: "var(--text-dim)" }}>{t("modelsConfig.accountsLoading")}</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 4 }}>
{accounts.map((account) => (
{rows.map((row) => (
<li
key={account.key}
style={{ display: "flex", gap: 8, alignItems: "baseline", fontSize: 12, color: "var(--text)", padding: "4px 8px", background: "var(--bg-subtle)", borderRadius: "var(--radius-control)" }}
key={row.key}
style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 12, color: "var(--text)", padding: "4px 8px", background: "var(--bg-subtle)", borderRadius: "var(--radius-control)" }}
>
<span title={account.label} style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{account.label ?? t("modelsConfig.accountNumber", { index: account.index ?? 1 })}</span>
{account.plan && <span style={{ color: "var(--text-dim)" }}>{account.plan}</span>}
<span title={row.title} style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.label}</span>
{row.plan && <span style={{ color: "var(--text-dim)" }}>{row.plan}</span>}
{row.account?.type === "api_key" && <span style={{ color: "var(--text-dim)" }}>{t("modelsConfig.accountApiKey")}</span>}
{row.account && (
<button
type="button"
onClick={() => setConfirm(row.account)}
disabled={busy}
aria-label={t("modelsConfig.disconnectAccount", { label: row.label })}
title={t("modelsConfig.disconnectAccount", { label: row.label })}
style={{ marginLeft: "auto", flexShrink: 0, display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 8px", background: "none", border: "1px solid color-mix(in srgb, var(--status-error) 30%, transparent)", borderRadius: 5, color: "var(--status-error)", cursor: busy ? "not-allowed" : "pointer", fontSize: 11 }}
>
<LogOut size={11} aria-hidden="true" /> {t("modelsConfig.disconnect")}
</button>
)}
</li>
))}
</ul>
)}
{accounts.length > 1 && (
{error && <p style={{ margin: 0, fontSize: 12, color: "var(--status-error)" }}>{error}</p>}
{rows.length > 1 && (
<p style={{ margin: 0, fontSize: 11, color: "var(--text-dim)", lineHeight: 1.5 }}>{t("modelsConfig.accountsRotationHint")}</p>
)}
{stored && !stored.supported && !error && (
<p style={{ margin: 0, fontSize: 11, color: "var(--text-dim)", lineHeight: 1.5 }}>{t("errors.logout_unsupported")}</p>
)}
<ConfirmDialog
open={confirm !== null}
onOpenChange={(open) => { if (!open && !busy) setConfirm(null); }}
title={t("modelsConfig.disconnectAccountTitle")}
description={confirm ? t("modelsConfig.disconnectAccountBody", { label: confirm.label }) : undefined}
confirmLabel={t("modelsConfig.disconnect")}
cancelLabel={t("modelsConfig.cancel")}
danger
busy={busy}
onConfirm={() => { if (confirm) void disconnect(confirm); }}
/>
</div>
);
}
Loading