Skip to content

Commit f34d904

Browse files
committed
fix(codex): align local qwen provider with cli proxy
1 parent bcc2b49 commit f34d904

4 files changed

Lines changed: 175 additions & 27 deletions

File tree

cli.js

Lines changed: 106 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ const WORKFLOW_DEFINITIONS_FILE = path.join(CONFIG_DIR, 'codexmate-workflows.jso
109109
const WORKFLOW_RUNS_FILE = path.join(CONFIG_DIR, 'codexmate-workflow-runs.jsonl');
110110
const DEFAULT_CLAUDE_MODEL = 'glm-4.7';
111111
const DEFAULT_QWEN_LOCAL_MODEL = 'coder-model';
112-
const DEFAULT_QWEN_OAUTH_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
112+
const DEFAULT_QWEN_OAUTH_BASE_URL = 'https://portal.qwen.ai/v1';
113113
const DEFAULT_MODEL_CONTEXT_WINDOW = 190000;
114114
const DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT = 185000;
115115
const CODEX_BACKUP_NAME = 'codex-config';
@@ -513,6 +513,24 @@ let g_modelsCache = new Map();
513513
let g_modelsInFlight = new Map();
514514
let g_builtinProxyRuntime = null;
515515
const DEFAULT_LOCAL_PROVIDER_NAME = 'local';
516+
const DEFAULT_QWEN_LOCAL_MODELS = Object.freeze(['coder-model']);
517+
const QWEN_PROXY_DEFAULT_HEADERS = Object.freeze({
518+
'X-Stainless-Runtime-Version': 'v22.17.0',
519+
'User-Agent': 'QwenCode/0.0.0 codexmate-local-proxy',
520+
'X-Stainless-Lang': 'js',
521+
'Accept-Language': '*',
522+
'X-Dashscope-Cachecontrol': 'enable',
523+
'X-Stainless-Os': process.platform === 'darwin' ? 'MacOS' : process.platform,
524+
'X-Dashscope-Authtype': 'qwen-oauth',
525+
'X-Stainless-Arch': process.arch,
526+
'X-Stainless-Runtime': 'node',
527+
'X-Stainless-Retry-Count': '0',
528+
'Accept-Encoding': 'gzip, deflate',
529+
'X-Stainless-Package-Version': '5.11.0',
530+
'Sec-Fetch-Mode': 'cors',
531+
'Connection': 'keep-alive',
532+
'X-Dashscope-Useragent': 'QwenCode/0.0.0 codexmate-local-proxy'
533+
});
516534

517535
function isBuiltinProxyProvider(providerName) {
518536
return typeof providerName === 'string' && providerName.trim().toLowerCase() === BUILTIN_PROXY_PROVIDER_NAME.toLowerCase();
@@ -1261,7 +1279,9 @@ function normalizeQwenOAuthResourceUrl(value) {
12611279
const raw = typeof value === 'string' ? value.trim() : '';
12621280
if (!raw) return DEFAULT_QWEN_OAUTH_BASE_URL;
12631281
const withProtocol = /^[a-z]+:\/\//i.test(raw) ? raw : `https://${raw}`;
1264-
return normalizeBaseUrl(withProtocol) || DEFAULT_QWEN_OAUTH_BASE_URL;
1282+
const normalized = normalizeBaseUrl(withProtocol);
1283+
if (!normalized) return DEFAULT_QWEN_OAUTH_BASE_URL;
1284+
return /\/v1$/i.test(normalized) ? normalized : `${normalized}/v1`;
12651285
}
12661286

12671287
function readQwenOAuthCredentials() {
@@ -6068,7 +6088,7 @@ function buildLocalProviderConfigEntry(overrides = {}) {
60686088
return {
60696089
name: DEFAULT_LOCAL_PROVIDER_NAME,
60706090
base_url: buildBuiltinProxyProviderBaseUrl(settings),
6071-
wire_api: 'responses',
6091+
wire_api: 'chat/completions',
60726092
requires_openai_auth: false,
60736093
preferred_auth_method: '',
60746094
request_max_retries: 4,
@@ -6434,6 +6454,53 @@ function resolveBuiltinProxyRequestUpstream(upstream) {
64346454
};
64356455
}
64366456

6457+
function buildQwenLocalModelsPayload() {
6458+
return {
6459+
object: 'list',
6460+
data: DEFAULT_QWEN_LOCAL_MODELS.map((id) => ({
6461+
id,
6462+
object: 'model',
6463+
created: 0,
6464+
owned_by: 'qwen-cli-oauth'
6465+
}))
6466+
};
6467+
}
6468+
6469+
function sendJsonResponse(res, statusCode, payload) {
6470+
const body = JSON.stringify(payload);
6471+
res.writeHead(statusCode, {
6472+
'Content-Type': 'application/json; charset=utf-8',
6473+
'Content-Length': Buffer.byteLength(body, 'utf-8')
6474+
});
6475+
res.end(body, 'utf-8');
6476+
}
6477+
6478+
function buildQwenProxyHeaders(req, token) {
6479+
const incoming = req && req.headers && typeof req.headers === 'object' ? req.headers : {};
6480+
const headers = { ...incoming };
6481+
delete headers.host;
6482+
delete headers.connection;
6483+
delete headers['content-length'];
6484+
delete headers.authorization;
6485+
delete headers['x-api-key'];
6486+
6487+
const acceptHeader = typeof incoming.accept === 'string' ? incoming.accept.toLowerCase() : '';
6488+
const wantsStream = acceptHeader.includes('text/event-stream');
6489+
for (const [key, value] of Object.entries(QWEN_PROXY_DEFAULT_HEADERS)) {
6490+
headers[key] = value;
6491+
}
6492+
headers.Authorization = /^bearer\s+/i.test(token || '') ? token : `Bearer ${token}`;
6493+
headers.Accept = wantsStream ? 'text/event-stream' : 'application/json';
6494+
headers['Content-Type'] = typeof incoming['content-type'] === 'string' && incoming['content-type'].trim()
6495+
? incoming['content-type']
6496+
: 'application/json';
6497+
headers['x-codexmate-proxy'] = '1';
6498+
if (!headers['x-forwarded-for'] && req.socket && req.socket.remoteAddress) {
6499+
headers['x-forwarded-for'] = req.socket.remoteAddress;
6500+
}
6501+
return headers;
6502+
}
6503+
64376504
function createBuiltinProxyServer(settings, upstream) {
64386505
const connections = new Set();
64396506
const timeoutMs = settings.timeoutMs;
@@ -6478,12 +6545,21 @@ function createBuiltinProxyServer(settings, upstream) {
64786545

64796546
const liveUpstream = resolveBuiltinProxyRequestUpstream(upstream);
64806547
if (liveUpstream.error) {
6481-
const body = JSON.stringify({ error: liveUpstream.error });
6482-
res.writeHead(503, {
6483-
'Content-Type': 'application/json; charset=utf-8',
6484-
'Content-Length': Buffer.byteLength(body, 'utf-8')
6548+
sendJsonResponse(res, 503, { error: liveUpstream.error });
6549+
return;
6550+
}
6551+
6552+
if (liveUpstream.authSource === 'qwen-oauth' && incomingPath === '/v1/models') {
6553+
sendJsonResponse(res, 200, buildQwenLocalModelsPayload());
6554+
return;
6555+
}
6556+
6557+
if (liveUpstream.authSource === 'qwen-oauth'
6558+
&& incomingPath !== '/v1/chat/completions'
6559+
&& incomingPath !== '/v1/models') {
6560+
sendJsonResponse(res, 404, {
6561+
error: 'local provider 仅支持 /v1/models 和 /v1/chat/completions'
64856562
});
6486-
res.end(body, 'utf-8');
64876563
return;
64886564
}
64896565

@@ -6515,26 +6591,30 @@ function createBuiltinProxyServer(settings, upstream) {
65156591
return;
65166592
}
65176593

6518-
const requestHeaders = { ...req.headers };
6519-
delete requestHeaders.host;
6520-
delete requestHeaders.connection;
6521-
delete requestHeaders['content-length'];
6522-
if (liveUpstream.authHeader) {
6523-
requestHeaders.authorization = liveUpstream.authHeader;
6524-
}
6525-
if (liveUpstream.extraHeaders && typeof liveUpstream.extraHeaders === 'object') {
6526-
for (const [key, value] of Object.entries(liveUpstream.extraHeaders)) {
6527-
if (!key) continue;
6528-
if (value === undefined || value === null || value === '') {
6529-
delete requestHeaders[key.toLowerCase()];
6530-
continue;
6594+
const requestHeaders = liveUpstream.authSource === 'qwen-oauth'
6595+
? buildQwenProxyHeaders(req, liveUpstream.authHeader || '')
6596+
: { ...req.headers };
6597+
if (liveUpstream.authSource !== 'qwen-oauth') {
6598+
delete requestHeaders.host;
6599+
delete requestHeaders.connection;
6600+
delete requestHeaders['content-length'];
6601+
if (liveUpstream.authHeader) {
6602+
requestHeaders.authorization = liveUpstream.authHeader;
6603+
}
6604+
if (liveUpstream.extraHeaders && typeof liveUpstream.extraHeaders === 'object') {
6605+
for (const [key, value] of Object.entries(liveUpstream.extraHeaders)) {
6606+
if (!key) continue;
6607+
if (value === undefined || value === null || value === '') {
6608+
delete requestHeaders[key.toLowerCase()];
6609+
continue;
6610+
}
6611+
requestHeaders[key] = value;
65316612
}
6532-
requestHeaders[key] = value;
65336613
}
6534-
}
6535-
requestHeaders['x-codexmate-proxy'] = '1';
6536-
if (!requestHeaders['x-forwarded-for'] && req.socket && req.socket.remoteAddress) {
6537-
requestHeaders['x-forwarded-for'] = req.socket.remoteAddress;
6614+
requestHeaders['x-codexmate-proxy'] = '1';
6615+
if (!requestHeaders['x-forwarded-for'] && req.socket && req.socket.remoteAddress) {
6616+
requestHeaders['x-forwarded-for'] = req.socket.remoteAddress;
6617+
}
65386618
}
65396619

65406620
const transport = targetUrl.protocol === 'https:' ? https : http;

tests/e2e/helpers.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,16 @@ function startLocalServer(options = {}) {
159159
? options.responsePaths.map(item => String(item || ''))
160160
: null;
161161
const requests = [];
162+
const requestDetails = [];
162163
return new Promise((resolve, reject) => {
163164
const server = http.createServer((req, res) => {
164165
const requestPath = String(req.url || '').split('?')[0];
165166
requests.push(requestPath);
167+
requestDetails.push({
168+
path: requestPath,
169+
method: req.method || 'GET',
170+
headers: { ...(req.headers || {}) }
171+
});
166172
if (requestPath && requestPath.startsWith(modelsPath)) {
167173
if (mode === 'none') {
168174
const errorBody = JSON.stringify({ error: 'not found' });
@@ -214,7 +220,7 @@ function startLocalServer(options = {}) {
214220
server.on('error', reject);
215221
server.listen(0, '127.0.0.1', () => {
216222
const address = server.address();
217-
resolve({ server, port: address.port, requests });
223+
resolve({ server, port: address.port, requests, requestDetails });
218224
});
219225
});
220226
}

tests/e2e/run.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ async function main() {
8383
cliPath,
8484
tmpHome,
8585
mockProviderUrl,
86+
mockProviderRequests: mockProvider.requests,
87+
mockProviderRequestDetails: mockProvider.requestDetails,
8688
noModelsUrl,
8789
htmlModelsUrl,
8890
authFailUrl,

tests/e2e/test-config.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,49 @@
1+
const http = require('http');
12
const { spawn } = require('child_process');
23
const toml = require('@iarna/toml');
34
const { assert, runSync, fs, path, os, waitForServer, postJson } = require('./helpers');
45

6+
function requestJson(url, method, payload) {
7+
return new Promise((resolve, reject) => {
8+
const target = new URL(url);
9+
const body = payload === undefined ? '' : JSON.stringify(payload);
10+
const req = http.request({
11+
protocol: target.protocol,
12+
hostname: target.hostname,
13+
port: target.port,
14+
path: `${target.pathname}${target.search}`,
15+
method,
16+
headers: {
17+
'Content-Type': 'application/json',
18+
'Accept': 'application/json',
19+
'Content-Length': Buffer.byteLength(body, 'utf-8')
20+
}
21+
}, (res) => {
22+
let raw = '';
23+
res.on('data', (chunk) => {
24+
raw += chunk;
25+
});
26+
res.on('end', () => {
27+
let parsed = null;
28+
try {
29+
parsed = raw ? JSON.parse(raw) : null;
30+
} catch (e) {}
31+
resolve({
32+
statusCode: res.statusCode || 0,
33+
headers: res.headers || {},
34+
body: parsed,
35+
rawBody: raw
36+
});
37+
});
38+
});
39+
req.on('error', reject);
40+
if (body) {
41+
req.write(body);
42+
}
43+
req.end();
44+
});
45+
}
46+
547
module.exports = async function testConfig(ctx) {
648
const {
749
api,
@@ -10,6 +52,7 @@ module.exports = async function testConfig(ctx) {
1052
cliPath,
1153
tmpHome,
1254
mockProviderUrl,
55+
mockProviderRequestDetails,
1356
noModelsUrl,
1457
htmlModelsUrl,
1558
authFailUrl
@@ -48,6 +91,7 @@ module.exports = async function testConfig(ctx) {
4891
assert(localTemplateBeforeLogin.template.includes('model_provider = "local"'), 'local template missing provider override');
4992
assert(localTemplateBeforeLogin.template.includes('[model_providers.local]'), 'local template missing local provider block');
5093
assert(localTemplateBeforeLogin.template.includes('base_url = "http://127.0.0.1:8318/v1"'), 'local template missing builtin proxy base_url');
94+
assert(localTemplateBeforeLogin.template.includes('wire_api = "chat/completions"'), 'local template should use chat/completions for qwen local provider');
5195

5296
const localApplyWithoutLogin = await api('apply-config-template', {
5397
template: localTemplateBeforeLogin.template
@@ -71,6 +115,7 @@ module.exports = async function testConfig(ctx) {
71115
const localModels = await api('models', { provider: 'local' });
72116
assert(!localModels.error, `local provider models should load after qwen oauth login: ${localModels.error || ''}`);
73117
assert(Array.isArray(localModels.models), 'local provider models should return a list');
118+
assert(localModels.models.includes('coder-model'), 'local provider models should include coder-model');
74119

75120
const localApply = await api('apply-config-template', {
76121
template: localTemplateBeforeLogin.template
@@ -85,6 +130,21 @@ module.exports = async function testConfig(ctx) {
85130
const localConfigContent = fs.readFileSync(localConfigPath, 'utf-8');
86131
assert(/\[model_providers\.local\]/.test(localConfigContent), 'config should persist local provider block');
87132
assert(/base_url\s*=\s*"http:\/\/127\.0\.0\.1:8318\/v1"/.test(localConfigContent), 'config should persist local builtin proxy url');
133+
assert(/wire_api\s*=\s*"chat\/completions"/.test(localConfigContent), 'config should persist chat/completions wire_api for local provider');
134+
135+
const localProxyChat = await requestJson('http://127.0.0.1:8318/v1/chat/completions', 'POST', {
136+
model: 'coder-model',
137+
messages: [{ role: 'user', content: 'ping from local provider' }],
138+
max_tokens: 1
139+
});
140+
assert(localProxyChat.statusCode === 200, `local proxy chat request should succeed, got ${localProxyChat.statusCode}`);
141+
const latestMockRequest = Array.isArray(mockProviderRequestDetails) && mockProviderRequestDetails.length > 0
142+
? mockProviderRequestDetails[mockProviderRequestDetails.length - 1]
143+
: null;
144+
assert(latestMockRequest && latestMockRequest.path === '/v1/chat/completions', 'local proxy should forward chat/completions to qwen upstream path');
145+
assert(latestMockRequest.headers.authorization === 'Bearer qwen-access-token', 'local proxy should forward qwen bearer token');
146+
assert(latestMockRequest.headers['x-dashscope-authtype'] === 'qwen-oauth', 'local proxy should set qwen oauth header');
147+
assert(latestMockRequest.headers['x-stainless-lang'] === 'js', 'local proxy should set qwen-compatible runtime headers');
88148

89149
const restorePrimaryTemplate = await api('get-config-template', {
90150
provider: 'e2e',

0 commit comments

Comments
 (0)