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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) 代理设置

Expand Down Expand Up @@ -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 倍速,无需逐会话点击开关,但额度消耗更快。

## 搜索

Expand Down
3 changes: 3 additions & 0 deletions src/auth-paths.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down
21 changes: 21 additions & 0 deletions src/auth-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
137 changes: 76 additions & 61 deletions src/client/OpenAICodexQuotaIndicator.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -115,66 +146,49 @@ export function OpenAICodexQuotaIndicator({ directory, t }: OpenAICodexQuotaIndi
)
const eligible = isGptModel(directoryState)
const [request, setRequest] = useState<UsageRequestState>({ status: 'loading' })
const [isHovered, setIsHovered] = useState(false)
const [isFocused, setIsFocused] = useState(false)
const tooltipId = useId()

const [choice, setChoice] = useState<ComposerQuotaPreferences['composerQuota']>('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<void> => {
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 <span style={{ display: 'inline-flex', gap: '8px' }}>{windows.map(({ kind, window }) => window === undefined ? null : <QuotaWindow key={kind} kind={kind} quota={window} t={t} />)}</span>
}

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 (
<span
role="status"
data-openai-codex-quota="weekly"
data-openai-codex-quota={kind}
aria-label={summary}
aria-describedby={tooltipVisible ? tooltipId : undefined}
tabIndex={0}
Expand All @@ -184,16 +198,17 @@ export function OpenAICodexQuotaIndicator({ directory, t }: OpenAICodexQuotaIndi
onBlur={() => { setIsFocused(false) }}
style={{
display: 'inline-flex',
width: `${QUOTA_PROGRESS_WIDTH_PX}px`,
gap: '4px',
height: '28px',
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span aria-hidden="true" style={{ fontSize: '11px', whiteSpace: 'nowrap' }}>{t(kind === 'weekly' ? 'composerQuotaWeekly' : 'composerQuotaFiveHour')}</span>
<span
aria-hidden="true"
data-openai-codex-quota-track="weekly"
data-openai-codex-quota-track={kind}
style={{
display: 'block',
width: `${QUOTA_PROGRESS_WIDTH_PX}px`,
Expand All @@ -205,7 +220,7 @@ export function OpenAICodexQuotaIndicator({ directory, t }: OpenAICodexQuotaIndi
>
<span
aria-hidden="true"
data-openai-codex-quota-progress="weekly"
data-openai-codex-quota-progress={kind}
data-openai-codex-quota-color={progressColor.name}
style={{
display: 'block',
Expand All @@ -220,7 +235,7 @@ export function OpenAICodexQuotaIndicator({ directory, t }: OpenAICodexQuotaIndi
<span
id={tooltipId}
role="tooltip"
data-openai-codex-quota-tooltip="weekly"
data-openai-codex-quota-tooltip={kind}
style={{
position: 'absolute',
bottom: 'calc(100% + 6px)',
Expand Down
Loading