diff --git a/.github/actions/llm-credentials/action.yml b/.github/actions/llm-credentials/action.yml index 0437f12..fac7d64 100644 --- a/.github/actions/llm-credentials/action.yml +++ b/.github/actions/llm-credentials/action.yml @@ -1,15 +1,15 @@ name: LLM credentials description: >- - Short-lived Anthropic or OpenAI credentials for pi through workload identity federation, so - the job needs no stored API key. The job needs `permissions: id-token: write`. Credentials are + Short-lived Anthropic, OpenAI or OpenRouter credentials for pi through workload identity + federation, so the job needs no stored API key. The job needs `permissions: id-token: write`. Credentials are refreshed in the background, so later steps run pi with no key in their env. Run the action again with `stop: true` once the model is done, to delete the credentials before later steps. inputs: model: description: >- - Pi model as provider/id, such as the PI_PREFERRED_MODEL variable. Its provider, anthropic or - openai, picks the credentials. Required unless stop is true. + Pi model as provider/id, such as the PI_PREFERRED_MODEL variable. Its provider, anthropic, + openai or openrouter, picks the credentials. Required unless stop is true. stop: description: Stop refreshing, delete the credentials and print the refresh log. default: 'false' @@ -28,6 +28,11 @@ inputs: description: OpenAI service account ID openai-audience: description: Audience the OpenAI workload identity provider requires + openrouter-federation-policy-id: + description: >- + OpenRouter federation policy ID. Also set it when the model is not on OpenRouter but later + steps call OpenRouter themselves: they get a token in pi's auth.json and in the file named + by OPENROUTER_ACCESS_TOKEN_FILE. runs: using: composite @@ -44,6 +49,8 @@ runs: OPENAI_IDENTITY_PROVIDER_ID: ${{ inputs.openai-identity-provider-id }} OPENAI_SERVICE_ACCOUNT_ID: ${{ inputs.openai-service-account-id }} OPENAI_WIF_AUDIENCE: ${{ inputs.openai-audience }} + OPENROUTER_FEDERATION_POLICY_ID: ${{ inputs.openrouter-federation-policy-id }} + OPENROUTER_ACCESS_TOKEN_FILE: ${{ runner.temp }}/openrouter-access-token run: | set -euo pipefail if [ "$STOP" = true ]; then @@ -69,8 +76,18 @@ runs: require OPENAI_SERVICE_ACCOUNT_ID openai-service-account-id OPENAI_SERVICE_ACCOUNT_ID require OPENAI_WIF_AUDIENCE openai-audience OPENAI_WIF_AUDIENCE ;; + openrouter) + require OPENROUTER_FEDERATION_POLICY_ID openrouter-federation-policy-id OPENROUTER_FEDERATION_POLICY_ID + ;; esac - bash "$GITHUB_ACTION_PATH/llm_credentials.sh" start "$provider" + providers=("$provider") + if [ "$provider" != openrouter ] && [ -n "$OPENROUTER_FEDERATION_POLICY_ID" ]; then + providers+=(openrouter) + fi + bash "$GITHUB_ACTION_PATH/llm_credentials.sh" start "${providers[@]}" + if [[ " ${providers[*]} " == *" openrouter "* ]]; then + echo "OPENROUTER_ACCESS_TOKEN_FILE=$OPENROUTER_ACCESS_TOKEN_FILE" >> "$GITHUB_ENV" + fi # The Anthropic SDK in pi does the exchange itself, so later steps need the federation # settings. OpenAI's token is already in pi's auth.json. if [ "$provider" = anthropic ]; then diff --git a/.github/actions/llm-credentials/llm_credentials.sh b/.github/actions/llm-credentials/llm_credentials.sh index 12008c5..5567d36 100644 --- a/.github/actions/llm-credentials/llm_credentials.sh +++ b/.github/actions/llm-credentials/llm_credentials.sh @@ -2,10 +2,12 @@ # Short-lived model credentials for pi in GitHub Actions, through workload identity federation # instead of stored API keys. The job needs `permissions: id-token: write`. # -# llm_credentials.sh start fetch once, then refresh in the background -# llm_credentials.sh refresh fetch once -# llm_credentials.sh stop stop refreshing, delete the credentials and -# print the refresh log +# llm_credentials.sh start ... fetch once, then refresh in the background +# llm_credentials.sh refresh ... fetch once +# llm_credentials.sh stop stop refreshing, delete the credentials and print +# the refresh log +# +# Providers are anthropic, openai and openrouter. # # GitHub's OIDC tokens last about five minutes, and each provider's credential expires no later # than the token it was exchanged for, so a run longer than that needs the background refresh. @@ -19,6 +21,10 @@ # openai: exchanges a GitHub token for an OpenAI access token using OPENAI_IDENTITY_PROVIDER_ID, # OPENAI_SERVICE_ACCOUNT_ID and OPENAI_WIF_AUDIENCE, and sets it as the openai entry of pi's # auth.json, which pi rereads when the file changes. Other entries are kept. +# openrouter: exchanges a GitHub token with audience $OPENROUTER_WIF_AUDIENCE (default +# https://openrouter.ai) for an OpenRouter access token using OPENROUTER_FEDERATION_POLICY_ID. +# It writes the token to $OPENROUTER_ACCESS_TOKEN_FILE for callers that read it per request, and +# sets it as the openrouter entry of pi's auth.json. set -euo pipefail umask 077 @@ -68,11 +74,38 @@ refresh_openai() { write "$auth" "$current" } +refresh_openrouter() { + local token body access current + token=$(github_token "${OPENROUTER_WIF_AUDIENCE:-https://openrouter.ai}") || return 1 + body=$(jq -rn --arg token "$token" --arg policy "${OPENROUTER_FEDERATION_POLICY_ID:?}" \ + '{grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token_type: "urn:ietf:params:oauth:token-type:jwt", + federation_policy_id: $policy, subject_token: $token} + | to_entries | map("\(.key)=\(.value | @uri)") | join("&")') || return 1 + access=$(printf '%s' "$body" | + curl -fsS --retry 3 https://openrouter.ai/api/v1/oauth/token \ + -H 'Content-Type: application/x-www-form-urlencoded' -d @- | + jq -re .access_token) || return 1 + mkdir -p "$(dirname "$auth")" || return 1 + current='{}' + if [ -s "$auth" ]; then + current=$(cat "$auth") || return 1 + fi + current=$(jq -e --arg key "$access" '.openrouter = {type: "api_key", key: $key}' <<<"$current") || return 1 + write "${OPENROUTER_ACCESS_TOKEN_FILE:?}" "$access" || return 1 + write "$auth" "$current" +} + +# refresh ...: every provider is attempted, and the call fails if any of them did. refresh() { - case "$1" in - anthropic | openai) "refresh_$1" ;; - *) echo "no federated credentials for provider '$1'" >&2; return 1 ;; - esac + local provider status=0 + for provider in "$@"; do + case "$provider" in + anthropic | openai | openrouter) "refresh_$provider" || { echo "$(date -u +%FT%TZ) $provider credential refresh failed"; status=1; } ;; + *) echo "no federated credentials for provider '$provider'" >&2; status=1 ;; + esac + done + return "$status" } stop_loop() { @@ -97,9 +130,12 @@ stop() { if [ -n "${ANTHROPIC_IDENTITY_TOKEN_FILE:-}" ]; then rm -f "$ANTHROPIC_IDENTITY_TOKEN_FILE" "$ANTHROPIC_IDENTITY_TOKEN_FILE.tmp" fi + if [ -n "${OPENROUTER_ACCESS_TOKEN_FILE:-}" ]; then + rm -f "$OPENROUTER_ACCESS_TOKEN_FILE" "$OPENROUTER_ACCESS_TOKEN_FILE.tmp" + fi rm -f "$auth.tmp" if [ -s "$auth" ]; then - if updated=$(jq -e 'del(.openai)' "$auth"); then + if updated=$(jq -e 'del(.openai, .openrouter)' "$auth"); then write "$auth" "$updated" else echo "$auth is not valid JSON; left it as is" >&2 @@ -113,13 +149,15 @@ stop() { return "$status" } -usage="usage: llm_credentials.sh start|refresh | stop" +usage="usage: llm_credentials.sh start|refresh ... | stop" case "${1:-}" in start) # A second start replaces the first loop rather than orphaning it. stop_loop - refresh "${2:?$usage}" - setsid bash "${BASH_SOURCE[0]}" loop "$2" >"$log" 2>&1 & + shift + [ "$#" -gt 0 ] || { echo "$usage" >&2; exit 1; } + refresh "$@" + setsid bash "${BASH_SOURCE[0]}" loop "$@" >"$log" 2>&1 & for _ in $(seq 50); do [ -s "$pidfile" ] && exit 0 sleep 0.1 @@ -127,12 +165,17 @@ case "${1:-}" in echo "credential refresh loop did not start" >&2 exit 1 ;; - refresh) refresh "${2:?$usage}" ;; + refresh) + shift + [ "$#" -gt 0 ] || { echo "$usage" >&2; exit 1; } + refresh "$@" + ;; stop) stop ;; loop) echo "$$" > "$pidfile" + shift while sleep "${LLM_CREDENTIALS_INTERVAL:-30}"; do - refresh "$2" || echo "$(date -u +%FT%TZ) $2 credential refresh failed" + refresh "$@" || true done ;; *) echo "$usage" >&2; exit 1 ;; diff --git a/README.md b/README.md index 84f6cb5..57728dd 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ short-lived provider credentials, which is why the caller grants `id-token: writ ### `llm-credentials` action -Gives pi short-lived Anthropic or OpenAI credentials through workload identity federation, so a +Gives pi short-lived Anthropic, OpenAI or OpenRouter credentials through workload identity federation, so a job needs no stored API key. It refreshes them every 30 seconds in the background, so long agent runs keep working past the five-minute life of a GitHub OIDC token. Run it again with `stop: true` once the model is done: that stops the refresh, deletes the credentials, and prints the refresh @@ -144,6 +144,11 @@ used. Don't also set `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` in the pi step: a key takes precedence over federation. For OpenAI the action sets only the `openai` entry of pi's `auth.json`. +A job whose own scripts call OpenRouter, with a pi model elsewhere, also passes +`openrouter-federation-policy-id: ${{ vars.OPENROUTER_FEDERATION_POLICY_ID }}`. Later steps then +get `OPENROUTER_ACCESS_TOKEN_FILE`, which holds an OpenRouter access token (15 minutes at most) +that the background loop keeps fresh. Read it before each request rather than once at startup. + ## Enrollment Enroll repositories that process, store, transmit, or can materially affect production customer data or production operations. Examples include application services, data pipelines, infrastructure-as-code, deployment tooling, internal admin tools, and customer-facing dashboards. diff --git a/scripts/test_llm_credentials.py b/scripts/test_llm_credentials.py index d73ba64..6bcd881 100644 --- a/scripts/test_llm_credentials.py +++ b/scripts/test_llm_credentials.py @@ -5,6 +5,7 @@ import tempfile import time import unittest +import urllib.parse import yaml @@ -28,6 +29,10 @@ if url.startswith("https://auth.openai.com/"): default = json.dumps({"access_token": "access-for-" + json.loads(body)["subject_token"]}) print(os.environ.get("FAKE_OPENAI_RESPONSE", default)) +elif url.startswith("https://openrouter.ai/"): + form = urllib.parse.parse_qs(body) + default = json.dumps({"access_token": "or-access-for-" + form["subject_token"][0]}) + print(os.environ.get("FAKE_OPENROUTER_RESPONSE", default)) else: counter = Path(os.environ["FAKE_CURL_LOG"] + ".n") n = int(counter.read_text()) + 1 if counter.exists() else 1 @@ -57,6 +62,8 @@ def setUp(self): "OPENAI_IDENTITY_PROVIDER_ID": "idp_test", "OPENAI_SERVICE_ACCOUNT_ID": "user-test", "OPENAI_WIF_AUDIENCE": "https://api.openai.com/v1", + "OPENROUTER_FEDERATION_POLICY_ID": "fp_test", + "OPENROUTER_ACCESS_TOKEN_FILE": str(self.root / "openrouter-token"), "PI_CODING_AGENT_DIR": str(self.root / "pi"), } @@ -102,12 +109,51 @@ def test_openai_keeps_other_auth_entries(self): self.assertEqual(auth["anthropic"], {"type": "api_key", "key": "other"}) self.assertEqual(auth["openai"]["key"], "access-for-jwt1-for-https://api.openai.com/v1") + def test_openrouter_exchanges_the_github_token(self): + self.assertEqual(self.run_script("refresh", "openrouter").returncode, 0) + oidc, exchange = self.requests() + self.assertTrue(oidc["url"].endswith("&audience=https%3A%2F%2Fopenrouter.ai")) + self.assertEqual(exchange["url"], "https://openrouter.ai/api/v1/oauth/token") + self.assertEqual(exchange["headers"], ["Content-Type: application/x-www-form-urlencoded"]) + self.assertEqual(urllib.parse.parse_qs(exchange["body"]), { + "grant_type": ["urn:ietf:params:oauth:grant-type:token-exchange"], + "subject_token_type": ["urn:ietf:params:oauth:token-type:jwt"], + "federation_policy_id": ["fp_test"], + "subject_token": ["jwt1-for-https://openrouter.ai"], + }) + token_file = self.root / "openrouter-token" + self.assertEqual(token_file.read_text(), "or-access-for-jwt1-for-https://openrouter.ai") + self.assertEqual(token_file.stat().st_mode & 0o777, 0o600) + self.assertEqual(json.loads(self.auth.read_text()), + {"openrouter": {"type": "api_key", "key": "or-access-for-jwt1-for-https://openrouter.ai"}}) + + def test_refresh_handles_several_providers(self): + self.assertEqual(self.run_script("refresh", "openai", "openrouter").returncode, 0) + self.assertEqual(set(json.loads(self.auth.read_text())), {"openai", "openrouter"}) + # One failing provider fails the call without skipping the others. + result = self.run_script("refresh", "openrouter", "openai", FAKE_OPENROUTER_RESPONSE="{}") + self.assertNotEqual(result.returncode, 0) + self.assertIn("openrouter credential refresh failed", result.stdout) + self.assertEqual(json.loads(self.auth.read_text())["openai"]["key"], + "access-for-jwt4-for-https://api.openai.com/v1") + + def test_stop_removes_openrouter_credentials(self): + self.run_script("refresh", "openai", "openrouter") + self.assertEqual(self.run_script("stop").returncode, 0) + self.assertFalse((self.root / "openrouter-token").exists()) + self.assertEqual(json.loads(self.auth.read_text()), {}) + def test_failed_refresh_keeps_working_credentials(self): self.run_script("refresh", "openai") self.run_script("refresh", "anthropic") before = {p: p.read_text() for p in (self.auth, self.token_file)} - for provider in ("openai", "anthropic"): + self.run_script("refresh", "openrouter") + before[self.root / "openrouter-token"] = (self.root / "openrouter-token").read_text() + before[self.auth] = self.auth.read_text() + for provider in ("openai", "anthropic", "openrouter"): self.assertNotEqual(self.run_script("refresh", provider, FAKE_CURL_FAIL="1").returncode, 0) + for response in ("{}", '{"error": "invalid_grant"}'): + self.assertNotEqual(self.run_script("refresh", "openrouter", FAKE_OPENROUTER_RESPONSE=response).returncode, 0) # The GitHub token is issued but OpenAI's response has no access token. for response in ("{}", '{"error": "invalid_grant"}'): self.assertNotEqual(self.run_script("refresh", "openai", FAKE_OPENAI_RESPONSE=response).returncode, 0) @@ -186,17 +232,29 @@ def run_action(self, model, **overrides): return result, read("calls"), read("github-env") def test_action_starts_the_model_provider(self): - _, calls, github_env = self.run_action("anthropic/claude-x") + _, calls, github_env = self.run_action("anthropic/claude-x", OPENROUTER_FEDERATION_POLICY_ID="") self.assertEqual(calls, "start anthropic\n") # Later steps run pi, whose Anthropic SDK needs the federation settings. self.assertEqual(github_env.splitlines(), [ f"{name}=id-{name}" for name in ("ANTHROPIC_FEDERATION_RULE_ID", "ANTHROPIC_ORGANIZATION_ID", "ANTHROPIC_SERVICE_ACCOUNT_ID", "ANTHROPIC_IDENTITY_TOKEN_FILE")]) - _, calls, github_env = self.run_action("openai/gpt-x") + _, calls, github_env = self.run_action("openai/gpt-x", OPENROUTER_FEDERATION_POLICY_ID="") self.assertEqual(calls, "start openai\n") self.assertEqual(github_env, "") + def test_action_adds_openrouter_when_its_policy_is_set(self): + _, calls, github_env = self.run_action("openai/gpt-x") + self.assertEqual(calls, "start openai openrouter\n") + self.assertEqual(github_env, "OPENROUTER_ACCESS_TOKEN_FILE=id-OPENROUTER_ACCESS_TOKEN_FILE\n") + + _, calls, _ = self.run_action("openai/gpt-x", OPENROUTER_FEDERATION_POLICY_ID="") + self.assertEqual(calls, "start openai\n") + + _, calls, github_env = self.run_action("openrouter/deepseek-x") + self.assertEqual(calls, "start openrouter\n") + self.assertIn("OPENROUTER_ACCESS_TOKEN_FILE=", github_env) + def test_action_stops(self): result, calls, _ = self.run_action("", STOP="true") self.assertEqual(result.returncode, 0, result.stderr) @@ -210,7 +268,8 @@ def test_action_requires_a_model(self): def test_action_requires_the_providers_ids(self): for model, name, hint in (("anthropic/claude-x", "ANTHROPIC_FEDERATION_RULE_ID", "anthropic-federation-rule-id"), - ("openai/gpt-x", "OPENAI_WIF_AUDIENCE", "openai-audience")): + ("openai/gpt-x", "OPENAI_WIF_AUDIENCE", "openai-audience"), + ("openrouter/x", "OPENROUTER_FEDERATION_POLICY_ID", "openrouter-federation-policy-id")): result, calls, _ = self.run_action(model, **{name: ""}) self.assertNotEqual(result.returncode, 0) self.assertIn(hint, result.stderr)