Skip to content

Commit ef41208

Browse files
Isolate AICORE secrets from Claude Code process environment (#742)
Move LiteLLM proxy lifecycle (start/stop) out of the upstream action patch and into the workflow as separate shell steps. AICORE_* secrets are now scoped only to the proxy start step, so Claude Code never has real credentials in its process environment. The proxy script is moved from the patch into the repo at .github/scripts/litellm-proxy.py. The upstream patch is simplified to only contain input definitions, conditional env logic, SAP compliance flags, and validate-env.ts changes. Python 3.12 and LiteLLM dependency installation are added to the setup composite action.
1 parent ca2a789 commit ef41208

4 files changed

Lines changed: 211 additions & 260 deletions

File tree

.github/actions/setup-claude-code-action/action.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,12 @@ runs:
3737
run: |
3838
curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.13"
3939
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
40+
41+
- name: Setup Python
42+
uses: actions/setup-python@v5
43+
with:
44+
python-version: "3.12"
45+
46+
- name: Install LiteLLM dependencies
47+
shell: bash
48+
run: pip install litellm==1.83.10 fastapi==0.136.0 uvicorn==0.44.0 --quiet
Lines changed: 3 additions & 254 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
diff --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
15463
diff --git a/base-action/src/validate-env.ts b/base-action/src/validate-env.ts
15564
index 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

Comments
 (0)