Skip to content

Commit e43f590

Browse files
committed
Merge branch 'dev'
2 parents 3e000b5 + 7e63d2e commit e43f590

50 files changed

Lines changed: 1585 additions & 322 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Unanswered issue triage and fixes
2+
3+
This work validates the unanswered issues open on 2026-07-21 before changing code.
4+
5+
## Scope
6+
7+
- #190: Cursor rejects an aggregate Codex tool catalog with `resource_exhausted`.
8+
- #181: provider deletion hides the server's actionable default-provider guard.
9+
- #198: usage/log cost estimates can be mistaken for actual billing.
10+
- #180 and #196: verify fixes already absorbed into `dev` instead of duplicating them.
11+
- #177 and #178: verify whether the vendors expose a model-inference contract that fits an OpenCodex provider.
12+
13+
[Decision Log]
14+
- 목적과 의도: Validate each report against current `dev`, fix confirmed defects, and leave evidence-backed issue replies.
15+
- 기존 구현 및 제약 조건: Cursor registers client tools through a protobuf `McpTools` field with observed count and byte ceilings. The same filtered catalog must drive protobuf advertising and client tool-call recognition. GUI-visible text must be translated in every locale.
16+
- 검토한 주요 대안: Import the closed #192 heuristic patch; drop whole namespaces by count; stub schemas and move them into prompt text; measure actual serialized definitions and prioritize recoverable tools.
17+
- 선택한 방식: Measure the real serialized `McpTools` payload, enforce one hard count cap, prioritize explicit and `tool_search`-loaded tools, and skip oversized candidates while continuing to fill the catalog. Surface server delete errors directly and label cost values as non-billing list-price equivalents.
18+
- 다른 대안 대신 이 방식을 선택한 이유: The closed patch undercounted protobuf bytes, could exceed the count cap through reserved tools, and did not reliably promote searched tools. Exact serialization removes those failure modes without inventing an incompatible schema-stubbing protocol.
19+
- 장점, 단점 및 영향: The transport stays below both observed Cursor limits and searched tools become usable on the next turn. Some low-priority tools can still be omitted in very large catalogs, so the request includes an explicit recovery note when `tool_search` is available. Serialization adds bounded setup work once per Cursor turn.
20+
21+
## Verification plan
22+
23+
- Focused parser, Cursor request-builder/tool-definition/error tests.
24+
- GUI lint, i18n lint, TypeScript build, and relevant existing tests.
25+
- Full repository pre-push gate before pushing `dev`.
26+
27+
## Result
28+
29+
- #180 was already implemented on current `dev` as the complete `ocx account` command family. Its 41-case regression matrix passes; privacy-scan fixture strings were changed from token-shaped literals without changing behavior.
30+
- #196 was already absorbed on `dev`; the registry and three Qwen 3.8 reasoning replay tests pass.
31+
- #190 now budgets the exact serialized `McpTools` protobuf to 120,000 bytes and 330 definitions, prioritizes explicit/native and tool-search-loaded definitions, and classifies residual catalog exhaustion as HTTP 400 `tool_catalog_too_large` rather than a quota rate limit.
32+
- #181 now displays the management API's structured delete error, with a translated fallback for network, empty, or malformed responses.
33+
- #198 now labels dashboard cost values as API list-price equivalents rather than charges in English, Korean, German, and Chinese, with matching public documentation.
34+
- #177 and #178 remain feature requests: Warp documents an asynchronous Oz agent-run API, while Factory documents configuring upstream model gateways for its own client. Neither public contract is an OpenAI-compatible model-inference endpoint that can be registered as an OpenCodex provider without a new adapter contract.
35+
36+
## Verification result
37+
38+
- Full repository suite: 3,296 passed, 0 failed before the fixture-only privacy repair.
39+
- Post-repair focused suite: 94 passed, 0 failed for Cursor error/catalog handling, request logging, and the #180 CLI matrix.
40+
- Root TypeScript typecheck passed.
41+
- GUI ESLint, i18n lint, and production build passed.
42+
- Privacy scan passed.
43+
- React Doctor full scan completed; it reported the repository's existing baseline (197 diagnostics, including one pre-existing `Models.tsx` state-updater error). No new diagnostic was introduced by these changes.

docs-site/src/content/docs/guides/web-dashboard.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ bun run dev:gui
3939
| **Usage / Debug** | Inspect token-usage coverage and trends, or enable opt-in provider transport and usage-extraction diagnostics. |
4040
| **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). |
4141

42+
Cost values in **Logs** and **Usage** are API list-price equivalents calculated from reported tokens.
43+
They are not billing receipts or evidence of an actual charge; subscription usage or provider credits
44+
may apply instead.
45+
4246
## Delegation picker vs spawn routing
4347

4448
The Dashboard's **Sub-agent delegation** picker stores `injectionModel` and, optionally,

docs-site/src/content/docs/ko/guides/web-dashboard.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ bun run dev:gui
3939
| **Usage / Debug** | 토큰 사용량의 측정 범위와 추이를 보거나, 선택적 프로바이더 전송/사용량 추출 진단을 켭니다. |
4040
| **Stop** | 프록시와 설치된 백그라운드 서비스를 정상 종료하고 네이티브 Codex를 복원한 뒤 끝냅니다(`POST /api/stop`). |
4141

42+
**Logs****Usage**의 비용 값은 보고된 토큰으로 계산한 API 정가 환산치입니다. 결제 영수증이나
43+
실제 청구 증거가 아니며, 구독 사용량 또는 프로바이더 크레딧이 대신 적용될 수 있습니다.
44+
4245
## 위임 선택기와 스폰 라우팅의 차이
4346

4447
Dashboard의 **Sub-agent delegation** 선택기는 `injectionModel`과 선택적인 `injectionEffort`

docs-site/src/content/docs/zh-cn/guides/web-dashboard.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ bun run dev:gui
3838
| **Usage / Debug** | 查看 token usage 覆盖率与趋势,或启用可选的 provider transport 和 usage 提取诊断。 |
3939
| **Stop** | 优雅地停止代理和已安装的后台服务,恢复原生 Codex 并退出(`POST /api/stop`)。 |
4040

41+
**Logs****Usage** 中的费用是根据已报告 token 计算的 API 标价折算值,不是账单,也不能证明
42+
实际发生了扣费;实际可能计入订阅用量或消耗服务商额度。
43+
4144
## 委派选择器与生成路由的区别
4245

4346
Dashboard 的 **Sub-agent delegation** 选择器会保存 `injectionModel`,以及可选的

gui/src/App.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,27 @@ function hashBelongsToPage(rawHash: string, page: Page): boolean {
3636

3737
const API_BASE = import.meta.env.VITE_API_BASE || "";
3838
const THEME_KEY = "ocx-theme";
39+
const PROVIDERS_VIEW_KEY = "ocx-providers-view";
40+
41+
function readProvidersViewPreference(): "classic" | "workspace" {
42+
try {
43+
return localStorage.getItem(PROVIDERS_VIEW_KEY) === "workspace" ? "workspace" : "classic";
44+
} catch {
45+
return "classic";
46+
}
47+
}
48+
49+
function writeProvidersViewPreference(view: "classic" | "workspace"): void {
50+
try {
51+
localStorage.setItem(PROVIDERS_VIEW_KEY, view);
52+
} catch {
53+
/* ignore quota / private-mode failures */
54+
}
55+
}
56+
57+
function providersHashForPage(): string {
58+
return readProvidersViewPreference() === "workspace" ? "providers/workspace" : "providers";
59+
}
3960

4061
const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [
4162
{ id: "dashboard", tkey: "nav.dashboard", Icon: IconGrid },
@@ -86,9 +107,22 @@ export default function App() {
86107
const rawHash = window.location.hash.replace(/^#\/?/, "");
87108
setNavOpen(false);
88109
if (!hashBelongsToPage(rawHash, nextPage)) {
89-
window.location.hash = nextPage;
110+
window.location.hash = nextPage === "providers" ? providersHashForPage() : nextPage;
90111
return;
91112
}
113+
// Preference is source of truth for Classic/Workspace. Bare #providers must not
114+
// wipe a saved workspace choice (that regressed when leaving Providers and returning).
115+
if (nextPage === "providers") {
116+
const preferred = readProvidersViewPreference();
117+
if (rawHash === "providers/workspace") {
118+
writeProvidersViewPreference("workspace");
119+
} else if (rawHash === "providers" && preferred === "workspace") {
120+
window.location.hash = "providers/workspace";
121+
return;
122+
} else if (rawHash === "providers") {
123+
writeProvidersViewPreference("classic");
124+
}
125+
}
92126
setPageState(nextPage);
93127
};
94128
window.addEventListener("hashchange", onHash);
@@ -97,6 +131,17 @@ export default function App() {
97131

98132
useEffect(() => {
99133
const rawHash = window.location.hash.replace(/^#\/?/, "");
134+
if (page === "providers") {
135+
// Honor an explicit workspace deep link on first load before normalizing
136+
// to the saved preference (bookmarks/shared links must not open Classic).
137+
if (rawHash === "providers/workspace") {
138+
writeProvidersViewPreference("workspace");
139+
return;
140+
}
141+
const wanted = providersHashForPage();
142+
if (rawHash !== wanted) window.location.hash = wanted;
143+
return;
144+
}
100145
if (!hashBelongsToPage(rawHash, page)) {
101146
window.location.hash = page;
102147
}
@@ -226,7 +271,12 @@ export default function App() {
226271
<nav>
227272
{NAV.map(({ id, tkey, Icon }) => (
228273
<button key={id} className={`nav-item${page === id ? " active" : ""}`} data-page={id}
229-
onClick={() => { setPageState(id); setNavOpen(false); }}
274+
onClick={() => {
275+
// Always sync the hash on nav click so Providers restores Classic/Workspace preference.
276+
window.location.hash = id === "providers" ? providersHashForPage() : id;
277+
setPageState(id);
278+
setNavOpen(false);
279+
}}
230280
aria-current={page === id ? "page" : undefined}>
231281
<Icon /> {t(tkey)}
232282
</button>

gui/src/api-error.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/** Prefer a structured local-management API error while keeping a translated fallback. */
2+
export async function apiErrorMessage(response: Response, fallback: string): Promise<string> {
3+
try {
4+
const data = await response.json() as { error?: unknown };
5+
if (typeof data.error === "string" && data.error.trim()) return data.error.trim();
6+
} catch {
7+
// The fallback is the stable UI contract for empty/non-JSON failures.
8+
}
9+
return fallback;
10+
}

gui/src/components/AddProviderModal.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { oauthTosRisk } from "../oauth-tos-risk";
1212
import OAuthTosWarningModal from "./OAuthTosWarningModal";
1313
import ProviderCatalog from "./provider-catalog/ProviderCatalog";
14+
import type { AccountLoginRow, AccountLoginStatus } from "./provider-catalog/ProviderCatalog";
1415
import type { CatalogPreset } from "./provider-catalog/provider-presets";
1516
import { baseUrlForChoice, matchChoiceId, resolvedBaseUrlForChoice } from "../base-url-choice";
1617

@@ -23,6 +24,7 @@ type FormState = ProviderPayloadForm;
2324

2425
export default function AddProviderModal({
2526
apiBase, existingNames, onClose, onAdded, initialTier, initialCustom = false,
27+
accountRows, accountStatus, accountBusy, onAccountLogin, onAccountCancelLogin, onAccountLogout, onOpen,
2628
}: {
2729
apiBase: string;
2830
existingNames: string[];
@@ -32,6 +34,13 @@ export default function AddProviderModal({
3234
initialTier?: "accounts" | "free" | "paid";
3335
/** Skip the catalog and open the custom-provider form immediately. */
3436
initialCustom?: boolean;
37+
accountRows?: AccountLoginRow[];
38+
accountStatus?: Record<string, AccountLoginStatus>;
39+
accountBusy?: string | null;
40+
onAccountLogin?: (provider: string) => void;
41+
onAccountCancelLogin?: (provider: string) => void;
42+
onAccountLogout?: (provider: string) => void;
43+
onOpen?: () => void;
3544
}) {
3645
const t = useT();
3746
const fallbackPresets = useMemo<Preset[]>(() => [
@@ -65,10 +74,11 @@ export default function AddProviderModal({
6574
const previousFocusRef = useRef<HTMLElement | null>(null);
6675
const dialogRef = useRef<HTMLDivElement>(null);
6776

68-
// Cleanup + focus-trap: save previous focus on mount, restore on unmount.
77+
// Refresh OAuth status once when the modal opens (not when fetchOauth identity changes).
6978
useEffect(() => {
7079
aliveRef.current = true;
7180
previousFocusRef.current = document.activeElement as HTMLElement | null;
81+
onOpen?.();
7282
const dialog = dialogRef.current;
7383
if (dialog) {
7484
const focusable = dialog.querySelector<HTMLElement>(
@@ -80,6 +90,7 @@ export default function AddProviderModal({
8090
aliveRef.current = false;
8191
previousFocusRef.current?.focus();
8292
};
93+
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only open hook
8394
}, []);
8495
useEffect(() => {
8596
const onKey = (e: KeyboardEvent) => {
@@ -308,6 +319,12 @@ export default function AddProviderModal({
308319
initialTier={initialTier}
309320
onSelectPreset={p => choosePreset(p)}
310321
onSelectCustom={() => choosePreset(fallbackPresets[0]!)}
322+
accountRows={accountRows}
323+
accountStatus={accountStatus}
324+
busyProvider={accountBusy}
325+
onLogin={onAccountLogin}
326+
onCancelLogin={onAccountCancelLogin}
327+
onLogout={onAccountLogout}
311328
/>
312329
) : form && (
313330
preset.auth === "oauth" && form.authMode === "oauth" ? (

gui/src/components/provider-catalog/ProviderCatalog.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import {
1212
type CatalogPreset,
1313
} from "./provider-presets";
1414

15-
export type AccountLoginStatus = { loggedIn: boolean; email?: string; error?: string };
15+
export type AccountLoginStatus = { loggedIn: boolean; email?: string; error?: string; needsReauth?: boolean };
1616
export type AccountLoginRow = {
1717
id: string;
1818
label: string;
19-
kind: "oauth" | "key";
19+
kind: "oauth" | "key" | "codex";
2020
statusLabel?: string;
21+
/** Optional deep-link for codex/account-pool management. */
22+
href?: string;
2123
};
2224

2325
export type CatalogTier = "accounts" | "free" | "paid";
@@ -102,8 +104,7 @@ export default function ProviderCatalog({
102104

103105
{tier === "accounts" && (
104106
<div className="provider-catalog-accounts-hint muted text-label">
105-
{t("modal.accountsHint")}{" "}
106-
<a href="#codex-auth">{t("modal.accountsCodexAuthLink")}</a>
107+
{t("modal.accountsHint")}
107108
</div>
108109
)}
109110

@@ -118,7 +119,7 @@ export default function ProviderCatalog({
118119
{presetsLoading && rows.length === 0 && (
119120
<div className="muted text-control provider-catalog-empty">{t("modal.catalogLoading")}</div>
120121
)}
121-
{rows.map(p => (
122+
{tier !== "accounts" && rows.map(p => (
122123
<button key={p.id} className="list-row" onClick={() => onSelectPreset(p)}>
123124
<div>
124125
<div className="title">{p.label}</div>
@@ -127,25 +128,36 @@ export default function ProviderCatalog({
127128
<div className="provider-catalog-badges">{badges(p)}</div>
128129
</button>
129130
))}
130-
{!presetsLoading && rows.length === 0 && (
131+
{tier !== "accounts" && !presetsLoading && rows.length === 0 && (
131132
<div className="muted text-control provider-catalog-empty">{t("modal.noMatch")}</div>
132133
)}
133134

134135
{tier === "accounts" && accountRows.map(row => {
135136
const status = accountStatus[row.id];
136137
const busy = busyProvider === row.id;
138+
const loggedIn = !!status?.loggedIn;
139+
const statusText = loggedIn
140+
? (status?.email ?? row.statusLabel ?? t("modal.accountLoggedIn"))
141+
: (status?.error ?? row.statusLabel ?? t("modal.accountLoggedOut"));
137142
return (
138143
<div key={row.id} className="list-row provider-catalog-account-row">
139144
<div>
140145
<div className="title">{row.label}</div>
141-
<div className="sub">
142-
{status?.loggedIn
143-
? (status.email ?? row.statusLabel ?? t("modal.accountLoggedIn"))
144-
: (status?.error ?? row.statusLabel ?? t("modal.accountLoggedOut"))}
145-
</div>
146+
<div className="sub">{statusText}</div>
146147
</div>
147148
<div className="provider-catalog-badges">
148-
{status?.loggedIn ? (
149+
{row.kind === "key" ? null : row.kind === "codex" ? (
150+
<>
151+
{loggedIn && (
152+
<a className="btn btn-ghost" href={row.href ?? "#codex-auth"}>{t("modal.accountManage")}</a>
153+
)}
154+
{onLogin && (
155+
<button className={loggedIn ? "btn btn-ghost" : "btn btn-primary"} onClick={() => onLogin(row.id)}>
156+
{loggedIn ? t("modal.accountAdd") : t("modal.accountLogin")}
157+
</button>
158+
)}
159+
</>
160+
) : loggedIn ? (
149161
onLogout && <button className="btn btn-ghost" onClick={() => onLogout(row.id)}>{t("modal.accountLogout")}</button>
150162
) : busy ? (
151163
onCancelLogin && <button className="btn btn-ghost" onClick={() => onCancelLogin(row.id)}>{t("common.cancel")}</button>
@@ -156,6 +168,9 @@ export default function ProviderCatalog({
156168
</div>
157169
);
158170
})}
171+
{tier === "accounts" && accountRows.length === 0 && !presetsLoading && (
172+
<div className="muted text-control provider-catalog-empty">{t("modal.noMatch")}</div>
173+
)}
159174
</div>
160175

161176
<div className="provider-catalog-footer">

0 commit comments

Comments
 (0)