diff --git a/app/curiositypedia-experience.tsx b/app/curiositypedia-experience.tsx
index a5e121e..4448faa 100644
--- a/app/curiositypedia-experience.tsx
+++ b/app/curiositypedia-experience.tsx
@@ -729,6 +729,7 @@ export function CuriosityPediaExperience() {
async function retryActiveQuestion() {
if (!activeJourney || !activeTurn) return;
+ const previewMedia = activeTurn.media[0];
await create({
seed: activeTurn.question,
performerId: nextPerformerId ?? activeJourney.performerId,
@@ -736,6 +737,14 @@ export function CuriosityPediaExperience() {
researchPreset: "standard",
answerDensity: preferences.answerDensity,
outputLocale: preferences.defaultOutputLocale,
+ preview: previewMedia ? {
+ question: activeTurn.question,
+ teaser: previewMedia.caption,
+ imageUrl: previewMedia.imageUrl,
+ imageAlt: previewMedia.alt,
+ sourceLabel: previewMedia.title ?? activeTurn.topicLabel,
+ sourceUrl: previewMedia.sourcePageUrl,
+ } : undefined,
});
}
diff --git a/app/globals.css b/app/globals.css
index c4874e5..1172320 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1006,7 +1006,6 @@ html[data-theme="dark"] :is(input, textarea, select) { color-scheme: dark; }
opacity: .75;
position: absolute;
top: 29%;
- transform: rotate(-11deg);
width: 94%;
z-index: 1;
}
@@ -1074,9 +1073,12 @@ html[data-theme="dark"] :is(input, textarea, select) { color-scheme: dark; }
inset calc(var(--celestial-size) * -.018) calc(var(--celestial-size) * -.018) calc(var(--celestial-size) * .03) rgba(122,74,0,.35),
0 0 calc(var(--celestial-size) * .06) rgba(255,212,59,.45);
height: 16%;
- left: 4%;
+ left: 0;
+ offset-anchor: center;
+ offset-path: ellipse(46% 21% at 50% 50%);
+ offset-rotate: 0deg;
position: absolute;
- top: 48%;
+ top: 0;
width: 16%;
z-index: 0;
}
@@ -1087,6 +1089,7 @@ html[data-theme="dark"] :is(input, textarea, select) { color-scheme: dark; }
animation: none;
background: linear-gradient(145deg, #ffffff, var(--sky));
left: 79%;
+ offset-path: none;
top: 24%;
transform: scale(.9);
z-index: 4;
@@ -1095,6 +1098,7 @@ html[data-theme="dark"] :is(input, textarea, select) { color-scheme: dark; }
animation: none;
background: var(--coral);
left: 79%;
+ offset-path: none;
top: 24%;
transform: scale(.9);
z-index: 4;
@@ -1103,14 +1107,13 @@ html[data-theme="dark"] :is(input, textarea, select) { color-scheme: dark; }
to { transform: translateX(-50%); }
}
@keyframes celestial-moon-orbit {
- 0% { left: 4%; top: 48%; transform: translate(-50%, -50%) scale(.72); z-index: 0; }
- 24.9% { z-index: 0; }
- 25% { left: 50%; top: 14%; transform: translate(-50%, -50%) scale(.86); z-index: 0; }
- 49.9% { z-index: 0; }
- 50% { left: 96%; top: 48%; transform: translate(-50%, -50%) scale(1.08); z-index: 4; }
- 75% { left: 50%; top: 82%; transform: translate(-50%, -50%) scale(.94); z-index: 4; }
- 99.9% { z-index: 4; }
- 100% { left: 4%; top: 48%; transform: translate(-50%, -50%) scale(.72); z-index: 0; }
+ 0% { offset-distance: 0%; transform: scale(1.08); z-index: 4; }
+ 25% { offset-distance: 25%; transform: scale(.94); z-index: 4; }
+ 49.9% { offset-distance: 49.9%; transform: scale(.72); z-index: 4; }
+ 50% { offset-distance: 50%; transform: scale(.72); z-index: 0; }
+ 75% { offset-distance: 75%; transform: scale(.86); z-index: 0; }
+ 99.9% { offset-distance: 99.9%; transform: scale(1.08); z-index: 0; }
+ 100% { offset-distance: 100%; transform: scale(1.08); z-index: 4; }
}
/* Empty / loading / footer */
diff --git a/docs/code-index.md b/docs/code-index.md
index ffdcf7c..b9ccd2e 100644
--- a/docs/code-index.md
+++ b/docs/code-index.md
@@ -74,7 +74,7 @@ Read [architecture.md](architecture.md) for system boundaries and change rules.
| [`lib/api.ts`](../lib/api.ts) | [`lib/contracts.ts`](../lib/contracts.ts)
[`lib/errors.ts`](../lib/errors.ts)
[`lib/viewer.ts`](../lib/viewer.ts) |
| [`lib/background-research.ts`](../lib/background-research.ts) | [`db/index.ts`](../db/index.ts)
[`lib/contracts.ts`](../lib/contracts.ts)
[`lib/errors.ts`](../lib/errors.ts)
[`lib/live-repository.ts`](../lib/live-repository.ts)
[`lib/live-research.ts`](../lib/live-research.ts)
[`lib/openai.ts`](../lib/openai.ts)
[`lib/provider-cost-control.ts`](../lib/provider-cost-control.ts)
[`lib/provider-usage.ts`](../lib/provider-usage.ts)
[`lib/research/provider-response.ts`](../lib/research/provider-response.ts)
[`lib/viewer.ts`](../lib/viewer.ts) |
| [`lib/bookmarks-repository.ts`](../lib/bookmarks-repository.ts) | [`db/index.ts`](../db/index.ts)
[`lib/contracts.ts`](../lib/contracts.ts)
[`lib/errors.ts`](../lib/errors.ts)
[`lib/request.ts`](../lib/request.ts)
[`lib/viewer.ts`](../lib/viewer.ts) |
-| [`lib/catalog.ts`](../lib/catalog.ts) | [`lib/contracts.ts`](../lib/contracts.ts) |
+| [`lib/catalog.ts`](../lib/catalog.ts) | [`lib/contracts.ts`](../lib/contracts.ts)
[`lib/research-config.ts`](../lib/research-config.ts) |
| [`lib/contracts.ts`](../lib/contracts.ts) | — |
| [`lib/diagnostics.ts`](../lib/diagnostics.ts) | [`db/index.ts`](../db/index.ts)
[`lib/contracts.ts`](../lib/contracts.ts)
[`lib/errors.ts`](../lib/errors.ts)
[`lib/viewer.ts`](../lib/viewer.ts) |
| [`lib/errors.ts`](../lib/errors.ts) | [`lib/contracts.ts`](../lib/contracts.ts) |
@@ -87,13 +87,14 @@ Read [architecture.md](architecture.md) for system boundaries and change rules.
| [`lib/landing-recommendations-repository.ts`](../lib/landing-recommendations-repository.ts) | [`db/index.ts`](../db/index.ts)
[`lib/contracts.ts`](../lib/contracts.ts)
[`lib/errors.ts`](../lib/errors.ts) |
| [`lib/live-redraw.ts`](../lib/live-redraw.ts) | [`lib/catalog.ts`](../lib/catalog.ts)
[`lib/contracts.ts`](../lib/contracts.ts)
[`lib/errors.ts`](../lib/errors.ts)
[`lib/i18n.ts`](../lib/i18n.ts)
[`lib/openai.ts`](../lib/openai.ts)
[`lib/provider-cost-control.ts`](../lib/provider-cost-control.ts)
[`lib/provider-usage.ts`](../lib/provider-usage.ts) |
| [`lib/live-repository.ts`](../lib/live-repository.ts) | [`db/index.ts`](../db/index.ts)
[`lib/catalog.ts`](../lib/catalog.ts)
[`lib/contracts.ts`](../lib/contracts.ts)
[`lib/errors.ts`](../lib/errors.ts)
[`lib/i18n.ts`](../lib/i18n.ts)
[`lib/live-research.ts`](../lib/live-research.ts)
[`lib/repository.ts`](../lib/repository.ts)
[`lib/request.ts`](../lib/request.ts)
[`lib/turn-options.ts`](../lib/turn-options.ts)
[`lib/usage-policy.ts`](../lib/usage-policy.ts)
[`lib/viewer.ts`](../lib/viewer.ts) |
-| [`lib/live-research.ts`](../lib/live-research.ts) | [`lib/catalog.ts`](../lib/catalog.ts)
[`lib/contracts.ts`](../lib/contracts.ts)
[`lib/errors.ts`](../lib/errors.ts)
[`lib/fixtures.ts`](../lib/fixtures.ts)
[`lib/i18n.ts`](../lib/i18n.ts)
[`lib/openai.ts`](../lib/openai.ts)
[`lib/provider-cost-control.ts`](../lib/provider-cost-control.ts)
[`lib/provider-usage.ts`](../lib/provider-usage.ts)
[`lib/research/prompt-policy.ts`](../lib/research/prompt-policy.ts)
[`lib/research/provider-response.ts`](../lib/research/provider-response.ts)
[`lib/research/provider-stream.ts`](../lib/research/provider-stream.ts)
[`lib/research/turn-validation.ts`](../lib/research/turn-validation.ts) |
-| [`lib/openai.ts`](../lib/openai.ts) | [`lib/errors.ts`](../lib/errors.ts) |
+| [`lib/live-research.ts`](../lib/live-research.ts) | [`lib/catalog.ts`](../lib/catalog.ts)
[`lib/contracts.ts`](../lib/contracts.ts)
[`lib/errors.ts`](../lib/errors.ts)
[`lib/fixtures.ts`](../lib/fixtures.ts)
[`lib/i18n.ts`](../lib/i18n.ts)
[`lib/openai.ts`](../lib/openai.ts)
[`lib/provider-cost-control.ts`](../lib/provider-cost-control.ts)
[`lib/provider-usage.ts`](../lib/provider-usage.ts)
[`lib/research-config.ts`](../lib/research-config.ts)
[`lib/research/prompt-policy.ts`](../lib/research/prompt-policy.ts)
[`lib/research/provider-response.ts`](../lib/research/provider-response.ts)
[`lib/research/provider-stream.ts`](../lib/research/provider-stream.ts)
[`lib/research/turn-validation.ts`](../lib/research/turn-validation.ts) |
+| [`lib/openai.ts`](../lib/openai.ts) | [`lib/errors.ts`](../lib/errors.ts)
[`lib/research-config.ts`](../lib/research-config.ts) |
| [`lib/preferences-repository.ts`](../lib/preferences-repository.ts) | [`db/index.ts`](../db/index.ts)
[`lib/catalog.ts`](../lib/catalog.ts)
[`lib/contracts.ts`](../lib/contracts.ts)
[`lib/errors.ts`](../lib/errors.ts)
[`lib/i18n.ts`](../lib/i18n.ts)
[`lib/request.ts`](../lib/request.ts)
[`lib/viewer.ts`](../lib/viewer.ts) |
| [`lib/provider-cost-control.ts`](../lib/provider-cost-control.ts) | [`db/index.ts`](../db/index.ts)
[`lib/catalog.ts`](../lib/catalog.ts)
[`lib/contracts.ts`](../lib/contracts.ts)
[`lib/errors.ts`](../lib/errors.ts)
[`lib/openai.ts`](../lib/openai.ts)
[`lib/usage-policy.ts`](../lib/usage-policy.ts) |
| [`lib/provider-usage.ts`](../lib/provider-usage.ts) | [`db/index.ts`](../db/index.ts)
[`lib/catalog.ts`](../lib/catalog.ts)
[`lib/contracts.ts`](../lib/contracts.ts)
[`lib/openai.ts`](../lib/openai.ts)
[`lib/provider-cost-control.ts`](../lib/provider-cost-control.ts) |
| [`lib/repository.ts`](../lib/repository.ts) | [`db/index.ts`](../db/index.ts)
[`lib/catalog.ts`](../lib/catalog.ts)
[`lib/contracts.ts`](../lib/contracts.ts)
[`lib/errors.ts`](../lib/errors.ts)
[`lib/fixtures.ts`](../lib/fixtures.ts)
[`lib/journeys/read-model.ts`](../lib/journeys/read-model.ts)
[`lib/request.ts`](../lib/request.ts)
[`lib/turn-options.ts`](../lib/turn-options.ts)
[`lib/usage-policy.ts`](../lib/usage-policy.ts)
[`lib/viewer.ts`](../lib/viewer.ts) |
| [`lib/request.ts`](../lib/request.ts) | [`lib/contracts.ts`](../lib/contracts.ts)
[`lib/errors.ts`](../lib/errors.ts) |
+| [`lib/research-config.ts`](../lib/research-config.ts) | — |
| [`lib/research-status-repository.ts`](../lib/research-status-repository.ts) | [`db/index.ts`](../db/index.ts)
[`lib/errors.ts`](../lib/errors.ts)
[`lib/viewer.ts`](../lib/viewer.ts) |
| [`lib/research/prompt-policy.ts`](../lib/research/prompt-policy.ts) | [`lib/catalog.ts`](../lib/catalog.ts)
[`lib/contracts.ts`](../lib/contracts.ts)
[`lib/i18n.ts`](../lib/i18n.ts) |
| [`lib/research/provider-response.ts`](../lib/research/provider-response.ts) | — |
diff --git a/lib/catalog.ts b/lib/catalog.ts
index e3f9b1b..d834f2c 100644
--- a/lib/catalog.ts
+++ b/lib/catalog.ts
@@ -7,6 +7,7 @@ import type {
PresetConfig,
UserPreferences,
} from "./contracts";
+import { OPENAI_PROMPT_LIMITS } from "./research-config";
export const PROMPT_VERSION = "wonder-research-turn@4.1.0";
@@ -188,33 +189,33 @@ export const PRESETS: PresetConfig[] = [
name: "Spark",
description: "A quick sourced orientation for simple or stable curiosity.",
sourceRange: "2–4 useful sources",
- waitBand: "about 25 seconds",
+ waitBand: "up to 180 seconds",
costBand: "lowest live cost",
- maxToolCalls: 2,
- maxOutputTokens: 4000,
- deadlineMs: 25_000,
+ maxToolCalls: OPENAI_PROMPT_LIMITS.liveResearch.spark.maxToolCalls,
+ maxOutputTokens: OPENAI_PROMPT_LIMITS.liveResearch.spark.maxOutputTokens,
+ deadlineMs: OPENAI_PROMPT_LIMITS.liveResearch.spark.timeoutMs,
},
{
id: "standard",
name: "Standard",
description: "Enough homework for a thoughtful, balanced performance.",
sourceRange: "3–8 useful sources",
- waitBand: "about 60 seconds",
+ waitBand: "up to 240 seconds",
costBand: "balanced live cost",
- maxToolCalls: 5,
- maxOutputTokens: 8000,
- deadlineMs: 60_000,
+ maxToolCalls: OPENAI_PROMPT_LIMITS.liveResearch.standard.maxToolCalls,
+ maxOutputTokens: OPENAI_PROMPT_LIMITS.liveResearch.standard.maxOutputTokens,
+ deadlineMs: OPENAI_PROMPT_LIMITS.liveResearch.standard.timeoutMs,
},
{
id: "deep",
name: "Deep",
description: "Broader evidence for niche, current, or contested questions.",
sourceRange: "6–15 useful sources",
- waitBand: "up to 120 seconds",
+ waitBand: "up to 300 seconds",
costBand: "highest live cost",
- maxToolCalls: 10,
- maxOutputTokens: 16000,
- deadlineMs: 120_000,
+ maxToolCalls: OPENAI_PROMPT_LIMITS.liveResearch.deep.maxToolCalls,
+ maxOutputTokens: OPENAI_PROMPT_LIMITS.liveResearch.deep.maxOutputTokens,
+ deadlineMs: OPENAI_PROMPT_LIMITS.liveResearch.deep.timeoutMs,
},
];
diff --git a/lib/live-research.ts b/lib/live-research.ts
index b36b106..2391ec2 100644
--- a/lib/live-research.ts
+++ b/lib/live-research.ts
@@ -23,6 +23,7 @@ import {
responseIncompleteReason,
structuredOutput,
} from "./openai";
+import { OPENAI_IMAGE_SEARCH_MAX_RESULTS } from "./research-config";
import { recordOpenAIUsage } from "./provider-usage";
import { reserveProviderCost } from "./provider-cost-control";
import { localeName } from "./i18n";
@@ -218,7 +219,7 @@ export function liveResearchRequestBody(
: {
type: "web_search",
search_content_types: ["image", "text"],
- image_settings: { max_results: 10, caption: true },
+ image_settings: { max_results: OPENAI_IMAGE_SEARCH_MAX_RESULTS, caption: true },
}],
tool_choice: "auto",
include: prepared.imagePreference === "avoid"
diff --git a/lib/openai.ts b/lib/openai.ts
index ecfdbb6..250d2d6 100644
--- a/lib/openai.ts
+++ b/lib/openai.ts
@@ -1,5 +1,6 @@
import { env } from "cloudflare:workers";
import { RepositoryError } from "./errors";
+export { OPENAI_PROMPT_LIMITS } from "./research-config";
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace -- Cloudflare augments this namespace.
@@ -13,20 +14,6 @@ declare global {
const RESPONSES_URL = "https://api.openai.com/v1/responses";
-export const OPENAI_PROMPT_LIMITS = {
- liveResearch: {
- spark: { maxToolCalls: 3, maxOutputTokens: 10_000, reasoning: "low", timeoutMs: 60_000 },
- standard: { maxToolCalls: 8, maxOutputTokens: 18_000, reasoning: "medium", timeoutMs: 150_000 },
- deep: { maxToolCalls: 12, maxOutputTokens: 26_000, reasoning: "high", timeoutMs: 180_000 },
- },
- starterGeneration: { maxOutputTokens: 6_000, reasoning: "high" },
- questionRedraw: { maxOutputTokens: 4_000, reasoning: "high" },
- visualCuration: { maxToolCalls: 12, maxOutputTokens: 14_000, reasoning: "medium", timeoutMs: 120_000 },
- imageNoteRepair: { maxOutputTokens: 3_000, reasoning: "medium", timeoutMs: 30_000 },
- citationRepair: { maxOutputTokens: 2_000, reasoning: "medium", timeoutMs: 30_000 },
- citationRecovery: { maxOutputTokens: 6_000, reasoning: "high", timeoutMs: 60_000 },
-} as const;
-
export function openAIConfigured(): boolean {
return openAIEnabled() && Boolean(env.OPENAI_API_KEY?.trim());
}
diff --git a/lib/provider-usage.ts b/lib/provider-usage.ts
index 0406bef..2e46686 100644
--- a/lib/provider-usage.ts
+++ b/lib/provider-usage.ts
@@ -4,6 +4,7 @@ import type { ModelId } from "./contracts";
import { isRecord } from "./openai";
import {
markProviderCostUncertain,
+ releaseProviderCost,
settleProviderCost,
} from "./provider-cost-control";
import type { ProviderCostOperation } from "./provider-cost-control";
@@ -97,6 +98,10 @@ export async function recordOpenAIUsage(input: RecordProviderUsageInput): Promis
Math.round(usage.estimatedCostUsd * 1_000_000),
input.providerRequestId ?? usage.providerResponseId,
);
+ } else if (input.outcome === "http_error") {
+ // A rejected HTTP request never started model work, so it must not
+ // retain a full model-priced hold or poison automatic retries.
+ await releaseProviderCost(input.costReservationId);
} else {
await markProviderCostUncertain(input.costReservationId, input.providerRequestId);
}
diff --git a/lib/research-config.ts b/lib/research-config.ts
new file mode 100644
index 0000000..3d44786
--- /dev/null
+++ b/lib/research-config.ts
@@ -0,0 +1,55 @@
+/**
+ * The canonical generation profile for every selectable OpenAI model.
+ *
+ * Model routing may choose only `model`; prompts, reasoning, token budgets,
+ * tool budgets, and deadlines must come from this shared profile.
+ */
+export const GENERATION_CONFIG_VERSION = "high-quality@1";
+
+export const OPENAI_PROMPT_LIMITS = Object.freeze({
+ liveResearch: Object.freeze({
+ spark: Object.freeze({
+ maxToolCalls: 8,
+ maxOutputTokens: 20_000,
+ reasoning: "high",
+ timeoutMs: 180_000,
+ }),
+ standard: Object.freeze({
+ maxToolCalls: 12,
+ maxOutputTokens: 30_000,
+ reasoning: "high",
+ timeoutMs: 240_000,
+ }),
+ deep: Object.freeze({
+ maxToolCalls: 16,
+ maxOutputTokens: 40_000,
+ reasoning: "high",
+ timeoutMs: 300_000,
+ }),
+ }),
+ starterGeneration: Object.freeze({ maxOutputTokens: 10_000, reasoning: "high" }),
+ questionRedraw: Object.freeze({ maxOutputTokens: 8_000, reasoning: "high" }),
+ visualCuration: Object.freeze({
+ maxToolCalls: 12,
+ maxOutputTokens: 20_000,
+ reasoning: "high",
+ timeoutMs: 180_000,
+ }),
+ imageNoteRepair: Object.freeze({
+ maxOutputTokens: 6_000,
+ reasoning: "high",
+ timeoutMs: 60_000,
+ }),
+ citationRepair: Object.freeze({
+ maxOutputTokens: 5_000,
+ reasoning: "high",
+ timeoutMs: 60_000,
+ }),
+ citationRecovery: Object.freeze({
+ maxOutputTokens: 10_000,
+ reasoning: "high",
+ timeoutMs: 90_000,
+ }),
+} as const);
+
+export const OPENAI_IMAGE_SEARCH_MAX_RESULTS = 10;
diff --git a/lib/research/provider-stream.ts b/lib/research/provider-stream.ts
index 5f7d3dc..cfc6ef3 100644
--- a/lib/research/provider-stream.ts
+++ b/lib/research/provider-stream.ts
@@ -113,6 +113,10 @@ export async function runProviderStream({
providerRequestId = response.headers.get("x-request-id");
if (!response.ok) {
+ const retryable = response.status === 408
+ || response.status === 409
+ || response.status === 429
+ || response.status >= 500;
usageRecorded = true;
await recordOutcome({
...usageContext,
@@ -132,11 +136,15 @@ export async function runProviderStream({
response.status === 401 || response.status === 403
? "PROVIDER_UNAVAILABLE"
: "PROVIDER_ERROR",
- response.status === 429
- ? "Live research is busy or has reached its provider limit. Please try again shortly."
- : "Live research could not reach the provider. The journey was not committed; it is safe to retry.",
+ response.status === 401 || response.status === 403
+ ? "This deployment cannot access the selected OpenAI model. Choose another model or verify the production OpenAI project access."
+ : response.status === 429
+ ? "Live research is busy or has reached its provider limit. Please try again shortly."
+ : retryable
+ ? "Live research could not reach the provider. The journey was not committed; it is safe to retry."
+ : "The provider rejected this research request. Verify the selected model and shared generation configuration.",
response.status === 429 ? 429 : 502,
- true,
+ retryable,
);
}
diff --git a/lib/usage-policy.ts b/lib/usage-policy.ts
index 0f1b2d9..18f3821 100644
--- a/lib/usage-policy.ts
+++ b/lib/usage-policy.ts
@@ -23,7 +23,7 @@ export const CURRENT_USER_POLICIES: Readonly {
assert.equal(OPENAI_PROMPT_LIMITS.questionRedraw.reasoning, "high");
- assert.equal(OPENAI_PROMPT_LIMITS.questionRedraw.maxOutputTokens, 4_000);
+ assert.equal(OPENAI_PROMPT_LIMITS.questionRedraw.maxOutputTokens, 8_000);
});
test("recognizes incomplete Responses API output across prompt paths", () => {
@@ -62,12 +62,12 @@ test("recognizes incomplete Responses API output across prompt paths", () => {
test("allocates scaled reasoning and output budgets to every prompt path", () => {
assert.deepEqual(OPENAI_PROMPT_LIMITS.liveResearch, {
- spark: { maxToolCalls: 3, maxOutputTokens: 10_000, reasoning: "low", timeoutMs: 60_000 },
- standard: { maxToolCalls: 8, maxOutputTokens: 18_000, reasoning: "medium", timeoutMs: 150_000 },
- deep: { maxToolCalls: 12, maxOutputTokens: 26_000, reasoning: "high", timeoutMs: 180_000 },
+ spark: { maxToolCalls: 8, maxOutputTokens: 20_000, reasoning: "high", timeoutMs: 180_000 },
+ standard: { maxToolCalls: 12, maxOutputTokens: 30_000, reasoning: "high", timeoutMs: 240_000 },
+ deep: { maxToolCalls: 16, maxOutputTokens: 40_000, reasoning: "high", timeoutMs: 300_000 },
});
- assert.deepEqual(OPENAI_PROMPT_LIMITS.starterGeneration, { maxOutputTokens: 6_000, reasoning: "high" });
- assert.deepEqual(OPENAI_PROMPT_LIMITS.imageNoteRepair, { maxOutputTokens: 3_000, reasoning: "medium", timeoutMs: 30_000 });
- assert.deepEqual(OPENAI_PROMPT_LIMITS.citationRepair, { maxOutputTokens: 2_000, reasoning: "medium", timeoutMs: 30_000 });
- assert.deepEqual(OPENAI_PROMPT_LIMITS.citationRecovery, { maxOutputTokens: 6_000, reasoning: "high", timeoutMs: 60_000 });
+ assert.deepEqual(OPENAI_PROMPT_LIMITS.starterGeneration, { maxOutputTokens: 10_000, reasoning: "high" });
+ assert.deepEqual(OPENAI_PROMPT_LIMITS.imageNoteRepair, { maxOutputTokens: 6_000, reasoning: "high", timeoutMs: 60_000 });
+ assert.deepEqual(OPENAI_PROMPT_LIMITS.citationRepair, { maxOutputTokens: 5_000, reasoning: "high", timeoutMs: 60_000 });
+ assert.deepEqual(OPENAI_PROMPT_LIMITS.citationRecovery, { maxOutputTokens: 10_000, reasoning: "high", timeoutMs: 90_000 });
});
diff --git a/tests/live-research.test.mjs b/tests/live-research.test.mjs
index 0347e3a..c8603a4 100644
--- a/tests/live-research.test.mjs
+++ b/tests/live-research.test.mjs
@@ -1,8 +1,8 @@
import assert from "node:assert/strict";
import test from "node:test";
import { env } from "cloudflare:workers";
-import { PERFORMERS } from "../lib/catalog.ts";
-import { runLiveResearch } from "../lib/live-research.ts";
+import { MODELS, PERFORMERS } from "../lib/catalog.ts";
+import { liveResearchRequestBody, runLiveResearch } from "../lib/live-research.ts";
import {
TURN_SCHEMA,
buildInstructions,
@@ -41,6 +41,21 @@ function preparedResearch(overrides = {}) {
};
}
+test("freezes one standard request configuration across every live model", () => {
+ const liveModels = MODELS.filter((model) => model.mode === "live");
+ const bodies = liveModels.map((model) => liveResearchRequestBody(preparedResearch({
+ modelId: model.id,
+ imagePreference: "prefer",
+ })));
+ const canonical = { ...bodies[0], model: "" };
+ for (const body of bodies) {
+ assert.deepEqual({ ...body, model: "" }, canonical);
+ assert.deepEqual(body.reasoning, { effort: "high" });
+ assert.equal(body.max_output_tokens, 30_000);
+ assert.equal(body.max_tool_calls, 12);
+ }
+});
+
test("lease ownership is checked before the first provider attempt", async () => {
let providerCalls = 0;
const originalFetch = globalThis.fetch;
@@ -934,14 +949,22 @@ test("records exactly one usage outcome for HTTP, missing-body, and missing-term
{
response: () => new Response("rejected", { status: 429 }),
code: "PROVIDER_ERROR",
+ retryable: true,
+ },
+ {
+ response: () => new Response("rejected", { status: 403 }),
+ code: "PROVIDER_UNAVAILABLE",
+ retryable: false,
},
{
response: () => new Response(null, { status: 200 }),
code: "PROVIDER_ERROR",
+ retryable: true,
},
{
response: () => new Response("data: [DONE]\n\n", { status: 200 }),
code: "PROVIDER_ERROR",
+ retryable: true,
},
];
try {
@@ -950,7 +973,7 @@ test("records exactly one usage outcome for HTTP, missing-body, and missing-term
globalThis.fetch = async () => item.response();
await assert.rejects(
() => runLiveResearch(preparedResearch({ requestId: `request-terminal-${index}` }), () => {}),
- (error) => error?.code === item.code,
+ (error) => error?.code === item.code && error?.retryable === item.retryable,
);
assert.equal(writes(), 1);
}
@@ -1139,8 +1162,8 @@ test("runs exactly one no-search repair after an initial citation mismatch", asy
"web_search_call.results",
]);
assert.equal(requests[0].tool_choice, "auto");
- assert.equal(requests[0].max_output_tokens, 18_000);
- assert.deepEqual(requests[0].reasoning, { effort: "medium" });
+ assert.equal(requests[0].max_output_tokens, 30_000);
+ assert.deepEqual(requests[0].reasoning, { effort: "high" });
assert.deepEqual(requests[0].text, {
format: {
type: "json_schema",
@@ -1154,8 +1177,8 @@ test("runs exactly one no-search repair after an initial citation mismatch", asy
assert.equal(requests[0].store, false);
assert.equal(requests[0].stream, true);
assert.equal(requests[1].tools, undefined);
- assert.equal(requests[1].max_output_tokens, 2_000);
- assert.deepEqual(requests[1].reasoning, { effort: "medium" });
+ assert.equal(requests[1].max_output_tokens, 5_000);
+ assert.deepEqual(requests[1].reasoning, { effort: "high" });
assert.equal(draft.answerBlocks[0].sourceIds.length, 1);
assert.equal(draft.usage.inputTokens, 120);
assert.equal(draft.usage.outputTokens, 60);
@@ -1258,10 +1281,10 @@ test("recovers an unsupported block with a targeted web search", async () => {
assert.equal(requests.length, 3);
assert.equal(requests[1].tools, undefined);
- assert.equal(requests[1].max_output_tokens, 2_000);
- assert.deepEqual(requests[1].reasoning, { effort: "medium" });
+ assert.equal(requests[1].max_output_tokens, 5_000);
+ assert.deepEqual(requests[1].reasoning, { effort: "high" });
assert.deepEqual(requests[2].tools, [{ type: "web_search" }]);
- assert.equal(requests[2].max_output_tokens, 6_000);
+ assert.equal(requests[2].max_output_tokens, 10_000);
assert.deepEqual(requests[2].reasoning, { effort: "high" });
assert.equal(draft.answerBlocks[0].text, recoveredText);
assert.ok(draft.sources.some((source) => source.url === "https://recovery.example.edu/finding"));
diff --git a/tests/provider-usage.test.mjs b/tests/provider-usage.test.mjs
index 328198d..b7e73f8 100644
--- a/tests/provider-usage.test.mjs
+++ b/tests/provider-usage.test.mjs
@@ -80,3 +80,37 @@ test("provider usage persists purpose, outcome, dimensions, and safe metadata",
delete env.DB;
}
});
+
+test("releases a reservation when the provider rejects the HTTP request before model work", async () => {
+ const writes = [];
+ env.DB = {
+ prepare(sql) {
+ let values = [];
+ return {
+ bind(...input) { values = input; return this; },
+ async run() {
+ writes.push({ statement: sql, values });
+ return { success: true, meta: { changes: 1 } };
+ },
+ };
+ },
+ };
+ try {
+ await recordOpenAIUsage({
+ identityId: "identity-rejected",
+ modelId: "gpt-5.6-luna",
+ operation: "live_research",
+ purpose: "primary_research",
+ outcome: "http_error",
+ costReservationId: "reservation-rejected",
+ providerRequestId: "req_rejected",
+ httpStatus: 403,
+ latencyMs: 25,
+ });
+ assert.match(writes[0].statement, /status = 'released'/);
+ assert.equal(writes[0].values[1], "reservation-rejected");
+ assert.match(writes[1].statement, /INSERT INTO provider_usage_events/);
+ } finally {
+ delete env.DB;
+ }
+});
diff --git a/tests/routes.test.mjs b/tests/routes.test.mjs
index 4c9a7ff..8c09ad3 100644
--- a/tests/routes.test.mjs
+++ b/tests/routes.test.mjs
@@ -74,3 +74,17 @@ test("the page-flip atlas leaves the final media page readable until the user co
assert.match(source, /activePanel === panelCount - 1 \? "Continue to the knowledge questions"/);
assert.match(source, /knowledgeDeclarationOpen \? setKnowledgeDeclarationOpen\(false\)/);
});
+
+test("retrying a question preserves its lead image on the research loading page", async () => {
+ const source = await readFile(new URL("../app/curiositypedia-experience.tsx", import.meta.url), "utf8");
+ const retryStart = source.indexOf("async function retryActiveQuestion()");
+ const retryEnd = source.indexOf("async function takeOverResearch()", retryStart);
+ const retrySource = source.slice(retryStart, retryEnd);
+
+ assert.ok(retryStart >= 0);
+ assert.ok(retryEnd > retryStart);
+ assert.match(retrySource, /const previewMedia = activeTurn\.media\[0\]/);
+ assert.match(retrySource, /preview: previewMedia \? \{/);
+ assert.match(retrySource, /imageUrl: previewMedia\.imageUrl/);
+ assert.match(retrySource, /sourceUrl: previewMedia\.sourcePageUrl/);
+});
diff --git a/tests/usage-summary.test.mjs b/tests/usage-summary.test.mjs
index cf5a5b7..247eddb 100644
--- a/tests/usage-summary.test.mjs
+++ b/tests/usage-summary.test.mjs
@@ -38,7 +38,7 @@ test("models the current policy for 50 guest and ChatGPT users without a paid-us
assert.deepEqual(policy.allowedModelIds, liveModelIds);
assert.ok(liveModelIds.every((modelId) => isModelAllowed(user.mode, modelId)));
assert.equal(policy.liveResearchLimit, guest ? 25 : 100);
- assert.equal(policy.identitySpendLimitUsd, guest ? 1 : 5);
+ assert.equal(policy.identitySpendLimitUsd, guest ? 3 : 15);
assert.equal(policy.journeyLimit, guest ? 50 : 25);
assert.equal(liveResearchLimit(user.mode), policy.liveResearchLimit);
assert.equal(identitySpendLimitUsd(user.mode), policy.identitySpendLimitUsd);
@@ -93,7 +93,7 @@ test("maps rolling guest usage to exact slot-return times", () => {
assert.equal(summary.spend.usedUsd, 0.25);
assert.equal(summary.spend.heldUsd, 0.1);
assert.equal(summary.spend.accountedUsd, 0.35);
- assert.equal(summary.spend.remainingUsd, 0.65);
+ assert.equal(summary.spend.remainingUsd, 2.65);
assert.equal(summary.spend.nextReleaseAt, now + 12 * HOUR);
assert.deepEqual(summary.library, { used: 4, limit: 50, remaining: 46 });
});