Z ZPI 文档 v0.3.8
介绍页

源码

packages/coding-agent/src/core/model-runtime.ts

typescript196 行查看原文
import type {
  Api,
  AssistantMessageEventStream,
  Context,
  MediaBudgetStats,
  Model,
  ResponsesSessionDiagnostic,
  ResponsesSessionOptions,
  SimpleStreamOptions,
  TranscriptContext,
} from "@zpi-dev/ai";
import {
  assertSupportedOptions,
  guardModelStream,
  normalizeContext,
  projectContextForModelMediaPolicy,
  streamSimple,
  validateReasoningConfig,
  validateThinkingMap,
} from "@zpi-dev/ai";
import { supportsResponsesWebSocket } from "@zpi-dev/ai/api/responses-session";
export interface ProviderChatModelConfig extends Omit<Model, "provider" | "api" | "baseUrl"> {
  type?: "chat";
  api?: Api;
}
export interface ProviderConfigInput {
  name?: string;
  baseUrl?: string;
  api?: Api;
  apiKey?: string;
  /** A rejected key requests one credential recovery; other callers resolve normally. */
  getApiKey?: (rejectedApiKey?: string) => Promise<string>;
  headers?: Record<string, string>;
  models?: ProviderChatModelConfig[];
  streamSimple?: (
    model: Model,
    context: TranscriptContext,
    options?: SimpleStreamOptions,
  ) => AssistantMessageEventStream;
}
export interface MediaProjectionContext {
  sessionId?: string;
  provider: string;
  modelId: string;
}
export interface ModelRuntimeOptions {
  fetch?: typeof fetch;
  /** Custom fetch implementations use HTTP unless the host explicitly enables WebSocket connections. */
  responsesTransport?: "auto" | "http";
  responsesProxy?: ResponsesSessionOptions["resolveProxy"];
  /** Optional local diagnostics; the SDK has no dependency on a logging backend. */
  onMediaProjection?: (stats: MediaBudgetStats, context: MediaProjectionContext) => void;
  onResponsesDiagnostic?: (diagnostic: ResponsesSessionDiagnostic, context: MediaProjectionContext) => void;
}
export class ModelRuntime {
  private providers = new Map<string, ProviderConfigInput>();
  private keys = new Map<string, string>();
  private requestFetch?: typeof fetch;
  private onMediaProjection?: ModelRuntimeOptions["onMediaProjection"];
  private onResponsesDiagnostic?: ModelRuntimeOptions["onResponsesDiagnostic"];
  private responsesTransport: "auto" | "http";
  readonly responsesProxy?: ResponsesSessionOptions["resolveProxy"];
  constructor(requestFetch?: typeof fetch, options: ModelRuntimeOptions = {}) {
    this.requestFetch = requestFetch ?? options.fetch;
    this.responsesTransport = options.responsesTransport ?? (this.requestFetch ? "http" : "auto");
    this.responsesProxy = options.responsesProxy;
    this.onMediaProjection = options.onMediaProjection;
    this.onResponsesDiagnostic = options.onResponsesDiagnostic;
  }
  get fetch(): typeof fetch {
    return this.requestFetch ?? globalThis.fetch;
  }
  canUseResponsesSession(model: Model): boolean {
    return (
      this.responsesTransport === "auto" &&
      supportsResponsesWebSocket(model) &&
      !this.providers.get(model.provider)?.streamSimple
    );
  }
  static async create(options: ModelRuntimeOptions = {}): Promise<ModelRuntime> {
    return new ModelRuntime(options.fetch, options);
  }
  mediaProjectionObserver(model: Model, sessionId?: string): SimpleStreamOptions["onMediaProjection"] {
    const report = this.onMediaProjection;
    return report
      ? (stats) => report(stats, { sessionId, provider: model.provider, modelId: model.id })
      : undefined;
  }
  responsesDiagnosticObserver(model: Model, sessionId?: string): ResponsesSessionOptions["onDiagnostic"] {
    const report = this.onResponsesDiagnostic;
    return report
      ? (diagnostic) => report(diagnostic, { sessionId, provider: model.provider, modelId: model.id })
      : undefined;
  }
  clearProviders(): void {
    this.providers.clear();
    this.keys.clear();
  }
  registerProvider(id: string, config: ProviderConfigInput): void {
    assertSupportedOptions(
      config,
      ["name", "baseUrl", "api", "apiKey", "getApiKey", "headers", "models", "streamSimple"],
      "provider",
    );
    for (const model of config.models ?? []) {
      assertSupportedOptions(
        model,
        [
          "type",
          "id",
          "name",
          "api",
          "auth",
          "input",
          "cost",
          "reasoning",
          "contextWindow",
          "maxTokens",
          "headers",
          "compat",
          "thinkingLevelMap",
          "reasoningConfig",
          "defaultThinkingLevel",
          "samplingParams",
        ],
        "model",
      );
      if (model.type && model.type !== "chat") throw new Error("Only chat models are supported");
      if (model.thinkingLevelMap) validateThinkingMap(model.thinkingLevelMap);
      if (model.reasoningConfig) validateReasoningConfig(model.reasoningConfig);
    }
    const old = this.providers.get(id);
    this.providers.set(id, { ...old, ...config, headers: config.headers ?? old?.headers });
    if (config.apiKey !== undefined) this.keys.set(id, config.apiKey);
  }
  getModel(provider: string, id: string): Model | undefined {
    const p = this.providers.get(provider);
    const model = p?.models?.find((m) => m.id === id);
    if (!p || !model) return undefined;
    const { type: _, ...fields } = model;
    return {
      ...fields,
      api: model.api ?? p.api ?? "openai-completions",
      provider,
      baseUrl: p.baseUrl ?? "",
      headers: { ...p.headers, ...model.headers },
    };
  }
  async getAvailable(): Promise<readonly Model[]> {
    const models: Model[] = [];
    for (const [id, p] of this.providers)
      if (p.streamSimple || p.getApiKey || this.keys.has(id))
        for (const m of p.models ?? []) {
          const model = this.getModel(id, m.id);
          if (model) models.push(model);
        }
    return models;
  }
  async setRuntimeApiKey(provider: string, key: string): Promise<void> {
    this.keys.set(provider, key);
  }
  streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
    const p = this.providers.get(model.provider);
    if (!p) throw new Error(`Provider not configured: ${model.provider}`);
    const transcript = normalizeContext(context);
    const opts = {
      ...options,
      fetch: options?.fetch ?? this.requestFetch,
      apiKey: options?.apiKey ?? this.keys.get(model.provider),
      onMediaProjection:
        options?.onMediaProjection ?? this.mediaProjectionObserver(model, options?.sessionId),
    };
    const customStream = p.streamSimple;
    if (customStream)
      return guardModelStream(
        model,
        async (guarded) => {
          const apiKey = p.getApiKey && options?.apiKey === undefined ? await p.getApiKey() : guarded.apiKey;
          guarded.signal?.throwIfAborted();
          return customStream(
            model,
            projectContextForModelMediaPolicy(transcript, model, { onProjection: opts.onMediaProjection }),
            { ...guarded, apiKey },
          );
        },
        opts,
      );
    // Native protocols own their request boundary, including direct API callers.
    // Only custom providers need projection here; the common stream wrapper does not repeat it.
    return streamSimple(model, transcript, {
      ...opts,
      ...(p.getApiKey && options?.apiKey === undefined ? { getApiKey: p.getApiKey } : {}),
    });
  }
}