mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
feat(voice): add ChatGPT OAuth auth option
This commit is contained in:
1 parent
b210ab0fe4
commit
881e24485c
23 files changed
+918
-34
No files matched your search
@@ -0,0 +1,41 @@
|
||||
export const CLOUD_VOICE_AUTH_MODES = Object.freeze(['api-key', 'oauth']);
|
||||
export const DEFAULT_CLOUD_VOICE_AUTH_MODE = 'api-key';
|
||||
export const CLOUD_VOICE_AUTH_STORAGE_KEY = 'godsEyeView.voice.cloudAuth';
|
||||
|
||||
function storageRef(storage) {
|
||||
if (storage) return storage;
|
||||
try {
|
||||
return typeof localStorage === 'undefined' ? null : localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCloudVoiceAuthMode(mode) {
|
||||
const normalized = String(mode || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return CLOUD_VOICE_AUTH_MODES.includes(normalized)
|
||||
? normalized
|
||||
: DEFAULT_CLOUD_VOICE_AUTH_MODE;
|
||||
}
|
||||
|
||||
export function readStoredCloudVoiceAuthMode(storage) {
|
||||
try {
|
||||
return normalizeCloudVoiceAuthMode(
|
||||
storageRef(storage)?.getItem(CLOUD_VOICE_AUTH_STORAGE_KEY),
|
||||
);
|
||||
} catch {
|
||||
return DEFAULT_CLOUD_VOICE_AUTH_MODE;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredCloudVoiceAuthMode(mode, storage) {
|
||||
const resolved = normalizeCloudVoiceAuthMode(mode);
|
||||
try {
|
||||
storageRef(storage)?.setItem(CLOUD_VOICE_AUTH_STORAGE_KEY, resolved);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CLOUD_VOICE_AUTH_STORAGE_KEY,
|
||||
readStoredCloudVoiceAuthMode,
|
||||
writeStoredCloudVoiceAuthMode,
|
||||
} from './cloudVoiceAuth.js';
|
||||
|
||||
function memoryStorage(seed = {}) {
|
||||
const values = new Map(Object.entries(seed));
|
||||
return {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
test('cloud voice auth defaults to API key and persists OAuth independently', () => {
|
||||
const storage = memoryStorage();
|
||||
assert.equal(readStoredCloudVoiceAuthMode(storage), 'api-key');
|
||||
assert.equal(writeStoredCloudVoiceAuthMode('oauth', storage), 'oauth');
|
||||
assert.equal(readStoredCloudVoiceAuthMode(storage), 'oauth');
|
||||
assert.equal(writeStoredCloudVoiceAuthMode('unknown', storage), 'api-key');
|
||||
assert.equal(
|
||||
readStoredCloudVoiceAuthMode(
|
||||
memoryStorage({ [CLOUD_VOICE_AUTH_STORAGE_KEY]: 'oauth' }),
|
||||
),
|
||||
'oauth',
|
||||
);
|
||||
});
|
||||
@@ -3194,6 +3194,20 @@ 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', () => {
|
||||
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);
|
||||
const after = controller.costTracker.state();
|
||||
assert.equal(after.totalUsd, before.totalUsd);
|
||||
assert.equal(controller.costCapStopped, false);
|
||||
});
|
||||
|
||||
test('F1: when idle, toggling does re-price the preview meter', () => {
|
||||
const { controller } = costControllerHarness();
|
||||
controller.status = 'idle';
|
||||
|
||||
@@ -15,11 +15,19 @@ export function createRealtimeBackend({
|
||||
);
|
||||
return Object.freeze({
|
||||
protocol: 'openai-realtime',
|
||||
async requestToken({ tier = DEFAULT_VOICE_TIER, signal } = {}) {
|
||||
async requestToken({
|
||||
tier = DEFAULT_VOICE_TIER,
|
||||
authMode = 'api-key',
|
||||
signal,
|
||||
} = {}) {
|
||||
signal = scoped(signal);
|
||||
signal.throwIfAborted();
|
||||
const separator = tokenEndpoint.includes('?') ? '&' : '?';
|
||||
const url = `${tokenEndpoint}${separator}tier=${encodeURIComponent(resolveVoiceModel(tier).tier)}`;
|
||||
const query = new URLSearchParams({
|
||||
tier: resolveVoiceModel(tier).tier,
|
||||
});
|
||||
if (authMode === 'oauth') query.set('auth', 'oauth');
|
||||
const url = `${tokenEndpoint}${separator}${query}`;
|
||||
const response = await tokenTransport(url, {
|
||||
signal,
|
||||
cache: 'no-store',
|
||||
|
||||
@@ -41,6 +41,22 @@ test('separate configured token and SDP transports preserve model metadata and c
|
||||
]);
|
||||
});
|
||||
|
||||
test('OAuth is an additive token query while API key stays the default', async () => {
|
||||
const calls = [];
|
||||
const backend = createRealtimeBackend({
|
||||
tokenTransport: async (input) => {
|
||||
calls.push(input);
|
||||
return tokenReply();
|
||||
},
|
||||
});
|
||||
await backend.requestToken();
|
||||
await backend.requestToken({ authMode: 'oauth' });
|
||||
assert.deepEqual(calls, [
|
||||
'/api/realtime/token?tier=standard',
|
||||
'/api/realtime/token?tier=standard&auth=oauth',
|
||||
]);
|
||||
});
|
||||
|
||||
test('denied tokens never negotiate or retry; reconnect requests a fresh token', async () => {
|
||||
let requests = 0;
|
||||
const backend = createRealtimeBackend({
|
||||
|
||||
@@ -82,6 +82,7 @@ export class RealtimeConnection {
|
||||
this.debugLog('session.starting', {
|
||||
epoch,
|
||||
tier: this.cost.voiceTier,
|
||||
authMode: this.cost.sessionCloudVoiceAuth,
|
||||
connection: this.connectionDiagnostics(),
|
||||
});
|
||||
let localStream = null;
|
||||
@@ -89,6 +90,7 @@ export class RealtimeConnection {
|
||||
try {
|
||||
const minted = await this.backend.requestToken({
|
||||
tier: this.cost.voiceTier,
|
||||
authMode: this.cost.sessionCloudVoiceAuth,
|
||||
signal,
|
||||
});
|
||||
const token = minted.token;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
writeStoredVoiceTier,
|
||||
writeStoredVoiceLimits,
|
||||
} from './realtimePreferences.js';
|
||||
import { readStoredCloudVoiceAuthMode } from './cloudVoiceAuth.js';
|
||||
import {
|
||||
createVoiceCostTracker,
|
||||
resolveVoiceModel,
|
||||
@@ -16,6 +17,8 @@ export class RealtimeCost {
|
||||
Object.assign(this, { readUi, readStatus }, operations);
|
||||
this.voiceTier = readStoredVoiceTier();
|
||||
this.voiceLimits = readStoredVoiceLimits();
|
||||
this.cloudVoiceAuth = readStoredCloudVoiceAuthMode();
|
||||
this.sessionCloudVoiceAuth = null;
|
||||
this.costTracker = createVoiceCostTracker({
|
||||
tier: this.voiceTier,
|
||||
limits: this.voiceLimits,
|
||||
@@ -56,6 +59,8 @@ export class RealtimeCost {
|
||||
}; applies next session`;
|
||||
}
|
||||
if (this.ui?.costValue) {
|
||||
const liveAuth = this.sessionCloudVoiceAuth || this.cloudVoiceAuth;
|
||||
this.ui.costValue.hidden = liveAuth === 'oauth';
|
||||
this.ui.costValue.textContent = state.display;
|
||||
this.ui.costValue.dataset.level = state.level;
|
||||
this.ui.costValue.title =
|
||||
@@ -136,6 +141,7 @@ export class RealtimeCost {
|
||||
*/
|
||||
recordUsage(usage) {
|
||||
if (!usage) return null;
|
||||
if (this.sessionCloudVoiceAuth === 'oauth') return null;
|
||||
const state = this.costTracker.record(usage);
|
||||
this.syncCostUi();
|
||||
if (state.warnCrossed) {
|
||||
@@ -190,6 +196,8 @@ export class RealtimeCost {
|
||||
prepareSession() {
|
||||
this.voiceTier = readStoredVoiceTier();
|
||||
this.voiceLimits = readStoredVoiceLimits();
|
||||
this.cloudVoiceAuth = readStoredCloudVoiceAuthMode();
|
||||
this.sessionCloudVoiceAuth = this.cloudVoiceAuth;
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user