Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d645f067d8 | |||
| 9c57490bf1 | |||
| aa31275bdc | |||
| cacab0de8c | |||
| 8562e77a99 | |||
| 9aac9c75b3 | |||
| eb37c9a493 | |||
| 7ee5d2347b | |||
| c9a9379c71 | |||
| ad31e754e3 | |||
| c4ce17bc84 | |||
| c0259b3c7d | |||
| 989a68a941 | |||
| 3e924fe06b | |||
| 96e0fda3bb | |||
| 91ba53d8b4 | |||
| f8ee4c952b | |||
| d0b41e9e29 | |||
| 8eaf9b3bbc | |||
| d9d6269e33 | |||
| d043c3eaa1 | |||
| 5b3308935f | |||
| 2d37c90e93 | |||
| b72d8483c7 | |||
| 2aea1d4fc8 | |||
| 1c557cdc25 | |||
| f1591cfa68 | |||
| 43f6a3831f | |||
| a2fd55fb87 | |||
| c7105a4c1e | |||
| b75b177af3 | |||
| a3d57cc6dc | |||
| 9239ab5d65 | |||
| 5ac68dad30 | |||
| 3f8bae9a6e | |||
| 6baaaac50c | |||
| e8a0bebb73 | |||
| 72f855ca83 | |||
| ba8a6b0374 | |||
| e670dd5178 | |||
| 84fee7a0e6 | |||
| 83d9668e82 | |||
| 2de3b6e872 | |||
| 40b430b284 | |||
| e5b826567d | |||
| 4738a2d212 | |||
| 606ea175ce | |||
| afaedf6677 | |||
| 78d6b91d0b | |||
| 1a66a5968c | |||
| efbbaa2667 | |||
| a57b52eccd | |||
| 213eb60e28 | |||
| 7ae29e4a7c |
@@ -67,3 +67,33 @@ jobs:
|
||||
- name: Print Output
|
||||
id: output
|
||||
run: echo "${{ steps.test-action.outputs.response }}"
|
||||
|
||||
test-action-prompt-file:
|
||||
name: GitHub Actions Test with Prompt File
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
id: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create Prompt File
|
||||
run: echo "hello" > prompt.txt
|
||||
|
||||
- name: Create System Prompt File
|
||||
run:
|
||||
echo "You are a helpful AI assistant for testing." > system-prompt.txt
|
||||
|
||||
- name: Test Local Action with Prompt File
|
||||
id: test-action-prompt-file
|
||||
uses: ./
|
||||
with:
|
||||
prompt-file: prompt.txt
|
||||
system-prompt-file: system-prompt.txt
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Print Output
|
||||
run: |
|
||||
echo "Response saved to: ${{ steps.test-action-prompt-file.outputs.response-file }}"
|
||||
cat "${{ steps.test-action-prompt-file.outputs.response-file }}"
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Lint Codebase
|
||||
id: super-linter
|
||||
uses: super-linter/super-linter/slim@v7
|
||||
uses: super-linter/super-linter/slim@12150456a73e248bdc94d0794898f94e23127c88
|
||||
env:
|
||||
DEFAULT_BRANCH: main
|
||||
FILTER_REGEX_EXCLUDE: dist/**/*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: undici
|
||||
version: 5.28.5
|
||||
version: 5.29.0
|
||||
type: npm
|
||||
summary: An HTTP/1.1 client, written from scratch for Node.js
|
||||
homepage: https://undici.nodejs.org
|
||||
|
||||
+1
-1
@@ -4,4 +4,4 @@
|
||||
############################################################################
|
||||
|
||||
# Default owners, unless a later match takes precedence.
|
||||
* @actions/actions-oss-maintainers
|
||||
* @actions/models
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ avoid having to include the `node_modules/` directory in the repository.
|
||||
1. Make your change, add tests, and make sure the tests still pass:
|
||||
`npm run test`
|
||||
1. Make sure your code is correctly formatted: `npm run format`
|
||||
1. Update `dist/index.js` using `npm run build`. This creates a single
|
||||
1. Update `dist/index.js` using `npm run bundle`. This creates a single
|
||||
JavaScript file that is used as an entrypoint for the action
|
||||
1. Push to your fork and [submit a pull request][pr]
|
||||
1. Pat yourself on the back and wait for your pull request to be reviewed and
|
||||
|
||||
@@ -7,12 +7,65 @@
|
||||
[](./badges/coverage.svg)
|
||||
|
||||
Use AI models from [GitHub Models](https://github.com/marketplace/models) in
|
||||
your actions.
|
||||
your workflows.
|
||||
|
||||
## Usage
|
||||
|
||||
Create a workflow to use the AI inference action:
|
||||
|
||||
```yaml
|
||||
name: 'AI inference'
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
inference:
|
||||
permissions:
|
||||
models: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Test Local Action
|
||||
id: inference
|
||||
uses: actions/ai-inference@v1
|
||||
with:
|
||||
prompt: 'Hello!'
|
||||
|
||||
- name: Print Output
|
||||
id: output
|
||||
run: echo "${{ steps.inference.outputs.response }}"
|
||||
```
|
||||
|
||||
### Using a prompt file
|
||||
|
||||
You can also provide a prompt file instead of an inline prompt:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: Run AI Inference with Prompt File
|
||||
id: inference
|
||||
uses: actions/ai-inference@v1
|
||||
with:
|
||||
prompt-file: './path/to/prompt.txt'
|
||||
```
|
||||
|
||||
### Using a system prompt file
|
||||
|
||||
In addition to the regular prompt, you can provide a system prompt file instead
|
||||
of an inline system prompt:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: Run AI Inference with System Prompt File
|
||||
id: inference
|
||||
uses: actions/ai-inference@v1
|
||||
with:
|
||||
prompt: 'Hello!'
|
||||
system-prompt-file: './path/to/system-prompt.txt'
|
||||
```
|
||||
|
||||
### Read output from file instead of output
|
||||
|
||||
This can be useful when model response exceeds actions output limit
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: Test Local Action
|
||||
@@ -21,9 +74,10 @@ steps:
|
||||
with:
|
||||
prompt: 'Hello!'
|
||||
|
||||
- name: Print Output
|
||||
id: output
|
||||
run: echo "${{ steps.test-action.outputs.response }}"
|
||||
- name: Use Response File
|
||||
run: |
|
||||
echo "Response saved to: ${{ steps.inference.outputs.response-file }}"
|
||||
cat "${{ steps.inference.outputs.response-file }}"
|
||||
```
|
||||
|
||||
## Inputs
|
||||
@@ -31,22 +85,25 @@ steps:
|
||||
Various inputs are defined in [`action.yml`](action.yml) to let you configure
|
||||
the action:
|
||||
|
||||
| Name | Description | Default |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
|
||||
| `token` | Token to use for inference. Typically the GITHUB_TOKEN secret | `github.token` |
|
||||
| `prompt` | The prompt to send to the model | N/A |
|
||||
| `system-prompt` | The system prompt to send to the model | `""` |
|
||||
| `model` | The model to use for inference. Must be available in the [GitHub Models](https://github.com/marketplace?type=models) catalog | `gpt-4o` |
|
||||
| `endpoint` | The endpoint to use for inference. If you're running this as part of an org, you should probably use the org-specific Models endpoint | `https://models.github.ai/inference` |
|
||||
| `max-tokens` | The max number of tokens to generate | 200 |
|
||||
| Name | Description | Default |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
|
||||
| `token` | Token to use for inference. Typically the GITHUB_TOKEN secret | `github.token` |
|
||||
| `prompt` | The prompt to send to the model | N/A |
|
||||
| `prompt-file` | Path to a file containing the prompt. If both `prompt` and `prompt-file` are provided, `prompt-file` takes precedence | `""` |
|
||||
| `system-prompt` | The system prompt to send to the model | `"You are a helpful assistant"` |
|
||||
| `system-prompt-file` | Path to a file containing the system prompt. If both `system-prompt` and `system-prompt-file` are provided, `system-prompt-file` takes precedence | `""` |
|
||||
| `model` | The model to use for inference. Must be available in the [GitHub Models](https://github.com/marketplace?type=models) catalog | `gpt-4o` |
|
||||
| `endpoint` | The endpoint to use for inference. If you're running this as part of an org, you should probably use the org-specific Models endpoint | `https://models.github.ai/inference` |
|
||||
| `max-tokens` | The max number of tokens to generate | 200 |
|
||||
|
||||
## Outputs
|
||||
|
||||
The AI inference action provides the following outputs:
|
||||
|
||||
| Name | Description |
|
||||
| ---------- | --------------------------- |
|
||||
| `response` | The response from the model |
|
||||
| Name | Description |
|
||||
| --------------- | ----------------------------------------------------------------------- |
|
||||
| `response` | The response from the model |
|
||||
| `response-file` | The file path where the response is saved (useful for larger responses) |
|
||||
|
||||
## Required Permissions
|
||||
|
||||
@@ -88,6 +145,11 @@ following steps:
|
||||
to create a new release in GitHub so users can easily reference the new tags
|
||||
in their workflows.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the terms of the MIT open source license. Please
|
||||
refer to [MIT](./LICENSE.txt) for the full terms.
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are welcome! See the [Contributor's Guide](CONTRIBUTING.md).
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Security
|
||||
|
||||
GitHub takes the security of our software products and services seriously,
|
||||
including all of the open source code repositories managed through our GitHub
|
||||
organizations, such as [GitHub](https://github.com/GitHub).
|
||||
|
||||
Even though
|
||||
[open source repositories are outside of the scope of our bug bounty program](https://bounty.github.com/index.html#scope)
|
||||
and therefore not eligible for bounty rewards, we will ensure that your finding
|
||||
gets passed along to the appropriate maintainers for remediation.
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
If you believe you have found a security vulnerability in any GitHub-owned
|
||||
repository, please report it to us through coordinated disclosure.
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues,
|
||||
discussions, or pull requests.**
|
||||
|
||||
Instead, please send an email to opensource-security[@]github.com.
|
||||
|
||||
Please include as much of the information listed below as you can to help us
|
||||
better understand and resolve the issue:
|
||||
|
||||
- The type of issue (e.g., buffer overflow, SQL injection, or cross-site
|
||||
scripting)
|
||||
- Full paths of source file(s) related to the manifestation of the issue
|
||||
- The location of the affected source code (tag/branch/commit or direct URL)
|
||||
- Any special configuration required to reproduce the issue
|
||||
- Step-by-step instructions to reproduce the issue
|
||||
- Proof-of-concept or exploit code (if possible)
|
||||
- Impact of the issue, including how an attacker might exploit the issue
|
||||
|
||||
This information will help us triage your report more quickly.
|
||||
|
||||
## Policy
|
||||
|
||||
See
|
||||
[GitHub's Safe Harbor Policy](https://docs.github.com/en/site-policy/security-policies/github-bug-bounty-program-legal-safe-harbor#1-safe-harbor-terms)
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Support
|
||||
|
||||
## How to file issues and get help
|
||||
|
||||
This project uses GitHub issues to track bugs and feature requests. Please
|
||||
search the existing issues before filing new issues to avoid duplicates. For new
|
||||
issues, file your bug or feature request as a new issue.
|
||||
|
||||
For help or questions about using this project, please file an issue.
|
||||
|
||||
This project is under active development and maintained by GitHub staff and the
|
||||
community. We will do our best to respond to support, feature requests, and
|
||||
community questions in a timely manner.
|
||||
|
||||
## GitHub Support Policy
|
||||
|
||||
Support for this project is limited to the resources listed above.
|
||||
+328
-21
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
import { jest } from '@jest/globals'
|
||||
import * as core from '../__fixtures__/core.js'
|
||||
|
||||
const mockPost = jest.fn().mockImplementation(() => ({
|
||||
body: {
|
||||
choices: [
|
||||
@@ -29,6 +28,81 @@ jest.unstable_mockModule('@azure-rest/ai-inference', () => ({
|
||||
isUnexpected: jest.fn(() => false)
|
||||
}))
|
||||
|
||||
// Default to throwing errors to catch unexpected calls
|
||||
const mockExistsSync = jest.fn().mockImplementation(() => {
|
||||
throw new Error(
|
||||
'Unexpected call to existsSync - test should override this implementation'
|
||||
)
|
||||
})
|
||||
const mockReadFileSync = jest.fn().mockImplementation(() => {
|
||||
throw new Error(
|
||||
'Unexpected call to readFileSync - test should override this implementation'
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper function to mock file system operations for one or more files
|
||||
* @param fileContents - Object mapping file paths to their contents
|
||||
* @param nonExistentFiles - Array of file paths that should be treated as non-existent
|
||||
*/
|
||||
function mockFileContent(
|
||||
fileContents: Record<string, string> = {},
|
||||
nonExistentFiles: string[] = []
|
||||
): void {
|
||||
// Mock existsSync to return true for files that exist, false for those that don't
|
||||
mockExistsSync.mockImplementation((...args: unknown[]): boolean => {
|
||||
const [path] = args as [string]
|
||||
if (nonExistentFiles.includes(path)) {
|
||||
return false
|
||||
}
|
||||
return path in fileContents || true
|
||||
})
|
||||
|
||||
// Mock readFileSync to return the content for known files
|
||||
mockReadFileSync.mockImplementation((...args: unknown[]): string => {
|
||||
const [path, options] = args as [string, BufferEncoding]
|
||||
if (options === 'utf-8' && path in fileContents) {
|
||||
return fileContents[path]
|
||||
}
|
||||
throw new Error(`Unexpected file read: ${path}`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to mock action inputs
|
||||
* @param inputs - Object mapping input names to their values
|
||||
*/
|
||||
function mockInputs(inputs: Record<string, string> = {}): void {
|
||||
// Default values that are applied unless overridden
|
||||
const defaultInputs: Record<string, string> = {
|
||||
token: 'fake-token'
|
||||
}
|
||||
|
||||
// Combine defaults with user-provided inputs
|
||||
const allInputs: Record<string, string> = { ...defaultInputs, ...inputs }
|
||||
|
||||
core.getInput.mockImplementation((name: string) => {
|
||||
return allInputs[name] || ''
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to verify common response assertions
|
||||
*/
|
||||
function verifyStandardResponse(): void {
|
||||
expect(core.setOutput).toHaveBeenNthCalledWith(1, 'response', 'Hello, user!')
|
||||
expect(core.setOutput).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'response-file',
|
||||
expect.stringContaining('modelResponse.txt')
|
||||
)
|
||||
}
|
||||
|
||||
jest.unstable_mockModule('fs', () => ({
|
||||
existsSync: mockExistsSync,
|
||||
readFileSync: mockReadFileSync
|
||||
}))
|
||||
|
||||
jest.unstable_mockModule('@actions/core', () => core)
|
||||
|
||||
// The module being tested should be imported dynamically. This ensures that the
|
||||
@@ -36,37 +110,270 @@ jest.unstable_mockModule('@actions/core', () => core)
|
||||
const { run } = await import('../src/main.js')
|
||||
|
||||
describe('main.ts', () => {
|
||||
// Reset all mocks before each test
|
||||
beforeEach(() => {
|
||||
// Set the action's inputs as return values from core.getInput().
|
||||
core.getInput.mockImplementation((name) => {
|
||||
if (name === 'prompt') return 'Hello, AI!'
|
||||
if (name === 'system_prompt') return 'You are a test assistant.'
|
||||
if (name === 'model_name') return 'gpt-4o'
|
||||
return ''
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('Sets the response output', async () => {
|
||||
// Set the action's inputs as return values from core.getInput().
|
||||
mockInputs({
|
||||
prompt: 'Hello, AI!',
|
||||
'system-prompt': 'You are a test assistant.'
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(core.setOutput).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'response',
|
||||
'Hello, user!'
|
||||
)
|
||||
expect(core.setOutput).toHaveBeenCalled()
|
||||
verifyStandardResponse()
|
||||
})
|
||||
|
||||
it('Sets a failed status', async () => {
|
||||
// Clear the getInput mock and return an empty prompt
|
||||
core.getInput.mockClear().mockReturnValueOnce('')
|
||||
it('Sets a failed status when no prompt is set', async () => {
|
||||
// Clear the getInput mock and simulate no prompt or prompt-file input
|
||||
mockInputs({
|
||||
prompt: '',
|
||||
'prompt-file': ''
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
// Verify that the action was marked as failed.
|
||||
expect(core.setFailed).toHaveBeenNthCalledWith(1, 'prompt is not set')
|
||||
expect(core.setFailed).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Neither prompt-file nor prompt was set'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses prompt-file', async () => {
|
||||
const promptFile = 'prompt.txt'
|
||||
const promptContent = 'This is a prompt from a file'
|
||||
|
||||
// Set up mock to return specific content for the prompt file
|
||||
mockFileContent({
|
||||
[promptFile]: promptContent
|
||||
})
|
||||
|
||||
// Set up input mocks
|
||||
mockInputs({
|
||||
'prompt-file': promptFile,
|
||||
'system-prompt': 'You are a test assistant.'
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(promptFile)
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(promptFile, 'utf-8')
|
||||
verifyStandardResponse()
|
||||
})
|
||||
|
||||
it('handles non-existent prompt-file with an error', async () => {
|
||||
const promptFile = 'non-existent-prompt.txt'
|
||||
|
||||
// Mock the file not existing
|
||||
mockFileContent({}, [promptFile])
|
||||
|
||||
// Set up input mocks
|
||||
mockInputs({
|
||||
'prompt-file': promptFile
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
// Verify that the error was correctly reported
|
||||
expect(core.setFailed).toHaveBeenCalledWith(
|
||||
`File for prompt-file was not found: ${promptFile}`
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers prompt-file over prompt when both are provided', async () => {
|
||||
const promptFile = 'prompt.txt'
|
||||
const promptFileContent = 'This is a prompt from a file that should be used'
|
||||
const promptString = 'This is a direct prompt that should be ignored'
|
||||
|
||||
// Set up mock to return specific content for the prompt file
|
||||
mockFileContent({
|
||||
[promptFile]: promptFileContent
|
||||
})
|
||||
|
||||
// Set up input mocks
|
||||
mockInputs({
|
||||
prompt: promptString,
|
||||
'prompt-file': promptFile,
|
||||
'system-prompt': 'You are a test assistant.'
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(promptFile)
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(promptFile, 'utf-8')
|
||||
|
||||
// Check that the post call was made with the prompt from the file, not the input parameter
|
||||
expect(mockPost).toHaveBeenCalledWith({
|
||||
body: {
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: expect.any(String)
|
||||
},
|
||||
{ role: 'user', content: promptFileContent } // Should use the file content, not the string input
|
||||
],
|
||||
max_tokens: expect.any(Number),
|
||||
model: expect.any(String)
|
||||
}
|
||||
})
|
||||
|
||||
verifyStandardResponse()
|
||||
})
|
||||
|
||||
it('uses system-prompt-file', async () => {
|
||||
const systemPromptFile = 'system-prompt.txt'
|
||||
const systemPromptContent =
|
||||
'You are a specialized system assistant for testing'
|
||||
|
||||
// Set up mock to return specific content for the system prompt file
|
||||
mockFileContent({
|
||||
[systemPromptFile]: systemPromptContent
|
||||
})
|
||||
|
||||
// Set up input mocks
|
||||
mockInputs({
|
||||
prompt: 'Hello, AI!',
|
||||
'system-prompt-file': systemPromptFile
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(systemPromptFile)
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(systemPromptFile, 'utf-8')
|
||||
verifyStandardResponse()
|
||||
})
|
||||
|
||||
it('handles non-existent system-prompt-file with an error', async () => {
|
||||
const systemPromptFile = 'non-existent-system-prompt.txt'
|
||||
|
||||
// Mock the file not existing
|
||||
mockFileContent({}, [systemPromptFile])
|
||||
|
||||
// Set up input mocks
|
||||
mockInputs({
|
||||
prompt: 'Hello, AI!',
|
||||
'system-prompt-file': systemPromptFile
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
// Verify that the error was correctly reported
|
||||
expect(core.setFailed).toHaveBeenCalledWith(
|
||||
`File for system-prompt-file was not found: ${systemPromptFile}`
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers system-prompt-file over system-prompt when both are provided', async () => {
|
||||
const systemPromptFile = 'system-prompt.txt'
|
||||
const systemPromptFileContent =
|
||||
'You are a specialized system assistant from file'
|
||||
const systemPromptString =
|
||||
'You are a basic system assistant from input parameter'
|
||||
|
||||
// Set up mock to return specific content for the system prompt file
|
||||
mockFileContent({
|
||||
[systemPromptFile]: systemPromptFileContent
|
||||
})
|
||||
|
||||
// Set up input mocks
|
||||
mockInputs({
|
||||
prompt: 'Hello, AI!',
|
||||
'system-prompt-file': systemPromptFile,
|
||||
'system-prompt': systemPromptString
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(systemPromptFile)
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(systemPromptFile, 'utf-8')
|
||||
|
||||
// Check that the post call was made with the system prompt from the file, not the input parameter
|
||||
expect(mockPost).toHaveBeenCalledWith({
|
||||
body: {
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemPromptFileContent // Should use the file content, not the string input
|
||||
},
|
||||
{ role: 'user', content: 'Hello, AI!' }
|
||||
],
|
||||
max_tokens: expect.any(Number),
|
||||
model: expect.any(String)
|
||||
}
|
||||
})
|
||||
|
||||
verifyStandardResponse()
|
||||
})
|
||||
|
||||
it('uses both prompt-file and system-prompt-file together', async () => {
|
||||
const promptFile = 'prompt.txt'
|
||||
const promptContent = 'This is a prompt from a file'
|
||||
const systemPromptFile = 'system-prompt.txt'
|
||||
const systemPromptContent =
|
||||
'You are a specialized system assistant from file'
|
||||
|
||||
// Set up mock to return specific content for both files
|
||||
mockFileContent({
|
||||
[promptFile]: promptContent,
|
||||
[systemPromptFile]: systemPromptContent
|
||||
})
|
||||
|
||||
// Set up input mocks
|
||||
mockInputs({
|
||||
'prompt-file': promptFile,
|
||||
'system-prompt-file': systemPromptFile
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(promptFile)
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(systemPromptFile)
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(promptFile, 'utf-8')
|
||||
expect(mockReadFileSync).toHaveBeenCalledWith(systemPromptFile, 'utf-8')
|
||||
|
||||
// Check that the post call was made with both the prompt and system prompt from files
|
||||
expect(mockPost).toHaveBeenCalledWith({
|
||||
body: {
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemPromptContent
|
||||
},
|
||||
{ role: 'user', content: promptContent }
|
||||
],
|
||||
max_tokens: expect.any(Number),
|
||||
model: expect.any(String)
|
||||
}
|
||||
})
|
||||
|
||||
verifyStandardResponse()
|
||||
})
|
||||
|
||||
it('passes custom max-tokens parameter to the model', async () => {
|
||||
const customMaxTokens = 500
|
||||
|
||||
mockInputs({
|
||||
prompt: 'Hello, AI!',
|
||||
'system-prompt': 'You are a test assistant.',
|
||||
'max-tokens': customMaxTokens.toString()
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
// Check that the post call was made with the correct max_tokens parameter
|
||||
expect(mockPost).toHaveBeenCalledWith({
|
||||
body: {
|
||||
messages: expect.any(Array),
|
||||
max_tokens: customMaxTokens,
|
||||
model: expect.any(String)
|
||||
}
|
||||
})
|
||||
|
||||
verifyStandardResponse()
|
||||
})
|
||||
})
|
||||
|
||||
+13
-3
@@ -4,19 +4,23 @@ author: 'GitHub'
|
||||
|
||||
# Add your action's branding here. This will appear on the GitHub Marketplace.
|
||||
branding:
|
||||
icon: 'play-circle'
|
||||
icon: 'message-square'
|
||||
color: red
|
||||
|
||||
# Define your inputs here.
|
||||
inputs:
|
||||
prompt:
|
||||
description: The prompt for the model
|
||||
required: true
|
||||
required: false
|
||||
default: ''
|
||||
prompt-file:
|
||||
description: Path to a file containing the prompt
|
||||
required: false
|
||||
default: ''
|
||||
model:
|
||||
description: The model to use
|
||||
required: false
|
||||
default: 'gpt-4o'
|
||||
default: 'openai/gpt-4o'
|
||||
endpoint:
|
||||
description: The endpoint to use
|
||||
required: false
|
||||
@@ -25,6 +29,10 @@ inputs:
|
||||
description: The system prompt for the model
|
||||
required: false
|
||||
default: 'You are a helpful assistant'
|
||||
system-prompt-file:
|
||||
description: Path to a file containing the system prompt
|
||||
required: false
|
||||
default: ''
|
||||
max-tokens:
|
||||
description: The maximum number of tokens to generate
|
||||
required: false
|
||||
@@ -38,6 +46,8 @@ inputs:
|
||||
outputs:
|
||||
response:
|
||||
description: The response from the model
|
||||
response-file:
|
||||
description: The file path where the response is saved
|
||||
|
||||
runs:
|
||||
using: node20
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="116" height="20" role="img" aria-label="Coverage: 89.47%"><title>Coverage: 89.47%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="116" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="53" height="20" fill="#dfb317"/><rect width="116" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="885" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">89.47%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">89.47%</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="116" height="20" role="img" aria-label="Coverage: 84.21%"><title>Coverage: 84.21%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="116" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="53" height="20" fill="#dfb317"/><rect width="116" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="885" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">84.21%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">84.21%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
+2595
-1753
File diff suppressed because it is too large
Load Diff
+1
-1
File diff suppressed because one or more lines are too long
Generated
+3653
-1894
File diff suppressed because it is too large
Load Diff
+17
-17
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "typescript-action",
|
||||
"description": "GitHub Actions TypeScript template",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"author": "",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
@@ -32,7 +32,7 @@
|
||||
"local-action": "npx @github/local-action . src/main.ts .env",
|
||||
"package": "npx rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript",
|
||||
"package:watch": "npm run package -- --watch",
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest",
|
||||
"test": "npx cross-env NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest",
|
||||
"all": "npm run format:write && npm run lint && npm run test && npm run coverage && npm run package"
|
||||
},
|
||||
"license": "MIT",
|
||||
@@ -43,30 +43,30 @@
|
||||
"@azure-rest/ai-inference": "latest",
|
||||
"@azure/core-auth": "latest",
|
||||
"@azure/core-sse": "latest",
|
||||
"@eslint/compat": "^1.2.7",
|
||||
"@github/local-action": "^3.1.3",
|
||||
"@eslint/compat": "^1.2.9",
|
||||
"@github/local-action": "^3.2.1",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@rollup/plugin-commonjs": "^28.0.1",
|
||||
"@rollup/plugin-commonjs": "^28.0.3",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20.17.28",
|
||||
"@typescript-eslint/eslint-plugin": "^8.28.0",
|
||||
"@typescript-eslint/parser": "^8.28.0",
|
||||
"eslint": "^9.23.0",
|
||||
"eslint-config-prettier": "^10.0.2",
|
||||
"eslint-import-resolver-typescript": "^4.3.1",
|
||||
"@types/node": "^22.15.21",
|
||||
"@typescript-eslint/eslint-plugin": "^8.32.1",
|
||||
"@typescript-eslint/parser": "^8.32.1",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-import-resolver-typescript": "^4.4.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jest": "^28.11.0",
|
||||
"eslint-plugin-prettier": "^5.2.5",
|
||||
"eslint-plugin-prettier": "^5.4.0",
|
||||
"jest": "^29.7.0",
|
||||
"make-coverage-badge": "^1.2.0",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-eslint": "^16.3.0",
|
||||
"rollup": "^4.38.0",
|
||||
"ts-jest": "^29.3.0",
|
||||
"prettier-eslint": "^16.4.2",
|
||||
"rollup": "^4.41.1",
|
||||
"ts-jest": "^29.3.4",
|
||||
"ts-jest-resolver": "^2.0.1",
|
||||
"typescript": "^5.8.2"
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "*"
|
||||
|
||||
+74
-10
@@ -1,6 +1,40 @@
|
||||
import * as core from '@actions/core'
|
||||
import ModelClient, { isUnexpected } from '@azure-rest/ai-inference'
|
||||
import { AzureKeyCredential } from '@azure/core-auth'
|
||||
import * as fs from 'fs'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
|
||||
const RESPONSE_FILE = 'modelResponse.txt'
|
||||
|
||||
/**
|
||||
* Helper function to load content from a file or use fallback input
|
||||
* @param filePathInput - Input name for the file path
|
||||
* @param contentInput - Input name for the direct content
|
||||
* @param defaultValue - Default value to use if neither file nor content is provided
|
||||
* @returns The loaded content
|
||||
*/
|
||||
function loadContentFromFileOrInput(
|
||||
filePathInput: string,
|
||||
contentInput: string,
|
||||
defaultValue?: string
|
||||
): string {
|
||||
const filePath = core.getInput(filePathInput)
|
||||
const contentString = core.getInput(contentInput)
|
||||
|
||||
if (filePath !== undefined && filePath !== '') {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`File for ${filePathInput} was not found: ${filePath}`)
|
||||
}
|
||||
return fs.readFileSync(filePath, 'utf-8')
|
||||
} else if (contentString !== undefined && contentString !== '') {
|
||||
return contentString
|
||||
} else if (defaultValue !== undefined) {
|
||||
return defaultValue
|
||||
} else {
|
||||
throw new Error(`Neither ${filePathInput} nor ${contentInput} was set`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main function for the action.
|
||||
@@ -9,12 +43,16 @@ import { AzureKeyCredential } from '@azure/core-auth'
|
||||
*/
|
||||
export async function run(): Promise<void> {
|
||||
try {
|
||||
const prompt: string = core.getInput('prompt')
|
||||
if (prompt === undefined || prompt === '') {
|
||||
throw new Error('prompt is not set')
|
||||
}
|
||||
// Load prompt content - required
|
||||
const prompt = loadContentFromFileOrInput('prompt-file', 'prompt')
|
||||
|
||||
// Load system prompt with default value
|
||||
const systemPrompt = loadContentFromFileOrInput(
|
||||
'system-prompt-file',
|
||||
'system-prompt',
|
||||
'You are a helpful assistant'
|
||||
)
|
||||
|
||||
const systemPrompt: string = core.getInput('system-prompt')
|
||||
const modelName: string = core.getInput('model')
|
||||
const maxTokens: number = parseInt(core.getInput('max-tokens'), 10)
|
||||
|
||||
@@ -22,9 +60,12 @@ export async function run(): Promise<void> {
|
||||
if (token === undefined) {
|
||||
throw new Error('GITHUB_TOKEN is not set')
|
||||
}
|
||||
|
||||
const endpoint = core.getInput('endpoint')
|
||||
|
||||
const client = ModelClient(endpoint, new AzureKeyCredential(token))
|
||||
const client = ModelClient(endpoint, new AzureKeyCredential(token), {
|
||||
userAgentOptions: { userAgentPrefix: 'github-actions-ai-inference' }
|
||||
})
|
||||
|
||||
const response = await client.path('/chat/completions').post({
|
||||
body: {
|
||||
@@ -35,15 +76,21 @@ export async function run(): Promise<void> {
|
||||
},
|
||||
{ role: 'user', content: prompt }
|
||||
],
|
||||
temperature: 1.0,
|
||||
top_p: 1.0,
|
||||
max_tokens: maxTokens,
|
||||
model: modelName
|
||||
}
|
||||
})
|
||||
|
||||
if (isUnexpected(response)) {
|
||||
throw response.body.error
|
||||
if (response.body.error) {
|
||||
throw response.body.error
|
||||
}
|
||||
throw new Error(
|
||||
'An error occurred while fetching the response (' +
|
||||
response.status +
|
||||
'): ' +
|
||||
response.body
|
||||
)
|
||||
}
|
||||
|
||||
const modelResponse: string | null =
|
||||
@@ -51,8 +98,25 @@ export async function run(): Promise<void> {
|
||||
|
||||
// Set outputs for other workflow steps to use
|
||||
core.setOutput('response', modelResponse || '')
|
||||
|
||||
// Save the response to a file in case the response overflow the output limit
|
||||
const responseFilePath = path.join(tempDir(), RESPONSE_FILE)
|
||||
core.setOutput('response-file', responseFilePath)
|
||||
|
||||
if (modelResponse && modelResponse !== '') {
|
||||
fs.writeFileSync(responseFilePath, modelResponse, 'utf-8')
|
||||
}
|
||||
} catch (error) {
|
||||
// Fail the workflow run if an error occurs
|
||||
if (error instanceof Error) core.setFailed(error.message)
|
||||
if (error instanceof Error) {
|
||||
core.setFailed(error.message)
|
||||
} else {
|
||||
core.setFailed('An unexpected error occurred')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tempDir(): string {
|
||||
const tempDirectory = process.env['RUNNER_TEMP'] || os.tmpdir()
|
||||
return tempDirectory
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user