feat: add OpenAI OAuth, strict model overrides, and Gmail pull mode
This commit is contained in:
+151
-6
@@ -1,11 +1,16 @@
|
||||
import OpenAI from 'openai';
|
||||
import type { ChatRequest, ChatResponse, ModelClient, MessageContentPart } from './types.js';
|
||||
import type { ChatRequest, ChatResponse, ModelClient, MessageContentPart, TokenUsage } from './types.js';
|
||||
import { getMessageTextWithTools } from './media.js';
|
||||
import { ensureValidOpenAIAuth } from '../auth/openai.js';
|
||||
|
||||
export interface OpenAIClientConfig {
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
maxTokens?: number;
|
||||
baseURL?: string;
|
||||
timeoutMs?: number;
|
||||
/** If true, use ChatGPT subscription OAuth via the Codex backend endpoint. */
|
||||
useOAuth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,20 +57,160 @@ function toOpenAIContent(content: string | MessageContentPart[]): string | OpenA
|
||||
}
|
||||
|
||||
export class OpenAIClient implements ModelClient {
|
||||
private client: OpenAI;
|
||||
private client?: OpenAI;
|
||||
private model: string;
|
||||
private defaultMaxTokens: number;
|
||||
private useOAuth: boolean;
|
||||
|
||||
constructor(config: OpenAIClientConfig) {
|
||||
this.client = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseURL,
|
||||
});
|
||||
const timeoutMs = config.timeoutMs ?? 20_000;
|
||||
this.useOAuth = Boolean(config.useOAuth);
|
||||
|
||||
// OAuth mode uses a different backend (ChatGPT Codex) and a different API shape.
|
||||
// Only initialize the OpenAI SDK for API-key providers.
|
||||
if (!this.useOAuth) {
|
||||
this.client = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseURL,
|
||||
timeout: timeoutMs,
|
||||
maxRetries: 0,
|
||||
});
|
||||
}
|
||||
this.model = config.model;
|
||||
this.defaultMaxTokens = config.maxTokens ?? 4096;
|
||||
}
|
||||
|
||||
private async chatViaOAuthCodex(request: ChatRequest): Promise<ChatResponse> {
|
||||
const CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses';
|
||||
|
||||
const auth = await ensureValidOpenAIAuth();
|
||||
|
||||
// Codex endpoint requires:
|
||||
// - instructions (non-empty)
|
||||
// - input must be a list
|
||||
// - store must be false
|
||||
// - stream must be true (SSE)
|
||||
const instructions = (request.system ?? '').trim() || 'You are helpful.';
|
||||
|
||||
const input = request.messages
|
||||
.map((m) => {
|
||||
const text = getMessageTextWithTools(m);
|
||||
if (!text) {return null;}
|
||||
return {
|
||||
role: m.role,
|
||||
content: [{ type: 'input_text', text }],
|
||||
};
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => Boolean(x));
|
||||
|
||||
const body = {
|
||||
model: this.model,
|
||||
instructions,
|
||||
store: false,
|
||||
stream: true,
|
||||
input,
|
||||
// Intentionally omit max_output_tokens: Codex endpoint rejects it.
|
||||
// Also omit tools/tool_choice for now.
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
'authorization': `Bearer ${auth.access_token}`,
|
||||
'originator': 'flynn',
|
||||
'user-agent': 'flynn/0.1',
|
||||
'session_id': `flynn-${Date.now()}`,
|
||||
};
|
||||
if (auth.account_id) {
|
||||
headers['ChatGPT-Account-Id'] = auth.account_id;
|
||||
}
|
||||
|
||||
const res = await fetch(CODEX_API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`);
|
||||
}
|
||||
|
||||
if (!res.body) {
|
||||
throw new Error('OpenAI OAuth request failed: missing response body');
|
||||
}
|
||||
|
||||
let buffer = '';
|
||||
let outputText = '';
|
||||
let usage: TokenUsage | undefined;
|
||||
|
||||
const reader = res.body.getReader();
|
||||
|
||||
const processBlock = (block: string): void => {
|
||||
const lines = block.split('\n');
|
||||
let data = '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
data += line.slice('data:'.length).trim();
|
||||
}
|
||||
}
|
||||
if (!data) {return;}
|
||||
let obj: any;
|
||||
try {
|
||||
obj = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (obj.type === 'response.output_text.delta' && typeof obj.delta === 'string') {
|
||||
outputText += obj.delta;
|
||||
}
|
||||
|
||||
if (obj.type === 'response.completed') {
|
||||
const u = obj.response?.usage;
|
||||
if (u) {
|
||||
usage = {
|
||||
inputTokens: u.input_tokens ?? 0,
|
||||
outputTokens: u.output_tokens ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.type === 'response.failed') {
|
||||
const detail = obj.response?.error?.message ?? 'OpenAI OAuth response failed';
|
||||
throw new Error(detail);
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {break;}
|
||||
buffer += Buffer.from(value).toString('utf8');
|
||||
|
||||
while (true) {
|
||||
const idx = buffer.indexOf('\n\n');
|
||||
if (idx === -1) {break;}
|
||||
const block = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
processBlock(block);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: outputText,
|
||||
stopReason: 'end_turn',
|
||||
usage: usage ?? { inputTokens: 0, outputTokens: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
async chat(request: ChatRequest): Promise<ChatResponse> {
|
||||
if (this.useOAuth) {
|
||||
return this.chatViaOAuthCodex(request);
|
||||
}
|
||||
|
||||
if (!this.client) {
|
||||
throw new Error('OpenAI client not initialized');
|
||||
}
|
||||
|
||||
const messages: OpenAI.ChatCompletionMessageParam[] = [];
|
||||
|
||||
if (request.system) {
|
||||
|
||||
Reference in New Issue
Block a user