The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64 and arm64. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
355 files changed
+47028
No files matched your search
@@ -0,0 +1,228 @@
|
||||
// Anthropic Messages (`POST {base}/messages`) — api.anthropic.com, and servers that imitate it
|
||||
// (vLLM, llama-server). Content blocks both ways; thinking with signatures that must be sent back
|
||||
// on the next turn; optional prompt-caching breakpoints.
|
||||
import type { Effort } from "../config/schema.ts"
|
||||
import { resolveKey } from "../config/load.ts"
|
||||
import { Parts, record, recordFailure, retrying, statusOf, SwapBanner } from "./common.ts"
|
||||
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
|
||||
import { sseJson } from "./sse.ts"
|
||||
import { estimateTokens } from "./tokens.ts"
|
||||
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent, type ToolCallPart } from "./types.ts"
|
||||
|
||||
/** Thinking budgets by effort, when the model's config gives no effort_map. */
|
||||
export const DEFAULT_BUDGETS: Record<Effort, number> = { minimal: 1024, low: 2048, medium: 8192, high: 16384, xhigh: 32000, max: 48000 }
|
||||
|
||||
type Block = Record<string, unknown>
|
||||
type AMessage = { role: "user" | "assistant"; content: Block[] }
|
||||
|
||||
/** Anthropic ids must match ^[a-zA-Z0-9_-]+$; ids from other providers are mapped consistently. */
|
||||
export const safeId = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_") || "call"
|
||||
|
||||
export function toAnthropicMessages(messages: Message[], vision: boolean, thinking: boolean): AMessage[] {
|
||||
const out: AMessage[] = []
|
||||
const push = (role: AMessage["role"], blocks: Block[]) => {
|
||||
if (!blocks.length) return
|
||||
const last = out[out.length - 1]
|
||||
// Consecutive turns of one role are merged: tool results and a following note are one user turn.
|
||||
if (last && last.role === role) last.content.push(...blocks)
|
||||
else out.push({ role, content: blocks })
|
||||
}
|
||||
for (const m of messages) {
|
||||
if (m.role === "user") {
|
||||
push(
|
||||
"user",
|
||||
m.parts.map((p) =>
|
||||
p.type === "text"
|
||||
? { type: "text", text: p.text }
|
||||
: vision
|
||||
? { type: "image", source: { type: "base64", media_type: p.mime, data: p.data } }
|
||||
: { type: "text", text: "[image omitted: this model has no vision]" },
|
||||
),
|
||||
)
|
||||
} else if (m.role === "assistant") {
|
||||
const blocks: Block[] = []
|
||||
for (const p of m.parts) {
|
||||
if (p.type === "reasoning") {
|
||||
// A thinking block can only go back with its signature, and only while thinking is on.
|
||||
if (!thinking) continue
|
||||
const redacted = (p.opaque as { redacted?: string } | undefined)?.redacted
|
||||
if (redacted) blocks.push({ type: "redacted_thinking", data: redacted })
|
||||
else if (p.signature) blocks.push({ type: "thinking", thinking: p.text, signature: p.signature })
|
||||
} else if (p.type === "text") {
|
||||
if (p.text.trim()) blocks.push({ type: "text", text: p.text })
|
||||
} else {
|
||||
let input: unknown = {}
|
||||
try {
|
||||
input = p.args.trim() ? JSON.parse(p.args) : {}
|
||||
} catch {
|
||||
input = { _unparsed: p.args }
|
||||
}
|
||||
blocks.push({ type: "tool_use", id: safeId(p.id), name: p.name, input })
|
||||
}
|
||||
}
|
||||
push("assistant", blocks)
|
||||
} else {
|
||||
push("user", [{ type: "tool_result", tool_use_id: safeId(m.callId), content: m.content || "(empty)", ...(m.isError ? { is_error: true } : {}) }])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export class AnthropicClient implements Client {
|
||||
constructor(private m: ResolvedModel) {}
|
||||
|
||||
private headers() {
|
||||
return authHeaders(this.m.connection, resolveKey(this.m.connectionName, this.m.connection), { "anthropic-version": "2023-06-01", ...this.m.spec.headers })
|
||||
}
|
||||
|
||||
buildBody(req: ChatRequest): Record<string, unknown> {
|
||||
const { m } = this
|
||||
const budget = req.effort ? (m.spec.effort_map?.[req.effort] ?? DEFAULT_BUDGETS[req.effort]) : undefined
|
||||
let maxTokens = m.spec.max_output ?? 8192
|
||||
if (budget && maxTokens <= budget) maxTokens = budget + Math.min(8192, maxTokens)
|
||||
const cache = m.spec.cache === true
|
||||
const body: Record<string, unknown> = {
|
||||
model: m.id,
|
||||
max_tokens: maxTokens,
|
||||
stream: true,
|
||||
messages: toAnthropicMessages(req.messages, m.spec.vision === true, !!budget),
|
||||
...m.connection.body,
|
||||
...m.spec.body,
|
||||
}
|
||||
if (req.system) body.system = cache ? [{ type: "text", text: req.system, cache_control: { type: "ephemeral" } }] : req.system
|
||||
if (req.tools.length && m.spec.tools !== false) {
|
||||
const tools = req.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters }))
|
||||
if (cache) (tools[tools.length - 1] as Block).cache_control = { type: "ephemeral" }
|
||||
body.tools = tools
|
||||
}
|
||||
if (cache) {
|
||||
const msgs = body.messages as AMessage[]
|
||||
const last = msgs[msgs.length - 1]?.content
|
||||
if (last?.length) last[last.length - 1] = { ...last[last.length - 1], cache_control: { type: "ephemeral" } }
|
||||
}
|
||||
if (budget) body.thinking = { type: "enabled", budget_tokens: budget }
|
||||
else {
|
||||
// Sampling parameters and extended thinking do not mix on this API.
|
||||
if (m.spec.temperature !== undefined) body.temperature = m.spec.temperature
|
||||
if (m.spec.top_p !== undefined) body.top_p = m.spec.top_p
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {
|
||||
const body = this.buildBody(req)
|
||||
yield* retrying(() => this.attempt(req, body))
|
||||
}
|
||||
|
||||
private async *attempt(req: ChatRequest, body: Record<string, unknown>): AsyncGenerator<StreamEvent> {
|
||||
const { m } = this
|
||||
const res = await request(
|
||||
joinUrl(m.connection.base_url, "messages"),
|
||||
{ method: "POST", headers: this.headers(), body: JSON.stringify(body), signal: req.signal, timeoutMs: (m.connection.timeout ?? 600) * 1000, tls: tlsFor(m.connection) },
|
||||
m.ref,
|
||||
).catch((e) => {
|
||||
recordFailure(m.ref, body, e)
|
||||
throw e
|
||||
})
|
||||
if (!res.body) throw new ProviderError(`${m.ref}: empty response`)
|
||||
|
||||
const out = new Parts()
|
||||
const banner = new SwapBanner()
|
||||
type Open = { type: string; text: string; signature?: string; id?: string; name?: string; json: string; data?: string; index: number }
|
||||
const blocks = new Map<number, Open>()
|
||||
let toolIndex = 0
|
||||
let finish = "stop"
|
||||
let input = 0
|
||||
let output = 0
|
||||
let cached: number | undefined
|
||||
let sawUsage = false
|
||||
|
||||
for await (const { data } of sseJson(record(res.body, m.ref, body))) {
|
||||
const ev = data as any
|
||||
switch (ev?.type) {
|
||||
case "message_start": {
|
||||
const u = ev.message?.usage
|
||||
if (u) {
|
||||
sawUsage = true
|
||||
cached = u.cache_read_input_tokens || undefined
|
||||
input = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
|
||||
output = u.output_tokens ?? 0
|
||||
}
|
||||
break
|
||||
}
|
||||
case "content_block_start": {
|
||||
const b = ev.content_block ?? {}
|
||||
blocks.set(ev.index, { type: b.type, text: b.text ?? b.thinking ?? "", id: b.id, name: b.name, json: "", data: b.data, index: ev.index })
|
||||
if (b.type === "tool_use") yield { type: "tool_call_delta", index: toolIndex, name: b.name, argsDelta: "" }
|
||||
if (b.type === "text" && b.text) {
|
||||
out.add("text", b.text)
|
||||
yield { type: "text", text: b.text }
|
||||
}
|
||||
break
|
||||
}
|
||||
case "content_block_delta": {
|
||||
const blk = blocks.get(ev.index)
|
||||
const d = ev.delta ?? {}
|
||||
if (!blk) break
|
||||
if (d.type === "text_delta") {
|
||||
out.add("text", d.text)
|
||||
yield { type: "text", text: d.text }
|
||||
} else if (d.type === "thinking_delta") {
|
||||
const b = banner.feed(d.thinking ?? "")
|
||||
if (b.notice) yield { type: "notice", message: b.notice }
|
||||
if (b.text) {
|
||||
blk.text += b.text
|
||||
if (b.text.trim()) out.output = true
|
||||
yield { type: "reasoning", text: b.text }
|
||||
}
|
||||
} else if (d.type === "signature_delta") blk.signature = (blk.signature ?? "") + d.signature
|
||||
else if (d.type === "input_json_delta") {
|
||||
blk.json += d.partial_json ?? ""
|
||||
out.output = true
|
||||
yield { type: "tool_call_delta", index: toolIndex, argsDelta: d.partial_json ?? "" }
|
||||
}
|
||||
break
|
||||
}
|
||||
case "content_block_stop": {
|
||||
const blk = blocks.get(ev.index)
|
||||
if (!blk) break
|
||||
if (blk.type === "thinking" && blk.text) out.push({ type: "reasoning", text: blk.text, signature: blk.signature })
|
||||
else if (blk.type === "redacted_thinking") out.push({ type: "reasoning", text: "", opaque: { redacted: blk.data } })
|
||||
else if (blk.type === "tool_use") {
|
||||
const call: ToolCallPart = { type: "tool_call", id: blk.id || `toolu_${toolIndex}`, name: blk.name ?? "", args: blk.json || "{}" }
|
||||
out.push(call)
|
||||
toolIndex++
|
||||
}
|
||||
blocks.delete(ev.index)
|
||||
break
|
||||
}
|
||||
case "message_delta": {
|
||||
if (ev.delta?.stop_reason) finish = ev.delta.stop_reason
|
||||
if (ev.usage?.output_tokens !== undefined) {
|
||||
sawUsage = true
|
||||
output = ev.usage.output_tokens
|
||||
}
|
||||
break
|
||||
}
|
||||
case "error": {
|
||||
const err = ev.error ?? {}
|
||||
throw new ProviderError(`${m.ref}: ${err.message ?? JSON.stringify(err)}`, statusOf(err) ?? (err.type === "overloaded_error" ? 529 : undefined), undefined, !out.output)
|
||||
}
|
||||
default:
|
||||
if (ev?.error) throw new ProviderError(`${m.ref}: ${ev.error.message ?? JSON.stringify(ev.error)}`, statusOf(ev.error), undefined, !out.output)
|
||||
}
|
||||
}
|
||||
const message = out.message()
|
||||
const reason = finish === "max_tokens" ? "length" : finish === "tool_use" || message.parts.some((p) => p.type === "tool_call") ? "tool_calls" : "stop"
|
||||
yield sawUsage
|
||||
? { type: "usage", usage: { input, output, cached } }
|
||||
: { type: "usage", usage: { input: estimateTokens(JSON.stringify(body.messages)), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true } }
|
||||
yield { type: "finish", reason, message }
|
||||
}
|
||||
|
||||
async listModels(): Promise<DiscoveredModel[]> {
|
||||
const res = await request(joinUrl(this.m.connection.base_url, "models"), { headers: this.headers(), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
|
||||
const j = (await res.json()) as any
|
||||
return (Array.isArray(j?.data) ? j.data : []).map((x: any) => ({ id: String(x.id), context: x.context_window ?? x.max_input_tokens })).filter((x: DiscoveredModel) => x.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// What every dialect shares: the retry rule, the part builder, the stream recorder, and
|
||||
// llama-swap's loading banner.
|
||||
import type { ReasoningPart, StreamEvent, TextPart, ToolCallPart, Message } from "./types.ts"
|
||||
import { ProviderError } from "./types.ts"
|
||||
import { env } from "../config/paths.ts"
|
||||
|
||||
type Assistant = Extract<Message, { role: "assistant" }>
|
||||
|
||||
/** Builds an assistant message as deltas arrive, and knows whether anything real has been said —
|
||||
* the line after which a retry would duplicate output. */
|
||||
export class Parts {
|
||||
readonly parts: Assistant["parts"] = []
|
||||
output = false
|
||||
|
||||
add(kind: "text" | "reasoning", text: string) {
|
||||
if (!text) return
|
||||
if (text.trim()) this.output = true
|
||||
const last = this.parts[this.parts.length - 1]
|
||||
if (last && last.type === kind && !(last as ReasoningPart).signature && !(last as ReasoningPart).opaque) (last as TextPart | ReasoningPart).text += text
|
||||
else this.parts.push({ type: kind, text } as TextPart | ReasoningPart)
|
||||
}
|
||||
|
||||
push(p: ReasoningPart | ToolCallPart) {
|
||||
this.output = true
|
||||
this.parts.push(p)
|
||||
}
|
||||
|
||||
message(): Assistant {
|
||||
return { role: "assistant", parts: this.parts }
|
||||
}
|
||||
}
|
||||
|
||||
/** One decision per failed attempt: retry (optionally saying why), or give up. */
|
||||
export type RetryHandler = (e: ProviderError) => { retry: boolean; notice?: string }
|
||||
|
||||
/** Run attempts until one succeeds. A retry is only ever considered while nothing of the reply has
|
||||
* been passed on (`beforeOutput`); a server error gets two retries on top of whatever `handle` allows. */
|
||||
export async function* retrying(attempt: () => AsyncGenerator<StreamEvent>, handle?: RetryHandler): AsyncGenerator<StreamEvent> {
|
||||
let retried5xx = 0
|
||||
for (let n = 0; ; n++) {
|
||||
try {
|
||||
yield* attempt()
|
||||
return
|
||||
} catch (e) {
|
||||
if (!(e instanceof ProviderError) || !e.beforeOutput || n >= 3) throw e
|
||||
const h = handle?.(e)
|
||||
if (h?.retry) {
|
||||
if (h.notice) yield { type: "notice", message: h.notice }
|
||||
continue
|
||||
}
|
||||
// Often the model's own output failing the server's parser (llama.cpp + gpt-oss), or the
|
||||
// model swapped out under us (llama-swap): sampling differs on a second go.
|
||||
// Twice: the first at once, the second after a pause (a model still loading, a busy server).
|
||||
if ((e.status ?? 0) >= 500 && retried5xx < 2) {
|
||||
retried5xx++
|
||||
yield { type: "notice", message: `server error, retrying (${retried5xx} of 2) — ${e.message.split("\n")[0]!.slice(0, 160)}` }
|
||||
if (retried5xx === 2) await new Promise((r) => setTimeout(r, 2000))
|
||||
continue
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Status for an error object inside a stream: a numeric code is HTTP; llama-swap's
|
||||
* `type: "server_error"` / `code: "internal_error"` is a 500 too. */
|
||||
export function statusOf(err: any): number | undefined {
|
||||
if (typeof err?.code === "number") return err.code
|
||||
if (typeof err?.status === "number") return err.status
|
||||
if (err?.type === "server_error" || err?.type === "overloaded_error" || err?.type === "api_error" || /internal/i.test(String(err?.code ?? ""))) return 500
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function recordName(ref: string) {
|
||||
return `${env("RECORD")}/${new Date().toISOString().replace(/[:.]/g, "-")}-${ref.replace(/[^\w.-]+/g, "_")}`
|
||||
}
|
||||
|
||||
export function recordFailure(ref: string, request: unknown, e: unknown) {
|
||||
if (!env("RECORD")) return
|
||||
const name = recordName(ref)
|
||||
void Bun.write(`${name}.request.json`, JSON.stringify(request, null, 2))
|
||||
const err = e as ProviderError
|
||||
void Bun.write(`${name}.error.json`, JSON.stringify({ status: err.status, message: err.message, body: err.body }, null, 2))
|
||||
}
|
||||
|
||||
/** LEMBAS_RECORD=<dir>: keep each request body and its raw SSE, to become test fixtures. */
|
||||
export function record(body: ReadableStream<Uint8Array>, ref: string, request: unknown): ReadableStream<Uint8Array> {
|
||||
const dir = env("RECORD")
|
||||
if (!dir) return body
|
||||
const [a, b] = body.tee()
|
||||
const name = recordName(ref)
|
||||
void Bun.write(`${name}.request.json`, JSON.stringify(request, null, 2))
|
||||
void new Response(b).text().then((raw) => Bun.write(`${name}.sse`, raw))
|
||||
return a
|
||||
}
|
||||
|
||||
/** llama-swap streams its model-loading progress as reasoning, fenced by "━━━━━" lines, before the
|
||||
* model's own first token. That is not the model thinking: it becomes one notice. */
|
||||
export class SwapBanner {
|
||||
private buf = ""
|
||||
private state: "start" | "inside" | "done" = "start"
|
||||
|
||||
private skipSpace = false
|
||||
|
||||
feed(text: string): { text: string; notice?: string } {
|
||||
if (this.state === "done") {
|
||||
// The banner is followed by blank lines, streamed token by token; they are its, not the model's.
|
||||
if (this.skipSpace) {
|
||||
text = text.trimStart()
|
||||
if (text) this.skipSpace = false
|
||||
}
|
||||
return { text }
|
||||
}
|
||||
this.buf += text
|
||||
if (this.state === "start") {
|
||||
const probe = this.buf.trimStart()
|
||||
if (probe.length < 5 && "━━━━━".startsWith(probe)) return { text: "" }
|
||||
if (!probe.startsWith("━━━━━")) {
|
||||
this.state = "done"
|
||||
const out = this.buf
|
||||
this.buf = ""
|
||||
return { text: out }
|
||||
}
|
||||
this.state = "inside"
|
||||
}
|
||||
const open = this.buf.indexOf("━━━━━")
|
||||
const close = this.buf.indexOf("━━━━━", open + 5)
|
||||
if (close === -1) {
|
||||
if (this.buf.length > 4000) {
|
||||
this.state = "done"
|
||||
const out = this.buf
|
||||
this.buf = ""
|
||||
return { text: out }
|
||||
}
|
||||
return { text: "" }
|
||||
}
|
||||
const inner = this.buf.slice(open + 5, close).replace(/━+/g, "").trim()
|
||||
let rest = this.buf.slice(close).replace(/^━+\s*/, "")
|
||||
this.state = "done"
|
||||
this.buf = ""
|
||||
rest = rest.trimStart()
|
||||
this.skipSpace = rest === ""
|
||||
const lines = inner.split("\n").map((l) => l.trim()).filter(Boolean)
|
||||
const done = lines.find((l) => /^Done!/.test(l)) ?? ""
|
||||
const what = lines[0] ?? "model loading"
|
||||
return { text: rest, notice: `${what}${done ? " — " + done.toLowerCase() : ""}` }
|
||||
}
|
||||
}
|
||||
|
||||
/** A tool call's arguments as they go back to the server in the history. A call cut off mid-way
|
||||
* (the output limit) or garbled by the model has arguments that are not JSON, and llama.cpp parses
|
||||
* the history's arguments again on every request — one broken call there fails each request after
|
||||
* it with a 500. The tool result beside it already told the model what went wrong. */
|
||||
export function historyArgs(args: string): string {
|
||||
try {
|
||||
const v = JSON.parse(args)
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) return args
|
||||
} catch {}
|
||||
return "{}"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// A model's context window when the config does not give one. Config always wins; what is found
|
||||
// here is kept in learned.json. Tried in order: the server's model list; llama-server's /props
|
||||
// (directly, or through llama-swap's /upstream/<model>/ passthrough); Ollama's /api/show.
|
||||
import { learned, learnContext } from "./learned.ts"
|
||||
import { authHeaders, request, tlsFor } from "./http.ts"
|
||||
import { OllamaClient } from "./ollama.ts"
|
||||
import type { Client, ResolvedModel } from "./types.ts"
|
||||
import { resolveKey } from "../config/load.ts"
|
||||
|
||||
/** The window to plan with: configured, else learned, else undefined (unknown). */
|
||||
export function contextWindow(m: ResolvedModel): number | undefined {
|
||||
return m.spec.context || learned().contexts[m.ref] || undefined
|
||||
}
|
||||
|
||||
export async function discoverContext(m: ResolvedModel, client: Client): Promise<number | undefined> {
|
||||
if (m.spec.context) return m.spec.context
|
||||
const known = learned().contexts[m.ref]
|
||||
if (known) return known
|
||||
const found = (await fromList(m, client)) ?? (await fromProps(m)) ?? (m.connection.dialect === "ollama" ? await (client as OllamaClient).contextOf(m.id).catch(() => undefined) : undefined)
|
||||
if (found && found > 0) learnContext(m.ref, found)
|
||||
return found
|
||||
}
|
||||
|
||||
async function fromList(m: ResolvedModel, client: Client): Promise<number | undefined> {
|
||||
try {
|
||||
return (await client.listModels()).find((d) => d.id === m.id)?.context
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function fromProps(m: ResolvedModel): Promise<number | undefined> {
|
||||
const base = m.connection.base_url.replace(/\/+$/, "")
|
||||
const origin = base.replace(/\/v1$/, "")
|
||||
const headers = authHeaders(m.connection, resolveKey(m.connectionName, m.connection))
|
||||
for (const url of [`${origin}/upstream/${encodeURIComponent(m.id)}/props`, `${origin}/props`]) {
|
||||
try {
|
||||
const res = await request(url, { headers, timeoutMs: 15_000, tls: tlsFor(m.connection) }, m.ref)
|
||||
const j = (await res.json()) as any
|
||||
const n = Number(j?.default_generation_settings?.n_ctx ?? j?.n_ctx)
|
||||
if (Number.isFinite(n) && n > 0) return n
|
||||
} catch {}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** llama-swap and friends: free the connection's memory when the session moves elsewhere. */
|
||||
export async function unload(m: ResolvedModel): Promise<void> {
|
||||
if (!m.connection.unload_url) return
|
||||
try {
|
||||
await request(m.connection.unload_url, { method: m.connection.unload_method ?? "POST", headers: authHeaders(m.connection, resolveKey(m.connectionName, m.connection)), timeoutMs: 10_000, tls: tlsFor(m.connection) }, m.ref)
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Reasoning effort. Logic ported from LLeMbas services/chat.py and generation.py
|
||||
// (© Jaroslav Beneš, MIT), where the reasons are written up at length:
|
||||
// - llama.cpp silently drops a top-level `reasoning_effort`; what reaches a gpt-oss behind it is
|
||||
// `chat_template_kwargs.reasoning_effort`, so by default both are sent — and only when chosen.
|
||||
// - A template that does not know a value raises and fails the whole request (Bonsai refuses
|
||||
// `high`), so a refusal is detected, the model's vocabulary narrowed, and the request retried once.
|
||||
import { EFFORTS, type Effort } from "../config/schema.ts"
|
||||
import type { ResolvedModel } from "./types.ts"
|
||||
import { learned } from "./learned.ts"
|
||||
|
||||
export const DEFAULT_EFFORTS: Effort[] = ["low", "medium", "high"]
|
||||
|
||||
/** The efforts this model accepts, in offering order: learned > configured > the common three. */
|
||||
export function effortsFor(m: ResolvedModel): Effort[] {
|
||||
const list = learned().efforts[m.ref] ?? m.spec.efforts ?? DEFAULT_EFFORTS
|
||||
return EFFORTS.filter((e) => list.includes(e))
|
||||
}
|
||||
|
||||
/** The model's default effort, or null for off / not a reasoning model. */
|
||||
export function defaultEffort(m: ResolvedModel): Effort | null {
|
||||
const e = m.spec.effort
|
||||
if (!e || e === "off") return null
|
||||
return effortsFor(m).includes(e) ? e : null
|
||||
}
|
||||
|
||||
/** Put the effort into an openai-chat request body. An effort outside the vocabulary is dropped. */
|
||||
export function applyEffort(body: Record<string, unknown>, m: ResolvedModel, effort: Effort | null | undefined) {
|
||||
if (!effort || !effortsFor(m).includes(effort)) return
|
||||
const style = m.spec.effort_style ?? "both"
|
||||
if (style !== "kwargs") body.reasoning_effort = effort
|
||||
if (style !== "top") {
|
||||
const kwargs = { ...((body.chat_template_kwargs as Record<string, unknown>) ?? {}) }
|
||||
kwargs.reasoning_effort = effort
|
||||
body.chat_template_kwargs = kwargs
|
||||
}
|
||||
}
|
||||
|
||||
export function stripEffort(body: Record<string, unknown>): Record<string, unknown> {
|
||||
const out = { ...body }
|
||||
delete out.reasoning_effort
|
||||
const kwargs = { ...((out.chat_template_kwargs as Record<string, unknown>) ?? {}) }
|
||||
delete kwargs.reasoning_effort
|
||||
if (Object.keys(kwargs).length) out.chat_template_kwargs = kwargs
|
||||
else delete out.chat_template_kwargs
|
||||
return out
|
||||
}
|
||||
|
||||
/** Whether an error is a chat template refusing the effort. Deliberately narrow. */
|
||||
export function effortRefused(message: string): boolean {
|
||||
const m = message.toLowerCase()
|
||||
return m.includes("effort") && (m.includes("unexpected") || m.includes("supported"))
|
||||
}
|
||||
|
||||
/** Efforts an error message advertises ("Supported types are xhigh (default), medium, and low"). */
|
||||
export function advertisedEfforts(message: string): Effort[] {
|
||||
const m = message.toLowerCase()
|
||||
const at = m.indexOf("supported")
|
||||
if (at === -1) return []
|
||||
// Whole words: "high" is a substring of "xhigh".
|
||||
const words = new Set(m.slice(at).match(/[a-z]+/g) ?? [])
|
||||
return EFFORTS.filter((e) => words.has(e))
|
||||
}
|
||||
|
||||
/** Read the accepted efforts out of a Jinja chat template (llama-server /props). ≥2 or nothing. */
|
||||
export function effortsFromTemplate(template: string): Effort[] {
|
||||
if (!template || !template.includes("reasoning_effort")) return []
|
||||
const found = new Set<string>()
|
||||
for (const match of template.matchAll(/reasoning_effort/g)) {
|
||||
let window = template.slice(match.index, match.index + 400)
|
||||
if (window.includes("%}")) window = window.split("%}")[0]!
|
||||
for (const lit of window.matchAll(/['"]([a-z]{3,8})['"]/g)) found.add(lit[1]!)
|
||||
}
|
||||
for (const group of template.matchAll(/[[(]((?:\s*['"][a-z]{3,8}['"]\s*,?)+)[\])]/g)) {
|
||||
const lits = [...group[1]!.matchAll(/['"]([a-z]{3,8})['"]/g)].map((x) => x[1]!)
|
||||
if (lits.length >= 2 && lits.every((v) => (EFFORTS as readonly string[]).includes(v))) lits.forEach((v) => found.add(v))
|
||||
}
|
||||
const out = EFFORTS.filter((e) => found.has(e))
|
||||
return out.length >= 2 ? out : []
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// Google Gemini, native (`{base}/models/{id}:streamGenerateContent?alt=sse`), base
|
||||
// https://generativelanguage.googleapis.com/v1beta. UNTESTED: no Gemini endpoint has been
|
||||
// available to try it against (see the wiki's Working-notes).
|
||||
import type { Effort } from "../config/schema.ts"
|
||||
import { resolveKey } from "../config/load.ts"
|
||||
import { DEFAULT_BUDGETS } from "./anthropic.ts"
|
||||
import { Parts, record, recordFailure, retrying, statusOf } from "./common.ts"
|
||||
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
|
||||
import { sseJson } from "./sse.ts"
|
||||
import { estimateTokens } from "./tokens.ts"
|
||||
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent, type ToolCallPart } from "./types.ts"
|
||||
|
||||
type Content = { role: "user" | "model"; parts: Record<string, unknown>[] }
|
||||
|
||||
// Checked against Hermes Agent (agent/gemini_native_adapter.py, agent/gemini_schema.py) and
|
||||
// OpenCode (provider/transform.ts). Still untested against a real Gemini endpoint.
|
||||
|
||||
/** Gemini's own id, without an aggregator prefix. */
|
||||
const bare = (id: string) => id.replace(/^(models|google|gemini)\//i, "")
|
||||
/** Gemini 3 and later: tool calls and results carry ids, and thinking is a level, not a budget. */
|
||||
export const isGemini3 = (id: string) => Number(/gemini-(\d+)/i.exec(bare(id))?.[1] ?? 0) >= 3
|
||||
const LEGACY = /gemini-(?:(?:flash|pro)-)?[12](?:[.-]|$)/i
|
||||
/** A call replayed without a signature (made by another model, or before signatures existed):
|
||||
* Google's documented sentinel, without which Gemini 3 refuses the whole history. */
|
||||
export const SKIP_SIGNATURE = "skip_thought_signature_validator"
|
||||
/** Thought tokens count against maxOutputTokens, and Gemini's own default is low. */
|
||||
export const DEFAULT_MAX_OUTPUT = 65535
|
||||
const PLACEHOLDER = "[The previous response was interrupted before it completed.]"
|
||||
|
||||
// ── tool schemas ──────────────────────────────────────────────────────────────────────────────
|
||||
// v1beta takes plain JSON Schema as `parametersJsonSchema`; only same-document $refs have to be
|
||||
// inlined. Other API versions take `parameters`, an OpenAPI subset where one unknown key 400s the
|
||||
// whole request, so that path drops everything outside the subset.
|
||||
|
||||
const ALLOWED = new Set(["type", "format", "title", "description", "nullable", "enum", "maxItems", "minItems", "properties", "required", "minProperties", "maxProperties", "minLength", "maxLength", "pattern", "example", "anyOf", "propertyOrdering", "default", "items", "minimum", "maximum"])
|
||||
const STRUCTURAL: Record<string, string[]> = { array: ["items", "minItems", "maxItems"], object: ["properties", "required", "minProperties", "maxProperties", "propertyOrdering"] }
|
||||
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v)
|
||||
|
||||
export function geminiSchema(s: unknown): Record<string, unknown> {
|
||||
if (!isObj(s)) return {}
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(s)) {
|
||||
if (!ALLOWED.has(k)) continue
|
||||
if (k === "properties") {
|
||||
if (isObj(v)) out.properties = Object.fromEntries(Object.entries(v).map(([p, sub]) => [p, geminiSchema(sub)]))
|
||||
} else if (k === "items") out.items = geminiSchema(v)
|
||||
else if (k === "anyOf") {
|
||||
if (Array.isArray(v)) out.anyOf = v.filter(isObj).map(geminiSchema)
|
||||
} else out[k] = v
|
||||
}
|
||||
const types = out.type
|
||||
if (Array.isArray(types)) {
|
||||
delete out.type
|
||||
const real = types.filter((t): t is string => typeof t === "string" && t !== "null")
|
||||
if (!real.length) out.type = types.includes("null") ? "null" : "object"
|
||||
else if (real.length === 1) out.type = real[0]
|
||||
else {
|
||||
// Several types: one branch each, and the structure belongs on the branch it describes.
|
||||
const moved: Record<string, unknown> = {}
|
||||
for (const keys of Object.values(STRUCTURAL)) for (const k of keys) if (k in out) (moved[k] = out[k]), delete out[k]
|
||||
out.anyOf = real.map((t) => geminiSchema({ type: t, ...Object.fromEntries(Object.entries(moved).filter(([k]) => STRUCTURAL[t]?.includes(k))) }))
|
||||
}
|
||||
if (types.includes("null")) out.nullable = true
|
||||
}
|
||||
// Every enum entry must be a string, whatever the declared type.
|
||||
if (Array.isArray(out.enum) && (Array.isArray(types) || ["integer", "number", "boolean"].includes(out.type as string))) {
|
||||
const vals = [...new Set(out.enum.map((e) => (typeof e === "boolean" || (typeof e === "number" && Number.isFinite(e)) ? String(e) : typeof e === "string" ? e : undefined)).filter((e) => e !== undefined))]
|
||||
if (vals.length) out.enum = vals
|
||||
else delete out.enum
|
||||
}
|
||||
// `required` naming a property that is not there fails the entire request.
|
||||
if (Array.isArray(out.required)) {
|
||||
const names = isObj(out.properties) ? out.properties : {}
|
||||
const req = out.required.filter((r): r is string => typeof r === "string" && r in names)
|
||||
if (req.length) out.required = req
|
||||
else delete out.required
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** For `parameters`: the subset, and never an empty schema. */
|
||||
export function geminiParameters(s: unknown): Record<string, unknown> {
|
||||
const out = geminiSchema(s)
|
||||
return Object.keys(out).length ? out : { type: "object", properties: {} }
|
||||
}
|
||||
|
||||
function inlineRefs(node: unknown, root: Record<string, unknown>, budget: { n: number }, stack: string[] = []): unknown {
|
||||
if (Array.isArray(node)) return node.map((x) => inlineRefs(x, root, budget, stack))
|
||||
if (!isObj(node)) return node
|
||||
const ref = node.$ref
|
||||
if (typeof ref !== "string") return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, inlineRefs(v, root, budget, stack)]))
|
||||
if (stack.includes(ref)) throw new Error(`circular $ref ${ref}`)
|
||||
if (--budget.n < 0) throw new Error("$ref budget exhausted")
|
||||
if (!ref.startsWith("#/")) throw new Error(`unresolvable $ref ${ref}`)
|
||||
let target: unknown = root
|
||||
for (const raw of ref.slice(2).split("/")) {
|
||||
const part = raw.replace(/~1/g, "/").replace(/~0/g, "~")
|
||||
target = isObj(target) ? target[part] : undefined
|
||||
}
|
||||
if (!isObj(target)) throw new Error(`unresolvable $ref ${ref}`)
|
||||
const inlined = inlineRefs(target, root, budget, [...stack, ref]) as Record<string, unknown>
|
||||
// Siblings of a $ref apply alongside it and win.
|
||||
const { $ref: _, ...siblings } = node
|
||||
return Object.keys(siblings).length ? { ...inlined, ...(inlineRefs(siblings, root, budget, stack) as object) } : inlined
|
||||
}
|
||||
|
||||
/** For `parametersJsonSchema`: root $schema dropped, local $refs inlined, an object root. A schema
|
||||
* whose refs cannot all be resolved goes as it is, so the provider names the real problem. */
|
||||
export function geminiJsonSchema(s: unknown): Record<string, unknown> {
|
||||
if (!isObj(s) || !Object.keys(s).length) return { type: "object", properties: {} }
|
||||
const { $schema: _, ...rest } = structuredClone(s)
|
||||
let out: Record<string, unknown>
|
||||
try {
|
||||
out = inlineRefs(rest, rest, { n: 256 }) as Record<string, unknown>
|
||||
} catch {
|
||||
return rest
|
||||
}
|
||||
delete out.$defs
|
||||
delete out.definitions
|
||||
if (!Object.keys(out).length) return { type: "object", properties: {} }
|
||||
if (out.type === "object" && !("properties" in out)) out.properties = {}
|
||||
return out
|
||||
}
|
||||
|
||||
/** `parametersJsonSchema` exists only on v1beta. */
|
||||
export const acceptsJsonSchema = (base: string) => /\/v1beta\/*$/i.test(base.trim())
|
||||
|
||||
// ── history ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A JSON Schema inside a function response is resolved by Gemini 3, and an unknown $ref 400s. */
|
||||
const hasRef = (v: unknown): boolean => (Array.isArray(v) ? v.some(hasRef) : isObj(v) ? Object.entries(v).some(([k, x]) => (k === "$ref" && typeof x === "string" && x.startsWith("#/")) || hasRef(x)) : false)
|
||||
|
||||
function functionResponse(content: string, isError: boolean | undefined): Record<string, unknown> {
|
||||
if (isError) return { error: content }
|
||||
// A JSON object result goes structured; anything else under "output", as Google documents.
|
||||
if (/^\s*\{/.test(content))
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (isObj(parsed) && !hasRef(parsed)) return parsed
|
||||
} catch {}
|
||||
return { output: content }
|
||||
}
|
||||
|
||||
export function toGeminiContents(messages: Message[], vision: boolean, ids = false): Content[] {
|
||||
const out: Content[] = []
|
||||
const names = new Map<string, string>()
|
||||
const results = (c: Content) => c.parts.some((p) => "functionResponse" in p)
|
||||
const push = (role: Content["role"], parts: Record<string, unknown>[]) => {
|
||||
if (!parts.length) return
|
||||
const last = out[out.length - 1]
|
||||
// Same-role turns must merge (Gemini insists on alternation), except that a person's words are
|
||||
// never folded into a turn of tool results: Gemini 3 then reads them as part of the result and
|
||||
// answers with nothing. A placeholder model turn keeps the two apart.
|
||||
if (last && last.role === role && role === "user" && results(last) !== parts.some((p) => "functionResponse" in p)) out.push({ role: "model", parts: [{ text: PLACEHOLDER }] }, { role, parts })
|
||||
else if (last && last.role === role) last.parts.push(...parts)
|
||||
else out.push({ role, parts })
|
||||
}
|
||||
for (const m of messages) {
|
||||
if (m.role === "user")
|
||||
push(
|
||||
"user",
|
||||
m.parts.map((p) => (p.type === "text" ? { text: p.text } : vision ? { inlineData: { mimeType: p.mime, data: p.data } } : { text: "[image omitted: this model has no vision]" })),
|
||||
)
|
||||
else if (m.role === "assistant") {
|
||||
const parts: Record<string, unknown>[] = []
|
||||
for (const p of m.parts) {
|
||||
if (p.type === "text" && p.text.trim()) parts.push({ text: p.text })
|
||||
else if (p.type === "tool_call") {
|
||||
names.set(p.id, p.name)
|
||||
let args: unknown = {}
|
||||
try {
|
||||
args = p.args.trim() ? JSON.parse(p.args) : {}
|
||||
} catch {
|
||||
args = { _raw: p.args }
|
||||
}
|
||||
// Gemini 3 wants the thought signature back on the call it came with.
|
||||
parts.push({ functionCall: { name: p.name, args: isObj(args) ? args : { _value: args }, ...(ids ? { id: p.id } : {}) }, thoughtSignature: p.signature ?? SKIP_SIGNATURE })
|
||||
}
|
||||
}
|
||||
push("model", parts)
|
||||
} else push("user", [{ functionResponse: { name: names.get(m.callId) ?? m.name, response: functionResponse(m.content, m.isError), ...(ids ? { id: m.callId } : {}) } }])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── thinking ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const LEVELS = ["minimal", "low", "medium", "high"] as const
|
||||
/** The levels a Gemini 3 model takes (OpenCode's table). */
|
||||
function levelsOf(id: string): readonly string[] {
|
||||
const s = id.toLowerCase()
|
||||
if (s.includes("gemma") || s.includes("flash-image")) return ["minimal", "high"]
|
||||
if (s.includes("pro-image")) return ["high"]
|
||||
if (s.includes("flash")) return LEVELS
|
||||
return ["low", "medium", "high"]
|
||||
}
|
||||
|
||||
export function thinkingConfig(id: string, effort: Effort, map?: Partial<Record<Effort, number>>): Record<string, unknown> {
|
||||
if (map?.[effort]) return { thinkingBudget: map[effort], includeThoughts: true }
|
||||
const b = bare(id)
|
||||
if (LEGACY.test(b)) {
|
||||
// 2.x takes a budget; 2.5 Pro goes to 32k, the rest to 24k.
|
||||
const max = /gemini-2[.-]5/i.test(b) && /pro/i.test(b) && !/flash/i.test(b) ? 32768 : 24576
|
||||
return { thinkingBudget: Math.min(DEFAULT_BUDGETS[effort], max), includeThoughts: true }
|
||||
}
|
||||
// 3 and later take a level: the one asked for, else the next one up the model has, else its top.
|
||||
const i = LEVELS.indexOf(effort as (typeof LEVELS)[number])
|
||||
const want = i < 0 ? LEVELS.length - 1 : i // xhigh and max: the top
|
||||
const have = levelsOf(b)
|
||||
const level = LEVELS.slice(want).find((l) => have.includes(l)) ?? have[have.length - 1]
|
||||
return { thinkingLevel: level, includeThoughts: true }
|
||||
}
|
||||
|
||||
/** Keys sorted, so a call sent again compares equal to itself (as Hermes does). */
|
||||
const sortedJson = (v: unknown) => JSON.stringify(v, (_, x) => (isObj(x) ? Object.fromEntries(Object.keys(x).sort().map((k) => [k, x[k]])) : x))
|
||||
|
||||
const FINISH: Record<string, string> = { STOP: "stop", MAX_TOKENS: "length", SAFETY: "content_filter", RECITATION: "content_filter", BLOCKLIST: "content_filter", PROHIBITED_CONTENT: "content_filter", SPII: "content_filter" }
|
||||
|
||||
export class GeminiClient implements Client {
|
||||
constructor(private m: ResolvedModel) {}
|
||||
|
||||
private headers() {
|
||||
return authHeaders(this.m.connection, resolveKey(this.m.connectionName, this.m.connection), this.m.spec.headers)
|
||||
}
|
||||
|
||||
buildBody(req: ChatRequest): Record<string, unknown> {
|
||||
const { m } = this
|
||||
const generationConfig: Record<string, unknown> = {}
|
||||
if (m.spec.temperature !== undefined) generationConfig.temperature = m.spec.temperature
|
||||
generationConfig.maxOutputTokens = m.spec.max_output ?? DEFAULT_MAX_OUTPUT
|
||||
if (m.spec.top_p !== undefined) generationConfig.topP = m.spec.top_p
|
||||
if (req.effort) generationConfig.thinkingConfig = thinkingConfig(m.id, req.effort, m.spec.effort_map)
|
||||
const body: Record<string, unknown> = { contents: toGeminiContents(req.messages, m.spec.vision === true, isGemini3(m.id)), generationConfig, ...m.connection.body, ...m.spec.body }
|
||||
if (req.system) body.systemInstruction = { parts: [{ text: req.system }] }
|
||||
if (req.tools.length && m.spec.tools !== false) {
|
||||
const json = acceptsJsonSchema(m.connection.base_url)
|
||||
body.tools = [{ functionDeclarations: req.tools.map((t) => ({ name: t.name, description: t.description, ...(json ? { parametersJsonSchema: geminiJsonSchema(t.parameters) } : { parameters: geminiParameters(t.parameters) }) })) }]
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {
|
||||
const body = this.buildBody(req)
|
||||
yield* retrying(() => this.attempt(req, body))
|
||||
}
|
||||
|
||||
private async *attempt(req: ChatRequest, body: Record<string, unknown>): AsyncGenerator<StreamEvent> {
|
||||
const { m } = this
|
||||
const url = joinUrl(m.connection.base_url, `models/${encodeURIComponent(m.id)}:streamGenerateContent?alt=sse`)
|
||||
const res = await request(url, { method: "POST", headers: this.headers(), body: JSON.stringify(body), signal: req.signal, timeoutMs: (m.connection.timeout ?? 600) * 1000, tls: tlsFor(m.connection) }, m.ref).catch((e) => {
|
||||
recordFailure(m.ref, body, e)
|
||||
throw e
|
||||
})
|
||||
if (!res.body) throw new ProviderError(`${m.ref}: empty response`)
|
||||
const out = new Parts()
|
||||
let finish = "stop"
|
||||
let usage: StreamEvent | undefined
|
||||
// One slot per call. Gemini 3 ids its calls; 2.5 does not, restarts part numbering in every
|
||||
// event and may send a call again whole, so a payload repeating (or extending) a slot's arguments
|
||||
// is that call and anything else is a new one.
|
||||
const slots = new Map<string, { index: number; part: ToolCallPart }>()
|
||||
const slotFor = (fc: any, sig: string, at: number, args: string) => {
|
||||
if (typeof fc.id === "string" && fc.id) return `id:${fc.id}`
|
||||
const key = `${at}:${fc.name}:${sig}`
|
||||
const s = slots.get(key)
|
||||
if (!s || args.startsWith(s.part.args)) return key
|
||||
for (const [k, o] of slots) if (k.startsWith(`${key}#`) && args.startsWith(o.part.args)) return k
|
||||
return `${key}#${slots.size}`
|
||||
}
|
||||
for await (const { data } of sseJson(record(res.body, m.ref, body))) {
|
||||
const chunk = data as any
|
||||
if (chunk?.error) throw new ProviderError(`${m.ref}: ${chunk.error.message ?? JSON.stringify(chunk.error)}`, statusOf(chunk.error) ?? chunk.error.code, undefined, !out.output)
|
||||
const cand = chunk?.candidates?.[0]
|
||||
const parts: any[] = cand?.content?.parts ?? []
|
||||
for (const [at, p] of parts.entries()) {
|
||||
if (typeof p.text === "string" && p.text) {
|
||||
const kind = p.thought === true ? "reasoning" : "text"
|
||||
out.add(kind, p.text)
|
||||
yield { type: kind, text: p.text }
|
||||
}
|
||||
const fc = p.functionCall
|
||||
if (fc?.name) {
|
||||
const args = sortedJson(fc.args ?? {})
|
||||
const sig = typeof p.thoughtSignature === "string" ? p.thoughtSignature : ""
|
||||
const key = slotFor(fc, sig, at, args)
|
||||
let s = slots.get(key)
|
||||
if (!s) {
|
||||
s = { index: slots.size, part: { type: "tool_call", id: typeof fc.id === "string" && fc.id ? fc.id : `gemini_call_${slots.size}`, name: fc.name, args: "" } }
|
||||
slots.set(key, s)
|
||||
out.push(s.part)
|
||||
}
|
||||
const prev = s.part.args
|
||||
s.part.args = args
|
||||
if (sig) s.part.signature = sig
|
||||
yield { type: "tool_call_delta", index: s.index, name: fc.name, argsDelta: args.startsWith(prev) ? args.slice(prev.length) : args }
|
||||
}
|
||||
}
|
||||
if (cand?.finishReason) finish = FINISH[String(cand.finishReason).toUpperCase()] ?? "stop"
|
||||
const u = chunk?.usageMetadata
|
||||
if (u) usage = { type: "usage", usage: { input: u.promptTokenCount ?? 0, output: (u.candidatesTokenCount ?? 0) + (u.thoughtsTokenCount ?? 0), reasoning: u.thoughtsTokenCount, cached: u.cachedContentTokenCount } }
|
||||
}
|
||||
const message = out.message()
|
||||
if (slots.size && finish !== "length") finish = "tool_calls"
|
||||
yield usage ?? { type: "usage", usage: { input: estimateTokens(JSON.stringify(body.contents)), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true } }
|
||||
yield { type: "finish", reason: finish, message }
|
||||
}
|
||||
|
||||
async listModels(): Promise<DiscoveredModel[]> {
|
||||
const res = await request(joinUrl(this.m.connection.base_url, "models?pageSize=200"), { headers: this.headers(), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
|
||||
const j = (await res.json()) as any
|
||||
return (j?.models ?? [])
|
||||
.filter((x: any) => !x.supportedGenerationMethods || x.supportedGenerationMethods.includes("generateContent"))
|
||||
.map((x: any) => ({ id: String(x.name ?? "").replace(/^models\//, ""), context: x.inputTokenLimit }))
|
||||
.filter((x: DiscoveredModel) => x.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import { expandHome } from "../config/paths.ts"
|
||||
import type { Connection } from "../config/schema.ts"
|
||||
import { ProviderError } from "./types.ts"
|
||||
import { duration } from "../duration.ts"
|
||||
|
||||
const caCache = new Map<string, string>()
|
||||
|
||||
/** Bun's fetch `tls` option for a connection, or undefined for the defaults. */
|
||||
export function tlsFor(c: Pick<Connection, "tls">): { ca?: string; rejectUnauthorized?: boolean } | undefined {
|
||||
if (!c.tls) return undefined
|
||||
const out: { ca?: string; rejectUnauthorized?: boolean } = {}
|
||||
if (c.tls.ca) {
|
||||
const file = expandHome(c.tls.ca)
|
||||
let pem = caCache.get(file)
|
||||
if (!pem) {
|
||||
try {
|
||||
pem = readFileSync(file, "utf8")
|
||||
} catch {
|
||||
throw new ProviderError(`cannot read tls.ca file ${c.tls.ca}`)
|
||||
}
|
||||
caCache.set(file, pem)
|
||||
}
|
||||
out.ca = pem
|
||||
}
|
||||
if (c.tls.insecure) out.rejectUnauthorized = false
|
||||
return out
|
||||
}
|
||||
|
||||
const DEFAULT_AUTH: Record<Connection["dialect"], NonNullable<Connection["auth"]>> = {
|
||||
"openai-chat": "bearer",
|
||||
responses: "bearer",
|
||||
anthropic: "x-api-key",
|
||||
gemini: "x-goog-api-key",
|
||||
ollama: "bearer",
|
||||
}
|
||||
|
||||
/** Auth plus the connection's and model's own headers. */
|
||||
export function authHeaders(c: Connection, key: string | undefined, extra: Record<string, string> = {}): Record<string, string> {
|
||||
const h: Record<string, string> = { "content-type": "application/json", ...extra }
|
||||
const style = c.auth ?? DEFAULT_AUTH[c.dialect]
|
||||
if (key && style === "bearer") h.authorization = `Bearer ${key}`
|
||||
else if (key && style !== "none") h[style] = key
|
||||
return { ...h, ...c.headers }
|
||||
}
|
||||
|
||||
export function joinUrl(base: string, path: string): string {
|
||||
return base.replace(/\/+$/, "") + "/" + path.replace(/^\/+/, "")
|
||||
}
|
||||
|
||||
/** Turn an HTTP error body into one readable line: provider JSON errors nest the message differently. */
|
||||
export function describeError(status: number, body: string): string {
|
||||
let msg = body.trim()
|
||||
try {
|
||||
const j = JSON.parse(body) as any
|
||||
msg = j?.error?.message ?? j?.error ?? j?.message ?? j?.detail ?? msg
|
||||
if (typeof msg !== "string") msg = JSON.stringify(msg)
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
if (msg.length > 600) msg = msg.slice(0, 600) + "…"
|
||||
return `HTTP ${status}: ${msg || "(empty body)"}`
|
||||
}
|
||||
|
||||
/** At most `max` bytes of a body, as text: an error page can be endless, or a gzip bomb. */
|
||||
async function cappedText(res: Response, max: number): Promise<string> {
|
||||
if (!res.body) return ""
|
||||
const reader = res.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done || !value) break
|
||||
chunks.push(value)
|
||||
size += value.length
|
||||
if (size >= max) {
|
||||
await reader.cancel().catch(() => {})
|
||||
break
|
||||
}
|
||||
}
|
||||
return new TextDecoder().decode(Buffer.concat(chunks).subarray(0, max))
|
||||
}
|
||||
|
||||
export async function request(
|
||||
url: string,
|
||||
init: RequestInit & { timeoutMs?: number; tls?: { ca?: string; rejectUnauthorized?: boolean } },
|
||||
what: string,
|
||||
): Promise<Response> {
|
||||
// The timeout is for silence, not for length: waiting for the response, then any gap between two
|
||||
// pieces of the body. A reply may stream for as long as it keeps streaming — with a large
|
||||
// max_output a model can think for half an hour, and a total limit cut that off mid-reply.
|
||||
const ms = init.timeoutMs ?? 600_000
|
||||
const ctl = new AbortController()
|
||||
let silent = false
|
||||
let timer = setTimeout(() => ((silent = true), ctl.abort()), ms)
|
||||
const signal = init.signal ? AbortSignal.any([init.signal, ctl.signal]) : ctl.signal
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url, { ...init, signal })
|
||||
} catch (e) {
|
||||
clearTimeout(timer)
|
||||
const err = e as Error
|
||||
if (init.signal?.aborted) throw new ProviderError("cancelled")
|
||||
if (silent || err.name === "TimeoutError") throw new ProviderError(`${what}: timed out — no response in ${duration(ms, true)}`, undefined, undefined, true, true)
|
||||
const code = (err as { code?: string }).code ?? ""
|
||||
if (/CERT|SIGNATURE|SELF_SIGNED/.test(code))
|
||||
throw new ProviderError(`${what}: TLS certificate of ${new URL(url).host} is not trusted (${code}). Install its CA system-wide, or set tls.ca on the connection.`)
|
||||
throw new ProviderError(`${what}: cannot reach ${new URL(url).host} — ${err.message}`, undefined, undefined, true, true)
|
||||
}
|
||||
clearTimeout(timer)
|
||||
// A redirect asked to be handled by hand is a response, not an error.
|
||||
if (!res.ok && !(init.redirect === "manual" && res.status >= 300 && res.status < 400)) {
|
||||
const body = await cappedText(res, 64 * 1024).catch(() => "")
|
||||
throw new ProviderError(`${what}: ${describeError(res.status, body)}`, res.status, body)
|
||||
}
|
||||
if (!res.body) return res
|
||||
const reader = res.body.getReader()
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(c) {
|
||||
timer = setTimeout(() => ((silent = true), ctl.abort()), ms)
|
||||
try {
|
||||
const r = await reader.read()
|
||||
if (r.done) c.close()
|
||||
else c.enqueue(r.value)
|
||||
} catch (e) {
|
||||
if (init.signal?.aborted) throw new ProviderError("cancelled")
|
||||
if (silent) throw new ProviderError(`${what}: timed out — the server sent nothing for ${duration(ms, true)}`, undefined, undefined, false)
|
||||
throw e
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
},
|
||||
cancel(reason) {
|
||||
clearTimeout(timer)
|
||||
return reader.cancel(reason)
|
||||
},
|
||||
})
|
||||
return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers })
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { findRef, modelRefs } from "./refs.ts"
|
||||
import type { Loaded } from "../config/load.ts"
|
||||
import { AnthropicClient } from "./anthropic.ts"
|
||||
import { OpenAIChatClient } from "./openai-chat.ts"
|
||||
import { ResponsesClient } from "./responses.ts"
|
||||
import { GeminiClient } from "./gemini.ts"
|
||||
import { OllamaClient } from "./ollama.ts"
|
||||
import { ProviderError, type Client, type ResolvedModel } from "./types.ts"
|
||||
|
||||
export class ModelError extends Error {}
|
||||
|
||||
/** `connection/model`. The model part may itself contain slashes (`openrouter/qwen/qwen3-coder`). */
|
||||
export function resolveModel(loaded: Loaded, ref: string | undefined): ResolvedModel {
|
||||
const refs = loaded.refs ?? modelRefs(loaded.connections)
|
||||
if (!ref) {
|
||||
// A LLeMbas instance says which model its account starts on; that, before anything else.
|
||||
const instance = Object.entries(loaded.connections).find(([, c]) => c.webui?.default && c.models[c.webui.default])
|
||||
if (instance) ref = refs.find((r) => r.connection === instance[0] && r.id === instance[1].webui!.default)?.ref ?? `${instance[0]}/${instance[1].webui!.default}`
|
||||
}
|
||||
if (!ref) {
|
||||
const first = refs[0]
|
||||
if (!first) throw new ModelError("No model configured. Add a connection to ~/.config/lembas/connections.yaml and set `model:` in config.yaml.")
|
||||
ref = first.ref
|
||||
}
|
||||
// Any form of the name (`deepseek/x`, or the old `example/x`): the model it names, under
|
||||
// the ref it has now.
|
||||
const known = findRef(loaded.connections, refs, ref)
|
||||
if (known) {
|
||||
const connection = loaded.connections[known.connection]!
|
||||
if (loaded.broken?.[known.connection]) throw new ModelError(`Connection "${known.connection}" is unusable: ${loaded.broken?.[known.connection]}`)
|
||||
return { ref: known.ref, connectionName: known.connection, connection, id: known.id, spec: connection.models[known.id] ?? {} }
|
||||
}
|
||||
const slash = ref.indexOf("/")
|
||||
if (slash <= 0) throw new ModelError(`Model "${ref}" must be written connection/model`)
|
||||
const connectionName = ref.slice(0, slash)
|
||||
const id = ref.slice(slash + 1)
|
||||
if (loaded.broken?.[connectionName]) throw new ModelError(`Connection "${connectionName}" is unusable: ${loaded.broken[connectionName]}`)
|
||||
const connection = loaded.connections[connectionName]
|
||||
if (!connection) throw new ModelError(`No connection named "${connectionName}" (have: ${Object.keys(loaded.connections).join(", ") || "none"})`)
|
||||
const spec = connection.models[id]
|
||||
if (!spec && !connection.discover)
|
||||
throw new ModelError(`Connection "${connectionName}" has no model "${id}" (have: ${Object.keys(connection.models).join(", ") || "none"}). Add it, or set discover: true.`)
|
||||
return { ref, connectionName, connection, id, spec: spec ?? {} }
|
||||
}
|
||||
|
||||
export function clientFor(m: ResolvedModel): Client {
|
||||
switch (m.connection.dialect) {
|
||||
case "openai-chat":
|
||||
return new OpenAIChatClient(m)
|
||||
case "anthropic":
|
||||
return new AnthropicClient(m)
|
||||
case "responses":
|
||||
return new ResponsesClient(m)
|
||||
case "gemini":
|
||||
return new GeminiClient(m)
|
||||
case "ollama":
|
||||
return new OllamaClient(m)
|
||||
}
|
||||
}
|
||||
|
||||
/** Which prompt family a model gets when the config does not say: anthropic, gpt, gemini, or
|
||||
* local — every open-weight or unknown model, which gets Hermes' act-don't-describe guidance. */
|
||||
export function familyOf(m: ResolvedModel): string {
|
||||
if (m.spec.family) return m.spec.family
|
||||
const id = m.id.toLowerCase()
|
||||
if (id.includes("claude")) return "anthropic"
|
||||
if (id.includes("gemini")) return "gemini"
|
||||
if (/\bgpt|^o[134]\b|^o[134]-|codex/.test(id)) return "gpt"
|
||||
return "local"
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import type { Effort } from "../config/schema.ts"
|
||||
import { paths } from "../config/paths.ts"
|
||||
|
||||
// What endpoints have taught us, kept across runs in ~/.local/state/lembas/learned.json.
|
||||
// Only ever narrows behaviour; deleting the file is always safe.
|
||||
interface Learned {
|
||||
/** `connection/model` → the efforts it actually accepts. */
|
||||
efforts: Record<string, Effort[]>
|
||||
/** base URLs that reject `stream_options`. */
|
||||
noStreamOptions: string[]
|
||||
/** base URLs that reject `return_progress`. */
|
||||
noProgress: string[]
|
||||
/** `connection/model` → context window found by discovery (config always wins). */
|
||||
contexts: Record<string, number>
|
||||
}
|
||||
|
||||
let cache: Learned | undefined
|
||||
|
||||
function file() {
|
||||
return join(paths.state, "learned.json")
|
||||
}
|
||||
|
||||
export function learned(): Learned {
|
||||
if (cache) return cache
|
||||
try {
|
||||
cache = { efforts: {}, noStreamOptions: [], noProgress: [], contexts: {}, ...JSON.parse(readFileSync(file(), "utf8")) }
|
||||
} catch {
|
||||
cache = { efforts: {}, noStreamOptions: [], noProgress: [], contexts: {} }
|
||||
}
|
||||
return cache!
|
||||
}
|
||||
|
||||
function save() {
|
||||
try {
|
||||
mkdirSync(dirname(file()), { recursive: true })
|
||||
writeFileSync(file(), JSON.stringify(learned(), null, 2))
|
||||
} catch {
|
||||
// bookkeeping must never fail a reply
|
||||
}
|
||||
}
|
||||
|
||||
export function learnEfforts(ref: string, efforts: Effort[]) {
|
||||
learned().efforts[ref] = efforts
|
||||
save()
|
||||
}
|
||||
|
||||
export function learnContext(ref: string, n: number) {
|
||||
learned().contexts[ref] = n
|
||||
save()
|
||||
}
|
||||
|
||||
export function learnNoStreamOptions(baseUrl: string) {
|
||||
const l = learned()
|
||||
if (!l.noStreamOptions.includes(baseUrl)) l.noStreamOptions.push(baseUrl)
|
||||
save()
|
||||
}
|
||||
|
||||
export function learnNoProgress(baseUrl: string) {
|
||||
const l = learned()
|
||||
if (!l.noProgress.includes(baseUrl)) l.noProgress.push(baseUrl)
|
||||
save()
|
||||
}
|
||||
|
||||
/** Tests only. */
|
||||
export function resetLearned() {
|
||||
cache = undefined
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Ollama, native (`POST {base}/api/chat`, NDJSON), base e.g. http://localhost:11434. Its
|
||||
// OpenAI-compatible /v1 works through openai-chat too; the native API adds num_ctx (without it
|
||||
// Ollama silently cuts every conversation to its small default window) and `think` levels.
|
||||
// UNTESTED: no Ollama server has been available to try it against (see the wiki). Checked against
|
||||
// Hermes Agent's handling (agent/model_metadata.py, agent/reasoning_params.py): /api/show decides
|
||||
// the window to ask for and whether `think` may be sent at all.
|
||||
import { resolveKey } from "../config/load.ts"
|
||||
import { Parts, record, recordFailure, retrying, statusOf } from "./common.ts"
|
||||
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
|
||||
import { estimateTokens } from "./tokens.ts"
|
||||
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent } from "./types.ts"
|
||||
|
||||
export function toOllamaMessages(system: string, messages: Message[], vision: boolean): Record<string, unknown>[] {
|
||||
const out: Record<string, unknown>[] = []
|
||||
if (system) out.push({ role: "system", content: system })
|
||||
for (const m of messages) {
|
||||
if (m.role === "user") {
|
||||
const images = vision ? m.parts.filter((p) => p.type === "image").map((p) => (p as { data: string }).data) : []
|
||||
const text = m.parts.map((p) => (p.type === "text" ? p.text : vision ? "" : "[image omitted: this model has no vision]")).filter(Boolean).join("\n")
|
||||
out.push({ role: "user", content: text, ...(images.length ? { images } : {}) })
|
||||
} else if (m.role === "assistant") {
|
||||
const text = m.parts.map((p) => (p.type === "text" ? p.text : "")).join("")
|
||||
const thinking = m.parts.map((p) => (p.type === "reasoning" ? p.text : "")).join("")
|
||||
const calls = m.parts.flatMap((p) => {
|
||||
if (p.type !== "tool_call") return []
|
||||
let args: unknown = {}
|
||||
try {
|
||||
args = p.args.trim() ? JSON.parse(p.args) : {}
|
||||
} catch {}
|
||||
return [{ function: { name: p.name, arguments: args } }]
|
||||
})
|
||||
out.push({ role: "assistant", content: text, ...(thinking ? { thinking } : {}), ...(calls.length ? { tool_calls: calls } : {}) })
|
||||
} else out.push({ role: "tool", content: m.content, tool_name: m.name })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** NDJSON: one JSON object per line. */
|
||||
async function* ndjson(body: ReadableStream<Uint8Array>): AsyncGenerator<unknown> {
|
||||
const dec = new TextDecoder()
|
||||
let buf = ""
|
||||
for await (const chunk of body) {
|
||||
buf += dec.decode(chunk, { stream: true })
|
||||
let nl: number
|
||||
while ((nl = buf.indexOf("\n")) !== -1) {
|
||||
const line = buf.slice(0, nl).trim()
|
||||
buf = buf.slice(nl + 1)
|
||||
if (line)
|
||||
try {
|
||||
yield JSON.parse(line)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
const last = (buf + dec.decode()).trim()
|
||||
if (last)
|
||||
try {
|
||||
yield JSON.parse(last)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** What /api/show says about a model. */
|
||||
export interface OllamaShow {
|
||||
/** "completion", "tools", "thinking", "vision"…; absent before Ollama 0.6. */
|
||||
capabilities?: string[]
|
||||
/** The runtime window the Modelfile sets (`num_ctx`), which the owner of the server chose. */
|
||||
numCtx?: number
|
||||
/** The trained maximum (`model_info["<arch>.context_length"]`). */
|
||||
trained?: number
|
||||
}
|
||||
|
||||
export function parseShow(j: any): OllamaShow {
|
||||
const out: OllamaShow = {}
|
||||
if (Array.isArray(j?.capabilities)) out.capabilities = j.capabilities.map(String)
|
||||
for (const line of String(j?.parameters ?? "").split("\n")) {
|
||||
const f = line.trim().split(/\s+/)
|
||||
if (f[0] === "num_ctx" && Number(f[1]) > 0) out.numCtx = Number(f[1])
|
||||
}
|
||||
const info = j?.model_info ?? {}
|
||||
const key = Object.keys(info).find((k) => k.endsWith(".context_length"))
|
||||
if (key && Number(info[key]) > 0) out.trained = Number(info[key])
|
||||
return out
|
||||
}
|
||||
|
||||
export class OllamaClient implements Client {
|
||||
private shown?: Promise<OllamaShow | undefined>
|
||||
constructor(private m: ResolvedModel) {}
|
||||
|
||||
/** /api/show for this model, asked once; undefined when the server does not answer it. */
|
||||
show(id = this.m.id): Promise<OllamaShow | undefined> {
|
||||
const ask = async () => {
|
||||
const res = await request(joinUrl(this.m.connection.base_url, "api/show"), { method: "POST", headers: this.headers(), body: JSON.stringify({ model: id, name: id }), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
|
||||
return parseShow(await res.json())
|
||||
}
|
||||
if (id !== this.m.id) return ask().catch(() => undefined)
|
||||
return (this.shown ??= ask().catch(() => undefined))
|
||||
}
|
||||
|
||||
private headers() {
|
||||
return authHeaders(this.m.connection, resolveKey(this.m.connectionName, this.m.connection), this.m.spec.headers)
|
||||
}
|
||||
|
||||
buildBody(req: ChatRequest, show?: OllamaShow): Record<string, unknown> {
|
||||
const { m } = this
|
||||
const options: Record<string, unknown> = {}
|
||||
// Without num_ctx Ollama cuts every conversation to its small default window. Configured wins;
|
||||
// else the Modelfile's, else the trained maximum (set `context` to cap it to what memory holds).
|
||||
const ctx = m.spec.context ?? show?.numCtx ?? show?.trained
|
||||
if (ctx) options.num_ctx = ctx
|
||||
if (m.spec.max_output) options.num_predict = m.spec.max_output
|
||||
if (m.spec.temperature !== undefined) options.temperature = m.spec.temperature
|
||||
if (m.spec.top_p !== undefined) options.top_p = m.spec.top_p
|
||||
const body: Record<string, unknown> = { model: m.id, messages: toOllamaMessages(req.system, req.messages, m.spec.vision === true), stream: true, options, ...m.connection.body, ...m.spec.body }
|
||||
if (req.tools.length && m.spec.tools !== false)
|
||||
body.tools = req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }))
|
||||
// gpt-oss takes a level; other thinking models take a switch. A model /api/show says cannot
|
||||
// think gets no `think` at all: Ollama refuses the request.
|
||||
const thinks = !show?.capabilities || show.capabilities.includes("thinking")
|
||||
if (thinks && req.effort) body.think = /gpt-oss/.test(m.id) ? (req.effort === "minimal" ? "low" : req.effort === "xhigh" || req.effort === "max" ? "high" : req.effort) : true
|
||||
else if (thinks && m.spec.efforts?.length) body.think = false
|
||||
return body
|
||||
}
|
||||
|
||||
async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {
|
||||
const body = this.buildBody(req, await this.show())
|
||||
yield* retrying(() => this.attempt(req, body))
|
||||
}
|
||||
|
||||
private async *attempt(req: ChatRequest, body: Record<string, unknown>): AsyncGenerator<StreamEvent> {
|
||||
const { m } = this
|
||||
const res = await request(joinUrl(m.connection.base_url, "api/chat"), { method: "POST", headers: this.headers(), body: JSON.stringify(body), signal: req.signal, timeoutMs: (m.connection.timeout ?? 600) * 1000, tls: tlsFor(m.connection) }, m.ref).catch((e) => {
|
||||
recordFailure(m.ref, body, e)
|
||||
throw e
|
||||
})
|
||||
if (!res.body) throw new ProviderError(`${m.ref}: empty response`)
|
||||
const out = new Parts()
|
||||
let finish = "stop"
|
||||
let usage: StreamEvent | undefined
|
||||
let calls = 0
|
||||
for await (const chunk of ndjson(record(res.body, m.ref, body)) as AsyncGenerator<any>) {
|
||||
if (chunk?.error) throw new ProviderError(`${m.ref}: ${chunk.error}`, statusOf(chunk) ?? 500, undefined, !out.output)
|
||||
const msg = chunk?.message ?? {}
|
||||
if (msg.thinking) {
|
||||
out.add("reasoning", msg.thinking)
|
||||
yield { type: "reasoning", text: msg.thinking }
|
||||
}
|
||||
if (msg.content) {
|
||||
out.add("text", msg.content)
|
||||
yield { type: "text", text: msg.content }
|
||||
}
|
||||
for (const tc of msg.tool_calls ?? []) {
|
||||
const args = JSON.stringify(tc.function?.arguments ?? {})
|
||||
yield { type: "tool_call_delta", index: calls, name: tc.function?.name, argsDelta: args }
|
||||
out.push({ type: "tool_call", id: tc.id ?? `ollama_call_${calls}`, name: tc.function?.name ?? "", args })
|
||||
calls++
|
||||
}
|
||||
if (chunk?.done) {
|
||||
finish = chunk.done_reason === "length" ? "length" : "stop"
|
||||
if (chunk.prompt_eval_count !== undefined || chunk.eval_count !== undefined) usage = { type: "usage", usage: { input: chunk.prompt_eval_count ?? 0, output: chunk.eval_count ?? 0 } }
|
||||
}
|
||||
}
|
||||
const message = out.message()
|
||||
if (calls && finish !== "length") finish = "tool_calls"
|
||||
yield usage ?? { type: "usage", usage: { input: estimateTokens(JSON.stringify(body.messages)), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true } }
|
||||
yield { type: "finish", reason: finish, message }
|
||||
}
|
||||
|
||||
async listModels(): Promise<DiscoveredModel[]> {
|
||||
const res = await request(joinUrl(this.m.connection.base_url, "api/tags"), { headers: this.headers(), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
|
||||
const j = (await res.json()) as any
|
||||
return (j?.models ?? []).map((x: any) => ({ id: String(x.name ?? x.model ?? "") })).filter((x: DiscoveredModel) => x.id)
|
||||
}
|
||||
|
||||
/** The model's window: the Modelfile's num_ctx, else its trained maximum. */
|
||||
async contextOf(id: string): Promise<number | undefined> {
|
||||
const s = await this.show(id)
|
||||
return s?.numCtx ?? s?.trained
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// OpenAI chat completions — and every server that imitates it: llama.cpp / llama-swap, vLLM,
|
||||
// LM Studio, Ollama's /v1, OpenRouter, DeepSeek. The quirks handled here are the ones LLeMbas
|
||||
// catalogued in production (see its Working-notes): usage that is never sent, `stream_options`
|
||||
// that is a 400, reasoning in `reasoning_content` or `reasoning` or inline <think> tags, tool-call
|
||||
// fragments without an index, arguments sent as an object, and chat templates that refuse efforts.
|
||||
import type { Effort } from "../config/schema.ts"
|
||||
import { resolveKey } from "../config/load.ts"
|
||||
import { advertisedEfforts, applyEffort, effortRefused, effortsFor, stripEffort } from "./effort.ts"
|
||||
import { authHeaders, request, joinUrl, tlsFor } from "./http.ts"
|
||||
import { learned, learnEfforts, learnNoProgress, learnNoStreamOptions } from "./learned.ts"
|
||||
import { historyArgs, Parts, record, recordFailure, retrying, statusOf, SwapBanner } from "./common.ts"
|
||||
import { sseJson } from "./sse.ts"
|
||||
import { ThinkSplitter } from "./think.ts"
|
||||
import { estimateTokens } from "./tokens.ts"
|
||||
import {
|
||||
ProviderError,
|
||||
type ChatRequest,
|
||||
type Client,
|
||||
type DiscoveredModel,
|
||||
type Message,
|
||||
type ResolvedModel,
|
||||
type StreamEvent,
|
||||
type ToolCallPart,
|
||||
type ReasoningPart,
|
||||
type TextPart,
|
||||
} from "./types.ts"
|
||||
|
||||
type Assistant = Extract<Message, { role: "assistant" }>
|
||||
|
||||
export function toOpenAIMessages(system: string, messages: Message[], vision: boolean): unknown[] {
|
||||
const out: unknown[] = []
|
||||
if (system) out.push({ role: "system", content: system })
|
||||
for (const m of messages) {
|
||||
if (m.role === "user") {
|
||||
const images = m.parts.filter((p) => p.type === "image")
|
||||
if (images.length === 0 || !vision) {
|
||||
const text = m.parts.map((p) => (p.type === "text" ? p.text : "[image omitted: model has no vision]")).join("\n")
|
||||
out.push({ role: "user", content: text })
|
||||
} else {
|
||||
out.push({
|
||||
role: "user",
|
||||
content: m.parts.map((p) =>
|
||||
p.type === "text"
|
||||
? { type: "text", text: p.text }
|
||||
: { type: "image_url", image_url: { url: `data:${p.mime};base64,${p.data}` } },
|
||||
),
|
||||
})
|
||||
}
|
||||
} else if (m.role === "assistant") {
|
||||
const text = m.parts.filter((p): p is TextPart => p.type === "text").map((p) => p.text).join("")
|
||||
const calls = m.parts.filter((p): p is ToolCallPart => p.type === "tool_call")
|
||||
const msg: Record<string, unknown> = { role: "assistant", content: text || null }
|
||||
if (calls.length)
|
||||
msg.tool_calls = calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: historyArgs(c.args) } }))
|
||||
out.push(msg)
|
||||
} else {
|
||||
out.push({ role: "tool", tool_call_id: m.callId, content: m.content })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Reassembles streamed tool-call fragments, keyed by index (the only field on every fragment). */
|
||||
export class ToolCallAccumulator {
|
||||
private calls = new Map<number, { id: string; name: string; args: string }>()
|
||||
|
||||
feed(fragments: unknown): { index: number; name?: string; argsDelta: string }[] {
|
||||
const deltas: { index: number; name?: string; argsDelta: string }[] = []
|
||||
if (!Array.isArray(fragments)) return deltas
|
||||
for (const f of fragments as any[]) {
|
||||
if (!f || typeof f !== "object") continue
|
||||
// Some servers omit index when there is only one call.
|
||||
const index = Number.isInteger(f.index) ? (f.index as number) : 0
|
||||
let call = this.calls.get(index)
|
||||
if (!call) this.calls.set(index, (call = { id: "", name: "", args: "" }))
|
||||
if (f.id) call.id = String(f.id)
|
||||
const fn = f.function ?? {}
|
||||
let name: string | undefined
|
||||
if (fn.name) name = call.name = String(fn.name)
|
||||
let delta = ""
|
||||
if (typeof fn.arguments === "string") delta = fn.arguments
|
||||
else if (fn.arguments && typeof fn.arguments === "object") delta = JSON.stringify(fn.arguments)
|
||||
call.args += delta
|
||||
deltas.push({ index, name, argsDelta: delta })
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
|
||||
result(): ToolCallPart[] {
|
||||
return [...this.calls.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.filter(([, c]) => c.name)
|
||||
.map(([i, c]) => ({ type: "tool_call", id: c.id || `call_${i}_${Date.now().toString(36)}`, name: c.name, args: c.args }))
|
||||
}
|
||||
}
|
||||
|
||||
function textOf(content: unknown): string {
|
||||
if (typeof content === "string") return content
|
||||
if (Array.isArray(content))
|
||||
return content.map((p: any) => (typeof p === "string" ? p : typeof p?.text === "string" ? p.text : "")).join("")
|
||||
return ""
|
||||
}
|
||||
|
||||
export class OpenAIChatClient implements Client {
|
||||
constructor(private m: ResolvedModel) {}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return authHeaders(this.m.connection, resolveKey(this.m.connectionName, this.m.connection), this.m.spec.headers)
|
||||
}
|
||||
|
||||
buildBody(req: ChatRequest): Record<string, unknown> {
|
||||
const { m } = this
|
||||
const body: Record<string, unknown> = {
|
||||
model: m.id,
|
||||
messages: toOpenAIMessages(req.system, req.messages, m.spec.vision === true),
|
||||
stream: true,
|
||||
...m.connection.body,
|
||||
...m.spec.body,
|
||||
}
|
||||
if (req.tools.length && m.spec.tools !== false)
|
||||
body.tools = req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }))
|
||||
if (m.spec.max_output) body[m.connection.quirks?.max_tokens_field ?? "max_tokens"] = m.spec.max_output
|
||||
if (m.spec.temperature !== undefined) body.temperature = m.spec.temperature
|
||||
if (m.spec.top_p !== undefined) body.top_p = m.spec.top_p
|
||||
applyEffort(body, m, req.effort)
|
||||
return body
|
||||
}
|
||||
|
||||
async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {
|
||||
const { m } = this
|
||||
const base = m.connection.base_url
|
||||
let body = this.buildBody(req)
|
||||
const usageQuirk = m.connection.quirks?.stream_usage ?? "auto"
|
||||
let wantUsage = usageQuirk === "on" || (usageQuirk === "auto" && !learned().noStreamOptions.includes(base))
|
||||
// llama.cpp says how far it is through reading the prompt — minutes, for a long conversation
|
||||
// on a local model. Asked for everywhere; a server that ignores it sends nothing more, and one
|
||||
// that refuses it is retried without and remembered.
|
||||
const progressQuirk = m.connection.quirks?.prompt_progress ?? "auto"
|
||||
let wantProgress = progressQuirk === "on" || (progressQuirk === "auto" && !learned().noProgress.includes(base))
|
||||
let droppedProgress = false
|
||||
|
||||
// Beyond the shared rule, three quirks of OpenAI-compatible servers are retried: a chat template
|
||||
// refusing the effort (llama.cpp — learned, then dropped), and a 400 for return_progress, then
|
||||
// for stream_options.
|
||||
yield* retrying(
|
||||
() => this.attempt(req, body, wantUsage, wantProgress),
|
||||
(e) => {
|
||||
const refused = (body.reasoning_effort ?? (body.chat_template_kwargs as any)?.reasoning_effort) as Effort | undefined
|
||||
if (refused && effortRefused(e.message)) {
|
||||
const advertised = advertisedEfforts(e.message).filter((x) => x !== refused)
|
||||
const narrowed = advertised.length ? advertised : effortsFor(m).filter((x) => x !== refused)
|
||||
if (narrowed.length) learnEfforts(m.ref, narrowed)
|
||||
body = stripEffort(body)
|
||||
return { retry: true, notice: `${m.ref} refused effort "${refused}"; retried without it (now offering ${narrowed.join(", ") || "none"})` }
|
||||
}
|
||||
// A 400 that names stream_options is about that; one that names neither field drops
|
||||
// progress first (the newer and rarer of the two).
|
||||
if (wantProgress && progressQuirk === "auto" && (e.status === 400 || e.status === 422) && !/stream_options/i.test(e.message)) {
|
||||
wantProgress = false
|
||||
droppedProgress = true
|
||||
// Remembered at once when the server names it; otherwise only if the retry then works,
|
||||
// so a 400 for something else (a prompt too long) does not switch progress off for good.
|
||||
if (/return_progress/i.test(e.message)) learnNoProgress(base)
|
||||
return { retry: true }
|
||||
}
|
||||
if (wantUsage && usageQuirk === "auto" && (e.status === 400 || e.status === 422)) {
|
||||
wantUsage = false
|
||||
learnNoStreamOptions(base)
|
||||
return { retry: true }
|
||||
}
|
||||
return { retry: false }
|
||||
},
|
||||
)
|
||||
if (droppedProgress) learnNoProgress(base)
|
||||
}
|
||||
|
||||
private async *attempt(req: ChatRequest, body: Record<string, unknown>, wantUsage: boolean, wantProgress = false): AsyncGenerator<StreamEvent> {
|
||||
const { m } = this
|
||||
const sent = { ...body, ...(wantUsage ? { stream_options: { include_usage: true } } : {}), ...(wantProgress ? { return_progress: true } : {}) }
|
||||
const res = await request(
|
||||
joinUrl(m.connection.base_url, "chat/completions"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(sent),
|
||||
signal: req.signal,
|
||||
timeoutMs: (m.connection.timeout ?? 600) * 1000,
|
||||
tls: tlsFor(m.connection),
|
||||
},
|
||||
m.ref,
|
||||
).catch((e) => {
|
||||
recordFailure(m.ref, sent, e)
|
||||
throw e
|
||||
})
|
||||
if (!res.body) throw new ProviderError(`${m.ref}: empty response`)
|
||||
const stream = record(res.body, m.ref, sent)
|
||||
|
||||
const splitter = m.connection.quirks?.think_tags === "off" ? undefined : new ThinkSplitter()
|
||||
const acc = new ToolCallAccumulator()
|
||||
const banner = new SwapBanner()
|
||||
const out = new Parts()
|
||||
const addText = (kind: "text" | "reasoning", text: string) => out.add(kind, text)
|
||||
let finish = "stop"
|
||||
let usage: StreamEvent | undefined
|
||||
|
||||
for await (const { data } of sseJson(stream)) {
|
||||
const chunk = data as any
|
||||
if (chunk?.error) {
|
||||
const err = chunk.error
|
||||
const msg = typeof err === "string" ? err : (err.message ?? JSON.stringify(err))
|
||||
// llama-swap's "group: model unloaded" (another request swapped the model out) carries a
|
||||
// string code; statusOf() still reads it as the server error it is.
|
||||
throw new ProviderError(`${m.ref}: ${String(msg).trim()}`, statusOf(err), undefined, !out.output)
|
||||
}
|
||||
if (chunk?.usage && typeof chunk.usage === "object") {
|
||||
const u = chunk.usage
|
||||
usage = {
|
||||
type: "usage",
|
||||
usage: {
|
||||
input: u.prompt_tokens ?? 0,
|
||||
output: u.completion_tokens ?? 0,
|
||||
reasoning: u.completion_tokens_details?.reasoning_tokens,
|
||||
cached: u.prompt_tokens_details?.cached_tokens ?? u.cache_read_input_tokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
// llama.cpp, while it reads the prompt: { total, cache, processed, time_ms }. Anything not
|
||||
// shaped like that is ignored, never an error.
|
||||
const pp = chunk?.prompt_progress
|
||||
if (pp && typeof pp === "object" && Number.isFinite(pp.total) && pp.total > 0 && !out.output)
|
||||
yield { type: "progress", total: Number(pp.total), cache: Number(pp.cache) || 0, processed: Number(pp.processed) || 0, ms: Number(pp.time_ms) || 0 }
|
||||
const choice = chunk?.choices?.[0]
|
||||
if (!choice) continue
|
||||
const delta = choice.delta ?? choice.message ?? {}
|
||||
let reasoning = delta.reasoning_content ?? delta.reasoning
|
||||
if (typeof reasoning === "string" && reasoning) {
|
||||
const b = banner.feed(reasoning)
|
||||
if (b.notice) yield { type: "notice", message: b.notice }
|
||||
reasoning = b.text
|
||||
}
|
||||
if (typeof reasoning === "string" && reasoning) {
|
||||
addText("reasoning", reasoning)
|
||||
yield { type: "reasoning", text: reasoning }
|
||||
}
|
||||
const content = textOf(delta.content)
|
||||
if (content) {
|
||||
for (const piece of splitter ? splitter.feed(content) : [{ kind: "text" as const, text: content }]) {
|
||||
addText(piece.kind, piece.text)
|
||||
yield { type: piece.kind, text: piece.text }
|
||||
}
|
||||
}
|
||||
for (const d of acc.feed(delta.tool_calls)) {
|
||||
out.output = true
|
||||
yield { type: "tool_call_delta", ...d }
|
||||
}
|
||||
if (choice.finish_reason) finish = String(choice.finish_reason)
|
||||
}
|
||||
for (const piece of splitter?.flush() ?? []) {
|
||||
addText(piece.kind, piece.text)
|
||||
yield { type: piece.kind, text: piece.text }
|
||||
}
|
||||
|
||||
const calls = acc.result()
|
||||
for (const c of calls) out.push(c)
|
||||
// finish_reason is a hint only: some servers say "stop" after tool calls.
|
||||
// …except "length": a call cut off at the output limit has broken arguments, and the engine says so.
|
||||
if (calls.length && finish !== "length") finish = "tool_calls"
|
||||
const message = out.message()
|
||||
const parts = message.parts
|
||||
if (!usage) {
|
||||
const out = parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.name + p.args : p.text), 0)
|
||||
usage = { type: "usage", usage: { input: estimateTokens(JSON.stringify(body.messages)), output: out, estimated: true } }
|
||||
}
|
||||
yield usage
|
||||
yield { type: "finish", reason: finish, message }
|
||||
}
|
||||
|
||||
async listModels(): Promise<DiscoveredModel[]> {
|
||||
const res = await request(joinUrl(this.m.connection.base_url, "models"), { headers: this.headers(), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
|
||||
const j = (await res.json()) as any
|
||||
const list: any[] = Array.isArray(j) ? j : Array.isArray(j?.data) ? j.data : Array.isArray(j?.models) ? j.models : []
|
||||
return list
|
||||
.map((x) => ({ id: String(x?.id ?? x?.name ?? ""), context: contextFrom(x) }))
|
||||
.filter((x) => x.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Context window from a /models entry — every server names it differently. */
|
||||
export function contextFrom(x: any): number | undefined {
|
||||
for (const v of [x?.context_length, x?.max_model_len, x?.context_window, x?.max_context_length, x?.meta?.n_ctx, x?.meta?.n_ctx_train]) {
|
||||
const n = Number(v)
|
||||
if (Number.isFinite(n) && n > 0) return n
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// How a model is named: by its provider, not by the login it came through. A LLeMbas
|
||||
// instance that speaks protocol 2 serves every model as `<provider>/<model>` — `deepseek/deepseek-flash`,
|
||||
// `llama/bonsai` — and that id is the model's ref here too: each provider is a connection of its
|
||||
// own as far as the person can see, backed by the instance (requests still go to its /v1, with the
|
||||
// served id). The older form of the ref was `<login connection>/<id>` (`example/deepseek-flash`).
|
||||
//
|
||||
// - An instance that does not speak protocol 2 serves bare ids: those stay `<login>/<id>`.
|
||||
// - A connection of the user's own with the provider's name (`deepseek:` in connections.yaml)
|
||||
// keeps `deepseek/x`; the instance's is then `<login>/deepseek/x`.
|
||||
// - Two instances serving the same provider: the one logged in to first keeps the short form, the
|
||||
// other's are `<login>/<provider>/<model>`.
|
||||
// - The old forms still resolve — `example/deepseek-flash` and `example/deepseek/deepseek-flash`
|
||||
// are `deepseek/deepseek-flash` — and a config.yaml that names one is rewritten to the new form.
|
||||
import type { Connection } from "../config/schema.ts"
|
||||
|
||||
export interface ModelRef {
|
||||
/** What the person sees and writes. */
|
||||
ref: string
|
||||
/** The connection it is spoken to through, and the id sent there. */
|
||||
connection: string
|
||||
id: string
|
||||
/** An instance's model: its provider, when known — what the old `<login>/<model>` is matched by. */
|
||||
provider?: string
|
||||
}
|
||||
|
||||
type Conns = Record<string, Connection & { webui?: { providers?: Record<string, string>; v2?: boolean } }>
|
||||
|
||||
/** The provider of a served id: as the instance said it (`provider`), else — from an
|
||||
* instance that speaks protocol 2, whose ids are always `<provider>/<model>` — the part before the
|
||||
* first `/`. An id with a `/` from an older instance is just an id. */
|
||||
export function providerOf(c: Conns[string], id: string): string | undefined {
|
||||
if (!c.webui) return undefined
|
||||
const said = c.webui.providers?.[id]
|
||||
if (said) return said
|
||||
return c.webui.v2 && id.indexOf("/") > 0 ? id.slice(0, id.indexOf("/")) : undefined
|
||||
}
|
||||
|
||||
/** Every model, in config order, with its ref. `logins`: the webui connections in the order they
|
||||
* were logged in to (first keeps a shared provider's short form). */
|
||||
export function modelRefs(connections: Conns, logins: string[] = []): ModelRef[] {
|
||||
const names = new Set(Object.keys(connections))
|
||||
const webui = Object.keys(connections).filter((n) => connections[n]!.webui)
|
||||
const order = [...logins.filter((n) => webui.includes(n)), ...webui.filter((n) => !logins.includes(n))]
|
||||
// Which webui connection a provider's short form belongs to.
|
||||
const owner = new Map<string, string>()
|
||||
for (const w of order)
|
||||
for (const id of Object.keys(connections[w]!.models)) {
|
||||
const provider = providerOf(connections[w]!, id)
|
||||
if (!provider) continue
|
||||
// A connection of that name wins it — unless it is this login itself (`llama/bonsai` on a
|
||||
// login named llama is the same string either way).
|
||||
if ((names.has(provider) && provider !== w) || owner.has(provider)) continue
|
||||
owner.set(provider, w)
|
||||
}
|
||||
const out: ModelRef[] = []
|
||||
for (const [name, c] of Object.entries(connections))
|
||||
for (const id of Object.keys(c.models)) {
|
||||
const provider = providerOf(c, id)
|
||||
const short = provider && owner.get(provider) === name
|
||||
out.push({ ref: short ? id : `${name}/${id}`, connection: name, id, ...(provider ? { provider } : {}) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** A ref as written — any of the forms above — as the model it names, or undefined. `old`: it was
|
||||
* the form from before providers (`<login>/<model>` for a served `<provider>/<model>`). */
|
||||
export function findRef(connections: Conns, refs: ModelRef[], ref: string): (ModelRef & { old?: boolean }) | undefined {
|
||||
const exact = refs.find((r) => r.ref === ref)
|
||||
if (exact) return exact
|
||||
const slash = ref.indexOf("/")
|
||||
if (slash <= 0) return undefined
|
||||
const conn = ref.slice(0, slash)
|
||||
const id = ref.slice(slash + 1)
|
||||
const c = connections[conn]
|
||||
if (!c) return undefined
|
||||
const direct = refs.find((r) => r.connection === conn && r.id === id)
|
||||
if (direct) return direct
|
||||
// `<login>/<model>` from before providers: the one served model of that name, whatever its provider.
|
||||
if (c.webui && !id.includes("/")) {
|
||||
const by = refs.filter((r) => r.connection === conn && r.provider && r.id === `${r.provider}/${id}`)
|
||||
if (by.length === 1) return { ...by[0]!, old: true }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// OpenAI Responses (`POST {base}/responses`) — api.openai.com, and vLLM. Stateless (store: false):
|
||||
// the whole history is sent as input items, and reasoning items come back opaque (with their
|
||||
// encrypted content when the server provides it) so a reasoning model keeps its thread.
|
||||
import { resolveKey } from "../config/load.ts"
|
||||
import { historyArgs, Parts, record, recordFailure, retrying, statusOf, SwapBanner } from "./common.ts"
|
||||
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
|
||||
import { contextFrom } from "./openai-chat.ts"
|
||||
import { sseJson } from "./sse.ts"
|
||||
import { estimateTokens } from "./tokens.ts"
|
||||
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent } from "./types.ts"
|
||||
|
||||
type Item = Record<string, unknown>
|
||||
|
||||
export function toResponsesInput(messages: Message[], vision: boolean): Item[] {
|
||||
const out: Item[] = []
|
||||
for (const m of messages) {
|
||||
if (m.role === "user") {
|
||||
out.push({
|
||||
role: "user",
|
||||
content: m.parts.map((p) =>
|
||||
p.type === "text"
|
||||
? { type: "input_text", text: p.text }
|
||||
: vision
|
||||
? { type: "input_image", image_url: `data:${p.mime};base64,${p.data}` }
|
||||
: { type: "input_text", text: "[image omitted: this model has no vision]" },
|
||||
),
|
||||
})
|
||||
} else if (m.role === "assistant") {
|
||||
let text: string[] = []
|
||||
const flush = () => {
|
||||
if (text.join("").trim()) out.push({ role: "assistant", content: [{ type: "output_text", text: text.join("") }] })
|
||||
text = []
|
||||
}
|
||||
for (const p of m.parts) {
|
||||
if (p.type === "text") text.push(p.text)
|
||||
else if (p.type === "reasoning") {
|
||||
// Only a reasoning item this dialect produced can go back; text from elsewhere cannot.
|
||||
const item = (p.opaque as { responses?: Item } | undefined)?.responses
|
||||
if (item) {
|
||||
flush()
|
||||
out.push(item)
|
||||
}
|
||||
} else {
|
||||
flush()
|
||||
out.push({ type: "function_call", call_id: p.id, name: p.name, arguments: historyArgs(p.args) })
|
||||
}
|
||||
}
|
||||
flush()
|
||||
} else out.push({ type: "function_call_output", call_id: m.callId, output: m.content })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export class ResponsesClient implements Client {
|
||||
constructor(private m: ResolvedModel) {}
|
||||
|
||||
private headers() {
|
||||
return authHeaders(this.m.connection, resolveKey(this.m.connectionName, this.m.connection), this.m.spec.headers)
|
||||
}
|
||||
|
||||
buildBody(req: ChatRequest): Record<string, unknown> {
|
||||
const { m } = this
|
||||
const body: Record<string, unknown> = {
|
||||
model: m.id,
|
||||
input: toResponsesInput(req.messages, m.spec.vision === true),
|
||||
stream: true,
|
||||
store: false,
|
||||
...m.connection.body,
|
||||
...m.spec.body,
|
||||
}
|
||||
if (req.system) body.instructions = req.system
|
||||
if (req.tools.length && m.spec.tools !== false)
|
||||
body.tools = req.tools.map((t) => ({ type: "function", name: t.name, description: t.description, parameters: t.parameters, strict: false }))
|
||||
if (m.spec.max_output) body.max_output_tokens = m.spec.max_output
|
||||
if (req.effort) {
|
||||
body.reasoning = { effort: req.effort, summary: "auto" }
|
||||
body.include = ["reasoning.encrypted_content"]
|
||||
} else {
|
||||
if (m.spec.temperature !== undefined) body.temperature = m.spec.temperature
|
||||
if (m.spec.top_p !== undefined) body.top_p = m.spec.top_p
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {
|
||||
let body = this.buildBody(req)
|
||||
yield* retrying(
|
||||
() => this.attempt(req, body),
|
||||
(e) => {
|
||||
// Some servers do not know `include`; drop it once rather than fail.
|
||||
if (body.include && (e.status === 400 || e.status === 422) && /include/i.test(e.message)) {
|
||||
const { include: _, ...rest } = body
|
||||
body = rest
|
||||
return { retry: true }
|
||||
}
|
||||
return { retry: false }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private async *attempt(req: ChatRequest, body: Record<string, unknown>): AsyncGenerator<StreamEvent> {
|
||||
const { m } = this
|
||||
const res = await request(
|
||||
joinUrl(m.connection.base_url, "responses"),
|
||||
{ method: "POST", headers: this.headers(), body: JSON.stringify(body), signal: req.signal, timeoutMs: (m.connection.timeout ?? 600) * 1000, tls: tlsFor(m.connection) },
|
||||
m.ref,
|
||||
).catch((e) => {
|
||||
recordFailure(m.ref, body, e)
|
||||
throw e
|
||||
})
|
||||
if (!res.body) throw new ProviderError(`${m.ref}: empty response`)
|
||||
|
||||
const out = new Parts()
|
||||
const banner = new SwapBanner()
|
||||
const reasoning = new Map<string, string>()
|
||||
const callIndex = new Map<string, number>()
|
||||
let finish = "stop"
|
||||
let usage: StreamEvent | undefined
|
||||
|
||||
for await (const { data } of sseJson(record(res.body, m.ref, body))) {
|
||||
const ev = data as any
|
||||
const t: string = ev?.type ?? ""
|
||||
if (t === "response.output_text.delta") {
|
||||
out.add("text", ev.delta ?? "")
|
||||
yield { type: "text", text: ev.delta ?? "" }
|
||||
} else if (t === "response.reasoning_summary_text.delta" || t === "response.reasoning_text.delta") {
|
||||
const b = banner.feed(ev.delta ?? "")
|
||||
if (b.notice) yield { type: "notice", message: b.notice }
|
||||
if (!b.text) continue
|
||||
reasoning.set(ev.item_id ?? "", (reasoning.get(ev.item_id ?? "") ?? "") + b.text)
|
||||
if (b.text.trim()) out.output = true
|
||||
yield { type: "reasoning", text: b.text }
|
||||
} else if (t === "response.output_item.added" && ev.item?.type === "function_call") {
|
||||
const index = callIndex.size
|
||||
callIndex.set(ev.item.id ?? ev.item.call_id, index)
|
||||
yield { type: "tool_call_delta", index, name: ev.item.name, argsDelta: "" }
|
||||
} else if (t === "response.function_call_arguments.delta") {
|
||||
out.output = true
|
||||
yield { type: "tool_call_delta", index: callIndex.get(ev.item_id) ?? 0, argsDelta: ev.delta ?? "" }
|
||||
} else if (t === "response.output_item.done") {
|
||||
const item = ev.item ?? {}
|
||||
if (item.type === "function_call") out.push({ type: "tool_call", id: item.call_id ?? item.id, name: item.name, args: item.arguments || "{}" })
|
||||
else if (item.type === "reasoning") {
|
||||
const summary = Array.isArray(item.summary) ? item.summary.map((s: any) => s.text ?? "").join("\n") : ""
|
||||
const text = reasoning.get(item.id ?? "") || summary
|
||||
// vLLM puts the raw reasoning in `content`, which it will not accept back; keep what can return.
|
||||
const { content: _c, ...back } = item
|
||||
if (text || item.encrypted_content) out.push({ type: "reasoning", text, opaque: item.encrypted_content ? { responses: back } : undefined })
|
||||
}
|
||||
} else if (t === "response.completed" || t === "response.incomplete") {
|
||||
const r = ev.response ?? {}
|
||||
if (t === "response.incomplete" || r.status === "incomplete") finish = "length"
|
||||
const u = r.usage
|
||||
if (u) usage = { type: "usage", usage: { input: u.input_tokens ?? 0, output: u.output_tokens ?? 0, reasoning: u.output_tokens_details?.reasoning_tokens, cached: u.input_tokens_details?.cached_tokens || undefined } }
|
||||
} else if (t === "response.failed" || t === "error" || ev?.error) {
|
||||
const err = ev.response?.error ?? ev.error ?? ev
|
||||
throw new ProviderError(`${m.ref}: ${err.message ?? JSON.stringify(err)}`, statusOf(err), undefined, !out.output)
|
||||
}
|
||||
}
|
||||
const message = out.message()
|
||||
if (finish !== "length" && message.parts.some((p) => p.type === "tool_call")) finish = "tool_calls"
|
||||
yield usage ?? {
|
||||
type: "usage",
|
||||
usage: { input: estimateTokens(JSON.stringify(body.input)), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true },
|
||||
}
|
||||
yield { type: "finish", reason: finish, message }
|
||||
}
|
||||
|
||||
async listModels(): Promise<DiscoveredModel[]> {
|
||||
const res = await request(joinUrl(this.m.connection.base_url, "models"), { headers: this.headers(), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
|
||||
const j = (await res.json()) as any
|
||||
return (Array.isArray(j?.data) ? j.data : []).map((x: any) => ({ id: String(x.id), context: contextFrom(x) })).filter((x: DiscoveredModel) => x.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Server-sent events → parsed JSON objects. Keep-alive comments and non-data lines are skipped;
|
||||
// a malformed frame is skipped rather than killing the reply (LLeMbas openai_client._stream_once).
|
||||
// Errors are surfaced in every shape seen in the wild — see the comments in `line()`.
|
||||
export async function* sseJson(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event?: string; data: unknown }> {
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ""
|
||||
let event: string | undefined
|
||||
let data: string[] = []
|
||||
const out: { event?: string; data: unknown }[] = []
|
||||
|
||||
const dispatch = () => {
|
||||
if (data.length === 0) return
|
||||
const raw = data.join("\n")
|
||||
data = []
|
||||
const ev = event
|
||||
event = undefined
|
||||
if (raw === "[DONE]") return
|
||||
try {
|
||||
out.push({ event: ev, data: JSON.parse(raw) as unknown })
|
||||
} catch {
|
||||
// unparseable frame: skip
|
||||
}
|
||||
}
|
||||
|
||||
const line = (l: string) => {
|
||||
if (l.endsWith("\r")) l = l.slice(0, -1)
|
||||
if (l === "") return dispatch()
|
||||
if (l.startsWith(":")) return
|
||||
// llama.cpp (seen behind llama-swap) writes a mid-stream failure as a bare JSON line with no
|
||||
// field name: `{"error":{"code":500,"message":"…"}}` — and as the stream's LAST line, with no
|
||||
// newline after it. By the SSE rules it is a field called `{"error"` and would be ignored,
|
||||
// silently ending the reply.
|
||||
if (l.startsWith("{")) {
|
||||
try {
|
||||
const bare = JSON.parse(l) as unknown
|
||||
if (bare && typeof bare === "object" && "error" in bare) {
|
||||
dispatch()
|
||||
out.push({ event: "error", data: bare })
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const colon = l.indexOf(":")
|
||||
const field = colon === -1 ? l : l.slice(0, colon)
|
||||
let value = colon === -1 ? "" : l.slice(colon + 1)
|
||||
if (value.startsWith(" ")) value = value.slice(1)
|
||||
if (field === "data") {
|
||||
// Some servers omit the blank line between events; a complete JSON frame on its own
|
||||
// `data:` line is dispatched before the next one starts.
|
||||
if (data.length > 0 && isJson(data.join("\n"))) dispatch()
|
||||
data.push(value)
|
||||
} else if (field === "event") event = value
|
||||
else if (field === "error") {
|
||||
// Some servers name the field `error:`. Same treatment as the bare JSON line.
|
||||
let parsed: unknown = value
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
} catch {}
|
||||
dispatch()
|
||||
out.push({ event: "error", data: parsed && typeof parsed === "object" && "error" in parsed ? parsed : { error: parsed } })
|
||||
}
|
||||
}
|
||||
|
||||
for await (const chunk of body) {
|
||||
buf += decoder.decode(chunk, { stream: true })
|
||||
let nl: number
|
||||
while ((nl = buf.indexOf("\n")) !== -1) {
|
||||
line(buf.slice(0, nl))
|
||||
buf = buf.slice(nl + 1)
|
||||
}
|
||||
yield* out.splice(0)
|
||||
}
|
||||
buf += decoder.decode()
|
||||
// The last line may have no newline after it — and that is exactly where an error lands.
|
||||
if (buf) line(buf)
|
||||
dispatch()
|
||||
yield* out.splice(0)
|
||||
}
|
||||
|
||||
function isJson(s: string): boolean {
|
||||
try {
|
||||
JSON.parse(s)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Ported from LLeMbas services/reasoning.py (© Jaroslav Beneš, MIT).
|
||||
// Splits inline <think>…</think> (and <thinking>, <reasoning>) out of streamed content.
|
||||
// Tags arrive split across chunks, so this scans as a stream and holds back only
|
||||
// as much as a partial tag could occupy.
|
||||
|
||||
const TAGS: [string, string][] = [
|
||||
["<thinking>", "</thinking>"],
|
||||
["<think>", "</think>"],
|
||||
["<reasoning>", "</reasoning>"],
|
||||
]
|
||||
|
||||
export type Piece = { kind: "reasoning" | "text"; text: string }
|
||||
|
||||
export class ThinkSplitter {
|
||||
private buf = ""
|
||||
private closing = ""
|
||||
|
||||
feed(chunk: string): Piece[] {
|
||||
this.buf += chunk
|
||||
return this.drain(false)
|
||||
}
|
||||
|
||||
flush(): Piece[] {
|
||||
return this.drain(true)
|
||||
}
|
||||
|
||||
private drain(final: boolean): Piece[] {
|
||||
const out: Piece[] = []
|
||||
while (this.buf) {
|
||||
if (this.closing) {
|
||||
const at = this.buf.indexOf(this.closing)
|
||||
if (at === -1) {
|
||||
const keep = final ? 0 : partialSuffix(this.buf, [this.closing])
|
||||
push(out, "reasoning", this.buf.slice(0, this.buf.length - keep))
|
||||
this.buf = this.buf.slice(this.buf.length - keep)
|
||||
return out
|
||||
}
|
||||
push(out, "reasoning", this.buf.slice(0, at))
|
||||
this.buf = this.buf.slice(at + this.closing.length)
|
||||
this.closing = ""
|
||||
continue
|
||||
}
|
||||
let best = -1
|
||||
let open = ""
|
||||
let close = ""
|
||||
for (const [o, c] of TAGS) {
|
||||
const i = this.buf.indexOf(o)
|
||||
if (i !== -1 && (best === -1 || i < best)) [best, open, close] = [i, o, c]
|
||||
}
|
||||
if (best === -1) {
|
||||
// Unlike the original, hold back only a tail that could really start a tag,
|
||||
// so ordinary text is never delayed.
|
||||
const keep = final ? 0 : partialSuffix(this.buf, TAGS.map((t) => t[0]))
|
||||
push(out, "text", this.buf.slice(0, this.buf.length - keep))
|
||||
this.buf = this.buf.slice(this.buf.length - keep)
|
||||
return out
|
||||
}
|
||||
push(out, "text", this.buf.slice(0, best))
|
||||
this.buf = this.buf.slice(best + open.length)
|
||||
this.closing = close
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
function push(out: Piece[], kind: Piece["kind"], text: string) {
|
||||
if (!text) return
|
||||
const last = out[out.length - 1]
|
||||
if (last && last.kind === kind) last.text += text
|
||||
else out.push({ kind, text })
|
||||
}
|
||||
|
||||
/** Length of the longest suffix of `s` that is a proper prefix of one of `tags`. */
|
||||
function partialSuffix(s: string, tags: string[]): number {
|
||||
let best = 0
|
||||
for (const tag of tags) {
|
||||
for (let n = Math.min(tag.length - 1, s.length); n > best; n--) {
|
||||
if (s.endsWith(tag.slice(0, n))) {
|
||||
best = n
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Rough token estimate for endpoints that report no usage (LLeMbas services/tokens.py).
|
||||
export const CHARS_PER_TOKEN = 4
|
||||
|
||||
export function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Connection, Effort, ModelSpec } from "../config/schema.ts"
|
||||
|
||||
// Provider-neutral conversation model. Every dialect converts to and from this.
|
||||
|
||||
export type TextPart = { type: "text"; text: string }
|
||||
export type ImagePart = { type: "image"; mime: string; data: string /* base64 */ }
|
||||
/** `signature` / `opaque` carry what a provider needs echoed back on the next turn
|
||||
* (Anthropic thinking signatures, Gemini thought signatures, Responses encrypted reasoning). */
|
||||
export type ReasoningPart = { type: "reasoning"; text: string; signature?: string; opaque?: unknown }
|
||||
/** `signature`: Gemini's thought signature, which must travel back with the call. */
|
||||
export type ToolCallPart = { type: "tool_call"; id: string; name: string; args: string /* raw JSON */; signature?: string }
|
||||
|
||||
export type Message =
|
||||
/** `turnId`: the turn a prompt started, kept with it in the store for
|
||||
* `_lembas/session/history`; never sent to a model. */
|
||||
| { role: "user"; parts: (TextPart | ImagePart)[]; turnId?: string }
|
||||
| { role: "assistant"; parts: (TextPart | ReasoningPart | ToolCallPart)[] }
|
||||
| { role: "tool"; callId: string; name: string; content: string; isError?: boolean }
|
||||
|
||||
export interface ToolSpec {
|
||||
name: string
|
||||
description: string
|
||||
/** JSON Schema for the arguments object. */
|
||||
parameters: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface Usage {
|
||||
input: number
|
||||
output: number
|
||||
reasoning?: number
|
||||
cached?: number
|
||||
/** True when the endpoint reported nothing and these are estimates. */
|
||||
estimated?: boolean
|
||||
}
|
||||
|
||||
export type StreamEvent =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "reasoning"; text: string }
|
||||
/** Partial tool-call progress, for the UI. */
|
||||
| { type: "tool_call_delta"; index: number; name?: string; argsDelta: string }
|
||||
| { type: "usage"; usage: Usage }
|
||||
/** How far the server is through reading the prompt, before the reply starts (llama.cpp's
|
||||
* return_progress): tokens in all, taken from its cache, and read so far; ms spent reading. */
|
||||
| { type: "progress"; total: number; cache: number; processed: number; ms: number }
|
||||
| { type: "finish"; reason: string; message: Extract<Message, { role: "assistant" }> }
|
||||
/** Something worth telling the user that is not an error, e.g. a retry. */
|
||||
| { type: "notice"; message: string }
|
||||
|
||||
export interface ChatRequest {
|
||||
system: string
|
||||
messages: Message[]
|
||||
tools: ToolSpec[]
|
||||
/** null = off */
|
||||
effort?: Effort | null
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface ResolvedModel {
|
||||
/** `connection/model` */
|
||||
ref: string
|
||||
connectionName: string
|
||||
connection: Connection
|
||||
id: string
|
||||
spec: ModelSpec
|
||||
}
|
||||
|
||||
export interface DiscoveredModel {
|
||||
id: string
|
||||
context?: number
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
stream(req: ChatRequest): AsyncGenerator<StreamEvent>
|
||||
listModels(): Promise<DiscoveredModel[]>
|
||||
}
|
||||
|
||||
export class ProviderError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
readonly body?: string,
|
||||
/** Nothing of the model's reply had been passed on yet, so a retry cannot duplicate output. */
|
||||
readonly beforeOutput = status !== undefined,
|
||||
/** The server could not be reached at all (refused, no route, no answer): another may be. */
|
||||
readonly unreachable = false,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user