fix(voice): validate Codex OAuth and report unknown usage

This commit is contained in:
devv-shayan committed 2026-09-27 21:09:26 +05:00
1 parent 881e24485c
commit a398a25934
8 files changed
+265 -19

No files matched your search

+3
View File
@@ -57,6 +57,9 @@ CESIUM_ION_TOKEN=
# ChatGPT/Codex OAuth session; the browser still receives only a short-lived
# Realtime client secret. Do not prefix with VITE_.
OPENAI_API_KEY=
# Optional: forbid API-key voice sessions. Select ChatGPT OAuth in Provider
# Settings; missing or expired Codex sign-in then fails closed.
# GEV_PREFER_CODEX_OAUTH=true
OPENAI_REALTIME_MODEL=gpt-realtime-2
# Cheaper voice tier, used when the GEV MIC panel's MINI toggle is on.
# Model ids and prices are external facts that drift — if OpenAI moves this id,
+78 -7
View File
@@ -1,5 +1,10 @@
import { spawn } from 'node:child_process';
import { accessSync, constants as fsConstants, readFileSync } from 'node:fs';
import {
accessSync,
constants as fsConstants,
readFileSync,
statSync,
} from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -16,22 +21,88 @@ export function codexAuthPath({
);
}
/** An optional operator lock that refuses the metered API-key voice lane. */
export function preferCodexOAuth(environment = process.env) {
if (!Object.hasOwn(environment, 'GEV_PREFER_CODEX_OAUTH')) return false;
const value = String(environment.GEV_PREFER_CODEX_OAUTH || '')
.trim()
.toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(value)) return true;
if (['0', 'false', 'no', 'off'].includes(value)) return false;
throw new Error(
'GEV_PREFER_CODEX_OAUTH must be true or false. Refusing to choose a voice auth mode.',
);
}
export function readCodexOAuthAccessToken({
authPath = codexAuthPath(),
readFile = readFileSync,
statFile = statSync,
nowMs = Date.now(),
} = {}) {
const unavailable =
'ChatGPT sign-in is unavailable. Sign in to Codex or the ChatGPT desktop app and try again.';
let parsed;
try {
parsed = JSON.parse(readFile(authPath, 'utf8'));
} catch {
throw new Error(
'ChatGPT sign-in is unavailable. Sign in to Codex or the ChatGPT desktop app and try again.',
);
throw new Error(unavailable);
}
const token = String(parsed?.tokens?.access_token || '').trim();
if (!token) {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(unavailable);
}
const mode = String(parsed.auth_mode || '')
.trim()
.toLowerCase();
const chatGptMode = mode
? mode === 'chatgpt' || mode === 'chatgptauthtokens'
: typeof parsed.OPENAI_API_KEY !== 'string';
const token = parsed.tokens?.access_token;
const refresh = parsed.tokens?.refresh_token;
if (
!chatGptMode ||
typeof token !== 'string' ||
!token.trim() ||
typeof refresh !== 'string' ||
!refresh.trim()
) {
throw new Error(unavailable);
}
// This only checks token shape and expiry. OpenAI verifies the signature when
// minting the short-lived Realtime credential; we never modify auth.json.
const parts = token.split('.');
if (
parts.length !== 3 ||
parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))
) {
throw new Error(unavailable);
}
let payload;
try {
payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
} catch {
throw new Error(unavailable);
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error(unavailable);
}
let expiresAtMs;
if (Object.hasOwn(payload, 'exp')) {
if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) {
throw new Error(unavailable);
}
expiresAtMs = payload.exp * 1000;
} else {
try {
expiresAtMs = statFile(authPath).mtimeMs + 60 * 60 * 1000;
} catch {
throw new Error(unavailable);
}
}
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs + 60_000) {
throw new Error(
'ChatGPT sign-in is unavailable. Sign in to Codex or the ChatGPT desktop app and try again.',
'ChatGPT sign-in has expired. Run codex login and try again.',
);
}
return token;
+28
View File
@@ -14,6 +14,7 @@ import {
import { realtimeInstructions } from './instructions.js';
import { GEV_REALTIME_TOOLS } from './tools.js';
import {
preferCodexOAuth,
readCodexOAuthAccessToken,
startCodexChatGptLogin,
} from './codex-auth.js';
@@ -174,6 +175,7 @@ function createRealtimeTokenHandler({
fetchImpl = (...args) => fetch(...args),
resolveApiKey = () => process.env.OPENAI_API_KEY,
resolveOAuthAccessToken = () => readCodexOAuthAccessToken(),
environment = process.env,
models = {},
} = {}) {
return async (req, res) => {
@@ -194,6 +196,28 @@ function createRealtimeTokenHandler({
return;
}
let oauthPinned;
try {
oauthPinned = preferCodexOAuth(environment);
} catch (error) {
res.statusCode = 503;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: error.message }));
return;
}
if (oauthPinned && authMode !== 'oauth') {
res.statusCode = 503;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
error:
'ChatGPT OAuth is required by GEV_PREFER_CODEX_OAUTH. Select USE CHATGPT OAUTH in Provider Settings.',
code: 'CODEX_OAUTH_REQUIRED',
}),
);
return;
}
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset.
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
@@ -332,6 +356,10 @@ function createRealtimeTokenHandler({
// case where a bogus ?tier= was silently downgraded to standard.
res.setHeader('X-GEV-Voice-Tier', tier);
res.setHeader('X-GEV-Voice-Model', model);
res.setHeader(
'X-GEV-Voice-Auth',
authMode === 'oauth' ? 'codex-oauth' : 'env',
);
if (requestedTier && !isKnownVoiceTier(requestedTier)) {
res.setHeader('X-GEV-Voice-Tier-Fallback', '1');
}
+82 -4
View File
@@ -4,11 +4,32 @@ import test from 'node:test';
import path from 'node:path';
import {
codexAuthPath,
preferCodexOAuth,
readCodexOAuthAccessToken,
resolveCodexExecutable,
startCodexChatGptLogin,
} from '../server/providers/openai/codex-auth.js';
const nowMs = Date.UTC(2026, 8, 27);
const jwt = (payload) =>
`${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`;
const auth = (token, extras = {}) =>
JSON.stringify({
auth_mode: 'chatgpt',
tokens: { access_token: token, refresh_token: 'refresh-fixture' },
...extras,
});
test('optional Codex OAuth pin fails closed on ambiguous values', () => {
assert.equal(preferCodexOAuth({}), false);
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'true' }), true);
assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'off' }), false);
assert.throws(
() => preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'maybe' }),
/must be true or false/,
);
});
test('Codex auth path follows explicit auth file, CODEX_HOME, then home directory', () => {
assert.equal(
codexAuthPath({
@@ -30,28 +51,85 @@ test('Codex auth path follows explicit auth file, CODEX_HOME, then home director
);
});
test('Codex OAuth token reader returns only the access token and fails closed', () => {
test('Codex OAuth reader accepts a current ChatGPT token and rejects unusable credentials', () => {
const current = jwt({ exp: nowMs / 1000 + 3600 });
assert.equal(
readCodexOAuthAccessToken({
authPath: '/unused',
readFile: () => JSON.stringify({ tokens: { access_token: ' oauth-fixture ' } }),
nowMs,
readFile: () => auth(current),
}),
'oauth-fixture',
current,
);
for (const readFile of [
() => '{bad json',
() => JSON.stringify({ tokens: {} }),
() => auth(current, { auth_mode: 'apikey' }),
() =>
JSON.stringify({
OPENAI_API_KEY: 'sk-fixture',
tokens: { access_token: current, refresh_token: 'refresh-fixture' },
}),
() =>
JSON.stringify({
auth_mode: 'chatgpt',
tokens: { access_token: current },
}),
() => auth('opaque-token'),
() => auth('header.not-json.signature'),
() => auth(jwt({ exp: 'tomorrow' })),
() => {
throw new Error('missing');
},
]) {
assert.throws(
() => readCodexOAuthAccessToken({ authPath: '/unused', readFile }),
() => readCodexOAuthAccessToken({ authPath: '/unused', readFile, nowMs }),
/ChatGPT sign-in is unavailable/,
);
}
});
test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by file age', () => {
for (const exp of [nowMs / 1000 - 1, nowMs / 1000 + 59]) {
assert.throws(
() =>
readCodexOAuthAccessToken({
authPath: '/unused',
nowMs,
readFile: () => auth(jwt({ exp })),
}),
/sign-in has expired/,
);
}
const noExp = jwt({ sub: 'fixture' });
const base = { authPath: '/unused', nowMs, readFile: () => auth(noExp) };
assert.equal(
readCodexOAuthAccessToken({
...base,
statFile: () => ({ mtimeMs: nowMs }),
}),
noExp,
);
assert.throws(
() =>
readCodexOAuthAccessToken({
...base,
statFile: () => ({ mtimeMs: nowMs - 3600_000 }),
}),
/sign-in has expired/,
);
assert.throws(
() =>
readCodexOAuthAccessToken({
...base,
statFile: () => {
throw new Error('missing');
},
}),
/sign-in is unavailable/,
);
});
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
const checked = [];
assert.equal(
+31 -2
View File
@@ -217,15 +217,44 @@ test('Realtime handler keeps API key as default and uses OAuth only when request
);
const token = routes.get('/api/realtime/token');
assert.equal((await request(token)).status, 200);
const apiResponse = await request(token);
assert.equal(apiResponse.status, 200);
assert.equal(apiResponse.headers['x-gev-voice-auth'], 'env');
assert.equal(oauthReads, 0, 'default cloud voice does not read OAuth');
assert.equal(authorizations.at(-1), 'Bearer fixture-api-key');
assert.equal((await request(token, { url: '/?auth=oauth' })).status, 200);
const oauthResponse = await request(token, { url: '/?auth=oauth' });
assert.equal(oauthResponse.status, 200);
assert.equal(oauthResponse.headers['x-gev-voice-auth'], 'codex-oauth');
assert.equal(oauthReads, 1);
assert.equal(authorizations.at(-1), 'Bearer fixture-oauth-token');
});
test('a Codex OAuth pin refuses API-key minting without silently switching modes', async (t) => {
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
const authorizations = [];
const routes = install(
openAiRealtimeProxy({
realtime: {
environment: { GEV_PREFER_CODEX_OAUTH: 'true' },
resolveApiKey: () => assert.fail('pinned OAuth cannot read an API key'),
resolveOAuthAccessToken: () => 'fixture-oauth-token',
fetchImpl: async (_url, options) => {
authorizations.push(options.headers.Authorization);
return Response.json({ value: 'fixture-ephemeral' });
},
},
}),
);
const token = routes.get('/api/realtime/token');
const refused = await request(token);
assert.equal(refused.status, 503);
assert.equal(refused.json().code, 'CODEX_OAUTH_REQUIRED');
assert.equal(authorizations.length, 0);
assert.equal((await request(token, { url: '/?auth=oauth' })).status, 200);
assert.deepEqual(authorizations, ['Bearer fixture-oauth-token']);
});
test('OAuth status and token minting never expose local OAuth to remote clients', async () => {
let oauthReads = 0;
const routes = install(
+14 -3
View File
@@ -3194,18 +3194,29 @@ test('F1: the toggle still records the next-session preference while live', () =
assert.match(ui.tierButton.title, /this session stays on/i);
});
test('OAuth cloud sessions hide API spend and do not trip the API spend guard', () => {
test('OAuth cloud sessions report unknown cost and usage without tripping the API spend guard', () => {
const { controller, ui } = costControllerHarness();
controller.status = 'listening';
controller._cost.cloudVoiceAuth = 'oauth';
controller._cost.sessionCloudVoiceAuth = 'oauth';
controller.syncCostUi();
const before = controller.costTracker.state();
assert.equal(ui.costValue.hidden, true);
assert.equal(controller.recordUsage(usdUsage(100)), null);
assert.equal(ui.costValue.hidden, false);
assert.equal(ui.costValue.textContent, 'COST UNKNOWN');
assert.deepEqual(controller.recordUsage(usdUsage(100)), {
responses: 1,
input: 0,
output: 1562500,
costKnown: false,
});
const after = controller.costTracker.state();
assert.equal(after.totalUsd, before.totalUsd);
assert.equal(controller.costCapStopped, false);
assert.match(ui.costValue.title, /1 response\(s\).*1562500 output tokens/);
controller.dc = {};
controller.responseActive = true;
controller.stop();
assert.match(ui.costValue.title, /Usage is incomplete/);
});
test('F1: when idle, toggling does re-price the preview meter', () => {
+4 -1
View File
@@ -239,7 +239,10 @@ export class GevRealtimeController extends RealtimeFacade {
status: this.status,
connection: this.connectionDiagnostics(),
});
if (this.dc && this.responseActive) this.costTracker.markIncomplete();
if (this.dc && this.responseActive) {
this.costTracker.markIncomplete();
this.syncCostUi();
}
this._connection.closeTransport();
this.stopVoiceVisualizer();
this._connection.releaseMedia();
+25 -2
View File
@@ -19,6 +19,7 @@ export class RealtimeCost {
this.voiceLimits = readStoredVoiceLimits();
this.cloudVoiceAuth = readStoredCloudVoiceAuthMode();
this.sessionCloudVoiceAuth = null;
this.oauthUsage = { responses: 0, input: 0, output: 0 };
this.costTracker = createVoiceCostTracker({
tier: this.voiceTier,
limits: this.voiceLimits,
@@ -60,7 +61,20 @@ export class RealtimeCost {
}
if (this.ui?.costValue) {
const liveAuth = this.sessionCloudVoiceAuth || this.cloudVoiceAuth;
this.ui.costValue.hidden = liveAuth === 'oauth';
if (liveAuth === 'oauth') {
const { responses, input, output } = this.oauthUsage;
this.ui.costValue.hidden = false;
this.ui.costValue.textContent = 'COST UNKNOWN';
this.ui.costValue.dataset.level = 'unknown';
this.ui.costValue.title =
`ChatGPT OAuth session: ${responses} response(s), ${input} input and ${output} output tokens reported. ` +
'USD cost and the API spend cap are unavailable for this auth mode.' +
(state.incomplete
? ' Usage is incomplete because a response was still in flight when the session ended.'
: '');
return;
}
this.ui.costValue.hidden = false;
this.ui.costValue.textContent = state.display;
this.ui.costValue.dataset.level = state.level;
this.ui.costValue.title =
@@ -141,7 +155,15 @@ export class RealtimeCost {
*/
recordUsage(usage) {
if (!usage) return null;
if (this.sessionCloudVoiceAuth === 'oauth') return null;
if (this.sessionCloudVoiceAuth === 'oauth') {
const count = (value) =>
Number.isFinite(value) && value > 0 ? value : 0;
this.oauthUsage.responses += 1;
this.oauthUsage.input += count(usage.input_tokens);
this.oauthUsage.output += count(usage.output_tokens);
this.syncCostUi();
return { ...this.oauthUsage, costKnown: false };
}
const state = this.costTracker.record(usage);
this.syncCostUi();
if (state.warnCrossed) {
@@ -198,6 +220,7 @@ export class RealtimeCost {
this.voiceLimits = readStoredVoiceLimits();
this.cloudVoiceAuth = readStoredCloudVoiceAuthMode();
this.sessionCloudVoiceAuth = this.cloudVoiceAuth;
this.oauthUsage = { responses: 0, input: 0, output: 0 };
this.costCapStopped = false;
// Provisional meter (tier-priced) so the readout shows $0.00 while
// connecting. It is REPLACED below with one bound to the model the server