mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Typed chat sessions no longer end on the voice inactivity timeout, and a failed or stopped reply keeps the conversation: Stop cancels only the reply in progress, and map actions that already ran stay in the model's context. Each upstream request and map action gets its own deadline, old tool results shrink instead of the history overflowing, and step limits, a missing or rejected key, the OAuth lock and throttling each get their own message. - Server: OpenAI chat is off under GEV_PREFER_CODEX_OAUTH; chat throttles default to the provider's throttle; replies carry API_KEY_MISSING, API_KEY_REJECTED, CODEX_OAUTH_REQUIRED and RATE_LIMITED codes; upstream Retry-After passes through, and a rejected key (Gemini's invalid-key 400 included) cools down for a minute - Pinokio forwards the chat throttle settings - UI: the reply reveal publishes only the message list; the border orbit runs only while busy; layers attach with a button or Enter; the log is aria-busy while a reply types out; auto-scroll respects a reader who scrolled up; Escape closes the attach menu; the chat gear says Settings; a note says chat bills the OpenAI key when voice uses ChatGPT sign-in - Layout does less work while panel height animates; unused blurs removed - Gemini voice start ends chat; the voice preview clears on every new session - Tests for the route gates, client limits, attachments and the fixes
157 lines
5.0 KiB
JavaScript
157 lines
5.0 KiB
JavaScript
import { createTextChatHandler } from '../common/textChat.js';
|
|
import { preferCodexOAuth } from './codex-auth.js';
|
|
import { OPENAI_DEFAULT_PER_MIN } from './rate-limit.js';
|
|
import { invalidCalls } from '../common/textTools.js';
|
|
import { textInstructions } from '../common/textInstructions.js';
|
|
import { GEV_REALTIME_TOOLS } from './tools.js';
|
|
|
|
const object = (value) =>
|
|
value && typeof value === 'object' && !Array.isArray(value);
|
|
const keys = (value, allowed) =>
|
|
Object.keys(value).every((key) => allowed.includes(key));
|
|
const callShape = (call) =>
|
|
object(call) &&
|
|
keys(call, ['id', 'type', 'function']) &&
|
|
typeof call.id === 'string' &&
|
|
call.id.length > 0 &&
|
|
call.id.length <= 1024 &&
|
|
call.type === 'function' &&
|
|
object(call.function) &&
|
|
keys(call.function, ['name', 'arguments']) &&
|
|
typeof call.function.name === 'string' &&
|
|
call.function.name.length <= 128 &&
|
|
typeof call.function.arguments === 'string';
|
|
function assistantShape(message) {
|
|
return (
|
|
object(message) &&
|
|
keys(message, ['role', 'content', 'tool_calls']) &&
|
|
message.role === 'assistant' &&
|
|
(message.content == null || typeof message.content === 'string') &&
|
|
(message.tool_calls === undefined ||
|
|
(Array.isArray(message.tool_calls) &&
|
|
message.tool_calls.length > 0 &&
|
|
message.tool_calls.length <= 32 &&
|
|
message.tool_calls.every(callShape) &&
|
|
new Set(message.tool_calls.map((call) => call.id)).size ===
|
|
message.tool_calls.length))
|
|
);
|
|
}
|
|
function validMessages(messages) {
|
|
if (!Array.isArray(messages) || !messages.length || messages.length > 96)
|
|
return false;
|
|
let pending = [];
|
|
for (const message of messages) {
|
|
if (!object(message)) return false;
|
|
if (pending.length) {
|
|
if (
|
|
!keys(message, ['role', 'content', 'tool_call_id']) ||
|
|
message.role !== 'tool' ||
|
|
typeof message.content !== 'string' ||
|
|
message.tool_call_id !== pending.shift()
|
|
)
|
|
return false;
|
|
} else if (message.role === 'user') {
|
|
if (
|
|
!keys(message, ['role', 'content']) ||
|
|
typeof message.content !== 'string'
|
|
)
|
|
return false;
|
|
} else if (assistantShape(message)) {
|
|
pending = (message.tool_calls || []).map((call) => call.id);
|
|
} else return false;
|
|
}
|
|
return !pending.length;
|
|
}
|
|
|
|
export function createOpenAiChatHandler({
|
|
annotationGuidance,
|
|
tools = GEV_REALTIME_TOOLS,
|
|
resolveApiKey = () => process.env.OPENAI_API_KEY,
|
|
resolveChatModel = () => process.env.OPENAI_CHAT_MODEL || 'gpt-4.1-mini',
|
|
resolveProviderRateLimit = () => process.env.GEV_RATELIMIT_OPENAI_PER_MIN,
|
|
environment = process.env,
|
|
...options
|
|
} = {}) {
|
|
return createTextChatHandler({
|
|
...options,
|
|
provider: 'openai',
|
|
resolveApiKey,
|
|
resolveProviderRateLimit,
|
|
providerPerMin: OPENAI_DEFAULT_PER_MIN,
|
|
// Chat can only bill the API key, which this operator lock forbids.
|
|
refuse() {
|
|
let error =
|
|
'ChatGPT OAuth is required by GEV_PREFER_CODEX_OAUTH, and typed OpenAI chat needs an API key.';
|
|
try {
|
|
if (!preferCodexOAuth(environment)) return null;
|
|
} catch (invalid) {
|
|
error = invalid.message;
|
|
}
|
|
return { error, code: 'CODEX_OAUTH_REQUIRED' };
|
|
},
|
|
prepare(input) {
|
|
const model = String(resolveChatModel()).trim();
|
|
if (
|
|
!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,119}$/.test(model) ||
|
|
/realtime|audio|tts/i.test(model)
|
|
)
|
|
return { errorStatus: 503 };
|
|
if (
|
|
!object(input) ||
|
|
!keys(input, ['messages']) ||
|
|
!validMessages(input.messages)
|
|
)
|
|
return null;
|
|
return {
|
|
endpoint: 'https://api.openai.com/v1/chat/completions',
|
|
headers: (key) => ({ Authorization: `Bearer ${key}` }),
|
|
body: {
|
|
model,
|
|
n: 1,
|
|
stream: false,
|
|
store: false,
|
|
max_completion_tokens: 4096,
|
|
messages: [
|
|
{ role: 'system', content: textInstructions(annotationGuidance) },
|
|
...input.messages,
|
|
],
|
|
tools: tools.map(({ name, description, parameters }) => ({
|
|
type: 'function',
|
|
function: { name, description, parameters, strict: false },
|
|
})),
|
|
},
|
|
};
|
|
},
|
|
parse(data) {
|
|
const choice = data?.choices?.[0];
|
|
const raw = choice?.message;
|
|
if (!raw) return null;
|
|
const content = {
|
|
role: raw.role,
|
|
content: raw.content ?? null,
|
|
...(raw.tool_calls?.length ? { tool_calls: raw.tool_calls } : {}),
|
|
};
|
|
if (
|
|
!assistantShape(content) ||
|
|
(!content.content && !content.tool_calls?.length)
|
|
)
|
|
return null;
|
|
const calls = (content.tool_calls || []).map((call) => {
|
|
try {
|
|
return {
|
|
name: call.function.name,
|
|
args: JSON.parse(call.function.arguments),
|
|
};
|
|
} catch {
|
|
return { name: call.function.name, parseError: true };
|
|
}
|
|
});
|
|
return {
|
|
content,
|
|
finishReason: choice.finish_reason,
|
|
invalidCalls: invalidCalls(calls, tools),
|
|
};
|
|
},
|
|
});
|
|
}
|