Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b130aca66 | ||
|
|
a3eabc2aaa | ||
|
|
0f9d08018b |
No files matched your search
@@ -18,7 +18,7 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
|
||||
const { data: isRunning, isLoading } = useQuery({
|
||||
queryKey: ['process', 'status'],
|
||||
queryFn: isProcessRunning,
|
||||
refetchInterval: 10000,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
|
||||
@@ -521,12 +521,20 @@ export class CloudAccountRepo {
|
||||
try {
|
||||
const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all();
|
||||
|
||||
// DEBUG LOGS
|
||||
const activeRows = rows.filter((r) => r.isActive);
|
||||
logger.info(
|
||||
`[DEBUG] getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
|
||||
logger.debug(
|
||||
`getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
|
||||
);
|
||||
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
|
||||
|
||||
// Auto-activate the single account if none are active
|
||||
if (rows.length > 0 && activeRows.length === 0) {
|
||||
const targetRow = rows[0];
|
||||
logger.info(
|
||||
`No active account found. Auto-activating account: ${targetRow.email} (${targetRow.id})`,
|
||||
);
|
||||
orm.update(accounts).set({ isActive: 1 }).where(eq(accounts.id, targetRow.id)).run();
|
||||
targetRow.isActive = 1;
|
||||
}
|
||||
|
||||
const cloudAccounts: CloudAccount[] = [];
|
||||
for (const normalizedRow of rows) {
|
||||
|
||||
@@ -184,6 +184,8 @@ export interface OpenAIChatResponse {
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
/** Optional identifier for the model's configuration snapshot. Included for OpenAI-spec compliance. */
|
||||
system_fingerprint?: string;
|
||||
}
|
||||
|
||||
export interface OpenAIChoice {
|
||||
|
||||
@@ -84,8 +84,26 @@ export class ProxyController {
|
||||
}
|
||||
|
||||
@Post('chat/completions')
|
||||
async chatCompletions(@Body() body: OpenAIChatRequest, @Res() res: FastifyReply) {
|
||||
await this.respondOpenAIChatCompletions(body, res);
|
||||
async chatCompletions(
|
||||
@Body() body: OpenAIChatRequest,
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() res: FastifyReply,
|
||||
) {
|
||||
await this.respondOpenAIChatCompletions(body, res, req);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias route: some clients (e.g. OpenCode configured with baseURL ending in /v1/messages)
|
||||
* send requests to /v1/messages/chat/completions. Forward to the same handler so those
|
||||
* clients do not receive a 404.
|
||||
*/
|
||||
@Post('messages/chat/completions')
|
||||
async messagesCompletionsAlias(
|
||||
@Body() body: OpenAIChatRequest,
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() res: FastifyReply,
|
||||
) {
|
||||
await this.respondOpenAIChatCompletions(body, res, req);
|
||||
}
|
||||
|
||||
@Post('completions')
|
||||
@@ -303,13 +321,23 @@ export class ProxyController {
|
||||
}
|
||||
}
|
||||
|
||||
private async respondOpenAIChatCompletions(body: OpenAIChatRequest, res: FastifyReply) {
|
||||
private async respondOpenAIChatCompletions(
|
||||
body: OpenAIChatRequest,
|
||||
res: FastifyReply,
|
||||
req?: FastifyRequest,
|
||||
) {
|
||||
const requestId = uuidv4();
|
||||
const startTime = Date.now();
|
||||
trafficLogger.logInbound(requestId, '/v1/chat/completions', body, undefined, { stream: body.stream });
|
||||
|
||||
// Extract x-session-affinity header so OpenCode session stickiness works even when
|
||||
// the request body does not carry an explicit session_id field.
|
||||
const headerSessionKey = req
|
||||
? (req.headers['x-session-affinity'] as string | undefined)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const result = await this.proxyService.handleChatCompletions(body, requestId);
|
||||
const result = await this.proxyService.handleChatCompletions(body, requestId, headerSessionKey);
|
||||
|
||||
if (body.stream && this.isObservableLike(result)) {
|
||||
this.writeSseResponse(res, result, requestId, '/v1/chat/completions', startTime);
|
||||
|
||||
@@ -691,8 +691,13 @@ export class ProxyService {
|
||||
|
||||
async handleChatCompletions(
|
||||
request: OpenAIChatRequest,
|
||||
_requestId?: string,
|
||||
headerSessionKey?: string,
|
||||
): Promise<OpenAIChatResponse | Observable<string>> {
|
||||
const sessionKey = this.extractOpenAISessionKey(request);
|
||||
// Prefer the x-session-affinity header value (sent by OpenCode) over body-level session ids.
|
||||
const sessionKey =
|
||||
(headerSessionKey ? `openai:${headerSessionKey}` : undefined) ??
|
||||
this.extractOpenAISessionKey(request);
|
||||
|
||||
const targetModel = this.resolveTargetModel(request.model);
|
||||
const extraHeaders = this.createModelSpecificHeaders(request.model);
|
||||
@@ -746,7 +751,7 @@ export class ProxyService {
|
||||
token.token.upstream_proxy_url,
|
||||
extraHeaders,
|
||||
);
|
||||
return this.processStreamResponse(stream, request.model, request.stream_options);
|
||||
return this.processStreamResponse(stream, request.model);
|
||||
} catch (streamError) {
|
||||
this.logger.warn(
|
||||
`Stream path failed for model=${request.model}; falling back to non-stream generation: ${
|
||||
@@ -805,7 +810,7 @@ export class ProxyService {
|
||||
token.token.upstream_proxy_url,
|
||||
extraHeaders,
|
||||
);
|
||||
return this.processStreamResponse(stream, request.model, request.stream_options);
|
||||
return this.processStreamResponse(stream, request.model);
|
||||
}
|
||||
|
||||
const response = await this.generateInternalWithStreamFallback(
|
||||
@@ -952,38 +957,19 @@ export class ProxyService {
|
||||
private processStreamResponse(
|
||||
upstreamStream: NodeJS.ReadableStream,
|
||||
model: string,
|
||||
streamOptions?: { include_usage?: boolean },
|
||||
): Observable<string> {
|
||||
return new Observable<string>((subscriber) => {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let hasEmittedChunk = false;
|
||||
let hasSentDone = false;
|
||||
let isThinking = false;
|
||||
let toolCallIndex = 0;
|
||||
let lastUsageMetadata: GeminiResponse['usageMetadata'];
|
||||
|
||||
const streamId = `chatcmpl-${uuidv4()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Emit initial role chunk immediately to prevent strict clients (like Hermes) from timing out during long TTFT
|
||||
const initialChunk = {
|
||||
id: streamId,
|
||||
object: 'chat.completion.chunk',
|
||||
created: created,
|
||||
model: model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { role: 'assistant', content: '' },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
subscriber.next(`data: ${JSON.stringify(initialChunk)}\n\n`);
|
||||
hasEmittedChunk = true;
|
||||
|
||||
const pushChunk = (payload: Record<string, unknown>): void => {
|
||||
hasEmittedChunk = true;
|
||||
subscriber.next(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
|
||||
@@ -1004,17 +990,10 @@ export class ProxyService {
|
||||
const candidate = json.candidates?.[0];
|
||||
const parts = candidate?.content?.parts || [];
|
||||
|
||||
if (json.usageMetadata) {
|
||||
lastUsageMetadata = json.usageMetadata;
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.thought && part.text) {
|
||||
let textToEmit = part.text;
|
||||
if (!isThinking) {
|
||||
isThinking = true;
|
||||
textToEmit = `<think>\n${textToEmit}`;
|
||||
}
|
||||
// Reasoning/thinking parts: emit via reasoning_content delta field
|
||||
// so clients that support it can display thinking separately.
|
||||
const reasoningChunk = {
|
||||
id: streamId,
|
||||
object: 'chat.completion.chunk',
|
||||
@@ -1023,7 +1002,7 @@ export class ProxyService {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content: textToEmit },
|
||||
delta: { reasoning_content: part.text },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
@@ -1085,11 +1064,6 @@ export class ProxyService {
|
||||
}
|
||||
|
||||
if (part.text) {
|
||||
let textToEmit = part.text;
|
||||
if (isThinking && !part.thought) {
|
||||
isThinking = false;
|
||||
textToEmit = `\n</think>\n\n${textToEmit}`;
|
||||
}
|
||||
const contentChunk = {
|
||||
id: streamId,
|
||||
object: 'chat.completion.chunk',
|
||||
@@ -1098,7 +1072,7 @@ export class ProxyService {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content: textToEmit },
|
||||
delta: { content: part.text },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
@@ -1108,23 +1082,13 @@ export class ProxyService {
|
||||
}
|
||||
|
||||
if (candidate?.finishReason) {
|
||||
if (isThinking) {
|
||||
isThinking = false;
|
||||
const closingChunk = {
|
||||
id: streamId,
|
||||
object: 'chat.completion.chunk',
|
||||
created: created,
|
||||
model: model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content: '\n</think>\n\n' },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
pushChunk(closingChunk);
|
||||
}
|
||||
// Per the OpenAI spec, finish_reason must be "tool_calls" when the
|
||||
// response contains function calls. Gemini always sends "STOP" even
|
||||
// for tool call responses, so we infer from toolCallIndex.
|
||||
const mappedFinishReason =
|
||||
toolCallIndex > 0
|
||||
? 'tool_calls'
|
||||
: this.mapGeminiFinishReasonToOpenAIFinishReason(candidate.finishReason);
|
||||
|
||||
const finishChunk = {
|
||||
id: streamId,
|
||||
@@ -1135,9 +1099,7 @@ export class ProxyService {
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: this.mapGeminiFinishReasonToOpenAIFinishReason(
|
||||
candidate.finishReason,
|
||||
),
|
||||
finish_reason: mappedFinishReason,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1147,7 +1109,7 @@ export class ProxyService {
|
||||
subscriber.complete();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
// ignore parse errors on individual SSE lines
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1168,23 +1130,6 @@ export class ProxyService {
|
||||
],
|
||||
});
|
||||
}
|
||||
if (streamOptions?.include_usage && lastUsageMetadata) {
|
||||
const usageChunk = {
|
||||
id: streamId,
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model,
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_tokens: lastUsageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: lastUsageMetadata.candidatesTokenCount || 0,
|
||||
total_tokens:
|
||||
(lastUsageMetadata.promptTokenCount || 0) +
|
||||
(lastUsageMetadata.candidatesTokenCount || 0),
|
||||
},
|
||||
};
|
||||
pushChunk(usageChunk);
|
||||
}
|
||||
if (!hasSentDone) {
|
||||
subscriber.next('data: [DONE]\n\n');
|
||||
hasSentDone = true;
|
||||
@@ -1192,17 +1137,12 @@ export class ProxyService {
|
||||
subscriber.complete();
|
||||
});
|
||||
|
||||
upstreamStream.on('error', (err) => {
|
||||
this.logger.error('OpenAI-compatible stream error:', err);
|
||||
upstreamStream.on('error', (err: unknown) => {
|
||||
// Convert to clean Error to avoid circular reference issues (socket objects)
|
||||
const cleanError = err instanceof Error ? new Error(err.message) : new Error(String(err));
|
||||
this.logger.error(`OpenAI-compatible stream error: ${cleanError.message}`);
|
||||
subscriber.error(cleanError);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (typeof (upstreamStream as any).destroy === 'function') {
|
||||
(upstreamStream as any).destroy();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1213,10 +1153,42 @@ export class ProxyService {
|
||||
const model = response.model;
|
||||
const choice = response.choices?.[0];
|
||||
const finishReason = choice?.finish_reason ?? 'stop';
|
||||
const toolCalls = choice?.message?.tool_calls;
|
||||
const content =
|
||||
choice?.message && isString(choice.message.content) ? choice.message.content : '';
|
||||
const chunkSize = 80;
|
||||
|
||||
// Emit tool_call deltas first when the fallback non-streaming response contains tool calls.
|
||||
// Without this, tool calls are silently dropped when the primary stream path fails.
|
||||
if (toolCalls && toolCalls.length > 0) {
|
||||
for (let tcIndex = 0; tcIndex < toolCalls.length; tcIndex++) {
|
||||
const tc = toolCalls[tcIndex];
|
||||
const tcChunk = {
|
||||
id: streamId,
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: tcIndex,
|
||||
id: tc.id,
|
||||
type: tc.type,
|
||||
function: tc.function,
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
subscriber.next(`data: ${JSON.stringify(tcChunk)}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (content.length === 0) {
|
||||
const finishChunk = {
|
||||
id: streamId,
|
||||
@@ -1623,12 +1595,14 @@ export class ProxyService {
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: model,
|
||||
system_fingerprint: 'fp_gemininexus',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: finalContent ? finalContent : toolCalls.length > 0 ? null : '',
|
||||
// When tool_calls are present, content must be null per OpenAI spec.
|
||||
content: toolCalls.length > 0 ? null : (finalContent || ''),
|
||||
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
},
|
||||
finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason(
|
||||
|
||||
@@ -848,6 +848,34 @@ export class GoogleAPIService {
|
||||
): Promise<{ credits: number; expiryDate: string } | null> {
|
||||
try {
|
||||
const fetchOptions = this.getFetchOptions(proxyUrl);
|
||||
|
||||
// Try loadCodeAssist first — fetchCredits returns 404 for many accounts
|
||||
// and loadCodeAssist provides the same paid tier credit data.
|
||||
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
||||
const loadAssistResponse = await fetch(URLS.DAILY_LOAD_PROJECT, {
|
||||
method: 'POST',
|
||||
headers: buildInternalApiHeaders(accessToken),
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ide_type: 'ANTIGRAVITY',
|
||||
ide_version: discoveryVersion,
|
||||
ide_name: 'geminiNexus',
|
||||
},
|
||||
}),
|
||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (loadAssistResponse.ok) {
|
||||
const loadAssistData = (await loadAssistResponse.json()) as LoadProjectResponse;
|
||||
const credits = extractAiCreditsFromProjectContext(loadAssistData);
|
||||
if (credits) {
|
||||
return credits;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the legacy fetchCredits endpoint if loadCodeAssist
|
||||
// did not return usable credit data.
|
||||
const response = await fetch(URLS.FETCH_CREDITS, {
|
||||
method: 'POST',
|
||||
headers: buildInternalApiHeaders(accessToken),
|
||||
@@ -867,34 +895,7 @@ export class GoogleAPIService {
|
||||
return toAiCredits(data);
|
||||
}
|
||||
|
||||
if (response.status !== 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `fetchCredits` has started returning 404 for some accounts. Reuse the
|
||||
// internal `loadCodeAssist` payload, which also exposes paid tier credits.
|
||||
logger.warn('[GoogleAPIService] fetchCredits returned 404, falling back to loadCodeAssist');
|
||||
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
||||
const fallbackResponse = await fetch(URLS.DAILY_LOAD_PROJECT, {
|
||||
method: 'POST',
|
||||
headers: buildInternalApiHeaders(accessToken),
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ide_type: 'ANTIGRAVITY',
|
||||
ide_version: discoveryVersion,
|
||||
ide_name: 'geminiNexus',
|
||||
},
|
||||
}),
|
||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (!fallbackResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackData = (await fallbackResponse.json()) as LoadProjectResponse;
|
||||
return extractAiCreditsFromProjectContext(fallbackData);
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user