🤖 refactor: convert providerModelFactory model-creation internals to Effect - #4030
Merged
Merged
Conversation
Member
Author
|
@codex review |
This comment has been minimized.
This comment has been minimized.
Member
Author
|
@codex security review |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This was referenced Aug 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 2c — the final Phase 2 slice — of the progressive Effect migration: converts the async model-creation internals of
src/node/services/providerModelFactory.tsto Effect. ThecreateModel/resolveAndCreateModelpipelines are nowEffect.genprograms composing viayield*, behind thinEffect.runPromisePromise facades. Public API and observable behavior are preserved exactly;providerModelFactory.test.tspasses unchanged (128/128).Background
Follows the house pattern proven in #4022 (spike), #4025 (memory), #4027 (retryManager + muxGatewayOauthService), and #4028 (providerService): Effect.gen internals, typed failure tags only where callers genuinely branch, Promise facades keeping pre-Effect callers and tests byte-identical.
Implementation
Converted pipelines (3):
_createModelCore→createModelCoreEffect: the ~1,200-line provider-dispatch pipeline is oneEffect.genprogram. Its 13 genuinely async steps (dynamicPROVIDER_REGISTRY.*()/providerDef.import()SDK module loads) becomeyield* Effect.promise(...). The old whole-pipelinetry/catchis a singleEffect.catchDefectfold producing the identical{ type: "unknown", raw: "Failed to create model: ..." }wire error — defects carry the raw thrown value, sogetErrorMessagesees exactly what the old catch block received, for both synchronous throws and rejected imports (probe-verified).createModel→ facade +createModelEffect(core + DevTools middleware wrap).resolveAndCreateModel→ facade +resolveAndCreateModelEffect; itsawait this.createModel(...)becomesyield* self.createModelEffect(...)— the composition win: resolve+create is one fiber with no intermediate Promise hop. The former ~50-line inline result type is extracted toResolveAndCreateModelResult(structurally identical) so facade and Effect method share it.Error taxonomy: zero tags. A full callsite audit (turnRequestBuilder, aiService, workspaceTitle/StatusGenerator, branchSummary, advisor, debug CLI, tests) shows every caller branches on the
Result<_, SendMessageError>wire union (success/error.type), never on thrown error identity. The wire union stays in the success channel, matching #4028's "tags only where callers branch" rule — here that count is zero.Deliberately NOT converted (documented in the class doc so reviewers don't flag the omission):
resolveEffectiveModelString,resolveGatewayModelString,resolveModelRoute,resolveProviderCredentials— all synchronous in this codebase): no async work → fiber overhead without composition win.getValidAuth()token refresh, mux-gateway auto-logout, Copilot billing classification, gateway usage normalization): AI SDK-owned async callbacks executed per network request after model creation — converting them would embed arunPromiseboundary per request with no error-typing win.preloadAISDKProviders: a singlePromise.allof module imports (test setup only).streamManager.ts/ OAuthFlowManager: deferred per phase scope.Security posture unchanged: the defect fold reuses
getErrorMessageverbatim — no new error wrapping that could captureconfigWithCreds, headers, or key material into error strings; no logging added.One TypeScript nuance: generator bodies lose the contextual typing the old
asyncreturn annotations provided, so wire-error literals (Err({ type: "policy_denied", ... })) would widen to{ type: string }. Fixed with a single annotatedpipelineconst increateModelCoreEffect(probe-verified that contextual typing flows throughEffect.geninto generator returns) — no per-site annotations needed.Validation
providerModelFactory.test.ts: 128/128 pass, file untouched.providerService,aiService,agentSession.preStreamError,agentSession.startupAutoRetry,streamManager,coderOauthService,codexOauthService,muxGatewayOauthService— all green.make static-checkgreen (typecheck both configs, prettier, ESLint, docs checks).Effect.catchDefectreceives the raw thrown/rejected value (Error and non-Error), andEffect.promiserejections become defects — confirming exact parity of the fold with the oldtry/catchbefore conversion.Risks
Low-to-moderate: this file constructs every SDK model Xum uses, so a behavioral regression would be broad. Mitigations: the diff is mechanical (whitespace-dominant; ~200 substantive lines), all error routing/branch logic is verbatim, and error-path parity was probe-verified rather than assumed. One intentional nuance: a defect escaping
resolveAndCreateModel's routing section (previously an ordinary rejection) now rejects throughrunPromisewith the original message preserved; no caller inspects rejection identity (they consumeResult), andcreateModel's catch-everything fold is unchanged.Lessons for Phase 3 (background workers/heartbeats/schedulers with Schedule & Scope)
Runtime-owned loops, timers, and resource lifecycles observed during this work — candidates for Effect
Schedule/Scopeownership:codexOauthService.getValidAuth()/coderOauthService.getValidAuth()inside fetch wrappers): today each request re-enters refresh logic with cross-process file locks and "tens of seconds" refresh windows (see the policy-recheck comment in the coder wrapper). A Phase 3 runtime-owned token-refresh worker withSchedulecould own renewal proactively, and the wrappers would only read current credentials.attachLanguageModelCleanup/moveLanguageModelCleanup+webSocketTransport.close: manual cleanup registries riding on model instances (WebSocket transport lifetime) are exactly the shapeScope/acquireRelease is for;branchSummary.tsalready races model creation against a deadline and manually runs cleanup on late arrivals — a scoped resource would make that race safe by construction.wrapFetchWithMuxGatewayAutoLogout: a fire-and-forget config mutation (providerService.setConfig) triggered from inside a fetch wrapper on 401 — an event-triggered side effect that would be better modeled as an interruptible, runtime-owned effect than an unawaited Promise inside a request path.Effect.genreturns widen without context; annotate the receiving const/return position instead of adding per-site generic annotations — TS propagates the context throughEffect.geninto generator return statements.Effect.catchDefectis the exact analogue of a whole-pipelinetry/catcharound mixed sync/async code: no need to threadEffect.try/tryPromisetags through every step when callers only consume a folded wire shape.Generated with
xum• Model:anthropic:claude-fable-5• Thinking:xhigh