Skip to content

Add reasoning effort config - #44

Merged
seoes merged 2 commits into
mainfrom
feat/reasoning-effort
Oct 4, 2026
Merged

seoes merged 2 commits into
mainfrom
feat/reasoning-effort

Conversation

@seoes

@seoes seoes commented Oct 4, 2026

Copy link
Copy Markdown
Owner

close #39

Comment thread packages/db/src/schema.ts Outdated

modelProviderId: integer().references(() => modelProviderTable.id),
modelName: text().notNull().default(""),
reasoningEffort: text(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚫️ [PROBLEM] Reasoning effort is accepted as free text end-to-end; a typo persists silently and breaks every later run for that repository

The column is unconstrained free text (reasoningEffort: text()), unlike the analogous prReviewOnPush enum right above it (packages/db/src/schema.ts:137), and nothing validates the value before it reaches the provider SDKs: the form field is a plain text input whose suggestion list is only advisory (apps/client/src/lib/components/organism/RepositoryForm.svelte:28, submit only trims at :291), the controller parses the request body with type-only casts and no runtime check (apps/api/src/api/repository/repository.controller.ts:24 and :70), and RepositoryService.normalizeUserPrompt (apps/api/src/api/repository/repository.service.ts:397) normalizes only userPrompt. The adapters then cast the stored string straight into the SDK unions (openai.ts:39, anthropic.ts:47), so a typo like "hig" or a provider-incompatible value is stored silently.

Since the agent loop treats provider 400s as retryable (apps/api/src/agent/llm/loop.ts:196), every later trigger for that repository retries 3x with backoff and produces no review or reply, with an error that points nowhere near this setting.

Fix: normalize and validate in RepositoryService.create/update mirroring normalizeUserPrompt — trim, lowercase, empty to null, reject values outside the allowed set — and extend the controller's existing error-prefix branch so this surfaces as a 400 with a clear message. Keep the client suggestion list as the single source of allowed values, and note the lists may differ per provider (the OpenAI and Anthropic params here use different keys, so a value one provider accepts may 400 the other).

const REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;

private normalizeReasoningEffort(value: unknown): string | null {
    const trimmed = typeof value === "string" ? value.trim().toLowerCase() : "";
    if (!trimmed) {
        return null;
    }
    if (!(REASONING_EFFORTS as readonly string[]).includes(trimmed)) {
        throw new Error(`Reasoning effort must be one of: ${REASONING_EFFORTS.join(", ")}`);
    }
    return trimmed;
}

// call alongside data.userPrompt = this.normalizeUserPrompt(...) in create/update:
// cleanData.reasoningEffort = this.normalizeReasoningEffort(cleanData.reasoningEffort);

Comment on lines +34 to +38
const inputClass = twMerge(
"h-10 w-full rounded-lg border border-neutral-200 bg-neutral-50 px-4 text-sm outline-none dark:border-neutral-700 dark:bg-neutral-800",
disabled ? "cursor-not-allowed opacity-60" : "",
className,
)}
type={password ? "password" : "text"}
{onchange}
{name}
{onkeydown} />
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚫️ [PROBLEM] inputClass is a plain const, so reactive changes to disabled/class stop restyling this shared atom

In Svelte 5 runes a plain const referencing props is evaluated once at component init and never recomputes. The previous markup used an inline class={twMerge(...)}, which re-evaluated whenever disabled or the documented class prop changed; the refactor to a plain const drops that reactivity, while the surrounding conditional attributes (placeholder, id, bind) stay reactive — an asymmetry that is hard to debug when it bites.

No current consumer passes reactive disabled/class, so nothing is visibly broken today, but this atom is shared by ~30 forms and the first caller that toggles disabled (e.g. an input disabled while a request is in flight) will silently keep enabled styling. Declaring it with $derived restores parity with the original behavior.

const inputClass = $derived(
    twMerge(
        "h-10 w-full rounded-lg border border-neutral-200 bg-neutral-50 px-4 text-sm outline-none dark:border-neutral-700 dark:bg-neutral-800",
        disabled ? "cursor-not-allowed opacity-60" : "",
        className,
    ),
);

@proval-0e6d07

proval-0e6d07 Bot commented Oct 4, 2026

Copy link
Copy Markdown

Thanks for this change — the reasoning effort knob is wired end-to-end with surprisingly few loose ends, and it was easy to trace from the form field all the way to both provider adapters.

Overview

Summary

This PR adds a per-repository reasoning effort setting: a new nullable repository.reasoning_effort column (migration 0043), threaded through SenderConfig into conditional reasoning_effort / output_config.effort params for the OpenAI and Anthropic senders at all 14 createSender call sites, plus an opt-in suggestion dropdown on the repository form. The main risk is that the value is free text with no validation at any layer, so a typo or browser-unsupported value persists silently and later fails every review/reply run for that repository with a provider 400 that doesn't point at the setting. The second is a latent reactivity regression in the shared InputText atom. Both are small, localized fixes — worth addressing before or right after merge.

Good points

  • 🟢 The three migration artifacts are consistent and correctly chained: 0043's prevId matches the 0042 snapshot id, and the journal entry, SQL, and snapshot all agree, so existing deployments keep migrating safely (packages/db/src/migration/0043_damp_jack_murdock.sql, packages/db/src/migration/meta/0043_snapshot.json:5).
  • 🟢 Both adapters gate the new parameter behind a truthy conditional spread and omit it entirely when unset, so existing repositories keep byte-identical request payloads (apps/api/src/agent/llm/openai.ts:37, apps/api/src/agent/llm/anthropic.ts:44).
  • 🟢 The suggestion dropdown is strictly opt-in: the {:else} branch preserves the previous input markup verbatim, so the ~30 existing InputText consumers keep identical DOM and binding behavior (apps/client/src/lib/components/atom/InputText.svelte:113).
  • 🟢 Every repository fixture and page was updated in lockstep with the widened type — the test fixture (apps/api/src/api/repository/repository.service.test.ts:121), demo fixtures (apps/client/src/lib/demo/fixtures.ts:148), and both form pages — leaving no stale literals behind.

Main Issues

  • ⚫️ Reasoning effort is accepted as free text end-to-end; a typo persists silently and then breaks every later run for that repository — the loop retries provider 400s 3× with backoff and posts nothing (packages/db/src/schema.ts:164; no validation anywhere along apps/api/src/api/repository/repository.service.ts:397, apps/api/src/agent/llm/openai.ts:39, apps/api/src/agent/llm/anthropic.ts:47). See the inline comment for the normalize-and-validate fix.
  • ⚫️ inputClass in the shared InputText atom is a plain const, so reactive disabled/class props stop restyling after mount; nothing breaks today since no consumer passes reactive props, but the first caller that toggles disabled will silently keep enabled styling. One-word $derived fix (apps/client/src/lib/components/atom/InputText.svelte:34). See the inline comment.

Additional Notes

  • SenderConfig.reasoningEffort is typed string | null and narrowed by unchecked casts at the SDK boundary (apps/api/src/agent/llm/factory.ts:11, apps/api/src/agent/llm/openai.ts:39); exporting a shared literal union from the schema/types would turn an invalid stored value into a compile error instead of a runtime 400.
  • The new conditional request params have no adapter coverage (apps/api/src/agent/llm/openai.ts:37-41, apps/api/src/agent/llm/anthropic.ts:44-52); extracting the per-provider param building into a small pure function would let you assert both the set and omit cases in loop.test.ts's directory cheaply.
  • The suggestion branch and legacy branch in InputText duplicate the full attribute set and differ subtly on events: picking a suggestion assigns value programmatically and never fires onchange (apps/client/src/lib/components/atom/InputText.svelte:61 vs :113); no current consumer combines both, but a shared attribute spread would prevent future drift.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Deploying proval-demo with  Cloudflare Pages  Cloudflare Pages

Latest commit: 8e9b326
Status: ✅  Deploy successful!
Preview URL: https://435f87be.proval-demo.pages.dev
Branch Preview URL: https://feat-reasoning-effort.proval-demo.pages.dev

View logs

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying proval with  Cloudflare Pages  Cloudflare Pages

Latest commit: 8e9b326
Status: ✅  Deploy successful!
Preview URL: https://3c3f8dad.proval.pages.dev
Branch Preview URL: https://feat-reasoning-effort.proval.pages.dev

View logs

...(openAiTools.length > 0 ? { tools: openAiTools, tool_choice: "auto" as const } : {}),
...(config.reasoningEffort
? {
reasoning_effort: config.reasoningEffort as OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [CRITICAL] Shared enum includes values OpenAI rejects; the cast hides the mismatch

🚨 [CRITICAL] The new shared reasoningEffortValueList (packages/db/src/schema.ts:101) adds "xhigh" and "max", and validation at normalizeReasoningEffort happily persists them (repository.service.ts:416-421), but OpenAI's reasoning_effort union only accepts none/minimal/low/medium/high. This as cast silently funnels invalid values into the request body, so a user picking "max" in the new Select compiles fine and then every review/reply run for that repository fails with a provider 400 — exactly the failure mode the enum was meant to close.

Could be wrong if you intentionally rely on newer SDK/API versions accepting these values; if so, a comment would help.

Fix: instead of casting, narrow at the boundary — map/whitelist the permitted subset per provider and omit (or throw) for unsupported members, e.g. accept only "minimal"|"low"|"medium"|"high" here.

...(config.reasoningEffort
    ? { reasoning_effort: config.reasoningEffort satisfies OpenAiReasoningEffort }
    : {}),
// with `satisfies`, adding an enum member OpenAI doesn't support becomes a compile error

effort: config.reasoningEffort as NonNullable<
Anthropic.Messages.MessageCreateParams["output_config"]
>["effort"],
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [CRITICAL] Anthropic adapter also casts through an invalid shared-enum value

🚨 [CRITICAL] The same cast pattern applies here: the shared enum's "none", "minimal", "xhigh" and "max" members (packages/db/src/schema.ts:101) are not valid Anthropic output_config.effort values (only low/medium/high are). This cast hides the mismatch from the compiler, so a repository stored with reasoningEffort "none" or "max" plus an Anthropic provider sends an invalid effort value and fails at request time instead of at save time. Add a small per-provider whitelist/switch in the adapter so unsupported members are rejected or ignored explicitly.

Comment thread packages/db/src/schema.ts

modelProviderId: integer().references(() => modelProviderTable.id),
modelName: text().notNull().default(""),
reasoningEffort: text({ enum: reasoningEffortValueList }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚫️ [PROBLEM] Enum column changed without a matching migration

⚫️ [PROBLEM] Making reasoningEffort an enum column changes what drizzle-kit emits for repository.reasoning_effort(CHECK constraint), but no new migration was added in this push. The last migration on record, packages/db/src/migration/0043_damp_jack_murdock.sql, is still a plain ALTER TABLE ... ADD reasoning_effort text;. The checked-in migration set now disagrees with the code schema, so fresh deployments won't have the constraint and the next drizzle-kit generate will surface an unexpected pending diff.

Fix: either generate a migration reflecting the CHECK, or add a comment explaining that the enum is intentionally TS-only (service-level normalizeReasoningEffort already validates writes).

@proval-0e6d07

proval-0e6d07 Bot commented Oct 4, 2026

Copy link
Copy Markdown

Thanks for the quick follow-up — the free-text validation gap from the last round is now closed end-to-end, and the InputText reactivity regression is gone too. This push is easy to reason about; the remaining concerns are all about one shared list being broader than what the providers accept.

Overview

Summary

This push converts the free-text reasoning effort into a single source of truth: reasoningEffortValueList in packages/db/src/schema.ts:102, typed as ReasoningEffort via packages/types/src/database.ts:90, with service-level validation (normalizeReasoningEffort, apps/api/src/api/repository/repository.service.ts:409) wired into both create and update paths, a regression test, and the client form switched from the bespoke suggestion input to the shared Select atom backed by the same list. SenderConfig.reasoningEffort in apps/api/src/agent/llm/factory.ts:12 is narrowed to the enum, and the InputText atom was collapsed to a single plain input, restoring the reactive class expression that the last review flagged (apps/client/src/lib/components/atom/InputText.svelte).

The main residual risk: the shared enum contains values the providers don't accept ("xhigh", "max" for OpenAI; "none", "minimal", "xhigh", "max" for Anthropic), and the adapters' casts hide that from the compiler, so those values pass save-time validation and later fail every run with a provider 400 — the same failure mode the previous round flagged, now at a narrower level.

Good points

  • 🟢 Service-level normalizeReasoningEffort mirrors the existing normalizeUserPrompt pattern — null handling, type check, trim, and validation against the shared list — so invalid values can no longer be persisted even when callers bypass the UI (apps/api/src/api/repository/repository.service.ts:409-424).
  • 🟢 A focused regression test in the custom-instructions describe block asserts the exact thrown message, documenting what the controller should map to a 400 (apps/api/src/api/repository/repository.service.test.ts:318-325).
  • 🟢 The two duplicated <input> branches in InputText were collapsed to one always-plain input with the class expression moved back inline into the markup, halving the component and restoring reactive restyling of disabled/class (apps/client/src/lib/components/atom/InputText.svelte).
  • 🟢 The homegrown suggestion state machine was replaced by the shared generic Select atom, which brings keyboard navigation and aria combobox semantics for free (apps/client/src/lib/components/organism/RepositoryForm.svelte:436-441).

Main Issues

  • 🚨 The shared enum is broader than either provider accepts. reasoningEffortValueList (packages/db/src/schema.ts:101) offers "xhigh" and "max", which normalizeReasoningEffort validates and the login lets users pick via the new Select (apps/client/src/lib/components/organism/RepositoryForm.svelte:104), but OpenAI's reasoning_effort union doesn't include them — the cast at apps/api/src/agent/llm/openai.ts:39 funnels them straight into the request body. Same pattern for Anthropic: "none", "minimal", "xhigh", "max" aren't valid output_config.effort values (apps/api/src/agent/llm/anthropic.ts:50). Result: a save-time-valid selection becomes a runtime 400 that retries 3× and posts nothing. Fix with a satisfies-style per-provider whitelist in each adapter so unsupported members are rejected or ignored explicitly — inline comments on both lines.
  • ⚫️ Narrowing reasoningEffort to an enum column (packages/db/src/schema.ts:166) changes what drizzle-kit emits for the column (CHECK constraint), but no new migration was added — the checked-in set still ends with 0043's plain ALTER TABLE repository ADD reasoning_effort text; (packages/db/src/migration/0043_damp_jack_murdock.sql:1), so fresh environments won't have the constraint and the next generate will surface an unexpected pending diff. Generate a 0044, or comment that the enum is intentionally TS-only.
  • ⚫️ The new Error("Invalid reasoning effort") isn't mapped to a client-facing error: the catch blocks in apps/api/src/api/repository/repository.controller.ts:44-47, 55-59, and 74-78 only return 400 for messages starting with "Custom instructions", so a direct API call with a bad value re-throws and surfaces as a 500. Extending those mappings (or a shared validation-error class) would keep the API contract consistent.

Additional Notes

  • Could be wrong about the OpenAI/Anthropic unions if you're deliberately depending on newer API versions accepting these values; a comment in reasoningEffortValueList would make that explicit.
  • The regression test file was updated in this push (apps/api/src/api/repository/repository.service.test.ts:318), nice to see validation and tests landing together.

@seoes

seoes commented Oct 4, 2026

Copy link
Copy Markdown
Owner Author

@proval-0e6d07 I know that it's too broad options but some of open weight models with OpenAI/Anthropic API supports it. It's on purpose

@proval-0e6d07

proval-0e6d07 Bot commented Oct 4, 2026

Copy link
Copy Markdown

@seoes

Understood — thanks for the explanation. If the intent is to support open-weight models served through OpenAI/Anthropic-compatible APIs (which often accept a wider effort range than the first-party vendors), then the "enum is broader than the provider union" concern is mostly moot, and the as casts in apps/api/src/agent/llm/openai.ts and apps/api/src/agent/llm/anthropic.ts are doing exactly what you want: passing the value through verbatim.

Two small suggestions to make that intent durable, so future reviewers don't re-flag it:

  1. Add a short comment above reasoningEffortValueList in packages/db/src/schema.ts:102 along the lines of: "Deliberately broader than the official OpenAI/Anthropic unions — these values are intended for open-weight models exposed through compatible APIs; unsupported values will be rejected by the provider at run time."
  2. Optionally pair it with a one-line comment at each adapter cast saying the cast is intentional passthrough.

That would resolve the 🚨 item and the "Additional Notes" point in my previous review with documentation only — no code change needed. The two remaining ⚫️ items (missing drizzle migration for the enum column, and the unmapped Invalid reasoning effort error returning a 500 on direct API calls) are still open and independent of this decision.

@seoes
seoes merged commit 82d60ef into main Oct 4, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

New Feature - model parameter configuration

1 participant