File size: 6,620 Bytes
7880373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""agent/llm.py β€” provider-agnostic LLM factory.

The only module in the codebase that knows about specific LLM providers
(Anthropic Claude vs OpenAI). Every other agent module (graph, chat_agent,
translate) goes through `make_chat_model` / `build_system_message` and never
imports `ChatAnthropic` / `ChatOpenAI` directly.

Security note (public HF Space): a `RunConfig.api_key`, when set, is a value
the visitor pasted into the sidebar for THIS session only. It must never be
cached (st.cache_*), logged, or written to disk β€” see dashboard/model_picker.py
and app.py for how the config is threaded through as a per-run snapshot.
"""
from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Optional

from langchain_core.messages import SystemMessage

ANTHROPIC_DEFAULT_MODEL = "claude-haiku-4-5-20251001"
OPENAI_DEFAULT_MODEL = "gpt-5-mini"

# (model_id, display_label) β€” data-driven so the sidebar picker and pricing
# table (agent/cost_log.py) stay in sync. Edit here to add/remove models.
MODEL_CATALOG: dict[str, list[tuple[str, str]]] = {
    "anthropic": [
        ("claude-haiku-4-5-20251001", "Claude Haiku 4.5 β€” fast (default)"),
        ("claude-sonnet-4-6", "Claude Sonnet 4.6 β€” deeper analysis"),
        ("claude-opus-4-8", "Claude Opus 4.8 β€” highest quality"),
    ],
    "openai": [
        ("gpt-5-mini", "GPT-5 mini β€” fast (default)"),
        ("gpt-5.1", "GPT-5.1 β€” deeper analysis"),
    ],
}

PROVIDER_LABELS: dict[str, str] = {
    "anthropic": "Anthropic Claude",
    "openai": "OpenAI",
}

_ENV_VARS: dict[str, str] = {
    "anthropic": "ANTHROPIC_API_KEY",
    "openai": "OPENAI_API_KEY",
}

_KEY_PREFIXES: dict[str, str] = {
    "anthropic": "sk-ant-",
    "openai": "sk-",
}

_CONSOLE_URLS: dict[str, str] = {
    "anthropic": "console.anthropic.com",
    "openai": "platform.openai.com",
}


@dataclass(frozen=True)
class RunConfig:
    """Frozen snapshot of provider/model/key for a single run.

    Built once (in the main Streamlit thread, at the moment of the user
    action) and passed as a plain argument into background threads and agent
    functions β€” never re-read from st.session_state after construction, so a
    mid-run change in the sidebar picker cannot affect an in-flight run.
    """
    provider: str = "anthropic"
    model: str = ANTHROPIC_DEFAULT_MODEL
    api_key: Optional[str] = None  # None β†’ the provider's own env var at construction


def default_config() -> RunConfig:
    """Today's behavior: Anthropic Haiku, key from ANTHROPIC_API_KEY env var."""
    return RunConfig(provider="anthropic", model=ANTHROPIC_DEFAULT_MODEL, api_key=None)


def resolve_api_key(provider: str, user_key: Optional[str]) -> tuple[Optional[str], str]:
    """Resolve which key to use for *provider*.

    Precedence: non-empty pasted key > env var > missing.
    Returns (key_or_None, source) where source is one of "user", "env", "missing".
    """
    user_key = (user_key or "").strip()
    if user_key:
        return user_key, "user"
    env_key = os.environ.get(_ENV_VARS.get(provider, ""), "").strip()
    if env_key:
        return env_key, "env"
    return None, "missing"


def key_looks_valid(provider: str, key: str) -> bool:
    """Cheap, no-network sanity check β€” catches pasting a key into the wrong provider."""
    prefix = _KEY_PREFIXES.get(provider)
    if not prefix or not key:
        return True
    return key.startswith(prefix)


def console_url(provider: str) -> str:
    return _CONSOLE_URLS.get(provider, "")


def make_chat_model(
    cfg: RunConfig,
    *,
    temperature: float = 0,
    max_retries: int = 5,
    max_tokens: Optional[int] = None,
):
    """Build a ChatAnthropic or ChatOpenAI client from *cfg*.

    `api_key` is passed explicitly only when `cfg.api_key` is set β€” otherwise
    the LangChain class reads its own provider env var, preserving today's
    behavior exactly when no key was pasted in the UI.
    """
    key, _source = resolve_api_key(cfg.provider, cfg.api_key)
    kwargs: dict = {"model": cfg.model, "temperature": temperature, "max_retries": max_retries}
    if max_tokens is not None:
        kwargs["max_tokens"] = max_tokens
    if key:
        kwargs["api_key"] = key

    if cfg.provider == "openai":
        from langchain_openai import ChatOpenAI
        return ChatOpenAI(**kwargs)

    from langchain_anthropic import ChatAnthropic
    return ChatAnthropic(**kwargs)


def build_system_message(
    cfg: RunConfig,
    primary_text: str,
    extra_texts: Optional[list[str]] = None,
) -> SystemMessage:
    """Build a provider-appropriate SystemMessage.

    Anthropic: content is a list of blocks; the primary block carries
    `cache_control: {type: ephemeral}` so prompt caching keeps working.
    Extra blocks (e.g. a language directive) are appended uncached, exactly
    matching the byte layout the app used before this refactor.

    OpenAI: plain string content β€” `cache_control` is an Anthropic-only
    extension and would be ignored at best, rejected at worst.
    """
    extra_texts = extra_texts or []
    if cfg.provider == "openai":
        parts = [primary_text] + list(extra_texts)
        return SystemMessage(content="\n\n".join(parts))

    content: list[dict] = [{
        "type": "text",
        "text": primary_text,
        "cache_control": {"type": "ephemeral"},
    }]
    for text in extra_texts:
        content.append({"type": "text", "text": text})
    return SystemMessage(content=content)


_AUTH_MARKERS = ("authentication_error", "401", "invalid x-api-key", "incorrect api key", "invalid_api_key")
_OVERLOAD_MARKERS = ("overloaded_error", "529")
_RATE_LIMIT_MARKERS = ("rate_limit", "429", "quota")


def classify_llm_error(exc, provider: str) -> Optional[str]:
    """Map a raw exception/message to a friendly, provider-aware string.

    Returns None when the error isn't recognized β€” the caller shows the raw
    message in that case. Never interpolates the API key.
    """
    text = str(exc).lower()
    label = PROVIDER_LABELS.get(provider, provider)

    if any(marker in text for marker in _AUTH_MARKERS):
        return (
            f"Your {label} API key was rejected (authentication error). "
            f"Check it in Model settings β€” get a key at {console_url(provider)}."
        )
    if any(marker in text for marker in _OVERLOAD_MARKERS):
        return f"{label} is temporarily overloaded. Please try again in a moment."
    if any(marker in text for marker in _RATE_LIMIT_MARKERS):
        return f"{label} rate limit reached. Please wait a moment and try again."
    return None