AIChannel and Provider Adapters

Connect upstream models through Downcity Model Protocol.

AIChannel owns only Federation's upstream execution boundary. It accepts AIChannelStreamInput and returns a ReadableStream<ModelStreamEvent>. Fallback, billing, Sessions, and tool execution do not belong to the Channel.

OpenAI-compatible upstream

import {
  AIChannel,
  AIService,
  stream_openai_compatible_model,
  type AIChannelStreamInput,
  type AIChannelStreamResult,
} from "@downcity/federation";

class DeepSeekChannel extends AIChannel {
  constructor() {
    super({
      id: "deepseek",
      env: { DEEPSEEK_API_KEY: "DeepSeek API Key" },
      base_url: "https://api.deepseek.com/v1",
    });
  }

  protected async stream(
    input: AIChannelStreamInput,
  ): Promise<AIChannelStreamResult> {
    const api_key = input.env("DEEPSEEK_API_KEY");
    if (!api_key) throw new Error("DEEPSEEK_API_KEY is required");
    return stream_openai_compatible_model(input, {
      api_key,
      base_url: this.base_url ?? "https://api.deepseek.com/v1",
    });
  }
}

const channel = new DeepSeekChannel();
const ai = new AIService();
ai.use(channel.model({
  id: "deepseek-v4-flash",
  upstream_model: "deepseek-chat",
  name: "DeepSeek V4 Flash",
  context_window: 128_000,
}));
federation.use(ai);

stream_openai_compatible_model() is a boundary adapter. It maps ModelCall to Chat Completions and converts upstream SSE into Downcity events. A private vendor protocol should provide its own adapter with the same input and output contract.

In-process ModelClient

For local Agent use without Federation:

const model = create_openai_compatible_model({
  id: "deepseek-chat",
  upstream_model: "deepseek-chat",
  base_url: "https://api.deepseek.com/v1",
  api_key: process.env.DEEPSEEK_API_KEY!,
});

Reasoning and fallback

ai.use(channel.model({
  id: "text-model",
  upstream_model: "vendor-text-model",
  name: "Text Model",
  reasoning: {
    efforts: [{ id: "high", name: "High" }],
    default_effort: "high",
  },
  fallback: [{
    match: (file) => file.media_type.startsWith("image/"),
    model_id: "vision-model",
  }],
}));

AIService passes only files from the latest user message to fallback rules. After selecting the final model, it validates reasoning and preserves that latest user message unchanged. Historical files matched by the final model's fallback rules become filename, original URL, or MIME type text before the call reaches the target Channel.

Channel and model provider_options are merged only on the server and interpreted by the selected Adapter. They are not part of the public model protocol.

Decision models (System One)

A model that returns typed decisions instead of generated text implements decision() instead of stream():

import {
  AIChannel,
  AIService,
  type AIChannelActionInput,
  type AIChargedResult,
  type AIDecisionResult,
} from "@downcity/federation";

class JevChannel extends AIChannel {
  constructor() {
    super({ id: "typesafe", env: { TYPESAFE_API_KEY: "TypeSafe API Key" } });
  }

  async decision(input: AIChannelActionInput): Promise<AIChargedResult<AIDecisionResult>> {
    const api_key = input.env("TYPESAFE_API_KEY");
    if (!api_key) throw new Error("TYPESAFE_API_KEY is required");
    return { output: await call_jev(api_key, input.input) };
  }
}

const ai = new AIService();
ai.use(new JevChannel().model({
  id: "jev-1.13",
  upstream_model: "typesafe/jev-1.13",
  name: "Jev",
  pricing: { currency: "USD", unit: "request", rates: { request: 0.0004 } },
}));
federation.use(ai);

Federation exposes POST /v1/ai/decision automatically and validates the request before the call reaches the Channel. The catalog reports model_type: "decision" with modalities: ["decision"], and because the model does not implement stream(), it is not offered as an Agent conversation model.

Metering and billing reuse the same path as every other capability: return usage from the Channel to record usage, and declare pricing to charge per request.