mirror of
https://github.com/sst/opencode.git
synced 2025-08-22 05:54:08 +00:00
feat: add dynamic API key support via scripts
closes #1302 Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
This commit is contained in:
parent
de1764841c
commit
d78e84d618
6 changed files with 463 additions and 4 deletions
|
@ -2,8 +2,12 @@ import path from "path"
|
|||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import { z } from "zod"
|
||||
import { Log } from "../util/log"
|
||||
import { App } from "../app/app"
|
||||
|
||||
export namespace Auth {
|
||||
const log = Log.create({ service: "auth" })
|
||||
|
||||
export const Oauth = z
|
||||
.object({
|
||||
type: z.literal("oauth"),
|
||||
|
@ -28,11 +32,22 @@ export namespace Auth {
|
|||
})
|
||||
.openapi({ ref: "WellKnownAuth" })
|
||||
|
||||
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).openapi({ ref: "Auth" })
|
||||
export const Helper = z.object({
|
||||
type: z.literal("helper"),
|
||||
command: z.array(z.string()),
|
||||
refreshInterval: z.number().default(3600), // 1 hour default
|
||||
timeout: z.number().default(5000), // 5 seconds default
|
||||
lastFetched: z.number().optional(),
|
||||
cachedKey: z.string().optional(),
|
||||
})
|
||||
|
||||
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown, Helper]).openapi({ ref: "Auth" })
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
const filepath = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
const helperCache = new Map<string, { key: string; expires: number }>()
|
||||
|
||||
export async function get(providerID: string) {
|
||||
const file = Bun.file(filepath)
|
||||
return file
|
||||
|
@ -60,4 +75,74 @@ export namespace Auth {
|
|||
await Bun.write(file, JSON.stringify(data, null, 2))
|
||||
await fs.chmod(file.name!, 0o600)
|
||||
}
|
||||
|
||||
export async function executeHelper(providerID: string, helper: z.infer<typeof Helper>): Promise<string | undefined> {
|
||||
const now = Date.now()
|
||||
const cacheKey = `${providerID}-${JSON.stringify(helper.command)}`
|
||||
|
||||
const cached = helperCache.get(cacheKey)
|
||||
if (cached && cached.expires > now) {
|
||||
log.debug("using cached helper result", { providerID })
|
||||
return cached.key
|
||||
}
|
||||
|
||||
if (helper.cachedKey && helper.lastFetched && now - helper.lastFetched < helper.refreshInterval * 1000) {
|
||||
log.debug("using stored helper result", { providerID })
|
||||
helperCache.set(cacheKey, {
|
||||
key: helper.cachedKey,
|
||||
expires: helper.lastFetched + helper.refreshInterval * 1000,
|
||||
})
|
||||
return helper.cachedKey
|
||||
}
|
||||
|
||||
try {
|
||||
log.info("executing helper command", { providerID, command: helper.command })
|
||||
|
||||
const process = Bun.spawn({
|
||||
cmd: helper.command,
|
||||
cwd: App.info().path.cwd,
|
||||
timeout: helper.timeout,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
await process.exited
|
||||
|
||||
if (process.exitCode !== 0) {
|
||||
const stderr = await new Response(process.stderr).text()
|
||||
log.error("helper command failed", { providerID, exitCode: process.exitCode, stderr })
|
||||
return undefined
|
||||
}
|
||||
|
||||
const stdout = await new Response(process.stdout).text()
|
||||
const apiKey = stdout.trim()
|
||||
|
||||
if (!apiKey) {
|
||||
log.error("helper command returned empty result", { providerID })
|
||||
return undefined
|
||||
}
|
||||
|
||||
const updatedHelper: z.infer<typeof Helper> = {
|
||||
...helper,
|
||||
cachedKey: apiKey,
|
||||
lastFetched: now,
|
||||
}
|
||||
|
||||
await set(providerID, updatedHelper)
|
||||
|
||||
helperCache.set(cacheKey, {
|
||||
key: apiKey,
|
||||
expires: now + helper.refreshInterval * 1000,
|
||||
})
|
||||
|
||||
log.info("helper command executed successfully", { providerID })
|
||||
return apiKey
|
||||
} catch (error) {
|
||||
log.error("helper command execution failed", {
|
||||
providerID,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,42 @@ export const AuthCommand = cmd({
|
|||
async handler() {},
|
||||
})
|
||||
|
||||
/**
|
||||
* Handle command parsing for helper auth allowing for quoted arguments, and spaces in arguments, example:
|
||||
* `op read "op://<some-vault>/OpenAI API Key/credential"` will be parsed as `["op", "read", "op://<some-vault>/OpenAI API Key/credential"]`
|
||||
*/
|
||||
function parseCommand(commandStr: string): string[] {
|
||||
const args: string[] = []
|
||||
let current = ""
|
||||
let inQuotes = false
|
||||
let quoteChar = ""
|
||||
|
||||
for (let i = 0; i < commandStr.length; i++) {
|
||||
const char = commandStr[i]
|
||||
|
||||
if (!inQuotes && (char === '"' || char === "'")) {
|
||||
inQuotes = true
|
||||
quoteChar = char
|
||||
} else if (inQuotes && char === quoteChar) {
|
||||
inQuotes = false
|
||||
quoteChar = ""
|
||||
} else if (!inQuotes && /\s/.test(char)) {
|
||||
if (current) {
|
||||
args.push(current)
|
||||
current = ""
|
||||
}
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
args.push(current)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
export const AuthListCommand = cmd({
|
||||
command: "list",
|
||||
aliases: ["ls"],
|
||||
|
@ -236,6 +272,66 @@ export const AuthLoginCommand = cmd({
|
|||
prompts.log.info("You can create an api key in the dashboard")
|
||||
}
|
||||
|
||||
const authMethod = await prompts.select({
|
||||
message: "How would you like to authenticate?",
|
||||
options: [
|
||||
{
|
||||
value: "api",
|
||||
label: "API Key",
|
||||
hint: "Enter a static API key",
|
||||
},
|
||||
{
|
||||
value: "helper",
|
||||
label: "Dynamic API Key (Helper Script)",
|
||||
hint: "Use a script to generate API keys dynamically",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(authMethod)) throw new UI.CancelledError()
|
||||
|
||||
if (authMethod === "helper") {
|
||||
prompts.log.info("Configure a script that outputs an API key to stdout")
|
||||
|
||||
const command = await prompts.text({
|
||||
message: "Enter the command to execute (space-separated)",
|
||||
placeholder: "./get-token.sh or python get-token.py",
|
||||
validate: (x) => (x && x.trim().length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
||||
|
||||
const refreshInterval = await prompts.text({
|
||||
message: "Refresh interval in seconds",
|
||||
initialValue: "3600",
|
||||
validate: (x) => {
|
||||
if (!x) return "Required"
|
||||
const num = parseInt(x)
|
||||
return num > 0 ? undefined : "Must be a positive number"
|
||||
},
|
||||
})
|
||||
if (prompts.isCancel(refreshInterval)) throw new UI.CancelledError()
|
||||
|
||||
const timeout = await prompts.text({
|
||||
message: "Command timeout in milliseconds",
|
||||
initialValue: "5000",
|
||||
validate: (x) => {
|
||||
if (!x) return "Required"
|
||||
const num = parseInt(x)
|
||||
return num >= 100 && num <= 30000 ? undefined : "Must be between 100 and 30000"
|
||||
},
|
||||
})
|
||||
if (prompts.isCancel(timeout)) throw new UI.CancelledError()
|
||||
|
||||
await Auth.set(provider, {
|
||||
type: "helper",
|
||||
command: parseCommand(command as string),
|
||||
refreshInterval: parseInt(refreshInterval as string),
|
||||
timeout: parseInt(timeout as string),
|
||||
})
|
||||
|
||||
prompts.outro("Helper authentication configured")
|
||||
return
|
||||
}
|
||||
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
|
|
|
@ -298,6 +298,28 @@ export namespace Config {
|
|||
})
|
||||
export type Layout = z.infer<typeof Layout>
|
||||
|
||||
export const ApiKeyHelper = z
|
||||
.object({
|
||||
command: z
|
||||
.array(z.string())
|
||||
.describe("Command to execute to retrieve the API key. Should output the key to stdout."),
|
||||
refreshInterval: z
|
||||
.number()
|
||||
.min(1)
|
||||
.default(3600)
|
||||
.describe("How often to refresh the API key in seconds (default: 3600 = 1 hour)"),
|
||||
timeout: z
|
||||
.number()
|
||||
.min(100)
|
||||
.max(30000)
|
||||
.default(5000)
|
||||
.describe("Timeout for the helper command in milliseconds (default: 5000ms)"),
|
||||
})
|
||||
.describe(
|
||||
"Configuration for dynamic API key generation via external script. The command should output the API key to stdout.",
|
||||
)
|
||||
export type ApiKeyHelper = z.infer<typeof ApiKeyHelper>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
|
||||
|
@ -351,7 +373,10 @@ export namespace Config {
|
|||
models: z.record(ModelsDev.Model.partial()).optional(),
|
||||
options: z
|
||||
.object({
|
||||
apiKey: z.string().optional(),
|
||||
apiKey: z
|
||||
.union([z.string().describe("Static API key"), ApiKeyHelper])
|
||||
.optional()
|
||||
.describe("API key configuration - either a static string or helper script configuration"),
|
||||
baseURL: z.string().optional(),
|
||||
})
|
||||
.catchall(z.any())
|
||||
|
|
|
@ -22,7 +22,7 @@ export namespace Provider {
|
|||
options?: Record<string, any>
|
||||
}>
|
||||
|
||||
type Source = "env" | "config" | "custom" | "api"
|
||||
type Source = "env" | "config" | "custom" | "api" | "helper"
|
||||
|
||||
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
|
||||
async anthropic() {
|
||||
|
@ -248,6 +248,14 @@ export namespace Provider {
|
|||
if (provider.type === "api") {
|
||||
mergeProvider(providerID, { apiKey: provider.key }, "api")
|
||||
}
|
||||
if (provider.type === "helper") {
|
||||
const apiKey = await Auth.executeHelper(providerID, provider)
|
||||
if (apiKey) {
|
||||
mergeProvider(providerID, { apiKey }, "helper")
|
||||
} else {
|
||||
log.warn("helper auth failed, skipping provider", { providerID })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// load custom
|
||||
|
@ -272,7 +280,38 @@ export namespace Provider {
|
|||
|
||||
// load config
|
||||
for (const [providerID, provider] of configProviders) {
|
||||
mergeProvider(providerID, provider.options ?? {}, "config")
|
||||
const options = { ...provider.options }
|
||||
|
||||
if (isApiKeyHelper(options.apiKey)) {
|
||||
const helperConfig = options.apiKey
|
||||
try {
|
||||
const helperAuth: z.infer<typeof Auth.Helper> = {
|
||||
type: "helper",
|
||||
command: helperConfig.command,
|
||||
refreshInterval: helperConfig.refreshInterval ?? 3600,
|
||||
timeout: helperConfig.timeout ?? 5000,
|
||||
}
|
||||
|
||||
await Auth.set(providerID, helperAuth)
|
||||
|
||||
const apiKey = await Auth.executeHelper(providerID, helperAuth)
|
||||
if (apiKey) {
|
||||
options.apiKey = apiKey
|
||||
} else {
|
||||
log.warn("apiKey helper failed for provider, no API key available", { providerID })
|
||||
delete options.apiKey
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("failed to process apiKey helper configuration", {
|
||||
providerID,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
command: helperConfig.command.join(" "),
|
||||
})
|
||||
delete options.apiKey
|
||||
}
|
||||
}
|
||||
|
||||
mergeProvider(providerID, options, "config")
|
||||
}
|
||||
|
||||
for (const [providerID, provider] of Object.entries(providers)) {
|
||||
|
@ -290,6 +329,10 @@ export namespace Provider {
|
|||
}
|
||||
})
|
||||
|
||||
function isApiKeyHelper(apiKey: any): apiKey is Config.ApiKeyHelper {
|
||||
return apiKey !== null && typeof apiKey === "object" && Array.isArray(apiKey.command) && apiKey.command.length > 0
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return state().then((state) => state.providers)
|
||||
}
|
||||
|
|
118
packages/opencode/test/auth/helper.test.ts
Normal file
118
packages/opencode/test/auth/helper.test.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
import "zod-openapi/extend"
|
||||
import { test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { App } from "../../src/app/app"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Global } from "../../src/global"
|
||||
import { z } from "zod"
|
||||
|
||||
const testDir = path.join(__dirname, "temp")
|
||||
|
||||
let originalDataPath: string
|
||||
|
||||
beforeEach(async () => {
|
||||
await fs.mkdir(testDir, { recursive: true })
|
||||
|
||||
// Mock Global.Path.data to isolate auth storage for testing
|
||||
originalDataPath = Global.Path.data
|
||||
;(Global.Path as any).data = testDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Restore original global path
|
||||
;(Global.Path as any).data = originalDataPath
|
||||
|
||||
await fs.rm(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("helper auth executes command and caches result", async () => {
|
||||
await App.provide({ cwd: testDir }, async () => {
|
||||
const scriptPath = path.join(testDir, "get-key.sh")
|
||||
await fs.writeFile(scriptPath, '#!/bin/bash\necho "test-api-key-123"', { mode: 0o755 })
|
||||
|
||||
const helperConfig: z.infer<typeof Auth.Helper> = {
|
||||
type: "helper",
|
||||
command: ["bash", scriptPath],
|
||||
refreshInterval: 3600,
|
||||
timeout: 5000,
|
||||
}
|
||||
|
||||
const apiKey = await Auth.executeHelper("test-provider", helperConfig)
|
||||
|
||||
expect(apiKey).toBe("test-api-key-123")
|
||||
|
||||
const stored = await Auth.get("test-provider")
|
||||
expect(stored?.type).toBe("helper")
|
||||
if (stored?.type === "helper") {
|
||||
expect(stored.cachedKey).toBe("test-api-key-123")
|
||||
expect(stored.lastFetched).toBeGreaterThan(Date.now() - 1000)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("helper auth uses cached result within refresh interval", async () => {
|
||||
await App.provide({ cwd: testDir }, async () => {
|
||||
const helperConfig: z.infer<typeof Auth.Helper> = {
|
||||
type: "helper",
|
||||
command: ["echo", "should-not-be-called"],
|
||||
refreshInterval: 3600,
|
||||
timeout: 5000,
|
||||
cachedKey: "cached-api-key",
|
||||
lastFetched: Date.now() - 1000, // 1 second ago, well within refresh interval
|
||||
}
|
||||
|
||||
const apiKey = await Auth.executeHelper("cached-test-provider", helperConfig)
|
||||
expect(apiKey).toBe("cached-api-key")
|
||||
})
|
||||
})
|
||||
|
||||
test("helper auth re-executes command when cache expires", async () => {
|
||||
await App.provide({ cwd: testDir }, async () => {
|
||||
const scriptPath = path.join(testDir, "fresh-key.sh")
|
||||
await fs.writeFile(scriptPath, '#!/bin/bash\necho "fresh-api-key"', { mode: 0o755 })
|
||||
|
||||
const helperConfig: z.infer<typeof Auth.Helper> = {
|
||||
type: "helper",
|
||||
command: ["bash", scriptPath],
|
||||
refreshInterval: 1,
|
||||
timeout: 5000,
|
||||
cachedKey: "old-api-key",
|
||||
lastFetched: Date.now() - 2000, // 2 seconds ago, past refresh interval
|
||||
}
|
||||
|
||||
const apiKey = await Auth.executeHelper("expired-test-provider", helperConfig)
|
||||
expect(apiKey).toBe("fresh-api-key")
|
||||
})
|
||||
})
|
||||
|
||||
test("helper auth handles command failure gracefully", async () => {
|
||||
await App.provide({ cwd: testDir }, async () => {
|
||||
const helperConfig: z.infer<typeof Auth.Helper> = {
|
||||
type: "helper",
|
||||
command: ["false"],
|
||||
refreshInterval: 3600,
|
||||
timeout: 5000,
|
||||
}
|
||||
|
||||
const apiKey = await Auth.executeHelper("test-provider", helperConfig)
|
||||
expect(apiKey).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
test("helper auth handles command timeout", async () => {
|
||||
await App.provide({ cwd: testDir }, async () => {
|
||||
const scriptPath = path.join(testDir, "slow-script.sh")
|
||||
await fs.writeFile(scriptPath, '#!/bin/bash\nsleep 2\necho "too-slow"', { mode: 0o755 })
|
||||
|
||||
const helperConfig: z.infer<typeof Auth.Helper> = {
|
||||
type: "helper",
|
||||
command: ["bash", scriptPath],
|
||||
refreshInterval: 3600,
|
||||
timeout: 500,
|
||||
}
|
||||
|
||||
const apiKey = await Auth.executeHelper("timeout-test-provider", helperConfig)
|
||||
expect(apiKey).toBeUndefined()
|
||||
})
|
||||
}, 5000)
|
|
@ -339,3 +339,95 @@ These are useful for:
|
|||
- Keeping sensitive data like API keys in separate files.
|
||||
- Including large instruction files without cluttering your config.
|
||||
- Sharing common configuration snippets across multiple config files.
|
||||
|
||||
## Providers
|
||||
|
||||
You can configure providers and their models in the `provider` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": {
|
||||
"anthropic": {
|
||||
"options": {
|
||||
"apiKey": "your-api-key",
|
||||
"baseURL": "https://api.anthropic.com"
|
||||
},
|
||||
"models": {
|
||||
"claude-3-5-sonnet-20241022": {
|
||||
"name": "Claude 3.5 Sonnet (Custom)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic API Keys with Helper Scripts
|
||||
|
||||
You can configure dynamic API keys that are generated by external scripts by using an object configuration for the `apiKey` field. This is useful for integrating with systems that use rotating tokens or require authentication workflows:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": {
|
||||
"litellm": {
|
||||
"options": {
|
||||
"baseURL": "https://your-litellm-proxy.com/v1",
|
||||
"apiKey": {
|
||||
"command": ["./scripts/get-token.sh"],
|
||||
"refreshInterval": 3600,
|
||||
"timeout": 5000
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"gpt-4": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `apiKey` field supports two formats:
|
||||
|
||||
**Static API Key:**
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "your-static-api-key-here"
|
||||
}
|
||||
```
|
||||
|
||||
**Dynamic API Key (Helper Script):**
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": {
|
||||
"command": ["./scripts/get-token.sh"],
|
||||
"refreshInterval": 3600,
|
||||
"timeout": 5000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The helper configuration supports:
|
||||
|
||||
- **`command`** (required): Array of command and arguments to execute. The script should output the API key to stdout.
|
||||
- **`refreshInterval`** (optional): How often to refresh the API key in seconds. Default: 3600 (1 hour).
|
||||
- **`timeout`** (optional): Command timeout in milliseconds. Default: 5000ms.
|
||||
|
||||
Example helper script (`get-token.sh`):
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Example script that fetches a token from an authentication service
|
||||
curl -s -X POST https://auth.example.com/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"client_id":"'$CLIENT_ID'","client_secret":"'$CLIENT_SECRET'"}' \
|
||||
| jq -r '.access_token'
|
||||
```
|
||||
|
||||
You can also configure helper authentication via the CLI:
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# Select your provider, then choose "Dynamic API Key (Helper Script)"
|
||||
```
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue