Skip to content

🤖 refactor: convert providerModelFactory model-creation internals to Effect - #4030

Merged
ThomasK33 merged 1 commit into
mainfrom
effect-phase2c-provider-model-factory
Aug 31, 2026
Merged

🤖 refactor: convert providerModelFactory model-creation internals to Effect#4030
ThomasK33 merged 1 commit into
mainfrom
effect-phase2c-provider-model-factory

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

Summary

Phase 2c — the final Phase 2 slice — of the progressive Effect migration: converts the async model-creation internals of src/node/services/providerModelFactory.ts to Effect. The createModel / resolveAndCreateModel pipelines are now Effect.gen programs composing via yield*, behind thin Effect.runPromise Promise facades. Public API and observable behavior are preserved exactly; providerModelFactory.test.ts passes 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):

  • _createModelCorecreateModelCoreEffect: the ~1,200-line provider-dispatch pipeline is one Effect.gen program. Its 13 genuinely async steps (dynamic PROVIDER_REGISTRY.*() / providerDef.import() SDK module loads) become yield* Effect.promise(...). The old whole-pipeline try/catch is a single Effect.catchDefect fold producing the identical { type: "unknown", raw: "Failed to create model: ..." } wire error — defects carry the raw thrown value, so getErrorMessage sees 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; its await this.createModel(...) becomes yield* self.createModelEffect(...) — the composition win: resolve+create is one fiber with no intermediate Promise hop. The former ~50-line inline result type is extracted to ResolveAndCreateModelResult (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):

  • Sync read/plumbing paths (resolveEffectiveModelString, resolveGatewayModelString, resolveModelRoute, resolveProviderCredentials — all synchronous in this codebase): no async work → fiber overhead without composition win.
  • Per-request fetch wrappers and doStream/doGenerate wrappers (Codex/Coder OAuth 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 a runPromise boundary per request with no error-typing win.
  • preloadAISDKProviders: a single Promise.all of module imports (test setup only).
  • streamManager.ts / OAuthFlowManager: deferred per phase scope.

Security posture unchanged: the defect fold reuses getErrorMessage verbatim — no new error wrapping that could capture configWithCreds, headers, or key material into error strings; no logging added.

One TypeScript nuance: generator bodies lose the contextual typing the old async return annotations provided, so wire-error literals (Err({ type: "policy_denied", ... })) would widen to { type: string }. Fixed with a single annotated pipeline const in createModelCoreEffect (probe-verified that contextual typing flows through Effect.gen into generator returns) — no per-site annotations needed.

Validation

  • providerModelFactory.test.ts: 128/128 pass, file untouched.
  • Targeted suites (464 tests / 8 files): providerService, aiService, agentSession.preStreamError, agentSession.startupAutoRetry, streamManager, coderOauthService, codexOauthService, muxGatewayOauthService — all green.
  • make static-check green (typecheck both configs, prettier, ESLint, docs checks).
  • Runtime probes against effect v4-rc verified: Effect.catchDefect receives the raw thrown/rejected value (Error and non-Error), and Effect.promise rejections become defects — confirming exact parity of the fold with the old try/catch before 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 through runPromise with the original message preserved; no caller inspects rejection identity (they consume Result), and createModel'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/Scope ownership:

  • Per-request OAuth token refresh (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 with Schedule could 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 shape Scope/acquireRelease is for; branchSummary.ts already 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.
  • Contextual-typing lesson (for any future gen conversion): wire-union literals in Effect.gen returns widen without context; annotate the receiving const/return position instead of adding per-site generic annotations — TS propagates the context through Effect.gen into generator return statements.
  • Effect.catchDefect is the exact analogue of a whole-pipeline try/catch around mixed sync/async code: no need to thread Effect.try/tryPromise tags through every step when callers only consume a folded wire shape.

Generated with xum • Model: anthropic:claude-fable-5 • Thinking: xhigh

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

This comment has been minimized.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

This comment has been minimized.

@chatgpt-codex-connector

This comment has been minimized.

@ThomasK33
ThomasK33 added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit 2270652 Aug 31, 2026
36 of 38 checks passed
@ThomasK33
ThomasK33 deleted the effect-phase2c-provider-model-factory branch August 31, 2026 22:14
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.

1 participant