From 28666e59310a617f14e898c525abbe7a1e3a567a Mon Sep 17 00:00:00 2001 From: Taylor Meek Date: Wed, 29 Oct 2025 15:35:52 -0700 Subject: [PATCH 1/6] sc-35229: Aptible Managed AI --- .dockerignore | 1 + .rspec | 4 +- Makefile | 11 +- lib/aptible/cli/agent.rb | 3 + lib/aptible/cli/helpers/ai_token.rb | 90 ++++++ lib/aptible/cli/resource_formatter.rb | 46 ++++ lib/aptible/cli/subcommands/ai_tokens.rb | 256 ++++++++++++++++++ .../aptible/cli/subcommands/ai_tokens_spec.rb | 167 ++++++++++++ spec/fabricators/account_fabricator.rb | 1 + spec/fabricators/ai_token_fabricator.rb | 40 +++ .../integration/ai_tokens_integration_spec.rb | 196 ++++++++++++++ 11 files changed, 813 insertions(+), 2 deletions(-) create mode 100644 lib/aptible/cli/helpers/ai_token.rb create mode 100644 lib/aptible/cli/subcommands/ai_tokens.rb create mode 100644 spec/aptible/cli/subcommands/ai_tokens_spec.rb create mode 100644 spec/fabricators/ai_token_fabricator.rb create mode 100644 spec/integration/ai_tokens_integration_spec.rb diff --git a/.dockerignore b/.dockerignore index ec33ffa7..9a3d3314 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,3 +18,4 @@ tmp /.idea /.vscode Makefile +.ruby-version diff --git a/.rspec b/.rspec index b3eb8b49..2e897715 100644 --- a/.rspec +++ b/.rspec @@ -1,2 +1,4 @@ --color ---format documentation \ No newline at end of file +--format documentation +--require spec_helper +--tag ~integration diff --git a/Makefile b/Makefile index 00ee47cd..7a7d274b 100644 --- a/Makefile +++ b/Makefile @@ -7,4 +7,13 @@ bash: build test: build docker compose run cli bundle exec rake -.PHONY: build bash test +integration: build + @echo "Running integration tests..." + @echo "Set DEPLOY_API_URL to point to your running deploy-api instance" + @echo "Set DEPLOY_API_TOKEN if authentication is required" + docker compose run \ + -e DEPLOY_API_URL=${DEPLOY_API_URL} \ + -e DEPLOY_API_TOKEN=${DEPLOY_API_TOKEN} \ + cli bundle exec rspec --tag integration + +.PHONY: build bash test integration diff --git a/lib/aptible/cli/agent.rb b/lib/aptible/cli/agent.rb index af5cbefd..76bc89f0 100644 --- a/lib/aptible/cli/agent.rb +++ b/lib/aptible/cli/agent.rb @@ -26,6 +26,7 @@ require_relative 'helpers/date_helpers' require_relative 'helpers/s3_log_helpers' require_relative 'helpers/maintenance' +require_relative 'helpers/ai_token' require_relative 'subcommands/apps' require_relative 'subcommands/config' @@ -45,6 +46,7 @@ require_relative 'subcommands/metric_drain' require_relative 'subcommands/maintenance' require_relative 'subcommands/backup_retention_policy' +require_relative 'subcommands/ai_tokens' module Aptible module CLI @@ -74,6 +76,7 @@ class Agent < Thor include Subcommands::MetricDrain include Subcommands::Maintenance include Subcommands::BackupRetentionPolicy + include Subcommands::AiTokens # Forward return codes on failures. def self.exit_on_failure? diff --git a/lib/aptible/cli/helpers/ai_token.rb b/lib/aptible/cli/helpers/ai_token.rb new file mode 100644 index 00000000..7f4a807c --- /dev/null +++ b/lib/aptible/cli/helpers/ai_token.rb @@ -0,0 +1,90 @@ +module Aptible + module CLI + module Helpers + module AiToken + include Helpers::Token + + def ensure_ai_token(account, id) + ai_tokens = account.ai_tokens.select { |t| t.id.to_s == id.to_s } + + if ai_tokens.empty? + raise Thor::Error, "AI token #{id} not found or access denied" + end + + ai_tokens.first + rescue HyperResource::ClientError => e + if e.response.status == 404 + raise Thor::Error, "AI token #{id} not found or access denied" + else + raise Thor::Error, "Failed to retrieve token: #{e.message}" + end + end + + def create_ai_token(account, opts) + ai_token = account.create_ai_token!(opts) + + # Log full HAL response in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' + begin + CLI.logger.warn "POST create response: #{JSON.pretty_generate(ai_token.body)}" + rescue StandardError + CLI.logger.warn "POST create response: #{ai_token.body.inspect}" + end + end + + Formatter.render(Renderer.current) do |root| + root.object do |node| + ResourceFormatter.inject_ai_token(node, ai_token, account) + + # Include the token value on creation if present + token_value = ai_token.attributes['token'] + node.value('token', token_value) if token_value + end + end + + # Warn about token value if present + token_value = ai_token.attributes['token'] + if token_value + CLI.logger.warn "\nSave the token value now - it will not be shown again!" + end + + ai_token + rescue HyperResource::ClientError, HyperResource::ServerError => e + # Log response body in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.respond_to?(:response) && e.response + begin + body = e.response.body + parsed_body = body.is_a?(String) ? JSON.parse(body) : body + CLI.logger.warn "POST create error response (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}" + rescue StandardError + CLI.logger.warn "POST create error response (#{e.response.status}): #{e.response.body.inspect}" + end + end + + # Extract clean error message from response + error_message = if e.respond_to?(:body) && e.body.is_a?(Hash) + e.body['error'] || e.message + elsif e.respond_to?(:response) && e.response&.status + "Failed to create token: HTTP #{e.response.status}" + else + e.message + end + raise Thor::Error, error_message + rescue HyperResource::ResponseError => e + # Log response body in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response + begin + body = e.response.body + parsed_body = body.is_a?(String) ? JSON.parse(body) : body + CLI.logger.warn "POST create response error (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}" + rescue StandardError + CLI.logger.warn "POST create response error (#{e.response.status}): #{e.response.body.inspect}" + end + end + + raise Thor::Error, "Failed to create token: HTTP #{e.response&.status || 'unknown error'}" + end + end + end + end +end diff --git a/lib/aptible/cli/resource_formatter.rb b/lib/aptible/cli/resource_formatter.rb index c8230a54..35ee922a 100644 --- a/lib/aptible/cli/resource_formatter.rb +++ b/lib/aptible/cli/resource_formatter.rb @@ -262,6 +262,52 @@ def inject_metric_drain(node, metric_drain, account) attach_account(node, account) end + def inject_ai_token(node, ai_token, account, include_display: false) + require 'base64' + + node.value('id', ai_token.id) + + # Decode the note from URL-safe base64 (encrypted at rest, decoded here for display) + encoded_note = ai_token.attributes['note'] rescue nil + note = if encoded_note + begin + Base64.urlsafe_decode64(encoded_note) + rescue ArgumentError + encoded_note # Fall back to raw value if decoding fails + end + end + node.value('note', note) if note + + # Check blocked status (use attributes hash for HyperResource compatibility) + is_blocked = ai_token.attributes['blocked'] rescue false + + # Display field is only used for list view (grouped_keyed_list) + if include_display + # Determine status: revoked if blocked, otherwise active + status = is_blocked ? 'REVOKED' : 'ACTIVE' + + # Format: "ID ACTIVE note" or "ID REVOKED note" + # Pad ACTIVE with 2 extra spaces to align with REVOKED (7 chars + 1 space = 8) + status_padded = status == 'ACTIVE' ? 'ACTIVE ' : 'REVOKED ' + display_note = note || '' + node.value('display', "#{ai_token.id} #{status_padded}#{display_note}") + end + + node.value('created_at', ai_token.created_at) + + # Optional fields - only include if present (use attributes hash for HyperResource compatibility) + updated_at = ai_token.attributes['updated_at'] rescue nil + node.value('updated_at', updated_at) if updated_at + + last_used_at = ai_token.attributes['last_used_at'] rescue nil + node.value('last_used_at', last_used_at) if last_used_at + + # Show status in detail view + node.value('status', is_blocked ? 'REVOKED' : 'ACTIVE') + + attach_account(node, account) if account + end + def inject_maintenance( node, command_prefix, diff --git a/lib/aptible/cli/subcommands/ai_tokens.rb b/lib/aptible/cli/subcommands/ai_tokens.rb new file mode 100644 index 00000000..978e52cc --- /dev/null +++ b/lib/aptible/cli/subcommands/ai_tokens.rb @@ -0,0 +1,256 @@ +module Aptible + module CLI + module Subcommands + module AiTokens + def self.included(thor) + thor.class_eval do + include Helpers::Token + include Helpers::Environment + include Helpers::AiToken + include Helpers::Telemetry + + desc 'ai:tokens:create [--environment ENVIRONMENT_HANDLE] [--note NOTE]', + 'Create a new AI token' + option :environment, aliases: '--env', desc: 'Environment to create the token in' + option :note, type: :string, desc: 'Optional note to describe the token (max 256 chars)' + define_method 'ai:tokens:create' do + telemetry(__method__, options) + + account = ensure_environment(options) + + opts = {} + if options[:note] + # URL-safe base64 encode the note for safe transport to deploy-api + # deploy-api will validate and encrypt it before storing in LiteLLM + require 'base64' + opts[:note] = Base64.urlsafe_encode64(options[:note], padding: true) + end + + create_ai_token(account, opts) + end + + desc 'ai:tokens:list [--environment ENVIRONMENT_HANDLE]', + 'List all AI tokens' + option :environment, aliases: '--env', desc: 'Environment to list tokens from' + define_method 'ai:tokens:list' do + telemetry(__method__, options) + + Formatter.render(Renderer.current) do |root| + root.grouped_keyed_list( + { 'environment' => 'handle' }, + 'display' + ) do |node| + accounts = scoped_environments(options) + + accounts.each do |account| + begin + # Fetch tokens collection + tokens = account.ai_tokens + + # Log full HAL response in debug mode (single API call returns all tokens) + if ENV['APTIBLE_DEBUG'] == 'DEBUG' + begin + tokens_array = tokens.map(&:body) + CLI.logger.warn "GET /accounts/#{account.id}/ai_tokens response: #{JSON.pretty_generate(tokens_array)}" + rescue StandardError + CLI.logger.warn "GET /accounts/#{account.id}/ai_tokens response: #{tokens.map(&:body).inspect}" + end + end + + tokens.each do |ai_token| + node.object do |n| + ResourceFormatter.inject_ai_token(n, ai_token, account, include_display: true) + end + end + rescue HyperResource::ClientError => e + # Log response body in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response + begin + body = e.response.body + parsed_body = body.is_a?(String) ? JSON.parse(body) : body + CLI.logger.warn "GET list error response (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}" + rescue StandardError + CLI.logger.warn "GET list error response (#{e.response.status}): #{e.response.body.inspect}" + end + end + + # Skip if endpoint not available for this account + if e.response&.status == 404 + next + elsif e.response&.status == 401 || e.response&.status == 403 + raise Thor::Error, "Unauthorized to list AI tokens for environment #{account.handle}" + else + raise Thor::Error, "Failed to list tokens: #{e.message}" + end + rescue HyperResource::ResponseError => e + # Log response body in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response + begin + body = e.response.body + parsed_body = body.is_a?(String) ? JSON.parse(body) : body + CLI.logger.warn "GET list response error (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}" + rescue StandardError + CLI.logger.warn "GET list response error (#{e.response.status}): #{e.response.body.inspect}" + end + end + + raise Thor::Error, "Failed to list tokens: HTTP #{e.response&.status || 'unknown error'}" + end + end + end + end + end + + desc 'ai:tokens:show ID', 'Show details of an AI token' + define_method 'ai:tokens:show' do |id| + telemetry(__method__, options.merge(id: id)) + + # GET /ai_tokens/:id via HAL + # Must set root URL explicitly for the request to work + api_root = Aptible::Api.configuration.root_url + ai_token = Aptible::Api::AiToken.new( + root: api_root, + token: fetch_token + ) + ai_token.href = "#{api_root}/ai_tokens/#{id}" + + begin + ai_token = ai_token.get + + # Log full HAL response in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' + begin + CLI.logger.warn "GET show response: #{JSON.pretty_generate(ai_token.body)}" + rescue StandardError + CLI.logger.warn "GET show response: #{ai_token.body.inspect}" + end + end + + # Get account from token's link if available + account = nil + if ai_token.links && ai_token.links.account + begin + account = Aptible::Api::Account.new( + token: fetch_token + ).find_by_url(ai_token.links.account.href) + rescue StandardError + # If we can't fetch the account, continue without it + account = nil + end + end + + Formatter.render(Renderer.current) do |root| + root.object do |node| + ResourceFormatter.inject_ai_token(node, ai_token, account) + end + end + rescue HyperResource::ClientError => e + # Log response body in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response + begin + body = e.response.body + parsed_body = body.is_a?(String) ? JSON.parse(body) : body + CLI.logger.warn "GET show error response (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}" + rescue StandardError + CLI.logger.warn "GET show error response (#{e.response.status}): #{e.response.body.inspect}" + end + end + + if e.response&.status == 404 + raise Thor::Error, "AI token #{id} not found or access denied" + elsif e.response&.status == 401 || e.response&.status == 403 + raise Thor::Error, "Unauthorized to view AI token #{id}" + else + raise Thor::Error, "Failed to retrieve token: #{e.message}" + end + rescue HyperResource::ResponseError => e + # Log response body in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response + begin + body = e.response.body + parsed_body = body.is_a?(String) ? JSON.parse(body) : body + CLI.logger.warn "GET show response error (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}" + rescue StandardError + CLI.logger.warn "GET show response error (#{e.response.status}): #{e.response.body.inspect}" + end + end + + raise Thor::Error, "Failed to retrieve token: HTTP #{e.response&.status || 'unknown error'}" + end + end + + desc 'ai:tokens:revoke ID', 'Revoke an AI token' + define_method 'ai:tokens:revoke' do |id| + telemetry(__method__, options.merge(id: id)) + + # First, fetch the token to verify it exists and get a proper resource + api_root = Aptible::Api.configuration.root_url + url = "#{api_root}/ai_tokens/#{id}" + + begin + ai_token = Aptible::Api::AiToken.new(token: fetch_token) + .find_by_url(url) + raise Thor::Error, "AI token #{id} not found" if ai_token.nil? + + # Log full HAL response in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' + begin + CLI.logger.warn "GET response: #{JSON.pretty_generate(ai_token.body)}" + rescue StandardError + CLI.logger.warn "GET response: #{ai_token.body.inspect}" + end + end + + # Check if already revoked before attempting DELETE + if ai_token.blocked + raise Thor::Error, 'Token has already been revoked' + end + + ai_token.delete + + # Log DELETE response in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' + if ai_token.response + response_body = ai_token.response.body + if response_body && !response_body.empty? + begin + CLI.logger.warn "DELETE response (#{ai_token.response.status}): #{JSON.pretty_generate(JSON.parse(response_body))}" + rescue StandardError + CLI.logger.warn "DELETE response (#{ai_token.response.status}): #{response_body.inspect}" + end + else + CLI.logger.warn "DELETE response (#{ai_token.response.status}): " + end + end + end + + CLI.logger.info 'AI token revoked successfully' + rescue HyperResource::ClientError => e + # Log response body in debug mode + if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response + begin + body = e.response.body + parsed_body = body.is_a?(String) ? JSON.parse(body) : body + CLI.logger.warn "DELETE error response (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}" + rescue StandardError + CLI.logger.warn "DELETE error response (#{e.response.status}): #{e.response.body.inspect}" + end + end + + if e.response&.status == 404 + raise Thor::Error, "AI token #{id} not found or access denied" + elsif e.response&.status == 401 || e.response&.status == 403 + raise Thor::Error, "Unauthorized to revoke AI token #{id}" + else + raise Thor::Error, "Failed to revoke token: #{e.message}" + end + end + # Note: HyperResource::ResponseError from empty 204 body is caught by + # Aptible::Resource::Base#delete (returns nil), so delete succeeds silently + end + end + end + end + end + end +end diff --git a/spec/aptible/cli/subcommands/ai_tokens_spec.rb b/spec/aptible/cli/subcommands/ai_tokens_spec.rb new file mode 100644 index 00000000..191bbf84 --- /dev/null +++ b/spec/aptible/cli/subcommands/ai_tokens_spec.rb @@ -0,0 +1,167 @@ +require 'spec_helper' + +describe Aptible::CLI::Agent do + let(:account) { Fabricate(:account) } + let(:token) { double('token') } + + before { allow(subject).to receive(:fetch_token).and_return(token) } + + describe '#ai:tokens:list' do + let!(:ai_token) do + Fabricate(:ai_token, name: 'test-token', account: account) + end + + before do + allow(subject).to receive(:scoped_environments).with({}).and_return([account]) + end + + it 'lists AI tokens for an account' do + expect { subject.send('ai:tokens:list') }.not_to raise_error + end + + it 'lists AI tokens across multiple accounts' do + other_account = Fabricate(:account) + Fabricate(:ai_token, name: 'test-token-2', account: other_account) + + allow(subject).to receive(:scoped_environments).with({}) + .and_return([account, other_account]) + + expect { subject.send('ai:tokens:list') }.not_to raise_error + end + + it 'skips accounts when endpoint returns 404' do + error_response = double('error_response', status: 404, body: 'Not Found') + error = HyperResource::ClientError.new('Not Found', response: error_response) + + allow(account).to receive(:ai_tokens).and_raise(error) + + expect { subject.send('ai:tokens:list') }.not_to raise_error + end + end + + describe '#ai:tokens:create' do + let(:created_token) do + Fabricate(:ai_token, name: 'new-token', account: account) + end + + before do + allow(subject).to receive(:ensure_environment).and_return(account) + end + + it 'creates an AI token with a name' do + expect(account).to receive(:create_ai_token!) + .with(name: 'new-token').and_return(created_token) + + subject.options = { name: 'new-token' } + expect { subject.send('ai:tokens:create') }.not_to raise_error + + expect(captured_logs).to include('Save the token value now') + end + + it 'creates an AI token without a name' do + expect(account).to receive(:create_ai_token!) + .with({}).and_return(created_token) + + subject.options = {} + subject.send('ai:tokens:create') + + expect(captured_logs).to include('Save the token value now') + end + + it 'warns user to save token value if present' do + token_with_value = Fabricate(:ai_token, name: 'new-token', account: account) + allow(token_with_value).to receive(:attributes) + .and_return({ 'token' => 'sk-secret-value' }) + + expect(account).to receive(:create_ai_token!) + .with(name: 'new-token').and_return(token_with_value) + + subject.options = { name: 'new-token' } + subject.send('ai:tokens:create') + + expect(captured_logs).to include('Save the token value now - it will not be shown again!') + end + end + + describe '#ai:tokens:show' do + let(:ai_token_resource) { double('ai_token_resource') } + let(:token_id) { 'sk-test-token-12345' } + let(:token_response) do + Fabricate(:ai_token, id: token_id, name: 'test-token', account: nil) + end + + before do + allow(Aptible::Api::Resource).to receive(:new) + .with(token: token) + .and_return(ai_token_resource) + allow(ai_token_resource).to receive(:href=) + end + + it 'shows an AI token successfully' do + allow(ai_token_resource).to receive(:get).and_return(token_response) + + expect { subject.send('ai:tokens:show', token_id) }.not_to raise_error + + expect(ai_token_resource).to have_received(:href=).with("/ai_tokens/#{token_id}") + expect(ai_token_resource).to have_received(:get) + end + + it 'raises an error if the token is not found (404)' do + error_response = double('error_response', status: 404, body: 'Not Found') + error = HyperResource::ClientError.new('Not Found', response: error_response) + allow(ai_token_resource).to receive(:get).and_raise(error) + + expect { subject.send('ai:tokens:show', 'nonexistent') } + .to raise_error(Thor::Error, /AI token nonexistent not found or access denied/) + end + + it 'raises an error on other client errors' do + error_response = double('error_response', status: 500, body: 'Internal Server Error') + error = HyperResource::ClientError.new('Internal Server Error', response: error_response) + allow(ai_token_resource).to receive(:get).and_raise(error) + + expect { subject.send('ai:tokens:show', token_id) } + .to raise_error(Thor::Error, /Failed to retrieve token/) + end + end + + describe '#ai:tokens:revoke' do + let(:ai_token_resource) { double('ai_token_resource') } + let(:token_id) { 'sk-test-token-12345' } + + before do + allow(Aptible::Api::Resource).to receive(:new) + .with(token: token) + .and_return(ai_token_resource) + allow(ai_token_resource).to receive(:href=) + end + + it 'revokes an AI token successfully' do + allow(ai_token_resource).to receive(:delete) + + subject.send('ai:tokens:revoke', token_id) + + expect(ai_token_resource).to have_received(:href=).with("/ai_tokens/#{token_id}") + expect(ai_token_resource).to have_received(:delete) + expect(captured_logs).to include('AI token revoked successfully') + end + + it 'raises an error if the token is not found (404)' do + error_response = double('error_response', status: 404, body: 'Not Found') + error = HyperResource::ClientError.new('Not Found', response: error_response) + allow(ai_token_resource).to receive(:delete).and_raise(error) + + expect { subject.send('ai:tokens:revoke', 'nonexistent') } + .to raise_error(Thor::Error, /AI token nonexistent not found or access denied/) + end + + it 'raises an error on other client errors' do + error_response = double('error_response', status: 500, body: 'Internal Server Error') + error = HyperResource::ClientError.new('Internal Server Error', response: error_response) + allow(ai_token_resource).to receive(:delete).and_raise(error) + + expect { subject.send('ai:tokens:revoke', token_id) } + .to raise_error(Thor::Error, /Failed to revoke token/) + end + end +end diff --git a/spec/fabricators/account_fabricator.rb b/spec/fabricators/account_fabricator.rb index eff3dde4..63015ecb 100644 --- a/spec/fabricators/account_fabricator.rb +++ b/spec/fabricators/account_fabricator.rb @@ -29,6 +29,7 @@ def each_certificate(&block) log_drains { [] } metric_drains { [] } backup_retention_policies { [] } + ai_tokens { [] } created_at { Time.now } links do |attrs| hash = { diff --git a/spec/fabricators/ai_token_fabricator.rb b/spec/fabricators/ai_token_fabricator.rb new file mode 100644 index 00000000..6096c75c --- /dev/null +++ b/spec/fabricators/ai_token_fabricator.rb @@ -0,0 +1,40 @@ +class StubAiToken < OpenStruct + def attributes + { + 'id' => id, + 'name' => name, + 'token' => token, + 'created_at' => created_at + } + end + + # Provide handle method for grouped list formatter + def handle + account ? account.handle : 'aptible' + end +end + +Fabricator(:ai_token, from: :stub_ai_token) do + id { sequence(:ai_token_id) } + name 'test-ai-token' + token 'sk-test-token-12345' + created_at { Time.now } + account + links do |attrs| + hash = {} + if attrs[:account] + hash[:account] = OpenStruct.new( + href: "/accounts/#{attrs[:account].id}" + ) + end + OpenStruct.new(hash) + end + + after_create do |ai_token| + if ai_token.account + ai_token.account.ai_tokens ||= [] + ai_token.account.ai_tokens << ai_token + end + end +end + diff --git a/spec/integration/ai_tokens_integration_spec.rb b/spec/integration/ai_tokens_integration_spec.rb new file mode 100644 index 00000000..551cb229 --- /dev/null +++ b/spec/integration/ai_tokens_integration_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'net/http' +require 'json' + +# Integration tests - require a running deploy-api instance +# +# Prerequisites: +# 1. Running deploy-api at DEPLOY_API_URL (e.g., http://localhost:3000) +# 2. Valid Aptible API token in DEPLOY_API_TOKEN +# 3. Target environment handle in TEST_ENVIRONMENT (e.g., "test-env") +# 4. LiteLLM configured in deploy-api (or mocked) +# +# Run with: +# DEPLOY_API_URL=http://localhost:3000 \ +# DEPLOY_API_TOKEN=your_token \ +# TEST_ENVIRONMENT=your-env-handle \ +# bundle exec rspec --tag integration +# +describe 'AI Tokens Integration', :integration do + before(:all) do + @api_url = ENV['DEPLOY_API_URL'] + @api_token = ENV['DEPLOY_API_TOKEN'] + @test_env = ENV['TEST_ENVIRONMENT'] + + skip 'Set DEPLOY_API_URL to run integration tests' unless @api_url + skip 'Set DEPLOY_API_TOKEN to run integration tests' unless @api_token + skip 'Set TEST_ENVIRONMENT to run integration tests' unless @test_env + + # Check if API is reachable + begin + uri = URI.parse(@api_url) + response = Net::HTTP.get_response(uri) + unless response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection) + skip "Deploy API not reachable at #{@api_url}" + end + rescue StandardError => e + skip "Deploy API not reachable at #{@api_url}: #{e.message}" + end + end + + let(:api_url) { @api_url } + let(:api_token) { @api_token } + let(:test_env) { @test_env } + let(:created_token_ids) { @created_token_ids ||= [] } + + # Clean up any tokens created during tests + after(:all) do + next unless @created_token_ids && @api_token + + @created_token_ids.each do |token_id| + # Best effort cleanup - don't fail if token already deleted + system("DEPLOY_API_URL=#{@api_url} DEPLOY_API_TOKEN=#{@api_token} " \ + "bundle exec aptible ai:tokens:revoke #{token_id} 2>/dev/null") + rescue StandardError + # Ignore errors during cleanup + end + end + + # Helper to make direct API calls for verification + def make_api_request(method, path, body = nil) + uri = URI.parse("#{api_url}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + + request = case method + when :get then Net::HTTP::Get.new(uri) + when :post then Net::HTTP::Post.new(uri) + when :delete then Net::HTTP::Delete.new(uri) + end + + request['Authorization'] = "Bearer #{api_token}" + request['Accept'] = 'application/hal+json' + request['Content-Type'] = 'application/json' if body + request.body = body.to_json if body + + http.request(request) + end + + describe 'ai:tokens:list' do + it 'successfully connects to deploy-api and lists tokens' do + # Get accounts first to find an account ID + response = make_api_request(:get, '/accounts') + expect(response.code).to eq('200'), "Failed to fetch accounts: #{response.body}" + + data = JSON.parse(response.body) + accounts = data.dig('_embedded', 'accounts') + expect(accounts).not_to be_empty, 'No accounts found' + + account_id = accounts.first['id'] + + # List tokens for this account + response = make_api_request(:get, "/accounts/#{account_id}/ai_tokens") + expect(response.code).to eq('200'), "Failed to list tokens: #{response.body}" + + data = JSON.parse(response.body) + expect(data).to have_key('_embedded') + expect(data['_embedded']).to have_key('ai_tokens') + # The list might be empty, that's OK + expect(data['_embedded']['ai_tokens']).to be_an(Array) + end + end + + describe 'ai:tokens:create' do + it 'creates a new AI token via API' do + # Get accounts to find an account ID + response = make_api_request(:get, '/accounts') + expect(response.code).to eq('200') + + data = JSON.parse(response.body) + account_id = data.dig('_embedded', 'accounts', 0, 'id') + expect(account_id).not_to be_nil + + # Create a token + token_name = "integration-test-#{Time.now.to_i}" + response = make_api_request(:post, "/accounts/#{account_id}/ai_tokens", { name: token_name }) + + expect(response.code).to eq('201'), "Failed to create token: #{response.body}" + + data = JSON.parse(response.body) + expect(data['name']).to eq(token_name) + expect(data['id']).not_to be_nil + expect(data['token']).not_to be_nil # Should include token value on creation + expect(data['_type']).to eq('ai_token') + + # Track for cleanup + @created_token_ids ||= [] + @created_token_ids << data['id'] + end + end + + describe 'ai:tokens:show' do + it 'retrieves details of a specific token' do + # First create a token + response = make_api_request(:get, '/accounts') + account_id = JSON.parse(response.body).dig('_embedded', 'accounts', 0, 'id') + + create_response = make_api_request(:post, "/accounts/#{account_id}/ai_tokens", + { name: "show-test-#{Time.now.to_i}" }) + token_data = JSON.parse(create_response.body) + token_id = token_data['id'] + + @created_token_ids ||= [] + @created_token_ids << token_id + + # Now retrieve it + response = make_api_request(:get, "/ai_tokens/#{token_id}") + expect(response.code).to eq('200'), "Failed to get token: #{response.body}" + + data = JSON.parse(response.body) + expect(data['id']).to eq(token_id) + expect(data['token']).to be_nil # Should NOT include token value on show + end + end + + describe 'ai:tokens:revoke' do + it 'revokes an existing token' do + # First create a token + response = make_api_request(:get, '/accounts') + account_id = JSON.parse(response.body).dig('_embedded', 'accounts', 0, 'id') + + create_response = make_api_request(:post, "/accounts/#{account_id}/ai_tokens", + { name: "revoke-test-#{Time.now.to_i}" }) + token_data = JSON.parse(create_response.body) + token_id = token_data['id'] + + # Revoke it + response = make_api_request(:delete, "/ai_tokens/#{token_id}") + expect(response.code).to eq('204'), "Failed to revoke token: #{response.body}" + + # Verify it's gone (should return 404) + response = make_api_request(:get, "/ai_tokens/#{token_id}") + expect(response.code).to eq('404'), "Token should not be found after revocation" + end + end + + describe 'authorization' do + it 'rejects requests without a valid token' do + response = make_api_request(:get, '/accounts') + account_id = JSON.parse(response.body).dig('_embedded', 'accounts', 0, 'id') + + # Try to create without proper auth by temporarily using bad token + uri = URI.parse("#{api_url}/accounts/#{account_id}/ai_tokens") + http = Net::HTTP.new(uri.host, uri.port) + request = Net::HTTP::Post.new(uri) + request['Authorization'] = 'Bearer invalid-token' + request['Content-Type'] = 'application/json' + request.body = { name: 'unauthorized-test' }.to_json + + response = http.request(request) + expect(response.code).to eq('401'), 'Should reject invalid token' + end + end +end + From a96802ad6532f825be3e02f4496251901911f950 Mon Sep 17 00:00:00 2001 From: Taylor Meek Date: Tue, 9 Dec 2025 08:40:09 -0800 Subject: [PATCH 2/6] sc-35229: AI tokens include a URL to connect to --- lib/aptible/cli/helpers/ai_token.rb | 6 +++++- lib/aptible/cli/resource_formatter.rb | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/aptible/cli/helpers/ai_token.rb b/lib/aptible/cli/helpers/ai_token.rb index 7f4a807c..59319ce5 100644 --- a/lib/aptible/cli/helpers/ai_token.rb +++ b/lib/aptible/cli/helpers/ai_token.rb @@ -42,10 +42,14 @@ def create_ai_token(account, opts) end end - # Warn about token value if present + # Warn about token value and gateway URL if present token_value = ai_token.attributes['token'] + gateway_url = ai_token.attributes['gateway_url'] if token_value CLI.logger.warn "\nSave the token value now - it will not be shown again!" + if gateway_url + CLI.logger.warn "Use this token to authenticate requests to: #{gateway_url}" + end end ai_token diff --git a/lib/aptible/cli/resource_formatter.rb b/lib/aptible/cli/resource_formatter.rb index 35ee922a..80081ffd 100644 --- a/lib/aptible/cli/resource_formatter.rb +++ b/lib/aptible/cli/resource_formatter.rb @@ -305,6 +305,10 @@ def inject_ai_token(node, ai_token, account, include_display: false) # Show status in detail view node.value('status', is_blocked ? 'REVOKED' : 'ACTIVE') + # Include gateway URL if present + gateway_url = ai_token.attributes['gateway_url'] rescue nil + node.value('gateway_url', gateway_url) if gateway_url + attach_account(node, account) if account end From fa749407a0eed9c86c8dfa94f7de9f2937187ec2 Mon Sep 17 00:00:00 2001 From: Taylor Meek Date: Thu, 11 Dec 2025 23:19:07 -0800 Subject: [PATCH 3/6] sc-35229: handle llm gateway errors --- lib/aptible/cli/subcommands/ai_tokens.rb | 79 +++++++++++------------- 1 file changed, 36 insertions(+), 43 deletions(-) diff --git a/lib/aptible/cli/subcommands/ai_tokens.rb b/lib/aptible/cli/subcommands/ai_tokens.rb index 978e52cc..d3edd3f3 100644 --- a/lib/aptible/cli/subcommands/ai_tokens.rb +++ b/lib/aptible/cli/subcommands/ai_tokens.rb @@ -63,38 +63,30 @@ def self.included(thor) end end rescue HyperResource::ClientError => e + error_message = extract_api_error(e) + # Log response body in debug mode if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response - begin - body = e.response.body - parsed_body = body.is_a?(String) ? JSON.parse(body) : body - CLI.logger.warn "GET list error response (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}" - rescue StandardError - CLI.logger.warn "GET list error response (#{e.response.status}): #{e.response.body.inspect}" - end + CLI.logger.warn "GET list error response (#{e.response.status}): #{error_message}" end # Skip if endpoint not available for this account if e.response&.status == 404 next elsif e.response&.status == 401 || e.response&.status == 403 - raise Thor::Error, "Unauthorized to list AI tokens for environment #{account.handle}" + raise Thor::Error, error_message else - raise Thor::Error, "Failed to list tokens: #{e.message}" + raise Thor::Error, "Failed to list tokens: #{error_message}" end rescue HyperResource::ResponseError => e + error_message = extract_api_error(e) + # Log response body in debug mode if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response - begin - body = e.response.body - parsed_body = body.is_a?(String) ? JSON.parse(body) : body - CLI.logger.warn "GET list response error (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}" - rescue StandardError - CLI.logger.warn "GET list response error (#{e.response.status}): #{e.response.body.inspect}" - end + CLI.logger.warn "GET list response error (#{e.response.status}): #{error_message}" end - raise Thor::Error, "Failed to list tokens: HTTP #{e.response&.status || 'unknown error'}" + raise Thor::Error, "Failed to list tokens: #{error_message}" end end end @@ -145,37 +137,29 @@ def self.included(thor) end end rescue HyperResource::ClientError => e + error_message = extract_api_error(e) + # Log response body in debug mode if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response - begin - body = e.response.body - parsed_body = body.is_a?(String) ? JSON.parse(body) : body - CLI.logger.warn "GET show error response (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}" - rescue StandardError - CLI.logger.warn "GET show error response (#{e.response.status}): #{e.response.body.inspect}" - end + CLI.logger.warn "GET show error response (#{e.response.status}): #{error_message}" end if e.response&.status == 404 raise Thor::Error, "AI token #{id} not found or access denied" elsif e.response&.status == 401 || e.response&.status == 403 - raise Thor::Error, "Unauthorized to view AI token #{id}" + raise Thor::Error, error_message else - raise Thor::Error, "Failed to retrieve token: #{e.message}" + raise Thor::Error, "Failed to retrieve token: #{error_message}" end rescue HyperResource::ResponseError => e + error_message = extract_api_error(e) + # Log response body in debug mode if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response - begin - body = e.response.body - parsed_body = body.is_a?(String) ? JSON.parse(body) : body - CLI.logger.warn "GET show response error (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}" - rescue StandardError - CLI.logger.warn "GET show response error (#{e.response.status}): #{e.response.body.inspect}" - end + CLI.logger.warn "GET show response error (#{e.response.status}): #{error_message}" end - raise Thor::Error, "Failed to retrieve token: HTTP #{e.response&.status || 'unknown error'}" + raise Thor::Error, "Failed to retrieve token: #{error_message}" end end @@ -226,28 +210,37 @@ def self.included(thor) CLI.logger.info 'AI token revoked successfully' rescue HyperResource::ClientError => e + error_message = extract_api_error(e) + # Log response body in debug mode if ENV['APTIBLE_DEBUG'] == 'DEBUG' && e.response - begin - body = e.response.body - parsed_body = body.is_a?(String) ? JSON.parse(body) : body - CLI.logger.warn "DELETE error response (#{e.response.status}): #{JSON.pretty_generate(parsed_body)}" - rescue StandardError - CLI.logger.warn "DELETE error response (#{e.response.status}): #{e.response.body.inspect}" - end + CLI.logger.warn "DELETE error response (#{e.response.status}): #{error_message}" end if e.response&.status == 404 raise Thor::Error, "AI token #{id} not found or access denied" elsif e.response&.status == 401 || e.response&.status == 403 - raise Thor::Error, "Unauthorized to revoke AI token #{id}" + raise Thor::Error, error_message else - raise Thor::Error, "Failed to revoke token: #{e.message}" + raise Thor::Error, "Failed to revoke token: #{error_message}" end end # Note: HyperResource::ResponseError from empty 204 body is caught by # Aptible::Resource::Base#delete (returns nil), so delete succeeds silently end + + private + + # Extract error message from HyperResource error response + def extract_api_error(error) + return error.message unless error.response + + body = error.response.body + parsed = body.is_a?(String) ? JSON.parse(body) : body + parsed['error'] || parsed.to_s + rescue StandardError + error.message + end end end end From 5d59fa119e035921b216bb201b9a05299830771f Mon Sep 17 00:00:00 2001 From: Taylor Meek Date: Mon, 15 Dec 2025 11:42:34 -0800 Subject: [PATCH 4/6] sc-35229: simplecov and json output --- .gitignore | 1 + Gemfile | 2 ++ spec/spec_helper.rb | 15 +++++++++++++++ 3 files changed, 18 insertions(+) diff --git a/.gitignore b/.gitignore index 449764c7..8eb272c0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ test/version_tmp tmp /.idea /.vscode +coverage/ diff --git a/Gemfile b/Gemfile index 00775107..d59de11a 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,8 @@ gem 'activesupport', '~> 4.0' gem 'rack', '~> 1.0' group :test do + gem 'simplecov' + gem 'simplecov_json_formatter' gem 'webmock' end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d1bcf9d1..a61e1fb7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,21 @@ Bundler.require :development +require 'simplecov' +require 'simplecov_json_formatter' + +# Configure SimpleCov for both HTML and JSON output +SimpleCov.start do + add_filter '/spec/' + add_filter '/vendor/' + + # Generate both HTML (for human viewing) and JSON (for CI/tooling) + formatter SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::JSONFormatter + ]) +end + # Load shared spec files Dir["#{File.dirname(__FILE__)}/shared/**/*.rb"].each do |file| require file From 17b11496d9b246014047a7d3effec7d14de99a55 Mon Sep 17 00:00:00 2001 From: Taylor Meek Date: Tue, 16 Dec 2025 15:22:31 -0800 Subject: [PATCH 5/6] sc-35229: show revoker and creator; show JSON on revoke --- Rakefile | 12 ++++- lib/aptible/cli/helpers/ai_token.rb | 14 ++--- lib/aptible/cli/resource_formatter.rb | 52 +++++++++++++++++++ lib/aptible/cli/subcommands/ai_tokens.rb | 27 ++++++++-- .../aptible/cli/subcommands/ai_tokens_spec.rb | 42 +++++++++------ 5 files changed, 116 insertions(+), 31 deletions(-) diff --git a/Rakefile b/Rakefile index 1a4d0407..0770f4cc 100644 --- a/Rakefile +++ b/Rakefile @@ -1,4 +1,12 @@ require 'bundler/gem_tasks' +require 'rspec/core/rake_task' +require 'rubocop/rake_task' -require 'aptible/tasks' -Aptible::Tasks.load_tasks +RSpec::Core::RakeTask.new(:spec) do |spec| + spec.pattern = 'spec/**/*_spec.rb' + spec.rspec_opts = '--exclude-pattern spec/integration/**/*_spec.rb' +end + +RuboCop::RakeTask.new + +task default: [:spec, :rubocop] diff --git a/lib/aptible/cli/helpers/ai_token.rb b/lib/aptible/cli/helpers/ai_token.rb index 59319ce5..0e572885 100644 --- a/lib/aptible/cli/helpers/ai_token.rb +++ b/lib/aptible/cli/helpers/ai_token.rb @@ -7,9 +7,7 @@ module AiToken def ensure_ai_token(account, id) ai_tokens = account.ai_tokens.select { |t| t.id.to_s == id.to_s } - if ai_tokens.empty? - raise Thor::Error, "AI token #{id} not found or access denied" - end + raise Thor::Error, "AI token #{id} not found or access denied" if ai_tokens.empty? ai_tokens.first rescue HyperResource::ClientError => e @@ -22,7 +20,7 @@ def ensure_ai_token(account, id) def create_ai_token(account, opts) ai_token = account.create_ai_token!(opts) - + # Log full HAL response in debug mode if ENV['APTIBLE_DEBUG'] == 'DEBUG' begin @@ -31,11 +29,11 @@ def create_ai_token(account, opts) CLI.logger.warn "POST create response: #{ai_token.body.inspect}" end end - + Formatter.render(Renderer.current) do |root| root.object do |node| ResourceFormatter.inject_ai_token(node, ai_token, account) - + # Include the token value on creation if present token_value = ai_token.attributes['token'] node.value('token', token_value) if token_value @@ -47,9 +45,7 @@ def create_ai_token(account, opts) gateway_url = ai_token.attributes['gateway_url'] if token_value CLI.logger.warn "\nSave the token value now - it will not be shown again!" - if gateway_url - CLI.logger.warn "Use this token to authenticate requests to: #{gateway_url}" - end + CLI.logger.warn "Use this token to authenticate requests to: #{gateway_url}" if gateway_url end ai_token diff --git a/lib/aptible/cli/resource_formatter.rb b/lib/aptible/cli/resource_formatter.rb index 80081ffd..48abc2fc 100644 --- a/lib/aptible/cli/resource_formatter.rb +++ b/lib/aptible/cli/resource_formatter.rb @@ -309,6 +309,58 @@ def inject_ai_token(node, ai_token, account, include_display: false) gateway_url = ai_token.attributes['gateway_url'] rescue nil node.value('gateway_url', gateway_url) if gateway_url + # Include actor tracking info if present (encrypted at rest in LiteLLM, decrypted by deploy-api) + # Show user (on whose behalf) details + created_by_user_id = ai_token.attributes['created_by_user_id'] rescue nil + created_by_actor_id = ai_token.attributes['created_by_actor_id'] rescue nil + + node.value('created_by_user_id', created_by_user_id) if created_by_user_id + + created_by_user_name = ai_token.attributes['created_by_user_name'] rescue nil + node.value('created_by_user_name', created_by_user_name) if created_by_user_name + + created_by_user_email = ai_token.attributes['created_by_user_email'] rescue nil + node.value('created_by_user_email', created_by_user_email) if created_by_user_email + + # Only show actor (who performed) details if different from user (impersonation case) + if created_by_actor_id && created_by_actor_id != created_by_user_id + node.value('created_by_actor_id', created_by_actor_id) + + created_by_actor_name = ai_token.attributes['created_by_actor_name'] rescue nil + node.value('created_by_actor_name', created_by_actor_name) if created_by_actor_name + + created_by_actor_email = ai_token.attributes['created_by_actor_email'] rescue nil + node.value('created_by_actor_email', created_by_actor_email) if created_by_actor_email + end + + # Show revoked_by user details + revoked_by_user_id = ai_token.attributes['revoked_by_user_id'] rescue nil + revoked_by_actor_id = ai_token.attributes['revoked_by_actor_id'] rescue nil + + if revoked_by_user_id + node.value('revoked_by_user_id', revoked_by_user_id) + + revoked_by_user_name = ai_token.attributes['revoked_by_user_name'] rescue nil + node.value('revoked_by_user_name', revoked_by_user_name) if revoked_by_user_name + + revoked_by_user_email = ai_token.attributes['revoked_by_user_email'] rescue nil + node.value('revoked_by_user_email', revoked_by_user_email) if revoked_by_user_email + end + + # Only show revoked_by actor details if different from user (impersonation case) + if revoked_by_actor_id && revoked_by_actor_id != revoked_by_user_id + node.value('revoked_by_actor_id', revoked_by_actor_id) + + revoked_by_actor_name = ai_token.attributes['revoked_by_actor_name'] rescue nil + node.value('revoked_by_actor_name', revoked_by_actor_name) if revoked_by_actor_name + + revoked_by_actor_email = ai_token.attributes['revoked_by_actor_email'] rescue nil + node.value('revoked_by_actor_email', revoked_by_actor_email) if revoked_by_actor_email + end + + revoked_at = ai_token.attributes['revoked_at'] rescue nil + node.value('revoked_at', revoked_at) if revoked_at + attach_account(node, account) if account end diff --git a/lib/aptible/cli/subcommands/ai_tokens.rb b/lib/aptible/cli/subcommands/ai_tokens.rb index d3edd3f3..bdde6a76 100644 --- a/lib/aptible/cli/subcommands/ai_tokens.rb +++ b/lib/aptible/cli/subcommands/ai_tokens.rb @@ -46,7 +46,8 @@ def self.included(thor) begin # Fetch tokens collection tokens = account.ai_tokens - + next unless tokens # Skip if no tokens available + # Log full HAL response in debug mode (single API call returns all tokens) if ENV['APTIBLE_DEBUG'] == 'DEBUG' begin @@ -190,7 +191,7 @@ def self.included(thor) raise Thor::Error, 'Token has already been revoked' end - ai_token.delete + revoked_token = ai_token.delete # Log DELETE response in debug mode if ENV['APTIBLE_DEBUG'] == 'DEBUG' @@ -208,7 +209,27 @@ def self.included(thor) end end - CLI.logger.info 'AI token revoked successfully' + # Render the revoked token (supports JSON output format) + Formatter.render(Renderer.current) do |root| + root.object do |node| + # Get account from token's link if available + account = nil + if revoked_token&.links && revoked_token.links.account + begin + account = Aptible::Api::Account.new( + token: fetch_token + ).find_by_url(revoked_token.links.account.href) + rescue StandardError + # If we can't fetch the account, continue without it + account = nil + end + end + + ResourceFormatter.inject_ai_token(node, revoked_token || ai_token, account) + end + end + + CLI.logger.info "\nAI token revoked successfully" rescue HyperResource::ClientError => e error_message = extract_api_error(e) diff --git a/spec/aptible/cli/subcommands/ai_tokens_spec.rb b/spec/aptible/cli/subcommands/ai_tokens_spec.rb index 191bbf84..0b3afe9f 100644 --- a/spec/aptible/cli/subcommands/ai_tokens_spec.rb +++ b/spec/aptible/cli/subcommands/ai_tokens_spec.rb @@ -48,17 +48,18 @@ allow(subject).to receive(:ensure_environment).and_return(account) end - it 'creates an AI token with a name' do + it 'creates an AI token with a note' do + encoded_note = Base64.urlsafe_encode64('new-token', padding: true) expect(account).to receive(:create_ai_token!) - .with(name: 'new-token').and_return(created_token) + .with(note: encoded_note).and_return(created_token) - subject.options = { name: 'new-token' } + subject.options = { note: 'new-token' } expect { subject.send('ai:tokens:create') }.not_to raise_error expect(captured_logs).to include('Save the token value now') end - it 'creates an AI token without a name' do + it 'creates an AI token without a note' do expect(account).to receive(:create_ai_token!) .with({}).and_return(created_token) @@ -71,15 +72,17 @@ it 'warns user to save token value if present' do token_with_value = Fabricate(:ai_token, name: 'new-token', account: account) allow(token_with_value).to receive(:attributes) - .and_return({ 'token' => 'sk-secret-value' }) + .and_return({ 'token' => 'sk-secret-value', 'gateway_url' => 'https://gateway.example.com' }) + encoded_note = Base64.urlsafe_encode64('new-token', padding: true) expect(account).to receive(:create_ai_token!) - .with(name: 'new-token').and_return(token_with_value) + .with(note: encoded_note).and_return(token_with_value) - subject.options = { name: 'new-token' } + subject.options = { note: 'new-token' } subject.send('ai:tokens:create') expect(captured_logs).to include('Save the token value now - it will not be shown again!') + expect(captured_logs).to include('Use this token to authenticate requests to: https://gateway.example.com') end end @@ -91,8 +94,8 @@ end before do - allow(Aptible::Api::Resource).to receive(:new) - .with(token: token) + allow(Aptible::Api::AiToken).to receive(:new) + .with(root: 'https://app-98582.aptible-test-leeroy.com', token: token) .and_return(ai_token_resource) allow(ai_token_resource).to receive(:href=) end @@ -102,7 +105,7 @@ expect { subject.send('ai:tokens:show', token_id) }.not_to raise_error - expect(ai_token_resource).to have_received(:href=).with("/ai_tokens/#{token_id}") + expect(ai_token_resource).to have_received(:href=).with("https://app-98582.aptible-test-leeroy.com/ai_tokens/#{token_id}") expect(ai_token_resource).to have_received(:get) end @@ -128,37 +131,42 @@ describe '#ai:tokens:revoke' do let(:ai_token_resource) { double('ai_token_resource') } let(:token_id) { 'sk-test-token-12345' } + let(:token_obj) { double('ai_token', blocked: false, delete: nil) } before do - allow(Aptible::Api::Resource).to receive(:new) + allow(Aptible::Api::AiToken).to receive(:new) .with(token: token) .and_return(ai_token_resource) - allow(ai_token_resource).to receive(:href=) end it 'revokes an AI token successfully' do - allow(ai_token_resource).to receive(:delete) + url = "https://app-98582.aptible-test-leeroy.com/ai_tokens/#{token_id}" + allow(ai_token_resource).to receive(:find_by_url).with(url).and_return(token_obj) + allow(token_obj).to receive(:delete) subject.send('ai:tokens:revoke', token_id) - expect(ai_token_resource).to have_received(:href=).with("/ai_tokens/#{token_id}") - expect(ai_token_resource).to have_received(:delete) + expect(ai_token_resource).to have_received(:find_by_url).with(url) + expect(token_obj).to have_received(:delete) expect(captured_logs).to include('AI token revoked successfully') end it 'raises an error if the token is not found (404)' do + url = "https://app-98582.aptible-test-leeroy.com/ai_tokens/nonexistent" error_response = double('error_response', status: 404, body: 'Not Found') error = HyperResource::ClientError.new('Not Found', response: error_response) - allow(ai_token_resource).to receive(:delete).and_raise(error) + allow(ai_token_resource).to receive(:find_by_url).with(url).and_raise(error) expect { subject.send('ai:tokens:revoke', 'nonexistent') } .to raise_error(Thor::Error, /AI token nonexistent not found or access denied/) end it 'raises an error on other client errors' do + url = "https://app-98582.aptible-test-leeroy.com/ai_tokens/#{token_id}" + allow(ai_token_resource).to receive(:find_by_url).with(url).and_return(token_obj) error_response = double('error_response', status: 500, body: 'Internal Server Error') error = HyperResource::ClientError.new('Internal Server Error', response: error_response) - allow(ai_token_resource).to receive(:delete).and_raise(error) + allow(token_obj).to receive(:delete).and_raise(error) expect { subject.send('ai:tokens:revoke', token_id) } .to raise_error(Thor::Error, /Failed to revoke token/) From ff7f88615be8513c7c8da86fbc6b632cf8bafe3d Mon Sep 17 00:00:00 2001 From: Taylor Meek Date: Wed, 17 Dec 2025 08:18:02 -0800 Subject: [PATCH 6/6] sc-35229: LLM Gateway references --- lib/aptible/cli/resource_formatter.rb | 2 +- lib/aptible/cli/subcommands/ai_tokens.rb | 2 +- spec/integration/ai_tokens_integration_spec.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/aptible/cli/resource_formatter.rb b/lib/aptible/cli/resource_formatter.rb index 48abc2fc..2453971d 100644 --- a/lib/aptible/cli/resource_formatter.rb +++ b/lib/aptible/cli/resource_formatter.rb @@ -309,7 +309,7 @@ def inject_ai_token(node, ai_token, account, include_display: false) gateway_url = ai_token.attributes['gateway_url'] rescue nil node.value('gateway_url', gateway_url) if gateway_url - # Include actor tracking info if present (encrypted at rest in LiteLLM, decrypted by deploy-api) + # Include actor tracking info if present (encrypted at rest in LLM Gateway, decrypted by deploy-api) # Show user (on whose behalf) details created_by_user_id = ai_token.attributes['created_by_user_id'] rescue nil created_by_actor_id = ai_token.attributes['created_by_actor_id'] rescue nil diff --git a/lib/aptible/cli/subcommands/ai_tokens.rb b/lib/aptible/cli/subcommands/ai_tokens.rb index bdde6a76..30b692fa 100644 --- a/lib/aptible/cli/subcommands/ai_tokens.rb +++ b/lib/aptible/cli/subcommands/ai_tokens.rb @@ -21,7 +21,7 @@ def self.included(thor) opts = {} if options[:note] # URL-safe base64 encode the note for safe transport to deploy-api - # deploy-api will validate and encrypt it before storing in LiteLLM + # deploy-api will validate and encrypt it before storing in LLM Gateway require 'base64' opts[:note] = Base64.urlsafe_encode64(options[:note], padding: true) end diff --git a/spec/integration/ai_tokens_integration_spec.rb b/spec/integration/ai_tokens_integration_spec.rb index 551cb229..95cb1b3f 100644 --- a/spec/integration/ai_tokens_integration_spec.rb +++ b/spec/integration/ai_tokens_integration_spec.rb @@ -10,7 +10,7 @@ # 1. Running deploy-api at DEPLOY_API_URL (e.g., http://localhost:3000) # 2. Valid Aptible API token in DEPLOY_API_TOKEN # 3. Target environment handle in TEST_ENVIRONMENT (e.g., "test-env") -# 4. LiteLLM configured in deploy-api (or mocked) +# 4. LLM Gateway configured in deploy-api (or mocked) # # Run with: # DEPLOY_API_URL=http://localhost:3000 \