Spaces:
Paused
Paused
Merge pull request #1 from icebear0828/refactor/code-audit-cleanup
Browse files- config/default.yaml +4 -0
- src/auth/chatgpt-oauth.ts +6 -4
- src/auth/manager.ts +0 -165
- src/config.ts +4 -0
- src/proxy/client.ts +0 -246
- src/routes/models.ts +7 -4
- src/session/manager.ts +5 -4
config/default.yaml
CHANGED
|
@@ -29,6 +29,10 @@ environment:
|
|
| 29 |
default_id: null
|
| 30 |
default_branch: "main"
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
streaming:
|
| 33 |
status_as_content: false
|
| 34 |
chunk_size: 100
|
|
|
|
| 29 |
default_id: null
|
| 30 |
default_branch: "main"
|
| 31 |
|
| 32 |
+
session:
|
| 33 |
+
ttl_minutes: 60
|
| 34 |
+
cleanup_interval_minutes: 5
|
| 35 |
+
|
| 36 |
streaming:
|
| 37 |
status_as_content: false
|
| 38 |
chunk_size: 100
|
src/auth/chatgpt-oauth.ts
CHANGED
|
@@ -247,12 +247,13 @@ export async function loginViaCli(): Promise<{
|
|
| 247 |
|
| 248 |
// Step 1: Send the initialize handshake
|
| 249 |
const config = getConfig();
|
|
|
|
| 250 |
sendRpc(
|
| 251 |
"initialize",
|
| 252 |
{
|
| 253 |
clientInfo: {
|
| 254 |
-
name:
|
| 255 |
-
title:
|
| 256 |
version: config.client.app_version,
|
| 257 |
},
|
| 258 |
},
|
|
@@ -380,12 +381,13 @@ export async function refreshTokenViaCli(): Promise<string> {
|
|
| 380 |
|
| 381 |
// Send initialize
|
| 382 |
const config = getConfig();
|
|
|
|
| 383 |
sendRpc(
|
| 384 |
"initialize",
|
| 385 |
{
|
| 386 |
clientInfo: {
|
| 387 |
-
name:
|
| 388 |
-
title:
|
| 389 |
version: config.client.app_version,
|
| 390 |
},
|
| 391 |
},
|
|
|
|
| 247 |
|
| 248 |
// Step 1: Send the initialize handshake
|
| 249 |
const config = getConfig();
|
| 250 |
+
const originator = config.client.originator;
|
| 251 |
sendRpc(
|
| 252 |
"initialize",
|
| 253 |
{
|
| 254 |
clientInfo: {
|
| 255 |
+
name: originator,
|
| 256 |
+
title: originator,
|
| 257 |
version: config.client.app_version,
|
| 258 |
},
|
| 259 |
},
|
|
|
|
| 381 |
|
| 382 |
// Send initialize
|
| 383 |
const config = getConfig();
|
| 384 |
+
const originator = config.client.originator;
|
| 385 |
sendRpc(
|
| 386 |
"initialize",
|
| 387 |
{
|
| 388 |
clientInfo: {
|
| 389 |
+
name: originator,
|
| 390 |
+
title: originator,
|
| 391 |
version: config.client.app_version,
|
| 392 |
},
|
| 393 |
},
|
src/auth/manager.ts
DELETED
|
@@ -1,165 +0,0 @@
|
|
| 1 |
-
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from "fs";
|
| 2 |
-
import { resolve, dirname } from "path";
|
| 3 |
-
import { randomBytes } from "crypto";
|
| 4 |
-
import { getConfig } from "../config.js";
|
| 5 |
-
import {
|
| 6 |
-
decodeJwtPayload,
|
| 7 |
-
extractChatGptAccountId,
|
| 8 |
-
extractUserProfile,
|
| 9 |
-
isTokenExpired,
|
| 10 |
-
} from "./jwt-utils.js";
|
| 11 |
-
|
| 12 |
-
interface PersistedAuth {
|
| 13 |
-
token: string;
|
| 14 |
-
proxyApiKey: string | null;
|
| 15 |
-
userInfo: { email?: string; accountId?: string; planType?: string } | null;
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
const AUTH_FILE = resolve(process.cwd(), "data", "auth.json");
|
| 19 |
-
|
| 20 |
-
export class AuthManager {
|
| 21 |
-
private token: string | null = null;
|
| 22 |
-
private userInfo: { email?: string; accountId?: string; planType?: string } | null = null;
|
| 23 |
-
private proxyApiKey: string | null = null;
|
| 24 |
-
private refreshLock: Promise<string | null> | null = null;
|
| 25 |
-
|
| 26 |
-
constructor() {
|
| 27 |
-
this.loadPersisted();
|
| 28 |
-
|
| 29 |
-
// Override with config jwt_token if set
|
| 30 |
-
const config = getConfig();
|
| 31 |
-
if (config.auth.jwt_token) {
|
| 32 |
-
this.setToken(config.auth.jwt_token);
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
// Override with env var if set
|
| 36 |
-
const envToken = process.env.CODEX_JWT_TOKEN;
|
| 37 |
-
if (envToken) {
|
| 38 |
-
this.setToken(envToken);
|
| 39 |
-
}
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
async getToken(forceRefresh?: boolean): Promise<string | null> {
|
| 43 |
-
if (forceRefresh || (this.token && this.isExpired())) {
|
| 44 |
-
// Use a lock to prevent concurrent refresh attempts
|
| 45 |
-
if (!this.refreshLock) {
|
| 46 |
-
this.refreshLock = this.attemptRefresh();
|
| 47 |
-
}
|
| 48 |
-
try {
|
| 49 |
-
return await this.refreshLock;
|
| 50 |
-
} finally {
|
| 51 |
-
this.refreshLock = null;
|
| 52 |
-
}
|
| 53 |
-
}
|
| 54 |
-
return this.token;
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
setToken(token: string): void {
|
| 58 |
-
this.token = token;
|
| 59 |
-
|
| 60 |
-
// Extract user info from JWT claims
|
| 61 |
-
const profile = extractUserProfile(token);
|
| 62 |
-
const accountId = extractChatGptAccountId(token);
|
| 63 |
-
this.userInfo = {
|
| 64 |
-
email: profile?.email,
|
| 65 |
-
accountId: accountId ?? undefined,
|
| 66 |
-
planType: profile?.chatgpt_plan_type,
|
| 67 |
-
};
|
| 68 |
-
|
| 69 |
-
// Generate proxy API key if we don't have one yet
|
| 70 |
-
if (!this.proxyApiKey) {
|
| 71 |
-
this.proxyApiKey = this.generateApiKey();
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
this.persist();
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
clearToken(): void {
|
| 78 |
-
this.token = null;
|
| 79 |
-
this.userInfo = null;
|
| 80 |
-
this.proxyApiKey = null;
|
| 81 |
-
try {
|
| 82 |
-
if (existsSync(AUTH_FILE)) {
|
| 83 |
-
unlinkSync(AUTH_FILE);
|
| 84 |
-
}
|
| 85 |
-
} catch {
|
| 86 |
-
// ignore cleanup errors
|
| 87 |
-
}
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
isAuthenticated(): boolean {
|
| 91 |
-
return this.token !== null && !this.isExpired();
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
getUserInfo(): { email?: string; accountId?: string; planType?: string } | null {
|
| 95 |
-
return this.userInfo;
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
getAccountId(): string | null {
|
| 99 |
-
if (!this.token) return null;
|
| 100 |
-
return extractChatGptAccountId(this.token);
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
getProxyApiKey(): string | null {
|
| 104 |
-
return this.proxyApiKey;
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
validateProxyApiKey(key: string): boolean {
|
| 108 |
-
if (!this.proxyApiKey) return false;
|
| 109 |
-
return key === this.proxyApiKey;
|
| 110 |
-
}
|
| 111 |
-
|
| 112 |
-
// --- private helpers ---
|
| 113 |
-
|
| 114 |
-
private isExpired(): boolean {
|
| 115 |
-
if (!this.token) return true;
|
| 116 |
-
const config = getConfig();
|
| 117 |
-
return isTokenExpired(this.token, config.auth.refresh_margin_seconds);
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
private async attemptRefresh(): Promise<string | null> {
|
| 121 |
-
// We cannot auto-refresh without Codex CLI interaction.
|
| 122 |
-
// If the token is expired, the user needs to re-login.
|
| 123 |
-
if (this.token && isTokenExpired(this.token)) {
|
| 124 |
-
this.token = null;
|
| 125 |
-
this.userInfo = null;
|
| 126 |
-
}
|
| 127 |
-
return this.token;
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
private persist(): void {
|
| 131 |
-
try {
|
| 132 |
-
const dir = dirname(AUTH_FILE);
|
| 133 |
-
if (!existsSync(dir)) {
|
| 134 |
-
mkdirSync(dir, { recursive: true });
|
| 135 |
-
}
|
| 136 |
-
const data: PersistedAuth = {
|
| 137 |
-
token: this.token!,
|
| 138 |
-
proxyApiKey: this.proxyApiKey,
|
| 139 |
-
userInfo: this.userInfo,
|
| 140 |
-
};
|
| 141 |
-
writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2), "utf-8");
|
| 142 |
-
} catch {
|
| 143 |
-
// Persist is best-effort
|
| 144 |
-
}
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
private loadPersisted(): void {
|
| 148 |
-
try {
|
| 149 |
-
if (!existsSync(AUTH_FILE)) return;
|
| 150 |
-
const raw = readFileSync(AUTH_FILE, "utf-8");
|
| 151 |
-
const data = JSON.parse(raw) as PersistedAuth;
|
| 152 |
-
if (data.token && typeof data.token === "string") {
|
| 153 |
-
this.token = data.token;
|
| 154 |
-
this.proxyApiKey = data.proxyApiKey ?? null;
|
| 155 |
-
this.userInfo = data.userInfo ?? null;
|
| 156 |
-
}
|
| 157 |
-
} catch {
|
| 158 |
-
// If the file is corrupt, start fresh
|
| 159 |
-
}
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
-
private generateApiKey(): string {
|
| 163 |
-
return "codex-proxy-" + randomBytes(24).toString("hex");
|
| 164 |
-
}
|
| 165 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/config.ts
CHANGED
|
@@ -35,6 +35,10 @@ const ConfigSchema = z.object({
|
|
| 35 |
default_id: z.string().nullable().default(null),
|
| 36 |
default_branch: z.string().default("main"),
|
| 37 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
streaming: z.object({
|
| 39 |
status_as_content: z.boolean().default(false),
|
| 40 |
chunk_size: z.number().default(100),
|
|
|
|
| 35 |
default_id: z.string().nullable().default(null),
|
| 36 |
default_branch: z.string().default("main"),
|
| 37 |
}),
|
| 38 |
+
session: z.object({
|
| 39 |
+
ttl_minutes: z.number().default(60),
|
| 40 |
+
cleanup_interval_minutes: z.number().default(5),
|
| 41 |
+
}),
|
| 42 |
streaming: z.object({
|
| 43 |
status_as_content: z.boolean().default(false),
|
| 44 |
chunk_size: z.number().default(100),
|
src/proxy/client.ts
DELETED
|
@@ -1,246 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* ProxyClient — fetch wrapper with auth headers, retry on 401, and SSE streaming.
|
| 3 |
-
*
|
| 4 |
-
* Mirrors the Codex Desktop ElectronFetchWrapper pattern.
|
| 5 |
-
*/
|
| 6 |
-
|
| 7 |
-
import { getConfig } from "../config.js";
|
| 8 |
-
import {
|
| 9 |
-
buildHeaders,
|
| 10 |
-
buildHeadersWithContentType,
|
| 11 |
-
} from "../fingerprint/manager.js";
|
| 12 |
-
|
| 13 |
-
export interface FetchOptions {
|
| 14 |
-
method?: string;
|
| 15 |
-
headers?: Record<string, string>;
|
| 16 |
-
body?: string;
|
| 17 |
-
signal?: AbortSignal;
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
export interface FetchResponse {
|
| 21 |
-
status: number;
|
| 22 |
-
headers: Record<string, string>;
|
| 23 |
-
body: unknown;
|
| 24 |
-
ok: boolean;
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
export class ProxyClient {
|
| 28 |
-
private token: string;
|
| 29 |
-
private accountId: string | null;
|
| 30 |
-
|
| 31 |
-
constructor(token: string, accountId: string | null) {
|
| 32 |
-
this.token = token;
|
| 33 |
-
this.accountId = accountId;
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
/** Update the bearer token (e.g. after a refresh). */
|
| 37 |
-
setToken(token: string): void {
|
| 38 |
-
this.token = token;
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
/** Update the account ID. */
|
| 42 |
-
setAccountId(accountId: string | null): void {
|
| 43 |
-
this.accountId = accountId;
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
// ---- public helpers ----
|
| 47 |
-
|
| 48 |
-
/** GET request, returns parsed JSON body. */
|
| 49 |
-
async get(path: string): Promise<FetchResponse> {
|
| 50 |
-
const url = this.ensureAbsoluteUrl(path);
|
| 51 |
-
const res = await this.fetchWithRetry(url, {
|
| 52 |
-
method: "GET",
|
| 53 |
-
headers: buildHeaders(this.token, this.accountId),
|
| 54 |
-
});
|
| 55 |
-
const body = await res.json();
|
| 56 |
-
return {
|
| 57 |
-
status: res.status,
|
| 58 |
-
headers: Object.fromEntries(res.headers.entries()),
|
| 59 |
-
body,
|
| 60 |
-
ok: res.ok,
|
| 61 |
-
};
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
/** POST request with JSON body, returns parsed JSON body. */
|
| 65 |
-
async post(path: string, body: unknown): Promise<FetchResponse> {
|
| 66 |
-
const url = this.ensureAbsoluteUrl(path);
|
| 67 |
-
const res = await this.fetchWithRetry(url, {
|
| 68 |
-
method: "POST",
|
| 69 |
-
headers: buildHeadersWithContentType(this.token, this.accountId),
|
| 70 |
-
body: JSON.stringify(body),
|
| 71 |
-
});
|
| 72 |
-
const resBody = await res.json();
|
| 73 |
-
return {
|
| 74 |
-
status: res.status,
|
| 75 |
-
headers: Object.fromEntries(res.headers.entries()),
|
| 76 |
-
body: resBody,
|
| 77 |
-
ok: res.ok,
|
| 78 |
-
};
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
/** GET an SSE endpoint — yields parsed `{ event?, data }` objects. */
|
| 82 |
-
async *stream(
|
| 83 |
-
path: string,
|
| 84 |
-
signal?: AbortSignal,
|
| 85 |
-
): AsyncGenerator<{ event?: string; data: unknown }> {
|
| 86 |
-
const url = this.ensureAbsoluteUrl(path);
|
| 87 |
-
const res = await this.fetchWithRetry(url, {
|
| 88 |
-
method: "GET",
|
| 89 |
-
headers: {
|
| 90 |
-
...buildHeaders(this.token, this.accountId),
|
| 91 |
-
Accept: "text/event-stream",
|
| 92 |
-
},
|
| 93 |
-
signal,
|
| 94 |
-
});
|
| 95 |
-
|
| 96 |
-
if (!res.ok) {
|
| 97 |
-
const text = await res.text();
|
| 98 |
-
throw new Error(`SSE request failed (${res.status}): ${text}`);
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
if (!res.body) {
|
| 102 |
-
throw new Error("Response body is null — cannot stream");
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
const reader = res.body
|
| 106 |
-
.pipeThrough(new TextDecoderStream())
|
| 107 |
-
.getReader();
|
| 108 |
-
|
| 109 |
-
let buffer = "";
|
| 110 |
-
try {
|
| 111 |
-
while (true) {
|
| 112 |
-
const { done, value } = await reader.read();
|
| 113 |
-
if (done) break;
|
| 114 |
-
|
| 115 |
-
buffer += value;
|
| 116 |
-
|
| 117 |
-
// Process complete SSE messages (separated by double newline)
|
| 118 |
-
const parts = buffer.split("\n\n");
|
| 119 |
-
// Last part may be incomplete — keep it in the buffer
|
| 120 |
-
buffer = parts.pop()!;
|
| 121 |
-
|
| 122 |
-
for (const part of parts) {
|
| 123 |
-
if (!part.trim()) continue;
|
| 124 |
-
for (const parsed of this.parseSSE(part)) {
|
| 125 |
-
if (parsed.data === "[DONE]") return;
|
| 126 |
-
try {
|
| 127 |
-
yield { event: parsed.event, data: JSON.parse(parsed.data) };
|
| 128 |
-
} catch {
|
| 129 |
-
yield { event: parsed.event, data: parsed.data };
|
| 130 |
-
}
|
| 131 |
-
}
|
| 132 |
-
}
|
| 133 |
-
}
|
| 134 |
-
|
| 135 |
-
// Process any remaining data in the buffer
|
| 136 |
-
if (buffer.trim()) {
|
| 137 |
-
for (const parsed of this.parseSSE(buffer)) {
|
| 138 |
-
if (parsed.data === "[DONE]") return;
|
| 139 |
-
try {
|
| 140 |
-
yield { event: parsed.event, data: JSON.parse(parsed.data) };
|
| 141 |
-
} catch {
|
| 142 |
-
yield { event: parsed.event, data: parsed.data };
|
| 143 |
-
}
|
| 144 |
-
}
|
| 145 |
-
}
|
| 146 |
-
} finally {
|
| 147 |
-
reader.releaseLock();
|
| 148 |
-
}
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
// ---- internal helpers ----
|
| 152 |
-
|
| 153 |
-
/**
|
| 154 |
-
* Resolve a relative URL to absolute using the configured base_url.
|
| 155 |
-
* Mirrors Codex's ensureAbsoluteUrl.
|
| 156 |
-
*/
|
| 157 |
-
private ensureAbsoluteUrl(url: string): string {
|
| 158 |
-
if (/^https?:\/\//i.test(url) || url.startsWith("data:")) return url;
|
| 159 |
-
const base = getConfig().api.base_url;
|
| 160 |
-
return `${base}/${url.replace(/^\/+/, "")}`;
|
| 161 |
-
}
|
| 162 |
-
|
| 163 |
-
/**
|
| 164 |
-
* Fetch with a single 401 retry (re-builds auth headers on retry).
|
| 165 |
-
*/
|
| 166 |
-
private async fetchWithRetry(
|
| 167 |
-
url: string,
|
| 168 |
-
options: FetchOptions,
|
| 169 |
-
onRefreshToken?: () => Promise<string | null>,
|
| 170 |
-
): Promise<Response> {
|
| 171 |
-
const config = getConfig();
|
| 172 |
-
const timeout = config.api.timeout_seconds * 1000;
|
| 173 |
-
|
| 174 |
-
const doFetch = (opts: FetchOptions): Promise<Response> => {
|
| 175 |
-
const controller = new AbortController();
|
| 176 |
-
const timer = setTimeout(() => controller.abort(), timeout);
|
| 177 |
-
const mergedSignal = opts.signal
|
| 178 |
-
? AbortSignal.any([opts.signal, controller.signal])
|
| 179 |
-
: controller.signal;
|
| 180 |
-
|
| 181 |
-
return fetch(url, {
|
| 182 |
-
method: opts.method ?? "GET",
|
| 183 |
-
headers: opts.headers,
|
| 184 |
-
body: opts.body,
|
| 185 |
-
signal: mergedSignal,
|
| 186 |
-
}).finally(() => clearTimeout(timer));
|
| 187 |
-
};
|
| 188 |
-
|
| 189 |
-
const res = await doFetch(options);
|
| 190 |
-
|
| 191 |
-
// Single retry on 401 if a refresh callback is provided
|
| 192 |
-
if (res.status === 401 && onRefreshToken) {
|
| 193 |
-
const newToken = await onRefreshToken();
|
| 194 |
-
if (newToken) {
|
| 195 |
-
this.token = newToken;
|
| 196 |
-
const retryHeaders = options.headers?.["Content-Type"]
|
| 197 |
-
? buildHeadersWithContentType(this.token, this.accountId)
|
| 198 |
-
: buildHeaders(this.token, this.accountId);
|
| 199 |
-
return doFetch({ ...options, headers: retryHeaders });
|
| 200 |
-
}
|
| 201 |
-
}
|
| 202 |
-
|
| 203 |
-
return res;
|
| 204 |
-
}
|
| 205 |
-
|
| 206 |
-
/**
|
| 207 |
-
* Parse raw SSE text block into individual events.
|
| 208 |
-
*/
|
| 209 |
-
private *parseSSE(
|
| 210 |
-
text: string,
|
| 211 |
-
): Generator<{ event?: string; data: string }> {
|
| 212 |
-
let event: string | undefined;
|
| 213 |
-
let dataLines: string[] = [];
|
| 214 |
-
|
| 215 |
-
for (const line of text.split("\n")) {
|
| 216 |
-
if (line.startsWith("event:")) {
|
| 217 |
-
event = line.slice(6).trim();
|
| 218 |
-
} else if (line.startsWith("data:")) {
|
| 219 |
-
dataLines.push(line.slice(5).trimStart());
|
| 220 |
-
} else if (line === "" && dataLines.length > 0) {
|
| 221 |
-
yield { event, data: dataLines.join("\n") };
|
| 222 |
-
event = undefined;
|
| 223 |
-
dataLines = [];
|
| 224 |
-
}
|
| 225 |
-
}
|
| 226 |
-
|
| 227 |
-
// Yield any remaining accumulated data
|
| 228 |
-
if (dataLines.length > 0) {
|
| 229 |
-
yield { event, data: dataLines.join("\n") };
|
| 230 |
-
}
|
| 231 |
-
}
|
| 232 |
-
}
|
| 233 |
-
|
| 234 |
-
/**
|
| 235 |
-
* Replace `{param}` placeholders in a URL template with encoded values.
|
| 236 |
-
*/
|
| 237 |
-
export function serializePath(
|
| 238 |
-
template: string,
|
| 239 |
-
params: Record<string, string>,
|
| 240 |
-
): string {
|
| 241 |
-
let path = template;
|
| 242 |
-
for (const [key, value] of Object.entries(params)) {
|
| 243 |
-
path = path.replace(`{${key}}`, encodeURIComponent(value));
|
| 244 |
-
}
|
| 245 |
-
return path;
|
| 246 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/routes/models.ts
CHANGED
|
@@ -143,11 +143,14 @@ export function getModelCatalog(): CodexModelInfo[] {
|
|
| 143 |
|
| 144 |
// --- Routes ---
|
| 145 |
|
|
|
|
|
|
|
|
|
|
| 146 |
function toOpenAIModel(info: CodexModelInfo): OpenAIModel {
|
| 147 |
return {
|
| 148 |
id: info.id,
|
| 149 |
object: "model",
|
| 150 |
-
created:
|
| 151 |
owned_by: "openai",
|
| 152 |
};
|
| 153 |
}
|
|
@@ -155,11 +158,11 @@ function toOpenAIModel(info: CodexModelInfo): OpenAIModel {
|
|
| 155 |
app.get("/v1/models", (c) => {
|
| 156 |
// Include catalog models + aliases as separate entries
|
| 157 |
const models: OpenAIModel[] = MODEL_CATALOG.map(toOpenAIModel);
|
| 158 |
-
for (const [alias
|
| 159 |
models.push({
|
| 160 |
id: alias,
|
| 161 |
object: "model",
|
| 162 |
-
created:
|
| 163 |
owned_by: "openai",
|
| 164 |
});
|
| 165 |
}
|
|
@@ -180,7 +183,7 @@ app.get("/v1/models/:modelId", (c) => {
|
|
| 180 |
return c.json({
|
| 181 |
id: modelId,
|
| 182 |
object: "model",
|
| 183 |
-
created:
|
| 184 |
owned_by: "openai",
|
| 185 |
});
|
| 186 |
}
|
|
|
|
| 143 |
|
| 144 |
// --- Routes ---
|
| 145 |
|
| 146 |
+
/** Stable timestamp used for all model `created` fields (2023-11-14T22:13:20Z). */
|
| 147 |
+
const MODEL_CREATED_TIMESTAMP = 1700000000;
|
| 148 |
+
|
| 149 |
function toOpenAIModel(info: CodexModelInfo): OpenAIModel {
|
| 150 |
return {
|
| 151 |
id: info.id,
|
| 152 |
object: "model",
|
| 153 |
+
created: MODEL_CREATED_TIMESTAMP,
|
| 154 |
owned_by: "openai",
|
| 155 |
};
|
| 156 |
}
|
|
|
|
| 158 |
app.get("/v1/models", (c) => {
|
| 159 |
// Include catalog models + aliases as separate entries
|
| 160 |
const models: OpenAIModel[] = MODEL_CATALOG.map(toOpenAIModel);
|
| 161 |
+
for (const [alias] of Object.entries(MODEL_ALIASES)) {
|
| 162 |
models.push({
|
| 163 |
id: alias,
|
| 164 |
object: "model",
|
| 165 |
+
created: MODEL_CREATED_TIMESTAMP,
|
| 166 |
owned_by: "openai",
|
| 167 |
});
|
| 168 |
}
|
|
|
|
| 183 |
return c.json({
|
| 184 |
id: modelId,
|
| 185 |
object: "model",
|
| 186 |
+
created: MODEL_CREATED_TIMESTAMP,
|
| 187 |
owned_by: "openai",
|
| 188 |
});
|
| 189 |
}
|
src/session/manager.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import { createHash } from "crypto";
|
|
|
|
| 2 |
|
| 3 |
interface Session {
|
| 4 |
taskId: string;
|
|
@@ -11,10 +12,10 @@ export class SessionManager {
|
|
| 11 |
private sessions = new Map<string, Session>();
|
| 12 |
private ttlMs: number;
|
| 13 |
|
| 14 |
-
constructor(
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
setInterval(() => this.cleanup(),
|
| 18 |
}
|
| 19 |
|
| 20 |
/**
|
|
|
|
| 1 |
import { createHash } from "crypto";
|
| 2 |
+
import { getConfig } from "../config.js";
|
| 3 |
|
| 4 |
interface Session {
|
| 5 |
taskId: string;
|
|
|
|
| 12 |
private sessions = new Map<string, Session>();
|
| 13 |
private ttlMs: number;
|
| 14 |
|
| 15 |
+
constructor() {
|
| 16 |
+
const { ttl_minutes, cleanup_interval_minutes } = getConfig().session;
|
| 17 |
+
this.ttlMs = ttl_minutes * 60 * 1000;
|
| 18 |
+
setInterval(() => this.cleanup(), cleanup_interval_minutes * 60 * 1000);
|
| 19 |
}
|
| 20 |
|
| 21 |
/**
|