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 files

1. 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 CHANGED
@@ -7,7 +7,7 @@ on:
7
 
8
  concurrency:
9
  group: sync-electron
10
- cancel-in-progress: true
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 >= 400 && retryErr.status < 600 ? retryErr.status : 502) as StatusCode;
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 >= 400 && upstreamStatus < 600 ? upstreamStatus : 502) as StatusCode;
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 >= 400 && err.status < 600 ? err.status : 502) as StatusCode;
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 not
128
- if (isRecord(node.not)) {
129
- node.not = walkSchema(node.not as Record<string, unknown>);
 
 
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="Server Online" 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,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="Star" class="text-xs font-semibold">{t("starOnGithub")}</StableText>
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="Check for Updates" 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 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="Proxy Assignment" class="text-xs font-semibold">{t("proxySettings")}</StableText>
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="Add Account">{t("addAccount")}</StableText>
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
  )}