blob: fe62bf0b629db9a6119d4d9bbe4af68478ea7210 [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.
import type {
AvailableModel,
CredentialField,
Message,
Protocol,
ProtocolCapabilities,
Request,
StreamEvent,
ToolCall,
} from '../dev.perfetto.Llm/protocol';
import {parseSse} from '../../base/sse';
import {zodToJsonSchema} from './json_schema';
// Sensible default openai-compatible placeholder URL for local llama.cpp.
// Since a lot of backends use the openapi protocol, it makes sense to be
// relatively unopinionated here (e.g. not perfer any organization).
const DEFAULT_BASE_URL = 'http://localhost:8080/v1';
interface OpenAiToolCall {
readonly id?: string;
readonly type?: 'function';
readonly function: {readonly name?: string; readonly arguments?: string};
}
interface OpenAiMessage {
readonly role: 'system' | 'user' | 'assistant' | 'tool';
readonly content?: string | null;
readonly tool_calls?: readonly OpenAiToolCall[];
readonly tool_call_id?: string;
}
// A tool-call fragment as it appears in a streamed delta. Per the OpenAI spec
// these arrive piecemeal keyed by `index`, with `arguments` a JSON *string*
// accumulated across chunks. But OpenAI-compatible servers vary:
// - llama.cpp / some builds omit `index` (single call, sent whole),
// - some emit `arguments` as an already-parsed object rather than a string.
// The assembler tolerates all of these.
interface ToolCallFragment {
readonly index?: number;
readonly id?: string;
readonly function?: {
readonly name?: string;
readonly arguments?: string | Record<string, unknown>;
};
}
interface StreamDelta {
readonly role?: string;
readonly content?: string | null;
readonly tool_calls?: readonly ToolCallFragment[];
}
interface StreamChunk {
readonly choices?: readonly {
readonly delta?: StreamDelta;
// Non-streaming-style servers may return the whole turn under `message`
// instead of incremental `delta`s; accept that too.
readonly message?: StreamDelta;
readonly finish_reason?: string | null;
}[];
readonly usage?: {
readonly prompt_tokens?: number;
readonly completion_tokens?: number;
readonly total_tokens?: number;
} | null;
}
function messagesToOpenAi(
systemPrompt: string,
messages: readonly Message[],
): OpenAiMessage[] {
const out: OpenAiMessage[] = [{role: 'system', content: systemPrompt}];
for (const msg of messages) {
switch (msg.role) {
case 'user':
out.push({role: 'user', content: msg.text});
break;
case 'model':
out.push({role: 'assistant', content: msg.text});
break;
case 'tool-call':
// The assistant turn that requested the tools. OpenAI threads results
// back by tool_call_id, so we must surface an id even though Gemini
// doesn't need one - synthesise a stable one from name+index if the
// backend didn't give us one when it streamed the call.
out.push({
role: 'assistant',
content: null,
tool_calls: msg.calls.map((c, i) => ({
id: c.id ?? `call_${i}_${c.name}`,
type: 'function',
function: {name: c.name, arguments: JSON.stringify(c.args)},
})),
});
break;
case 'tool-result':
for (const [i, r] of msg.results.entries()) {
out.push({
role: 'tool',
tool_call_id: r.id ?? `call_${i}_${r.name}`,
content: r.result,
});
}
break;
}
}
return out;
}
function toolsToOpenAi(request: Request) {
if (request.tools.length === 0) return undefined;
return request.tools.map((t) => ({
type: 'function' as const,
function: {
name: t.name,
description: t.description,
parameters: zodToJsonSchema(t.inputSchema),
},
}));
}
function classifyError(status: number, body: string): StreamEvent {
let kind: 'rate-limit' | 'auth' | 'context-length' | 'network' | 'unknown';
if (status === 429) kind = 'rate-limit';
else if (status === 401 || status === 403) kind = 'auth';
else if (status === 400 && /context|maximum.*token|too long/i.test(body)) {
kind = 'context-length';
} else kind = 'unknown';
return {
type: 'stop',
reason: 'error',
error: {kind, message: `OpenAI-compatible API error ${status}: ${body}`},
};
}
const CAPABILITIES: ProtocolCapabilities = {
nativeToolCalling: true,
streaming: true,
};
const CREDENTIAL_FIELDS: readonly CredentialField[] = [
{
key: 'endpoint',
label: 'Base URL',
required: true,
placeholder: DEFAULT_BASE_URL,
},
{
// Optional: local servers (llama-server, LM Studio, Ollama) usually run
// without auth. Sent as a bearer token when present.
key: 'apiKey',
label: 'API key (optional for local servers)',
secret: true,
},
];
// Accumulates streamed tool-call fragments into whole calls. Fragments are
// grouped by `index` where present; servers that omit it (sending a call whole)
// fall back to a positional key so multiple distinct calls don't collide. The
// `arguments` field is concatenated as a string, but if a server hands us an
// already-parsed object we stash it directly rather than string-concatenating
// (which would otherwise produce "[object Object]" and lose all the args).
class ToolCallAssembler {
private readonly byKey = new Map<
string,
{
id?: string;
name: string;
argsStr: string;
argsObj?: Record<string, unknown>;
}
>();
private fallbackSeq = 0;
add(fragments: readonly ToolCallFragment[]): void {
for (const tc of fragments) {
const key =
tc.index !== undefined
? `i${tc.index}`
: tc.id ?? `seq${this.fallbackSeq++}`;
const cur = this.byKey.get(key) ?? {name: '', argsStr: ''};
const rawArgs = tc.function?.arguments;
if (typeof rawArgs === 'object' && rawArgs !== null) {
cur.argsObj = rawArgs;
} else if (typeof rawArgs === 'string') {
cur.argsStr += rawArgs;
}
this.byKey.set(key, {
id: tc.id ?? cur.id,
name: cur.name + (tc.function?.name ?? ''),
argsStr: cur.argsStr,
argsObj: cur.argsObj,
});
}
}
finish(): ToolCall[] {
const calls: ToolCall[] = [];
for (const {id, name, argsStr, argsObj} of this.byKey.values()) {
if (name === '') continue;
let parsed: Record<string, unknown> = argsObj ?? {};
if (argsObj === undefined && argsStr.trim() !== '') {
try {
parsed = JSON.parse(argsStr);
} catch {
// Leave args empty on malformed JSON; the registry will validate and
// the model can self-correct from the resulting tool error.
}
}
calls.push({id, name, args: parsed});
}
return calls;
}
}
export class OpenAiProtocol implements Protocol {
readonly id = 'openai-compatible';
readonly label = 'OpenAI-compatible (OpenAI, llama-server, vLLM, ...)';
readonly capabilities = CAPABILITIES;
readonly credentialFields = CREDENTIAL_FIELDS;
// GET {base}/models is part of the OpenAI spec and implemented by virtually
// every compatible server (OpenAI, llama-server, vLLM, LM Studio, Ollama).
// Returns {data: [{id, ...}]}.
async listModels(
credentials: Readonly<Record<string, string>>,
signal?: AbortSignal,
): Promise<readonly AvailableModel[]> {
const base = (credentials.endpoint || DEFAULT_BASE_URL).replace(/\/$/, '');
const apiKey = credentials.apiKey ?? '';
const resp = await fetch(`${base}/models`, {
headers: apiKey ? {Authorization: `Bearer ${apiKey}`} : {},
signal,
});
if (!resp.ok) {
const text = await resp.text().catch(() => '<no body>');
throw new Error(
`OpenAI-compatible listModels error ${resp.status}: ${text}`,
);
}
const json = (await resp.json()) as {
readonly data?: readonly {readonly id?: string}[];
};
return (json.data ?? [])
.map((m) => m.id ?? '')
.filter((id) => id !== '')
.map((id) => ({name: id}));
}
async *createStream(
model: string,
request: Request,
credentials: Readonly<Record<string, string>>,
signal?: AbortSignal,
): AsyncGenerator<StreamEvent, void, void> {
const base = (credentials.endpoint || DEFAULT_BASE_URL).replace(/\/$/, '');
const apiKey = credentials.apiKey ?? '';
const url = `${base}/chat/completions`;
const tools = toolsToOpenAi(request);
const body = {
model,
messages: messagesToOpenAi(request.systemPrompt, request.messages),
...(tools ? {tools} : {}),
stream: true,
// Ask for usage in the final chunk (supported by OpenAI and most clones).
stream_options: {include_usage: true},
};
let resp: Response;
try {
resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? {Authorization: `Bearer ${apiKey}`} : {}),
},
body: JSON.stringify(body),
signal,
});
} catch (e) {
if (signal?.aborted) return;
yield {
type: 'stop',
reason: 'error',
error: {kind: 'network', message: String(e)},
};
return;
}
if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => '<no body>');
yield classifyError(resp.status, text);
return;
}
const assembler = new ToolCallAssembler();
let finishReason: string | null = null;
for await (const rawChunk of parseSse(resp.body, signal)) {
const chunk = rawChunk as StreamChunk;
const choice = chunk.choices?.[0];
// Prefer incremental `delta`, but accept `message` for servers that
// return the whole turn in one chunk.
const delta = choice?.delta ?? choice?.message;
if (delta?.content) {
yield {type: 'text', text: delta.content};
}
if (delta?.tool_calls) {
assembler.add(delta.tool_calls);
}
if (choice?.finish_reason) {
finishReason = choice.finish_reason;
}
if (chunk.usage) {
yield {
type: 'usage',
usage: {
inputTokens: chunk.usage.prompt_tokens,
outputTokens: chunk.usage.completion_tokens,
totalTokens: chunk.usage.total_tokens,
},
};
}
}
if (signal?.aborted) return;
// Emit assembled tool calls (if any) before the terminal stop event.
const calls = assembler.finish();
for (const call of calls) {
yield {type: 'tool-call', call};
}
if (calls.length > 0 || finishReason === 'tool_calls') {
yield {type: 'stop', reason: 'tool-calls'};
} else if (finishReason === 'length') {
yield {type: 'stop', reason: 'length'};
} else {
yield {type: 'stop', reason: 'end'};
}
}
}