Skip to content
Open
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ HOST=127.0.0.1
PORT=8787
LOG_LEVEL=info

# Optional: let Jev (TypeSafe's decision model, via OpenRouter) choose the
# reasoning effort for each user message. Set an OpenRouter API key to enable it;
# WIREBOT_JEV_FAST=auto additionally lets Jev pick Codex's fast service tier for
# quick replies (fast mode costs more credits). Message text is sent to OpenRouter.
# WIREBOT_JEV_API_KEY=sk-or-v1-replace-me
# WIREBOT_JEV_EFFORT=auto
# WIREBOT_JEV_FAST=off
# WIREBOT_JEV_FAST_TIER=fast
# WIREBOT_JEV_MODEL=~typesafe/jev-latest

# Wirebot pins its own Codex CLI version, so the pinned Codex should not
# self-check for updates.
CODEX_CHECK_UPDATES=false
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ All notable changes to Wirebot are documented in this file.

## [Unreleased]

### Added

- Optional per-message reasoning-effort routing with [Jev](https://openrouter.ai/~typesafe/jev-latest),
TypeSafe's decision model, through OpenRouter. With `WIREBOT_JEV_API_KEY` set, each user turn
gets the effort level Jev picks among the ones the selected model supports (`low` for a
greeting, `ultra` for a large research task, and so on), falling back to the configured effort
on any error or timeout. `WIREBOT_JEV_FAST=auto` also lets Jev choose Codex's fast service tier
for quick replies on a per-turn basis.

## [0.3.3] - 2026-09-21

### Changed
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ and reverse-proxy that origin to the container's port 8787 (publish it in your c
| `CODEX_API_KEY` | unset | OpenAI API key for headless Codex auth |
| `HOST` / `PORT` | `0.0.0.0` / `8787` | Mini App listener (container default) |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` |
| `WIREBOT_JEV_API_KEY` | unset | OpenRouter key; enables Jev turn routing |
| `WIREBOT_JEV_EFFORT` | `auto` | `off` keeps the configured effort |
| `WIREBOT_JEV_FAST` | `off` | `auto` lets Jev pick the fast tier |
| `WIREBOT_JEV_FAST_TIER` | `fast` | Service tier id used for fast replies |
| `WIREBOT_JEV_MODEL` | `~typesafe/jev-latest` | OpenRouter model id for the router |

## Authenticate Codex

Expand Down Expand Up @@ -199,6 +204,14 @@ Wirebot connects through the Discord Gateway with [discord.js](https://github.co

The connector is deliberately text-only. It does not download Discord attachments, stickers, or polls, and it never uploads generated files; both inbound and outbound omissions are stated in the conversation. Model-authored mentions and link previews are suppressed on every outbound create and edit. Set `DISCORD_BOT_TOKEN` and `DISCORD_ALLOWED_USER_IDS` together to enable it, then follow [docs/discord.md](docs/discord.md) for the Developer Portal intent, invite permissions, user IDs, and first run.

## Automatic reasoning effort with Jev

Reasoning effort and speed are normally fixed in Codex settings for everyone who uses the bot. Wirebot can instead ask [Jev](https://openrouter.ai/~typesafe/jev-latest), TypeSafe's decision model, to pick them per message. Jev does not generate text: it answers typed questions about the message with calibrated probabilities in well under a second, so a greeting gets `low`, a typo fix `medium`, a debugging session `high`, a migration design `xhigh`, and a large research task `ultra` — whatever levels the selected Codex model reports.

Set `WIREBOT_JEV_API_KEY` to an [OpenRouter](https://openrouter.ai/) API key to enable it. Before each user turn Wirebot sends the message text, whether it starts a new task, the attachment count, and the connector name to OpenRouter's Decisions endpoint, then passes the chosen effort to `turn/start`. Scheduled runs keep their own effort settings. Routing is advisory: a timeout (2.5 s), an API error, or a level the model does not support falls back to the configured effort, and the decision is logged with its probabilities at `info` level.

`WIREBOT_JEV_FAST=auto` additionally lets Jev decide per turn whether a fast reply is worth it: short conversational messages and quick lookups run on the model's fast service tier (`WIREBOT_JEV_FAST_TIER`, `fast` by default, matching Codex's Fast mode), everything else on the standard tier. Fast mode consumes credits at a higher rate, so this stays off unless enabled. Message text leaves the host for this feature; do not enable it where that is not acceptable.

## Scheduled runs

Ask Codex naturally, for example, “Every weekday at 9, check this project for failed CI runs” or “Revisit this task every hour and notify me only if something changed.” Wirebot exposes a host-managed `automation_update` tool to new Codex tasks and stores each schedule with an explicit time zone. A task created before upgrading does not have that tool in its persisted definition; send `/new` once before asking it to create or edit schedules. `/schedules` remains available for viewing them.
Expand Down
14 changes: 2 additions & 12 deletions src/channels/config-fields.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
ConfigValidationError,
type EditableConfigSnapshot,
type ModelCapability,
selectedModel,
} from "../codex/config-service.js";
import { errorMessage } from "../shared/errors.js";

Expand Down Expand Up @@ -58,7 +58,7 @@ export function fieldOptions(
snapshot: EditableConfigSnapshot,
field: ConfigFieldKey,
): FieldOption[] {
const model = currentModel(snapshot);
const model = selectedModel(snapshot);
switch (field) {
case "model":
return snapshot.capabilities.models.map((candidate) => ({
Expand Down Expand Up @@ -191,13 +191,3 @@ export async function applyConfigValue(
: `⚠️ ${errorMessage(error)}`;
}
}

function currentModel(snapshot: EditableConfigSnapshot): ModelCapability | undefined {
const selected = snapshot.values.model;
const models = snapshot.capabilities.models;
if (selected !== null) {
const match = models.find((model) => model.model === selected);
if (match !== undefined) return match;
}
return models.find((model) => model.isDefault) ?? models[0];
}
13 changes: 13 additions & 0 deletions src/codex/config-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,19 @@ export interface EditableConfigSnapshot {
readonly validation: ConfigValidationResult;
}

/** The model the config selects, or the catalog default when it names none. */
export function selectedModel(
snapshot: Pick<EditableConfigSnapshot, "values" | "capabilities">,
): ModelCapability | undefined {
const selected = snapshot.values.model;
const models = snapshot.capabilities.models;
if (selected !== null) {
const match = models.find((model) => model.model === selected);
if (match !== undefined) return match;
}
return models.find((model) => model.isDefault) ?? models[0];
}

export class ConfigValidationError extends BridgeError {
public readonly issues: readonly ConfigValidationIssue[];

Expand Down
59 changes: 52 additions & 7 deletions src/codex/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ type CodexTurnSettings = Readonly<
| "sandboxPolicy"
| "model"
| "serviceTier"
| "serviceTierForTurn"
| "effort"
| "summary"
| "personality"
Expand All @@ -154,9 +155,32 @@ type ExplicitSkillInputProvider = (
text: string,
) => readonly ExplicitSkillInput[] | Promise<readonly ExplicitSkillInput[]>;

/** What a turn router learns about a user message before the turn starts. */
export interface TurnRoutingRequest {
readonly conversationKey: string;
readonly connector: string;
/** Message text after voice transcription. */
readonly text: string;
/** True when this message starts a new Codex thread. */
readonly newThread: boolean;
readonly attachmentCount: number;
}

/** Per-turn overrides layered over the configured turn settings. */
export interface TurnRoutingDecision {
readonly effort?: string;
readonly serviceTierForTurn?: string;
}

type TurnRoutingProvider = (
request: TurnRoutingRequest,
) => TurnRoutingDecision | Promise<TurnRoutingDecision>;

export interface CodexServiceProviders {
readonly effectiveSettings?: EffectiveCodexSettingsProvider;
readonly explicitSkillInputs?: ExplicitSkillInputProvider;
/** Optional per-turn settings routing for user turns (not scheduled runs). */
readonly turnRouting?: TurnRoutingProvider;
/** Static deployment-environment context added to every turn. */
readonly environmentContext?: ApplicationContext;
readonly externalAuthTokens?: (
Expand All @@ -182,6 +206,7 @@ export class CodexService {
readonly #remoteClientContextEnabled: () => boolean;
readonly #effectiveSettings: EffectiveCodexSettingsProvider;
readonly #explicitSkillInputs: ExplicitSkillInputProvider;
readonly #turnRouting: TurnRoutingProvider | undefined;
readonly #environmentContext: ApplicationContext | undefined;
readonly #externalAuthTokens: CodexServiceProviders["externalAuthTokens"];
#pauseGate: Deferred<void> | undefined;
Expand Down Expand Up @@ -215,6 +240,7 @@ export class CodexService {
this.#remoteClientContextEnabled = remoteClientContextEnabled;
this.#effectiveSettings = providers.effectiveSettings ?? (() => ({}));
this.#explicitSkillInputs = providers.explicitSkillInputs ?? (() => []);
this.#turnRouting = providers.turnRouting;
this.#environmentContext = providers.environmentContext;
this.#externalAuthTokens = providers.externalAuthTokens;
rpc.onNotification((notification) => this.handleNotification(notification));
Expand Down Expand Up @@ -338,12 +364,17 @@ export class CodexService {
this.#effectiveSettings(),
this.#explicitSkillInputs(prepared),
]);
const threadId = await this.ensureThread(
conversationKey,
connector,
ephemeral,
settings.thread ?? {},
);
const newThread = ephemeral || this.#conversations.get(conversationKey) === undefined;
const [threadId, routing] = await Promise.all([
this.ensureThread(conversationKey, connector, ephemeral, settings.thread ?? {}),
this.routeTurn({
conversationKey,
connector,
text: prepared,
newThread,
attachmentCount: attachments.length,
}),
]);
const session = this.requireSession(threadId);
this.#conversationSessions.set(conversationKey, session);
session.adoptPresenter(conversationKey, connector, responder, invocation);
Expand All @@ -356,7 +387,7 @@ export class CodexService {
conversationKey,
connector,
input: [...createTurnInput(prepared, connector, attachments), ...skillInputs],
turnSettings: settings.turn ?? {},
turnSettings: { ...(settings.turn ?? {}), ...routing },
onStarted: () => {
started = true;
},
Expand Down Expand Up @@ -388,6 +419,20 @@ export class CodexService {
}
}

/** Routing is advisory: a failing router never fails the turn. */
private async routeTurn(request: TurnRoutingRequest): Promise<TurnRoutingDecision> {
if (this.#turnRouting === undefined) return {};
try {
return await this.#turnRouting(request);
} catch (error) {
this.#logger.warn("Turn routing failed; using the configured settings", {
conversationKey: request.conversationKey,
error: errorMessage(error),
});
return {};
}
}

public async runScheduledTurn(request: ScheduledTurnRequest): Promise<CodexTurnResult> {
await this.enterJob();
try {
Expand Down
40 changes: 40 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ const envSchema = z.object({
HOST: z.string().min(1).default("127.0.0.1"),
PORT: z.coerce.number().int().min(1).max(65_535).default(8787),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
WIREBOT_JEV_API_KEY: z.string().min(1).optional(),
WIREBOT_JEV_MODEL: z.string().min(1).default("~typesafe/jev-latest"),
WIREBOT_JEV_ENDPOINT: z.url().default("https://openrouter.ai/api/alpha/decisions"),
WIREBOT_JEV_EFFORT: z.enum(["auto", "off"]).default("auto"),
WIREBOT_JEV_FAST: z.enum(["auto", "off"]).default("off"),
WIREBOT_JEV_FAST_TIER: z.string().min(1).default("fast"),
});

/**
Expand All @@ -55,6 +61,7 @@ export const bridgeOnlyEnvironmentKeys: ReadonlySet<keyof z.infer<typeof envSche
"TELEGRAM_API_BASE",
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_POLL_TIMEOUT",
"WIREBOT_JEV_API_KEY",
] as const);

export interface ChatgptAuthConfig {
Expand Down Expand Up @@ -85,6 +92,18 @@ export interface DiscordConfig {
readonly adminUserIds: ReadonlySet<string> | undefined;
}

/** Jev turn routing through OpenRouter; present only when an API key is set. */
export interface JevConfig {
readonly apiKey: string;
readonly model: string;
readonly endpoint: string;
/** Let Jev choose the reasoning effort for each user turn. */
readonly effort: boolean;
/** Let Jev choose per turn whether to use the fast service tier. */
readonly fast: boolean;
readonly fastTier: string;
}

export interface AppConfig {
readonly telegram: TelegramConfig | undefined;
readonly telegramApiBase: string;
Expand All @@ -103,6 +122,7 @@ export interface AppConfig {
readonly host: string;
readonly port: number;
readonly logLevel: LogLevel;
readonly jev: JevConfig | undefined;
}

export function loadAppConfig(environment: NodeJS.ProcessEnv = process.env): AppConfig {
Expand Down Expand Up @@ -142,6 +162,26 @@ export function loadAppConfig(environment: NodeJS.ProcessEnv = process.env): App
host: parsed.HOST,
port: parsed.PORT,
logLevel: parsed.LOG_LEVEL,
jev: jevConfigFromParsed(parsed),
};
}

function jevConfigFromParsed(parsed: z.infer<typeof envSchema>): JevConfig | undefined {
if (parsed.WIREBOT_JEV_API_KEY === undefined) return undefined;
const effort = parsed.WIREBOT_JEV_EFFORT === "auto";
const fast = parsed.WIREBOT_JEV_FAST === "auto";
if (!effort && !fast) {
throw new Error(
"WIREBOT_JEV_API_KEY is set but both WIREBOT_JEV_EFFORT and WIREBOT_JEV_FAST are off; unset the key or enable one of them",
);
}
return {
apiKey: parsed.WIREBOT_JEV_API_KEY,
model: parsed.WIREBOT_JEV_MODEL,
endpoint: parsed.WIREBOT_JEV_ENDPOINT,
effort,
fast,
fastTier: parsed.WIREBOT_JEV_FAST_TIER,
};
}

Expand Down
20 changes: 20 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { WirebotMcpServer } from "./mcp/server.js";
import { BrowserAuth } from "./miniapp/browser-auth.js";
import { MiniAppServer } from "./miniapp/server.js";
import { QuickTunnel } from "./miniapp/tunnel.js";
import { OpenRouterJevClient } from "./routing/jev.js";
import { JevTurnRouter } from "./routing/turn-router.js";
import { deferred } from "./shared/async.js";
import { errorMessage } from "./shared/errors.js";
import { atomicWriteFile, ensureDirectory, readFileIfExists } from "./shared/fs.js";
Expand Down Expand Up @@ -122,6 +124,7 @@ export async function runWirebot(): Promise<void> {
chatgptAuth === undefined ? undefined : () => Promise.resolve(chatgptAuth),
);
let liveRuntime: CodexRuntimeService | undefined;
let turnRouter: JevTurnRouter | undefined;
codex = new CodexService(
rpc,
conversations,
Expand All @@ -134,6 +137,7 @@ export async function runWirebot(): Promise<void> {
{
effectiveSettings: () => liveRuntime?.settings() ?? {},
explicitSkillInputs: (text) => liveRuntime?.skillInputs(text) ?? [],
turnRouting: (request) => turnRouter?.route(request) ?? {},
...(config.container ? { environmentContext: createContainerEnvironmentContext() } : {}),
...(chatgptAuth === undefined
? {}
Expand All @@ -160,6 +164,22 @@ export async function runWirebot(): Promise<void> {
logger.info("Codex is authenticated with the API key from CODEX_API_KEY");
}
const configService = new CodexConfigService(rpc, config.workspace);
if (config.jev !== undefined) {
turnRouter = new JevTurnRouter({
client: new OpenRouterJevClient(config.jev),
config: configService,
logger: logger.child({ component: "jev-router" }),
effort: config.jev.effort,
fast: config.jev.fast,
fastTier: config.jev.fastTier,
});
logger.info("Jev turn routing is enabled", {
model: config.jev.model,
effort: config.jev.effort,
fast: config.jev.fast,
fastTier: config.jev.fastTier,
});
}
const runtime = new CodexRuntimeService({
rpc,
codex,
Expand Down
Loading
Loading