diff --git a/louter.cabal b/louter.cabal
--- a/louter.cabal
+++ b/louter.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name: louter
-version: 0.1.1.0
+version: 0.1.1.1
 synopsis: Multi-protocol LLM router and client library
 description: Protocol converter library that lets your application connect to any LLM API
              (OpenAI, Gemini, Anthropic) with automatic protocol translation, SSE streaming,
@@ -9,6 +9,7 @@
 license-file: LICENSE
 author: Junji Hashimoto
 maintainer: junji.hashimoto@gmail.com
+homepage: https://github.com/junjihashimoto/louter
 category: Web
 build-type: Simple
 
diff --git a/src/Louter/Client.hs b/src/Louter/Client.hs
--- a/src/Louter/Client.hs
+++ b/src/Louter/Client.hs
@@ -1,5 +1,6 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
 
 -- | High-level client API for Louter
 -- This module uses the same proven converters as the proxy server
@@ -45,14 +46,18 @@
 import qualified Data.ByteString.Lazy as BL
 import Data.Conduit ((.|), runConduit, ConduitT, yield, await)
 import qualified Data.Conduit.List as CL
+import qualified Data.HashMap.Strict as HMS
 import Data.Text (Text)
 import qualified Data.Text as T
 import qualified Data.Text.Encoding as TE
 import qualified Data.Vector as V
+import Debug.Trace (trace)
 import Network.HTTP.Client
 import Network.HTTP.Client.TLS (tlsManagerSettings)
 import Network.HTTP.Types (hContentType, hAuthorization)
 import Network.HTTP.Types.Header (RequestHeaders)
+import System.Environment (lookupEnv)
+import System.IO.Unsafe (unsafePerformIO)
 
 -- Import server-side converters (proven, tested code)
 import Louter.Protocol.AnthropicConverter
@@ -61,6 +66,24 @@
 import Louter.Types.Response
 import Louter.Types.Streaming
 
+-- | Check if debug mode is enabled via LOUTER_DEBUG environment variable
+-- Set LOUTER_DEBUG=1 or LOUTER_DEBUG=true to enable debug logging
+{-# NOINLINE isDebugEnabled #-}
+isDebugEnabled :: Bool
+isDebugEnabled = unsafePerformIO $ do
+  maybeDebug <- lookupEnv "LOUTER_DEBUG"
+  pure $ case maybeDebug of
+    Just "1" -> True
+    Just "true" -> True
+    Just "TRUE" -> True
+    Just "yes" -> True
+    Just "YES" -> True
+    _ -> False
+
+-- | Conditional debug trace - only traces if LOUTER_DEBUG is set
+debugTrace :: String -> a -> a
+debugTrace msg x = if isDebugEnabled then trace msg x else x
+
 -- | Client configuration
 data Client = Client
   { clientManager :: Manager
@@ -103,20 +126,175 @@
         Left err -> pure $ Left $ "Failed to parse response: " <> T.pack err
         Right resp -> pure $ Right resp
 
+-- | Parse SSE stream from HTTP response
+parseSSEStream :: Manager -> Request -> ConduitT () StreamEvent IO ()
+parseSSEStream manager httpReq = do
+  -- We need to lift the withResponse into the Conduit monad
+  -- The trick is to use bracket-style resource management
+  response <- liftIO $ responseOpen httpReq manager
+  parseSSEChunks (responseBody response)
+  liftIO $ responseClose response
+
+-- | Parse SSE chunks from body reader
+parseSSEChunks :: BodyReader -> ConduitT () StreamEvent IO ()
+parseSSEChunks bodyReader = loop BS.empty HMS.empty
+  where
+    loop acc toolCallState = do
+      chunk <- liftIO $ brRead bodyReader
+      if BS.null chunk
+        then do
+          -- End of stream - emit any buffered tool calls
+          mapM_ emitToolCall (HMS.toList toolCallState)
+        else do
+          let combined = acc <> chunk
+              lines' = BS8.split '\n' combined
+          case lines' of
+            [] -> loop BS.empty toolCallState
+            [incomplete] -> loop incomplete toolCallState
+            _ -> do
+              let (completeLines, rest) = (init lines', last lines')
+              newState <- foldM processSSELine toolCallState completeLines
+              loop rest newState
+
+    processSSELine state line
+      | BS.isPrefixOf "data: " line = do
+          let jsonText = TE.decodeUtf8 $ BS.drop 6 line
+          if jsonText == "[DONE]"
+            then do
+              -- Emit all buffered tool calls and finish
+              mapM_ emitToolCall (HMS.toList state)
+              yield (StreamFinish "stop")
+              pure HMS.empty
+            else case eitherDecode (BL.fromStrict $ TE.encodeUtf8 jsonText) of
+              Right (Object chunk) -> processChunk state chunk
+              Left err -> do
+                yield (StreamError $ "Failed to parse JSON: " <> T.pack err)
+                pure state
+              _ -> pure state
+      | otherwise = pure state
+
+    processChunk state chunk = do
+      case HM.lookup "choices" chunk of
+        Just (Array choices) | not (V.null choices) -> do
+          case V.head choices of
+            Object choice -> processChoice state choice
+            _ -> pure state
+        _ -> pure state
+
+    processChoice state choice = do
+      case HM.lookup "delta" choice of
+        Just (Object delta) -> do
+          -- Handle content
+          newState1 <- case HM.lookup "content" delta of
+            Just (String content) -> do
+              yield (StreamContent content)
+              pure state
+            _ -> pure state
+
+          -- Handle reasoning (o1 models)
+          newState2 <- case HM.lookup "reasoning" delta of
+            Just (String reasoning) -> do
+              yield (StreamReasoning reasoning)
+              pure newState1
+            _ -> pure newState1
+
+          -- Handle tool calls (need buffering)
+          case HM.lookup "tool_calls" delta of
+            Just (Array toolCalls) -> processToolCalls newState2 toolCalls
+            _ -> pure newState2
+        _ -> pure state
+
+    processToolCalls state toolCalls = do
+      V.foldM processToolCallDelta state toolCalls
+
+    processToolCallDelta state (Object tcDelta) = do
+      case HM.lookup "index" tcDelta of
+        Just (Number idx) -> do
+          let index = floor idx :: Int
+          let existingTC = HMS.lookupDefault emptyToolCallState index state
+
+          -- Update tool call state
+          let updatedTC = existingTC
+                { tcId = case HM.lookup "id" tcDelta of
+                    Just (String id') -> Just id'
+                    _ -> tcId existingTC
+                , tcName = case HM.lookup "function" tcDelta >>= getFunctionName of
+                    Just name -> Just name
+                    _ -> tcName existingTC
+                , tcArgs = tcArgs existingTC <> case HM.lookup "function" tcDelta >>= getFunctionArgs of
+                    Just args -> args
+                    _ -> ""
+                }
+
+          -- Check if JSON is complete
+          if isCompleteJSON (tcArgs updatedTC) && isJust (tcId updatedTC) && isJust (tcName updatedTC)
+            then do
+              -- Emit complete tool call
+              emitToolCall (index, updatedTC)
+              pure $ HMS.delete index state
+            else
+              pure $ HMS.insert index updatedTC state
+        _ -> pure state
+    processToolCallDelta state _ = pure state
+
+    getFunctionName (Object func) = case HM.lookup "name" func of
+      Just (String name) -> Just name
+      _ -> Nothing
+    getFunctionName _ = Nothing
+
+    getFunctionArgs (Object func) = case HM.lookup "arguments" func of
+      Just (String args) -> Just args
+      _ -> Nothing
+    getFunctionArgs _ = Nothing
+
+    emptyToolCallState = ToolCallBufferState Nothing Nothing ""
+
+    emitToolCall (_, ToolCallBufferState (Just id') (Just name) args) = do
+      case eitherDecode (BL.fromStrict $ TE.encodeUtf8 args) of
+        Right argsValue -> yield (StreamToolCall $ ToolCall id' name argsValue)
+        Left _ -> pure ()  -- Malformed JSON, skip
+    emitToolCall _ = pure ()
+
+    isCompleteJSON txt =
+      let trimmed = T.strip txt
+      in not (T.null trimmed)
+         && T.head trimmed == '{'
+         && T.last trimmed == '}'
+         && case eitherDecode (BL.fromStrict $ TE.encodeUtf8 txt) of
+              Right (_ :: Value) -> True
+              Left _ -> False
+
+-- | Tool call buffer state
+data ToolCallBufferState = ToolCallBufferState
+  { tcId :: Maybe Text
+  , tcName :: Maybe Text
+  , tcArgs :: Text
+  } deriving (Show)
+
+isJust :: Maybe a -> Bool
+isJust (Just _) = True
+isJust Nothing = False
+
 -- | Streaming chat with conduit
 streamChat :: Client -> ChatRequest -> ConduitT () StreamEvent IO ()
 streamChat client req = do
   let req' = req { reqStream = True }
-  -- For now, just make the request and parse simple events
-  -- TODO: Implement proper streaming when we have tested server-side streaming
-  result <- liftIO $ makeRequest client req'
-  case result of
+  let backend = clientBackend client
+
+  -- Convert ChatRequest to backend-specific format
+  case convertRequestToBackend backend req' of
     Left err -> yield (StreamError err)
-    Right _respBody -> do
-      -- Placeholder: just return a finish event
-      -- Real implementation would parse SSE stream
-      yield (StreamFinish "stop")
+    Right (url, body, headers) -> do
+      httpReq <- liftIO $ parseRequest (T.unpack url)
+      let httpReq' = httpReq
+            { method = "POST"
+            , requestBody = RequestBodyLBS body
+            , requestHeaders = headers
+            }
 
+      -- Make streaming request and pipe to parseSSEStream
+      parseSSEStream (clientManager client) httpReq'
+
 -- | Type alias for streaming callbacks
 type StreamCallback = StreamEvent -> IO ()
 
@@ -134,6 +312,11 @@
   case convertRequestToBackend backend chatReq of
     Left err -> pure $ Left err
     Right (url, body, headers) -> do
+      -- Debug logging (only if LOUTER_DEBUG is set)
+      debugTrace ("DEBUG: Request URL: " <> T.unpack url) $ return ()
+      debugTrace ("DEBUG: Request headers: " <> show headers) $ return ()
+      debugTrace ("DEBUG: Request body (first 500 bytes): " <> show (BL.take 500 body)) $ return ()
+
       req <- parseRequest (T.unpack url)
       let req' = req
             { method = "POST"
@@ -142,6 +325,8 @@
             }
 
       response <- httpLbs req' clientManager
+      debugTrace ("DEBUG: Response status: " <> show (responseStatus response)) $ return ()
+      debugTrace ("DEBUG: Response headers: " <> show (responseHeaders response)) $ return ()
       pure $ Right $ responseBody response
 
 -- | Convert ChatRequest to backend-specific format
@@ -201,11 +386,46 @@
       Right (url, requestBody, headers)
 
     BackendGemini{..} -> do
-      let url = case backendBaseUrl of
-            Just u -> u <> "/v1beta/models/" <> reqModel chatReq <> ":generateContent"
-            Nothing -> "https://generativelanguage.googleapis.com/v1beta/models/"
-                      <> reqModel chatReq <> ":generateContent"
+      let baseUrl = case backendBaseUrl of
+            Just u -> u
+            Nothing -> "https://generativelanguage.googleapis.com"
 
+          -- Construct URL path based on endpoint type
+          -- Different endpoints use different URL structures:
+          -- - generativelanguage.googleapis.com: /v1beta/models/{model}:generateContent
+          -- - aiplatform.googleapis.com: /v1/publishers/google/models/{model}:generateContent
+          -- - {region}-aiplatform.googleapis.com: /v1/publishers/google/models/{model}:generateContent
+          baseUrlWithPath = if T.isInfixOf "generativelanguage.googleapis.com" baseUrl
+                           then baseUrl <> "/v1beta/models/" <> reqModel chatReq <> ":generateContent"
+                           else if T.isInfixOf "aiplatform.googleapis.com" baseUrl
+                           then baseUrl <> "/v1/publishers/google/models/" <> reqModel chatReq <> ":generateContent"
+                           else baseUrl <> "/v1beta/models/" <> reqModel chatReq <> ":generateContent"  -- default
+
+          -- Determine authentication method based on endpoint
+          -- Three methods:
+          -- 1. Query parameter: aiplatform.googleapis.com?key=API_KEY
+          -- 2. Bearer token: ${LOCATION}-aiplatform.googleapis.com with Authorization header
+          -- 3. API key header: generativelanguage.googleapis.com with x-goog-api-key header
+          (finalUrl, authHeaders) = if backendRequiresAuth
+            then
+              if T.isInfixOf "generativelanguage.googleapis.com" baseUrl
+              then
+                -- Method 3: x-goog-api-key header for generativelanguage.googleapis.com
+                (baseUrlWithPath, [("x-goog-api-key", TE.encodeUtf8 backendApiKey)])
+              else if T.isInfixOf "-aiplatform.googleapis.com" baseUrl
+              then
+                -- Method 2: Authorization Bearer for region-specific endpoints (e.g., us-central1-aiplatform.googleapis.com)
+                (baseUrlWithPath, [(hAuthorization, TE.encodeUtf8 $ "Bearer " <> backendApiKey)])
+              else if T.isInfixOf "aiplatform.googleapis.com" baseUrl
+              then
+                -- Method 1: Query parameter for aiplatform.googleapis.com
+                (baseUrlWithPath <> "?key=" <> backendApiKey, [])
+              else
+                -- Default to x-goog-api-key for unknown endpoints
+                (baseUrlWithPath, [("x-goog-api-key", TE.encodeUtf8 backendApiKey)])
+            else
+              (baseUrlWithPath, [])
+
       -- Convert to Gemini format (reverse of what geminiToOpenAI does)
       let geminiContents = map chatMessageToGemini (reqMessages chatReq)
           geminiTools = if null (reqTools chatReq)
@@ -222,12 +442,9 @@
                    Just m -> ["generationConfig" .= object ["maxOutputTokens" .= m]]
                    Nothing -> [])
 
-          headers = [(hContentType, "application/json")]
-                 ++ if backendRequiresAuth
-                    then [(hAuthorization, TE.encodeUtf8 $ "Bearer " <> backendApiKey)]
-                    else []
+          headers = [(hContentType, "application/json")] ++ authHeaders
 
-      Right (url, requestBody, headers)
+      Right (finalUrl, requestBody, headers)
 
 -- | Parse backend response into ChatResponse
 parseBackendResponse :: Backend -> BL.ByteString -> Either String ChatResponse
@@ -328,15 +545,119 @@
     Left err -> Left (T.unpack err)
     Right openAIFormat -> parseOpenAIResponse (encode openAIFormat)
 
--- | Parse Gemini format response (uses converter)
+-- | Parse Gemini format response
+-- Gemini response format: {"candidates": [{"content": {"role": "model", "parts": [{"text": "..."}]}}]}
 parseGeminiResponse :: BL.ByteString -> Either String ChatResponse
 parseGeminiResponse body = do
+  -- Debug logging (only if LOUTER_DEBUG is set)
+  let bodyPreview = BL.take 1000 body
+  debugTrace ("DEBUG: Gemini response body (first 1000 bytes): " <> show bodyPreview) $ return ()
+  debugTrace ("DEBUG: Gemini response body length: " <> show (BL.length body)) $ return ()
+
   obj <- eitherDecode body
-  -- Use geminiToOpenAI converter, then parse as OpenAI
-  case geminiToOpenAI "unknown" False obj of  -- False = non-streaming
-    Left err -> Left (T.unpack err)
-    Right openAIFormat -> parseOpenAIResponse (encode openAIFormat)
+  case obj of
+    Object o -> do
+      -- Extract model (optional)
+      let model = case HM.lookup "modelVersion" o of
+            Just (String m) -> m
+            _ -> "unknown"
 
+      -- Extract candidates array
+      candidates <- case HM.lookup "candidates" o of
+        Just (Array cs) -> Right $ V.toList cs
+        _ -> Left $ "Missing 'candidates' field in Gemini response. Available fields: "
+                 <> show (HM.keys o) <> ". Response body: " <> show (BL.take 500 body)
+
+      -- Parse each candidate as a choice
+      choices <- mapM (parseGeminiCandidate model) (zip [0..] candidates)
+
+      -- Extract usage metadata if present
+      let usage = case HM.lookup "usageMetadata" o of
+            Just (Object u) -> Just $ Usage
+              { usagePromptTokens = case HM.lookup "promptTokenCount" u of
+                  Just (Number n) -> floor n
+                  _ -> 0
+              , usageCompletionTokens = case HM.lookup "candidatesTokenCount" u of
+                  Just (Number n) -> floor n
+                  _ -> 0
+              , usageTotalTokens = case HM.lookup "totalTokenCount" u of
+                  Just (Number n) -> floor n
+                  _ -> 0
+              }
+            _ -> Nothing
+
+      -- Extract response ID (optional)
+      let respId = case HM.lookup "responseId" o of
+            Just (String i) -> i
+            _ -> "unknown"
+
+      Right $ ChatResponse respId model choices usage
+
+    _ -> Left "Expected JSON object for Gemini response"
+
+-- Parse a single Gemini candidate into a Choice
+parseGeminiCandidate :: Text -> (Int, Value) -> Either String Choice
+parseGeminiCandidate model (index, Object candidate) = do
+  -- Extract content object
+  content <- case HM.lookup "content" candidate of
+    Just (Object c) -> Right c
+    _ -> Left "Missing 'content' in candidate"
+
+  -- Extract parts array
+  parts <- case HM.lookup "parts" content of
+    Just (Array ps) -> Right $ V.toList ps
+    _ -> Left "Missing 'parts' in content"
+
+  -- Extract text from parts and function calls
+  let (texts, functionCalls) = extractGeminiParts parts
+
+  -- Combine all text parts
+  let messageText = T.intercalate " " texts
+
+  -- Parse finish reason
+  finishReason <- case HM.lookup "finishReason" candidate of
+    Just (String "STOP") -> Right $ Just FinishStop
+    Just (String "MAX_TOKENS") -> Right $ Just FinishLength
+    Just (String "SAFETY") -> Right $ Just FinishContentFilter
+    _ -> Right Nothing
+
+  Right $ Choice
+    { choiceIndex = index
+    , choiceMessage = messageText
+    , choiceToolCalls = functionCalls
+    , choiceFinishReason = finishReason
+    }
+parseGeminiCandidate _ (_, _) = Left "Expected object for candidate"
+
+-- Extract text and function calls from Gemini parts
+extractGeminiParts :: [Value] -> ([Text], [ResponseToolCall])
+extractGeminiParts parts =
+  let texts = [txt | Object part <- parts
+                    , Just (String txt) <- [HM.lookup "text" part]]
+
+      functionCalls = [call | Object part <- parts
+                             , Just call <- [parseGeminiFunctionCall part]]
+  in (texts, functionCalls)
+
+-- Parse a Gemini function call part
+parseGeminiFunctionCall :: HM.KeyMap Value -> Maybe ResponseToolCall
+parseGeminiFunctionCall part = do
+  Object funcCall <- HM.lookup "functionCall" part
+  String name <- HM.lookup "name" funcCall
+  args <- HM.lookup "args" funcCall
+
+  -- Generate an ID (Gemini doesn't provide one)
+  let callId = "call_" <> name
+
+  -- Encode args as proper JSON string
+  let argsJson = TE.decodeUtf8 $ BL.toStrict $ encode args
+
+  Just $ ResponseToolCall
+    { rtcId = callId
+    , rtcType = "function"
+    , rtcFunction = FunctionCall name argsJson
+    }
+
 -- Helper conversions for Anthropic
 chatMessageToAnthropic :: Message -> Value
 chatMessageToAnthropic msg = object
@@ -357,11 +678,22 @@
         RoleAssistant -> "model"
         RoleUser -> "user"
         _ -> "user"  -- Default for system/tool
-      parts = [object ["text" .= msgContent msg]]
+      -- Convert each ContentPart to Gemini part format
+      parts = map contentPartToGemini (msgContent msg)
   in object
       [ "role" .= (role :: Text)
       , "parts" .= parts
       ]
+
+-- Convert ContentPart to Gemini part format
+contentPartToGemini :: ContentPart -> Value
+contentPartToGemini (TextPart txt) = object ["text" .= txt]
+contentPartToGemini (ImagePart mediaType imageData) = object
+  [ "inline_data" .= object
+      [ "mime_type" .= mediaType
+      , "data" .= imageData
+      ]
+  ]
 
 chatToolToGemini :: Tool -> Value
 chatToolToGemini tool = object $
