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
71 changes: 63 additions & 8 deletions apps/codex-plus-manager/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ import {
normalizeAggregateDefaultRelayId,
} from "./aggregate-members";
import { relayAuthForLiveDraft, shouldBackfillRelayProfileBeforeSwitch } from "./relay-live-files";
import { createRelayApiKey, normalizeRelayApiKeys, relayProfileWithNormalizedApiKeys, type RelayApiKey } from "./provider-api-keys";
import { relayHeadersValidationMessage, serializeRelayHeaders } from "./relay-headers";
import { sessionProviderForProtocol } from "./relay-session";
import { usePopoverLayer } from "./use-popover-layer";
Expand Down Expand Up @@ -421,6 +422,8 @@ export type RelayProfile = {
baseUrl: string;
upstreamBaseUrl: string;
apiKey: string;
apiKeys?: RelayApiKey[];
activeApiKeyId?: string;
protocol: RelayProtocol;
relayMode: RelayMode;
sessionProvider?: RelaySessionProvider;
Expand Down Expand Up @@ -8874,6 +8877,11 @@ function RelayProfileEditor({
const updateDraft = (patch: Partial<RelayProfile>) => {
onProfileChange(applyRelayProfilePatchToFiles(profile, patch, { allowGenerateFiles: isNew }));
};
const normalizedApiKeys = normalizeRelayApiKeys(profile);
const updateApiKeys = (apiKeys: RelayApiKey[], activeApiKeyId = normalizedApiKeys.activeApiKeyId) => {
const selected = apiKeys.find((entry) => entry.id === activeApiKeyId) || apiKeys[0];
updateDraft({ apiKeys, activeApiKeyId: selected?.id || "", apiKey: selected?.apiKey || "" });
};
const selectEditorMode = (mode: RelayEditorMode) => {
if (mode === "official") {
updateDraft({ relayMode: "official", officialMixApiKey: false });
Expand Down Expand Up @@ -9318,13 +9326,58 @@ function RelayProfileEditor({
placeholder={t("填写供应商的 Base URL")}
/>
</Field>
<Field className="relay-field-key" label="API Key">
<Input
type="password"
value={profile.apiKey}
onChange={(event) => updateDraft({ apiKey: event.currentTarget.value })}
placeholder={t("输入供应商的 API Key")}
/>
<Field className="relay-field-key" label="API Keys">
<div className="relay-api-key-list">
{normalizedApiKeys.apiKeys.map((entry) => {
const active = entry.id === normalizedApiKeys.activeApiKeyId;
return (
<div className={`relay-api-key-row ${active ? "active" : ""}`} key={entry.id}>
<button
aria-label={t("设为当前 Key")}
aria-pressed={active}
className="relay-api-key-select"
onClick={() => updateApiKeys(normalizedApiKeys.apiKeys, entry.id)}
title={t("设为当前 Key")}
type="button"
>
<CheckCircle2 className="h-4 w-4" />
</button>
<Input
aria-label={t("Key 名称")}
value={entry.name}
onChange={(event) => updateApiKeys(normalizedApiKeys.apiKeys.map((key) =>
key.id === entry.id ? { ...key, name: event.currentTarget.value } : key))}
/>
<Input
aria-label={tf("{0} 的 API Key", [entry.name])}
type="password"
value={entry.apiKey}
onChange={(event) => updateApiKeys(normalizedApiKeys.apiKeys.map((key) =>
key.id === entry.id ? { ...key, apiKey: event.currentTarget.value } : key))}
placeholder={t("输入供应商的 API Key")}
/>
<Button
aria-label={t("删除 Key")}
disabled={normalizedApiKeys.apiKeys.length === 1}
onClick={() => updateApiKeys(normalizedApiKeys.apiKeys.filter((key) => key.id !== entry.id))}
size="icon"
title={t("删除 Key")}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})}
<Button onClick={() => {
const key = createRelayApiKey(normalizedApiKeys.apiKeys);
updateApiKeys([...normalizedApiKeys.apiKeys, key]);
}} size="sm" type="button" variant="secondary">
<Plus className="h-4 w-4" />
{t("添加 Key")}
</Button>
</div>
</Field>
<Field className="relay-field-protocol" label={t("上游协议")}>
<AppSelect
Expand Down Expand Up @@ -13224,7 +13277,9 @@ function normalizeRelayProfile(profile: RelayProfile, defaultContextSelection =
standardOpenaiProtocol: profile.standardOpenaiProtocol === true,
webSearchHistoryCompat: profile.webSearchHistoryCompat === true,
};
return relayProfileUsesLiveFiles(normalized) ? deriveRelayProfileFromFiles(normalized) : normalized;
const derived = relayProfileUsesLiveFiles(normalized) ? deriveRelayProfileFromFiles(normalized) : normalized;
return derived.noAuth || (derived.relayMode === "official" && !derived.officialMixApiKey)
? derived : relayProfileWithNormalizedApiKeys(derived);
}

function hydrateAggregateRelayProfile(profile: RelayProfile, aggregate: AggregateRelayProfile | undefined): RelayProfile {
Expand Down
5 changes: 5 additions & 0 deletions apps/codex-plus-manager/src/i18n-en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@

// Plain strings: t("中文") -> EN_PLAIN["中文"].
export const EN_PLAIN: Record<string, string> = {
"设为当前 Key": "Select this key",
"Key 名称": "Key name",
"删除 Key": "Delete key",
"添加 Key": "Add key",
"本地服务器状态": "Local server status",
"调试连接状态": "Debug connection status",
"当前配置无需本地服务器": "The current configuration does not require a local server",
Expand Down Expand Up @@ -1444,6 +1448,7 @@ export const EN_PLAIN: Record<string, string> = {

// Interpolated strings: tf("前缀 {0}", [x]) -> EN_TEMPLATE["前缀 {0}"] with {0} filled.
export const EN_TEMPLATE: Record<string, string> = {
"{0} 的 API Key": "API key for {0}",
"{0} 个插件": "{0} plugins",
"{0} / {1} 个插件": "{0} / {1} plugins",
"查看插件 {0} 的详情": "View details for plugin {0}",
Expand Down
31 changes: 31 additions & 0 deletions apps/codex-plus-manager/src/named-key-editor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import ts from "typescript";
import { normalizeRelayApiKeys, relayProfileWithNormalizedApiKeys } from "./provider-api-keys.ts";

test("编辑命名 Key 同步选择、列表和既有 API 字段", async () => {
const source = await readFile(new URL("./App.tsx", import.meta.url), "utf8");
const file = ts.createSourceFile("App.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
let code = "";
const visit = (node: ts.Node) => {
if (ts.isVariableDeclaration(node) && node.name.getText(file) === "updateApiKeys") {
code = node.initializer!.getText(file);
}
ts.forEachChild(node, visit);
};
visit(file);
assert.ok(code);
const profile = { apiKey: "a", apiKeys: [{ id: "a", name: "A", apiKey: "a" }, { id: "b", name: "B", apiKey: "b" }], activeApiKeyId: "a" };
const normalized = normalizeRelayApiKeys(profile);
let patch: unknown;
const compiled = ts.transpileModule(`const update = ${code};`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
const update = new Function("normalizedApiKeys", "updateDraft", `${compiled}; return update;`)(
normalized, (value: unknown) => { patch = value; },
);
update(normalized.apiKeys, "b");
assert.deepEqual(patch, { apiKeys: normalized.apiKeys, activeApiKeyId: "b", apiKey: "b" });
assert.equal(relayProfileWithNormalizedApiKeys({ ...profile, activeApiKeyId: "b" } as never).apiKey, "b");
});
49 changes: 49 additions & 0 deletions apps/codex-plus-manager/src/provider-api-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { normalizeRelayApiKeys, relayProfileWithNormalizedApiKeys } from "./provider-api-keys.ts";
import type { RelayProfile } from "./App.tsx";

test("旧单 Key 自动迁移为默认命名 Key", () => {
assert.deepEqual(normalizeRelayApiKeys({ apiKey: "sk-legacy" }), {
apiKeys: [{ id: "default", name: "默认", apiKey: "sk-legacy" }],
activeApiKeyId: "default",
});
});

test("空配置也提供一个可直接编辑的默认 Key", () => {
assert.deepEqual(normalizeRelayApiKeys({ apiKey: "" }), {
apiKeys: [{ id: "default", name: "默认", apiKey: "" }],
activeApiKeyId: "default",
});
});

test("命名 Key 去重并回退到首个有效选项", () => {
const normalized = normalizeRelayApiKeys({
apiKey: "old",
activeApiKeyId: "missing",
apiKeys: [
{ id: "same", name: "主账号", apiKey: "sk-a" },
{ id: "same", name: "", apiKey: "sk-b" },
],
});
assert.deepEqual(normalized, {
apiKeys: [
{ id: "same", name: "主账号", apiKey: "sk-a" },
{ id: "same-2", name: "Key 2", apiKey: "sk-b" },
],
activeApiKeyId: "same",
});
});

test("当前命名 Key 始终决定运行时 apiKey", () => {
const profile = {
apiKey: "stale",
activeApiKeyId: "backup",
apiKeys: [
{ id: "main", name: "主账号", apiKey: "sk-main" },
{ id: "backup", name: "备用", apiKey: "sk-backup" },
],
} as RelayProfile;
assert.equal(relayProfileWithNormalizedApiKeys(profile).apiKey, "sk-backup");
});
64 changes: 64 additions & 0 deletions apps/codex-plus-manager/src/provider-api-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { RelayProfile } from "./App";

export type RelayApiKey = { id: string; name: string; apiKey: string };

const DEFAULT_KEY_ID = "default";

function uniqueKeyId(base: string, used: Set<string>): string {
let id = base;
let suffix = 2;
while (used.has(id)) {
id = `${base}-${suffix}`;
suffix += 1;
}
return id;
}

export function normalizeRelayApiKeys(profile: Pick<RelayProfile, "apiKey" | "apiKeys" | "activeApiKeyId">): {
apiKeys: RelayApiKey[];
activeApiKeyId: string;
} {
const used = new Set<string>();
const apiKeys = (Array.isArray(profile.apiKeys) ? profile.apiKeys : []).map((entry, index) => {
const requestedId = String(entry?.id || "").trim() || `key-${index + 1}`;
const id = uniqueKeyId(requestedId, used);
used.add(id);
return {
id,
name: String(entry?.name || "").trim() || `Key ${index + 1}`,
apiKey: String(entry?.apiKey || ""),
};
});
if (!apiKeys.length) {
apiKeys.push({ id: DEFAULT_KEY_ID, name: "默认", apiKey: profile.apiKey || "" });
}
const requestedActiveId = String(profile.activeApiKeyId || "").trim();
const activeApiKeyId = apiKeys.some((entry) => entry.id === requestedActiveId)
? requestedActiveId
: apiKeys[0]?.id || "";
return { apiKeys, activeApiKeyId };
}

export function selectedRelayApiKey(profile: Pick<RelayProfile, "apiKey" | "apiKeys" | "activeApiKeyId">): RelayApiKey | null {
const normalized = normalizeRelayApiKeys(profile);
return normalized.apiKeys.find((entry) => entry.id === normalized.activeApiKeyId) || null;
}

export function relayProfileWithNormalizedApiKeys(profile: RelayProfile): RelayProfile {
const normalized = normalizeRelayApiKeys(profile);
const selected = normalized.apiKeys.find((entry) => entry.id === normalized.activeApiKeyId);
return {
...profile,
...normalized,
apiKey: selected?.apiKey ?? profile.apiKey,
};
}

export function createRelayApiKey(apiKeys: RelayApiKey[]): RelayApiKey {
const used = new Set(apiKeys.map((entry) => entry.id));
return {
id: uniqueKeyId(`key-${Date.now().toString(36)}`, used),
name: `Key ${apiKeys.length + 1}`,
apiKey: "",
};
}
62 changes: 62 additions & 0 deletions apps/codex-plus-manager/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1877,6 +1877,60 @@ body {
grid-column: auto;
}

.relay-field-key {
grid-column: 1 / -1;
}

.relay-api-key-list {
display: grid;
gap: 8px;
}

.relay-api-key-row {
display: grid;
grid-template-columns: 36px minmax(120px, 0.55fr) minmax(220px, 1.45fr) 36px;
align-items: center;
gap: 8px;
min-width: 0;
padding: 6px;
border: 1px solid hsl(var(--border) / 0.8);
border-radius: var(--radius-md);
background: hsl(var(--surface-raised) / 0.35);
}

.relay-api-key-row.active {
border-color: hsl(var(--brand-accent) / 0.55);
background: hsl(var(--brand-accent) / 0.07);
}

.relay-api-key-select {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: hsl(var(--muted-foreground));
cursor: pointer;
}

.relay-api-key-select:hover,
.relay-api-key-select:focus-visible {
background: hsl(var(--surface-raised));
color: hsl(var(--foreground));
outline: none;
}

.relay-api-key-row.active .relay-api-key-select {
color: hsl(var(--brand-accent));
}

.relay-api-key-add {
justify-self: start;
}

/* 折叠式高级区:整块描边,标题行自带箭头,收起时用一行提示说明里面有什么 */
.relay-advanced-block {
grid-column: 1 / -1;
Expand Down Expand Up @@ -7003,6 +7057,14 @@ select {
flex-direction: column;
}

.relay-api-key-row {
grid-template-columns: 36px minmax(0, 1fr) 36px;
}

.relay-api-key-row > input[type="password"] {
grid-column: 2 / 4;
}

.relay-model-list-tools {
justify-content: flex-start;
}
Expand Down
6 changes: 6 additions & 0 deletions crates/codex-plus-core/src/ccs_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ pub fn relay_profile_from_ccs(
base_url: provider.base_url.clone(),
upstream_base_url: provider.base_url.clone(),
api_key: provider.api_key.clone(),
api_keys: vec![crate::settings::RelayApiKey {
id: "default".to_string(),
name: "默认".to_string(),
api_key: provider.api_key.clone(),
}],
active_api_key_id: "default".to_string(),
protocol: provider.protocol,
relay_mode: RelayMode::PureApi,
official_mix_api_key: false,
Expand Down
2 changes: 1 addition & 1 deletion crates/codex-plus-core/src/model_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -799,7 +799,7 @@ pub async fn fetch_relay_profile_model_ids(
} else {
profile.upstream_base_url.trim().to_string()
},
api_key: profile.api_key.trim().to_string(),
api_key: crate::relay_config::relay_profile_api_key(profile),
headers: profile.custom_headers.clone(),
};
if source.base_url.is_empty() {
Expand Down
6 changes: 6 additions & 0 deletions crates/codex-plus-core/src/provider_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ fn relay_profile_from_request(
base_url: request.base_url.clone(),
upstream_base_url: request.base_url.clone(),
api_key: request.api_key.clone(),
api_keys: vec![crate::settings::RelayApiKey {
id: "default".to_string(),
name: "默认".to_string(),
api_key: request.api_key.clone(),
}],
active_api_key_id: "default".to_string(),
protocol: relay_protocol(&request.wire_api),
relay_mode: relay_mode(&request.relay_mode),
official_mix_api_key: false,
Expand Down
Loading