codexunlimit 0.1.0-web.12 → 0.1.0-web.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -75
- package/cli.mjs +2 -110
- package/codex-desktop.ps1 +85 -0
- package/package.json +33 -14
- package/runtime.mjs +1 -0
- package/shared-CUSGWETD.mjs +1 -0
- package/web/app.js +1 -154
- package/web/style.css +5 -0
- package/account-pool.mjs +0 -161
- package/app.mjs +0 -161
- package/codex-account-client.mjs +0 -53
- package/config.mjs +0 -224
- package/doctor.mjs +0 -20
- package/image-http.mjs +0 -22
- package/io.mjs +0 -5
- package/license-client.mjs +0 -150
- package/product.mjs +0 -5
- package/protocol.mjs +0 -28
- package/quota.mjs +0 -34
- package/relay.mjs +0 -156
- package/state.mjs +0 -33
- package/updates.mjs +0 -47
- package/web/markdown.js +0 -15
- package/web/messages.js +0 -66
- package/web/pool-ui.js +0 -53
- package/web/stream.js +0 -25
- package/web/updates.js +0 -26
- package/windows.mjs +0 -53
package/doctor.mjs
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import {execFileSync} from 'node:child_process';
|
|
5
|
-
import {codexExecutable} from './codex-account-client.mjs';
|
|
6
|
-
import {VERSION} from './product.mjs';
|
|
7
|
-
|
|
8
|
-
export async function diagnoseRuntime(){
|
|
9
|
-
const version=process.versions.node.split('.').map(Number),checks=[{name:'Node.js',ok:version[0]===22&&version[1]>=15||version[0]>=24,version:process.versions.node},{name:'Codex Unlimit',ok:true,version:VERSION}];
|
|
10
|
-
try{await import('node:sqlite');checks.push({name:'SQLite',ok:true});}catch{checks.push({name:'SQLite',ok:false});}
|
|
11
|
-
const home=fs.mkdtempSync(path.join(os.tmpdir(),'cu-runtime-check-'));
|
|
12
|
-
try{
|
|
13
|
-
const env={...process.env,CODEX_HOME:home};for(const key of Object.keys(env))if(/^(OPENAI_|CHATGPT_|CODEX_API_KEY|NODE_OPTIONS|NODE_PATH|NODE_AUTH_TOKEN)/i.test(key))delete env[key];
|
|
14
|
-
const expected=JSON.parse(fs.readFileSync(new URL('./package.json',import.meta.url),'utf8')).dependencies['@openai/codex'];
|
|
15
|
-
const output=execFileSync(codexExecutable(),['--version'],{env,encoding:'utf8',windowsHide:true,timeout:20000,stdio:['ignore','pipe','pipe']});
|
|
16
|
-
const actual=output.match(/codex-cli (\d+\.\d+\.\d+)/)?.[1];checks.push({name:'Codex runtime',ok:actual===expected,...(actual?{version:actual}:{})});
|
|
17
|
-
}catch{checks.push({name:'Codex runtime',ok:false});}
|
|
18
|
-
finally{if(path.dirname(home)===os.tmpdir())fs.rmSync(home,{recursive:true,force:true,maxRetries:5,retryDelay:100});}
|
|
19
|
-
return {ok:checks.every(check=>check.ok),platform:process.platform,architecture:process.arch,checks,accountCredentialsRead:false};
|
|
20
|
-
}
|
package/image-http.mjs
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import http from 'node:http';
|
|
2
|
-
import https from 'node:https';
|
|
3
|
-
import {Readable} from 'node:stream';
|
|
4
|
-
|
|
5
|
-
export const isImageRoute=route=>/^\/(?:v1\/)?images\/(?:generations|edits)$/.test(route);
|
|
6
|
-
export function imageRequest(value,options={}){
|
|
7
|
-
const url=new URL(value);
|
|
8
|
-
if(url.username||url.password||url.hash||url.search||!isImageRoute(url.pathname)||!(url.protocol==='https:'||url.protocol==='http:'&&url.hostname==='127.0.0.1'))throw Error('image_destination_denied');
|
|
9
|
-
const body=Buffer.from(options.body??''),headers=new Headers(options.headers);headers.set('content-length',String(body.length));headers.set('accept-encoding','identity');
|
|
10
|
-
for(const name of ['host','proxy-authorization','proxy-connection'])headers.delete(name);
|
|
11
|
-
return new Promise((resolve,reject)=>{
|
|
12
|
-
const request=(url.protocol==='https:'?https:http).request(url,{method:'POST',headers:Object.fromEntries(headers),signal:options.signal,agent:false,rejectUnauthorized:true},response=>{
|
|
13
|
-
clearTimeout(timer);const responseHeaders=new Headers();
|
|
14
|
-
for(const [name,value]of Object.entries(response.headers))if(value!==undefined)responseHeaders.set(name,Array.isArray(value)?value.join(', '):String(value));
|
|
15
|
-
if([204,205,304].includes(response.statusCode)){response.resume();resolve(new Response(null,{status:response.statusCode,headers:responseHeaders}));}
|
|
16
|
-
else resolve(new Response(Readable.toWeb(response),{status:response.statusCode,headers:responseHeaders}));
|
|
17
|
-
});
|
|
18
|
-
const timer=setTimeout(()=>request.destroy(Error('image_header_timeout')),600000);timer.unref();
|
|
19
|
-
request.once('error',error=>{clearTimeout(timer);reject(error);});request.end(body);
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
|
-
export const gatewayRequest=(url,options)=>isImageRoute(new URL(url).pathname)?imageRequest(url,options):fetch(url,options);
|
package/io.mjs
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
export async function boundedJson(response,limit,code='invalid_remote_response'){
|
|
2
|
-
const chunks=[];let size=0;
|
|
3
|
-
for await(const chunk of response.body){size+=chunk.length;if(size>limit)throw Object.assign(Error(code),{status:502});chunks.push(chunk);}
|
|
4
|
-
try{return JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{throw Object.assign(Error(code),{status:502});}
|
|
5
|
-
}
|
package/license-client.mjs
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
import {execFileSync} from 'node:child_process';
|
|
5
|
-
import {fileURLToPath} from 'node:url';
|
|
6
|
-
import {LICENSE_PUBLIC_KEY,LICENSE_ORIGIN} from './product.mjs';
|
|
7
|
-
import {hash,publicText,signProof,verifyEnvelope} from './protocol.mjs';
|
|
8
|
-
import {boundedJson} from './io.mjs';
|
|
9
|
-
class RelayError extends Error{constructor(status,code){super(code);this.status=status;this.code=code;}}
|
|
10
|
-
const TERMINAL_DENIALS=new Set(['license_invalid_or_expired','device_revoked','activation_invalid','device_already_bound_or_revoked']);
|
|
11
|
-
const LOCAL_DENIALS=new Set(['activation_required','license_signature_invalid','clock_rollback_detected','license_invalid_or_expired','license_revoked']);
|
|
12
|
-
function serviceError(code,status=503,retryAfter=1){const error=new RelayError(status,code);error.transient=true;error.authoritative=false;error.retryAfter=retryAfter;return error;}
|
|
13
|
-
function normalizeFailure(error){
|
|
14
|
-
const code=typeof error?.code==='string'?error.code:error?.message;
|
|
15
|
-
if(code==='license_lease_expired')return serviceError(code,503,error?.retryAfter??1);
|
|
16
|
-
if(error?.transient||error?.status>=500||error?.status===429||['license_server_unavailable','license_response_invalid','license_service_busy','try_later','ingress_rate_limit','ingress_busy','ingress_capacity'].includes(code))return serviceError(code==='license_service_busy'||error?.status===429?'license_service_busy':'license_server_unavailable',error?.status===429?429:503,error?.retryAfter??1);
|
|
17
|
-
if(TERMINAL_DENIALS.has(code)||LOCAL_DENIALS.has(code)){const failure=new RelayError(403,code);failure.authoritative=error?.authoritative!==false;return failure;}
|
|
18
|
-
return serviceError('license_server_unavailable');
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function windowsDevice(action,value){
|
|
22
|
-
const executable=path.join(process.env.SystemRoot??'C:/Windows','System32/WindowsPowerShell/v1.0/powershell.exe');
|
|
23
|
-
try{return execFileSync(executable,['-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',fileURLToPath(new URL('./device.ps1',import.meta.url))],
|
|
24
|
-
{input:JSON.stringify({action,value}),encoding:'utf8',windowsHide:true,timeout:15000,stdio:['pipe','pipe','pipe']}).trim();}
|
|
25
|
-
catch{throw Error('device_protection_failed');}
|
|
26
|
-
}
|
|
27
|
-
export function machineIdentity(){
|
|
28
|
-
let value;
|
|
29
|
-
if(process.platform==='win32')value=windowsDevice('identity');
|
|
30
|
-
else if(process.platform==='darwin'){
|
|
31
|
-
const text=execFileSync('/usr/sbin/ioreg',['-rd1','-c','IOPlatformExpertDevice'],{encoding:'utf8',timeout:10000});
|
|
32
|
-
value=text.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)?.[1];
|
|
33
|
-
}else{
|
|
34
|
-
const file=fs.existsSync('/run/tdh-machine-id')?'/run/tdh-machine-id':'/etc/machine-id';
|
|
35
|
-
value=fs.readFileSync(file,'utf8').trim();
|
|
36
|
-
}
|
|
37
|
-
if(!value||value.length<16)throw Error('stable_machine_identity_required');
|
|
38
|
-
return hash('cu-web-device-v1:'+process.platform+':'+value.trim().toLowerCase());
|
|
39
|
-
}
|
|
40
|
-
export function loadDevice(state){
|
|
41
|
-
const deviceId=machineIdentity();let saved=state.get('license.device');
|
|
42
|
-
if(!saved){
|
|
43
|
-
const pair=crypto.generateKeyPairSync('ed25519');
|
|
44
|
-
const raw=pair.privateKey.export({type:'pkcs8',format:'der'}).toString('base64');
|
|
45
|
-
saved={deviceId,publicKey:publicText(pair.privateKey),format:process.platform==='win32'?'dpapi':'owner-permissions',
|
|
46
|
-
privateKey:process.platform==='win32'?windowsDevice('protect',raw):raw};
|
|
47
|
-
state.set('license.device',saved);
|
|
48
|
-
}
|
|
49
|
-
if(saved.deviceId!==deviceId)throw Error('activation_belongs_to_another_device');
|
|
50
|
-
const raw=saved.format==='dpapi'?windowsDevice('unprotect',saved.privateKey):saved.privateKey;
|
|
51
|
-
const privateKey=crypto.createPrivateKey({key:Buffer.from(raw,'base64'),format:'der',type:'pkcs8'});
|
|
52
|
-
if(publicText(privateKey)!==saved.publicKey)throw Error('device_key_mismatch');
|
|
53
|
-
return {deviceId,publicKey:saved.publicKey,privateKey};
|
|
54
|
-
}
|
|
55
|
-
export async function requestLicense(route,body,{request=fetch}={}){
|
|
56
|
-
if(!['challenge','activate','renew'].includes(route))throw Error('invalid_license_route');
|
|
57
|
-
let response;
|
|
58
|
-
try{response=await request(LICENSE_ORIGIN+'/api/licenses/'+route,{method:'POST',redirect:'error',
|
|
59
|
-
headers:{'content-type':'application/json'},body:JSON.stringify(body),signal:AbortSignal.timeout(10000)});}
|
|
60
|
-
catch{throw serviceError('license_server_unavailable');}
|
|
61
|
-
if(response.status===429||response.status>=500||response.status===408){response.body?.cancel().catch(()=>{});const value=Number(response.headers.get('retry-after'));throw serviceError(response.status===429?'license_service_busy':'license_server_unavailable',response.status===429?429:503,Number.isFinite(value)&&value>0?Math.min(60,Math.ceil(value)):1);}
|
|
62
|
-
let data;try{data=await boundedJson(response,16384,'license_response_invalid');}catch{throw serviceError('license_response_invalid');}
|
|
63
|
-
if(!response.ok){const error=new RelayError(403,typeof data.error==='string'&&/^[a-z_]+$/.test(data.error)?data.error:'license_request_rejected');error.authoritative=true;throw error;}
|
|
64
|
-
return data;
|
|
65
|
-
}
|
|
66
|
-
export class LicenseClient {
|
|
67
|
-
constructor(state,{publicKey=LICENSE_PUBLIC_KEY,request=requestLicense,device,now=Date.now}={}){
|
|
68
|
-
this.state=state;this.publicKey=publicKey;this.request=request;this.device=device??loadDevice(state);this.now=now;this.pending=null;this.nextAttempt=0;this.lastFailure=null;this.failures=0;
|
|
69
|
-
this.wallStart=now();this.monotonicStart=performance.now();
|
|
70
|
-
}
|
|
71
|
-
check(envelope=this.state.get('license.lease'),feature='desktop',{allowExpired=false}={}){
|
|
72
|
-
let claims;try{claims=verifyEnvelope(envelope,this.publicKey);}catch{throw Error('license_signature_invalid');}
|
|
73
|
-
const now=this.now(),last=this.state.get('license.clock')??0;
|
|
74
|
-
if(now<last-30000||now<this.wallStart+performance.now()-this.monotonicStart-30000)throw Error('clock_rollback_detected');
|
|
75
|
-
if(claims.kind!=='tdh-relay-lease-v1'||claims.product!=='tdh-relay'||claims.deviceId!==this.device.deviceId||
|
|
76
|
-
claims.keyHash!==hash(this.device.publicKey)||!Array.isArray(claims.features)||!claims.features.includes(feature)||!Number.isFinite(claims.licenseExpiresAt)||
|
|
77
|
-
!Number.isFinite(claims.issuedAt)||!Number.isFinite(claims.expiresAt)||claims.issuedAt>now+60000||
|
|
78
|
-
claims.expiresAt<=claims.issuedAt||claims.expiresAt>claims.issuedAt+600000||claims.expiresAt>claims.licenseExpiresAt)throw Error('license_invalid_or_expired');
|
|
79
|
-
if(!allowExpired&&claims.expiresAt<=now)throw serviceError('license_lease_expired');
|
|
80
|
-
if(now>last+10000)this.state.set('license.clock',now);
|
|
81
|
-
return claims;
|
|
82
|
-
}
|
|
83
|
-
async proof(action){
|
|
84
|
-
const challenge=await this.request('challenge',{deviceId:this.device.deviceId,publicKey:this.device.publicKey});
|
|
85
|
-
if(typeof challenge.id!=='string'||typeof challenge.nonce!=='string'||challenge.nonce.length>100)throw Error('challenge_invalid');
|
|
86
|
-
return {challengeId:challenge.id,proof:signProof(action,challenge.nonce,this.device.deviceId,this.device.publicKey,this.device.privateKey)};
|
|
87
|
-
}
|
|
88
|
-
save(response){
|
|
89
|
-
const claims=this.check(response.lease);
|
|
90
|
-
this.state.transaction(()=>{this.state.set('license.lease',response.lease);this.state.set('license.activation',claims.activationId);this.state.set('license.denied',false);});
|
|
91
|
-
this.nextAttempt=0;this.lastFailure=null;this.failures=0;return claims;
|
|
92
|
-
}
|
|
93
|
-
async activate(key){
|
|
94
|
-
if(typeof key!=='string'||!/^TDH-[A-Za-z0-9_-]{32}$/.test(key.trim()))throw Error('invalid_activation_key');
|
|
95
|
-
key=key.trim();const proof=await this.proof('activate:'+hash(key));
|
|
96
|
-
return this.save(await this.request('activate',{...proof,key}));
|
|
97
|
-
}
|
|
98
|
-
async renew(){
|
|
99
|
-
if(this.pending)return this.pending;
|
|
100
|
-
const activationId=this.state.get('license.activation');if(!activationId)throw Error('activation_required');
|
|
101
|
-
this.pending=(async()=>{
|
|
102
|
-
try{
|
|
103
|
-
for(let attempt=0;;attempt++){
|
|
104
|
-
const proof=await this.proof('renew:'+activationId);
|
|
105
|
-
const response=await this.request('renew',{...proof,activationId});
|
|
106
|
-
try{return this.save(response);}
|
|
107
|
-
catch(error){error.authoritative=false;if(error.code!=='license_lease_expired'||attempt>=1)throw error;}
|
|
108
|
-
}
|
|
109
|
-
}catch(error){
|
|
110
|
-
const failure=normalizeFailure(error);this.lastFailure=failure;
|
|
111
|
-
if(TERMINAL_DENIALS.has(failure.code)&&failure.authoritative){
|
|
112
|
-
this.state.set('license.denied',true);this.state.set('license.lease',null);
|
|
113
|
-
}
|
|
114
|
-
this.failures=Math.min(this.failures+1,6);const delay=failure.transient?Math.max((failure.retryAfter??1)*1000,Math.min(30000,1000*2**(this.failures-1))):30000;
|
|
115
|
-
this.nextAttempt=this.now()+delay;failure.retryAfter=Math.ceil(delay/1000);throw failure;
|
|
116
|
-
}finally{this.pending=null;}
|
|
117
|
-
})();
|
|
118
|
-
return this.pending;
|
|
119
|
-
}
|
|
120
|
-
async ensure(feature='desktop',{online=false}={}){
|
|
121
|
-
let current;try{if(!this.state.get('license.denied'))current=this.check(undefined,feature);}catch{}
|
|
122
|
-
if(online||!current||current.expiresAt-this.now()<300000){
|
|
123
|
-
if(this.now()>=this.nextAttempt){
|
|
124
|
-
try{await this.renew();}
|
|
125
|
-
catch(error){if(!current||this.state.get('license.denied')||online)throw normalizeFailure(error);}
|
|
126
|
-
}else if((!current||online)&&this.lastFailure)throw this.lastFailure;
|
|
127
|
-
}
|
|
128
|
-
if(this.state.get('license.denied'))throw new RelayError(403,'license_revoked');
|
|
129
|
-
try{return this.check(undefined,feature);}catch(error){if(this.lastFailure?.transient)throw this.lastFailure;throw normalizeFailure(error);}
|
|
130
|
-
}
|
|
131
|
-
async forRequest(feature='desktop'){
|
|
132
|
-
try{if(!this.state.get('license.denied'))return this.check(undefined,feature,{allowExpired:true});}catch{}
|
|
133
|
-
return this.ensure(feature);
|
|
134
|
-
}
|
|
135
|
-
acceptLease(encoded){
|
|
136
|
-
if(typeof encoded!=='string'||encoded.length>4096||this.state.get('license.denied'))return false;
|
|
137
|
-
try{
|
|
138
|
-
const envelope=JSON.parse(Buffer.from(encoded,'base64url')),claims=this.check(envelope);
|
|
139
|
-
if(claims.activationId!==this.state.get('license.activation'))return false;
|
|
140
|
-
const current=this.check(undefined,'desktop',{allowExpired:true});
|
|
141
|
-
if(claims.licenseId!==current.licenseId||claims.issuedAt<current.issuedAt||claims.issuedAt===current.issuedAt&&claims.expiresAt<=current.expiresAt)return false;
|
|
142
|
-
this.save({lease:envelope});return true;
|
|
143
|
-
}catch{return false;}
|
|
144
|
-
}
|
|
145
|
-
status(){
|
|
146
|
-
try{const c=this.check();return {active:!this.state.get('license.denied'),licenseId:c.licenseId,activationId:c.activationId,
|
|
147
|
-
leaseUntil:new Date(c.expiresAt).toISOString(),expiresAt:new Date(c.licenseExpiresAt).toISOString()};}
|
|
148
|
-
catch{return {active:false,activationRequired:!this.state.get('license.activation')};}
|
|
149
|
-
}
|
|
150
|
-
}
|
package/product.mjs
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
export const VERSION='0.1.0-web.12';
|
|
2
|
-
export const PRODUCT='codexunlimit-connector';
|
|
3
|
-
export const DEFAULT_PORT=18457;
|
|
4
|
-
export const LICENSE_ORIGIN='https://codexunlimit.tudonghoa.me';
|
|
5
|
-
export const LICENSE_PUBLIC_KEY='MCowBQYDK2VwAyEAnOW-TAyqv57sVI-ZOW3m0jurLc6hHzzeJ-KbiU_9hxk';
|
package/protocol.mjs
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
export const hash=value=>crypto.createHash('sha256').update(value).digest('hex');
|
|
4
|
-
export const publicText=key=>crypto.createPublicKey(key).export({type:'spki',format:'der'}).toString('base64url');
|
|
5
|
-
export function readPublic(text){
|
|
6
|
-
if(typeof text!=='string'||text.length>150)throw Error('invalid_device_key');
|
|
7
|
-
const key=crypto.createPublicKey({key:Buffer.from(text,'base64url'),type:'spki',format:'der'});
|
|
8
|
-
if(key.asymmetricKeyType!=='ed25519'&&!(key.asymmetricKeyType==='ec'&&key.asymmetricKeyDetails?.namedCurve==='prime256v1'))throw Error('invalid_device_key');
|
|
9
|
-
return key;
|
|
10
|
-
}
|
|
11
|
-
export function signEnvelope(value,privateKey){
|
|
12
|
-
const payload=Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
13
|
-
return {payload,signature:crypto.sign(null,Buffer.from(payload),privateKey).toString('base64url')};
|
|
14
|
-
}
|
|
15
|
-
export function verifyEnvelope(envelope,publicKey){
|
|
16
|
-
if(!envelope||typeof envelope.payload!=='string'||envelope.payload.length>100000||
|
|
17
|
-
typeof envelope.signature!=='string'||!crypto.verify(null,Buffer.from(envelope.payload),readPublic(publicKey),Buffer.from(envelope.signature,'base64url')))throw Error('signature_invalid');
|
|
18
|
-
return JSON.parse(Buffer.from(envelope.payload,'base64url'));
|
|
19
|
-
}
|
|
20
|
-
export function proofText(action,nonce,deviceId,publicKey){
|
|
21
|
-
return Buffer.from(JSON.stringify(['tdh-relay-license-v1',action,nonce,deviceId,publicKey]));
|
|
22
|
-
}
|
|
23
|
-
export function signProof(action,nonce,deviceId,publicKey,privateKey){
|
|
24
|
-
return crypto.sign(privateKey.asymmetricKeyType==='ec'?'sha256':null,proofText(action,nonce,deviceId,publicKey),privateKey).toString('base64url');
|
|
25
|
-
}
|
|
26
|
-
export function verifyProof(action,nonce,deviceId,publicKey,signature){
|
|
27
|
-
try{const key=readPublic(publicKey);return crypto.verify(key.asymmetricKeyType==='ec'?'sha256':null,proofText(action,nonce,deviceId,publicKey),key,Buffer.from(signature,'base64url'));}catch{return false;}
|
|
28
|
-
}
|
package/quota.mjs
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
export function accountIdentity(credentials){
|
|
4
|
-
let claims={};try{claims=JSON.parse(Buffer.from(credentials.token.split('.')[1],'base64url'));}catch{}
|
|
5
|
-
const auth=claims['https://api.openai.com/auth']??{},profile=claims['https://api.openai.com/profile']??{};
|
|
6
|
-
const accountId=credentials.accountId??auth.chatgpt_account_id,subject=typeof claims.sub==='string'?claims.sub:'';
|
|
7
|
-
const email=typeof (claims.email??profile.email)==='string'?(claims.email??profile.email).slice(0,254):null;
|
|
8
|
-
return {ref:crypto.createHash('sha256').update(subject||accountId?JSON.stringify([subject,accountId??'']):credentials.token).digest('hex'),email,plan:typeof auth.chatgpt_plan_type==='string'?auth.chatgpt_plan_type.slice(0,40):null,accountId};
|
|
9
|
-
}
|
|
10
|
-
function windowValue(minutes,used,resetAt){
|
|
11
|
-
if(![300,10080].includes(minutes)||typeof used!=='number'||!Number.isFinite(used)||used<0||used>100)return null;
|
|
12
|
-
return {minutes,used,resetAt:Number.isSafeInteger(resetAt)&&resetAt>0&&resetAt<8640000000000000?resetAt:null};
|
|
13
|
-
}
|
|
14
|
-
export function headerWindows(headers,now=Date.now()){
|
|
15
|
-
const number=value=>typeof value==='string'&&/^\d+(?:\.\d+)?$/.test(value)&&Number.isFinite(Number(value))?Number(value):null;
|
|
16
|
-
return ['primary','secondary'].map(prefix=>{
|
|
17
|
-
const get=name=>number(headers.get('x-codex-'+prefix+'-'+name));
|
|
18
|
-
const reset=get('reset-at'),after=get('reset-after-seconds');return windowValue(get('window-minutes'),get('used-percent'),reset!==null?reset*1000:after!==null?now+after*1000:null);
|
|
19
|
-
}).filter(Boolean);
|
|
20
|
-
}
|
|
21
|
-
export function rateLimitWindows(result){
|
|
22
|
-
const bucket=result?.rateLimitsByLimitId?.codex??result?.rateLimits;
|
|
23
|
-
if(!bucket||bucket.limitId&&bucket.limitId!=='codex')return [];
|
|
24
|
-
return [bucket.primary,bucket.secondary].filter(Boolean).map(window=>windowValue(window.windowDurationMins,window.usedPercent,typeof window.resetsAt==='number'?window.resetsAt*1000:null)).filter(Boolean);
|
|
25
|
-
}
|
|
26
|
-
export class LocalQuota{
|
|
27
|
-
constructor(state,{now=Date.now}={}){this.db=state.db;this.now=now;this.db.exec('CREATE TABLE IF NOT EXISTS local_account_quota(account_ref TEXT NOT NULL,minutes INTEGER NOT NULL,used REAL NOT NULL,reset_at INTEGER,observed_at INTEGER NOT NULL,source TEXT NOT NULL,PRIMARY KEY(account_ref,minutes))');}
|
|
28
|
-
save(ref,windows,source){
|
|
29
|
-
const now=this.now();for(const window of windows)this.db.prepare('INSERT INTO local_account_quota VALUES(?,?,?,?,?,?) ON CONFLICT(account_ref,minutes) DO UPDATE SET used=excluded.used,reset_at=excluded.reset_at,observed_at=excluded.observed_at,source=excluded.source WHERE excluded.observed_at>=observed_at').run(ref,window.minutes,window.used,window.resetAt,now,source);
|
|
30
|
-
this.db.prepare('DELETE FROM local_account_quota WHERE observed_at<?').run(now-30*86400000);
|
|
31
|
-
}
|
|
32
|
-
observe(credentials,response){if(response.ok||response.status===429)this.save(accountIdentity(credentials).ref,headerWindows(response.headers,this.now()),'response_headers');}
|
|
33
|
-
read(ref){return this.db.prepare('SELECT minutes,used,reset_at,observed_at,source FROM local_account_quota WHERE account_ref=? ORDER BY minutes').all(ref).map(window=>({...window,stale:this.now()-window.observed_at>15*60000||window.reset_at!==null&&window.reset_at<=this.now()}));}
|
|
34
|
-
}
|
package/relay.mjs
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import http from 'node:http';
|
|
2
|
-
import crypto from 'node:crypto';
|
|
3
|
-
import {Readable} from 'node:stream';
|
|
4
|
-
import {pipeline} from 'node:stream/promises';
|
|
5
|
-
import {PRODUCT,VERSION,LICENSE_ORIGIN} from './product.mjs';
|
|
6
|
-
import {hash} from './protocol.mjs';
|
|
7
|
-
import {isImageRoute,gatewayRequest} from './image-http.mjs';
|
|
8
|
-
import {boundedJson} from './io.mjs';
|
|
9
|
-
|
|
10
|
-
const CONTEXT_HEADERS=['content-type','content-encoding','chatgpt-account-id','session_id','session-id','conversation_id','originator','version','openai-beta','x-codex-beta-features','x-codex-parent-thread-id','x-codex-turn-metadata','x-codex-turn-state'];
|
|
11
|
-
export function signedHeaders(license,state,method,route,authorization,body=Buffer.alloc(0),headers=new Headers(),timestamp=Date.now()){
|
|
12
|
-
const envelope=state.get('license.lease');license.check(envelope,'desktop',{allowExpired:true});
|
|
13
|
-
const nonce=crypto.randomBytes(24).toString('base64url'),key=license.device.privateKey;
|
|
14
|
-
const bodyHash=hash(body??Buffer.alloc(0));
|
|
15
|
-
const contextHeaders=isImageRoute(route)?[...CONTEXT_HEADERS,'x-codex-image-turn-id']:CONTEXT_HEADERS;
|
|
16
|
-
const text=Buffer.from(JSON.stringify(['cu-request-v2',envelope.payload,method,route,timestamp,nonce,hash(authorization),bodyHash,contextHeaders.map(name=>[name,headers.get(name)??''])]));
|
|
17
|
-
return {'x-cu-lease':Buffer.from(JSON.stringify(envelope)).toString('base64url'),'x-cu-time':String(timestamp),'x-cu-nonce':nonce,
|
|
18
|
-
'x-cu-proof-version':'2','x-cu-body-sha256':bodyHash,
|
|
19
|
-
'x-cu-proof':crypto.sign(key.asymmetricKeyType==='ec'?'sha256':null,text,key).toString('base64url')};
|
|
20
|
-
}
|
|
21
|
-
const connectionError=(status,code)=>Object.assign(Error(code),{status,code});
|
|
22
|
-
const requestId=value=>typeof value==='string'&&/^[a-f0-9-]{36}$/i.test(value)?value:null;
|
|
23
|
-
function waitForRetry(ms,signal){
|
|
24
|
-
return new Promise((resolve,reject)=>{
|
|
25
|
-
if(signal.aborted)return reject(signal.reason);
|
|
26
|
-
const finish=error=>{clearTimeout(timer);signal.removeEventListener('abort',abort);error?reject(error):resolve();};
|
|
27
|
-
const abort=()=>finish(signal.reason??connectionError(499,'client_cancelled'));
|
|
28
|
-
const timer=setTimeout(()=>finish(),ms);signal.addEventListener('abort',abort,{once:true});
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
function connectionQueue(limit,{maxQueued,queueTimeoutMs}){
|
|
32
|
-
const active={inference:0,models:0},pending=[];let capacity=Math.min(2,limit);
|
|
33
|
-
const drain=()=>{for(let i=0;i<pending.length;){const item=pending[i];if(active[item.lane]<(item.lane==='models'?1:capacity)){pending.splice(i,1);item.grant();}else i++;}};
|
|
34
|
-
return {
|
|
35
|
-
observe(response){const value=response.headers.get('x-cu-max-concurrent');if(value&&/^[1-9][0-9]{0,2}$/.test(value)){capacity=Math.min(limit,Number(value));drain();}},
|
|
36
|
-
acquire(lane,signal){return new Promise((resolve,reject)=>{
|
|
37
|
-
if(signal.aborted)return reject(signal.reason);
|
|
38
|
-
if(pending.length>=maxQueued)return reject(connectionError(429,'local_queue_full'));
|
|
39
|
-
let timer,settled=false;
|
|
40
|
-
const clean=()=>{clearTimeout(timer);signal.removeEventListener('abort',abort);};
|
|
41
|
-
const fail=error=>{if(settled)return;settled=true;clean();const i=pending.indexOf(item);if(i>=0)pending.splice(i,1);reject(error);};
|
|
42
|
-
const abort=()=>fail(signal.reason??connectionError(499,'client_cancelled'));
|
|
43
|
-
const item={lane,grant(){if(settled)return;settled=true;clean();active[lane]++;let released=false;resolve(()=>{if(released)return;released=true;active[lane]--;drain();});}};
|
|
44
|
-
signal.addEventListener('abort',abort,{once:true});timer=setTimeout(()=>fail(connectionError(429,'local_queue_timeout')),queueTimeoutMs);
|
|
45
|
-
pending.push(item);drain();
|
|
46
|
-
})}
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
export function createConnector(state,{license,localApp,pool,onStop=()=>{},prepareStop=()=>{},origin=LICENSE_ORIGIN,request=gatewayRequest,maxBufferedBytes=128*1024*1024,bodyTimeoutMs=60000,maxQueued=32,queueTimeoutMs=15000,retryBudgetMs=15000,retryDelayMs=1000}={}){
|
|
50
|
-
if(new URL(origin).protocol!=='https:')throw Error('https_required');
|
|
51
|
-
let buffered=0,clockOffsetMs=0;
|
|
52
|
-
const queue=connectionQueue(state.settings.get('relay.max_concurrency')??4,{maxQueued,queueTimeoutMs});
|
|
53
|
-
const server=http.createServer(async(req,res)=>{
|
|
54
|
-
const controller=new AbortController(),started=Date.now(),localRequestId=crypto.randomUUID();
|
|
55
|
-
let status=503,reserved=0,bodyTimer,release,tracked=false,stage='routing',errorCode=null,serverRequestId=null,attempts=0,queueMs=0,uploadMs=0,proofAgeMs=null;
|
|
56
|
-
req.on('aborted',()=>controller.abort());res.on('close',()=>{if(!res.writableFinished)controller.abort();});
|
|
57
|
-
const json=(code,data)=>{status=code;errorCode=data?.error?.code??errorCode;res.writeHead(code,{'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify(data));};
|
|
58
|
-
try{
|
|
59
|
-
if(!['127.0.0.1:'+server.address().port,'localhost:'+server.address().port].includes(req.headers.host))return json(403,{error:{code:'local_request_denied'}});
|
|
60
|
-
if(localApp&&await localApp(req,res,server))return;
|
|
61
|
-
if(req.headers.origin)return json(403,{error:{code:'local_request_denied'}});
|
|
62
|
-
if(req.method==='GET'&&req.url==='/healthz')return json(200,{ok:true,service:PRODUCT,version:VERSION,mode:'desktop'});
|
|
63
|
-
if(req.method==='GET'&&req.url==='/control/identity'){
|
|
64
|
-
const nonce=String(req.headers['x-cu-nonce']??'');if(!/^[a-f0-9]{48}$/.test(nonce))return json(400,{error:{code:'invalid_nonce'}});
|
|
65
|
-
return json(200,{proof:crypto.createHmac('sha256',state.get('control_token')).update('cu-instance:'+nonce).digest('hex')});
|
|
66
|
-
}
|
|
67
|
-
if(req.method==='POST'&&req.url==='/control/stop'){
|
|
68
|
-
const a=Buffer.from(state.get('control_token')??''),b=Buffer.from(String(req.headers['x-cu-control']??''));
|
|
69
|
-
if(!a.length||a.length!==b.length||!crypto.timingSafeEqual(a,b))return json(403,{error:{code:'control_denied'}});
|
|
70
|
-
const mode=req.headers['x-cu-stop-mode']??'exit';if(!['exit','update'].includes(mode))return json(400,{error:{code:'invalid_stop_mode'}});
|
|
71
|
-
await prepareStop(mode);json(200,{stopped:true});setImmediate(onStop);return;
|
|
72
|
-
}
|
|
73
|
-
tracked=/^\/v1\/(?:responses(?:\/compact)?|models(?:\?.*)?|images\/(?:generations|edits))$/.test(req.url)||isImageRoute(req.url);
|
|
74
|
-
if(tracked)res.setHeader('x-cu-local-request-id',localRequestId);
|
|
75
|
-
if(!(req.method==='GET'&&/^\/v1\/models(?:\?client_version=[a-zA-Z0-9._%-]{1,100})?$/.test(req.url))&&!(req.method==='POST'&&(['/v1/responses','/v1/responses/compact'].includes(req.url)||isImageRoute(req.url))))return json(404,{error:{code:'route_not_found'}});
|
|
76
|
-
let authorization=req.headers.authorization??'';
|
|
77
|
-
const selectedAccount=pool?.active();
|
|
78
|
-
if(!/^Bearer [^\s]{16,32768}$/.test(authorization)||authorization.startsWith('Bearer sk-'))return json(401,{error:{code:'chatgpt_sign_in_required'}});
|
|
79
|
-
stage='queue';const queuedAt=Date.now();
|
|
80
|
-
release=await queue.acquire(req.method==='GET'?'models':'inference',controller.signal);queueMs=Date.now()-queuedAt;
|
|
81
|
-
controller.signal.throwIfAborted();
|
|
82
|
-
const limit=state.settings.get('relay.max_body_bytes');if(Number(req.headers['content-length']??0)>limit){req.resume();return json(413,{error:{code:'request_too_large'}});}
|
|
83
|
-
const length=Number(req.headers['content-length']??limit);
|
|
84
|
-
if(!Number.isSafeInteger(length)||length<0)return json(400,{error:{code:'invalid_body_length'}});
|
|
85
|
-
// Account for input chunks and their contiguous copy together.
|
|
86
|
-
reserved=req.method==='POST'?2*length:0;
|
|
87
|
-
if(buffered+reserved>maxBufferedBytes){reserved=0;req.resume();return json(503,{error:{code:'client_memory_busy'}});}
|
|
88
|
-
buffered+=reserved;stage='upload';const uploadStarted=Date.now();
|
|
89
|
-
bodyTimer=setTimeout(()=>{controller.abort(connectionError(408,'local_upload_timeout'));req.destroy();},bodyTimeoutMs);bodyTimer.unref();
|
|
90
|
-
const headers=new Headers();
|
|
91
|
-
for(const name of ['authorization','content-type','content-encoding','chatgpt-account-id','session_id','session-id','conversation_id','originator','user-agent','version','openai-beta','x-codex-beta-features','x-codex-parent-thread-id','x-codex-turn-metadata','x-codex-turn-state','x-codex-image-turn-id'])if(typeof req.headers[name]==='string')headers.set(name,req.headers[name]);
|
|
92
|
-
const localToken=Buffer.from(String(req.headers['x-cu-control']??'')),expectedToken=Buffer.from(state.get('control_token')??'');
|
|
93
|
-
const fromLocalApp=expectedToken.length>0&&localToken.length===expectedToken.length&&crypto.timingSafeEqual(localToken,expectedToken);
|
|
94
|
-
if(pool&&selectedAccount!=='codex'&&!fromLocalApp){
|
|
95
|
-
if(!state.get('web.privacyAcceptedAt'))throw Object.assign(Error('privacy_required'),{status:403});
|
|
96
|
-
const credentials=await pool.credentials(selectedAccount);authorization='Bearer '+credentials.token;headers.set('authorization',authorization);headers.delete('chatgpt-account-id');if(credentials.accountId)headers.set('chatgpt-account-id',credentials.accountId);
|
|
97
|
-
}
|
|
98
|
-
// Bound input in the connector too, before forwarding. No request content is persisted.
|
|
99
|
-
const chunks=[];let size=0;for await(const chunk of req){size+=chunk.length;if(size>limit)throw Object.assign(Error('request_too_large'),{status:413});chunks.push(chunk);}
|
|
100
|
-
clearTimeout(bodyTimer);uploadMs=Date.now()-uploadStarted;const payload=req.method==='POST'?Buffer.concat(chunks):undefined;chunks.length=0;
|
|
101
|
-
// Queueing and upload finish before the timestamp/nonce are minted. A retry
|
|
102
|
-
// is allowed only after an authenticated gateway's explicit pre-upstream denial.
|
|
103
|
-
stage='license';controller.signal.throwIfAborted();await license.forRequest('desktop');controller.signal.throwIfAborted();
|
|
104
|
-
const retryDeadline=Date.now()+retryBudgetMs;let response,proofRetries=0;
|
|
105
|
-
for(;;){
|
|
106
|
-
stage='gateway';const sentAt=Date.now();
|
|
107
|
-
for(const [key,value]of Object.entries(signedHeaders(license,state,req.method,req.url,authorization,payload,headers,Math.round(sentAt+clockOffsetMs))))headers.set(key,value);
|
|
108
|
-
attempts++;
|
|
109
|
-
response=await request(origin+req.url,{method:req.method,headers,body:payload,redirect:'error',signal:AbortSignal.any([controller.signal,AbortSignal.timeout(isImageRoute(req.url)?630000:1800000)])});
|
|
110
|
-
queue.observe(response);serverRequestId=requestId(response.headers.get('x-cu-request-id'));
|
|
111
|
-
const receivedAt=Date.now(),serverDate=Date.parse(response.headers.get('date')??'');
|
|
112
|
-
if(Number.isFinite(serverDate)){
|
|
113
|
-
proofAgeMs=serverDate-Number(headers.get('x-cu-time'));
|
|
114
|
-
const offset=serverDate+500-(sentAt+receivedAt)/2;
|
|
115
|
-
if(serverRequestId&&receivedAt-sentAt<5000&&Math.abs(offset)<=300000)clockOffsetMs=Math.round(offset);
|
|
116
|
-
}
|
|
117
|
-
let gatewayCode=null;
|
|
118
|
-
if(serverRequestId&&response.status>=400&&(response.headers.get('content-type')??'').toLowerCase().startsWith('application/json')){
|
|
119
|
-
const data=await boundedJson(response,32768,'gateway_response_invalid').catch(()=>{throw connectionError(502,'gateway_response_invalid');});
|
|
120
|
-
gatewayCode=typeof data?.error?.code==='string'&&/^[a-z_]{1,80}$/.test(data.error.code)?data.error.code:null;
|
|
121
|
-
response=new Response(JSON.stringify(data),{status:response.status,headers:response.headers});
|
|
122
|
-
}
|
|
123
|
-
const stale=response.status===408&&gatewayCode==='request_proof_expired'&&proofRetries<1;
|
|
124
|
-
const busy=response.status===429&&gatewayCode==='license_concurrency_limit';
|
|
125
|
-
const requestedDelay=Number(response.headers.get('retry-after'))*1000;
|
|
126
|
-
const delay=stale?0:Math.max(retryDelayMs,Number.isFinite(requestedDelay)&&requestedDelay>0?requestedDelay:0);
|
|
127
|
-
if((stale||busy)&&attempts<8&&Date.now()+delay<retryDeadline){
|
|
128
|
-
await response.body?.cancel();if(stale)proofRetries++;
|
|
129
|
-
stage='retry_wait';await waitForRetry(delay,controller.signal);continue;
|
|
130
|
-
}
|
|
131
|
-
errorCode=gatewayCode;break;
|
|
132
|
-
}
|
|
133
|
-
license.acceptLease(response.headers.get('x-cu-lease'));
|
|
134
|
-
pool?.observe({token:authorization.slice(7),accountId:headers.get('chatgpt-account-id')??undefined},response);
|
|
135
|
-
if(response.status>=400&&!(response.headers.get('content-type')??'').toLowerCase().startsWith('application/json')){
|
|
136
|
-
response.body?.cancel().catch(()=>{});status=response.status===429?429:503;const wait=Number(response.headers.get('retry-after'));res.setHeader('retry-after',String(Number.isFinite(wait)&&wait>0?Math.min(60,Math.ceil(wait)):3));
|
|
137
|
-
return json(status,{error:{code:'service_unavailable',message:'Máy chủ tạm thời chưa phản hồi hợp lệ. Không tự gửi lại tác vụ đang chạy; kiểm tra kết nối rồi thử lại.'}});
|
|
138
|
-
}
|
|
139
|
-
stage='stream';status=response.status;const outgoing={'cache-control':'no-store'};
|
|
140
|
-
for(const [key,value]of response.headers)if(['content-type','retry-after','x-cu-request-id'].includes(key)||key.startsWith('x-codex-')||isImageRoute(req.url)&&key==='x-relay-policy-mode')outgoing[key]=value;
|
|
141
|
-
res.writeHead(status,outgoing);if(response.body)await pipeline(Readable.fromWeb(response.body),res,{signal:controller.signal});else res.end();stage='complete';
|
|
142
|
-
}catch(error){
|
|
143
|
-
const reason=controller.signal.reason?.status?controller.signal.reason:error;
|
|
144
|
-
status=reason?.status??(controller.signal.aborted?499:502);
|
|
145
|
-
errorCode=(typeof reason?.code==='string'?reason.code.toLowerCase():typeof reason?.cause?.code==='string'?reason.cause.code.toLowerCase():null)??(controller.signal.aborted?'client_cancelled':stage==='stream'?'stream_interrupted':'connection_failed');
|
|
146
|
-
if(res.headersSent||status===499||res.destroyed)res.destroy();
|
|
147
|
-
else{if([429,503].includes(status))res.setHeader('retry-after',String(reason?.retryAfter??3));json(status,{error:{code:errorCode}});}
|
|
148
|
-
}finally{
|
|
149
|
-
if(stage==='queue')queueMs=Date.now()-started;
|
|
150
|
-
if(stage==='upload')uploadMs=Math.max(0,Date.now()-started-queueMs);
|
|
151
|
-
clearTimeout(bodyTimer);controller.abort();buffered-=reserved;release?.();
|
|
152
|
-
if(tracked){try{state.record({status,duration:Date.now()-started,requestId:localRequestId,serverRequestId,route:req.url.split('?')[0],method:req.method,errorCode,stage,queueMs,uploadMs,attempts,proofAgeMs});}catch{}}
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
server.headersTimeout=15000;server.requestTimeout=bodyTimeoutMs+queueTimeoutMs;return server;
|
|
156
|
-
}
|
package/state.mjs
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
import {DatabaseSync} from 'node:sqlite';
|
|
5
|
-
import {DEFAULT_PORT,VERSION} from './product.mjs';
|
|
6
|
-
export class ClientState{
|
|
7
|
-
constructor(home){
|
|
8
|
-
this.home=path.resolve(home);fs.mkdirSync(path.join(this.home,'data'),{recursive:true,mode:0o700});
|
|
9
|
-
this.db=new DatabaseSync(path.join(this.home,'data','connector.sqlite'));
|
|
10
|
-
this.db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS desktop_usage(id INTEGER PRIMARY KEY,ts INTEGER NOT NULL,status INTEGER NOT NULL,duration_ms INTEGER NOT NULL);');
|
|
11
|
-
this.db.exec('CREATE TABLE IF NOT EXISTS connection_diagnostics(id INTEGER PRIMARY KEY,at INTEGER NOT NULL,request_id TEXT,server_request_id TEXT,route TEXT,method TEXT,status INTEGER,error_code TEXT,stage TEXT,duration_ms INTEGER,queue_ms INTEGER,upload_ms INTEGER,attempts INTEGER,proof_age_ms INTEGER);');
|
|
12
|
-
this.settings={get:key=>this.get(key),set:(key,value)=>this.set(key,value)};
|
|
13
|
-
}
|
|
14
|
-
get(key){const row=this.db.prepare('SELECT value FROM settings WHERE key=?').get(key);return row?JSON.parse(row.value):null;}
|
|
15
|
-
set(key,value){this.db.prepare('INSERT INTO settings VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key,JSON.stringify(value));}
|
|
16
|
-
transaction(action){this.db.exec('BEGIN IMMEDIATE');try{const value=action();this.db.exec('COMMIT');return value;}catch(error){this.db.exec('ROLLBACK');throw error;}}
|
|
17
|
-
initialize({port=DEFAULT_PORT,codexHome}={}){
|
|
18
|
-
if(!this.get('initialized')){this.set('relay.port',port);this.set('control_token',crypto.randomBytes(32).toString('base64url'));this.set('initialized',true);}
|
|
19
|
-
this.set('relay.max_body_bytes',64*1024*1024);this.set('relay.max_concurrency',4);this.set('version',VERSION);
|
|
20
|
-
if(codexHome)this.set('codex_home',path.resolve(codexHome));
|
|
21
|
-
}
|
|
22
|
-
record(row){
|
|
23
|
-
const now=Date.now();this.db.prepare('INSERT INTO desktop_usage(ts,status,duration_ms) VALUES(?,?,?)').run(now,row.status,row.duration);this.db.prepare('DELETE FROM desktop_usage WHERE id<(SELECT MAX(id)-2000 FROM desktop_usage)').run();
|
|
24
|
-
if(!/^\/(?:v1\/)?(?:responses(?:\/compact)?|models|images\/(?:generations|edits))$/.test(row.route??''))return;
|
|
25
|
-
const code=value=>typeof value==='string'&&/^[a-z_]{1,80}$/.test(value)?value:null;
|
|
26
|
-
const id=value=>typeof value==='string'&&/^[a-f0-9-]{36}$/i.test(value)?value:null;
|
|
27
|
-
const number=value=>Number.isSafeInteger(value)?value:null;
|
|
28
|
-
this.db.prepare('INSERT INTO connection_diagnostics(at,request_id,server_request_id,route,method,status,error_code,stage,duration_ms,queue_ms,upload_ms,attempts,proof_age_ms) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)').run(now,id(row.requestId),id(row.serverRequestId),row.route,['GET','POST'].includes(row.method)?row.method:null,number(row.status),code(row.errorCode),code(row.stage),number(row.duration),number(row.queueMs),number(row.uploadMs),number(row.attempts),number(row.proofAgeMs));
|
|
29
|
-
this.db.prepare('DELETE FROM connection_diagnostics WHERE id<=(SELECT MAX(id)-2000 FROM connection_diagnostics) OR at<?').run(now-7*86400000);
|
|
30
|
-
}
|
|
31
|
-
diagnostics(){return this.db.prepare('SELECT * FROM connection_diagnostics ORDER BY id DESC LIMIT 100').all();}
|
|
32
|
-
close(){this.db.close();}
|
|
33
|
-
}
|
package/updates.mjs
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import {VERSION} from './product.mjs';
|
|
2
|
-
import {boundedJson} from './io.mjs';
|
|
3
|
-
|
|
4
|
-
function parseVersion(value){
|
|
5
|
-
if(typeof value!=='string'||value.length>120)return null;
|
|
6
|
-
const match=/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value);
|
|
7
|
-
if(!match)return null;
|
|
8
|
-
const pre=match[4]?.split('.')??[];
|
|
9
|
-
if(pre.some(id=>/^0\d+$/.test(id)))return null;
|
|
10
|
-
return {core:match.slice(1,4).map(BigInt),pre};
|
|
11
|
-
}
|
|
12
|
-
export function compareVersions(left,right){
|
|
13
|
-
const a=parseVersion(left),b=parseVersion(right);if(!a||!b)throw Error('invalid_version');
|
|
14
|
-
const compare=(x,y)=>x<y?-1:x>y?1:0;
|
|
15
|
-
for(let i=0;i<3;i++){const result=compare(a.core[i],b.core[i]);if(result)return result;}
|
|
16
|
-
if(!a.pre.length||!b.pre.length)return compare(Number(!a.pre.length),Number(!b.pre.length));
|
|
17
|
-
for(let i=0;i<Math.max(a.pre.length,b.pre.length);i++){
|
|
18
|
-
if(a.pre[i]===undefined)return -1;if(b.pre[i]===undefined)return 1;
|
|
19
|
-
const x=a.pre[i],y=b.pre[i],xn=/^\d+$/.test(x),yn=/^\d+$/.test(y);
|
|
20
|
-
const result=xn&&yn?compare(BigInt(x),BigInt(y)):xn!==yn?(xn?-1:1):compare(x,y);
|
|
21
|
-
if(result)return result;
|
|
22
|
-
}
|
|
23
|
-
return 0;
|
|
24
|
-
}
|
|
25
|
-
export function createUpdateChecker({request=fetch,now=Date.now,version=VERSION,platform=process.platform}={}){
|
|
26
|
-
let cached=null,pending=null,nextCheck=0;
|
|
27
|
-
const commands={installCommand:(platform==='win32'?'npm.cmd':'npm')+' i -g codexunlimit@latest',restartCommand:platform==='win32'?'codexunlimit.cmd restart':'codexunlimit restart'};
|
|
28
|
-
return function check(){
|
|
29
|
-
if(pending)return pending;
|
|
30
|
-
if(cached&&now()<nextCheck)return Promise.resolve(cached);
|
|
31
|
-
pending=(async()=>{
|
|
32
|
-
try{
|
|
33
|
-
const response=await request('https://registry.npmjs.org/codexunlimit/latest',{headers:{accept:'application/json'},redirect:'error',signal:AbortSignal.timeout(5000)});
|
|
34
|
-
if(!response.ok)throw Error('update_check_failed');
|
|
35
|
-
const release=await boundedJson(response,65536);
|
|
36
|
-
if(release.name!=='codexunlimit'||!parseVersion(release.version))throw Error('invalid_release');
|
|
37
|
-
cached={currentVersion:version,latestVersion:release.version,available:compareVersions(release.version,version)>0,status:'checked',checkedAt:now(),...commands};
|
|
38
|
-
nextCheck=now()+3600000;
|
|
39
|
-
}catch{
|
|
40
|
-
cached={currentVersion:version,latestVersion:null,available:false,status:'unavailable',checkedAt:null,...commands};
|
|
41
|
-
nextCheck=now()+60000;
|
|
42
|
-
}
|
|
43
|
-
return cached;
|
|
44
|
-
})().finally(()=>{pending=null;});
|
|
45
|
-
return pending;
|
|
46
|
-
};
|
|
47
|
-
}
|
package/web/markdown.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import markdownit from '/app/vendor/markdown-it.js';
|
|
2
|
-
|
|
3
|
-
const markdown=markdownit({html:false,linkify:false,breaks:false});
|
|
4
|
-
const escape=markdown.utils.escapeHtml;
|
|
5
|
-
markdown.validateLink=value=>/^(?:https?:\/\/|mailto:|#)/i.test(value);
|
|
6
|
-
markdown.renderer.rules.image=(tokens,index)=>'<span class="markdown-image">[Ảnh: '+escape(tokens[index].content||'không có mô tả')+']</span>';
|
|
7
|
-
markdown.renderer.rules.link_open=(tokens,index,options,environment,renderer)=>{
|
|
8
|
-
tokens[index].attrSet('target','_blank');tokens[index].attrSet('rel','noopener noreferrer');return renderer.renderToken(tokens,index,options);
|
|
9
|
-
};
|
|
10
|
-
const fence=markdown.renderer.rules.fence;
|
|
11
|
-
markdown.renderer.rules.fence=(tokens,index,options,environment,renderer)=>{
|
|
12
|
-
const name=tokens[index].info.trim().split(/\s+/)[0],language=/^[a-z0-9_+-]{1,24}$/i.test(name)?name:'Code';
|
|
13
|
-
return '<div class="markdown-code"><div class="markdown-code-head"><span>'+escape(language)+'</span><button type="button" data-copy-code>Sao chép</button></div>'+fence(tokens,index,options,environment,renderer)+'</div>';
|
|
14
|
-
};
|
|
15
|
-
export function renderMarkdown(text){return markdown.render(String(text??''));}
|
package/web/messages.js
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
export const errors={
|
|
2
|
-
connection_busy:'Các tác vụ trước vẫn đang chạy. Chờ chúng hoàn tất rồi gửi tiếp; yêu cầu này chưa được gửi tới mô hình.',
|
|
3
|
-
request_clock_error:'Chưa xác thực được thời điểm gửi yêu cầu. Bật đồng bộ ngày giờ trên máy rồi thử lại.',
|
|
4
|
-
managed_provider_conflict:'Tên kết nối Codex Unlimit đang được dùng trong cấu hình khác. Liên hệ hỗ trợ để giữ nguyên kết nối hiện có.',
|
|
5
|
-
pool_platform_unavailable:'Chức năng đăng nhập pool chưa hỗ trợ hệ điều hành hoặc kiến trúc máy này.',
|
|
6
|
-
pool_runtime_missing:'Thiếu công cụ đăng nhập đi kèm. Cài lại gói Codex Unlimit bằng npm, giữ các dependency tùy chọn.',
|
|
7
|
-
pool_runtime_unavailable:'Chưa mở được bộ quản lý tài khoản. Khởi động lại Codex Unlimit rồi thử lại.',
|
|
8
|
-
pool_provider_rejected:'Chưa thực hiện được yêu cầu với tài khoản. Đăng nhập lại tài khoản này rồi thử lại.',
|
|
9
|
-
pool_request_timeout:'Tài khoản chưa phản hồi kịp. Kiểm tra mạng rồi thử lại.',
|
|
10
|
-
pool_account_missing:'Tài khoản không còn trong pool. Tải lại danh sách rồi chọn lại.',
|
|
11
|
-
pool_account_expired:'Phiên tài khoản cần đăng nhập lại. Chọn Đăng nhập lại trong thẻ tài khoản.',
|
|
12
|
-
pool_account_exists:'Tài khoản này đã có trong pool. Chọn tài khoản đã lưu để sử dụng.',
|
|
13
|
-
pool_storage_invalid:'Không mở được vùng lưu tài khoản riêng. Kiểm tra thư mục dữ liệu của ứng dụng.',
|
|
14
|
-
pool_login_pending:'Đang có một phiên đăng nhập. Hoàn tất hoặc hủy phiên đó trước.',
|
|
15
|
-
pool_full:'Pool đã có 10 tài khoản. Gỡ tài khoản không còn dùng trước khi thêm mới.',
|
|
16
|
-
pool_login_unavailable:'Chưa mở được trang đăng nhập OpenAI. Kiểm tra mạng hoặc thử cách đăng nhập bằng mã.',
|
|
17
|
-
pool_login_failed:'Chưa hoàn tất đăng nhập. Thử lại và xác nhận đúng tài khoản trên trang OpenAI.',
|
|
18
|
-
pool_login_expired:'Phiên đăng nhập đã hết thời gian. Chọn Thêm tài khoản để bắt đầu lại.',
|
|
19
|
-
pool_login_interrupted:'Lần đăng nhập trước bị gián đoạn. Chọn Đăng nhập lại để tiếp tục.',
|
|
20
|
-
pool_quota_unavailable:'Chưa đọc được quota 5 giờ/tuần của tài khoản. Số liệu cũ vẫn được giữ; thử lại hoặc đăng nhập lại nếu phiên đã hết hạn.',
|
|
21
|
-
connector_update_required:'Bản đang chạy cần được cập nhật. Mở trang cài đặt, cài gói mới rồi mở lại Codex Unlimit.',
|
|
22
|
-
request_integrity_failed:'Yêu cầu chưa được xác thực. Cập nhật Codex Unlimit rồi thử lại.',
|
|
23
|
-
license_server_unavailable:'Chưa liên lạc được máy chủ kiểm tra key. Kiểm tra mạng rồi chọn Kiểm tra thời hạn.',
|
|
24
|
-
license_service_busy:'Máy chủ kiểm tra key đang bận. Chờ vài giây rồi thử lại; đây không phải thông báo key hết hạn.',
|
|
25
|
-
license_lease_expired:'Phiên xác thực tạm thời đã hết hạn. Chọn Kiểm tra thời hạn để lấy phiên mới; nếu lặp lại, kiểm tra ngày giờ trên máy.',
|
|
26
|
-
license_invalid_or_expired:'Key đã hết hạn hoặc bị thu hồi. Liên hệ nơi cấp key.',
|
|
27
|
-
license_revoked:'Key đã bị thu hồi. Liên hệ nơi cấp key.',
|
|
28
|
-
device_limit_reached:'Key đã đủ số máy được phép dùng. Mở trang khách hàng để kiểm tra thiết bị.',
|
|
29
|
-
device_revoked:'Thiết bị này đã bị thu hồi quyền sử dụng. Liên hệ nơi cấp key.',
|
|
30
|
-
activation_invalid:'Lần kích hoạt này không còn hợp lệ. Nhập lại key để kích hoạt.',
|
|
31
|
-
privacy_required:'Đánh dấu đồng ý sử dụng phiên đăng nhập trước khi kết nối.',
|
|
32
|
-
request_too_large:'Cuộc trò chuyện đã quá dài. Hãy mở cuộc trò chuyện mới.',
|
|
33
|
-
open_from_command:'Phiên mở trang đã hết hiệu lực. Chọn Mở Codex Unlimit trong khay Windows hoặc chạy lại lệnh codexunlimit.',
|
|
34
|
-
session_expired:'Phiên làm việc đã hết hạn. Mở lại Codex Unlimit từ khay Windows hoặc lệnh codexunlimit.',
|
|
35
|
-
account_required:'Chưa đọc được phiên ChatGPT trên máy này. Mở Codex, đăng nhập ChatGPT rồi chọn Kết nối lại.',
|
|
36
|
-
account_expired:'Phiên ChatGPT đã hết hạn. Đăng nhập lại trong Codex rồi chọn Kết nối lại.',
|
|
37
|
-
activation_required:'Kích hoạt key trong phần Mã sử dụng trước khi kết nối Codex.',
|
|
38
|
-
invalid_activation_key:'Key chưa đúng định dạng. Kiểm tra lại mã bạn nhận được.',
|
|
39
|
-
usage_limit:'Bạn đã đến giới hạn sử dụng hiện tại. Vui lòng thử lại sau.',
|
|
40
|
-
service_unavailable:'Máy chủ xử lý chưa phản hồi. Thử lại sau hoặc liên hệ hỗ trợ.',
|
|
41
|
-
local_service_unavailable:'Ứng dụng trên máy chưa phản hồi. Mở lại từ biểu tượng CU trong khay Windows hoặc chạy codexunlimit.',
|
|
42
|
-
operation_failed:'Chưa hoàn tất thao tác. Chọn Kiểm tra kết nối để xem phần cần xử lý.',
|
|
43
|
-
models_unavailable:'Chưa tải được danh sách mô hình. Kiểm tra thời hạn và kết nối lại tài khoản.',
|
|
44
|
-
invalid_message:'Tin nhắn quá dài hoặc cuộc trò chuyện đã đầy. Hãy mở cuộc trò chuyện mới.',
|
|
45
|
-
existing_connector_review_required:'Codex đang dùng kết nối do bản native cũ hoặc TDH Relay quản lý. Mở bản đó, chọn Khôi phục kết nối ban đầu rồi Tắt ứng dụng; sau đó kết nối lại tại đây.',
|
|
46
|
-
active_codex_profile_requires_manual_setup:'Codex đang chọn một profile riêng. Bỏ lựa chọn profile trong cấu hình Codex trước khi bật kết nối, hoặc nhờ hỗ trợ kiểm tra profile đó.',
|
|
47
|
-
multiline_root_config_requires_manual_setup:'Cấu hình Codex có nội dung nhiều dòng cần được kiểm tra trước khi sửa. Chưa thay đổi file; hãy liên hệ hỗ trợ.',
|
|
48
|
-
duplicate_provider_settings_review_required:'Cấu hình Codex có thiết lập kết nối bị lặp. Chưa thay đổi file; hãy nhờ hỗ trợ xử lý mục bị lặp.',
|
|
49
|
-
config_backup_missing_or_changed_review_required:'Bản sao cấu hình gốc không còn nguyên vẹn. Chưa ghi đè cấu hình Codex; hãy liên hệ hỗ trợ để khôi phục.',
|
|
50
|
-
managed_configuration_changed_review_required:'Kết nối Codex đã được một ứng dụng khác chỉnh sửa. Chưa ghi đè thay đổi đó; hãy nhờ hỗ trợ kiểm tra cấu hình.',
|
|
51
|
-
managed_configuration_already_present:'Có cấu hình Codex Unlimit từ lần cài khác. Mở bản đang quản lý kết nối để khôi phục trước.',
|
|
52
|
-
config_target_changed_review_required:'Thư mục hoặc cổng kết nối đã thay đổi. Khôi phục kết nối ở bản cũ trước khi kết nối lại.',
|
|
53
|
-
new_provider_settings_review_required:'Codex có danh sách mô hình riêng được thêm sau khi kết nối. Hãy nhờ hỗ trợ kiểm tra trước khi khôi phục.',
|
|
54
|
-
config_symlink_requires_manual_setup:'File cấu hình Codex đang là liên kết tới vị trí khác. Chưa sửa file để tránh tác động nhầm; hãy liên hệ hỗ trợ.',
|
|
55
|
-
config_permission_denied:'Windows chưa cho phép sửa cấu hình Codex. Đóng Codex, kiểm tra quyền ghi hoặc phần mềm bảo vệ thư mục rồi thử lại.',
|
|
56
|
-
config_missing:'Không tìm thấy cấu hình cần khôi phục. Chọn Kiểm tra kết nối và liên hệ hỗ trợ nếu vẫn lỗi.',
|
|
57
|
-
codex_home_missing:'Chưa xác định được thư mục cấu hình Codex. Mở Codex một lần rồi thử lại.',
|
|
58
|
-
saved_root_line_corrupt:'Bản lưu thiết lập kết nối không còn nguyên vẹn. Chưa sửa cấu hình; hãy liên hệ hỗ trợ.',
|
|
59
|
-
saved_root_line_moved:'Vị trí thiết lập kết nối đã thay đổi. Chưa sửa cấu hình; hãy liên hệ hỗ trợ.',
|
|
60
|
-
startup_unavailable:'Tự khởi động cùng hệ điều hành hiện hỗ trợ Windows.',
|
|
61
|
-
startup_update_failed:'Chưa cập nhật được tùy chọn khởi động cùng Windows. Kiểm tra quyền ghi thư mục Startup rồi thử lại.',
|
|
62
|
-
tray_unavailable:'Chưa mở được biểu tượng khay Windows. Bạn vẫn có thể mở và tắt ứng dụng tại trang này.',
|
|
63
|
-
invalid_input:'Thông tin gửi chưa hợp lệ. Tải lại trang rồi thử lại.',
|
|
64
|
-
clock_rollback_detected:'Giờ trên máy chưa khớp. Bật đồng bộ ngày giờ của hệ điều hành rồi kiểm tra thời hạn lại.',
|
|
65
|
-
license_signature_invalid:'Chưa xác minh được thông tin key. Chọn Kiểm tra thời hạn; nếu vẫn lỗi hãy cập nhật ứng dụng.'
|
|
66
|
-
};
|