11diff --git a/action.yml b/action.yml
2- index b6d0f05..e3e2145 100644
2+ index b6d0f05..054e0b8 100644
33--- a/action.yml
44+++ b/action.yml
55@@ -85,6 +85,14 @@ inputs:
@@ -33,66 +33,7 @@ index b6d0f05..e3e2145 100644
3333
3434 - name: Setup Custom Bun Path
3535 if: inputs.path_to_bun_executable != ''
36- @@ -237,6 +246,58 @@ runs:
37- echo "/usr/bin" >> "$GITHUB_PATH"
38- echo "/bin" >> "$GITHUB_PATH"
39-
40- + - name: Setup Python for LiteLLM
41- + if: inputs.use_litellm == 'true'
42- + uses: actions/setup-python@v5
43- + with:
44- + python-version: "3.12"
45- +
46- + - name: Install LiteLLM dependencies
47- + if: inputs.use_litellm == 'true'
48- + shell: bash
49- + run: |
50- + pip install litellm==1.83.10 fastapi==0.136.0 uvicorn==0.44.0 --quiet
51- +
52- + - name: Start LiteLLM Proxy
53- + if: inputs.use_litellm == 'true'
54- + id: litellm
55- + shell: bash
56- + env:
57- + LITELLM_MODEL: ${{ inputs.litellm_model }}
58- + run: |
59- + LITELLM_PORT=$(python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()")
60- + echo "Starting LiteLLM proxy on port ${LITELLM_PORT}..."
61- +
62- + LITELLM_PROXY_PORT=${LITELLM_PORT} python3 "${GITHUB_ACTION_PATH}/scripts/litellm-proxy.py" &
63- + LITELLM_PID=$!
64- + echo "LITELLM_PID=${LITELLM_PID}" >> "$GITHUB_ENV"
65- + echo "LITELLM_PORT=${LITELLM_PORT}" >> "$GITHUB_ENV"
66- +
67- + for i in $(seq 1 30); do
68- + if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:${LITELLM_PORT}/health/readiness" > /dev/null 2>&1; then
69- + echo "LiteLLM proxy is ready on port ${LITELLM_PORT}"
70- + break
71- + fi
72- + if ! kill -0 $LITELLM_PID 2>/dev/null; then
73- + echo "ERROR: LiteLLM proxy process died"
74- + exit 1
75- + fi
76- + sleep 2
77- + done
78- +
79- + if ! curl -sf --connect-timeout 2 --max-time 3 "http://localhost:${LITELLM_PORT}/health/readiness" > /dev/null 2>&1; then
80- + echo "ERROR: LiteLLM proxy failed to start after 60 seconds"
81- + exit 1
82- + fi
83- +
84- + # Set env vars for Claude Code CLI to use the proxy
85- + echo "ANTHROPIC_BASE_URL=http://localhost:${LITELLM_PORT}" >> "$GITHUB_ENV"
86- + echo "ANTHROPIC_MODEL=${{ inputs.litellm_model }}" >> "$GITHUB_ENV"
87- +
88- + # Set a dummy API key — the proxy handles auth via AICORE_SERVICE_KEY
89- + LITELLM_PROXY_API_KEY="litellm-proxy-key"
90- + echo "LITELLM_PROXY_API_KEY=${LITELLM_PROXY_API_KEY}" >> "$GITHUB_ENV"
91- +
92- - name: Run Claude Code Action
93- id: run
94- shell: bash
95- @@ -292,13 +353,14 @@ runs:
36+ @@ -292,13 +301,14 @@ runs:
9637 NODE_VERSION: ${{ env.NODE_VERSION }}
9738
9839 # Provider configuration
@@ -108,49 +49,17 @@ index b6d0f05..e3e2145 100644
10849
10950 # AWS configuration
11051 AWS_REGION: ${{ env.AWS_REGION }}
111- @@ -335,6 +397,18 @@ runs:
52+ @@ -335,6 +345,10 @@ runs:
11253 MCP_TOOL_TIMEOUT: ${{ env.MCP_TOOL_TIMEOUT }}
11354 MAX_MCP_OUTPUT_TOKENS: ${{ env.MAX_MCP_OUTPUT_TOKENS }}
11455
115- + # SAP AI Core configuration (for LiteLLM)
116- + AICORE_SERVICE_KEY: ${{ env.AICORE_SERVICE_KEY }}
117- + AICORE_AUTH_URL: ${{ env.AICORE_AUTH_URL }}
118- + AICORE_CLIENT_ID: ${{ env.AICORE_CLIENT_ID }}
119- + AICORE_CLIENT_SECRET: ${{ env.AICORE_CLIENT_SECRET }}
120- + AICORE_BASE_URL: ${{ env.AICORE_BASE_URL }}
121- + AICORE_RESOURCE_GROUP: ${{ env.AICORE_RESOURCE_GROUP }}
122- +
12356+ # SAP compliance configuration
12457+ DISABLE_TELEMETRY: "1"
12558+ DISABLE_ERROR_REPORTING: "1"
12659+
12760 # Telemetry configuration
12861 CLAUDE_CODE_ENABLE_TELEMETRY: ${{ env.CLAUDE_CODE_ENABLE_TELEMETRY }}
12962 OTEL_METRICS_EXPORTER: ${{ env.OTEL_METRICS_EXPORTER }}
130- @@ -372,6 +446,23 @@ runs:
131- echo "DYLD_FRAMEWORK_PATH="
132- } >> "$GITHUB_ENV"
133-
134- + - name: Stop LiteLLM Proxy
135- + if: always() && inputs.use_litellm == 'true'
136- + shell: bash
137- + run: |
138- + if [ -n "${LITELLM_PID}" ] && kill -0 "${LITELLM_PID}" 2>/dev/null; then
139- + echo "Stopping LiteLLM proxy (PID: ${LITELLM_PID})..."
140- + kill "${LITELLM_PID}" 2>/dev/null || true
141- + for _ in $(seq 1 20); do
142- + kill -0 "${LITELLM_PID}" 2>/dev/null || break
143- + sleep 0.5
144- + done
145- + kill -9 "${LITELLM_PID}" 2>/dev/null || true
146- + echo "LiteLLM proxy stopped."
147- + else
148- + echo "LiteLLM proxy already stopped."
149- + fi
150- +
151- - name: Cleanup SSH signing key
152- if: always() && inputs.ssh_signing_key != ''
153- shell: bash
15463diff --git a/base-action/src/validate-env.ts b/base-action/src/validate-env.ts
15564index 1f28da3..4eae3f2 100644
15665--- a/base-action/src/validate-env.ts
@@ -207,163 +116,3 @@ index 1f28da3..4eae3f2 100644
207116 }
208117
209118 if (errors.length > 0) {
210- diff --git a/scripts/litellm-proxy.py b/scripts/litellm-proxy.py
211- new file mode 100644
212- index 0000000..79817a5
213- --- /dev/null
214- +++ b/scripts/litellm-proxy.py
215- @@ -0,0 +1,154 @@
216- + """
217- + Minimal Anthropic Messages API proxy using LiteLLM SDK.
218- +
219- + Accepts Anthropic Messages API requests on /v1/messages and routes them
220- + through LiteLLM to SAP AI Core (or any other LiteLLM-supported provider).
221- +
222- + Uses litellm.anthropic.messages.acreate() which handles Anthropic format
223- + natively — no format translation, no Pydantic serialization bugs.
224- + """
225- +
226- + import json
227- + import logging
228- + import os
229- + import sys
230- +
231- + from fastapi import FastAPI, Request
232- + from fastapi.responses import JSONResponse, StreamingResponse
233- +
234- + import litellm
235- +
236- + # Drop unsupported params (e.g., 'thinking') instead of raising errors.
237- + # Claude Code CLI sends params that not all providers support.
238- + litellm.drop_params = True
239- +
240- + logging.basicConfig(level=logging.INFO, stream=sys.stderr)
241- + logger = logging.getLogger("litellm-proxy")
242- +
243- + app = FastAPI()
244- +
245- + # The LiteLLM model to route requests to (e.g., "sap/anthropic--claude-4.6-sonnet")
246- + LITELLM_MODEL = os.environ.get("LITELLM_MODEL", "sap/anthropic--claude-4.6-sonnet")
247- +
248- +
249- + @app.get("/health/readiness")
250- + async def health_readiness():
251- + return {"status": "ok"}
252- +
253- +
254- + @app.post("/v1/messages")
255- + async def messages(request: Request):
256- + """
257- + Proxy Anthropic Messages API requests through LiteLLM SDK.
258- +
259- + Claude Code CLI sends requests here (via ANTHROPIC_BASE_URL).
260- + We forward them to LiteLLM which routes to SAP AI Core.
261- + """
262- + try:
263- + body = await request.json()
264- + except Exception:
265- + return JSONResponse(
266- + status_code=400,
267- + content={
268- + "type": "error",
269- + "error": {
270- + "type": "invalid_request_error",
271- + "message": "Request body is not valid JSON.",
272- + },
273- + },
274- + )
275- +
276- + original_model = body.get("model", "unknown")
277- + body["model"] = LITELLM_MODEL
278- +
279- + logger.info(
280- + f"Proxying request: {original_model} -> {LITELLM_MODEL}, stream={body.get('stream', False)}"
281- + )
282- +
283- + is_streaming = body.get("stream", False)
284- +
285- + try:
286- + if is_streaming:
287- + return await _handle_streaming(body)
288- + else:
289- + return await _handle_non_streaming(body)
290- + except Exception as e:
291- + logger.exception(f"Error proxying request: {e}")
292- + return JSONResponse(
293- + status_code=500,
294- + content={
295- + "type": "error",
296- + "error": {
297- + "type": "api_error",
298- + "message": "An internal proxy error occurred. Check server logs for details.",
299- + },
300- + },
301- + )
302- +
303- +
304- + async def _handle_non_streaming(body: dict) -> JSONResponse:
305- + """Handle non-streaming Anthropic Messages API request."""
306- + body.pop("stream", None)
307- +
308- + response = await litellm.anthropic.messages.acreate(**body)
309- +
310- + # litellm.anthropic.messages.acreate() returns Anthropic-format response
311- + if hasattr(response, "model_dump"):
312- + result = response.model_dump()
313- + elif isinstance(response, dict):
314- + result = response
315- + else:
316- + result = json.loads(str(response))
317- +
318- + return JSONResponse(content=result)
319- +
320- +
321- + async def _handle_streaming(body: dict) -> StreamingResponse:
322- + """Handle streaming Anthropic Messages API request."""
323- + body["stream"] = True
324- +
325- + response = await litellm.anthropic.messages.acreate(**body)
326- +
327- + async def event_generator():
328- + """Yield SSE events from LiteLLM streaming response."""
329- + try:
330- + async for chunk in response:
331- + # Each chunk from litellm.anthropic.messages.acreate(stream=True)
332- + # is already in Anthropic SSE format
333- + if hasattr(chunk, "model_dump"):
334- + data = chunk.model_dump()
335- + elif isinstance(chunk, dict):
336- + data = chunk
337- + else:
338- + data = json.loads(str(chunk))
339- +
340- + event_type = data.get("type")
341- + if not event_type:
342- + logger.warning("Chunk missing 'type' field: %s", data)
343- + continue
344- + yield f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
345- + except Exception as e:
346- + logger.exception(f"Streaming error: {e}")
347- + error_data = {
348- + "type": "error",
349- + "error": {"type": "api_error", "message": "An internal proxy error occurred. Check server logs for details."},
350- + }
351- + yield f"event: error\ndata: {json.dumps(error_data)}\n\n"
352- +
353- + return StreamingResponse(
354- + event_generator(),
355- + media_type="text/event-stream",
356- + headers={
357- + "Cache-Control": "no-cache",
358- + "Connection": "keep-alive",
359- + "X-Accel-Buffering": "no",
360- + },
361- + )
362- +
363- +
364- + if __name__ == "__main__":
365- + import uvicorn
366- +
367- + port = int(os.environ.get("LITELLM_PROXY_PORT", "4000"))
368- + logger.info(f"Starting LiteLLM proxy on port {port}, model={LITELLM_MODEL}")
369- + uvicorn.run(app, host="127.0.0.1", port=port, log_level="info")
0 commit comments