Skip to content

Commit 3605cae

Browse files
awsl233777SurviveMymkiux
authored
fix(proxy): stream chat fallback, simplify toasts, add issue-inject template (#152)
* fix(proxy): stream chat fallback responses * refactor(web-ui): simplify Claude toast messages * feat(web-ui): align codex models and config toasts --------- Co-authored-by: SurviveM <254925152+SurviveM@users.noreply.github.com> Co-authored-by: ymkiux <ymkiux@users.noreply.github.com>
1 parent 8d2adf0 commit 3605cae

20 files changed

Lines changed: 788 additions & 201 deletions

cli/builtin-proxy.js

Lines changed: 324 additions & 3 deletions
Large diffs are not rendered by default.

lib/cli-models-utils.js

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,44 +48,105 @@ const ANTHROPIC_CLAUDE_MODELS = Object.freeze([
4848
'claude-3-haiku'
4949
]);
5050

51+
const DEEPSEEK_CLAUDE_COMPAT_MODELS = Object.freeze([
52+
'DeepSeek-V3.2',
53+
'DeepSeek-V3',
54+
'DeepSeek-R1',
55+
'deepseek-chat'
56+
]);
57+
58+
const QWEN_CLAUDE_COMPAT_MODELS = Object.freeze([
59+
'qwen3-coder',
60+
'qwen-max',
61+
'qwen-plus',
62+
'qwen-turbo'
63+
]);
64+
65+
const MODELSCOPE_CLAUDE_COMPAT_MODELS = Object.freeze([
66+
'ZhipuAI/GLM-5'
67+
]);
68+
5169
function normalizeModelCatalogId(value) {
5270
return typeof value === 'string' ? value.trim().toLowerCase() : '';
5371
}
5472

55-
function isBigModelClaudeCompatibleBaseUrl(baseUrl) {
73+
function hasPathSegment(baseUrl, segment) {
5674
const normalized = normalizeBaseUrl(baseUrl);
5775
if (!normalized) return false;
5876
try {
5977
const parsed = new URL(normalized);
60-
const host = String(parsed.hostname || '').toLowerCase();
6178
const pathname = String(parsed.pathname || '').toLowerCase();
62-
const isBigModelHost = host === 'bigmodel.cn' || host.endsWith('.bigmodel.cn');
63-
const hasAnthropicSegment = /(^|\/)anthropic(\/|$)/.test(pathname);
64-
return isBigModelHost && hasAnthropicSegment;
79+
const escaped = String(segment || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&').toLowerCase();
80+
return new RegExp(`(^|/)${escaped}(/|$)`).test(pathname);
6581
} catch (_) {
6682
return false;
6783
}
6884
}
6985

70-
function isAnthropicBaseUrl(baseUrl) {
86+
function getBaseUrlHost(baseUrl) {
7187
const normalized = normalizeBaseUrl(baseUrl);
72-
if (!normalized) return false;
88+
if (!normalized) return '';
7389
try {
7490
const parsed = new URL(normalized);
75-
const host = String(parsed.hostname || '').toLowerCase();
76-
return host === 'api.anthropic.com' || host.endsWith('.anthropic.com');
91+
return String(parsed.hostname || '').toLowerCase();
7792
} catch (_) {
78-
return false;
93+
return '';
7994
}
8095
}
8196

97+
function isHostOrSubdomain(host, domain) {
98+
return host === domain || host.endsWith(`.${domain}`);
99+
}
100+
101+
function isBigModelClaudeCompatibleBaseUrl(baseUrl) {
102+
const host = getBaseUrlHost(baseUrl);
103+
return isHostOrSubdomain(host, 'bigmodel.cn') && hasPathSegment(baseUrl, 'anthropic');
104+
}
105+
106+
function isAnthropicBaseUrl(baseUrl) {
107+
const host = getBaseUrlHost(baseUrl);
108+
return isHostOrSubdomain(host, 'anthropic.com');
109+
}
110+
111+
function isDeepSeekClaudeCompatibleBaseUrl(baseUrl) {
112+
const host = getBaseUrlHost(baseUrl);
113+
return isHostOrSubdomain(host, 'deepseek.com') && hasPathSegment(baseUrl, 'anthropic');
114+
}
115+
116+
function isQwenClaudeCompatibleBaseUrl(baseUrl) {
117+
const host = getBaseUrlHost(baseUrl);
118+
return isHostOrSubdomain(host, 'dashscope.aliyuncs.com') && hasPathSegment(baseUrl, 'anthropic');
119+
}
120+
121+
function isZaiClaudeCompatibleBaseUrl(baseUrl) {
122+
const host = getBaseUrlHost(baseUrl);
123+
return isHostOrSubdomain(host, 'z.ai') && hasPathSegment(baseUrl, 'anthropic');
124+
}
125+
126+
function isModelScopeBaseUrl(baseUrl) {
127+
const host = getBaseUrlHost(baseUrl);
128+
return isHostOrSubdomain(host, 'modelscope.cn');
129+
}
130+
82131
function getSupplementalModelsForBaseUrl(baseUrl) {
83132
if (isBigModelClaudeCompatibleBaseUrl(baseUrl)) {
84133
return [...BIGMODEL_CLAUDE_COMPAT_MODELS];
85134
}
86135
if (isAnthropicBaseUrl(baseUrl)) {
87136
return [...ANTHROPIC_CLAUDE_MODELS];
88137
}
138+
if (isDeepSeekClaudeCompatibleBaseUrl(baseUrl)) {
139+
return [...DEEPSEEK_CLAUDE_COMPAT_MODELS];
140+
}
141+
if (isQwenClaudeCompatibleBaseUrl(baseUrl)) {
142+
return [...QWEN_CLAUDE_COMPAT_MODELS];
143+
}
144+
if (isZaiClaudeCompatibleBaseUrl(baseUrl)) {
145+
return [...BIGMODEL_CLAUDE_COMPAT_MODELS];
146+
}
147+
if (isModelScopeBaseUrl(baseUrl)) {
148+
return [...MODELSCOPE_CLAUDE_COMPAT_MODELS];
149+
}
89150
return [];
90151
}
91152

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { pluginOwnership, templateOwnershipById } from '../ownership.mjs';
2+
3+
export function buildBuiltinIssueInjectTemplate(t) {
4+
const tr = (key, fallback, params = null) => (typeof t === 'function' ? t(key, params) : fallback);
5+
const timestamp = new Date().toISOString();
6+
const ownership = templateOwnershipById && templateOwnershipById.builtin_issue_inject
7+
? templateOwnershipById.builtin_issue_inject
8+
: pluginOwnership;
9+
return {
10+
id: 'builtin_issue_inject',
11+
name: tr('plugins.builtin.issueInject.name', 'Issue inject'),
12+
description: tr('plugins.builtin.issueInject.desc', 'Inject {{issue}} into issue {{num}}'),
13+
template: [
14+
tr('plugins.builtin.issueInject.line1', '## Requirements'),
15+
'',
16+
'{{issue}}',
17+
'',
18+
tr('plugins.builtin.issueInject.line2', '## Verification'),
19+
''
20+
].join('\n'),
21+
createdAt: timestamp,
22+
updatedAt: timestamp,
23+
isBuiltin: true,
24+
createdBy: ownership && typeof ownership.createdBy === 'string' ? ownership.createdBy : '',
25+
maintainers: ownership && Array.isArray(ownership.maintainers) ? ownership.maintainers : []
26+
};
27+
}

plugins/prompt-templates/overview.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from './storage.mjs';
77
import { buildBuiltinCommentPolishTemplate } from './comment-polish/index.mjs';
88
import { buildBuiltinRuleAckTemplate } from './rule-ack/index.mjs';
9+
import { buildBuiltinIssueInjectTemplate } from './issue-inject/index.mjs';
910

1011
function ensureBuiltinTemplates(rawList, builtins) {
1112
const list = Array.isArray(rawList) ? rawList.filter(Boolean) : [];
@@ -32,7 +33,8 @@ export async function loadPromptTemplatesOverview(ctx, options = {}) {
3233
const rawList = readPromptTemplatesFromStorage(localStorage);
3334
const normalized = ensureBuiltinTemplates(rawList, [
3435
buildBuiltinCommentPolishTemplate(t),
35-
buildBuiltinRuleAckTemplate(t)
36+
buildBuiltinRuleAckTemplate(t),
37+
buildBuiltinIssueInjectTemplate(t)
3638
]);
3739
app.promptTemplatesListRaw = normalized;
3840
persistPromptTemplatesToStorage(normalized, localStorage);

plugins/prompt-templates/ownership.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ export const templateOwnershipById = {
1414
templateId: 'builtin_rule_ack',
1515
createdBy: 'ymkiux',
1616
maintainers: ['ymkiux']
17+
},
18+
builtin_issue_inject: {
19+
templateId: 'builtin_issue_inject',
20+
createdBy: 'ymkiux',
21+
maintainers: ['ymkiux']
1722
}
1823
};
1924

tests/unit/builtin-proxy-responses-shim.test.mjs

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ function createTestController() {
110110
async function startTestProxy(upstreamPort, options = {}) {
111111
const controller = createTestController();
112112
const runtime = await controller.createBuiltinProxyServer(
113-
{ host: '127.0.0.1', port: 0, timeoutMs: 2000 },
113+
{ host: '127.0.0.1', port: 0, timeoutMs: options.timeoutMs || 2000 },
114114
{
115115
providerName: options.providerName || 'test',
116116
baseUrl: options.baseUrl || `http://127.0.0.1:${upstreamPort}/v1`,
@@ -167,6 +167,112 @@ test('builtin-proxy /v1/responses falls back to chat-only upstream and returns R
167167
}
168168
});
169169

170+
test('builtin-proxy /v1/responses falls back to chat when upstream responses times out', async () => {
171+
const sockets = new Set();
172+
let capturedChatRequest = null;
173+
const upstream = http.createServer((req, res) => {
174+
if (req.url === '/v1/responses' && req.method === 'POST') {
175+
// Simulate gateways that accept /responses but never complete the request.
176+
return;
177+
}
178+
if (req.url === '/v1/chat/completions' && req.method === 'POST') {
179+
const chunks = [];
180+
req.on('data', (chunk) => chunks.push(chunk));
181+
req.on('end', () => {
182+
capturedChatRequest = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
183+
res.writeHead(200, { 'Content-Type': 'application/json' });
184+
res.end(JSON.stringify({
185+
id: 'chatcmpl_after_timeout',
186+
model: 'gpt-test',
187+
choices: [{ message: { role: 'assistant', content: 'fallback-after-timeout' } }]
188+
}));
189+
});
190+
return;
191+
}
192+
res.writeHead(404, { 'Content-Type': 'application/json' });
193+
res.end(JSON.stringify({ error: 'not found' }));
194+
});
195+
upstream.on('connection', (socket) => {
196+
sockets.add(socket);
197+
socket.on('close', () => sockets.delete(socket));
198+
});
199+
const { port: upstreamPort } = await listen(upstream);
200+
let proxyRuntime = null;
201+
202+
try {
203+
proxyRuntime = await startTestProxy(upstreamPort, { timeoutMs: 1000 });
204+
const proxyPort = proxyRuntime.server.address().port;
205+
const resp = await requestText(`http://127.0.0.1:${proxyPort}/v1/responses`, {
206+
method: 'POST',
207+
headers: { 'Content-Type': 'application/json' },
208+
body: { model: 'gpt-test', input: 'ping', stream: false }
209+
});
210+
assert.equal(resp.status, 200);
211+
assert.ok(capturedChatRequest, 'chat fallback should run after responses timeout');
212+
assert.equal(capturedChatRequest.stream, false);
213+
const parsed = JSON.parse(resp.text);
214+
assert.equal(parsed.output[0].content[0].text, 'fallback-after-timeout');
215+
} finally {
216+
if (proxyRuntime) {
217+
await closeServer(proxyRuntime.server, proxyRuntime.connections);
218+
}
219+
await closeServer(upstream, sockets);
220+
}
221+
});
222+
223+
test('builtin-proxy /v1/responses stream=true streams chat fallback as Responses SSE', async () => {
224+
let capturedChatRequest = null;
225+
const upstream = http.createServer((req, res) => {
226+
if (req.url === '/v1/responses' && req.method === 'POST') {
227+
res.writeHead(404, { 'Content-Type': 'application/json' });
228+
res.end(JSON.stringify({ error: 'responses endpoint unavailable' }));
229+
return;
230+
}
231+
if (req.url === '/v1/chat/completions' && req.method === 'POST') {
232+
const chunks = [];
233+
req.on('data', (chunk) => chunks.push(chunk));
234+
req.on('end', () => {
235+
capturedChatRequest = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
236+
res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8' });
237+
res.write('data: {"id":"chatcmpl_stream","model":"gpt-test","choices":[{"delta":{"role":"assistant"}}]}\n\n');
238+
res.write('data: {"id":"chatcmpl_stream","model":"gpt-test","choices":[{"delta":{"content":"hello"}}]}\n\n');
239+
res.write('data: {"id":"chatcmpl_stream","model":"gpt-test","choices":[{"delta":{"content":"-stream"}}]}\n\n');
240+
res.end('data: [DONE]\n\n');
241+
});
242+
return;
243+
}
244+
res.writeHead(404, { 'Content-Type': 'application/json' });
245+
res.end(JSON.stringify({ error: 'not found' }));
246+
});
247+
const { port: upstreamPort } = await listen(upstream);
248+
let proxyRuntime = null;
249+
250+
try {
251+
proxyRuntime = await startTestProxy(upstreamPort);
252+
const proxyPort = proxyRuntime.server.address().port;
253+
const sse = await requestText(`http://127.0.0.1:${proxyPort}/v1/responses`, {
254+
method: 'POST',
255+
headers: { 'Content-Type': 'application/json' },
256+
body: { model: 'gpt-test', input: 'ping', stream: true }
257+
});
258+
assert.equal(sse.status, 200);
259+
assert.ok(capturedChatRequest, 'streaming chat fallback should be called');
260+
assert.equal(capturedChatRequest.stream, true);
261+
assert.match(sse.headers['content-type'], /text\/event-stream/i);
262+
assert.match(sse.text, /event: response\.created/);
263+
assert.match(sse.text, /event: response\.output_text\.delta/);
264+
assert.match(sse.text, /"delta":"hello"/);
265+
assert.match(sse.text, /"delta":"-stream"/);
266+
assert.match(sse.text, /event: response\.completed/);
267+
assert.match(sse.text, /data: \[DONE\]/);
268+
} finally {
269+
if (proxyRuntime) {
270+
await closeServer(proxyRuntime.server, proxyRuntime.connections);
271+
}
272+
await closeServer(upstream);
273+
}
274+
});
275+
170276
test('builtin-proxy /v1/responses stream=true returns SSE wrapper with done sentinel', async () => {
171277
const upstream = http.createServer((req, res) => {
172278
if (req.url === '/v1/responses' && req.method === 'POST') {

tests/unit/claude-settings-sync.test.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ test('applyClaudeConfig reports informative message for external credential only
381381
const result = await applyClaudeConfig.call(context, 'imported');
382382
assert.strictEqual(context.currentClaudeConfig, 'imported');
383383
assert.strictEqual(refreshCount, 1);
384-
assert.deepStrictEqual(messages, [{ msg: '检测到外部 Claude 认证状态;当前仅支持展示,若需由 codexmate 接管请补充 API Key', type: 'info' }]);
384+
assert.deepStrictEqual(messages, [{ msg: '使用外部认证,无需 API Key', type: 'info' }]);
385385
assert.deepStrictEqual(result, messages[0]);
386386
});
387387

tests/unit/cli-models-utils.test.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,25 @@ test('getSupplementalModelsForBaseUrl returns Anthropic Claude models for offici
3232
assert(!models.includes('glm-5.1'));
3333
});
3434

35+
test('getSupplementalModelsForBaseUrl returns provider-specific Claude-compatible Codex catalogs', () => {
36+
const deepseekModels = getSupplementalModelsForBaseUrl('https://api.deepseek.com/anthropic');
37+
const qwenModels = getSupplementalModelsForBaseUrl('https://coding.dashscope.aliyuncs.com/apps/anthropic');
38+
const zaiModels = getSupplementalModelsForBaseUrl('https://api.z.ai/api/anthropic');
39+
const modelscopeModels = getSupplementalModelsForBaseUrl('https://api-inference.modelscope.cn');
40+
41+
assert(deepseekModels.includes('DeepSeek-V3.2'));
42+
assert(!deepseekModels.includes('qwen3-coder'));
43+
assert(qwenModels.includes('qwen3-coder'));
44+
assert(!qwenModels.includes('DeepSeek-V3.2'));
45+
assert(zaiModels.includes('glm-5'));
46+
assert(modelscopeModels.includes('ZhipuAI/GLM-5'));
47+
});
48+
3549
test('getSupplementalModelsForBaseUrl does not match unrelated bigmodel hosts or paths', () => {
3650
assert.deepStrictEqual(getSupplementalModelsForBaseUrl('https://notbigmodel.cn/api/anthropic'), []);
3751
assert.deepStrictEqual(getSupplementalModelsForBaseUrl('https://open.bigmodel.cn/api/anthropicx'), []);
52+
assert.deepStrictEqual(getSupplementalModelsForBaseUrl('https://api.deepseek.com/v1'), []);
53+
assert.deepStrictEqual(getSupplementalModelsForBaseUrl('https://coding.dashscope.aliyuncs.com/apps/openai'), []);
3854
});
3955

4056
test('mergeModelCatalog keeps remote order and appends missing Claude endpoint extras once', () => {

tests/unit/compact-layout-ui.test.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ test('styles keep desktop layout wide and session history readable on large scre
6262
assert.match(styles, /\.session-layout\s*\{[\s\S]*grid-template-columns:\s*minmax\(260px,\s*360px\)\s*minmax\(0,\s*1fr\);/);
6363
assert.match(styles, /\.session-preview-scroll\s*\{[\s\S]*padding-right:\s*52px;/);
6464
assert.match(styles, /\.session-timeline\s*\{[\s\S]*right:\s*4px;[\s\S]*width:\s*44px;/);
65-
assert.match(styles, /\.session-item\s*\{[\s\S]*min-height:\s*80px;/);
65+
assert.match(styles, /\.session-item\s*\{[\s\S]*min-height:\s*108px;[\s\S]*contain-intrinsic-size:\s*108px;/);
66+
assert.match(styles, /\.session-item-cwd\s*\{[\s\S]*flex:\s*1 0 100%;[\s\S]*white-space:\s*normal;[\s\S]*overflow:\s*visible;[\s\S]*overflow-wrap:\s*anywhere;/);
67+
assert.doesNotMatch(styles, /@media \(max-width: 540px\)\s*\{[\s\S]*\.session-item\s*\{[\s\S]*height:\s*75px;/);
6668

6769
const html = readBundledWebUiHtml();
6870
assert.match(html, /class="brand-logo"\s+src="\/res\/logo-pack\.webp"/);

web-ui/modules/app.methods.claude-config.mjs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,22 +77,25 @@ export function createClaudeConfigMethods(options = {}) {
7777

7878
const config = this.claudeConfigs[name];
7979
if (!config.apiKey) {
80-
this.showMessage('已保存,未应用', 'info');
80+
this.showMessage('已保存(未填写 API Key)', 'info');
8181
this.closeEditConfigModal();
8282
if (name === this.currentClaudeConfig) {
8383
this.refreshClaudeModelContext();
8484
}
8585
return;
8686
}
8787

88+
const _claudeKey = `${name}|${config.apiKey || ""}|${config.baseUrl || ""}|${config.model || ""}`;
8889
try {
8990
const res = await api('apply-claude-config', { config });
9091
if (res.error || res.success === false) {
9192
this.showMessage(res.error || '应用配置失败', 'error');
9293
} else {
9394
this.currentClaudeConfig = name;
94-
const targetTip = res.targetPath ? `(${res.targetPath})` : '';
95-
this.showMessage(`已保存并应用到 Claude 配置${targetTip}`, 'success');
95+
if (this._lastAppliedClaudeKey !== _claudeKey) {
96+
this.showMessage('Claude 配置已生效', 'success');
97+
this._lastAppliedClaudeKey = _claudeKey;
98+
}
9699
this.closeEditConfigModal();
97100
this.refreshClaudeModelContext();
98101
}
@@ -153,18 +156,21 @@ export function createClaudeConfigMethods(options = {}) {
153156

154157
if (!config.apiKey) {
155158
if (config.externalCredentialType) {
156-
return this.showMessage('检测到外部 Claude 认证状态;当前仅支持展示,若需由 codexmate 接管请补充 API Key', 'info');
159+
return this.showMessage('使用外部认证,无需 API Key', 'info');
157160
}
158161
return this.showMessage('请先配置 API Key', 'error');
159162
}
160163

164+
const _claudeKey2 = `${name}|${config.apiKey || ""}|${config.baseUrl || ""}|${config.model || ""}`;
161165
try {
162166
const res = await api('apply-claude-config', { config });
163167
if (res.error || res.success === false) {
164168
this.showMessage(res.error || '应用配置失败', 'error');
165169
} else {
166-
const targetTip = res.targetPath ? `(${res.targetPath})` : '';
167-
this.showMessage(`已应用配置到 Claude 设置: ${name}${targetTip}`, 'success');
170+
if (this._lastAppliedClaudeKey !== _claudeKey2) {
171+
this.showMessage('配置已应用', 'success');
172+
this._lastAppliedClaudeKey = _claudeKey2;
173+
}
168174
}
169175
} catch (_) {
170176
this.showMessage('应用配置失败', 'error');

0 commit comments

Comments
 (0)