Skip to content

OpenRouter Provider

OpenRouter provides access to multiple AI models through a unified API, with advanced features like fallback models, multimodal support, and PDF processing.

Configuration

Configure OpenRouter in your agent:

ruby
class OpenRouterAgent < ApplicationAgent
  layout "agent"
  generate_with :open_router, model: "qwen/qwen3-30b-a3b:free", instructions: "You're a basic Open Router agent."
end

Features

Structured Output Support

OpenRouter supports structured output for compatible models (like OpenAI's GPT-4o and GPT-4o-mini), allowing you to receive responses in a predefined JSON schema format. This is particularly useful for data extraction tasks.

Compatible Models

Models that support both vision capabilities AND structured output:

  • openai/gpt-4o
  • openai/gpt-4o-mini
  • openai/gpt-4-turbo (structured output only, no vision)
  • openai/gpt-3.5-turbo variants (structured output only, no vision)

Using Structured Output

Define your schema and pass it to the prompt method:

ruby
class OpenRouterAgent < ApplicationAgent
  generate_with :open_router, model: "openai/gpt-4o-mini"

  def analyze_image
    @image_url = params[:image_url]

    prompt(
      message: build_image_message,
      output_schema: image_analysis_schema
    )
  end

  private

  def image_analysis_schema
    {
      name: "image_analysis",
      strict: true,
      schema: {
        type: "object",
        properties: {
          description: { type: "string" },
          objects: {
            type: "array",
            items: {
              type: "object",
              properties: {
                name: { type: "string" },
                position: { type: "string" },
                color: { type: "string" }
              },
              required: ["name", "position", "color"],
              additionalProperties: false
            }
          },
          scene_type: {
            type: "string",
            enum: ["indoor", "outdoor", "abstract", "document", "photo", "illustration"]
          }
        },
        required: ["description", "objects", "scene_type"],
        additionalProperties: false
      }
    }
  end
end

TIP

When using strict: true with OpenAI models, all properties defined in your schema must be included in the required array. This ensures deterministic responses.

For more comprehensive structured output examples, including receipt data extraction and document parsing, see the Data Extraction Agent documentation.

Multimodal Support

OpenRouter supports vision-capable models for image analysis:

ruby
require "test_helper"
require "base64"
require "active_agent/action_prompt/message"

class OpenRouterIntegrationTest < ActiveSupport::TestCase
  setup do
    @agent = OpenRouterIntegrationAgent.new
  end

  test "analyzes image with structured output schema" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_image_analysis_structured") do
      # Use the sales chart image URL for structured analysis
      image_url = "https://raw.githubusercontent.com/activeagents/activeagent/refs/heads/main/test/fixtures/images/sales_chart.png"

      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      # Verify the structure matches our schema
      assert result.key?("description")
      assert result.key?("objects")
      assert result.key?("scene_type")
      assert result.key?("primary_colors")
      assert result["objects"].is_a?(Array)
      assert [ "indoor", "outdoor", "abstract", "document", "photo", "illustration" ].include?(result["scene_type"])
    end
  end

  test "analyzes remote image URL without structured output" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_remote_image_basic") do
      # Use a landscape image URL for basic analysis
      image_url = "https://picsum.photos/400/300"

      # For now, just use analyze_image without the structured output schema
      # We'll get a natural language description instead of JSON
      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.is_a?(String)
      assert response.message.content.length > 10
      # Since analyze_image uses structured output, we'll get JSON
      # Just verify we got a response
      # In the future, we could add a simple_analyze action without schema

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "extracts receipt data with structured output from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_receipt_extraction_local") do
      # Use the test receipt image - file exists, no conditional needed
      receipt_path = Rails.root.join("..", "..", "test", "fixtures", "images", "test_receipt.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: receipt_path).extract_receipt_data
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["merchant"]["name"], "Corner Mart"
      assert_equal result["total"]["amount"], 14.83
      assert_equal result["items"].size, 4
      result["items"].each do |item|
        assert item.key?("name")
        assert item.key?("quantity")
        assert item.key?("price")
      end
      assert_equal result["items"][0], { "name"=>"Milk", "quantity"=>1, "price"=>3.49 }
      assert_equal result["items"][1], { "name"=>"Bread", "quantity"=>1, "price"=>2.29 }
      assert_equal result["items"][2], { "name"=>"Apples", "quantity"=>1, "price"=>5.1 }
      assert_equal result["items"][3], { "name"=>"Eggs", "quantity"=>1, "price"=>2.99 }
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "handles base64 encoded images with sales chart" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_base64_sales_chart") do
      # Use the sales chart image
      chart_path = Rails.root.join("..", "..", "test", "fixtures", "images", "sales_chart.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: chart_path).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert_includes response.message.content, "(Q1, Q2, Q3, Q4), with varying heights indicating different sales amounts"

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes PDF document from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_local") do
      # Use the sample resume PDF
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")

      # Read and encode the PDF as base64 - OpenRouter accepts PDFs as image_url with data URL
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Extract information from this document and return as JSON",
        output_schema: :resume_schema
      ).analyze_pdf
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"].first, { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 }
      assert_equal result["experience"].first, { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" }

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_processing_local

  test "processes PDF from remote URL of resume no plugins" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_remote_no_plugin") do
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze the PDF",
        output_schema: :resume_schema,
        skip_plugin: true
      ).analyze_pdf

      # Remote URLs are not supported without a PDF engine plugin
      # OpenAI: Inputs by file URL are not supported for chat completions. Use the ResponsesAPI for this option.
      # https://platform.openai.com/docs/guides/pdf-files#file-urls
      # Accept either the OpenAI error directly or our wrapped error
      # Suppress ruby-openai gem's error output to STDERR
      error = assert_raises(ActiveAgent::GenerationProvider::Base::GenerationProviderError, OpenAI::Error) do
        prompt.generate_now
      end

      # Check the error message regardless of which error type was raised
      error_message = error.message
      assert_match(/Missing required parameter.*file_id/, error_message)
      assert_match(/Provider returned error|invalid_request_error/, error_message)
    end
  end

  # region pdf_native_support
  test "processes PDF with native model support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_native") do
      # Test with a model that might have native PDF support
      # Using the native engine (charged as input tokens)
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Analyze this PDF document",
        pdf_engine: "native"  # Use native engine (charged as input tokens)
      ).analyze_pdf

      # First verify the prompt has the plugins in options
      assert prompt.options[:plugins].present?, "Plugins should be present in prompt options"
      assert prompt.options[:fallback_models].present?, "Fallback models should be present in prompt options"
      assert_equal "file-parser", prompt.options[:plugins][0][:id]
      assert_equal "native", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      assert_includes response.message.content, "John Doe"

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_native_support

  test "processes PDF without any plugin for models with built-in support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_no_plugin") do
      # Test without any plugin - for models that have built-in PDF support
      pdf_url = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze this PDF document",
        skip_plugin: true  # Don't use any plugin
      ).analyze_pdf

      # Verify no plugins are included when skip_plugin is true
      assert_empty prompt.options[:plugins], "Should not have plugins when skip_plugin is true"

      response = prompt.generate_now
      raw_response = response.raw_response
      assert_equal "Google", raw_response["provider"]
      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes scanned PDF with OCR engine" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_ocr") do
      # Test with the mistral-ocr engine for scanned documents
      # Using a simple PDF that should be processable
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Extract text from this PDF.",
        output_schema: :resume_schema,
        pdf_engine: "mistral-ocr"  # OCR engine for text extraction
      ).analyze_pdf

      # Verify OCR engine is specified
      assert prompt.options[:plugins].present?, "Should have plugins for OCR"
      assert_equal "mistral-ocr", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      # MUST return valid JSON - no fallback allowed
      raw_response = response.raw_response
      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"], [ { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 } ]
      assert_equal result["experience"], [ { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" } ]

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "uses fallback models when primary fails" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_fallback_models") do
      prompt = OpenRouterIntegrationAgent.test_fallback
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # Check metadata for fallback usage
      if response.respond_to?(:metadata) && response.metadata
        # Should use one of the fallback models, not the primary
        possible_models = [ "openai/gpt-3.5-turbo-0301", "openai/gpt-3.5-turbo", "openai/gpt-4o-mini" ]
        assert possible_models.include?(response.metadata[:model_used])
        assert response.metadata[:provider].present?
      end

      # The response should still work (2+2=4)
      assert response.message.content.include?("4")

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "applies transforms for long content" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_transforms") do
      # Generate a very long text
      long_text = "Lorem ipsum dolor sit amet. " * 1000

      prompt = OpenRouterIntegrationAgent.with(text: long_text).process_long_text
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # The summary should be much shorter than the original
      assert response.message.content.length < long_text.length / 10

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "tracks usage and costs" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_cost_tracking") do
      prompt = OpenRouterIntegrationAgent.with(message: "Hello").prompt_context
      response = prompt.generate_now

      assert_not_nil response

      # Check for usage information
      if response.respond_to?(:usage) && response.usage
        assert response.usage["prompt_tokens"].is_a?(Integer)
        assert response.usage["completion_tokens"].is_a?(Integer)
        assert response.usage["total_tokens"].is_a?(Integer)
      end

      # Check for metadata with model information from OpenRouter
      if response.respond_to?(:metadata) && response.metadata
        assert response.metadata[:model_used].present?
        assert response.metadata[:provider].present?
        # Verify we're using the expected model (gpt-4o-mini)
        assert_equal "openai/gpt-4o-mini", response.metadata[:model_used]
      end

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "includes OpenRouter headers in requests" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "app_name" => "TestApp",
      "site_url" => "https://test.example.com"
    )

    # Get the headers that would be sent
    headers = provider.send(:openrouter_headers)

    assert_equal "https://test.example.com", headers["HTTP-Referer"]
    assert_equal "TestApp", headers["X-Title"]
  end

  test "builds provider preferences correctly" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "enable_fallbacks" => true,
      "provider" => {
        "order" => [ "OpenAI", "Anthropic" ],
        "require_parameters" => true,
        "data_collection" => "deny"
      }
    )

    prefs = provider.send(:build_provider_preferences)

    assert_equal [ "OpenAI", "Anthropic" ], prefs[:order]
    assert_equal true, prefs[:require_parameters]
    assert_equal true, prefs[:allow_fallbacks]
    assert_equal "deny", prefs[:data_collection]
  end

  test "configures data collection policies" do
    # Test deny all data collection
    provider_deny = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => "deny"
    )
    prefs_deny = provider_deny.send(:build_provider_preferences)
    assert_equal "deny", prefs_deny[:data_collection]

    # Test allow all data collection (default)
    provider_allow = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )
    prefs_allow = provider_allow.send(:build_provider_preferences)
    assert_equal "allow", prefs_allow[:data_collection]

    # Test selective provider data collection
    provider_selective = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => [ "OpenAI", "Google" ]
    )
    prefs_selective = provider_selective.send(:build_provider_preferences)
    assert_equal [ "OpenAI", "Google" ], prefs_selective[:data_collection]
  end

  test "handles multimodal content correctly" do
    # Create a message with multimodal content
    message = ActiveAgent::ActionPrompt::Message.new(
      content: [
        { type: "text", text: "What's in this image?" },
        { type: "image_url", image_url: { url: "https://example.com/image.jpg" } }
      ],
      role: :user
    )

    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      messages: [ message ]
    )

    assert prompt.multimodal?
  end

  test "converts file type to image_url for OpenRouter PDF support" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Test file type conversion
    file_item = {
      type: "file",
      file: {
        file_data: "data:application/pdf;base64,JVBERi0xLj..."
      }
    }

    formatted = provider.send(:format_content_item, file_item)

    assert_equal "image_url", formatted[:type]
    assert_equal "data:application/pdf;base64,JVBERi0xLj...", formatted[:image_url][:url]
  end

  test "respects configuration hierarchy for site_url" do
    # Test with explicit site_url config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "site_url" => "https://configured.example.com"
    )

    assert_equal "https://configured.example.com", provider.instance_variable_get(:@site_url)

    # Test with default_url_options in config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "default_url_options" => {
        "host" => "fromconfig.example.com"
      }
    )

    assert_equal "fromconfig.example.com", provider.instance_variable_get(:@site_url)
  end

  test "handles rate limit information in metadata" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a mock response
    prompt = ActiveAgent::ActionPrompt::Prompt.new(message: "test")
    response = ActiveAgent::GenerationProvider::Response.new(prompt: prompt)

    headers = {
      "x-provider" => "OpenAI",
      "x-model" => "gpt-4o",
      "x-ratelimit-requests-limit" => "100",
      "x-ratelimit-requests-remaining" => "99",
      "x-ratelimit-tokens-limit" => "10000",
      "x-ratelimit-tokens-remaining" => "9500"
    }

    provider.send(:add_openrouter_metadata, response, headers)

    assert_equal "100", response.metadata[:ratelimit][:requests_limit]
    assert_equal "99", response.metadata[:ratelimit][:requests_remaining]
    assert_equal "10000", response.metadata[:ratelimit][:tokens_limit]
    assert_equal "9500", response.metadata[:ratelimit][:tokens_remaining]
  end

  test "includes plugins parameter when passed in options" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a prompt with plugins option
    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      message: "test",
      options: {
        plugins: [
          {
            id: "file-parser",
            pdf: {
              engine: "pdf-text"
            }
          }
        ]
      }
    )

    # Set the prompt on the provider
    provider.instance_variable_set(:@prompt, prompt)

    # Build parameters and verify plugins are included
    parameters = provider.send(:build_openrouter_parameters)

    assert_not_nil parameters[:plugins]
    assert_equal 1, parameters[:plugins].size
    assert_equal "file-parser", parameters[:plugins][0][:id]
    assert_equal "pdf-text", parameters[:plugins][0][:pdf][:engine]
  end
end
Image Analysis with Structured Output

activeagent/test/agents/open_router_integration_test.rb:57

ruby
# Response object
#<ActiveAgent::GenerationProvider::Response:0x3d68
  @message=#<ActiveAgent::ActionPrompt::Message:0x3d7c
    @action_id=nil,
    @action_name=nil,
    @action_requested=false,
    @charset="UTF-8",
    @content="```json\n{\n  \"description\": \"A scenic view of the Golden Gate Bridge partially obscured by fog, spanning across the water. The bridge is seen from an elevated perspective, showcasing its iconic towers and cables. A sailboat is visible on the water below.\",\n...",
    @role=:assistant>
  @prompt=#<ActiveAgent::ActionPrompt::Prompt:0x3d90 ...>
  @content_type="application/json"
  @raw_response={...}>

# Message content
response.message.content # => "```json\n{\n  \"description\": \"A scenic view of the Golden Gate Bridge partially obscured by fog, spanning across the water. The bridge is seen from an elevated perspective, showcasing its iconic towers and cables. A sailboat is visible on the water below.\",\n  \"objects\": [\n    \"Golden Gate Bridge\",\n    \"water\",\n    \"fog\",\n    \"sailboat\"\n  ],\n  \"scene_type\": \"landscape\",\n  \"primary_colors\": [\n    \"blue\",\n    \"gray\",\n    \"white\",\n    \"brown\"\n  ]\n}\n```"

Receipt Data Extraction with Structured Output

Extract structured data from receipts and documents using OpenRouter's structured output capabilities. This example demonstrates how to parse receipt images and extract specific fields like merchant information, items, and totals.

Test Implementation

ruby
require "test_helper"
require "base64"
require "active_agent/action_prompt/message"

class OpenRouterIntegrationTest < ActiveSupport::TestCase
  setup do
    @agent = OpenRouterIntegrationAgent.new
  end

  test "analyzes image with structured output schema" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_image_analysis_structured") do
      # Use the sales chart image URL for structured analysis
      image_url = "https://raw.githubusercontent.com/activeagents/activeagent/refs/heads/main/test/fixtures/images/sales_chart.png"

      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      # Verify the structure matches our schema
      assert result.key?("description")
      assert result.key?("objects")
      assert result.key?("scene_type")
      assert result.key?("primary_colors")
      assert result["objects"].is_a?(Array)
      assert [ "indoor", "outdoor", "abstract", "document", "photo", "illustration" ].include?(result["scene_type"])
    end
  end

  test "analyzes remote image URL without structured output" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_remote_image_basic") do
      # Use a landscape image URL for basic analysis
      image_url = "https://picsum.photos/400/300"

      # For now, just use analyze_image without the structured output schema
      # We'll get a natural language description instead of JSON
      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.is_a?(String)
      assert response.message.content.length > 10
      # Since analyze_image uses structured output, we'll get JSON
      # Just verify we got a response
      # In the future, we could add a simple_analyze action without schema

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "extracts receipt data with structured output from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_receipt_extraction_local") do
      # Use the test receipt image - file exists, no conditional needed
      receipt_path = Rails.root.join("..", "..", "test", "fixtures", "images", "test_receipt.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: receipt_path).extract_receipt_data
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["merchant"]["name"], "Corner Mart"
      assert_equal result["total"]["amount"], 14.83
      assert_equal result["items"].size, 4
      result["items"].each do |item|
        assert item.key?("name")
        assert item.key?("quantity")
        assert item.key?("price")
      end
      assert_equal result["items"][0], { "name"=>"Milk", "quantity"=>1, "price"=>3.49 }
      assert_equal result["items"][1], { "name"=>"Bread", "quantity"=>1, "price"=>2.29 }
      assert_equal result["items"][2], { "name"=>"Apples", "quantity"=>1, "price"=>5.1 }
      assert_equal result["items"][3], { "name"=>"Eggs", "quantity"=>1, "price"=>2.99 }
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "handles base64 encoded images with sales chart" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_base64_sales_chart") do
      # Use the sales chart image
      chart_path = Rails.root.join("..", "..", "test", "fixtures", "images", "sales_chart.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: chart_path).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert_includes response.message.content, "(Q1, Q2, Q3, Q4), with varying heights indicating different sales amounts"

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes PDF document from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_local") do
      # Use the sample resume PDF
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")

      # Read and encode the PDF as base64 - OpenRouter accepts PDFs as image_url with data URL
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Extract information from this document and return as JSON",
        output_schema: :resume_schema
      ).analyze_pdf
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"].first, { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 }
      assert_equal result["experience"].first, { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" }

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_processing_local

  test "processes PDF from remote URL of resume no plugins" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_remote_no_plugin") do
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze the PDF",
        output_schema: :resume_schema,
        skip_plugin: true
      ).analyze_pdf

      # Remote URLs are not supported without a PDF engine plugin
      # OpenAI: Inputs by file URL are not supported for chat completions. Use the ResponsesAPI for this option.
      # https://platform.openai.com/docs/guides/pdf-files#file-urls
      # Accept either the OpenAI error directly or our wrapped error
      # Suppress ruby-openai gem's error output to STDERR
      error = assert_raises(ActiveAgent::GenerationProvider::Base::GenerationProviderError, OpenAI::Error) do
        prompt.generate_now
      end

      # Check the error message regardless of which error type was raised
      error_message = error.message
      assert_match(/Missing required parameter.*file_id/, error_message)
      assert_match(/Provider returned error|invalid_request_error/, error_message)
    end
  end

  # region pdf_native_support
  test "processes PDF with native model support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_native") do
      # Test with a model that might have native PDF support
      # Using the native engine (charged as input tokens)
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Analyze this PDF document",
        pdf_engine: "native"  # Use native engine (charged as input tokens)
      ).analyze_pdf

      # First verify the prompt has the plugins in options
      assert prompt.options[:plugins].present?, "Plugins should be present in prompt options"
      assert prompt.options[:fallback_models].present?, "Fallback models should be present in prompt options"
      assert_equal "file-parser", prompt.options[:plugins][0][:id]
      assert_equal "native", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      assert_includes response.message.content, "John Doe"

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_native_support

  test "processes PDF without any plugin for models with built-in support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_no_plugin") do
      # Test without any plugin - for models that have built-in PDF support
      pdf_url = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze this PDF document",
        skip_plugin: true  # Don't use any plugin
      ).analyze_pdf

      # Verify no plugins are included when skip_plugin is true
      assert_empty prompt.options[:plugins], "Should not have plugins when skip_plugin is true"

      response = prompt.generate_now
      raw_response = response.raw_response
      assert_equal "Google", raw_response["provider"]
      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes scanned PDF with OCR engine" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_ocr") do
      # Test with the mistral-ocr engine for scanned documents
      # Using a simple PDF that should be processable
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Extract text from this PDF.",
        output_schema: :resume_schema,
        pdf_engine: "mistral-ocr"  # OCR engine for text extraction
      ).analyze_pdf

      # Verify OCR engine is specified
      assert prompt.options[:plugins].present?, "Should have plugins for OCR"
      assert_equal "mistral-ocr", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      # MUST return valid JSON - no fallback allowed
      raw_response = response.raw_response
      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"], [ { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 } ]
      assert_equal result["experience"], [ { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" } ]

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "uses fallback models when primary fails" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_fallback_models") do
      prompt = OpenRouterIntegrationAgent.test_fallback
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # Check metadata for fallback usage
      if response.respond_to?(:metadata) && response.metadata
        # Should use one of the fallback models, not the primary
        possible_models = [ "openai/gpt-3.5-turbo-0301", "openai/gpt-3.5-turbo", "openai/gpt-4o-mini" ]
        assert possible_models.include?(response.metadata[:model_used])
        assert response.metadata[:provider].present?
      end

      # The response should still work (2+2=4)
      assert response.message.content.include?("4")

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "applies transforms for long content" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_transforms") do
      # Generate a very long text
      long_text = "Lorem ipsum dolor sit amet. " * 1000

      prompt = OpenRouterIntegrationAgent.with(text: long_text).process_long_text
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # The summary should be much shorter than the original
      assert response.message.content.length < long_text.length / 10

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "tracks usage and costs" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_cost_tracking") do
      prompt = OpenRouterIntegrationAgent.with(message: "Hello").prompt_context
      response = prompt.generate_now

      assert_not_nil response

      # Check for usage information
      if response.respond_to?(:usage) && response.usage
        assert response.usage["prompt_tokens"].is_a?(Integer)
        assert response.usage["completion_tokens"].is_a?(Integer)
        assert response.usage["total_tokens"].is_a?(Integer)
      end

      # Check for metadata with model information from OpenRouter
      if response.respond_to?(:metadata) && response.metadata
        assert response.metadata[:model_used].present?
        assert response.metadata[:provider].present?
        # Verify we're using the expected model (gpt-4o-mini)
        assert_equal "openai/gpt-4o-mini", response.metadata[:model_used]
      end

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "includes OpenRouter headers in requests" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "app_name" => "TestApp",
      "site_url" => "https://test.example.com"
    )

    # Get the headers that would be sent
    headers = provider.send(:openrouter_headers)

    assert_equal "https://test.example.com", headers["HTTP-Referer"]
    assert_equal "TestApp", headers["X-Title"]
  end

  test "builds provider preferences correctly" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "enable_fallbacks" => true,
      "provider" => {
        "order" => [ "OpenAI", "Anthropic" ],
        "require_parameters" => true,
        "data_collection" => "deny"
      }
    )

    prefs = provider.send(:build_provider_preferences)

    assert_equal [ "OpenAI", "Anthropic" ], prefs[:order]
    assert_equal true, prefs[:require_parameters]
    assert_equal true, prefs[:allow_fallbacks]
    assert_equal "deny", prefs[:data_collection]
  end

  test "configures data collection policies" do
    # Test deny all data collection
    provider_deny = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => "deny"
    )
    prefs_deny = provider_deny.send(:build_provider_preferences)
    assert_equal "deny", prefs_deny[:data_collection]

    # Test allow all data collection (default)
    provider_allow = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )
    prefs_allow = provider_allow.send(:build_provider_preferences)
    assert_equal "allow", prefs_allow[:data_collection]

    # Test selective provider data collection
    provider_selective = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => [ "OpenAI", "Google" ]
    )
    prefs_selective = provider_selective.send(:build_provider_preferences)
    assert_equal [ "OpenAI", "Google" ], prefs_selective[:data_collection]
  end

  test "handles multimodal content correctly" do
    # Create a message with multimodal content
    message = ActiveAgent::ActionPrompt::Message.new(
      content: [
        { type: "text", text: "What's in this image?" },
        { type: "image_url", image_url: { url: "https://example.com/image.jpg" } }
      ],
      role: :user
    )

    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      messages: [ message ]
    )

    assert prompt.multimodal?
  end

  test "converts file type to image_url for OpenRouter PDF support" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Test file type conversion
    file_item = {
      type: "file",
      file: {
        file_data: "data:application/pdf;base64,JVBERi0xLj..."
      }
    }

    formatted = provider.send(:format_content_item, file_item)

    assert_equal "image_url", formatted[:type]
    assert_equal "data:application/pdf;base64,JVBERi0xLj...", formatted[:image_url][:url]
  end

  test "respects configuration hierarchy for site_url" do
    # Test with explicit site_url config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "site_url" => "https://configured.example.com"
    )

    assert_equal "https://configured.example.com", provider.instance_variable_get(:@site_url)

    # Test with default_url_options in config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "default_url_options" => {
        "host" => "fromconfig.example.com"
      }
    )

    assert_equal "fromconfig.example.com", provider.instance_variable_get(:@site_url)
  end

  test "handles rate limit information in metadata" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a mock response
    prompt = ActiveAgent::ActionPrompt::Prompt.new(message: "test")
    response = ActiveAgent::GenerationProvider::Response.new(prompt: prompt)

    headers = {
      "x-provider" => "OpenAI",
      "x-model" => "gpt-4o",
      "x-ratelimit-requests-limit" => "100",
      "x-ratelimit-requests-remaining" => "99",
      "x-ratelimit-tokens-limit" => "10000",
      "x-ratelimit-tokens-remaining" => "9500"
    }

    provider.send(:add_openrouter_metadata, response, headers)

    assert_equal "100", response.metadata[:ratelimit][:requests_limit]
    assert_equal "99", response.metadata[:ratelimit][:requests_remaining]
    assert_equal "10000", response.metadata[:ratelimit][:tokens_limit]
    assert_equal "9500", response.metadata[:ratelimit][:tokens_remaining]
  end

  test "includes plugins parameter when passed in options" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a prompt with plugins option
    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      message: "test",
      options: {
        plugins: [
          {
            id: "file-parser",
            pdf: {
              engine: "pdf-text"
            }
          }
        ]
      }
    )

    # Set the prompt on the provider
    provider.instance_variable_set(:@prompt, prompt)

    # Build parameters and verify plugins are included
    parameters = provider.send(:build_openrouter_parameters)

    assert_not_nil parameters[:plugins]
    assert_equal 1, parameters[:plugins].size
    assert_equal "file-parser", parameters[:plugins][0][:id]
    assert_equal "pdf-text", parameters[:plugins][0][:pdf][:engine]
  end
end

Receipt Schema Definition

ruby
def receipt_schema
  {
    name: "receipt_data",
    strict: true,
    schema: {
      type: "object",
      properties: {
        merchant: {
          type: "object",
          properties: {
            name: { type: "string" },
            address: { type: "string" }
          },
          required: [ "name", "address" ],
          additionalProperties: false
        },
        date: { type: "string" },
        total: {
          type: "object",
          properties: {
            amount: { type: "number" },
            currency: { type: "string" }
          },
          required: [ "amount", "currency" ],
          additionalProperties: false
        },
        items: {
          type: "array",
          items: {
            type: "object",
            properties: {
              name: { type: "string" },
              quantity: { type: "integer" },
              price: { type: "number" }
            },
            required: [ "name", "price", "quantity" ],
            additionalProperties: false
          }
        },
        tax: { type: "number" },
        subtotal: { type: "number" }
      },
      required: [ "merchant", "total", "items", "date", "tax", "subtotal" ],
      additionalProperties: false
    }
  }
end

The receipt schema ensures consistent extraction of:

  • Merchant name and address
  • Individual line items with names and prices
  • Subtotal, tax, and total amounts
  • Currency information
Receipt Extraction Example Output

activeagent/test/agents/open_router_integration_test.rb:90

ruby
# Response object
#<ActiveAgent::GenerationProvider::Response:0x3d18
  @message=#<ActiveAgent::ActionPrompt::Message:0x3d2c
    @action_id=nil,
    @action_name=nil,
    @action_requested=false,
    @charset="UTF-8",
    @content={"merchant"=>{"name"=>"Corner Mart", "address"=>"123 Main St.\nCity, State 12345"}, "date"=>"", "total"=>{"amount"=>14.83, "currency"=>"USD"}, "items"=>[{"name"=>"Milk", "quantity"=>1, "price"=>3.49}, {"name"=>"Bread", "quantity"=>1, "price"=>2.29}, {"name"=>"Apples", "quantity"=>1, "price"=>5.1}, {"name"=>"Eggs", "quantity"=>1, "price"=>2.99}], "tax"=>0.96, "subtotal"=>13.87},
    @role=:assistant>
  @prompt=#<ActiveAgent::ActionPrompt::Prompt:0x3d40 ...>
  @content_type="application/json"
  @raw_response={...}>

# Message content
response.message.content # => {"merchant"=>{"name"=>"Corner Mart", "address"=>"123 Main St.\nCity, State 12345"}, "date"=>"", "total"=>{"amount"=>14.83, "currency"=>"USD"}, "items"=>[{"name"=>"Milk", "quantity"=>1, "price"=>3.49}, {"name"=>"Bread", "quantity"=>1, "price"=>2.29}, {"name"=>"Apples", "quantity"=>1, "price"=>5.1}, {"name"=>"Eggs", "quantity"=>1, "price"=>2.99}], "tax"=>0.96, "subtotal"=>13.87}

TIP

This example uses structured output to ensure the receipt data is returned in a consistent JSON format. For more examples of structured data extraction from various document types, see the Data Extraction Agent documentation.

PDF Processing

OpenRouter supports PDF processing with various engines:

ruby
require "test_helper"
require "base64"
require "active_agent/action_prompt/message"

class OpenRouterIntegrationTest < ActiveSupport::TestCase
  setup do
    @agent = OpenRouterIntegrationAgent.new
  end

  test "analyzes image with structured output schema" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_image_analysis_structured") do
      # Use the sales chart image URL for structured analysis
      image_url = "https://raw.githubusercontent.com/activeagents/activeagent/refs/heads/main/test/fixtures/images/sales_chart.png"

      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      # Verify the structure matches our schema
      assert result.key?("description")
      assert result.key?("objects")
      assert result.key?("scene_type")
      assert result.key?("primary_colors")
      assert result["objects"].is_a?(Array)
      assert [ "indoor", "outdoor", "abstract", "document", "photo", "illustration" ].include?(result["scene_type"])
    end
  end

  test "analyzes remote image URL without structured output" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_remote_image_basic") do
      # Use a landscape image URL for basic analysis
      image_url = "https://picsum.photos/400/300"

      # For now, just use analyze_image without the structured output schema
      # We'll get a natural language description instead of JSON
      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.is_a?(String)
      assert response.message.content.length > 10
      # Since analyze_image uses structured output, we'll get JSON
      # Just verify we got a response
      # In the future, we could add a simple_analyze action without schema

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "extracts receipt data with structured output from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_receipt_extraction_local") do
      # Use the test receipt image - file exists, no conditional needed
      receipt_path = Rails.root.join("..", "..", "test", "fixtures", "images", "test_receipt.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: receipt_path).extract_receipt_data
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["merchant"]["name"], "Corner Mart"
      assert_equal result["total"]["amount"], 14.83
      assert_equal result["items"].size, 4
      result["items"].each do |item|
        assert item.key?("name")
        assert item.key?("quantity")
        assert item.key?("price")
      end
      assert_equal result["items"][0], { "name"=>"Milk", "quantity"=>1, "price"=>3.49 }
      assert_equal result["items"][1], { "name"=>"Bread", "quantity"=>1, "price"=>2.29 }
      assert_equal result["items"][2], { "name"=>"Apples", "quantity"=>1, "price"=>5.1 }
      assert_equal result["items"][3], { "name"=>"Eggs", "quantity"=>1, "price"=>2.99 }
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "handles base64 encoded images with sales chart" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_base64_sales_chart") do
      # Use the sales chart image
      chart_path = Rails.root.join("..", "..", "test", "fixtures", "images", "sales_chart.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: chart_path).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert_includes response.message.content, "(Q1, Q2, Q3, Q4), with varying heights indicating different sales amounts"

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes PDF document from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_local") do
      # Use the sample resume PDF
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")

      # Read and encode the PDF as base64 - OpenRouter accepts PDFs as image_url with data URL
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Extract information from this document and return as JSON",
        output_schema: :resume_schema
      ).analyze_pdf
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"].first, { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 }
      assert_equal result["experience"].first, { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" }

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_processing_local

  test "processes PDF from remote URL of resume no plugins" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_remote_no_plugin") do
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze the PDF",
        output_schema: :resume_schema,
        skip_plugin: true
      ).analyze_pdf

      # Remote URLs are not supported without a PDF engine plugin
      # OpenAI: Inputs by file URL are not supported for chat completions. Use the ResponsesAPI for this option.
      # https://platform.openai.com/docs/guides/pdf-files#file-urls
      # Accept either the OpenAI error directly or our wrapped error
      # Suppress ruby-openai gem's error output to STDERR
      error = assert_raises(ActiveAgent::GenerationProvider::Base::GenerationProviderError, OpenAI::Error) do
        prompt.generate_now
      end

      # Check the error message regardless of which error type was raised
      error_message = error.message
      assert_match(/Missing required parameter.*file_id/, error_message)
      assert_match(/Provider returned error|invalid_request_error/, error_message)
    end
  end

  # region pdf_native_support
  test "processes PDF with native model support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_native") do
      # Test with a model that might have native PDF support
      # Using the native engine (charged as input tokens)
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Analyze this PDF document",
        pdf_engine: "native"  # Use native engine (charged as input tokens)
      ).analyze_pdf

      # First verify the prompt has the plugins in options
      assert prompt.options[:plugins].present?, "Plugins should be present in prompt options"
      assert prompt.options[:fallback_models].present?, "Fallback models should be present in prompt options"
      assert_equal "file-parser", prompt.options[:plugins][0][:id]
      assert_equal "native", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      assert_includes response.message.content, "John Doe"

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_native_support

  test "processes PDF without any plugin for models with built-in support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_no_plugin") do
      # Test without any plugin - for models that have built-in PDF support
      pdf_url = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze this PDF document",
        skip_plugin: true  # Don't use any plugin
      ).analyze_pdf

      # Verify no plugins are included when skip_plugin is true
      assert_empty prompt.options[:plugins], "Should not have plugins when skip_plugin is true"

      response = prompt.generate_now
      raw_response = response.raw_response
      assert_equal "Google", raw_response["provider"]
      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes scanned PDF with OCR engine" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_ocr") do
      # Test with the mistral-ocr engine for scanned documents
      # Using a simple PDF that should be processable
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Extract text from this PDF.",
        output_schema: :resume_schema,
        pdf_engine: "mistral-ocr"  # OCR engine for text extraction
      ).analyze_pdf

      # Verify OCR engine is specified
      assert prompt.options[:plugins].present?, "Should have plugins for OCR"
      assert_equal "mistral-ocr", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      # MUST return valid JSON - no fallback allowed
      raw_response = response.raw_response
      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"], [ { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 } ]
      assert_equal result["experience"], [ { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" } ]

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "uses fallback models when primary fails" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_fallback_models") do
      prompt = OpenRouterIntegrationAgent.test_fallback
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # Check metadata for fallback usage
      if response.respond_to?(:metadata) && response.metadata
        # Should use one of the fallback models, not the primary
        possible_models = [ "openai/gpt-3.5-turbo-0301", "openai/gpt-3.5-turbo", "openai/gpt-4o-mini" ]
        assert possible_models.include?(response.metadata[:model_used])
        assert response.metadata[:provider].present?
      end

      # The response should still work (2+2=4)
      assert response.message.content.include?("4")

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "applies transforms for long content" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_transforms") do
      # Generate a very long text
      long_text = "Lorem ipsum dolor sit amet. " * 1000

      prompt = OpenRouterIntegrationAgent.with(text: long_text).process_long_text
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # The summary should be much shorter than the original
      assert response.message.content.length < long_text.length / 10

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "tracks usage and costs" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_cost_tracking") do
      prompt = OpenRouterIntegrationAgent.with(message: "Hello").prompt_context
      response = prompt.generate_now

      assert_not_nil response

      # Check for usage information
      if response.respond_to?(:usage) && response.usage
        assert response.usage["prompt_tokens"].is_a?(Integer)
        assert response.usage["completion_tokens"].is_a?(Integer)
        assert response.usage["total_tokens"].is_a?(Integer)
      end

      # Check for metadata with model information from OpenRouter
      if response.respond_to?(:metadata) && response.metadata
        assert response.metadata[:model_used].present?
        assert response.metadata[:provider].present?
        # Verify we're using the expected model (gpt-4o-mini)
        assert_equal "openai/gpt-4o-mini", response.metadata[:model_used]
      end

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "includes OpenRouter headers in requests" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "app_name" => "TestApp",
      "site_url" => "https://test.example.com"
    )

    # Get the headers that would be sent
    headers = provider.send(:openrouter_headers)

    assert_equal "https://test.example.com", headers["HTTP-Referer"]
    assert_equal "TestApp", headers["X-Title"]
  end

  test "builds provider preferences correctly" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "enable_fallbacks" => true,
      "provider" => {
        "order" => [ "OpenAI", "Anthropic" ],
        "require_parameters" => true,
        "data_collection" => "deny"
      }
    )

    prefs = provider.send(:build_provider_preferences)

    assert_equal [ "OpenAI", "Anthropic" ], prefs[:order]
    assert_equal true, prefs[:require_parameters]
    assert_equal true, prefs[:allow_fallbacks]
    assert_equal "deny", prefs[:data_collection]
  end

  test "configures data collection policies" do
    # Test deny all data collection
    provider_deny = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => "deny"
    )
    prefs_deny = provider_deny.send(:build_provider_preferences)
    assert_equal "deny", prefs_deny[:data_collection]

    # Test allow all data collection (default)
    provider_allow = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )
    prefs_allow = provider_allow.send(:build_provider_preferences)
    assert_equal "allow", prefs_allow[:data_collection]

    # Test selective provider data collection
    provider_selective = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => [ "OpenAI", "Google" ]
    )
    prefs_selective = provider_selective.send(:build_provider_preferences)
    assert_equal [ "OpenAI", "Google" ], prefs_selective[:data_collection]
  end

  test "handles multimodal content correctly" do
    # Create a message with multimodal content
    message = ActiveAgent::ActionPrompt::Message.new(
      content: [
        { type: "text", text: "What's in this image?" },
        { type: "image_url", image_url: { url: "https://example.com/image.jpg" } }
      ],
      role: :user
    )

    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      messages: [ message ]
    )

    assert prompt.multimodal?
  end

  test "converts file type to image_url for OpenRouter PDF support" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Test file type conversion
    file_item = {
      type: "file",
      file: {
        file_data: "data:application/pdf;base64,JVBERi0xLj..."
      }
    }

    formatted = provider.send(:format_content_item, file_item)

    assert_equal "image_url", formatted[:type]
    assert_equal "data:application/pdf;base64,JVBERi0xLj...", formatted[:image_url][:url]
  end

  test "respects configuration hierarchy for site_url" do
    # Test with explicit site_url config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "site_url" => "https://configured.example.com"
    )

    assert_equal "https://configured.example.com", provider.instance_variable_get(:@site_url)

    # Test with default_url_options in config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "default_url_options" => {
        "host" => "fromconfig.example.com"
      }
    )

    assert_equal "fromconfig.example.com", provider.instance_variable_get(:@site_url)
  end

  test "handles rate limit information in metadata" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a mock response
    prompt = ActiveAgent::ActionPrompt::Prompt.new(message: "test")
    response = ActiveAgent::GenerationProvider::Response.new(prompt: prompt)

    headers = {
      "x-provider" => "OpenAI",
      "x-model" => "gpt-4o",
      "x-ratelimit-requests-limit" => "100",
      "x-ratelimit-requests-remaining" => "99",
      "x-ratelimit-tokens-limit" => "10000",
      "x-ratelimit-tokens-remaining" => "9500"
    }

    provider.send(:add_openrouter_metadata, response, headers)

    assert_equal "100", response.metadata[:ratelimit][:requests_limit]
    assert_equal "99", response.metadata[:ratelimit][:requests_remaining]
    assert_equal "10000", response.metadata[:ratelimit][:tokens_limit]
    assert_equal "9500", response.metadata[:ratelimit][:tokens_remaining]
  end

  test "includes plugins parameter when passed in options" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a prompt with plugins option
    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      message: "test",
      options: {
        plugins: [
          {
            id: "file-parser",
            pdf: {
              engine: "pdf-text"
            }
          }
        ]
      }
    )

    # Set the prompt on the provider
    provider.instance_variable_set(:@prompt, prompt)

    # Build parameters and verify plugins are included
    parameters = provider.send(:build_openrouter_parameters)

    assert_not_nil parameters[:plugins]
    assert_equal 1, parameters[:plugins].size
    assert_equal "file-parser", parameters[:plugins][0][:id]
    assert_equal "pdf-text", parameters[:plugins][0][:pdf][:engine]
  end
end
PDF Processing Example

activeagent/test/agents/open_router_integration_test.rb:144

ruby
# Response object
#<ActiveAgent::GenerationProvider::Response:0x3fac
  @message=#<ActiveAgent::ActionPrompt::Message:0x3fc0
    @action_id=nil,
    @action_name=nil,
    @action_requested=false,
    @charset="UTF-8",
    @content={"name"=>"John Doe", "email"=>"john.doe@example.com", "phone"=>"(555) 123-4567", "education"=>[{"degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020}], "experience"=>[{"job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024"}]},
    @role=:assistant>
  @prompt=#<ActiveAgent::ActionPrompt::Prompt:0x3fd4 ...>
  @content_type="application/json"
  @raw_response={...}>

# Message content
response.message.content # => {"name"=>"John Doe", "email"=>"john.doe@example.com", "phone"=>"(555) 123-4567", "education"=>[{"degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020}], "experience"=>[{"job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024"}]}

PDF Processing Options

OpenRouter offers multiple PDF processing engines:

  • Native Engine: Charged as input tokens, best for models with built-in PDF support
  • Mistral OCR Engine: $2 per 1000 pages, optimized for scanned documents
  • No Plugin: For models that have built-in PDF capabilities

Example with OCR engine:

ruby
test "processes PDF with native model support" do
  skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

  VCR.use_cassette("openrouter_pdf_native") do
    # Test with a model that might have native PDF support
    # Using the native engine (charged as input tokens)
    pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")
    pdf_data = Base64.strict_encode64(File.read(pdf_path))

    prompt = OpenRouterIntegrationAgent.with(
      pdf_data: pdf_data,
      prompt_text: "Analyze this PDF document",
      pdf_engine: "native"  # Use native engine (charged as input tokens)
    ).analyze_pdf

    # First verify the prompt has the plugins in options
    assert prompt.options[:plugins].present?, "Plugins should be present in prompt options"
    assert prompt.options[:fallback_models].present?, "Fallback models should be present in prompt options"
    assert_equal "file-parser", prompt.options[:plugins][0][:id]
    assert_equal "native", prompt.options[:plugins][0][:pdf][:engine]

    response = prompt.generate_now

    assert_not_nil response
    assert_not_nil response.message
    assert response.message.content.present?
    assert_includes response.message.content, "John Doe"

    # Generate documentation example
    doc_example_output(response)
  end
end
OCR Processing Example

activeagent/test/agents/open_router_integration_test.rb:273

ruby
# Response object
#<ActiveAgent::GenerationProvider::Response:0x3e1c
  @message=#<ActiveAgent::ActionPrompt::Message:0x3e30
    @action_id=nil,
    @action_name=nil,
    @action_requested=false,
    @charset="UTF-8",
    @content={"name"=>"John Doe", "email"=>"john.doe@example.com", "phone"=>"(555) 123-4567", "education"=>[{"degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020}], "experience"=>[{"job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024"}]},
    @role=:assistant>
  @prompt=#<ActiveAgent::ActionPrompt::Prompt:0x3e44 ...>
  @content_type="application/json"
  @raw_response={...}>

# Message content
response.message.content # => {"name"=>"John Doe", "email"=>"john.doe@example.com", "phone"=>"(555) 123-4567", "education"=>[{"degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020}], "experience"=>[{"job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024"}]}

Fallback Models

Configure fallback models for improved reliability:

ruby
require "test_helper"
require "base64"
require "active_agent/action_prompt/message"

class OpenRouterIntegrationTest < ActiveSupport::TestCase
  setup do
    @agent = OpenRouterIntegrationAgent.new
  end

  test "analyzes image with structured output schema" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_image_analysis_structured") do
      # Use the sales chart image URL for structured analysis
      image_url = "https://raw.githubusercontent.com/activeagents/activeagent/refs/heads/main/test/fixtures/images/sales_chart.png"

      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      # Verify the structure matches our schema
      assert result.key?("description")
      assert result.key?("objects")
      assert result.key?("scene_type")
      assert result.key?("primary_colors")
      assert result["objects"].is_a?(Array)
      assert [ "indoor", "outdoor", "abstract", "document", "photo", "illustration" ].include?(result["scene_type"])
    end
  end

  test "analyzes remote image URL without structured output" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_remote_image_basic") do
      # Use a landscape image URL for basic analysis
      image_url = "https://picsum.photos/400/300"

      # For now, just use analyze_image without the structured output schema
      # We'll get a natural language description instead of JSON
      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.is_a?(String)
      assert response.message.content.length > 10
      # Since analyze_image uses structured output, we'll get JSON
      # Just verify we got a response
      # In the future, we could add a simple_analyze action without schema

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "extracts receipt data with structured output from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_receipt_extraction_local") do
      # Use the test receipt image - file exists, no conditional needed
      receipt_path = Rails.root.join("..", "..", "test", "fixtures", "images", "test_receipt.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: receipt_path).extract_receipt_data
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["merchant"]["name"], "Corner Mart"
      assert_equal result["total"]["amount"], 14.83
      assert_equal result["items"].size, 4
      result["items"].each do |item|
        assert item.key?("name")
        assert item.key?("quantity")
        assert item.key?("price")
      end
      assert_equal result["items"][0], { "name"=>"Milk", "quantity"=>1, "price"=>3.49 }
      assert_equal result["items"][1], { "name"=>"Bread", "quantity"=>1, "price"=>2.29 }
      assert_equal result["items"][2], { "name"=>"Apples", "quantity"=>1, "price"=>5.1 }
      assert_equal result["items"][3], { "name"=>"Eggs", "quantity"=>1, "price"=>2.99 }
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "handles base64 encoded images with sales chart" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_base64_sales_chart") do
      # Use the sales chart image
      chart_path = Rails.root.join("..", "..", "test", "fixtures", "images", "sales_chart.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: chart_path).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert_includes response.message.content, "(Q1, Q2, Q3, Q4), with varying heights indicating different sales amounts"

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes PDF document from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_local") do
      # Use the sample resume PDF
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")

      # Read and encode the PDF as base64 - OpenRouter accepts PDFs as image_url with data URL
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Extract information from this document and return as JSON",
        output_schema: :resume_schema
      ).analyze_pdf
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"].first, { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 }
      assert_equal result["experience"].first, { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" }

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_processing_local

  test "processes PDF from remote URL of resume no plugins" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_remote_no_plugin") do
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze the PDF",
        output_schema: :resume_schema,
        skip_plugin: true
      ).analyze_pdf

      # Remote URLs are not supported without a PDF engine plugin
      # OpenAI: Inputs by file URL are not supported for chat completions. Use the ResponsesAPI for this option.
      # https://platform.openai.com/docs/guides/pdf-files#file-urls
      # Accept either the OpenAI error directly or our wrapped error
      # Suppress ruby-openai gem's error output to STDERR
      error = assert_raises(ActiveAgent::GenerationProvider::Base::GenerationProviderError, OpenAI::Error) do
        prompt.generate_now
      end

      # Check the error message regardless of which error type was raised
      error_message = error.message
      assert_match(/Missing required parameter.*file_id/, error_message)
      assert_match(/Provider returned error|invalid_request_error/, error_message)
    end
  end

  # region pdf_native_support
  test "processes PDF with native model support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_native") do
      # Test with a model that might have native PDF support
      # Using the native engine (charged as input tokens)
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Analyze this PDF document",
        pdf_engine: "native"  # Use native engine (charged as input tokens)
      ).analyze_pdf

      # First verify the prompt has the plugins in options
      assert prompt.options[:plugins].present?, "Plugins should be present in prompt options"
      assert prompt.options[:fallback_models].present?, "Fallback models should be present in prompt options"
      assert_equal "file-parser", prompt.options[:plugins][0][:id]
      assert_equal "native", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      assert_includes response.message.content, "John Doe"

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_native_support

  test "processes PDF without any plugin for models with built-in support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_no_plugin") do
      # Test without any plugin - for models that have built-in PDF support
      pdf_url = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze this PDF document",
        skip_plugin: true  # Don't use any plugin
      ).analyze_pdf

      # Verify no plugins are included when skip_plugin is true
      assert_empty prompt.options[:plugins], "Should not have plugins when skip_plugin is true"

      response = prompt.generate_now
      raw_response = response.raw_response
      assert_equal "Google", raw_response["provider"]
      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes scanned PDF with OCR engine" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_ocr") do
      # Test with the mistral-ocr engine for scanned documents
      # Using a simple PDF that should be processable
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Extract text from this PDF.",
        output_schema: :resume_schema,
        pdf_engine: "mistral-ocr"  # OCR engine for text extraction
      ).analyze_pdf

      # Verify OCR engine is specified
      assert prompt.options[:plugins].present?, "Should have plugins for OCR"
      assert_equal "mistral-ocr", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      # MUST return valid JSON - no fallback allowed
      raw_response = response.raw_response
      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"], [ { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 } ]
      assert_equal result["experience"], [ { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" } ]

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "uses fallback models when primary fails" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_fallback_models") do
      prompt = OpenRouterIntegrationAgent.test_fallback
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # Check metadata for fallback usage
      if response.respond_to?(:metadata) && response.metadata
        # Should use one of the fallback models, not the primary
        possible_models = [ "openai/gpt-3.5-turbo-0301", "openai/gpt-3.5-turbo", "openai/gpt-4o-mini" ]
        assert possible_models.include?(response.metadata[:model_used])
        assert response.metadata[:provider].present?
      end

      # The response should still work (2+2=4)
      assert response.message.content.include?("4")

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "applies transforms for long content" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_transforms") do
      # Generate a very long text
      long_text = "Lorem ipsum dolor sit amet. " * 1000

      prompt = OpenRouterIntegrationAgent.with(text: long_text).process_long_text
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # The summary should be much shorter than the original
      assert response.message.content.length < long_text.length / 10

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "tracks usage and costs" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_cost_tracking") do
      prompt = OpenRouterIntegrationAgent.with(message: "Hello").prompt_context
      response = prompt.generate_now

      assert_not_nil response

      # Check for usage information
      if response.respond_to?(:usage) && response.usage
        assert response.usage["prompt_tokens"].is_a?(Integer)
        assert response.usage["completion_tokens"].is_a?(Integer)
        assert response.usage["total_tokens"].is_a?(Integer)
      end

      # Check for metadata with model information from OpenRouter
      if response.respond_to?(:metadata) && response.metadata
        assert response.metadata[:model_used].present?
        assert response.metadata[:provider].present?
        # Verify we're using the expected model (gpt-4o-mini)
        assert_equal "openai/gpt-4o-mini", response.metadata[:model_used]
      end

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "includes OpenRouter headers in requests" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "app_name" => "TestApp",
      "site_url" => "https://test.example.com"
    )

    # Get the headers that would be sent
    headers = provider.send(:openrouter_headers)

    assert_equal "https://test.example.com", headers["HTTP-Referer"]
    assert_equal "TestApp", headers["X-Title"]
  end

  test "builds provider preferences correctly" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "enable_fallbacks" => true,
      "provider" => {
        "order" => [ "OpenAI", "Anthropic" ],
        "require_parameters" => true,
        "data_collection" => "deny"
      }
    )

    prefs = provider.send(:build_provider_preferences)

    assert_equal [ "OpenAI", "Anthropic" ], prefs[:order]
    assert_equal true, prefs[:require_parameters]
    assert_equal true, prefs[:allow_fallbacks]
    assert_equal "deny", prefs[:data_collection]
  end

  test "configures data collection policies" do
    # Test deny all data collection
    provider_deny = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => "deny"
    )
    prefs_deny = provider_deny.send(:build_provider_preferences)
    assert_equal "deny", prefs_deny[:data_collection]

    # Test allow all data collection (default)
    provider_allow = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )
    prefs_allow = provider_allow.send(:build_provider_preferences)
    assert_equal "allow", prefs_allow[:data_collection]

    # Test selective provider data collection
    provider_selective = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => [ "OpenAI", "Google" ]
    )
    prefs_selective = provider_selective.send(:build_provider_preferences)
    assert_equal [ "OpenAI", "Google" ], prefs_selective[:data_collection]
  end

  test "handles multimodal content correctly" do
    # Create a message with multimodal content
    message = ActiveAgent::ActionPrompt::Message.new(
      content: [
        { type: "text", text: "What's in this image?" },
        { type: "image_url", image_url: { url: "https://example.com/image.jpg" } }
      ],
      role: :user
    )

    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      messages: [ message ]
    )

    assert prompt.multimodal?
  end

  test "converts file type to image_url for OpenRouter PDF support" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Test file type conversion
    file_item = {
      type: "file",
      file: {
        file_data: "data:application/pdf;base64,JVBERi0xLj..."
      }
    }

    formatted = provider.send(:format_content_item, file_item)

    assert_equal "image_url", formatted[:type]
    assert_equal "data:application/pdf;base64,JVBERi0xLj...", formatted[:image_url][:url]
  end

  test "respects configuration hierarchy for site_url" do
    # Test with explicit site_url config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "site_url" => "https://configured.example.com"
    )

    assert_equal "https://configured.example.com", provider.instance_variable_get(:@site_url)

    # Test with default_url_options in config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "default_url_options" => {
        "host" => "fromconfig.example.com"
      }
    )

    assert_equal "fromconfig.example.com", provider.instance_variable_get(:@site_url)
  end

  test "handles rate limit information in metadata" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a mock response
    prompt = ActiveAgent::ActionPrompt::Prompt.new(message: "test")
    response = ActiveAgent::GenerationProvider::Response.new(prompt: prompt)

    headers = {
      "x-provider" => "OpenAI",
      "x-model" => "gpt-4o",
      "x-ratelimit-requests-limit" => "100",
      "x-ratelimit-requests-remaining" => "99",
      "x-ratelimit-tokens-limit" => "10000",
      "x-ratelimit-tokens-remaining" => "9500"
    }

    provider.send(:add_openrouter_metadata, response, headers)

    assert_equal "100", response.metadata[:ratelimit][:requests_limit]
    assert_equal "99", response.metadata[:ratelimit][:requests_remaining]
    assert_equal "10000", response.metadata[:ratelimit][:tokens_limit]
    assert_equal "9500", response.metadata[:ratelimit][:tokens_remaining]
  end

  test "includes plugins parameter when passed in options" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a prompt with plugins option
    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      message: "test",
      options: {
        plugins: [
          {
            id: "file-parser",
            pdf: {
              engine: "pdf-text"
            }
          }
        ]
      }
    )

    # Set the prompt on the provider
    provider.instance_variable_set(:@prompt, prompt)

    # Build parameters and verify plugins are included
    parameters = provider.send(:build_openrouter_parameters)

    assert_not_nil parameters[:plugins]
    assert_equal 1, parameters[:plugins].size
    assert_equal "file-parser", parameters[:plugins][0][:id]
    assert_equal "pdf-text", parameters[:plugins][0][:pdf][:engine]
  end
end
Fallback Model Example

activeagent/test/agents/open_router_integration_test.rb:299

ruby
# Response object
#<ActiveAgent::GenerationProvider::Response:0x3ca0
  @message=#<ActiveAgent::ActionPrompt::Message:0x3cb4
    @action_id=nil,
    @action_name=nil,
    @action_requested=false,
    @charset="UTF-8",
    @content="4",
    @role=:assistant>
  @prompt=#<ActiveAgent::ActionPrompt::Prompt:0x3cc8 ...>
  @content_type="text/plain"
  @raw_response={...}>

# Message content
response.message.content # => "4"

Content Transforms

Apply transforms for handling long content:

ruby
require "test_helper"
require "base64"
require "active_agent/action_prompt/message"

class OpenRouterIntegrationTest < ActiveSupport::TestCase
  setup do
    @agent = OpenRouterIntegrationAgent.new
  end

  test "analyzes image with structured output schema" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_image_analysis_structured") do
      # Use the sales chart image URL for structured analysis
      image_url = "https://raw.githubusercontent.com/activeagents/activeagent/refs/heads/main/test/fixtures/images/sales_chart.png"

      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      # Verify the structure matches our schema
      assert result.key?("description")
      assert result.key?("objects")
      assert result.key?("scene_type")
      assert result.key?("primary_colors")
      assert result["objects"].is_a?(Array)
      assert [ "indoor", "outdoor", "abstract", "document", "photo", "illustration" ].include?(result["scene_type"])
    end
  end

  test "analyzes remote image URL without structured output" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_remote_image_basic") do
      # Use a landscape image URL for basic analysis
      image_url = "https://picsum.photos/400/300"

      # For now, just use analyze_image without the structured output schema
      # We'll get a natural language description instead of JSON
      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.is_a?(String)
      assert response.message.content.length > 10
      # Since analyze_image uses structured output, we'll get JSON
      # Just verify we got a response
      # In the future, we could add a simple_analyze action without schema

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "extracts receipt data with structured output from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_receipt_extraction_local") do
      # Use the test receipt image - file exists, no conditional needed
      receipt_path = Rails.root.join("..", "..", "test", "fixtures", "images", "test_receipt.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: receipt_path).extract_receipt_data
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["merchant"]["name"], "Corner Mart"
      assert_equal result["total"]["amount"], 14.83
      assert_equal result["items"].size, 4
      result["items"].each do |item|
        assert item.key?("name")
        assert item.key?("quantity")
        assert item.key?("price")
      end
      assert_equal result["items"][0], { "name"=>"Milk", "quantity"=>1, "price"=>3.49 }
      assert_equal result["items"][1], { "name"=>"Bread", "quantity"=>1, "price"=>2.29 }
      assert_equal result["items"][2], { "name"=>"Apples", "quantity"=>1, "price"=>5.1 }
      assert_equal result["items"][3], { "name"=>"Eggs", "quantity"=>1, "price"=>2.99 }
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "handles base64 encoded images with sales chart" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_base64_sales_chart") do
      # Use the sales chart image
      chart_path = Rails.root.join("..", "..", "test", "fixtures", "images", "sales_chart.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: chart_path).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert_includes response.message.content, "(Q1, Q2, Q3, Q4), with varying heights indicating different sales amounts"

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes PDF document from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_local") do
      # Use the sample resume PDF
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")

      # Read and encode the PDF as base64 - OpenRouter accepts PDFs as image_url with data URL
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Extract information from this document and return as JSON",
        output_schema: :resume_schema
      ).analyze_pdf
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"].first, { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 }
      assert_equal result["experience"].first, { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" }

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_processing_local

  test "processes PDF from remote URL of resume no plugins" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_remote_no_plugin") do
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze the PDF",
        output_schema: :resume_schema,
        skip_plugin: true
      ).analyze_pdf

      # Remote URLs are not supported without a PDF engine plugin
      # OpenAI: Inputs by file URL are not supported for chat completions. Use the ResponsesAPI for this option.
      # https://platform.openai.com/docs/guides/pdf-files#file-urls
      # Accept either the OpenAI error directly or our wrapped error
      # Suppress ruby-openai gem's error output to STDERR
      error = assert_raises(ActiveAgent::GenerationProvider::Base::GenerationProviderError, OpenAI::Error) do
        prompt.generate_now
      end

      # Check the error message regardless of which error type was raised
      error_message = error.message
      assert_match(/Missing required parameter.*file_id/, error_message)
      assert_match(/Provider returned error|invalid_request_error/, error_message)
    end
  end

  # region pdf_native_support
  test "processes PDF with native model support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_native") do
      # Test with a model that might have native PDF support
      # Using the native engine (charged as input tokens)
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Analyze this PDF document",
        pdf_engine: "native"  # Use native engine (charged as input tokens)
      ).analyze_pdf

      # First verify the prompt has the plugins in options
      assert prompt.options[:plugins].present?, "Plugins should be present in prompt options"
      assert prompt.options[:fallback_models].present?, "Fallback models should be present in prompt options"
      assert_equal "file-parser", prompt.options[:plugins][0][:id]
      assert_equal "native", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      assert_includes response.message.content, "John Doe"

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_native_support

  test "processes PDF without any plugin for models with built-in support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_no_plugin") do
      # Test without any plugin - for models that have built-in PDF support
      pdf_url = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze this PDF document",
        skip_plugin: true  # Don't use any plugin
      ).analyze_pdf

      # Verify no plugins are included when skip_plugin is true
      assert_empty prompt.options[:plugins], "Should not have plugins when skip_plugin is true"

      response = prompt.generate_now
      raw_response = response.raw_response
      assert_equal "Google", raw_response["provider"]
      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes scanned PDF with OCR engine" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_ocr") do
      # Test with the mistral-ocr engine for scanned documents
      # Using a simple PDF that should be processable
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Extract text from this PDF.",
        output_schema: :resume_schema,
        pdf_engine: "mistral-ocr"  # OCR engine for text extraction
      ).analyze_pdf

      # Verify OCR engine is specified
      assert prompt.options[:plugins].present?, "Should have plugins for OCR"
      assert_equal "mistral-ocr", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      # MUST return valid JSON - no fallback allowed
      raw_response = response.raw_response
      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"], [ { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 } ]
      assert_equal result["experience"], [ { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" } ]

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "uses fallback models when primary fails" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_fallback_models") do
      prompt = OpenRouterIntegrationAgent.test_fallback
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # Check metadata for fallback usage
      if response.respond_to?(:metadata) && response.metadata
        # Should use one of the fallback models, not the primary
        possible_models = [ "openai/gpt-3.5-turbo-0301", "openai/gpt-3.5-turbo", "openai/gpt-4o-mini" ]
        assert possible_models.include?(response.metadata[:model_used])
        assert response.metadata[:provider].present?
      end

      # The response should still work (2+2=4)
      assert response.message.content.include?("4")

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "applies transforms for long content" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_transforms") do
      # Generate a very long text
      long_text = "Lorem ipsum dolor sit amet. " * 1000

      prompt = OpenRouterIntegrationAgent.with(text: long_text).process_long_text
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # The summary should be much shorter than the original
      assert response.message.content.length < long_text.length / 10

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "tracks usage and costs" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_cost_tracking") do
      prompt = OpenRouterIntegrationAgent.with(message: "Hello").prompt_context
      response = prompt.generate_now

      assert_not_nil response

      # Check for usage information
      if response.respond_to?(:usage) && response.usage
        assert response.usage["prompt_tokens"].is_a?(Integer)
        assert response.usage["completion_tokens"].is_a?(Integer)
        assert response.usage["total_tokens"].is_a?(Integer)
      end

      # Check for metadata with model information from OpenRouter
      if response.respond_to?(:metadata) && response.metadata
        assert response.metadata[:model_used].present?
        assert response.metadata[:provider].present?
        # Verify we're using the expected model (gpt-4o-mini)
        assert_equal "openai/gpt-4o-mini", response.metadata[:model_used]
      end

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "includes OpenRouter headers in requests" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "app_name" => "TestApp",
      "site_url" => "https://test.example.com"
    )

    # Get the headers that would be sent
    headers = provider.send(:openrouter_headers)

    assert_equal "https://test.example.com", headers["HTTP-Referer"]
    assert_equal "TestApp", headers["X-Title"]
  end

  test "builds provider preferences correctly" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "enable_fallbacks" => true,
      "provider" => {
        "order" => [ "OpenAI", "Anthropic" ],
        "require_parameters" => true,
        "data_collection" => "deny"
      }
    )

    prefs = provider.send(:build_provider_preferences)

    assert_equal [ "OpenAI", "Anthropic" ], prefs[:order]
    assert_equal true, prefs[:require_parameters]
    assert_equal true, prefs[:allow_fallbacks]
    assert_equal "deny", prefs[:data_collection]
  end

  test "configures data collection policies" do
    # Test deny all data collection
    provider_deny = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => "deny"
    )
    prefs_deny = provider_deny.send(:build_provider_preferences)
    assert_equal "deny", prefs_deny[:data_collection]

    # Test allow all data collection (default)
    provider_allow = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )
    prefs_allow = provider_allow.send(:build_provider_preferences)
    assert_equal "allow", prefs_allow[:data_collection]

    # Test selective provider data collection
    provider_selective = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => [ "OpenAI", "Google" ]
    )
    prefs_selective = provider_selective.send(:build_provider_preferences)
    assert_equal [ "OpenAI", "Google" ], prefs_selective[:data_collection]
  end

  test "handles multimodal content correctly" do
    # Create a message with multimodal content
    message = ActiveAgent::ActionPrompt::Message.new(
      content: [
        { type: "text", text: "What's in this image?" },
        { type: "image_url", image_url: { url: "https://example.com/image.jpg" } }
      ],
      role: :user
    )

    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      messages: [ message ]
    )

    assert prompt.multimodal?
  end

  test "converts file type to image_url for OpenRouter PDF support" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Test file type conversion
    file_item = {
      type: "file",
      file: {
        file_data: "data:application/pdf;base64,JVBERi0xLj..."
      }
    }

    formatted = provider.send(:format_content_item, file_item)

    assert_equal "image_url", formatted[:type]
    assert_equal "data:application/pdf;base64,JVBERi0xLj...", formatted[:image_url][:url]
  end

  test "respects configuration hierarchy for site_url" do
    # Test with explicit site_url config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "site_url" => "https://configured.example.com"
    )

    assert_equal "https://configured.example.com", provider.instance_variable_get(:@site_url)

    # Test with default_url_options in config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "default_url_options" => {
        "host" => "fromconfig.example.com"
      }
    )

    assert_equal "fromconfig.example.com", provider.instance_variable_get(:@site_url)
  end

  test "handles rate limit information in metadata" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a mock response
    prompt = ActiveAgent::ActionPrompt::Prompt.new(message: "test")
    response = ActiveAgent::GenerationProvider::Response.new(prompt: prompt)

    headers = {
      "x-provider" => "OpenAI",
      "x-model" => "gpt-4o",
      "x-ratelimit-requests-limit" => "100",
      "x-ratelimit-requests-remaining" => "99",
      "x-ratelimit-tokens-limit" => "10000",
      "x-ratelimit-tokens-remaining" => "9500"
    }

    provider.send(:add_openrouter_metadata, response, headers)

    assert_equal "100", response.metadata[:ratelimit][:requests_limit]
    assert_equal "99", response.metadata[:ratelimit][:requests_remaining]
    assert_equal "10000", response.metadata[:ratelimit][:tokens_limit]
    assert_equal "9500", response.metadata[:ratelimit][:tokens_remaining]
  end

  test "includes plugins parameter when passed in options" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a prompt with plugins option
    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      message: "test",
      options: {
        plugins: [
          {
            id: "file-parser",
            pdf: {
              engine: "pdf-text"
            }
          }
        ]
      }
    )

    # Set the prompt on the provider
    provider.instance_variable_set(:@prompt, prompt)

    # Build parameters and verify plugins are included
    parameters = provider.send(:build_openrouter_parameters)

    assert_not_nil parameters[:plugins]
    assert_equal 1, parameters[:plugins].size
    assert_equal "file-parser", parameters[:plugins][0][:id]
    assert_equal "pdf-text", parameters[:plugins][0][:pdf][:engine]
  end
end
Transform Example

activeagent/test/agents/open_router_integration_test.rb:321

ruby
# Response object
#<ActiveAgent::GenerationProvider::Response:0x3c50
  @message=#<ActiveAgent::ActionPrompt::Message:0x3c64
    @action_id=nil,
    @action_name=nil,
    @action_requested=false,
    @charset="UTF-8",
    @content="- The text consists predominantly of repetitive phrases of \"Lorem ipsum dolor sit amet,\" which is a placeholder text commonly used in design and printing.\n- It emphasizes the lack of specific content or context, serving primarily as a demonstration of typo...",
    @role=:assistant>
  @prompt=#<ActiveAgent::ActionPrompt::Prompt:0x3c78 ...>
  @content_type="text/plain"
  @raw_response={...}>

# Message content
response.message.content # => "- The text consists predominantly of repetitive phrases of \"Lorem ipsum dolor sit amet,\" which is a placeholder text commonly used in design and printing.\n- It emphasizes the lack of specific content or context, serving primarily as a demonstration of typography and layout.\n- The repetitive nature of the text suggests it is intended for testing visual elements rather than conveying meaningful information."

Usage and Cost Tracking

Track token usage and costs for OpenRouter requests:

ruby
require "test_helper"
require "base64"
require "active_agent/action_prompt/message"

class OpenRouterIntegrationTest < ActiveSupport::TestCase
  setup do
    @agent = OpenRouterIntegrationAgent.new
  end

  test "analyzes image with structured output schema" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_image_analysis_structured") do
      # Use the sales chart image URL for structured analysis
      image_url = "https://raw.githubusercontent.com/activeagents/activeagent/refs/heads/main/test/fixtures/images/sales_chart.png"

      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      # Verify the structure matches our schema
      assert result.key?("description")
      assert result.key?("objects")
      assert result.key?("scene_type")
      assert result.key?("primary_colors")
      assert result["objects"].is_a?(Array)
      assert [ "indoor", "outdoor", "abstract", "document", "photo", "illustration" ].include?(result["scene_type"])
    end
  end

  test "analyzes remote image URL without structured output" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_remote_image_basic") do
      # Use a landscape image URL for basic analysis
      image_url = "https://picsum.photos/400/300"

      # For now, just use analyze_image without the structured output schema
      # We'll get a natural language description instead of JSON
      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.is_a?(String)
      assert response.message.content.length > 10
      # Since analyze_image uses structured output, we'll get JSON
      # Just verify we got a response
      # In the future, we could add a simple_analyze action without schema

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "extracts receipt data with structured output from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_receipt_extraction_local") do
      # Use the test receipt image - file exists, no conditional needed
      receipt_path = Rails.root.join("..", "..", "test", "fixtures", "images", "test_receipt.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: receipt_path).extract_receipt_data
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["merchant"]["name"], "Corner Mart"
      assert_equal result["total"]["amount"], 14.83
      assert_equal result["items"].size, 4
      result["items"].each do |item|
        assert item.key?("name")
        assert item.key?("quantity")
        assert item.key?("price")
      end
      assert_equal result["items"][0], { "name"=>"Milk", "quantity"=>1, "price"=>3.49 }
      assert_equal result["items"][1], { "name"=>"Bread", "quantity"=>1, "price"=>2.29 }
      assert_equal result["items"][2], { "name"=>"Apples", "quantity"=>1, "price"=>5.1 }
      assert_equal result["items"][3], { "name"=>"Eggs", "quantity"=>1, "price"=>2.99 }
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "handles base64 encoded images with sales chart" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_base64_sales_chart") do
      # Use the sales chart image
      chart_path = Rails.root.join("..", "..", "test", "fixtures", "images", "sales_chart.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: chart_path).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert_includes response.message.content, "(Q1, Q2, Q3, Q4), with varying heights indicating different sales amounts"

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes PDF document from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_local") do
      # Use the sample resume PDF
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")

      # Read and encode the PDF as base64 - OpenRouter accepts PDFs as image_url with data URL
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Extract information from this document and return as JSON",
        output_schema: :resume_schema
      ).analyze_pdf
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"].first, { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 }
      assert_equal result["experience"].first, { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" }

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_processing_local

  test "processes PDF from remote URL of resume no plugins" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_remote_no_plugin") do
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze the PDF",
        output_schema: :resume_schema,
        skip_plugin: true
      ).analyze_pdf

      # Remote URLs are not supported without a PDF engine plugin
      # OpenAI: Inputs by file URL are not supported for chat completions. Use the ResponsesAPI for this option.
      # https://platform.openai.com/docs/guides/pdf-files#file-urls
      # Accept either the OpenAI error directly or our wrapped error
      # Suppress ruby-openai gem's error output to STDERR
      error = assert_raises(ActiveAgent::GenerationProvider::Base::GenerationProviderError, OpenAI::Error) do
        prompt.generate_now
      end

      # Check the error message regardless of which error type was raised
      error_message = error.message
      assert_match(/Missing required parameter.*file_id/, error_message)
      assert_match(/Provider returned error|invalid_request_error/, error_message)
    end
  end

  # region pdf_native_support
  test "processes PDF with native model support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_native") do
      # Test with a model that might have native PDF support
      # Using the native engine (charged as input tokens)
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Analyze this PDF document",
        pdf_engine: "native"  # Use native engine (charged as input tokens)
      ).analyze_pdf

      # First verify the prompt has the plugins in options
      assert prompt.options[:plugins].present?, "Plugins should be present in prompt options"
      assert prompt.options[:fallback_models].present?, "Fallback models should be present in prompt options"
      assert_equal "file-parser", prompt.options[:plugins][0][:id]
      assert_equal "native", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      assert_includes response.message.content, "John Doe"

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_native_support

  test "processes PDF without any plugin for models with built-in support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_no_plugin") do
      # Test without any plugin - for models that have built-in PDF support
      pdf_url = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze this PDF document",
        skip_plugin: true  # Don't use any plugin
      ).analyze_pdf

      # Verify no plugins are included when skip_plugin is true
      assert_empty prompt.options[:plugins], "Should not have plugins when skip_plugin is true"

      response = prompt.generate_now
      raw_response = response.raw_response
      assert_equal "Google", raw_response["provider"]
      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes scanned PDF with OCR engine" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_ocr") do
      # Test with the mistral-ocr engine for scanned documents
      # Using a simple PDF that should be processable
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Extract text from this PDF.",
        output_schema: :resume_schema,
        pdf_engine: "mistral-ocr"  # OCR engine for text extraction
      ).analyze_pdf

      # Verify OCR engine is specified
      assert prompt.options[:plugins].present?, "Should have plugins for OCR"
      assert_equal "mistral-ocr", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      # MUST return valid JSON - no fallback allowed
      raw_response = response.raw_response
      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"], [ { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 } ]
      assert_equal result["experience"], [ { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" } ]

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "uses fallback models when primary fails" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_fallback_models") do
      prompt = OpenRouterIntegrationAgent.test_fallback
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # Check metadata for fallback usage
      if response.respond_to?(:metadata) && response.metadata
        # Should use one of the fallback models, not the primary
        possible_models = [ "openai/gpt-3.5-turbo-0301", "openai/gpt-3.5-turbo", "openai/gpt-4o-mini" ]
        assert possible_models.include?(response.metadata[:model_used])
        assert response.metadata[:provider].present?
      end

      # The response should still work (2+2=4)
      assert response.message.content.include?("4")

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "applies transforms for long content" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_transforms") do
      # Generate a very long text
      long_text = "Lorem ipsum dolor sit amet. " * 1000

      prompt = OpenRouterIntegrationAgent.with(text: long_text).process_long_text
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # The summary should be much shorter than the original
      assert response.message.content.length < long_text.length / 10

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "tracks usage and costs" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_cost_tracking") do
      prompt = OpenRouterIntegrationAgent.with(message: "Hello").prompt_context
      response = prompt.generate_now

      assert_not_nil response

      # Check for usage information
      if response.respond_to?(:usage) && response.usage
        assert response.usage["prompt_tokens"].is_a?(Integer)
        assert response.usage["completion_tokens"].is_a?(Integer)
        assert response.usage["total_tokens"].is_a?(Integer)
      end

      # Check for metadata with model information from OpenRouter
      if response.respond_to?(:metadata) && response.metadata
        assert response.metadata[:model_used].present?
        assert response.metadata[:provider].present?
        # Verify we're using the expected model (gpt-4o-mini)
        assert_equal "openai/gpt-4o-mini", response.metadata[:model_used]
      end

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "includes OpenRouter headers in requests" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "app_name" => "TestApp",
      "site_url" => "https://test.example.com"
    )

    # Get the headers that would be sent
    headers = provider.send(:openrouter_headers)

    assert_equal "https://test.example.com", headers["HTTP-Referer"]
    assert_equal "TestApp", headers["X-Title"]
  end

  test "builds provider preferences correctly" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "enable_fallbacks" => true,
      "provider" => {
        "order" => [ "OpenAI", "Anthropic" ],
        "require_parameters" => true,
        "data_collection" => "deny"
      }
    )

    prefs = provider.send(:build_provider_preferences)

    assert_equal [ "OpenAI", "Anthropic" ], prefs[:order]
    assert_equal true, prefs[:require_parameters]
    assert_equal true, prefs[:allow_fallbacks]
    assert_equal "deny", prefs[:data_collection]
  end

  test "configures data collection policies" do
    # Test deny all data collection
    provider_deny = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => "deny"
    )
    prefs_deny = provider_deny.send(:build_provider_preferences)
    assert_equal "deny", prefs_deny[:data_collection]

    # Test allow all data collection (default)
    provider_allow = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )
    prefs_allow = provider_allow.send(:build_provider_preferences)
    assert_equal "allow", prefs_allow[:data_collection]

    # Test selective provider data collection
    provider_selective = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => [ "OpenAI", "Google" ]
    )
    prefs_selective = provider_selective.send(:build_provider_preferences)
    assert_equal [ "OpenAI", "Google" ], prefs_selective[:data_collection]
  end

  test "handles multimodal content correctly" do
    # Create a message with multimodal content
    message = ActiveAgent::ActionPrompt::Message.new(
      content: [
        { type: "text", text: "What's in this image?" },
        { type: "image_url", image_url: { url: "https://example.com/image.jpg" } }
      ],
      role: :user
    )

    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      messages: [ message ]
    )

    assert prompt.multimodal?
  end

  test "converts file type to image_url for OpenRouter PDF support" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Test file type conversion
    file_item = {
      type: "file",
      file: {
        file_data: "data:application/pdf;base64,JVBERi0xLj..."
      }
    }

    formatted = provider.send(:format_content_item, file_item)

    assert_equal "image_url", formatted[:type]
    assert_equal "data:application/pdf;base64,JVBERi0xLj...", formatted[:image_url][:url]
  end

  test "respects configuration hierarchy for site_url" do
    # Test with explicit site_url config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "site_url" => "https://configured.example.com"
    )

    assert_equal "https://configured.example.com", provider.instance_variable_get(:@site_url)

    # Test with default_url_options in config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "default_url_options" => {
        "host" => "fromconfig.example.com"
      }
    )

    assert_equal "fromconfig.example.com", provider.instance_variable_get(:@site_url)
  end

  test "handles rate limit information in metadata" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a mock response
    prompt = ActiveAgent::ActionPrompt::Prompt.new(message: "test")
    response = ActiveAgent::GenerationProvider::Response.new(prompt: prompt)

    headers = {
      "x-provider" => "OpenAI",
      "x-model" => "gpt-4o",
      "x-ratelimit-requests-limit" => "100",
      "x-ratelimit-requests-remaining" => "99",
      "x-ratelimit-tokens-limit" => "10000",
      "x-ratelimit-tokens-remaining" => "9500"
    }

    provider.send(:add_openrouter_metadata, response, headers)

    assert_equal "100", response.metadata[:ratelimit][:requests_limit]
    assert_equal "99", response.metadata[:ratelimit][:requests_remaining]
    assert_equal "10000", response.metadata[:ratelimit][:tokens_limit]
    assert_equal "9500", response.metadata[:ratelimit][:tokens_remaining]
  end

  test "includes plugins parameter when passed in options" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a prompt with plugins option
    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      message: "test",
      options: {
        plugins: [
          {
            id: "file-parser",
            pdf: {
              engine: "pdf-text"
            }
          }
        ]
      }
    )

    # Set the prompt on the provider
    provider.instance_variable_set(:@prompt, prompt)

    # Build parameters and verify plugins are included
    parameters = provider.send(:build_openrouter_parameters)

    assert_not_nil parameters[:plugins]
    assert_equal 1, parameters[:plugins].size
    assert_equal "file-parser", parameters[:plugins][0][:id]
    assert_equal "pdf-text", parameters[:plugins][0][:pdf][:engine]
  end
end
Usage Tracking Example

activeagent/test/agents/open_router_integration_test.rb:350

ruby
# Response object
#<ActiveAgent::GenerationProvider::Response:0x3ebc
  @message=#<ActiveAgent::ActionPrompt::Message:0x3ed0
    @action_id=nil,
    @action_name=nil,
    @action_requested=false,
    @charset="UTF-8",
    @content="Hello! How can I assist you today?",
    @role=:assistant>
  @prompt=#<ActiveAgent::ActionPrompt::Prompt:0x3ee4 ...>
  @content_type="text/plain"
  @raw_response={...}>

# Message content
response.message.content # => "Hello! How can I assist you today?"

Provider Preferences

Configure provider preferences for routing and data collection:

ruby
require "test_helper"
require "base64"
require "active_agent/action_prompt/message"

class OpenRouterIntegrationTest < ActiveSupport::TestCase
  setup do
    @agent = OpenRouterIntegrationAgent.new
  end

  test "analyzes image with structured output schema" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_image_analysis_structured") do
      # Use the sales chart image URL for structured analysis
      image_url = "https://raw.githubusercontent.com/activeagents/activeagent/refs/heads/main/test/fixtures/images/sales_chart.png"

      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      # Verify the structure matches our schema
      assert result.key?("description")
      assert result.key?("objects")
      assert result.key?("scene_type")
      assert result.key?("primary_colors")
      assert result["objects"].is_a?(Array)
      assert [ "indoor", "outdoor", "abstract", "document", "photo", "illustration" ].include?(result["scene_type"])
    end
  end

  test "analyzes remote image URL without structured output" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_remote_image_basic") do
      # Use a landscape image URL for basic analysis
      image_url = "https://picsum.photos/400/300"

      # For now, just use analyze_image without the structured output schema
      # We'll get a natural language description instead of JSON
      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.is_a?(String)
      assert response.message.content.length > 10
      # Since analyze_image uses structured output, we'll get JSON
      # Just verify we got a response
      # In the future, we could add a simple_analyze action without schema

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "extracts receipt data with structured output from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_receipt_extraction_local") do
      # Use the test receipt image - file exists, no conditional needed
      receipt_path = Rails.root.join("..", "..", "test", "fixtures", "images", "test_receipt.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: receipt_path).extract_receipt_data
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["merchant"]["name"], "Corner Mart"
      assert_equal result["total"]["amount"], 14.83
      assert_equal result["items"].size, 4
      result["items"].each do |item|
        assert item.key?("name")
        assert item.key?("quantity")
        assert item.key?("price")
      end
      assert_equal result["items"][0], { "name"=>"Milk", "quantity"=>1, "price"=>3.49 }
      assert_equal result["items"][1], { "name"=>"Bread", "quantity"=>1, "price"=>2.29 }
      assert_equal result["items"][2], { "name"=>"Apples", "quantity"=>1, "price"=>5.1 }
      assert_equal result["items"][3], { "name"=>"Eggs", "quantity"=>1, "price"=>2.99 }
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "handles base64 encoded images with sales chart" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_base64_sales_chart") do
      # Use the sales chart image
      chart_path = Rails.root.join("..", "..", "test", "fixtures", "images", "sales_chart.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: chart_path).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert_includes response.message.content, "(Q1, Q2, Q3, Q4), with varying heights indicating different sales amounts"

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes PDF document from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_local") do
      # Use the sample resume PDF
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")

      # Read and encode the PDF as base64 - OpenRouter accepts PDFs as image_url with data URL
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Extract information from this document and return as JSON",
        output_schema: :resume_schema
      ).analyze_pdf
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"].first, { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 }
      assert_equal result["experience"].first, { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" }

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_processing_local

  test "processes PDF from remote URL of resume no plugins" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_remote_no_plugin") do
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze the PDF",
        output_schema: :resume_schema,
        skip_plugin: true
      ).analyze_pdf

      # Remote URLs are not supported without a PDF engine plugin
      # OpenAI: Inputs by file URL are not supported for chat completions. Use the ResponsesAPI for this option.
      # https://platform.openai.com/docs/guides/pdf-files#file-urls
      # Accept either the OpenAI error directly or our wrapped error
      # Suppress ruby-openai gem's error output to STDERR
      error = assert_raises(ActiveAgent::GenerationProvider::Base::GenerationProviderError, OpenAI::Error) do
        prompt.generate_now
      end

      # Check the error message regardless of which error type was raised
      error_message = error.message
      assert_match(/Missing required parameter.*file_id/, error_message)
      assert_match(/Provider returned error|invalid_request_error/, error_message)
    end
  end

  # region pdf_native_support
  test "processes PDF with native model support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_native") do
      # Test with a model that might have native PDF support
      # Using the native engine (charged as input tokens)
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Analyze this PDF document",
        pdf_engine: "native"  # Use native engine (charged as input tokens)
      ).analyze_pdf

      # First verify the prompt has the plugins in options
      assert prompt.options[:plugins].present?, "Plugins should be present in prompt options"
      assert prompt.options[:fallback_models].present?, "Fallback models should be present in prompt options"
      assert_equal "file-parser", prompt.options[:plugins][0][:id]
      assert_equal "native", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      assert_includes response.message.content, "John Doe"

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_native_support

  test "processes PDF without any plugin for models with built-in support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_no_plugin") do
      # Test without any plugin - for models that have built-in PDF support
      pdf_url = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze this PDF document",
        skip_plugin: true  # Don't use any plugin
      ).analyze_pdf

      # Verify no plugins are included when skip_plugin is true
      assert_empty prompt.options[:plugins], "Should not have plugins when skip_plugin is true"

      response = prompt.generate_now
      raw_response = response.raw_response
      assert_equal "Google", raw_response["provider"]
      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes scanned PDF with OCR engine" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_ocr") do
      # Test with the mistral-ocr engine for scanned documents
      # Using a simple PDF that should be processable
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Extract text from this PDF.",
        output_schema: :resume_schema,
        pdf_engine: "mistral-ocr"  # OCR engine for text extraction
      ).analyze_pdf

      # Verify OCR engine is specified
      assert prompt.options[:plugins].present?, "Should have plugins for OCR"
      assert_equal "mistral-ocr", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      # MUST return valid JSON - no fallback allowed
      raw_response = response.raw_response
      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"], [ { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 } ]
      assert_equal result["experience"], [ { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" } ]

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "uses fallback models when primary fails" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_fallback_models") do
      prompt = OpenRouterIntegrationAgent.test_fallback
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # Check metadata for fallback usage
      if response.respond_to?(:metadata) && response.metadata
        # Should use one of the fallback models, not the primary
        possible_models = [ "openai/gpt-3.5-turbo-0301", "openai/gpt-3.5-turbo", "openai/gpt-4o-mini" ]
        assert possible_models.include?(response.metadata[:model_used])
        assert response.metadata[:provider].present?
      end

      # The response should still work (2+2=4)
      assert response.message.content.include?("4")

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "applies transforms for long content" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_transforms") do
      # Generate a very long text
      long_text = "Lorem ipsum dolor sit amet. " * 1000

      prompt = OpenRouterIntegrationAgent.with(text: long_text).process_long_text
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # The summary should be much shorter than the original
      assert response.message.content.length < long_text.length / 10

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "tracks usage and costs" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_cost_tracking") do
      prompt = OpenRouterIntegrationAgent.with(message: "Hello").prompt_context
      response = prompt.generate_now

      assert_not_nil response

      # Check for usage information
      if response.respond_to?(:usage) && response.usage
        assert response.usage["prompt_tokens"].is_a?(Integer)
        assert response.usage["completion_tokens"].is_a?(Integer)
        assert response.usage["total_tokens"].is_a?(Integer)
      end

      # Check for metadata with model information from OpenRouter
      if response.respond_to?(:metadata) && response.metadata
        assert response.metadata[:model_used].present?
        assert response.metadata[:provider].present?
        # Verify we're using the expected model (gpt-4o-mini)
        assert_equal "openai/gpt-4o-mini", response.metadata[:model_used]
      end

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "includes OpenRouter headers in requests" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "app_name" => "TestApp",
      "site_url" => "https://test.example.com"
    )

    # Get the headers that would be sent
    headers = provider.send(:openrouter_headers)

    assert_equal "https://test.example.com", headers["HTTP-Referer"]
    assert_equal "TestApp", headers["X-Title"]
  end

  test "builds provider preferences correctly" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "enable_fallbacks" => true,
      "provider" => {
        "order" => [ "OpenAI", "Anthropic" ],
        "require_parameters" => true,
        "data_collection" => "deny"
      }
    )

    prefs = provider.send(:build_provider_preferences)

    assert_equal [ "OpenAI", "Anthropic" ], prefs[:order]
    assert_equal true, prefs[:require_parameters]
    assert_equal true, prefs[:allow_fallbacks]
    assert_equal "deny", prefs[:data_collection]
  end

  test "configures data collection policies" do
    # Test deny all data collection
    provider_deny = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => "deny"
    )
    prefs_deny = provider_deny.send(:build_provider_preferences)
    assert_equal "deny", prefs_deny[:data_collection]

    # Test allow all data collection (default)
    provider_allow = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )
    prefs_allow = provider_allow.send(:build_provider_preferences)
    assert_equal "allow", prefs_allow[:data_collection]

    # Test selective provider data collection
    provider_selective = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => [ "OpenAI", "Google" ]
    )
    prefs_selective = provider_selective.send(:build_provider_preferences)
    assert_equal [ "OpenAI", "Google" ], prefs_selective[:data_collection]
  end

  test "handles multimodal content correctly" do
    # Create a message with multimodal content
    message = ActiveAgent::ActionPrompt::Message.new(
      content: [
        { type: "text", text: "What's in this image?" },
        { type: "image_url", image_url: { url: "https://example.com/image.jpg" } }
      ],
      role: :user
    )

    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      messages: [ message ]
    )

    assert prompt.multimodal?
  end

  test "converts file type to image_url for OpenRouter PDF support" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Test file type conversion
    file_item = {
      type: "file",
      file: {
        file_data: "data:application/pdf;base64,JVBERi0xLj..."
      }
    }

    formatted = provider.send(:format_content_item, file_item)

    assert_equal "image_url", formatted[:type]
    assert_equal "data:application/pdf;base64,JVBERi0xLj...", formatted[:image_url][:url]
  end

  test "respects configuration hierarchy for site_url" do
    # Test with explicit site_url config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "site_url" => "https://configured.example.com"
    )

    assert_equal "https://configured.example.com", provider.instance_variable_get(:@site_url)

    # Test with default_url_options in config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "default_url_options" => {
        "host" => "fromconfig.example.com"
      }
    )

    assert_equal "fromconfig.example.com", provider.instance_variable_get(:@site_url)
  end

  test "handles rate limit information in metadata" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a mock response
    prompt = ActiveAgent::ActionPrompt::Prompt.new(message: "test")
    response = ActiveAgent::GenerationProvider::Response.new(prompt: prompt)

    headers = {
      "x-provider" => "OpenAI",
      "x-model" => "gpt-4o",
      "x-ratelimit-requests-limit" => "100",
      "x-ratelimit-requests-remaining" => "99",
      "x-ratelimit-tokens-limit" => "10000",
      "x-ratelimit-tokens-remaining" => "9500"
    }

    provider.send(:add_openrouter_metadata, response, headers)

    assert_equal "100", response.metadata[:ratelimit][:requests_limit]
    assert_equal "99", response.metadata[:ratelimit][:requests_remaining]
    assert_equal "10000", response.metadata[:ratelimit][:tokens_limit]
    assert_equal "9500", response.metadata[:ratelimit][:tokens_remaining]
  end

  test "includes plugins parameter when passed in options" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a prompt with plugins option
    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      message: "test",
      options: {
        plugins: [
          {
            id: "file-parser",
            pdf: {
              engine: "pdf-text"
            }
          }
        ]
      }
    )

    # Set the prompt on the provider
    provider.instance_variable_set(:@prompt, prompt)

    # Build parameters and verify plugins are included
    parameters = provider.send(:build_openrouter_parameters)

    assert_not_nil parameters[:plugins]
    assert_equal 1, parameters[:plugins].size
    assert_equal "file-parser", parameters[:plugins][0][:id]
    assert_equal "pdf-text", parameters[:plugins][0][:pdf][:engine]
  end
end

Data Collection Policies

OpenRouter supports configuring data collection policies to control which providers can collect and use your data for training. According to the OpenRouter documentation, you can configure this in three ways:

  1. Allow all providers (default): All providers can collect data
  2. Deny all providers: No providers can collect data
  3. Selective providers: Only specified providers can collect data

Configuration Examples

ruby
require "test_helper"
require "base64"
require "active_agent/action_prompt/message"

class OpenRouterIntegrationTest < ActiveSupport::TestCase
  setup do
    @agent = OpenRouterIntegrationAgent.new
  end

  test "analyzes image with structured output schema" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_image_analysis_structured") do
      # Use the sales chart image URL for structured analysis
      image_url = "https://raw.githubusercontent.com/activeagents/activeagent/refs/heads/main/test/fixtures/images/sales_chart.png"

      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      # Verify the structure matches our schema
      assert result.key?("description")
      assert result.key?("objects")
      assert result.key?("scene_type")
      assert result.key?("primary_colors")
      assert result["objects"].is_a?(Array)
      assert [ "indoor", "outdoor", "abstract", "document", "photo", "illustration" ].include?(result["scene_type"])
    end
  end

  test "analyzes remote image URL without structured output" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_remote_image_basic") do
      # Use a landscape image URL for basic analysis
      image_url = "https://picsum.photos/400/300"

      # For now, just use analyze_image without the structured output schema
      # We'll get a natural language description instead of JSON
      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.is_a?(String)
      assert response.message.content.length > 10
      # Since analyze_image uses structured output, we'll get JSON
      # Just verify we got a response
      # In the future, we could add a simple_analyze action without schema

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "extracts receipt data with structured output from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_receipt_extraction_local") do
      # Use the test receipt image - file exists, no conditional needed
      receipt_path = Rails.root.join("..", "..", "test", "fixtures", "images", "test_receipt.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: receipt_path).extract_receipt_data
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["merchant"]["name"], "Corner Mart"
      assert_equal result["total"]["amount"], 14.83
      assert_equal result["items"].size, 4
      result["items"].each do |item|
        assert item.key?("name")
        assert item.key?("quantity")
        assert item.key?("price")
      end
      assert_equal result["items"][0], { "name"=>"Milk", "quantity"=>1, "price"=>3.49 }
      assert_equal result["items"][1], { "name"=>"Bread", "quantity"=>1, "price"=>2.29 }
      assert_equal result["items"][2], { "name"=>"Apples", "quantity"=>1, "price"=>5.1 }
      assert_equal result["items"][3], { "name"=>"Eggs", "quantity"=>1, "price"=>2.99 }
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "handles base64 encoded images with sales chart" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_base64_sales_chart") do
      # Use the sales chart image
      chart_path = Rails.root.join("..", "..", "test", "fixtures", "images", "sales_chart.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: chart_path).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert_includes response.message.content, "(Q1, Q2, Q3, Q4), with varying heights indicating different sales amounts"

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes PDF document from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_local") do
      # Use the sample resume PDF
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")

      # Read and encode the PDF as base64 - OpenRouter accepts PDFs as image_url with data URL
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Extract information from this document and return as JSON",
        output_schema: :resume_schema
      ).analyze_pdf
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"].first, { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 }
      assert_equal result["experience"].first, { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" }

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_processing_local

  test "processes PDF from remote URL of resume no plugins" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_remote_no_plugin") do
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze the PDF",
        output_schema: :resume_schema,
        skip_plugin: true
      ).analyze_pdf

      # Remote URLs are not supported without a PDF engine plugin
      # OpenAI: Inputs by file URL are not supported for chat completions. Use the ResponsesAPI for this option.
      # https://platform.openai.com/docs/guides/pdf-files#file-urls
      # Accept either the OpenAI error directly or our wrapped error
      # Suppress ruby-openai gem's error output to STDERR
      error = assert_raises(ActiveAgent::GenerationProvider::Base::GenerationProviderError, OpenAI::Error) do
        prompt.generate_now
      end

      # Check the error message regardless of which error type was raised
      error_message = error.message
      assert_match(/Missing required parameter.*file_id/, error_message)
      assert_match(/Provider returned error|invalid_request_error/, error_message)
    end
  end

  # region pdf_native_support
  test "processes PDF with native model support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_native") do
      # Test with a model that might have native PDF support
      # Using the native engine (charged as input tokens)
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Analyze this PDF document",
        pdf_engine: "native"  # Use native engine (charged as input tokens)
      ).analyze_pdf

      # First verify the prompt has the plugins in options
      assert prompt.options[:plugins].present?, "Plugins should be present in prompt options"
      assert prompt.options[:fallback_models].present?, "Fallback models should be present in prompt options"
      assert_equal "file-parser", prompt.options[:plugins][0][:id]
      assert_equal "native", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      assert_includes response.message.content, "John Doe"

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_native_support

  test "processes PDF without any plugin for models with built-in support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_no_plugin") do
      # Test without any plugin - for models that have built-in PDF support
      pdf_url = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze this PDF document",
        skip_plugin: true  # Don't use any plugin
      ).analyze_pdf

      # Verify no plugins are included when skip_plugin is true
      assert_empty prompt.options[:plugins], "Should not have plugins when skip_plugin is true"

      response = prompt.generate_now
      raw_response = response.raw_response
      assert_equal "Google", raw_response["provider"]
      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes scanned PDF with OCR engine" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_ocr") do
      # Test with the mistral-ocr engine for scanned documents
      # Using a simple PDF that should be processable
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Extract text from this PDF.",
        output_schema: :resume_schema,
        pdf_engine: "mistral-ocr"  # OCR engine for text extraction
      ).analyze_pdf

      # Verify OCR engine is specified
      assert prompt.options[:plugins].present?, "Should have plugins for OCR"
      assert_equal "mistral-ocr", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      # MUST return valid JSON - no fallback allowed
      raw_response = response.raw_response
      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"], [ { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 } ]
      assert_equal result["experience"], [ { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" } ]

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "uses fallback models when primary fails" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_fallback_models") do
      prompt = OpenRouterIntegrationAgent.test_fallback
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # Check metadata for fallback usage
      if response.respond_to?(:metadata) && response.metadata
        # Should use one of the fallback models, not the primary
        possible_models = [ "openai/gpt-3.5-turbo-0301", "openai/gpt-3.5-turbo", "openai/gpt-4o-mini" ]
        assert possible_models.include?(response.metadata[:model_used])
        assert response.metadata[:provider].present?
      end

      # The response should still work (2+2=4)
      assert response.message.content.include?("4")

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "applies transforms for long content" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_transforms") do
      # Generate a very long text
      long_text = "Lorem ipsum dolor sit amet. " * 1000

      prompt = OpenRouterIntegrationAgent.with(text: long_text).process_long_text
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # The summary should be much shorter than the original
      assert response.message.content.length < long_text.length / 10

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "tracks usage and costs" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_cost_tracking") do
      prompt = OpenRouterIntegrationAgent.with(message: "Hello").prompt_context
      response = prompt.generate_now

      assert_not_nil response

      # Check for usage information
      if response.respond_to?(:usage) && response.usage
        assert response.usage["prompt_tokens"].is_a?(Integer)
        assert response.usage["completion_tokens"].is_a?(Integer)
        assert response.usage["total_tokens"].is_a?(Integer)
      end

      # Check for metadata with model information from OpenRouter
      if response.respond_to?(:metadata) && response.metadata
        assert response.metadata[:model_used].present?
        assert response.metadata[:provider].present?
        # Verify we're using the expected model (gpt-4o-mini)
        assert_equal "openai/gpt-4o-mini", response.metadata[:model_used]
      end

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "includes OpenRouter headers in requests" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "app_name" => "TestApp",
      "site_url" => "https://test.example.com"
    )

    # Get the headers that would be sent
    headers = provider.send(:openrouter_headers)

    assert_equal "https://test.example.com", headers["HTTP-Referer"]
    assert_equal "TestApp", headers["X-Title"]
  end

  test "builds provider preferences correctly" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "enable_fallbacks" => true,
      "provider" => {
        "order" => [ "OpenAI", "Anthropic" ],
        "require_parameters" => true,
        "data_collection" => "deny"
      }
    )

    prefs = provider.send(:build_provider_preferences)

    assert_equal [ "OpenAI", "Anthropic" ], prefs[:order]
    assert_equal true, prefs[:require_parameters]
    assert_equal true, prefs[:allow_fallbacks]
    assert_equal "deny", prefs[:data_collection]
  end

  test "configures data collection policies" do
    # Test deny all data collection
    provider_deny = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => "deny"
    )
    prefs_deny = provider_deny.send(:build_provider_preferences)
    assert_equal "deny", prefs_deny[:data_collection]

    # Test allow all data collection (default)
    provider_allow = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )
    prefs_allow = provider_allow.send(:build_provider_preferences)
    assert_equal "allow", prefs_allow[:data_collection]

    # Test selective provider data collection
    provider_selective = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => [ "OpenAI", "Google" ]
    )
    prefs_selective = provider_selective.send(:build_provider_preferences)
    assert_equal [ "OpenAI", "Google" ], prefs_selective[:data_collection]
  end

  test "handles multimodal content correctly" do
    # Create a message with multimodal content
    message = ActiveAgent::ActionPrompt::Message.new(
      content: [
        { type: "text", text: "What's in this image?" },
        { type: "image_url", image_url: { url: "https://example.com/image.jpg" } }
      ],
      role: :user
    )

    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      messages: [ message ]
    )

    assert prompt.multimodal?
  end

  test "converts file type to image_url for OpenRouter PDF support" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Test file type conversion
    file_item = {
      type: "file",
      file: {
        file_data: "data:application/pdf;base64,JVBERi0xLj..."
      }
    }

    formatted = provider.send(:format_content_item, file_item)

    assert_equal "image_url", formatted[:type]
    assert_equal "data:application/pdf;base64,JVBERi0xLj...", formatted[:image_url][:url]
  end

  test "respects configuration hierarchy for site_url" do
    # Test with explicit site_url config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "site_url" => "https://configured.example.com"
    )

    assert_equal "https://configured.example.com", provider.instance_variable_get(:@site_url)

    # Test with default_url_options in config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "default_url_options" => {
        "host" => "fromconfig.example.com"
      }
    )

    assert_equal "fromconfig.example.com", provider.instance_variable_get(:@site_url)
  end

  test "handles rate limit information in metadata" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a mock response
    prompt = ActiveAgent::ActionPrompt::Prompt.new(message: "test")
    response = ActiveAgent::GenerationProvider::Response.new(prompt: prompt)

    headers = {
      "x-provider" => "OpenAI",
      "x-model" => "gpt-4o",
      "x-ratelimit-requests-limit" => "100",
      "x-ratelimit-requests-remaining" => "99",
      "x-ratelimit-tokens-limit" => "10000",
      "x-ratelimit-tokens-remaining" => "9500"
    }

    provider.send(:add_openrouter_metadata, response, headers)

    assert_equal "100", response.metadata[:ratelimit][:requests_limit]
    assert_equal "99", response.metadata[:ratelimit][:requests_remaining]
    assert_equal "10000", response.metadata[:ratelimit][:tokens_limit]
    assert_equal "9500", response.metadata[:ratelimit][:tokens_remaining]
  end

  test "includes plugins parameter when passed in options" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a prompt with plugins option
    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      message: "test",
      options: {
        plugins: [
          {
            id: "file-parser",
            pdf: {
              engine: "pdf-text"
            }
          }
        ]
      }
    )

    # Set the prompt on the provider
    provider.instance_variable_set(:@prompt, prompt)

    # Build parameters and verify plugins are included
    parameters = provider.send(:build_openrouter_parameters)

    assert_not_nil parameters[:plugins]
    assert_equal 1, parameters[:plugins].size
    assert_equal "file-parser", parameters[:plugins][0][:id]
    assert_equal "pdf-text", parameters[:plugins][0][:pdf][:engine]
  end
end

Real-World Example: Privacy-Focused Agent

Here's a complete example of an agent configured to handle sensitive data with strict privacy controls:

ruby
generate_with :open_router,
  model: "openai/gpt-4o-mini",
  data_collection: "deny",  # Prevent all providers from collecting data
  enable_fallbacks: true,
  fallback_models: [ "openai/gpt-3.5-turbo" ]

Processing sensitive financial data:

ruby
def analyze_financial_data
  @data = params[:financial_data]
  @analysis_type = params[:analysis_type] || "summary"

  prompt(
    message: build_financial_message,
    instructions: "You are analyzing sensitive financial data. Ensure privacy and confidentiality."
  )
end

Selective provider data collection for medical records:

ruby
def process_medical_records
  # Only allow specific trusted providers to collect data
  prompt(
    message: "Analyze the following medical record: #{params[:record]}",
    instructions: "Handle medical data with utmost privacy",
    options: {
      provider: {
        data_collection: [ "OpenAI" ]  # Only OpenAI can collect this data
      }
    }
  )
end

You can configure data collection at multiple levels:

ruby
# In config/active_agent.yml
development:
  open_router:
    api_key: <%= Rails.application.credentials.dig(:open_router, :api_key) %>
    model: openai/gpt-4o
    data_collection: deny  # Deny all providers from collecting data
    require_parameters: true  # Require model providers to support all specified parameters

# Or allow specific providers only
production:
  open_router:
    api_key: <%= Rails.application.credentials.dig(:open_router, :api_key) %>
    model: openai/gpt-4o
    data_collection: ["OpenAI", "Google"]  # Only these providers can collect data
    require_parameters: false  # Allow fallback to providers that don't support all parameters

# In your agent configuration
class PrivacyFocusedAgent < ApplicationAgent
  generate_with :open_router,
    model: "openai/gpt-4o",
    data_collection: "deny",  # Override for this specific agent
    require_parameters: true  # Ensure all parameters are supported
end

Privacy Considerations

When handling sensitive data, consider setting data_collection: "deny" to ensure your data is not used for model training. This is especially important for:

  • Personal information
  • Proprietary business data
  • Medical or financial records
  • Confidential communications

TIP

The data_collection parameter respects OpenRouter's provider compliance requirements. Providers that don't comply with your data collection policy will be automatically excluded from the routing pool.

Headers and Site Configuration

OpenRouter supports custom headers for tracking and attribution:

ruby
require "test_helper"
require "base64"
require "active_agent/action_prompt/message"

class OpenRouterIntegrationTest < ActiveSupport::TestCase
  setup do
    @agent = OpenRouterIntegrationAgent.new
  end

  test "analyzes image with structured output schema" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_image_analysis_structured") do
      # Use the sales chart image URL for structured analysis
      image_url = "https://raw.githubusercontent.com/activeagents/activeagent/refs/heads/main/test/fixtures/images/sales_chart.png"

      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      # Verify the structure matches our schema
      assert result.key?("description")
      assert result.key?("objects")
      assert result.key?("scene_type")
      assert result.key?("primary_colors")
      assert result["objects"].is_a?(Array)
      assert [ "indoor", "outdoor", "abstract", "document", "photo", "illustration" ].include?(result["scene_type"])
    end
  end

  test "analyzes remote image URL without structured output" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_remote_image_basic") do
      # Use a landscape image URL for basic analysis
      image_url = "https://picsum.photos/400/300"

      # For now, just use analyze_image without the structured output schema
      # We'll get a natural language description instead of JSON
      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.is_a?(String)
      assert response.message.content.length > 10
      # Since analyze_image uses structured output, we'll get JSON
      # Just verify we got a response
      # In the future, we could add a simple_analyze action without schema

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "extracts receipt data with structured output from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_receipt_extraction_local") do
      # Use the test receipt image - file exists, no conditional needed
      receipt_path = Rails.root.join("..", "..", "test", "fixtures", "images", "test_receipt.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: receipt_path).extract_receipt_data
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["merchant"]["name"], "Corner Mart"
      assert_equal result["total"]["amount"], 14.83
      assert_equal result["items"].size, 4
      result["items"].each do |item|
        assert item.key?("name")
        assert item.key?("quantity")
        assert item.key?("price")
      end
      assert_equal result["items"][0], { "name"=>"Milk", "quantity"=>1, "price"=>3.49 }
      assert_equal result["items"][1], { "name"=>"Bread", "quantity"=>1, "price"=>2.29 }
      assert_equal result["items"][2], { "name"=>"Apples", "quantity"=>1, "price"=>5.1 }
      assert_equal result["items"][3], { "name"=>"Eggs", "quantity"=>1, "price"=>2.99 }
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "handles base64 encoded images with sales chart" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_base64_sales_chart") do
      # Use the sales chart image
      chart_path = Rails.root.join("..", "..", "test", "fixtures", "images", "sales_chart.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: chart_path).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert_includes response.message.content, "(Q1, Q2, Q3, Q4), with varying heights indicating different sales amounts"

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes PDF document from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_local") do
      # Use the sample resume PDF
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")

      # Read and encode the PDF as base64 - OpenRouter accepts PDFs as image_url with data URL
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Extract information from this document and return as JSON",
        output_schema: :resume_schema
      ).analyze_pdf
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"].first, { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 }
      assert_equal result["experience"].first, { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" }

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_processing_local

  test "processes PDF from remote URL of resume no plugins" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_remote_no_plugin") do
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze the PDF",
        output_schema: :resume_schema,
        skip_plugin: true
      ).analyze_pdf

      # Remote URLs are not supported without a PDF engine plugin
      # OpenAI: Inputs by file URL are not supported for chat completions. Use the ResponsesAPI for this option.
      # https://platform.openai.com/docs/guides/pdf-files#file-urls
      # Accept either the OpenAI error directly or our wrapped error
      # Suppress ruby-openai gem's error output to STDERR
      error = assert_raises(ActiveAgent::GenerationProvider::Base::GenerationProviderError, OpenAI::Error) do
        prompt.generate_now
      end

      # Check the error message regardless of which error type was raised
      error_message = error.message
      assert_match(/Missing required parameter.*file_id/, error_message)
      assert_match(/Provider returned error|invalid_request_error/, error_message)
    end
  end

  # region pdf_native_support
  test "processes PDF with native model support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_native") do
      # Test with a model that might have native PDF support
      # Using the native engine (charged as input tokens)
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Analyze this PDF document",
        pdf_engine: "native"  # Use native engine (charged as input tokens)
      ).analyze_pdf

      # First verify the prompt has the plugins in options
      assert prompt.options[:plugins].present?, "Plugins should be present in prompt options"
      assert prompt.options[:fallback_models].present?, "Fallback models should be present in prompt options"
      assert_equal "file-parser", prompt.options[:plugins][0][:id]
      assert_equal "native", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      assert_includes response.message.content, "John Doe"

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_native_support

  test "processes PDF without any plugin for models with built-in support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_no_plugin") do
      # Test without any plugin - for models that have built-in PDF support
      pdf_url = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze this PDF document",
        skip_plugin: true  # Don't use any plugin
      ).analyze_pdf

      # Verify no plugins are included when skip_plugin is true
      assert_empty prompt.options[:plugins], "Should not have plugins when skip_plugin is true"

      response = prompt.generate_now
      raw_response = response.raw_response
      assert_equal "Google", raw_response["provider"]
      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes scanned PDF with OCR engine" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_ocr") do
      # Test with the mistral-ocr engine for scanned documents
      # Using a simple PDF that should be processable
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Extract text from this PDF.",
        output_schema: :resume_schema,
        pdf_engine: "mistral-ocr"  # OCR engine for text extraction
      ).analyze_pdf

      # Verify OCR engine is specified
      assert prompt.options[:plugins].present?, "Should have plugins for OCR"
      assert_equal "mistral-ocr", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      # MUST return valid JSON - no fallback allowed
      raw_response = response.raw_response
      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"], [ { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 } ]
      assert_equal result["experience"], [ { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" } ]

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "uses fallback models when primary fails" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_fallback_models") do
      prompt = OpenRouterIntegrationAgent.test_fallback
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # Check metadata for fallback usage
      if response.respond_to?(:metadata) && response.metadata
        # Should use one of the fallback models, not the primary
        possible_models = [ "openai/gpt-3.5-turbo-0301", "openai/gpt-3.5-turbo", "openai/gpt-4o-mini" ]
        assert possible_models.include?(response.metadata[:model_used])
        assert response.metadata[:provider].present?
      end

      # The response should still work (2+2=4)
      assert response.message.content.include?("4")

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "applies transforms for long content" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_transforms") do
      # Generate a very long text
      long_text = "Lorem ipsum dolor sit amet. " * 1000

      prompt = OpenRouterIntegrationAgent.with(text: long_text).process_long_text
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # The summary should be much shorter than the original
      assert response.message.content.length < long_text.length / 10

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "tracks usage and costs" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_cost_tracking") do
      prompt = OpenRouterIntegrationAgent.with(message: "Hello").prompt_context
      response = prompt.generate_now

      assert_not_nil response

      # Check for usage information
      if response.respond_to?(:usage) && response.usage
        assert response.usage["prompt_tokens"].is_a?(Integer)
        assert response.usage["completion_tokens"].is_a?(Integer)
        assert response.usage["total_tokens"].is_a?(Integer)
      end

      # Check for metadata with model information from OpenRouter
      if response.respond_to?(:metadata) && response.metadata
        assert response.metadata[:model_used].present?
        assert response.metadata[:provider].present?
        # Verify we're using the expected model (gpt-4o-mini)
        assert_equal "openai/gpt-4o-mini", response.metadata[:model_used]
      end

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "includes OpenRouter headers in requests" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "app_name" => "TestApp",
      "site_url" => "https://test.example.com"
    )

    # Get the headers that would be sent
    headers = provider.send(:openrouter_headers)

    assert_equal "https://test.example.com", headers["HTTP-Referer"]
    assert_equal "TestApp", headers["X-Title"]
  end

  test "builds provider preferences correctly" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "enable_fallbacks" => true,
      "provider" => {
        "order" => [ "OpenAI", "Anthropic" ],
        "require_parameters" => true,
        "data_collection" => "deny"
      }
    )

    prefs = provider.send(:build_provider_preferences)

    assert_equal [ "OpenAI", "Anthropic" ], prefs[:order]
    assert_equal true, prefs[:require_parameters]
    assert_equal true, prefs[:allow_fallbacks]
    assert_equal "deny", prefs[:data_collection]
  end

  test "configures data collection policies" do
    # Test deny all data collection
    provider_deny = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => "deny"
    )
    prefs_deny = provider_deny.send(:build_provider_preferences)
    assert_equal "deny", prefs_deny[:data_collection]

    # Test allow all data collection (default)
    provider_allow = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )
    prefs_allow = provider_allow.send(:build_provider_preferences)
    assert_equal "allow", prefs_allow[:data_collection]

    # Test selective provider data collection
    provider_selective = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => [ "OpenAI", "Google" ]
    )
    prefs_selective = provider_selective.send(:build_provider_preferences)
    assert_equal [ "OpenAI", "Google" ], prefs_selective[:data_collection]
  end

  test "handles multimodal content correctly" do
    # Create a message with multimodal content
    message = ActiveAgent::ActionPrompt::Message.new(
      content: [
        { type: "text", text: "What's in this image?" },
        { type: "image_url", image_url: { url: "https://example.com/image.jpg" } }
      ],
      role: :user
    )

    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      messages: [ message ]
    )

    assert prompt.multimodal?
  end

  test "converts file type to image_url for OpenRouter PDF support" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Test file type conversion
    file_item = {
      type: "file",
      file: {
        file_data: "data:application/pdf;base64,JVBERi0xLj..."
      }
    }

    formatted = provider.send(:format_content_item, file_item)

    assert_equal "image_url", formatted[:type]
    assert_equal "data:application/pdf;base64,JVBERi0xLj...", formatted[:image_url][:url]
  end

  test "respects configuration hierarchy for site_url" do
    # Test with explicit site_url config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "site_url" => "https://configured.example.com"
    )

    assert_equal "https://configured.example.com", provider.instance_variable_get(:@site_url)

    # Test with default_url_options in config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "default_url_options" => {
        "host" => "fromconfig.example.com"
      }
    )

    assert_equal "fromconfig.example.com", provider.instance_variable_get(:@site_url)
  end

  test "handles rate limit information in metadata" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a mock response
    prompt = ActiveAgent::ActionPrompt::Prompt.new(message: "test")
    response = ActiveAgent::GenerationProvider::Response.new(prompt: prompt)

    headers = {
      "x-provider" => "OpenAI",
      "x-model" => "gpt-4o",
      "x-ratelimit-requests-limit" => "100",
      "x-ratelimit-requests-remaining" => "99",
      "x-ratelimit-tokens-limit" => "10000",
      "x-ratelimit-tokens-remaining" => "9500"
    }

    provider.send(:add_openrouter_metadata, response, headers)

    assert_equal "100", response.metadata[:ratelimit][:requests_limit]
    assert_equal "99", response.metadata[:ratelimit][:requests_remaining]
    assert_equal "10000", response.metadata[:ratelimit][:tokens_limit]
    assert_equal "9500", response.metadata[:ratelimit][:tokens_remaining]
  end

  test "includes plugins parameter when passed in options" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a prompt with plugins option
    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      message: "test",
      options: {
        plugins: [
          {
            id: "file-parser",
            pdf: {
              engine: "pdf-text"
            }
          }
        ]
      }
    )

    # Set the prompt on the provider
    provider.instance_variable_set(:@prompt, prompt)

    # Build parameters and verify plugins are included
    parameters = provider.send(:build_openrouter_parameters)

    assert_not_nil parameters[:plugins]
    assert_equal 1, parameters[:plugins].size
    assert_equal "file-parser", parameters[:plugins][0][:id]
    assert_equal "pdf-text", parameters[:plugins][0][:pdf][:engine]
  end
end

Model Capabilities Detection

The provider automatically detects model capabilities:

ruby
require "test_helper"
require "base64"
require "active_agent/action_prompt/message"

class OpenRouterIntegrationTest < ActiveSupport::TestCase
  setup do
    @agent = OpenRouterIntegrationAgent.new
  end

  test "analyzes image with structured output schema" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_image_analysis_structured") do
      # Use the sales chart image URL for structured analysis
      image_url = "https://raw.githubusercontent.com/activeagents/activeagent/refs/heads/main/test/fixtures/images/sales_chart.png"

      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      # Verify the structure matches our schema
      assert result.key?("description")
      assert result.key?("objects")
      assert result.key?("scene_type")
      assert result.key?("primary_colors")
      assert result["objects"].is_a?(Array)
      assert [ "indoor", "outdoor", "abstract", "document", "photo", "illustration" ].include?(result["scene_type"])
    end
  end

  test "analyzes remote image URL without structured output" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_remote_image_basic") do
      # Use a landscape image URL for basic analysis
      image_url = "https://picsum.photos/400/300"

      # For now, just use analyze_image without the structured output schema
      # We'll get a natural language description instead of JSON
      prompt = OpenRouterIntegrationAgent.with(image_url: image_url).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.is_a?(String)
      assert response.message.content.length > 10
      # Since analyze_image uses structured output, we'll get JSON
      # Just verify we got a response
      # In the future, we could add a simple_analyze action without schema

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "extracts receipt data with structured output from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_receipt_extraction_local") do
      # Use the test receipt image - file exists, no conditional needed
      receipt_path = Rails.root.join("..", "..", "test", "fixtures", "images", "test_receipt.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: receipt_path).extract_receipt_data
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["merchant"]["name"], "Corner Mart"
      assert_equal result["total"]["amount"], 14.83
      assert_equal result["items"].size, 4
      result["items"].each do |item|
        assert item.key?("name")
        assert item.key?("quantity")
        assert item.key?("price")
      end
      assert_equal result["items"][0], { "name"=>"Milk", "quantity"=>1, "price"=>3.49 }
      assert_equal result["items"][1], { "name"=>"Bread", "quantity"=>1, "price"=>2.29 }
      assert_equal result["items"][2], { "name"=>"Apples", "quantity"=>1, "price"=>5.1 }
      assert_equal result["items"][3], { "name"=>"Eggs", "quantity"=>1, "price"=>2.99 }
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "handles base64 encoded images with sales chart" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_base64_sales_chart") do
      # Use the sales chart image
      chart_path = Rails.root.join("..", "..", "test", "fixtures", "images", "sales_chart.png")

      prompt = OpenRouterIntegrationAgent.with(image_path: chart_path).analyze_image
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert_includes response.message.content, "(Q1, Q2, Q3, Q4), with varying heights indicating different sales amounts"

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes PDF document from local file" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_local") do
      # Use the sample resume PDF
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")

      # Read and encode the PDF as base64 - OpenRouter accepts PDFs as image_url with data URL
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Extract information from this document and return as JSON",
        output_schema: :resume_schema
      ).analyze_pdf
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"].first, { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 }
      assert_equal result["experience"].first, { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" }

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_processing_local

  test "processes PDF from remote URL of resume no plugins" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_remote_no_plugin") do
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze the PDF",
        output_schema: :resume_schema,
        skip_plugin: true
      ).analyze_pdf

      # Remote URLs are not supported without a PDF engine plugin
      # OpenAI: Inputs by file URL are not supported for chat completions. Use the ResponsesAPI for this option.
      # https://platform.openai.com/docs/guides/pdf-files#file-urls
      # Accept either the OpenAI error directly or our wrapped error
      # Suppress ruby-openai gem's error output to STDERR
      error = assert_raises(ActiveAgent::GenerationProvider::Base::GenerationProviderError, OpenAI::Error) do
        prompt.generate_now
      end

      # Check the error message regardless of which error type was raised
      error_message = error.message
      assert_match(/Missing required parameter.*file_id/, error_message)
      assert_match(/Provider returned error|invalid_request_error/, error_message)
    end
  end

  # region pdf_native_support
  test "processes PDF with native model support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_native") do
      # Test with a model that might have native PDF support
      # Using the native engine (charged as input tokens)
      pdf_path = Rails.root.join("..", "..", "test", "fixtures", "files", "sample_resume.pdf")
      pdf_data = Base64.strict_encode64(File.read(pdf_path))

      prompt = OpenRouterIntegrationAgent.with(
        pdf_data: pdf_data,
        prompt_text: "Analyze this PDF document",
        pdf_engine: "native"  # Use native engine (charged as input tokens)
      ).analyze_pdf

      # First verify the prompt has the plugins in options
      assert prompt.options[:plugins].present?, "Plugins should be present in prompt options"
      assert prompt.options[:fallback_models].present?, "Fallback models should be present in prompt options"
      assert_equal "file-parser", prompt.options[:plugins][0][:id]
      assert_equal "native", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      assert_includes response.message.content, "John Doe"

      # Generate documentation example
      doc_example_output(response)
    end
  end
  # endregion pdf_native_support

  test "processes PDF without any plugin for models with built-in support" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_no_plugin") do
      # Test without any plugin - for models that have built-in PDF support
      pdf_url = "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Analyze this PDF document",
        skip_plugin: true  # Don't use any plugin
      ).analyze_pdf

      # Verify no plugins are included when skip_plugin is true
      assert_empty prompt.options[:plugins], "Should not have plugins when skip_plugin is true"

      response = prompt.generate_now
      raw_response = response.raw_response
      assert_equal "Google", raw_response["provider"]
      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?
      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "processes scanned PDF with OCR engine" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_pdf_ocr") do
      # Test with the mistral-ocr engine for scanned documents
      # Using a simple PDF that should be processable
      pdf_url = "https://docs.activeagents.ai/sample_resume.pdf"

      prompt = OpenRouterIntegrationAgent.with(
        pdf_url: pdf_url,
        prompt_text: "Extract text from this PDF.",
        output_schema: :resume_schema,
        pdf_engine: "mistral-ocr"  # OCR engine for text extraction
      ).analyze_pdf

      # Verify OCR engine is specified
      assert prompt.options[:plugins].present?, "Should have plugins for OCR"
      assert_equal "mistral-ocr", prompt.options[:plugins][0][:pdf][:engine]

      response = prompt.generate_now

      # MUST return valid JSON - no fallback allowed
      raw_response = response.raw_response
      # When output_schema is present, content is already parsed
      result = response.message.content

      assert_equal result["name"], "John Doe"
      assert_equal result["email"], "john.doe@example.com"
      assert_equal result["phone"], "(555) 123-4567"
      assert_equal result["education"], [ { "degree"=>"BS Computer Science", "institution"=>"Stanford University", "year"=>2020 } ]
      assert_equal result["experience"], [ { "job_title"=>"Senior Software Engineer", "company"=>"TechCorp", "duration"=>"2020-2024" } ]

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "uses fallback models when primary fails" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_fallback_models") do
      prompt = OpenRouterIntegrationAgent.test_fallback
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message

      # Check metadata for fallback usage
      if response.respond_to?(:metadata) && response.metadata
        # Should use one of the fallback models, not the primary
        possible_models = [ "openai/gpt-3.5-turbo-0301", "openai/gpt-3.5-turbo", "openai/gpt-4o-mini" ]
        assert possible_models.include?(response.metadata[:model_used])
        assert response.metadata[:provider].present?
      end

      # The response should still work (2+2=4)
      assert response.message.content.include?("4")

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "applies transforms for long content" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_transforms") do
      # Generate a very long text
      long_text = "Lorem ipsum dolor sit amet. " * 1000

      prompt = OpenRouterIntegrationAgent.with(text: long_text).process_long_text
      response = prompt.generate_now

      assert_not_nil response
      assert_not_nil response.message
      assert response.message.content.present?

      # The summary should be much shorter than the original
      assert response.message.content.length < long_text.length / 10

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "tracks usage and costs" do
    skip "Requires actual OpenRouter API key and credits" unless has_openrouter_credentials?

    VCR.use_cassette("openrouter_cost_tracking") do
      prompt = OpenRouterIntegrationAgent.with(message: "Hello").prompt_context
      response = prompt.generate_now

      assert_not_nil response

      # Check for usage information
      if response.respond_to?(:usage) && response.usage
        assert response.usage["prompt_tokens"].is_a?(Integer)
        assert response.usage["completion_tokens"].is_a?(Integer)
        assert response.usage["total_tokens"].is_a?(Integer)
      end

      # Check for metadata with model information from OpenRouter
      if response.respond_to?(:metadata) && response.metadata
        assert response.metadata[:model_used].present?
        assert response.metadata[:provider].present?
        # Verify we're using the expected model (gpt-4o-mini)
        assert_equal "openai/gpt-4o-mini", response.metadata[:model_used]
      end

      # Generate documentation example
      doc_example_output(response)
    end
  end

  test "includes OpenRouter headers in requests" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "app_name" => "TestApp",
      "site_url" => "https://test.example.com"
    )

    # Get the headers that would be sent
    headers = provider.send(:openrouter_headers)

    assert_equal "https://test.example.com", headers["HTTP-Referer"]
    assert_equal "TestApp", headers["X-Title"]
  end

  test "builds provider preferences correctly" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "enable_fallbacks" => true,
      "provider" => {
        "order" => [ "OpenAI", "Anthropic" ],
        "require_parameters" => true,
        "data_collection" => "deny"
      }
    )

    prefs = provider.send(:build_provider_preferences)

    assert_equal [ "OpenAI", "Anthropic" ], prefs[:order]
    assert_equal true, prefs[:require_parameters]
    assert_equal true, prefs[:allow_fallbacks]
    assert_equal "deny", prefs[:data_collection]
  end

  test "configures data collection policies" do
    # Test deny all data collection
    provider_deny = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => "deny"
    )
    prefs_deny = provider_deny.send(:build_provider_preferences)
    assert_equal "deny", prefs_deny[:data_collection]

    # Test allow all data collection (default)
    provider_allow = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )
    prefs_allow = provider_allow.send(:build_provider_preferences)
    assert_equal "allow", prefs_allow[:data_collection]

    # Test selective provider data collection
    provider_selective = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "data_collection" => [ "OpenAI", "Google" ]
    )
    prefs_selective = provider_selective.send(:build_provider_preferences)
    assert_equal [ "OpenAI", "Google" ], prefs_selective[:data_collection]
  end

  test "handles multimodal content correctly" do
    # Create a message with multimodal content
    message = ActiveAgent::ActionPrompt::Message.new(
      content: [
        { type: "text", text: "What's in this image?" },
        { type: "image_url", image_url: { url: "https://example.com/image.jpg" } }
      ],
      role: :user
    )

    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      messages: [ message ]
    )

    assert prompt.multimodal?
  end

  test "converts file type to image_url for OpenRouter PDF support" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Test file type conversion
    file_item = {
      type: "file",
      file: {
        file_data: "data:application/pdf;base64,JVBERi0xLj..."
      }
    }

    formatted = provider.send(:format_content_item, file_item)

    assert_equal "image_url", formatted[:type]
    assert_equal "data:application/pdf;base64,JVBERi0xLj...", formatted[:image_url][:url]
  end

  test "respects configuration hierarchy for site_url" do
    # Test with explicit site_url config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "site_url" => "https://configured.example.com"
    )

    assert_equal "https://configured.example.com", provider.instance_variable_get(:@site_url)

    # Test with default_url_options in config
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o",
      "default_url_options" => {
        "host" => "fromconfig.example.com"
      }
    )

    assert_equal "fromconfig.example.com", provider.instance_variable_get(:@site_url)
  end

  test "handles rate limit information in metadata" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a mock response
    prompt = ActiveAgent::ActionPrompt::Prompt.new(message: "test")
    response = ActiveAgent::GenerationProvider::Response.new(prompt: prompt)

    headers = {
      "x-provider" => "OpenAI",
      "x-model" => "gpt-4o",
      "x-ratelimit-requests-limit" => "100",
      "x-ratelimit-requests-remaining" => "99",
      "x-ratelimit-tokens-limit" => "10000",
      "x-ratelimit-tokens-remaining" => "9500"
    }

    provider.send(:add_openrouter_metadata, response, headers)

    assert_equal "100", response.metadata[:ratelimit][:requests_limit]
    assert_equal "99", response.metadata[:ratelimit][:requests_remaining]
    assert_equal "10000", response.metadata[:ratelimit][:tokens_limit]
    assert_equal "9500", response.metadata[:ratelimit][:tokens_remaining]
  end

  test "includes plugins parameter when passed in options" do
    provider = ActiveAgent::GenerationProvider::OpenRouterProvider.new(
      "model" => "openai/gpt-4o"
    )

    # Create a prompt with plugins option
    prompt = ActiveAgent::ActionPrompt::Prompt.new(
      message: "test",
      options: {
        plugins: [
          {
            id: "file-parser",
            pdf: {
              engine: "pdf-text"
            }
          }
        ]
      }
    )

    # Set the prompt on the provider
    provider.instance_variable_set(:@prompt, prompt)

    # Build parameters and verify plugins are included
    parameters = provider.send(:build_openrouter_parameters)

    assert_not_nil parameters[:plugins]
    assert_equal 1, parameters[:plugins].size
    assert_equal "file-parser", parameters[:plugins][0][:id]
    assert_equal "pdf-text", parameters[:plugins][0][:pdf][:engine]
  end
end

Important Notes

Model Compatibility

When using OpenRouter's advanced features, ensure your chosen model supports the required capabilities:

  • Structured Output: Requires models like openai/gpt-4o, openai/gpt-4o-mini, or other OpenAI models with structured output support
  • Vision/Image Analysis: Requires vision-capable models like GPT-4o, Claude 3, or Gemini Pro Vision
  • PDF Processing: May require specific plugins or engines depending on the model and document type

For tasks requiring both vision and structured output (like receipt extraction), use models that support both capabilities, such as:

  • openai/gpt-4o
  • openai/gpt-4o-mini

See Also