ServicesAI Services

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.