Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccc5d36a0d | ||
|
|
bfdb58fc81 | ||
|
|
b32dd24765 | ||
|
|
2c81d129f9 | ||
|
|
f4b713c3f4 | ||
|
|
5b130aca66 | ||
|
|
4653fa2b41 | ||
|
|
a3eabc2aaa | ||
|
|
099671a4e5 | ||
|
|
0f9d08018b |
No files matched your search
@@ -0,0 +1,266 @@
|
|||||||
|
**✅ 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
@@ -450,7 +450,7 @@ const config: ForgeConfig = {
|
|||||||
new MakerSquirrel({
|
new MakerSquirrel({
|
||||||
setupIcon: 'images/icon.ico',
|
setupIcon: 'images/icon.ico',
|
||||||
iconUrl:
|
iconUrl:
|
||||||
'https://raw.githubusercontent.com/Draculabo/AntigravityManager/main/images/icon.ico',
|
'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
|
||||||
}),
|
}),
|
||||||
...(process.platform === 'win32' && process.arch === 'x64'
|
...(process.platform === 'win32' && process.arch === 'x64'
|
||||||
? [
|
? [
|
||||||
@@ -484,8 +484,8 @@ const config: ForgeConfig = {
|
|||||||
name: '@electron-forge/publisher-github',
|
name: '@electron-forge/publisher-github',
|
||||||
config: {
|
config: {
|
||||||
repository: {
|
repository: {
|
||||||
owner: 'Draculabo',
|
owner: 'evandrodevbr',
|
||||||
name: 'AntigravityManager',
|
name: 'GeminiNexus',
|
||||||
},
|
},
|
||||||
draft: true,
|
draft: true,
|
||||||
prerelease: false,
|
prerelease: false,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Gemini Nexus</title>
|
<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';" />
|
<meta http-equiv="Content-Security-Policy" content="script-src 'self';" />
|
||||||
<link rel="stylesheet" href="/src/styles/global.css" />
|
<link rel="stylesheet" href="/src/styles/global.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+2
-1
@@ -10,6 +10,7 @@
|
|||||||
"dev": "cross-env DEBUG=electron-forge:* electron-forge start",
|
"dev": "cross-env DEBUG=electron-forge:* electron-forge start",
|
||||||
"package": "electron-forge package",
|
"package": "electron-forge package",
|
||||||
"make": "electron-forge make",
|
"make": "electron-forge make",
|
||||||
|
"make:win": "node scripts/build-windows.js",
|
||||||
"publish": "electron-forge publish",
|
"publish": "electron-forge publish",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --check .",
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
"test:all": "vitest run && playwright test",
|
"test:all": "vitest run && playwright test",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"author": "Draculabo",
|
"author": "evandrodevbr",
|
||||||
"license": "CC-BY-NC-SA-4.0",
|
"license": "CC-BY-NC-SA-4.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-forge/cli": "^7.11.1",
|
"@electron-forge/cli": "^7.11.1",
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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();
|
||||||
@@ -18,7 +18,7 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
|
|||||||
const { data: isRunning, isLoading } = useQuery({
|
const { data: isRunning, isLoading } = useQuery({
|
||||||
queryKey: ['process', 'status'],
|
queryKey: ['process', 'status'],
|
||||||
queryFn: isProcessRunning,
|
queryFn: isProcessRunning,
|
||||||
refetchInterval: 10000,
|
refetchInterval: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const startMutation = useMutation({
|
const startMutation = useMutation({
|
||||||
|
|||||||
@@ -521,12 +521,20 @@ export class CloudAccountRepo {
|
|||||||
try {
|
try {
|
||||||
const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all();
|
const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all();
|
||||||
|
|
||||||
// DEBUG LOGS
|
|
||||||
const activeRows = rows.filter((r) => r.isActive);
|
const activeRows = rows.filter((r) => r.isActive);
|
||||||
logger.info(
|
logger.debug(
|
||||||
`[DEBUG] getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
|
`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[] = [];
|
const cloudAccounts: CloudAccount[] = [];
|
||||||
for (const normalizedRow of rows) {
|
for (const normalizedRow of rows) {
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ export interface OpenAIChatResponse {
|
|||||||
completion_tokens: number;
|
completion_tokens: number;
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
};
|
};
|
||||||
|
/** Optional identifier for the model's configuration snapshot. Included for OpenAI-spec compliance. */
|
||||||
|
system_fingerprint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenAIChoice {
|
export interface OpenAIChoice {
|
||||||
|
|||||||
@@ -84,8 +84,26 @@ export class ProxyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('chat/completions')
|
@Post('chat/completions')
|
||||||
async chatCompletions(@Body() body: OpenAIChatRequest, @Res() res: FastifyReply) {
|
async chatCompletions(
|
||||||
await this.respondOpenAIChatCompletions(body, res);
|
@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')
|
@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 requestId = uuidv4();
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
trafficLogger.logInbound(requestId, '/v1/chat/completions', body, undefined, { stream: body.stream });
|
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 {
|
try {
|
||||||
const result = await this.proxyService.handleChatCompletions(body, requestId);
|
const result = await this.proxyService.handleChatCompletions(body, requestId, headerSessionKey);
|
||||||
|
|
||||||
if (body.stream && this.isObservableLike(result)) {
|
if (body.stream && this.isObservableLike(result)) {
|
||||||
this.writeSseResponse(res, result, requestId, '/v1/chat/completions', startTime);
|
this.writeSseResponse(res, result, requestId, '/v1/chat/completions', startTime);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import { getMaxOutputTokens, getThinkingBudget } from '../../../lib/geminiNexus/ModelSpecs';
|
import { getMaxOutputTokens, getThinkingBudget } from '../../../lib/geminiNexus/ModelSpecs';
|
||||||
import { resolveRequestUserAgent } from './request-user-agent';
|
import { resolveRequestUserAgent } from './request-user-agent';
|
||||||
import { UpstreamRequestError } from './clients/upstream-error';
|
import { UpstreamRequestError } from './clients/upstream-error';
|
||||||
|
import { trafficLogger } from '../../../utils/traffic-logger';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProxyService {
|
export class ProxyService {
|
||||||
@@ -691,8 +692,13 @@ export class ProxyService {
|
|||||||
|
|
||||||
async handleChatCompletions(
|
async handleChatCompletions(
|
||||||
request: OpenAIChatRequest,
|
request: OpenAIChatRequest,
|
||||||
|
requestId?: string,
|
||||||
|
headerSessionKey?: string,
|
||||||
): Promise<OpenAIChatResponse | Observable<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 targetModel = this.resolveTargetModel(request.model);
|
||||||
const extraHeaders = this.createModelSpecificHeaders(request.model);
|
const extraHeaders = this.createModelSpecificHeaders(request.model);
|
||||||
@@ -740,13 +746,14 @@ export class ProxyService {
|
|||||||
// Use v1internal API (same as Anthropic handler)
|
// Use v1internal API (same as Anthropic handler)
|
||||||
if (request.stream) {
|
if (request.stream) {
|
||||||
try {
|
try {
|
||||||
|
trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', geminiBody, extraHeaders, { model: effectiveTargetModel });
|
||||||
const stream = await this.geminiClient.streamGenerateInternal(
|
const stream = await this.geminiClient.streamGenerateInternal(
|
||||||
geminiBody,
|
geminiBody,
|
||||||
token.token.access_token,
|
token.token.access_token,
|
||||||
token.token.upstream_proxy_url,
|
token.token.upstream_proxy_url,
|
||||||
extraHeaders,
|
extraHeaders,
|
||||||
);
|
);
|
||||||
return this.processStreamResponse(stream, request.model, request.stream_options);
|
return this.processStreamResponse(stream, request.model, requestId);
|
||||||
} catch (streamError) {
|
} catch (streamError) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Stream path failed for model=${request.model}; falling back to non-stream generation: ${
|
`Stream path failed for model=${request.model}; falling back to non-stream generation: ${
|
||||||
@@ -754,12 +761,14 @@ export class ProxyService {
|
|||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', geminiBody, extraHeaders, { model: effectiveTargetModel });
|
||||||
const response = await this.generateInternalWithStreamFallback(
|
const response = await this.generateInternalWithStreamFallback(
|
||||||
geminiBody,
|
geminiBody,
|
||||||
token.token.access_token,
|
token.token.access_token,
|
||||||
token.token.upstream_proxy_url,
|
token.token.upstream_proxy_url,
|
||||||
extraHeaders,
|
extraHeaders,
|
||||||
);
|
);
|
||||||
|
trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, response, 0);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Upstream response snippet after stream fallback: ${JSON.stringify(response).substring(0, 500)}`,
|
`Upstream response snippet after stream fallback: ${JSON.stringify(response).substring(0, 500)}`,
|
||||||
);
|
);
|
||||||
@@ -799,21 +808,24 @@ export class ProxyService {
|
|||||||
fallbackBody.model = effectiveTargetModel;
|
fallbackBody.model = effectiveTargetModel;
|
||||||
this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id);
|
this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id);
|
||||||
if (request.stream) {
|
if (request.stream) {
|
||||||
|
trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', fallbackBody, extraHeaders, { model: effectiveTargetModel });
|
||||||
const stream = await this.geminiClient.streamGenerateInternal(
|
const stream = await this.geminiClient.streamGenerateInternal(
|
||||||
fallbackBody,
|
fallbackBody,
|
||||||
token.token.access_token,
|
token.token.access_token,
|
||||||
token.token.upstream_proxy_url,
|
token.token.upstream_proxy_url,
|
||||||
extraHeaders,
|
extraHeaders,
|
||||||
);
|
);
|
||||||
return this.processStreamResponse(stream, request.model, request.stream_options);
|
return this.processStreamResponse(stream, request.model, requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trafficLogger.logUpstream(requestId || 'unknown', '/v1/chat/completions', fallbackBody, extraHeaders, { model: effectiveTargetModel });
|
||||||
const response = await this.generateInternalWithStreamFallback(
|
const response = await this.generateInternalWithStreamFallback(
|
||||||
fallbackBody,
|
fallbackBody,
|
||||||
token.token.access_token,
|
token.token.access_token,
|
||||||
token.token.upstream_proxy_url,
|
token.token.upstream_proxy_url,
|
||||||
extraHeaders,
|
extraHeaders,
|
||||||
);
|
);
|
||||||
|
trafficLogger.logUpstreamResponse(requestId || 'unknown', '/v1/chat/completions', 200, response, 0);
|
||||||
const claudeResponse = transformResponse(response);
|
const claudeResponse = transformResponse(response);
|
||||||
return this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
|
return this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
|
||||||
} catch (fallbackErr) {
|
} catch (fallbackErr) {
|
||||||
@@ -952,38 +964,40 @@ export class ProxyService {
|
|||||||
private processStreamResponse(
|
private processStreamResponse(
|
||||||
upstreamStream: NodeJS.ReadableStream,
|
upstreamStream: NodeJS.ReadableStream,
|
||||||
model: string,
|
model: string,
|
||||||
streamOptions?: { include_usage?: boolean },
|
requestId?: string,
|
||||||
): Observable<string> {
|
): Observable<string> {
|
||||||
return new Observable<string>((subscriber) => {
|
return new Observable<string>((subscriber) => {
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
let hasEmittedChunk = false;
|
let hasEmittedChunk = false;
|
||||||
|
let hasSentRoleChunk = false;
|
||||||
let hasSentDone = false;
|
let hasSentDone = false;
|
||||||
let isThinking = false;
|
|
||||||
let toolCallIndex = 0;
|
let toolCallIndex = 0;
|
||||||
let lastUsageMetadata: GeminiResponse['usageMetadata'];
|
|
||||||
|
|
||||||
const streamId = `chatcmpl-${uuidv4()}`;
|
const streamId = `chatcmpl-${uuidv4()}`;
|
||||||
const created = Math.floor(Date.now() / 1000);
|
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 pushRoleChunk = (): void => {
|
||||||
const initialChunk = {
|
if (hasSentRoleChunk) return;
|
||||||
id: streamId,
|
hasSentRoleChunk = true;
|
||||||
object: 'chat.completion.chunk',
|
const roleChunk = {
|
||||||
created: created,
|
id: streamId,
|
||||||
model: model,
|
object: 'chat.completion.chunk',
|
||||||
choices: [
|
created,
|
||||||
{
|
model,
|
||||||
index: 0,
|
choices: [
|
||||||
delta: { role: 'assistant', content: '' },
|
{
|
||||||
finish_reason: null,
|
index: 0,
|
||||||
},
|
delta: { role: 'assistant', content: '' },
|
||||||
],
|
finish_reason: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
subscriber.next(`data: ${JSON.stringify(roleChunk)}\n\n`);
|
||||||
};
|
};
|
||||||
subscriber.next(`data: ${JSON.stringify(initialChunk)}\n\n`);
|
|
||||||
hasEmittedChunk = true;
|
|
||||||
|
|
||||||
const pushChunk = (payload: Record<string, unknown>): void => {
|
const pushChunk = (payload: Record<string, unknown>): void => {
|
||||||
|
hasEmittedChunk = true;
|
||||||
subscriber.next(`data: ${JSON.stringify(payload)}\n\n`);
|
subscriber.next(`data: ${JSON.stringify(payload)}\n\n`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1001,20 +1015,16 @@ export class ProxyService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(dataStr);
|
const json = JSON.parse(dataStr);
|
||||||
const candidate = json.candidates?.[0];
|
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 parts = candidate?.content?.parts || [];
|
const parts = candidate?.content?.parts || [];
|
||||||
|
|
||||||
if (json.usageMetadata) {
|
|
||||||
lastUsageMetadata = json.usageMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (part.thought && part.text) {
|
if (part.thought && part.text) {
|
||||||
let textToEmit = part.text;
|
// Reasoning/thinking parts: emit via reasoning_content delta field
|
||||||
if (!isThinking) {
|
// so clients that support it can display thinking separately.
|
||||||
isThinking = true;
|
|
||||||
textToEmit = `<think>\n${textToEmit}`;
|
|
||||||
}
|
|
||||||
const reasoningChunk = {
|
const reasoningChunk = {
|
||||||
id: streamId,
|
id: streamId,
|
||||||
object: 'chat.completion.chunk',
|
object: 'chat.completion.chunk',
|
||||||
@@ -1023,16 +1033,18 @@ export class ProxyService {
|
|||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
delta: { content: textToEmit },
|
delta: { reasoning_content: part.text },
|
||||||
finish_reason: null,
|
finish_reason: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
pushRoleChunk();
|
||||||
pushChunk(reasoningChunk);
|
pushChunk(reasoningChunk);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (part.functionCall) {
|
if (part.functionCall) {
|
||||||
|
pushRoleChunk();
|
||||||
const currentToolIndex = toolCallIndex++;
|
const currentToolIndex = toolCallIndex++;
|
||||||
const toolCallChunk = {
|
const toolCallChunk = {
|
||||||
id: streamId,
|
id: streamId,
|
||||||
@@ -1080,16 +1092,13 @@ export class ProxyService {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
pushRoleChunk();
|
||||||
pushChunk(imageChunk);
|
pushChunk(imageChunk);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (part.text) {
|
if (part.text) {
|
||||||
let textToEmit = part.text;
|
pushRoleChunk();
|
||||||
if (isThinking && !part.thought) {
|
|
||||||
isThinking = false;
|
|
||||||
textToEmit = `\n</think>\n\n${textToEmit}`;
|
|
||||||
}
|
|
||||||
const contentChunk = {
|
const contentChunk = {
|
||||||
id: streamId,
|
id: streamId,
|
||||||
object: 'chat.completion.chunk',
|
object: 'chat.completion.chunk',
|
||||||
@@ -1098,7 +1107,7 @@ export class ProxyService {
|
|||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
delta: { content: textToEmit },
|
delta: { content: part.text },
|
||||||
finish_reason: null,
|
finish_reason: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1108,23 +1117,13 @@ export class ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (candidate?.finishReason) {
|
if (candidate?.finishReason) {
|
||||||
if (isThinking) {
|
// Per the OpenAI spec, finish_reason must be "tool_calls" when the
|
||||||
isThinking = false;
|
// response contains function calls. Gemini always sends "STOP" even
|
||||||
const closingChunk = {
|
// for tool call responses, so we infer from toolCallIndex.
|
||||||
id: streamId,
|
const mappedFinishReason =
|
||||||
object: 'chat.completion.chunk',
|
toolCallIndex > 0
|
||||||
created: created,
|
? 'tool_calls'
|
||||||
model: model,
|
: this.mapGeminiFinishReasonToOpenAIFinishReason(candidate.finishReason);
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
delta: { content: '\n</think>\n\n' },
|
|
||||||
finish_reason: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
pushChunk(closingChunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
const finishChunk = {
|
const finishChunk = {
|
||||||
id: streamId,
|
id: streamId,
|
||||||
@@ -1135,9 +1134,7 @@ export class ProxyService {
|
|||||||
{
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
delta: {},
|
delta: {},
|
||||||
finish_reason: this.mapGeminiFinishReasonToOpenAIFinishReason(
|
finish_reason: mappedFinishReason,
|
||||||
candidate.finishReason,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -1147,7 +1144,7 @@ export class ProxyService {
|
|||||||
subscriber.complete();
|
subscriber.complete();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors
|
// ignore parse errors on individual SSE lines
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1168,23 +1165,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) {
|
if (!hasSentDone) {
|
||||||
subscriber.next('data: [DONE]\n\n');
|
subscriber.next('data: [DONE]\n\n');
|
||||||
hasSentDone = true;
|
hasSentDone = true;
|
||||||
@@ -1192,17 +1172,12 @@ export class ProxyService {
|
|||||||
subscriber.complete();
|
subscriber.complete();
|
||||||
});
|
});
|
||||||
|
|
||||||
upstreamStream.on('error', (err) => {
|
upstreamStream.on('error', (err: unknown) => {
|
||||||
this.logger.error('OpenAI-compatible stream error:', err);
|
// Convert to clean Error to avoid circular reference issues (socket objects)
|
||||||
const cleanError = err instanceof Error ? new Error(err.message) : new Error(String(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);
|
subscriber.error(cleanError);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (typeof (upstreamStream as any).destroy === 'function') {
|
|
||||||
(upstreamStream as any).destroy();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1213,10 +1188,42 @@ export class ProxyService {
|
|||||||
const model = response.model;
|
const model = response.model;
|
||||||
const choice = response.choices?.[0];
|
const choice = response.choices?.[0];
|
||||||
const finishReason = choice?.finish_reason ?? 'stop';
|
const finishReason = choice?.finish_reason ?? 'stop';
|
||||||
|
const toolCalls = choice?.message?.tool_calls;
|
||||||
const content =
|
const content =
|
||||||
choice?.message && isString(choice.message.content) ? choice.message.content : '';
|
choice?.message && isString(choice.message.content) ? choice.message.content : '';
|
||||||
const chunkSize = 80;
|
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) {
|
if (content.length === 0) {
|
||||||
const finishChunk = {
|
const finishChunk = {
|
||||||
id: streamId,
|
id: streamId,
|
||||||
@@ -1623,12 +1630,14 @@ export class ProxyService {
|
|||||||
object: 'chat.completion',
|
object: 'chat.completion',
|
||||||
created: Math.floor(Date.now() / 1000),
|
created: Math.floor(Date.now() / 1000),
|
||||||
model: model,
|
model: model,
|
||||||
|
system_fingerprint: 'fp_gemininexus',
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
message: {
|
message: {
|
||||||
role: 'assistant',
|
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,
|
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||||
},
|
},
|
||||||
finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason(
|
finish_reason: this.mapAnthropicStopReasonToOpenAIFinishReason(
|
||||||
|
|||||||
@@ -848,6 +848,34 @@ export class GoogleAPIService {
|
|||||||
): Promise<{ credits: number; expiryDate: string } | null> {
|
): Promise<{ credits: number; expiryDate: string } | null> {
|
||||||
try {
|
try {
|
||||||
const fetchOptions = this.getFetchOptions(proxyUrl);
|
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, {
|
const response = await fetch(URLS.FETCH_CREDITS, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: buildInternalApiHeaders(accessToken),
|
headers: buildInternalApiHeaders(accessToken),
|
||||||
@@ -867,34 +895,7 @@ export class GoogleAPIService {
|
|||||||
return toAiCredits(data);
|
return toAiCredits(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status !== 404) {
|
return null;
|
||||||
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ function createReplyMock() {
|
|||||||
return reply;
|
return reply;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createReqMock() {
|
||||||
|
return { headers: {} } as any;
|
||||||
|
}
|
||||||
|
|
||||||
describe('ProxyController Integration', () => {
|
describe('ProxyController Integration', () => {
|
||||||
it('routes Claude OpenAI requests to protocol parity path', async () => {
|
it('routes Claude OpenAI requests to protocol parity path', async () => {
|
||||||
const proxyService = {
|
const proxyService = {
|
||||||
@@ -19,6 +23,7 @@ describe('ProxyController Integration', () => {
|
|||||||
};
|
};
|
||||||
const controller = new ProxyController(proxyService as any);
|
const controller = new ProxyController(proxyService as any);
|
||||||
const reply = createReplyMock();
|
const reply = createReplyMock();
|
||||||
|
const req = createReqMock();
|
||||||
|
|
||||||
await controller.chatCompletions(
|
await controller.chatCompletions(
|
||||||
{
|
{
|
||||||
@@ -26,6 +31,7 @@ describe('ProxyController Integration', () => {
|
|||||||
stream: false,
|
stream: false,
|
||||||
messages: [{ role: 'user', content: 'hello' }],
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
} as any,
|
} as any,
|
||||||
|
req,
|
||||||
reply as any,
|
reply as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -42,6 +48,7 @@ describe('ProxyController Integration', () => {
|
|||||||
};
|
};
|
||||||
const controller = new ProxyController(proxyService as any);
|
const controller = new ProxyController(proxyService as any);
|
||||||
const reply = createReplyMock();
|
const reply = createReplyMock();
|
||||||
|
const req = createReqMock();
|
||||||
|
|
||||||
await controller.chatCompletions(
|
await controller.chatCompletions(
|
||||||
{
|
{
|
||||||
@@ -49,6 +56,7 @@ describe('ProxyController Integration', () => {
|
|||||||
stream: true,
|
stream: true,
|
||||||
messages: [{ role: 'user', content: 'hello' }],
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
} as any,
|
} as any,
|
||||||
|
req,
|
||||||
reply as any,
|
reply as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -648,6 +648,7 @@ describe('ProxyService Protocol Parity Fixtures', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const output = chunks.join('');
|
const output = chunks.join('');
|
||||||
|
expect(output).toContain('"role":"assistant"');
|
||||||
expect(output).toContain('"reasoning_content":"reasoning text"');
|
expect(output).toContain('"reasoning_content":"reasoning text"');
|
||||||
expect(output).toContain('"tool_calls"');
|
expect(output).toContain('"tool_calls"');
|
||||||
expect(output).toContain('"content":"final answer"');
|
expect(output).toContain('"content":"final answer"');
|
||||||
@@ -689,6 +690,7 @@ describe('ProxyService Protocol Parity Fixtures', () => {
|
|||||||
|
|
||||||
expect(streamResult.completed).not.toBe(true);
|
expect(streamResult.completed).not.toBe(true);
|
||||||
expect(streamResult.error?.message).toContain('socket hang up');
|
expect(streamResult.error?.message).toContain('socket hang up');
|
||||||
|
expect(chunks.join('')).toContain('"role":"assistant"');
|
||||||
expect(chunks.join('')).toContain('"content":"partial output"');
|
expect(chunks.join('')).toContain('"content":"partial output"');
|
||||||
expect(chunks.join('')).not.toContain('data: [DONE]');
|
expect(chunks.join('')).not.toContain('data: [DONE]');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export type InstallNoticeLanguage = 'zh-CN' | 'en' | 'ru' | 'vi';
|
export type InstallNoticeLanguage = 'zh-CN' | 'en' | 'ru' | 'vi' | 'pt-BR';
|
||||||
|
|
||||||
const installNoticeText: Record<
|
const installNoticeText: Record<
|
||||||
InstallNoticeLanguage,
|
InstallNoticeLanguage,
|
||||||
@@ -39,6 +39,13 @@ const installNoticeText: Record<
|
|||||||
detailPrefix: 'Thư mục cài đặt: ',
|
detailPrefix: 'Thư mục cài đặt: ',
|
||||||
buttons: ['Mở thư mục cài đặt', 'Đã hiểu'],
|
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({
|
export function resolveInstallNoticeLanguage({
|
||||||
@@ -66,6 +73,10 @@ export function resolveInstallNoticeLanguage({
|
|||||||
return 'vi';
|
return 'vi';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith('pt')) {
|
||||||
|
return 'pt-BR';
|
||||||
|
}
|
||||||
|
|
||||||
return 'en';
|
return 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,9 +87,22 @@ class TrafficLogger {
|
|||||||
console.error('Traffic DailyRotateFile transport error', error);
|
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({
|
this.winstonLogger = winston.createLogger({
|
||||||
level: 'debug',
|
level: 'debug',
|
||||||
transports: [rotateTransport],
|
transports: [
|
||||||
|
rotateTransport,
|
||||||
|
new winston.transports.Console({
|
||||||
|
level: 'debug',
|
||||||
|
format: consoleFormat,
|
||||||
|
}),
|
||||||
|
],
|
||||||
exitOnError: false,
|
exitOnError: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user