blob: 089672f07e4635332d7ad5bbea3c9db3bfe212ef [file]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// The Protocol layer: the code-behind that knows how to talk to a *kind* of
// LLM API (gemini, anthropic, openai-compatible, ...). A protocol is provided
// by a plugin and registered with the gateway; one protocol backs many
// providers (see provider.ts). The rest of the gateway, and consumers like the
// Intelletto assistant, only ever deal in the neutral request/response shapes
// defined here - they never see a backend's native wire format.
import type {z} from 'zod';
// --- Neutral conversation shapes ---------------------------------------------
// A neutral tool definition, as the model sees it. The Protocol is responsible
// for down-converting `inputSchema` into whatever subset of JSON Schema the
// backend accepts.
export interface ToolDef {
readonly name: string;
readonly description: string;
// Authored in zod; the protocol converts to the backend's native schema.
readonly inputSchema: z.ZodType;
}
// One model->user message asking to invoke a tool.
export interface ToolCall {
// Backend-specific id used to thread the result back to this call. Some
// backends (Gemini) key on the tool name instead of an id; protocols that
// don't need an id leave it undefined.
readonly id?: string;
readonly name: string;
readonly args: Record<string, unknown>;
// Opaque, protocol-private blob the consumer must carry through history and
// hand back unchanged when re-sending this call. Used by backends that
// require state to be echoed verbatim on the next request - e.g. Gemini's
// `thoughtSignature`, which the API rejects requests for if dropped. Other
// protocols leave it undefined and ignore it.
readonly signature?: string;
}
// The user->model result of a previously-requested tool call.
export interface ToolResult {
readonly id?: string;
readonly name: string;
// The serialised tool output (or an error string the model can recover from).
readonly result: string;
readonly isError?: boolean;
}
// One entry in the conversation history. The consumer owns the history and
// resends it on every request (LLM endpoints are stateless).
export type Message =
| {readonly role: 'user'; readonly text: string}
| {readonly role: 'model'; readonly text: string}
| {readonly role: 'tool-call'; readonly calls: readonly ToolCall[]}
| {
readonly role: 'tool-result';
readonly results: readonly ToolResult[];
};
// A complete request handed to the protocol. Everything the backend needs to
// produce the next turn.
export interface Request {
readonly systemPrompt: string;
readonly messages: readonly Message[];
readonly tools: readonly ToolDef[];
}
// --- Streamed response events ------------------------------------------------
// Token usage, normalised across backends. Any field a backend doesn't report
// is left undefined.
export interface TokenUsage {
readonly inputTokens?: number;
readonly outputTokens?: number;
readonly totalTokens?: number;
}
// Why a turn stopped. Backends report a zoo of finish reasons; we collapse them
// into this set.
export type StopReason =
| 'end' // The model finished its turn normally.
| 'tool-calls' // The model wants to call tools and is waiting on results.
| 'length' // Hit the output-token limit.
| 'error'; // Aborted by a backend error (see NeutralError).
// Normalised backend error categories. Surfaced to the user in the chat.
export type ErrorKind =
| 'rate-limit'
| 'auth'
| 'context-length'
| 'network'
| 'unknown';
export interface Error {
readonly kind: ErrorKind;
readonly message: string;
}
// The incremental events a protocol emits while streaming one turn. A turn is a
// sequence of these ending in exactly one `stop`.
export type StreamEvent =
// Incremental assistant text.
| {readonly type: 'text'; readonly text: string}
// Incremental "thinking" text, where the backend exposes it. Kept separate so
// consumers can show or hide it independently of the answer.
| {readonly type: 'thought'; readonly text: string}
// A fully-formed tool call the model wants executed.
| {readonly type: 'tool-call'; readonly call: ToolCall}
// Token accounting for the turn (may arrive incrementally; last one wins).
| {readonly type: 'usage'; readonly usage: TokenUsage}
// Terminal event. `error` is set iff reason === 'error'.
| {
readonly type: 'stop';
readonly reason: StopReason;
readonly error?: Error;
};
// --- What a protocol declares about itself -----------------------------------
export interface ProtocolCapabilities {
// Whether the backend natively supports tool/function calling. If false the
// gateway/consumer would have to emulate it via prompt injection (not done in
// Phase 1 - all Phase 1 protocols are native).
readonly nativeToolCalling: boolean;
// Whether the backend streams responses incrementally.
readonly streaming: boolean;
}
// Describes the credential/connection form a provider for this protocol needs.
// The settings UI renders a field per entry; the protocol declares its shape so
// the gateway never has to know about any specific backend's login form.
export interface CredentialField {
readonly key: string;
readonly label: string;
// `secret` fields are masked in the UI and flagged for any future
// settings-export stripping (see settings.ts `.meta({secret: true})`).
readonly secret?: boolean;
readonly required?: boolean;
readonly placeholder?: string;
}
// One model the backend reports it can serve, as returned by listModels(). Just
// the backend's own model identifier (e.g. 'gemini-2.5-flash') for now - the
// settings UI uses these to populate the model-name combobox.
export interface AvailableModel {
readonly name: string;
}
// The contract a protocol implementation must satisfy. Implemented by protocol
// plugins (e.g. dev.perfetto.LlmProtocolGemini) and registered with the gateway
// via registerProtocol().
export interface Protocol {
// Stable id referenced by providers, e.g. 'gemini'.
readonly id: string;
// Human-readable name for the settings UI, e.g. 'Google Gemini'.
readonly label: string;
readonly capabilities: ProtocolCapabilities;
// The credential fields a provider using this protocol must supply.
readonly credentialFields: readonly CredentialField[];
// Ask the backend which models it can serve, using the provider's
// credentials. Optional: a protocol that has no models endpoint (or doesn't
// implement it) simply omits this, and the settings UI falls back to a
// free-text model name. Throws on a network/auth error so the caller can
// tell "couldn't reach the backend" from "backend served an empty list".
listModels?(
credentials: Readonly<Record<string, string>>,
signal?: AbortSignal,
): Promise<readonly AvailableModel[]>;
// Take a neutral request and stream back a normalised response. The protocol
// owns translating tool defs/calls/results to and from the backend's native
// format and normalising errors into a terminal `stop` event (it must not
// throw for backend errors - it emits {type:'stop', reason:'error'} so the
// consumer can render it in the chat). `credentials` is the provider's
// configured credential bag (keyed by CredentialField.key).
createStream(
model: string,
request: Request,
credentials: Readonly<Record<string, string>>,
signal?: AbortSignal,
): AsyncGenerator<StreamEvent, void, void>;
}