Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# OpenAI
OPENAI_API_KEY=sk-pon-tu-api-key
# Opcional. Si lo dejas vacío, el SDK usará su modelo por defecto.
# Opcional. Si lo dejas vacío se usa gpt-5.4-mini (valor por defecto explícito, no el del SDK).
OPENAI_MODEL=

# ResponseGrid API (compartida por todas las cuentas; las credenciales por cuenta viven en accounts.json)
Expand Down
2 changes: 1 addition & 1 deletion README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Edita `.env`:

```env
OPENAI_API_KEY=sk-...
# Opcional. Si lo dejas vacío, el SDK usará su modelo por defecto.
# Opcional. Si lo dejas vacío se usa gpt-5.4-mini (valor por defecto explícito, no el del SDK).
OPENAI_MODEL=

API_BASE_URL=https://api.responsegrid.app
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Two files, both git-ignored (only their `*.example.*` counterparts are tracked):

```env
OPENAI_API_KEY=sk-...
OPENAI_MODEL= # optional; empty = SDK default
OPENAI_MODEL= # optional; empty = gpt-5.4-mini (explicit default, not the SDK's)
API_BASE_URL=https://api.responsegrid.app

# Only if you have any channel="whatsapp" account:
Expand Down
21 changes: 11 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@openai/agents": "latest",
"dotenv": "latest",
"openai": "latest",
"telegraf": "latest",
"@openai/agents": "^0.12.0",
"dotenv": "^17.4.2",
"openai": "^6.45.0",
"telegraf": "^4.16.3",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/node": "latest",
"tsx": "latest",
"typescript": "latest"
"@types/node": "^26.1.0",
"tsx": "^4.22.5",
"typescript": "^6.0.3"
},
"engines": {
"node": ">=24"
Expand Down
3 changes: 2 additions & 1 deletion src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ Estilo:

export const apiAgent = new Agent<AgentContext>({
name: "ResponseGrid Agent",
...(env.openaiModel ? { model: env.openaiModel } : {}),
// Siempre explícito (string): el SDK aplica así los modelSettings por defecto del modelo.
model: env.openaiModel,
instructions: async (runContext) => buildInstructions(runContext.context.account),
tools: agentTools,
});
29 changes: 29 additions & 0 deletions src/application/conversation-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import test from "node:test";
import assert from "node:assert";
import { ConversationService, MAX_TEXT_LENGTH, isCorruptedHistoryError, isTransientError } from "./conversation-service.js";
import { ModelIncompleteResponseError } from "./model-incomplete-response-error.js";
import { RateLimiter } from "./rate-limiter.js";
import type { ConversationLogFields } from "../infrastructure/observability/conversation-logger.js";
import type { Account } from "../domain/account.js";
import type { MessagingChannel, SelectionOption } from "../domain/ports/messaging-channel.port.js";
import type { AuthStore } from "../domain/ports/auth-store.port.js";
Expand Down Expand Up @@ -273,6 +275,33 @@ test("ConversationService · si el reintento también falla, avisa sin lanzar",
assert.match(sent[0].text, /problema técnico temporal/);
});

test("isTransientError no trata una respuesta incompleta del modelo como transitoria", () => {
assert.ok(!isTransientError(new ModelIncompleteResponseError("max_output_tokens").message));
});

test("ConversationService · respuesta incompleta del modelo: registra la causa, no reintenta y avisa", async () => {
const authStore = makeFakeAuthStore();
const logs: ConversationLogFields[] = [];
let calls = 0;
const service = new ConversationService(
{ getSession: () => makeFakeSession(), authStore, log: (fields) => void logs.push(fields) },
async () => {
calls += 1;
throw new ModelIncompleteResponseError("max_output_tokens");
},
);
const { channel, sent } = makeFakeChannel();

await service.handle({ account, chatId: "904", text: "consulta" }, channel); // no debe lanzar

assert.strictEqual(calls, 1, "no reintenta");
const errors = logs.filter((l) => l.kind === "error");
assert.strictEqual(errors.length, 1);
assert.strictEqual(errors[0].error, "model-incomplete (max_output_tokens)");
assert.strictEqual(sent.length, 1);
assert.match(sent[0].text, /problema procesando/);
});

test("ConversationService · error no transitorio: avisa sin reintentar ni lanzar", async () => {
const authStore = makeFakeAuthStore();
let calls = 0;
Expand Down
16 changes: 12 additions & 4 deletions src/application/conversation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ import type { ConversationStore } from "../domain/ports/conversation-store.port.
import type { AuthStore } from "../domain/ports/auth-store.port.js";
import { ApiClient } from "../infrastructure/responsegrid/api-client.js";
import { logConversation, type ConversationLogFields } from "../infrastructure/observability/conversation-logger.js";
import { ModelIncompleteResponseError } from "./model-incomplete-response-error.js";
import type { RateLimiter } from "./rate-limiter.js";
import { detectGreeting, WELCOME } from "./welcome.js";

/** Longitud máxima de un mensaje de texto que se procesa (protege coste/abuso). */
export const MAX_TEXT_LENGTH = 8000;

/** Aviso genérico al usuario ante un error no recuperable del run. */
const GENERIC_ERROR_REPLY = "Perdona, ha habido un problema procesando tu mensaje. Inténtalo de nuevo, por favor.";

/**
* Detecta el error de OpenAI por historial con un par de tool incompleto
* ("No tool call found for function call output ..."), que bloquea cada turno
Expand Down Expand Up @@ -145,6 +149,13 @@ export class ConversationService {
const message = error instanceof Error ? error.message : String(error);
log({ kind: "error", ...logBase, ms: Date.now() - startedAt, error: message });

// Respuesta incompleta y vacía del modelo: no es transitoria (reintentar repite el
// fallo). Ya queda registrada arriba como "model-incomplete (<motivo>)".
if (error instanceof ModelIncompleteResponseError) {
await channel.sendText(chatId, GENERIC_ERROR_REPLY);
return;
}

// Historial con un par de tool incompleto: OpenAI rechaza cada turno y el
// usuario queda bloqueado. Reiniciamos la sesión y pedimos repetir.
if (isCorruptedHistoryError(message)) {
Expand Down Expand Up @@ -174,10 +185,7 @@ export class ConversationService {
}
} else {
// Cualquier otro error: nunca dejar al usuario en silencio.
await channel.sendText(
chatId,
"Perdona, ha habido un problema procesando tu mensaje. Inténtalo de nuevo, por favor.",
);
await channel.sendText(chatId, GENERIC_ERROR_REPLY);
return;
}
}
Expand Down
18 changes: 18 additions & 0 deletions src/application/model-incomplete-response-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* El modelo devolvió una respuesta incompleta SIN ninguna salida (status "incomplete" y
* output vacío). No es transitorio: repetir la misma petición vuelve a fallar igual
* (p. ej. un schema de tool rechazado agota max_output_tokens sin generar nada), así que
* no se reintenta. Antes el SDK lo tomaba como "sin salida final", repetía turnos y
* acababa en un MaxTurnsExceededError que ocultaba la causa (incidente 2026-09-11).
*/
export class ModelIncompleteResponseError extends Error {
override readonly name = "ModelIncompleteResponseError";

constructor(
/** Motivo que da la API (`incomplete_details.reason`), p. ej. "max_output_tokens". */
readonly reason: string,
readonly responseId?: string,
) {
super(`model-incomplete (${reason})`);
}
}
17 changes: 17 additions & 0 deletions src/config/env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import test from "node:test";
import assert from "node:assert";
import { DEFAULT_OPENAI_MODEL, resolveOpenAIModel } from "./env.js";

test("resolveOpenAIModel", async (t) => {
await t.test("sin OPENAI_MODEL usa el modelo por defecto explícito (no el del SDK)", () => {
assert.strictEqual(DEFAULT_OPENAI_MODEL, "gpt-5.4-mini");
assert.strictEqual(resolveOpenAIModel(undefined), "gpt-5.4-mini");
assert.strictEqual(resolveOpenAIModel(""), "gpt-5.4-mini");
assert.strictEqual(resolveOpenAIModel(" "), "gpt-5.4-mini");
});

await t.test("respeta OPENAI_MODEL si está definido", () => {
assert.strictEqual(resolveOpenAIModel("gpt-5.5"), "gpt-5.5");
assert.strictEqual(resolveOpenAIModel(" gpt-5.5 "), "gpt-5.5");
});
});
13 changes: 12 additions & 1 deletion src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,20 @@ function optionalInt(name: string): number | undefined {
return value ? Number.parseInt(value, 10) : undefined;
}

/**
* Modelo por defecto explícito (el verificado en producción). No delegamos en el
* default del SDK: ya ha cambiado entre versiones de @openai/agents.
*/
export const DEFAULT_OPENAI_MODEL = "gpt-5.4-mini";

/** OPENAI_MODEL si está definido y no vacío; si no, DEFAULT_OPENAI_MODEL. */
export function resolveOpenAIModel(value: string | undefined): string {
return value && value.trim() !== "" ? value.trim() : DEFAULT_OPENAI_MODEL;
}

export const env = {
openaiApiKey: required("OPENAI_API_KEY"),
openaiModel: optional("OPENAI_MODEL"),
openaiModel: resolveOpenAIModel(process.env.OPENAI_MODEL),

// ResponseGrid / API externa — una sola instancia compartida por todas las cuentas.
apiBaseUrl: optional("API_BASE_URL"),
Expand Down
17 changes: 12 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { loadAccountsFromFile } from "./config/accounts.js";
import { AccountRegistry } from "./application/account-registry.js";
import { ConversationService } from "./application/conversation-service.js";
import { RateLimiter } from "./application/rate-limiter.js";
import { createOpenAIRunner } from "./infrastructure/openai/incomplete-response-guard.js";
import { FileSessionRepository } from "./infrastructure/persistence/file-session-store.js";
import { JsonTokenStore } from "./infrastructure/persistence/json-token-store.js";
import { startTelegramBots } from "./infrastructure/telegram/telegram-bootstrap.js";
Expand All @@ -16,11 +17,17 @@ const registry = new AccountRegistry(loadAccountsFromFile(accountsFile));
const sessions = new FileSessionRepository(join(process.cwd(), ".sessions"));
const authStore = new JsonTokenStore(join(process.cwd(), ".sessions", "tokens.json"));

const conversationService = new ConversationService({
getSession: (account, chatId) => sessions.getOrCreate(account, chatId),
authStore,
rateLimiter: new RateLimiter(),
});
// Runner con guard de respuestas incompletas del modelo (falla rápido en vez de agotar turnos).
const runner = createOpenAIRunner();

const conversationService = new ConversationService(
{
getSession: (account, chatId) => sessions.getOrCreate(account, chatId),
authStore,
rateLimiter: new RateLimiter(),
},
(agent, input, options) => runner.run(agent, input, options),
);

const telegramBots = startTelegramBots(registry, conversationService, sessions, authStore);
// Telegraf v4: bot.launch() no resuelve hasta que el bot se DETIENE, así que NO se puede
Expand Down
Loading