From eef2b756c2a5ed99b92c3f6b6e082a6e7fb42f44 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Tue, 9 Dec 2025 18:26:29 +0800 Subject: [PATCH] feat: add configurable custom HTTP headers to API requests - Add support for custom HTTP headers in API requests, configurable via workflow and environment variables - Inject version and commit information into the Docker build and application binary - Document default and custom HTTP header usage in all README files, including examples for single-line and multiline formats - Update action metadata and input definitions to include custom headers - Refactor HTTP client creation to always include default headers, and allow merging with user-supplied headers - Implement and test header parsing logic supporting both comma-separated and multiline formats, with error handling for invalid formats - Add comprehensive unit tests for header parsing and merging behavior - Ensure default headers are present in all API requests, and allow user headers to override them if needed Signed-off-by: Bo-Yi Wu --- .github/workflows/docker.yml | 19 +++++ Dockerfile | 10 ++- README.md | 90 +++++++++++++++++--- README.zh-CN.md | 114 ++++++++++++++++++++----- README.zh-TW.md | 114 ++++++++++++++++++++----- action.yml | 4 + client.go | 98 +++++++++++++++------ client_test.go | 74 +++++++++++++--- config.go | 45 ++++++++++ config_test.go | 159 +++++++++++++++++++++++++++++++++++ version.go | 27 ++++++ 11 files changed, 657 insertions(+), 97 deletions(-) create mode 100644 version.go diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 2ff3984..e48e37a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -73,6 +73,19 @@ jobs: uses: docker/setup-buildx-action@v3 if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' + - name: Get version info + id: version + if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' + run: | + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + else + VERSION="dev" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "commit=${{ github.sha }}" >> $GITHUB_OUTPUT + - name: Docker meta id: docker-meta if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' @@ -95,6 +108,9 @@ jobs: file: Dockerfile load: true tags: ${{ env.REPO }}:scan + build-args: | + VERSION=${{ steps.version.outputs.version }} + COMMIT=${{ steps.version.outputs.commit }} cache-from: type=gha cache-to: type=gha,mode=max @@ -132,5 +148,8 @@ jobs: push: true tags: ${{ steps.docker-meta.outputs.tags }} labels: ${{ steps.docker-meta.outputs.labels }} + build-args: | + VERSION=${{ steps.version.outputs.version }} + COMMIT=${{ steps.version.outputs.commit }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 0678d1f..6d620c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,9 @@ FROM golang:1.25-alpine AS builder +# Build arguments for version injection +ARG VERSION=dev +ARG COMMIT=unknown + WORKDIR /app # Copy go mod files @@ -11,8 +15,10 @@ RUN go mod download # Copy source code COPY . . -# Build the application -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o llm-action . +# Build the application with version information +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \ + -ldflags "-s -w -X main.Version=${VERSION} -X main.Commit=${COMMIT}" \ + -o llm-action . # Final stage FROM alpine:3.22 diff --git a/README.md b/README.md index f9a67b2..d0b9d32 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,12 @@ A GitHub Action to interact with OpenAI-compatible LLM services, supporting cust - [Using with Ollama](#using-with-ollama) - [Chain Multiple LLM Calls](#chain-multiple-llm-calls) - [Debug Mode](#debug-mode) + - [Custom HTTP Headers](#custom-http-headers) + - [Default Headers](#default-headers) + - [Custom Headers](#custom-headers) + - [Single Line Format](#single-line-format) + - [Multiline Format](#multiline-format) + - [Headers with Custom Authentication](#headers-with-custom-authentication) - [Supported Services](#supported-services) - [Security Considerations](#security-considerations) - [License](#license) @@ -62,6 +68,7 @@ A GitHub Action to interact with OpenAI-compatible LLM services, supporting cust - 🐛 Debug mode with secure API key masking - 🎨 Go template support for dynamic prompts with environment variables - 🛠️ Structured output via function calling (tool schema support) +- 📋 Custom HTTP headers support for log analysis and custom authentication ## Inputs @@ -78,13 +85,14 @@ A GitHub Action to interact with OpenAI-compatible LLM services, supporting cust | `temperature` | Temperature for response randomness (0.0-2.0) | No | `0.7` | | `max_tokens` | Maximum tokens in the response | No | `1000` | | `debug` | Enable debug mode to print all parameters (API key will be masked) | No | `false` | +| `headers` | Custom HTTP headers for API requests. Format: `Header1:Value1,Header2:Value2` or multiline | No | `''` | ## Outputs -| Output | Description | -| ------------ | ------------------------------------------------------------------------------------------------- | -| `response` | The raw response from the LLM (always available) | -| `` | When using tool_schema, each field from the function arguments JSON becomes a separate output | +| Output | Description | +| ---------- | --------------------------------------------------------------------------------------------- | +| `response` | The raw response from the LLM (always available) | +| `` | When using tool_schema, each field from the function arguments JSON becomes a separate output | **Output Behavior:** @@ -142,7 +150,7 @@ uses: appleboy/LLM-action@main ### With System Prompt -```yaml +````yaml - name: Code Review with LLM id: review uses: appleboy/LLM-action@v1 @@ -162,7 +170,7 @@ uses: appleboy/LLM-action@main - name: Post Review Comment run: | echo "${{ steps.review.outputs.response }}" -``` +```` ### With Multiline System Prompt @@ -194,7 +202,7 @@ uses: appleboy/LLM-action@main Instead of embedding long prompts in YAML, you can load them from a file: -```yaml +````yaml - name: Code Review with Prompt File id: review uses: appleboy/LLM-action@v1 @@ -208,7 +216,7 @@ Instead of embedding long prompts in YAML, you can load them from a file: def calculate(x, y): return x / y ``` -``` +```` Or using `file://` prefix: @@ -417,7 +425,7 @@ Use `tool_schema` to get structured JSON output from the LLM using function call #### Code Review with Structured Output -```yaml +````yaml - name: Structured Code Review id: review uses: appleboy/LLM-action@v1 @@ -466,7 +474,7 @@ Use `tool_schema` to get structured JSON output from the LLM using function call echo "Score: $SCORE" echo "Issues: $ISSUES" echo "Suggestions: $SUGGESTIONS" -``` +```` **Why use environment variables instead of direct interpolation?** @@ -708,6 +716,68 @@ main.Config{ **Security Note:** When debug mode is enabled, the API key is automatically masked (only showing first 4 and last 4 characters) to prevent accidental exposure in logs. +### Custom HTTP Headers + +#### Default Headers + +Every API request automatically includes the following headers for identification and log analysis: + +| Header | Value | Description | +| ------------------ | ---------------------- | --------------------------------------- | +| `User-Agent` | `LLM-action/{version}` | Standard User-Agent with action version | +| `X-Action-Name` | `appleboy/LLM-action` | Full name of the GitHub Action | +| `X-Action-Version` | `{version}` | Semantic version of the action | + +These headers help you identify requests from this action in your LLM service logs. + +#### Custom Headers + +Use the `headers` input to add custom HTTP headers to API requests. This is useful for: + +- Adding request tracking IDs for log analysis +- Custom authentication headers +- Passing metadata to your LLM service + +#### Single Line Format + +```yaml +- name: Call LLM with Custom Headers + uses: appleboy/LLM-action@v1 + with: + api_key: ${{ secrets.OPENAI_API_KEY }} + input_prompt: "Hello, world!" + headers: "X-Request-ID:${{ github.run_id }},X-Trace-ID:${{ github.sha }}" +``` + +#### Multiline Format + +```yaml +- name: Call LLM with Multiple Headers + uses: appleboy/LLM-action@v1 + with: + api_key: ${{ secrets.OPENAI_API_KEY }} + input_prompt: "Analyze this code" + headers: | + X-Request-ID:${{ github.run_id }} + X-Trace-ID:${{ github.sha }} + X-Environment:production + X-Repository:${{ github.repository }} +``` + +#### Headers with Custom Authentication + +```yaml +- name: Call Custom LLM Service + uses: appleboy/LLM-action@v1 + with: + base_url: "https://your-llm-service.com/v1" + api_key: ${{ secrets.LLM_API_KEY }} + input_prompt: "Generate a summary" + headers: | + X-Custom-Auth:${{ secrets.CUSTOM_AUTH_TOKEN }} + X-Tenant-ID:my-tenant +``` + ## Supported Services This action works with any OpenAI-compatible API, including: diff --git a/README.zh-CN.md b/README.zh-CN.md index f8a7562..e06209c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -44,6 +44,12 @@ - [搭配 Ollama 使用](#搭配-ollama-使用) - [链接多个 LLM 调用](#链接多个-llm-调用) - [调试模式](#调试模式) + - [自定义 HTTP Headers](#自定义-http-headers) + - [默认 Headers](#默认-headers) + - [自定义 Headers](#自定义-headers) + - [单行格式](#单行格式) + - [多行格式](#多行格式) + - [搭配自定义认证使用](#搭配自定义认证使用) - [支持的服务](#支持的服务) - [安全考量](#安全考量) - [授权](#授权) @@ -62,29 +68,31 @@ - 🐛 调试模式,并安全地屏蔽 API 密钥 - 🎨 支持 Go 模板语法,可动态插入环境变量 - 🛠️ 通过函数调用支持结构化输出(tool schema 支持) +- 📋 支持自定义 HTTP headers,适用于日志分析和自定义认证 ## 输入参数 -| 输入 | 说明 | 必填 | 默认值 | -| ----------------- | ------------------------------------------------------------------------------- | ---- | --------------------------- | -| `base_url` | OpenAI 兼容 API 端点的基础 URL | 否 | `https://api.openai.com/v1` | -| `api_key` | 用于验证的 API 密钥 | 是 | - | -| `model` | 要使用的模型名称 | 否 | `gpt-4o` | -| `skip_ssl_verify` | 跳过 SSL 证书验证 | 否 | `false` | -| `ca_cert` | 自定义 CA 证书。支持证书内容、文件路径或 URL | 否 | `''` | -| `system_prompt` | 设定上下文的系统提示词。支持纯文本、文件路径或 URL。支持 Go 模板语法与环境变量 | 否 | `''` | -| `input_prompt` | 用户输入给 LLM 的提示词。支持纯文本、文件路径或 URL。支持 Go 模板语法与环境变量 | 是 | - | +| 输入 | 说明 | 必填 | 默认值 | +| ----------------- | -------------------------------------------------------------------------------------- | ---- | --------------------------- | +| `base_url` | OpenAI 兼容 API 端点的基础 URL | 否 | `https://api.openai.com/v1` | +| `api_key` | 用于验证的 API 密钥 | 是 | - | +| `model` | 要使用的模型名称 | 否 | `gpt-4o` | +| `skip_ssl_verify` | 跳过 SSL 证书验证 | 否 | `false` | +| `ca_cert` | 自定义 CA 证书。支持证书内容、文件路径或 URL | 否 | `''` | +| `system_prompt` | 设定上下文的系统提示词。支持纯文本、文件路径或 URL。支持 Go 模板语法与环境变量 | 否 | `''` | +| `input_prompt` | 用户输入给 LLM 的提示词。支持纯文本、文件路径或 URL。支持 Go 模板语法与环境变量 | 是 | - | | `tool_schema` | 用于结构化输出的 JSON schema(函数调用)。支持纯文本、文件路径或 URL。支持 Go 模板语法 | 否 | `''` | -| `temperature` | 响应随机性的温度值(0.0-2.0) | 否 | `0.7` | -| `max_tokens` | 响应中的最大令牌数 | 否 | `1000` | -| `debug` | 启用调试模式以显示所有参数(API 密钥将被屏蔽) | 否 | `false` | +| `temperature` | 响应随机性的温度值(0.0-2.0) | 否 | `0.7` | +| `max_tokens` | 响应中的最大令牌数 | 否 | `1000` | +| `debug` | 启用调试模式以显示所有参数(API 密钥将被屏蔽) | 否 | `false` | +| `headers` | 自定义 HTTP headers。格式:`Header1:Value1,Header2:Value2` 或多行格式 | 否 | `''` | ## 输出参数 -| 输出 | 说明 | -| ---------- | ------------------------------------------------------------------------ | -| `response` | 来自 LLM 的原始响应(始终可用) | -| `` | 使用 tool_schema 时,函数参数 JSON 中的每个字段都会成为独立的输出 | +| 输出 | 说明 | +| ---------- | ----------------------------------------------------------------- | +| `response` | 来自 LLM 的原始响应(始终可用) | +| `` | 使用 tool_schema 时,函数参数 JSON 中的每个字段都会成为独立的输出 | **输出行为:** @@ -142,7 +150,7 @@ uses: appleboy/LLM-action@main ### 使用系统提示词 -```yaml +````yaml - name: Code Review with LLM id: review uses: appleboy/LLM-action@v1 @@ -162,7 +170,7 @@ uses: appleboy/LLM-action@main - name: Post Review Comment run: | echo "${{ steps.review.outputs.response }}" -``` +```` ### 使用多行系统提示词 @@ -194,7 +202,7 @@ uses: appleboy/LLM-action@main 无需在 YAML 中嵌入冗长的提示词,可以从文件加载: -```yaml +````yaml - name: Code Review with Prompt File id: review uses: appleboy/LLM-action@v1 @@ -208,7 +216,7 @@ uses: appleboy/LLM-action@main def calculate(x, y): return x / y ``` -``` +```` 或使用 `file://` 前缀: @@ -417,7 +425,7 @@ uses: appleboy/LLM-action@main #### 结构化代码审查 -```yaml +````yaml - name: Structured Code Review id: review uses: appleboy/LLM-action@v1 @@ -466,7 +474,7 @@ uses: appleboy/LLM-action@main echo "评分:$SCORE" echo "问题:$ISSUES" echo "建议:$SUGGESTIONS" -``` +```` **为什么使用环境变量而非直接插值?** @@ -708,6 +716,68 @@ main.Config{ **安全说明:** 当启用调试模式时,API 密钥会自动屏蔽(仅显示前 4 个和后 4 个字符),以防止在日志中意外泄露。 +### 自定义 HTTP Headers + +#### 默认 Headers + +每个 API 请求都会自动包含以下 headers,用于识别和日志分析: + +| Header | 值 | 说明 | +| ------------------ | ---------------------- | --------------------------------- | +| `User-Agent` | `LLM-action/{version}` | 标准 User-Agent,包含 Action 版本 | +| `X-Action-Name` | `appleboy/LLM-action` | GitHub Action 的完整名称 | +| `X-Action-Version` | `{version}` | Action 的语义化版本号 | + +这些 headers 可帮助您在 LLM 服务日志中识别来自此 Action 的请求。 + +#### 自定义 Headers + +使用 `headers` 输入参数为 API 请求添加自定义 HTTP headers。适用于: + +- 添加请求追踪 ID 以进行日志分析 +- 自定义认证标头 +- 传递元数据给您的 LLM 服务 + +#### 单行格式 + +```yaml +- name: Call LLM with Custom Headers + uses: appleboy/LLM-action@v1 + with: + api_key: ${{ secrets.OPENAI_API_KEY }} + input_prompt: "Hello, world!" + headers: "X-Request-ID:${{ github.run_id }},X-Trace-ID:${{ github.sha }}" +``` + +#### 多行格式 + +```yaml +- name: Call LLM with Multiple Headers + uses: appleboy/LLM-action@v1 + with: + api_key: ${{ secrets.OPENAI_API_KEY }} + input_prompt: "分析此代码" + headers: | + X-Request-ID:${{ github.run_id }} + X-Trace-ID:${{ github.sha }} + X-Environment:production + X-Repository:${{ github.repository }} +``` + +#### 搭配自定义认证使用 + +```yaml +- name: Call Custom LLM Service + uses: appleboy/LLM-action@v1 + with: + base_url: "https://your-llm-service.com/v1" + api_key: ${{ secrets.LLM_API_KEY }} + input_prompt: "生成摘要" + headers: | + X-Custom-Auth:${{ secrets.CUSTOM_AUTH_TOKEN }} + X-Tenant-ID:my-tenant +``` + ## 支持的服务 此 Action 适用于任何 OpenAI 兼容的 API,包括: diff --git a/README.zh-TW.md b/README.zh-TW.md index 6aba583..a7fa82d 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -44,6 +44,12 @@ - [搭配 Ollama 使用](#搭配-ollama-使用) - [鏈結多個 LLM 呼叫](#鏈結多個-llm-呼叫) - [偵錯模式](#偵錯模式) + - [自訂 HTTP Headers](#自訂-http-headers) + - [預設 Headers](#預設-headers) + - [自訂 Headers](#自訂-headers) + - [單行格式](#單行格式) + - [多行格式](#多行格式) + - [搭配自訂認證使用](#搭配自訂認證使用) - [支援的服務](#支援的服務) - [安全考量](#安全考量) - [授權](#授權) @@ -62,29 +68,31 @@ - 🐛 偵錯模式,並安全地遮罩 API 金鑰 - 🎨 支援 Go 模板語法,可動態插入環境變數 - 🛠️ 透過函數呼叫支援結構化輸出(tool schema 支援) +- 📋 支援自訂 HTTP headers,適用於日誌分析和自訂認證 ## 輸入參數 -| 輸入 | 說明 | 必填 | 預設值 | -| ----------------- | --------------------------------------------------------------------------------- | ---- | --------------------------- | -| `base_url` | OpenAI 相容 API 端點的基礎 URL | 否 | `https://api.openai.com/v1` | -| `api_key` | 用於驗證的 API 金鑰 | 是 | - | -| `model` | 要使用的模型名稱 | 否 | `gpt-4o` | -| `skip_ssl_verify` | 跳過 SSL 憑證驗證 | 否 | `false` | -| `ca_cert` | 自訂 CA 憑證。支援憑證內容、檔案路徑或 URL | 否 | `''` | -| `system_prompt` | 設定情境的系統提示詞。支援純文字、檔案路徑或 URL。支援 Go 模板語法與環境變數 | 否 | `''` | -| `input_prompt` | 使用者輸入給 LLM 的提示詞。支援純文字、檔案路徑或 URL。支援 Go 模板語法與環境變數 | 是 | - | +| 輸入 | 說明 | 必填 | 預設值 | +| ----------------- | -------------------------------------------------------------------------------------- | ---- | --------------------------- | +| `base_url` | OpenAI 相容 API 端點的基礎 URL | 否 | `https://api.openai.com/v1` | +| `api_key` | 用於驗證的 API 金鑰 | 是 | - | +| `model` | 要使用的模型名稱 | 否 | `gpt-4o` | +| `skip_ssl_verify` | 跳過 SSL 憑證驗證 | 否 | `false` | +| `ca_cert` | 自訂 CA 憑證。支援憑證內容、檔案路徑或 URL | 否 | `''` | +| `system_prompt` | 設定情境的系統提示詞。支援純文字、檔案路徑或 URL。支援 Go 模板語法與環境變數 | 否 | `''` | +| `input_prompt` | 使用者輸入給 LLM 的提示詞。支援純文字、檔案路徑或 URL。支援 Go 模板語法與環境變數 | 是 | - | | `tool_schema` | 用於結構化輸出的 JSON schema(函數呼叫)。支援純文字、檔案路徑或 URL。支援 Go 模板語法 | 否 | `''` | -| `temperature` | 回應隨機性的溫度值(0.0-2.0) | 否 | `0.7` | -| `max_tokens` | 回應中的最大權杖數 | 否 | `1000` | -| `debug` | 啟用偵錯模式以顯示所有參數(API 金鑰將被遮罩) | 否 | `false` | +| `temperature` | 回應隨機性的溫度值(0.0-2.0) | 否 | `0.7` | +| `max_tokens` | 回應中的最大權杖數 | 否 | `1000` | +| `debug` | 啟用偵錯模式以顯示所有參數(API 金鑰將被遮罩) | 否 | `false` | +| `headers` | 自訂 HTTP headers。格式:`Header1:Value1,Header2:Value2` 或多行格式 | 否 | `''` | ## 輸出參數 -| 輸出 | 說明 | -| ---------- | ------------------------------------------------------------------------ | -| `response` | 來自 LLM 的原始回應(始終可用) | -| `` | 使用 tool_schema 時,函數參數 JSON 中的每個欄位都會成為獨立的輸出 | +| 輸出 | 說明 | +| ---------- | ----------------------------------------------------------------- | +| `response` | 來自 LLM 的原始回應(始終可用) | +| `` | 使用 tool_schema 時,函數參數 JSON 中的每個欄位都會成為獨立的輸出 | **輸出行為:** @@ -142,7 +150,7 @@ uses: appleboy/LLM-action@main ### 使用系統提示詞 -```yaml +````yaml - name: Code Review with LLM id: review uses: appleboy/LLM-action@v1 @@ -162,7 +170,7 @@ uses: appleboy/LLM-action@main - name: Post Review Comment run: | echo "${{ steps.review.outputs.response }}" -``` +```` ### 使用多行系統提示詞 @@ -194,7 +202,7 @@ uses: appleboy/LLM-action@main 不需要在 YAML 中嵌入冗長的提示詞,可以從檔案載入: -```yaml +````yaml - name: Code Review with Prompt File id: review uses: appleboy/LLM-action@v1 @@ -208,7 +216,7 @@ uses: appleboy/LLM-action@main def calculate(x, y): return x / y ``` -``` +```` 或使用 `file://` 前綴: @@ -417,7 +425,7 @@ uses: appleboy/LLM-action@main #### 結構化程式碼審查 -```yaml +````yaml - name: Structured Code Review id: review uses: appleboy/LLM-action@v1 @@ -466,7 +474,7 @@ uses: appleboy/LLM-action@main echo "評分:$SCORE" echo "問題:$ISSUES" echo "建議:$SUGGESTIONS" -``` +```` **為什麼使用環境變數而非直接插值?** @@ -708,6 +716,68 @@ main.Config{ **安全說明:** 當啟用偵錯模式時,API 金鑰會自動遮罩(僅顯示前 4 個和後 4 個字元),以防止在日誌中意外洩露。 +### 自訂 HTTP Headers + +#### 預設 Headers + +每個 API 請求都會自動包含以下 headers,用於識別和日誌分析: + +| Header | 值 | 說明 | +| ------------------ | ---------------------- | --------------------------------- | +| `User-Agent` | `LLM-action/{version}` | 標準 User-Agent,包含 Action 版本 | +| `X-Action-Name` | `appleboy/LLM-action` | GitHub Action 的完整名稱 | +| `X-Action-Version` | `{version}` | Action 的語意化版本號 | + +這些 headers 可協助您在 LLM 服務日誌中識別來自此 Action 的請求。 + +#### 自訂 Headers + +使用 `headers` 輸入參數為 API 請求添加自訂 HTTP headers。適用於: + +- 添加請求追蹤 ID 以進行日誌分析 +- 自訂認證標頭 +- 傳遞元資料給您的 LLM 服務 + +#### 單行格式 + +```yaml +- name: Call LLM with Custom Headers + uses: appleboy/LLM-action@v1 + with: + api_key: ${{ secrets.OPENAI_API_KEY }} + input_prompt: "Hello, world!" + headers: "X-Request-ID:${{ github.run_id }},X-Trace-ID:${{ github.sha }}" +``` + +#### 多行格式 + +```yaml +- name: Call LLM with Multiple Headers + uses: appleboy/LLM-action@v1 + with: + api_key: ${{ secrets.OPENAI_API_KEY }} + input_prompt: "分析此程式碼" + headers: | + X-Request-ID:${{ github.run_id }} + X-Trace-ID:${{ github.sha }} + X-Environment:production + X-Repository:${{ github.repository }} +``` + +#### 搭配自訂認證使用 + +```yaml +- name: Call Custom LLM Service + uses: appleboy/LLM-action@v1 + with: + base_url: "https://your-llm-service.com/v1" + api_key: ${{ secrets.LLM_API_KEY }} + input_prompt: "產生摘要" + headers: | + X-Custom-Auth:${{ secrets.CUSTOM_AUTH_TOKEN }} + X-Tenant-ID:my-tenant +``` + ## 支援的服務 此 Action 適用於任何 OpenAI 相容的 API,包括: diff --git a/action.yml b/action.yml index 1717c06..567a2ca 100644 --- a/action.yml +++ b/action.yml @@ -45,6 +45,10 @@ inputs: description: 'Enable debug mode to print all parameters' required: false default: 'false' + headers: + description: 'Custom HTTP headers to include in API requests. Format: "Header1:Value1,Header2:Value2" or multiline with one header per line. Useful for log analysis or custom authentication.' + required: false + default: '' outputs: response: diff --git a/client.go b/client.go index ccb0652..1b9f9bd 100644 --- a/client.go +++ b/client.go @@ -9,55 +9,99 @@ import ( openai "github.com/sashabaranov/go-openai" ) +// headerTransport wraps an http.RoundTripper to add custom headers to requests +type headerTransport struct { + base http.RoundTripper + headers map[string]string +} + +// RoundTrip implements http.RoundTripper interface +func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Clone the request to avoid modifying the original + reqClone := req.Clone(req.Context()) + for key, value := range t.headers { + reqClone.Header.Set(key, value) + } + return t.base.RoundTrip(reqClone) +} + // NewClient creates a new OpenAI client with the given configuration func NewClient(config *Config) (*openai.Client, error) { clientConfig := openai.DefaultConfig(config.APIKey) clientConfig.BaseURL = config.BaseURL - // Handle custom CA certificate and SSL verification - httpClient, err := createHTTPClient(config.CACert, config.SkipSSLVerify) + // Handle custom CA certificate, SSL verification, and headers + httpClient, err := createHTTPClient(config.CACert, config.SkipSSLVerify, config.Headers) if err != nil { return nil, err } - if httpClient != nil { - clientConfig.HTTPClient = httpClient - } + clientConfig.HTTPClient = httpClient return openai.NewClientWithConfig(clientConfig), nil } -// createHTTPClient creates an HTTP client with optional custom CA certificate and SSL verification settings -// Returns nil if no custom configuration is needed -func createHTTPClient(caCert string, skipSSLVerify bool) (*http.Client, error) { - // If no custom CA cert and SSL verification is enabled, use default client - if caCert == "" && !skipSSLVerify { - return nil, nil +// getDefaultHeaders returns the default headers for all API requests +func getDefaultHeaders() map[string]string { + return map[string]string{ + "User-Agent": GetUserAgent(), + "X-Action-Name": ActionName, + "X-Action-Version": Version, } +} - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, +// mergeHeaders merges custom headers with default headers. +// Custom headers take precedence over default headers. +func mergeHeaders(customHeaders map[string]string) map[string]string { + merged := getDefaultHeaders() + for key, value := range customHeaders { + merged[key] = value } + return merged +} - // Handle custom CA certificate - if caCert != "" { - caCertPool := x509.NewCertPool() - if ok := caCertPool.AppendCertsFromPEM([]byte(caCert)); !ok { - return nil, fmt.Errorf("failed to parse CA certificate") +// createHTTPClient creates an HTTP client with optional custom CA certificate, +// SSL verification settings, and headers (including default action headers). +func createHTTPClient( + caCert string, + skipSSLVerify bool, + customHeaders map[string]string, +) (*http.Client, error) { + baseTransport := http.DefaultTransport + + // Only create custom TLS transport if needed + if caCert != "" || skipSSLVerify { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + // Handle custom CA certificate + if caCert != "" { + caCertPool := x509.NewCertPool() + if ok := caCertPool.AppendCertsFromPEM([]byte(caCert)); !ok { + return nil, fmt.Errorf("failed to parse CA certificate") + } + tlsConfig.RootCAs = caCertPool } - tlsConfig.RootCAs = caCertPool - } - // Handle SSL verification skip - // #nosec G402 - This is intentionally configurable by the user for local/self-hosted LLM services - if skipSSLVerify { - tlsConfig.InsecureSkipVerify = true + // Handle SSL verification skip + // #nosec G402 - This is intentionally configurable by the user for local/self-hosted LLM services + if skipSSLVerify { + tlsConfig.InsecureSkipVerify = true + } + + baseTransport = &http.Transport{ + TLSClientConfig: tlsConfig, + } } - customTransport := &http.Transport{ - TLSClientConfig: tlsConfig, + // Always wrap transport with headers (default + custom) + allHeaders := mergeHeaders(customHeaders) + finalTransport := &headerTransport{ + base: baseTransport, + headers: allHeaders, } return &http.Client{ - Transport: customTransport, + Transport: finalTransport, }, nil } diff --git a/client_test.go b/client_test.go index 78fa300..f1ee55a 100644 --- a/client_test.go +++ b/client_test.go @@ -97,42 +97,56 @@ func TestCreateHTTPClient(t *testing.T) { name string caCert string skipSSLVerify bool - expectNil bool + customHeaders map[string]string expectError bool }{ { - name: "No custom config returns nil", + name: "Default config with default headers", caCert: "", skipSSLVerify: false, - expectNil: true, + customHeaders: nil, expectError: false, }, { name: "Skip SSL only", caCert: "", skipSSLVerify: true, - expectNil: false, + customHeaders: nil, expectError: false, }, { name: "Invalid CA certificate", caCert: "invalid-cert", skipSSLVerify: false, - expectNil: false, + customHeaders: nil, expectError: true, }, { name: "Invalid CA certificate with skip SSL", caCert: "invalid-cert", skipSSLVerify: true, - expectNil: false, + customHeaders: nil, expectError: true, }, + { + name: "Custom headers only", + caCert: "", + skipSSLVerify: false, + customHeaders: map[string]string{"X-Custom": "value"}, + expectError: false, + }, + { + name: "Custom headers with skip SSL", + caCert: "", + skipSSLVerify: true, + customHeaders: map[string]string{"X-Request-ID": "test123"}, + expectError: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - client, err := createHTTPClient(tt.caCert, tt.skipSSLVerify) + client, err := createHTTPClient(tt.caCert, tt.skipSSLVerify, tt.customHeaders) if tt.expectError { if err == nil { @@ -146,13 +160,7 @@ func TestCreateHTTPClient(t *testing.T) { return } - if tt.expectNil { - if client != nil { - t.Error("expected nil client, got non-nil") - } - return - } - + // Client should always be non-nil (default headers are always added) if client == nil { t.Error("expected non-nil client, got nil") return @@ -164,3 +172,41 @@ func TestCreateHTTPClient(t *testing.T) { }) } } + +func TestGetDefaultHeaders(t *testing.T) { + headers := getDefaultHeaders() + + if headers["User-Agent"] == "" { + t.Error("expected User-Agent header to be set") + } + if headers["X-Action-Name"] != ActionName { + t.Errorf("expected X-Action-Name to be %s, got %s", ActionName, headers["X-Action-Name"]) + } + if headers["X-Action-Version"] == "" { + t.Error("expected X-Action-Version header to be set") + } +} + +func TestMergeHeaders(t *testing.T) { + customHeaders := map[string]string{ + "X-Custom": "value", + "User-Agent": "custom-agent", // Override default + } + + merged := mergeHeaders(customHeaders) + + // Custom headers should be present + if merged["X-Custom"] != "value" { + t.Errorf("expected X-Custom to be 'value', got '%s'", merged["X-Custom"]) + } + + // Custom User-Agent should override default + if merged["User-Agent"] != "custom-agent" { + t.Errorf("expected User-Agent to be 'custom-agent', got '%s'", merged["User-Agent"]) + } + + // Default headers should still be present + if merged["X-Action-Name"] != ActionName { + t.Errorf("expected X-Action-Name to be %s, got %s", ActionName, merged["X-Action-Name"]) + } +} diff --git a/config.go b/config.go index 007cd78..aeaff9c 100644 --- a/config.go +++ b/config.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "strconv" + "strings" ) var ( @@ -25,6 +26,7 @@ type Config struct { Temperature float64 MaxTokens int Debug bool + Headers map[string]string } // LoadConfig loads configuration from environment variables @@ -105,6 +107,10 @@ func LoadConfig() (*Config, error) { return nil, err } + if err := config.parseHeaders(os.Getenv("INPUT_HEADERS")); err != nil { + return nil, err + } + return config, nil } @@ -166,3 +172,42 @@ func (c *Config) parseDebug(s string) error { c.Debug = debug return nil } + +// parseHeaders parses headers string to map +// Format: "Header1:Value1,Header2:Value2" or multiline "Header1:Value1\nHeader2:Value2" +func (c *Config) parseHeaders(s string) error { + if s == "" { + return nil + } + + c.Headers = make(map[string]string) + + // Support both comma-separated and newline-separated formats + // First normalize newlines to commas for consistent parsing + normalized := strings.ReplaceAll(s, "\n", ",") + + pairs := strings.Split(normalized, ",") + for _, pair := range pairs { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + + // Split on first colon only (value may contain colons) + idx := strings.Index(pair, ":") + if idx == -1 { + return fmt.Errorf("invalid header format: %q (expected 'Key:Value')", pair) + } + + key := strings.TrimSpace(pair[:idx]) + value := strings.TrimSpace(pair[idx+1:]) + + if key == "" { + return fmt.Errorf("empty header key in: %q", pair) + } + + c.Headers[key] = value + } + + return nil +} diff --git a/config_test.go b/config_test.go index 2d78ee9..e9c735c 100644 --- a/config_test.go +++ b/config_test.go @@ -411,6 +411,7 @@ func clearEnvVars() { os.Unsetenv("INPUT_TEMPERATURE") os.Unsetenv("INPUT_MAX_TOKENS") os.Unsetenv("INPUT_DEBUG") + os.Unsetenv("INPUT_HEADERS") } // contentLoadTestCase represents a test case for content loading (CA cert, tool schema, etc.) @@ -664,3 +665,161 @@ func TestLoadConfigWithToolSchemaTemplate(t *testing.T) { clearEnvVars() os.Unsetenv("INPUT_FUNCTION_NAME") } + +func TestConfigParseHeaders(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]string + expectError bool + }{ + { + name: "Empty string", + input: "", + expected: nil, + expectError: false, + }, + { + name: "Single header", + input: "X-Custom-Header:value123", + expected: map[string]string{ + "X-Custom-Header": "value123", + }, + expectError: false, + }, + { + name: "Multiple headers comma separated", + input: "X-Request-ID:abc123,X-Trace-ID:trace456", + expected: map[string]string{ + "X-Request-ID": "abc123", + "X-Trace-ID": "trace456", + }, + expectError: false, + }, + { + name: "Multiple headers newline separated", + input: "X-Request-ID:abc123\nX-Trace-ID:trace456", + expected: map[string]string{ + "X-Request-ID": "abc123", + "X-Trace-ID": "trace456", + }, + expectError: false, + }, + { + name: "Headers with spaces", + input: " X-Header1 : value1 , X-Header2 : value2 ", + expected: map[string]string{ + "X-Header1": "value1", + "X-Header2": "value2", + }, + expectError: false, + }, + { + name: "Value with colon", + input: "Authorization:Bearer:token:with:colons", + expected: map[string]string{ + "Authorization": "Bearer:token:with:colons", + }, + expectError: false, + }, + { + name: "Empty value", + input: "X-Empty-Value:", + expected: map[string]string{ + "X-Empty-Value": "", + }, + expectError: false, + }, + { + name: "Invalid format no colon", + input: "InvalidHeader", + expected: nil, + expectError: true, + }, + { + name: "Empty key", + input: ":value", + expected: nil, + expectError: true, + }, + { + name: "Skip empty entries", + input: "X-Header1:value1,,X-Header2:value2,", + expected: map[string]string{ + "X-Header1": "value1", + "X-Header2": "value2", + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &Config{} + err := config.parseHeaders(tt.input) + + if tt.expectError { + if err == nil { + t.Error("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if tt.expected == nil { + if config.Headers != nil { + t.Errorf("expected nil Headers, got %v", config.Headers) + } + return + } + + if len(config.Headers) != len(tt.expected) { + t.Errorf("expected %d headers, got %d", len(tt.expected), len(config.Headers)) + return + } + + for key, expectedValue := range tt.expected { + if actualValue, ok := config.Headers[key]; !ok { + t.Errorf("missing header key: %s", key) + } else if actualValue != expectedValue { + t.Errorf("header %s: expected '%s', got '%s'", key, expectedValue, actualValue) + } + } + }) + } +} + +func TestLoadConfigWithHeaders(t *testing.T) { + clearEnvVars() + os.Setenv("INPUT_API_KEY", "test-key") + os.Setenv("INPUT_INPUT_PROMPT", "Hello") + os.Setenv("INPUT_HEADERS", "X-Request-ID:test123,X-Trace-ID:trace456") + + config, err := LoadConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := map[string]string{ + "X-Request-ID": "test123", + "X-Trace-ID": "trace456", + } + + if len(config.Headers) != len(expected) { + t.Errorf("expected %d headers, got %d", len(expected), len(config.Headers)) + } + + for key, expectedValue := range expected { + if actualValue, ok := config.Headers[key]; !ok { + t.Errorf("missing header key: %s", key) + } else if actualValue != expectedValue { + t.Errorf("header %s: expected '%s', got '%s'", key, expectedValue, actualValue) + } + } + + clearEnvVars() +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..75715e5 --- /dev/null +++ b/version.go @@ -0,0 +1,27 @@ +package main + +// Version information - injected at build time via ldflags +var ( + // Version is the semantic version of the action + Version = "dev" + // Commit is the git commit hash + Commit = "unknown" +) + +// Action metadata +const ( + // ActionName is the full name of the GitHub Action + ActionName = "appleboy/LLM-action" + // ActionShortName is the short name used in User-Agent + ActionShortName = "LLM-action" +) + +// GetVersion returns the current version string +func GetVersion() string { + return Version +} + +// GetUserAgent returns the User-Agent string for HTTP requests +func GetUserAgent() string { + return ActionShortName + "/" + Version +}