File size: 15,205 Bytes
891669b
cf32d0c
 
 
 
 
 
 
 
126d8cd
0bce5fd
126d8cd
0bce5fd
 
891669b
 
 
 
486b786
 
 
 
891669b
 
 
 
 
 
 
 
126d8cd
486b786
 
891669b
486b786
 
 
 
 
 
 
 
126d8cd
 
 
 
 
 
486b786
 
 
891669b
 
 
126d8cd
 
 
 
 
 
 
 
486b786
 
891669b
 
 
 
486b786
891669b
 
 
 
 
 
486b786
 
 
 
891669b
126d8cd
 
486b786
 
 
 
 
 
 
 
 
891669b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486b786
891669b
 
486b786
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
adbf39e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0bce5fd
486b786
 
 
 
 
0bce5fd
 
486b786
 
 
0bce5fd
 
486b786
 
cf32d0c
 
 
 
486b786
 
 
adbf39e
 
486b786
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
891669b
 
 
486b786
891669b
 
 
 
 
 
 
 
 
486b786
 
126d8cd
 
 
 
 
 
 
 
cf32d0c
 
 
 
126d8cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0bce5fd
126d8cd
0bce5fd
 
126d8cd
 
 
 
 
0bce5fd
 
126d8cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486b786
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
# ---- Changelog ----
# [2026-04-17] Claude (Sonnet 4.6) β€” Enable Qwen3 thinking mode for OpenRouter + HuggingFace
# What: /think prefix prepended to system prompt in _call_openrouter and _call_huggingface
#   when QB_THINKING_ENABLED=true (default). Activates Qwen3's native chain-of-thought.
# Why: QB was running without extended reasoning, producing fabricated anchor text in specs
#   because it didn't reason through code structure before writing. Thinking on improves
#   spec anchor quality and cross-repo reasoning.
# How: Qwen3-native: /think in system prompt activates <think>...</think> CoT blocks.
#   Controlled by QB_THINKING_ENABLED env var (default "true"). Anthropic provider unchanged.
# [2026-04-16] Claude (Sonnet 4.6) β€” Add HuggingFace Inference API as primary provider
# What: "huggingface" provider added; auto-fallback to OpenRouter on 402/404/503
# Why: Leverage HF more; OpenRouter stays as backup. Explicit user request.
# How: Same OpenAI-compat path as OpenRouter. _call_huggingface() catches 402/404/503 and
#      retries via _call_openrouter(). HF_MODEL_ID env var (format differs from OpenRouter).
# [2026-03-29] Switchblade (TQB / Block E) β€” Anthropic model client
# What: Claude API client with retry logic, replacing HuggingFace InferenceClient
# Why: PRD Block E β€” swap from Kimi K2.5 (HF) to Claude (Anthropic SDK)
# How: Anthropic SDK, exponential backoff on transient errors, env-configurable model
# [2026-03-30] Josh + Claude β€” Multi-provider support
# What: Added OpenRouter as alternative provider. QB stays Claude, workers can use either.
# Why: Josh hitting Anthropic rate limits. Need to spread usage across providers.
# How: CODEMINE_PROVIDER env var selects "anthropic" or "openrouter". Same tool_use interface.
# -------------------

import os
import time
import logging

logger = logging.getLogger(__name__)

# Provider selection: "anthropic", "openrouter", or "huggingface"
PROVIDER = os.getenv("CODEMINE_PROVIDER", "anthropic").lower()


def get_client():
    """Create and return a client instance based on CODEMINE_PROVIDER."""
    if PROVIDER == "openrouter":
        from openai import OpenAI
        return OpenAI(
            base_url="https://openrouter.ai/api/v1",
            api_key=os.getenv("OPENROUTER_API_KEY"),
        )
    elif PROVIDER == "huggingface":
        from openai import OpenAI
        return OpenAI(
            base_url="https://api-inference.huggingface.co/v1",
            api_key=os.getenv("HF_TOKEN"),
        )
    else:
        from anthropic import Anthropic
        return Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))


def get_model_id() -> str:
    """Return the model ID, configurable via env var.

    HF model IDs use HuggingFace hub format (e.g. Qwen/Qwen3-Coder-480B-A35B-Instruct).
    OpenRouter uses its own slug format (e.g. qwen/qwen3-coder).
    Use HF_MODEL_ID and CODEMINE_MODEL_ID to override each independently.
    """
    if PROVIDER == "huggingface":
        return os.getenv("HF_MODEL_ID", "Qwen/Qwen3-Coder-480B-A35B-Instruct")
    if PROVIDER == "openrouter":
        return os.getenv("CODEMINE_MODEL_ID", "anthropic/claude-sonnet-4")
    return os.getenv("CLAUDE_MODEL_ID", "claude-sonnet-4-6")


def call_model(
    client,
    system_prompt: str,
    messages: list,
    tools: list,
    max_retries: int = 3,
    max_tokens: int = 8192,
):
    """Call the model API with native tool use and retry logic.

    Dispatches to Anthropic or OpenRouter based on CODEMINE_PROVIDER.
    Both support tool_use β€” OpenRouter via OpenAI-compatible format.
    """
    if PROVIDER == "huggingface":
        return _call_huggingface(client, system_prompt, messages, tools, max_retries, max_tokens)
    if PROVIDER == "openrouter":
        return _call_openrouter(client, system_prompt, messages, tools, max_retries, max_tokens)
    return _call_anthropic(client, system_prompt, messages, tools, max_retries, max_tokens)


def _call_anthropic(client, system_prompt, messages, tools, max_retries, max_tokens):
    """Anthropic native API call."""
    from anthropic import APIStatusError, APITimeoutError, APIConnectionError

    model_id = get_model_id()
    last_error = None

    for attempt in range(max_retries):
        try:
            response = client.messages.create(
                model=model_id,
                max_tokens=max_tokens,
                system=system_prompt,
                messages=messages,
                tools=tools if tools else [],
            )
            return response

        except APITimeoutError as e:
            last_error = e
            logger.warning("API timeout on attempt %d/%d: %s", attempt + 1, max_retries, e)
        except APIConnectionError as e:
            last_error = e
            logger.warning("API connection error on attempt %d/%d: %s", attempt + 1, max_retries, e)
        except APIStatusError as e:
            if e.status_code >= 500:
                last_error = e
                logger.warning("API %d error on attempt %d/%d: %s", e.status_code, attempt + 1, max_retries, e)
            else:
                raise

        if attempt < max_retries - 1:
            backoff = 2 * (2 ** attempt)
            logger.info("Retrying in %d seconds...", backoff)
            time.sleep(backoff)

    raise last_error


def _convert_messages_to_openai(messages: list) -> list:
    """Convert Anthropic-style messages to OpenAI chat format.

    Anthropic uses content block arrays for tool_use (assistant) and
    tool_result (user) messages. OpenAI uses tool_calls on the assistant
    message and separate role="tool" messages for results.
    """
    import json as _json
    converted = []
    for msg in messages:
        role = msg.get("role", "user")
        content = msg.get("content", "")

        # Simple string content β€” pass through
        if isinstance(content, str):
            if content:
                converted.append({"role": role, "content": content})
            continue

        # List content β€” Anthropic content blocks
        if not isinstance(content, list):
            continue

        if role == "assistant":
            # Extract text and tool_use blocks
            text_parts = []
            tool_calls = []
            for block in content:
                if isinstance(block, dict):
                    if block.get("type") == "text" and block.get("text"):
                        text_parts.append(block["text"])
                    elif block.get("type") == "tool_use":
                        tool_calls.append({
                            "id": block["id"],
                            "type": "function",
                            "function": {
                                "name": block["name"],
                                "arguments": _json.dumps(block.get("input", {})),
                            },
                        })
            assistant_msg = {"role": "assistant"}
            assistant_msg["content"] = "\n".join(text_parts) if text_parts else None
            if tool_calls:
                assistant_msg["tool_calls"] = tool_calls
            converted.append(assistant_msg)

        elif role == "user":
            # Could be tool_result blocks or mixed content
            tool_results = []
            text_parts = []
            for block in content:
                if isinstance(block, dict):
                    if block.get("type") == "tool_result":
                        tool_results.append({
                            "role": "tool",
                            "tool_call_id": block["tool_use_id"],
                            "content": str(block.get("content", "")),
                        })
                    elif block.get("type") == "text" and block.get("text"):
                        text_parts.append(block["text"])
                elif isinstance(block, str) and block:
                    text_parts.append(block)
            if text_parts:
                converted.append({"role": "user", "content": "\n".join(text_parts)})
            converted.extend(tool_results)

    return converted


def _call_openrouter(client, system_prompt, messages, tools, max_retries, max_tokens, model_id=None):
    """OpenRouter call via OpenAI-compatible SDK.

    OpenRouter supports tool_use for Claude and other models via the
    standard OpenAI tools format. We convert Anthropic-style tool defs
    to OpenAI format and wrap the response to match Anthropic's structure.

    model_id: override for fallback callers (e.g. HF fallback needs OR model format).
    """
    from openai import APITimeoutError, APIConnectionError, APIStatusError

    if model_id is None:
        model_id = get_model_id()
    last_error = None

    # Enable Qwen3 native thinking mode β€” prepend /think to activate chain-of-thought
    if os.getenv("QB_THINKING_ENABLED", "true").lower() == "true":
        system_prompt = "/think\n" + system_prompt

    # Convert Anthropic tool format to OpenAI format
    openai_tools = _convert_tools_to_openai(tools) if tools else []

    # Convert Anthropic-style messages to OpenAI format and prepend system prompt
    full_messages = [{"role": "system", "content": system_prompt}] + _convert_messages_to_openai(messages)

    for attempt in range(max_retries):
        try:
            kwargs = {
                "model": model_id,
                "max_tokens": max_tokens,
                "messages": full_messages,
            }
            if openai_tools:
                kwargs["tools"] = openai_tools

            response = client.chat.completions.create(**kwargs)
            # Wrap OpenAI response to match Anthropic's structure
            return _wrap_openai_response(response)

        except (APITimeoutError, APIConnectionError) as e:
            last_error = e
            logger.warning("OpenRouter error on attempt %d/%d: %s", attempt + 1, max_retries, e)
        except APIStatusError as e:
            if e.status_code >= 500:
                last_error = e
                logger.warning("OpenRouter %d error on attempt %d/%d: %s", e.status_code, attempt + 1, max_retries, e)
            else:
                raise

        if attempt < max_retries - 1:
            backoff = 2 * (2 ** attempt)
            logger.info("Retrying in %d seconds...", backoff)
            time.sleep(backoff)

    raise last_error


def _call_huggingface(client, system_prompt, messages, tools, max_retries, max_tokens):
    """HuggingFace Inference API call via OpenAI-compatible SDK.

    Same interface as OpenRouter. On 402 (credits exhausted or model unavailable),
    automatically falls back to OpenRouter so runs don't silently die.
    """
    from openai import OpenAI, APITimeoutError, APIConnectionError, APIStatusError

    # Enable Qwen3 native thinking mode β€” prepend /think to activate chain-of-thought
    if os.getenv("QB_THINKING_ENABLED", "true").lower() == "true":
        system_prompt = "/think\n" + system_prompt

    model_id = get_model_id()
    openai_tools = _convert_tools_to_openai(tools) if tools else []
    full_messages = [{"role": "system", "content": system_prompt}] + _convert_messages_to_openai(messages)
    last_error = None

    for attempt in range(max_retries):
        try:
            kwargs = {
                "model": model_id,
                "max_tokens": max_tokens,
                "messages": full_messages,
            }
            if openai_tools:
                kwargs["tools"] = openai_tools

            response = client.chat.completions.create(**kwargs)
            return _wrap_openai_response(response)

        except APIStatusError as e:
            if e.status_code < 500:
                logger.warning(
                    "HF Inference API %d (model unavailable/not on serverless/credits). Falling back to OpenRouter.",
                    e.status_code,
                )
                or_client = OpenAI(
                    base_url="https://openrouter.ai/api/v1",
                    api_key=os.getenv("OPENROUTER_API_KEY"),
                )
                or_model = os.getenv("CODEMINE_MODEL_ID", "qwen/qwen3-coder")
                return _call_openrouter(or_client, system_prompt, messages, tools, max_retries, max_tokens, model_id=or_model)
            elif e.status_code >= 500:
                last_error = e
                logger.warning("HF %d error on attempt %d/%d: %s", e.status_code, attempt + 1, max_retries, e)
            else:
                raise

        except (APITimeoutError, APIConnectionError) as e:
            last_error = e
            logger.warning("HF connection error on attempt %d/%d: %s", attempt + 1, max_retries, e)

        if attempt < max_retries - 1:
            backoff = 2 * (2 ** attempt)
            logger.info("Retrying in %d seconds...", backoff)
            time.sleep(backoff)

    raise last_error


def _convert_tools_to_openai(tools: list) -> list:
    """Convert Anthropic tool definitions to OpenAI function calling format.

    Anthropic: {"name": "x", "description": "y", "input_schema": {...}}
    OpenAI:    {"type": "function", "function": {"name": "x", "description": "y", "parameters": {...}}}
    """
    return [
        {
            "type": "function",
            "function": {
                "name": t["name"],
                "description": t.get("description", ""),
                "parameters": t.get("input_schema", {"type": "object", "properties": {}}),
            },
        }
        for t in tools
    ]


class _ContentBlock:
    """Mimics Anthropic's ContentBlock for OpenAI response wrapping."""
    def __init__(self, block_type, **kwargs):
        self.type = block_type
        for k, v in kwargs.items():
            setattr(self, k, v)


class _WrappedResponse:
    """Mimics Anthropic's Message response for OpenAI compatibility."""
    def __init__(self, content, stop_reason):
        self.content = content
        self.stop_reason = stop_reason


def _wrap_openai_response(response) -> _WrappedResponse:
    """Convert OpenAI ChatCompletion to Anthropic-like Message structure.

    The rest of app.py expects:
        response.content = [ContentBlock(type="text"|"tool_use", ...)]
        response.stop_reason = "end_turn" | "tool_use"
    """
    import json as _json

    choice = response.choices[0]
    message = choice.message
    content_blocks = []

    # Text content
    if message.content:
        content_blocks.append(_ContentBlock("text", text=message.content))

    # Tool calls
    if message.tool_calls:
        for tc in message.tool_calls:
            try:
                tool_input = _json.loads(tc.function.arguments)
            except (_json.JSONDecodeError, TypeError):
                tool_input = {}
            content_blocks.append(_ContentBlock(
                "tool_use",
                id=tc.id,
                name=tc.function.name,
                input=tool_input,
            ))

    # Map stop reason
    if choice.finish_reason == "tool_calls":
        stop_reason = "tool_use"
    elif choice.finish_reason == "stop":
        stop_reason = "end_turn"
    else:
        stop_reason = choice.finish_reason or "end_turn"

    return _WrappedResponse(content_blocks, stop_reason)