diff --git a/AGENTS.md b/AGENTS.md index c0e46e705..c66fa590d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d1739611..e361b0864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `: 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. diff --git a/app/api/auth/accounts/[provider]/route.ts b/app/api/auth/accounts/[provider]/route.ts new file mode 100644 index 000000000..c297aa8a0 --- /dev/null +++ b/app/api/auth/accounts/[provider]/route.ts @@ -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; + 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 }); + } +} diff --git a/app/api/auth/logout/[provider]/route.ts b/app/api/auth/logout/[provider]/route.ts deleted file mode 100644 index 318e33d1c..000000000 --- a/app/api/auth/logout/[provider]/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NextResponse } from "next/server"; - -export const dynamic = "force-dynamic"; - -// omp has no logout RPC command (modes/rpc/rpc-types.ts) and no non-interactive -// CLI logout subcommand (cli-commands.ts) — credential removal only exists as -// the interactive /logout selector in omp's own TUI, backed by the SQLite -// credential store omp-web must never write. -export async function POST( - _req: Request, - { params }: { params: Promise<{ provider: string }> } -) { - const { provider } = await params; - return NextResponse.json( - { - error: - `omp-web cannot disconnect "${provider}": omp exposes no logout command outside its own UI. ` + - "Run `omp` in a terminal and use /logout to remove the credential.", - code: "logout_unsupported", - }, - { status: 501 }, - ); -} diff --git a/components/ModelsConfig.tsx b/components/ModelsConfig.tsx index 17f2e44bd..368da5feb 100644 --- a/components/ModelsConfig.tsx +++ b/components/ModelsConfig.tsx @@ -784,6 +784,8 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre const { t, tn } = useI18n(); const [loginState, setLoginState] = useState({ 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(null); const inputRef = useRef(null); @@ -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(); @@ -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") }); @@ -1020,7 +1007,7 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre )} - + {/* Actions */}
@@ -1032,22 +1019,12 @@ function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefre {t("modelsConfig.cancel")} ) : ( - <> - - {provider.loggedIn && ( - - )} - + )}
diff --git a/components/ProviderAccounts.tsx b/components/ProviderAccounts.tsx index a63a4bcbd..db82b9628 100644 --- a/components/ProviderAccounts.tsx +++ b/components/ProviderAccounts.tsx @@ -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(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 (
- {accounts.length > 1 ? t("modelsConfig.accountsCount", { count: accounts.length }) : t("modelsConfig.accounts")} + {rows.length > 1 ? t("modelsConfig.accountsCount", { count: rows.length }) : t("modelsConfig.accounts")}
{loading ? (
{t("modelsConfig.accountsLoading")}
) : (
    - {accounts.map((account) => ( + {rows.map((row) => (
  • - {account.label ?? t("modelsConfig.accountNumber", { index: account.index ?? 1 })} - {account.plan && {account.plan}} + {row.label} + {row.plan && {row.plan}} + {row.account?.type === "api_key" && {t("modelsConfig.accountApiKey")}} + {row.account && ( + + )}
  • ))}
)} - {accounts.length > 1 && ( + {error &&

{error}

} + {rows.length > 1 && (

{t("modelsConfig.accountsRotationHint")}

)} + {stored && !stored.supported && !error && ( +

{t("errors.logout_unsupported")}

+ )} + { 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); }} + />
); } diff --git a/lib/auth-accounts-route.test.mjs b/lib/auth-accounts-route.test.mjs new file mode 100644 index 000000000..3273110e9 --- /dev/null +++ b/lib/auth-accounts-route.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test, { beforeEach } from "node:test"; +import { fileURLToPath } from "node:url"; +import { createJiti } from "jiti"; + +// Real /api/auth/accounts/[provider] handlers; the shared utility omp process +// is replaced by a fake that answers like omp does (or like an older omp). +const jiti = createJiti(import.meta.url, { + tryNative: false, + alias: { "@/": fileURLToPath(new URL("../", import.meta.url)) }, +}); +const route = await jiti.import("../app/api/auth/accounts/[provider]/route.ts"); + +let reply; +const sent = []; +beforeEach(() => { + sent.length = 0; + globalThis.__ompUtilityRpcState = { + proc: { + isAlive: true, + sendCommand: async (command) => { sent.push(command); return reply(command); }, + dispose: async () => {}, + }, + idleTimer: null, + queue: Promise.resolve(), + }; +}); + +const params = { params: Promise.resolve({ provider: "anthropic" }) }; +const url = "http://localhost/api/auth/accounts/anthropic"; +const unknownCommand = (command) => { throw new Error(`Unknown command: ${command.type}`); }; + +test("GET lists only well-formed accounts and only their known fields", async () => { + reply = () => ({ + accounts: [ + { credentialId: 7, provider: "anthropic", label: "a@x.test", detail: "oauth #7", type: "oauth", active: true, token: "secret" }, + { credentialId: "8", label: "bad id", detail: "", type: "oauth", active: false }, + ], + }); + const body = await (await route.GET(new Request(url), params)).json(); + assert.deepEqual(sent, [{ type: "get_logout_accounts", providerId: "anthropic" }]); + assert.deepEqual(body, { + supported: true, + accounts: [{ credentialId: 7, label: "a@x.test", detail: "oauth #7", type: "oauth" }], + }); +}); + +test("older omp without the RPC is reported as unsupported, not as an error", async () => { + reply = unknownCommand; + const list = await route.GET(new Request(url), params); + assert.equal(list.status, 200); + assert.deepEqual(await list.json(), { supported: false, accounts: [] }); + + const del = await route.DELETE(new Request(`${url}?credentialId=7`, { method: "DELETE" }), params); + assert.equal(del.status, 501); + assert.equal((await del.json()).code, "logout_unsupported"); +}); + +test("DELETE removes exactly the requested credential", async () => { + reply = () => ({}); + const res = await route.DELETE(new Request(`${url}?credentialId=7`, { method: "DELETE" }), params); + assert.equal(res.status, 200); + assert.deepEqual(sent, [{ type: "logout", providerId: "anthropic", credentialId: 7 }]); +}); + +test("DELETE rejects ids omp could misread without contacting it", async () => { + reply = () => ({}); + for (const id of ["", "abc", "-1", "1.5", "9007199254740993"]) { + const res = await route.DELETE(new Request(`${url}?credentialId=${encodeURIComponent(id)}`, { method: "DELETE" }), params); + assert.equal(res.status, 400, `credentialId=${JSON.stringify(id)}`); + } + assert.deepEqual(sent, []); +}); + +test("other omp failures surface as server errors", async () => { + reply = () => { throw new Error("Credential 7 is not stored for anthropic"); }; + const res = await route.DELETE(new Request(`${url}?credentialId=7`, { method: "DELETE" }), params); + assert.equal(res.status, 500); + assert.match((await res.json()).error, /not stored/); +}); diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index 074c3e095..aa0e4b787 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -655,7 +655,7 @@ "errors.login_no_pending": "No pending login for this token", "errors.login_token_code_required": "A login token and authorization code are required", "errors.login_token_mismatch": "The login token does not match the provider", - "errors.logout_unsupported": "omp-web cannot disconnect this provider: omp exposes no logout command outside its own UI. Run `omp` in a terminal and use /logout to remove the credential.", + "errors.logout_unsupported": "omp-web cannot disconnect accounts with this omp version. Update omp, or run `omp` in a terminal and use /logout.", "errors.model_id_required": "A model ID is required", "errors.model_required": "A model config is required", "errors.model_test_unresolved": "The model did not resolve — check the API key and provider config", @@ -864,7 +864,7 @@ "modelsConfig.addFromCatalog": "from catalog", "modelsConfig.addModel": "+ model", "modelsConfig.addProvider": "+ Add provider", - "modelsConfig.alreadyConnected": "Already connected. You can re-login or disconnect.", + "modelsConfig.alreadyConnected": "Connected. Signing in with another account adds it; signing in with a connected account refreshes it.", "modelsConfig.api": "API", "modelsConfig.apiKey": "API Key", "modelsConfig.apiKeyHint": "Prefix with `!` to run a shell command, or use an env var name", @@ -940,7 +940,7 @@ "modelsConfig.providerNotConfigured": "{name} is not configured.", "modelsConfig.reasoningThinking": "Reasoning / thinking", "modelsConfig.reload": "Reload", - "modelsConfig.relogin": "Re-login", + "modelsConfig.addAccount": "Add account", "modelsConfig.remove": "Remove", "modelsConfig.rename": "Rename", "modelsConfig.resetToAuto": "reset to auto", @@ -1641,5 +1641,9 @@ "modelsConfig.accountsCount": "{count} accounts", "modelsConfig.accountsLoading": "Loading accounts…", "modelsConfig.accountNumber": "Account {index}", - "modelsConfig.accountsRotationHint": "omp manages account selection and rotation." + "modelsConfig.accountsRotationHint": "omp manages account selection and rotation.", + "modelsConfig.accountApiKey": "API key", + "modelsConfig.disconnectAccount": "Disconnect {label}", + "modelsConfig.disconnectAccountTitle": "Disconnect account?", + "modelsConfig.disconnectAccountBody": "omp will delete the stored credential for {label}. Other accounts for this provider stay connected." } diff --git a/lib/i18n/locales/ja.json b/lib/i18n/locales/ja.json index 8408e7047..da405cc5c 100644 --- a/lib/i18n/locales/ja.json +++ b/lib/i18n/locales/ja.json @@ -642,7 +642,7 @@ "errors.login_no_pending": "このトークンに対応する保留中のログインがありません", "errors.login_token_code_required": "ログイントークンと認可コードが必要です", "errors.login_token_mismatch": "ログイントークンがプロバイダーと一致しません", - "errors.logout_unsupported": "omp-web ではこのプロバイダーを切断できません。omp は自身の UI 以外にログアウトコマンドを提供していません。ターミナルで `omp` を実行し、/logout で資格情報を削除してください。", + "errors.logout_unsupported": "この omp のバージョンでは omp-web からアカウントを切断できません。omp を更新するか、ターミナルで `omp` を実行して /logout を使ってください。", "errors.model_id_required": "モデルIDが必要です", "errors.model_required": "モデル設定が必要です", "errors.model_test_unresolved": "モデルを解決できませんでした。APIキーとプロバイダー設定を確認してください", @@ -830,7 +830,7 @@ "modelsConfig.addFromCatalog": "カタログから", "modelsConfig.addModel": "+ モデル", "modelsConfig.addProvider": "+ プロバイダーを追加", - "modelsConfig.alreadyConnected": "既に接続されています。再ログインまたは切断ができます。", + "modelsConfig.alreadyConnected": "接続済みです。別のアカウントでサインインすると追加され、接続済みのアカウントでサインインすると更新されます。", "modelsConfig.api": "API", "modelsConfig.apiKey": "APIキー", "modelsConfig.apiKeyHint": "先頭に `!` を付けるとシェルコマンドを実行します。環境変数名も指定できます", @@ -906,7 +906,7 @@ "modelsConfig.providerNotConfigured": "{name} は設定されていません。", "modelsConfig.reasoningThinking": "推論 / 思考", "modelsConfig.reload": "再読み込み", - "modelsConfig.relogin": "再ログイン", + "modelsConfig.addAccount": "アカウントを追加", "modelsConfig.remove": "削除", "modelsConfig.rename": "名前を変更", "modelsConfig.resetToAuto": "自動にリセット", @@ -1604,5 +1604,9 @@ "modelsConfig.accountsCount": "{count} 件のアカウント", "modelsConfig.accountsLoading": "アカウントを読み込み中…", "modelsConfig.accountNumber": "アカウント {index}", - "modelsConfig.accountsRotationHint": "アカウントの選択とローテーションは omp が管理します。" + "modelsConfig.accountsRotationHint": "アカウントの選択とローテーションは omp が管理します。", + "modelsConfig.accountApiKey": "APIキー", + "modelsConfig.disconnectAccount": "{label} を切断", + "modelsConfig.disconnectAccountTitle": "アカウントを切断しますか?", + "modelsConfig.disconnectAccountBody": "omp は {label} の保存済み資格情報を削除します。このプロバイダーの他のアカウントは接続されたままです。" } diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index b6de27b5f..321bbb6f2 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -642,7 +642,7 @@ "errors.login_no_pending": "此 token 没有待处理的登录", "errors.login_token_code_required": "需要提供登录 token 和授权码", "errors.login_token_mismatch": "登录 token 与提供商不匹配", - "errors.logout_unsupported": "omp-web 无法断开此提供商的连接:omp 未在其自身 UI 之外提供登出命令。请在终端运行 `omp` 并使用 /logout 移除凭据。", + "errors.logout_unsupported": "此版本的 omp 无法从 omp-web 断开账户。请更新 omp,或在终端运行 `omp` 并使用 /logout。", "errors.model_id_required": "需要提供模型 ID", "errors.model_required": "需要提供模型配置", "errors.model_test_unresolved": "模型未能解析——请检查 API 密钥和提供商配置", @@ -830,7 +830,7 @@ "modelsConfig.addFromCatalog": "从目录添加", "modelsConfig.addModel": "+ 模型", "modelsConfig.addProvider": "+ 添加提供商", - "modelsConfig.alreadyConnected": "已连接。你可以重新登录或断开连接。", + "modelsConfig.alreadyConnected": "已连接。使用其他账户登录会添加该账户;使用已连接的账户登录会刷新其凭据。", "modelsConfig.api": "API", "modelsConfig.apiKey": "API 密钥", "modelsConfig.apiKeyHint": "以 `!` 开头可运行 shell 命令,也可使用环境变量名", @@ -906,7 +906,7 @@ "modelsConfig.providerNotConfigured": "{name} 未配置。", "modelsConfig.reasoningThinking": "推理 / 思考", "modelsConfig.reload": "重新加载", - "modelsConfig.relogin": "重新登录", + "modelsConfig.addAccount": "添加账户", "modelsConfig.remove": "移除", "modelsConfig.rename": "重命名", "modelsConfig.resetToAuto": "重置为自动", @@ -1604,5 +1604,9 @@ "modelsConfig.accountsCount": "{count} 个账户", "modelsConfig.accountsLoading": "正在加载账户…", "modelsConfig.accountNumber": "账户 {index}", - "modelsConfig.accountsRotationHint": "账户的选择和轮换由 omp 管理。" + "modelsConfig.accountsRotationHint": "账户的选择和轮换由 omp 管理。", + "modelsConfig.accountApiKey": "API 密钥", + "modelsConfig.disconnectAccount": "断开 {label}", + "modelsConfig.disconnectAccountTitle": "断开账户?", + "modelsConfig.disconnectAccountBody": "omp 将删除 {label} 的已存储凭据。此提供商的其他账户保持连接。" } diff --git a/lib/provider-accounts.test.mjs b/lib/provider-accounts.test.mjs index 576952248..37c095a80 100644 --- a/lib/provider-accounts.test.mjs +++ b/lib/provider-accounts.test.mjs @@ -3,7 +3,7 @@ import test from "node:test"; import { createJiti } from "jiti"; const jiti = createJiti(import.meta.url); -const { summarizeProviderAccounts } = await jiti.import("./provider-accounts.ts"); +const { summarizeProviderAccounts, planForStoredAccount } = await jiti.import("./provider-accounts.ts"); test("distinct accounts are listed once even when usage is split per model/tier", () => { const reports = [ @@ -27,3 +27,14 @@ test("accounts without a label are told apart by their position", () => { assert.deepEqual(summarizeProviderAccounts(reports, "p").map((a) => a.index), [1, 2]); assert.deepEqual(summarizeProviderAccounts(reports, "none"), []); }); + +test("a usage plan is shown only for the one stored account its email identifies", () => { + const usage = [{ key: "a@x.test", label: "a@x.test", plan: "max" }, { key: "b@x.test", label: "b@x.test", plan: "pro" }]; + const single = ["a@x.test (Org A)", "b@x.test"]; + assert.equal(planForStoredAccount("a@x.test (Org A)", single, usage), "max"); + assert.equal(planForStoredAccount("b@x.test", single, usage), "pro"); + // One email in two orgs: the bare-email usage row cannot tell them apart. + const shared = ["a@x.test (Org A)", "a@x.test (Org B)"]; + assert.equal(planForStoredAccount("a@x.test (Org A)", shared, usage), undefined); + assert.equal(planForStoredAccount("a@x.test.evil", ["a@x.test.evil"], usage), undefined); +}); diff --git a/lib/provider-accounts.ts b/lib/provider-accounts.ts index be25c7d10..802e6f521 100644 --- a/lib/provider-accounts.ts +++ b/lib/provider-accounts.ts @@ -33,3 +33,14 @@ export function summarizeProviderAccounts(reports: readonly ProviderUsageReport[ } return [...accounts.values()]; } + +/** + * The usage plan for one of omp's stored-account labels. omp suffixes the org + * (`email (org)`) while usage reports the bare email, so a plan is returned + * only when its email identifies exactly one stored account. + */ +export function planForStoredAccount(label: string, storedLabels: readonly string[], usage: readonly ProviderAccountSummary[]): string | undefined { + const sameEmail = (stored: string, email: string | undefined) => !!email && (stored === email || stored.startsWith(`${email} (`)); + const match = usage.find((u) => sameEmail(label, u.label)); + return match && storedLabels.filter((stored) => sameEmail(stored, match.label)).length === 1 ? match.plan : undefined; +}