Spaces:
Paused
Paused
icebear0828 Claude Opus 4.6 commited on
Commit ·
6220911
1
Parent(s): 4779e44
fix: review improvements — schema walker, status helper, UI i18n, CI version skip
Browse files1. walkSchema: add patternProperties, if/then/else traversal + cycle detection
2. proxy-handler: extract toErrorStatus() helper, centralize 4x StatusCode casts
3. Header StableText: dynamic t() references for i18n + whitespace-nowrap on status button
4. sync-electron.yml: cancel-in-progress: false + merge two pushes into one
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- .github/workflows/sync-electron.yml +2 -3
- CHANGELOG.md +6 -0
- src/routes/shared/proxy-handler.ts +8 -3
- src/translation/shared-utils.ts +26 -10
- web/src/components/Header.tsx +6 -6
.github/workflows/sync-electron.yml
CHANGED
|
@@ -7,7 +7,7 @@ on:
|
|
| 7 |
|
| 8 |
concurrency:
|
| 9 |
group: sync-electron
|
| 10 |
-
cancel-in-progress:
|
| 11 |
|
| 12 |
permissions:
|
| 13 |
actions: write
|
|
@@ -104,8 +104,7 @@ jobs:
|
|
| 104 |
if: env.CONFLICT != 'true'
|
| 105 |
run: |
|
| 106 |
if [ "$BUMP" = "true" ]; then
|
| 107 |
-
git push origin electron --follow-tags
|
| 108 |
-
git push origin master
|
| 109 |
else
|
| 110 |
git push origin electron
|
| 111 |
fi
|
|
|
|
| 7 |
|
| 8 |
concurrency:
|
| 9 |
group: sync-electron
|
| 10 |
+
cancel-in-progress: false
|
| 11 |
|
| 12 |
permissions:
|
| 13 |
actions: write
|
|
|
|
| 104 |
if: env.CONFLICT != 'true'
|
| 105 |
run: |
|
| 106 |
if [ "$BUMP" = "true" ]; then
|
| 107 |
+
git push origin electron master --follow-tags
|
|
|
|
| 108 |
else
|
| 109 |
git push origin electron
|
| 110 |
fi
|
CHANGELOG.md
CHANGED
|
@@ -20,6 +20,12 @@
|
|
| 20 |
|
| 21 |
### Fixed
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
- 混合 plan 账号路由失败:free 和 team/plus 账号混用时,请求 plan 受限模型(如 `gpt-5.4`)可能 fallback 到不兼容的 free 账号导致 400 错误,现在严格按 plan 过滤,无匹配账号时返回明确错误而非降级 (#54)
|
| 24 |
- `cached_tokens` / `reasoning_tokens` 透传:从 Codex API 响应的 `input_tokens_details` 和 `output_tokens_details` 中提取,传递到 OpenAI(`prompt_tokens_details`)、Anthropic(`cache_read_input_tokens`)、Gemini(`cachedContentTokenCount`)三种格式,覆盖流式和非流式模式 (#55, #58)
|
| 25 |
- Dashboard 模型选择器使用后端 catalog 的 `isDefault` 字段,替代硬编码 `gpt-5.4`
|
|
|
|
| 20 |
|
| 21 |
### Fixed
|
| 22 |
|
| 23 |
+
- JSON Schema `additionalProperties` 递归注入:`injectAdditionalProperties()` 递归注入 `additionalProperties: false` 到 JSON Schema 所有 object 节点,覆盖 `properties`、`patternProperties`、`$defs`/`definitions`、`items`、`prefixItems`、组合器(`oneOf`/`anyOf`/`allOf`)、条件(`if`/`then`/`else`),含循环检测;三个端点(OpenAI/Gemini/Responses passthrough)统一调用 (#64)
|
| 24 |
+
- CONNECT tunnel header 解析:循环跳过中间 header block(CONNECT 200、100 Continue),修复代理模式下 tunnel 的 `HTTP/1.1 200` 被当作真实状态码导致上游 4xx 错误被掩盖为 502 的问题 (#64)
|
| 25 |
+
- 上游 HTTP 状态码透传:非流式 collect 路径从错误消息提取真实 HTTP 状态码,不再硬编码 502;提取 `toErrorStatus()` 辅助函数统一 4 处 StatusCode 转换 (#64)
|
| 26 |
+
- Dashboard 中英文切换按钮宽度跳变:`StableText` 的 `reference` 从英文硬编码改为 `t()` 动态取值,按钮宽度跟随当前语言自适应
|
| 27 |
+
- Dashboard "指纹更新中..." 按钮竖排显示:更新状态按钮添加 `whitespace-nowrap`,防止 CJK 字符逐字换行
|
| 28 |
+
- CI 版本跳号(v1.0.28 → v1.0.30):`sync-electron.yml` 的 `cancel-in-progress` 改为 `false`,避免 workflow 被取消后 tag 已推送但版本号未同步回 master;合并两次 `git push` 为一次减少部分推送窗口
|
| 29 |
- 混合 plan 账号路由失败:free 和 team/plus 账号混用时,请求 plan 受限模型(如 `gpt-5.4`)可能 fallback 到不兼容的 free 账号导致 400 错误,现在严格按 plan 过滤,无匹配账号时返回明确错误而非降级 (#54)
|
| 30 |
- `cached_tokens` / `reasoning_tokens` 透传:从 Codex API 响应的 `input_tokens_details` 和 `output_tokens_details` 中提取,传递到 OpenAI(`prompt_tokens_details`)、Anthropic(`cache_read_input_tokens`)、Gemini(`cachedContentTokenCount`)三种格式,覆盖流式和非流式模式 (#55, #58)
|
| 31 |
- Dashboard 模型选择器使用后端 catalog 的 `isDefault` 字段,替代硬编码 `gpt-5.4`
|
src/routes/shared/proxy-handler.ts
CHANGED
|
@@ -54,6 +54,11 @@ export interface FormatAdapter {
|
|
| 54 |
*
|
| 55 |
* Handles: acquire, session lookup, retry, stream/collect, release, error formatting.
|
| 56 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
/** Check if a CodexApiError indicates the model is not supported on the account's plan. */
|
| 58 |
function isModelNotSupportedError(err: CodexApiError): boolean {
|
| 59 |
// Only 4xx client errors (exclude 429 rate-limit)
|
|
@@ -186,7 +191,7 @@ export async function handleProxyRequest(
|
|
| 186 |
} catch (retryErr) {
|
| 187 |
accountPool.release(currentEntryId);
|
| 188 |
if (retryErr instanceof CodexApiError) {
|
| 189 |
-
const code = (retryErr.status
|
| 190 |
c.status(code);
|
| 191 |
return c.json(fmt.formatError(code, retryErr.message));
|
| 192 |
}
|
|
@@ -210,7 +215,7 @@ export async function handleProxyRequest(
|
|
| 210 |
// Extract upstream status from error message (e.g. "HTTP/1.1 400 Bad Request")
|
| 211 |
const statusMatch = msg.match(/HTTP\/[\d.]+ (\d{3})/);
|
| 212 |
const upstreamStatus = statusMatch ? parseInt(statusMatch[1], 10) : 0;
|
| 213 |
-
const code = (upstreamStatus
|
| 214 |
c.status(code);
|
| 215 |
return c.json(fmt.formatError(code, msg));
|
| 216 |
}
|
|
@@ -241,7 +246,7 @@ export async function handleProxyRequest(
|
|
| 241 |
continue; // re-enter model retry loop
|
| 242 |
}
|
| 243 |
// No other account available — return error (already released above)
|
| 244 |
-
const code = (err.status
|
| 245 |
c.status(code);
|
| 246 |
return c.json(fmt.formatError(code, err.message));
|
| 247 |
}
|
|
|
|
| 54 |
*
|
| 55 |
* Handles: acquire, session lookup, retry, stream/collect, release, error formatting.
|
| 56 |
*/
|
| 57 |
+
/** Clamp an HTTP status to a valid error StatusCode, defaulting to 502 for non-error codes. */
|
| 58 |
+
function toErrorStatus(status: number): StatusCode {
|
| 59 |
+
return (status >= 400 && status < 600 ? status : 502) as StatusCode;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
/** Check if a CodexApiError indicates the model is not supported on the account's plan. */
|
| 63 |
function isModelNotSupportedError(err: CodexApiError): boolean {
|
| 64 |
// Only 4xx client errors (exclude 429 rate-limit)
|
|
|
|
| 191 |
} catch (retryErr) {
|
| 192 |
accountPool.release(currentEntryId);
|
| 193 |
if (retryErr instanceof CodexApiError) {
|
| 194 |
+
const code = toErrorStatus(retryErr.status);
|
| 195 |
c.status(code);
|
| 196 |
return c.json(fmt.formatError(code, retryErr.message));
|
| 197 |
}
|
|
|
|
| 215 |
// Extract upstream status from error message (e.g. "HTTP/1.1 400 Bad Request")
|
| 216 |
const statusMatch = msg.match(/HTTP\/[\d.]+ (\d{3})/);
|
| 217 |
const upstreamStatus = statusMatch ? parseInt(statusMatch[1], 10) : 0;
|
| 218 |
+
const code = toErrorStatus(upstreamStatus);
|
| 219 |
c.status(code);
|
| 220 |
return c.json(fmt.formatError(code, msg));
|
| 221 |
}
|
|
|
|
| 246 |
continue; // re-enter model retry loop
|
| 247 |
}
|
| 248 |
// No other account available — return error (already released above)
|
| 249 |
+
const code = toErrorStatus(err.status);
|
| 250 |
c.status(code);
|
| 251 |
return c.json(fmt.formatError(code, err.message));
|
| 252 |
}
|
src/translation/shared-utils.ts
CHANGED
|
@@ -72,10 +72,14 @@ export function budgetToEffort(budget: number | undefined): string | undefined {
|
|
| 72 |
export function injectAdditionalProperties(
|
| 73 |
schema: Record<string, unknown>,
|
| 74 |
): Record<string, unknown> {
|
| 75 |
-
return walkSchema(structuredClone(schema));
|
| 76 |
}
|
| 77 |
|
| 78 |
-
function walkSchema(node: Record<string, unknown>): Record<string, unknown> {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
// Inject on object types that don't already specify additionalProperties
|
| 80 |
if (node.type === "object" && node.additionalProperties === undefined) {
|
| 81 |
node.additionalProperties = false;
|
|
@@ -86,7 +90,17 @@ function walkSchema(node: Record<string, unknown>): Record<string, unknown> {
|
|
| 86 |
for (const key of Object.keys(node.properties)) {
|
| 87 |
const prop = node.properties[key];
|
| 88 |
if (isRecord(prop)) {
|
| 89 |
-
node.properties[key] = walkSchema(prop);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
}
|
| 92 |
}
|
|
@@ -97,7 +111,7 @@ function walkSchema(node: Record<string, unknown>): Record<string, unknown> {
|
|
| 97 |
const defs = node[defsKey] as Record<string, unknown>;
|
| 98 |
for (const key of Object.keys(defs)) {
|
| 99 |
if (isRecord(defs[key])) {
|
| 100 |
-
defs[key] = walkSchema(defs[key] as Record<string, unknown>);
|
| 101 |
}
|
| 102 |
}
|
| 103 |
}
|
|
@@ -105,13 +119,13 @@ function walkSchema(node: Record<string, unknown>): Record<string, unknown> {
|
|
| 105 |
|
| 106 |
// Traverse items (array items)
|
| 107 |
if (isRecord(node.items)) {
|
| 108 |
-
node.items = walkSchema(node.items as Record<string, unknown>);
|
| 109 |
}
|
| 110 |
|
| 111 |
// Traverse prefixItems
|
| 112 |
if (Array.isArray(node.prefixItems)) {
|
| 113 |
node.prefixItems = node.prefixItems.map((item: unknown) =>
|
| 114 |
-
isRecord(item) ? walkSchema(item) : item,
|
| 115 |
);
|
| 116 |
}
|
| 117 |
|
|
@@ -119,14 +133,16 @@ function walkSchema(node: Record<string, unknown>): Record<string, unknown> {
|
|
| 119 |
for (const combiner of ["oneOf", "anyOf", "allOf"] as const) {
|
| 120 |
if (Array.isArray(node[combiner])) {
|
| 121 |
node[combiner] = (node[combiner] as unknown[]).map((entry: unknown) =>
|
| 122 |
-
isRecord(entry) ? walkSchema(entry) : entry,
|
| 123 |
);
|
| 124 |
}
|
| 125 |
}
|
| 126 |
|
| 127 |
-
// Traverse
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
| 130 |
}
|
| 131 |
|
| 132 |
return node;
|
|
|
|
| 72 |
export function injectAdditionalProperties(
|
| 73 |
schema: Record<string, unknown>,
|
| 74 |
): Record<string, unknown> {
|
| 75 |
+
return walkSchema(structuredClone(schema), new Set());
|
| 76 |
}
|
| 77 |
|
| 78 |
+
function walkSchema(node: Record<string, unknown>, seen: Set<object>): Record<string, unknown> {
|
| 79 |
+
// Cycle detection — stop if we've already visited this node
|
| 80 |
+
if (seen.has(node)) return node;
|
| 81 |
+
seen.add(node);
|
| 82 |
+
|
| 83 |
// Inject on object types that don't already specify additionalProperties
|
| 84 |
if (node.type === "object" && node.additionalProperties === undefined) {
|
| 85 |
node.additionalProperties = false;
|
|
|
|
| 90 |
for (const key of Object.keys(node.properties)) {
|
| 91 |
const prop = node.properties[key];
|
| 92 |
if (isRecord(prop)) {
|
| 93 |
+
node.properties[key] = walkSchema(prop, seen);
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// Traverse patternProperties
|
| 99 |
+
if (isRecord(node.patternProperties)) {
|
| 100 |
+
for (const key of Object.keys(node.patternProperties)) {
|
| 101 |
+
const prop = node.patternProperties[key];
|
| 102 |
+
if (isRecord(prop)) {
|
| 103 |
+
node.patternProperties[key] = walkSchema(prop, seen);
|
| 104 |
}
|
| 105 |
}
|
| 106 |
}
|
|
|
|
| 111 |
const defs = node[defsKey] as Record<string, unknown>;
|
| 112 |
for (const key of Object.keys(defs)) {
|
| 113 |
if (isRecord(defs[key])) {
|
| 114 |
+
defs[key] = walkSchema(defs[key] as Record<string, unknown>, seen);
|
| 115 |
}
|
| 116 |
}
|
| 117 |
}
|
|
|
|
| 119 |
|
| 120 |
// Traverse items (array items)
|
| 121 |
if (isRecord(node.items)) {
|
| 122 |
+
node.items = walkSchema(node.items as Record<string, unknown>, seen);
|
| 123 |
}
|
| 124 |
|
| 125 |
// Traverse prefixItems
|
| 126 |
if (Array.isArray(node.prefixItems)) {
|
| 127 |
node.prefixItems = node.prefixItems.map((item: unknown) =>
|
| 128 |
+
isRecord(item) ? walkSchema(item, seen) : item,
|
| 129 |
);
|
| 130 |
}
|
| 131 |
|
|
|
|
| 133 |
for (const combiner of ["oneOf", "anyOf", "allOf"] as const) {
|
| 134 |
if (Array.isArray(node[combiner])) {
|
| 135 |
node[combiner] = (node[combiner] as unknown[]).map((entry: unknown) =>
|
| 136 |
+
isRecord(entry) ? walkSchema(entry, seen) : entry,
|
| 137 |
);
|
| 138 |
}
|
| 139 |
}
|
| 140 |
|
| 141 |
+
// Traverse conditional: if, then, else
|
| 142 |
+
for (const keyword of ["if", "then", "else", "not"] as const) {
|
| 143 |
+
if (isRecord(node[keyword])) {
|
| 144 |
+
node[keyword] = walkSchema(node[keyword] as Record<string, unknown>, seen);
|
| 145 |
+
}
|
| 146 |
}
|
| 147 |
|
| 148 |
return node;
|
web/src/components/Header.tsx
CHANGED
|
@@ -74,7 +74,7 @@ export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checkin
|
|
| 74 |
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
| 75 |
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-primary" />
|
| 76 |
</span>
|
| 77 |
-
<StableText reference="
|
| 78 |
{version && (
|
| 79 |
<span class="text-[0.65rem] font-mono text-primary/70 whitespace-nowrap">v{version}</span>
|
| 80 |
)}
|
|
@@ -92,7 +92,7 @@ export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checkin
|
|
| 92 |
<svg class="size-3.5" viewBox="0 0 24 24" fill="currentColor">
|
| 93 |
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
| 94 |
</svg>
|
| 95 |
-
<StableText reference="
|
| 96 |
</a>
|
| 97 |
{/* Check for Updates */}
|
| 98 |
<button
|
|
@@ -103,13 +103,13 @@ export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checkin
|
|
| 103 |
<svg class={`size-3.5 ${checking ? "animate-spin" : ""}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 104 |
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.992 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.985 4.356v4.992" />
|
| 105 |
</svg>
|
| 106 |
-
<StableText reference="
|
| 107 |
</button>
|
| 108 |
{/* Update status message */}
|
| 109 |
{updateStatusMsg && !checking && (
|
| 110 |
<button
|
| 111 |
onClick={hasUpdate && onOpenUpdateModal ? onOpenUpdateModal : onCheckUpdate}
|
| 112 |
-
class={`hidden lg:inline text-xs font-medium ${updateStatusColor} hover:underline`}
|
| 113 |
>
|
| 114 |
{updateStatusMsg}
|
| 115 |
</button>
|
|
@@ -140,7 +140,7 @@ export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checkin
|
|
| 140 |
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 141 |
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
|
| 142 |
</svg>
|
| 143 |
-
<StableText reference="
|
| 144 |
</a>
|
| 145 |
<button
|
| 146 |
onClick={onAddAccount}
|
|
@@ -149,7 +149,7 @@ export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checkin
|
|
| 149 |
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
| 150 |
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
| 151 |
</svg>
|
| 152 |
-
<StableText reference="
|
| 153 |
</button>
|
| 154 |
</>
|
| 155 |
)}
|
|
|
|
| 74 |
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
| 75 |
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-primary" />
|
| 76 |
</span>
|
| 77 |
+
<StableText reference={t("serverOnline")} class="text-xs font-semibold text-primary">{t("serverOnline")}</StableText>
|
| 78 |
{version && (
|
| 79 |
<span class="text-[0.65rem] font-mono text-primary/70 whitespace-nowrap">v{version}</span>
|
| 80 |
)}
|
|
|
|
| 92 |
<svg class="size-3.5" viewBox="0 0 24 24" fill="currentColor">
|
| 93 |
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
| 94 |
</svg>
|
| 95 |
+
<StableText reference={t("starOnGithub")} class="text-xs font-semibold">{t("starOnGithub")}</StableText>
|
| 96 |
</a>
|
| 97 |
{/* Check for Updates */}
|
| 98 |
<button
|
|
|
|
| 103 |
<svg class={`size-3.5 ${checking ? "animate-spin" : ""}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 104 |
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.992 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M20.985 4.356v4.992" />
|
| 105 |
</svg>
|
| 106 |
+
<StableText reference={t("checkForUpdates")} class="text-xs font-semibold">{checking ? t("checkingUpdates") : t("checkForUpdates")}</StableText>
|
| 107 |
</button>
|
| 108 |
{/* Update status message */}
|
| 109 |
{updateStatusMsg && !checking && (
|
| 110 |
<button
|
| 111 |
onClick={hasUpdate && onOpenUpdateModal ? onOpenUpdateModal : onCheckUpdate}
|
| 112 |
+
class={`hidden lg:inline whitespace-nowrap text-xs font-medium ${updateStatusColor} hover:underline`}
|
| 113 |
>
|
| 114 |
{updateStatusMsg}
|
| 115 |
</button>
|
|
|
|
| 140 |
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 141 |
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
|
| 142 |
</svg>
|
| 143 |
+
<StableText reference={t("proxySettings")} class="text-xs font-semibold">{t("proxySettings")}</StableText>
|
| 144 |
</a>
|
| 145 |
<button
|
| 146 |
onClick={onAddAccount}
|
|
|
|
| 149 |
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
| 150 |
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
| 151 |
</svg>
|
| 152 |
+
<StableText reference={t("addAccount")}>{t("addAccount")}</StableText>
|
| 153 |
</button>
|
| 154 |
</>
|
| 155 |
)}
|