Adding Providers

This guide walks through integrating a new upstream API provider into ToolRouter. A provider is any external API that executes tool skills — image generation, search, transcription, etc.

Overview

Adding a provider touches 6 files:

FilePurpose
src/tools/shared/<provider>-client.tsAPI client (HTTP calls, response parsing, file downloads)
src/core/provider-catalog.tsDisplay metadata for /v1/providers endpoints
src/tools/<tool>/models.tsModel registry entries (if the provider serves models)
src/tools/<tool>/index.tsTool requirement + changelog
src/tools/<tool>/skills/<skill>.tsDispatch path in the handler
.env.localLocal API key for development

Step 1: Create the API client

Create src/tools/shared/<provider>-client.ts. Every client follows the same pattern:

typescript
import { writeFile } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { randomUUID } from 'crypto';
import type { SkillContext } from '../../core/types.js';
import { CircuitBreaker } from '../../core/circuit-breaker.js';

const BASE_URL = 'https://api.example.com/v1';
const REQUEST_TIMEOUT_MS = 120_000;

export const exampleBreaker = new CircuitBreaker({
  name: 'example',
  threshold: 3,
  resetMs: 60_000,
});

API patterns

Providers fall into three patterns:

PatternDescriptionExamples
SynchronousPOST → immediate resultPhotalabs, Prodia
Async queueSubmit → poll status → fetch resultfal.ai, Higgsfield
StreamingPOST → stream chunks(not yet used)

Required conventions

  • Circuit breaker: Wrap all calls in providerBreaker.call(async () => { ... }) — trips after 3 consecutive failures, resets after 60s
  • Timeout: Use AbortSignal.timeout(REQUEST_TIMEOUT_MS) on all fetch calls
  • Download to /tmp: Provider URLs are ephemeral — always download media to local /tmp and return the path. The asset system picks up *_path keys and uploads to permanent storage
  • File naming: Use <provider>_<type>_<id>.<ext> pattern (e.g. phota_gen_a1b2c3d4.png)
  • Error handling: Throw on failure — never swallow errors. Include the HTTP status and response body in error messages
  • Logging: Use context.log() for operational logging (goes to stderr in stdio mode)

Auth patterns

ProviderHeader formatEnv var
fal.aiAuthorization: Key ${key}FAL_KEY
ProdiaAuthorization: Bearer ${token}PRODIA_TOKEN
HiggsfieldAuthorization: Key ${keyId}:${keySecret}HIGGSFIELD_API_KEY
PhotalabsX-API-Key: ${key}PHOTA_API_KEY

Return types

Export a result interface that includes:

typescript
export interface ExampleResult {
  image_path: string;    // Local /tmp path (required for asset system)
  job_id: string;        // Provider request ID or synthetic UUID
  content_type: string;  // MIME type (e.g. 'image/png')
}

For providers returning multiple images, use image_paths: string[] instead.

Step 2: Register in provider catalog

Add display metadata to src/core/provider-catalog.ts:

typescript
export const PROVIDER_DISPLAY: Record<string, ProviderDisplay> = {
  // ... existing providers
  example: {
    displayName: 'Example',
    description: 'What this provider does — keep it concise.',
    website: 'https://example.com',
  },
};

This makes the provider appear on /v1/providers endpoints and the website's provider pages.

Step 3: Add model entries (if applicable)

If the provider serves models (image gen, video gen, etc.), add entries to the relevant models.ts:

  1. Add the provider to the ModelProvider union type:
typescript
   export type ModelProvider = 'fal' | 'prodia' | 'higgsfield' | 'example';
  1. Add model entries to MODEL_REGISTRY:
typescript
   'example-model': {
     key: 'example-model',
     displayName: 'Example Model',
     falId: 'endpoint-id',       // Provider-specific endpoint identifier
     provider: 'example',        // Routes to your dispatch path
     capabilities: ['text-to-image'],
     pricing: { amount: 0.05, unit: 'per-image' },
     maxImages: 4,
     description: 'What this model is good at.',
   },

The provider field is what routes the model to your dispatch path in the handler. The falId field stores the provider-specific model/endpoint identifier.

Step 4: Add tool requirement

In the tool's index.ts, add a requirement entry so the credential system knows about the provider:

typescript
requirements: [
  // ... existing requirements
  {
    name: 'example',
    type: 'secret',
    displayName: 'Example API Key',
    description: 'Optional: use your own Example key instead of the platform default',
    acquireUrl: 'https://example.com/api-keys',
    envFallback: 'EXAMPLE_API_KEY',
  },
],

Also add a changelog entry:

typescript
changelog: [
  // ... existing entries
  { date: '2026-03-27', changes: ['Added Example as a provider — X new models'] },
],

Step 5: Add dispatch path in handler

In the skill handler (e.g. text-to-image.ts), add a provider dispatch block. Place it before the default fal.ai path:

typescript
import { exampleGenerate } from '../../shared/example-client.js';

// Inside the handler, after model resolution:

// ---------------------------------------------------------------------------
// Example provider path
// ---------------------------------------------------------------------------
if (model?.provider === 'example') {
  const unavailable = requireKeyOrUnavailable(context, 'example', 'EXAMPLE_API_KEY');
  if (unavailable) return unavailable;
  const apiKey = resolveKey(context, 'example', 'EXAMPLE_API_KEY')!;

  // Build provider-specific params from input
  // Call provider client
  // Calculate cost
  // Return { output, usage: { raw_cost } }
}

Dispatch order

The handler checks providers in order:

  1. Prodia (explicit provider === 'prodia' or inference.* prefix)
  2. Higgsfield (explicit provider === 'higgsfield')
  3. Photalabs (explicit provider === 'phota')
  4. fal.ai (default fallback)

Add your provider before the fal.ai default path.

Output structure

All providers must return the same output shape:

typescript
const output: Record<string, unknown> = {
  format: 'gallery',
  format_data: { images: [{ url, title }] },
  images: [{ url, width, height, content_type }],
  model: modelName,
  model_key: model.key,
  seed: null,
  request_id: result.job_id,
  num_images: count,
  image_path: result.image_paths[0],  // First image for asset system
};

// Multi-image: additional paths
for (let i = 1; i < result.image_paths.length; i++) {
  output[`image_${i + 1}_path`] = result.image_paths[i];
}

return { output, usage: { raw_cost: calculatedCost } };

Step 6: Set up credentials

Local development

Add the API key to .env.local:

EXAMPLE_API_KEY=your-key-here

Production (Railway)

bash
railway variables --set "EXAMPLE_API_KEY=your-key-here" --service ToolRouter
railway variables --set "EXAMPLE_API_KEY=your-key-here" --service Worker

Pricing strategies

How you calculate raw_cost depends on the provider:

StrategyWhen to useExample
Live API pricingProvider returns cost in responseProdia (result.price.dollars)
Pre-execution pricingProvider has a pricing APIfal.ai (fetch /v1/models/pricing before billing)
Fixed pricingProvider has static documented ratesHiggsfield ($0.09/720p), Photalabs ($0.09/1K)

Rule: Never silently fall back to $0 if pricing is unavailable. Either throw an error or use a static rate from the provider's documentation. The handler must always return usage: { raw_cost }.

Checklist

  • [ ] Client file in src/tools/shared/ with circuit breaker, timeouts, and /tmp downloads
  • [ ] Provider entry in src/core/provider-catalog.ts
  • [ ] ModelProvider type updated in models.ts
  • [ ] Model entries added to MODEL_REGISTRY
  • [ ] Tool requirement added in index.ts
  • [ ] Changelog entry added in index.ts
  • [ ] Dispatch path added in handler(s) before the fal.ai default
  • [ ] API key added to .env.local and production env vars
  • [ ] npm run build passes
  • [ ] providers.md doc updated with new provider details