Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
850c05ea4d | ||
|
|
701473dc11 | ||
|
|
786ceefcbc | ||
|
|
cb41259270 |
+2
-1
@@ -9,7 +9,8 @@ ACTIONS_STEP_DEBUG=true
|
|||||||
|
|
||||||
# GitHub Actions inputs should follow `INPUT_<name>` format (case-sensitive).
|
# GitHub Actions inputs should follow `INPUT_<name>` format (case-sensitive).
|
||||||
# Hyphens should not be converted to underscores!
|
# Hyphens should not be converted to underscores!
|
||||||
INPUT_PROMPT=hello
|
INPUT_prompt=hello
|
||||||
|
INPUT_token=
|
||||||
|
|
||||||
# GitHub Actions default environment variables. These are set for every run of a
|
# GitHub Actions default environment variables. These are set for every run of a
|
||||||
# workflow and can be used in your actions. Setting the value here will override
|
# workflow and can be used in your actions. Setting the value here will override
|
||||||
|
|||||||
@@ -67,33 +67,3 @@ jobs:
|
|||||||
- name: Print Output
|
- name: Print Output
|
||||||
id: output
|
id: output
|
||||||
run: echo "${{ steps.test-action.outputs.response }}"
|
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
|
- name: Lint Codebase
|
||||||
id: super-linter
|
id: super-linter
|
||||||
uses: super-linter/super-linter/slim@12150456a73e248bdc94d0794898f94e23127c88
|
uses: super-linter/super-linter/slim@v7
|
||||||
env:
|
env:
|
||||||
DEFAULT_BRANCH: main
|
DEFAULT_BRANCH: main
|
||||||
FILTER_REGEX_EXCLUDE: dist/**/*
|
FILTER_REGEX_EXCLUDE: dist/**/*
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: undici
|
name: undici
|
||||||
version: 5.29.0
|
version: 5.28.5
|
||||||
type: npm
|
type: npm
|
||||||
summary: An HTTP/1.1 client, written from scratch for Node.js
|
summary: An HTTP/1.1 client, written from scratch for Node.js
|
||||||
homepage: https://undici.nodejs.org
|
homepage: https://undici.nodejs.org
|
||||||
|
|||||||
+1
-1
@@ -4,4 +4,4 @@
|
|||||||
############################################################################
|
############################################################################
|
||||||
|
|
||||||
# Default owners, unless a later match takes precedence.
|
# Default owners, unless a later match takes precedence.
|
||||||
* @actions/models
|
* @actions/actions-oss-maintainers
|
||||||
|
|||||||
+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:
|
1. Make your change, add tests, and make sure the tests still pass:
|
||||||
`npm run test`
|
`npm run test`
|
||||||
1. Make sure your code is correctly formatted: `npm run format`
|
1. Make sure your code is correctly formatted: `npm run format`
|
||||||
1. Update `dist/index.js` using `npm run bundle`. This creates a single
|
1. Update `dist/index.js` using `npm run build`. This creates a single
|
||||||
JavaScript file that is used as an entrypoint for the action
|
JavaScript file that is used as an entrypoint for the action
|
||||||
1. Push to your fork and [submit a pull request][pr]
|
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
|
1. Pat yourself on the back and wait for your pull request to be reviewed and
|
||||||
|
|||||||
@@ -7,65 +7,12 @@
|
|||||||
[](./badges/coverage.svg)
|
[](./badges/coverage.svg)
|
||||||
|
|
||||||
Use AI models from [GitHub Models](https://github.com/marketplace/models) in
|
Use AI models from [GitHub Models](https://github.com/marketplace/models) in
|
||||||
your workflows.
|
your actions.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Create a workflow to use the AI inference action:
|
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
|
```yaml
|
||||||
steps:
|
steps:
|
||||||
- name: Test Local Action
|
- name: Test Local Action
|
||||||
@@ -74,10 +21,9 @@ steps:
|
|||||||
with:
|
with:
|
||||||
prompt: 'Hello!'
|
prompt: 'Hello!'
|
||||||
|
|
||||||
- name: Use Response File
|
- name: Print Output
|
||||||
run: |
|
id: output
|
||||||
echo "Response saved to: ${{ steps.inference.outputs.response-file }}"
|
run: echo "${{ steps.test-action.outputs.response }}"
|
||||||
cat "${{ steps.inference.outputs.response-file }}"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
@@ -85,25 +31,22 @@ steps:
|
|||||||
Various inputs are defined in [`action.yml`](action.yml) to let you configure
|
Various inputs are defined in [`action.yml`](action.yml) to let you configure
|
||||||
the action:
|
the action:
|
||||||
|
|
||||||
| Name | Description | Default |
|
| Name | Description | Default |
|
||||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
|
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
|
||||||
| `token` | Token to use for inference. Typically the GITHUB_TOKEN secret | `github.token` |
|
| `token` | Token to use for inference. Typically the GITHUB_TOKEN secret | `github.token` |
|
||||||
| `prompt` | The prompt to send to the model | N/A |
|
| `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 | `""` |
|
||||||
| `system-prompt` | The system prompt to send to the model | `"You are a helpful assistant"` |
|
| `model` | The model to use for inference. Must be available in the [GitHub Models](https://github.com/marketplace?type=models) catalog | `gpt-4o` |
|
||||||
| `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 | `""` |
|
| `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` |
|
||||||
| `model` | The model to use for inference. Must be available in the [GitHub Models](https://github.com/marketplace?type=models) catalog | `gpt-4o` |
|
| `max-tokens` | The max number of tokens to generate | 200 |
|
||||||
| `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
|
## Outputs
|
||||||
|
|
||||||
The AI inference action provides the following outputs:
|
The AI inference action provides the following outputs:
|
||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
| --------------- | ----------------------------------------------------------------------- |
|
| ---------- | --------------------------- |
|
||||||
| `response` | The response from the model |
|
| `response` | The response from the model |
|
||||||
| `response-file` | The file path where the response is saved (useful for larger responses) |
|
|
||||||
|
|
||||||
## Required Permissions
|
## Required Permissions
|
||||||
|
|
||||||
@@ -145,11 +88,6 @@ following steps:
|
|||||||
to create a new release in GitHub so users can easily reference the new tags
|
to create a new release in GitHub so users can easily reference the new tags
|
||||||
in their workflows.
|
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
|
||||||
|
|
||||||
Contributions are welcome! See the [Contributor's Guide](CONTRIBUTING.md).
|
Contributions are welcome! See the [Contributor's Guide](CONTRIBUTING.md).
|
||||||
|
|||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
# 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
@@ -1,17 +0,0 @@
|
|||||||
# 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.
|
|
||||||
+21
-328
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
import { jest } from '@jest/globals'
|
import { jest } from '@jest/globals'
|
||||||
import * as core from '../__fixtures__/core.js'
|
import * as core from '../__fixtures__/core.js'
|
||||||
|
|
||||||
const mockPost = jest.fn().mockImplementation(() => ({
|
const mockPost = jest.fn().mockImplementation(() => ({
|
||||||
body: {
|
body: {
|
||||||
choices: [
|
choices: [
|
||||||
@@ -28,81 +29,6 @@ jest.unstable_mockModule('@azure-rest/ai-inference', () => ({
|
|||||||
isUnexpected: jest.fn(() => false)
|
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)
|
jest.unstable_mockModule('@actions/core', () => core)
|
||||||
|
|
||||||
// The module being tested should be imported dynamically. This ensures that the
|
// The module being tested should be imported dynamically. This ensures that the
|
||||||
@@ -110,270 +36,37 @@ jest.unstable_mockModule('@actions/core', () => core)
|
|||||||
const { run } = await import('../src/main.js')
|
const { run } = await import('../src/main.js')
|
||||||
|
|
||||||
describe('main.ts', () => {
|
describe('main.ts', () => {
|
||||||
// Reset all mocks before each test
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks()
|
// 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()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Sets the response output', async () => {
|
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()
|
await run()
|
||||||
|
|
||||||
expect(core.setOutput).toHaveBeenCalled()
|
expect(core.setOutput).toHaveBeenNthCalledWith(
|
||||||
verifyStandardResponse()
|
1,
|
||||||
|
'response',
|
||||||
|
'Hello, user!'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Sets a failed status when no prompt is set', async () => {
|
it('Sets a failed status', async () => {
|
||||||
// Clear the getInput mock and simulate no prompt or prompt-file input
|
// Clear the getInput mock and return an empty prompt
|
||||||
mockInputs({
|
core.getInput.mockClear().mockReturnValueOnce('')
|
||||||
prompt: '',
|
|
||||||
'prompt-file': ''
|
|
||||||
})
|
|
||||||
|
|
||||||
await run()
|
await run()
|
||||||
|
|
||||||
// Verify that the action was marked as failed.
|
// Verify that the action was marked as failed.
|
||||||
expect(core.setFailed).toHaveBeenNthCalledWith(
|
expect(core.setFailed).toHaveBeenNthCalledWith(1, 'prompt is not set')
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-12
@@ -4,18 +4,14 @@ author: 'GitHub'
|
|||||||
|
|
||||||
# Add your action's branding here. This will appear on the GitHub Marketplace.
|
# Add your action's branding here. This will appear on the GitHub Marketplace.
|
||||||
branding:
|
branding:
|
||||||
icon: 'message-square'
|
icon: 'play-circle'
|
||||||
color: red
|
color: red
|
||||||
|
|
||||||
# Define your inputs here.
|
# Define your inputs here.
|
||||||
inputs:
|
inputs:
|
||||||
prompt:
|
prompt:
|
||||||
description: The prompt for the model
|
description: The prompt for the model
|
||||||
required: false
|
required: true
|
||||||
default: ''
|
|
||||||
prompt-file:
|
|
||||||
description: Path to a file containing the prompt
|
|
||||||
required: false
|
|
||||||
default: ''
|
default: ''
|
||||||
model:
|
model:
|
||||||
description: The model to use
|
description: The model to use
|
||||||
@@ -29,10 +25,6 @@ inputs:
|
|||||||
description: The system prompt for the model
|
description: The system prompt for the model
|
||||||
required: false
|
required: false
|
||||||
default: 'You are a helpful assistant'
|
default: 'You are a helpful assistant'
|
||||||
system-prompt-file:
|
|
||||||
description: Path to a file containing the system prompt
|
|
||||||
required: false
|
|
||||||
default: ''
|
|
||||||
max-tokens:
|
max-tokens:
|
||||||
description: The maximum number of tokens to generate
|
description: The maximum number of tokens to generate
|
||||||
required: false
|
required: false
|
||||||
@@ -46,8 +38,6 @@ inputs:
|
|||||||
outputs:
|
outputs:
|
||||||
response:
|
response:
|
||||||
description: The response from the model
|
description: The response from the model
|
||||||
response-file:
|
|
||||||
description: The file path where the response is saved
|
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: node20
|
using: node20
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
<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>
|
<svg xmlns="http://www.w3.org/2000/svg" width="116" height="20" role="img" aria-label="Coverage: 77.27%"><title>Coverage: 77.27%</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="#e05d44"/><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">77.27%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">77.27%</text></g></svg>
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
+1751
-2583
File diff suppressed because it is too large
Load Diff
+1
-1
File diff suppressed because one or more lines are too long
Generated
+1920
-3679
File diff suppressed because it is too large
Load Diff
+17
-17
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "typescript-action",
|
"name": "typescript-action",
|
||||||
"description": "GitHub Actions TypeScript template",
|
"description": "GitHub Actions TypeScript template",
|
||||||
"version": "1.0.0",
|
"version": "0.0.0",
|
||||||
"author": "",
|
"author": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"local-action": "npx @github/local-action . src/main.ts .env",
|
"local-action": "npx @github/local-action . src/main.ts .env",
|
||||||
"package": "npx rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript",
|
"package": "npx rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript",
|
||||||
"package:watch": "npm run package -- --watch",
|
"package:watch": "npm run package -- --watch",
|
||||||
"test": "npx cross-env NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest",
|
"test": "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"
|
"all": "npm run format:write && npm run lint && npm run test && npm run coverage && npm run package"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -43,30 +43,30 @@
|
|||||||
"@azure-rest/ai-inference": "latest",
|
"@azure-rest/ai-inference": "latest",
|
||||||
"@azure/core-auth": "latest",
|
"@azure/core-auth": "latest",
|
||||||
"@azure/core-sse": "latest",
|
"@azure/core-sse": "latest",
|
||||||
"@eslint/compat": "^1.2.9",
|
"@eslint/compat": "^1.2.7",
|
||||||
"@github/local-action": "^3.2.1",
|
"@github/local-action": "^3.1.3",
|
||||||
"@jest/globals": "^29.7.0",
|
"@jest/globals": "^29.7.0",
|
||||||
"@rollup/plugin-commonjs": "^28.0.3",
|
"@rollup/plugin-commonjs": "^28.0.1",
|
||||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||||
"@rollup/plugin-typescript": "^12.1.2",
|
"@rollup/plugin-typescript": "^12.1.1",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^20.17.28",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.32.1",
|
"@typescript-eslint/eslint-plugin": "^8.28.0",
|
||||||
"@typescript-eslint/parser": "^8.32.1",
|
"@typescript-eslint/parser": "^8.28.0",
|
||||||
"eslint": "^9.27.0",
|
"eslint": "^9.23.0",
|
||||||
"eslint-config-prettier": "^10.1.5",
|
"eslint-config-prettier": "^10.0.2",
|
||||||
"eslint-import-resolver-typescript": "^4.4.0",
|
"eslint-import-resolver-typescript": "^4.3.1",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-jest": "^28.11.0",
|
"eslint-plugin-jest": "^28.11.0",
|
||||||
"eslint-plugin-prettier": "^5.4.0",
|
"eslint-plugin-prettier": "^5.2.5",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"make-coverage-badge": "^1.2.0",
|
"make-coverage-badge": "^1.2.0",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-eslint": "^16.4.2",
|
"prettier-eslint": "^16.3.0",
|
||||||
"rollup": "^4.41.1",
|
"rollup": "^4.38.0",
|
||||||
"ts-jest": "^29.3.4",
|
"ts-jest": "^29.3.0",
|
||||||
"ts-jest-resolver": "^2.0.1",
|
"ts-jest-resolver": "^2.0.1",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.2"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-linux-x64-gnu": "*"
|
"@rollup/rollup-linux-x64-gnu": "*"
|
||||||
|
|||||||
+8
-61
@@ -1,40 +1,6 @@
|
|||||||
import * as core from '@actions/core'
|
import * as core from '@actions/core'
|
||||||
import ModelClient, { isUnexpected } from '@azure-rest/ai-inference'
|
import ModelClient, { isUnexpected } from '@azure-rest/ai-inference'
|
||||||
import { AzureKeyCredential } from '@azure/core-auth'
|
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.
|
* The main function for the action.
|
||||||
@@ -43,16 +9,12 @@ function loadContentFromFileOrInput(
|
|||||||
*/
|
*/
|
||||||
export async function run(): Promise<void> {
|
export async function run(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Load prompt content - required
|
const prompt: string = core.getInput('prompt')
|
||||||
const prompt = loadContentFromFileOrInput('prompt-file', 'prompt')
|
if (prompt === undefined || prompt === '') {
|
||||||
|
throw new Error('prompt is not set')
|
||||||
// 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 modelName: string = core.getInput('model')
|
||||||
const maxTokens: number = parseInt(core.getInput('max-tokens'), 10)
|
const maxTokens: number = parseInt(core.getInput('max-tokens'), 10)
|
||||||
|
|
||||||
@@ -60,12 +22,9 @@ export async function run(): Promise<void> {
|
|||||||
if (token === undefined) {
|
if (token === undefined) {
|
||||||
throw new Error('GITHUB_TOKEN is not set')
|
throw new Error('GITHUB_TOKEN is not set')
|
||||||
}
|
}
|
||||||
|
|
||||||
const endpoint = core.getInput('endpoint')
|
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({
|
const response = await client.path('/chat/completions').post({
|
||||||
body: {
|
body: {
|
||||||
@@ -76,6 +35,8 @@ export async function run(): Promise<void> {
|
|||||||
},
|
},
|
||||||
{ role: 'user', content: prompt }
|
{ role: 'user', content: prompt }
|
||||||
],
|
],
|
||||||
|
temperature: 1.0,
|
||||||
|
top_p: 1.0,
|
||||||
max_tokens: maxTokens,
|
max_tokens: maxTokens,
|
||||||
model: modelName
|
model: modelName
|
||||||
}
|
}
|
||||||
@@ -92,20 +53,11 @@ export async function run(): Promise<void> {
|
|||||||
response.body
|
response.body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelResponse: string | null =
|
const modelResponse: string | null =
|
||||||
response.body.choices[0].message.content
|
response.body.choices[0].message.content
|
||||||
|
|
||||||
// Set outputs for other workflow steps to use
|
// Set outputs for other workflow steps to use
|
||||||
core.setOutput('response', modelResponse || '')
|
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) {
|
} catch (error) {
|
||||||
// Fail the workflow run if an error occurs
|
// Fail the workflow run if an error occurs
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
@@ -115,8 +67,3 @@ export async function run(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function tempDir(): string {
|
|
||||||
const tempDirectory = process.env['RUNNER_TEMP'] || os.tmpdir()
|
|
||||||
return tempDirectory
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user