From 8cc55ebd074b2978ddfd44cfbddd66a81c1c6578 Mon Sep 17 00:00:00 2001 From: majd-tqe Date: Fri, 30 Oct 2026 23:08:28 +0300 Subject: [PATCH 1/2] feat: configure composer quota indicators --- README.md | 4 +- README.zh.md | 4 +- src/auth-paths.ts | 3 + src/auth-routes.ts | 21 +++ src/client/OpenAICodexQuotaIndicator.tsx | 137 ++++++++++-------- src/client/OpenAICodexSettings.tsx | 36 +++++ src/client/locales.ts | 16 ++ src/index.ts | 4 + src/tool-policy.ts | 17 +++ tests/fast-mode-routes.spec.ts | 49 ++++++- ...enai-codex-quota-indicator.client.spec.tsx | 68 ++++++++- tests/openai-codex-settings.client.spec.tsx | 26 ++++ tests/tool-policy.spec.ts | 18 ++- 13 files changed, 328 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 4c4241a..357c34a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Use a ChatGPT subscription in [DeepSeek Harness](https://github.com/deepseek-ai/ - optional HTTP(S) URL input added to Harness's existing `read_image` tool - an `imagegen` tool with a selectable GPT Image backend, workspace or conversation reference images, and automatic workspace output - browser image input through dsh's existing paste and drop controls -- a per-conversation Fast Mode switch and compact weekly quota indicator in the Web composer +- a per-conversation Fast Mode switch and configurable five-hour and weekly quota indicators in the Web composer - optional backend-authorized model recovery, including hidden Luna Reserve routing - a three-scope HTTP(S) proxy control for Codex-only or process-wide routing @@ -123,7 +123,7 @@ The Settings page selects `gpt-image-2` (the current Codex default), `gpt-image- `read_image` stores validated bytes as a dsh attachment before returning the actual image block. Local paths are delegated unchanged to Harness, including its configured filesystem and sandbox behavior. The URL extension bounds redirects and bytes, rejects credentials embedded in URLs, rejects local/private/special network targets, and pins each validated public address across the corresponding HTTP hop. -For an eligible Codex GPT conversation, the Web composer also exposes a session-local Fast Mode switch. Enabling it adds the provider's priority service tier only to that conversation; it does not change saved model settings. A neighboring quota bar shows the applicable weekly limit and provider-declared reset time. The Settings page also has a **Force 1.5× speed by default** switch (off by default) that applies Fast Mode to every conversation without the per-conversation toggle; quota is consumed faster while it is on. +For an eligible Codex GPT conversation, the Web composer also exposes a session-local Fast Mode switch. Enabling it adds the provider's priority service tier only to that conversation; it does not change saved model settings. The neighboring quota indicator can show the five-hour limit, the weekly limit, or both; choose the display in **Settings → OpenAI Codex**. Each bar shows its remaining quota and provider-declared reset time on hover. The default remains the weekly limit. The Settings page also has a **Force 1.5× speed by default** switch (off by default) that applies Fast Mode to every conversation without the per-conversation toggle; quota is consumed faster while it is on. ## Search diff --git a/README.zh.md b/README.zh.md index 6bce0d5..85be8cd 100644 --- a/README.zh.md +++ b/README.zh.md @@ -16,7 +16,7 @@ - 为 Harness 现有 `read_image` 工具增加可选的 HTTP(S) URL 输入 - 可选择 GPT Image 后端的 `imagegen` 工具,支持工作区/会话参考图和自动工作区输出 - 复用 dsh Web 输入框的粘贴和拖放图片能力 -- 在 Web 输入框提供按会话生效的 Fast Mode 开关与紧凑的每周额度指示器 +- 在 Web 输入框提供按会话生效的 Fast Mode 开关与可配置的 5 小时和每周额度指示器 - 可选的后端授权模型恢复,包括隐藏的 Luna Reserve 路由 - 可选择仅 Codex 或整个进程范围的三态 HTTP(S) 代理设置 @@ -123,7 +123,7 @@ bundle 会为新建 agent 选择 `openai-codex` / `gpt-5.6-sol`,并选择 Code `read_image` 在返回实际图片块之前,会先验证图片并把字节持久化为 dsh 附件。本地路径原样委托给 Harness,继续沿用当前文件系统和沙箱行为;URL 扩展会限制重定向次数与下载字节数,拒绝内嵌凭据和本地/私网/特殊网络目标,并在每一跳把连接固定到已经验证的公网地址。 -对符合条件的 Codex GPT 会话,Web 输入框还会显示仅对当前会话生效的 Fast Mode 开关。开启后只为该会话加入提供方的 priority service tier,不会修改已保存的模型设置;旁边的额度条显示对应的每周额度和提供方声明的重置时间。设置页另有默认关闭的 **默认强制开启 1.5 倍速** 开关,开启后所有会话默认使用 1.5 倍速,无需逐会话点击开关,但额度消耗更快。 +对符合条件的 Codex GPT 会话,Web 输入框还会显示仅对当前会话生效的 Fast Mode 开关。开启后只为该会话加入提供方的 priority service tier,不会修改已保存的模型设置;旁边的额度指示器可在 **设置 → OpenAI Codex** 中选择显示 5 小时额度、每周额度或两者;悬停可查看剩余额度和提供方声明的重置时间。默认显示每周额度。设置页另有默认关闭的 **默认强制开启 1.5 倍速** 开关,开启后所有会话默认使用 1.5 倍速,无需逐会话点击开关,但额度消耗更快。 ## 搜索 diff --git a/src/auth-paths.ts b/src/auth-paths.ts index 3b7ec0a..da2797b 100644 --- a/src/auth-paths.ts +++ b/src/auth-paths.ts @@ -1,5 +1,8 @@ /** Node-free route constants shared by the Host and browser plugin halves. */ +/** Composer quota display preference endpoint. */ +export const OPENAI_CODEX_COMPOSER_QUOTA_PATH = '/plugins/dsh-openai-codex/composer-quota' + /** Plugin-owned status endpoint consumed by its browser half. */ export const OPENAI_CODEX_AUTH_STATUS_PATH = '/plugins/dsh-openai-codex/auth/status' /** Plugin-owned browser and device-code login endpoint consumed by its browser half. */ diff --git a/src/auth-routes.ts b/src/auth-routes.ts index efe25f3..88dca85 100644 --- a/src/auth-routes.ts +++ b/src/auth-routes.ts @@ -18,6 +18,7 @@ import { } from "./usage.ts"; import type { OpenAICodexUsage } from "./usage.ts"; import { + OPENAI_CODEX_COMPOSER_QUOTA_PATH, OPENAI_CODEX_AUTH_CANCEL_PATH, OPENAI_CODEX_AUTH_LOGIN_PATH, OPENAI_CODEX_AUTH_LOGOUT_PATH, @@ -1072,6 +1073,26 @@ export function registerOpenAICodexAuthRoutes( } }, }), + ctx.webServer.register({ + kind: "exact", + path: OPENAI_CODEX_COMPOSER_QUOTA_PATH, + handler: async (req, res) => { + if (!(await authorize(req, res))) return; + if (req.method === "GET") return json(res, 200, imageTools.composerQuotaSnapshot()); + if (req.method !== "POST") return json(res, 405, { error: "method not allowed" }); + try { + const body = await readSettingsBody(req); + const choice = body["composerQuota"]; + if (Object.keys(body).some(key => key !== "composerQuota") || + (choice !== "five-hour" && choice !== "weekly" && choice !== "both")) { + throw new TypeError("Invalid composer quota choice"); + } + return json(res, 200, await imageTools.updateComposerQuota({ composerQuota: choice })); + } catch (error: unknown) { + return json(res, 400, { error: safeMessage(error) }); + } + }, + }), ctx.webServer.register({ kind: "exact", path: OPENAI_CODEX_FAST_MODE_SETTINGS_PATH, diff --git a/src/client/OpenAICodexQuotaIndicator.tsx b/src/client/OpenAICodexQuotaIndicator.tsx index d82ee55..c1f96ae 100644 --- a/src/client/OpenAICodexQuotaIndicator.tsx +++ b/src/client/OpenAICodexQuotaIndicator.tsx @@ -1,10 +1,11 @@ -/** Compact weekly Codex quota indicator for the Composer tool row. */ +/** Configurable Codex quota indicators for the Composer tool row. */ import { useEffect, useId, useSyncExternalStore, useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ModelDirectoryState } from '@deepseek-ai/dsh-client-ui-model-selection/client' import type { OpenAICodexUsage, OpenAICodexRateLimitWindow } from '../usage.ts' -import { OPENAI_CODEX_AUTH_STATUS_PATH } from '../auth-paths.ts' +import { OPENAI_CODEX_AUTH_STATUS_PATH, OPENAI_CODEX_COMPOSER_QUOTA_PATH } from '../auth-paths.ts' +import type { ComposerQuotaPreferences } from '../tool-policy.ts' import { formatOpenAICodexResetAt } from './OpenAICodexSettings.tsx' import type { OpenAICodexSettingsKey } from './locales.ts' @@ -58,10 +59,40 @@ function usageFromStatus(value: unknown): OpenAICodexUsage | undefined { return usage as unknown as OpenAICodexUsage } -function weeklyQuotaOf(usage: OpenAICodexUsage): OpenAICodexRateLimitWindow | undefined { - return usage.rateLimits - .find(limit => limit.id === 'codex') - ?.windows.find(window => window.windowSeconds === WEEK_SECONDS) +function quotaOf(usage: OpenAICodexUsage, seconds: number): OpenAICodexRateLimitWindow | undefined { + return usage.rateLimits.find(limit => limit.id === 'codex')?.windows.find(window => window.windowSeconds === seconds) +} + +// All mounted eligible composers share one request and timer. +const usageListeners = new Set<(state: UsageRequestState) => void>() +let sharedUsage: UsageRequestState = { status: 'loading' } +let stopPolling: (() => void) | undefined +function subscribeUsage(listener: (state: UsageRequestState) => void): () => void { + usageListeners.add(listener) + if (stopPolling === undefined) { + const controller = new AbortController() + let inFlight = false + const refresh = async () => { + if (inFlight) return + inFlight = true + try { + const response = await fetch(OPENAI_CODEX_AUTH_STATUS_PATH, { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal: controller.signal }) + const value: unknown = await response.json().catch(() => undefined) + const usage = response.ok ? usageFromStatus(value) : undefined + if (!controller.signal.aborted) sharedUsage = usage === undefined ? { status: 'hidden' } : { status: 'ready', usage } + } catch { + if (!controller.signal.aborted) sharedUsage = { status: 'hidden' } + } finally { + inFlight = false + if (!controller.signal.aborted) for (const notify of usageListeners) notify(sharedUsage) + } + } + void refresh() + const timer = window.setInterval(() => { void refresh() }, USAGE_POLL_INTERVAL_MS) + stopPolling = () => { window.clearInterval(timer); controller.abort(); sharedUsage = { status: 'loading' }; stopPolling = undefined } + } + listener(sharedUsage) + return () => { usageListeners.delete(listener); if (usageListeners.size === 0) stopPolling?.() } } function isGptModel(state: ModelDirectoryState): boolean { @@ -115,66 +146,49 @@ export function OpenAICodexQuotaIndicator({ directory, t }: OpenAICodexQuotaIndi ) const eligible = isGptModel(directoryState) const [request, setRequest] = useState({ status: 'loading' }) - const [isHovered, setIsHovered] = useState(false) - const [isFocused, setIsFocused] = useState(false) - const tooltipId = useId() - + const [choice, setChoice] = useState('weekly') useEffect(() => { - if (!eligible) { - setRequest({ status: 'hidden' }) - return - } - - const controller = new AbortController() - let inFlight = false + if (!eligible) return + return subscribeUsage(setRequest) + }, [eligible]) + useEffect(() => { + if (!eligible) return let disposed = false - - const refresh = async (): Promise => { - if (inFlight || disposed) return - inFlight = true - try { - const response = await fetch(OPENAI_CODEX_AUTH_STATUS_PATH, { - method: 'GET', - credentials: 'same-origin', - headers: { accept: 'application/json' }, - signal: controller.signal, - }) - const value: unknown = await response.json().catch(() => undefined) - const usage = response.ok ? usageFromStatus(value) : undefined - if (!disposed && !controller.signal.aborted) { - setRequest(usage === undefined ? { status: 'hidden' } : { status: 'ready', usage }) - } - } catch { - if (!disposed && !controller.signal.aborted) setRequest({ status: 'hidden' }) - } finally { - inFlight = false - } - } - - setRequest({ status: 'loading' }) - void refresh() - const timer = window.setInterval(() => { void refresh() }, USAGE_POLL_INTERVAL_MS) - return () => { - disposed = true - window.clearInterval(timer) - controller.abort() + const refreshChoice = () => { + void fetch(OPENAI_CODEX_COMPOSER_QUOTA_PATH, { credentials: 'same-origin' }) + .then(response => response.ok ? response.json() : undefined) + .then((value: unknown) => { + if (!disposed && isRecord(value) && (value['composerQuota'] === 'weekly' || value['composerQuota'] === 'five-hour' || value['composerQuota'] === 'both')) setChoice(value['composerQuota']) + }).catch(() => undefined) } + refreshChoice() + window.addEventListener('dsh-codex-composer-quota-changed', refreshChoice) + return () => { disposed = true; window.removeEventListener('dsh-codex-composer-quota-changed', refreshChoice) } }, [eligible]) - if (!eligible || request.status !== 'ready' || request.usage === undefined) return null - const weekly = weeklyQuotaOf(request.usage) - if (weekly === undefined) return null - - const percent = formatPercent(weekly.remainingPercent) - const fullResetTime = formatOpenAICodexResetAt(weekly.resetAt) ?? t('resetUnavailable') - const summary = t('composerWeeklyQuotaSummary', { percent, time: fullResetTime }) - const boundedPercent = boundedQuotaPercent(weekly.remainingPercent) - const progressColor = quotaProgressColor(weekly.remainingPercent) + const windows = [ + ...(choice === 'five-hour' || choice === 'both' ? [{ kind: 'five-hour' as const, window: quotaOf(request.usage, 5 * 60 * 60) }] : []), + ...(choice === 'weekly' || choice === 'both' ? [{ kind: 'weekly' as const, window: quotaOf(request.usage, WEEK_SECONDS) }] : []), + ] + return {windows.map(({ kind, window }) => window === undefined ? null : )} +} + +function QuotaWindow({ kind, quota, t }: { kind: 'five-hour' | 'weekly'; quota: OpenAICodexRateLimitWindow; t: Translate }) { + const [isHovered, setIsHovered] = useState(false) + const [isFocused, setIsFocused] = useState(false) + const tooltipId = useId() + const percent = formatPercent(quota.remainingPercent) + const fullResetTime = formatOpenAICodexResetAt(quota.resetAt) + const summary = fullResetTime === undefined + ? t(kind === 'weekly' ? 'composerWeeklyQuotaNoReset' : 'composerFiveHourQuotaNoReset', { percent }) + : t(kind === 'weekly' ? 'composerWeeklyQuotaSummary' : 'composerFiveHourQuotaSummary', { percent, time: fullResetTime }) + const boundedPercent = boundedQuotaPercent(quota.remainingPercent) + const progressColor = quotaProgressColor(quota.remainingPercent) const tooltipVisible = isHovered || isFocused return ( { setIsFocused(false) }} style={{ display: 'inline-flex', - width: `${QUOTA_PROGRESS_WIDTH_PX}px`, + gap: '4px', height: '28px', position: 'relative', alignItems: 'center', justifyContent: 'center', }} > +