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
107 changes: 107 additions & 0 deletions apps/gateway/src/server.oauth-test.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import {
createRuntimeMemoryCoordinator,
createSqliteDb,
DEFAULT_OPENAI_CODEX_CLIENT_VERSION,
encryptSecret,
hashKey,
SqliteKeyStore,
SqliteOAuthTokenStore,
} from "@helm/core";
import { expect, it, vi } from "vitest";
import { loadBundledCodexModels } from "./oauth/codex-bundled-models.js";
import { buildServer, type ServerHandle } from "./server.js";

it.each([
DEFAULT_OPENAI_CODEX_CLIENT_VERSION,
"0.160.1",
])("uses the account catalog and configured Codex version %s in the admin connectivity test", async (version) => {
const directory = await mkdtemp(join(tmpdir(), "helm-oauth-test-"));
const db = createSqliteDb(join(directory, "helm.db"));
const encKey = Buffer.alloc(32, 11);
let server: ServerHandle | undefined;
try {
await new SqliteKeyStore(db).createKey({
keyId: "test-root",
hash: hashKey("synthetic-root-key"),
prefix: "helm_test",
accountId: "test",
role: "root",
});
await new SqliteOAuthTokenStore(db).upsert({
providerId: "openai-codex",
account: "test-account",
accessEnc: encryptSecret("opaque-test-access", encKey),
refreshEnc: encryptSecret("test-refresh", encKey),
expiresAt: 9_999_999_999_999,
meta: JSON.stringify({ accountId: "test-workspace", isFedramp: true }),
updatedAt: 1,
});
vi.stubEnv("HELM_DATA_DIR", directory);
vi.stubEnv("HELM_SIGNALS_DISABLED", "1");
vi.stubEnv("HELM_OAUTH_ENC_KEY", encKey.toString("base64"));
vi.stubEnv("HELM_OPENAI_CODEX_CLIENT_VERSION", version);
vi.stubEnv("HELM_ADMIN_USER", "test-admin");
vi.stubEnv("HELM_ADMIN_PASSWORD", "test-password");
const model = loadBundledCodexModels().find((entry) => entry.slug === "gpt-6.1-sol");
if (!model) throw new Error("Missing bundled GPT-6.1 Sol");
const requests: Array<{ headers: Headers; body: Record<string, unknown> }> = [];
vi.stubGlobal(
"fetch",
vi.fn<typeof fetch>(async (input, init) => {
if (String(input).includes("/models?")) {
return Response.json({ models: [{ ...model, use_responses_lite: true }] });
}
if (!String(input).endsWith("/responses")) throw new Error("Unexpected network request");
requests.push({
headers: new Headers(init?.headers),
body: JSON.parse(String(init?.body)),
});
return new Response(
[
{ type: "response.output_text.delta", delta: "Hello!" },
{ type: "response.completed", response: { id: "test-response", output: [] } },
]
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
.join(""),
{ headers: { "Content-Type": "text/event-stream" } },
);
}),
);
server = await buildServer({
configDir: resolve("config"),
memoryCoordinator: createRuntimeMemoryCoordinator({
capacityBytes: () => Number.MAX_SAFE_INTEGER,
}),
resourcePressure: { shouldRun: async () => false, shouldRunHeavy: async () => false },
logger: { log: () => {} },
});
const response = await server.app.request("/admin/api/oauth/openai-codex/test", {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from("test-admin:test-password").toString("base64")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ account: "test-account", model: "gpt-6.1-sol" }),
});
expect(response.status).toBe(200);
expect(await response.text()).toContain('"text":"Hello!"');
expect(requests).toHaveLength(1);
expect(requests[0]?.headers.get("version")).toBe(version);
expect(requests[0]?.headers.get("user-agent")).toContain(`codex_cli_rs/${version}`);
expect(requests[0]?.headers.get("chatgpt-account-id")).toBe("test-workspace");
expect(requests[0]?.headers.get("X-OpenAI-Fedramp")).toBe("true");
expect(requests[0]?.headers.get("x-openai-internal-codex-responses-lite")).toBe("true");
expect(requests[0]?.body).toMatchObject({ model: "gpt-6.1-sol", stream: true, store: false });
expect(requests[0]?.body).not.toHaveProperty("max_output_tokens");
expect(db.$sqlite.prepare("SELECT COUNT(*) AS n FROM telemetry").get()).toEqual({ n: 0 });
} finally {
await server?.dispose?.();
db.$sqlite.close();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
await rm(directory, { recursive: true, force: true });
}
});
51 changes: 41 additions & 10 deletions apps/gateway/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,10 @@ interface LoadedCodexAccountCatalog {

async function loadCodexAccountCatalog(input: {
account: string;
tokenManager: ReturnType<typeof createTokenManager>;
tokenManager: Pick<
ReturnType<typeof createTokenManager>,
"getAuthHeader" | "currentMetadata" | "invalidate"
>;
proxyFetch?: typeof globalThis.fetch;
clientVersion: string;
catalog: CodexModelCatalog;
Expand Down Expand Up @@ -879,17 +882,17 @@ export interface OAuthQuotaSeed {
// no credential can be built (fail-open). The raw client carries NO circuit breaker
// (that lives in the executor layer), so a one-off test client is fully isolated from
// the live pool's breaker/telemetry state.
function buildOAuthAccountClient(
async function buildOAuthAccountClient(
providerId: string,
account: string,
oauthCtx: OAuthRuntimeCtx,
proxy: ProxyConfig | undefined,
fastMode: boolean,
base: { baseUrl: string; timeoutMs: number },
onResponseMeta?: (headers: Headers) => void,
codexRuntime?: CodexAccountRuntime,
codexRuntime?: CodexAccountRuntime | CodexOAuthRuntime,
xaiRuntime?: XaiAccountRuntime,
): ProviderClient | null {
): Promise<ProviderClient | null> {
const spec = ROUTABLE_OAUTH[providerId];
if (!spec) return null;
const accountConfig = {
Expand All @@ -904,6 +907,28 @@ function buildOAuthAccountClient(
} as unknown as ProviderConfigShared;
const cred = buildCredential(accountConfig, oauthCtx, proxy, base.timeoutMs);
if (!cred) return null;
let accountCodexRuntime: CodexAccountRuntime | undefined;
if (providerId === "openai-codex" && codexRuntime && "getAuthHeader" in cred) {
accountCodexRuntime =
"key" in codexRuntime
? codexRuntime
: (
await loadCodexAccountCatalog({
account,
tokenManager: {
getAuthHeader: cred.getAuthHeader,
currentMetadata: cred.currentMetadata,
invalidate: cred.onUnauthorized,
},
proxyFetch: proxy ? makeProxyFetch(proxy) : undefined,
clientVersion: codexRuntime.clientVersion ?? DEFAULT_OPENAI_CODEX_CLIENT_VERSION,
catalog: codexRuntime.catalog,
runInBackground: codexRuntime.runInBackground,
onCatalogChanged: codexRuntime.onCatalogChanged,
responsesWebSocketConnector: codexRuntime.responsesWebSocketConnector,
})
)?.runtime;
}
// Stable per-account anti-ban identity (never rotates): Anthropic gets a
// metadata.user_id; Codex a stable session_id plus its own installation id (the
// client's is machine-wide, shared by every account on that install). All
Expand All @@ -925,7 +950,7 @@ function buildOAuthAccountClient(
identity,
onResponseMeta,
fastMode,
codexRuntime,
accountCodexRuntime,
xaiRuntime,
);
}
Expand Down Expand Up @@ -1277,7 +1302,7 @@ export async function synthesizeOAuthProviders(
// Per-account executor client: type + oauth preset + base, threaded with the
// egress proxy + stable anti-ban identity. Extracted to buildOAuthAccountClient
// so the admin connectivity tester binds IDENTICALLY (it shares this builder).
const client = buildOAuthAccountClient(
const client = await buildOAuthAccountClient(
providerId,
account,
oauthCtx,
Expand Down Expand Up @@ -4015,10 +4040,16 @@ export async function buildServer(
acctSettings,
);
const fastMode = getAccountSettings(acctSettings, providerId, account).fastMode === true;
return buildOAuthAccountClient(providerId, account, ctx, proxy, fastMode, {
baseUrl: synthBaseUrl,
timeoutMs,
});
return buildOAuthAccountClient(
providerId,
account,
ctx,
proxy,
fastMode,
{ baseUrl: synthBaseUrl, timeoutMs },
undefined,
codexRuntime,
);
},
});
}
Expand Down
18 changes: 8 additions & 10 deletions implementation-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@

---

## 2026-09-30 · Codex 连通性测试复用账号运行配置(docs/05、11)

- **根因**:管理测试只传账号凭证,遗漏 Codex 运行配置,导致客户端版本回退为 `0.0.0`,同时失去账号目录的模型参数与持久身份;正常路由已正确加载这些配置。
- **修复**:共享账号客户端构建器复用现有的账号目录加载逻辑;测试沿用运行时客户端版本、代理、模型元数据与账号身份,仍固定指定账号,不进入模型回退或写入请求遥测。
- **验收边界**:真实管理端点的合成回归覆盖默认版本、自定义版本、账号身份及 Responses Lite;目录不代表账号已获上游授权,真实上游拒绝仍原样展示。

## 2026-09-30 · Codex 账号详情只展示周额度周期(docs/11)

- **决定**:详情页复用已有的 Codex 账号周额度选择器,按实际时长识别周窗口,不再把 `primary` 固定当作周限;当前用量、历史、已用比例与倒计时统一使用周窗口,隐藏 5h 周期切换。
Expand Down Expand Up @@ -71,18 +77,10 @@
- **验证方式**:本机起 admin/portal 开发服务器,只读代理到线上(非 GET 一律本地 403),在 1440 和 2560 两种视口下截图逐页走查;admin 单测 834/834,svelte-check 0 错误,admin e2e 14/14(真实 gateway + adapter-static 构建)。
- **TODO(后续 PR)**:方案文档第 6 期"配置 UI 化"——把运行时调优类配置(超时、memory worker、signal feedback 等)并入 DB runtime settings;providers/model-aliases/pricing/capabilities 走 YAML 回写并做跨文件引用校验;引导与密钥类配置在 UI 上只读展示。

## 2026-09-26 · Self-Service Portal UI 审计整改(docs/12)

- **背景**:对 portal(key 持有者自助门户)做了一轮 UI/信息密度审计,修复 6 项:Overview 增加「按模型」表格与≥7d 的「每日用量」表;Requests 列表改为 ↑input/↓output+cached 分列显示、延迟按秒显示;请求详情页补齐请求时间、requested vs served model、reasoning effort、TTFT/TPS/生成耗时、fallback 尝试;Account 页三张卡片在宽屏并排(页面铺满、卡内两列 `<dl>`,标签与值不会被拉开)+ 用量/限额进度条;内容不限宽(见上一条),顶部导航直接放 LocaleSwitcher、新增 Account 导航项。
- **fallback 尝试展示边界(R7,docs/12 §8)**:详情页新增「Fallback attempts」区块,逐条只显示 outcome(success/timeout/rate_limited/circuit_open/skipped/error)+ latency_ms,**绝不**显示 provider、内部 alias 或 wire model——这些是供应链细节(CLAUDE.md 原则 6),且 `toPortalDecisionView` 的白名单投影本就没有透出这些字段。测试 `request-detail-parity.test.ts` 用 `not.toContain("attempt.provider")` / `not.toContain("attempt.alias")` 固化这条边界,防止未来有人为了"更详细"而误加。
- **删除 CostBreakdown.svelte(偏离字面任务措辞的决定)**:原任务描述是"修复 Cost 卡片,隐藏空行",但检查 `CostBreakdownSchema`(`packages/shared/src/decision/schema.ts`)后发现 `routing_usd`/`eval_usd` 对 portal key holder 永远是 null(后端从不为 portal 填充这两项),补丁式"隐藏空行"只会剩一个多余的容器包着一个数字。按 CLAUDE.md「优先复用/删除过时路径而非加兼容层」的原则,直接删除该组件,详情页改为渲染 `detail.cost_usd` 单一 Total。测试同步固化为 `not.toContain("CostBreakdown")` + `cost-total` testid。
- **Overview 「按模型」表 / 「每日用量」表未引入新字段**:两者都复用既有 `GET /portal/api/usage/stats` 返回的 `by_model` 和 `series`(未新增/修改后端 schema),因此未新增安全边界测试——现有的 key 隔离测试(`apps/gateway/src/routes/portal/index.test.ts` 的 R5 write-force 断言)已覆盖这条数据源。
- **可视化走查发现并修复的布局坑**:Overview 页最初把「按模型」表放进了甜甜圈图所在的 `lg:col-span-1` 卡片(3 栏网格的 1/3 宽)——5 列(Model/Requests/Tokens/Cost/Share)在该宽度下右侧 Cost/Share 列被裁切。修复:表格移出,改为图表网格下方独立的 `lg:col-span-3` 全宽 `<section class="card mt-4">`(与「每日用量」表同一模式),甜甜圈卡片只保留图 + 精简色块图例。用真实生产数据(只读代理到 helm.easymeta.au)截图两种尺寸(1440×812、2560×1440)验证修复前后对比确认。
- **真实生产 box 验证发现请求详情页会报错(非回归,是预期的"字段未上线"场景)→ 已补防御**:本次给 `PortalDecisionView` 新增的 `attempts`/`tps`/`ttfb_ms`/`requested_reasoning_effort`/`reasoning_effort` 字段还没有部署到生产 box,代理到真实数据时旧响应缺这些字段(JSON.parse 后是 `undefined` 而非 `null`),`detail.attempts.length` 会触发 `Cannot read properties of undefined`。这不是代码 bug,但客户端理应对"字段未部署"宽容降级:新增 `apps/portal/src/lib/request-detail-normalize.ts`(`normalizePortalDetail`),在 `load()` 拿到响应后立即把缺失的 `attempts`→`[]`、`tps`/`ttfb_ms`/`generation_ms`→`null`,页面模板其余逻辑不用改。单测 `request-detail-normalize.test.ts` 覆盖"字段齐全原样透传"与"字段缺失时按各自类型的哨兵值归一化"两种情况;`request-detail-parity.test.ts` 新增一条固化调用点存在。原先 TODO 已解决。
- **可视化走查已完成**:真实生产数据 Playwright 走查全部完成(12 张 + 1 张 mock 截图),Overview/Requests/Connect/Memory/Account 五个页面在两种视口下逐一审阅,无遗留视觉问题;定向 vitest 57/57、`svelte-check` 0 错误 0 警告、i18n 对齐 12/12。

## 更早历史总览

2026-09-26:Portal 用量、请求详情与布局审计,复用 key 隔离数据源,缺失字段兼容及真实页面验证;完整记录见 git history。

2026-09-26:DeepSeek fallback 保留 opaque reasoning 降级,request-contract mutation ledger 回写执行器;未据此宣称历史上游 400 已修复。完整记录见 git history。

2026-09-26:订阅重连复用 OAuth 流程及原账号代理、标签,解密和账号匹配失败时拒绝,详见 Git 历史。
Expand Down