File size: 4,002 Bytes
0d599d9
 
 
02422e3
0d599d9
02422e3
0d599d9
02422e3
 
0d599d9
 
 
02422e3
 
 
 
0d599d9
 
 
 
 
 
 
 
 
02422e3
 
0d599d9
 
 
 
 
 
 
 
 
 
 
 
 
 
02422e3
 
0d599d9
 
 
 
 
 
 
 
 
 
 
02422e3
 
 
 
 
0d599d9
 
02422e3
0d599d9
 
 
 
 
 
 
02422e3
 
0d599d9
02422e3
 
 
 
0d599d9
 
 
 
 
 
02422e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d599d9
 
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
"""
openai_adapter.py — Concrete LLMAdapter for OpenAI / OpenRouter / Ollama.

V3 (Sidecar): adds stream_chat() for sentence-level streaming.

Works with any OpenAI-compatible API. Configure via environment variables:
  OPENAI_API_KEY=sk-...
  OPENAI_BASE_URL=https://openrouter.ai/api/v1
  OPENAI_MODEL=gpt-4o-mini
"""

import os
from typing import AsyncGenerator, Optional

from openai import AsyncOpenAI, OpenAI

from llm_adapter import LLMAdapter


class OpenAIAdapter(LLMAdapter):
    """
    Wraps any OpenAI-compatible API as an LLMAdapter.

    Args:
        api_key:      API key. Defaults to OPENAI_API_KEY env var.
        base_url:     API base URL. Defaults to OPENAI_BASE_URL env var.
        model:        Model name. Defaults to OPENAI_MODEL env var.
        system_prompt: Default system prompt for all calls.
        temperature:   Sampling temperature (0 = deterministic).
        max_tokens:    Max tokens in response.
    """

    DEFAULT_BASE_URL = "https://api.openai.com/v1"
    DEFAULT_MODEL    = "gpt-4o-mini"

    def __init__(
        self,
        api_key:       Optional[str] = None,
        base_url:      Optional[str] = None,
        model:         Optional[str] = None,
        system_prompt: Optional[str] = None,
        temperature:   float = 0.7,
        max_tokens:    int   = 1024,
    ):
        self._api_key  = api_key  or os.getenv("OPENAI_API_KEY", "")
        self._base_url = base_url or os.getenv("OPENAI_BASE_URL", self.DEFAULT_BASE_URL)
        self._model    = model    or os.getenv("OPENAI_MODEL",    self.DEFAULT_MODEL)

        self._system_prompt = system_prompt or (
            "You are a helpful AI assistant. Be concise and accurate."
        )
        self._temperature = temperature
        self._max_tokens  = max_tokens

        # Synchronous client (existing blocking interface)
        self._client = OpenAI(api_key=self._api_key, base_url=self._base_url)

        # Async client (streaming interface)
        self._async_client = AsyncOpenAI(api_key=self._api_key, base_url=self._base_url)

    # ------------------------------------------------------------------
    # Blocking interface (existing — unchanged)
    # ------------------------------------------------------------------

    def chat(
        self,
        prompt:        str,
        system_prompt: Optional[str] = None,
    ) -> str:
        """Send prompt to LLM and return response text (blocking)."""
        sys_msg  = system_prompt or self._system_prompt
        response = self._client.chat.completions.create(
            model       = self._model,
            messages    = [
                {"role": "system", "content": sys_msg},
                {"role": "user",   "content": prompt},
            ],
            temperature = self._temperature,
            max_tokens  = self._max_tokens,
        )
        return response.choices[0].message.content or ""

    # ------------------------------------------------------------------
    # Streaming interface (NEW)
    # ------------------------------------------------------------------

    async def stream_chat(
        self,
        prompt:        str,
        system_prompt: Optional[str] = None,
    ) -> AsyncGenerator[str, None]:
        """
        Yield token chunks from OpenAI as they arrive (true async streaming).
        """
        sys_msg = system_prompt or self._system_prompt

        stream = await self._async_client.chat.completions.create(
            model       = self._model,
            messages    = [
                {"role": "system", "content": sys_msg},
                {"role": "user",   "content": prompt},
            ],
            temperature = self._temperature,
            max_tokens  = self._max_tokens,
            stream      = True,
        )

        async for chunk in stream:
            delta = chunk.choices[0].delta
            if delta and delta.content:
                yield delta.content

    def get_model_name(self) -> str:
        return self._model