Compare commits

..
Author SHA1 Message Date
Gemini Nexus Bot a5e98b02b1 fix(proxy): ensure OpenAI tool call streaming emits proper delta chunks and increase body limit
- Emit tool call id+name in first SSE delta and arguments in subsequent deltas
- Split large tool arguments into 1000-char chunks to prevent framing issues
- Set FastifyAdapter bodyLimit to 10MB to handle large payloads
- Add TDD tests for tool call streaming verification
2026-04-22 01:37:26 -03:00
18 changed files with 348 additions and 866 deletions

No files matched your search

-266
View File
@@ -1,266 +0,0 @@
**✅ Documento Completo: Integração com Custom Endpoint do OpenCode (Provedores Custom / OpenAI-Compatible)**
OpenCode (https://opencode.ai) é um agente de IA para terminal que usa o **Vercel AI SDK** para se comunicar com provedores de LLM. Ele suporta nativamente provedores customizados via o pacote `@ai-sdk/openai-compatible`, que segue o padrão **OpenAI Chat Completions API** (`/v1/chat/completions`).
Isso significa que **sua API precisa implementar exatamente o formato OpenAI-compatible** para que o OpenCode consiga conversar com ela diretamente.
---
### 1. Como Configurar um Provedor Custom no OpenCode
Crie ou edite o arquivo de configuração (recomendado: `~/.config/opencode/opencode.json` ou `opencode.json` na raiz do projeto):
```json
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"minha-ia-custom": {
"npm": "@ai-sdk/openai-compatible",
"name": "Minha IA Custom (OpenCode)",
"options": {
"baseURL": "https://sua-api.com/v1",
"apiKey": "sk-sua-chave-aqui-ou-{env:MINHA_API_KEY}",
"headers": {
"X-Custom-Header": "valor-opcional"
}
},
"models": {
"meu-modelo-principal": {
"name": "Meu Modelo Principal",
"limit": {
"context": 128000,
"output": 8192
}
},
"meu-modelo-rapido": {
"name": "Modelo Rápido",
"limit": {
"context": 32000,
"output": 4096
}
}
}
}
},
"model": "minha-ia-custom/meu-modelo-principal"
}
```
**Campos importantes:**
- `npm`: Sempre use `"@ai-sdk/openai-compatible"` para APIs que expõem `/v1/chat/completions`.
- `baseURL`: URL base da sua API (deve terminar com `/v1`).
- `apiKey`: Pode ser valor direto ou `{env:NOME_VARIAVEL}`.
- `models`: Defina os modelos que sua API expõe. O `limit` ajuda o OpenCode a gerenciar contexto.
- `model`: Define o modelo padrão no formato `provider_id/model_id`.
Depois de salvar, reinicie o OpenCode ou use `/models` para selecionar.
**Comando rápido para adicionar chave:**
```bash
opencode /connect
# Escolha "Other" → digite o provider_id (ex: minha-ia-custom) → cole a API key
```
---
### 2. Como o OpenCode Envia Requisições
O OpenCode (via AI SDK) faz chamadas **POST** para:
```
POST {baseURL}/chat/completions
```
**Headers enviados:**
- `Authorization: Bearer <sua-api-key>`
- `Content-Type: application/json`
- Headers customizados que você definir em `options.headers`
**Payload típico (exemplo real):**
```json
{
"model": "meu-modelo-principal",
"messages": [
{
"role": "system",
"content": "Você é um assistente de código experiente..."
},
{
"role": "user",
"content": "Crie uma função em Python que..."
}
],
"stream": true,
"temperature": 0.7,
"max_tokens": 8192,
"tools": [ /* array de tools se o agente estiver usando ferramentas */ ],
"tool_choice": "auto"
}
```
**Parâmetros comuns que o OpenCode envia:**
- `messages` (array completo do histórico)
- `stream: true` (quase sempre, para UX em tempo real)
- `temperature`, `max_tokens`, `top_p`
- `tools` + `tool_choice` (quando usa agentes com ferramentas/MCP)
- `response_format` (às vezes)
- `providerOptions` (opções específicas do provedor – raras no openai-compatible)
---
### 3. Como o OpenCode Recebe e Processa Respostas
#### A. Streaming (recomendado – usado na maioria das interações)
Sua API deve responder com `Content-Type: text/event-stream` e eventos SSE:
```
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1712345678,"model":"meu-modelo-principal","choices":[{"index":0,"delta":{"content":"Olá"},"finish_reason":null}]}
data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"content":"! Como"},"finish_reason":null}]}
data: [DONE]
```
O OpenCode processa cada chunk em tempo real.
#### B. Resposta não-streaming (completa)
```json
{
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1712345678,
"model": "meu-modelo-principal",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Resposta completa aqui..."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 150,
"completion_tokens": 320,
"total_tokens": 470
}
}
```
#### Suporte a Tool Calling (essencial para agentes)
Se sua API suportar `tools`, o OpenCode pode:
1. Enviar `tools` na requisição.
2. Receber `tool_calls` na resposta.
3. Executar a ferramenta (via MCP ou built-in).
4. Enviar o resultado de volta como mensagem `tool`.
**Exemplo de resposta com tool call:**
```json
{
"choices": [{
"message": {
"role": "assistant",
"tool_calls": [{
"id": "call_123",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"path\": \"src/app.ts\"}"
}
}]
}
}]
}
```
---
### 4. Como é Exibido no Chat do OpenCode (TUI)
- **Streaming**: Texto aparece caractere por caractere com efeito de digitação.
- **Markdown**: Suporte completo a código (syntax highlight), listas, tabelas, etc.
- **Tool Calls**: Mostra “Executando ferramenta: read_file...” + resultado.
- **Multi-turn**: Mantém histórico completo na conversa.
- **Erros**: Mostra mensagens de erro claras no terminal.
- **Contexto**: Respeita os limites de `context` e `output` que você definiu no config.
O OpenCode usa o TUI (Terminal User Interface) baseado em React/Ink para renderizar tudo de forma bonita.
---
### 5. Requisitos Mínimos da Sua API para Funcionar Perfeitamente
| Recurso | Obrigatório? | Recomendado? | Observação |
|----------------------|--------------|--------------|----------|
| `/v1/chat/completions` | Sim | - | Endpoint principal |
| Streaming (SSE) | Sim | - | Essencial para UX |
| Tool Calling | Não | Sim | Para agentes completos |
| System messages | Sim | - | - |
| Multi-turn history | Sim | - | - |
| Usage stats | Não | Sim | Mostra tokens gastos |
| Error handling | Sim | - | Retorne JSON de erro padrão OpenAI |
**Endpoint exato esperado:**
`POST https://sua-api.com/v1/chat/completions`
---
### 6. Exemplo Completo de Configuração + Teste
1. Configure o `opencode.json` como acima.
2. Rode:
```bash
opencode
```
3. Digite algo ou use `/models` para selecionar seu provedor.
4. Teste com:
```bash
curl -X POST https://sua-api.com/v1/chat/completions \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{"model":"meu-modelo-principal","messages":[{"role":"user","content":"Olá"}],"stream":true}'
```
---
### 7. Troubleshooting Comum
- **“Route not found” ou requests indo para lugar errado** → Verifique se `baseURL` termina com `/v1` e não com `/v1/chat/completions`.
- **Custom provider não aparece** → Certifique-se que o ID do provider no `/connect` bate com a chave no `opencode.json`.
- **Sem streaming / resposta lenta** → Implemente SSE corretamente.
- **Tool calls não funcionam** → Sua API precisa retornar `tool_calls` no formato OpenAI exato.
- **Erro de autenticação** → Use `apiKey` ou `headers` corretamente.
---
### Referências Oficiais
- **Docs Principais**: https://opencode.ai/docs/providers/
- **Models**: https://opencode.ai/docs/models/
- **Config**: https://opencode.ai/docs/config/
- **Schema de Config**: https://opencode.ai/config.json
- **GitHub**: https://github.com/anomalyco/opencode
- **AI SDK OpenAI-Compatible**: https://ai-sdk.dev/providers/openai-compatible-providers
---
**Resumo Final para Você (que está construindo a API):**
Sua API **precisa ser 100% compatível** com o endpoint OpenAI `/v1/chat/completions` (streaming + tool calling recomendado).
O OpenCode vai:
1. Configurar via `opencode.json` (baseURL + apiKey)
2. Enviar requisições padrão OpenAI
3. Receber streaming ou resposta completa
4. Renderizar tudo lindamente no TUI com suporte a ferramentas
Se você implementar exatamente o padrão OpenAI Chat Completions, vai funcionar perfeitamente com o OpenCode.
Quer que eu monte um **exemplo de servidor mínimo** (em Node.js/Fastify ou Python/FastAPI) que já funcione com o OpenCode? Posso criar um template completo para você.
+3 -3
View File
@@ -450,7 +450,7 @@ const config: ForgeConfig = {
new MakerSquirrel({
setupIcon: 'images/icon.ico',
iconUrl:
'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
'https://raw.githubusercontent.com/Draculabo/AntigravityManager/main/images/icon.ico',
}),
...(process.platform === 'win32' && process.arch === 'x64'
? [
@@ -484,8 +484,8 @@ const config: ForgeConfig = {
name: '@electron-forge/publisher-github',
config: {
repository: {
owner: 'evandrodevbr',
name: 'GeminiNexus',
owner: 'Draculabo',
name: 'AntigravityManager',
},
draft: true,
prerelease: false,
-1
View File
@@ -3,7 +3,6 @@
<head>
<meta charset="UTF-8" />
<title>Gemini Nexus</title>
<link rel="icon" type="image/png" href="/src/assets/icon.png" />
<meta http-equiv="Content-Security-Policy" content="script-src 'self';" />
<link rel="stylesheet" href="/src/styles/global.css" />
</head>
+1 -2
View File
@@ -10,7 +10,6 @@
"dev": "cross-env DEBUG=electron-forge:* electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"make:win": "node scripts/build-windows.js",
"publish": "electron-forge publish",
"lint": "eslint .",
"format": "prettier --check .",
@@ -22,7 +21,7 @@
"test:all": "vitest run && playwright test",
"type-check": "tsc --noEmit"
},
"author": "evandrodevbr",
"author": "Draculabo",
"license": "CC-BY-NC-SA-4.0",
"devDependencies": {
"@electron-forge/cli": "^7.11.1",
-50
View File
@@ -1,50 +0,0 @@
const { execSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const WIX_ZIP = path.join(process.cwd(), 'wix314-binaries.zip');
const WIX_DIR = path.join(process.cwd(), 'tools', 'wix314');
function extractWix() {
if (!fs.existsSync(WIX_ZIP)) {
console.error(`WiX zip not found: ${WIX_ZIP}`);
process.exit(1);
}
if (fs.existsSync(WIX_DIR)) {
console.log(`WiX already extracted at ${WIX_DIR}`);
return;
}
console.log(`Extracting WiX binaries to ${WIX_DIR}...`);
fs.mkdirSync(WIX_DIR, { recursive: true });
// Use PowerShell Expand-Archive (built into Windows)
const psCommand = `Expand-Archive -Path "${WIX_ZIP}" -DestinationPath "${WIX_DIR}" -Force`;
execSync(psCommand, { shell: 'powershell.exe', stdio: 'inherit' });
console.log('WiX extraction complete.');
}
function setupWixPath() {
// electron-wix-msi's detect-wix.js runs execSync('candle -?') which relies on PATH
process.env.PATH = `${WIX_DIR};${process.env.PATH}`;
console.log(`Added ${WIX_DIR} to PATH`);
}
function runMake() {
console.log('Running electron-forge make...');
const make = spawn('npx', ['electron-forge', 'make'], {
stdio: 'inherit',
shell: true,
env: process.env,
});
make.on('exit', (code) => {
process.exit(code ?? 0);
});
}
extractWix();
setupWixPath();
runMake();
+1 -1
View File
@@ -18,7 +18,7 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
const { data: isRunning, isLoading } = useQuery({
queryKey: ['process', 'status'],
queryFn: isProcessRunning,
refetchInterval: 30000,
refetchInterval: 10000,
});
const startMutation = useMutation({
+4 -12
View File
@@ -521,20 +521,12 @@ 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.debug(
`getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
logger.info(
`[DEBUG] getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
);
// 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;
}
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
const cloudAccounts: CloudAccount[] = [];
for (const normalizedRow of rows) {
+7 -3
View File
@@ -22,9 +22,13 @@ export async function bootstrapNestServer(config: ProxyConfig): Promise<boolean>
setServerConfig(config);
try {
app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), {
logger: ['error', 'warn', 'log'],
});
app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ bodyLimit: 10 * 1024 * 1024 }),
{
logger: ['error', 'warn', 'log'],
},
);
// Enable CORS
app.enableCors();
@@ -184,8 +184,6 @@ 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 {
+4 -32
View File
@@ -84,26 +84,8 @@ export class ProxyController {
}
@Post('chat/completions')
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);
async chatCompletions(@Body() body: OpenAIChatRequest, @Res() res: FastifyReply) {
await this.respondOpenAIChatCompletions(body, res);
}
@Post('completions')
@@ -321,23 +303,13 @@ export class ProxyController {
}
}
private async respondOpenAIChatCompletions(
body: OpenAIChatRequest,
res: FastifyReply,
req?: FastifyRequest,
) {
private async respondOpenAIChatCompletions(body: OpenAIChatRequest, res: FastifyReply) {
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, headerSessionKey);
const result = await this.proxyService.handleChatCompletions(body, requestId);
if (body.stream && this.isObservableLike(result)) {
this.writeSseResponse(res, result, requestId, '/v1/chat/completions', startTime);
+125 -100
View File
@@ -33,7 +33,6 @@ import {
import { getMaxOutputTokens, getThinkingBudget } from '../../../lib/geminiNexus/ModelSpecs';
import { resolveRequestUserAgent } from './request-user-agent';
import { UpstreamRequestError } from './clients/upstream-error';
import { trafficLogger } from '../../../utils/traffic-logger';
@Injectable()
export class ProxyService {
@@ -692,13 +691,9 @@ export class ProxyService {
async handleChatCompletions(
request: OpenAIChatRequest,
requestId?: string,
headerSessionKey?: string,
_requestId?: string,
): Promise<OpenAIChatResponse | Observable<string>> {
// 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 sessionKey = this.extractOpenAISessionKey(request);
const targetModel = this.resolveTargetModel(request.model);
const extraHeaders = this.createModelSpecificHeaders(request.model);
@@ -746,14 +741,13 @@ export class ProxyService {
// Use v1internal API (same as Anthropic handler)
if (request.stream) {
try {
trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', geminiBody, extraHeaders, { model: effectiveTargetModel });
const stream = await this.geminiClient.streamGenerateInternal(
geminiBody,
token.token.access_token,
token.token.upstream_proxy_url,
extraHeaders,
);
return this.processStreamResponse(stream, request.model, requestId);
return this.processStreamResponse(stream, request.model, request.stream_options);
} catch (streamError) {
this.logger.warn(
`Stream path failed for model=${request.model}; falling back to non-stream generation: ${
@@ -761,14 +755,12 @@ export class ProxyService {
}`,
);
trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', geminiBody, extraHeaders, { model: effectiveTargetModel });
const response = await this.generateInternalWithStreamFallback(
geminiBody,
token.token.access_token,
token.token.upstream_proxy_url,
extraHeaders,
);
trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, response, 0);
this.logger.log(
`Upstream response snippet after stream fallback: ${JSON.stringify(response).substring(0, 500)}`,
);
@@ -808,24 +800,21 @@ export class ProxyService {
fallbackBody.model = effectiveTargetModel;
this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id);
if (request.stream) {
trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', fallbackBody, extraHeaders, { model: effectiveTargetModel });
const stream = await this.geminiClient.streamGenerateInternal(
fallbackBody,
token.token.access_token,
token.token.upstream_proxy_url,
extraHeaders,
);
return this.processStreamResponse(stream, request.model, requestId);
return this.processStreamResponse(stream, request.model, request.stream_options);
}
trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', fallbackBody, extraHeaders, { model: effectiveTargetModel });
const response = await this.generateInternalWithStreamFallback(
fallbackBody,
token.token.access_token,
token.token.upstream_proxy_url,
extraHeaders,
);
trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, response, 0);
const claudeResponse = transformResponse(response);
return this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
} catch (fallbackErr) {
@@ -964,40 +953,38 @@ export class ProxyService {
private processStreamResponse(
upstreamStream: NodeJS.ReadableStream,
model: string,
requestId?: string,
streamOptions?: { include_usage?: boolean },
): Observable<string> {
return new Observable<string>((subscriber) => {
const decoder = new TextDecoder();
let buffer = '';
let hasEmittedChunk = false;
let hasSentRoleChunk = 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);
const pushRoleChunk = (): void => {
if (hasSentRoleChunk) return;
hasSentRoleChunk = true;
const roleChunk = {
id: streamId,
object: 'chat.completion.chunk',
created,
model,
choices: [
{
index: 0,
delta: { role: 'assistant', content: '' },
finish_reason: null,
},
],
};
subscriber.next(`data: ${JSON.stringify(roleChunk)}\n\n`);
// 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`);
};
@@ -1015,16 +1002,20 @@ export class ProxyService {
try {
const json = JSON.parse(dataStr);
trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, json, 0, { rawLine: trimmed });
// v1internal API wraps response in {"response": {"candidates": [...]}}
const responsePayload = json.response ?? json;
const candidate = responsePayload.candidates?.[0];
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) {
// Reasoning/thinking parts: emit via reasoning_content delta field
// so clients that support it can display thinking separately.
let textToEmit = part.text;
if (!isThinking) {
isThinking = true;
textToEmit = `<think>\n${textToEmit}`;
}
const reasoningChunk = {
id: streamId,
object: 'chat.completion.chunk',
@@ -1033,20 +1024,22 @@ export class ProxyService {
choices: [
{
index: 0,
delta: { reasoning_content: part.text },
delta: { content: textToEmit },
finish_reason: null,
},
],
};
pushRoleChunk();
pushChunk(reasoningChunk);
continue;
}
if (part.functionCall) {
pushRoleChunk();
const currentToolIndex = toolCallIndex++;
const toolCallChunk = {
const toolCallId = part.functionCall.id || `${part.functionCall.name}-${uuidv4()}`;
const argsJson = JSON.stringify(part.functionCall.args || {});
// First delta: id, type, name, empty arguments
pushChunk({
id: streamId,
object: 'chat.completion.chunk',
created: created,
@@ -1058,11 +1051,11 @@ export class ProxyService {
tool_calls: [
{
index: currentToolIndex,
id: part.functionCall.id || `${part.functionCall.name}-${uuidv4()}`,
id: toolCallId,
type: 'function',
function: {
name: part.functionCall.name,
arguments: JSON.stringify(part.functionCall.args || {}),
arguments: '',
},
},
],
@@ -1070,8 +1063,35 @@ export class ProxyService {
finish_reason: null,
},
],
};
pushChunk(toolCallChunk);
});
// Subsequent deltas: only arguments (split if large to respect SSE framing)
const ARG_CHUNK_SIZE = 1000;
for (let i = 0; i < argsJson.length; i += ARG_CHUNK_SIZE) {
const slice = argsJson.slice(i, i + ARG_CHUNK_SIZE);
pushChunk({
id: streamId,
object: 'chat.completion.chunk',
created: created,
model: model,
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: currentToolIndex,
function: {
arguments: slice,
},
},
],
},
finish_reason: null,
},
],
});
}
continue;
}
@@ -1092,13 +1112,16 @@ export class ProxyService {
},
],
};
pushRoleChunk();
pushChunk(imageChunk);
continue;
}
if (part.text) {
pushRoleChunk();
let textToEmit = part.text;
if (isThinking && !part.thought) {
isThinking = false;
textToEmit = `\n</think>\n\n${textToEmit}`;
}
const contentChunk = {
id: streamId,
object: 'chat.completion.chunk',
@@ -1107,7 +1130,7 @@ export class ProxyService {
choices: [
{
index: 0,
delta: { content: part.text },
delta: { content: textToEmit },
finish_reason: null,
},
],
@@ -1117,13 +1140,23 @@ export class ProxyService {
}
if (candidate?.finishReason) {
// 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);
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);
}
const finishChunk = {
id: streamId,
@@ -1134,7 +1167,9 @@ export class ProxyService {
{
index: 0,
delta: {},
finish_reason: mappedFinishReason,
finish_reason: this.mapGeminiFinishReasonToOpenAIFinishReason(
candidate.finishReason,
),
},
],
};
@@ -1144,7 +1179,7 @@ export class ProxyService {
subscriber.complete();
}
} catch {
// ignore parse errors on individual SSE lines
// ignore parse errors
}
}
});
@@ -1165,6 +1200,23 @@ 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;
@@ -1172,12 +1224,17 @@ export class ProxyService {
subscriber.complete();
});
upstreamStream.on('error', (err: unknown) => {
// Convert to clean Error to avoid circular reference issues (socket objects)
upstreamStream.on('error', (err) => {
this.logger.error('OpenAI-compatible stream error:', err);
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();
}
};
});
}
@@ -1188,42 +1245,10 @@ 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,
@@ -1630,14 +1655,12 @@ export class ProxyService {
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: model,
system_fingerprint: 'fp_gemininexus',
choices: [
{
index: 0,
message: {
role: 'assistant',
// When tool_calls are present, content must be null per OpenAI spec.
content: toolCalls.length > 0 ? null : (finalContent || ''),
content: finalContent ? finalContent : toolCalls.length > 0 ? null : '',
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
},
finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason(
@@ -1705,7 +1728,9 @@ export class ProxyService {
if (error instanceof UpstreamRequestError) {
const status = error.status;
if (status === 401) {
this.logger.warn(`Upstream returned 401 for account ${accountId}; attempting token refresh`);
this.logger.warn(
`Upstream returned 401 for account ${accountId}; attempting token refresh`,
);
const refreshed = await this.tokenManager.forceRefreshToken(accountId);
if (refreshed) {
this.logger.log(`Token refreshed successfully for account ${accountId}; retry permitted`);
+28 -29
View File
@@ -848,34 +848,6 @@ 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),
@@ -895,7 +867,34 @@ export class GoogleAPIService {
return toAiCredits(data);
}
return null;
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);
} catch {
return null;
}
@@ -11,10 +11,6 @@ function createReplyMock() {
return reply;
}
function createReqMock() {
return { headers: {} } as any;
}
describe('ProxyController Integration', () => {
it('routes Claude OpenAI requests to protocol parity path', async () => {
const proxyService = {
@@ -23,7 +19,6 @@ describe('ProxyController Integration', () => {
};
const controller = new ProxyController(proxyService as any);
const reply = createReplyMock();
const req = createReqMock();
await controller.chatCompletions(
{
@@ -31,7 +26,6 @@ describe('ProxyController Integration', () => {
stream: false,
messages: [{ role: 'user', content: 'hello' }],
} as any,
req,
reply as any,
);
@@ -48,7 +42,6 @@ describe('ProxyController Integration', () => {
};
const controller = new ProxyController(proxyService as any);
const reply = createReplyMock();
const req = createReqMock();
await controller.chatCompletions(
{
@@ -56,7 +49,6 @@ describe('ProxyController Integration', () => {
stream: true,
messages: [{ role: 'user', content: 'hello' }],
} as any,
req,
reply as any,
);
-2
View File
@@ -648,7 +648,6 @@ describe('ProxyService Protocol Parity Fixtures', () => {
});
const output = chunks.join('');
expect(output).toContain('"role":"assistant"');
expect(output).toContain('"reasoning_content":"reasoning text"');
expect(output).toContain('"tool_calls"');
expect(output).toContain('"content":"final answer"');
@@ -690,7 +689,6 @@ describe('ProxyService Protocol Parity Fixtures', () => {
expect(streamResult.completed).not.toBe(true);
expect(streamResult.error?.message).toContain('socket hang up');
expect(chunks.join('')).toContain('"role":"assistant"');
expect(chunks.join('')).toContain('"content":"partial output"');
expect(chunks.join('')).not.toContain('data: [DONE]');
});
-329
View File
@@ -1,329 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { EventEmitter } from 'events';
import { ProxyService } from '../../server/modules/proxy/proxy.service';
// Minimal mocks so ProxyService can instantiate
const mockTokenManager = {
getNextToken: vi.fn(),
markAsRateLimited: vi.fn(),
markAsForbidden: vi.fn(),
forceRefreshToken: vi.fn(),
markFromUpstreamError: vi.fn(),
recordParityError: vi.fn(),
getModelOutputLimitForAccount: vi.fn(),
getModelThinkingBudgetForAccount: vi.fn(),
resolveDynamicModelForAccount: vi.fn((_token: unknown, model: string) => model),
};
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
class TestableProxyService extends ProxyService {
constructor() {
super(mockTokenManager as any, mockGeminiClient as any);
}
public testProcessStreamResponse(
stream: any,
model: string = 'gemini-3.1-pro-low',
requestId?: string,
) {
return (this as any).processStreamResponse(stream, model, requestId);
}
}
function collectChunks(observable: any): Promise<string[]> {
return new Promise((resolve, reject) => {
const chunks: string[] = [];
observable.subscribe({
next: (chunk: string) => chunks.push(chunk),
error: reject,
complete: () => resolve(chunks),
});
});
}
function emitSseLine(stream: EventEmitter, payload: object): void {
stream.emit('data', Buffer.from(`data: ${JSON.stringify(payload)}\n`));
}
describe('ProxyService Stream Debug', () => {
it('emits role chunk followed by content for a simple text response', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
emitSseLine(stream, {
candidates: [
{
content: {
parts: [{ text: 'Hello world' }],
},
},
],
});
emitSseLine(stream, {
candidates: [
{
content: {
parts: [{ text: '!' }],
},
finishReason: 'STOP',
},
],
});
stream.emit('end');
const chunks = await promise;
console.log('=== Simple text response chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
// Should have at least role + content + finish + DONE
expect(chunks.length).toBeGreaterThanOrEqual(4);
expect(chunks[0]).toContain('"role":"assistant"');
expect(chunks.some((c) => c.includes('"content":"Hello world"'))).toBe(true);
expect(chunks.some((c) => c.includes('"finish_reason":"stop"'))).toBe(true);
expect(chunks[chunks.length - 1]).toContain('[DONE]');
});
it('handles empty parts array gracefully', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
emitSseLine(stream, {
candidates: [
{
content: {
parts: [],
},
finishReason: 'STOP',
},
],
});
stream.emit('end');
const chunks = await promise;
console.log('=== Empty parts response chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
// With empty parts, only finish + DONE should be emitted
expect(chunks.length).toBeGreaterThanOrEqual(2);
expect(chunks.some((c) => c.includes('"finish_reason":"stop"'))).toBe(true);
expect(chunks[chunks.length - 1]).toContain('[DONE]');
});
it('handles reasoning content (thought parts)', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
emitSseLine(stream, {
candidates: [
{
content: {
parts: [{ thought: true, text: 'Let me think...' }],
},
},
],
});
emitSseLine(stream, {
candidates: [
{
content: {
parts: [{ text: 'Final answer' }],
},
finishReason: 'STOP',
},
],
});
stream.emit('end');
const chunks = await promise;
console.log('=== Reasoning response chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
expect(chunks.some((c) => c.includes('"reasoning_content":"Let me think..."'))).toBe(true);
expect(chunks.some((c) => c.includes('"content":"Final answer"'))).toBe(true);
});
it('handles function call parts', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
emitSseLine(stream, {
candidates: [
{
content: {
parts: [
{
functionCall: {
id: 'fc-1',
name: 'get_weather',
args: { city: 'London' },
},
},
],
},
finishReason: 'STOP',
},
],
});
stream.emit('end');
const chunks = await promise;
console.log('=== Function call response chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
expect(chunks.some((c) => c.includes('"tool_calls"'))).toBe(true);
expect(chunks.some((c) => c.includes('"finish_reason":"tool_calls"'))).toBe(true);
});
it('handles multiple SSE lines in a single buffer', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
// Simulate two complete SSE lines in one data chunk
const payload1 = JSON.stringify({
candidates: [{ content: { parts: [{ text: 'First' }] } }],
});
const payload2 = JSON.stringify({
candidates: [{ content: { parts: [{ text: 'Second' }] }, finishReason: 'STOP' }],
});
stream.emit('data', Buffer.from(`data: ${payload1}\ndata: ${payload2}\n`));
stream.emit('end');
const chunks = await promise;
console.log('=== Multi-line buffer chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
expect(chunks.some((c) => c.includes('"content":"First"'))).toBe(true);
expect(chunks.some((c) => c.includes('"content":"Second"'))).toBe(true);
expect(chunks[chunks.length - 1]).toContain('[DONE]');
});
it('handles partial SSE lines across multiple data events', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
const payload = JSON.stringify({
candidates: [{ content: { parts: [{ text: 'Split' }] }, finishReason: 'STOP' }],
});
const line = `data: ${payload}\n`;
// Split the line across two data events
const mid = Math.floor(line.length / 2);
stream.emit('data', Buffer.from(line.slice(0, mid)));
stream.emit('data', Buffer.from(line.slice(mid)));
stream.emit('end');
const chunks = await promise;
console.log('=== Split-line buffer chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
expect(chunks.some((c) => c.includes('"content":"Split"'))).toBe(true);
expect(chunks[chunks.length - 1]).toContain('[DONE]');
});
it('handles Gemini v1internal wrapped response format (response.candidates)', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
// v1internal API wraps the payload in {"response": {"candidates": [...]}}
stream.emit(
'data',
Buffer.from(
`data: ${JSON.stringify({
response: {
candidates: [
{
content: {
parts: [{ text: 'Hello from wrapped' }],
},
},
],
},
traceId: 'abc123',
metadata: {},
})}\n`,
),
);
stream.emit(
'data',
Buffer.from(
`data: ${JSON.stringify({
response: {
candidates: [
{
content: {
parts: [{ text: ' response' }],
},
finishReason: 'STOP',
},
],
},
traceId: 'abc123',
metadata: {},
})}\n`,
),
);
stream.emit('end');
const chunks = await promise;
console.log('=== Wrapped response format chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
expect(chunks.some((c) => c.includes('"content":"Hello from wrapped"'))).toBe(true);
expect(chunks.some((c) => c.includes('"content":" response"'))).toBe(true);
expect(chunks[chunks.length - 1]).toContain('[DONE]');
});
it('handles Gemini streaming chunks with no content field', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStreamResponse(stream, 'gemini-3.1-pro-low');
const promise = collectChunks(observable);
// Sometimes Gemini sends candidates without content at all
emitSseLine(stream, {
candidates: [{ index: 0 }],
});
emitSseLine(stream, {
candidates: [{ content: { parts: [{ text: 'Late content' }] }, finishReason: 'STOP' }],
});
stream.emit('end');
const chunks = await promise;
console.log('=== No-content-then-late-content chunks ===');
chunks.forEach((c, i) => console.log(`Chunk ${i}:`, c.trim()));
expect(chunks.some((c) => c.includes('"content":"Late content"'))).toBe(true);
});
});
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest';
import { EventEmitter } from 'events';
import { ProxyService } from '../../server/modules/proxy/proxy.service';
import { Observable } from 'rxjs';
// Subclass to access private method
class TestableProxyService extends ProxyService {
constructor() {
super({} as any, {} as any);
}
public testProcessStream(
stream: any,
model: string = 'model',
streamOptions?: { include_usage?: boolean },
): Observable<string> {
return (this as any).processStreamResponse(stream, model, streamOptions);
}
}
function parseSseChunk(raw: string): Record<string, unknown> {
const lines = raw.split('\n').filter((l) => l.trim().startsWith('data: '));
if (lines.length === 0) return {};
const payload = lines[0].slice(6).trim();
try {
return JSON.parse(payload) as Record<string, unknown>;
} catch {
return {};
}
}
describe('ProxyService Tool Streaming (OpenAI format)', () => {
it('should emit tool call deltas with id+name first, then arguments separately', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStream(stream, 'gpt-4o');
const chunks: string[] = [];
const done = new Promise<void>((resolve, reject) => {
observable.subscribe({
next: (chunk) => chunks.push(chunk),
error: reject,
complete: resolve,
});
});
const payload = JSON.stringify({
candidates: [
{
content: {
parts: [
{ functionCall: { id: 'call_123', name: 'get_weather', args: { location: 'NYC' } } },
],
},
finishReason: 'STOP',
},
],
});
stream.emit('data', Buffer.from(`data: ${payload}\n`));
stream.emit('end');
await done;
const toolCallChunks = chunks.filter((c) => c.includes('"tool_calls"'));
expect(toolCallChunks.length).toBeGreaterThanOrEqual(2);
const first = parseSseChunk(toolCallChunks[0]);
const firstDeltas = (first.choices as any)?.[0]?.delta?.tool_calls as any[];
expect(firstDeltas).toBeDefined();
expect(firstDeltas[0].index).toBe(0);
expect(firstDeltas[0].id).toBe('call_123');
expect(firstDeltas[0].type).toBe('function');
expect(firstDeltas[0].function.name).toBe('get_weather');
expect(firstDeltas[0].function.arguments).toBe('');
const second = parseSseChunk(toolCallChunks[1]);
const secondDeltas = (second.choices as any)?.[0]?.delta?.tool_calls as any[];
expect(secondDeltas).toBeDefined();
expect(secondDeltas[0].index).toBe(0);
expect(secondDeltas[0].function.arguments).toBe('{"location":"NYC"}');
// Subsequent deltas must NOT repeat id/type/name to keep strict clients happy
expect(secondDeltas[0].id).toBeUndefined();
expect(secondDeltas[0].type).toBeUndefined();
expect(secondDeltas[0].function.name).toBeUndefined();
});
it('should split large tool arguments into multiple argument deltas', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStream(stream, 'gpt-4o');
const chunks: string[] = [];
const done = new Promise<void>((resolve, reject) => {
observable.subscribe({
next: (chunk) => chunks.push(chunk),
error: reject,
complete: resolve,
});
});
const bigArgs = { code: 'a'.repeat(5000) };
const payload = JSON.stringify({
candidates: [
{
content: {
parts: [{ functionCall: { id: 'call_456', name: 'write_file', args: bigArgs } }],
},
finishReason: 'STOP',
},
],
});
stream.emit('data', Buffer.from(`data: ${payload}\n`));
stream.emit('end');
await done;
const toolCallChunks = chunks.filter((c) => c.includes('"tool_calls"'));
// At least: 1st (id+name) + one or more argument deltas
expect(toolCallChunks.length).toBeGreaterThanOrEqual(2);
const first = parseSseChunk(toolCallChunks[0]);
expect((first.choices as any)[0].delta.tool_calls[0].function.arguments).toBe('');
// Concatenate all argument deltas
let argsConcat = '';
for (let i = 1; i < toolCallChunks.length; i++) {
const parsed = parseSseChunk(toolCallChunks[i]);
const argDelta = (parsed.choices as any)?.[0]?.delta?.tool_calls?.[0]?.function?.arguments;
if (typeof argDelta === 'string') {
argsConcat += argDelta;
}
}
expect(argsConcat).toBe(JSON.stringify(bigArgs));
});
it('should not break normal text streaming when no tools are present', async () => {
const service = new TestableProxyService();
const stream = new EventEmitter();
const observable = service.testProcessStream(stream, 'gpt-4o');
const chunks: string[] = [];
const done = new Promise<void>((resolve, reject) => {
observable.subscribe({
next: (chunk) => chunks.push(chunk),
error: reject,
complete: resolve,
});
});
const payload = JSON.stringify({
candidates: [
{
content: {
parts: [{ text: 'hello world' }],
},
finishReason: 'STOP',
},
],
});
stream.emit('data', Buffer.from(`data: ${payload}\n`));
stream.emit('end');
await done;
const output = chunks.join('');
expect(output).toContain('"content":"hello world"');
expect(output).toContain('data: [DONE]');
expect(output).not.toContain('"tool_calls"');
});
});
+1 -12
View File
@@ -1,6 +1,6 @@
import path from 'path';
export type InstallNoticeLanguage = 'zh-CN' | 'en' | 'ru' | 'vi' | 'pt-BR';
export type InstallNoticeLanguage = 'zh-CN' | 'en' | 'ru' | 'vi';
const installNoticeText: Record<
InstallNoticeLanguage,
@@ -39,13 +39,6 @@ const installNoticeText: Record<
detailPrefix: 'Thư mục cài đặt: ',
buttons: ['Mở thư mục cài đặt', 'Đã hiểu'],
},
'pt-BR': {
title: 'Inicie pelo menu Iniciar',
message:
'Detectamos que o app está sendo executado fora do local de instalação. Para garantir que as atualizações automáticas funcionem, inicie-o pelo menu Iniciar ou pelo atalho da área de trabalho. Se não houver atalho, execute o instalador novamente.',
detailPrefix: 'Local de instalação: ',
buttons: ['Abrir pasta de instalação', 'Entendi'],
},
};
export function resolveInstallNoticeLanguage({
@@ -73,10 +66,6 @@ export function resolveInstallNoticeLanguage({
return 'vi';
}
if (normalized.startsWith('pt')) {
return 'pt-BR';
}
return 'en';
}
+1 -14
View File
@@ -87,22 +87,9 @@ class TrafficLogger {
console.error('Traffic DailyRotateFile transport error', error);
});
const consoleFormat = winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ level, message }) => {
return `[TRAFFIC] ${level}: ${message}`;
}),
);
this.winstonLogger = winston.createLogger({
level: 'debug',
transports: [
rotateTransport,
new winston.transports.Console({
level: 'debug',
format: consoleFormat,
}),
],
transports: [rotateTransport],
exitOnError: false,
});
}