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 -->
- ARCHITECTURE.md +60 -12
- pyproject.toml +1 -1
- smoke/capabilities.py +13 -4
- smoke/features.py +3 -0
- smoke/lib/e2e.py +4 -1
- smoke/prereq/test_voice_prereq_live.py +20 -9
- smoke/product/test_voice_product_live.py +23 -15
- src/free_claude_code/config/admin/manifest.py +8 -0
- src/free_claude_code/config/admin/persistence.py +24 -3
- src/free_claude_code/messaging/limiter.py +144 -104
- src/free_claude_code/messaging/platforms/discord.py +118 -48
- src/free_claude_code/messaging/platforms/discord_io.py +7 -3
- src/free_claude_code/messaging/platforms/factory.py +18 -19
- src/free_claude_code/messaging/platforms/outbox.py +54 -32
- src/free_claude_code/messaging/platforms/ports.py +4 -2
- src/free_claude_code/messaging/platforms/telegram.py +93 -63
- src/free_claude_code/messaging/platforms/telegram_io.py +7 -3
- src/free_claude_code/messaging/platforms/voice_flow.py +30 -21
- src/free_claude_code/messaging/transcription.py +100 -137
- src/free_claude_code/messaging/voice.py +9 -33
- src/free_claude_code/providers/cerebras/client.py +3 -1
- src/free_claude_code/providers/cloudflare/client.py +9 -1
- src/free_claude_code/providers/codestral/client.py +3 -1
- src/free_claude_code/providers/cohere/client.py +3 -1
- src/free_claude_code/providers/deepseek/client.py +3 -1
- src/free_claude_code/providers/error_mapping.py +6 -9
- src/free_claude_code/providers/fireworks/client.py +3 -1
- src/free_claude_code/providers/gemini/client.py +3 -1
- src/free_claude_code/providers/github_models/client.py +3 -1
- src/free_claude_code/providers/groq/client.py +3 -1
- src/free_claude_code/providers/huggingface/client.py +3 -1
- src/free_claude_code/providers/kimi/client.py +3 -1
- src/free_claude_code/providers/llamacpp/client.py +3 -1
- src/free_claude_code/providers/lmstudio/client.py +3 -1
- src/free_claude_code/providers/minimax/client.py +3 -1
- src/free_claude_code/providers/mistral/client.py +3 -1
- src/free_claude_code/providers/nvidia_nim/client.py +9 -1
- src/free_claude_code/providers/nvidia_nim/voice.py +87 -67
- src/free_claude_code/providers/ollama/client.py +3 -1
- src/free_claude_code/providers/open_router/client.py +3 -1
- src/free_claude_code/providers/opencode/client.py +9 -1
- src/free_claude_code/providers/rate_limit.py +13 -87
- src/free_claude_code/providers/runtime/cache.py +11 -9
- src/free_claude_code/providers/runtime/factory.py +165 -50
- src/free_claude_code/providers/sambanova/client.py +3 -1
- src/free_claude_code/providers/transports/anthropic_messages/recovery.py +2 -4
- src/free_claude_code/providers/transports/anthropic_messages/stream.py +6 -8
- src/free_claude_code/providers/transports/anthropic_messages/transport.py +4 -8
- src/free_claude_code/providers/transports/openai_chat/stream.py +2 -2
- src/free_claude_code/providers/transports/openai_chat/transport.py +5 -9
|
@@ -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
|
|
|
|
| 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,
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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`,
|
|
|
|
| 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`
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 658 |
-
and fire-and-forget
|
|
|
|
|
|
|
| 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,
|
| 664 |
-
replies, and the handoff to
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "free-claude-code"
|
| 7 |
-
version = "3.4.
|
| 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"
|
|
@@ -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
|
| 154 |
"provider construction failure",
|
| 155 |
-
(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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 |
),
|
|
@@ -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",
|
|
@@ -323,7 +323,10 @@ class FakePlatform:
|
|
| 323 |
async def start(self) -> None:
|
| 324 |
return None
|
| 325 |
|
| 326 |
-
async def
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
@@ -5,13 +5,16 @@ from pathlib import Path
|
|
| 5 |
|
| 6 |
import pytest
|
| 7 |
|
| 8 |
-
from free_claude_code.messaging.transcription import
|
|
|
|
|
|
|
| 9 |
from smoke.lib.config import SmokeConfig
|
| 10 |
|
| 11 |
pytestmark = [pytest.mark.live, pytest.mark.smoke_target("voice")]
|
| 12 |
|
| 13 |
|
| 14 |
-
|
|
|
|
| 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 |
-
|
| 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 |
|
|
@@ -3,7 +3,8 @@ from pathlib import Path
|
|
| 3 |
|
| 4 |
import pytest
|
| 5 |
|
| 6 |
-
from free_claude_code.messaging.transcription import
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 52 |
-
|
| 53 |
-
|
| 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()
|
|
@@ -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",
|
|
@@ -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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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,
|
|
@@ -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 |
-
|
| 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 |
-
|
| 32 |
-
|
| 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
|
| 42 |
-
rate_window: float
|
| 43 |
-
|
| 44 |
-
|
| 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.
|
|
|
|
| 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
|
| 79 |
-
"""
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 156 |
)
|
|
|
|
|
|
|
| 157 |
except asyncio.CancelledError:
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
except Exception as e:
|
| 160 |
-
|
| 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 =
|
| 175 |
-
"""
|
|
|
|
| 176 |
self._shutdown.set()
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
task = self._worker_task
|
| 185 |
-
if
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 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 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
| 215 |
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
await self._enqueue_internal_multi(func, [future], dedup_key, front)
|
| 218 |
|
| 219 |
-
async def _enqueue_internal_multi(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 247 |
|
| 248 |
-
future = asyncio.
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 258 |
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
async def _wrapped():
|
| 262 |
max_retries = 2
|
| 263 |
for attempt in range(max_retries + 1):
|
| 264 |
try:
|
| 265 |
-
|
|
|
|
| 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 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 297 |
)
|
| 298 |
-
if not future.done():
|
| 299 |
-
future.set_exception(e)
|
| 300 |
break
|
| 301 |
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.")
|
|
@@ -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.
|
| 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 |
-
|
| 78 |
-
|
| 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.
|
| 106 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 107 |
self.outbound = DiscordMessenger(
|
| 108 |
get_client=lambda: self._client,
|
| 109 |
get_discord=_get_discord,
|
| 110 |
-
|
| 111 |
)
|
| 112 |
self._voice_flow = VoiceNoteFlow(
|
| 113 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 189 |
-
|
| 190 |
-
self.
|
| 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 |
-
|
| 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 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
-
|
|
|
|
|
|
|
| 210 |
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
if
|
| 214 |
-
self._connected = False
|
| 215 |
return
|
| 216 |
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
| 225 |
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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."""
|
|
@@ -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 |
-
|
| 31 |
) -> None:
|
| 32 |
self._get_client = get_client
|
| 33 |
self._get_discord = get_discord
|
| 34 |
self._outbox = PlatformOutbox(
|
| 35 |
-
|
| 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()
|
|
@@ -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 |
-
|
| 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 |
-
|
| 53 |
-
|
| 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 |
-
|
| 81 |
-
|
| 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 |
)
|
|
@@ -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 |
-
|
| 24 |
send: SendOperation,
|
| 25 |
edit: EditOperation,
|
| 26 |
delete_many: DeleteManyOperation,
|
| 27 |
) -> None:
|
| 28 |
-
self.
|
| 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 |
-
|
| 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 |
-
|
| 64 |
return None
|
| 65 |
-
return cast(str | None, await
|
| 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 |
-
|
| 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 |
-
|
| 87 |
else:
|
| 88 |
-
await
|
| 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 |
-
|
| 113 |
else:
|
| 114 |
-
await
|
| 115 |
|
| 116 |
def fire_and_forget(self, task: Awaitable[Any]) -> None:
|
| 117 |
-
"""
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.")
|
|
@@ -11,14 +11,16 @@ InboundMessageHandler = Callable[[IncomingMessage], Awaitable[None]]
|
|
| 11 |
|
| 12 |
@runtime_checkable
|
| 13 |
class MessagingRuntime(Protocol):
|
| 14 |
-
"""Owns
|
| 15 |
|
| 16 |
@property
|
| 17 |
def name(self) -> str: ...
|
| 18 |
|
| 19 |
async def start(self) -> None: ...
|
| 20 |
|
| 21 |
-
async def
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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 |
-
|
| 54 |
-
|
| 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
|
| 78 |
self.outbound = TelegramMessenger(
|
| 79 |
get_application=lambda: self._application,
|
| 80 |
-
|
| 81 |
)
|
| 82 |
self._voice_flow = VoiceNoteFlow(
|
| 83 |
-
|
| 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 |
-
|
|
|
|
| 132 |
|
| 133 |
-
|
| 134 |
MessageHandler(filters.TEXT & (~filters.COMMAND), self._on_telegram_message)
|
| 135 |
)
|
| 136 |
-
|
| 137 |
-
|
| 138 |
MessageHandler(filters.COMMAND, self._on_telegram_message)
|
| 139 |
)
|
| 140 |
-
|
| 141 |
-
MessageHandler(filters.VOICE, self._on_telegram_voice)
|
| 142 |
-
)
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 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
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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."""
|
|
@@ -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 |
-
|
| 48 |
) -> None:
|
| 49 |
self._get_application = get_application
|
| 50 |
self._outbox = PlatformOutbox(
|
| 51 |
-
|
| 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()
|
|
@@ -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,
|
| 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 |
-
|
| 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.
|
| 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.
|
| 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.
|
| 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 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 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 |
+
)
|
|
@@ -1,23 +1,11 @@
|
|
| 1 |
-
"""
|
| 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
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 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 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
)
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
|
| 142 |
def _load_audio(file_path: Path) -> dict[str, Any]:
|
| 143 |
-
"""Load audio file
|
| 144 |
import librosa
|
| 145 |
|
| 146 |
-
waveform,
|
| 147 |
-
|
| 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 |
-
|
| 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}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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(
|
|
@@ -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__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -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(
|
|
@@ -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(
|
|
@@ -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(
|
|
@@ -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
|
| 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:
|
| 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
|
| 348 |
-
|
| 349 |
-
``rate_limiter`` to use the process-wide singleton.
|
| 350 |
"""
|
| 351 |
message = get_user_facing_error_message(e)
|
| 352 |
-
limiter = rate_limiter
|
| 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))
|
|
@@ -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(
|
|
@@ -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 |
|
|
@@ -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 |
|
|
@@ -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(
|
|
@@ -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(
|
|
@@ -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(
|
|
@@ -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 |
)
|
|
@@ -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 |
|
|
@@ -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(
|
|
@@ -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(
|
|
@@ -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__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
*,
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
)
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 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()
|
|
|
|
|
|
|
@@ -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 |
|
|
@@ -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(
|
|
@@ -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__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -1,11 +1,11 @@
|
|
| 1 |
-
"""
|
| 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,
|
| 9 |
|
| 10 |
import httpx
|
| 11 |
import openai
|
|
@@ -58,11 +58,13 @@ def retryable_upstream_transport_error(exc: BaseException) -> bool:
|
|
| 58 |
)
|
| 59 |
|
| 60 |
|
| 61 |
-
class
|
| 62 |
"""
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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"
|
| 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
|
| 201 |
|
| 202 |
Args:
|
| 203 |
seconds: How long to block (default 60s)
|
| 204 |
"""
|
| 205 |
self._blocked_until = time.monotonic() + seconds
|
| 206 |
-
logger.warning(f"
|
| 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())
|
|
@@ -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
|
| 39 |
items = list(self._providers.items())
|
| 40 |
errors: list[Exception] = []
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
| 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:
|
|
@@ -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[
|
|
|
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
-
def _create_nvidia_nim(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
from free_claude_code.providers.nvidia_nim import NvidiaNimProvider
|
| 20 |
|
| 21 |
-
return NvidiaNimProvider(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
-
def _create_open_router(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
from free_claude_code.providers.open_router import OpenRouterProvider
|
| 26 |
|
| 27 |
-
return OpenRouterProvider(config)
|
| 28 |
|
| 29 |
|
| 30 |
-
def _create_mistral(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
from free_claude_code.providers.mistral import MistralProvider
|
| 32 |
|
| 33 |
-
return MistralProvider(config)
|
| 34 |
|
| 35 |
|
| 36 |
def _create_mistral_codestral(
|
| 37 |
-
config: ProviderConfig,
|
|
|
|
|
|
|
| 38 |
) -> BaseProvider:
|
| 39 |
from free_claude_code.providers.codestral import CodestralProvider
|
| 40 |
|
| 41 |
-
return CodestralProvider(config)
|
| 42 |
|
| 43 |
|
| 44 |
-
def _create_deepseek(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
from free_claude_code.providers.deepseek import DeepSeekProvider
|
| 46 |
|
| 47 |
-
return DeepSeekProvider(config)
|
| 48 |
|
| 49 |
|
| 50 |
-
def _create_lmstudio(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
from free_claude_code.providers.lmstudio import LMStudioProvider
|
| 52 |
|
| 53 |
-
return LMStudioProvider(config)
|
| 54 |
|
| 55 |
|
| 56 |
-
def _create_llamacpp(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
from free_claude_code.providers.llamacpp import LlamaCppProvider
|
| 58 |
|
| 59 |
-
return LlamaCppProvider(config)
|
| 60 |
|
| 61 |
|
| 62 |
-
def _create_ollama(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
from free_claude_code.providers.ollama import OllamaProvider
|
| 64 |
|
| 65 |
-
return OllamaProvider(config)
|
| 66 |
|
| 67 |
|
| 68 |
-
def _create_kimi(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
from free_claude_code.providers.kimi import KimiProvider
|
| 70 |
|
| 71 |
-
return KimiProvider(config)
|
| 72 |
|
| 73 |
|
| 74 |
-
def _create_wafer(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
from free_claude_code.providers.wafer import WaferProvider
|
| 76 |
|
| 77 |
-
return WaferProvider(config)
|
| 78 |
|
| 79 |
|
| 80 |
-
def _create_minimax(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
from free_claude_code.providers.minimax import MiniMaxProvider
|
| 82 |
|
| 83 |
-
return MiniMaxProvider(config)
|
| 84 |
|
| 85 |
|
| 86 |
-
def _create_opencode(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
from free_claude_code.providers.opencode import OpenCodeProvider
|
| 88 |
|
| 89 |
-
return OpenCodeProvider(config)
|
| 90 |
|
| 91 |
|
| 92 |
-
def _create_opencode_go(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
from free_claude_code.providers.opencode import OpenCodeProvider
|
| 94 |
|
| 95 |
-
return OpenCodeProvider(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
|
| 98 |
-
def _create_vercel(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
from free_claude_code.providers.vercel import VercelProvider
|
| 100 |
|
| 101 |
-
return VercelProvider(config)
|
| 102 |
|
| 103 |
|
| 104 |
-
def _create_huggingface(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
from free_claude_code.providers.huggingface import HuggingFaceProvider
|
| 106 |
|
| 107 |
-
return HuggingFaceProvider(config)
|
| 108 |
|
| 109 |
|
| 110 |
-
def _create_cohere(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
from free_claude_code.providers.cohere import CohereProvider
|
| 112 |
|
| 113 |
-
return CohereProvider(config)
|
| 114 |
|
| 115 |
|
| 116 |
-
def _create_github_models(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
from free_claude_code.providers.github_models import GitHubModelsProvider
|
| 118 |
|
| 119 |
-
return GitHubModelsProvider(config)
|
| 120 |
|
| 121 |
|
| 122 |
-
def _create_zai(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
from free_claude_code.providers.zai import ZaiProvider
|
| 124 |
|
| 125 |
-
return ZaiProvider(config)
|
| 126 |
|
| 127 |
|
| 128 |
-
def _create_fireworks(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
from free_claude_code.providers.fireworks import FireworksProvider
|
| 130 |
|
| 131 |
-
return FireworksProvider(config)
|
| 132 |
|
| 133 |
|
| 134 |
-
def _create_cloudflare(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
from free_claude_code.providers.cloudflare import CloudflareProvider
|
| 136 |
|
| 137 |
-
return CloudflareProvider(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
|
| 140 |
-
def _create_gemini(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
from free_claude_code.providers.gemini import GeminiProvider
|
| 142 |
|
| 143 |
-
return GeminiProvider(config)
|
| 144 |
|
| 145 |
|
| 146 |
-
def _create_groq(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
from free_claude_code.providers.groq import GroqProvider
|
| 148 |
|
| 149 |
-
return GroqProvider(config)
|
| 150 |
|
| 151 |
|
| 152 |
-
def _create_sambanova(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
from free_claude_code.providers.sambanova import SambaNovaProvider
|
| 154 |
|
| 155 |
-
return SambaNovaProvider(config)
|
| 156 |
|
| 157 |
|
| 158 |
-
def _create_cerebras(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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(
|
|
@@ -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 |
-
|
| 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
|
|
@@ -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.
|
| 95 |
while True:
|
| 96 |
stream_opened = False
|
| 97 |
try:
|
| 98 |
-
response = (
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 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.
|
| 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:
|
|
@@ -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
|
| 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.
|
| 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.
|
| 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(
|
|
@@ -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.
|
| 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.
|
| 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,
|
|
@@ -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
|
| 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.
|
| 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.
|
| 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.
|
| 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(
|