diff --git a/src/server/features/onboarding/OnboardingChatAgent.ts b/src/server/features/onboarding/OnboardingChatAgent.ts index 3f11da8..3d5921a 100644 --- a/src/server/features/onboarding/OnboardingChatAgent.ts +++ b/src/server/features/onboarding/OnboardingChatAgent.ts @@ -171,11 +171,12 @@ export class OnboardingChatAgent extends AIChatAgent { // Cancel the (billable) LLM call if the user aborts/navigates away. abortSignal: options?.abortSignal, // Budget shared by reasoning + visible output. Reasoning tokens (enabled - // on the model) eat into this, so it's well above what the ~350-word - // strategy needs — otherwise the answer truncates mid-table once the - // model has spent the budget thinking. It's a ceiling, not a target: the - // model only generates (and we only bill) what it actually uses. - maxOutputTokens: 4000, + // on the model) eat into this, and at max effort they dwarf the ~350-word + // strategy — the old 4000 cap left almost no answer headroom and + // truncated mid-table once the model had spent the budget thinking. + // Deliberately roomy: it's a per-step ceiling, not a target — the model + // only generates (and we only bill) what it actually uses. + maxOutputTokens: 32_000, stopWhen: stepCountIs(5), // Meter LLM spend against the same credit pool as DataForSEO: sum the real // per-step cost OpenRouter reports and deduct it. Best-effort, hosted-only. diff --git a/src/server/features/sam/SamChatAgent.ts b/src/server/features/sam/SamChatAgent.ts index 6521005..beb9f72 100644 --- a/src/server/features/sam/SamChatAgent.ts +++ b/src/server/features/sam/SamChatAgent.ts @@ -302,9 +302,13 @@ export class SamChatAgent extends Think { // SAM is meant to run complex multi-step work in one turn (site-read // intake plus a full research chain, multi-competitor sweeps), so give // it generous headroom — cost is bounded by per-step metering and the - // model stopping on its own, not by this cap. + // model stopping on its own, not by this cap. The per-step budget is + // shared by max-effort reasoning + visible output; a tight cap risks + // reasoning eating the reply (the issue #161 failure mode), so it's + // deliberately roomy — ~10x measured reasoning use — while keeping the + // worst-case turn (48 steps at the full cap) under ~$2. maxSteps: 48, - maxOutputTokens: 6000, + maxOutputTokens: 32_000, }; }); } diff --git a/src/server/lib/openrouter.ts b/src/server/lib/openrouter.ts index 668fa9a..f06cafc 100644 --- a/src/server/lib/openrouter.ts +++ b/src/server/lib/openrouter.ts @@ -9,30 +9,12 @@ import { // OpenRouter model slug used for the in-app chat agents (onboarding + SAM). // Override with OPENROUTER_MODEL to swap models without a code change. -const DEFAULT_CHAT_AGENT_MODEL = "minimax/minimax-m3"; +const DEFAULT_CHAT_AGENT_MODEL = "openai/gpt-5.6-luna"; + +// Previous default; kept reachable via OPENROUTER_MODEL for rollback. Its +// routing needs the ZDR/provider tuning below. +const MINIMAX_M3 = "minimax/minimax-m3"; -/** - * Returns the AI SDK LanguageModel for the chat agents. `usage: { include: true }` - * turns on OpenRouter usage accounting so each response carries its real USD - * cost (providerMetadata.openrouter.usage.cost) — which we meter against the - * shared usage-credit pool. `provider.order` prefers Together, then Atlas - * Cloud (fp8); `zdr: true` restricts routing to Zero-Data-Retention endpoints - * (prompts are never retained), which is the actual constraint — it excludes - * MiniMax first-party without a hand-maintained allowlist. The account also - * enforces this ("Non-frontier requires ZDR" data policy); the request-level - * flag is belt-and-braces so the constraint survives a dashboard change. - * Fallbacks stay on within the ZDR set because pinning providers caused a - * prod outage (Jul 2026: Together upstream-rate-limited m3 and every chat - * turn 429'd); as of Jul 2026 the ZDR set for m3 is Together/AtlasCloud/ - * Novita/Parasail at the same price plus Morph at 2x output as a last resort. - * - * `reasoning` turns on OpenRouter's reasoning-token channel so the model's - * chain-of-thought comes back as a separate reasoning stream instead of - * leaking into the visible answer text (MiniMax M3 otherwise dumps its - * `` trace inline). `effort: "medium"` is OpenRouter's default — - * stated explicitly only because the SDK type requires one once the channel - * is configured. - */ export async function getChatAgentModel(): Promise { const apiKey = await getRequiredEnvValue("OPENROUTER_API_KEY"); const modelId = await getOptionalEnvValue("OPENROUTER_MODEL"); @@ -40,21 +22,49 @@ export async function getChatAgentModel(): Promise { } /** - * Synchronous variant for callers that already hold the env values. Think's - * `getModel()` hook is sync and runs on every turn, so the SAM agent reads the - * key/model from its DO env and builds the model here. + * Returns the AI SDK LanguageModel for the chat agents. `usage: { include: true }` + * turns on OpenRouter usage accounting so each response carries its real USD + * cost (providerMetadata.openrouter.usage.cost) — which we meter against the + * shared usage-credit pool. + * + * Default model: GPT-5.6 Luna at `reasoning.effort: "max"` — "max" is valid at + * the OpenRouter API for GPT-5.x but missing from the SDK's effort union, so + * the reasoning config rides in `extraBody`. Reasoning tokens stream on the + * separate reasoning channel and are billed as output tokens, which the usage + * accounting above captures. + * + * Sync on purpose: Think's `getModel()` hook is sync and runs on every turn, + * so the SAM agent reads the key/model from its DO env and builds here. */ export function buildChatAgentModel( apiKey: string, modelId?: string, ): LanguageModelV3 { - return createOpenRouter({ apiKey })(modelId ?? DEFAULT_CHAT_AGENT_MODEL, { + const model = modelId ?? DEFAULT_CHAT_AGENT_MODEL; + const openrouter = createOpenRouter({ apiKey }); + + // MiniMax M3 (env-override path only): `provider.order` prefers Together, + // then Atlas Cloud (fp8); `zdr: true` restricts routing to Zero-Data- + // Retention endpoints, which excludes MiniMax first-party — the account's + // "Non-frontier requires ZDR" data policy enforces the same, this flag is + // belt-and-braces. Fallbacks stay on within the ZDR set because pinning + // providers caused a prod outage (Jul 2026: Together upstream-rate-limited + // m3 and every chat turn 429'd). The explicit reasoning channel keeps m3's + // `` trace out of the visible answer text. + if (model === MINIMAX_M3) { + return openrouter(model, { + usage: { include: true }, + reasoning: { effort: "medium" }, + provider: { + order: ["together", "atlas-cloud/fp8"], + zdr: true, + allow_fallbacks: true, + }, + }); + } + + return openrouter(model, { usage: { include: true }, - reasoning: { effort: "medium" }, - provider: { - order: ["together", "atlas-cloud/fp8"], - zdr: true, - allow_fallbacks: true, - }, + extraBody: { reasoning: { effort: "max" } }, }); }