LastNoob commited on
Commit
4294178
·
unverified ·
1 Parent(s): 0602b26

Replace global runtime resources with explicit ownership (#1042)

Browse files

## Problem

Provider, messaging, and transcription resources relied on
process-global state, leaving replacement, cancellation, and shutdown
ownership ambiguous. Separate server lifetimes could share
event-loop-bound resources or retain failed cleanup work.

## Changes

| Before | After |
| --- | --- |
| Provider clients found limiters through global singleton and scoped
registries. | Each provider instance receives and owns one explicitly
constructed limiter. |
| Messaging queues and voice pipelines relied on singleton or
module-global state. | Each platform owns its limiter and outbox, while
the application owns one injected transcriber. |
| Messaging shutdown mixed ingress, active work, delivery, and SDK
cleanup. | Application shutdown quiesces ingress, drains work, closes
delivery, then releases transcription and providers. |
| Cancelled or failed provider cleanup could be forgotten or treated as
complete. | The provider manager retains shielded generation and
unpublished-runtime cleanup until it succeeds. |
| Discord and Telegram startup tasks could outlive or poison runtime
readiness. | Platform runtimes observe long-lived tasks and retry only
independently repeatable lifecycle steps. |
| Constructor-captured security and diagnostic settings appeared
hot-applicable. | Admin marks those settings restart-required so applied
policy matches the running resource graph. |
| Lifecycle races lacked direct ownership coverage. | Deterministic
cancellation, retry, isolation, teardown, and live smoke contracts
protect the final ownership model. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR moves runtime resources from global state into explicitly owned
application objects. The main changes are:

- Provider generations own their rate limiters and cleanup tasks.
- Messaging platforms own their limiter, outbox, ingress, and delivery
lifecycle.
- Application shutdown now runs through ordered cleanup gates.
- Voice transcription is injected as an owned runtime resource.
- Admin config marks constructor-captured settings as restart-required.
</details>

<h3>Confidence Score: 4/5</h3>

The shutdown path needs a bounded cleanup result before merging.

Cleanup steps that hang never reach the retryable incomplete-shutdown
path. ASGI shutdown can remain stuck while waiting for an external SDK,
transcriber, workflow, or provider cleanup. The retry ownership model
works only after cleanup returns or raises.

src/free_claude_code/runtime/application.py

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- T-Rex ran the requested verification, but its local artifact
references were not uploaded.
- The validation run completed successfully with EXIT\_CODE: 0 and 62
tests passed in 3.91 seconds, using the command uv run pytest -vv
tests/runtime/test\_application\_runtime.py
tests/runtime/test\_provider\_manager.py
tests/providers/test\_provider\_runtime.py.

<a
href="https://app.greptile.com/trex/runs/14064214/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| src/free_claude_code/runtime/application.py | Refactors shutdown into
ordered retryable cleanup gates, but cleanup awaitables can still block
shutdown forever. |
| src/free_claude_code/runtime/asgi.py | Reports incomplete runtime
shutdown when `close()` returns false. |
| src/free_claude_code/runtime/provider_manager.py | Adds owned provider
cleanup retry state and shielded generation cleanup. |

</details>

<a
href="https://app.greptile.com/api/ide/codex?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22alishahryar1%2Ffree-claude-code%22%20on%20the%20existing%20branch%20%22refactor%2Fruntime-owned-resources%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22refactor%2Fruntime-owned-resources%22.%0A%0AFix%20the%20following%201%20code%20review%20issue.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%201%0Asrc%2Ffree_claude_code%2Fruntime%2Fapplication.py%3A59%0A**Cleanup%20Await%20Blocks%20Shutdown**%0A%0AWhen%20a%20platform%20SDK%20stop%2C%20workflow%20drain%2C%20transcriber%20close%2C%20or%20provider%20cleanup%20hangs%2C%20this%20helper%20waits%20forever%20and%20never%20returns%20%60False%60.%20ASGI%20shutdown%20stays%20stuck%20in%20%60runtime.close%28%29%60%20instead%20of%20reporting%20an%20incomplete%20shutdown%2C%20so%20the%20retained%20resource%20graph%20cannot%20be%20retried%20cleanly.%0A%0A&repo=alishahryar1%2Ffree-claude-code&pr=1042&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodexDark.svg?v=6"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"><img
alt="Fix All in Codex"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"></picture></a>

<sub>Reviews (2): Last reviewed commit: ["Report incomplete runtime
shutdown to
AS..."](https://github.com/alishahryar1/free-claude-code/commit/338b2bd179c3875b15bbd52818dd04c780e5d46d)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=43454593)</sub>

> Greptile also left **1 inline comment** on this PR.

**Context used:**

- Context used - CLAUDE.md
([source](https://app.greptile.com/alishahryar1/github/Alishahryar1/free-claude-code/-/custom-context?memory=d2fd24d8-0dec-4faf-8ee4-e085e215a2f8))

<!-- /greptile_comment -->

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. ARCHITECTURE.md +60 -12
  2. pyproject.toml +1 -1
  3. smoke/capabilities.py +13 -4
  4. smoke/features.py +3 -0
  5. smoke/lib/e2e.py +4 -1
  6. smoke/prereq/test_voice_prereq_live.py +20 -9
  7. smoke/product/test_voice_product_live.py +23 -15
  8. src/free_claude_code/config/admin/manifest.py +8 -0
  9. src/free_claude_code/config/admin/persistence.py +24 -3
  10. src/free_claude_code/messaging/limiter.py +144 -104
  11. src/free_claude_code/messaging/platforms/discord.py +118 -48
  12. src/free_claude_code/messaging/platforms/discord_io.py +7 -3
  13. src/free_claude_code/messaging/platforms/factory.py +18 -19
  14. src/free_claude_code/messaging/platforms/outbox.py +54 -32
  15. src/free_claude_code/messaging/platforms/ports.py +4 -2
  16. src/free_claude_code/messaging/platforms/telegram.py +93 -63
  17. src/free_claude_code/messaging/platforms/telegram_io.py +7 -3
  18. src/free_claude_code/messaging/platforms/voice_flow.py +30 -21
  19. src/free_claude_code/messaging/transcription.py +100 -137
  20. src/free_claude_code/messaging/voice.py +9 -33
  21. src/free_claude_code/providers/cerebras/client.py +3 -1
  22. src/free_claude_code/providers/cloudflare/client.py +9 -1
  23. src/free_claude_code/providers/codestral/client.py +3 -1
  24. src/free_claude_code/providers/cohere/client.py +3 -1
  25. src/free_claude_code/providers/deepseek/client.py +3 -1
  26. src/free_claude_code/providers/error_mapping.py +6 -9
  27. src/free_claude_code/providers/fireworks/client.py +3 -1
  28. src/free_claude_code/providers/gemini/client.py +3 -1
  29. src/free_claude_code/providers/github_models/client.py +3 -1
  30. src/free_claude_code/providers/groq/client.py +3 -1
  31. src/free_claude_code/providers/huggingface/client.py +3 -1
  32. src/free_claude_code/providers/kimi/client.py +3 -1
  33. src/free_claude_code/providers/llamacpp/client.py +3 -1
  34. src/free_claude_code/providers/lmstudio/client.py +3 -1
  35. src/free_claude_code/providers/minimax/client.py +3 -1
  36. src/free_claude_code/providers/mistral/client.py +3 -1
  37. src/free_claude_code/providers/nvidia_nim/client.py +9 -1
  38. src/free_claude_code/providers/nvidia_nim/voice.py +87 -67
  39. src/free_claude_code/providers/ollama/client.py +3 -1
  40. src/free_claude_code/providers/open_router/client.py +3 -1
  41. src/free_claude_code/providers/opencode/client.py +9 -1
  42. src/free_claude_code/providers/rate_limit.py +13 -87
  43. src/free_claude_code/providers/runtime/cache.py +11 -9
  44. src/free_claude_code/providers/runtime/factory.py +165 -50
  45. src/free_claude_code/providers/sambanova/client.py +3 -1
  46. src/free_claude_code/providers/transports/anthropic_messages/recovery.py +2 -4
  47. src/free_claude_code/providers/transports/anthropic_messages/stream.py +6 -8
  48. src/free_claude_code/providers/transports/anthropic_messages/transport.py +4 -8
  49. src/free_claude_code/providers/transports/openai_chat/stream.py +2 -2
  50. src/free_claude_code/providers/transports/openai_chat/transport.py +5 -9
ARCHITECTURE.md CHANGED
@@ -153,7 +153,8 @@ On final shutdown it best-effort kills registered child processes.
153
 
154
  [runtime/bootstrap.py](src/free_claude_code/runtime/bootstrap.py) is the single production composition function. The CLI
155
  supervisor supplies one settings snapshot and its restart callback; bootstrap
156
- configures logging, constructs the runtime owners, supplies
 
157
  [api/ports.py](src/free_claude_code/api/ports.py) to the pure API factory, and returns the ASGI application.
158
 
159
  [api/app.py](src/free_claude_code/api/app.py) registers routers, HTTP correlation middleware, and exception handlers around
@@ -162,8 +163,17 @@ runtime resources. `app.state.services` is the only runtime state published to
162
  FastAPI.
163
 
164
  [runtime/application.py](src/free_claude_code/runtime/application.py) owns process startup and shutdown, optional messaging,
165
- the managed CLI session manager, Admin pending state, and the injected restart
166
- callback. [runtime/asgi.py](src/free_claude_code/runtime/asgi.py) drives that owner from ASGI lifespan messages and preserves
 
 
 
 
 
 
 
 
 
167
  the concise startup-failure contract.
168
 
169
  [runtime/provider_manager.py](src/free_claude_code/runtime/provider_manager.py) is the only owner that constructs, publishes,
@@ -173,7 +183,11 @@ streaming responses release it from the response iterator's `finally` path on
173
  completion, failure, cancellation, or disconnect. A provider-only Admin Apply
174
  prepares a candidate and commits configuration before publication. New requests
175
  then use the candidate while old streams finish on the retired generation; its
176
- last lease closes it exactly once.
 
 
 
 
177
 
178
  The manager also owns one application-lifetime provider model catalog and its
179
  single best-effort discovery task. The catalog survives provider replacement.
@@ -366,7 +380,14 @@ credential env var, default base URL, settings attribute names, and proxy suppor
366
 
367
  [providers/runtime/](src/free_claude_code/providers/runtime/) owns construction details for one
368
  closable provider generation: factory wiring, provider configuration, lazy
369
- provider instances, and transport cleanup. Application-level generation
 
 
 
 
 
 
 
370
  publication, request leases, model metadata, discovery orchestration, and
371
  configured-model validation belong to `ProviderRuntimeManager` in the runtime
372
  package. This separates a single generation's resources from process-lifetime
@@ -632,13 +653,14 @@ If `MESSAGING_PLATFORM` is `none`, or if the selected platform token is missing,
632
  the messaging bridge is skipped.
633
 
634
  `ApplicationRuntime` privately owns the selected platform runtime, the
635
- `MessagingWorkflow`, and its managed CLI session manager. The workflow owns
 
636
  conversation snapshot restoration and final persistence flush. The API sees only
637
  the `SessionControlPort` used to preserve `/stop` behavior.
638
 
639
  The platform factory returns a `MessagingPlatformComponents` bundle from
640
  [messaging/platforms/ports.py](src/free_claude_code/messaging/platforms/ports.py): a
641
- `MessagingRuntime` for lifecycle and inbound callbacks, an `OutboundMessenger`
642
  for queued sends/edits/deletes, and an optional `VoiceCancellation` port for
643
  reply-scoped `/clear` during voice transcription. Workflow code depends on
644
  these ports, not on Telegram or Discord SDK objects.
@@ -646,7 +668,16 @@ these ports, not on Telegram or Discord SDK objects.
646
  Runtime adapters in
647
  [messaging/platforms/telegram.py](src/free_claude_code/messaging/platforms/telegram.py) and
648
  [messaging/platforms/discord.py](src/free_claude_code/messaging/platforms/discord.py) own SDK client
649
- lifecycle, event subscription, inbound handoff, and voice-note handoff. Inbound
 
 
 
 
 
 
 
 
 
650
  normalization lives in
651
  [messaging/platforms/telegram_inbound.py](src/free_claude_code/messaging/platforms/telegram_inbound.py)
652
  and [messaging/platforms/discord_inbound.py](src/free_claude_code/messaging/platforms/discord_inbound.py).
@@ -654,14 +685,27 @@ Outbound SDK calls live in
654
  [messaging/platforms/telegram_io.py](src/free_claude_code/messaging/platforms/telegram_io.py) and
655
  [messaging/platforms/discord_io.py](src/free_claude_code/messaging/platforms/discord_io.py). Shared
656
  delivery policy lives in [messaging/platforms/outbox.py](src/free_claude_code/messaging/platforms/outbox.py),
657
- which owns queued send/edit/list-based delete, dedup keys, limiter delegation,
658
- and fire-and-forget behavior. Workflow and command code request deletion of
 
 
659
  message ID lists; platform IO decides whether to use native batch deletion
660
  (Telegram) or internal per-message deletion (Discord).
661
  Shared voice-note orchestration lives in
662
  [messaging/platforms/voice_flow.py](src/free_claude_code/messaging/platforms/voice_flow.py), which owns
663
- pending voice registration, temp-file cleanup, transcription, cancellation, error
664
- replies, and the handoff to `IncomingMessage`.
 
 
 
 
 
 
 
 
 
 
 
665
 
666
  [messaging/workflow.py](src/free_claude_code/messaging/workflow.py) contains `MessagingWorkflow`, the
667
  platform-agnostic coordinator. It owns dependencies, callback wiring, stop/clear
@@ -755,6 +799,10 @@ Logging defaults are conservative:
755
  message logging are opt-in.
756
  - Messaging text, transcription previews, CLI diagnostics, and detailed
757
  messaging exception strings are controlled by separate diagnostic flags.
 
 
 
 
758
  - Values under keys that look like API keys, authorization, tokens, or secrets
759
  are redacted by trace helpers where structured traces are emitted.
760
 
 
153
 
154
  [runtime/bootstrap.py](src/free_claude_code/runtime/bootstrap.py) is the single production composition function. The CLI
155
  supervisor supplies one settings snapshot and its restart callback; bootstrap
156
+ configures logging, constructs the runtime owners and the configured voice
157
+ transcriber, supplies
158
  [api/ports.py](src/free_claude_code/api/ports.py) to the pure API factory, and returns the ASGI application.
159
 
160
  [api/app.py](src/free_claude_code/api/app.py) registers routers, HTTP correlation middleware, and exception handlers around
 
163
  FastAPI.
164
 
165
  [runtime/application.py](src/free_claude_code/runtime/application.py) owns process startup and shutdown, optional messaging,
166
+ the selected transcriber, the managed CLI session manager, Admin pending state,
167
+ and the injected restart callback. Shutdown is serialized and ordered: quiesce
168
+ messaging ingress, cancel and drain workflow/CLI work, flush persistence, close
169
+ delivery, close transcription, then close providers. An owner reference is
170
+ released only after its cleanup succeeds; cancellation or failure leaves the
171
+ incomplete graph retryable. Teardown stops at a failed dependency gate rather
172
+ than closing resources that still-live upstream work may need, and the ASGI
173
+ adapter reports that incomplete graph as lifespan shutdown failure. Cleanup is
174
+ completion-driven: generic timeouts do not cancel half-closed external resources;
175
+ the process supervisor owns any force-termination deadline.
176
+ [runtime/asgi.py](src/free_claude_code/runtime/asgi.py) drives that owner from ASGI lifespan messages and preserves
177
  the concise startup-failure contract.
178
 
179
  [runtime/provider_manager.py](src/free_claude_code/runtime/provider_manager.py) is the only owner that constructs, publishes,
 
183
  completion, failure, cancellation, or disconnect. A provider-only Admin Apply
184
  prepares a candidate and commits configuration before publication. New requests
185
  then use the candidate while old streams finish on the retired generation; its
186
+ last lease closes it exactly once. Final shutdown rejects new acquisition and
187
+ replacement, waits every lease, and awaits the same manager-owned cleanup task
188
+ even if the initiating request or lease release is cancelled. Failed generation
189
+ or unpublished-candidate cleanup remains owned and retryable; the manager does
190
+ not become terminal or clear its model catalog until every owned runtime closes.
191
 
192
  The manager also owns one application-lifetime provider model catalog and its
193
  single best-effort discovery task. The catalog survives provider replacement.
 
380
 
381
  [providers/runtime/](src/free_claude_code/providers/runtime/) owns construction details for one
382
  closable provider generation: factory wiring, provider configuration, lazy
383
+ provider instances, provider-owned rate limiters, and transport cleanup. Each
384
+ lazy provider receives a fresh `ProviderRateLimiter`; there is no process
385
+ singleton or second limiter registry. The provider cache already guarantees one
386
+ provider and limiter per provider ID within a generation. Retired generations
387
+ retain their own synchronization state until request leases drain, while new
388
+ generations and separate server instances never reuse it. Hot replacement
389
+ therefore begins with fresh quota state; an old and new generation enforce
390
+ independent budgets while old request leases drain. Application-level generation
391
  publication, request leases, model metadata, discovery orchestration, and
392
  configured-model validation belong to `ProviderRuntimeManager` in the runtime
393
  package. This separates a single generation's resources from process-lifetime
 
653
  the messaging bridge is skipped.
654
 
655
  `ApplicationRuntime` privately owns the selected platform runtime, the
656
+ `MessagingWorkflow`, configured `Transcriber`, and managed CLI session manager.
657
+ The workflow owns
658
  conversation snapshot restoration and final persistence flush. The API sees only
659
  the `SessionControlPort` used to preserve `/stop` behavior.
660
 
661
  The platform factory returns a `MessagingPlatformComponents` bundle from
662
  [messaging/platforms/ports.py](src/free_claude_code/messaging/platforms/ports.py): a
663
+ `MessagingRuntime` with separate `quiesce()` and `close()` phases, an `OutboundMessenger`
664
  for queued sends/edits/deletes, and an optional `VoiceCancellation` port for
665
  reply-scoped `/clear` during voice transcription. Workflow code depends on
666
  these ports, not on Telegram or Discord SDK objects.
 
668
  Runtime adapters in
669
  [messaging/platforms/telegram.py](src/free_claude_code/messaging/platforms/telegram.py) and
670
  [messaging/platforms/discord.py](src/free_claude_code/messaging/platforms/discord.py) own SDK client
671
+ lifecycle, event subscription, inbound handoff, voice-note handoff, and one
672
+ injected `MessagingRateLimiter`. The platform factory creates a fresh limiter
673
+ for the selected runtime. `quiesce()` stops new SDK ingress and drains active
674
+ handlers while delivery remains available; after workflow tasks settle,
675
+ `close()` drains the outbox and limiter. Discord additionally retains, observes,
676
+ and drains its long-lived client task and inbound-handler tasks, so an SDK exit
677
+ after initial readiness immediately withdraws the runtime's connected state.
678
+ Telegram retries initialization and polling as separate repeatable steps; it
679
+ never restarts an already-running SDK application after polling bootstrap fails.
680
+ Separate application runtimes cannot share or stop each other's queue. Inbound
681
  normalization lives in
682
  [messaging/platforms/telegram_inbound.py](src/free_claude_code/messaging/platforms/telegram_inbound.py)
683
  and [messaging/platforms/discord_inbound.py](src/free_claude_code/messaging/platforms/discord_inbound.py).
 
685
  [messaging/platforms/telegram_io.py](src/free_claude_code/messaging/platforms/telegram_io.py) and
686
  [messaging/platforms/discord_io.py](src/free_claude_code/messaging/platforms/discord_io.py). Shared
687
  delivery policy lives in [messaging/platforms/outbox.py](src/free_claude_code/messaging/platforms/outbox.py),
688
+ which requires that limiter directly and owns queued send/edit/list-based delete,
689
+ dedup keys, and retained fire-and-forget tasks. Shutdown cancels and awaits both
690
+ queued limiter work and arbitrary outbox work; there is no optional unthrottled
691
+ fallback, and both owners reject admission once close begins. Workflow and command code request deletion of
692
  message ID lists; platform IO decides whether to use native batch deletion
693
  (Telegram) or internal per-message deletion (Discord).
694
  Shared voice-note orchestration lives in
695
  [messaging/platforms/voice_flow.py](src/free_claude_code/messaging/platforms/voice_flow.py), which owns
696
+ pending voice registration, file-size validation, temp-file cleanup,
697
+ transcription, cancellation, error replies, and the handoff to
698
+ `IncomingMessage`. It depends only on the consumer-owned `Transcriber` protocol
699
+ from [messaging/voice.py](src/free_claude_code/messaging/voice.py). Bootstrap selects either the
700
+ instance-owned local Whisper `TranscriptionService` or the provider-owned
701
+ `NvidiaNimTranscriber`. Messaging no longer imports a provider adapter, and the
702
+ local service retains only one lazy pipeline for its immutable runtime settings;
703
+ caller cancellation waits for thread-backed transcription to actually exit
704
+ before temporary files, pipelines, or credentials are released. The NIM adapter
705
+ closes its per-call authenticated gRPC channel before that worker exits. Changing the
706
+ credential used by an active voice backend through Admin is therefore
707
+ restart-required, while the same provider credential remains hot-replaceable
708
+ when voice does not use it.
709
 
710
  [messaging/workflow.py](src/free_claude_code/messaging/workflow.py) contains `MessagingWorkflow`, the
711
  platform-agnostic coordinator. It owns dependencies, callback wiring, stop/clear
 
799
  message logging are opt-in.
800
  - Messaging text, transcription previews, CLI diagnostics, and detailed
801
  messaging exception strings are controlled by separate diagnostic flags.
802
+ - Process logging, server/managed-CLI authentication, and messaging diagnostics
803
+ are captured by their lifecycle owners at construction. Admin marks those
804
+ settings restart-required so an Apply cannot report success while an existing
805
+ runtime continues using stale security or privacy policy.
806
  - Values under keys that look like API keys, authorization, tokens, or secrets
807
  are redacted by trace helpers where structured traces are emitted.
808
 
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
 
5
  [project]
6
  name = "free-claude-code"
7
- version = "3.4.17"
8
  description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM"
9
  readme = "README.md"
10
  requires-python = ">=3.14.0"
 
4
 
5
  [project]
6
  name = "free-claude-code"
7
+ version = "3.4.18"
8
  description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM"
9
  readme = "README.md"
10
  requires-python = ">=3.14.0"
smoke/capabilities.py CHANGED
@@ -150,9 +150,13 @@ CAPABILITY_CONTRACTS: tuple[CapabilityContract, ...] = (
150
  "provider_proxy_timeout_config",
151
  "free_claude_code.providers.runtime.ProviderRuntime",
152
  "provider proxy, timeout, and rate-limit settings",
153
- "provider client and scoped limiter config",
154
  "provider construction failure",
155
- ("tests/api/test_dependencies.py", "tests/providers/test_provider_runtime.py"),
 
 
 
 
156
  ),
157
  CapabilityContract(
158
  "provider_routing",
@@ -271,7 +275,7 @@ CAPABILITY_CONTRACTS: tuple[CapabilityContract, ...] = (
271
  "provider_runtime",
272
  "rate_limit_and_disconnect",
273
  "smart_rate_limiting",
274
- "free_claude_code.providers.rate_limit.GlobalRateLimiter",
275
  "concurrent provider requests and 429/disconnect failures",
276
  "proactive throttle, retry, cleanup",
277
  "mapped provider error or smoke skip for upstream disconnect",
@@ -357,6 +361,9 @@ CAPABILITY_CONTRACTS: tuple[CapabilityContract, ...] = (
357
  (
358
  "tests/messaging/test_discord_platform.py",
359
  "tests/messaging/test_telegram.py",
 
 
 
360
  ),
361
  ("test_telegram_bot_api_permissions", "test_discord_bot_api_permissions"),
362
  ),
@@ -410,13 +417,15 @@ CAPABILITY_CONTRACTS: tuple[CapabilityContract, ...] = (
410
  "voice",
411
  "voice_transcription",
412
  "voice_notes",
413
- "free_claude_code.messaging.voice.VoiceTranscriptionService",
414
  "Discord/Telegram audio file and voice backend settings",
415
  "transcribed prompt routed to handler",
416
  "missing optional extra or backend error shown to user",
417
  (
418
  "tests/messaging/test_voice_handlers.py",
419
  "tests/messaging/test_transcription.py",
 
 
420
  ),
421
  ("test_voice_transcription_backend_when_explicitly_enabled",),
422
  ),
 
150
  "provider_proxy_timeout_config",
151
  "free_claude_code.providers.runtime.ProviderRuntime",
152
  "provider proxy, timeout, and rate-limit settings",
153
+ "provider client and instance-owned limiter config",
154
  "provider construction failure",
155
+ (
156
+ "tests/api/test_dependencies.py",
157
+ "tests/providers/test_provider_runtime.py",
158
+ "tests/providers/test_provider_rate_limit.py",
159
+ ),
160
  ),
161
  CapabilityContract(
162
  "provider_routing",
 
275
  "provider_runtime",
276
  "rate_limit_and_disconnect",
277
  "smart_rate_limiting",
278
+ "free_claude_code.providers.rate_limit.ProviderRateLimiter",
279
  "concurrent provider requests and 429/disconnect failures",
280
  "proactive throttle, retry, cleanup",
281
  "mapped provider error or smoke skip for upstream disconnect",
 
361
  (
362
  "tests/messaging/test_discord_platform.py",
363
  "tests/messaging/test_telegram.py",
364
+ "tests/messaging/test_limiter.py",
365
+ "tests/messaging/test_platform_outbox.py",
366
+ "tests/runtime/test_application_runtime.py",
367
  ),
368
  ("test_telegram_bot_api_permissions", "test_discord_bot_api_permissions"),
369
  ),
 
417
  "voice",
418
  "voice_transcription",
419
  "voice_notes",
420
+ "free_claude_code.messaging.voice.Transcriber",
421
  "Discord/Telegram audio file and voice backend settings",
422
  "transcribed prompt routed to handler",
423
  "missing optional extra or backend error shown to user",
424
  (
425
  "tests/messaging/test_voice_handlers.py",
426
  "tests/messaging/test_transcription.py",
427
+ "tests/messaging/test_transcription_nim.py",
428
+ "tests/messaging/test_platform_voice_flow.py",
429
  ),
430
  ("test_voice_transcription_backend_when_explicitly_enabled",),
431
  ),
smoke/features.py CHANGED
@@ -214,6 +214,9 @@ FEATURE_INVENTORY: tuple[FeatureCoverage, ...] = (
214
  (
215
  "tests/messaging/test_discord_platform.py",
216
  "tests/messaging/test_telegram.py",
 
 
 
217
  ),
218
  (
219
  "test_telegram_bot_api_permissions",
 
214
  (
215
  "tests/messaging/test_discord_platform.py",
216
  "tests/messaging/test_telegram.py",
217
+ "tests/messaging/test_limiter.py",
218
+ "tests/messaging/test_platform_outbox.py",
219
+ "tests/runtime/test_application_runtime.py",
220
  ),
221
  (
222
  "test_telegram_bot_api_permissions",
smoke/lib/e2e.py CHANGED
@@ -323,7 +323,10 @@ class FakePlatform:
323
  async def start(self) -> None:
324
  return None
325
 
326
- async def stop(self) -> None:
 
 
 
327
  for task in self._tasks:
328
  if not task.done():
329
  task.cancel()
 
323
  async def start(self) -> None:
324
  return None
325
 
326
+ async def quiesce(self) -> None:
327
+ return None
328
+
329
+ async def close(self) -> None:
330
  for task in self._tasks:
331
  if not task.done():
332
  task.cancel()
smoke/prereq/test_voice_prereq_live.py CHANGED
@@ -5,13 +5,16 @@ from pathlib import Path
5
 
6
  import pytest
7
 
8
- from free_claude_code.messaging.transcription import transcribe_audio
 
 
9
  from smoke.lib.config import SmokeConfig
10
 
11
  pytestmark = [pytest.mark.live, pytest.mark.smoke_target("voice")]
12
 
13
 
14
- def test_voice_transcription_backend_when_explicitly_enabled(
 
15
  smoke_config: SmokeConfig, tmp_path: Path
16
  ) -> None:
17
  if not smoke_config.settings.voice_note_enabled:
@@ -21,16 +24,24 @@ def test_voice_transcription_backend_when_explicitly_enabled(
21
 
22
  wav_path = tmp_path / "smoke-tone.wav"
23
  _write_tone_wav(wav_path)
 
 
 
 
 
 
 
 
 
 
 
 
24
  try:
25
- t_kw: dict[str, str] = {
26
- "whisper_model": smoke_config.settings.whisper_model,
27
- "whisper_device": smoke_config.settings.whisper_device,
28
- }
29
- if smoke_config.settings.whisper_device == "nvidia_nim":
30
- t_kw["nvidia_nim_api_key"] = smoke_config.settings.nvidia_nim_api_key
31
- text = transcribe_audio(wav_path, "audio/wav", **t_kw)
32
  except ImportError as exc:
33
  pytest.skip(str(exc))
 
 
34
  assert isinstance(text, str)
35
  assert text.strip()
36
 
 
5
 
6
  import pytest
7
 
8
+ from free_claude_code.messaging.transcription import TranscriptionService
9
+ from free_claude_code.messaging.voice import Transcriber
10
+ from free_claude_code.providers.nvidia_nim.voice import NvidiaNimTranscriber
11
  from smoke.lib.config import SmokeConfig
12
 
13
  pytestmark = [pytest.mark.live, pytest.mark.smoke_target("voice")]
14
 
15
 
16
+ @pytest.mark.asyncio
17
+ async def test_voice_transcription_backend_when_explicitly_enabled(
18
  smoke_config: SmokeConfig, tmp_path: Path
19
  ) -> None:
20
  if not smoke_config.settings.voice_note_enabled:
 
24
 
25
  wav_path = tmp_path / "smoke-tone.wav"
26
  _write_tone_wav(wav_path)
27
+ transcriber: Transcriber
28
+ if smoke_config.settings.whisper_device == "nvidia_nim":
29
+ transcriber = NvidiaNimTranscriber(
30
+ model=smoke_config.settings.whisper_model,
31
+ api_key=smoke_config.settings.nvidia_nim_api_key,
32
+ )
33
+ else:
34
+ transcriber = TranscriptionService(
35
+ model=smoke_config.settings.whisper_model,
36
+ device=smoke_config.settings.whisper_device,
37
+ huggingface_api_key=smoke_config.settings.huggingface_api_key,
38
+ )
39
  try:
40
+ text = await transcriber.transcribe(wav_path)
 
 
 
 
 
 
41
  except ImportError as exc:
42
  pytest.skip(str(exc))
43
+ finally:
44
+ await transcriber.close()
45
  assert isinstance(text, str)
46
  assert text.strip()
47
 
smoke/product/test_voice_product_live.py CHANGED
@@ -3,7 +3,8 @@ from pathlib import Path
3
 
4
  import pytest
5
 
6
- from free_claude_code.messaging.transcription import transcribe_audio
 
7
  from smoke.lib.config import SmokeConfig
8
  from smoke.lib.e2e import VoiceFixtureDriver
9
 
@@ -11,7 +12,10 @@ pytestmark = [pytest.mark.live]
11
 
12
 
13
  @pytest.mark.smoke_target("voice")
14
- def test_voice_local_backend_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None:
 
 
 
15
  if not smoke_config.settings.voice_note_enabled:
16
  pytest.skip("missing_env: VOICE_NOTE_ENABLED is false")
17
  if os.getenv("FCC_SMOKE_RUN_VOICE") != "1":
@@ -21,22 +25,25 @@ def test_voice_local_backend_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> N
21
 
22
  wav_path = tmp_path / "voice-local-product.wav"
23
  VoiceFixtureDriver.write_tone_wav(wav_path)
 
 
 
 
 
24
  try:
25
- text = transcribe_audio(
26
- wav_path,
27
- "audio/wav",
28
- whisper_model=smoke_config.settings.whisper_model,
29
- whisper_device=smoke_config.settings.whisper_device,
30
- )
31
  except ImportError as exc:
32
  pytest.skip(f"missing_env: {exc}")
 
 
33
 
34
  assert isinstance(text, str)
35
  assert text.strip()
36
 
37
 
38
  @pytest.mark.smoke_target("voice")
39
- def test_voice_nim_backend_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None:
 
40
  if not smoke_config.settings.voice_note_enabled:
41
  pytest.skip("missing_env: VOICE_NOTE_ENABLED is false")
42
  if os.getenv("FCC_SMOKE_RUN_VOICE") != "1":
@@ -48,13 +55,14 @@ def test_voice_nim_backend_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> Non
48
 
49
  wav_path = tmp_path / "voice-nim-product.wav"
50
  VoiceFixtureDriver.write_tone_wav(wav_path)
51
- text = transcribe_audio(
52
- wav_path,
53
- "audio/wav",
54
- whisper_model=smoke_config.settings.whisper_model,
55
- whisper_device="nvidia_nim",
56
- nvidia_nim_api_key=smoke_config.settings.nvidia_nim_api_key,
57
  )
 
 
 
 
58
 
59
  assert isinstance(text, str)
60
  assert text.strip()
 
3
 
4
  import pytest
5
 
6
+ from free_claude_code.messaging.transcription import TranscriptionService
7
+ from free_claude_code.providers.nvidia_nim.voice import NvidiaNimTranscriber
8
  from smoke.lib.config import SmokeConfig
9
  from smoke.lib.e2e import VoiceFixtureDriver
10
 
 
12
 
13
 
14
  @pytest.mark.smoke_target("voice")
15
+ @pytest.mark.asyncio
16
+ async def test_voice_local_backend_e2e(
17
+ smoke_config: SmokeConfig, tmp_path: Path
18
+ ) -> None:
19
  if not smoke_config.settings.voice_note_enabled:
20
  pytest.skip("missing_env: VOICE_NOTE_ENABLED is false")
21
  if os.getenv("FCC_SMOKE_RUN_VOICE") != "1":
 
25
 
26
  wav_path = tmp_path / "voice-local-product.wav"
27
  VoiceFixtureDriver.write_tone_wav(wav_path)
28
+ transcriber = TranscriptionService(
29
+ model=smoke_config.settings.whisper_model,
30
+ device=smoke_config.settings.whisper_device,
31
+ huggingface_api_key=smoke_config.settings.huggingface_api_key,
32
+ )
33
  try:
34
+ text = await transcriber.transcribe(wav_path)
 
 
 
 
 
35
  except ImportError as exc:
36
  pytest.skip(f"missing_env: {exc}")
37
+ finally:
38
+ await transcriber.close()
39
 
40
  assert isinstance(text, str)
41
  assert text.strip()
42
 
43
 
44
  @pytest.mark.smoke_target("voice")
45
+ @pytest.mark.asyncio
46
+ async def test_voice_nim_backend_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None:
47
  if not smoke_config.settings.voice_note_enabled:
48
  pytest.skip("missing_env: VOICE_NOTE_ENABLED is false")
49
  if os.getenv("FCC_SMOKE_RUN_VOICE") != "1":
 
55
 
56
  wav_path = tmp_path / "voice-nim-product.wav"
57
  VoiceFixtureDriver.write_tone_wav(wav_path)
58
+ transcriber = NvidiaNimTranscriber(
59
+ model=smoke_config.settings.whisper_model,
60
+ api_key=smoke_config.settings.nvidia_nim_api_key,
 
 
 
61
  )
62
+ try:
63
+ text = await transcriber.transcribe(wav_path)
64
+ finally:
65
+ await transcriber.close()
66
 
67
  assert isinstance(text, str)
68
  assert text.strip()
src/free_claude_code/config/admin/manifest.py CHANGED
@@ -168,6 +168,7 @@ _NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = (
168
  settings_attr="anthropic_auth_token",
169
  default="freecc",
170
  secret=True,
 
171
  description="Protects Claude/API access. It is not admin-page login.",
172
  ),
173
  ConfigFieldSpec(
@@ -424,6 +425,7 @@ _NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = (
424
  settings_attr="debug_platform_edits",
425
  default="false",
426
  advanced=True,
 
427
  ),
428
  ConfigFieldSpec(
429
  "DEBUG_SUBAGENT_STACK",
@@ -433,6 +435,7 @@ _NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = (
433
  settings_attr="debug_subagent_stack",
434
  default="false",
435
  advanced=True,
 
436
  ),
437
  ConfigFieldSpec(
438
  "LOG_RAW_API_PAYLOADS",
@@ -442,6 +445,7 @@ _NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = (
442
  settings_attr="log_raw_api_payloads",
443
  default="false",
444
  advanced=True,
 
445
  ),
446
  ConfigFieldSpec(
447
  "LOG_RAW_SSE_EVENTS",
@@ -460,6 +464,7 @@ _NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = (
460
  settings_attr="log_api_error_tracebacks",
461
  default="false",
462
  advanced=True,
 
463
  ),
464
  ConfigFieldSpec(
465
  "LOG_RAW_MESSAGING_CONTENT",
@@ -469,6 +474,7 @@ _NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = (
469
  settings_attr="log_raw_messaging_content",
470
  default="false",
471
  advanced=True,
 
472
  ),
473
  ConfigFieldSpec(
474
  "LOG_RAW_CLI_DIAGNOSTICS",
@@ -478,6 +484,7 @@ _NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = (
478
  settings_attr="log_raw_cli_diagnostics",
479
  default="false",
480
  advanced=True,
 
481
  ),
482
  ConfigFieldSpec(
483
  "LOG_MESSAGING_ERROR_DETAILS",
@@ -487,6 +494,7 @@ _NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = (
487
  settings_attr="log_messaging_error_details",
488
  default="false",
489
  advanced=True,
 
490
  ),
491
  ConfigFieldSpec(
492
  "FCC_SMOKE_MODEL_NVIDIA_NIM",
 
168
  settings_attr="anthropic_auth_token",
169
  default="freecc",
170
  secret=True,
171
+ restart_required=True,
172
  description="Protects Claude/API access. It is not admin-page login.",
173
  ),
174
  ConfigFieldSpec(
 
425
  settings_attr="debug_platform_edits",
426
  default="false",
427
  advanced=True,
428
+ restart_required=True,
429
  ),
430
  ConfigFieldSpec(
431
  "DEBUG_SUBAGENT_STACK",
 
435
  settings_attr="debug_subagent_stack",
436
  default="false",
437
  advanced=True,
438
+ restart_required=True,
439
  ),
440
  ConfigFieldSpec(
441
  "LOG_RAW_API_PAYLOADS",
 
445
  settings_attr="log_raw_api_payloads",
446
  default="false",
447
  advanced=True,
448
+ restart_required=True,
449
  ),
450
  ConfigFieldSpec(
451
  "LOG_RAW_SSE_EVENTS",
 
464
  settings_attr="log_api_error_tracebacks",
465
  default="false",
466
  advanced=True,
467
+ restart_required=True,
468
  ),
469
  ConfigFieldSpec(
470
  "LOG_RAW_MESSAGING_CONTENT",
 
474
  settings_attr="log_raw_messaging_content",
475
  default="false",
476
  advanced=True,
477
+ restart_required=True,
478
  ),
479
  ConfigFieldSpec(
480
  "LOG_RAW_CLI_DIAGNOSTICS",
 
484
  settings_attr="log_raw_cli_diagnostics",
485
  default="false",
486
  advanced=True,
487
+ restart_required=True,
488
  ),
489
  ConfigFieldSpec(
490
  "LOG_MESSAGING_ERROR_DETAILS",
 
494
  settings_attr="log_messaging_error_details",
495
  default="false",
496
  advanced=True,
497
+ restart_required=True,
498
  ),
499
  ConfigFieldSpec(
500
  "FCC_SMOKE_MODEL_NVIDIA_NIM",
src/free_claude_code/config/admin/persistence.py CHANGED
@@ -106,14 +106,25 @@ def validate_updates(updates: Mapping[str, Any]) -> dict[str, Any]:
106
  return prepare_admin_update(updates).validation_response()
107
 
108
 
109
- def changed_pending_fields(updates: Mapping[str, Any]) -> list[str]:
 
 
 
 
110
  """Return changed fields that require manual runtime action."""
111
 
112
  state = load_value_state()
113
  pending: list[str] = []
114
  for key, value in updates.items():
115
  field = FIELD_BY_KEY.get(key)
116
- if field is None or not (field.restart_required or field.session_sensitive):
 
 
 
 
 
 
 
117
  continue
118
  if normalize_for_env(value) == str(state[key]["value"]):
119
  continue
@@ -121,6 +132,14 @@ def changed_pending_fields(updates: Mapping[str, Any]) -> list[str]:
121
  return pending
122
 
123
 
 
 
 
 
 
 
 
 
124
  def prepare_admin_update(updates: Mapping[str, Any]) -> PreparedAdminUpdate:
125
  """Validate an update and construct its prospective Settings snapshot."""
126
 
@@ -128,7 +147,9 @@ def prepare_admin_update(updates: Mapping[str, Any]) -> PreparedAdminUpdate:
128
  effective_values = effective_values_for_validation(target_values)
129
  settings, errors = settings_from_values(effective_values)
130
  pending_fields = (
131
- tuple(changed_pending_fields(updates)) if settings is not None else ()
 
 
132
  )
133
  return PreparedAdminUpdate(
134
  target_values=target_values,
 
106
  return prepare_admin_update(updates).validation_response()
107
 
108
 
109
+ def changed_pending_fields(
110
+ updates: Mapping[str, Any],
111
+ *,
112
+ settings: Settings,
113
+ ) -> list[str]:
114
  """Return changed fields that require manual runtime action."""
115
 
116
  state = load_value_state()
117
  pending: list[str] = []
118
  for key, value in updates.items():
119
  field = FIELD_BY_KEY.get(key)
120
+ if field is None or is_locked_source(state[key]["source"]):
121
+ continue
122
+ if field.secret and value == MASKED_SECRET:
123
+ continue
124
+ requires_restart = field.restart_required or field.session_sensitive
125
+ if not requires_restart:
126
+ requires_restart = _active_voice_credential(settings) == key
127
+ if not requires_restart:
128
  continue
129
  if normalize_for_env(value) == str(state[key]["value"]):
130
  continue
 
132
  return pending
133
 
134
 
135
+ def _active_voice_credential(settings: Settings) -> str | None:
136
+ if not settings.voice_note_enabled:
137
+ return None
138
+ if settings.whisper_device == "nvidia_nim":
139
+ return "NVIDIA_NIM_API_KEY"
140
+ return "HUGGINGFACE_API_KEY"
141
+
142
+
143
  def prepare_admin_update(updates: Mapping[str, Any]) -> PreparedAdminUpdate:
144
  """Validate an update and construct its prospective Settings snapshot."""
145
 
 
147
  effective_values = effective_values_for_validation(target_values)
148
  settings, errors = settings_from_values(effective_values)
149
  pending_fields = (
150
+ tuple(changed_pending_fields(updates, settings=settings))
151
+ if settings is not None
152
+ else ()
153
  )
154
  return PreparedAdminUpdate(
155
  target_values=target_values,
src/free_claude_code/messaging/limiter.py CHANGED
@@ -1,9 +1,4 @@
1
- """
2
- Global Rate Limiter for Messaging Platforms.
3
-
4
- Centralizes outgoing message requests and ensures compliance with rate limits
5
- using a strict sliding window algorithm and a task queue.
6
- """
7
 
8
  import asyncio
9
  from collections import deque
@@ -12,7 +7,6 @@ from typing import Any
12
 
13
  from loguru import logger
14
 
15
- from free_claude_code.config.settings import get_settings
16
  from free_claude_code.core.rate_limit import (
17
  StrictSlidingWindowLimiter as SlidingWindowLimiter,
18
  )
@@ -22,43 +16,21 @@ from .safe_diagnostics import format_exception_for_log
22
 
23
  class MessagingRateLimiter:
24
  """
25
- A thread-safe global rate limiter for messaging.
26
 
27
  Uses a custom queue with task compaction (deduplication) to ensure
28
  only the latest version of a message update is processed.
29
  """
30
 
31
- _instance: MessagingRateLimiter | None = None
32
- _lock = asyncio.Lock()
33
-
34
- def __new__(cls, *args, **kwargs):
35
- return super().__new__(cls)
36
-
37
- @classmethod
38
- async def get_instance(
39
- cls,
40
  *,
41
- rate_limit: int = 1,
42
- rate_window: float = 1.0,
43
- ) -> MessagingRateLimiter:
44
- """Get the singleton instance of the limiter.
45
-
46
- ``rate_limit`` and ``rate_window`` apply only when the singleton is first
47
- created. Call :meth:`shutdown_instance` before changing parameters.
48
- """
49
- async with cls._lock:
50
- if cls._instance is None:
51
- cls._instance = cls(rate_limit=rate_limit, rate_window=rate_window)
52
- # Start the background worker (tracked for graceful shutdown).
53
- cls._instance._start_worker()
54
- return cls._instance
55
-
56
- def __init__(self, *, rate_limit: int, rate_window: float) -> None:
57
- # Prevent double initialization in singleton
58
- if hasattr(self, "_initialized"):
59
- return
60
-
61
  self.limiter = SlidingWindowLimiter(rate_limit, rate_window)
 
62
  # Custom queue state - using deque for O(1) popleft
63
  self._queue_list: deque[str] = deque() # Deque of dedup_keys in order
64
  self._queue_map: dict[
@@ -66,25 +38,27 @@ class MessagingRateLimiter:
66
  ] = {}
67
  self._condition = asyncio.Condition()
68
  self._shutdown = asyncio.Event()
69
- self._worker_task: asyncio.Task | None = None
70
-
71
- self._initialized = True
 
72
  self._paused_until = 0
73
 
74
  logger.info(
75
  f"MessagingRateLimiter initialized ({rate_limit} req / {rate_window}s with Task Compaction)"
76
  )
77
 
78
- def _start_worker(self) -> None:
79
- """Ensure the worker task exists."""
 
 
80
  if self._worker_task and not self._worker_task.done():
81
  return
82
- # Named task helps debugging shutdown hangs.
83
  self._worker_task = asyncio.create_task(
84
  self._worker(), name="msg-limiter-worker"
85
  )
86
 
87
- async def _worker(self):
88
  """Background worker that processes queued messaging tasks."""
89
  logger.info("MessagingRateLimiter worker started")
90
  while not self._shutdown.is_set():
@@ -99,6 +73,7 @@ class MessagingRateLimiter:
99
 
100
  dedup_key = self._queue_list.popleft()
101
  func, futures = self._queue_map.pop(dedup_key)
 
102
 
103
  # Check for manual pause (FloodWait)
104
  now = asyncio.get_event_loop().time()
@@ -116,6 +91,19 @@ class MessagingRateLimiter:
116
  for f in futures:
117
  if not f.done():
118
  f.set_result(result)
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  except Exception as e:
120
  # Report error to all futures and log it
121
  for f in futures:
@@ -148,17 +136,26 @@ class MessagingRateLimiter:
148
  asyncio.get_event_loop().time() + wait_secs
149
  )
150
  else:
151
- d = get_settings().log_messaging_error_details
152
  logger.error(
153
  "Error in limiter worker for key {}: {}",
154
  dedup_key,
155
- format_exception_for_log(e, log_full_message=d),
 
 
 
156
  )
 
 
157
  except asyncio.CancelledError:
158
- break
 
 
 
 
 
 
159
  except Exception as e:
160
- d = get_settings().log_messaging_error_details
161
- if d:
162
  logger.error(
163
  "MessagingRateLimiter worker critical error: {}",
164
  e,
@@ -171,53 +168,84 @@ class MessagingRateLimiter:
171
  )
172
  await asyncio.sleep(1)
173
 
174
- async def shutdown(self, timeout: float = 2.0) -> None:
175
- """Stop the background worker so process shutdown doesn't hang."""
 
176
  self._shutdown.set()
177
- try:
178
- async with self._condition:
179
- self._condition.notify_all()
180
- except Exception:
181
- # Best-effort: condition may be bound to a closing loop.
182
- pass
183
-
 
 
 
 
 
 
 
 
 
 
 
184
  task = self._worker_task
185
- if not task or task.done():
186
- self._worker_task = None
187
- return
188
-
189
- task.cancel()
190
- try:
191
- await asyncio.wait_for(task, timeout=timeout)
192
- except TimeoutError:
193
- logger.warning("MessagingRateLimiter worker did not stop before timeout")
194
- except asyncio.CancelledError:
195
- pass
196
- except Exception as e:
197
- d = get_settings().log_messaging_error_details
198
- logger.debug(
199
- "MessagingRateLimiter worker shutdown error: {}",
200
- format_exception_for_log(e, log_full_message=d),
201
- )
202
- finally:
203
  self._worker_task = None
204
 
205
- @classmethod
206
- async def shutdown_instance(cls, timeout: float = 2.0) -> None:
207
- """Shutdown and clear the singleton instance (safe to call multiple times)."""
208
- inst = cls._instance
209
- if not inst:
210
- return
211
- try:
212
- await inst.shutdown(timeout=timeout)
213
- finally:
214
- cls._instance = None
 
215
 
216
- async def _enqueue_internal(self, func, future, dedup_key, front=False):
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  await self._enqueue_internal_multi(func, [future], dedup_key, front)
218
 
219
- async def _enqueue_internal_multi(self, func, futures, dedup_key, front=False):
 
 
 
 
 
 
220
  async with self._condition:
 
 
 
 
221
  if dedup_key in self._queue_map:
222
  # Compaction: Update existing task with new func, append new futures
223
  _old_func, old_futures = self._queue_map[dedup_key]
@@ -241,28 +269,33 @@ class MessagingRateLimiter:
241
  Enqueue a messaging task and return its future result.
242
  If dedup_key is provided, subsequent tasks with the same key will replace this one.
243
  """
 
244
  if dedup_key is None:
245
  # Unique key to avoid deduplication
246
- dedup_key = f"task_{id(func)}_{asyncio.get_event_loop().time()}"
247
 
248
- future = asyncio.get_event_loop().create_future()
249
- await self._enqueue_internal(func, future, dedup_key)
 
 
 
 
250
  return await future
251
 
252
  def fire_and_forget(
253
  self, func: Callable[[], Awaitable[Any]], dedup_key: str | None = None
254
- ):
255
  """Enqueue a task without waiting for the result."""
 
256
  if dedup_key is None:
257
- dedup_key = f"task_{id(func)}_{asyncio.get_event_loop().time()}"
258
 
259
- future = asyncio.get_event_loop().create_future()
260
-
261
- async def _wrapped():
262
  max_retries = 2
263
  for attempt in range(max_retries + 1):
264
  try:
265
- return await self.enqueue(func, dedup_key)
 
266
  except Exception as e:
267
  error_msg = str(e).lower()
268
  # Only retry transient connectivity issues that might have slipped through
@@ -271,8 +304,7 @@ class MessagingRateLimiter:
271
  x in error_msg for x in ["connect", "timeout", "broken"]
272
  ):
273
  wait = 2**attempt
274
- d = get_settings().log_messaging_error_details
275
- if d:
276
  logger.warning(
277
  "Limiter fire_and_forget transient error (attempt {}): {}. Retrying in {}s...",
278
  attempt + 1,
@@ -289,14 +321,22 @@ class MessagingRateLimiter:
289
  await asyncio.sleep(wait)
290
  continue
291
 
292
- d = get_settings().log_messaging_error_details
293
  logger.error(
294
  "Final error in fire_and_forget for key {}: {}",
295
  dedup_key,
296
- format_exception_for_log(e, log_full_message=d),
 
 
 
297
  )
298
- if not future.done():
299
- future.set_exception(e)
300
  break
301
 
302
- _ = asyncio.create_task(_wrapped())
 
 
 
 
 
 
 
 
 
1
+ """Runtime-owned queued delivery for one messaging platform."""
 
 
 
 
 
2
 
3
  import asyncio
4
  from collections import deque
 
7
 
8
  from loguru import logger
9
 
 
10
  from free_claude_code.core.rate_limit import (
11
  StrictSlidingWindowLimiter as SlidingWindowLimiter,
12
  )
 
16
 
17
  class MessagingRateLimiter:
18
  """
19
+ Rate limiter and compacting work queue for one messaging runtime.
20
 
21
  Uses a custom queue with task compaction (deduplication) to ensure
22
  only the latest version of a message update is processed.
23
  """
24
 
25
+ def __init__(
26
+ self,
 
 
 
 
 
 
 
27
  *,
28
+ rate_limit: int,
29
+ rate_window: float,
30
+ log_error_details: bool = False,
31
+ ) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  self.limiter = SlidingWindowLimiter(rate_limit, rate_window)
33
+ self._log_error_details = log_error_details
34
  # Custom queue state - using deque for O(1) popleft
35
  self._queue_list: deque[str] = deque() # Deque of dedup_keys in order
36
  self._queue_map: dict[
 
38
  ] = {}
39
  self._condition = asyncio.Condition()
40
  self._shutdown = asyncio.Event()
41
+ self._worker_task: asyncio.Task[None] | None = None
42
+ self._background_tasks: set[asyncio.Task[None]] = set()
43
+ self._active_futures: list[asyncio.Future[Any]] = []
44
+ self._closed = False
45
  self._paused_until = 0
46
 
47
  logger.info(
48
  f"MessagingRateLimiter initialized ({rate_limit} req / {rate_window}s with Task Compaction)"
49
  )
50
 
51
+ def start(self) -> None:
52
+ """Start the owned worker on the current event loop."""
53
+ if self._closed:
54
+ raise RuntimeError("Messaging rate limiter is closed.")
55
  if self._worker_task and not self._worker_task.done():
56
  return
 
57
  self._worker_task = asyncio.create_task(
58
  self._worker(), name="msg-limiter-worker"
59
  )
60
 
61
+ async def _worker(self) -> None:
62
  """Background worker that processes queued messaging tasks."""
63
  logger.info("MessagingRateLimiter worker started")
64
  while not self._shutdown.is_set():
 
73
 
74
  dedup_key = self._queue_list.popleft()
75
  func, futures = self._queue_map.pop(dedup_key)
76
+ self._active_futures = futures
77
 
78
  # Check for manual pause (FloodWait)
79
  now = asyncio.get_event_loop().time()
 
91
  for f in futures:
92
  if not f.done():
93
  f.set_result(result)
94
+ except asyncio.CancelledError:
95
+ for f in futures:
96
+ if not f.done():
97
+ f.cancel()
98
+ worker = asyncio.current_task()
99
+ if self._shutdown.is_set() or (
100
+ worker is not None and worker.cancelling()
101
+ ):
102
+ raise
103
+ logger.debug(
104
+ "Messaging operation cancelled for key {}; worker remains active",
105
+ dedup_key,
106
+ )
107
  except Exception as e:
108
  # Report error to all futures and log it
109
  for f in futures:
 
136
  asyncio.get_event_loop().time() + wait_secs
137
  )
138
  else:
 
139
  logger.error(
140
  "Error in limiter worker for key {}: {}",
141
  dedup_key,
142
+ format_exception_for_log(
143
+ e,
144
+ log_full_message=self._log_error_details,
145
+ ),
146
  )
147
+ finally:
148
+ self._active_futures = []
149
  except asyncio.CancelledError:
150
+ for future in self._active_futures:
151
+ if not future.done():
152
+ future.cancel()
153
+ self._active_futures = []
154
+ if self._shutdown.is_set():
155
+ break
156
+ raise
157
  except Exception as e:
158
+ if self._log_error_details:
 
159
  logger.error(
160
  "MessagingRateLimiter worker critical error: {}",
161
  e,
 
168
  )
169
  await asyncio.sleep(1)
170
 
171
+ async def shutdown(self, timeout: float | None = None) -> None:
172
+ """Cancel queued work and stop every task owned by this limiter."""
173
+ self._closed = True
174
  self._shutdown.set()
175
+ async with self._condition:
176
+ queued_futures = [
177
+ future
178
+ for _func, futures in self._queue_map.values()
179
+ for future in futures
180
+ ]
181
+ self._queue_list.clear()
182
+ self._queue_map.clear()
183
+ for future in queued_futures:
184
+ if not future.done():
185
+ future.cancel()
186
+ for future in self._active_futures:
187
+ if not future.done():
188
+ future.cancel()
189
+ self._condition.notify_all()
190
+
191
+ cancellation: asyncio.CancelledError | None = None
192
+ timeout_error: TimeoutError | None = None
193
  task = self._worker_task
194
+ if task and not task.done():
195
+ task.cancel()
196
+ try:
197
+ drain = asyncio.gather(task, return_exceptions=True)
198
+ if timeout is None:
199
+ await drain
200
+ else:
201
+ await asyncio.wait_for(drain, timeout=timeout)
202
+ except TimeoutError as exc:
203
+ timeout_error = exc
204
+ except asyncio.CancelledError as exc:
205
+ cancellation = exc
206
+ if task is None or task.done():
 
 
 
 
 
207
  self._worker_task = None
208
 
209
+ background_tasks = tuple(self._background_tasks)
210
+ for background_task in background_tasks:
211
+ background_task.cancel()
212
+ if background_tasks:
213
+ try:
214
+ await asyncio.gather(*background_tasks, return_exceptions=True)
215
+ except asyncio.CancelledError as exc:
216
+ cancellation = exc
217
+ self._background_tasks.difference_update(
218
+ task for task in background_tasks if task.done()
219
+ )
220
 
221
+ if cancellation is not None:
222
+ raise cancellation
223
+ if timeout_error is not None:
224
+ raise TimeoutError(
225
+ "MessagingRateLimiter worker did not stop before timeout"
226
+ ) from timeout_error
227
+
228
+ async def _enqueue_internal(
229
+ self,
230
+ func: Callable[[], Awaitable[Any]],
231
+ future: asyncio.Future[Any],
232
+ dedup_key: str,
233
+ front: bool = False,
234
+ ) -> None:
235
  await self._enqueue_internal_multi(func, [future], dedup_key, front)
236
 
237
+ async def _enqueue_internal_multi(
238
+ self,
239
+ func: Callable[[], Awaitable[Any]],
240
+ futures: list[asyncio.Future[Any]],
241
+ dedup_key: str,
242
+ front: bool = False,
243
+ ) -> None:
244
  async with self._condition:
245
+ if self._closed:
246
+ raise RuntimeError("Messaging rate limiter is closed.")
247
+ if self._worker_task is None or self._worker_task.done():
248
+ raise RuntimeError("Messaging rate limiter has not been started.")
249
  if dedup_key in self._queue_map:
250
  # Compaction: Update existing task with new func, append new futures
251
  _old_func, old_futures = self._queue_map[dedup_key]
 
269
  Enqueue a messaging task and return its future result.
270
  If dedup_key is provided, subsequent tasks with the same key will replace this one.
271
  """
272
+ self._require_running()
273
  if dedup_key is None:
274
  # Unique key to avoid deduplication
275
+ dedup_key = f"task_{id(func)}_{asyncio.get_running_loop().time()}"
276
 
277
+ future = asyncio.get_running_loop().create_future()
278
+ try:
279
+ await self._enqueue_internal(func, future, dedup_key)
280
+ except BaseException:
281
+ future.cancel()
282
+ raise
283
  return await future
284
 
285
  def fire_and_forget(
286
  self, func: Callable[[], Awaitable[Any]], dedup_key: str | None = None
287
+ ) -> None:
288
  """Enqueue a task without waiting for the result."""
289
+ self._require_running()
290
  if dedup_key is None:
291
+ dedup_key = f"task_{id(func)}_{asyncio.get_running_loop().time()}"
292
 
293
+ async def _wrapped() -> None:
 
 
294
  max_retries = 2
295
  for attempt in range(max_retries + 1):
296
  try:
297
+ await self.enqueue(func, dedup_key)
298
+ return
299
  except Exception as e:
300
  error_msg = str(e).lower()
301
  # Only retry transient connectivity issues that might have slipped through
 
304
  x in error_msg for x in ["connect", "timeout", "broken"]
305
  ):
306
  wait = 2**attempt
307
+ if self._log_error_details:
 
308
  logger.warning(
309
  "Limiter fire_and_forget transient error (attempt {}): {}. Retrying in {}s...",
310
  attempt + 1,
 
321
  await asyncio.sleep(wait)
322
  continue
323
 
 
324
  logger.error(
325
  "Final error in fire_and_forget for key {}: {}",
326
  dedup_key,
327
+ format_exception_for_log(
328
+ e,
329
+ log_full_message=self._log_error_details,
330
+ ),
331
  )
 
 
332
  break
333
 
334
+ task = asyncio.create_task(_wrapped(), name=f"msg-limiter:{dedup_key}")
335
+ self._background_tasks.add(task)
336
+ task.add_done_callback(self._background_tasks.discard)
337
+
338
+ def _require_running(self) -> None:
339
+ if self._closed:
340
+ raise RuntimeError("Messaging rate limiter is closed.")
341
+ if self._worker_task is None or self._worker_task.done():
342
+ raise RuntimeError("Messaging rate limiter has not been started.")
src/free_claude_code/messaging/platforms/discord.py CHANGED
@@ -9,8 +9,10 @@ from loguru import logger
9
 
10
  from free_claude_code.core.anthropic import format_user_error_preview
11
 
 
12
  from ..models import IncomingMessage
13
  from ..rendering.discord_markdown import format_status_discord
 
14
  from .discord_inbound import (
15
  discord_text_message_from_event,
16
  discord_voice_request_from_event,
@@ -55,8 +57,7 @@ if DISCORD_AVAILABLE and _discord_module is not None:
55
  self._runtime = runtime
56
 
57
  async def on_ready(self) -> None:
58
- self._runtime._connected = True
59
- logger.info("Discord platform connected")
60
 
61
  async def on_message(self, message: Any) -> None:
62
  await self._runtime._handle_client_message(message)
@@ -74,13 +75,8 @@ class DiscordRuntime:
74
  bot_token: str | None = None,
75
  allowed_channel_ids: str | None = None,
76
  *,
77
- voice_note_enabled: bool = True,
78
- whisper_model: str = "base",
79
- whisper_device: str = "cpu",
80
- huggingface_api_key: str = "",
81
- nvidia_nim_api_key: str = "",
82
- messaging_rate_limit: int = 1,
83
- messaging_rate_window: float = 1.0,
84
  log_raw_messaging_content: bool = False,
85
  log_api_error_tracebacks: bool = False,
86
  ) -> None:
@@ -102,30 +98,45 @@ class DiscordRuntime:
102
  self._client = _DiscordClient(self, intents)
103
  self._message_handler: InboundMessageHandler | None = None
104
  self._connected = False
105
- self._limiter: Any | None = None
106
- self._start_task: asyncio.Task | None = None
 
 
 
107
  self.outbound = DiscordMessenger(
108
  get_client=lambda: self._client,
109
  get_discord=_get_discord,
110
- get_limiter=lambda: self._limiter,
111
  )
112
  self._voice_flow = VoiceNoteFlow(
113
- voice_note_enabled=voice_note_enabled,
114
- whisper_model=whisper_model,
115
- whisper_device=whisper_device,
116
- huggingface_api_key=huggingface_api_key,
117
- nvidia_nim_api_key=nvidia_nim_api_key,
118
  log_raw_messaging_content=log_raw_messaging_content,
119
  log_api_error_tracebacks=log_api_error_tracebacks,
120
  )
121
- self._messaging_rate_limit = messaging_rate_limit
122
- self._messaging_rate_window = messaging_rate_window
123
  self._log_raw_messaging_content = log_raw_messaging_content
124
  self._log_api_error_tracebacks = log_api_error_tracebacks
125
 
126
  async def _handle_client_message(self, message: Any) -> None:
127
  """Adapter entry point used by the internal Discord client."""
128
- await self._on_discord_message(message)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  async def cancel_pending_voice(
131
  self, chat_id: str, reply_id: str
@@ -185,46 +196,105 @@ class DiscordRuntime:
185
  if not self.bot_token:
186
  raise ValueError("DISCORD_BOT_TOKEN is required")
187
 
188
- from ..limiter import MessagingRateLimiter
189
-
190
- self._limiter = await MessagingRateLimiter.get_instance(
191
- rate_limit=self._messaging_rate_limit,
192
- rate_window=self._messaging_rate_window,
193
- )
194
 
195
  self._start_task = asyncio.create_task(
196
  self._client.start(self.bot_token),
197
  name="discord-client-start",
198
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
 
200
- max_wait = 30
201
- waited = 0.0
202
- while not self._connected and waited < max_wait:
203
- await asyncio.sleep(0.5)
204
- waited += 0.5
205
 
206
- if not self._connected:
207
- raise RuntimeError("Discord client failed to connect within timeout")
 
 
 
 
208
 
209
- logger.info("Discord platform started")
 
 
210
 
211
- async def stop(self) -> None:
212
- """Stop Discord SDK resources."""
213
- if self._client.is_closed():
214
- self._connected = False
215
  return
216
 
217
- await self._client.close()
218
- if self._start_task and not self._start_task.done():
219
- try:
220
- await asyncio.wait_for(self._start_task, timeout=5.0)
221
- except TimeoutError, asyncio.CancelledError:
222
- self._start_task.cancel()
223
- with contextlib.suppress(asyncio.CancelledError):
224
- await self._start_task
 
225
 
226
- self._connected = False
227
- logger.info("Discord platform stopped")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
  def on_message(self, handler: Callable[[IncomingMessage], Awaitable[None]]) -> None:
230
  """Register the workflow callback for inbound messages."""
 
9
 
10
  from free_claude_code.core.anthropic import format_user_error_preview
11
 
12
+ from ..limiter import MessagingRateLimiter
13
  from ..models import IncomingMessage
14
  from ..rendering.discord_markdown import format_status_discord
15
+ from ..voice import Transcriber
16
  from .discord_inbound import (
17
  discord_text_message_from_event,
18
  discord_voice_request_from_event,
 
57
  self._runtime = runtime
58
 
59
  async def on_ready(self) -> None:
60
+ self._runtime._mark_connected()
 
61
 
62
  async def on_message(self, message: Any) -> None:
63
  await self._runtime._handle_client_message(message)
 
75
  bot_token: str | None = None,
76
  allowed_channel_ids: str | None = None,
77
  *,
78
+ limiter: MessagingRateLimiter,
79
+ transcriber: Transcriber | None,
 
 
 
 
 
80
  log_raw_messaging_content: bool = False,
81
  log_api_error_tracebacks: bool = False,
82
  ) -> None:
 
98
  self._client = _DiscordClient(self, intents)
99
  self._message_handler: InboundMessageHandler | None = None
100
  self._connected = False
101
+ self._accepting_messages = False
102
+ self._ready = asyncio.Event()
103
+ self._inbound_tasks: set[asyncio.Task[Any]] = set()
104
+ self._limiter = limiter
105
+ self._start_task: asyncio.Task[None] | None = None
106
  self.outbound = DiscordMessenger(
107
  get_client=lambda: self._client,
108
  get_discord=_get_discord,
109
+ limiter=limiter,
110
  )
111
  self._voice_flow = VoiceNoteFlow(
112
+ transcriber=transcriber,
 
 
 
 
113
  log_raw_messaging_content=log_raw_messaging_content,
114
  log_api_error_tracebacks=log_api_error_tracebacks,
115
  )
 
 
116
  self._log_raw_messaging_content = log_raw_messaging_content
117
  self._log_api_error_tracebacks = log_api_error_tracebacks
118
 
119
  async def _handle_client_message(self, message: Any) -> None:
120
  """Adapter entry point used by the internal Discord client."""
121
+ if not self._accepting_messages:
122
+ return
123
+ task = asyncio.current_task()
124
+ if task is not None:
125
+ self._inbound_tasks.add(task)
126
+ try:
127
+ if self._accepting_messages:
128
+ await self._on_discord_message(message)
129
+ finally:
130
+ if task is not None:
131
+ self._inbound_tasks.discard(task)
132
+
133
+ def _mark_connected(self) -> None:
134
+ """Publish Discord readiness while this runtime accepts ingress."""
135
+ if not self._accepting_messages:
136
+ return
137
+ self._connected = True
138
+ self._ready.set()
139
+ logger.info("Discord platform connected")
140
 
141
  async def cancel_pending_voice(
142
  self, chat_id: str, reply_id: str
 
196
  if not self.bot_token:
197
  raise ValueError("DISCORD_BOT_TOKEN is required")
198
 
199
+ self._limiter.start()
200
+ self._accepting_messages = True
201
+ self._ready.clear()
 
 
 
202
 
203
  self._start_task = asyncio.create_task(
204
  self._client.start(self.bot_token),
205
  name="discord-client-start",
206
  )
207
+ self._start_task.add_done_callback(self._observe_client_exit)
208
+ ready_task = asyncio.create_task(
209
+ self._ready.wait(),
210
+ name="discord-client-ready",
211
+ )
212
+ try:
213
+ done, _pending = await asyncio.wait(
214
+ (self._start_task, ready_task),
215
+ timeout=30.0,
216
+ return_when=asyncio.FIRST_COMPLETED,
217
+ )
218
+ if not done:
219
+ raise RuntimeError("Discord client failed to connect within timeout")
220
+ if self._start_task in done:
221
+ await self._start_task
222
+ raise RuntimeError("Discord client stopped before becoming ready")
223
+ if self._start_task.done():
224
+ await self._start_task
225
+ raise RuntimeError("Discord client stopped unexpectedly")
226
+ finally:
227
+ ready_task.cancel()
228
+ await asyncio.gather(ready_task, return_exceptions=True)
229
 
230
+ logger.info("Discord platform started")
 
 
 
 
231
 
232
+ def _observe_client_exit(self, task: asyncio.Task[None]) -> None:
233
+ """Observe the long-lived Discord client task and publish lost readiness."""
234
+ if task.cancelled():
235
+ exception: BaseException | None = None
236
+ else:
237
+ exception = task.exception()
238
 
239
+ was_connected = self._connected
240
+ if not self._accepting_messages:
241
+ return
242
 
243
+ self._connected = False
244
+ self._ready.clear()
245
+ if not was_connected:
 
246
  return
247
 
248
+ if exception is None:
249
+ logger.error("Discord client stopped unexpectedly")
250
+ elif self._log_api_error_tracebacks:
251
+ logger.error("Discord client stopped unexpectedly: {}", exception)
252
+ else:
253
+ logger.error(
254
+ "Discord client stopped unexpectedly: exc_type={}",
255
+ type(exception).__name__,
256
+ )
257
 
258
+ async def quiesce(self) -> None:
259
+ """Stop Discord ingress and drain active SDK handlers."""
260
+ self._accepting_messages = False
261
+ try:
262
+ if not self._client.is_closed():
263
+ await self._client.close()
264
+ finally:
265
+ try:
266
+ await self._drain_start_task()
267
+ finally:
268
+ try:
269
+ await self._drain_inbound_tasks()
270
+ finally:
271
+ self._connected = False
272
+ self._ready.clear()
273
+
274
+ async def close(self) -> None:
275
+ """Close Discord delivery resources after ingress is quiescent."""
276
+ try:
277
+ await self.outbound.close()
278
+ finally:
279
+ await self._limiter.shutdown()
280
+ logger.info("Discord platform closed")
281
+
282
+ async def _drain_start_task(self) -> None:
283
+ task = self._start_task
284
+ if task is None:
285
+ return
286
+ if not task.done():
287
+ task.cancel()
288
+ try:
289
+ await asyncio.gather(task, return_exceptions=True)
290
+ finally:
291
+ if task.done() and self._start_task is task:
292
+ self._start_task = None
293
+
294
+ async def _drain_inbound_tasks(self) -> None:
295
+ tasks = tuple(self._inbound_tasks)
296
+ if tasks:
297
+ await asyncio.gather(*tasks, return_exceptions=True)
298
 
299
  def on_message(self, handler: Callable[[IncomingMessage], Awaitable[None]]) -> None:
300
  """Register the workflow callback for inbound messages."""
src/free_claude_code/messaging/platforms/discord_io.py CHANGED
@@ -3,13 +3,13 @@
3
  from collections.abc import Awaitable, Callable
4
  from typing import Any, cast
5
 
 
6
  from .outbox import PlatformOutbox
7
 
8
  DISCORD_MESSAGE_LIMIT = 2000
9
 
10
  ClientGetter = Callable[[], Any]
11
  DiscordGetter = Callable[[], Any]
12
- LimiterGetter = Callable[[], Any | None]
13
 
14
 
15
  def truncate_discord_message(text: str, limit: int = DISCORD_MESSAGE_LIMIT) -> str:
@@ -27,12 +27,12 @@ class DiscordMessenger:
27
  *,
28
  get_client: ClientGetter,
29
  get_discord: DiscordGetter,
30
- get_limiter: LimiterGetter,
31
  ) -> None:
32
  self._get_client = get_client
33
  self._get_discord = get_discord
34
  self._outbox = PlatformOutbox(
35
- get_limiter=get_limiter,
36
  send=self.send_message,
37
  edit=self.edit_message,
38
  delete_many=self.delete_messages,
@@ -161,3 +161,7 @@ class DiscordMessenger:
161
  def fire_and_forget(self, task: Awaitable[Any]) -> None:
162
  """Execute a coroutine without awaiting it."""
163
  self._outbox.fire_and_forget(task)
 
 
 
 
 
3
  from collections.abc import Awaitable, Callable
4
  from typing import Any, cast
5
 
6
+ from ..limiter import MessagingRateLimiter
7
  from .outbox import PlatformOutbox
8
 
9
  DISCORD_MESSAGE_LIMIT = 2000
10
 
11
  ClientGetter = Callable[[], Any]
12
  DiscordGetter = Callable[[], Any]
 
13
 
14
 
15
  def truncate_discord_message(text: str, limit: int = DISCORD_MESSAGE_LIMIT) -> str:
 
27
  *,
28
  get_client: ClientGetter,
29
  get_discord: DiscordGetter,
30
+ limiter: MessagingRateLimiter,
31
  ) -> None:
32
  self._get_client = get_client
33
  self._get_discord = get_discord
34
  self._outbox = PlatformOutbox(
35
+ limiter=limiter,
36
  send=self.send_message,
37
  edit=self.edit_message,
38
  delete_many=self.delete_messages,
 
161
  def fire_and_forget(self, task: Awaitable[Any]) -> None:
162
  """Execute a coroutine without awaiting it."""
163
  self._outbox.fire_and_forget(task)
164
+
165
+ async def close(self) -> None:
166
+ """Cancel outstanding outbound work."""
167
+ await self._outbox.close()
src/free_claude_code/messaging/platforms/factory.py CHANGED
@@ -4,6 +4,8 @@ from dataclasses import dataclass
4
 
5
  from loguru import logger
6
 
 
 
7
  from .ports import MessagingPlatformComponents
8
 
9
 
@@ -16,14 +18,11 @@ class MessagingPlatformOptions:
16
  telegram_proxy_url: str = ""
17
  discord_bot_token: str | None = None
18
  allowed_discord_channels: str | None = None
19
- voice_note_enabled: bool = True
20
- whisper_model: str = "base"
21
- whisper_device: str = "cpu"
22
- huggingface_api_key: str = ""
23
- nvidia_nim_api_key: str = ""
24
  messaging_rate_limit: int = 1
25
  messaging_rate_window: float = 1.0
26
  log_raw_messaging_content: bool = False
 
27
  log_api_error_tracebacks: bool = False
28
 
29
 
@@ -45,17 +44,17 @@ def create_messaging_components(
45
 
46
  from .telegram import TelegramRuntime
47
 
 
 
 
 
 
48
  runtime = TelegramRuntime(
49
  bot_token=bot_token,
50
  allowed_user_id=opts.allowed_telegram_user_id,
51
  telegram_proxy_url=opts.telegram_proxy_url,
52
- voice_note_enabled=opts.voice_note_enabled,
53
- whisper_model=opts.whisper_model,
54
- whisper_device=opts.whisper_device,
55
- huggingface_api_key=opts.huggingface_api_key,
56
- nvidia_nim_api_key=opts.nvidia_nim_api_key,
57
- messaging_rate_limit=opts.messaging_rate_limit,
58
- messaging_rate_window=opts.messaging_rate_window,
59
  log_raw_messaging_content=opts.log_raw_messaging_content,
60
  log_api_error_tracebacks=opts.log_api_error_tracebacks,
61
  )
@@ -74,16 +73,16 @@ def create_messaging_components(
74
 
75
  from .discord import DiscordRuntime
76
 
 
 
 
 
 
77
  runtime = DiscordRuntime(
78
  bot_token=bot_token,
79
  allowed_channel_ids=opts.allowed_discord_channels,
80
- voice_note_enabled=opts.voice_note_enabled,
81
- whisper_model=opts.whisper_model,
82
- whisper_device=opts.whisper_device,
83
- huggingface_api_key=opts.huggingface_api_key,
84
- nvidia_nim_api_key=opts.nvidia_nim_api_key,
85
- messaging_rate_limit=opts.messaging_rate_limit,
86
- messaging_rate_window=opts.messaging_rate_window,
87
  log_raw_messaging_content=opts.log_raw_messaging_content,
88
  log_api_error_tracebacks=opts.log_api_error_tracebacks,
89
  )
 
4
 
5
  from loguru import logger
6
 
7
+ from ..limiter import MessagingRateLimiter
8
+ from ..voice import Transcriber
9
  from .ports import MessagingPlatformComponents
10
 
11
 
 
18
  telegram_proxy_url: str = ""
19
  discord_bot_token: str | None = None
20
  allowed_discord_channels: str | None = None
21
+ transcriber: Transcriber | None = None
 
 
 
 
22
  messaging_rate_limit: int = 1
23
  messaging_rate_window: float = 1.0
24
  log_raw_messaging_content: bool = False
25
+ log_messaging_error_details: bool = False
26
  log_api_error_tracebacks: bool = False
27
 
28
 
 
44
 
45
  from .telegram import TelegramRuntime
46
 
47
+ limiter = MessagingRateLimiter(
48
+ rate_limit=opts.messaging_rate_limit,
49
+ rate_window=opts.messaging_rate_window,
50
+ log_error_details=opts.log_messaging_error_details,
51
+ )
52
  runtime = TelegramRuntime(
53
  bot_token=bot_token,
54
  allowed_user_id=opts.allowed_telegram_user_id,
55
  telegram_proxy_url=opts.telegram_proxy_url,
56
+ limiter=limiter,
57
+ transcriber=opts.transcriber,
 
 
 
 
 
58
  log_raw_messaging_content=opts.log_raw_messaging_content,
59
  log_api_error_tracebacks=opts.log_api_error_tracebacks,
60
  )
 
73
 
74
  from .discord import DiscordRuntime
75
 
76
+ limiter = MessagingRateLimiter(
77
+ rate_limit=opts.messaging_rate_limit,
78
+ rate_window=opts.messaging_rate_window,
79
+ log_error_details=opts.log_messaging_error_details,
80
+ )
81
  runtime = DiscordRuntime(
82
  bot_token=bot_token,
83
  allowed_channel_ids=opts.allowed_discord_channels,
84
+ limiter=limiter,
85
+ transcriber=opts.transcriber,
 
 
 
 
 
86
  log_raw_messaging_content=opts.log_raw_messaging_content,
87
  log_api_error_tracebacks=opts.log_api_error_tracebacks,
88
  )
src/free_claude_code/messaging/platforms/outbox.py CHANGED
@@ -5,13 +5,16 @@ import hashlib
5
  from collections.abc import Awaitable, Callable
6
  from typing import Any, cast
7
 
 
 
 
 
8
  SendOperation = Callable[
9
  [str, str, str | None, str | None, str | None],
10
  Awaitable[str],
11
  ]
12
  EditOperation = Callable[[str, str, str, str | None], Awaitable[None]]
13
  DeleteManyOperation = Callable[[str, list[str]], Awaitable[None]]
14
- LimiterGetter = Callable[[], Any | None]
15
 
16
 
17
  class PlatformOutbox:
@@ -20,15 +23,17 @@ class PlatformOutbox:
20
  def __init__(
21
  self,
22
  *,
23
- get_limiter: LimiterGetter,
24
  send: SendOperation,
25
  edit: EditOperation,
26
  delete_many: DeleteManyOperation,
27
  ) -> None:
28
- self._get_limiter = get_limiter
29
  self._send = send
30
  self._edit = edit
31
  self._delete_many = delete_many
 
 
32
 
33
  async def queue_send_message(
34
  self,
@@ -40,15 +45,7 @@ class PlatformOutbox:
40
  message_thread_id: str | None = None,
41
  ) -> str | None:
42
  """Queue or immediately send a platform message."""
43
- limiter = self._get_limiter()
44
- if limiter is None:
45
- return await self._send(
46
- chat_id,
47
- text,
48
- reply_to,
49
- parse_mode,
50
- message_thread_id,
51
- )
52
 
53
  async def _send() -> str:
54
  return await self._send(
@@ -60,9 +57,9 @@ class PlatformOutbox:
60
  )
61
 
62
  if fire_and_forget:
63
- limiter.fire_and_forget(_send)
64
  return None
65
- return cast(str | None, await limiter.enqueue(_send))
66
 
67
  async def queue_edit_message(
68
  self,
@@ -73,19 +70,16 @@ class PlatformOutbox:
73
  fire_and_forget: bool = True,
74
  ) -> None:
75
  """Queue or immediately edit a platform message."""
76
- limiter = self._get_limiter()
77
- if limiter is None:
78
- await self._edit(chat_id, message_id, text, parse_mode)
79
- return
80
 
81
  async def _edit() -> None:
82
  await self._edit(chat_id, message_id, text, parse_mode)
83
 
84
  dedup_key = f"edit:{chat_id}:{message_id}"
85
  if fire_and_forget:
86
- limiter.fire_and_forget(_edit, dedup_key=dedup_key)
87
  else:
88
- await limiter.enqueue(_edit, dedup_key=dedup_key)
89
 
90
  async def queue_delete_messages(
91
  self,
@@ -94,28 +88,56 @@ class PlatformOutbox:
94
  fire_and_forget: bool = True,
95
  ) -> None:
96
  """Queue or immediately bulk-delete platform messages."""
 
97
  ids_snapshot = tuple(str(message_id) for message_id in message_ids)
98
  if not ids_snapshot:
99
  return
100
 
101
- limiter = self._get_limiter()
102
- if limiter is None:
103
- await self._delete_many(chat_id, list(ids_snapshot))
104
- return
105
-
106
  async def _delete_many() -> None:
107
  await self._delete_many(chat_id, list(ids_snapshot))
108
 
109
  digest = hashlib.sha256("\x1f".join(ids_snapshot).encode()).hexdigest()[:16]
110
  dedup_key = f"del_bulk:{chat_id}:{digest}"
111
  if fire_and_forget:
112
- limiter.fire_and_forget(_delete_many, dedup_key=dedup_key)
113
  else:
114
- await limiter.enqueue(_delete_many, dedup_key=dedup_key)
115
 
116
  def fire_and_forget(self, task: Awaitable[Any]) -> None:
117
- """Execute a coroutine or future without awaiting it."""
118
- if asyncio.iscoroutine(task):
119
- _ = asyncio.create_task(task)
120
- else:
121
- _ = asyncio.ensure_future(task)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  from collections.abc import Awaitable, Callable
6
  from typing import Any, cast
7
 
8
+ from loguru import logger
9
+
10
+ from ..limiter import MessagingRateLimiter
11
+
12
  SendOperation = Callable[
13
  [str, str, str | None, str | None, str | None],
14
  Awaitable[str],
15
  ]
16
  EditOperation = Callable[[str, str, str, str | None], Awaitable[None]]
17
  DeleteManyOperation = Callable[[str, list[str]], Awaitable[None]]
 
18
 
19
 
20
  class PlatformOutbox:
 
23
  def __init__(
24
  self,
25
  *,
26
+ limiter: MessagingRateLimiter,
27
  send: SendOperation,
28
  edit: EditOperation,
29
  delete_many: DeleteManyOperation,
30
  ) -> None:
31
+ self._limiter = limiter
32
  self._send = send
33
  self._edit = edit
34
  self._delete_many = delete_many
35
+ self._background_tasks: set[asyncio.Future[Any]] = set()
36
+ self._closed = False
37
 
38
  async def queue_send_message(
39
  self,
 
45
  message_thread_id: str | None = None,
46
  ) -> str | None:
47
  """Queue or immediately send a platform message."""
48
+ self._require_open()
 
 
 
 
 
 
 
 
49
 
50
  async def _send() -> str:
51
  return await self._send(
 
57
  )
58
 
59
  if fire_and_forget:
60
+ self._limiter.fire_and_forget(_send)
61
  return None
62
+ return cast(str | None, await self._limiter.enqueue(_send))
63
 
64
  async def queue_edit_message(
65
  self,
 
70
  fire_and_forget: bool = True,
71
  ) -> None:
72
  """Queue or immediately edit a platform message."""
73
+ self._require_open()
 
 
 
74
 
75
  async def _edit() -> None:
76
  await self._edit(chat_id, message_id, text, parse_mode)
77
 
78
  dedup_key = f"edit:{chat_id}:{message_id}"
79
  if fire_and_forget:
80
+ self._limiter.fire_and_forget(_edit, dedup_key=dedup_key)
81
  else:
82
+ await self._limiter.enqueue(_edit, dedup_key=dedup_key)
83
 
84
  async def queue_delete_messages(
85
  self,
 
88
  fire_and_forget: bool = True,
89
  ) -> None:
90
  """Queue or immediately bulk-delete platform messages."""
91
+ self._require_open()
92
  ids_snapshot = tuple(str(message_id) for message_id in message_ids)
93
  if not ids_snapshot:
94
  return
95
 
 
 
 
 
 
96
  async def _delete_many() -> None:
97
  await self._delete_many(chat_id, list(ids_snapshot))
98
 
99
  digest = hashlib.sha256("\x1f".join(ids_snapshot).encode()).hexdigest()[:16]
100
  dedup_key = f"del_bulk:{chat_id}:{digest}"
101
  if fire_and_forget:
102
+ self._limiter.fire_and_forget(_delete_many, dedup_key=dedup_key)
103
  else:
104
+ await self._limiter.enqueue(_delete_many, dedup_key=dedup_key)
105
 
106
  def fire_and_forget(self, task: Awaitable[Any]) -> None:
107
+ """Run and retain arbitrary outbound work until completion or shutdown."""
108
+ future = asyncio.ensure_future(task)
109
+ if self._closed:
110
+ future.cancel()
111
+ raise RuntimeError("Platform outbox is closed.")
112
+ self._background_tasks.add(future)
113
+ future.add_done_callback(self._complete_background_task)
114
+
115
+ async def close(self) -> None:
116
+ """Cancel and await arbitrary outbound work owned by this outbox."""
117
+ if not self._closed:
118
+ self._closed = True
119
+ tasks = tuple(self._background_tasks)
120
+ for task in tasks:
121
+ task.cancel()
122
+ try:
123
+ if tasks:
124
+ await asyncio.gather(*tasks, return_exceptions=True)
125
+ finally:
126
+ self._background_tasks.difference_update(
127
+ task for task in tasks if task.done()
128
+ )
129
+
130
+ def _complete_background_task(self, task: asyncio.Future[Any]) -> None:
131
+ self._background_tasks.discard(task)
132
+ if task.cancelled():
133
+ return
134
+ error = task.exception()
135
+ if error is not None:
136
+ logger.error(
137
+ "Outbound background task failed: exc_type={}",
138
+ type(error).__name__,
139
+ )
140
+
141
+ def _require_open(self) -> None:
142
+ if self._closed:
143
+ raise RuntimeError("Platform outbox is closed.")
src/free_claude_code/messaging/platforms/ports.py CHANGED
@@ -11,14 +11,16 @@ InboundMessageHandler = Callable[[IncomingMessage], Awaitable[None]]
11
 
12
  @runtime_checkable
13
  class MessagingRuntime(Protocol):
14
- """Owns inbound SDK lifecycle for one messaging platform."""
15
 
16
  @property
17
  def name(self) -> str: ...
18
 
19
  async def start(self) -> None: ...
20
 
21
- async def stop(self) -> None: ...
 
 
22
 
23
  def on_message(self, handler: InboundMessageHandler) -> None: ...
24
 
 
11
 
12
  @runtime_checkable
13
  class MessagingRuntime(Protocol):
14
+ """Owns ingress and delivery lifecycle for one messaging platform."""
15
 
16
  @property
17
  def name(self) -> str: ...
18
 
19
  async def start(self) -> None: ...
20
 
21
+ async def quiesce(self) -> None: ...
22
+
23
+ async def close(self) -> None: ...
24
 
25
  def on_message(self, handler: InboundMessageHandler) -> None: ...
26
 
src/free_claude_code/messaging/platforms/telegram.py CHANGED
@@ -4,7 +4,6 @@ import asyncio
4
  import contextlib
5
  import os
6
  from collections.abc import Awaitable, Callable
7
- from typing import Any
8
 
9
  # Opt-in to future behavior for python-telegram-bot (retry_after as timedelta).
10
  os.environ["PTB_TIMEDELTA"] = "1"
@@ -13,8 +12,10 @@ from loguru import logger
13
 
14
  from free_claude_code.core.anthropic import format_user_error_preview
15
 
 
16
  from ..models import IncomingMessage
17
  from ..rendering.telegram_markdown import escape_md_v2
 
18
  from .ports import InboundMessageHandler
19
  from .telegram_inbound import (
20
  telegram_text_message_from_update,
@@ -50,13 +51,8 @@ class TelegramRuntime:
50
  allowed_user_id: str | None = None,
51
  *,
52
  telegram_proxy_url: str = "",
53
- voice_note_enabled: bool = True,
54
- whisper_model: str = "base",
55
- whisper_device: str = "cpu",
56
- huggingface_api_key: str = "",
57
- nvidia_nim_api_key: str = "",
58
- messaging_rate_limit: int = 1,
59
- messaging_rate_window: float = 1.0,
60
  log_raw_messaging_content: bool = False,
61
  log_api_error_tracebacks: bool = False,
62
  ) -> None:
@@ -74,22 +70,16 @@ class TelegramRuntime:
74
  self._application: Application | None = None
75
  self._message_handler: InboundMessageHandler | None = None
76
  self._connected = False
77
- self._limiter: Any | None = None
78
  self.outbound = TelegramMessenger(
79
  get_application=lambda: self._application,
80
- get_limiter=lambda: self._limiter,
81
  )
82
  self._voice_flow = VoiceNoteFlow(
83
- voice_note_enabled=voice_note_enabled,
84
- whisper_model=whisper_model,
85
- whisper_device=whisper_device,
86
- huggingface_api_key=huggingface_api_key,
87
- nvidia_nim_api_key=nvidia_nim_api_key,
88
  log_raw_messaging_content=log_raw_messaging_content,
89
  log_api_error_tracebacks=log_api_error_tracebacks,
90
  )
91
- self._messaging_rate_limit = messaging_rate_limit
92
- self._messaging_rate_window = messaging_rate_window
93
  self._log_raw_messaging_content = log_raw_messaging_content
94
  self._log_api_error_tracebacks = log_api_error_tracebacks
95
 
@@ -128,51 +118,31 @@ class TelegramRuntime:
128
  connection_pool_size=8, connect_timeout=30.0, read_timeout=30.0
129
  )
130
  builder = Application.builder().token(self.bot_token).request(request)
131
- self._application = builder.build()
 
132
 
133
- self._application.add_handler(
134
  MessageHandler(filters.TEXT & (~filters.COMMAND), self._on_telegram_message)
135
  )
136
- self._application.add_handler(CommandHandler("start", self._on_start_command))
137
- self._application.add_handler(
138
  MessageHandler(filters.COMMAND, self._on_telegram_message)
139
  )
140
- self._application.add_handler(
141
- MessageHandler(filters.VOICE, self._on_telegram_voice)
142
- )
143
 
144
- max_retries = 3
145
- for attempt in range(max_retries):
146
- try:
147
- await self._application.initialize()
148
- await self._application.start()
149
- if self._application.updater:
150
- await self._application.updater.start_polling(
151
- drop_pending_updates=False
152
- )
153
- self._connected = True
154
- break
155
- except Exception as e:
156
- if attempt < max_retries - 1:
157
- wait_time = 2 * (attempt + 1)
158
- logger.warning(
159
- "Connection failed (attempt {}/{}): {}. Retrying in {}s...",
160
- attempt + 1,
161
- max_retries,
162
- e,
163
- wait_time,
164
- )
165
- await asyncio.sleep(wait_time)
166
- else:
167
- logger.error("Failed to connect after {} attempts", max_retries)
168
- raise
169
-
170
- from ..limiter import MessagingRateLimiter
171
-
172
- self._limiter = await MessagingRateLimiter.get_instance(
173
- rate_limit=self._messaging_rate_limit,
174
- rate_window=self._messaging_rate_window,
175
  )
 
 
 
 
 
 
 
 
 
176
 
177
  try:
178
  target = self.allowed_user_id
@@ -193,15 +163,75 @@ class TelegramRuntime:
193
 
194
  logger.info("Telegram platform started (Bot API)")
195
 
196
- async def stop(self) -> None:
197
- """Stop Telegram polling and SDK resources."""
198
- if self._application and self._application.updater:
199
- await self._application.updater.stop()
200
- await self._application.stop()
201
- await self._application.shutdown()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
- self._connected = False
204
- logger.info("Telegram platform stopped")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
 
206
  def on_message(self, handler: Callable[[IncomingMessage], Awaitable[None]]) -> None:
207
  """Register the workflow callback for inbound messages."""
 
4
  import contextlib
5
  import os
6
  from collections.abc import Awaitable, Callable
 
7
 
8
  # Opt-in to future behavior for python-telegram-bot (retry_after as timedelta).
9
  os.environ["PTB_TIMEDELTA"] = "1"
 
12
 
13
  from free_claude_code.core.anthropic import format_user_error_preview
14
 
15
+ from ..limiter import MessagingRateLimiter
16
  from ..models import IncomingMessage
17
  from ..rendering.telegram_markdown import escape_md_v2
18
+ from ..voice import Transcriber
19
  from .ports import InboundMessageHandler
20
  from .telegram_inbound import (
21
  telegram_text_message_from_update,
 
51
  allowed_user_id: str | None = None,
52
  *,
53
  telegram_proxy_url: str = "",
54
+ limiter: MessagingRateLimiter,
55
+ transcriber: Transcriber | None,
 
 
 
 
 
56
  log_raw_messaging_content: bool = False,
57
  log_api_error_tracebacks: bool = False,
58
  ) -> None:
 
70
  self._application: Application | None = None
71
  self._message_handler: InboundMessageHandler | None = None
72
  self._connected = False
73
+ self._limiter = limiter
74
  self.outbound = TelegramMessenger(
75
  get_application=lambda: self._application,
76
+ limiter=limiter,
77
  )
78
  self._voice_flow = VoiceNoteFlow(
79
+ transcriber=transcriber,
 
 
 
 
80
  log_raw_messaging_content=log_raw_messaging_content,
81
  log_api_error_tracebacks=log_api_error_tracebacks,
82
  )
 
 
83
  self._log_raw_messaging_content = log_raw_messaging_content
84
  self._log_api_error_tracebacks = log_api_error_tracebacks
85
 
 
118
  connection_pool_size=8, connect_timeout=30.0, read_timeout=30.0
119
  )
120
  builder = Application.builder().token(self.bot_token).request(request)
121
+ application = builder.build()
122
+ self._application = application
123
 
124
+ application.add_handler(
125
  MessageHandler(filters.TEXT & (~filters.COMMAND), self._on_telegram_message)
126
  )
127
+ application.add_handler(CommandHandler("start", self._on_start_command))
128
+ application.add_handler(
129
  MessageHandler(filters.COMMAND, self._on_telegram_message)
130
  )
131
+ application.add_handler(MessageHandler(filters.VOICE, self._on_telegram_voice))
 
 
132
 
133
+ await self._retry_connection_step(
134
+ application.initialize,
135
+ step="initialization",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  )
137
+ await application.start()
138
+ self._limiter.start()
139
+ updater = application.updater
140
+ if updater is not None:
141
+ await self._retry_connection_step(
142
+ lambda: updater.start_polling(drop_pending_updates=False),
143
+ step="polling",
144
+ )
145
+ self._connected = True
146
 
147
  try:
148
  target = self.allowed_user_id
 
163
 
164
  logger.info("Telegram platform started (Bot API)")
165
 
166
+ async def _retry_connection_step(
167
+ self,
168
+ operation: Callable[[], Awaitable[object]],
169
+ *,
170
+ step: str,
171
+ ) -> None:
172
+ """Retry one independently repeatable Telegram connection step."""
173
+ max_attempts = 3
174
+ for attempt in range(1, max_attempts + 1):
175
+ try:
176
+ await operation()
177
+ return
178
+ except Exception as exc:
179
+ if attempt == max_attempts:
180
+ logger.error(
181
+ "Telegram {} failed after {} attempts",
182
+ step,
183
+ max_attempts,
184
+ )
185
+ raise
186
+ wait_time = 2 * attempt
187
+ if self._log_api_error_tracebacks:
188
+ logger.warning(
189
+ "Telegram {} failed (attempt {}/{}): {}. Retrying in {}s...",
190
+ step,
191
+ attempt,
192
+ max_attempts,
193
+ exc,
194
+ wait_time,
195
+ )
196
+ else:
197
+ logger.warning(
198
+ "Telegram {} failed (attempt {}/{}): exc_type={}. Retrying in {}s...",
199
+ step,
200
+ attempt,
201
+ max_attempts,
202
+ type(exc).__name__,
203
+ wait_time,
204
+ )
205
+ await asyncio.sleep(wait_time)
206
 
207
+ async def quiesce(self) -> None:
208
+ """Stop Telegram ingress after draining active SDK handlers."""
209
+ application = self._application
210
+ updater = application.updater if application is not None else None
211
+ try:
212
+ if updater is not None and updater.running:
213
+ await updater.stop()
214
+ finally:
215
+ try:
216
+ if application is not None and application.running:
217
+ await application.stop()
218
+ finally:
219
+ self._connected = False
220
+
221
+ async def close(self) -> None:
222
+ """Close Telegram delivery and initialized SDK resources."""
223
+ application = self._application
224
+ try:
225
+ await self.outbound.close()
226
+ finally:
227
+ try:
228
+ await self._limiter.shutdown()
229
+ finally:
230
+ try:
231
+ if application is not None:
232
+ await application.shutdown()
233
+ finally:
234
+ logger.info("Telegram platform closed")
235
 
236
  def on_message(self, handler: Callable[[IncomingMessage], Awaitable[None]]) -> None:
237
  """Register the workflow callback for inbound messages."""
src/free_claude_code/messaging/platforms/telegram_io.py CHANGED
@@ -7,6 +7,7 @@ from typing import Any
7
 
8
  from loguru import logger
9
 
 
10
  from .outbox import PlatformOutbox
11
 
12
  TELEGRAM_DELETE_MESSAGES_BATCH_SIZE = 100
@@ -34,7 +35,6 @@ except ImportError:
34
  TelegramBaseError = Exception
35
 
36
  ApplicationGetter = Callable[[], Any | None]
37
- LimiterGetter = Callable[[], Any | None]
38
 
39
 
40
  class TelegramMessenger:
@@ -44,11 +44,11 @@ class TelegramMessenger:
44
  self,
45
  *,
46
  get_application: ApplicationGetter,
47
- get_limiter: LimiterGetter,
48
  ) -> None:
49
  self._get_application = get_application
50
  self._outbox = PlatformOutbox(
51
- get_limiter=get_limiter,
52
  send=self.send_message,
53
  edit=self.edit_message,
54
  delete_many=self.delete_messages,
@@ -281,3 +281,7 @@ class TelegramMessenger:
281
  def fire_and_forget(self, task: Awaitable[Any]) -> None:
282
  """Execute a coroutine without awaiting it."""
283
  self._outbox.fire_and_forget(task)
 
 
 
 
 
7
 
8
  from loguru import logger
9
 
10
+ from ..limiter import MessagingRateLimiter
11
  from .outbox import PlatformOutbox
12
 
13
  TELEGRAM_DELETE_MESSAGES_BATCH_SIZE = 100
 
35
  TelegramBaseError = Exception
36
 
37
  ApplicationGetter = Callable[[], Any | None]
 
38
 
39
 
40
  class TelegramMessenger:
 
44
  self,
45
  *,
46
  get_application: ApplicationGetter,
47
+ limiter: MessagingRateLimiter,
48
  ) -> None:
49
  self._get_application = get_application
50
  self._outbox = PlatformOutbox(
51
+ limiter=limiter,
52
  send=self.send_message,
53
  edit=self.edit_message,
54
  delete_many=self.delete_messages,
 
281
  def fire_and_forget(self, task: Awaitable[Any]) -> None:
282
  """Execute a coroutine without awaiting it."""
283
  self._outbox.fire_and_forget(task)
284
+
285
+ async def close(self) -> None:
286
+ """Cancel outstanding outbound work."""
287
+ await self._outbox.close()
src/free_claude_code/messaging/platforms/voice_flow.py CHANGED
@@ -1,5 +1,6 @@
1
  """Shared voice-note flow for messaging platform adapters."""
2
 
 
3
  import contextlib
4
  import tempfile
5
  from collections.abc import Awaitable, Callable
@@ -12,9 +13,10 @@ from loguru import logger
12
  from free_claude_code.core.anthropic import format_user_error_preview
13
 
14
  from ..models import IncomingMessage
15
- from ..voice import PendingVoiceRegistry, VoiceTranscriptionService
16
 
17
  AUDIO_EXTENSIONS = (".ogg", ".mp4", ".mp3", ".wav", ".m4a")
 
18
  VOICE_DISABLED_MESSAGE = "Voice notes are disabled."
19
  VOICE_TRANSCRIPTION_ERROR_MESSAGE = (
20
  "Could not transcribe voice note. Please try again or send text."
@@ -87,35 +89,25 @@ class VoiceNoteFlow:
87
  def __init__(
88
  self,
89
  *,
90
- voice_note_enabled: bool,
91
- whisper_model: str,
92
- whisper_device: str,
93
- huggingface_api_key: str,
94
- nvidia_nim_api_key: str,
95
  log_raw_messaging_content: bool,
96
  log_api_error_tracebacks: bool,
97
  ) -> None:
98
- self._voice_note_enabled = voice_note_enabled
99
- self._whisper_model = whisper_model
100
- self._whisper_device = whisper_device
101
  self._log_raw_messaging_content = log_raw_messaging_content
102
  self._log_api_error_tracebacks = log_api_error_tracebacks
103
  self._pending_voice = PendingVoiceRegistry()
104
- self._voice_transcription = VoiceTranscriptionService(
105
- huggingface_api_key=huggingface_api_key,
106
- nvidia_nim_api_key=nvidia_nim_api_key,
107
- )
108
 
109
  @property
110
  def is_enabled(self) -> bool:
111
  """Return whether voice-note handling is enabled."""
112
- return self._voice_note_enabled
113
 
114
  async def reply_if_disabled(
115
  self, reply_text: Callable[[str], Awaitable[None]]
116
  ) -> bool:
117
  """Reply with the disabled message when voice-note handling is disabled."""
118
- if self._voice_note_enabled:
119
  return False
120
  await reply_text(VOICE_DISABLED_MESSAGE)
121
  return True
@@ -180,13 +172,12 @@ class VoiceNoteFlow:
180
 
181
  try:
182
  await request.download_to(tmp_path)
 
183
 
184
- transcribed = await self._voice_transcription.transcribe(
185
- tmp_path,
186
- request.content_type,
187
- whisper_model=self._whisper_model,
188
- whisper_device=self._whisper_device,
189
- )
190
 
191
  if not await self.is_voice_still_pending(
192
  request.chat_id,
@@ -218,6 +209,14 @@ class VoiceNoteFlow:
218
  self._log_transcription(request, transcribed)
219
  await message_handler(incoming)
220
  return True
 
 
 
 
 
 
 
 
221
  except ValueError as e:
222
  await self._clear_failed_pending_voice(
223
  request,
@@ -291,3 +290,13 @@ class VoiceNoteFlow:
291
  request.message_id,
292
  len(transcribed),
293
  )
 
 
 
 
 
 
 
 
 
 
 
1
  """Shared voice-note flow for messaging platform adapters."""
2
 
3
+ import asyncio
4
  import contextlib
5
  import tempfile
6
  from collections.abc import Awaitable, Callable
 
13
  from free_claude_code.core.anthropic import format_user_error_preview
14
 
15
  from ..models import IncomingMessage
16
+ from ..voice import PendingVoiceRegistry, Transcriber
17
 
18
  AUDIO_EXTENSIONS = (".ogg", ".mp4", ".mp3", ".wav", ".m4a")
19
+ MAX_AUDIO_SIZE_BYTES = 25 * 1024 * 1024
20
  VOICE_DISABLED_MESSAGE = "Voice notes are disabled."
21
  VOICE_TRANSCRIPTION_ERROR_MESSAGE = (
22
  "Could not transcribe voice note. Please try again or send text."
 
89
  def __init__(
90
  self,
91
  *,
92
+ transcriber: Transcriber | None,
 
 
 
 
93
  log_raw_messaging_content: bool,
94
  log_api_error_tracebacks: bool,
95
  ) -> None:
96
+ self._transcriber = transcriber
 
 
97
  self._log_raw_messaging_content = log_raw_messaging_content
98
  self._log_api_error_tracebacks = log_api_error_tracebacks
99
  self._pending_voice = PendingVoiceRegistry()
 
 
 
 
100
 
101
  @property
102
  def is_enabled(self) -> bool:
103
  """Return whether voice-note handling is enabled."""
104
+ return self._transcriber is not None
105
 
106
  async def reply_if_disabled(
107
  self, reply_text: Callable[[str], Awaitable[None]]
108
  ) -> bool:
109
  """Reply with the disabled message when voice-note handling is disabled."""
110
+ if self.is_enabled:
111
  return False
112
  await reply_text(VOICE_DISABLED_MESSAGE)
113
  return True
 
172
 
173
  try:
174
  await request.download_to(tmp_path)
175
+ _validate_audio_file(tmp_path)
176
 
177
+ transcriber = self._transcriber
178
+ if transcriber is None:
179
+ raise RuntimeError("Voice transcription is not configured.")
180
+ transcribed = await transcriber.transcribe(tmp_path)
 
 
181
 
182
  if not await self.is_voice_still_pending(
183
  request.chat_id,
 
209
  self._log_transcription(request, transcribed)
210
  await message_handler(incoming)
211
  return True
212
+ except asyncio.CancelledError:
213
+ await self._clear_failed_pending_voice(
214
+ request,
215
+ status_msg_id_text,
216
+ queue_delete_messages,
217
+ handed_off=handed_off,
218
+ )
219
+ raise
220
  except ValueError as e:
221
  await self._clear_failed_pending_voice(
222
  request,
 
290
  request.message_id,
291
  len(transcribed),
292
  )
293
+
294
+
295
+ def _validate_audio_file(file_path: Path) -> None:
296
+ if not file_path.exists():
297
+ raise FileNotFoundError(f"Audio file not found: {file_path}")
298
+ size = file_path.stat().st_size
299
+ if size > MAX_AUDIO_SIZE_BYTES:
300
+ raise ValueError(
301
+ f"Audio file too large ({size} bytes). Max {MAX_AUDIO_SIZE_BYTES} bytes."
302
+ )
src/free_claude_code/messaging/transcription.py CHANGED
@@ -1,23 +1,11 @@
1
- """Voice note transcription for messaging platforms.
2
-
3
- Supports:
4
- - Local Whisper (cpu/cuda): Hugging Face transformers pipeline
5
- - NVIDIA NIM: NVIDIA NIM Whisper/Parakeet
6
- """
7
 
 
8
  from pathlib import Path
9
  from typing import Any
10
 
11
  from loguru import logger
12
 
13
- from free_claude_code.providers.nvidia_nim.voice import (
14
- transcribe_audio_file as transcribe_nvidia_nim_audio,
15
- )
16
-
17
- # Max file size in bytes (25 MB)
18
- MAX_AUDIO_SIZE_BYTES = 25 * 1024 * 1024
19
-
20
- # Short model names -> full Hugging Face model IDs (for local Whisper)
21
  _MODEL_MAP: dict[str, str] = {
22
  "tiny": "openai/whisper-tiny",
23
  "base": "openai/whisper-base",
@@ -27,143 +15,118 @@ _MODEL_MAP: dict[str, str] = {
27
  "large-v3": "openai/whisper-large-v3",
28
  "large-v3-turbo": "openai/whisper-large-v3-turbo",
29
  }
 
30
 
31
- # Lazy-loaded pipelines: (model_id, device, Hugging Face API key fingerprint) -> pipeline
32
- _pipeline_cache: dict[tuple[str, str, str], Any] = {}
33
-
34
-
35
- def _resolve_model_id(whisper_model: str) -> str:
36
- """Resolve short name to full Hugging Face model ID."""
37
- return _MODEL_MAP.get(whisper_model, whisper_model)
38
 
 
 
39
 
40
- def _get_pipeline(model_id: str, device: str, huggingface_api_key: str = "") -> Any:
41
- """Lazy-load transformers Whisper pipeline. Raises ImportError if not installed."""
42
- global _pipeline_cache
43
- if device not in ("cpu", "cuda"):
44
- raise ValueError(f"whisper_device must be 'cpu' or 'cuda', got {device!r}")
45
- resolved_token = huggingface_api_key or ""
46
- cache_key = (model_id, device, resolved_token)
47
- if cache_key not in _pipeline_cache:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  try:
49
  import torch
50
  from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
51
-
52
- hf_auth_token = resolved_token or None
53
-
54
- use_cuda = device == "cuda" and torch.cuda.is_available()
55
- pipe_device = "cuda:0" if use_cuda else "cpu"
56
- model_dtype = torch.float16 if use_cuda else torch.float32
57
-
58
- model = AutoModelForSpeechSeq2Seq.from_pretrained(
59
- model_id,
60
- dtype=model_dtype,
61
- low_cpu_mem_usage=True,
62
- attn_implementation="sdpa",
63
- token=hf_auth_token,
64
- )
65
- model = model.to(pipe_device)
66
- processor = AutoProcessor.from_pretrained(model_id, token=hf_auth_token)
67
-
68
- pipe = pipeline(
69
- "automatic-speech-recognition",
70
- model=model,
71
- tokenizer=processor.tokenizer,
72
- feature_extractor=processor.feature_extractor,
73
- device=pipe_device,
74
- )
75
- _pipeline_cache[cache_key] = pipe
76
- logger.debug(
77
- f"Loaded Whisper pipeline: model={model_id} device={pipe_device}"
78
- )
79
- except ImportError as e:
80
  raise ImportError(
81
- "Local Whisper requires the voice_local extra. Install with: uv sync --extra voice_local"
82
- ) from e
83
- return _pipeline_cache[cache_key]
84
-
85
-
86
- def transcribe_audio(
87
- file_path: Path,
88
- mime_type: str,
89
- *,
90
- whisper_model: str = "base",
91
- whisper_device: str = "cpu",
92
- huggingface_api_key: str = "",
93
- nvidia_nim_api_key: str = "",
94
- ) -> str:
95
- """
96
- Transcribe audio file to text.
97
-
98
- Supports:
99
- - whisper_device="cpu"/"cuda": local Whisper (requires voice_local extra)
100
- - whisper_device="nvidia_nim": NVIDIA NIM Whisper API (requires voice extra)
101
-
102
- Args:
103
- file_path: Path to audio file (OGG, MP3, MP4, WAV, M4A supported)
104
- mime_type: MIME type of the audio (e.g. "audio/ogg")
105
- whisper_model: Model ID or short name (local) or NVIDIA NIM model
106
- whisper_device: "cpu" | "cuda" | "nvidia_nim"
107
-
108
- Returns:
109
- Transcribed text
110
-
111
- Raises:
112
- FileNotFoundError: If file does not exist
113
- ValueError: If file too large
114
- ImportError: If voice_local extra not installed (for local Whisper)
115
- """
116
-
117
- if not file_path.exists():
118
- raise FileNotFoundError(f"Audio file not found: {file_path}")
119
-
120
- size = file_path.stat().st_size
121
- if size > MAX_AUDIO_SIZE_BYTES:
122
- raise ValueError(
123
- f"Audio file too large ({size} bytes). Max {MAX_AUDIO_SIZE_BYTES} bytes."
124
  )
125
-
126
- if whisper_device == "nvidia_nim":
127
- return transcribe_nvidia_nim_audio(
128
- file_path, whisper_model, api_key=nvidia_nim_api_key
 
 
 
 
129
  )
130
- return _transcribe_local(
131
- file_path,
132
- whisper_model,
133
- whisper_device,
134
- huggingface_api_key=huggingface_api_key,
135
- )
136
 
137
 
138
- # Whisper expects 16 kHz sample rate
139
- _WHISPER_SAMPLE_RATE = 16000
 
 
 
 
 
 
 
140
 
141
 
142
  def _load_audio(file_path: Path) -> dict[str, Any]:
143
- """Load audio file to waveform dict. No ffmpeg required."""
144
  import librosa
145
 
146
- waveform, sr = librosa.load(str(file_path), sr=_WHISPER_SAMPLE_RATE, mono=True)
147
- return {"array": waveform, "sampling_rate": sr}
148
-
149
-
150
- def _transcribe_local(
151
- file_path: Path,
152
- whisper_model: str,
153
- whisper_device: str,
154
- *,
155
- huggingface_api_key: str = "",
156
- ) -> str:
157
- """Transcribe using transformers Whisper pipeline."""
158
- model_id = _resolve_model_id(whisper_model)
159
- pipe = _get_pipeline(
160
- model_id, whisper_device, huggingface_api_key=huggingface_api_key
161
  )
162
- audio = _load_audio(file_path)
163
- result = pipe(audio, generate_kwargs={"language": "en", "task": "transcribe"})
164
- text = result.get("text", "") or ""
165
- if isinstance(text, list):
166
- text = " ".join(text) if text else ""
167
- result_text = text.strip()
168
- logger.debug(f"Local transcription: {len(result_text)} chars")
169
- return result_text or "(no speech detected)"
 
1
+ """Instance-owned local Whisper transcription."""
 
 
 
 
 
2
 
3
+ import asyncio
4
  from pathlib import Path
5
  from typing import Any
6
 
7
  from loguru import logger
8
 
 
 
 
 
 
 
 
 
9
  _MODEL_MAP: dict[str, str] = {
10
  "tiny": "openai/whisper-tiny",
11
  "base": "openai/whisper-base",
 
15
  "large-v3": "openai/whisper-large-v3",
16
  "large-v3-turbo": "openai/whisper-large-v3-turbo",
17
  }
18
+ _WHISPER_SAMPLE_RATE = 16000
19
 
 
 
 
 
 
 
 
20
 
21
+ class TranscriptionService:
22
+ """Own one lazily loaded local Whisper pipeline."""
23
 
24
+ def __init__(
25
+ self,
26
+ *,
27
+ model: str,
28
+ device: str,
29
+ huggingface_api_key: str = "",
30
+ ) -> None:
31
+ if device not in {"cpu", "cuda"}:
32
+ raise ValueError(
33
+ f"Local Whisper device must be 'cpu' or 'cuda', got {device!r}"
34
+ )
35
+ self._model_id = _MODEL_MAP.get(model, model)
36
+ self._device = device
37
+ self._huggingface_api_key = huggingface_api_key
38
+ self._pipeline: Any | None = None
39
+ self._lock = asyncio.Lock()
40
+ self._closed = False
41
+
42
+ async def transcribe(self, file_path: Path) -> str:
43
+ """Transcribe one audio file without blocking the event loop."""
44
+ async with self._lock:
45
+ if self._closed:
46
+ raise RuntimeError("Transcription service is closed.")
47
+ worker = asyncio.create_task(
48
+ asyncio.to_thread(self._transcribe_sync, file_path)
49
+ )
50
+ try:
51
+ return await asyncio.shield(worker)
52
+ except asyncio.CancelledError:
53
+ await _wait_for_thread_exit(worker)
54
+ raise
55
+
56
+ async def close(self) -> None:
57
+ """Prevent new work and release the owned model pipeline."""
58
+ self._closed = True
59
+ async with self._lock:
60
+ self._pipeline = None
61
+ self._huggingface_api_key = ""
62
+
63
+ def _transcribe_sync(self, file_path: Path) -> str:
64
+ pipe = self._get_pipeline()
65
+ audio = _load_audio(file_path)
66
+ result = pipe(audio, generate_kwargs={"language": "en", "task": "transcribe"})
67
+ text = result.get("text", "") or ""
68
+ if isinstance(text, list):
69
+ text = " ".join(text) if text else ""
70
+ result_text = text.strip()
71
+ logger.debug("Local transcription: {} chars", len(result_text))
72
+ return result_text or "(no speech detected)"
73
+
74
+ def _get_pipeline(self) -> Any:
75
+ if self._pipeline is not None:
76
+ return self._pipeline
77
  try:
78
  import torch
79
  from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
80
+ except ImportError as exc:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  raise ImportError(
82
+ "Local Whisper requires the voice_local extra. "
83
+ "Install with: uv sync --extra voice_local"
84
+ ) from exc
85
+
86
+ token = self._huggingface_api_key or None
87
+ use_cuda = self._device == "cuda" and torch.cuda.is_available()
88
+ pipeline_device = "cuda:0" if use_cuda else "cpu"
89
+ model_dtype = torch.float16 if use_cuda else torch.float32
90
+ model = AutoModelForSpeechSeq2Seq.from_pretrained(
91
+ self._model_id,
92
+ dtype=model_dtype,
93
+ low_cpu_mem_usage=True,
94
+ attn_implementation="sdpa",
95
+ token=token,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  )
97
+ model = model.to(pipeline_device)
98
+ processor = AutoProcessor.from_pretrained(self._model_id, token=token)
99
+ self._pipeline = pipeline(
100
+ "automatic-speech-recognition",
101
+ model=model,
102
+ tokenizer=processor.tokenizer,
103
+ feature_extractor=processor.feature_extractor,
104
+ device=pipeline_device,
105
  )
106
+ logger.debug(
107
+ "Loaded Whisper pipeline: model={} device={}",
108
+ self._model_id,
109
+ pipeline_device,
110
+ )
111
+ return self._pipeline
112
 
113
 
114
+ async def _wait_for_thread_exit(worker: asyncio.Task[str]) -> None:
115
+ """Wait through repeated caller cancellation without cancelling thread work."""
116
+ while not worker.done():
117
+ try:
118
+ await asyncio.shield(asyncio.wait((worker,)))
119
+ except asyncio.CancelledError:
120
+ continue
121
+ if not worker.cancelled():
122
+ worker.exception()
123
 
124
 
125
  def _load_audio(file_path: Path) -> dict[str, Any]:
126
+ """Load an audio file into the waveform shape expected by Whisper."""
127
  import librosa
128
 
129
+ waveform, sample_rate = librosa.load(
130
+ str(file_path), sr=_WHISPER_SAMPLE_RATE, mono=True
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  )
132
+ return {"array": waveform, "sampling_rate": sample_rate}
 
 
 
 
 
 
 
src/free_claude_code/messaging/voice.py CHANGED
@@ -2,6 +2,15 @@
2
 
3
  import asyncio
4
  from pathlib import Path
 
 
 
 
 
 
 
 
 
5
 
6
 
7
  class PendingVoiceRegistry:
@@ -39,36 +48,3 @@ class PendingVoiceRegistry:
39
  async with self._lock:
40
  self._pending.pop((chat_id, voice_msg_id), None)
41
  self._pending.pop((chat_id, status_msg_id), None)
42
-
43
-
44
- class VoiceTranscriptionService:
45
- """Run configured transcription backends off the event loop."""
46
-
47
- def __init__(
48
- self,
49
- *,
50
- huggingface_api_key: str = "",
51
- nvidia_nim_api_key: str = "",
52
- ) -> None:
53
- self._huggingface_api_key = huggingface_api_key
54
- self._nvidia_nim_api_key = nvidia_nim_api_key
55
-
56
- async def transcribe(
57
- self,
58
- file_path: Path,
59
- mime_type: str,
60
- *,
61
- whisper_model: str,
62
- whisper_device: str,
63
- ) -> str:
64
- from .transcription import transcribe_audio
65
-
66
- return await asyncio.to_thread(
67
- transcribe_audio,
68
- file_path,
69
- mime_type,
70
- whisper_model=whisper_model,
71
- whisper_device=whisper_device,
72
- huggingface_api_key=self._huggingface_api_key,
73
- nvidia_nim_api_key=self._nvidia_nim_api_key,
74
- )
 
2
 
3
  import asyncio
4
  from pathlib import Path
5
+ from typing import Protocol
6
+
7
+
8
+ class Transcriber(Protocol):
9
+ """Consumer-owned voice transcription boundary."""
10
+
11
+ async def transcribe(self, file_path: Path) -> str: ...
12
+
13
+ async def close(self) -> None: ...
14
 
15
 
16
  class PendingVoiceRegistry:
 
48
  async with self._lock:
49
  self._pending.pop((chat_id, voice_msg_id), None)
50
  self._pending.pop((chat_id, status_msg_id), None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/free_claude_code/providers/cerebras/client.py CHANGED
@@ -4,6 +4,7 @@ from typing import Any
4
 
5
  from free_claude_code.providers.base import ProviderConfig
6
  from free_claude_code.providers.defaults import CEREBRAS_DEFAULT_BASE
 
7
  from free_claude_code.providers.transports.openai_chat import (
8
  OpenAIChatRequestPolicy,
9
  OpenAIChatTransport,
@@ -20,12 +21,13 @@ _REQUEST_POLICY = OpenAIChatRequestPolicy(
20
  class CerebrasProvider(OpenAIChatTransport):
21
  """Cerebras API at ``https://api.cerebras.ai/v1/chat/completions``."""
22
 
23
- def __init__(self, config: ProviderConfig):
24
  super().__init__(
25
  config,
26
  provider_name="CEREBRAS",
27
  base_url=config.base_url or CEREBRAS_DEFAULT_BASE,
28
  api_key=config.api_key,
 
29
  )
30
 
31
  def _build_request_body(
 
4
 
5
  from free_claude_code.providers.base import ProviderConfig
6
  from free_claude_code.providers.defaults import CEREBRAS_DEFAULT_BASE
7
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
8
  from free_claude_code.providers.transports.openai_chat import (
9
  OpenAIChatRequestPolicy,
10
  OpenAIChatTransport,
 
21
  class CerebrasProvider(OpenAIChatTransport):
22
  """Cerebras API at ``https://api.cerebras.ai/v1/chat/completions``."""
23
 
24
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
25
  super().__init__(
26
  config,
27
  provider_name="CEREBRAS",
28
  base_url=config.base_url or CEREBRAS_DEFAULT_BASE,
29
  api_key=config.api_key,
30
+ rate_limiter=rate_limiter,
31
  )
32
 
33
  def _build_request_body(
src/free_claude_code/providers/cloudflare/client.py CHANGED
@@ -17,6 +17,7 @@ from free_claude_code.providers.model_listing import (
17
  extract_openai_model_ids,
18
  model_infos_from_ids,
19
  )
 
20
  from free_claude_code.providers.transports.http import maybe_await_aclose
21
  from free_claude_code.providers.transports.openai_chat import (
22
  OpenAIChatRequestPolicy,
@@ -59,7 +60,13 @@ def _cloudflare_account_api_url(api_root: str | None, account_id: str) -> str:
59
  class CloudflareProvider(OpenAIChatTransport):
60
  """Cloudflare Workers AI OpenAI-compatible chat provider."""
61
 
62
- def __init__(self, config: ProviderConfig, *, account_id: str):
 
 
 
 
 
 
63
  base_url = cloudflare_ai_base_url(config.base_url, account_id)
64
  self._model_search_url = _cloudflare_model_search_url(
65
  config.base_url, account_id
@@ -78,6 +85,7 @@ class CloudflareProvider(OpenAIChatTransport):
78
  provider_name="CLOUDFLARE",
79
  base_url=base_url,
80
  api_key=config.api_key,
 
81
  )
82
 
83
  async def cleanup(self) -> None:
 
17
  extract_openai_model_ids,
18
  model_infos_from_ids,
19
  )
20
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
21
  from free_claude_code.providers.transports.http import maybe_await_aclose
22
  from free_claude_code.providers.transports.openai_chat import (
23
  OpenAIChatRequestPolicy,
 
60
  class CloudflareProvider(OpenAIChatTransport):
61
  """Cloudflare Workers AI OpenAI-compatible chat provider."""
62
 
63
+ def __init__(
64
+ self,
65
+ config: ProviderConfig,
66
+ *,
67
+ account_id: str,
68
+ rate_limiter: ProviderRateLimiter,
69
+ ):
70
  base_url = cloudflare_ai_base_url(config.base_url, account_id)
71
  self._model_search_url = _cloudflare_model_search_url(
72
  config.base_url, account_id
 
85
  provider_name="CLOUDFLARE",
86
  base_url=base_url,
87
  api_key=config.api_key,
88
+ rate_limiter=rate_limiter,
89
  )
90
 
91
  async def cleanup(self) -> None:
src/free_claude_code/providers/codestral/client.py CHANGED
@@ -4,6 +4,7 @@ from typing import Any
4
 
5
  from free_claude_code.providers.base import ProviderConfig
6
  from free_claude_code.providers.defaults import CODESTRAL_DEFAULT_BASE
 
7
  from free_claude_code.providers.transports.openai_chat import (
8
  OpenAIChatRequestPolicy,
9
  OpenAIChatTransport,
@@ -20,12 +21,13 @@ class CodestralProvider(OpenAIChatTransport):
20
  Request shaping matches Mistral La Plateforme.
21
  """
22
 
23
- def __init__(self, config: ProviderConfig):
24
  super().__init__(
25
  config,
26
  provider_name="CODESTRAL",
27
  base_url=config.base_url or CODESTRAL_DEFAULT_BASE,
28
  api_key=config.api_key,
 
29
  )
30
 
31
  def _build_request_body(
 
4
 
5
  from free_claude_code.providers.base import ProviderConfig
6
  from free_claude_code.providers.defaults import CODESTRAL_DEFAULT_BASE
7
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
8
  from free_claude_code.providers.transports.openai_chat import (
9
  OpenAIChatRequestPolicy,
10
  OpenAIChatTransport,
 
21
  Request shaping matches Mistral La Plateforme.
22
  """
23
 
24
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
25
  super().__init__(
26
  config,
27
  provider_name="CODESTRAL",
28
  base_url=config.base_url or CODESTRAL_DEFAULT_BASE,
29
  api_key=config.api_key,
30
+ rate_limiter=rate_limiter,
31
  )
32
 
33
  def _build_request_body(
src/free_claude_code/providers/cohere/client.py CHANGED
@@ -7,6 +7,7 @@ from typing import Any
7
  from free_claude_code.providers.base import ProviderConfig
8
  from free_claude_code.providers.defaults import COHERE_DEFAULT_BASE
9
  from free_claude_code.providers.exceptions import InvalidRequestError
 
10
  from free_claude_code.providers.transports.openai_chat import (
11
  OpenAIChatRequestPolicy,
12
  OpenAIChatTransport,
@@ -44,12 +45,13 @@ _REQUEST_POLICY = OpenAIChatRequestPolicy(
44
  class CohereProvider(OpenAIChatTransport):
45
  """Cohere Compatibility API at ``https://api.cohere.ai/compatibility/v1``."""
46
 
47
- def __init__(self, config: ProviderConfig):
48
  super().__init__(
49
  config,
50
  provider_name="COHERE",
51
  base_url=config.base_url or COHERE_DEFAULT_BASE,
52
  api_key=config.api_key,
 
53
  )
54
 
55
  def _build_request_body(
 
7
  from free_claude_code.providers.base import ProviderConfig
8
  from free_claude_code.providers.defaults import COHERE_DEFAULT_BASE
9
  from free_claude_code.providers.exceptions import InvalidRequestError
10
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
11
  from free_claude_code.providers.transports.openai_chat import (
12
  OpenAIChatRequestPolicy,
13
  OpenAIChatTransport,
 
45
  class CohereProvider(OpenAIChatTransport):
46
  """Cohere Compatibility API at ``https://api.cohere.ai/compatibility/v1``."""
47
 
48
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
49
  super().__init__(
50
  config,
51
  provider_name="COHERE",
52
  base_url=config.base_url or COHERE_DEFAULT_BASE,
53
  api_key=config.api_key,
54
+ rate_limiter=rate_limiter,
55
  )
56
 
57
  def _build_request_body(
src/free_claude_code/providers/deepseek/client.py CHANGED
@@ -4,6 +4,7 @@ from typing import Any
4
 
5
  from free_claude_code.providers.base import ProviderConfig
6
  from free_claude_code.providers.defaults import DEEPSEEK_DEFAULT_BASE
 
7
  from free_claude_code.providers.transports.openai_chat import OpenAIChatTransport
8
  from free_claude_code.providers.transports.openai_chat.usage import usage_int
9
 
@@ -13,12 +14,13 @@ from .compat import build_deepseek_request_body
13
  class DeepSeekProvider(OpenAIChatTransport):
14
  """DeepSeek using ``https://api.deepseek.com`` Chat Completions."""
15
 
16
- def __init__(self, config: ProviderConfig):
17
  super().__init__(
18
  config,
19
  provider_name="DEEPSEEK",
20
  base_url=config.base_url or DEEPSEEK_DEFAULT_BASE,
21
  api_key=config.api_key,
 
22
  )
23
 
24
  def _build_request_body(
 
4
 
5
  from free_claude_code.providers.base import ProviderConfig
6
  from free_claude_code.providers.defaults import DEEPSEEK_DEFAULT_BASE
7
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
8
  from free_claude_code.providers.transports.openai_chat import OpenAIChatTransport
9
  from free_claude_code.providers.transports.openai_chat.usage import usage_int
10
 
 
14
  class DeepSeekProvider(OpenAIChatTransport):
15
  """DeepSeek using ``https://api.deepseek.com`` Chat Completions."""
16
 
17
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
18
  super().__init__(
19
  config,
20
  provider_name="DEEPSEEK",
21
  base_url=config.base_url or DEEPSEEK_DEFAULT_BASE,
22
  api_key=config.api_key,
23
+ rate_limiter=rate_limiter,
24
  )
25
 
26
  def _build_request_body(
src/free_claude_code/providers/error_mapping.py CHANGED
@@ -24,7 +24,7 @@ from free_claude_code.providers.exceptions import (
24
  ProviderError,
25
  RateLimitError,
26
  )
27
- from free_claude_code.providers.rate_limit import GlobalRateLimiter
28
 
29
  _BODY_ATTR = "_fcc_provider_error_body"
30
  _BODY_TRUNCATED_ATTR = "_fcc_provider_error_body_truncated"
@@ -311,7 +311,7 @@ def map_stream_start_error(
311
  provider_name: str,
312
  read_timeout_s: float | None,
313
  request_id: str | None,
314
- rate_limiter: GlobalRateLimiter | None = None,
315
  ) -> ProviderError:
316
  """Map a final pre-start stream failure into an HTTP-serializable provider error.
317
 
@@ -339,17 +339,14 @@ def map_stream_start_error(
339
  return APIError(message, status_code=502, raw_error=str(error))
340
 
341
 
342
- def map_error(
343
- e: Exception, *, rate_limiter: GlobalRateLimiter | None = None
344
- ) -> Exception:
345
  """Map OpenAI or HTTPX exception to specific ProviderError.
346
 
347
- Streaming transports should pass their scoped limiter (``self._global_rate_limiter``)
348
- so reactive 429 handling applies to the correct provider. Tests may omit
349
- ``rate_limiter`` to use the process-wide singleton.
350
  """
351
  message = get_user_facing_error_message(e)
352
- limiter = rate_limiter or GlobalRateLimiter.get_instance()
353
 
354
  if isinstance(e, openai.AuthenticationError):
355
  return AuthenticationError(message, raw_error=str(e))
 
24
  ProviderError,
25
  RateLimitError,
26
  )
27
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
28
 
29
  _BODY_ATTR = "_fcc_provider_error_body"
30
  _BODY_TRUNCATED_ATTR = "_fcc_provider_error_body_truncated"
 
311
  provider_name: str,
312
  read_timeout_s: float | None,
313
  request_id: str | None,
314
+ rate_limiter: ProviderRateLimiter,
315
  ) -> ProviderError:
316
  """Map a final pre-start stream failure into an HTTP-serializable provider error.
317
 
 
339
  return APIError(message, status_code=502, raw_error=str(error))
340
 
341
 
342
+ def map_error(e: Exception, *, rate_limiter: ProviderRateLimiter) -> Exception:
 
 
343
  """Map OpenAI or HTTPX exception to specific ProviderError.
344
 
345
+ Streaming transports pass their owned limiter so reactive 429 handling
346
+ applies only to that provider instance.
 
347
  """
348
  message = get_user_facing_error_message(e)
349
+ limiter = rate_limiter
350
 
351
  if isinstance(e, openai.AuthenticationError):
352
  return AuthenticationError(message, raw_error=str(e))
src/free_claude_code/providers/fireworks/client.py CHANGED
@@ -5,6 +5,7 @@ from typing import Any
5
  from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
6
  from free_claude_code.providers.base import ProviderConfig
7
  from free_claude_code.providers.defaults import FIREWORKS_DEFAULT_BASE
 
8
  from free_claude_code.providers.transports.openai_chat import (
9
  OpenAIChatRequestPolicy,
10
  OpenAIChatTransport,
@@ -27,12 +28,13 @@ _REQUEST_POLICY = OpenAIChatRequestPolicy(
27
  class FireworksProvider(OpenAIChatTransport):
28
  """Fireworks AI using ``https://api.fireworks.ai/inference/v1/chat/completions``."""
29
 
30
- def __init__(self, config: ProviderConfig):
31
  super().__init__(
32
  config,
33
  provider_name="FIREWORKS",
34
  base_url=config.base_url or FIREWORKS_BASE_URL,
35
  api_key=config.api_key,
 
36
  )
37
 
38
  def _build_request_body(
 
5
  from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
6
  from free_claude_code.providers.base import ProviderConfig
7
  from free_claude_code.providers.defaults import FIREWORKS_DEFAULT_BASE
8
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
9
  from free_claude_code.providers.transports.openai_chat import (
10
  OpenAIChatRequestPolicy,
11
  OpenAIChatTransport,
 
28
  class FireworksProvider(OpenAIChatTransport):
29
  """Fireworks AI using ``https://api.fireworks.ai/inference/v1/chat/completions``."""
30
 
31
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
32
  super().__init__(
33
  config,
34
  provider_name="FIREWORKS",
35
  base_url=config.base_url or FIREWORKS_BASE_URL,
36
  api_key=config.api_key,
37
+ rate_limiter=rate_limiter,
38
  )
39
 
40
  def _build_request_body(
src/free_claude_code/providers/gemini/client.py CHANGED
@@ -5,6 +5,7 @@ from typing import Any
5
 
6
  from free_claude_code.providers.base import ProviderConfig
7
  from free_claude_code.providers.defaults import GEMINI_DEFAULT_BASE
 
8
  from free_claude_code.providers.transports.openai_chat import (
9
  OpenAIChatRequestPolicy,
10
  OpenAIChatTransport,
@@ -20,12 +21,13 @@ _REQUEST_POLICY = OpenAIChatRequestPolicy(provider_name="GEMINI")
20
  class GeminiProvider(OpenAIChatTransport):
21
  """Gemini API using ``https://generativelanguage.googleapis.com/v1beta/openai/``."""
22
 
23
- def __init__(self, config: ProviderConfig):
24
  super().__init__(
25
  config,
26
  provider_name="GEMINI",
27
  base_url=config.base_url or GEMINI_DEFAULT_BASE,
28
  api_key=config.api_key,
 
29
  )
30
  self._tool_call_extra_content_by_id: dict[str, dict[str, Any]] = {}
31
 
 
5
 
6
  from free_claude_code.providers.base import ProviderConfig
7
  from free_claude_code.providers.defaults import GEMINI_DEFAULT_BASE
8
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
9
  from free_claude_code.providers.transports.openai_chat import (
10
  OpenAIChatRequestPolicy,
11
  OpenAIChatTransport,
 
21
  class GeminiProvider(OpenAIChatTransport):
22
  """Gemini API using ``https://generativelanguage.googleapis.com/v1beta/openai/``."""
23
 
24
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
25
  super().__init__(
26
  config,
27
  provider_name="GEMINI",
28
  base_url=config.base_url or GEMINI_DEFAULT_BASE,
29
  api_key=config.api_key,
30
+ rate_limiter=rate_limiter,
31
  )
32
  self._tool_call_extra_content_by_id: dict[str, dict[str, Any]] = {}
33
 
src/free_claude_code/providers/github_models/client.py CHANGED
@@ -12,6 +12,7 @@ from free_claude_code.providers.model_listing import (
12
  ProviderModelInfo,
13
  model_infos_from_ids,
14
  )
 
15
  from free_claude_code.providers.transports.http import maybe_await_aclose
16
  from free_claude_code.providers.transports.openai_chat import (
17
  OpenAIChatRequestPolicy,
@@ -31,7 +32,7 @@ _REQUIRED_MODEL_CAPABILITIES = frozenset({"streaming", "tool-calling"})
31
  class GitHubModelsProvider(OpenAIChatTransport):
32
  """GitHub Models OpenAI-compatible inference provider."""
33
 
34
- def __init__(self, config: ProviderConfig):
35
  self._catalog_url = GITHUB_MODELS_CATALOG_URL
36
  self._model_list_client = httpx.AsyncClient(
37
  proxy=config.proxy or None,
@@ -47,6 +48,7 @@ class GitHubModelsProvider(OpenAIChatTransport):
47
  provider_name="GITHUB_MODELS",
48
  base_url=config.base_url or GITHUB_MODELS_DEFAULT_BASE,
49
  api_key=config.api_key,
 
50
  default_headers=_github_models_default_headers(),
51
  )
52
 
 
12
  ProviderModelInfo,
13
  model_infos_from_ids,
14
  )
15
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
16
  from free_claude_code.providers.transports.http import maybe_await_aclose
17
  from free_claude_code.providers.transports.openai_chat import (
18
  OpenAIChatRequestPolicy,
 
32
  class GitHubModelsProvider(OpenAIChatTransport):
33
  """GitHub Models OpenAI-compatible inference provider."""
34
 
35
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
36
  self._catalog_url = GITHUB_MODELS_CATALOG_URL
37
  self._model_list_client = httpx.AsyncClient(
38
  proxy=config.proxy or None,
 
48
  provider_name="GITHUB_MODELS",
49
  base_url=config.base_url or GITHUB_MODELS_DEFAULT_BASE,
50
  api_key=config.api_key,
51
+ rate_limiter=rate_limiter,
52
  default_headers=_github_models_default_headers(),
53
  )
54
 
src/free_claude_code/providers/groq/client.py CHANGED
@@ -4,6 +4,7 @@ from typing import Any
4
 
5
  from free_claude_code.providers.base import ProviderConfig
6
  from free_claude_code.providers.defaults import GROQ_DEFAULT_BASE
 
7
  from free_claude_code.providers.transports.openai_chat import (
8
  OpenAIChatRequestPolicy,
9
  OpenAIChatTransport,
@@ -23,12 +24,13 @@ _REQUEST_POLICY = OpenAIChatRequestPolicy(
23
  class GroqProvider(OpenAIChatTransport):
24
  """Groq API using ``https://api.groq.com/openai/v1/chat/completions``."""
25
 
26
- def __init__(self, config: ProviderConfig):
27
  super().__init__(
28
  config,
29
  provider_name="GROQ",
30
  base_url=config.base_url or GROQ_DEFAULT_BASE,
31
  api_key=config.api_key,
 
32
  )
33
 
34
  def _build_request_body(
 
4
 
5
  from free_claude_code.providers.base import ProviderConfig
6
  from free_claude_code.providers.defaults import GROQ_DEFAULT_BASE
7
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
8
  from free_claude_code.providers.transports.openai_chat import (
9
  OpenAIChatRequestPolicy,
10
  OpenAIChatTransport,
 
24
  class GroqProvider(OpenAIChatTransport):
25
  """Groq API using ``https://api.groq.com/openai/v1/chat/completions``."""
26
 
27
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
28
  super().__init__(
29
  config,
30
  provider_name="GROQ",
31
  base_url=config.base_url or GROQ_DEFAULT_BASE,
32
  api_key=config.api_key,
33
+ rate_limiter=rate_limiter,
34
  )
35
 
36
  def _build_request_body(
src/free_claude_code/providers/huggingface/client.py CHANGED
@@ -8,18 +8,20 @@ from free_claude_code.core.anthropic.conversion import OpenAIConversionError
8
  from free_claude_code.providers.base import ProviderConfig
9
  from free_claude_code.providers.defaults import HUGGINGFACE_DEFAULT_BASE
10
  from free_claude_code.providers.exceptions import InvalidRequestError
 
11
  from free_claude_code.providers.transports.openai_chat import OpenAIChatTransport
12
 
13
 
14
  class HuggingFaceProvider(OpenAIChatTransport):
15
  """Hugging Face Inference Providers router at ``https://router.huggingface.co/v1``."""
16
 
17
- def __init__(self, config: ProviderConfig):
18
  super().__init__(
19
  config,
20
  provider_name="HUGGINGFACE",
21
  base_url=config.base_url or HUGGINGFACE_DEFAULT_BASE,
22
  api_key=config.api_key,
 
23
  )
24
 
25
  def _build_request_body(
 
8
  from free_claude_code.providers.base import ProviderConfig
9
  from free_claude_code.providers.defaults import HUGGINGFACE_DEFAULT_BASE
10
  from free_claude_code.providers.exceptions import InvalidRequestError
11
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
12
  from free_claude_code.providers.transports.openai_chat import OpenAIChatTransport
13
 
14
 
15
  class HuggingFaceProvider(OpenAIChatTransport):
16
  """Hugging Face Inference Providers router at ``https://router.huggingface.co/v1``."""
17
 
18
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
19
  super().__init__(
20
  config,
21
  provider_name="HUGGINGFACE",
22
  base_url=config.base_url or HUGGINGFACE_DEFAULT_BASE,
23
  api_key=config.api_key,
24
+ rate_limiter=rate_limiter,
25
  )
26
 
27
  def _build_request_body(
src/free_claude_code/providers/kimi/client.py CHANGED
@@ -5,6 +5,7 @@ from typing import Any
5
  from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
6
  from free_claude_code.providers.base import ProviderConfig
7
  from free_claude_code.providers.defaults import KIMI_DEFAULT_BASE
 
8
  from free_claude_code.providers.transports.openai_chat import (
9
  OpenAIChatRequestPolicy,
10
  OpenAIChatTransport,
@@ -23,12 +24,13 @@ _REQUEST_POLICY = OpenAIChatRequestPolicy(
23
  class KimiProvider(OpenAIChatTransport):
24
  """Kimi provider using ``https://api.moonshot.ai/v1/chat/completions``."""
25
 
26
- def __init__(self, config: ProviderConfig):
27
  super().__init__(
28
  config,
29
  provider_name="KIMI",
30
  base_url=config.base_url or KIMI_DEFAULT_BASE,
31
  api_key=config.api_key,
 
32
  )
33
 
34
  def _build_request_body(
 
5
  from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
6
  from free_claude_code.providers.base import ProviderConfig
7
  from free_claude_code.providers.defaults import KIMI_DEFAULT_BASE
8
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
9
  from free_claude_code.providers.transports.openai_chat import (
10
  OpenAIChatRequestPolicy,
11
  OpenAIChatTransport,
 
24
  class KimiProvider(OpenAIChatTransport):
25
  """Kimi provider using ``https://api.moonshot.ai/v1/chat/completions``."""
26
 
27
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
28
  super().__init__(
29
  config,
30
  provider_name="KIMI",
31
  base_url=config.base_url or KIMI_DEFAULT_BASE,
32
  api_key=config.api_key,
33
+ rate_limiter=rate_limiter,
34
  )
35
 
36
  def _build_request_body(
src/free_claude_code/providers/llamacpp/client.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from free_claude_code.providers.base import ProviderConfig
4
  from free_claude_code.providers.defaults import LLAMACPP_DEFAULT_BASE
 
5
  from free_claude_code.providers.transports.anthropic_messages import (
6
  AnthropicMessagesTransport,
7
  )
@@ -10,9 +11,10 @@ from free_claude_code.providers.transports.anthropic_messages import (
10
  class LlamaCppProvider(AnthropicMessagesTransport):
11
  """Llama.cpp provider using native Anthropic Messages endpoint."""
12
 
13
- def __init__(self, config: ProviderConfig):
14
  super().__init__(
15
  config,
16
  provider_name="LLAMACPP",
17
  default_base_url=LLAMACPP_DEFAULT_BASE,
 
18
  )
 
2
 
3
  from free_claude_code.providers.base import ProviderConfig
4
  from free_claude_code.providers.defaults import LLAMACPP_DEFAULT_BASE
5
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
6
  from free_claude_code.providers.transports.anthropic_messages import (
7
  AnthropicMessagesTransport,
8
  )
 
11
  class LlamaCppProvider(AnthropicMessagesTransport):
12
  """Llama.cpp provider using native Anthropic Messages endpoint."""
13
 
14
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
15
  super().__init__(
16
  config,
17
  provider_name="LLAMACPP",
18
  default_base_url=LLAMACPP_DEFAULT_BASE,
19
+ rate_limiter=rate_limiter,
20
  )
src/free_claude_code/providers/lmstudio/client.py CHANGED
@@ -26,6 +26,7 @@ from free_claude_code.core.anthropic.conversion import OpenAIConversionError
26
  from free_claude_code.providers.base import ProviderConfig
27
  from free_claude_code.providers.defaults import LMSTUDIO_DEFAULT_BASE
28
  from free_claude_code.providers.exceptions import InvalidRequestError
 
29
  from free_claude_code.providers.transports.openai_chat import OpenAIChatTransport
30
 
31
 
@@ -39,12 +40,13 @@ class LMStudioProvider(OpenAIChatTransport):
39
  # dying mid-stream.
40
  _CONTEXT_CACHE_TTL_S = 30.0
41
 
42
- def __init__(self, config: ProviderConfig):
43
  super().__init__(
44
  config,
45
  provider_name="LMSTUDIO",
46
  base_url=config.base_url or LMSTUDIO_DEFAULT_BASE,
47
  api_key=config.api_key or "lm-studio",
 
48
  )
49
  self._loaded_context_cache: tuple[float, int | None] = (0.0, None)
50
 
 
26
  from free_claude_code.providers.base import ProviderConfig
27
  from free_claude_code.providers.defaults import LMSTUDIO_DEFAULT_BASE
28
  from free_claude_code.providers.exceptions import InvalidRequestError
29
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
30
  from free_claude_code.providers.transports.openai_chat import OpenAIChatTransport
31
 
32
 
 
40
  # dying mid-stream.
41
  _CONTEXT_CACHE_TTL_S = 30.0
42
 
43
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
44
  super().__init__(
45
  config,
46
  provider_name="LMSTUDIO",
47
  base_url=config.base_url or LMSTUDIO_DEFAULT_BASE,
48
  api_key=config.api_key or "lm-studio",
49
+ rate_limiter=rate_limiter,
50
  )
51
  self._loaded_context_cache: tuple[float, int | None] = (0.0, None)
52
 
src/free_claude_code/providers/minimax/client.py CHANGED
@@ -5,6 +5,7 @@ from typing import Any
5
  from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
6
  from free_claude_code.providers.base import ProviderConfig
7
  from free_claude_code.providers.defaults import MINIMAX_DEFAULT_BASE
 
8
  from free_claude_code.providers.transports.openai_chat import (
9
  OpenAIChatRequestPolicy,
10
  OpenAIChatTransport,
@@ -21,12 +22,13 @@ _REQUEST_POLICY = OpenAIChatRequestPolicy(
21
  class MiniMaxProvider(OpenAIChatTransport):
22
  """MiniMax using ``https://api.minimax.io/v1/chat/completions``."""
23
 
24
- def __init__(self, config: ProviderConfig):
25
  super().__init__(
26
  config,
27
  provider_name="MINIMAX",
28
  base_url=config.base_url or MINIMAX_DEFAULT_BASE,
29
  api_key=config.api_key,
 
30
  )
31
 
32
  def _build_request_body(
 
5
  from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
6
  from free_claude_code.providers.base import ProviderConfig
7
  from free_claude_code.providers.defaults import MINIMAX_DEFAULT_BASE
8
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
9
  from free_claude_code.providers.transports.openai_chat import (
10
  OpenAIChatRequestPolicy,
11
  OpenAIChatTransport,
 
22
  class MiniMaxProvider(OpenAIChatTransport):
23
  """MiniMax using ``https://api.minimax.io/v1/chat/completions``."""
24
 
25
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
26
  super().__init__(
27
  config,
28
  provider_name="MINIMAX",
29
  base_url=config.base_url or MINIMAX_DEFAULT_BASE,
30
  api_key=config.api_key,
31
+ rate_limiter=rate_limiter,
32
  )
33
 
34
  def _build_request_body(
src/free_claude_code/providers/mistral/client.py CHANGED
@@ -6,6 +6,7 @@ from loguru import logger
6
 
7
  from free_claude_code.providers.base import ProviderConfig
8
  from free_claude_code.providers.defaults import MISTRAL_DEFAULT_BASE
 
9
  from free_claude_code.providers.transports.openai_chat import (
10
  OpenAIChatRequestPolicy,
11
  OpenAIChatTransport,
@@ -25,12 +26,13 @@ _REQUEST_POLICY = OpenAIChatRequestPolicy(provider_name="MISTRAL")
25
  class MistralProvider(OpenAIChatTransport):
26
  """Mistral API using ``https://api.mistral.ai/v1/chat/completions``."""
27
 
28
- def __init__(self, config: ProviderConfig):
29
  super().__init__(
30
  config,
31
  provider_name="MISTRAL",
32
  base_url=config.base_url or MISTRAL_DEFAULT_BASE,
33
  api_key=config.api_key,
 
34
  )
35
 
36
  def _build_request_body(
 
6
 
7
  from free_claude_code.providers.base import ProviderConfig
8
  from free_claude_code.providers.defaults import MISTRAL_DEFAULT_BASE
9
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
10
  from free_claude_code.providers.transports.openai_chat import (
11
  OpenAIChatRequestPolicy,
12
  OpenAIChatTransport,
 
26
  class MistralProvider(OpenAIChatTransport):
27
  """Mistral API using ``https://api.mistral.ai/v1/chat/completions``."""
28
 
29
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
30
  super().__init__(
31
  config,
32
  provider_name="MISTRAL",
33
  base_url=config.base_url or MISTRAL_DEFAULT_BASE,
34
  api_key=config.api_key,
35
+ rate_limiter=rate_limiter,
36
  )
37
 
38
  def _build_request_body(
src/free_claude_code/providers/nvidia_nim/client.py CHANGED
@@ -9,6 +9,7 @@ from loguru import logger
9
  from free_claude_code.config.nim import NimSettings
10
  from free_claude_code.providers.base import ProviderConfig
11
  from free_claude_code.providers.defaults import NVIDIA_NIM_DEFAULT_BASE
 
12
  from free_claude_code.providers.transports.openai_chat import OpenAIChatTransport
13
 
14
  from .request_options import build_nim_request_body
@@ -26,12 +27,19 @@ from .tool_schema import (
26
  class NvidiaNimProvider(OpenAIChatTransport):
27
  """NVIDIA NIM provider using official OpenAI client."""
28
 
29
- def __init__(self, config: ProviderConfig, *, nim_settings: NimSettings):
 
 
 
 
 
 
30
  super().__init__(
31
  config,
32
  provider_name="NIM",
33
  base_url=config.base_url or NVIDIA_NIM_DEFAULT_BASE,
34
  api_key=config.api_key,
 
35
  )
36
  self._nim_settings = nim_settings
37
 
 
9
  from free_claude_code.config.nim import NimSettings
10
  from free_claude_code.providers.base import ProviderConfig
11
  from free_claude_code.providers.defaults import NVIDIA_NIM_DEFAULT_BASE
12
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
13
  from free_claude_code.providers.transports.openai_chat import OpenAIChatTransport
14
 
15
  from .request_options import build_nim_request_body
 
27
  class NvidiaNimProvider(OpenAIChatTransport):
28
  """NVIDIA NIM provider using official OpenAI client."""
29
 
30
+ def __init__(
31
+ self,
32
+ config: ProviderConfig,
33
+ *,
34
+ nim_settings: NimSettings,
35
+ rate_limiter: ProviderRateLimiter,
36
+ ):
37
  super().__init__(
38
  config,
39
  provider_name="NIM",
40
  base_url=config.base_url or NVIDIA_NIM_DEFAULT_BASE,
41
  api_key=config.api_key,
42
+ rate_limiter=rate_limiter,
43
  )
44
  self._nim_settings = nim_settings
45
 
src/free_claude_code/providers/nvidia_nim/voice.py CHANGED
@@ -1,5 +1,6 @@
1
  """NVIDIA NIM / Riva offline ASR for voice notes (provider-owned transport)."""
2
 
 
3
  from pathlib import Path
4
 
5
  from loguru import logger
@@ -23,71 +24,90 @@ _NIM_ASR_MODEL_MAP: dict[str, tuple[str, str]] = {
23
  _RIVA_SERVER = "grpc.nvcf.nvidia.com:443"
24
 
25
 
26
- def transcribe_audio_file(
27
- file_path: Path,
28
- model: str,
29
- *,
30
- api_key: str,
31
- ) -> str:
32
- """Transcribe audio using NVIDIA NIM / Riva gRPC (offline recognition).
33
-
34
- Args:
35
- file_path: Path to encoded audio bytes readable by Riva.
36
- model: Hugging Face-style NIM model id (see ``_NIM_ASR_MODEL_MAP``).
37
- api_key: NVIDIA API key (Bearer token); must be non-empty.
38
-
39
- Returns:
40
- Transcript text, or ``(no speech detected)`` when empty.
41
- """
42
- key = (api_key or "").strip()
43
- if not key:
44
- raise ValueError(
45
- "NVIDIA NIM transcription requires a non-empty nvidia_nim_api_key "
46
- "(configure NVIDIA_NIM_API_KEY or pass api_key explicitly)."
47
- )
48
-
49
- try:
50
- import riva.client
51
- except ImportError as e:
52
- raise ImportError(
53
- "NVIDIA NIM transcription requires the voice extra. "
54
- "Install with: uv sync --extra voice"
55
- ) from e
56
-
57
- model_config = _NIM_ASR_MODEL_MAP.get(model)
58
- if not model_config:
59
- raise ValueError(
60
- f"No NVIDIA NIM config found for model: {model}. "
61
- f"Supported models: {', '.join(_NIM_ASR_MODEL_MAP.keys())}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  )
63
- function_id, language_code = model_config
64
-
65
- auth = riva.client.Auth(
66
- use_ssl=True,
67
- uri=_RIVA_SERVER,
68
- metadata_args=[
69
- ["function-id", function_id],
70
- ["authorization", f"Bearer {key}"],
71
- ],
72
- )
73
-
74
- asr_service = riva.client.ASRService(auth)
75
-
76
- config = riva.client.RecognitionConfig(
77
- language_code=language_code,
78
- max_alternatives=1,
79
- verbatim_transcripts=True,
80
- )
81
-
82
- with open(file_path, "rb") as f:
83
- data = f.read()
84
-
85
- response = asr_service.offline_recognize(data, config)
86
-
87
- transcript = ""
88
- results = getattr(response, "results", None)
89
- if results and results[0].alternatives:
90
- transcript = results[0].alternatives[0].transcript
91
-
92
- logger.debug(f"NIM transcription: {len(transcript)} chars")
93
- return transcript or "(no speech detected)"
 
1
  """NVIDIA NIM / Riva offline ASR for voice notes (provider-owned transport)."""
2
 
3
+ import asyncio
4
  from pathlib import Path
5
 
6
  from loguru import logger
 
24
  _RIVA_SERVER = "grpc.nvcf.nvidia.com:443"
25
 
26
 
27
+ class NvidiaNimTranscriber:
28
+ """Own configured NVIDIA NIM / Riva transcription."""
29
+
30
+ def __init__(self, *, model: str, api_key: str) -> None:
31
+ self._model = model
32
+ self._key = api_key.strip()
33
+ self._lock = asyncio.Lock()
34
+ self._closed = False
35
+
36
+ async def transcribe(self, file_path: Path) -> str:
37
+ """Transcribe one audio file without blocking the event loop."""
38
+ async with self._lock:
39
+ if self._closed:
40
+ raise RuntimeError("NVIDIA NIM transcriber is closed.")
41
+ worker = asyncio.create_task(
42
+ asyncio.to_thread(self._transcribe_sync, file_path)
43
+ )
44
+ try:
45
+ return await asyncio.shield(worker)
46
+ except asyncio.CancelledError:
47
+ await _wait_for_thread_exit(worker)
48
+ raise
49
+
50
+ async def close(self) -> None:
51
+ """Close this stateless adapter to future work."""
52
+ self._closed = True
53
+ async with self._lock:
54
+ self._key = ""
55
+
56
+ def _transcribe_sync(self, file_path: Path) -> str:
57
+ if not self._key:
58
+ raise ValueError(
59
+ "NVIDIA NIM transcription requires a non-empty "
60
+ "nvidia_nim_api_key (configure NVIDIA_NIM_API_KEY)."
61
+ )
62
+ model_config = _NIM_ASR_MODEL_MAP.get(self._model)
63
+ if model_config is None:
64
+ raise ValueError(
65
+ f"No NVIDIA NIM config found for model: {self._model}. "
66
+ f"Supported models: {', '.join(_NIM_ASR_MODEL_MAP)}"
67
+ )
68
+ function_id, language_code = model_config
69
+ try:
70
+ import riva.client
71
+ except ImportError as exc:
72
+ raise ImportError(
73
+ "NVIDIA NIM transcription requires the voice extra. "
74
+ "Install with: uv sync --extra voice"
75
+ ) from exc
76
+
77
+ auth = riva.client.Auth(
78
+ use_ssl=True,
79
+ uri=_RIVA_SERVER,
80
+ metadata_args=[
81
+ ["function-id", function_id],
82
+ ["authorization", f"Bearer {self._key}"],
83
+ ],
84
  )
85
+ try:
86
+ asr_service = riva.client.ASRService(auth)
87
+ config = riva.client.RecognitionConfig(
88
+ language_code=language_code,
89
+ max_alternatives=1,
90
+ verbatim_transcripts=True,
91
+ )
92
+ data = file_path.read_bytes()
93
+ response = asr_service.offline_recognize(data, config)
94
+
95
+ transcript = ""
96
+ results = getattr(response, "results", None)
97
+ if results and results[0].alternatives:
98
+ transcript = results[0].alternatives[0].transcript
99
+ logger.debug("NIM transcription: {} chars", len(transcript))
100
+ return transcript or "(no speech detected)"
101
+ finally:
102
+ auth.channel.close()
103
+
104
+
105
+ async def _wait_for_thread_exit(worker: asyncio.Task[str]) -> None:
106
+ """Wait through repeated caller cancellation without cancelling thread work."""
107
+ while not worker.done():
108
+ try:
109
+ await asyncio.shield(asyncio.wait((worker,)))
110
+ except asyncio.CancelledError:
111
+ continue
112
+ if not worker.cancelled():
113
+ worker.exception()
 
 
src/free_claude_code/providers/ollama/client.py CHANGED
@@ -5,6 +5,7 @@ import httpx
5
  from free_claude_code.providers.base import ProviderConfig
6
  from free_claude_code.providers.defaults import OLLAMA_DEFAULT_BASE
7
  from free_claude_code.providers.model_listing import extract_ollama_model_ids
 
8
  from free_claude_code.providers.transports.anthropic_messages import (
9
  AnthropicMessagesTransport,
10
  )
@@ -13,11 +14,12 @@ from free_claude_code.providers.transports.anthropic_messages import (
13
  class OllamaProvider(AnthropicMessagesTransport):
14
  """Ollama provider using native Anthropic Messages API."""
15
 
16
- def __init__(self, config: ProviderConfig):
17
  super().__init__(
18
  config,
19
  provider_name="OLLAMA",
20
  default_base_url=OLLAMA_DEFAULT_BASE,
 
21
  )
22
  self._api_key = config.api_key or "ollama"
23
 
 
5
  from free_claude_code.providers.base import ProviderConfig
6
  from free_claude_code.providers.defaults import OLLAMA_DEFAULT_BASE
7
  from free_claude_code.providers.model_listing import extract_ollama_model_ids
8
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
9
  from free_claude_code.providers.transports.anthropic_messages import (
10
  AnthropicMessagesTransport,
11
  )
 
14
  class OllamaProvider(AnthropicMessagesTransport):
15
  """Ollama provider using native Anthropic Messages API."""
16
 
17
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
18
  super().__init__(
19
  config,
20
  provider_name="OLLAMA",
21
  default_base_url=OLLAMA_DEFAULT_BASE,
22
+ rate_limiter=rate_limiter,
23
  )
24
  self._api_key = config.api_key or "ollama"
25
 
src/free_claude_code/providers/open_router/client.py CHANGED
@@ -13,6 +13,7 @@ from free_claude_code.providers.model_listing import (
13
  extract_openrouter_tool_model_ids,
14
  extract_openrouter_tool_model_infos,
15
  )
 
16
  from free_claude_code.providers.transports.openai_chat import (
17
  OpenAIChatRequestPolicy,
18
  OpenAIChatTransport,
@@ -33,12 +34,13 @@ _REQUEST_POLICY = OpenAIChatRequestPolicy(
33
  class OpenRouterProvider(OpenAIChatTransport):
34
  """OpenRouter provider using the OpenAI-compatible Chat Completions API."""
35
 
36
- def __init__(self, config: ProviderConfig):
37
  super().__init__(
38
  config,
39
  provider_name="OPENROUTER",
40
  base_url=config.base_url or OPENROUTER_DEFAULT_BASE,
41
  api_key=config.api_key,
 
42
  )
43
 
44
  def _build_request_body(
 
13
  extract_openrouter_tool_model_ids,
14
  extract_openrouter_tool_model_infos,
15
  )
16
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
17
  from free_claude_code.providers.transports.openai_chat import (
18
  OpenAIChatRequestPolicy,
19
  OpenAIChatTransport,
 
34
  class OpenRouterProvider(OpenAIChatTransport):
35
  """OpenRouter provider using the OpenAI-compatible Chat Completions API."""
36
 
37
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
38
  super().__init__(
39
  config,
40
  provider_name="OPENROUTER",
41
  base_url=config.base_url or OPENROUTER_DEFAULT_BASE,
42
  api_key=config.api_key,
43
+ rate_limiter=rate_limiter,
44
  )
45
 
46
  def _build_request_body(
src/free_claude_code/providers/opencode/client.py CHANGED
@@ -4,6 +4,7 @@ from typing import Any
4
 
5
  from free_claude_code.providers.base import ProviderConfig
6
  from free_claude_code.providers.defaults import OPENCODE_DEFAULT_BASE
 
7
  from free_claude_code.providers.transports.openai_chat import (
8
  OpenAIChatRequestPolicy,
9
  OpenAIChatTransport,
@@ -14,12 +15,19 @@ from free_claude_code.providers.transports.openai_chat import (
14
  class OpenCodeProvider(OpenAIChatTransport):
15
  """OpenCode Zen provider using ``https://opencode.ai/zen/v1/chat/completions``."""
16
 
17
- def __init__(self, config: ProviderConfig, provider_name: str = "OPENCODE"):
 
 
 
 
 
 
18
  super().__init__(
19
  config,
20
  provider_name=provider_name,
21
  base_url=config.base_url or OPENCODE_DEFAULT_BASE,
22
  api_key=config.api_key,
 
23
  )
24
  self._request_policy = OpenAIChatRequestPolicy(provider_name=provider_name)
25
 
 
4
 
5
  from free_claude_code.providers.base import ProviderConfig
6
  from free_claude_code.providers.defaults import OPENCODE_DEFAULT_BASE
7
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
8
  from free_claude_code.providers.transports.openai_chat import (
9
  OpenAIChatRequestPolicy,
10
  OpenAIChatTransport,
 
15
  class OpenCodeProvider(OpenAIChatTransport):
16
  """OpenCode Zen provider using ``https://opencode.ai/zen/v1/chat/completions``."""
17
 
18
+ def __init__(
19
+ self,
20
+ config: ProviderConfig,
21
+ provider_name: str = "OPENCODE",
22
+ *,
23
+ rate_limiter: ProviderRateLimiter,
24
+ ):
25
  super().__init__(
26
  config,
27
  provider_name=provider_name,
28
  base_url=config.base_url or OPENCODE_DEFAULT_BASE,
29
  api_key=config.api_key,
30
+ rate_limiter=rate_limiter,
31
  )
32
  self._request_policy = OpenAIChatRequestPolicy(provider_name=provider_name)
33
 
src/free_claude_code/providers/rate_limit.py CHANGED
@@ -1,11 +1,11 @@
1
- """Global rate limiter for API requests."""
2
 
3
  import asyncio
4
  import random
5
  import time
6
  from collections.abc import AsyncIterator, Callable
7
  from contextlib import asynccontextmanager
8
- from typing import Any, ClassVar, TypeVar
9
 
10
  import httpx
11
  import openai
@@ -58,11 +58,13 @@ def retryable_upstream_transport_error(exc: BaseException) -> bool:
58
  )
59
 
60
 
61
- class GlobalRateLimiter:
62
  """
63
- Global singleton rate limiter that blocks all requests
64
- when a rate limit error is encountered (reactive) and
65
- throttles requests (proactive) using a strict rolling window.
 
 
66
 
67
  Optionally enforces a max_concurrency cap: at most N provider streams
68
  may be open simultaneously, independent of the sliding window.
@@ -72,19 +74,12 @@ class GlobalRateLimiter:
72
  Concurrency limit - caps simultaneously open streams.
73
  """
74
 
75
- _instance: ClassVar[GlobalRateLimiter | None] = None
76
- _scoped_instances: ClassVar[dict[str, GlobalRateLimiter]] = {}
77
-
78
  def __init__(
79
  self,
80
  rate_limit: int = 40,
81
  rate_window: float = 60.0,
82
  max_concurrency: int = 5,
83
  ):
84
- # Prevent re-initialization on singleton reuse
85
- if hasattr(self, "_initialized"):
86
- return
87
-
88
  if rate_limit <= 0:
89
  raise ValueError("rate_limit must be > 0")
90
  if rate_window <= 0:
@@ -100,69 +95,10 @@ class GlobalRateLimiter:
100
  )
101
  self._blocked_until: float = 0
102
  self._concurrency_sem = asyncio.Semaphore(max_concurrency)
103
- self._initialized = True
104
-
105
  logger.info(
106
- f"GlobalRateLimiter (Provider) initialized ({rate_limit} req / {rate_window}s, max_concurrency={max_concurrency})"
107
- )
108
-
109
- @classmethod
110
- def get_instance(
111
- cls,
112
- rate_limit: int | None = None,
113
- rate_window: float | None = None,
114
- max_concurrency: int = 5,
115
- ) -> GlobalRateLimiter:
116
- """Get or create the singleton instance.
117
-
118
- Args:
119
- rate_limit: Requests per window (only used on first creation)
120
- rate_window: Window in seconds (only used on first creation)
121
- max_concurrency: Max simultaneous open streams (only used on first creation)
122
- """
123
- if cls._instance is None:
124
- cls._instance = cls(
125
- rate_limit=rate_limit or 40,
126
- rate_window=rate_window or 60.0,
127
- max_concurrency=max_concurrency,
128
- )
129
- return cls._instance
130
-
131
- @classmethod
132
- def get_scoped_instance(
133
- cls,
134
- scope: str,
135
- *,
136
- rate_limit: int | None = None,
137
- rate_window: float | None = None,
138
- max_concurrency: int = 5,
139
- ) -> GlobalRateLimiter:
140
- """Get or create a provider-scoped limiter instance."""
141
- if not scope:
142
- raise ValueError("scope must be non-empty")
143
- desired_rate_limit = rate_limit or 40
144
- desired_rate_window = float(rate_window or 60.0)
145
- existing = cls._scoped_instances.get(scope)
146
- if existing and existing.matches_config(
147
- desired_rate_limit, desired_rate_window, max_concurrency
148
- ):
149
- return existing
150
- if existing:
151
- logger.info(
152
- "Rebuilding provider rate limiter for updated scope '{}'", scope
153
- )
154
- cls._scoped_instances[scope] = cls(
155
- rate_limit=desired_rate_limit,
156
- rate_window=desired_rate_window,
157
- max_concurrency=max_concurrency,
158
  )
159
- return cls._scoped_instances[scope]
160
-
161
- @classmethod
162
- def reset_instance(cls) -> None:
163
- """Reset singleton (for testing)."""
164
- cls._instance = None
165
- cls._scoped_instances = {}
166
 
167
  async def wait_if_blocked(self) -> bool:
168
  """
@@ -177,7 +113,7 @@ class GlobalRateLimiter:
177
  if now < self._blocked_until:
178
  wait_time = self._blocked_until - now
179
  logger.warning(
180
- f"Global provider rate limit active (reactive), waiting {wait_time:.1f}s..."
181
  )
182
  await asyncio.sleep(wait_time)
183
  waited_reactively = True
@@ -197,28 +133,18 @@ class GlobalRateLimiter:
197
 
198
  def set_blocked(self, seconds: float = 60) -> None:
199
  """
200
- Set global block for specified seconds (reactive).
201
 
202
  Args:
203
  seconds: How long to block (default 60s)
204
  """
205
  self._blocked_until = time.monotonic() + seconds
206
- logger.warning(f"Global provider rate limit set for {seconds:.1f}s (reactive)")
207
 
208
  def is_blocked(self) -> bool:
209
  """Check if currently reactively blocked."""
210
  return time.monotonic() < self._blocked_until
211
 
212
- def matches_config(
213
- self, rate_limit: int, rate_window: float, max_concurrency: int
214
- ) -> bool:
215
- """Return whether this limiter matches the requested runtime config."""
216
- return (
217
- self._rate_limit == rate_limit
218
- and self._rate_window == float(rate_window)
219
- and self._max_concurrency == max_concurrency
220
- )
221
-
222
  def remaining_wait(self) -> float:
223
  """Get remaining reactive wait time in seconds."""
224
  return max(0.0, self._blocked_until - time.monotonic())
 
1
+ """Provider-owned upstream rate limiting and retry policy."""
2
 
3
  import asyncio
4
  import random
5
  import time
6
  from collections.abc import AsyncIterator, Callable
7
  from contextlib import asynccontextmanager
8
+ from typing import Any, TypeVar
9
 
10
  import httpx
11
  import openai
 
58
  )
59
 
60
 
61
+ class ProviderRateLimiter:
62
  """
63
+ Rate limiter owned by one provider instance.
64
+
65
+ Blocks that provider's requests when a rate-limit error is encountered
66
+ (reactive) and throttles its requests with a strict rolling window
67
+ (proactive).
68
 
69
  Optionally enforces a max_concurrency cap: at most N provider streams
70
  may be open simultaneously, independent of the sliding window.
 
74
  Concurrency limit - caps simultaneously open streams.
75
  """
76
 
 
 
 
77
  def __init__(
78
  self,
79
  rate_limit: int = 40,
80
  rate_window: float = 60.0,
81
  max_concurrency: int = 5,
82
  ):
 
 
 
 
83
  if rate_limit <= 0:
84
  raise ValueError("rate_limit must be > 0")
85
  if rate_window <= 0:
 
95
  )
96
  self._blocked_until: float = 0
97
  self._concurrency_sem = asyncio.Semaphore(max_concurrency)
 
 
98
  logger.info(
99
+ "ProviderRateLimiter initialized "
100
+ f"({rate_limit} req / {rate_window}s, max_concurrency={max_concurrency})"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  )
 
 
 
 
 
 
 
102
 
103
  async def wait_if_blocked(self) -> bool:
104
  """
 
113
  if now < self._blocked_until:
114
  wait_time = self._blocked_until - now
115
  logger.warning(
116
+ f"Provider rate limit active (reactive), waiting {wait_time:.1f}s..."
117
  )
118
  await asyncio.sleep(wait_time)
119
  waited_reactively = True
 
133
 
134
  def set_blocked(self, seconds: float = 60) -> None:
135
  """
136
+ Set this provider's block for the specified seconds (reactive).
137
 
138
  Args:
139
  seconds: How long to block (default 60s)
140
  """
141
  self._blocked_until = time.monotonic() + seconds
142
+ logger.warning(f"Provider rate limit set for {seconds:.1f}s (reactive)")
143
 
144
  def is_blocked(self) -> bool:
145
  """Check if currently reactively blocked."""
146
  return time.monotonic() < self._blocked_until
147
 
 
 
 
 
 
 
 
 
 
 
148
  def remaining_wait(self) -> float:
149
  """Get remaining reactive wait time in seconds."""
150
  return max(0.0, self._blocked_until - time.monotonic())
src/free_claude_code/providers/runtime/cache.py CHANGED
@@ -1,5 +1,6 @@
1
  """Provider instance cache and cleanup."""
2
 
 
3
  from collections.abc import Callable, MutableMapping
4
 
5
  from free_claude_code.config.settings import Settings
@@ -35,17 +36,18 @@ class ProviderCache:
35
  return self._providers[provider_id]
36
 
37
  async def cleanup(self) -> None:
38
- """Clean up every cached provider, then clear the cache."""
39
  items = list(self._providers.items())
40
  errors: list[Exception] = []
41
- try:
42
- for _provider_id, provider in items:
43
- try:
44
- await provider.cleanup()
45
- except Exception as exc:
46
- errors.append(exc)
47
- finally:
48
- self._providers.clear()
 
49
  if len(errors) == 1:
50
  raise errors[0]
51
  if len(errors) > 1:
 
1
  """Provider instance cache and cleanup."""
2
 
3
+ import asyncio
4
  from collections.abc import Callable, MutableMapping
5
 
6
  from free_claude_code.config.settings import Settings
 
36
  return self._providers[provider_id]
37
 
38
  async def cleanup(self) -> None:
39
+ """Clean every cached provider, retaining unfinished entries for retry."""
40
  items = list(self._providers.items())
41
  errors: list[Exception] = []
42
+ for provider_id, provider in items:
43
+ try:
44
+ await provider.cleanup()
45
+ except asyncio.CancelledError:
46
+ raise
47
+ except Exception as exc:
48
+ errors.append(exc)
49
+ else:
50
+ self._providers.pop(provider_id, None)
51
  if len(errors) == 1:
52
  raise errors[0]
53
  if len(errors) > 1:
src/free_claude_code/providers/runtime/factory.py CHANGED
@@ -9,156 +9,265 @@ from free_claude_code.config.provider_catalog import (
9
  from free_claude_code.config.settings import Settings
10
  from free_claude_code.providers.base import BaseProvider, ProviderConfig
11
  from free_claude_code.providers.exceptions import UnknownProviderTypeError
 
12
 
13
  from .config import build_provider_config
14
 
15
- ProviderFactory = Callable[[ProviderConfig, Settings], BaseProvider]
 
 
16
 
17
 
18
- def _create_nvidia_nim(config: ProviderConfig, settings: Settings) -> BaseProvider:
 
 
 
 
19
  from free_claude_code.providers.nvidia_nim import NvidiaNimProvider
20
 
21
- return NvidiaNimProvider(config, nim_settings=settings.nim)
 
 
 
 
22
 
23
 
24
- def _create_open_router(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
25
  from free_claude_code.providers.open_router import OpenRouterProvider
26
 
27
- return OpenRouterProvider(config)
28
 
29
 
30
- def _create_mistral(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
31
  from free_claude_code.providers.mistral import MistralProvider
32
 
33
- return MistralProvider(config)
34
 
35
 
36
  def _create_mistral_codestral(
37
- config: ProviderConfig, _settings: Settings
 
 
38
  ) -> BaseProvider:
39
  from free_claude_code.providers.codestral import CodestralProvider
40
 
41
- return CodestralProvider(config)
42
 
43
 
44
- def _create_deepseek(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
45
  from free_claude_code.providers.deepseek import DeepSeekProvider
46
 
47
- return DeepSeekProvider(config)
48
 
49
 
50
- def _create_lmstudio(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
51
  from free_claude_code.providers.lmstudio import LMStudioProvider
52
 
53
- return LMStudioProvider(config)
54
 
55
 
56
- def _create_llamacpp(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
57
  from free_claude_code.providers.llamacpp import LlamaCppProvider
58
 
59
- return LlamaCppProvider(config)
60
 
61
 
62
- def _create_ollama(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
63
  from free_claude_code.providers.ollama import OllamaProvider
64
 
65
- return OllamaProvider(config)
66
 
67
 
68
- def _create_kimi(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
69
  from free_claude_code.providers.kimi import KimiProvider
70
 
71
- return KimiProvider(config)
72
 
73
 
74
- def _create_wafer(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
75
  from free_claude_code.providers.wafer import WaferProvider
76
 
77
- return WaferProvider(config)
78
 
79
 
80
- def _create_minimax(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
81
  from free_claude_code.providers.minimax import MiniMaxProvider
82
 
83
- return MiniMaxProvider(config)
84
 
85
 
86
- def _create_opencode(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
87
  from free_claude_code.providers.opencode import OpenCodeProvider
88
 
89
- return OpenCodeProvider(config)
90
 
91
 
92
- def _create_opencode_go(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
93
  from free_claude_code.providers.opencode import OpenCodeProvider
94
 
95
- return OpenCodeProvider(config, provider_name="OPENCODE_GO")
 
 
 
 
96
 
97
 
98
- def _create_vercel(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
99
  from free_claude_code.providers.vercel import VercelProvider
100
 
101
- return VercelProvider(config)
102
 
103
 
104
- def _create_huggingface(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
105
  from free_claude_code.providers.huggingface import HuggingFaceProvider
106
 
107
- return HuggingFaceProvider(config)
108
 
109
 
110
- def _create_cohere(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
111
  from free_claude_code.providers.cohere import CohereProvider
112
 
113
- return CohereProvider(config)
114
 
115
 
116
- def _create_github_models(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
117
  from free_claude_code.providers.github_models import GitHubModelsProvider
118
 
119
- return GitHubModelsProvider(config)
120
 
121
 
122
- def _create_zai(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
123
  from free_claude_code.providers.zai import ZaiProvider
124
 
125
- return ZaiProvider(config)
126
 
127
 
128
- def _create_fireworks(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
129
  from free_claude_code.providers.fireworks import FireworksProvider
130
 
131
- return FireworksProvider(config)
132
 
133
 
134
- def _create_cloudflare(config: ProviderConfig, settings: Settings) -> BaseProvider:
 
 
 
 
135
  from free_claude_code.providers.cloudflare import CloudflareProvider
136
 
137
- return CloudflareProvider(config, account_id=settings.cloudflare_account_id)
 
 
 
 
138
 
139
 
140
- def _create_gemini(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
141
  from free_claude_code.providers.gemini import GeminiProvider
142
 
143
- return GeminiProvider(config)
144
 
145
 
146
- def _create_groq(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
147
  from free_claude_code.providers.groq import GroqProvider
148
 
149
- return GroqProvider(config)
150
 
151
 
152
- def _create_sambanova(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
153
  from free_claude_code.providers.sambanova import SambaNovaProvider
154
 
155
- return SambaNovaProvider(config)
156
 
157
 
158
- def _create_cerebras(config: ProviderConfig, _settings: Settings) -> BaseProvider:
 
 
 
 
159
  from free_claude_code.providers.cerebras import CerebrasProvider
160
 
161
- return CerebrasProvider(config)
162
 
163
 
164
  PROVIDER_FACTORIES: dict[str, ProviderFactory] = {
@@ -210,4 +319,10 @@ def create_provider(provider_id: str, settings: Settings) -> BaseProvider:
210
  factory = PROVIDER_FACTORIES.get(provider_id)
211
  if factory is None:
212
  raise AssertionError(f"Unhandled provider descriptor: {provider_id}")
213
- return factory(build_provider_config(descriptor, settings), settings)
 
 
 
 
 
 
 
9
  from free_claude_code.config.settings import Settings
10
  from free_claude_code.providers.base import BaseProvider, ProviderConfig
11
  from free_claude_code.providers.exceptions import UnknownProviderTypeError
12
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
13
 
14
  from .config import build_provider_config
15
 
16
+ ProviderFactory = Callable[
17
+ [ProviderConfig, Settings, ProviderRateLimiter], BaseProvider
18
+ ]
19
 
20
 
21
+ def _create_nvidia_nim(
22
+ config: ProviderConfig,
23
+ settings: Settings,
24
+ rate_limiter: ProviderRateLimiter,
25
+ ) -> BaseProvider:
26
  from free_claude_code.providers.nvidia_nim import NvidiaNimProvider
27
 
28
+ return NvidiaNimProvider(
29
+ config,
30
+ nim_settings=settings.nim,
31
+ rate_limiter=rate_limiter,
32
+ )
33
 
34
 
35
+ def _create_open_router(
36
+ config: ProviderConfig,
37
+ _settings: Settings,
38
+ rate_limiter: ProviderRateLimiter,
39
+ ) -> BaseProvider:
40
  from free_claude_code.providers.open_router import OpenRouterProvider
41
 
42
+ return OpenRouterProvider(config, rate_limiter=rate_limiter)
43
 
44
 
45
+ def _create_mistral(
46
+ config: ProviderConfig,
47
+ _settings: Settings,
48
+ rate_limiter: ProviderRateLimiter,
49
+ ) -> BaseProvider:
50
  from free_claude_code.providers.mistral import MistralProvider
51
 
52
+ return MistralProvider(config, rate_limiter=rate_limiter)
53
 
54
 
55
  def _create_mistral_codestral(
56
+ config: ProviderConfig,
57
+ _settings: Settings,
58
+ rate_limiter: ProviderRateLimiter,
59
  ) -> BaseProvider:
60
  from free_claude_code.providers.codestral import CodestralProvider
61
 
62
+ return CodestralProvider(config, rate_limiter=rate_limiter)
63
 
64
 
65
+ def _create_deepseek(
66
+ config: ProviderConfig,
67
+ _settings: Settings,
68
+ rate_limiter: ProviderRateLimiter,
69
+ ) -> BaseProvider:
70
  from free_claude_code.providers.deepseek import DeepSeekProvider
71
 
72
+ return DeepSeekProvider(config, rate_limiter=rate_limiter)
73
 
74
 
75
+ def _create_lmstudio(
76
+ config: ProviderConfig,
77
+ _settings: Settings,
78
+ rate_limiter: ProviderRateLimiter,
79
+ ) -> BaseProvider:
80
  from free_claude_code.providers.lmstudio import LMStudioProvider
81
 
82
+ return LMStudioProvider(config, rate_limiter=rate_limiter)
83
 
84
 
85
+ def _create_llamacpp(
86
+ config: ProviderConfig,
87
+ _settings: Settings,
88
+ rate_limiter: ProviderRateLimiter,
89
+ ) -> BaseProvider:
90
  from free_claude_code.providers.llamacpp import LlamaCppProvider
91
 
92
+ return LlamaCppProvider(config, rate_limiter=rate_limiter)
93
 
94
 
95
+ def _create_ollama(
96
+ config: ProviderConfig,
97
+ _settings: Settings,
98
+ rate_limiter: ProviderRateLimiter,
99
+ ) -> BaseProvider:
100
  from free_claude_code.providers.ollama import OllamaProvider
101
 
102
+ return OllamaProvider(config, rate_limiter=rate_limiter)
103
 
104
 
105
+ def _create_kimi(
106
+ config: ProviderConfig,
107
+ _settings: Settings,
108
+ rate_limiter: ProviderRateLimiter,
109
+ ) -> BaseProvider:
110
  from free_claude_code.providers.kimi import KimiProvider
111
 
112
+ return KimiProvider(config, rate_limiter=rate_limiter)
113
 
114
 
115
+ def _create_wafer(
116
+ config: ProviderConfig,
117
+ _settings: Settings,
118
+ rate_limiter: ProviderRateLimiter,
119
+ ) -> BaseProvider:
120
  from free_claude_code.providers.wafer import WaferProvider
121
 
122
+ return WaferProvider(config, rate_limiter=rate_limiter)
123
 
124
 
125
+ def _create_minimax(
126
+ config: ProviderConfig,
127
+ _settings: Settings,
128
+ rate_limiter: ProviderRateLimiter,
129
+ ) -> BaseProvider:
130
  from free_claude_code.providers.minimax import MiniMaxProvider
131
 
132
+ return MiniMaxProvider(config, rate_limiter=rate_limiter)
133
 
134
 
135
+ def _create_opencode(
136
+ config: ProviderConfig,
137
+ _settings: Settings,
138
+ rate_limiter: ProviderRateLimiter,
139
+ ) -> BaseProvider:
140
  from free_claude_code.providers.opencode import OpenCodeProvider
141
 
142
+ return OpenCodeProvider(config, rate_limiter=rate_limiter)
143
 
144
 
145
+ def _create_opencode_go(
146
+ config: ProviderConfig,
147
+ _settings: Settings,
148
+ rate_limiter: ProviderRateLimiter,
149
+ ) -> BaseProvider:
150
  from free_claude_code.providers.opencode import OpenCodeProvider
151
 
152
+ return OpenCodeProvider(
153
+ config,
154
+ provider_name="OPENCODE_GO",
155
+ rate_limiter=rate_limiter,
156
+ )
157
 
158
 
159
+ def _create_vercel(
160
+ config: ProviderConfig,
161
+ _settings: Settings,
162
+ rate_limiter: ProviderRateLimiter,
163
+ ) -> BaseProvider:
164
  from free_claude_code.providers.vercel import VercelProvider
165
 
166
+ return VercelProvider(config, rate_limiter=rate_limiter)
167
 
168
 
169
+ def _create_huggingface(
170
+ config: ProviderConfig,
171
+ _settings: Settings,
172
+ rate_limiter: ProviderRateLimiter,
173
+ ) -> BaseProvider:
174
  from free_claude_code.providers.huggingface import HuggingFaceProvider
175
 
176
+ return HuggingFaceProvider(config, rate_limiter=rate_limiter)
177
 
178
 
179
+ def _create_cohere(
180
+ config: ProviderConfig,
181
+ _settings: Settings,
182
+ rate_limiter: ProviderRateLimiter,
183
+ ) -> BaseProvider:
184
  from free_claude_code.providers.cohere import CohereProvider
185
 
186
+ return CohereProvider(config, rate_limiter=rate_limiter)
187
 
188
 
189
+ def _create_github_models(
190
+ config: ProviderConfig,
191
+ _settings: Settings,
192
+ rate_limiter: ProviderRateLimiter,
193
+ ) -> BaseProvider:
194
  from free_claude_code.providers.github_models import GitHubModelsProvider
195
 
196
+ return GitHubModelsProvider(config, rate_limiter=rate_limiter)
197
 
198
 
199
+ def _create_zai(
200
+ config: ProviderConfig,
201
+ _settings: Settings,
202
+ rate_limiter: ProviderRateLimiter,
203
+ ) -> BaseProvider:
204
  from free_claude_code.providers.zai import ZaiProvider
205
 
206
+ return ZaiProvider(config, rate_limiter=rate_limiter)
207
 
208
 
209
+ def _create_fireworks(
210
+ config: ProviderConfig,
211
+ _settings: Settings,
212
+ rate_limiter: ProviderRateLimiter,
213
+ ) -> BaseProvider:
214
  from free_claude_code.providers.fireworks import FireworksProvider
215
 
216
+ return FireworksProvider(config, rate_limiter=rate_limiter)
217
 
218
 
219
+ def _create_cloudflare(
220
+ config: ProviderConfig,
221
+ settings: Settings,
222
+ rate_limiter: ProviderRateLimiter,
223
+ ) -> BaseProvider:
224
  from free_claude_code.providers.cloudflare import CloudflareProvider
225
 
226
+ return CloudflareProvider(
227
+ config,
228
+ account_id=settings.cloudflare_account_id,
229
+ rate_limiter=rate_limiter,
230
+ )
231
 
232
 
233
+ def _create_gemini(
234
+ config: ProviderConfig,
235
+ _settings: Settings,
236
+ rate_limiter: ProviderRateLimiter,
237
+ ) -> BaseProvider:
238
  from free_claude_code.providers.gemini import GeminiProvider
239
 
240
+ return GeminiProvider(config, rate_limiter=rate_limiter)
241
 
242
 
243
+ def _create_groq(
244
+ config: ProviderConfig,
245
+ _settings: Settings,
246
+ rate_limiter: ProviderRateLimiter,
247
+ ) -> BaseProvider:
248
  from free_claude_code.providers.groq import GroqProvider
249
 
250
+ return GroqProvider(config, rate_limiter=rate_limiter)
251
 
252
 
253
+ def _create_sambanova(
254
+ config: ProviderConfig,
255
+ _settings: Settings,
256
+ rate_limiter: ProviderRateLimiter,
257
+ ) -> BaseProvider:
258
  from free_claude_code.providers.sambanova import SambaNovaProvider
259
 
260
+ return SambaNovaProvider(config, rate_limiter=rate_limiter)
261
 
262
 
263
+ def _create_cerebras(
264
+ config: ProviderConfig,
265
+ _settings: Settings,
266
+ rate_limiter: ProviderRateLimiter,
267
+ ) -> BaseProvider:
268
  from free_claude_code.providers.cerebras import CerebrasProvider
269
 
270
+ return CerebrasProvider(config, rate_limiter=rate_limiter)
271
 
272
 
273
  PROVIDER_FACTORIES: dict[str, ProviderFactory] = {
 
319
  factory = PROVIDER_FACTORIES.get(provider_id)
320
  if factory is None:
321
  raise AssertionError(f"Unhandled provider descriptor: {provider_id}")
322
+ config = build_provider_config(descriptor, settings)
323
+ rate_limiter = ProviderRateLimiter(
324
+ rate_limit=config.rate_limit or 40,
325
+ rate_window=config.rate_window or 60.0,
326
+ max_concurrency=config.max_concurrency,
327
+ )
328
+ return factory(config, settings, rate_limiter)
src/free_claude_code/providers/sambanova/client.py CHANGED
@@ -4,6 +4,7 @@ from typing import Any
4
 
5
  from free_claude_code.providers.base import ProviderConfig
6
  from free_claude_code.providers.defaults import SAMBANOVA_DEFAULT_BASE
 
7
  from free_claude_code.providers.transports.openai_chat import (
8
  OpenAIChatRequestPolicy,
9
  OpenAIChatTransport,
@@ -19,12 +20,13 @@ _REQUEST_POLICY = OpenAIChatRequestPolicy(
19
  class SambaNovaProvider(OpenAIChatTransport):
20
  """SambaNova Cloud API at ``https://api.sambanova.ai/v1``."""
21
 
22
- def __init__(self, config: ProviderConfig):
23
  super().__init__(
24
  config,
25
  provider_name="SAMBANOVA",
26
  base_url=config.base_url or SAMBANOVA_DEFAULT_BASE,
27
  api_key=config.api_key,
 
28
  )
29
 
30
  def _build_request_body(
 
4
 
5
  from free_claude_code.providers.base import ProviderConfig
6
  from free_claude_code.providers.defaults import SAMBANOVA_DEFAULT_BASE
7
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
8
  from free_claude_code.providers.transports.openai_chat import (
9
  OpenAIChatRequestPolicy,
10
  OpenAIChatTransport,
 
20
  class SambaNovaProvider(OpenAIChatTransport):
21
  """SambaNova Cloud API at ``https://api.sambanova.ai/v1``."""
22
 
23
+ def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter):
24
  super().__init__(
25
  config,
26
  provider_name="SAMBANOVA",
27
  base_url=config.base_url or SAMBANOVA_DEFAULT_BASE,
28
  api_key=config.api_key,
29
+ rate_limiter=rate_limiter,
30
  )
31
 
32
  def _build_request_body(
src/free_claude_code/providers/transports/anthropic_messages/recovery.py CHANGED
@@ -48,10 +48,8 @@ class AnthropicMessagesRecovery:
48
  for attempt in range(MIDSTREAM_RECOVERY_ATTEMPTS):
49
  response: httpx.Response | None = None
50
  try:
51
- response = (
52
- await self._transport._global_rate_limiter.execute_with_retry(
53
- self._transport._validated_stream_send, body, req_tag=req_tag
54
- )
55
  )
56
  state = self._transport._new_stream_state(
57
  None, thinking_enabled=thinking_enabled
 
48
  for attempt in range(MIDSTREAM_RECOVERY_ATTEMPTS):
49
  response: httpx.Response | None = None
50
  try:
51
+ response = await self._transport._rate_limiter.execute_with_retry(
52
+ self._transport._validated_stream_send, body, req_tag=req_tag
 
 
53
  )
54
  state = self._transport._new_stream_state(
55
  None, thinking_enabled=thinking_enabled
src/free_claude_code/providers/transports/anthropic_messages/stream.py CHANGED
@@ -91,16 +91,14 @@ class AnthropicMessagesStreamAdapter:
91
  ledger = self._new_ledger()
92
  recovery = RecoveryController(provider_name=tag, request_id=self._request_id)
93
 
94
- async with self._transport._global_rate_limiter.concurrency_slot():
95
  while True:
96
  stream_opened = False
97
  try:
98
- response = (
99
- await self._transport._global_rate_limiter.execute_with_retry(
100
- self._transport._validated_stream_send,
101
- body,
102
- req_tag=req_tag,
103
- )
104
  )
105
  stream_opened = True
106
  chunk_count = 0
@@ -261,7 +259,7 @@ class AnthropicMessagesStreamAdapter:
261
  provider_name=tag,
262
  read_timeout_s=self._transport._config.http_read_timeout,
263
  request_id=self._request_id,
264
- rate_limiter=self._transport._global_rate_limiter,
265
  ) from error
266
  return
267
  finally:
 
91
  ledger = self._new_ledger()
92
  recovery = RecoveryController(provider_name=tag, request_id=self._request_id)
93
 
94
+ async with self._transport._rate_limiter.concurrency_slot():
95
  while True:
96
  stream_opened = False
97
  try:
98
+ response = await self._transport._rate_limiter.execute_with_retry(
99
+ self._transport._validated_stream_send,
100
+ body,
101
+ req_tag=req_tag,
 
 
102
  )
103
  stream_opened = True
104
  chunk_count = 0
 
259
  provider_name=tag,
260
  read_timeout_s=self._transport._config.http_read_timeout,
261
  request_id=self._request_id,
262
+ rate_limiter=self._transport._rate_limiter,
263
  ) from error
264
  return
265
  finally:
src/free_claude_code/providers/transports/anthropic_messages/transport.py CHANGED
@@ -20,7 +20,7 @@ from free_claude_code.providers.model_listing import (
20
  extract_openai_model_ids,
21
  model_infos_from_ids,
22
  )
23
- from free_claude_code.providers.rate_limit import GlobalRateLimiter
24
  from free_claude_code.providers.transports.http import maybe_await_aclose
25
 
26
  from .http import model_list_json, raise_for_status_with_body
@@ -44,18 +44,14 @@ class AnthropicMessagesTransport(BaseProvider):
44
  *,
45
  provider_name: str,
46
  default_base_url: str,
 
47
  ):
48
  super().__init__(config)
49
  self._provider_name = provider_name
50
  self._api_key = config.api_key
51
  self._base_url = (config.base_url or default_base_url).rstrip("/")
52
  self._request_policy = NativeMessagesRequestPolicy(provider_name=provider_name)
53
- self._global_rate_limiter = GlobalRateLimiter.get_scoped_instance(
54
- provider_name.lower(),
55
- rate_limit=config.rate_limit,
56
- rate_window=config.rate_window,
57
- max_concurrency=config.max_concurrency,
58
- )
59
  self._client = httpx.AsyncClient(
60
  base_url=self._base_url,
61
  proxy=config.proxy or None,
@@ -182,7 +178,7 @@ class AnthropicMessagesTransport(BaseProvider):
182
  self, error: Exception, request_id: str | None
183
  ) -> tuple[Exception, str]:
184
  """Map an exception into a user-facing provider error message."""
185
- mapped_error = map_error(error, rate_limiter=self._global_rate_limiter)
186
  return (
187
  mapped_error,
188
  user_visible_message_for_mapped_provider_error(
 
20
  extract_openai_model_ids,
21
  model_infos_from_ids,
22
  )
23
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
24
  from free_claude_code.providers.transports.http import maybe_await_aclose
25
 
26
  from .http import model_list_json, raise_for_status_with_body
 
44
  *,
45
  provider_name: str,
46
  default_base_url: str,
47
+ rate_limiter: ProviderRateLimiter,
48
  ):
49
  super().__init__(config)
50
  self._provider_name = provider_name
51
  self._api_key = config.api_key
52
  self._base_url = (config.base_url or default_base_url).rstrip("/")
53
  self._request_policy = NativeMessagesRequestPolicy(provider_name=provider_name)
54
+ self._rate_limiter = rate_limiter
 
 
 
 
 
55
  self._client = httpx.AsyncClient(
56
  base_url=self._base_url,
57
  proxy=config.proxy or None,
 
178
  self, error: Exception, request_id: str | None
179
  ) -> tuple[Exception, str]:
180
  """Map an exception into a user-facing provider error message."""
181
+ mapped_error = map_error(error, rate_limiter=self._rate_limiter)
182
  return (
183
  mapped_error,
184
  user_visible_message_for_mapped_provider_error(
src/free_claude_code/providers/transports/openai_chat/stream.py CHANGED
@@ -104,7 +104,7 @@ class OpenAIChatStreamAdapter:
104
  tool_argument_aliases: dict[str, dict[str, str]] = {}
105
  tool_argument_alias_buffers: dict[int, str] = {}
106
 
107
- async with self._transport._global_rate_limiter.concurrency_slot():
108
  while True:
109
  if not ledger.message_started:
110
  for event in hold_event(ledger.message_start()):
@@ -306,7 +306,7 @@ class OpenAIChatStreamAdapter:
306
  provider_name=tag,
307
  read_timeout_s=self._transport._config.http_read_timeout,
308
  request_id=self._request_id,
309
- rate_limiter=self._transport._global_rate_limiter,
310
  ) from error
311
  for event in ledger.terminal_error_tail(
312
  error_message,
 
104
  tool_argument_aliases: dict[str, dict[str, str]] = {}
105
  tool_argument_alias_buffers: dict[int, str] = {}
106
 
107
+ async with self._transport._rate_limiter.concurrency_slot():
108
  while True:
109
  if not ledger.message_started:
110
  for event in hold_event(ledger.message_start()):
 
306
  provider_name=tag,
307
  read_timeout_s=self._transport._config.http_read_timeout,
308
  request_id=self._request_id,
309
+ rate_limiter=self._transport._rate_limiter,
310
  ) from error
311
  for event in ledger.terminal_error_tail(
312
  error_message,
src/free_claude_code/providers/transports/openai_chat/transport.py CHANGED
@@ -16,7 +16,7 @@ from free_claude_code.providers.error_mapping import (
16
  user_visible_message_for_mapped_provider_error,
17
  )
18
  from free_claude_code.providers.model_listing import extract_openai_model_ids
19
- from free_claude_code.providers.rate_limit import GlobalRateLimiter
20
 
21
  from .output_cap import clamp_output_tokens, parse_output_token_cap
22
  from .stream import OpenAIChatStreamAdapter
@@ -33,6 +33,7 @@ class OpenAIChatTransport(BaseProvider):
33
  provider_name: str,
34
  base_url: str,
35
  api_key: str,
 
36
  default_headers: Mapping[str, str] | None = None,
37
  ):
38
  super().__init__(config)
@@ -42,12 +43,7 @@ class OpenAIChatTransport(BaseProvider):
42
  # Learned per-model output-token caps from upstream 400 rejections, so
43
  # later requests clamp proactively instead of paying the 400 each time.
44
  self._model_output_caps: dict[str, int] = {}
45
- self._global_rate_limiter = GlobalRateLimiter.get_scoped_instance(
46
- provider_name.lower(),
47
- rate_limit=config.rate_limit,
48
- rate_window=config.rate_window,
49
- max_concurrency=config.max_concurrency,
50
- )
51
  http_client = None
52
  if config.proxy:
53
  http_client = httpx.AsyncClient(
@@ -125,7 +121,7 @@ class OpenAIChatTransport(BaseProvider):
125
  while True:
126
  try:
127
  create_body = self._prepare_create_body(body)
128
- stream = await self._global_rate_limiter.execute_with_retry(
129
  self._client.chat.completions.create, **create_body, stream=True
130
  )
131
  return stream, body
@@ -198,7 +194,7 @@ class OpenAIChatTransport(BaseProvider):
198
  def _map_error_details(
199
  self, error: Exception, request_id: str | None
200
  ) -> tuple[Exception, str]:
201
- mapped_error = map_error(error, rate_limiter=self._global_rate_limiter)
202
  return (
203
  mapped_error,
204
  user_visible_message_for_mapped_provider_error(
 
16
  user_visible_message_for_mapped_provider_error,
17
  )
18
  from free_claude_code.providers.model_listing import extract_openai_model_ids
19
+ from free_claude_code.providers.rate_limit import ProviderRateLimiter
20
 
21
  from .output_cap import clamp_output_tokens, parse_output_token_cap
22
  from .stream import OpenAIChatStreamAdapter
 
33
  provider_name: str,
34
  base_url: str,
35
  api_key: str,
36
+ rate_limiter: ProviderRateLimiter,
37
  default_headers: Mapping[str, str] | None = None,
38
  ):
39
  super().__init__(config)
 
43
  # Learned per-model output-token caps from upstream 400 rejections, so
44
  # later requests clamp proactively instead of paying the 400 each time.
45
  self._model_output_caps: dict[str, int] = {}
46
+ self._rate_limiter = rate_limiter
 
 
 
 
 
47
  http_client = None
48
  if config.proxy:
49
  http_client = httpx.AsyncClient(
 
121
  while True:
122
  try:
123
  create_body = self._prepare_create_body(body)
124
+ stream = await self._rate_limiter.execute_with_retry(
125
  self._client.chat.completions.create, **create_body, stream=True
126
  )
127
  return stream, body
 
194
  def _map_error_details(
195
  self, error: Exception, request_id: str | None
196
  ) -> tuple[Exception, str]:
197
+ mapped_error = map_error(error, rate_limiter=self._rate_limiter)
198
  return (
199
  mapped_error,
200
  user_visible_message_for_mapped_provider_error(