Three real defects flagged in the post-merge review of #616, plus one cheap hardening: 1. OpenCode `limit.output` override was a silent no-op on opencode-ai@1.1.56. Top-level `limit.output` has no read site in OpenCode (verified against the v1.1.56 source: `OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000` in session/llm.ts; per-model `model.limit.output` has its own scope). Plumbed via `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX=5000` env var on the OpenCode spawn instead. Drops dead `OpenCodeConfig.limit?` type field and the corresponding config write in `buildSecurityConfig`. This was the headline mechanism of #616 — without the env var, the upfront `max_tokens` reservation stayed at 32_000 and low-wallet runs continued failing the way #616 was supposed to prevent. 2. Phantom auto-reload buffer for detached-card accounts. DELETE /payment-method clears `stripeCustomerId` but leaves `autoReloadEnabled` intact, so an account with welcome-credit residue and a detached card could mint a key with `keyLimitCents = balance + autoReloadAmountCents` ($50 default, schema-cap $100K) of free spend headroom we have no way to bill. Conjunctive `account.autoReloadEnabled && hasCard` in the buffer selection closes this. Defense-in-depth follow-up worth doing: clear `autoReloadEnabled` in the card-detach handler. 3. The autoReloadEnabled 402 branch fired for phase-1 noop paths (`!stripeCustomerId`, `reloadAmountCents < 50`, `balance >= threshold`) where `result.failure == null`, returning `"insufficient balance"` with no actionable code. Gated on `result.status === "failed"` so non-charge paths fall through to the `hasCard` / no-card branches and emit `router_balance_exhausted` / `router_requires_card` instead. 4. (cheap) `ROUTER_KEYLIMIT_EXHAUSTED_PATTERN` now uses `/is` instead of `/i` so `.*?` crosses newlines. Defends the BillingError reclassification against any upstream layer that wraps the OpenRouter error onto multiple lines. Trivial. Test plan: 488/488 unit tests pass (1 new test for newline regex behavior).
This commit is contained in:
committed by
pullfrog[bot]
parent
93cc7b1a44
commit
ec43c0e0d1
+15
-11
@@ -56,19 +56,23 @@ type OpenCodeConfig = {
|
|||||||
agent?: Record<string, unknown>;
|
agent?: Record<string, unknown>;
|
||||||
model?: string;
|
model?: string;
|
||||||
enabled_providers?: string[];
|
enabled_providers?: string[];
|
||||||
/**
|
|
||||||
* OpenCode's `limit.output` controls the per-inference `max_tokens` the agent
|
|
||||||
* reserves with the upstream model. OpenCode defaults to 32_000 (sized for
|
|
||||||
* long-running TUI sessions where a human user might want big outputs).
|
|
||||||
* Pullfrog runs are headless and short — typical outputs are 1-3K tokens —
|
|
||||||
* so we override to a much smaller value. This drastically reduces the
|
|
||||||
* upfront budget reservation OpenRouter requires per call, which is what
|
|
||||||
* lets low-wallet runs actually start.
|
|
||||||
*/
|
|
||||||
limit?: { output?: number };
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-inference `max_tokens` reservation the agent sends to the upstream
|
||||||
|
* model. OpenCode's default is 32_000 (sized for long-running TUI sessions
|
||||||
|
* where a human user might want big outputs). Pullfrog runs are headless and
|
||||||
|
* short — typical outputs are 1-3K tokens — so we cap at 5_000. This
|
||||||
|
* drastically reduces the upfront budget reservation OpenRouter requires per
|
||||||
|
* call (~$0.38 vs ~$2.40 for Opus), which is what lets low-wallet runs
|
||||||
|
* actually start.
|
||||||
|
*
|
||||||
|
* Plumbed via `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` env var rather than the
|
||||||
|
* config JSON. OpenCode's `OUTPUT_TOKEN_MAX` (session/llm.ts) is sourced
|
||||||
|
* exclusively from this env var; top-level `limit.output` in the config
|
||||||
|
* has no read site and is silently dropped on merge.
|
||||||
|
*/
|
||||||
const PULLFROG_OPENCODE_OUTPUT_LIMIT = 5000;
|
const PULLFROG_OPENCODE_OUTPUT_LIMIT = 5000;
|
||||||
|
|
||||||
function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string {
|
function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string {
|
||||||
@@ -85,7 +89,6 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
|
|||||||
[pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl },
|
[pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl },
|
||||||
},
|
},
|
||||||
agent: buildReviewerAgentConfig(),
|
agent: buildReviewerAgentConfig(),
|
||||||
limit: { output: PULLFROG_OPENCODE_OUTPUT_LIMIT },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (model) {
|
if (model) {
|
||||||
@@ -942,6 +945,7 @@ export const opencode = agent({
|
|||||||
...homeEnv,
|
...homeEnv,
|
||||||
OPENCODE_CONFIG_CONTENT: buildSecurityConfig(ctx, model),
|
OPENCODE_CONFIG_CONTENT: buildSecurityConfig(ctx, model),
|
||||||
OPENCODE_PERMISSION: permissionOverride,
|
OPENCODE_PERMISSION: permissionOverride,
|
||||||
|
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: PULLFROG_OPENCODE_OUTPUT_LIMIT.toString(),
|
||||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
|
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -113,4 +113,15 @@ describe("isRouterKeylimitExhaustedError", () => {
|
|||||||
false
|
false
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches across newlines (defends against upstream wrapping/reformatting)", () => {
|
||||||
|
expect(
|
||||||
|
isRouterKeylimitExhaustedError(
|
||||||
|
"APIError: This request requires more credits, or\nfewer max_tokens. You requested up to 32000 tokens"
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isRouterKeylimitExhaustedError("You requested up to 32000 tokens,\nbut can only afford 22800")
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,8 +51,13 @@ export function detectProviderError(text: string): string | null {
|
|||||||
* `APIError: This request requires more credits, or fewer max_tokens.
|
* `APIError: This request requires more credits, or fewer max_tokens.
|
||||||
* You requested up to 32000 tokens, but can only afford 22800.`
|
* You requested up to 32000 tokens, but can only afford 22800.`
|
||||||
*/
|
*/
|
||||||
|
// `/s` (dotAll) lets `.*?` cross newlines so we still detect the error if any
|
||||||
|
// upstream layer reformats the message onto multiple lines. Without it, a
|
||||||
|
// single inserted `\n` would silently bypass the BillingError reclassification
|
||||||
|
// and the user would see the generic `❌ Pullfrog failed` dump instead of the
|
||||||
|
// actionable top-up CTA.
|
||||||
const ROUTER_KEYLIMIT_EXHAUSTED_PATTERN =
|
const ROUTER_KEYLIMIT_EXHAUSTED_PATTERN =
|
||||||
/requires more credits.*?fewer max_tokens|requested up to \d+ tokens.*?can only afford/i;
|
/requires more credits.*?fewer max_tokens|requested up to \d+ tokens.*?can only afford/is;
|
||||||
|
|
||||||
export function isRouterKeylimitExhaustedError(text: string): boolean {
|
export function isRouterKeylimitExhaustedError(text: string): boolean {
|
||||||
return ROUTER_KEYLIMIT_EXHAUSTED_PATTERN.test(text);
|
return ROUTER_KEYLIMIT_EXHAUSTED_PATTERN.test(text);
|
||||||
|
|||||||
Reference in New Issue
Block a user