diff --git a/docker-compose.example.yml b/docker-compose.example.yml index b8db48c..f1deda1 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -12,6 +12,7 @@ services: OPENAI_API_KEY: ${OPENAI_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} XAI_API_KEY: ${XAI_API_KEY:-} + GEMINI_API_KEY: ${GEMINI_API_KEY:-} HERMES_AGENT_API_BASE_URL: ${HERMES_AGENT_API_BASE_URL:-http://127.0.0.1:8642/v1} HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-} HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-} diff --git a/docs/api/rest.md b/docs/api/rest.md index 7552a66..e67f69a 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -34,11 +34,13 @@ Chat upload limits: "openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null }, "anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null }, "xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null }, + "gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null }, "hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null } } } ``` - OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation. +- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids. - `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id. - The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message. @@ -56,7 +58,7 @@ Chat upload limits: ``` Behavior notes: -- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, and `xai` chat completions. +- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, `xai`, and `gemini` chat completions. - Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration. ## Active Runs @@ -128,7 +130,7 @@ Behavior notes: ```json { "title": "optional title", - "provider": "optional openai|anthropic|xai|hermes-agent", + "provider": "optional openai|anthropic|xai|gemini|hermes-agent", "model": "optional model id", "additionalSystemPrompt": "optional stored system prompt", "enabledTools": ["web_search", "fetch_url"], @@ -234,7 +236,7 @@ Notes: ```json { "chatId": "optional-chat-id", - "provider": "openai|anthropic|xai|hermes-agent", + "provider": "openai|anthropic|xai|gemini|hermes-agent", "model": "string", "messages": [ { @@ -294,12 +296,14 @@ Behavior notes: - For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction. - For `anthropic`, backend calls Anthropic's Messages API and enables internal tool use with Anthropic `tool_use`/`tool_result` content blocks. - For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction. +- For `gemini`, backend calls Google's native Gemini `generateContent` API and enables internal tool use with Gemini function calling. - For `hermes-agent`, backend calls the configured Hermes Agent OpenAI-compatible Chat Completions API without adding Sybil-managed tool definitions; Hermes Agent handles its own tools server-side. - For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items. +- For `gemini`, image attachments are sent as native Gemini `inlineData` parts and text attachments are sent as text parts. - For `xai` and `hermes-agent`, image attachments are sent as Chat Completions content parts alongside text. - For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds. - For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks. -- Available Sybil-managed tool calls for `openai`, `anthropic`, and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available. +- Available Sybil-managed tool calls for `openai`, `anthropic`, `xai`, and `gemini`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available. - `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. - `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side). - `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check ` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed. @@ -417,9 +421,9 @@ Behavior notes: "updatedAt": "...", "starred": false, "starredAt": null, - "initiatedProvider": "openai|anthropic|xai|hermes-agent|null", + "initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null", "initiatedModel": "string|null", - "lastUsedProvider": "openai|anthropic|xai|hermes-agent|null", + "lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null", "lastUsedModel": "string|null", "additionalSystemPrompt": null, "enabledTools": ["web_search", "fetch_url"] @@ -469,9 +473,9 @@ Behavior notes: "updatedAt": "...", "starred": false, "starredAt": null, - "initiatedProvider": "openai|anthropic|xai|hermes-agent|null", + "initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null", "initiatedModel": "string|null", - "lastUsedProvider": "openai|anthropic|xai|hermes-agent|null", + "lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null", "lastUsedModel": "string|null", "additionalSystemPrompt": null, "enabledTools": ["web_search", "fetch_url"], diff --git a/docs/api/streaming-chat.md b/docs/api/streaming-chat.md index 84607a0..63825eb 100644 --- a/docs/api/streaming-chat.md +++ b/docs/api/streaming-chat.md @@ -21,7 +21,7 @@ Authentication: { "chatId": "optional-chat-id", "persist": true, - "provider": "openai|anthropic|xai|hermes-agent", + "provider": "openai|anthropic|xai|gemini|hermes-agent", "model": "string", "messages": [ { @@ -174,9 +174,11 @@ Terminal tool-call event: - `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text. - `anthropic`: backend uses Anthropic's Messages API and may execute the same internal tools with `tool_use`/`tool_result` content blocks before producing final text. - `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text. +- `gemini`: backend uses Google's native Gemini `streamGenerateContent` API and may execute the same internal tool calls before producing final text. - `fetch_url` sends browser-like navigation headers for outbound URL requests to reduce false 403s from sites that reject generic server clients. - `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract. - `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items. +- `gemini`: image attachments are sent as native Gemini `inlineData` parts; text attachments are inlined as text parts. - `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts. - `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds. - `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`, and emits normalized `tool_call` SSE events when Anthropic `tool_use` blocks are executed. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks. @@ -185,7 +187,7 @@ Terminal tool-call event: - `shell_exec` is available only when `CHAT_SHELL_TOOL_ENABLED=true`. It uses the same devbox SSH configuration, starts in `CHAT_CODEX_REMOTE_WORKDIR`, and runs non-interactive shell commands there with SSH stdin closed, not inside the Sybil server container. - `CHAT_MAX_TOOL_ROUNDS` controls how many model/tool result cycles may occur before the backend returns a tool-call limit message; default is 100. -Tool-enabled streaming notes (`openai`/`anthropic`/`xai`): +Tool-enabled streaming notes (`openai`/`anthropic`/`xai`/`gemini`): - Stream still emits standard `meta`, `delta`, `done|error` events. - Stream may emit `tool_call` events while tool calls are executed. - `delta` events carry assistant text and are emitted incrementally for normal text rounds. The backend may buffer model-native text briefly while determining whether a provider round contains tool calls. diff --git a/ios/AGENTS.md b/ios/AGENTS.md index 9ef1e75..1c8aa22 100644 --- a/ios/AGENTS.md +++ b/ios/AGENTS.md @@ -57,4 +57,5 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`. - OpenAI: `gpt-4.1-mini` - Anthropic: `claude-3-5-sonnet-latest` - xAI: `grok-3-mini` + - Gemini: `gemini-3.5-flash` - Hermes Agent: `hermes-agent` diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift index 49f2b91..f38a35c 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift @@ -4,6 +4,7 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable { case openai case anthropic case xai + case gemini case hermesAgent = "hermes-agent" public var displayName: String { @@ -11,6 +12,7 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable { case .openai: return "OpenAI" case .anthropic: return "Anthropic" case .xai: return "xAI" + case .gemini: return "Gemini" case .hermesAgent: return "Hermes Agent" } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift index 41c9945..55e5145 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSettingsStore.swift @@ -11,11 +11,13 @@ final class SybilSettingsStore { static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel" static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel" static let preferredXAIModel = "sybil.ios.preferredXAIModel" + static let preferredGeminiModel = "sybil.ios.preferredGeminiModel" static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel" static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider" static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel" static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel" static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel" + static let quickQuestionPreferredGeminiModel = "sybil.ios.quickQuestionPreferredGeminiModel" static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel" } @@ -44,6 +46,7 @@ final class SybilSettingsStore { .openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini", .anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest", .xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini", + .gemini: defaults.string(forKey: Keys.preferredGeminiModel) ?? "gemini-3.5-flash", .hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent" ] self.preferredModelByProvider = preferredModels @@ -54,6 +57,7 @@ final class SybilSettingsStore { .openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini", .anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest", .xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini", + .gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash", .hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent" ] } @@ -72,12 +76,14 @@ final class SybilSettingsStore { defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel) defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel) defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel) + defaults.set(preferredModelByProvider[.gemini], forKey: Keys.preferredGeminiModel) defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel) defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider) defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel) defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel) defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel) + defaults.set(quickQuestionPreferredModelByProvider[.gemini], forKey: Keys.quickQuestionPreferredGeminiModel) defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel) } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index 873db2f..c1df12e 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -160,6 +160,7 @@ final class SybilViewModel { .openai: ["gpt-4.1-mini"], .anthropic: ["claude-3-5-sonnet-latest"], .xai: ["grok-3-mini"], + .gemini: ["gemini-3.5-flash", "gemini-flash-latest"], .hermesAgent: ["hermes-agent"] ] diff --git a/server/README.md b/server/README.md index cbbed45..ba05b08 100644 --- a/server/README.md +++ b/server/README.md @@ -1,7 +1,7 @@ # Sybil Server Backend API for: -- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Hermes Agent) +- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Gemini / Hermes Agent) - Personal chat database (chats/messages + LLM call log) ## Stack @@ -43,6 +43,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev). - `OPENAI_API_KEY` - `ANTHROPIC_API_KEY` - `XAI_API_KEY` +- `GEMINI_API_KEY` - `HERMES_AGENT_API_BASE_URL` (`http://127.0.0.1:8642/v1` by default; include the `/v1` suffix) - `HERMES_AGENT_API_KEY` (enables the Hermes Agent provider; set to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth) - `HERMES_AGENT_MODEL` (optional fallback/override model id; defaults client-side to `hermes-agent`) @@ -50,7 +51,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev). - `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only) - `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`) - `CHAT_MAX_TOOL_ROUNDS` (`100` by default; maximum model/tool result cycles per chat completion) -- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for OpenAI/xAI) +- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for managed-tool providers) - `CHAT_CODEX_REMOTE_HOST` (required when Codex tool is enabled; SSH host/IP or `user@host`) - `CHAT_CODEX_REMOTE_USER` (optional SSH user when host does not include one) - `CHAT_CODEX_REMOTE_PORT` (`22` by default) @@ -58,7 +59,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev). - `CHAT_CODEX_SSH_KEY_PATH` (recommended: path to a read-only mounted private key) - `CHAT_CODEX_SSH_PRIVATE_KEY_B64` (optional fallback private key delivery) - `CHAT_CODEX_EXEC_TIMEOUT_MS` (`600000` by default) -- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for OpenAI/xAI on the same devbox) +- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for managed-tool providers on the same devbox) - `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default) ## API diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index beead72..3f08490 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -13,6 +13,7 @@ enum Provider { openai anthropic xai + gemini hermes_agent @map("hermes-agent") } diff --git a/server/src/env.ts b/server/src/env.ts index fa51ef9..f3a9267 100644 --- a/server/src/env.ts +++ b/server/src/env.ts @@ -66,6 +66,7 @@ const EnvSchema = z.object({ OPENAI_API_KEY: z.string().optional(), ANTHROPIC_API_KEY: z.string().optional(), XAI_API_KEY: z.string().optional(), + GEMINI_API_KEY: z.string().optional(), HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema, HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema, HERMES_AGENT_MODEL: OptionalTrimmedStringSchema, diff --git a/server/src/llm/protocols/gemini-api.ts b/server/src/llm/protocols/gemini-api.ts new file mode 100644 index 0000000..9fc6743 --- /dev/null +++ b/server/src/llm/protocols/gemini-api.ts @@ -0,0 +1,501 @@ +import { + buildChatToolSystemPrompt, + executeToolCallAndBuildEvent, + getEnabledChatTools, + getUnstreamedText, + looksLikeDanglingToolIntent, + MAX_DANGLING_TOOL_INTENT_RETRIES, + MAX_TOOL_ROUNDS, + prepareToolCallExecution, + type NormalizedToolCall, + type ToolAwareCompletionParams, + type ToolAwareCompletionResult, + type ToolAwareStreamingEvent, + type ToolAwareUsage, + type ToolExecutionEvent, +} from "../chat-tools.js"; +import { + buildImageSummaryText, + buildTextAttachmentPrompt, + buildTopLevelSystemPrompt, + getImageAttachments, + getTextAttachments, + parseImageDataUrl, +} from "../message-content.js"; +import type { ChatMessage } from "../types.js"; + +type GeminiClient = { + apiKey: string; + baseURL: string; +}; + +const INTERNAL_CORRECTION = + "Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool."; + +function normalizeModelResourceName(model: string) { + const trimmed = model.trim().replace(/^\/+/, ""); + return trimmed.startsWith("models/") || trimmed.startsWith("tunedModels/") ? trimmed : `models/${trimmed}`; +} + +function geminiUrl(client: GeminiClient, model: string, method: "generateContent" | "streamGenerateContent", extraParams: Record = {}) { + const url = new URL(`${client.baseURL.replace(/\/+$/, "")}/${normalizeModelResourceName(model)}:${method}`); + url.searchParams.set("key", client.apiKey); + for (const [key, value] of Object.entries(extraParams)) { + url.searchParams.set(key, value); + } + return url; +} + +function generationConfig(params: Pick) { + const config: Record = {}; + if (params.temperature !== undefined) config.temperature = params.temperature; + if (params.maxTokens !== undefined) config.maxOutputTokens = params.maxTokens; + return Object.keys(config).length ? config : undefined; +} + +function toGeminiJsonSchema(schema: unknown): Record | undefined { + if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined; + const input = schema as Record; + const output: Record = {}; + + if (typeof input.type === "string") output.type = input.type; + if (typeof input.description === "string") output.description = input.description; + if (typeof input.format === "string") output.format = input.format; + if (typeof input.nullable === "boolean") output.nullable = input.nullable; + if (Array.isArray(input.enum)) output.enum = input.enum.filter((value) => typeof value === "string"); + if (Array.isArray(input.required)) output.required = input.required.filter((value) => typeof value === "string"); + + const items = toGeminiJsonSchema(input.items); + if (items) output.items = items; + + if (input.properties && typeof input.properties === "object" && !Array.isArray(input.properties)) { + const properties: Record = {}; + for (const [key, value] of Object.entries(input.properties)) { + const propertySchema = toGeminiJsonSchema(value); + if (propertySchema) properties[key] = propertySchema; + } + if (Object.keys(properties).length) output.properties = properties; + } + + return Object.keys(output).length ? output : undefined; +} + +function toGeminiTools(tools: any[]) { + const functionDeclarations = tools + .map((tool) => { + if (tool?.type !== "function") return null; + const declaration: Record = { + name: tool.function.name, + description: tool.function.description, + }; + const parameters = toGeminiJsonSchema(tool.function.parameters); + if (parameters) declaration.parameters = parameters; + return declaration; + }) + .filter(Boolean); + + return functionDeclarations.length ? [{ functionDeclarations }] : undefined; +} + +function toContentParts(message: ChatMessage) { + const imageAttachments = getImageAttachments(message); + const textAttachments = getTextAttachments(message); + const parts: Array> = []; + + for (const attachment of imageAttachments) { + const source = parseImageDataUrl(attachment); + parts.push({ + inlineData: { + mimeType: source.mediaType, + data: source.data, + }, + }); + } + + const imageSummary = buildImageSummaryText(imageAttachments); + if (imageSummary) { + parts.push({ text: imageSummary }); + } + + for (const attachment of textAttachments) { + parts.push({ text: buildTextAttachmentPrompt(attachment) }); + } + + if (message.content.trim()) { + parts.push({ text: message.content }); + } + + return parts.length ? parts : [{ text: "" }]; +} + +function buildConversationContent(message: ChatMessage) { + if (message.role === "system") { + throw new Error("System messages must be handled separately for Gemini."); + } + + if (message.role === "tool") { + const name = message.name?.trim() || "tool"; + return { + role: "user", + parts: [{ text: `Tool output (${name}):\n${message.content}` }], + }; + } + + return { + role: message.role === "assistant" ? "model" : "user", + parts: toContentParts(message), + }; +} + +function buildBaseContents(messages: ChatMessage[]) { + return messages.filter((message) => message.role !== "system").map((message) => buildConversationContent(message)); +} + +function buildSystemInstruction(params: ToolAwareCompletionParams, toolSystemPrompt?: string) { + const text = buildTopLevelSystemPrompt(params.messages, params.userLocation, toolSystemPrompt); + return text ? { parts: [{ text }] } : undefined; +} + +function mergeUsage(acc: Required, usage: any) { + const normalized = normalizeUsage(usage); + if (!normalized) return false; + acc.inputTokens += normalized.inputTokens; + acc.outputTokens += normalized.outputTokens; + acc.totalTokens += normalized.totalTokens; + return true; +} + +function normalizeUsage(usage: any) { + if (!usage) return null; + const inputTokens = usage.promptTokenCount ?? 0; + const outputTokens = usage.candidatesTokenCount ?? 0; + const totalTokens = usage.totalTokenCount ?? inputTokens + outputTokens; + return { inputTokens, outputTokens, totalTokens }; +} + +function getCandidate(response: any) { + return Array.isArray(response?.candidates) ? response.candidates[0] : null; +} + +function getParts(response: any) { + const parts = getCandidate(response)?.content?.parts; + return Array.isArray(parts) ? parts : []; +} + +function extractText(response: any) { + return getParts(response) + .map((part: any) => (typeof part?.text === "string" ? part.text : "")) + .join(""); +} + +function stringifyToolArgs(args: unknown) { + try { + return JSON.stringify(args ?? {}); + } catch { + return "{}"; + } +} + +function normalizeToolCallsFromParts(parts: any[], round: number): NormalizedToolCall[] { + return parts + .filter((part) => part?.functionCall) + .map((part, index) => ({ + id: part.functionCall.id ?? `tool_call_${round}_${index}`, + name: part.functionCall.name ?? "unknown_tool", + arguments: stringifyToolArgs(part.functionCall.args), + })); +} + +function buildFunctionResponsePart(call: NormalizedToolCall, toolResult: unknown) { + return { + functionResponse: { + id: call.id, + name: call.name, + response: toolResult, + }, + }; +} + +function appendCorrection(conversation: any[], text: string) { + conversation.push({ role: "model", parts: [{ text }] }); + conversation.push({ role: "user", parts: [{ text: INTERNAL_CORRECTION }] }); +} + +async function parseGeminiResponse(response: Response) { + const bodyText = await response.text(); + let body: any = null; + try { + body = bodyText ? JSON.parse(bodyText) : null; + } catch { + body = { raw: bodyText }; + } + + if (!response.ok) { + throw new Error(body?.error?.message ?? `Gemini API request failed with status ${response.status}.`); + } + + return body; +} + +async function generateContent(params: ToolAwareCompletionParams, body: Record) { + const response = await fetch(geminiUrl(params.client, params.model, "generateContent"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return parseGeminiResponse(response); +} + +function getFailureMessage(response: any, text: string, toolCallCount: number) { + const promptBlockReason = response?.promptFeedback?.blockReason; + if (promptBlockReason) return `Gemini prompt blocked: ${promptBlockReason}.`; + + const candidate = getCandidate(response); + const finishReason = candidate?.finishReason; + if (!finishReason || finishReason === "STOP" || finishReason === "MAX_TOKENS") return null; + if (text || toolCallCount > 0) return null; + return candidate?.finishMessage ?? `Gemini response stopped: ${finishReason}.`; +} + +function buildRequest(params: ToolAwareCompletionParams, conversation: any[], enabledTools: any[] = []) { + const tools = toGeminiTools(enabledTools); + return { + contents: conversation, + systemInstruction: buildSystemInstruction(params, enabledTools.length ? buildChatToolSystemPrompt(params) : undefined), + generationConfig: generationConfig(params), + tools, + toolConfig: tools ? { functionCallingConfig: { mode: "AUTO" } } : undefined, + }; +} + +export async function completeWithGeminiApi(params: ToolAwareCompletionParams): Promise { + const enabledTools = getEnabledChatTools(params); + const conversation = buildBaseContents(params.messages); + const rawResponses: unknown[] = []; + const toolEvents: ToolExecutionEvent[] = []; + const usageAcc: Required = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; + let sawUsage = false; + let totalToolCalls = 0; + let danglingToolIntentRetries = 0; + + for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { + const response = await generateContent(params, buildRequest(params, conversation, enabledTools)); + rawResponses.push(response); + sawUsage = mergeUsage(usageAcc, response?.usageMetadata) || sawUsage; + + const parts = getParts(response); + const text = extractText(response); + const normalizedToolCalls = normalizeToolCallsFromParts(parts, round); + const failureMessage = getFailureMessage(response, text, normalizedToolCalls.length); + if (failureMessage) throw new Error(failureMessage); + + if (!normalizedToolCalls.length) { + if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) { + danglingToolIntentRetries += 1; + appendCorrection(conversation, text); + continue; + } + return { + text, + usage: sawUsage ? usageAcc : undefined, + raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "gemini.generateContent" }, + toolEvents, + }; + } + + totalToolCalls += normalizedToolCalls.length; + conversation.push({ role: "model", parts }); + + const toolResultParts: any[] = []; + for (const call of normalizedToolCalls) { + const { execution } = prepareToolCallExecution(call); + const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params); + toolEvents.push(event); + toolResultParts.push(buildFunctionResponsePart(call, toolResult)); + } + + conversation.push({ role: "user", parts: toolResultParts }); + } + + return { + text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.", + usage: sawUsage ? usageAcc : undefined, + raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "gemini.generateContent" }, + toolEvents, + }; +} + +function findSseBoundary(buffer: string) { + const crlf = buffer.indexOf("\r\n\r\n"); + const lf = buffer.indexOf("\n\n"); + if (crlf === -1) return lf === -1 ? null : { index: lf, length: 2 }; + if (lf === -1) return { index: crlf, length: 4 }; + return crlf < lf ? { index: crlf, length: 4 } : { index: lf, length: 2 }; +} + +function parseSseEvent(rawEvent: string) { + const data = rawEvent + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trimStart()) + .join("\n") + .trim(); + if (!data || data === "[DONE]") return null; + return JSON.parse(data); +} + +async function* streamGeminiResponses(params: ToolAwareCompletionParams, body: Record) { + const response = await fetch(geminiUrl(params.client, params.model, "streamGenerateContent", { alt: "sse" }), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + await parseGeminiResponse(response); + return; + } + + if (!response.body) { + throw new Error("Gemini stream response did not include a body."); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let boundary = findSseBoundary(buffer); + while (boundary) { + const rawEvent = buffer.slice(0, boundary.index); + buffer = buffer.slice(boundary.index + boundary.length); + const event = parseSseEvent(rawEvent); + if (event) yield event; + boundary = findSseBoundary(buffer); + } + } + + buffer += decoder.decode(); + const tail = buffer.trim(); + if (tail) { + const event = parseSseEvent(tail); + if (event) yield event; + } +} + +export async function* streamWithGeminiApi(params: ToolAwareCompletionParams): AsyncGenerator { + const enabledTools = getEnabledChatTools(params); + const conversation = buildBaseContents(params.messages); + const rawResponses: unknown[] = []; + const toolEvents: ToolExecutionEvent[] = []; + const usageAcc: Required = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; + let sawUsage = false; + let totalToolCalls = 0; + let danglingToolIntentRetries = 0; + + if (!enabledTools.length) { + let text = ""; + let latestUsage: any = null; + for await (const response of streamGeminiResponses(params, buildRequest(params, conversation))) { + rawResponses.push(response); + if (response?.usageMetadata) latestUsage = response.usageMetadata; + const failureMessage = getFailureMessage(response, extractText(response), 0); + if (failureMessage) throw new Error(failureMessage); + const delta = extractText(response); + if (delta) { + text += delta; + yield { type: "delta", text: delta }; + } + } + + sawUsage = mergeUsage(usageAcc, latestUsage) || sawUsage; + + yield { + type: "done", + result: { + text, + usage: sawUsage ? usageAcc : undefined, + raw: { streamed: true, responses: rawResponses, toolCallsUsed: 0, api: "gemini.streamGenerateContent" }, + toolEvents: [], + }, + }; + return; + } + + for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { + const roundParts: any[] = []; + let roundText = ""; + let latestRoundResponse: any = null; + let latestRoundUsage: any = null; + + for await (const response of streamGeminiResponses(params, buildRequest(params, conversation, enabledTools))) { + rawResponses.push(response); + latestRoundResponse = response; + if (response?.usageMetadata) latestRoundUsage = response.usageMetadata; + roundParts.push(...getParts(response)); + roundText += extractText(response); + } + + sawUsage = mergeUsage(usageAcc, latestRoundUsage) || sawUsage; + + const normalizedToolCalls = normalizeToolCallsFromParts(roundParts, round); + const failureMessage = getFailureMessage(latestRoundResponse ?? { candidates: [{ content: { parts: roundParts } }] }, roundText, normalizedToolCalls.length); + if (failureMessage) throw new Error(failureMessage); + + if (!normalizedToolCalls.length) { + if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) { + danglingToolIntentRetries += 1; + appendCorrection(conversation, roundText); + continue; + } + const unstreamedText = getUnstreamedText(roundText, ""); + if (unstreamedText) { + yield { type: "delta", text: unstreamedText }; + } + yield { + type: "done", + result: { + text: roundText, + usage: sawUsage ? usageAcc : undefined, + raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "gemini.streamGenerateContent" }, + toolEvents, + }, + }; + return; + } + + totalToolCalls += normalizedToolCalls.length; + conversation.push({ role: "model", parts: roundParts }); + + const toolResultParts: any[] = []; + for (const call of normalizedToolCalls) { + const { event: initiatedEvent, execution } = prepareToolCallExecution(call); + yield { type: "tool_call", event: initiatedEvent }; + const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params); + toolEvents.push(event); + yield { type: "tool_call", event }; + toolResultParts.push(buildFunctionResponsePart(call, toolResult)); + } + + conversation.push({ role: "user", parts: toolResultParts }); + } + + yield { + type: "done", + result: { + text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.", + usage: sawUsage ? usageAcc : undefined, + raw: { + streamed: true, + responses: rawResponses, + toolCallsUsed: totalToolCalls, + toolCallLimitReached: true, + api: "gemini.streamGenerateContent", + }, + toolEvents, + }, + }; +} diff --git a/server/src/llm/provider-adapters.ts b/server/src/llm/provider-adapters.ts index 053969d..d2d8057 100644 --- a/server/src/llm/provider-adapters.ts +++ b/server/src/llm/provider-adapters.ts @@ -5,10 +5,11 @@ import { type ToolAwareStreamingEvent, } from "./chat-tools.js"; import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "./protocols/chat-completions-api.js"; +import { completeWithGeminiApi, streamWithGeminiApi } from "./protocols/gemini-api.js"; import { completeWithMessagesApi, streamWithMessagesApi } from "./protocols/messages-api.js"; import { completeWithResponsesApi, streamWithResponsesApi } from "./protocols/responses-api.js"; import { env } from "../env.js"; -import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js"; +import { anthropicClient, geminiClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js"; import type { ChatMessage, Provider } from "./types.js"; type ProviderAdapterParams = { @@ -27,7 +28,7 @@ export type ProviderChatAdapter = { stream(params: ProviderAdapterParams): AsyncGenerator; }; -type ChatProtocolId = "chat-completions" | "messages" | "responses"; +type ChatProtocolId = "chat-completions" | "gemini" | "messages" | "responses"; type ChatProtocol = { id: ChatProtocolId; @@ -39,6 +40,7 @@ type ModelCatalogSpec = { enabled?: () => boolean; fetchModels(client: any): Promise; fallbackModels?: () => string[]; + sortModels?: (models: string[]) => string[]; }; type ProviderBackendSpec = { @@ -61,6 +63,12 @@ const messagesProtocol: ChatProtocol = { stream: streamWithMessagesApi, }; +const geminiProtocol: ChatProtocol = { + id: "gemini", + complete: completeWithGeminiApi, + stream: streamWithGeminiApi, +}; + const responsesProtocol: ChatProtocol = { id: "responses", complete: completeWithResponsesApi, @@ -77,6 +85,10 @@ function modelIdsFromListResponse(page: any) { : []; } +function stripModelResourcePrefix(model: string) { + return model.startsWith("models/") ? model.slice("models/".length) : model; +} + function isLikelyResponsesApiModel(model: string) { const id = model.toLowerCase(); if (id.includes("embedding") || id.includes("moderation")) return false; @@ -86,6 +98,37 @@ function isLikelyResponsesApiModel(model: string) { return /^(gpt-|o\d|chatgpt-)/.test(id); } +function isLikelyGeminiChatModel(model: string) { + const id = model.toLowerCase(); + if (!id.startsWith("gemini-")) return false; + if (id.includes("embedding") || id.includes("embed")) return false; + if (id.includes("image") || id.includes("imagen") || id.includes("veo")) return false; + if (id.includes("audio") || id.includes("tts") || id.includes("live")) return false; + if (id.includes("computer-use") || id.includes("robotics")) return false; + return true; +} + +function preferGeminiModels(models: string[]) { + const preferred = [ + "gemini-3.5-flash", + "gemini-flash-latest", + "gemini-3.1-flash-lite", + "gemini-3-flash-preview", + "gemini-pro-latest", + ]; + const modelSet = new Set(models); + return [...preferred.filter((model) => modelSet.delete(model)), ...[...modelSet].sort((a, b) => a.localeCompare(b))]; +} + +async function fetchJson(url: URL): Promise { + const response = await fetch(url); + const body: any = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(body?.error?.message ?? `Gemini model fetch failed with status ${response.status}.`); + } + return body; +} + function withClient(params: ProviderAdapterParams, client: any, enabledTools?: string[]): ToolAwareCompletionParams { return { client, @@ -160,6 +203,29 @@ const backendSpecs: Record = { }, }, }, + gemini: { + createClient: geminiClient, + plainProtocol: geminiProtocol, + toolProtocol: geminiProtocol, + managedTools: true, + modelCatalog: { + async fetchModels(client) { + const url = new URL(`${client.baseURL.replace(/\/+$/, "")}/models`); + url.searchParams.set("key", client.apiKey); + url.searchParams.set("pageSize", "1000"); + const page = await fetchJson(url); + return Array.isArray(page?.models) + ? page.models + .filter((model: any) => Array.isArray(model?.supportedGenerationMethods) && model.supportedGenerationMethods.includes("generateContent")) + .map((model: any) => model?.name) + .filter((id: unknown): id is string => typeof id === "string") + .map(stripModelResourcePrefix) + .filter(isLikelyGeminiChatModel) + : []; + }, + sortModels: preferGeminiModels, + }, + }, "hermes-agent": { createClient: hermesAgentClient, plainProtocol: chatCompletionsProtocol, @@ -209,7 +275,8 @@ export function listModelCatalogProviders(): Provider[] { export async function fetchProviderCatalogModels(provider: Provider) { const spec = backendSpecs[provider].modelCatalog; if (!spec) return []; - return uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient())); + const models = uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient())); + return spec.sortModels ? spec.sortModels(models) : models; } export function getProviderCatalogFallbackModels(provider: Provider) { diff --git a/server/src/llm/provider-ids.ts b/server/src/llm/provider-ids.ts index 08c7b30..8d141f8 100644 --- a/server/src/llm/provider-ids.ts +++ b/server/src/llm/provider-ids.ts @@ -6,6 +6,7 @@ const apiToPrismaProvider = { openai: "openai", anthropic: "anthropic", xai: "xai", + gemini: "gemini", "hermes-agent": "hermes_agent", } as const satisfies Record; @@ -13,6 +14,7 @@ const prismaToApiProvider = { openai: "openai", anthropic: "anthropic", xai: "xai", + gemini: "gemini", hermes_agent: "hermes-agent", "hermes-agent": "hermes-agent", } as const satisfies Record; diff --git a/server/src/llm/providers.ts b/server/src/llm/providers.ts index 5340519..f5e1be7 100644 --- a/server/src/llm/providers.ts +++ b/server/src/llm/providers.ts @@ -1,5 +1,5 @@ -import OpenAI from "openai"; import Anthropic from "@anthropic-ai/sdk"; +import OpenAI from "openai"; import { env } from "../env.js"; export function openaiClient() { @@ -13,6 +13,14 @@ export function xaiClient() { return new OpenAI({ apiKey: env.XAI_API_KEY, baseURL: "https://api.x.ai/v1" }); } +export function geminiClient() { + if (!env.GEMINI_API_KEY) throw new Error("GEMINI_API_KEY not set"); + return { + apiKey: env.GEMINI_API_KEY, + baseURL: "https://generativelanguage.googleapis.com/v1beta", + }; +} + export function isHermesAgentConfigured() { return Boolean(env.HERMES_AGENT_API_KEY); } diff --git a/server/src/llm/types.ts b/server/src/llm/types.ts index b4cf6d4..663ba48 100644 --- a/server/src/llm/types.ts +++ b/server/src/llm/types.ts @@ -1,4 +1,4 @@ -export const PROVIDERS = ["openai", "anthropic", "xai", "hermes-agent"] as const; +export const PROVIDERS = ["openai", "anthropic", "xai", "gemini", "hermes-agent"] as const; export type Provider = (typeof PROVIDERS)[number]; diff --git a/server/src/routes.ts b/server/src/routes.ts index 09fbd6b..16295e0 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -16,7 +16,7 @@ import { exaClient } from "./search/exa.js"; import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js"; import type { ChatAttachment } from "./llm/types.js"; -const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]); +const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]); const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000; const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value)); diff --git a/server/tests/provider-adapters.test.ts b/server/tests/provider-adapters.test.ts index 8e5ee99..e48a519 100644 --- a/server/tests/provider-adapters.test.ts +++ b/server/tests/provider-adapters.test.ts @@ -27,6 +27,12 @@ test("provider backend registry selects chat protocol and managed-tool mode", () managedTools: true, enabledTools: ["web_search"], }); + assert.deepEqual(describeProviderChatBackend("gemini", ["web_search"]), { + provider: "gemini", + protocol: "gemini", + managedTools: true, + enabledTools: ["web_search"], + }); assert.deepEqual(describeProviderChatBackend("hermes-agent", ["web_search"]), { provider: "hermes-agent", protocol: "chat-completions", diff --git a/server/tests/provider-ids.test.ts b/server/tests/provider-ids.test.ts index 0a20829..307587e 100644 --- a/server/tests/provider-ids.test.ts +++ b/server/tests/provider-ids.test.ts @@ -5,8 +5,10 @@ import { fromPrismaProvider, serializeProviderFields, toPrismaProvider } from ". test("Hermes Agent provider id maps between API and Prisma enum forms", () => { assert.equal(toPrismaProvider("hermes-agent"), "hermes_agent"); assert.equal(fromPrismaProvider("hermes_agent"), "hermes-agent"); - assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "xai" }), { + assert.equal(toPrismaProvider("gemini"), "gemini"); + assert.equal(fromPrismaProvider("gemini"), "gemini"); + assert.deepEqual(serializeProviderFields({ initiatedProvider: "hermes_agent", lastUsedProvider: "gemini" }), { initiatedProvider: "hermes-agent", - lastUsedProvider: "xai", + lastUsedProvider: "gemini", }); }); diff --git a/tui/src/config.ts b/tui/src/config.ts index e703cca..f21afda 100644 --- a/tui/src/config.ts +++ b/tui/src/config.ts @@ -1,6 +1,6 @@ import type { Provider } from "./types.js"; -const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "hermes-agent"]; +const PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini", "hermes-agent"]; function normalizeBaseUrl(value: string) { const trimmed = value.trim(); diff --git a/tui/src/index.ts b/tui/src/index.ts index 7b71f0a..0fc80e5 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -42,12 +42,13 @@ type ToolLogMetadata = { resultPreview?: string | null; }; -const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"]; +const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini"]; const PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"]; const PROVIDER_FALLBACK_MODELS: Record = { openai: ["gpt-4.1-mini"], anthropic: ["claude-3-5-sonnet-latest"], xai: ["grok-3-mini"], + gemini: ["gemini-3.5-flash", "gemini-flash-latest"], "hermes-agent": ["hermes-agent"], }; @@ -55,6 +56,7 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = { openai: { models: [], loadedAt: null, error: null }, anthropic: { models: [], loadedAt: null, error: null }, xai: { models: [], loadedAt: null, error: null }, + gemini: { models: [], loadedAt: null, error: null }, }; function escapeTags(value: string) { @@ -79,6 +81,7 @@ function getProviderLabel(provider: Provider | null | undefined) { if (provider === "openai") return "OpenAI"; if (provider === "anthropic") return "Anthropic"; if (provider === "xai") return "xAI"; + if (provider === "gemini") return "Gemini"; if (provider === "hermes-agent") return "Hermes Agent"; return ""; } @@ -266,6 +269,7 @@ async function main() { openai: null, anthropic: null, xai: null, + gemini: null, "hermes-agent": null, }; let model: string = config.defaultModel ?? pickProviderModel(getModelOptions(modelCatalog, provider), null); diff --git a/tui/src/types.ts b/tui/src/types.ts index 852301a..d6718d2 100644 --- a/tui/src/types.ts +++ b/tui/src/types.ts @@ -1,4 +1,4 @@ -export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent"; +export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent"; export type ProviderModelInfo = { models: string[]; diff --git a/web/src/App.tsx b/web/src/App.tsx index f08d5a5..b4f6af7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -123,6 +123,7 @@ const PROVIDER_FALLBACK_MODELS: Record = { openai: ["gpt-4.1-mini"], anthropic: ["claude-3-5-sonnet-latest"], xai: ["grok-3-mini"], + gemini: ["gemini-3.5-flash", "gemini-flash-latest"], "hermes-agent": ["hermes-agent"], }; @@ -130,9 +131,10 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = { openai: { models: [], loadedAt: null, error: null }, anthropic: { models: [], loadedAt: null, error: null }, xai: { models: [], loadedAt: null, error: null }, + gemini: { models: [], loadedAt: null, error: null }, }; -const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai"]; +const BASE_PROVIDERS: Provider[] = ["openai", "anthropic", "xai", "gemini"]; const ALL_PROVIDERS: Provider[] = [...BASE_PROVIDERS, "hermes-agent"]; const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider"; @@ -149,6 +151,7 @@ const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = { openai: null, anthropic: null, xai: null, + gemini: null, "hermes-agent": null, }; const EMPTY_ACTIVE_RUNS: ActiveRunsState = { @@ -345,6 +348,7 @@ function loadStoredModelPreferences() { openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null, anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null, xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null, + gemini: typeof parsed.gemini === "string" && parsed.gemini.trim() ? parsed.gemini.trim() : null, "hermes-agent": typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null, }; @@ -354,7 +358,9 @@ function loadStoredModelPreferences() { } function normalizeStoredProvider(value: unknown): Provider { - return value === "anthropic" || value === "xai" || value === "openai" || value === "hermes-agent" ? value : "openai"; + return value === "anthropic" || value === "xai" || value === "gemini" || value === "openai" || value === "hermes-agent" + ? value + : "openai"; } function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferences { @@ -364,6 +370,7 @@ function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferenc openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null, anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null, xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null, + gemini: typeof parsed.gemini === "string" && parsed.gemini.trim() ? parsed.gemini.trim() : null, "hermes-agent": typeof parsed["hermes-agent"] === "string" && parsed["hermes-agent"].trim() ? parsed["hermes-agent"].trim() : null, }; @@ -395,6 +402,7 @@ function getProviderLabel(provider: Provider | null | undefined) { if (provider === "openai") return "OpenAI"; if (provider === "anthropic") return "Anthropic"; if (provider === "xai") return "xAI"; + if (provider === "gemini") return "Gemini"; if (provider === "hermes-agent") return "Hermes Agent"; return ""; } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 53a495a..904bc92 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -149,7 +149,7 @@ export type CompletionRequestMessage = { attachments?: ChatAttachment[]; }; -export type Provider = "openai" | "anthropic" | "xai" | "hermes-agent"; +export type Provider = "openai" | "anthropic" | "xai" | "gemini" | "hermes-agent"; export type ProviderModelInfo = { models: string[];