Skip to content

Route deepseek-v4-flash to Fireworks when DeepSeek API is unhealthy - #745

Closed
jahooma wants to merge 6753 commits into
mainfrom
deepseek-fireworks-fallback
Closed

Route deepseek-v4-flash to Fireworks when DeepSeek API is unhealthy#745
jahooma wants to merge 6753 commits into
mainfrom
deepseek-fireworks-fallback

Conversation

@jahooma

@jahooma jahooma commented May 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds Fireworks as a transparent fallback for deepseek-v4-flash (accounts/fireworks/models/deepseek-v4-flash).
  • New passive circuit breaker (deepseek-health.ts): 3 failures / 60s window opens the circuit for 5 min, then the next request probes DeepSeek and resets on success. No background polling — every user request is the probe, so all pods converge naturally.
  • Tighter 60s headersTimeout for the Flash undici agent so dead-API requests fail fast instead of hanging on the existing 30-min default (kept for reasoning models on v4-pro).
  • _post.ts routes to Fireworks when the circuit is open, plus inline pre-stream failover so the first user to hit an outage also gets a Fireworks response instead of an error.
  • Pricing entry in FIREWORKS_PRICING_MAP: 0.14 / 0.03 / 0.28 per M tokens (input / cached / output).

How it works

  1. createDeepSeekRequestTracked wraps the DeepSeek fetch. Network errors, timeouts, 5xx/408/429 → recordDeepSeekFailure(). 2xx → recordDeepSeekSuccess() (clears state).
  2. When recentFailures.length >= 3 within the 60s window, openUntil = now + 5min.
  3. Routing in _post.ts calls shouldBypassDeepSeek(model) and, if true, sets useDeepSeek = false so the existing Fireworks branch picks up the same model id (now in FIREWORKS_MODEL_MAP).
  4. After cooldown expires, the next request retries DeepSeek directly. Success resets; another failure re-opens.

Test plan

  • Unit tests for circuit breaker and outage classifier (11 new tests in deepseek-health.test.ts, all passing).
  • Existing fireworks-deployment and fireworks-health test suites still pass (75 llm-api tests total).
  • bun run typecheck clean for changed files (pre-existing SDK errors unrelated).
  • Manual verification once deployed: confirm deepseek/deepseek-v4-flash calls succeed via DeepSeek when healthy and via Fireworks once the breaker opens (induce by temporarily pointing DEEPSEEK_BASE_URL at a sink, or watch real outage logs).

🤖 Generated with Claude Code

github-actions[bot] and others added 30 commits May 2, 2026 23:25
[email protected] split its package exports into separate `import`
and `require` conditions. In bun --compile binaries, require.resolve from
init-node.ts now returns the build-time absolute path of tree-sitter.cjs,
which doesn't exist on user machines, causing freebuff to crash on
startup with "Cannot find module .../tree-sitter.cjs".

Fix by embedding tree-sitter.wasm into the binary via Bun's
\`import ... with { type: 'file' }\` and stashing the resulting bunfs path
in process.env so all copies of init-node.ts (including the SDK pre-built
bundle's inlined copy) can pick it up.
The pre-init module legitimately needs to set process.env so the embedded
wasm path reaches every copy of init-node.ts (the SDK's pre-built bundle
inlines its own copy).
- cli/src/pre-init/tree-sitter-wasm.ts: silence TS error for the bun-only
  `with { type: 'file' }` import (TS resolves the .wasm via the package's
  exports map and has no loader for binary assets).
- cli/src/__tests__/integration-tmux.test.ts: explicitly clear
  FREEBUFF_MODE from the tmux global env before running. A prior freebuff
  build or `bun run dev:freebuff` in the same tmux server leaves it set,
  which made the help-output test see the freebuff CLI variant (no
  `--agent` flag) instead of codebuff.
- web/jest.config.cjs: fix react/react-dom moduleNameMapper paths — they
  pointed at `web/node_modules/react` but bun hoists react to the
  workspace root.
- web/jest.setup.js: polyfill TextEncoder/TextDecoder, ReadableStream,
  Request/Response/Headers/fetch from Node + undici. JSDOM lacks these
  globals, and undici (loaded transitively via `next/server`) needs them
  at module-load time.
Mirrors the existing input on freebuff-release.yml so prod releases
can be built from a specific commit while still bumping version on
latest main. Used to roll back to a known-good commit when main is
broken.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The previous fix (6f223bf) embedded the wasm path on process.env and
let init-node.ts resolve it via fs.existsSync. That worked on Linux/mac
but kept crashing on Windows: bun --compile reports the embedded asset
as `B:\~BUN\root\tree-sitter.wasm`, and fs.existsSync returns false for
that path even though fs.readFileSync succeeds. resolveTreeSitterWasm
fell through every branch and threw "Internal error: tree-sitter.wasm
not found".

Read the bytes once in pre-init via fs.readFileSync (which works on
Windows bunfs) and stash them on globalThis. init-node.ts now passes
them straight to Parser.init({ wasmBinary }), bypassing locateFile and
filesystem path resolution entirely.

globalThis is the cross-bundle channel: the SDK pre-built bundle inlines
its own copy of init-node.ts, so a module-level variable in this package
isn't visible to the singleton initialized via the SDK. The path-based
fallback is preserved for external SDK consumers that don't pre-load.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Both --version smoke tests passed on Windows even though the binary
crashed for users: commander exits the process synchronously, before the
Parser.init promise has a chance to reject. Three changes to close the
gap:

- cli/scripts/smoke-binary.ts: portable script that spawns the binary,
  lets it run for 5s, kills it, and asserts the captured stdout/stderr
  doesn't contain earlyFatalHandler markers ("Fatal error during
  startup", "Internal error: tree-sitter.wasm not found", unhandled
  rejections, missing modules). Wired into the release-build smoke step
  for every platform and into the freebuff-e2e build smoke step.

- freebuff/e2e/tests/startup.e2e.test.ts: wait for "Pick a model to
  start" to render instead of just non-empty output. The model selector
  only appears once the binary survived module init (Parser.init
  included), the auth/session API call returned, and the React tree
  mounted, so a half-rendered crash splash no longer satisfies the
  assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The previous smoke test and e2e test both checked for known error markers
("Fatal error during startup", etc.). That misses anything we didn't
think to add — novel error messages, silent crashes, hangs, segfaults
that produce no stderr.

Switch both to a positive signal: assert the binary actually rendered a
known boot screen. If something goes wrong we don't anticipate, the
boot text never appears and the test fails with a clear "binary never
reached a known boot screen" diagnostic. Negative pattern matches stay
for clearer error messages on regressions of bugs we've already seen.

- cli/scripts/smoke-binary.ts: gate pass/fail on at least one of N boot
  signals appearing in stdout/stderr (chat surface header, login modal,
  freebuff queue states, freebuff country-block screen, chat input
  prompt). Verified locally: passes on real binaries, fails on a stub
  that hangs without rendering.

- freebuff/e2e/tests/startup.e2e.test.ts: wait for the FREEBUFF ASCII
  logo's F+R crossbar pattern (`█████╗  ██████╔╝`). The logo renders
  for every valid boot state — including the country-block screen that
  GitHub Actions runners hit because their egress is flagged as
  anonymized network — so this assertion survives the geo gate that
  was tripping the previous "Pick a model to start" wait.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Freebuff 0.0.62 still crashed on Windows with the same "Internal error:
tree-sitter.wasm not found" — surfaced this time through the late
renderer-cleanup handler ("Unhandled rejection: error: ...") instead of
the early one, so it appeared *after* the logo had rendered. CI Windows
smoke passed because the rejection fires past the 5s kill timer (after
React mounts and the renderer is up), and even when it does fire, the
boot screen has already matched our positive signal.

Root cause: the previous fix's `fs.readFileSync(treeSitterWasmPath)` of
the bunfs path silently fails on Windows for some user environments,
its catch block falls through, globalThis stays unset, and init-node
then hits the broken path-based fallback. CI Windows happened to pass
fs.readFileSync — user Windows didn't.

Bypass the filesystem entirely: bake the wasm bytes into the JS source
as a base64 string literal that bun --compile bundles into the binary's
text segment. No runtime fs read, no path normalization, no platform
quirks.

- cli/src/pre-init/tree-sitter-wasm-bytes.ts: committed stub with empty
  base64. Dev mode and unit tests see this and fall through to
  code-map's path-based resolution (which works locally because
  node_modules/web-tree-sitter/tree-sitter.wasm exists).
- cli/scripts/build-binary.ts: overwrites the stub with the real bytes
  before `bun build --compile`, restores it after. `process.on('exit',
  restore)` is a backstop so a crash mid-build doesn't leave a multi-MB
  diff in the working tree.
- cli/src/pre-init/tree-sitter-wasm.ts: drop the `with { type: 'file' }`
  + readFileSync path, decode the embedded base64 directly.
- cli/scripts/smoke-binary.ts: bump the run window from 5s to 10s and
  match the late-handler form ("Unhandled rejection:" / "Uncaught
  exception:") in addition to the early one. The 0.0.62 regression
  fired *after* the boot screen rendered, so a positive boot signal
  alone isn't enough — we need to keep watching for fatal markers
  through the full window.

Verified locally: full bun --compile build embeds 205KB of wasm as
274KB of base64, stub is restored after build (and after a simulated
mid-build crash via the exit handler), binary boots cleanly to the
chat surface with no wasm errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
James Grugett and others added 22 commits May 21, 2026 11:31
Adds Fireworks as a transparent fallback for deepseek-v4-flash, gated by
a passive circuit breaker so we only divert when the official DeepSeek
API actually misbehaves.

- New deepseek-health.ts circuit breaker: 3 failures in 60s opens the
  circuit for 5 min; the next request after expiry probes DeepSeek again
  and resets on success. No background polling — every user request is
  itself the probe.
- Tighter 60s headersTimeout for the Flash undici agent so dead-API
  requests fail fast (the existing 30-min default is kept for reasoning
  models on v4-pro).
- handleDeepSeek{Stream,NonStream} now wrap the fetch call so network
  errors, timeouts, and 5xx/408/429 responses feed the breaker; 2xx
  resets it.
- _post.ts routes to Fireworks when the circuit is open and adds inline
  pre-stream failover so the first user to hit an outage also gets a
  Fireworks response instead of an error.
- Adds accounts/fireworks/models/deepseek-v4-flash to the Fireworks
  model + pricing maps.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@codebuff-team

ghost commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

This is a well-structured change — a passive circuit breaker with sensible failure/window/cooldown parameters, tests for the classifier and breaker state transitions, and a reasonable fallback wiring in _post.ts — but every file touched (web/src/app/api/v1/chat/completions/_post.ts, web/src/llm-api/deepseek-health.ts, web/src/llm-api/deepseek.ts, web/src/llm-api/fireworks.ts, and the new test file) lives under web/, which is explicitly out of scope for this mirror per repo policy.

Since this is a member submission, the substance looks portable: the circuit-breaker design (in-process state, no polling, request-as-probe) and the tightened headersTimeout for the Flash agent are both defensible engineering choices. If this needs to land, it should be applied directly to the private source tree rather than merged through the public mirror.

No action needed here other than closing as out-of-scope for this repo; happy to review a version of this diff scoped to non-web/ shared packages if there's a portable piece (e.g., the classifier logic in deepseek-health.ts if it were relocated to a shared package).

@codebuff-team codebuff-team added bot:triaged Classified by the community triage bot pr:out-of-scope Touches paths the public mirror does not accept labels Aug 20, 2026
@victorxheng
victorxheng force-pushed the deepseek-fireworks-fallback branch from 1cfcf33 to e47f537 Compare August 31, 2026 21:01
@github-actions

ghost commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

PR checks failed

A couple of things need fixing before this can be reviewed.
None of them are about the code itself.

This PR touches paths the public mirror does not accept.

  • web/src/app/api/v1/chat/completions/_post.ts
  • web/src/llm-api/__tests__/deepseek-health.test.ts
  • web/src/llm-api/deepseek-health.ts
  • web/src/llm-api/deepseek.ts
  • web/src/llm-api/fireworks.ts

Backend, database, billing and deployment code is not part of
this repository. A change to those paths cannot be merged here
regardless of its quality. See CONTRIBUTING.md for the paths
that are in scope.


Edit the PR and this check re-runs automatically.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:triaged Classified by the community triage bot pr:out-of-scope Touches paths the public mirror does not accept

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants