File size: 5,539 Bytes
2415446
 
 
 
 
 
 
 
 
 
 
 
61bb677
0a54372
2415446
 
 
 
c817fe8
2415446
 
61bb677
2415446
 
 
61bb677
2415446
 
 
 
61bb677
2415446
61bb677
2415446
 
61bb677
2415446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0a54372
2415446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0a54372
2415446
 
 
 
 
 
 
 
c817fe8
0a54372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2415446
 
 
 
 
 
 
c817fe8
2415446
 
 
 
61bb677
2415446
 
 
61bb677
2415446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
"""Cloudflare Workers AI provider using OpenAI-compatible chat completions."""

from collections.abc import Iterator, Mapping
from dataclasses import replace
from typing import Any
from urllib.parse import quote

import httpx

from free_claude_code.application.errors import ApplicationUnavailableError
from free_claude_code.application.model_metadata import ProviderModelInfo
from free_claude_code.config.provider_catalog import CLOUDFLARE_AI_REST_ROOT
from free_claude_code.core.anthropic import ReasoningReplayMode
from free_claude_code.providers.admission import ProviderAdmissionController
from free_claude_code.providers.base import ProviderConfig
from free_claude_code.providers.http import maybe_await_aclose
from free_claude_code.providers.model_listing import (
    ModelListResponseError,
    extract_openai_model_infos,
)
from free_claude_code.providers.openai_chat import (
    ChatTemplateReasoning,
    OpenAIChatProfile,
    OpenAIChatProvider,
    OpenAIChatRequestPolicy,
    validate_extra_body_does_not_override_canonical_fields,
)

_REQUEST_POLICY = OpenAIChatRequestPolicy(
    provider_name="CLOUDFLARE",
    reasoning_replay=ReasoningReplayMode.REASONING_CONTENT,
    include_extra_body=True,
    extra_body_validator=validate_extra_body_does_not_override_canonical_fields,
    max_tokens_field="max_completion_tokens",
)
_PROFILE = OpenAIChatProfile(_REQUEST_POLICY, ChatTemplateReasoning())


def cloudflare_ai_base_url(api_root: str | None, account_id: str) -> str:
    """Return the account-scoped Cloudflare Workers AI OpenAI-compatible base URL."""

    return f"{_cloudflare_account_api_url(api_root, account_id)}/ai/v1"


def _cloudflare_model_search_url(api_root: str | None, account_id: str) -> str:
    """Return the Cloudflare account model-search endpoint URL."""

    return f"{_cloudflare_account_api_url(api_root, account_id)}/ai/models/search"


def _cloudflare_account_api_url(api_root: str | None, account_id: str) -> str:
    """Return the account-scoped Cloudflare API root URL."""

    stripped_account = account_id.strip()
    if not stripped_account:
        raise ApplicationUnavailableError(
            "CLOUDFLARE_ACCOUNT_ID is not set. Add it to your .env file."
        )
    root = (api_root or CLOUDFLARE_AI_REST_ROOT).rstrip("/")
    encoded_account = quote(stripped_account, safe="")
    return f"{root}/accounts/{encoded_account}"


class CloudflareProvider(OpenAIChatProvider):
    """Cloudflare Workers AI OpenAI-compatible chat provider."""

    def __init__(
        self,
        config: ProviderConfig,
        *,
        account_id: str,
        admission: ProviderAdmissionController,
    ):
        base_url = cloudflare_ai_base_url(config.base_url, account_id)
        self._model_search_url = _cloudflare_model_search_url(
            config.base_url, account_id
        )
        self._model_list_client = httpx.AsyncClient(
            proxy=config.proxy or None,
            timeout=httpx.Timeout(
                config.http_read_timeout,
                connect=config.http_connect_timeout,
                read=config.http_read_timeout,
                write=config.http_write_timeout,
            ),
        )
        super().__init__(
            replace(config, base_url=base_url),
            profile=_PROFILE,
            admission=admission,
        )

    async def cleanup(self) -> None:
        """Release provider client resources."""
        await super().cleanup()
        await self._model_list_client.aclose()

    async def list_model_infos(self) -> frozenset[ProviderModelInfo]:
        """Return Cloudflare Workers AI metadata from account model search."""

        async def request() -> httpx.Response:
            response = await self._model_list_client.get(
                self._model_search_url,
                params={"format": "openrouter"},
                headers=self._model_list_headers(),
            )
            try:
                response.raise_for_status()
            except Exception:
                await maybe_await_aclose(response)
                raise
            return response

        response = await self._admission.run_with_retry(request)
        try:
            try:
                payload = response.json()
            except ValueError as exc:
                raise ModelListResponseError(
                    "CLOUDFLARE model-list response is malformed: invalid JSON"
                ) from exc
            return extract_openai_model_infos(payload, provider_name="CLOUDFLARE")
        finally:
            await maybe_await_aclose(response)

    def _handle_extra_reasoning(
        self, delta: Any, ledger: Any, *, output_reasoning: bool
    ) -> Iterator[str]:
        """Map Cloudflare's ``reasoning`` delta field to Anthropic thinking."""
        reasoning = _cloudflare_reasoning(delta)
        if not output_reasoning or not reasoning:
            return
        yield from ledger.ensure_thinking_block()
        yield ledger.emit_thinking_delta(reasoning)

    def _model_list_headers(self) -> dict[str, str]:
        return {"Authorization": f"Bearer {self._api_key}"}


def _cloudflare_reasoning(delta: Any) -> str | None:
    reasoning = getattr(delta, "reasoning", None)
    if isinstance(reasoning, str) and reasoning:
        return reasoning

    model_extra = getattr(delta, "model_extra", None)
    if isinstance(model_extra, Mapping):
        reasoning = model_extra.get("reasoning")
        if isinstance(reasoning, str) and reasoning:
            return reasoning

    return None