Skip to content

Support creating model providers with xAI OAuth and support responses API - #61

Merged
seoes merged 11 commits into
seoes:mainfrom
NriotHrreion:feature/openai-xai-oauth
Oct 14, 2026
Merged

seoes merged 11 commits into
seoes:mainfrom
NriotHrreion:feature/openai-xai-oauth

Conversation

@NriotHrreion

@NriotHrreion NriotHrreion commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

refs #9
close #40

  • Support OpenAI Responses API
  • Integrate xAI OAuth
    Integrate OpenAI OAuth

What changed

OpenAI Responses API

  • Added apps/api/src/agent/llm/openai-responses.ts and registered it as a new LLM sender.
  • Added responses api as an option in frontend ModelForm
image

xAI OAuth

Auth process:

  1. The user clicks "Connect to xAI", then Proval's backend to request a device code from xAI's authentication service.
  2. Proval displays the verification code received from xAI and authorization link, which the user opens to sign in and approve the connection.
  3. Meanwhile, Proval's backend periodically polls xAI's token endpoint using the device code until authorization succeeds, is denied, or expires.
  4. After approval, xAI returns an access token and a refresh token, which Proval encrypts and stores. The saved connection can then be tested by sending a request to xAI's model API using the access token.

Changes:

  • Added authMethod, oauthCredential and oauthStatus columns to model_provider table.
  • Added backend routes and handlers, and apps/api/src/api/model/xai-oauth.service.ts to process xAI OAuth requests.
  • In apps/api/src/api/model/model.service.ts, added createModelProviderSender() to replace createSender(). createModelProviderSender() consumes the provider info read from database, converting the provider info into a valid config and passing the config into createSender(). This can simplify the provider sender creation process, especially when the xAI provider is going to be added.
  • Registered xAI as a new LLM sender, placing it alongside the OpenAI sender, OpenAI Responses sender and Anthropic sender. The xAI sender handles authentication and delegates API requests to the OpenAI Responses sender.
  • Consolidate the verification methods for all three sender types into a single verifyConfig() method. verifyConfig() is for verifying provider configurations not stored in the database, while verifySaved() is for verifying providers read from the database.
  • Added timeoutSecond and maxOutputToken config option to verifyConfig() method.

UI

  • Added a modal to select model provider type, which is shown when the user clicks on "Add Model Provider"
image
  • Added a dedicated page for xAI OAuth:
image image

@NriotHrreion NriotHrreion changed the title Support creating model providers with OpenAI and xAI OAuth Support creating model providers with xAI OAuth and support responses API Oct 8, 2026
@NriotHrreion
NriotHrreion marked this pull request as ready for review October 9, 2026 00:44
Comment thread apps/api/src/agent/llm/openai-responses.ts
public async listModels(modelProviderId: number): Promise<ModelProviderModelListResponse> {
const modelProvider = await this.findById(modelProviderId);

if (modelProvider.authMethod === "xai_oauth") {

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] xAI credential errors from listModels surface as HTTP 500

⚫️ [PROBLEM] This new branch lets getXaiModelList propagate credential errors — the 401/403 Error("xAI denied model discovery...") from apps/api/src/agent/llm/xai.ts:125 and XaiOAuthError from getAccessToken — but the catch in listModelProviderModels maps only the exact message "Model provider not found" to 404 and returns 500 for everything else (apps/api/src/api/model/model.controller.ts:111-114). An expired OAuth token therefore reports HTTP 500 on routine model discovery, creating false server-error alerts even though the error carries an explicit 4xx status that inputError() honors on the other endpoints. Map XaiOAuthError to error.status (and the permission error to 403) in that catch.

Comment thread apps/api/src/webhook/github/github.controller.ts Outdated
Comment thread apps/api/src/agent/llm/xai.ts Outdated
availableModels = [];
modelListError = body.error ?? "Model discovery is unavailable. You can enter a model ID manually.";
}
} catch {

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] Discovery failures still fall through to the generic empty-list message

⚫️ [PROBLEM] The new error surfacing only covers the non-ok HTTP branch above (lines 184-188). This catch still swallows network failures without setting modelListError, and an ok response whose body is {models: [], source: "unavailable"} — exactly what apps/api/src/api/model/model.service.ts:177 returns when xAI discovery fails non-authently — also leaves it empty. Both paths fall through to the generic "No models were returned by this provider." at line 723, misreporting a failed discovery as an empty provider list — the silent behavior this change was meant to fix. Set modelListError in the catch and when source === "unavailable".

@proval-0e6d07

proval-0e6d07 Bot commented Oct 9, 2026

Copy link
Copy Markdown

Thanks for this PR — adding xAI OAuth, the OpenAI Responses sender, and centralizing sender construction is a lot of moving parts, and the care shows (especially around credential handling and the end-to-end OAuth test suite).

a. Summary

This PR introduces xAI OAuth device-flow authentication (service, controller, routes, migration 0046), a new openai_responses sender with full reasoning-output replay across tool calls, a shared createModelProviderSender helper used by all webhook/activity call sites, stricter Zod validation on model provider CRUD, and client UI for OAuth connection status and per-provider reasoning-effort filtering. The design is solid overall — credential handling is centralized and API responses use explicit allowlists so the new oauthCredential column can't leak. The main risks are around error-path consistency: the Responses sender trusts reasoningEffort without validating it against OpenAI's supported set, xAI requests disable SDK retries while the loop only retries 429/400, and several new credential/config failure paths surface as HTTP 500s or generic "empty list" messages instead of their intended statuses.

b. Good points

  • 🟢 toResponse was rewritten as an explicit field allowlist (apps/api/src/api/model/model.service.ts:122), so the new oauthCredential column is excluded by default rather than relying on destructure-and-subtract — future schema growth can't leak secrets through the API.
  • 🟢 createXaiFetch pins requests to https://api.x.ai with a /v1/ path prefix, rejects userinfo, and sets redirect: "error" (apps/api/src/agent/llm/xai.ts:44-47), so the long-lived OAuth bearer token can never be forwarded off-origin or followed across a redirect.
  • 🟢 oauthOwner layers CSRF defenses — Sec-Fetch-Site rejection plus a custom X-Proval-OAuth header, no-store, and an HttpOnly/SameSite=Strict attempt cookie (apps/api/src/api/model/model.controller.ts:21-39) — meaning a cross-site page can neither start nor poll someone else's device-flow attempt.
  • 🟢 The xAI OAuth test suite runs the whole spec in an isolated child process against a temp migration copy (apps/api/src/api/model/xai-oauth.test.ts:8-11), pinning migration safety, the full device flow, refresh concurrency, and credential-injection rejection end-to-end.

c. Main Issues

  • ⚫️ apps/api/src/agent/llm/openai-responses.ts:44 — reasoningEffort is cast into reasoning.effort without validation; xhigh/max (allowed by the shared enum and the repository UI for non-xAI providers) produce an upstream 400 on every step, retried three times before the run fails.
  • ⚫️ apps/api/src/webhook/github/github.controller.ts:192 — createModelProviderSender runs before runWithActivity and can now throw synchronously; the error becomes an HTTP 500 with no activity record, so GitHub retries the delivery with nothing for the operator to inspect (Forgejo already constructs lazily inside the callback).
  • ⚫️ apps/api/src/agent/llm/xai.ts:94 — maxRetries: 0 disables SDK retries for xAI only; combined with the loop retrying just 429/400 (loop.ts:198), a transient 502/503 from api.x.ai aborts the whole review run.
  • ⚫️ apps/client/src/lib/components/organism/RepositoryForm.svelte:189 — the new error surfacing misses the fetch-exception path and the ok-but-source: "unavailable" response, both of which still render the generic "No models were returned" message.
  • ⚫️ apps/api/src/api/model/model.service.ts:170 — credential errors thrown by the new xAI listModels branch surface as HTTP 500 because the controller catch only maps "Model provider not found" to 404 (model.controller.ts:111-114), producing false server-error alerts when tokens lapse.

d. Additional Notes

  • apps/api/src/agent/llm/xai.ts:98 — the xAI sender inherits getModel() from the wrapped Responses sender, so it reports provider: "openai_responses"; consider overriding it to "xai".
  • apps/api/src/api/model/model.service.ts:201 — verifySaved sends an uncapped probe while verifyConfig uses maxOutputToken: 1, so "Test connection" on a saved provider costs a full generation; passing 1 through would match the pre-save probe.
  • apps/api/src/api/model/model.service.ts:163 — the void …revoke(…).catch(() => {}) swallows all revocation failures silently; log the error so skipped revocations of still-active upstream tokens remain observable.
  • apps/api/src/api/model/model.service.ts:72 — the new Zod create() guards (http/https baseUrl, required apiKey, .strict() key rejection) have no corresponding test coverage in model.test.ts.
  • apps/client/src/lib/components/organism/ModelForm.svelte:450 — aria-live="polite" on the container includes the per-second countdown, so screen readers re-announce the panel every tick; keep the live region on the status message only.
  • apps/client/src/lib/components/organism/RepositoryForm.svelte:109 — the xAI effort allow-list is duplicated at lines 109 and 116 plus apps/api/src/agent/llm/xai.ts:7; hoisting it to a shared constant would keep them in lockstep.
  • apps/api/src/api/activity/activity.service.ts:17 and line 22 — duplicate imports from "../model/model.service.js" can be merged into one.

const client = new OpenAI({
apiKey: "oauth",
baseURL: xaiBaseUrl,
timeout: config.timeoutSecond * 1000,

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] Model-list retries now span up to three timeout windows with no overall deadline

Removing maxRetries: 0 fixes the retry gap, but this client now inherits the SDK default of 2 retries while the only bound is the per-attempt timeout: config.timeoutSecond * 1000 at apps/api/src/agent/llm/xai.ts:112 — and timeoutSecond allows up to 7200 seconds (apps/api/src/api/model/model.service.ts:22). When api.x.ai is degraded (slow 5xx or per-attempt timeouts), client.models.list() at apps/api/src/agent/llm/xai.ts:117 can run up to roughly three full timeout windows plus backoff, so the /model-provider/:id/models request and the discovery UI hang far past the configured timeout — before this push it was a single attempt.

The sender path already solves this by passing AbortSignal.timeout(config.timeoutSecond * 1000) into each responses.create (apps/api/src/agent/llm/openai-responses.ts:16); mirror that so all retry attempts share one deadline:

const signal = AbortSignal.timeout(config.timeoutSecond * 1000);
const page = await client.models.list({}, { signal });
return page.data.map((model) => ({ id: model.id }));

@proval-0e6d07

proval-0e6d07 Bot commented Oct 9, 2026

Copy link
Copy Markdown

Thanks for the follow-up — this push cleanly closes two of the findings from the last round: sender construction is now lazy inside runWithActivity, and the xAI maxRetries: 0 exemption is gone.

a. Summary

This push moves createModelProviderSender into the runWithActivity callback at all five GitHub sites (apps/api/src/webhook/github/github.controller.ts:208, 278, 359, 410, 487) and all four GitLab sites (apps/api/src/webhook/gitlab/gitlab.controller.ts:195, 296, 368, 462), so config errors land in activity records instead of escaping as HTTP 500s — matching the Forgejo pattern. It also removes maxRetries: 0 from both xAI call sites (apps/api/src/agent/llm/xai.ts:94, 114), letting xAI recover from transient 5xx the same way other providers do. One residual issue: the model-list path now retries without a shared overall deadline.

b. Good points

  • 🟢 All nine GitHub/GitLab call sites now construct the sender inside the runWithActivity callback (github.controller.ts:208, gitlab.controller.ts:195), so synchronous config failures are recorded via activityService.fail (activity.runner.ts:14-18) instead of surfacing as untracked 500s — fully resolving the prior eager-construction finding.
  • 🟢 The sender path stays deadline-bounded after enabling retries: createOpenAiResponsesSender still wraps every responses.create in AbortSignal.timeout(config.timeoutSecond * 1000) (apps/api/src/agent/llm/openai-responses.ts:16), so SDK retries are squeezed into one configured window rather than multiplying agent-step latency.

c. Main Issues

  • ⚫️ apps/api/src/agent/llm/xai.ts:112 — getXaiModelList now inherits the SDK's default 2 retries but has no overall deadline; only the per-attempt timeout bounds it, so a degraded api.x.ai can hold the model-discovery request up to roughly three full timeout windows (timeoutSecond allows up to 7200s) before giving up.

d. Additional Notes

  • No test covers the new xAI retry behavior (a transient 5xx on api.x.ai recovering on retry) in apps/api/src/agent/llm/xai.ts — the original regression could return without failing CI; consider a test where the first model-list request fails with 503 and the second succeeds.

@NriotHrreion

NriotHrreion commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Merge conflicts resolved.

Comment thread apps/api/src/agent/llm/factory.ts Outdated
const limiter = loadLimiter(modelProviderId, concurrency);
const fetch = createLLMFetch();
switch (config.provider) {
if (sdkConfig.provider === "xai") return withConcurrencyLimit(createXaiSender(sdkConfig), limiter);

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] Concurrency queue has no deadline, so timeoutSecond no longer bounds send()

⚫️ [PROBLEM] This PR routes xAI (and openai_responses below) through withConcurrencyLimit, but Limiter.acquire parks callers in a queue with no deadline or abort path (apps/api/src/agent/llm/concurrency.ts:41-49). The comment at factory.ts:31-32 says the SDK deadline owns the timeout — yet that clock only starts once a slot frees, so a request queued behind busy slots can wait far past timeoutSecond (allowed up to 7200s per attempt, multiplied by SDK retries). This is not just the agent loop: the HTTP verify endpoint POST /model-provider/:id/verify (apps/api/src/api/index.ts:55) calls verifySaved → sender.send directly (apps/api/src/api/model/model.service.ts:212) with the shared limiter for that provider id, so a dashboard verification can stall for as long as in-flight activity sends hold the slots. Cancellation has the same gap: the loop checks stopIfCanceled only before calling send (apps/api/src/agent/llm/loop.ts:167), so an activity canceled while still queued will still fire its LLM request — spending tokens — when a slot opens.

Consider bounding the queue wait (e.g. reject if not scheduled within timeoutSecond, or accept an AbortSignal so canceled sends can be dropped while queued) in Limiter.acquire/run.

@proval-0e6d07

proval-0e6d07 Bot commented Oct 13, 2026

Copy link
Copy Markdown

Thanks for the follow-up — this push lands three substantial pieces on top of the earlier xAI/OAuth work: trusted target-branch AGENTS.md guidance, a settle-before-cleanup fix for parallel sub-agents, and a per-provider sender concurrency limit with full config plumbing (DB migration through client form).

a. Summary

Guidance files are discovered and read from the fetched target-head git objects rather than the PR checkout, then formatted under a strict character budget and threaded through all seven agent flows. The review pipeline now waits for every sub-agent to settle before rethrowing, so workspace.clean() can no longer delete the shared checkout out from under running siblings. A new Limiter wraps every provider sender and is configurable per model provider (migration 0046/0047, API validation, ModelForm). The agent loop gained an opt-in endWhenRequiredToolsCalled flag so output-by-tool agents skip the wasteful confirming turn. One design gap in the new concurrency path needs attention: the queue wait is unbounded.

b. Good points

  • 🟢 AGENTS.md is enumerated with git ls-tree against targetHeadSha and read via git cat-file blob (apps/api/src/git-provider/workspace.ts:359), with a stage test proving PR-injected guidance never reaches the system prompt — closes the classic branch-fleets prompt-injection vector.
  • 🟢 Sub-agents run under Promise.allSettled with a rethrow loop (apps/api/src/agent/pull-request/review/index.ts:138), so workspace cleanup can't destroy the checkout while siblings still run, while failure still propagates.
  • 🟢 Early-exit keys off toolCallCount incremented only after tool.execute succeeds (apps/api/src/agent/llm/loop.ts:324), so a required tool that throws returns an error result and the model can retry instead of the run ending silently — with a solid option matrix in apps/api/src/agent/llm/loop.test.ts:227.
  • 🟢 The 0047 table rebuild copies the new concurrency column in its INSERT…SELECT (packages/db/src/migration/0047_xai_oauth.sql:17), and a legacy-DB simulation test asserts pre-existing values survive the rebuild (apps/api/src/api/model/xai-oauth.test.ts:43).

c. Main Issues

  • ⚫️ apps/api/src/agent/llm/factory.ts:53 — withConcurrencyLimit queues sends with no deadline or abort path, so timeoutSecond no longer bounds send() end-to-end; the HTTP verify endpoint can stall behind activity traffic, and canceled activities still fire queued LLM requests. Inline comment above has details.

d. Additional Notes

  • apps/api/src/agent/llm/concurrency.ts:78 — loadLimiter calls setMax on every reuse, so a future caller that passes concurrency: undefined for an existing provider would silently reset the shared limit to unlimited; no current production caller hits this, but a guard or assert would make the contract explicit.
  • apps/api/src/agent/llm/xai.ts — the earlier note still stands that a transient-5xx-recovering-on-retry test for the model list would lock in the maxRetries fix; nothing in this push changed that path.

@seoes

seoes commented Oct 13, 2026

Copy link
Copy Markdown
Owner

Hi @NriotHrreion. It's a big change!

It's quite late to review because I heard that there will be a massive changes from xAi but it turned out to be a new monthly plans so we can use existing xAi's OAuth, API without changed from this branch

I'll test it in my local with my X subscription(I had none so I should purchase one) and merge it

big thanks for you hard work

@seoes

seoes commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

@NriotHrreion

I looked at your changes and run from my local environment and It seems to work well. Great job

I'll merge this branch after adding some additional changes. mostly about structure for code/files and minor UX.

image

and this Grok model feels smarter than I thought btw. I should use this model for this repo

seoes added 2 commits October 14, 2026 13:18
- Keep timeout and concurrency validation in the service zod schemas,
  including xAI OAuth attempt input
- Flatten SenderConfig and SenderSDKConfig, and always build API key
  fetches with createLLMFetch
- Fold XaiConnectionConfig into XaiSenderConfig
- Drop the Sec-Fetch-Site check on xAI OAuth routes(already got
  X-Proval-OAuth)
- Parse the raw xAI OAuth body in the service and keep the parsed value
  as attemptInput
- Refresh xAI tokens from expires_in only, and shorten the early refresh
  window from 120 seconds to 60 seconds
- Check xAI verification URLs by https and host only
- Update the affected tests
Comment thread apps/api/src/agent/llm/xai.ts Outdated
Comment on lines +114 to +115
const original = unwrapConnectionCause(error);
if (original !== error) throw original;

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] Network failures in getXaiModelList now surface as HTTP 500 instead of "unavailable"

⚫️ [PROBLEM] The reworked unwrapConnectionCause (apps/api/src/agent/llm/xai.ts:67) unwraps any OpenAI.APIConnectionError whose cause is an Error, and getXaiModelList rethrows that cause (lines 114-115). The previous unwrapXaiError only unwrapped the private XaiCredentialError wrapper, so ordinary connection failures fell through to return null and the caller rendered { models: [], source: "unavailable" }. Now the OpenAI SDK wraps a plain fetch failure (DNS error, connection refused — the raw TypeError) as APIConnectionError with the TypeError as cause, so original !== error holds and the raw error is thrown out of listModels.

Impact: for xai_oauth providers, GET /model-provider/:id/models now returns HTTP 500 with a raw "fetch failed"-style message whenever api.x.ai is unreachable (listModelProviderModels maps everything except "Model provider not found" to 500, apps/api/src/api/model/model.controller.ts:109-114), instead of the graceful empty-list response the sibling non-xAI branch still produces via its catch-all (apps/api/src/api/model/model.service.ts:191-201). This also extends the earlier thread on this endpoint (4226058907) from credential errors to all connection-level failures.

Fix: only rethrow the unwrapped cause when it is a credential/auth error (e.g. instanceof XaiOAuthError, or the "Refusing to send xAI credentials" guard error); otherwise return null so connection failures keep producing the "unavailable" response. Keeping unwrapConnectionCause as-is for createXaiSender.send is fine.

    } catch (error) {
        const original = unwrapConnectionCause(error);
        if (original !== error && original instanceof XaiOAuthError) throw original;
        if (error instanceof OpenAI.APIError && [401, 403].includes(error.status ?? 0)) {
            throw new Error("xAI denied model discovery. Check the account permission or enter a model ID manually");
        }
        return null;
    }

@proval-0e6d07

proval-0e6d07 Bot commented Oct 14, 2026

Copy link
Copy Markdown

Thanks for the follow-up, @NriotHrreion — the form split (ModelAPIForm/ModelXaiForm), the factory config-type rework, and the Zod consolidation are all in good shape, and the deleted /create/xai-oauth route is cleanly re-pointed. One new regression in the xAI model-list path needs a look.

Overview

a. Summary

This push completes the API-key/xAI form split: ModelForm.svelte is renamed to ModelXaiForm.svelte with all dual-auth branching removed, a new ModelAPIForm.svelte handles key-based providers, and the create flow is re-routed through a chooser page. On the API side, SenderConfig becomes a single discriminated type with getAccessToken as a first-class field, the xAI credential error plumbing is simplified to a generic unwrapConnectionCause, the xAI OAuth service owns its Zod attempt schema (start() parses unknown input), and duplicate controller validation guards were dropped in favor of the service schemas. The expiresAt === null case (tokens without expires_in) now intentionally triggers a refresh on every getAccessToken call, with a test pinning that contract.

b. Good points

  • 🟢 The xAI credential guard in createXaiFetch is retained and tightened (apps/api/src/agent/llm/xai.ts:23-31), and the new tests pin both the lookalike-host and userinfo-bypass rejections (apps/api/src/api/model/xai-oauth.test.ts:449-450) — this is the code that keeps OAuth bearer tokens off foreign hosts.
  • 🟢 The refreshes again when the token response omits expires_in test (apps/api/src/api/model/xai-oauth.test.ts:326) converts the dropped JWT-exp fallback into a documented, verified contract instead of an implicit behavior change.
  • 🟢 The route refactor deletes and re-points atomically: list-page buttons, the dashboard CTA, and the removed modal have no remaining references to the deleted /create/xai-oauth page.
  • 🟢 SenderConfig's discriminated-provider design makes each sender's real contract visible in one type, and the xAI branch in createSender (apps/api/src/agent/llm/factory.ts:47-59) now constructs exactly the config createXaiSender declares.

c. Main Issues

  • ⚫️ apps/api/src/agent/llm/xai.ts:114 — getXaiModelList now rethrows any unwrapped connection cause, so a plain network failure (DNS, connection refused) to api.x.ai reaches listModelProviderModels as a raw error and GET /model-provider/:id/models returns HTTP 500 with a "fetch failed"-style message instead of the graceful { models: [], source: "unavailable" } response the sibling non-xAI branch still produces. Fix by only rethrowing credential/auth causes (XaiOAuthError, the credential-guard error) and returning null otherwise. Inline comment has the suggested patch.

d. Additional Notes

  • apps/api/src/api/model/xai-oauth.service.ts:339 — a credential stored without expires_in now refreshes synchronously on every getAccessToken call (i.e. once per agent-loop send). Refreshes are deduplicated by refreshMap, so it is correctness-safe, but if xAI ever returns opaque tokens, consider a short token TTL cache to avoid an extra auth.x.ai round-trip per send.
  • apps/api/src/api/model/model.service.ts:44 — apiKey: "oauth" / baseURL: modelProvider.baseUrl are placeholders the xai factory path never reads (it pins baseURL to xaiBaseUrl); a comment would prevent future readers from assuming a custom baseUrl takes effect.
  • apps/client/src/lib/components/organism/ModelAPIForm.svelte:188 — testConnection() sends Number(timeoutSecond) unvalidated, so a bad value surfaces the server's raw Zod message instead of the friendly 10–7200 alert used in handleSubmit; reuse that validation.
  • apps/client/src/lib/components/organism/ModelAPIForm.svelte:120 — removeModelProvider(), the timeout/concurrency validation, and the test-result parsing are now duplicated nearly verbatim between ModelAPIForm and ModelXaiForm; a shared helper would prevent drift.

Previously raised and still open, unchanged by this push: the concurrency queue has no deadline so timeoutSecond no longer bounds a queued send() (apps/api/src/agent/llm/factory.ts:44-50, thread 4254158760), and the model-list retry window question in thread 4226566080. The earlier maxRetries: 0 concern (4226058913) is confirmed fixed — createXaiSender no longer passes it.

@seoes

seoes commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

@NriotHrreion

Hi! I did some updates. started with minor changes but it looks big

dashboard

  • Remove Provider select modal and add select page on /model-provider/create
  • Separate ModelForm.svelte to ModelAPIForm.svelte and ModelXaiForm.svelte
image

API

  • Simplified SenderConfig and SenderSDKConfig(kinda look complicated to me)
  • Remove XaiConnectionConfig and let XaiSenderConfig handle
  • Remove doublecheck for timeout and concurrency from controller and service. only service checks schema with zod
  • Remove Sec-Fetch-Site header because there's X-Proval-OAuth already
  • More strict xAi token checking and refreshing
  • tests

Mostly about making structure/workflow more cleaner and more visible. Didn't want to deny your code changes but hope you understand. just wanted to maintain clean codebase to keep the project developed easily over time. Still it's really great PR.

@NriotHrreion

Copy link
Copy Markdown
Contributor Author

@seoes I think the original auth type selector modal is lighter than a page. Could you tell me your thoughts on this change?

@seoes

seoes commented Oct 14, 2026

Copy link
Copy Markdown
Owner

@NriotHrreion

Oh yes. Modals are lighter and require less code. But when I tried to use dashboard, a modal window at that moment didn't feel quite natural (maybe it's just for me).

and Although it creates an additional file but the /model-provider/create route is already empty and so I thought it would be better to separate the functionality and place the relevant components directly on that page(same as ModelAPIForm and ModelXaiForm). and Sveltekit can preloads next page with prefetch so I thought there will be no loading issues

It's not the right answer but we can discuss about it

@seoes

seoes commented Oct 14, 2026

Copy link
Copy Markdown
Owner

@proval-0e6d07

pros and cons for each modal / separate page on this workflow? (selecting model provider auth type)

@NriotHrreion

NriotHrreion commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

@seoes Put the select page in /model-provider/create is good, but I think there is too much space and the page just looks kinda "blank".

Or change the grid view to a list view?

@proval-0e6d07

proval-0e6d07 Bot commented Oct 14, 2026

Copy link
Copy Markdown

@seoes

Good question to kick off a design discussion — here's how I see it for the current /model-provider/create selection step (apps/client/src/routes/model-provider/create/+page.svelte → continues to /create/api-key or /create/xai).

Separate page (current approach)

Pros

  • Deep-linkable / shareable. Each step has a real URL (/create/api-key, /create/xai), so users can bookmark it, refresh mid-flow without losing state, and you can link directly to the xAI flow from elsewhere (e.g. a "Reconnect required" badge on the provider list could link straight to /create/xai).
  • Browser back/forward works naturally. The OAuth device-code flow has waits, polling, and error states; losing a modal's state on accidental navigation is more annoying than losing a page's state, since the page survives back-navigation and you can re-enter the flow from history.
  • Scales better. The selection screen currently has 2 cards, but if more OAuth providers (OpenAI OAuth was explicitly deferred in this PR) or other auth types get added, a page layout handles a growing list more gracefully than a constrained modal.
  • Simpler routing/state. No need to manage modal open/close state or juggle which sub-step component is mounted inside one page; each step is self-contained.
  • Screen real estate. The xAI flow shows a verification code, an authorize link, and polling status. On narrow viewports, a modal tends to cramp multi-element instruction screens.

Cons

  • Extra navigation hop. Select → Continue → form is three taps/screens; a modal with in-place switching could collapse the "Continue" step entirely (click a card and go straight to the form).
  • Loses page context. Navigating away from the provider list means coming back to it afterwards (though SvelteKit goto + list state makes this easy).
  • Feels heavyweight for a two-option choice — the current selection page is mostly empty space around two toggle cards.

Modal approach

Pros

  • Faster interaction. Click "Add Model Provider" → modal appears instantly → pick a card → form. No goto, no layout remount, fewer perceived steps. The current "Continue" button could even be dropped (click card = proceed).
  • Keeps list context. The user stays on the provider list; after creating, closing the modal returns them exactly where they were.
  • Consistent with modal conventions if the rest of the app uses modals for quick creation flows.

Cons

  • Refresh/navigation kills in-flight state. Mid xAI OAuth (device code shown, backend polling), an accidental Esc/refresh/back drops context with harder recovery than a page.
  • Not deep-linkable. "Reconnect required" → xAI OAuth flow requires opening the modal and re-picking each time; you can't link directly to the auth step.
  • Layout constraints. Modals fight long content (scroll, small viewports) and get awkward when the selection list grows past 2–4 options.
  • State management overhead. One page must coordinate the selection state + whichever form (API key vs xAI OAuth) is active, instead of SvelteKit routing doing it for free.

My take

The current separate-page approach is the right fit for the OAuth part specifically: device-code flows have multi-step, long-running state (code display → external authorize → backend polling → verify), and pages survive refresh/back-navigation far better. If the concern is friction, the cheap win is removing the "Continue" button and letting clicking a card navigate directly, which keeps the page-based routing benefits while matching a modal's speed. A modal would be better suited to the short, single-step API-key form if you wanted to optimize that path later.

@seoes

seoes commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

@NriotHrreion

I saw the page again after you said, it really is 🤣

Good point. Maybe it should be go back with your modal design.

consider it after Proval got several auth types

@proval-0e6d07

proval-0e6d07 Bot commented Oct 14, 2026

Copy link
Copy Markdown

Hi @NriotHrreion — another tidy follow-up. This push closes out the last open thread from the previous review and lands the modal rework you and @seoes settled on in the discussion.

Overview

a. Summary

The key behavioral fix is in apps/api/src/agent/llm/xai.ts (lines 116-121): the catch block in getXaiModelList no longer blanket-rethrows the unwrapped cause. Connection-level failures (timeouts, TypeError("fetch failed"), 5xx) now fall through to return null, which renders as a graceful source: "unavailable" model list instead of an HTTP 500 — exactly the intent of thread 4267409727. Only two things are rethrown: errors whose name is "XaiOAuthError" (now set explicitly in the constructor at xai-oauth.service.ts:43) and the "Refusing to send xAI credentials" guard error, so genuine credential problems still surface the reconnect prompt; the pre-existing OpenAI.APIError 401/403 branch is untouched. The 401/403 classification covers precisely the failure paths getAccessToken can throw, so the narrowing is sound.

On the client side, the heavyweight /model-provider/create selection page is gone, replaced by a shared Modal atom on the list page with direct buttons to /model-provider/create/api-key and /create/xai — implementing the design conclusion from the conversation. The deleted route has no remaining references; only the three repointed hrefs survive.

b. Good points

  • 🟢 The list page now reuses the shared Modal atom for the two auth choices instead of a dedicated selection route (apps/client/src/routes/model-provider/+page.svelte, lines 61-90). This follows through on the modal-vs-page discussion while keeping both entry points deep-linkable, and the route removal left no dead links.
  • 🟢 The new regression test locks both halves of the error-classification contract in one offline case (apps/api/src/api/model/xai-oauth.test.ts, line ~455): an injected TypeError("fetch failed") asserts null (unavailable), and a thrown XaiOAuthError asserts the rejection still propagates to the controller. Any future regression in unwrapConnectionCause or the classification fails CI.
  • 🟢 Setting this.name = "XaiOAuthError" explicitly in the constructor (apps/api/src/api/model/xai-oauth.service.ts, line 43) gives the error a stable identity that survives the native-Error-subclass pitfall, letting xai.ts match on a marker without introducing an import cycle back into the LLM layer.

Main Issues

None for this push — the changes both compile cleanly against the surrounding flow and the prior findings they address are resolved.

Additional Notes

  • apps/api/src/agent/llm/xai.ts, lines 116-119: the rethrow decision is now coupled to two string literals across module boundaries — original.name === "XaiOAuthError" depends on the name set at xai-oauth.service.ts:43, and the Refusing to send xAI credentials prefix depends on the exact wording of the guard at xai.ts:27. A future rename or rewording would silently flip failures from rethrow to the empty unavailable list with no compile-time signal (the test guards the name path; the message path is only guarded indirectly). Consider a shared marker constant or type guard in a leaf module both files can import.
  • apps/client/src/routes/+page.svelte, line 112: the dashboard onboarding CTA now links straight to /model-provider/create/api-key, so users entering from the dashboard never see the xAI OAuth option until they reach /model-provider — worth a deliberate consistency decision (e.g. pointing it at the list page's choice) later.
  • Accepted tradeoff, for awareness: a non-XaiOAuthError throw from getAccessToken (e.g. an undecryptable credential after an env-key change) now yields the graceful null/unavailable list instead of the pre-push blanket rethrow — the rare case would look like "no models" rather than an actionable error. No action needed now; trimming this later would only require classifying the decrypt failure.

Overall this looks like a clean close of the round — nothing blocking, and the two noted items are judgment calls rather than defects.

@seoes
seoes merged commit 90cf27f into seoes:main Oct 14, 2026
4 checks passed
@NriotHrreion
NriotHrreion deleted the feature/openai-xai-oauth branch October 14, 2026 07:10
@seoes

seoes commented Oct 14, 2026

Copy link
Copy Markdown
Owner

@NriotHrreion

I merged this branch

version 0.3.0 will deployed with many new features soon!

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.

Support OpenAI Responses API alongside Chat Completions

2 participants