diff --git a/src/changelog/index.ts b/src/changelog/index.ts index 8a9b171ad..ab4a7db92 100644 --- a/src/changelog/index.ts +++ b/src/changelog/index.ts @@ -120,6 +120,19 @@ export interface StartupChangelogResult { versions: string[]; } +/** + * Hard-cut a string to a UTF-8 byte budget without splitting a code point: + * encode, truncate, decode, then strip a trailing replacement char (a cut + * multi-byte sequence) and trailing whitespace. + */ +function cutUtf8ToByteBudget(text: string, budget: number): string { + return Buffer.from(text, "utf8") + .subarray(0, budget) + .toString("utf8") + .replace(/\uFFFD$/, "") + .trimEnd(); +} + /** * Bound automatic startup markdown: newest-first, at most `maxEntries` sections, * hard cap `maxBytes` with a trailing hint when truncated. @@ -156,10 +169,7 @@ export function formatStartupChangelog( if (kept.length === 0) { // Single section larger than the budget: hard-cut the body so the // watermark path never dumps unbounded markdown into the banner. - const raw = Buffer.from(piece, "utf8") - .subarray(0, bodyBudget) - .toString("utf8"); - kept.push(raw.replace(/\uFFFD$/, "").trimEnd() + "…"); + kept.push(`${cutUtf8ToByteBudget(piece, bodyBudget)}…`); } truncated = true; break; @@ -172,10 +182,7 @@ export function formatStartupChangelog( } // Final hard cap if hint + body still overshoots (pathological tiny maxBytes). if (Buffer.byteLength(markdown, "utf8") > maxBytes) { - const cut = Buffer.from(markdown, "utf8") - .subarray(0, maxBytes) - .toString("utf8"); - markdown = cut.replace(/\uFFFD$/, "").trimEnd(); + markdown = cutUtf8ToByteBudget(markdown, maxBytes); truncated = true; } return { diff --git a/src/cost/cost-visibility.test.ts b/src/cost/cost-visibility.test.ts index 043e00882..86a463bf3 100644 --- a/src/cost/cost-visibility.test.ts +++ b/src/cost/cost-visibility.test.ts @@ -8,28 +8,7 @@ import { isCodingPlanProviderName, isFreeModelId, } from "./cost-visibility.js"; -import type { PricingCache } from "./pricing-fetcher.js"; - -const pricingCache: PricingCache = { - timestamp: 0, - models: { - "glm-5.1": { - inputPricePerToken: 0.000002, - outputPricePerToken: 0.00001, - cacheReadPricePerToken: 0, - }, - "free-model": { - inputPricePerToken: 0, - outputPricePerToken: 0, - cacheReadPricePerToken: 0, - }, - "gpt-5.6-luna": { - inputPricePerToken: 0.000001, - outputPricePerToken: 0.000008, - cacheReadPricePerToken: 0, - }, - }, -}; +import { testPricingCache as pricingCache } from "./pricing-test-fixture.js"; describe("isFreeModelId", () => { it("matches :free and -free suffixes case-insensitively", () => { diff --git a/src/cost/cost-visibility.ts b/src/cost/cost-visibility.ts index d71ccc94c..689ffc26d 100644 --- a/src/cost/cost-visibility.ts +++ b/src/cost/cost-visibility.ts @@ -90,18 +90,27 @@ export type CostHiddenReason = | "free-model" | "zero-priced"; -function isCodingPlanSession(input: CostVisibilityInput): boolean { +function isSessionOfType( + input: CostVisibilityInput, + isProviderName: (name: string) => boolean, + isBaseURL: (baseURL: string | undefined) => boolean, +): boolean { if (input.providerName !== undefined) { - return isCodingPlanProviderName(input.providerName); + return isProviderName(input.providerName); } - return isCodingPlanBaseURL(input.baseURL); + return isBaseURL(input.baseURL); +} + +function isCodingPlanSession(input: CostVisibilityInput): boolean { + return isSessionOfType(input, isCodingPlanProviderName, isCodingPlanBaseURL); } function isChatGPTSubscriptionSession(input: CostVisibilityInput): boolean { - if (input.providerName !== undefined) { - return isCodexProviderName(input.providerName); - } - return isChatGPTSubscriptionBaseURL(input.baseURL); + return isSessionOfType( + input, + isCodexProviderName, + isChatGPTSubscriptionBaseURL, + ); } // Non-null when the dollar cost should be suppressed: a manual provider diff --git a/src/cost/pricing-fetcher.ts b/src/cost/pricing-fetcher.ts index c6f6bfa1e..badb1be96 100644 --- a/src/cost/pricing-fetcher.ts +++ b/src/cost/pricing-fetcher.ts @@ -111,43 +111,106 @@ function* walkModelNodes( } } -export function parseModelsDevPricing( - payload: unknown, -): Record { +/** + * All per-model fields extracted from one models.dev node in a single pass. + * The `parseModelsDev*` collectors below share one traversal through + * `collectModelsDevFields` and only differ in which field they keep. + */ +interface ModelsDevModelFields { + pricing: ModelPricing | null; + reasoning: boolean | undefined; + contextWindow: number | undefined; +} + +function extractModelsDevModelFields( + node: Record, +): ModelsDevModelFields { + // models.dev nests the window under `limit.context`. + const limit = isRecord(node.limit) ? node.limit : undefined; + const context = + limit !== undefined && typeof limit.context === "number" + ? limit.context + : undefined; + return { + pricing: parseModelPricing(node), + reasoning: typeof node.reasoning === "boolean" ? node.reasoning : undefined, + contextWindow: + context !== undefined && Number.isFinite(context) && context > 0 + ? context + : undefined, + }; +} + +function collectModelsDevFields(payload: unknown): { + models: Record; + reasoning: Record; + contextWindows: Record; +} { const models: Record = {}; + const reasoning: Record = {}; + const contextWindows: Record = {}; for (const [id, node] of walkModelNodes(payload)) { - const pricing = parseModelPricing(node); - if (pricing !== null) models[id] = pricing; + const fields = extractModelsDevModelFields(node); + if (fields.pricing !== null) models[id] = fields.pricing; + if (fields.reasoning !== undefined) reasoning[id] = fields.reasoning; + if (fields.contextWindow !== undefined) + contextWindows[id] = fields.contextWindow; } - return models; + return { models, reasoning, contextWindows }; +} + +export function parseModelsDevPricing( + payload: unknown, +): Record { + return collectModelsDevFields(payload).models; } export function parseModelsDevReasoning( payload: unknown, ): Record { - const reasoning: Record = {}; - for (const [id, node] of walkModelNodes(payload)) { - if (typeof node.reasoning === "boolean") reasoning[id] = node.reasoning; - } - return reasoning; + return collectModelsDevFields(payload).reasoning; } export function parseModelsDevContextWindows( payload: unknown, ): Record { - const windows: Record = {}; - for (const [id, node] of walkModelNodes(payload)) { - // models.dev nests the window under `limit.context`. - const limit = isRecord(node.limit) ? node.limit : undefined; - const context = - limit !== undefined && typeof limit.context === "number" - ? limit.context - : undefined; - if (context !== undefined && Number.isFinite(context) && context > 0) { - windows[id] = context; - } + return collectModelsDevFields(payload).contextWindows; +} + +function isBooleanValue(value: unknown): value is boolean { + return typeof value === "boolean"; +} + +function isPositiveFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +/** + * Validated string-keyed record reader for cache sections: keeps entries + * whose values pass the guard, drops the rest. Shared by the reasoning and + * context-window sections of the pricing cache. + */ +function collectValidatedRecord( + section: unknown, + isValid: (value: unknown) => value is T, +): Record { + const collected: Record = {}; + if (!isRecord(section)) return collected; + for (const [modelId, value] of Object.entries(section)) { + if (isValid(value)) collected[modelId] = value; } - return windows; + return collected; +} + +/** Optional per-model metadata: present only when non-empty (older caches omit them). */ +function optionalMetadataFields( + reasoning: Record, + contextWindows: Record, +): Pick { + return { + ...(Object.keys(reasoning).length > 0 ? { reasoning } : {}), + ...(Object.keys(contextWindows).length > 0 ? { contextWindows } : {}), + }; } export async function readPricingCache( @@ -163,29 +226,18 @@ export async function readPricingCache( if (pricing instanceof type.errors) return null; models[modelId] = pricing; } - const reasoning: Record = {}; - if (isRecord(payload) && isRecord(payload.reasoning)) { - for (const [modelId, flag] of Object.entries(payload.reasoning)) { - if (typeof flag === "boolean") reasoning[modelId] = flag; - } - } - const contextWindows: Record = {}; - if (isRecord(payload) && isRecord(payload.contextWindows)) { - for (const [modelId, window] of Object.entries(payload.contextWindows)) { - if ( - typeof window === "number" && - Number.isFinite(window) && - window > 0 - ) { - contextWindows[modelId] = window; - } - } - } + const reasoning = collectValidatedRecord( + isRecord(payload) ? payload.reasoning : undefined, + isBooleanValue, + ); + const contextWindows = collectValidatedRecord( + isRecord(payload) ? payload.contextWindows : undefined, + isPositiveFiniteNumber, + ); return { timestamp: parsed.timestamp, models, - ...(Object.keys(reasoning).length > 0 ? { reasoning } : {}), - ...(Object.keys(contextWindows).length > 0 ? { contextWindows } : {}), + ...optionalMetadataFields(reasoning, contextWindows), }; } catch { return null; @@ -219,17 +271,14 @@ export async function fetchPricing( throw new Error(`models.dev pricing request failed: ${response.status}`); } const payload = await response.json(); - const models = parseModelsDevPricing(payload); + const { models, reasoning, contextWindows } = collectModelsDevFields(payload); if (Object.keys(models).length === 0) { throw new Error("models.dev pricing response did not include model prices"); } - const reasoning = parseModelsDevReasoning(payload); - const contextWindows = parseModelsDevContextWindows(payload); return { timestamp: now(), models, - ...(Object.keys(reasoning).length > 0 ? { reasoning } : {}), - ...(Object.keys(contextWindows).length > 0 ? { contextWindows } : {}), + ...optionalMetadataFields(reasoning, contextWindows), }; } diff --git a/src/cost/pricing-test-fixture.ts b/src/cost/pricing-test-fixture.ts new file mode 100644 index 000000000..3c971a59b --- /dev/null +++ b/src/cost/pricing-test-fixture.ts @@ -0,0 +1,32 @@ +import type { PricingCache } from "./pricing-fetcher.js"; + +/** + * Pricing cache shared by the cost test files: metered glm-5.1, metered + * gpt-5.6-luna, zero-priced free-model, and gpt-4 for the lookup tests. + * Prices are exact — session-cost assertions compare computed totals. + */ +export const testPricingCache: PricingCache = { + timestamp: 0, + models: { + "glm-5.1": { + inputPricePerToken: 0.000002, + outputPricePerToken: 0.00001, + cacheReadPricePerToken: 0, + }, + "gpt-5.6-luna": { + inputPricePerToken: 0.000001, + outputPricePerToken: 0.000008, + cacheReadPricePerToken: 0, + }, + "free-model": { + inputPricePerToken: 0, + outputPricePerToken: 0, + cacheReadPricePerToken: 0, + }, + "gpt-4": { + inputPricePerToken: 0.00003, + outputPricePerToken: 0.00006, + cacheReadPricePerToken: 0, + }, + }, +}; diff --git a/src/cost/session-cost.test.ts b/src/cost/session-cost.test.ts index 5aa370e5e..663ec183b 100644 --- a/src/cost/session-cost.test.ts +++ b/src/cost/session-cost.test.ts @@ -2,28 +2,12 @@ import { describe, expect, it } from "bun:test"; import type { TokenUsage } from "@intx/types/runtime"; import { createFaremeter, formatCost } from "./faremeter.js"; -import type { PricingCache } from "./pricing-fetcher.js"; +import { testPricingCache as pricingCache } from "./pricing-test-fixture.js"; import { billingIdentityFromSource, createSessionCostAccumulator, } from "./session-cost.js"; -const pricingCache: PricingCache = { - timestamp: 0, - models: { - "glm-5.1": { - inputPricePerToken: 0.000002, - outputPricePerToken: 0.00001, - cacheReadPricePerToken: 0, - }, - "gpt-5.6-luna": { - inputPricePerToken: 0.000001, - outputPricePerToken: 0.000008, - cacheReadPricePerToken: 0, - }, - }, -}; - const usage = (input: number, output: number): TokenUsage => ({ input, output, diff --git a/src/inference-error-message.ts b/src/inference-error-message.ts index 5277f1a5f..cf1d8ef4e 100644 --- a/src/inference-error-message.ts +++ b/src/inference-error-message.ts @@ -6,10 +6,7 @@ * happened and whether they can do anything about it. */ -import { - formatCodexUsageLimitMessage, - parseCodexUsageLimitError, -} from "./auth/codex/usage-limit-error.js"; +import { formatCodexUsageLimitMessage } from "./auth/codex/usage-limit-error.js"; import { codexProfileFromProviderName, isCodexProviderName, @@ -21,6 +18,7 @@ import { isCodexShortRateLimitInferenceError, isGatewayOverloadInferenceError, isXaiShortRateLimitInferenceError, + parseCodexUsageLimitFromError, RATE_LIMIT_USER_MESSAGE, type InferenceErrorLike, } from "./inference-gateway-error.js"; @@ -78,14 +76,6 @@ function codexUsageLimitLine(error: InferenceErrorLike): string | undefined { return undefined; } - const candidates: unknown[] = []; - if (error.raw !== undefined) candidates.push(error.raw); - if ( - typeof error.message === "string" && - error.message.trim().startsWith("{") - ) { - candidates.push(error.message); - } // Already-normalized path: message is our formatted line. if ( typeof error.message === "string" && @@ -95,18 +85,16 @@ function codexUsageLimitLine(error: InferenceErrorLike): string | undefined { return error.message; } - for (const candidate of candidates) { - const parsed = parseCodexUsageLimitError(candidate); - if (parsed === undefined) continue; - const profile = - error.providerId !== undefined - ? codexProfileFromProviderName(error.providerId) - : undefined; - return formatCodexUsageLimitMessage(parsed, { - ...(profile !== undefined ? { profile } : {}), - }); - } - return undefined; + // Candidate-list-plus-parse scan shared with the gateway-error module. + const parsed = parseCodexUsageLimitFromError(error); + if (parsed === undefined) return undefined; + const profile = + error.providerId !== undefined + ? codexProfileFromProviderName(error.providerId) + : undefined; + return formatCodexUsageLimitMessage(parsed, { + ...(profile !== undefined ? { profile } : {}), + }); } const TERMINAL_DIAGNOSTIC_MAX_CHARS = 240; @@ -126,9 +114,9 @@ function terminalProviderFailureCategory(error: InferenceErrorLike): string { return /^[a-z][a-z0-9_]*$/i.test(category) ? category : "unknown"; } -export function terminalProviderFailureMessage( +/** Sanitize the display label: trim, scrub, clamp, drop a trailing "Provider". */ +function terminalProviderFailureLabel( providerId: string, - error: InferenceErrorLike, displayLabel?: string, ): string { const preferred = displayLabel?.trim() || providerId; @@ -136,9 +124,18 @@ export function terminalProviderFailureMessage( preferred, TERMINAL_PROVIDER_LABEL_MAX_CHARS, ); - const label = ( - sanitizedLabel.length > 0 ? sanitizedLabel : "Unknown" - ).replace(/\s+Provider$/i, ""); + return (sanitizedLabel.length > 0 ? sanitizedLabel : "Unknown").replace( + /\s+Provider$/i, + "", + ); +} + +export function terminalProviderFailureMessage( + providerId: string, + error: InferenceErrorLike, + displayLabel?: string, +): string { + const label = terminalProviderFailureLabel(providerId, displayLabel); const category = terminalProviderFailureCategory(error); const message = safeDisplayText( error.message ?? "", @@ -181,14 +178,7 @@ function terminalProviderFailureSummary( error: InferenceErrorLike, displayLabel?: string, ): string { - const preferred = displayLabel?.trim() || providerId; - const sanitizedLabel = safeDisplayText( - preferred, - TERMINAL_PROVIDER_LABEL_MAX_CHARS, - ); - const label = ( - sanitizedLabel.length > 0 ? sanitizedLabel : "Unknown" - ).replace(/\s+Provider$/i, ""); + const label = terminalProviderFailureLabel(providerId, displayLabel); const category = terminalProviderFailureCategory(error); return `${label} Provider failed (${category}). ${terminalProviderFailureGuidance(error, category)}`; } diff --git a/src/inference-gateway-error.test.ts b/src/inference-gateway-error.test.ts index 6c8f1a0e3..622795f2c 100644 --- a/src/inference-gateway-error.test.ts +++ b/src/inference-gateway-error.test.ts @@ -151,27 +151,24 @@ describe("normalizeInferenceErrorForRetry", () => { // Without Go context, leave intx's classification alone. expect(normalizeInferenceErrorForRetry(bare)).toEqual(bare); - const viaRequestURL = normalizeInferenceErrorForRetry({ - ...bare, - requestURL: "https://opencode.ai/zen/go/v1/chat/completions", - }); - expect(viaRequestURL.category).toBe("retryable"); - // Bare 429 keeps the original message and appends a short retry hint. - expect(viaRequestURL.message.toLowerCase()).toMatch( - /too many requests|rate limit/, - ); - - const viaProviderId = normalizeInferenceErrorForRetry({ - ...bare, - providerId: "opencode-go", - }); - expect(viaProviderId.category).toBe("retryable"); - - const viaFlag = normalizeInferenceErrorForRetry({ - ...bare, - opencodeGo: true, - }); - expect(viaFlag.category).toBe("retryable"); + // All three Go-context channels reclassify the same bare 429. + for (const [context, checkMessage] of [ + [{ requestURL: "https://opencode.ai/zen/go/v1/chat/completions" }, true], + [{ providerId: "opencode-go" }, false], + [{ opencodeGo: true }, false], + ] as const) { + const normalized = normalizeInferenceErrorForRetry({ + ...bare, + ...context, + }); + expect(normalized.category).toBe("retryable"); + if (checkMessage) { + // Bare 429 keeps the original message and appends a short retry hint. + expect(normalized.message.toLowerCase()).toMatch( + /too many requests|rate limit/, + ); + } + } }); test("403 with usage-limit body reclassifies as quota_exhausted", () => { diff --git a/src/inference-gateway-error.ts b/src/inference-gateway-error.ts index 0c88a7f65..2e97ebbd6 100644 --- a/src/inference-gateway-error.ts +++ b/src/inference-gateway-error.ts @@ -83,6 +83,19 @@ function stringFromRaw(raw: unknown): string { } } +/** + * Marker-list check over the joined, lowercased parts: true when any marker + * is a substring of the combined text. Shared by the gateway-overload, xAI + * quota, and xAI capacity detectors. + */ +function combinedTextIncludesMarker( + parts: string[], + markers: readonly string[], +): boolean { + const combined = parts.join("\n").toLowerCase(); + return markers.some((marker) => combined.includes(marker)); +} + /** True when the payload looks like an HTML error page rather than API JSON/SSE. */ export function looksLikeHtmlGatewayBody(text: string): boolean { const trimmed = text.trimStart().slice(0, 512).toLowerCase(); @@ -97,9 +110,7 @@ export function looksLikeHtmlGatewayBody(text: string): boolean { function textSuggestsGatewayOverload(...parts: string[]): boolean { const combined = parts.join("\n").toLowerCase(); if (combined.includes("503")) return true; - return GATEWAY_OVERLOAD_TEXT_MARKERS.some((marker) => - combined.includes(marker), - ); + return combinedTextIncludesMarker(parts, GATEWAY_OVERLOAD_TEXT_MARKERS); } function hasGatewayOverloadStatus(error: InferenceErrorLike): boolean { @@ -234,8 +245,7 @@ function isKnownXaiProviderId(providerId: string | undefined): boolean { } function textHasXaiQuotaMarkers(...parts: string[]): boolean { - const combined = parts.join("\n").toLowerCase(); - return XAI_QUOTA_BODY_MARKERS.some((marker) => combined.includes(marker)); + return combinedTextIncludesMarker(parts, XAI_QUOTA_BODY_MARKERS); } /** @@ -287,8 +297,7 @@ function isXaiCapacityExactPhrase(part: string): boolean { } function textSuggestsXaiCapacity(...parts: string[]): boolean { - const combined = parts.join("\n").toLowerCase(); - if (XAI_CAPACITY_TEXT_MARKERS.some((marker) => combined.includes(marker))) { + if (combinedTextIncludesMarker(parts, XAI_CAPACITY_TEXT_MARKERS)) { return true; } return parts.some(isXaiCapacityExactPhrase); @@ -320,6 +329,29 @@ export function normalizeXaiCapacityError( }; } +/** + * Shared short-rate-limit predicate behind the twin provider checks: a known + * provider's HTTP 429 whose category is quota_exhausted (or already remapped + * retryable) and whose body carries no usage-limit markers. Provider identity + * and the usage-limit veto differ per provider; check order is fixed. + */ +function isShortRateLimitInferenceError( + error: InferenceErrorLike, + isKnownProvider: (providerId: string | undefined) => boolean, + isUsageLimit: (error: InferenceErrorLike) => boolean, +): boolean { + if (!isKnownProvider(error.providerId)) return false; + if (error.statusCode !== 429) return false; + if (error.category !== "quota_exhausted" && error.category !== "retryable") + return false; + if (isUsageLimit(error)) return false; + return true; +} + +function hasXaiQuotaMarkers(error: InferenceErrorLike): boolean { + return textHasXaiQuotaMarkers(error.message ?? "", stringFromRaw(error.raw)); +} + /** * True when a known-xAI HTTP 429 looks like a short rate limit rather than a * usage/quota window. Used by both retry normalization and transcript copy — @@ -331,32 +363,28 @@ export function normalizeXaiCapacityError( export function isXaiShortRateLimitInferenceError( error: InferenceErrorLike, ): boolean { - if (!isKnownXaiProviderId(error.providerId)) return false; - if (error.statusCode !== 429) return false; - if (error.category !== "quota_exhausted" && error.category !== "retryable") - return false; - if (textHasXaiQuotaMarkers(error.message ?? "", stringFromRaw(error.raw))) - return false; - return true; + return isShortRateLimitInferenceError( + error, + isKnownXaiProviderId, + hasXaiQuotaMarkers, + ); } /** - * intx defaults bare 429 → quota_exhausted. For known-xAI / Grok contexts a - * bare 429 (or rate-limit body without usage/quota markers) reclassifies as - * retryable so moderate Retry-After values are not treated as long-window - * quota exhaustion by the Corbits blind-wait abort. - * - * Clear usage/quota body markers keep quota_exhausted. Unknown providers are - * never remapped. + * Shared early-return chain and return skeleton behind the twin rate-limit + * normalizers: non-429s, non-quota categories, unknown providers, and bodies + * with usage-limit markers pass through untouched; a bare (or marker-free) + * 429 becomes retryable with scrubbed rate-limit copy. */ -export function normalizeXaiRateLimitError( +function normalizeProviderRateLimitError( error: InferenceErrorWithGoContext, + isKnownProvider: (providerId: string | undefined) => boolean, + isUsageLimit: (error: InferenceErrorLike) => boolean, ): InferenceError { if (error.statusCode !== 429) return error; if (error.category !== "quota_exhausted") return error; - if (!isKnownXaiProviderId(error.providerId)) return error; - if (textHasXaiQuotaMarkers(error.message ?? "", stringFromRaw(error.raw))) - return error; + if (!isKnownProvider(error.providerId)) return error; + if (isUsageLimit(error)) return error; return { category: "retryable", @@ -369,7 +397,26 @@ export function normalizeXaiRateLimitError( }; } -function parseCodexUsageLimitFromError( +/** + * intx defaults bare 429 → quota_exhausted. For known-xAI / Grok contexts a + * bare 429 (or rate-limit body without usage/quota markers) reclassifies as + * retryable so moderate Retry-After values are not treated as long-window + * quota exhaustion by the Corbits blind-wait abort. + * + * Clear usage/quota body markers keep quota_exhausted. Unknown providers are + * never remapped. + */ +export function normalizeXaiRateLimitError( + error: InferenceErrorWithGoContext, +): InferenceError { + return normalizeProviderRateLimitError( + error, + isKnownXaiProviderId, + hasXaiQuotaMarkers, + ); +} + +export function parseCodexUsageLimitFromError( error: InferenceErrorLike, ): ReturnType { const candidates: unknown[] = []; @@ -392,6 +439,10 @@ function isKnownCodexProviderId(providerId: string | undefined): boolean { return providerId !== undefined && isCodexProviderName(providerId); } +function hasCodexUsageLimit(error: InferenceErrorLike): boolean { + return parseCodexUsageLimitFromError(error) !== undefined; +} + /** * True when a known-Codex HTTP 429 looks like a short rate limit rather than a * `usage_limit_reached` window. Used by both retry normalization and transcript @@ -404,12 +455,11 @@ function isKnownCodexProviderId(providerId: string | undefined): boolean { export function isCodexShortRateLimitInferenceError( error: InferenceErrorLike, ): boolean { - if (!isKnownCodexProviderId(error.providerId)) return false; - if (error.statusCode !== 429) return false; - if (error.category !== "quota_exhausted" && error.category !== "retryable") - return false; - if (parseCodexUsageLimitFromError(error) !== undefined) return false; - return true; + return isShortRateLimitInferenceError( + error, + isKnownCodexProviderId, + hasCodexUsageLimit, + ); } /** @@ -423,20 +473,11 @@ export function isCodexShortRateLimitInferenceError( export function normalizeCodexRateLimitError( error: InferenceErrorWithGoContext, ): InferenceError { - if (error.statusCode !== 429) return error; - if (error.category !== "quota_exhausted") return error; - if (!isKnownCodexProviderId(error.providerId)) return error; - if (parseCodexUsageLimitFromError(error) !== undefined) return error; - - return { - category: "retryable", - message: RATE_LIMIT_USER_MESSAGE, - statusCode: 429, - ...(error.raw !== undefined ? { raw: error.raw } : {}), - ...(error.retryAfterMs !== undefined - ? { retryAfterMs: error.retryAfterMs } - : {}), - }; + return normalizeProviderRateLimitError( + error, + isKnownCodexProviderId, + hasCodexUsageLimit, + ); } /** diff --git a/src/list-dir.test.ts b/src/list-dir.test.ts index 9d94847bf..e82606b83 100644 --- a/src/list-dir.test.ts +++ b/src/list-dir.test.ts @@ -12,6 +12,25 @@ async function fixture(): Promise { return dir; } +/** + * Parameterized outside-workspace setup: a fresh tmp dir containing `file`. + * With `linkName`, the outside dir is also symlinked to + * `join(dir, linkName)` (the symlink-escape shape). + */ +async function outsideFixture( + dir: string, + options: { file: string; prefix?: string; linkName?: string }, +): Promise { + const outside = await mkdtemp( + join(tmpdir(), options.prefix ?? "list-dir-outside-"), + ); + await writeFile(join(outside, options.file), ""); + if (options.linkName !== undefined) { + await symlink(outside, join(dir, options.linkName)); + } + return outside; +} + describe("listDirectory", () => { test("lists entries sorted, marking directories with a trailing slash", async () => { const dir = await fixture(); @@ -38,9 +57,7 @@ describe("listDirectory", () => { test("refuses to follow a symlink that resolves outside the workspace", async () => { const dir = await fixture(); - const outside = await mkdtemp(join(tmpdir(), "list-dir-outside-")); - await writeFile(join(outside, "secret.txt"), ""); - await symlink(outside, join(dir, "escape")); + await outsideFixture(dir, { file: "secret.txt", linkName: "escape" }); const out = await listDirectory(dir, "escape"); expect(out).toContain("outside the workspace"); expect(out).not.toContain("secret.txt"); @@ -48,8 +65,10 @@ describe("listDirectory", () => { test("allowOutside lists a path outside the workspace", async () => { const dir = await fixture(); - const outside = await mkdtemp(join(tmpdir(), "list-dir-yolo-")); - await writeFile(join(outside, "other.txt"), ""); + const outside = await outsideFixture(dir, { + file: "other.txt", + prefix: "list-dir-yolo-", + }); const out = await listDirectory(dir, outside, { allowOutside: true }); expect(out.split("\n")).toContain("other.txt"); expect(out).not.toContain("outside the workspace"); @@ -57,17 +76,21 @@ describe("listDirectory", () => { test("allowOutside follows a symlink that resolves outside the workspace", async () => { const dir = await fixture(); - const outside = await mkdtemp(join(tmpdir(), "list-dir-yolo-link-")); - await writeFile(join(outside, "secret.txt"), ""); - await symlink(outside, join(dir, "escape")); + await outsideFixture(dir, { + file: "secret.txt", + prefix: "list-dir-yolo-link-", + linkName: "escape", + }); const out = await listDirectory(dir, "escape", { allowOutside: true }); expect(out.split("\n")).toContain("secret.txt"); }); test("allowOutside getter is resolved per call", async () => { const dir = await fixture(); - const outside = await mkdtemp(join(tmpdir(), "list-dir-yolo-getter-")); - await writeFile(join(outside, "other.txt"), ""); + const outside = await outsideFixture(dir, { + file: "other.txt", + prefix: "list-dir-yolo-getter-", + }); let allow = false; const blocked = await listDirectory(dir, outside, { allowOutside: () => allow, @@ -84,8 +107,10 @@ describe("listDirectory", () => { test("lists a registered sibling worktree root (CL-6729)", async () => { const dir = await fixture(); - const sibling = await mkdtemp(join(tmpdir(), "list-dir-sibling-")); - await writeFile(join(sibling, "sibling-file.txt"), ""); + const sibling = await outsideFixture(dir, { + file: "sibling-file.txt", + prefix: "list-dir-sibling-", + }); const roots = [await realpath(sibling)]; const out = await listDirectory(dir, sibling, { @@ -97,8 +122,10 @@ describe("listDirectory", () => { test("lists a sibling worktree via relative traversal (CL-6729)", async () => { const dir = await fixture(); - const sibling = await mkdtemp(join(tmpdir(), "list-dir-sibling-rel-")); - await writeFile(join(sibling, "sibling-file.txt"), ""); + const sibling = await outsideFixture(dir, { + file: "sibling-file.txt", + prefix: "list-dir-sibling-rel-", + }); const roots = [await realpath(sibling)]; const out = await listDirectory(dir, join("..", basename(sibling)), { diff --git a/src/pricing-fetcher.test.ts b/src/pricing-fetcher.test.ts index 803bfccc7..a6e3d28bb 100644 --- a/src/pricing-fetcher.test.ts +++ b/src/pricing-fetcher.test.ts @@ -14,6 +14,7 @@ import { defaultPricingCachePath, type PricingCache, } from "./cost/pricing-fetcher.js"; +import { testPricingCache } from "./cost/pricing-test-fixture.js"; // --------------------------------------------------------------------------- // defaultPricingCachePath @@ -60,45 +61,63 @@ describe("parseModelsDevReasoning", () => { // parseModelsDevPricing // --------------------------------------------------------------------------- -describe("parseModelsDevPricing", () => { - test("extracts model pricing from a flat object with id field", () => { - const payload = { - id: "gpt-4", - input_cost_per_million: 30, - output_cost_per_million: 60, - cache_read_cost_per_million: 3, - }; - const result = parseModelsDevPricing(payload); - expect(result["gpt-4"]).toEqual({ - inputPricePerToken: 30 / 1_000_000, - outputPricePerToken: 60 / 1_000_000, - cacheReadPricePerToken: 3 / 1_000_000, - }); - }); +/** Flat models.dev entry with default per-million costs; overrides win. */ +function pricingPayload( + fields: Record, +): Record { + return { + input_cost_per_million: 10, + output_cost_per_million: 20, + ...fields, + }; +} - test("extracts model pricing using model field when id is absent", () => { - const payload = { - model: "claude-3", - input_cost_per_million: 15, - output_cost_per_million: 75, - }; - const result = parseModelsDevPricing(payload); - expect(result["claude-3"]).toMatchObject({ - inputPricePerToken: 15 / 1_000_000, - outputPricePerToken: 75 / 1_000_000, - cacheReadPricePerToken: 0, +describe("parseModelsDevPricing", () => { + for (const { name, payload, modelId, expected } of [ + { + name: "extracts model pricing from a flat object with id field", + payload: pricingPayload({ + id: "gpt-4", + input_cost_per_million: 30, + output_cost_per_million: 60, + cache_read_cost_per_million: 3, + }), + modelId: "gpt-4", + expected: { + inputPricePerToken: 30 / 1_000_000, + outputPricePerToken: 60 / 1_000_000, + cacheReadPricePerToken: 3 / 1_000_000, + }, + }, + { + name: "extracts model pricing using model field when id is absent", + payload: pricingPayload({ + model: "claude-3", + input_cost_per_million: 15, + output_cost_per_million: 75, + }), + modelId: "claude-3", + expected: { + inputPricePerToken: 15 / 1_000_000, + outputPricePerToken: 75 / 1_000_000, + cacheReadPricePerToken: 0, + }, + }, + { + name: "defaults cacheReadPricePerToken to 0 when field is absent", + payload: pricingPayload({ id: "model-x" }), + modelId: "model-x", + expected: { + inputPricePerToken: 10 / 1_000_000, + outputPricePerToken: 20 / 1_000_000, + cacheReadPricePerToken: 0, + }, + }, + ]) { + test(name, () => { + expect(parseModelsDevPricing(payload)[modelId]).toEqual(expected); }); - }); - - test("defaults cacheReadPricePerToken to 0 when field is absent", () => { - const payload = { - id: "model-x", - input_cost_per_million: 10, - output_cost_per_million: 20, - }; - const result = parseModelsDevPricing(payload); - expect(defined(result["model-x"]).cacheReadPricePerToken).toBe(0); - }); + } test("recurses into nested objects", () => { const payload = { @@ -156,16 +175,8 @@ describe("parseModelsDevPricing", () => { // --------------------------------------------------------------------------- describe("lookupModelPricing", () => { - const cache: PricingCache = { - timestamp: 0, - models: { - "gpt-4": { - inputPricePerToken: 0.00003, - outputPricePerToken: 0.00006, - cacheReadPricePerToken: 0, - }, - }, - }; + // Shared cost fixture (covers gpt-4 at the same prices). + const cache: PricingCache = testPricingCache; test("returns pricing for a known model", () => { expect(lookupModelPricing(cache, "gpt-4")).toEqual( diff --git a/src/upgrade/index.ts b/src/upgrade/index.ts index f5fbc340e..df3dee7ad 100644 --- a/src/upgrade/index.ts +++ b/src/upgrade/index.ts @@ -10,7 +10,11 @@ import { basename } from "node:path"; import { existsSync } from "node:fs"; -import { compareVersions, parseVersionString } from "../changelog/index.js"; +import { + compareVersions, + entryVersion, + parseVersionString, +} from "../changelog/index.js"; import { COMMAND_NAME, PRODUCT_NAME } from "../branding.js"; import pkg from "../../package.json" with { type: "json" }; @@ -73,7 +77,7 @@ export interface UpgradeCheckOptions { function normalizeVersion(raw: string): string | null { const parsed = parseVersionString(raw); if (parsed === null) return null; - return `${parsed.major}.${parsed.minor}.${parsed.patch}`; + return entryVersion(parsed); } /**